[译] NAT 穿透的工作原理

原文地址: https://tailscale.com/blog/how-nat-traversal-works

在我们之前讨论Tailscale工作原理的文章中,涉及了很多内容。然而,我们略过了如何穿透NAT(网络地址转换器)并直接连接设备,不论它们之间存在什么障碍。现在让我们来探讨这个问题!

我们从简单的问题开始:如何在两台机器之间建立点对点连接。在Tailscale我们会建立一个WireGuard®隧道,但这并不是重点。我们使用的技术适用于很多情况,得益于前人多年努力。例如,WebRTC使用这些技术在网页浏览器之间传输音频、视频和数据。VoIP电话和一些视频游戏也使用类似的技术,尽管不总是成功。

我们将宽泛的讨论这些技术,并适时提及Tailscale和其他例子。假设你正在创建自己的协议并希望实现NAT穿透。你需要两样东西:

首先,协议应该基于UDP。虽然可以使用TCP进行NAT穿透,但这会增加已有棘手问题的复杂性,甚至可能需要修改内核,具体取决于你深入的程度。我们将在本文其余部分专注于UDP。

如果你选择TCP是因为你希望在完成NAT穿透后拥有面向流( stream-oriented connection)的连接,可以考虑使用QUIC。它基于UDP构建,因此我们可以专注于UDP进行NAT穿透,并最终仍然拥有一个良好的流协议。

其次,你需要直接控制网络套接字,以发送和接收数据包。通常,你不能使用现有的网络库进行NAT穿透,因为你需要发送和接收不属于主要通信协议的数据包。一些协议将NAT穿透和其他部分紧密集成(例如WebRTC)。但如果你正在构建自己的协议,最好将NAT穿透视为独立实体,与主要协议共享一个套接字。两者并行运行,一个使另一个成为可能。

直接控制套接字可能很困难,具体取决于你的情况。一种解决方法是运行本地代理。你的协议与代理通信,代理同时进行NAT穿透并中继数据包给对等方。这个间接层允许你在不更改原始程序的情况下受益于NAT穿透。

让我们从基本原则开始讨论NAT穿透。我们的目标是让UDP数据包在两台设备之间双向流动,以便其他协议(如WireGuard、QUIC、WebRTC等)能够正常工作。 有两个主要障碍:状态防火墙NAT设备

理解状态防火墙

状态防火墙是我们两个问题中较简单的一个。事实上,大多数NAT设备都包括一个状态防火墙,所以我们需要先解决这个子问题,然后才能解决NAT问题。

有许多种类的状态防火墙。你可能知道的有Windows Defender防火墙、Ubuntu的ufw(使用iptables/nftables)、BSD的pf(macOS也使用)和AWS的安全组。它们都高度可配置化,但最常见的配置是允许所有“出站”连接并阻止所有“入站”连接,可能会有一些例外,比如允许入站的SSH。

但连接和“方向”是协议设计者想象的产物。在实际操作中,每个连接最终都是双向的;所有数据包都会来回传输。防火墙如何知道什么是入站,什么是出站?

这就是状态部分的作用。状态防火墙记住过去看到的数据包,并可以在处理新出现的数据包时使用这些信息。

对于UDP,规则非常简单:如果防火墙先前看到一个匹配的出站数据包,它将允许入站的UDP数据包。例如,如果我们的笔记本防火墙看到一个从2.2.2.2:1234到7.7.7.7:5678的UDP数据包离开笔记本,它会记住从7.7.7.7:5678到2.2.2.2:1234的入站数据包也应该被允许。信任的一方显然打算与7.7.7.7:5678通信,所以我们应该让他们回复。

(顺便说一句,一些非常松散的防火墙可能允许来自任何地方的流量返回到2.2.2.2:1234,只要2.2.2.2:1234曾与任何人通信过。这种防火墙使我们的穿透工作更容易,但越来越少见。)

防火墙面临的问题

只要路径上的所有防火墙的规则都只“朝向”相同的方向,这条UDP流量规则对我们来说只是一个小问题。防火墙后面的机器必须是发起所有连接的一方,否则没有什么可以与之通信。

这很好,但并不是很有趣:我们已经重新发明了客户端/服务器通信模式,其中服务器使自己易于客户端访问。在VPN世界中,这导致了星型拓扑:VPN hub 没有防火墙阻止客户端访问,防火墙保护的客户端可以连接到 VPNhub。

当两个“客户端”想直接通信时,问题就出现了。双方的防火墙面向彼此。根据我们上面建立的规则,这意味着双方都必须先行动,但也意味着双方都不能先行动,因为都在等待对方先行动!

我们如何解决这个问题?一种方法是要求用户重新配置双方中的一个或两个防火墙“打开一个端口”,允许另一台机器的流量。这对用户来说不太友好。它也无法扩大到像Tailscale这样的网状网络中进行配置,因为在这种网络中,连接中的一方或者双方经常变动。而且,在很多情况下你无法控制防火墙:比如你最喜欢的咖啡馆或机场的路由器。

因此,我们需要寻找一种不用重新配置防火墙的方式。

绕过严格的防火墙

诀窍是状态防火墙的规则。对于UDP:数据包必须先流出,数据包才能流入

然而,没有规定这些数据包必须彼此相关,除了IP和端口正确匹配。只要一些数据包以正确的源和目的地流出,任何看起来像响应的数据包都会被允许流入,即使对方从未收到你的数据包!

所以,为了穿越这些多个状态防火墙,我们需要共享一些信息以开始:对等方必须提前知道其对等方正在使用的ip:port。一种方法是手动静态配置每个对等方,但这种方法的扩展性很差。为了超越这一点,我们建立了一个协调服务器,以灵活、安全的方式保持ip:port信息同步。

然后,对等方开始相互发送UDP数据包。他们清楚其中一些数据包会丢失,因此这些数据包不能携带任何重要信息,除非你准备重新传输它们。这通常适用于UDP,但在这里尤其适用。在这个过程中,我们将丢失一些数据包。

