Intro

这篇文章实际上是读 Tailscale blog 的文章 How NAT traversal works 的一些理解的笔记,同时加上了一些市面上常见的穿透方式的工作原理的整理。

NAT

NAT (Network Address Translation) 的出现主要是为了应对 IPv4 地址不够用的问题。

简单来说,大部分的机器都会使用一个 Private IP (比如 192.168.1.2),当一台这样的设备 A 需要访问公网的设备 B (比如 8.8.8.8 )时,NAT 会将 IP header 中的 Private IP + Port 变成 Public IP + Port (比如 212.5.102.68 ),然后再转发到 Internet。而对于这个包的 Response 回来时,NAT 会根据其维护的映射关系将 IP Header 做一个反向的转换,这样就可以做到多台设备 share 一个 Pubclic IP。

A (192.168.1.2) <--> (192.168.1.1) Router/NAT (212.5.102.68) <--> Internet <--> (8.8.8.8) B

这个过程对于双方是完全透明的,比如在这里 A 并不会知道他自己在 Internet 上的 Public IP, B 也不会知道 A 的 Private IP。

STUN 和基本的穿透

我们可以看到,这时候 A 是可以正常访问外部的,但是显然外部设备无法直接访问 A ,因为并不知道 A 经过 NAT 之后的 Public IP。这时候就需要 STUN (Session Traversal Utilities for NAT) 协议,STUN 的大概工作流程就是:

  • A 会向一台 STUN Server 去发送请求,询问对方看到自己的 IP 是什么
  • STUN Server 就会回复类似「在我看来,你的包是从这个 IP:Port 来的」

这样之后 A 还需要以某种方式将这个信息告诉对端,这样对方就会尝试去连接这个 IP:Port,但是实际上情况没有这么简单。

NAT 的类型

NAT 实际上是有好几种类型的,一些会在 Firewall 上加上额外的限制:

  • Full Cone: 这是最开放的,只要建立了映射,外部的任何设备都可以直接通过映射的 Public IP 访问到内部
  • Restricted Cone: 相比于 Full Cone,这里额外要求内部设备已经向该外部设备发送过包,也就是说对 Firewall 来说是一个 established connection
  • Port-Restricted Cone:在 Restricted 的基础上,还要求内部设备已经向该设备该 Port 发过包,这样才被认为是 established
  • Symmetric:这是最严格的,NAT 会为内部设备往不同外部设备的连接建立不同的映射,对应不同的 Public IP: Port

Tailscale 的文章里面,给出了这样一张表,十分清晰:

映射与 Endpoint 无关 映射与 Endpoint IP:Port 相关
Firewall 与 Endpoint 无关 Full Cone N/A
Firewall 与 Endpoint IP 相关 Restricted Cone N/A
Firewall 与 Endpoint IP:Port 相关 Port-Restricted Cone Symmetric

实际上,只要映射与 Endpoint 无关,即使 Firewall 很严格要求 IP:Port 都一致,我们也可以让内部的设备发包来穿透。真正麻烦的是 Symmetric 的情况,因为对每个 Endpoint 都建立了不同的映射,因此 STUN Server 和目标外部设备所看到的 Public IP: Port 是不一样的。这种时候一般就需要仰仗 Relay 的方式来进行穿透。

Relay 协议:TURN

经典的 Relay 协议是 TURN (Traversal Using Relays around NAT),TURN 的工作方式很直接:

  • 设备都去 TURN Server 上认证,成功之后 TURN 就会为其分配一个 IP:Port
  • 然后对端设备去连接这个 IP:Port,TURN Server 就会 relay 这些流量

但是 TURN 存在很多问题,导致当前主流的穿透工具在 relay 的时候普遍都不用 TURN,比如 Tailscale 用了自己的协议 DERP

端口扫描和生日悖论:尝试穿透 Symmetric NAT

实际上穿透 Symmetric NAT 也不是一定要寄希望于 Relay,只是方式会比较 Hack。

