forward proxy와 CONNECT의 실체(L0), ACL 시스템과 first-match 평가(L1). 모든 출력은 Ubuntu 24.04 + Squid 6.14 컨테이너에서 실측한 값이다.
1784336987.589 1 127.0.0.1 TCP_MISS/200 296 GET http://origin.lab:8080/ - HIER_DIRECT/127.0.0.1
1784337051.449 6 127.0.0.1 TCP_TUNNEL/200 2167 CONNECT origin.lab:8443 - HIER_DIRECT/127.0.0.1
1784337051.460 0 127.0.0.1 TCP_DENIED/403 3352 CONNECT origin.lab:9999 - HIER_NONE/-
한 Squid, 세 가지 결말 — 중계(MISS) · 터널(TUNNEL) · 정책 거부(DENIED). 이 문서는 이 세 줄을 읽는 법을 다룬다.
목적지 전체를 요청 라인에 실어 보내는 것 — forward proxy의 전부는 여기서 시작한다.
보안망은 워크로드가 외부와 직접 연결되는 것을 금지한다. 대신 외부로 나가는 모든 요청이 통과해야 하는 단일 관문을 세우고, 방화벽은 "관문 → 외부"만 연다. 관문이 하나로 모이면 정책(허용 도메인), 감사(접속 로그), 캐시(응답 재사용)가 따라온다.
forward proxy는 클라이언트의 대리인(클라이언트가 프록시의 존재를 알고 명시적으로 사용, 목적지는 임의 서버) — reverse proxy는 서버의 대리인(클라이언트는 프록시인 줄 모름, 목적지는 뒤에 숨은 특정 서버)이다. nginx/Envoy로 익숙한 쪽은 후자, Squid의 본업은 전자.
프록시에게 말할 때도 그냥 HTTP다. 다른 것은 요청 라인의 request-target 형태 하나(RFC 9112):
| 형태 | 예시 | 언제 |
|---|---|---|
| origin-form | GET / HTTP/1.1 | 서버에 직접 (평소) |
| absolute-form | GET http://origin.lab/ HTTP/1.1 | 프록시에 http:// 요청 |
| authority-form | CONNECT origin.lab:443 HTTP/1.1 | 프록시에 터널 요청 |
직접 요청은 목적지가 TCP 연결 자체에 담겨 있어 경로만 보내면 되지만, 프록시 요청은 TCP가 프록시로 가 있으므로 목적지 전체를 요청 라인 안에 문자열로 실어야 한다.
HTTP_PROXY 환경변수는 OS/커널 기능이 아니라 각 클라이언트 라이브러리가 자발적으로
따르는 관례다. curl · Python requests · Go net/http는 읽고, Java는 기본 무시(-Dhttp.proxyHost).
http_proxy는 "http:// URL에 쓸 프록시", https_proxy는 "https:// URL에 쓸 프록시"
(프록시 자체와의 연결은 두 경우 다 보통 평문 http). no_proxy 파싱 규칙은 구현마다 다르다 — 함정 0.A.
모드 1 — http://. Squid는 absolute-form을 끝까지 파싱한다. URL 수준 ACL, 캐시 판단이
가능하고, 요청을 다시 써서 보낸다: absolute-form → origin-form 변환 + Via,
X-Forwarded-For 추가. 오리진이 받는 요청은 클라이언트가 보낸 것과 바이트 수준에서 다르다.
모드 2 — https:// (CONNECT). 클라이언트는 TLS 시작 전에
CONNECT host:443을 보낸다. Squid는 ACL 판정 후 오리진에 raw TCP만 열고
200 Connection established를 돌려준 뒤 양방향 바이트 복사기가 된다.
TLS는 클라이언트↔오리진 end-to-end — 증거는 curl에 보이는 인증서가 오리진 것(CN=origin.lab)
그대로라는 사실. Squid가 아는 것은 CONNECT 라인의 host:port 문자열, 그리고 그 host의 resolve는 Squid가 한다.
자급자족 랩: 로컬 오리진(HTTP :8080 / HTTPS :8443 self-signed) + /etc/hosts에
origin.lab 등록 + 최소 squid.conf. air-gap에서도 그대로 재현된다.
# 오리진 2대 + hosts
mkdir -p /root/lab/www && cd /root/lab && echo '<h1>hello from origin</h1>' > www/index.html
(cd www && python3 -m http.server 8080 &)
openssl req -x509 -newkey rsa:2048 -nodes -keyout origin.key -out origin.crt \
-days 30 -subj "/CN=origin.lab" -addext "subjectAltName=DNS:origin.lab"
# (https_server.py: ssl.SSLContext로 8443 서빙 — 본문 생략)
echo "127.0.0.1 origin.lab" >> /etc/hosts
# /etc/squid/squid.conf — L0 최소 랩 설정 (운영 금지)
http_port 3128
acl lab_net src 127.0.0.1/32
acl SSL_ports port 443 8443
acl CONNECT method CONNECT
http_access deny CONNECT !SSL_ports
http_access allow lab_net
http_access deny all
cache deny all
access_log /var/log/squid/access.log squid
실험 A — absolute-form 관찰. curl -v -x http://127.0.0.1:3128 http://origin.lab:8080/
# 실측 출력
> GET http://origin.lab:8080/ HTTP/1.1 ← 목적지가 요청 라인 안에
< HTTP/1.1 200 OK
< Via: 1.1 <host> (squid/6.14) ← Squid의 흔적
실험 B — CONNECT 터널 관찰. curl -v -x … --cacert origin.crt https://origin.lab:8443/
# 실측 출력
> CONNECT origin.lab:8443 HTTP/1.1
< HTTP/1.1 200 Connection established
* TLSv1.3 (OUT), TLS handshake, Client hello ← TLS는 터널 "안에서" 시작
* Server certificate: subject: CN=origin.lab ← 오리진 인증서 그대로 = end-to-end
실험 C — tcpdump로 재작성 검증. 프록시→오리진 구간(port 8080)만 캡처하면, 오리진이 받은 바이트에 클라이언트가 보낸 적 없는 헤더가 있다:
# 실측 출력 · 오리진 도착분
GET / HTTP/1.1 ← origin-form으로 되돌아감
Host: origin.lab:8080
Via: 1.1 <host> (squid/6.14) ← Squid가 추가
X-Forwarded-For: 127.0.0.1 ← Squid가 추가 (내부 IP 노출)
실험 D — CONNECT 로그는 부검 기록이다. 터널을 12초 열어두고 access.log 줄 수를 추적:
# 실측 출력
터널 open 후 4초 경과 — 새 로그 줄 수: 0 ← 살아있는 동안 로그 없음
터널 close 후 — 새 로그 줄 수: 1
1784341782.994 11970 … TCP_TUNNEL/200 1911 CONNECT origin.lab:8443 …
elapsed 11970ms = 정확히 터널 수명. 로그 한 줄은 트랜잭션 종료 시점의 부검 기록이라
duration과 바이트를 담을 수 있다. 바이트 필드는 Squid가 클라이언트 방향으로 보낸 총량
(200 응답 라인 + 서버→클라로 릴레이된 TLS 핸드셰이크·암호화 레코드). HTTP 요청 없이 터널만 열면
본문만큼 줄어든다(2167 → 1911로 실측).
Go는 CIDR을 이해하지만 다수 구현은 도메인 접미사 매칭만 한다. 와일드카드·포트 지원도 제각각. 증상: 같은 NO_PROXY인데 특정 언어 앱만 내부 트래픽이 Squid로 흘러 403 또는 지연 증가.
기본값으로 내부 클라이언트 IP와 프록시 호스트명이 외부 오리진에 전달된다. 보안 심사 단골 지적.
통제: via off · forwarded_for delete.
웹소켓·gRPC 스트림 등 오래 사는 CONNECT는 살아있는 동안 access.log에 한 줄도 없다. "로그에 없다 = 트래픽 없다"는 오판. 실시간은 mgr: 인터페이스(L6)로. 장애로 터널이 일제히 끊기면 그 순간 로그가 한꺼번에 쏟아지는 것도 같은 원리.
could not resolve host로 더 일찍 실패.
"timeout인가 resolve 실패인가"가 첫 분기.ACL은 어휘, http_access는 문장. 그리고 문장은 위에서부터 딱 하나만 읽힌다.
acl 줄은 조건에 이름을 붙이는 정의, http_access 줄은 이름들을 조합한
판정 규칙이다. 분리 이유: 같은 조건이 여러 판정에 재사용되고, 나중에 배울
ssl_bump · cache · cache_peer_access 같은 다른 판정 지시자들도 같은 ACL을 쓴다.
acl <이름> <타입> <값1> <값2> ...
# 같은 이름의 acl 줄 여러 개 = 값이 하나의 ACL로 병합
# acl 정의 줄끼리는 순서 무의미 / http_access 줄들은 순서가 전부
# 주요 타입: src dst dstdomain dstdom_regex port method proto
① ACL 내부의 값들은 OR — acl SSL_ports port 443 8443은 둘 중 하나면 참.
② 한 줄 위의 ACL들은 AND — 전부 참일 때만 그 줄이 발동, !는 부정.
③ 줄들 사이는 first-match — 처음 발동한 줄의 allow/deny로 즉시 종결, 아래는 존재하지 않는 것과 같다.
그리고 ④ 끝까지 아무 줄도 발동하지 않으면 마지막 줄의 반대가 암묵 기본값 — 여기에 보안을
맡기지 않으려고 모든 운영 설정이 http_access deny all로 끝난다.
선행 점: .origin.lab(점 있음)은 origin.lab 과 모든 서브도메인 매칭,
origin.lab(점 없음)은 정확히 그 호스트만. URL에 IP가 오면: dstdomain은 도메인
문자열 비교라서 Squid가 그 IP를 rDNS로 도메인으로 바꿔 비교를 시도한다. 실제 인터넷의 PTR은
대개 클라우드/ISP 이름 → 매칭 실패 → 403. rDNS는 별도 DNS 조회가 필요한 slow ACL이라 지연도 얹힌다(L3).
실습 1·2 — first-match 순서 버그. forbidden.lab을 차단 대상으로 두고,
deny bad_sites를 광범위 allow lab_net의 아래 vs 위에 배치:
# 실측 출력
[v1: allow가 deny보다 위] forbidden.lab → HTTP 200 ← 차단 규칙이 죽어 있다 (dead rule)
[v2: deny를 위로 이동] forbidden.lab → HTTP 403
origin.lab → HTTP 200
1784359692.327 0 127.0.0.1 TCP_DENIED/403 3374 GET http://forbidden.lab:8080/ - HIER_NONE/-
TCP_DENIED + HIER_NONE(오리진에 아예 안 나감) = 이 403의 발원지는 오리진이 아니라
Squid 정책이다. L9 장애 진단의 첫 번째 축.
실습 3 — 암묵 기본값 실측. allow 줄과 deny all을 전부 지우고 deny 두 줄만 남기면:
# 실측 출력
[v3: 매치되는 줄 없음] origin.lab → HTTP 200 ← allow가 하나도 없는데 통과
마지막 줄이 deny라서 기본값이 allow가 됐다. deny 규칙으로만 채운 방화벽은 문이 열려 있는 상태 —
deny all을 항상 명시하는 이유의 실물.
실습 4 — 문법 검증기. 정의 안 된 ACL을 참조하면:
# 실측 출력
ERROR: ACL not found: no_such_acl
FATAL: Bungled /tmp/broken.conf line 9: http_access deny no_such_acl
FATAL이 뜨는 설정으로 restart하면 Squid는 아예 안 뜬다(reload는 기존 설정으로 버틴다 — L7).
운영 절차는 항상 squid -k parse → reload 순서.
실습 5 — 선행 점과 IP 요청. 화이트리스트를 .origin.lab으로 두고 세 가지 호출:
# 실측 출력
http://origin.lab:8080/ → HTTP 200 ← .origin.lab이 자기 자신 매칭
http://sub.origin.lab:8080/ → HTTP 200 ← 서브도메인 매칭
http://127.0.0.2:8080/ → HTTP 403 ← IP 요청: rDNS 실패 → deny all
흥미: 같은 랩에서 http://127.0.0.1:8080/은 200이 나왔다 —
/etc/hosts의 127.0.0.1 origin.lab 덕에 rDNS가 origin.lab을 돌려줬기 때문.
같은 IP 요청이라도 rDNS 결과에 따라 판정이 뒤집힌다 = dstdomain의 IP 매칭은
내가 통제하지 않는 PTR 레코드에 의존하는 도박.
"차단 티켓 처리했는데 여전히 접속됩니다" 대부분은 위쪽 광범위 allow가 먼저 먹는 경우. 반대 방향(위쪽 광범위 deny 때문에 새 allow가 안 먹어 403)도 똑같이 흔하다. 수백 줄 conf에서는 규칙을 추가할 위치가 규칙 내용보다 어렵다.
SDK의 IP 직결 호출, 하드코딩된 IP는 rDNS가 기대 도메인을 안 돌려주는 한 403. 증상이 "같은 API인데 어떤 앱만 403"으로 나타나 순서 문제로 오인하기 쉽다. access.log URL 필드에 도메인 대신 IP가 찍혀 있으면 이 케이스.
acl internal src 10.0.0.0/8
acl payments dstdomain .pay.example
acl SSL_ports port 443
acl CONNECT method CONNECT
http_access deny CONNECT !SSL_ports
http_access allow internal payments
http_access deny all
10.0.0.7 → CONNECT pay.example:443 ·
(b) 10.0.0.7 → CONNECT pay.example:8443 ·
(c) 192.168.1.5 → GET http://pay.example/
http_access allow monitoring_net을 추가했는데 여전히 403.
원인과 확인 방법은?
deny all 또는 광범위 deny)가 먼저 매치
— 특히 deny all 아래에 추가했다면 그 줄은 영원히 실행되지 않는다.
확인: access.log에서 TCP_DENIED + HIER_NONE으로 Squid 정책 거부임을 못박고,
conf에서 monitoring_net 줄과 그 위 deny 줄들의 상대 위치를 본다.| 반사신경 | 내용 |
|---|---|
| 평가 3층 | 값 OR → 줄 AND(!=부정) → 줄간 first-match. 무매치면 마지막 줄의 반대. |
| 403 + HIER_NONE | 거부의 발원지는 오리진이 아니라 Squid 정책(ACL). |
| CONNECT 로그 | 터널 종료 시 기록. elapsed=터널 수명, 바이트=클라 방향 전달 총량. |
| dstdomain | .d.com=자신+서브도메인, d.com=정확히 그 호스트. IP 요청은 rDNS 의존. |
| 설정 변경 절차 | squid -k parse(문법) → squid -k reconfigure(reload). FATAL 설정으로 restart 금지. |
| 프록시 경유 테스트 | curl -v -x http://proxy:3128 URL — 요청 라인 형태(absolute/CONNECT)부터 본다. |
| env 변수 | 라이브러리 관례일 뿐(Java는 무시). https_proxy=https:// URL용 프록시. no_proxy 파싱은 구현차. |