吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 30|回复: 0
上一主题 下一主题
收起左侧

[其他原创] 【含AIGC】NAT 后面的两台设备怎么直连:UDP 打洞原理与 Go 实现,附 P2P 工具

[复制链接]
跳转到指定楼层
楼主
s3rious74 发表于 2026-11-10 16:26 回帖奖励

声明:本文的部分代码和文字借助 AI 辅助完成,数据和截图均为本人实测。水平有限,如有错误或不严谨之处,恳请各位指出斧正,谢谢!

前言

故事的起因是:我看到同事在用 UU 远程,而且是免费的。这让我感到挺有意思的,远程桌面要实时传画面,按理说很吃服务器带宽,凭什么它这么流畅,还不收钱

后来我 Google 查资料,又借助 AI 一起分析,才知道 UU 远程用的是 P2P 打洞:两台电脑能直连时,画面直接点对点传,不经过它的服务器;只有打不通时,才退回它的中转节点。服务器大部分时候只负责「牵线」,流量成本自然低得多,我猜这也是它敢免费的原因之一。

直连和中转差多少,我后来自己实测过:两台家宽电脑之间用 ssh 管道传 100MB,走中转(打洞失败,退回服务器转发)只有 3.4 Mbps,被服务器 3M 的出口带宽卡死;打洞直连跑到 167 Mbps,快了将近 50 倍,而且不花服务器一分流量。

弄明白这一点之后,我决定自己从头学一遍 P2P 打洞,并且要做出一个真正能用的成果:两台都在 NAT 后面的设备,打洞直连传文件。核心问题是:两台设备都在路由器(NAT)后面,谁也没有公网 IP,怎么直连?答案就是 UDP 打洞。这篇文章就是这一路的整理:先讲原理,再给最小实现,最后附上做好的工具。

实验环境:

  • 系统和工具:CachyOS(Arch 系 Linux),编辑器 LazyVim,代码用 Go 1.26 写
  • 网络:家宽 PC ↔ 电信 5G 手机(手机在运营商的 CGNAT 后面)
  • 服务器:一台阿里云 ECS,出方向只有 3Mbps,只用来做会合和打不通时的中继兜底

1. 基础:NAT 是一张表

家里的电脑 10.0.1.2:5000 往外发一个 UDP 包,路由器把源地址改成自己的公网地址,比如 203.0.113.1:40000,并在表里记一行:

内网 10.0.1.2:5000  ⇄  公网 203.0.113.1:40000     放行:发去过的地址

外面的包打到 203.0.113.1:40000 时,路由器查表:有对应的行就转给内网,没有就丢掉。

NAT 之间的差别就在这张表的两个细节上:一个是映射,一个是过滤。简单来讲,同一个内网端口发往不同的目的地,公网端口不变就是锥形,变了就是对称型,这个就是不同的映射类型。另外一个,如果针对外来的数据包,不筛选ip和端口,只筛选ip,同时筛选ip和端口分别对应不同的nat类型 nat1,na2,nat3 对称型就是nat4,这种型最难打通。

国内家宽路由器和手机 4G/5G 绝大多数是 NAT3:映射不变,过滤最严,要核对 IP 和端口。好消息是 NAT3 和 NAT3 之间能打通。

打洞要用到三个角色:

角色 干什么 必须有吗
STUN 一面镜子:告诉你「我看到你的公网地址是 X」 要有(或者由信令服务器顺手做)
信令 传话:把双方的公网地址转告给对方 必须有
中继(TURN) 打不通时帮你转发所有数据 兜底用

前两个只在建立连接的头几百毫秒用到,之后数据直连。所以一台很小的服务器就够用。

2. 讨论原理:打洞四步

  1. 注册:A、B 分别向服务器发一个包。服务器看到的源地址,就是 NAT 改写后的公网地址。
  2. 交换地址:服务器把 B 的公网地址告诉 A,把 A 的告诉 B。
  3. A 往 B 的公网地址发包:包出 NAT A 时,NAT A 记下「放行 B」。包到了 NAT B,B 的表里没有「放行 A」,丢弃。
  4. B 往 A 的公网地址发包:NAT B 记下「放行 A」。包到了 NAT A,正好匹配第 3 步开的门,进来了。A 回一个包,NAT B 也认,洞就通了。

两个要点:

  • 自己发的包是给自己开门的,真正穿过去的是对方的包。所以两边都要发,谁先谁后无所谓,第一个包被丢也没关系。
  • 注册和打洞必须用同一个 socket。换一个 socket 就换了本地端口,NAT 会新开一行映射,服务器告诉对方的地址就不再指向你了。打通之后,传数据也要继续用这个 socket。

