NAT 穿透在不同环境下的差异

2024-08-16
6分钟阅读时长

前言

有时候会觉得 BT 以及可以实现 P2P 的 VPN 很”魔法“,因为一直对端到端直连的技术细节好像知道,又好像不知道。借着捣鼓了一阵 wireguard 组网,写下本文加深下对 P2P 理解。

配置这些工具没什么难度,但背后的技术路径却很有意思。 tailscale 那片著名的 NAT 文章讲的很全面,本文也是主要结合这篇文章总结下 NAT 穿透的各种难度。

完整又原始的 wireguard

单纯用 wireguard 实现中转组网的话很简单,只需要一个中转服务器。且有很多的开源工具辅助,例如 wireguard-ui、easy-wireguard…利用这些工具可以管理各个 peer,生成 conf 文件。

wireguard 已经足够“完整“了。跨平台的客户端、声明式的配置、自动化的路由 AllowIPs 等等。如上段所说,wireguard 算的上最轻便的组网方案了,一般情况下很好用。

但想用 vanilla wireugard 实现 peer-to-peer 直连的话,就要手动配置每个 peer 的 endpoint。如果 peer 的数量是 n ,那么这将是一个 O(n^2 - n) 的工作,还不包括路由转发的配置工作,并且每个 peer 的公网地址也很难是固定的。

这些任务很繁重,没有在 github 上找到一个 vanilla wireguard 的自动化处理这些任务的工具,不过基于 wireguard 的上层工具倒是不少。

最终采用了 headscale,是 tailscale 服务器的开源版,基于 wireguard 实现。也算是一个 wireguard 自动化配置工具。简单配置下即可实现端到端直连需求,tailscale 客户端会自动添加 iptables 规则等路由操作。

回到正题,接下来从简单到困难,盘点下 NAT 穿透的各种难度。

最简陋的环境

首先抽象一个“很简陋”的 NAT 环境下的端到端直连,简陋是指对端防火墙允许一切传入。此时只要一端想办法得到对端的公网地址后,就可以直连。如何得到对端的公网地址也很简单,架设一个类似 DDNS 服务的“协调器”,两个端点访问协调器时,协调器自然可以看到端点的公网地址。

这个协调器服务称之为 STUN (Session Traversal Utilities for NAT),STUN 记录了每个端点的公网 IP,可以告诉各个端点对端的地址,让其自己去建立连接。

只要各个端点向 STUN 上报自己的地址就可以,虽然很简单,但这样确实就足够了。

最常见的环境

STUN 的原理看上去十分简单,其实就是很简单。不过当 NAT 环境变得不“简陋”,即对端不允许一切传入,只允许传出的响应数据包。这也是大多 NAT 防火墙的默认配置。此时 STUN 会受到一些限制。

想象一下这种情况下 STUN 协助端到端建连的过程:首先一端建立一个 socket 访问 STUN 获取对端的地址,接着再建立一个 socket 访问对端,这时当然会被对端的 NAT 防火墙拦截,因为对端拒绝一切传入。

穿透只能到此为止了吗?前面说到,有状态防火墙允许一种“特殊的传入”,即自身传出的响应,否则我们就没办法愉快问对方是 GG 还是 MM 了。

回到 STUN 的限制上,就是我们要让对端防火墙认为我们的是“响应”。记得吗,对端也向 STUN 传出了数据包,所以 STUN 知道对端 NAT 此时开放(映射)的地址(端口),这个端口可以接受响应传入。

另一端则可以向对端的这个端口发送数据包,随后基于这个端口开始通信~~,可以开始为所欲为了~~。显而易见我们用来直连(穿透)的 socket 重用了访问 STUN 的 socket。

这就是为什么与 STUN 通信和 NAT 穿透要使用同一个 socket。

有点难度的环境

STUN 的限制似乎也不是很麻烦,最终来看只是限制只使用一个端口而已。

不过都知道 NAT 按照所谓的“锥形“分为四个等级,这个“锥形”划分比较抽象,我们不用。更容易理解的分类是 easy nat 和 hard nat,根据“是否依赖目的地址”划分的。

