Skip to main content
  1. Posts/

冲云破雾:Tailscale 原理简述

·

组建自己的私有零信任网络。

最近对搭建自己的组网产生了兴趣,综合考虑安全性、可用性、速度和易用性选择了 Tailscale 作为解决方案。为了更深入地理解 Tailscale 的原理,对参考资料中的两篇介绍进行了简单翻译与概括。

数据面 #

Hub-and-spoke 架构 #

Tailscale 底层采用 WireGuard 在节点间构建轻量的加密隧道。包括 WireGuard 在内的传统 VPN 采用 Hub-and-spoke 的中心化架构,节点必须连接到一个 VPN 网关才能与网络中其他节点通信。这样的架构比较简单,但每个节点必须知道其他节点的信息,如公钥、公网 IP、端口号等等。节点数增多后,节点需要存储的信息也会骤然增多。

同时,VPN 网关也成为了系统中的一个单点,容易面临单点故障。而如果源节点和目标节点都离 VPN 网关很远,但两者本身距离很近,那么必须经过 VPN 网关的机制就引入了不必要的延迟。

WireGuard 通过将 VPN 网关拆分到多个不同位置的机器上来解决这一问题,但这也使得加入新节点时需要将密钥分别分发到这些机器上。

Peer-to-peer 通信 #

尽管如此,这一架构下并不能实现 peer-to-peer 的通信。我们可以在每两个节点间建立隧道,但这样会让隧道数量成倍增长。例如,对于一个 10 个节点的网络,就需要 10x(10-1)/2=45 条隧道,并且每个节点依然要管理其他所有节点的信息。

况且,节点不一定能拥有一个静态的 IP,也不一定能控制它所处的网络的防火墙以打开端口。对节点间流量的审计也变得不可能了。

控制面 #

Tailscale 则通过一个中心化的控制面来解决上述问题。我们稍后讨论 NAT 穿透的问题,因此先假设所有节点都有静态 IP 而且能自由打开防火墙端口。

密钥分发 #

首先是密钥如何分发的问题。Tailscale 在节点上安装的客户端会与中心化的 Tailscale 协调服务器通信,但和 Hub-and-spoke 架构不同,这一中心化架构仅仅是对控制面而言的,并不传输实际的数据。换而言之,控制面是中心化的而数据面是去中心化的。

节点生成自己的密钥对后,将公钥以及自己的信息提交给协调服务器,并从那里下载别人的公钥和信息。注意私钥是不会离开节点的,这保证了没有人可以获取节点的私钥从而伪装成这个节点。节点间通信时,发送方直接使用接收方的公钥加密消息,实现端到端加密。

节点认证 #

协调服务器也需要认证节点,随后才能发送公钥给它。这里的认证可以用预共享的密钥(也就是传统的用户名/密码机制)、预先颁发的“设备证书”、多因素认证以及基于 OAuth2/OIDC/SAML 的 SSO。

DERP #

然而,之前假设所有节点都有静态 IP 而且能自由打开防火墙端口毕竟过于理想化,现实中的节点常常位于 NAT + 防火墙后面。Tailscale 采用了基于 STUNICE 标准的 UDP 打洞技术来在这些节点间建立连接。但也有一些防火墙直接屏蔽了 UDP,此时 Tailscale 提供了一种基于 TCP 的 Fallback 机制,称为 DERP(Designated Encrypted Relay for Packets)。DERP 服务器分布在世界各地,用来作为公网中转使得两台机器能够通信,这就和传统的利用公网服务器的内网穿透原理类似了。由于流量用节点私钥才能解密,DERP 服务器只能中转流量,无法解密流量。实际上,DERP 服务器的实现相当简单

其他功能 #

Tailscale 还支持设置全局的 ACL 和流量审计功能,这都是因为其中心化的控制面的存在。子网路由功能则让已连接的节点成为 VPN 网关(此时是 Hub-and-spoke 架构),让其同网段的其余机器也能被其他节点访问,使得增量部署成为可能。

