跨电脑复制共享-局域网直连踩坑

上一篇:跨电脑复制共享-WebRTC 打洞踩坑

网址:baiyuze.github.io/copysync/

Github:github.com/baiyuze/cop...

上一篇聊的是公司和家里两台电脑怎么打洞,最后我随口说了句「服务器得自己搭」。后来越想越觉得这句话挺劝退的。你想啊,很多人的两台电脑就在同一个路由器底下,就为了复制粘贴,还得先找台常开的机器装个服务,谁愿意折腾这个。

所以 1.4.0 我干脆加了个局域网模式,同一个网络里两边装上就能配对,服务器不要了。

一开始我觉得这事挺简单的,不就是 mDNS 发现一下,然后直连嘛。结果真做起来,大部分时间都耗在了 Windows 防火墙和 macOS 的本地网络权限上,坑一个接一个。这篇就聊聊这些。

服务器原来都干了啥

先捋一下,服务器原来管的事其实就几样:告诉你谁在线、在哪个地址;帮两边转发连接要用的信令;配对的时候拿配对码换对方的公钥;还有打洞用的 STUN 和中转用的 TURN。

拿掉以后,谁在线用 mDNS 广播,信令两台电脑之间直接用 UDP 发,配对码在局域网里直接交换。STUN、TURN 就用不着了,同一个局域网里本来就能直接连。

安全上我倒没太担心,因为当初压根就没信任过服务器。每条信令都签了名,配对时两边还要人工对一下指纹,服务器能看、能丢,但改不了,也冒充不了谁。所以把转发的从服务器换成局域网,其实没啥区别。

不过有一个检查以前可有可无,这回必须加上:收到的消息,收件人得是我自己。

csharp 复制代码
if msg.GetToDeviceId() != v.selfID {
  return from, nil, ErrWrongRecipient
}

以前服务器是按收件人转发的,不会送错。局域网里可就不好说了,谁都能抓到一条 A 发给 B 的消息,原封不动转给 C。签名是真的,C 要是正好也跟 A 配过对,那就直接收下了。

先拿原型试试防火墙

保险起见,动手之前我先写了个一次性的小程序试水:mDNS 注册、浏览,再用一个固定的 UDP 端口互相发 hello。然后在 Mac 和 Windows 11 虚拟机之间来回试。

先是啥防火墙规则都不加。结果 Windows 能发现 Mac,Mac 却发现不了 Windows。但只要 Windows 主动给 Mac 发过一次包,两边就都能正常收发了。

然后我故意在防火墙弹窗里点了「取消」,等于加了条阻止规则。本来以为这下肯定不通了,结果......跟没加一模一样,照样能通。

查了下才知道,Windows 防火墙是有状态的。阻止规则挡的只是别人主动连进来的包,你自己先发出去的,对面的回包它一律放行。

后面又试了几种:只有 Mac 知道 Windows 的地址,不通;只有 Windows 知道 Mac 的地址,通;两边都知道,也通。

这下就好办了,让两边都往对方发包就行。被挡的那边只要自己也发了,就能通。这样用户当时在防火墙弹窗里点的是啥,也就无所谓了。

那「Mac 发现不了 Windows」又是咋回事呢?原来 Windows 自带的 mDNS 入站规则,只放行系统自己的 Dnscache 服务,我的进程压根收不到别人的查询,那当然也就没法回答。后来我索性不自己回答了,在 Windows 上直接调系统的 DNS-SD 服务来注册(dnsapi.dll 里的 DnsServiceRegister),让系统帮我回答。浏览还是用 pion/mdns,因为浏览是我先发查询出去的,回包进得来。

配对的时候得先敲个门

不过「两边都发」这招,在配对之前是不灵的。

你想,配对的时候,显示配对码的那台只是干等着,是输码的那台去找它。要是显示码的正好是台没放行防火墙的 Windows,Mac 发过来的配对请求,直接就被挡在门外了。

我想的办法挺土的:让 Windows 先往外发个包,相当于先敲个门。

