前言

之前有过一篇关于 NAT打洞 的,主要是 UDP打洞,感兴趣的可以看之前的文章:NAT打洞
该篇文章主要讲述的是对 TCP打洞 的实现,以及其中的步骤的讲解和优化
也算是继续完善这个系列的坑…

可运行的代码放在了 Github仓库

当你开始阅读时,默认读者已具备一定的网络基础以及对NAT有基本的认识
那我们开始!

TCP 打洞

与 UDP 的区别

TCP与UDP打洞,最大的不同在于TCP是有连接,UDP是无连接
这就决定了,UDP可以一边打洞一边监听端口
其次就是,UDP打洞成功就能进行通信,TCP是要想办法达成握手,三次握手成了才可以通信

但其实,并没什么影响。。。因为打洞的原理并没有变,只是流程上需要稍微改变一下

流程

整个的原理和流程如下图[^1]:(先说完这个图再说改进)

TCP打洞流程

  • Step1-2: R 向服务器注册信息(这里其实UDP和TCP都可以,改进部分有说明)
  • Step3: I 向服务器发出 “想要连接的请求”,用的是TCP(这样可以知道 I 自身的 NAT 信息 NI:Y
  • Step4: 服务器将 “I 想要连接的请求” 发送给 R
  • Step5: R 收到服务器发来的 “I 想要连接的请求” 后,用 R:J 向服务器发起 TCP 连接
    服务器收到连接后,会收到来自 NR:K 的信息,之后会判断 NR 是否等于 R,
    • 是的话,即 R 不经过 NAT,I 可以直接发起连接
    • 否的话,即 R 经过 NAT,需要 R 打洞后,I 才可以发起连接(这里讨论这种情况)
  • Step6: 服务器向 R 发送打洞的消息,并将 NI:Y 信息发送给 R,表示可以通过 NI:Y 与 I 通信
  • Step7: R 收到 NI:Y 后,用 R:JNI:Y 发送 TCP 连接请求(指SYN)
  • Step8: R 不会收到返回信息(指ACK),但这里也有可能收到拒绝信息,但这里不影响
    随后 R 直接断开通过 R:J 发起的 TCP 连接,并开启 TCP Server 监听 J 端口
  • Step9: R 用 其他端口 向服务器发送 “打洞完成” 的信息
  • Step10: 服务器通过 NI:Y“打洞完成” 的信息 以及 NR:K 的信息发送给 I,并且关闭与之的通信
  • Step11: I 收到信息后,会终止 NI:Y 与服务器的 TCP 连接,然后会用 NI:YNR:K 发起 TCP 连接请求(指SYN)
  • 最终 SYN 会被 NR:K 送进给 R:J,之后会进行完整的三次握手建立TCP连接

改进

这里的 Step1、2、9 其实对连接并没有要求
1 2 9 可以用同一个连接 专门用来发送信息 也是可以的,这里起到的作用单纯是通知状态和交换信息
甚至 Step1-2 直接用 R:J 与服务器进行通信也是可以的,因为 Step6 之后都会断开与服务器的连接而转为打洞和监听

其次,这里并没有那么严谨的区分 I 和 R 与服务器发起连接的顺序,因为与服务器建立连接之后,不管是 Step4 还是 Step10,都是在等待服务器返回信息 才进行下一步操作(这里代码中我用了心跳包的方式维持)

Step6 的 NI:Y 信息其实可以在 Step4 就带过来给 R,这里主要是考虑是否需要考虑打洞的情况,因为 R 如果不需要打洞的话 NI:Y 的信息其实并不需要告知 R
但如果单纯是考虑是否需要打洞的话,其实还得考虑双方的 NAT 类型 来进一步判断是谁来打洞
比如说:

I 是 NAT3 *端口限制性锥形NAT( Port Restricted Clone NAT)*,
R 是 NAT4 对称式NAT(Symmetric NAT)
则需要 I 来打洞,R 向 NI 发起连接

如果不需要考虑的话,其实这里 punchHole 一直为 True 都可以

改进后:(其实也没多大必要)

sequenceDiagram
participant I
participant NI
participant S
participant NR
participant R

par
R ->>+ S : Step2: TCP - S:Z < NR:K < R:J
Note over NR: NR:K;想要被 I 连接

I ->> S : Step1: TCP - I:X > NI:Y > S:Z
Note over NI: NI:Y;想要连接R
end

Note right of S: NI:Y;需要打洞
S -->> R : 

R->>+ NI: Step3: TCP - NI:Y < NR:K < R:J (打洞)
NI--x R: 
# Note right of R: Destroy TCP R:J

R->>R: Step4: 
① Destroy TCP R:J
② CreateServer Listen R:J R->>S: Step5: New Connection
S:* < NR:* < R:* Note over NR: 打洞完成 Note left of S: NR:K S-->>- I: I->>I: Step6:
Destroy TCP I:X I->>NI: Step7: TCP - I:X NI->>-NR: NR->>R: Note over NI,NR: Step7: TCP - I:X > NI:Y > NR:K > R:J R-->>I: ACK I-->>R: ACK
  • par 里的 Step1、Step2 不分先后
  • Step3 发出去就可以进入 Step4 了
  • Step5 的 * 号表示任意,只要不是 R:J 都行(反正你也开Server监听了,也绑定不了)
  • Step7 是用 Step6 的 I:XNR:K 直接发起连接

代码部分说明

1
2
3
1. 使用前,请先将 Server.js 部署,记得开启端口
2. 完善 Client*.js 中的 `SERVER_IP` `SERVER_PORT` `LOCAL_PORT`
3. 启动 ClientA.jsClientB.js
1
2
3
4
5
6
7
8
9
10
11
/**
* 这里的 from 和 to,分别代表的是:
* from: 想要发起连接的一方
* to: 被连接的一方
*
* 它 **并不关乎** 这个 msg 是由 ClientA 发出还是由 ClientB 发出
*/
msg: {
from: "ClientA"
to: "ClientB"
}
1
2
3
4
5
6
7
8
9
// 连接成功后,可以将 ClientA.js、ClientB.js 中的这段代码放开,然后通过键盘输入发送消息

// 键盘输入发送消息
process.stdin.on('data', data => {
socket.write(JSON.stringify({
type: "msg",
msg: data.toString().trim()
}))
})

其他

一些实验发现

  1. NAT 维持 TCP洞口 的时间一般比较短,我试过我这边区域的网络,一般是维持 5s 左右,超过 5s 这个端口没有数据包,
    NAT 会默认这个端口不再使用,外面数据包再想进来都会被 NAT 丢弃,这点不管是打洞,亦或是已经建立的TCP通信
    (通过服务端设置延迟返回信息,测试得到,但各区域的 NAT 会有所不同,可以自己试下)

  2. 同样,在这段维持时间内,用户通过 同一个端口 向外发送信息,都会绑定同一个 NAT 端口。所以并不需要端口复用,
    只要在维持时间内,客户端 Destroy 之前的TCP连接(再暴力点可以直接抛出来终结掉之前的连接),
    再用 同一个端口 建立连接/监听这个端口 都是可以

    数据包发送是毫秒级的,对于 5s 的窗口期来说 是完全足够的 (手动才勉强)

  3. 当前这个,最差可以在 NAT3 和 NAT3 之间建立起连接,如果其中一方是 NAT4 的话,NAT3 作为需要打洞的一方即可
    但可能需要用 同一个端口 向 NAT4 的多个端口发送数据包(端口预测)


说在最后

首先是发现,网上关于 “NAT穿越”、”打洞”、“P2P”…… 等等话题,要么就是一大段理论概念,要么就是水文章,实际有用的、能参考的没多少

剩下能参考的,基本都是 UDP 的,TCP 就几乎没有

然后 TCP 吧,有的基本都是 C/C++ 的
然后还一堆不要脸 CV 的,要点脸的都知道挂个转载或者挂个出处吧
特别是那些有这个字段 SO_REUSEADDR ,还跟你说一定要设置端口复用的,一大半都是 CV 的
( 我就这么直接下定义了!)


然后这篇文章也算是补充了一下 我之前 NAT打洞 一文里 关于 TCP 的部分
当时刚接触 然后去做相关的测试和实验,还比较的懵懂,然后是找了些资料 又再去研究了下 计算机网络 ,然后发现网上的 真的 好水呀,真的好气!
然后还是决定去翻了翻书本和找了下文献之类的,现在过来填个坑吧

这同时也是完善 P2P 这部分的坑,后续其实还有
就是 NAT3 / NAT4 与 NAT4 相互连接,NAT4 一般是 手机热点 连接的网络
NAT3/4 与 NAT4 建立连接,需要 端口预测 / 端口探测,先挖个坑,后续有时间再来填…


参考文献

[^1]: TCP Connections for P2P Apps: A Software Approach to Solving the NAT Problem