Files
dsh_shenxian/scripts/overlay-probe.cjs
T
admin 09ce76f3af 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)
2026-09-18 08:08:51 +08:00

1503 lines
71 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 覆盖网络 · **观测最小集**探针
*
* ## 为什么是"一条命令 + 一个退出码"
* `交接单_relay落地R2-R4` 的教训原文是「**静默失效靠判别器定位**」。要让判别器能被**脚本**
* (而不是人读日志)用,就必须有「一条命令出 PASS/FAIL」的入口 —— 本文件就是那个入口:
*
* cd "E:/ProgramData/AI技能/aliyun-dsh-server" && node "D:/github/dsh_shenxian/scripts/overlay-probe.cjs"
*
* - **退出码**:全绿 `0` / 任一红 `1` / 用法或取数失败 `2`(可直接被 automation 消费)
* - **输出**:≤ 12 行(每行 = 一条指标);红的条目**另外**写到 stderr,并**指名**是哪个 ID
* - **阈值**:⛔ **一个都不许硬编码** —— 全部从 `参数表_覆盖网络` 的 `` | `KEY` | 值 | `` 行读出来。
* 缺键 ⇒ **报错退出**(⛔ 绝不用默认值静默兜底 —— 那正是"观测形同虚设"的成因)。
*
* ## 🔴 本文件的**零数字纪律**(判据 = 交接单 §6 E5)
* `grep -nE "[0-9]{3,}" scripts/overlay-probe.cjs` 必须**零命中** —— 连标识符名与注释里
* 都不许出现"看起来像阈值"的数字(端口、节点名、日期一律走参数表或从取回的数据里取)。
* 这样就不存在"脚本里藏着一个没人知道出处的常量"的可能。
*
* ## 🆕 `OBS-11` 的**集合判据**(序⑪ 规划 / 序⑫ 执行)
* 旧口径 = 「**两个计数相等**」,有两处**结构性**缺陷:
* ⓐ 对实例/端点落点**在线态敏感** ⇒ 合法态被判红(**假红**);
* ⓑ 对「**一进一出**」替换式变化**不敏感** ⇒ 真变化被放过(**假绿**)。
* 新口径 = **三集包含式**(差集逐条点名):
*
* required ⊆ actual // 缺 ⇒ FAIL(点名缺项)
* actual ⊆ required ∪ allowed ∪ ranges ∪ derived // 多 ⇒ FAIL(点名多出项)
* derived = { <RELAY_BIND>:<p> | p ∈ /status.endpoints[].localPort } // 唯一来源 = relay 自身 /status
*
* ⇒ `required` 抓"**消失**"、包含式抓"**新增**"、`derived` 让**合法动态落点**有名字
* ⇒ 替换式变化(一进一出)**必然**被两条断言之一命中。
* `LISTEN_COUNT` / `NFT_RULES` **已退役**(仅在参数表里留作对账),⛔ 本文件不得再引用它们。
*
* ## 🆕 `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`(不参与退出码)。
* ⛔ 夹具模式**必须**配 `--table <副本>`(避免误改生产参数表)。
*
* ## 运行前置
* - `cwd` = 工作区根(按 `TABLE_RE` 找参数表;也可 `--dir` / `--table`)
* - 本机能 `ssh` 到中继机(走 `~/.ssh/config` 别名;⚠️ 别名里配的端口可能陈旧 ⇒ 一律用表里的 `SSH_PORT`)
*
* ## ⛔ 它不做什么
* 不做 dashboard、不引外部监控依赖、不开新端口、不写任何远端文件。**纯只读**。
*
* @module scripts/overlay-probe
*/
'use strict'
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
/** 参数表文件名(⛔ 不写死日期数字:用正则匹配,避免脚本里出现像阈值的常量)。 */
const TABLE_RE = /^参数表_覆盖网络_.+\.md$/
/** 参数行:`` | `KEY` | 值 | … ``(只取前两列)。 */
const KEY_RE = /^\|\s*`([A-Z0-9_]+)`\s*\|\s*([^|]*)\|/
/** 观测阈值行:`` | `OBS-NN` | 指标 | 阈值 | 判据 | ``(阈值一律按 KEY 解析)。 */
const OBS_RE = /^\|\s*`(OBS-[0-9]+)`\s*\|\s*([^|]*)\|/
/** 阈值里允许出现的"键引用"形态(`` `KEY` ``)。 */
const KEY_ONLY_RE = /^`([A-Z0-9_]+)`$/
/** 区间形态:`host:lo-hi`(也容错只给端口段的形态,以及 `~` / en-dash 作分隔符)。 */
const RANGE_HOST_RE = /^(.+):(\d+)\s*[-–~]\s*(\d+)$/
/** 区间形态(只给端口段,主机由 `RELAY_BIND` 补)。 */
const RANGE_BARE_RE = /^(\d+)\s*[-–~]\s*(\d+)$/
/** `ss` 数据行的首列(`-l` ⇒ 全部是它;据此跳过表头与噪声行)。 */
const SS_STATE = 'LISTEN'
const EXIT_OK = 0
const EXIT_FAIL = 1
const EXIT_USAGE = 2
/* ─────────── 参数表装载 ─────────── */
function argOf(argv, name) {
const i = argv.indexOf(name)
return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : undefined
}
function resolveTablePath(argv) {
const explicit = argOf(argv, '--table')
if (explicit !== undefined) return explicit
const dir = argOf(argv, '--dir') ?? process.env.DSHS_OVERLAY_TABLE_DIR ?? process.cwd()
const hits = fs.readdirSync(dir).filter((n) => TABLE_RE.test(n))
if (hits.length !== 1) {
throw new Error(`在 ${dir} 下按 /${TABLE_RE.source}/ 找到 ${hits.length} 个参数表(要求恰好 1 个)`)
}
return path.join(dir, hits[0])
}
/** 值 → 去掉 markdown 的 `**` 与反引号。 */
function cleanValue(raw) {
return String(raw).replace(/[*`]/g, '').trim()
}
function loadTable(file) {
const text = fs.readFileSync(file, 'utf8')
const params = new Map()
const obs = new Map()
for (const line of text.split(/\r?\n/)) {
const o = OBS_RE.exec(line)
if (o !== null) {
obs.set(o[1], cleanValue(o[2]))
continue
}
const k = KEY_RE.exec(line)
if (k !== null && !params.has(k[1])) {
// ⚠️ 先到先得:同一键在多处出现时以**第一处**(正表)为准,避免被"示例行"覆盖。
params.set(k[1], cleanValue(k[2]))
}
}
return { file, params, obs }
}
function makeReaders(table) {
const bad = []
/** 取**字符串**参数;缺失/空 ⇒ 记账(最后统一报错退出,⛔ 不静默兜底)。 */
const need = (key) => {
if (!table.params.has(key) || table.params.get(key) === '') {
bad.push(key)
return ''
}
return table.params.get(key)
}
/** 取**数值**参数:参数表里有的键名优先;否则按**字面量**(阈值表里可以直接写数)。 */
const num = (keyOrRef) => {
const m = KEY_ONLY_RE.exec(keyOrRef)
let v
if (m !== null) v = need(m[1])
else if (table.params.has(keyOrRef)) v = need(keyOrRef)
else v = String(keyOrRef).trim()
const n = Number(v)
if (v === '' || !Number.isFinite(n)) {
bad.push(`${keyOrRef}=${JSON.stringify(v)} 不是数`)
return Number.NaN
}
return n
}
return { need, num, bad }
}
/* ─────────── 集合判据的取值/解析 ─────────── */
/** 逗号分隔 → Set(去空、去首尾空白;⛔ 保持原文,报错要点名)。 */
function parseList(value) {
return new Set(
String(value)
.split(',')
.map((s) => s.trim())
.filter((s) => s !== ''),
)
}
/**
* 解析区间集合。`fallbackHost` 用于**只给端口段**的形态(如拨号池那个 `DIAL_POOL_BOUND`)。
* 非法项**记账**而不是静默丢弃(丢项 = 白名单悄悄变窄 = 假红)。
*/
function parseRanges(value, fallbackHost, bad) {
const out = []
for (const item of String(value).split(',')) {
const t = item.trim()
if (t === '') continue
const withHost = RANGE_HOST_RE.exec(t)
if (withHost !== null) {
out.push({ host: withHost[1], lo: Number(withHost[2]), hi: Number(withHost[3]) })
continue
}
const bare = RANGE_BARE_RE.exec(t)
if (bare !== null && fallbackHost !== undefined) {
out.push({ host: fallbackHost, lo: Number(bare[1]), hi: Number(bare[2]) })
continue
}
bad.push(`区间 ${JSON.stringify(t)} 形态不合法`)
}
return out
}
/** 单个 `host:port` 是否落在某个区间内。 */
function inRanges(addr, ranges) {
const i = addr.lastIndexOf(':')
if (i <= 0) return false
const host = addr.slice(0, i)
const port = Number(addr.slice(i + 1))
if (!Number.isFinite(port)) return false
return ranges.some((r) => r.host === host && port >= r.lo && port <= r.hi)
}
function decodeB64(raw) {
if (raw === undefined || raw === '') return ''
return Buffer.from(String(raw), 'base64').toString('utf8')
}
/**
* `ss -lntp` 原文 → 监听集合(`host:port` 字符串,**逐条保留原文** ⇒ 报错能点名)。
* ⚠️ 从**右侧**第一个 `:` 拆 host/port —— `[::]:22` 必须拆对(左侧 `indexOf` 会拆成 `[`)。
*/
function parseListenRaw(text) {
const set = new Set()
for (const line of String(text).split(/\r?\n/)) {
const f = line.trim().split(/\s+/)
if (f.length < 4 || f[0] !== SS_STATE) continue
const local = f[3]
const i = local.lastIndexOf(':')
if (i <= 0) continue
set.add(`${local.slice(0, i)}:${local.slice(i + 1)}`)
}
return set
}
/** 从 `input` hook 可达的链(`family/table/chain`,含 `jump` 传递闭包)。 */
function inputReachableChains(items) {
const keyOf = (o) => `${o.family}/${o.table}/${o.chain ?? o.name}`
const reached = new Set()
for (const it of items) {
if (it.chain !== undefined && it.chain.hook === 'input') reached.add(keyOf(it.chain))
}
const rules = items.filter((it) => it.rule !== undefined).map((it) => it.rule)
let grew = true
while (grew) {
grew = false
for (const r of rules) {
if (!reached.has(keyOf(r))) continue
for (const e of r.expr ?? []) {
const target = e.jump !== undefined ? e.jump.target : undefined
if (typeof target !== 'string') continue
const k = `${r.family}/${r.table}/${target}`
if (!reached.has(k)) {
reached.add(k)
grew = true
}
}
}
}
return reached
}
/** 把一条规则的表达式归一成 `<proto>:<dport>`(无端口匹配 ⇒ `<proto>:any`)。 */
function ruleToToken(expr) {
let proto
let port
for (const e of expr ?? []) {
const m = e.match
if (m === undefined) continue
const pl = m.left !== undefined ? m.left.payload : undefined
if (pl === undefined || pl.field !== 'dport' || m.op !== '==') continue
if (typeof pl.protocol === 'string') proto = pl.protocol
const right = m.right
if (typeof right === 'number') port = String(right)
else if (right !== null && typeof right === 'object') {
if (Array.isArray(right.range)) port = `${right.range[0]}-${right.range[1]}`
else if (Array.isArray(right.set)) port = right.set.map((x) => `${x}`).join('+')
else if (typeof right.prefix === 'object' && right.prefix !== null) port = String(right.prefix.addr)
}
}
return `${proto ?? 'any'}:${port ?? 'any'}`
}
/** `nft -j` 原文 → 入站 accept 集合。失败返回 `null`(⛔ 由调用方显式标 `text-fallback`,不许静默改判据)。 */
function parseNftJsonRaw(text) {
let doc
try {
doc = JSON.parse(text)
} catch {
return null
}
const items = Array.isArray(doc.nftables) ? doc.nftables : null
if (items === null) return null
const reached = inputReachableChains(items)
const out = new Set()
for (const it of items) {
const r = it.rule
if (r === undefined) continue
if (!reached.has(`${r.family}/${r.table}/${r.chain}`)) continue
if (!(r.expr ?? []).some((e) => e.accept !== undefined)) continue
out.add(ruleToToken(r.expr))
}
return out
}
/**
* 退化路径:`nft list ruleset` 文本里抽 accept 行。
* ⚠️ 必须**链感知** —— 否则 `FORWARD` 链上的 `accept`(docker 那几条)会被当成入站规则 ⇒ **假红**。
* 做法:先按 `chain X {` / `}` 切出每链的规则行与是否 `hook input`,再沿 `jump` 取传递闭包
* (⛔ 与 `-j` 路径同一判据,只是取数方式不同)。
*/
function parseNftTextRaw(text) {
const chains = new Map()
let cur
for (const line of String(text).split(/\r?\n/)) {
const t = line.trim()
if (t === '' || t.startsWith('#')) continue
const open = /^chain\s+(\S+)\s*\{$/.exec(t)
if (open !== null) {
cur = open[1]
if (!chains.has(cur)) chains.set(cur, { hooked: false, rules: [] })
continue
}
if (t === '}' || t === '};') {
cur = undefined
continue
}
if (cur === undefined) continue
const c = chains.get(cur)
if (/\bhook\s+input\b/.test(t)) c.hooked = true
// ⚠️ `policy accept;` **不算规则**(`accept` 后紧跟 `;` ⇒ 不构成"独立 token")。
if (/(^|\s)accept(\s|$)/.test(t)) c.rules.push(t)
}
const reached = new Set()
for (const [name, c] of chains) if (c.hooked) reached.add(name)
let grew = true
while (grew) {
grew = false
for (const name of [...reached]) {
for (const rule of chains.get(name).rules) {
const j = /\bjump\s+(\S+)/.exec(rule)
if (j !== null && !reached.has(j[1])) {
reached.add(j[1])
grew = true
}
}
}
}
const protoRe = /\b(tcp|udp|icmp|icmpv6|ip|ip6)\b/
const dportRe = /dport\s+(\d+)/
const out = new Set()
for (const name of reached) {
for (const rule of chains.get(name).rules) {
const head = rule.slice(0, rule.search(/(^|\s)accept(\s|$)/))
const proto = protoRe.exec(head)
const dport = dportRe.exec(head)
out.add(`${proto === null ? 'any' : proto[1]}:${dport === null ? 'any' : dport[1]}`)
}
}
return out
}
/* ─────────── 只读取数 ─────────── */
/**
* 同步小睡 —— `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], {
encoding: 'utf8',
timeout: timeoutMs,
}).trim()
}
/**
* 一次 ssh 取回全部远端只读事实(⛔ 压 ssh 次数 = 压成本)。
* 🆕 序⑫:`ss` / `nft` **回传原文**(`base64 -w0`),归一化在本地做 ——
* 旧版只回 `wc -l` 的计数 ⇒ 判据只能比数字(假红/假绿的根源)。
* ⚠️ `base64` 字母表无 `=`(除末尾填充)⇒ 与下面 `key=value` 的行解析兼容。
*/
function remoteFacts(r, sshPort, target, peerLocalPort) {
const host = r.need('RELAY_BIND')
const cmd = [
`echo "listenRaw=$(ss -lntp 2>/dev/null | base64 -w0)"`,
`echo "nftJsonRaw=$(nft -j list ruleset 2>/dev/null | base64 -w0)"`,
`echo "nftTextRaw=$(nft list ruleset 2>/dev/null | base64 -w0)"`,
`echo "instanceA=$(curl -s -o /dev/null -w '%{http_code}' --http1.1 http://${host}:${r.need('LOCAL_INSTANCE_PORT')}/)"`,
`echo "instanceB=$(curl -s -o /dev/null -w '%{http_code}' --http1.1 http://${host}:${peerLocalPort}/)"`,
`echo "portal=$(curl -s -o /dev/null -w '%{http_code}' --http1.1 -H 'Host: ${r.need('PORTAL_HOST_HEADER')}' ${r.need('PORTAL_URL')})"`,
`echo "relayRss=$(ps -o rss= -p $(systemctl show -p MainPID --value dshs-relay))"`,
].join('; ')
const out = new Map()
for (const line of ssh(sshPort, target, cmd, r.num('SSH_TIMEOUT_MS')).split(/\r?\n/)) {
const i = line.indexOf('=')
if (i > 0) out.set(line.slice(0, i), line.slice(i + 1).trim())
}
return out
}
function readFixture(file) {
if (!fs.existsSync(file)) throw new Error(`夹具不存在:${file}`)
return fs.readFileSync(file, 'utf8')
}
/* ─────────── 主流程 ─────────── */
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' +
' --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'
)
}
function main() {
const argv = process.argv.slice(2)
if (argv.includes('--help') || argv.includes('-h')) {
process.stdout.write(usage())
return EXIT_OK
}
const listenFx = argOf(argv, '--listen-fixture')
const nftFx = argOf(argv, '--nft-fixture')
const statusFx = argOf(argv, '--status-fixture')
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
}
let table
try {
table = loadTable(resolveTablePath(argv))
} catch (err) {
process.stderr.write(`❌ 参数表装载失败:${err.message}\n`)
return EXIT_USAGE
}
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 = 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
}
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')
// 取 `/status`(观测的**主数据源**)。独立一次 ssh:对端实例面在中继机上的回环落点口号**要靠它**。
try {
status = 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-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
try {
facts = remoteFacts(r, sshPort, target, peerLocalPort0)
} catch (err) {
process.stderr.write(`❌ 远端只读取数失败:${err.message}\n`)
return EXIT_USAGE
}
}
const counters = status.counters ?? {}
const cap = status.capacity ?? {}
const eps = Array.isArray(status.endpoints) ? status.endpoints : []
const peerEp = eps.find((e) => e.port === r.num('PEER_INSTANCE_PORT'))
const peerLabel = peerEp === undefined ? '对端' : peerEp.hostId
const peerLocalPort = peerEp === undefined ? r.num('PEER_INSTANCE_PORT') : peerEp.localPort
const relayBind = r.need('RELAY_BIND')
const required = parseList(r.need('LISTEN_REQUIRED'))
const allowed = parseList(r.need('LISTEN_ALLOWED'))
const ranges = parseRanges(r.need('LISTEN_ALLOWED_RANGES'), relayBind, r.bad)
// 拨号池**不另立键**(⛔ 避免两处漂移):复用既有 `DIAL_POOL_BOUND`,主机取 `RELAY_BIND`。
ranges.push(...parseRanges(r.need('DIAL_POOL_BOUND'), relayBind, r.bad))
const nftAllowed = parseList(r.need('NFT_ALLOW_INBOUND'))
// `derived` 的**唯一来源** = relay 自身 `/status` 的端点回环落点(动态值 ⇒ ⛔ 不许写死进参数表)。
const derived = new Set(eps.map((e) => `${relayBind}:${e.localPort}`))
if (r.bad.length > 0) {
process.stderr.write(`❌ 参数表缺键/坏值:${r.bad.join(' , ')}\n`)
return EXIT_USAGE
}
const codeSet = r.need('PROBE_CODE_SET').split(',').map((s) => Number(s.trim()))
const rows = []
/**
* 夹具模式下非集合类指标无法取证 ⇒ 记 SKIP(⛔ 不参与退出码,否则"先红后绿"表达不出来)。
* ⚠️ `judged = true` 的行**仍按真实判据出 PASS/FAIL** —— `OBS-11` 就是它,
* 否则夹具模式恒绿 ⇒ 整个"假绿实证"就假了。
*/
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')})`)
add(
'OBS-02',
Number(cap.max) === r.num('RELAY_MAX_HOSTS') && Number(cap.free) === Number(cap.max) - Number(cap.used),
`capacity max=${cap.max} used=${cap.used} free=${cap.free} (阈值 max=${r.num('RELAY_MAX_HOSTS')}, free=max-used)`,
)
add(
'OBS-03',
counters.identityRequired === true && Number(counters.trustedSigners) >= r.num('MIN_TRUSTED_SIGNERS'),
`identityRequired=${counters.identityRequired} trustedSigners=${counters.trustedSigners} (阈值 ≥ ${r.num('MIN_TRUSTED_SIGNERS')})`,
)
add(
'OBS-04',
Number(counters.identityOk) >= r.num('MIN_IDENTITY_OK'),
`identityOk=${counters.identityOk} (阈值 ≥ ${r.num('MIN_IDENTITY_OK')})`,
)
add(
'OBS-05',
Number(counters.revokedHosts) <= r.num('MAX_REVOKED_HOSTS'),
`revokedHosts=${counters.revokedHosts} (阈值 ≤ ${r.num('MAX_REVOKED_HOSTS')})`,
)
const dialKeys = ['dial', 'dialDenied', 'dialFailed']
add(
'OBS-06',
dialKeys.every((k) => typeof counters[k] === 'number'),
`判别器 ${dialKeys.map((k) => `${k}=${counters[k]}`).join(' ')} (必须都是 number)`,
)
add(
'OBS-07',
Number(counters.authFailed) <= r.num('MAX_AUTH_FAILED'),
`authFailed=${counters.authFailed} authed=${counters.authed} (阈值 ≤ ${r.num('MAX_AUTH_FAILED')})`,
)
add(
'OBS-08',
eps.length > 0 && eps.every((e) => e.online === true),
`端点表 ${eps.length} 条 / 离线 ${eps.filter((e) => e.online !== true).length} 条`,
)
judgeObs09()
add('OBS-10', Number(facts.get('portal')) === r.num('PORTAL_CODE'), `门户=${facts.get('portal')} (阈值 = ${r.num('PORTAL_CODE')})`)
} else {
for (const id of [
'OBS-01',
'OBS-02',
'OBS-03',
'OBS-04',
'OBS-05',
'OBS-06',
'OBS-07',
'OBS-08',
]) {
add(id, true, '夹具模式未取证')
}
judgeObs09()
add('OBS-10', true, '夹具模式未取证')
}
/* ── OBS-11:**集合判据**(三集包含式 + nft 入站 accept 白名单) ── */
// 夹具模式下 `facts.listenRaw` 亦由夹具文件编码而来 ⇒ 两条路径同一份解析逻辑(⛔ 不写两套)。
const actual = parseListenRaw(decodeB64(facts.get('listenRaw')))
const missing = [...required].filter((a) => !actual.has(a)).sort()
// ⛔ 顺序:required → allowed → ranges → derived;只在**全不命中**时才进 `extra`。
const extra = [...actual]
.filter((a) => !required.has(a) && !allowed.has(a) && !inRanges(a, ranges) && !derived.has(a))
.sort()
let acceptSet = new Set()
let nftNote = ' nft=none'
const nftJsonText = nftFx !== undefined ? readFixture(nftFx) : decodeB64(facts.get('nftJsonRaw'))
if (nftJsonText !== '') {
acceptSet = parseNftJsonRaw(nftJsonText)
nftNote = ''
if (acceptSet === null) {
// ⛔ 退化路径**必须显式标记**(不许静默改判据):`-j` 不可用时才走文本解析。
acceptSet = parseNftTextRaw(nftFx !== undefined ? nftJsonText : decodeB64(facts.get('nftTextRaw')))
nftNote = ' nft=text-fallback'
}
} else if (!fixture) {
process.stderr.write('❌ OBS-11 取不到 nft 规则集(`-j` 与文本两路都为空)\n')
return EXIT_USAGE
}
const nftExtra = [...acceptSet].filter((t) => !nftAllowed.has(t)).sort()
// relay 的"只绑回环"不变量 —— 🆕 序⑫ 改为**本地从监听集合算**(旧版是远端 `grep -c`:
// ① 它按"整行含该口号"计数 ⇒ 连 peer 列都算进去,会**高估**;② 夹具模式下取不到 ⇒ NaN ⇒ 假红。
// 现在:按 Local 列**精确**取端口 ⇒ 顺带把"`RELAY_PORT` 只出现在 `RELAY_BIND` 上"这条判据变成严格版。
const relayPort = r.need('RELAY_PORT')
const portOf = (a) => a.slice(a.lastIndexOf(':') + 1)
const relayListenTotal = [...actual].filter((a) => portOf(a) === relayPort).length
const relayListenLoopback = [...actual].filter((a) => a === `${relayBind}:${relayPort}`).length
const ok11 =
missing.length === 0 &&
extra.length === 0 &&
nftExtra.length === 0 &&
relayListenTotal === relayListenLoopback &&
relayListenTotal > 0
add(
'OBS-11',
ok11,
`集合 必在 ${required.size} 允许 ${allowed.size} 区间 ${ranges.length} 派生 ${derived.size} 实际 ${actual.size} ` +
`多出 ${extra.length} 缺失 ${missing.length} |nft accept ${acceptSet.size} 多出 ${nftExtra.length}` +
`${nftNote} |relay 口绑定回环=${relayListenLoopback}/${relayListenTotal} 条`,
true,
)
for (const a of missing) process.stderr.write(`OBS-11 缺失 ${a}\n`)
for (const a of extra) process.stderr.write(`OBS-11 多出 ${a}\n`)
for (const t of nftExtra) process.stderr.write(`OBS-11 nft 多出 ${t}\n`)
if (!fixture) {
add(
'OBS-12',
Number(facts.get('relayRss')) <= r.num('RELAY_RSS_MAX_KB'),
`relay RSS=${facts.get('relayRss')}KB (阈值 ≤ ${r.num('RELAY_RSS_MAX_KB')}KB)`,
)
} else {
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`)
}
const red = rows.filter((x) => x.skip !== true && !x.ok)
if (red.length > 0) {
process.stderr.write(`❌ ${red.length} 项红:${red.map((x) => x.id).join(' , ')}\n`)
return EXIT_FAIL
}
return EXIT_OK
}
process.exit(main())