起因
帐号中心(Logto)的审计日志里,每一条记录的 IP 都是同一个内网地址:本地服务器自己。Nginx Proxy Manager(下文简称 NPM)的访问日志也一样,看不到任何访客的真实地址。这样一来,登录记录、限流、异常排查都失去了意义。
站点的入口链路是这样的:
访客 → 阿里云服务器(frps)→ 本地服务器上的 frpc → NPM(Docker)→ 各个服务
难点在于这条线路一直在用,全站的服务都挂在上面。直接改 frpc 配置再重启,所有服务都会断开,所以这次的要求是:尽量不中断地完成切换。
为什么 IP 全是本机
frp 的 tcp 类型隧道只转发字节流。对 NPM 来说,连接是 frpc 从本机发起的,它当然只能看到本机地址。Logto 虽然开了 TRUST_PROXY_HEADER,会读 X-Forwarded-For,但 NPM 写进去的就是它看到的地址,也就是本机。
HTTPS 流量在 frp 这一层是加密的,frpc 没法往里插 HTTP 头。能用的办法只有 Proxy Protocol:frpc 在每个 TCP 连接的最前面附上一小段头部,里面写着访客的原始地址;接收方读出这段头部,再把它当作客户端地址。
前提是两端要同时改:frpc 发 Proxy Protocol,NPM 按 Proxy Protocol 接收。只改一边,连接会直接失败。这也是不能在原端口上直接改的原因。
思路:先开一条并行的新通道
不动原来的 443,另开一套新入口,验证没问题后再把公网端口切过去:
- NPM 在原有的 443 之外,额外监听一个只接收 Proxy Protocol 的端口 8443;
- frpc 新增一条带 Proxy Protocol 的隧道指向 8443,先挂在一个测试用的公网端口上;
- 测试通过后,把新旧两条隧道的公网端口对调,并用热重载生效。
第一步:NPM 额外监听 8443
NPM 支持自定义配置片段:/data/nginx/custom/server_proxy.conf 会被引入到每个代理主机的 server 块里。在宿主机上找到这个容器目录对应的挂载路径,新建文件:
listen 8443 ssl proxy_protocol;
set_real_ip_from 192.168.1.0/24;
set_real_ip_from 172.16.0.0/12;
real_ip_header proxy_protocol;
listen 8443 ssl proxy_protocol:给所有站点多加一个监听端口,证书沿用各站点自己的配置;set_real_ip_from:只信任来自本机局域网和 Docker 网段的 Proxy Protocol 头,其他来源的不认;real_ip_header proxy_protocol:用头部里的地址替换客户端地址。之后 NPM 照常把这个地址写进X-Forwarded-For,后面的服务不需要任何改动。
原来的 443 完全没变,局域网直连 NPM 的请求也不受影响。先检查语法,再热重载:
docker exec nginx-proxy-manager-app-1 nginx -t
docker exec nginx-proxy-manager-app-1 nginx -s reload
这里踩了个坑:我写好文件、重载完成之后,顺手把之前准备好的「撤销命令」也执行了,文件被删掉了。好在 nginx 已经重载过,内存里的配置还在,重新创建文件、再重载一次就好了。这个文件之后就是线上依赖,删掉后 NPM 下次重载时 8443 会消失,公网 HTTPS 就会全断。
容器里没有 netstat 和 ss,可以直接查 /proc/net/tcp 确认端口在监听(8443 的十六进制是 20FB,0A 表示 LISTEN):
docker exec nginx-proxy-manager-app-1 sh -c "grep -i ':20FB 00000000:0000 0A' /proc/net/tcp /proc/net/tcp6"
第二步:让 frpc 支持热重载
改隧道要让 frpc 重新读取配置。systemctl restart 会断开全部隧道;frpc 自带的 frpc reload 只重建有变化的隧道,但它依赖管理接口 webServer。原配置里没开,所以这里必须重启一次。这是整个过程中唯一一次全站中断,大约 2~3 秒。
在 /etc/frp/frpc.toml 的最上面(所有 [[proxies]] 之前)加上:
webServer.addr = "127.0.0.1"
webServer.port = 7400
同时加上新的测试隧道。localIP 填的是 NPM 容器的 IP,因为 8443 没有映射到宿主机,宿主机可以直接访问容器 IP:
[[proxies]]
name = "web-https-pp"
type = "tcp"
localIP = "172.20.0.2"
localPort = 8443
remotePort = 8443
transport.proxyProtocolVersion = "v2"
改之前先备份,改完先校验,再重启:
sudo cp /etc/frp/frpc.toml /etc/frp/frpc.toml.bak-$(date +%Y%m%d%H%M%S)
sudo frpc verify -c /etc/frp/frpc.toml
sudo systemctl restart frpc
在阿里云安全组临时放行 8443,然后从外网测试:
curl -sS -o /dev/null -w '%{http_code}\n' --resolve account.0w0.online:8443:<阿里云公网IP> https://account.0w0.online:8443/api/status
返回 204,NPM 访问日志里的 Client 变成了发起请求那台机器的公网地址,说明整条链路已经通了。
frpc 日志里会时不时出现 work connection pool is full, discarding,只是连接池满了丢弃多余的预建连接,可以忽略。
第三步:对调端口,热重载切换
把旧隧道 web-https 的 remotePort 从 443 改成一个备用端口(这里用 18443),新隧道从 8443 改成 443:
[[proxies]]
name = "web-https"
type = "tcp"
localIP = "192.168.1.20"
localPort = 443
remotePort = 18443
[[proxies]]
name = "web-https-pp"
type = "tcp"
localIP = "172.20.0.2"
localPort = 8443
remotePort = 443
transport.proxyProtocolVersion = "v2"
然后热重载:
sudo frpc verify -c /etc/frp/frpc.toml && sudo frpc reload -c /etc/frp/frpc.toml
只有这两条 HTTPS 隧道被重建,SSH 和 80 端口的隧道完全没断。再用正常的 443 访问一次:
curl -sS -o /dev/null -w '%{http_code}\n' https://account.0w0.online/api/status
返回 204,NPM 日志里记录的是真实公网 IP。切换完成。
旧隧道暂时保留在 18443,用来回滚:把两条隧道的 remotePort 对调回去,再执行一次 frpc reload,就能恢复原来的行为。
重启会不会掉
- frpc 配置在磁盘上,
systemctl is-enabled frpc是enabled,开机自动启动; server_proxy.conf放在 NPM 挂载到宿主机的数据目录里,容器重启甚至重建都会重新加载;- 开机时 frpc 可能比 Docker 先起来。这没有关系:frpc 每来一个访客才去连一次 NPM,NPM 起来之前的几秒连不上,之后就正常了。
唯一的隐患是 localIP 写死了容器 IP 172.20.0.2。查了一下,NPM 所在的 Docker 网络里只有它一个容器,所以单纯重启主机或容器,IP 都不会变。但如果以后有别的容器加入这个网络,或者网络被重建(比如 docker compose down 后再 up、升级 NPM),IP 就可能变,公网 HTTPS 会直接断开。
根治的办法是下次升级 NPM 时顺手给它加一个 8443:8443 端口映射,再把 localIP 改成宿主机的局域网地址,以后就不用再管容器 IP 了。
小结
- frp 的 TCP 隧道会丢掉访客 IP;HTTPS 场景下只能用 Proxy Protocol 传递,而且收发两端必须同时支持;
- 生产线路不要原地改:先开一个并行的新端口,测试通过再切换;
- 先启用 frpc 的
webServer,以后改隧道一律用frpc reload,只影响有变化的隧道; - 局域网内直连 NPM 的请求不经过 frp,日志里显示内网地址是正常的,比如产品后端调用帐号中心的机器对机器请求。
Conversation
评论区
还没有评论,来做第一个发言的人吧。