easy nat 是不依赖目的地址的一种 nat,顾名思义是较为容易穿透 (?) 的 NAT。Esay NAT 的特点是:内网机器同一个 socket 发出去数据包,经过 nat 映射后,nat 为此 socket 创建的端口是固定的。不管这个 socket 是发往 1.1.1.1,还是发往 2.2.2.2 的,即目的地址不相关。这也是我们上一步“最常见的环境”中可以成功直连的原因。

Hard NAT 就是依赖目的地址那种了,特点是:内网机器就算用同一个 socket 发出的数据包,经过 NAT 后,如果发往目的地址不一致。NAT 会为该 socket 的每一个目的地址映射一个不同的端口,等待目的地址的响应。

回到 STUN 上,当 Hard NAT 的一端向 STUN 服务器上报地址时,STUN 拿到的是独属于自己(STUN)的地址,只有 STUN 的响应被允许传入,对端的“响应”会被 Hard NAT 拦截。

想象一个理想的流程,肯定得由 Hard NAT 的一端先向 Easy NAT 的对端发送请求,Esay NAT 的一端拿到了独属于自己的地址(端口),开始基于这个端口开始通信。好像很自然,但问题是此时 Easy NAT 会拦截掉这个数据包,Hard NAT 给我们开放的这个专属端口遗落进网络长河中。

简单的魔改下 NAT 设备,让其记录下这个对端地址,如果可以办得到的话。除此以外,我们如何找到这个被遗落的端口?

前文说到 Hard NAT 设备对于 socket 的目的地址是关心的。即同一个 socket 发往 1.1.1.1 与发往 2.2.2.2 的数据包在经过 Hard NAT 设备映射后,是两个端口。所以当两端向对端发送数据包时,各个端点 NAT 设备映射的端口只有自己那端的 NAT 设备自己知道。

众所周知端口的数量只有 65535 个,让 Easy NAT 背后的端点暴力猜测那个“专属”端口也不是不可以~~(傲娇早就退环境了啊!)~~。不过从 1 开始遍历有点傻,让 Hard NAT 背后的端点多开点 socket 向 Easy NAT 发几次包,再利用点算法(生日悖论)提高猜中的概率。

当 Easy NAT 一端猜中端口,就可以基于这个端口通信了。

搞不定的环境

当一端是 Easy NAT,另一端是 Hard NAT 的话,限制又多了一个穿透必须由 Hard NAT 端发起。但只要从 STUN 服务器拿到对端 IP,再花费几秒钟猜测一次端口,这也是可以接受的。

可当两端都是 Hard NAT 呢?记得吗,Hard NAT 的映射规则是:[socket,dest ip:port],一端打开的每个端口(socket)猜测对端端口时,会映射一个新的端口,对端也是同理,一端的每个端口都要猜 65535 次,单纯暴力的话大概需要两端各进行 65535^2 次,如此巨大的复杂度就算上生日悖论算法也是难以接受。

所以在两端都是 Hard Nat 情况下的继续采用 STUN 协助打洞目前来看有点不太现实,只能走中转方案。

99.99% 可以成功的环境

最常见的一种 NAT 实现就是 Linux 内核 netfilter 框架了,用 iptables 等工具可以简单的配置转换规则,连接的应答包也会自动应用“反向规则”。例如我们只需要配置 SNAT,当应答数据包经过 netfilter hook 时,会自动应用 DNAT (反向 SNAT)。

这都依托于有状态防火墙的链路追踪,基于这个特性,我们可以得出结论:不管两端的链路经历了多少 NAT 设备节点,最关键的 NAT 节点,始终只有距离发出端和接收端最近的那两个 NAT 节点。就像引用透明的函数式,不管函数多么复杂,其没有副作用。(正如本文的引言,数据包出走半生,归来时一定得是 NAT 设备期望的返回,令人忍俊不禁)

综上所述,只要确认有一端是 Easy NAT 就 99.99% 可以让 N2N 转为 P2P。当出现一端是 Easy NAT 却没有打洞成功的话。肯定是经过了奇奇怪怪的“NAT”,例如各种 proxy vpn。与 STUN 通信经过了 proxy,发出端变成了 proxy 节点,距离发出端最近的 NAT 也就变成了 proxy 节点的 NAT。对于这个 NAT ,我们能做的只有什么都不做。

百分百可以成功的环境

端口转发,或者公网 ip,选一个吧。

参考