feat(overlay): 内容块级寻址 + 实例逐步拉起 + 骨干选路 + 组密钥加密(序24–㉛ 累积同步)

代码
- 内容分发块级寻址:新增 src/net/relay/content/{chunker,store,runtime,source,peer,crypto}.ts
- 组密钥(C 档)确定性加密:AES-256-GCM,块 id β′ = sha256(密文) 前 32 hex;双 epoch 过渡窗口
- 实例生命周期:三处 teardown() 不再杀实例(local/remote/leased-spawner);启动认领 + TCP 探活判孤儿
- 骨干选路:jitter 选路 + endpoint-target;relay client/server/wire/identity/directory/rendezvous/switcher 调整
- 工作台 src/web/server.ts、src/worker/relay-tunnel.ts 装配与候选链观测

脚本与测试
- scripts/overlay-{probe,keyring,jitter}.cjs 更新
- 探针新增 OBS-21(每连接候选数)/ OBS-22(teardown 静态守卫 + 认领面)/ OBS-23(组密钥加密)
- 新增 test/{orchestrator-teardown,orchestrator-rehydrate,overlay-content,overlay-jitter}.test.mjs;relay 两例更新

文档
- 新增交接单:覆盖网络-序24-内容分发块级寻址 / 序25-实例逐步拉起 / 序26-骨干稳定选路与加密
- INDEX.md、交接单/README.md、skills/dsh-auto-handoff-chain/SKILL.md 同步

验收(零回归,2026-09-18 08:0x 复核)
- npm test           201 tests / 200 pass / 0 fail / 1 skipped
- overlay-failover-drill --scene all --table   12 PASS / 0 SKIP / 0 FAIL
- overlay-probe --table                        23 PASS / 0 SKIP / 0 FAIL (rc=0)
This commit is contained in:
admin committed 2026-09-18 08:08:51 +08:00
1 parent 04776af4b1
commit 09ce76f3af
38 files changed
+9133 -211

No files matched your search