我们的笔记本和工作站现在在固定端口上监听,因此它们都确切知道要与哪个ip:port通信。让我们看看会发生什么。

笔记本的第一个数据包,从2.2.2.2:1234到7.7.7.7:5678,通过Windows Defender防火墙并进入互联网。另一端的公司防火墙阻止了该数据包,因为它没有记录7.7.7.7:5678曾与2.2.2.2:1234通信。然而,Windows Defender现在记住应该允许来自7.7.7.7:5678到2.2.2.2:1234的响应。

接下来,工作站的第一个数据包,从7.7.7.7:5678到2.2.2.2:1234,通过公司防火墙并穿越互联网。当它到达笔记本时,Windows Defender认为“啊,这是对那个出站请求的响应”,并允许该数据包通过!此外,公司防火墙现在记住应该允许来自2.2.2.2:1234到7.7.7.7:5678的响应,这些数据包也是可以的。

收到工作站的一个数据包后,笔记本再次发送另一个数据包。它通过Windows Defender防火墙,通过公司防火墙(因为这是对先前发送的数据包的“响应”),并到达工作站。

成功!我们为一对防火墙建立了双向通信,这在一开始看来是不可能的。

NAT设备的问题

事情并非总是那么简单。因为我们依赖防火墙和NAT的一些规则。所以我们需要小心管理我们的连接。那么哪些是我们需要注意的呢?

两端必须大致同时尝试通信,以便所有中间防火墙在两个对等方仍然在的时候打开。一个方法是让对等方持续重试,但这很浪费资源。如果两端能够同时开始建立连接,岂不是更好?

这听起来有点像递归:要进行通信,首先你需要能够通信。然而,这个预先存在的‘辅助通道’不需要非常复杂:它可以有几秒钟的延迟,只需要传输几千字节的数据,因此一个小型虚拟机就可以轻松地为成千上万的机器充当媒介。

在过去,我曾使用XMPP聊天消息作为辅助通道,效果很好。另一个例子是,WebRTC需要你自己创建一个‘信令通道’(这个名称揭示了WebRTC的IP电话背景),并将其插入WebRTC API中。在Tailscale中,我们的协调服务器和一系列DERP(Detour Encrypted Routing Protocol)服务器充当了我们的辅助通道。

状态防火墙的内存有限,这意味着我们需要定期通信以保持连接的活跃。如果一段时间内没有看到数据包(对于UDP来说,一个常见的值是30秒),防火墙会忘记这个会话,我们不得不重新开始。为了避免这种情况,我们使用计时器,必须定期发送数据包以重置计时器,或者有一些带外的方式来按需重新启动连接。

好的一面是,我们不需要担心我们的两个对等方之间究竟有多少防火墙。只要它们是状态防火墙并允许出站连接,同时传输技术就能穿透任意数量的层。这真的很棒,因为这意味着我们只需实现一次逻辑,它就能在所有地方工作。

……对吗?

嗯,不完全是。要让这项工作成功,我们的对等方需要提前知道对方使用的ip:port。这就是NAT发挥作用并破坏我们计划的地方。

NAT 的本质

我们可以把NAT(Network Address Translator)设备看作是具有一种非常恼人特性的状态防火墙:除了所有状态防火墙的功能外,它们还会在数据包通过时对其进行修改。

NAT设备是指任何进行网络地址转换的设备,即更改源或目的IP地址或端口。然而,当谈论连接问题和NAT穿透时,所有问题都来自源NAT,简称SNAT。如你所料,还有目的NAT(DNAT),它非常有用,但与NAT穿透无关。

SNAT最常见的用途是通过使用少于设备数量的IP地址将许多设备连接到互联网。在消费级路由器的情况下,我们将所有设备映射到一个面向公众的IP地址。这是可取的,因为事实证明,世界上希望访问互联网的设备数量远远超过可用的IP地址数量(至少在IPv4中——我们稍后会讨论IPv6)。NAT让我们能够让许多设备共享一个IP地址,因此尽管全球IPv4地址短缺,我们仍可以用现有的地址进一步扩展互联网。

在充满NAT的网络中导航

让我们看看当你的笔记本连接到家庭Wi-Fi并与互联网上的服务器通信时会发生什么。

你的笔记本从192.168.0.20:1234发送UDP数据包到7.7.7.7:5678。这与笔记本有一个公共IP时的情况完全相同。但 192.168.0.20 是一个私有IP地址,这个ip 地址重复出现在许多不同人的私有网络中。互联网将不知道如何将响应传回给我们。

此时家庭路由器登场。笔记本的数据包在前往互联网的途中先经过家庭路由器,路由器看到这是一个从未见过的新会话。

它知道192.168.0.20在互联网上无法被传递,但它可以绕过这个问题:它在自己的公共IP地址上选择一些未使用的UDP端口——我们使用2.2.2.2:4242,并创建一个NAT映射,建立等价关系:局域网侧的192.168.0.20:1234相当于互联网侧的2.2.2.2:4242。

从现在开始,每当它看到匹配该映射的数据包时,它会适当地重写数据包中的IP和端口。

继续我们的数据包之旅:家庭路由器应用刚刚创建的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 组合可供选择,并能支持更多的活跃客户端。

STUN

我们现在遇到了一个类似于之前状态防火墙场景的问题,但这次涉及NAT设备:

我们的问题是,我们的两个客户端不知道彼此NAT后的ip。更糟糕的是,严格来说,在对方发送数据包之前根本不存在ip,因为NAT映射只有在向互联网发送出站流量时才会创建。我们回到了状态防火墙的问题,而且更棘手:双方都必须先发言,但没有一方知道该与谁发言,直到另一方先发言为止。

我们如何打破这个僵局?这就是STUN的作用所在。STUN既是一系列对NAT设备详细行为的研究,也是一种帮助NAT穿透的协议。目前我们关注的主要是网络协议。

