Files
keycloak-pattern/docs/experiment-d4-certificate-renewal.md
T
DongHyeonkaandClaude Opus 5 716e62524a docs(d4): measure the controls before the injection that needs a password
The forced renewal needs sudo on the host, and the host asks for a password.
That blocks the injection, not the experiment — the part worth doing first
was the control anyway.

Control 1 — new connections, 0.2s x 900 over 180s: 900/900 = 200, zero
failures. The noise floor is 0, so a single non-200 during the renewal can
be attributed to the renewal. Without this the observation would have been
uninterpretable, which is the mistake A-6 made calling a -41% control
"no effect" and A-8 made claiming zero-downtime from 9 samples.

Control 2 — the poll cannot answer the question the plan actually asked.
TLS handshakes were 900/900, meaning every request is a fresh connection,
so it measures "are new connections accepted", not "what happens to a
request already in flight". A separate device: the 845KB admin console
bundle pulled at --limit-rate 20k, holding one request open for 42 seconds.
Baseline 200 / 845361 bytes / 1 connection.

Monitors are running under setsid, and stop three minutes after the
certificate serial changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 17:12:31 +09:00

14 KiB
Raw Blame History

D-4 — 인증서 갱신

브랜치 feature/keycloak-d4-certificate-renewal · 증거 docs/evidence/d4-certificate-renewal/ · 2026-09-04 17:2517:35 KST


구조

D-4 구조 — 체인과 SAN 제약

다이어그램 규약은 diagrams/_style.md. 실험대 전체 구조는 diagrams/lab-topology.svg.


0. 결론부터

확인 결과
인증서 구성 SAN 3개 (auth/app1/app2), 와일드카드 아님
체인 완결성 정상. Verify return code: 0 (ok), 4단계
갱신 자동화 동작 중. certbot-renew.timer enabled·active, 11시간 전 실행됨
남은 기간 88일 (갱신까지 약 58일)
강제 갱신 실측 ★ 못 했다. sudo: a password is required

측정한 것과 못 한 것을 나눠 적는다. 못 한 것을 안 한 것처럼 쓰면 이 기록 전체의 신뢰가 깎인다.


1. 인증서 구성 — B-7 에서 실제로 걸린 제약

X509v3 Subject Alternative Name:
    DNS:app1.hyeonworks.com, DNS:app2.hyeonworks.com, DNS:auth.hyeonworks.com

세 이름뿐이고 와일드카드가 아니다.

이 제약이 B-7 에서 실제 비용을 만들었다. oauth2-proxy 를 올릴 호스트명이 없어 Grafana 가 쓰던 app2 를 빌려야 했고, 그 때문에 관측 스택의 웹 UI 가 실험 동안 내려가 있었다.

"인증서에 이름을 몇 개 넣을 것인가" 는 TLS 설정이 아니라 나중에 무엇을 배포할 수 있는가를 정하는 결정이다.

이 실험대 와일드카드였다면
새 호스트 추가 인증서 재발급 필요 바로 가능
DNS-01 검증 필요 필요 (와일드카드는 DNS-01 만 가능)
노출 이름 3개만 하위 전체가 한 키에 묶인다

2. 체인이 완전한가 — 흔한 실수 확인

 0 s:CN = auth.hyeonworks.com                            ← 리프
 1 s:C = US, O = Let's Encrypt, CN = YE2                 ← 중간
 2 s:C = US, O = ISRG, CN = Root YE
 3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
Verify return code: 0 (ok)

중간 인증서가 함께 제공된다.

개념 — fullchain.pem vs cert.pem

certbot 은 두 파일을 만든다.

파일 내용 nginx 에 넣으면
cert.pem 리프만 일부 클라이언트에서 검증 실패
fullchain.pem 리프 + 중간 정상

브라우저는 중간 인증서를 캐시하고 있어 cert.pem 으로도 대개 동작한다. 그래서 실수해도 개발 중에는 안 드러나고, 캐시가 없는 클라이언트 (모바일 앱, curl, 다른 서버)에서만 깨진다.

openssl s_client -connect <host>:443 -servername <host> | grep -E "^ *[0-9] s:"

단계가 2개 이상이면 fullchain 이고, 1개면 cert.pem 을 쓴 것이다. 이 실험대는 4단계로 정상이다.


3. 갱신 자동화는 동작한다

NEXT                         LEFT      LAST                         PASSED
Fri 2026-09-04 17:03:46 KST  1h 54min  Fri 2026-09-04 03:19:39 KST  11h ago
타이머 enabled: enabled / active: active

하루 두 번 돌고, 11시간 전에 실제로 실행됐다.

만료: Dec 2 00:47:22 2026 GMT
남은 일수: 88일

아직 갱신하지 않은 것이 정상이다 — Let's Encrypt 는 90일 발급이고 certbot 은 30일 남았을 때 갱신한다. 지금 실행돼도 아무것도 안 한다.

타이머가 돌았다는 것과 갱신이 됐다는 것은 다르다. "타이머가 active 니까 괜찮다" 는 확인이 아니다. 실제 갱신은 58일 뒤이며, 그때 처음으로 절차가 시험된다.