例如,在我的组网中将设备分为 trusted、server 和 untrusted 三类,利用 ACL Tags 区分。我设定了 trusted 设备可以访问任意设备的任意端口;server 设备可以访问其他 server 设备的任意端口、以及 trusted 设备的应用端口;untrusted 设备可以访问 trusted 设备和 server 设备的应用端口。并且,还可以添加测试用例来确保 ACL 规则设置符合预期。

{
  "tagOwners": {
    "tag:trusted": ["SignorMercurio@github"],
    "tag:server": ["SignorMercurio@github"],
    "tag:untrusted": ["SignorMercurio@github"]
  },
  "acls": [
    // Match absolutely everything.
    // Comment this section out if you want to define specific restrictions.
    // {"action": "accept", "src": ["*"], "dst": ["*:*"]},

    // Trusted devices can access everything.
    {
      "action": "accept",
      "src": ["tag:trusted"],
      "dst": ["*:*"]
    },
    // Server can access other servers.
    {
      "action": "accept",
      "src": ["tag:server"],
      "dst": ["tag:server:*"]
    },
    // Server can access user applications on trusted devices.
    {
      "action": "accept",
      "src": ["tag:server"],
      "dst": ["tag:trusted:80,443,1024-65535"]
    },
    // Untrusted devices can access user applications on trusted devices and servers.
    {
      "action": "accept",
      "src": ["tag:untrusted"],
      "dst": ["tag:trusted:80,443,1024-65535", "tag:server:80,443,1024-65535"]
    }
  ],
  "tests": [
    {
      "src": "tag:trusted",
      "accept": [
        "tag:trusted:22",
        "tag:trusted:80",
        "tag:trusted:443",
        "tag:trusted:8080",
        "tag:server:22",
        "tag:server:80",
        "tag:server:443",
        "tag:server:8080",
        "tag:untrusted:22",
        "tag:untrusted:80",
        "tag:untrusted:443",
        "tag:untrusted:8080"
      ]
    },
    {
      "src": "tag:server",
      "accept": [
        "tag:server:22",
        "tag:server:80",
        "tag:server:443",
        "tag:server:8080",
        "tag:trusted:80",
        "tag:trusted:443",
        "tag:trusted:8080"
      ],
      "deny": [
        "tag:trusted:22",
        "tag:untrusted:22",
        "tag:untrusted:80",
        "tag:untrusted:443",
        "tag:untrusted:8080"
      ]
    },
    {
      "src": "tag:untrusted",
      "accept": [
        "tag:trusted:80",
        "tag:trusted:443",
        "tag:trusted:8080",
        "tag:server:80",
        "tag:server:443",
        "tag:server:8080"
      ],
      "deny": [
        "tag:trusted:22",
        "tag:server:22",
        "tag:untrusted:22",
        "tag:untrusted:80",
        "tag:untrusted:443",
        "tag:untrusted:8080"
      ]
    }
  ]
}

2022.12 更新:由于 Taildrop 功能目前仅限于在同一 User 的机器之间使用,需要使用 Taildrop 的设备(我的组网中是所有 tag:trusted 设备)必须去掉 ACL Tag 并重新登录,使其归属于同一 User。最后,只需要将上面的 ACL 规则中的 tag:trusted 修改为用户全名即可。

NAT 穿透 #

接下来详细讨论 Tailscale 如何实现 NAT 穿透。要解决的问题是:让两台位于 NAT + 防火墙后的机器在无法控制防火墙的情况下能够互相通信。

背景 #

我们采用 UDP 协议进行 NAT 穿透,主要是由于 TCP 协议的复杂性会让 NAT 穿透问题进一步复杂化。如果考虑 TCP 是因为想要面向字节流的连接,那么同样基于 UDP 的 QUIC 协议就是一个很好的选择。

