nps内网穿透获取真实IP地址
内网穿透丢真实 IP 的本质:TCP 隧道只做”字节流搬运”,客户端地址不在链路任何报文里——穿透客户端(npc/frpc)永远从内网主机本地回环连目标服务,源站看到的对端就是
127.0.0.1。真实 IP 是带外信息,必须显式选一种协议载体把它”带进来”。本文记录 nps 场景下的完整落地方案(HAProxy + PROXY protocol + nginx stream 分流 + 透明绑定),适用症状:NAS/内网服务的访问日志、面板登录记录全部显示127.0.0.1。
问题与机制
三类消费方对”真实 IP”的要求不同,方案分层:
| 消费方 | 读什么 | 需要的载体 |
|---|---|---|
| 自建 nginx 及其后的 Web 应用 | 访问日志 $remote_addr、X-Real-IP/XFF 头 |
PROXY protocol(或 XFF 头) |
只读 TCP 对端的闭源面板(如飞牛 fnOS,官方论坛确认其不读 X-Forwarded-For) |
socket 源地址 | 伪造 TCP 源(透明绑定) |
| 不经代理的裸 TCP 服务(SSH、数据库直连等) | socket 源地址 | 同上,需逐服务处理 |
工具生态:frp 原生支持 PROXY protocol(入口生成、目标消费);nps 原版不支持,但它的 TCP 隧道原样转字节流——因此可以在公网侧用 HAProxy send-proxy 注入 PROXY 头,让 nps 把它穿透到内网,本方案即建立在这个事实上。
处理前的实例:
知识背景
先认识本案登场的三个组件(nps、HAProxy、飞牛 fnOS,nginx 假定为已知),再梳理概念。
- nps / npc:轻量开源内网穿透工具,服务端 nps 部署在有公网 IP 的机器上,客户端 npc 跑在内网机器上,两者之间维持一条控制长连接(bridge,默认端口 8024)。访客连上公网侧开放的隧道入口端口后,nps 通过控制连接命令 npc 在本地发起对目标服务的连接,再双向搬运字节流;附带 Web 管理界面(默认 8080)管理客户端与隧道。隧道分两大类:TCP/UDP 隧道(纯字节管道)与 HTTP/HTTPS 域名代理(七层代发、自动加
X-Forwarded-For/X-Real-IP头,但要求把 TLS 终结在公网侧)。原版 nps 不支持 PROXY protocol——这是本案要在公网侧额外架一层 HAProxy 的原因。 - HAProxy:老牌开源高性能负载均衡/反向代理,双层转发能力:
mode tcp是 L4 字节流管道(完全不解析应用层协议,TLS 可直接透传),mode http是 L7 处理。它的标配能力之一就是 PROXY protocol——server 行加send-proxy(v1 文本头)/send-proxy-v2(二进制头)即可在连接建立时向下游声明真实四元组,frontend 用option proxy_protocol接收上游同款头。配置以 frontend/backend 组织,haproxy -c -f验语法、systemd 托管;EL7 时代yum install haproxy拿到的 1.5.x 已支持send-proxy。本案里它只做一件事:接住访客的 TLS 原始连接,向 npssend-proxy注入访客头。它不是必需件——若公网机本身就跑 nginx,用 stream 模块的proxy_protocol on可做同样的注入(本案内网侧的”stream 分流”正是这个手法),届时可省一层。 - fnOS(飞牛):免费的国产 NAS 操作系统,底层是 Debian(本环境为 Debian 12),面向家庭私有云,提供 Web 管理面板(文件、存储、相册、Docker、应用中心等)与手机/TV 端 App。与本案相关的两点:其一,系统自带一套管理 nginx(位于
/usr/trim/nginx)服务于面板,面板默认监听 5666(HTTP)/5667(HTTPS)、端口可自定义(本环境改成了 8088/8098),它与你自己部署的 nginx 是两个独立进程,排查端口归属时别混淆;其二,面板的登录记录只认 TCP 对端地址,不解析X-Forwarded-For,也没有”信任代理”设置(官方论坛多个功能需求帖均确认)——这条厂商限制正是最后必须走”伪造 TCP 源”路线的原因。
看懂方案前需要就位的基础概念:
- 穿透工具的两段连接:客户端(npc/frpc)与公网服务端之间维持一条控制长连接;访客接入时,服务端把这条 TCP 字节流转给客户端,由客户端在本地再发起一条到目标服务的连接。因此目标服务眼里的”客户端”永远是穿透进程——对端 = 本机回环(
127.0.0.1)或同内网地址,访客地址在 TCP/IP 层根本不存在。 - “改客户端 IP”只有两条路:应用层塞进头(XFF / PROXY protocol),或网络层伪造 TCP 源。头这条路要求每一跳有会读头的组件;伪造源这条路要求内核配合(下条)。
- 伪造 TCP 源(
IP_TRANSPARENT)三件套:进程要有CAP_NET_ADMIN,以”非本机地址”作为源 bind/connect;伪造连接的回程包(源为本机、目的为公网访客)默认会按路由表从物理网卡出去、被当 Martian 丢弃,必须用策略路由把它回注本地——ip rule add from 127.0.0.1 lookup 100+ip route add local default dev lo table 100,”local 路由”就是”这个包按本机投递处理”,从而送达持有透明 socket 的进程;顺带rp_filter置 0。 - PROXY protocol v1:TCP 流最前端的一行文本
PROXY TCP4 <源IP> <目的IP> <源端口> <目的端口>\r\n,是 L4 层的带外声明。接收方要么”解析并剥掉”(nginxlisten proxy_protocol、HAProxy),要么完全不认(多数闭源服务的 TLS 会因首包不是握手记录而失败)——所以它能停在会解析的组件上,把最后一段交给真协议。 - X-Forwarded-For:HTTP 应用层头,只有 HTTP 组件会读;应用/面板还要”愿意信”——很多 NAS 系统(如 fnOS)压根不读头,这决定了它们只能走伪造源那条最重的路。
- TLS 终结点 = 证书出示点:浏览器拿到的证书永远来自”解密的那个进程”。终结在哪,证书就得放在哪;纯 L4 穿透则把终结点推到最内层服务(设备自签证书会直接暴露)。
- nginx 两个上下文的语义分叉(易被想当然):http 的
listen proxy_protocol解析头并替换$remote_addr;stream 的只填$proxy_protocol_addr变量、不换地址,要换得靠 stream 版 realip(且它只有set_real_ip_from,无real_ip_header)。realip 系与 stream_ssl 系扩展都非默认编译,缺了会在-t时以unknown directive/not allowed here现形。 - 容器网络前提:容器须以
--network host与宿主机共享网络栈——宿主机上的策略路由/防火墙规则才对容器内 nginx 的流量生效;透明绑定所需 caps 由docker run --cap-add提供。 - nps 隧道模式:TCP 隧道=纯字节管道(本方案的载体);域名代理=七层代发、自动加 XFF/X-Real-IP 头,但要求 TLS 终结在公网(证书上云)、按域名分发——两者取舍构成本案路线选择。
最终方案
目标架构
一句话读图:PROXY 头只在 HAProxy 处注入一次,nps 全程当普通字节穿透;内网 nginx 用 stream 按 SNI 分流——普通站点在 http 层”解头即止”(realip 改 $remote_addr),闭源面板那条支路才做”TLS 终结 + 以访客 IP 为源的透明绑定”。HTTP 流量(80)只经过重定向,不进入 stream,直接落 http 的 18080。
配置终态(按文件)
① ECS /etc/haproxy/haproxy.cfg
1 | global |
SELinux 为 Enforcing 时补 setsebool -P haproxy_connect_any 1;安全组/防火墙对外仍只放行 80/443,54443/50080 仅监听给本机 HAProxy。
② nps 隧道变更
| 动作 | 内容 |
|---|---|
| 新增 | TCP 隧道 54443 → 127.0.0.1:18443(HTTPS 全流量,进 stream 分流) |
| 新增 | TCP 隧道 50080 → 127.0.0.1:18080(HTTP 流量,直接进 http) |
| 关闭(不删) | 原 443、80 两条隧道,保留一周回滚窗口 |
③ NAS nginx 主配置(容器内 /service/app/nginx-1.16.1/conf/nginx.conf)
1 | #user nobody; # 保持注释:进程以 root 运行,透明绑定依赖的 NET_ADMIN 才在 worker 上生效 |
④ NAS vhost 终态形态(单站示意,其余 vhost 同构)
每个 vhost 只需追加两对隧道监听(原 80/443 保留,局域网直连不受影响;IPv4 与 [::] 两种写法都要加):
1 | server { |
X-Real-IP/XFF 转发头无需改动——realip 已把 $remote_addr 换成访客 IP,原有写法自然转真。闭源面板的 vhost(如 nasadmin)在隧道侧不再被使用(流量在 stream 层已被截走),保留服务局域网直连。
⑤ NAS stream 分流 /service/config/l4/nasadmin.conf
1 | stream { |
stream 与 http 的语义分叉在这里最关键:stream 的 listen proxy_protocol 默认只填 $proxy_protocol_addr、不换 $remote_addr,必须靠 stream 版 realip(仅 set_real_ip_from 一条指令,来源固定为 PROXY 头,没有 real_ip_header)才会改写地址。
⑥ NAS 宿主机 回程路由与持久化
1 | # /usr/local/sbin/tp-net.sh |
1 | # /etc/systemd/system/tp-net.service |
为什么按 from 127.0.0.1 而不是常见的 mangle OUTPUT + fwmark:透明连接的响应包由本机进程生成,其路由在 socket 发包前就已确定,mangle OUTPUT 打标发生在路由决策之后,fwmark 规则救不回 SYN-ACK;而响应包的源地址恒为 127.0.0.1,按源建规则在查表时刻即可命中,把它们按”本机投递”回注 lo,交给持有透明 socket 的 nginx。
部署前提
- nginx 编译需启用四个非默认扩展:
http_realip_module、stream_ssl_module、stream_ssl_preread_module、stream_realip_module; - nginx 所在容器:
--network host --cap-add NET_ADMIN --cap-add NET_RAW(透明绑定需要 NET_ADMIN,docker exec <容器> grep CapEff /proc/self/status可验证,默认能力集a80425fb不含,补上后应为a80435fb); - 证书路径对 http(vhost)与 stream(⑤)可见,两处引用同一份。
操作步骤(按序)
顺序原则:内网侧先就位(不引流,零风险)→ 公网侧备好(不启动)→ nps 换道割接。
- NAS:按 ③④⑤ 落配置、按 ⑥ 建脚本与单元并
systemctl enable --now tp-net;容器内nginx -t通过后 reload,ss -lntp | grep -E '18080|18443|18447|18449'确认四个新监听(此时尚无流量,纯待命)。 - NAS:用 ③–⑤ 的文件重建/确认容器(含部署前提里的 caps)。
- ECS:
yum -y install haproxy(CentOS7 已停维,yum 报 404 先切 vault 源:sed -i 's|^mirrorlist=|#mirrorlist=|;s|^#baseurl=http://mirror.centos.org|baseurl=https://vault.centos.org|' /etc/yum.repos.d/CentOS-Base.repo && yum makecache);落 ①,haproxy -c -f /etc/haproxy/haproxy.cfg通过,先不启动(443/80 还被 nps 占着)。 - nps 后台:建 ② 的两条新隧道并确认在线(ECS
ss -lntp | grep -E '54443|50080'有 nps 监听)。 - 割接(约十秒中断):nps 后台关闭原 443/80 隧道 → ECS
systemctl enable --now haproxy→ss -lntp | grep -E ':80 |:443 '确认端口主人换成 haproxy;npc 若未跟随隧道变更则重启一次。 - 验证(下一节);稳定一周后删除旧隧道,把 ①③⑤ 与编译参数固化进镜像/配置仓库。
关键取舍(为什么是这个形状)
- 不迁”域名代理 + XFF 头”:nps 域名代理模式会自动加
X-Forwarded-For/X-Real-IP,看似最省事,但要求 TLS 终结到公网侧(证书上云)且按域名分发,本环境证书必须留在内网——否决。 - 面板支路走 stream(L4)而不是 http 里
proxy_bind transparent:HTTP 反代默认每请求重开上游连接,而透明源写死访客四元组,同连接的第二个请求 bind 即 EADDRINUSE(500),WebSocket 长连接还会全程占坑,tcp_tw_reuse无解;stream 一条客户端连接对一条上游连接,只 bind 一次,天然规避。 - 面板支路在 stream 内终结 TLS 而非纯穿透:纯 L4 穿透会把 TLS 终结点推到面板侧,浏览器拿到的是设备自签证书(表现为”证书失效”告警);终结后用自有通配证书应答、再
proxy_ssl on回源,透明与正确证书兼得。 - vhost 双监听而非改原监听:给原
listen 443直接加proxy_protocol会让不带头的局域网直连全部握手失败。 - 割接用”新增隧道→关闭旧隧道(不删)→停 HAProxy 即回滚”:全程有退路。
验证
链路三层各测一次,全绿才算生效:
1 | journalctl -u haproxy -n 5 # ① 入口:Connect from <访客真实IP> |
两个测试噪声要避开:在公网服务器上 curl 自己的公网 IP,日志出现的是服务器出口而非访客 IP;tail 混有变更前的旧行,必须用带唯一标记的请求现测现查。
易踩坑与排障速查
以下都是本次落地实际撞过(或极可能撞上)的坑,按”症状 → 原因 → 处置”索引。排障总思路:三层各测一次(HAProxy 日志看字节进没进、NAS 回环抓包看头到没到且落在哪个端口、应用日志看语义生效没有),三方对不齐的那一层就是病灶。
| 症状 | 原因 | 处置 |
|---|---|---|
批量 sed 改 listen 后只有一个文件生效 |
其余文件 CRLF 行尾,行尾锚定 $ 失配 |
file *.conf 确诊;sed -i 's/\r$//' 统一后重跑 |
| 改完配置没任何效果 | nginx 在容器里,宿主/容器两套文件视图;或 -t 在宿主跑通 ≠ 容器加载 |
一律 docker exec 执行 -t 与 -s reload;用 nginx -T 看运行态 |
ss -lntp 同一端口挂两个 nginx pid |
reload 后旧 master/worker 未退,仍在新连接上服役旧配置 | 确认世代(ps -eo pid,lstart),必要时重启容器 |
-t 报 unknown directive "set_real_ip_from" / "ssl_preread" |
realip、stream_ssl/preread 扩展没编(都不是默认编译) | 镜像重建补四个扩展模块(见「部署前提」) |
-t 报 "real_ip_header" directive is not allowed here(写在 stream 里) |
stream 版 realip 只有 set_real_ip_from,来源固定为 PROXY 头 |
删掉该指令即可 |
割接后日志仍 127.0.0.1,但 tcpdump 抓到 PROXY TCP4 <真IP> 在流首 |
http 层缺 realip 扩展;或 stream 层”只填变量不换地址”被想当然 | 对照「知识背景」语义分叉条;stream 场景加 set_real_ip_from |
invalid local address "1.2.3.4:0" 每请求刷错,页面却正常、IP 没换 |
proxy_bind 变量绑定必须带合法端口,:0 非法后静默跳过绑定 |
改 $remote_addr:$remote_port |
| 面板一访问就 504;回环抓包只见 SYN 不见 SYN-ACK | 本机生成的回包路由先于 mangle OUTPUT 打标确定,fwmark 规则救不回来 | 按源建规则(⑥);rp_filter 两边置 0 |
面板时好时坏,日志 bind(<访客IP>:<端口>) failed (98) |
HTTP 反代每请求重开上游连接,固定源四元组被上一请求/WS 长连接占用 | 结构死结,别再修:面板支路移入 stream(一连接一绑定) |
vhost 里残留 listen [::]:18443,站点 404 / 端口被抢 |
http 先于 stream 绑定,双栈残留 socket 截走隧道流量 | 批量改监听永远 IPv4/[::] 双写法 grep 复查 |
| 割接后浏览器提示证书无效(CN=设备自签) | 纯 L4 穿透把 TLS 终结点移到面板侧,出示的是面板自签证书 | 面板支路在 stream 内终结 TLS(配置 ⑤ 已含) |
| 启动 haproxy 后 443 还在 nps 名下(或 haproxy 起不来) | 旧隧道未关,端口被占,bind 静默失败 | 先关旧隧道再 start;journalctl -u haproxy 看 cannot bind socket |
| nps 后台改了隧道,npc 行为如旧 | 配置未即时下发/未应用 | 重启 npc 强制重拉 |
| 日志里出现”服务器自己的 IP” | 在公网服务器上 curl 自己的公网地址,属自环流量 | 验证必须从第三方网络发起 |
测试方法上的两个陷阱同样值得记住:tail 出来的历史行会冒充”没修好”,必须用带唯一标记的 URL 现测现 grep(如 ?realipcheck=probe7);grep ... | head 会截断证据导致误判,先 grep -c 数总量。
未覆盖面:不经自建 nginx 的裸 TCP 隧道(SSH、数据库、迅雷面板等)仍是 127.0.0.1——需要时按 ⑤ 的面板专路形状(listen proxy_protocol + set_real_ip_from + proxy_bind ... transparent + ⑥ 回程路由)逐服务复制即可。