20 KiB
04 — TLS
이 단계가 끝나면
https:// 가 열리고 체인이 완전하며, 갱신이 자동으로 서빙까지 닿는다.
전제
03 이 끝나 엣지 nginx 가 Traefik 으로 프록시한다.
어디서 치는가
이 단계는 전부 [kc-lab-edge] 에서 친다. 인증서·certbot·갱신 타이머·
deploy 훅이 전부 엣지 게스트에 산다. 물리 호스트에는 아무것도 두지 않는다 —
그래야 virsh undefine kc-lab-edge 한 줄로 이 계층을 통째로 되돌릴 수 있다.
★ 검증 방식을 먼저 정한다 — HTTP-01 이냐 DNS-01 이냐
같은 Let's Encrypt 인증서인데 「이 도메인이 네 것이냐」를 증명하는 방법만 다르다. 그리고 이 실험대에서는 선택의 여지가 없다.
| HTTP-01 | DNS-01 | |
|---|---|---|
| 검증 방향 | Let's Encrypt → 우리 서버 (인바운드) | certbot → DNS 공급자 API (아웃바운드) |
| 공개 인터넷에서 보여야 하나 | 그렇다 | 아니다 |
| 와일드카드 | 불가 | 가능 |
| 필요한 것 | 80 포트 · 공개 A 레코드 | DNS 공급자 API 토큰 |
이 실험대는 공개 인터넷을 쓰지 않는다. 도메인 세 개는 tailnet 주소를 가리킨다.
dig +short auth.hyeonworks.com
100.83.212.4
100.64.0.0/10 은 CGNAT 용으로 예약된 대역이라 공개 인터넷에서 라우팅
자체가 되지 않는다. 방화벽을 여는 문제가 아니라 그 주소가 인터넷에 존재하지
않는다. Let's Encrypt 를 tailnet 에 초대할 방법도 없다. 그래서 HTTP-01 은
쓸 수 없고 DNS-01 을 쓴다.
공개 서버라면 HTTP-01 이 맞다. 토큰도 DNS 연동도 필요 없어서 관리할 것이 적다. DNS-01 이 더 좋은 방식이어서 고르는 것이 아니라, HTTP-01 이 못 쓰이는 환경이라 고르는 것이다. 개념은
session-lab-concepts.md의 「DNS-01 은 언제 쓰는가」.
1. certbot 을 깐다
cloud-init 이 이미 깔았다면 건너뛴다 —
kc-lab.yaml.example 의
packages 에 들어 있다.
하기 — [kc-lab-edge]
sudo apt install -y certbot python3-certbot-dns-cloudflare
확인 — 쓸 수 있는 검증 방식이 무엇인가
certbot plugins 2>/dev/null | grep -E '^\*'
실측
* dns-cloudflare
* standalone
* webroot
어디를 봐야 하는가 — dns-cloudflare 한 줄이 있는가. 없으면 플러그인
패키지가 안 깔린 것이고, --dns-cloudflare 를 줘도 unrecognized arguments
로 끝난다.
2. 인증서를 받는다
DNS-01 이면 와일드카드를 받을 수 있다. 이 실험대는 처음에 이름 셋을
따로 받았고, 그 비용이 B-7 에서 청구됐다 — oauth2-proxy 를 올릴 네 번째
이름이 없어 Grafana 의 app2 를 빌려야 했다.
하기 — [kc-lab-edge]. 토큰은 존 하나 + DNS:Edit 으로 좁힌다.
계정 전역 API Key 를 쓰지 않는다.
sudo install -m 600 /dev/null /etc/letsencrypt/cloudflare.ini
sudo tee /etc/letsencrypt/cloudflare.ini >/dev/null <<'EOF'
dns_cloudflare_api_token = <Cloudflare API 토큰>
EOF
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d hyeonworks.com -d '*.hyeonworks.com' --dry-run
--dry-run 을 먼저 붙인다. Let's Encrypt 는 같은 이름 조합에 대해
주당 중복 인증서 5장 제한이 있고, --dry-run 은 그 한도를 쓰지 않는다.
통과하면 --dry-run 만 떼고 다시 친다.
DNS-01 은 느리다. TXT 레코드가 퍼질 때까지 기다려야 해서 발급이 수십 초 걸린다. certbot 이 기본 대기 시간을 두고 있으니 중간에 끊지 않는다.
확인 — 인증서가 실제로 생겼고 이름이 다 들어갔는가
sudo certbot certificates
어디를 봐야 하는가 — 네 줄이다. Domains: 에 이름 세 개가 한 줄에
나열되어 있는가(줄이 두 개로 갈라져 있으면 인증서가 두 장 만들어진 것이다),
Expiry Date: 가 오늘로부터 90일 뒤이고 VALID 인가, Certificate Path: 가
/etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem 인가, 그리고
Private Key Path: 가 그 옆의 privkey.pem 인가. 경로 두 개가 03 의
nginx 설정에 적은 것과 한 글자도 다르면 안 된다.
이 결과가 의미하는 것 — 여기 나온 경로가 곧 nginx 가 읽을 파일이다.
발급은 성공했는데 No certificates found 가 나오면 sudo 없이 쳐서 다른
사용자의 설정 디렉터리를 본 것이다. 발급 자체가 실패했다면 검증 단계에서
막힌 것이므로 DNS 와 80 포트부터 본다(아래 「막히면」).
이 실험대는 와일드카드를 쓰지 않았고, 그 비용이 나중에 청구됐다. B-7 에서 oauth2-proxy 를 올릴 네 번째 이름이 없어 Grafana 의
app2를 빌려야 했다. 와일드카드는 DNS-01 검증이 필요하고 그건 DNS 공급자 API 를 붙여야 한다 — 그 절충을 안 한 결과다.
3. nginx 가 fullchain 을 보게 한다
03 의 설정에 이미 있다.
ssl_certificate /etc/letsencrypt/live/auth.hyeonworks.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.hyeonworks.com/privkey.pem;
cert.pem 이 아니라 fullchain.pem. 서버 인증서만 보내면 중간 인증서가
빠져 체인이 끊긴다. 브라우저는 대개 캐시나 AIA 로 보완해서 정상으로 보이고,
캐시가 없는 클라이언트에서만 깨진다. 그래서 발견이 늦다.
하기
sudo nginx -t && sudo systemctl reload nginx
4. 확인 — 열리는가, 체인이 완전한가
확인 ① 열리나 — 처음 한 번은 협상 과정을 읽는다
curl -v https://auth.hyeonworks.com/realms/master -o /dev/null
형태 (이 실험대에서 캡처해 두지 않았다 — 읽어야 할 줄만)
* SSL connection using TLSv1.3 / ...
* subject: CN=auth.hyeonworks.com
* issuer: C=US; O=Let's Encrypt; CN=...
* SSL certificate verify ok.
> GET /realms/master HTTP/1.1
< HTTP/1.1 200 OK
어디를 봐야 하는가 — * 로 시작하는 줄 넷이다. 어떤 TLS 판으로
협상했는가, subject 의 CN 이 지금 친 이름과 같은가, issuer 가 Let's
Encrypt 인가, 그리고 SSL certificate verify ok. 가 있는가. 그 아래
< 로 시작하는 첫 줄이 응답 상태다. -o /dev/null 은 본문만 버리는 것이라
이 줄들은 그대로 남는다.
이 결과가 의미하는 것 — 이 네 줄이 다 나오면 인증서가 붙었고 체인이
클라이언트 기준으로 검증됐다. TLS 에서 막힐 때 봐야 할 것이 전부 여기
있으므로, 인증서를 처음 붙인 직후에는 코드 한 칸이 아니라 이 화면을
본다. verify 줄 대신 unable to get local issuer certificate 가 나오면
중간 인증서가 빠진 것이고, 그 원인은 3번의 cert.pem/fullchain.pem
이다 — 확인 ②로 간다.
같은 것을 반복해서 재거나 03·05 의 값과 나란히 비교할 때만 값만 뽑는 형태로 줄인다.
curl -s -o /dev/null -w '%{http_code}\n' https://auth.hyeonworks.com/realms/master
200
확인 ② 체인 단계와 검증
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'
실측
0 s:CN = auth.hyeonworks.com
i:C = US, O = Let's Encrypt, CN = YE2
1 s:C = US, O = Let's Encrypt, CN = YE2
i:C = US, O = ISRG, CN = Root YE
2 s:C = US, O = ISRG, CN = Root YE
i:C = US, O = Internet Security Research Group, CN = ISRG Root X2
3 s:C = US, O = Internet Security Research Group, CN = ISRG Root X2
i:C = US, O = Internet Security Research Group, CN = ISRG Root X1
Verify return code: 0 (ok)
어디를 봐야 하는가 — 왼쪽의 번호(0·1·2·3)가 몇까지 가는가, 그리고
각 단계의 i:(발급자)가 바로 다음 단계의 s:(주체)와 같은가. 0번이
우리 서버 인증서이고, 위 실측에서 0의 i: 가 CN = YE2 인데 1의 s: 가
같은 CN = YE2 다 — 사슬이 이어져 있다는 뜻이다. 마지막이
Verify return code: 0 (ok).
이 결과가 의미하는 것 — 단계가 1개면 cert.pem 을 쓴 것이다.
서버가 자기 인증서만 보내고 중간 인증서를 안 보낸 상태다. 이때 브라우저는
대개 캐시나 AIA 로 보완해서 정상으로 보이므로, 이 명령이 유일하게
믿을 수 있는 판정이다. 고치는 곳은 03 의 ssl_certificate 한 줄이고,
고친 뒤 nginx -t && systemctl reload nginx 하고 여기서 다시 잰다.
Verify return code 가 0 이 아니면 숫자마다 뜻이 다르다 —
10 은 만료, 20 은 발급자를 못 찾음, 21 은 첫 인증서를 검증 못 함.
확인 ③ 이름 세 개가 한 인증서인가
for H in auth app1 app2; do
echo | openssl s_client -connect $H.hyeonworks.com:443 -servername $H.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -serial
done
어디를 봐야 하는가 — 찍히는 세 줄의 일련번호가 서로 같은가. 값 자체는 아무 의미가 없고 셋이 일치하는지만 본다.
이 결과가 의미하는 것 — 셋이 같으면 SAN 하나에 이름 셋이 들어 있는 인증서 한 장이고, 갱신도 한 번에 끝난다. 다르면 인증서가 여러 장이라 갱신 훅도 장마다 따로 돌고, 한 장만 갱신됐을 때 나머지 이름이 만료되는 상황이 생긴다. 이름별로 무엇이 실려 있는지 보려면 SAN 을 직접 편다.
echo | openssl s_client -connect auth.hyeonworks.com:443 -servername auth.hyeonworks.com 2>/dev/null \
| openssl x509 -noout -ext subjectAltName
5. ★ 갱신이 서빙까지 닿게 한다 — 여기가 이 단계의 핵심이다
타이머가 도는 것만으로는 부족하다.
확인 — 타이머
systemctl list-timers certbot-renew.timer
어디를 봐야 하는가 — 네 칸이다. NEXT(다음 실행 시각)와 LEFT(남은
시간)가 채워져 있는가, LAST/PASSED 가 하루 안쪽인가, UNIT 옆의
ACTIVATES 가 certbot-renew.service 를 가리키는가. 표가 통째로 비어
나오면 타이머가 없는 것이다 — 이름이 배포판마다 다르니
systemctl list-timers --all | grep -i certbot 로 찾는다.
이 결과가 의미하는 것 — 여기까지가 「갱신이 돌기는 하는가」의 답이고,
대부분의 문서가 여기서 끝난다. 그런데 이것이 active 여도 갱신된 인증서가
서빙되지는 않는다. nginx 는 인증서를
기동 시점에 읽어 메모리에 들고 있고, certbot 은 live/ 심볼릭 링크만 갈아
끼운다. 경로는 그대로이고 내용만 바뀌므로 nginx 는 모른다.
배포판 기본 유닛에는 reload 를 부르는 것이 없다.
systemctl cat certbot-renew.service
[Service]
Type=oneshot
ExecStart=/usr/bin/certbot -q renew
PrivateTmp=true
어디를 봐야 하는가 — ExecStart= 한 줄과, 그 아래에 ExecStartPost= 가
있는지 없는지. 그리고 ExecStart 의 인자에 --deploy-hook 이 붙어
있는지. 여기 없는 것을 보는 것이 이 명령의 목적이다.
이 결과가 의미하는 것 — ExecStartPost 도 --deploy-hook 도 없다.
즉 이 유닛은 인증서를 새로 받는 데까지만 책임지고, 받은 것을 누가
읽게 만드는 일은 아무도 하지 않는다. 배포판이 이렇게 준다는 것이 요점이다 —
「기본값이니 괜찮겠지」가 바로 이 결함의 서식지다. 여기 뭔가 적혀 있는
배포판이라면 아래 훅은 필요 없고, 대신 그 명령이 nginx 를 reload 하는지만
확인하면 된다.
하기 — 훅 하나를 넣는다
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh > /dev/null <<'EOF'
#!/bin/sh
nginx -t && nginx -s reload
EOF
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
저장소에 같은 파일이 있다 —
deploy/lab/edge/reload-nginx.sh.
여기 손으로 치지 말고 그걸 밀어 넣는 편이 낫다.
cat deploy/lab/edge/reload-nginx.sh \
| ssh kc-lab-edge 'sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null'
ssh kc-lab-edge 'sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh'
이 훅은 한동안 저장소에 없었다. 호스트에만 있어서, 호스트를 초기화하면 아무 오류 없이 사라지고 D-4 가 측정한 상태(갱신 성공 · 서빙 38분 25초 지연 · 타이머는
SUCCESS)로 되돌아갔다. 저장소에 두는 이유가 이것이다.
deploy/ 에 넣는다. post/ 는 갱신이 없어도 매번 돌아 하루 두 번 워커를
갈아치운다. deploy/ 는 실제로 갱신됐을 때만 실행된다.
확인 — 실제로 도는지
이 확인은 상태를 바꾼다. --force-renewal 은 인증서를 실제로 새로
받으므로 발급 한도(주당 중복 5장)를 깎는다. 먼저 --dry-run 으로 훅이
호출되는 것까지만 보고, 진짜 판정이 필요할 때만 강제 갱신을 한 번 쓴다.
sudo certbot renew --dry-run
어디를 봐야 하는가 — 출력 끝의 Running deploy-hook command 줄과
simulated renewals 요약. 훅 줄이 아예 안 나오면 파일이 deploy/ 가 아닌
곳에 있거나 실행 권한이 없는 것이다(ls -l 로 x 를 본다).
이 결과가 의미하는 것 — dry-run 은 훅이 호출되는지까지만 말해 준다. 호출된 훅이 nginx 를 정말 갈아 끼웠는지는 dry-run 으로 알 수 없다 — 그래서 아래를 한 번 한다.
# 강제 갱신 전에 워커 PID 를 적어 둔다
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
sudo certbot renew --force-renewal
# 워커 PID 가 바뀌었으면 reload 된 것이다
ps -eo pid,lstart,args | grep 'nginx: worker' | grep -v grep
어디를 봐야 하는가 — 두 출력의 첫 열(PID)과 둘째 열 묶음(lstart, 프로세스
시작 시각). 앞뒤로 놓고 PID 집합이 통째로 바뀌었는지만 본다. lstart 를
같이 뽑는 이유는 PID 가 우연히 재사용됐을 때를 가르기 위해서다. 마스터
프로세스는 그대로이고 워커만 갈리는 것이 정상이다.
이 결과가 의미하는 것 — PID 가 바뀌었으면 훅이 돌아 nginx 가 새 인증서를 읽었다. 안 바뀌었으면 인증서는 갱신됐는데 서빙되는 것은 옛것이고, 이 상태가 아래 표의 왼쪽 칸이다.
판정은 로그 문구가 아니라 워커 PID 로 한다. certbot 이
Hook 'deploy-hook' ran with error output 이라고 찍는데 실패가 아니다 —
nginx 의 types_hash 경고가 stderr 로 나갔을 뿐이고 내용은
test is successful · signal process started 다.
로그에서
error를 grep 하는 감시를 걸면 성공한 훅을 실패로 오독한다.
실측 — 이 실험대에서 잰 차이
| 훅 없음 | 훅 있음 | |
|---|---|---|
| 갱신 → 서빙 | 2305초 (38분 25초) | 1~2초 |
| 무엇이 reload 했나 | 사람이 직접 | certbot deploy 훅 |
| 아무도 안 했다면 | 다음 nginx 재시작까지 = 사실상 무기한 | — |
88일 동안 이 결함이 보이지 않는다. 타이머는 정상이고 매번 SUCCESS 로
끝나며, 만료 30일 전까지는 갱신 자체를 하지 않아 발현할 기회가 없다.
발현하는 날의 증상은 인증서 만료이고, 그날에도 로그에는 SUCCESS 라고
적혀 있다.
원문: D-4 ·
D-4a ·
증거 evidence/d4-certificate-renewal/
6. reload 는 무중단인가 — 쟀다
궁금할 것이므로 결과만 적는다. 무중단이다.
새 연결 8856건 전부 200, p95 는 205.7ms 대 204.3ms 로 변화 없음. 그리고 845KB 를 20k/s 로 받는 중이던 요청이 전송 12초째에 reload 를 맞고도 845361바이트를 온전히 받았다(연결수 1). 옛 워커가 그 요청을 끝까지 책임진다.
막히면
| 증상 | 원인 | 확인 |
|---|---|---|
| certbot 검증 실패 | DNS 가 이 호스트를 안 가리킴 · 80 이 막힘 | dig +short auth.hyeonworks.com · 밖에서 curl -I http://auth.hyeonworks.com |
| 체인 단계가 1개 | cert.pem 을 씀 |
위 확인 ② |
| 갱신은 됐는데 옛 인증서가 나감 | deploy 훅 없음 | 워커 PID · sudo ls /etc/letsencrypt/renewal-hooks/deploy/ |
| 훅이 실패한 것처럼 보임 | stderr 경고를 error 로 표시 | 문구 말고 워커 PID |
| 발급 한도 | 주당 중복 인증서 5장 | --dry-run 으로 먼저 시험 |
unrecognized arguments: --dns-cloudflare |
플러그인 미설치 | certbot plugins | grep '^\*' |
| DNS-01 이 오래 걸림 | TXT 전파 대기 | 정상이다. 끊지 않는다 |
| 재구축 뒤 인증서가 없음 | 발급하지 말고 백업을 되돌린다 | 한도를 아끼는 길이다 |
근거를 재려면 (선택)
평소에는 필요 없다. 문서에 남길 근거가 필요할 때만 이렇게까지 한다.
갱신 중 가용성을 재려면 주입 전에 대조군부터 잡는다. 평시 오류율을 모르면 갱신 중에 나온 실패 한 건을 해석할 수 없다.
# 대조군 — 0.2초 × 900회 = 180초
i=0; while [ $i -lt 900 ]; do
curl -s -o /dev/null -w '%{http_code} %{time_total}\n' --max-time 5 \
https://auth.hyeonworks.com/realms/master
i=$((i+1)); sleep 0.2
done > /tmp/control.txt
awk '{print $1}' /tmp/control.txt | sort | uniq -c
여기서는 값만 뽑는 형태가 맞다. 900번을 재서 코드별로 세는 것이 목적이고, 헤더는 볼 일이 없다. 앞의 확인 ①과 형태가 다른 이유가 이것이다.
어디를 봐야 하는가 — uniq -c 가 내놓는 줄이 몇 개인가. 한 줄이면
900번이 전부 같은 코드였다는 뜻이고, 그 줄의 왼쪽 수가 900 인지 본다.
두 줄 이상이면 그 자체가 평시 오류가 있다는 뜻이다. 응답 시간이 궁금하면
둘째 열을 따로 본다.
awk '{print $2}' /tmp/control.txt | sort -n | tail -1 # 최악값
이 결과가 의미하는 것 — 이 실험대의 대조군은 900건 전부 200, 오류 0 이었다. 그래서 갱신 중 비200 이 한 번이라도 나오면 갱신 탓으로 귀속할 수 있었다. 대조군에 이미 오류가 섞여 있다면 주입 중의 오류 한 건은 아무것도 증명하지 못하므로, 대조군이 깨끗해질 때까지는 주입을 하지 않는다.
그리고 두 기계의 시각을 나란히 놓기 전에 시계부터 잰다.
A=$(date -u +%s.%N); B=$(ssh test-server 'date -u +%s.%N'); C=$(date -u +%s.%N)
# 왜곡 ≈ B − (A+C)/2 , 어느 쪽이 맞는지는 외부 기준으로 가른다
curl -sI https://www.google.com | grep -i '^date:'
timedatectl show -p NTP -p NTPSynchronized
어디를 봐야 하는가 — 세 수 A·B·C 를 눈으로 빼서 초 단위 차이가
몇인가. A 와 C 는 같은 기계에서 SSH 왕복 직전·직후에 찍은 것이라, 그
가운데가 「저쪽 시각을 잰 순간의 이쪽 시각」이다. 그다음 Date: 헤더의
시각이 둘 중 어느 쪽에 가까운가. 마지막으로 NTPSynchronized=yes 인가.
이 결과가 의미하는 것 — 차이가 수초 이내면 두 기계의 로그를 그대로 나란히 놓아도 된다. 크면 먼저 어느 쪽이 틀렸는지 가른 다음 보정한다. 이 실험대는 test-server 가 NTP 미동기로 106초 빨랐고, 보정하지 않은 첫 계산은 훅이 인증서 발급보다 104초 먼저 실행된 것이 되어 물리적으로 불가능했다 — 음수 지연이 나오면 계산이 아니라 시계를 의심한다.