STUN依赖于一个简单的观察:当从一个被NAT的环境与互联网上的服务器通信时,服务器看到的 ip 是NAT设备为转发客户端所使用的公网ip,而不是你的局域网ip。因此,服务器可以告诉你它看到的ip。这样,你就知道你映射的公网ip端口是多少,你可以将这个映射告诉你的对等方,现在他们知道该往哪里发送数据包了!我们又回到了“简单”的防火墙穿透情况。

STUN协议的基本原理就是这样:你的机器向STUN服务器发送一个“从你的角度看,我的端点是什么?”的请求,服务器回复“这是我看到你的UDP数据包来自的ip。”

(STUN协议还有很多其他的内容——比如,有一种方式可以对响应中的ip进行混淆,以防止非常糟糕的NAT设备弄乱数据包的负载,还有一个完整的认证机制,主要由TURN和ICE使用,这些都是STUN的兄弟协议,我们稍后会讨论。对于地址发现,我们可以忽略这些内容。)

顺便提一下,这就是为什么我们在介绍中说,如果你想自己实现这个功能,NAT穿透逻辑和你的主协议必须共享一个网络套接字。每个套接字在NAT设备上得到的映射是不同的,所以为了发现你的公共ip,你必须从你打算用来通信的套接字发送和接收STUN数据包,否则你会得到一个无用的答案。

这有什么帮助

有了STUN这个工具,看起来我们已经接近完成了。每台机器都可以使用STUN来发现自己的公网映射ip,然后告诉对方,双方都进行防火墙穿透,然后我们就都搞定了……对吗?

其实,这是一个喜忧参半的情况。这在某些情况下会奏效,但在其他情况下则不会。一般来说,这对大多数家庭路由器有效,但在一些企业级NAT网关上会失败。NAT设备越是强调它是一种安全设备,失败的概率就越高。(NAT并不能以任何有意义的方式增强安全性,但这是另一个话题。)

问题在于我们之前做了一个假设:当STUN服务器告诉我们,从它的角度来看,我们是2.2.2.2:4242时,我们假设这意味着从整个互联网的角度来看,我们都是2.2.2.2:4242,因此任何人都可以通过与2.2.2.2:4242通信来联系到我们。

事实证明,这并不总是正确的。一些NAT设备的行为完全符合我们的假设。它们的状态防火墙组件仍然希望看到数据包以正确的顺序流动,但我们可以可靠地找出正确的ip并将其提供给我们的对等方,然后通过同时传输技巧来穿透。这些NAT设备非常棒,我们结合STUN和同时发送数据包的方法在这些设备上会很好地工作。

(理论上,也有一些超级宽松的NAT设备,根本没有状态防火墙功能。在这些设备中,你甚至不需要同时传输,STUN请求告诉你一个任何人都可以连接的互联网ip,不需要进一步的操作。如果这种设备还存在,它们也越来越稀少。)

其他NAT设备则更为复杂,它们为你与每个不同的目的地通信创建完全不同的NAT映射。在这样的设备上,如果我们使用同一个套接字与5.5.5.5:1234和7.7.7.7:2345通信,我们最终会在2.2.2.2上得到两个不同的端口,每个目的地一个。如果你使用错误的端口进行通信,你就无法通过。

对 NAT 进行命名

既然我们已经发现并非所有NAT设备的行为都相同,我们应该讨论一下术语。如果你以前做过与NAT穿透相关的事情,可能听说过“全锥型(Full Cone)”、“限制锥型(Restricted Cone)”、“端口限制锥型(Port-Restricted Cone)”和“对称型(Symmetric)”NAT。这些术语来自早期对NAT穿透的研究。

  1. 全锥型(Full Cone)NAT:
    当一个内部IP地址和端口(如192.168.0.1:1234)映射到一个外部IP地址和端口(如2.2.2.2:5678)时,任何外部主机都可以通过这个外部IP地址和端口与内部主机通信,只要外部主机发送的数据包符合这个映射。
  2. 限制锥型(Restricted Cone)NAT:
    内部主机的IP地址和端口映射到一个外部IP地址和端口,但只有内部主机先前发送数据包到的外部IP地址才可以通过这个外部IP地址和端口与内部主机通信。
  3. 端口限制锥型(Port-Restricted Cone)NAT:
    类似于限制锥型NAT,但限制更严格,不仅要求外部IP地址匹配,还要求外部端口也匹配。
  4. 对称型(Symmetric)NAT:
    每个内部IP地址和端口对每个不同的外部IP地址和端口组合都有一个不同的映射。即如果一个内部主机与两个不同的外部主机通信,它们将使用不同的外部IP地址和端口组合。

这些术语其实相当令人困惑。我总是需要查阅什么是“限制锥型”NAT。并不是只有我这样,因为现在大多数互联网用户称“简单”的NAT为“全锥型”,尽管它们更可能是“端口限制锥型”。

最近的研究和RFC提出了一种更好的分类方法。首先,早期研究中只关注NAT的“锥型(多个ip映射为单个ip)”维度,而实际上NAT设备的行为变化维度远不止一个“锥型”。因此,单一地关注NAT是否为“锥型”是不够的,也不够全面. 其次, 使用更简单明了的词汇来描述NAT的行为,使人们更容易理解NAT设备的实际行为。这些新术语更直观,避免了使用像“锥型”这样可能让人困惑的术语。

上述“简单”和“困难”的NAT在一个维度上有所不同:它们的NAT映射是否依赖于目标。这种情况下,RFC 4787称简单的变体为“端点独立映射”(EIM),而困难的变体为“端点依赖映射”(EDM)。EDM还有一个子类别,具体说明映射是仅根据目标IP变化,还是根据目标IP和端口变化。对于NAT穿透来说,这种区分并不重要。所有类型的EDM NAT对我们来说都是坏消息。

