feat(pipeline): keycloak-session-store 25편·virtualization 59편을 S3→S5→S6 으로 돌린다
기록 84편을 계약 에이전트로 다시 썼다. 기존 71편(kss 25 · virt 46)과, 계약에만 있고 안 쓰여 있던 새 글감 13편이다. 원장 84개를 열어 단계마다 스킬 영수증과 관문 종료 코드를 적었고 verify-pipeline-run.py 가 error 0 으로 닫는다. SSOT 결함 둘을 고쳤다. - kss 의 `약 58일` 이 반입 중 `약 59일` 로 바뀌어 있었다. 원 증거 파일이 「남은 일수: 88일 … 실제 갱신까지 약 58일」로 산수를 직접 적는다. D-4a 쪽 `약 59일` 은 강제 갱신 뒤(`VALID: 89 days`)라 맞는 값이라 그대로 뒀다. - virt §198 의 `11.6GB` 는 §178 의 원 측정 `Mem: 11648`(MiB)과 어긋나는데 원 가이드의 표기 그대로라 고치지 않고 쓰이는 자리에 대조를 적었다. 기록의 수치 오류 셋을 고쳤다 — CASE 요약의 「게스트 셋에 8240MB」(5120+3120 은 둘이다), k3s 편이 같은 것을 여섯·일곱·여덟로 세던 것, no-docker 편의 「셋을 더 든다」(§281 의 표는 네 행이고 디스크 행이 빠져 있었다). 계약을 셋 고쳤다. - kss 의 sourceRepository 리비전이 cdac9b8 이었는데 그 커밋에는 docs/guides/** 28개가 아예 없다. 9465582b 로 바꾸고, 반입한 바이트가 어느 커밋과도 같지 않다는 것을 측정값과 함께 적었다 — 반입은 커밋이 아니라 그 시점의 작업 트리에서 떠 온 것이다(kss 297/306 · virt 12/14 가 작업 트리와 같고, 200 커밋을 거슬러 전수 대조했을 때 가장 가까운 커밋도 28개가 어긋났다). - virt 계약이 「2026-09-11 재배분」이라고 적는데 SSOT 는 재배분 날짜를 적지 않고 재배분 뒤 값은 이미 2026-09-10 측정에 찍혀 있다. - kss 후보 대장이 지나친 절 아홉에 처분을 적었다(warn 9 → 0). 새 글감은 0건이고 넷은 앵커가 h3 슬러그의 접두가 아니라 중간 토막이라 검사기가 못 본 것이었다. style_profile.mjs 의 결함 둘을 고쳤다 — frontmatter 가 문장으로 세어져 (실측 398자짜리 「문장」 하나) 평균 길이를 기준 안으로 밀어 올리고 있었고, engPerSent 의 분자는 목록을 포함한 글에서, 분모는 목록을 걷어낸 글에서 세고 있었다(Question 기록에서 11.94 → 3.86). verify-pipeline.py 전 항목 PASS · error 0 · unittest 334건 OK. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
d473609e0a
commit
2109f726fe
+134
@@ -0,0 +1,134 @@
|
||||
---
|
||||
kind: CONCEPT
|
||||
slug: two-l7-hops-and-the-entry-point-recursion
|
||||
title: L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀
|
||||
topic: lab-entry-path-and-measurement-integrity
|
||||
topicName: 실험대의 진입 경로
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
basisVersion: 이 실험대의 2026-09-03 배치 · 호스트 nginx 1.30.4 (Arch) · k3s v1.36.4 의 기본 ingress 인 Traefik
|
||||
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
|
||||
source:
|
||||
- final/document.md#261-진입점-자체가-죽으면-로드밸런서의-재귀-문제
|
||||
- final/document.md#275-호스트-nginx와-traefik은-무엇이-다른가-둘-다-필요한-이유
|
||||
- final/document.md#257-리버스-프록시와-upstream
|
||||
- final/document.md#258-왜-tls를-끊어서-내용을-보는가
|
||||
---
|
||||
|
||||
# L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀
|
||||
|
||||
이 실험대의 요청은 HTTP 를 읽는 서버를 두 번 지난다. 바깥의 nginx 가 TLS 를 끊고 어느 노드로 보낼지 정하고, 그 노드의 Traefik 이 어느 파드로 보낼지 정한다. 한쪽만으로는 안 되는 이유가 서로 다르고, 진입점을 이중화하려 하면 「어느 진입점으로 갈지 누가 정하는가」가 새로 생겨 재귀가 끝나지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
그 결정이 든 근거가 「L7 홉 수는 전후 모두 2홉」인데 왜 2홉인지는 그 기록에 없다. 이 글이 그 앞을 맡는다.
|
||||
- **신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다**
|
||||
그 기준이 말하는 경계가 두 홉 가운데 어느 쪽인지를 이 글이 먼저 정한다.
|
||||
- **공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다**
|
||||
여기 적은 두 겹에 세 번째를 붙이지 않기로 한 결정이다.
|
||||
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
|
||||
바깥 홉을 실제로 세우는 절차가 거기 있다.
|
||||
- **Guest 의 packet 이 Host Physical NIC 에 닿기까지 — virtio-net · virtqueue · vhost-net · TAP · Bridge**
|
||||
이 두 겹 아래에서 패킷이 게스트까지 가는 길을 그 글이 설명한다.
|
||||
- **가까운 층부터 한 층씩 건너뛰며 확인한다 — 층마다 성공 신호가 다르다**
|
||||
진입 경로가 층으로 나뉘어 있어서 한 칸씩 건너뛰며 칠 수 있다.
|
||||
|
||||
## 본문
|
||||
|
||||
<!-- body:start -->
|
||||
|
||||
## 요청 하나가 L7 을 두 번 지난다
|
||||
|
||||
브라우저가 보낸 요청은 파드에 닿기까지 HTTP 를 읽는 서버를 두 번 지난다.
|
||||
|
||||
```text
|
||||
브라우저 ─▶ nginx (L7 · TLS 종료) ─▶ Traefik (L7 · Ingress 라우팅) ─▶ Pod
|
||||
```
|
||||
|
||||
리버스 프록시는 클라이언트 요청을 대신 받아 뒤쪽 서버로 전달하는 서버를 말한다. nginx 의 `upstream` 블록은 뒤쪽 서버 여러 대를 하나의 논리 이름으로 묶고, `proxy_pass http://이름;` 으로 그 그룹을 가리키면 nginx 가 요청을 분배한다. 저장소의 단일 호스트용 설정은 `proxy_pass http://keycloak:8080` 으로 대상 하나를 가리키는데, 멀티노드로 재려면 `upstream` 형태로 바꿔야 한다. 바깥 홉이 이 형태이고 묶인 것은 게스트 두 대의 `80` 포트다.
|
||||
|
||||
엣지 nginx 는 물리 호스트에서 게스트 한 대로 옮겨졌는데 그때도 HTTP 를 읽는 홉의 수는 2 그대로였다. 늘어난 것은 호스트 커널이 하는 L4 전달 한 번이고, 커널은 HTTP 를 읽지 않는다.
|
||||
|
||||
## 바깥 홉이 TLS 를 끊는 이유
|
||||
|
||||
TLS 종료는 프록시가 암호를 풀어 평문 HTTP 를 읽는 것을 말한다. 굳이 푸는 첫째 이유는 내용을 안 보면 어디로 보낼지 정할 수 없기 때문이다. 여러 도메인이 하나의 IP 와 443 포트를 공유하고, 어느 서비스로 보낼지는 HTTP `Host` 헤더에 적혀 있는데 그 헤더가 TLS 안에 암호화돼 있다. TLS 핸드셰이크의 평문 부분에 도메인이 들어 있어서 도메인 단위 분기는 풀지 않고도 된다. 다만 경로 단위 분기는 그렇게 할 수 없고, 인증서도 백엔드마다 따로 관리해야 한다.
|
||||
|
||||
이 실험대에는 이유가 하나 더 있다. `X-Forwarded-Proto: https` 나 `X-Forwarded-Host` 같은 헤더는 평문 HTTP 를 편집할 수 있어야 넣을 수 있고, Keycloak 이 `iss` 클레임과 redirect URL 을 외부 주소로 만들려면 그 헤더가 필요하다. 그래서 이 구조에서 TLS 종료는 고를 수 있는 것이 아니라 전제다.
|
||||
|
||||
대가는 셋이다. 프록시 뒤 구간이 평문이 된다 — 운영은 `127.0.0.1`, lab 은 `virbr0` 로 둘 다 머신 밖으로 안 나간다. 신뢰 경계가 프록시까지 넓어져서 프록시 보안이 곧 전체 보안이 된다. 그리고 클라이언트 인증서가 사라지는데, 백엔드가 그것을 직접 검증해야 하면 TLS 를 끊으면 안 되고 그때는 TCP 를 그대로 흘리는 L4 통과 구성을 쓴다.
|
||||
|
||||
## 바깥과 안이 아는 것이 다르다
|
||||
|
||||
| 무엇을 견주나 | 바깥의 nginx | Traefik (k3s ingress) |
|
||||
|---|---|---|
|
||||
| 사는 곳 | 클러스터 밖의 프로세스 | 클러스터 안, 파드 |
|
||||
| 아는 대상 | IP 와 포트 (고정) | 쿠버네티스 Service 와 Ingress (동적) |
|
||||
| 설정하는 방법 | 파일 편집 뒤 `nginx -s reload` | `kubectl apply` 로 Ingress 리소스 |
|
||||
| 대상이 바뀌면 | 사람이 고쳐야 한다 | 자동 반영 |
|
||||
| 무엇을 정하나 | 어느 노드로 보낼까 | 어느 파드로 보낼까 |
|
||||
| TLS | 여기서 종료 | 평문으로 받음 |
|
||||
|
||||
nginx 는 클러스터의 존재를 모르고 파드 IP 가 바뀌는 것도 모른다. Ingress 는 설정을 적어 둔 쿠버네티스 리소스여서 그 자체로는 아무 일도 하지 않고, 그 설정을 실제로 수행하는 프로그램이 Ingress Controller 다. 컨트롤러는 API 서버를 계속 감시하다가 Ingress 리소스가 생기거나 바뀌면 자기 라우팅 설정을 갱신한다.
|
||||
|
||||
Traefik 만 쓰면 어느 노드로 보낼지를 정할 것이 없다. k3s 에 딸린 servicelb 덕분에 Traefik 이 두 노드의 80 과 443 에 모두 바인딩되지만, 브라우저는 어느 노드로 가야 할지 모르고 그 노드가 내려가면 그 IP 로는 아무도 받지 못한다. Traefik 은 노드 안에서 파드로 나눠 주고, 노드들 사이에서 나눠 주는 일은 클러스터 밖의 무언가가 맡아야 한다.
|
||||
|
||||
nginx 만 쓰고 Traefik 을 끄면 Ingress 리소스를 못 쓰게 되고 서비스가 늘거나 파드 IP 가 바뀔 때마다 사람이 파일을 고친다. 그리고 두 경우 모두 운영 구조와 달라진다. 운영이 `host nginx → k3s(Traefik)` 이므로 실험대도 그 2홉을 복제해야 `X-Forwarded-*` 신뢰 경계 결론이 그대로 이전되고, 그것이 둘 다 두는 결정적인 이유다.
|
||||
|
||||
## ALB 와 NLB 는 같은 곳을 놓고 고르는 두 선택지다
|
||||
|
||||
ALB 와 NLB 는 AWS 의 두 제품이고 이 실험대에는 없다. 여기서는 진입점 배치를 견주는 이름으로만 쓴다. 둘 다 클러스터 밖의 로드밸런서여서 Ingress Controller 와 대응되는 짝이 아니다.
|
||||
|
||||
| 무엇이 다른가 | ALB (L7) | NLB (L4) |
|
||||
|---|---|---|
|
||||
| 이해하는 것 | HTTP/HTTPS | TCP/UDP |
|
||||
| 라우팅 기준 | 호스트명·경로 | 포트 |
|
||||
| TLS | 종료함 | 통과 또는 종료 |
|
||||
| `X-Forwarded-*` | 추가함 | 추가 안 함 (PROXY protocol 사용) |
|
||||
|
||||
진입점은 한 곳이고 HTTP 를 읽는 처리는 어딘가에서 반드시 한 번 일어난다. 배치의 차이는 진입점과 L7 처리기가 같은 장비인가 다른 장비인가에 있다.
|
||||
|
||||
```text
|
||||
[ALB 패턴]
|
||||
브라우저 ─▶ ALB (L7 · TLS 종료 · 경로 라우팅) ─▶ Pod
|
||||
└ 진입점이자 L7 처리기. 하나가 두 역할.
|
||||
|
||||
[NLB 패턴]
|
||||
브라우저 ─▶ NLB (L4 · 통과) ─▶ ingress controller (L7 · TLS 종료) ─▶ Pod
|
||||
└ 진입점만 └ L7 처리기. 역할이 둘로 나뉜다.
|
||||
```
|
||||
|
||||
| 배치 | 진입점 | L7 처리 위치 |
|
||||
|---|---|---|
|
||||
| ALB 단독 | ALB (L7) | 진입점 한 곳 |
|
||||
| NLB + ingress | NLB (L4) | 클러스터 안 한 곳 |
|
||||
| L7 + ingress | nginx (L7) | 두 곳 모두 |
|
||||
|
||||
이 실험대는 두 패턴 중 어느 쪽도 아니다. 바깥의 nginx 가 TLS 를 끊고 `X-Forwarded-*` 를 넣으므로 ALB 에 가까운데, 그 뒤의 Traefik 이 또 HTTP 를 읽는다.
|
||||
|
||||
## 진입점을 이중화하려 하면 재귀가 끝나지 않는다
|
||||
|
||||
한 머신 안에서 nginx 를 여러 개 띄우는 것은 의미가 없다. nginx 는 이미 master 프로세스 1개와 worker N개 구조이고, worker 들이 리스닝 소켓을 공유하며 CPU 코어 수만큼 병렬로 처리한다. 같은 머신에 인스턴스를 늘려도 그 머신이 내려가면 전부 함께 내려가므로 가용성은 늘지 않는다.
|
||||
|
||||
진짜 이중화는 머신을 늘리는 것이고, 그러면 「어느 nginx 로 갈지는 누가 정하는가」가 새로 생긴다. 앞에 로드밸런서를 또 두면 이번에는 그것이 단일 장애점이 되어 같은 물음이 한 칸 앞으로 옮겨 갈 뿐이다. 실무는 이 재귀를 소프트웨어가 아니라 네트워크 계층의 장치로 끊는다.
|
||||
|
||||
| 방법 | 재귀를 끊는 원리 | 전환 시간 |
|
||||
|---|---|---|
|
||||
| VIP + VRRP (keepalived) | 선택자가 없다. IP 자체가 이동한다 | 1~3초 |
|
||||
| DNS 다중 A 레코드 | 클라이언트가 고른다. 실패 시 다음 IP로 재시도 | TTL 의존, 느림 |
|
||||
| 애니캐스트 + BGP/ECMP | 라우터가 가장 가까운 경로로 보낸다 | 즉시, 대규모 전용 |
|
||||
| 클라우드 LB에 위임 | AWS가 내부적으로 다중 AZ로 이중화. 사용자는 DNS 이름만 받음 | 관리 불필요 |
|
||||
|
||||
VRRP 는 가상 IP 하나를 여러 장비가 번갈아 갖게 하는 방식으로 재귀를 끊는다. 가상 IP 는 한 번에 한 대만 갖고, MASTER 가 내려가면 BACKUP 이 그 IP 를 가져간 뒤 gratuitous ARP 를 브로드캐스트해 스위치의 MAC 테이블을 갱신한다. 클라이언트는 계속 같은 IP 로 접속하는데 그 IP 가 어느 장비에 붙어 있는지만 바뀐다. 고르는 주체가 없어서 「누가 정하는가」라는 물음 자체가 생기지 않는다.
|
||||
|
||||
클라우드 로드밸런서를 쓰면 AWS 가 여러 가용 영역에 걸쳐 이 재귀를 대신 풀어 주고 사용자는 DNS 이름 하나만 받는다.
|
||||
|
||||
## 이 실험대가 관측한 범위
|
||||
|
||||
바깥의 nginx 는 이 실험대의 단일 장애점이고 물리 머신도 한 대라 그것도 단일 장애점이다. 숨길 이유가 없으므로 알려진 한계로 적어 둔다.
|
||||
|
||||
이 실험대는 진입점을 이중화하지 않는다. 물리 머신이 한 대라 keepalived 를 구성해도 그 머신이 내려가면 끝이고, 검증 대상은 Keycloak 의 세션과 토큰이지 로드밸런서 가용성이 아니다. 다만 Traefik 은 이미 두 노드에 떠 있으므로, 노드 하나를 내리고 바깥 nginx 의 `upstream` 이 어떻게 반응하는지는 그대로 관찰할 수 있다.
|
||||
|
||||
ALB 와 NLB 의 대조는 AWS 의 두 제품을 기준으로 적은 것이고 이 실험대에서 관측한 것이 아니다. VRRP 도 keepalived 의 일반 동작이고 이 실험대에 구성하지 않았다. NLB 에 해당하는 것 역시 이 실험대에 아직 없고, 필요해지면 nginx 의 `stream {}` 블록이 그 일을 맡는다. k3s API 서버를 밖에서 접근하거나 PostgreSQL 과 Redis 를 게스트 밖에서 직접 관찰하거나 Keycloak 의 mTLS 를 실험할 때가 그런 경우다. nginx 는 한 프로세스에서 L7 과 L4 를 함께 수행할 수 있어서, AWS 에서 두 제품으로 나뉘어 있는 것과 다르다.
|
||||
|
||||
<!-- body:end -->
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
---
|
||||
kind: PROJECT_DECISION
|
||||
slug: no-public-tunnel-because-a-third-hop-pollutes-the-measurement
|
||||
title: 공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다
|
||||
topic: lab-entry-path-and-measurement-integrity
|
||||
topicName: 실험대의 진입 경로
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
decisionStatus: ADOPTED
|
||||
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
|
||||
source:
|
||||
- final/document.md#305-tunnel-채택하지-않은-이유를-남긴-자산
|
||||
- final/document.md#302-왜-적용하지-않는-것을-남겨두는가
|
||||
- final/document.md#300-9층-deploy-무엇이-살아-있고-무엇이-참조인가
|
||||
- final/document.md#301-전체-지도
|
||||
---
|
||||
|
||||
# 공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다
|
||||
|
||||
Cloudflare named tunnel 을 앞에 붙이면 브라우저에서 파드까지가 3홉이 되고, 이 실험대가 재려는 것이 정확히 2홉의 forwarded 헤더 계약이라 측정이 오염된다. 그래서 tailnet 직결로 두고 터널 설정 파일은 채택하지 않은 선택지로 남긴다.
|
||||
|
||||
## 근거
|
||||
|
||||
§305 가 `deploy/` 아래의 `tunnel/cloudflared-config.yml` 을 두고 채택하지 않았다고 적으면서 기각 이유를 전후 홉 그림으로 남겼다. 기각한 파일을 지우지 않는 방침은 §300 부터 §302 에 따로 있다. 저장소에 있으나 적용되지 않는 설정이 여럿인데, 그것들이 죽은 코드가 아니라 의도적으로 남겨 둔 참조 자산이라고 전체 지도와 함께 적는다.
|
||||
|
||||
- **L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀**
|
||||
이 결정이 지키려는 2홉이 무엇이고 그 둘의 역할이 왜 다른지를 그 글이 설명한다.
|
||||
- **신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다**
|
||||
앞에 한 겹이 더 붙으면 경계가 Cloudflare 엣지로 옮겨 가 그 기준을 거기서 다시 세워야 한다.
|
||||
- **인증서는 DNS-01 로 받는다 — 실험대 주소가 공개 인터넷에 없어 HTTP-01 이 성립하지 않는다**
|
||||
이 결정이 청구한 비용 하나를 그 결정이 받는다.
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
같은 2홉을 지킨 채 엣지의 위치만 바꾼 결정이라 홉 수를 건드리지 않는다.
|
||||
- **이 실험대의 certbot 은 HTTP-01 로 받고 있는가 DNS-01 로 받고 있는가**
|
||||
재구축 때 인증서 디렉터리를 지워도 되는지가 그 물음에 걸려 있다.
|
||||
|
||||
## 결정문
|
||||
|
||||
공개 터널을 쓰지 않고 tailnet 직결로 둔다. 기각한 설정 파일 `tunnel/cloudflared-config.yml` 은 지우지 않고 채택하지 않은 선택지로 남긴다.
|
||||
|
||||
조건이 바뀌어 공개 접근이 필요해지면(예를 들어 다른 회선으로 이전) 그 파일을 그대로 쓴다.
|
||||
|
||||
## 판단 이유
|
||||
|
||||
Cloudflare named tunnel 은 아웃바운드 연결만 쓰므로 포트포워딩 없이 공개 HTTPS 이름을 얻는다. 공유기를 건드릴 수 없는 환경에서는 매력적인 선택지이고, §305 는 그 설정의 함정 둘까지 적어 두었다. `service:` 에 `127.0.0.1` 을 쓰면 cloudflared 컨테이너 자신을 가리키므로 Compose 서비스 DNS 이름을 써야 하고, 마지막 catch-all 이 없으면 오류가 난다.
|
||||
|
||||
기각 근거는 홉 수다. 터널을 쓰면 브라우저가 Cloudflare 엣지와 nginx 와 Traefik 을 지나 파드에 닿아 3홉이 된다. 지금은 브라우저가 nginx 와 Traefik 만 지나 파드에 닿는 2홉이다. Cloudflare 엣지가 TLS 를 끊고 다시 맺으면서 HTTP 를 읽는 홉이 하나 늘고 `CF-Connecting-IP` 같은 자체 헤더가 섞인다. 이 실험대가 측정하려는 것이 정확히 `nginx → Traefik` 2홉의 forwarded 헤더 계약이므로, 앞에 한 겹이 더 붙으면 측정이 오염된다.
|
||||
|
||||
터널이 무엇을 해 주는지가 아니라 그것이 이 실험대의 측정 대상에 무엇을 하는지를 보고 정했다.
|
||||
|
||||
## 영향
|
||||
|
||||
진입 경로가 2홉으로 고정된다. 밖에서 들어오는 요청을 처음 받는 프록시가 하나뿐이라 신뢰 경계가 한 곳이고, forwarded 헤더를 누가 쓰는지가 갈리지 않는다.
|
||||
|
||||
감수한 비용은 진입 주소다. `dig` 가 내놓는 `100.83.212.4` 는 `100.64.0.0/10` 안에 있고 그 대역이 CGNAT 용으로 예약돼 있어 공개 인터넷에서 라우팅되지 않는다.
|
||||
|
||||
그 대가가 두 곳에서 청구됐다.
|
||||
인증서 발급 : HTTP-01 로 받을 수 없어 DNS-01 로 갔다
|
||||
재구축 : 인증서 디렉터리 `/etc/letsencrypt/` 를 지워도 되는지가 미확정이다
|
||||
|
||||
기각한 파일을 지우지 않는 방침에도 근거가 셋 있다.
|
||||
① 저장소의 목적이 비교다 : 선택지를 나란히 두고 트레이드오프를 적는 것 자체가 산출물이라, 하나만 남기면 왜 이것을 골랐는지를 뒷받침할 근거가 없어진다
|
||||
② 죽은 코드가 아니라 테스트되는 코드다 : `scripts/verify-public-tunnel-config.sh` 가 붙어 있어 실행되지 않을 뿐 깨지면 드러난다
|
||||
③ 실험대 전용 설정은 `lab/` 아래로 분리했다 : 일반 배포 설정과 섞이지 않는다
|
||||
|
||||
재지 않은 것도 있다. 터널을 붙인 상태와 지금을 같은 방법으로 잰 비교는 이 저장소에 없다. 기각의 근거는 홉 수와 섞이는 헤더이지 두 구성을 재서 견준 값이 아니다. 인증서를 지금 어느 방식으로 받고 있는지도 이 실험대에서 아직 재지 않았다.
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
---
|
||||
kind: REFERENCE
|
||||
slug: overwrite-a-forwarded-header-at-the-trust-boundary-do-not-extend-it
|
||||
title: 신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다
|
||||
topic: lab-entry-path-and-measurement-integrity
|
||||
topicName: 실험대의 진입 경로
|
||||
project: virtualization
|
||||
status: 게시 전
|
||||
sourceRevision: 9465582b5d1630eb4ae7c4e078021486919bf6b6
|
||||
source:
|
||||
- final/document.md#209-nginx-keycloak-lab-conf-스티키-스위치와-신뢰-경계
|
||||
- final/document.md#259-x-forwarded--와-신뢰-경계
|
||||
- final/document.md#207-lab-edge-dnat-nft-dnat-파일이-자기-안에-적어-둔-네-가지
|
||||
- final/document.md#206-이-부의-출처와-범위
|
||||
---
|
||||
|
||||
# 신뢰 경계에서는 forwarded 헤더를 덧붙이지 않고 덮어쓴다
|
||||
|
||||
맨 바깥 프록시는 클라이언트가 보낸 `X-Forwarded-For` 를 버리고 자기가 본 주소로 덮어쓴다. nginx 에서는 `$proxy_add_x_forwarded_for` 가 아니라 `$remote_addr` 를 쓴다는 뜻이다. 커널 쪽에도 같은 계약의 절반이 있어서, 경계 앞에서는 출발지 주소를 바꾸지 않는다.
|
||||
|
||||
## 관계
|
||||
|
||||
- **L7 이 두 겹인 이유와, 진입점 하나를 이중화하려 할 때 끝나지 않는 재귀**
|
||||
이 기준이 말하는 경계가 두 홉 가운데 어느 쪽인지를 그 글이 먼저 정한다.
|
||||
- **엣지 게스트에 nginx 를 세우고 호스트 DNAT 으로 밖에서 들어오는 길을 연다**
|
||||
이 기준이 적용되는 설정을 실제로 쓰는 절차다. 헤더 네 줄과 DNAT 규칙이 거기 있다.
|
||||
- **엣지 nginx 를 호스트에서 게스트 VM 으로 옮긴다 — 성능이 아니라 더러워지는 층의 격리**
|
||||
그 이동으로 경계가 호스트에서 엣지 게스트로 옮겨 갔다.
|
||||
- **공개 터널을 쓰지 않고 tailnet 직결로 둔다 — 홉이 하나 늘면 재려던 계약이 오염된다**
|
||||
경계 앞에 한 겹을 더 세우지 않기로 한 결정이다. 그 결정의 이유가 이 실험대가 재려는 계약이다.
|
||||
- **호스트 안에서는 404, 밖에서는 connection refused — 앞 체인의 accept 가 libvirt 의 reject 를 막지 못했다**
|
||||
경계 프록시가 아닌 경로를 막는 일이 방화벽의 몫이라는 것을 그 기록이 보여 준다.
|
||||
|
||||
## 목적
|
||||
|
||||
`X-Forwarded-For` 는 누구나 보낼 수 있는 평범한 HTTP 헤더다. 값을 믿을 만하게 만드는 것은 헤더 이름이 아니라 그 값을 쓴 주체이고, 쓰는 주체를 하나로 좁히는 것이 이 기준이다. 맨 바깥 프록시가 클라이언트의 값을 이어 붙이면 위조된 값이 사슬 앞에 남고, 그러면 뒤쪽의 어느 것도 그 헤더를 근거로 쓸 수 없다.
|
||||
|
||||
설정 정본이 그 줄 위에 이유를 적어 두었다. 원문은 영어이고 그대로 옮긴다.
|
||||
|
||||
$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.
|
||||
|
||||
§206 은 설정 원본의 주석 59줄을 대조하면서 이 저장소 어디에도 없는 것 둘을 셌고, 이 설명이 그중 하나다. §189 과 §190 은 그 줄을 옮겨 적기만 하고 왜 그 형태여야 하는지는 적지 않았다.
|
||||
|
||||
## 규칙
|
||||
|
||||
### 1. 맨 바깥 프록시는 클라이언트가 보낸 `X-Forwarded-For` 를 버리고 자기가 본 주소로 덮어쓴다
|
||||
|
||||
nginx 에서는 `proxy_set_header X-Forwarded-For $remote_addr;` 이고 `$proxy_add_x_forwarded_for` 가 아니다. 둘의 차이는 클라이언트가 보낸 값을 사슬 앞에 남기느냐 버리느냐에 있다. `proxy_set_header` 가 값을 이어 붙이지 않고 덮어쓰는 지시어인 이유도 여기에 있다.
|
||||
|
||||
### 2. `X-Forwarded-Proto` 와 `X-Forwarded-Host` 도 같은 규칙으로 다룬다
|
||||
|
||||
셋 다 프록시가 뒤쪽 서버에게 원래 클라이언트가 어땠는지 알려 주는 관례적 헤더이고, 클라이언트가 임의로 보낼 수 있다. 하나만 덮어쓰고 나머지를 이어 붙이면, 뒤쪽 서버는 클라이언트가 보낸 값을 그대로 근거로 삼는다. 공격자가 `X-Forwarded-Host` 를 조작해 인증 흐름을 자기 도메인으로 돌릴 수 있는 것도 이 경로다.
|
||||
|
||||
### 3. 경계 앞에서는 출발지 주소를 바꾸지 않는다
|
||||
|
||||
호스트의 DNAT 파일은 DNAT 만 걸고 SNAT 는 걸지 않는다. 파일 자신이 「DNAT only, never SNAT」이라고 적고 그 이유를 바로 뒤에 붙였다. masquerade 를 걸면 출발지가 다시 쓰여 엣지가 모든 클라이언트를 `192.168.122.1` 로 보게 되고, 그러면 이 실험대가 재는 `X-Forwarded-For` 계약이 조용히 무효가 된다. SNAT 없이도 응답이 돌아오는 것은 게스트의 기본 경로가 호스트이기 때문이다. 응답이 호스트를 다시 지나고, conntrack 이 변환을 알아서 되돌린다. 경계 앞에서는 출발지를 바꾸지 않고 경계에서는 클라이언트가 준 값을 버린다는 두 문장이 한 계약이다.
|
||||
|
||||
### 4. 경계가 어디인지는 「그 앞에 우리가 통제하지 않는 것이 있는가」로 가른다
|
||||
|
||||
이름에 엣지가 붙었다고 해서 그 프록시가 경계가 되는 것은 아니다. 우리가 통제하지 않는 쪽에서 요청을 직접 받는 첫 프록시가 경계이고, 그 한 대만 이 기준을 따른다. 이 실험대에서는 엣지 게스트의 nginx 가 그렇고, 클라우드라면 ALB 가 같은 곳에 선다.
|
||||
|
||||
### 5. 프록시를 한 겹 더 넣거나 옮길 때 이 기준을 다시 적용한다
|
||||
|
||||
설정을 처음 쓸 때보다 구성을 바꿀 때 이 기준이 실제로 쓰인다. 이 실험대가 엣지를 호스트에서 게스트로 옮겼을 때 경계도 함께 옮겨 갔다. 공개 터널을 앞에 붙였다면 경계가 Cloudflare 엣지로 한 번 더 옮겨 갔을 것이다. 옮겨 간 뒤에도 옛 경계가 클라이언트의 값을 이어 붙이고 있으면 사슬은 다시 믿을 수 없게 된다.
|
||||
|
||||
## 적용 조건
|
||||
|
||||
- 신뢰 경계에 선 맨 바깥 프록시 한 대. 이 실험대에서는 엣지 게스트의 nginx 이고, 클라우드라면 ALB 가 같은 곳에 선다
|
||||
- 대상 헤더 : `X-Forwarded-For` · `X-Forwarded-Proto` · `X-Forwarded-Host` 셋. 셋 다 관례적 헤더이고 클라이언트가 임의로 보낼 수 있다
|
||||
- 적용 시점 : 설정을 처음 쓸 때가 아니라 프록시를 한 겹 더 넣거나 옮길 때
|
||||
- 경계 판정 : 그 프록시 앞에 우리가 통제하지 않는 것이 있을 때
|
||||
|
||||
## 예외
|
||||
|
||||
경계 안쪽의 두 번째 홉은 반대다. 이 실험대의 Traefik 처럼 신뢰하는 프록시 뒤에 서는 것은 앞이 쓴 값을 이어받아야 하고, 거기서 덮어쓰면 원래 클라이언트 주소가 없어진다. `$proxy_add_x_forwarded_for` 자체가 틀린 값은 아니고, 쓰는 곳이 따로 정해져 있다. 경계에서 쓰면 클라이언트가 위조한 값을 그대로 통과시킨다.
|
||||
|
||||
이 실험대는 그 두 번째 홉을 아직 재지 않았다. 저장소에 적힌 forwarded 헤더 계약이 1홉을 가정한 것이라 `nginx → Traefik` 2홉과 어긋나고, Traefik 이 앞이 쓴 값을 덮어쓰는지 신뢰하는지 이어 붙이는지에 따라 결과가 갈린다. 이 실험대가 가장 먼저 실측할 항목이 그것이다.
|
||||
|
||||
L4 통과 구성에는 적용되지 않는다. NLB 처럼 TCP 를 그대로 흘리면 원본 IP 가 보존되어 헤더가 아예 필요 없다. 그때 원본 주소를 알리는 데 쓰는 것은 PROXY protocol 이라 이 기준의 대상이 아니다.
|
||||
|
||||
경계 앞에 CDN 이나 터널이 있으면 그 공급자의 헤더가 정본이 된다. Cloudflare 라면 `CF-Connecting-IP` 이고, 그때는 그 헤더를 놓고 이 기준을 다시 세운다.
|
||||
|
||||
이 기준만으로 신뢰가 완성되지 않는다. 경계 프록시를 거치지 않는 경로로 뒤쪽에 직접 닿을 수 있으면 헤더를 어떻게 쓰든 소용이 없다. 그 경로를 막는 것은 방화벽과 네트워크 배치의 일이고, 이 실험대에서는 게스트가 libvirt NAT 뒤에 있는 것이 그 몫을 한다.
|
||||
|
||||
## 예시
|
||||
|
||||
- 경계의 nginx : `proxy_set_header X-Forwarded-For $remote_addr;`
|
||||
- 경계 안쪽 두 번째 홉 : `$proxy_add_x_forwarded_for` 로 앞이 쓴 값을 이어받는다
|
||||
- 호스트 커널 : DNAT 만 걸고 masquerade 는 걸지 않는다
|
||||
- masquerade 를 걸었을 때 엣지가 보는 클라이언트 주소 : `192.168.122.1` 하나
|
||||
- 경계 앞에 Cloudflare 가 있을 때의 정본 헤더 : `CF-Connecting-IP`
|
||||
- 클라이언트가 보낸 값을 이어 붙인 사슬 : 위조된 값이 앞에 남아 뒤쪽에서 근거로 못 쓴다
|
||||
- L4 통과 구성 : 헤더가 필요 없고 PROXY protocol 을 쓴다
|
||||
Reference in New Issue
Block a user