本文章永久分享链接: https://tflow.top/p2p
本文参考博客: https://rebootcat.com/2021/03/28/p2p_nat_traversal/
阅读本文的前置条件
本文假设你已满足前置条件,了解NAT类型,STUN与UPnP这两种P2P技术。
本文主要根据不同场景介绍各种nat类型设备间的UDP打洞技术,针对一些技术细节展开拓展性讨论,最后对P2P可行性等级进行总结。
如果不想了解任何技术细节,可以直接点击我 查看文末的P2P可行性等级划分。
场景预设
现在假设你的设备已通过UDP打洞(STUN)获取到了目标的IP与端口信息,你的设备会:
- 先向公⽹服务器发起请求,探测⾃⼰是不是在⼀个 NAT 下
- 如果不在NAT下,说明自己是公网节点,可以与目标建立连接(让目标主动连接)
- 如果在,则获取⾃⼰的 NAT 类型是什么,根据不同的类型采取相应不同的策略
注意:A 和 B 均要与 公网服务器 一致保持连接心跳,确保 NAT 映射端口有效。
以下介绍不同场景下的打洞策略。
节点 A 在 NAT 下面,节点 B 在公网环境中
打洞策略一句话概括:只要保证节点 A 主动向节点 B 发起连接,两者就可以连接成功。以下详细解析的假设前提为A是锥型NAT。
在这个场景中:
- 节点B(公网):它拥有固定的公网IP和开放的端口,任何设备都可以直接向
(B_public_ip:B_port)发送数据。 - 节点A(NAT后):它没有直接可达的公网IP。但它可以通过向公网服务器发送心跳包,在自身的NAT上打开一个“洞”,假设映射为
(A_public_ip:A_public_port)。
仅仅知道“A主动连接B”是不够的,一个健壮的实现需要以下步骤:
阶段一:信息交换与准备
- 通过公网服务器协调:节点A和节点B都连接到同一个公网服务器S。
- B宣告自身地址:节点B将自己的公网地址
(B_public_ip:B_port)通过服务器S告知节点A。同时,B开始在B_port上启动UDP监听,等待 incoming connections。 - A获取B的地址:节点A从服务器S处获取到B的公网地址。
阶段二:A侧“洞”的建立与连接发起
A向B发送打洞包:这是最关键的一步。节点A主动地向节点B的地址(B_public_ip:B_port)发送一个或多个UDP数据包。这个数据包的内容可以是握手信息、任意有效载荷,甚至是一个空包。
- 作用:
- 对于节点B:它收到了来自
(A_public_ip:A_public_port)的包,因此它知道了A在公网上的映射地址。B现在可以直接向A的地址回复数据。 - 对于节点A的NAT设备:这个出站数据包创建或刷新了一条映射规则:“任何从
(A_private_ip:A_private_port)发往(B_public_ip:B_port)的流量,都使用公网地址(A_public_ip:A_public_port)”。由于是锥型NAT,这个“洞”现在也允许来自B的、发往A_public_port的入站数据包通过。
- 对于节点B:它收到了来自
阶段三:B侧响应与连接确认
- B向A回复确认包:节点B在收到A的打洞包后,立即向记录的A的公网地址
(A_public_ip:A_public_port)发送一个UDP回复包。 - 连接建立:B的回复包会顺利通过A的NAT设备(因为“洞”刚刚由A的出站包打开),到达节点A。至此,一条双向的P2P UDP通道就成功建立了。
总结
当节点A在NAT后而节点B在公网时,打洞策略的精髓在于利用锥型NAT对出站请求的宽松回放策略。通过由内网方(A)主动发起连接,巧妙地“引导”NAT设备为这次P2P通信打开一个双向的通道。实现上,需要公网服务器进行信息中介,并辅以持续的心跳和保活机制来维持通道的稳定性。这是一个高效且可靠的策略,是更复杂P2P打洞场景的基础。
如果A是对称NAT,情况会复杂一些。因为对称NAT在为A分配公网端口时,不仅看源地址,还看目标地址。A向B发包时使用的公网端口A_public_port_B,与A向服务器S发心跳时使用的公网端口A_public_port_S可能是不同的。这意味着B无法使用从服务器S那里得到的A的地址来连接A。
对于对称NAT,唯一的方法就是让A向B发送打洞包,并使用这个新创建的、专门用于与B通信的映射来建立连接。信息交换和连接流程不变,但开发者需要理解其端口的动态性。这在后面的场景也会再次提到。