随后,一个必要条件是能够直接控制收发网络包的 socket。这是因为上层协议本身并不处理 NAT 穿透,处理 NAT 穿透的应该是一个独立的网络层协议。这可以通过一个本地代理实现,应用程序与代理通信,代理来处理 NAT 穿透和包传递的问题,这样应用程序里的协议就不用更改。

满足必要条件后,要解决最开始提出的问题就只需要关心两点障碍,即有状态防火墙和 NAT 设备。

防火墙 #

防火墙问题比较好解决,而且 NAT 设备通常都自带一个有状态防火墙,所以对付 NAT 前总得先解决这个问题。

放行规则 #

通常防火墙的默认配置是阻止所有传入连接、允许所有传出连接,在这一基础上可能会添加一些额外规则,比如允许传入 SSH 连接。

但所谓的“传入”和“传出”只是为了易于理解,我们知道实际上所有连接都是双向的,仅仅单向地传递数据而收不到响应没有意义。对防火墙而言,看到的无非是飞来飞去的数据包,那它怎么知道哪些是“传入”的、哪些是“传出”的呢?

这就是“有状态”的含义,防火墙会记住过去看到过的包,以此为依据判断如何处理新的包。对 UDP 来说,对于一个传入的包,如果之前出现过对应的传出的包,那么就可以放行。比如防火墙看到一个从 2.2.2.2:12347.7.7.7:5678 的包,那么会记住从 7.7.7.7:56782.2.2.2:1234 的包也是可以放行的。

有些宽松的防火墙一旦看到从 2.2.2.2:1234 传出的包,就会允许任意传入 2.2.2.2:1234 的包,这使得 NAT 穿透变得很容易。但这种情况很少见。

这个规则使得防火墙后的设备要想和别人建立连接,必须得自己先发起连接。因此传统 VPN 采用的是 Hub-and-spoke 架构,防火墙后的机器主动连到 VPN 网关上。而如果通信双方是两个防火墙后的设备,那么任意一方发起的连接就都会被另一方的防火墙阻止。

利用放行规则 #

绕过这一限制也很简单,前面提到:防火墙看到一个从 2.2.2.2:12347.7.7.7:5678 的包,就会放行从 7.7.7.7:56782.2.2.2:1234 的包,这里的原因是因为前一个是请求而后一个通常是响应,为了正常交互要允许响应包通过。但并没有规定说后一个包必须是响应!因此,即使 2.2.2.2:1234 发送的包根本没有到达 7.7.7.7:5678,后者也可以发送一个看起来像响应的包回去,并且能通过防火墙。此时,对于 7.7.7.7 的防火墙而言,因为有传出的 7.7.7.7:56782.2.2.2:1234 的包,所以 2.2.2.2:12347.7.7.7:5678 的包也可以放行了,双向连接成功建立。

时间同步 #

需要注意的是,这个方法要求两边必须在差不多的时间向对端发起连接,而要在“差不多的时间”上达成一致,就需要两边能够进行某种通信,这样就形成了一个循环。因此,我们必须使用 out-of-band 的方式建立信道来传输数据,而在 Tailscale 里则是由协调服务器和 DERP 服务器来完成。并且,两边需要每隔一段时间重新发送包,来防止防火墙关闭对应的端口放行规则。但我们不必担心中间有多少防火墙,因为只要发起连接的时间相差不多,任意多的防火墙都会放行对应的包。

对端地址 #

可以发现,我们必须知道通信对端使用的 IP:port,这就是中心化的 Tailscale 协调服务器的事了。不过,由于 NAT 的存在,获取对端的 IP:port 没那么容易。

NAT 设备 #

NAT 设备实际上就是一个会修改数据包的有状态防火墙,而这一步修改会带来很多麻烦。将 NAT 后的设备的地址(私有地址)改成公网地址称为 SNAT,反过来将公网地址改成私有地址称为 DNAT。NAT 穿透和 DNAT 无关,麻烦主要来自 SNAT。

