ADR-0006:对端信令在 NATS 不通时回退到中继
- 状态:第 1 阶段已实现,第 2、3 阶段提议中
- 日期:2026-09-19
- 关联:ADR-0005(FERRY 中继协议;本文把其中"
Probe保留为回退通道"一句落实为设计)、ADR-0004(LRP 对端认证)
摘要
节点之间的信令包(HANDSHAKE_SYN、HANDSHAKE_ACK、OFFER、ANSWER、RESTART_NOTIFY)目前全部走 NATS。一旦某个节点的 NATS 连接已死或半死,探测(probe)就无法重新协商,哪怕这个节点到中继的连接完全正常、本可以承载数据。
本文提议启用线路上早已存在的第二条信令通道,即中继的 Probe 帧:当 NATS 通道迟迟没有进展时,同一个信令包再通过中继发送一份。
背景
信令现在怎么走
- 一个探测负责与一个对端协商传输方式(ICE 与 LRP 在
Probe.discover里竞速)。两个拨号器都以Sender: p.signal.Send构造(probe_factory.go),即发布到对端的 NATS 主题。 - 中继承载 WireGuard 数据包(
Forward帧),也支持承载信令包(Probe帧)。目前Probe的唯一发送方是 LRP 拨号器的 OFFER/ANSWER(lrpDialer.sendOfferFromLrp)。 - 接收侧已经是共用的:中继客户端的
probeWorker把Probe载荷反序列化为SignalPacket,交给node.probeFactory.Handle,与 NATS 消息是同一个入口(node.go、lrp_client.go)。因此节点本来就能处理经中继到达的 SYN、ACK、OFFER、ANSWER 和 RESTART_NOTIFY。 MESSAGE包(网络图推送)来自控制面,不是对端发的,仍然只走 NATS。
故障现象
2026-09-19 测试:macOS App 在 wifi 上,iPhone 从 wifi 切到蜂窝,共两次:
| 次数 | Mac App 到手机中断 | 手机的 NATS |
|---|---|---|
| 1 | 108 s | 出现 nats: stale connection,约每 17 s 重连一次,共 6 次 |
| 2 | 23 s | 没有重连 |
第 1 次里,Mac 在 10 s 后发现直连路径已断,之后每 2 s 发一次 SYN,手机大约在第 105 s 才收到第一个。同一时段:
- 手机到中继的 TCP 连接在切网后约 20 s 重新注册成功,此后一直正常;
- 只依赖中继数据的容器 22 s 就恢复了;
- 服务器上,手机蜂窝出口的 3 条到
:4222的连接,发送队列里各积压 485 字节未被确认,而它到:6266的连接没有积压。
这些 NATS 连接为什么不可用,目前不清楚(暂时的推测是运营商网络对该端口新连接的处理)。对设计而言这不重要:NATS 是重新协商所依赖的第二个独立依赖,它失效时,中继明明可用,却没有被用来做信令。
已经做过的相关改动:NATS 每 5 s 探活、最多允许 2 次未应答(死连接约 15 s 内发现,之前要几分钟),以及信令不可用时中继转直连的重试不再重启探测。这些缩短了故障时间,但没有消除这个依赖。
目标
- NATS 不可用、但中继会话正常的节点,仍能与同样在中继上的对端达到
lrp-ready,并且能通过经中继交换的 ICE 候选升级为直连。 - NATS 健康时行为不变,只是在探测迟迟没有进展的那段时间里多一点流量。
- 新旧版本节点互通,任何方向都不要求先升级中继。
非目标:取代 NATS、中继之间互联、改变中继转发的内容(属于 ADR-0005)、对中继运营方隐藏信令元数据。
决策
1. 两个拨号器共用一个发送器
在 internal/server/transport 新增 peerSignaler。它实现拨号器现在使用的 Send(ctx, to PeerID, data []byte) error 形态,在 probe_factory.go 中替换两个拨号器的 p.signal.Send。它管理两条通道:
- NATS:
infra.SignalService.Send。 - 中继:
Lrp.Send(ctx, to, relay.Probe, data),中继客户端已连接时可用。infra.Lrp增加Connected() bool。
2. 升级规则
发送方看不到接收方的 NATS 状态:向一个订阅已死的对端发布消息,发布本身仍然成功。所以触发条件是"没有进展",而不是"自己的 NATS 出问题"。
对每一次探测尝试(从 Start 或 restart 起,到探测离开 probing 为止):
- 先只走 NATS,与现在一致;
- 这次尝试进入
probing已超过relaySignalAfter(2 s,即一个 SYN 周期)仍没有离开,此后每个包在中继已连接时同时经中继再发一份; - 如果本地 NATS 报告
Connected() == false,或某次 NATS 发送返回错误,立即改用中继; - 探测离开
probing时停止升级,下一次尝试重新计时。
触发条件不看"是否收到了对端的包":收到对端经 NATS 发来的包,只能证明对端到我们这个方向可用,不能证明我们发出的包也到得了对端(对端订阅已死而发布仍然可用时,正是这种不对称),所以只看这次尝试停留在 probing 的时间。
一开始就两条通道同时发更简单,也快 2 s。不采用的原因:这会让每个健康对端的每次尝试都多出重复包,而重复包正是主要风险(见下)。
3. 接收端
接收路径不变。经中继到达的包与 NATS 的包一样进入 probeFactory.Handle,发送者身份取自 SignalPacket.SenderID,与 NATS 一致。
信任程度:目前任何通过认证的 NATS 客户端都可以发布带任意 SenderID 的包;中继由共享中继令牌认证,开启对端认证后还有 ADR-0004。所以中继通道的可信度不低于 NATS。
加固方案随 ADR-0005 一起做:中继对 Probe 也像对 Forward 一样,改写来源 ID,接收端丢弃 SenderID 与之不符的包。本次不做,因为在中继还不改写 Probe 的情况下强制校验,会让现有的 LRP OFFER/ANSWER 路径失效。
4. 重复包
两条通道会让同一个包可能到达两次,间隔甚至可达数秒。现有处理逻辑本来就能容忍重传:SYN 每 2 s 重发一次,ICE 候选会缓存并重发,LRP 会话形成后 5 s 内收到的 SYN 视为重传(lrpSynGrace,提交 20fda9c2)。剩下的风险是:一个迟到的重复 SYN 在这个窗口之后到达已建立的会话,被误判为"对端重启",从而重启探测。
第 2 阶段去掉这个时间启发式:每次探测尝试生成一个随机 attempt_id,放入 SignalPacket(新增可选字段,旧版本会忽略)。接收方对带当前尝试 id 的 SYN 一律视为重传,不论走哪条通道、延迟多久;id 不同才算真正的重启。旧版本不发送 id,继续使用时间窗口规则。
5. 需要一并处理的限制与已有问题
MaxProbePayload是 2048 字节。旧版本的中继客户端收到更大的Probe会丢弃并让数据流错位,所以发送方自己保证不超过这个值(超过的包只走 NATS),而不是调大接收限制。SYN 携带发送方的节点记录,实测约几百字节(单元测试里限制在限额的一半以内)。- 遇到超长的
Probe帧时,TCP 客户端原来只打印警告就返回,没有读取载荷,这会让数据流错位(lrp_client_tcp.go)。已改成读取并丢弃载荷(超长的Forward同理)。 - 向一个未注册的对端中继时,服务器每个包打一行 warn 日志
relay target not found。升级期间就是每 2 s 每个离线对端一行,需要限频。
6. 可观测性
每次尝试开始升级时打一条 info 日志(signaling escalated to relay,带原因:没有进展、NATS 断开、NATS 发送错误)。lattice status 不变。
备选方案
- 信令全部改走中继:可以彻底摆脱 NATS 依赖,但同时失去 NATS 在网络图推送和在线状态上的作用,并且所有握手都压到中继上。与 ADR-0005 的非目标冲突,不采用。
- 只加固 NATS(第二个端口的入口、NATS over WebSocket、并发多连接):有用,与本方案互不冲突,但只在故障和端口有关时才有效;如果问题出在服务端到客户端这条流本身,就帮不上忙,而中继本来就是我们信任、用来传数据的连接。
- 从第一个包起两条通道都发:见决策第 2 点。
- 只保留更快的 NATS 故障检测:能把中断缩短到约 15 s 加重连时间,但重连可能又落到另一条不可用的连接上,第 1 次测试里的 6 轮循环就是例子。
落地步骤
- 只改客户端:
peerSignaler、Connected()、载荷上限与丢弃载荷的修复。服务器和旧版本节点都不用动,因为它们本来就接受Probe帧。旧版本收到升级后的包没有问题;但如果 NATS 不通的恰好是旧版本节点,它自己还是不会经中继发信令,所以只要任意一端是新版本、且中继能连到另一端,就有帮助。 SignalPacket增加attempt_id,然后去掉对lrpSynGrace的依赖。- 中继改写
Probe的来源 ID(ADR-0005),接收端校验。
测试计划
- 单元测试:用一个会吞包的假 NATS 发送器和一个假中继,验证升级时间线(2 s 之前只走 NATS、之后两条都发、NATS 断开或出错时立即走中继、探测离开
probing后停止);SYN、ACK、OFFER 经两条通道重复到达时,只形成一个会话。 - 集成测试:两个节点加一个中继,双向丢弃它们之间的 NATS,探测应到达
lrp-ready;随后经中继交换的 ICE 候选应能到达ice-ready。 - 真机复现,不必再等蜂窝抖动:在云主机上用
iptables -t raw规则丢弃容器到:4222的流量(为什么用raw表,见测试环境备忘),重启 Mac 节点,预期容器与 Mac 在中继注册后约 10 s 内到达lrp-ready。测完删除规则并确认已删除。 - 回归:手机 wifi 切蜂窝多做几次,不应出现超过 30 s 的中断。
第 1 阶段实现记录
已实现并有测试:
peerSignaler(internal/server/transport/peer_signaler.go)与infra.Lrp.Connected();两个拨号器改用它发信令;状态机进入probing时开始计时。- 触发条件与最初的设计不同:只看这次尝试在
probing停留的时间,不再统计"是否收到对端的包",理由见决策第 2 点。 - 中继 TCP 客户端收到超长
Probe或超长Forward时读取并丢弃载荷,不再让数据流错位;有一个用真实中继验证的测试,去掉修复后该测试会失败。 - 中继服务器对"目标不在线"的告警日志按目的地限频(30 s 一次)。
- 集成测试:两个 LRP 拨号器、真实中继、NATS 丢弃所有包,握手在约 2 s 内完成;对照组关闭升级后握手停滞。
真机验证(2026-09-19,容器 v14 加 macOS App 均为该实现):
- 在云主机上用
iptables -t raw双向丢弃容器与 NATS(4222)之间的流量(容器发出的包和服务器发回的包),容器日志出现nats: stale connection和心跳失败,容器与 Mac、手机已建立的 WireGuard 会话不受影响。 - 在 Mac App 里断开再连接,触发一次新的握手。Mac 在开始探测 2 s 后记录
signaling escalated to relay(原因no-progress),70 ms 后 SYN 到达容器,容器随即因 NATS 已断开直接改走中继,双方 ICE 候选和 LRP 的 OFFER 也经中继交换,容器在 Mac 开始探测后约 4.6 s 到达lrp-ready,Ping 恢复。 - 测试期间该规则累计丢弃约 190 个 NATS 包;测试后规则和定时器均已删除并核对。
实现过程中发现并一并处理的两个已有问题:
- 信令里带着凭据。SYN、ACK、OFFER 里的对端记录会带上本节点注册响应里的
Token(后续向控制面拉网络图用的凭据)和带中继令牌的LrpUrl,发给每一个通信对端(注册响应确实会填这两个字段,见peer.go的registerStandalone)。传输层里没有任何地方使用对端的这两个字段,现在发出前清空(signalingPeer),同时让包更小。 - 中继 TCP 客户端的写入竞争。连接建立后的对端认证(在收包协程里)与写循环会同时写同一个缓冲写入器,两路帧可能交错。现在用一把写锁串行化。
待确认的问题
relaySignalAfter取 2 s 是否合适?还是重启后的第一次尝试(对端更可能正在等待)就应该两条通道同时发?- 升级要在整个
probing窗口(最长 65 s)内一直进行,还是发送有限次数后放弃? - 第 2 阶段需要改
signal.proto:生成的 Go 文件是否在仓库内重新生成?Apple 端的绑定是否需要重新构建?