P2P穿透NAT的原理

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了P2P穿透NAT的原理相关的知识,希望对你有一定的参考价值。

参考技术A

NAT 俗称网络地址转换,基本 NAT 都部署在路由器或者交换机上。

主要还是IP地址的不足,使用少量的公有IP 地址代表较多的私有IP 地址的方式,将有助于减缓可用的IP地址空间的枯竭。用大白话:比如你有一个路由器(家用的那种就可以)这个路由器本身链接了公网(被分配到了一个公网的IP地址)。路由器后面有接了N多个设备,每个设备都分配到了一个私有的地址(内网地址),这些地址可以通过这个路由器和外网交互(并非是代理的中继方式)。

1. 基本NAT: 这种NAT下的私有IP只有少部分(并不是全部)可以和外网通讯。每次这些私有地址向外网发送数据的时候,NAT就会把这个数据报的 源地址IP 修改成NAT的 公网地址 (这样接收方以为这个数据报是从NAT发给我的,我后面数据报在会给这个NAT就好了),并在NAT内存下对应的 映射关系 (端口和内网私有IP的映射)。当NAT收外网的数据报的时候,根据数据报的端口号找到内网的私有IP地址,将这个数据报的目的IP地址修改成内网私有IP地址(整个过程NAT负责了IP地址来回替换,辅助完成了内网机器和外网设备通讯,这里的外网设备有可能也是一个NAT)。如果收到的数据报在映射表中找不到对应映射,这个数据报就会被丢弃。

存在问题: 基本的NAT由于不会改动端口信息,当内部2个不同机器(2个不同的私有IP)使用同样的端口会出现映射错误了。

2. NPAT: 这类的NAT是在基本NAT基础上演化而来,它不仅修改 IP地址 还有 端口号 。基本可以满足NAT内全部的网络访问要求。NAT内的私有IP地址第一次向外网发送数据,NAT会选一个 映射表 里面还没有被使用过的 端口号 ,然后修改 发送数据报 的 源IP地址 和 源端口号 并发送出去,并把 私有IP地址 和 源端口号 ,和修改后的 端口号 映射关系记录下来。之后这个私有IP地址发送数据会首先判别之前是不是已经有这个映射关系,如果有就继续按照映射关系修改数据报。当NAT收到外部的数据,会从映射表找到对应关系。修改数据报的 目的IP地址 和 目的端口号 ,如果映射表中没有记录这个数据报就会被丢弃了。

解决了基础NAT的端口问题: 通过修改端口的可以让NAT内的多个私有IP地址可以使用相同的端口进行通信。因为NPAT更加灵活所以现在基本的NAT技术就是指的NPAT技术。

如果2台机器在同一个NAT下,那么他们可以直接通信了。

情况 1: 如果2台机器有一台在NAT下另一个在外网,那么只能NAT内的私有机器主动链接外网机器,因为外网的机器主动链接内网的机器,NAT映射表并不存在这个数据转发项,这个数据报就被丢弃了。

情况 2: 如果2台机器都在不同的NAT下,那么不管哪一方发起链接请求数据报都不会到达对应的机器。这种情况非常常见。

那么最简单的方式解决上面2个问题:就是在公网部署一台 中继服务器 ,双方机器都链接这台服务器。然后 中继服务器 帮助这2台服务器转发数据。这种方式最简单也是效率最低的。

到这里P2P正式登场了: 比如有下面这样的网络拓扑结构(就是上面情况2)

如果 NAT-A, IP:40.32.5.125 和 NAT-B, IP:234.12.3.8 需要直接通信的话,基本不可能,所以需要做些手段。

假设需要 手机 (图中)建立TCP链接到 电脑 (图中)需要如下手续:

**1. **由被链接方发送数据报(可以是UDP甚至是TCP的SYNC握手包)

[图片上传失败...(image-4b61ae-1528199423593)]
绿色线表示了数据报流动的方向

图中 电脑 给 NAT-A IP:40.32.5.125 / 端口:4553 发出了一个数据报,这个数据报在经过 NAT-B 的时候 源IP地址 被修改成了 NAT-B的IP地址234.12.3.8 , 源端口 被修改成了 678 。这个数据报在到达 NAT-A 的时候,在映射表中找不到 678 端口对应的内部私有IP的映射。所以这个数据报一定会被 NAT-A 丢弃掉。但是经过这次数据报发送,在 NAT-B 的映射表里面就会标记 40.32.5.125:4553 我已经发送过数据过去了,那么后面只要从这个地址发送来的数据报,我就可以转发到内网正确的设备上面。

**2. **完成第一步后,链接方可以发起数据请求

p2p可以合理的利用互联网的资源,比如两个人视频聊天。完全可以通过p2p打穿NAT后互发数据(QQ就是这么办的)。但是有的时候设备之间的直连性能很差,比如:电信的用户和移动的用户视频。这个问题是ISP厂商之间的过渡带宽太窄,就算是设备直连但是依旧速度不快。所以这里情况下需要自己搭建服务器中继(中继也不是完全无作用)。这个服务器就是网关的作用,一般有多个网卡。不同的网卡对接不同的ISP厂商,然后互相转发。

NAT穿透的工作原理

一、引言

1.1 背景:IPv4地址短缺,引入NAT

全球IPv4地址早已不够用,因此人们发明了NAT(网络地址转换)来缓解这个问题。

简单来说,大部分机器都使用私有IP地址,如果它们需要访问公网服务,那么,

  • 出向流量:需要经过一台NAT设备,它会对流量进行SNAT,将私有srcIP+Port转换成NAT设备的公网IP+Port(这样应答包才能回来),然后再将包发出去;
  • 应答流量(入向):到达NAT设备后进行相反的转换,然后再转发给客户端。

整个过程对双方透明。

更多关于NAT的内容,可参考(译)NAT-网络地址转换(2016)。译注。

以上就是本文所讨论的问题的基本背景

1.2 需求:两台经过NAT的机器建立点对点连接

在以上所描述的NAT背景下,我们从最简单的问题开始:如何在两台经过NAT的机器之间建立点对点连接(直连)。如下图所示:

直接用机器的IP互连显然是不行的,因为它们都是私有IP(例如192.168.1.x)。在Tailscale中,我们会建立一个WireGuard®隧道来解决这个问题——但这并不是太重要,因为我们将过去几代人努力都整合到了一个工具集,这些技术广泛适用于各种场景。例如,

  1. WebRTC使用这些技术在浏览器之间完成peer-to-peer语音、视频和数据传输,
  2. VoIP电话和一些视频游戏也使用类似机制,虽然不是所有情况下都很成功。

接下来,本文将在一般意义上讨论这些技术,并在合适的地方拿Tailscale和其他一些东西作为例子。

