feat(overlay): 覆盖网络线 序㊾ —— 探针观测面改「两台中继并集」(附 序㊽ 源码/文档补提交)

序㊾(本棒):
- scripts/overlay-probe.cjs:OBS-01 / OBS-08 / OBS-09 的数据源由「只读 47 中继」
  改为「按两台中继取并集」,消除 worker 归属漂移时的假红 / 假 SKIP
  · endpoints 以 network:hostId:port 为键合并,online 取「或」、localPort 取在线那一侧
  · used 按 network/hostId 去重计数(不求和,避免凭空放大在册数)
  · localPort 属中继机回环落点 ⇒ 按归属分机探活(106 侧落点由 106 机上探)
  · derived(OBS-11)保持 47 视角;阈值与判据一律未放宽
  · OBS-16 计数约束:对 47 /status 的读取仍为三次、Δ 只取 47 的 counters;
    对端 106 的采样为独立一次,落在第三次采样之后,不进 (status2, status3] 门窗口
  · 新增 --peer-status-fixture(并集的对端那一半)与「并集不可取证」强制留痕
- 交接单《覆盖网络-序45-低熵块治理-测熵与实现》§16 全节(§8 前前缀逐字未变)
- 参数表 §11.16 补记(§10 现算指纹未变,值格未动)