具体就是,输码的那台在 mDNS 广播里带个 s=1,意思是「我在找人配对」。显示码的那台一看到,就先往它的每个地址发个空包,就 4 个字节。这样 Windows 防火墙里就有了一条「我自己先发出去的」记录,后面 Mac 的配对请求再进来,就算回包了,放行。

这里我还踩了个小坑。一开始敲门是按设备限速的,2 秒敲一次。结果 Mac 身上有好几个地址,Wi-Fi 一个、虚拟机网卡又一个,2 秒里正好只敲到了那个不通的。后来改成按「设备 + 地址」限速,每个地址都敲一遍,就好了。

广播里可不能带设备名

mDNS 是发给整个网络的。公司、咖啡馆、宿舍,同一个网络里的人谁都能看见。要是广播里写着「张三的 MacBook Pro」,那不等于跟所有人喊「张三在这儿呢」。

所以广播里我啥能认出人的东西都没放。实例名每次启动随机生成,附带的信息里只有个版本号和一个当天的令牌,配对码开着的时候再多一个 p=1。

令牌是这么算的:

css 复制代码
func presenceToken(deviceID string, day time.Time) string {
  sum := sha256.Sum256([]byte("copysync-lan|" + deviceID + "|" + day.UTC().Format("2006-01-02")))
  return hex.EncodeToString(sum[:8])
}

配过对的设备知道对方的 ID,自己算一下就能认出来。我让它按昨天、今天、明天各算一遍,主要是怕两边时钟差那么一点,又正好赶上过零点。外人看到的呢,就是一串每天都在变的乱码。当然了,同一天里令牌是不变的,人家能看出来是同一台机器,但不知道是谁的,换一天也对不上号。

1.5.0 加的「搜索附近设备」算是个例外。两台都打开雷达的时候,能直接看到对方,点一下就配对,码都不用输,那这时候就必须广播设备名了。所以我让设备名只在雷达窗口开着的时候才广播,窗口一关马上撤掉,而且得两台都打开才能互相看到。

没了服务器,信令怎么传

直接用 UDP 发。端口固定用 8788,要是被占了,比如同一台 Mac 上两个用户都登录着,那就随机挑一个,再跟着 mDNS 告诉对方。

每个包前面加 4 个字节的暗号 CSL1,后面跟 protobuf,最大 16 KB。还没连上的设备,每 10 秒往它可能在的地址喊一声 hello;连上了就 30 秒一次,当心跳用。UDP 偶尔会丢包,所以每条信令发出去 150 毫秒后我会再补一份,重复的那份靠 nonce 去重。

hello 里我还塞了个启动标识,每次启动随机生成一个。为啥要这个呢?以前对方重启了,服务器会通知我。现在没服务器了,我只能傻等旧连接超时,实测得等 30 秒左右。有了启动标识就简单了,一看跟上次不一样,就知道对方重启过,直接把连接重建了。实测 Windows 一重启,Mac 1.6 秒就知道了。

要是两台电脑不在一个网段,mDNS 是过不去的。那就配对的时候手动输一下对方的地址,两边都记下来。以后只要有一边地址没变,就还能找到对方。网段扫描我没做,你在公司网络里扫一圈试试,八成会被当成在搞端口扫描。

macOS 15 的本地网络权限

这块是最折腾的,单独说说。

原型在 Mac 上跑得好好的。结果打包成 App、交给 launchd 当后台服务启动以后,一往局域网发包就报错:

vbnet 复制代码
sendto: no route to host

更怪的是,对方先发过来的包,它能收到,也能回。

我查了一圈才搞明白,这是 macOS 15 的本地网络权限。后台进程想主动往局域网发包,得用户点头才行。我的原型是从终端里跑的,蹭了终端的权限,所以根本测不出来。

说起来比较丢人,这问题其实 1.3 就有了,我一直没发现。原因是我平时都拿 Mac 和 Windows 虚拟机互测,Parallels 的共享网络做了一层 NAT,虚拟机发过来的包看着就像是 Mac 自己的 IP,而往自己发包是不受限制的,就这么阴差阳错地绕过去了。要是两台真的 Mac,又都没拿到权限,那谁也连不上谁。

