#!/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 = { :

| 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 } /** 把一条规则的表达式归一成 `:`(无端口匹配 ⇒ `: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 --nft-fixture --status-fixture \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 ⬅️ 只有 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 --candidates-fixture-106 \n' + ' (某侧缺夹具文件 ⇒ 该侧 SKIP;全缺 ⇒ OBS-21 整体 SKIP ⇒ ⛔ 夹具模式绝不去 ssh)\n' + // 序㉘→单A:OBS-22(退出路径不杀实例)**每侧一个夹具** —— 内容 = 该机部署产物里三处 teardown 的原文段。 ' 🆕 序㉘单A:OBS-22(退出路径不杀实例)--teardown-fixture-47 --teardown-fixture-106 \n' + ' (某侧缺夹具文件 ⇒ 该侧 SKIP;全缺 ⇒ OBS-22 整体 SKIP ⇒ ⛔ 夹具模式绝不去 ssh)\n' + // 序㉘→单B:OBS-23(组密钥加密)—— 数据源 = `content.crypto` 块(真机从 /status 取;夹具用 --content-crypto-fixture)。 ' 🆕 序㉘单B:OBS-23(组密钥加密)--content-crypto-fixture \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` 块)⇒ 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 `;缺省 ⇒ **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 ` 天然分单元, * 这里再过一遍 `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 /* ① 静态守卫:逐文件取 `## ` 段,核对标记 + 禁词 */ 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 `;缺省 ⇒ 从 `--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())