Compare commits

...
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 001efd624a docs: correct the memory analysis to distinguish host and guest headroom
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:02:11 +09:00
DongHyeonkaandClaude Opus 5 d6f8b9f8b3 docs: restructure roadmap into A/B/operations layers and add five operational items
Adds backup rehearsal, version upgrade, observability, secret management and certificate renewal. Corrects the experiment order so the refresh-token contention test runs after the shared store exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:56:13 +09:00
DongHyeonkaandClaude Opus 5 df4d3b4345 docs: map published open questions to lab coverage and fix experiment order
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:44:12 +09:00
DongHyeonkaandClaude Opus 5 61ba5db259 docs: add lab operations guide with tooling, commands and drills
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:39:45 +09:00
DongHyeonkaandClaude Opus 5 bc784fcd6e docs: surface the spoofing bypass finding in the roadmap
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:24:52 +09:00
DongHyeonkaandClaude Opus 5 ddcb1c08e6 docs: record the session store lab roadmap and progress
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:20:38 +09:00
DongHyeonkaandClaude Opus 5 e1ba9c5626 docs: record proxy-bypass closure with before and after evidence
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:17:24 +09:00
DongHyeonkaandClaude Opus 5 3af52bb66a feat: narrow Traefik trusted range and restrict echo ingress to Traefik
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 16:15:28 +09:00
DongHyeonkaandClaude Opus 5 98874b0c6c docs: retake staged evidence screenshots with indented output
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:52:47 +09:00
DongHyeonkaandClaude Opus 5 b708c8d503 feat: indent echo responses for readable evidence
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:48:57 +09:00
DongHyeonkaandClaude Opus 5 7737787937 docs: record the staged fix and post-fix evidence
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:33:40 +09:00
DongHyeonkaandClaude Opus 5 1c1b86e849 feat: let the app interpret forwarded headers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:30:31 +09:00
DongHyeonkaandClaude Opus 5 2294c52095 feat: make Traefik trust forwarded headers from the host nginx
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:29:18 +09:00
DongHyeonkaandClaude Opus 5 69d4502757 docs: collect evidence for the two-hop header contract failure
Control experiment isolates two independent causes: the nginx 443 block still emits X-Forwarded-Proto http, and Traefik rewrites forwarded headers regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:04:38 +09:00
DongHyeonkaandClaude Opus 5 ae1f391598 docs: expand two-hop header contract with switch locations and per-pattern impact
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:57:08 +09:00
DongHyeonkaandClaude Opus 5 844d6f1d33 docs: measure and diagnose the two-hop proxy header contract
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:35:27 +09:00
DongHyeonkaandClaude Opus 5 a831792c5c docs: map deploy/ assets and record why unapplied configs are kept
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:18:08 +09:00
DongHyeonkaandClaude Opus 5 bcfdeb93ee docs: explain every setting used in the echo manifest
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:11:18 +09:00
DongHyeonkaandClaude Opus 5 deae8966b8 docs: record why Docker must not be installed on the lab host
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:53:36 +09:00
DongHyeonkaandClaude Opus 5 6c90468c5a feat: report pod address from echo endpoint
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:44:29 +09:00
DongHyeonka 51bb055d61 feat: 2홉 구성 진행 2026-09-03 11:46:56 +09:00
39 changed files with 5910 additions and 1 deletions
+4
View File
@@ -12,3 +12,7 @@ frontend/dist/
bff/target bff/target
token-mediator/target token-mediator/target
# lab cloud-init contains a console password; keep the filled copy local
deploy/lab/cloud-init/kc-lab.yaml
deploy/lab/cloud-init/kc-lab-*.yaml
@@ -0,0 +1 @@
[ 295ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
@@ -0,0 +1 @@
[ 125ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
@@ -0,0 +1 @@
[ 95ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
@@ -0,0 +1 @@
[ 91ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
@@ -0,0 +1 @@
[ 88ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ https://hyeonworks.com/api/v1/studio/session:0
@@ -0,0 +1,25 @@
- generic [ref=f7e3]:
- link "본문으로 건너뛰기" [ref=f7e4] [cursor=pointer]:
- /url: "#main-content"
- banner [ref=f7e5]:
- generic [ref=f7e6]:
- link "TechLog 홈" [ref=f7e8] [cursor=pointer]:
- /url: /
- text: TechLog
- generic [ref=f7e9]:
- button "TechLog 검색 열기" [ref=f7e11] [cursor=pointer]: 검색
- group [ref=f7e12]:
- generic "메뉴" [ref=f7e13] [cursor=pointer]
- generic [ref=f7e14]:
- paragraph [ref=f7e15]: 화면을 준비하고 있습니다.
- generic [ref=f7e16]: TechLog 로딩 중
- contentinfo [ref=f7e17]:
- generic [ref=f7e18]:
- generic [ref=f7e19]:
- paragraph [ref=f7e20]: 동현
- paragraph [ref=f7e21]: 문제를 재현하고 검증해 실제 운영에 적용할 수 있는 형태로 정리합니다.
- generic [ref=f7e22]:
- link "프로필" [ref=f7e23] [cursor=pointer]:
- /url: /profile
- link "변경 기록" [ref=f7e24] [cursor=pointer]:
- /url: /releases
+5
View File
@@ -15,6 +15,11 @@ Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은
- AP3: Backend-for-Frontend (BFF) - AP3: Backend-for-Frontend (BFF)
- AP4: Edge forward-auth - AP4: Edge forward-auth
세션 저장소·refresh token 경쟁·장애 복구는 네 패턴을 가로지르는 별도 축으로
`develop-keycloak-session-store` 브랜치에서 진행합니다. 계획과 진행 상황은
[`docs/session-store-lab-roadmap.md`](docs/session-store-lab-roadmap.md)에
있습니다.
현재 `develop`의 공통 baseline은 Keycloak, PostgreSQL, Spring Boot API, 현재 `develop`의 공통 baseline은 Keycloak, PostgreSQL, Spring Boot API,
nginx를 Docker Compose로 실행하는 토대입니다. 패턴별 구현은 이 baseline nginx를 Docker Compose로 실행하는 토대입니다. 패턴별 구현은 이 baseline
위에서 별도 브랜치로 진행합니다. 위에서 별도 브랜치로 진행합니다.
@@ -1,6 +1,8 @@
package com.example.keycloakpattern; package com.example.keycloakpattern;
import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -9,6 +11,8 @@ import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import jakarta.servlet.http.HttpServletRequest;
@RestController @RestController
@RequestMapping("/api") @RequestMapping("/api")
public class ApiController { public class ApiController {
@@ -18,6 +22,38 @@ public class ApiController {
return Map.of("status", "ok", "service", "keycloak-pattern-api"); return Map.of("status", "ok", "service", "keycloak-pattern-api");
} }
/**
* Reflects what actually reached the application after the proxy chain.
*
* <p>The reverse proxy contract is defined in {@code docs/reverse-proxy-headers.md}
* for a single nginx hop. The lab runs {@code nginx -> Traefik -> pod}, so this
* endpoint exists to measure the two-hop result instead of assuming it.
*
* <p>{@code scheme}, {@code secure} and {@code requestUrl} are the values Keycloak
* uses to build the {@code iss} claim and redirect URLs. If forwarded headers are
* lost or rewritten, the mismatch shows up here first.
*/
@GetMapping("/echo")
public Map<String, Object> echo(HttpServletRequest request) {
Map<String, List<String>> headers = new LinkedHashMap<>();
for (String name : Collections.list(request.getHeaderNames())) {
headers.put(name.toLowerCase(), Collections.list(request.getHeaders(name)));
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("headers", headers);
response.put("remoteAddr", request.getRemoteAddr());
// Pod IP. Identifies which replica answered, which is what makes the
// host nginx upstream distribution and the sticky-session switch observable.
response.put("localAddr", request.getLocalAddr());
response.put("scheme", request.getScheme());
response.put("secure", request.isSecure());
response.put("serverName", request.getServerName());
response.put("serverPort", request.getServerPort());
response.put("requestUrl", request.getRequestURL().toString());
return response;
}
@GetMapping("/me") @GetMapping("/me")
public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) { public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) {
Map<String, Object> response = new LinkedHashMap<>(); Map<String, Object> response = new LinkedHashMap<>();
@@ -17,7 +17,8 @@ public class SecurityConfig {
.sessionManagement(session -> .sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public") .requestMatchers("/actuator/health", "/actuator/health/**", "/api/public",
"/api/echo")
.permitAll() .permitAll()
.anyRequest() .anyRequest()
.authenticated()) .authenticated())
@@ -1,9 +1,19 @@
server: server:
port: ${SERVER_PORT:8081} port: ${SERVER_PORT:8081}
# Spring ignores X-Forwarded-* unless this is set, so scheme/secure/requestUrl
# report the raw connection by default. Keycloak has the same opt-in as
# KC_PROXY_HEADERS. Flipping this to "native" is what the two-hop measurement
# compares against.
forward-headers-strategy: ${SERVER_FORWARD_HEADERS_STRATEGY:none}
spring: spring:
application: application:
name: keycloak-pattern-api name: keycloak-pattern-api
jackson:
serialization:
# /api/echo is read by humans and captured as evidence screenshots, so the
# response is indented rather than relying on a browser's JSON viewer.
indent-output: true
security: security:
oauth2: oauth2:
resourceserver: resourceserver:
@@ -25,6 +25,18 @@ class ApiSecurityTest {
.andExpect(jsonPath("$.status").value("ok")); .andExpect(jsonPath("$.status").value("ok"));
} }
@Test
void echoEndpointReflectsForwardedHeadersWithoutAuthentication() throws Exception {
mockMvc.perform(get("/api/echo")
.header("X-Forwarded-Proto", "https")
.header("X-Forwarded-Host", "app1.example.test"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.headers['x-forwarded-proto'][0]").value("https"))
.andExpect(jsonPath("$.headers['x-forwarded-host'][0]").value("app1.example.test"))
.andExpect(jsonPath("$.requestUrl").exists())
.andExpect(jsonPath("$.remoteAddr").exists());
}
@Test @Test
void protectedEndpointRejectsAnonymousRequests() throws Exception { void protectedEndpointRejectsAnonymousRequests() throws Exception {
mockMvc.perform(get("/api/me")) mockMvc.perform(get("/api/me"))
+136
View File
@@ -0,0 +1,136 @@
# Session store lab
세션 저장소·refresh token 경쟁·장애 복구를 검증하는 2노드 k3s 실험대.
네 인증 패턴(AP1~AP4)을 가로지르는 공통층이므로 별도 축으로 관리한다.
이 문서는 **절차**만 담는다.
| 문서 | 내용 |
|---|---|
| [`docs/session-store-lab-roadmap.md`](../../docs/session-store-lab-roadmap.md) | 이 축의 계획과 진행 상황 |
| [`docs/session-lab-concepts.md`](../../docs/session-lab-concepts.md) | 등장 개념 전체 |
| [`docs/session-lab-operations.md`](../../docs/session-lab-operations.md) | 관측 도구 · 자주 쓰는 명령 · 훈련 |
| [`docs/two-hop-proxy-header-contract.md`](../../docs/two-hop-proxy-header-contract.md) | 첫 실험 결과 |
## 토폴로지
```
브라우저 / SSH (tailnet)
│ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4
lab host ── nginx :443 TLS 종료 · X-Forwarded-* 주입
│ nginx :80 301 → https
│ virbr0 192.168.122.0/24 (libvirt NAT)
├──▶ kc-lab-1 .11 k3s server Traefik :80
└──▶ kc-lab-2 .12 k3s agent Traefik :80
└──▶ Pod
```
`nginx → Traefik` **2홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다.
L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의
접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다.
## 구성 요소
| 경로 | 역할 |
|---|---|
| `cloud-init/kc-lab.yaml.example` | 게스트 부트스트랩 템플릿 |
| `host/nginx-keycloak-lab.conf` | lab host의 `sites-available/keycloak-lab` |
| `k8s/echo.yaml` | 2홉 헤더 계약 측정용 워크로드 |
| `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 |
| `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 |
| `scripts/measure-proxy-headers.sh` | 헤더 계약 실측 |
| `scripts/verify-lab.sh` | 인프라 상태 점검 |
## 상태 점검
```bash
./deploy/lab/scripts/verify-lab.sh # lab host 에서
```
**`404`가 성공 신호다.** TLS가 종료되고 Traefik까지 도달했으나 매칭되는
Ingress 규칙이 없다는 뜻이다. `502`나 연결 거부면 체인이 끊긴 것이다.
## 첫 실험 — 2홉 헤더 계약
[`docs/reverse-proxy-headers.md`](../../docs/reverse-proxy-headers.md)의 계약은
nginx **1홉**을 가정하고 쓰였다. 실제 배치는 2홉이므로, nginx가 세팅한
`X-Forwarded-*`를 Traefik이 그대로 넘기는지 덮어쓰는지 **측정해서 확인한다.**
이 결론이 뒤의 모든 실험에 깔린다. Keycloak의 `iss` 클레임, redirect URL,
쿠키 도메인 검증이 전부 이 헤더에 의존하기 때문이다.
```bash
# 워크스테이션: 이미지 빌드 후 두 노드에 반입
./deploy/lab/scripts/build-and-import.sh
# lab host: 배포
kubectl apply -f deploy/lab/k8s/echo.yaml
kubectl -n header-lab rollout status deployment/echo
# 어디서든: 실측
./deploy/lab/scripts/measure-proxy-headers.sh
```
관측 대상은 넷이다.
1. `X-Forwarded-For` — Traefik이 **덧붙이는가 덮어쓰는가**
2. `X-Forwarded-Proto` / `-Host` / `-Port` — 그대로 전달되는가
3. **위조 내성** — 클라이언트가 직접 넣은 `X-Forwarded-*`가 앱까지 도달하는가
4. `scheme` / `secure` / `requestUrl` — Keycloak이 URL을 만들 때 쓰는 값
3번이 신뢰 경계의 핵심이다. 이 헤더들은 누구나 위조할 수 있는 평범한 HTTP
헤더이므로, 신뢰 경계에 선 프록시가 **반드시 덮어써야** 한다.
## 이미지 배포 경로
k3s는 containerd를 쓰고 이 실험대에는 레지스트리가 없다.
```
워크스테이션 docker build → docker save
│ ssh (lab host 경유)
게스트 sudo k3s ctr images import
매니페스트 imagePullPolicy: Never
```
**두 노드 모두에 반입해야 한다.** 스케줄러가 어느 노드에 배치할지 모른다.
Keycloak·PostgreSQL·Redis는 공식 이미지를 그대로 당겨오므로 이 경로가
필요한 것은 자체 빌드 이미지뿐이다.
**lab host에 Docker를 설치하지 않는다.** k3s의 containerd와 이미지 저장소가
갈려서 `docker build`한 이미지를 k3s가 보지 못하게 된다.
## 게스트 재생성
파괴적 실험 후 초기화하는 경로다.
```bash
virsh destroy kc-lab-1
virsh undefine kc-lab-1 # --remove-all-storage 는 시드 ISO 까지 지운다
virsh vol-delete --pool default kc-lab-1.qcow2
./deploy/lab/scripts/rebuild-seed.sh 1 # user-data 를 고쳤을 때만
virt-install --name kc-lab-1 --memory 3584 --vcpus 2 \
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
--network network=default,mac=52:54:00:aa:bb:11 \
--import --os-variant debian12 --noautoconsole
```
시드는 **virtio 디스크**로 붙인다. `virt-install --cloud-init`은 시드를 SATA
CD-ROM으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리
하드웨어 드라이버를 제외해서 **AHCI 장치를 보지 못한다.** 그러면 cloud-init이
데이터소스를 찾지 못하고 아무 오류도 남기지 않은 채 종료한다. 증상은
hostname이 `localhost`로 남고 SSH가 `Permission denied (publickey)`로 거부되는
것뿐이다.
게스트에 들어갈 수 없을 때는 화면을 직접 뜬다.
```bash
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
```
`localhost login:`이면 cloud-init 미실행, `kc-lab-1 login:`이면 실행된 것이다.
+37
View File
@@ -0,0 +1,37 @@
#cloud-config
# Template for both lab guests. scripts/rebuild-seed.sh substitutes __NODE__
# and bakes this into a CIDATA seed image.
#
# Copy to kc-lab.yaml and fill the two placeholders. The real file is ignored by
# git because plain_text_passwd is a credential, however disposable.
#
# Indentation is spaces only. YAML forbids tabs, and cloud-init fails silently
# on a parse error: the guest boots as "localhost" with no user and no way in.
hostname: kc-lab-__NODE__
fqdn: kc-lab-__NODE__
manage_etc_hosts: true
users:
- name: donghyeon
groups: [sudo]
shell: /bin/bash
# NOPASSWD is required: the k3s installer and the fault-injection scripts
# run non-interactively and would block on a password prompt.
sudo: ['ALL=(ALL) NOPASSWD:ALL']
# Console-only escape hatch. Without it, a cloud-init failure leaves a guest
# that cannot be logged into at all, so its own failure log is unreadable.
# ssh_pwauth stays false, so this never widens SSH exposure.
lock_passwd: false
plain_text_passwd: CHANGE_ME
ssh_authorized_keys:
# Lab host key: needed because automation runs from the lab host, where
# agent forwarding is not available.
- CHANGE_ME_LAB_HOST_PUBLIC_KEY
# Workstation key: lets ProxyJump reach the guest directly.
- CHANGE_ME_WORKSTATION_PUBLIC_KEY
ssh_pwauth: false
package_update: true
packages:
- curl
- nftables
+56
View File
@@ -0,0 +1,56 @@
# Lab entry point. Deployed on the lab host as
# /etc/nginx/sites-available/keycloak-lab
# and symlinked from sites-enabled/.
#
# Arch does not ship the Debian sites-available convention, so nginx.conf needs
# include /etc/nginx/sites-enabled/*;
# inside its http { } block before this file has any effect.
#
# This is the outer of two L7 hops. It terminates TLS and hands plain HTTP to
# the Traefik instance running on each k3s node.
upstream k3s_traefik {
# Sticky-session switch. Keycloak recommends affinity on AUTH_SESSION_ID;
# ip_hash is the cheap stand-in for a single-browser lab. Leaving it off is
# the interesting case: Infinispan still routes correctly, only slower.
# ip_hash;
server 192.168.122.11:80;
server 192.168.122.12:80;
}
server {
listen 80 default_server;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl default_server;
http2 on;
server_name _;
# fullchain.pem, never cert.pem: omitting the intermediates passes on
# desktop browsers and fails on mobile and curl.
ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://k3s_traefik;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
# $remote_addr, not $proxy_add_x_forwarded_for. This is the trust
# boundary: a client-supplied X-Forwarded-For must be discarded, not
# extended, or nothing downstream can rely on the value.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
+62
View File
@@ -0,0 +1,62 @@
# Restrict who may reach the echo pods.
#
# Traefik is configured to trust X-Forwarded-* from the whole pod CIDR, and the
# app's Tomcat valve trusts every private range by default. Both are IP-range
# decisions, so any pod in the cluster can forge those headers by talking to the
# Service directly and bypassing Traefik entirely. Measured, not hypothetical:
#
# kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \
# curl -s http://echo:8081/api/echo -H 'X-Forwarded-Host: evil.example.com'
# → serverName evil.example.com, remoteAddr 1.2.3.4
#
# A NetworkPolicy closes that path. It selects by label rather than IP, so it
# survives pod restarts and rescheduling — unlike the trustedIPs list, which
# could not name Traefik because its IP changes.
#
# "Trusting forwarded headers" and "guaranteeing a proxy sits in front" are a
# pair. Doing only the first leaves this hole.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: echo-allow-traefik-only
namespace: header-lab
spec:
podSelector:
matchLabels:
app: echo
policyTypes:
- Ingress
ingress:
# The proxy itself. namespaceSelector and podSelector in one list item are
# ANDed, so this is "traefik pods in kube-system" and nothing else.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
ports:
- protocol: TCP
port: 8081
# kubelet readiness/liveness probes originate from the node, not from a pod,
# so they need their own rule. Without it the probes fail and the pods are
# restarted in a loop.
#
# The probe's source address is the node's flannel bridge (cni0), which
# holds the first address of that node's /24:
# kc-lab-1 10.42.0.1 kc-lab-2 10.42.1.1
# Listing them as /32 keeps this rule from re-admitting arbitrary pods,
# which a broader 10.42.0.0/16 block would do and would undo the policy.
#
# Adding a node means adding its gateway here. Verify with:
# kubectl get nodes -o jsonpath='{range .items[*]}{.spec.podCIDR}{"\n"}{end}'
- from:
- ipBlock:
cidr: 10.42.0.1/32
- ipBlock:
cidr: 10.42.1.1/32
ports:
- protocol: TCP
port: 8081
+113
View File
@@ -0,0 +1,113 @@
# Header echo workload for the two-hop proxy contract measurement.
#
# browser -> host nginx (TLS termination) -> Traefik -> this pod
#
# The image is built from backend/ and imported straight into each node's
# containerd, so imagePullPolicy must stay Never. See scripts/build-and-import.sh.
apiVersion: v1
kind: Namespace
metadata:
name: header-lab
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: echo
namespace: header-lab
spec:
replicas: 2
selector:
matchLabels:
app: echo
template:
metadata:
labels:
app: echo
spec:
# One replica per node so the sticky-session switch on the host nginx
# upstream has something observable to route between.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: echo
containers:
- name: echo
image: keycloak-pattern-api:lab
imagePullPolicy: Never
ports:
- containerPort: 8081
name: http
env:
- name: SERVER_PORT
value: "8081"
# "none" makes the app report the raw connection, so scheme/secure/
# requestUrl show what arrives without any forwarded-header handling.
# Set to "native" and redeploy to see the same request interpreted
# with X-Forwarded-* honoured. Keycloak's KC_PROXY_HEADERS is the
# same opt-in, which is why measuring both sides matters here.
- name: SERVER_FORWARD_HEADERS_STRATEGY
value: "native"
# The JVM sizes its heap from the container limit, not the host.
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=70"
# /api/echo is permitAll, so the JWT decoder is never exercised.
# These stay pointed at the future Keycloak service name.
- name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI
value: "https://auth.hyeonworks.com/realms/keycloak-patterns"
- name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI
value: "https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
initialDelaySeconds: 15
periodSeconds: 5
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
initialDelaySeconds: 45
periodSeconds: 15
resources:
requests:
memory: 320Mi
cpu: 100m
limits:
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: echo
namespace: header-lab
spec:
selector:
app: echo
ports:
- port: 8081
targetPort: http
name: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: echo
namespace: header-lab
spec:
# k3s ships Traefik as the default ingress controller. Keeping it is what
# makes this lab a faithful two-hop replica.
ingressClassName: traefik
rules:
- host: app1.hyeonworks.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: echo
port:
number: 8081
@@ -0,0 +1,43 @@
# Make Traefik trust the X-Forwarded-* headers that the host nginx sets.
#
# Without this, Traefik rewrites every forwarded header from its own connection,
# which is plain HTTP on port 80. The application then sees scheme=http even
# though the browser connected over TLS. See docs/two-hop-proxy-header-contract.md.
#
# k3s installs Traefik through its bundled HelmChart, so values are overridden
# with a HelmChartConfig rather than by editing the deployment. k3s reconciles
# the chart and recreates the Traefik pod.
#
# kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
# kubectl -n kube-system rollout status deploy/traefik --timeout=180s
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
ports:
web:
forwardedHeaders:
# Requests arriving from these sources keep their existing
# X-Forwarded-* values instead of having them rewritten.
#
# 10.42.0.0/16 is the pod CIDR. It is required because the traefik
# Service uses externalTrafficPolicy: Cluster, so svclb SNATs the
# traffic and Traefik sees a pod-network address rather than the
# host nginx address.
#
# The node/host range is deliberately absent. Because svclb SNATs,
# the host nginx address never reaches Traefik — measured, not assumed.
# Trusting a range that cannot appear only widens the surface.
#
# Trusting the whole pod CIDR still means any pod in the cluster could
# forge these headers, which is why echo-network-policy.yaml restricts
# who may reach the application at all.
trustedIPs:
- 10.42.0.0/16
websecure:
forwardedHeaders:
trustedIPs:
- 10.42.0.0/16
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Build the API image on this workstation and import it into each lab node's
# containerd.
#
# k3s does not run Docker and the lab has no registry, so images are shipped as
# a stream: docker save -> ssh through the lab host -> k3s ctr images import.
# Every node needs its own copy because the scheduler may place the pod anywhere.
#
# ./deploy/lab/scripts/build-and-import.sh
# IMAGE=keycloak-pattern-api:lab NODES="kc-lab-1" ./deploy/lab/scripts/build-and-import.sh
set -euo pipefail
IMAGE="${IMAGE:-keycloak-pattern-api:lab}"
NODES="${NODES:-kc-lab-1 kc-lab-2}"
LAB_HOST="${LAB_HOST:-test-server}"
CONTEXT="${CONTEXT:-backend}"
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
echo "==> building ${IMAGE} from ${CONTEXT}/"
docker build -t "$IMAGE" "$CONTEXT"
for node in $NODES; do
echo "==> importing into ${node}"
# Nested ssh: the workstation cannot reach the guests directly because they
# sit behind the lab host's libvirt NAT. The lab host's ~/.ssh/config holds
# the kc-lab-* aliases.
docker save "$IMAGE" \
| ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images import -'"
done
echo "==> verifying"
for node in $NODES; do
printf ' %-10s ' "$node"
ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images ls -q'" \
| grep -c "$IMAGE" \
| xargs -I{} echo "{} match(es)"
done
echo
echo "next: kubectl rollout restart -n header-lab deployment/echo"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Measure what the nginx -> Traefik chain actually delivers to the application.
#
# docs/reverse-proxy-headers.md documents a single-hop nginx contract. The lab
# runs two hops, so the forwarded headers are measured rather than assumed.
# Run from anywhere that can resolve the lab hostnames.
#
# ./deploy/lab/scripts/measure-proxy-headers.sh
set -euo pipefail
HOST="${HOST:-app1.hyeonworks.com}"
URL="https://${HOST}/api/echo"
jqf() {
if command -v jq >/dev/null 2>&1; then jq "$@"; else python3 -m json.tool; fi
}
echo "=== 1. baseline: what the app sees for a normal request ==="
curl -s "$URL" | jqf '{
scheme, secure, serverName, serverPort, requestUrl, remoteAddr,
forwarded: .headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip" or . == "forwarded"))
}' 2>/dev/null || curl -s "$URL"
echo
echo "=== 2. spoof test: client sends its own X-Forwarded-* ==="
echo " a trusted boundary must overwrite these, not append to them"
curl -s "$URL" \
-H 'X-Forwarded-For: 1.2.3.4' \
-H 'X-Forwarded-Proto: http' \
-H 'X-Forwarded-Host: evil.example.com' \
-H 'X-Real-IP: 1.2.3.4' \
| jqf '.headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip"))' 2>/dev/null
echo
echo "=== 3. which pod answered (host nginx upstream distribution) ==="
for _ in 1 2 3 4; do
curl -s "$URL" | jqf -r '.headers["x-forwarded-server"] // "n/a"' 2>/dev/null
done
echo
echo "=== 4. plain HTTP is redirected, not proxied ==="
curl -s -o /dev/null -w ' http -> %{http_code} %{redirect_url}\n' "http://${HOST}/api/echo"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Rebuild a guest's cloud-init seed image and publish it into the libvirt pool.
# Run on the lab host.
#
# ./rebuild-seed.sh 1
#
# The same content lives in three places: the source YAML, the ISO, and the
# uploaded pool volume. Editing the YAML alone changes nothing, which is why
# this is a script and not a set of remembered commands.
#
# A rebuilt seed only takes effect on a freshly created VM. cloud-init runs its
# per-instance modules once per instance-id, so an existing guest ignores it.
set -euo pipefail
N="${1:?usage: rebuild-seed.sh <1|2>}"
CLOUD_DIR="${CLOUD_DIR:-$HOME/workspace/cloud}"
POOL="${POOL:-default}"
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
cd "$CLOUD_DIR"
src="kc-lab-${N}.yaml"
iso="seed-kc-lab-${N}.iso"
meta="meta-kc-lab-${N}"
[ -f "$src" ] || { echo "missing $CLOUD_DIR/$src" >&2; exit 1; }
# A fresh instance-id makes cloud-init treat the guest as new and re-run the
# per-instance modules.
printf 'instance-id: kc-lab-%s-%s\nlocal-hostname: kc-lab-%s\n' \
"$N" "$(date +%s)" "$N" > "$meta"
# NoCloud looks for a volume labelled cidata holding files named exactly
# user-data and meta-data. -graft-points renames them inside the image so no
# staging directory is needed.
xorrisofs -quiet -output "$iso" -volid CIDATA -joliet -rock -graft-points \
"/user-data=${src}" "/meta-data=${meta}"
size="$(stat -c%s "$iso")"
virsh vol-delete --pool "$POOL" "$iso" >/dev/null 2>&1 || true
virsh vol-create-as "$POOL" "$iso" "$size" --format raw >/dev/null
virsh vol-upload --pool "$POOL" "$iso" "$iso"
echo "$iso published to pool '$POOL' ($size bytes)"
echo "attach it as a virtio disk, not a SATA cdrom:"
echo " --disk vol=${POOL}/${iso},device=disk,bus=virtio,readonly=on"
echo "Debian genericcloud images carry no AHCI driver, so a SATA cdrom is invisible"
echo "to the guest and cloud-init fails with no error anywhere."
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Confirm the lab infrastructure is intact. Run on the lab host.
#
# A 404 from the HTTPS entry point is the success signal: TLS terminated and the
# request reached Traefik, which simply had no matching ingress rule. A 502 or a
# refused connection means the chain is broken somewhere.
set -uo pipefail
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
HOSTS="${HOSTS:-auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com}"
NODE_IPS="${NODE_IPS:-192.168.122.11 192.168.122.12}"
fail=0
check() { # description, expected, actual
if [ "$2" = "$3" ]; then printf ' ok %-34s %s\n' "$1" "$3"
else printf ' FAIL %-34s got %s, want %s\n' "$1" "$3" "$2"; fail=1; fi
}
echo "== guests =="
for name in kc-lab-1 kc-lab-2; do
check "$name" running "$(virsh domstate "$name" 2>/dev/null || echo absent)"
done
echo "== k3s =="
ready="$(kubectl get nodes --no-headers 2>/dev/null | grep -c ' Ready ')"
check "nodes Ready" 2 "$ready"
lb="$(kubectl -n kube-system get svc traefik \
-o jsonpath='{.status.loadBalancer.ingress[*].ip}' 2>/dev/null | wc -w)"
check "traefik node IPs" 2 "$lb"
echo "== host nginx =="
check "service" active "$(systemctl is-active nginx)"
check "cert renew timer" active "$(systemctl is-active certbot-renew.timer)"
for ip in $NODE_IPS; do
check "traefik $ip" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://${ip}/")"
done
echo "== public entry point =="
for h in $HOSTS; do
check "https://$h" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "https://${h}/")"
check "tls verify $h" 0 "$(curl -s -o /dev/null -w '%{ssl_verify_result}' --max-time 8 "https://${h}/")"
done
check "http redirect" 301 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "http://${HOSTS%% *}/")"
echo
[ "$fail" -eq 0 ] && echo "lab is healthy" || echo "lab has failures"
exit "$fail"
@@ -0,0 +1,34 @@
수집 시각: 2026-09-03 15:01:30 KST
대상: https://app1.hyeonworks.com/api/echo
=== [1] 호스트 nginx 가 주입하는 헤더 ===
3: server 192.168.122.11:80;
4: server 192.168.122.12:80;
8: listen 80 default_server;
14: listen 443 ssl default_server;
26: proxy_set_header Host $host;
27: proxy_set_header X-Forwarded-Host $host;
28: proxy_set_header X-Forwarded-Proto http;
29: proxy_set_header X-Forwarded-Port 80;
30: proxy_set_header X-Forwarded-For $remote_addr;
31: proxy_set_header X-Real-IP $remote_addr;
=== [2] Traefik entryPoint 인자 (forwardedHeaders 부재 확인) ===
["--entryPoints.metrics.address=:9100/tcp"
"--entryPoints.traefik.address=:8080/tcp"
"--entryPoints.web.address=:8000/tcp"
"--entryPoints.websecure.address=:8443/tcp"
"--metrics.prometheus.entrypoint=metrics"
"--entryPoints.websecure.http.tls=true"
→ forwardedHeaders.trustedIPs 인자가 없음 = 기본값(신뢰 안 함)
=== [3] Traefik 파드 수와 위치 ===
traefik-59b7647586-ftwf8 10.42.0.8 kc-lab-1
=== [4] traefik Service externalTrafficPolicy ===
Cluster
→ Cluster = svclb 가 SNAT 하여 클라이언트 IP 소실
=== [5] 앱 파드의 스위치 상태 ===
SERVER_PORT=8081
SERVER_FORWARD_HEADERS_STRATEGY=none
@@ -0,0 +1,87 @@
수집 시각: 2026-09-03 15:02:27 KST
=== [A] 정상 경로 — 브라우저와 같은 요청 ===
명령: curl -s https://app1.hyeonworks.com/api/echo
x-forwarded-proto http
x-forwarded-port 80
x-forwarded-for 10.42.0.1
x-forwarded-host app1.hyeonworks.com
x-real-ip 10.42.0.1
x-forwarded-server traefik-59b7647586-ftwf8
--- 앱이 해석한 값
scheme http
secure False
serverName app1.hyeonworks.com
serverPort 80
remoteAddr 10.42.0.8
localAddr 10.42.1.3
requestUrl http://app1.hyeonworks.com/api/echo
=== [B] 대조 실험 1 — nginx 우회, 헤더 없이 Traefik 직접 ===
명령: curl http://192.168.122.11/api/echo -H 'Host: app1.hyeonworks.com' (test-server 에서)
x-forwarded-proto http
x-forwarded-port 80
x-forwarded-for 10.42.0.1
x-forwarded-host app1.hyeonworks.com
x-real-ip 10.42.0.1
x-forwarded-server traefik-59b7647586-ftwf8
--- 앱이 해석한 값
scheme http
secure False
serverName app1.hyeonworks.com
serverPort 80
remoteAddr 10.42.0.8
localAddr 10.42.0.9
requestUrl http://app1.hyeonworks.com/api/echo
=== [C] 대조 실험 2 — nginx 우회, 올바른 헤더를 명시해서 ===
명령: 위와 동일 + -H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Port: 443' -H 'X-Forwarded-For: 203.0.113.7'
x-forwarded-proto http
x-forwarded-port 80
x-forwarded-for 10.42.0.1
x-forwarded-host app1.hyeonworks.com
x-real-ip 10.42.0.1
x-forwarded-server traefik-59b7647586-ftwf8
--- 앱이 해석한 값
scheme http
secure False
serverName app1.hyeonworks.com
serverPort 80
remoteAddr 10.42.0.8
localAddr 10.42.1.3
requestUrl http://app1.hyeonworks.com/api/echo
★ [C] 에서 https/443/203.0.113.7 을 명시했음에도 http/80/10.42.0.1 이 도달했다.
→ Traefik 이 들어온 X-Forwarded-* 를 신뢰하지 않고 재작성한다는 독립적 증거.
=== [D] 위조 테스트 — 클라이언트가 직접 헤더 주입 ===
명령: curl https://app1.hyeonworks.com/api/echo -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
x-forwarded-proto http
x-forwarded-port 80
x-forwarded-for 10.42.1.0
x-forwarded-host app1.hyeonworks.com
x-real-ip 10.42.1.0
x-forwarded-server traefik-59b7647586-ftwf8
--- 앱이 해석한 값
scheme http
secure False
serverName app1.hyeonworks.com
serverPort 80
remoteAddr 10.42.0.8
localAddr 10.42.0.9
requestUrl http://app1.hyeonworks.com/api/echo
★ evil.example.com 과 1.2.3.4 가 도달하지 않았다 = 신뢰 경계는 작동.
=== [E] 파드 분배 8회 ===
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
pod 10.42.1.3 | traefik traefik-59b7647586-ftwf8
pod 10.42.0.9 | traefik traefik-59b7647586-ftwf8
=== [F] HTTP → HTTPS 리다이렉트 ===
status=301 location=https://app1.hyeonworks.com/api/echo
@@ -0,0 +1,55 @@
수집 시각: 2026-09-03 15:32:04 KST
단계: A(nginx) + B(Traefik) + C(앱) 모두 적용 후
=== [1] nginx 가 보내는 값 ===
28: proxy_set_header X-Forwarded-Proto https;
29: proxy_set_header X-Forwarded-Port 443;
30: proxy_set_header X-Forwarded-For $remote_addr;
31: proxy_set_header X-Real-IP $remote_addr;
=== [2] Traefik entryPoint 인자 ===
"--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16
"--entryPoints.websecure.forwardedHeaders.trustedIPs=10.42.0.0/16
=== [3] 앱 스위치 ===
SERVER_FORWARD_HEADERS_STRATEGY=native
=== [4] 최종 측정 ===
x-forwarded-proto https
x-forwarded-port 443
x-forwarded-host app1.hyeonworks.com
x-real-ip 100.123.124.30
x-forwarded-server traefik-697889c85-g7xpp
--- 앱이 해석한 값
scheme https
secure True
serverName app1.hyeonworks.com
serverPort 443
remoteAddr 100.123.124.30
localAddr 10.42.0.10
requestUrl https://app1.hyeonworks.com/api/echo
=== [5] 위조 테스트 — 클라이언트가 http/evil/1.2.3.4 를 주입 ===
x-forwarded-proto https
x-forwarded-port 443
x-forwarded-host app1.hyeonworks.com
x-real-ip 100.123.124.30
x-forwarded-server traefik-697889c85-g7xpp
--- 앱이 해석한 값
scheme https
secure True
serverName app1.hyeonworks.com
serverPort 443
remoteAddr 100.123.124.30
localAddr 10.42.1.6
requestUrl https://app1.hyeonworks.com/api/echo
★ 주입값이 하나도 반영되지 않았다. nginx 의 $remote_addr 덮어쓰기가 방어한다.
=== [6] 파드 분배 6회 ===
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
pod 10.42.0.10 | remoteAddr 100.123.124.30 | scheme https
pod 10.42.1.6 | remoteAddr 100.123.124.30 | scheme https
@@ -0,0 +1,48 @@
수집 시각: 2026-09-03 16:16:48 KST
주제: 프록시 우회 경로 차단 (NetworkPolicy)
=== [1] 차단 전 — 클러스터 안에서 앱에 직접 요청 ===
명령: kubectl run ... -- curl http://echo:8081/api/echo \
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
scheme https
secure True
serverName evil.example.com ← 위조 성공
remoteAddr 1.2.3.4 ← 위조 성공
requestUrl https://evil.example.com/api/echo
★ Traefik 을 거치지 않으면 헤더 위조가 그대로 통한다.
trustedIPs 와 internalProxies 가 둘 다 '대역'을 믿기 때문.
=== [2] 적용한 것 ===
deploy/lab/k8s/traefik-forwarded-headers.yaml — 192.168.122.0/24 제거
"--entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16"
"--entryPoints.websecure.forwardedHeaders.trustedIPs=10.42.0.0/16"
deploy/lab/k8s/echo-network-policy.yaml — Traefik 파드에서만 8081 허용
[{"from":[{"namespaceSelector":{"matchLabels":{"kubernetes.io/metadata.name":"kube-system"}},"podSelector":{"matchLabels":{"app.kubernetes.io/name":"traefik"}}}],"ports":[{"port":8081,"protocol":"TCP"}]},{"from":[{"ipBlock":{"cidr":"10.42.0.1/32"}},{"ipBlock":{"cidr":"10.42.1.1/32"}}],"ports":[{"port":8081,"protocol":"TCP"}]}]
=== [3] 차단 후 — 정상 경로 (계속 동작해야 함) ===
x-forwarded-proto https
x-forwarded-port 443
x-forwarded-host app1.hyeonworks.com
x-real-ip 100.123.124.30
x-forwarded-server traefik-5d6fcf895-wpfhr
--- 앱이 해석한 값
scheme https
secure True
serverName app1.hyeonworks.com
serverPort 443
remoteAddr 100.123.124.30
localAddr 10.42.0.14
requestUrl https://app1.hyeonworks.com/api/echo
=== [4] 차단 후 — 우회 시도 ===
HTTP 000 / curl exit 7
HTTP 000 / curl exit 7
★ curl exit 7 = Failed to connect. 연결 자체가 성립하지 않는다.
=== [5] 파드 건강 상태 (probe 가 차단되지 않았는지) ===
echo-54dbd94986-8jmdb 1/1 Running restarts=0
echo-54dbd94986-lfltk 1/1 Running restarts=0
@@ -0,0 +1,199 @@
# 증거 — 2홉 프록시 헤더 계약 (수정 전 상태)
`docs/two-hop-proxy-header-contract.md`의 진단을 뒷받침하는 원자료.
**모두 수정 전 상태에서 수집**했으며, 수정 후 재수집하여 대조한다.
수집 시각: 2026-09-03 15:01~15:03 KST
| 파일 | 내용 |
|---|---|
| `01-environment.txt` | 수정 전 세 계층의 설정 스냅샷 |
| `02-measurements.txt` | 수정 전 측정 · 대조 실험 · 위조 테스트 · 분배 |
| `stage-a-nginx-fixed.png` | A 단계 브라우저 화면 |
| `stage-b-traefik-trusts.png` | B 단계 브라우저 화면 |
| `stage-c-resolved.png` | C 단계 브라우저 화면 |
| `04-after-fix.txt` | 수정 후 측정 · 위조 테스트 · 분배 |
---
## 확인된 문제는 둘이다
최초 진단은 "Traefik이 덮어쓴다" 하나였으나, 증거 수집 과정에서
**독립된 원인이 두 개**임이 드러났다.
### 문제 1 — nginx가 애초에 틀린 값을 보낸다
`01-environment.txt`
```
26: proxy_set_header Host $host;
27: proxy_set_header X-Forwarded-Host $host;
28: proxy_set_header X-Forwarded-Proto http; ← https 여야 한다
29: proxy_set_header X-Forwarded-Port 80; ← 443 이어야 한다
30: proxy_set_header X-Forwarded-For $remote_addr;
31: proxy_set_header X-Real-IP $remote_addr;
```
`listen 443 ssl` 서버 블록 안인데 `X-Forwarded-Proto``http`다.
TLS를 종료하는 서버가 "원래 요청은 평문이었다"고 알리고 있다.
HTTP 전용으로 먼저 세운 뒤 TLS를 얹는 과정에서 **이 두 줄을 함께 바꾸지
않아 남은 값**이다. 설정 자체는 문법 오류가 없으므로 `nginx -t`도 통과하고,
**아무 경고 없이 잘못된 값이 전파된다.**
### 문제 2 — Traefik이 올바른 값이 와도 덮어쓴다
`02-measurements.txt`**대조 실험 [C]** 가 이를 독립적으로 증명한다.
nginx를 우회해 Traefik에 직접 요청하면서 올바른 헤더를 명시했다.
```
보낸 것 : X-Forwarded-Proto: https
X-Forwarded-Port: 443
X-Forwarded-For: 203.0.113.7
도달한 것: x-forwarded-proto http
x-forwarded-port 80
x-forwarded-for 10.42.0.1
```
**세 값 모두 재작성됐다.** Traefik entryPoint에
`forwardedHeaders.trustedIPs`가 설정되지 않아 들어온 헤더를 신뢰하지 않는다.
`01-environment.txt`의 Traefik 인자 목록에 `forwardedHeaders` 관련 항목이
하나도 없는 것이 그 근거다.
**문제 1만 고쳐서는 해결되지 않는다.** 두 원인이 직렬로 걸려 있다.
---
## 브라우저 증거
스크린샷은 모두 **브라우저가 `/api/echo` 응답을 렌더링한 실제 화면**이다.
앱이 정렬된 JSON을 내보내도록 `spring.jackson.serialization.indent-output`
켜두었으므로 브라우저의 JSON 뷰어 설정과 무관하게 동일하게 읽힌다.
세 장은 **같은 요청을 세 가지 설정 상태에서** 찍은 것이다.
| 파일 | 켜진 스위치 | 화면에서 확인할 것 |
|---|---|---|
| `stage-a-nginx-fixed.png` | nginx 만 | `x-forwarded-proto: http` — Traefik 이 덮어씀 |
| `stage-b-traefik-trusts.png` | nginx + Traefik | **헤더는 `https`인데 `scheme: http`** |
| `stage-c-resolved.png` | 셋 다 | `scheme: https`, `secure: true` |
**`stage-b`가 가장 중요한 한 장이다.** `x-forwarded-proto: https`가 앱에
도착해 있는데도 `scheme: http`, `secure: false`, `requestUrl: http://...`다.
**헤더가 도착하는 것과 앱이 그것을 읽는 것은 다른 문제**임을 한 화면이
보여준다.
## 정상으로 확인된 것
증거 수집에서 **문제가 아니라고 확인된 항목**도 함께 남긴다.
| 항목 | 결과 |
|---|---|
| TLS 종료 | 정상. 실인증서, `isSecureContext=true` |
| `X-Forwarded-Host` | 유지됨 — Traefik이 이것만은 덮어쓰지 않는다 |
| 위조 차단 | 클라이언트가 넣은 `evil.example.com`, `1.2.3.4`가 앱에 도달하지 않음 |
| 파드 분배 | 8회 요청이 두 파드에 정확히 번갈아 도달 |
| HTTP 리다이렉트 | `301 → https://app1.hyeonworks.com/api/echo` |
**위조가 차단되는 것은 nginx가 막아서가 아니라 Traefik이 전부 덮어쓰기
때문**이다. 문제 2를 고치면 이 방어가 nginx의 `$remote_addr` 덮어쓰기로
옮겨간다. 수정 후 재측정에서 **위조가 여전히 막히는지 반드시 확인**해야 한다.
---
## 재수집 방법
```bash
# 터미널 증거
./deploy/lab/scripts/measure-proxy-headers.sh
# 개별 확인
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool
# 대조 실험 (test-server 에서, nginx 우회)
curl -s http://192.168.122.11/api/echo \
-H 'Host: app1.hyeonworks.com' \
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Port: 443' \
-H 'X-Forwarded-For: 203.0.113.7' | python3 -m json.tool
```
---
## 수정 후 (2026-09-03 15:32 KST)
세 스위치를 순서대로 켜며 각 단계를 측정했다. 상세 절차는
`docs/two-hop-proxy-header-contract.md` 9~11절.
| 파일 | 단계 |
|---|---|
| `stage-a-nginx-fixed.png` | A — nginx 만 고침 |
| `stage-b-traefik-trusts.png` | B — Traefik `trustedIPs` 추가 |
| `stage-c-resolved.png` | C — 앱 `strategy=native` |
| `04-after-fix.txt` | 최종 측정 · 위조 테스트 · 분배 |
스크린샷은 브라우저가 `/api/echo` 응답을 렌더링한 **실제 화면**이다.
### 단계별 결과
| 항목 | 최초 | A | B | C |
|---|---|---|---|---|
| `x-forwarded-proto` | `http` | **`http`** | `https` | `https` |
| `x-real-ip` | `10.42.1.0` | `10.42.1.0` | `100.123.124.30` | `100.123.124.30` |
| `scheme` (앱 해석) | `http` | `http` | **`http`** | **`https`** |
| `requestUrl` | `http://…` | `http://…` | `http://…` | **`https://…`** |
**A 이후 아무 변화가 없는 것**이 Traefik 덮어쓰기의 증거이고,
**B 이후 헤더는 살아났으나 앱 해석은 그대로인 것**이 2번과 3번 스위치가
다른 일을 한다는 증거다.
### 위조 차단 재확인
`04-after-fix.txt` [5]. 클라이언트가 `X-Forwarded-Proto: http`,
`X-Forwarded-Host: evil.example.com`, `X-Forwarded-For: 1.2.3.4`를 주입했으나
**하나도 반영되지 않았다.**
**방어 주체가 바뀌었다.** 수정 전에는 Traefik이 전부 덮어써서 막았고,
수정 후에는 nginx의 `$remote_addr`가 막는다. 그래서 nginx에서
`$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 안 된다.
### 겪은 함정
`kubectl rollout status`가 완료를 알려도 **helm-controller의 Job이 차트를
업그레이드하는 동안 구 Traefik 파드가 함께 살아 있다.** 이 시점에 측정하면
옛 파드가 응답해 "고쳤는데 안 바뀌었다"고 오해하게 된다. `x-forwarded-server`
값의 파드 이름으로 어느 파드가 응답했는지 확인해야 한다.
---
## 프록시 우회 차단 (2026-09-03 16:16 KST)
`05-networkpolicy.txt`
헤더 신뢰를 켠 뒤 남아 있던 구멍을 실증하고 막았다.
**차단 전** — 클러스터 안에서 Traefik을 우회해 앱에 직접 요청하면
`serverName: evil.example.com`, `remoteAddr: 1.2.3.4`**위조가 성립했다.**
**적용한 것**
| 파일 | 변경 |
|---|---|
| `traefik-forwarded-headers.yaml` | `192.168.122.0/24` 제거 (SNAT 때문에 도달 불가한 대역) |
| `echo-network-policy.yaml` | Traefik 파드에서만 8081 허용 (라벨 기준) |
**차단 후**
```
정상 경로 scheme=https, remoteAddr=100.123.124.30 동작
우회 시도 HTTP 000 / curl exit 7 연결 거부
파드 상태 1/1 Running, restarts=0 probe 정상
```
`exit 7`은 curl의 "Failed to connect"다. HTTP 403이 아니라
**TCP 연결 자체가 성립하지 않았다**는 뜻이다.
`restarts=0`이 중요하다. NetworkPolicy에서 kubelet probe 경로를 빠뜨리면
probe가 실패해 파드가 재시작 루프에 빠진다. 노드의 cni0 주소
(`10.42.0.1`, `10.42.1.1`)를 `/32`로 허용해 이를 피했다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

+283
View File
@@ -0,0 +1,283 @@
# 열린 질문 커버리지 — 이 실험대로 답할 수 있는가
공개 기록(`hyeonworks.com/questions`)에 등록된 KeyCloak Patterns 열린 질문
네 개를, 이 실험대가 실제로 검증할 수 있는지 대조한 결과.
**결론 — 네 개 모두 이 실험대에서 재현 가능하다. 다만 로드맵에 빠진 항목이
있고, 순서가 한 곳 뒤집혀 있다.**
| # | 질문 | 게시 | 로드맵 커버 |
|---|---|---|---|
| Q1 | [서버 세션 기반 인증 구조는 다중 인스턴스에서 어떻게 운영할 것인가](https://hyeonworks.com/questions/server-session-pattern-multi-instance) | 2026.08.29 | **부분** |
| Q2 | [Refresh Token Rotation과 다중 Replica 경쟁을 어떻게 처리할 것인가](https://hyeonworks.com/questions/refresh-rotation-replica-contention) | 2026.08.26 | **부분** |
| Q3 | [BFF의 Session과 OAuth2AuthorizedClient를 어디에 저장할 것인가](https://hyeonworks.com/questions/bff-session-authorized-client-store) | 2026.08.30 | **부분** |
| Q4 | [Forward-Auth 구조에서 Application Authorization을 어디까지 Edge에 둘 것인가](https://hyeonworks.com/questions/edge-authorization-scope) | 2026.08.31 | **없음** |
---
## 발견한 구조적 문제
### 1. 순서가 뒤집혀 있다
Q2가 명시한다.
> 이 경쟁은 **저장소를 공유한 뒤에야 재현**되기 때문에 저장소 결정을 하고
> 나서 해당 문제를 이어서 풀어보자.
**Q3(저장소 결정) → Q2(경쟁 재현)** 순서다. 그런데 로드맵은
`refresh-token-concurrency``redis-app-session-store`보다 **앞**에 두었다.
**Q2를 먼저 시도하면 재현 자체가 불가능하다.** 저장소가 process-local이면
두 replica가 같은 refresh token 항목을 보지 않기 때문이다.
→ 로드맵 순서를 교정한다.
### 2. Session과 Authorized Client는 조회 키가 다르다
Q3의 핵심이며 로드맵에 이 구분이 없었다.
| 상태 | 조회 키 | 저장 위치(현재) |
|---|---|---|
| Application Session | **session ID** | 서블릿 컨테이너 in-memory |
| OAuth2AuthorizedClient | **client registration 이름 + principal name** | 자동구성 in-memory |
**`session ID`가 조회 키에 없다.** 그래서 같은 사용자가 두 브라우저에서
로그인하면 **동일한 authorized client 항목을 공유**한다.
Q1의 제약이 이를 그대로 지적한다.
> 여러 인스턴스가 같은 세션을 사용할 수 있도록 Session Store를 공유
> 저장소로 변경하는 것만으로는 **충분하지 않다.**
→ 실험을 "Redis 도입" 하나로 뭉뚱그리면 안 된다. **두 저장소를 각각 설계하고
각각 검증해야 한다.**
### 3. 이미 해결한 문제가 질문에도 있다
Q1의 제약:
> Resource Server의 8081이 host에도 열려 있어서 모든 client가 BFF만 거치도록
> **network에서 강제된 상태가 아니다.**
이는 2홉 헤더 실험에서 마주친 **프록시 우회 경로**와 같은 문제이며,
NetworkPolicy로 닫는 방법을 이미 확립했다
([`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) 11절).
→ Q1에 답할 때 그 패턴을 그대로 재사용한다.
---
## Q1. 다중 인스턴스 운영
**질문이 요구하는 검증 5단계**
| # | 검증 | 실험대 가능 | 로드맵 |
|---|---|---|---|
| 1 | 한쪽에서 로그인 후 **다른 인스턴스로 요청 시 200 유지** | 가능 | 없음 |
| 2 | 한 인스턴스 재시작 후 **같은 session cookie로 상태 유지** | 가능 | 없음 |
| 3 | 같은 사용자 두 브라우저 → **authorized client 덮어쓰는가** | 가능 | **없음** |
| 4 | 한쪽 logout 후 **다른 쪽 요청** | 가능 | 부분 (백채널 로그아웃) |
| 5 | **session 만료 ≠ token 만료** 각 경우의 응답과 화면 | 가능 | **없음** |
**실험대 준비 상태** — BFF를 2 replica로 띄우면 전부 재현된다. 호스트 nginx의
`ip_hash` 주석을 켜고 끄면 **스티키 유무 비교**까지 같은 구성에서 된다.
**추가로 필요한 것**
- BFF 이미지 (아직 `bff/` 디렉터리에 소스 없음)
- 로그아웃 전파를 관찰할 두 번째 앱 (`app2.hyeonworks.com` 이름은 확보)
**3번이 특히 중요하다.** "Redis만 붙이면 해결"이라는 착각을 깨는 항목이고,
조회 키가 다르다는 사실의 실증이다.
---
## Q2. Refresh Token Rotation 경쟁
**질문이 요구하는 검증 5단계**
| # | 검증 | 실험대 가능 | 로드맵 |
|---|---|---|---|
| 1 | replica 두 대에서 **access token 만료 직후 동시 요청** | 가능 | 있음 |
| 2 | **이긴 쪽/지는 쪽 응답** 각각 기록 | 가능 | 부분 |
| 3 | 지는 쪽이 **저장된 새 token으로 재시도해 성공하는가** | 가능 | **없음** |
| 4 | **지는 쪽 사용자 화면**에 무엇이 보이는가 | 가능 | **없음** |
| 5 | **lock 유무를 같은 입력으로 비교** (실패율·지연) | 가능 | **없음** |
**5번이 결론을 내는 기준이다.**
> 실패가 사용자에게 노출되면 lock을 고르고, 노출되지 않으면 재시도로 둔다.
로드맵에 없던 항목인데, **이것이 없으면 질문에 답할 수 없다.**
**제약을 지켜야 한다**
- rotation + 재사용 0회는 **전제로 고정**한다. 바꾸지 않고 답한다
- 이미 발급된 access token은 만료 전까지 통하므로 **재현은 access token 만료
직후에 맞춰 실행**한다. 그렇지 않으면 실패가 화면에 보이지 않는다
**선행 조건** — Q3의 저장소 공유가 먼저다.
---
## Q3. BFF 저장소 결정
**질문이 요구하는 검증 5단계**
| # | 검증 | 실험대 가능 | 로드맵 |
|---|---|---|---|
| 1 | 인스턴스 두 대에서 **로그인 유지와 재시작 복구** | 가능 | 부분 |
| 2 | 저장소를 열어 **refresh token이 평문인가** | 가능 | **없음** |
| 3 | **session TTL ≠ token 만료** 그 순간의 응답과 화면 | 가능 | **없음** |
| 4 | logout 뒤 **두 store에 잔여 항목이 없는가** | 가능 | 부분 |
| 5 | **저장소를 끊은 상태**에서 로그인·API 호출 오류 | 가능 | 있음 |
**로드맵에 없던 큰 항목 — 후보 비교**
질문은 "Redis로 간다"가 아니라 **"Redis와 JDBC 중 무엇이 이 접근 패턴에
맞는가"** 를 묻는다.
> 요청마다 읽는 값과 가끔 읽는 값이 섞여 있다.
이 실험대에는 PostgreSQL이 이미 있으므로 **JDBC 후보를 같은 조건에서 비교할
수 있다.** Redis만 붙이면 질문의 절반만 답하는 셈이다.
**2번(평문 확인)의 실행 방법**
```bash
kubectl -n <ns> exec -it deploy/redis -- redis-cli --scan --pattern 'spring:session:*'
kubectl -n <ns> exec -it deploy/redis -- redis-cli GET <key>
```
저장소를 직접 열어 refresh token이 그대로 읽히는지 본다. 읽힌다면
암호화 설계가 필요하고, 그 key 교체 절차는 별도 과제다.
---
## Q4. Edge 인가 범위 — 로드맵에 전혀 없다
이 축을 A층(Keycloak)·B층(앱 세션) 중심으로 잡으면서 **AP4의 인가 범위
질문을 빠뜨렸다.**
**질문이 요구하는 검증**
| 검증 | 실험대 가능 |
|---|---|
| role을 헤더에 담고 **다중 값 구분자·escaping** 확인 | 가능 |
| **헤더 크기 상한** 초과 시 proxy가 자르는가 요청이 거부되는가 | 가능 |
| role 변경 후 **몇 번째 요청부터 반영되는가** | 가능 |
| upstream이 헤더 존재만 보는가 값과 service identity까지 보는가 | 가능 |
**이 실험대에서 특히 잘 맞는 이유**
nginx의 헤더 처리 특성을 이미 실측했다. 질문이 지적한
> Nginx는 client가 보낸 동명 헤더를 merge하지 않고 **덮어쓴다.**
는 2홉 헤더 실험에서 `proxy_set_header X-Forwarded-For $remote_addr`
확인한 그 동작이다. **`X-Auth-Request-*`도 같은 규칙을 따르는지**를 같은
방법으로 검증할 수 있다.
그리고 질문의 제약
> internal token 검사가 controller 한 곳에만 있다. 헤더를 늘리기 전에 이
> 검사를 **공통 경계로 옮겨야** 된다.
는 코드 변경이므로 `backend/`에서 진행한다.
---
## 교정된 실험 순서
기존 로드맵의 순서를 질문의 의존 관계에 맞춰 조정한다.
```
✅ 환경 구축
✅ 2홉 프록시 헤더 계약
──────────────────────────────────────────────────────────
1. Keycloak 멀티노드 클러스터 형성 (선행 인프라)
2. persistent vs volatile 세션 (A층)
3. BFF 저장소 결정 → Q3 ★ Q2 의 선행 조건
4. 다중 인스턴스 운영 → Q1
5. Refresh Token 경쟁 → Q2 ★ 3 이후여야 재현됨
6. Edge 인가 범위 → Q4 ← 새로 추가
7. 장애 주입과 복구 (전 항목 공통)
```
**바뀐 점**
- `refresh-token-concurrency``redis-app-session-store` **뒤로** 이동
- 저장소 결정이 **Redis 도입**이 아니라 **Redis vs JDBC 비교**로 확장
- **Edge 인가 범위(Q4)** 신규 추가
## 브랜치 매핑
| 실험 | 브랜치 | 상태 |
|---|---|---|
| 멀티노드 클러스터 | `feature/keycloak-multinode-cluster-jdbc-ping` | 존재 |
| persistent vs volatile | `feature/keycloak-persistent-vs-volatile-sessions` | 존재 |
| BFF 저장소 (Q3) | `feature/keycloak-redis-app-session-store` | 존재 — **범위 확장 필요** |
| 다중 인스턴스 (Q1) | — | **없음** |
| refresh 경쟁 (Q2) | `feature/keycloak-refresh-token-concurrency` | 존재 |
| Edge 인가 (Q4) | — | **없음** |
| 장애 주입 | `feature/keycloak-failure-injection-recovery` | 존재 |
**두 개를 새로 만들어야 한다.**
```bash
git checkout develop-keycloak-session-store
git checkout -b feature/keycloak-multi-instance-session-operation
git checkout -b feature/keycloak-edge-authorization-scope
```
## 공통 선행 조건 — BFF 구현은 이미 있다
세 질문(Q1·Q2·Q3)이 모두 **BFF를 2 replica로 띄우는 것**을 전제한다.
`develop-keycloak-session-store``bff/`에는 빌드 산출물만 있지만,
**`develop-keycloak-pattern3`에 구현이 완성되어 있다.**
```
bff/Dockerfile
bff/pom.xml
bff/src/main/java/com/example/keycloakpattern/bff/
├ BffApplication.java
├ BffController.java
├ CsrfController.java
├ SecurityConfig.java
└ SpaCsrfTokenRequestHandler.java
bff/src/main/resources/application.yml
bff/src/main/resources/static/{index.html,app.js}
bff/src/test/java/.../BffControllerTest.java
```
→ 새로 구현할 필요가 없다. **AP3 브랜치에서 이 실험대로 가져온다.**
```bash
git checkout develop-keycloak-session-store
git checkout develop-keycloak-pattern3 -- bff/
```
가져온 뒤 확인할 것 — 질문들이 지목한 부분이 코드에 그대로 있는지.
| 확인 | 어디를 볼 것인가 |
|---|---|
| Session 저장소가 in-memory 자동구성인가 | `SecurityConfig.java`, `application.yml`에 Spring Session 설정 부재 |
| `OAuth2AuthorizedClientService`가 in-memory인가 | Bean 정의 부재 → 자동구성 결과 확인 필요 |
| authorized client 조회에 session ID가 없는가 | Spring Security 기본 계약 |
Q3가 "어떤 구현체가 실제로 쓰이는지는 자동구성 결과까지 확인해야 정확히
알 수 있다"고 남긴 미지수를, **기동 후 Bean을 실제로 조회해서** 확정할 수 있다.
```bash
kubectl -n <ns> exec deploy/bff -- \
curl -s localhost:8082/actuator/beans | grep -i authorizedClientService
```
## 참고
| 문서 | 관계 |
|---|---|
| [`session-store-lab-roadmap.md`](session-store-lab-roadmap.md) | 이 문서가 그 순서를 교정한다 |
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | Q1의 우회 경로 제약, Q4의 헤더 덮어쓰기 근거 |
| [`session-lab-operations.md`](session-lab-operations.md) | 실행 도구와 명령 |
| [`four-pattern-tradeoff-matrix.md`](four-pattern-tradeoff-matrix.md) | Q4가 되돌아가는 선택지(BFF)의 비교표 |
File diff suppressed because it is too large Load Diff
+434
View File
@@ -0,0 +1,434 @@
# 실험대 운영 — 도구 · 명령 · 훈련
실험을 돌리는 데 반복해서 쓰는 것들. 개념은
[`session-lab-concepts.md`](session-lab-concepts.md), 계획은
[`session-store-lab-roadmap.md`](session-store-lab-roadmap.md)에 있다.
---
## 1. 관측 도구
### htop
**설치** — 세 대 모두 배포판이 다르다.
```bash
# lab host (Arch)
sudo pacman -S htop
# 게스트 (Debian) — lab host 에서 한 번에
for n in kc-lab-1 kc-lab-2; do
ssh $n 'sudo apt-get update -qq && sudo apt-get install -y -qq htop'
done
```
**lab host에서 htop을 읽는 법 — VM은 프로세스로 보인다**
가장 중요한 관점이다. 게스트는 **호스트 입장에서 `qemu-system-x86_64`
프로세스 하나**다. 그래서 VM의 메모리 사용량이 곧 그 프로세스의 RES다.
```
pid=4677 RSS=3765MB qemu-system-x86 ← kc-lab-1 (할당 3584M)
pid=4770 RSS=2670MB qemu-system-x86 ← kc-lab-2 (할당 2560M)
```
**RSS가 할당량보다 조금 큰 이유**는 QEMU 자체의 에뮬레이션 오버헤드
(장치 모델, 버퍼)가 더해지기 때문이다. 게스트가 터치한 페이지만큼만
RSS로 잡히므로, 게스트가 메모리를 더 쓰면 RSS도 할당 상한까지 올라간다.
**htop 안에서 쓸 키**
| 키 | 동작 | 이 실험대에서 |
|---|---|---|
| `F5` | 트리 뷰 | `libvirtd``qemu-system` 계층 확인 |
| `F4` | 필터 | `qemu`만 보기 |
| `F6` | 정렬 기준 | `PERCENT_MEM`으로 정렬 |
| `M` / `P` | 메모리/CPU 정렬 | |
| `u` | 사용자 필터 | `libvirt-qemu`로 VM만 |
| `H` | 스레드 숨김 | QEMU는 vCPU마다 스레드라 켜두면 지저분하다 |
**게스트 안에서 htop을 읽을 때** 보이는 것은 `k3s`(server 또는 agent),
`containerd`, `containerd-shim`, 그리고 각 파드의 `java` 프로세스다.
Java 힙 상한은 컨테이너 limit의 70%(`-XX:MaxRAMPercentage=70`)이므로
512Mi limit이면 약 358Mi다.
### k9s
설치는 되어 있다. 별도 구성 없이 `~/.kube/config`를 읽는다.
```bash
k9s
```
| 키 | 동작 |
|---|---|
| `:` | 명령 모드 — `:pods` `:svc` `:ing` `:nodes` `:events` |
| `0` | 전체 네임스페이스 |
| `/` | 필터 |
| `l` | 로그 |
| `d` | describe |
| `y` | YAML |
| `s` | 파드 안 셸 |
| `Ctrl+d` | 파드 삭제 ← **장애 주입에 씀** |
| `esc` / `q` | 뒤로 / 종료 |
`~/.config/k9s/config.yaml``refreshRate`를 2초로 낮추면 노드를 죽였을 때
파드 재배치가 실시간으로 보인다.
### kubectl top
k3s가 metrics-server를 기본 배포하므로 바로 쓸 수 있다.
```bash
kubectl top nodes
kubectl -n header-lab top pods
```
**htop과 보는 층이 다르다.**
| | 보는 것 |
|---|---|
| `htop` (lab host) | VM 프로세스 = 게스트 전체 |
| `htop` (게스트) | 게스트 안의 프로세스 |
| `kubectl top` | 파드·노드 단위, 클러스터 관점 |
### 상태 점검 스크립트
```bash
./deploy/lab/scripts/verify-lab.sh # lab host 에서
./deploy/lab/scripts/measure-proxy-headers.sh # 어디서든
```
`verify-lab.sh`는 게스트·k3s·nginx·인증서·공개 진입점을 한 번에 확인하고
`lab is healthy`를 출력한다. **`404`가 성공 신호**다.
---
## 2. 자원 실측과 예산
**2026-09-03 기준, Keycloak 배포 전**
### 호스트 여유와 게스트 여유는 다르다
가장 오해하기 쉬운 지점이다. 호스트만 보면 절망적으로 보인다.
```
lab host 총 7628MB · 사용 7189MB · 여유 439MB
├ qemu #1 RSS 3765MB kc-lab-1 (할당 3584MB) → 상한 도달
└ qemu #2 RSS 2633MB kc-lab-2 (할당 2560MB) → 상한 도달
```
그런데 게스트 안을 보면 여유가 있다.
```
kc-lab-1 총 3423MB · used 1464 · buff/cache 2020 · available 1959MB
kc-lab-2 총 2480MB · used 580 · buff/cache 1714 · available 1899MB
─────────────────
게스트 여유 합계 약 3.8GB
```
**왜 이런가** — QEMU의 RSS는 게스트가 **터치한 페이지**만큼이다. 게스트가
메모리를 페이지 캐시로 다 채우면 QEMU RSS도 할당 상한까지 올라간다.
지금이 그 상태다.
**그래서 앞으로 워크로드를 올려도 호스트 압박은 늘지 않는다.** 게스트 안의
페이지 캐시가 밀려날 뿐이다. **QEMU RSS는 이미 천장이다.**
```
확인 방법:
ps -eo rss,args --sort=-rss | grep '[q]emu-system' # 호스트에서 본 VM
ssh kc-lab-1 free -m # 게스트 안 실제
kubectl top nodes # working set
```
세 값이 다른 것을 보는 것이 이 실험대의 메모리 감각이다.
### 배포 예산
| 워크로드 | 예상 | 배치 |
|---|---|---|
| Keycloak × 2 | 각 700Mi | 노드당 1개 |
| PostgreSQL | 300Mi | kc-lab-1 |
| Redis | 100Mi | kc-lab-2 |
| BFF × 2 | 각 400Mi | 노드당 1개 |
| **합계** | **약 2600Mi** | |
**게스트 여유 3.8GB 중 2.6GB → 가능하다.** 다만 여기에
Prometheus/Grafana(로드맵 10번 관측성)를 얹을 여유는 없다.
### 대응 — 비용이 없는 것부터
**1. 끝난 실험은 지운다**
```bash
kubectl delete ns header-lab # 파드 2개 × 150Mi 회수
```
증거는 `docs/evidence/`에 남아 있으므로 워크로드를 유지할 이유가 없다.
**2. Keycloak 힙을 명시적으로 제한한다**
Keycloak은 기본값이 넉넉해 그냥 두면 1GB를 넘긴다.
```yaml
env:
- name: JAVA_OPTS_KC_HEAP
value: "-Xms256m -Xmx512m"
resources:
limits:
memory: 768Mi
```
**모든 워크로드에 `resources.limits`를 반드시 건다.** 안 걸면 한 파드가
게스트 메모리를 다 먹고 다른 파드까지 OOMKilled된다.
**3. 실험을 순차로 돌린다 — 동시에 다 띄우지 않는다**
```
A층(Keycloak + PostgreSQL) → 결과 기록 → 정리
B층(BFF + Redis) → 결과 기록 → 정리
관측성(Prometheus) → 필요할 때만
```
절약책이 아니라 **정상적인 실험 운영 방식**이다. 동시에 띄우면 변수가
섞여서 원인 분리가 어려워진다.
### swap은 쓰지 않는다
호스트에는 8GB swap이 있지만 **게스트에는 0MB이며, 그것이 맞다.**
| 이유 | |
|---|---|
| k3s/kubelet | 기본적으로 swap 을 거부한다 |
| 성능 | 호스트 swap 으로 QEMU 페이지가 밀리면 급락한다 |
| **측정 오염** | 이 실험대는 **타이밍**(refresh 경쟁, Infinispan 복제 지연)을 잰다. swap 이 끼면 측정이 통째로 무의미해진다 |
### 근본 해결 — 메모리 증설
남은 실험이 10개이고 관측성까지 하려면 증설이 가장 확실하다.
```bash
sudo pacman -S dmidecode
sudo dmidecode -t memory | grep -E "Maximum Capacity|Number Of Devices|Size:|Locator:|Type:|Speed:"
```
| 슬롯 상태 | 조치 |
|---|---|
| 2슬롯 중 1개만 사용 | 동일 규격 8GB 추가 → 16GB |
| 온보드 8GB + 슬롯 1개 | 16GB 추가 → 24GB |
| 2슬롯 모두 사용 | 8GB × 2 를 16GB × 2 로 교체 |
i5-1135G7(Tiger Lake)은 DDR4-3200 SO-DIMM을 쓰며 최대 용량은 보드마다
다르므로 `Maximum Capacity` 값을 확인한다. **비용 대비 효과가 가장 크다**
증설하면 Prometheus·Grafana·BFF 2 replica를 동시에 띄우고도 남는다.
## 3. 자주 쓰는 명령
### VM (lab host, `LIBVIRT_DEFAULT_URI=qemu:///system`)
```bash
virsh list --all # 상태
virsh domstate kc-lab-1
virsh domblklist kc-lab-1 # 붙은 디스크
virsh net-dhcp-leases default # 게스트 IP
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 화면 (PNG 로 저장됨)
virsh send-key kc-lab-1 --codeset linux KEY_ENTER
virsh destroy kc-lab-1 # 전원 강제 차단 = 노드 상실
virsh start kc-lab-1 # 재기동
virsh shutdown kc-lab-1 # ACPI 정상 종료
```
**`destroy`는 파일을 지우지 않는다.** 전원 코드를 뽑는 것에 해당한다.
정의와 디스크를 지우는 것은 `undefine`이다.
### 클러스터
```bash
kubectl get nodes -o wide
kubectl get pods -A -o wide
kubectl -n <ns> logs -f deployment/<name>
kubectl -n <ns> describe pod <pod>
kubectl -n <ns> rollout status deployment/<name>
kubectl -n <ns> rollout restart deployment/<name>
kubectl -n <ns> rollout undo deployment/<name> # 직전 버전으로
# 설정 스위치 껐다 켜기 — 실험의 기본 동작
kubectl -n <ns> set env deployment/<name> KEY=VALUE
# 임시 파드로 클러스터 안에서 테스트
kubectl -n <ns> run t --rm -i --restart=Never --image=curlimages/curl:8.11.1 -- \
curl -s http://<svc>:<port>/path
```
### 이미지 반입
k3s는 containerd를 쓰고 레지스트리가 없다. **자체 빌드 이미지는 매번 이
경로를 탄다.**
```bash
# 워크스테이션에서
docker build -t keycloak-pattern-api:lab backend
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
# 확인
ssh test-server "ssh kc-lab-1 'sudo k3s ctr images ls -q | grep keycloak-pattern'"
```
**두 노드 모두**에 넣는다. 스케줄러가 어디에 배치할지 모른다.
매니페스트는 `imagePullPolicy: Never`여야 한다.
`ctr`이 아니라 **`k3s ctr`** 이다. 시스템에 별도 `ctr`이 있으면 다른 소켓을
보게 되어 "성공했는데 파드는 못 찾는" 상태가 된다.
### 저장소·브랜치
```bash
# 워크스테이션 — 작성·커밋
git add -A && git commit -m "..." && git push origin develop-keycloak-session-store
# lab host — 받기만 (읽기 전용으로 운용)
cd ~/workspace/keycloak-pattern && git pull
# 실험별 브랜치 이동
git checkout feature/keycloak-multinode-cluster-jdbc-ping
```
**lab host의 저장소는 읽기 전용으로 쓴다.** 거기서 편집하면 드리프트가
생긴다 — nginx 설정에서 실제로 겪었다
([`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) 9절).
### 호스트 nginx
```bash
sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab
sudo nginx -t && sudo systemctl reload nginx
sudo nginx -T | grep -n 'upstream\|server_name' # 최종 병합 설정
```
**`nginx -t`를 통과한 뒤에만 reload한다.** 깨진 설정으로 reload하면 서비스가
내려간다. `-T`(대문자)는 include까지 펼친 최종 설정을 출력하므로
"파일을 고쳤는데 반영이 안 된다" 상황의 확인 수단이다.
---
## 4. 손에 익혀야 할 훈련
개념은 알지만 직접 해보지 않은 조작들. **남은 실험 5개 중 4개가 훈련 3
위에 서 있다.**
### 훈련 1 — 이미지 반입
앞으로 BFF·token-mediator를 올릴 때마다 반복된다.
```bash
docker build -t keycloak-pattern-api:lab backend
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
ssh test-server "ssh kc-lab-1 'sudo k3s ctr images ls -q | grep keycloak-pattern'"
```
### 훈련 2 — 설정 스위치 껐다 켜기
**설정을 바꿔가며 비교하는 것이 이 실험대의 본체**다.
```bash
kubectl -n header-lab set env deployment/echo SERVER_FORWARD_HEADERS_STRATEGY=none
kubectl -n header-lab rollout status deployment/echo
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -E '"scheme"|"secure"'
# → "http" / false 로 바뀐다
kubectl -n header-lab set env deployment/echo SERVER_FORWARD_HEADERS_STRATEGY=native
kubectl -n header-lab rollout status deployment/echo
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -E '"scheme"|"secure"'
# → "https" / true 로 돌아온다
```
### 훈련 3 — 노드를 죽였다 살리기
**가장 중요하다.** 장애 실험의 전제 조작이다.
```bash
# 죽이기 — 전원 차단에 해당
virsh destroy kc-lab-2
# 관찰 (NotReady 로 바뀌는 데 40초 안팎)
kubectl get nodes
kubectl -n header-lab get pods -o wide
kubectl get events -A --sort-by=.lastTimestamp | tail -20
# 서비스가 살아있는지
curl -sI https://app1.hyeonworks.com/api/echo | head -1
# 되살리기
virsh start kc-lab-2
kubectl get nodes # Ready 복귀
kubectl -n header-lab get pods -o wide
```
**이번에 특별히 확인할 것** — 현재 **Traefik은 replica 1**이고
`kc-lab-2`에 있다. 그 노드를 죽이면 **진입점 자체가 사라지는지**,
아니면 다른 노드로 재배치되어 복구되는지 관찰한다.
| 관찰 | 의미 |
|---|---|
| `curl`이 계속 200 | svclb가 남은 노드로 흘려보냄 + Traefik 재배치 성공 |
| `curl`이 실패했다가 복구 | 재배치에 걸린 시간만큼 다운타임 |
| `curl`이 계속 실패 | Traefik replica를 2로 늘려야 한다 |
이 결과에 따라 **Keycloak 배포 전에 Traefik replica를 조정할지** 결정한다.
```bash
# 필요하다면
kubectl -n kube-system scale deployment/traefik --replicas=2
```
**망가져도 된다.** `virt-install` 한 줄로 재생성되며
([`deploy/lab/README.md`](../deploy/lab/README.md) 게스트 재생성),
그러라고 만든 실험대다.
---
## 5. 실행 주체 감사 (2026-09-03)
무엇을 직접 했고 무엇을 대행했는지의 기록. **대행한 항목은 언젠가 직접
해야 하는 것들**이다.
### 직접 수행
VM 생성 · cloud-init 작성 · SSH 키와 config · k3s server/agent 설치 ·
kubeconfig 구성 · 호스트 nginx 설치와 설정 · certbot DNS-01 인증서 발급 ·
Cloudflare DNS 레코드 · git 저장소 클론과 브랜치 · `echo.yaml` 최초 배포 ·
nginx `X-Forwarded-Proto` 수정
### 대행 수행
| 작업 | 언제 다시 필요한가 |
|---|---|
| 이미지 빌드 → 노드 반입 (2회차 이후) | 자체 이미지를 올릴 때마다 |
| `kubectl apply` (traefik HelmChartConfig, NetworkPolicy) | 모든 배포 |
| `kubectl set env` / `rollout restart` | 설정 비교 실험마다 |
| HelmChartConfig 재조정 대기와 파드 확인 | Traefik 설정 변경 시 |
| git 브랜치 생성 · 커밋 · push | 실험마다 |
| Playwright 증거 수집 | 브라우저 관점이 필요할 때마다 |
| `kubectl run` 임시 파드 위조 테스트 | 클러스터 내부 검증 시 |
| 설정 되돌렸다 복구 (단계별 스크린샷) | 비교 증거를 만들 때 |
### 아직 한 번도 하지 않은 것
| 항목 | 필요해지는 시점 |
|---|---|
| **`virsh destroy` + 재생성** | **장애 실험 전부** |
| `rebuild-seed.sh` | cloud-init 을 바꿀 때 |
| k9s 실사용 | 장애 중 상태 관찰 |
| `kubectl delete ns` | 실험 정리, 메모리 회수 |
### 만들었지만 미검증이었던 것 → 2026-09-03 확인 완료
| 스크립트 | 결과 |
|---|---|
| `verify-lab.sh` | 정상 — `lab is healthy` |
| `measure-proxy-headers.sh` | 정상 — 4개 항목 모두 출력 |
+388
View File
@@ -0,0 +1,388 @@
# 세션 저장소 실험 축 — 계획과 진행
`develop-keycloak-session-store` 브랜치가 담당하는 작업의 전체 지도.
**무엇이 끝났고 무엇이 남았는지**를 여기서 추적한다.
## 왜 별도 축인가
네 인증 패턴(AP1~AP4)은 **브라우저와 토큰의 관계**를 비교한다. 이 축은
그것과 직교하는 질문을 다룬다 — **세션과 토큰이 서버 쪽 어디에 저장되고,
그 저장소가 죽으면 무슨 일이 벌어지는가.**
초기 검토에서 전제 하나가 교정됐다. **Keycloak은 Redis를 세션 저장소로
지원하지 않는다.** 그래서 이 축은 두 계층으로 갈린다.
| 계층 | 저장소 | 해당 패턴 |
|---|---|---|
| **A. Keycloak 자체** | 임베디드 Infinispan + PostgreSQL | 네 패턴 공통 |
| **B. 애플리케이션 세션** | **Redis** | AP2 / AP3 / AP4 |
A층은 네 패턴과 무관하게 공통이고, B층은 서버 세션을 갖는 세 패턴에만
존재한다. 그래서 이 축을 AP1~AP4 어디에도 넣지 않고 별도로 둔다.
## 기존 브랜치 레지스트리에 넣지 않는 이유
`docs/keycloak-branch-manifest.tsv``scripts/audit-keycloak-branches.sh`
**정확히 39개** 브랜치를 강제하고, 각 브랜치가 외부 노트 파일과 1:1로
대응하는지 검사한다.
```sh
if [ "$expected_count" -ne 39 ]; then
echo "manifest must contain exactly 39 Keycloak branches" >&2
```
이 축의 브랜치를 manifest에 추가하면 그 감사가 깨진다. 원래 39개는
**완결된 인벤토리**이므로 건드리지 않고, 이 축은 이 문서로 추적한다.
## 진행 상황
```
✅ 환경 구축
✅ 2홉 프록시 헤더 계약
──────────────────────────────────────────────────────────
A층 — Keycloak 자체 (공개 질문에 없는 영역)
1. 멀티노드 클러스터 형성
2. persistent vs volatile 세션
B층 — 애플리케이션 세션 (공개 열린 질문 대응)
3. BFF 저장소 결정 → Q3
4. 다중 인스턴스 운영 → Q1
5. Refresh Token 경쟁 → Q2 ★ 3 이후여야 재현됨
6. Edge 인가 범위 → Q4
공통 — 운영 역량
7. 장애 주입과 복구
8. 백업과 복구 리허설
9. 버전 업그레이드
10. 관측성
11. 비밀 관리
12. 인증서 갱신 실측
```
**순서 근거는 [`open-questions-coverage.md`](open-questions-coverage.md)에 있다.**
특히 5번(refresh 경쟁)은 3번(저장소 공유) 이후여야 **재현 자체가 성립한다.**
### ✅ 완료 — 환경 구축
2노드 k3s 실험대. 상세는 [`deploy/lab/README.md`](../deploy/lab/README.md),
개념은 [`session-lab-concepts.md`](session-lab-concepts.md),
운영 도구는 [`session-lab-operations.md`](session-lab-operations.md).
```
브라우저 ─https─▶ 호스트 nginx(TLS 종료) ─▶ Traefik ─▶ Pod
kc-lab-1 / kc-lab-2
```
**왜 Docker Compose가 아닌가** — 한 커널에서 "노드 죽이기"는 프로세스
죽이기일 뿐이다. 노드 간 방화벽·비대칭 파티션·진짜 노드 상실은 **독립된
커널 두 개**가 있어야 성립한다.
### ✅ 완료 — 2홉 프록시 헤더 계약
[`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) ·
증거 [`evidence/two-hop-proxy-headers/`](evidence/two-hop-proxy-headers/)
**확인한 것**`docs/reverse-proxy-headers.md`의 1홉 계약이 2홉에서
성립하지 않는다. 원인이 둘이었고 스위치가 셋이었다.
| 스위치 | 하는 일 |
|---|---|
| nginx `proxy_set_header` | 헤더를 **만든다** |
| Traefik `forwardedHeaders.trustedIPs` | 받은 헤더를 **전달할지 버릴지** |
| 앱 `forward-headers-strategy` / `KC_PROXY_HEADERS` | 도착한 헤더를 **읽을지** |
**발견한 취약점 — 헤더 신뢰를 켠 순간 위조가 통했다**
세 스위치를 다 켜고 나니 새 구멍이 생겼다. Traefik을 거치지 않고 앱에 직접
요청하면서 헤더를 붙이자 **그대로 통과했다.**
```
serverName evil.example.com ← 위조 성공
remoteAddr 1.2.3.4 ← 위조 성공
requestUrl https://evil.example.com/api/echo
```
두 신뢰 설정이 모두 **"대역"을 믿기 때문**이다. IP로는 Traefik을 특정할 수
없다 — 파드 IP가 재시작마다 바뀐다(측정 중 `10.42.0.8``10.42.1.12`로,
노드까지 옮겨갔다). **NetworkPolicy는 IP가 아니라 라벨로 지정**하므로 이를
닫는다.
**"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.**
이 교훈이 6번(Edge 인가)에서 결정적이 된다 — `X-Auth-Request-*`가 위조되면
그것은 쿠키 속성이 아니라 **신원 위조**다.
**이 결과가 뒤에 미치는 영향** — Keycloak을 올릴 때
`KC_PROXY_HEADERS=xforwarded``KC_HOSTNAME=https://auth.hyeonworks.com`
근거를 갖고 넣을 수 있고, 로그인이 깨져도 프록시 원인은 배제하고 볼 수 있다.
---
## A층 — Keycloak 자체
**공개 열린 질문 네 개는 전부 애플리케이션 계층(B층)이다.** Keycloak 자체의
클러스터링과 세션 저장을 다루는 질문은 아직 등록되어 있지 않다.
**그러나 이 두 항목이 이 실험대의 존재 이유에 더 가깝다.** B층 실험은 replica
2개면 되므로 Docker Compose로도 상당 부분 가능하지만, A층은 **독립된 커널
두 개**를 요구한다. 실험 후 결과를 **새 열린 질문으로 등록할 후보**다.
### 1. Keycloak 멀티노드 클러스터 형성
브랜치: `feature/keycloak-multinode-cluster-jdbc-ping`
**확인할 것**
- Keycloak 2개 파드가 **서로를 발견해 하나의 클러스터를 이루는가**
- Keycloak 26의 기본 디스커버리는 `jdbc-ping` — PostgreSQL의 `JGROUPS_PING`
테이블로 서로를 찾는다. 멀티캐스트가 필요 없다
- **TCP 7800이 막히면 무엇이 먼저 보이는가** — 디스커버리는 DB로 하지만 실제
클러스터 통신은 7800이다. 막으면 "DB에는 서로 등록되는데 클러스터가 안 붙는"
증상이 나온다. **단일 노드에서는 재현 불가능한 고장**이며, 이 실험대를
2노드로 만든 이유 중 하나다
**주의** — Traefik이 replica 1이므로 그 파드가 있는 노드를 죽이면 진입점
자체가 사라질 수 있다. **훈련 3([`session-lab-operations.md`](session-lab-operations.md))에서
먼저 확인**하고 replica 조정 여부를 정한다.
**설정 근거** — 헤더 계약에서 확정한 값을 그대로 쓴다.
```
KC_HOSTNAME=https://auth.hyeonworks.com
KC_HOSTNAME_STRICT=true
KC_PROXY_HEADERS=xforwarded
KC_HTTP_ENABLED=true
```
### 2. persistent vs volatile 세션
브랜치: `feature/keycloak-persistent-vs-volatile-sessions`
**확인할 것**
- Keycloak 26 기본값은 `persistent-user-sessions` — 세션이 **DB가 진실의
원천**이다
- `--features-disabled=persistent-user-sessions`로 volatile 전환 시 비교
- **PostgreSQL을 죽이면** 각각 어떻게 되는가
- **노드 하나를 죽이면** 세션이 살아남는가
- **롤링 배포 시 로그아웃되는가** ← 운영에서 가장 자주 겪는 시나리오이며,
사실상 persistent를 켜는 진짜 이유다
이것이 "세션을 DB에 둘 때 vs 안 둘 때"의 Keycloak 버전이다.
---
## B층 — 애플리케이션 세션
공개 열린 질문 네 개에 대응한다. 각 질문이 요구하는 검증 단계는
[`open-questions-coverage.md`](open-questions-coverage.md)에 항목별로 있다.
**공통 선행 조건** — BFF 2 replica. 구현은 `develop-keycloak-pattern3`
이미 있으므로 가져온다.
```bash
git checkout develop-keycloak-pattern3 -- bff/
```
### 3. BFF 저장소 결정 → [Q3](https://hyeonworks.com/questions/bff-session-authorized-client-store)
브랜치: `feature/keycloak-redis-app-session-store`
**질문의 핵심은 "Redis 도입"이 아니라 "Redis와 JDBC 중 무엇이 맞는가"다.**
PostgreSQL이 이미 있으므로 같은 조건에서 비교할 수 있다.
**Session과 Authorized Client는 조회 키가 다르다.**
| 상태 | 조회 키 |
|---|---|
| Application Session | **session ID** |
| OAuth2AuthorizedClient | **registration 이름 + principal name** |
`session ID`가 없으므로 **같은 사용자의 여러 브라우저가 동일한 authorized
client를 공유**한다. 따라서 **두 저장소를 각각 설계해야 한다.**
검증 5단계 — 로그인 유지·재시작 복구 / **refresh token 평문 여부** /
**session TTL ≠ token 만료** / logout 후 잔여 항목 / 저장소 끊김 시 오류.
### 4. 다중 인스턴스 운영 → [Q1](https://hyeonworks.com/questions/server-session-pattern-multi-instance)
브랜치: `feature/keycloak-multi-instance-session-operation` **(생성 필요)**
검증 5단계 — 다른 인스턴스로 요청 시 200 유지 / 재시작 후 session cookie /
**authorized client 덮어쓰기** / logout 전파 / 만료 어긋남.
**3번(덮어쓰기)이 특히 중요하다.** "Redis만 붙이면 해결"이라는 착각을 깨는
항목이다.
**질문의 제약 하나는 이미 해결법을 안다** — "Resource Server의 8081이 host에도
열려 있어 BFF만 거치도록 강제되지 않았다"는 2홉 실험의 **프록시 우회 경로와
같은 문제**이며, NetworkPolicy 패턴을 그대로 재사용한다.
호스트 nginx의 `ip_hash` 주석을 켜고 끄면 **스티키 유무 비교**까지 같은
구성에서 된다.
### 5. Refresh Token 경쟁 → [Q2](https://hyeonworks.com/questions/refresh-rotation-replica-contention)
브랜치: `feature/keycloak-refresh-token-concurrency`
**★ 3번 이후여야 한다.** 저장소가 process-local이면 두 replica가 같은 refresh
token 항목을 보지 않아 **경쟁 자체가 재현되지 않는다.**
검증 5단계 — 만료 직후 동시 요청 / 이긴 쪽·지는 쪽 응답 / **지는 쪽이 새
token으로 재시도해 성공하는가** / **지는 쪽 사용자 화면** /
**lock 유무를 같은 입력으로 비교**.
**마지막이 결론 기준이다** — *실패가 사용자에게 노출되면 lock, 노출되지 않으면
재시도.*
**제약** — rotation + 재사용 0회는 전제로 고정한다. 그리고 이미 발급된 access
token은 만료 전까지 통하므로 **재현은 access token 만료 직후에 맞춰 실행**한다.
### 6. Edge 인가 범위 → [Q4](https://hyeonworks.com/questions/edge-authorization-scope)
브랜치: `feature/keycloak-edge-authorization-scope` **(생성 필요)**
**확인할 것** — role을 헤더에 담고 **다중 값 구분자·escaping** / **헤더 크기
상한** 초과 시 자르는가 거부하는가 / role 변경이 **몇 번째 요청부터 반영**되는가
/ upstream이 헤더 존재만 보는가 값과 service identity까지 보는가.
2홉 실험에서 확인한 **nginx가 동명 헤더를 merge하지 않고 덮어쓴다**는 동작이
`X-Auth-Request-*`에도 적용되는지 같은 방법으로 검증한다.
질문의 제약 — internal token 검사가 controller 한 곳에만 있어 **공통 경계로
옮겨야** 한다. 코드 변경이므로 `backend/`에서 진행한다.
---
## 공통 — 운영 역량
**여기부터는 "구성했다"가 아니라 "운영해봤다"에 필요한 항목이다.**
백업과 업그레이드는 빠지면 티가 난다.
### 7. 장애 주입과 복구
브랜치: `feature/keycloak-failure-injection-recovery`
| 주입 | 방법 |
|---|---|
| 노드 상실 | `virsh destroy` — 프로세스 kill 이 아닌 진짜 상실 |
| 비대칭 파티션 | 한쪽 게스트의 인바운드만 nftables 로 차단 |
| JGroups 7800 차단 | NetworkPolicy — 운영에서 쓸 방식 그대로 |
| DB 상실 | PostgreSQL 파드 정지 |
| Redis 상실 | Redis 파드 정지 |
| 지연 주입 | 게스트 안에서 `tc netem` — 커널이 분리돼 있어 안전 |
**복구 절차**를 각각 기록한다. 실제 장애의 대부분은 완전 사망이 아니라
**부분 장애**(느려짐, 일부 실패)이므로 netem 지연을 기본값으로 둔다.
### 8. 백업과 복구 리허설
**"백업이 있다"와 "복구해봤다"는 완전히 다르다.**
- `pg_dump`로 realm·세션·JGROUPS_PING 포함 전체 덤프
- **일부러 파괴** — PVC 삭제 또는 DB 드롭
- 덤프에서 복구하고 **로그인이 되는지, 기존 세션이 살아나는지** 확인
- 복구에 걸린 시간을 기록한다 (RTO)
- 백업 시점 이후 데이터가 무엇을 잃는지 확인한다 (RPO)
Redis 쪽은 `appendonly` 유무에 따른 차이를 함께 본다.
### 9. Keycloak 버전 업그레이드
**운영에서 가장 무서운 작업 중 하나다.** realm 마이그레이션과 **DB 스키마
변경이 자동으로 실행**되며, 실패하면 되돌리기 어렵다.
- 현재 26.7.0 → 다음 마이너로 이미지 태그 변경
- **업그레이드 전 백업**을 먼저 확보한다 (8번의 전제)
- 롤링 중 **기존 세션이 유지되는가** (2번의 persistent 설정과 연결된다)
- 스키마 변경 로그를 확인한다
- **롤백이 되는가** — 스키마가 바뀐 뒤에는 이전 버전이 뜨지 않을 수 있다
### 10. 관측성
지금은 `kubectl top`뿐이라 **장애 중 무슨 일이 있었는지 사후 추적이 안 된다.**
- `KC_METRICS_ENABLED=true` + `KC_HEALTH_ENABLED=true`
- Prometheus + Grafana 배포
- 볼 지표 — Infinispan 캐시 항목 수·축출, DB 커넥션 풀 사용률,
로그인 성공/실패율, **클러스터 멤버 수**
- 장애 주입(7번) 중에 **어떤 지표가 먼저 움직이는지** 기록한다
### 11. 비밀 관리
지금 방식대로면 client secret과 DB 비밀번호가 **매니페스트에 평문**으로 들어간다.
- k8s `Secret`으로 분리
- 저장소에는 `.example`만 커밋 (기존 `.env.example` 관례 그대로)
- 평문 Secret은 etcd에 base64로만 저장되므로 실제로는 감춰지지 않는다는 점을
확인한다 — `kubectl get secret -o yaml`로 직접 본다
- SealedSecret 또는 외부 저장소가 필요한 지점을 판단한다
### 12. 인증서 갱신 실측
90일을 기다리지 않고 강제로 겪는다.
```bash
sudo certbot renew --force-renewal
```
- nginx reload 타이밍에 **무중단인가**
- 갱신 중 진행 중이던 요청은 어떻게 되는가
- `certbot-renew.timer`가 실제로 동작하는가 (`--dry-run`이 아니라 실제 갱신)
---
## 스코프에서 제외한 것
### 이 실험대가 재현하지 못하는 것
| 항목 | 이유 |
|---|---|
| 성능·처리량 측정 | 단일 물리 머신의 숫자는 운영에 대해 아무것도 말해주지 않는다 |
| 실제 AZ 간 지연 | 한 박스 안이라 재현 불가. `tc netem` 으로 근사만 |
| ALB 고유 동작 | 자체 스티키 쿠키·60초 idle timeout 은 실물 ALB 가 있어야 한다 |
| PostgreSQL HA | 스코프 폭발. "죽으면 어떻게 되나"까지가 현실적 선 |
| 멀티 사이트 / cross-site Infinispan | 로컬에서 "사이트"가 가짜라 배우는 것이 적다 |
**이 실험대가 검증하는 것은 계약(정합성)이지 성능이 아니다.**
### 구조적으로 줄 수 없는 경험
실험 설계로는 만들 수 없는 것들. **무엇을 겪지 않았는지 아는 것도 기록의
일부다.**
| 없는 것 | 왜 |
|---|---|
| **규모** | 수천 세션에서의 커넥션 풀 고갈, Infinispan 캐시 축출 |
| **시간** | 몇 달 돌면서 드러나는 디스크 참, 로그 누적, 메모리 누수 |
| **다른 사람** | 동시에 만지는 사람, 온콜, 인수인계, "내가 안 바꿨는데 바뀌어 있음" |
| **실제 사용자** | 봇, 오래된 클라이언트, 예측 못 한 사용 패턴 |
| **클라우드 관리형 컴포넌트** | ALB·RDS·ElastiCache의 고유 동작과 **그것들의 장애 모드** |
| **비용** | 운영 판단의 큰 축인데 실험대엔 없다 |
| **보안 사고 대응** | 실제 침해, 토큰 유출 후 회수, 감사 로그 추적 |
**"시간"은 부분적으로 살 수 있다.** 실험이 끝나도 클러스터를 지우지 않고
몇 주 켜둔 채로 두면 인증서가 갱신되고, 로그가 쌓이고, 예상 못 한 것이
죽는다. 실험 설계로는 만들 수 없는 종류의 관찰이다.
### 따라서 말할 수 있는 것과 없는 것
**말할 수 있다** — Keycloak 멀티노드에서 세션과 토큰이 어디에 저장되고 각
저장소가 죽으면 무엇이 어떻게 실패하는지 재현하고 복구했다. 프록시 체인의
헤더 계약을 측정으로 확정했고, 신뢰 경계의 구멍을 실증하고 막았다.
**말하면 안 된다** — "운영해봤다", "대규모 트래픽을 다뤄봤다"
**그 경계를 정확히 구분해 말하는 것 자체가 이 기록의 목적이다.**
## 관련 문서
| 문서 | 내용 |
|---|---|
| [`open-questions-coverage.md`](open-questions-coverage.md) | 공개 열린 질문 4개와의 대조, 순서 근거 |
| [`session-lab-concepts.md`](session-lab-concepts.md) | 등장 개념 전체 (가상화·네트워크·k3s·TLS·패키지) |
| [`session-lab-operations.md`](session-lab-operations.md) | 관측 도구 · 자주 쓰는 명령 · 훈련 · 자원 예산 |
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | 첫 실험의 측정·진단·수정 |
| [`deploy/lab/README.md`](../deploy/lab/README.md) | 실험대 구축·복구 절차 |
| [`four-pattern-tradeoff-matrix.md`](four-pattern-tradeoff-matrix.md) | AP1~AP4 비교. "server session" 행이 B층 대상 |
| [`refresh-token-rotation.md`](refresh-token-rotation.md) | 회전 계약 (1홉·단일 노드 가정) |
| [`reverse-proxy-headers.md`](reverse-proxy-headers.md) | 1홉 헤더 계약 원본 |
+816
View File
@@ -0,0 +1,816 @@
# 2홉 프록시 헤더 계약 — 측정·진단·적용
`docs/reverse-proxy-headers.md`의 계약은 **nginx 한 홉**을 가정하고 쓰였다.
실험대와 운영은 모두 **`nginx → Traefik` 두 홉**이므로 그 계약이 그대로
성립하는지 측정했다. **성립하지 않는다.**
---
## 1. 왜 이것부터 재는가
Keycloak과 그 앞의 애플리케이션이 만드는 값 대부분이 **"원래 요청이
무엇이었나"** 에 의존한다.
| 만들어지는 값 | 의존하는 정보 |
|---|---|
| 토큰의 `iss` 클레임 | 외부 스킴 + 호스트 |
| OAuth2 `redirect_uri` | 외부 스킴 + 호스트 + 포트 |
| 세션 쿠키의 `Secure` 속성 | 외부 스킴 |
| brute-force 탐지·감사 로그 | 클라이언트 IP |
그런데 **TLS는 맨 앞 nginx가 끊는다.** 그 뒤로는 평문 HTTP가 흐르므로,
뒤쪽 구성요소는 원래 요청이 HTTPS였다는 사실을 **오직 `X-Forwarded-*`
헤더로만** 알 수 있다. 이 헤더가 중간에서 사라지거나 바뀌면 위 값이 전부
틀어진다.
Keycloak을 올린 뒤에 로그인이 깨지면 **세션 문제인지 프록시 문제인지 구분할
수 없다.** 그래서 Keycloak 없이 이 계약만 먼저 떼어내 측정했다.
---
## 2. 구조 — 누가 어디에 사는가
### 물리적 배치
```
┌─ test-server (호스트 OS · Arch · 베어메탈) ──────────────────┐
│ │
│ [스위치 1] nginx ← 호스트 OS 의 프로세스 │
│ /etc/nginx/sites-available/keycloak-lab │
│ = deploy/lab/host/nginx-keycloak-lab.conf │
│ │
│ ┌─ kc-lab-1 (VM) ─────────────┐ ┌─ kc-lab-2 (VM) ────────┐ │
│ │ svclb 파드 :80 │ │ svclb 파드 :80 │ │
│ │ ↓ │ │ └────────────────┼──┼─┐
│ │ [스위치 2] Traefik 파드 ◀──┼─┼────────────────────────┼──┼─┘
│ │ 클러스터 전체에 하나뿐 │ │ │ │
│ │ ↓ │ │ │ │
│ │ [스위치 3] 앱 파드 │ │ [스위치 3] 앱 파드 │ │
│ └─────────────────────────────┘ └────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
| # | 무엇 | 사는 곳 | 설정 파일 |
|---|---|---|---|
| 1 | nginx | **호스트 OS의 프로세스** | `deploy/lab/host/nginx-keycloak-lab.conf` |
| 2 | Traefik | **클러스터 안 파드 1개** | `HelmChartConfig` (kube-system) |
| 3 | 앱 | **클러스터 안 파드 N개** | 각 앱의 매니페스트 `env` |
### Traefik은 노드마다 있지 않다
k3s 기본값이 **replica 1**이다. 대신 **svclb**(klipper-lb) DaemonSet이 각
노드의 80/443 hostPort를 열어두고, 받은 트래픽을 **그 하나의 Traefik 파드로**
전달한다.
측정에서 8회 요청의 `x-forwarded-server`가 모두 같은 파드 이름이었던 것이
그 증거다. 이 사실은 **노드 상실 실험에서 변수**가 된다 — Traefik이 있는
노드를 죽이면 다른 노드의 svclb도 보낼 곳을 잃는다.
### "홉"은 노드 수가 아니라 프록시 계층 수다
```
홉 1 홉 2 목적지
호스트 nginx ──▶ Traefik ──▶ 앱 파드
(HTTP 를 봄) (HTTP 를 봄) (HTTP 를 봄)
```
**svclb는 홉으로 세지 않는다.** iptables 수준의 전달이라 HTTP를 아예 보지
않기 때문이다. 다만 SNAT를 하므로 **IP는 바꾼다.**
---
## 3. 측정 장치
`backend``/api/echo`가 **자신에게 실제로 도달한 것**을 그대로 돌려준다.
```
GET https://app1.hyeonworks.com/api/echo
→ { headers, remoteAddr, localAddr, scheme, secure, serverName, serverPort, requestUrl }
```
`scheme` · `secure` · `requestUrl`은 Keycloak이 `iss`와 redirect URL을 만들 때
쓰는 것과 **같은 종류의 값**이다. `localAddr`은 파드 IP이므로 어느 노드가
응답했는지 알려준다.
배포는 `deploy/lab/k8s/echo.yaml`, 실행은
`deploy/lab/scripts/measure-proxy-headers.sh`.
---
## 4. 요청 흐름 — 홉마다 헤더가 어떻게 변하는가
```
┌─ 1. 브라우저 ────────────────────────────────────────────────┐
│ GET /api/echo │
│ Host: app1.hyeonworks.com │
│ 전 구간 TLS 로 암호화 │
└──────────────────────────┬───────────────────────────────────┘
│ tailnet → 100.83.212.4:443
┌─ 2. 호스트 nginx ────────▼───────────────────────────────────┐
│ ★ TLS 종료 — 이 지점부터 평문 HTTP │
│ │
│ 원래 요청 정보를 헤더로 바꿔 붙인다: │
│ Host app1.hyeonworks.com │
│ X-Forwarded-Host app1.hyeonworks.com │
│ X-Forwarded-Proto https ← 원래 스킴 │
│ X-Forwarded-Port 443 │
│ X-Forwarded-For <클라이언트 IP> ($remote_addr 로 덮어씀) │
│ X-Real-IP <클라이언트 IP> │
└──────────────────────────┬───────────────────────────────────┘
│ upstream 라운드로빈
│ 192.168.122.11:80 또는 .12:80
┌─ 3. svclb (klipper-lb) ──▼───────────────────────────────────┐
│ 노드의 hostPort 80 에서 받아 iptables 로 전달 │
│ externalTrafficPolicy: Cluster → SNAT 발생 │
│ │
│ ★ 출발지 IP 가 노드의 flannel 게이트웨이로 바뀐다 │
│ → 클라이언트 IP 1차 소실 │
└──────────────────────────┬───────────────────────────────────┘
│ 10.42.0.8 (Traefik 파드)
┌─ 4. Traefik ─────────────▼───────────────────────────────────┐
│ Ingress 규칙 매칭: host app1.hyeonworks.com, path /api │
│ │
│ ★ forwardedHeaders.trustedIPs 미설정 │
│ → 들어온 X-Forwarded-* 를 신뢰하지 않고 │
│ 자기가 받은 연결을 기준으로 다시 쓴다 │
│ │
│ X-Forwarded-Proto https → http 자기가 받은 게 평문이므로 │
│ X-Forwarded-Port 443 → 80 │
│ X-Forwarded-For 실IP → 10.42.1.0 ← 2차 소실 │
│ X-Real-IP 실IP → 10.42.1.0 │
│ X-Forwarded-Host 유지 │
│ X-Forwarded-Server traefik-... 자기 이름 추가 │
└──────────────────────────┬───────────────────────────────────┘
│ Service → 파드
┌─ 5. 애플리케이션 ────────▼───────────────────────────────────┐
│ Spring: forward-headers-strategy = none │
│ → forwarded 헤더를 해석하지 않고 TCP 연결 그대로 보고 │
│ │
│ scheme http │
│ secure false │
│ requestUrl http://app1.hyeonworks.com/api/echo │
└──────────────────────────────────────────────────────────────┘
```
---
## 5. 측정 결과
| 헤더 | nginx 가 보낸 값 | 앱에 도달한 값 | |
|---|---|---|---|
| `X-Forwarded-Proto` | `https` | **`http`** | 덮어써짐 |
| `X-Forwarded-Port` | `443` | **`80`** | 덮어써짐 |
| `X-Forwarded-For` | 클라이언트 IP | **`10.42.1.0`** | 소실 |
| `X-Real-IP` | 클라이언트 IP | **`10.42.1.0`** | 소실 |
| `X-Forwarded-Host` | `app1.hyeonworks.com` | `app1.hyeonworks.com` | 유지 |
앱이 최종적으로 보는 값:
```
scheme http
secure false
requestUrl http://app1.hyeonworks.com/api/echo
```
**위조 테스트** — 클라이언트가 `X-Forwarded-For: 1.2.3.4`,
`X-Forwarded-Host: evil.example.com`을 직접 붙여 요청했으나 **앱에 흔적이
도달하지 않았다.** 다만 이는 nginx가 막아서가 아니라 **Traefik이 전부
덮어썼기 때문**이다. 신뢰 경계는 결과적으로 작동하지만, 그 대가로 정당한
값까지 함께 버려진다.
**파드 분배** — 8회 요청이 두 파드(`10.42.0.9`, `10.42.1.3`)에 정확히 번갈아
도달했다. nginx upstream 라운드로빈과 Service 분배가 모두 작동한다.
---
## 5-1. 대조 실험 — 원인이 둘임을 분리한다
측정값만으로는 "누가 값을 바꿨는지" 알 수 없다. nginx를 우회해 Traefik에
직접 요청하여 원인을 분리했다.
```bash
# test-server 에서, nginx 를 거치지 않고 노드의 Traefik 에 직접
curl -s http://192.168.122.11/api/echo \
-H 'Host: app1.hyeonworks.com' \
-H 'X-Forwarded-Proto: https' \
-H 'X-Forwarded-Port: 443' \
-H 'X-Forwarded-For: 203.0.113.7'
```
| | 보낸 값 | 도달한 값 |
|---|---|---|
| `X-Forwarded-Proto` | `https` | **`http`** |
| `X-Forwarded-Port` | `443` | **`80`** |
| `X-Forwarded-For` | `203.0.113.7` | **`10.42.0.1`** |
**올바른 헤더를 명시했는데도 전부 재작성됐다.** Traefik의 덮어쓰기가
독립적으로 증명된다.
그리고 이 과정에서 **두 번째 원인**이 드러났다.
### 원인 A — nginx가 애초에 틀린 값을 보내고 있다
`/etc/nginx/sites-available/keycloak-lab`의 443 서버 블록:
```nginx
listen 443 ssl default_server;
...
proxy_set_header X-Forwarded-Proto http; # ← https 여야 한다
proxy_set_header X-Forwarded-Port 80; # ← 443 이어야 한다
```
**TLS를 종료하는 서버가 "원래 요청은 평문이었다"고 알리고 있다.**
HTTP 전용으로 먼저 세운 뒤 TLS를 얹는 과정에서 이 두 줄을 함께 바꾸지 않아
남은 값이다.
문법 오류가 아니므로 `nginx -t`도 통과하고 **아무 경고 없이 잘못된 값이
전파된다.** 이런 종류의 실수는 측정 없이는 드러나지 않는다.
### 원인 B — Traefik이 올바른 값이 와도 덮어쓴다
위 대조 실험이 보여준 것이다. `forwardedHeaders.trustedIPs` 미설정.
**두 원인은 직렬로 걸려 있다. A만 고쳐도 B 때문에 해결되지 않는다.**
> 증거 원자료: `docs/evidence/two-hop-proxy-headers/`
---
## 6. 원인 — 독립된 스위치 세 개
이 사슬에는 **각각 따로 켜야 하는 스위치가 세 개** 있다.
**하나만 꺼져 있어도 정보가 끊긴다.**
| # | 위치 | 스위치 | 현재 | 하는 일 | 꺼져 있으면 |
|---|---|---|---|---|---|
| 1 | nginx | `proxy_set_header X-Forwarded-*` | **켜짐** | 헤더를 **만든다** | 헤더가 존재하지 않음 |
| 2 | Traefik | `forwardedHeaders.trustedIPs` | **꺼짐** | 받은 헤더를 **전달할지 버릴지** | **버리고 자기 값으로 재작성** |
| 3 | 앱 | `forward-headers-strategy` 등 | **꺼짐** | 도착한 헤더를 **읽어서 반영할지** | 헤더가 와 있어도 무시 |
지금은 2번에서 끊긴다. 2번을 고쳐도 3번을 켜지 않으면 앱은 여전히 원래
스킴을 모른다.
### 3번을 구체적으로
헤더는 **이미 앱에 도착해 있다.** `/api/echo` 출력에 `x-forwarded-proto: http`
찍혔다. 도착은 했다.
그런데 앱이 `request.getScheme()`을 부르면 `http`가 나온다.
**헤더를 읽지 않고 TCP 연결 자체를 보기 때문**이다.
`forward-headers-strategy=native`를 켜면 Tomcat이 헤더를 읽어서
**요청 객체의 scheme·host·port·remoteAddr를 갈아끼운다.**
즉 3번은 **"도착한 헤더를 진짜로 믿고 내 요청 정보를 바꿔칠까"** 의 스위치다.
### 3번은 앱마다 하나씩이다
1번과 2번은 한 번 켜면 끝이지만 **3번은 새 앱을 올릴 때마다 따로 켜야 한다.**
```
[1] nginx 1개 고정
[2] Traefik 1개 고정
[3] 앱 N개 Keycloak · BFF · oauth2-proxy · backend API …
```
그리고 **빠뜨려도 오류가 나지 않고 조용히 틀린 값으로 동작**한다.
이것이 이 계약을 문서로 고정해두어야 하는 이유다.
### 기본값이 "믿지 않음"인 것은 의도된 설계다
`X-Forwarded-*`는 **누구나 위조할 수 있는 평범한 HTTP 헤더**다.
"누구로부터 온 것을 믿을지"를 명시하지 않으면 **신뢰하지 않는 쪽이 안전**하다.
프레임워크들이 하나같이 기본값을 꺼두는 이유다.
### 네 번째 요인 — `externalTrafficPolicy: Cluster`
svclb가 트래픽을 SNAT하면서 클라이언트 IP가 Traefik에 도달하기 전에 이미
사라진다. 2번을 고치면 `X-Forwarded-For`에 담긴 nginx의 값은 살아나지만,
**TCP 출발지 주소 자체는 복원되지 않는다.**
---
## 7. 앱 스위치를 켜는 방법
### Spring Boot
```yaml
server:
forward-headers-strategy: native # none | native | framework
```
환경변수는 `SERVER_FORWARD_HEADERS_STRATEGY=native`.
| 값 | 구현 | 신뢰 IP 제한 |
|---|---|---|
| `none` (기본) | 무시 | — |
| **`native`** | 서블릿 컨테이너 기능 (Tomcat `RemoteIpValve`) | **있음** |
| `framework` | Spring `ForwardedHeaderFilter` | **없음 — 무조건 신뢰** |
**`native`를 권하는 이유가 마지막 열이다.** Tomcat의 `RemoteIpValve`
`internalProxies` 기본 정규식(`10.x`, `192.168.x`, `172.16~31.x`, `127.x`)에
해당하는 **출발지에서 온 요청만** 헤더를 반영한다. 파드 IP가 `10.42.x`
기본값에 들어간다.
`framework`는 그런 필터가 없어 **누가 보내든 믿는다.**
**켜면 실제로 무슨 일이 일어나나** — 밸브가 요청 객체를 갈아끼운다.
```
X-Forwarded-For → request.getRemoteAddr()
X-Forwarded-Proto → request.getScheme(), isSecure()
X-Forwarded-Port → request.getServerPort()
X-Forwarded-Host → request.getServerName()
→ 그 결과 getRequestURL() 이 외부 URL 로 재구성됨
```
**애플리케이션 코드는 한 줄도 고치지 않는다.** 프레임워크가 요청 정보를
바꿔서 넘겨준다.
### Keycloak
```
KC_PROXY_HEADERS=xforwarded # xforwarded | forwarded
```
| 값 | 읽는 헤더 |
|---|---|
| `xforwarded` | `X-Forwarded-For`, `-Proto`, `-Host`, `-Port` (관례) |
| `forwarded` | RFC 7239의 `Forwarded:` 단일 헤더 |
| 미설정 | 무시 |
**Keycloak은 방어가 두 겹이다.**
| 설정 | 담당 |
|---|---|
| `KC_HOSTNAME=https://auth...` | 스킴·호스트를 **고정** — 헤더와 무관 |
| `KC_PROXY_HEADERS=xforwarded` | **클라이언트 IP** 등 나머지를 헤더에서 |
그래서 `iss``KC_HOSTNAME`만으로도 살아난다. 하지만 brute-force 탐지와
감사 로그의 IP는 `KC_PROXY_HEADERS`가 있어야 맞는다.
> 예전 `KC_PROXY=edge` 옵션은 Keycloak 24에서 deprecated 되고
> `KC_PROXY_HEADERS`로 대체됐다. 오래된 예제 참고 시 주의.
### oauth2-proxy
```
--reverse-proxy=true # 또는 OAUTH2_PROXY_REVERSE_PROXY=true
```
신뢰 IP 제한 기능이 없어 **무조건 신뢰**한다.
### 뒤쪽에 nginx가 있는 경우
```nginx
set_real_ip_from 10.42.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
```
신뢰 IP 지정이 **필수**다. `set_real_ip_from` 없이는 동작하지 않는다.
### 공통 원리
어느 프레임워크든 결국 **두 가지를 정하는 일**이다.
1. **어떤 헤더 형식을 읽을지**`X-Forwarded-*` vs RFC 7239 `Forwarded`
2. **누구로부터 온 것을 믿을지** — 신뢰 프록시 IP 목록
두 번째가 있는 구현이 안전하다. Spring `native`와 nginx `real_ip`는 있고,
Spring `framework`와 oauth2-proxy는 없다.
### 켤 때 반드시 같이 봐야 하는 것
**앱에 프록시를 거치지 않고 직접 도달할 경로가 있으면 안 된다.**
헤더 신뢰를 켠 상태에서 공격자가 앱에 직접 요청하며
`X-Forwarded-Proto: https`를 붙이면, 앱은 그걸 믿고 **`Secure` 쿠키를
발급하거나 IP 기반 제한을 우회**당한다.
쿠버네티스에서는 Service ClusterIP로 파드에 직접 접근할 수 있으므로,
**NetworkPolicy로 Traefik에서 오는 트래픽만 허용**하는 것이 정석이다.
이 클러스터는 kube-router 내장 컨트롤러가 있어 적용 가능하다.
**"헤더를 믿는다"는 결정과 "그 앞에 반드시 프록시가 있다"는 보장은 한 쌍이다.**
한쪽만 하면 구멍이 된다.
---
## 8. 영향 — 패턴별
| 패턴 | 의존도 | 무엇이 깨지나 |
|---|---|---|
| AP1 SPA direct | 낮음 | 브라우저가 Keycloak에 직접 감. Keycloak 자체만 필요 |
| AP2 token mediator | 중간 | mediator의 redirect URI |
| **AP3 BFF** | **높음** | **로그인 자체가 실패**, 세션 쿠키 `Secure` 유실 |
| **AP4 edge auth** | **결정적** | **인증 결과가 헤더로 전달됨** |
### AP3(BFF)에서 왜 중요한가
**흔한 오해 — "BFF가 넘기는 헤더는 세션 쿠키뿐인데?"**
쿠키를 **전달하는 것**은 문제가 아니다. 프록시는 `Cookie:` 헤더를 그대로
통과시킨다. 문제는 **쿠키와 URL을 만들 때**이고, 그 재료가 `X-Forwarded-*`다.
**(1) OAuth2 `redirect_uri` 생성 — 가장 먼저 터진다**
BFF는 Spring Security의 `oauth2Login`을 쓴다. Keycloak으로 사용자를 보낼 때
`redirect_uri` 파라미터를 **현재 요청 URL로부터** 만든다.
```
redirect_uri = {scheme}://{serverName}:{serverPort}/login/oauth2/code/keycloak
↑ request.getScheme() 에서 온다
```
`scheme=http`면 이렇게 나간다.
```
redirect_uri=http://app1.hyeonworks.com/login/oauth2/code/keycloak
```
그러면 두 가지 중 하나가 벌어진다.
- Keycloak 클라이언트에 `https://...`만 등록돼 있으면
**`invalid_redirect_uri` 오류로 로그인 거부**
- 실수로 `http://...`도 등록해뒀다면
→ 브라우저가 https 페이지에서 http로 리다이렉트 →
**혼합 콘텐츠 차단 또는 세션 쿠키 유실**
**세션 쿠키 문제보다 먼저, 로그인 자체가 안 된다.**
**(2) 세션 쿠키의 `Secure` 속성**
서블릿 컨테이너는 `request.isSecure()`를 보고 `Set-Cookie``Secure`
붙일지 정한다. `isSecure()``false`**`Secure` 없는 세션 쿠키**가 나간다.
| 결과 | 내용 |
|---|---|
| 평문 전송 위험 | 그 쿠키는 http 요청에도 실려 나간다. 중간자가 세션을 탈취할 수 있다 |
| **`SameSite=None` 사용 불가** | 브라우저는 `Secure` 없는 `SameSite=None` 쿠키를 **거부**한다 |
두 번째가 AP3의 학습 주제와 정면으로 부딪힌다. AP3는 `oauth2Login` 세션과
**CSRF·SameSite 방어**가 핵심인데, `Secure`가 없으면 `SameSite` 설계
선택지가 통째로 사라진다.
**(3) 로그아웃 `post_logout_redirect_uri`**
같은 원리로 http가 박히고, Keycloak에 등록된 값과 불일치해 거부된다.
**정리하면** — BFF에서 헤더 계약이 중요한 이유는 쿠키를 *전달*하기 때문이
아니라, **쿠키와 OAuth2 URL을 *생성*하는 재료이기 때문**이다.
### AP4(edge auth)에서 왜 결정적인가
AP4는 **헤더 신뢰가 패턴의 존재 이유 자체**다.
oauth2-proxy는 인증을 끝내고 **결과를 헤더로 downstream에 넘긴다.**
```
X-Auth-Request-User
X-Auth-Request-Email
X-Auth-Request-Groups
X-Auth-Request-Access-Token
```
downstream 앱은 **이 헤더를 믿고 "누가 로그인했는지"를 판단**한다. 토큰을
직접 검증하지 않는다. 그것이 AP4가 "기존 upstream을 수정하지 않고 경계에서
일괄 인증"할 수 있는 이유다.
**그래서 여기서 헤더 신뢰가 무너지면 인증 우회가 된다.**
```
공격자가 직접: X-Auth-Request-User: admin
프록시가 안 덮어쓰면 → downstream 은 admin 으로 인식
```
지금 측정한 `X-Forwarded-*` 문제와 **구조가 완전히 같다.** 헤더 이름과
의미만 다르다.
| 헤더군 | 담는 정보 | 위조되면 |
|---|---|---|
| `X-Forwarded-*` | 원래 요청이 어땠나 | 쿠키 속성·URL이 틀어짐 |
| `X-Auth-Request-*` | **누가 인증됐나** | **신원 위조 = 인증 우회** |
저장소의 `feature/keycloak-header-spoofing-defense` 브랜치
(manifest: `ap4 / locally-verified`)가 이 문제를 다룬다.
**지금 확정하는 2홉 계약이 그 브랜치의 전제**다 — 1홉 가정으로 검증된
방어가 2홉에서도 유효한지 다시 확인해야 한다.
---
## 9. 해결 — 어느 파일의 무엇을 어떻게 고치는가
세 곳을 순서대로 고쳤다. **각 단계마다 측정하여 어느 스위치가 무엇을
담당하는지 데이터로 확인했다.**
### A. nginx — 원래 스킴을 사실대로 알린다
| | |
|---|---|
| 저장소 파일 | `deploy/lab/host/nginx-keycloak-lab.conf` |
| 서버 배포 위치 | `/etc/nginx/sites-available/keycloak-lab` |
| 활성화 | `/etc/nginx/sites-enabled/keycloak-lab` 심볼릭 링크 |
`server { listen 443 ssl ... }` 블록의 `location /` 안에서 두 줄을 고친다.
```diff
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
- proxy_set_header X-Forwarded-Proto http;
- proxy_set_header X-Forwarded-Port 80;
+ proxy_set_header X-Forwarded-Proto https;
+ proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
```
**`listen 443 ssl``proxy_pass http://`는 건드리지 않는다.**
앞에서 HTTPS로 받고 뒤로 평문으로 보내는 것은 의도된 설계다.
고치는 것은 **뒤로 보낼 때 붙이는 라벨**뿐이다.
배포:
```bash
cd ~/workspace/keycloak-pattern && git pull
sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab
sudo nginx -t && sudo systemctl reload nginx
grep -n 'X-Forwarded-Proto\|X-Forwarded-Port' /etc/nginx/sites-available/keycloak-lab
```
**손으로 서버 파일을 고치지 않는다.** 저장소에서 단방향으로 복사한다.
이 실수가 발생한 원인 자체가 저장소와 서버의 드리프트였다.
### B. Traefik — nginx의 헤더를 신뢰한다
| | |
|---|---|
| 저장소 파일 | `deploy/lab/k8s/traefik-forwarded-headers.yaml` (신규) |
| 적용 대상 | `kube-system` 네임스페이스의 Traefik HelmChart |
k3s의 Traefik은 번들 HelmChart로 설치되므로 **Deployment를 직접 고치면
안 된다.** helm-controller가 되돌린다. `HelmChartConfig`로 차트 값을
덮어써야 한다.
```yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
ports:
web:
forwardedHeaders:
trustedIPs:
- 10.42.0.0/16 # 파드 대역 (svclb SNAT 출발지)
- 192.168.122.0/24 # 노드·호스트 대역
websecure:
forwardedHeaders:
trustedIPs:
- 10.42.0.0/16
- 192.168.122.0/24
```
**`10.42.0.0/16`이 필요한 이유** — traefik Service가
`externalTrafficPolicy: Cluster`이므로 svclb가 SNAT한다. Traefik이 보는
출발지는 호스트 nginx의 주소가 아니라 **파드 네트워크 주소**다.
적용:
```bash
kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
kubectl -n kube-system rollout status deploy/traefik --timeout=180s
```
**함정 — `rollout status` 완료가 곧 반영은 아니다.** helm-controller가
`helm-install-traefik` **Job을 새로 돌려** 차트를 업그레이드하므로, 그 사이
**구 파드가 잠시 함께 살아 있다.** 이 시점에 측정하면 옛 파드가 응답해
"고쳤는데 안 바뀌었다"고 오해하게 된다. 실제로 이 함정에 한 번 걸렸다.
파드 이름과 인자로 확인한다.
```bash
kubectl -n kube-system get pods -l app.kubernetes.io/name=traefik
kubectl -n kube-system get pod -l app.kubernetes.io/name=traefik \
-o jsonpath='{.items[0].spec.containers[0].args}' | tr ',' '\n' | grep -i forwarded
# --entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16,192.168.122.0/24
```
**트레이드오프** — 파드 대역 전체를 신뢰하면 **클러스터 안의 어떤 파드든
헤더를 위조할 수 있다.** 실험대에서는 받아들일 만하지만 운영에서는 좁혀야
한다. 좁히려면 `externalTrafficPolicy: Local`로 SNAT를 없애고 실제
출발지(`192.168.122.1`)만 신뢰하는 방법이 있으나, 그러면 해당 노드에 Traefik
파드가 없을 때 트래픽이 버려진다.
### C. 앱 — 도착한 헤더를 해석한다
| | |
|---|---|
| 저장소 파일 | `deploy/lab/k8s/echo.yaml` |
| 위치 | `spec.template.spec.containers[0].env` |
```diff
- name: SERVER_FORWARD_HEADERS_STRATEGY
- value: "none"
+ value: "native"
```
```bash
kubectl apply -f deploy/lab/k8s/echo.yaml
kubectl -n header-lab rollout status deployment/echo --timeout=180s
```
앱마다 스위치 이름이 다르다.
| 앱 | 설정 | 넣는 곳 |
|---|---|---|
| Spring Boot | `SERVER_FORWARD_HEADERS_STRATEGY=native` | 컨테이너 `env` |
| **Keycloak** | **`KC_PROXY_HEADERS=xforwarded`** | 컨테이너 `env` |
| oauth2-proxy | `--reverse-proxy=true` | 컨테이너 `args` |
**새 앱을 올릴 때마다 반복해야 한다.** 빠뜨려도 오류가 나지 않는다.
---
## 10. 단계별 측정 결과
각 스위치를 하나씩 켜면서 측정한 값이다.
**어느 스위치가 무엇을 담당하는지가 그대로 드러난다.**
| 측정 항목 | 최초 | A 이후 | B 이후 | **C 이후** |
|---|---|---|---|---|
| nginx 가 보내는 값 | `http`/`80` | `https`/`443` | `https`/`443` | `https`/`443` |
| `x-forwarded-proto` | `http` | **`http`** | `https` | `https` |
| `x-forwarded-port` | `80` | **`80`** | `443` | `443` |
| `x-real-ip` | `10.42.1.0` | `10.42.1.0` | `100.123.124.30` | `100.123.124.30` |
| `scheme` (앱 해석) | `http` | `http` | **`http`** | **`https`** |
| `secure` | `false` | `false` | **`false`** | **`true`** |
| `requestUrl` | `http://…` | `http://…` | `http://…` | **`https://…`** |
**A 이후에 아무것도 바뀌지 않은 것**이 Traefik의 덮어쓰기를 증명한다.
nginx가 올바른 값을 보내는데도 앱에는 `http`가 도달했다.
**B 이후에 헤더는 살아났지만 앱 해석은 그대로**인 것이 2번과 3번 스위치가
서로 다른 일을 한다는 증거다. 헤더는 도착해 있었지만 앱이 읽지 않았다.
**C 이후에야 앱이 원래 요청을 인식한다.**
최종 상태:
```
x-forwarded-proto https
x-forwarded-port 443
x-real-ip 100.123.124.30 ← 실제 클라이언트(워크스테이션 tailnet IP)
scheme https
secure True
serverPort 443
remoteAddr 100.123.124.30
requestUrl https://app1.hyeonworks.com/api/echo
```
---
## 11. 검증
### 정상 동작
```bash
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool
```
| 항목 | 기대값 |
|---|---|
| `x-forwarded-proto` | `https` |
| `x-forwarded-port` | `443` |
| `x-real-ip` | 실제 클라이언트 IP |
| `scheme` | `https` |
| `secure` | `true` |
| `requestUrl` | `https://app1.hyeonworks.com/api/echo` |
### 위조 차단 — 이쪽이 더 중요하다
헤더 신뢰를 켠 뒤에는 **위조가 여전히 막히는지 반드시 확인해야 한다.**
```bash
curl -s https://app1.hyeonworks.com/api/echo \
-H 'X-Forwarded-Proto: http' \
-H 'X-Forwarded-Host: evil.example.com' \
-H 'X-Forwarded-For: 1.2.3.4' \
-H 'X-Real-IP: 1.2.3.4' | python3 -m json.tool
```
**주입한 값이 하나도 반영되지 않아야 한다.** 실측 결과 `scheme=https`,
`serverName=app1.hyeonworks.com`, `remoteAddr=100.123.124.30`이 유지됐다.
방어의 주체가 바뀌었다는 점에 유의한다.
| | 수정 전 | 수정 후 |
|---|---|---|
| 위조를 막는 주체 | **Traefik** (전부 덮어씀) | **nginx** (`$remote_addr`로 덮어씀) |
| 대가 | 정당한 값도 함께 버려짐 | 없음 |
**따라서 nginx의 `$remote_addr` 사용은 선택이 아니라 필수다.**
`$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 클라이언트가 넣은 값이
사슬 앞부분에 남아 신뢰 경계가 무너진다.
### 프록시 우회 경로 차단
앱이 헤더를 신뢰하게 되면 **Traefik을 거치지 않고 파드에 직접 도달할 수
있는 경로가 곧 구멍**이 된다. 클러스터 안에서는 Service ClusterIP로 접근할
수 있으므로 실제로 위조가 성립했다.
```bash
kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \
curl -s http://echo:8081/api/echo \
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
```
```
serverName evil.example.com ← 위조 성공
remoteAddr 1.2.3.4 ← 위조 성공
requestUrl https://evil.example.com/api/echo
```
**두 신뢰 설정이 모두 "대역"을 믿기 때문**이다.
| 계층 | 신뢰 범위 | 지정 방식 |
|---|---|---|
| Traefik `trustedIPs` | 파드 대역 전체 | IP 대역 |
| 앱 Tomcat `internalProxies` | 사설 대역 전체 (기본 정규식) | IP 정규식 |
IP로는 Traefik을 특정할 수 없다. **파드 IP가 재시작마다 바뀌기 때문**이다
(측정 중 실제로 `10.42.0.8``10.42.1.12`로, 노드까지 옮겨갔다).
**해결 — NetworkPolicy는 IP가 아니라 라벨로 지정한다.**
| | |
|---|---|
| 저장소 파일 | `deploy/lab/k8s/echo-network-policy.yaml` |
```yaml
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik # ← IP 가 아니라 라벨
ports:
- protocol: TCP
port: 8081
```
`namespaceSelector``podSelector`를 **같은 리스트 항목**에 두면 AND로
결합된다. 별개 항목으로 나누면 OR이 되어 kube-system 전체가 허용되므로
주의한다.
**kubelet probe를 위한 규칙이 별도로 필요하다.** readiness/liveness는 파드가
아니라 노드에서 오므로 위 규칙에 걸리지 않는다. 빠뜨리면 probe가 실패하고
**파드가 재시작 루프에 빠진다.**
```yaml
- from:
- ipBlock: { cidr: 10.42.0.1/32 } # kc-lab-1 의 cni0
- ipBlock: { cidr: 10.42.1.1/32 } # kc-lab-2 의 cni0
```
probe의 출발지는 **노드의 flannel 브리지(cni0)** 이고, 각 노드 `/24`의 첫
주소다. `/32`로 정확히 지정해야 한다 — `10.42.0.0/16`으로 넓히면 임의의
파드가 다시 들어와 정책이 무의미해진다.
**적용 후 확인**
```
정상 경로 scheme=https, remoteAddr=100.123.124.30 계속 동작
우회 시도 HTTP 000 / curl exit 7 연결 자체가 거부됨
파드 상태 1/1 Running, restarts=0 probe 정상
```
**"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.**
앞의 것만 하면 이 구멍이 남는다.
## 12. 증거
`docs/evidence/two-hop-proxy-headers/`
| 파일 | 내용 |
|---|---|
| `01-environment.txt` | 수정 전 세 계층 설정 |
| `02-measurements.txt` | 수정 전 측정 + 대조 실험 |
| `03-browser-https-vs-app-http.png` | 브라우저와 앱의 인식 차이 |
| `stage-a-nginx-fixed.png` | A 이후 — 여전히 `http` |
| `stage-b-traefik-trusts.png` | B 이후 — 헤더는 살아났으나 앱 해석은 `http` |
| `stage-c-resolved.png` | C 이후 — 전 구간 `https` |
| `04-after-fix.txt` | 최종 측정 + 위조 테스트 + 분배 |
---
## 11. 참고
- 1홉 계약 원본: `docs/reverse-proxy-headers.md`
- 개념 상세: `docs/session-lab-concepts.md`
- 패턴 비교: `docs/four-pattern-tradeoff-matrix.md`
- 측정 배포: `deploy/lab/k8s/echo.yaml`
- 측정 실행: `deploy/lab/scripts/measure-proxy-headers.sh`