4. ★ 못 한 것 — 강제 갱신과 무중단 확인

계획서의 D-4 는 이렇게 적혀 있었다.

sudo certbot renew --force-renewal

실행할 수 없었다.

$ sudo -n -l
sudo: a password is required
$ sudo -n systemctl reload nginx
sudo: a password is required

test-server 의 sudo 는 비밀번호를 요구한다. 게스트(kc-lab-1/2)는 무암호라 A층에서 conntrack·tc 를 자유롭게 썼는데, 호스트는 다르다.

이 사실은 B-7 에서 처음 드러났다 — nginx 설정을 읽으려던 시도가 계속 빈 결과였고, 그게 sudo 의 조용한 실패였다. 여기서 다시 확인된다.

아직 답하지 못한 것 — 과 그 앞에서 먼저 한 것

sudo 를 못 쓰니 주입은 못 한다. 하지만 주입 없이도 할 수 있는 일이 하나 남아 있었고, 그게 사실 더 중요하다 — 대조군이다.

갱신 중에 000 이 한 번 나왔다고 하자. 그게 갱신 탓인가? 평시 오류율을 모르면 대답할 수 없다. A-6 에서 41% 를 "영향 없음"이라 적었다가 틀린 것도, A-8 에서 표본 9개로 무중단을 주장한 것도 같은 종류의 실수였다. 그래서 주입보다 대조군을 먼저 잡는다.

대조군 1 — 새 연결을 받아주는가 (0.2초 × 900회 = 180초)

표본 900 개
[상태코드]   900 × 200          ← 오류 0
[응답시간]   최소 67 · 중앙 98 · p95 195 · 최대 1121 ms
[TLS 핸드셰이크]  900회 / 900   ← 매 요청이 새 연결이다

잡음 바닥이 0이다. 이 뜻은 명확하다 — 갱신 중에 비200 이 단 한 번이라도 나오면, 그건 갱신 탓으로 귀속해도 된다. 대조군이 0이 아니었다면 그렇게 말할 수 없었다.

대조군 2 — 그런데 위 장치는 D-4 의 질문에 답하지 못한다

핸드셰이크가 900/900 이라는 것은 매 요청이 새 TCP 연결이라는 뜻이다. 그래서 이 폴링이 재는 것은 "새 연결을 받아주는가" 이지, 계획서가 물은 "갱신 중 진행 중이던 요청은 어떻게 되는가" 가 아니다. reload 순간에 실제로 전송 중인 요청이 있어야 잴 수 있다.

장치를 따로 만들었다 — 845KB 짜리 관리 콘솔 번들을 --limit-rate 20k 로 일부러 느리게 받아, 요청 하나를 42초 동안 살아 있게 만든다.

코드=200  받은바이트=845361  총시간=41.392198s  연결수=1  실효속도=20423B/s
기대 크기 845361 / 실제 845361 bytes

주입 시 이 값과 비교해 판정한다.

관측
코드 200 · 크기 845361 · 연결수 1 진행 중이던 요청이 끝까지 살았다 = graceful
코드 000 또는 크기 부족 reload 가 진행 중이던 연결을 끊었다
연결수 ≥ 2 중간에 끊겨 curl 이 다시 붙었다 (사용자에겐 지연으로 보인다)

남은 항목

계획서의 항목 상태
갱신 중 새 연결이 끊기는가 대조군 확보 (평시 0/900 실패) · 주입 대기
갱신 중 진행 중이던 요청은 장치 확보 (42초 in-flight) · 주입 대기
certbot-renew.timer 가 실제 갱신을 하는가 미측정 (58일 뒤에야 알 수 있다)

주입 명령은 하나뿐이고, 비밀번호가 있는 사람만 실행할 수 있다.

ssh test-server
sudo certbot renew --force-renewal

이론적으로는 무엇을 기대하는가

   certbot renew  →  새 인증서 파일 저장
        └─ deploy-hook: nginx -s reload
             └─ nginx 는 새 워커를 띄우고 옛 워커는 진행 중 요청을 끝낸 뒤 종료
                  → graceful. 진행 중 요청은 옛 인증서로 완결된다

nginx 의 reload 는 설계상 무중단이지만, 확인하지 않았으므로 그렇게 쓰면 안 된다. 이 실험대에서 반복해 배운 것이 바로 그것이다 — A-1 의 NetworkPolicy, A-3 의 --grace-period=0, B-5 의 AOF 모두 "그럴 것이다" 가 틀렸던 사례다.



증거 파일

증거 수집 시각: 2026-09-04 15:09 15:09 KST (파일 mtime 기준. 문서 상단의 시각 표기는 작성 시점이라 다를 수 있다.)

파일 종류
01-certificate-state.txt 터미널 원문

파일별 상세는 evidence/d4-certificate-renewal/README.md.

5. 재현 절차 (명령어)

# ─────────────────────────────────────────────────────────────
# 1. 인증서 내용 — 밖에서 볼 수 있다(sudo 불필요)
# ─────────────────────────────────────────────────────────────
for H in auth app1 app2; do
  echo "-- $H.hyeonworks.com"
  echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
    | openssl x509 -noout -serial -dates -subject -ext subjectAltName