照着 Apple 的 TN3179 改了两个地方:后台服务的 Info.plist 里加上 NSLocalNetworkUsageDescription 和 NSBonjourServices,LaunchAgent 里再写上 AssociatedBundleIdentifiers,告诉系统这个后台服务是 CopySync.app 家的。

改完用正式打包的方式又测了一遍,结果又冒出来几个文档里没怎么提的情况。

一是授权窗只在屏幕解锁的时候才弹。而且授权是记在后台服务自己名下的,并没有像我以为的那样算到 CopySync.app 头上。

二是升级以后虽然不会再弹窗了,但新程序刚启动那几秒到十几秒,包照样会被拦,过一会儿才自动放行。所以「被系统拦截」这个提示,我改成了拦满 30 秒才显示,不然每升级一次就闪一下,用户看了肯定一脸懵。

三是这几秒还能把 mDNS 搞坏。pion/mdns 启动的时候,哪块网卡加入组播失败了,以后就再也不用它了。要是 mDNS 正好赶在这几秒里启动,Wi-Fi 这块网卡就算废了,得等下次登录才能恢复。

第三个我是这么修的:启动 mDNS 之前先探一下有没有被拦,被拦着就每一轮都探,啥时候探通了,就把 mDNS 重启一下。用户要是后来才去系统设置里开权限,也是走这条路恢复。

怎么知道自己有没有被拦

这里有个问题。还没配对任何设备的时候,后台服务压根不会往局域网发包,那它也就不知道自己被拦了,用户这边只能看到「找不到设备」,一头雾水。

所以得主动去探一下。我的办法是往本网段里一个普通地址的 9 号端口(discard,丢弃端口)发几个字节,看 sendto 报不报错。

挑地址也有讲究。网关不行,发往网关的包可能压根不受这个限制。本机地址也不行,往自己发永远不受限制,Parallels 那个坑就是这么来的。最后我挑的是网段末尾附近的一个地址,比如 /24 网段的 .253。

EHOSTUNREACH 也不一定是被拦了

这个是 1.6.1 才修好的。有一阵我在日志里看到,局域网发现每隔一两分钟就重启一次,而且只有某台配过对的设备不在线的时候才这样。

查下来是 macOS 的一个行为。你往同网段一个不在线的地址发包,第一次 ARP 解析失败,返回的是 EHOSTUNREACH,跟被权限拦下时一模一样。然后接下来 20 秒里再发,返回的就变成 EHOSTDOWN 了,因为系统已经把对方标成不在线了(这个时长是 net.link.ether.inet.host_down_time)。

而真被权限拦下的包,压根走不到 ARP 那一步,你发多少次都是 EHOSTUNREACH。

所以事情是这样的:每隔一阵,发给离线设备的 hello 就碰上一次 ARP 失败,被我当成了「被拦了」。紧接着一探测,又通了,又当成「放行了」,于是重启 mDNS。然后再来一遍,没完没了。

改起来倒是简单,碰到 EHOSTUNREACH 就马上再发一次,看第二次的结果:

go 复制代码
func writeUDP(w udpWriter, dst netip.AddrPort, pkt []byte) error {
  _, err := w.WriteToUDPAddrPort(pkt, dst)
  if deniedByOS(err) { // EHOSTUNREACH
    _, err = w.WriteToUDPAddrPort(pkt, dst)
  }
  return err
}

反正第一次本来就没发出去,对方也不会收到两份。

pion/mdns 不报下线

做雷达的时候又发现个事:pion/mdns 的 Browse 只在一个设备第一次出现,或者地址、附带信息变了的时候才通知我,下线它是从来不管的。对方关了窗口、合上了盖子,在我这边的列表里还一直挂着。

那雷达上谁在线就不能指望 mDNS 了。我让打开雷达的设备每 2 秒给附近同样开着雷达的设备发个 ping,7 秒没动静,就从列表里拿掉。

