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
@@ -8,10 +8,21 @@
|
||||
* 而 336 ms 目前是"跨云链路很差"的**唯一证据** ⇒ 若它是口径问题,后面所有
|
||||
* "跨云不可玩"的结论都要重判。本脚本做**三方对比**:ICMP / TCP 握手 / relay 心跳。
|
||||
*
|
||||
* ## 序㉖ 增补:`--watch` **持续采样**(点测 → 连续观测)
|
||||
*
|
||||
* 序㉖ 的选路主序是 **jitter**(`src/net/relay/jitter.ts`)⇒ 运维侧必须能**连续**看每条候选
|
||||
* 路径的 jitter,否则"选路按 jitter"这件事在生产上是**不可见证**的。
|
||||
*
|
||||
* 🔴 **本脚本⛔ 不自带统计实现**:它 `require('../lib/net/relay/jitter.js')` 复用
|
||||
* `absDeltas` / `percentile` / `histogram` / `JitterTracker` / `orderByJitter` /
|
||||
* `pickJitterTarget` —— 与产品路径**同一份算法、同一份阈值(`JITTER_*` env)**。
|
||||
* 本线教训:「另一份实现 = 另一处静默失效」(取址链踩过两次)。
|
||||
*
|
||||
* 用法:
|
||||
* node overlay-jitter.cjs --icmp 106.54.21.172 --count 300 --interval 0.2
|
||||
* node overlay-jitter.cjs --tcp 106.54.21.172:22 --tcp-n 30
|
||||
* node overlay-jitter.cjs --icmp H --count 300 --tcp H:22 --tcp-n 30 # 两者一起跑
|
||||
* node overlay-jitter.cjs --watch --targets 106.54.21.172:22,47.77.182.89:22 --rounds 40 --gap 400
|
||||
*
|
||||
* @module scripts/overlay-jitter
|
||||
*/
|
||||
@@ -111,6 +122,100 @@ function tcpHandshake(host, port, n) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 单次 TCP 握手 RTT(ms);失败 ⇒ `undefined`(**不记 0** —— 0 会被当成"极稳"污染排序)。
|
||||
* ⚠️ 这是 I/O,不是统计;统计一律交给 `lib/net/relay/jitter.js`。
|
||||
*/
|
||||
function tcpSampleOnce(host, port) {
|
||||
return new Promise((resolve) => {
|
||||
const t = process.hrtime.bigint()
|
||||
const s = net.connect(port, host)
|
||||
let settled = false
|
||||
const done = (v) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
s.destroy()
|
||||
resolve(v)
|
||||
}
|
||||
s.setTimeout(5000)
|
||||
s.on('connect', () => done(Number(process.hrtime.bigint() - t) / 1e6))
|
||||
s.on('error', () => done(undefined))
|
||||
s.on('timeout', () => done(undefined))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 序㉖ `--watch`:**持续采样 + 按产品同一份算法给出选路序**。
|
||||
*
|
||||
* 输出里的 `order` 就是 `orderByJitter()` 的结果 ⇒ 与 `directory.ts` 生产选路**同函数**。
|
||||
*/
|
||||
async function watch(targetsRaw, rounds, gap) {
|
||||
const path = require('node:path')
|
||||
const J = require(path.join(__dirname, '..', 'lib', 'net', 'relay', 'jitter.js'))
|
||||
const th = J.jitterThresholds()
|
||||
const tracker = new J.JitterTracker(th)
|
||||
const urls = targetsRaw.map((s) => String(s))
|
||||
const counters = new Map(urls.map((u) => [u, { ok: 0, fail: 0, rtts: [] }]))
|
||||
const N = Math.max(2, rounds || 40)
|
||||
|
||||
for (let r = 0; r < N; r++) {
|
||||
for (const u of urls) {
|
||||
const [h, p] = u.split(':')
|
||||
const rtt = await tcpSampleOnce(h, Number(p || 22))
|
||||
const c = counters.get(u)
|
||||
if (rtt === undefined) c.fail += 1
|
||||
else {
|
||||
c.ok += 1
|
||||
c.rtts.push(Math.round(rtt * 100) / 100)
|
||||
tracker.record(u, rtt)
|
||||
}
|
||||
}
|
||||
if (gap > 0 && r < N - 1) await new Promise((res) => setTimeout(res, gap))
|
||||
}
|
||||
|
||||
const per = {}
|
||||
for (const u of urls) {
|
||||
const c = counters.get(u)
|
||||
const st = tracker.stats(u)
|
||||
const sorted = [...c.rtts].sort((a, b) => a - b)
|
||||
per[u] = {
|
||||
samples: c.ok,
|
||||
failed: c.fail,
|
||||
rttMedianMs: sorted.length === 0 ? undefined : sorted[Math.floor(sorted.length / 2)],
|
||||
rttP95Ms: sorted.length === 0 ? undefined : sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))],
|
||||
/** ⚠️ `undefined` = 差分样本 < `JITTER_MIN_SAMPLES` ⇒ **未知**,⛔ 不当 0 用。 */
|
||||
jitterMs: st === undefined ? undefined : st.p95AbsDeltaMs,
|
||||
deltas: st === undefined ? 0 : st.deltas,
|
||||
hist: st === undefined ? undefined : st.hist,
|
||||
}
|
||||
}
|
||||
|
||||
const order = J.orderByJitter(urls, tracker)
|
||||
const first = order[0]
|
||||
const curJ = per[first] && per[first].jitterMs
|
||||
const pick =
|
||||
curJ === undefined
|
||||
? undefined
|
||||
: J.pickJitterTarget({ urls, tracker, curUrl: first, curJitterMs: curJ, switchMs: th.switchMs })
|
||||
|
||||
return {
|
||||
probe: 'tcp-handshake',
|
||||
thresholds: th,
|
||||
rounds: N,
|
||||
gapMs: gap,
|
||||
targets: per,
|
||||
order: [...order],
|
||||
/** 主判据 E1 的读法:`order` 首位 = jitter 最低者(**可能与 RTT 最低者不是同一条**)。 */
|
||||
e1: {
|
||||
curUrl: first,
|
||||
curJitterMs: curJ,
|
||||
rttLowestUrl: urls.slice().sort((a, b) => (per[a].rttMedianMs ?? 1e9) - (per[b].rttMedianMs ?? 1e9))[0],
|
||||
note: 'order 首位由 jitter 决定;若 rttLowestUrl !== order[0] ⇒ 直接印证"看 jitter 不看 RTT"',
|
||||
wouldSwitchTo: pick === undefined ? null : pick,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const res = { host: require('node:os').hostname(), platform: process.platform }
|
||||
if (args.icmp) res.icmp = icmp(String(args.icmp), args.count || 300, args.interval)
|
||||
@@ -118,6 +223,13 @@ async function main() {
|
||||
const [h, p] = String(args.tcp).split(':')
|
||||
res.tcpHandshake = await tcpHandshake(h, Number(p || 22), args['tcp-n'] || 30)
|
||||
}
|
||||
if (args.watch) {
|
||||
if (typeof args.targets !== 'string') {
|
||||
log('--watch 需要 --targets host:port[,host:port...]')
|
||||
process.exit(2)
|
||||
}
|
||||
res.watch = await watch(args.targets.split(','), args.rounds, args.gap === undefined ? 400 : args.gap)
|
||||
}
|
||||
out(res)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
* | `sign-revocations` | 在线签名者 | 撤单台(⛔ 撤**签名者**是"根重签一份 SignerSet"的事) |
|
||||
* | `verify-grant` | 任何地方 | 自检:凭据是不是受信签名者签的 |
|
||||
* | `recover-root` | 离线 | **恢复演练**:从纸质恢复码重建根私钥,再签一份 SignerSet 验通 |
|
||||
* | 🆕 `init-group-key` | **每个内容组一次**(结果 scp 到该组每台机器) | 生成**组密钥**(对称,32 B)落 `0600`;⛔ 密钥本体**永不进 stdout / 日志** |
|
||||
* | 🆕 `sign-group-key` | 在线签名者(47) | 签 `(network, group, epoch, keyId)` **三元组**(⛔ 载荷内**不含密钥本体**) |
|
||||
* | 🆕 `verify-group-key` | 任何地方 | 自检:三元组是不是受信签名者签的(篡改 `epoch` 必须失败) |
|
||||
*
|
||||
* ## 用法
|
||||
* ```bash
|
||||
@@ -30,10 +33,16 @@
|
||||
* # 演练三判据:--expect-pub 比公钥;
|
||||
* # 再给 --signers <hex,…> --network <n> --issued-at <iso> ⇒ 用重建的根签 SignerSet 验通(判据②);
|
||||
* # 再给 --expect-sig <b64>(原根对同一 doc 签出的)⇒ 逐字节比对(判据③)
|
||||
* # 🆕 序㉘ · 单 B(组密钥加密):
|
||||
* node scripts/overlay-keyring.cjs init-group-key --group relay --epoch 1 --out /etc/dshs/content-group-key.json [--network ops]
|
||||
* node scripts/overlay-keyring.cjs sign-group-key --signer-key <pem> --network ops --group relay --epoch 1 \
|
||||
* --key /etc/dshs/content-group-key.json --out <json>
|
||||
* node scripts/overlay-keyring.cjs verify-group-key --file <json> --signer-pub <hex> [--group relay --epoch 1]
|
||||
* ```
|
||||
*
|
||||
* 🔴 **所有落盘的私钥一律 `0600`**(`writeSecret`),且**stdout 只打印公钥 / 指纹** ——
|
||||
* 🔴 **所有落盘的私钥 / 密钥一律 `0600`**(`writeSecret`),且**stdout 只打印公钥 / 指纹** ——
|
||||
* 私钥进 stdout 就会进终端历史、`journalctl`、CI 日志。
|
||||
* ⚠️ 组密钥是**对称密钥** ⇒ 同一条铁律:stdout 只打印 `keyId`(`sha256(key)` 前 16 hex),⛔ 不打印 key。
|
||||
*
|
||||
* @module scripts/overlay-keyring
|
||||
*/
|
||||
@@ -44,6 +53,8 @@ const { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } = requir
|
||||
const { dirname } = require('node:path')
|
||||
|
||||
const id = require('../lib/net/relay/identity.js')
|
||||
// 🆕 序㉘ · 单 B:组密钥(对称)—— 生成 / 签名三元组 / 验签。⚠️ 只搬运算,判据全在模块里。
|
||||
const cfgx = require('../lib/net/relay/content/crypto.js')
|
||||
|
||||
const [, , cmd, ...rest] = process.argv
|
||||
|
||||
@@ -212,6 +223,90 @@ const COMMANDS = {
|
||||
process.stdout.write(`✓ 验签通过:${verdict.doc.network}/${verdict.doc.hostId} 指纹=${id.nodeKeyFingerprint(verdict.doc.nodeKey)}\n`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 🆕 序㉘ · 单 B:生成**组密钥**(对称,32 字节 = `crypto.KEY_LEN`)—— **每个内容组一次**。
|
||||
*
|
||||
* 三条纪律:
|
||||
* ① 落盘走 `writeSecret` ⇒ **`0600`**(密钥本体只准在文件里,⛔ **不经网络、不经 relay**);
|
||||
* ② stdout **只打印 `keyId`**(`sha256(key)` 前 16 hex)⇒ 可安全贴进回报 / 日志;
|
||||
* ③ 同一把密钥要铺到该组的**每台**机器(`scp` + `0600` + 属主正确)。
|
||||
*/
|
||||
'init-group-key': () => {
|
||||
const group = need('group')
|
||||
const epoch = Number(need('epoch'))
|
||||
if (!Number.isInteger(epoch) || epoch <= 0) throw new Error('--epoch 必须是正整数')
|
||||
const key = require('node:crypto').randomBytes(cfgx.KEY_LEN)
|
||||
const doc = {
|
||||
version: cfgx.CONTENT_CIPHER_VERSION,
|
||||
group,
|
||||
...(args.network === undefined ? {} : { network: String(args.network) }),
|
||||
epoch,
|
||||
key: key.toString('base64'),
|
||||
previous: [],
|
||||
}
|
||||
writeSecret(need('out'), `${JSON.stringify(doc, null, 2)}\n`, '组密钥文件')
|
||||
process.stdout.write(
|
||||
`✓ group=${group} epoch=${epoch} keyId=${cfgx.keyIdOf(key)}(⚠️ 密钥本体只在文件里,⛔ stdout 不打印)\n`,
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* 🆕 序㉘ · 单 B:签名者签一份**组密钥凭据** = `(network, group, epoch, keyId)` **三元组**。
|
||||
*
|
||||
* 🔴 **载荷内不含密钥本体** —— 它只回答"当前这一代是哪一把(`keyId`)",
|
||||
* 供节点做**版本错配检测**;密钥本体仍只走 `0600` 文件通道。
|
||||
*/
|
||||
'sign-group-key': () => {
|
||||
const spec = need('key').trim()
|
||||
let keyB64 = spec
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(spec, 'utf8'))
|
||||
if (raw !== null && typeof raw === 'object' && typeof raw.key === 'string') keyB64 = raw.key
|
||||
} catch {
|
||||
/* 不是文件 ⇒ 当 base64 用(也允许直接给 base64,便于夹具) */
|
||||
}
|
||||
const keyBuf = Buffer.from(keyB64, 'base64')
|
||||
if (keyBuf.length !== cfgx.KEY_LEN) {
|
||||
throw new Error(`--key 必须给出 ${cfgx.KEY_LEN} 字节密钥(含 key 字段的 JSON 文件,或裸 base64)`)
|
||||
}
|
||||
const epoch = Number(need('epoch'))
|
||||
if (!Number.isInteger(epoch) || epoch <= 0) throw new Error('--epoch 必须是正整数')
|
||||
const doc = {
|
||||
version: cfgx.CONTENT_CIPHER_VERSION,
|
||||
network: need('network'),
|
||||
group: need('group'),
|
||||
epoch,
|
||||
keyId: cfgx.keyIdOf(keyBuf),
|
||||
issuedAt: nowIso(),
|
||||
}
|
||||
const sig = id.signPayloadWith(readFileSync(need('signer-key'), 'utf8'), cfgx.groupKeyCredentialPayload(doc))
|
||||
writePublic(need('out'), { doc, sig })
|
||||
process.stdout.write(`✓ 已签发组密钥凭据:${doc.network}/${doc.group} epoch=${doc.epoch} keyId=${doc.keyId}\n`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 🆕 序㉘ · 单 B:自检组密钥凭据(**篡改 `epoch` 必须失败** —— 失败关闭)。
|
||||
*
|
||||
* 可选交叉核对:`--group` / `--epoch` / `--key`(给了就与凭据里的值**逐字比对**,
|
||||
* 防"签的是 A、装的是 B"这种静默错配)。
|
||||
*/
|
||||
'verify-group-key': () => {
|
||||
const raw = JSON.parse(readFileSync(need('file'), 'utf8'))
|
||||
const verdict = cfgx.verifyGroupKeyCredential(raw.doc, raw.sig, need('signer-pub').split(',').map((s) => s.trim()))
|
||||
if (!verdict.ok) {
|
||||
process.stderr.write(`✗ 验签失败:${verdict.reason}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
const d = verdict.doc
|
||||
if (typeof args.group === 'string' && args.group.trim() !== d.group) {
|
||||
throw new Error(`⛔ 组名不配:凭据 group=${d.group} ≠ 传入 ${args.group.trim()}`)
|
||||
}
|
||||
if (args.epoch !== undefined && Number(args.epoch) !== d.epoch) {
|
||||
throw new Error(`⛔ epoch 不配:凭据 epoch=${d.epoch} ≠ 传入 ${String(args.epoch)}`)
|
||||
}
|
||||
process.stdout.write(`✓ 验签通过:network=${d.network} group=${d.group} epoch=${d.epoch} keyId=${d.keyId}\n`)
|
||||
},
|
||||
|
||||
/**
|
||||
* **根密钥恢复演练**:从纸质恢复码重建根私钥(⛔ 不用原文件),再签一份 SignerSet 并验通。
|
||||
*
|
||||
|
||||
+902
-17
@@ -32,7 +32,34 @@
|
||||
* ⇒ 替换式变化(一进一出)**必然**被两条断言之一命中。
|
||||
* `LISTEN_COUNT` / `NFT_RULES` **已退役**(仅在参数表里留作对账),⛔ 本文件不得再引用它们。
|
||||
*
|
||||
* ## 🧪 夹具模式(`--listen-fixture` / `--nft-fixture` / `--status-fixture`)
|
||||
* ## 🆕 `OBS-09` 的**在册判据**(序 ⑳ 口径修正)
|
||||
* 旧口径 = 「**两个实例面 HTTP 码都必须 ∈ `PROBE_CODE_SET`**」⇒ 实例**根本没在跑**时判红。
|
||||
* 这在本线是**已知环境态**(`systemctl restart dshs` 会收掉 `dsh-*.scope`,平台**按需拉起、
|
||||
* ⛔ 无 reconciler** ⇒ 无人访问时实例不自回)⇒ 长期恒红的条目**等于没有判别力**。
|
||||
* 新口径 = **在册 ⇒ 必须可达**:
|
||||
*
|
||||
* 在册判据 = `/status.endpoints[]` 中存在该实例端口 **且** `online === true`
|
||||
* · 不在册 ⇒ **SKIP**(⛔ 不是 PASS:那会掩盖"该在册却掉出端点表",那一类由 `OBS-01`/`OBS-08` 守)
|
||||
* · 在册 ⇒ 实例面码必须 ∈ `PROBE_CODE_SET` ⇒ 否则 **FAIL**(注册了却打不开 = 真红)
|
||||
*
|
||||
* ⇒ 两侧都要能判:夹具模式下用 `--instance-fixture` 显式给两个码(缺省 ⇒ 在册仍判 ⇒ FAIL 会点名)。
|
||||
*
|
||||
* ## 🆕 `OBS-16` 的**门窗口判据**(序㉑ · 在册缺陷 P-1 的验收)
|
||||
* `OBS-13` 的 `ΔstatusHits ≥ 1` 只证明"计数器没卡死"——它由**探针自己两次读**即满足,⛔ 证明不了
|
||||
* "订阅生效期间轮询停了"(旧判据下门 95% 时间开着、轮询照旧在跑,而 `OBS-13` **仍然全绿** ⇒
|
||||
* 实测降幅只有 1.10× 却长时间没人发现)。本项用**第三次采样**把窗口拉长到
|
||||
* `PRESENCE_GATE_WINDOW_MS`(≥ 若干倍 `RELAY_STATUS_POLL_MS`):
|
||||
*
|
||||
* 窗口内命中增量 ≥ 1(探针自身那一次 = 活性证明)**且** ≤ `PRESENCE_GATE_HITS_MAX`(默认 1
|
||||
* = 除探针外**零**命中)**且** `subs ≥ 1`("没人拉"不是因为"没人订阅")
|
||||
*
|
||||
* ⇒ 轮询还在跑(每周期一次)时窗口内会多出十余次命中 ⇒ **必红**;真停了 ⇒ 恒等 1 ⇒ 绿。
|
||||
* ⚠️ 这一项要求观察窗内**通道健康**(建立期/换址期会读 `/status` 的路径如 `waitUpOnStatus`)——
|
||||
* 窗口内真抖动导致它红,那是**真实信号**,⛔ 不许当噪声压掉。
|
||||
* ⚠️ 夹具模式**必须**显式给 `--status-fixture-3`:缺省 = 与第二份同一份 ⇒ Δ=0 ⇒ 活性证明不成立
|
||||
* ⇒ FAIL 并点名(**契约面**,⛔ 故意如此 —— 与 `OBS-09` 的 `--instance-fixture` 同规则)。
|
||||
*
|
||||
* ## 🧪 夹具模式(`--listen-fixture` / `--nft-fixture` / `--status-fixture` / `--instance-fixture`)
|
||||
* 判据改造**必须自带"旧判据会放过、新判据能抓住"的实证**。夹具就是那个实证手段:
|
||||
* 把远端原文喂进来 ⇒ **不 ssh**、零生产副作用;输出行首加 `⚠️ FIXTURE`(stderr 另标一次,
|
||||
* ⛔ 防止被下游当成生产结论);此时非集合类指标记 `SKIP`(不参与退出码)。
|
||||
@@ -339,6 +366,15 @@ function parseNftTextRaw(text) {
|
||||
|
||||
/* ─────────── 只读取数 ─────────── */
|
||||
|
||||
/**
|
||||
* 同步小睡 —— `main()` 是同步流程(不能 `await`),而 `OBS-13` 的"增量"判据必须**真的等一段**。
|
||||
* `Atomics.wait` 精确阻塞且不烧 CPU(⛔ 不用 busy-loop,也不另起子进程 `sleep`)。
|
||||
*/
|
||||
function sleepSync(ms) {
|
||||
if (!(ms > 0)) return
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
||||
}
|
||||
|
||||
/** 一次 ssh:命令**作为单个 argv 元素**下发 ⇒ 远端 shell 解析引号,本地不过 shell。 */
|
||||
function ssh(port, target, command, timeoutMs) {
|
||||
return execFileSync('ssh', ['-p', String(port), '-o', 'BatchMode=yes', target, command], {
|
||||
@@ -383,7 +419,23 @@ function usage() {
|
||||
return (
|
||||
'用法:node scripts/overlay-probe.cjs [--table <参数表.md>] [--dir <目录>]\n' +
|
||||
' 🧪 夹具模式(⛔ 必须同时给 --table;不 ssh):\n' +
|
||||
' --listen-fixture <ss 原文> --nft-fixture <nft -j 原文> --status-fixture </status 原文>\n'
|
||||
' --listen-fixture <ss 原文> --nft-fixture <nft -j 原文> --status-fixture </status 原文>\n' +
|
||||
' --status-fixture-2 <第二次 /status 原文> ⬅️ 只有"增量"类判据(OBS-13)需要;缺省 = 与第一次同一份\n' +
|
||||
' --status-fixture-3 <第三次 /status 原文> ⬅️ 只有 OBS-16(门窗口)需要;缺省 = 与第二份同一份 ⇒ 会 FAIL 并点名\n' +
|
||||
' --instance-fixture <本机码>,<对端码> ⬅️ 只有 OBS-09 需要(实例面 HTTP 码);缺省 ⇒ 在册则 FAIL 并点名\n' +
|
||||
' --content-fixture <content 块 JSON> ⬅️ 只有 OBS-17 需要(内容面判别器);缺省 ⇒ 无 content 块 ⇒ FAIL 并点名\n' +
|
||||
// 序㉖:OBS-19(抖动块)/ OBS-20(容量余量)**不需要新参数** —— 数据源就是 `--status-fixture`/真机 `/status`。
|
||||
' 🆕 序㉖:OBS-19(`status.jitter` 结构+口径)/ OBS-20(`capacity.utilPct < utilMaxPct`)复用上面的 /status 源\n' +
|
||||
// 序㉗:OBS-21(每连接候选数 ≥ 2)**每侧一个夹具** —— 两侧数据来自**不同主机**的 journalctl。
|
||||
' 🆕 序㉗:OBS-21(每连接候选数 ≥ 2)--candidates-fixture-47 <journalctl 原文> --candidates-fixture-106 <journalctl 原文>\n' +
|
||||
' (某侧缺夹具文件 ⇒ 该侧 SKIP;全缺 ⇒ OBS-21 整体 SKIP ⇒ ⛔ 夹具模式绝不去 ssh)\n' +
|
||||
// 序㉘→单A:OBS-22(退出路径不杀实例)**每侧一个夹具** —— 内容 = 该机部署产物里三处 teardown 的原文段。
|
||||
' 🆕 序㉘单A:OBS-22(退出路径不杀实例)--teardown-fixture-47 <grep 原文> --teardown-fixture-106 <grep 原文>\n' +
|
||||
' (某侧缺夹具文件 ⇒ 该侧 SKIP;全缺 ⇒ OBS-22 整体 SKIP ⇒ ⛔ 夹具模式绝不去 ssh)\n' +
|
||||
// 序㉘→单B:OBS-23(组密钥加密)—— 数据源 = `content.crypto` 块(真机从 /status 取;夹具用 --content-crypto-fixture)。
|
||||
' 🆕 序㉘单B:OBS-23(组密钥加密)--content-crypto-fixture <crypto 块 JSON>\n' +
|
||||
' (缺省 ⇒ 从 `--content-fixture` / 真机 `content.crypto` 取;两处都没有 ⇒ **SKIP + 留痕**\n' +
|
||||
' = "缺省不启用"这一**合法状态**,⛔ 但绝不是"静默绿")\n'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -397,7 +449,30 @@ function main() {
|
||||
const listenFx = argOf(argv, '--listen-fixture')
|
||||
const nftFx = argOf(argv, '--nft-fixture')
|
||||
const statusFx = argOf(argv, '--status-fixture')
|
||||
const fixture = listenFx !== undefined || nftFx !== undefined || statusFx !== undefined
|
||||
const statusFx2 = argOf(argv, '--status-fixture-2')
|
||||
const statusFx3 = argOf(argv, '--status-fixture-3')
|
||||
const instFx = argOf(argv, '--instance-fixture')
|
||||
const contentFx = argOf(argv, '--content-fixture')
|
||||
const candFx47 = argOf(argv, '--candidates-fixture-47')
|
||||
const candFx106 = argOf(argv, '--candidates-fixture-106')
|
||||
// 序㉘→单A(OBS-22):每侧一份「该机部署产物里三处 teardown 的 grep 原文」。
|
||||
const teardownFx47 = argOf(argv, '--teardown-fixture-47')
|
||||
const teardownFx106 = argOf(argv, '--teardown-fixture-106')
|
||||
// 序㉘→单B(OBS-23):`content.crypto` 块(十个判别器键)。
|
||||
const cryptoFx = argOf(argv, '--content-crypto-fixture')
|
||||
const fixture =
|
||||
listenFx !== undefined ||
|
||||
nftFx !== undefined ||
|
||||
statusFx !== undefined ||
|
||||
statusFx2 !== undefined ||
|
||||
statusFx3 !== undefined ||
|
||||
instFx !== undefined ||
|
||||
contentFx !== undefined ||
|
||||
candFx47 !== undefined ||
|
||||
candFx106 !== undefined ||
|
||||
teardownFx47 !== undefined ||
|
||||
teardownFx106 !== undefined ||
|
||||
cryptoFx !== undefined
|
||||
if (fixture && argOf(argv, '--table') === undefined) {
|
||||
process.stderr.write('❌ 夹具模式必须配 --table <参数表副本>(避免误改生产参数表)\n')
|
||||
return EXIT_USAGE
|
||||
@@ -413,11 +488,29 @@ function main() {
|
||||
const r = makeReaders(table)
|
||||
|
||||
let status
|
||||
/**
|
||||
* **第二次** `/status` 采样 —— 只有"增量"类判据要它(`OBS-13` 的稳态帧率 = 两次读数之差)。
|
||||
*
|
||||
* 为什么必须真的**隔一段时间再取一次**:「稳态 0 帧」在单快照里**不可判**(累计量只增不减),
|
||||
* 而"因为读不到所以看起来是 0"正是本线反复踩的那类**假绿** ⇒ 故配 `ΔstatusHits ≥ 1` 作活性证明。
|
||||
* ⛔ 夹具模式下不允许用"同一份读两次"冒充增量:那会让 `OBS-13` 恒绿 ⇒ 用 `--status-fixture-2` 显式给第二份。
|
||||
*/
|
||||
let status2
|
||||
/**
|
||||
* **第三次** `/status` 采样 —— 只有 `OBS-16`(**门窗口**)要它,见文件头同名小节。
|
||||
* 窗口必须真的够长(≥ 若干倍轮询周期),否则"轮询还在跑"与"轮询停了"在计数上分不开。
|
||||
*/
|
||||
let status3
|
||||
let facts
|
||||
if (fixture) {
|
||||
process.stderr.write('⚠️ FIXTURE 本次为**夹具模式**:未连接任何远端,结论不得当生产判据\n')
|
||||
const primaryFx = statusFx ?? statusFx2
|
||||
try {
|
||||
status = statusFx === undefined ? { endpoints: [] } : JSON.parse(readFixture(statusFx))
|
||||
status = primaryFx === undefined ? { endpoints: [] } : JSON.parse(readFixture(primaryFx))
|
||||
// 第二份:缺省 = 与第一份同一份 ⇒ Δ=0;要造 FAIL 就显式给 `--status-fixture-2`。
|
||||
status2 = statusFx2 === undefined ? status : JSON.parse(readFixture(statusFx2))
|
||||
// 第三份(`OBS-16` 门窗口):同规则 —— 缺省 = 与第二份同一份 ⇒ Δ=0 ⇒ 会 FAIL 并点名。
|
||||
status3 = statusFx3 === undefined ? status2 : JSON.parse(readFixture(statusFx3))
|
||||
} catch (err) {
|
||||
process.stderr.write(`❌ 夹具装载失败(/status):${err.message}\n`)
|
||||
return EXIT_USAGE
|
||||
@@ -425,6 +518,12 @@ function main() {
|
||||
facts = new Map()
|
||||
if (listenFx !== undefined) facts.set('listenRaw', Buffer.from(readFixture(listenFx), 'utf8').toString('base64'))
|
||||
if (nftFx !== undefined) facts.set('nftJsonRaw', Buffer.from(readFixture(nftFx), 'utf8').toString('base64'))
|
||||
// OBS-09:实例面两个码(`<本机码>,<对端码>`)—— 夹具模式取不到远端 ⇒ 由调用方显式给。
|
||||
if (instFx !== undefined) {
|
||||
const pair = instFx.split(',')
|
||||
facts.set('instanceA', (pair[0] ?? '').trim())
|
||||
facts.set('instanceB', (pair[1] ?? '').trim())
|
||||
}
|
||||
} else {
|
||||
const sshPort = r.num('SSH_PORT')
|
||||
const target = r.need('SSH_TARGET_47')
|
||||
@@ -435,6 +534,26 @@ function main() {
|
||||
process.stderr.write(`❌ 取 /status 失败:${err.message}\n`)
|
||||
return EXIT_USAGE
|
||||
}
|
||||
// 第二次采样(间隔取自参数表 ⇒ ⛔ 脚本内无魔数):只为 `OBS-13` 的"增量"判据。
|
||||
sleepSync(r.num('PRESENCE_SAMPLE_GAP_MS'))
|
||||
try {
|
||||
status2 = JSON.parse(ssh(sshPort, target, `curl -s ${r.need('RELAY_STATUS_URL')}`, r.num('SSH_TIMEOUT_MS')))
|
||||
} catch (err) {
|
||||
process.stderr.write(`❌ 第二次取 /status 失败:${err.message}\n`)
|
||||
return EXIT_USAGE
|
||||
}
|
||||
/**
|
||||
* 第三次采样(`OBS-16` 的**门窗口**,窗口长度取自参数表):再读一次 `/status`,用
|
||||
* "窗口内**除本探针之外**没人读过它"判「订阅生效期间 `/status` 轮询真的停了」。
|
||||
* ⚠️ 这一睡是有意为之(窗口必须 ≥ 若干倍轮询周期,否则判据无分辨力);它只加在**真机**路径上。
|
||||
*/
|
||||
sleepSync(r.num('PRESENCE_GATE_WINDOW_MS'))
|
||||
try {
|
||||
status3 = JSON.parse(ssh(sshPort, target, `curl -s ${r.need('RELAY_STATUS_URL')}`, r.num('SSH_TIMEOUT_MS')))
|
||||
} catch (err) {
|
||||
process.stderr.write(`❌ 第三次取 /status 失败:${err.message}\n`)
|
||||
return EXIT_USAGE
|
||||
}
|
||||
const preEps = Array.isArray(status.endpoints) ? status.endpoints : []
|
||||
const prePeer = preEps.find((e) => e.port === r.num('PEER_INSTANCE_PORT'))
|
||||
const peerLocalPort0 = prePeer === undefined ? r.num('PEER_INSTANCE_PORT') : prePeer.localPort
|
||||
@@ -475,8 +594,36 @@ function main() {
|
||||
* ⚠️ `judged = true` 的行**仍按真实判据出 PASS/FAIL** —— `OBS-11` 就是它,
|
||||
* 否则夹具模式恒绿 ⇒ 整个"假绿实证"就假了。
|
||||
*/
|
||||
const add = (id, ok, text, judged = false) =>
|
||||
rows.push(fixture && judged !== true ? { id, ok: true, skip: true, text } : { id, ok, text })
|
||||
const add = (id, ok, text, judged = false, skip = false) =>
|
||||
rows.push(skip || (fixture && judged !== true) ? { id, ok: true, skip: true, text } : { id, ok, text })
|
||||
|
||||
/**
|
||||
* ── `OBS-09`:**在册 ⇒ 必须可达**(序 ⑳ 口径修正;详见文件头同名小节)──
|
||||
*
|
||||
* 两种模式**都要判** ⇒ 单独成函数,⛔ 不塞进 `!fixture` 分支(否则夹具模式证不了
|
||||
* "既能判 PASS 也能判 FAIL")。`eps` / `codeSet` / `add` 均已就绪。
|
||||
*/
|
||||
const judgeObs09 = () => {
|
||||
// 在册判据 = `/status.endpoints[]` 里有**该实例端口**且 `online === true`。
|
||||
const registered = new Set(eps.filter((e) => e.online === true).map((e) => Number(e.port)))
|
||||
const probes = [
|
||||
{ label: `本机:${r.need('LOCAL_INSTANCE_PORT')}`, port: r.num('LOCAL_INSTANCE_PORT'), code: Number(facts.get('instanceA')) },
|
||||
{ label: `${peerLabel}:${peerLocalPort}`, port: r.num('PEER_INSTANCE_PORT'), code: Number(facts.get('instanceB')) },
|
||||
]
|
||||
const inReg = probes.filter((p) => registered.has(p.port))
|
||||
const outReg = probes.filter((p) => !registered.has(p.port))
|
||||
const bad = inReg.filter((p) => !codeSet.includes(p.code))
|
||||
add(
|
||||
'OBS-09',
|
||||
bad.length === 0,
|
||||
`在册实例面 ${inReg.map((p) => `${p.label}=${p.code}`).join(' ') || '无'}` +
|
||||
` (阈值 ∈ {${codeSet.join(',')}})` +
|
||||
`|不在册 ${outReg.length} 项(记 SKIP 的依据):${outReg.map((p) => p.label).join(',') || '无'}` +
|
||||
`${bad.length > 0 ? ` |❌ ${bad.map((p) => `${p.label}=${p.code}`).join(' ')}` : ''}`,
|
||||
true,
|
||||
inReg.length === 0,
|
||||
)
|
||||
}
|
||||
|
||||
if (!fixture) {
|
||||
add('OBS-01', Number(cap.used) >= r.num('MIN_HOSTS'), `在册节点 used=${cap.used} (阈值 ≥ ${r.num('MIN_HOSTS')})`)
|
||||
@@ -516,13 +663,7 @@ function main() {
|
||||
eps.length > 0 && eps.every((e) => e.online === true),
|
||||
`端点表 ${eps.length} 条 / 离线 ${eps.filter((e) => e.online !== true).length} 条`,
|
||||
)
|
||||
const instanceA = Number(facts.get('instanceA'))
|
||||
const instanceB = Number(facts.get('instanceB'))
|
||||
add(
|
||||
'OBS-09',
|
||||
codeSet.includes(instanceA) && codeSet.includes(instanceB),
|
||||
`实例面 本机:${r.need('LOCAL_INSTANCE_PORT')}=${facts.get('instanceA')} ${peerLabel}:${peerLocalPort}=${facts.get('instanceB')} (阈值 ∈ {${codeSet.join(',')}})`,
|
||||
)
|
||||
judgeObs09()
|
||||
add('OBS-10', Number(facts.get('portal')) === r.num('PORTAL_CODE'), `门户=${facts.get('portal')} (阈值 = ${r.num('PORTAL_CODE')})`)
|
||||
} else {
|
||||
for (const id of [
|
||||
@@ -534,11 +675,11 @@ function main() {
|
||||
'OBS-06',
|
||||
'OBS-07',
|
||||
'OBS-08',
|
||||
'OBS-09',
|
||||
'OBS-10',
|
||||
]) {
|
||||
add(id, true, '夹具模式未取证')
|
||||
}
|
||||
judgeObs09()
|
||||
add('OBS-10', true, '夹具模式未取证')
|
||||
}
|
||||
|
||||
/* ── OBS-11:**集合判据**(三集包含式 + nft 入站 accept 白名单) ── */
|
||||
@@ -602,9 +743,753 @@ function main() {
|
||||
add('OBS-12', true, '夹具模式未取证')
|
||||
}
|
||||
|
||||
/* ── OBS-13/14/15:presence(序⑲)—— 判据**全在 `/status` 上** ⇒ 夹具模式也按真判据出 PASS/FAIL ── */
|
||||
// (⛔ 不是"夹具模式一律 SKIP":那样"假绿实证"就假了,与本线纪律相悖。)
|
||||
|
||||
const presence = Array.isArray(status.presence) ? status.presence : []
|
||||
const timing = status.presenceTiming ?? {}
|
||||
const sessions = Array.isArray(status.sessions) ? status.sessions : []
|
||||
const counters2 = (status2 ?? {}).counters ?? {}
|
||||
|
||||
/**
|
||||
* 陈旧度 p95 —— **只统计 `devices > 0` 的条目**:处于 grace 窗口(`devices = 0` 但仍判在线)
|
||||
* 的条目**本来就允许陈旧**(D3 最终一致,5–15 s 陈旧是设计值,⛔ 不是缺陷)⇒ 计进来就是假红。
|
||||
*/
|
||||
const staleAges = presence
|
||||
.filter((p) => Number(p.devices) > 0)
|
||||
.map((p) => Number(p.lastSeenAgoMs))
|
||||
.sort((a, b) => a - b)
|
||||
const p95 = staleAges.length === 0 ? 0 : staleAges[Math.max(0, Math.ceil(staleAges.length * 0.95) - 1)]
|
||||
|
||||
const timingOk =
|
||||
Number(timing.graceMs) === r.num('PRESENCE_GRACE_MS') &&
|
||||
Number(timing.offlineDebounceMs) === r.num('PRESENCE_OFFLINE_DEBOUNCE_MS') &&
|
||||
Number(timing.batchMs) === r.num('PRESENCE_BATCH_MS') &&
|
||||
Number(timing.ttlMs) === r.num('PRESENCE_TTL_MS') &&
|
||||
Number(timing.subMax) === r.num('PRESENCE_SUB_MAX')
|
||||
const presenceCounters = ['subs', 'pushed', 'snaps', 'rejected', 'statusHits']
|
||||
const countersOk = presenceCounters.every((k) => typeof counters[k] === 'number')
|
||||
const dPushed = Number(counters2.pushed) - Number(counters.pushed)
|
||||
const dHits = Number(counters2.statusHits) - Number(counters.statusHits)
|
||||
add(
|
||||
'OBS-13',
|
||||
timingOk &&
|
||||
countersOk &&
|
||||
Number(counters.snaps) <= Number(counters.pushed) &&
|
||||
dPushed <= r.num('PRESENCE_STEADY_FRAMES_MAX') &&
|
||||
dHits >= r.num('PRESENCE_SAMPLE_HITS_MIN'),
|
||||
`稳态帧率 Δpushed=${dPushed} (阈值 ≤ ${r.num('PRESENCE_STEADY_FRAMES_MAX')}),` +
|
||||
`活性 ΔstatusHits=${dHits} (阈值 ≥ ${r.num('PRESENCE_SAMPLE_HITS_MIN')}) |口径=${timingOk ? '一致' : '❌漂移'} ` +
|
||||
`判别器=${countersOk ? '齐' : '❌缺'} subs=${counters.subs} pushed=${counters.pushed} snaps=${counters.snaps} ` +
|
||||
`rejected=${counters.rejected} statusHits=${counters.statusHits}`,
|
||||
true,
|
||||
)
|
||||
|
||||
// E4:**有订阅者却一帧 `SNAP` 都没发过** ⇒ 首帧走的是"逐台拉"(N+1)那条老路。
|
||||
add(
|
||||
'OBS-14',
|
||||
countersOk && Number(counters.snaps) <= Number(counters.pushed) && (Number(counters.subs) === 0 || Number(counters.snaps) >= 1),
|
||||
`首帧即全量:snaps=${counters.snaps} pushed=${counters.pushed} subs=${counters.subs}(有订阅者 ⇒ snaps ≥ 1;⛔ N+1 会留 0)`,
|
||||
true,
|
||||
)
|
||||
|
||||
/**
|
||||
* 在线态**表不撒谎**:仍在活跃的会话必须能被 `presence[]` 覆盖到(`online = true`)。
|
||||
* ⚠️ 只判 `lastSeenAgoMs ≤ PRESENCE_TTL_MS` 的那些 —— 超过 TTL 的半开会话正处于
|
||||
* 「TTL 安全网 vs 会话 idle 超时」的灰区(两者都按 45 s),⛔ 计进来就是假红。
|
||||
*/
|
||||
const uncovered = sessions
|
||||
.filter((s) => Number(s.lastSeenAgoMs) <= r.num('PRESENCE_TTL_MS'))
|
||||
.filter((s) => {
|
||||
const p = presence.find((x) => x.name === s.name)
|
||||
return p === undefined || p.online !== true
|
||||
})
|
||||
.map((s) => s.name)
|
||||
add(
|
||||
'OBS-15',
|
||||
uncovered.length === 0 && (staleAges.length === 0 || p95 <= r.num('PRESENCE_STALE_P95_MAX_MS')),
|
||||
`在线态陈旧 p95=${p95}ms (阈值 ≤ ${r.num('PRESENCE_STALE_P95_MAX_MS')}ms,样本 ${staleAges.length} 条 devices>0),` +
|
||||
`活跃会话未覆盖 ${uncovered.length} 条${uncovered.length > 0 ? `:${uncovered.join(',')}` : ''}`,
|
||||
true,
|
||||
)
|
||||
|
||||
/**
|
||||
* ── `OBS-16`:**订阅生效期间 `/status` 轮询真的停了**(序㉑ · 在册缺陷 P-1 的验收)──
|
||||
*
|
||||
* 判据三件套(口径见文件头同名小节):① 窗口内命中增量 ≥ 1(探针自身那一次 = **活性证明**,
|
||||
* 把"读不到"与"确实为 0"分开)② ≤ `PRESENCE_GATE_HITS_MAX`(多出来的每一次都算"别人还在拉")
|
||||
* ③ `subs ≥ 1`("没人拉"不得是因为"没人订阅")。
|
||||
*/
|
||||
const counters3 = (status3 ?? {}).counters ?? {}
|
||||
const counters3Ok = presenceCounters.every((k) => typeof counters3[k] === 'number')
|
||||
const dHitsGate = Number(counters3.statusHits) - Number(counters2.statusHits)
|
||||
add(
|
||||
'OBS-16',
|
||||
timingOk &&
|
||||
counters3Ok &&
|
||||
Number(counters3.subs) >= 1 &&
|
||||
dHitsGate >= 1 &&
|
||||
dHitsGate <= r.num('PRESENCE_GATE_HITS_MAX'),
|
||||
`门窗口 ΔstatusHits=${dHitsGate}(≥ 1 = 探针自身读数|≤ ${r.num('PRESENCE_GATE_HITS_MAX')} = 除探针外**零**命中)` +
|
||||
`,窗口 ${r.num('PRESENCE_GATE_WINDOW_MS')}ms,期间 subs=${counters3.subs}` +
|
||||
`${counters3Ok ? '' : ' ❌ 判别器缺'}`,
|
||||
true,
|
||||
)
|
||||
|
||||
/**
|
||||
* ── `OBS-17`:**内容寻址判别器齐全 + 命中可断言**(序㉔ · 内容分发的验收)──
|
||||
*
|
||||
* 判据三件套(口径见参数表 §6 `OBS-17`):
|
||||
* ① **判别器存在性**:`content.source` 五档、`content.peer` 五键、`content.store` 七键
|
||||
* **全部**是 `number`。⛔ 缺一即 FAIL —— 这正是"静默放行"回头条件的机器判据
|
||||
* (本线反复踩:「只写日志」的实现让脚本无法断言,判据却显示全绿)。
|
||||
* ② **口径一致**:`content.blockSize == CONTENT_BLOCK_SIZE` 且
|
||||
* `content.storeMaxBytes == CONTENT_STORE_MAX_BYTES`(防"装配了但用的是另一套默认值")。
|
||||
* ③ **活性**:`local + peer` 的**命中计数**(第一份读)`≥ CONTENT_TIER_HITS_MIN`。
|
||||
* 🔴 为什么必须有 ③:只判 ①(判别器存在)会让"装了但一次都没命中"**全绿** ——
|
||||
* 而那正好是 E1 失败(全是回源)的样子。
|
||||
*
|
||||
* ⚠️ 夹具模式:`--content-fixture <content 块 JSON>`;缺省(真机模式读不到 `content` 块)⇒ FAIL 并点名。
|
||||
*/
|
||||
const contentBlock = (() => {
|
||||
if (contentFx !== undefined) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(contentFx, 'utf8'))
|
||||
} catch (err) {
|
||||
r.bad.push(`--content-fixture 解析失败:${err.message}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
// 真机模式:从第一份 `/status` 里取 `content` 块(缺 ⇒ FAIL 并点名)
|
||||
return (status ?? {}).content
|
||||
})()
|
||||
const SRC_TIERS_3 = ['local', 'peer', 'edge', 'region', 'origin']
|
||||
const PEER_KEYS_3 = ['peerHits', 'peerMisses', 'crossGroupDenied', 'declarations', 'withdrawn']
|
||||
const STORE_KEYS_3 = [
|
||||
'puts',
|
||||
'putRejected',
|
||||
'hits',
|
||||
'misses',
|
||||
'corruptReads',
|
||||
'evicted',
|
||||
'oversizeRejected',
|
||||
]
|
||||
{
|
||||
const c = contentBlock
|
||||
const missing = []
|
||||
if (c === undefined || typeof c !== 'object') {
|
||||
missing.push('content 块缺失')
|
||||
} else {
|
||||
const src = c.source ?? {}
|
||||
const pr = c.peer ?? {}
|
||||
const st = c.store ?? {}
|
||||
for (const k of SRC_TIERS_3) if (typeof src[k] !== 'number') missing.push(`source.${k}`)
|
||||
for (const k of PEER_KEYS_3) if (typeof pr[k] !== 'number') missing.push(`peer.${k}`)
|
||||
for (const k of STORE_KEYS_3) if (typeof st[k] !== 'number') missing.push(`store.${k}`)
|
||||
}
|
||||
const shapeOk = missing.length === 0
|
||||
// ② 口径一致
|
||||
const blockSizeOk = shapeOk && Number(c.blockSize) === r.num('CONTENT_BLOCK_SIZE')
|
||||
const storeMaxOk = shapeOk && Number(c.storeMaxBytes) === r.num('CONTENT_STORE_MAX_BYTES')
|
||||
// ③ 活性:local + peer 命中
|
||||
const tierHits = shapeOk ? Number(c.source.local) + Number(c.source.peer) : 0
|
||||
const activeOk = tierHits >= r.num('CONTENT_TIER_HITS_MIN')
|
||||
add(
|
||||
'OBS-17',
|
||||
shapeOk && blockSizeOk && storeMaxOk && activeOk,
|
||||
`内容面判别器 ${shapeOk ? '齐全' : `❌ 缺 ${missing.join(',')}`}` +
|
||||
`|口径 blockSize=${c?.blockSize}(阈值 ${r.num('CONTENT_BLOCK_SIZE')})` +
|
||||
` storeMax=${c?.storeMaxBytes}(阈值 ${r.num('CONTENT_STORE_MAX_BYTES')})` +
|
||||
`|活性 local+peer 命中=${tierHits}(阈值 ≥ ${r.num('CONTENT_TIER_HITS_MIN')})`,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ── `OBS-18`:**「逐步拉起」的认领逻辑在册 + 计数自洽**(序㉕ · 实例逐步拉起的验收)──
|
||||
*
|
||||
* 判据两件套(口径见参数表 §6 `OBS-18`):
|
||||
* ① 🔴 **在册(强判据)**:`dshs-worker` 的 journald 里存在 `[rehydrate]` 行。
|
||||
* **换回旧行为(启动即 `cleanAllStaleScopes()`)⇒ 永远不打这行 ⇒ 必红。**
|
||||
* 这正是本序"把清空换成认领"的可机器断言面 —— ⛔ 只写代码不留计数/日志的改法在这里过不去。
|
||||
* ② **计数自洽**:最近一条 `[rehydrate] summary {...}` 的键齐全,且
|
||||
* **`adopted + stopped === scanned`**(每条被扫到的 scope 都必须有处置结论 ——
|
||||
* ⛔ 不许"扫到了但既不认领也不停",那会让实例悬在两者之间)。
|
||||
*
|
||||
* ⚠️ **诚实标注**:本项**故意不**把"扫到几条"当判据 —— 认领发生在**进程启动时刻**,
|
||||
* 而探针是**事后**读 ⇒ 拿"当前 scope 数"去比一定假红(启动后才起的实例必然对不上)。
|
||||
* 故 `scanned/adopted/stopped` 只作**信息输出**,真机活性由 S6 的 E1/E3/E4 断言。
|
||||
*
|
||||
* ⚠️ 夹具模式:`--rehydrate-fixture <journalctl 原文>`;缺省 ⇒ **SKIP**(本项真机数据只能 ssh 取)。
|
||||
*/
|
||||
{
|
||||
const rehydrateFx = argOf(argv, '--rehydrate-fixture')
|
||||
let text
|
||||
if (rehydrateFx !== undefined) {
|
||||
try {
|
||||
text = fs.readFileSync(rehydrateFx, 'utf8')
|
||||
} catch (err) {
|
||||
r.bad.push(`--rehydrate-fixture 读取失败:${err.message}`)
|
||||
text = ''
|
||||
}
|
||||
} else if (fixture) {
|
||||
text = undefined
|
||||
} else {
|
||||
try {
|
||||
text = ssh(
|
||||
// ⚠️ 必须**在块内重取**:`sshPort` 在外层块的词法作用域里,本块取不到
|
||||
r.num('SSH_PORT'),
|
||||
r.need('SSH_TARGET_106'),
|
||||
`journalctl -u dshs-worker --no-pager -n 3000 2>/dev/null | grep -F "[rehydrate]" | tail -20`,
|
||||
r.num('SSH_TIMEOUT_MS'),
|
||||
)
|
||||
} catch (err) {
|
||||
r.bad.push(`106 journalctl 读取失败:${err.message}`)
|
||||
text = ''
|
||||
}
|
||||
}
|
||||
if (text === undefined) {
|
||||
add('OBS-18', true, '夹具模式未给 --rehydrate-fixture ⇒ SKIP(本项只能 ssh 取证)', false, true)
|
||||
} else {
|
||||
const lines = text.split(/\r?\n/).filter((l) => l.includes('[rehydrate]'))
|
||||
const summaryLine = [...lines].reverse().find((l) => l.includes('summary '))
|
||||
let sm
|
||||
if (summaryLine !== undefined) {
|
||||
const m = /\{[^\n]*\}\s*$/.exec(summaryLine.trim())
|
||||
if (m !== null) {
|
||||
try {
|
||||
sm = JSON.parse(m[0])
|
||||
} catch {
|
||||
sm = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
const keys = ['scanned', 'adopted', 'stopped', 'probeOk', 'probeFail', 'retained', 'notes']
|
||||
const missing = sm === undefined || typeof sm !== 'object' ? keys.slice() : keys.filter((k) => !(k in sm))
|
||||
const sumOk =
|
||||
sm !== undefined &&
|
||||
typeof sm === 'object' &&
|
||||
missing.length === 0 &&
|
||||
Number.isInteger(sm.scanned) &&
|
||||
Number.isInteger(sm.adopted) &&
|
||||
Number.isInteger(sm.stopped) &&
|
||||
sm.adopted + sm.stopped === sm.scanned
|
||||
add(
|
||||
'OBS-18',
|
||||
lines.length > 0 && sumOk,
|
||||
`认领日志行数=${lines.length}` +
|
||||
// 🔑 「读不到」与「确无该行」必须**可区分**(本线"静默放行"教训的机器判据):
|
||||
// 0 行时把 journalctl 原文字节数一并打出 —— 0 字节 = 读取失败,>0 字节 = 真没有该行。
|
||||
(lines.length === 0 ? `(journalctl 原文 ${text.length} 字节)` : '') +
|
||||
(sm === undefined || typeof sm !== 'object'
|
||||
? `|❌ 无 summary(缺 ${missing.join(',')})`
|
||||
: `|summary scanned=${sm.scanned} adopted=${sm.adopted} stopped=${sm.stopped}` +
|
||||
` probeOk=${sm.probeOk} probeFail=${sm.probeFail}` +
|
||||
(sumOk ? '' : `|❌ 自洽不成立(缺 ${missing.join(',') || 'adopted+stopped=scanned'})`)),
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ── `OBS-19`:**抖动观测块在册 + 口径一致**(序㉖ · 骨干稳定选路的验收)──
|
||||
*
|
||||
* 判据三件套(口径见参数表 §6 `OBS-19`):
|
||||
* ① **判别器齐全**:`/status.jitter` 的 `p95AbsDeltaMs` / `meanAbsDeltaMs` / `maxAbsDeltaMs` /
|
||||
* `samples` / `deltas` / `sessions` / `thresholdMs` / `alerts` 必须**都是 `number`**,
|
||||
* `overThreshold` 必须是 `boolean`,`hist` 必须是**数组**。
|
||||
* ⛔ 缺一即 FAIL —— "没样本就少一个键"会让探针分不清「**没装**」与「**装了但还没采到**」
|
||||
* (本线两处静默失效就是这么漏过去的)。
|
||||
* ② **口径一致**:`jitter.hist.length === JITTER_HIST_BUCKETS` 且
|
||||
* `jitter.thresholdMs === JITTER_LIMIT_MS`(防"装了但用的是另一套默认值 / 另一个键")。
|
||||
* ③ ① 本身即覆盖"**无样本也必须结构完整**"(`sessions === 0` 时上述键依然全在)。
|
||||
*
|
||||
* ⚠️ 夹具模式:数据源 = `--status-fixture` 里的 `jitter` 块(`judged=true` ⇒ 夹具模式照判)。
|
||||
*/
|
||||
{
|
||||
const j = (status ?? {}).jitter
|
||||
const NUM_KEYS_19 = [
|
||||
'p95AbsDeltaMs',
|
||||
'meanAbsDeltaMs',
|
||||
'maxAbsDeltaMs',
|
||||
'samples',
|
||||
'deltas',
|
||||
'sessions',
|
||||
'thresholdMs',
|
||||
'alerts',
|
||||
]
|
||||
const missing19 = []
|
||||
if (j === undefined || typeof j !== 'object') missing19.push('jitter 块缺失')
|
||||
else {
|
||||
for (const k of NUM_KEYS_19) if (typeof j[k] !== 'number') missing19.push(k)
|
||||
if (typeof j.overThreshold !== 'boolean') missing19.push('overThreshold')
|
||||
if (!Array.isArray(j.hist)) missing19.push('hist')
|
||||
}
|
||||
const shape19 = missing19.length === 0
|
||||
const buckets19 = shape19 && j.hist.length === r.num('JITTER_HIST_BUCKETS')
|
||||
const thr19 = shape19 && Number(j.thresholdMs) === r.num('JITTER_LIMIT_MS')
|
||||
add(
|
||||
'OBS-19',
|
||||
shape19 && buckets19 && thr19,
|
||||
`抖动块 ${shape19 ? '齐全' : `❌ 缺 ${missing19.join(',')}`}` +
|
||||
`|p95|ΔRTT|=${j?.p95AbsDeltaMs} sessions=${j?.sessions} overThreshold=${j?.overThreshold} alerts=${j?.alerts}` +
|
||||
`|口径 hist=${Array.isArray(j?.hist) ? j.hist.length : 'n/a'}(阈值 ${r.num('JITTER_HIST_BUCKETS')})` +
|
||||
` thresholdMs=${j?.thresholdMs}(阈值 ${r.num('JITTER_LIMIT_MS')})`,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ── `OBS-20`:**容量余量仍在(⛔ 没打满)**(序㉖ · `E4` 的验收)──
|
||||
*
|
||||
* 判据两件套(口径见参数表 §6 `OBS-20`):
|
||||
* ① `capacity.utilPct` / `capacity.utilMaxPct` 必须**都是 `number`**(⛔ 不许少键);
|
||||
* ② **`utilPct < utilMaxPct`** ⇒ 新接入仍被接受(**余量 > 0**)。
|
||||
* ⚠️ 方向是"**小于**":`utilPct ≥ utilMaxPct` 说明**软门已在拦新节点** ⇒ 对"骨干应留
|
||||
* 30%+ 余量"的口径就是**不合格**(要扩容,⛔ 不是改判据凑绿)。
|
||||
*/
|
||||
{
|
||||
const c20 = (status ?? {}).capacity
|
||||
const shape20 =
|
||||
c20 !== undefined && typeof c20 === 'object' && typeof c20.utilPct === 'number' && typeof c20.utilMaxPct === 'number'
|
||||
const utilPct20 = shape20 ? Number(c20.utilPct) : NaN
|
||||
const utilMax20 = shape20 ? Number(c20.utilMaxPct) : NaN
|
||||
const ok20 = shape20 && (utilMax20 <= 0 || utilPct20 < utilMax20)
|
||||
add(
|
||||
'OBS-20',
|
||||
ok20,
|
||||
shape20
|
||||
? `容量 used=${c20.used} max=${c20.max}|利用率 ${utilPct20}% < 软门 ${utilMax20}%` +
|
||||
`(余量 ${utilMax20 > 0 ? Math.round((utilMax20 - utilPct20) * 100) / 100 : '不限'} 个百分点)` +
|
||||
`|软门拒接计数 utilRefused=${(status ?? {}).counters?.utilRefused ?? 'n/a'}` +
|
||||
(ok20 ? '' : '|❌ 已打满软门(新接入被拒 ⇒ 余量不足)')
|
||||
: '❌ capacity.utilPct / utilMaxPct 缺失(⛔ 不许少键)',
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ── `OBS-21`:**每连接候选数 ≥ 2**(序㉗ · E3「路径多样性」的验收)──
|
||||
*
|
||||
* 数据源 = **各连接路径自己的观测行**(`CAND_OBS_PREFIX`,由 `relay-tunnel.ts#RelayCandidateObservation`
|
||||
* 在**每次真实解析**时写、并按 `RELAY_CAND_OBS_MS` 周期重发上次快照):
|
||||
* `[overlay-candidates] scope=… resolves=… count=… hosts=… source=… detail=… urls=…`
|
||||
*
|
||||
* 判据两件套(口径见参数表 §6 `OBS-21`):
|
||||
* ① **该路径有观测行**(⛔ 缺行即 FAIL —— 并把 journalctl **原文字节数**一并打出:
|
||||
* `0 字节` = 读取失败,`>0 字节` = 真没有该行。本线的"静默放行"教训要求两者**可区分**);
|
||||
* ② **`count ≥ CAND_MIN`**(每连接候选数 ≥ 2)。
|
||||
*
|
||||
* 🔑 **`hosts`(主机名个数)只作信息输出、⛔ 不作判据** —— 且它**不是「独立物理路径数」**:
|
||||
* 观测器**不解析 DNS**(零网络),而生产目录前两条候选 `alotbuy.com` 与 `relay-direct.alotbuy.com`
|
||||
* **摘名不同、同落 47** ⇒ **真机实测 `count=3` 时 `hosts` 也报 3**,而机器级独立路径只有 **2**(47 + 106)。
|
||||
* 故本项**照实判「条数」**,把 `hosts` 打出来供人判「冗余建成」,⛔ **不放宽判据凑绿**;
|
||||
* 真机首轮读数(2026-09-18 00:0x)已实证 **worker 侧 `count=1`**(根因见回报)。
|
||||
*
|
||||
* ⚠️ 夹具模式:`--candidates-fixture-47` / `--candidates-fixture-106`(journalctl 原文);
|
||||
* 某侧缺夹具文件 ⇒ **该侧 SKIP**,全侧都缺 ⇒ 本项 SKIP(⛔ 不在夹具模式下去 ssh)。
|
||||
*/
|
||||
{
|
||||
/** ⛔ 前缀**不在脚本里硬编码** —— 与阈值同一条纪律:参数表 = 探针的唯一取数来源。 */
|
||||
const CAND_OBS_PREFIX = r.need('CAND_OBS_PREFIX')
|
||||
const cmin = r.num('CAND_MIN')
|
||||
/** 每条连接路径 = 一个 relay 客户端单元;`CAND_OBS_UNITS_*` 的值格是 `unit=scope,unit=scope`。 */
|
||||
const targets = [
|
||||
{ host: '47', units: r.need('CAND_OBS_UNITS_47'), fx: candFx47 },
|
||||
{ host: '106', units: r.need('CAND_OBS_UNITS_106'), fx: candFx106 },
|
||||
]
|
||||
const paths = []
|
||||
for (const t of targets) {
|
||||
for (const spec of t.units.split(',').map((s) => s.trim()).filter((s) => s !== '')) {
|
||||
const eq = spec.indexOf('=')
|
||||
const unit = (eq < 0 ? spec : spec.slice(0, eq)).trim()
|
||||
const scope = (eq < 0 ? '' : spec.slice(eq + 1)).trim()
|
||||
paths.push({ label: `${unit}@${t.host}`, unit, scope, host: t.host, fx: t.fx })
|
||||
}
|
||||
}
|
||||
const seen = []
|
||||
let anyJudged = false
|
||||
let allOk = true
|
||||
for (const p of paths) {
|
||||
let text
|
||||
if (p.fx !== undefined) {
|
||||
try {
|
||||
text = fs.readFileSync(p.fx, 'utf8')
|
||||
} catch (err) {
|
||||
r.bad.push(`--candidates-fixture-${p.host} 读取失败:${err.message}`)
|
||||
text = ''
|
||||
}
|
||||
} else if (fixture) {
|
||||
seen.push(`${p.label} SKIP(夹具模式未给 --candidates-fixture-${p.host})`)
|
||||
continue
|
||||
} else {
|
||||
try {
|
||||
text = ssh(
|
||||
r.num('SSH_PORT'),
|
||||
r.need(p.host === '47' ? 'SSH_TARGET_47' : 'SSH_TARGET_106'),
|
||||
`journalctl -u ${p.unit} --no-pager -n 3000 2>/dev/null | grep -F "${CAND_OBS_PREFIX}" | tail -5`,
|
||||
r.num('SSH_TIMEOUT_MS'),
|
||||
)
|
||||
} catch (err) {
|
||||
r.bad.push(`${p.label} journalctl 读取失败:${err.message}`)
|
||||
text = ''
|
||||
}
|
||||
}
|
||||
anyJudged = true
|
||||
const lines = text.split(/\r?\n/).filter((l) => l.includes(CAND_OBS_PREFIX))
|
||||
/**
|
||||
* ⚠️ **夹具模式下一份文件可能含多台/多个单元的混合行**(47 上就有 `manager` + `worker` 两个
|
||||
* scope)⇒ 必须按 `scope=` 过滤;真机路径已用 `journalctl -u <unit>` 天然分单元,
|
||||
* 这里再过一遍 `scope` 是为了**核对接线**(⛔ 不一致即 FAIL —— 那是接线错,不是观测错)。
|
||||
*/
|
||||
const mine = lines.filter((l) => {
|
||||
const m = /(?:^|\s)scope=([^\s]*)/.exec(l)
|
||||
return m !== null && m[1] === p.scope
|
||||
})
|
||||
const line = mine.length > 0 ? mine[mine.length - 1] : undefined
|
||||
if (line === undefined) {
|
||||
allOk = false
|
||||
// 🔑 「读不到」与「确无该行」必须可区分:0 字节 = 读取失败,>0 字节 = 真没有该行。
|
||||
seen.push(
|
||||
`${p.label} ❌ 无 scope=${p.scope} 的观测行(journalctl 原文 ${text.length} 字节` +
|
||||
`/含前缀的行 ${lines.length} 条)`,
|
||||
)
|
||||
continue
|
||||
}
|
||||
const grab = (k) => {
|
||||
const m = new RegExp(`(?:^|\\s)${k}=([^\\s]*)`).exec(line)
|
||||
return m === null ? undefined : m[1]
|
||||
}
|
||||
const count = Number(grab('count'))
|
||||
const hosts = Number(grab('hosts'))
|
||||
const shapeOk = Number.isInteger(count) && Number.isInteger(hosts) && count >= 0
|
||||
const ok = shapeOk && count >= cmin
|
||||
if (!ok) allOk = false
|
||||
seen.push(
|
||||
`${p.label} ` +
|
||||
(shapeOk
|
||||
? `count=${count} hosts=${hosts}${ok ? ' ✓' : ` ❌(判据 ≥ ${cmin})`}` +
|
||||
` source=${grab('source')} urls=${grab('urls')}`
|
||||
: `❌ 观测行解析失败(${line.trim().slice(0, 160)})`),
|
||||
)
|
||||
}
|
||||
add(
|
||||
'OBS-21',
|
||||
anyJudged && allOk,
|
||||
`每连接候选数(判据 ≥ ${cmin}):${seen.join('|')}` +
|
||||
(anyJudged ? '' : '|❌ 全部路径 SKIP(未取到任何观测行)'),
|
||||
anyJudged,
|
||||
!anyJudged,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ── `OBS-22`:**退出路径不杀实例**(序㉘ → 单 A · 候选 `B` 的验收)──
|
||||
*
|
||||
* 序㉕ 查明:「Manager 重启后逐步拉起既有实例」的真凶 = `LocalSpawner.teardown()` 在 SIGTERM
|
||||
* 退出路径上**逐个停掉在册实例** ⇒ 启动认领 `rehydrateAdoptedScopes()` 永远扫不到存量。
|
||||
* 候选 `B` = 三处 `teardown()` 一起改:**退出进程不再停实例**。
|
||||
*
|
||||
* 判据两件套(口径见参数表 §6 `OBS-22`):
|
||||
* ① **静态守卫(强判据 · 判别力在此)**:读**部署中的** `lib/supervisor/{orchestrator,
|
||||
* remote-spawner,leased-spawner}.js` 里三处 `teardown()` 的函数体窗口,要求
|
||||
* ⓐ 带守卫标记 `TEARDOWN_GUARD_MARKER`;ⓑ 窗口内**不出现** `TEARDOWN_FORBIDDEN_TOKENS` 任何一项。
|
||||
* **旧产物 ⇒ 无标记 + `orchestrator` 体里含 `stop(` ⇒ 必红**(= S4「真机先红」的可机器断言面)。
|
||||
* 🔑 判**部署产物**而不是 `src/`:`lib/` 才是真正在跑的那一份(本线已有"src 改了但没 build/没铺"的实证)。
|
||||
* ② **认领面(信息输出为主)**:该机 `TEARDOWN_UNIT_*` 的最近一条 `[rehydrate] summary` 若存在,
|
||||
* 要求键齐全且 **`adopted + stopped === scanned`**,且 **`scanned > 0 ⇒ adopted > 0`**
|
||||
* (扫到存量却一条都没认领 ⇒ 被杀了);**`scanned === 0` 只作信息输出、⛔ 不判红**。
|
||||
*
|
||||
* 🔴 **为什么 ② 里 `scanned > 0` 不能无条件当判据(本单的诚实口径,可推翻)**:
|
||||
* `scanned === 0` 在日志上**有两种不可区分的成因** —— ⓐ 实例被退出路径杀了(旧行为的现场)
|
||||
* ⓑ 启动时本来就没有存量实例(平台**正常空闲态**,两机实测 `0/0`)。
|
||||
* 把 `scanned > 0` 设成无条件判据 ⇒ 空闲态**永久红** ⇒ 判据灵敏度被磨掉(本线已明文反对
|
||||
* "长期挂一条红项")。⇒ 真正的判别交给 ①(不依赖有没有实例);② 只在"确有 summary"时收紧。
|
||||
* ⚠️ 故「重启前后 scope 数守恒」这条**在本探针里无法只读完成**(要守恒就得先重启生产)——
|
||||
* 它由执行棒 S5/S6 的**真机演练**举证(原文见交接单 §8.7),⛔ 探针不冒充。
|
||||
*
|
||||
* ⚠️ 夹具模式:`--teardown-fixture-47` / `--teardown-fixture-106`(**grep 原文**);
|
||||
* 某侧缺夹具文件 ⇒ **该侧 SKIP**,两侧都缺 ⇒ 本项 SKIP(⛔ 不在夹具模式下去 ssh)。
|
||||
* ② 在夹具模式复用 `--rehydrate-fixture`(与 `OBS-18` 同一个参数)。
|
||||
*/
|
||||
{
|
||||
const MARKER = r.need('TEARDOWN_GUARD_MARKER')
|
||||
const TOKENS = r
|
||||
.need('TEARDOWN_FORBIDDEN_TOKENS')
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s !== '')
|
||||
const FILES = ['orchestrator', 'remote-spawner', 'leased-spawner']
|
||||
const rehydrateFx = argOf(argv, '--rehydrate-fixture')
|
||||
const targets = [
|
||||
{
|
||||
host: '47',
|
||||
lib: r.need('TEARDOWN_LIB_47'),
|
||||
unit: r.need('TEARDOWN_UNIT_47'),
|
||||
fx: teardownFx47,
|
||||
},
|
||||
{
|
||||
host: '106',
|
||||
lib: r.need('TEARDOWN_LIB_106'),
|
||||
unit: r.need('TEARDOWN_UNIT_106'),
|
||||
fx: teardownFx106,
|
||||
},
|
||||
]
|
||||
const seen = []
|
||||
let anyJudged = false
|
||||
let allOk = true
|
||||
for (const t of targets) {
|
||||
let lib
|
||||
if (t.fx !== undefined) {
|
||||
try {
|
||||
lib = fs.readFileSync(t.fx, 'utf8')
|
||||
} catch (err) {
|
||||
r.bad.push(`--teardown-fixture-${t.host} 读取失败:${err.message}`)
|
||||
lib = ''
|
||||
}
|
||||
} else if (fixture) {
|
||||
seen.push(`${t.host} SKIP(夹具模式未给 --teardown-fixture-${t.host})`)
|
||||
continue
|
||||
} else {
|
||||
try {
|
||||
lib = ssh(
|
||||
r.num('SSH_PORT'),
|
||||
r.need(t.host === '47' ? 'SSH_TARGET_47' : 'SSH_TARGET_106'),
|
||||
FILES.map(
|
||||
(f) => `echo "## ${f}"; grep -A7 "async teardown" ${t.lib}/${f}.js 2>/dev/null | head -8`,
|
||||
).join('; '),
|
||||
r.num('SSH_TIMEOUT_MS'),
|
||||
)
|
||||
} catch (err) {
|
||||
r.bad.push(`${t.host} 部署产物读取失败:${err.message}`)
|
||||
lib = ''
|
||||
}
|
||||
}
|
||||
anyJudged = true
|
||||
|
||||
/* ① 静态守卫:逐文件取 `## <name>` 段,核对标记 + 禁词 */
|
||||
const segments = new Map()
|
||||
{
|
||||
let cur
|
||||
for (const line of lib.split(/\r?\n/)) {
|
||||
const h = /^## (.+)$/.exec(line.trim())
|
||||
if (h !== null) {
|
||||
cur = h[1]
|
||||
segments.set(cur, [])
|
||||
continue
|
||||
}
|
||||
if (cur !== undefined) segments.get(cur).push(line)
|
||||
}
|
||||
}
|
||||
const offenders = []
|
||||
const noMarker = []
|
||||
for (const f of FILES) {
|
||||
const body = segments.get(f)
|
||||
if (body === undefined || body.join('').trim() === '') {
|
||||
offenders.push(`${f}:取不到 teardown 段`)
|
||||
continue
|
||||
}
|
||||
if (!body.join('\n').includes(MARKER)) noMarker.push(f)
|
||||
for (const line of body) {
|
||||
for (const tok of TOKENS) if (line.includes(tok)) offenders.push(`${f}「${line.trim().slice(0, 90)}」`)
|
||||
}
|
||||
}
|
||||
const staticOk = offenders.length === 0 && noMarker.length === 0
|
||||
if (!staticOk) allOk = false
|
||||
|
||||
/* ② 认领面:有 summary 才收紧(`scanned === 0` 只作信息 —— 见上方 🔴 口径说明) */
|
||||
let text
|
||||
if (t.fx !== undefined) {
|
||||
if (rehydrateFx !== undefined) {
|
||||
try {
|
||||
text = fs.readFileSync(rehydrateFx, 'utf8')
|
||||
} catch (err) {
|
||||
r.bad.push(`--rehydrate-fixture 读取失败:${err.message}`)
|
||||
text = ''
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
text = ssh(
|
||||
r.num('SSH_PORT'),
|
||||
r.need(t.host === '47' ? 'SSH_TARGET_47' : 'SSH_TARGET_106'),
|
||||
`journalctl -u ${t.unit} --no-pager -n 3000 2>/dev/null | grep -F "[rehydrate]" | tail -20`,
|
||||
r.num('SSH_TIMEOUT_MS'),
|
||||
)
|
||||
} catch (err) {
|
||||
r.bad.push(`${t.host} rehydrate 日志读取失败:${err.message}`)
|
||||
text = ''
|
||||
}
|
||||
}
|
||||
let claim
|
||||
if (text === undefined) {
|
||||
claim = '认领面:未取到(夹具模式未给 --rehydrate-fixture)'
|
||||
} else {
|
||||
const lines = text.split(/\r?\n/).filter((l) => l.includes('[rehydrate]'))
|
||||
const summaryLine = [...lines].reverse().find((l) => l.includes('summary '))
|
||||
let sm
|
||||
if (summaryLine !== undefined) {
|
||||
const m = /\{[^\n]*\}\s*$/.exec(summaryLine.trim())
|
||||
if (m !== null) {
|
||||
try {
|
||||
sm = JSON.parse(m[0])
|
||||
} catch {
|
||||
sm = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sm === undefined || typeof sm !== 'object') {
|
||||
// 🔑 「读不到」与「确无该行」可分:0 字节 = 读取失败,>0 字节 = 真没有该行。
|
||||
claim = `认领面:无 summary(journalctl 原文 ${text.length} 字节)⇒ 无存量可比、只报信息`
|
||||
} else {
|
||||
const keys = ['scanned', 'adopted', 'stopped', 'probeOk', 'probeFail', 'retained', 'notes']
|
||||
const missing = keys.filter((k) => !(k in sm))
|
||||
const selfOk =
|
||||
missing.length === 0 &&
|
||||
Number.isInteger(sm.scanned) &&
|
||||
Number.isInteger(sm.adopted) &&
|
||||
Number.isInteger(sm.stopped) &&
|
||||
sm.adopted + sm.stopped === sm.scanned
|
||||
const noKillOk = !(sm.scanned > 0 && sm.adopted === 0)
|
||||
const claimOk = selfOk && noKillOk
|
||||
if (!claimOk) allOk = false
|
||||
claim =
|
||||
`认领面:scanned=${sm.scanned} adopted=${sm.adopted} stopped=${sm.stopped}` +
|
||||
` probeOk=${sm.probeOk}` +
|
||||
(sm.scanned === 0
|
||||
? `(scanned=0 ⇒ 无存量可比、不判红)`
|
||||
: claimOk
|
||||
? ' ✓'
|
||||
: `❌${selfOk ? '' : ` 自洽不成立(缺 ${missing.join(',') || 'adopted+stopped=scanned'})`}` +
|
||||
`${noKillOk ? '' : ' 扫到存量但一条未认领 ⇒ 疑似被退出路径杀掉'}`)
|
||||
}
|
||||
}
|
||||
|
||||
seen.push(
|
||||
`${t.host} ` +
|
||||
(staticOk ? '静态守卫 ✓' : `❌${noMarker.length > 0 ? ` 缺标记(${noMarker.join(',')})` : ''}` +
|
||||
`${offenders.length > 0 ? ` 停实例命中 ${offenders.join(' / ')}` : ''}`) +
|
||||
`|${claim}`,
|
||||
)
|
||||
}
|
||||
add(
|
||||
'OBS-22',
|
||||
anyJudged && allOk,
|
||||
`退出路径不杀实例(三处 teardown 静态守卫 + 认领面自洽):${seen.join('|')}` +
|
||||
(anyJudged ? '' : '|❌ 两侧全 SKIP(未取到任何 teardown 段)'),
|
||||
anyJudged,
|
||||
!anyJudged,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ── `OBS-23`:**组密钥加密已启用且"共享不退化、明文不出现"自证成立**(序㉘ · 单 B)──
|
||||
*
|
||||
* 判据三件套(口径见参数表 §6 `OBS-23` / §3.9):
|
||||
* ① **判别器齐全**:`content.crypto` 的十个键**全部**是 `number`(⛔ 缺一即 FAIL ——
|
||||
* 这正是"静默放行"的机器判据);🔴 **且**(真机)两台机上的密钥文件 `stat -c %a`
|
||||
* 必须 = `CONTENT_KEY_FILE_MODE`(**启用了加密却拿不到 `0600` 的密钥文件 ⇒ 判据面不自洽**)。
|
||||
* ② **F1 + F2**:F1 = `detChecks ≥ CONTENT_CRYPTO_DET_MIN` 且 `detMismatches = 0`
|
||||
* (同明文两次 ⇒ 密文逐字节相同);F2 = `decrypts ≥ CONTENT_CRYPTO_DECRYPT_MIN` 且
|
||||
* `decryptRejected = 0` 且 `source.local + source.peer ≥ CONTENT_TIER_HITS_MIN`
|
||||
* (共享**没有**退化成"次次回源" —— 阈值与 `OBS-17` ③ **同源**,⛔ 不另立一套)。
|
||||
* ③ **F4①:明文不出现**:`plainScans ≥ CONTENT_CRYPTO_SCAN_MIN` 且 `plainLeaks = 0`
|
||||
* (对**自己刚加密出的字节**做字节级扫描 —— 命中即 FAIL)。
|
||||
*
|
||||
* 🔴 **`decryptRejected` 的方向**:稳态必须 **= 0**。它一旦增长,说明有节点密钥 / epoch 不一致
|
||||
* (单 B §9-6 的回头条件)⇒ ⛔ 不许"重启一次看看"、⛔ 不许放宽判据。
|
||||
*
|
||||
* ⚠️ **"缺省不启用" = SKIP + 留痕**(合法状态),⛔ 但**不许**既不是 SKIP 也不是 FAIL 的"静默绿"。
|
||||
* ⚠️ 夹具模式:`--content-crypto-fixture <crypto 块 JSON>`;缺省 ⇒ 从 `--content-fixture` 里的
|
||||
* `crypto` 子块取;再没有 ⇒ SKIP。⛔ **夹具模式绝不去 ssh**(与 `OBS-21` / `OBS-22` 同纪律)。
|
||||
*/
|
||||
{
|
||||
const CRYPTO_NUM_KEYS = [
|
||||
'encrypts',
|
||||
'decrypts',
|
||||
'decryptRejected',
|
||||
'epochs',
|
||||
'epoch',
|
||||
'epochExpired',
|
||||
'detChecks',
|
||||
'detMismatches',
|
||||
'plainScans',
|
||||
'plainLeaks',
|
||||
]
|
||||
let cryptoBlock
|
||||
if (cryptoFx !== undefined) {
|
||||
try {
|
||||
cryptoBlock = JSON.parse(fs.readFileSync(cryptoFx, 'utf8'))
|
||||
} catch (err) {
|
||||
r.bad.push(`--content-crypto-fixture 解析失败:${err.message}`)
|
||||
cryptoBlock = undefined
|
||||
}
|
||||
} else if (contentBlock !== undefined && typeof contentBlock === 'object') {
|
||||
cryptoBlock = contentBlock.crypto
|
||||
}
|
||||
if (cryptoBlock === undefined || typeof cryptoBlock !== 'object') {
|
||||
add('OBS-23', false, '⛔ 未启用组密钥加密(content.crypto 块缺席)⇒ SKIP + 留痕(缺省不启用是合法状态,⛔ 非静默绿)', false, true)
|
||||
} else {
|
||||
const missing = CRYPTO_NUM_KEYS.filter((k) => typeof cryptoBlock[k] !== 'number')
|
||||
const shapeOk = missing.length === 0
|
||||
// ①-b(真机):密钥文件必须存在且权限 = 期望值
|
||||
// 🔴 门 = `!fixture`(⛔ **不是** `cryptoFx === undefined`)—— 夹具模式**绝不去 ssh**,
|
||||
// 这是 `OBS-21` / `OBS-22` 同款纪律;用 `cryptoFx` 当门会让"给了 `--content-fixture`
|
||||
// 但没给 `--content-crypto-fixture`"这条路径**偷偷 ssh** 到生产机(契约面不一致)。
|
||||
let modeClaim = '密钥文件权限:夹具模式不查'
|
||||
let modeOk = true
|
||||
if (!fixture) {
|
||||
const want = r.num('CONTENT_KEY_FILE_MODE')
|
||||
const file = r.need('CONTENT_KEY_FILE')
|
||||
const seenMode = []
|
||||
for (const host of ['47', '106']) {
|
||||
try {
|
||||
const out = ssh(
|
||||
r.num('SSH_PORT'),
|
||||
r.need(host === '47' ? 'SSH_TARGET_47' : 'SSH_TARGET_106'),
|
||||
`stat -c %a ${file} 2>/dev/null`,
|
||||
r.num('SSH_TIMEOUT_MS'),
|
||||
)
|
||||
const mode = String(out ?? '').trim()
|
||||
seenMode.push(`${host}=${mode === '' ? '(原文字节 0 ⇒ 读不到 / 无该文件)' : mode}`)
|
||||
if (mode !== String(want)) modeOk = false
|
||||
} catch (err) {
|
||||
seenMode.push(`${host}=❌读取失败(${err.message})`)
|
||||
modeOk = false
|
||||
}
|
||||
}
|
||||
modeClaim = `密钥文件 ${file} 权限须=${want}:${seenMode.join(' ')}`
|
||||
}
|
||||
const detMin = r.num('CONTENT_CRYPTO_DET_MIN')
|
||||
const decMin = r.num('CONTENT_CRYPTO_DECRYPT_MIN')
|
||||
const scanMin = r.num('CONTENT_CRYPTO_SCAN_MIN')
|
||||
// ② F1 + F2(F2 的阈值与 OBS-17 ③ 同源)
|
||||
const detOk = Number(cryptoBlock.detChecks) >= detMin && Number(cryptoBlock.detMismatches) === 0
|
||||
const tierHits = Number(contentBlock?.source?.local ?? 0) + Number(contentBlock?.source?.peer ?? 0)
|
||||
const shareOk =
|
||||
Number(cryptoBlock.decrypts) >= decMin &&
|
||||
Number(cryptoBlock.decryptRejected) === 0 &&
|
||||
tierHits >= r.num('CONTENT_TIER_HITS_MIN')
|
||||
// ③ F4①(明文不出现)
|
||||
const plainOk = Number(cryptoBlock.plainScans) >= scanMin && Number(cryptoBlock.plainLeaks) === 0
|
||||
const ok = shapeOk && modeOk && detOk && shareOk && plainOk
|
||||
add(
|
||||
'OBS-23',
|
||||
ok,
|
||||
`组密钥加密(GCM 确定性)判别器 ${shapeOk ? '齐全' : `❌ 缺 ${missing.join(',')}`}` +
|
||||
`|F1 确定性 detChecks=${cryptoBlock.detChecks}(阈值 ≥ ${detMin}) detMismatches=${cryptoBlock.detMismatches}(须 0)` +
|
||||
`|F2 共享 decrypts=${cryptoBlock.decrypts}(阈值 ≥ ${decMin}) decryptRejected=${cryptoBlock.decryptRejected}(须 0)` +
|
||||
` local+peer 命中=${tierHits}(阈值 ≥ ${r.num('CONTENT_TIER_HITS_MIN')})` +
|
||||
`|F4① 明文扫描 plainScans=${cryptoBlock.plainScans}(阈值 ≥ ${scanMin}) plainLeaks=${cryptoBlock.plainLeaks}(须 0)` +
|
||||
`|epoch=${cryptoBlock.epoch}/${cryptoBlock.epochs} epochExpired=${cryptoBlock.epochExpired}` +
|
||||
`|${modeClaim}`,
|
||||
true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = fixture ? '⚠️ FIXTURE ' : ''
|
||||
for (const row of rows) {
|
||||
process.stdout.write(`${prefix}${row.skip === true ? 'SKIP' : row.ok ? 'PASS' : 'FAIL'} ${row.id} ${row.text}\n`)
|
||||
for (const row of rows) { process.stdout.write(`${prefix}${row.skip === true ? 'SKIP' : row.ok ? 'PASS' : 'FAIL'} ${row.id} ${row.text}\n`)
|
||||
}
|
||||
const red = rows.filter((x) => x.skip !== true && !x.ok)
|
||||
if (red.length > 0) {
|
||||
|
||||
Reference in new issue
Block a user