什么时候打不通:两边都是对称型(NAT4 ↔ NAT4),或者一边对称、一边 NAT3。对称型 NAT 往不同的目的地发包会换端口,服务器看到的端口对别人无效。这时只能走中继。实际遇到的不多,但工具必须兜底。

3. 架构

           ┌──────────── 会合服务器(一台小 VPS)────────────┐
           │  UDP 7001:STUN + 信令(镜子 + 传话)            │
           │  UDP 3478:中继(打不通时兜底)                  │
           └───────────▲──────────────────────▲──────────────┘
                       │ 注册 / 拿对方地址      │
   ┌───────────────────┴───┐            ┌─────┴─────────────────┐
   │ 发送端                 │            │ 接收端                 │
   │ ① 会合 + 打洞(rdv.go)│  打通的洞  │ ① 会合 + 打洞          │
   │ ② QUIC 接管同一个 socket│◀═════════▶│ ② QUIC 接管同一个 socket│
   │ ③ 房间密钥认证(auth.go)│  直连 UDP  │ ③ 房间密钥认证          │
   │ ④ 传文件 / 端口转发     │            │ ④ 收文件 / 暴露端口     │
   └───────────────────────┘            └───────────────────────┘

打通的只是一条「能互发 UDP 包」的路。UDP 会丢包、乱序、没有加密,所以在这条路上跑 QUIC:可靠传输、拥塞控制、TLS 1.3 加密、一条连接里多路并发的流,全都有了。

下面按这个顺序看代码。完整代码在文末的仓库里,这里贴的是去掉错误处理之后的核心部分。

4. 代码

4.1 STUN:20 字节的镜子

STUN 请求只有一个 20 字节的头:

0x0001 (Binding Request) | 长度 0 | 魔数 0x2112A442 | 12 字节随机事务 ID

服务器的回应带一个 XOR-MAPPED-ADDRESS 属性,内容是「我看到的你的 IP:端口」。服务端实现就这么几行:

// bindingResponse 回应 req,告诉对方:我看到你的地址是 from
func bindingResponse(req []byte, from *net.UDPAddr) []byte {
 ip := from.IP.To4()
 b := make([]byte, 32)
 binary.BigEndian.PutUint16(b[0:], 0x0101) // Binding Success Response
 binary.BigEndian.PutUint16(b[2:], 12)     // 属性总长
 copy(b[4:20], req[4:20])                  // 原样带回魔数和事务 ID
 binary.BigEndian.PutUint16(b[20:], 0x0020) // XOR-MAPPED-ADDRESS
 binary.BigEndian.PutUint16(b[22:], 8)
 b[25] = 0x01 // IPv4
 binary.BigEndian.PutUint16(b[26:], uint16(from.Port)^uint16(magicCookie>>16))
 binary.BigEndian.PutUint32(b[28:], binary.BigEndian.Uint32(ip)^magicCookie)
 return b
}

为什么地址要和魔数异或?有些路由器的 ALG 功能会检查包里出现的自家公网 IP 并「好心」改掉,异或之后它就认不出来了。客户端收到后再异或回来即可。

公共 STUN 服务器很多(stun.miwifi.com:3478、stun.chat.bilibili.com:3478 等),用同一个 socket 问两三个不同的服务器,比较返回的端口:一样就是锥形,不一样就是对称型。lab/stunprobe 用 150 行做了这件事,可以先拿它测测自己的网络。

4.2 信令服务器:记下地址,凑齐一对就互相转告

信令服务器收到注册包时,源地址就是 NAT 改写后的公网地址,所以它顺手就把 STUN 的活干了。核心逻辑:

// 报文:→ /R2 <房间号> <id> <内网候选> <标志>
//       ← /Y <你的公网地址>
//       ← /P <对方 id> <对方公网地址> <对方内网候选> <标志>
func serve(conn *net.UDPConn) {
 rooms := map[string]map[string]*member{} // 房间号 → id → 成员
 buf := make([]byte, 1500)
 for {
  n, from, _ := conn.ReadFromUDP(buf)
  f := strings.Fields(string(buf[:n]))
  if len(f) != 5 || f[0] != "/R2" {
   continue
  }
  room, id, lan := f[1], f[2], f[3]
  conn.WriteToUDP([]byte("/Y "+from.String()), from) // 镜子:告诉它我看到的地址

  if rooms[room] == nil {
   rooms[room] = map[string]*member{}
  }
  rooms[room][id] = &member{pub: from, lan: lan, seen: time.Now()}
  for oid, o := range rooms[room] {
   if oid == id || time.Since(o.seen) > 30*time.Second {
    continue
   }
   // 双向转告:两边几乎同时拿到对方地址,同时开始打洞
   conn.WriteToUDP([]byte(fmt.Sprintf("/P %s %s %s -", oid, o.pub, o.lan)), from)
   conn.WriteToUDP([]byte(fmt.Sprintf("/P %s %s %s -", id, from, lan)), o.pub)
  }
 }
}

