Skip to content

ADR-0006:对端信令在 NATS 不通时回退到中继

  • 状态:第 1 阶段已实现,第 2、3 阶段提议中
  • 日期:2026-09-19
  • 关联:ADR-0005(FERRY 中继协议;本文把其中"Probe 保留为回退通道"一句落实为设计)、ADR-0004(LRP 对端认证)

摘要

节点之间的信令包(HANDSHAKE_SYNHANDSHAKE_ACKOFFERANSWERRESTART_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)。
  • 接收侧已经是共用的:中继客户端的 probeWorkerProbe 载荷反序列化为 SignalPacket,交给 node.probeFactory.Handle,与 NATS 消息是同一个入口(node.golrp_client.go)。因此节点本来就能处理经中继到达的 SYN、ACK、OFFER、ANSWER 和 RESTART_NOTIFY。
  • MESSAGE 包(网络图推送)来自控制面,不是对端发的,仍然只走 NATS。

故障现象

2026-09-19 测试:macOS App 在 wifi 上,iPhone 从 wifi 切到蜂窝,共两次:

次数Mac App 到手机中断手机的 NATS
1108 s出现 nats: stale connection,约每 17 s 重连一次,共 6 次
223 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 内发现,之前要几分钟),以及信令不可用时中继转直连的重试不再重启探测。这些缩短了故障时间,但没有消除这个依赖。

目标

  1. NATS 不可用、但中继会话正常的节点,仍能与同样在中继上的对端达到 lrp-ready,并且能通过经中继交换的 ICE 候选升级为直连。
  2. NATS 健康时行为不变,只是在探测迟迟没有进展的那段时间里多一点流量。
  3. 新旧版本节点互通,任何方向都不要求先升级中继。

非目标:取代 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 出问题"。

对每一次探测尝试(从 Startrestart 起,到探测离开 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 轮循环就是例子。

落地步骤

  1. 只改客户端peerSignalerConnected()、载荷上限与丢弃载荷的修复。服务器和旧版本节点都不用动,因为它们本来就接受 Probe 帧。旧版本收到升级后的包没有问题;但如果 NATS 不通的恰好是旧版本节点,它自己还是不会经中继发信令,所以只要任意一端是新版本、且中继能连到另一端,就有帮助。
  2. SignalPacket 增加 attempt_id,然后去掉对 lrpSynGrace 的依赖。
  3. 中继改写 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 阶段实现记录

已实现并有测试:

  • peerSignalerinternal/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 包;测试后规则和定时器均已删除并核对。

实现过程中发现并一并处理的两个已有问题:

  1. 信令里带着凭据。SYN、ACK、OFFER 里的对端记录会带上本节点注册响应里的 Token(后续向控制面拉网络图用的凭据)和带中继令牌的 LrpUrl,发给每一个通信对端(注册响应确实会填这两个字段,见 peer.goregisterStandalone)。传输层里没有任何地方使用对端的这两个字段,现在发出前清空(signalingPeer),同时让包更小。
  2. 中继 TCP 客户端的写入竞争。连接建立后的对端认证(在收包协程里)与写循环会同时写同一个缓冲写入器,两路帧可能交错。现在用一把写锁串行化。

待确认的问题

  1. relaySignalAfter 取 2 s 是否合适?还是重启后的第一次尝试(对端更可能正在等待)就应该两条通道同时发?
  2. 升级要在整个 probing 窗口(最长 65 s)内一直进行,还是发送有限次数后放弃?
  3. 第 2 阶段需要改 signal.proto:生成的 Go 文件是否在仓库内重新生成?Apple 端的绑定是否需要重新构建?

Built with Lattice · Console