Files
dsh_ai1net_server/scripts/overlay-probe.cjs
T

617 lines
26 KiB
JavaScript
Raw Normal View History

#!/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` **已退役**(仅在参数表里留作对账),⛔ 本文件不得再引用它们。
*
* ## 🧪 夹具模式(`--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
}
/** 把一条规则的表达式归一成 `<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
}
/* ─────────── 只读取数 ─────────── */
/** 一次 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'
)
}
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())