两台位于 NAT 后的设备现在想和对方通信,但并不知道对方的 IP:port。实际上,如果不发起连接,这个 IP:port 根本不存在,因为发起连接后 NAT 设备才会设置一个从公网 IP:port 到内网 IP:port 的映射。因此,两边都需要先发起连接,但都不知道目标是谁,并且只要目标不发起连接就不可能知道。

STUN #

可以看到,在没有第三方介入的情况下,这一死锁不可能解决。STUN 服务器就可以扮演这个角色。STUN 协议原理很简单:当 NAT 后的设备访问公网服务器时,服务器看到的一定是被转换后的公网 IP:port 地址,那么让服务器告诉我们它看到的这个地址是什么,我们再把这个我们自己的地址(通过 out-of-band 信道)告诉对端,不就可以了吗?这样就和上文的防火墙问题一样了。

STUN 服务器所做的就是像这样告诉你“我看到你在哪”。由于每个 socket 会被 NAT 设备映射到不同的地址,我们必须用控制收发包的 socket 来执行 STUN 协议,这也就是为什么上文提过这是个必要条件。

有了 STUN,对于大部分家用路由器而言已经可以实现 NAT 穿透了,然而有些路由器会对不同的目标地址设置不同的源地址映射,使得通过 STUN 获取的地址失效。例如,如果我们在公网地址为 2.2.2.2 的路由器上分别与 5.5.5.5:12347.7.7.7:2345 通信,NAT 在转换源地址时会使用 2.2.2.2 的两个不同的端口号。

NAT 类型 #

根据 RFC4787,easy NAT,也就是 Endpoint-Independent Mapping(EIM)不会对不同目标地址映射不同源地址;而 hard NAT,也就是 Endpoint-Dependent Mapping(EDM)会。这一分类是我们在进行 NAT 穿透时真正关心的,而 NAT Cone 分类则是增加了一个防火墙的维度。但由于之前的同步发送包的方法已经可以穿过防火墙,这种分类对我们意义不大。

Endpoint-Independent NATEndpoint-Dependent NAT
Endpoint-Independent FirewallFull Cone
Endpoint-Dependent Firewall
(dest. IP only)
Restricted Cone
Endpoint-Dependent Firewall
(dest. IP+port)
Port-Restricted ConeSymmetric

后备方案 #

上文提过,因各种原因(如拦截所有 UDP 包的防火墙)无法成功利用 UDP 建立连接时,Tailscale 采用 基于 TCP 和 HTTP(s) 的 DERP 服务中转流量,这一后备机制保证了连接能成功建立,毕竟通常防火墙不会阻止传出的 HTTP(s) 连接。

使用 DERP 中转无疑会带来延迟和更低的带宽,因此只有当其余方法都失败时才会切换到 DERP。实际上,DERP 不仅是后备方案,也是协助 NAT 穿透的一个 out-of-band 信道。而当 UDP 打洞成功时,DERP 还能自动升级到 peer-to-peer 连接,十分方便。因此 Tailscale 网络中两台机器建立连接时,往往是先通过 DERP 连接确保连接能够建立,随后再升级到 peer-to-peer 连接提升速度和带宽。

实际上,面对 hard NAT,我们也并非束手无策只能使用 DERP。Tailscale 还引入了许多更高级的 NAT 穿透玩法来尽可能覆盖更多 hard NAT 场景,从而在这些场景下也能成功建立 peer-to-peer 连接。

穿透 hard NAT #

利用生日悖论 #

现在假设两台机器分别位于一个 easy NAT 和一个 hard NAT 后面,此时 easy NAT 后的机器不知道要发送包给谁。我们先假设 hard NAT 后面的机器通过 STUN 获取的 IP 是正确的,那么就剩端口号不知道了。

端口号有 65535 种可能,如果用 100 个包每秒的速度穷举需要 10 分钟,而且与端口扫描无异,容易被 IDS 检测后阻止。但如果我们在 hard NAT 后的机器上开放不止一个端口(使用不止一个 socket 发送包)呢?

