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

+13
View File
@@ -249,7 +249,20 @@ export class LeasedSpawner implements Spawner {
await this.lease.release(userId)
}
/**
* 覆盖网络线 序 ㉘ → 单 A(候选 `B`):**把"停心跳"与"停实例"两件事拆开**。
*
* ① `stopHeartbeat()` **保留** —— 进程要走了就不该再续租;租约按 `DSHS_CLUSTER_LEASE_TTL_MS`
* (47 实测 30000 ms)自然过期,这是「进程不在就别再续租」的正确语义。
* ② `inner.teardown()` **保留** —— 它只是转发,`inner` = `RemoteSpawner` ⇒ 已是 no-op。
*
* ⛔ **严禁**在本函数里对 worker 下发停止(今天没有,将来也不许);停止实例的正路是
* `Spawner.stop(userId)`(路由层在用户**显式**停实例时调用),⛔ 不是退出路径。
* ⚠️ 副作用(如实记账):心跳停 ⇒ 租约过期 ⇒ **归属记录会与"仍在跑的实例"不一致**;
* 这是候选 `B` 的真实新增风险,靠 `cleanStaleScopes(uid)` + 认领探活兜住(本单 §7.4 lease 行)。
*/
async teardown(): Promise<void> {
// ⛔ 退出不停实例(guard: teardown-must-not-stop-instances)—— 只停心跳,实例留给下一个进程
this.stopHeartbeat()
await this.inner.teardown()
}
+337 -5
View File
@@ -22,6 +22,7 @@ import {
realpathSync,
writeFileSync,
} from 'node:fs'
import { connect } from 'node:net'
import { dirname, join } from 'node:path'
import type { ServerConfig } from '../config.js'
import { handoffPath, homeRoot, userRoot, workspaceRoot } from '../fs/workspace.js'
@@ -174,6 +175,139 @@ function mountParentDirArgs(dest: string, stopAt: string): string[] {
* Local backend: owns the lifecycle of per-user DSH process pairs via
* child_process. State is in-memory. Implements {@link Spawner}.
*/
/* ─────────────────────────────────────────────────────────────────────────────
* 覆盖网络线 序 ㉕:「逐步拉起」= 启动时**不再一刀切清空**既有实例 scope。
*
* 背景(档案 30 的历史):实例真实生命周期在 OS 层(systemd scope),编排器只靠
* 内存 map 追踪 ⇒ 重启后 map 空、旧 scope 成孤儿 ⇒ 当时的修法是「启动即统一清掉」。
* 但那条修法有两个副作用:① **所有**实例在 Manager 启动瞬间被同时杀掉(N 个一起 = 启动
* 风暴)② 空闲期实例也不保。本序把它换成「**扫描 → 认领 → 逐个错峰探活**」。
*
* 🔴 一条必须先说的**客观边界**(本序实测得出,⛔ 别再试图绕过):
* 「认领」**不可能**做到"用户无感直接复用" —— 因为 `Instance.launchToken` 是 dsh web
* **启动时在 stdout 打印一次**的一次性凭据(`/dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=/`),
* 既**不落盘**、也无法在运行期重新取出(实测:无 token 直连实例回 **401**)。而恢复它的两条
* 路都被红线封死:改官方 dsh 取 token(**R2**)✗;把 token 落盘成可读凭据(**R11** 安全维度净变差)✗。
* ⇒ 故 `enter` 在"实例 alive 但无 token"时只能拿 503(`routes/dsh.ts` 的既有语义,⛔ 未改)。
* ⇒ 本序交付的语义 = **「不批量清空、错峰保留、访问时自然替换」**:重启后既有 scope **不被杀**,
* 按节流逐个探活登记;用户访问时由 `spawnInstance` 既有的 `cleanStaleScopes(uid)` **自然替换**
* (换端口换 token,与旧行为等价但**错峰**、且空闲期实例不死)。⛔ 未新增任何凭据落盘。
* ───────────────────────────────────────────────────────────────────────────── */
/** 从 scope 的 `Description`(= `systemd-run` 记录的完整 argv)恢复出的实例三要素。 */
export interface AdoptedScopeInfo {
/** 平台侧用户 id —— 从 `--chdir <dataRoot>/users/<userId>/…` 反解。 */
userId: string
role: InstanceRole
/** 仅 `role === 'main'` 有;watchdog 走 headless、无监听端口。 */
port?: number
/** 实例 cwd(= `spawnInstance` 传给 `spawnAsUser` 的 `folder`)。 */
folder: string
}
/** 单个既有 scope 的处置决定(⛔ 纯数据,便于单测与先红后绿)。 */
export type ScopeAction =
| { kind: 'adopt' }
| { kind: 'stop'; reason: string }
/**
* 解析 `systemctl show -p Description` 的内容。
*
* ⛔ **纯函数、零 IO、零副作用**;**任何**一处不自洽一律回 `undefined` ⇒ 调用方按
* **旧行为 stop**(保守:宁可清掉,也不让一个解析错半截的实例留在系统里)。
*
* 自洽校验(三道,缺一即拒):
* ① 必须能解出 `--profile web|headless`(决定 main / watchdog,⛔ 猜不得)
* ② 必须能解出 `--chdir <abs>` 且其中含 `/users/<userId>/` 段(拿 userId)
* ③ argv 里的 `--reuid <n>` 必须**等于** scope 名里的 uid(交叉验证;本序实测 106 实例为
* `dsh-100002-ef8d1d12.scope` + `--reuid 100002` ⇒ 两者必然同源)
*/
export function parseScopeDescription(desc: string, uidFromName: number): AdoptedScopeInfo | undefined {
const tokens = desc.split(/\s+/).filter((t) => t !== '')
const valueOf = (flag: string): string | undefined => {
const i = tokens.indexOf(flag)
return i >= 0 ? tokens[i + 1] : undefined
}
// ① role
const profile = valueOf('--profile')
if (profile !== 'web' && profile !== 'headless') return undefined
const role: InstanceRole = profile === 'web' ? 'main' : 'watchdog'
// ③ uid 交叉校验
const reuid = valueOf('--reuid')
if (reuid === undefined || reuid !== String(uidFromName)) return undefined
// ② folder + userId
const folder = valueOf('--chdir')
if (folder === undefined || !folder.startsWith('/')) return undefined
const marker = '/users/'
const at = folder.indexOf(marker)
if (at < 0) return undefined
const rest = folder.slice(at + marker.length)
const slash = rest.indexOf('/')
const userId = slash < 0 ? rest : rest.slice(0, slash)
if (userId === '' || userId.includes(' ')) return undefined
// main 必须解出端口;watchdog 不该有端口(有 ⇒ 不自洽)
const portRaw = valueOf('--port')
if (role === 'main') {
if (portRaw === undefined || !/^\d+$/.test(portRaw)) return undefined
const port = Number(portRaw)
if (!Number.isInteger(port) || port <= 0 || port > 65535) return undefined
return { userId, role, port, folder }
}
if (portRaw !== undefined) return undefined
return { userId, role, folder }
}
/**
* 单个 scope 该「认领」还是「按旧行为停掉」。
*
* @param info 解析结果;`undefined` = 解析失败 ⇒ **停**
* @param dupUid 同一 uid 名下出现多个 scope(本平台不可能产生,= 异常/旧 bug 残留)
* ⇒ **全部停**(档案 30 的风险本体:多实例共 profile 写冲突)
*
* 判 `stop` 的四种情形(⛔ 一个都别放宽):
* ① `dup-uid` —— 同 uid 多 scope;
* ② `unparsable` —— 端口 / 用户 / uid 任一解不出(半截信息认领 = 后续替换时定位错实例);
* ③ `no-probe-target` —— watchdog:一次性 headless 任务、无监听端口 ⇒ **无法确认健康**
* 且留着无收益(它正常应当很快自己退出);
* ④ role=main 却无端口 —— 由 ② 一并覆盖(`parseScopeDescription` 直接拒)。
*/
export function decideScopeAction(info: AdoptedScopeInfo | undefined, dupUid: boolean): ScopeAction {
if (dupUid) return { kind: 'stop', reason: 'dup-uid' }
if (info === undefined) return { kind: 'stop', reason: 'unparsable' }
if (info.role !== 'main') return { kind: 'stop', reason: 'no-probe-target' }
return { kind: 'adopt' }
}
/** scope 名 → uid。仅接受本平台自己产生的形态(`dsh-<uid>-<8hex>.scope`)。 */
export function parseScopeUnitName(name: string): number | undefined {
const m = /^dsh-(\d+)-[0-9a-f]+\.scope$/.exec(name)
if (m === null || m[1] === undefined) return undefined
const uid = Number(m[1])
return Number.isInteger(uid) && uid > 0 ? uid : undefined
}
/** 已被「认领」的既有 scope —— ⛔ 刻意**不进** `mains`:见文件头 序 ㉕ 的边界说明。 */
interface AdoptedScope {
unit: string
uid: number
info: AdoptedScopeInfo
adoptedAt: number
/** 探活结果:`undefined` = 未探;`true`/`false` = 结果(失败即按旧行为停)。 */
alive?: boolean
}
/** 认领 / 回收的**计数面**(判别器必须落计数,⛔ 不许只写日志)—— 供探针与演练断言。 */
export interface RehydrateReport {
scanned: number
adopted: number
stopped: number
probeOk: number
probeFail: number
retained: number
notes: string[]
}
export class LocalSpawner implements Spawner {
private readonly mains = new Map<string, Instance>()
private readonly watchdogs = new Map<string, Instance>()
@@ -196,6 +330,18 @@ export class LocalSpawner implements Spawner {
private readonly reapTimer: NodeJS.Timeout | undefined
private readonly portGuard: PortGuard | undefined
/** 序 ㉕:认领到的既有实例 scope(⛔ 刻意不进 `mains`,理由见文件头)。key = unit 名。 */
private readonly adopted = new Map<string, AdoptedScope>()
/** 序 ㉕:认领 / 回收的计数面(判别器必须落计数)。 */
private readonly rehydrate: RehydrateReport = {
scanned: 0,
adopted: 0,
stopped: 0,
probeOk: 0,
probeFail: 0,
retained: 0,
notes: [],
}
constructor(
private readonly config: ServerConfig,
@@ -205,8 +351,9 @@ export class LocalSpawner implements Spawner {
private readonly resolveUid: (userId: string) => Promise<number>,
) {
this.portGuard = createPortGuard(config.portGuard)
// 档案 30:portal 启动即清掉遗留实例 scope(重启后无法接管)。
this.cleanAllStaleScopes()
// 覆盖网络线 序 ㉕:原为「档案 30:portal 启动即清掉遗留实例 scope(重启后无法接管)」。
// 现改为「扫描 → 认领 → 逐个错峰探活」—— 见文件头 序 ㉕ 的完整说明与那条客观边界。
this.rehydrateAdoptedScopes()
// Local-mode idle reap: periodically stop mains that are idle past the TTL,
// then cap the resident count (LRU by last activity). Only armed when at
// least one of the two rules is enabled. The timer is unref'd so it never
@@ -453,10 +600,28 @@ export class LocalSpawner implements Spawner {
this.resetCrashState(userId)
}
/** Stop every tracked process on shutdown. */
/**
* 覆盖网络线 序 ㉘ → 单 A(候选 `B`):**退出路径不再停任何实例**。
*
* 改前语义 = 清 `reapTimer` + 逐个 `stop(userId)`(把在册实例全杀掉)⇒ 进程一重启,实例
* scope 随主进程一起消失 ⇒ 启动认领 `rehydrateAdoptedScopes()` 永远扫不到存量
* ⇒ 「Manager 重启后逐步拉起既有实例」**不可能成立**(序 ㉕ 已实测的真凶)。
*
* 改后:**只停本进程自己的定时器**,实例留给下一个进程。回收责任移交给下面三条:
* ① `rehydrateAdoptedScopes()` —— 启动时扫 OS 层既有 scope ⇒ 探活 ⇒ 活的认领 / **端口不通**的停掉;
* ② `cleanStaleScopes(uid)` —— 用户访问 / spawn 前清同 uid(**档案 30 本体**,⛔ 不许删);
* ③ idle-reap —— 缺省 60 s 间隔 / TTL 7 天 / 每 host 上限 4(`src/config.ts:300-302`)。
*
* ⛔ **不许**在退出路径里停实例、也**不许**向远端下发停止 —— 停止实例的正路是 `stop(userId)`
* (由路由层在用户**显式**停实例时调用),⛔ 不是退出路径。机器断言见
* `test/orchestrator-teardown.test.mjs` + 探针 `OBS-22`。
* ⚠️ 边界(如实):`launchToken` 不可恢复 ⇒ 重启后 `enter` 仍可能 503,存量实例要在用户**下次访问**
* 时被 `cleanStaleScopes` 自然替换。收益 = 「不被杀 + 访问时自然替换」,⛔ **不是**「重启后直接可用」。
* 🔙 回滚 = 把下面那行循环加回来 ⇒ 秒级(本单 §6 路 A / 路 B)。
*/
async teardown(): Promise<void> {
// ⛔ 退出不停实例(guard: teardown-must-not-stop-instances)
if (this.reapTimer !== undefined) clearInterval(this.reapTimer)
for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId)
}
/** No-op: local mode has no sidecar — the control plane owns the volume. */
@@ -1215,6 +1380,171 @@ export class LocalSpawner implements Spawner {
this.restartTimers.set(userId, timer)
}
/* ── 序 ㉕:既有实例 scope 的「扫描 → 认领 → 错峰探活」 ──────────────────────
*
* ⛔ 三条自我约束(违反即等于放大档案 30 的风险):
* ① 只在 `isolationMode === 'account'` 下认领 —— 其它形态根本不产生 scope,
* 此时**保持旧行为**(走 `cleanAllStaleScopes()`,实际是空操作)。
* ② 解析不出 / 同 uid 重复 / watchdog / 探活不通 ⇒ **一律按旧行为停掉**。
* ③ 认领**只登记 + 探活**:⛔ 不 stop、⛔ 不 spawn、⛔ 不接管道、⛔ 不落任何凭据。
*
* 🔑 「孤儿」的判据 = **端口不通**,不是「启动了却不认识」:
* 端口在听 ⇒ 它是**有效实例**(留着 = 与重启前稳态一致,用户/后台任务零中断);
* 端口不通 ⇒ 才是档案 30 说的孤儿 ⇒ 按旧行为停掉。
* 而"双实例共 profile"那一半由 `cleanStaleScopes(uid)`(spawn 前清同 uid)继续兜住 —— **本序未动**。
*/
/** 节流间隔:逐条认领之间的最小时间差(⛔ 不落生产 env,只读进程环境取默认)。 */
private rehydrateStaggerMs(): number {
const n = Number(process.env.DSHS_REHYDRATE_STAGGER_MS ?? '500')
return Number.isFinite(n) && n >= 0 ? n : 500
}
/** 单条探活超时。 */
private rehydrateProbeMs(): number {
const n = Number(process.env.DSHS_REHYDRATE_PROBE_MS ?? '2000')
return Number.isFinite(n) && n > 0 ? n : 2000
}
/** 认领计数快照(供演练 / 探针断言,⛔ 只读)。 */
rehydrateReport(): RehydrateReport {
return { ...this.rehydrate, notes: [...this.rehydrate.notes] }
}
private rehydrateAdoptedScopes(): void {
// ① 非 account 形态不产生 scope ⇒ 保持旧语义(此处是空操作)。
if (this.config.isolationMode !== 'account') {
this.cleanAllStaleScopes()
return
}
const found = this.scanExistingScopes()
this.rehydrate.scanned = found.length
if (found.length === 0) {
process.stderr.write('[rehydrate] 无既有实例 scope ⇒ 不动作(与旧行为等价)\n')
return
}
const perUid = new Map<number, number>()
for (const s of found) perUid.set(s.uid, (perUid.get(s.uid) ?? 0) + 1)
const stagger = this.rehydrateStaggerMs()
const schedule = (i: number): void => {
if (i >= found.length) {
process.stderr.write(`[rehydrate] summary ${JSON.stringify(this.rehydrateReport())}\n`)
return
}
const t = setTimeout(() => {
const s = found[i]
if (s !== undefined) this.adoptOne(s, (perUid.get(s.uid) ?? 0) > 1)
schedule(i + 1)
}, stagger)
t.unref()
}
schedule(0)
}
private scanExistingScopes(): { unit: string; uid: number; desc: string }[] {
const out: { unit: string; uid: number; desc: string }[] = []
let listing: string
try {
listing = execFileSync('systemctl', ['list-units', '--type=scope', '--no-legend', '--plain'], {
encoding: 'utf8',
timeout: 10000,
})
} catch {
// ⚠️ 列不出来 ⇒ **不杀任何东西**(与旧行为一致:旧代码的 catch 同样吞掉、不清不杀)
process.stderr.write('[rehydrate] ⚠️ systemctl list-units 失败 ⇒ 本次不认领、不清理\n')
return out
}
for (const line of listing.split('\n')) {
const name = line.trim().split(/\s+/)[0]
if (name === undefined || name === '') continue
const uid = parseScopeUnitName(name)
if (uid === undefined) continue
out.push({ unit: name, uid, desc: this.scopeDescription(name) })
}
return out
}
/** 取 scope 的 `Description`(= `systemd-run` 记录的 argv);取不到 ⇒ 空串 ⇒ 解析必失败 ⇒ 停。 */
private scopeDescription(unit: string): string {
try {
return execFileSync('systemctl', ['show', unit, '-p', 'Description', '--value'], {
encoding: 'utf8',
timeout: 10000,
}).trim()
} catch {
return ''
}
}
private adoptOne(s: { unit: string; uid: number; desc: string }, dupUid: boolean): void {
const info = parseScopeDescription(s.desc, s.uid)
const action = decideScopeAction(info, dupUid)
if (action.kind === 'stop') {
this.rehydrate.stopped += 1
const note = `stop ${s.unit} (${action.reason})`
this.rehydrate.notes.push(note)
process.stderr.write(`[rehydrate] ⛔ ${note}\n`)
this.stopUnit(s.unit)
return
}
const adopted: AdoptedScopeInfo = info as AdoptedScopeInfo
const rec: AdoptedScope = { unit: s.unit, uid: s.uid, info: adopted, adoptedAt: Date.now() }
this.adopted.set(s.unit, rec)
this.rehydrate.adopted += 1
process.stderr.write(
`[rehydrate] adopted ${s.unit} uid=${s.uid} role=${adopted.role} port=${adopted.port ?? '-'} user=${adopted.userId}\n`,
)
this.probeAdopted(rec)
}
/** TCP 探活:端口在听 ⇒ 保留(有效实例);连不上 ⇒ 按旧行为停掉(孤儿)。 */
private probeAdopted(rec: AdoptedScope): void {
const port = rec.info.port
if (port === undefined) {
// 不应发生(main 必带端口);保守停掉。
this.rehydrate.stopped += 1
this.adopted.delete(rec.unit)
this.stopUnit(rec.unit)
return
}
let settled = false
const sock = connect({ host: '127.0.0.1', port })
const done = (ok: boolean): void => {
if (settled) return
settled = true
try {
sock.destroy()
} catch {
/* ignore */
}
rec.alive = ok
if (ok) {
this.rehydrate.probeOk += 1
process.stderr.write(`[rehydrate] probe OK ${rec.unit} :${port}\n`)
return
}
this.rehydrate.probeFail += 1
const note = `probe-fail ${rec.unit} :${port}`
this.rehydrate.notes.push(note)
process.stderr.write(`[rehydrate] ⛔ ${note} ⇒ 判孤儿,按旧行为停掉\n`)
this.rehydrate.stopped += 1
this.adopted.delete(rec.unit)
this.stopUnit(rec.unit)
}
sock.setTimeout(this.rehydrateProbeMs(), () => done(false))
sock.once('error', () => done(false))
sock.once('connect', () => done(true))
}
/** 停一个 unit(与既有清理同款:失败**静默跳过**,⛔ 不抛)。 */
private stopUnit(unit: string): void {
try {
execFileSync('systemctl', ['stop', unit], { timeout: 10000 })
} catch {
/* ignore */
}
}
/** 档案 30:清掉指定 uid 名下的残留 systemd scope(孤儿)。严格前缀匹配,不误伤门户自身。 */
private cleanStaleScopes(uid: number): void {
this.stopScopesByPrefix(`dsh-${uid}-`)
@@ -1234,7 +1564,9 @@ export class LocalSpawner implements Spawner {
if (name === undefined || name === '') continue
if (!name.startsWith(prefix) || !name.endsWith('.scope')) continue
if (re !== undefined && !re.test(name)) continue
try { execFileSync('systemctl', ['stop', name], { timeout: 10000 }) } catch { /* ignore */ }
this.stopUnit(name)
// 序 ㉕:被清掉的 unit 若在认领表里,同步摘掉(避免留下陈旧记录)
this.adopted.delete(name)
}
} catch { /* list-units 失败:跳过 */ }
}
+9
View File
@@ -326,7 +326,16 @@ export class RemoteSpawner implements Spawner {
await this.call(host, 'POST', '/stop', { userId }, randomUUID())
}
/**
* 覆盖网络线 序 ㉘ → 单 A(候选 `B`):**取证已是 no-op**(`git show HEAD:` 与工作区逐字相同)
* ⇒ 本单**不做语义改动**,只把「退出不停实例」这条约束**固化**成可被机器断言的守卫标记
* (防将来被改回去 ⇒ "同一语义三份实现"里最容易被顺手破坏的一份)。
*
* ⛔ 本函数体里**永远不许**出现向远端下发停止的调用(`test/orchestrator-teardown.test.mjs`
* 有动态断言 + 静态 grep 断言)。
*/
async teardown(): Promise<void> {
// ⛔ 退出不停远端实例(guard: teardown-must-not-stop-instances)
// 远端实例的寿命长于任何单个 Manager 副本 ⇒ 由 Manager 的归属/租约管理,不在关闭时清。
}