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

+93
View File
@@ -36,6 +36,8 @@ import {
signDirectory,
waitUpOnStatus,
} from '../lib/net/relay/index.js'
/** 序㉗:候选链观测(`OBS-21` 的判据锚点 —— 行格式一变,探针就会**静默取不到值**)。 */
import { CAND_OBS_PREFIX, RelayCandidateObservation, candidateObsMs } from '../lib/worker/relay-tunnel.js'
/* ─────────── 搭台工具 ─────────── */
@@ -842,3 +844,94 @@ test('F20 burst 窗口(计划内重启)内必须继续等,窗口过后才
assert.equal(ok, true, 'burst 窗口内必须继续等 ⇒ 对端重启完就 up')
assert.ok(ms >= 1_300, `⛔ 不许在窗口内就判死(旧坑:100ms 处就返回 false);实测 ${ms}ms`)
})
/* ─────────── 序㉗:候选链观测(`OBS-21`「每连接候选数 ≥ 2」的判据锚点) ─────────── */
/**
* O1 · **观测行的固定 key 序就是探针的判据锚点** ⇒ 逐字锁住。
*
* 🔴 为什么这条最要紧:探针 `OBS-21` 是按 `key=value` **按名取值**的 ⇒ 谁把键改名 / 把值里的
* 空白留在行里 / 少写一个键,探针会**静默取不到**(本线最贵的一类失效:不是报错,是"看不见")。
*/
test('O1 观测行格式锁定:固定 key 序 + count 为条数 + hosts 按主机去重(丢 scheme)', () => {
const lines = []
const obs = new RelayCandidateObservation('manager', (l) => lines.push(l), 0)
obs.record(
[
'wss://alotbuy.com/dshs-relay',
'https://alotbuy.com/other',
'wss://106.54.21.172/dshs-relay',
],
'cache',
'/var/lib/dshs/overlay/directory.json',
)
assert.equal(lines.length, 1, '一次 record 写一行')
const line = lines[0]
assert.ok(line.startsWith(CAND_OBS_PREFIX), `必须以固定前缀开头:${line}`)
const keys = [...line.matchAll(/(?:^|\s)([a-z]+)=/g)].map((m) => m[1])
assert.deepEqual(
keys,
['scope', 'resolves', 'count', 'hosts', 'source', 'detail', 'urls'],
'固定 key 序 = 契约(⛔ 改它 = 破坏探针判据)',
)
const snap = obs.snapshot()
assert.equal(snap.count, 3, 'count = 候选**条数**(⛔ 不按主机去重)')
assert.equal(snap.hosts, 2, 'hosts = 独立主机数(alotbuy.com 的两条算同一台 —— scheme 不参与)')
assert.equal(snap.source, 'cache')
assert.equal(snap.resolves, 1)
assert.equal(snap.unresolved, false)
})
/**
* O2 · **稳态不刷屏**:`RELAY_FAILOVER_CHECK_MS` 是 2 s,同形状会被反复解析 ⇒ 变化才写。
* ⚠️ 但解析次数必须**照实累计**(探针拿 `resolves` 判"这个进程到底解析过没有")。
*/
test('O2 同形状重复 record ⛔ 不重复写行(防刷屏),形状一变立刻写', () => {
const lines = []
const obs = new RelayCandidateObservation('worker', (l) => lines.push(l), 0)
const urls = ['wss://a.example/dshs-relay']
obs.record(urls, 'chain', '')
obs.record(urls, 'chain', '')
obs.record(urls, 'chain', '')
assert.equal(lines.length, 1, '稳态巡检 ⛔ 不许刷屏')
assert.equal(obs.snapshot().resolves, 3, '解析次数必须照实累计')
obs.record(['wss://a.example/dshs-relay', 'wss://b.example/dshs-relay'], 'chain', '')
assert.equal(lines.length, 2, '条数变了(候选集变化)⇒ 必须立刻写')
assert.equal(obs.snapshot().count, 2)
})
/**
* O3 · 🔴 **「从未解析」与「解析出 0 条」必须可区分**(本线两处静默失效都栽在这一点),
* 且**周期重发在零网络下也能写出行**(探针是**事后**读,没有它就可能读不到行)。
*/
test('O3 「从未解析」≠「解析出 0 条」+ 周期重发零网络写行 + stop 后不再写', async () => {
const lines = []
const obs = new RelayCandidateObservation('worker', (l) => lines.push(l), 5)
const s0 = obs.snapshot()
assert.equal(s0.unresolved, true, '没解析过 ⇒ unresolved')
assert.equal(s0.source, 'unresolved')
assert.equal(s0.resolves, 0)
obs.start()
await new Promise((r) => setTimeout(r, 40))
obs.stop()
assert.ok(lines.length >= 2, `周期重发必须写出行(实测 ${lines.length} 行)`)
assert.ok(
lines.every((l) => l.includes('source=unresolved')),
'未解析时重发的行也必须**诚实**写 unresolved(⛔ 不许假装 0 条 = 已解析)',
)
const n = lines.length
await new Promise((r) => setTimeout(r, 30))
assert.equal(lines.length, n, 'stop() 后 ⛔ 不许再写')
obs.record([], 'none', 'none')
assert.equal(obs.snapshot().unresolved, false, '解析过就是解析过 —— 哪怕解析出 0 条')
assert.equal(obs.snapshot().count, 0)
assert.equal(obs.snapshot().hosts, 0)
})
/** O4 · 重发周期取自 env(与 `switcher.ts#relayFailoverThresholds` 同纪律:值格必须纯数字)。 */
test('O4 重发周期取自 env,`0` = 关闭,非纯数字 ⇒ 回退默认(不静默变成 NaN)', () => {
assert.equal(candidateObsMs({}), 300_000, '缺省 300 s')
assert.equal(candidateObsMs({ RELAY_CAND_OBS_MS: '0' }), 0, '0 = 关闭周期重发')
assert.equal(candidateObsMs({ RELAY_CAND_OBS_MS: '60000' }), 60_000, '显式覆写生效')
assert.equal(candidateObsMs({ RELAY_CAND_OBS_MS: '6e4' }), 300_000, '非纯数字 ⇒ 回退默认')
})