Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
001efd624a | ||
|
|
d6f8b9f8b3 | ||
|
|
df4d3b4345 | ||
|
|
61ba5db259 | ||
|
|
bc784fcd6e | ||
|
|
ddcb1c08e6 | ||
|
|
e1ba9c5626 | ||
|
|
3af52bb66a | ||
|
|
98874b0c6c | ||
|
|
b708c8d503 | ||
|
|
7737787937 | ||
|
|
1c1b86e849 | ||
|
|
2294c52095 | ||
|
|
69d4502757 | ||
|
|
ae1f391598 | ||
|
|
844d6f1d33 | ||
|
|
a831792c5c | ||
|
|
bcfdeb93ee | ||
|
|
deae8966b8 | ||
|
|
6c90468c5a | ||
|
|
51bb055d61 | ||
|
|
c22b217fbd | ||
|
|
c07593c471 | ||
|
|
d01a60964a | ||
|
|
5d7544256a | ||
|
|
eacc0e86c9 | ||
|
|
ac912cb354 | ||
|
|
e4cee2a06d | ||
|
|
006b405ad5 | ||
|
|
8539d1bf5b | ||
|
|
3473875d9a | ||
|
|
f077e5038e | ||
|
|
e4eb7e54f7 | ||
|
|
bdde0feb86 | ||
|
|
3da8e609ae |
@@ -9,3 +9,10 @@ e2e/node_modules/
|
||||
google-e2e/node_modules/
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
bff/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
|
||||
@@ -15,6 +15,11 @@ Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은
|
||||
- AP3: Backend-for-Frontend (BFF)
|
||||
- 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,
|
||||
nginx를 Docker Compose로 실행하는 토대입니다. 패턴별 구현은 이 baseline
|
||||
위에서 별도 브랜치로 진행합니다.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.example.keycloakpattern;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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.RestController;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
public class ApiController {
|
||||
@@ -18,6 +22,38 @@ public class ApiController {
|
||||
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")
|
||||
public Map<String, Object> currentUser(@AuthenticationPrincipal Jwt jwt) {
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
|
||||
@@ -17,7 +17,8 @@ public class SecurityConfig {
|
||||
.sessionManagement(session ->
|
||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(authorize -> authorize
|
||||
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
|
||||
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public",
|
||||
"/api/echo")
|
||||
.permitAll()
|
||||
.anyRequest()
|
||||
.authenticated())
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
server:
|
||||
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:
|
||||
application:
|
||||
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:
|
||||
oauth2:
|
||||
resourceserver:
|
||||
|
||||
@@ -25,6 +25,18 @@ class ApiSecurityTest {
|
||||
.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
|
||||
void protectedEndpointRejectsAnonymousRequests() throws Exception {
|
||||
mockMvc.perform(get("/api/me"))
|
||||
|
||||
@@ -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:`이면 실행된 것이다.
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+42
@@ -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"
|
||||
Executable
+42
@@ -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"
|
||||
Executable
+47
@@ -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."
|
||||
Executable
+47
@@ -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,6 @@
|
||||
# Keycloak receives HTTP only from the trusted reverse proxy.
|
||||
KC_HTTP_ENABLED=true
|
||||
KC_PROXY_HEADERS=xforwarded
|
||||
KC_HOSTNAME=https://auth.example.test
|
||||
KC_HOSTNAME_STRICT=true
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name auth.example.test;
|
||||
|
||||
location / {
|
||||
proxy_pass http://keycloak:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
auth.example.test {
|
||||
tls /etc/tls/tls.crt /etc/tls/tls.key
|
||||
|
||||
reverse_proxy keycloak:8080 {
|
||||
header_up Host {host}
|
||||
header_up X-Forwarded-Host {host}
|
||||
header_up X-Forwarded-Port 443
|
||||
header_up X-Forwarded-Proto https
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
events {}
|
||||
|
||||
http {
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name auth.example.test;
|
||||
|
||||
ssl_certificate /etc/tls/tls.crt;
|
||||
ssl_certificate_key /etc/tls/tls.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
|
||||
location / {
|
||||
proxy_pass http://keycloak:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Port 443;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
tunnel: 00000000-0000-0000-0000-000000000000
|
||||
credentials-file: /etc/cloudflared/00000000-0000-0000-0000-000000000000.json
|
||||
|
||||
ingress:
|
||||
- hostname: auth.example.test
|
||||
service: http://reverse-proxy:8080
|
||||
- service: http_status:404
|
||||
@@ -0,0 +1,18 @@
|
||||
# Federated account key: `sub`, not email
|
||||
|
||||
외부 IdP의 email은 표시·연락 속성이지 계정 식별자나 자동 연결 증명이 아니다.
|
||||
Keycloak의 federated identity는 provider alias와 provider user ID(`sub`)를
|
||||
로컬 사용자에 연결한다.
|
||||
|
||||
정책:
|
||||
|
||||
- 신규 identity의 email이 기존 로컬 계정과 충돌하면 기존 계정의 인증을 다시
|
||||
요구하는 기본 First Broker Login flow를 사용한다.
|
||||
- `Automatically Set Existing User`를 production flow에 넣지 않는다.
|
||||
- upstream email 변경은 같은 `sub`의 계정 귀속을 바꾸지 않는다.
|
||||
- 마지막 로그인 수단을 unlink하는 UI에서는 먼저 다른 인증 수단을 등록하도록
|
||||
안내한다.
|
||||
|
||||
`verify-account-linking-sub-vs-email.sh`는 mock IdP 사용자의 email을 실제로
|
||||
변경하고 다시 로그인한다. 로컬 사용자 ID가 유지되고 federated `userId`가
|
||||
upstream `sub`와 같은지 확인한 후 원래 email을 복구한다.
|
||||
@@ -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 |
@@ -0,0 +1,37 @@
|
||||
# Four Keycloak integration patterns
|
||||
|
||||
| 축 | AP1 SPA direct | AP2 token mediator | AP3 BFF | AP4 edge auth |
|
||||
|---|---|---|---|---|
|
||||
| OAuth client | public | confidential | confidential | confidential proxy |
|
||||
| browser 보유물 | access/refresh token | 짧은 handoff code 또는 app token | HttpOnly session cookie | proxy session cookie |
|
||||
| OAuth code 교환 | browser + PKCE | mediator backend | BFF | oauth2-proxy |
|
||||
| API bearer 검증 | Spring resource server | mediator/downstream API | BFF 내부 또는 downstream | edge가 인증 후 trusted header |
|
||||
| server session | 없음 | handoff 상태만 짧게 | 필수 | proxy cookie/session |
|
||||
| XSS token 탈취면 | 가장 큼 | 축소 | browser token 제거 | browser token 제거 |
|
||||
| CSRF 주의 | token endpoint/refresh 설계 | app cookie 사용 시 | 필수 방어 | proxy cookie 사용 시 |
|
||||
| 수평 확장 상태 | 단순 | handoff store 공유 가능 | session store 필요 | proxy 설정에 따름 |
|
||||
| 주 학습 포인트 | PKCE/JWT/RS | token 경계·one-time handoff | oauth2Login/session/CSRF | auth_request/header trust |
|
||||
|
||||
## 선택 기준
|
||||
|
||||
- 브라우저에서 OAuth와 token 수명주기를 직접 학습하려면 AP1.
|
||||
- 브라우저에 upstream token을 주지 않되 API 호출은 bearer 중심으로 유지하려면
|
||||
AP2.
|
||||
- token을 browser에서 완전히 제거하고 애플리케이션 단위 인가·세션을
|
||||
중앙화하려면 AP3.
|
||||
- 기존 upstream을 수정하기 어렵고 경계에서 일괄 인증하려면 AP4.
|
||||
|
||||
Google federation은 다섯 번째 인증 패턴이 아니다. 네 패턴 모두 최종적으로
|
||||
Keycloak token/session을 소비하며, Google은 Keycloak 앞의 upstream IdP
|
||||
hop으로 추가된다.
|
||||
|
||||
## 이 repository의 실행 증거
|
||||
|
||||
- AP1: PKCE SPA, issuer/audience, token storage, refresh/logout 검증
|
||||
- AP2: confidential client와 one-time access handoff 검증
|
||||
- AP3: `oauth2Login` session과 CSRF/SameSite 검증
|
||||
- AP4: oauth2-proxy, nginx `auth_request`, spoofed header 제거 검증
|
||||
- 공통: local mock Google brokering, First Broker Login, claim/role mapping 검증
|
||||
|
||||
각 근거 브랜치와 병합 여부는 `keycloak-branch-manifest.tsv` 및
|
||||
`audit-keycloak-branches.sh`로 추적한다.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Google redirect URI policy
|
||||
|
||||
Google에 등록하는 redirect URI는 애플리케이션 SPA callback이 아니라 Keycloak
|
||||
broker endpoint다.
|
||||
|
||||
```text
|
||||
https://auth.example.test/realms/keycloak-patterns/broker/google/endpoint
|
||||
```
|
||||
|
||||
규칙:
|
||||
|
||||
- production URI는 HTTPS와 고정된 public Keycloak origin을 사용한다.
|
||||
- wildcard, path prefix, 임시 tunnel hostname을 production OAuth client에
|
||||
등록하지 않는다.
|
||||
- 개발·스테이징·운영은 Google OAuth client를 분리한다.
|
||||
- reverse proxy가 있더라도 Google이 보는 URI와 Keycloak이 생성하는 URI가
|
||||
byte-for-byte 같아야 한다.
|
||||
- `configure-google-idp.sh`가 출력하는 URI를 Google Console의 Authorized
|
||||
redirect URI와 대조한다.
|
||||
|
||||
```sh
|
||||
PUBLIC_KEYCLOAK_URL=https://auth.example.test \
|
||||
./scripts/verify-google-redirect-uri-policy.sh
|
||||
```
|
||||
@@ -0,0 +1,20 @@
|
||||
# HTTPS termination: nginx or Caddy
|
||||
|
||||
두 예제 모두 public `443`에서 TLS를 종료하고 private Docker network의
|
||||
`keycloak:8080`으로 전달한다. Keycloak 쪽 설정은
|
||||
`deploy/reverse-proxy/keycloak.env.example`의 hostname/proxy contract를
|
||||
같이 사용한다.
|
||||
|
||||
- nginx: 인증서 배포·갱신을 운영자가 담당할 때 적합하다.
|
||||
- Caddy: ACME를 통한 인증서 수명주기를 proxy가 담당하게 할 때 간단하다.
|
||||
- 둘을 동시에 production entry point로 띄우지 않는다.
|
||||
- 인증서와 private key는 repository 또는 image에 포함하지 않는다.
|
||||
- HTTP challenge/redirect 및 방화벽의 80/443 허용은 배포 환경에서 별도로
|
||||
결정한다.
|
||||
|
||||
검증 스크립트는 임시 자체 서명 인증서를 만들고 두 vendor image에서 설정을
|
||||
각각 validate한 뒤 임시 파일을 제거한다.
|
||||
|
||||
```sh
|
||||
./scripts/verify-https-termination-config.sh
|
||||
```
|
||||
@@ -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)의 비교표 |
|
||||
@@ -0,0 +1,21 @@
|
||||
# Public HTTPS domain for broker callbacks
|
||||
|
||||
Google brokering을 반복 테스트할 때는 Cloudflare **named tunnel + 관리
|
||||
도메인**을 기본 profile로 사용한다. `trycloudflare.com` quick tunnel과
|
||||
임의 ngrok URL은 일회성 데모용이며 고정 callback으로 간주하지 않는다.
|
||||
|
||||
설정 순서:
|
||||
|
||||
1. `cloudflared tunnel login`
|
||||
2. `cloudflared tunnel create keycloak-patterns`
|
||||
3. 예제 config의 tunnel UUID와 credentials path를 실제 값으로 교체
|
||||
4. `cloudflared tunnel route dns keycloak-patterns auth.example.test`
|
||||
5. `cloudflared tunnel run keycloak-patterns`
|
||||
6. Keycloak `KC_HOSTNAME`과 Google redirect URI를 같은 public host로 설정
|
||||
|
||||
컨테이너 안의 `127.0.0.1`은 cloudflared 컨테이너 자신이므로 origin에는
|
||||
`reverse-proxy:8080` 같은 Compose service DNS를 사용한다. 마지막 catch-all
|
||||
ingress는 알 수 없는 hostname을 404로 끝낸다.
|
||||
|
||||
실 tunnel 생성과 DNS 변경에는 사용자 소유 계정·도메인이 필요하므로 자동
|
||||
검증은 ingress 파일의 구조까지만 수행한다.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Reverse proxy headers
|
||||
|
||||
TLS를 reverse proxy에서 종료하면 Keycloak은 브라우저가 사용한 외부 origin을
|
||||
정확히 알아야 한다. 배포 예제는 다음 계약을 함께 적용한다.
|
||||
|
||||
- nginx는 `Host`, `X-Forwarded-Host`, `X-Forwarded-Port`,
|
||||
`X-Forwarded-Proto`, `X-Forwarded-For`를 덮어쓴다.
|
||||
- Keycloak은 `KC_PROXY_HEADERS=xforwarded`로 그 헤더 형식을 명시한다.
|
||||
- `KC_HOSTNAME`은 외부 HTTPS URL로 고정하고 strict hostname 검증을 켠다.
|
||||
- Keycloak의 8080 포트는 public으로 publish하지 않고 proxy network에서만
|
||||
접근시킨다. 신뢰되지 않은 클라이언트가 forwarded header를 직접 넣을 수
|
||||
있으면 안 된다.
|
||||
|
||||
`scripts/verify-reverse-proxy-headers.sh`는 양쪽 설정의 짝과 nginx 구문을
|
||||
검증한다.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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개 항목 모두 출력 |
|
||||
@@ -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홉 헤더 계약 원본 |
|
||||
@@ -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`
|
||||
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"test:first-broker": "node first-broker-login.mjs",
|
||||
"test:claim-mapping": "node claim-mapping.mjs",
|
||||
"test:claim-to-role": "node claim-to-role.mjs"
|
||||
"test:claim-to-role": "node claim-to-role.mjs",
|
||||
"test:sub-vs-email": "node sub-vs-email.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright-core": "1.55.1"
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chromium } from "playwright-core";
|
||||
|
||||
const baseUrl = process.env.KEYCLOAK_URL ?? "http://localhost:8080";
|
||||
const adminUsername = process.env.KC_BOOTSTRAP_ADMIN_USERNAME;
|
||||
const adminPassword = process.env.KC_BOOTSTRAP_ADMIN_PASSWORD;
|
||||
const mockPassword = process.env.MOCK_GOOGLE_USER_PASSWORD;
|
||||
assert.ok(adminUsername && adminPassword && mockPassword);
|
||||
|
||||
async function adminToken() {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/realms/master/protocol/openid-connect/token`,
|
||||
{
|
||||
method: "POST",
|
||||
body: new URLSearchParams({
|
||||
client_id: "admin-cli",
|
||||
grant_type: "password",
|
||||
username: adminUsername,
|
||||
password: adminPassword,
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
return (await response.json()).access_token;
|
||||
}
|
||||
|
||||
async function adminJson(token, path, init = {}) {
|
||||
const response = await fetch(`${baseUrl}/admin/realms/${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(init.body ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
});
|
||||
assert.ok(response.ok, `${init.method ?? "GET"} ${path}: ${response.status}`);
|
||||
return response.status === 204 ? undefined : response.json();
|
||||
}
|
||||
|
||||
async function users(token, realm, query) {
|
||||
return adminJson(token, `${realm}/users?${new URLSearchParams(query)}`);
|
||||
}
|
||||
|
||||
async function brokerLogin(browser) {
|
||||
const page = await browser.newPage();
|
||||
const url = new URL(
|
||||
`${baseUrl}/realms/keycloak-patterns/protocol/openid-connect/auth`,
|
||||
);
|
||||
url.search = new URLSearchParams({
|
||||
client_id: "spa-public",
|
||||
redirect_uri: "http://localhost:8088/",
|
||||
response_type: "code",
|
||||
scope: "openid profile email",
|
||||
state: crypto.randomUUID(),
|
||||
nonce: crypto.randomUUID(),
|
||||
code_challenge: "K2qUEfBl-nQvF2gB4dNxC2zYVwZc1CVnZb5CsX2L7fI",
|
||||
code_challenge_method: "S256",
|
||||
kc_idp_hint: "mock-google",
|
||||
prompt: "login",
|
||||
});
|
||||
await page.goto(url.toString());
|
||||
await page.waitForURL(/\/realms\/mock-google\//u);
|
||||
await page.locator("#username").fill("mock-new-user");
|
||||
await page.locator("#password").fill(mockPassword);
|
||||
await page.locator("#kc-login").click();
|
||||
await page.waitForURL(/localhost:8088\/\?.*code=/u);
|
||||
await page.close();
|
||||
}
|
||||
|
||||
const token = await adminToken();
|
||||
const mockUsers = await users(token, "mock-google", {
|
||||
username: "mock-new-user",
|
||||
exact: "true",
|
||||
});
|
||||
assert.equal(mockUsers.length, 1);
|
||||
const mockUser = await adminJson(
|
||||
token,
|
||||
`mock-google/users/${mockUsers[0].id}`,
|
||||
);
|
||||
const originalEmail = mockUser.email;
|
||||
const changedEmail = "broker-renamed-user@example.test";
|
||||
|
||||
for (const existing of await users(token, "keycloak-patterns", {
|
||||
username: `mock-google.${mockUser.id}`,
|
||||
exact: "true",
|
||||
})) {
|
||||
await adminJson(token, `keycloak-patterns/users/${existing.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
||||
headless: true,
|
||||
args: ["--no-sandbox"],
|
||||
});
|
||||
|
||||
try {
|
||||
await brokerLogin(browser);
|
||||
const before = await users(token, "keycloak-patterns", {
|
||||
username: `mock-google.${mockUser.id}`,
|
||||
exact: "true",
|
||||
});
|
||||
assert.equal(before.length, 1);
|
||||
const localUserId = before[0].id;
|
||||
const identities = await adminJson(
|
||||
token,
|
||||
`keycloak-patterns/users/${localUserId}/federated-identity`,
|
||||
);
|
||||
assert.equal(identities[0].userId, mockUser.id);
|
||||
|
||||
await adminJson(token, `mock-google/users/${mockUser.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ...mockUser, email: changedEmail }),
|
||||
});
|
||||
await brokerLogin(browser);
|
||||
|
||||
const after = await users(token, "keycloak-patterns", {
|
||||
username: `mock-google.${mockUser.id}`,
|
||||
exact: "true",
|
||||
});
|
||||
assert.equal(after.length, 1);
|
||||
assert.equal(after[0].id, localUserId);
|
||||
console.log(
|
||||
"Federated identity verified: provider sub stayed linked while upstream email changed",
|
||||
);
|
||||
} finally {
|
||||
await adminJson(token, `mock-google/users/${mockUser.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ ...mockUser, email: originalEmail }),
|
||||
});
|
||||
await browser.close();
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
set -a
|
||||
. ./.env
|
||||
set +a
|
||||
|
||||
./scripts/set-first-broker-login-mode.sh secure
|
||||
cd google-e2e
|
||||
npm install --ignore-scripts
|
||||
npm run test:sub-vs-email
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
realm="${KEYCLOAK_REALM:-keycloak-patterns}"
|
||||
public_keycloak_url="${PUBLIC_KEYCLOAK_URL:-https://auth.example.test}"
|
||||
expected="$public_keycloak_url/realms/$realm/broker/google/endpoint"
|
||||
|
||||
case "$public_keycloak_url" in
|
||||
https://*) ;;
|
||||
*)
|
||||
echo "PUBLIC_KEYCLOAK_URL must use https outside the local mock environment" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$public_keycloak_url" in
|
||||
*\** | */)
|
||||
echo "PUBLIC_KEYCLOAK_URL must be an exact origin without wildcard/trailing slash" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
test "$expected" = "https://auth.example.test/realms/keycloak-patterns/broker/google/endpoint"
|
||||
echo "Google redirect URI policy verified: $expected"
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
test_dir="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf "$test_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
|
||||
-subj "/CN=auth.example.test" \
|
||||
-keyout "$test_dir/tls.key" \
|
||||
-out "$test_dir/tls.crt" >/dev/null 2>&1
|
||||
|
||||
docker run --rm \
|
||||
--add-host keycloak:127.0.0.1 \
|
||||
-v "$PWD/deploy/tls/nginx.conf:/etc/nginx/nginx.conf:ro" \
|
||||
-v "$test_dir:/etc/tls:ro" \
|
||||
nginx:1.29-alpine nginx -t
|
||||
|
||||
docker run --rm \
|
||||
--add-host keycloak:127.0.0.1 \
|
||||
-v "$PWD/deploy/tls/Caddyfile:/etc/caddy/Caddyfile:ro" \
|
||||
-v "$test_dir:/etc/tls:ro" \
|
||||
caddy:2.10.2-alpine caddy validate --config /etc/caddy/Caddyfile
|
||||
|
||||
echo "nginx and Caddy HTTPS termination configurations verified"
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
config=deploy/tunnel/cloudflared-config.yml
|
||||
grep -q '^tunnel: [0-9a-f-]*$' "$config"
|
||||
grep -q '^ - hostname: auth.example.test$' "$config"
|
||||
grep -q '^ service: http://reverse-proxy:8080$' "$config"
|
||||
grep -q '^ - service: http_status:404$' "$config"
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD/$config:/etc/cloudflared/config.yml:ro" \
|
||||
cloudflare/cloudflared:2025.6.1 \
|
||||
tunnel --config /etc/cloudflared/config.yml ingress validate
|
||||
|
||||
echo "Cloudflare named-tunnel ingress configuration verified"
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
config=deploy/reverse-proxy/nginx-keycloak.conf
|
||||
env_file=deploy/reverse-proxy/keycloak.env.example
|
||||
|
||||
grep -q 'proxy_set_header X-Forwarded-Host' "$config"
|
||||
grep -q 'proxy_set_header X-Forwarded-Port 443' "$config"
|
||||
grep -q 'proxy_set_header X-Forwarded-Proto https' "$config"
|
||||
grep -q '^KC_PROXY_HEADERS=xforwarded$' "$env_file"
|
||||
grep -q '^KC_HOSTNAME=https://' "$env_file"
|
||||
|
||||
docker run --rm \
|
||||
--add-host keycloak:127.0.0.1 \
|
||||
-v "$PWD/$config:/etc/nginx/conf.d/default.conf:ro" \
|
||||
nginx:1.29-alpine nginx -t
|
||||
|
||||
echo "Reverse-proxy header and Keycloak hostname contracts verified"
|
||||
Reference in New Issue
Block a user