TCP实现P2P(NAT3-NAT4)
前言
这篇讲的主要是在原来的基础上,对于 NAT4 方面的 实现思路,不想看过程可以 省流
如果你对 NAT 完全不了解,可以到这里 NAT打洞 看一下其中的 初识部分
如果你对 NAT 有些了解,但不知道如何让他们之间进行通信,可以看一下这篇 TCP打洞,实现P2P
可运行的代码放在了 Github仓库
当你开始阅读本文时,默认读者已具备一定的网络基础以及对 NAT 有一定深度的理解
那我们开始!
NAT3 和 NAT4 的区别
先说明一下:L:A — NL:H —— S:Z —— NR:J — R:U
L | NL | S | NR | R |
---|---|---|---|---|
L主机 | L侧的NAT | 服务端 | R侧的NAT | R主机 |
端口就不用怎么说明了吧……
NAT3
NAT3 是短时间内 协议+端口 是固定不变的
协议主要是分UDP和TCP,端口变化 比如说
L主机用 A端口 TCP 访问:S的Z端口、R主机的U端口、R主机的V端口,就会有以下三条记录:
1 | L:A - NL:H - S:Z // S的 Z 端口 |
flowchart LR subgraph L A end subgraph NL H end subgraph R U V end subgraph S Z end A --> H --> Z H --> U H --> V
只要用 L:A
发出数据,都会从 NL:H
出去
NAT4
NAT4 是不管访问谁,只要 协议+对端IP+对端端口
有一个不一样,NL就 不会 用同一个端口将请求发出,上面的记录就会变成
1 | L:A - NL:H - S:Z // S的 Z 端口 |
flowchart LR subgraph L A end subgraph "NL" F G H end subgraph R U V end subgraph S Z end A --> H --> Z A --> G --> U A --> F --> V
所以可以大致的认为:
- 处于NAT3的主机,本地端口不变,NAT的出端口也不变
- 处于NAT4的主机,不管怎样,建立新的连接时 端口都会变
打洞
谁来打洞
打洞是为了让外面的 SYN 进来,那维持端口的稳定保持不变,以及让端口长时间存活就成了关键,同时为了提高成功率和缩短连接,端口应该尽可能让建立连接的一方知道
简单来说就是,看上文出现的三条记录:
假设让 NAT3 一方进行打洞
graph LR A[L:A] --> B((NL:H)) B --> Z(S:Z) B --> U(R:U) B --> V(R:V)
这时只要通过
L:A
向 任意主机 发送数据都可以来维持NL:H
,
同时因为H端口
是 “共用” 的,所以 H 的端口信息可以被 S 知晓后,S可以同步给R,让 R 主动给 H 发送数据包L:A <--x-- NL:H <-- R:*
假设让 NAT4 一方进行打洞
graph LR A[L:A] --> H((NL:H)) A[L:A] --> G((NL:G)) A[L:A] --> F((NL:F)) H --> D(S:Z) G --> E(R:U) F --> V(R:V)
这时 L 想要维持洞口,需要维持3个端口,
想要维持H端口
,只能通过L:A
不断向S:Z
发出数据,同理G、F亦是如此
而且三个端口的信息并不能互通 “共用”,互通了也无法使用(就是 R:V 给 H/G 发送消息也收不到)
因此让处于 NAT3 进行打洞,更为合适
给谁打洞
现在知道是由 NAT3 的一方进行打洞了,但对方是 NAT4 ,端口是每次连接都会变化的,那给谁打洞呢?
这个问题,一些小伙伴其实已经发现 上面已经出现了解决方案了,还是这两条记录
并且我们假设让 L 处于 NAT3 中,R 处于 NAT4 中,加上 NR,把原来中的 S 去掉,再简化一下,就会呈现:
flowchart LR subgraph NAT3 L:A --> NL:H end subgraph NAT4 subgraph R U ... V end subgraph NR J xxx K end end V --> K U --> J NL:H --SYN--> K -.-x V xxx --- ... NL:H --SYN--> J -.-x U
这时候对于 NAT3 来说,不管 J 还是 K 发来的数据包 它都会放行
假设现在让 R 用 任意端口 ...
向 H
发出数据,就会出现 H <-- NR:* <-- R:*
的这么一条记录
但这时 NAT3 并不会给 NR:*
发来的数据放行,因为没有 NR:*
的记录
那我们怎么添加这么一条记录呢?现在我们设想一个极端的情况:
如果
L:A - NL:H
给NR
的 全端口 都发送了 SYN
那是不是 R 不管从哪个端口发出 SYN
只要是通过NR
的 任意端口 出来的,都会被NL:H
放行
实际上也确实如此,NAT3 一方打洞,与之前不同的是,这次不是只打一个洞,而是留出了多个洞口以供放行
但实际上我们不需要给全端口都发送 SYN
一个是工作量大,可能后面发完 前面的洞口又维持不住了
另一个是可能会被对方的 NAT 认为是扫描端口之类的活,被短时间封了就不好了
建立连接
注意:与之前的连接不同,这次我们 L、R 都保持一条与 S服务端 的连接用于交换信息
蓝色块:代表专门与 服务端 通信,交换信息的连接
黄色块:代表此次通信所带携带的信息
实线:表示 主动 建立 新连接
sequenceDiagram participant L participant NL participant S participant NR participant R %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% rect rgb(205, 235, 255) par L ->> +S : Step1: TCP - L:A > NL:H > S:Z Note left of S: NL:H;注册信息 S-->> L: Note right of L: NAT3;等待连接 R ->>S : Step2: TCP - S:Z < NR:K < R:J Note right of S: NR:K
想要与 L 连接 S-->> -L: Step3 Note right of L: R请求连接;打洞ID end end %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% L ->> +S: Step4: TCP - L:B > NL:T > S:Z Note left of S: NL:T
同意R连接;打洞ID rect rgb(205, 235, 255) par 可选 S -->> R: Step4-1【可选】 Note left of R: NAT4;L 同意连接
ID;等待发起连接 R ->> S: ① S:Z < NR:* < S:* R ->> S: ② S:Z < NR:* < S:* %% Note right of S: 判断这几次连接
端口变换的规律 S -->> S: 判断端口变换的规律,确定打洞范围 end end %% S-->> -L: Step4: TCP - L:B < NL:T < S:Z Note right of L: 该ID打洞范围:
[NR:K-20, NR:K+30] %% Note over NL: 该ID打洞范围 [NR:K-20, NR:K+30] S-->> -L: L ->> +NR: Step5: L:B > NL:T > NR:K±x(打洞) NR --x L: L->>L: Step6:
① Destroy TCP L:B
② CreateServer Listen L:B %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% rect rgb(205, 235, 255) par L -->> +S: Step7: TCP - L:A > NL:H > S:Z Note left of S: 打洞完成;打洞ID Note left of R: 发起连接;NL:T S -->> -R: end end R ->> NR: Step8: NR:? < R:U NR ->> -NL: Step8: TCP L:B < NL:T < NR:? < R:U NL ->> NL: If ? ∈ [NR:K-20, NR:K+30] NL ->> L: %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %% Note over L,R: Step8: TCP - L:B < NL:T < NR:? < R:U L-->>R: ACK R-->>L: ACK
- Step1-2: L 和 R 建立起与 S 的连接 用于交换信息,并且双方表示连接的意愿
(后续可以以此来组网)这时服务端还可以充当 STUN 来判断他们所属的 NAT网络 - Step3: S 判断需要打洞的一方(这里是L),并将请求连接的消息传给 L
- Step4: L 将 “同意连接 表示可以打洞” 的信息,用新的端口 L:B 与 S 建立连接,并告知 S,这时 S 知道接下来打洞的端口是 NL:T
- Step4-1【可选】: 这时 S 可以把同意连接的信息返回给 R,并让R给自己发起多次连接,S 就可以通过判断 R 端口的变化找出规律(比如最简单的单调递增 递减,又或者双端甚至多端的递增递减)
- 之后 S 将 需要打洞的端口 返回给 L
- Step5: L 结束与 S 的连接,并尝试用 L:B ,向 S 返回的 需要打洞的端口 发起连接(这步并不会收到返回信息)
- Step6: 随后 L 立即断开通过 L:B 发起的TCP连接,并开启 TCP Server 监听 B 端口
- Step7: L (用Step1建立的连接) 向 S 发送 “打洞完成” 的信息
- Step8: S 将 “打洞完成” 的信息 以及 NL:T 的信息发送给 R
- Step9: R 收到信息后,会用 任意端口 直接向 NL:T 发起TCP连接(指SYN)
- 如果 NR:? 恰好是上面 “需要打洞的端口” 的话,NL:T 就会放行这个 SYN
- 如果
? ∉ "需要打洞的端口"
,则任意一方都 没有 任何的回应
主要问题
说了这么多,但这些都只是给大家提供的 实现思路 ,虽然我确实也 成功实现 并建立连接了
但真实的网络环境要复杂的多的多的多,举几个实现过程中可能遇到的问题:
Step8 中尝试建立连接,单一一条数据包成功率的肯定会低,可以尝试 多线程 多几个端口同时发出
效果肯定是更好的,说到底还是 “只要将一个 SYN 送进 NAT” 就能成,多发点没坏最难的也不是 服务端 预测端口,但这个端口的范围肯定是越小越精确是越好的
最难的其实是 Step5 打洞这一步,这里并不仅仅只是 把数据包发送出去就完事了 这么简单, 而是端口的状态很难维持。
由于这个是套接字,端口会被绑定占用,本地想要快速发送,甚至可以很暴力的通过 Error
干掉线程 结束占用,然后快速的发送下一个数据包
但对于 NAT 来说就不是这样了,你要是过快,它可能还处于 SYN_SENT
状态,或者也极有可能处在 *_WAIT
状态
这时它就会给你绑定一个 新的端口 与远端建立连接,这个时候就已经失去意义了
又或者是有些 NAT,你与下一个端口建立连接了,他就不放行之前发送过/连接过的数据包了
就是 H 先给 J 发送,再给 K 发送,然后它就只允许 K 返回,J 返回的数据包直接丢弃
- 还有就是,什么时候将端口 由 Client 主动建立连接,转为 Server 来监听端口也是很关键
- 过早:还没打完洞 / 端口还被占用着
- 过晚:端口已经被弃了、别人的SYN 请求数据包已经发过了
- 甚至还有的 NAT 会将外部的主动发起的 SYN 给过滤掉,只允许同侧的、同区域的、甚至是同运营商的通过,这就让被动接收的可能性变得很小
总之,这些都是问题,我也只是在网络环境稳定,端口变化极小的情况下 (就是凌晨) 才得以成功,而且基本上也都还是要各种重连尝试,成功率很低很低,基本可以考虑放弃了。
但我很不爽,于是我又写了个 udp 的版本,当个爽局 (*^▽^*)^.^
这里再贴个 Github仓库
UDP 实现
采用 UDP 的话,对于 NAT3 端来说,可以说是毫无压力了呀
因为可以开着 “Server” 监听,然后一直往外发数据就完了,真就给对面 全端口 发数据包
能连进来的都加到一个队列里发心跳包,就可以一直维持了
sequenceDiagram participant L participant NL participant S participant NR participant R %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% R ->> +S : Step2: UDP - S:Z < NR:K < R:J Note right of S: NR:K
想要与 L 连接 L ->> S : Step1: UDP - L:A > NL:H > S:Z Note left of S: NL:H;注册信息 S-->> L: Step3 Note right of L: R请求连接;NR:K L -->> S: Step4 Note left of S: 同意R连接;开始打洞 S -->> -R: Note right of NR: NL:H;L 同意连接 %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% L ->> +NR: Step5: UDP - L:A > NL:H > NR:K-x L ->> NR: Step5: UDP - L:A > NL:H > NR:K±x L ->> NR: Step5: UDP - L:A > NL:H > NR:K+x R ->> NR: Step6: NR:? < R:U R ->> NR: Step6: NR:? < R:U R ->> NR: Step6: NR:? < R:U NR ->> -NL: Step6: UDP L:A < NL:H < NR:? < R:U NL ->> NL: If ? ∈ [NR:K-x, NR:K+x] NL ->> L: %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %% Note over L,R: Step8: TCP - L:B < NL:T < NR:? < R:U L-->>R: heart R-->>L: heart
- Step1-2: 同理,无关先后,交换信息,让 S 知道 NL:H 和 NR:K
- Step3: S 把 NR:K 带给 L
- Step4: L 表示同意连接,同时注意需要维护与 S 的心跳包,S 也通知 R 开始给 NL:H 发起连接
- Step5:
同意之后不需要等待S的回复,直接往 NR:K 附近的端口发数据包就行直接 全端口 发送数据包 (确信) - Step6: R 侧不断给 NL:H 发出数据包,直到有响应 则代表连接成功
不需要维护端口 套接字就是简单得多,监听端口的同时,只管发就行,不需要顾及 Timing
需要注意的是,维护与S的心跳包,可以让S主动给自己发送消息,比如说更为精确的洞口范围等等
例如极端点的,R 长时间连不上,就会给 S 发送消息,S 可以让 L 重新往新的 NR:K 发送信息
虽然说 NAT4 通常是在变端口,但也不能完全排除 IP 也在变的情况,恰巧就碰上了呢,有些 NAT 就是会一段时间过后,在 IP池 重新选一个当出口
最终实验的结果,一般是两到三次就能成功连上了,然后耗时大概在 3-5s 左右,还是可以接受的
最后还是建议直接 运行代码,实践才是硬道理
总结
极致的省流:
NAT3侧 尝试给对面全端口发送数据包,等对面来连
遇到 NAT4 不要用 TCP,改用 UDP~O(∩_∩)O 哈哈~
最后说几句
只能说,用 TCP 实现 NAT4 是真的很难,这个基本上就是瞎蒙,而且这还是 NAT3 - NAT4,还没到 NAT4 - NAT4
然后这个系列后面应该不会出 NAT4-NAT4 的了,主要是两个原因,一个是真的很难,就是碰运气
其次是意义不大,目前 NAT4 绝大多是 移动网络 里使用,就是手机开数据
其他的物联设备不太清楚,但物联设备也没有直连的意义,通常都是通过主机或是sink节点下发指令
我能想到的场景可能就是两台电脑都分别连上两个手机热点,然后两台电脑开始尝试直连…….
后续有时间可能会出个组网的,老规矩先挖坑 然后咕咕咕
毕竟大内网的情况下 NAT3 - NAT3 的场景还是居多,用 TCP 实现可以进行一些比如大文件的同步、传输等等
目前绝大多数 远程桌面控制 的软件都是基于UDP魔改的数据传输协议,比如向日葵的HSKRC这种,主要是提高成功率的同时,让 UDP 变得更可靠,同时也不至于像 TCP 那样 “太过可靠”
至于画面什么的可以流畅就行,UDP 本就很适合;指令的数据包很小,改用 TCP 甚至走转发都可以
就是首先能连上了,之后怎样传输 还是有很大的魔改的空间的嘛
最后还提一嘴就是,虽然 去NAT化 的政策已经下来,IPv6 的推进速度也在不断加快,但 v4 和 v6 共存依旧是我的观点,而且内网的概念,不管是 v4 还是 v6 都会一直在。所有设备全公网那是不现实、不可靠的,处在内网的设备 或是 处在网关内的设备 仍将是大部分。
既然说到 IPv6 了,感觉之后还可以谈谈我对 IPv6 的一些学习和理解,一直挖坑一直爽哈哈哈哈哈
完