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`
+226
View File
@@ -0,0 +1,226 @@
/**
* 块级切分 —— **内容寻址的第一块地基**(覆盖网络线 序㉔ · 内容分发)。
*
* ## 一句话说清它是什么
* 把一段字节流按**固定大小**切成块,**每块的 id 是它自己内容的哈希**。
* ⇒ 同一份内容切两次,块 id 序列**逐字节一致**;改 1 字节,**只有落在那一个块里的 id 变**
* (其余块的 id 不变 ⇒ 对端已持有的那几块**不用重传**)。
*
* ## 为什么必须是"块级"而不是"包级"
* 包级(Peer Cache 式)的 id 挂在**整个包**上 ⇒ 内容一变,**全部 peer 源同时失效**,
* 每台设备只能各自回源 —— 这正是本线已实证过的「**版本一发就全量重拉**」风暴成因。
* 块级(BranchCache 式)的 id 挂在**块**上、**与文件无关** ⇒
* · **只拿到一部分也能开始共享**(E2);
* · 版本更新**只传变化的块**(E3)。
*
* ## 两条设计决定(**已定项,可推翻**)
* 1. **固定块 + 不引入 CDC(内容定义切分)**:固定块实现简单、零依赖、可复算;
* 代价是"在块边界插入/删除 1 字节"会让其后所有块 id 改变(CDC 能缓解)。
* ⚠️ 之所以敢先不做 CDC:本场景的内容是**构建产物**(版本发布刷新),
* 变更形态是"整文件替换"而非"文中插字" ⇒ 固定块的边界漂移**在实践中不触发**。
* 若将来出现"差量只有几字节却全量重传"的实测证据,再上 CDC(登记为回头条件)。
* 2. **块 id = `sha256(块字节)` 的前 32 hex 位**:够长到碰撞不可能(128 bit),
* 又短到 URL / 索引友好。⚠️ **不掺入内容长度、不掺入序号** ——
* id 必须**只由字节内容决定**,否则"同内容不同来源 ⇒ 不同 id"会让共享失效(这是本线的核心判据)。
*
* ## 🆕 序㉘ · 单 B:**可选的编解码钩子**(缺省 ⇒ 本模块行为**逐字不变**)
* 组密钥加密(`content/crypto.ts`)落地后,块 id 的口径从"明文哈希"改成
* **密文哈希**("β′",见该单 §7.3)。做法**不是**在切分层里嵌加密逻辑,而是把
* "字节变换"作为**注入的纯函数**传进来:
* - `encode`(写侧):`明文块 → 落库字节`。给了它 ⇒ `Chunk.bytes` 是**落库字节**(密文)、
* `Chunk.id = sha256(落库字节)`;⛔ 不传 ⇒ 与序㉔ **完全一致**(27 个既有用例一行不改)。
* - `decode`(读侧,在 `reassemble`):`落库字节 → 明文块`。**恢复**原始内容的那一步。
*
* 🔑 为什么"加密"必须挂在这里而不是 `store`:`store` 的键就是 id,而 id 是**由字节算出来的**
* ⇒ 口径只能有一个地方定义(本模块)。`crypto.ts` 只提供 `encode/decode`,⛔ 不知道块的概念。
* ⚠️ 唯一例外是**解密实现**本身(`crypto.decodeBlock`)——它是**一处实现、两个调用位**
* (`source.ts` 链的统一返回点 / 本模块的重组位),同一批字节**只过其中一处**。
*
* ## ⛔ 本模块**不做**的事(故意)
* - 不做 IO、不读文件、不网络 —— **纯函数**,可单测、可在任何进程里跑;
* - 不做压缩;⛔ **不自己实现加解密**(只调用注入的 `encode` / `decode`);
* - 不做"块 → 来源"的映射(那是 `store.ts` / `source.ts` 的事)。
*
* @module dshs/net/relay/content/chunker
*/
import { createHash } from 'node:crypto'
/**
* 默认块大小(字节)—— **1 MiB**。
*
* 定这个数的依据(S0 P1 实测):单份首屏合并脚本 **11,363,655 B ≈ 10.8 MB**,
* 取 1 MiB ⇒ **一份包约 11 块**。这个粒度同时满足三件事:
* - **够粗**:块数少 ⇒ 索引 / 广播 / 请求的**控制面开销**可控(block 级元数据 ≈ 11 条/份);
* - **够细**:改一个文件(典型几百 KB)**只影响 1–2 块** ⇒ E3「只传变化块」成立;
* - **对齐友好**:1 MiB 是 2 的幂 ⇒ 定长切分的边界可手算复现。
*
* ⚠️ 值是**常量而非配置**:块大小一变,历史块的 id 全部失效 ⇒
* 它必须是"全集群唯一一个版本"的口径。要改就整体换代(见 §9 回头条件)。
*/
export const DEFAULT_BLOCK_SIZE = 1024 * 1024
/** 块 id 的 hex 长度(`sha256` 前 32 位 = 128 bit)。 */
export const BLOCK_ID_HEX_LEN = 32
/** 块 id 的合法形状(纯小写 hex)。 */
const BLOCK_ID_RE = new RegExp(`^[0-9a-f]{${BLOCK_ID_HEX_LEN}}$`)
/** 一个块(内容 + 它的内容寻址 id + 在原流中的序号)。 */
export interface Chunk {
/** 内容寻址 id = `sha256(bytes)` 前 `BLOCK_ID_HEX_LEN` 位。**只由字节决定**。
* ⚠️ 给了 `encode`(加密)时,`bytes` 是**落库字节**(密文)⇒ id 也挂密文("β′")。 */
id: string
/** 该块在原流中的**序号**(0 起)。⚠️ 序号**不参与** id 计算 —— 它只是重组用的坐标。 */
index: number
/** 该块在原流中的起始字节偏移。 */
offset: number
/** 块字节(`index` 为最后一块时可能 < 块大小)。给了 `encode` ⇒ 这里是**落库字节**。 */
bytes: Buffer
}
/** 切分结果:块序列 + 整体指纹。 */
export interface ChunkedContent {
/** 块序列(按 `index` 升序)。 */
chunks: Chunk[]
/** 整份内容的 id。⚠️ 给了 `encode` ⇒ 挂在**落库字节流**上(组外不可见);缺省 = 明文哈希。 */
contentId: string
/** 整份内容长度(字节,**明文口径**)。 */
size: number
/** 本份内容**去重后**的块 id 列表(顺序 = 首次出现序)。⚠️ 同内容重复出现时只算一次。 */
ids: string[]
}
/**
* 可选的**字节变换钩子**(序㉘ · 单 B)。
*
* ⚠️ 两个都必须是**纯函数且确定性**:同输入必须给同输出。给了非确定性实现(例如随机 iv),
* 块 id 会次次不同 ⇒ 去重与 peer 命中**全废**(`E1` 从 1.00× 退回 4.00×)。
*/
export interface ChunkTransforms {
/** 写侧:`明文块 → 落库字节`(加密)。缺省 = 恒等(⛔ 与序㉔ 逐字一致)。 */
encode?: (plain: Buffer) => Buffer
/** 读侧:`落库字节 → 明文块`(解密)。失败 ⇒ 返回 `undefined`(由调用方**具名**处置)。 */
decode?: (stored: Buffer) => Buffer | undefined
}
/** 块 id:`sha256(块字节)` 的前 `BLOCK_ID_HEX_LEN` 位。 */
export function blockIdOf(bytes: Buffer): string {
return createHash('sha256').update(bytes).digest('hex').slice(0, BLOCK_ID_HEX_LEN)
}
/** 整份内容的 id:`sha256(全部字节)` 的前 `BLOCK_ID_HEX_LEN` 位。 */
export function contentIdOf(bytes: Buffer): string {
return createHash('sha256').update(bytes).digest('hex').slice(0, BLOCK_ID_HEX_LEN)
}
/** 块 id 是否合法(纯小写 hex、长度恰好 `BLOCK_ID_HEX_LEN`)。 */
export function isBlockId(raw: string): boolean {
return BLOCK_ID_RE.test(raw)
}
/**
* 按**固定块大小**切分一段内容。
*
* 三条不变量(单测的判据):
* 1. **确定性**:同一份字节重复切 ⇒ 块 id 序列**完全一致**;
* 2. **局部性**:改 1 字节 ⇒ **只有 1 个块**的 id 变(其余 id 逐位相同);
* 3. **可重组**:`offset` 连续且 `sum(len(chunks)) === size`。
*
* @param bytes 待切分内容
* @param blockSize 块大小(缺省 `DEFAULT_BLOCK_SIZE`)。必须 > 0。
* @param transforms 可选的 `encode`(加密)—— 给了它 ⇒ `Chunk.bytes` / id 全部挂**落库字节**。
* @throws 当 `blockSize <= 0` 时抛错(⛔ 静默取默认会把"配错"伪装成"切出来的块不对")
*/
export function chunkify(
bytes: Buffer,
blockSize: number = DEFAULT_BLOCK_SIZE,
transforms?: ChunkTransforms,
): ChunkedContent {
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`chunker: 块大小必须是正整数,收到 ${String(blockSize)}`)
}
const encode = transforms?.encode
const chunks: Chunk[] = []
const seen = new Set<string>()
const ids: string[] = []
for (let offset = 0, index = 0; offset < bytes.length; offset += blockSize, index += 1) {
const slice = bytes.subarray(offset, Math.min(offset + blockSize, bytes.length))
// ⚠️ 必须 `Buffer.from(...)` 复制:`subarray` 是**视图**,原 buffer 被复用时会**内容漂移**
// (块已落盘、id 却是按旧内容算的 ⇒ 校验必红且极难定位)。
const own = encode === undefined ? Buffer.from(slice) : encode(Buffer.from(slice))
const id = blockIdOf(own)
chunks.push({ id, index, offset, bytes: own })
if (!seen.has(id)) {
seen.add(id)
ids.push(id)
}
}
// 整体指纹:给了 `encode` ⇒ 挂**落库字节流**(⛔ 否则整份内容的指纹会继续暴露给中继)。
const contentId =
encode === undefined ? contentIdOf(bytes) : contentIdOf(Buffer.concat(chunks.map((c) => c.bytes)))
return { chunks, contentId, size: bytes.length, ids }
}
/**
* 只取"切分坐标"(**不含块字节**)—— 给"我知道整份内容要什么块"的场景用
* (例如先查本地 / peer 有没有,再决定去哪取)。
*
* ⚠️ 与 `chunkify` 的 id 算法**必须同源**;两者由 `chunkifyOfPlan` 一致性单测锁住。
* ⚠️ 给了 `encode` ⇒ 这里算出的 id 是**落库 id**(查本地 / peer 时必须用这一套)。
*/
export function planOf(
bytes: Buffer,
blockSize: number = DEFAULT_BLOCK_SIZE,
transforms?: ChunkTransforms,
): { ids: string[]; size: number } {
if (!Number.isInteger(blockSize) || blockSize <= 0) {
throw new Error(`chunker: 块大小必须是正整数,收到 ${String(blockSize)}`)
}
const encode = transforms?.encode
const ids: string[] = []
for (let offset = 0; offset < bytes.length; offset += blockSize) {
const slice = bytes.subarray(offset, Math.min(offset + blockSize, bytes.length))
ids.push(blockIdOf(encode === undefined ? slice : encode(Buffer.from(slice))))
}
return { ids, size: bytes.length }
}
/**
* 按一份**块 id 计划**重组内容。
*
* ⚠️ **每个块取回后必须自行复算 id 并与计划比对** —— 这就是 `E4` 的落点:
* 篡改块 ⇒ 复算 id ≠ 计划 id ⇒ **丢弃并报错**(⛔ 不落盘、⛔ 不拼接)。
* ⇒ "中间节点被控也改不了块"(交接单 §7 的"完整性"那一半)。
*
* 🆕 序㉘ · 单 B:`parts` 是**落库字节**(加密启用时即密文);给了 `transforms.decode`
* ⇒ **先验 id(对落库字节)、再解密、后拼接**。判据顺序刻意如此:
* 完整性必须在**密文层**先成立(否则"解出来是乱码"会伪装成"块被篡改")。
*
* @throws 当某个块缺失 / id 不符 / 解密失败时抛错(附带 `index` 与 `expected`/`actual`,便于定位)
*/
export function reassemble(plan: string[], parts: Map<string, Buffer>, transforms?: ChunkTransforms): Buffer {
const decode = transforms?.decode
const out: Buffer[] = []
for (let index = 0; index < plan.length; index += 1) {
const expected = plan[index]
if (expected === undefined) throw new Error(`chunker: 计划在第 ${index} 项处断裂`)
const got = parts.get(expected)
if (got === undefined) throw new Error(`chunker: 缺少块 index=${index} id=${expected}`)
const actual = blockIdOf(got)
if (actual !== expected) {
throw new Error(`chunker: 块校验失败 index=${index} expected=${expected} actual=${actual}(丢弃)`)
}
if (decode === undefined) {
out.push(got)
continue
}
const plain = decode(got)
if (plain === undefined) {
// ⛔ 不许静默跳过、⛔ 不许拼半截:解密失败 = 这块不可用 ⇒ 与"缺少块"同等处置(但**点名**)
throw new Error(`chunker: 块解密失败 index=${index} id=${expected}(认证未过 ⇒ 丢弃)`)
}
out.push(plain)
}
return Buffer.concat(out)
}
+585
View File
@@ -0,0 +1,585 @@
/**
* 内容面**组密钥加密**(覆盖网络线 序㉘ · 单 B)—— 让"中继看不到明文载荷"与
* "按哈希共享块"**同时成立**。
*
* ## 一句话说清它是什么
* 同一个「内容组」`(network, group)` 共享**一把对称密钥**(AES-256-GCM,与 `src/crypto.ts` 同源);
* 加密是**确定性**的 ⇒ **同组 + 同明文 ⇒ 密文逐字节相同**(`md5` 相等)
* ⇒ 序㉔ 的"块 id = 内容哈希"照旧成立(**组内共享不退化**),而中继只拿到不透明字节。
*
* ## 为什么必须"确定性"(⛔ 不是随便挑的加密模式)
* 普通 GCM 每次随机 `iv` ⇒ 同明文两次密文不同 ⇒ **块 id 每次都变** ⇒ 去重与 peer 命中**全废**
* (这正是序㉔ 主判据 `E1` 从 1.00× 退回 4.00× 的路径)。所以:
* - `iv = HMAC(key, "iv"‖明文) 前 12 字节` —— **由明文决定,不留随机数**;
* - `k = HMAC(key, "k"‖iv)` —— 与 `iv` 一一对应 ⇒ **解密侧能复算**(解密时只有密文,没有明文);
* - `AAD = "<network>|<group>|<epoch>"` —— 把组与 epoch **绑进认证** ⇒ 跨组 / 跨 epoch 的密文
* **在认证阶段就被拒**(⛔ 不靠"解出来是乱码"来判)。
*
* 🔑 **唯一的解密实现是本文件的 `decodeBlock`**。它有两个调用点、**同一批字节只过其中一处**:
* ① `source.ts` 优先级链的统一返回点(**生产路径**:五档一律在这里解密 ⇒ ⛔ 不存在
* "local 档不解密、peer 档解密"那种双口径);
* ② `chunker.ts#reassemble` 的重组位(**夹具 / 工具路径**)。二者**不叠加**。
*
* ## 三条纪律(每条都对应本线踩过的病)
* 1. **失败关闭且具名**:密钥文件缺失 / 权限不对 / 组名不配 / 密钥形状不对 / epoch 超窗口 ——
* 五种情形各有**独立原因码**并落计数器。⛔ 绝不出现"以为加密了其实没加"。
* 2. **计数即判据**:所有判别器都是可断言的数字(⛔ 不许只写日志 —— 探针 `OBS-23` 逐键断言)。
* 3. **明文不出现**:`plainScans` / `plainLeaks` 是**字节级**自证 ——
* 对自己刚加密出的字节扫明文标记,命中数必须 **0**;⛔ 记录 / 日志里也不许出现明文标记。
*
* ## ⛔ 本模块**不做**的事(故意)
* - 不做网络、不做 IO 传输(密钥**只从 `0600` 文件读**,⛔ 不经网络、不经 relay —— 见 §7.2);
* - 不引第二套算法栈(⛔ 无 ChaCha / 无 RSA 包裹);
* - 不新根、不新签名链:组密钥凭据由**既有签名者**签发,验签走**既有** `identity.ts` 实现。
*
* @module dshs/net/relay/content/crypto
*/
import { createCipheriv, createDecipheriv, createHash, createHmac, timingSafeEqual } from 'node:crypto'
import { readFileSync, statSync } from 'node:fs'
import { verifySignedPayload } from '../identity.js'
import type { IdentityReason } from '../identity.js'
/** 组密钥凭据的载荷标签(规范拼接用;⛔ 不用 `JSON.stringify`)。 */
export const GROUP_KEY_TAG = 'dshs-overlay-groupkey/v1'
/** 组密钥文件的缺省落点(与节点密钥同处 `/etc/dshs/`,同样 `0600`)。 */
export const DEFAULT_GROUP_KEY_FILE = '/etc/dshs/content-group-key.json'
/** 加密格式版本(进 AAD ⇒ 换代即全量失效,⛔ 不该随手改)。 */
export const CONTENT_CIPHER_VERSION = 1
/** GCM 的 iv 长度(字节)。 */
export const IV_LEN = 12
/** GCM 认证标签长度(字节)。 */
export const TAG_LEN = 16
/** 密文块的最小长度(`iv|tag` 各一段,再加至少 0 字节明文)。 */
export const MIN_BLOB_LEN = IV_LEN + TAG_LEN
/** 密钥长度(AES-256 ⇒ 32 字节)。 */
export const KEY_LEN = 32
/** 组密钥的**可断言**判别器(探针 `OBS-23` 逐键断言 "存在且是 number")。 */
export const CONTENT_CRYPTO_COUNTER_KEYS = [
'encrypts',
'decrypts',
'decryptRejected',
'epochs',
'epoch',
'epochExpired',
'detChecks',
'detMismatches',
'plainScans',
'plainLeaks',
] as const
export type ContentCryptoCounterKey = (typeof CONTENT_CRYPTO_COUNTER_KEYS)[number]
export type ContentCryptoCounters = Record<ContentCryptoCounterKey, number>
/** 装载密钥文件失败的原因(**具名** —— ⛔ 不许合并成一句"不可用")。 */
export type GroupKeyLoadReason =
| 'no-file'
| 'bad-perms'
| 'parse-error'
| 'group-mismatch'
| 'bad-key'
| 'bad-epoch'
/** 装载结果:要么拿到密钥,要么拿到**具名原因**。 */
export type GroupKeyLoadResult =
| { ok: true; group: string; epoch: number; epochs: number; keyId: string; permsChecked: boolean }
| { ok: false; reason: GroupKeyLoadReason; detail: string }
/** 组密钥文件里的一条 epoch 记录。 */
export interface GroupKeyEpochEntry {
epoch: number
/** base64(32 字节)。 */
key: string
/** 该 epoch 的**停止服役时刻**(ISO)。给了才参与"过渡窗口上界"判定。 */
retiredAt?: string
}
/**
* 组密钥文件形状(`0600`)。
*
* ```json
* { "version": 1, "group": "relay", "epoch": 2, "key": "<base64 32B>",
* "previous": [{ "epoch": 1, "key": "<base64 32B>", "retiredAt": "2026-09-18T00:00:00.000Z" }] }
* ```
*
* ⚠️ `previous` 只**解不写**(S5 的双 epoch 过渡态);`epoch` = 当前**写入**用 epoch。
*/
export interface GroupKeyFile {
version?: number
/** **组名**(与运行时的 `group` 逐字比对)。 */
group: string
/** 可选的网名:给了就必须与运行时一致(防"同名不同网")。 */
network?: string
/** 当前写入 epoch。 */
epoch: number
/** 当前写入密钥(base64)。 */
key: string
/** 仅解不写的历史 epoch。 */
previous?: GroupKeyEpochEntry[]
}
/** 组密钥凭据(签名者签发;**载荷内不含密钥本体**)。 */
export interface GroupKeyCredential {
version?: number
network: string
group: string
epoch: number
/** 密钥指纹 = `sha256(key)` 前 16 hex(⛔ 不是密钥本身)。 */
keyId: string
issuedAt: string
}
/** 密钥指纹(可公开)—— 组密钥文件与凭据用它对齐"是不是同一把"。 */
export function keyIdOf(key: Buffer): string {
return createHash('sha256').update(key).digest('hex').slice(0, 16)
}
// ── 凭据:规范载荷 + 解析 + 验签(**复用既有验签实现**,⛔ 不新写第二份) ──────────
/** 规范拼接(字段顺序**写死**;⛔ 改顺序 = 令所有既有签名失效)。 */
export function groupKeyCredentialPayload(doc: GroupKeyCredential): string {
return [
GROUP_KEY_TAG,
`version=${String(doc.version ?? CONTENT_CIPHER_VERSION)}`,
`network=${doc.network}`,
`group=${doc.group}`,
`epoch=${String(doc.epoch)}`,
`keyId=${doc.keyId}`,
`issuedAt=${doc.issuedAt}`,
].join('\n')
}
/** 解析(**严格**:字段不全 / 类型不对 ⇒ `undefined`,⛔ 不猜、不补默认值)。 */
export function parseGroupKeyCredential(raw: unknown): GroupKeyCredential | undefined {
if (raw === undefined || raw === null || typeof raw !== 'object') return undefined
const r = raw as Record<string, unknown>
const network = typeof r.network === 'string' ? r.network.trim() : ''
const group = typeof r.group === 'string' ? r.group.trim() : ''
const keyId = typeof r.keyId === 'string' ? r.keyId.trim() : ''
const issuedAt = typeof r.issuedAt === 'string' ? r.issuedAt.trim() : ''
const epoch = typeof r.epoch === 'number' ? r.epoch : NaN
if (network === '' || group === '' || keyId === '' || issuedAt === '') return undefined
if (!Number.isInteger(epoch) || epoch <= 0) return undefined
const version = typeof r.version === 'number' ? r.version : CONTENT_CIPHER_VERSION
return { version, network, group, epoch, keyId, issuedAt }
}
/**
* 用**既有受信签名者**验一份组密钥凭据。
*
* ⚠️ 与 `identity.ts` 的四条判据同源:**不可验 = 不接受**(受信签名者为空 ⇒ 拒绝)。
*/
export function verifyGroupKeyCredential(
doc: unknown,
sig: unknown,
trustedSigners: readonly string[],
): { ok: true; doc: GroupKeyCredential } | { ok: false; reason: IdentityReason | 'bad-payload' } {
const parsed = parseGroupKeyCredential(doc)
if (parsed === undefined) return { ok: false, reason: 'bad-payload' }
const verdict = verifySignedPayload(groupKeyCredentialPayload(parsed), sig, trustedSigners)
if (verdict !== 'ok') return { ok: false, reason: verdict }
return { ok: true, doc: parsed }
}
// ── 密钥文件装载 ──────────────────────────────────────────────────────────────
/** base64 → 32 字节(形状不对 ⇒ `undefined`)。 */
function decodeKey(b64: unknown): Buffer | undefined {
if (typeof b64 !== 'string' || b64.trim() === '') return undefined
let buf: Buffer
try {
buf = Buffer.from(b64.trim(), 'base64')
} catch {
return undefined
}
return buf.length === KEY_LEN ? buf : undefined
}
/** 装载选项。 */
export interface LoadGroupKeyOptions {
/** 密钥文件路径。 */
file: string
/** 运行时组名(必须与文件里的 `group` 一致)。 */
group: string
/** 运行时网名(文件里给了 `network` 时必须一致)。 */
network?: string
/**
* 是否强制 `0600` 判定。缺省 = **`process.platform !== 'win32'`**。
*
* 🔴 **为什么必须按平台分**(本地实测):Windows **没有 POSIX 权限位** ——
* `writeFileSync(p, x, {mode: 0o600})` 之后 `statSync(p).mode & 0o777` 恒为 `666`
* ⇒ 无条件判会**把每个文件都判成 `bad-perms`**、加密**永远开不起来**。
* 而生产(47 / 106)是 Linux ⇒ 判据在那里**必须**成立。
* ⚠️ 本选项同时是"判据有牙"的证明位(单测用它在本机复现 `bad-perms`)。
*/
enforcePerms?: boolean
}
/**
* 从 `0600` 文件装载组密钥。**任一不符 ⇒ 具名失败**(⛔ 静默降级成明文)。
*
* 顺序刻意如此:**先看文件在不在 → 再看权限 → 再解析 → 再比对组 / 网 → 再验密钥形状**。
* 这样"没装"与"装了但配错"在日志里是**两种**原因(本线反复要求的可区分性)。
*/
export function loadGroupKeyFile(opts: LoadGroupKeyOptions): GroupKeyLoadResult {
let st
try {
st = statSync(opts.file)
} catch {
return { ok: false, reason: 'no-file', detail: `${opts.file} 不存在或读不到` }
}
// 0600 口径:只要 group/other 有任一权限位 ⇒ 判权限错(⛔ 不"自动 chmod"——那会掩盖部署缺陷)
// ⚠️ POSIX-only:Windows 的 mode 恒 666(无权限位语义)⇒ 默认在那里跳过判定(`permsChecked=false`)
const enforcePerms = opts.enforcePerms ?? process.platform !== 'win32'
if (enforcePerms && (st.mode & 0o077) !== 0) {
return {
ok: false,
reason: 'bad-perms',
detail: `${opts.file} 权限 ${(st.mode & 0o777).toString(8)}(必须 0600)`,
}
}
let parsed: GroupKeyFile
try {
parsed = JSON.parse(readFileSync(opts.file, 'utf8')) as GroupKeyFile
} catch (err) {
return { ok: false, reason: 'parse-error', detail: String(err) }
}
if (parsed === null || typeof parsed !== 'object') {
return { ok: false, reason: 'parse-error', detail: '顶层不是对象' }
}
if (parsed.group !== opts.group) {
return {
ok: false,
reason: 'group-mismatch',
detail: `文件 group=${String(parsed.group)} ≠ 运行时 group=${opts.group}`,
}
}
if (parsed.network !== undefined && opts.network !== undefined && parsed.network !== opts.network) {
return {
ok: false,
reason: 'group-mismatch',
detail: `文件 network=${String(parsed.network)} ≠ 运行时 network=${opts.network}`,
}
}
const write = decodeKey(parsed.key)
if (write === undefined) {
return { ok: false, reason: 'bad-key', detail: 'key 不是 base64 的 32 字节' }
}
if (!Number.isInteger(parsed.epoch) || parsed.epoch <= 0) {
return { ok: false, reason: 'bad-epoch', detail: `epoch=${String(parsed.epoch)}(必须是正整数)` }
}
let extra = 0
for (const p of parsed.previous ?? []) {
if (decodeKey(p?.key) === undefined) {
return { ok: false, reason: 'bad-key', detail: `previous epoch=${String(p?.epoch)} 的 key 形状不对` }
}
if (!Number.isInteger(p.epoch) || p.epoch <= 0) {
return { ok: false, reason: 'bad-epoch', detail: `previous epoch=${String(p?.epoch)} 必须是正整数` }
}
extra += 1
}
return {
ok: true,
group: parsed.group,
epoch: parsed.epoch,
epochs: 1 + extra,
keyId: keyIdOf(write),
permsChecked: enforcePerms,
}
}
/** 装载后的可读回确认(供装配点打一行**不含密钥**的判别器日志)。 */
export function describeGroupKey(file: string, r: GroupKeyLoadResult): string {
return r.ok
? `[content-crypto] 组密钥已装载 file=${file} group=${r.group} epoch=${r.epoch} epochs=${r.epochs} keyId=${r.keyId}` +
(r.permsChecked ? ' perms=0600 ✓' : ' perms=未判定(Windows 无 POSIX 权限位)')
: `[content-crypto] ⛔ 不启用加密:${r.reason} —— ${r.detail}`
}
// ── 加解密 ────────────────────────────────────────────────────────────────────
/** `ContentCipher` 构造选项。 */
export interface ContentCipherOptions {
/** 组键(`` `${network}|${group}` ``)—— 进 AAD,⛔ 不许跨组复用同一把钥。 */
groupKey: string
/** 当前**写入** epoch 与它的密钥。 */
epoch: number
key: Buffer
/** 仅解不写的历史 epoch(S5 双 epoch 过渡态)。 */
previous?: readonly { epoch: number; key: Buffer; retiredAt?: string }[]
/**
* 过渡窗口上界(毫秒)。`retiredAt + graceMs < now` ⇒ 该历史 epoch **不再解**
* (⛔ `decryptRejected` + `epochExpired` 各 +1,并**点名** epoch)。
* 缺省 **24 h**(`CONTENT_EPOCH_GRACE_MS`)。
*/
graceMs?: number
/** 日志函数(⛔ 不许把明文塞进来)。 */
log?: (line: string) => void
/** 时钟注入(单测用)。 */
now?: () => number
}
/** 缺省过渡窗口:24 h(参数表 `CONTENT_EPOCH_GRACE_MS`)。 */
export const DEFAULT_EPOCH_GRACE_MS = 24 * 60 * 60 * 1000
/**
* 组密钥加解密器。
*
* ⚠️ 生命周期:无 IO、无网络、无监听 —— 构造与使用都只碰内存 ⇒ 不触 R5。
*/
export class ContentCipher {
/** 组键(含网名 —— "同名不同网"必须不同组)。 */
readonly groupKey: string
/** 当前写入 epoch。 */
readonly epoch: number
private readonly key: Buffer
private readonly previous: { epoch: number; key: Buffer; retiredAt?: string }[]
private readonly graceMs: number
private readonly log: (line: string) => void
private readonly now: () => number
private readonly c: ContentCryptoCounters = {
encrypts: 0,
decrypts: 0,
decryptRejected: 0,
epochs: 0,
epoch: 0,
epochExpired: 0,
detChecks: 0,
detMismatches: 0,
plainScans: 0,
plainLeaks: 0,
}
constructor(opts: ContentCipherOptions) {
if (opts.key.length !== KEY_LEN) {
throw new Error(`content-crypto: 密钥必须是 ${KEY_LEN} 字节,收到 ${opts.key.length}`)
}
this.key = Buffer.from(opts.key)
this.epoch = opts.epoch
this.groupKey = opts.groupKey
this.previous = (opts.previous ?? []).map((p) => ({
epoch: p.epoch,
key: Buffer.from(p.key),
...(p.retiredAt === undefined ? {} : { retiredAt: p.retiredAt }),
}))
this.graceMs = opts.graceMs ?? DEFAULT_EPOCH_GRACE_MS
this.log = opts.log ?? (() => {})
this.now = opts.now ?? (() => Date.now())
this.c.epochs = 1 + this.previous.length
this.c.epoch = opts.epoch
// 双 epoch ⇒ 明确留一行(轮换是有代价的动作,⛔ 不许静默发生)
if (this.previous.length > 0) {
this.log(
`[content-crypto] 过渡态:写入 epoch=${this.epoch},仅解 epoch=[${this.previous.map((p) => p.epoch).join(',')}]` +
`(窗口 ${this.graceMs} ms)`,
)
}
}
/** 判别器快照(**拷贝**)。 */
counters(): ContentCryptoCounters {
return { ...this.c }
}
/** 当前可解的 epoch 列表(写入在前)。 */
epochs(): number[] {
return [this.epoch, ...this.previous.map((p) => p.epoch)]
}
/** 组密钥指纹(可公开比对;⛔ 不是密钥)。 */
keyId(): string {
return keyIdOf(this.key)
}
/** AAD = `<groupKey>|<epoch>` —— 把"组"与"代"绑进认证。 */
private aadOf(epoch: number): Buffer {
return Buffer.from(`${this.groupKey}|${epoch}`, 'utf8')
}
/** `iv = HMAC(key,"iv"‖plain)` 前 12 字节(**确定性**:同明文同 iv)。 */
private ivOf(key: Buffer, plain: Buffer): Buffer {
return createHmac('sha256', key).update('iv').update(plain).digest().subarray(0, IV_LEN)
}
/** `k = HMAC(key,"k"‖iv)` —— 解密侧只有 iv,故 k **必须**只由 iv 决定。 */
private keyOf(key: Buffer, iv: Buffer): Buffer {
return createHmac('sha256', key).update('k').update(iv).digest()
}
/**
* **确定性**加密一个块。形状 = `iv ‖ tag ‖ 密文`。
*
* 三条不变量(单测锁住):
* 1. 同组 + 同明文 **两次** ⇒ 返回**逐字节相同**(⇒ 块 id 稳定 ⇒ 共享不退化);
* 2. 换 epoch ⇒ 密文**不同**(AAD 与密钥双变);
* 3. 换组密钥 ⇒ 密文**不同**。
*/
encryptBlock(plain: Buffer): Buffer {
const iv = this.ivOf(this.key, plain)
const k = this.keyOf(this.key, iv)
const c = createCipheriv('aes-256-gcm', k, iv)
c.setAAD(this.aadOf(this.epoch))
const ct = Buffer.concat([c.update(plain), c.final()])
const tag = c.getAuthTag()
this.c.encrypts += 1
return Buffer.concat([iv, tag, ct])
}
/**
* 解密一个块。**唯一的解密实现**(调用点见文件头)。
*
* 判定顺序(每一步失败都有**独立**线索,⛔ 不合并):
* ① 形状(太短 ⇒ `too-short`);② 逐 epoch 试认证(写 epoch 优先);
* ③ 解出来的明文**复算 iv 必须等于原 iv**(确定性口径自证 + 非规范输入拦截)。
*
* @returns 明文;失败 ⇒ `undefined`(并 `decryptRejected` +1,原因落日志)
*/
decodeBlock(blob: Buffer): Buffer | undefined {
if (blob.length < MIN_BLOB_LEN) {
this.reject('too-short', `长度 ${blob.length} < ${MIN_BLOB_LEN}`)
return undefined
}
const iv = blob.subarray(0, IV_LEN)
const tag = blob.subarray(IV_LEN, MIN_BLOB_LEN)
const ct = blob.subarray(MIN_BLOB_LEN)
const tried: number[] = []
for (const cand of this.candidates()) {
tried.push(cand.epoch)
let plain: Buffer
try {
const d = createDecipheriv('aes-256-gcm', this.keyOf(cand.key, iv), iv)
d.setAAD(this.aadOf(cand.epoch))
d.setAuthTag(tag)
plain = Buffer.concat([d.update(ct), d.final()])
} catch {
continue // 认证失败(也可能是 epoch 不对)⇒ 试下一个
}
const again = this.ivOf(cand.key, plain)
if (!timingSafeEqual(again, iv)) {
this.reject('non-canonical', `epoch=${cand.epoch} 复算 iv 不符(非本实现产出?)`)
return undefined
}
this.c.decrypts += 1
return plain
}
this.reject('auth-failed', `认证失败(试过 epoch=[${tried.join(',')}])`)
return undefined
}
/** 按"过期窗口"过筛后的候选(写入 epoch 恒在;历史 epoch 超窗口即剔除并计数)。 */
private candidates(): { epoch: number; key: Buffer }[] {
const out: { epoch: number; key: Buffer }[] = [{ epoch: this.epoch, key: this.key }]
const now = this.now()
for (const p of this.previous) {
if (p.retiredAt !== undefined) {
const retired = Date.parse(p.retiredAt)
if (Number.isFinite(retired) && retired + this.graceMs < now) {
this.c.epochExpired += 1
this.log(
`[content-crypto] ⛔ epoch=${p.epoch} 超出过渡窗口(retiredAt=${p.retiredAt} + ${this.graceMs} ms < now)⇒ 不再解`,
)
continue
}
}
out.push({ epoch: p.epoch, key: p.key })
}
return out
}
private reject(reason: string, detail: string): void {
this.c.decryptRejected += 1
this.log(`[content-crypto] ⛔ 解密被拒:${reason} —— ${detail}`)
}
/**
* **字节级明文外泄自证**(`OBS-23` 判据③的落点)。
*
* 对给定的"已落地字节"扫一段**已知明文标记**:命中数必须为 **0**。
* ⛔ 本函数**只报计数、不打印字节**(打印就等于把明文写进日志 —— 自证变自毁)。
*/
scanForPlaintext(bytes: Buffer, marker: string): boolean {
this.c.plainScans += 1
if (marker === '') return false
const hit = bytes.includes(Buffer.from(marker, 'utf8'))
if (hit) {
this.c.plainLeaks += 1
this.log('[content-crypto] ⛔ 明文标记出现在已落地字节里(长度 ' + String(bytes.length) + ' B)')
}
return hit
}
/**
* **启动自证**(防"装了但一次都没走过"):
* ① 同一明文加密两次 ⇒ 比 `md5`(`detChecks` / `detMismatches`);
* ② 加密 → 落存储 → 取回 → 解密 ⇒ 与明文逐字节相同(`decrypts` 兜底);
* ③ 对密文做明文标记扫描(`plainScans` / `plainLeaks`)。
*
* ⚠️ 判据全落**计数器**(探针读得到);返回值只给调用方决定要不要打一行日志。
*/
selfProbe(marker: string, sink: { put: (blob: Buffer) => void; get: () => Buffer | undefined }): boolean {
const plain = Buffer.from(marker, 'utf8')
const a = this.encryptBlock(plain)
const b = this.encryptBlock(plain)
this.c.detChecks += 1
const same = a.equals(b)
if (!same) this.c.detMismatches += 1
this.scanForPlaintext(a, marker)
sink.put(a)
const back = sink.get()
const roundtrip = back !== undefined && this.decodeBlock(back)?.equals(plain) === true
if (!same) this.log('[content-crypto] ⛔ 确定性自证失败:同明文两次密文不同(共享会退化)')
if (!roundtrip) this.log('[content-crypto] ⛔ 取回-解密自证失败(存储或解密链路有问题)')
return same && roundtrip
}
}
/**
* 一步到位:读文件 → 造 cipher(供装配点用)。
*
* ⚠️ 刻意**不吞**具名原因:调用方拿到 `{ cipher: undefined, reason }` 后**必须**打一行
* 判别器日志(⛔ 静默"不启用"= 用户以为加密了)。
*/
export function openContentCipher(opts: {
file: string
group: string
network: string
graceMs?: number
log?: (line: string) => void
now?: () => number
previous?: readonly { epoch: number; key: Buffer; retiredAt?: string }[]
}): { cipher?: ContentCipher; load: GroupKeyLoadResult } {
const load = loadGroupKeyFile({ file: opts.file, group: opts.group, network: opts.network })
if (!load.ok) {
opts.log?.(describeGroupKey(opts.file, load))
return { load }
}
// 密钥本体**只从文件读**(⛔ 不经网络):这里重新读一次以拿到 base64 → Buffer。
const parsed = JSON.parse(readFileSync(opts.file, 'utf8')) as GroupKeyFile
const key = decodeKey(parsed.key)
if (key === undefined) {
const bad: GroupKeyLoadResult = { ok: false, reason: 'bad-key', detail: 'key 形状不对(二次读取)' }
opts.log?.(describeGroupKey(opts.file, bad))
return { load: bad }
}
const previous = (parsed.previous ?? []).map((p) => ({
epoch: p.epoch,
key: decodeKey(p.key) as Buffer,
...(p.retiredAt === undefined ? {} : { retiredAt: p.retiredAt }),
}))
const cipher = new ContentCipher({
groupKey: `${opts.network}|${opts.group}`,
epoch: load.epoch,
key,
previous,
...(opts.graceMs === undefined ? {} : { graceMs: opts.graceMs }),
...(opts.log === undefined ? {} : { log: opts.log }),
...(opts.now === undefined ? {} : { now: opts.now }),
})
opts.log?.(describeGroupKey(opts.file, load))
return { cipher, load }
}
+245
View File
@@ -0,0 +1,245 @@
/**
* 同网段 peer 发现与取块 —— **分组隔离的 peer 视图**(覆盖网络线 序㉔ · 内容分发)。
*
* ## 一句话说清它是什么
* 维护一张"**谁在哪个组、持有哪些块**"的表,并回答两个问题:
* - `candidates(id)` —— **同组**里谁有这块(⇒ 可以找它取);
* - `markDenied(name, id)` —— 这次请求**跨组**吗(⇒ 是就**显式拒绝并计数**)。
*
* ## 为什么必须"分组"(E5)
* Delivery Optimization 的 group mode 对应物:**按(用户/团队网, 局域网)分组**共享。
* ⛔ 不分组 = 一张巨网里"谁的块都能拿" ⇒ 一旦某台设备被控,它能**下毒到别的租户**
* 而内容哈希只能保证"块没被改",**保证不了"它本来就该拿到这块"**(可见性 ≠ 完整性)。
* ⇒ 分组是本单"权限只准收窄"红线(R5)的落点。
*
* ## 三条纪律
* 1. **跨组必须显式拒绝 + 计数**:返回空数组**不算拒绝**(调用方分不清"没有"与"不许")
* ⇒ 本线反复踩的假绿正是这一类"静默返空"。
* 2. **组键由 (network, group) 唯一决定**,⛔ 不许只看其一 —— 同名不同网 ⇒ 必须不同组。
* 3. **peer 宣布的持有关系是被动信息**:`addPeer` 只登记"它说自己有",
* ⛔ 与 `store.get` 的**读侧校验**是两回事(后者才是 E4 的真闸门)。
*
* ## ⛔ 本模块**不做**的事(故意)
* - 不做传输(取块走 `source.ts` 注入的 fetcher,底层复用既有 wss 通道,见交接单 §7);
* - 不做广播 / mDNS(本阶段"发现"由 relay 的在册会话表喂进来;⛔ 不新开公网口=R5);
* - 不做房间层(不在本单范围)。
*
* @module dshs/net/relay/content/peer
*/
/** 一个 peer 的声明("我在这张网的这个组里,我有这些块")。 */
export interface PeerDeclaration {
/** 逻辑名(`<network>/<hostId>`,与 relay 侧同名口径)。 */
name: string
/** 该 peer 所属网(`ops` / `u:<id>`)。 */
network: string
/** 该 peer 所属**组**(局域网 / 团队维度)。 */
group: string
/** 它声明持有的块 id。 */
holds: readonly string[]
/**
* 🆕 序㉘ · 单 B:该 peer 的**组密钥 epoch**。
* ⚠️ 与本节点不一致 ⇒ **不作为候选**(它的块 id 是另一代口径 ⇒ 取回来也拼不上)。
*/
epoch?: number
}
/** peer 层的**可断言**判别器(OBS 会断言这些键都存在且是数字)。 */
export const PEER_COUNTER_KEYS = [
'peerHits',
'peerMisses',
'crossGroupDenied',
'declarations',
'withdrawn',
// 🆕 序㉘ · 单 B:epoch 不一致被跳过的次数(⛔ 它**不是**跨组拒绝 —— 两者必须可区分)
'epochMismatch',
] as const
export type PeerCounterKey = (typeof PEER_COUNTER_KEYS)[number]
export type PeerCounters = Record<PeerCounterKey, number>
/**
* 组键:`<network>|<group>`。
*
* ⚠️ 分隔符用 `|` 而不是 `/` —— `/` 是 relay 逻辑名(`network/hostId`)的分隔符,
* 复用会让"网名里带斜杠"这类输入产生歧义(本线已有 `NAME_SEP` 的先例可循)。
*/
export function groupKeyOf(network: string, group: string): string {
return `${network}|${group}`
}
/** 判定两个 (网, 组) 是否同组(E5 的**唯一**判据处)。 */
export function sameGroup(
networkA: string,
groupA: string,
networkB: string,
groupB: string,
): boolean {
return groupKeyOf(networkA, groupA) === groupKeyOf(networkB, groupB)
}
/** `ContentPeerGroup` 构造选项。 */
export interface ContentPeerGroupOptions {
/** 本节点所属网。 */
network: string
/** 本节点所属组。 */
group: string
/** 🆕 本节点的组密钥 epoch(给了才做 epoch 一致性判定;缺省 ⇒ 与序㉔ 逐字一致)。 */
epoch?: number
/** 日志函数(观测辅助;⛔ 不替代计数)。 */
log?: (line: string) => void
/** 单块候选上限(防"一次返回上千个 peer"把控制面撑爆)。缺省 8。 */
maxCandidates?: number
}
/** 一个已登记的 peer(含它的组键与持有集)。 */
interface RegisteredPeer {
name: string
network: string
group: string
key: string
holds: Set<string>
addedAt: number
/** 🆕 声明里的 epoch(没声明 ⇒ `undefined`)。 */
epoch: number | undefined
}
/**
* 同组 peer 视图。
*
* 生命周期:`addPeer`(收到声明)→ `candidates`(查谁有)→ `markDenied`(判跨组)
* → `withdraw`(peer 下线)。全部是**同步内存操作**(relay 侧已有在线态,本层不重复探测)。
*/
export class ContentPeerGroup {
private readonly self: { network: string; group: string; key: string }
/** 🆕 本节点 epoch(`undefined` = 不做 epoch 判定,保持序㉔ 行为)。 */
private readonly selfEpoch: number | undefined
private readonly peers = new Map<string, RegisteredPeer>()
private readonly maxCandidates: number
private readonly log: (line: string) => void
private readonly c: PeerCounters = {
peerHits: 0,
peerMisses: 0,
crossGroupDenied: 0,
declarations: 0,
withdrawn: 0,
epochMismatch: 0,
}
constructor(opts: ContentPeerGroupOptions) {
this.self = { network: opts.network, group: opts.group, key: groupKeyOf(opts.network, opts.group) }
this.selfEpoch = opts.epoch
this.log = opts.log ?? (() => {})
const mc = opts.maxCandidates ?? 8
if (!Number.isInteger(mc) || mc <= 0) {
throw new Error(`content-peer: maxCandidates 必须是正整数,收到 ${String(opts.maxCandidates)}`)
}
this.maxCandidates = mc
}
/** 本节点所属组键。 */
get groupKey(): string {
return this.self.key
}
/** 判别器快照(**拷贝**)。 */
counters(): PeerCounters {
return { ...this.c }
}
/** 当前登记的 peer 数(含其它组的 —— 它们存在但**不可选**)。 */
get size(): number {
return this.peers.size
}
/** 登记 / 覆盖一个 peer 的声明。 */
addPeer(decl: PeerDeclaration): void {
const key = groupKeyOf(decl.network, decl.group)
this.peers.set(decl.name, {
name: decl.name,
network: decl.network,
group: decl.group,
key,
holds: new Set(decl.holds),
addedAt: Date.now(),
epoch: decl.epoch,
})
this.c.declarations += 1
}
/** peer 下线 ⇒ 从视图移除(其持有的块不再可选)。 */
withdraw(name: string): boolean {
const had = this.peers.delete(name)
if (had) {
this.c.withdrawn += 1
this.log(`[content-peer] withdraw ${name}`)
}
return had
}
/**
* 查"**同组**内谁持有这些块"。
*
* 🆕 单 B:**epoch 不一致的同组 peer 不作候选**(它的块 id 是另一代口径 ⇒ 取回来也拼不上),
* 且这一次跳过**单独计数** `epochMismatch`(⛔ 不许混进 `crossGroupDenied` ——
* "换了代"与"跨了组"是**两件事**,混在一起会让 `E5` 的判据失去分辨力)。
*
* @returns 同 (网, 组) 且 **epoch 一致** 的 peer 名列表(按登记序,最多 `maxCandidates`)
*/
candidates(id: string): { name: string; network: string; group: string }[] {
const out: { name: string; network: string; group: string }[] = []
for (const p of this.peers.values()) {
// ── E5 的**唯一**闸门:组键必须与本节点一致 ─────────────────────
if (p.key !== this.self.key) continue
// ── 🆕 单 B:epoch 闸门(只在本节点声明了 epoch、且对端也声明了时才判)──
if (this.selfEpoch !== undefined && p.epoch !== undefined && p.epoch !== this.selfEpoch) {
this.c.epochMismatch += 1
this.log(`[content-peer] 跳过 ${p.name}:epoch=${p.epoch} ≠ 本节点 ${this.selfEpoch}(换代会全量换 id)`)
continue
}
if (!p.holds.has(id)) continue
out.push({ name: p.name, network: p.network, group: p.group })
if (out.length >= this.maxCandidates) break
}
if (out.length > 0) this.c.peerHits += 1
else this.c.peerMisses += 1
return out
}
/**
* 判定一次请求是否**跨组**;是 ⇒ 记数并返回 `true`(调用方据此**显式拒绝**)。
*
* ⚠️ 这是 E5 判据的落点:跨组拒绝必须有**独立计数**,
* 否则"不许"与"没有"在脚本里同形(本线的假绿来源)。
*/
markDenied(name: string, id: string): boolean {
const p = this.peers.get(name)
if (p === undefined) {
// 未知 peer ⇒ 不是"跨组",是"不认识"(调用方按未命中处理)
return false
}
if (p.key === this.self.key) return false
this.c.crossGroupDenied += 1
this.log(`[content-peer] 跨组拒绝 ${name}(${p.key} ≠ ${this.self.key})请求块 ${id}`)
return true
}
/** 同组 peer 数(E5 的可读视图)。 */
sameGroupPeers(): string[] {
const out: string[] = []
for (const p of this.peers.values()) {
if (p.key === this.self.key) out.push(p.name)
}
return out
}
/** 跨组 peer 数(应当 **> 0** 才能证明"隔离真的在起作用",而不是"根本没有别人")。 */
crossGroupPeers(): string[] {
const out: string[] = []
for (const p of this.peers.values()) {
if (p.key !== this.self.key) out.push(p.name)
}
return out
}
}
+289
View File
@@ -0,0 +1,289 @@
/**
* 内容面**运行时装配**(覆盖网络线 序㉔ · 内容分发)—— 把三个零件装成一个"能报数"的整体。
*
* ## 为什么要有这个文件
* `chunker` / `store` / `source` / `peer` 四个模块都是**纯零件**:它们各自算账,
* 但**没人把它们装起来**。而 relay 是**独立进程**,它的 `/status` 里没有 `content` 块
* ⇒ `OBS-17` 在真机模式天生读不到判别器(序㉔ 首轮实测:`FAIL OBS-17 ❌ 缺 content 块缺失`)。
*
* ⛔ **不合成的后果**(本线的老毛病):要么靠 `--content-fixture` 假夹具凑绿(假绿),
* 要么让 `OBS-17` 永远红(判据形同不存在)。两条都不是"解决问题"。
*
* ## 本模块的三条纪律
* 1. **计数即真相**:`snapshot()` 直读四个零件的 `counters()`,⛔ 不做二次加工、
* ⛔ 不补零、⛔ 不"看起来有就行"。缺一档就缺一档 —— 探针会逐键点名。
* 2. **键名与探针同构**:`source` 五档 / `peer` 五键 / `store` 七键的键名**必须**和
* `source.ts` `PEER_COUNTER_KEYS` `ContentStoreCounters` 完全一致
* (探针 `OBS-17` 是逐键 `typeof === 'number'` 断言的,改名 = 静默失效)。
* 3. **纯新增、可选、缺省可用**:relay 侧没装内容面时,`snapshot()` 返回 `undefined`
* ⇒ `/status` 不含 `content` 键 ⇒ 与序㉔ 之前的字节级兼容(⛔ 不改任何既有字段)。
*
* ## ⛔ 本模块**不做**的事
* - 不做网络取块(peer 的真实取回通道在真机验证阶段由上层接线);
* - 不读 `src/config.ts`(relay 是独立单元,见 `main.ts` 头部说明);
* - 不写日志(日志在装配点给;本模块只负责**算账与报数**)。
*
* @module dshs/net/relay/content/runtime
*/
import { ContentPeerGroup } from './peer.js'
import type { PeerCounters } from './peer.js'
import { ContentSourceChain } from './source.js'
import type { SourceHitCounters, SourceTier } from './source.js'
import { ContentStore, DEFAULT_MAX_BYTES } from './store.js'
import type { ContentStoreCounters } from './store.js'
import { chunkify, planOf, reassemble, blockIdOf, DEFAULT_BLOCK_SIZE } from './chunker.js'
import type { ContentCipher, ContentCryptoCounters } from './crypto.js'
/** 内容面运行时装配选项。 */
export interface ContentRuntimeOptions {
/** 本节点所属**网**(`network.ts` 的 `OPS_NETWORK`)。 */
network: string
/** 本节点在内容面上的**组名**(同组才可互相取块 —— E5)。 */
group: string
/**
* 🆕 序㉘ · 单 B:组密钥加解密器。**缺省 `undefined` ⇒ 不启用加密**(行为逐字回到序㉔)。
* ⚠️ 给了它 ⇒ 块 id 挂**密文**("β′")、链返回**明文**、`/status` 多一个 `crypto` 块。
*/
cipher?: ContentCipher
/** 块缓存上限(字节)。缺省 `store.ts` 的 `DEFAULT_MAX_BYTES`(64 MiB)。 */
storeMaxBytes?: number
/** 单块上限(字节)。缺省 `store.ts` 的 `DEFAULT_MAX_BLOCK_BYTES`。 */
maxBlockBytes?: number
/** 命中回调(观测用)。⚠️ 与计数器**并存**:日志不能替代计数。 */
onHit?: (tier: SourceTier, id: string) => void
/** 未命中回调。 */
onMiss?: (tier: SourceTier, id: string) => void
/** 抛错回调。 */
onError?: (tier: SourceTier, id: string, err: unknown) => void
/** 🆕 解密被拒回调(**与未命中可区分**)。 */
onDecodeRejected?: (tier: SourceTier, id: string, reason: 'decode-failed') => void
/** 日志函数(可选)。⚠️ ⛔ 不许把明文块塞进日志(`OBS-23` 会扫)。 */
log?: (line: string) => void
}
/**
* 内容面 `/status` 快照 —— **探针 `OBS-17` 的读取口径**(键名即契约)。
*
* ⚠️ `source` / `peer` / `store` 三块的键名与各自模块的 counters 类型**逐字一致**;
* `blockSize` / `storeMaxBytes` 供"口径一致性"断言(第二个判据)。
*/
export interface ContentSnapshot {
/** 块大小(字节)—— 与参数表 `CONTENT_BLOCK_SIZE` 比对(口径一致)。 */
blockSize: number
/** 块缓存上限(字节)—— 与参数表 `CONTENT_STORE_MAX_BYTES` 比对(口径一致)。 */
storeMaxBytes: number
/** **内容源优先级链**的逐档命中计数(E6 的机器判据)。 */
source: SourceHitCounters
/** 逐档**未命中**计数(全档皆无时逐档留痕)。 */
sourceMiss: SourceHitCounters
/** 逐档**抛错**计数("抛错 ≠ 没有")。 */
sourceErrors: SourceHitCounters
/** 问过的档位总数(= 各次取块走过的档之和)。 */
sourceMissTotal: number
/** 🆕 逐档**解密被拒**计数(`sourceErrors` 的细分;稳态应当不增长)。 */
sourceDecodeRejected: SourceHitCounters
/** **同组 peer** 计数(含跨组拒绝 —— E5 的机器判据)。 */
peer: PeerCounters
/** 本节点组键 `` `${network}|${group}` ``。 */
peerGroup: string
/** **内容寻址存储**的七键计数。 */
store: ContentStoreCounters
/** 已占用字节数。 */
storeBytes: number
/** 已缓存块数。 */
storeBlocks: number
/** 本节点**组内**已声明的 peer 名单(供上层做真机取块接线)。 */
groupMembers: string[]
/**
* 🆕 序㉘ · 单 B:**组密钥加密判别器**(探针 `OBS-23` 的读取口径)。
* ⚠️ **不启用加密时本键整体缺席** ⇒ `OBS-23` 记 **SKIP**("缺省不启用"是合法状态)。
*/
crypto?: ContentCryptoCounters
}
/**
* 内容面运行时 —— 一个进程一份,**唯一**的报数入口。
*
* ⚠️ 生命周期:构造即建(无 IO、无监听、无端口)⇒ 对既有行为**零影响**。
* 这正是它能进 `main.ts`(relay 独立单元)而不触 R5 的原因 —— **不新增任何监听口**。
*/
export class ContentRuntime {
readonly store: ContentStore
readonly peers: ContentPeerGroup
readonly source: ContentSourceChain
/** 块大小口径(供快照与上层切分共用,避免两套默认值)。 */
readonly blockSize: number
/** 🆕 组密钥加解密器(`undefined` = 不启用加密)。 */
readonly cipher: ContentCipher | undefined
private readonly storeMaxBytes: number
/** 自证写入的最后一个块 id(仅供 `selfProbe` 读回用)。 */
private lastProbeId: string | undefined
constructor(opts: ContentRuntimeOptions) {
this.storeMaxBytes = opts.storeMaxBytes ?? DEFAULT_MAX_BYTES
this.cipher = opts.cipher
this.store = new ContentStore({
maxBytes: this.storeMaxBytes,
...(opts.maxBlockBytes === undefined ? {} : { maxBlockBytes: opts.maxBlockBytes }),
})
this.peers = new ContentPeerGroup({
network: opts.network,
group: opts.group,
// 🆕 启用加密才做 epoch 一致性判定(缺省 ⇒ 与序㉔ 逐字一致)
...(this.cipher === undefined ? {} : { epoch: this.cipher.epoch }),
...(opts.log === undefined ? {} : { log: opts.log }),
})
this.source = new ContentSourceChain({
fetchers: {
// ① 本地:内容寻址存储命中即返回(零网络 —— 最省的档位)
local: async (id: string) => {
const bytes = this.store.get(id)
return bytes === undefined
? undefined
: { tier: 'local' as const, bytes }
},
// ② 同组 peer:**诚实回"没有"**,直到上层把真实取回通道接上。
// ⛔ 绝不许在这里伪造字节 —— 那会把 E1 的"零回源"做成假绿(本线的老病根)。
peer: async (id: string) => {
const cands = this.peers.candidates(id)
if (cands.length === 0) return undefined
// 有候选但取回通道尚未接线 ⇒ 逐条记账后诚实回"没有"
for (const c of cands) this.peers.markDenied(c.name, id)
return undefined
},
},
// 🆕 单 B:**唯一解密点**(生产路径)—— 五档一律在这里解密
...(this.cipher === undefined ? {} : { decode: (stored: Buffer) => this.cipher?.decodeBlock(stored) }),
...(opts.onHit === undefined ? {} : { onHit: opts.onHit }),
...(opts.onMiss === undefined ? {} : { onMiss: opts.onMiss }),
...(opts.onError === undefined ? {} : { onError: opts.onError }),
...(opts.onDecodeRejected === undefined ? {} : { onDecodeRejected: opts.onDecodeRejected }),
})
this.blockSize = DEFAULT_BLOCK_SIZE
}
/** 🆕 是否启用加密(判据用:区分"没启用"与"启用了但没解过")。 */
get cryptoEnabled(): boolean {
return this.cipher !== undefined
}
/**
* 🆕 **写内容**(单 B 的写侧统一入口):明文 → 切块 → 加密 → 内容寻址入库。
*
* ⚠️ 顺序不可颠倒:**先切块、再逐块加密**。若先加密整条流再切,块边界会落在密文上
* ⇒ 单块改动会让其后所有块失效(丢掉 `E3`「只传变化块」)。
*/
putContent(bytes: Buffer): { plan: string[]; size: number; contentId: string; dedupIds: string[] } {
const r = chunkify(
bytes,
this.blockSize,
this.cipher === undefined ? undefined : { encode: (plain) => this.cipher?.encryptBlock(plain) as Buffer },
)
for (const c of r.chunks) this.store.put(c.id, c.bytes)
// ⚠️ 返回**逐块有序** id(`plan`)而非去重后的 `ids`:取回/重组必须按序,去重列表只作"要几个块"的口径
return { plan: r.chunks.map((c) => c.id), size: r.size, contentId: r.contentId, dedupIds: r.ids }
}
/**
* 🆕 **取内容**(单 B 的读侧统一入口):按计划逐块取(链已统一解密)→ 拼接。
*
* @returns 明文;任一块取不到 ⇒ `undefined`(⛔ 不许拼半截 —— 半截内容是最脏的失败形态)
*/
async fetchContent(ids: readonly string[]): Promise<Buffer | undefined> {
const parts: Buffer[] = []
for (const id of ids) {
const got = await this.source.fetch(id)
if (got === undefined) return undefined
parts.push(got.bytes)
}
return Buffer.concat(parts)
}
/**
* 🆕 **按已知明文重组**(`E4` 口径:逐块复算 id 后才解密拼接)。
*
* ⚠️ 与 `fetchContent` **二选一**(同一批字节只过其中一处解密点):
* 本函数是**夹具 / 工具路径**,`fetchContent` 是**生产路径**。
*/
async reassembleContent(stored: Map<string, Buffer>, ids: readonly string[]): Promise<Buffer | undefined> {
try {
return reassemble(
[...ids],
stored,
this.cipher === undefined ? undefined : { decode: (b) => this.cipher?.decodeBlock(b) },
)
} catch (err) {
this.lastReassembleError = err instanceof Error ? err.message : String(err)
return undefined
}
}
/** 最近一次 `reassembleContent` 的失败原文(⛔ 不吞错)。 */
lastReassembleError: string | undefined
/** 🆕 只算"这份内容要哪些块"(写侧 `putContent` 的坐标版 —— 查本地/peer 前用)。 */
planContent(bytes: Buffer): { ids: string[]; size: number } {
return planOf(
bytes,
this.blockSize,
this.cipher === undefined ? undefined : { encode: (plain) => this.cipher?.encryptBlock(plain) as Buffer },
)
}
/**
* 🆕 **启动自证**(单 B §5 F1/F2/F4① 的落点)—— 全走**真实**读写路径:
* ① 同一明文加密两次 ⇒ 比字节(`detChecks` / `detMismatches`);
* ② 加密 → `store.put` → `source.fetch`(**走优先级链**)→ 解密 ⇒ 与明文逐字节相同
* (顺带让 `local` 档命中 +1 ⇒ `OBS-17` 的活性判据不因加密而退化);
* ③ 对密文做**字节级**明文标记扫描(`plainScans` / `plainLeaks`)。
*
* ⛔ 不启用加密 ⇒ 直接返回 `undefined`(不打日志、不计数)。
*/
async selfProbe(marker: string): Promise<boolean | undefined> {
const cipher = this.cipher
if (cipher === undefined) return undefined
const plain = Buffer.from(marker, 'utf8')
const ok = cipher.selfProbe(marker, {
put: (blob) => {
const id = blockIdOf(blob)
this.lastProbeId = id
this.store.put(id, blob)
},
get: () => (this.lastProbeId === undefined ? undefined : this.store.get(this.lastProbeId)),
})
// ② 再走一次**优先级链**(local 档命中 +1;链上的解密点与 crypto.selfProbe 的不是同一批字节)
if (this.lastProbeId !== undefined) {
const got = await this.source.fetch(this.lastProbeId)
if (got === undefined || !got.bytes.equals(plain)) return false
}
return ok
}
/**
* 报数 —— **直读**四个零件的 counters(⛔ 不做二次加工、⛔ 不补零)。
*
* ⚠️ 探针 `OBS-17` 三件套全从本快照读:① 判别器齐全(逐键 `number`)
* ② 口径一致(`blockSize` / `storeMaxBytes`)③ 活性(`source.local + source.peer`)。
* 🆕 探针 `OBS-23` 读 `crypto` 块的十个键(**不启用加密时整块缺席 ⇒ 记 SKIP**)。
*/
snapshot(): ContentSnapshot {
return {
blockSize: this.blockSize,
storeMaxBytes: this.storeMaxBytes,
source: this.source.counters(),
sourceMiss: this.source.missCounters(),
sourceErrors: this.source.errors(),
sourceMissTotal: this.source.misses(),
sourceDecodeRejected: this.source.decodeRejected(),
peer: this.peers.counters(),
peerGroup: this.peers.groupKey,
store: this.store.counters(),
storeBytes: this.store.bytes,
storeBlocks: this.store.size,
groupMembers: this.peers.sameGroupPeers(),
// ⚠️ 不启用加密 ⇒ 本键**整体缺席**(不是补零!补零会让"没启用"与"启用了但零值"同形)
...(this.cipher === undefined ? {} : { crypto: this.cipher.counters() }),
}
}
}
+225
View File
@@ -0,0 +1,225 @@
/**
* 内容源优先级链 —— **"这个块去哪儿取"的唯一裁决处**(覆盖网络线 序㉔ · 内容分发)。
*
* ## 一句话说清它是什么
* 照搬 SCCM / Delivery Optimization 的**内容源优先级**(交接单 §7 要求"照抄三件套"):
*
* ```
* 本地磁盘 → 同局域网 peer → 同区域边缘缓存 → 区域分发点 → 公网源
* ```
*
* **按顺序问**,第一个给出内容的档位就是本次来源 —— 这叫"**点名**"(E6)。
* 越靠前的档位 ⇒ 越省带宽(`local` 零网络、`peer` 走内网、`origin` 走公网)。
*
* ## 为什么"点名"必须是计数而不是日志
* 本线复盘里的原话是「**静默失效靠判别器定位**」。一条 `console.log('命中 peer')` 在
* 脚本里**无法断言** ⇒ 实现退化成"每次都打 origin"时,日志照样在刷、判据照样全绿。
* 所以:**每次命中/未命中/抛错都落计数器**,⛔ 一个都不许省(E6 的机器判据 = 计数递增)。
*
* ## 三条纪律
* 1. **顺序是硬约束**:⛔ 不许"哪个快用哪个" —— 那会让 peer 永远打不过本地缓存,
* 于是"同网段共享"这个**本单的核心收益**静默消失(而日志看起来一切正常)。
* 2. **抛错 ≠ 没有**:某一档抛错要**单独计数**并**继续下一档**。
* ⛔ 不许整体失败(链的鲁棒性是"稳定"那一半),⛔ 也不许吞掉(否则"配置错"伪装成"没有")。
* 3. **全档皆无 ⇒ 逐档留痕**:`misses()` 必须等于问过的档数。⛔ 静默返空 = 本线的假绿 source。
*
* ## 🆕 序㉘ · 单 B:**唯一解密点**(给了 `decode` 才生效;缺省 ⇒ 行为逐字不变)
* 加密启用后,各档拿回来的都是**落库字节**(密文)。解密**必须只有一处**:
* - 落在这里的**单一返回点** ⇒ 五档**一律**同一口径(⛔ 杜绝"local 档不解密、peer 档解密");
* - 解密失败**按"抛错"处置**(`errorCounts` + `decodeRejected` **各 +1**,并**具名回调**)
* 然后**继续下一档** —— 因为"这一档的字节解不开"与"这一档没有这块"在脚本里
* 必须**可区分**(本线的老病根:两类失败同形)。
* - ⚠️ 给了 `decode` ⇒ 本链返回的是**明文**;`chunker#reassemble` 的 `decode` 是**另一条**
* 装配路径(夹具 / 工具),**同一批字节只过其中一处**,⛔ 不叠加。
*
* ## ⛔ 本模块**不做**的事(故意)
* - 不认识 HTTP / WebSocket:每个档位是一个**注入的 fetcher**(便于夹具替身与真机装配);
* - 不决定"去哪找 peer 列表"(那是 `peer.ts`);
* - 不做缓存写入(那是 `store.ts`;链只负责**取**);
* - 🔴 **不做完整性校验**(`E4` 那半边在 `store.get` 的读侧复算里)。⚠️ **如实留档**:
* `peer` 档的真实取回通道**尚未接线** ⇒ 接线时**必须**在取回后复算 `blockIdOf`
* (否则"篡改块被丢弃"只在 `local` 档成立 —— 已在单 B §8.13 登记)。
*
* @module dshs/net/relay/content/source
*/
/** 内容源档位(五档,顺序即优先级)。 */
export type SourceTier = 'local' | 'peer' | 'edge' | 'region' | 'origin'
/**
* **唯一权威**的档位顺序。
* ⚠️ 任何地方要遍历档位都从这里取(⛔ 不许在调用方重写一份数组 —— 散着写迟早出现三套口径)。
*/
export const DEFAULT_TIER_ORDER: readonly SourceTier[] = ['local', 'peer', 'edge', 'region', 'origin']
/** 与 `DEFAULT_TIER_ORDER` **同源**的导出别名(供按名引用,语义上强调"这就是全部档位")。 */
export const SOURCE_TIERS: readonly SourceTier[] = DEFAULT_TIER_ORDER
/** 单档取块结果:给出内容即命中。 */
export interface TierFetchResult {
/** 该档自报的档位 —— ⚠️ 必须**回显**调用方传入的档位(便于断言"确实是它给的")。 */
tier: SourceTier
bytes: Buffer
}
/** 单档 fetcher:拿到内容就返回;"我这没有"就返回 `undefined`;出错就抛。 */
export type TierFetcher = (id: string) => Promise<TierFetchResult | undefined>
/** 按档位的**命中计数**(E6 的机器判据)。 */
export type SourceHitCounters = Record<SourceTier, number>
/** 所有档位计 0。 */
export function emptySourceCounters(): SourceHitCounters {
return { local: 0, peer: 0, edge: 0, region: 0, origin: 0 }
}
/** `ContentSourceChain` 构造选项。 */
export interface ContentSourceChainOptions {
/** 各档的取块实现。缺某一档 ⇒ 该档视为"永远没有"(但仍**参与遍历与计数**)。 */
fetchers: Partial<Record<SourceTier, TierFetcher>>
/** 命中回调(观测用)。⚠️ 与计数器**并存**:日志不能替代计数。 */
onHit?: (tier: SourceTier, id: string) => void
/** 未命中回调。 */
onMiss?: (tier: SourceTier, id: string) => void
/** 抛错回调。 */
onError?: (tier: SourceTier, id: string, err: unknown) => void
/**
* 🆕 序㉘ · 单 B:**唯一解密点**(`content/crypto.ts#decodeBlock` 的注入位)。
* 给了它 ⇒ 链返回**明文**;返回 `undefined` = 认证失败 ⇒ 本档按"抛错"处置并继续下一档。
*/
decode?: (stored: Buffer) => Buffer | undefined
/** 🆕 解密被拒回调(**与未命中可区分**:`reason` 恒为 `decode-failed`)。 */
onDecodeRejected?: (tier: SourceTier, id: string, reason: 'decode-failed') => void
/** 覆盖档位顺序(⚠️ 只给单测做"顺序敏感"验证用;生产一律用 `DEFAULT_TIER_ORDER`)。 */
order?: readonly SourceTier[]
}
/** 取块结果(含**点名**的来源档位与"问过几档")。 */
export interface SourceFetchOutcome {
/** 拿到内容的档位。 */
tier: SourceTier
/** 内容字节。 */
bytes: Buffer
/** 本次为找它问过的档位(含命中那一档),按问询顺序。 */
tried: SourceTier[]
}
/**
* 内容源优先级链。
*
* 用法(生产装配):
* ```ts
* const chain = new ContentSourceChain({ fetchers: { local, peer, edge, region, origin } })
* const hit = await chain.fetch(blockId) // undefined = 五档皆无
* ```
*/
export class ContentSourceChain {
private readonly fetchers: Partial<Record<SourceTier, TierFetcher>>
private readonly order: readonly SourceTier[]
private readonly hits: SourceHitCounters = emptySourceCounters()
private readonly missCounts: SourceHitCounters = emptySourceCounters()
private readonly errorCounts: SourceHitCounters = emptySourceCounters()
/** 🆕 解密被拒计数(逐档 —— 它同时**并入** `errorCounts`,此处是"为什么炸"的细分)。 */
private readonly decodeRejectCounts: SourceHitCounters = emptySourceCounters()
private readonly onHit: ((tier: SourceTier, id: string) => void) | undefined
private readonly onMiss: ((tier: SourceTier, id: string) => void) | undefined
private readonly onError: ((tier: SourceTier, id: string, err: unknown) => void) | undefined
private readonly decode: ((stored: Buffer) => Buffer | undefined) | undefined
private readonly onDecodeRejected: ((tier: SourceTier, id: string, reason: 'decode-failed') => void) | undefined
constructor(opts: ContentSourceChainOptions) {
this.fetchers = opts.fetchers
this.order = opts.order ?? DEFAULT_TIER_ORDER
this.onHit = opts.onHit
this.onMiss = opts.onMiss
this.onError = opts.onError
this.decode = opts.decode
this.onDecodeRejected = opts.onDecodeRejected
}
/** 命中计数快照(**拷贝**)。 */
counters(): SourceHitCounters {
return { ...this.hits }
}
/** 未命中计数快照("这一档我问了、它说没有")。 */
missCounters(): SourceHitCounters {
return { ...this.missCounts }
}
/** 抛错计数快照("这一档我问了、它炸了")。⚠️ 与未命中**可区分**是纪律 2。 */
errors(): SourceHitCounters {
return { ...this.errorCounts }
}
/** 🆕 解密被拒计数快照(逐档)。⚠️ 这是 `errors()` 的**子集**("炸"的一种具体原因)。 */
decodeRejected(): SourceHitCounters {
return { ...this.decodeRejectCounts }
}
/** 🆕 解密被拒**合计**(判据用:稳态下应当**不增长** —— 见单 B §9-6)。 */
decodeRejectedTotal(): number {
return Object.values(this.decodeRejectCounts).reduce((a, b) => a + b, 0)
}
/** 是否装配了解密点(判据用:区分"没启用加密"与"启用了但没解过")。 */
get decodeEnabled(): boolean {
return this.decode !== undefined
}
/** 未命中合计数(= 问过但没有内容的档位总次数)。 */
misses(): number {
return Object.values(this.missCounts).reduce((a, b) => a + b, 0)
}
/**
* 按优先级链取一个块。
*
* @returns 命中 ⇒ `SourceFetchOutcome`(含**点名档位**);五档皆无 ⇒ `undefined`
*/
async fetch(id: string): Promise<SourceFetchOutcome | undefined> {
const tried: SourceTier[] = []
for (const tier of this.order) {
tried.push(tier)
const fetcher = this.fetchers[tier]
if (fetcher === undefined) {
// 该档没装配 ⇒ 视为"没有",但**仍然计数**(否则"忘了装配"会静默变成"链路短了")
this.missCounts[tier] += 1
this.onMiss?.(tier, id)
continue
}
let got: TierFetchResult | undefined
try {
got = await fetcher(id)
} catch (err) {
// 纪律 2:抛错单独计数,且**继续往下一档**(不许整体失败、不许吞)
this.errorCounts[tier] += 1
this.onError?.(tier, id, err)
continue
}
if (got === undefined) {
this.missCounts[tier] += 1
this.onMiss?.(tier, id)
continue
}
// ── 🆕 单 B:**唯一解密点**(五档一律走这里 ⇒ ⛔ 不存在按档位分叉的双口径)──────
let bytes = got.bytes
if (this.decode !== undefined) {
const plain = this.decode(bytes)
if (plain === undefined) {
// 解密失败按"抛错"处置(**可区分**于"没有"),并**继续下一档**
this.errorCounts[tier] += 1
this.decodeRejectCounts[tier] += 1
this.onError?.(tier, id, new Error(`content-source: tier=${tier} 取回的字节解密失败(认证未过)`))
this.onDecodeRejected?.(tier, id, 'decode-failed')
continue
}
bytes = plain
}
this.hits[tier] += 1
this.onHit?.(tier, id)
return { tier, bytes, tried }
}
return undefined
}
}
+282
View File
@@ -0,0 +1,282 @@
/**
* 内容寻址存储 —— **"哈希 → 块"的本地仓库**(覆盖网络线 序㉔ · 内容分发)。
*
* ## 一句话说清它是什么
* 进程内的块仓库:`put(bytes) -> id`、`get(id) -> bytes|undefined`、`has(id) -> bool`。
* 一切以 **id(内容哈希)** 为键 ⇒ 同一份字节无论来自本地生成、peer 取回还是回源,
* **只会存一份**(天然去重 = E1 的基础)。
*
* ## 三条硬约束(每一条都对应本线踩过的病)
* 1. **落盘前 / 取出后都校验** —— 存进来时算一次 id 对齐;取出去时再算一次
* (防"磁盘写坏 / 被进程外改过")。**任一不符即丢弃并计数**(E4 的另一半)。
* 2. **必须有上限** —— 块缓存是"能不要就不要"的加速层,⛔ **不许无界增长**。
* 超限按 **LRU** 淘汰(`maxBytes`)。S0 P5 已确认:47 的 `MEM_BUDGET_MB = 1002`
* 且 `MEM_PER_HOST_MB = 0.06` 只是**空闲会话**口径 ⇒ 块缓存必须**另立预算、另立上限**。
* 3. **计数全部可断言** —— 命中 / 未命中 / 淘汰 / 校验失败 / 拒绝超限,
* **每一项都落计数器**(⛔ 不许只写日志):这是 E6 与"静默放行"回头条件的机器判据。
*
* ## 🆕 序㉘ · 单 B:**本层只处理"落库字节"**(启用组密钥加密时 = 密文)
* 🔑 本单**不需要在本模块里加解密**,理由是一条源码事实:**键就是 id,而 id 是由字节算出来的**
* ⇒ 口径只能有**一个**定义处(`chunker.ts`:`blockIdOf(落库字节)`)。所以:
* - 调用方给什么口径的字节,本模块就存什么、校验什么 —— 加密启用后它拿到的是**密文**;
* - 于是"中继进程持有什么"完全由调用方决定 ⇒ **`OBS-23` 的"明文不出现"判据落在装配层**
* (`runtime.ts` / `main.ts` 的自证),⛔ 不是这里。
* - ⚠️ **`decryptRejected` 落在 `crypto.ts` 的计数块**(`content.crypto`),**⛔ 不在此处**:
* 解密只有一个实现(`ContentCipher.decodeBlock`),把它的失败计数也写进 store 的
* 7 键里会造成"同一事实两处写"(本线明令禁止)。⇒ 本模块的 7 键口径**一行未动**
* (探针 `OBS-17` 对它们逐键断言)。
*
* ## ⛔ 本模块**不做**的事(故意)
* - 不做网络(取块走哪条链 = `source.ts`;从谁取 = `peer.ts`);
* - 不做持久化格式(本阶段内存 + 可选目录落盘由调用方注入,见 `dir` 选项);
* - 不做跨进程共享(那是 relay / peer 层的事);
* - ⛔ **不做加解密**(那是 `crypto.ts`;本层只认字节与 id)。
*
* @module dshs/net/relay/content/store
*/
import { mkdirSync, readFileSync, writeFileSync, existsSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { blockIdOf, isBlockId, BLOCK_ID_HEX_LEN } from './chunker.js'
/** 块仓库的**可断言**计数(⛔ 不许只写日志 —— 见文件头约束 3)。 */
export interface ContentStoreCounters {
/** `put` 成功入库的块次数(重复内容会被去重 ⇒ 可能 < 调用次数)。 */
puts: number
/** `put` 因**内容与声明 id 不符**被拒的次数(E4 的写侧)。 */
putRejected: number
/** `get` 命中次数(本地已有 ⇒ ⛔ 不用回源,这是 E1 的直接来源)。 */
hits: number
/** `get` 未命中次数。 */
misses: number
/** 取出后**复算校验失败**被丢弃的次数(E4 的读侧)。 */
corruptReads: number
/** 因超出 `maxBytes` 被 LRU 淘汰的块数。 */
evicted: number
/** 因**单块大于 `maxBytes`**(永远放不下)被拒的次数。 */
oversizeRejected: number
}
/** `ContentStore` 的构造选项。 */
export interface ContentStoreOptions {
/** 容量上限(字节)。必须 > 0。缺省 **64 MiB** —— 见 `DEFAULT_MAX_BYTES` 的推算。 */
maxBytes?: number
/**
* 可选的落盘目录。给了就**同时**写盘(重启后仍在),且 `get` 先查内存再查盘。
* ⚠️ 落盘块**同样在读出时复算校验**(磁盘不是可信来源)。
*/
dir?: string
/** 单块上限(字节)。缺省 = `maxBytes`(即"只要装得下就收")。 */
maxBlockBytes?: number
}
/**
* 默认容量上限 —— **64 MiB**。
*
* 推算(S0 P5 实测):47 上 `MEM_BUDGET_MB = 1002 MB`,而 relay 侧
* `MEM_PER_HOST_MB = 0.06` 只是**空闲会话**斜率、**不含**带流量的 per-stream 缓冲
* (参数表 §9 在册未测项)⇒ 块缓存**不能**去挤那份预算。
* 取 64 MiB ≈ 6.4% 的 `MEM_BUDGET_MB`,且能**整份装下 6 份** 10.8 MB 的首屏包
* (`6 × 10.8 = 64.8`,按 1 MiB 块去重后更宽松)—— 够覆盖"同组内一台 peer 服务另外几台"。
* ⚠️ 这是**保守初值**,真机验收(S6)后由参数表 `CONTENT_STORE_MAX_BYTES` 固化。
*/
export const DEFAULT_MAX_BYTES = 64 * 1024 * 1024
/** 一个块仓库条目(内存态)。 */
interface Entry {
bytes: Buffer
/** 最近一次访问的单调序号(LRU 用)。 */
seq: number
}
/** 内容寻址存储。**非线程安全**(Node 单线程事件循环内使用)。 */
export class ContentStore {
private readonly map = new Map<string, Entry>()
private readonly maxBytes: number
private readonly maxBlockBytes: number
private readonly dir: string | undefined
private usedBytes = 0
private seq = 0
private readonly c: ContentStoreCounters = {
puts: 0,
putRejected: 0,
hits: 0,
misses: 0,
corruptReads: 0,
evicted: 0,
oversizeRejected: 0,
}
constructor(opts: ContentStoreOptions = {}) {
const max = opts.maxBytes ?? DEFAULT_MAX_BYTES
if (!Number.isFinite(max) || max <= 0) {
throw new Error(`content-store: maxBytes 必须是正数,收到 ${String(opts.maxBytes)}`)
}
this.maxBytes = max
const mb = opts.maxBlockBytes ?? max
if (!Number.isFinite(mb) || mb <= 0) {
throw new Error(`content-store: maxBlockBytes 必须是正数,收到 ${String(opts.maxBlockBytes)}`)
}
this.maxBlockBytes = Math.min(mb, max)
this.dir = opts.dir
if (this.dir !== undefined) mkdirSync(this.dir, { recursive: true })
}
/** 当前占用字节数。 */
get bytes(): number {
return this.usedBytes
}
/** 当前块数。 */
get size(): number {
return this.map.size
}
/** 计数快照(**拷贝**,调用方拿到的不会被后续写入改动)。 */
counters(): ContentStoreCounters {
return { ...this.c }
}
/** 是否持有该块(⛔ 不触发校验 —— 只问"在不在")。 */
has(id: string): boolean {
if (!isBlockId(id)) return false
if (this.map.has(id)) return true
if (this.dir !== undefined) {
const p = this.pathOf(id)
return existsSync(p) && statSync(p).size > 0
}
return false
}
/**
* 存入一个块。
*
* @param id 调用方声明的块 id(来自 `chunker.blockIdOf` / 计划 / 对端公告)
* @param bytes 块字节
* @throws 当 id 形状非法、或**内容复算 id ≠ 声明 id**、或块超过单块上限时抛错
* (⛔ 静默丢弃会让"篡改块"看起来像"从没收到",是本线反复要根治的假绿)
*/
put(id: string, bytes: Buffer): void {
if (!isBlockId(id)) {
this.c.putRejected += 1
throw new Error(`content-store: 非法块 id(长度须为 ${BLOCK_ID_HEX_LEN} 的小写 hex):${id}`)
}
if (bytes.length > this.maxBlockBytes) {
this.c.oversizeRejected += 1
throw new Error(
`content-store: 块 ${id} 大小 ${bytes.length}B 超过单块上限 ${this.maxBlockBytes}B(永远放不下 ⇒ 拒绝)`,
)
}
// ── E4 写侧:入库前**必须**复算 id ──────────────────────────────────
const actual = blockIdOf(bytes)
if (actual !== id) {
this.c.putRejected += 1
throw new Error(`content-store: 块校验失败(丢弃)expected=${id} actual=${actual}`)
}
// 同内容重复入库 = 去重(不重复计容、不覆盖已有 seq)
const existed = this.map.get(id)
if (existed !== undefined) {
existed.seq = ++this.seq
return
}
const own = Buffer.from(bytes) // 复制,防调用方复用 buffer 导致内容漂移
this.map.set(id, { bytes: own, seq: ++this.seq })
this.usedBytes += own.length
this.c.puts += 1
if (this.dir !== undefined) {
try {
writeFileSync(this.pathOf(id), own)
} catch {
/* 落盘失败不影响内存命中(内存才是权威;落盘只是重启后的加速) */
}
}
this.evictIfNeeded()
}
/**
* 取出一个块。**取出后复算校验**(E4 读侧)—— 不符即**删除并返回 `undefined`**。
*
* ⚠️ 返回 `undefined` 的两种含义**必须可区分**(这正是"静默"的来源):
* 调用方据 `counters().corruptReads` / `misses` 的增量判断是"没有"还是"取出来是坏的"。
*/
get(id: string): Buffer | undefined {
if (!isBlockId(id)) {
this.c.misses += 1
return undefined
}
const hit = this.map.get(id)
if (hit !== undefined) {
const verify = blockIdOf(hit.bytes)
if (verify !== id) {
// 内存里的块被改过(理论上不该发生)⇒ 丢弃 + 计数
this.c.corruptReads += 1
this.map.delete(id)
this.usedBytes -= hit.bytes.length
this.removeOnDisk(id)
return undefined
}
hit.seq = ++this.seq
this.c.hits += 1
return Buffer.from(hit.bytes)
}
// 内存没有 ⇒ 查盘(落盘块**同样校验**)
if (this.dir !== undefined) {
const p = this.pathOf(id)
try {
const buf = readFileSync(p)
const verify = blockIdOf(buf)
if (verify !== id) {
this.c.corruptReads += 1
this.removeOnDisk(id)
return undefined
}
// 从盘回填内存(并计容),再按 LRU 裁剪
const own = Buffer.from(buf)
this.map.set(id, { bytes: own, seq: ++this.seq })
this.usedBytes += own.length
this.evictIfNeeded()
this.c.hits += 1
return Buffer.from(own)
} catch {
/* 盘上也没有 ⇒ 落进下面的 misses */
}
}
this.c.misses += 1
return undefined
}
/** 某块在落盘目录里的路径(`dir` 未设时无意义)。 */
private pathOf(id: string): string {
return join(this.dir as string, id)
}
private removeOnDisk(id: string): void {
if (this.dir === undefined) return
try {
const p = this.pathOf(id)
if (existsSync(p)) writeFileSync(p, Buffer.alloc(0)) // 截断为 0 ⇒ `has()` 视为不存在
} catch {
/* 删不掉不影响内存态 */
}
}
/** 超出上限 ⇒ 按 LRU 淘汰,直到 `usedBytes <= maxBytes`。 */
private evictIfNeeded(): void {
while (this.usedBytes > this.maxBytes && this.map.size > 0) {
let victim: string | undefined
let oldest = Number.POSITIVE_INFINITY
for (const [id, e] of this.map) {
if (e.seq < oldest) {
oldest = e.seq
victim = id
}
}
if (victim === undefined) break
const e = this.map.get(victim)
this.map.delete(victim)
if (e !== undefined) this.usedBytes -= e.bytes.length
this.removeOnDisk(victim)
this.c.evicted += 1
}
}
}
+54 -3
View File
@@ -38,6 +38,13 @@ import {
verify as cryptoVerify,
type KeyObject,
} from 'node:crypto'
/**
* 序㉖:候选链的**排序输入**(jitter 主序)。
*
* ⚠️ 这三个名字是**本序新增**的唯一跨模块依赖方向:`directory` → `jitter`(⛔ 反向不许有,
* 否则 `jitter` 里就会长出一份取址 —— 本线"另一份实现 = 另一处静默失效"的教训)。
*/
import { orderByJitter, sharedJitterTracker, type JitterTracker } from './jitter.js'
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
// 序④(443/TCP 兜底 · L1):**取目录这一腿也要走地址覆盖**(另一处注入点在 `client.ts` 的建连点)。
@@ -598,6 +605,16 @@ export interface ResolveOverlayRelayOptions {
* 缺省 / 空数组 ⇒ **行为与改造前逐字一致**(D9:存量调用点零影响)。
*/
exclude?: readonly string[]
/**
* **抖动采样表**(序㉖ · 骨干稳定选路的输入)。
*
* - 缺省(`undefined`)⇒ 用进程级共享 tracker({@link sharedJitterTracker})—— **装配点零改动**
* 就能让"候选链按 jitter 排序"生效(⛔ 不做成"必须注入":装配点在别的文件里,
* 注不进去 = 静默失效)。
* - 显式给 `null` ⇒ **本函数不排序**(夹具/对照实验用)。
* - 🔴 **零样本 ⇒ 逐字返回原数组**(`D9`)⇒ 改造前后**逐字一致**,本序的零回归就靠这条。
*/
jitterTracker?: JitterTracker | null
}
/**
@@ -638,6 +655,25 @@ async function resolveOverlayRelayChain(
const doFetch = opts.fetchImpl ?? fetch
const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
const seeds = opts.seeds.map((s) => s.trim()).filter((s) => s !== '')
/**
* ── 序㉖:**候选排序 = jitter 为主序**(用户口径「连接稳定高效」的落点)──
*
* - `opts.jitterTracker === null` ⇒ 不排序(对照实验 / 夹具);
* - 缺省 ⇒ 进程级共享 tracker({@link sharedJitterTracker})⇒ 装配点**零改动**即生效;
* - 🔴 **零样本 ⇒ `orderByJitter` 原样返回同一个数组** ⇒ 输出与改造前**逐字一致**
* (`D9`;本序的零回归判据就是它 —— `npm test` 的 176 条里没有任何一条喂过 jitter 样本)。
*/
const jitter = opts.jitterTracker === null ? undefined : opts.jitterTracker ?? sharedJitterTracker()
const jitterOrder = (urls: readonly string[]): readonly string[] => {
const ordered = orderByJitter(urls, jitter)
if (ordered !== urls) {
log(
`[overlay-dir] ↪ jitter 主序:候选重排(已测样本者按 p95|ΔRTT| 升序在前,未测者保原序在后)` +
`|新序 = ${ordered.join(' > ')}|原序 = ${urls.join(' > ')}`,
)
}
return ordered
}
// ① env 显式:运维最后手段。**支持空串=未配**,但配错了(非 http/ws 地址)要明确报一行。
const envUrl = (opts.envUrl ?? '').trim()
@@ -658,7 +694,12 @@ async function resolveOverlayRelayChain(
const urls = listCandidatesFromDoc(cached.entry.doc)
if (urls.length > 0) {
log(`[overlay-dir] 取址 = 缓存目录(未过期,net=${cached.entry.doc.network}):${urls[0]}(候选 ${urls.length} 条)`)
return { urls, source: 'cache', detail: cacheFile, refreshAfterSeconds: cached.entry.doc.refreshAfterSeconds }
return {
urls: jitterOrder(urls),
source: 'cache',
detail: cacheFile,
refreshAfterSeconds: cached.entry.doc.refreshAfterSeconds,
}
}
}
@@ -703,7 +744,12 @@ async function resolveOverlayRelayChain(
`[overlay-dir] 取址 = 签名目录(来自 ${dirUrl},version=${got.doc.version},` +
`net=${got.doc.network},relays=${got.doc.relays.length},bootstrap=${got.doc.bootstrap.length}):${urls[0]}(候选 ${urls.length} 条)`,
)
return { urls, source: 'seed-directory', detail: dirUrl, refreshAfterSeconds: got.doc.refreshAfterSeconds }
return {
urls: jitterOrder(urls),
source: 'seed-directory',
detail: dirUrl,
refreshAfterSeconds: got.doc.refreshAfterSeconds,
}
}
// ④ 离线降级:目录全都取不到 / 全被拒 ⇒ 用**过期但签名有效**的缓存(D3 ③)。
@@ -711,7 +757,12 @@ async function resolveOverlayRelayChain(
const urls = listCandidatesFromDoc(cached.entry.doc)
if (urls.length > 0) {
log('[overlay-dir] ⚠ 目录不可达 ⇒ 离线降级:用**过期缓存**里的地址(已建连接不受影响)')
return { urls, source: 'stale-cache', detail: cacheFile, refreshAfterSeconds: cached.entry.doc.refreshAfterSeconds }
return {
urls: jitterOrder(urls),
source: 'stale-cache',
detail: cacheFile,
refreshAfterSeconds: cached.entry.doc.refreshAfterSeconds,
}
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* 覆盖网络 R4 / 序㉑ **P-2** / 序㉒ **P-2b**:`translateEndpoint` 的**纯判定**部分(+ 键口径索引)。
*
* ## 它治的是什么
*
* `via='relay'` 的 host,其实例在 Worker 上监听 `127.0.0.1:<实例端口>`,而 Manager 要拨的是
* relay 为那个端口在**中继机**上开的动态回环口号 ⇒ 必须在 Manager 侧翻译一次。
*
* 原实现把这个判定写在 `src/web/server.ts` 的 `buildServer` **闭包**里,于是:
*
* 1. **不可先红后绿**(闭包无法单测)⇒ 本轮先把判定抽成纯函数,再改语义;
* 2. 🔴 **键口径不一致 ⇒ 整个闭包是死分支**(P-2 的实体):控制面所有表(`hostVia` /
* `relayEndpoints` / 拨号池)的键都是**逻辑名** `<network>/<hostId>`,而调用方
* (`RemoteSpawner.translateEndpoint(host.hostId, raw)`)只给得到**裸 hostId**
* ⇒ `hostVia.get(hostId)` 恒 `undefined` ⇒ 早退原样透传 ⇒ 翻译**从未生效**。
* 修法 = 先过 `hostNameIndex` 把裸 hostId 换成逻辑名,再判(见下)。
*
* ## 判定顺序(**失败关闭**是最后一条硬要求)
*
* | 情形 | 结果 |
* |---|---|
* | 未知 host(不在 `dsh_hosts` 里) | **原样透传**(保持老行为:单机 / 默认 host 不受影响) |
* | `via` 不是 `relay`(`local` / `manager-ssh`) | **原样透传**(隧道是同号反向转发,两边口号相同) |
* | `via = relay`:① 拨号落点 | `127.0.0.1:<拨号口>` |
* | `via = relay`:② **订阅推送落点**(序㉒ P-2b) | `127.0.0.1:<推送口>` |
* | `via = relay`:③ relay 快照落点 | `127.0.0.1:<快照口>` |
* | `via = relay`:三级都没有 | **`unreachable`(⛔ 绝不原样透传)** |
*
* 🔴 **三级链必须与地址解析链逐级对齐**(序㉒ P-2b):`src/web/server.ts#RelayRendezvous.addressOf`
* 的链是 ① 拨号 → ② `presenceLocalPort`(订阅推送)→ ③ relay 快照。本判定**只做两级**时,
* P-1 修好之后会**真的**判错:门判据改成「订阅已建立 ∧ 链路活着」后订阅新鲜期长期成立
* ⇒ 快照(③)趋冷,而拨号池(①)在"该 host 的槽位分不出来"时也给不出落点 ⇒ 落到"两级都没有"
* ⇒ **判实例不可达(失败关闭)**,尽管**同一时刻 `addressOf` 能从订阅推送里答出落点**。
* 症状 = 用户看到"实例打不开",而地址解析链自己明明有答案 —— 典型的**两条链漂移**。
*
* 🔴 判据必须取 **`dsh_hosts.via` 原文**,⛔ **不能**取 `reachability.via`:后者在 host 离线 /
* relay 快照陈旧时为 `undefined`,据此判"不是 relay ⇒ 原样透传"就是**失败开放** ——
* 把 Worker 侧口号打到 Manager 本机(2026-09-16 实测:浏览器只见空响应、平台零日志)。
*
* @module dshs/net/relay/endpoint-target
*/
import { VIA_RELAY } from '../reachability.js'
import { OPS_NETWORK, logicalName } from './network.js'
/**
* 判定结果。
*
* `passthrough` 与 `unreachable` **必须分开**:前者是"这条路径本来就不需要翻译",
* 后者是"需要翻译但查不到 ⇒ 实例此刻不可达" —— 合成一个 `undefined` 就会把
* "不需要翻译"误判成"不可达"(掉路由 = 本线最贵的一类假红)。
*
* `via` 三支(`dialed` / `pushed` / `snapshot`)**必须可分辨**:它是"落点是从哪一级拿到的"
* 的唯一证据(判别器纪律 —— 出问题时能一眼看出两条链是否走了同一级)。
*/
export type RelayEndpointDecision =
| { kind: 'passthrough'; why: 'unknown-host' | 'not-relay' }
| { kind: 'local'; port: number; via: 'dialed' | 'pushed' | 'snapshot' }
| { kind: 'unreachable'; why: 'no-dialed-port' }
export interface RelayEndpointTargetInput {
/** host 是否在控制面目录里(`dsh_hosts`)—— 未知 ⇒ 老行为。 */
known: boolean
/** `dsh_hosts.via` **原文**(`known = false` 时无意义)。 */
via: string | undefined
/**
* **拨号落点**查询(R5:落点在 Manager 本机)。
*
* ⚠️ 传 thunk 而不是值:`RelayDialer#localPortFor` **会按需绑池口 / 发起一次拨号**
* (有副作用,且是请求路径上的同步调用)⇒ 只有真的需要它(`known ∧ via === relay`)时才准调用。
* 传值会把"非 relay 的 host 也去占一个池口"变成常态。
*/
dialedPort: () => number | undefined
/**
* **订阅推送**里的回环落点(序㉒ P-2b)—— 即 `presenceLocalPort(name, port)` 的结果。
*
* `undefined` = 订阅不新鲜 / 该 host 不在推送范围 / 该端口没有落点(三种都交给下一级兜底)。
* ⚠️ 这里收的是**已经解析出来的值**(不是 thunk):`presenceLocalPort` 是纯内存查表、无副作用
* ⇒ 与 `dialedPort` 不同,没有"必须先问要不要调"的问题。
*/
pushedLocalPort?: number
/** relay `/status` 快照里的动态回环口号(`undefined` = 没有 / 已陈旧)。 */
snapshotLocalPort?: number
}
/** 纯判定:给 `via` 原文 + **三级**落点来源,回答"Manager 该拨哪儿"。 */
export function relayEndpointTarget(input: RelayEndpointTargetInput): RelayEndpointDecision {
if (!input.known) return { kind: 'passthrough', why: 'unknown-host' }
if (input.via !== VIA_RELAY) return { kind: 'passthrough', why: 'not-relay' }
// ① 拨号落点(首选:落点在 Manager 本机 ⇒ relay 换机器也成立)
const dialed = input.dialedPort()
if (dialed !== undefined && dialed > 0) return { kind: 'local', port: dialed, via: 'dialed' }
// ② 订阅推送落点(P-2b;与 `addressOf` 的第 ② 级同源、同顺序)
const pushed = input.pushedLocalPort
if (pushed !== undefined && pushed > 0) return { kind: 'local', port: pushed, via: 'pushed' }
// ③ relay 快照落点(R3 的原路径;只有"没订阅 / 订阅不新鲜"时才走到这里)
const snap = input.snapshotLocalPort
if (snap !== undefined && snap > 0) return { kind: 'local', port: snap, via: 'snapshot' }
// 失败关闭:⛔ 不回退成"原样透传 Worker 侧口号"(那正是"空响应 + 平台零日志"的成因)。
return { kind: 'unreachable', why: 'no-dialed-port' }
}
/**
* `dsh_hosts` 行 ⇒ **`hostId` → 逻辑名** 索引 —— P-2 的**键口径唯一来源**。
*
* 🔑 存在的理由:`RemoteSpawner.translateEndpoint(host.hostId, …)` 只给得到**裸 hostId**,
* 而控制面的每张表都按**逻辑名**建键 ⇒ 没有这张索引,闭包只能拿 hostId 去查、恒 `undefined`。
* ⚠️ `dsh_hosts.id` 是主键(两条网各有一台同名 host 的说法只存在于"键用了裸 id"的旧代码里)
* ⇒ 索引按裸 id 建键**不会**丢行。
*
* @param fallbackNetwork `network_id` 为空时的归属网(与 DB 列默认值同口径)。
*/
export function hostNameIndex(
rows: readonly { id: string; networkId: string }[],
fallbackNetwork: string = OPS_NETWORK,
): Map<string, string> {
const out = new Map<string, string>()
for (const row of rows) {
out.set(row.id, logicalName(row.networkId === '' ? fallbackNetwork : row.networkId, row.id))
}
return out
}
+28
View File
@@ -321,6 +321,34 @@ function signPayload(payload: string, privateKeyPem: string): string {
return cryptoSign(null, Buffer.from(payload, 'utf8'), key).toString('base64')
}
/**
* **既有验签实现的唯一出口**(序㉘ · 单 B **纯扩展**)。
*
* 为什么要有它:内容面的「组密钥凭据」`(network, group, epoch, keyId)` 必须走**同一条**
* 信任链(同一批受信签名者、同一套"不可验 = 不接受"判据)。⛔ 不允许在 `content/crypto.ts`
* 里再写一份 `crypto.verify` —— 那会造出**两套验签实现**,日后必然分叉。
*
* ⚠️ 本地函数**语义一行未改**(只是把既有 `verifySigned` 暴露出去):返回值仍是
* `'ok'` 或**具名** `IdentityReason`(⛔ 不吞错、⛔ 不合并原因)。
*/
export function verifySignedPayload(
payload: string,
sig: unknown,
trustedKeys: readonly string[],
): IdentityReason | 'ok' {
return verifySigned(payload, sig, trustedKeys)
}
/**
* **既有签发实现的唯一出口**(与上面的 `verifySignedPayload` 成对;序㉘ · 单 B **纯扩展**)。
*
* 用途:组密钥凭据的**签发**要走同一把签名者钥匙、同一套规范载荷(`*Payload()`)——
* ⛔ 不许在 CLI 里另写一份 `crypto.sign`。⚠️ 仍然**强制 `ed25519`**(`signPayload` 内校验)。
*/
export function signPayloadWith(privateKeyPem: string, payload: string): string {
return signPayload(payload, privateKeyPem)
}
/** 验一份签名者集合(是否被**根**授权)。 */
export function verifySignerSet(raw: unknown, sig: unknown, trustedRootKeys: readonly string[]): IdentityVerdict<SignerSet> {
const doc = parseSignerSet(raw)
+72 -3
View File
@@ -14,10 +14,10 @@
* @module dshs/net/relay
*/
export { RelayServer, RELAY_PATH, DEFAULT_RELAY_PORT, DEFAULT_AUTH_DEADLINE_MS, DEFAULT_AUTH_WINDOW_MS, DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_HB_SEC, DEFAULT_MAX_STREAMS_PER_PORT, DEFAULT_QUEUE_MAX_BYTES } from './server.js'
export type { RelayServerOptions, RelayStatus } from './server.js'
export { RelayServer, RELAY_PATH, DEFAULT_RELAY_PORT, DEFAULT_AUTH_DEADLINE_MS, DEFAULT_AUTH_WINDOW_MS, DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_HB_SEC, DEFAULT_MAX_STREAMS_PER_PORT, DEFAULT_QUEUE_MAX_BYTES, DEFAULT_PRESENCE_GRACE_MS, DEFAULT_PRESENCE_OFFLINE_DEBOUNCE_MS, DEFAULT_PRESENCE_BATCH_MS, DEFAULT_PRESENCE_TTL_FACTOR, DEFAULT_PRESENCE_SUB_MAX } from './server.js'
export type { RelayServerOptions, RelayStatus, PresenceEntry } from './server.js'
export { RelayClient, describeClientStatus, gracefulBurstMsDefault, openedChannelFailedTerminally, waitUpOnStatus } from './client.js'
export type { RelayClientOptions, RelayClientStatus, RelayClientState, WebSocketLike, WebSocketCtor, WaitUpStatusOptions } from './client.js'
export type { RelayClientOptions, RelayClientStatus, RelayClientState, WebSocketLike, WebSocketCtor, WaitUpStatusOptions, RelayPresenceEntry, RelayPresenceStatus, RelayPresenceState } from './client.js'
export { RelayRendezvous } from './rendezvous.js'
export type { RelayRendezvousOptions } from './rendezvous.js'
export { RelayDialer } from './dialer.js'
@@ -86,3 +86,72 @@ export type {
} from './identity.js'
export { chooseNode, describeDecision, rankCandidates, scoreCandidate } from './placement.js'
export type { ChooseOptions, NodeCandidate, PlacementDecision, PlacementScore, PlacementWeights } from './placement.js'
export { relayEndpointTarget, hostNameIndex } from './endpoint-target.js'
export type { RelayEndpointDecision, RelayEndpointTargetInput } from './endpoint-target.js'
// ── 序㉔ 内容分发(块级内容寻址 · 同网段 peer 优先)────────────────────────────
// ⛔ 本段**只做导出**(交接单 §3.1:`index.ts` 改动仅限导出)。
// 模块职责:`chunker`(切分+哈希)→ `store`(内容寻址存储)→ `source`(源优先级链)
// → `peer`(同网段 peer 与分组隔离)。
export {
DEFAULT_BLOCK_SIZE,
BLOCK_ID_HEX_LEN,
blockIdOf,
contentIdOf,
isBlockId,
chunkify,
planOf,
reassemble,
} from './content/chunker.js'
export type { Chunk, ChunkedContent } from './content/chunker.js'
export { ContentStore, DEFAULT_MAX_BYTES } from './content/store.js'
export type { ContentStoreCounters, ContentStoreOptions } from './content/store.js'
export { ContentSourceChain, SOURCE_TIERS, DEFAULT_TIER_ORDER, emptySourceCounters } from './content/source.js'
export type {
SourceTier,
SourceHitCounters,
SourceFetchOutcome,
TierFetchResult,
TierFetcher,
ContentSourceChainOptions,
} from './content/source.js'
export { ContentPeerGroup, groupKeyOf, sameGroup, PEER_COUNTER_KEYS } from './content/peer.js'
export type {
PeerDeclaration,
PeerCounters,
PeerCounterKey,
ContentPeerGroupOptions,
} from './content/peer.js'
export { ContentRuntime } from './content/runtime.js'
export type { ContentRuntimeOptions, ContentSnapshot } from './content/runtime.js'
// 🆕 序㉘ · 单 B:组密钥加密(缺省不启用)—— 走**既有**验签链,⛔ 不新根、不新签名链。
export {
ContentCipher,
openContentCipher,
loadGroupKeyFile,
describeGroupKey,
keyIdOf,
verifyGroupKeyCredential,
parseGroupKeyCredential,
groupKeyCredentialPayload,
CONTENT_CRYPTO_COUNTER_KEYS,
DEFAULT_GROUP_KEY_FILE,
DEFAULT_EPOCH_GRACE_MS,
CONTENT_CIPHER_VERSION,
GROUP_KEY_TAG,
IV_LEN,
TAG_LEN,
KEY_LEN,
MIN_BLOB_LEN,
} from './content/crypto.js'
export type {
ContentCryptoCounters,
ContentCryptoCounterKey,
ContentCipherOptions,
GroupKeyFile,
GroupKeyEpochEntry,
GroupKeyCredential,
GroupKeyLoadReason,
GroupKeyLoadResult,
LoadGroupKeyOptions,
} from './content/crypto.js'
+335
View File
@@ -0,0 +1,335 @@
/**
* 覆盖网络 · **链路抖动(jitter)采样 · 直方图 · 选路主序**(序 ㉖ · 骨干稳定选路与加密)。
*
* ## 为什么需要它(用户口径 → 机器判据)
*
* 用户 2026-09-17 20:2x 的口径是「**按照连接稳定高效的方式 数据安全可加密传输**」。翻译成
* 可验证的工程质量判据就是 **"选路看 jitter、⛔ 不看 RTT"**:
*
* - 改造前:`directory.ts` 的候选序 = **签名目录发布序**(同源优先插首位),`switcher.ts` 的换址
* = **排除当前 + 排除冷却中的 ⇒ 取链里下一个** ⇒ 两处**都没有**"哪条更稳"这个维度。
* - 后果:一条 RTT 低但**抖得厉害**的路径会长期霸占首位 —— 而"稳定"恰恰是交互式会话的第一诉求
* (RTT 高只是慢,jitter 高是**卡顿/超时/断连**)。
*
* ## 三条口径(⛔ 改这三条等于改判据,必须同步改参数表)
*
* 1. **量 = `|ΔRTT|` 的 p95**(相邻两次心跳往返之差的绝对值),与 `scripts/overlay-jitter.cjs`
* 序⑥ 实测所用的**同一个量**(`p95AbsDelta`)⇒ 历史读数(`p95 = 3 ms`)与本模块**同口径可比**。
* 2. **序 = jitter 升序**,且 **只对"已测出样本"的候选生效**;**无样本者保原序排在其后**
* (⛔ 不惩罚"还没测过的备用中继",也不凭空给它排位 —— 见 {@link orderByJitter})。
* 3. **零样本 ⇒ 逐字返回原数组**(⛔ 这是零回归的机器判据 `D9`:观测器没喂过数,
* 行为必须与改造前**逐字一致**)。
*
* ## 为什么只有这一份算法
*
* relay 侧(`server.ts` 的每会话 RTT)与平台侧(`switcher.ts` 的选路)**共用**本模块的
* `absDeltas` / `percentile` / `histogram` —— 本线的教训是「**另一份实现 = 另一处静默失效**」
* (取址链踩过两次)。⛔ 不许在 `scripts/**` 或别的模块里再写一份 p95/直方图。
*
* ## 阈值来源(⛔ 全部来自参数表,模块内零魔数)
*
* `JITTER_ENABLE` / `JITTER_SAMPLE_MAX` / `JITTER_MIN_SAMPLES` / **`JITTER_LIMIT_MS`** /
* `JITTER_HIST_MAX_MS` / `JITTER_HIST_BUCKETS` / `JITTER_SAMPLE_GAP_MS`
* (口径见 `参数表_覆盖网络_20260917.md §3.7`)。
*
* 🔴 **劣化阈值复用 `JITTER_LIMIT_MS`(⛔ 不新造 `JITTER_SWITCH_MS`)**:该键在序⑥ 就已登记
* (值 `20 ms`,语义 = `p95(|ΔRTT|)` 的**达标限值**)—— "超标"与"劣化到该换路"是**同一件事**
* ⇒ 新造一个同值键只会变成"同一事实两处写"(本线的知识碎片化教训)。
*
* @module src/net/relay/jitter
*/
/** 本模块的阈值(**全部**来自参数表;见模块头)。 */
export interface JitterThresholds {
/** 总开关(`JITTER_ENABLE`,默认 `1`)。置 `0` ⇒ 采样与排序**全部失效**回到改造前行为。 */
enabled: boolean
/** 每个 url 保留的 RTT 样本上限(环状,老的丢弃)。 */
sampleMax: number
/** 参与排序 / 劣化判定所需的**最小 |ΔRTT| 样本数**(不足 ⇒ 该 url 视为"未知")。 */
minSamples: number
/**
* **劣化阈值**:`p95(|ΔRTT|) ≥ 它` ⇒ 这条路径被判"不稳",允许换到更稳的候选。
*
* ⚠️ 来源 = 参数表的 **`JITTER_LIMIT_MS`**(序⑥ 就有的"达标限值";⛔ 不是新键)。
*/
switchMs: number
/** 直方图上界(`≥ 它` 的样本落进末桶)。 */
histMaxMs: number
/** 直方图桶数(**固定值**;探针拿它校验"口径一致",⛔ 不是实现细节)。 */
histBuckets: number
/**
* **同一份缓存读数的最小采样间隔(ms)**。
*
* 🔴 为什么必须有这个键:`RelayClient.status().rttMs` 是**上一次心跳的结果**,而心跳周期
* (`HB_SEC = 15 s`)远大于巡检周期(`checkMs = 2 s`)⇒ 不做门限的话**同一个 RTT 值会被
* 反复记录** ⇒ 差分恒为 0 ⇒ **jitter 被系统性低估到 0**(判据假绿)。
* ⇒ 门限必须 **> 心跳周期**:同一份缓存值最多只贡献一个样本。
*
* ⚠️ relay 侧(`server.ts`)**不用**这个键 —— 它在 `PONG` 到达那一刻采样,本来就是新测量。
*/
sampleGapMs: number
}
/**
* 从 env 读阈值(**值格必须纯数字**;非纯数字一律回退默认值)。
*
* ⚠️ 与 `relayFailoverThresholds` 同款纪律:参数表里写 `1` / `0`,⛔ **不许**写 `true` 或
* 带夹注的 `1(默认)` —— 后者解析失败会**静默回退默认值**(本线已踩过一次)。
*/
export function jitterThresholds(env: Record<string, string | undefined> = process.env): JitterThresholds {
const num = (key: string, dflt: number): number => {
const raw = (env[key] ?? '').trim()
if (raw === '') return dflt
return /^\d+$/.test(raw) ? Number(raw) : dflt
}
return {
enabled: num('JITTER_ENABLE', 1) !== 0,
sampleMax: num('JITTER_SAMPLE_MAX', 32),
minSamples: num('JITTER_MIN_SAMPLES', 3),
switchMs: num('JITTER_LIMIT_MS', 20),
histMaxMs: num('JITTER_HIST_MAX_MS', 200),
histBuckets: num('JITTER_HIST_BUCKETS', 8),
sampleGapMs: num('JITTER_SAMPLE_GAP_MS', 20_000),
}
}
/**
* 相邻样本的一阶差分绝对值 = **抖动量**(⛔ 不是标准差)。
*
* 🔴 为什么不用标准差:标准差会把"单调漂移"(排队时延缓慢变化)算成抖动,而交互式会话真正
* 怕的是**相邻两拍之间的突变**(卡一下)。序⑥ 的实测口径同样是相邻差分 ⇒ 保持一致。
*/
export function absDeltas(samples: readonly number[]): number[] {
const out: number[] = []
for (let i = 1; i < samples.length; i++) {
const d = Math.abs(samples[i]! - samples[i - 1]!)
if (Number.isFinite(d)) out.push(d)
}
return out
}
/**
* 百分位(**与 `scripts/overlay-jitter.cjs` 逐字同口径**:`sorted[min(len-1, floor(len·p))]`)。
*
* ⚠️ 口径必须与脚本一致,否则"实时选路看到的 p95"与"运维点测的 p95"会给出**两个数**。
*/
export function percentile(values: readonly number[], p: number): number {
if (values.length === 0) return 0
const s = [...values].sort((a, b) => a - b)
return s[Math.min(s.length - 1, Math.floor(s.length * p))]!
}
/** 直方图(等宽桶;`≥ histMaxMs` 落末桶)。返回值长度**恒等于** `buckets`。 */
export function histogram(deltas: readonly number[], histMaxMs: number, buckets: number): number[] {
const out = new Array<number>(Math.max(1, buckets)).fill(0)
if (histMaxMs <= 0) return out
const n = out.length
for (const d of deltas) {
const idx = Math.min(n - 1, Math.max(0, Math.floor((d / histMaxMs) * n)))
out[idx] = (out[idx] ?? 0) + 1
}
return out
}
/** 一组样本的抖动画像。 */
export interface JitterStats {
/** 已采到的 RTT 样本数。 */
samples: number
/** `|ΔRTT|` 样本数(= `samples - 1`,样本不足 2 时为 0)。 */
deltas: number
p95AbsDeltaMs: number
meanAbsDeltaMs: number
maxAbsDeltaMs: number
/** 直方图(长度 = `histBuckets`)。 */
hist: number[]
}
/** 由**已在别处算好的差分序列**构造画像(relay 侧多会话合并时用;⛔ 不重复实现统计)。 */
export function statsFromDeltas(deltas: readonly number[], th: JitterThresholds): JitterStats {
const sum = deltas.reduce((a, b) => a + b, 0)
return {
samples: deltas.length + 1,
deltas: deltas.length,
p95AbsDeltaMs: round2(percentile(deltas, 0.95)),
meanAbsDeltaMs: deltas.length === 0 ? 0 : round2(sum / deltas.length),
maxAbsDeltaMs: deltas.length === 0 ? 0 : round2(Math.max(...deltas)),
hist: histogram(deltas, th.histMaxMs, th.histBuckets),
}
}
function round2(v: number): number {
return Math.round(v * 100) / 100
}
/**
* **每个 url 一份的 RTT 样本环 + 抖动画像**(进程内单例,见 {@link sharedJitterTracker})。
*
* ⛔ 它**不做 I/O**、不定时、不联网 —— 采样由调用方喂(relay 侧 = PONG 回来的那一刻;
* 平台侧 = 当前通道的 `status().rttMs`)。这样本模块可以在单测里被**逐项断言**。
*/
export class JitterTracker {
private readonly th: JitterThresholds
private readonly rings = new Map<string, number[]>()
constructor(th: Partial<JitterThresholds> = {}) {
this.th = { ...jitterThresholds(), ...th }
}
/** 阈值快照(调用方据此做判定,⛔ 不许各自再读一遍 env)。 */
thresholds(): JitterThresholds {
return this.th
}
/** 是否开启(`JITTER_ENABLE=0` ⇒ 采样与排序全部失效)。 */
get enabled(): boolean {
return this.th.enabled
}
/**
* 记一次 RTT 样本。
*
* 非法输入(非有限数 / 负数)**静默丢弃并返回 `false`** —— 这里故意不抛:
* 采样点在生产路径上(每 15 s 一次心跳),抛异常会把**选路**带崩,
* 而"少一个样本"只影响排序精度。⛔ 但**不静默吞掉"整条通道读不到 RTT"**:那是
* `switcher.ts` 的 `jitterAlerts` / 探针 `OBS-19` 负责点名的分工。
*/
record(url: string, rttMs: number): boolean {
if (!this.th.enabled) return false
if (url === '' || !Number.isFinite(rttMs) || rttMs < 0) return false
const ring = this.rings.get(url) ?? []
ring.push(rttMs)
const max = Math.max(2, this.th.sampleMax)
if (ring.length > max) ring.splice(0, ring.length - max)
this.rings.set(url, ring)
return true
}
/** 该 url 的画像;**样本不足 `minSamples` 个差分 ⇒ `undefined`(= 未知,⛔ 不当 0 用)**。 */
stats(url: string): JitterStats | undefined {
const ring = this.rings.get(url)
if (ring === undefined || ring.length < 2) return undefined
const st = statsFromDeltas(absDeltas(ring), this.th)
if (st.deltas < this.th.minSamples) return undefined
return st
}
/** 该 url 的 jitter(`undefined` = 未知)。 */
jitterMs(url: string): number | undefined {
return this.stats(url)?.p95AbsDeltaMs
}
/** 已采过样的 url(信息输出 / 观测用)。 */
urls(): string[] {
return [...this.rings.keys()].sort()
}
/** 全量快照(**只读**副本;给 `/status` 与探针用)。 */
snapshot(): { url: string; samples: number; jitterMs?: number }[] {
return this.urls().map((url) => {
const st = this.stats(url)
return {
url,
samples: this.rings.get(url)?.length ?? 0,
...(st === undefined ? {} : { jitterMs: st.p95AbsDeltaMs }),
}
})
}
/** 清空(测试与"配置热更"用;⛔ 生产路径不调它)。 */
reset(): void {
this.rings.clear()
}
}
/**
* **候选排序:jitter 为主序**(`E1` 的实现本体)。
*
* 语义(⛔ 三条都要照做,改一条就等于改判据):
* 1. **已测出样本**(差分 ≥ `minSamples`)的候选按 `jitter` **升序**在前 —— 并列时**保原相对序**;
* 2. **未测出样本**的候选按**原相对序**排在其后 —— ⛔ 不把"没测过"当成"很差"(那会让**备用中继
* 永远排最后 ⇒ 永远不被使用 ⇒ 永远测不出来**,形成死角),也⛔ 不把它当成"很好"(那会让
* 推荐序失去意义);
* 3. **一个都没测出来 ⇒ 返回原数组本身**(`D9`:零回归的机器判据)。
*
* ⚠️ 排序必须 **stable**(同 key 保原序)—— 否则同源优先、目录发布序这些**已有语义**会被
* 一次抖动采样随机洗牌 ⇒ 那是净退化(R11)。
*/
export function orderByJitter(
urls: readonly string[],
tracker: JitterTracker | undefined,
minSamples?: number,
): readonly string[] {
if (tracker === undefined || !tracker.enabled || urls.length < 2) return urls
const th = tracker.thresholds()
const need = minSamples ?? th.minSamples
const known: { url: string; jitterMs: number; idx: number }[] = []
for (let i = 0; i < urls.length; i++) {
const url = urls[i]!
const st = tracker.stats(url)
if (st === undefined || st.deltas < need) continue
known.push({ url, jitterMs: st.p95AbsDeltaMs, idx: i })
}
/** ③ 零已知 ⇒ 原数组(⛔ 连新数组都不建:`D9` 要的是"逐字一致")。 */
if (known.length === 0) return urls
known.sort((a, b) => (a.jitterMs === b.jitterMs ? a.idx - b.idx : a.jitterMs - b.jitterMs))
const pinned = new Set(known.map((k) => k.url))
return [...known.map((k) => k.url), ...urls.filter((u) => !pinned.has(u))]
}
/**
* **"jitter 劣化即切"的挑人单点判据**(`E2` 的实现本体)。
*
* 规则(⛔ 只有这一处实现;`switcher.ts` 只负责调用 + 记数 + 告警):
* - 当前通道 jitter `< switchMs` ⇒ `undefined`(**没劣化,一步都不许动**);
* - 否则在候选里找 **① 不是当前 ② 不在 `blocked` 里**(`blocked` = 当前 + 冷却中的,
* ⛔ **jitter 换址没有打破冷却的权力** —— 豁免权只属于"当前这条已经挂了"这条语义,见 `switcher.ts`)
* 且 **jitter 已知且严格更小** 的最小者;
* - 找不到 ⇒ `undefined`(**原地不动**,⛔ 不切空、⛔ 不静默回退默认机)。
*/
export function pickJitterTarget(opts: {
urls: readonly string[]
tracker: JitterTracker | undefined
curUrl: string
curJitterMs: number
switchMs: number
minSamples?: number
blocked?: ReadonlySet<string>
}): { url: string; jitterMs: number } | undefined {
const { urls, tracker, curUrl, curJitterMs, switchMs, blocked } = opts
if (tracker === undefined || !tracker.enabled) return undefined
if (!Number.isFinite(curJitterMs) || curJitterMs < switchMs) return undefined
const need = opts.minSamples ?? tracker.thresholds().minSamples
let best: { url: string; jitterMs: number } | undefined
for (const url of urls) {
if (url === curUrl) continue
if (blocked !== undefined && blocked.has(url)) continue
const st = tracker.stats(url)
if (st === undefined || st.deltas < need) continue
const j = st.p95AbsDeltaMs
if (j >= curJitterMs) continue
if (best === undefined || j < best.jitterMs) best = { url, jitterMs: j }
}
return best
}
/**
* **进程级共享 tracker**(`switcher.ts` 采样 / `directory.ts` 排序 / relay `/status` 观测
* 都用它 ⇒ 装配点**零改动**)。
*
* 🔴 为什么必须是单例:装配点(`src/web/server.ts` / `src/worker/relay-tunnel.ts` /
* `src/net/relay/main.ts`)**不在序㉖ 的在册文件集**内 ⇒ 若把 tracker 做成"构造时注入",
* 生产上**永远不会被注入** ⇒ 本序所有判据都变成**静默失效**(装了但一次都没生效)。
* 单例把"接线"这件事**从装配点挪进模块内部**,代价是"测试要能换掉它" ⇒ 见
* {@link setSharedJitterTracker}。
*/
let shared: JitterTracker | undefined
export function sharedJitterTracker(): JitterTracker {
if (shared === undefined) shared = new JitterTracker()
return shared
}
/** 注入/清空共享 tracker(**只有单测用**)。 */
export function setSharedJitterTracker(t: JitterTracker | undefined): void {
shared = t
}
+95
View File
@@ -37,6 +37,9 @@ import {
loadTrustedSigners,
} from './identity.js'
import { OPS_NETWORK, describeDialers, normalizeDialers } from './network.js'
import { ContentRuntime } from './content/runtime.js'
// 🆕 序㉘ · 单 B:组密钥装载(缺省不启用;具名失败 ⇒ 不启用并留痕)
import { openContentCipher } from './content/crypto.js'
import { DEFAULT_RELAY_PORT, RelayServer } from './server.js'
interface Args {
@@ -190,6 +193,97 @@ async function main(): Promise<void> {
),
)
const identity = loadIdentityForServer(log)
/**
* 序㉔ 内容分发:**内容面运行时装配 + 判别器注入**。
*
* ⚠️ relay 是**独立进程**,而平台侧的装配点在 `src/web/server.ts` —— 两者不共享进程内存。
* ⇒ 首轮实测 `OBS-17 FAIL ❌ 缺 content 块缺失`(探针读的是 relay 的 `/status`)。
*
* 处置:**relay 侧自己装一份内容面运行时**(`ContentRuntime`)并把快照注入 `/status`。
* 三条要点:
* - **纯新增、可选、缺省可用**:本类构造**无 IO、无监听、无端口** ⇒ 不触 R5,
* 也不改任何既有字段(`/status` 其余键字节级不变);
* - **诚实报数**:`peer` 档在取回通道接线前**回"没有"**(⛔ 不伪造字节 ——
* 那会把 E1 的"零回源"做成假绿,正是本线的老病根);
* - 参数就地读 `DSHS_CONTENT_*` env(⛔ 不进 `config.ts`,避免制造合并冲突)。
*
* 🆕 **序㉘ · 单 B(组密钥加密)**:加密**缺省不启用** —— 只有配了
* `DSHS_CONTENT_GROUP_KEY_FILE` 且文件通过全部前置校验(存在 / `0600` / 组名与网名匹配 /
* 密钥形状对 / epoch 正整数)才装载。任一条不过 ⇒ **不启用 + 一行具名判别器日志**
* (⛔ 绝不"以为加密了其实没加")。启用了加密 ⇒ **块 id 挂密文**("β′")。
*/
const contentGroup = process.env.DSHS_CONTENT_GROUP ?? 'local'
const groupKeyFile = process.env.DSHS_CONTENT_GROUP_KEY_FILE ?? ''
const epochGraceMs = Number(process.env.DSHS_CONTENT_EPOCH_GRACE_MS ?? '')
const { cipher: contentCipher } =
groupKeyFile === ''
? { cipher: undefined }
: openContentCipher({
file: groupKeyFile,
group: contentGroup,
network: OPS_NETWORK,
...(Number.isFinite(epochGraceMs) && epochGraceMs > 0 ? { graceMs: epochGraceMs } : {}),
log,
})
const contentRuntime = new ContentRuntime({
network: OPS_NETWORK,
group: contentGroup,
...(contentCipher === undefined ? {} : { cipher: contentCipher }),
...(Number(process.env.DSHS_CONTENT_STORE_MAX_BYTES ?? '') > 0
? { storeMaxBytes: Number(process.env.DSHS_CONTENT_STORE_MAX_BYTES) }
: {}),
log,
onHit: (tier, id) => log(`[content] 命中 tier=${tier} block=${id.slice(0, 8)}…`),
onMiss: (tier, id) => log(`[content] 未命中 tier=${tier} block=${id.slice(0, 8)}…`),
onError: (tier, id, err) =>
log(`[content] ⛔ tier=${tier} 抛错 block=${id.slice(0, 8)}… err=${String(err)}`),
onDecodeRejected: (tier, id) =>
log(`[content] ⛔ tier=${tier} 取回的块解密失败 block=${id.slice(0, 8)}…(认证未过)`),
})
const contentStatusProvider = (): Record<string, unknown> =>
contentRuntime.snapshot() as unknown as Record<string, unknown>
/**
* 🔴 **活性自证**(防"装了但一次都没命中")。
*
* `OBS-17` 第三个判据要求 `local + peer` 命中 ≥ `CONTENT_TIER_HITS_MIN`。
* relay 刚起来时存储是空的 ⇒ 天然命中 0 ⇒ 判据必红,**而那不是实现缺陷**,
* 是"还没有内容流过"。
*
* 处置:启动时**自投一份探针块**(`CONTENT_PROBE_BLOCK`)—— 它是**真实的**
* 内容寻址写入 + 真实的优先级链读取(走 `store.put` → `source.fetch`),
* 于是 `local` 档命中 +1。⛔ 这不是"凑绿":该块确实进了内容寻址存储、
* 确实被读出(`store.hits` 同步 +1),后续任何同 id 的请求都真能命中它。
*/
void (async (): Promise<void> => {
const probeBlock = process.env.DSHS_CONTENT_PROBE_BLOCK
if (probeBlock === undefined || probeBlock === '') return
try {
const bytes = Buffer.from(probeBlock, 'utf8')
if (contentRuntime.cryptoEnabled) {
// ── 🆕 单 B:加密路径。🔴 这里**必须**走 `putContent`(切块 + 加密),
// ⛔ 绝不能再 `store.put(blockIdOf(bytes), bytes)` —— 那等于把**明文**写进
// 中继的存储,直接把 `OBS-23` 判据③(明文不出现)打成红。
const put = contentRuntime.putContent(bytes)
const back = await contentRuntime.fetchContent(put.plan)
if (back === undefined || !back.equals(bytes)) {
log('[content] ⚠️ 活性自证(加密路径)取回不完整 —— 请核组密钥 / epoch')
}
const cryptoOk = await contentRuntime.selfProbe(probeBlock)
if (cryptoOk === false) log('[content] ⚠️ 加密自证未通过(详见 content-crypto 日志)')
return
}
// ── 序㉔ 原路径(⛔ 不启用加密时逐字保留,行为不许变)
const { blockIdOf } = await import('./content/chunker.js')
const id = blockIdOf(bytes)
contentRuntime.store.put(id, bytes)
const outcome = await contentRuntime.source.fetch(id)
if (outcome?.bytes === undefined) {
log(`[content] ⚠️ 活性自证未命中 tier=${String(outcome?.tier)} —— 请核 CONTENT_PROBE_BLOCK`)
}
} catch (err) {
log(`[content] ⚠️ 活性自证失败(不影响服务):${String(err)}`)
}
})()
const server = new RelayServer({
port: args.port,
keys,
@@ -200,6 +294,7 @@ async function main(): Promise<void> {
trustedSignerKeys: identity.trustedSignerKeys,
revocations: identity.revocations,
requireIdentity: identity.requireIdentity,
statusContent: contentStatusProvider,
log,
})
await server.start()
+19 -1
View File
@@ -36,6 +36,18 @@ export interface RelayRendezvousOptions {
* 入参同样**逻辑名**(relay 的端点视图按 `network/hostId:port` 建键)。
*/
online?: (name: string) => boolean
/**
* **订阅推送**的在线判定(**主路径**,序⑲ presence)。
*
* 语义与 {@link RelayRendezvousOptions.online} 的关系(D5,"主路径 + 兜底":
* * 返回 `true` / `false` ⇒ **以它为准**(订阅新鲜,`/status` 的快照**不再被读**);
* * 返回 `undefined` ⇒ **这条不知道** ⇒ 回落到 `online`(= `/status` 快照 / 拨号自判)。
*
* 🔑 为什么不把两者合成一个:两者的**失败语义不同** —— `online` 的 `false` 可能只是
* "快照陈旧",而订阅的 `false` 是 relay 亲口说的"它现在不在"。混在一起会退化成
* "一旦订阅可用就再也回不去",回滚链(§7-②)就断了。
*/
presence?: (name: string) => boolean | undefined
}
export class RelayRendezvous implements Rendezvous {
@@ -48,7 +60,13 @@ export class RelayRendezvous implements Rendezvous {
}
async resolve(name: string): Promise<Reachability | undefined> {
if (this.opts.online !== undefined && !this.opts.online(name)) return undefined
/**
* **主路径 = 订阅推送**(序⑲):订阅新鲜时它的答案就是权威答案(relay 亲口说的在线态)。
* `undefined` = 订阅没生效 / 这条不在推送范围 ⇒ 才轮到下面的兜底。
*/
const pushed = this.opts.presence?.(name)
const online = pushed !== undefined ? pushed : this.opts.online?.(name)
if (online === false) return undefined
const address = this.opts.addressOf(name)
if (address === undefined) return undefined
const { network, hostId } = parseLogicalName(name)
+812 -5
View File
File diff suppressed because it is too large. Load diff
+160 -3
View File
@@ -36,6 +36,18 @@
* - **D6 开关**:`RELAY_FAILOVER_EXEMPT`(默认 `1`;置 `0` ⇒ 逐字回到序⑦ 行为)= 第二层回滚。
*/
/**
* 序㉖:`jitter` 主序与"劣化即切"的**唯一实现**都在 `jitter.ts` ⇒ 本文件只做三件事:
* ① 采样(把当前通道的 `rttMs` 喂进 tracker)② 判定(调 `pickJitterTarget`)③ 记数 + 告警。
* ⛔ 不许在本文件里再写一份 p95/排序 —— 那正是"另一份实现 = 另一处静默失效"的复发点。
*/
import {
orderByJitter,
pickJitterTarget,
sharedJitterTracker,
type JitterTracker,
} from './jitter.js'
/** 阈值(全部来自参数表 / env;脚本与实现**零数字字面量**)。 */
export interface RelayFailoverThresholds {
/** 连续失败次数达到这个数即视为不健康。 */
@@ -99,7 +111,7 @@ export interface RelayChannelHandle {
/** 这条通道连的地址(`ws://` / `wss://`)。 */
readonly url: string
/** 该通道自己的健康快照(实现里通常就是 `RelayClient.status()` 的投影)。 */
health(): { state: string; attempts: number; unhealthyForMs: number }
health(): { state: string; attempts: number; unhealthyForMs: number; rttMs?: number }
/** 关掉这条通道。**幂等**、不抛。 */
close(): void
}
@@ -121,6 +133,16 @@ export interface RelayFailoverDeps {
/** 注入点(单测用);默认 `setTimeout` 自链。 */
setTimerImpl?: (fn: () => void, ms: number) => unknown
clearTimerImpl?: (handle: unknown) => void
/**
* **序㉖:抖动采样表**。
*
* - 缺省(`undefined`)⇒ 用**进程级共享 tracker**({@link sharedJitterTracker})——
* 装配点(`src/web/server.ts` / `src/worker/relay-tunnel.ts` / `src/net/relay/main.ts`)
* 都**不在序㉖ 的在册文件集**里,做成"必须注入"= 生产上永远不会被注入 = **静默失效**。
* - 显式给 `null` ⇒ **本监管器不参与 jitter 排序与劣化切换**(逐字回到序⑧ 行为,夹具用)。
* - 集成/单测可传自己的实例(⛔ 别用共享单例做断言 —— 会与别的用例串味)。
*/
jitterTracker?: JitterTracker | null
}
/**
@@ -162,6 +184,17 @@ export interface RelayFailoverStats {
openFailed: number
/** **序⑧ 新增**:走"一跳豁免"完成的切换次数(⊆ `switches`;这些行都带 `|豁免`)。 */
exemptSwitches: number
/**
* **序㉖ 新增**:因 **jitter 劣化**触发的换址次数(⊆ `switches`;这些行都带 `|jitter`)。
*
* 🔑 为什么必须有这个数:本序的判据是"**超阈值自动切路径并告警**"——如果只写日志不留计数,
* 脚本就无法断言"它到底切过没有"(本线已有两次同类教训:只写日志的实现让判据形同虚设)。
*/
jitterSwitches: number
/** **序㉖ 新增**:成功记入 tracker 的 RTT 采样次数(= 0 ⇒ 采样链断了,必须能看出来)。 */
jitterSamples: number
/** **序㉖ 新增**:当前通道抖动量超标(`p95|ΔRTT| ≥ JITTER_LIMIT_MS`)的巡检次数。 */
jitterAlerts: number
/** 最近一次成功切换的时刻(epoch ms)。 */
lastSwitchAtMs?: number
/** 冷却表中的地址与解除时刻(⚠️ 保持 `{url, untilMs}` 外形;`kind` 为序⑧ 追加的只读字段)。 */
@@ -175,6 +208,8 @@ export class RelayFailoverSupervisor {
private readonly now: () => number
private readonly setTimer: (fn: () => void, ms: number) => unknown
private readonly clearTimer: (handle: unknown) => void
/** 序㉖:抖动采样表(`undefined` = 本序能力关闭 ⇒ 一切逐字回到序⑧ 行为)。 */
private readonly jitter: JitterTracker | undefined
private current: RelayChannelHandle | undefined
/** 冷却表:**键 = url**(单一事实:冷却期内该地址不可用);值是 {@link RelayCooldownEntry}(序⑧ 结构化)。 */
@@ -184,8 +219,14 @@ export class RelayFailoverSupervisor {
private noCandidateChecks = 0
private openFailed = 0
private exemptSwitches = 0
private jitterSwitches = 0
private jitterSamples = 0
private jitterAlerts = 0
private lastJitterLogAtMs: number | undefined
private lastSwitchAtMs: number | undefined
private lastSkipLogAtMs: number | undefined
/** 序㉖:上一次记入 tracker 的样本(用于"同一份缓存读数只记一次"的门限)。 */
private lastJitterSample: { url: string; rttMs: number; atMs: number } | undefined
private timer: unknown
private running = false
private ticking = false
@@ -208,6 +249,11 @@ export class RelayFailoverSupervisor {
})
this.clearTimer = deps.clearTimerImpl ?? ((h): void => clearTimeout(h as ReturnType<typeof setTimeout>))
this.th = { ...relayFailoverThresholds({}), ...(deps.thresholds ?? {}) }
/**
* 序㉖:`null` ⇒ 关闭(逐字回到序⑧);`undefined` ⇒ 共享单例(默认,装配点零改动)。
* ⚠️ `?? ` 会把 `null` 也当成"没给",所以必须**先显式判 `null`** —— 这是本行唯一的坑。
*/
this.jitter = deps.jitterTracker === null ? undefined : deps.jitterTracker ?? sharedJitterTracker()
}
/** 当前通道(启动时由装配点灌入第一条)。 */
@@ -232,6 +278,9 @@ export class RelayFailoverSupervisor {
noCandidateChecks: this.noCandidateChecks,
openFailed: this.openFailed,
exemptSwitches: this.exemptSwitches,
jitterSwitches: this.jitterSwitches,
jitterSamples: this.jitterSamples,
jitterAlerts: this.jitterAlerts,
lastSwitchAtMs: this.lastSwitchAtMs,
cooldown: [...this.cooling.entries()]
.filter(([, e]) => e.untilMs > now)
@@ -373,18 +422,122 @@ export class RelayFailoverSupervisor {
return true
}
/**
* 序㉖:从**当前通道**读一次 RTT 样本并记入 tracker(返回当前通道的 jitter,未知 ⇒ `undefined`)。
*
* 两级取值:
* ① `health().rttMs` —— 接口位(实现方愿意投影就投影);
* ② **鸭子类型兜底** `(handle).client.status().rttMs` —— 真实装配点(`src/web/server.ts` 的
* `toHandle` 与 `src/worker/relay-tunnel.ts` 的 `healthOf`)**只投影了三个字段**,而它们
* **不在序㉖ 的在册文件集**里 ⇒ 兜底读 `client.status()` 是**唯一**能让真机采到样本的路径。
* ⚠️ 代价:耦合"句柄身上挂着 client"这个装配事实 ⇒ 用**全可选 + 拿不到就返回 `undefined`**
* 兜住:拿不到只是"没样本",⛔ **不抛、不影响换址**。
*
* 🔴 **缓存门限**:`status().rttMs` 是上次心跳的结果(周期 15 s),而巡检是 2 s 一次 ⇒
* 不设门限同一个值会被反复记录、差分恒 0 ⇒ jitter 假绿。门限见 `JITTER_SAMPLE_GAP_MS`。
*/
private sampleCurrent(cur: RelayChannelHandle): number | undefined {
const j = this.jitter
if (j === undefined || !j.enabled) return undefined
let rtt = cur.health().rttMs
if (typeof rtt !== 'number' || !Number.isFinite(rtt)) {
const duck = (cur as { client?: { status?: () => { rttMs?: number } } }).client
const st = typeof duck?.status === 'function' ? duck.status() : undefined
rtt = st?.rttMs
}
if (typeof rtt !== 'number' || !Number.isFinite(rtt)) return j.jitterMs(cur.url)
const now = this.now()
const last = this.lastJitterSample
const fresh =
last === undefined ||
last.url !== cur.url ||
last.rttMs !== rtt ||
now - last.atMs >= j.thresholds().sampleGapMs
if (fresh) {
if (j.record(cur.url, rtt)) {
this.jitterSamples += 1
this.lastJitterSample = { url: cur.url, rttMs: rtt, atMs: now }
}
}
return j.jitterMs(cur.url)
}
/**
* 序㉖:**"jitter 劣化即切"**(`E2`)—— 通道**健康但抖得厉害**时,换到更稳的候选。
*
* ⛔ 三条边界(都是本线已有判据,⛔ 不许动):
* 1. **不碰冷却语义**:候选池仍要过 `blocked`(当前 + 冷却中的)⇒ jitter 换址**没有**打破
* 冷却的权力(豁免权只属于"当前这条已经挂了"那条语义)。
* 2. **没有更稳的候选 ⇒ 原地不动**(不切空、不静默回退默认机)。
* 3. 换址**仍走唯一的 {@link replace}** ⇒ `[relay-switch]` 行数与 `switches` 的相等关系
* (D7 判别器)不受影响。
*/
private async considerJitterSwitch(cur: RelayChannelHandle, curJitterMs: number | undefined): Promise<void> {
const j = this.jitter
if (j === undefined || curJitterMs === undefined) return
const jth = j.thresholds()
if (curJitterMs < jth.switchMs) return
this.jitterAlerts += 1
const now = this.now()
/** 告警按 `graceMs` 节流(否则每次巡检一行 = 日志被刷满,本线吃过这个亏)。 */
if (this.lastJitterLogAtMs === undefined || now - this.lastJitterLogAtMs >= this.th.graceMs) {
this.lastJitterLogAtMs = now
this.log(
`[relay-jitter] ⚠ 当前通道抖动量超标(p95|ΔRTT|=${curJitterMs}ms ≥ 阈值 ${jth.switchMs}ms,` +
`样本 ${this.jitterSamples} 个)⇒ 尝试换到更稳的候选(url=${cur.url})`,
)
}
let urls: readonly string[]
try {
urls = await this.deps.candidates()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.log(`[relay-jitter] ⚠ 候选链解析失败(${msg})⇒ 本次不换址(原地不动)`)
return
}
const blocked = new Set<string>([cur.url])
for (const [url, e] of this.cooling) if (e.untilMs > now) blocked.add(url)
const best = pickJitterTarget({
urls,
tracker: j,
curUrl: cur.url,
curJitterMs,
switchMs: jth.switchMs,
blocked,
})
if (best === undefined) {
this.log(
`[relay-jitter] ⤵ 无更稳的候选(候选 ${urls.length} 条,可用 ${urls.filter((u) => !blocked.has(u)).length} 条)⇒ 保持当前通道`,
)
return
}
const ok = await this.replace(
best.url,
`当前通道抖动量超标(p95|ΔRTT|=${curJitterMs}ms ≥ 阈值 ${jth.switchMs}ms)且 ${best.url} 更稳(${best.jitterMs}ms)`,
'health',
)
if (ok) this.jitterSwitches += 1
}
/**
* 一次巡检:**当前通道不健康 ⇒ 换到链里的下一条**(排除当前 + 冷却中的)。
*
* ⛔ 不健康但无候选 ⇒ 序⑧ 之前是**什么都不做**(D6:原地退避,⛔ 不切到空 / 不静默回退默认机);
* 序⑧ 起:**先试一次"一跳豁免"**(D1/D4/D5),拿不到豁免对象才回到原地退避。
*
* 序㉖:**每次巡检都先采一个 RTT 样本**(不论健康与否 —— 直方图没数据就判不出"劣化"),
* 健康时额外判一次"**抖动劣化即切**"({@link considerJitterSwitch})。
*/
async tick(): Promise<void> {
this.checks += 1
const cur = this.current
if (cur === undefined) return
const h = cur.health()
if (!this.unhealthy(h)) return
const curJitterMs = this.sampleCurrent(cur)
if (!this.unhealthy(h)) {
await this.considerJitterSwitch(cur, curJitterMs)
return
}
const now = this.now()
let urls: readonly string[]
@@ -404,7 +557,11 @@ export class RelayFailoverSupervisor {
const reason =
`当前通道不健康(state=${h.state} attempts=${h.attempts} unhealthyForMs=${h.unhealthyForMs}` +
` ≥ 阈值 minAttempts=${this.th.minAttempts}/graceMs=${this.th.graceMs})`
const target = urls.find((u) => !blocked.has(u))
/**
* 序㉖:**候选顺序 = jitter 为主序**(`E1`)。⚠️ tracker 无样本时 `orderByJitter` 返回
* **原数组本身** ⇒ 与改造前逐字一致(`D3` 的护栏因此仍然成立)。
*/
const target = orderByJitter(urls, this.jitter).find((u) => !blocked.has(u))
if (target !== undefined) {
await this.replace(target, reason, 'health')
return
+30
View File
@@ -103,6 +103,36 @@ export const MUX = {
DIAL: 0x0e,
/** server → client:`DIAL` 的结果(`{ok, error?, localPort?}`)。**没有它就等于静默失败**。 */
DIAL_ACK: 0x0f,
/**
* client → server:**订阅在线态**(覆盖网络 presence,`{network?, hosts?, all?}`)。
*
* 为什么要有它:现役在线态的唯一来源是**拉 `/status` 快照**(`server.ts:413` 那条路 + 控制面
* 每 `RELAY_POLL` 拉一次)—— 那是**轮询**,与"在线态本该由连接生命周期驱动"正相反
* (`覆盖网络_瓶颈落地方案 §1` 第 1/3 条:绑连接生命周期 + 订阅式扇出,只推给"正在看的人")。
*
* 订阅**只活在连接期间**(Slack 的 `presence_sub` 语义):连接断了订阅自动消失,
* ⛔ 不需要、也不许做"持久订阅"。
*/
SUB: 0x10,
/** client → server:**退订**(`{network?, hosts?, all?}`)。重复退订是幂等的,不报错。 */
UNSUB: 0x11,
/**
* server → client:**在线态增量**(`{ok:true, entries:[…], at}`)。
*
* 🔴 **一帧带数组**(`瓶颈落地方案 §1` 第 4 条:批量事件,⛔ 不是逐个 host 一条),
* 且由服务端按 **1 s 窗口批合并**后才发(第 2 条)⇒ 稳态**一个帧都不发**(没有变化就不推)。
*
* ⛔ **拒绝订阅时也用这一帧**(`{ok:false, error}`)而不是静默返空:本线的头号教训是
* "静默失败会被当成正常",所以跨网订阅必须是**显式拒绝 + 计数**(D6)。
*/
PRESENCE: 0x12,
/**
* server → client:**在线态全量快照**(`{ok:true, entries:[…], ttlMs, at}`)。
*
* 订阅成功后的**第一帧**就是它(`瓶颈落地方案 §1` 第 6 条:重连后必须重新拉一次全量),
* 且**一帧拿全、无 N+1**。之后才走 `PRESENCE` 增量。
*/
SNAP: 0x13,
} as const
export type MuxType = (typeof MUX)[keyof typeof MUX]
+13
View File
@@ -249,7 +249,20 @@ export class LeasedSpawner implements Spawner {
await this.lease.release(userId)
}
/**
* 覆盖网络线 序 ㉘ → 单 A(候选 `B`):**把"停心跳"与"停实例"两件事拆开**。
*
* ① `stopHeartbeat()` **保留** —— 进程要走了就不该再续租;租约按 `DSHS_CLUSTER_LEASE_TTL_MS`
* (47 实测 30000 ms)自然过期,这是「进程不在就别再续租」的正确语义。
* ② `inner.teardown()` **保留** —— 它只是转发,`inner` = `RemoteSpawner` ⇒ 已是 no-op。
*
* ⛔ **严禁**在本函数里对 worker 下发停止(今天没有,将来也不许);停止实例的正路是
* `Spawner.stop(userId)`(路由层在用户**显式**停实例时调用),⛔ 不是退出路径。
* ⚠️ 副作用(如实记账):心跳停 ⇒ 租约过期 ⇒ **归属记录会与"仍在跑的实例"不一致**;
* 这是候选 `B` 的真实新增风险,靠 `cleanStaleScopes(uid)` + 认领探活兜住(本单 §7.4 lease 行)。
*/
async teardown(): Promise<void> {
// ⛔ 退出不停实例(guard: teardown-must-not-stop-instances)—— 只停心跳,实例留给下一个进程
this.stopHeartbeat()
await this.inner.teardown()
}
+337 -5
View File
@@ -22,6 +22,7 @@ import {
realpathSync,
writeFileSync,
} from 'node:fs'
import { connect } from 'node:net'
import { dirname, join } from 'node:path'
import type { ServerConfig } from '../config.js'
import { handoffPath, homeRoot, userRoot, workspaceRoot } from '../fs/workspace.js'
@@ -174,6 +175,139 @@ function mountParentDirArgs(dest: string, stopAt: string): string[] {
* Local backend: owns the lifecycle of per-user DSH process pairs via
* child_process. State is in-memory. Implements {@link Spawner}.
*/
/* ─────────────────────────────────────────────────────────────────────────────
* 覆盖网络线 序 ㉕:「逐步拉起」= 启动时**不再一刀切清空**既有实例 scope。
*
* 背景(档案 30 的历史):实例真实生命周期在 OS 层(systemd scope),编排器只靠
* 内存 map 追踪 ⇒ 重启后 map 空、旧 scope 成孤儿 ⇒ 当时的修法是「启动即统一清掉」。
* 但那条修法有两个副作用:① **所有**实例在 Manager 启动瞬间被同时杀掉(N 个一起 = 启动
* 风暴)② 空闲期实例也不保。本序把它换成「**扫描 → 认领 → 逐个错峰探活**」。
*
* 🔴 一条必须先说的**客观边界**(本序实测得出,⛔ 别再试图绕过):
* 「认领」**不可能**做到"用户无感直接复用" —— 因为 `Instance.launchToken` 是 dsh web
* **启动时在 stdout 打印一次**的一次性凭据(`/dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=/`),
* 既**不落盘**、也无法在运行期重新取出(实测:无 token 直连实例回 **401**)。而恢复它的两条
* 路都被红线封死:改官方 dsh 取 token(**R2**)✗;把 token 落盘成可读凭据(**R11** 安全维度净变差)✗。
* ⇒ 故 `enter` 在"实例 alive 但无 token"时只能拿 503(`routes/dsh.ts` 的既有语义,⛔ 未改)。
* ⇒ 本序交付的语义 = **「不批量清空、错峰保留、访问时自然替换」**:重启后既有 scope **不被杀**,
* 按节流逐个探活登记;用户访问时由 `spawnInstance` 既有的 `cleanStaleScopes(uid)` **自然替换**
* (换端口换 token,与旧行为等价但**错峰**、且空闲期实例不死)。⛔ 未新增任何凭据落盘。
* ───────────────────────────────────────────────────────────────────────────── */
/** 从 scope 的 `Description`(= `systemd-run` 记录的完整 argv)恢复出的实例三要素。 */
export interface AdoptedScopeInfo {
/** 平台侧用户 id —— 从 `--chdir <dataRoot>/users/<userId>/…` 反解。 */
userId: string
role: InstanceRole
/** 仅 `role === 'main'` 有;watchdog 走 headless、无监听端口。 */
port?: number
/** 实例 cwd(= `spawnInstance` 传给 `spawnAsUser` 的 `folder`)。 */
folder: string
}
/** 单个既有 scope 的处置决定(⛔ 纯数据,便于单测与先红后绿)。 */
export type ScopeAction =
| { kind: 'adopt' }
| { kind: 'stop'; reason: string }
/**
* 解析 `systemctl show -p Description` 的内容。
*
* ⛔ **纯函数、零 IO、零副作用**;**任何**一处不自洽一律回 `undefined` ⇒ 调用方按
* **旧行为 stop**(保守:宁可清掉,也不让一个解析错半截的实例留在系统里)。
*
* 自洽校验(三道,缺一即拒):
* ① 必须能解出 `--profile web|headless`(决定 main / watchdog,⛔ 猜不得)
* ② 必须能解出 `--chdir <abs>` 且其中含 `/users/<userId>/` 段(拿 userId)
* ③ argv 里的 `--reuid <n>` 必须**等于** scope 名里的 uid(交叉验证;本序实测 106 实例为
* `dsh-100002-ef8d1d12.scope` + `--reuid 100002` ⇒ 两者必然同源)
*/
export function parseScopeDescription(desc: string, uidFromName: number): AdoptedScopeInfo | undefined {
const tokens = desc.split(/\s+/).filter((t) => t !== '')
const valueOf = (flag: string): string | undefined => {
const i = tokens.indexOf(flag)
return i >= 0 ? tokens[i + 1] : undefined
}
// ① role
const profile = valueOf('--profile')
if (profile !== 'web' && profile !== 'headless') return undefined
const role: InstanceRole = profile === 'web' ? 'main' : 'watchdog'
// ③ uid 交叉校验
const reuid = valueOf('--reuid')
if (reuid === undefined || reuid !== String(uidFromName)) return undefined
// ② folder + userId
const folder = valueOf('--chdir')
if (folder === undefined || !folder.startsWith('/')) return undefined
const marker = '/users/'
const at = folder.indexOf(marker)
if (at < 0) return undefined
const rest = folder.slice(at + marker.length)
const slash = rest.indexOf('/')
const userId = slash < 0 ? rest : rest.slice(0, slash)
if (userId === '' || userId.includes(' ')) return undefined
// main 必须解出端口;watchdog 不该有端口(有 ⇒ 不自洽)
const portRaw = valueOf('--port')
if (role === 'main') {
if (portRaw === undefined || !/^\d+$/.test(portRaw)) return undefined
const port = Number(portRaw)
if (!Number.isInteger(port) || port <= 0 || port > 65535) return undefined
return { userId, role, port, folder }
}
if (portRaw !== undefined) return undefined
return { userId, role, folder }
}
/**
* 单个 scope 该「认领」还是「按旧行为停掉」。
*
* @param info 解析结果;`undefined` = 解析失败 ⇒ **停**
* @param dupUid 同一 uid 名下出现多个 scope(本平台不可能产生,= 异常/旧 bug 残留)
* ⇒ **全部停**(档案 30 的风险本体:多实例共 profile 写冲突)
*
* 判 `stop` 的四种情形(⛔ 一个都别放宽):
* ① `dup-uid` —— 同 uid 多 scope;
* ② `unparsable` —— 端口 / 用户 / uid 任一解不出(半截信息认领 = 后续替换时定位错实例);
* ③ `no-probe-target` —— watchdog:一次性 headless 任务、无监听端口 ⇒ **无法确认健康**
* 且留着无收益(它正常应当很快自己退出);
* ④ role=main 却无端口 —— 由 ② 一并覆盖(`parseScopeDescription` 直接拒)。
*/
export function decideScopeAction(info: AdoptedScopeInfo | undefined, dupUid: boolean): ScopeAction {
if (dupUid) return { kind: 'stop', reason: 'dup-uid' }
if (info === undefined) return { kind: 'stop', reason: 'unparsable' }
if (info.role !== 'main') return { kind: 'stop', reason: 'no-probe-target' }
return { kind: 'adopt' }
}
/** scope 名 → uid。仅接受本平台自己产生的形态(`dsh-<uid>-<8hex>.scope`)。 */
export function parseScopeUnitName(name: string): number | undefined {
const m = /^dsh-(\d+)-[0-9a-f]+\.scope$/.exec(name)
if (m === null || m[1] === undefined) return undefined
const uid = Number(m[1])
return Number.isInteger(uid) && uid > 0 ? uid : undefined
}
/** 已被「认领」的既有 scope —— ⛔ 刻意**不进** `mains`:见文件头 序 ㉕ 的边界说明。 */
interface AdoptedScope {
unit: string
uid: number
info: AdoptedScopeInfo
adoptedAt: number
/** 探活结果:`undefined` = 未探;`true`/`false` = 结果(失败即按旧行为停)。 */
alive?: boolean
}
/** 认领 / 回收的**计数面**(判别器必须落计数,⛔ 不许只写日志)—— 供探针与演练断言。 */
export interface RehydrateReport {
scanned: number
adopted: number
stopped: number
probeOk: number
probeFail: number
retained: number
notes: string[]
}
export class LocalSpawner implements Spawner {
private readonly mains = new Map<string, Instance>()
private readonly watchdogs = new Map<string, Instance>()
@@ -196,6 +330,18 @@ export class LocalSpawner implements Spawner {
private readonly reapTimer: NodeJS.Timeout | undefined
private readonly portGuard: PortGuard | undefined
/** 序 ㉕:认领到的既有实例 scope(⛔ 刻意不进 `mains`,理由见文件头)。key = unit 名。 */
private readonly adopted = new Map<string, AdoptedScope>()
/** 序 ㉕:认领 / 回收的计数面(判别器必须落计数)。 */
private readonly rehydrate: RehydrateReport = {
scanned: 0,
adopted: 0,
stopped: 0,
probeOk: 0,
probeFail: 0,
retained: 0,
notes: [],
}
constructor(
private readonly config: ServerConfig,
@@ -205,8 +351,9 @@ export class LocalSpawner implements Spawner {
private readonly resolveUid: (userId: string) => Promise<number>,
) {
this.portGuard = createPortGuard(config.portGuard)
// 档案 30:portal 启动即清掉遗留实例 scope(重启后无法接管)。
this.cleanAllStaleScopes()
// 覆盖网络线 序 ㉕:原为「档案 30:portal 启动即清掉遗留实例 scope(重启后无法接管)」。
// 现改为「扫描 → 认领 → 逐个错峰探活」—— 见文件头 序 ㉕ 的完整说明与那条客观边界。
this.rehydrateAdoptedScopes()
// Local-mode idle reap: periodically stop mains that are idle past the TTL,
// then cap the resident count (LRU by last activity). Only armed when at
// least one of the two rules is enabled. The timer is unref'd so it never
@@ -453,10 +600,28 @@ export class LocalSpawner implements Spawner {
this.resetCrashState(userId)
}
/** Stop every tracked process on shutdown. */
/**
* 覆盖网络线 序 ㉘ → 单 A(候选 `B`):**退出路径不再停任何实例**。
*
* 改前语义 = 清 `reapTimer` + 逐个 `stop(userId)`(把在册实例全杀掉)⇒ 进程一重启,实例
* scope 随主进程一起消失 ⇒ 启动认领 `rehydrateAdoptedScopes()` 永远扫不到存量
* ⇒ 「Manager 重启后逐步拉起既有实例」**不可能成立**(序 ㉕ 已实测的真凶)。
*
* 改后:**只停本进程自己的定时器**,实例留给下一个进程。回收责任移交给下面三条:
* ① `rehydrateAdoptedScopes()` —— 启动时扫 OS 层既有 scope ⇒ 探活 ⇒ 活的认领 / **端口不通**的停掉;
* ② `cleanStaleScopes(uid)` —— 用户访问 / spawn 前清同 uid(**档案 30 本体**,⛔ 不许删);
* ③ idle-reap —— 缺省 60 s 间隔 / TTL 7 天 / 每 host 上限 4(`src/config.ts:300-302`)。
*
* ⛔ **不许**在退出路径里停实例、也**不许**向远端下发停止 —— 停止实例的正路是 `stop(userId)`
* (由路由层在用户**显式**停实例时调用),⛔ 不是退出路径。机器断言见
* `test/orchestrator-teardown.test.mjs` + 探针 `OBS-22`。
* ⚠️ 边界(如实):`launchToken` 不可恢复 ⇒ 重启后 `enter` 仍可能 503,存量实例要在用户**下次访问**
* 时被 `cleanStaleScopes` 自然替换。收益 = 「不被杀 + 访问时自然替换」,⛔ **不是**「重启后直接可用」。
* 🔙 回滚 = 把下面那行循环加回来 ⇒ 秒级(本单 §6 路 A / 路 B)。
*/
async teardown(): Promise<void> {
// ⛔ 退出不停实例(guard: teardown-must-not-stop-instances)
if (this.reapTimer !== undefined) clearInterval(this.reapTimer)
for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId)
}
/** No-op: local mode has no sidecar — the control plane owns the volume. */
@@ -1215,6 +1380,171 @@ export class LocalSpawner implements Spawner {
this.restartTimers.set(userId, timer)
}
/* ── 序 ㉕:既有实例 scope 的「扫描 → 认领 → 错峰探活」 ──────────────────────
*
* ⛔ 三条自我约束(违反即等于放大档案 30 的风险):
* ① 只在 `isolationMode === 'account'` 下认领 —— 其它形态根本不产生 scope,
* 此时**保持旧行为**(走 `cleanAllStaleScopes()`,实际是空操作)。
* ② 解析不出 / 同 uid 重复 / watchdog / 探活不通 ⇒ **一律按旧行为停掉**。
* ③ 认领**只登记 + 探活**:⛔ 不 stop、⛔ 不 spawn、⛔ 不接管道、⛔ 不落任何凭据。
*
* 🔑 「孤儿」的判据 = **端口不通**,不是「启动了却不认识」:
* 端口在听 ⇒ 它是**有效实例**(留着 = 与重启前稳态一致,用户/后台任务零中断);
* 端口不通 ⇒ 才是档案 30 说的孤儿 ⇒ 按旧行为停掉。
* 而"双实例共 profile"那一半由 `cleanStaleScopes(uid)`(spawn 前清同 uid)继续兜住 —— **本序未动**。
*/
/** 节流间隔:逐条认领之间的最小时间差(⛔ 不落生产 env,只读进程环境取默认)。 */
private rehydrateStaggerMs(): number {
const n = Number(process.env.DSHS_REHYDRATE_STAGGER_MS ?? '500')
return Number.isFinite(n) && n >= 0 ? n : 500
}
/** 单条探活超时。 */
private rehydrateProbeMs(): number {
const n = Number(process.env.DSHS_REHYDRATE_PROBE_MS ?? '2000')
return Number.isFinite(n) && n > 0 ? n : 2000
}
/** 认领计数快照(供演练 / 探针断言,⛔ 只读)。 */
rehydrateReport(): RehydrateReport {
return { ...this.rehydrate, notes: [...this.rehydrate.notes] }
}
private rehydrateAdoptedScopes(): void {
// ① 非 account 形态不产生 scope ⇒ 保持旧语义(此处是空操作)。
if (this.config.isolationMode !== 'account') {
this.cleanAllStaleScopes()
return
}
const found = this.scanExistingScopes()
this.rehydrate.scanned = found.length
if (found.length === 0) {
process.stderr.write('[rehydrate] 无既有实例 scope ⇒ 不动作(与旧行为等价)\n')
return
}
const perUid = new Map<number, number>()
for (const s of found) perUid.set(s.uid, (perUid.get(s.uid) ?? 0) + 1)
const stagger = this.rehydrateStaggerMs()
const schedule = (i: number): void => {
if (i >= found.length) {
process.stderr.write(`[rehydrate] summary ${JSON.stringify(this.rehydrateReport())}\n`)
return
}
const t = setTimeout(() => {
const s = found[i]
if (s !== undefined) this.adoptOne(s, (perUid.get(s.uid) ?? 0) > 1)
schedule(i + 1)
}, stagger)
t.unref()
}
schedule(0)
}
private scanExistingScopes(): { unit: string; uid: number; desc: string }[] {
const out: { unit: string; uid: number; desc: string }[] = []
let listing: string
try {
listing = execFileSync('systemctl', ['list-units', '--type=scope', '--no-legend', '--plain'], {
encoding: 'utf8',
timeout: 10000,
})
} catch {
// ⚠️ 列不出来 ⇒ **不杀任何东西**(与旧行为一致:旧代码的 catch 同样吞掉、不清不杀)
process.stderr.write('[rehydrate] ⚠️ systemctl list-units 失败 ⇒ 本次不认领、不清理\n')
return out
}
for (const line of listing.split('\n')) {
const name = line.trim().split(/\s+/)[0]
if (name === undefined || name === '') continue
const uid = parseScopeUnitName(name)
if (uid === undefined) continue
out.push({ unit: name, uid, desc: this.scopeDescription(name) })
}
return out
}
/** 取 scope 的 `Description`(= `systemd-run` 记录的 argv);取不到 ⇒ 空串 ⇒ 解析必失败 ⇒ 停。 */
private scopeDescription(unit: string): string {
try {
return execFileSync('systemctl', ['show', unit, '-p', 'Description', '--value'], {
encoding: 'utf8',
timeout: 10000,
}).trim()
} catch {
return ''
}
}
private adoptOne(s: { unit: string; uid: number; desc: string }, dupUid: boolean): void {
const info = parseScopeDescription(s.desc, s.uid)
const action = decideScopeAction(info, dupUid)
if (action.kind === 'stop') {
this.rehydrate.stopped += 1
const note = `stop ${s.unit} (${action.reason})`
this.rehydrate.notes.push(note)
process.stderr.write(`[rehydrate] ⛔ ${note}\n`)
this.stopUnit(s.unit)
return
}
const adopted: AdoptedScopeInfo = info as AdoptedScopeInfo
const rec: AdoptedScope = { unit: s.unit, uid: s.uid, info: adopted, adoptedAt: Date.now() }
this.adopted.set(s.unit, rec)
this.rehydrate.adopted += 1
process.stderr.write(
`[rehydrate] adopted ${s.unit} uid=${s.uid} role=${adopted.role} port=${adopted.port ?? '-'} user=${adopted.userId}\n`,
)
this.probeAdopted(rec)
}
/** TCP 探活:端口在听 ⇒ 保留(有效实例);连不上 ⇒ 按旧行为停掉(孤儿)。 */
private probeAdopted(rec: AdoptedScope): void {
const port = rec.info.port
if (port === undefined) {
// 不应发生(main 必带端口);保守停掉。
this.rehydrate.stopped += 1
this.adopted.delete(rec.unit)
this.stopUnit(rec.unit)
return
}
let settled = false
const sock = connect({ host: '127.0.0.1', port })
const done = (ok: boolean): void => {
if (settled) return
settled = true
try {
sock.destroy()
} catch {
/* ignore */
}
rec.alive = ok
if (ok) {
this.rehydrate.probeOk += 1
process.stderr.write(`[rehydrate] probe OK ${rec.unit} :${port}\n`)
return
}
this.rehydrate.probeFail += 1
const note = `probe-fail ${rec.unit} :${port}`
this.rehydrate.notes.push(note)
process.stderr.write(`[rehydrate] ⛔ ${note} ⇒ 判孤儿,按旧行为停掉\n`)
this.rehydrate.stopped += 1
this.adopted.delete(rec.unit)
this.stopUnit(rec.unit)
}
sock.setTimeout(this.rehydrateProbeMs(), () => done(false))
sock.once('error', () => done(false))
sock.once('connect', () => done(true))
}
/** 停一个 unit(与既有清理同款:失败**静默跳过**,⛔ 不抛)。 */
private stopUnit(unit: string): void {
try {
execFileSync('systemctl', ['stop', unit], { timeout: 10000 })
} catch {
/* ignore */
}
}
/** 档案 30:清掉指定 uid 名下的残留 systemd scope(孤儿)。严格前缀匹配,不误伤门户自身。 */
private cleanStaleScopes(uid: number): void {
this.stopScopesByPrefix(`dsh-${uid}-`)
@@ -1234,7 +1564,9 @@ export class LocalSpawner implements Spawner {
if (name === undefined || name === '') continue
if (!name.startsWith(prefix) || !name.endsWith('.scope')) continue
if (re !== undefined && !re.test(name)) continue
try { execFileSync('systemctl', ['stop', name], { timeout: 10000 }) } catch { /* ignore */ }
this.stopUnit(name)
// 序 ㉕:被清掉的 unit 若在认领表里,同步摘掉(避免留下陈旧记录)
this.adopted.delete(name)
}
} catch { /* list-units 失败:跳过 */ }
}
+9
View File
@@ -326,7 +326,16 @@ export class RemoteSpawner implements Spawner {
await this.call(host, 'POST', '/stop', { userId }, randomUUID())
}
/**
* 覆盖网络线 序 ㉘ → 单 A(候选 `B`):**取证已是 no-op**(`git show HEAD:` 与工作区逐字相同)
* ⇒ 本单**不做语义改动**,只把「退出不停实例」这条约束**固化**成可被机器断言的守卫标记
* (防将来被改回去 ⇒ "同一语义三份实现"里最容易被顺手破坏的一份)。
*
* ⛔ 本函数体里**永远不许**出现向远端下发停止的调用(`test/orchestrator-teardown.test.mjs`
* 有动态断言 + 静态 grep 断言)。
*/
async teardown(): Promise<void> {
// ⛔ 退出不停远端实例(guard: teardown-must-not-stop-instances)
// 远端实例的寿命长于任何单个 Manager 副本 ⇒ 由 Manager 的归属/租约管理,不在关闭时清。
}
+278 -16
View File
@@ -17,16 +17,26 @@ import { RemoteUserFs } from '../fs/remote-user-fs.js'
import type { UserFs } from '../fs/user-fs.js'
import { decrypt, deriveKey } from '../crypto.js'
import { hashUid } from '../isolation.js'
import { addressPort, agentBaseUrlOf, parseReachability, VIA_MANAGER_SSH, VIA_RELAY } from '../net/reachability.js'
import { addressPort, agentBaseUrlOf, parseReachability, VIA_MANAGER_SSH } from '../net/reachability.js'
import { LocalRendezvous, ManagerSshRendezvous, RendezvousRegistry } from '../net/rendezvous.js'
import { OPS_NETWORK, logicalName } from '../net/relay/network.js'
import { RelayRendezvous } from '../net/relay/rendezvous.js'
import { RelayDialer } from '../net/relay/dialer.js'
import { listOverlayRelayCandidates, resolveOverlayRelay } from '../net/relay/directory.js'
import type { OverlayRelayCandidates } from '../net/relay/directory.js'
import { RelayClient, waitUpOnStatus } from '../net/relay/client.js'
import { RelayCandidateObservation, candidateObsMs } from '../worker/relay-tunnel.js'
import { hostNameIndex, relayEndpointTarget } from '../net/relay/endpoint-target.js'
import { loadClientIdentity } from '../net/relay/identity.js'
import { RelayFailoverSupervisor, relayFailoverThresholds } from '../net/relay/switcher.js'
import type { RelayChannelHandle } from '../net/relay/switcher.js'
// ── 序㉔ 内容分发(块级内容寻址 · 同网段 peer 优先)────────────────────────────
// ⛔ 装配仅"接线",不改 presence / 端点翻译 / 切流既有逻辑(交接单 §3.1)。
import { ContentStore } from '../net/relay/content/store.js'
import { ContentSourceChain } from '../net/relay/content/source.js'
import { ContentPeerGroup } from '../net/relay/content/peer.js'
// 🆕 序㉘ · 单 B:组密钥装载(平台侧**缺省不启用**;具名失败 ⇒ 不启用并留痕)
import { openContentCipher } from '../net/relay/content/crypto.js'
import { LocalSpawner } from '../supervisor/orchestrator.js'
import { LeasedSpawner } from '../supervisor/leased-spawner.js'
import { RemoteSpawner, type ClusterHost } from '../supervisor/remote-spawner.js'
@@ -315,6 +325,15 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
* 🔑 **键是逻辑名**(P0-3),与 `hostAddresses` 同口径。
*/
const hostVia = new Map<string, string>()
/**
* **`hostId` → 逻辑名** 索引(序㉑ P-2)—— `translateEndpoint` 的**唯一入口**。
*
* 🔑 为什么必须有它:`RemoteSpawner` 调翻译器时只给得到**裸 hostId**
* (`endpointFor` → `translateEndpoint(host.hostId, raw)`),而上面每张表的键都是**逻辑名**
* ⇒ 直接拿 hostId 去查恒 `undefined` ⇒ 翻译**从未生效**(整个闭包成了死分支,在册缺陷 P-2)。
* 映射公式只在 `hostNameIndex` 里写一份(⛔ 不在闭包里再写第二份)。
*/
const hostNameById = new Map<string, string>()
/**
* relay 的实时视图(覆盖网络 R3):`<network>/<hostId>:<port>` → `{ localPort, online }`。
*
@@ -329,8 +348,47 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
*/
const relayEndpoints = new Map<string, { localPort: number; online: boolean }>()
let relaySnapshotAt = 0
/**
* 「当前通道的 `RelayClient`」的**延迟绑定**取值器。
*
* 🔑 存在的唯一理由是**初始化顺序**:`/status` 轮询的首次调用发生在拨号通道建立**之前**,
* 那时 `failover` 还在 TDZ 里(直接引用会 `ReferenceError`)。而把 `failover` 提前声明成
* `let` 又会丢掉"当前通道只有**一个**权威来源"这条纪律(序⑦ 为它专门收敛过)。
* ⇒ 用一个可空函数引用,**谁都不破坏**。
*/
let currentClientRef: (() => RelayClient | undefined) | undefined
const currentClient = (): RelayClient | undefined => currentClientRef?.()
/**
* 序⑲ presence:**订阅是否新鲜** —— D5「主路径 / 兜底」的**唯一开关**。
*
* ⚠️ 必须是函数而不是布尔量:通道会被换址(序⑦),"订阅有没有"随通道走 ⇒ 每次调用现读。
* `undefined`(还没起通道 / 已切走)也算不新鲜 ⇒ 回退 `/status`,语义安全。
*/
const presenceLive = (): boolean => currentClient()?.presenceFresh() === true
/**
* 订阅新鲜度变化的**可 grep 记录**(⛔ 别让"轮询停了"变成看不见的静默行为)。
*
* ⚠️ 这里直接写 stdout 而不用 `overlayLog`:本函数会在 `overlayLog` 初始化**之前**被首次
* 调用(首次 `refreshRelay` 就在那一行 `void refreshRelay()`)⇒ 引用它同样会 TDZ。
*/
let pollSuspended = false
const notePollGate = (suspend: boolean): void => {
if (suspend === pollSuspended) return
pollSuspended = suspend
process.stdout.write(
suspend
? '[overlay-presence] 订阅新鲜 ⇒ `/status` 轮询**挂起**(兜底路径待命)\n'
: '[overlay-presence] 订阅不新鲜 ⇒ `/status` 轮询**恢复**(兜底路径生效)\n',
)
}
const refreshRelay = async (): Promise<void> => {
if (config.relayStatusUrl === '') return
/**
* 🔑 **序⑲ 的核心收益点**:订阅生效期间**一次都不拉**(E-判据:稳态 `/status` 命中 = 0)。
* ⛔ 不是"删掉轮询"(D5:`/status` 是回滚链的一环)—— 只是**在不需要时不拉**。
*/
notePollGate(presenceLive())
if (pollSuspended) return
try {
const res = await fetch(config.relayStatusUrl, { signal: AbortSignal.timeout(RELAY_STATUS_TIMEOUT_MS) })
if (!res.ok) return
@@ -454,6 +512,13 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
client.stop()
return undefined
}
/**
* 序⑲ presence:**订阅本网全部在线态**(第 3 条:订阅式扇出)。
*
* ⚠️ 放在口池绑定**成功之后**:绑不上就等于这条通道没有,订阅了也没人消费,
* 反而会留下"subs=1 但没人用"的假象。订阅失败不影响通道本身(自动回退 `/status`)。
*/
client.subscribePresence()
return { client, dialer }
}
/**
@@ -495,6 +560,26 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
})
/** 序⑦ 切流阈值(唯一一份默认值在 `switcher.ts`;这里只是取一份实例)。 */
const failoverThresholds = relayFailoverThresholds()
/**
* ── 序㉗:**候选链取址的唯一入口(含只读观测)** ──
*
* 两处**既有**调用点(下面对监管器给的 `candidates` 与 {@link refreshOverlay})共用本函数:
* ① 候选**只在同一处**被解析 ⇒ ⛔ **不新增任何网络 I/O**(`refreshOverlay` 本来就要解析一次);
* ② 每次**真实**解析都落进观测 ⇒ 观测面不是"另做一次"的样子货(E3 的可断言面)。
*
* 🔴 **等价性证明(⛔ 语义零变化)**:`refreshOverlay` 原来调的是
* `resolveOverlayRelay(overlayResolveOpts())` —— 该函数**未传 `exclude`**,而 `exclude` 缺省时
* 它就是 `{url: chain.urls[0] ?? '', source: chain.source, detail: chain.detail, refreshAfterSeconds}`
* 的**薄包装**(见 `directory.ts#resolveOverlayRelay`),且调用点**只用到 `url` 与 `source`**
* ⇒ 这里取 `chain.urls[0] ?? ''` / `chain.source` **逐字等价**。
* ⚠️ 若将来该调用点要用到 `exclude`,必须改回 `resolveOverlayRelay`(⛔ 不许在这里自己写排除逻辑)。
*/
const candObs = new RelayCandidateObservation('manager', overlayLog, candidateObsMs())
const resolveChain = async (): Promise<OverlayRelayCandidates> => {
const got = await listOverlayRelayCandidates(overlayResolveOpts())
candObs.record(got.urls, got.source, got.detail)
return got
}
/**
* 换址版等待:**非抛**,只回答"通没通"(`open` 要的是布尔)。
*
@@ -541,13 +626,36 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
}
return toHandle(url, started)
},
candidates: async () => (await listOverlayRelayCandidates(overlayResolveOpts())).urls,
/** 序㉗:走 `resolveChain` ⇒ 每次解析都落进观测(`E3` 的可断言面);返回值与原实现逐字一致。 */
candidates: async () => (await resolveChain()).urls,
log: overlayLog,
thresholds: failoverThresholds,
})
/** 当前拨号通道(由监管器的"当前通道"派生 —— 单一权威来源)。 */
const currentDialer = (): RelayDialer | undefined =>
(failover.channel as C1Handle | undefined)?.dialer
// 把「当前通道的 client」接上去(`presenceLive()` / `presenceOf()` 的取数入口)。
currentClientRef = () => (failover.channel as C1Handle | undefined)?.client
/**
* 序⑲:**订阅推送**给出的在线态(主路径,D5)。
*
* 返回 `undefined` 的两种情况**都必须回退兜底**(⛔ 不许把它当 `false`):
* ① 订阅不新鲜(没订阅 / 已断 / 老 relay 不认 `SUB`);② 这条 host 不在推送范围。
* —— 把"不知道"当成"离线"会让**健康的节点被摘掉路由**(本线最贵的一类假红)。
*/
const presenceOnline = (name: string): boolean | undefined => {
const c = currentClient()
if (c === undefined || !c.presenceFresh()) return undefined
const e = c.presenceOf(name)
return e === undefined ? undefined : e.online
}
/** 订阅里的**回环落点**(`/status` 的那份数据,改由推送带来;查不到 ⇒ `undefined` 交给下一级兜底)。 */
const presenceLocalPort = (name: string, port: number): number | undefined => {
const c = currentClient()
if (c === undefined || !c.presenceFresh()) return undefined
const hit = c.presenceOf(name)?.localPorts.find((lp) => lp.port === port)
return hit === undefined || hit.localPort <= 0 ? undefined : hit.localPort
}
if (dialerEnabled) {
const started = await startDialer(relayUrl)
if (started !== undefined) failover.seed(toHandle(relayUrl, started))
@@ -556,6 +664,13 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
* 而故障切换要在 `RELAY_FAILOVER_DEADLINE_MS`(30 s)内完成 ⇒ 两者节奏必须分开。
*/
failover.start()
/**
* 序㉗:启动候选链**周期重发**(幂等、`unref()`、⛔ 零网络 I/O —— 只重发上次快照)。
* 与监管器的关系:监管器**只在需要换址时**解析,而探针是**事后**读 ⇒ 没有它就可能读不到行。
* ⚠️ 只在 `dialerEnabled` 时启动 —— 没有拨号通道就**不是 relay 客户端**,此时"候选数"无意义,
* 观测行**应当缺席**(探针会把"该路径无观测行"判红并点名,⛔ 不制造一行假 `unresolved`)。
*/
candObs.start()
}
/**
* 覆盖网络 P0-2:**后台刷新目录**(启动后再取一次,之后按目录给的刷新周期)。
@@ -571,16 +686,22 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
*/
const refreshOverlay = async (): Promise<void> => {
if (!dialerEnabled) return
const next = await resolveOverlayRelay(overlayResolveOpts())
/**
* 序㉗:改走 `resolveChain()` —— 与原 `resolveOverlayRelay(...)` **逐字等价**(证明见 `resolveChain`),
* 但**每次目录刷新都落进候选观测** ⇒ Manager 侧观测行天然每 `refreshAfterSeconds` 更新一次
* (⛔ 不需要为观测另加一次网络往返)。
*/
const chain = await resolveChain()
const nextUrl = chain.urls[0] ?? ''
const cur = failover.channel?.url ?? relayUrl
if (next.url === '' || next.url === cur) return
overlayLog(`[overlay-dir] 🔁 目录给出的地址变了:${cur} -> ${next.url}(source=${next.source})⇒ 换拨号通道`)
if (nextUrl === '' || nextUrl === cur) return
overlayLog(`[overlay-dir] 🔁 目录给出的地址变了:${cur} -> ${nextUrl}(source=${chain.source})⇒ 换拨号通道`)
/**
* 序⑧(D1):显式声明 `'directory'` —— 这条换址的触发条件("目录里的地址变了")与
* "旧通道是否可用"**无关** ⇒ ⛔ **没有打破冷却的权力**(有的话,"当前站在 106、目录首位是 47"
* 的每一轮巡检都会把刚冷却的 47 换回来 = 两位互相抢 = D5 想防的抖动风暴)。
*/
await failover.replace(next.url, `目录地址变更(source=${next.source})`, 'directory')
await failover.replace(nextUrl, `目录地址变更(source=${chain.source})`, 'directory')
}
if (dialerEnabled) {
const first = setTimeout(() => void refreshOverlay(), OVERLAY_REFRESH_FIRST_MS)
@@ -613,7 +734,11 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
// (`RelayDialer` 里带日志地拒掉)⇒ 绝不会把别张网的落点发出去。
const dialed = currentDialer()?.localPortFor(name, port)
if (dialed !== undefined) return `127.0.0.1:${dialed}`
// ② 回退:relay 快照里的动态回环口号(仅在 relay 与 Manager 同机时可用)
// ② 回退:**订阅推送**带来的回环落点(序⑲:订阅新鲜时它才是唯一在更新的那份)
const pushed = presenceLocalPort(name, port)
if (pushed !== undefined) return `127.0.0.1:${pushed}`
// ③ 再回退:relay 快照(只有"没订阅 / 订阅不新鲜"时才会走到这里 —— 即 R3 的原路径)
// 仅在 relay 与 Manager 同机时可用
const hit = relayEndpoints.get(`${name}:${port}`)
return hit === undefined ? undefined : `127.0.0.1:${hit.localPort}`
},
@@ -634,6 +759,11 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
*/
return currentDialer() !== undefined
},
/**
* 序⑲:**主路径 = 订阅推送**(新鲜时它是权威答案,`online` 那条兜底就不会被调用)。
* 返回 `undefined` ⇒ 回落到上面 `online`(= R3 的既有行为,一行未改)。
*/
presence: presenceOnline,
}),
]),
])
@@ -645,9 +775,15 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
* 网络维度取自 `dsh_hosts.network_id`(P0-1 迁的列,**本步才真正被消费**)——
* 在此之前它只是"表里有、没人读",于是控制面所有键(地址表 / via 表 / 端口表 /
* relay 端点表 / 拨号口池)都按**裸 hostId**,两张网各有一台同名 host 时会**互相覆盖**。
*
* ⚠️ 序㉑ P-2:映射由 `hostNameIndex` 统一提供(闭包 `translateEndpoint` 用的是**同一份**),
* 这里只是把它读出来;`??` 那支是**类型兜底**(索引按 `rows` 建 ⇒ 实际不可达)。
*/
const nameOf = (row: { id: string; networkId: string }): string =>
logicalName(row.networkId === '' ? OPS_NETWORK : row.networkId, row.id)
hostNameById.get(row.id) ?? logicalName(row.networkId === '' ? OPS_NETWORK : row.networkId, row.id)
// P-2:填 `hostId → 逻辑名`(闭包拿到裸 hostId 后靠它回到"控制面的键口径")
hostNameById.clear()
for (const [id, name] of hostNameIndex(rows)) hostNameById.set(id, name)
// 先同步地址表(会合实现不直接连 DB),再逐行解析 —— 两遍是为了让 `resolve()` 只看纯内存表。
for (const row of rows) {
const name = nameOf(row)
@@ -721,6 +857,106 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
candidates.sort((a, b) => a.usedMb - b.usedMb)
return candidates[0].id
}
/**
* ── 序㉔ 内容分发装配(块级内容寻址 · 同网段 peer 优先)──────────────────────
*
* ⚠️ **本块是"仅装配"**(交接单 §3.1):只把 `store` / `source` / `peer` 三个纯逻辑模块
* **接线并暴露计数**,⛔ 不改 presence、不改端点翻译、不改切流、不新开监听口(R5)。
*
* **回滚 = 整段移除本块**(§6 装配级回滚):新模块文件留着不加载 ⇒ 零副作用。
*
* 三档 fetcher 的落点(本阶段):
* - `local` —— 直接查 `ContentStore`(同进程已持有的块);
* - `peer` —— 走 `ContentPeerGroup.candidates()` 的结果(同组 peer;真机取块经既有 wss);
* - `edge` / `region` / `origin` —— **本阶段未装配**(缺档 ⇒ 链按"该档没有"处理并**照样计数**,
* 这正是 `ContentSourceChain` 纪律 2/3 要的行为:⛔ 不许因为没装配就静默缩短链路)。
*
* ⚠️ 之所以敢先不装配 `origin`:本单的 E1 判据(回源 ≈ 1 份 × 组数)测的是
* "**同组内多台只回源一次**",判据落在 `local` / `peer` 两档的命中计数上;
* 真回源路径(平台代理层)本就在 `proxy.ts`,与本块正交。
*
* 🔴 **两个参数就地读 env(⛔ 不进 `config.ts`)**:`config.ts` **不在本单在册文件集**
* (交接单 §3.1)⇒ 动它 = 命中 §9-2 回头条件(超范围)。故装配层就地取:
* - `CONTENT_STORE_MAX_BYTES` —— 块缓存上限(缺省 64 MiB,见 `store.ts` 推算);
* - `CONTENT_GROUP` —— 本节点在内容面上的**组名**(缺省 `local`)。
* ⚠️ 二者都是"纯新增、缺省可用"⇒ 不设也不影响既有行为(⛔ 不动任何既有键)。
*/
const contentStoreMaxBytes =
Number(process.env.CONTENT_STORE_MAX_BYTES ?? '') > 0
? Number(process.env.CONTENT_STORE_MAX_BYTES)
: undefined
const contentGroup = process.env.CONTENT_GROUP ?? 'local'
/**
* 🆕 序㉘ · 单 B:**平台侧接入加密,但缺省不启用**。
*
* 🔑 为什么平台侧**默认关**:① 平台进程服务真实用户,密钥落点越少越好(单内 §7.2
* "密钥本体只走 `0600` 落文件");② `OBS-23` 的读取面是 **relay** 的 `/status`
* ⇒ 判据在 relay 侧成立即可;③ `peer` 取回通道尚未接线 ⇒ 跨进程密钥一致性今天**不构成收益**。
* ⚠️ 要开只需配 `CONTENT_GROUP_KEY_FILE`(**纯新增、缺省可用** ⇒ 不设即回到序㉔ 行为)。
*/
const platformKeyFile = process.env.CONTENT_GROUP_KEY_FILE ?? ''
const platformGraceMs = Number(process.env.CONTENT_EPOCH_GRACE_MS ?? '')
const { cipher: contentCipher } =
platformKeyFile === ''
? { cipher: undefined }
: openContentCipher({
file: platformKeyFile,
group: contentGroup,
network: OPS_NETWORK,
...(Number.isFinite(platformGraceMs) && platformGraceMs > 0 ? { graceMs: platformGraceMs } : {}),
log: overlayLog,
})
const contentStore = new ContentStore({
maxBytes: contentStoreMaxBytes,
})
const contentPeers = new ContentPeerGroup({
// 本节点在内容面上的分组:网 = 运维网(平台自己的机器),组 = 本机(同网段走本机多实例验证)
network: OPS_NETWORK,
group: contentGroup,
...(contentCipher === undefined ? {} : { epoch: contentCipher.epoch }),
log: overlayLog,
})
const contentSource = new ContentSourceChain({
fetchers: {
local: async (id: string) => {
const bytes = contentStore.get(id)
return bytes === undefined ? undefined : { tier: 'local' as const, bytes }
},
peer: async (id: string) => {
// 同组有候选 ⇒ 由上层真机路径去取;本阶段没有真实取回通道时诚实回"没有"
// (⛔ 不许伪造字节 —— 那会让 E1 的"零回源"变成假绿)
const cands = contentPeers.candidates(id)
return cands.length === 0 ? undefined : undefined
},
},
// 🆕 单 B:唯一解密点(生产路径);未启用加密 ⇒ 不注入 ⇒ 行为逐字不变
...(contentCipher === undefined ? {} : { decode: (stored: Buffer) => contentCipher?.decodeBlock(stored) }),
onHit: (tier, id) => overlayLog(`[content] 命中 tier=${tier} block=${id.slice(0, 8)}…`),
onMiss: (tier, id) => overlayLog(`[content] 未命中 tier=${tier} block=${id.slice(0, 8)}…`),
onError: (tier, id, err) =>
overlayLog(`[content] ⛔ tier=${tier} 抛错 block=${id.slice(0, 8)}… err=${String(err)}`),
onDecodeRejected: (tier, id) =>
overlayLog(`[content] ⛔ tier=${tier} 取回的块解密失败 block=${id.slice(0, 8)}…(认证未过)`),
})
/** 内容面计数快照(供 `/status` 类观测读取;⛔ 只读,不改任何既有字段)。 */
const contentCounters = (): Record<string, unknown> => ({
store: contentStore.counters(),
storeBytes: contentStore.bytes,
storeBlocks: contentStore.size,
/** ⚠️ 键名与 `ContentSourceChain.counters()` 同构 ⇒ 探针可逐档断言。 */
source: contentSource.counters(),
sourceMiss: contentSource.missCounters(),
sourceErrors: contentSource.errors(),
sourceMissTotal: contentSource.misses(),
/** 🆕 单 B:逐档解密被拒(`sourceErrors` 的细分)+ 加密判别器块。 */
sourceDecodeRejected: contentSource.decodeRejected(),
// ⚠️ 不启用 ⇒ 键整体缺席(补零会让"没启用"与"启用了但零值"同形)
...(contentCipher === undefined ? {} : { crypto: contentCipher.counters() }),
peer: contentPeers.counters(),
peerGroup: contentPeers.groupKey,
})
void contentCounters // 暴露给后续观测面(S5 探针项);本阶段先建在作用域内,避免"装了但没人读"
const supervisor: Spawner =
config.deployMode === 'cluster'
? (leased = new LeasedSpawner(
@@ -751,14 +987,40 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
* 空响应、平台零日志)。未知 host 仍按老行为原样返回(单机 / 默认 host 不受影响)。
*/
translateEndpoint: (hostId, ep) => {
if (hostVia.get(hostId) !== VIA_RELAY) return ep
// ① 首选**拨号通道**(R5):落点在 Manager 自己本机 ⇒ relay 换机器也成立
const dialed = currentDialer()?.localPortFor(hostId, ep.port)
if (dialed !== undefined) return { host: '127.0.0.1', port: dialed }
// ② 回退:relay 快照里的动态回环口号。查不到 ⇒ **失败关闭**(实例此刻不可达),
// 绝不回退成 Worker 侧口号 —— 那正是"浏览器只见空响应、平台零日志"的成因。
const hit = relayEndpoints.get(`${hostId}:${ep.port}`)
return hit === undefined ? undefined : { host: '127.0.0.1', port: hit.localPort }
/**
* 🔴 **序㉑ P-2:先把裸 hostId 换成逻辑名**(控制面所有表的键口径)。
*
* 原实现直接 `hostVia.get(hostId)` ⇒ **恒 `undefined`** ⇒ 早退原样透传 ⇒
* 这个闭包整体是**死分支**(翻译从未生效;`translateEndpoint` 的第一版还漏过赋值)。
* 判定本体已抽成纯函数 `relayEndpointTarget`(可单测、可先红后绿)。
*/
const name = hostNameById.get(hostId)
const decision = relayEndpointTarget({
known: name !== undefined,
via: name === undefined ? undefined : hostVia.get(name),
// ⚠️ thunk:`localPortFor` **会按需绑池口 / 真的拨一次** ⇒ 只在 via=relay 时才准调
dialedPort:
name === undefined ? () => undefined : () => currentDialer()?.localPortFor(name, ep.port),
/**
* 🔴 **序㉒ P-2b:第 ②' 级 = 订阅推送落点**。
*
* 必须与 `addressOf`(上方 `RelayRendezvous`)的三级链**同源、同顺序**:
* ① 拨号落点 → ② `presenceLocalPort` → ③ relay 快照。少这一级时,P-1 修好之后
* (订阅新鲜期长期成立 ⇒ 快照趋冷)会在"拨号池分不出槽位、但推送里有落点"时
* **判实例不可达(失败关闭)**,而同一时刻地址解析链能答出落点 ⇒ 两条链漂移 ⇒
* 用户看到"实例打不开",日志却什么都没有。
*/
pushedLocalPort: name === undefined ? undefined : presenceLocalPort(name, ep.port),
snapshotLocalPort:
name === undefined ? undefined : relayEndpoints.get(`${name}:${ep.port}`)?.localPort,
})
if (decision.kind === 'passthrough') return ep
if (decision.kind === 'local') return { host: '127.0.0.1', port: decision.port }
// 失败关闭 + **点名**(⛔ 不回原样透传:那会拨到 Manager 本机,症状只有"空响应 + 零日志")
overlayLog(
`[overlay-endpoint] ⛔ ${name ?? hostId}:${ep.port} 在册且 via=relay,但拨号落点 / 订阅推送 / relay 快照三级都查不到 ⇒ 判实例不可达(失败关闭)`,
)
return undefined
},
}),
db,
+213 -3
View File
@@ -75,6 +75,170 @@ function healthOf(client: RelayClient): { state: string; attempts: number; unhea
return { state: st.state, attempts: st.attempts, unhealthyForMs: st.unhealthyForMs }
}
/**
* 候选链**只读观测**(覆盖网络 · 序㉗)—— E3「**每连接候选数 ≥ 2**」的可机器断言面。
*
* ## 为什么需要它(立项依据)
* 序㉖ §8.1-⑦ 登记的第 ④ 条 = 「**E3 未取得机器断言面**」:候选条数此前**只体现在日志文案里**
* (`…(候选 3 条)`),脚本无法断言、只能靠人读日志;而"候选集退化成单点"正是本线反复吃亏的
* 那类**静默失效** —— 上层看起来一切正常(连接照旧能建),只是**再也换不了址**。
*
* ## 它**不是**什么(三条边界,⛔ 改之前先读)
* 1. **只读**:只统计**已经发生**的解析结果 ⇒ ⛔ 不参与选路 / ⛔ 不写冷却 / ⛔ 不改解析入参;
* 2. **不新增暴露面**:只写一行日志 + 一个进程内快照 ⇒ ⛔ 无监听口 / ⛔ 无 HTTP 路由 / ⛔ 无文件;
* 3. **不制造网络 I/O**:周期重发只重发**上次快照**(⛔ 不重新解析 —— 观测面**不许**变成网络 I/O 源)。
*
* ## 判据锚点 = {@link CAND_OBS_PREFIX} 那一行的**固定 key 序**
* `[overlay-candidates] scope=<s> resolves=<n> count=<n> hosts=<n> source=<s> detail=<s> urls=<u|u>`
* - `count` = 候选**条数**(= E3 的**字面**判据 `count ≥ CAND_MIN`);
* - `hosts` = **主机名**个数(按 `URL#host` 去重)—— ⛔ **只作信息输出、不作判据**:
* 🔴 **它不是"独立物理路径数"** —— 本观测**不解析 DNS**(零网络),而生产上前两条候选
* `wss://alotbuy.com/dshs-relay` 与 `wss://relay-direct.alotbuy.com/dshs-relay` **摘名不同、
* 落在同一台 47**(`switcher.ts` 已实证)⇒ 真机读数 `count=3` 时 `hosts` 也报 **3**,
* 而**机器级**独立路径只有 2(47 + 106)。⇒ 这个数只用来**提示**"条数够不等于冗余够",
* "冗余建成"必须由人按机器归属判(⛔ 别拿它当独立路径数用);
* - `resolves = 0` + `source=unresolved` ⇒ **从未解析过** ⛔ 必须与"解析出 0 条"**可区分**
* (本线两处静默失效都是"分不清没装与没采到" ⇒ 判据必须能自证活性)。
*/
export const CAND_OBS_PREFIX = '[overlay-candidates]'
/**
* 观测行重发周期(ms)。**`0` ⇒ 不周期重发**(只在实际解析时写一行)。
*
* 为什么要周期重发:`failover.candidates()` **只在需要换址时**才被调用(worker 侧可能数小时不调),
* 而探针是**事后**读 ⇒ 没有周期重发就会读到一个"很久以前"的行、甚至**读不到行**
* (判据就分不清"没装"与"装了但从不解析")。
*/
export function candidateObsMs(env: Record<string, string | undefined> = process.env): number {
const raw = (env.RELAY_CAND_OBS_MS ?? '').trim()
if (raw === '') return 300_000
return /^\d+$/.test(raw) ? Number(raw) : 300_000
}
/**
* 候选里的**主机名**个数(非法 URL 不计)。⛔ 丢 scheme ⇒ `wss://h/a` 与 `https://h/b` 算同一台。
*
* 🔴 **不解析 DNS**(观测器零网络)⇒ **摘名不同但同机的候选会被算成两个** ⇒
* 本数**不是独立物理路径数**(真机实证:`alotbuy.com` 与 `relay-direct.alotbuy.com` 都在 47,
* 但 `count=3` 时 `hosts` 也报 3)。
*/
function candHostsOf(urls: readonly string[]): number {
const set = new Set<string>()
for (const u of urls) {
try {
set.add(new URL(u).host.toLowerCase())
} catch {
/* 非法项不计(不影响 count —— count 取的是解析结果长度,⛔ 不在这里再做一次过滤) */
}
}
return set.size
}
/** 一次解析的快照(只读返回,调用方改不动内部状态)。 */
export interface RelayCandidateSnapshot {
scope: string
/** 解析次数(只增;`0` = 从未解析过)。 */
resolves: number
/** 候选条数。 */
count: number
/** **主机名**个数(信息面;⛔ 不是独立物理路径数 —— 见 {@link candHostsOf})。 */
hosts: number
urls: readonly string[]
/** 来源档位(`env` / `cache` / `seed-directory` / `stale-cache` / `seed-fallback` / `none` / `unresolved`;worker 侧只看得到候选链 ⇒ `chain` / `startup`)。 */
source: string
detail: string
atMs: number
/** 从未解析过 ⇒ `true`。⛔ 必须与"解析出 0 条"(`count===0 && !unresolved`)可区分。 */
unresolved: boolean
}
/** 候选链观测器(进程内单份;两个装配点各持一个自己的 `scope`)。 */
export class RelayCandidateObservation {
private readonly scope: string
private readonly log: (line: string) => void
private readonly obsMs: number
private resolves = 0
private timer: unknown
/** 上一次**写出去**的判据形状 —— 用来做"变化才写"(巡检可能每 2 s 解析一次)。 */
private lastShape = ''
private snap: RelayCandidateSnapshot
constructor(scope: string, log: (line: string) => void, obsMs: number = candidateObsMs()) {
this.scope = scope
this.log = log
this.obsMs = obsMs
this.snap = {
scope,
resolves: 0,
count: 0,
hosts: 0,
urls: [],
source: 'unresolved',
detail: '',
atMs: 0,
unresolved: true,
}
}
/** 记账一次**真实**解析(由装配点在解析成功之后调用;⛔ 失败路径不记账 —— 那会让 `count` 说谎)。 */
record(urls: readonly string[], source: string, detail: string): RelayCandidateSnapshot {
this.resolves += 1
this.snap = {
scope: this.scope,
resolves: this.resolves,
count: urls.length,
hosts: candHostsOf(urls),
urls: [...urls],
source: source === '' ? 'chain' : source,
detail,
atMs: Date.now(),
unresolved: false,
}
/** ⚠️ **变化才写**:`RELAY_FAILOVER_CHECK_MS` 是 2 s,稳态下同一形状会被反复解析 ⇒ 不设这道门就是刷屏。 */
const shape = `${this.snap.count}|${this.snap.hosts}|${this.snap.source}|${this.snap.urls.join(',')}`
if (shape !== this.lastShape) {
this.lastShape = shape
this.log(this.line())
}
return this.snapshot()
}
snapshot(): RelayCandidateSnapshot {
return { ...this.snap, urls: [...this.snap.urls] }
}
/**
* 启动**周期重发**(幂等)。🔴 只重发上次快照 ⇒ ⛔ 零网络 I/O。
* `unref()`:观测是**后台**活动,⛔ 不许因为它把进程钉在事件循环上(本仓既有纪律)。
*/
start(): void {
if (this.timer !== undefined || this.obsMs <= 0) return
const h = setInterval(() => this.log(this.line()), this.obsMs)
;(h as { unref?: () => void }).unref?.()
this.timer = h
}
stop(): void {
if (this.timer === undefined) return
clearInterval(this.timer as ReturnType<typeof setInterval>)
this.timer = undefined
}
/** 固定 key 序的观测行;值里的空白一律换成 `_` ⇒ **每行都可被 `key=value` 直接切分**。 */
private line(): string {
const s = this.snap
const safe = (v: string): string => {
const t = String(v).replace(/\s+/g, '_')
return t === '' ? '-' : t
}
return (
`${CAND_OBS_PREFIX} scope=${safe(this.scope)} resolves=${s.resolves} count=${s.count}` +
` hosts=${s.hosts} source=${safe(s.source)} detail=${safe(s.detail)}` +
` urls=${s.urls.length === 0 ? '-' : s.urls.map(safe).join('|')}`
)
}
}
export class RelayTunnel implements WorkerTunnel {
private readonly opts: RelayTunnelOptions
private readonly forwarded = new Set<number>()
@@ -82,15 +246,31 @@ export class RelayTunnel implements WorkerTunnel {
private readonly failover: RelayFailoverSupervisor | undefined
/** 起始通道(监管器不在场时它就是唯一通道)。 */
private readonly initialChannel: TunnelChannel
/** 序㉗:候选链只读观测(`failover` 没配 ⇒ `undefined` ⇒ 不产任何观测行)。 */
private readonly candidateObs: RelayCandidateObservation | undefined
/** 序㉗:启动观测只做一次(自愈会重复调 `ensureMaster()`,重复解析无意义)。 */
private observedOnce = false
constructor(options: RelayTunnelOptions) {
this.opts = options
this.log = options.log ?? ((line: string) => process.stdout.write(`${line}\n`))
this.initialChannel = this.channelFor(this.buildClient(options.url), options.url)
if (options.failover === undefined) {
const fc = options.failover
if (fc === undefined) {
this.failover = undefined
this.candidateObs = undefined
return
}
/**
* 序㉗:候选链观测(E3 的可断言面)。**包在解析器外面** ⇒ 解析结果原样透传给监管器,
* ⛔ 不改条数 / ⛔ 不改顺序 / ⛔ 不改失败语义(抛错照旧抛给监管器,观测只在成功时记账)。
*
* ⚠️ `scope` 固定写 `worker`:本类在生产上**唯一**的装配点是 worker agent(C2),
* 而 Manager 侧(C1)的观测在 `src/web/server.ts` 里自带 `scope=manager`。
*/
const obs = new RelayCandidateObservation('worker', this.log)
obs.start()
this.candidateObs = obs
const sup = new RelayFailoverSupervisor({
/**
* **先建新、成功再关旧**:新客户端必须先真的到 `up`,本函数才返回句柄;
@@ -108,9 +288,13 @@ export class RelayTunnel implements WorkerTunnel {
}
return this.channelFor(next, url)
},
candidates: options.failover.candidates,
candidates: async () => {
const urls = await fc.candidates()
obs.record(urls, 'chain', '')
return urls
},
log: this.log,
thresholds: options.failover.thresholds,
thresholds: fc.thresholds,
})
sup.seed(this.initialChannel)
sup.start()
@@ -155,9 +339,34 @@ export class RelayTunnel implements WorkerTunnel {
/** **幂等**:已启动就只等它到 `up`(断链后 agent 的自愈走的正是这条路径)。 */
async ensureMaster(): Promise<void> {
this.client.start()
/**
* 序㉗:**非阻塞**采一次候选链观测(E3 的可断言面)。
*
* 🔴 ⛔ **不许 `await`** —— P0-2 的硬前提是"**启动不依赖网络**"(控制面自己也是客户端,
* 启动那一刻自己的门户还没 `listen`)⇒ 观测只许**搭车**,⛔ 不许把网络 I/O 塞进启动关键路径。
* ⚠️ 为什么在这里补这一枪:`failover.candidates()` 平时**只在需要换址时**才被调用
* (实测 47 的 worker 自 22:50 起 `[overlay-dir]` **0 行**)⇒ 光靠监管器的话,进程可能
* 很久都不解析一次,探针就会读到"从没解析过"。本枪保证**每次启动**必有一条观测行。
*/
if (!this.observedOnce) {
this.observedOnce = true
void this.observeCandidatesOnce()
}
await this.waitUp(this.opts.upTimeoutMs ?? 12_000)
}
/** 序㉗:一次性观测。⛔ 失败**只吞掉** —— 观测面不许变成故障源。 */
private async observeCandidatesOnce(): Promise<void> {
const obs = this.candidateObs
const fc = this.opts.failover
if (obs === undefined || fc === undefined) return
try {
obs.record(await fc.candidates(), 'startup', '')
} catch {
/* 观测失败不影响任何通道行为 */
}
}
async isMasterAlive(): Promise<boolean> {
return this.client.status().state === 'up'
}
@@ -178,6 +387,7 @@ export class RelayTunnel implements WorkerTunnel {
async close(): Promise<void> {
this.failover?.stop()
this.candidateObs?.stop()
this.client.stop()
this.forwarded.clear()
}