我们可以在 hard NAT 后的机器上开放 256 个端口,然后在 easy NAT 后的机器上向其随机端口发包。根据生日悖论,我们可以计算出多次探测后的成功概率:

随机探测次数成功率
17450%
25664%
102498%
204899.9%

可以看到,如果以 100 个包每秒的速度探测,两秒内成功的概率就能超过 50%;即使在运气差的情况下,20 秒内几乎肯定能成功,此时只探测了约 3% 的端口。当然,这会让连接建立有所延迟,但是之前会先有 DERP 保底,所以还行。

然而,如果两边都是 hard NAT,由于此时源端口也需要随机了,搜索空间就变成了原来的平方。这种情况下要达到 99.9% 的成功率需要 170000 次探测,以 100 个包每秒的速度需要 28 分钟;而 50% 的成功率则需要 54000 次探测和 9 分钟。而且,也并不是所有路由器都能维持这么多的会话和承受这样的负载。

尽管如此,考虑到连接建立后只需要持续发包保证连接不断就能一直用,某些场景下 28 分钟也不是不能接受。总得来收生日悖论让我们能比较好地对付一个 hard NAT 的场景,和少数两个 hard NAT 的场景。

修改端口映射 #

hard NAT 之所以 hard 是因为 NAT 设备会修根据目标地址改端口映射。这一行为是否可以被部分绕过呢?实际上,不仅可以,还有三个协议都支持。

最老的协议是 UPnP IGD(Universal Plug’n’Play Internet Gateway Device),因为年代关系难以实现也难以确保安全,不过许多路由器都支持这一协议。后来出现了更易实现和更安全的 NAT-PMP(NAT Port Mapping Protocol)和其第二版 PCP(Port Control Protocol),主要用于端口转发。这三种协议本质上都是向 NAT 设备请求分配一个 WAN 端口(公网 IP 的端口)映射到某个内网设备的某个端口。

Tailscale 所做的就是测试本地网关是否支持这三种协议之一,如果支持则请求一个端口映射。此时在通过 STUN 获取 IP:port 后,我们实际上告诉了 NAT 设备不要对这个端口应用防火墙规则,这样在这个端口上进行 NAT 穿透就很容易了。

但这三种协议不一定所有路由器都支持,即使支持也可能被默认关闭了、或者是因为安全策略主动关闭了。因此,不能认为这些协议一定可用。

多层 NAT #

由于 IPv4 数量越发稀缺,许多 ISP 已经不再给家用宽带分配公网 IP。也就是说,家用宽带的地址依然是经过 ISP NAT 过后的地址,此时我们需要穿透多层 NAT。

由于 NAT 对内网机器而言是透明的,之前的 NAT 穿透方法依然不会受到什么影响,但修改端口映射行不通了,因为它们都运行在距离内网机器最近的一层 NAT 上,而实际需要修改端口映射的是距离最远的(最外层的)NAT。因此,多层 NAT 饱受诟病,尽管多数应用程序不会进行显式 NAT 穿透,不会受到影响。

然而,端口映射协议失效意味着许多多人在线游戏的体验大幅下降,且很可能不会有 IPv6 支持,因此有选择的情况下并不建议使用多层 NAT。

CGNAT #

上面提到的 ISP NAT 被称为 CGNAT(Carrier-grade NAT)。在 CGNAT 之前,我们还可以手动设置家庭路由器来进行方便的 NAT 穿透,但我们无法设置 ISP 级的 NAT 设备。好在 CGNAT 是多层 NAT 的一种,因此之前的 NAT 穿透技术能用。

但是,如果我们想连接的两台设备在两个不同的 NAT 下,但在同一个 CGNAT 下呢?这里的问题主要在于,此时我们需要的 IP:port 是在 CGNAT 下的地址,但 STUN 服务器由于在公网上,返回的是 CGNAT 外的地址也就是公网地址。

