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:
1 parent
04776af4b1
commit
09ce76f3af
38 files changed
+9133
-211
No files matched your search
+371
-2
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
+160
-3
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in new issue
Block a user