按照命名事物的传统,端点独立的NAT仍然依赖于一个端点:每个源ip得到一个不同的映射,否则你的数据包会与其他人的数据包混在一起,造成混乱。严格来说,我们应该说“目标端点独立映射”(DEIM?),但这太冗长了。端点总是指“目标端点”。

你可能会想,端点依赖的两种类型如何映射到四种锥型。答案是锥型包括了NAT行为的两个正交维度。一个是我们刚刚讨论过的NAT映射行为,另一个是状态防火墙行为。与NAT映射行为类似,防火墙可以是端点独立或几种端点依赖的变体。如果将所有这些放入矩阵中,你可以从其更基本的属性中重建NAT的锥型:

端点独立NAT映射 端点依赖NAT映射(所有类型)
端点独立防火墙 全锥型NAT 理论上存在,但实际中未出现
端点依赖防火墙(目标IP) 限制锥型NAT 理论上存在,但实际中未出现
端点依赖防火墙(目标IP+端口) 端口限制锥型NAT 对称型NAT

端点不依赖的NAT映射(Endpoint-Independent NAT mapping)是指内部主机的源IP和端口在与不同的目标IP通信时总是映射到相同的外部IP和端口。换句话说,无论目标IP是什么,内部主机的源IP和端口映射到的外部IP和端口保持不变。
例如,如果内部主机A(IP: 192.168.0.20,端口: 1234)通过一个端点不依赖的NAT设备与两个不同的目标IP通信(例如目标IP1: 7.7.7.7,目标IP2: 8.8.8.8),那么:
主机A与目标IP1通信时,其源IP和端口可能映射为外部IP 2.2.2.2,端口 4242。
主机A与目标IP2通信时,其源IP和端口仍然映射为外部IP 2.2.2.2,端口 4242。
这个映射是不变的,不会因为目标IP的变化而变化。
端点依赖的NAT映射(Endpoint-Dependent NAT mapping)则会根据目标IP的不同为内部主机分配不同的外部IP和端口。例如:
主机A与目标IP1通信时,源IP和端口可能映射为外部IP 2.2.2.2,端口 4242。
主机A与目标IP2通信时,源IP和端口可能映射为外部IP 2.2.2.2,但端口可能是 4243。
这种映射依赖于目标IP,因此每个不同的目标IP会有不同的映射。

这样细分之后,我们可以看到锥型对我们并不太有用。我们关心的主要区别是对称型与其他类型——换句话说,我们关心的是NAT设备是EIM还是EDM。

虽然确切知道防火墙的行为很有趣,但从编写NAT穿透代码的角度来看,我们不关心。我们的同时传输技巧可以穿透所有三种防火墙。在实际应用中,我们主要处理的是IP和端口依赖的端点防火墙。所以,对于实际代码,我们可以简化表格为:

端点独立NAT映射 端点依赖NAT映射(目标IP)
防火墙是 简单NAT 困难NAT

如果你想了解更多关于NAT的新分类,你可以在RFC 4787(UDP的NAT行为要求)5382(TCP的要求)5508(ICMP的要求)中找到详细信息。如果你在实现NAT设备,这些RFC也是你实现行为的指南,确保它们是良好行为的设备,不会因为Halo多人游戏无法工作而产生投诉。

回到我们的NAT穿透。我们在使用STUN和防火墙穿透时做得很好,但这些困难的NAT是个大问题。在整个路径中只需要一个这样的NAT就会打破我们当前的穿透计划。

但是等等,这篇文章的标题是“如何实现NAT穿透”,而不是“如何实现不了NAT穿透”。所以,我肯定有一些解决办法,对吧?

你有没有想过放弃?

这是我们讨论的一个尴尬点:当我们用尽了所有的技巧,却依然无法连接时会发生什么?很多NAT穿透代码在这种情况下会放弃,宣称无法建立连接。显然,这对我们来说是不可接受的;Tailscale离不开连接性。

我们可以使用一个两端都能顺利通信的中继,并让它在两端之间转发数据包。但等一下,这不是很糟糕吗?

某种程度上是的。这当然不如直接连接好,但如果中继在网络路径上“足够接近”你原本的直接连接路径,并且具有足够的带宽,对连接质量的影响并不大。可能会有一些额外的延迟,带宽可能会减少一些,但这仍然比无法连接要好得多,这是我们之前面临的情况。

请记住,我们只在直接连接失败的情况下才会采用这种方法。我们仍然可以通过许多不同的网络建立直接连接。使用中继来处理这些情况其实并不算糟糕。

此外,一些网络可以比复杂的NAT更直接地破坏我们的连接。例如,我们观察到UC Berkeley的客人Wi-Fi阻止了所有出站的UDP流量,除了DNS流量。再多的NAT技巧也无法绕过吞噬数据包的防火墙。所以无论如何,我们都需要某种可靠的备用方案。

你可以通过多种方式实现中继。经典的方法是使用一种叫做TURN(Traversal Using Relays around NAT)的协议。我们将跳过协议的细节,但基本思路是你向互联网上的TURN服务器进行身份验证,它会告诉你“好,我分配了ip,会为你中继数据包。”你把TURN的ip告诉你的对等端,这样我们就回到了一个完全简单的客户端/服务器通信场景。

对于Tailscale,我们没有使用TURN作为我们的中继。它不是一种特别好用的协议,而且与STUN不同的是,它没有实际的互操作性优势,因为互联网没有公开的TURN服务器。

相反,我们创建了DERP(Detoured Encrypted Routing Protocol),这是一种通用的数据包中继协议。它运行在HTTP之上,这在出站规则严格的网络中非常方便,并且根据目标的公钥中继加密的有效负载。

如前所述,我们将这种通信路径既用作NAT穿透失败时的数据中继(在其他系统中类似于TURN的作用),又用作帮助NAT穿透的侧通道。DERP既是我们最后的连接备份,也是我们在可能的情况下升级到点对点连接的助手。