这个 ping 还顺带解决了两个问题。一个是,能收到 ping,说明它的来源地址肯定是通的。多网卡的时候 mDNS 报的地址不一定通,配对请求就优先往 ping 的来源发。另一个是,没放行防火墙的 Windows 发过 ping 以后,对方的配对请求就进得来了,等于顺手把门也敲了。

怎么测的

上一篇那个打洞实验室(Linux 网络命名空间 + iptables)里,我又加了两种拓扑:一种是同一个网段,一种是中间隔了台不做 NAT 的路由器的两个网段。没放行防火墙的 Windows,就在每台主机上加一条规则来模拟:

css 复制代码
iptables -A INPUT -p udp -m conntrack --ctstate NEW -j DROP

跑了这么几个场景:

场景 结果
同一网段,靠 mDNS 发现 直连
两个网段,一边记得对方地址 直连
两个网段,两台都挡入站,都记得地址 直连
两个网段,两台都挡入站,只有一边记得 连不上
两个网段之间被隔离 连不上

后面两个是故意让它连不上的,就是想确认一下,该失败的时候它真失败了,而不是碰巧连上了。

真机还是老样子,Mac 加 Windows 11 虚拟机。Windows 上的程序放在一个新路径下,一条防火墙规则都没有。结果是这样的:

  • Windows 显示配对码、Mac 输入,2 秒就配上了(Windows 先敲了门)。
  • 单独重启 Mac,或者单独重启 Windows,4 秒内都能恢复同步。
  • Windows 换个 IP,大概 32 秒后旧连接被判定失效,再过 4 秒就重新连上了。
  • 网卡禁用 15 秒再打开,8 秒后恢复直连。

中间还闹了个笑话。第一轮测出来,两边重启后 1 秒左右就重连上了,我当时还挺美。后来才发现,是清理测试进程的时候漏了几个,所谓的「重启」,其实旧进程还顶着同一个身份在那跑着呢。第二轮把环境清干净重测,才是上面这个 4 秒。

还搞不定的

  • 两台都没放行防火墙的 Windows,又不在一个网段,还只有一边知道对方地址,这种是真连不上。界面上我放了个「放行」按钮,点一下弹个 UAC,用 netsh 加条规则就行。
  • macOS 的授权窗只在解锁的时候才出来,用户要是点了拒绝,就只能自己去系统设置里打开了。
  • 两边地址都变了的话,得手动输一次。台式机建议在路由器里把 IP 固定一下。
  • 目前只支持 IPv4。

先这样

现在同一个网络里的两台电脑,装上就能用,服务器的事不用管了。要跨网络的话,还是可以自己搭服务器,或者走 Tailscale。

每一轮的测试记录我都放这儿了:baiyuze.github.io/copysync/de...

你要是也被 launchd 后台服务的本地网络权限坑过,或者知道 Windows 上有更省事的 mDNS 做法,欢迎评论区聊聊。

相关推荐
静默回滚3 小时前
逐帧处理视频的内存去哪了,我从看错读数查到没排队
前端
与驴OO3 小时前
聊聊Agent的记忆系统如何设计
后端
重庆穿山甲3 小时前
我们不做「看起来很聪明」的知识库:一套企业级 RAG 平台的 37 屏真机实录
前端
风花一世月3 小时前
🛩️ 我把一段夕阳飞行做成了能按键驾驶的网页(24 秒四段航线,在线直接玩)
前端
用户11481867894843 小时前
实现一个「配置驱动」的动态表格组件(Vue + Element UI)
前端
数据掘金3 小时前
小程序分享参数在页面栈中的传递
前端
乐享qicheng3 小时前
1,自定义表单通俗理解-PC设计APP渲染
前端
为你学会写情书3 小时前
大屏适配别再只会 transform: scale 了,这套方案更稳
前端
孤独的神3 小时前
《AI 成了我的工位搭子,也成了我的焦虑来源》
前端