让 HAProxy 和 Caddy 之后的应用拿到客户端 IP
假定部署结构如下:
- 客户端 → HAProxy → Caddy → 应用
- HAProxy 用
mode tcp转发 443 - Caddy 持有证书并终止 TLS
- 应用只监听回环
这种结构下,应用默认只能看到 Caddy 的地址(127.0.0.1)。要拿到真实的客户端 IP,需要把地址分两跳传下去:
- HAProxy 通过 PROXY protocol 把源地址带到 Caddy;
- Caddy 把它写进
X-Forwarded-For; - 应用读取这个头。
地址如何走过两跳
| 跳 | 传递方式 | 作用 |
|---|---|---|
| 客户端 → HAProxy | TLS | HAProxy 在 TCP 层看到客户端源地址 |
| HAProxy → Caddy | PROXY protocol | TLS 透传时把源地址带给 Caddy |
| Caddy → 应用 | X-Forwarded-For | 应用从 HTTP 头读取真实 IP |
HAProxy 工作在 mode tcp,只看得到 TCP 字节和 TLS ClientHello 里的 SNI,读不到 HTTP,因此无法添加 X-Forwarded-For。Caddy 终止 TLS 后能看到 HTTP,但它默认不采信上游送来的 XFF(文档):
For these X-Forwarded-* headers, by default, the proxy will ignore their values from incoming requests, to prevent spoofing.
所以分工是:PROXY protocol 补上 HAProxy 这一跳,XFF 负责 Caddy 到应用这一跳。
配置 HAProxy
在 frontend 上绑定 443,在指向 Caddy 的 backend 上加 send-proxy-v2:
frontend fe_https mode tcp bind *:443 default_backend be_caddy
backend be_caddy mode tcp option tcplog server caddy 127.0.0.1:8453 send-proxy-v2send-proxy-v2 只加在 backend 的 server 行上。它会让 HAProxy 在每条连接的开头、TLS 握手之前,写入一段包含客户端源地址的 PROXY v2 头。
配置 Caddy
在 Caddyfile 最上面的 global options 里启用 proxy_protocol listener wrapper,并把它放在 tls 之前:
{ servers { listener_wrappers { proxy_protocol { allow 127.0.0.1/32 } tls } }}
app.example.com:8453 { reverse_proxy 127.0.0.1:9000}几个关键点:
proxy_protocol必须在tls之前。它在 TLS 握手之前读取明文前缀,顺序反了就解析不到。allow指定可信来源。127.0.0.1/32表示只采用同机 HAProxy 送来的 PROXY 头;如果 HAProxy 改用::1连接,这里要写::1/128。fallback_policy默认是ignore。不在allow列表里的来源,连接照收,但 PROXY 头里的地址会被丢弃。因此allow不能省略,也不要把默认策略改成use,否则任何人都能伪造来源地址。servers块只对 Caddyfile 中实际存在的站点生效,所以上面保留下面的站点块;改完配置后 reload。reverse_proxy默认会向上游写X-Forwarded-For、X-Forwarded-Proto和X-Forwarded-Host,不需要额外的header_up。其中X-Forwarded-Proto取自 Caddy 收到的请求协议(这里是https),X-Forwarded-Host取自请求的Host;这三个头都由 Caddy 生成,不是从 HAProxy 透传的。如果站点块里手写了header_up X-Forwarded-For ...,它会覆盖默认行为,应当删掉。
这里不需要配置 trusted_proxies。它控制的是是否采信上游通过 HTTP 头送来的地址,而不是是否生成这些头。proxy_protocol 已经在 TCP 层把连接对端换成了真实客户端,Caddy 直接以这个地址为准;没有 trusted_proxies 时,入站的头会被忽略并重新生成,正是这条链路想要的结果。
配置应用
应用读取 X-Forwarded-For,取最右边一条。以 Rust 为例:
use std::net::IpAddr;
fn client_ip(xff: &str) -> Option<IpAddr> { xff.rsplit(',').next()?.trim().parse().ok()}这条链路上,Caddy 会忽略入站请求里的 XFF,按自己看到的客户端地址重写这个头,所以右起第一条就是真实客户端;最左边那条是调用方自己写的,永远不可信。
如果应用需要还原原始协议或 Host(例如生成绝对 URL),可以同时读取 X-Forwarded-Proto 和 X-Forwarded-Host。
如果应用读的是 TCP 对端(例如 axum 的 ConnectInfo),代理之后只能拿到 Caddy 的地址(这里是 127.0.0.1),必须改成读 XFF。
验证配置
下面用 203.0.113.7 作为示例地址;它属于 RFC 5737 保留的文档专用网段,不会与真实主机冲突。
① 应用层——跳过 Caddy,直接打应用,注入一条 XFF:
curl -s -H 'X-Forwarded-For: 203.0.113.7' http://127.0.0.1:9000/api/v1/ip/addresses/self# {"ip":"203.0.113.7"} → 应用读取 XFF 正确# {"ip":"127.0.0.1"} → 应用读的是 ConnectInfo② Caddy 层——跳过 HAProxy,自己发一个 PROXY 头(curl --haproxy-clientip 需要 curl ≥ 8.2,旧版可用这段 Python):
import socket, ssls = socket.create_connection(("127.0.0.1", 8453))s.sendall(b"PROXY TCP4 203.0.113.7 127.0.0.1 40000 8453\r\n")c = ssl.create_default_context().wrap_socket(s, server_hostname="app.example.com")c.sendall(b"GET /api/v1/ip/addresses/self HTTP/1.1\r\nHost: app.example.com\r\nConnection: close\r\n\r\n")print(c.recv(4096).decode(errors="replace").split("\r\n\r\n")[-1])这里发的是 PROXY v1 头,Caddy 的 wrapper 同时支持 v1 和 v2。
| 输出 | 含义 |
|---|---|
203.0.113.7 | Caddy 已采用,应用也读取正确;再确认 HAProxy 配置并 reload |
127.0.0.1 | Caddy 解析了头但没采用,检查 allow / fallback_policy |
| 报错 / 挂住 | listener_wrappers 没生效,检查顺序和 reload |
③ 端到端——从公网访问:
curl -s https://app.example.com/api/v1/ip/addresses/self # 应是真实公网 IP注意事项
-
不要信任 XFF 的最左端。那是调用方自己填的,只有最右侧、由可信代理写入的地址才可信。
-
Caddy 默认不记录访问日志,只记错误和警告。需要观察 Caddy 看到的客户端时,可以临时加一个响应头,验完记得删掉:
header X-Caddy-Saw {remote_host}