根据之前的分析,我们是不知道在这种情况下对端要向什么 IP:Port 去发起连接,因为对端会得到的映射和 STUN Server 的不同。那么这时候,如果我们向多个 STUN Server 去请求,就能得到多个 IP:Port 结果。因为 IP 的个数肯定是比较少的(一般就只有一个),而 Port 的上限是 65535,所以我们所有的 IP:Port 的可能性的个数也是比较有限的。这时候,如果以 100 packets/s 的速度去发包遍历一个 IP 上所有的 Port,那么最差的情况下就可以用 10 分钟来完成穿透。不过端口扫描算是典型的攻击行为,有风险会引起 IDS (Intrusion Detection System) 的响应。

在此基础上,我们还可以用生日悖论的原理来优化,即在内部设备上打开比如 256 个Port,所有 Port 的 socket 都往对端 IP 的 随机 Port 发包,这时候就会形成 256 个 NAT 的映射项。这时候对端同样去随机开 Port 给内部设备发包,那么根据生日悖论的原理,很快就能完成 Port 的碰撞。

发包个数(对端打开的 Port 数目) 碰撞概率
174 50%
256 64%
1024 98%
2048 99.9%

如果发包的速度同样是 100 packets/s,那么发 2048 个包只需要 20s,这时候成功率已经达到了 99.9%。

但是这种方式只能应用于只有一边是 Symmetric 的情况。

我们可以看到,如果对端同样是 Symmetric,那么碰撞就会要求就会变成对端的请求不仅用了同样的 Port,而且这时候对端的 NAT 要求的 Port 也是符合的。也就是说本来我们要碰撞的是一维的空间,即对端的 Port (可称之为 dst port),而在两边 Symmetric 的情况下我们要碰撞的就是二维的空间,即 src port 和 dst port 组合起来的 pair,可想而知这样的难度会大大增加。要达到 50% 的成功率,需要 54,000 个包,而 99.99% 则需要 170,000 个包,以之前的速度发包的话分别需要 9 分钟和 28 分钟。

另外,一般的路由器支持的 active session 数量是很有限的,双端 Symmetric 情况下这样进行穿透也容易 overload 它的 session table。

UPnP 等 Port mapping protocols

实际上还有一些方式可以帮助我们更简单的进行穿透,我们把这一类协议称为 Port mapping protocols,其中最经典常见的就是 UPnP-IGD (Universal Plug’n’Play - Internet Gateway Device Protocol)。

UPnP-IGD 大概是这么工作的:

  • 用 UPnP 协议来发现网络中的网关设备,一般可能是发 SSDP,然后网关会响应 SSDP
  • 向网关发送请求,直接请求对方将自己的 LAN IP: Port 转发到 WAN
  • 网关会回复,返回分配的 WAN IP: Port

大概可以将 UPnP-IGD 理解成一个本地版本的 STUN,内部设备同样可以通过这个方式来知道自己被映射后的外部 IP 和 Port,而且更好的是会让网关接下来对这个映射的处理更加宽容,毕竟是内部设备自己请求打开的。

但是因为 UPnP 经常被人担心安全性有问题,所以在很多设备上都是关闭的。

与 UPnP 类似的还有 NAT-PMP 和 PCP,情况也是类似。

多层 NAT

在实际的网络中,一台设备到 Internet 的过程中很可能是会经过不止一次 NAT 的。

最典型的例子就是家庭网络,一般情况下 ISP 提供的光猫本身就是兼有网关的功能的,在这里会有一层 NAT,然后同时很多人会在光猫下连自己的家庭主路由,这里就会再做一次 NAT。这也是为什么大家经常会说希望想把 ISP 的光猫改成桥接模式。

多层 NAT 一般情况下对于我们用 STUN 等方式穿透是没有影响的,NAT 的工作本身就是透明的,我们在用 STUN 的过程中本身也只关心其最外侧的一次映射产生的真正的 Public IP 和 Port,只要这里能连上,往内的几次 NAT 自然都会依样转发。不过值得注意的是 UPnP 等这些 Port mapping protocols 就不能用了,因为只能获取到最内侧的一次 NAT 的映射情况。

CGNAT 和 Hairpinning

ISP 为了节省 IP 通常还会在他自己的网络中做 NAT(所以家庭宽带一般都没有 Public IP),这是一个特别宏大的多层 NAT,我们一般称其为 运营商级别的 NAT,即 Carrier-grade NAT,简称 CGNAT。