1.3 方案:NAT穿透

1.3.1 两个必备前提:UDP+能直接控制socket

如果想设计自己的协议来实现NAT穿透,那必须满足以下两个条件:

  1. 协议应该基于UDP

理论上用TCP也能实现,但它会给本已相当复杂的问题再增加一层复杂性,甚至还需要定制化内核——取决于你想实现到什么程度。本文接下来都将关注在UDP上。

如果考虑TCP是想在NAT穿透时获得面向流的连接(stream-orientedconnection),可以考虑用QUIC来替代,它构建在UDP之上,因此我们能将关注点放在UDPNAT穿透,而仍然能获得一个很好的流协议(streamprotocol)。

  1. 对收发包的socket有直接控制权

例如,从经验上来说,无法基于某个现有的网络库实现NAT穿透,因为我们必须在使用的“主要”协议之外,发送和接收额外的数据包

某些协议(例如WebRTC)将NAT穿透与其他部分紧密集成。但如果你在构建自己的协议,建议将NAT穿透作为一个独立实体,与主协议并行运行,二者仅仅是共享socket的关系,如下图所示,这将带来很大帮助:

1.3.2 保底方式:中继

在某些场景中,直接访问socket这一条件可能很难满足。

退而求其次的一个方式是设置一个localproxy(本地代理),主协议与这个proxy通信,后者来完成NAT穿透,将包中继(relay)给对端。这种方式增加了一个额外的间接成本,但好处是:

  1. 仍然能获得NAT穿透,
  2. 不需要对已有的应用程序做任何改动

1.4 挑战:有状态防火墙和NAT设备

有了以上铺垫,下面就从最基本的原则开始,一步步看如何实现一个企业级的NAT穿透方案。

我们的目标是:在两个设备之间通过UDP实现双向通信,有了这个基础,上层的其他协议(WireGuard,QUIC,WebRTC等)就能做一些更酷的事情。

但即便这个看似最基本的功能,在实现上也要解决两个障碍

  1. 有状态防火墙
  2. NAT设备

二、穿透防火墙

有状态防火墙是以上两个问题中相对比较容易解决的。实际上,大部分NAT设备都自带了一个有状态防火墙,因此要解决第二个问题,必须先解决有第一个问题。

