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

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

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

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

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

No files matched your search

+213 -3
View File
@@ -75,6 +75,170 @@ function healthOf(client: RelayClient): { state: string; attempts: number; unhea
return { state: st.state, attempts: st.attempts, unhealthyForMs: st.unhealthyForMs }
}
/**
* 候选链**只读观测**(覆盖网络 · 序㉗)—— E3「**每连接候选数 ≥ 2**」的可机器断言面。
*
* ## 为什么需要它(立项依据)
* 序㉖ §8.1-⑦ 登记的第 ④ 条 = 「**E3 未取得机器断言面**」:候选条数此前**只体现在日志文案里**
* (`…(候选 3 条)`),脚本无法断言、只能靠人读日志;而"候选集退化成单点"正是本线反复吃亏的
* 那类**静默失效** —— 上层看起来一切正常(连接照旧能建),只是**再也换不了址**。
*
* ## 它**不是**什么(三条边界,⛔ 改之前先读)
* 1. **只读**:只统计**已经发生**的解析结果 ⇒ ⛔ 不参与选路 / ⛔ 不写冷却 / ⛔ 不改解析入参;
* 2. **不新增暴露面**:只写一行日志 + 一个进程内快照 ⇒ ⛔ 无监听口 / ⛔ 无 HTTP 路由 / ⛔ 无文件;
* 3. **不制造网络 I/O**:周期重发只重发**上次快照**(⛔ 不重新解析 —— 观测面**不许**变成网络 I/O 源)。
*
* ## 判据锚点 = {@link CAND_OBS_PREFIX} 那一行的**固定 key 序**
* `[overlay-candidates] scope=<s> resolves=<n> count=<n> hosts=<n> source=<s> detail=<s> urls=<u|u>`
* - `count` = 候选**条数**(= E3 的**字面**判据 `count ≥ CAND_MIN`);
* - `hosts` = **主机名**个数(按 `URL#host` 去重)—— ⛔ **只作信息输出、不作判据**:
* 🔴 **它不是"独立物理路径数"** —— 本观测**不解析 DNS**(零网络),而生产上前两条候选
* `wss://alotbuy.com/dshs-relay` 与 `wss://relay-direct.alotbuy.com/dshs-relay` **摘名不同、
* 落在同一台 47**(`switcher.ts` 已实证)⇒ 真机读数 `count=3` 时 `hosts` 也报 **3**,
* 而**机器级**独立路径只有 2(47 + 106)。⇒ 这个数只用来**提示**"条数够不等于冗余够",
* "冗余建成"必须由人按机器归属判(⛔ 别拿它当独立路径数用);
* - `resolves = 0` + `source=unresolved` ⇒ **从未解析过** ⛔ 必须与"解析出 0 条"**可区分**
* (本线两处静默失效都是"分不清没装与没采到" ⇒ 判据必须能自证活性)。
*/
export const CAND_OBS_PREFIX = '[overlay-candidates]'
/**
* 观测行重发周期(ms)。**`0` ⇒ 不周期重发**(只在实际解析时写一行)。
*
* 为什么要周期重发:`failover.candidates()` **只在需要换址时**才被调用(worker 侧可能数小时不调),
* 而探针是**事后**读 ⇒ 没有周期重发就会读到一个"很久以前"的行、甚至**读不到行**
* (判据就分不清"没装"与"装了但从不解析")。
*/
export function candidateObsMs(env: Record<string, string | undefined> = process.env): number {
const raw = (env.RELAY_CAND_OBS_MS ?? '').trim()
if (raw === '') return 300_000
return /^\d+$/.test(raw) ? Number(raw) : 300_000
}
/**
* 候选里的**主机名**个数(非法 URL 不计)。⛔ 丢 scheme ⇒ `wss://h/a` 与 `https://h/b` 算同一台。
*
* 🔴 **不解析 DNS**(观测器零网络)⇒ **摘名不同但同机的候选会被算成两个** ⇒
* 本数**不是独立物理路径数**(真机实证:`alotbuy.com` 与 `relay-direct.alotbuy.com` 都在 47,
* 但 `count=3` 时 `hosts` 也报 3)。
*/
function candHostsOf(urls: readonly string[]): number {
const set = new Set<string>()
for (const u of urls) {
try {
set.add(new URL(u).host.toLowerCase())
} catch {
/* 非法项不计(不影响 count —— count 取的是解析结果长度,⛔ 不在这里再做一次过滤) */
}
}
return set.size
}
/** 一次解析的快照(只读返回,调用方改不动内部状态)。 */
export interface RelayCandidateSnapshot {
scope: string
/** 解析次数(只增;`0` = 从未解析过)。 */
resolves: number
/** 候选条数。 */
count: number
/** **主机名**个数(信息面;⛔ 不是独立物理路径数 —— 见 {@link candHostsOf})。 */
hosts: number
urls: readonly string[]
/** 来源档位(`env` / `cache` / `seed-directory` / `stale-cache` / `seed-fallback` / `none` / `unresolved`;worker 侧只看得到候选链 ⇒ `chain` / `startup`)。 */
source: string
detail: string
atMs: number
/** 从未解析过 ⇒ `true`。⛔ 必须与"解析出 0 条"(`count===0 && !unresolved`)可区分。 */
unresolved: boolean
}
/** 候选链观测器(进程内单份;两个装配点各持一个自己的 `scope`)。 */
export class RelayCandidateObservation {
private readonly scope: string
private readonly log: (line: string) => void
private readonly obsMs: number
private resolves = 0
private timer: unknown
/** 上一次**写出去**的判据形状 —— 用来做"变化才写"(巡检可能每 2 s 解析一次)。 */
private lastShape = ''
private snap: RelayCandidateSnapshot
constructor(scope: string, log: (line: string) => void, obsMs: number = candidateObsMs()) {
this.scope = scope
this.log = log
this.obsMs = obsMs
this.snap = {
scope,
resolves: 0,
count: 0,
hosts: 0,
urls: [],
source: 'unresolved',
detail: '',
atMs: 0,
unresolved: true,
}
}
/** 记账一次**真实**解析(由装配点在解析成功之后调用;⛔ 失败路径不记账 —— 那会让 `count` 说谎)。 */
record(urls: readonly string[], source: string, detail: string): RelayCandidateSnapshot {
this.resolves += 1
this.snap = {
scope: this.scope,
resolves: this.resolves,
count: urls.length,
hosts: candHostsOf(urls),
urls: [...urls],
source: source === '' ? 'chain' : source,
detail,
atMs: Date.now(),
unresolved: false,
}
/** ⚠️ **变化才写**:`RELAY_FAILOVER_CHECK_MS` 是 2 s,稳态下同一形状会被反复解析 ⇒ 不设这道门就是刷屏。 */
const shape = `${this.snap.count}|${this.snap.hosts}|${this.snap.source}|${this.snap.urls.join(',')}`
if (shape !== this.lastShape) {
this.lastShape = shape
this.log(this.line())
}
return this.snapshot()
}
snapshot(): RelayCandidateSnapshot {
return { ...this.snap, urls: [...this.snap.urls] }
}
/**
* 启动**周期重发**(幂等)。🔴 只重发上次快照 ⇒ ⛔ 零网络 I/O。
* `unref()`:观测是**后台**活动,⛔ 不许因为它把进程钉在事件循环上(本仓既有纪律)。
*/
start(): void {
if (this.timer !== undefined || this.obsMs <= 0) return
const h = setInterval(() => this.log(this.line()), this.obsMs)
;(h as { unref?: () => void }).unref?.()
this.timer = h
}
stop(): void {
if (this.timer === undefined) return
clearInterval(this.timer as ReturnType<typeof setInterval>)
this.timer = undefined
}
/** 固定 key 序的观测行;值里的空白一律换成 `_` ⇒ **每行都可被 `key=value` 直接切分**。 */
private line(): string {
const s = this.snap
const safe = (v: string): string => {
const t = String(v).replace(/\s+/g, '_')
return t === '' ? '-' : t
}
return (
`${CAND_OBS_PREFIX} scope=${safe(this.scope)} resolves=${s.resolves} count=${s.count}` +
` hosts=${s.hosts} source=${safe(s.source)} detail=${safe(s.detail)}` +
` urls=${s.urls.length === 0 ? '-' : s.urls.map(safe).join('|')}`
)
}
}
export class RelayTunnel implements WorkerTunnel {
private readonly opts: RelayTunnelOptions
private readonly forwarded = new Set<number>()
@@ -82,15 +246,31 @@ export class RelayTunnel implements WorkerTunnel {
private readonly failover: RelayFailoverSupervisor | undefined
/** 起始通道(监管器不在场时它就是唯一通道)。 */
private readonly initialChannel: TunnelChannel
/** 序㉗:候选链只读观测(`failover` 没配 ⇒ `undefined` ⇒ 不产任何观测行)。 */
private readonly candidateObs: RelayCandidateObservation | undefined
/** 序㉗:启动观测只做一次(自愈会重复调 `ensureMaster()`,重复解析无意义)。 */
private observedOnce = false
constructor(options: RelayTunnelOptions) {
this.opts = options
this.log = options.log ?? ((line: string) => process.stdout.write(`${line}\n`))
this.initialChannel = this.channelFor(this.buildClient(options.url), options.url)
if (options.failover === undefined) {
const fc = options.failover
if (fc === undefined) {
this.failover = undefined
this.candidateObs = undefined
return
}
/**
* 序㉗:候选链观测(E3 的可断言面)。**包在解析器外面** ⇒ 解析结果原样透传给监管器,
* ⛔ 不改条数 / ⛔ 不改顺序 / ⛔ 不改失败语义(抛错照旧抛给监管器,观测只在成功时记账)。
*
* ⚠️ `scope` 固定写 `worker`:本类在生产上**唯一**的装配点是 worker agent(C2),
* 而 Manager 侧(C1)的观测在 `src/web/server.ts` 里自带 `scope=manager`。
*/
const obs = new RelayCandidateObservation('worker', this.log)
obs.start()
this.candidateObs = obs
const sup = new RelayFailoverSupervisor({
/**
* **先建新、成功再关旧**:新客户端必须先真的到 `up`,本函数才返回句柄;
@@ -108,9 +288,13 @@ export class RelayTunnel implements WorkerTunnel {
}
return this.channelFor(next, url)
},
candidates: options.failover.candidates,
candidates: async () => {
const urls = await fc.candidates()
obs.record(urls, 'chain', '')
return urls
},
log: this.log,
thresholds: options.failover.thresholds,
thresholds: fc.thresholds,
})
sup.seed(this.initialChannel)
sup.start()
@@ -155,9 +339,34 @@ export class RelayTunnel implements WorkerTunnel {
/** **幂等**:已启动就只等它到 `up`(断链后 agent 的自愈走的正是这条路径)。 */
async ensureMaster(): Promise<void> {
this.client.start()
/**
* 序㉗:**非阻塞**采一次候选链观测(E3 的可断言面)。
*
* 🔴 ⛔ **不许 `await`** —— P0-2 的硬前提是"**启动不依赖网络**"(控制面自己也是客户端,
* 启动那一刻自己的门户还没 `listen`)⇒ 观测只许**搭车**,⛔ 不许把网络 I/O 塞进启动关键路径。
* ⚠️ 为什么在这里补这一枪:`failover.candidates()` 平时**只在需要换址时**才被调用
* (实测 47 的 worker 自 22:50 起 `[overlay-dir]` **0 行**)⇒ 光靠监管器的话,进程可能
* 很久都不解析一次,探针就会读到"从没解析过"。本枪保证**每次启动**必有一条观测行。
*/
if (!this.observedOnce) {
this.observedOnce = true
void this.observeCandidatesOnce()
}
await this.waitUp(this.opts.upTimeoutMs ?? 12_000)
}
/** 序㉗:一次性观测。⛔ 失败**只吞掉** —— 观测面不许变成故障源。 */
private async observeCandidatesOnce(): Promise<void> {
const obs = this.candidateObs
const fc = this.opts.failover
if (obs === undefined || fc === undefined) return
try {
obs.record(await fc.candidates(), 'startup', '')
} catch {
/* 观测失败不影响任何通道行为 */
}
}
async isMasterAlive(): Promise<boolean> {
return this.client.status().state === 'up'
}
@@ -178,6 +387,7 @@ export class RelayTunnel implements WorkerTunnel {
async close(): Promise<void> {
this.failover?.stop()
this.candidateObs?.stop()
this.client.stop()
this.forwarded.clear()
}