最近看了一下 libp2p 的直连,里面比较关键的一个点是 NAT Hole Punching。
继续往下看会发现 TCP Hole Punching 里面还有一个很重要的东西:
Socket Reuse
这里简单记录一下 libp2p TCP 直连的过程,以及 Socket Reuse 为什么这么重要。
1. 为什么需要打洞
假设两个节点都在 NAT 后面:
Peer A -> NAT A -> Internet <- NAT B <- Peer B
A 和 B 都可以主动访问公网,但是其他机器不能直接访问它们的内网地址。
例如 A:
192.168.1.10:4001
A 主动建立一个 TCP Connection 后,NAT 可能产生:
192.168.1.10:4001 -> 1.2.3.4:52000
外面的节点真正看到的是:
1.2.3.4:52000
但是 B 知道这个地址,并不代表直接连接它就一定能够成功。
因为对于 NAT 来说,一个没有对应连接状态的外部 SYN 通常不会直接转发给内网机器。
所以需要 Hole Punching。
2. libp2p 怎么做直连
libp2p 的思路是先通过 Relay 让两个 Peer 联系上,然后再尝试建立真正的直连。
整体过程其实就是:
A -> Relay -> B -> 交换地址 -> 同步 -> 同时 Connect -> TCP Direct Connection
Relay 主要解决的是两个 Peer 一开始互相访问不到的问题。
等双方已经可以通过 Relay 通信以后,再通过 DCUtR 协调 Hole Punching。
如果打洞成功,后面的连接就可以直接变成:
A -> B
而不是一直:
A -> Relay -> B
3. 为什么双方要同时 Connect
TCP 打洞里面比较关键的一步,就是双方几乎同时主动连接对方。
假设 A 主动连接 B:
A -> NAT A -> NAT B -> B
A 发出 SYN 后,NAT A 会记录这次主动连接。
与此同时 B 也主动连接 A:
B -> NAT B -> NAT A -> A
NAT B 同样会记录自己的主动连接。
于是整个过程变成:
A 发 SYN -> NAT A 建立状态
B 发 SYN -> NAT B 建立状态
双方的流量都有机会通过对方 NAT
TCP 本身也支持这种情况,叫:
TCP Simultaneous Open
所以 DCUtR 很重要的一件事情,就是协调双方在比较接近的时间发起连接。
4. Socket Reuse 是什么
这里的 Socket Reuse 很容易被理解成“复用一条已经建立的 TCP Connection”。
其实重点不是这个。
这里更重要的是:
复用同一个 Local Port。
比如 libp2p 正在监听:
0.0.0.0:4001
正常使用:
net.Dial("tcp", remote)
操作系统一般会给这个主动连接选择一个临时 Source Port,例如:
192.168.1.10:52731 -> Remote
但 TCP Hole Punching 更希望:
Listen :4001 -> Dial from :4001
也就是监听端口和主动连接使用同一个 Local Port。
这也是 libp2p go-reuseport 这类实现存在的重要原因。
5. 为什么 Socket Reuse 对打洞很重要
假设 libp2p 监听:
:4001
但是主动连接 Relay 时使用了随机端口:
:52731
NAT 建立出来的映射可能是:
192.168.1.10:52731 -> 1.2.3.4:62000
远程 Peer 观察到的是:
1.2.3.4:62000
问题是,这个 NAT Mapping 是 52731 产生的,而 libp2p 真正监听的是 4001。
如果后面 Hole Punching 又使用另外一个随机端口:
52731 -> 49120
NAT Mapping 很可能又发生变化。
前面得到的 Observed Address 就不一定还有参考价值。
而使用 Socket Reuse 后,可以尽量做到:
Listen :4001 -> Dial from :4001 -> NAT Mapping -> Observed Address
后面真正开始打洞的时候继续:
Observed Address -> Dial from :4001 -> 尝试复用 NAT Mapping -> Direct Connection
这就是 Socket Reuse 在 TCP Hole Punching 里面非常重要的原因。
它不是为了少创建一个 Socket,而是为了:
尽量让监听端口、主动连接端口和 NAT Mapping 保持一致。
6. 举一个完整的例子
假设 Peer A:
192.168.1.10:4001
经过 NAT 后:
192.168.1.10:4001 -> 1.1.1.1:50001
Peer B:
192.168.2.10:4001
经过 NAT 后:
192.168.2.10:4001 -> 2.2.2.2:60001
双方通过 Relay 交换地址以后得到:
A 知道 B = 2.2.2.2:60001
B 知道 A = 1.1.1.1:50001
DCUtR 再协调双方同时连接:
A:4001 -> 2.2.2.2:60001
B:4001 -> 1.1.1.1:50001
因为双方都在主动向外建立连接:
A Connect -> NAT A 建立连接状态
B Connect -> NAT B 建立连接状态
如果 NAT 行为允许,双方的 SYN 就可能穿过对方 NAT,最后建立 TCP Connection:
Peer A -> Direct TCP Connection -> Peer B
这就是 TCP Hole Punching 最核心的过程。
7. Socket Reuse 也不是万能的
这里还要注意一点。
Socket Reuse 只是让:
Local Port -> NAT Mapping
尽可能稳定。
但是公网端口最终还是由 NAT 决定。
有些 NAT 对不同 Remote Address 可能产生不同的 Mapping:
A:4001 -> Server X = Public:50001
A:4001 -> Server Y = Public:61023
虽然 A 本地一直是 4001,公网 Port 还是变了。
这种网络环境下 Hole Punching 就可能失败。
所以 libp2p 仍然需要 Relay 作为 fallback:
Hole Punching 成功 -> Direct Connection
Hole Punching 失败 -> Relay Connection
Socket Reuse 是提高 TCP 打洞成功率的重要条件,但不是保证所有 NAT 都可以穿透。
总结
libp2p TCP 直连的过程其实可以简单理解成:
Relay 建立通信 -> 交换 Observed Address -> DCUtR 同步 -> Socket Reuse -> 双方同时 Connect -> TCP Hole Punching -> Direct Connection
其中 Socket Reuse 最关键的地方不是“复用 Socket”,而是:
Listen Port -> Dial Source Port -> NAT Mapping
尽量保持在同一套端口关系里面。
如果每次主动 Dial 都随机更换 Source Port:
4001 -> 52731 -> 49120 -> 58321
那么 NAT Mapping 也可能一直变化,前面观察到的公网地址就很难拿来继续打洞。
所以对于 libp2p TCP Hole Punching 来说,我觉得 Socket Reuse 可以理解成一个比较核心的基础能力:
尽量使用同一个 Local Port 主动建立连接,让已经建立的 NAT Mapping 可以继续被 Hole Punching 利用。
理解这一点以后,再看 Relay、DCUtR 和 Observed Address,整个 libp2p TCP 直连流程就比较容易串起来了。
ref
libp2p Hole Punching:
https://docs.libp2p.io/concepts/hole-punching/
DCUtR Specification:
https://github.com/libp2p/specs/blob/master/relay/DCUtR.md
libp2p Hole Punching Specification:
https://github.com/libp2p/specs/blob/master/connections/hole-punching.md
go-reuseport: