两台机器想要直连。可它们都没有公网地址。一台躲在家用路由器后面,另一台藏在运营商后面。这才是 互联网的常态,也正是点对点网络如果不借助外力就活不下去的状态。NAT 穿透就是在这种情况下硬 挤出一条直连路径的一整套技巧——而它为什么大多数时候能成、又为什么会在某一类可预测的场合失败, 归根结底取决于中间那台 NAT 是怎么分配映射的。
这是一篇讲机制的拆解。不吹不黑。我会讲清楚:NAT 为什么破坏点对点、经典的 NAT 分类以及为什么 对称型 NAT 是那块硬骨头、STUN 如何发现你的反射地址、UDP 打洞的分步过程、TURN 作为中继兜底、ICE 作为把它们串起来的框架,以及 2026 年都变了什么:CGNAT、端口随机化、 QUIC,以及基于 WireGuard 的 overlay。
NAT 为什么会破坏点对点
NAT 之所以存在,一是因为 IPv4 地址用光了,二是因为家庭和企业网络希望只有一个出口来做策略和状态 管理。它做了两件在这里至关重要的事。
第一,它隐藏地址。你的笔记本是 192.168.1.20,而互联网只看到一台机器 203.0.113.7。对端
没法去拨 192.168.1.20,因为这个地址出了你的局域网就毫无意义,并且在另外几百万个局域网里也
存在。你没法宣告一个自己并不拥有的可达地址。
第二,它默认是有状态且单向的。NAT 只有在握有一条”内部 (ip, port) 映射到外部 (ip, port)”的表项时,才知道该怎么翻译一个包。而这条表项是你这一侧先发流量出去时才会创建的——
出站创建状态,入站需要状态。所以从互联网不请自来的 SYN 或 UDP 报文会被丢弃:根本没有映射可以拿来
翻译它。正是这种不对称,让”监听”不可能,而”主动拨号”可行。
两件事合在一起:两台藏在 NAT 后面的主机都能主动拨向公网,但谁都无法接受拨入。如果双方都有公网 地址,P2P 会易如反掌;而 NAT 恰好把 P2P 需要的那个属性给拿掉了。无论是发现问题(“我该把自己的 什么地址告诉对端?”),还是可达性问题(“包真的能到吗?”),都是 NAT 造成的。
NAT 的分类,以及为什么对称型 NAT 是硬骨头
民间的分类法各不相同,但经典的四类模型(来自 RFC 3489 时代的 STUN)在预判打洞能否成功这一点上 相当好用。它们之间的差别在于:NAT 对谁可以向已有映射发入站有多宽容,以及它如何挑选外部端口。
| 类型 | 出站映射 | 谁能向该映射发入站 |
|---|---|---|
| 全锥型(Full-cone) | 每个内部 (ip, port) 固定一个外部端口 | 任何人 |
| 受限锥型(Restricted cone) | 每个内部 (ip, port) 固定一个外部端口 | 只有内部侧曾发过包的主机,端口不限 |
| 端口受限锥型(Port-restricted cone) | 每个内部 (ip, port) 固定一个外部端口 | 只有内部侧曾发过的那个精确 (ip, port) |
| 对称型(Symmetric) | 每个目的地址分配一个新的外部端口 | 只有那一个目的 (ip, port) |
前三种都是”锥型” NAT:无论目的是谁,外部端口都是内部端点的一个稳定函数。这种稳定性正是打洞能 成功的全部理由——你可以预测这个映射,并把它告诉对端。
对称型 NAT 把这一点打破了。它会针对你通信的每一个目的地址分配一个不同的外部端口。
你想预测对端的端口,一定会猜错,因为 NAT 早就另选了一个。更糟的是,探测这件事本身——你往对端
地址发包——所创建的,恰恰就是对端猜不到的那条映射。对称型 NAT 通常出现在企业设备、部分移动
运营商那里,如今在 CGNAT 后面也越来越常见。它把入站限制在你最近一次发过包的单个 (ip, port)
上,是表格里最严格的一行,也是击溃朴素打洞的那一行。
有一个值得内化的细节:映射行为与过滤行为是相互独立的。一台 NAT 可以采用端点无关的映射 (锥型式、端口稳定),却使用依赖地址的过滤。RFC 4787 的现代术语把这两个维度分开描述。对穿透 而言,映射维度通常决定成败;过滤维度决定第一个探测包能不能过去。
STUN:发现你的反射地址
在你能告诉别人”往这里发包”之前,你得先知道 NAT 分给你的公网 (ip, port)。这正是 STUN
(Session Traversal Utilities for NAT,NAT 会话穿透工具)要做的事。STUN 客户端往一台公网
STUN 服务器发一个 Binding Request;服务器查看这个包到达时的源地址,再通过 Binding Response
把它反射回来。这个被观察到的地址就是你的**服务器反射(server-reflexive)**候选地址。
客户端 NAT (203.0.113.7) STUN 服务器
| | |
|-- Binding Request --------------------------------> |
| (源 192.168.1.20:54321) | |
| | 分配 |
| | 203.0.113.7:62000 |
| | |
| <---------------------- Binding Response --------- |
| XOR-MAPPED-ADDRESS = 203.0.113.7:62000 |
STUN 便宜(几个 UDP 往返),服务器端无状态。它不是中继,也完全不认识你的对端。有两点现实很关键:
- 多问几台服务器。 如果两台 STUN 服务器对同一个本地 socket 返回了不同的外部端口,那你就在 对称型 NAT(或按服务器做映射的 NAT)后面。这一次比较,是你手头最廉价的对称型 NAT 检测手段。
- STUN 对过滤行为一无所知。 知道自己的外部端口,并不意味着对端发往它的包会被放行;那取决于 过滤维度。这件事只能靠试着发来验证。
如果关于 STUN 你只记得一件事:它只回答”我从外面看是什么样”,从不回答”我能不能被连上”。
UDP 打洞,分步拆解
打洞是一种协调技巧。洞察在于:虽然两端都无法接受不请自来的入站包,但双方都能创建那条让对端 进来的映射——只要顺序对。UDP 是首选介质,因为它没有连接握手,所打的映射只要 NAT 的 UDP 定时器 允许就能一直留着,而且不会有谁在收到意外首包时甩出一个 RST。
唯一的前提是一台汇合服务器(rendezvous server):一台有公网地址、两端都够得着、且两端都 愿意把地址交给它的第三方主机。在 ICE 里它就是信令通道;在 mesh VPN 里它就是锚点节点。
Alice 汇合服务器(公网) Bob
| | |
| 1. 注册 | |
|------------------>| |
| |<------ 注册 -------|
| | |
| 2. "谁在这个房间?" | |
|------------------>| |
|<-- Bob=198.51.100.9:40000 ------------>
| |--- Alice=203.0.113.7:62000 -->
| | |
| 3. 打洞:往 Bob 的地址发 UDP |
|----------------------------------------> (NAT-A 打开一条朝向
| | Bob 的映射;可能先被
| | B 的过滤丢掉)
| 4. 打洞:往 Alice 的地址发 UDP |
|<---------------------------------------- (NAT-B 打开一条朝向
| | Alice 的映射)
| 5. 现在两条映射都已在位: |
|<================= 直连 UDP ============>|
顺序很重要。第 3 步的包可能被 Bob 的过滤丢掉,但关键在于:它为 Alice 创建了一条朝向 Bob 地址的 出站映射。当 Bob 第 4 步的包到达时,那条映射已经存在,并且是被允许的(对受限型 NAT 而言,因为 Alice 确实发往了 Bob 的精确地址)。对称地,Bob 的包为他自己创建了出站映射,于是 Alice 重传 的那个包现在就能过去了。所以第 5 步通常需要一次重传:每个方向上的第一个洞负责开门,第二个包才 真正走进门里。
# 对每个对端,一旦地址通过汇合服务器交换完毕:
for attempt in 1..N:
sendto(bob_addr, "PUNCH") # 持续打开/刷新我这边的映射
recv() # 收到 Bob 的 PUNCH 就说明洞通了
if peer_seen: break
sleep(backoff) # 重传是必需项,不是可选项
if not peer_seen:
回落到中继 # 见下文 TURN
两个运维层面的提醒。第一,打洞本质上是一个时序和保活问题:NAT 的 UDP 映射会过期(空闲时 常在 30–120 秒,CGNAT 上有时更短),所以活着的路径需要周期性流量,否则会悄悄失效。第二,一旦 路径打通,就应该切到一个加密的短保活包——这正是真实协议不会”打一次就完事”的原因。
TURN:你付费买来的中继
当打洞失败时——任意一侧是对称型 NAT、防火墙全部丢弃、或者两端就是没法就某条路径达成一致——剩下 的唯一选择就是别再想着直连。TURN(Traversal Using Relays around NAT)在一台公网服务器上 分配一个中继地址(relay address)。两端都把流量发给中继,由中继转发。从每台 NAT 的视角看, 这不过是普通的、发往一台公网服务器的出站流量,而它总是能成。
Alice ----(出站 UDP)----> TURN 中继 <----(出站 UDP)---- Bob
(NAT-A 映射) (NAT-B 映射)
TURN 是那个有保证的兜底,也是 NAT 穿透之所以可靠、而不仅仅是碰运气的原因。它同时也是昂贵的 那一环:这一对节点之间的全部数据现在都要穿过你的中继,既产生实实在在的出口成本,又增加延迟—— 路径不再是最近的那条,而是两台 NAT 都允许的那条绕行路。这就是”优先直连,实在不行才中继”背后的 全部经济学:一条走中继的会话,成本可能是直连的数量级之差。基于 UDP 的 TURN 是常态;UDP 被彻底 封死的网络里还有基于 TCP 或 TLS 的 TURN,代价是延迟。
ICE:把它们统一起来的框架
实践中,你不会单独去实现 STUN 或 TURN。ICE(Interactive Connectivity Establishment)才是 跑通这一切的状态机:收集候选、通过信令交换、两两配对、逐个测试,然后挑出最好的那条能用的。
每一侧会收集:
- 主机候选(host candidate)——本地地址(在局域网里有用)。
- 服务器反射候选(server-reflexive candidate)——STUN 发现的那条公网映射。
- 中继候选(relayed candidate)——TURN 分配来的地址。
这些候选通过信令通道交换(你自己应用层的协议——WebRTC 的 offer/answer,mesh VPN 的对端列表)。 接着 ICE 执行连通性检查(connectivity check):从每个本地候选向每个远端候选发一个 STUN Binding Request,并等待回应。一条双向都能应答的候选对,就是一条可用路径。ICE 会给可用候选对按 偏好排序——直连优先于中继、延迟低的优先——然后提名最优的那条。它还会让备选路径保持存活,这样 路径失效时无需重新协商就能切换。
ICE 的核心结论:穿透是一场协商,而不是一个协议。STUN 给你地址,打洞给你路径,TURN 给你 保证,而 ICE 是把它们排出优先级的策略。
到 2026 年都变了什么
经典图景仍然是正确的心智模型,但环境确实移位了。
- CGNAT 成了常态。 许多家宽和移动用户都处在运营商级 NAT 后面,有时还是多层嵌套。你根本不 拥有公网地址;那个”公网”映射其实在运营商的 NAT 内部。CGNAT 常常使用端点相关(对称型)映射, 这是打洞的最坏情况,于是中继的使用多于十年前。部分部署还按用户共享地址并划分端口段,这在概念 上什么都没改变,但会让你手工测试的结果变得毫无意义。
- 端口随机化。 许多 NAT 从一大片随机池里挑外部端口,而不是顺序分配。这并不改变打洞能否 成功(协调是在带外进行的),但它废掉了所有”预测下一个端口”的启发式,也击溃了朴素的端口扫描。
- QUIC 与基于 UDP 的传输。 QUIC 跑在 UDP 上,并拥有自己的连接 ID,因此连接连续性与 IP/端口 元组无关。一条 QUIC 连接于是能在会杀死 TCP 连接的 NAT 重绑定中存活下来。它本身不会让打洞更 容易,但能让一条已经打通的路径更有韧性。
- 基于 WireGuard 的 overlay。 现代 mesh VPN(比如我在这里跑的那套) 把自己的协议封装在 UDP 里,再在 overlay 内部跑类似 STUN 的发现,找出彼此被反射映射的端点, 然后打洞。结果是一张扁平的虚拟子网,每个节点都能寻址到任意节点,中继则作为路由层的兜底。这些 overlay 到底给什么、不给什么,在这篇伴文里。
一段话总结
NAT 之所以破坏 P2P,是因为它隐藏地址,并拒绝不请自来的入站流量。STUN 告诉你 NAT 分给你的地址, 却不告诉你有没有人能连上它。打洞利用”出站会创建状态”这一事实,让两端协调着互相对拨,从而使彼此 的包能穿过对方的过滤器——前提是 NAT 把端口保持稳定。对称型 NAT 和 CGNAT 之所以难缠,是因为它们 按目的地址分配端口,这也正是 TURN 存在的理由:一个永远能用、也永远要花钱的中继。ICE 是那个先试 直连、再回落中继的框架;也正因如此,“NAT 穿透”最好别被理解为某一个协议,而应被理解为一组关于 中间设备会如何行事的、按序下注的博弈。