跨电脑复制共享-WebRTC 打洞踩坑

网址:baiyuze.github.io/copysync/

最近在写一个自用的小工具 CopySync,干的事很简单:这台电脑复制,那台电脑粘贴,文字、截图、文件都行。我公司和家里各一台 Mac,Apple ID 不一样,系统自带的通用剪贴板用不了,就自己撸了一个。后来顺手把 Windows 也支持了。

技术上是 Go 写后台服务,Flutter 写界面,两台电脑之间走 WebRTC 直连(库是 pion),打不通再走我自己服务器上的 TURN 中转。服务器是台 5M 带宽的小机器。

东西跑起来以后,有个问题一直没解决:公司那台和家里那台,设备列表里永远是「中转」,从来没直连过。

中转意味着流量全绕服务器,速度卡在 0.6 MB/s 左右,复制个几十兆的文件得等一分多钟。同一个局域网里的两台是直连的,所以我怀疑问题出在公司网络上。

这篇记一下排查过程。前后改了三个版本才弄干净,中间有两个坑我觉得挺有意思。

先量一下公司网络

WebRTC 打洞靠 STUN:问一台公网服务器「你看到我的地址是多少」,把这个地址通过信令告诉对方,然后双方同时往对方的地址发包。所以第一步是看看公司网络在 STUN 服务器眼里长什么样。

我从同一个本地 UDP 端口,挨个去问一圈不同网络里的 STUN 服务器,结果有点意外:不同服务器看到的公网 IP 不一样,一共 4 个。

又换着测了几次:往同一台服务器的 8 个不同端口发,全部从同一个出口出去;同一个本地端口发往 Cloudflare 和 Twilio,看到的是出口 C 上的同一个端口。也就是说,公司路由器按目标 IP 决定走哪条线,而每条线自己都是很规矩的 NAT,单拿出来都好打洞。坑在于你不知道发给对面的包会走哪一条。

我的客户端只问了自己的服务器,拿到的是出口 A 的地址,也只把这一个告诉了家里那台。可往家里发包的时候,路由器看的是家里的 IP,八成走了另外三条之一。家里收到一个陌生地址发来的包,直接丢了。

第一反应是多配几个 STUN 服务器,让 pion 多收集几个地址。试了,没用。看了下 pion 的实现,它每问一台 STUN 服务器都会单独开一个本地端口,问回来的地址只对那个端口有效,几个结果互相用不上,等于白问。

改成共用一个端口

那就反过来:所有连接都从同一个本地端口收发,我自己在这个端口上把每个出口都探一遍,再把这些地址全部告诉对面。

pion 的 ice 包里正好有 UniversalUDPMux,能把一个 UDP socket 交给所有 PeerConnection 共用,它还自带一个 GetXORMappedAddr,可以直接在这个 socket 上做 STUN 探测:

go 复制代码
conn, _ := net.ListenUDP("udp4", &net.UDPAddr{})
mux := ice.NewUniversalUDPMuxDefault(ice.UniversalUDPMuxParams{UDPConn: conn})

settings := webrtc.SettingEngine{}
settings.SetICEUDPMux(mux) // 所有连接都走这个端口

// 在同一个端口上问 STUN,问到的地址对所有连接都成立
addr, err := mux.GetXORMappedAddr(stunAddr, 1500*time.Millisecond)

STUN 服务器挑了一组 IP 分散的,自己的服务器加上 B 站、Cloudflare、Twilio 这些公共的,并发去问,按 IP 去重,7 台就能把公司的 4 个出口都覆盖到。

探到几个出口,就拼几条 srflx candidate,跟在 offer/answer 后面经信令发给对面:

xml 复制代码
candidate:<foundation> 1 udp <priority> <出口IP> <端口> typ srflx raddr 0.0.0.0 rport <本地端口>

对面会对每一条做连通性检查,和真实出口对得上的那条就通了。会改写端口的 C、D 也不碍事,探测拿到的就是这个端口在那条线上的真实映射,不用猜。

中间还被 DNS 污染坑了一下:stun.syncthing.net 在我这儿被解析成 192.0.2.42,代理软件的 fake-ip 也会混进来 198.18 开头的地址。这些得先扔掉,不然要么白等超时,要么被当成一个出口发给对面。

改完再看日志:

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

终于直连了,从连上信令到直连不到 3 秒。

不过这一轮 D 没探到,有时候就是没有服务器恰好落在那条线上。所以又加了个出口记忆:见过的出口记下来,不改端口的那种,下次没探到也能用「出口 IP + 本地端口」补上。改端口的就没法推算了,只能等下一轮。

还有个小调整:中转的握手往往比直连快,不拦着的话经常直连还没打通就选中了中转,所以中转路径要等几秒才允许被选。

以为好了,其实没有

高兴了没多久。家里那台一重启,又回到了中转,而且怎么重试都回不来。反过来公司这台重启,一两秒就直连。

makefile 复制代码
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 毫秒后才收到家里的包;失败的几次都是家里先发。

后来想明白了,是公司路由器的锅。家里的包先到的时候,路由器没有直接丢掉,而是给这个陌生来源记了一条连接记录,把那个端口占住了。等公司这台再往家里发,端口冲突,路由器只好换个端口发出去,之前告诉家里的地址就对不上了,家里的包也一直撞在那条记录上。家里是运营商级 NAT,这种包直接丢,所以没这毛病。

那为什么取决于谁重启?家里刚启动时,公司这台的出口地址是现成的,跟着 offer 就发过去了,家里拿到马上开始发包;公司这台得等家里的 answer 绕服务器一圈回来才能发,天然慢一拍。反过来公司这台重启时,出口要现探,地址发得晚,它反而成了先发包的那个。