几个细节:

  • 内网候选:客户端把自己网卡的地址也报上来。两台机器在同一个局域网时,直接走内网地址更快。
  • 回包要从收到注册的那个端口发出去。服务器听多个端口时,从别的端口回包,过不了对方 NAT 的过滤(NAT3 要核对端口)。
  • 完整版(lab/punch/main.go 的 runServer)还做了按 IP 限速、过期清理、多端口共享房间,以及中继。

4.3 打洞:每 20ms 往所有候选发探测

客户端拿到对方的候选地址(公网地址 + 内网地址)后,同时往所有候选发探测包,谁先回应就用谁:

// 收:对方的探测进来就回应;收到回应就是打通了
go func() {
 triggered := map[string]bool{}
 for {
  n, from, err := c.ReadFromUDP(buf)
  if err != nil {
   return
  }
  switch string(buf[:min(n, 3)]) {
  case "/PP": // 对方的探测
   c.WriteToUDP(pktAck, from)
   if !triggered[from.String()] { // 触发检查:立刻反向探测一次,不等下一个 20ms
    triggered[from.String()] = true
    c.WriteToUDP(pktPunch, from)
   }
  case "/PA": // 对方的回应:这条路通了
   choose(c, from)
   return
  }
 }
}()

// 发:每 20ms 往所有候选发一轮探测,4 秒还不通就换端口重来
tick := time.NewTicker(20 * time.Millisecond)
for {
 for _, a := range peerCands {
  c.WriteToUDP(pktPunch, a)
 }
 select {
 case <-done:
  return &session{conn: c, peer: chosen}, nil
 case <-tick.C:
 }
 if time.Now().After(deadline) {
  return nil, errPunchTimeout
 }
}

实测这两个细节很值:探测间隔从 200ms 改成 20ms,再加上触发检查,家宽 ↔ 5G 手机的打洞耗时从 250ms 降到 88ms,基本就是两端开始时刻之差加一个 RTT。

「4 秒不通就换端口重来」也不是多余的。有些 NAT 在对方的包先到时会留下冲突状态,这一对端口就废了,换一对新端口就好。连续两轮打不通,就改走中继。

4.4 QUIC 接管同一个 socket

打通的 socket 原样交给 QUIC(用的是 quic-go):

tr := &quic.Transport{Conn: s.conn} // 就是刚才打洞的那个 socket

// 发送端
conn, err := tr.Dial(ctx, s.peer, tlsConf, quicConf)
// 接收端
ln, err := tr.Listen(tlsConf, quicConf)
conn, err := ln.Accept(ctx)

有一个坑:QUIC 接管 socket 之后,对方迟到的探测包、会合服务器的回执也会进到这个 socket,不能让它们把 QUIC 搞乱。quic-go 的规矩是:首字节 0x40 那一位为 0 的包不是 QUIC 包,交给 ReadNonQUICPacket。所以我把所有打洞和信令报文都设计成以 /(0x2f)开头:

// QUIC 管不着的包:回应迟到的探测,信令报文转给连接迁移用
for {
 n, from, err := tr.ReadNonQUICPacket(ctx, buf)
 if err != nil {
  return
 }
 if strings.HasPrefix(string(buf[:n]), "/PP") {
  tr.WriteTo(pktAck, from)
 }
}

KeepAlivePeriod 要设:NAT 的映射静默一段时间就会过期(我测的家用路由器是 60~90 秒),QUIC 每 5 秒一个保活包,洞就一直开着。

4.5 认证:房间名就是密钥

两端用同一个「房间名」配对。房间名不能明文出现在线路上,所以:

  • 报给会合服务器的是房间号 HMAC(密钥, "rendezvous"),服务器和它的日志都看不到房间名;
  • QUIC 证书是自签的、不校验,握手之后两端再各证明一次「我知道密钥」:HMAC(密钥, 角色 ‖ EKM)。EKM 是从这次 TLS 握手导出的密钥材料,每次握手都不同。中间人两头各握一次手,两边的 EKM 不一样,转发对方的证明也对不上。这叫通道绑定,不需要 CA 证书也不怕中间人。
