CopySync 的技术方案
局域网直连:不用服务器也能配对和同步
状态:已实现,随 1.4.0 发布;「搜索附近设备」随 1.5.0 发布
代码:
client-core/internal/transport/lan/(发现、信令、配对、搜索附近设备、地址簿)、transport/signed/(签名与校验)、peers/manager.go(两条通道合并)验证:
transport/lan/lan_test.go、peers/manager_test.go、tools/natlab/(局域网场景)、Mac 与 Windows 虚拟机实机测试
一、问题
1.3 之前,两台电脑必须先连上同一台信令服务器,才能配对、知道对方在线、交换连接信息。即使两台就在同一个路由器下面,用户也得先找一台常开的电脑部署服务器。
服务器在这里只做三件事:
| 服务器的职责 | 局域网里用什么代替 |
|---|---|
| 发现:谁在线、在哪个地址 | mDNS(DNS-SD)广播 |
| 转发签名的信令 | 设备之间直接用 UDP 收发 |
| 配对码:码 → 公钥 | 在局域网里直接交换 |
| STUN / TURN | 同一局域网里用不上,host candidate 就能直连 |
信任模型本来就把服务器当成不可信的转发者:信令逐条签名,配对靠人工核对指纹,DTLS 指纹随签名一起传。把转发者从服务器换成局域网,安全性不变。
二、原型实测
动手之前,先用一个临时程序(mDNS 注册与浏览,加固定 UDP 端口互发 hello)在 Mac 和 Windows 虚拟机之间测了几种情况。虚拟机在 Parallels 的共享网络里,与 Mac 同一网段,网络类型是「公用」。
Windows 防火墙
| 情况 | 结果 |
|---|---|
| 程序没有任何防火墙规则 | Windows 能发现 Mac(自己发出的查询,回答作为回包放行);Mac 发现不了 Windows(Windows 回答不了查询)。Windows 主动打招呼后,双方都能收发 |
| 有显式的入站阻止规则(相当于弹窗时点了取消) | 同上。有状态防火墙对回包一律放行,阻止规则只挡主动连进来的包 |
| 只有 Mac 知道 Windows 的地址,Windows 不知道 Mac 的 | 不通:Mac 的包被挡,Windows 不会主动发 |
| 只有 Windows 知道 Mac 的地址 | 通 |
| 两边都知道对方地址 | 通 |
| 删掉程序的入站规则,再加一条 UDP 允许规则 | 只有 Mac 知道地址时也通了 |
结论:
- 被挡的一方只要先发包就能通。 所以设计成「两边都往对方发」,谁也不依赖入站规则。
- Windows 内置的 mDNS 入站规则只放行系统的 Dnscache 服务,我们自己的进程收不到别人的查询。改用系统的 DNS-SD 服务(
DnsServiceRegister)注册后,即使有阻止规则,Mac 也能立刻发现 Windows。浏览仍用 pion/mdns,不受影响。 - 弹窗点过取消、或者跨网段时被连的一方是 Windows,就需要放行。界面上提供一个按钮,用一次 UAC 运行 netsh 修好。
macOS 15 的本地网络权限
从终端运行的程序不受限制,但同一个程序打包成 App、由 launchd 启动(与后台服务一样)时,往局域网地址发包直接返回 sendto: no route to host。对方先发来的包仍然能收到,也能回复。
这说明 1.3 的后台服务在 macOS 15 上其实也连不到局域网地址。它和 Windows 虚拟机之间显示「直连」,是因为 Parallels 的 NAT 让虚拟机的包看起来来自 Mac 自己的 IP,恰好绕开了限制。两台 Mac 之间如果都没拿到权限,谁也连不上谁。
处理办法:
- 后台服务的 Info.plist 加上
NSLocalNetworkUsageDescription(中英日本地化)与NSBonjourServices。 - LaunchAgent 写上
AssociatedBundleIdentifiers,让系统知道后台服务属于 CopySync.app(TN3179 的建议)。 - 后台服务发现发往本机直连网段的包持续返回
EHOSTUNREACH时,报告「被系统拦截」,界面提示用户去「系统设置 → 隐私与安全性 → 本地网络」打开。 - 但对方不在线时也会得到
EHOSTUNREACH:ARP 解析失败的那一次发送返回它,之后 20 秒(net.link.ether.inet.host_down_time)里返回EHOSTDOWN。被权限拦下的包到不了 ARP 那一步,立即再发还是EHOSTUNREACH。所以遇到EHOSTUNREACH就立即再发一次,仍是它才算被拦。两种情况都在真机上测过(1.6.0 之后):launchd 启动的新签名程序头 2.5 秒里,在线和不在线的地址都是两次EHOSTUNREACH;之后发往不在线地址的是EHOSTUNREACH加EHOSTDOWN。
后来用正式打包方式(同样的 Info.plist,由 launchd 带着 AssociatedBundleIdentifiers 启动)在解锁的屏幕上复测:
- 系统会弹出授权窗,授权记在后台服务自己的 bundle ID 名下,没有因为
AssociatedBundleIdentifiers算到 CopySync.app 头上。用户允许后,mDNS 与单播都正常。 - 换了程序文件(ad-hoc 重新签名)后没有再问,授权仍然有效;但新程序刚启动的几秒到十几秒里,发往局域网的包仍会被拦下,之后自动放行。所以「被系统拦截」要持续 30 秒才报告,否则每次升级都会闪一下提示。
- 这段拦截还会让 mDNS 失效:pion/mdns 启动时在哪块网卡上加入组播失败,就再也不用那块网卡。于是启动 mDNS 之前先探测一次,被拦期间每一轮都探测,探测一通过就重启 mDNS。用户后来才在系统设置里允许,也走同一条路恢复。判断拦截解除只认探测包:被拦时,发给先联系过本机的设备照样能成功。
- 还没配对任何设备时,后台服务不往局域网发单播,就发现不了自己被拦着。于是在 macOS 上定期往本网段里一个普通地址的丢弃端口发几个字节探测(不用网关:发往网关的包可能不受这项限制)。往本机自己的地址发包也不受限制,不能当作「没被拦」的证据——Parallels 的 NAT 会让虚拟机的包看起来来自本机地址。
三、方案
1. 两条通道并存
peers.Manager 同时持有信令服务器客户端和局域网通道:
- 在线:服务器说在线、局域网里可达、P2P 已连通,任一成立即可。服务器断开只清掉服务器那一半。
- 发送信令:签一次名,局域网可达就走局域网,连着服务器就也经服务器转发。接收方的
signed.Verifier两条路共用一份 nonce 记录,只处理先到的那份。 - 配对结果通用:配对只是交换公钥,局域网里配好的设备,以后加上服务器照样能跨网络同步。
签名与校验从信令客户端里抽到 transport/signed,并补上一条检查:to_device_id 必须是本机。服务器按收件人转发,这条检查以前可有可无;局域网里谁都能把截到的消息转发给第三台设备,必须有。
2. 发现
- 服务类型
_copysync._udp,实例名每次启动随机(copysync-加 12 位十六进制),不带设备名。 - TXT:
v=1,t=当日令牌,配对码开放期间加p=1。 - 令牌是
sha256("copysync-lan|" + device_id + "|" + UTC 日期)的前 8 字节。已配对的设备按昨天、今天、明天各算一遍就能认出对方;同一网络里的其他人看到的只是一串每天都变的字符,无法跨天关联。 - Windows 上由系统的 DNS-SD 服务回答查询(见上一节),其他平台由 pion/mdns 回答。网络变化时(本机地址集合变了)重新加入组播、重新广播。
3. 局域网信令
- 固定 UDP 端口 8788,被占用时(同一台 Mac 上两个用户都登录着)退回随机端口,端口随 mDNS 和地址一起告诉对方。
- 每个包是 4 字节魔数
CSL1加LanPacket,上限 16 KB,按来源限速。 - 已配对设备之间收发的就是服务器转发的那种
Signed。上线通知是SignalMessage里新增的LanHello:自报的地址、要不要回一条、自己多久发一次。 - 对每台还没连通的设备,每 10 秒往它可能在的地址发 hello(已连通时 30 秒保活)。候选地址按顺序取:最近一次收到它消息的来源、mDNS 发现的、它自报的、地址簿里的,最多 12 个。
- 收到有效消息就记下来源地址,并标记可达;超过对方声明间隔的三倍没收到,视为不可达。
- 信令发往对方最近的来源地址,150 毫秒后再补发一次,抵消 UDP 偶尔丢包;重复的那份被 nonce 去重。
4. 地址簿与跨网段
- 新表
device_addrs(device_id, addr, last_seen),每台设备最多 8 个,随设备一起删除。 - 跨网段时 mDNS 过不去,配对时由用户输入对方配对码下面显示的地址。配对双方都记下对方的地址,之后任意一方换了地址,只要另一方没换,就能重新找到。
- 两边都换了地址时,在设备菜单里「输入它的地址」。不做网段扫描:在公司网络里会被当成端口扫描。
- Tailscale 不转发 mDNS,但 100.x 地址就是普通的可达地址:配对时输入它,之后同样靠地址簿。
5. 配对
- 显示码的一方:码在服务器和局域网两边同时开放(连着服务器时用服务器签发的码,否则本机生成,格式见
proto/pairingcode),一边用掉另一边就失效。错码 10 次作废。 - 输入码的一方:先在局域网里找,发往所有
p=1的实例和用户输入的地址,每 500 毫秒重发,3 秒为限(输了地址时 5 秒);找不到且连着服务器再去服务器兑换。 - 请求和回复都是
LanPairInfo,用自带的公钥签名,并校验 ID 由公钥派生;回复回显请求的 nonce,绑定到这次请求。配对码在局域网里明文传输,与服务器模式下服务器能看到配对码等价:防中间人靠的是用户核对指纹。 - 配对请求与回复本身证明了对方在哪个地址,收到即标记可达,确认后第一次连接不必再等 hello。
- 敲门:输入码的一方在 mDNS 里带上
s=1;显示码的一方看到后,先往它的每个地址发一个只有魔数的空包。显示码的一方若是没放行防火墙的 Windows,对方主动发来的配对请求本会被挡住;先发过包,有状态防火墙就会放行。只在同一网段有效(靠 mDNS),跨网段时仍需在被连的一方放行。
6. 搜索附近设备
配对码之外的另一种配对方式,1.5.0 加入:两台都在「设备」页打开「搜索附近设备」,互相出现在雷达上,点一下对方就发起配对。原来的「添加设备」(配对码)不变,雷达是单独的入口。
- 只在窗口打开期间公开设备名。 平时广播里只有匿名令牌;窗口打开时加上
n(设备名,UTF-8 再 base64url 编码,最多 60 字节)和o(系统)。编码是为了经 Windows 的系统 DNS-SD 服务转一道也不走样。窗口关掉、界面退出时,后台服务的WatchNearby调用随之结束,广播里的设备名随即撤下。 - 只有两台都打开时才互相看得到:没打开窗口的一方不广播设备名,也不接受这种请求。
- 靠 ping 判断对方还在。 pion/mdns 只在实例出现或变化时上报,不报下线,对方退出或睡眠后会一直留在列表里。所以打开窗口的设备每 2 秒(每轮 tick)向附近每个也打开了窗口的实例发一个
LanNearbyPing(带自己的实例名),收到的一方刷新那个实例的时间;7 秒没有消息就从列表里拿掉。ping 的来源地址也记下来,作为发配对请求的首选地址(多网卡时 mDNS 报的不一定是走得通的那个)。 - ping 自带名字、系统和令牌。 实例 5 分钟没有新消息就会被过期丢掉,而 pion 只在实例有变化时才报:对方先打开窗口、过了 5 分钟本机才打开时,本机已经忘了它,也不会再收到它的广播。对方看到本机打开了窗口就会 ping 过来,收到不认识的实例的 ping,按 ping 里的内容把它加回列表。
- ping 同时是敲门。 两台都在互相发,没放行防火墙的 Windows 发过 ping 之后,对方随后的配对请求就进得来,不需要「放行」。
- 配对请求是
LanPacket.nearby_request:与pair_request相同的签名LanPairInfo,只是不带配对码。对方窗口开着时,上层弹出核对指纹(同一台设备的重复请求只弹一次),并立即回复pair_response;窗口已关时回pair_reject(closed),发起方立即报「对方没有回应」,不必等到超时。之后两边核对指纹、各自确认,与配对码完全相同。 - 安全性。 配对码挡的是「任何人都能发起」:这里由对方的用户在弹窗里决定接不接受,而且只在对方开着窗口时才会弹。防中间人仍然靠两边核对安全指纹,这一点与配对码相同。
- 兼容。 旧版本不广播
n,不会出现在雷达上;收到新的包类型时直接忽略。 - 已配对的设备也显示在雷达上(按令牌认出),标为「已配对」,不能点。
- 只在同一网段有效(靠 mDNS)。跨网段仍用配对码加地址。
7. 重启
hello 里带一个每次启动随机生成的启动标识。收到已配对设备的 hello、发现启动标识变了,就知道它重启过:立即丢掉旧的 P2P 连接重建,不必等连接超时。用服务器时,这件事由服务器的上下线通知完成;没有服务器时,没有这一步的话,应答方重启后要等发起方自己发现连接失效(实测约 30 秒)。
另外,mDNS 里出现已配对设备的新实例名或新地址时,即使还当它可达,也马上打招呼:重启的那台若是没放行防火墙的 Windows,它主动发来的包进不来,要靠本机先发。
8. 其他改动
- 服务器地址默认为空。1.4.0 之前的默认值
ws://127.0.0.1:8787/signal其实没有服务器在跑,升级时清空一次(配置文件记config_version,只迁移一次)。 - 没有服务器时不探测公共 STUN:只在局域网里连,探测出口没有用,还会把公网 IP 暴露给公共服务器。
- 解除配对时断开 P2P 连接(以前不断开)。
copysync-cli:join CODE [ADDRESS]、address DEVICE ADDR,nat里加了局域网一节;1.5.0 加了nearby [NAME](搜索附近设备,给了 NAME 就在它出现时发起配对)。
代码位置
| 位置 | 内容 |
|---|---|
transport/lan/lan.go | socket、hello、可达状态、地址簿、诊断 |
transport/lan/pairing.go | 局域网配对 |
transport/lan/nearby.go | 搜索附近设备:广播设备名、ping、不带配对码的配对请求 |
transport/lan/discovery.go、mdns*.go | 发现接口、pion/mdns 实现、Windows 的系统 DNS-SD 注册、测试用的内存网络 |
transport/lan/addrs.go | 本机地址、地址解析、令牌 |
transport/lan/permission_*.go | macOS 打开本地网络设置与探测地址、Windows 用 UAC 放行防火墙 |
transport/signed/ | 签名、校验、防重放、收件人检查 |
peers/manager.go | 两条通道合并、配对流程 |
store/store.go | device_addrs 表 |
config/config.go | 默认不用服务器、lan_disabled、一次性迁移 |
四、能覆盖哪些场景
| 两台电脑 | 结果 |
|---|---|
| 同一网段 | mDNS 发现,直连 |
| 不同网段,网段之间互通 | 配对时输入地址,直连;之后靠地址簿 |
| 同一 tailnet | 配对时输入 100.x 地址,直连 |
| 一台是没放行防火墙的 Windows | 同一网段里能配对(它先「敲门」)、能重连;跨网段时要它知道对方的地址 |
| 两台都是没放行防火墙的 Windows,跨网段,只有一边知道地址 | 连不上,需要在被连的一方点「放行」 |
| 两个网段之间被隔离(访客网络等) | 连不上,只能经两边都能访问的服务器中转 |
| macOS 15 没允许本地网络 | 对方先发来时能连;两台都是这样的 Mac 时连不上,界面提示去允许 |
五、验证
单元测试与集成测试
transport/signed/signed_test.go:签名与校验;同一条信令从两条通道到达只处理一次;收件人不是本机时拒收;未配对、冒用 ID、时间戳过期都拒收。
transport/lan/nearby_test.go:只在窗口打开期间广播设备名、两台都打开才互相看到、关掉即消失;认出已配对的设备;点了对方后交换身份、对方关掉窗口时很快返回找不到;不再收到 ping 的设备消失、收到 ping 又出现;实例被过期丢掉后只靠对方的 ping 也能完整显示;设备名编码与截断。
transport/lan/lan_test.go:两个实例绑在 127.0.0.1 的不同端口上,用内存网络代替 mDNS。已配对设备互相找到并收发信令、补发的那份被去重、地址记进地址簿;只靠记得的地址互相找到;未配对设备的 hello 不算数;局域网配对、按地址配对、码只能用一次、错 10 次后作废;可达状态过期;地址解析与令牌换天;显示码的一方向在找人配对的实例敲门、配对期间广播里带 s=1;对方重启后察觉。permission_darwin_test.go:探测地址在本网段里且不是本机,本机地址不算直连网段里的对端;对方不在线(EHOSTUNREACH 后接 EHOSTDOWN)不算被拦,也不重启 mDNS。
peers/manager_test.go:两个完整的 peers.Manager。搜索附近设备:互相看到、点了对方后双方核对确认、直连并收发消息,重复的请求只弹一次确认,配对后显示为已配对;对方没打开窗口时看不到、配不上。没有服务器时配对、P2P 直连、双向收消息,解除配对后断开;不同网段时不输地址找不到、输了地址能配对并直连;关掉局域网只用服务器时原来的配对路径照常工作,错码立即被拒;服务器和局域网同时开着时,同一个码只能用一次。
config/config_test.go:旧默认地址清空、自己的服务器保留、只迁移一次。store/store_test.go:地址簿的排序、上限、随设备删除。
界面:pairing_flow_test.dart 覆盖原来的添加设备不变、雷达是单独入口;雷达显示附近的设备、点了之后弹出核对指纹并关掉雷达(随之撤下本机)、对方没回应时的提示、局域网发现关闭时的说明;显示本机地址、找不到时展开地址框并带地址重试、错码提示、侧栏的局域网状态与 macOS 拦截提示。
NAT 打洞实验室
新增 natlab lanpeer:不起任何服务器,用真实的局域网通道连接,两端身份由种子派生,相当于已经配对。新增两种拓扑:同一网段(网桥)与两个网段(路由器,不做 NAT)。
| 场景 | 预期 | 结果 |
|---|---|---|
| 同一网段,靠 mDNS 发现 | 直连 | 通过 |
| 两个网段,一边记得对方地址 | 直连 | 通过 |
| 两个网段,两台都挡入站新连接,都记得地址 | 直连 | 通过 |
| 两个网段,两台都挡入站新连接,只有一边记得 | 连不上 | 通过 |
| 两个网段之间被隔离 | 连不上 | 通过 |
「两台都挡入站」用每台主机上的 iptables -A INPUT -p udp -m conntrack --ctstate NEW -j DROP 模拟没有放行规则的 Windows 防火墙,证明两边同时发包确实能穿过有状态防火墙。
实机验收
Mac 与 Windows 11 虚拟机,各跑一个用 -tags fakeclip 编译的后台服务(独立数据目录,不碰真实剪贴板),不配服务器。Windows 上的程序是新的路径,没有任何防火墙规则。
- 局域网配对成功,两边的安全指纹一致。
- 文本双向同步;5 MB 文件从 Mac 传到 Windows,SHA-256 一致。
- 新的一对在确认配对后立即直连;8788 被占用时退回随机端口,地址里自动带上端口。
第二轮把 Mac 一侧换成正式打包的后台服务,由 launchd 启动,用户在授权窗里点了允许:
- Windows 显示配对码、Mac 只输入配对码:2 秒配对成功(Windows 先敲门)。
- 文本双向同步。
- 只重启 Mac、只重启 Windows:都在 4 秒内恢复,以「复制后对方收到」计时,含每轮约 1 至 2 秒的检测开销。
- 新程序刚启动时没有误报「被系统拦截」。
第三轮用正式安装的 App:Mac 装进 /Applications,Windows 用 CI 构建的安装包覆盖安装,两边都配着服务器,Windows 没放行防火墙:
- Mac 换成新版、Windows 还是旧版(1.1.2 开发版)时,经服务器文本双向同步。
- 图片(Mac 上的 PNG 数据、Windows 上的位图)和文件夹(带子目录与中文文件名)双向同步,SHA-256 一致。
- 在 Windows 防火墙里挡住到服务器的连接、重启 Windows 的后台服务:只靠局域网,12 秒内重新直连,同步正常;Mac 1.6 秒后就从启动标识察觉对方重启。
- Windows 换 IP:约 32 秒后旧连接被判定失效,4 秒后重连。网卡禁用 15 秒再启用:启用后 8 秒恢复直连。
- Mac 装上新版后,局域网发现正常工作,没有误报「被系统拦截」(修复前见「六、踩过的坑」)。
- 没测:睡眠唤醒、Mac 上开关 Wi-Fi、两台 Windows 之间、真实的跨网段与 Tailscale(只在 NAT 打洞实验室里测过)。
第四轮(1.5.0,搜索附近设备):Mac 与 Windows 11 虚拟机各跑一个 -tags fakeclip 的后台服务(独立数据目录,与两边正式安装的 1.4.0 同时运行,局域网端口因此都退回随机端口),Windows 上的程序是新的路径,没有任何防火墙规则,用 copysync-cli nearby 代替界面:
- Mac 打开后立刻看到 Windows,设备名经 Windows 的系统 DNS-SD 服务转发后完好;点它不到 1 秒收到回应,两边指纹一致,确认后直连。没放行防火墙的 Windows 照样收到了请求(它先发过 ping)。
- 两边都只打开、不点:Mac 打开后 1 秒内出现在 Windows 的列表里,并认出是已配对的设备;Mac 关掉后 1 秒内从 Windows 的列表里消失(广播里撤下设备名,pion 报了更新)。
- 配对后文本双向同步。
第一轮里「两边重启后约 1 秒重新直连」的结果不可靠:清理测试进程时漏掉了几个,「重启」时旧实例还以同一个身份在运行。第二轮在干净的环境里重测,结果如上。
六、踩过的坑
- 重启后要 30 秒才恢复。 应答方重启后,发起方还以为旧连接活着,要等握手超时。加了启动标识,见「7. 重启」。
- 没放行防火墙的 Windows 显示配对码时,同网段的 Mac 配不上。 加了「敲门」;一开始按实例限速,2 秒内只敲到了走不通的那个地址,改成按「实例 + 地址」限速。
- 被系统拦截的提示会闪。 新程序启动初期的拦截、发往本机地址的成功,都会让判断出错,见第二节。
- 升级后局域网发现不工作。 mDNS 恰好在新程序被拦的那几秒里启动,Wi-Fi 和虚拟机的网卡都没用上,要等下次登录。改为拦截一解除就重启 mDNS,见第二节。
- 局域网发现每一两分钟重启一次。 已配对的设备不在线时,发往它旧地址的打招呼包每隔一阵就碰上一次 ARP 解析失败,返回的
EHOSTUNREACH被当成了「被系统拦截」;紧接着的探测成功,又被当成「拦截解除」,于是重启 mDNS。改为立即再发一次来区分,见第二节。 - 短暂的拦截被报告了 5 分钟。 启动时的第一次探测先把下次探测排到 10 分钟后,偏偏这次被拦,之后又没有包能证明拦截已解除。改为看到探测结果再排下一次。
- 网络刚变化时 mDNS 启动失败,之后不再重试。 网卡还没拿到地址时启动会报 no usable interfaces。改为每 5 秒重试,直到成功。
- 配对后的第一次连接要等 30 秒。 确认配对后立即发起连接,但双方还没互发过 hello,局域网通道认为对方不可达,offer 发不出去,连接停在「连接中」直到握手超时。改为配对的请求与回复到达时就标记可达;offer 发不出去时立即复位,不等超时。
- 配对前就被发现的实例,配对后认不出来。 令牌是按已配对设备算的,配对后要把已经发现的实例重新对一遍。
- pion 的浏览每秒报一次同一个实例,还会报出无关的服务。 只在地址或令牌变化、或超过 30 秒时才行动;校验服务类型与
v=1。 - 锁的顺序。 局域网通道持锁时回调上层(取已配对设备、查 P2P 状态),上层持锁时又查局域网的可达状态。可达状态单独放在一把叶子锁里,回调一律在锁外。
- Parallels 的 NAT 掩盖了 macOS 的拦截,见第二节。
- natlab 里新建 veth 时,第一个名字先在容器自己的命名空间里创建,起名
eth0会与容器的网卡冲突;[[ … ]] && …放在函数末尾、条件不成立时会让函数返回 1,触发set -e。
七、局限与后续
- 两台都没放行防火墙的 Windows、跨网段、只有一边知道地址时连不上,需要在被连的一方点「放行」。
- macOS 15 的本地网络授权弹窗只在屏幕解锁时出现。用户拒绝之后,要在系统设置里手动打开。
- 敲门只在同一网段有效;跨网段时,被连的一方若是没放行防火墙的 Windows,仍需点「放行」。
- 不做网段扫描。两边地址都变了要手动输入一次,台式机建议在路由器里固定地址。
- 只支持 IPv4。
- 本机换 IP 后,旧的 P2P 连接要等 ICE 判定失效(约 30 秒)才重建。
- 发送时连接恰好断开,那一条可能丢失:同步层没有送达确认(1.1 起就是这样,与局域网模式无关)。