+371 -2
View File
@@ -46,7 +46,7 @@ import { connect } from 'node:net'
import { networkInterfaces } from 'node:os'
import type { Duplex } from 'node:stream'
import { MUX, decodeMux, encodeJsonFrame, encodeMux, parseJsonPayload, type MuxFrame } from './wire.js'
import { OPS_NETWORK, assertNetworkId } from './network.js'
import { OPS_NETWORK, NAME_SEP, assertNetworkId, logicalName } from './network.js'
import { signProof, publicKeyOfPrivate, type NodeGrant } from './identity.js'
import { MuxDuplex } from './duplex.js'
// 序④(443/TCP 兜底 · L1):地址覆盖(只依赖 node 内建,**无循环依赖**)。
@@ -223,6 +223,83 @@ export interface RelayClientStatus {
* ⛔ **只读观测量**:由既有状态机被动产生,**不驱动任何行为**(不改重试 / 不改退避 / 不新增定时器)。
*/
inGracefulBurstWindow: boolean
/**
* **presence 订阅视图**(序⑲)—— 订阅侧的判别器。
*
* 🔑 存在的理由:控制面必须能回答「**我现在到底还在不在推送上**」—— 否则"订阅静默失效"
* 与"这张网里确实没人"完全同形(本线头号教训)。判据 = `state==='subscribed'` +
* `lastSnapAgoMs/lastPushAgoMs` 的**新鲜度**。
*/
presence: RelayPresenceStatus
}
/** presence 订阅的**连接期状态**(⛔ 不是持久订阅:连接一断,`state` 回 `idle`)。 */
export type RelayPresenceState =
/** 未订阅(默认)。 */
| 'idle'
/** 已发 `SUB`、等首帧 `SNAP`(这一段是"可能静默"的唯一窗口 ⇒ 必须可观测)。 */
| 'pending'
/** 已拿到 `SNAP`:推送上。 */
| 'subscribed'
/**
* 对端**不支持** SUB(老 relay:未知帧号 ⇒ 直接关连接)⇒ **已停止再试**。
* 存在的意义:滚动升级期间新客户端遇到旧 relay 时,⛔ 不许把连接反复踢死。
*/
| 'unsupported'
/** 一条在线态记录(与 `server.ts` 的 `PresenceEntry` 同形,这里只做结构性约束)。 */
export interface RelayPresenceEntry {
name: string
hostId: string
network: string
online: boolean
devices: number
ports: number[]
/** 每个声明端口在 **relay 本机**的回环落点(`0` = 无)—— 让订阅路径也能供地址(见服务端注释)。 */
localPorts: { port: number; localPort: number }[]
lastSeenAgoMs: number
offlineInMs?: number
changedAt: number
}
export interface RelayPresenceStatus {
state: RelayPresenceState
/** 订阅范围:`all` = 本网全部;数字 = 点名订阅的个数(`idle`/`unsupported` ⇒ `0`)。 */
scope: 'all' | number
/** 最近一次收到 `SNAP` 距今多久(ms);从未收到 ⇒ `undefined`(⛔ 别用 0 冒充"刚收到")。 */
lastSnapAgoMs?: number
/** 最近一次收到 `PRESENCE` 增量距今多久(ms);从未收到 ⇒ `undefined`。 */
lastPushAgoMs?: number
/**
* **最近一次入站帧**(**任何**帧,含心跳 `PING`/`PONG`)距今多久(ms)—— `undefined` = 还没收到过。
*
* 🔑 门的判据看**它**(链路活没活),⛔ 不看上面两个"载荷年龄"(见 `presenceFresh()` 的 P-1 说明)。
*/
lastInboundAgoMs?: number
/** 门的**入站静默上界**(ms):`max(服务端下发 TTL, 半开阈值)` —— 超过它才允许判"不新鲜"。 */
linkSilentMaxMs: number
/** **门此刻的判定结果**(= `presenceFresh()`):让"门为什么开着/关着"一眼可判,⛔ 不静默。 */
fresh: boolean
/** 当前缓存里的在线态条数(**本地镜像**:订阅方读它,⛔ 不自己推导)。 */
entries: number
/** 当前缓存中 `online === true` 的条数(一眼看出"推送有没有真的更新过")。 */
onlineEntries: number
/** 收到的 `SNAP` 帧数。 */
snapFrames: number
/** 收到的 `PRESENCE` 增量帧数(**稳态必须停住不走** —— E1 的机器可读判据)。 */
pushFrames: number
/**
* **最近一帧带了几条记录**。
*
* 🔑 存在的理由:`pushFrames` 只回答"推了几帧",答不了"一帧里装了几条" —— 而"批合并真的生效"
* 恰恰是后半句(一帧带 host 数组,⛔ 不是逐个 host 一条)。没有它,"6 台同时上线合并成 1 帧"
* 与"6 台各推 1 帧"在计数上无法区分。
*/
lastFrameEntries: number
/** 被服务端**显式拒绝**的订阅次数(跨网 / 越界;⛔ 静默返空不算)。 */
rejected: number
/** `SUB` 发出后"连接掉了都没等到 `SNAP`"的次数(老 relay 的指纹)⇒ 达阈值判 `unsupported`。 */
subFailures: number
}
interface LocalStream {
@@ -254,6 +331,12 @@ interface DialStream {
const DEFAULT_QUEUE_MAX = 1 << 20
const DEFAULT_HIGH_WATER = 256 * 1024
const FLUSH_INTERVAL_MS = 20
/**
* presence TTL 的**保守默认**(ms)—— 只在"还没收到过 `SNAP`(因此没拿到服务端下发的 TTL)"
* 时用。取值 = 服务端默认 `HB_SEC(15s) × 3`(`server.ts#DEFAULT_PRESENCE_TTL_FACTOR`)。
* ⚠️ 它只影响"何时回退 `/status`",⛔ 不参与任何在线态判定 ⇒ 宁可短(早回退)也不许长。
*/
const DEFAULT_PRESENCE_TTL_MS = 45_000
export class RelayClient {
private readonly opts: RelayClientOptions
@@ -346,6 +429,23 @@ export class RelayClient {
private bytesIn = 0
private bytesOut = 0
/* ── presence(序⑲):订阅 + 本地镜像 ── */
/** 订阅范围(`undefined` = 未订阅)。`'all'` = 本网全部;数组 = 点名的逻辑名。 */
private presenceSub: 'all' | string[] | undefined
/** 本地镜像:**订阅方唯一该读的在线态来源**(键 = 逻辑名)。 */
private readonly presenceMap = new Map<string, RelayPresenceEntry>()
private presenceState: RelayPresenceState = 'idle'
/** 发过 `SUB` 但还没拿到 `SNAP` 的时刻;`0` = 没有在途订阅请求。 */
private subSentAt = 0
/** 服务端下发的 presence TTL(ms);未收到过 `SNAP` ⇒ 用默认值(保守)。 */
private presenceTtlMs = DEFAULT_PRESENCE_TTL_MS
private snapFrames = 0
private pushFrames = 0
private lastFrameEntries = 0
private presenceRejected = 0
private subFailures = 0
private lastSnapAt = 0
private lastPushAt = 0
constructor(opts: RelayClientOptions) {
this.opts = opts
this.allow = new Set(opts.ports)
@@ -428,9 +528,229 @@ export class RelayClient {
unhealthySinceMs: this.unhealthySince,
unhealthyForMs: this.unhealthySince === undefined ? 0 : Math.max(0, now - this.unhealthySince),
inGracefulBurstWindow: this.state === 'backoff' && this.burstUntil !== undefined && now < this.burstUntil,
presence: this.presenceStatus(),
}
}
/* ═══════════ presence 订阅(序⑲)═══════════ */
/**
* **订阅在线态**(`瓶颈落地方案 §1` 第 3 条:订阅式扇出,只推给"正在看的人")。
*
* `hosts` 省略 ⇒ 订阅**本网全部**(`all`)。订阅**只活在连接期间**(第 3 条)⇒
* 断线重连后由客户端自己重发,⛔ 服务端不保存任何持久订阅。
*
* ⚠️ **可重复调用**:调用即"以最后一次为准"(改范围会先发 `UNSUB` 再发 `SUB`),
* 这比"调用两次报错"更符合控制面"周期对账"的用法。
*/
subscribePresence(hosts?: readonly string[]): void {
if (this.presenceState === 'unsupported') return
this.presenceSub = hosts === undefined ? 'all' : hosts.map((h) => this.qualify(h))
if (this.state !== 'up') {
// 还没连上 ⇒ 记下范围,`onHelloAck` 起来时会自动发(⛔ 不在握手前发,那会被当未认证帧)。
this.presenceState = 'pending'
return
}
this.sendSubscribe()
}
/** **退订**(幂等)。控制面在"不再需要推送"时调用它 ⇒ `/status` 轮询会自动恢复(兜底路径)。 */
unsubscribePresence(): void {
const had = this.presenceSub !== undefined
this.presenceSub = undefined
this.presenceState = 'idle'
this.subSentAt = 0
this.presenceMap.clear()
if (had && this.state === 'up') this.send(encodeJsonFrame(MUX.UNSUB, 0, { all: true }))
}
/**
* presence 视图 —— 控制面读它(⛔ **不要自己另建一份在线态**,否则就是双权威)。
*
* `entries` 只在订阅生效期间有值;`state !== 'subscribed'` ⇒ 调用方**必须**回退 `/status`
* (D5:`/status` 是兜底路径,不是废弃路径)。
*/
presenceStatus(): RelayPresenceStatus {
const now = this.now()
let onlineEntries = 0
for (const e of this.presenceMap.values()) if (e.online) onlineEntries += 1
return {
state: this.presenceState,
scope: this.presenceSub === undefined ? 0 : this.presenceSub === 'all' ? 'all' : this.presenceSub.length,
lastSnapAgoMs: this.lastSnapAt === 0 ? undefined : now - this.lastSnapAt,
lastPushAgoMs: this.lastPushAt === 0 ? undefined : now - this.lastPushAt,
// ⚠️ `lastFrameAt` 只在 `up` 期间被刷新;`idle` 时它可能是上一轮的残值 ⇒ 只在 `up` 时报。
lastInboundAgoMs: this.state === 'up' ? now - this.lastFrameAt : undefined,
linkSilentMaxMs: this.presenceLinkSilentMaxMs(),
fresh: this.presenceFresh(),
entries: this.presenceMap.size,
onlineEntries,
snapFrames: this.snapFrames,
pushFrames: this.pushFrames,
lastFrameEntries: this.lastFrameEntries,
rejected: this.presenceRejected,
subFailures: this.subFailures,
}
}
/** 本地在线态镜像(键 = 逻辑名);未订阅 ⇒ 空数组(调用方据此回退 `/status`)。 */
presenceEntries(): RelayPresenceEntry[] {
return [...this.presenceMap.values()]
}
/** 单条查询:`undefined` = **不知道**(⛔ 与"离线"必须能分开 —— 否则会把未知当死)。 */
presenceOf(name: string): RelayPresenceEntry | undefined {
return this.presenceMap.get(name)
}
/**
* 订阅**可用**(= 允许拿这份镜像替代 `/status` 兜底)—— D5「主路径 / 兜底」的**唯一开关**。
*
* 🔑 判据 = 「**订阅已建立 ∧ 链路活着**」,⛔ **不是**「presence 载荷新鲜度」。
*
* 为什么必须这样定(在册缺陷 **P-1**,2026-09-17 序 ⑳ 实测):presence 是**变化驱动**的
* —— 稳态(无状态变化)下一帧都不推(`pushed/snaps` 自 relay 起就恒为 1,这是**设计属性**、
* ⛔ 不是故障)。若把门的判据定成"最近一次 presence 载荷距今 ≤ TTL",那"**没有变化**"就会被
* 读成"**没有数据**" ⇒ 45 s 后门必然重开、`/status` 轮询照旧在跑 ⇒ 核心收益归零
* (实测降幅仅 **1.10×**,而设计目标 ≥ 10×;门开关占空比 5.0% ⇒ 理论降幅 1.05×,吻合)。
*
* 镜像的**有效性**本来就不靠"载荷多久没来",靠两件事:
* ① **订阅已建立**(`state === 'subscribed'`,即拿到过首帧全量 `SNAP`);
* ② **通道有序可靠**(WS over TCP)⇒ relay 侧每一次变化**必然**以 `PRESENCE` 帧按序到达,
* 不会漏、不会乱序 ⇒ 载荷的**年龄与内容正确性无关**。
* 只有**链路死**才会让镜像失效,而链路死由半开巡检兜(`2.5 × hbSec` 内**没有任何**入站帧
* ⇒ 主动断链重连);断链时 `presenceState` 立刻离开 `subscribed` ⇒ 本函数随即回 `false`
* ⇒ 上层必然回退 `/status`(D5 的兜底路径,⛔ 一行都没删)。
*
* `ttlMs`(服务端下发,生产 45 s)在此退化为**入站静默上界**的一员:与半开阈值取大
* (`presenceLinkSilentMaxMs`)—— 兜"半开巡检的 tick 还没到"的那一个极窄窗口,
* ⛔ 不再当"载荷新鲜度"用。
*
* ⚠️ **已知残余**(写进交接单,不在此处兜):relay 侧**静默**清掉订阅而 socket 仍活 ⇒ 本判据
* 察觉不到。当前代码里这条路径**不可达**(唯一清空 `session.subs` 的是显式 `UNSUB`;relay 重启 /
* 会话回收都会断 socket ⇒ 走 ①/② 的路径被发现)。一旦真出现,正解 = 心跳帧携带订阅态断言,
* 或周期性 `SNAP` 复核(⛔ 不靠缩短 TTL)。
*/
presenceFresh(ttlMs?: number): boolean {
if (this.presenceState !== 'subscribed') return false
// 没拿到过首帧全量 ⇒ 镜像没有权威来源(空表 ≠ "这张网里没人")。
if (this.lastSnapAt === 0) return false
return this.now() - this.lastFrameAt <= this.presenceLinkSilentMaxMs(ttlMs)
}
/**
* 门的**入站静默上界**(ms)。
*
* 🔴 取 `max(服务端 TTL, 半开阈值)` 的理由:上界**不得比链路巡检更紧** —— 否则门会比
* "链路真的死了"更早打开(那就是把 P-1 换个方向重犯:拿一个与镜像有效性无关的时钟当判据)。
* ⚠️ 测试档把 TTL 压到 3 s 而心跳仍是 15 s ⇒ 若只用 TTL,测试里门会无谓地开合(假红源)。
*/
private presenceLinkSilentMaxMs(ttlMs?: number): number {
const bound = ttlMs ?? this.presenceTtlMs ?? DEFAULT_PRESENCE_TTL_MS
return Math.max(bound, this.halfOpenMs())
}
/** **半开阈值**(ms)—— 唯一来源:半开巡检与门判据共用,⛔ 不引入第二个时间口径。 */
private halfOpenMs(): number {
return this.opts.halfOpenMs ?? Math.max(3_000, Math.round(this.hbSec * 1_000 * 2.5))
}
private qualify(h: string): string {
// 裸 `hostId` 按本网补全;给了完整逻辑名就**原样保留**(跨网与否由服务端判,这里不猜)。
return h.includes(NAME_SEP) ? h : logicalName(this.network, h)
}
private sendSubscribe(): void {
if (this.presenceSub === undefined) return
const payload = this.presenceSub === 'all' ? { all: true } : { hosts: this.presenceSub }
this.subSentAt = this.now()
if (this.presenceState !== 'subscribed') this.presenceState = 'pending'
this.send(encodeJsonFrame(MUX.SUB, 0, payload))
}
/** 重连后重订阅(第 6 条:**重连后必须重新拉一次全量** —— 首帧 `SNAP` 就是那个全量)。 */
private resendPresenceSub(): void {
if (this.presenceSub === undefined || this.presenceState === 'unsupported') return
this.presenceMap.clear()
this.sendSubscribe()
}
/**
* `SNAP` —— **首帧即全量**(第 6 条,⛔ 无 N+1):整表替换而不是增量合并。
*
* 为什么必须整表替换:relay 重启后回环口号会全部重分配,增量合并会把过期条目永远留下
* (这正是 ssh 版"静默打到别人实例"的同族病,`web/server.ts#refreshRelay` 已为轮询路径
* 踩过一次 ⇒ 订阅路径不许复现)。
*/
private onSnapFrame(frame: MuxFrame): void {
const msg = parseJsonPayload(frame.payload)
if (msg === null || msg.ok !== true) {
this.log('[relay-client] ⛔ SNAP 负载非法 ⇒ 忽略(不回退状态机,等下一个帧)')
return
}
this.presenceMap.clear()
for (const e of toPresenceEntries(msg.entries)) this.presenceMap.set(e.name, e)
// TTL 口径由**服务端**下发(⛔ 客户端不写死数字:写死就会在服务端调参后悄悄不一致)。
if (typeof msg.ttlMs === 'number' && msg.ttlMs > 0) this.presenceTtlMs = msg.ttlMs
this.snapFrames += 1
this.lastSnapAt = this.now()
this.subSentAt = 0
this.presenceState = 'subscribed'
this.lastFrameEntries = this.presenceMap.size
this.log(`[relay-client] presence SNAP ${this.presenceMap.size} 条(scope=${this.presenceSub === 'all' ? 'all' : (this.presenceSub?.length ?? 0)})`)
}
/** `PRESENCE` 增量(或**显式拒绝**)。一帧带数组 ⇒ 就地合并。 */
private onPresenceFrame(frame: MuxFrame): void {
const msg = parseJsonPayload(frame.payload)
if (msg === null) {
this.log('[relay-client] ⛔ PRESENCE 负载非法 ⇒ 忽略')
return
}
if (msg.ok !== true) {
// 🔴 **显式拒绝**必须计数 + 响亮:静默返空与"本网没人"完全同形(本线头号教训)。
this.presenceRejected += 1
const why = typeof msg.error === 'string' ? msg.error : 'unknown'
this.log(`[relay-client] presence ⛔ 订阅被拒:${why}(rejected=${this.presenceRejected})`)
this.presenceState = 'idle'
this.presenceSub = undefined
return
}
const entries = toPresenceEntries(msg.entries)
for (const e of entries) this.presenceMap.set(e.name, e)
this.pushFrames += 1
this.lastFrameEntries = entries.length
this.lastPushAt = this.now()
if (this.presenceState !== 'subscribed') {
this.presenceState = 'subscribed'
this.subSentAt = 0
}
}
/**
* 连接掉了 ⇒ presence 订阅**随之消失**(第 3 条),必须让上层能看见这件事。
*
* 🔴 **老 relay 的指纹**:发了 `SUB` 却**没等到 `SNAP` 连接就掉了** —— 未知帧号会直接关连接。
* 达阈值 ⇒ 判 `unsupported` 并**停止再试**:滚动升级期间"新客户端 + 旧 relay"绝不能变成
* "连接被反复踢死"(那会把升级顺序依赖变成生产事故)。
*/
private onPresenceDown(): void {
if (this.subSentAt !== 0) {
this.subFailures += 1
this.subSentAt = 0
if (this.subFailures >= 2) {
this.presenceState = 'unsupported'
this.log(
`[relay-client] presence ⛔ 对端不认 SUB(${this.subFailures} 次"发了 SUB 没等到 SNAP 就断")⇒ 停止订阅,改由 /status 兜底`,
)
return
}
}
if (this.presenceSub !== undefined) this.presenceState = 'pending'
// ⚠️ **保留**上一次的 entries 但把新鲜度打掉:`presenceFresh()` 会因为 state!=='subscribed'
// 直接回 false ⇒ 上层必然回退 `/status`,而不会拿着过期数据当事实。
}
/* ═══════════ 连接生命周期 ═══════════ */
private now(): number {
@@ -556,6 +876,8 @@ export class RelayClient {
}
}
this.setState('backoff')
// presence:订阅随连接消失(第 3 条)⇒ 必须让上层看得见"现在已经不在推送上了"。
this.onPresenceDown()
if (this.stopped) return
this.scheduleRetry(wasUp ? `${why} (was up)` : why, graceful, fixedDelayMs)
}
@@ -640,7 +962,7 @@ export class RelayClient {
*/
private startHealthWatch(): void {
clearInterval(this.healthTimer)
const halfOpen = this.opts.halfOpenMs ?? Math.max(3_000, Math.round(this.hbSec * 1_000 * 2.5))
const halfOpen = this.halfOpenMs()
const tick = Math.max(500, Math.round(halfOpen / 4))
const timer = setInterval(() => {
if (this.stopped || this.state !== 'up') return
@@ -753,6 +1075,12 @@ export class RelayClient {
this.pingSentAt = undefined
}
return
case MUX.SNAP:
this.onSnapFrame(frame)
return
case MUX.PRESENCE:
this.onPresenceFrame(frame)
return
default:
this.log(`[relay-client] unknown mux type=${frame.type} ⇒ reconnect`)
this.onDown('unknown frame type')
@@ -811,6 +1139,8 @@ export class RelayClient {
for (const p of this.opts.ports) this.allow.add(p)
for (const p of this.dynamicPorts) this.allow.add(p)
if (this.dynamicPorts.size > 0) void this.replayDynamicPorts()
// presence(第 6 条):**重连必须重新拉一次全量** —— 见 `resendPresenceSub`。
this.resendPresenceSub()
}
/** 把运行期端口重新声明一遍(重连后调用;逐条独立,单条失败不影响其余)。 */
@@ -1314,6 +1644,45 @@ function errText(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}
/**
* 把 `SNAP` / `PRESENCE` 负载里的 `entries` 数组**逐条校验**后再采用(序⑲)。
*
* 为什么逐条校验而不是 `as RelayPresenceEntry[]`:这是**跨进程**来的数据(WS 帧),
* 一条字段缺失的条目如果被直接当成事实,症状是"某台机器永远显示离线" ——
* 与本线反复踩的"静默失败"同族(宁可**丢这一条**并留着上一条,也不采用半截数据)。
*/
function toPresenceEntries(raw: unknown): RelayPresenceEntry[] {
if (!Array.isArray(raw)) return []
const out: RelayPresenceEntry[] = []
for (const item of raw) {
if (item === null || typeof item !== 'object') continue
const e = item as Record<string, unknown>
if (typeof e.name !== 'string' || e.name === '') continue
if (typeof e.hostId !== 'string' || typeof e.network !== 'string') continue
if (typeof e.online !== 'boolean') continue
out.push({
name: e.name,
hostId: e.hostId,
network: e.network,
online: e.online,
devices: typeof e.devices === 'number' ? e.devices : 0,
ports: Array.isArray(e.ports) ? e.ports.filter((p): p is number => typeof p === 'number') : [],
localPorts: Array.isArray(e.localPorts)
? (e.localPorts as unknown[]).flatMap((raw) => {
if (raw === null || typeof raw !== 'object') return []
const lp = raw as { port?: unknown; localPort?: unknown }
if (typeof lp.port !== 'number' || typeof lp.localPort !== 'number') return []
return [{ port: lp.port, localPort: lp.localPort }]
})
: [],
lastSeenAgoMs: typeof e.lastSeenAgoMs === 'number' ? e.lastSeenAgoMs : 0,
offlineInMs: typeof e.offlineInMs === 'number' ? e.offlineInMs : undefined,
changedAt: typeof e.changedAt === 'number' ? e.changedAt : 0,
})
}
return out
}
/** 便于诊断:把 client 当前状态压成一行(`systemctl status` / 日志里直接可读)。 */
export function describeClientStatus(s: RelayClientStatus): string {
const retry = s.nextRetryMs === undefined ? '' : ` nextRetryIn=${Math.round(s.nextRetryMs)}ms`