这时,因多层 NAT 而失效的端口映射协议就派上了用场。只要两台设备对应的两个 NAT 设备中有一个支持端口映射协议,那么对应的设备就相当于被 un-NAT 了,此时就变成了类似一层 NAT 设备和公网机器连接的情况,非常简单。不过,我们依然不能认为端口映射协议一定可用,因为端口映射协议支持通常被 ISP 默认关闭。

那么,如果两个 NAT 设备都不支持端口映射协议怎么办呢?假设现在有两台设备 A 和 B,分别运行 STUN 协议,得到的结果是 2.2.2.2:12342.2.2.2:5678。那么为了 A 发送的数据包能到达 B,预期的 CGNAT 的行为类似:

  1. CGNAT 查找 A 的 NAT 映射,修改源地址为 2.2.2.2:1234,目标地址依然为 2.2.2.2:5678
  2. 2.2.2.2:5678 和 B 的 NAT 映射相符,因此将目标地址改为 B 的内网地址
  3. 将数据包通过内网网卡发送给 B,而不是向公网转发

这一行为被称为 Hairpinning。可以预想到,并不是所有 NAT 设备支持 Hairpinning。多数情况下,NAT 设备是否支持 Hairpinning 并不重要,因为内网两个设备通信一般不会经过网关。但对于 CGNAT 而言,这关乎到你是否能成功建立 peer-to-peer 连接而不用关心自己是不是在一个 CGNAT 后面。如果 Hairpinning 和端口映射协议都不可用,那就只能用 DERP 了。

IPv6 #

如果所有设备都能被一个固定的唯一 IP 访问到,就用不着上面一大堆 NAT 相关的技巧了,包括 STUN、生日悖论、端口映射协议和 Hairpining。然而 IPv6 尚未普及,因此只能算是一种备选方案,还不能算是一种解决方案。并且,IPv4 和 IPv6 混合的网络中,又多出了一种我们不得不考虑的设备:NAT64。

上文中提及的 NAT 都是指 NAT44,即将 IPv4 地址转为 IPv4 地址的 NAT。同理,NAT64 将内网 IPv6 地址转为公网 IPv4 地址,结合将 IPv4 DNS 应答转换为 IPv6 地址的 DNS64,我们可以构建一个 IPv6-only 的内网,同时还能访问 IPv4 网络。

如果只关注 DNS 域名,那当然可以正常使用,但我们还关心 IP 和端口号,因为我们要穿透 NAT。幸运的话,我们的设备支持 CLAT(Customer-side translator),这样 OS 假装能直连 IPv4,实际上用的是 NAT64,而我们不用担心任何事情。

但 CLAT 在移动设备上比较普遍,笔记本、主机和服务器上就比较少见了,此时我们得手动去完成 CLAT 的工作,也就是检测 NAT64+DNS64 设置并使用。检测只需要发送 DNS 请求到 ipv4only.arpa.,这一域名解析到一个固定的 IPv4 地址,而如果返回的是 IPv6 地址,那说明 DNS64 翻译过了,此时就能获得 NAT64 前缀。

这样,如果要发送包到 IPv4 地址,只要发送 IPv6 包到 {NAT64 prefix + IPv4 address},同理从这个地址收到的包也来自 IPv4 地址。现在就可以和 STUN 服务器通信,获得 NAT64 后的 IP:port,于是我们又回到了经典的 NAT 穿越场景。

好在如今多数 IPv6-only 网络是移动网络,而且几乎所有移动设备都支持 CLAT,所以我们很少需要手动去完成 CLAT 的工作。但边界情况也是情况,为了确保连接可用,必须支持在 IPv6-only 网络上与 IPv4-only 网络上的设备通信。

大整合 #

