#!/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)"