CopySync

CopySync 的技术方案

多出口网络的 NAT 打洞

状态:已实现。多出口探测随 1.1.0 发布(2026-10-08);中转后换直连、连接失效检测与补发随 1.1.1 发布(2026-10-09);谁先发包随 1.1.2 发布(2026-10-09)

代码:client-core/internal/transport/p2p/egress*.go、manager.go、peer.go;补发在 client-core/internal/sync/backlog.go

验证:tools/natlab/(NAT 打洞实验室,CI 中运行)

一、问题

两台 Mac 配对成功,同步也正常,但设备列表里一直显示「中转」:数据全部经服务器转发,速度被服务器带宽卡在约 0.6 MB/s。

两台设备分别在公司和家里。逐一排查后确认,打不通的根源是公司网络有多个出口。

二、测量

公司这台

从同一个本地 UDP 端口,向分布在不同网络的多台 STUN 服务器发 Binding 请求,记录每台服务器看到的本机公网地址。实测有 4 个不同的出口 IP,下文以出口 A–D 代称(公网地址不公开):

出口落在这个出口上的服务器公网端口
出口 A自己的服务器(百度云北京)、Twilio与本地端口相同
出口 BB 站、Google、Nextcloud与本地端口相同
出口 CCloudflare、FreeSWITCH改写,但对不同目标相同
出口 D阿里云上的服务、有时是 Google(经 Wi-Fi 时)改写

另外两组对照实验:

结论:公司网络等于 4 个并列的、各自「端点无关映射」的 NAT,按目标 IP 选择走哪一个。单看每一条都很好打洞,难点只在于「发往对端的包走哪一条」事先不知道。

公司路由器不支持 UPnP 与 NAT-PMP,也没有 IPv6。

家里这台

项目结果
国内目标同一本地端口发往不同服务器,公网地址与端口都相同:端点无关映射,好打洞
国外目标经本机的代理出去,与 CopySync 无关
路由器支持 UPnP,但它自己的外网地址与服务器看到的地址不同:运营商还有一层 NAT,在路由器上映射端口没用
IPv6只有内网地址

旧版本为什么打不通

  1. 只问了一台服务器。客户端只从自己的服务器得知本机公网地址,即出口 A,也只把它告诉对端。发往对端的包按目标 IP 很可能从另外三个出口之一出去,对端收到的源地址与告诉它的不一致,被它的路由器当作陌生来源丢弃。
  2. 多配几台 STUN 也没用。pion 每问一台 STUN 服务器都单独开一个本地端口,问到的地址只对那个端口成立,互相用不上。

三、方案

1. 所有连接共用一个本地端口

p2p.Manager 启动时打开一个 UDP 端口,用 pion 的 UniversalUDPMux 包装,交给所有 PeerConnection(SettingEngine.SetICEUDPMux)。之后本机的所有连通性检查都从这个端口收发,于是「这个端口在每条线路上的公网地址」是一组固定、可知的值。

信令服务器下发的 STUN 地址不再交给 pion,改由本端在共享端口上探测;TURN 照常交给 pion 用于中转。打不开端口时退回 pion 的默认做法。

2. 探测每个出口

在共享端口上并发探测一组 IP 分散的服务器:信令服务器下发的 STUN,加上内置的公共服务器(B 站、Cloudflare、Twilio、Nextcloud、Google、FreeSWITCH)。实测 7 台服务器覆盖了公司全部出口。

3. 把所有出口地址告诉对端

每个出口的公网地址作为一条 srflx candidate,在 offer 或 answer 之后经信令发给对端,最多 8 条。对端为每条建立 candidate pair 做连通性检查,与实际出口吻合的那一条能打通。

4. 记住见过的出口

某一轮的探测服务器可能恰好都没落到某条线路上(实测出口 D 就时有时无)。本端按「本机各网卡的 IPv4 地址」区分网络,记下每个网络见过的出口 IP 及其是否改写端口,保存 7 天(~/Library/Application Support/CopySync/egress.json)。

不改写端口的线路,地址就是「出口 IP + 本地端口」,这一轮没探测到也可以推算出来补发给对端;会改写端口的线路无法推算,不补。

5. 优先直连

中转路径经服务器,握手往往比直连快。若不加约束,常常在直连打通之前就选中了中转,之后不再更换。中转路径要等一段时间才允许被选中(SetRelayAcceptanceMinWait):1.1.0 是 3 秒,1.1.1 放宽到 5 秒,见第 8 节。

6. 两端显示一致

只有发送方能确定自己的数据是否走 TURN:对端经中转发来的包,在本端看来可能只是一个普通的 peer-reflexive 地址。连接就绪后两端经控制通道交换 LinkInfo{relayed},任一端经中转,两端都显示「中转」(1.0.2 引入)。

7. 隐私开关与诊断

8. 落到中转后,再试直连(1.1.1)

