docs(keycloak-session-store): import the session-storage lab as a new project

The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -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,46 @@
# Experiment A-1 — cut the JGroups transport (TCP 7800) while leaving discovery alone.
#
# The point is to separate two things that are easy to conflate:
#
# discovery how the nodes FIND each other -> PostgreSQL JGROUPS_PING table
# transport how they actually TALK -> TCP 7800
#
# Blocking only the transport produces a state that cannot happen on a single
# node: both members stay registered in the database, so each believes the other
# exists, yet no message gets through.
#
# kubectl apply -f deploy/lab/k8s/a1-block-jgroups-transport.yaml
# kubectl -n keycloak-lab delete networkpolicy a1-block-jgroups-transport
#
# NetworkPolicy is an ALLOWLIST, not a firewall with deny rules. There is no way
# to write "deny 7800". The moment a pod is selected by a policy carrying
# policyTypes: [Ingress], every inbound port is denied unless a rule permits it.
# So 7800 is blocked by *omission*: 8080 and 9000 are listed, 7800 is not.
#
# That makes the two allow rules load-bearing — get them wrong and the experiment
# measures a dead Keycloak instead of a partitioned cluster:
#
# 8080 the HTTP endpoint. Traefik, the other pod's REST calls, and the probe
# traffic all arrive here.
# 9000 the management port: /health/started, /health/ready, /health/live and
# /metrics. Losing it means the kubelet fails the readiness probe and
# kills the pod — the cluster would break for the wrong reason.
#
# Both rules deliberately omit `from:`, which allows those ports from any source.
# Narrowing the source is not the subject here; the 2-hop experiment already
# established how to do that by label when it matters.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: a1-block-jgroups-transport
namespace: keycloak-lab
spec:
podSelector:
matchLabels:
app: keycloak
policyTypes: [Ingress]
ingress:
- ports:
- { port: 8080, protocol: TCP } # HTTP — must stay open
- { port: 9000, protocol: TCP } # health + metrics — must stay open
# 7800 is absent on purpose. That is the whole experiment.
@@ -0,0 +1,128 @@
# Experiment B-7 — oauth2-proxy, to measure how replicas share a cookie secret
# and what happens when it is rotated (Q1, unknown 7).
#
# This is a different shape of problem from the BFF. The BFF keeps state on the
# server, so the question was "which store". oauth2-proxy keeps no server state
# at all: the whole session rides in a cookie that is signed and encrypted with
# --cookie-secret. So there is nothing to share and nothing to lose on restart —
# instead, every replica must hold the *same* secret, and changing it invalidates
# every cookie at once.
#
# kubectl apply -f deploy/lab/k8s/b7-oauth2-proxy.yaml
#
# app2.hyeonworks.com is borrowed from Grafana for the duration of this
# experiment; the certificate only covers auth / app1 / app2, so a fourth name
# is not available. Grafana's Ingress is restored afterwards.
apiVersion: v1
kind: Secret
metadata:
name: oauth2-proxy-secrets
namespace: keycloak-lab
type: Opaque
stringData:
# oauth2-proxy requires exactly 16, 24 or 32 bytes. This is the value whose
# rotation the experiment is about.
COOKIE_SECRET_A: "lab-cookie-secret-aaaaaaaaaaaaaa"
COOKIE_SECRET_B: "lab-cookie-secret-bbbbbbbbbbbbbb"
CLIENT_SECRET: proxy-lab-secret
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: oauth2-proxy
namespace: keycloak-lab
spec:
# Two replicas is the point: Q1 asks how they share the secret.
replicas: 2
selector:
matchLabels: { app: oauth2-proxy }
template:
metadata:
labels: { app: oauth2-proxy }
spec:
# See B-1: Kubernetes injects <SVCNAME>_PORT as a tcp:// URL and it
# collides with ordinary configuration names.
enableServiceLinks: false
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: oauth2-proxy }
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
args:
- --provider=oidc
- --oidc-issuer-url=https://auth.hyeonworks.com/realms/keycloak-patterns
- --client-id=oauth2-proxy
- --redirect-url=https://app2.hyeonworks.com/oauth2/callback
- --email-domain=*
- --http-address=0.0.0.0:4180
# The upstream is the same echo app the B-4 header experiment used,
# so what the proxy forwards can be read straight off the response.
- --upstream=http://echo.header-lab.svc:8081
# ★ 이 옵션을 켜면 세션(=쿠키)에 access token 이 들어간다.
# 그러면 Set-Cookie 가 커져 프록시 앞단에서 502 가 났다.
# B-4 에서 본 헤더 크기 절벽이 이번에는 응답 쪽에서 나타난 것이다.
# - --pass-authorization-header=true
- --set-xauthrequest=true
- --reverse-proxy=true
- --cookie-secure=true
# One hour, matching the value Q1 records for the current setup.
- --cookie-expire=1h
- --skip-provider-button=true
# ★ 쿠키에 세션 전체를 담으면 Set-Cookie 가 커지고, 그 응답이
# 앞단 nginx 의 proxy_buffer 를 넘겨 502 가 났다(측정됨).
# Redis 로 옮기면 쿠키에는 티켓만 남는다 — 그리고 그 순간
# "replica 가 secret 을 공유해야 한다"는 문제의 성격도 바뀐다.
- --session-store-type=redis
- --redis-connection-url=redis://redis.keycloak-lab.svc:6379
env:
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: oauth2-proxy-secrets, key: CLIENT_SECRET }
# Which of the two secrets is in use is switched here. Both replicas
# read the same key, which is exactly the sharing Q1 asks about.
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef: { name: oauth2-proxy-secrets, key: COOKIE_SECRET_A }
ports:
- containerPort: 4180
name: http
readinessProbe:
httpGet: { path: /ping, port: http }
initialDelaySeconds: 5
resources:
requests: { memory: 32Mi, cpu: 20m }
limits: { memory: 128Mi }
---
apiVersion: v1
kind: Service
metadata:
name: oauth2-proxy
namespace: keycloak-lab
spec:
selector: { app: oauth2-proxy }
ports:
- port: 4180
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: oauth2-proxy
namespace: keycloak-lab
spec:
ingressClassName: traefik
rules:
- host: app2.hyeonworks.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: oauth2-proxy
port:
number: 4180
@@ -0,0 +1,214 @@
# BFF (2 replicas) + Redis, for the B-layer experiments.
#
# The BFF is deployed FIRST WITHOUT any session store wiring. That is deliberate:
# B-0 asks what Spring Boot's autoconfiguration actually picks when nothing is
# configured, and the only honest way to answer is to look at a running instance
# that has been given nothing. Redis is deployed alongside but left unused until
# B-1 turns it on.
#
# kubectl apply -f deploy/lab/k8s/bff-redis.yaml
#
# Image comes from the workstation, not a registry:
# docker build -t keycloak-pattern-bff:lab bff/
# docker save keycloak-pattern-bff:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
# (repeat for kc-lab-2)
# so imagePullPolicy must stay Never on both replicas.
apiVersion: v1
kind: Secret
metadata:
name: bff-secrets
namespace: keycloak-lab
type: Opaque
stringData:
# Matches the client created with kcadm in the keycloak-patterns realm.
# Base64 in etcd is not encryption — see D-3.
KEYCLOAK_CLIENT_SECRET: bff-lab-secret
---
# Redis. B-5 measured that turning on AOF with `redis-cli config set` changes
# nothing here, because /data is the container filesystem and dies with the
# container — the appendonlydir was created and then thrown away. Persistence
# configuration without a volume is decoration.
#
# So the volume comes first, and only then does `--appendonly yes` mean anything.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: redis-data
namespace: keycloak-lab
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: keycloak-lab
spec:
replicas: 1
selector:
matchLabels: { app: redis }
template:
metadata:
labels: { app: redis }
spec:
# Same node as postgres so a node-loss experiment takes both stores at
# once, matching how A-4 was set up.
nodeSelector:
kubernetes.io/hostname: kc-lab-2
containers:
- name: redis
image: redis:7.4-alpine
# appendfsync everysec 이 기본값이다 — 1초 분량을 잃을 수 있다.
# Keycloak 의 synchronous_commit OFF(A-3)와 같은 모양의 트레이드오프다.
args: ["redis-server", "--appendonly", "yes", "--dir", "/data"]
ports:
- containerPort: 6379
name: redis
readinessProbe:
exec: { command: ["redis-cli", "ping"] }
initialDelaySeconds: 3
volumeMounts:
- name: data
mountPath: /data
resources:
requests: { memory: 32Mi, cpu: 20m }
limits: { memory: 128Mi }
volumes:
- name: data
persistentVolumeClaim:
claimName: redis-data
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: keycloak-lab
spec:
selector: { app: redis }
ports:
- port: 6379
targetPort: redis
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: bff
namespace: keycloak-lab
spec:
# Two replicas is the whole point: Q1 and Q2 only exist because a request can
# land on an instance that did not handle the login.
replicas: 2
selector:
matchLabels: { app: bff }
template:
metadata:
labels: { app: bff }
spec:
# Spread across both nodes so "the other instance" is genuinely another
# machine, not another process on the same kernel.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: bff }
# 쿠버네티스는 같은 네임스페이스의 Service 마다 Docker link 시절의
# 환경변수를 자동 주입한다: REDIS_PORT=tcp://10.43.57.116:6379.
# 그것이 application.yml 의 ${REDIS_PORT:6379} 를 덮어써서 기동이 실패했다.
# Failed to bind properties under 'spring.data.redis.port' to int:
# Value: "tcp://10.43.57.116:6379"
# 이 주입 자체를 끄는 것이 근본 처방이다. 이름을 바꿔 피하면 다음 사람이
# 같은 함정에 다시 빠진다.
enableServiceLinks: false
containers:
- name: bff
image: keycloak-pattern-bff:lab
imagePullPolicy: Never
ports:
- containerPort: 8083
name: http
env:
# The browser is redirected to the public name; the BFF calls the
# token endpoint over the cluster network. Getting these two the same
# way round is what the 2-hop header experiment was about.
- name: KC_ISSUER_EXTERNAL
value: https://auth.hyeonworks.com/realms/keycloak-patterns
- name: KC_ISSUER_INTERNAL
value: http://keycloak.keycloak-lab.svc:8080/realms/keycloak-patterns
# echo 는 header-lab 네임스페이스의 8081 이다. 다른 네임스페이스의
# 서비스는 <svc>.<ns>.svc 로 부른다. 이름을 틀리면 500 이 나는데
# 원인은 UnresolvedAddressException 이지 토큰 문제가 아니다.
- name: RESOURCE_API_BASE_URL
value: http://echo.header-lab.svc:8081
- name: KEYCLOAK_CLIENT_SECRET
valueFrom:
secretKeyRef: { name: bff-secrets, key: KEYCLOAK_CLIENT_SECRET }
# Spring needs to know it is behind TLS termination, for the same
# reason Keycloak needs KC_PROXY_HEADERS. Without it the redirect_uri
# it builds comes back as http:// and Keycloak rejects it.
- name: SERVER_FORWARD_HEADERS_STRATEGY
value: native
# B-1: Application Session 을 Redis 로 옮긴다.
# OAuth2AuthorizedClient 는 이것으로 옮겨지지 않는다 — 조회 키가
# 다르기 때문이며, B-0 에서 확인한 사실이다.
- name: SPRING_SESSION_STORE_TYPE
value: redis
- name: REDIS_HOST
value: redis.keycloak-lab.svc
- name: REDIS_PORT
value: "6379"
# B-2: authorized client 는 PostgreSQL 로. 세션(Redis)과 다른
# 저장소를 쓰는 것이 Q3 가 말한 "각각 설계한다"의 실물이다.
- name: BFF_DB_URL
value: jdbc:postgresql://postgres.keycloak-lab.svc:5432/keycloak
- name: BFF_DB_USER
value: keycloak
- name: BFF_DB_PASSWORD
valueFrom:
secretKeyRef: { name: keycloak-lab-secrets, key: POSTGRES_PASSWORD }
- name: JAVA_TOOL_OPTIONS
value: "-Xms128m -Xmx320m"
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: http }
initialDelaySeconds: 20
failureThreshold: 30
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: http }
initialDelaySeconds: 60
resources:
requests: { memory: 320Mi, cpu: 100m }
limits: { memory: 512Mi }
---
apiVersion: v1
kind: Service
metadata:
name: bff
namespace: keycloak-lab
spec:
selector: { app: bff }
ports:
- port: 8083
targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: bff
namespace: keycloak-lab
spec:
ingressClassName: traefik
rules:
- host: app1.hyeonworks.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: bff
port:
number: 8083
@@ -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,277 @@
# Keycloak multi-node cluster with PostgreSQL.
#
# Goal of this manifest: two Keycloak pods on two different nodes must discover
# each other and form one Infinispan cluster. Keycloak 26 discovers peers through
# the database (jdbc-ping) rather than multicast, writing to a JGROUPS_PING table,
# but the cluster traffic itself runs over TCP 7800 between the pods. Those are
# two separate mechanisms, which is why "registered in the DB but not clustered"
# is a real failure mode — and one that a single node cannot reproduce.
#
# kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
# kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
#
# Secrets are plain here. Proper secret handling is roadmap item 11; keeping it
# visible for now is deliberate so the gap is obvious rather than forgotten.
apiVersion: v1
kind: Namespace
metadata:
name: keycloak-lab
---
apiVersion: v1
kind: Secret
metadata:
name: keycloak-lab-secrets
namespace: keycloak-lab
type: Opaque
stringData:
POSTGRES_PASSWORD: lab-postgres-change-me
KC_BOOTSTRAP_ADMIN_PASSWORD: lab-admin-change-me
---
# PostgreSQL. local-path binds the volume to whichever node the pod lands on, so
# the database is effectively pinned to one node. That is not a flaw here: it is
# what makes "the database node dies" a meaningful experiment later.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: postgres-data
namespace: keycloak-lab
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: keycloak-lab
spec:
replicas: 1
strategy:
type: Recreate # RWO volume cannot be mounted by two pods at once
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
name: postgres
env:
- name: POSTGRES_DB
value: keycloak
- name: POSTGRES_USER
value: keycloak
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: keycloak-lab-secrets
key: POSTGRES_PASSWORD
# The image refuses to initialise into a non-empty mount, and
# local-path volumes are clean, but this keeps the data one level
# down so a lost+found or similar never blocks initdb.
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["sh", "-c", "pg_isready -U keycloak -d keycloak"]
initialDelaySeconds: 10
periodSeconds: 5
resources:
requests:
memory: 192Mi
cpu: 50m
limits:
memory: 512Mi
volumes:
- name: data
persistentVolumeClaim:
claimName: postgres-data
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: keycloak-lab
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: postgres
---
# Keycloak. A StatefulSet rather than a Deployment so each pod keeps a stable
# name (keycloak-0, keycloak-1); cluster membership is far easier to read in
# logs and in the JGROUPS_PING table when the identities do not churn.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: keycloak
namespace: keycloak-lab
spec:
serviceName: keycloak-headless
replicas: 2
podManagementPolicy: Parallel # both pods start together, so they race to
# register — which is the interesting case
selector:
matchLabels:
app: keycloak
template:
metadata:
labels:
app: keycloak
spec:
# One pod per node. Two pods on one node would share a kernel and make the
# 7800 blocking experiment meaningless.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: keycloak
containers:
- name: keycloak
image: quay.io/keycloak/keycloak:26.7.0
# "start", not "start-dev". Dev mode forces cache=local and there is
# no cluster to form at all.
args: ["start"]
ports:
- containerPort: 8080
name: http
- containerPort: 9000
name: management
- containerPort: 7800
name: jgroups
env:
- name: KC_DB
value: postgres
- name: KC_DB_URL
value: jdbc:postgresql://postgres:5432/keycloak
- name: KC_DB_USERNAME
value: keycloak
- name: KC_DB_PASSWORD
valueFrom:
secretKeyRef:
name: keycloak-lab-secrets
key: POSTGRES_PASSWORD
# Settings confirmed by the two-hop header measurement.
# KC_HOSTNAME carries the full external URL, which pins scheme and
# host for issuer and redirect URLs regardless of headers.
# KC_PROXY_HEADERS is the separate opt-in that lets the forwarded
# client address through — the same kind of switch as Spring's
# forward-headers-strategy. See docs/two-hop-proxy-header-contract.md.
- name: KC_HOSTNAME
value: https://auth.hyeonworks.com
- name: KC_HOSTNAME_STRICT
value: "true"
- name: KC_PROXY_HEADERS
value: xforwarded
- name: KC_HTTP_ENABLED
value: "true"
- name: KC_HEALTH_ENABLED
value: "true"
- name: KC_METRICS_ENABLED
value: "true"
# Without an explicit cap the JVM sizes its heap from the container
# limit and this lab has roughly 3.8GB of guest headroom in total.
- name: JAVA_OPTS_KC_HEAP
value: "-Xms256m -Xmx512m"
- name: KC_BOOTSTRAP_ADMIN_USERNAME
value: admin
- name: KC_BOOTSTRAP_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: keycloak-lab-secrets
key: KC_BOOTSTRAP_ADMIN_PASSWORD
# Keycloak serves health and metrics on the management port (9000),
# not on 8080, since version 25.
startupProbe:
httpGet:
path: /health/started
port: management
periodSeconds: 10
failureThreshold: 60 # first boot runs an implicit build
readinessProbe:
httpGet:
path: /health/ready
port: management
periodSeconds: 10
livenessProbe:
httpGet:
path: /health/live
port: management
periodSeconds: 30
resources:
requests:
memory: 640Mi
cpu: 100m
limits:
memory: 900Mi
---
# Headless service. Not required for jdbc-ping discovery, which goes through the
# database, but it gives each pod a stable DNS name for direct inspection.
apiVersion: v1
kind: Service
metadata:
name: keycloak-headless
namespace: keycloak-lab
spec:
clusterIP: None
selector:
app: keycloak
ports:
- port: 8080
targetPort: http
name: http
- port: 9000
targetPort: management
name: management
---
apiVersion: v1
kind: Service
metadata:
name: keycloak
namespace: keycloak-lab
spec:
selector:
app: keycloak
ports:
- port: 8080
targetPort: http
name: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: keycloak
namespace: keycloak-lab
spec:
ingressClassName: traefik
rules:
- host: auth.hyeonworks.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: keycloak
port:
number: 8080
@@ -0,0 +1,373 @@
# Prometheus + node-exporter + Grafana.
#
# Purpose: during a fault-injection experiment, know *which signal moved first*.
# Without a metrics store the only record is whatever scrolled past in a terminal,
# and "the cluster recovered in about a minute" is not a measurement.
#
# kubectl apply -f deploy/lab/k8s/observability.yaml
# kubectl -n observability rollout status deployment/prometheus --timeout=300s
#
# Placement decision — Prometheus and Grafana are pinned to the control-plane
# node (kc-lab-1). An observability stack must not share a failure domain with
# the thing it observes. With only two nodes that cannot be fully avoided, so the
# rule here is: the node that gets killed in experiments is the *agent*
# (kc-lab-2, holding keycloak-0 and postgres), and everything needed to watch
# that happen lives on the server node.
apiVersion: v1
kind: Namespace
metadata:
name: observability
---
# Prometheus discovers scrape targets by querying the Kubernetes API, so it
# needs read access to nodes, services, endpoints and pods. Without this the
# kubernetes_sd_configs below silently return no targets.
apiVersion: v1
kind: ServiceAccount
metadata:
name: prometheus
namespace: observability
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: prometheus
rules:
- apiGroups: [""]
# nodes/proxy is required in addition to nodes/metrics: the kubelet job
# reaches each node through the API server's proxy subresource
# (/api/v1/nodes/<name>/proxy/metrics). Without it every kubelet target
# fails with 403 Forbidden while the other jobs stay green — a partial
# failure that is easy to miss unless the target list is checked.
resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods]
verbs: [get, list, watch]
- nonResourceURLs: ["/metrics"]
verbs: [get]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: prometheus
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: prometheus
subjects:
- kind: ServiceAccount
name: prometheus
namespace: observability
---
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
namespace: observability
data:
prometheus.yml: |
global:
# 15s is short for production but right here: a node loss should show up
# within a couple of samples, not a minute later.
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
# Prometheus scraping itself. Useful as a control: if this target is down,
# the problem is Prometheus, not the thing being measured.
- job_name: prometheus
static_configs:
- targets: ['localhost:9090']
# Keycloak. Metrics live on the management port 9000, not 8080 — the same
# split that the health probes use. KC_METRICS_ENABLED=true is already set
# on the StatefulSet.
#
# Discovery is by endpoints rather than a static list because pod IPs
# change on every restart; that was observed directly when the lab was
# power-cycled and every pod came back with a new address.
- job_name: keycloak
kubernetes_sd_configs:
- role: endpoints
namespaces:
names: [keycloak-lab]
relabel_configs:
- source_labels: [__meta_kubernetes_service_name, __meta_kubernetes_endpoint_port_name]
action: keep
regex: keycloak-headless;management
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: node
# node-exporter, one per node via DaemonSet. This is what answers
# "did the machine die or did the process die".
- job_name: node-exporter
kubernetes_sd_configs:
- role: endpoints
namespaces:
names: [observability]
relabel_configs:
- source_labels: [__meta_kubernetes_service_name]
action: keep
regex: node-exporter
- source_labels: [__meta_kubernetes_pod_node_name]
target_label: node
# The kubelet's own metrics, reached through the API server proxy so no
# extra port needs opening.
- job_name: kubelet
scheme: https
tls_config:
ca_file: /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
insecure_skip_verify: true
bearer_token_file: /var/run/secrets/kubernetes.io/serviceaccount/token
kubernetes_sd_configs:
- role: node
relabel_configs:
- action: labelmap
regex: __meta_kubernetes_node_label_(.+)
- target_label: __address__
replacement: kubernetes.default.svc:443
- source_labels: [__meta_kubernetes_node_name]
regex: (.+)
target_label: __metrics_path__
replacement: /api/v1/nodes/${1}/proxy/metrics
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: prometheus-data
namespace: observability
spec:
accessModes: [ReadWriteOnce]
storageClassName: local-path
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prometheus
namespace: observability
spec:
replicas: 1
strategy:
type: Recreate # RWO volume; two pods cannot mount it at once
selector:
matchLabels:
app: prometheus
template:
metadata:
labels:
app: prometheus
spec:
serviceAccountName: prometheus
# See the placement note at the top of this file.
nodeSelector:
node-role.kubernetes.io/control-plane: "true"
securityContext:
fsGroup: 65534 # the image runs as nobody and must own the volume
containers:
- name: prometheus
image: prom/prometheus:v3.1.0
args:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
# 7 days is far more than an experiment needs and keeps the volume
# small enough that it never becomes the reason a node fills up.
- --storage.tsdb.retention.time=7d
- --web.enable-lifecycle
ports:
- containerPort: 9090
name: http
volumeMounts:
- name: config
mountPath: /etc/prometheus
- name: data
mountPath: /prometheus
readinessProbe:
httpGet: { path: /-/ready, port: http }
initialDelaySeconds: 10
livenessProbe:
httpGet: { path: /-/healthy, port: http }
initialDelaySeconds: 30
resources:
requests: { memory: 256Mi, cpu: 50m }
limits: { memory: 640Mi }
volumes:
- name: config
configMap:
name: prometheus-config
- name: data
persistentVolumeClaim:
claimName: prometheus-data
---
apiVersion: v1
kind: Service
metadata:
name: prometheus
namespace: observability
spec:
selector:
app: prometheus
ports:
- port: 9090
targetPort: http
---
# node-exporter. A DaemonSet so every node reports, including one that is about
# to be killed — the last samples before it goes silent are the interesting part.
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: node-exporter
namespace: observability
spec:
selector:
matchLabels:
app: node-exporter
template:
metadata:
labels:
app: node-exporter
spec:
# Host namespaces: the point is to measure the machine, not the container.
hostNetwork: true
hostPID: true
tolerations:
- operator: Exists # must also run on tainted nodes
containers:
- name: node-exporter
image: prom/node-exporter:v1.8.2
args:
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
- --path.rootfs=/host/root
- --collector.filesystem.mount-points-exclude=^/(dev|proc|sys|var/lib/docker/.+|var/lib/kubelet/.+)($|/)
ports:
- containerPort: 9100
name: metrics
hostPort: 9100
volumeMounts:
- { name: proc, mountPath: /host/proc, readOnly: true }
- { name: sys, mountPath: /host/sys, readOnly: true }
- { name: rootfs, mountPath: /host/root, readOnly: true, mountPropagation: HostToContainer }
resources:
requests: { memory: 32Mi, cpu: 20m }
limits: { memory: 96Mi }
volumes:
- { name: proc, hostPath: { path: /proc } }
- { name: sys, hostPath: { path: /sys } }
- { name: rootfs, hostPath: { path: / } }
---
apiVersion: v1
kind: Service
metadata:
name: node-exporter
namespace: observability
spec:
clusterIP: None # headless: Prometheus wants each pod, not a VIP
selector:
app: node-exporter
ports:
- port: 9100
targetPort: metrics
name: metrics
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana
namespace: observability
spec:
replicas: 1
selector:
matchLabels:
app: grafana
template:
metadata:
labels:
app: grafana
spec:
nodeSelector:
node-role.kubernetes.io/control-plane: "true"
containers:
- name: grafana
image: grafana/grafana:11.4.0
ports:
- containerPort: 3000
name: http
env:
- name: GF_SECURITY_ADMIN_USER
value: admin
- name: GF_SECURITY_ADMIN_PASSWORD
value: lab-grafana-change-me
# Grafana builds absolute URLs for redirects and asset paths. Behind
# the nginx -> Traefik chain it must be told the external address,
# for exactly the reason Keycloak needs KC_HOSTNAME. Without it,
# login redirects come back as http://<pod-ip>:3000.
- name: GF_SERVER_ROOT_URL
value: https://app2.hyeonworks.com
volumeMounts:
- name: datasources
mountPath: /etc/grafana/provisioning/datasources
readinessProbe:
httpGet: { path: /api/health, port: http }
initialDelaySeconds: 15
resources:
requests: { memory: 128Mi, cpu: 50m }
limits: { memory: 320Mi }
volumes:
- name: datasources
configMap:
name: grafana-datasources
---
# Provisioning the datasource as a file means Grafana comes up already wired to
# Prometheus. Clicking through the UI would leave the configuration only in
# Grafana's own database, which is emptyDir here and disappears on restart.
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-datasources
namespace: observability
data:
prometheus.yaml: |
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus.observability.svc:9090
isDefault: true
---
apiVersion: v1
kind: Service
metadata:
name: grafana
namespace: observability
spec:
selector:
app: grafana
ports:
- port: 3000
targetPort: http
---
# Grafana is published on app2.hyeonworks.com because that name is already in
# the wildcard-free certificate (auth / app1 / app2) and is otherwise unused.
# It moves when app2 is needed for the SSO experiment.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: grafana
namespace: observability
spec:
ingressClassName: traefik
rules:
- host: app2.hyeonworks.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: grafana
port:
number: 3000
@@ -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
@@ -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"
@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Experiment 0c — where does a session entry actually live?
#
# Experiment 0b showed keycloak-1's session cache never moved when keycloak-0
# handled a login. That leaves two explanations:
#
# (a) a DISTRIBUTED cache with owners=1 — entries are spread across nodes by
# consistent hashing, and this one happened to land on keycloak-0;
# (b) a LOCAL cache — each node only ever caches what it handled itself.
#
# They are distinguished by driving logins at the OTHER node. Under (a) the
# entries would keep landing on both nodes regardless of who was asked. Under
# (b) the count rises only on the node that received the request.
set -uo pipefail
NS="${NS:-keycloak-lab}"
N="${N:-5}"
K0_IP=$(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1_IP=$(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.status.podIP}')
ADMIN_PW=$(kubectl -n "$NS" get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
echo "수집 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo " keycloak-0 = $K0_IP ($(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.spec.nodeName}'))"
echo " keycloak-1 = $K1_IP ($(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.spec.nodeName}'))"
echo
kubectl -n "$NS" run kc-own --rm -i --restart=Never \
--image=curlimages/curl:8.11.1 --quiet --command -- sh -c "
O=/tmp/o; : > \$O
ent() {
curl -s --retry 3 --max-time 20 http://\$1:9000/metrics \
| grep -E '^vendor_statistics_approximate_entries_unique.cache=.sessions' \
| awk '{print \$NF}'
}
login() { i=0; while [ \$i -lt $N ]; do
curl -s -o /dev/null -X POST http://\$1:8080/realms/master/protocol/openid-connect/token \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d 'password=$ADMIN_PW'
i=\$((i+1)); done; sleep 5; }
{
printf '%-32s %12s %12s\n' '단계' 'k0 entries' 'k1 entries'
printf '%-32s %12s %12s\n' '시작' \"\$(ent $K0_IP)\" \"\$(ent $K1_IP)\"
login $K1_IP
printf '%-32s %12s %12s\n' 'keycloak-1 에 로그인 ${N}회' \"\$(ent $K0_IP)\" \"\$(ent $K1_IP)\"
login $K0_IP
printf '%-32s %12s %12s\n' 'keycloak-0 에 로그인 ${N}회' \"\$(ent $K0_IP)\" \"\$(ent $K1_IP)\"
} >> \$O
cat \$O
" 2>&1 | grep -v '^pod .* deleted$'
echo
echo "=== 대조: PostgreSQL 에는 몇 건인가 ==="
kubectl -n "$NS" exec deploy/postgres -- psql -U keycloak -d keycloak -tAc \
"select count(*) from offline_user_session where offline_flag='0'" 2>/dev/null | sed 's/^/ online 세션 /'
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Experiment 0b — does the Infinispan cache itself replicate, or do both nodes
# merely agree because they read the same database?
#
# Experiment 0 proved the two nodes give the same answers. That alone does NOT
# prove Infinispan replicated anything: with persistent-user-sessions (the
# Keycloak 26 default) the session is written to PostgreSQL, so two nodes reading
# one database would agree even with the cache disabled entirely.
#
# This script separates the two by measuring the cache counters on BOTH nodes
# around a single login. If the write on keycloak-0 shows up as cache activity
# on keycloak-1, the replication is real and not a database artifact.
set -uo pipefail
NS="${NS:-keycloak-lab}"
K0_IP=$(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1_IP=$(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.status.podIP}')
ADMIN_PW=$(kubectl -n "$NS" get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
echo "수집 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo
# 파드 출력을 스트리밍으로 받으면 조각이 유실된다. 실제로 첫 시도에서
# keycloak-1 의 스냅샷과 그 다음 마커가 통째로 사라져 델타가 0 으로 보였다.
# 파드 안에서 파일로 모았다가 마지막에 한 번만 내보낸다.
kubectl -n "$NS" run kc-delta --rm -i --restart=Never \
--image=curlimages/curl:8.11.1 --quiet --command -- sh -c "
set -u
K0='http://$K0_IP'; K1='http://$K1_IP'
O=/tmp/o.txt; : > \$O
snap() {
curl -s --retry 3 --retry-connrefused --max-time 20 \$1:9000/metrics \
| grep -E '^vendor_(statistics_(stores|hits|misses|approximate_entries_unique)|rpc_manager_replication_count)\{cache=\"(sessions|clientSessions)\"' \
| sed 's/,cache_manager=\"keycloak\"//; s/,node=\"[^\"]*\"//' >> \$O
}
echo '###BEFORE_K0' >> \$O; snap \$K0
echo '###BEFORE_K1' >> \$O; snap \$K1
echo '###LOGIN' >> \$O
curl -s -o /dev/null -w 'http_code=%{http_code}\n' -X POST \
\"\$K0:8080/realms/master/protocol/openid-connect/token\" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d 'password=$ADMIN_PW' >> \$O
sleep 5
echo '###AFTER_K0' >> \$O; snap \$K0
echo '###AFTER_K1' >> \$O; snap \$K1
echo '###END' >> \$O
cat \$O
" 2>&1 | grep -v '^pod .* deleted$' > /tmp/cache-delta.txt
python3 - /tmp/cache-delta.txt <<'PY'
import re, sys
raw = open(sys.argv[1]).read()
blocks, cur = {}, None
for line in raw.splitlines():
if line.startswith('###'):
cur = line[3:]; blocks[cur] = {}
elif cur and '{' in line:
m = re.match(r'(\S+?)\{cache="(\w+)"\}\s+(\S+)', line)
if m:
blocks[cur][(m.group(1), m.group(2))] = float(m.group(3))
print('=== 로그인은 keycloak-0 에만 보냈다 ===')
code = [l for l in raw.splitlines() if l.startswith('http_code=')]
print(' 로그인 응답: ' + (code[0] if code else '없음'))
for n in ('BEFORE_K0','BEFORE_K1','AFTER_K0','AFTER_K1'):
if not blocks.get(n):
print(f' !! {n} 스냅샷이 비었다 — 델타를 신뢰할 수 없다')
print()
hdr = f" {'계수기':<42} {'캐시':<15} {'전':>8} {'후':>8} {'증가':>7}"
for node in ('K0', 'K1'):
who = 'keycloak-0 (로그인을 받은 노드)' if node == 'K0' else 'keycloak-1 (아무 요청도 받지 않은 노드)'
print(f'=== {who} ===')
print(hdr)
b, a = blocks.get(f'BEFORE_{node}', {}), blocks.get(f'AFTER_{node}', {})
for k in sorted(set(b) | set(a)):
before, after = b.get(k[0:2], 0.0), a.get(k[0:2], 0.0)
d = after - before
mark = ' ←' if d else ''
name = k[0].replace('vendor_statistics_', '').replace('vendor_rpc_manager_', 'rpc.')
print(f" {name:<42} {k[1]:<15} {before:>8.0f} {after:>8.0f} {d:>+7.0f}{mark}")
print()
PY
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Experiment 0d — capture the actual SQL that the OTHER node runs.
#
# Experiments 0b/0c showed that session entries never appear in keycloak-1's
# memory, yet keycloak-1 can use a session keycloak-0 created. The conclusion
# "keycloak-1 reads it from PostgreSQL" was an inference, not an observation.
#
# This script turns on statement logging in PostgreSQL for a few seconds, sends
# ONE refresh request to keycloak-1 for a session born on keycloak-0, and greps
# the database log for that session id. If the inference is right, the SQL is
# there, issued from keycloak-1's pod IP.
#
# It also checks whether serving that request makes keycloak-1 cache the session
# — which sharpens "each node caches what it handled" from "what it logged in"
# to "what it touched".
set -uo pipefail
NS="${NS:-keycloak-lab}"
PSQL="kubectl -n $NS exec deploy/postgres -- psql -U keycloak -d keycloak -tAc"
K0_IP=$(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1_IP=$(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.status.podIP}')
ADMIN_PW=$(kubectl -n "$NS" get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
echo "수집 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo " keycloak-0 = $K0_IP (세션을 만드는 노드)"
echo " keycloak-1 = $K1_IP (읽기만 하는 노드)"
echo
# %h 를 넣어야 어느 파드가 보낸 질의인지 로그에서 구분된다.
echo "=== PostgreSQL 문장 로깅을 켠다 ==="
$PSQL "alter system set log_statement='all'" >/dev/null 2>&1
$PSQL "alter system set log_line_prefix='%m [%p] %h '" >/dev/null 2>&1
$PSQL "select pg_reload_conf()" >/dev/null 2>&1
echo " log_statement = $($PSQL 'show log_statement' 2>/dev/null)"
echo " log_line_prefix = $($PSQL 'show log_line_prefix' 2>/dev/null)"
echo
# 로그 커서를 잡아둔다. 이 줄 수 이후만 본다.
LOG_BEFORE=$(kubectl -n "$NS" logs deploy/postgres --tail=-1 2>/dev/null | wc -l)
RESULT=$(kubectl -n "$NS" run kc-readpath --rm -i --restart=Never \
--image=curlimages/curl:8.11.1 --quiet --command -- sh -c "
O=/tmp/o; : > \$O
TOKEN_EP='/realms/master/protocol/openid-connect/token'
jget() { sed -n \"s/.*\\\"\$1\\\":\\\"\\([^\\\"]*\\)\\\".*/\\1/p\"; }
ent() {
curl -s --retry 3 --max-time 20 http://\$1:9000/metrics \
| grep -E '^vendor_statistics_approximate_entries_unique.cache=.sessions' | awk '{print \$NF}'
}
# keycloak-0 에서 로그인한다
L=\$(curl -s -X POST \"http://$K0_IP:8080\$TOKEN_EP\" -d grant_type=password \
-d client_id=admin-cli -d username=admin -d 'password=$ADMIN_PW')
SID=\$(echo \"\$L\" | jget access_token | cut -d. -f2 | sed 's/\$/==/' | base64 -d 2>/dev/null | jget sid)
RT=\$(echo \"\$L\" | jget refresh_token)
echo \"SID=\$SID\" >> \$O
echo \"K1_ENTRIES_BEFORE=\$(ent $K1_IP)\" >> \$O
sleep 2
# 반대편 노드에 refresh 를 딱 한 번 보낸다
# 인용을 한 겹 더 쌓으면 curl 이 URL 을 통째로 못 읽는다. 실제로 000 이 나왔다.
CODE=\$(curl -s -o /dev/null -w '%{http_code}' -X POST \
\"http://$K1_IP:8080\$TOKEN_EP\" \
-d grant_type=refresh_token -d client_id=admin-cli -d \"refresh_token=\$RT\")
echo \"REFRESH_ON_K1=\$CODE\" >> \$O
sleep 3
echo \"K1_ENTRIES_AFTER=\$(ent $K1_IP)\" >> \$O
cat \$O
" 2>&1 | grep -v '^pod .* deleted$')
echo "=== 요청 ==="
echo "$RESULT" | sed 's/^/ /'
SID=$(echo "$RESULT" | sed -n 's/^SID=//p')
echo
echo "=== PostgreSQL 문장 로깅을 끈다 ==="
$PSQL "alter system reset log_statement" >/dev/null 2>&1
$PSQL "alter system reset log_line_prefix" >/dev/null 2>&1
$PSQL "select pg_reload_conf()" >/dev/null 2>&1
echo " log_statement = $($PSQL 'show log_statement' 2>/dev/null)"
echo
echo "=== keycloak-1 이 실제로 보낸 SQL 문장 ==="
echo " (파라미터가 \$1 로 묶여 있어, sid 는 바로 아래 DETAIL 줄에 있다)"
echo
kubectl -n "$NS" logs deploy/postgres --tail=-1 2>/dev/null \
| tail -n +$((LOG_BEFORE + 1)) \
| grep -F "$K1_IP" | grep -E "LOG: execute" \
| sed 's/.*execute [^:]*: //' | sed 's/^/ /' | head -12
echo
echo "=== 그 sid 를 언급한 SQL — 누가 보냈는가 ==="
echo " 찾는 sid: $SID"
echo
kubectl -n "$NS" logs deploy/postgres --tail=-1 2>/dev/null \
| tail -n +$((LOG_BEFORE + 1)) \
| grep -F "$SID" \
| sed -e "s/$K0_IP/[keycloak-0]/g" -e "s/$K1_IP/[keycloak-1]/g" \
| cut -c1-220 \
| head -20
echo
echo "=== 요약: 파드별 질의 건수 ==="
kubectl -n "$NS" logs deploy/postgres --tail=-1 2>/dev/null \
| tail -n +$((LOG_BEFORE + 1)) \
| grep -F "$SID" \
| grep -oE "^[0-9-]+ [0-9:.]+ [A-Z]+ \[[0-9]+\] [0-9.]+" \
| awk '{print $NF}' | sort | uniq -c \
| sed -e "s/$K0_IP/[keycloak-0]/" -e "s/$K1_IP/[keycloak-1]/" -e 's/^/ /'
@@ -0,0 +1,207 @@
#!/usr/bin/env bash
# Experiment 0 — is a session created on one Keycloak node usable on the other?
#
# Forming a cluster is not the same as sharing session state. The Infinispan log
# says "cluster view (2)", but that only proves the members found each other.
#
# Design notes, learned the hard way:
#
# * Every probe has a CONTROL. A result from the far node means nothing unless
# the same call against the issuing node is also measured. The first version
# of this script reported "403 on keycloak-1" as if it were a replication
# failure; the issuing node returned 403 too, and the cause was a missing
# openid scope. Measure both, always.
#
# * Sessions are tracked by SID, not by count. Both the test login and the
# admin API calls create sessions for the same user, so counts are noisy.
# A specific session id either appears in a node's answer or it does not.
#
# * The probe is the REFRESH TOKEN grant, not userinfo. userinfo only validates
# a signature and can succeed on a node that knows nothing about the session.
# Refreshing requires the node to find the session, check it is alive, and
# write back a new refresh time — it actually touches the session store.
#
# Talks to pod IPs directly: going through nginx/Traefik would hide which node
# handled each request, which is the entire question.
#
# ./deploy/lab/scripts/experiment-session-replication.sh
set -uo pipefail
NS="${NS:-keycloak-lab}"
OUT="${OUT:-/tmp/session-replication}"
mkdir -p "$OUT"
PSQL="kubectl -n $NS exec deploy/postgres -- psql -U keycloak -d keycloak -tAc"
echo "수집 시각: $(date '+%Y-%m-%d %H:%M:%S %Z')"
echo
K0_IP=$(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.status.podIP}')
K1_IP=$(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.status.podIP}')
K0_NODE=$(kubectl -n "$NS" get pod keycloak-0 -o jsonpath='{.spec.nodeName}')
K1_NODE=$(kubectl -n "$NS" get pod keycloak-1 -o jsonpath='{.spec.nodeName}')
ADMIN_PW=$(kubectl -n "$NS" get secret keycloak-lab-secrets \
-o jsonpath='{.data.KC_BOOTSTRAP_ADMIN_PASSWORD}' | base64 -d)
echo "=== 대상 ==="
printf ' keycloak-0 %-14s %s\n' "$K0_IP" "$K0_NODE"
printf ' keycloak-1 %-14s %s\n' "$K1_IP" "$K1_NODE"
echo
echo "=== [0] 실험 전 DB 세션 ==="
$PSQL "select offline_flag, count(*) from offline_user_session group by offline_flag" 2>/dev/null \
| sed 's/^/ offline_flag=/' || echo " (없음)"
echo
# 파드 하나 안에서 전 단계를 실행한다. 단계마다 파드를 새로 띄우면 토큰을
# 단계 사이로 넘길 수 없다.
kubectl -n "$NS" run kc-probe --rm -i --restart=Never \
--image=curlimages/curl:8.11.1 --quiet --command -- sh -c "
set -u
K0='http://$K0_IP:8080'; K1='http://$K1_IP:8080'
TOKEN_EP='/realms/master/protocol/openid-connect/token'
jget() { sed -n \"s/.*\\\"\$1\\\":\\\"\\([^\\\"]*\\)\\\".*/\\1/p\"; }
# ── [1] keycloak-0 에서 로그인. 이 노드가 세션의 출생지다 ──────────────────
LOGIN=\$(curl -s -X POST \"\$K0\$TOKEN_EP\" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d 'password=$ADMIN_PW')
echo '###STEP1_LOGIN'; echo \"\$LOGIN\"
AT=\$(echo \"\$LOGIN\" | jget access_token)
RT=\$(echo \"\$LOGIN\" | jget refresh_token)
# ── [2] 관리 API 조회용 토큰. 세션 오염을 피하려고 따로 하나만 더 만든다 ──
ADMTOK=\$(curl -s -X POST \"\$K0\$TOKEN_EP\" \
-d grant_type=password -d client_id=admin-cli \
-d username=admin -d 'password=$ADMIN_PW' | jget access_token)
CID=\$(curl -s -H \"Authorization: Bearer \$ADMTOK\" \
\"\$K0/admin/realms/master/clients?clientId=admin-cli\" | jget id | head -1)
# ── [3] 두 노드에 같은 질문을 한다: admin-cli 의 세션 목록 ────────────────
echo '###STEP3_SESSIONS_K0'
curl -s -H \"Authorization: Bearer \$ADMTOK\" \
\"\$K0/admin/realms/master/clients/\$CID/user-sessions?max=100\"
echo
echo '###STEP3_SESSIONS_K1'
curl -s -H \"Authorization: Bearer \$ADMTOK\" \
\"\$K1/admin/realms/master/clients/\$CID/user-sessions?max=100\"
echo
# ── [4] 대조군: keycloak-0 이 발급한 refresh token 을 keycloak-0 에 쓴다 ──
# 먼저 반대편에 써야 하므로 여기서는 쓰지 않고, 순서를 [5] 뒤로 미룬다.
# refresh token 은 회전(rotation)되므로 한 번 쓰면 옛 것이 무효가 된다.
# 따라서 '반대편 먼저'가 유일하게 의미 있는 순서다.
# ── [5] 시험군: keycloak-0 이 발급한 refresh token 을 keycloak-1 에 쓴다 ──
echo '###STEP5_REFRESH_ON_K1'
curl -s -w '\nhttp_code=%{http_code}\n' -X POST \"\$K1\$TOKEN_EP\" \
-d grant_type=refresh_token -d client_id=admin-cli -d \"refresh_token=\$RT\"
RT2=\$(curl -s -X POST \"\$K1\$TOKEN_EP\" \
-d grant_type=refresh_token -d client_id=admin-cli -d \"refresh_token=\$RT\" \
| jget refresh_token)
# ── [6] 무효화가 반대 방향으로도 전파되는가 ───────────────────────────────
# keycloak-1 에서 로그아웃시키고, keycloak-0 에서 갱신을 시도한다.
echo '###STEP6_LOGOUT_VIA_K1'
curl -s -o /dev/null -w 'http_code=%{http_code}\n' -X POST \"\$K1/realms/master/protocol/openid-connect/logout\" \
-d client_id=admin-cli -d \"refresh_token=\$RT2\"
echo '###STEP7_REFRESH_ON_K0_AFTER_LOGOUT'
curl -s -w '\nhttp_code=%{http_code}\n' -X POST \"\$K0\$TOKEN_EP\" \
-d grant_type=refresh_token -d client_id=admin-cli -d \"refresh_token=\$RT2\"
echo '###END'
" > "$OUT/raw.txt" 2>&1
sed -i '/^pod .* deleted$/d' "$OUT/raw.txt"
python3 - "$OUT/raw.txt" <<'PY' | tee "$OUT/report.txt"
import base64, json, sys
raw = open(sys.argv[1]).read()
blocks, cur = {}, None
for line in raw.splitlines():
if line.startswith('###'):
cur = line[3:]; blocks[cur] = []
elif cur is not None:
blocks[cur].append(line)
get = lambda k: '\n'.join(blocks.get(k, [])).strip()
def j(s):
try: return json.JSONDecoder().raw_decode(s.strip())[0]
except Exception: return None
def claims(tok):
p = tok.split('.')[1]; p += '=' * (-len(p) % 4)
return json.loads(base64.urlsafe_b64decode(p))
login = j(get('STEP1_LOGIN'))
if not login or 'access_token' not in login:
print('로그인 실패:', get('STEP1_LOGIN')[:300]); sys.exit(1)
ac = claims(login['access_token'])
rc = claims(login['refresh_token'])
SID = ac['sid']
print('=== [1] keycloak-0 에서 로그인 ===')
print(f" sid {SID}")
print(f" sub {ac.get('sub')}")
print(f" iss {ac.get('iss')}")
print(f" access 수명 {ac['exp']-ac['iat']}초")
print(f" refresh 수명 {rc['exp']-rc['iat']}초 typ={rc.get('typ')}")
print(f" refresh jti {rc.get('jti')}")
print()
print('=== [3] 같은 sid 가 두 노드 모두에서 보이는가 ===')
for step, who in (('STEP3_SESSIONS_K0', 'keycloak-0 (발급 노드)'),
('STEP3_SESSIONS_K1', 'keycloak-1 (반대편)')):
d = j(get(step))
if d is None:
print(f' {who:24} 파싱 실패: {get(step)[:120]}'); continue
ids = [s.get('id') for s in d]
mark = '보임 ✔' if SID in ids else '없음 ✘'
print(f' {who:24} 세션 {len(ids)}개 중 대상 sid → {mark}')
for s in d:
if s.get('id') == SID:
print(f" ipAddress={s.get('ipAddress')} start={s.get('start')} lastAccess={s.get('lastAccess')}")
def show(step, title, expect):
print(); print(f'=== {title} ===')
body = get(step)
code = [l for l in body.splitlines() if l.startswith('http_code=')]
code = code[0].split('=')[1] if code else '?'
d = j(body)
ok = '기대대로' if code == expect else f'기대({expect})와 다름'
print(f' HTTP {code} ← {ok}')
if d and 'access_token' in d:
c = claims(d['access_token'])
same = '동일 ✔' if c.get('sid') == SID else f"다름 ✘ ({c.get('sid')})"
print(f' 새 토큰의 sid → {same}')
elif d:
print(f" error {d.get('error')}")
print(f" error_description {d.get('error_description')}")
show('STEP5_REFRESH_ON_K1',
'[5] keycloak-0 이 발급한 refresh token 을 keycloak-1 에 사용', '200')
print(); print('=== [6] keycloak-1 을 통해 로그아웃 ===')
print(' ' + get('STEP6_LOGOUT_VIA_K1').strip())
show('STEP7_REFRESH_ON_K0_AFTER_LOGOUT',
'[7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파)', '400')
open('/tmp/session-replication/sid.txt','w').write(SID)
PY
SID=$(cat /tmp/session-replication/sid.txt 2>/dev/null)
echo
echo "=== [8] PostgreSQL 에서 그 sid 를 직접 확인 ==="
echo " 대상 sid: $SID"
$PSQL "select user_session_id, offline_flag, created_on, last_session_refresh
from offline_user_session where user_session_id='$SID'" 2>/dev/null \
| sed 's/^/ /' | grep -q . \
&& $PSQL "select user_session_id||' | flag='||offline_flag||' | created='||created_on||' | refresh='||last_session_refresh
from offline_user_session where user_session_id='$SID'" 2>/dev/null | sed 's/^/ /' \
|| echo " 행 없음 — 로그아웃으로 삭제되었다"
echo
echo " 전체 세션 수: $($PSQL 'select count(*) from offline_user_session' 2>/dev/null)"
@@ -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"
@@ -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."
@@ -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"