跳到内容
观澜涉川 · 札记EN

让 HAProxy 和 Caddy 之后的应用拿到客户端 IP

· 约 5 分钟阅读

假定部署结构如下:

  • 客户端 → HAProxy → Caddy → 应用
  • HAProxy 用 mode tcp 转发 443
  • Caddy 持有证书并终止 TLS
  • 应用只监听回环

这种结构下,应用默认只能看到 Caddy 的地址(127.0.0.1)。要拿到真实的客户端 IP,需要把地址分两跳传下去:

  1. HAProxy 通过 PROXY protocol 把源地址带到 Caddy;
  2. Caddy 把它写进 X-Forwarded-For;
  3. 应用读取这个头。
客户端 IP 在 HAProxy、Caddy 与应用之间的传递链路 客户端通过 TLS 连接 HAProxy;HAProxy 以 TCP 转发到 Caddy,并在连接开头附加 PROXY protocol 头;Caddy 终止 TLS、采用源地址并写入 X-Forwarded-For;应用读取该头得到真实客户端 IP。 客户端 x.x.x.x TLS 握手 + 加密数据 HAProxy :443 mode tcp:只转发密文 读不到 HTTP,加不了 XFF PROXY protocol PROXY TCP4 x.x.x.x … Caddy :8453 proxy_protocol 采用源地址 再写入 X-Forwarded-For X-Forwarded-For x.x.x.x 应用 :9000 读取 XFF 最右一条 不要读 TCP 对端(ConnectInfo)
图:客户端 IP 的两跳传递

地址如何走过两跳

跳传递方式作用
客户端 → HAProxyTLSHAProxy 在 TCP 层看到客户端源地址
HAProxy → CaddyPROXY protocolTLS 透传时把源地址带给 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-v2

send-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:

Terminal window
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, ssl
s = 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.7Caddy 已采用,应用也读取正确;再确认 HAProxy 配置并 reload
127.0.0.1Caddy 解析了头但没采用,检查 allow / fallback_policy
报错 / 挂住listener_wrappers 没生效,检查顺序和 reload

③ 端到端——从公网访问:

Terminal window
curl -s https://app.example.com/api/v1/ip/addresses/self # 应是真实公网 IP

注意事项

  • 不要信任 XFF 的最左端。那是调用方自己填的,只有最右侧、由可信代理写入的地址才可信。

  • Caddy 默认不记录访问日志,只记错误和警告。需要观察 Caddy 看到的客户端时,可以临时加一个响应头,验完记得删掉:

    header X-Caddy-Saw {remote_host}

参考