附(前几棒已完成并已部署、但尚未入仓的源码 / 文档):
- src/net/relay/content/*.ts、src/net/relay/index.ts、main.ts:块级寻址 C 域分离
- src/supervisor/orchestrator.ts、src/worker/agent.ts:日志采集与巡检(方案 C)
- test/overlay-content.test.mjs:随附用例(npm test = 200 pass / 0 fail / 1 skipped,Node 22)
- scripts/dshlog.mjs(跨机日志取证)、scripts/overlay-entropy.cjs(熵探针)
- dsh-server-docs/04-调整方案/129、133;INDEX.md / docs-manifest.json / 交接单 README 登记
This commit is contained in:
admin committed 2026-09-19 05:35:35 +08:00
1 parent 45b4999d24
commit d2ef362a98
20 files changed
+2998 -183

No files matched your search

+62 -11
View File
@@ -36,15 +36,24 @@
* ⚠️ 唯一例外是**解密实现**本身(`crypto.decodeBlock`)——它是**一处实现、两个调用位**
* (`source.ts` 链的统一返回点 / 本模块的重组位),同一批字节**只过其中一处**。
*
* ## 🆕 序㊻ · C(域分离):**per-network keyed hash**
* 块 id / 内容 id 从"裸哈希"升级为 **`HMAC-SHA256(netKey, bytes)`**,输出仍取前
* `BLOCK_ID_HEX_LEN` = 32 hex。`netKey` 是**域密钥**(由 `crypto.ts` 从组密钥按 network
* 维度派生),经与 `encode` / `decode` **同一个注入通道**(`ChunkTransforms.netKey`)传进来。
* - **治**:跨 network 的 COF / LRI(同一块 id 在 A / B 两个网同时出现 ⇒ 推出跨租户相关性)。
* - ⛔ **不治**:同一 network 内部持钥者枚举(那是 `04-133 §3.2` 写死的口径)。
* - 🔴 **缺省不传 / 传空 ⇒ 回落裸 `sha256`** —— 这既是**回滚路径**,也是"既有单测语义不变"的保证。
*
* ## ⛔ 本模块**不做**的事(故意)
* - 不做 IO、不读文件、不网络 —— **纯函数**,可单测、可在任何进程里跑;
* - 不做压缩;⛔ **不自己实现加解密**(只调用注入的 `encode` / `decode`);
* - 🔑 **不自己派生 `netKey`**(本模块不认识"组密钥"这个概念 —— 派生在 `crypto.ts`);
* - 不做"块 → 来源"的映射(那是 `store.ts` / `source.ts` 的事)。
*
* @module dshs/net/relay/content/chunker
*/
import { createHash } from 'node:crypto'
import { createHash, createHmac } from 'node:crypto'
/**
* 默认块大小(字节)—— **1 MiB**。
@@ -102,16 +111,51 @@ export interface ChunkTransforms {
encode?: (plain: Buffer) => Buffer
/** 读侧:`落库字节 → 明文块`(解密)。失败 ⇒ 返回 `undefined`(由调用方**具名**处置)。 */
decode?: (stored: Buffer) => Buffer | undefined
/**
* 🆕 序㊻ · C(域分离):块 id 的**域密钥**(per-network keyed hash)。
*
* 给了它 ⇒ `blockIdOf` / `contentIdOf` 走 `HMAC-SHA256(netKey, bytes)`(输出仍取前
* `BLOCK_ID_HEX_LEN` 位);⛔ **缺省 / 空 ⇒ 回落裸 `sha256`**(= 回滚路径)。
*
* ⚠️ 必须是**确定性**的:它只由「组密钥 + network」派生(`crypto.ts#deriveBlockIdKey`)。
* ⛔ **不许把 network 之外的随机量塞进来** —— 那会让块 id 次次不同 ⇒ 去重与 peer 命中全废
* (`E1` 退回 `4.00×`,与 `encode` 非确定性的后果**同一条路径**)。
*/
netKey?: Buffer
}
/** 块 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 函数的**唯一实现**(口径只能有一处)。
*
* ⚠️ 刻意写成"两条整句分支"而不是"选一个 Hash 对象再链式调用":后者的联合类型在
* `strict` 下会漂,而这个函数是**全集群块 id 口径的唯一定义处** ⇒ 宁可啰嗦、不要巧。
*/
function idDigestOf(bytes: Buffer, netKey?: Buffer): string {
const hex =
netKey === undefined || netKey.length === 0
? createHash('sha256').update(bytes).digest('hex')
: createHmac('sha256', netKey).update(bytes).digest('hex')
return 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。
*
* - ⛔ 不传 `netKey`(或缺省 / 空)⇒ **裸 `sha256(块字节)`** 前 `BLOCK_ID_HEX_LEN` 位(与序㉔ 逐字一致);
* - ✅ 传了 `netKey` ⇒ **`HMAC-SHA256(netKey, 块字节)`** 前同样位数(序㊻ · C 域分离)。
*/
export function blockIdOf(bytes: Buffer, netKey?: Buffer): string {
return idDigestOf(bytes, netKey)
}
/**
* 整份内容的 id。
*
* - ⛔ 不传 `netKey` ⇒ 裸 `sha256(全部字节)`(与序㉔ 逐字一致);
* - ✅ 传了 `netKey` ⇒ `HMAC-SHA256(netKey, 全部字节)`(序㊻ · C 域分离)。
*/
export function contentIdOf(bytes: Buffer, netKey?: Buffer): string {
return idDigestOf(bytes, netKey)
}
/** 块 id 是否合法(纯小写 hex、长度恰好 `BLOCK_ID_HEX_LEN`)。 */
@@ -141,6 +185,7 @@ export function chunkify(
throw new Error(`chunker: 块大小必须是正整数,收到 ${String(blockSize)}`)
}
const encode = transforms?.encode
const netKey = transforms?.netKey
const chunks: Chunk[] = []
const seen = new Set<string>()
const ids: string[] = []
@@ -149,7 +194,7 @@ export function chunkify(
// ⚠️ 必须 `Buffer.from(...)` 复制:`subarray` 是**视图**,原 buffer 被复用时会**内容漂移**
// (块已落盘、id 却是按旧内容算的 ⇒ 校验必红且极难定位)。
const own = encode === undefined ? Buffer.from(slice) : encode(Buffer.from(slice))
const id = blockIdOf(own)
const id = blockIdOf(own, netKey)
chunks.push({ id, index, offset, bytes: own })
if (!seen.has(id)) {
seen.add(id)
@@ -158,7 +203,9 @@ export function chunkify(
}
// 整体指纹:给了 `encode` ⇒ 挂**落库字节流**(⛔ 否则整份内容的指纹会继续暴露给中继)。
const contentId =
encode === undefined ? contentIdOf(bytes) : contentIdOf(Buffer.concat(chunks.map((c) => c.bytes)))
encode === undefined
? contentIdOf(bytes, netKey)
: contentIdOf(Buffer.concat(chunks.map((c) => c.bytes)), netKey)
return { chunks, contentId, size: bytes.length, ids }
}
@@ -178,10 +225,11 @@ export function planOf(
throw new Error(`chunker: 块大小必须是正整数,收到 ${String(blockSize)}`)
}
const encode = transforms?.encode
const netKey = transforms?.netKey
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))))
ids.push(blockIdOf(encode === undefined ? slice : encode(Buffer.from(slice)), netKey))
}
return { ids, size: bytes.length }
}
@@ -201,13 +249,16 @@ export function planOf(
*/
export function reassemble(plan: string[], parts: Map<string, Buffer>, transforms?: ChunkTransforms): Buffer {
const decode = transforms?.decode
const netKey = transforms?.netKey
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)
// 🔴 复算必须用**同一把域密钥**(`netKey`)。⛔ 漏传 ⇒ 启用域分离后**每个块都判校验失败**
// (现场表现 = "取回的块全被丢弃",而块本身是好的 —— 这正是本线要根治的难定位形态)。
const actual = blockIdOf(got, netKey)
if (actual !== expected) {
throw new Error(`chunker: 块校验失败 index=${index} expected=${expected} actual=${actual}(丢弃)`)
}
+42
View File
@@ -301,6 +301,38 @@ export function describeGroupKey(file: string, r: GroupKeyLoadResult): string {
: `[content-crypto] ⛔ 不启用加密:${r.reason} —— ${r.detail}`
}
// ── 🆕 序㊻ · C(域分离):块 id 的 per-network 域密钥 ──────────────────────────────
/**
* 块 id 域密钥的**派生标签**。
*
* 🔴 **改这个字面量 = 全部块 id 换代**(缓存全清、去重率归零重算)⇒ ⛔ 只在换代时动,
* 且必须与 `E1` 基线重置**同时做**(`04-133 §3.3`)。
*/
export const BLOCK_ID_DOMAIN_TAG = 'dshs-overlay-block-id/v1'
/**
* 🆕 序㊻ · C(域分离):从**组密钥本体**按 **network 维度**派生块 id 的域密钥。
*
* ## 为什么复用组密钥链路(⛔ 不新增密钥文件 / ⛔ 不新增 env)
* 块 id 的可观测面只有一件:**同一个 id 是否在两个 network 里同时出现**(跨租户相关性)。
* 要挡住它,只需要**每网一把互不相同的派生钥**;而组密钥已经**按 `(network, group)` 分发到位、
* `0600` 落盘、可随 epoch 轮换** ⇒ 直接派生即可,无需任何新的运维对象。
*
* ## 三条口径(写死)
* - ✅ **确定性**:同钥同网 ⇒ 同派生钥(否则块 id 次次不同,去重全废);
* - ✅ **单向**:只有持组密钥者能算出某个 network 的派生钥 ⇒ **无钥者算不出另一个网的块 id**
* (= 跨网 COF / LRI 被切断的那一半);
* - ⚠️ **轮换组密钥 ⇒ 派生钥变 ⇒ 块 id 换代**。⚠️ 这与"轮换后密文全变 ⇒ 块 id 全变"**同向**,
* ⇒ **不引入额外代价**(密文口径本来就是 `sha256(密文)`)。
*
* @param groupKeyMaterial 组密钥本体(32 字节 AES key,**来自 `0600` 文件**)
* @param network 本节点所属网络标识(`network.ts#OPS_NETWORK` 一类)
*/
export function deriveBlockIdKey(groupKeyMaterial: Buffer, network: string): Buffer {
return createHmac('sha256', groupKeyMaterial).update(BLOCK_ID_DOMAIN_TAG).update(network, 'utf8').digest()
}
// ── 加解密 ────────────────────────────────────────────────────────────────────
/** `ContentCipher` 构造选项。 */
@@ -397,6 +429,16 @@ export class ContentCipher {
return keyIdOf(this.key)
}
/**
* 🆕 序㊻ · C(域分离):**本网**的块 id 域密钥(由当前**写入**密钥派生)。
*
* ⚠️ 返回的是**派生钥**(32 B),⛔ **不是密钥本体** —— 但它仍是密钥材料 ⇒
* 调用方**不许打印、不许进日志、不许进 `/status`**(`/status` 只放 `keyIdOf()` 指纹)。
*/
blockIdKeyOf(network: string): Buffer {
return deriveBlockIdKey(this.key, network)
}
/** AAD = `<groupKey>|<epoch>` —— 把"组"与"代"绑进认证。 */
private aadOf(epoch: number): Buffer {
return Buffer.from(`${this.groupKey}|${epoch}`, 'utf8')
+64 -17
View File
@@ -49,6 +49,8 @@ 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 { ChunkTransforms } from './chunker.js'
import { keyIdOf } from './crypto.js'
import type { ContentCipher, ContentCryptoCounters } from './crypto.js'
import { DIRECT_CAND_MAX_ADDRS, isValidAddress } from '../direct/candidate.js'
import type { CandidateVerdict, DirectAddress } from '../direct/candidate.js'
@@ -292,6 +294,16 @@ export interface ContentSnapshot {
* ⚠️ **不启用加密时本键整体缺席** ⇒ `OBS-23` 记 **SKIP**("缺省不启用"是合法状态)。
*/
crypto?: ContentCryptoCounters
/**
* 🆕 序㊻ · C(域分离):块 id 是否走**per-network keyed hash**。
* ⚠️ **未启用域分离时本键整体缺席**(⛔ 不补 `false`)⇒ 与 `crypto` 同一纪律。
*/
blockIdKeyed?: boolean
/**
* 🆕 序㊻ · C:域密钥的**可公开指纹**(16 hex;⛔ 不是密钥)。
* 🔑 **跨机口径一致性**的机器判据:47 与 106 必须逐字相同。
*/
blockIdKeyId?: string
}
/**
* 内容面运行时 —— 一个进程一份,**唯一**的报数入口。
@@ -307,6 +319,22 @@ export class ContentRuntime {
readonly blockSize: number
/** 🆕 组密钥加解密器(`undefined` = 不启用加密)。 */
readonly cipher: ContentCipher | undefined
/**
* 🆕 序㊻ · C(域分离):块 id 的**域密钥**(`undefined` = 裸哈希口径 ⇒ 与序㉔ 逐字一致)。
*
* 🔑 派生方式 = `cipher.blockIdKeyOf(network)`(组密钥 + network ⇒ 不新增密钥文件 / 不新增 env)。
* ⇒ **加密与域分离同开同关**:没启用组密钥就没有域密钥,块 id 回到裸哈希(= 回滚路径)。
*
* ⛔ **这是密钥材料** —— 不许打印、不许进日志、不许进 `/status`
* (`/status` 只放 {@link blockIdKeyId} 指纹)。
*/
readonly netKey: Buffer | undefined
/**
* 🆕 序㊻ · C:域密钥的**可公开指纹**(`undefined` = 未启用域分离)。
* ⚠️ 它是"**两机口径是否一致**"的机器判据:47 与 106 的该值必须逐字相同,
* 否则跨机取块会**全部判校验失败**(而块本身是好的 —— 最难定位的形态)。
*/
readonly blockIdKeyId: string | undefined
private readonly storeMaxBytes: number
/** 本节点所属网(候选登记的防御性比对用;⛔ 不从别处猜)。 */
@@ -344,9 +372,14 @@ export class ContentRuntime {
this.network = opts.network
this.log = opts.log
this.cipher = opts.cipher
// 🆕 序㊻ · C:域密钥与 cipher **同开同关**(⛔ 不新增 env / 不新增密钥文件)。
// ⚠️ 顺序:必须在 `new ContentStore` 之前 —— store 的两处复算用它。
this.netKey = this.cipher?.blockIdKeyOf(opts.network)
this.blockIdKeyId = this.netKey === undefined ? undefined : keyIdOf(this.netKey)
this.store = new ContentStore({
maxBytes: this.storeMaxBytes,
...(opts.maxBlockBytes === undefined ? {} : { maxBlockBytes: opts.maxBlockBytes }),
...(this.netKey === undefined ? {} : { netKey: this.netKey }),
})
this.peers = new ContentPeerGroup({
network: opts.network,
@@ -447,7 +480,7 @@ export class ContentRuntime {
}
// ── 🔴 **D7 闸门**:复算块 id(⛔ 这一行不许省、不许"先信后验")────────────
this.wire.idChecks += 1
const actual = blockIdOf(gotBytes)
const actual = blockIdOf(gotBytes, this.netKey)
if (actual !== id) {
this.wire.idMismatches += 1
this.wire.errors[name] += 1
@@ -540,6 +573,27 @@ export class ContentRuntime {
return this.cipher !== undefined
}
/**
* 🆕 序㊻ · C:**写侧变换**(加密 + 域密钥)—— 一个对象同时给两样,⛔ 不许只给一半。
*
* ⛔ 不启用组密钥 ⇒ `undefined`(与序㉔ 逐字一致)。🔴 只给 `encode` 不给 `netKey` 的形态
* = "块 id 挂密文但不带域维度" ⇒ 正是本线要根治的**静默失效**;所以两样在一个函数里产出。
*/
private writeTransforms(): ChunkTransforms | undefined {
const cipher = this.cipher
if (cipher === undefined) return undefined
const encode = (plain: Buffer): Buffer => cipher.encryptBlock(plain)
return this.netKey === undefined ? { encode } : { encode, netKey: this.netKey }
}
/** 🆕 序㊻ · C:**读侧变换**(重组位的解密 + 域密钥)。⚠️ 生产路径的解密在 `source` 链。 */
private readTransforms(): ChunkTransforms | undefined {
const cipher = this.cipher
if (cipher === undefined) return undefined
const decode = (stored: Buffer): Buffer | undefined => cipher.decodeBlock(stored)
return this.netKey === undefined ? { decode } : { decode, netKey: this.netKey }
}
/**
* 🆕 **写内容**(单 B 的写侧统一入口):明文 → 切块 → 加密 → 内容寻址入库。
*
@@ -547,11 +601,7 @@ export class ContentRuntime {
* ⇒ 单块改动会让其后所有块失效(丢掉 `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 },
)
const r = chunkify(bytes, this.blockSize, this.writeTransforms())
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 }
@@ -580,11 +630,7 @@ export class ContentRuntime {
*/
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) },
)
return reassemble([...ids], stored, this.readTransforms())
} catch (err) {
this.lastReassembleError = err instanceof Error ? err.message : String(err)
return undefined
@@ -596,11 +642,7 @@ export class ContentRuntime {
/** 🆕 只算"这份内容要哪些块"(写侧 `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 },
)
return planOf(bytes, this.blockSize, this.writeTransforms())
}
/**
@@ -618,7 +660,9 @@ export class ContentRuntime {
const plain = Buffer.from(marker, 'utf8')
const ok = cipher.selfProbe(marker, {
put: (blob) => {
const id = blockIdOf(blob)
// 🔴 序㊻ · C:这里的 id **必须**与 store 的复算口径一致(同样带 `netKey`)。
// 漏传 ⇒ `store.put` 抛"块校验失败" ⇒ 启动自证直接失败(= 调用点漏改的现行判据)。
const id = blockIdOf(blob, this.netKey)
this.lastProbeId = id
this.store.put(id, blob)
},
@@ -658,6 +702,9 @@ export class ContentRuntime {
peerWire: this.peerWireSnapshot(),
// ⚠️ 不启用加密 ⇒ 本键**整体缺席**(不是补零!补零会让"没启用"与"启用了但零值"同形)
...(this.cipher === undefined ? {} : { crypto: this.cipher.counters() }),
// 🆕 序㊻ · C(域分离):**同样"未启用即整体缺席"** —— 由 `blockIdKeyId` 的存在性
// 区分"没开域分离"与"开了但指纹是空串"(⛔ 不补 false、⛔ 不补空串)。
...(this.blockIdKeyId === undefined ? {} : { blockIdKeyed: true, blockIdKeyId: this.blockIdKeyId }),
}
}
}
+15 -3
View File
@@ -68,6 +68,15 @@ export interface ContentStoreOptions {
dir?: string
/** 单块上限(字节)。缺省 = `maxBytes`(即"只要装得下就收")。 */
maxBlockBytes?: number
/**
* 🆕 序㊻ · C(域分离):块 id 的**域密钥**(per-network keyed hash)。
*
* 给了它 ⇒ 本层的两处复算(入库前 / 取出后)走 `HMAC-SHA256(netKey, bytes)`;
* ⛔ 缺省 ⇒ 回落裸 `sha256`(= 回滚路径)。⚠️ **必须与写侧的 `netKey` 一致** ——
* 不一致的症状是"每个块都判校验失败"(`putRejected` / `corruptReads` 涨),
* 而块本身是好的(本线最恨的难定位形态)。
*/
netKey?: Buffer
}
/**
@@ -95,6 +104,8 @@ export class ContentStore {
private readonly maxBytes: number
private readonly maxBlockBytes: number
private readonly dir: string | undefined
/** 🆕 序㊻ · C:块 id 的域密钥(`undefined` = 裸哈希口径,与序㉔ 逐字一致)。 */
private readonly netKey: Buffer | undefined
private usedBytes = 0
private seq = 0
private readonly c: ContentStoreCounters = {
@@ -119,6 +130,7 @@ export class ContentStore {
}
this.maxBlockBytes = Math.min(mb, max)
this.dir = opts.dir
this.netKey = opts.netKey
if (this.dir !== undefined) mkdirSync(this.dir, { recursive: true })
}
@@ -168,7 +180,7 @@ export class ContentStore {
)
}
// ── E4 写侧:入库前**必须**复算 id ──────────────────────────────────
const actual = blockIdOf(bytes)
const actual = blockIdOf(bytes, this.netKey)
if (actual !== id) {
this.c.putRejected += 1
throw new Error(`content-store: 块校验失败(丢弃)expected=${id} actual=${actual}`)
@@ -206,7 +218,7 @@ export class ContentStore {
}
const hit = this.map.get(id)
if (hit !== undefined) {
const verify = blockIdOf(hit.bytes)
const verify = blockIdOf(hit.bytes, this.netKey)
if (verify !== id) {
// 内存里的块被改过(理论上不该发生)⇒ 丢弃 + 计数
this.c.corruptReads += 1
@@ -224,7 +236,7 @@ export class ContentStore {
const p = this.pathOf(id)
try {
const buf = readFileSync(p)
const verify = blockIdOf(buf)
const verify = blockIdOf(buf, this.netKey)
if (verify !== id) {
this.c.corruptReads += 1
this.removeOnDisk(id)
+4 -1
View File
@@ -104,7 +104,7 @@ export {
planOf,
reassemble,
} from './content/chunker.js'
export type { Chunk, ChunkedContent } from './content/chunker.js'
export type { Chunk, ChunkedContent, ChunkTransforms } 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'
@@ -144,6 +144,9 @@ export {
TAG_LEN,
KEY_LEN,
MIN_BLOB_LEN,
// 🆕 序㊻ · C(域分离):块 id 的 per-network 域密钥(⛔ 不新增密钥文件 / 不新增 env)。
BLOCK_ID_DOMAIN_TAG,
deriveBlockIdKey,
} from './content/crypto.js'
export type {
ContentCryptoCounters,
+15 -1
View File
@@ -242,6 +242,17 @@ async function main(): Promise<void> {
})
const contentStatusProvider = (): Record<string, unknown> =>
contentRuntime.snapshot() as unknown as Record<string, unknown>
/**
* 🆕 序㊻ · C(域分离):**启动判别器**(防"装了但没生效")。
*
* ⛔ 不打印域密钥本体(它是密钥材料)—— 只打印**可公开指纹**。
* 🔑 该指纹是"两机口径一致"的比对位:47 与 106 逐字相同才说明同一块 id 口径。
*/
log(
contentRuntime.blockIdKeyId === undefined
? '[content] 块 id 口径 = 裸 sha256(⛔ 未启用域分离 —— 缺组密钥,或组密钥未装载)'
: `[content] 块 id 口径 = HMAC-SHA256(域密钥) blockIdKeyId=${contentRuntime.blockIdKeyId}`,
)
/**
* 🔴 **活性自证**(防"装了但一次都没命中")。
*
@@ -273,8 +284,11 @@ async function main(): Promise<void> {
return
}
// ── 序㉔ 原路径(⛔ 不启用加密时逐字保留,行为不许变)
// 🔴 序㊻ · C:本分支**恒有** `netKey === undefined`(它就是 `cryptoEnabled === false` 的那一支)
// ⇒ 显式传 `contentRuntime.netKey` 而不是省略,是为了让"调用点是否过 netKey"**在源码上可核**
// (⛔ 漏一处的代价 = 每个块都判校验失败且极难定位)。
const { blockIdOf } = await import('./content/chunker.js')
const id = blockIdOf(bytes)
const id = blockIdOf(bytes, contentRuntime.netKey)
contentRuntime.store.put(id, bytes)
const outcome = await contentRuntime.source.fetch(id)
if (outcome?.bytes === undefined) {
+83 -8
View File
@@ -343,6 +343,28 @@ export class LocalSpawner implements Spawner {
notes: [],
}
/**
* 覆盖网络线 序 ㊽:**本轮认领的探活全部落定**之后的回调。
*
* ## 为什么需要它(这正是一个实测缺陷的修法)
* 认领来的存量实例**刻意不进 `mains`**(文件头 序 ㉕ 的边界:`launchToken` 不可恢复),
* 而 `listUserInstances()` 的口径**就是 `mains`** ⇒ 上一进程遗留的实例监听端口**没有任何人**
* 会向 relay 重新声明一遍。实测症状(2026-09-19):106 的实例 `:21001` 进程健在、
* `[rehydrate] probe OK` 也打了,但两台中继的端点表里都没有它(47 侧只留一条
* `w-106:19000 online=false` 的**孤儿**条目)⇒ Manager 侧 `(hostId, port)` 翻译不出来。
*
* ⇒ 由 **worker agent** 接这个回调,把 `adoptedInstancePorts()` 并进对账口径(⛔ 不改认领语义、
* ⛔ 不把认领实例写进 `mains`):端口一落定就登记,**不必等 20 s 对账节拍**。
* ⚠️ 缺省 `undefined` ⇒ 行为与改造前**逐字一致**(其他装配点不受影响)。
*/
onRehydrateSettled: (() => void) | undefined
/** 序 ㊽:本轮仍在途的探活条数。探活是**异步**的(socket 事件)⇒ ⛔ 不能只看 `schedule` 的基例。 */
private pendingProbes = 0
/** 序 ㊽:本轮 `schedule` 已走完(不会再产生新探活)。`true` + `pendingProbes === 0` ⇒ 落定。 */
private rehydrateScheduled = false
constructor(
private readonly config: ServerConfig,
/** Resolve the user's own API key (decrypted); null = user has none. */
@@ -1411,16 +1433,62 @@ export class LocalSpawner implements Spawner {
return { ...this.rehydrate, notes: [...this.rehydrate.notes] }
}
/**
* 覆盖网络线 序 ㊽:**已认领且探活通过**的实例监听端口(worker agent 的对账口径之一)。
*
* 与 {@link listUserInstances} 的关系 = **互补,不合并**:那个的口径是"本进程 launch 过的"
* (`mains`),这个是"本进程**接管**的"(`adopted`)。认领来的实例没有 `launchToken`、
* 也进不了 `status(userId)`(文件头 序 ㉕ 的客观边界,⛔ 未改),但"**这个端口在听、且归本
* worker 管**"与实例身份无关 ⇒ 该登记给 relay 就得登记(否则跨机代理查不到落点)。
*
* ⚠️ 只报 `alive === true` 的:探活没过的那条已被 `probeAdopted` 按旧行为停掉了,
* 报出去只会让上游去登记一个死口。⛔ 未出生的探活(`alive === undefined`)同样不报。
*/
adoptedInstancePorts(): number[] {
const out: number[] = []
for (const rec of this.adopted.values()) {
if (rec.alive === true && rec.info.port !== undefined) out.push(rec.info.port)
}
return out.sort((a, b) => a - b)
}
/**
* 序 ㊽:本轮 `schedule` 收尾 —— ⚠️ **探活可能还没落定**(异步)⇒ 只置标志,
* 由 {@link probeAdopted} 的最后一条补射(见 {@link maybeRehydrateSettled})。
*/
private settleRehydrate(): void {
this.rehydrateScheduled = true
this.maybeRehydrateSettled()
}
/** 序 ㊽:`schedule` 走完 + 在途探活归零 ⇒ **恰好通知一次**(幂等;回调抛错⛔不许拖垮编排器)。 */
private maybeRehydrateSettled(): void {
if (!this.rehydrateScheduled || this.pendingProbes > 0) return
this.rehydrateScheduled = false
try {
this.onRehydrateSettled?.()
} catch (err) {
process.stderr.write(
`[rehydrate] ⚠️ 落定回调抛错(已吞,不阻断):${err instanceof Error ? err.message : String(err)}\n`,
)
}
}
private rehydrateAdoptedScopes(): void {
// 序 ㊽:新一轮认领起算 —— 上一轮的落定标志必须清掉(否则残留的 `true` 会让本轮提前通知)。
this.pendingProbes = 0
this.rehydrateScheduled = false
// ① 非 account 形态不产生 scope ⇒ 保持旧语义(此处是空操作)。
if (this.config.isolationMode !== 'account') {
this.cleanAllStaleScopes()
this.settleRehydrate()
return
}
const found = this.scanExistingScopes()
this.rehydrate.scanned = found.length
if (found.length === 0) {
process.stderr.write('[rehydrate] 无既有实例 scope ⇒ 不动作(与旧行为等价)\n')
this.settleRehydrate()
return
}
const perUid = new Map<number, number>()
@@ -1429,6 +1497,7 @@ export class LocalSpawner implements Spawner {
const schedule = (i: number): void => {
if (i >= found.length) {
process.stderr.write(`[rehydrate] summary ${JSON.stringify(this.rehydrateReport())}\n`)
this.settleRehydrate()
return
}
const t = setTimeout(() => {
@@ -1505,9 +1574,13 @@ export class LocalSpawner implements Spawner {
this.rehydrate.stopped += 1
this.adopted.delete(rec.unit)
this.stopUnit(rec.unit)
// 序 ㊽:这一条**不产生**探活 ⇒ 若此刻已是本轮最后一条,落定通知不能等它。
this.maybeRehydrateSettled()
return
}
let settled = false
// 序 ㊽:在途计数 —— 探活由 socket 事件驱动,`schedule` 的基例可能先于它跑完。
this.pendingProbes += 1
const sock = connect({ host: '127.0.0.1', port })
const done = (ok: boolean): void => {
if (settled) return
@@ -1521,15 +1594,17 @@ export class LocalSpawner implements Spawner {
if (ok) {
this.rehydrate.probeOk += 1
process.stderr.write(`[rehydrate] probe OK ${rec.unit} :${port}\n`)
return
} else {
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)
}
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)
this.pendingProbes -= 1
this.maybeRehydrateSettled()
}
sock.setTimeout(this.rehydrateProbeMs(), () => done(false))
sock.once('error', () => done(false))
+38 -2
View File
@@ -253,16 +253,42 @@ export function buildWorkerAgent(
}
}
/**
* **本 worker 此刻真的在管的实例端口**(对账口径的**唯一**来源)。
*
* 🔴 覆盖网络线 序 ㊽:必须是**两条腿的并集** ——
* ① `listUserInstances()` = 本进程 `launch` 过的(`LocalSpawner.mains`);
* ② `adoptedInstancePorts()` = 本进程**认领**来的存量实例(⛔ 不进 `mains`,见其文件头 序 ㉕)。
*
* 只取 ① 就是本次实测的缺陷:worker 重启后 `mains` 空 ⇒ 上一进程遗留的实例端口**没人重新声明**,
* relay 端点表里只留一条 `online=false` 的孤儿条目(实测 47 侧 `w-106:19000`)⇒ Manager 侧
* `(hostId, port)` 翻译不出来。并进 ② 之后,`forward(port)` 会把那条孤儿**就地覆盖**成在线
* (relay 侧 `ensureEndpoint` 复用既有条目、只换绑定会话,⛔ 不新开口、⛔ 不动白名单)。
*/
const liveInstancePorts = async (): Promise<Set<number>> => {
const live = new Set(
(await spawner.listUserInstances()).map((i) => i.port).filter((p): p is number => p !== undefined),
)
for (const port of spawner.adoptedInstancePorts()) live.add(port)
return live
}
/** 把活着的实例端口补齐、把已消失的撤掉(崩溃退出也走这里收敛,不必逐个挂 exit 钩子)。 */
const reconcileTunnel = async (): Promise<void> => {
if (tunnel === undefined || !tunnelReady) return
const live = new Set((await spawner.listUserInstances()).map((i) => i.port).filter((p): p is number => p !== undefined))
const live = await liveInstancePorts()
for (const port of live) await tunnel.forward(port)
for (const port of tunnel.ports) {
if (!live.has(port) && !staticPorts.includes(port)) await tunnel.cancel(port)
}
}
/** 隧道一拍 = 自愈 + 对账(序 ㊽:定时器与"认领落定"回调**共用同一份语义**,⛔ 不写第二套)。 */
const tunnelTick = async (): Promise<void> => {
await healTunnel()
await reconcileTunnel()
}
let tunnelTimer: NodeJS.Timeout | undefined
if (tunnel !== undefined) {
void tunnel
@@ -274,9 +300,19 @@ export function buildWorkerAgent(
console.error('[tunnel] 建立失败(跨机代理将不可用,本机功能不受影响):', err instanceof Error ? err.message : err)
})
tunnelTimer = setInterval(() => {
void healTunnel().then(reconcileTunnel)
void tunnelTick()
}, 20_000)
tunnelTimer.unref?.()
/**
* 序 ㊽:**认领(rehydrate)落定即登记** —— ⛔ 不等 20 s 对账节拍。
*
* 认领来的存量实例端口**只有这一条路**会被声明给 relay(它们不进 `mains`)⇒ 若只靠 20 s
* 定时器,重启后有一段"实例活着但中继查不到落点"的窗口;本回调把它压到探活落定的那一刻。
* ⚠️ 定时器**保留**:它仍是断链自愈 / 端口漂移的兜底(回调只负责"起步那一拍")。
*/
spawner.onRehydrateSettled = () => {
void tunnelTick()
}
}
const app = Fastify({ logger: { level: options.logLevel ?? 'info' }, bodyLimit: MAX_PATCH_BYTES + 4096 })
const cache: OpCache = { order: [], results: new Map() }