前言

这篇讲的主要是在原来的基础上,对于 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
2
3
L:A - NL:H - S:Z		// S的 Z 端口
L:A - NL:H - R:U // R主机的 U 端口
L:A - NL:H - R:V // R主机的 V 端口
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
2
3
L:A - NL:H - S:Z		// S的 Z 端口
L:A - NL:G - R:U // R主机的 U 端口
L:A - NL:F - R:V // R主机的 V 端口
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

所以可以大致的认为:

  1. 处于NAT3的主机,本地端口不变,NAT的出端口也不变
  2. 处于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:VH/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:HNR全端口 都发送了 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: LR 建立起与 S 的连接 用于交换信息,并且双方表示连接的意愿(后续可以以此来组网)这时服务端还可以充当 STUN 来判断他们所属的 NAT网络
  • Step3: S 判断需要打洞的一方(这里是L),并将请求连接的消息传给 L
  • Step4: L 将 “同意连接 表示可以打洞” 的信息,用新的端口 L:BS 建立连接,并告知 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
    • 如果 ? ∉ "需要打洞的端口",则任意一方都 没有 任何的回应

主要问题

说了这么多,但这些都只是给大家提供的 实现思路 ,虽然我确实也 成功实现 并建立连接了

但真实的网络环境要复杂的多的多的多,举几个实现过程中可能遇到的问题:

  1. Step8 中尝试建立连接,单一一条数据包成功率的肯定会低,可以尝试 多线程 多几个端口同时发出
    效果肯定是更好的,说到底还是 “只要将一个 SYN 送进 NAT” 就能成,多发点没坏

  2. 最难的也不是 服务端 预测端口,但这个端口的范围肯定是越小越精确是越好的

  3. 最难的其实是 Step5 打洞这一步,这里并不仅仅只是 把数据包发送出去就完事了 这么简单, 而是端口的状态很难维持。

由于这个是套接字,端口会被绑定占用,本地想要快速发送,甚至可以很暴力的通过 Error 干掉线程 结束占用,然后快速的发送下一个数据包

但对于 NAT 来说就不是这样了,你要是过快,它可能还处于 SYN_SENT 状态,或者也极有可能处在 *_WAIT 状态
这时它就会给你绑定一个 新的端口 与远端建立连接,这个时候就已经失去意义了

又或者是有些 NAT,你与下一个端口建立连接了,他就不放行之前发送过/连接过的数据包了
就是 H 先给 J 发送,再给 K 发送,然后它就只允许 K 返回,J 返回的数据包直接丢弃

  1. 还有就是,什么时候将端口 由 Client 主动建立连接,转为 Server 来监听端口也是很关键
    • 过早:还没打完洞 / 端口还被占用着
    • 过晚:端口已经被弃了、别人的SYN 请求数据包已经发过了
  2. 甚至还有的 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:HNR: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 的一些学习和理解,一直挖坑一直爽哈哈哈哈哈