有状态防火墙具体有很多种类型,有些你可能见过:

  • WindowsDefenderfirewall
  • Ubuntu’sufw(usingiptables/nftables)
  • BSD/macOSpf
  • AWSSecurityGroups(安全组

2.1 有状态防火墙

2.1.1 默认行为(策略)

以上防火墙的配置都是很灵活的,但大部分配置默认都是如下行为:

  1. 允许所有出向连接(allowsall“outbound”connections)
  2. 禁止所有入向连接(blocksall“inbound”connections)

可能有少量例外规则,例如allowinginboundSSH。

2.1.2 如何区分入向和出向包

连接(connection)和方向(direction)都是协议设计者头脑中的概念,到了物理传输层,每个连接都是双向的;允许所有的宝物双向传输。那防火墙是如何区分哪些是入向包、哪些是出向包的呢?这就要回到“有状态”(stateful)这三个字了:有状态防火墙会记录它看到的每个包,当收到下一个包时,会利用这些信息(状态)来判断应该做什么。

对UDP来说,规则很简单:如果防火墙之前看到过一个出向包(outbound),就会允许相应的入向包(inbound)通过,以下图为例:

笔记本电脑中自带了一个防火墙,当该防火墙看到从这台机器出去的2.2.2.2:1234->5.5.5.5:5678包时,就会记录一下:5.5.5.5:5678->2.2.2.2:1234入向包应该放行。这里的逻辑是:我们信任的世界(即笔记本)想主动与5.5.5.5:5678通信,因此应该放行(allow)其回报路径。

某些非常宽松的防火墙只要看到有从2.2.2.2:1234出去的包,就会允许所有从外部进入2.2.2.2:1234的流量。这种防火墙对我们的NAT穿透来说非常友好,但已经越来越少见了。

2.2 防火墙朝向(face-off)与穿透方案

2.2.1 防火墙朝向相同

场景特点:服务端IP可直接访问

在NAT穿透场景中,以上默认规则对UDP流量的影响不大——只要路径上所有防火墙的“朝向”是一样的。一般来说,从内网访问公网上的某个服务器都属于这种情况。

我们唯一的要求是:连接必须是由防火墙后面的机器发起的。这是因为在它主动和别人通信之前,没人能主动和它通信,如下图所示:

穿透方案:客户端直连服务端,或hub-and-spoke拓扑

但上图是假设了通信双方中,其中一端(服务端)是能直接访问到的。在VPN场景中,这就形成了所谓的hub-and-spoke拓扑:中心的hub没有任何防火墙策略,谁都能访问到;防火墙后面的spokes连接到hub。如下图所示:

2.2.2 防火墙朝向不同的方向

场景特点:服务端IP不可直接访问

但如果两个“客户端”相连,以上方式就不行了,此时两边的防火墙相向而立,如下图所示:

根据前面的讨论,这种情况意味着:两边要同时发起连接请求,但也意味着两边都无法发起有效请求,因为对方先发起请求才能在它的防火墙上打开一条缝让我们进去!如何破解这个问题呢?一种方式是让用户重新配置一边或两边的防火墙,打开一个端口,允许对方的流量进来。

  1. 这显然对用户不友好,在像Tailscale这样的mesh网络中的扩展性也不好,在mesh网络中,我们假设对端会以一定的粒度在公网上移动。
  2. 此外,在很多情况下用户也没有防火墙的控制权限:例如在咖啡馆或机场中,连接的路由器是不受你控制的(否则你可能就有麻烦了)。

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

穿透方案:两边同时主动建连,在本地防火墙为对方打开一个洞

解决的思路还是先重新审视前面提到的有状态防火墙规则:

  • 对于UDP,其规则(逻辑)是:包必须先出去才能进来(packetsmustflowoutbeforepacketscanflowbackin)。
  • 注意,这里除了要满足包的IP和端口要匹配这一条件之外,并没有要求包必须是相关的(related)。换句话说,只要某些包带着正确的来源和目的地址出去了,任何看起来像是响应的包都会被防火墙放进来——即使对端根本没收到你发出去的包。

因此,要穿透这些有状态防火墙,我们只需要共享一些信息:让两端提前知道对方使用的ip:port

  • 手动静态配置是一种方式,但显然扩展性不好;
  • 我们开发了一个coordinationserver,以灵活、安全的方式来同步ip:port信息。

有了对方的ip:port信息之后,两端开始给对方发送UDP包。在这个过程中,我们预料到某些宝物将会被丢弃。因此,双方必须要接受某些包会丢失的事实,因此如果是重要信息,你必须自己准备好重传。对UDP来说丢包是可接受的,但这里尤其需要接受。

来看一下具体建连(穿透)过程:

  1. 如图所示,笔记本出去的第一包,2.2.2.2:1234->7.7.7.7:5678,穿过WindowsDefender防火墙进入到公网。

对方的防火墙会将这个包拦截掉,因为它没有7.7.7.7:5678->2.2.2.2:1234的流量记录。但另一方面,WindowsDefender此时已经记录了出向连接,因此会允许7.7.7.7:5678->2.2.2.2:1234的应答包进来。

  1. 接着,第一个7.7.7.7:5678->2.2.2.2:1234穿过它自己的防火墙到达公网。

到达客户端侧时,WindowsDefender认为这是刚才出向包的应答包,因此就放行它进入了!此外,右侧的防火墙此时也记录了:2.2.2.2:1234->7.7.7.7:5678的包应该放行。

  1. 笔记本收到服务器发来的包之后,发送一个包作为应答。这个包穿过WindowsDefender防火墙和服务端防火墙(因为这是对服务端发送的包的应答包),达到服务端。

成功!这样我们就建立了一个穿透两个相向防火墙的双向通信连接。而初看之下,这项任务似乎是不可能完成的。

2.3 关于穿透防火墙的一些思考

穿透防火墙并非永远这么轻松,有时会受一些第三方系统的间接影响,需要仔细处理。那穿透防火墙需要注意什么呢?重要的一点是:通信双方必须几乎同时发起通信,这样才能在路径上的防火墙打开一条缝,而且两端还都是活着的。

2.3.1 双向主动建连:旁路信道

如何实现“同时”呢?一种方式是两端不断重试,但显然这种方式很浪费资源。假如双方都知道何时开始建连就好了。

  • 这听上去是鸡生蛋蛋生鸡的问题了:双方想要通信,必须先提前通个信
  • 但实际上,我们可以通过旁路信道(sidechannel)来达到这个目的,并且这个旁路信道并不需要很fancy:它可以有几秒钟的延迟、只需要传送几KB的信息,因此即使是一个配置非常低的虚拟机,也能为几千台机器提供这样的旁路通信服务。在遥远的过去,我曾用XMPP聊天消息作为旁路,效果非常不错。另一个例子是WebRTC,它需要你提供一个自己的“信令信道”(signallingchannel,这个词也暗示了WebRTC的IPtelephonyancestry),并将其配置到WebRTCAPI。在Tailscale,我们的协调服务器(coordinationserver)和DERP(DetourEncryptedRoutingProtocol)服务器集群是我们的旁路信道。

2.3.2 非活跃连接被防火墙清理

有状态防火墙内存通常比较有限,因此会定期清理不活跃的连接(UDP常见的是30s),因此要保持连接alive的话需要定期通信,否则就会被防火墙关闭,为避免这个问题,我们,

  1. 要么定期向对方发包来keepalive,
  2. 要么有某种带外方式来按需重建连接。

2.3.3 问题都解决了?不,挑战刚刚开始

对于防火墙穿透来说,我们并不需要关心路径上有几堵墙——只要它们是有状态防火墙且允许出向连接,这种同时发包(simultaneoustransmission)机制就能穿透任意多层防火墙。这一点对我们来说非常友好,因为只需要实现一个逻辑,然后能适用于任何地方了。

…对吗?

其实,不完全对。这个机制有效的前提是:我们能提前知道对方的ip:port。而这就涉及到了我们今天的主题:NAT,它会使前面我们刚获得的一点满足感顿时消失。

下面,进入本文正题

三、NAT的本质

3.1 NAT设备与有状态防火墙

可以认为NAT设备是一个增强版的有状态防火墙,虽然它的增强功能对于本文场景来说并不受欢迎:除了前面提到的有状态拦截/放行功能之外,它们还会在数据包经过时修改这些包。

3.2 NAT穿透与SNAT/DNAT

具体来说,NAT设备能完成某种类型的网络地址转换,例如,替换源或目的IP地址或端口。

  • 讨论连接问题和NAT穿透问题时,我们只会受sourceNAT——SNAT的影响
  • DNAT不会影响NAT穿透。

3.3 SNAT的意义:解决IPv4地址短缺问题

SNAT最常见的使用场景是将很多设备连接到公网,而只使用少数几个公网IP。例如对于消费级路由器,会将所有设备的(私有)IP地址映射为单个连接到公网的IP地址。

这种方式存在的意义是:我们有远多于可用公网IP数量的设备需要连接到公网,(至少对IPv4来说如此,IPv6的情况后面会讨论)。NAT使多个设备能共享同一IP地址,因此即使面临IPv4地址短缺的问题,我们仍然能不断扩张互联网的规模。

3.4 SNAT过程:以家用路由器为例

假设你的笔记本连接到家里的WiFi,下面看一下它连接到公网某个服务器时的情形:

  1. 笔记本发送UDPpacket192.168.0.20:1234->7.7.7.7:5678。

这一步就好像笔记本有一个公网IP一样,但源地址192.168.0.20是私有地址,只能出现在私有网络,公网不认,收到这样的包时它不知道如何应答。

  1. 家用路由器出场,执行SNAT。

包经过路由器时,路由器发现这是一个它没有见过的新会话(session)。它知道192.168.0.20是私有IP,公网无法给这样的地址回包,但它有办法解决:

  • 在它自己的公网IP上挑一个可用的UDP端口,例如2.2.2.2:4242,然后创建一个NATmapping:192.168.0.20:1234<-->2.2.2.2:4242,然后将包发到公网,此时源地址变成了2.2.2.2:4242而不是原来的192.168.0.20:1234。因此服务端看到的是转换之后地址,接下来,每个能匹配到这条映射规则的包,都会被路由器改写IP和端口。

  1. 反向路径是类似的,路由器会执行相反的地址转换,将2.2.2.2:4242变回192.168.0.20:1234。对于笔记本来说,它根本感知不知道这正反两次变换过程。

这里是拿家用路由器作为例子,但办公网的原理是一样的。不同之处在于,办公网的NAT可能有多台设备组成(高可用、容量等目的),而且它们有不止一个公网IP地址可用,因此在选择可用的公网ip:port来做映射时,选择空间更大,能支持更多客户端。

3.5 SNAT给穿透带来的挑战

现在我们遇到了与前面有状态防火墙类似的情况,但这次是NAT设备:通信双方不知道对方的ip:port是什么,因此无法主动建连,如下图所示:

但这次比有状态防火墙更糟糕,严格来说,在双方发包之前,根本无法确定(自己及对方的)ip:port信息,因为只有出向包经过路由器之后才会产生NATmapping(即,可以被对方连接的ip:port信息)。

因此我们又回到了与防火墙遇到的问题,并且情况更糟糕:双方都需要主动和对方建连,但又不知道对方的公网地址是多少,只有当对方先说话之后,我们才能拿到它的地址信息。

如何破解以上死锁呢?这就轮到STUN登场了。

四、穿透“NAT+防火墙”:STUN(
SessionTraversalUtilitiesforNAT)协议

STUN既是一些对NAT设备行为的详细研究,也是一种协助NAT穿透的协议。本文主要关注STUN协议。

4.1 STUN原理

STUN基于一个简单的观察:从一个会被NAT的客户端访问公网服务器时,服务器看到的是NAT设备的公网ip:port地址,而非该客户端的局域网ip:port地址

也就是说,服务器能告诉客户端它看到的客户端的ip:port是什么。因此,只要将这个信息以某种方式告诉通信对端(peer),后者就知道该和哪个地址建连了!这样就又简化为前面的防火墙穿透问题了

本质上这就是STUN协议的工作原理,如下图所示:

  • 笔记本向STUN服务器发送一个请求:“从你的角度看,我的地址什么?”
  • STUN服务器返回一个响应:“我看到你的UDP包是从这个地址来的:ip:port”。

4.2 为什么NAT穿透逻辑和主协议要共享同一个socket

理解了STUN原理,也就能理解为什么我们在文章开头说,如果要实现自己的NAT穿透逻辑和主协议,就必须让二者共享同一个socket

  1. 每个socket在NAT设备上都对应一个映射关系(私网地址->公网地址),
  2. STUN服务器只是辅助穿透的基础设施,
  3. 与STUN服务器通信之后,在NAT及防火墙设备上打开了一个连接,允许入向包进来(回忆前面内容,只要目的地址对,UDP包就能进来,不管这些包是不是从STUN服务器来的),
  4. 因此,接下来只要将这个地址告诉我们的通信对端(peer),让它往这个地址发包,就能实现穿透了。

4.3 STUN的问题:不能穿透所有NAT设备(例如企业级NAT网关)

有了STUN,我们的穿透目的似乎已经实现了:每台机器都通过STUN来获取自己的私网socket对应的公网ip:port,然后把这个信息告诉对端,然后两端同时发起穿透防火墙的尝试,后面的过程就和上一节介绍的防火墙穿透一样了,对吗

答案是:看情况。某些情况下确实如此,但有些情况下却不行。通常来说,

  • 对于大部分家用路由器场景,这种方式是没问题的;
  • 但对于一些企业级NAT网关来说,这种方式无法奏效。

NAT设备的说明书上越强调它的安全性,STUN方式失败的可能性就越高。(但注意,从实际意义上来说,NAT设备在任何方面都并不会增强网络的安全性,但这不是本文重点,因此不展开。)

4.4 重新审视STUN的前提

再次审视前面关于STUN的假设:当STUN服务器告诉客户端在公网看来它的地址是2.2.2.2:4242时,那所有目的地址是2.2.2.2:4242的包就都能穿透防火墙到达该客户端。

这也正是问题所在:这一点并不总是成立

  • 某些NAT设备的行为与我们假设的一致,它们的有状态防火墙组件只要看到有客户端自己发起的出向包,就会允许相应的入向包进入;因此只要利用STUN功能,再加上两端同时发起防火墙穿透,就能把连接打通;
  • 另外一些NAT设备就要困难很多了,它会针对每个目的地址来生成一条相应的映射关系。在这样的设备上,如果我们用相同的socket来分别发送数据包到5.5.5.5:1234and7.7.7.7:2345,我们就会得到2.2.2.2上的两个不同的端口,每个目的地址对应一个。如果反向包的端口用的不对,包就无法通过防火墙。如下图所示:

五、中场补课:NAT正式术语

知道NAT设备的行为并不是完全一样之后,我们来引入一些正式术语。

5.1 早期术语

如果之前接触过NAT穿透,可能会听说过下面这些名词:

  • “FullCone”
  • “RestrictedCone”
  • “Port-RestrictedCone”
  • “Symmetric”NATs

这些都是NAT穿透领域的早期术语。

但其实这些术语相当让人困惑。我每次都要查一下RestrictedConeNAT是什么意思。从实际经验来看,我并不是唯一对此感到困惑的人。例如,如今互联网上将“easy”NAT归类为FullCone,而实际上它们更应该归类为Port-RestrictedCone。

5.2 近期研究与新术语

最近的一些研究和RFC已经提出了一些更准确的术语。

  • 首先,它们明确了如下事实:NAT设备的行为差异表现在多个维度,而并非只有早期研究中所说的“cone”这一个维度,因此基于“cone”来划分类别并不是很有帮助
  • 其次,新研究和新术语能更准确地描述NAT在做什么

前面提到的所谓"easy"和"hard"NAT,只在一个维度有不同:NAT映射是否考虑到目的地址信息。RFC4787中,

  • easyNAT及其变种称为“Endpoint-IndependentMapping”(EIM,终点无关的映射)

但是,从“命名很难”这一程序员界的伟大传统来说,EIM这个词其实也并不是100%准确,因为这种NAT仍然依赖endpoint,只不过依赖的是源endpoint:每个sourceip:port对应一个映射——否则你的包就会和别人的包混在一起,导致混乱。

严格来说,EIM应该称为“
DestinationEndpointIndependentMapping”(DEIM?),但这个名字太拗口了,而且按照惯例,Endpoint永远指的是DestinationEndpoint。

  • hardNAT以及变种称为“Endpoint-DependentMapping”(EDM,终点相关的映射)。

EDM中还有一个子类型,依据是只根据dst_ip做映射,还是根据dst_ip+dst_port做映射。对于NAT穿透来说,这种区分对来说是一样的:它们都会导致STUN方式不可用

5.3 老的cone类型划分

你可能会有疑问:根据是否依赖endpoint这一条件,只能组合出两种可能,那为什么传统分类中会有四种cone类型呢?答案是cone包含了两个正交维度的NAT行为

  • NAT映射行为:前面已经介绍过了,
  • 有状态防火墙行为:与前者类似,也是分为与endpoint相关还是无关两种类型。

因此最终组合如下:

NATConeTypes

Endpoint无关NATmapping

Endpoint相关NATmapping(alltypes)

Endpoint无关防火墙

FullConeNAT

N/A*

Endpoint相关防火墙(dst.IPonly)

RestrictedConeNAT

N/A*

Endpoint相关防火墙(dst.IP+port)

Port-RestrictedConeNAT

SymmetricNAT

分解到这种程度之后就可以看出,cone类型对NAT穿透场景来说并没有什么意义。我们关心的只有一点:是否是Symmetric——换句话说,一个NAT设备是EIM还是EDM类型的。

5.4 针对NAT穿透场景:简化NAT分类

以上讨论可知,虽然理解防火墙的具体行为很重要,但对于编写NAT穿透代码来说,这一点并不重要。我们的两端同时发包方式(
simultaneoustransmissiontrick)能有效穿透以上三种类型的防火墙。在真实场景中,我们主要在处理的是
IP-and-portendpoint-dependent防火墙。

因此,对于实际NAT穿透实现,我们可以将以上分类简化成:

Endpoint-IndependentNATmapping

Endpoint-DependentNATmapping(dst.IPonly)

Firewallisyes

EasyNAT

HardNAT

5.5 更多NAT规范(RFC)

想了解更多新的NAT术语,可参考

  • RFC4787(NATBehavioralRequirementsforUDP)
  • RFC5382(forTCP)
  • RFC5508(forICMP)

如果自己实现NAT,那应该(should)遵循这些RFC的规范,这样才能使你的NAT行为符合业界惯例,与其他厂商的设备或软件良好兼容。

六、穿透NAT+防火墙:STUN不可用时,fallback到中继模式

6.1 问题回顾与保底方式(中继)

补完基础知识(尤其是定义了什么是hardNAT)之后,回到我们的NAT穿透主题。

  • 第1~4节已经解决了STUN和防火墙穿透的问题,
  • hardNAT对我们来说是个大问题,只要路径上出现一个这种设备,前面的方案就行不通了。

准备放弃了吗?这才进入NAT真正有挑战的部分:如果已经试过了前面介绍的所有方式仍然不能穿透,我们该怎么办呢?

  • 实际上,确实有很多NAT实现在这种情况下都会选择放弃,向用户报一个“无法连接”之类的错误。
  • 但对我们来说,这么快就放弃显然是不可接受的——解决不了连通性问题,Tailscale就没有存在的意义。

我们的保底解决方式是:创建一个中继连接(relay)实现双方的无障碍地通信。但是,中继方式性能不是很差吗?这要看具体情况:

  • 如果能直连,那显然没必要用中继方式;
  • 但如果无法直连,而中继路径又非常接近双方直连的真实路径,并且带宽足够大,那中继方式并不会明显降低通信质量。延迟肯定会增加一点,带宽会占用一些,但相比完全连接不上,还是更能让用户接受的

不过要注意:我们只有在无法直连时才会选择中继方式。实际场景中,

  1. 对于大部分网络,我们都能通过前面介绍的方式实现直连,
  2. 剩下的长尾用中继方式来解决,并不算一个很糟的方式。

此外,某些网络会阻止NAT穿透,其影响比这种hardNAT大多了。例如,我们观察到UCBerkeleyguestWiFi禁止除DNS流量之外的所有outboundUDP流量。不管用什么NAT黑科技,都无法绕过这个拦截。因此我们终归还是需要一些可靠的fallback机制。

6.2 中继协议:TURN、DERP

有多种中继实现方式。

  1. TURN(TraversalUsingRelaysaroundNAT):经典方式,核心理念是
  • 用户(人)先去公网上的TURN服务器认证,成功后后者会告诉你:“我已经为你分配了ip:port,接下来将为你中继流量”,然后将这个ip:port地址告诉对方,让它去连接这个地址,接下去就是非常简单的客户端/服务器通信模型了。

Tailscale并不使用TURN。这种协议用起来并不是很好,而且与STUN不同,它没有真正的交互性,因为互联网上并没有公开的TURN服务器。

  1. DERP(DetouredEncryptedRoutingProtocol)

这是我们创建的一个协议,DERP,

  • 它是一个通用目的包中继协议,运行在HTTP之上,而大部分网络都是允许HTTP通信的。它根据目的公钥(destination’spublickey)来中继加密的流量(encryptedpayloads)。

前面也简单提到过,DERP既是我们在NAT穿透失败时的保底通信方式(此时的角色与TURN类似),也是在其他一些场景下帮助我们完成NAT穿透的旁路信道。换句话说,它既是我们的保底方式,也是有更好的穿透链路时,帮助我们进行连接升级(
upgradetoapeer-to-peerconnection)的基础设施。

6.3 小结

有了“中继”这种保底方式之后,我们穿透的成功率大大增加了。如果此时不再阅读本文接下来的内容,而是把上面介绍的穿透方式都实现了,我预计:

  • 90%的情况下,你都能实现直连穿透;
  • 剩下的10%里,用中继方式能穿透一些(some);

这已经算是一个“足够好”的穿透实现了。

七、穿透NAT+防火墙:企业级改进

如果你并不满足于“足够好”,那我们可以做的事情还有很多!

本节将介绍一些五花八门的tricks,在某些特殊场景下会帮到我们。单独使用这项技术都无法解决NAT穿透问题,但将它们巧妙地组合起来,我们能更加接近100%的穿透成功率。

7.1 穿透hardNAT:暴力端口扫描

回忆hardNAT中遇到的问题,如下图所示,关键问题是:easyNAT不知道该往hardNAT方的哪个ip:port发包。

必须要往正确的ip:port发包,才能穿透防火墙,实现双向互通。怎么办呢?

  1. 首先,我们能知道hardNAT的一些ip:port,因为我们有STUN服务器。

这里先假设我们获得的这些IP地址都是正确的(这一点并不总是成立,但这里先这么假设。而实际上,大部分情况下这一点都是成立的,如果对此有兴趣,可以参考REQ-2inRFC4787)。

  1. IP地址确定了,剩下的就是端口了。总共有65535中可能,我们能遍历这个端口范围吗?

如果发包速度是100packets/s,那最坏情况下,需要10分钟来找到正确的端口。还是那句话,这虽然不是最优的,但总比连不上好。

这很像是端口扫描(事实上,确实是),实际中可能会触发对方的网络入侵检测软件。

7.2 基于生日悖论改进暴力扫描:hardside多开端口+easyside随机探测

利用birthdayparadox算法,我们能对端口扫描进行改进。

  • 上一节的基本前提是:hardside只打开一个端口,然后easyside暴力扫描65535个端口来寻找这个端口;
  • 这里的改进是:在hardsize开多个端口,例如256个(即同时打开256个socket,目的地址都是easyside的ip:port),然后easyside随机探测这边的端口。

这里省去算法的数学模型,如果你对实现干兴趣,可以看看我写的pythoncalculator。计算过程是“经典”生日悖论的一个小变种。下面是随着easysiderandomprobe次数(假设hardsize256个端口)的变化,两边打开的端口有重合(即通信成功)的概率:

随机探测次数

成功概率

174

50%

256

64%

1024

98%

2048

99.9%

根据以上结果,如果还是假设100ports/s这样相当温和的探测速率,那2秒钟就有约50%的成功概率。即使非常不走运,我们仍然能在20s时几乎100%穿透成功,而此时只探测了总端口空间的4%

非常好!虽然这种hardNAT给我们带来了严重的穿透延迟,但最终结果仍然是成功的。那么,如果是两个hardNAT,我们还能处理吗?

7.3 双hardNAT场景

这种情况下仍然可以用前面的多端口+随机探测方式,但成功概率要低很多了:

  • 每次通过一台hardNAT去探测对方的端口(目的端口)时,我们自己同时也生成了一个随机源端口
  • 这意味着我们的搜索空间变成了二维srcport,dstport对,而不再是之前的一维dstport空间。

这里我们也不就具体计算展开,只告诉结果:仍然假设目的端打开256个端口,从源端发起2048次(20秒),成功的概率是:0.01%

如果你之前学过生日悖论,就并不会对这个结果感到惊讶。理论上来说,

  • 要达到99.9%的成功率,我们需要两边各进行170,000次探测——如果还是以100packets/sec的速度,就需要28分钟
  • 要达到50%的成功率,“只”需要54,000packets,也就是9分钟
  • 如果不使用生日悖论方式,而且暴力穷举,需要1.2年时间

对于某些应用来说,28分钟可能仍然是一个可接受的时间。用半个小时暴力穿透NAT之后,这个连接就可以一直用着——除非NAT设备重启,那样就需要再次花半个小时穿透建个新连接。但对于交互式应用来说,这样显然是不可接受的。

更糟糕的是,如果去看常见的办公网路由器,你会震惊于它的activesessionlowlimit有多么低。例如,一台JuniperSRX300最多支持64,000activesessions。也就是说,

  • 如果我们想创建一个成功的穿透连接,就会把它的整张session表打爆(因为我们要暴力探测65535个端口,每次探测都是一条新连接记录)!这显然要求这台路由器能从容优雅地处理过载的情况
  • 这只是创建一条连接带来的影响!如果20台机器同时对这台路由器发起穿透呢?绝对的灾难!

至此,我们通过这种方式穿透了比之前更难一些的网络拓扑。这是一个很大的成就,因为家用路由器一般都是easyNAT,hardNAT一般都是办公网路由器或云NAT网关。这意味着这种方式能帮我们解决

  • home-to-office(家->办公室)
  • home-to-cloud(家->云)

的场景,以及一部分

  • office-to-cloud(办公室->云)
  • cloud-to-cloud(云->办公室)

场景。

7.4 控制端口映射(portmapping)过程:UPnP/NAT-PMP/PCP协议

如果我们能让NAT设备的行为简单点,不要把事情搞这么复杂,那建立连接(穿透)就会简单很多。真有这样的好事吗?还真有,有专门的一种协议叫端口映射协议(portmappingprotocols)。通过这种协议禁用掉前面遇到的那些乱七八糟的东西之后,我们将得到一个非常简单的“请求-响应”。

下面是三个具体的端口映射协议:

  1. UPnPIGD(UniversalPlug’n’PlayInternetGatewayDevice)

最老的端口控制协议,诞生于1990s晚期,因此使用了很多上世纪90年代的技术(XML、SOAP、multicastHTTPoverUDP——对,HTTPoverUDP),而且很难准确和安全地实现这个协议。但以前很多路由器都内置了UPnP协议,现在仍然很多。

请求和响应:

  • “你好,请将我的lan-ip:port转发到公网(WAN)”,“好的,我已经为你分配了一个公网映射wan-ip:port”。
  1. NAT-PMP

UPnPIGD出来几年之后,Apple推出了一个功能类似的协议,名为NAT-PMP(NATPortMappingProtocol)。

但与UPnP不同,这个协议做端口转发,不管是在客户端还是服务端,实现起来都非常简单。

  1. PCP

稍后一点,又出现了NAT-PMPv2版,并起了个新名字PCP(PortControlProtocol)。

因此要更好地实现穿透,可以

  1. 先判断本地的默认网关上是否启用了UPnPIGD,NAT-PMPandPCP
  2. 如果探测发现其中任何一种协议有响应,我们就申请一个公网端口映射

可以将这理解为一个加强版STUN:我们不仅能发现自己的公网ip:port,而且能指示我们的NAT设备对我们的通信对端友好一些——但并不是为这个端口修改或添加防火墙规则。

  1. 接下来,任何到达我们NAT设备的、地址是我们申请的端口的包,都会被设备转发到我们。

但我们不能假设这个协议一定可用

  1. 本地NAT设备可能不支持这个协议;
  2. 设备支持但默认禁用了,或者没人知道还有这么个功能,因此从来没开过;
  3. 安全策略要求关闭这个特性。

这一点非常常见,因为UPnP协议曾曝出一些高危漏洞(后面都修复了,因此如果是较新的设备,可以安全地使用UPnP——如果实现没问题)。不幸的是,某些设备的配置中,UPnP,NAT-PMP,PCP是放在一个开关里的(可能统称为“UPnP”功能),一开全开,一关全关。因此如果有人担心UPnP的安全性,他连另外两个也用不了。

最后,终归来说,只要这种协议可用,就能有效地减少一次NAT,大大方便建连过程。但接下来看一些不常见的场景。

7.5 多NAT协商(NegotiatingnumerousNATs)

目前为止,我们看到的客户端和服务端都各只有一个NAT设备。如果有多个NAT设备会怎么样?例如下面这种拓扑:

这个例子比较简单,不会给穿透带来太大问题。包从客户端A经过多次NAT到达公网的过程,与前面分析的穿过多层有状态防火墙是一样的:

  • 额外的这层(NAT设备)对客户端和服务端来说都不可见,我们的穿透技术也不关心中间到底经过了多少层设备。
  • 真正有影响的其实只是最后一层设备,因为对端需要在这一层设备上找到入口让包进来。

具体来说,真正有影响的是端口转发协议。

  1. 客户端使用这种协议分配端口时,为我们分配端口的是最靠近客户端的这层NAT设备;
  2. 而我们期望的是让最离客户端最远的那层NAT来分配,否则我们得到的就是一个网络中间层分配的ip:port,对端是用不了的;
  3. 不幸的是,这几种协议都不能递归地告诉我们下一层NAT设备是多少——虽然可以用traceroute之类的工具来探测网络路径,再加上猜路上的设备是不是NAT设备(尝试发送NAT请求)——但这个就看运气了。

这就是为什么互联网上充斥着大量的文章说double-NAT有多糟糕,以及警告用户为保持后向兼容不要使用double-NAT。但实际上,double-NAT对于绝大部分互联网应用来说都是不可见的(透明的),因为大部分应用并不需要主动地做这种NAT穿透。

但我也绝不是在建议你在自己的网络中设置double-NAT。

  1. 破坏了端口映射协议之后,某些视频游戏的多人(multiplayer)模式就会无法使用,
  2. 也可能会使你的IPv6网络无法派上用场,后者是不用NAT就能双向直连的一个好方案。

但如果double-NAT并不是你能控制的,那除了不能用到这种端口映射协议之外,其他大部分东西都是不受影响的。

double-NAT的故事到这里就结束了吗?——并没有,而且更大型的double-NAT场景将展现在我们面前。

7.6 运营商级NAT带来的问题

即使用NAT来解决IPv4地址不够的问题,地址仍然是不够用的,ISP(互联网服务提供商)显然无法为每个家庭都分配一个公网IP地址。那怎么解决这个问题呢?ISP的做法是不够了就再嵌套一层NAT

  1. 家用路由器将你的客户端SNAT到一个“intermediate”IP然后发送到运营商网络,
  2. ISP’snetwork中的NAT设备再将这些intermediateIPs映射到少量的公网IP。

后面这种NAT就称为“运营商级NAT”(carrier-gradeNAT,或称电信级NAT),缩写CGNAT。如下图所示:

CGNAT对NAT穿透来说是一个大麻烦。

  • 在此之前,办公网用户要快速实现NAT穿透,只需在他们的路由器上手动设置端口映射就行了。
  • 但有了CGNAT之后就不管用了,因为你无法控制运营商的CGNAT!

好消息是:这其实是double-NAT的一个小变种,因此前面介绍的解决方式大部分还仍然是适用的。某些东西可能会无法按预期工作,但只要肯给ISP交钱,这些也都能解决。除了portmappingprotocols,其他我们已经介绍的所有东西在CGNAT里都是适用的。

新挑战:同一CGNAT侧直连,STUN不可用

但我们确实遇到了一个新挑战:如何直连两个在同一CGNAT但不同家用路由器中的对端呢?如下图所示:

在这种情况下,STUN就无法正常工作了:STUN看到的是客户端在公网(CGNAT后面)看到的地址,而我们想获得的是在“middlenetwork”中的ip:port,这才是对端真正需要的地址,

解决方案:如果端口映射协议能用:一端做端口映射

怎么办呢?

如果你想到了端口映射协议,那恭喜,答对了!如果peer中任何一个NAT支持端口映射协议,对我们就能实现穿透,因为它分配的ip:port正是对端所需要的信息。

这里讽刺的是:double-NAT(指CGNAT)破坏了端口映射协议,但在这里又救了我们!当然,我们假设这些协议一定可用,因为CGNATISP倾向于在它们的家用路由器侧关闭这些功能,已避免软件得到“错误的”结果,产生混淆。

解决方案:如果端口映射协议不能用:NAThairpin模式

如果不走运,NAT上没有端口映射功能怎么办?

让我们回到基于STUN的技术,看会发生什么。两端在CGNAT的同一侧,假设STUN告诉我们A的地址是2.2.2.2:1234,B的地址是2.2.2.2:5678。

那么接下来的问题是:如果A向2.2.2.2:5678发包会怎么样?期望的CGNAT行为是:

  1. 执行A的NAT映射规则,即对2.2.2.2:1234->2.2.2.2:5678进行SNAT。
  2. 注意到目的地址2.2.2.2:5678匹配到的是B的入向NAT映射,因此接着对这个包执行DNAT,将目的IP改成B的私有地址。
  3. 通过CGNAT的internal接口(而不是public接口,对应公网)将包发给B。

这种NAT行为有个专门的术语,叫hairpinning(直译为发卡,意思是像发卡一样,沿着一边上去,然后从另一边绕回来),

大家应该猜到的一个事实是:不是所以NAT都支持hairpin模式。实际上,大量well-behavedNAT设备都不支持hairpin模式,

  • 因为它们都有“只有src_ip是私有地址且dst_ip是公网地址的包才会经过我”之类的假设。
  • 因此对于这种目的地址不是公网、需要让路由器把包再转回内网的包,它们会直接丢弃
  • 这些逻辑甚至是直接实现在路由芯片中的,因此除非升级硬件,否则单靠软件编程无法改变这种行为。

Hairpin是所有NAT设备的特性(支持或不支持),并不是CGNAT独有的。

  1. 在大部分情况下,这个特性对我们的NAT穿透目的来说都是无所谓的,因为我们期望中两个LANNAT设备会直接通信,不会再向上绕到它们的默认网关CGNAT来解决这个问题

Hairpin特性可有可无这件事有点遗憾,这可能也是为什么hairpin功能经常broken的原因。

  1. 一旦必须涉及到CGNAT,那hairpinning对连接性来说就至关重要了。

Hairpinning使内网连接的行为与公网连接的行为完成一致,因此我们无需关心目的地址类型,也不用知晓自己是否在一台CGNAT后面。

如果hairpinning和portmappingprotocols都不可用,那只能降级到中继模式了

7.7 全IPv6网络:理想之地,但并非问题全无

行文至此,一些读者可能已经对着屏幕咆哮:不要再用IPv4了!花这么多时间精力解决这些没意义的东西,还不如直接换成IPv6!

  • 的确,之所以有这些乱七八糟的东西,就是因为IPv4地址不够了,我们一直在用越来越复杂的NAT来给IPv4续命
  • 如果IP地址够用,无需NAT就能让世界上的每个设备都有一个自己的公网IP地址,这些问题不就解决了吗?

简单来说,是的,这也正是IPv6能做的事情。但是,也只说对了一半:在理想的全IPv6世界中,所有这些东西会变得更加简单,但我们面临的问题并不会完全消失——因为有状态防火墙仍然还是存在的

  • 办公室中的电脑可能有一个公网IPv6地址,但你们公司肯定会架设一个防火墙,只允许你的电脑主动访问公网,而不允许反向主动建连。
  • 其他设备上的防火墙也仍然存在,应用类似的规则。

因此,我们仍然会用到

  1. 本文最开始介绍的防火墙穿透技术,以及
  2. 帮助我们获取自己的公网ip:port信息的旁路信道
  3. 仍然需要在某些场景下fallback到中继模式,例如fallback到最通用的HTTP中继协议,以绕过某些网络禁止outboundUDP的问题。

但我们现在可以抛弃STUN、生日悖论、端口映射协议、hairpin等等东西了。这是一个好消息!

全球IPv4/IPv6部署现状

另一个更加严峻的现实问题是:当前并不是一个全IPv6世界。目前世界上

  • 大部分还是IPv4,
  • 大约33%是IPv6,而且分布极度不均匀,因此某些通信对所在的可能是100%IPv6,也可能是0%,或二者之间。

不幸的是,这意味着,IPv6**还**无法作为我们的解决方案。就目前来说,它只是我们的工具箱中的一个备选。对于某些peer来说,它简直是完美工具,但对其他peer来说,它是用不了的。如果目标是“任何情况下都能穿透(连接)成功”,那我们就仍然需要IPv4+NAT那些东西。

新场景:NAT64/DNS64

IPv4/IPv6共存也引出了一个新的场景:NAT64设备。

前面介绍的都是NAT44设备:它们将一个IPv4地址转换成另一IPv4地址。NAT64从名字可以看出,是将一个内侧IPv6地址转换成一个外侧IPv4地址。利用DNS64设备,我们能将IPv4DNS应答给IPv6网络,这样对终端来说,它看到的就是一个全IPv6网络,而仍然能访问IPv4公网。

如果需要处理DNS问题,那这种方式工作良好。例如,如果连接到google.com,将这个域名解析成IP地址的过程会涉及到DNS64设备,它又会进一步involveNAT64设备,但后一步对用户来说是无感知的。

对于NAT和防火墙穿透来说,我们会关心每个具体的IP地址和端口

解决方案:CLAT(Customer-sidetransLATor)

如果设备支持CLAT(Customer-sidetranslator—fromCustomerXLAT),那我们就很幸运:

  • CLAT假装操作系统有直接IPv4连接,而背后使用的是NAT64,以对应用程序无感知。在有CLAT的设备上,我们无需做任何特殊的事情。
  • CLAT在移动设备上非常常见,但在桌面电脑、笔记本和服务器上非常少见,因此在后者上,必须自己做CLAT做的事情:检测NAT64+DNS64的存在,然后正确地使用它们。

解决方案:CLAT不存在时,手动穿透NAT64设备

  1. 首先检测是否存在NAT64+DNS64。

方法很简单:向ipv4only.arpa.发送一个DNS请求。这个域名会解析到一个已知的、固定的IPv4地址,而且是纯IPv4地址。如果得到的是一个IPv6地址,就可以判断有DNS64服务器做了转换,而它必然会用到NAT64。这样就能判断出NAT64的前缀是多少。

  1. 此后,要向IPv4地址发包时,发送格式为NAT64prefix+IPv4address的IPv6包。类似地,收到来源格式为NAT64prefix+IPv4address的包时,就是IPv4流量。
  2. 接下来,通过NAT64网络与STUN通信来获取自己在NAT64上的公网ip:port,接下来就回到经典的NAT穿透问题了——除了需要多做一点点事情。

幸运的是,如今的大部分v6-only网络都是移动运营商网络,而几乎所有手机都支持CLAT。运营v6-only网络的ISPs会在他们给你的路由器上部署CLAT,因此最后你其实不需要做什么事情。但如果想实现100%穿透,就需要解决这种边边角角的问题,即必须显式支持从v6-only网络连接v4-only对端。

7.8 将所有解决方式集成到ICE协议

针对具体场景,该选择哪种穿透方式?

至此,我们的NAT穿透之旅终于快结束了。我们已经覆盖了有状态防火墙、简单和高级NAT、IPv4和IPv6。只要将以上解决方式都实现了,NAT穿透的目的就达到了!

但是,

  • 对于给定的peer,如何判断改用哪种方式呢?
  • 如何判断这是一个简单有状态防火墙的场景,还是该用到生日悖论算法,还是需要手动处理NAT64呢?
  • 还是通信双方在一个WiFi网络下,连防火墙都没有,因此不需要任何操作呢?

早期NAT穿透比较简单,能让我们精确判断出peer之间的路径特点,然后针对性地采用相应的解决方式。但后面,网络工程师和NAT设备开发工程师引入了一些新理念,给路径判断造成很大困难。因此我们需要简化客户端侧的思考(判断逻辑)。

这就要提到
InteractiveConnectivityEstablishment(ICE,交换式连接建立)协议了。与STUN/TURN类似,ICE来自电信领域,因此其RFC充满了SIP、SDP、信令会话、拨号等等电话术语。但如果忽略这些领域术语,我们会看到它描述了一个极其优雅的判断最佳连接路径的算法

真的?这个算法是:每种方法都试一遍,然后选择最佳的那个方法。就是这个算法,惊喜吗?

来更深入地看一下这个算法。

ICE(
InteractiveConnectivityEstablishment)算法

这里的讨论不会严格遵循ICEspec,因此如果是在自己实现一个可互操作的ICE客户端,应该通读RFC8445,根据它的描述来实现。这里忽略所有电信术语,只关注核心的算法逻辑,并提供几个在ICE规范允许范围的灵活建议。

  1. 为实现和某个peer的通信,首先需要确定我们自己用的(客户端侧)这个socket的地址,这是一个列表,至少应该包括:
  • 我们自己的IPv6ip:ports我们自己的IPv4LANip:ports(局域网地址)通过STUN服务器获取到的我们自己的IPv4WANip:ports(公网地址,可能会经过NAT64转换)通过端口映射协议获取到的我们自己的IPv4WANip:port(NAT设备的端口映射协议分配的公网地址)运营商提供给我们的endpoints(例如,静态配置的端口转发
  1. 通过旁路信道与peer互换这个列表。两边都拿到对方的列表后,就开始互相探测对方提供的地址。列表中地址没有优先级,也就是说,如果对方给的了15个地址,那我们应该把这15个地址都探测一遍。

这些探测包有两个目的

  • 打开防火墙,穿透NAT,也就是本文一直在介绍的内容;健康检测。我们在不断交换(最好是已认证的)“ping/pong”包,来检测某个特定的路径是不是端到端通的。
  1. 最后,一小会儿之后,从可用的备选地址中(根据某些条件)选择“最佳”的那个,任务完成!

这个算法的优美之处在于:只要选择最佳线路(地址)的算法是正确的,那就总能获得最佳路径。

  • ICE会预先对这些备选地址进行排序(通常:LAN>WAN>WAN+NAT),但用户也可以自己指定这个排序行为。
  • 从v0.100.0开始,Tailscale从原来的hardcode优先级切换成了根据round-triplatency的方式,它大部分情况下排序的结果和LAN>WAN>WAN+NAT是一致的。但相比于静态排序,我们是动态计算每条路径应该属于哪个类别。

ICEspec将协议组织为两个阶段:

  1. 探测阶段
  2. 通信阶段

但不一定要严格遵循这两个步骤的顺序。在Tailscale,