done
#   세 이름의 일련번호가 같으면 → 인증서 1장에 SAN 3개다.
#   B-7 이 네 번째 이름을 못 쓴 이유가 여기 있다.

# ─────────────────────────────────────────────────────────────
# 2. 체인 완결성 — 단계가 1개면 cert.pem 을 쓴 것이다
# ─────────────────────────────────────────────────────────────
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
  | grep -E '^ *[0-9]+ s:|^ *i:|Verify return code'

# ─────────────────────────────────────────────────────────────
# 3. ★ 대조군을 먼저 잡는다 — 이걸 건너뛰면 주입 결과를 해석할 수 없다
# ─────────────────────────────────────────────────────────────
# 3-a. 새 연결 가용성: 0.2초 × 900회 = 180초
i=0
while [ $i -lt 900 ]; do
  curl -s -o /dev/null -w '%{http_code} %{time_total} %{time_appconnect}\n' \
    --max-time 5 https://auth.hyeonworks.com/realms/master
  i=$((i+1)); sleep 0.2
done > /tmp/d4-control.txt
awk '{print $1}' /tmp/d4-control.txt | sort | uniq -c      # 비200 이 몇 개인가

# 3-b. 진행 중 요청: 845KB 를 20k/s 로 받아 42초 동안 살려 둔다
JS=$(curl -s https://auth.hyeonworks.com/admin/master/console/ \
     | grep -oE '/resources/[a-z0-9]+/admin/[^"]+\.js' | head -1)
curl -s --limit-rate 20k -o /tmp/inflight.bin \
  -w '코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}\n' \
  "https://auth.hyeonworks.com$JS"

# ─────────────────────────────────────────────────────────────
# 4. 감시를 켠다 — 일련번호가 바뀌면 3분 더 재고 스스로 멈춘다
# ─────────────────────────────────────────────────────────────
BASE=$(echo | openssl s_client -connect auth.hyeonworks.com:443 \
       -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -serial | cut -d= -f2)
rm -f /tmp/d4-stop

# 감시 0 — 일련번호. 바뀌는 순간이 nginx 가 새 인증서를 집은 순간이다
setsid bash -c '
  BASE='"$BASE"'
  while [ ! -f /tmp/d4-stop ]; do
    S=$(echo | openssl s_client -connect auth.hyeonworks.com:443 \
        -servername auth.hyeonworks.com 2>/dev/null | openssl x509 -noout -serial -enddate | tr "\n" " ")
    echo "$(date -u +%H:%M:%S) $S"
    case "$S" in *"$BASE"*) ;; *) sleep 180; touch /tmp/d4-stop;; esac
    sleep 5
  done' > /tmp/d4-serial.txt 2>&1 < /dev/null &

# 감시 1 — 새 연결
setsid bash -c '
  while [ ! -f /tmp/d4-stop ]; do
    echo "$(date -u +%H:%M:%S.%2N) $(curl -s -o /dev/null -w "%{http_code} %{time_total}" \
      --max-time 5 https://auth.hyeonworks.com/realms/master)"
    sleep 0.2
  done' > /tmp/d4-poll.txt 2>&1 < /dev/null &

# 감시 2 — 진행 중 요청을 끊김 없이 연달아 (reload 순간에 반드시 하나가 떠 있게)
setsid bash -c '
  while [ ! -f /tmp/d4-stop ]; do
    echo "$(date -u +%H:%M:%S) $(curl -s --limit-rate 20k -o /dev/null \
      -w "코드=%{http_code} 바이트=%{size_download} 시간=%{time_total} 연결수=%{num_connects}" \
      https://auth.hyeonworks.com'"$JS"')"
  done' > /tmp/d4-inflight.txt 2>&1 < /dev/null &
#   ★ setsid 가 필요하다. 그냥 & 로 띄우면 부모 셸이 끝날 때 같이 죽는다
#     (A-3 에서 파드 안 & 가 exec 종료와 함께 죽은 것과 같은 함정이다).

# ─────────────────────────────────────────────────────────────
# 5. 주입 — 여기만 sudo 가 필요하다
# ─────────────────────────────────────────────────────────────
ssh test-server
sudo certbot renew --force-renewal

# ─────────────────────────────────────────────────────────────
# 6. 판정
# ─────────────────────────────────────────────────────────────
grep -vE ' 200 ' /tmp/d4-poll.txt          # 새 연결이 끊긴 순간 (대조군은 0건)
grep -v '코드=200' /tmp/d4-inflight.txt    # 진행 중 요청이 끊긴 순간
grep -v "$BASE" /tmp/d4-serial.txt | head  # 새 인증서가 서빙되기 시작한 시각

6. 남긴 것

강제 갱신 + 무중단 측정 sudo 권한이 필요하다
SAN 확장 새 호스트를 쓰려면 재발급 — B-7 에서 실제로 걸렸다
D-3 과 연결 인증서 개인키도 같은 비밀 관리 문제다