原理分析:UDP和TCP在NAT环境下的P2P打洞实现

本文最后更新于:2024年2月21日 下午

参考文章

P2P的特点

在当前互联网的结构模式下,大部分的数据通信和交互都是以C/S结构进行通信,即一个客户端和一个中心服务器,客户端通过将数据交给服务器,再有服务器将数据进行适当的处理后与客户端进行交互。除了C/S,还有一种常见的结构,即P2P通信。在P2P网络下,主要的通信双方为“节点”,节点和节点之间的通信是直达的,不需要中心服务器对信息进行处理。

由于这篇博客本身的目的并不是非要在C/S和P2P中抉择出一个好坏,重点主要放在P2P的技术实现上,就不做优劣对比了。P2P网络本质上是一种去中心化的网络结构,每个节点都直接与其他节点交互,共享节点和节点之间的资源与服务。这种结构相对来说可以更有效的利用资源,提高传输效率和可靠性(打洞成功的情况下)。

但是既然有这么多好处,那么必然也有相对应的挑战:P2P网络最核心的本质还是需要节点和节点能够直接通信。但是在当前国内的网络环境下,大部分的家庭用户并没有一个自己的公网IP。很多时候想实现节点和节点的直接通信,我们都需要想一个办法跨过防火墙,来让两个节点能够“握手”并“通信”,而这个过程便称为“打洞”。

NAT的通信方式

NAT通信流程图

  1. 内网请求:用户设备(内网IP地址为192.168.1.10)通过端口5000发起对外部服务器的请求。
  2. 地址转换:路由器接收到来自内网用户的请求后,使用NAT机制将源地址从内网IP转换为路由器的公网IP地址(203.0.113.45),同时,源端口从5000更改为1024。NAT通过这一端口映射的过程,来维护外网和内网设备的通信。
  3. 请求转发:经过地址转换后,路由器将修改后的请求通过互联网转发至目标服务器(IP地址为51.68.141.240)的80端口。s
  4. 服务器响应:服务器接收到请求后,对该请求进行处理,并通过相同的端口(80)向互联网发送响应数据。
  5. 响应数据路由:互联网将服务器的响应数据发回路由器的公网IP地址(203.0.113.45)的1024端口。
  6. 反向地址转换:路由器接收到响应数据后,再次使用NAT机制,将响应数据包的目标地址从路由器的“公网IP地址(203.0.113.45):端口(1024)”映射回用户的”内网IP地址(192.168.1.10)和端口5000“。
  7. 内网传递响应:最后,路由器将响应数据发送回内网用户,完成整个通信过程。

UDP的打洞过程

建立点对点会话连接

image-20240130172638076

  1. 注册与穿透服务器:两个客户端A和B分别与穿透服务器S建立UDP会话。在此过程中,服务器S记录每个客户端的两个Endpoint:客户端自认为用来与S通信的内网Endpoint对,以及服务器观察到的客户端用来进行通信的公网Endpoint
  2. Endpoint信息的记录与交换:服务器S将从客户端收到的注册消息中提取内网Endpoint信息,并从IP和UDP头部提取公网Endpoint信息。如果客户端不在NAT后面,这两个Endpoint应该是相同的。
  3. 发起UDP打洞请求:假设客户端A想要直接与客户端B建立UDP会话。A首先不知道如何到达B,因此A请求S帮助建立与B的UDP会话。
  4. 服务器响应:S回复A,包含B的公网和内网Endpoint信息。同时,S使用其与B的UDP会话向B发送包含A的公网和内网Endpoint的连接请求消息。收到这些消息后,A和B知道了对方的公网和内网Endpoint
  5. 双向打洞:A收到B的公网和内网Endpoint后,开始向这两个Endpoint发送UDP数据包,并锁定首先从B处获得有效响应的Endpoint。类似地,B在收到转发的连接请求后,开始向A的已知Endpoint发送UDP数据包,锁定第一个有效的Endpoint并以此通讯。由于A和B相互发送数据包的操作本身是异步的,因此A和B发送数据包的前后顺序并没有严格要求。

