在路由器上手搓 MiniUPnPd:CGNAT 穿透实录

在路由器上手搓 MiniUPnPd:CGNAT 穿透实录

两周前我搞清楚了一件折腾了挺久的事——为什么我家路由器的 UPnP 功能一直没用。

表面上看,UPnP 是启用的,MiniUPnPd 服务跑着,端口映射请求也发出去了,但就是不生效。以为是配置问题,查了半天没结论。最后发现,根本原因是运营商的 CGNAT。


什么是 CGNAT,为什么它让 UPnP 坏掉了

CGNAT 是运营商级 NAT(Carrier-Grade NAT)。简单来说就是:你家路由器有一个 WAN IP,但这个 IP 不是公网 IP,它是运营商内部的私有地址(比如 100.72.x.x),在运营商侧还有一层 NAT 把你和真正的公网隔开。

这直接导致了一个问题:MiniUPnPd 会读取路由器的 WAN IP,发现是 100.x.x.x,判断”这是私有地址,不是公网 IP,没法做端口映射”,然后直接拒绝了所有 UPnP 请求。

这是 MiniUPnPd 2.2.1 的逻辑。它没办法处理 CGNAT 这种情况。


升级到 2.3.9 的正确姿势

MiniUPnPd 从 2.3.x 开始支持一个叫 allow-filtered 的 STUN 模式。意思是:用 STUN 协议去外网探测真实的公网 IP(CGNAT 出口 IP),然后用它来做 UPnP 的外部地址,绕过”WAN IP 是私有地址”的判断。

但路由器上的包管理器没有 2.3.9,只有 2.2.1。所以我需要在路由器上从源码编译一个出来。

路由器是 GL.iNet,基于 OpenWrt,芯片是 MediaTek MT7987,aarch64 架构。上面有 gcc 8.4,可以原生编译。

编译过程比预想的麻烦一些,主要是依赖问题:

1. 系统 .so 库被 strip 了,无法链接

OpenWrt 的 iptables 动态库被裁剪过,头文件也不全。直接用系统的 iptables 无法链接。解决方案:从源码单独编译 iptables 1.8.7,把生成的静态库路径告诉 MiniUPnPd 的 configure:

./configure --firewall=iptables --ipv6 --igd2 \
  --iptablespath=/tmp/iptables-1.8.7

2. 其他依赖头文件也缺

libcap-ng、libuuid、libmnl、libnetfilter_conntrack——都得从源码安装头文件。没有包管理器能直接解决,逐个下载、编译、make install-headers

3. config.h 要手动开功能

configure 不会自动启用所有需要的特性,要手动在 config.h 里加:

#define ENABLE_LEASEFILE
#define ENABLE_IPV6
#define IGD_V2
#define IPTABLES_143

编译完成之后,还需要打一个 init 脚本的补丁。原来的 init 脚本只接受 use_stun=yes/no,不认识 allow-filtered 这个值,我加了一个 ListValue 让它支持第三种选项。

最后的配置关键一行:

ext_perform_stun=allow-filtered
stun_host=stun.hot-chilli.net
stun_port=3478

注意 STUN 服务器要选支持 RFC 5780(NAT Behavior Discovery)的,stun.hot-chilli.net:3478 可以用。Google 的 stun.l.google.com:19302 不行——VPN 路由下非标准端口会被拦截。


还有一个隐藏坑:OpenClash 劫持了 STUN DNS

配好之后发现 STUN 还是探测不到公网 IP。查了半天才发现:路由器上跑着 OpenClash,fake IP 模式,劫持了所有 DNS 查询。STUN 服务器的域名被解析成了 fake IP,UDP 包发到了虚假地址,当然探测不到。

解决方案:在 OpenClash 的绕过列表里加上 STUN 服务器域名,让它走直连。


CGNAT 穿透的真正原理

修好 UPnP 之后,我重新梳理了一下整条链路,才真正理解了 CGNAT 下穿透是怎么工作的。

关键点是:光有 UPnP 端口映射是不够的。

UPnP 只解决了路由器这一侧的映射(LAN 内网 → 路由器 WAN)。但 CGNAT 还有一层(路由器 WAN → 运营商公网),这一层路由器根本没法控制。

真正让入站流量能到达的,是 STUN 打洞 + conntrack

  1. 应用先主动发一个出站 UDP 包到对端
  2. 这个出站包经过 CGNAT 时,CGNAT 建立了一条 conntrack 记录(出站 IP:Port ↔ 公网 IP:Port)
  3. 之后对端的回包,CGNAT 查 conntrack 发现有对应记录,就放进来了
  4. UPnP 负责把路由器内网的映射打通,但前提是 CGNAT 那层的 conntrack 已经建立

所以整条链路是:LAN 客户端 → UPnP 映射(路由器侧)→ 主动出站打洞 → CGNAT conntrack 建立 → 入站流量回来

实际验证:从阿里云 VPS 向客户端发包,成功收到了回应。


值不值得折腾?

说实话,这一圈折腾下来,最终”解锁”的功能对我日常使用的影响并不大——我主要是用家庭服务器远程访问,IPv6 直连方案其实已经够用了。

但折腾本身的收获不少:

  • 知道了 CGNAT 的运营商 WAN IP(100.x.x.x)和普通 NAT 的本质区别
  • 理解了 UPnP 和 STUN 打洞是两件不同的事,不能混为一谈
  • 学会了在嵌入式系统(OpenWrt)上处理依赖地狱、原生编译的方法论
  • 发现 OpenClash fake IP 模式会悄悄搞坏很多需要真实 DNS 解析的东西

最后送给同样 CGNAT 苦手的朋友一句:先确认你的 NAT 类型是 full cone,不然 CGNAT 穿透是不可能的。 对称 NAT 下,每次出站的端口偏移不固定,没法提前告知对端,打洞就是白搭。

我家是 full cone,才能走通这条路。你那边要先查清楚。

发表回复