现在我们实现了所有这些防火墙穿透和 NAT 穿透技巧,那我们怎么判断什么情况下用什么技巧呢?答案来自一个同样和电话相关(STUN 也是)的协议——ICE(Interactive Connectivity Establishment)。简单来说,就是把能试的都是一遍,然后选最好的那个。

更详细地说,首先我们需要获取一个本地 socket 可能的地址列表,包含了所有其他设备可能能访问到的本机的 IP:port 地址,至少应包括:

  • IPv6 的 IP:port
  • IPv4 内网 IP:port
  • IPv4 公网 IP:port(获取自 STUN)
  • IPv4 公网 IP:port(通过端口映射协议获取)
  • 手动设置的 endpoint 地址(如静态端口转发)

随后,我们与对端通过 out-of-band 信道交换这一列表,并开始逐一探测对方列表中的项。这些探测包既是用来穿透防火墙和 NAT 的,又能作为健康检查的消息。最后,我们选择一个“最佳”(根据特定的指标)的路径作为我们的穿透方案。

ICE 的指标是预先设定的 LAN > WAN > WAN+NAT,而 Tailscale 则基于 round-trip 延时,最终的顺序通常也是 LAN > WAN > WAN+NAT,但无需预先设定这一顺序。ICE 需要先进入探测阶段随后再进入通信阶段,但 Tailscale 中没有这一顺序限制。上文已经提过,Tailscale 优先建立 DERP 连接使得连接立即可用,同时并行地进行路径发现;一旦发现更优路径,连接就可以自动、透明地升级。

需要注意的是,我们需要确保连接往返的路径是一致的,否则路径上的防火墙可能会因为会话超时而关闭对应端口的访问。方法很简单,持续发送 ping/pong 消息即可。如果追求更强的健壮性,我们还需要检测目前的路径是否可用,如果不可用则切换到另一条路径。考虑到路径不可用的情况较为罕见,一个简单的处理办法是直接降级为 relay,然后重新开始路径发现。

最后,通信的安全性则由上层协议保证,如 QUIC 使用 TLS 证书、WireGuard 采用公钥密码等。而当动态切换路径时,显然基于 IP 地址的安全防护策略没有意义。总之,上层协议至少要保证端到端加密和认证。

如果上层协议安全,那么 ping/pong 消息即使能被伪造也没关系,因为最坏情况下攻击者也只能将你的流量导向他所控制的服务器,但端到端加密决定了攻击者无法获取任何消息内容。当然,我们也可以对这类路径发现包进行加密和认证以进一步提高安全性。

总结 #

可以看到,NAT 穿透问题其实相当复杂——解决大部分情况下的 NAT 穿透比较简单,但对于各类少见情况的处理引入了极大的复杂度。但研究这一问题不仅有趣,而且是值得的:建立 peer-to-peer 连接后,我们能实现许多传统架构下不能做的事。回顾前文,我们可以总结出实现健壮的 NAT 穿透所需要的准备:

  • 一个基于 UDP 的协议
  • 对收发数据包的 socket 的直接控制权
  • 一个与对端设备在未建立连接时通信的 out-of-band 信道
  • 若干个 STUN 服务器
  • 一系列后备中转服务器

随后,我们需要:

  • 枚举当前 socket 的所有的 IP:port 地址
  • 查询 STUN 服务器获取公网 IP:port 地址以及所处的 NAT 类型
  • 尝试端口映射协议以获取更多公网 IP:port
  • 检测 NAT64 的存在,并通过它获取另一个公网 IP:port
  • 将所有获取的地址与密钥和对端通过 out-of-band 信道交换
  • 与对端通过后备中转服务器建立连接并进行通信
  • 尝试与对端的 IP:port 建立连接并检测连接质量,必要的话使用穿透 hard NAT 的技巧
  • 发现更优连接线路后,透明地升级到该线路上继续通信
  • 如果当前线路停止工作,降级以保持连接不中断
  • 确保端到端加密和认证

参考资料 #

  1. How Tailscale works

  2. How NAT traversal works