不同NAT下打洞的过程

客户端A和B都在同一个NAT后

image-20240131145913368

  1. 建立会话:客户端A与服务器S建立UDP会话,NAT分配公网端口62000。

  2. 端口分配:客户端B也与服务器S建立UDP会话,NAT为其分配公网端口62005。

  3. 连接请求:客户端A请求使用打洞技术与客户端B建立通信,并通过服务器S作为介绍人。

  4. 交换Endpoint信息:服务器S向客户端A发送客户端B的公网和内网Endpoint信息,并将客户端A的信息转发给客户端B。

  5. 尝试直接通信:客户端A和B尝试向彼此的公网和内网Endpoint发送UDP数据包。

  6. 选择通信路径:根据NAT的支持情况,客户端可能通过NAT(支持Hairpin转译)或直接(不支持Hairpin转译)进行通信。

    Hairpin转译

    Hairpin是NAT中的一种转译技术,其主要实现了让NAT后的两台设备都可以通过公网的IP和端口进行直接通信,具体的效果如下

    1. 客户端A发送数据:假设客户端A想要发送数据到客户端B。首先,客户端A会将数据包发送到NAT设备,目标是客户端B的公网Endpoint(例如155.99.25.11:62005)。
    2. NAT设备处理:NAT设备接收到来自客户端A的数据包,并查看目标地址。这里涉及Hairpin转译,因为数据包的源地址和目标地址都是由NAT设备分配的公网地址。
    3. Hairpin转译动作:如果NAT设备支持Hairpin NAT,它会识别出虽然目标地址是公网地址,但实际上目的地是内网中的另一个客户端。NAT设备将会将数据包的目标地址从B的公网Endpoint转换为B的内网IP地址(10.1.1.3),同时可能还会更改源地址从A的公网Endpoint到A的内网IP地址(10.0.0.1)。
    4. 数据包转发给客户端B:完成地址转换后,NAT设备将数据包转发到客户端B的内网地址上。此时,数据包好像是从客户端A直接发送给客户端B而不是经过互联网,即使它们实际上是通过NAT设备的公网地址进行通信的。
    5. 客户端B接收数据:客户端B收到了来自客户端A的数据包,尽管这些数据包最初是发送到NAT设备的公网地址的。

    在P2P通信中,由于内网Endpoint比公网的Endpoint要更早到达客户端B,也就是说Hairpin转译的通信流程还没走完,客户端A通过内网Endpoint和B建立的通信就完成了。因此在实际的通信中,由于内网路由通常比经过NAT的路由更快,客户端A和B更倾向于使用内网Endpoint进行后续的常规通信。

客户端A和B在不同NAT后

不同NAT后打洞的原理

  1. 会话初始化:客户端A和B分别从它们的本地端口4321发起到服务器S的1234端口的UDP通信会话。
  2. 端口映射:NAT A为客户端A分配公网端口62000,而NAT B为客户端B分配公网端口31000。
  3. 注册与记录:A和B向服务器S注册它们的内网和公网Endpoint。
  4. 请求协助:客户端A请求服务器S帮助与客户端B建立连接。
  5. Endpoint交换:服务器S向两个客户端交换彼此的公网和内网Endpoint信息。
  6. 尝试直连:A和B尝试直接向彼此的公网和内网Endpoint发送UDP数据包。
  7. NAT行为:如果NAT A和NAT B表现良好,它们将保留公网到内网的映射,为P2P通信“打洞”。
  8. 通信验证:一旦客户端验证了公网Endpoint的可用性,且因为在两个不同的NAT后,内网Endpoint不可达,它们将停止向内网Endpoint发送消息,只用公网Endpoint通信。

客户端A和B在多层NAT后