现在我们有了一个中继,再加上之前讨论的穿透技巧,我们的状况相当不错。我们不能穿透所有的障碍,但可以穿透相当多的障碍,并且当我们失败时还有备份。如果你现在停止阅读并仅仅实现上述方法,我估计你可以在90%以上的情况下建立直接连接,并且你的中继可以保证始终有一些连接性。

额外的内容

但…如果你对现在足够好的中继不满意,还有很多我们可以做的. 以下是一组有点杂乱的技巧,可以在特定情况下帮助我们。单独使用它们都无法解决NAT穿透问题,但通过明智地组合它们,我们可以逐步接近100%的成功率。

生日悖论的好处

让我们重新审视困难NAT的问题。关键问题是,拥有简单NAT的一方不知道要向困难NAT的一方发送到哪个ip。但是必须向正确的ip发送,以便打开其防火墙以返回流量。我们能对此做些什么呢?

我们知道困难NAT一方的某个ip ,因为我们运行了STUN。假设我们得到的IP地址是正确的。尽管这不一定是正确的,但让我们暂时假设这个前提。事实证明,大多数情况下这种假设是安全的。(如果你想知道为什么,请参阅RFC 4787中的REQ-2。)

如果IP地址是正确的,我们唯一不知道的就是端口。端口有65535个可能性… 我们能试遍所有吗?以每秒100个数据包的速度,最坏的情况是10分钟找到正确的端口。这比没有好,但也不算好。并且它看起来确实像端口扫描(因为实际上确实如此),这可能会激怒网络入侵检测软件。

通过生日悖论,我们可以做得更好。与其在困难NAT一侧只打开1个端口并让简单NAT一侧尝试65535种可能性,不如在困难NAT一侧打开256个端口(通过让256个套接字发送到简单NAT一侧的ip),并让简单NAT一侧随机探测目标端口。

我省略了详细的数学计算,但你可以查看我在工作过程中制作的小Python计算器。这个计算与“经典”生日悖论略有不同,因为它是研究两个包含不同元素的集合之间的碰撞,而不是单个集合内的碰撞。幸运的是,这种差异对我们略有利!以下是随机探测的成功率(即成功通信的概率),假设困难NAT一侧有256个端口开放:

随机探测次数 成功概率
174 50%
256 64%
1024 98%
2048 99.9%

如果我们保持每秒100个端口的探测速率,有一半概率我们将在2秒内成功连接。而且即使我们不走运,在20秒内我们几乎可以保证找到连接方式,探测不到总搜索空间的4%。

这太棒了!通过这个额外的技巧,路径中的一个困难NAT只是一个令人讨厌的障碍,但我们可以应付。那么如果有两个困难NAT呢?

我们可以尝试应用相同的技巧,但现在搜索变得更加困难:每次通过困难NAT探测一个随机目标端口也会导致随机源端口。这意味着我们现在在寻找{源端口,目标端口}对的碰撞,而不仅仅是目标端口。

我再一次省略了计算,但在与前一个设置相同的情况下(困难NAT一侧有256个探测,简单NAT一侧有2048个探测),20秒后的成功率是… 0.01%。

如果你之前研究过生日悖论,这不应该令人惊讶。生日悖论让我们将N“努力”转换为sqrt(N)的量级。但我们将搜索空间的大小平方了,因此即使是减少的努力量也还是大量的努力。要达到99.9%的成功率,我们需要每一侧发送170,000次探测。以每秒100个数据包的速率,这需要28分钟的尝试才能建立连接。50%的时间我们会在“仅仅”54,000个数据包后成功,但这仍然需要9分钟的等待时间,而没有连接。尽管如此,这还是比没有生日悖论的1.2年要好。

在某些应用中,28分钟可能仍然值得。花半小时强行突破,然后你可以继续ping以保持打开的路径无限期——或者至少直到其中一个NAT重启并丢弃所有状态,然后你又回到了强行突破的情况。但对于任何类型的交互式连接来说,这并不乐观。

更糟糕的是,如果你查看常见的办公路由器,你会发现它们的活动会话限制惊人地低。例如,Juniper SRX 300的最大活动会话数为64,000个。我们一次尝试就会消耗其整个会话表!而且这还假设路由器在过载时表现良好。所有这些仅仅是为了建立单个连接!如果我们有20台机器在同一个路由器后面进行这些操作呢?那将是灾难。

尽管如此,通过这个技巧,我们可以穿透比以前稍微复杂一些的网络拓扑。这是一个大问题,因为家庭路由器往往是简单NAT,而困难NAT往往是办公路由器或云NAT网关。这意味着这个技巧为我们提供了改进的家庭到办公室和家庭到云的连接性,以及一些办公室到云和云到云的连接性。

可操控的端口映射

如果我们可以请求困难NAT不要那么难搞,并允许更多数据通过,那NAT穿透将会变得容易得多。事实上,有协议可以实现这个目的!实际上有三种协议。让我们来谈谈端口映射协议。

