NEE's Blog

TCP 打洞:一种无需基础设施的 P2P 连接测试方法

March 15, 2026

本文翻译自 TCP Hole Punching,原载于 Hacker News。

背景:为什么 TCP 打洞这么难?

TCP 打洞(TCP Hole Punching)是一种让两台位于 NAT(Network Address Translation)路由器后的计算机直接建立连接的技术。但要让它正常工作,需要满足一系列苛刻的条件:

  • 双方必须知道对方的公网 IP(WAN IP)
  • 必须知道正确的外部端口
  • 必须在完全相同的时刻发起连接

在实际应用中,这通常意味着需要:

  1. 使用 STUN 服务器查找公网 IP
  2. 进行 NAT 类型枚举和端口预测
  3. 通过 NTP 同步时间
  4. 通过某个「信道」交换所有必要的元数据(公网 IP、端口预测、打洞时间等)

这一整套流程需要大量的基础设施和代码支持——复杂且容易出错。

问题来了: 如果你只是想测试一下你的打洞算法是否有效呢?你不关心其他部分的软件实现。这篇文章就介绍了一种简化的方法。


第一部分:选择时间桶(Bucket)

要绕过固定基础设施的需求,一个简单的方法是使用确定性算法,从单一参数推导出所有元数据。核心思想是:让双方在没有通信的情况下,基于时间收敛到相同的参数。

首先,我们基于 Unix 时间戳选择一个起始参数。但在分布式系统中,我们知道网络上不存在绝对的「现在」。所以需要一点数学技巧来制造「现在」:

now = timestamp()
max_clock_error = 20s  # 时间戳可能的最大偏差
min_run_window = 10s   # 双方运行程序的总时间窗口
window = (max_clock_error * 2) + 2
bucket = int((now - max_clock_error) // window)

这里定义了什么是「可接受的解决方案」:

  • 协议有特定的运行时间范围
  • 时间戳必须在某个范围内才被视为有效
  • 通过量化处理,即使存在时钟偏差,双方也能收敛到同一个数字

这个量化后的数字就是所谓的「(bucket)」。


第二部分:选择端口

既然双方共享同一个 bucket,我们就可以用它来推导一个共享的端口列表。

核心假设是:本地端口等于外部端口。许多家用路由器会尝试在外部映射中保留源端口。这个特性被称为「等差映射(equal delta mapping)」——虽然不是所有路由器都支持,但为了算法简洁,我们牺牲了一些覆盖率。

为了在没有通信的情况下生成共享端口列表,我们用 bucket 作为伪随机数生成器(PRNG)的种子:

large_prime = 2654435761
stable_boundary = (bucket * large_prime) % 0xFFFFFFFF

这里 0xFFFFFFFF 限制了边界数的范围,防止溢出 PRNG 接受的种子值。

为什么用质数? 如果乘数与模数共享公因数,可能的数字空间会因共享因子而缩小。质数确保数字空间包含唯一的项。

然后使用 randrange 函数计算端口范围。在我的代码中,我生成 16 个端口,并排除那些无法绑定的端口。手动选择随机端口时,与操作系统现有程序的端口冲突是预期中的情况。


第三部分:Socket 和网络编程

在继续之前,有必要回顾一下 TCP 打洞的 socket 要求。有一些非常特定的 socket 选项必须设置:

s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)

TCP 打洞涉及激进地重用 socket 地址。通常在 TCP 中,如果连接失败,你会关闭 socket。但在 TCP 打洞中,任何形式的清理都会破坏协议。

清理意味着调用 close(),而 close() 可能会发送 RST 数据包,告诉远程路由器「连接有问题,忽略它」。

如果你关闭 socket,操作系统也会开始自己的清理过程。socket 会进入 TIME_WAIT 等状态,即使你设置了正确的 socket 选项,也很难可靠地重用同一个地址。

为什么必须用非阻塞 Socket?

TCP 打洞唯一正确的模型是使用非阻塞 socket。你不能使用阻塞 socket,因为你需要能够快速发送 SYN 数据包而不必等待响应。

异步网络也不行。 异步网络会阻止你的软件精确控制时序,而 TCP 打洞对时序高度敏感。如果数据包交换偏差哪怕几毫秒,整个协议都可能失败。

我的建议是:使用非阻塞 socket 配合 select 进行轮询。这让你能够正确处理每个连接状态而不影响时序:

for ... sel.register(s, selectors.EVENT_WRITE | selectors.EVENT_READ)

打洞的过程很简单:在 (dest_ip, port) 元组上调用 connect_ex,每次睡眠 0.01 秒,直到过期(src_port == dest_port)。

实际的打洞过程并不优雅——你只是在疯狂发送 SYN 包。但这部分需要足够激进以创建远程映射,但又不能太激进以至于耗尽 CPU。我用 0.01 秒的睡眠时间来平衡。


第四部分:选择胜者

在这个算法中使用了多个端口,所以可能返回多个成功的连接。问题是:如何选择同一个连接?

我的方法是让双方选择一个领导者(leader)和跟随者(follower)。领导者是公网 IP 数值更大的一方。

领导者在一个连接上发送单个字符,然后干净地关闭其他连接:

winner.send(b"$")
...
loser.shutdown(socket.SHUT_RDWR)
loser.close()

跟随者使用 select 轮询事件。如果找到一个,调用 recv(1) 来选择那个连接(胜者)。

为什么用单个字符? 因为 TCP 是流式协议。如果「成功」分隔符是一个单词,跟随者就必须实现缓冲读取算法来确保接收完整数据(我不想增加这个复杂性)。单个字符是原子的。


第五部分:整合

现在,我们得到一个简单的 TCP 打洞算法——只需要目标 IP 就能使用

由于协议是确定性的:

  • 测试时不需要基础设施
  • 主机之间不需要交换元数据

主机仍可使用 NTP 同步时间(推荐,但可选——虚拟机中的旧操作系统可能无法很好地维护时间)。

假设另一个进程会协调运行这个工具。不过,只要命令落在 10 秒的 min_run_window 内,你也可以轻松在多个终端上自己运行所有内容。

注意: 此打洞方法适用于使用等差分配的常见路由器。

完整代码

你可以在原文中找到 tcp_punch.py 的完整实现。在本地测试时,可以运行:

python tcp_punch.py 127.0.0.1

来体验代码的工作方式。


总结

这篇文章展示了一种巧妙的 TCP 打洞简化方法:

传统方法 本文方法
需要 STUN 服务器 无需基础设施
需要 NTP 时间同步 容忍 20 秒时钟偏差
需要元数据交换信道 确定性算法推导
复杂的生产级实现 简单的测试工具

核心思想: 通过将 Unix 时间戳量化为「桶」,双方可以在没有通信的情况下收敛到相同的参数,从而简化打洞测试。

这种方法牺牲了一定的覆盖率(不支持所有 NAT 类型),但换来了极大的简洁性,非常适合学习和测试 TCP 打洞的基本原理。


如果你想深入了解 NAT 穿透技术,建议进一步研究 STUN、TURN 和 ICE 协议,它们是 WebRTC 等 P2P 应用的基础。

comments powered by Disqus