--- date: 2026-04-19T17:45:08+08:00 title: NAT穿透工作原理 tags: [tailscale,网络] categories: [tailscale] draft: false repost: https://tailscale.com/blog/how-nat-traversal-works --- ## 前言 我们在 [Tailscale 工作原理](https://nosae.top/posts/tailescale%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86/) 中涵盖了很多内容。但是,我们只是略微提及了如何绕过 NAT(网络地址转换器),而且无论设备之间有什么障碍,我们都假设他们能直连。下面我们就来详细讨论下。 ![NAT traversal with laptops diagram.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/50eafc1638d93b9637dcee0d55967a8fa09e05c7-1700x800.png) 我们先从一个简单的问题入手:在两台机器之间建立点对点连接。在 Tailscale 的案例中,我们希望建立一个 WireGuard® 隧道,但这其实并不重要。我们使用的技术应用广泛,是许多人几十年来研究的成果。例如, [WebRTC](https://webrtc.org/) 就利用这套技术在 Web 浏览器之间传输点对点音频、视频和数据。VoIP 电话和一些视频游戏也使用了类似的技术,尽管并非总是成功。 我们将对这些技术进行一般性讨论,并在适当情况下以 Tailscale 等为例进行说明。假设您正在创建自己的协议,并且需要实现 NAT 穿透。您需要两样东西: **首先,协议应该基于 UDP**。虽然也可以使用 TCP 进行 NAT 穿透,但这会使原本就相当复杂的问题更加复杂,甚至可能需要对内核进行定制,具体取决于你想要实现的深度。所以下面我们重点讨论下 UDP。如果你因为希望在 NAT 穿透完成后建立面向流的连接而选择 TCP,不妨考虑改用 QUIC。QUIC 基于 UDP 构建,因此我们可以专注于使用 UDP 进行 NAT 穿透,最终仍然能够获得良好的流传输协议。 **其次,你需要直接控制发送和接收网络数据包的网络套接字**。通常情况下,你不能直接使用现有的网络库来穿透 NAT,因为你需要发送和接收一些额外的、不属于你正在使用的“主”协议的数据包。有些协议将 NAT 穿透与其他功能紧密集成在一起(例如 WebRTC)。但如果你要自己构建 NAT 穿透机制,最好将 NAT 穿透视为一个独立的实体,它与你的主协议共享一个套接字。两者并行运行,互相支持。 *简单来说,标准的网络库只负责传输协议数据(如 TLS 握手、HTTP 请求)。但基于 NAT 穿透的连接需要你在同一个端口上,既发送业务数据,又发送用于维持连接、探测路径的“额外数据包”。如果你无法直接控制 socket,你就没法塞进这些“私货”* ![NAT traversal with app diagram.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/5bef4a143b33f1a5b2ce49e919dccb4e59b7569a-1600x760.png) 上面说到你要控制 socket,那如果你正在开发一个程序,但无法修改它的底层 Socket 逻辑呢?比如你只想在这个程序写业务逻辑,不关心 NAT 穿透。可以通过代理来解决: - 你的程序:不需要知道什么是 NAT,它只需像往常一样把数据发给本机的 `127.0.0.1:[某个端口]`。 - 本地代理:拦截这些数据。它具备“直接控制 Socket”的能力。这个代理程序一边在公网上进行复杂的 NAT 穿透,一边把你的数据包封装好发给对方。 了解了前提条件后,我们从头开始讲解 NAT 穿透。我们的目标是让 UDP 数据包在两个设备之间双向传输,以便其他协议(例如 WireGuard、QUIC、WebRTC 等)能够发挥作用。实现这一目标有两个障碍:有状态防火墙和 NAT 设备。 ## 了解防火墙 防火墙是我们面临的两个问题中相对简单的一个。事实上,大多数 NAT 设备都包含防火墙,因此我们需要先解决这部分问题,才能着手处理 NAT 本身。 有很多种防火墙方案可供选择。您可能比较熟悉的有 Windows Defender 防火墙、Ubuntu 的 ufw(使用 iptables/nftables)、BSD 的 pf(macOS 也用它)以及 AWS 的安全组。它们都具有很高的可配置性,但最常见的配置是允许所有“出站”连接,阻止所有“入站”连接。当然,也可能有一些特意选择的例外情况,例如允许入站 SSH 连接。 但连接和“方向”只是协议设计者的想象。在实际传输过程中,每个连接最终都是双向的;都是单个数据包在来回传输。防火墙如何知道哪些是入站数据,哪些是出站数据呢? 这就是有状态防火墙的作用所在。有状态防火墙会记住过去遇到的数据包,并利用这些信息来决定如何处理新出现的数据包。 ![NAT traversal diagram.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/2b44043b374217d6fa2d3a138b77c171df05bdf2-1600x740.png) 对于 UDP,规则非常简单:如果之前接收到过出站数据包,那么相应的入站数据包就直接放行。例如: 如果我们的笔记本电脑防火墙检测到有从 `2.2.2.2:1234` 到 `5.5.5.5:5678` 的数据包发出,防火墙会记录下来,后续收到从 `5.5.5.5:5678` 过来的数据包时就会放行。 (顺便提一下,一些规则非常宽松的防火墙可能会允许来自任何何发送给 `2.2.2.2:1234`,前提是 `2.2.2.2:1234` 与任意设备通信过。这样的防火墙使我们的穿透工作更容易,但这种情况越来越少见。) ### 双向防火墙 可以看到,在 C/S 架构中,防火墙往往时单向的,即给 Client -> Server 方向放行,只阻拦 Server -> Client 方向,特别是在 VPN 领域: ![VPN AWS diagram.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/2bbe8d0fa6f3a66b71f0aec01bf831a3cc1a8a65-2210x1082.png) 但是如果是两个 Client 要直接相互通信呢?问题就出现了。现在防火墙彼此相对。根据我们上面制定的规则,这意味着双方都必须先行,但同时也意味着任何一方都不能先行,因为对方必须先行! ![Diagram of two 'clients' trying to talk directly but facing firewalls prevent further action.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/928409c960e0b0bcd53560edf80a934b24eaec11-1740x620.png) 如何解决这个问题呢?一种方法是要求用户主动配置防火墙,允许另一台机器的流量通过。但这对于用户来说并不友好。此外,它也无法扩展到像 Tailscale 这样的网状网络,因为我们预期网络中的节点会定期在互联网上移动。当然,在很多情况下,你无法控制防火墙:你不可能去配置外面咖啡馆或机场的路由器。 我们需要另一种方案,一种不需要重新配置防火墙的方案。 ### 如何应对防火墙 应对的关键还是在于防火墙本身的规则,即对于 UDP 协议,规则是:**数据包必须先流出,才能返回。** 具体点说,只要数据包的源地址和目的地址正确,就能向外流动,随后目的端就能将 **“响应”** 顺利穿过源端的防火墙,到达源端。这里所谓的“响应”在语义上不一定是对源端之前发送过来数据包的一种响应,而是可以是任何东西,因为防火墙只认 IP 和端口。所以你也可以理解为,在源端的防火墙上为目的端打了个洞,让目的端的包能穿过防火墙到达源端。 因此,为了跨越这些多重有状态防火墙,我们需要共享一些信息才能开始通信:对等节点必须预先知道对方正在使用的 `ip:port` 。一种方法是手动静态配置每个对等节点,但这种方法的可扩展性有限。为了解决这个问题,我们构建了一个 [协调服务器 ](https://nosae.top/posts/tailescale%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86/#%e6%8e%a7%e5%88%b6%e5%b9%b3%e9%9d%a2%e7%a7%98%e9%92%a5%e4%ba%a4%e6%8d%a2%e4%b8%8e%e5%8d%8f%e8%b0%83),以灵活、安全的方式保持 `ip:port` 信息的同步。 OK,现在来看一个具体点的例子。首先源端(Laptop)向目的端(Server)发出第一个数据包,显而易见,会被对端的防火墙拦下来。但我们注意到源端的防火墙记住了目的端的 `ip:port`,因此后续来自该目的端的数据包会被放行: ![Packet flow blocked by facing firewalls as shown in diagram.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/9cfd8653ec918a72d6909e5603d65c2ca4b6e5c9-1740x680.png) 同样的,因为目的端向源端发送了所谓的 "响应包",因此目的端的防火墙也记住了源端的 `ip:port`,后续源端发过来的包也能通过目的端的防火墙: ![NAT traversal packet exchange.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/7189e9a0b2caf65998dc2e50e84e8f4a35e73bbb-1740x680.png) 最终,在两端都配置了防火墙的情况下,顺利地建立了双向通信。 ### 应对防火墙的注意事项 管理这种通过穿透防火墙的连接并非易事。 首先,我们注意到源端发送第一个数据包的时候,会被目的端防火墙拦下来,那目的端一开始其实是不知道对方要建立连接的,因此这就要求双方都有建连的意愿,才能按照上面的方式成功建连。所以这个是“先有鸡还是先有蛋”的问题。 我们需要一个侧信道(side channel)。这种预先存在的“侧信道”并不需要非常复杂:它可以有几秒钟的延迟,总共只需要传输几千字节的数据,因此一台小型虚拟机就可以轻松地为成千上万台机器牵线搭桥。简单来说,侧信道是 C/S 里服务器的角色,每个端点都可以与其主动建连,而且我们先假设这种建连是长连接,那么只要源端想与目的端通信,就只需要通知侧信道,由侧信道将这个连接的意愿主动推送给目的端,然后两端相互与对方建连,后续就没侧信道什么事儿了。 因此,侧信道可以简单视为一个所有端点都能访问的,用于传递一些元数据与建连意愿的中转服务器。 在 Tailescale 中,协调服务器和 DERP(Detour Encrypted Routing Protocol)服务集群充当了这种侧信道的角色。 ## NAT 的本质 解决了防火墙的问题后,还有一个非常烦人的东西——**NAT(Network Address Translator)**,它会在在数据包入站和出站的时候对其进行修改。 NAT 设备指的是任何执行网络转换的设备,即更改源 IP 地址、目的 IP 地址或端口的设备。NAT 又分为 SNAT 和 DNAT,在讨论 NAT 穿透的时候我们关心的是 SNAT。SNAT 最常见的用途是将大量设备连接到互联网,同时使用比设备数量更少的 IP 地址。对于消费级路由器,我们会将所有设备映射到同一个公网 IP 地址 ### 在有 NAT 的网络中通信 让我们来看看当你的笔记本电脑连接到家里的 Wi-Fi 并与互联网上的服务器通信时会发生什么。 ![Diagram shows established two-way communication through a pair of firewalls.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/6eb196d17f6a5fc312a5ed657f6f5a2a47213cf3-2000x760.png) 您的笔记本电脑从 `192.168.0.20:1234` 发送 UDP 数据包到 `7.7.7.7:5678` 。这和笔记本电脑拥有公网 IP 地址的情况完全一样。但这在互联网上行不通: `192.168.0.20` 是一个私有 IP 地址,它出现在许多人的私有网络中。互联网无法将响应返回给我们。 这时,家用路由器就派上了用场。笔记本电脑的数据包在前往互联网的途中会经过家用路由器。它在自己的公网 IP 地址上选择一个未使用的 UDP 端口——我们将使用 `2.2.2.2:4242` 并创建一个 *NAT 映射* 来建立等效性:局域网侧的 `192.168.0.20:1234` 与互联网侧的 `2.2.2.2:4242` 相同。 从现在开始,每当它看到与该映射匹配的数据包时,它都会相应地重写数据包中的 IP 地址和端口。 ![Diagram shows laptop connected to home Wi-Fi, talking to a server on the internet.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/953404277d143f8e1ece8df72697208593faccb0-2080x640.png) 继续数据包的传输过程:家用路由器应用刚刚创建的 NAT 映射,并将数据包发送到互联网。只不过现在数据包的源地址是 `2.2.2.2:4242` ,而不是 `192.168.0.20:1234` 。数据包继续发送到服务器,而服务器对此毫不知情。它仍然与 `2.2.2.2:4242` 通信,就像我们之前没有使用 NAT 的例子一样。 随后服务器的响应会按预期到达家用路由器,它会将 `2.2.2.2:4242` 重写回原来的地址 `192.168.0.20:1234` 。笔记本电脑 *对此也* 毫不知情,从它的视角来看,互联网神奇地弄清楚了如何处理它的私有 IP 地址。 我们这里举的例子是家用路由器,但同样的原理也适用于企业网络。通常的区别在于,企业网络中的 NAT 层由多台机器组成(出于高可用性或容量方面的考虑),这些机器可以拥有多个公网 IP 地址,因此它们有更多公网 `ip:port` 组合可供选择,从而可以同时支持更多活跃客户端。 ![Multiple NATs on a single layer allow for higher availability or capacity, but function the same as a single NAT.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/cdd34c97cc748ad3a478656650dc5c3f6091dc12-2300x1076.png) ### 了解 STUN 我们现在遇到的问题看起来和之前使用有状态防火墙的情况类似,只不过这次使用的是 NAT 设备: ![Diagram shows laptop’s packets flowing through the home router on their way to the internet.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/20d2f883c81f9771bc15fcde173334b46f7beabb-2180x620.png) 问题在于,两个对等节点都不知道对方的 `ip:port` 。更糟糕的是,严格来说,在对方发送数据包之前, `ip:port` 是 *不存在的* ,因为 NAT 映射只有在出站流量需要时才会创建。我们又回到了有状态防火墙的问题,而且情况更糟:双方都必须先通信,但双方都不知道该和谁通信,而且只有在对方先通信后才能知道。 如何打破僵局?这就需要用到 **STUN(Session Traversal Utilities for NAT)** 了。STUN 既是对 NAT 设备详细行为进行研究的一系列方法,也是一个有助于 NAT 穿透的协议。目前我们主要关注的是网络协议。 STUN 依赖于一个简单的观察:当您从经过 NAT 的客户端与互联网上的服务器通信时,服务器会看到客户端 NAT 为客户端映射的公网 `ip:port` 。因此,服务器可以告诉你它看到的 `ip:port` 。这样,你就能知道来自你局域网 `ip:port` 流量在互联网上实际是什么 `ip:port`,随后你可以把这个映射关系告诉你的对等方,这样他们就知道该把数据包发送到哪里了! STUN 协议的基本原理就是:你的机器向 **STUN 服务器** 发送一个“从你的角度来看,我的端点是什么?”的请求,服务器回复“这是我看到你的 UDP 数据包来自的 `ip:port` ”。 ![STUN is both a set of studies of the detailed behavior of NAT devices, and a protocol that aids in NAT traversal.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/b48af5689535a521c586d6f535bac4f6a95d62b7-1840x976.png) (STUN 协议还有很多其他内容——例如,混淆响应包中的 `ip:port`,以防止 NAT 损坏数据包的有效载荷;此外,它还包含一整套认证机制,但这些机制实际上只被 TURN 和 ICE 这两个 STUN 的同源协议使用,我们稍后会讨论它们。对于地址发现来说,我们可以忽略所有这些内容。) 结合之前介绍的 NAT 原理,在这里顺带一提,如果你想自己实现 NAT 穿透逻辑,那么用于 NAT 穿透的流量(需要发送至 STUN 的流量)以及你的业务流量必须共享同一个 socket,否则你从 STUN 服务器得到的响应就没用。 ### 有了 STUN 就一切就绪了吗 有了 STUN 这个工具,我们似乎就快要完成了。每台机器都可以使用 STUN 来发现其本地套接字的公网 `ip:port` ,并将此信息告知其他机器,然后所有机器都进行防火墙穿透操作,一切就绪……对吧? 嗯,情况比较复杂。有些情况下可行,有些情况下则不行。一般来说,这种方法适用于大多数家用路由器,但会与某些企业级 NAT 网关不兼容。NAT 设备宣传册上越强调其安全功能,失败的概率就越高。(NAT 并不能真正提升安全性,但这又是另一个话题了。) 问题出在我们之前的一个假设上:当 STUN 服务器告诉我们,从它的角度来看,我们的 IP 地址是 `2.2.2.2:4242` ,我们假设这意味着从整个互联网的角度来看,我们的 IP 地址也是 `2.2.2.2:4242` 从这个角度来看,因此任何人都可以通过与我们交谈来联系我们。 `2.2.2.2:4242` 。 事实证明,情况并非总是如此。有些 NAT 设备的行为完全符合我们的预期。它们的有状态防火墙组件仍然希望数据包按正确的顺序传输,但我们可以可靠地确定要提供给对端的正确 `ip:port` ,并利用并发传输技巧成功通过。这些 NAT 设备性能优异,我们的 STUN 和并发数据包发送组合方案可以很好地与它们配合使用。 (理论上,也存在一些 NAT 设备非常宽松,完全不带状态防火墙功能。在这些设备中,甚至不需要同时传输,STUN 请求会提供一个任何人都可以连接的互联网 `ip:port`,并且无需任何额外步骤。就算这类设备仍然存在,但它们也越来越少见了) 其它 NAT 设备更难穿透:对于每个不同的目标地址都有不同的 NAT 映射。 在这样的设备上,即使我们使用同一个 socket 向不同目标设备发送数据, 比如地址 `5.5.5.5:1234` 和 `7.7.7.7:2345` ,那么 2.2.2.2 上就会出现两个不同的端口,分别对应这两个目标地址。 ![Example of when NAT devices are more difficult, and create a completely different NAT mapping for every different destination.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/c9edd473a0702412836a0f0efa1024b2df60a22e-2000x1076.png) ### NAT 分类 根据不同 NAT 的行为,按照开放程度降序、安全程度升序,可以把 NAT 分为四种类型: 1. NAT1(全锥形, Full Cone NAT) 2. NAT2(受限锥形, Restricted Cone NAT) 3. NAT3(端口受限锥形, Port Restricted Cone NAT) 4. NAT4(对称形) 说实话,这些术语确实挺让人困惑的。比如我总是要查一下“受限锥形 NAT”到底是什么意思。而且我发现,有这种困惑的人不在少数,因为网上大部分人把“简单”的 NAT 称为“全锥形 NAT”,但实际上现在它们更可能是“端口受限锥形 NAT”。 上述“简单”和“复杂”NAT 的区别仅在于一点:它们的 NAT 映射是否取决于目标端点。RFC [4787](https://tools.ietf.org/html/rfc4787) 将简单 NAT 称为“端点无关映射”(简称 EIM)将复杂 NAT 称为“端点相关映射”(简称 EDM)。另外,EDM 还有一个子类别,用于指定映射是否仅取决于目标 IP 地址,还是同时取决于目标 IP 地址和端口。对于 NAT 穿透而言,这种区别无关紧要。两种 EDM NAT 对我们来说都是同样糟糕的。 那两种端点依赖性如何映射到 4 种锥形?答案是由 NAT 行为的两个正交维度决定。一个是 NAT 映射行为,另一个是有状态防火墙行为。与 NAT 映射行为类似,防火墙可以是端点无关的,也可以是端点依赖的几种变体。如果您将所有这些放入一个矩阵中,就可以从 NAT 更基本的属性得出它的锥形: | | 与端点无关的 NAT 映射 | 端点相关 NAT 映射(所有类型) | | :----------------------------- | :-------------------- | ----------------------------- | | 端点无关防火墙 | 全锥形 NAT | N/A | | 端点相关防火墙(仅限目标 IP) | 受限锥形 NAT | N/A | | 端点相关防火墙(目标 IP+端口) | 端口受限锥形 NAT | 对称形 NAT | ![img](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/v2-6f45c5d1925dd1ae458d1484cd0cca57_1440w.jpg) ![img](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/v2-92035079ae582f8932ca9ea53a0f3a17_1440w.jpg) ![img](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/v2-262a9e62eb9f74e20257e79c7c7a3893_1440w.jpg) ![img](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/v2-7db3c7c0c8cf6c95f242c41ba0ad5345_1440w.jpg) 这样分析之后,我们可以看出锥形特性对我们来说用处不大。我们真正关心的区别在于对称性与其他任何特性之间的差异——换句话说,我们关心的是 NAT 设备是 EIM 还是 EDM,即是否端点相关,因为从编写 NAT 穿透代码的角度来看,使用我们之前应对防火墙的技巧,可以绕过所有三种类型的防火墙。并且在实际应用中,我们绝大多数情况下遇到的防火墙类型是「目标 IP+端口相关相关的防火墙」。因此,我们可简化上面的表格(相当于只保留最后一行): - 简单 NAT:端点无关 NAT 映射 - 复杂 NAT:端点相关 NAT 映射 回到我们的 NAT 穿透问题。我们之前使用 STUN 和防火墙穿透效果不错,但这些复杂 NAT 是个大问题。整个路径中只要有一个复杂 NAT,就会破坏我们当前的穿透方案。 ### 你有没有想过放弃? 现在是时候谈谈我们讨论中比较尴尬的部分了:如果我们用尽所有办法, 仍然无法连接,该怎么办?很多 NAT 穿透代码会直接放弃,并声明连接失败。这显然不能接受;无法建立连接,Tailscale 就什么都不是。 我们可以使用一个 P2P 双端都能畅通无阻地通信的中继(relay),让它来回交换数据包。一般来说你需要买一台服务器以及一个公网 IP,无论是成本还是通信时延,这个方案都不是很理想。无论如何,总有一些复杂的网络环境让我们不得不使用这种方式。因此,我们仅在直连失败时才采用这种连接方式。 此外,有些网络会比复杂的 NAT 更直接地破坏我们的连接。例如,我们发现加州大学伯克利分校的访客 Wi-Fi 会阻止除 DNS 流量之外的所有出站 UDP 流量。无论使用多么巧妙的 NAT 技巧,都无法绕过防火墙拦截数据包。因此,我们需要某种可靠的备用方案,以防万一。 你可以用多种方式实现中继。最经典的方式是使用 TURN 协议(Traversal Using Relays around NAT)。我们忽略协议的细节,其基本思想是:你向互联网上的 TURN 服务器进行身份验证,服务器会告诉你“好的,我已经分配了 `ip:port` ,我会为你中继数据包”。你把 TURN 服务器的 `ip:port` 告诉你的对端,这样我们就回到了非常简单的客户端/服务器通信场景。 从我们要解决的问题来看待 TURN 的话,它的作用就是将双端的飘忽不定的公网 IP 转变成由 TURN 服务器分配的固定的公网 IP。 对于 Tailscale,我们没有使用 TURN 作为中继协议。TURN 协议使用起来并不方便,而且与 STUN 协议不同,由于互联网上没有开放的 TURN 服务器,因此它并没有真正的互操作性优势。 Tailscale 创建了 [DERP(Detour Encrypted Routing Protocol)](https://tailscale.com/blog/how-tailscale-works#encrypted-tcp-relays-derp) ,这是一种通用的数据包中继协议。它运行在 HTTP 之上,这在具有严格出站规则的网络中非常方便,并根据目标端的公钥来中继加密的有效载荷。 正如我们之前简要提到的,我们使用这条通信路径,既作为 NAT 穿透失败时的数据中继(类似于其他系统中的 TURN),也作为辅助 NAT 穿透的侧信道。DERP 既是我们获取连接的最后手段,也是在条件允许的情况下升级到点对点连接的辅助手段。 现在我们有了中继,除了我们已经掌握的穿透技巧之外…… 就目前讨论的情况来看,我们情况相当不错。大部分情况我们都能应付了,而且我们还有备用方案以防万一。如果你现在停止阅读并只实施上述方案,我估计你在 90% 以上的时间能成功直连,而你的中继则能保证某些连接一直正常工作。 ## NAT 极客笔记 但是……如果您不满足于“足够好”,我们还有很多可以做的!以下是一些零散的技巧,可以在特定情况下帮助我们。这些技巧本身并不能解决 NAT 穿透问题,但通过巧妙地组合使用,我们可以逐步接近 100%的成功率。 ### 生日的好处 让我们重新审视一下硬 NAT 的问题。关键在于,使用简单 NAT 的一方不知道应该将流量发送到复杂 NAT 端的哪个 `ip:port` 。但它必须发送到正确的 `ip:port` ,才能打开防火墙允许流量返回。我们该如何解决这个问题呢? ![Illustration of key issue when the side with the easy NAT doesn’t know what ip: port to send to on the hard side.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/647364b5f593aafded475c9018f5a299f9893104-2000x760.png) 我们通过 STUN 可以获知复杂 NAT(目的端)的 IP。如果 IP 地址正确,我们唯一未知的就是端口。端口有 65535 种可能……源端想要与目的端建立连接,最笨的方法是遍历这些端口。按每秒 100 个数据包的速度计算,最坏情况下需要 10 分钟才能找到正确的端口。而且这种行为极像“端口扫描”,很容易被安全软件拦截。 参考 [生日悖论 ](https://en.wikipedia.org/wiki/Birthday_problem),我们先将问题抽象一下: 1. 目的端开启 m 个端口 2. 源端探测了 n 次 3. N 为可能的端口总数,为固定值 N = 65535 这 n 次探测全部失败都概率是:$$P(\text{fail}) = \left(1 - \frac{m}{N}\right)^n$$ 当 n = 500 时,仅需 5 秒,我们有 85% 的成功率: - 传统方案 (m = 1): 成功率 $\approx 0.76\%$。 - 新方案 (m = 256): 成功率 $1 - (1 - 256/65535)^{500} \approx \mathbf{85.8\%}$。 通过固定 m = 256、仅 n 的大小,成功率可以整理为如下表格: | 随机探测次数 | 成功几率 | | :----------- | :------- | | 174 | 50% | | 256 | 64% | | 1024 | 98% | | 2048 | 99.9% | 这意味着最多探测 10 秒,就能得到高达 98% 的成功率探测。 在的“一端简单、一端复杂”场景中,我们只需要猜中对方的一个端口(目标端口)。 但在两端都是复杂 NAT 场景下,端口都是随机生成的,这意味着,成功连接不再仅仅是猜中对方的端口,而是要实现同时猜中双方的端口。 即便借助生日悖论,在同样的 20 秒尝试时间内,之前的方案几乎 100% 成功,而双端困难方案的成功率仅为 0.01%。要达到 99.9% 的成功率,每端需要发送 17 万个包,耗时约 28 分钟。 更要命的是,路由器需要记录每一个发出的请求(会话)。像 Juniper SRX 300 这样比较不错的办公级路由器,其并发会话上限通常在 64,000 个左右。为了完成这一次穿透,你可能会占满整台路由器的内存,导致公司里其他所有人的网络全部断开。这已经不是在打洞,而是在对自己公司的网络进行 DDoS 攻击。 尽管双方都是复杂 NAT 的时候极难穿透,但掌握这个技巧依然有重大意义,原因在于: - 家庭路由器: 大多是简单 NAT - 公司/云网关: 大多是复杂 NAT 这意味着: - 家 - 家: 秒连 - 家 - 公司/云: 之前的技巧可以轻松搞定,这对远程办公和访问云服务至关重要 - 公司 - 公司: 虽然极其困难且可能搞崩路由器,但至少在理论上提供了一种“最后手段”。而且一旦连通,只要不断发送心跳包维持住这个洞,不需要重新暴力破解 ### 端口映射协议 如果我们能主动控制,放行更多流量,我们的复杂 NAT 就容易多了。结果发现,还真有办法!确切地说与端口映射协议有关。 最古老的协议是 [UPnP IGD](https://openconnectivity.org/developer/specifications/upnp-resources/upnp/internet-gateway-device-igd-v-2-0/) (Universal Plug’n’Play Internet Gateway Device)。UPnP 是一套网络协议栈,允许设备在局域网内自动发现彼此。而 IGD 是 UPnP 协议中的一个特定配置文件。它的核心功能是:允许内网的主机向路由器请求临时的端口转发规则。很多路由器出厂时都预装了 UPnP。 通常情况下,为了让外网访问你的内网服务,你需要登录路由器后台手动设置。而支持 UPnP IGD 的设备会通过以下流程自动化这一过程: 1. 你的设备在局域网广播一个 UDP 包,寻找支持 UPnP 的路由器 2. 路由器响应后,设备会通过 HTTP/XML 接口获取路由器的功能列表 3. 设备告诉路由器:“请把你的公网端口 `12345` 映射到我的内网 IP `192.168.1.5` 的端口 `8888`” 4. 路由器在防火墙/NAT 表中动态添加一条规则。现在,任何发往公网 `IP:12345` 的流量都会直接转发到你的设备上 与 NAT 打洞有着本质区别: | 特性 | STUN / 探测 / 打洞 | UPnP IGD | | -------- | -------------------------------- | ---------------------------------- | | 原理 | 利用 NAT 的既有行为寻找“漏洞” | 显式管理协议,由路由器主动配合 | | 成功率 | 取决于 NAT 类型(复杂 NAT 极难) | 只要路由器开启了 UPnP,成功率 100% | | 适用范围 | 几乎所有网络环境 | 仅限于设备拥有控制权限的局域网 | | 性能 | 有一定的延迟和包损风险 | 极高性能,等同于原生端口转发 | 但 UPnP 的缺点也很明显: - 安全性风险: 如果内网有恶意软件,它也可以通过 UPnP 悄悄在防火墙上开一个后门,将你的电脑完全暴露给公网。因此,许多企业级路由器默认关闭 UPnP - 多层 NAT 失效: UPnP 通常只能作用于第一层路由器。如果你处在“运营商 NAT -> 家用路由器 -> 电脑”的环境下,你只能控制家用路由器,无法改变运营商那一层的 NAT 行为 除了 UPnP 外,类似的还有 [NAT-PMP](https://tools.ietf.org/html/rfc6886) (NAT Port Mapping Protocol)、PCP (Port Control Protocol) 协议等。 因此,为了进一步增强连接的成功率,我们可以在本地默认网关上查找 UPnP IGD、NAT-PMP 和 PCP。如果其中一个协议响应,我们就请求一个公网端口映射。你可以把它想象成一个功能更强大的 STUN:除了发现我们的公网 `ip:port` 之外,我们还可以指示 NAT 对其他网络更友好,不强制执行该端口的防火墙规则。任何来自任何地方并到达我们映射端口的数据包都将返回到我们的设备。 ### 多个 NAT 到目前为止,我们所看到的拓扑结构都是每个客户端位于一个 NAT 设备之后,两个 NAT 设备彼此相对。如果我们构建一个“双重 NAT”,即在其中一台机器前面串联两个 NAT 设备,会发生什么情况? ![What happens if we build a “double NAT”, by chaining two NATs in front of one of our machines?](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/b35c4c4e9151c88c07ef9b77daf2a1bcc97dc36b-2000x760.png) 在这个例子中,客户端 A 发送的数据包在到达互联网之前会经过两层不同的 NAT。但结果与多层有状态防火墙的情况相同:额外的 NAT 层对所有人都是不可见的,而且无论 NAT 层数多少,我们的其他技术都能正常工作。真正重要的是到达互联网之前的“最后一层”NAT 的行为,因为我们的对端必须找到穿过这一层的路径。 最大的问题在于我们的端口映射协议。它们作用于离客户端最近的 NAT 层,而我们需要影响的却是距离最远的 NAT 层。虽然你仍然可以使用端口映射协议,但你会得到一个位于“中间”网络的 `ip:port` ,远程对等方无法访问。遗憾的是,这些协议都无法提供足够的信息来找到“下一个 NAT 层”并重复上述过程,尽管你可以尝试使用 traceroute 命令并向接下来的几跳发送一些盲请求。 实际上,双重 NAT 对大多数互联网应用程序来说是透明的,因为大多数应用程序并不会尝试进行这种显式的 NAT 穿透。 ### CGNAT 即使使用 NAT 来扩展 IPv4 地址的供应,但如果 ISP 还是给每家每户都分配一个独立 IP,仍然会面临资源耗尽的风险。 为了解决这个问题,ISP 使用多层 NAT:您的家庭路由器将您的设备被 SNAT 到 “中间” IP 地址,在下一层 NAT 在转换为公网 IP。这就是“运营商级 NAT”,简称 CGNAT (carrier-grade NAT)。 ![Diagram of “carrier-grade NAT” (CGNAT).](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/96c154b056a1c0a992e131423c81fcfb1e6df368-2100x1080.png) 在 CGNAT 出现之前,一些用户可以通过在家用路由器上手动配置端口转发来绕过 NAT 穿透的难题。但是,你无法重新配置 ISP 的 CGNAT! 好消息是,除了端口映射协议之外,我们目前使用的所有技巧在 CGNAT 环境下都能正常工作。 然而,我们确实需要克服一个新的挑战:如何连接位于同一 CGNAT 之后但内部具有不同本地 NAT 的两个对等体? ![How do we connect two peers who are behind the same CGNAT, but different home NATs within?](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/4fb6b92dd545e2e365cd5739e5ef4dbc9b554875-2100x1080.png) STUN 还能帮上忙吗?STUN 服务器是位于互联网的,访问 STUN 的话它会返回你在互联网上的 "ip: port"(经过 CGNAT 后),但你想要的是在中间网络上的 "ip: port"(经过第一层 NAT 后)。 端口映射协议还能帮上忙吗?如果能成功映射,你就获得了一个在运营商内网行为一致的 `IP:端口`,这让连接变得极度简单。然而,运营商通常会在光猫设备上关掉这些功能,以防软件因为拿到“内网映射”而非“公网映射”而感到困惑。 让我们回到基于 STUN 的技术,看看会发生什么。两个对等体都在同一个 CGNAT 后面,假设 STUN 告诉我们对等体 A 是 `2.2.2.2:1234` ,对等体 B 是 `2.2.2.2:5678` 。 当对等方 A 向目标方发送数据包时会发生什么?理想情况是,A 尝试向 `2.2.2.2:5678`(B 的公网映射)发送包,这个包到达运营商的网关时,网关发现目标 IP 竟然就是自己(`2.2.2.2`),于是它把包原地调头,发给内网的 Peer B。这个行为叫做 HairPinning——回环。 回环虽然逻辑简单,但很多路由器并不支持。路由器的芯片逻辑往往认为“发往非内网 IP 的包必须从 WAN 口发出去;另外,在某些高性能交换芯片上,这种“调头”操作没有预设的物理路径,软件层面也不支持。 在普通局域网,回环不重要,因为两个设备直接用内网 IP(如 `192.168.x.x`)就能说话,不需要经过网关。而在 CGNAT 中,两个 Peer 之间隔着运营商庞大且复杂的内网,他们彼此并不知道对方的内网 IP。他们必须像在互联网上一样进行“打洞”。如果没有回环功能,即便你用了再巧妙的 STUN 打洞技巧,包只要到了网关那里就很可能被丢弃。 回环技术允许你应用与互联网连接相同的技巧,而无需担心是否处于 CGNAT 之后。如果回环和端口映射协议都失败,你就只能使用中继了。 ### 理性情况下使用 IPv6,NAT64 也可以 到这时,我估计你们有些人正在对着屏幕大喊:解决这一切乱象的办法就是 IPv6。这一切的根源在于 IPv4 地址即将耗尽,而我们却不断使用 NAT 来规避这个问题。其实,一个更简单的解决方案就是消除 IP 地址短缺,让世界上所有设备都能直接访问,无需 NAT。而这正是 IPv6 能够带给我们的。 虽然使用 IPv6 仍然有防火墙,我们需要用到文章开头提到的防火墙穿透机制,以及一个侧信道,以便对等方知道要联系哪个 `ip:port` 。我们可能还需要使用比如 HTTP 协议作为备用中继,以便绕过那些阻止出站 UDP 的网络。但我们可以摆脱 STUN、生日悖论技巧、端口映射协议以及所有那些绕环回环的繁琐操作。 但问题在于我们目前所处的世界主要以 v4 为主,我们要连接的对等节点很可能不支持 v6。这意味着在某些支持的 v6 的对等节点上能发挥极佳的效果,但在另一些对等节点上则完全无效。 与此同时,IPv6 和 IPv4 的共存又引入了我们必须考虑的另一种新情况:NAT64 设备。 ![Diagram shows the coexistence of IPv6 and IPv4 introducing a scenario for NAT64 devices.](https://cdn.jsdelivr.net/gh/NOS-AE/assets@main/img/d84e6f36bd77b3cd3b0524c7fac9a5c17fb96f05-2000x900.png) 到目前为止,我们讨论的 NAT 都是 NAT44:它们将一侧的 IPv4 地址转换为另一侧的 IPv4 地址。正如您可能已经猜到的,NAT64 可以在协议之间进行转换。NAT 内部的 IPv6 地址在外部会转换为 IPv4 地址。结合 DNS64 将 IPv4 DNS 响应转换为 IPv6,您可以向终端设备提供一个纯 IPv6 网络,同时仍然允许其访问 IPv4 互联网。 如果你的应用程序只通过 **域名**(如 `google.com`)访问网络,DNS64 + NAT64 完美无缝,你根本感知不到它的存在。但是在做 NAT 穿透的时候,两个 Peer 交换的是具体的 `IP:Port` 字符串。一个纯 IPv6 的节点拿到另一个节点发来的 `1.2.3.4:8888`(IPv4 地址)时,它根本不知道该怎么发包,因为它的网络栈里没有 IPv4 路由。 一种解决方法是 CLAT(Customer-side translator)。CLAT 会在你的操作系统内核中创建一张虚拟 IPv4 网卡。应用程序以为自己是在通过原生 IPv4 发包,但 CLAT 会在底层默默把这些包通过特定的“IPv6 前缀”封装,然后送给外面的 NAT64 转换。我们的手机上几乎全员内置 CLAT,所以手机上写打洞代码很省心。但桌面端和服务器上,CLAT 非常罕见。 如果没有 CLAT,P2P 程序就必须自己手写逻辑,去模拟 CLAT:检测是否存在 NAT64+DNS64 配置,并对其进行适当的使用。 首先程序需要知道自己是否处于一个 NAT64 环境中,这一步可以通过向保留域名 `ipv4only.arpa` 发送 DNS 请求来实现:这个域名只绑定了一个固定的 IPv4 地址。但如果你的查询如果其返回了一个 IPv6 地址,说明 NAT64 在做这个转换。下一步,你可以通过对比这个 IPv6 地址及其 IPv4 地址,提取出运营商的 NAT64 前缀。 拿到前缀后,如果要向 peer 的 IPv4 地址发包,程序手动将其拼接为 IPv6 地址:`{NAT64 前缀 + IPv4 address}` 发送出去;同样地,如果您收到来自 `{NAT64 prefix + IPv4 address}` 的包,这就是 IPv4 流量。接下来,通过这个 NAT64 网关去向公网的 STUN 服务器做经典的 STUN 探测,获取自己在 NAT64 公网上的映射,从而退回到经典的 NAT 穿透流程。 ## 将所有内容与 ICE 整合 既然有这么多打洞技巧(普通打洞、生日悖论、NAT64、UPnP 等),程序怎么知道面对具体的对端时该用哪一个?答案是不要去猜,全部同时尝试,谁行用谁。 这就是 ICE(Interactive Connectivity Establishment,互动式连接建立)协议的核心。 早期的网络研究试图让程序像医生一样,先诊断出路径上是哪种 NAT,然后针对性地下药。但现实中网络设备千奇百怪,这种“先诊断后处理”的模式很快就因为无法横向扩展而破产了。 ICE 协议打破了这种思维定势,它的核心算法极其简单粗暴,却又优雅至极:把所有可能性列出来,同时发包测试,哪个最快最稳,就用哪个。 第一步,本地 Socket 尽可能多地收集自己的公网或内网边界,他们作为 Candidates。不要挑食,只要有可能连通的 `IP:端口` 全放进来: - 原生 IPv6 地址 - IPv4 局域网地址,比如 Wi-Fi 内网 - 通过 STUN 探测到的 IPv4 公网 地址,包括通过 NAT64 合成的 - 通过端口映射协议(UPnP/PMP)主动申请到的公网端口 - 运营商提供的端点,比如用于静态配置的端口转发 第二步,我们通过侧信道与对等方交换候选地址列表,并开始向对方的端点发送探测包。同样,此时无需区分:如果对等方提供了 15 个端点,则向所有 15 个端点发送“你在吗?”的探测包。这些数据包身兼两职。它们的第一个功能是打开防火墙并穿透 NAT,另一个功能是检测对端联通性。 第三步,当收到多个成功的探测回包时,根据某种“启发式算法(Heuristic)”选择最优路径。该算法的妙处在于,只要启发式方法正确,就能始终获得最优解。ICE 要求预先对候选路径进行评分(通常为:LAN > WAN > WAN+NAT)。而 Tailscale 将路径优先级排序从硬编码改为基于 RTT 来排序,这通常会导致相同的 LAN > WAN > WAN+NAT 排序。但与静态排序不同的是,我们可以自然地发现路径所属的“类别”,而无需预先猜测。 传统的 ICE 是“先探测,再通信”。但 Tailscale 进行了彻底的异步化:连接启动时,直接默认走 DERP(中转服务器),用户可以秒连并立即传输数据。与此同时,后台默默进行 ICE 探测。几秒钟后,一旦发现更好的 P2P 通道,连接会 **无缝且透明地升级** 到 P2P,用户完全感知不到。 需要注意的一点是路径不对称。ICE 会尽力确保对等方选择相同的网络路径,从而保证双向数据包畅通,使所有 NAT 和防火墙保持开放状态。但网络世界中,A 到 B 能通,并不意味着 B 到 A 走的是同一条路。为了维持两端 NAT 洞口一直打开,必须保证双向都有流量。解决办法很简单:连接建立后,持续、定期地发送心跳包 如果当前正在用的 P2P 路径因为路由器重启或状态丢失突然断了怎么办?最经济的高可用策略是:直接跌落回保底的中转服务器(Relay),然后重新触发一轮 ICE 路径探测。 最后,我们应该谈谈安全性。在动态切换网络路径的世界里,基于 IP 地址的安全策略(如防火墙白名单)完全失效了。因为你的对端随时可能从一个 Wi-Fi IP 变成一个蜂窝网络 IP,再变成一个 NAT64 IP。正如你的底层采用 QUIC(自带 TLS)或 WireGuard(自带公钥身份验证)一样,无论网络路径怎么变、怎么切,只要上层的加密和认证还在,网络就是安全的。甚至有人在探测阶段伪造 Ping 包,最坏的结果也只是把你的流量吸引到中转服务器上,而无法破解你的加密数据。 ## The End 如果您实施了以上所有步骤,您将拥有最先进的 NAT 穿透软件,能够在各种情况下建立直接连接。而且,当穿透失败时(这种情况很可能发生),您的中继网络可以弥补不足。上述这一切实现起来都相当复杂。 好消息是,一旦你完成了这一步,你就拥有了一种超能力:你可以探索令人兴奋且相对未被充分开发的点对点应用世界。许多有趣的去中心化软件创意都止步于第一关,因为事实证明,在互联网上相互通信比预想的要困难得多。但现在你知道如何克服这一障碍,所以去创造一些酷炫的东西吧! **最后总结一下:** 要实现强大的 NAT 穿透,你需要以下要素: - 一种基于 UDP 的增强型协议 - 程序中直接访问套接字 - 与对等节点交流的侧信道 - 几台 STUN 服务器 - 备用中继网络(可选,但强烈建议) 然后,你需要: - 枚举所有直接连接接口上套接字的 `ip:ports` - 查询 STUN 服务器以发现 WAN `ip:ports` 以及 NAT 的“难度”(如有)。 - 尝试使用端口映射协议来查找更多 WAN `ip:ports` - 检查 NAT64 模式,如果适用,也通过该模式查找 WAN `ip:port` - 通过你的侧信道与你的对等方交换所有这些 `ip:ports` ,以及一些加密密钥来保护所有内容。 - 开始通过备用中继与你的对等节点通信(可选,用于快速建立连接) - 探测所有对等方的 `ip:ports` 以检查连接性;如有必要/需要,还可以执行生日攻击以绕过更复杂的 NAT。 - 当您发现比当前使用的连接路径更好的连接路径时,请透明地升级到新的路径。 - 如果当前路径停止工作,请根据需要降级以保持连接。 - 确保所有数据都经过端到端加密和身份验证。 ## 参考 https://zhuanlan.zhihu.com/p/8857399795