#!/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` **已退役**(仅在参数表里留作对账),⛔ 本文件不得再引用它们。 * * ## 🧪 夹具模式(`--listen-fixture` / `--nft-fixture` / `--status-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 } /* ─────────── 只读取数 ─────────── */ /** 一次 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' ) } 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 fixture = listenFx !== undefined || nftFx !== undefined || statusFx !== 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 let facts if (fixture) { process.stderr.write('⚠️ FIXTURE 本次为**夹具模式**:未连接任何远端,结论不得当生产判据\n') try { status = statusFx === undefined ? { endpoints: [] } : JSON.parse(readFixture(statusFx)) } 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')) } 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 } 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) => rows.push(fixture && judged !== true ? { id, ok: true, skip: true, text } : { id, ok, text }) 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} 条`, ) const instanceA = Number(facts.get('instanceA')) const instanceB = Number(facts.get('instanceB')) add( 'OBS-09', codeSet.includes(instanceA) && codeSet.includes(instanceB), `实例面 本机:${r.need('LOCAL_INSTANCE_PORT')}=${facts.get('instanceA')} ${peerLabel}:${peerLocalPort}=${facts.get('instanceB')} (阈值 ∈ {${codeSet.join(',')}})`, ) 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', 'OBS-09', 'OBS-10', ]) { add(id, 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, '夹具模式未取证') } 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())