多层NAT下打洞的原理

  1. 客户端发起连接 - 客户端A和B分别从它们的内网地址发起到服务器S的UDP连接。
  2. NAT A和B映射 - NAT A和NAT B各自为客户端A和B创建了公网到内网的地址映射。
  3. NAT C建立映射 - 在ISP级别的NAT C为两个会话建立了公网到内网的地址映射。
  4. 尝试建立P2P连接 - 客户端A和B尝试通过UDP打洞技术建立直接的P2P连接。
  5. NAT C的Hairpin转译 - 如果NAT C支持Hairpin转译,它会处理从A到B和从B到A的数据包。
  6. 数据包路由 - NAT C将数据包正确地路由到另一端的客户端。
  7. 数据包到达目的地 - 经过NAT的转译,数据包成功到达对方客户端。

当NAT不支持Hairpin转发的时候就无能为力了,目前Hairpin的普及度也需要打一个问号。也存在一些特殊的NAT结构,让P2P的成功率更加没有保证。如果希望P2P打洞的成功率变高,则需要整个互联网都推动这一块的发展。

打洞成功后的空闲超时机制

即使在通过上述的几种不同的方法打洞成功,这种方法打出的隧道也并不是可以一直可靠的。大部分的NAT内部都有一个维护UDP转换信息的计时器:如果在一段时间内某个端口上不再有数据通信,那么这个隧道就会因为空闲超时被关闭掉。

维持隧道连接

如果希望P2P的隧道能不受NAT网关的时间限制,就需要通过发送持续的心跳包来维持这个隧道的活跃状态。

除了心跳包的方法,当然也可以在双方长时间没有数据往来的时候将当前的隧道关闭,并在下一次需要通信的时候建立连接。通过这样的方式避免不必要的流量浪费。

使用TCP实现P2P打洞

与UDP协议相比,使用TCP实现P2P打洞最大的问题并不在于TCP诸如三次握手等协议层的问题。相比之下,由于TCP拥有诸如SYN_SENTESTABLISHED这种状态描述来记录一个会话的具体生命周期,使用TCP进行P2P要比使用UDP更加健壮一些。缺点是由于TCP的打洞目前还未推广开来,因此支持TCP打洞的设备并不多。

Sockets与TCP端口的复用

在操作系统的通信API当中,如果要使用Socket建立TCP的连接,可以使用connect()方法来发送一个请求;或使用listen()accept()来监听一个请求。但是相比UDP,正常情况下TCP要求一个端口仅能绑定一个Socket通信。如果想要绑定第二个的话则会失败。

如果我们需要实现一个TCP的打洞,我们需要有一个端口,可以在监听请求的同时对外发送请求。为了实现这一点,我们需要使用操作系统中的一种特殊的Socket:通过在TCP Socket携带SO_REUSEADDR这个特殊的关键字,我们就可以实现复用一个TCP端口,绑定多个Sockets。但这么做也是有限制的:所有绑定在这个端口上的Socket请求,都必须要携带SO_REUSEADDR这个关键字。

在类似BSD的系统中,除了针对端口的绑定,还有SO_REUSEPORT这个关键字,用于区分具体是复用端口还是复用地址。这个时候就需要将两个参数同时设置才能生效。

建立TCP的P2P数据流

其实TCP打洞的过程和UDP的打洞过程本质并没有很大区别。都是通过握手服务器获取到了对方的 Endpoint 信息后,同时尝试对内网和公网的地址进行访问。

TCP打洞的实现

正如上面介绍Sockets之于TCP的端口复用中提到的:在TCP协议中,开发者需要维护多个Socket,分别来处理监听和信息的发送;而对于UDP来说,只需要维护一个Socket,就可以实现客户端和客户端之间的信息交互。

TCP连接的握手过程

在TCP打洞技术中,客户端应用程序根据操作系统的不同,可能观察到两种不同的行为。这两种行为反映了不同TCP实现对同步包(SYN)的处理方式的差异。在这里我们假设A向B发送的SYN数据包被B的防火墙丢弃了,但是B向A的Endpoint发送的SYN数据包可以正常抵达(即至少有一方可握手成功)。

基于BSD的操作系统行为:

基于BSD的操作系统