问题。1.1.0 实机验收直连之后约 10 分钟,家里那台重启了后台服务,重连后变成了中转,之后一直是中转:

23:19:42  P2P 连接状态  direct
23:29:01  P2P 连接状态  offline      家里那台断开
23:29:06  P2P 连接状态  connecting   它重新上线,本端发起握手
23:29:10  P2P 连接状态  relay

这是一场赛跑。家里那台刚启动,要先探测完出口才能把出口地址发过来,而它访问部分公共 STUN 要经过代理,探测慢。出口地址到达之前,3 秒的中转等待时限已到,中转被选中。pion 选定 candidate 对之后不会再换,于是整个连接期间都走中转,哪怕直连其实打得通。

做法。

  1. 缩短探测、放宽等待:域名并发解析,整轮探测从约 1.6 秒降到 0.2 秒以内;中转等待从 3 秒放宽到 5 秒,比等待出口地址的上限(4 秒)长。
  2. 中转时用 ICE 重启再打一次洞:连接以中转就绪后,由发起连接的一方(不礼让方,两端规则一致,不会同时发起)安排一次尝试:先重新探测出口,再发一个 ICERestart 的 offer。两端重新收集地址、同时再打一次洞。打通就换成直连;仍打不通,还是落回中转,下次隔得更久再试:10 秒、30 秒、2 分钟、5 分钟、15 分钟,之后每 15 分钟一次。
  3. 只在空闲时重启:有数据流未关闭,或 10 秒内有过消息往来,就推迟。重启期间 pion 会清掉选中的 candidate 对,发出的数据暂时丢弃,由 SCTP 重传补上:连接不断、DTLS 与数据通道不变,只停顿几秒,但仍不该在传文件时做。

ICE 重启要处理的细节:

pion 4.4 也带有自动续提名(renomination),能不重启就换线路,但要求两端都开启,对端是旧版本时不起作用。ICE 重启是标准做法;对端是 1.1.0 时,按代码逻辑也能配合:它会把重启的 offer 当成新握手,状态短暂显示「连接中」,随后照常恢复。这一点没有单独测试,两台都升级最稳妥。

9. 发现失效的连接,补发没送达的内容(1.1.1)

问题。1.1.1 开发期间又遇到一次:家里那台断线后,本端一直以为连接还在,此后复制的 6 个文件全部「发」进了一条死连接,对方一个也没收到,日志却记着「内容已发送」。三处缺陷叠在一起:

  1. pion 报告连接「失败」或「已关闭」后,本端只把状态改成离线,没有关闭连接。Connect 看到连接对象还在就直接返回,重连巡检因此永远重连不上。
  2. 控制通道在连接失败后仍显示「打开」,消息照样写进去,发送不报错。
  3. 推送文件时,等发送缓冲排空的 60 秒超时到了也按成功处理。

做法。

10. 谁先发包(1.1.2)

问题。1.1.1 装上之后,本机重启时 1~3 秒就直连;家里那台一重启,就落到中转,之后几次 ICE 重启也换不回直连:

00:28:00.185  P2P 连接状态  connecting   家里那台刚重启上线
00:28:05.947  P2P 连接状态  relay
00:28:15.947  当前经中转,尝试换成直连  attempt=1
00:28:51.411  当前经中转,尝试换成直连  attempt=2
00:30:16.195  信令已连接                 本机重启
00:30:17.363  P2P 连接状态  direct

临时打开 pion 的 ICE 日志对比成功的那次:本机往家里那台的地址发出第一个包,65 毫秒后才收到对方的第一个包,本机先发。失败的几次正好相反:

根因。谁的包先到,谁的路由器就先收到一个「陌生来源」的包。公司路由器没有丢弃它,而是留下了一条连接记录,占住了「出口地址:端口 ↔ 对方地址:端口」这一对。本机随后往同一个对方地址发包时,源端口与这条记录冲突,路由器只好换一个端口;事先告诉对方的地址就对不上了,对方的包也一直撞在那条记录上。这条记录要在对方停止发包 30 秒左右后才过期,而每次重启对方又先发包,于是一直打不通。家里的路由器(运营商级 NAT)直接丢弃这类包,不受影响。

NAT 实验室起初没有复现,是因为模拟路由器都丢弃外来的新连接(第六节「踩过的坑」里那条),而且实验室里信令没有延迟,两端几乎同时发包。去掉公司侧路由器的丢弃规则、给信令加上 80 毫秒延迟后,旧代码稳定落到中转,后续的重启也都失败;连探测服务器看到的公网端口都被挤得变了,与实测公司网络里出口 C、D 端口忽变的现象一致。

做法。让「先发包」由规则决定,而不是靠运气:

代码位置

