nps内网穿透获取真实IP地址

cuixiaogang

内网穿透丢真实 IP 的本质:TCP 隧道只做”字节流搬运”,客户端地址不在链路任何报文里——穿透客户端(npc/frpc)永远从内网主机本地回环连目标服务,源站看到的对端就是 127.0.0.1。真实 IP 是带外信息,必须显式选一种协议载体把它”带进来”。本文记录 nps 场景下的完整落地方案(HAProxy + PROXY protocol + nginx stream 分流 + 透明绑定),适用症状:NAS/内网服务的访问日志、面板登录记录全部显示 127.0.0.1

问题与机制

原始拓扑与失真点
原始拓扑与失真点

三类消费方对”真实 IP”的要求不同,方案分层:

消费方 读什么 需要的载体
自建 nginx 及其后的 Web 应用 访问日志 $remote_addrX-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 把它穿透到内网,本方案即建立在这个事实上。

处理前的实例

飞牛 fnOS的登录日志
飞牛 fnOS的登录日志

nginx访问日志
nginx访问日志

知识背景

先认识本案登场的三个组件(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 原始连接,向 nps send-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 层的带外声明。接收方要么”解析并剥掉”(nginx listen 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
global
log /dev/log local0
maxconn 4096

defaults
mode tcp
log global
timeout connect 5s
timeout client 3600s
timeout server 3600s

frontend fe_tls
bind *:443
default_backend nps_tls
backend nps_tls
server nps 127.0.0.1:54443 send-proxy # 连接建立即注入一行 PROXY TCP4 <访客IP> ...

frontend fe_http
bind *:80
default_backend nps_http
backend nps_http
server nps 127.0.0.1:50080 send-proxy

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)
关闭(不删) 44380 两条隧道,保留一周回滚窗口

③ NAS nginx 主配置(容器内 /service/app/nginx-1.16.1/conf/nginx.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#user  nobody;                 # 保持注释:进程以 root 运行,透明绑定依赖的 NET_ADMIN 才在 worker 上生效
worker_processes 1;
error_log logs/error.log warn;

events { worker_connections 1024; }

http {
include mime.types;
default_type application/octet-stream;

# PROXY protocol(HAProxy 注入、nps 穿透、npc 回环送达)→ $remote_addr
set_real_ip_from 127.0.0.1;
real_ip_header proxy_protocol;

sendfile on;
keepalive_timeout 65;

# 80 的兜底站:未匹配任何 server_name 的请求(扫描器等)
server {
listen 80;
server_name localhost;
location / { root html; index index.html index.htm; }
}

include /service/config/vhost/*.conf;
}

# 顶层:四层(stream)配置,含 18443 的 SNI 分流
include /service/config/l4/*.conf;

④ NAS vhost 终态形态(单站示意,其余 vhost 同构)

每个 vhost 只需追加两对隧道监听(原 80/443 保留,局域网直连不受影响;IPv4 与 [::] 两种写法都要加):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
server {
listen 80;
listen [::]:80;
listen 18080 proxy_protocol; # ← 隧道入口(HTTP)
listen [::]:18080 proxy_protocol;
server_name www.cuixiaogang2015.top;
return 301 https://www.cuixiaogang2015.top$request_uri;
}

server {
listen 443 ssl;
listen [::]:443 ssl;
listen 18447 ssl proxy_protocol; # ← 隧道入口(HTTPS,经 stream 分流转入)
listen [::]:18447 ssl proxy_protocol;
server_name www.cuixiaogang2015.top;

ssl_certificate /service/config/cert/cuixiaogang2015.top.pem;
ssl_certificate_key /service/config/cert/cuixiaogang2015.top.key;

access_log /service/logs/blog_access.log;

location / {
proxy_pass http://127.0.0.1:8861;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket 类后端补三件套:proxy_http_version 1.1; Upgrade/Connection 头
}
}

X-Real-IP/XFF 转发头无需改动——realip 已把 $remote_addr 换成访客 IP,原有写法自然转真。闭源面板的 vhost(如 nasadmin)在隧道侧不再被使用(流量在 stream 层已被截走),保留服务局域网直连。

⑤ NAS stream 分流 /service/config/l4/nasadmin.conf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
stream {
map $ssl_preread_server_name $nas_route {
nasadmin.cuixiaogang2015.top 127.0.0.1:18449; # 闭源面板域名
default 127.0.0.1:18447; # 其余交给 http vhost
}
server { # A:隧道总入口,解 PROXY 头、按 SNI 分流、继续向下传头
listen 18443 proxy_protocol;
ssl_preread on;
set_real_ip_from 127.0.0.1;
proxy_protocol on;
proxy_pass $nas_route;
proxy_timeout 1h;
}
server { # C:面板专路——终结 TLS、以访客真实源透明连面板
listen 18449 ssl proxy_protocol;
set_real_ip_from 127.0.0.1;
ssl_certificate /service/config/cert/cuixiaogang2015.top.pem;
ssl_certificate_key /service/config/cert/cuixiaogang2015.top.key;
proxy_ssl on;
proxy_bind $remote_addr:$remote_port transparent;
proxy_pass 127.0.0.1:8098;
proxy_timeout 1h;
}
}

stream 与 http 的语义分叉在这里最关键:stream 的 listen proxy_protocol 默认只填 $proxy_protocol_addr、不换 $remote_addr,必须靠 stream 版 realip(仅 set_real_ip_from 一条指令,来源固定为 PROXY 头,没有 real_ip_header)才会改写地址。

⑥ NAS 宿主机 回程路由与持久化

1
2
3
4
5
6
# /usr/local/sbin/tp-net.sh
#!/bin/bash
IF=$(ip route get 1.1.1.1 | awk '{for(i=1;i<=NF;i++) if($i=="dev") {print $(i+1); exit}}')
sysctl -q -w net.ipv4.conf.all.rp_filter=0 "net.ipv4.conf.$IF.rp_filter=0"
ip route show table 100 | grep -q '^local default' || ip route add local default dev lo table 100
ip rule show | grep -q 'from 127.0.0.1' || ip rule add from 127.0.0.1 lookup 100
1
2
3
4
5
6
7
8
9
10
# /etc/systemd/system/tp-net.service
[Unit]
Description=TPROXY return-route for real-ip transparent proxy
After=network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/tp-net.sh
[Install]
WantedBy=multi-user.target

为什么按 from 127.0.0.1 而不是常见的 mangle OUTPUT + fwmark:透明连接的响应包由本机进程生成,其路由在 socket 发包前就已确定,mangle OUTPUT 打标发生在路由决策之后,fwmark 规则救不回 SYN-ACK;而响应包的源地址恒为 127.0.0.1,按源建规则在查表时刻即可命中,把它们按”本机投递”回注 lo,交给持有透明 socket 的 nginx。

部署前提

  • nginx 编译需启用四个非默认扩展:http_realip_modulestream_ssl_modulestream_ssl_preread_modulestream_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 换道割接

  1. NAS:按 ③④⑤ 落配置、按 ⑥ 建脚本与单元并 systemctl enable --now tp-net;容器内 nginx -t 通过后 reload,ss -lntp | grep -E '18080|18443|18447|18449' 确认四个新监听(此时尚无流量,纯待命)。
  2. NAS:用 ③–⑤ 的文件重建/确认容器(含部署前提里的 caps)。
  3. 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 占着)。
  4. nps 后台:建 ② 的两条新隧道并确认在线(ECS ss -lntp | grep -E '54443|50080' 有 nps 监听)。
  5. 割接(约十秒中断):nps 后台关闭原 443/80 隧道 → ECS systemctl enable --now haproxyss -lntp | grep -E ':80 |:443 ' 确认端口主人换成 haproxy;npc 若未跟随隧道变更则重启一次。
  6. 验证(下一节);稳定一周后删除旧隧道,把 ①③⑤ 与编译参数固化进镜像/配置仓库。

关键取舍(为什么是这个形状)

  • 不迁”域名代理 + 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
2
3
4
journalctl -u haproxy -n 5                     # ① 入口:Connect from <访客真实IP>
curl -sk "https://www.cuixiaogang2015.top/?realipcheck=probe1" # ② 用外部网络发起,带唯一标记
tail -n 3 /service/logs/blog_access.log # 只 grep 标记行:行首应为访客公网 IP
ss -ant '( sport = :8098 )' | grep -v LISTEN # ③ 面板:上游连接对端 = 访客公网 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),必要时重启容器
-tunknown 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 haproxycannot 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 + ⑥ 回程路由)逐服务复制即可。

最终结果展示

nginx访问日志
nginx访问日志

飞牛 fnOS的登录日志
飞牛 fnOS的登录日志