ekm, _ := conn.ConnectionState().TLS.ExportKeyingMaterial("p2pcp auth", nil, 32)
proof := func(role string) []byte { return mac(secret, []byte(role), ekm) }
// 客户端先发 proof("client");服务端验过之后才回 proof("server")

5. 工具:p2pcp

上面这些合起来就是 p2pcp。需要 Go 1.26,纯 Go、没有 cgo,Linux、macOS、Android(arm64)都能直接交叉编译。windows不行。

git clone https://github.com/numb747/uu-remote-study.git && cd uu-remote-study/lab
go build ./p2pcp ./punch
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build ./p2pcp   # 比如放到手机上用 adb 跑

两个子命令最常用:

传文件

# 接收端
p2pcp recv -server 你的服务器:7001 -room 一个别人猜不到的房间名 -o ~/下载
# 发送端
p2pcp send -server 你的服务器:7001 -room 同一个房间名 大文件.zip 另一个文件

断了自动重连续传,传完校验 sha256。

实拍:左边是电脑(家宽),右边是用 adb 连着的手机(电信 5G,在运营商的 CGNAT 后面),两边各敲一条命令,72ms 打通,之后数据直连:100MB 用了 6 秒,平均 139.6 Mbps,峰值 172 Mbps,判定丢失 0。服务器地址放在 ~/.config/p2pcp/server 里(手机上用环境变量 P2PCP_SERVER),所以命令里不用写 -server。

端口转发(比如让 ssh / rsync 走打洞直连)

# 远端:把本机 22 端口暴露出去
p2pcp expose  -server 你的服务器:7001 -room 房间名 -to 127.0.0.1:22
# 本机:本地 2223 端口转发到远端的 22
p2pcp forward -server 你的服务器:7001 -room 房间名 -listen 127.0.0.1:2223
ssh -p 2223 user@127.0.0.1

每个 TCP 连接对应 QUIC 里的一条流。打不通时自动走中继,后台继续打洞,打通了就把连接迁过去,ssh 会话不断。

部署会合服务器:任何一台有公网 IP 的小 VPS,安全组放行 UDP 7001 和 3478:

punch server -listen :7001 -relay :3478

网络体检:连不上时先跑 p2pcp check,它会测会合端口通不通、NAT 映射行为、有没有 IPv6、中继能不能用。

两个会合端口都通;同一个 socket 换个端口再问一次,映射没变(锥形 NAT,不是对称型);中继也可用。

实测(家宽 PC ↔ 电信 5G 手机):

项目 结果
打洞耗时 40~100ms
PC → 手机传文件(100MB) 85~140 Mbps,随 5G 基站负载波动
手机 → PC 传文件(100MB) 约 40 Mbps,卡在 5G 上行
ssh 管道(家宽 ↔ 家宽) 167 Mbps,中继只有 3.4
手机连续换三次网络(端口转发) 三次都迁移成功,上面的 TCP 会话没断

日常我用的是包了一层的版本:电脑端的右键菜单「P2P 直传到手机」加一个安卓 App(仓库里的 apps/),手机开着 App 就能收。下图是电脑把这篇文章的动图发到手机上:

6. 几个踩过的坑

  • 开着 TUN 模式的网络工具(会创建虚拟网卡、接管全部流量的那类):程序拿到的「本机地址」是虚拟网卡的,打洞包也可能被它接走。加 -bind auto,绑定到物理网卡。
  • 运营商对个别端口有特殊处理:我遇到过会合服务器用 UDP 7000 时,电信某台 CGNAT 后面全部打不通,换成 7001 立刻好了。所以 -server 可以写多个地址,轮着试。
  • 保活:映射寿命短的只有 60 秒,连接上至少每 30 秒要有一个包。
  • NAT4 ↔ NAT4 打不通是原理决定的,只能中继。中继的速度取决于服务器带宽。

7. 代码

https://github.com/numb747/uu-remote-study

  • lab/p2pcp:本文的工具
  • lab/punch:会合服务器 + 最小打洞 demo(打通后能互发文字)
  • lab/stunprobe:测自己的 NAT 类型
  • lab/netns:免 root 的 NAT 实验室,想在本机复现各种 NAT 之间的打洞情况,可以用它

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-11-11 06:23

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表