文件内容
p2p/egress.go探测、地址过滤、srflx candidate 生成
p2p/egress_history.go出口历史与推算
p2p/manager.go共享端口、探测时机、把地址交给每个连接
p2p/peer.go发送出口地址(含谁先发包)、中转等待、LinkInfo、ICE 重启换直连、失效连接的清理
sync/backlog.go逐台发送、待补发队列、连接恢复后补发
server/relay/turn.goTURN 可绑定指定 IP(ListenIP),实验室需要

四、能覆盖哪些场景

本端对端结果
单出口单出口直连
多出口(按目标 IP 选)单出口直连
多出口多出口直连(实验室验证;能否成立取决于两边线路组合,属于尽力而为)
任意端口随机分配的 NAT中转
任意UDP 被拦截中转
同一局域网—直连

只要多出口的一侧升级,就能与旧版本的对端直连:新增的只是标准的 srflx candidate,旧版本照常处理。

五、验证

单元测试与集成测试

p2p/egress_test.go:解析服务器地址;过滤污染地址;同一出口被多台服务器看到时归并;区分改写端口与不改写端口;按历史补出口、换网络不串用、过期失效;生成的 candidate 能被 pion 解析,且实测地址优先级高于推算地址。

p2p/egress_integration_test.go:起一台本机 STUN 服务,验证管理器在共享端口上完成探测,把出口地址作为 srflx candidate 发给对端,连接照常建立。

p2p/upgrade_test.go(1.1.1):ICE 重启期间两端状态一直是直连、重启中发出的消息照样送达;中转时发起方自己重启并改回直连,礼让方不主动重启;对端换了证书时本端重建连接(去掉这个处理,测试会卡在「连接中」);连接断开时发送报错。信令桩异步、不保序地投递,比真实服务器更苛刻。TestEgressOrder(1.1.2)核对出口地址的收发顺序:初次握手发起方先收到对方的再发自己的,请应答方等的重启反过来;去掉等待,这个测试会失败。

sync/backlog_test.go(1.1.1):连不上的设备在连接恢复后按复制先后收到补发、带补发标记,已送达的不重复;太旧的、已删除的不补发,补发途中又断开的留到下次;收到补发的文件不写进剪贴板。

NAT 打洞实验室

tools/natlab/run.sh 用 Linux 网络命名空间与 iptables 搭出真实拓扑,client-core/cmd/natlab 用 CopySync 真实的 P2P 代码扮演两台设备:

[a] 192.168.10.2 ── [rA] 出口 10.255.0.11–14 ──┐
                                               [inet] 信令  TURN  STUN×4
[b] 192.168.20.2 ── [rB] 出口 10.254.0.21–22 ──┘

rA 用 SNAT 规则按目标地址选出口,还原公司网络;rB 还原家用路由器或端口随机分配的 NAT。两台路由器都丢弃外网主动发起的新连接,与真实路由器一致(原因见「踩过的坑」)。

场景预期结果
单出口 ↔ 家用路由器直连通过
两个出口,旧版本(对照)中转通过:复现了原问题
两个出口 ↔ 家用路由器直连通过
四个出口 ↔ 家用路由器直连通过
两个出口,第二条没探测到,无历史中转通过
两个出口,第二条没探测到,有历史直连通过
两个出口 ↔ 两个出口直连通过
两个出口 ↔ 端口随机分配的 NAT中转通过
一开始打不通,8 秒后网络恢复(1.1.1)先中转,随后换成直连通过
公司路由器会为外来的包占住端口,信令延迟 80 毫秒(1.1.2)直连(初次握手即打通)通过;1.1.1 在这里落到中转,重试也失败
家用路由器会为外来的包占住端口,信令延迟 80 毫秒(1.1.2)先中转,第一次重试换对方先发包后直连通过

最后一个场景让家用路由器开头 8 秒不放行发往公司出口的 UDP,第一次握手只能落到中转;放开后,脚本还要求日志里确实出现了「中转 → 尝试换成直连」,排除一开始就碰巧直连的情况。

本地运行:./tools/natlab/docker.sh(macOS 上在特权容器里跑;国内网络加 APK_MIRROR=mirrors.aliyun.com)。CI 的 natlab 任务在每次提交时运行。

实机验收

公司这台(4 个出口)升级到 1.1.0,家里那台仍是 1.0.2:

23:19:40.083  信令已连接
23:19:41.718  出口探测完成  B:64778  C:17820  A:64778
23:19:42.884  P2P 连接状态  direct

连上信令后约 3 秒建立直连。改造前同一对设备只能中转。

公司这台升级到 1.1.1 之后:

00:12:14.666  信令已连接
00:12:14.837  出口探测完成  C:19735  B:49454  D:60103  A:49454
00:12:18.127  P2P 连接状态  direct

探测在连上信令后 0.17 秒完成(1.1.0 约 1.6 秒),4 条线路全部探到。

六、踩过的坑

七、局限与后续