覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 relay 子系统 src/net/relay/**(wire/duplex/server/client/dialer/switcher/directory/identity/keys/placement/network/addr-override/main/index)
- 新增 src/worker/relay-tunnel.ts、src/web/routes/overlay.ts
- 新增观测/演练脚本 overlay-probe、overlay-failover-drill、overlay-keyring、overlay-holepunch、overlay-jitter、overlay-wan、overlay-relaykey-add、relay-mem-calibrate
- 新增测试 12 个(relay / relay-failover / remote-spawner / instance-port / overlay-{network,auth,bootstrap,identity} / remote-user-fs 等)
验收基线:npm test = 162 pass / 0 fail / 1 skip;--scene all = 12 PASS / 0 SKIP / 0 FAIL;overlay-probe = 12/12
618 lines
26 KiB
JavaScript
618 lines
26 KiB
JavaScript
#!/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())
|