Compare commits

..
Author SHA1 Message Date
DongHyeonkaandClaude Opus 5 9c3cde457e docs: close nine gaps found by re-reading every open question in full
Cross-checked each question's 남은 미지수, 다음 검증 and 제약 against the plan item by item. Adds B-0 (autoconfiguration actually chosen), B-6 (encryption key rotation) and B-7 (oauth2-proxy cookie secret rotation) as new experiments, plus lock-holder death, rotation-disabled comparison, partial-logout recovery, store latency and the Q4 design checklist. Restores the Redis persistence comparison and records the correct index URL.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:16:59 +09:00
DongHyeonkaandClaude Opus 5 f1e8c35805 docs: plan all remaining experiments with architecture, injection points and predictions
Twenty experiments across four layers, each with a topology diagram marking where the fault goes in, the metrics to watch, a falsifiable prediction written before the run, and a pass/fail rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:50:23 +09:00
DongHyeonkaandClaude Opus 5 1de6108157 docs: add the prerequisite knowledge this lab assumes
Builds up from HTTP statelessness to why session storage location determines the operational response, so the measurements have context to land in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:24:21 +09:00
DongHyeonkaandClaude Opus 5 22d873eb4f docs: capture the SQL the other node actually runs, and correct the replication claim
PostgreSQL statement logging shows keycloak-1 reading and updating the session created on keycloak-0. The same transaction reveals optimistic locking via VERSION, SKIP LOCKED, and synchronous_commit turned off. Fixes the earlier concept note that credited Infinispan with cross-node propagation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:14:45 +09:00
DongHyeonkaandClaude Opus 5 e5ebaeb623 docs: prove sessions are shared by PostgreSQL, not Infinispan replication
Experiment 0 with three probes: cross-node refresh/logout, cache counter deltas around a single login, and cache entry ownership. Each node caches only what it handled; cache totals sum exactly to the database count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:58:10 +09:00
DongHyeonkaandClaude Opus 5 006da7d490 docs: record observability setup and add concept layers 10-13
Adds Kubernetes resources (StatefulSet, PVC, Secret, RBAC, placement), Keycloak clustering internals (Infinispan, JGroups), Prometheus concepts and virtualization operations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:21:45 +09:00
DongHyeonkaandClaude Opus 5 d5cc2b55a9 fix: grant nodes/proxy so kubelet metrics can be scraped
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:14:51 +09:00
DongHyeonkaandClaude Opus 5 0bb0e0ac49 feat: add Prometheus, node-exporter and Grafana for fault-injection observability
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 09:13:41 +09:00
DongHyeonkaandClaude Opus 5 33878e8880 docs: record multi-node cluster setup, rationale and formation evidence
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:26:45 +09:00
DongHyeonkaandClaude Opus 5 6dce35ec83 feat: deploy Keycloak multi-node cluster with PostgreSQL
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:17:25 +09:00
DongHyeonkaandClaude Opus 5 001efd624a docs: correct the memory analysis to distinguish host and guest headroom
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 17:02:11 +09:00
DongHyeonkaandClaude Opus 5 d6f8b9f8b3 docs: restructure roadmap into A/B/operations layers and add five operational items
Adds backup rehearsal, version upgrade, observability, secret management and certificate renewal. Corrects the experiment order so the refresh-token contention test runs after the shared store exists.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 15:04:38 +09:00
DongHyeonkaandClaude Opus 5 ae1f391598 docs: expand two-hop header contract with switch locations and per-pattern impact
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:57:08 +09:00
DongHyeonkaandClaude Opus 5 844d6f1d33 docs: measure and diagnose the two-hop proxy header contract
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:35:27 +09:00
DongHyeonkaandClaude Opus 5 a831792c5c docs: map deploy/ assets and record why unapplied configs are kept
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:18:08 +09:00
DongHyeonkaandClaude Opus 5 bcfdeb93ee docs: explain every setting used in the echo manifest
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 14:11:18 +09:00
DongHyeonkaandClaude Opus 5 deae8966b8 docs: record why Docker must not be installed on the lab host
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:53:36 +09:00
DongHyeonkaandClaude Opus 5 6c90468c5a feat: report pod address from echo endpoint
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 13:44:29 +09:00
DongHyeonka 51bb055d61 feat: 2홉 구성 진행 2026-09-03 11:46:56 +09:00
DongHyeonka c22b217fbd chore: 메타데이터 gitignore 2026-09-02 14:44:06 +09:00
donghyeon-ka c07593c471 merge: four-pattern tradeoff matrix 2026-07-25 16:31:44 +09:00
donghyeon-ka d01a60964a docs: compare four authentication patterns 2026-07-25 16:31:44 +09:00
donghyeon-ka 5d7544256a merge: public domain tunnel profile 2026-07-25 16:31:13 +09:00
donghyeon-ka eacc0e86c9 feat: add validated named tunnel profile 2026-07-25 16:31:13 +09:00
donghyeon-ka ac912cb354 merge: HTTPS termination profiles 2026-07-25 16:30:25 +09:00
donghyeon-ka e4cee2a06d feat: add validated HTTPS termination profiles 2026-07-25 16:30:25 +09:00
donghyeon-ka 006b405ad5 merge: reverse proxy header contract 2026-07-25 16:29:32 +09:00
donghyeon-ka 8539d1bf5b feat: define trusted reverse proxy header contract 2026-07-25 16:29:32 +09:00
donghyeon-ka 3473875d9a merge: Google redirect URI policy 2026-07-25 16:28:53 +09:00
donghyeon-ka f077e5038e docs: define exact Google redirect URI policy 2026-07-25 16:28:53 +09:00
donghyeon-ka e4eb7e54f7 merge: federated subject identity contract 2026-07-25 16:28:00 +09:00
donghyeon-ka bdde0feb86 test: verify broker identity uses subject not email 2026-07-25 16:28:00 +09:00
donghyeon-ka 3da8e609ae merge: broker claim-to-role mapping 2026-07-25 16:26:59 +09:00
donghyeon-ka ed064473dc feat: map broker claims to realm roles 2026-07-25 16:26:59 +09:00
donghyeon-ka a84f7d50a1 merge: Google claim attribute mapping 2026-07-25 16:24:24 +09:00
donghyeon-ka 98b07b4fdf feat: map upstream Google identity claims 2026-07-25 16:24:24 +09:00
donghyeon-ka 98566a713d merge: hardened First Broker Login flow 2026-07-25 16:17:54 +09:00
donghyeon-ka aeb783e592 test: reproduce and block unsafe broker auto-link 2026-07-25 16:17:54 +09:00
donghyeon-ka 3bbbaf5230 merge: Google broker configuration profiles 2026-07-25 15:30:47 +09:00
donghyeon-ka 2ee4b2af1c feat: add Google broker configuration profiles 2026-07-25 15:30:47 +09:00
donghyeon-ka 030ae94be3 merge: Keycloak branch governance index 2026-07-25 15:26:26 +09:00
donghyeon-ka 728e737dc8 docs: add 39-branch Keycloak governance index 2026-07-25 15:26:26 +09:00
123 changed files with 12779 additions and 535 deletions
+7 -2
View File
@@ -10,10 +10,15 @@ POSTGRES_PASSWORD=change-me-postgres-password
TOKEN_MEDIATING_CLIENT_SECRET=change-me-token-mediating-client-secret
BFF_CLIENT_SECRET=change-me-bff-client-secret
EDGE_PROXY_CLIENT_SECRET=change-me-edge-proxy-client-secret
OAUTH2_PROXY_COOKIE_SECRET=generate-a-base64-encoded-32-byte-secret
INTERNAL_AUTH_TOKEN=generate-a-long-random-edge-to-backend-token
MOCK_GOOGLE_BROKER_CLIENT_SECRET=change-me-mock-google-broker-client-secret
ADMIN_USER_PASSWORD=change-me-admin-user-password
REGULAR_USER_PASSWORD=change-me-regular-user-password
MOCK_GOOGLE_USER_PASSWORD=change-me-mock-google-user-password
# Optional real-Google profile. These are consumed only by
# scripts/configure-google-idp.sh and must never be committed with real values.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Port 80 is the single-EC2 target. 8088 avoids common local port conflicts.
NGINX_PORT=8088
+11 -2
View File
@@ -4,6 +4,15 @@
*.iml
backend/target/
**/node_modules/
frontend/dist/
build/
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,22 @@
[ 1289ms] [WARNING] <meta name="apple-mobile-web-app-capable" content="yes"> is deprecated. Please include <meta name="mobile-web-app-capable" content="yes"> @ https://app2.hyeonworks.com/login:0
[ 1417ms] [VERBOSE] [DOM] Input elements should have autocomplete attributes (suggested: "username"): (More info: https://goo.gl/9p2vKq) %o @ https://app2.hyeonworks.com/login:0
[ 9024ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 9130ms] [WARNING] <meta name="apple-mobile-web-app-capable" content="yes"> is deprecated. Please include <meta name="mobile-web-app-capable" content="yes"> @ https://app2.hyeonworks.com/:0
[ 9456ms] [WARNING] Deprecation warning: value provided is not in a recognized RFC2822 or ISO format. moment construction falls back to js Date(), which is not reliable across all browsers and versions. Non RFC2822/ISO date formats are discouraged. Please refer to http://momentjs.com/guides/#/warnings/js-date/ for more info.
Arguments:
[0] _isAMomentObject: true, _isUTC: false, _useUTC: false, _l: undefined, _i: Thu, 27 Aug 2026 13:03:49, _f: undefined, _strict: undefined, _locale: [object Object]
Error
at a.createFromInputFallback (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:3)
at an (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:25647)
at un (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:29355)
at aa (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:29221)
at on (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:28938)
at sa (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:29715)
at A (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:624:29748)
at a (https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:621:89)
at f (https://app2.hyeonworks.com/public/build/3719.c065b2e146c4c8347d51.js:1:4635)
at u (https://app2.hyeonworks.com/public/build/322.177b4bb01c5d74f9b28f.js:2473:47448) @ https://app2.hyeonworks.com/public/build/6029.0549a3fcb50e73c4b256.js:620
[ 9605ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 10620ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 11527ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 13875ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
@@ -0,0 +1,7 @@
[ 144ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 153ms] [WARNING] <meta name="apple-mobile-web-app-capable" content="yes"> is deprecated. Please include <meta name="mobile-web-app-capable" content="yes"> @ https://app2.hyeonworks.com/explore:0
[ 1077ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 2101ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 2922ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 7323ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 9370ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
@@ -0,0 +1,8 @@
[ 271ms] [WARNING] <meta name="apple-mobile-web-app-capable" content="yes"> is deprecated. Please include <meta name="mobile-web-app-capable" content="yes"> @ https://app2.hyeonworks.com/explore?schemaVersion=1&orgId=1&panes=%7B%22a%22%3A%7B%22datasource%22%3A%22PBFA97CFB590B2093%22%2C%22queries%22%3A%5B%7B%22refId%22%3A%22A%22%2C%22expr%22%3A%22vendor_statistics_approximate_entries_unique%7Bcache%3D%5C%22sessions%5C%22%7D%22%2C%22range%22%3Atrue%2C%22instant%22%3Afalse%2C%22editorMode%22%3A%22code%22%2C%22legendFormat%22%3A%22%7B%7Bpod%7D%7D%20on%20%7B%7Bnode%7D%7D%22%2C%22datasource%22%3A%7B%22type%22%3A%22prometheus%22%2C%22uid%22%3A%22PBFA97CFB590B2093%22%7D%7D%5D%2C%22range%22%3A%7B%22from%22%3A%22now-15m%22%2C%22to%22%3A%22now%22%7D%7D%7D:0
[ 346ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 1512ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 2433ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 6941ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 13188ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 21578ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
[ 25998ms] [ERROR] WebSocket connection to 'wss://app2.hyeonworks.com/api/live/ws' failed: Error during WebSocket handshake: Unexpected response code: 400 @ https://app2.hyeonworks.com/public/build/1518.a3f1f690c084a37f01c7.js:362
@@ -0,0 +1,7 @@
[ 766ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/3p-cookies/step1.html:0
[ 781ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/3p-cookies/step2.html:0
[ 17929ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/3p-cookies/step1.html:0
[ 17949ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/3p-cookies/step2.html:0
[ 17981ms] [WARNING] An iframe which has both allow-scripts and allow-same-origin for its sandbox attribute can escape its sandboxing. @ https://auth.hyeonworks.com/realms/master/protocol/openid-connect/login-status-iframe.html:0
[ 18447ms] [WARNING] For accessibility reasons an aria-label should be specified on nav groups if a title isn't @ https://auth.hyeonworks.com/resources/9v5yc/admin/keycloak.v2/assets/main-BbID33M6.js:7
[ 18462ms] [WARNING] For accessibility reasons an aria-label should be specified on nav groups if a title isn't @ https://auth.hyeonworks.com/resources/9v5yc/admin/keycloak.v2/assets/main-BbID33M6.js:7
@@ -0,0 +1,2 @@
[ 75ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/questions:0
[ 100ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ https://hyeonworks.com/favicon.ico:0
@@ -0,0 +1 @@
[ 149ms] [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 @@
[ 103ms] [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 @@
[ 132ms] [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 @@
[ 239ms] [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 @@
[ 105ms] [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 @@
[ 108ms] [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,2 @@
- main [ref=e7]:
- status "Loading" [ref=e10]
@@ -0,0 +1 @@
- main [ref=f3e7]
@@ -0,0 +1,2 @@
- main [ref=f6e7]:
- status "Loading" [ref=f6e10]
@@ -0,0 +1,104 @@
- generic [ref=f9e4]:
- link "Skip to main content" [ref=f9e5] [cursor=pointer]:
- /url: "#pageContent"
- banner [ref=f9e7]:
- generic [ref=f9e8]:
- link [ref=f9e10] [cursor=pointer]:
- /url: /
- img "Grafana" [ref=f9e11]
- generic [ref=f9e14]:
- button "Search or jump to..." [ref=f9e18] [cursor=pointer]
- generic [ref=f9e19]: ctrl+k
- generic [ref=f9e23]:
- button "New" [ref=f9e24] [cursor=pointer]
- button "Help" [ref=f9e30] [cursor=pointer]
- button "News" [ref=f9e33] [cursor=pointer]
- button "Profile" [ref=f9e36] [cursor=pointer]:
- img "User avatar" [ref=f9e37]
- generic [ref=f9e38]:
- button "Open menu" [ref=f9e40] [cursor=pointer]
- navigation "Breadcrumbs" [ref=f9e43]:
- list [ref=f9e44]:
- listitem [ref=f9e45]:
- link "Home" [ref=f9e46] [cursor=pointer]:
- /url: /
- listitem [ref=f9e50]:
- link "Explore" [ref=f9e51] [cursor=pointer]:
- /url: /explore
- listitem [ref=f9e55]:
- generic "Prometheus" [ref=f9e56]
- generic [ref=f9e57]:
- button "Show more items" [ref=f9e60] [cursor=pointer]
- button "Toggle top search bar" [ref=f9e64] [cursor=pointer]
- main [ref=f9e70]:
- generic [ref=f9e72]:
- heading "Explore" [level=1] [ref=f9e73]
- generic [ref=f9e78]:
- navigation "Explore toolbar" [ref=f9e80]:
- navigation "Search links" [ref=f9e82]:
- generic [ref=f9e83]:
- button "Content outline" [expanded] [ref=f9e85] [cursor=pointer]:
- generic [ref=f9e88]: Outline
- generic [ref=f9e93] [cursor=pointer]:
- img "Prometheus logo" [ref=f9e95]
- textbox "Select a data source" [ref=f9e96]:
- /placeholder: ""
- button "Show more items" [ref=f9e102] [cursor=pointer]
- generic [ref=f9e106]:
- generic [ref=f9e110]:
- button "Collapse outline" [expanded] [ref=f9e112] [cursor=pointer]:
- img "arrow-from-right" [ref=f9e113]
- button "Queries" [ref=f9e116] [cursor=pointer]:
- img "arrow" [ref=f9e117]
- generic [ref=f9e124]:
- generic [ref=f9e126]:
- generic "Query editor row" [ref=f9e129]:
- generic [ref=f9e130]:
- generic [ref=f9e132]:
- generic [ref=f9e133]:
- button "Collapse query row" [expanded] [ref=f9e134] [cursor=pointer]
- generic [ref=f9e137]:
- button "Query editor row title A" [ref=f9e138] [cursor=pointer]:
- generic [ref=f9e139]: A
- emphasis [ref=f9e140]: (Prometheus)
- generic [ref=f9e141]:
- button "Show data source help" [ref=f9e143] [cursor=pointer]
- button "Duplicate query" [ref=f9e147] [cursor=pointer]
- button "Hide response" [ref=f9e151] [cursor=pointer]
- button "Remove query" [ref=f9e155] [cursor=pointer]
- button "Drag and drop to reorder" [ref=f9e158]:
- img "Drag and drop to reorder" [ref=f9e159]
- generic [ref=f9e162]:
- generic [ref=f9e163]:
- button "Kick start your query" [ref=f9e164] [cursor=pointer]
- generic [ref=f9e167]:
- generic [ref=f9e168] [cursor=pointer]: Explain
- generic [ref=f9e169]:
- checkbox "Explain Toggle switch" [ref=f9e170]
- generic "Toggle switch" [ref=f9e171] [cursor=pointer]
- radiogroup [ref=f9e176]:
- generic [ref=f9e177]:
- radio "Builder" [ref=f9e178] [cursor=pointer]
- generic [ref=f9e179] [cursor=pointer]: Builder
- generic [ref=f9e180]:
- radio "Code" [checked] [ref=f9e181] [cursor=pointer]
- generic [ref=f9e182] [cursor=pointer]: Code
- generic [ref=f9e184]:
- generic [ref=f9e186]:
- button "Loading metrics..." [disabled] [ref=f9e187] [cursor=pointer]
- generic [ref=f9e190]: Loading editor
- 'button "Options Legend: {{pod}} on {{node}} Format: Time series Step: auto Type: Range Exemplars: false" [ref=f9e198] [cursor=pointer]':
- generic [ref=f9e202]:
- heading "Options" [level=6] [ref=f9e203]
- generic [ref=f9e204]:
- generic [ref=f9e205]: "Legend: {{pod}} on {{node}}"
- generic [ref=f9e206]: "Format: Time series"
- generic [ref=f9e207]: "Step: auto"
- generic [ref=f9e208]: "Type: Range"
- generic [ref=f9e209]: "Exemplars: false"
- generic [ref=f9e210]:
- button "Add query" [ref=f9e211] [cursor=pointer]
- button "Query history" [ref=f9e215] [cursor=pointer]
- button "Query inspector" [ref=f9e219] [cursor=pointer]
- generic:
- main
@@ -0,0 +1,4 @@
- main [ref=f12e3]:
- generic [ref=f12e4]:
- progressbar "Contents" [ref=f12e5]
- paragraph [ref=f12e8]: Loading the Administration Console
@@ -0,0 +1,4 @@
- generic [active] [ref=f15e1]:
- progressbar "Loading" [ref=f15e4]
- generic:
- list
@@ -0,0 +1 @@
- generic [active] [ref=f18e1]: Not Found
+12 -19
View File
@@ -1,5 +1,12 @@
# Keycloak Authentication Patterns
The 39-branch implementation registry is documented in
[`docs/keycloak-branch-index.md`](docs/keycloak-branch-index.md).
Google brokering has a credential-free local OIDC harness and an opt-in
real-Google profile described in
[`docs/google-idp-brokering.md`](docs/google-idp-brokering.md).
Keycloak을 중심으로 네 가지 브라우저 인증 통합 패턴을 같은 로컬
인프라에서 비교하는 학습 프로젝트입니다.
@@ -8,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
위에서 별도 브랜치로 진행합니다.
@@ -96,22 +108,3 @@ Keycloak을 잠시 중지하고 export한 뒤 자동으로 다시 올립니다.
runtime export에는 실제 client secret과 credential hash가 포함될 수 있어
gitignored `build/keycloak-export/`에 권한 `0600`으로만 저장됩니다.
## AP4: oauth2-proxy Edge Forward Auth
`develop-keycloak-pattern4`는 oauth2-proxy와 Nginx `auth_request`
인증을 edge에서 강제하는 패턴입니다.
```bash
./scripts/verify-pattern4.sh
```
첫 feature에서는 oauth2-proxy를 `http://localhost:4180`에 직접 노출해
OIDC redirect/PKCE/callback과 forwarded-user를 분리 확인합니다. 두 번째
feature부터 `http://localhost:8088` Nginx가 단일 진입점이며, 내부
`auth_request`는 브라우저 요청을 login 302로, API 요청을 JSON 401로
구분합니다. 최종 feature에서는 backend와 oauth2-proxy의 호스트 노출을
제거하고 Nginx 헤더 덮어쓰기와 내부 토큰 검증으로 spoofing을 막습니다.
자세한 내용은
[`docs/ap4-edge-forward-auth.md`](docs/ap4-edge-forward-auth.md)를
참고하세요.
@@ -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<>();
@@ -1,59 +0,0 @@
package com.example.keycloakpattern;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.LinkedHashMap;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class EdgeIdentityController {
private final byte[] internalAuthToken;
EdgeIdentityController(@Value("${edge.internal-auth-token}") String internalAuthToken) {
if (!hasText(internalAuthToken)) {
throw new IllegalStateException("edge.internal-auth-token must be configured");
}
this.internalAuthToken = internalAuthToken.getBytes(StandardCharsets.UTF_8);
}
@GetMapping("/edge/me")
ResponseEntity<Map<String, Object>> currentUser(HttpServletRequest request) {
String authRequestUser = request.getHeader("X-Auth-Request-User");
if (!hasText(authRequestUser) || !hasValidInternalToken(request)) {
return ResponseEntity.status(401).body(Map.of(
"error",
"trusted edge authentication is required"
));
}
Map<String, Object> response = new LinkedHashMap<>();
response.put("pattern", "AP4-edge-forward-auth");
response.put("user", authRequestUser);
response.put("email", request.getHeader("X-Auth-Request-Email"));
response.put("identityHeader", "X-Auth-Request-User");
return ResponseEntity.ok(response);
}
private boolean hasValidInternalToken(HttpServletRequest request) {
String suppliedToken = request.getHeader("X-Internal-Auth-Token");
if (!hasText(suppliedToken)) {
return false;
}
return MessageDigest.isEqual(
internalAuthToken,
suppliedToken.getBytes(StandardCharsets.UTF_8)
);
}
private static boolean hasText(String value) {
return value != null && !value.isBlank();
}
}
@@ -17,12 +17,8 @@ public class SecurityConfig {
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(
"/actuator/health",
"/actuator/health/**",
"/api/public",
"/edge/**"
)
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public",
"/api/echo")
.permitAll()
.anyRequest()
.authenticated())
+10 -3
View File
@@ -1,12 +1,19 @@
server:
port: ${SERVER_PORT:8081}
edge:
internal-auth-token: ${EDGE_INTERNAL_AUTH_TOKEN:}
# 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:
@@ -11,7 +11,7 @@ import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMock
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest(properties = "edge.internal-auth-token=test-internal-edge-token")
@SpringBootTest
@AutoConfigureMockMvc
class ApiSecurityTest {
@@ -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"))
@@ -40,36 +52,4 @@ class ApiSecurityTest {
.andExpect(jsonPath("$.subject").value("test-subject"))
.andExpect(jsonPath("$.username").value("regular-user"));
}
@Test
void edgeEndpointRejectsMissingTrustedHeaders() throws Exception {
mockMvc.perform(get("/edge/me"))
.andExpect(status().isUnauthorized());
}
@Test
void edgeEndpointRejectsForgedIdentityWithoutInternalToken() throws Exception {
mockMvc.perform(get("/edge/me")
.header("X-Auth-Request-User", "spoofed-admin"))
.andExpect(status().isUnauthorized());
}
@Test
void edgeEndpointRejectsWrongInternalToken() throws Exception {
mockMvc.perform(get("/edge/me")
.header("X-Auth-Request-User", "spoofed-admin")
.header("X-Internal-Auth-Token", "wrong-token"))
.andExpect(status().isUnauthorized());
}
@Test
void edgeEndpointAcceptsIdentityFromTrustedEdge() throws Exception {
mockMvc.perform(get("/edge/me")
.header("X-Auth-Request-User", "regular-user")
.header("X-Auth-Request-Email", "regular-user@example.test")
.header("X-Internal-Auth-Token", "test-internal-edge-token"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.user").value("regular-user"))
.andExpect(jsonPath("$.identityHeader").value("X-Auth-Request-User"));
}
}
+136
View File
@@ -0,0 +1,136 @@
# Session store lab
세션 저장소·refresh token 경쟁·장애 복구를 검증하는 2노드 k3s 실험대.
네 인증 패턴(AP1~AP4)을 가로지르는 공통층이므로 별도 축으로 관리한다.
이 문서는 **절차**만 담는다.
| 문서 | 내용 |
|---|---|
| [`docs/session-store-lab-roadmap.md`](../../docs/session-store-lab-roadmap.md) | 이 축의 계획과 진행 상황 |
| [`docs/session-lab-concepts.md`](../../docs/session-lab-concepts.md) | 등장 개념 전체 |
| [`docs/session-lab-operations.md`](../../docs/session-lab-operations.md) | 관측 도구 · 자주 쓰는 명령 · 훈련 |
| [`docs/two-hop-proxy-header-contract.md`](../../docs/two-hop-proxy-header-contract.md) | 첫 실험 결과 |
## 토폴로지
```
브라우저 / SSH (tailnet)
│ https://{auth,app1,app2}.hyeonworks.com → 100.83.212.4
lab host ── nginx :443 TLS 종료 · X-Forwarded-* 주입
│ nginx :80 301 → https
│ virbr0 192.168.122.0/24 (libvirt NAT)
├──▶ kc-lab-1 .11 k3s server Traefik :80
└──▶ kc-lab-2 .12 k3s agent Traefik :80
└──▶ Pod
```
`nginx → Traefik` **2홉**이 운영 구조와 같다는 점이 이 배치의 핵심이다.
L7 프록시가 두 겹인 이유는 역할이 다르기 때문이다 — nginx는 바깥세상과의
접점(TLS·인증서·헤더)을, Traefik은 클러스터 내부의 동적 라우팅을 맡는다.
## 구성 요소
| 경로 | 역할 |
|---|---|
| `cloud-init/kc-lab.yaml.example` | 게스트 부트스트랩 템플릿 |
| `host/nginx-keycloak-lab.conf` | lab host의 `sites-available/keycloak-lab` |
| `k8s/echo.yaml` | 2홉 헤더 계약 측정용 워크로드 |
| `scripts/rebuild-seed.sh` | cloud-init 시드 ISO 재생성 + 풀 업로드 |
| `scripts/build-and-import.sh` | 이미지 빌드 → 각 노드 containerd 반입 |
| `scripts/measure-proxy-headers.sh` | 헤더 계약 실측 |
| `scripts/verify-lab.sh` | 인프라 상태 점검 |
## 상태 점검
```bash
./deploy/lab/scripts/verify-lab.sh # lab host 에서
```
**`404`가 성공 신호다.** TLS가 종료되고 Traefik까지 도달했으나 매칭되는
Ingress 규칙이 없다는 뜻이다. `502`나 연결 거부면 체인이 끊긴 것이다.
## 첫 실험 — 2홉 헤더 계약
[`docs/reverse-proxy-headers.md`](../../docs/reverse-proxy-headers.md)의 계약은
nginx **1홉**을 가정하고 쓰였다. 실제 배치는 2홉이므로, nginx가 세팅한
`X-Forwarded-*`를 Traefik이 그대로 넘기는지 덮어쓰는지 **측정해서 확인한다.**
이 결론이 뒤의 모든 실험에 깔린다. Keycloak의 `iss` 클레임, redirect URL,
쿠키 도메인 검증이 전부 이 헤더에 의존하기 때문이다.
```bash
# 워크스테이션: 이미지 빌드 후 두 노드에 반입
./deploy/lab/scripts/build-and-import.sh
# lab host: 배포
kubectl apply -f deploy/lab/k8s/echo.yaml
kubectl -n header-lab rollout status deployment/echo
# 어디서든: 실측
./deploy/lab/scripts/measure-proxy-headers.sh
```
관측 대상은 넷이다.
1. `X-Forwarded-For` — Traefik이 **덧붙이는가 덮어쓰는가**
2. `X-Forwarded-Proto` / `-Host` / `-Port` — 그대로 전달되는가
3. **위조 내성** — 클라이언트가 직접 넣은 `X-Forwarded-*`가 앱까지 도달하는가
4. `scheme` / `secure` / `requestUrl` — Keycloak이 URL을 만들 때 쓰는 값
3번이 신뢰 경계의 핵심이다. 이 헤더들은 누구나 위조할 수 있는 평범한 HTTP
헤더이므로, 신뢰 경계에 선 프록시가 **반드시 덮어써야** 한다.
## 이미지 배포 경로
k3s는 containerd를 쓰고 이 실험대에는 레지스트리가 없다.
```
워크스테이션 docker build → docker save
│ ssh (lab host 경유)
게스트 sudo k3s ctr images import
매니페스트 imagePullPolicy: Never
```
**두 노드 모두에 반입해야 한다.** 스케줄러가 어느 노드에 배치할지 모른다.
Keycloak·PostgreSQL·Redis는 공식 이미지를 그대로 당겨오므로 이 경로가
필요한 것은 자체 빌드 이미지뿐이다.
**lab host에 Docker를 설치하지 않는다.** k3s의 containerd와 이미지 저장소가
갈려서 `docker build`한 이미지를 k3s가 보지 못하게 된다.
## 게스트 재생성
파괴적 실험 후 초기화하는 경로다.
```bash
virsh destroy kc-lab-1
virsh undefine kc-lab-1 # --remove-all-storage 는 시드 ISO 까지 지운다
virsh vol-delete --pool default kc-lab-1.qcow2
./deploy/lab/scripts/rebuild-seed.sh 1 # user-data 를 고쳤을 때만
virt-install --name kc-lab-1 --memory 3584 --vcpus 2 \
--disk size=20,backing_store=/var/lib/libvirt/images/base.qcow2 \
--disk vol=default/seed-kc-lab-1.iso,device=disk,bus=virtio,readonly=on \
--network network=default,mac=52:54:00:aa:bb:11 \
--import --os-variant debian12 --noautoconsole
```
시드는 **virtio 디스크**로 붙인다. `virt-install --cloud-init`은 시드를 SATA
CD-ROM으로 붙이는데, Debian `genericcloud` 이미지는 크기를 줄이려고 물리
하드웨어 드라이버를 제외해서 **AHCI 장치를 보지 못한다.** 그러면 cloud-init이
데이터소스를 찾지 못하고 아무 오류도 남기지 않은 채 종료한다. 증상은
hostname이 `localhost`로 남고 SSH가 `Permission denied (publickey)`로 거부되는
것뿐이다.
게스트에 들어갈 수 없을 때는 화면을 직접 뜬다.
```bash
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 확장자와 무관하게 PNG 로 저장된다
```
`localhost login:`이면 cloud-init 미실행, `kc-lab-1 login:`이면 실행된 것이다.
+37
View File
@@ -0,0 +1,37 @@
#cloud-config
# Template for both lab guests. scripts/rebuild-seed.sh substitutes __NODE__
# and bakes this into a CIDATA seed image.
#
# Copy to kc-lab.yaml and fill the two placeholders. The real file is ignored by
# git because plain_text_passwd is a credential, however disposable.
#
# Indentation is spaces only. YAML forbids tabs, and cloud-init fails silently
# on a parse error: the guest boots as "localhost" with no user and no way in.
hostname: kc-lab-__NODE__
fqdn: kc-lab-__NODE__
manage_etc_hosts: true
users:
- name: donghyeon
groups: [sudo]
shell: /bin/bash
# NOPASSWD is required: the k3s installer and the fault-injection scripts
# run non-interactively and would block on a password prompt.
sudo: ['ALL=(ALL) NOPASSWD:ALL']
# Console-only escape hatch. Without it, a cloud-init failure leaves a guest
# that cannot be logged into at all, so its own failure log is unreadable.
# ssh_pwauth stays false, so this never widens SSH exposure.
lock_passwd: false
plain_text_passwd: CHANGE_ME
ssh_authorized_keys:
# Lab host key: needed because automation runs from the lab host, where
# agent forwarding is not available.
- CHANGE_ME_LAB_HOST_PUBLIC_KEY
# Workstation key: lets ProxyJump reach the guest directly.
- CHANGE_ME_WORKSTATION_PUBLIC_KEY
ssh_pwauth: false
package_update: true
packages:
- curl
- nftables
+56
View File
@@ -0,0 +1,56 @@
# Lab entry point. Deployed on the lab host as
# /etc/nginx/sites-available/keycloak-lab
# and symlinked from sites-enabled/.
#
# Arch does not ship the Debian sites-available convention, so nginx.conf needs
# include /etc/nginx/sites-enabled/*;
# inside its http { } block before this file has any effect.
#
# This is the outer of two L7 hops. It terminates TLS and hands plain HTTP to
# the Traefik instance running on each k3s node.
upstream k3s_traefik {
# Sticky-session switch. Keycloak recommends affinity on AUTH_SESSION_ID;
# ip_hash is the cheap stand-in for a single-browser lab. Leaving it off is
# the interesting case: Infinispan still routes correctly, only slower.
# ip_hash;
server 192.168.122.11:80;
server 192.168.122.12:80;
}
server {
listen 80 default_server;
server_name _;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl default_server;
http2 on;
server_name _;
# fullchain.pem, never cert.pem: omitting the intermediates passes on
# desktop browsers and fails on mobile and curl.
ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
location / {
proxy_pass http://k3s_traefik;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Port 443;
# $remote_addr, not $proxy_add_x_forwarded_for. This is the trust
# boundary: a client-supplied X-Forwarded-For must be discarded, not
# extended, or nothing downstream can rely on the value.
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
}
+62
View File
@@ -0,0 +1,62 @@
# Restrict who may reach the echo pods.
#
# Traefik is configured to trust X-Forwarded-* from the whole pod CIDR, and the
# app's Tomcat valve trusts every private range by default. Both are IP-range
# decisions, so any pod in the cluster can forge those headers by talking to the
# Service directly and bypassing Traefik entirely. Measured, not hypothetical:
#
# kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \
# curl -s http://echo:8081/api/echo -H 'X-Forwarded-Host: evil.example.com'
# → serverName evil.example.com, remoteAddr 1.2.3.4
#
# A NetworkPolicy closes that path. It selects by label rather than IP, so it
# survives pod restarts and rescheduling — unlike the trustedIPs list, which
# could not name Traefik because its IP changes.
#
# "Trusting forwarded headers" and "guaranteeing a proxy sits in front" are a
# pair. Doing only the first leaves this hole.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: echo-allow-traefik-only
namespace: header-lab
spec:
podSelector:
matchLabels:
app: echo
policyTypes:
- Ingress
ingress:
# The proxy itself. namespaceSelector and podSelector in one list item are
# ANDed, so this is "traefik pods in kube-system" and nothing else.
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik
ports:
- protocol: TCP
port: 8081
# kubelet readiness/liveness probes originate from the node, not from a pod,
# so they need their own rule. Without it the probes fail and the pods are
# restarted in a loop.
#
# The probe's source address is the node's flannel bridge (cni0), which
# holds the first address of that node's /24:
# kc-lab-1 10.42.0.1 kc-lab-2 10.42.1.1
# Listing them as /32 keeps this rule from re-admitting arbitrary pods,
# which a broader 10.42.0.0/16 block would do and would undo the policy.
#
# Adding a node means adding its gateway here. Verify with:
# kubectl get nodes -o jsonpath='{range .items[*]}{.spec.podCIDR}{"\n"}{end}'
- from:
- ipBlock:
cidr: 10.42.0.1/32
- ipBlock:
cidr: 10.42.1.1/32
ports:
- protocol: TCP
port: 8081
+113
View File
@@ -0,0 +1,113 @@
# Header echo workload for the two-hop proxy contract measurement.
#
# browser -> host nginx (TLS termination) -> Traefik -> this pod
#
# The image is built from backend/ and imported straight into each node's
# containerd, so imagePullPolicy must stay Never. See scripts/build-and-import.sh.
apiVersion: v1
kind: Namespace
metadata:
name: header-lab
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: echo
namespace: header-lab
spec:
replicas: 2
selector:
matchLabels:
app: echo
template:
metadata:
labels:
app: echo
spec:
# One replica per node so the sticky-session switch on the host nginx
# upstream has something observable to route between.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app: echo
containers:
- name: echo
image: keycloak-pattern-api:lab
imagePullPolicy: Never
ports:
- containerPort: 8081
name: http
env:
- name: SERVER_PORT
value: "8081"
# "none" makes the app report the raw connection, so scheme/secure/
# requestUrl show what arrives without any forwarded-header handling.
# Set to "native" and redeploy to see the same request interpreted
# with X-Forwarded-* honoured. Keycloak's KC_PROXY_HEADERS is the
# same opt-in, which is why measuring both sides matters here.
- name: SERVER_FORWARD_HEADERS_STRATEGY
value: "native"
# The JVM sizes its heap from the container limit, not the host.
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=70"
# /api/echo is permitAll, so the JWT decoder is never exercised.
# These stay pointed at the future Keycloak service name.
- name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI
value: "https://auth.hyeonworks.com/realms/keycloak-patterns"
- name: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI
value: "https://auth.hyeonworks.com/realms/keycloak-patterns/protocol/openid-connect/certs"
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: http
initialDelaySeconds: 15
periodSeconds: 5
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: http
initialDelaySeconds: 45
periodSeconds: 15
resources:
requests:
memory: 320Mi
cpu: 100m
limits:
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: echo
namespace: header-lab
spec:
selector:
app: echo
ports:
- port: 8081
targetPort: http
name: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: echo
namespace: header-lab
spec:
# k3s ships Traefik as the default ingress controller. Keeping it is what
# makes this lab a faithful two-hop replica.
ingressClassName: traefik
rules:
- host: app1.hyeonworks.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: echo
port:
number: 8081
+277
View File
@@ -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
+373
View File
@@ -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
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Build the API image on this workstation and import it into each lab node's
# containerd.
#
# k3s does not run Docker and the lab has no registry, so images are shipped as
# a stream: docker save -> ssh through the lab host -> k3s ctr images import.
# Every node needs its own copy because the scheduler may place the pod anywhere.
#
# ./deploy/lab/scripts/build-and-import.sh
# IMAGE=keycloak-pattern-api:lab NODES="kc-lab-1" ./deploy/lab/scripts/build-and-import.sh
set -euo pipefail
IMAGE="${IMAGE:-keycloak-pattern-api:lab}"
NODES="${NODES:-kc-lab-1 kc-lab-2}"
LAB_HOST="${LAB_HOST:-test-server}"
CONTEXT="${CONTEXT:-backend}"
repo_root="$(git rev-parse --show-toplevel)"
cd "$repo_root"
echo "==> building ${IMAGE} from ${CONTEXT}/"
docker build -t "$IMAGE" "$CONTEXT"
for node in $NODES; do
echo "==> importing into ${node}"
# Nested ssh: the workstation cannot reach the guests directly because they
# sit behind the lab host's libvirt NAT. The lab host's ~/.ssh/config holds
# the kc-lab-* aliases.
docker save "$IMAGE" \
| ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images import -'"
done
echo "==> verifying"
for node in $NODES; do
printf ' %-10s ' "$node"
ssh "$LAB_HOST" "ssh ${node} 'sudo k3s ctr images ls -q'" \
| grep -c "$IMAGE" \
| xargs -I{} echo "{} match(es)"
done
echo
echo "next: kubectl rollout restart -n header-lab deployment/echo"
+55
View File
@@ -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 세션 /'
+83
View File
@@ -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
+108
View File
@@ -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/^/ /'
+207
View File
@@ -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)"
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Measure what the nginx -> Traefik chain actually delivers to the application.
#
# docs/reverse-proxy-headers.md documents a single-hop nginx contract. The lab
# runs two hops, so the forwarded headers are measured rather than assumed.
# Run from anywhere that can resolve the lab hostnames.
#
# ./deploy/lab/scripts/measure-proxy-headers.sh
set -euo pipefail
HOST="${HOST:-app1.hyeonworks.com}"
URL="https://${HOST}/api/echo"
jqf() {
if command -v jq >/dev/null 2>&1; then jq "$@"; else python3 -m json.tool; fi
}
echo "=== 1. baseline: what the app sees for a normal request ==="
curl -s "$URL" | jqf '{
scheme, secure, serverName, serverPort, requestUrl, remoteAddr,
forwarded: .headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip" or . == "forwarded"))
}' 2>/dev/null || curl -s "$URL"
echo
echo "=== 2. spoof test: client sends its own X-Forwarded-* ==="
echo " a trusted boundary must overwrite these, not append to them"
curl -s "$URL" \
-H 'X-Forwarded-For: 1.2.3.4' \
-H 'X-Forwarded-Proto: http' \
-H 'X-Forwarded-Host: evil.example.com' \
-H 'X-Real-IP: 1.2.3.4' \
| jqf '.headers | with_entries(select(.key | startswith("x-forwarded") or . == "x-real-ip"))' 2>/dev/null
echo
echo "=== 3. which pod answered (host nginx upstream distribution) ==="
for _ in 1 2 3 4; do
curl -s "$URL" | jqf -r '.headers["x-forwarded-server"] // "n/a"' 2>/dev/null
done
echo
echo "=== 4. plain HTTP is redirected, not proxied ==="
curl -s -o /dev/null -w ' http -> %{http_code} %{redirect_url}\n' "http://${HOST}/api/echo"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Rebuild a guest's cloud-init seed image and publish it into the libvirt pool.
# Run on the lab host.
#
# ./rebuild-seed.sh 1
#
# The same content lives in three places: the source YAML, the ISO, and the
# uploaded pool volume. Editing the YAML alone changes nothing, which is why
# this is a script and not a set of remembered commands.
#
# A rebuilt seed only takes effect on a freshly created VM. cloud-init runs its
# per-instance modules once per instance-id, so an existing guest ignores it.
set -euo pipefail
N="${1:?usage: rebuild-seed.sh <1|2>}"
CLOUD_DIR="${CLOUD_DIR:-$HOME/workspace/cloud}"
POOL="${POOL:-default}"
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
cd "$CLOUD_DIR"
src="kc-lab-${N}.yaml"
iso="seed-kc-lab-${N}.iso"
meta="meta-kc-lab-${N}"
[ -f "$src" ] || { echo "missing $CLOUD_DIR/$src" >&2; exit 1; }
# A fresh instance-id makes cloud-init treat the guest as new and re-run the
# per-instance modules.
printf 'instance-id: kc-lab-%s-%s\nlocal-hostname: kc-lab-%s\n' \
"$N" "$(date +%s)" "$N" > "$meta"
# NoCloud looks for a volume labelled cidata holding files named exactly
# user-data and meta-data. -graft-points renames them inside the image so no
# staging directory is needed.
xorrisofs -quiet -output "$iso" -volid CIDATA -joliet -rock -graft-points \
"/user-data=${src}" "/meta-data=${meta}"
size="$(stat -c%s "$iso")"
virsh vol-delete --pool "$POOL" "$iso" >/dev/null 2>&1 || true
virsh vol-create-as "$POOL" "$iso" "$size" --format raw >/dev/null
virsh vol-upload --pool "$POOL" "$iso" "$iso"
echo "$iso published to pool '$POOL' ($size bytes)"
echo "attach it as a virtio disk, not a SATA cdrom:"
echo " --disk vol=${POOL}/${iso},device=disk,bus=virtio,readonly=on"
echo "Debian genericcloud images carry no AHCI driver, so a SATA cdrom is invisible"
echo "to the guest and cloud-init fails with no error anywhere."
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Confirm the lab infrastructure is intact. Run on the lab host.
#
# A 404 from the HTTPS entry point is the success signal: TLS terminated and the
# request reached Traefik, which simply had no matching ingress rule. A 502 or a
# refused connection means the chain is broken somewhere.
set -uo pipefail
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
HOSTS="${HOSTS:-auth.hyeonworks.com app1.hyeonworks.com app2.hyeonworks.com}"
NODE_IPS="${NODE_IPS:-192.168.122.11 192.168.122.12}"
fail=0
check() { # description, expected, actual
if [ "$2" = "$3" ]; then printf ' ok %-34s %s\n' "$1" "$3"
else printf ' FAIL %-34s got %s, want %s\n' "$1" "$3" "$2"; fail=1; fi
}
echo "== guests =="
for name in kc-lab-1 kc-lab-2; do
check "$name" running "$(virsh domstate "$name" 2>/dev/null || echo absent)"
done
echo "== k3s =="
ready="$(kubectl get nodes --no-headers 2>/dev/null | grep -c ' Ready ')"
check "nodes Ready" 2 "$ready"
lb="$(kubectl -n kube-system get svc traefik \
-o jsonpath='{.status.loadBalancer.ingress[*].ip}' 2>/dev/null | wc -w)"
check "traefik node IPs" 2 "$lb"
echo "== host nginx =="
check "service" active "$(systemctl is-active nginx)"
check "cert renew timer" active "$(systemctl is-active certbot-renew.timer)"
for ip in $NODE_IPS; do
check "traefik $ip" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "http://${ip}/")"
done
echo "== public entry point =="
for h in $HOSTS; do
check "https://$h" 404 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "https://${h}/")"
check "tls verify $h" 0 "$(curl -s -o /dev/null -w '%{ssl_verify_result}' --max-time 8 "https://${h}/")"
done
check "http redirect" 301 "$(curl -s -o /dev/null -w '%{http_code}' --max-time 8 "http://${HOSTS%% *}/")"
echo
[ "$fail" -eq 0 ] && echo "lab is healthy" || echo "lab has failures"
exit "$fail"
@@ -0,0 +1,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
+14
View File
@@ -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;
}
}
+10
View File
@@ -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
}
}
+21
View File
@@ -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;
}
}
}
+7
View File
@@ -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
+6 -63
View File
@@ -38,8 +38,10 @@ services:
TOKEN_MEDIATING_CLIENT_SECRET: ${TOKEN_MEDIATING_CLIENT_SECRET:?set TOKEN_MEDIATING_CLIENT_SECRET in .env}
BFF_CLIENT_SECRET: ${BFF_CLIENT_SECRET:?set BFF_CLIENT_SECRET in .env}
EDGE_PROXY_CLIENT_SECRET: ${EDGE_PROXY_CLIENT_SECRET:?set EDGE_PROXY_CLIENT_SECRET in .env}
MOCK_GOOGLE_BROKER_CLIENT_SECRET: ${MOCK_GOOGLE_BROKER_CLIENT_SECRET:?set MOCK_GOOGLE_BROKER_CLIENT_SECRET in .env}
ADMIN_USER_PASSWORD: ${ADMIN_USER_PASSWORD:?set ADMIN_USER_PASSWORD in .env}
REGULAR_USER_PASSWORD: ${REGULAR_USER_PASSWORD:?set REGULAR_USER_PASSWORD in .env}
MOCK_GOOGLE_USER_PASSWORD: ${MOCK_GOOGLE_USER_PASSWORD:?set MOCK_GOOGLE_USER_PASSWORD in .env}
ports:
- "127.0.0.1:8080:8080"
volumes:
@@ -69,9 +71,8 @@ services:
SERVER_PORT: "8081"
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: http://localhost:8080/realms/keycloak-patterns
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
EDGE_INTERNAL_AUTH_TOKEN: ${INTERNAL_AUTH_TOKEN:?set INTERNAL_AUTH_TOKEN in .env}
expose:
- "8081"
ports:
- "127.0.0.1:8081:8081"
depends_on:
keycloak:
condition: service_healthy
@@ -87,67 +88,13 @@ services:
- keycloak-net
restart: unless-stopped
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.2
command:
- --http-address=0.0.0.0:4180
- --provider=keycloak-oidc
- --oidc-issuer-url=http://localhost:8080/realms/keycloak-patterns
- --skip-oidc-discovery=true
- --login-url=http://localhost:8080/realms/keycloak-patterns/protocol/openid-connect/auth
- --redeem-url=http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/token
- --oidc-jwks-url=http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/certs
- --profile-url=http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/userinfo
- --validate-url=http://keycloak:8080/realms/keycloak-patterns/protocol/openid-connect/userinfo
- --redirect-url=http://localhost:8088/oauth2/callback
- --upstream=http://app:8081
- --email-domain=*
- --scope=openid profile email
- --code-challenge-method=S256
- --reverse-proxy=true
- --trusted-proxy-ip=172.30.40.10/32
- --cookie-name=AP4_SESSION
- --cookie-secure=false
- --cookie-samesite=lax
- --cookie-expire=1h
- --skip-provider-button=true
- --set-xauthrequest=true
- --pass-user-headers=true
- --whitelist-domain=localhost:8088
- --whitelist-domain=localhost:8080
environment:
OAUTH2_PROXY_CLIENT_ID: edge-proxy
OAUTH2_PROXY_CLIENT_SECRET: ${EDGE_PROXY_CLIENT_SECRET:?set EDGE_PROXY_CLIENT_SECRET in .env}
OAUTH2_PROXY_COOKIE_SECRET: ${OAUTH2_PROXY_COOKIE_SECRET:?set OAUTH2_PROXY_COOKIE_SECRET in .env}
expose:
- "4180"
depends_on:
keycloak:
condition: service_healthy
app:
condition: service_healthy
healthcheck:
test:
- CMD
- /bin/oauth2-proxy
- --version
interval: 10s
timeout: 5s
retries: 3
start_period: 5s
networks:
- keycloak-net
restart: unless-stopped
nginx:
build:
context: ./frontend
environment:
INTERNAL_AUTH_TOKEN: ${INTERNAL_AUTH_TOKEN:?set INTERNAL_AUTH_TOKEN in .env}
ports:
- "127.0.0.1:${NGINX_PORT:-8088}:80"
depends_on:
oauth2-proxy:
app:
condition: service_healthy
healthcheck:
test:
@@ -157,8 +104,7 @@ services:
timeout: 5s
retries: 12
networks:
keycloak-net:
ipv4_address: 172.30.40.10
- keycloak-net
restart: unless-stopped
volumes:
@@ -168,6 +114,3 @@ volumes:
networks:
keycloak-net:
driver: bridge
ipam:
config:
- subnet: 172.30.40.0/24
+18
View File
@@ -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을 복구한다.
-70
View File
@@ -1,70 +0,0 @@
# AP4 · oauth2-proxy Edge Forward Auth
## 첫 단계: oauth2-proxy 자체 OIDC 흐름
`feature/keycloak-oauth2-proxy-oidc-flow`에서는 oauth2-proxy를
`http://localhost:4180`에 직접 노출해 구성 요소를 분리해서 확인합니다.
1. `/edge/me` 미인증 요청이 Keycloak로 redirect됩니다.
2. oauth2-proxy는 confidential `edge-proxy` client와 PKCE S256을 사용합니다.
3. callback에서 code/token 교환과 ID/access token 검증은 서버끼리
수행합니다.
4. 브라우저에는 HttpOnly `AP4_SESSION` cookie만 남습니다.
5. oauth2-proxy가 backend 요청에 `X-Forwarded-User`를 붙여 200을 받습니다.
Keycloak이 발급하는 issuer는 브라우저 기준
`http://localhost:8080/realms/keycloak-patterns`입니다. 컨테이너 내부의
`localhost`는 oauth2-proxy 자신이므로 discovery endpoint에 도달할 수
없습니다. 그래서 이 로컬 Compose 구성은 issuer 검증값은 외부 URL로
유지하되, login URL은 브라우저용 외부 주소, token/JWKS/userinfo는
`http://keycloak:8080` 내부 주소로 각각 명시합니다.
HTTP 로컬 시연이라 `cookie-secure=false`를 사용합니다. 운영 HTTPS에서는
반드시 secure cookie로 되돌려야 합니다.
## 다음 단계의 보안 전제
이 첫 feature의 backend는 전달된 사용자 헤더를 신뢰하며 8081도
loopback에 publish되어 있습니다. 따라서 로컬에서 직접
`X-Forwarded-User: spoofed-admin`을 보내면 우회가 재현됩니다. 이후
Nginx `auth_request` 통합을 거쳐 최종 feature에서 backend no-publish와
내부 shared-secret 검증을 함께 적용합니다.
## 두 번째 단계: Nginx `auth_request`
`feature/keycloak-nginx-auth-request-integration`부터 외부 진입점은
`http://localhost:8088` Nginx 하나입니다. oauth2-proxy의 4180 포트는
Compose 네트워크에만 expose됩니다.
- Nginx의 정확 일치 `location = /oauth2/auth``internal`이라 외부에서
직접 호출할 수 없습니다.
- 인증 서브리퀘스트에는 본문을 보내지 않고 `Content-Length`
비웁니다.
- 일반 브라우저 요청의 401은 `/oauth2/start` 302로 변환합니다.
- API 요청 `/api/edge`는 redirect하지 않고 JSON 401을 반환합니다.
- 인증 성공 시 oauth2-proxy의 `X-Auth-Request-User`와 email만 backend로
전달합니다.
Nginx 컨테이너 IP를 전용 Compose subnet에서 고정하고 oauth2-proxy의
trusted proxy를 그 단일 IP로 제한합니다. 다만 이 단계에서는 backend
8081이 로컬 호스트에 열려 있어 신뢰 헤더를 직접 위조할 수 있습니다.
그 재현 조건은 마지막 feature에서 제거합니다.
## 마지막 단계: 신뢰 경계와 헤더 스푸핑 방어
`feature/keycloak-header-spoofing-defense`에서는 신뢰 경계를 실제
네트워크와 application 양쪽에서 강제합니다.
1. backend 8081과 oauth2-proxy 4180은 host에 publish하지 않습니다.
브라우저가 접근 가능한 application 포트는 Nginx 8088뿐입니다.
2. Nginx는 client가 보낸 `X-Auth-Request-User`, email, 내부 토큰을
그대로 전달하지 않고 oauth2-proxy 결과와 server-side 토큰으로
항상 덮어씁니다.
3. backend는 `X-Auth-Request-User``X-Internal-Auth-Token`이 모두
유효할 때만 edge identity를 받아들이며 token은 constant-time으로
비교합니다.
shared token은 방어 심층화 수단입니다. 운영에서는 Secret Manager나
orchestrator secret으로 주입하고 주기적으로 교체해야 합니다. 서비스
간 mTLS 또는 service mesh identity를 사용할 수 있다면 단순 shared
token보다 강한 workload identity로 대체하는 편이 좋습니다.
@@ -0,0 +1,56 @@
수집 시각: 2026-09-03 17:24:54 KST
대상: Keycloak 26.7.0 × 2 + PostgreSQL 16, k3s 2노드
=== [1] 파드 배치 ===
keycloak-0 1/1 10.42.1.18 kc-lab-2
keycloak-1 1/1 10.42.0.16 kc-lab-1
postgres-7b474b88c8-bw7b8 1/1 10.42.1.19 kc-lab-2
=== [2] 클러스터 뷰 로그 (Infinispan) ===
-- keycloak-0 --
2026-09-03 08:18:23,359 INFO [org.infinispan.CLUSTER] (executor-thread-1) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-26938(v=16.0.12)|1] (2) [keycloak-1-26938(v=16.0.12), keycloak-0-49501(v=16.0.12)]
2026-09-03 08:18:23,433 INFO [org.infinispan.CLUSTER] (executor-thread-1) ISPN000079: Channel `ISPN` local address is `keycloak-0-49501`, physical addresses are `[10.42.1.18:7800]`
-- keycloak-1 --
2026-09-03 08:18:23,269 INFO [org.infinispan.CLUSTER] (jgroups-5,keycloak-1-26938(v=16.0.12)) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-26938(v=16.0.12)|1] (2) [keycloak-1-26938(v=16.0.12), keycloak-0-49501(v=16.0.12)]
2026-09-03 08:18:23,282 INFO [org.infinispan.CLUSTER] (jgroups-5,keycloak-1-26938(v=16.0.12)) ISPN100000: Node keycloak-0-49501 joined the cluster
2026-09-03 08:18:23,286 INFO [org.infinispan.CLUSTER] (jgroups-5,keycloak-1-26938(v=16.0.12)) ISPN100000: Node keycloak-0-49501 joined the cluster
=== [3] JGROUPS_PING 테이블 구조 ===
Table "public.jgroups_ping"
Column | Type | Collation | Nullable | Default
----------------+------------------------+-----------+----------+---------
address | character varying(200) | | not null |
name | character varying(200) | | |
cluster_name | character varying(200) | | not null |
ip | character varying(200) | | not null |
coord | boolean | | |
last_update | bigint | | |
coordinated_by | character varying(200) | | |
Indexes:
"constraint_jgroups_ping" PRIMARY KEY, btree (address)
=== [4] JGROUPS_PING 등록 내역 ===
name | cluster_name | ip | coord
------------------+--------------+-----------------+-------
keycloak-0-49501 | ISPN | 10.42.1.18:7800 | f
keycloak-1-26938 | ISPN | 10.42.0.16:7800 | t
(2 rows)
=== [5] 외부 접근 — OIDC discovery ===
issuer https://auth.hyeonworks.com/realms/master
authorization_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/auth
token_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token
end_session_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/logout
jwks_uri https://auth.hyeonworks.com/realms/master/protocol/openid-connect/certs
★ 전부 https. 첫 실험에서 확정한 KC_HOSTNAME + KC_PROXY_HEADERS 조합이 작동한다.
=== [6] 자원 사용 ===
keycloak-0 8m 594Mi
keycloak-1 9m 593Mi
postgres-7b474b88c8-bw7b8 3m 67Mi
--- 노드 ---
kc-lab-1 2248Mi (65%)
kc-lab-2 1447Mi (58%)
@@ -0,0 +1,62 @@
# 증거 — Keycloak 멀티노드 클러스터 형성
`docs/keycloak-multinode-cluster.md`의 근거 자료.
**정상적으로 클러스터가 형성된 상태**에서 수집했으며, 이후 고장을 주입한
뒤 이것과 대조한다.
수집 시각: 2026-09-03 17:24 KST
| 파일 | 내용 |
|---|---|
| `01-cluster-formed.txt` | 파드 배치·클러스터 뷰 로그·JGROUPS_PING·OIDC discovery·자원 |
## 이 상태에서 확인된 것
**클러스터 뷰가 멤버 2를 보고한다**
```
ISPN000094: Received new cluster view for channel ISPN:
[keycloak-1-26938|1] (2) [keycloak-1-26938, keycloak-0-49501]
ISPN100000: Node keycloak-0-49501 joined the cluster
ISPN000079: physical addresses are [10.42.1.18:7800]
```
**디스커버리와 통신 경로가 한 테이블에 다 보인다**
```
name | cluster_name | ip | coord
------------------+--------------+-----------------+-------
keycloak-0-49501 | ISPN | 10.42.1.18:7800 | f
keycloak-1-26938 | ISPN | 10.42.0.16:7800 | t
```
`name`/`cluster_name`은 **DB 디스커버리**의 결과이고, `ip``:7800`
**실제 통신 경로**다. 7800을 막으면 이 표는 그대로 채워지면서 클러스터 뷰만
깨질 것으로 예상한다 — 다음 실험의 가설이다.
`coord = t``keycloak-1`이 코디네이터다.
**배치** — 서로 다른 노드에 하나씩. PostgreSQL은 `kc-lab-2`에 있으므로
**그 노드를 죽이면 Keycloak 하나와 DB가 동시에 사라진다.**
```
keycloak-0 10.42.1.18 kc-lab-2
keycloak-1 10.42.0.16 kc-lab-1
postgres 10.42.1.19 kc-lab-2
```
**issuer가 https로 발급된다** — 첫 실험(2홉 헤더 계약)의 결론이 적용된 결과다.
## 재수집
```bash
kubectl -n keycloak-lab get pods -o wide
kubectl -n keycloak-lab logs keycloak-0 | grep -E 'ISPN000094|ISPN000079|ISPN100000'
PG=$(kubectl -n keycloak-lab get pod -l app=postgres -o name | head -1)
kubectl -n keycloak-lab exec "$PG" -- \
psql -U keycloak -d keycloak -c "SELECT name, cluster_name, ip, coord FROM jgroups_ping ORDER BY name;"
curl -s https://auth.hyeonworks.com/realms/master/.well-known/openid-configuration | python3 -m json.tool
kubectl -n keycloak-lab top pods
```
@@ -0,0 +1,52 @@
===================================================================
실험 0 — 한 노드에서 만든 세션이 다른 노드에서 쓰이는가
===================================================================
### 사전 확인: 클러스터가 2 멤버로 형성되었는가
2026-09-04 00:52:09,294 INFO [org.infinispan.CLUSTER] (executor-thread-1) ISPN000094: Received new cluster view for channel ISPN: [keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749(v=16.0.12), keycloak-0-30843(v=16.0.12)]
name | ip | coord
------------------+-----------------+-------
keycloak-1-48749 | 10.42.0.35:7800 | t
keycloak-0-30843 | 10.42.1.43:7800 | f
(2 rows)
수집 시각: 2026-09-04 09:54:29 KST
=== 대상 ===
keycloak-0 10.42.1.43 kc-lab-2
keycloak-1 10.42.0.35 kc-lab-1
=== [0] 실험 전 DB 세션 ===
=== [1] keycloak-0 에서 로그인 ===
sid jiv3rVZi1VeaO07oVJkL_MYW
sub None
iss https://auth.hyeonworks.com/realms/master
access 수명 60초
refresh 수명 1800초 typ=Refresh
refresh jti 7669cc49-4778-851f-3c49-65f76964ae8e
=== [3] 같은 sid 가 두 노드 모두에서 보이는가 ===
keycloak-0 (발급 노드) 세션 2개 중 대상 sid → 보임 ✔
ipAddress=10.42.1.44 start=1788483164000 lastAccess=1788483164000
keycloak-1 (반대편) 세션 2개 중 대상 sid → 보임 ✔
ipAddress=10.42.1.44 start=1788483164000 lastAccess=1788483164000
=== [5] keycloak-0 이 발급한 refresh token 을 keycloak-1 에 사용 ===
HTTP 200 ← 기대대로
새 토큰의 sid → 동일 ✔
=== [6] keycloak-1 을 통해 로그아웃 ===
http_code=204
=== [7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파) ===
HTTP 400 ← 기대대로
error invalid_grant
error_description Session not active
=== [8] PostgreSQL 에서 그 sid 를 직접 확인 ===
대상 sid: jiv3rVZi1VeaO07oVJkL_MYW
행 없음 — 로그아웃으로 삭제되었다
전체 세션 수: 1
@@ -0,0 +1,35 @@
===================================================================
실험 0b — Infinispan 이 복제한 것인가, DB 를 같이 본 것인가
===================================================================
수집 시각: 2026-09-04 09:54:41 KST
=== 로그인은 keycloak-0 에만 보냈다 ===
로그인 응답: http_code=200
=== keycloak-0 (로그인을 받은 노드) ===
계수기 캐시 전 후 증가
rpc.replication_count clientSessions 1 1 +0
rpc.replication_count sessions 1 1 +0
approximate_entries_unique clientSessions 1 2 +1 ←
approximate_entries_unique sessions 1 2 +1 ←
hits clientSessions 2 2 +0
hits sessions 2 2 +0
misses clientSessions 2 3 +1 ←
misses sessions 3 4 +1 ←
stores clientSessions 2 3 +1 ←
stores sessions 2 3 +1 ←
=== keycloak-1 (아무 요청도 받지 않은 노드) ===
계수기 캐시 전 후 증가
rpc.replication_count clientSessions 7 7 +0
rpc.replication_count sessions 7 7 +0
approximate_entries_unique clientSessions 0 0 +0
approximate_entries_unique sessions 0 0 +0
hits clientSessions 4 4 +0
hits sessions 4 4 +0
misses clientSessions 0 0 +0
misses sessions 0 0 +0
stores clientSessions 1 1 +0
stores sessions 1 1 +0
@@ -0,0 +1,15 @@
===================================================================
실험 0c — 세션 엔트리는 어느 노드에 있는가 (로컬 캐시인가 분산인가)
===================================================================
수집 시각: 2026-09-04 09:54:54 KST
keycloak-0 = 10.42.1.43 (kc-lab-2)
keycloak-1 = 10.42.0.35 (kc-lab-1)
단계 k0 entries k1 entries
시작 2.0 0.0
keycloak-1 에 로그인 5회 2.0 5.0
keycloak-0 에 로그인 5회 7.0 5.0
=== 대조: PostgreSQL 에는 몇 건인가 ===
online 세션 12
@@ -0,0 +1,56 @@
===================================================================
실험 0d — 반대편 노드가 정말 DB 에서 읽는가 (SQL 을 직접 잡는다)
===================================================================
수집 시각: 2026-09-04 10:14:17 KST
keycloak-0 = 10.42.1.43 (세션을 만드는 노드)
keycloak-1 = 10.42.0.35 (읽기만 하는 노드)
=== PostgreSQL 문장 로깅을 켠다 ===
log_statement = all
log_line_prefix = %m [%p] %h
=== 요청 ===
SID=jSt9GEPVQLJsO-1CeJjVgltg
K1_ENTRIES_BEFORE=5.0
REFRESH_ON_K1=200
K1_ENTRIES_AFTER=5.0
=== PostgreSQL 문장 로깅을 끈다 ===
log_statement = none
=== keycloak-1 이 실제로 보낸 SQL 문장 ===
(파라미터가 $1 로 묶여 있어, sid 는 바로 아래 DETAIL 줄에 있다)
select puse1_0.OFFLINE_FLAG,puse1_0.USER_SESSION_ID,puse1_0.BROKER_SESSION_ID,puse1_0.CREATED_ON,puse1_0.DATA,puse1_0.LAST_SESSION_REFRESH,puse1_0.REALM_ID,puse1_0.REMEMBER_ME,puse1_0.USER_ID,puse1_0.VERSION from OFFLINE_USER_SESSION puse1_0 where (puse1_0.OFFLINE_FLAG,puse1_0.USER_SESSION_ID) in (($1,$2))
select puse1_0.VERSION from OFFLINE_USER_SESSION puse1_0 where puse1_0.USER_SESSION_ID=$1 and puse1_0.OFFLINE_FLAG=$2 for no key update of puse1_0 skip locked
select pcse1_0.CLIENT_ID,pcse1_0.CLIENT_STORAGE_PROVIDER,pcse1_0.EXTERNAL_CLIENT_ID,pcse1_0.OFFLINE_FLAG,pcse1_0.USER_SESSION_ID,pcse1_0.DATA,pcse1_0.REALM_ID,pcse1_0.TIMESTAMP,pcse1_0.VERSION from OFFLINE_CLIENT_SESSION pcse1_0 where (pcse1_0.CLIENT_ID,pcse1_0.CLIENT_STORAGE_PROVIDER,pcse1_0.EXTERNAL_CLIENT_ID,pcse1_0.OFFLINE_FLAG,pcse1_0.USER_SESSION_ID) in (($1,$2,$3,$4,$5))
select pcse1_0.VERSION from OFFLINE_CLIENT_SESSION pcse1_0 where pcse1_0.USER_SESSION_ID=$1 and pcse1_0.OFFLINE_FLAG=$2 and pcse1_0.CLIENT_ID=$3 and pcse1_0.EXTERNAL_CLIENT_ID=$4 and pcse1_0.CLIENT_STORAGE_PROVIDER=$5 for no key update of pcse1_0 skip locked
update OFFLINE_CLIENT_SESSION set TIMESTAMP=$1,VERSION=$2 where CLIENT_ID=$3 and CLIENT_STORAGE_PROVIDER=$4 and EXTERNAL_CLIENT_ID=$5 and OFFLINE_FLAG=$6 and USER_SESSION_ID=$7 and VERSION=$8
update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1,VERSION=$2 where OFFLINE_FLAG=$3 and USER_SESSION_ID=$4 and VERSION=$5
SET LOCAL synchronous_commit TO OFF
COMMIT
DELETE from JGROUPS_PING WHERE address=$1
INSERT INTO JGROUPS_PING (address, name, cluster_name, ip, coord, last_update, coordinated_by) values ($1, $2, $3, $4, $5, $6, $7)
COMMIT
DELETE from JGROUPS_PING WHERE address=$1
=== 그 sid 를 언급한 SQL — 누가 보냈는가 ===
찾는 sid: jSt9GEPVQLJsO-1CeJjVgltg
2026-09-04 01:12:32.851 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg'
2026-09-04 01:12:32.852 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'local', $3 = 'local', $4 = '0', $5 = 'jSt9GEPVQLJsO-1CeJjVgltg'
2026-09-04 01:12:32.860 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg'
2026-09-04 01:12:32.862 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'local', $3 = 'local', $4 = '0', $5 = 'jSt9GEPVQLJsO-1CeJjVgltg'
2026-09-04 01:12:32.863 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = NULL, $2 = '1788484352', $3 = '{"ipAddress":"10.42.1.50","authMethod":"openid-connect","rememberMe":false,"started":0,"notes":{"KC_DEVICE_NOTE":"
2026-09-04 01:12:32.864 UTC [81407] [keycloak-0] DETAIL: parameters: $1 = '{"authMethod":"openid-connect","notes":{"clientId":"131a9912-b578-4b9c-b16a-97518704077e","userSessionStartedAt":"1788484352","iss":"https://aut
2026-09-04 01:12:34.934 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '0', $2 = 'jSt9GEPVQLJsO-1CeJjVgltg'
2026-09-04 01:12:34.936 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = 'jSt9GEPVQLJsO-1CeJjVgltg', $2 = '0'
2026-09-04 01:12:34.937 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '131a9912-b578-4b9c-b16a-97518704077e', $2 = 'local', $3 = 'local', $4 = '0', $5 = 'jSt9GEPVQLJsO-1CeJjVgltg'
2026-09-04 01:12:34.938 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = 'jSt9GEPVQLJsO-1CeJjVgltg', $2 = '0', $3 = '131a9912-b578-4b9c-b16a-97518704077e', $4 = 'local', $5 = 'local'
2026-09-04 01:12:34.944 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '1788484354', $2 = '1', $3 = '131a9912-b578-4b9c-b16a-97518704077e', $4 = 'local', $5 = 'local', $6 = '0', $7 = 'jSt9GEPVQLJsO-1CeJjVgltg', $8 =
2026-09-04 01:12:34.946 UTC [81376] [keycloak-1] DETAIL: parameters: $1 = '1788484354', $2 = '1', $3 = '0', $4 = 'jSt9GEPVQLJsO-1CeJjVgltg', $5 = '0'
=== 요약: 파드별 질의 건수 ===
6 [keycloak-1]
6 [keycloak-0]
@@ -0,0 +1,26 @@
# 실험 0 — 세션 복제 증거
수집: 2026-09-04 09:54 KST · Keycloak 26 / Infinispan 16.0.12 / PostgreSQL 16
해설: [`docs/experiment-00-session-replication.md`](../../experiment-00-session-replication.md)
| 파일 | 무엇을 보여주는가 |
|---|---|
| `01-cross-node-session.txt` | 클러스터 2멤버 확인 → keycloak-0 로그인 → 같은 sid 가 양쪽에서 보임 → **keycloak-1 이 refresh 성공(200)** → keycloak-1 로그아웃 → **keycloak-0 갱신 실패(400)** → DB 행 삭제 확인 |
| `02-cache-delta.txt` | 로그인 하나를 사이에 둔 양쪽 노드의 캐시 계수기. **keycloak-1 은 전부 +0** |
| `03-cache-ownership.txt` | 로그인을 반대편에 몰아준 결과. **요청을 받은 노드에서만 엔트리가 는다.** 캐시 합 7+5 = DB 12 |
| `session-cache-entries-per-pod.png` | 위 사실의 시계열. 파란 선(keycloak-1)이 0에 붙어 있는 동안 초록 선(keycloak-0)만 14까지 오른다 |
| `keycloak-admin-sessions.png` | 관리 콘솔의 Sessions 화면. 브라우저는 nginx→Traefik 을 거쳐 두 파드 중 하나에 닿지만 **어느 파드가 만든 세션이든 전부 보인다** |
## 핵심 한 줄
클러스터는 형성되지만 **세션 엔트리는 노드를 건너가지 않는다.**
두 노드가 같은 답을 하는 이유는 Infinispan 복제가 아니라 **같은 PostgreSQL** 이다.
| 파일 | 무엇을 보여주는가 |
|---|---|
| `04-read-path-sql.txt` | PostgreSQL 문장 로깅으로 잡은 **keycloak-1 이 실제로 날린 SQL**. `SELECT ... FROM OFFLINE_USER_SESSION` 로 남의 세션을 읽고 `UPDATE ... where VERSION=$5` 로 쓴다. 같은 트랜잭션에 `SET LOCAL synchronous_commit TO OFF` 가 들어 있다 |
## 추론이 관측이 된 지점
0b·0c 는 "keycloak-1 메모리에 없는데 쓸 수 있으니 DB 에서 읽었을 것"이라는
**추론**이었다. 0d 에서 그 SQL 을 파드 IP 와 함께 직접 잡았다.
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

@@ -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

+636
View File
@@ -0,0 +1,636 @@
# 실험 0 — 한 노드에서 만든 세션이 다른 노드에서 쓰이는가
로드맵 A-0. 이후 모든 장애 실험의 기준선이다.
> **맥락이 안 잡히면 먼저 읽을 것** —
> [`docs/session-lab-prerequisites.md`](session-lab-prerequisites.md).
> 왜 세션이 문제가 되는지, Keycloak이 세션을 어디에 두는지, 그래서 이 실험이
> 무엇을 가르려는 것인지를 바닥부터 세워둔 문서다.
- 실행 스크립트 — [`deploy/lab/scripts/experiment-session-replication.sh`](../deploy/lab/scripts/experiment-session-replication.sh),
[`experiment-cache-replication-delta.sh`](../deploy/lab/scripts/experiment-cache-replication-delta.sh),
[`experiment-cache-ownership.sh`](../deploy/lab/scripts/experiment-cache-ownership.sh),
[`experiment-session-read-path.sh`](../deploy/lab/scripts/experiment-session-read-path.sh)
- 증거 — [`docs/evidence/session-replication/`](evidence/session-replication/)
- 수집 시각 — 2026-09-04 09:54 KST, Keycloak 26 / Infinispan 16.0.12 / PostgreSQL 16
---
## 0. 결론부터
| 물음 | 답 |
|---|---|
| 한 노드에서 만든 세션을 다른 노드가 쓸 수 있는가 | **그렇다** |
| 로그아웃이 반대 방향으로 전파되는가 | **그렇다** |
| **그 공유는 Infinispan 복제 덕분인가** | **아니다** |
| 그럼 무엇이 공유하는가 | **PostgreSQL** — 반대편 노드가 날린 SQL을 직접 잡았다 |
**클러스터가 형성됐다는 것과 세션이 복제된다는 것은 다른 얘기였다.**
로그에는 `(2) [keycloak-0, keycloak-1]`이 찍히고 `JGROUPS_PING`에도 둘 다
등록되어 있지만, **세션 엔트리는 노드 사이를 건너가지 않는다.**
각 노드는 **자기가 처리한 로그인만** 캐시한다. 두 노드가 같은 답을 내놓는
이유는 복제가 아니라 **같은 데이터베이스를 보기 때문**이다.
---
## 1. 왜 이 실험이 첫 번째인가
앞선 작업에서 Keycloak 2노드 클러스터를 세우고 `ISPN000094`로 멤버 2개를
확인했다. 거기서 멈추면 **"클러스터가 떴다"까지만 아는 것**이고, 그 위에서
장애를 주입해봐야 무엇이 무엇 때문에 깨졌는지 해석할 수 없다.
기준선이 없으면 이런 잘못된 추론을 하게 된다.
> 7800을 막았더니 세션이 깨졌다 → 역시 세션은 7800으로 복제되는구나
실제로는 7800으로 세션이 오가지 않는다는 것을 **먼저** 알아야, 7800을 막았을
때 깨지는 것이 무엇인지 정확히 말할 수 있다.
---
## 2. 실험 설계에서 배운 것 세 가지
측정값보다 **어떻게 측정할지**에서 더 많이 틀렸다. 세 번 고쳤다.
### 2-1. 대조군 없는 측정은 해석할 수 없다
첫 판본은 이렇게 보고했다.
```
=== [4] keycloak-0 이 발급한 토큰을 keycloak-1 이 받는가 ===
http_code=403
```
**403을 "복제 실패"로 읽을 뻔했다.** 발급 노드에도 같은 요청을 보내보니
```
--- userinfo, scope 없음 ---
k0(발급노드) 403
k1(반대편) 403
--- 403 본문 ---
WWW-Authenticate: Bearer realm="master", error="insufficient_scope",
error_description="Missing openid scope"
```
**양쪽 다 403이었다.** 원인은 복제가 아니라 요청에 `openid` scope가 없다는
것이었다. 오히려 **두 노드가 똑같이 답했다는 사실 자체가 일치의 증거**였다.
> **원칙** — 반대편 노드의 응답은 발급 노드의 응답과 나란히 놓기 전까지
> 아무 의미가 없다. 시험군만 재는 측정은 측정이 아니다.
### 2-2. 개수가 아니라 식별자로 추적한다
`client-session-stats``active=2`를 돌려줬다. 그런데 스크립트 자체가
로그인을 두 번 하고(시험용 + 관리 API 호출용) 있었다. **개수는 실험 도구가
만든 잡음에 그대로 오염된다.**
바꾼 방식: 토큰의 `sid`를 뽑아, 각 노드의 세션 목록에 **그 sid가 있는지**를
본다. 개수가 몇이든 상관없다.
```
keycloak-0 (발급 노드) 세션 2개 중 대상 sid → 보임 ✔
keycloak-1 (반대편) 세션 2개 중 대상 sid → 보임 ✔
```
### 2-3. 세션 저장소를 실제로 건드리는 탐침을 골라야 한다
| 탐침 | 하는 일 | 적합한가 |
|---|---|---|
| `userinfo` | 서명 검증 + scope 확인 | **아니다.** 세션을 몰라도 통과할 수 있다 |
| **`refresh_token` 그랜트** | 세션을 찾고, 살아있는지 보고, 갱신 시각을 쓴다 | **그렇다** |
refresh는 **읽고 쓴다.** 그래서 "저 노드가 이 세션을 정말로 아는가"에 답한다.
여기에 더해 refresh token은 **회전(rotation)** 된다 — 한 번 쓰면 옛 것이
무효가 된다. 따라서 **반대편 노드에 먼저 써야** 한다. 발급 노드에 먼저 쓰면
시험군에 쓸 토큰이 사라진다. 대조군과 시험군의 순서가 강제된다.
---
## 3. 실험 0 — 교차 노드 세션 사용
### 실행
```bash
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c "delete from offline_user_session"
kubectl -n keycloak-lab rollout restart statefulset/keycloak # 캐시를 비운다
./deploy/lab/scripts/experiment-session-replication.sh
```
nginx나 Traefik을 거치지 않고 **파드 IP로 직접** 말을 건다. 로드밸런서를
거치면 어느 노드가 처리했는지가 감춰지는데, 그게 바로 이 실험의 질문이다.
### 결과 — [`01-cross-node-session.txt`](evidence/session-replication/01-cross-node-session.txt)
```
### 사전 확인: 클러스터가 2 멤버로 형성되었는가
ISPN000094: Received new cluster view for channel ISPN:
[keycloak-1-48749(v=16.0.12)|5] (2) [keycloak-1-48749, keycloak-0-30843]
name | ip | coord
------------------+-----------------+-------
keycloak-1-48749 | 10.42.0.35:7800 | t
keycloak-0-30843 | 10.42.1.43:7800 | f
=== 대상 ===
keycloak-0 10.42.1.43 kc-lab-2
keycloak-1 10.42.0.35 kc-lab-1
=== [1] keycloak-0 에서 로그인 ===
sid jiv3rVZi1VeaO07oVJkL_MYW
iss https://auth.hyeonworks.com/realms/master
access 수명 60초
refresh 수명 1800초 typ=Refresh
=== [3] 같은 sid 가 두 노드 모두에서 보이는가 ===
keycloak-0 (발급 노드) 세션 2개 중 대상 sid → 보임 ✔
keycloak-1 (반대편) 세션 2개 중 대상 sid → 보임 ✔
=== [5] keycloak-0 이 발급한 refresh token 을 keycloak-1 에 사용 ===
HTTP 200 ← 기대대로
새 토큰의 sid → 동일 ✔
=== [6] keycloak-1 을 통해 로그아웃 ===
http_code=204
=== [7] 로그아웃 후 keycloak-0 에서 갱신 시도 (무효화 전파) ===
HTTP 400 ← 기대대로
error invalid_grant
error_description Session not active
=== [8] PostgreSQL 에서 그 sid 를 직접 확인 ===
행 없음 — 로그아웃으로 삭제되었다
```
**네 가지가 모두 기대대로다.**
| | 확인된 것 |
|---|---|
| 조회 | 같은 sid가 양쪽에서 보인다 |
| **쓰기** | keycloak-0의 refresh token을 keycloak-1이 받아 갱신했고, **sid가 유지된다** |
| **역방향 무효화** | keycloak-1의 로그아웃이 keycloak-0의 갱신을 막았다 |
| 영속 | 로그아웃과 함께 DB 행이 사라졌다 |
**`sid`는 JWT 안에만 있는 값이 아니다.** PostgreSQL의
`OFFLINE_USER_SESSION.user_session_id` 컬럼에 **문자 그대로** 들어 있다.
---
## 4. 실험 0b — 복제인가, 같은 DB를 본 것인가
실험 0은 "두 노드가 같은 답을 한다"까지만 증명한다. **그것으로는 Infinispan이
복제했다고 말할 수 없다.** `persistent-user-sessions`(Keycloak 26 기본값)에서는
세션이 PostgreSQL에 기록되므로, **캐시를 아예 꺼도 두 노드는 같은 답을 한다.**
가르는 방법: 로그인 한 번을 사이에 두고 **양쪽 노드의 캐시 계수기**를 잰다.
### 결과 — [`02-cache-delta.txt`](evidence/session-replication/02-cache-delta.txt)
```
=== 로그인은 keycloak-0 에만 보냈다 ===
로그인 응답: http_code=200
=== keycloak-0 (로그인을 받은 노드) ===
계수기 캐시 전 후 증가
approximate_entries_unique sessions 1 2 +1 ←
stores sessions 2 3 +1 ←
misses sessions 3 4 +1 ←
rpc.replication_count sessions 1 1 +0
=== keycloak-1 (아무 요청도 받지 않은 노드) ===
approximate_entries_unique sessions 0 0 +0
stores sessions 1 1 +0
hits sessions 4 4 +0
rpc.replication_count sessions 7 7 +0
```
**keycloak-1의 계수기가 하나도 움직이지 않았다.** 엔트리도 0, 저장도 0.
그리고 keycloak-1의 `sessions` 캐시 엔트리는 **처음부터 끝까지 0**이다.
keycloak-0이 세션을 9개 들고 있는 동안에도 0이었다.
---
## 5. 실험 0c — 엔트리는 어느 노드에 있는가
0b의 결과에는 두 가지 설명이 가능하다.
| | |
|---|---|
| (a) **분산 캐시 + owners=1** | 일관 해싱으로 흩어지는데 이번 건이 우연히 keycloak-0에 떨어졌다 |
| (b) **로컬 캐시** | 각 노드는 자기가 처리한 것만 캐시한다 |
**반대편 노드에 로그인을 몰아주면 갈린다.** (a)라면 어느 쪽에 요청하든 엔트리는
양쪽에 흩어진다. (b)라면 **요청을 받은 노드에서만** 는다.
### 결과 — [`03-cache-ownership.txt`](evidence/session-replication/03-cache-ownership.txt)
```
단계 k0 entries k1 entries
시작 2.0 0.0
keycloak-1 에 로그인 5회 2.0 5.0 ← k0 그대로, k1 만 +5
keycloak-0 에 로그인 5회 7.0 5.0 ← k0 만 +5, k1 그대로
=== 대조: PostgreSQL 에는 몇 건인가 ===
online 세션 12 ← 7 + 5 = 12, 정확히 일치
```
**(b)다.** 그리고 **7 + 5 = 12**로 DB 총계와 정확히 맞는다 — 모든 세션이 DB에
있고, 각각은 **자기를 만든 노드 한 곳에만** 캐시되어 있다.
### 그래프로 본 같은 사실
![세션 캐시 엔트리 수](evidence/session-replication/session-cache-entries-per-pod.png)
`vendor_statistics_approximate_entries_unique{cache="sessions"}` — Grafana Explore.
**파란 선(keycloak-1)이 0에 붙어 있는 동안 초록 선(keycloak-0)만 14까지
올라간다.** 파란 선은 09:50, 즉 **keycloak-1에 직접 로그인을 보낸 순간에만**
5로 뛴다. 중간의 절벽은 캐시를 비우려고 파드를 재시작한 지점이다.
> 캐시 설정은 파일에서 읽을 수 없다. 파드의 `/opt/keycloak/conf/cache-ispn.xml`은
> `<cache-container name="keycloak"><transport/></cache-container>` 뿐이고,
> Keycloak 26은 캐시를 **코드에서** 만든다. 그래서 위 결론은 설정을 읽어서가
> 아니라 **동작을 측정해서** 얻었다.
---
## 6. 실험 0d — 반대편 노드가 정말 DB에서 읽는가
0b·0c까지는 **추론**이었다. "keycloak-1의 메모리에 없는데 쓸 수 있으니 DB에서
읽었을 것이다" — 그럴듯하지만 **SQL을 본 적은 없다.**
PostgreSQL의 문장 로깅을 몇 초만 켜고, keycloak-0에서 만든 세션에 대해
**keycloak-1에 refresh를 딱 한 번** 보낸 뒤 로그를 뒤졌다.
```bash
alter system set log_statement='all';
alter system set log_line_prefix='%m [%p] %h '; -- %h 로 파드 IP 를 남긴다
select pg_reload_conf();
```
### 잡힌 트랜잭션 — [`04-read-path-sql.txt`](evidence/session-replication/04-read-path-sql.txt)
```
01:12:34.934 pid=81376 | BEGIN
01:12:34.934 pid=81376 | select ... from OFFLINE_USER_SESSION where (OFFLINE_FLAG,USER_SESSION_ID) in (($1,$2))
01:12:34.936 pid=81376 | select VERSION from OFFLINE_USER_SESSION ... for no key update skip locked
01:12:34.937 pid=81376 | select ... from OFFLINE_CLIENT_SESSION where (...) in ((...))
01:12:34.938 pid=81376 | select VERSION from OFFLINE_CLIENT_SESSION ... for no key update skip locked
01:12:34.944 pid=81376 | update OFFLINE_CLIENT_SESSION set TIMESTAMP=$1,VERSION=$2 where ... and VERSION=$8
01:12:34.946 pid=81376 | update OFFLINE_USER_SESSION set LAST_SESSION_REFRESH=$1,VERSION=$2 where ... and VERSION=$5
01:12:34.946 pid=81376 | SET LOCAL synchronous_commit TO OFF
01:12:34.947 pid=81376 | COMMIT
```
이 연결의 클라이언트 IP는 `10.42.0.35` — **keycloak-1의 파드 IP**다.
sid 하나에 대해 keycloak-0이 6건(로그인), keycloak-1이 6건(갱신)을 날렸다.
```
=== 요약: 파드별 질의 건수 ===
6 [keycloak-1]
6 [keycloak-0]
```
**추론이 관측이 되었다.** keycloak-1은 세션을 DB에서 읽고, DB에 쓴다.
### 여기서 딸려 나온 것 세 가지
이 13밀리초짜리 트랜잭션 하나에 **원래 질문들의 답이 절반쯤 들어 있다.**
#### (1) 낙관적 락 — `VERSION` 컬럼
```sql
update OFFLINE_USER_SESSION
set LAST_SESSION_REFRESH=$1, VERSION=$2
where OFFLINE_FLAG=$3 and USER_SESSION_ID=$4 and VERSION=$5
```
읽은 뒤 다른 노드가 먼저 고쳤다면 `VERSION`이 달라져 **`UPDATE`가 0행을
갱신하고 실패한다.** 잠금을 오래 잡지 않고 충돌을 사후에 검출하는 방식이다.
**리프레시 토큰 동시 갱신 경쟁(로드맵 B-5)이 여기서 갈린다.** 두 요청이
같은 세션을 동시에 갱신하면 하나는 이 검사에서 진다.
#### (2) `FOR NO KEY UPDATE ... SKIP LOCKED`
```sql
select VERSION from OFFLINE_USER_SESSION
where USER_SESSION_ID=$1 and OFFLINE_FLAG=$2
for no key update of puse1_0 skip locked
( )
```
| 절 | 뜻 |
|---|---|
| `FOR NO KEY UPDATE` | 행을 잠그되 **외래키 참조는 막지 않는다.** `FOR UPDATE`보다 약해 경합이 준다 |
| **`SKIP LOCKED`** | 이미 잠긴 행을 **기다리지 않고 건너뛴다** |
`SKIP LOCKED`가 핵심이다. 같은 세션에 동시 요청이 몰려도 **줄을 서지 않는다.**
대기 대신 낙관적 락 실패로 처리한다 — 처리량을 위해 **지연 대신 재시도**를
고른 설계다.
#### (3) `SET LOCAL synchronous_commit TO OFF` — 내구성을 일부 포기한다
**같은 트랜잭션 안에서**, `COMMIT` 직전에 나온다. pid로 경계를 확인했다.
| | |
|---|---|
| 기본값 `on` | `COMMIT`**WAL이 디스크에 내려간 뒤** 돌아온다 |
| **`off`** | **WAL 플러시를 기다리지 않고** 즉시 돌아온다 |
**결과: PostgreSQL이 갑자기 죽으면 직전 수백 밀리초의 세션 갱신이 사라질 수
있다.** 커밋했다고 응답해놓고 없어진다.
Keycloak이 이걸 의도적으로 켠 이유는 명확하다 — `LAST_SESSION_REFRESH` 갱신은
**초당 수백 번 일어나고, 몇백 밀리초쯤 잃어도 사용자가 다시 갱신하면 그만**이다.
로그인·로그아웃 같은 것과 달리 잃어도 되는 쓰기다.
> **DB 복구 실험(A-2)에서 그대로 관측될 지점이다.** PostgreSQL을 정상 종료가
> 아니라 강제 종료시키면, 마지막 몇백 밀리초의 세션 갱신이 실제로 없어져야
> 한다. 이건 버그가 아니라 **설계된 트레이드오프**다.
```bash
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c "show synchronous_commit" # 전역 기본값은 on
```
전역 설정은 `on`이고, **Keycloak이 세션 트랜잭션에만 `SET LOCAL`로 끈다.**
`SET LOCAL`은 그 트랜잭션이 끝나면 되돌아간다.
### 덤: 캐시는 읽어도 채워지지 않는다
```
K1_ENTRIES_BEFORE=5.0
REFRESH_ON_K1=200
K1_ENTRIES_AFTER=5.0 ← 갱신을 처리하고도 그대로
```
**keycloak-1은 남의 세션을 DB에서 읽어 처리하고도 캐시에 담지 않았다.**
0c에서 세운 모델 "각 노드는 자기가 처리한 것만 캐시한다"를 더 좁혀야 한다.
> 캐시에 담기는 것은 **그 노드가 로그인시켜 만든 세션**뿐이다.
> 남의 세션은 매번 DB에서 읽는다.
로드밸런서가 세션을 만든 노드가 아닌 쪽으로 요청을 보내면 **매번 DB를 친다.**
세션 어피니티(sticky session)가 정확성이 아니라 **성능** 문제인 이유가 이것이다.
### 덤 2: jdbc-ping 하트비트가 그대로 보인다
```
01:12:37.551 pid=81369 | BEGIN
01:12:37.551 pid=81369 | DELETE from JGROUPS_PING WHERE address=$1
01:12:37.552 pid=81369 | INSERT INTO JGROUPS_PING (address, name, cluster_name, ip, coord, last_update, coordinated_by) values (...)
01:12:37.553 pid=81369 | COMMIT
```
**디스커버리는 별도 연결(pid=81369)에서 주기적으로 자기 행을 지우고 다시
넣는다.** 세션 트래픽과 완전히 분리된 경로다 — 11층에서 말한 "디스커버리와
트랜스포트는 다른 경로"가 로그에서 눈으로 확인된다.
---
## 7. 그래서 무엇이 세션을 공유하는가
```
로그인 (keycloak-0)
├──▶ PostgreSQL OFFLINE_USER_SESSION ← 진실의 원천. 양쪽이 본다
└──▶ keycloak-0 로컬 캐시 ← 자기 것만. 건너가지 않는다
keycloak-1 이 그 세션을 물으면
└──▶ 자기 캐시에 없음 → PostgreSQL 에서 읽는다
```
| 계층 | 역할 | 노드 간 공유 |
|---|---|---|
| **PostgreSQL** | 진실의 원천 | **여기서 일어난다** |
| **Infinispan `sessions`** | 자기 노드가 처리한 세션의 룩어사이드 캐시 | **일어나지 않는다** |
| **Infinispan 클러스터** | 무효화 메시지, `work` 캐시 등 | 형성은 되어 있다 |
이건 **Keycloak 26의 의도된 설계**다. `persistent-user-sessions`가 기본이 되면서
DB가 진실의 원천이 됐고, 세션 캐시는 **복제할 이유가 없어졌다.** 복제를 하면
네트워크와 메모리를 쓰면서 DB와 캐시 두 벌을 정합하게 유지해야 한다.
---
## 8. 개념
### 8-1. `persistent-user-sessions`
Keycloak 25에서 도입되고 **26에서 기본값**이 된 기능. 사용자 세션을
Infinispan에만 두지 않고 **데이터베이스에 기록**한다.
| | 켜져 있을 때 (기본) | 꺼져 있을 때 (volatile) |
|---|---|---|
| 진실의 원천 | **PostgreSQL** | Infinispan |
| 전체 재시작 후 | **세션이 남는다** | 전부 사라진다 |
| 노드 간 공유 | DB가 한다 | **복제가 해야 한다** |
| 로그인당 비용 | DB 쓰기 | 네트워크 복제 |
**이 실험의 결론은 전부 "켜져 있을 때"의 이야기다.** 끄면 다른 그림이 나오고,
그 비교가 로드맵 A-2다.
```bash
kubectl -n keycloak-lab exec keycloak-0 -- \
/opt/keycloak/bin/kc.sh show-config 2>/dev/null | grep -i feature
```
### 8-2. 온라인 세션이 `OFFLINE_` 테이블에 들어간다
**`USER_SESSION` 테이블은 존재하지 않는다.** 처음에 이걸 찾다가 없어서 당황했다.
```
public | auth_session | table | keycloak
public | jgroups_ping | table | keycloak
public | offline_client_session | table | keycloak
public | offline_user_session | table | keycloak
public | revoked_token | table | keycloak
public | root_auth_session | table | keycloak
```
`persistent-user-sessions`는 **기존 오프라인 세션 테이블을 재사용**하고
`offline_flag` 컬럼으로 구분한다.
| `offline_flag` | 의미 |
|---|---|
| **`'0'`** | **온라인 세션** (일반 로그인) |
| `'1'` | 오프라인 세션 (`offline_access`) |
기본키가 `(user_session_id, offline_flag)` 복합키인 이유다 — 같은 세션 id가
온라인/오프라인 두 행으로 존재할 수 있다.
```sql
select offline_flag, count(*) from offline_user_session group by offline_flag;
select user_session_id, offline_flag, created_on, last_session_refresh
from offline_user_session where user_session_id = '<sid>';
```
**이름이 내용을 배신하는 스키마다.** 운영에서 "온라인 세션이 DB 어디 있냐"를
찾을 때 이걸 모르면 한참 헤맨다.
### 8-3. `sid` — 토큰과 DB를 잇는 열쇠
```
JWT access_token 의 sid jiv3rVZi1VeaO07oVJkL_MYW
↕ 같은 값
DB user_session_id jiv3rVZi1VeaO07oVJkL_MYW
↕ 같은 값
Admin API 세션 목록의 id jiv3rVZi1VeaO07oVJkL_MYW
```
세 곳에서 같은 문자열이다. **장애를 추적할 때 이 값 하나로 토큰·DB·관리 API를
꿰뚫을 수 있다.** 백채널 로그아웃의 `sid` 클레임도 이것이다.
### 8-4. `openid` scope가 없으면 OIDC 토큰이 아니다
`admin-cli``scope` 없이 direct grant를 하면 나오는 클레임은 이렇다.
```
--- access_token ---
클레임: azp, exp, iat, iss, jti, scope, sid, typ
typ = Bearer | sub = None | sid = OxikTqdHCJ7ESm2GPKI1oa6c
```
**`sub`이 없다.** OIDC가 아니라 순수 OAuth2 액세스 토큰이기 때문이다.
`sub`은 OIDC가 요구하는 클레임이고, `openid` scope가 있어야 붙는다.
같은 이유로 `userinfo`가 403 `insufficient_scope`를 준다 — userinfo는 OIDC
엔드포인트다. **두 현상은 하나의 원인**이다.
### 8-5. 룩어사이드(lookaside) 캐시
```
읽기: 캐시 확인 → 없으면 DB → 캐시에 채움
쓰기: DB 에 쓰고 → 캐시에도 씀
```
캐시가 **DB 앞에 서 있되 DB를 대체하지 않는** 구조. 캐시를 통째로 날려도
정확성은 유지되고 느려지기만 한다. Keycloak 26의 세션 캐시가 이 모양이다.
이 성질이 **노드 상실 실험(A-3)의 결과를 미리 결정한다** — 노드가 죽으면
그 노드의 캐시는 사라지지만 세션은 DB에 있으므로 살아남아야 한다.
---
## 9. 다음 실험에 대한 예측
기준선이 생겼으므로 **틀릴 수 있는 예측**을 세울 수 있다. 예측이 빗나가면
그것이야말로 배울 거리다.
| 실험 | 예측 | 근거 |
|---|---|---|
| **A-1** TCP 7800 차단 | **세션 공유는 안 깨진다.** 대신 무효화 전파와 `work` 캐시가 깨진다 | 세션은 7800으로 오가지 않는다 |
| **A-2** DB 손실 | **즉시 전면 장애.** 캐시에 있는 세션도 못 쓴다 | DB가 진실의 원천 |
| **A-2'** DB **강제** 종료 | 직전 수백 ms 의 세션 갱신이 **사라진다** | `synchronous_commit OFF` |
| **B-5** 동시 갱신 경쟁 | 한쪽이 `VERSION` 검사에서 지고 재시도한다 | 낙관적 락 |
| **A-3** 노드 상실 (kc-lab-2) | **세션은 살아남는다.** 죽은 노드의 캐시만 사라진다 | 룩어사이드 |
| **A-4** volatile 비교 | 7800 차단이 **A-1과 정반대로** 치명적이 된다 | 그때는 캐시가 진실의 원천 |
특히 A-1은 **직관과 어긋나는 예측**이다. "클러스터 포트를 막으면 세션이
깨진다"가 상식이지만, 이 기준선이 맞다면 안 깨져야 한다.
---
## 10. 겪은 함정
### 10-1. kubectl 스트림에서 출력이 통째로 사라졌다
`kubectl run --rm -i ... | grep` 로 받으면 **중간 조각이 유실됐다.**
keycloak-1의 스냅샷과 그 다음 마커가 함께 없어져, 전값이 0으로 잡히면서
**가짜 델타가 만들어졌다.**
```
###BEFORE_K1 ← 여기 있어야 할 지표 20줄과
http_code=200 다음 마커 ###LOGIN 이 통째로 사라졌다
###AFTER_K0
```
이때 리포트는 keycloak-1이 `+9`, `+7` 증가한 것처럼 보였다. **없는 복제가
있는 것처럼 보이는, 가장 나쁜 종류의 오류다.**
| 고친 방법 | |
|---|---|
| 파드 안에서 파일로 모으고 마지막에 `cat` 한 번 | 스트리밍 중 유실을 없앤다 |
| 스냅샷이 비면 **경고를 출력**한다 | 조용히 0으로 계산되는 것을 막는다 |
```bash
for n in ('BEFORE_K0','BEFORE_K1','AFTER_K0','AFTER_K1'):
if not blocks.get(n):
print(f' !! {n} 스냅샷이 비었다 — 델타를 신뢰할 수 없다')
```
**계측 코드는 자기가 실패했는지 스스로 말해야 한다.**
### 10-2. DB에서 직접 지우면 캐시는 남는다
정리하려고 `delete from offline_user_session`을 실행했더니, **캐시 엔트리는
그대로 남아** 캐시 합계(19)와 DB 총계(15)가 어긋났다.
> 운영에서 세션 테이블을 직접 손대면 캐시와 DB가 갈라진다. 세션을 지울 때는
> 관리 API(`logout-all`)를 쓰거나, DB를 건드렸다면 **파드를 재시작**해야 한다.
이 실험의 최종 수치는 **파드 재시작 후** 다시 잰 것이다.
### 10-3. Keycloak 이미지에는 `curl`이 없다
`kubectl exec keycloak-0 -- curl` 은 실패한다. 임시 `curlimages/curl` 파드를
띄워 파드 네트워크 안에서 호출했다. 파드 IP는 클러스터 밖에서 닿지 않으므로
이 방법이 사실상 유일하다.
### 10-4. 중첩 셸의 변수 치환
`ssh host '... $VAR ...'` 안에 다시 `sh -c "..."` 를 넣으면 인용이 세 겹이 되어
치환이 조용히 깨진다. 첫 시도에서 파드 IP가 빈 문자열이 되어 아무 출력도
나오지 않았다.
**스크립트 파일로 만들어 `scp` 로 옮기는 쪽이 옳다.** 재현도 되고 저장소에
남는다. `deploy/lab/scripts/` 아래 세 스크립트가 그 결과다.
---
## 11. 재현
```bash
# 1. 깨끗한 상태로 되돌린다 (DB 비우고 캐시 비우기)
ssh test-server '
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c "delete from offline_user_session"
kubectl -n keycloak-lab rollout restart statefulset/keycloak
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=300s'
# 2. 세 실험을 순서대로
ssh test-server '/tmp/experiment-session-replication.sh' # 교차 노드 사용
ssh test-server '/tmp/experiment-cache-replication-delta.sh' # 복제인가 DB인가
ssh test-server '/tmp/experiment-cache-ownership.sh' # 엔트리 위치
# 3. 그래프
# https://app2.hyeonworks.com/explore
# vendor_statistics_approximate_entries_unique{cache="sessions"}
# Legend: {{pod}} on {{node}}
```
### 확인용 명령 모음
```bash
# 클러스터 멤버
kubectl -n keycloak-lab logs keycloak-0 | grep ISPN000094 | tail -1
kubectl -n keycloak-lab exec deploy/postgres -- \
psql -U keycloak -d keycloak -c "select name, ip, coord from jgroups_ping"
# DB 세션
kubectl -n keycloak-lab exec deploy/postgres -- psql -U keycloak -d keycloak \
-c "select offline_flag, count(*) from offline_user_session group by offline_flag"
# 노드별 캐시 엔트리 (파드 안에서)
curl -s http://<pod-ip>:9000/metrics \
| grep 'approximate_entries_unique{cache="sessions"'
```
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
# First Broker Login security
Keycloak 26.7.0's built-in `first broker login` flow does **not** silently
auto-link by email. It contains:
- `Create User If Unique`
- `Handle Existing Account`
- `Confirm link existing account`
- email verification or re-authentication ownership proof
`Automatically set existing user` is an explicit, dangerous opt-in. The local
acceptance harness copies the built-in flow, enables AutoLink, disables the
ownership-proof branch, and signs in through a controllable OIDC account whose
email collides with `regular-user`. It verifies that the external identity is
attached without proof. The harness then assigns the original built-in flow,
repeats the login, observes the existing-account confirmation page, and verifies
that no federated identity was attached.
Run after the stack is healthy:
```bash
./scripts/verify-first-broker-login.sh
```
The vulnerable flow remains only as a disabled learning artifact. The
`mock-google` provider is always returned to the secure built-in flow at the end
of the verification.
+37
View File
@@ -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`로 추적한다.
+23
View File
@@ -0,0 +1,23 @@
# Google claim and identity mapping
The broker uses the upstream OIDC `sub` as the stable federated identity key.
Email is a mutable profile attribute and is never the external identity key.
The default mapping policy is:
| Upstream claim | Keycloak target |
|---|---|
| `sub` | stable username `${ALIAS}.${CLAIM.sub}` and federated identity ID |
| `email` | email |
| `given_name` | first name |
| `family_name` | last name |
| `picture` | custom `picture` attribute |
| `hd` | custom `hd` attribute |
The Identity Provider uses `syncMode=IMPORT`: profile values are imported on
first login and later local edits are not overwritten on every login. `FORCE`
is an explicit alternative when upstream freshness is more important.
`./scripts/verify-google-claim-mapping.sh` signs in through the controllable
OIDC realm and verifies the resulting Keycloak user, custom attributes, stable
subject-derived username, and federated identity record.
+18
View File
@@ -0,0 +1,18 @@
# Google claim-to-role mapping
`hd=example.test`인 upstream OIDC identity에는 Keycloak realm role
`employee-role`을 부여한다. 매핑 키는 email이 아니라 Google subject이며,
role 조건에 쓰는 `hd` claim은 mock provider와 실제 Google provider에서 같은
계약을 사용한다.
Realm import는 `oidc-role-idp-mapper`를 선언한다. 실제 Google 설정 스크립트도
같은 mapper를 upsert한다. 따라서 재실행해도 mapper가 중복되지 않는다.
검증:
```sh
./scripts/verify-google-claim-to-role.sh
```
검증기는 mock Google 로그인, Authorization Code + PKCE 교환, 최종 Keycloak
access token의 `realm_access.roles`를 차례로 확인한다.
+28
View File
@@ -0,0 +1,28 @@
# Google IdP brokering
Keycloak is the only issuer trusted by AP1AP4. Google is an upstream Identity
Provider; applications do not receive or validate a Google token.
## Two verification profiles
The default local profile imports a second Keycloak realm named `mock-google`.
It acts as a controllable OIDC provider and allows tests to choose claims such
as a duplicate email, `email_verified=false`, `hd`, and `picture`. This is the
safe way to reproduce an unsafe email auto-link without impersonating a real
Google account.
The real-Google profile is configured explicitly:
1. Create a Google OAuth **Web application**.
2. Register the exact redirect URI printed by
`./scripts/configure-google-idp.sh`.
3. Put `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` in ignored `.env`.
4. Start the stack and run the configuration script.
The script writes `providerId=google`, `trustEmail=false`, minimal
`openid profile email` scopes, and `syncMode=IMPORT` through the Keycloak Admin
API. Credentials are never written to the realm export or repository.
Google requires a public HTTPS redirect for non-local deployments. Local mock
verification proves the Keycloak brokering boundary; a real Google login is a
separate credentialed acceptance profile.
+24
View File
@@ -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
```
+20
View File
@@ -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
```
+29
View File
@@ -0,0 +1,29 @@
# Keycloak branch implementation index
The source inventory contains 39 `feature-keycloak-*.md` branch notes. This
repository preserves one local Git feature branch for every note and merges it
with `--no-ff` into either the common `develop` baseline or one of the four
authentication-pattern branches.
| Target | Meaning |
|---|---|
| `common` | Shared realm, federation, deployment, or governance contract. Merge into `develop`, then propagate to AP1AP4. |
| `ap1` | Browser-based OAuth client: vanilla SPA, Authorization Code + PKCE, Resource Server. |
| `ap2` | Token-mediating confidential backend: browser receives access token only. |
| `ap3` | BFF: backend owns every OAuth token and browser owns only a session cookie. |
| `ap4` | Edge forward-auth: oauth2-proxy/Nginx owns login and backend trusts an isolated identity header. |
The machine-readable registry is
[`keycloak-branch-manifest.tsv`](keycloak-branch-manifest.tsv). Run:
```bash
./scripts/audit-keycloak-branches.sh
```
The audit succeeds only when all 39 note names have matching local feature
branches and each feature tip is reachable from its declared target branch.
Google credentials are never committed. The default local acceptance harness
uses a second Keycloak realm as a controllable OIDC provider so claim mapping
and unsafe-linking failure paths can be reproduced. A real Google login remains
an explicit credentialed/public-HTTPS verification profile.
+40
View File
@@ -0,0 +1,40 @@
branch target delivery
feature/keycloak-account-linking-spa-ux ap1 documented-and-contract-tested
feature/keycloak-account-linking-sub-vs-email common documented-and-contract-tested
feature/keycloak-bff-csrf-samesite-defense ap3 locally-verified
feature/keycloak-bff-oauth2login-session ap3 locally-verified
feature/keycloak-bff-vs-spa-direct ap3 documented
feature/keycloak-docker-compose-stack common locally-verified
feature/keycloak-edge-forwardauth-google-federation ap4 documented-and-config-tested
feature/keycloak-edge-forwardauth-no-google ap4 documented-and-config-tested
feature/keycloak-federation-spa-zero-change ap1 contract-tested
feature/keycloak-first-broker-login-flow common locally-verified-with-mock-idp
feature/keycloak-four-pattern-tradeoff-matrix common documented-and-evidence-linked
feature/keycloak-google-claim-attribute-mapping common locally-verified-with-mock-idp
feature/keycloak-google-redirect-uri-policy common config-tested
feature/keycloak-header-spoofing-defense ap4 locally-verified
feature/keycloak-https-termination-caddy-nginx common config-tested
feature/keycloak-idp-brokering-google-client common locally-verified-with-mock-idp
feature/keycloak-idp-mappers-claim-to-role common locally-verified-with-mock-idp
feature/keycloak-internal-spa-direct-google-federation ap1 documented-and-contract-tested
feature/keycloak-internal-spa-direct-no-google ap1 documented-and-contract-tested
feature/keycloak-iss-claim-hostname-mismatch ap1 locally-verified
feature/keycloak-nginx-auth-request-integration ap4 locally-verified
feature/keycloak-oauth2-proxy-oidc-flow ap4 locally-verified
feature/keycloak-patterns common governance
feature/keycloak-pkce-flow-stages ap1 contract-tested
feature/keycloak-public-domain-tunneling common config-tested
feature/keycloak-realm-client-export common locally-verified
feature/keycloak-refresh-rotation-and-logout ap1 locally-verified
feature/keycloak-refresh-token-rotation ap1 contract-tested
feature/keycloak-reverse-proxy-headers common config-tested
feature/keycloak-single-ec2-google-federation ap1 documented-and-config-tested
feature/keycloak-single-ec2-no-google ap1 documented-and-contract-tested
feature/keycloak-spa-token-storage-tradeoff ap1 locally-verified
feature/keycloak-spring-rs-audience-validator ap1 locally-verified
feature/keycloak-spring-rs-role-mapping ap1 locally-verified
feature/keycloak-three-leg-trust-chain ap1 documented-and-contract-tested
feature/keycloak-token-mediating-access-handoff ap2 locally-verified
feature/keycloak-token-mediating-confidential-client ap2 locally-verified
feature/keycloak-traefik-forwardauth-alternative ap4 config-tested
feature/keycloak-vanilla-js-spa-pkce ap1 locally-verified
1 branch target delivery
2 feature/keycloak-account-linking-spa-ux ap1 documented-and-contract-tested
3 feature/keycloak-account-linking-sub-vs-email common documented-and-contract-tested
4 feature/keycloak-bff-csrf-samesite-defense ap3 locally-verified
5 feature/keycloak-bff-oauth2login-session ap3 locally-verified
6 feature/keycloak-bff-vs-spa-direct ap3 documented
7 feature/keycloak-docker-compose-stack common locally-verified
8 feature/keycloak-edge-forwardauth-google-federation ap4 documented-and-config-tested
9 feature/keycloak-edge-forwardauth-no-google ap4 documented-and-config-tested
10 feature/keycloak-federation-spa-zero-change ap1 contract-tested
11 feature/keycloak-first-broker-login-flow common locally-verified-with-mock-idp
12 feature/keycloak-four-pattern-tradeoff-matrix common documented-and-evidence-linked
13 feature/keycloak-google-claim-attribute-mapping common locally-verified-with-mock-idp
14 feature/keycloak-google-redirect-uri-policy common config-tested
15 feature/keycloak-header-spoofing-defense ap4 locally-verified
16 feature/keycloak-https-termination-caddy-nginx common config-tested
17 feature/keycloak-idp-brokering-google-client common locally-verified-with-mock-idp
18 feature/keycloak-idp-mappers-claim-to-role common locally-verified-with-mock-idp
19 feature/keycloak-internal-spa-direct-google-federation ap1 documented-and-contract-tested
20 feature/keycloak-internal-spa-direct-no-google ap1 documented-and-contract-tested
21 feature/keycloak-iss-claim-hostname-mismatch ap1 locally-verified
22 feature/keycloak-nginx-auth-request-integration ap4 locally-verified
23 feature/keycloak-oauth2-proxy-oidc-flow ap4 locally-verified
24 feature/keycloak-patterns common governance
25 feature/keycloak-pkce-flow-stages ap1 contract-tested
26 feature/keycloak-public-domain-tunneling common config-tested
27 feature/keycloak-realm-client-export common locally-verified
28 feature/keycloak-refresh-rotation-and-logout ap1 locally-verified
29 feature/keycloak-refresh-token-rotation ap1 contract-tested
30 feature/keycloak-reverse-proxy-headers common config-tested
31 feature/keycloak-single-ec2-google-federation ap1 documented-and-config-tested
32 feature/keycloak-single-ec2-no-google ap1 documented-and-contract-tested
33 feature/keycloak-spa-token-storage-tradeoff ap1 locally-verified
34 feature/keycloak-spring-rs-audience-validator ap1 locally-verified
35 feature/keycloak-spring-rs-role-mapping ap1 locally-verified
36 feature/keycloak-three-leg-trust-chain ap1 documented-and-contract-tested
37 feature/keycloak-token-mediating-access-handoff ap2 locally-verified
38 feature/keycloak-token-mediating-confidential-client ap2 locally-verified
39 feature/keycloak-traefik-forwardauth-alternative ap4 config-tested
40 feature/keycloak-vanilla-js-spa-pkce ap1 locally-verified
+377
View File
@@ -0,0 +1,377 @@
# Keycloak 멀티노드 클러스터 — 구성과 형성 확인
로드맵 1번. 세션 저장소 실험 전부의 선행 인프라다.
브랜치 `feature/keycloak-multinode-cluster-jdbc-ping`.
**결과 — 두 파드가 서로 다른 노드에서 하나의 Infinispan 클러스터를 이뤘다.**
---
## 1. 무엇을 확인하려는가
Keycloak 26은 **디스커버리와 클러스터 통신을 서로 다른 경로로** 처리한다.
| 단계 | 경로 | 실패하면 |
|---|---|---|
| **디스커버리** — 서로를 찾는다 | PostgreSQL의 `JGROUPS_PING` 테이블 | 상대의 존재 자체를 모른다 |
| **클러스터 통신** — 실제로 대화한다 | **TCP 7800** (파드 간 직접) | **DB에는 등록되는데 클러스터가 안 붙는다** |
두 번째 줄이 이 실험대를 2노드로 만든 이유다. **단일 노드에서는 이 고장을
재현할 수 없다** — 같은 커널 안에서는 막을 경계가 없기 때문이다.
먼저 **정상적으로 붙는 상태**를 확보하고 실측값을 남긴다. 그래야 다음 실험에서
깨뜨렸을 때 무엇이 달라졌는지 비교할 수 있다.
---
## 2. 배포한 구성과 그 근거
매니페스트: [`deploy/lab/k8s/keycloak-cluster.yaml`](../deploy/lab/k8s/keycloak-cluster.yaml)
### 2-1. 왜 StatefulSet인가
Deployment를 쓰면 파드 이름이 `keycloak-7d9f8b-x4k2p`처럼 매번 바뀐다.
StatefulSet은 **`keycloak-0`, `keycloak-1`로 고정**된다.
```yaml
kind: StatefulSet
spec:
serviceName: keycloak-headless
replicas: 2
podManagementPolicy: Parallel
```
**이 실험에서 이름 안정성이 중요한 이유** — 클러스터 멤버십을 읽는 곳이 두
군데인데(Infinispan 로그, `JGROUPS_PING` 테이블) 이름이 계속 바뀌면 대조가
어렵다. 실제로 Infinispan은 `keycloak-0-49501`처럼 **파드 이름 + 랜덤 접미사**를
노드 식별자로 쓴다.
**`podManagementPolicy: Parallel`** — 기본값 `OrderedReady`는 0번이 Ready가 된
뒤에야 1번을 만든다. `Parallel`은 **동시에 시작**하므로 두 파드가 DB에 등록을
경쟁하게 되고, 그것이 운영에서 실제로 일어나는 상황이다.
### 2-2. 왜 `start`이고 `start-dev`가 아닌가
```yaml
args: ["start"]
```
`start-dev`**`cache=local`을 강제**한다. 클러스터가 아예 형성되지 않는다.
저장소의 `docker-compose.yml``start-dev`를 쓰는 것은 단일 인스턴스 학습용이며,
이 실험대에서는 쓸 수 없다.
`--optimized`는 붙이지 않았다. 붙이려면 사전 `build`가 필요하고, 없으면
첫 기동에 **암묵적 build가 실행되어 60~90초**가 걸린다. 그래서 아래처럼
`startupProbe`를 넉넉하게 준다.
### 2-3. 노드당 하나씩 배치
```yaml
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app: keycloak }
```
**두 파드가 한 노드에 몰리면 7800 차단 실험이 무의미해진다.** 같은 커널 안의
루프백 통신이라 막을 대상이 없기 때문이다.
`ScheduleAnyway`를 고른 이유는 장애 실험 때문이다. `DoNotSchedule`이면 노드
하나를 죽였을 때 남은 파드가 **배치되지 못하고 Pending에 머문다.**
### 2-4. 헬스체크는 9000 포트다
```yaml
ports:
- { containerPort: 8080, name: http }
- { containerPort: 9000, name: management }
- { containerPort: 7800, name: jgroups }
startupProbe: { httpGet: { path: /health/started, port: management }, failureThreshold: 60 }
readinessProbe:{ httpGet: { path: /health/ready, port: management } }
livenessProbe: { httpGet: { path: /health/live, port: management } }
```
**Keycloak 25부터 health와 metrics가 8080이 아니라 관리 포트 9000으로 옮겨졌다.**
8080으로 프로브를 걸면 404가 나고 파드가 영원히 Ready가 되지 않는다.
`KC_HEALTH_ENABLED=true`를 켜야 엔드포인트가 노출된다.
`startupProbe``failureThreshold: 60` × `periodSeconds: 10` = **최대 10분**을
기다린다. 첫 기동의 암묵적 build 때문이다. 이게 없으면 liveness가 먼저 발동해
**재시작 루프**에 빠진다.
### 2-5. 환경변수 — 첫 실험에서 확정한 값
```yaml
- { 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" }
```
[`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md)에서
측정으로 확정한 조합이다.
| 설정 | 역할 |
|---|---|
| `KC_HOSTNAME`**전체 URL** | 스킴·호스트를 **고정**한다. 헤더와 무관하게 `iss`가 https로 발급된다 |
| `KC_HOSTNAME_STRICT=true` | Host 헤더를 믿지 않는다. 조작으로 흐름을 돌릴 여지를 없앤다 |
| `KC_PROXY_HEADERS=xforwarded` | **클라이언트 IP** 등 나머지를 forwarded 헤더에서 가져온다 |
| `KC_HTTP_ENABLED=true` | 앞단이 TLS를 끊었으므로 평문 HTTP를 받는다 |
**이 실험을 먼저 하지 않았다면** 지금 `iss``http://10.42.x.x`로 나왔을 것이고,
원인을 세션 쪽에서 찾느라 헤맸을 것이다.
### 2-6. 힙 상한
```yaml
- { name: JAVA_OPTS_KC_HEAP, value: "-Xms256m -Xmx512m" }
resources:
requests: { memory: 640Mi, cpu: 100m }
limits: { memory: 900Mi }
```
Keycloak은 기본값이 넉넉해 그냥 두면 1GB를 넘긴다. 이 실험대의 게스트 여유가
약 3.8GB이므로 명시적으로 잡는다. 실측 결과 **파드당 약 590Mi**로 안정됐다.
### 2-7. PostgreSQL — 볼륨이 노드에 고정된다
```yaml
storageClassName: local-path
strategy:
type: Recreate
env:
- { name: PGDATA, value: /var/lib/postgresql/data/pgdata }
```
k3s 기본 `local-path` 프로비저너는 **파드가 배치된 노드의 로컬 디스크**에
볼륨을 만든다. 따라서 PostgreSQL은 그 노드에 묶인다.
**이것은 결함이 아니라 실험 조건이다.** 나중에 "데이터베이스가 있는 노드가
죽으면" 시나리오가 그래서 의미를 갖는다.
- `strategy: Recreate` — RWO 볼륨은 두 파드가 동시에 마운트할 수 없다.
기본값 `RollingUpdate`면 새 파드가 볼륨을 못 잡고 멈춘다
- `PGDATA`를 한 단계 아래로 — 마운트 지점에 `lost+found` 같은 것이 있으면
`initdb`가 거부한다
### 2-8. 헤드리스 서비스는 왜 두는가
```yaml
kind: Service
metadata: { name: keycloak-headless }
spec:
clusterIP: None
```
**jdbc-ping 디스커버리에는 필요 없다.** DB로 서로를 찾기 때문이다.
개별 파드에 안정된 DNS 이름으로 접근해 상태를 조회하기 위해 둔다.
---
## 3. 실행한 명령
### 3-1. 브랜치와 정리
```bash
# 워크스테이션
cd ~/workspace/keycloak-pattern
git checkout -b feature/keycloak-multinode-cluster-jdbc-ping
git merge --no-edit develop-keycloak-session-store
# lab host — 끝난 실험을 지워 메모리를 회수한다
kubectl delete ns header-lab
```
정리 후 게스트 사용량이 `kc-lab-1 1593Mi(46%)` / `kc-lab-2 872Mi(35%)`로 떨어졌다.
### 3-2. 배포
```bash
# 워크스테이션 — 매니페스트 작성 후
git add deploy/lab/k8s/keycloak-cluster.yaml
git commit -m "feat: deploy Keycloak multi-node cluster with PostgreSQL"
git push -u origin feature/keycloak-multinode-cluster-jdbc-ping
# lab host
cd ~/workspace/keycloak-pattern
git fetch origin
git checkout -b feature/keycloak-multinode-cluster-jdbc-ping origin/feature/keycloak-multinode-cluster-jdbc-ping
kubectl apply -f deploy/lab/k8s/keycloak-cluster.yaml
```
**PostgreSQL을 먼저 기다린다.** Keycloak이 DB 없이 뜨면 기동에 실패한다.
```bash
kubectl -n keycloak-lab rollout status deployment/postgres --timeout=180s
kubectl -n keycloak-lab rollout status statefulset/keycloak --timeout=600s
```
이미지를 당겨오고 암묵적 build가 도는 첫 기동은 **수 분** 걸린다.
### 3-3. 검증
```bash
# 파드 배치 — 서로 다른 노드에 있어야 한다
kubectl -n keycloak-lab get pods -o wide
# 클러스터 뷰 — Infinispan 로그
kubectl -n keycloak-lab logs keycloak-0 | grep -E 'ISPN000094|ISPN000079|ISPN100000'
# 디스커버리 테이블
PG=$(kubectl -n keycloak-lab get pod -l app=postgres -o name | head -1)
kubectl -n keycloak-lab exec "$PG" -- \
psql -U keycloak -d keycloak -c "SELECT name, cluster_name, ip, coord FROM jgroups_ping ORDER BY name;"
# 외부 접근과 issuer
curl -s https://auth.hyeonworks.com/realms/master/.well-known/openid-configuration | python3 -m json.tool
# 자원
kubectl -n keycloak-lab top pods
```
---
## 4. 확인된 사실
증거 원자료: [`evidence/keycloak-multinode-cluster/`](evidence/keycloak-multinode-cluster/)
### 4-1. 클러스터가 형성됐다
```
ISPN000094: Received new cluster view for channel ISPN:
[keycloak-1-26938(v=16.0.12)|1] (2) [keycloak-1-26938, keycloak-0-49501]
↑ 멤버 수
ISPN100000: Node keycloak-0-49501 joined the cluster
ISPN000079: Channel `ISPN` local address is `keycloak-0-49501`,
physical addresses are `[10.42.1.18:7800]`
```
두 파드가 **동일한 뷰**를 보고 있고, 물리 주소가 **7800**임이 로그에 찍힌다.
### 4-2. 디스커버리와 통신이 분리되어 있다
```
name | cluster_name | ip | coord
------------------+--------------+-----------------+-------
keycloak-0-49501 | ISPN | 10.42.1.18:7800 | f
keycloak-1-26938 | ISPN | 10.42.0.16:7800 | t
```
**테이블 하나에 두 메커니즘이 다 보인다.**
- `name`·`cluster_name` — **DB로 하는 디스커버리**의 결과
- `ip` 컬럼의 `:7800`**실제 통신이 일어날 경로**
`coord``t``keycloak-1`이 코디네이터다. 이 노드를 죽였을 때 인계가
일어나는지가 다음 실험 항목이다.
전체 스키마는 `address / name / cluster_name / ip / coord / last_update /
coordinated_by`이며 기본키는 `address`다.
### 4-3. 노드당 하나씩 배치됐다
```
keycloak-0 10.42.1.18 kc-lab-2
keycloak-1 10.42.0.16 kc-lab-1
postgres 10.42.1.19 kc-lab-2
```
파드 IP 대역이 노드를 알려준다(`10.42.0.x` = kc-lab-1, `10.42.1.x` = kc-lab-2).
**독립된 커널 두 개에 하나씩** 떴으므로 7800 차단 실험의 전제가 성립한다.
PostgreSQL이 `kc-lab-2`에 있다는 점도 기록해둔다. **`kc-lab-2`를 죽이면
Keycloak 하나와 데이터베이스가 동시에 사라진다.**
### 4-4. 2홉 헤더 계약이 실제로 작동한다
```
issuer https://auth.hyeonworks.com/realms/master
authorization_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/auth
token_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/token
end_session_endpoint https://auth.hyeonworks.com/realms/master/protocol/openid-connect/logout
jwks_uri https://auth.hyeonworks.com/realms/master/protocol/openid-connect/certs
```
**전부 `https`이고 외부 호스트명이다.** 첫 실험의 결론이 그대로 값을 했다.
### 4-5. 자원
```
keycloak-0 594Mi
keycloak-1 593Mi
postgres 67Mi
──────────────────────
kc-lab-1 2248Mi (65%)
kc-lab-2 1447Mi (58%)
```
예상(파드당 700Mi)보다 적다. `JAVA_OPTS_KC_HEAP` 제한이 작동했다.
BFF와 Redis를 추가할 여유가 남아 있다.
---
## 5. 겪은 함정
### `JGROUPS_PING` 컬럼명은 자료마다 다르다
오래된 문서에는 `own_addr`, `ping_data` 같은 이름이 나오지만 **Keycloak 26의
실제 스키마는 다르다.**
```
address / name / cluster_name / ip / coord / last_update / coordinated_by
```
쿼리 전에 `\d jgroups_ping`으로 확인한다.
### Keycloak 컨테이너에 `curl`이 없다
메트릭을 파드 안에서 조회하려다 실패했다.
```
sh: line 1: curl: command not found
```
Keycloak 공식 이미지는 최소 구성이다. 메트릭을 볼 때는 포트포워딩하거나
임시 파드를 쓴다.
```bash
kubectl -n keycloak-lab port-forward keycloak-0 9000:9000 &
curl -s localhost:9000/metrics | grep -i cluster
# 또는
kubectl -n keycloak-lab run m --rm -i --restart=Never --image=curlimages/curl:8.11.1 -- \
curl -s http://keycloak-0.keycloak-headless:9000/metrics
```
### 첫 기동이 느린 것은 정상이다
`--optimized` 없이 `start`하면 **암묵적 build**가 실행된다. `startupProbe`
넉넉히 주지 않으면 liveness가 먼저 발동해 재시작 루프에 빠진다.
---
## 6. 다음 실험 — 깨뜨려서 무엇이 보이는지
정상 상태를 확보했으므로 이제 의도적으로 고장을 만든다.
| 실험 | 방법 | 확인할 것 |
|---|---|---|
| **7800 차단** | NetworkPolicy로 파드 간 7800만 차단 | **DB엔 등록되는데 클러스터가 안 붙는** 증상. 로그에 무엇이 먼저 보이는가 |
| **노드 상실** | `virsh destroy kc-lab-2` | 코디네이터 인계가 일어나는가. PostgreSQL도 같이 죽는다는 점에 유의 |
| **DB 상실** | postgres 파드 정지 | 이미 형성된 클러스터는 버티는가. 새 로그인은? |
**7800 차단부터 하는 것이 좋다.** 되돌리기가 가장 쉽고(NetworkPolicy 삭제),
증상이 로그에 선명하게 남는다.
## 참고
| 문서 | 관계 |
|---|---|
| [`session-store-lab-roadmap.md`](session-store-lab-roadmap.md) | 이 실험은 로드맵 1번 |
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | `KC_HOSTNAME`·`KC_PROXY_HEADERS` 값의 근거 |
| [`session-lab-operations.md`](session-lab-operations.md) | 명령·자원 예산 |
| [`session-lab-concepts.md`](session-lab-concepts.md) | StatefulSet·프로브·PVC 등 개념 |
+429
View File
@@ -0,0 +1,429 @@
# 관측성 — Prometheus · node-exporter · Grafana
로드맵 10번. 장애 주입 실험보다 **먼저** 세운다.
**왜 먼저인가** — 나중에 세우면 이미 지나간 장애의 지표를 볼 수 없다.
"클러스터가 1분쯤 뒤에 복구됐다"는 측정이 아니라 인상이다.
로드맵에 *"장애 주입 중에 어떤 지표가 먼저 움직이는지 기록한다"*고 적어둔 항목은
관측이 먼저 서 있어야만 가능하다.
메모리를 8GB → 12GB로 증설한 뒤에야 올릴 수 있게 됐다.
---
## 1. 무엇을 세웠나
```
┌─ Grafana ──────────┐
브라우저 ──────▶│ app2.hyeonworks.com│ 대시보드
└─────────┬──────────┘
│ PromQL
┌─────────▼──────────┐
│ Prometheus │ 수집·저장 (TSDB, 7일)
└─────────┬──────────┘
│ scrape (15초)
┌───────────────────┼───────────────────┐
▼ ▼ ▼
Keycloak :9000 node-exporter :9100 kubelet
(앱 지표) (머신 지표) (컨테이너 지표)
```
매니페스트: [`deploy/lab/k8s/observability.yaml`](../deploy/lab/k8s/observability.yaml)
| 구성요소 | 역할 | 실측 메모리 |
|---|---|---|
| Prometheus | 수집·저장·질의 | 164Mi |
| node-exporter (DaemonSet) | 노드당 하나, 머신 지표 | 8Mi × 2 |
| Grafana | 시각화 | 65Mi |
| **합계** | | **약 245Mi** |
예상(550Mi)보다 훨씬 적다. 실험대 규모에서는 관측성 비용이 거의 무시할 수준이다.
---
## 2. 왜 kube-prometheus-stack을 쓰지 않았나
Helm 차트 하나로 끝내는 방법이 있지만 **평범한 매니페스트를 직접 썼다.**
| | kube-prometheus-stack | 직접 작성 |
|---|---|---|
| 설치 | Helm 한 줄 | 매니페스트 400줄 |
| 메모리 | 1.5GB 이상 | **245Mi** |
| 포함 | Operator, Alertmanager, 대시보드 다수, kube-state-metrics | 필요한 것만 |
| **보이는 것** | 추상화 뒤에 숨음 | **스크레이프 설정·RBAC·relabel 이 눈에 보임** |
세 번째 줄이 결정적이다. 이 실험대의 목적은 **인과를 직접 확인하는 것**이므로,
"어떻게 타깃을 찾는가"가 YAML에 드러나 있어야 한다. Operator를 쓰면
`ServiceMonitor` 하나만 보이고 그 아래는 감춰진다.
---
## 3. 구성 결정과 근거
### 3-1. 관측 스택의 배치 — 장애 도메인 분리
```yaml
nodeSelector:
node-role.kubernetes.io/control-plane: "true"
```
**관측 시스템은 관측 대상과 같은 장애 도메인에 있으면 안 된다.** 죽는 순간을
기록해야 하는데 같이 죽으면 기록이 남지 않는다.
노드가 둘뿐이라 완전히 피할 수는 없다. 그래서 규칙을 정했다.
| 노드 | 역할 | 실험에서 |
|---|---|---|
| **kc-lab-1** (k3s **server**) | control plane · Traefik · coredns · metrics-server · local-path-provisioner | **관측 스택을 여기 둔다. 죽이지 않는다** |
| **kc-lab-2** (k3s **agent**) | keycloak-0 · postgres | **장애 주입 대상** |
`kubernetes.io/hostname`으로 못박지 않고 **`node-role.kubernetes.io/control-plane`
라벨**을 쓴 이유는 의미가 드러나기 때문이다 — "컨트롤 플레인 노드에 둔다"는
의도가 호스트 이름보다 오래간다.
### 3-2. 앞선 판단을 정정했다
배치를 조사하기 전에는 **"노드 상실 실험은 `kc-lab-1`을 죽여서 하자"**고
적었다. 그 노드에 Keycloak 하나만 있다고 생각했기 때문이다. **틀렸다.**
```
kc-lab-1 (server) keycloak-1, traefik, coredns, metrics-server, local-path-provisioner
kc-lab-2 (agent) keycloak-0, postgres
```
`kc-lab-1`을 죽이면 **API 서버·DNS·인그레스가 한꺼번에 사라진다.** 노드 상실이
아니라 **컨트롤 플레인 상실**이며, `kubectl`조차 동작하지 않는다.
**깨끗한 워커 노드 상실 실험은 `kc-lab-2`를 죽이는 것이다.** 그때도 변수가
둘(keycloak-0 + postgres)이지만, 클러스터 제어는 살아 있고 관측도 계속된다.
### 3-3. 스크레이프 주기 15초
```yaml
global:
scrape_interval: 15s
```
운영에서는 30~60초가 흔하지만 여기서는 짧게 잡았다. **노드가 죽는 순간을
두어 샘플 안에 잡아야** "무엇이 먼저 움직였나"를 말할 수 있다.
60초면 장애와 복구가 같은 샘플에 뭉개진다.
### 3-4. 타깃을 정적 목록으로 두지 않는다
```yaml
kubernetes_sd_configs:
- role: endpoints
namespaces: { names: [keycloak-lab] }
```
**파드 IP는 재시작마다 바뀐다.** 실험대를 전원 종료했다 켰을 때 모든 파드가
새 주소를 받는 것을 직접 확인했다(`10.42.1.22``10.42.1.25`).
정적 목록을 적어두면 그때마다 깨진다.
쿠버네티스 API에 물어보는 방식(service discovery)이므로 **파드가 옮겨다녀도
따라간다.** Traefik의 `trustedIPs`에 개별 IP를 적을 수 없었던 것과 같은 이유다.
### 3-5. relabel — 발견한 것을 걸러내고 이름을 붙인다
```yaml
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
```
service discovery는 네임스페이스의 **모든 엔드포인트**를 가져온다. 그중
필요한 것만 남기고 나머지는 버리는 것이 `keep`이다.
- 첫 규칙 — `keycloak-headless` 서비스의 `management` 포트만 남긴다.
8080(http)까지 긁으면 애플리케이션 트래픽 포트에 헛되이 요청이 간다
- 나머지 두 규칙 — **`pod``node` 라벨을 붙인다.** 이것이 없으면
"어느 파드가, 어느 노드에서" 라는 질문에 답할 수 없다.
노드 상실 실험에서 결정적이다
### 3-6. Keycloak 지표는 9000 포트다
헬스체크와 같은 관리 포트다. `KC_METRICS_ENABLED=true`가 이미 StatefulSet에
설정돼 있다. **8080을 긁으면 지표가 나오지 않는다.**
### 3-7. node-exporter는 DaemonSet + 호스트 네임스페이스
```yaml
kind: DaemonSet
spec:
template:
spec:
hostNetwork: true
hostPID: true
tolerations:
- operator: Exists
```
- **DaemonSet** — 노드마다 정확히 하나. 죽을 노드에도 있어야 **꺼지기 직전의
마지막 샘플**이 남는다
- **`hostNetwork`/`hostPID`** — 측정 대상이 컨테이너가 아니라 **머신**이다.
컨테이너 네임스페이스 안에서 보면 자기 자신만 보인다
- **`tolerations: operator: Exists`** — 어떤 taint가 걸린 노드에도 뜬다.
관측이 빠지는 노드가 있으면 안 된다
### 3-8. Prometheus 저장소는 PVC
```yaml
storageClassName: local-path
--storage.tsdb.retention.time=7d
```
`emptyDir`로 두면 파드가 재시작될 때 **장애 실험의 기록이 통째로 사라진다.**
사후 추적이 목적이므로 영속 저장이 필요하다.
`local-path`는 노드에 고정되므로 Prometheus도 `kc-lab-1`에 묶인다.
`nodeSelector`와 방향이 같아 문제가 되지 않는다.
보존 7일은 실험 기간보다 넉넉하면서 **볼륨이 노드를 채우는 원인이 되지 않을**
크기다.
```yaml
securityContext:
fsGroup: 65534
```
`prom/prometheus` 이미지는 `nobody`(65534)로 실행된다. `fsGroup`이 없으면
새로 만들어진 볼륨의 소유자가 root라 **쓰기 권한이 없어 기동에 실패한다.**
### 3-9. Grafana에도 외부 URL을 알려줘야 한다
```yaml
- name: GF_SERVER_ROOT_URL
value: https://app2.hyeonworks.com
```
**Keycloak의 `KC_HOSTNAME`과 정확히 같은 성격의 설정이다.** Grafana도
리다이렉트와 자산 경로에 절대 URL을 만든다. 이 값이 없으면 로그인 리다이렉트가
`http://<파드IP>:3000`으로 나간다.
2홉 헤더 계약에서 확인한 원리가 여기서도 그대로 적용된다 —
**프록시 뒤의 애플리케이션은 자기가 외부에서 어떤 주소로 보이는지 모른다.**
### 3-10. 데이터소스는 파일로 프로비저닝
```yaml
volumeMounts:
- name: datasources
mountPath: /etc/grafana/provisioning/datasources
```
UI에서 클릭으로 추가하면 Grafana 자체 DB에만 남는다. 그 DB는 여기서
`emptyDir`이므로 **파드가 재시작되면 사라진다.** 파일로 두면 항상 같은 상태로
뜬다.
### 3-11. Grafana를 `app2`에 붙인 이유
인증서에 들어 있는 이름이 `auth` / `app1` / `app2` 셋뿐이고 `app2`가 비어
있었다. **SSO 실험에서 `app2`가 필요해지면 옮긴다.**
---
## 4. 실행한 명령
```bash
# 워크스테이션 — 매니페스트 작성 후
git add deploy/lab/k8s/observability.yaml
git commit -m "feat: add Prometheus, node-exporter and Grafana"
git push origin feature/keycloak-multinode-cluster-jdbc-ping
# lab host
cd ~/workspace/keycloak-pattern && git pull
kubectl apply -f deploy/lab/k8s/observability.yaml
kubectl -n observability rollout status deployment/prometheus --timeout=300s
kubectl -n observability rollout status daemonset/node-exporter --timeout=180s
kubectl -n observability rollout status deployment/grafana --timeout=300s
```
**검증 — 배포 성공과 타깃 수집은 다른 문제다.**
```bash
kubectl -n observability run q --rm -i --restart=Never \
--image=curlimages/curl:8.11.1 --quiet --command -- \
curl -s "http://prometheus.observability.svc:9090/api/v1/targets?state=any" > /tmp/targets.json
python3 -c "
import json
d,_ = json.JSONDecoder().raw_decode(open('/tmp/targets.json').read())
ts = d['data']['activeTargets']
print(f\"{sum(1 for t in ts if t['health']=='up')}/{len(ts)} up\")
for t in ts:
if t['health'] != 'up': print(t['labels'], t.get('lastError'))
"
```
---
## 5. 겪은 함정
### kubelet 타깃이 403 Forbidden
첫 배포에서 **7개 중 5개만 up**이었다.
```
DOWN kubelet kc-lab-1 server returned HTTP status 403 Forbidden
DOWN kubelet kc-lab-2 server returned HTTP status 403 Forbidden
```
원인은 RBAC였다. kubelet 지표는 **API 서버의 proxy 서브리소스**를 통해
가져온다.
```
/api/v1/nodes/<name>/proxy/metrics
─────
```
이 경로에는 `nodes``nodes/metrics`가 아니라 **`nodes/proxy`** 권한이
필요하다.
```diff
- resources: [nodes, nodes/metrics, services, endpoints, pods]
+ resources: [nodes, nodes/metrics, nodes/proxy, services, endpoints, pods]
```
**다른 잡은 전부 정상이었다.** 이런 부분 실패는 타깃 목록을 직접 확인하지
않으면 드러나지 않는다. `rollout status`는 "성공"이라고 말한다.
### `kubectl run --rm -i`의 출력에 종료 메시지가 섞인다
```
json.decoder.JSONDecodeError: Extra data: line 1 column 54973
```
`kubectl run --rm`은 컨테이너 출력 뒤에 `pod "q" deleted`를 덧붙인다.
JSON 파서가 그 뒤를 만나면 실패한다.
**해결**`raw_decode`로 앞쪽의 완전한 JSON만 읽는다.
```python
d, _ = json.JSONDecoder().raw_decode(raw)
```
---
## 6. 실험에 쓸 지표
메트릭 이름이 **1506개** 수집된다. 그중 장애 실험에서 볼 것들이다.
### 가장 중요한 것 — `up`
```promql
up
up{job="keycloak"}
```
Prometheus가 타깃을 긁는 데 성공했는가를 0/1로 알려주는 **합성 지표**다.
타깃이 응답하지 않으면 0이 된다.
**노드나 파드가 죽는 순간 가장 먼저 움직이는 신호**이며, 다른 모든 지표가
사라지는 것과 달리 `up`**0이라는 값으로 남는다.** 그래서 "언제부터 죽었나"를
사후에 알 수 있다.
### JGroups — 7800 차단 실험의 핵심
```promql
vendor_jgroups_fd_sock2_get_num_suspected_members
vendor_jgroups_merge3_get_views
vendor_jgroups_tcp_get_different_cluster_messages
```
| 지표 | 무엇을 말하는가 |
|---|---|
| `fd_sock2_..._suspected_members` | **FD_SOCK2가 의심하는 멤버 수.** 현재 두 파드 모두 `0`. 7800이 막히면 상대를 suspect 하기 시작한다 |
| `merge3_get_views` | **MERGE3가 처리한 뷰 수.** split brain 후 다시 합칠 때 움직인다 |
| `tcp_get_different_cluster_messages` | 다른 클러스터로부터 온 메시지 |
**7800 차단 실험의 가설**`JGROUPS_PING` 테이블은 그대로 채워진 채
`suspected_members`가 0에서 1로 오르고, 클러스터 뷰가 각각 1로 쪼개진다.
### 노드 지표
```promql
node_memory_MemAvailable_bytes
node_load1
node_network_receive_bytes_total
node_filesystem_avail_bytes
```
**"머신이 죽었나 프로세스가 죽었나"** 를 가르는 데 쓴다. 파드는 사라졌는데
node-exporter가 살아 있으면 프로세스 문제이고, 둘 다 사라지면 머신 문제다.
### Keycloak 애플리케이션 지표
```promql
keycloak_session_expiration_task_seconds_count
```
`keycloak_` 접두 지표는 아직 적다. 세션 관련 지표는 **실제 로그인이 발생해야**
나타나므로, 세션 복제 실험 이후 다시 조사한다.
---
## 7. 접근
| | 주소 | 계정 |
|---|---|---|
| Grafana | `https://app2.hyeonworks.com` | `admin` / `lab-grafana-change-me` |
| Prometheus | 클러스터 내부 `prometheus.observability.svc:9090` | — |
Prometheus UI를 직접 보려면 포트포워딩한다.
```bash
kubectl -n observability port-forward svc/prometheus 9090:9090
# http://localhost:9090/targets
```
**Grafana 비밀번호가 매니페스트에 평문이다.** 로드맵 11번(비밀 관리)에서
정리한다. 지금 드러내 두는 것은 의도이며, 감춰두면 잊어버린다.
---
## 8. 자원 실측
```
grafana 65Mi
prometheus 164Mi
node-exporter 8Mi × 2
────────────────────────
합계 약 245Mi
kc-lab-1 2045Mi (41%)
kc-lab-2 1131Mi (28%)
호스트 여유 3957MB
```
메모리 증설(8GB → 12GB) 전이었다면 kc-lab-1이 60%를 넘겼을 것이다.
증설이 이 항목을 가능하게 했다.
---
## 9. 다음
관측이 서 있으므로 이제 고장을 주입하면 **무엇이 먼저 움직였는지**가 기록된다.
```
0. 세션 복제 확인 ← 로그인 세션을 만들어 두 노드에 복제되는지
1. TCP 7800 차단 ← suspected_members 와 JGROUPS_PING 대조
2. DB 상실 ← postgres 파드 정지
3. 노드 상실 ← kc-lab-2 (agent) 를 죽인다. kc-lab-1 이 아니다
```
각 실험 전후로 같은 PromQL을 실행해 대조한다.
## 참고
| 문서 | 관계 |
|---|---|
| [`keycloak-multinode-cluster.md`](keycloak-multinode-cluster.md) | 관측 대상의 구성 |
| [`session-lab-concepts.md`](session-lab-concepts.md) | Prometheus·RBAC·DaemonSet 등 개념 |
| [`session-store-lab-roadmap.md`](session-store-lab-roadmap.md) | 로드맵 10번 |
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | `GF_SERVER_ROOT_URL`이 필요한 이유 |
+291
View File
@@ -0,0 +1,291 @@
# 열린 질문 커버리지 — 이 실험대로 답할 수 있는가
공개 기록에 등록된 KeyCloak Patterns 열린 질문 네 개를, 이 실험대가 실제로
검증할 수 있는지 대조한 결과.
> **목록 경로는 [`/explore/questions`](https://hyeonworks.com/explore/questions)**
> 다. `/questions` 는 404 이고 개별 문서만 `/questions/<slug>` 로 열린다.
>
> **2026-09-04 재확인** — Playwright 로 네 문서를 전문 재독하고
> 「남은 미지수」·「다음 검증」·「제약」을 항목 단위로 대조한 결과
> **계획에 빠진 항목 9개**를 찾아 보강했다. 항목별 실험 번호 대조표는
> [`experiment-plan.md`](experiment-plan.md) B층 머리에 있다.
**결론 — 네 개 모두 이 실험대에서 재현 가능하다. 다만 로드맵에 빠진 항목이
있고, 순서가 한 곳 뒤집혀 있다.**
| # | 질문 | 게시 | 로드맵 커버 |
|---|---|---|---|
| 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)의 비교표 |
+21
View File
@@ -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 파일의 구조까지만 수행한다.
+15
View File
@@ -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
+434
View File
@@ -0,0 +1,434 @@
# 실험대 운영 — 도구 · 명령 · 훈련
실험을 돌리는 데 반복해서 쓰는 것들. 개념은
[`session-lab-concepts.md`](session-lab-concepts.md), 계획은
[`session-store-lab-roadmap.md`](session-store-lab-roadmap.md)에 있다.
---
## 1. 관측 도구
### htop
**설치** — 세 대 모두 배포판이 다르다.
```bash
# lab host (Arch)
sudo pacman -S htop
# 게스트 (Debian) — lab host 에서 한 번에
for n in kc-lab-1 kc-lab-2; do
ssh $n 'sudo apt-get update -qq && sudo apt-get install -y -qq htop'
done
```
**lab host에서 htop을 읽는 법 — VM은 프로세스로 보인다**
가장 중요한 관점이다. 게스트는 **호스트 입장에서 `qemu-system-x86_64`
프로세스 하나**다. 그래서 VM의 메모리 사용량이 곧 그 프로세스의 RES다.
```
pid=4677 RSS=3765MB qemu-system-x86 ← kc-lab-1 (할당 3584M)
pid=4770 RSS=2670MB qemu-system-x86 ← kc-lab-2 (할당 2560M)
```
**RSS가 할당량보다 조금 큰 이유**는 QEMU 자체의 에뮬레이션 오버헤드
(장치 모델, 버퍼)가 더해지기 때문이다. 게스트가 터치한 페이지만큼만
RSS로 잡히므로, 게스트가 메모리를 더 쓰면 RSS도 할당 상한까지 올라간다.
**htop 안에서 쓸 키**
| 키 | 동작 | 이 실험대에서 |
|---|---|---|
| `F5` | 트리 뷰 | `libvirtd``qemu-system` 계층 확인 |
| `F4` | 필터 | `qemu`만 보기 |
| `F6` | 정렬 기준 | `PERCENT_MEM`으로 정렬 |
| `M` / `P` | 메모리/CPU 정렬 | |
| `u` | 사용자 필터 | `libvirt-qemu`로 VM만 |
| `H` | 스레드 숨김 | QEMU는 vCPU마다 스레드라 켜두면 지저분하다 |
**게스트 안에서 htop을 읽을 때** 보이는 것은 `k3s`(server 또는 agent),
`containerd`, `containerd-shim`, 그리고 각 파드의 `java` 프로세스다.
Java 힙 상한은 컨테이너 limit의 70%(`-XX:MaxRAMPercentage=70`)이므로
512Mi limit이면 약 358Mi다.
### k9s
설치는 되어 있다. 별도 구성 없이 `~/.kube/config`를 읽는다.
```bash
k9s
```
| 키 | 동작 |
|---|---|
| `:` | 명령 모드 — `:pods` `:svc` `:ing` `:nodes` `:events` |
| `0` | 전체 네임스페이스 |
| `/` | 필터 |
| `l` | 로그 |
| `d` | describe |
| `y` | YAML |
| `s` | 파드 안 셸 |
| `Ctrl+d` | 파드 삭제 ← **장애 주입에 씀** |
| `esc` / `q` | 뒤로 / 종료 |
`~/.config/k9s/config.yaml``refreshRate`를 2초로 낮추면 노드를 죽였을 때
파드 재배치가 실시간으로 보인다.
### kubectl top
k3s가 metrics-server를 기본 배포하므로 바로 쓸 수 있다.
```bash
kubectl top nodes
kubectl -n header-lab top pods
```
**htop과 보는 층이 다르다.**
| | 보는 것 |
|---|---|
| `htop` (lab host) | VM 프로세스 = 게스트 전체 |
| `htop` (게스트) | 게스트 안의 프로세스 |
| `kubectl top` | 파드·노드 단위, 클러스터 관점 |
### 상태 점검 스크립트
```bash
./deploy/lab/scripts/verify-lab.sh # lab host 에서
./deploy/lab/scripts/measure-proxy-headers.sh # 어디서든
```
`verify-lab.sh`는 게스트·k3s·nginx·인증서·공개 진입점을 한 번에 확인하고
`lab is healthy`를 출력한다. **`404`가 성공 신호**다.
---
## 2. 자원 실측과 예산
**2026-09-03 기준, Keycloak 배포 전**
### 호스트 여유와 게스트 여유는 다르다
가장 오해하기 쉬운 지점이다. 호스트만 보면 절망적으로 보인다.
```
lab host 총 7628MB · 사용 7189MB · 여유 439MB
├ qemu #1 RSS 3765MB kc-lab-1 (할당 3584MB) → 상한 도달
└ qemu #2 RSS 2633MB kc-lab-2 (할당 2560MB) → 상한 도달
```
그런데 게스트 안을 보면 여유가 있다.
```
kc-lab-1 총 3423MB · used 1464 · buff/cache 2020 · available 1959MB
kc-lab-2 총 2480MB · used 580 · buff/cache 1714 · available 1899MB
─────────────────
게스트 여유 합계 약 3.8GB
```
**왜 이런가** — QEMU의 RSS는 게스트가 **터치한 페이지**만큼이다. 게스트가
메모리를 페이지 캐시로 다 채우면 QEMU RSS도 할당 상한까지 올라간다.
지금이 그 상태다.
**그래서 앞으로 워크로드를 올려도 호스트 압박은 늘지 않는다.** 게스트 안의
페이지 캐시가 밀려날 뿐이다. **QEMU RSS는 이미 천장이다.**
```
확인 방법:
ps -eo rss,args --sort=-rss | grep '[q]emu-system' # 호스트에서 본 VM
ssh kc-lab-1 free -m # 게스트 안 실제
kubectl top nodes # working set
```
세 값이 다른 것을 보는 것이 이 실험대의 메모리 감각이다.
### 배포 예산
| 워크로드 | 예상 | 배치 |
|---|---|---|
| Keycloak × 2 | 각 700Mi | 노드당 1개 |
| PostgreSQL | 300Mi | kc-lab-1 |
| Redis | 100Mi | kc-lab-2 |
| BFF × 2 | 각 400Mi | 노드당 1개 |
| **합계** | **약 2600Mi** | |
**게스트 여유 3.8GB 중 2.6GB → 가능하다.** 다만 여기에
Prometheus/Grafana(로드맵 10번 관측성)를 얹을 여유는 없다.
### 대응 — 비용이 없는 것부터
**1. 끝난 실험은 지운다**
```bash
kubectl delete ns header-lab # 파드 2개 × 150Mi 회수
```
증거는 `docs/evidence/`에 남아 있으므로 워크로드를 유지할 이유가 없다.
**2. Keycloak 힙을 명시적으로 제한한다**
Keycloak은 기본값이 넉넉해 그냥 두면 1GB를 넘긴다.
```yaml
env:
- name: JAVA_OPTS_KC_HEAP
value: "-Xms256m -Xmx512m"
resources:
limits:
memory: 768Mi
```
**모든 워크로드에 `resources.limits`를 반드시 건다.** 안 걸면 한 파드가
게스트 메모리를 다 먹고 다른 파드까지 OOMKilled된다.
**3. 실험을 순차로 돌린다 — 동시에 다 띄우지 않는다**
```
A층(Keycloak + PostgreSQL) → 결과 기록 → 정리
B층(BFF + Redis) → 결과 기록 → 정리
관측성(Prometheus) → 필요할 때만
```
절약책이 아니라 **정상적인 실험 운영 방식**이다. 동시에 띄우면 변수가
섞여서 원인 분리가 어려워진다.
### swap은 쓰지 않는다
호스트에는 8GB swap이 있지만 **게스트에는 0MB이며, 그것이 맞다.**
| 이유 | |
|---|---|
| k3s/kubelet | 기본적으로 swap 을 거부한다 |
| 성능 | 호스트 swap 으로 QEMU 페이지가 밀리면 급락한다 |
| **측정 오염** | 이 실험대는 **타이밍**(refresh 경쟁, Infinispan 복제 지연)을 잰다. swap 이 끼면 측정이 통째로 무의미해진다 |
### 근본 해결 — 메모리 증설
남은 실험이 10개이고 관측성까지 하려면 증설이 가장 확실하다.
```bash
sudo pacman -S dmidecode
sudo dmidecode -t memory | grep -E "Maximum Capacity|Number Of Devices|Size:|Locator:|Type:|Speed:"
```
| 슬롯 상태 | 조치 |
|---|---|
| 2슬롯 중 1개만 사용 | 동일 규격 8GB 추가 → 16GB |
| 온보드 8GB + 슬롯 1개 | 16GB 추가 → 24GB |
| 2슬롯 모두 사용 | 8GB × 2 를 16GB × 2 로 교체 |
i5-1135G7(Tiger Lake)은 DDR4-3200 SO-DIMM을 쓰며 최대 용량은 보드마다
다르므로 `Maximum Capacity` 값을 확인한다. **비용 대비 효과가 가장 크다**
증설하면 Prometheus·Grafana·BFF 2 replica를 동시에 띄우고도 남는다.
## 3. 자주 쓰는 명령
### VM (lab host, `LIBVIRT_DEFAULT_URI=qemu:///system`)
```bash
virsh list --all # 상태
virsh domstate kc-lab-1
virsh domblklist kc-lab-1 # 붙은 디스크
virsh net-dhcp-leases default # 게스트 IP
virsh screenshot kc-lab-1 /tmp/kc1.ppm # 화면 (PNG 로 저장됨)
virsh send-key kc-lab-1 --codeset linux KEY_ENTER
virsh destroy kc-lab-1 # 전원 강제 차단 = 노드 상실
virsh start kc-lab-1 # 재기동
virsh shutdown kc-lab-1 # ACPI 정상 종료
```
**`destroy`는 파일을 지우지 않는다.** 전원 코드를 뽑는 것에 해당한다.
정의와 디스크를 지우는 것은 `undefine`이다.
### 클러스터
```bash
kubectl get nodes -o wide
kubectl get pods -A -o wide
kubectl -n <ns> logs -f deployment/<name>
kubectl -n <ns> describe pod <pod>
kubectl -n <ns> rollout status deployment/<name>
kubectl -n <ns> rollout restart deployment/<name>
kubectl -n <ns> rollout undo deployment/<name> # 직전 버전으로
# 설정 스위치 껐다 켜기 — 실험의 기본 동작
kubectl -n <ns> set env deployment/<name> KEY=VALUE
# 임시 파드로 클러스터 안에서 테스트
kubectl -n <ns> run t --rm -i --restart=Never --image=curlimages/curl:8.11.1 -- \
curl -s http://<svc>:<port>/path
```
### 이미지 반입
k3s는 containerd를 쓰고 레지스트리가 없다. **자체 빌드 이미지는 매번 이
경로를 탄다.**
```bash
# 워크스테이션에서
docker build -t keycloak-pattern-api:lab backend
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
# 확인
ssh test-server "ssh kc-lab-1 'sudo k3s ctr images ls -q | grep keycloak-pattern'"
```
**두 노드 모두**에 넣는다. 스케줄러가 어디에 배치할지 모른다.
매니페스트는 `imagePullPolicy: Never`여야 한다.
`ctr`이 아니라 **`k3s ctr`** 이다. 시스템에 별도 `ctr`이 있으면 다른 소켓을
보게 되어 "성공했는데 파드는 못 찾는" 상태가 된다.
### 저장소·브랜치
```bash
# 워크스테이션 — 작성·커밋
git add -A && git commit -m "..." && git push origin develop-keycloak-session-store
# lab host — 받기만 (읽기 전용으로 운용)
cd ~/workspace/keycloak-pattern && git pull
# 실험별 브랜치 이동
git checkout feature/keycloak-multinode-cluster-jdbc-ping
```
**lab host의 저장소는 읽기 전용으로 쓴다.** 거기서 편집하면 드리프트가
생긴다 — nginx 설정에서 실제로 겪었다
([`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) 9절).
### 호스트 nginx
```bash
sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab
sudo nginx -t && sudo systemctl reload nginx
sudo nginx -T | grep -n 'upstream\|server_name' # 최종 병합 설정
```
**`nginx -t`를 통과한 뒤에만 reload한다.** 깨진 설정으로 reload하면 서비스가
내려간다. `-T`(대문자)는 include까지 펼친 최종 설정을 출력하므로
"파일을 고쳤는데 반영이 안 된다" 상황의 확인 수단이다.
---
## 4. 손에 익혀야 할 훈련
개념은 알지만 직접 해보지 않은 조작들. **남은 실험 5개 중 4개가 훈련 3
위에 서 있다.**
### 훈련 1 — 이미지 반입
앞으로 BFF·token-mediator를 올릴 때마다 반복된다.
```bash
docker build -t keycloak-pattern-api:lab backend
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-1 'sudo k3s ctr images import -'"
docker save keycloak-pattern-api:lab | ssh test-server "ssh kc-lab-2 'sudo k3s ctr images import -'"
ssh test-server "ssh kc-lab-1 'sudo k3s ctr images ls -q | grep keycloak-pattern'"
```
### 훈련 2 — 설정 스위치 껐다 켜기
**설정을 바꿔가며 비교하는 것이 이 실험대의 본체**다.
```bash
kubectl -n header-lab set env deployment/echo SERVER_FORWARD_HEADERS_STRATEGY=none
kubectl -n header-lab rollout status deployment/echo
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -E '"scheme"|"secure"'
# → "http" / false 로 바뀐다
kubectl -n header-lab set env deployment/echo SERVER_FORWARD_HEADERS_STRATEGY=native
kubectl -n header-lab rollout status deployment/echo
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool | grep -E '"scheme"|"secure"'
# → "https" / true 로 돌아온다
```
### 훈련 3 — 노드를 죽였다 살리기
**가장 중요하다.** 장애 실험의 전제 조작이다.
```bash
# 죽이기 — 전원 차단에 해당
virsh destroy kc-lab-2
# 관찰 (NotReady 로 바뀌는 데 40초 안팎)
kubectl get nodes
kubectl -n header-lab get pods -o wide
kubectl get events -A --sort-by=.lastTimestamp | tail -20
# 서비스가 살아있는지
curl -sI https://app1.hyeonworks.com/api/echo | head -1
# 되살리기
virsh start kc-lab-2
kubectl get nodes # Ready 복귀
kubectl -n header-lab get pods -o wide
```
**이번에 특별히 확인할 것** — 현재 **Traefik은 replica 1**이고
`kc-lab-2`에 있다. 그 노드를 죽이면 **진입점 자체가 사라지는지**,
아니면 다른 노드로 재배치되어 복구되는지 관찰한다.
| 관찰 | 의미 |
|---|---|
| `curl`이 계속 200 | svclb가 남은 노드로 흘려보냄 + Traefik 재배치 성공 |
| `curl`이 실패했다가 복구 | 재배치에 걸린 시간만큼 다운타임 |
| `curl`이 계속 실패 | Traefik replica를 2로 늘려야 한다 |
이 결과에 따라 **Keycloak 배포 전에 Traefik replica를 조정할지** 결정한다.
```bash
# 필요하다면
kubectl -n kube-system scale deployment/traefik --replicas=2
```
**망가져도 된다.** `virt-install` 한 줄로 재생성되며
([`deploy/lab/README.md`](../deploy/lab/README.md) 게스트 재생성),
그러라고 만든 실험대다.
---
## 5. 실행 주체 감사 (2026-09-03)
무엇을 직접 했고 무엇을 대행했는지의 기록. **대행한 항목은 언젠가 직접
해야 하는 것들**이다.
### 직접 수행
VM 생성 · cloud-init 작성 · SSH 키와 config · k3s server/agent 설치 ·
kubeconfig 구성 · 호스트 nginx 설치와 설정 · certbot DNS-01 인증서 발급 ·
Cloudflare DNS 레코드 · git 저장소 클론과 브랜치 · `echo.yaml` 최초 배포 ·
nginx `X-Forwarded-Proto` 수정
### 대행 수행
| 작업 | 언제 다시 필요한가 |
|---|---|
| 이미지 빌드 → 노드 반입 (2회차 이후) | 자체 이미지를 올릴 때마다 |
| `kubectl apply` (traefik HelmChartConfig, NetworkPolicy) | 모든 배포 |
| `kubectl set env` / `rollout restart` | 설정 비교 실험마다 |
| HelmChartConfig 재조정 대기와 파드 확인 | Traefik 설정 변경 시 |
| git 브랜치 생성 · 커밋 · push | 실험마다 |
| Playwright 증거 수집 | 브라우저 관점이 필요할 때마다 |
| `kubectl run` 임시 파드 위조 테스트 | 클러스터 내부 검증 시 |
| 설정 되돌렸다 복구 (단계별 스크린샷) | 비교 증거를 만들 때 |
### 아직 한 번도 하지 않은 것
| 항목 | 필요해지는 시점 |
|---|---|
| **`virsh destroy` + 재생성** | **장애 실험 전부** |
| `rebuild-seed.sh` | cloud-init 을 바꿀 때 |
| k9s 실사용 | 장애 중 상태 관찰 |
| `kubectl delete ns` | 실험 정리, 메모리 회수 |
### 만들었지만 미검증이었던 것 → 2026-09-03 확인 완료
| 스크립트 | 결과 |
|---|---|
| `verify-lab.sh` | 정상 — `lab is healthy` |
| `measure-proxy-headers.sh` | 정상 — 4개 항목 모두 출력 |
+400
View File
@@ -0,0 +1,400 @@
# 이 실험을 이해하기 위한 선수 지식
실험 결과를 먼저 들이밀었더니 맥락이 사라졌다. 이 문서는 **왜 이런 걸
측정하고 있는지**를 바닥부터 세운다.
읽는 순서가 곧 의존 관계다. 아는 절은 건너뛰어도 되지만, 3장까지는
"세션"이라는 말의 뜻이 계속 바뀌므로 훑고 가는 편이 낫다.
---
## 0. 출발점 — 당신이 원래 물은 것
> Keycloak이 여러 개일 경우, 세션 저장소를 Redis나 별도 저장소로 쓸 경우,
> Redis와 DB에 분리해서 세션과 토큰을 관리할 때 어떻게 달라지는지.
> SSO를 추가하면 어떻게 달라지는지. Redis 또는 DB가 죽으면 어떻게 복구하는지.
이 질문에 답하려면 **"세션이 어디에 있는가"** 를 정확히 알아야 한다.
지금 하고 있는 실험은 전부 그 한 문장을 쪼갠 것이다.
---
## 1. HTTP는 기억이 없다
모든 것의 출발점.
```
요청 1: GET /login → 서버
요청 2: GET /mypage → 서버 ← 서버는 요청 1을 기억하지 못한다
```
HTTP 요청은 **하나하나가 완전히 독립적**이다. 서버 입장에서 두 번째 요청은
생판 처음 보는 사람이 보낸 것과 구별되지 않는다.
그래서 "로그인했다"는 사실을 **어딘가 저장**해야 한다.
```
브라우저 서버
┌──────────────┐ ┌────────────────────────┐
│ 쿠키 │ │ 세션 저장소 │
│ SESSIONID= │ ──── 매 요청 ────▶ │ abc123 → { │
│ abc123 │ 이 값만 보냄 │ user: "홍길동", │
└──────────────┘ │ 로그인시각: ... │
│ } │
작은 표만 들고 다닌다 └────────────────────────┘
실제 내용은 여기 있다
```
| 용어 | 뜻 |
|---|---|
| **쿠키** | 브라우저가 들고 다니는 **작은 표(번호표)**. 보통 세션 ID만 들어 있다 |
| **세션** | 서버가 그 번호에 대해 기억하는 **실제 내용** |
**서버가 1대면 여기서 이야기가 끝난다.** 문제는 2대부터다.
---
## 2. 서버가 2대가 되는 순간 — 이 실험의 진짜 출발점
```
로그인 요청 ──▶ 서버 A A의 메모리에 "abc123 = 홍길동" 기록
다음 요청 ──▶ 서버 B B: "abc123? 그런 거 모르는데" → 로그아웃 화면
```
**이게 전부다.** 분산 세션이라는 주제 전체가 이 한 장면에서 나온다.
푸는 방법은 셋뿐이다.
| 방법 | 어떻게 | 대가 |
|---|---|---|
| **1. 고정 배정** (sticky session) | 같은 사람은 항상 같은 서버로 보낸다 | **그 서버가 죽으면 그 사람 세션은 사라진다.** 부하도 안 고르게 퍼진다 |
| **2. 복제** | 서버끼리 메모리 내용을 서로 보낸다 | 서버가 N대면 트래픽이 N² 로 는다. 어긋남(불일치)이 생긴다 |
| **3. 공유 저장소** | 세션을 바깥(DB·Redis)에 두고 모두가 본다 | **그게 죽으면 전체가 멈춘다.** 매 요청마다 네트워크 왕복 |
> **당신이 원래 물은 "Redis나 별도 저장소를 쓰면"이 바로 3번**이다.
> 그리고 "Redis나 DB가 죽으면 어떻게 복구하나"는 3번의 대가를 묻는 것이다.
**Keycloak도 예외가 아니다.** Keycloak을 2대 띄우면 정확히 이 문제가 생긴다.
Keycloak이 이걸 어떻게 풀었는지가 실험 0의 주제다.
---
## 3. Keycloak은 무엇이고, 왜 세션을 갖는가
### 3-1. 하는 일
Keycloak은 **로그인을 대신 해주는 서버**다.
```
[사용자] [내 앱] [Keycloak]
│ │ │
│─ 접속 ──────▶│ │
│◀─ "Keycloak 가서 로그인하고 와" ────│
│──────────────────── 로그인 ───────▶│
│◀─────────────── 토큰 발급 ─────────│
│─ 토큰 들고 ──▶│ │
│ │─ 이 토큰 유효해? ──▶│
```
내 앱은 비밀번호를 저장하지도, 검증하지도 않는다. 그 일을 Keycloak이 한다.
### 3-2. 그래서 **세션이 두 겹**이 된다
여기가 헷갈리는 지점이다. "세션"이라는 말이 두 가지를 가리킨다.
```
┌─────────────────────────────────────────────────────┐
│ Keycloak 의 SSO 세션 │
│ "이 브라우저는 홍길동으로 로그인되어 있다" │
│ 쿠키 이름: KEYCLOAK_IDENTITY │
└─────────────────────────────────────────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 앱1 의 세션 │ │ 앱2 의 세션 │
│ (또는 토큰) │ │ (또는 토큰) │
└──────────────────┘ └──────────────────┘
```
| | 누가 갖는가 | 사라지면 |
|---|---|---|
| **SSO 세션** | **Keycloak** | 모든 앱에서 다시 로그인해야 한다 |
| 앱 세션 | 각 애플리케이션 | 그 앱만 다시 들어가면 된다 |
**SSO가 되는 원리가 이것이다.** 앱1에서 로그인하면 Keycloak에 SSO 세션이
생긴다. 앱2로 가면 Keycloak이 "이 브라우저 이미 로그인했네" 하고 **로그인
화면 없이** 바로 토큰을 준다.
> **그래서 Keycloak의 세션이 사라지면 SSO 전체가 깨진다.**
> 당신 질문의 "SSO를 추가하면 어떻게 달라지는지"가 여기 걸린다.
> 앱이 하나일 때는 그 앱만 재로그인이지만, SSO에서는 **전 앱이 동시에** 터진다.
---
## 4. 토큰이 있는데 왜 세션이 필요한가
가장 흔한 오해다. "JWT는 stateless라서 서버가 기억할 게 없다"는 말은
**반만 맞다.**
### 4-1. 토큰이 두 종류다
```
로그인 성공
├──▶ access token 수명 짧음 (이 실험대: 60초)
│ JWT. 서명이 붙어 있어 서버가 아무것도 기억 안 해도 검증된다
│ → 진짜 stateless
└──▶ refresh token 수명 김 (이 실험대: 1800초 = 30분)
access token 이 만료되면 이걸로 새로 받는다
→ 서버가 세션을 기억하고 있어야 한다
```
| | access token | refresh token |
|---|---|---|
| 검증 방식 | **서명만 보면 됨** | **서버 세션 조회 필요** |
| 취소 | **불가능** (만료를 기다려야) | 가능 |
| 수명 | 짧게 (분 단위) | 길게 (시간~일) |
### 4-2. 그래서 이렇게 된다
```
0초 로그인 세션 생성
0초 access token 발급 이후 60초간은 서버에 안 물어봐도 됨
60초 access token 만료
60초 refresh 요청 ─────▶ 서버: "이 세션 살아 있나?" ◀── 여기서 세션 필요
60초 새 access token
120초 또 만료 → 또 refresh → 또 세션 조회
```
**60초마다 세션 저장소를 친다.** access token 수명이 짧을수록 세션 저장소
부하가 커진다 — 보안과 성능의 맞바꿈이 여기서 일어난다.
### 4-3. 로그아웃도 세션이 있어야 한다
**로그아웃 = 세션 삭제**다. 세션이 없으면 로그아웃이라는 개념 자체가 없다.
이미 발급된 access token은 서명이 유효하므로 만료 전까지 계속 통과한다.
> 그래서 access token 수명을 60초로 짧게 잡는다. 로그아웃해도 최대 60초는
> 살아 있다는 뜻이고, 그 이상은 refresh 가 막히므로 끝난다.
---
## 5. Keycloak은 세션을 어디에 두는가 — **버전에 따라 답이 다르다**
이 실험 전체가 여기에 걸려 있다.
### 5-1. 두 개의 후보
| | 무엇 | 성질 |
|---|---|---|
| **Infinispan** | Keycloak **안에 내장된** 분산 캐시. Java 라이브러리 | 메모리. 빠름. 프로세스가 죽으면 사라짐 |
| **데이터베이스** | PostgreSQL 등 바깥의 DB | 디스크. 느림. 재시작해도 남음 |
**Infinispan은 별도로 설치하는 물건이 아니다.** Keycloak 프로세스 안에서 도는
라이브러리다. Redis처럼 따로 띄우는 게 아니다 — 이걸 헷갈리면 전체가 안 맞는다.
### 5-2. 버전별로 이렇게 바뀌었다
| 버전 | 진실의 원천 | 전체 재시작하면 |
|---|---|---|
| ~24 | **Infinispan (메모리)** | **세션 전부 소멸** |
| 25 | 선택 (`persistent-user-sessions` 옵션) | 설정에 따라 |
| **26 (지금 이 실험대)** | **데이터베이스** | **세션 살아남음** |
**이게 결정적이다.** 인터넷에 있는 Keycloak 클러스터링 자료 대부분은
24 이전 기준이라 **"세션은 Infinispan이 노드끼리 복제한다"** 고 쓰여 있다.
26에서는 더 이상 사실이 아니다.
> 제가 처음에 개념 문서에 "Infinispan = 캐시 + 노드 간 실시간 전파"라고
> 써둔 것도 이 옛 모델을 그대로 옮긴 것이었다. 실험 0에서 틀렸음이 드러났다.
---
## 6. Infinispan / JGroups / 7800 — 이름들의 정체
실험 로그에 계속 나오는 이름들이다.
```
Keycloak 프로세스
┌────────────────────────────────────────┐
│ Infinispan "세션을 어디 두고 어떻게 │ ← 캐시 계층
│ 나눌까" │
│ │ │
│ JGroups "누가 우리 멤버이고 │ ← 그룹 통신 계층
│ 어떻게 메시지를 주고받나" │
│ │ │
│ TCP 7800 실제 소켓 │ ← 네트워크
└────────────────────────────────────────┘
```
| 이름 | 정체 |
|---|---|
| **Infinispan** | Keycloak 내장 캐시. 세션·realm 설정·로그인 실패 횟수 등을 담는다 |
| **JGroups** | Infinispan이 노드끼리 대화할 때 쓰는 하부 라이브러리 |
| **TCP 7800** | JGroups가 쓰는 포트. **노드 간 통신 경로** |
| **jdbc-ping** | 서로를 **찾는** 방법. DB의 `JGROUPS_PING` 테이블을 게시판처럼 쓴다 |
| `ISPN000094` | "새 멤버 명단을 받았다"는 로그 코드 |
**찾는 것과 대화하는 것이 다른 경로다.**
```
디스커버리 (서로를 찾는다) → PostgreSQL JGROUPS_PING 테이블
트랜스포트 (실제 대화) → TCP 7800
```
---
## 7. 왜 쿠버네티스와 노드 2대가 나오는가
당신 질문은 "Keycloak이 여러 개일 경우"였다. 그걸 **진짜로** 재현하려면
Keycloak 프로세스 2개가 **서로 다른 기계**에 있어야 한다.
| 방식 | 노드 상실을 실험할 수 있나 |
|---|---|
| Docker 컨테이너 2개 (한 기계) | **못 한다.** 커널이 하나라 "기계가 죽는" 상황을 못 만든다 |
| **VM 2대 + k3s** | **된다.** 하나를 전원 차단할 수 있다 |
그래서 이 실험대는 VM 2대(`kc-lab-1`, `kc-lab-2`) 위에 k3s를 올렸다.
```
kc-lab-1 (k3s 서버) kc-lab-2 (k3s 에이전트)
├─ keycloak-1 ├─ keycloak-0
├─ traefik, coredns └─ postgres
└─ prometheus, grafana
```
**`keycloak-0` / `keycloak-1` 은 Keycloak 프로세스**이고,
**`kc-lab-1` / `kc-lab-2` 는 그것들이 올라간 기계**다. 이름이 비슷해서
헷갈리기 쉬운데 계층이 다르다.
---
## 8. 그래서 실험 0은 무엇을 알아내려 한 것인가
### 8-1. 답해야 할 실무 질문
> Keycloak을 2대로 늘렸다. **한 대가 죽으면 로그인한 사람들은 어떻게 되나?**
> **DB가 죽으면?** **노드 사이 네트워크가 끊기면?**
이 질문들에 답하려면 **정상일 때 무엇이 어디에 있는지**를 먼저 알아야 한다.
그게 없으면 장애를 일으켜도 무엇이 왜 깨졌는지 해석할 수 없다.
### 8-2. 그래서 실험 0의 질문은 두 개다
```
질문 A. 한 노드에서 만든 세션을 다른 노드가 쓸 수 있는가?
↓ 답: 그렇다
질문 B. 그 공유는 무엇 덕분인가?
(a) Infinispan 이 메모리를 복제해서
(b) 둘 다 같은 DB 를 봐서
```
### 8-3. **B를 구분해야 하는 이유** — 운영 대응이 정반대다
| 상황 | (a) 복제라면 | (b) DB라면 |
|---|---|---|
| 노드 간 7800 끊김 | **세션 공유 깨짐** | **멀쩡** |
| DB 죽음 | 한동안 버팀 | **즉시 전면 장애** |
| 노드 1대 죽음 | 세션 살아남음 | 세션 살아남음 |
| 성능 병목 | 노드 간 네트워크 | **DB, 커넥션 풀** |
| 튜닝할 곳 | JGroups 설정 | **DB 인덱스, 커넥션 수** |
| 노드를 10대로 늘리면 | **복제 트래픽 폭증** | DB 부하 증가 |
**같은 증상에 정반대 처방이 나온다.** 그래서 추측이 아니라 측정으로
확정해야 했다.
---
## 9. 왜 하필 "캐시 엔트리 개수"를 셌는가
질문 B를 가르는 가장 직접적인 방법이기 때문이다.
```
keycloak-0 에만 로그인을 보낸다
└──▶ 그리고 keycloak-1 의 메모리를 들여다본다
그 세션이 들어와 있으면 → (a) 복제한 것
비어 있으면 → (b) DB 로 공유한 것
```
Keycloak은 자기 캐시에 몇 개가 들었는지를 `/metrics` 로 알려준다.
```
vendor_statistics_approximate_entries_unique{cache="sessions"} 7.0
───────────── ───
세션 캐시 7개 들어 있다
```
**측정 결과: keycloak-1은 계속 0이었다.** keycloak-0이 14개를 들고 있는
동안에도 0. 그리고 keycloak-1에 직접 로그인을 보낸 순간에만 늘었다.
```
단계 k0 k1
시작 2 0
keycloak-1 에 로그인 5회 2 5 ← k0 안 늘어남
keycloak-0 에 로그인 5회 7 5 ← k1 안 늘어남
PostgreSQL 세션 수: 12 = 7 + 5 ← 캐시 합과 정확히 일치
```
**각 노드는 자기가 처리한 것만 캐시한다. 메모리는 건너가지 않는다.**
→ 답은 **(b)**.
그리고 마지막으로 PostgreSQL 로그를 켜서, keycloak-1이 **실제로 날리는
SELECT 문**을 잡았다. 추측이 아니라는 것을 못 박기 위해서다.
---
## 10. 이 사실이 당신 운영에 뜻하는 것
| 알게 된 것 | 실무적 의미 |
|---|---|
| 세션은 DB에 있다 | **DB가 단일 장애점이다.** HA·백업 계획이 Keycloak 대수보다 중요하다 |
| 메모리는 로컬 캐시일 뿐 | Keycloak을 몇 대로 늘려도 **노드 간 트래픽은 안 는다.** 대신 DB 부하가 는다 |
| 남의 세션은 캐시 안 함 | **sticky session 은 정확성이 아니라 성능 문제다.** 없어도 동작하지만 DB를 더 친다 |
| refresh 마다 DB 읽기+쓰기 | access token 수명을 줄이면 **DB 부하가 그만큼 는다** |
| `synchronous_commit OFF` | **DB가 강제 종료되면 직전 수백 ms 갱신이 사라진다** (의도된 설계) |
| 낙관적 락 (`VERSION`) | **동시에 refresh 하면 한쪽이 진다.** 클라이언트에 재시도가 필요하다 |
---
## 11. 앞으로 할 실험과 각각이 답하는 질문
| # | 실험 | 답하는 실무 질문 | 예측 |
|---|---|---|---|
| **A-0** | ✅ 세션 복제 확인 | 정상일 때 세션은 어디 있나 | — (완료) |
| **A-1** | TCP 7800 차단 | 노드 간 네트워크가 끊기면? | 세션 공유는 **안 깨짐**. 무효화 전파가 깨질 것 |
| **A-2** | DB 정지 | **DB가 죽으면?** | **즉시 전면 장애** |
| A-2' | DB 강제 종료 | 복구하면 뭘 잃나 | 직전 수백 ms 세션 갱신 소멸 |
| **A-3** | 노드 1대 전원 차단 | **Keycloak 한 대가 죽으면?** | 세션 살아남음 |
| **A-4** | volatile 모드 비교 | 옛 방식(24 이전)은 뭐가 다른가 | A-1이 **정반대로** 치명적이 됨 |
| B-5 | 동시 refresh 경쟁 | 토큰 갱신이 겹치면? | 한쪽이 낙관적 락에서 짐 |
| — | SSO 다중 앱 | **SSO를 붙이면 뭐가 달라지나** | Keycloak 세션 하나가 전 앱을 좌우 |
**A-1이 특히 중요하다.** 통념("클러스터 포트 막으면 세션 깨짐")과
이번 측정("세션은 7800으로 안 다님")이 정면으로 어긋나므로, **둘 중 하나는
틀렸다.** 실험이 판정한다.
---
## 12. 용어 빠른 참조
| 용어 | 한 줄 |
|---|---|
| **세션** | 서버가 "이 사람 로그인했음"을 기억하는 것 |
| **SSO 세션** | Keycloak이 가진 세션. 이게 죽으면 전 앱 재로그인 |
| **access token** | 짧게 사는 JWT. 서명만으로 검증. 취소 불가 |
| **refresh token** | access token을 새로 받는 표. **세션 조회가 필요** |
| **sid** | 세션 식별자. JWT·DB·관리 API에서 **같은 문자열** |
| **Infinispan** | Keycloak **내장** 캐시 (별도 설치 아님) |
| **JGroups** | Infinispan의 노드 간 통신 라이브러리 |
| **jdbc-ping** | DB 테이블로 서로를 찾는 방식 |
| **7800** | 노드 간 통신 포트 |
| **persistent-user-sessions** | 세션을 DB에 저장하는 기능. **KC 26 기본값** |
| **`OFFLINE_USER_SESSION`** | 이름과 달리 **온라인 세션도** 여기 있다 (`offline_flag='0'`) |
| **낙관적 락** | 읽을 때 버전과 같을 때만 쓰기. 충돌은 사후 검출 |
| **kc-lab-1/2** | VM(기계) 이름 |
| **keycloak-0/1** | Keycloak 프로세스(파드) 이름 |
+393
View File
@@ -0,0 +1,393 @@
# 세션 저장소 실험 축 — 계획과 진행
`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. 인증서 갱신 실측
```
**각 실험의 구조·주입 지점·확인 항목은
[`experiment-plan.md`](experiment-plan.md)에 미리 확정해두었다.**
순서 근거는 [`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 멀티노드에서 세션과 토큰이 어디에 저장되고 각
저장소가 죽으면 무엇이 어떻게 실패하는지 재현하고 복구했다. 프록시 체인의
헤더 계약을 측정으로 확정했고, 신뢰 경계의 구멍을 실증하고 막았다.
**말하면 안 된다** — "운영해봤다", "대규모 트래픽을 다뤄봤다"
**그 경계를 정확히 구분해 말하는 것 자체가 이 기록의 목적이다.**
## 관련 문서
| 문서 | 내용 |
|---|---|
| [`experiment-plan.md`](experiment-plan.md) | **실험 20개의 구조도·주입 방법·관측 지점·예측** |
| [`session-lab-prerequisites.md`](session-lab-prerequisites.md) | 이 실험을 이해하기 위한 선수 지식 |
| [`experiment-00-session-replication.md`](experiment-00-session-replication.md) | A-0 결과 — 세션 공유의 실체 |
| [`open-questions-coverage.md`](open-questions-coverage.md) | 공개 열린 질문 4개와의 대조, 순서 근거 |
| [`session-lab-concepts.md`](session-lab-concepts.md) | 등장 개념 전체 (가상화·네트워크·k3s·TLS·패키지) |
| [`session-lab-operations.md`](session-lab-operations.md) | 관측 도구 · 자주 쓰는 명령 · 훈련 · 자원 예산 |
| [`two-hop-proxy-header-contract.md`](two-hop-proxy-header-contract.md) | 첫 실험의 측정·진단·수정 |
| [`deploy/lab/README.md`](../deploy/lab/README.md) | 실험대 구축·복구 절차 |
| [`four-pattern-tradeoff-matrix.md`](four-pattern-tradeoff-matrix.md) | AP1~AP4 비교. "server session" 행이 B층 대상 |
| [`refresh-token-rotation.md`](refresh-token-rotation.md) | 회전 계약 (1홉·단일 노드 가정) |
| [`reverse-proxy-headers.md`](reverse-proxy-headers.md) | 1홉 헤더 계약 원본 |
+816
View File
@@ -0,0 +1,816 @@
# 2홉 프록시 헤더 계약 — 측정·진단·적용
`docs/reverse-proxy-headers.md`의 계약은 **nginx 한 홉**을 가정하고 쓰였다.
실험대와 운영은 모두 **`nginx → Traefik` 두 홉**이므로 그 계약이 그대로
성립하는지 측정했다. **성립하지 않는다.**
---
## 1. 왜 이것부터 재는가
Keycloak과 그 앞의 애플리케이션이 만드는 값 대부분이 **"원래 요청이
무엇이었나"** 에 의존한다.
| 만들어지는 값 | 의존하는 정보 |
|---|---|
| 토큰의 `iss` 클레임 | 외부 스킴 + 호스트 |
| OAuth2 `redirect_uri` | 외부 스킴 + 호스트 + 포트 |
| 세션 쿠키의 `Secure` 속성 | 외부 스킴 |
| brute-force 탐지·감사 로그 | 클라이언트 IP |
그런데 **TLS는 맨 앞 nginx가 끊는다.** 그 뒤로는 평문 HTTP가 흐르므로,
뒤쪽 구성요소는 원래 요청이 HTTPS였다는 사실을 **오직 `X-Forwarded-*`
헤더로만** 알 수 있다. 이 헤더가 중간에서 사라지거나 바뀌면 위 값이 전부
틀어진다.
Keycloak을 올린 뒤에 로그인이 깨지면 **세션 문제인지 프록시 문제인지 구분할
수 없다.** 그래서 Keycloak 없이 이 계약만 먼저 떼어내 측정했다.
---
## 2. 구조 — 누가 어디에 사는가
### 물리적 배치
```
┌─ test-server (호스트 OS · Arch · 베어메탈) ──────────────────┐
│ │
│ [스위치 1] nginx ← 호스트 OS 의 프로세스 │
│ /etc/nginx/sites-available/keycloak-lab │
│ = deploy/lab/host/nginx-keycloak-lab.conf │
│ │
│ ┌─ kc-lab-1 (VM) ─────────────┐ ┌─ kc-lab-2 (VM) ────────┐ │
│ │ svclb 파드 :80 │ │ svclb 파드 :80 │ │
│ │ ↓ │ │ └────────────────┼──┼─┐
│ │ [스위치 2] Traefik 파드 ◀──┼─┼────────────────────────┼──┼─┘
│ │ 클러스터 전체에 하나뿐 │ │ │ │
│ │ ↓ │ │ │ │
│ │ [스위치 3] 앱 파드 │ │ [스위치 3] 앱 파드 │ │
│ └─────────────────────────────┘ └────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```
| # | 무엇 | 사는 곳 | 설정 파일 |
|---|---|---|---|
| 1 | nginx | **호스트 OS의 프로세스** | `deploy/lab/host/nginx-keycloak-lab.conf` |
| 2 | Traefik | **클러스터 안 파드 1개** | `HelmChartConfig` (kube-system) |
| 3 | 앱 | **클러스터 안 파드 N개** | 각 앱의 매니페스트 `env` |
### Traefik은 노드마다 있지 않다
k3s 기본값이 **replica 1**이다. 대신 **svclb**(klipper-lb) DaemonSet이 각
노드의 80/443 hostPort를 열어두고, 받은 트래픽을 **그 하나의 Traefik 파드로**
전달한다.
측정에서 8회 요청의 `x-forwarded-server`가 모두 같은 파드 이름이었던 것이
그 증거다. 이 사실은 **노드 상실 실험에서 변수**가 된다 — Traefik이 있는
노드를 죽이면 다른 노드의 svclb도 보낼 곳을 잃는다.
### "홉"은 노드 수가 아니라 프록시 계층 수다
```
홉 1 홉 2 목적지
호스트 nginx ──▶ Traefik ──▶ 앱 파드
(HTTP 를 봄) (HTTP 를 봄) (HTTP 를 봄)
```
**svclb는 홉으로 세지 않는다.** iptables 수준의 전달이라 HTTP를 아예 보지
않기 때문이다. 다만 SNAT를 하므로 **IP는 바꾼다.**
---
## 3. 측정 장치
`backend``/api/echo`가 **자신에게 실제로 도달한 것**을 그대로 돌려준다.
```
GET https://app1.hyeonworks.com/api/echo
→ { headers, remoteAddr, localAddr, scheme, secure, serverName, serverPort, requestUrl }
```
`scheme` · `secure` · `requestUrl`은 Keycloak이 `iss`와 redirect URL을 만들 때
쓰는 것과 **같은 종류의 값**이다. `localAddr`은 파드 IP이므로 어느 노드가
응답했는지 알려준다.
배포는 `deploy/lab/k8s/echo.yaml`, 실행은
`deploy/lab/scripts/measure-proxy-headers.sh`.
---
## 4. 요청 흐름 — 홉마다 헤더가 어떻게 변하는가
```
┌─ 1. 브라우저 ────────────────────────────────────────────────┐
│ GET /api/echo │
│ Host: app1.hyeonworks.com │
│ 전 구간 TLS 로 암호화 │
└──────────────────────────┬───────────────────────────────────┘
│ tailnet → 100.83.212.4:443
┌─ 2. 호스트 nginx ────────▼───────────────────────────────────┐
│ ★ TLS 종료 — 이 지점부터 평문 HTTP │
│ │
│ 원래 요청 정보를 헤더로 바꿔 붙인다: │
│ Host app1.hyeonworks.com │
│ X-Forwarded-Host app1.hyeonworks.com │
│ X-Forwarded-Proto https ← 원래 스킴 │
│ X-Forwarded-Port 443 │
│ X-Forwarded-For <클라이언트 IP> ($remote_addr 로 덮어씀) │
│ X-Real-IP <클라이언트 IP> │
└──────────────────────────┬───────────────────────────────────┘
│ upstream 라운드로빈
│ 192.168.122.11:80 또는 .12:80
┌─ 3. svclb (klipper-lb) ──▼───────────────────────────────────┐
│ 노드의 hostPort 80 에서 받아 iptables 로 전달 │
│ externalTrafficPolicy: Cluster → SNAT 발생 │
│ │
│ ★ 출발지 IP 가 노드의 flannel 게이트웨이로 바뀐다 │
│ → 클라이언트 IP 1차 소실 │
└──────────────────────────┬───────────────────────────────────┘
│ 10.42.0.8 (Traefik 파드)
┌─ 4. Traefik ─────────────▼───────────────────────────────────┐
│ Ingress 규칙 매칭: host app1.hyeonworks.com, path /api │
│ │
│ ★ forwardedHeaders.trustedIPs 미설정 │
│ → 들어온 X-Forwarded-* 를 신뢰하지 않고 │
│ 자기가 받은 연결을 기준으로 다시 쓴다 │
│ │
│ X-Forwarded-Proto https → http 자기가 받은 게 평문이므로 │
│ X-Forwarded-Port 443 → 80 │
│ X-Forwarded-For 실IP → 10.42.1.0 ← 2차 소실 │
│ X-Real-IP 실IP → 10.42.1.0 │
│ X-Forwarded-Host 유지 │
│ X-Forwarded-Server traefik-... 자기 이름 추가 │
└──────────────────────────┬───────────────────────────────────┘
│ Service → 파드
┌─ 5. 애플리케이션 ────────▼───────────────────────────────────┐
│ Spring: forward-headers-strategy = none │
│ → forwarded 헤더를 해석하지 않고 TCP 연결 그대로 보고 │
│ │
│ scheme http │
│ secure false │
│ requestUrl http://app1.hyeonworks.com/api/echo │
└──────────────────────────────────────────────────────────────┘
```
---
## 5. 측정 결과
| 헤더 | nginx 가 보낸 값 | 앱에 도달한 값 | |
|---|---|---|---|
| `X-Forwarded-Proto` | `https` | **`http`** | 덮어써짐 |
| `X-Forwarded-Port` | `443` | **`80`** | 덮어써짐 |
| `X-Forwarded-For` | 클라이언트 IP | **`10.42.1.0`** | 소실 |
| `X-Real-IP` | 클라이언트 IP | **`10.42.1.0`** | 소실 |
| `X-Forwarded-Host` | `app1.hyeonworks.com` | `app1.hyeonworks.com` | 유지 |
앱이 최종적으로 보는 값:
```
scheme http
secure false
requestUrl http://app1.hyeonworks.com/api/echo
```
**위조 테스트** — 클라이언트가 `X-Forwarded-For: 1.2.3.4`,
`X-Forwarded-Host: evil.example.com`을 직접 붙여 요청했으나 **앱에 흔적이
도달하지 않았다.** 다만 이는 nginx가 막아서가 아니라 **Traefik이 전부
덮어썼기 때문**이다. 신뢰 경계는 결과적으로 작동하지만, 그 대가로 정당한
값까지 함께 버려진다.
**파드 분배** — 8회 요청이 두 파드(`10.42.0.9`, `10.42.1.3`)에 정확히 번갈아
도달했다. nginx upstream 라운드로빈과 Service 분배가 모두 작동한다.
---
## 5-1. 대조 실험 — 원인이 둘임을 분리한다
측정값만으로는 "누가 값을 바꿨는지" 알 수 없다. nginx를 우회해 Traefik에
직접 요청하여 원인을 분리했다.
```bash
# test-server 에서, nginx 를 거치지 않고 노드의 Traefik 에 직접
curl -s http://192.168.122.11/api/echo \
-H 'Host: app1.hyeonworks.com' \
-H 'X-Forwarded-Proto: https' \
-H 'X-Forwarded-Port: 443' \
-H 'X-Forwarded-For: 203.0.113.7'
```
| | 보낸 값 | 도달한 값 |
|---|---|---|
| `X-Forwarded-Proto` | `https` | **`http`** |
| `X-Forwarded-Port` | `443` | **`80`** |
| `X-Forwarded-For` | `203.0.113.7` | **`10.42.0.1`** |
**올바른 헤더를 명시했는데도 전부 재작성됐다.** Traefik의 덮어쓰기가
독립적으로 증명된다.
그리고 이 과정에서 **두 번째 원인**이 드러났다.
### 원인 A — nginx가 애초에 틀린 값을 보내고 있다
`/etc/nginx/sites-available/keycloak-lab`의 443 서버 블록:
```nginx
listen 443 ssl default_server;
...
proxy_set_header X-Forwarded-Proto http; # ← https 여야 한다
proxy_set_header X-Forwarded-Port 80; # ← 443 이어야 한다
```
**TLS를 종료하는 서버가 "원래 요청은 평문이었다"고 알리고 있다.**
HTTP 전용으로 먼저 세운 뒤 TLS를 얹는 과정에서 이 두 줄을 함께 바꾸지 않아
남은 값이다.
문법 오류가 아니므로 `nginx -t`도 통과하고 **아무 경고 없이 잘못된 값이
전파된다.** 이런 종류의 실수는 측정 없이는 드러나지 않는다.
### 원인 B — Traefik이 올바른 값이 와도 덮어쓴다
위 대조 실험이 보여준 것이다. `forwardedHeaders.trustedIPs` 미설정.
**두 원인은 직렬로 걸려 있다. A만 고쳐도 B 때문에 해결되지 않는다.**
> 증거 원자료: `docs/evidence/two-hop-proxy-headers/`
---
## 6. 원인 — 독립된 스위치 세 개
이 사슬에는 **각각 따로 켜야 하는 스위치가 세 개** 있다.
**하나만 꺼져 있어도 정보가 끊긴다.**
| # | 위치 | 스위치 | 현재 | 하는 일 | 꺼져 있으면 |
|---|---|---|---|---|---|
| 1 | nginx | `proxy_set_header X-Forwarded-*` | **켜짐** | 헤더를 **만든다** | 헤더가 존재하지 않음 |
| 2 | Traefik | `forwardedHeaders.trustedIPs` | **꺼짐** | 받은 헤더를 **전달할지 버릴지** | **버리고 자기 값으로 재작성** |
| 3 | 앱 | `forward-headers-strategy` 등 | **꺼짐** | 도착한 헤더를 **읽어서 반영할지** | 헤더가 와 있어도 무시 |
지금은 2번에서 끊긴다. 2번을 고쳐도 3번을 켜지 않으면 앱은 여전히 원래
스킴을 모른다.
### 3번을 구체적으로
헤더는 **이미 앱에 도착해 있다.** `/api/echo` 출력에 `x-forwarded-proto: http`
찍혔다. 도착은 했다.
그런데 앱이 `request.getScheme()`을 부르면 `http`가 나온다.
**헤더를 읽지 않고 TCP 연결 자체를 보기 때문**이다.
`forward-headers-strategy=native`를 켜면 Tomcat이 헤더를 읽어서
**요청 객체의 scheme·host·port·remoteAddr를 갈아끼운다.**
즉 3번은 **"도착한 헤더를 진짜로 믿고 내 요청 정보를 바꿔칠까"** 의 스위치다.
### 3번은 앱마다 하나씩이다
1번과 2번은 한 번 켜면 끝이지만 **3번은 새 앱을 올릴 때마다 따로 켜야 한다.**
```
[1] nginx 1개 고정
[2] Traefik 1개 고정
[3] 앱 N개 Keycloak · BFF · oauth2-proxy · backend API …
```
그리고 **빠뜨려도 오류가 나지 않고 조용히 틀린 값으로 동작**한다.
이것이 이 계약을 문서로 고정해두어야 하는 이유다.
### 기본값이 "믿지 않음"인 것은 의도된 설계다
`X-Forwarded-*`는 **누구나 위조할 수 있는 평범한 HTTP 헤더**다.
"누구로부터 온 것을 믿을지"를 명시하지 않으면 **신뢰하지 않는 쪽이 안전**하다.
프레임워크들이 하나같이 기본값을 꺼두는 이유다.
### 네 번째 요인 — `externalTrafficPolicy: Cluster`
svclb가 트래픽을 SNAT하면서 클라이언트 IP가 Traefik에 도달하기 전에 이미
사라진다. 2번을 고치면 `X-Forwarded-For`에 담긴 nginx의 값은 살아나지만,
**TCP 출발지 주소 자체는 복원되지 않는다.**
---
## 7. 앱 스위치를 켜는 방법
### Spring Boot
```yaml
server:
forward-headers-strategy: native # none | native | framework
```
환경변수는 `SERVER_FORWARD_HEADERS_STRATEGY=native`.
| 값 | 구현 | 신뢰 IP 제한 |
|---|---|---|
| `none` (기본) | 무시 | — |
| **`native`** | 서블릿 컨테이너 기능 (Tomcat `RemoteIpValve`) | **있음** |
| `framework` | Spring `ForwardedHeaderFilter` | **없음 — 무조건 신뢰** |
**`native`를 권하는 이유가 마지막 열이다.** Tomcat의 `RemoteIpValve`
`internalProxies` 기본 정규식(`10.x`, `192.168.x`, `172.16~31.x`, `127.x`)에
해당하는 **출발지에서 온 요청만** 헤더를 반영한다. 파드 IP가 `10.42.x`
기본값에 들어간다.
`framework`는 그런 필터가 없어 **누가 보내든 믿는다.**
**켜면 실제로 무슨 일이 일어나나** — 밸브가 요청 객체를 갈아끼운다.
```
X-Forwarded-For → request.getRemoteAddr()
X-Forwarded-Proto → request.getScheme(), isSecure()
X-Forwarded-Port → request.getServerPort()
X-Forwarded-Host → request.getServerName()
→ 그 결과 getRequestURL() 이 외부 URL 로 재구성됨
```
**애플리케이션 코드는 한 줄도 고치지 않는다.** 프레임워크가 요청 정보를
바꿔서 넘겨준다.
### Keycloak
```
KC_PROXY_HEADERS=xforwarded # xforwarded | forwarded
```
| 값 | 읽는 헤더 |
|---|---|
| `xforwarded` | `X-Forwarded-For`, `-Proto`, `-Host`, `-Port` (관례) |
| `forwarded` | RFC 7239의 `Forwarded:` 단일 헤더 |
| 미설정 | 무시 |
**Keycloak은 방어가 두 겹이다.**
| 설정 | 담당 |
|---|---|
| `KC_HOSTNAME=https://auth...` | 스킴·호스트를 **고정** — 헤더와 무관 |
| `KC_PROXY_HEADERS=xforwarded` | **클라이언트 IP** 등 나머지를 헤더에서 |
그래서 `iss``KC_HOSTNAME`만으로도 살아난다. 하지만 brute-force 탐지와
감사 로그의 IP는 `KC_PROXY_HEADERS`가 있어야 맞는다.
> 예전 `KC_PROXY=edge` 옵션은 Keycloak 24에서 deprecated 되고
> `KC_PROXY_HEADERS`로 대체됐다. 오래된 예제 참고 시 주의.
### oauth2-proxy
```
--reverse-proxy=true # 또는 OAUTH2_PROXY_REVERSE_PROXY=true
```
신뢰 IP 제한 기능이 없어 **무조건 신뢰**한다.
### 뒤쪽에 nginx가 있는 경우
```nginx
set_real_ip_from 10.42.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
```
신뢰 IP 지정이 **필수**다. `set_real_ip_from` 없이는 동작하지 않는다.
### 공통 원리
어느 프레임워크든 결국 **두 가지를 정하는 일**이다.
1. **어떤 헤더 형식을 읽을지**`X-Forwarded-*` vs RFC 7239 `Forwarded`
2. **누구로부터 온 것을 믿을지** — 신뢰 프록시 IP 목록
두 번째가 있는 구현이 안전하다. Spring `native`와 nginx `real_ip`는 있고,
Spring `framework`와 oauth2-proxy는 없다.
### 켤 때 반드시 같이 봐야 하는 것
**앱에 프록시를 거치지 않고 직접 도달할 경로가 있으면 안 된다.**
헤더 신뢰를 켠 상태에서 공격자가 앱에 직접 요청하며
`X-Forwarded-Proto: https`를 붙이면, 앱은 그걸 믿고 **`Secure` 쿠키를
발급하거나 IP 기반 제한을 우회**당한다.
쿠버네티스에서는 Service ClusterIP로 파드에 직접 접근할 수 있으므로,
**NetworkPolicy로 Traefik에서 오는 트래픽만 허용**하는 것이 정석이다.
이 클러스터는 kube-router 내장 컨트롤러가 있어 적용 가능하다.
**"헤더를 믿는다"는 결정과 "그 앞에 반드시 프록시가 있다"는 보장은 한 쌍이다.**
한쪽만 하면 구멍이 된다.
---
## 8. 영향 — 패턴별
| 패턴 | 의존도 | 무엇이 깨지나 |
|---|---|---|
| AP1 SPA direct | 낮음 | 브라우저가 Keycloak에 직접 감. Keycloak 자체만 필요 |
| AP2 token mediator | 중간 | mediator의 redirect URI |
| **AP3 BFF** | **높음** | **로그인 자체가 실패**, 세션 쿠키 `Secure` 유실 |
| **AP4 edge auth** | **결정적** | **인증 결과가 헤더로 전달됨** |
### AP3(BFF)에서 왜 중요한가
**흔한 오해 — "BFF가 넘기는 헤더는 세션 쿠키뿐인데?"**
쿠키를 **전달하는 것**은 문제가 아니다. 프록시는 `Cookie:` 헤더를 그대로
통과시킨다. 문제는 **쿠키와 URL을 만들 때**이고, 그 재료가 `X-Forwarded-*`다.
**(1) OAuth2 `redirect_uri` 생성 — 가장 먼저 터진다**
BFF는 Spring Security의 `oauth2Login`을 쓴다. Keycloak으로 사용자를 보낼 때
`redirect_uri` 파라미터를 **현재 요청 URL로부터** 만든다.
```
redirect_uri = {scheme}://{serverName}:{serverPort}/login/oauth2/code/keycloak
↑ request.getScheme() 에서 온다
```
`scheme=http`면 이렇게 나간다.
```
redirect_uri=http://app1.hyeonworks.com/login/oauth2/code/keycloak
```
그러면 두 가지 중 하나가 벌어진다.
- Keycloak 클라이언트에 `https://...`만 등록돼 있으면
**`invalid_redirect_uri` 오류로 로그인 거부**
- 실수로 `http://...`도 등록해뒀다면
→ 브라우저가 https 페이지에서 http로 리다이렉트 →
**혼합 콘텐츠 차단 또는 세션 쿠키 유실**
**세션 쿠키 문제보다 먼저, 로그인 자체가 안 된다.**
**(2) 세션 쿠키의 `Secure` 속성**
서블릿 컨테이너는 `request.isSecure()`를 보고 `Set-Cookie``Secure`
붙일지 정한다. `isSecure()``false`**`Secure` 없는 세션 쿠키**가 나간다.
| 결과 | 내용 |
|---|---|
| 평문 전송 위험 | 그 쿠키는 http 요청에도 실려 나간다. 중간자가 세션을 탈취할 수 있다 |
| **`SameSite=None` 사용 불가** | 브라우저는 `Secure` 없는 `SameSite=None` 쿠키를 **거부**한다 |
두 번째가 AP3의 학습 주제와 정면으로 부딪힌다. AP3는 `oauth2Login` 세션과
**CSRF·SameSite 방어**가 핵심인데, `Secure`가 없으면 `SameSite` 설계
선택지가 통째로 사라진다.
**(3) 로그아웃 `post_logout_redirect_uri`**
같은 원리로 http가 박히고, Keycloak에 등록된 값과 불일치해 거부된다.
**정리하면** — BFF에서 헤더 계약이 중요한 이유는 쿠키를 *전달*하기 때문이
아니라, **쿠키와 OAuth2 URL을 *생성*하는 재료이기 때문**이다.
### AP4(edge auth)에서 왜 결정적인가
AP4는 **헤더 신뢰가 패턴의 존재 이유 자체**다.
oauth2-proxy는 인증을 끝내고 **결과를 헤더로 downstream에 넘긴다.**
```
X-Auth-Request-User
X-Auth-Request-Email
X-Auth-Request-Groups
X-Auth-Request-Access-Token
```
downstream 앱은 **이 헤더를 믿고 "누가 로그인했는지"를 판단**한다. 토큰을
직접 검증하지 않는다. 그것이 AP4가 "기존 upstream을 수정하지 않고 경계에서
일괄 인증"할 수 있는 이유다.
**그래서 여기서 헤더 신뢰가 무너지면 인증 우회가 된다.**
```
공격자가 직접: X-Auth-Request-User: admin
프록시가 안 덮어쓰면 → downstream 은 admin 으로 인식
```
지금 측정한 `X-Forwarded-*` 문제와 **구조가 완전히 같다.** 헤더 이름과
의미만 다르다.
| 헤더군 | 담는 정보 | 위조되면 |
|---|---|---|
| `X-Forwarded-*` | 원래 요청이 어땠나 | 쿠키 속성·URL이 틀어짐 |
| `X-Auth-Request-*` | **누가 인증됐나** | **신원 위조 = 인증 우회** |
저장소의 `feature/keycloak-header-spoofing-defense` 브랜치
(manifest: `ap4 / locally-verified`)가 이 문제를 다룬다.
**지금 확정하는 2홉 계약이 그 브랜치의 전제**다 — 1홉 가정으로 검증된
방어가 2홉에서도 유효한지 다시 확인해야 한다.
---
## 9. 해결 — 어느 파일의 무엇을 어떻게 고치는가
세 곳을 순서대로 고쳤다. **각 단계마다 측정하여 어느 스위치가 무엇을
담당하는지 데이터로 확인했다.**
### A. nginx — 원래 스킴을 사실대로 알린다
| | |
|---|---|
| 저장소 파일 | `deploy/lab/host/nginx-keycloak-lab.conf` |
| 서버 배포 위치 | `/etc/nginx/sites-available/keycloak-lab` |
| 활성화 | `/etc/nginx/sites-enabled/keycloak-lab` 심볼릭 링크 |
`server { listen 443 ssl ... }` 블록의 `location /` 안에서 두 줄을 고친다.
```diff
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
- proxy_set_header X-Forwarded-Proto http;
- proxy_set_header X-Forwarded-Port 80;
+ proxy_set_header X-Forwarded-Proto https;
+ proxy_set_header X-Forwarded-Port 443;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
```
**`listen 443 ssl``proxy_pass http://`는 건드리지 않는다.**
앞에서 HTTPS로 받고 뒤로 평문으로 보내는 것은 의도된 설계다.
고치는 것은 **뒤로 보낼 때 붙이는 라벨**뿐이다.
배포:
```bash
cd ~/workspace/keycloak-pattern && git pull
sudo cp deploy/lab/host/nginx-keycloak-lab.conf /etc/nginx/sites-available/keycloak-lab
sudo nginx -t && sudo systemctl reload nginx
grep -n 'X-Forwarded-Proto\|X-Forwarded-Port' /etc/nginx/sites-available/keycloak-lab
```
**손으로 서버 파일을 고치지 않는다.** 저장소에서 단방향으로 복사한다.
이 실수가 발생한 원인 자체가 저장소와 서버의 드리프트였다.
### B. Traefik — nginx의 헤더를 신뢰한다
| | |
|---|---|
| 저장소 파일 | `deploy/lab/k8s/traefik-forwarded-headers.yaml` (신규) |
| 적용 대상 | `kube-system` 네임스페이스의 Traefik HelmChart |
k3s의 Traefik은 번들 HelmChart로 설치되므로 **Deployment를 직접 고치면
안 된다.** helm-controller가 되돌린다. `HelmChartConfig`로 차트 값을
덮어써야 한다.
```yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
ports:
web:
forwardedHeaders:
trustedIPs:
- 10.42.0.0/16 # 파드 대역 (svclb SNAT 출발지)
- 192.168.122.0/24 # 노드·호스트 대역
websecure:
forwardedHeaders:
trustedIPs:
- 10.42.0.0/16
- 192.168.122.0/24
```
**`10.42.0.0/16`이 필요한 이유** — traefik Service가
`externalTrafficPolicy: Cluster`이므로 svclb가 SNAT한다. Traefik이 보는
출발지는 호스트 nginx의 주소가 아니라 **파드 네트워크 주소**다.
적용:
```bash
kubectl apply -f deploy/lab/k8s/traefik-forwarded-headers.yaml
kubectl -n kube-system rollout status deploy/traefik --timeout=180s
```
**함정 — `rollout status` 완료가 곧 반영은 아니다.** helm-controller가
`helm-install-traefik` **Job을 새로 돌려** 차트를 업그레이드하므로, 그 사이
**구 파드가 잠시 함께 살아 있다.** 이 시점에 측정하면 옛 파드가 응답해
"고쳤는데 안 바뀌었다"고 오해하게 된다. 실제로 이 함정에 한 번 걸렸다.
파드 이름과 인자로 확인한다.
```bash
kubectl -n kube-system get pods -l app.kubernetes.io/name=traefik
kubectl -n kube-system get pod -l app.kubernetes.io/name=traefik \
-o jsonpath='{.items[0].spec.containers[0].args}' | tr ',' '\n' | grep -i forwarded
# --entryPoints.web.forwardedHeaders.trustedIPs=10.42.0.0/16,192.168.122.0/24
```
**트레이드오프** — 파드 대역 전체를 신뢰하면 **클러스터 안의 어떤 파드든
헤더를 위조할 수 있다.** 실험대에서는 받아들일 만하지만 운영에서는 좁혀야
한다. 좁히려면 `externalTrafficPolicy: Local`로 SNAT를 없애고 실제
출발지(`192.168.122.1`)만 신뢰하는 방법이 있으나, 그러면 해당 노드에 Traefik
파드가 없을 때 트래픽이 버려진다.
### C. 앱 — 도착한 헤더를 해석한다
| | |
|---|---|
| 저장소 파일 | `deploy/lab/k8s/echo.yaml` |
| 위치 | `spec.template.spec.containers[0].env` |
```diff
- name: SERVER_FORWARD_HEADERS_STRATEGY
- value: "none"
+ value: "native"
```
```bash
kubectl apply -f deploy/lab/k8s/echo.yaml
kubectl -n header-lab rollout status deployment/echo --timeout=180s
```
앱마다 스위치 이름이 다르다.
| 앱 | 설정 | 넣는 곳 |
|---|---|---|
| Spring Boot | `SERVER_FORWARD_HEADERS_STRATEGY=native` | 컨테이너 `env` |
| **Keycloak** | **`KC_PROXY_HEADERS=xforwarded`** | 컨테이너 `env` |
| oauth2-proxy | `--reverse-proxy=true` | 컨테이너 `args` |
**새 앱을 올릴 때마다 반복해야 한다.** 빠뜨려도 오류가 나지 않는다.
---
## 10. 단계별 측정 결과
각 스위치를 하나씩 켜면서 측정한 값이다.
**어느 스위치가 무엇을 담당하는지가 그대로 드러난다.**
| 측정 항목 | 최초 | A 이후 | B 이후 | **C 이후** |
|---|---|---|---|---|
| nginx 가 보내는 값 | `http`/`80` | `https`/`443` | `https`/`443` | `https`/`443` |
| `x-forwarded-proto` | `http` | **`http`** | `https` | `https` |
| `x-forwarded-port` | `80` | **`80`** | `443` | `443` |
| `x-real-ip` | `10.42.1.0` | `10.42.1.0` | `100.123.124.30` | `100.123.124.30` |
| `scheme` (앱 해석) | `http` | `http` | **`http`** | **`https`** |
| `secure` | `false` | `false` | **`false`** | **`true`** |
| `requestUrl` | `http://…` | `http://…` | `http://…` | **`https://…`** |
**A 이후에 아무것도 바뀌지 않은 것**이 Traefik의 덮어쓰기를 증명한다.
nginx가 올바른 값을 보내는데도 앱에는 `http`가 도달했다.
**B 이후에 헤더는 살아났지만 앱 해석은 그대로**인 것이 2번과 3번 스위치가
서로 다른 일을 한다는 증거다. 헤더는 도착해 있었지만 앱이 읽지 않았다.
**C 이후에야 앱이 원래 요청을 인식한다.**
최종 상태:
```
x-forwarded-proto https
x-forwarded-port 443
x-real-ip 100.123.124.30 ← 실제 클라이언트(워크스테이션 tailnet IP)
scheme https
secure True
serverPort 443
remoteAddr 100.123.124.30
requestUrl https://app1.hyeonworks.com/api/echo
```
---
## 11. 검증
### 정상 동작
```bash
curl -s https://app1.hyeonworks.com/api/echo | python3 -m json.tool
```
| 항목 | 기대값 |
|---|---|
| `x-forwarded-proto` | `https` |
| `x-forwarded-port` | `443` |
| `x-real-ip` | 실제 클라이언트 IP |
| `scheme` | `https` |
| `secure` | `true` |
| `requestUrl` | `https://app1.hyeonworks.com/api/echo` |
### 위조 차단 — 이쪽이 더 중요하다
헤더 신뢰를 켠 뒤에는 **위조가 여전히 막히는지 반드시 확인해야 한다.**
```bash
curl -s https://app1.hyeonworks.com/api/echo \
-H 'X-Forwarded-Proto: http' \
-H 'X-Forwarded-Host: evil.example.com' \
-H 'X-Forwarded-For: 1.2.3.4' \
-H 'X-Real-IP: 1.2.3.4' | python3 -m json.tool
```
**주입한 값이 하나도 반영되지 않아야 한다.** 실측 결과 `scheme=https`,
`serverName=app1.hyeonworks.com`, `remoteAddr=100.123.124.30`이 유지됐다.
방어의 주체가 바뀌었다는 점에 유의한다.
| | 수정 전 | 수정 후 |
|---|---|---|
| 위조를 막는 주체 | **Traefik** (전부 덮어씀) | **nginx** (`$remote_addr`로 덮어씀) |
| 대가 | 정당한 값도 함께 버려짐 | 없음 |
**따라서 nginx의 `$remote_addr` 사용은 선택이 아니라 필수다.**
`$proxy_add_x_forwarded_for`(덧붙이기)로 바꾸면 클라이언트가 넣은 값이
사슬 앞부분에 남아 신뢰 경계가 무너진다.
### 프록시 우회 경로 차단
앱이 헤더를 신뢰하게 되면 **Traefik을 거치지 않고 파드에 직접 도달할 수
있는 경로가 곧 구멍**이 된다. 클러스터 안에서는 Service ClusterIP로 접근할
수 있으므로 실제로 위조가 성립했다.
```bash
kubectl -n header-lab run t --rm -i --restart=Never --image=curlimages/curl -- \
curl -s http://echo:8081/api/echo \
-H 'X-Forwarded-Proto: https' -H 'X-Forwarded-Host: evil.example.com' -H 'X-Forwarded-For: 1.2.3.4'
```
```
serverName evil.example.com ← 위조 성공
remoteAddr 1.2.3.4 ← 위조 성공
requestUrl https://evil.example.com/api/echo
```
**두 신뢰 설정이 모두 "대역"을 믿기 때문**이다.
| 계층 | 신뢰 범위 | 지정 방식 |
|---|---|---|
| Traefik `trustedIPs` | 파드 대역 전체 | IP 대역 |
| 앱 Tomcat `internalProxies` | 사설 대역 전체 (기본 정규식) | IP 정규식 |
IP로는 Traefik을 특정할 수 없다. **파드 IP가 재시작마다 바뀌기 때문**이다
(측정 중 실제로 `10.42.0.8``10.42.1.12`로, 노드까지 옮겨갔다).
**해결 — NetworkPolicy는 IP가 아니라 라벨로 지정한다.**
| | |
|---|---|
| 저장소 파일 | `deploy/lab/k8s/echo-network-policy.yaml` |
```yaml
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
app.kubernetes.io/name: traefik # ← IP 가 아니라 라벨
ports:
- protocol: TCP
port: 8081
```
`namespaceSelector``podSelector`를 **같은 리스트 항목**에 두면 AND로
결합된다. 별개 항목으로 나누면 OR이 되어 kube-system 전체가 허용되므로
주의한다.
**kubelet probe를 위한 규칙이 별도로 필요하다.** readiness/liveness는 파드가
아니라 노드에서 오므로 위 규칙에 걸리지 않는다. 빠뜨리면 probe가 실패하고
**파드가 재시작 루프에 빠진다.**
```yaml
- from:
- ipBlock: { cidr: 10.42.0.1/32 } # kc-lab-1 의 cni0
- ipBlock: { cidr: 10.42.1.1/32 } # kc-lab-2 의 cni0
```
probe의 출발지는 **노드의 flannel 브리지(cni0)** 이고, 각 노드 `/24`의 첫
주소다. `/32`로 정확히 지정해야 한다 — `10.42.0.0/16`으로 넓히면 임의의
파드가 다시 들어와 정책이 무의미해진다.
**적용 후 확인**
```
정상 경로 scheme=https, remoteAddr=100.123.124.30 계속 동작
우회 시도 HTTP 000 / curl exit 7 연결 자체가 거부됨
파드 상태 1/1 Running, restarts=0 probe 정상
```
**"헤더를 믿는다"와 "앞에 반드시 프록시가 있다"는 한 쌍이다.**
앞의 것만 하면 이 구멍이 남는다.
## 12. 증거
`docs/evidence/two-hop-proxy-headers/`
| 파일 | 내용 |
|---|---|
| `01-environment.txt` | 수정 전 세 계층 설정 |
| `02-measurements.txt` | 수정 전 측정 + 대조 실험 |
| `03-browser-https-vs-app-http.png` | 브라우저와 앱의 인식 차이 |
| `stage-a-nginx-fixed.png` | A 이후 — 여전히 `http` |
| `stage-b-traefik-trusts.png` | B 이후 — 헤더는 살아났으나 앱 해석은 `http` |
| `stage-c-resolved.png` | C 이후 — 전 구간 `https` |
| `04-after-fix.txt` | 최종 측정 + 위조 테스트 + 분배 |
---
## 11. 참고
- 1홉 계약 원본: `docs/reverse-proxy-headers.md`
- 개념 상세: `docs/session-lab-concepts.md`
- 패턴 비교: `docs/four-pattern-tradeoff-matrix.md`
- 측정 배포: `deploy/lab/k8s/echo.yaml`
- 측정 실행: `deploy/lab/scripts/measure-proxy-headers.sh`
-12
View File
@@ -1,12 +0,0 @@
{
"name": "keycloak-pattern-e2e",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"test:pattern4": "node pattern4.mjs"
},
"devDependencies": {
"playwright-core": "1.62.0"
}
}
-138
View File
@@ -1,138 +0,0 @@
import assert from "node:assert/strict";
import { chromium } from "playwright-core";
const password = process.env.E2E_PASSWORD;
assert.ok(password, "E2E_PASSWORD must be set");
const edgeBaseUrl = "http://localhost:8088";
const edgeEntryUrl = `${edgeBaseUrl}/`;
async function completeKeycloakLogin(page) {
for (let attempt = 1; attempt <= 2; attempt += 1) {
await page.locator("#username").fill(
process.env.E2E_USERNAME ?? "regular-user",
);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
await page.waitForLoadState("domcontentloaded");
if (page.url() === edgeEntryUrl) {
return;
}
if (attempt === 1) {
await page.goto(`${edgeBaseUrl}/oauth2/start?rd=${encodeURIComponent(edgeEntryUrl)}`);
await page.waitForURL(/localhost:8080/u);
}
}
throw new Error(`Keycloak login did not return to AP4: ${page.url()}`);
}
const browser = await chromium.launch({
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
headless: true,
args: ["--no-sandbox"],
});
try {
const context = await browser.newContext();
const page = await context.newPage();
const browserRequests = [];
page.on("request", (request) =>
browserRequests.push({ method: request.method(), url: request.url() }),
);
const edgeResponsePromise = page.waitForResponse(
(response) =>
response.url() === edgeEntryUrl &&
response.status() === 302,
);
const authorizationRequestPromise = page.waitForRequest((request) =>
request.url().includes(
"/protocol/openid-connect/auth?approval_prompt=",
),
);
await page.goto(edgeEntryUrl);
const unauthenticatedEdgeResponse = await edgeResponsePromise;
assert.equal(unauthenticatedEdgeResponse.status(), 302);
const authorizationRequest = await authorizationRequestPromise;
const authorizationUrl = new URL(authorizationRequest.url());
assert.equal(authorizationUrl.searchParams.get("client_id"), "edge-proxy");
assert.equal(authorizationUrl.searchParams.get("code_challenge_method"), "S256");
assert.ok(authorizationUrl.searchParams.get("code_challenge"));
await page.waitForURL(/localhost:8080/u);
await completeKeycloakLogin(page);
const edgeIdentity = JSON.parse(await page.locator("body").innerText());
assert.equal(edgeIdentity.pattern, "AP4-edge-forward-auth");
assert.ok(edgeIdentity.user);
assert.equal(edgeIdentity.identityHeader, "X-Auth-Request-User");
const callbackRequest = browserRequests.find(({ url }) =>
url.startsWith(`${edgeBaseUrl}/oauth2/callback?`),
);
assert.ok(callbackRequest);
assert.equal(callbackRequest.method, "GET");
assert.equal(
browserRequests.some(({ url }) =>
url.includes("/protocol/openid-connect/token"),
),
false,
"the confidential token exchange must be server-to-server",
);
const cookies = await context.cookies(edgeEntryUrl);
const sessionCookie = cookies.find((cookie) => cookie.name === "AP4_SESSION");
assert.ok(sessionCookie);
assert.equal(sessionCookie.httpOnly, true);
assert.equal(sessionCookie.sameSite, "Lax");
assert.equal(sessionCookie.secure, false);
const storage = await page.evaluate(() => ({
localStorage: Object.values(localStorage),
sessionStorage: Object.values(sessionStorage),
readableCookies: document.cookie,
}));
assert.deepEqual(storage.localStorage, []);
assert.deepEqual(storage.sessionStorage, []);
assert.equal(storage.readableCookies.includes("AP4_SESSION"), false);
const spoofAttempt = await page.evaluate(async () => {
const response = await fetch("/", {
headers: {
"X-Auth-Request-User": "spoofed-admin",
"X-Auth-Request-Email": "spoofed-admin@example.test",
"X-Internal-Auth-Token": "attacker-controlled-token",
},
});
return { status: response.status, body: await response.json() };
});
assert.equal(spoofAttempt.status, 200);
assert.equal(spoofAttempt.body.user, edgeIdentity.user);
assert.notEqual(spoofAttempt.body.user, "spoofed-admin");
const externalAuthSubrequest = await fetch(`${edgeBaseUrl}/oauth2/auth`);
assert.equal(externalAuthSubrequest.status, 404);
const apiResponse = await fetch(`${edgeBaseUrl}/api/edge`, {
redirect: "manual",
});
assert.equal(apiResponse.status, 401);
assert.equal(apiResponse.headers.get("location"), null);
await assert.rejects(
fetch("http://localhost:4180/ping"),
"oauth2-proxy must not be published on the host",
);
await assert.rejects(
fetch("http://localhost:8081/edge/me"),
"backend must not be published on the host",
);
console.log(
"pattern4 hardened edge verified: auth_request, no backend publish, spoofed headers overwritten",
);
} finally {
await browser.close();
}
+1 -1
View File
@@ -1,4 +1,4 @@
FROM nginx:1.29-alpine
COPY default.conf.template /etc/nginx/templates/default.conf.template
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/index.html
-75
View File
@@ -1,75 +0,0 @@
server {
listen 80;
server_name _;
large_client_header_buffers 4 16k;
location = /health {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location = /oauth2/auth {
internal;
proxy_pass http://oauth2-proxy:4180;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Uri $request_uri;
}
location /oauth2/ {
proxy_pass http://oauth2-proxy:4180;
proxy_http_version 1.1;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Auth-Request-Redirect $scheme://$http_host$request_uri;
}
location = /api/edge {
auth_request /oauth2/auth;
error_page 401 = @api_unauthorized;
auth_request_set $auth_user $upstream_http_x_auth_request_user;
auth_request_set $auth_email $upstream_http_x_auth_request_email;
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie always;
proxy_pass http://app:8081/edge/me;
proxy_set_header X-Auth-Request-User $auth_user;
proxy_set_header X-Auth-Request-Email $auth_email;
proxy_set_header X-Internal-Auth-Token "${INTERNAL_AUTH_TOKEN}";
}
location / {
auth_request /oauth2/auth;
error_page 401 = @oauth2_signin;
auth_request_set $auth_user $upstream_http_x_auth_request_user;
auth_request_set $auth_email $upstream_http_x_auth_request_email;
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header Set-Cookie $auth_cookie always;
proxy_pass http://app:8081/edge/me;
proxy_set_header X-Auth-Request-User $auth_user;
proxy_set_header X-Auth-Request-Email $auth_email;
proxy_set_header X-Internal-Auth-Token "${INTERNAL_AUTH_TOKEN}";
}
location @oauth2_signin {
return 302 $scheme://$http_host/oauth2/start?rd=$scheme://$http_host$request_uri;
}
location @api_unauthorized {
default_type application/json;
return 401 '{"error":"authentication required"}';
}
}

Some files were not shown because too many files have changed in this diff Show More