节点 A 和 B 在相同的 NAT 下
这个场景通常被称为“局域网内互联”或“NAT回流”。
以下情况都属于A和B位于相同NAT下:
- 同一个家庭或办公室Wi-Fi网络:你的手机和你的笔记本电脑连接到同一个家用路由器。这是最常见的情况。
- 同一个公司或校园网络:公司内部不同部门的电脑,或者大学宿舍里不同同学的电脑,可能都通过同一个出口网关(NAT设备)访问互联网.
- 同一个蜂窝移动网络基站:在某些情况下,同一基站下的两台移动设备可能会共享一个公网IP地址.
- 同一个咖啡馆/机场的公共Wi-Fi:所有连接该热点的设备都处于同一个NAT之后
这个场景的复杂性在于,设备A和B通过公网服务器S看到的对方地址,是它们的“公网映射地址”,而不是它们在局域网内的真实IP。
我们假设:
- 局域网:
192.168.1.0/24 - 路由器/NAT公网IP:
203.0.113.1 - 节点A:
192.168.1.100:54320 - 节点B:
192.168.1.101:12345
当A和B分别连接公网服务器S时:
- S看到A的地址是:
203.0.113.1:60000(NAT为A分配的映射) - S看到B的地址是:
203.0.113.1:60001(NAT为B分配的映射)
现在,如果A简单地使用从S那里获取的B的地址(203.0.113.1:60001)去发起连接,会发生什么?
这取决于NAT设备的“回环地址转换” 能力:
- 支持回环地址转换的NAT:
- A发出的数据包(源:
192.168.1.100:54320, 目标:203.0.113.1:60001)到达路由器。 - 路由器识别出目标IP
203.0.113.1就是自己,并且目标端口60001映射的是内网设备192.168.1.101:12345。 - 于是路由器将数据包“绕回”内部,直接发送给B
- 结果:连接成功,但数据包走了不必要的路径(出到路由器再绕回来),增加了延迟和路由器负载
- A发出的数据包(源:
- 不支持回环地址转换的NAT
- A发出的数据包到达路由器
- 路由器无法将目标为自身公网IP的包正确转发回内网,它可能会丢弃这个包,或者不知道如何处理
- 结果:连接失败。两个在同一个房间里的设备,无法通信,因为它们的连接策略是错误的
所以一个优秀的P2P打洞策略必须能处理这种情况,核心思想是:优先尝试直接使用局域网地址进行通信。
阶段一:信息收集与地址发现
节点A和B在通过服务器S交换地址时,不应只交换一个公网地址,而应交换一个“地址候选列表”。这个列表应包括:
- 主机候选:设备自身的局域网IP地址(如
192.168.1.100:54320)。 - 服务器反射候选:从STUN服务器获取的公网映射地址(如
203.0.113.1:60000) - (可选)中继候选:如果使用了TURN服务器,还会有一个中继地址
阶段二:并行连接尝试
当A准备与B连接时,它会同时使用B的多个候选地址发起连接尝试。同样,B也会做同样的事情。这个过程通常由ICE框架自动化完成。
- 尝试局域网直连:
- A向B的“主机候选”(
192.168.1.101:12345)发送连接请求。 - B也向A的“主机候选”(
192.168.1.100:54320)发送连接请求。 - 结果:如果成功,这是最优路径,延迟最低,不消耗公网带宽。
- A向B的“主机候选”(
- 尝试公网地址打洞:
- 同时,A也向B的“服务器反射候选”(
203.0.113.1:60001)发送打洞包。 - 同时,B也向A的“服务器反射候选”(
203.0.113.1:60000)发送打洞包。 - 结果:
- 如果NAT支持回环,这条路径也能建立连接,作为备选。
- 如果设备不在同一NAT下(比如B在另一个家庭网络),那么步骤1的局域网直连会失败,而这一步的公网打洞将成功建立连接。
- 同时,A也向B的“服务器反射候选”(
阶段三:选择最优连接
A和B之间会通过ICE的连通性检查,确定哪些候选地址对之间可以通信。它们会优先选择网络路径最优的那个连接(通常是局域网直连),并关闭其他不必要的连接尝试。

节点 A 和 B 在不同的 NAT 下
节点 A 和 B 的 NAT 可能是任意一种 NAT 类型,分以下场景进行探讨
- A 和 B 均是锥形 NAT(NAT1/2/3)
- A 和 B 分别是对称型NAT4和普通锥形(NAT1/2)
- A 和 B 分别是对称型MAT4和 端口限制型NAT3
- A 和 B 都是对称型NAT4
A 和 B 均是锥形 NAT
锥形 NAT 之间能够很轻易打洞成功,具体流程如下:

以下流程假定已经完成了 NAT 类型探测,A/B 知道自己的 NAT 类型,以及通过 NAT 映射出去的端口 PA1/PB1。
- A 向 server 发起与 B 的打洞请求,server 向 B 转发打洞请求,同时A 向 PB1 直接发送探测包,那么 A 为 B 在 PA1 已经成功打洞,但是 A 的消息无法到达,因为 B 的 NAT 会将不明的地址(PA1) 丢弃。(注意:这里有可能不是丢弃,而是拒绝)
- B 收到从 server 转发过来的打洞请求后,向 PA1 直接发送探测包,这时 B 的 NAT 可以放行 PA1 的消息了,也就是 B 为 A 在 PB1 上完成了打洞。
- 至此,A 和 B 消息能够互通,打洞成功
A 和 B 分别是对称型和普通锥形
假设 A 是对称 NAT,B 是普通锥形:

- A 向 server 发起与 B 的打洞请求,server 向 B 转发打洞请求,同时发送探测包到 PB1,这个探测包是从 PA2 发出的,不是 PA1(因为对称型)。也就是 A 在端口 PA2 为 PB1 完成打洞,同时 B 的 NAT 会丢弃来自不明地址 PA2 的包。(注意:这里有可能不是丢弃,而是拒绝)
- B 收到从 server 转发过来的打洞请求,向 PA1 发送初始探测包(一开始不知道 PA2),这个时候 B 已经为 A 在 PB1 打好洞,至此 PA2 的消息能够通过 PB1 到达 B。(注意:因为是普通锥形,不对端口做限制,所以从不同端口 PA2 过来的包能被 B 接受)
- 经过步骤2,B 可以收到 PA2 的消息,同时结合 A 的 NAT 类型,重新改发探测包到 PA2,于是 A 在 PA2 能收到 PB1 的探测包,至此 A 和 B 消息可以互通,打洞成功
如果 A 和 B 正好角色相反,那么可以调整打洞的方向即可
A 和 B 分别是对称型和端口限制型
原本大致过程是同上面一种场景,但是由于 B 是端口限制型 NAT,会导致 PB1 只允许 PA1 通过(上面红色字体部分B 已经为 A 在 PB1 打好洞),从而 PA2 过来的包会被 B 的 NAT 拒绝,导致打洞失败。

那假如 B 探测包不是发往 PA1 而是 PA2 呢?那 A 和 B 就能打洞成功。
那么问题来了,B 如何知道 PA2 呢?
通常来讲,有两种办法:
- 端口探测
- 端口预测
端口探测
对于对称型的 NAT在映射内网端口的时候,有一些 NAT 设备会采取比较傻瓜的端口分配方法,比如进行简单的线性变化。
- 比如每次分配的端口号递增 1
- PA2 = (PA1 + PB1 + IPA + IPB) % 65535
对于这种 NAT,要探测这种特性需要用到两台及以上的公网 server,通过与不同的 server 连接映射的公网 Port,归纳总结自己的 NAT 映射规律,那么对于 B 来说,打洞的时候第一次向 A 发包,就直接往 PA2 发包就好了。
端口预测
有一些对称型 NAT 为了安全考虑,分配端口的方法难以预测,比如随机分配端口,那么对于这种情况,如何预测端口号呢?
基于一个理论:生日攻击理论
生日攻击理论讲的是在一个班级里,每个人的生日可能是 365 天里的任何一天,每年有 365 天,如果要让 至少有两人的生日相同的概率超过 50%,问这个班级最少需要多少人?
在一个班级中,每个人的生日是均匀分布的,每年有365天。要计算至少两人生日相同的概率超过50%,需要先计算所有人生日都不同的概率,然后用1减去该概率。
所有人生日都不同的概率 P(所有不同)P(所有不同) 为:

至少两人生日相同的概率 P(至少一对相同)P(至少一对相同) 为:

通过计算,当 n=23 时:

班级最少需要 23 人才能使至少两人生日相同的概率超过50%。
所以,生日攻击理论说的直白点就是,利用了远小于样本集的尝试次数,就能够很大概率获得两个相同的碰撞采样结果。
那么针对端口号的样本集 65535,实际是 (1025, 65535],双方随机打洞需要尝试多少次(打多少洞)才能刚好碰撞成功呢?
其实也就是将公式中的365改成65535,概率调高成80%,计算得到尝试次数为 460 次。(也就是班级至少要有460人)
也就是说对于 B 来说,可以尝试随机往 A 的 460 个不同的端口发探测包,就有 80% 的概率能够正好预测到 NAT-A 随机分配的 PA2。 460 个探测包的代价基本可以忽略不计。

至此,可以完美实现对称型和端口限制型的打洞。
A 和 B 都是对称型
由于 A 和 B 均是对称型 NAT,那么比上面一种场景更严格,A 和 B 探测得到的公网 Port 均会被修改,无法完成打洞。
拓展:NAT 对陌生地址包的行为
上面能够打洞成功的场景下,都是基于一个前提: NAT 对陌生地址发来的包采用的是丢弃策略。这里的陌生地址指的是自己没有主动往外发包的 {dest_ip, dest_port} 对。
如果不是丢弃,而是采用黑名单机制呢?为了安全考虑,有一些 NAT 在收到陌生地址的包后,会触发防火墙模块,并且在自己的 deny 列表中增加一项{PA2, PB1},随后自己再往 A 发包的时候,本来打算使用 PB1 进行发包,但是发现 deny 列表里已经存在了 PB1,于是会重新选择一个端口号 PB2 发包。于是对于这种锥形 NAT 会退化成对称型的 NAT。
知道了这个原理,要解决也很容易。
- 设置有限 TTL,避免惊动对方防火墙
- 一开始 A 往 B 发包,可以设置 TTL 为 3,这个数大到足够通过自己的外网 NAT(可能有多层),又会被中间的某个运营商 router 丢弃,从而不会惊动 B 的防火墙模块,同时为 B 打好了洞。
- 同理,B 也做类似的操作,为 A 打好洞
- A 和 B 两边都等待一段时间,比如 2 s
- 再互相发探测包,不用设置 TTL,打洞成功。
关于 TTL 的值设置为多少,需要做一定的探测,不然可能设置过小,也许都没有走出自己的 NAT,设置过大,可能导致惊动了对方的防火墙
可见以上设置有限TTL的方法网络工具和应用支持才可配置TTL,如果不支持,只能选择尝试改变NAT的黑名单策略。
一般自己买的路由器不可能这么变态而直接默认防火墙黑名单策略,所以如果遇到这种黑名单策略的情况,尽可能将光猫调为桥接模式,路由器直接拨号上网。
拓展:TCP 打洞的可行性
TCP 也能实现 NAT 打洞,只不过相比 UDP 会更复杂一点。原因是:
- 一个UDP套接字由一个二元组来标识,该二元组包含一个目的地址和一个目的端口号;而一个TCP套接字是由一个四元组来标识,包括源IP地址、源端口号、目的IP地址、目的端口号
- TCP 套接字仅允许建立 1 对 1 的响应,即应用程序将一个套接字绑定到本地的端口后,试图将第二个套接字绑定到该端口的操作都会失败
基本的TCP打洞策略如下:
- A 和 B 分别位于不同的 NAT 下面
- A 启动 tcp client,bind 一个 local_port PA1’,执行 connect 连接公网 tcp server,server 获取 A 的映射公网端口 PA
- B 同上,B 启动 tcp client,bind 一个 local_port PB1’,执行 connect 连接公网 tcp server,server 获取 B 的映射公网端口 PB
- A 和 B 保持和 server 的连接,不断开,避免各自的 NAT 上的映射规则过期 {PA1’ → PA} 和 {PB1’ → PB1}
- A 和 B 通过 server 互相获取对方的公网 Port PA1 和 PB1,准备开始打洞
- A 新启动一个 tcp 套接字,使用 SO_REUSEADDR/SO_REUSEPORT 绑定到之前与 server 连接的本地端口,也就是 PA1’,并且调用 listen 处于等待监听状态
- B 同上,bind PB1’ 并且调用 listen 处于监听状态
- A 再新建一个套接字 bind 到之前的端口,调用 connect 发起向 PB1 的连接,也就是 A 往 PB1 发送 syn 包,也就是为 B 打洞,NAT-B 会丢弃这个包
- 同时 B也再新建一个套接字 bind 到之前的端口, 也调用 connect 发起向 PA1 的连接,也就是 B 往 PA1 发送 syn 包,也就是为 A 打洞
- 假设 A 发送完 syn 之后,B 的 syn 包达到了 NAT-A,NAT-A 能通过,这个时候有的 linux 系统上 A 会认为自己的异步 connect 调用成功,同时利用相同的 seq 发送 SYN+ACK 包 到 PB1,NAT-B 也能顺利通过,再返回 ACK 包,连接建立成功;有的 linux 系统会走正常的 accept 操作,也能顺利建立连接
P2P可行性分级表
根据以上详细分析,分以下三个等级↓
优:理想P2P条件。有一方或双方无需NAT(即NAT0),这种情况下可直连。- 双方均 支持IPv6。
- 一方支持IPv4,另一方可为任意NAT类型。
良:可靠P2P连接。映射稳定,过滤宽松,STUN打洞成功率接近100%- 一方为NAT1,另一方为任意NAT类型,近似直连。
- 双方均为NAT2或NAT3
- 任意一方为NAT4,另一方为NAT1或NAT2。
差:对打洞时序要求严格,成功率中等,可能需要依赖TURN中继- 任意一方为NAT4,且另一方为NAT3。需要STUN进行端口预测(生日攻击)
- 双方均为NAT4。神仙难救。必须使用TURN中继