最早的是UPnP IGD(Universal Plug’n’Play Internet Gateway Device 协议。它诞生于1990年代后期,因此使用了很多90年代的技术(XML、SOAP、通过UDP的多播HTTP——真的),而且很难正确和安全地实现——但很多路由器都带有UPnP,很多路由器至今仍在使用。如果我们简化逻辑,我们会发现所有三种端口映射协议都实现了一个非常简单的请求-响应:“嗨,请将WAN端口转发到lan-ip”以及“好的,我已为你分配了wan-ip。”

在UPnP IGD推出几年后,苹果推出了一个竞争协议,叫做NAT-PMP(NAT Port Mapping Protocol)。与UPnP不同,它仅处理端口转发,且极其简单易实现,无论是客户端还是NAT设备。不久之后,NAT-PMP v2又被重新命名为PCP(Port Control Protocol)

为了帮助我们更好的连接,我们可以在本地默认网关上寻找UPnP IGD、NAT-PMP和PCP协议。如果其中一个协议有响应,我们就请求一个公共端口映射。你可以将其视为一种超级增强的STUN:除了发现我们的公共ip:port外,我们还可以指示NAT对我们的对等节点更加友好,不对该端口强制执行防火墙规则。任何从任何地方到达我们映射端口的数据包都能回到我们这里。

然而,你不能假设这些协议的存在。它们可能没有在你的设备上实现或者默认被禁用,没有人知道需要开启它们。它们也可能被策略禁用。

策略禁用是相当常见的,因为 UPnP 存在许多引人注目的漏洞(这些漏洞已经修复,因此如果正确实现的话,新设备可以安全地提供UPnP)。不幸的是,许多设备都带有一个“UPnP”复选框,实际上可以同时切换 UPnP、NAT-PMP 和 PCP,因此担心 UPnP 安全性的人们最终也禁用了完全安全的替代方案。

总的来说,当这些协议可用时,它有效地减少网络链路上的一次NAT,使得连接变得轻而易举……但让我们看看一些不寻常的情况。

多 NAT 协商(Negotiating numerous NATs)

目前为止,我们讨论的拓扑结构是每个客户端都位于一个NAT设备后,两者面对面相对。那么,如果在我们的一台设备前链上两个NAT,形成“双重NAT”,会发生什么?

如果我们在设备前链上两个NAT,会发生什么?在这种情况下,没有什么特别之处。客户端A的数据包在到达互联网之前会经过两层不同的NAT。但结果与多层状态防火墙的情况相同:额外的NAT层对所有人都是不可见的,我们的其他技术无论有多少层NAT都能正常工作。关键在于“最后”一层NAT的行为,因为那是我们的对等节点必须穿透的一层。

破坏的主要是端口映射协议。端口映射协议作用于离客户端最近的一层NAT,而我们需要影响的是最远的一层。你仍然可以使用端口映射协议,但你会得到一个“中间”网络中的ip,而你的远程对等节点无法访问。遗憾的是,这些协议没有提供足够的信息让你找到“下一层NAT”并在那里重复这个过程,尽管你可以尝试使用traceroute和一些盲目的请求来探测下几个跳。

破坏端口映射协议是为什么网上充满了关于双重NAT的警告,并且告诉你要尽量避免它们。但实际上,对大多数使用互联网的应用程序来说,双重NAT是完全不可见的,因为大多数应用程序不会尝试这种显式的NAT穿透。

我并不是说你应该在你的网络中设置双重NAT。破坏端口映射协议会影响许多视频游戏的多人模式,并且可能会剥夺你的网络中的IPv6,从而失去一些无需NAT的良好连接选项。但是,如果不可控的环境因素迫使你使用双重NAT,而且你能接受其缺点,大多数东西仍然会正常工作。

这是一件好事,因为你知道什么不可控的环境因素会迫使你使用双重NAT吗?让我们谈谈运营商级NAT。

关于 CGNAT

即使使用NAT来延长IPv4地址的供给,我们仍然面临地址不足的问题。ISP已无法为其网络中的每个家庭分配一个完整的公共IP地址。为了解决这个问题,ISP递归地应用SNAT:你的家庭路由器将设备SNAT到一个“中间”IP地址,而在ISP网络的更远处,第二层NAT设备将这些中间IP地址映射到更少的公共IP地址上。这就是“carrier-grade NAT”,简称CGNAT。

如何连接位于相同CGNAT后面但不同家庭NAT内的两个对等节点?

CGNAT对NAT穿透技术来说是一个重要的发展。在CGNAT之前,用户可以通过手动配置家庭路由器的端口转发来解决NAT穿透的难题。但你无法重新配置ISP的CGNAT!现在即使是高级用户也必须应对NAT带来的问题。

好消息是,这只是一个普通的双重NAT,就像我们之前讨论的那样,大部分情况下都是可以的。尽管有些东西可能不会像原来那样好用,但仍然可以正常工作,ISP也能因此收费。除了端口映射协议之外,我们当前的所有技巧在CGNAT环境中都能正常工作。

然而,我们需要克服一个新挑战:如何连接位于相同CGNAT后面但不同家庭NAT内的两个对等节点?这就是我们在上图中设置对等节点A和B的方式。

问题在于STUN并没有按照我们希望的方式工作。我们希望找到我们在“中间网络”中的ip,因为它有效地扮演了我们两个对等节点的小型互联网的角色。但STUN告诉我们的是从STUN服务器的角度看到的ip,而STUN服务器在互联网外的CGNAT之外。

如果你认为端口映射协议在这里可以帮忙,那你是对的!如果任一对等节点的家庭NAT支持其中一个端口映射协议,我们就很高兴,因为我们有一个像未经过NAT的服务器那样的ip,连接变得很简单。讽刺的是,双重NAT“破坏”端口映射协议反而帮助了我们!当然,我们仍然不能指望这些协议帮忙,因为CGNAT ISP往往在他们提供的设备中关闭它们,以避免软件因获得“错误”结果而困惑。

但如果我们没有那么幸运,不能在NAT上映射端口呢?让我们回到基于STUN的技术,看看会发生什么。两个对等节点都位于相同的CGNAT后面,所以假设STUN告诉我们对等节点A是2.2.2.2:1234,对等节点B是2.2.2.2:5678。

问题是:当对等节点A发送一个数据包到2.2.2.2:5678时,会发生什么?我们可能希望在CGNAT盒子中发生以下情况:

  • 应用对等节点A的NAT映射,将数据包重写为从2.2.2.2:1234到2.2.2.2:5678。
  • 注意到2.2.2.2:5678匹配对等节点B的入站NAT映射,将数据包重写为从2.2.2.2:1234到对等节点B的私有IP。
  • 将数据包发送到对等节点B,在“内部”接口上而不是向互联网发送。

这种NAT的行为称为“回发”(hairpinning),经过这么多铺垫,你不会惊讶地发现回发在某些NAT上有效,而在其他NAT上无效。

实际上,许多其他方面表现良好的NAT设备不支持回发,因为它们做出了一些假设,比如“从我的内部网络到非内部IP地址的数据包总是会流向互联网”,因此当数据包在路由器内转向时就会丢弃这些数据包。这些假设甚至可能被固化在路由硅片中,无法通过新硬件修复。

回发或其缺失是所有NAT的特性,不仅仅是CGNAT。在大多数情况下,这无关紧要,因为你会期望两个局域网设备直接互相通信,而不是通过它们的默认网关进行回发。而且遗憾的是,这通常无关紧要,可能也是回发常常失效的原因。

但一旦涉及到CGNAT,回发变得至关重要。回发让你可以使用与互联网连接相同的技巧,而不必担心你是否位于CGNAT之后。如果回发和端口映射协议都失败了,你只能依靠中继。

理想情况下使用IPv6,尽管有NAT64

到目前为止,我预计你们中的一些人已经在屏幕前大喊,解决这一切混乱的办法就是IPv6。这一切问题的根源在于我们快要用完IPv4地址了,而我们为了绕过这个问题不断增加NAT。一个更简单的解决方案是消除IP地址短缺,让世界上的每个设备都可以无需NAT进行连接。这正是IPv6的优势。

你们是对的!某种程度上。在一个只有IPv6的世界里,这一切变得更简单了。但并不是没有挑战,因为我们仍然需要面对有状态防火墙。你的办公室工作站可能有一个全球可达的IPv6地址,但我敢打赌在你和更大互联网之间仍然有一个公司防火墙在执行“仅出站连接”的规则。而且设备上的防火墙也还在,执行相同的规则。

因此,我们仍然需要文章开头提到的防火墙穿透技术,以及一个侧信道让对等节点知道要通信的ip。我们可能还需要备用的中继,使用像HTTP这样的协议,从阻止出站UDP的网络中逃脱。但我们可以省去STUN、生日悖论技巧、端口映射协议以及所有的回发麻烦。这要好得多!

大问题在于,我们目前还没有一个全IPv6的世界。我们有一个主要是IPv4,约33%是IPv6的世界。这34%分布极不均匀,因此一组特定的对等节点可能是100%的IPv6,0%的IPv6,或介于两者之间的任何比例。

不幸的是,这意味着IPv6还不是我们问题的解决方案。现在,它只是我们连接工具箱中的一个额外工具。它在某些对等节点对上工作得非常好,而在其他对上完全不起作用。如果我们目标是“无论如何都能连接”,我们还必须处理IPv4+NAT的问题。

同时,IPv6和IPv4的共存引入了我们必须考虑的另一个新场景:NAT64设备。

到目前为止,我们讨论的NAT都是NAT44:它们将一侧的IPv4地址转换为另一侧的不同IPv4地址。NAT64,如你所猜的那样,是在协议之间进行转换。NAT内部侧的IPv6变为外部侧的IPv4。结合DNS64将IPv4 DNS翻译成IPv6,你可以向终端设备呈现一个仅IPv6的网络,同时仍然可以访问IPv4互联网。

(顺便提一下,你可以无限制地扩展这种命名方案。有些实验使用NAT46;如果你喜欢混乱,还可以部署NAT66;一些RFC使用NAT444来指代运营商级NAT。)

如果你只处理DNS名称,这一切都很好。如果你连接到google.com,将其转化为IP地址涉及DNS64机制,这让NAT64在你不知情的情况下参与进来。

但我们关心的是NAT和防火墙穿透的具体IP和端口。对我们来说怎么办?如果幸运的话,我们的设备支持CLAT(客户侧转换器 — 来自Customer XLAT)。CLAT让操作系统假装它有直接的IPv4连接,使用NAT64在幕后使其工作。在CLAT设备上,我们不需要做任何特别的事情。

CLAT在移动设备上很常见,但在桌面电脑、笔记本和服务器上很少见。在这些设备上,我们必须显式地执行CLAT本该做的工作:检测NAT64+DNS64设置,并适当地使用它。

检测NAT64+DNS64很容易:向ipv4only.arpa发送一个DNS请求。该名称解析为已知的、恒定的IPv4地址,并且仅为IPv4地址。如果你得到IPv6地址返回,你就知道DNS64进行了某些翻译以引导你到NAT64。这让你能够找出NAT64前缀。

从那里,要与IPv4地址通信,发送IPv6数据包到{NAT64前缀+IPv4地址}。同样,如果你从{NAT64前缀+IPv4地址}接收到流量,那就是IPv4流量。现在通过NAT64使用STUN来发现你的公共ip,你又回到了经典的NAT穿透问题 — 虽然要多做一些工作。

对我们来说,幸运的是,这是一个相当罕见的特殊情况。今天大多数仅v6网络是移动运营商,几乎所有的手机都支持CLAT。运行仅v6网络的ISP在他们给你的路由器上部署CLAT,你仍然不知情。但如果你想抓住最后几次连接机会,你也必须显式支持从v6-only网络与v4-only对等节点通信。

集成所有技术到ICE

我们已经快到终点了。我们讨论了有状态防火墙、简单和高级的NAT技巧、IPv4和IPv6。那么,实施上述所有内容后,我们就完成了!

但问题是,如何确定对特定的对等方应该使用哪些技巧?如何知道这是一个简单的有状态防火墙问题,还是需要使用生日悖论,或者需要手动处理NAT64?或者也许你们两个在同一个Wi-Fi网络上,没有防火墙,不需要任何努力。

早期的NAT穿透研究要求你精确描述你和对等方之间的路径,并部署一组特定的解决方法来应对该确切路径。但事实证明,网络工程师和NAT设备程序员有许多创造性的想法,这种方法很快就失去了可扩展性。我们需要一种不那么费脑筋的方法。

于是,交互连接建立协议(ICE)登场了。和STUN和TURN一样,ICE起源于电话世界,因此RFC中充满了SIP、SDP、信令会话和拨号等内容。然而,如果你深入挖掘,它还规定了一种惊人的优雅算法来找出建立连接的最佳方法。

准备好了吗?这个算法是:同时尝试所有方法,并选择最有效的。这就是全部。是不是很惊讶?

让我们详细看看这个算法。我们将偏离ICE规范的一些内容,所以如果你正在实现一个互操作的ICE客户端,你应该阅读RFC 8445并按其实施。我们将跳过所有面向电话的内容,专注于核心逻辑,并建议一些ICE规范中未提到的自由度。

要与对等方通信,我们首先收集一个候选端点列表。候选是我们对等方可能用来与我们通信的任何ip:port。在这阶段我们不需要挑剔,这个列表至少应该包括:

  • IPv6 ip:ports
  • IPv4局域网ip:ports
  • 通过STUN发现的IPv4广域网ip:ports(可能通过NAT64翻译)
  • 由端口映射协议分配的IPv4广域网ip:port
  • 运营商提供的端点(例如,静态配置的端口转发)

然后,我们通过侧信道交换候选列表,并开始向对方的端点发送探测数据包。此时,你不需要区分:如果对等方提供了15个端点,你向所有15个端点发送“你在吗?”探测。

这些数据包有双重作用。它们的第一个功能是作为打开防火墙和穿透NAT的数据包,就像我们在整篇文章中做的那样。但另一个功能是作为健康检查。我们在交换(希望是经过身份验证的)“ping”和“pong”数据包,以检查特定路径是否端到端有效。

最后,在经过一段时间后,我们选择观察到的最佳(根据某些启发式)候选路径,然后完成连接。

这个算法的美妙之处在于,如果你的启发式方法正确,你总能得到最佳答案。ICE要求你提前对候选进行评分(通常是:局域网 > 广域网 > 广域网+NAT),但不一定非要这样。Tailscale在v0.100.0版本开始,从硬编码的偏好顺序切换到基于往返延迟的顺序,这通常也会导致相同的局域网 > 广域网 > 广域网+NAT的排序。但与静态排序不同的是,我们有机地发现路径所属的“类别”,而不是事先猜测。

ICE规范将协议结构化为“探测阶段”和“好吧,开始通信”阶段,但没有必要严格按顺序进行。在Tailscale中,我们在发现更好的路径时动态升级连接,并且所有连接都从预选的DERP开始。这意味着你可以立即通过后备路径使用连接,同时路径发现并行运行。通常,在几秒钟后,我们会找到更好的路径,然后你的连接会透明地升级到它。

需要注意的是非对称路径。ICE尽力确保两端选择了相同的网络路径,以保持所有NAT和防火墙的双向数据流开放。你不需要做同样的努力,但你必须确保所有使用的路径上都有双向流量。这可以通过定期发送ping/pong探测来实现。

为了真正稳健,你还需要检测当前选定路径的故障(例如,由于维护导致NAT状态丢失),并降级到另一条路径。你可以通过继续探测所有可能的路径并保持一组“温暖”的备用路径来实现,但降级很少见,所以可能更有效的是回退到最后的中继,然后重新启动路径发现。

最后,我们应该提到安全性。整篇文章中,我假设你在这个连接上运行的“上层”协议带有自己的安全性(QUIC有TLS证书,WireGuard有自己的公钥……)。如果不是这种情况,你绝对需要自己提供安全性。一旦你在运行时动态切换路径,基于IP的安全性就变得毫无意义(本来也没多大意义),你必须至少有端到端的身份验证。

如果你的上层有安全性,严格来说,如果你的ping/pong探测是可伪造的也没关系。最坏的情况是攻击者可以说服你通过他们中继你的流量。在存在端到端安全性的情况下,这不是什么大问题(尽管这显然取决于你的威胁模型)。但为了安全起见,你最好也对路径发现数据包进行身份验证和加密。请咨询你的本地应用安全工程师,了解如何安全地做到这一点。

结论

终于结束了。如果你实现了上述所有内容,你将拥有最先进的NAT穿透软件,可以在尽可能多的情况下建立直接连接。而当穿透失败时,你将拥有一个中继网络来弥补不足,因为在很长一段时间内,这类情况仍然会发生。

这一切都相当复杂!这类问题探索起来很有趣,但要做到正确尤其棘手,特别是如果你开始追逐那些略微增加连接机会的边缘情况。

好消息是,一旦你完成了这一切,你就拥有了一种超级能力:你可以探索激动人心且相对未被充分探索的点对点应用程序的世界。许多去中心化软件的有趣想法在一开始就因难以在互联网上相互通信而被搁浅。但现在你知道如何解决这个问题,所以去构建一些酷炫的东西吧!

最后总结

要实现可靠的NAT穿透,你需要以下组件:

  • 基于UDP的协议
  • 程序中直接访问的套接字
  • 与对等方的通信侧信道
  • 几个STUN服务器
  • 中继网络(可选,但强烈推荐)

然后,你需要:

  1. 枚举直接连接接口上套接字的所有ip:port
  2. 查询STUN服务器以发现WAN ip:port及NAT的“难度”
  3. 尝试使用端口映射协议找到更多的WAN ip:port
  4. 检查NAT64并通过其发现一个WAN ip:port(如适用)
  5. 通过侧信道与对等方交换所有这些ip:port,以及一些加密密钥来确保所有通信安全
  6. 通过中继网络开始与对等方通信(可选,用于快速连接建立)
  7. 探测对等方的所有ip:port以确定连接性,如果需要/希望,还可以执行生日攻击以通过更难的NAT
  8. 在发现比当前使用的路径更好的连接路径时,透明地升级到新路径
  9. 如果活动路径停止工作,按需降级以保持连接
  10. 确保所有通信都是端到端加密和身份验证的
Author: Sean
Link: https://blog.whileaway.io/posts/9569c3a/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.