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

323 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# D-4 — 인증서 갱신
브랜치 `feature/keycloak-d4-certificate-renewal` ·
증거 [`docs/evidence/d4-certificate-renewal/`](evidence/d4-certificate-renewal/) ·
2026-09-04 17:2517:35 KST
---
## 구조
![D-4 구조 — 체인과 SAN 제약](diagrams/d4-cert-chain.svg)
> 다이어그램 규약은 [`diagrams/_style.md`](diagrams/_style.md).
> 실험대 전체 구조는 [`diagrams/lab-topology.svg`](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, 다른 서버)에서만 깨진다.**
```bash
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 는 이렇게 적혀 있었다.
```bash
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일 뒤에야 알 수 있다) |
주입 명령은 하나뿐이고, 비밀번호가 있는 사람만 실행할 수 있다.
```bash
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/01-certificate-state.txt) | 터미널 원문 |
파일별 상세는 [`evidence/d4-certificate-renewal/README.md`](evidence/d4-certificate-renewal/README.md).
## 5. 재현 절차 (명령어)
```bash
# ─────────────────────────────────────────────────────────────
# 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 과 연결** | 인증서 **개인키**도 같은 비밀 관리 문제다 |