简单来说,A的网络程序接收到了B发来的SYN信号,这个信号的Endpoint对应了A之前试图发出去的信号回应。于是,A的网络部件就把这个新信号和它原来用来尝试联系B的那个通道(socket)联系起来了。这样一来,A尝试连接B的connect()就成功了,而且A用来等待别人的监听socket并没有被使用到。

Linux和Windows系统的行为:

Linux和Windows操作系统

在打洞过程中,A收到了B发出的SYN信号。这个信号与A尝试向B发起的连接请求相对应,因此A的TCP实现决定将这个新的连接尝试与原本用于尝试连接B的socket关联起来。

接着,A通过向B发送SYN-ACK响应,继续常规的TCP连接建立流程。然而,因为A先前向B尝试发起的connect()操作使用了和新的socket相同的源和目标Endpoint,所以connect()操作最终会失败,而用于接受B传来的SYN的新建立的socket下的accept()方法则会成功。

顺序打洞

在一些老旧的Windows系统上,当双方想要进行通信的时候,打洞这个操作不一定是并行的,他有可能是一个顺序下来的步骤。

  1. A告诉S,它想和B通信,但A这边无法正常接受数据包。
  2. 然后B尝试通过connect()发送一个信号给A,希望通过S的帮助到达A。但因为A还没准备好,所以B的尝试失败了。
  3. B告诉S,它完成了尝试,并开始准备通过listen()接收A的信号。
  4. S告诉A,现在轮到你尝试直接联系B了。

之所以有这种应用场景的需求,是因为在一些老旧的系统上并不能够并发的打开TCP连接,或socket接口没有实现SO_REUSEADDR的关键字。这种方法相对于并发连接来说速度会更慢一些,同时这种方法也需要与S服务器能够一直存在连接。而现在主流的操作系统在建立了P2P连接之后就不需要依赖于服务器S的连接了。

当前NAT网络现状

以下部分内容来源于 GPT-4 的总结

为了使上文提到的打洞技术能够顺利工作,NAT必须具备一些关键的行为特性。虽然不是所有现有的NAT都符合这些要求,但许多NAT已经做到了,并且随着NAT厂商逐渐认识到对点对点(P2P)协议的需求(例如IP语音和在线游戏),它们正变得更加友好于P2P网络。

这里并不旨在提供一个关于NAT应如何表现的完全或确定性的规范。我们只是提供一些信息,介绍哪些最常见的行为能够支持或阻碍P2P打洞。IETF已经启动了BEHAVE工作组,旨在为NAT行为定义官方的“最佳当前实践”。

关键特性包括:

  • 一致的端点转换:NAT需要一致地将私有网络上的TCP或UDP源端点映射到一个单一的公共端点。这种NAT被称为圆锥NAT,它可以确保来自同一私有端点的所有会话通过NAT上的同一个公共端点传输。
  • 处理未请求的TCP连接:当NAT收到一个未经请求的SYN包时,它应该静默丢弃该包,而不是主动拒绝它。这样可以避免干扰TCP打洞过程。
  • 保持负载不变:一些NAT会“盲目”扫描数据包负载中的IP地址并进行转换,这种行为虽不常见,但应用程序可以通过对IP地址进行混淆来保护自己。
  • Hairpin转换:在一些需要Hairpin转换支持的多级NAT场景中,当前NAT对此的支持很少,但随着IPv4地址空间的耗尽,支持Hairpin转换在未来NAT实现中变得重要。

简而言之,为了支持P2P通信和打洞技术,NAT需要具备一些特定的行为特性,包括一致的端点转换、正确处理未请求的连接尝试、保持数据包负载不变,以及在需要时支持Hairpin转换。随着对P2P通信需求的增加,NAT技术也在逐步适应,以更好地支持这些应用。

在英文原文中还有一些有关于如何衡量和测试NAT结构的方法,由于本文主要探究的是P2P打洞的技术实现,因此在这里就不做拓展了。后续如果遇到有关的技术问题则会在这里进行进一步的更新


原理分析:UDP和TCP在NAT环境下的P2P打洞实现
https://halc.top/p/bb3a9deb
作者
HalcyonAzure
发布于
2024年1月31日
许可协议