CGNAT 会带来一个新的场景,就是希望连通的两端可能是在一个 CGNAT 的同一侧的,也就是都在其内部。 这种时候他们的直连应该是完全不经过 Internet 的,但是如果通过 STUN 去探测的话就会返回 CGNAT 外侧的 IP: Port,这就会使得 STUN 无法工作。

但是我们可以想到在这种情况下 Port mapping protocols 反而又能工作了,因为反正是在 CGNAT 内部,我们只需要有内部的可用的 NAT 映射情况信息就够了。

而如果没有一边的映射是可用的,还可以寄希望于路由器是否启用了 NAT Hairpinning:

  • 通过 STUN 获取到对端的 Public IP 信息,然后去访问这个地址
  • 在正常的 NAT 配置中,这样的包会先被转发到公网,然后就被 drop (因为被路由回同一个 Public 是不允许的)
  • 但是如果配置了 NAT Hairpinning,那么路由器会识别出实际上这个目标是内部网络的一部分,就会将目标修改成内部地址,然后转发

ICE

ICE (Interactive Connectivity Establishment) 实际上可以说是上面所说的一连串方法的一种选择算法。大概的工作流程是:

  1. 双方先进行 Candidates 的收集,这里所说的 candidates 包括本机的内部 IP: Port (Host)、通过 STUN 获取的 Public IP: Port (Server reflexive)、Relay 的 Server 等
  2. 双方通过信令通道用某种协议(比如 WebRTC 使用的是 SDP)来交换各自的 candidates 信息
  3. 双方用对方的各种 candidates 信息来尝试进行连接,检查有哪些方式是可以连通的
  4. 在可以连通的组合里面,根据优先级进行排序,一般顺序是 Host candidates, Server reflexive candidates, Relay candidates
  5. 进行连接

流行的一些穿透工具

Tailscale

Tailscale 的做法比较类似于 ICE 框架,但是有一些他们自己的优化:

  • ICE 中对于各个连通方式的排序是 hard code 的,但是 Tailscale 会根据 rtt 来动态调整(这个结果大部分时候会和 ICE 的排序一致)
  • 并不像 ICE 一样一定是先完成全部探测再进行连接的,而是所有先全部走 Relay(最快能连通且 100% 成功),之后发现有更优的路径再切换过去
  • 在 Relay 的时候使用的是他们自己开发的 DERP 协议,Tailscale 作为一家卖服务的公司当然也提供了 DERP Server

FRP

FRP (Fast Reverse Proxy) 正如其名,是反向代理,所以相当于是用 Relay 模式进行穿透。用具有公网 IP 的 FRP Server 来作为 relay 将没有公网 IP 的 FRP Client 打开的 service 暴露到公网上。

不过后来 FRP 也提供了 XTCP 模式,也会尝试进行非 Relay 的 NAT 穿透来直连,而且可以配置 fallback,在 NAT Type 比较不友好的时候可以 fallback 回 Relay 模式 (STCP)。

Ngrok

Ngrok 也是反向代理,原理类似于 FRP。不过不像 FRP 只是一个 Opensource 的软件,Ngrok 本身是一家公司,卖的实际上是整套服务,所以会提供给用户 relay server。

Surge Ponte

Surge 从 5.0 开始推出了 Ponte,提供穿透的功能。开启 Ponte 之后 Surge 会先探测 NAT 类型,仅在处于 Full Cone NAT 的时候才会进行直接的 NAT 穿透。其他时候都会通过 Relay 来进行 UDP 转发。

因为 Surge 本身通常被用作一个代理工具,所以用户本身在上面配置了很多可用的代理 Server,Ponte 会直接使用这些 Server 来进行 Relay,这是非常有意思的点。而且因为是代理服务器,所以各种网络性能应该都会比较好,Relay 的效果也就会比较好。我们知道其实 Restricted Cone 和 Port-Restricted Cone 都没有那么难穿透,Surge 在这里都直接选择 Relay 可能也和这个优势有关(可能也和其独立开发模式有关)。