解决办法挺土的,定个顺序:让一方先憋着不公布自己的出口地址,等收到对方的、自己先往那边发了包,再公布。这样它的路由器上先有一条自己发起的记录,对面的包回来正好对上。初次握手让发起方憋着;可我也不知道别人家是哪一边的路由器有这毛病,所以后面的重试两边轮流憋,最多重试一次就能避开。

pion 选定了就不换

上面日志里那个「尝试换成直连」也是这一轮加的。pion 一旦选定 candidate pair 就不会再换,一次握手输给了中转,整个连接期间都是中转。所以落到中转以后,由发起方挑个空闲的时候(没在传文件,10 秒内没有消息往来)做一次 ICE restart 重新打洞,打通就切到直连,打不通就隔久一点再试,最长 15 分钟一次。

ICE restart 有两个地方我实际撞到了。一是生成 offer 的时候 pion 就开始收集新的 candidate,可能抢在 SDP 前面发出去,对面手里还是旧的远端描述,ufrag 对不上就直接扔了,所以得先攒着,等 SDP 发出去再发。二是信令本身不保证顺序,接收端也要把 ufrag 对不上的 candidate 先缓存起来,等新的远端描述设好了再加。

顺手搭了个打洞实验室

这种问题靠两台真机复现太折磨了,每次都得两台机器配合着重启、翻日志。后来用 Linux 的网络命名空间加 iptables 搭了个实验室,Mac 上放在 Docker 里跑:

rA 用 SNAT 规则按目标地址选出口,模拟公司网络;rB 模拟家用路由器,或者端口随机分配的那种 NAT。两端跑的是 CopySync 真实的连接代码,现在有 11 个场景,每次提交都在 GitHub Actions 里跑一遍:

场景 结果
单出口 ↔ 家用路由器 直连
两个出口,旧版本(对照) 中转
两个出口 / 四个出口 ↔ 家用路由器 直连
两个出口,第二条没探到,没有记忆 / 有记忆 中转 / 直连
两个出口 ↔ 两个出口 直连
两个出口 ↔ 端口随机分配的 NAT 中转(这种本来就打不通)
一开始打不通,之后网络恢复 先中转,随后直连
公司路由器会占端口 直连
家用路由器会占端口 先中转,重试一次后直连

搞笑的是实验室刚搭好的时候,连最简单的「单出口 ↔ 家用路由器」都打不通。查了半天,是模拟路由器太宽松:对面的包被放进了路由器本机,Linux 的 conntrack 给它建了条未应答的记录,本端再往外发时 SNAT 就换了端口。真实路由器的防火墙会直接丢掉这类包,加条 DROP 规则就好了。

后来回头看,「谁先发包」那个问题其实就是同一个机制,只不过发生在真实的公司路由器上。为了在实验室里复现它,我又反过来专门做了一种不丢包、会占端口的路由器,再给信令加上 80 毫秒延迟。旧代码在这个场景下稳定落到中转,修完之后直连。

还有一个比较丢人的 bug

1.1.0 发出去之后,有一次家里那台断了下网,之后我在公司复制了 6 个文件,那边一个都没收到,日志里却写着「内容已发送」。

原因是 pion 报告连接 failed 之后不会自己关掉连接,DataChannel 的状态还是 open,往里写也不报错。我这边只是把状态标成了离线,连接对象还留着,重连逻辑一看「连接还在」就直接返回了,于是永远连不回去。等发送缓冲排空的地方还有个 60 秒超时,超时了也当成功处理。几个问题叠在一起,文件就这么全发进了一条死连接。

现在连接一进 failed 或 closed 就关掉重建;没送到的内容记在每台设备的待补发队列里,连上以后补发。补发的只进对方的复制记录,不去覆盖对方当前的剪贴板,毕竟那边的人可能刚复制了别的东西。


代码在这:github.com/baiyuze/cop... ,Mac 和 Windows 都能用,服务器得自己搭,一台小云主机就够,两台电脑在同一个局域网的话直接跑在其中一台 Mac 上也行。完整的测量数据和设计过程我整理在这里:baiyuze.github.io/copysync/de...

如果你的网络也打不通直连,可以把 copysync-cli nat 的输出贴到 issue 里,我挺好奇还有哪些网络是现在这套办法覆盖不到的。

相关推荐
Amos_Web1 小时前
Rspack 源码解析(十八):Tree Shaking 如何用 SideEffects 重写模块连接
前端·rust·前端框架
百度一下吧1 小时前
前端包管理工具和使用手册
前端
ServBay1 小时前
如何用 AI Agent 构建全栈应用(2026版)
后端·ai编程
黎燃1 小时前
我给自己写了一个 mini OpenRouter:基于蓝耘 MaaS 的多模型路由网关实战
后端
leobertlan1 小时前
痛苦系列 | DSP-01 从连续到离散:DSP基础与采样
android·后端
高频因子挖掘机1 小时前
同一只股票前复权和不复权价格对不上?先检查这几个口径
后端·github·api
anxiao_m2 小时前
水利数字孪生怎么选?主流可视化渲染平台深度横向测评
大数据·前端·人工智能·图形渲染·云渲染
kill5222 小时前
useRef
前端·javascript·react.js
蓝悦无人机2 小时前
从“摸形状“到“点到线面的距离“——聊聊 SLAM 前端(激光雷达篇)
前端·loam·数据关联·激光slam·点云特征提取·点到线面距离·icp/ndt