本文主要是对文章:NAT 穿透是如何工作的:技术原理及企业级实践的分析再学习
其原文是:tailscale.com/blog/how-na…
前文讲述了实际例子和IP地址短缺的问题,然后提出了:如何在两台经过 NAT 的机器之间建立 点对点连接(直连)。
然后紧接着提出了:
直接用机器的 IP 互连显然是不行的,因为它们都是私有 IP(例如 192.168.1.x)。在 Tailscale 中,我们会建立一个 WireGuard® 隧道 来解决这个问题 —— 但这并不是太重要,因为我们将过去几代人努力都整合到了一个工具集, 这些技术广泛适用于各种场景。例如,
WebRTC 使用这些技术在浏览器之间完成 peer-to-peer 语音、视频和数据传输, VoIP 电话和一些视频游戏也使用类似机制,虽然不是所有情况下都很成功。接下来,本文将在一般意义上讨论这些技术,并在合适的地方拿 Tailscale 和其他一些东西作为例子。
我完全能理解直连不行的问题和需要经过防火墙以及NAT的困难,但是对于他所讲的隧道和集成的技术我并没有相对应的认识。按照我所学习的内容,所谓协议和隧道不过是人为假设出来的概念,他们的实质就是在数据包前面所加的标识信息,并不是物理上实际扯了一根线连接两台机,但我还是想继续深入探究一下,找到了一些参考文章来做这部分基础认知的学习材料:
Tailscale 基础教程:部署私有 DERP 中继服务器
网络当中数据包实际上的传输
这里讨论的传输包括数据中心,边缘,两台计算机直连等情况
互联网中数据的传送,其实分为好几层来处理数据的,每一层有它自己明确的功能。例如就像流水线生产一样,一部分人负责这部分的工作,处理完之后就把剩余的工作扔给另外一部分人来处理……
对于互联网数据传送的分层模型,有分成七层的,有分成5层的,还有分成4层的。例如分成七层模型的如下(从上到下):
- 应用层
- 表示层
- 会话层
- 传输层
- 网络层
- 数据链路层
- 物理层
七层中,越往下越靠近计算机底层,越往上越靠近用户。
我可以这么来描述这个传输的模型:
1. 物理层
物理层面上,机器之间传输的是0、1信号,使用包括光纤、电缆、双绞线等物体,不存在任何的墙壁,就是接上了线这个电流或者说物理介质就到达对应的机器。就这一层来说的话,网络传输物理层面上是每个小局域网的广播扩散,不断在有相连介质当中扩散的过程;那也就是说我的电脑发送信息,物理层面上来说在我的电脑用网线直连的局域网内,所有的机器都会收到我的数据包。
但这样的话怎么做识别和路由,数据的划分还有协议呢?
2. 数据链路层
正如之前所说,物理层它只是单纯着负责在计算机之间传输0,1这样的电信号。假如这些0,1组合的传送毫无规则,计算机不可能随便取任意长度任意排列的01来做解读,因为这样传输过来的0、1就是没有意义的,只有拥有排列组合顺序的0、1才是信息的载体。因此,需要制定一套规则来进行0,1的传送。例如多少个电信号为一组,每一组信号应该如何标识才能让计算机读懂等。
数据链路层工作在物理层之上,负责给这些0、1制定传送的规则,然后另一方再按照相应的规则来进行解读。 这里的规则有这么几个:
-
以太网协议:以太网协议规定,一组电信号构成一个数据包,我们把这个数据包称之为“帧”。每一个桢由标头(Head) 和数据(Data) 两部分组成。
帧的最大长度是1518个字节,最小长度为64字节。假如需要传送的数据很大的话,就分成多个帧来进行传送。 也就是说这个协议规定了数据在底层应该有的分组顺序。
-
MAC地址 :把一台计算的的数据通过物理层和链路层发送给另一台计算机,如何能够保证目标计算机能够接收同时其他计算机不接收呢?
这就是MAC地址来做的事情,连入网络的每一个计算机都会有网卡接口,每一个网卡都会一个地址,这个地址就叫做MAC地址。计算机之间的数据传送,就是通过MAC地址来唯一寻找、传送的。MAC地址在网卡生产是就被唯一标识了。
正如前文所说,计算机的数据都是先在网络直连的局域网内广播,那么这个MAC地址怎么做到唯一标识呢?实际上所有的局域网内计算机都会收到数据包,因为连接了物理信号就直接传输过去了,这个过程是广播的,没有设置任何物理上的阻碍的。但是当一台计算机按照以太网协议规定的帧将数据读入时,对于一帧的数据,计算机会拿出其中的目标MAC与自己的相比,相同才会接收,不相同就直接丢弃,这个过程是硬件上的与或操作,信号一到就会像是盖章一样去比较。 就和在班上点名一样,大家都能听到,但是只有叫道名字的人才会回答
-
广播与ARP协议:能够让网络当中的计算机A知道计算机B的 MAC地址的协议
这部分内容有关IP地址,换句话说,所有的寻址和协议都是软件层面来实现的功能,只不过由操作系统负责还是编写的应用程序负责而已,同时可以看到即便是MAC地址的信息传递和寻址相关也是通过多层地址的机制来做的
3. 网络层
实际上仅靠上述两个部分就能满足一个区域的网络连接要求,通过广播的形式来连接并判断MAC地址是否就是目标,但是如果网络的规模进一步扩大,每一台电脑都要接收到彼此的信息,不仅需要每一台电脑都要连接网线还要求彼此之间的距离不是很长,因此产生了子网。
那如何区分哪些MAC地址是属于同一个子网的呢?假如是同一个子网,那我们就用广播的形式把数据传送给对方,如果不是同一个子网的,我们就会把数据发给网关,让网关进行转发。
为了解决这个问题我们引入了一套新的地址协议,这个地址协议能够帮助我们区分MAC地址是否处于同一个子网中。这也是网络层负责解决的问题。
在网络层有以下几个协议:
- IP协议
IP协议有两种版本,一种是IPv4,另一种是IPv6 这个IP地址由32为的二进制数组成,我们一般把它分成4段的十进制表示,地址范围为0.0.0.0~255.255.255.255
每一台想要联网的计算机都会有一个IP地址。这个IP地址被分为两部分,前面一部分代表网络部分,后面一部分代表主机部分。并且网络部分和主机部分的二进制位数是不固定的。
按照前文所说,计算机硬件上只能接收到帧为单位的数据,他能看到的只有0、1的数据,那如何才能展现出ip寻址并指向唯一的MAC呢? 实际上在上文中帧的结构里已经能够看到,对于一段数据可以分作标签部分和数据部分,在标签部分按照规定好的规则来填写包括IP地址在内的数据,然后将数据部分传给其他的进程,这就做到了一种概念上的通道和网络连接,通过识别之后丢弃转发等操作的结合就能够实现现在网络的所有基础功能。
- ARP协议
有了两台计算机的IP地址,我们就可以判断出它们是否处于同一个子网之中。 假如他们处于同一个子网之中,计算机A要给计算机B发送数据时。我们可以通过ARP协议来得到计算机B的MAC地址。ARP协议也是通过广播的形式给同一个子网中的每台电脑发送一个数据包(当然,这个数据包会包含接收方的IP地址)。对方收到这个数据包之后,会取出IP地址与自身的对比,如果相同,则把自己的MAC地址回复给对方,否则就丢弃这个数据包。 这样,计算机A就能知道计算机B的MAC地址了。
4. 传输层
到传输层就已经完成了机器层级的网络传输,也就是说现在获取到数据的计算机已经将数据包的数据部分存入内存当中,剩下的问题就是该将这些数据交给计算机当中的哪一个进程或者说是应用。
完成将计算机中的数据转给对应程序任务的工作就是传输层,那如何让计算机知道这些数据该给哪一个进程呢?
这个时候,端口(Port) 这个家伙就上场了,也就是说,在从计算机A传数据给计算表B的时候,还得指定一个端口,以供特定的应用程序来接受处理。
用比较制式的说法就是,传输层的功能就是建立端口到端口的通信,相比网络层的功能是建立主机到主机的通信。但说白了就是已经完成外部机器的网络传输之后,操作系统决定将数据传输给哪一个进程使用的协议。
5. 应用层
在应用层之下的网络传输都几乎制式化规定好了,要么是写进了硬件,要么是写进了操作系统,如果要对这些协议做更改做出自己的协议可以采用和上述数据结构一样的想法,也就是在数据部分再分出标记部分和数据部分,并且规定这些标签指向的应用或者是进程,以作到虚拟化的网络环境。
理解到这个层次就可以继续来看所说的NAT穿透,或者说建立分布式对等网络时候可能会给出什么样的方案呢?可以说所有网络传输在硬件层面只有单纯的传输和简单的标识,真正来处理判别和路由的都是操作系统和应用,也就是所谓网络架构都是基于文件系统、基于软件编排上来做的。 在这个基础上进一步预想,阻碍通信的只会是软件系统,硬件上不存在任何的物理上的阻碍(当然地理上很远需要中间转发的阻碍是有的,不过为了避免其他这类要素影响,我们先暂且讨论局部网络的穿透),那么想要实现NAT或者说对等网络,那就需要:1. 在网络层能够路由到正确的目标机器 2. 在传输层及以上能够实现正确的数据传递;关键是怎么样才能解决计算机自身的包括NAT,防火墙在内的各项检验措施,关键应当是如何修改那些标签部分的数据,让自身能够通过原本正常的网络通信协议和机制完成通信,接着往下看文章
将上述的分析及流程更加西街道系统流程整理以下可以得到以下几张图:
[图一]
[图二]
[图三]
[图四]
以上图片均引用自上述连接的文章
NAT 穿透基础
1. 两个必备前提:UDP + 能直接控制 socket
如果想设计自己的协议来实现 NAT 穿透,那必须满足以下两个条件:
协议应该基于 UDP
对收发包的 socket 有直接控制权
这两个条件的理解是: UDP是数据报文头当中的标签格式,Socket是操作系统接收数据的调用工具;这样就可以理解自定义NAT 穿透的协议需要设计识别的标签信息,同时还能调用操作系统函数来完成对数据的接收,如果硬要用分层来理解就是在传输层(and 网络层)和应用层上做文章。
2. 保底方式:中继
就是只要网络能够连接,这种方式必定是可以执行的,可以说是最后方案了。
在某些场景中,直接访问 socket 这一条件可能很难满足。
退而求其次的一个方式是设置一个 local proxy(本地代理),换句话说就是一个中转的服务器,主协议与这个 proxy 通信 ,后者来完成 NAT 穿透,将包中继(relay)给对端。这种方式增加了一个额外的间接层,或者是中转对象
好处是:
- 仍然能获得 NAT 穿透
- 不需要对已有的应用程序做任何改动。
- 大多数情况下的摆烂做法
坏处是:
- 通信质量取决于中继服务器,且类似向日葵一类软件已经显示出其在图像方面有着非常高的延迟
- 时延很大且不"稳定"
这里补充一下:
NAT 按照 NAT 映射行为和有状态防火墙行为可以分为多种类型,但对于 NAT 穿透来说根本不需要关心这么多类型,只需要看 NAT 或者有状态防火墙是否会严格检查目标 Endpoint,根据这个因素,可以将 NAT 分为 Easy NAT 和 Hard NAT。
- Easy NAT 及其变种称为 “Endpoint-Independent Mapping” (EIM,终点无关的映射) 这里的 Endpoint 指的是目标 Endpoint,也就是说,有状态防火墙只要看到有客户端自己发起的出向包,就会允许相应的入向包进入,不管这个入向包是谁发进来的都可以。
- hard NAT 以及变种称为 “Endpoint-Dependent Mapping”(EDM,终点相关的映射) 这种 NAT 会针对每个目标 Endpoint 来生成一条相应的映射关系。 在这样的设备上,如果客户端向某个目标 Endpoint 发起了出向包,假设客户端的公网 IP 是 2.2.2.2,那么有状态防火墙就会打开一个端口,假设是 4242。那么只有来自该目标 Endpoint 的入向包才允许通过
2.2.2.2:4242,其他客户端一律不允许。这种 NAT 更加严格,所以叫 Hard NAT。
挑战:有状态防火墙和 NAT 设备
如果不使用保底的中继方式,只靠软件的编程方式来实现穿透NAT的P2P通信,必须要面临的挑战是有状态防火墙和 NAT 设备;但如果是使用中继则通过一个中转的服务器避开了这两个问题。
文中提到: 一步步看如何实现一个企业级的 NAT 穿透方案:在两个设备之间通过 UDP 实现双向通信, 有了这个基础,上层的其他协议(WireGuard, QUIC, WebRTC 等)就能做一些更酷的事情
但要实现这个目标需要解决两个问题:
- 有状态防火墙
- NAT 设备
穿透防火墙
有状态防火墙具体有很多种类型:
- Windows Defender --> firewall
- Ubuntu’s --> ufw (using iptables/nftables)
- BSD/macOS --> pf
- AWS --> Security Groups(安全组)
1. 有状态防火墙
1.1 默认的规则
以上防火墙的配置都是很灵活的,但大部分配置默认都是如下行为:
- 允许所有出向连接(allows all “outbound” connections)
- 禁止所有入向连接(blocks all “inbound” connections)
- 可能有少量例外规则,例如 allowing inbound SSH。
1.2 如何区分"inbound" 和 "outbound"
连接(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. 防火墙朝向(face-off)与穿透方案
2.1 防火墙朝向相同
场景特点:服务端 IP 可直接访问 在 NAT 穿透场景中,以上默认规则对 UDP 流量的影响不大 —— 只要路径上所有防火墙的“朝向”是一样的。一般来说,从内网访问公网上的某个服务器都属于这种情况。
我们唯一的要求是:连接必须是由防火墙后面的机器发起的。这是因为 在它主动和别人通信之前,没人能主动和它通信,如下图所示:
穿透方案:客户端直连服务端,或 hub-and-spoke 拓扑
2.2 防火墙朝向不同
场景特点:服务端 IP 不可直接访问 但如果两个“客户端”想直连,以上方式就不行了,此时两边的防火墙相向而立,如下图所示:
根据前面的讨论,这种情况意味着:两边要同时发起连接请求,但也意味着 两边都无法发起有效请求,因为对方先发起请求才能在它的防火墙上打开一条缝让我们进去!如何破解这个问题呢?一种方式是让用户重新配置一边或两边的防火墙,打开一个端口, 允许对方的流量进来。
- 这显然对用户不友好,在像 Tailscale 这样的 mesh 网络中的扩展性也不好,在 mesh 网络中,我们假设对端会以一定的粒度在公网上移动。
- 此外,在很多情况下用户也没有防火墙的控制权限:例如在咖啡馆或机场中,连接的路 由器是不受你控制的(否则你可能就有麻烦了)。
因此,我们需要寻找一种不用重新配置防火墙的方式。
穿透方案:两边同时主动建连,在本地防火墙为对方打开一个洞
解决的思路还是先重新审视前面提到的有状态防火墙规则:
对于 UDP,其规则(逻辑)是:包必须先出去才能进来(packets must flow out before packets can flow back in)。
注意,这里除了要满足包的 IP 和端口要匹配这一条件之外,并没有要求包必须是相关的(related)。换句话说,只要某些包带着正确的源和目的地址出去了,任何看起来像是响应的包都会被防火墙放进来 —— 即使对端根本没收到你发出去的包。
因此,要穿透这些有状态防火墙,我们只需要共享一些信息:让两端提前知道对方使用的 ip:port:
手动静态配置是一种方式,但显然扩展性不好, 总不能每一次建立可靠都使用人工配置文件再重启吧;
我们开发了一个 coordination server, 以灵活、安全的方式来同步 ip:port 信息。有了对方的 ip:port 信息之后,两端开始给对方发送 UDP 包。在这个过程中,我们预料到某些包将会被丢弃。因此,双方必须要接受某些包会丢失的事实, 因此如果是重要信息,你必须自己准备好重传。对 UDP 来说丢包是可接受的,但这里尤其需要接受。
来看一下具体建连(穿透)过程:
- 如图所示,笔记本出去的第一包,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 防火墙 和服务端防火墙(因为这是对服务端发送的包的应答包),达到服务端。
这样我们就建立了一个穿透两个相向防火墙的双向通信连接
3. 关于穿透防火墙的一些思考
重要的一点是:通信双方必须几乎同时发起通信, 这样才能在路径上的防火墙打开一条缝,而且两端还都是活着的。
3.1 双向主动建连:旁路信道
如何实现“同时”呢?一种方式是两端不断重试,但显然这种方式很浪费资源。假如双方都 知道何时开始建连就好了。
这听上去是鸡生蛋蛋生鸡的问题了:双方想要通信,必须先提前通个信。
但实际上,我们可以通过旁路信道(side channel)来达到这个目的 ,并且这个旁路信道并不需要很 fancy:它可以有几秒钟的延迟、只需要传送几 KB 的 信息,因此即使是一个配置非常低的虚拟机,也能为几千台机器提供这样的旁路通信服务。比较直白来说就是专门做一个用于发送和建立连接的程序(进程),让他来做这个不断重试的行为,这样只是不需要本体程序来做这个流程,并没有彻底避免重试连接。
在遥远的过去,该文章的作者曾用 XMPP 聊天消息作为旁路,效果非常不错。
相关学习资料: xmpp.org/
有一个名字相似的XAMPP,但是和咱们讨论的NAT穿透协议,没有直接关联blog.csdn.net/qq_36595013…
另一个例子是 WebRTC,它需要你提供一个自己的“信令信道”(signalling channel, 这个词也暗示了 WebRTC 的 IP 通讯电话的血统"IP telephony ancestry"),并将其配置到 WebRTC API。在 Tailscale,我们的协调服务器(coordination server)和 DERP (Detour Encrypted Routing Protocol) 服务器集群是我们的旁路信道。
WebRTC 相关的资料: webrtc.org.cn/; zhuanlan.zhihu.com/p/86751078; developer.mozilla.org/zh-CN/docs/… 但是他就是一个工具,本身对于穿透概念来说并不重要
DERP (Detour Encrypted Routing Protocol) 可以参考: Tailscale 基础教程:部署私有 DERP 中继服务器
3.2 非活跃连接被防火墙清理
有状态防火墙内存通常比较有限,因此会定期清理不活跃的连接(UDP 常见的是 30s), 因此要保持连接 alive 的话需要定期通信,否则就会被防火墙关闭,为避免这个问题, 我们要么定期向对方发包来 keepalive, 要么有某种带外方式来按需重建连接。
3.3 问题都解决了?不,挑战刚刚开始
对于防火墙穿透来说, 我们并不需要关心路径上有几堵墙 —— 只要它们是有状态防火墙且允许出向连接,这种同时发包(simultaneous transmission)机制就能穿透任意多层防火墙。这一点对我们来说非常友好,因为只需要实现一个逻辑,然后能适用于任何地方了。
…对吗?
其实,不完全对。这个机制有效的前提是:我们能提前知道对方的 ip:port。而这就涉及到了我们今天的主题:NAT,它会使前面我们刚获得的一点满足感顿时消失。
穿透防火墙的前提是得先知道对方的地址,那么上面所说的那些穿透防火墙的方法和理论就能够成立
下面,进入本文正题。
NAT 的本质
3.1 NAT 设备与有状态防火墙
可以认为 NAT 设备是一个增强版的有状态防火墙,虽然它的增强功能 对于本文场景来说并不受欢迎:除了前面提到的有状态拦截/放行功能之外,它们还会在数据包经过时修改这些包。
3.2 NAT 穿透与 SNAT/DNAT
具体来说,NAT 设备能完成某种类型的网络地址转换,例如,替换源或目的 IP 地址或端口。
讨论连接问题和 NAT 穿透问题时,我们只会受 source NAT —— SNAT 的影响。(Destination NAT)DNAT 不会影响 NAT 穿透。
3.3 SNAT 的意义:解决 IPv4 地址短缺问题
SNAT 最常见的使用场景是将很多设备连接到公网,而只使用少数几个公网 IP。例如对于消费级路由器,会将所有设备的(私有) IP 地址映射为单个连接到公网的 IP 地址。
这种方式存在的意义是:我们有远多于可用公网 IP 数量的设备需要连接到公网,(至少 对 IPv4 来说如此,IPv6 的情况后面会讨论)。NAT 使多个设备能共享同一 IP 地址,因 此即使面临 IPv4 地址短缺的问题,我们仍然能不断扩张互联网的规模。
3.4 SNAT 过程:以家用路由器为例
假设你的笔记本连接到家里的 WiFi,下面看一下它连接到公网某个服务器时的情形:
- 笔记本发送 UDP packet 192.168.0.20:1234 -> 7.7.7.7:5678。
这一步就好像笔记本有一个公网 IP 一样,但源地址 192.168.0.20 是私有地址, 只能出现在私有网络,公网不认,收到这样的包时它不知道如何应答。
- 家用路由器出场,执行 SNAT。
包经过路由器时,路由器发现这是一个它没有见过的新会话(session)。它知道 192.168.0.20 是私有 IP,公网无法给这样的地址回包,但它有办法解决:
- 在它自己的公网 IP 上挑一个可用的 UDP 端口,例如 2.2.2.2:4242,
- 然后创建一个 NAT mapping:192.168.0.20:1234 <--> 2.2.2.2:4242,
- 然后将包发到公网,此时源地址变成了 2.2.2.2:4242 而不是原来的 192.168.0.20:1234。因此服务端看到的是转换之后地址,
- 接下来,每个能匹配到这条映射规则的包,都会被路由器改写 IP 和 端口。
不过这个过程其实和NAT的原意不太相同;NAT是能够将内外部网络地址分配分开的机制,原本应该是对IPV4地址不足以及局域网内访问管理使用的技术,也就是说这些内部地址都是首先路由器给每一台机器分配的,然后在这个局域网内是有用的地址,但是也存在着多层路由导致即便在同一个路由器下也无法通过网络直接访问的情况。
- 反向路径是类似的,路由器会执行相反的地址转换,将 2.2.2.2:4242 变回 192.168.0.20:1234。对于笔记本来说,它根本感知不知道这正反两次变换过程。
这里是拿家用路由器作为例子,但办公网的原理是一样的。不同之处在 于,办公网的 NAT 可能有多台设备组成(高可用、容量等目的),而且它们有不止一个公 网 IP 地址可用,因此在选择可用的公网 ip:port 来做映射时,选择空间更大,能支持 更多客户端。
3.5 SNAT 给穿透带来的挑战
现在我们遇到了与前面有状态防火墙类似的情况,但这次是 NAT 设备:通信双方 不知道对方的 ip:port 是什么,因此无法主动建连,如下图所示:
但这次比有状态防火墙更糟糕,严格来说,在双方发包之前,根本无法确定(自己及对方的)ip:port 信息,因为 只有出向包经过路由器之后才会产生 NAT mapping(即,可以被对方连接的 ip:port 信息)。
因此我们又回到了与防火墙遇到的问题,并且情况更糟糕:双方都需要主动和对 方建连,但又不知道对方的公网地址是多少,只有当对方先说话之后,我们才能拿到它的地址信息。
如何破解以上死锁呢?这就轮到 STUN 登场了。
4 穿透 “NAT+防火墙”:STUN (Session Traversal Utilities for NAT) 协议
STUN 既是一些对 NAT 设备行为的详细研究,也是一种协助 NAT 穿透的协议。本文主要关注 STUN 协议。
4.1 STUN 原理
STUN 基于一个简单的观察:从一个会被 NAT 的客户端访问公网服务器时, 服务器看到的是 NAT 设备的公网 ip:port 地址,而非该 客户端的局域网 ip:port 地址。
也就是说,服务器能告诉客户端它看到的客户端的 ip:port 是什么。因此,只要将这个信息以某种方式告诉通信对端(peer),后者就知道该和哪个地址建连了!这样就又简化为前面的防火墙穿透问题了。
本质上这就是 STUN 协议的工作原理,如下图所示:
- 笔记本向 STUN 服务器发送一个请求:“从你的角度看,我的地址什么?”
- STUN 服务器返回一个响应:“我看到你的 UDP 包是从这个地址来的:ip:port”。
The STUN protocol has a bunch more stuff in it — there’s a way of obfuscating the ip:port in the response to stop really broken NATs from mangling the packet’s payload, and a whole authentication mechanism that only really gets used by TURN and ICE, sibling protocols to STUN that we’ll talk about in a bit. We can ignore all of that stuff for address discovery.
4.2 为什么 NAT 穿透逻辑和主协议要共享同一个 socket
理解了 STUN 原理,也就能理解为什么我们在文章开头说,如果 要实现自己的 NAT 穿透逻辑和主协议,就必须让二者共享同一个 socket:
- 每个 socket 在 NAT 设备上都对应一个映射关系(私网地址 -> 公网地址),
- STUN 服务器只是辅助穿透的基础设施,
- 与 STUN 服务器通信之后,在 NAT 及防火墙设备上打开了一个连接,允许入向包进来(回忆前面内容, 只要目的地址对,UDP 包就能进来,不管这些包是不是从 STUN 服务器来的),
- 因此,接下来只要将这个地址告诉我们的通信对端(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 功能,再加上两端同时 发起防火墙穿透,就能把连接打通;
in theory, there are also NAT devices that are super relaxed, and don’t ship with stateful firewall stuff at all. In those, you don’t even need simultaneous transmission, the STUN request gives you an internet ip:port that anyone can connect to with no further ceremony. If such devices do still exist, they’re increasingly rare.
- 另外一些 NAT 设备就要困难很多了,它会针对每个目的地址来生成一条相应的映射关系。在这样的设备上,如果我们用相同的 socket 来分别发送数据包到 5.5.5.5:1234 and 7.7.7.7:2345,我们就会得到 2.2.2.2 上的两个不同的端口,每个目的地址对应一个。如果反向包的端口用的不对,包就无法通过防火墙。如下图所示:
5 中场补课:NAT 正式术语
知道 NAT 设备的行为并不是完全一样之后,我们来引入一些正式术语。
5.1 早期术语
如果之前接触过 NAT 穿透,可能会听说过下面这些名词:
- “Full Cone”
- “Restricted Cone”
- “Port-Restricted Cone”
- “Symmetric” NATs
这些都是 NAT 穿透领域的早期术语。
但其实这些术语相当让人困惑。我每次都要 查一下 Restricted Cone NAT 是什么意思。从实际经验来看,我并不是唯一对此感到困惑的人。例如,如今互联网上将 “easy” NAT 归类为 Full Cone,而实际上它们更应该归类为 Port-Restricted Cone。
5.2 近期研究与新术语
最近的一些研究和 RFC 已经提出了一些更准确的术语。
- 首先,它们明确了如下事实:NAT 设备的行为差异表现在多个维度, 而并非只有早期研究中所说的 “cone” 这一个维度,因此基于 “cone” 来划分类别并不是很有帮助。
- 其次,新研究和新术语能更准确地描述 NAT 在做什么。
前面提到的所谓 "easy" 和 "hard" NAT,只在一个维度有不同:NAT 映射是否考虑到目的地址信息。RFC 4787 中,
- 将 easy NAT 及其变种称为 “Endpoint-Independent Mapping” (EIM,终点无关的映射)但是,从“命名很难”这一程序员界的伟大传统来说,EIM 这个词其实 也并不是 100% 准确,因为这种 NAT 仍然依赖 endpoint,只不过依赖的是源 endpoint:每个 source ip:port 对应一个映射 —— 否则你的包就会和别人的包混在一起,导致混乱。严格来说,EIM 应该称为 “Destination Endpoint Independent Mapping” (DEIM?), 但这个名字太拗口了,而且按照惯例,Endpoint 永远指的是 Destination Endpoint。
- 将 hard NAT 以及变种称为 “Endpoint-Dependent Mapping”(EDM,终点相关的映射) 。EDM 中还有一个子类型,依据是只根据 dst_ip 做映射,还是根据 dst_ip + dst_port 做映射。对于 NAT 穿透来说,这种区分对来说是一样的:它们都会导致 STUN 方式不可用。
5.3 老的 cone 类型划分
你可能会有疑问:根据是否依赖 endpoint 这一条件,只能组合出两种可能,那为什么传 统分类中会有四种 cone 类型呢?答案是 cone 包含了两个正交维度的 NAT 行为:
- NAT 映射行为:前面已经介绍过了,
- 有状态防火墙行为:与前者类似,也是分为与 endpoint 相关还是无关两种类型。
因此最终组合如下:
分解到这种程度之后就可以看出,cone 类型对 NAT 穿透场景来说并没有什么意义。我们关心的只有一点:是否是 Symmetric —— 换句话说,一个 NAT 设备是 EIM 还是 EDM 类型的。
5.4 针对 NAT 穿透场景:简化 NAT 分类
以上讨论可知,虽然理解防火墙的具体行为很重要,但对于编写 NAT 穿透代码来说,这一点并不重要。我们的两端同时发包方式(simultaneous transmission trick)能 有效穿透以上三种类型的防火墙。在真实场景中, 我们主要在处理的是 IP-and-port endpoint-dependent 防火墙。
因此,对于实际 NAT 穿透实现,我们可以将以上分类简化成:
- Endpoint-Independent NAT mapping Endpoint-Dependent NAT mapping (dst. IP only)
- Firewall is yes Easy NAT Hard NAT
5.5 更多 NAT 规范(RFC)
想了解更多新的 NAT 术语,可参考
- RFC 4787 (NAT Behavioral Requirements for UDP)
- RFC 5382 (for TCP)
- RFC 5508 (for ICMP)
如果自己实现 NAT,那应该(should)遵循这些 RFC 的规范,这样才能使你的 NAT 行为符合业界惯例,与其他厂商的设备或软件良好兼容。
6 穿透 NAT+防火墙:STUN 不可用时,fallback 到中继模式
6.1 问题回顾与保底方式(中继)
补完基础知识(尤其是定义了什么是 hard NAT)之后,回到我们的 NAT 穿透主题。
- 第 1~4 节已经解决了 STUN 和防火墙穿透的问题,
- 但 hard NAT 对我们来说是个大问题,只要路径上出现一个这种设备,前面的方案就行不通了。准备放弃了吗?这才进入 NAT 真正有挑战的部分:如果已经试过了前面介绍的所有方式 仍然不能穿透,我们该怎么办呢?
- 实际上,确实有很多 NAT 实现在这种情况下都会选择放弃,向用户报一个“无法连接”之类的错误。
- 但对我们来说,这么快就放弃显然是不可接受的 —— 解决不了连通性问题,Tailscale 就没有存在的意义。我们的保底解决方式是:创建一个中继连接(relay)实现双方的无障碍地通信。但是,中继方式性能不是很差吗?这要看具体情况:
如果能直连,那显然没必要用中继方式;但如果无法直连,而中继路径又非常接近双方直连的真实路径,并且带宽足够大,那中 继方式并不会明显降低通信质量。延迟肯定会增加一点,带宽会占用一些,但 相比完全连接不上,还是更能让用户接受的。不过要注意:我们只有在无法直连时才会选择中继方式。实际场景中,
- 对于大部分网络,我们都能通过前面介绍的方式实现直连,
- 剩下的长尾用中继方式来解决,并不算一个很糟的方式。
此外,某些网络会阻止 NAT 穿透,其影响比这种 hard NAT 大多了。例如,我们观察到 UC Berkeley guest WiFi 禁止除 DNS 流量之外的所有 outbound UDP 流量。不管用什么 NAT 黑科技,都无法绕过这个拦截。因此我们终归还是需要一些可靠的 fallback 机制。
6.2 中继协议:TURN、DERP
有多种中继实现方式。
- TURN (Traversal Using Relays around NAT):经典方式,核心理念是用户(人)先去公网上的 TURN 服务器认证,成功后后者会告诉你:“我已经为你分配了 ip:port,接下来将为你中继流量”,然后将这个 ip:port 地址告诉对方,让它去连接这个地址,接下去就是非常简单的客户端/服务器通信模型了。Tailscale 并不使用 TURN。这种协议用起来并不是很好,而且与 STUN 不同, 它没有真正的交互性,因为互联网上并没有公开的 TURN 服务器。
- DERP (Detoured Encrypted Routing Protocol)
这是我们创建的一个协议,DERP,
- 它是一个通用目的包中继协议,运行在 HTTP 之上,而大部分网络都是允许 HTTP 通信的。
- 它根据目的公钥(destination’s public key)来中继加密的流量(encrypted payloads)。
前面也简单提到过,DERP 既是我们在 NAT 穿透失败时的保底通信方式(此时的角色 与 TURN 类似),也是在其他一些场景下帮助我们完成 NAT 穿透的旁路信道。换句话说,它既是我们的保底方式,也是有更好的穿透链路时,帮助我们进行连接升 级(upgrade to a peer-to-peer connection)的基础设施。
6.3 小结
有了“中继”这种保底方式之后,我们穿透的成功率大大增加了。如果此时不再阅读本文接下来的内容,而是把上面介绍的穿透方式都实现了,我预计:
- 90% 的情况下,你都能实现直连穿透;
- 剩下的 10% 里,用中继方式能穿透一些(some);这已经算是一个“足够好”的穿透实现了。
7 穿透 NAT+防火墙:企业级改进
如果你并不满足于“足够好”,那我们可以做的事情还有很多!
本节将介绍一些五花八门的 tricks,在某些特殊场景下会帮到我们。单独使用这项技术都 无法解决 NAT 穿透问题,但将它们巧妙地组合起来,我们能更加接近 100% 的穿透成功率。
7.1 穿透 hard NAT:暴力端口扫描
回忆 hard NAT 中遇到的问题,如下图所示,关键问题是:easy NAT 不知道该往 hard NAT 方的哪个 ip:port 发包。
但必须要往正确的 ip:port 发包,才能穿透防火墙,实现双向互通。怎么办呢?
- 首先,我们能知道 hard NAT 的一些 ip:port,因为我们有 STUN 服务器。
这里先假设我们获得的这些 IP 地址都是正确的(这一点并不总是成立,但这里先这么假 设。而实际上,大部分情况下这一点都是成立的,如果对此有兴趣,可以参考 REQ-2 in RFC 4787)。
- IP 地址确定了,剩下的就是端口了。总共有 65535 中可能,我们能遍历这个端口范围吗?
如果发包速度是 100 packets/s,那最坏情况下,需要 10 分钟来找到正确的端口。还是那句话,这虽然不是最优的,但总比连不上好。
这很像是端口扫描(事实上,确实是),实际中可能会触发对方的网络入侵检测软件。
7.2 基于生日悖论改进暴力扫描:hard side 多开端口 + easy side 随机探测
利用 birthday paradox 算法, 我们能对端口扫描进行改进。
生日攻击本质是一个概率问题: zhuanlan.zhihu.com/p/379077249; anders.wang/python-birt…; 生日悖论
- 上一节的基本前提是:hard side 只打开一个端口,然后 easy side 暴力扫描 65535 个端口来寻找这个端口;
- 这里的改进是:在 hard size 开多个端口,例如 256 个(即同时打开 256 个 socket,目的地址都是 easy side 的 ip:port), 然后 easy side 随机探测这边的端口。
这里省去算法的数学模型,如果你对实现干兴趣,可以看看我写的 python calculator。计算过程是“经典”生日悖论的一个小变种。下面是随着 easy side random probe 次数(假设 hard size 256 个端口)的变化,两边打开的端口有重合(即通信成功)的概率:
根据以上结果,如果还是假设 100 ports/s 这样相当温和的探测速率,那 2 秒钟就有约 50% 的成功概率。即使非常不走运,我们仍然能在 20s 时几乎 100% 穿透成功,而此时只探测了总端口空间的 4%。
非常好!虽然这种 hard NAT 给我们带来了严重的穿透延迟,但最终结果仍然是成功的。那么,如果是两个 hard NAT,我们还能处理吗?
7.3 双 hard NAT 场景
这种情况下仍然可以用前面的 多端口+随机探测 方式,但成功概率要低很多了:
- 每次通过一台 hard NAT 去探测对方的端口(目的端口)时,我们自己同时也生成了一个随机源端口,
- 这意味着我们的搜索空间变成了二维 {src port, dst port} 对,而不再是之前的一维 dst port 空间。
这里我们也不就具体计算展开,只告诉结果:仍然假设目的端打开 256 个端口,从源端发起 2048 次(20 秒), 成功的概率是:0.01%。
如果你之前学过生日悖论,就并不会对这个结果感到惊讶。理论上来说,
- 要达到 99.9% 的成功率,我们需要两边各进行170,000 次探测 —— 如果还是以 100 packets/sec 的速度,就需要 28 分钟。
- 要达到 50% 的成功率,“只”需要 54,000 packets,也就是 9 分钟。
- 如果不使用生日悖论方式,而且暴力穷举,需要 1.2 年时间!对于某些应用来说,28 分钟可能仍然是一个可接受的时间。用半个小时暴力穿透 NAT 之后, 这个连接就可以一直用着 —— 除非 NAT 设备重启,那样就需要再次花半个小时穿透建个新连接。但对于 交互式应用来说,这样显然是不可接受的。
更糟糕的是,如果去看常见的办公网路由器,你会震惊于它的 active session low limit 有多么低。例如,一台 Juniper SRX 300 最多支持 64,000 active sessions。也就是说,
- 如果我们想创建一个成功的穿透连接,就会把它的整张 session 表打爆 (因为我们要暴力探测 65535 个端口,每次探测都是一条新连接记录)!这显然要求这台路由器能从容优雅地处理过载的情况。
- 这只是创建一条连接带来的影响!如果 20 台机器同时对这台路由器发起穿透呢?绝对的灾难!至此,我们通过这种方式穿透了比之前更难一些的网络拓扑。这是一个很大的成就,因为 家用路由器一般都是 easy NAT,hard NAT 一般都是办公网路由器或云 NAT 网关。这意味着这种方式能帮我们解决
- home-to-office(家->办公室)
- home-to-cloud (家->云) 的场景,以及一部分
- office-to-cloud (办公室->云)
- cloud-to-cloud (云->办公室) 场景。
7.4 控制端口映射(port mapping)过程:UPnP/NAT-PMP/PCP 协议
如果我们能让 NAT 设备的行为简单点,不要把事情搞这么复杂,那建 立连接(穿透)就会简单很多。真有这样的好事吗?还真有,有专门的一种协议叫 端口映射协议(port mapping protocols)。通过这种协议禁用掉前面 遇到的那些乱七八糟的东西之后,我们将得到一个非常简单的“请求-响应”。
下面是三个具体的端口映射协议:
- UPnP IGD (Universal Plug’n’Play Internet Gateway Device)
最老的端口控制协议, 诞生于 1990s 晚期,因此使用了很多上世纪 90 年代的技术 (XML、SOAP、multicast HTTP over UDP —— 对,HTTP over UDP ),而且很难准确和安全地实现这个协议。但以前很多路由器都内置了 UPnP 协议, 现在仍然很多。
请求和响应:
“你好,请将我的 lan-ip:port 转发到公网(WAN)”, “好的,我已经为你分配了一个公网映射 wan-ip:port ”。
UPnP IGD 出来几年之后,Apple 推出了一个功能类似的协议,名为 NAT-PMP (NAT Port Mapping Protocol)。
但与 UPnP 不同,这个协议只做端口转发,不管是在客户端还是服务端,实现起来都非常简单。
- PCP
稍后一点,又出现了 NAT-PMP v2 版,并起了个新名字PCP (Port Control Protocol)。
因此要更好地实现穿透,可以
- 先判断本地的默认网关上是否启用了 UPnP IGD, NAT-PMP and PCP, 如果探测发现其中任何一种协议有响应,我们就申请一个公网端口映射,
- 可以将这理解为一个加强版 STUN:我们不仅能发现自己的公网 ip:port,而且能指示我们的 NAT 设备对我们的通信对端友好一些 —— 但并不是为这个端口修改或添加防火墙规则。
- 接下来,任何到达我们 NAT 设备的、地址是我们申请的端口的包,都会被设备转发到我们。
但我们不能假设这个协议一定可用:
- 本地 NAT 设备可能不支持这个协议;
- 设备支持但默认禁用了,或者没人知道还有这么个功能,因此从来没开过;
- 安全策略要求关闭这个特性。
这一点非常常见,因为 UPnP 协议曾曝出一些高危漏洞(后面都修复了,因此如果是较新的设备,可以安全地使用 UPnP —— 如果实现没问题)。不幸的是,某些设备的配置中,UPnP, NAT-PMP,PCP 是放在一个开关里的(可能 统称为 “UPnP” 功能),一开全开,一关全关。因此如果有人担心 UPnP 的安全性,他连另 外两个也用不了。
最后,终归来说,只要这种协议可用,就能有效地减少一次 NAT,大大方便建连过程。但接下来看一些不常见的场景。
7.5 多 NAT 协商(Negotiating numerous NATs)
目前为止,我们看到的客户端和服务端都各只有一个 NAT 设备。如果有多个 NAT 设备会 怎么样?例如下面这种拓扑:
这个例子比较简单,不会给穿透带来太大问题。包从客户端 A 经过多次 NAT 到达公网的过程,与前面分析的穿过多层有状态防火墙是一样的:
- 额外的这层(NAT 设备)对客户端和服务端来说都不可见,我们的穿 透技术也不关心中间到底经过了多少层设备。
- 真正有影响的其实只是最后一层设备,因为对端需要在这一层设备上 找到入口让包进来。
具体来说,真正有影响的是端口转发协议。
客户端使用这种协议分配端口时,为我们分配端口的是最靠近客户端的这层 NAT 设备;而我们期望的是让最离客户端最远的那层 NAT 来分配,否则我们得到的就是一个网络中间层分配的 ip:port,对端是用不了的;不幸的是,这几种协议都不能递归地告诉我们下一层 NAT 设备是多少 —— 虽然可以用 traceroute 之类的工具来探测网络路径,再加上 猜路上的设备是不是 NAT 设备(尝试发送 NAT 请求) —— 但这个就看运气了。这就是为什么互联网上充斥着大量的文章说 double-NAT 有多糟糕,以 及警告用户为保持后向兼容不要使用 double-NAT。但实际上,double-NAT 对于绝大部分 互联网应用来说都是不可见的(透明的),因为大部分应用并不需要主动地做这种 NAT 穿 透。
但我也绝不是在建议你在自己的网络中设置 double-NAT。
- 破坏了端口映射协议之后,某些视频游戏的多人(multiplayer)模式就会无法使用,
- 也可能会使你的 IPv6 网络无法派上用场,后者是不用 NAT 就能双向直连的一个好方案。
但如果 double-NAT 并不是你能控制的,那除了不能用到这种端口映射协议之外,其他大部分东西都是不受影响的。
double-NAT 的故事到这里就结束了吗?—— 并没有,而且更大型的 double-NAT 场景将展现在我们面前。
7.6 运营商级 NAT 带来的问题
即使用 NAT 来解决 IPv4 地址不够的问题,地址仍然是不够用的,ISP(互联网服务提供商) 显然 无法为每个家庭都分配一个公网 IP 地址。那怎么解决这个问题呢?ISP 的做法是不够了就再嵌套一层 NAT:
- 家用路由器将你的客户端 SNAT 到一个 “intermediate” IP 然后发送到运营商网络,
- ISP’s network 中的 NAT 设备再将这些 intermediate IPs 映射到少量的公网 IP。后面这种 NAT 就称为“运营商级 NAT”(carrier-grade NAT,或称电信级 NAT),缩写 CGNAT。如下图所示:
CGNAT 对 NAT 穿透来说是一个大麻烦。
在此之前,办公网用户要快速实现 NAT 穿透,只需在他们的路由器上手动设置端口映射就行了。但有了 CGNAT 之后就不管用了,因为你无法控制运营商的 CGNAT!好消息是:这其实是 double-NAT 的一个小变种,因此前面介绍的解决方式大部分还仍然是适用的。某些东西可能会无法按预期工作,但只要肯给 ISP 交钱,这些也都能解决。除了 port mapping protocols,其他我们已经介绍的所有东西在 CGNAT 里都是适用的。
新挑战:同一 CGNAT 侧直连,STUN 不可用
但我们确实遇到了一个新挑战:如何直连两个在同一 CGNAT 但不同家用路由器中的对端呢?如下图所示:
在这种情况下,STUN 就无法正常工作了:STUN 看到的是客户端在公网(CGNAT 后面)看到的地址, 而我们想获得的是在 “middle network” 中的 ip:port,这才是对端真正需要的地址,
解决方案:如果端口映射协议能用:一端做端口映射
怎么办呢?
如果你想到了端口映射协议,那恭喜,答对了!如果 peer 中任何一个 NAT 支持端口映射协议, 对我们就能实现穿透,因为它分配的 ip:port 正是对端所需要的信息。
这里讽刺的是:double-NAT(指 CGNAT)破坏了端口映射协议,但在这里又救了我们!当然,我们假设这些协议一定可用,因为 CGNAT ISP 倾向于在它们的家用路由器侧关闭 这些功能,已避免软件得到“错误的”结果,产生混淆。
解决方案:如果端口映射协议不能用:NAT hairpin 模式
如果不走运,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 进行 SNAT。
- 注意到目的地址 2.2.2.2:5678 匹配到的是 B 的入向 NAT 映射,因此接着对这个包执行 DNAT,将目的 IP 改成 B 的私有地址。
- 通过 CGNAT 的 internal 接口(而不是 public 接口,对应公网)将包发给 B。这种 NAT 行为有个专门的术语,叫 hairpinning(直译为发卡,意思 是像发卡一样,沿着一边上去,然后从另一边绕回来),
大家应该猜到的一个事实是:不是所以 NAT 都支持 hairpin 模式。实际上,大量 well-behaved NAT 设备都不支持 hairpin 模式,
- 因为它们都有 “只有 src_ip 是私有地址且 dst_ip 是公网地址的包才会经过我” 之类的假设。
- 因此对于这种目的地址不是公网、需要让路由器把包再转回内网的包,它们会直接丢弃。
- 这些逻辑甚至是直接实现在路由芯片中的,因此除非升级硬件,否则单靠软件编程无法改变这种行为。Hairpin 是所有 NAT 设备的特性(支持或不支持),并不是 CGNAT 独有的。
- 在大部分情况下,这个特性对我们的 NAT 穿透目的来说都是无所谓的,因为我们期望中 两个 LAN NAT 设备会直接通信,不会再向上绕到它们的默认网关 CGNAT 来解决这个问题。
Hairpin 特性可有可无这件事有点遗憾,这可能也是为什么 hairpin 功能经常 broken 的原因。
- 一旦必须涉及到 CGNAT,那 hairpinning 对连接性来说就至关重要了。
Hairpinning 使内网连接的行为与公网连接的行为完成一致,因此我们无需关心目的 地址类型,也不用知晓自己是否在一台 CGNAT 后面。
如果 hairpinning 和 port mapping protocols 都不可用,那只能降级到中继模式了。
7.7 全 IPv6 网络:理想之地,但并非问题全无
行文至此,一些读者可能已经对着屏幕咆哮:不要再用 IPv4 了!花这么多时间精力解决这些没意义的东西,还不如直接换成 IPv6!
- 的确,之所以有这些乱七八糟的东西,就是因为 IPv4 地址不够了,我们一直在用越来越复杂的 NAT 来给 IPv4 续命。
- 如果 IP 地址够用,无需 NAT 就能让世界上的每个设备都有一个自己的公网 IP 地址,这些问题不就解决了吗?
简单来说,是的,这也正是 IPv6 能做的事情。但是,也只说对了一半:在理想的全 IPv6 世界中,所有这些东西会变得更加简单,但我们面临的问题并不会完全消失 —— 因为有状态防火墙仍然还是存在的。
- 办公室中的电脑可能有一个公网 IPv6 地址,但你们公司肯定会架设一个防火墙,只允许 你的电脑主动访问公网,而不允许反向主动建连。
- 其他设备上的防火墙也仍然存在,应用类似的规则。
因此,我们仍然会用到
- 本文最开始介绍的防火墙穿透技术,以及帮助我们获取自己的公网 ip:port 信息的旁路信道
- 仍然需要在某些场景下 fallback 到中继模式,例如 fallback 到最通用的 HTTP 中继 协议,以绕过某些网络禁止 outbound UDP 的问题。
但我们现在可以抛弃 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 设备,我们能将 IPv4 DNS 应答给 IPv6 网络,这样对终端来说,它看到的就是一个 全 IPv6 网络,而仍然能访问 IPv4 公网。
Incidentally, you can extend this naming scheme indefinitely. There have been some experiments with NAT46; you could deploy NAT66 if you enjoy chaos; and some RFCs use NAT444 for carrier-grade NAT.
如果需要处理 DNS 问题,那这种方式工作良好。例如,如果连接到 google.com,将这个域名解析成 IP 地址的过程会涉及到 DNS64 设备,它又会进一步 involve NAT64 设备,但后一步对用户来说是无感知的。
但对于 NAT 和防火墙穿透来说,我们会关心每个具体的 IP 地址和端口。
解决方案:CLAT (Customer-side transLATor)
如果设备支持 CLAT (Customer-side translator — from Customer XLAT),那我们就很幸运:
- CLAT 假装操作系统有直接 IPv4 连接,而背后使用的是 NAT64,以对应用程序无感知。在有 CLAT 的设备上,我们无需做任何特殊的事情。
- CLAT 在移动设备上非常常见,但在桌面电脑、笔记本和服务器上非常少见, 因此在后者上,必须自己做 CLAT 做的事情:检测 NAT64+DNS64 的存在,然后正确地使用它们。
解决方案:CLAT 不存在时,手动穿透 NAT64 设备
- 首先检测是否存在 NAT64+DNS64。
方法很简单:向 ipv4only.arpa. 发送一个 DNS 请求。这个域名会解析 到一个已知的、固定的 IPv4 地址,而且是纯 IPv4 地址。如果得到的 是一个 IPv6 地址,就可以判断有 DNS64 服务器做了转换,而它必然会用到 NAT64。这样 就能判断出 NAT64 的前缀是多少。
- 此后,要向 IPv4 地址发包时,发送格式为{NAT64 prefix + IPv4 address} 的 IPv6 包。类似地,收到来源格式为 {NAT64 prefix + IPv4 address} 的包时,就是 IPv4 流量。
- 接下来,通过 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 设备开发工程师引入了一些新理念,给路径判断造成很大困难。因此 我们需要简化客户端侧的思考(判断逻辑)。
这就要提到 Interactive Connectivity Establishment (ICE,交换式连接建立) 协议了。与 STUN/TURN 类似,ICE 来自电信领域,因此其 RFC 充满了 SIP、SDP、信令会话、拨号等等电话术语。但如果忽略这些领域术语,我们会看到它描述了一个极其优雅的判断最佳连接路径的算法。
真的?这个算法是:每种方法都试一遍,然后选择最佳的那个方法。就是这个算法,惊喜吗?
来更深入地看一下这个算法。
ICE (Interactive Connectivity Establishment) 算法
这里的讨论不会严格遵循 ICE spec,因此如果是在自己实现一个可互操作的 ICE 客户端,应该通读RFC 8445, 根据它的描述来实现。这里忽略所有电信术语,只关注核心的算法逻辑, 并提供几个在 ICE 规范允许范围的灵活建议。
- 为实现和某个 peer 的通信,首先需要确定我们自己用的(客户端侧)这个 socket 的地址, 这是一个列表,至少应该包括:
- 我们自己的 IPv6 ip:ports
- 我们自己的 IPv4 LAN ip:ports(局域网地址)
- 通过 STUN 服务器获取到的我们自己的 IPv4 WAN ip:ports(公网地址,可能会经过 NAT64 转换)
- 通过端口映射协议获取到的我们自己的 IPv4 WAN ip:port(NAT 设备的端口映射协议分配的公网地址)
- 运营商提供给我们的 endpoints(例如,静态配置的端口转发)
- 通过旁路信道与 peer 互换这个列表。两边都拿到对方的列表后,就开始互相探测对方提供的地址。列表中地址没有优先级,也就是说,如果对方给的了 15 个地址,那我们应该把这 15 个地址都探测一遍。
这些探测包有两个目的:
- 打开防火墙,穿透 NAT,也就是本文一直在介绍的内容;
- 健康检测。我们在不断交换(最好是已认证的)“ping/pong” 包,来检测某个特定的路径是不是端到端通的。
- 最后,一小会儿之后,从可用的备选地址中(根据某些条件)选择“最佳”的那个,任务完成!
这个算法的优美之处在于:只要选择最佳线路(地址)的算法是正确的,那就总能获得最佳路径。
- ICE 会预先对这些备选地址进行排序(通常:LAN > WAN > WAN+NAT),但用户也可以自己指定这个排序行为。
- 从 v0.100.0 开始,Tailscale 从原来的 hardcode 优先级切换成了根据 round-trip latency 的方式,它大部分情况下排序的结果和 LAN > WAN > WAN+NAT 是一致的。但相比于静态排序,我们是动态计算每条路径应该属于哪个类别。
ICE spec 将协议组织为两个阶段:
- 探测阶段
- 通信阶段
但不一定要严格遵循这两个步骤的顺序。在 Tailscale,
我们发现更优的路径之后就会自动切换过去, 所有的连接都是先选择 DERP 模式(中继模式)。这意味着连接立即就能建立(优先级最低但 100% 能成功的模式),用户不用任何等待, 然后并行进行路径发现。通常几秒钟之后,我们就能发现一条更优路径,然后将现有连接透明升级(upgrade)过去。但有一点需要关心:非对称路径。ICE 花了一些精力来保证通信双方选择的是相同的网络 路径,这样才能保证这条路径上有双向流量,能保持防火墙和 NAT 设备的连接一直处于 open 状态。自己实现的话,其实并不需要花同样大的精力来实现这个保证,但需要确保你所有使用的所有路径上,都有双向流量。这个目标就很简单了,只需要定期在所有已使用的路径上发 ping/pong 就行了。
健壮性与降级
要实现健壮性,还需要检测当前已选择的路径是否已经失败了(例如,NAT 设备维护清掉了所有状态), 如果失败了就要降级(downgrade)到其他路径。这里有两种方式:
- 持续探测所有路径,维护一个降级时会用的备用地址列表;
- 直接降级到保底的中继模式,然后再通过路径探测升级到更好的路径。
考虑到发生降级的概率是非常小的,因此这种方式可能是更经济的。
7.9 安全
最后需要提到安全。
本文的所有内容都假设:我们使用的上层协议已经有了自己的安全机制( 例如 QUIC 协议有 TLS 证书,WireGuard 协议有自己的公钥)。如果还没有安全机制,那显然是要立即补上的。一旦动态切换路径,基于 IP 的安全机制就是无用的了 (IP 协议最开始就没怎么考虑安全性),至少要有端到端的认证。
- 严格来说,如果上层协议有安全机制,那即使收到是欺骗性的 ping/pong 流量,问题都不大, 最坏的情况也就是攻击者诱导两端通过他们的系统来中继流量。而有了端到端安全机制,这并不是一个大问题(取决于你的威胁模型)。
- 但出于谨慎考虑,最好还是对路径发现的包也做认证和加密。具体如何做可以咨询你们的应用安全工程师。
8 结束语
我们终于完成了 NAT 穿透的目标!
如果实现了以上提到的所有技术,你将得到一个业内领先的 NAT 穿透软件,能在绝大多数场景下实现端到端直连。如果直连不了,还可以降级到保底的中继模式(对于长尾来说只能靠中继了)。
但这些工作相当复杂!其中一些问题研究起来很有意思,但很难做到完全正确,尤其是那些 非常边边角角的场景,真正出现的概率极小,但解决它们所需花费的经历又极大。不过,这种工作只需要做一次,一旦解决了,你就具备了某种超级能力:探索令人激动的、相对还比较崭新的端到端应用(peer-to-peer applications)世界。
8.1 跨公网 端到端直连
去中心化软件领域中的许多有趣想法,简化之后其实都变成了 跨过公网(互联网)实现端到端直连 这一问题,开始时可能觉得很简单,但真正做才 发现比想象中难多了。现在知道如何解决这个问题了,动手开做吧!
8.2 结束语
实现健壮的 NAT 穿透需要下列基础:
- 一种基于 UDP 的协议;
- 能在程序内直接访问 socket;
- 有一个与 peer 通信的旁路信道;
- 若干 STUN 服务器;
- 一个保底用的中继网络(可选,但强烈推荐)
然后需要:
- 遍历所有的 ip:port;
- 查询 STUN 服务器来获取自己的公网 ip:port 信息,以及判断自己这一侧的 NAT 的“难度”(difficulty);
- 使用 port mapping 协议来获取更多的公网 ip:ports;
- 检查 NAT64,通过它获取自己的公网 ip:port;
- 将自己的所有公网 ip:ports 信息通过旁路信道与 peer 交换,以及某些加密秘钥来保证通信安全;
- 通过保底的中继方式与对方开始通信(可选,这样连接能快速建立)
- 如果有必要/想这么做,探测对方的提供的所有 ip:port,以及执行生日攻击(birthday attacks)来穿透 harder NAT;
- 发现更优路径之后,透明升级到该路径;
- 如果当前路径断了,降级到其他可用的路径;
- 确保所有东西都是加密的,并且有端到端认证。
以下是译文作者推荐的自己的文章: