1020 lines
46 KiB
JavaScript
1020 lines
46 KiB
JavaScript
#!/usr/bin/env node
|
||||
|
|
/**
|
|||
|
|
* 覆盖网络 **直连(打洞)观测面**(序㊵ · P2 · S5)—— **只读** + 一个**自检**子命令。
|
|||
|
|
*
|
|||
|
|
* ## 三个子命令
|
|||
|
|
* | 子命令 | 作用 | 副作用 |
|
|||
|
|
* |---|---|---|
|
|||
|
|
* | `selfcheck [--json] [--out <f>] [--ss]` | 跑完**全部**直连判据(开关 / 提示 / 候选准入 / 打洞 / 冷却 / join 回读)并出**机器可读读数** | 仅在系统临时目录里建夹具;`--ss` 时短暂绑一个 UDP 口(秒级) |
|
|||
|
|
* | `punch --peer <ip:port>[,…] [--self-port <p>] [--deadline <ms>] [--bind <host>] [--json]` | **真机腿**:本机单侧打洞探测(读对方是否回包) | 短暂绑一个 UDP 口(秒级) |
|
|||
|
|
* | `peer-wire [--json] [--out <f>] [--status-url <url>]` | 🆕 序㊶(S6):`peer` 档**接线**自检(通道顺序 / 具名降级 / **D7 复算闸门** / 候选准入复用 S5 / 块 id 不变性读数) | 纯进程内夹具(⛔ 零监听、零网络;`--status-url` 时只读一个 HTTP GET) |
|
|||
|
|
* | `status [--json]` | 只读:当前生效的开关(env > 本机配置 > 缺省)+ 三段提示 | **零** |
|
|||
|
|
*
|
|||
|
|
* ## 🔴 它与 `overlay-probe.cjs` 的分工(⛔ 别把两处的判据混起来)
|
|||
|
|
* - **本脚本产出读数**(`DIRECT_SELFCHECK_FILE` 那份 JSON,形状见下);
|
|||
|
|
* - **`overlay-probe.cjs` 的 `OBS-26/27/28` 判读数**(阈值**全部**取自参数表,⛔ 本脚本内无阈值判断)。
|
|||
|
|
* ⇒ "阈值唯一来源 = 参数表"这条铁律不被绕过:本脚本只负责**量**,不负责**判**。
|
|||
|
|
*
|
|||
|
|
* ## 读数形状(`selfcheck --json`)
|
|||
|
|
* ```jsonc
|
|||
|
|
* {
|
|||
|
|
* "switch": { "defaultEnabled": true, "offCase": { "enabled": false, "udpSocketsOpened": 0, "candidatesEmitted": 0 }, … },
|
|||
|
|
* "hint": { "parts": 3, "mentionsOff": true, "mentionsScope": true, "mentionsImpact": true },
|
|||
|
|
* "candidate": { "accepted": 3, "rejected": [ { "case": "cross-network", "reason": "cross-network" }, … ], "silentRejections": 0 },
|
|||
|
|
* "punch": { "attemptsOk": 1, "success": { "bidirectional": true }, "oneWay": { … }, "dead": [ … ] },
|
|||
|
|
* "cooldown": { "ms": 300000, "blocked": 1, "secondAttemptBlocked": true, "zeroRejected": "throws" },
|
|||
|
|
* "degraded": false, // 🆕 序㊸:是否有"具名降级"的腿
|
|||
|
|
* "degradedLegs": [],
|
|||
|
|
* "joinConf": { "available": true, "direct": true, "readback": true },
|
|||
|
|
* // ⚠️ 节点形态(无 lib/net/relay/registry.js)下 joinConf **换成**:
|
|||
|
|
* // { "available": false, "reason": "module-missing",
|
|||
|
|
* // "missing": [ { "spec": "../lib/net/relay/registry.js", "code": "MODULE_NOT_FOUND", "message": "…原文…" } ] }
|
|||
|
|
* // ⇒ 形状与"跑了但读数为空"**完全不同** = 「没装」与「没采到」可分。
|
|||
|
|
* "deps": { "ok": true, "found": ["node:dgram", …] },
|
|||
|
|
* "udp": { "ssBefore": null, "ssDuringOffCase": null, "ssDuringOnCase": null }
|
|||
|
|
* }
|
|||
|
|
* ```
|
|||
|
|
*
|
|||
|
|
* ## ⛔ 它不做什么
|
|||
|
|
* ⛔ 不写任何远端文件(除 `--out` 指定的那份读数)|⛔ 不改任何配置|⛔ 不开长驻监听
|
|||
|
|
* |⛔ 不碰 `nft` / 云安全组(那是 D8 的收口动作,⛔ 不在本脚本职责内)。
|
|||
|
|
*
|
|||
|
|
* ## 🆕 序㊸:**可跑性 = "节点形态也能跑"**(106 侧观测面缺口收口)
|
|||
|
|
*
|
|||
|
|
* **实测缺口**:106(worker/relay 节点)的 `/opt/dshs-cluster/lib` **不含 `registry.js`**
|
|||
|
|
* —— 那是**控制面注册表**模块,节点不落它。而本脚本原先在**顶层** `require` 了它
|
|||
|
|
* (外加同样依赖它的 `join.js`)⇒ 在 106 上**任何**子命令都跑不起来:
|
|||
|
|
* `Error: Cannot find module '../lib/net/relay/registry.js'`。
|
|||
|
|
* 偏偏 106 正是**真机打洞腿**的取证机 ⇒ 只能临时写最小脚本绕行(那正是"脆"的来源)。
|
|||
|
|
*
|
|||
|
|
* **处置 = 惰性 / 可选加载 + 具名降级**(⛔ **不是**"再铺一份 `registry.js`"):
|
|||
|
|
* - 只有 `join 回读`(判据 D3)这条腿需要 `registry.js` / `join.js` ⇒ **就只让它依赖**:
|
|||
|
|
* `joinConfCases()` 内部惰性加载,**加载不到 ⇒ 具名降级**(读出 `available:false` +
|
|||
|
|
* 缺哪个模块 + **原文** message),其余 `status` / `punch` / `peer-wire` 及其余读数照常。
|
|||
|
|
* - 🔴 **⛔ 绝不静默返回"没有"** —— 降级必须**点名**缺的是哪个模块
|
|||
|
|
* (本线明令:「**没装**」与「**没采到**」必须可分);读数里另给
|
|||
|
|
* `degraded: true` / `degradedLegs: ["joinConf"]` 两个**机器可读**标记。
|
|||
|
|
* - ⚠️ 只吞 `MODULE_NOT_FOUND`(且**原文 message 原样带出**);其它异常(语法错、依赖内部崩)
|
|||
|
|
* **一律照抛** —— 否则会把真故障伪装成"缺模块"。
|
|||
|
|
*
|
|||
|
|
* @module scripts/overlay-direct-probe
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
'use strict'
|
|||
|
|
|
|||
|
|
const { execFileSync } = require('node:child_process')
|
|||
|
|
const fs = require('node:fs')
|
|||
|
|
const os = require('node:os')
|
|||
|
|
const path = require('node:path')
|
|||
|
|
const { randomBytes } = require('node:crypto')
|
|||
|
|
|
|||
|
|
const net = require('../lib/net/relay/network.js')
|
|||
|
|
const id = require('../lib/net/relay/identity.js')
|
|||
|
|
const direct = require('../lib/net/relay/direct/index.js')
|
|||
|
|
const cand = require('../lib/net/relay/direct/candidate.js')
|
|||
|
|
const punch = require('../lib/net/relay/direct/punch.js')
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* **惰性 / 可选加载**(⛔ 它不是"容错"、更不是"静默兜底")—— 见文件头「序㊸」段。
|
|||
|
|
*
|
|||
|
|
* 判据(**必须同时成立**,缺一即视为"把真故障伪装成缺模块"):
|
|||
|
|
* 1. 只把 `MODULE_NOT_FOUND` / `ERR_MODULE_NOT_FOUND` 当"缺模块";
|
|||
|
|
* 2. 其余异常(语法错 / 依赖内部崩 / 权限)**原样抛出**;
|
|||
|
|
* 3. **原文 message 一字不改地带出**给调用方点名(⛔ 不 `catch {}` 吞掉)。
|
|||
|
|
*
|
|||
|
|
* @param {string} spec 相对本脚本的模块说明符
|
|||
|
|
* @returns {{ok:boolean, mod:any, code:string|null, message:string|null}}
|
|||
|
|
*/
|
|||
|
|
function optionalModule(spec) {
|
|||
|
|
try {
|
|||
|
|
return { ok: true, mod: require(spec), code: null, message: null }
|
|||
|
|
} catch (err) {
|
|||
|
|
const code = (err && err.code) || null
|
|||
|
|
if (code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND') {
|
|||
|
|
return { ok: false, mod: null, code, message: err.message }
|
|||
|
|
}
|
|||
|
|
throw err
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 🔴 **启动期硬断言**:本脚本依赖的常量必须在场,缺一个就**具名失败**(`exit 2`)。
|
|||
|
|
*
|
|||
|
|
* 为什么必须有这一关(**已实测踩过**):`direct/index.js` 是**选择性**桶导出 —— 它 import 了
|
|||
|
|
* `DEFAULT_PUNCH_DEADLINE_MS` / `DEFAULT_DIRECT_COOLDOWN_MS` 供 **内部**使用,但**不向外转发**。
|
|||
|
|
* 从桶上取这两个值 ⇒ `undefined` ⇒ `Number(undefined)` = `NaN` ⇒ 只在下游出症状
|
|||
|
|
* (`DirectCooldown` 构造期断言抛 `收到 null`,因为 `JSON.stringify(NaN) === 'null'`)。
|
|||
|
|
* ⛔ 这属于本线明令禁止的「**静默取缺省**」⇒ 改成**入场即点名**,⛔ 不许靠下游兜底。
|
|||
|
|
*/
|
|||
|
|
const REQUIRED_CONSTS = [
|
|||
|
|
['punch', punch, ['PUNCH_PORT_BASE', 'PUNCH_PORT_SPAN', 'PUNCH_PROBE_INTERVAL_MS', 'DEFAULT_PUNCH_DEADLINE_MS', 'DEFAULT_DIRECT_COOLDOWN_MS']],
|
|||
|
|
['cand', cand, ['DIRECT_CAND_MAX_ADDRS']],
|
|||
|
|
['direct', direct, ['DIRECT_ENV_KEY', 'DIRECT_ON_VALUES', 'DIRECT_OFF_VALUES']],
|
|||
|
|
]
|
|||
|
|
{
|
|||
|
|
const missing = []
|
|||
|
|
for (const [mod, obj, keys] of REQUIRED_CONSTS) {
|
|||
|
|
for (const k of keys) {
|
|||
|
|
if (obj[k] === undefined) missing.push(`${mod}.${k}`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
if (missing.length > 0) {
|
|||
|
|
process.stderr.write(
|
|||
|
|
`✗ overlay-direct-probe 启动自检失败:缺常量 ${missing.join(' , ')}\n` +
|
|||
|
|
` (⛔ 不再静默取缺省;请核对 lib/net/relay/direct/*.js 的导出面与本次部署是否一致)\n`,
|
|||
|
|
)
|
|||
|
|
process.exit(2)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|||
|
|
|
|||
|
|
function parseArgs(argv) {
|
|||
|
|
const out = { _: [] }
|
|||
|
|
for (let i = 0; i < argv.length; i++) {
|
|||
|
|
const a = argv[i]
|
|||
|
|
if (!a.startsWith('--')) {
|
|||
|
|
out._.push(a)
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
const key = a.slice(2)
|
|||
|
|
const next = argv[i + 1]
|
|||
|
|
if (next === undefined || next.startsWith('--')) out[key] = true
|
|||
|
|
else {
|
|||
|
|
out[key] = next
|
|||
|
|
i++
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return out
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const args = parseArgs(process.argv.slice(2))
|
|||
|
|
const opt = (name) => (typeof args[name] === 'string' && args[name].trim() !== '' ? args[name].trim() : undefined)
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* `ss -lunp` 里**直连口区间**(`[base, base+span)`)的 UDP 行数。
|
|||
|
|
* ⛔ 取不到 ⇒ `null` —— **绝不**把「没采到」写成 `0`(本线铁律:两者必须可分)。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 为什么按**端口区间**计数而不是全量行数:全量行数会被 `chronyd` 之类的常驻 UDP 口污染,
|
|||
|
|
* 「前 N → 后 N」这种差分在它们抖动时会出现**假绿/假红**。直连口区间是本功能专属,判据干净。
|
|||
|
|
*/
|
|||
|
|
function ssDirectUdpLines(base, span) {
|
|||
|
|
try {
|
|||
|
|
const out = execFileSync('ss', ['-lunp'], { encoding: 'utf8', timeout: 5000 })
|
|||
|
|
const lo = Number(base)
|
|||
|
|
const hi = lo + Number(span)
|
|||
|
|
return out
|
|||
|
|
.split('\n')
|
|||
|
|
.filter((l) => l.trim() !== '' && !l.startsWith('State'))
|
|||
|
|
.filter((l) => {
|
|||
|
|
const m = /:(\d+)\s/.exec(l)
|
|||
|
|
if (m === null) return false
|
|||
|
|
const p = Number(m[1])
|
|||
|
|
return p >= lo && p < hi
|
|||
|
|
}).length
|
|||
|
|
} catch (err) {
|
|||
|
|
return null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** `--ss` 是**开关**(无值),所以按"出现过"判 —— ⛔ 不是 `opt()`(那要求带值 ⇒ 恒 undefined)。 */
|
|||
|
|
const wantSs = args.ss !== undefined
|
|||
|
|
|
|||
|
|
/** 找一个**没人监听**的本地端口(用于产出 `deadline` 那条负腿)。 */
|
|||
|
|
async function freeDeadPort() {
|
|||
|
|
const s = new (require('node:dgram').Socket)('udp4')
|
|||
|
|
await new Promise((res) => s.bind({ port: 0, address: '127.0.0.1' }, res))
|
|||
|
|
const p = s.address().port
|
|||
|
|
await new Promise((res) => s.close(res))
|
|||
|
|
return p
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 依赖面断言:`lib/net/relay/direct/*.js` 里出现的模块说明符**只准**是
|
|||
|
|
* ① Node 内建 `node:*` ② 本模块内相对路径(`./` `../`)。
|
|||
|
|
* ⇒ 把硬门「⛔ 不引入新依赖(只用 Node 内建 `dgram`)」变成**机器可断言**的一条。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 产物是 **ESM**(`package.json` 里 `"type": "module"`)⇒ 两种写法都要扫:
|
|||
|
|
* `import … from "x"` 与 `import "x"`(`require(` 一并兼容 —— 编进 CJS 也照样查得到)。
|
|||
|
|
* (第一版只扫 `require(` ⇒ 一条都没扫到、**判据恒绿** —— 正是本线要防的那种"静默放行"。)
|
|||
|
|
*/
|
|||
|
|
function depsAudit() {
|
|||
|
|
const dir = path.join(__dirname, '..', 'lib', 'net', 'relay', 'direct')
|
|||
|
|
const found = new Set()
|
|||
|
|
const bad = []
|
|||
|
|
const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js'))
|
|||
|
|
for (const f of files) {
|
|||
|
|
const text = fs.readFileSync(path.join(dir, f), 'utf8')
|
|||
|
|
const specs = []
|
|||
|
|
for (const m of text.matchAll(/require\(["']([^"']+)["']\)/g)) specs.push(m[1])
|
|||
|
|
for (const m of text.matchAll(/^\s*import\s+[^;]*?from\s+["']([^"']+)["']/gm)) specs.push(m[1])
|
|||
|
|
for (const m of text.matchAll(/^\s*import\s+["']([^"']+)["']/gm)) specs.push(m[1])
|
|||
|
|
for (const spec of specs) {
|
|||
|
|
found.add(spec)
|
|||
|
|
const okBuiltin = spec.startsWith('node:')
|
|||
|
|
const okLocal = spec.startsWith('./') || spec.startsWith('../')
|
|||
|
|
if (!okBuiltin && !okLocal) bad.push(`${f}:${spec}`)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { ok: bad.length === 0 && found.size > 0, found: [...found].sort(), bad, files: files.length }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 开关四态(缺省 / 开 / 关 / 非法)——**每个都真跑一次**,⛔ 不靠"读代码推断"。 */
|
|||
|
|
async function switchCases(ctx) {
|
|||
|
|
const mkPath = (state, deadlineMs) =>
|
|||
|
|
new direct.DirectPath({ switchState: state, cooldownMs: ctx.cooldownMs, portBase: ctx.portBase, portSpan: ctx.portSpan, deadlineMs, maxAddrs: ctx.maxAddrs })
|
|||
|
|
|
|||
|
|
const good = cand.encodeDirectMessage({
|
|||
|
|
hostId: 'w-106',
|
|||
|
|
network: 'ops',
|
|||
|
|
addrs: [{ host: '10.0.0.9', port: ctx.portBase + 1 }],
|
|||
|
|
})
|
|||
|
|
const ctxOf = { dialers: ctx.dialers, from: { network: 'ops', hostId: 'w-106' }, selfHostId: 'manager' }
|
|||
|
|
|
|||
|
|
const defaultCase = direct.resolveDirectSwitch({})
|
|||
|
|
const onState = direct.resolveDirectSwitch({ [direct.DIRECT_ENV_KEY]: 'true' })
|
|||
|
|
const offState = direct.resolveDirectSwitch({ [direct.DIRECT_ENV_KEY]: 'false' })
|
|||
|
|
const badState = direct.resolveDirectSwitch({ [direct.DIRECT_ENV_KEY]: 'maybe' })
|
|||
|
|
|
|||
|
|
const onPath = mkPath(onState, 400)
|
|||
|
|
const onVerdict = onPath.offerCandidate(good, ctxOf)
|
|||
|
|
const onAttempt = await onPath.attempt('ops/void', [{ host: '127.0.0.1', port: await freeDeadPort() }], { sleep })
|
|||
|
|
const onStatus = onPath.status()
|
|||
|
|
|
|||
|
|
const offPath = mkPath(offState, 400)
|
|||
|
|
const offVerdict = offPath.offerCandidate(good, ctxOf)
|
|||
|
|
const offAttempt = await offPath.attempt('ops/void', [{ host: '127.0.0.1', port: await freeDeadPort() }], { sleep })
|
|||
|
|
const offStatus = offPath.status()
|
|||
|
|
|
|||
|
|
const badPath = mkPath(badState, 400)
|
|||
|
|
const badVerdict = badPath.offerCandidate(good, ctxOf)
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
envKey: direct.DIRECT_ENV_KEY,
|
|||
|
|
defaultEnabled: direct.DEFAULT_DIRECT_ENABLED,
|
|||
|
|
defaultCase: { enabled: defaultCase.enabled, source: defaultCase.source },
|
|||
|
|
onCase: {
|
|||
|
|
raw: onState.raw,
|
|||
|
|
enabled: onState.enabled,
|
|||
|
|
candidateOk: onVerdict.ok === true,
|
|||
|
|
candidatesEmitted: onStatus.counters.candidatesEmitted,
|
|||
|
|
udpSocketsOpened: onStatus.counters.udpSocketsOpened,
|
|||
|
|
attemptReason: onAttempt.reason,
|
|||
|
|
},
|
|||
|
|
offCase: {
|
|||
|
|
raw: offState.raw,
|
|||
|
|
enabled: offState.enabled,
|
|||
|
|
candidateReason: offVerdict.ok ? '' : onVerdict_reason(offVerdict),
|
|||
|
|
candidatesEmitted: offStatus.counters.candidatesEmitted,
|
|||
|
|
udpSocketsOpened: offStatus.counters.udpSocketsOpened,
|
|||
|
|
attemptReason: offAttempt.reason,
|
|||
|
|
},
|
|||
|
|
badCase: {
|
|||
|
|
raw: badState.raw,
|
|||
|
|
enabled: badState.enabled,
|
|||
|
|
invalid: badState.invalid,
|
|||
|
|
candidateReason: badVerdict.ok ? '' : onVerdict_reason(badVerdict),
|
|||
|
|
openedSockets: badPath.status().counters.udpSocketsOpened,
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onVerdict_reason(v) {
|
|||
|
|
return v.ok ? '' : v.reason
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 提示文案(三段)是否**可行动**:提没提"怎么关"、提没提"谁能连进来"、提没提"关掉影响什么"。 */
|
|||
|
|
function hintAudit() {
|
|||
|
|
const lines = direct.directHintLines()
|
|||
|
|
const text = direct.directHintText()
|
|||
|
|
return {
|
|||
|
|
parts: lines.length,
|
|||
|
|
mentionsOff: text.includes(direct.DIRECT_ENV_KEY) && direct.DIRECT_OFF_VALUES.some((v) => text.includes(v)),
|
|||
|
|
mentionsScope: text.includes('白名单') && text.includes('同一张覆盖网'),
|
|||
|
|
mentionsImpact: text.includes('不影响') && text.includes('中继'),
|
|||
|
|
text,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 候选准入矩阵(**判据 D1**)——每一条都走 `CandidateLedger.judge`(⇒ 记账与判定同一入口)。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 白名单形状**真的走** `network.ts#normalizeDialers`(= `server.ts` 构造期用的那一个)
|
|||
|
|
* ⇒ 这里量的是"**同一份策略**"的行为,⛔ 不是复刻一个判据。
|
|||
|
|
*/
|
|||
|
|
function candidateMatrix(ctx) {
|
|||
|
|
const dialers = net.normalizeDialers(
|
|||
|
|
new Map([
|
|||
|
|
['ops', new Set(['manager', 'w-106'])],
|
|||
|
|
['u:5', new Set(['w-106'])],
|
|||
|
|
]),
|
|||
|
|
)
|
|||
|
|
const led = new cand.CandidateLedger()
|
|||
|
|
const accepted = []
|
|||
|
|
const rejected = []
|
|||
|
|
const run = (name, raw, c) => {
|
|||
|
|
const v = led.judge(raw, c)
|
|||
|
|
if (v.ok) accepted.push(name)
|
|||
|
|
else rejected.push({ case: name, reason: v.reason, detail: v.detail })
|
|||
|
|
return v
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const msg = (over = {}) =>
|
|||
|
|
cand.encodeDirectMessage({
|
|||
|
|
hostId: 'w-106',
|
|||
|
|
network: 'ops',
|
|||
|
|
addrs: [{ host: '10.0.0.9', port: ctx.portBase + 1 }],
|
|||
|
|
ts: Date.now(),
|
|||
|
|
...over,
|
|||
|
|
})
|
|||
|
|
const inOps = { dialers, from: { network: 'ops', hostId: 'w-106' }, selfHostId: 'manager', maxAddrs: ctx.maxAddrs }
|
|||
|
|
|
|||
|
|
run('ops-单地址', msg(), inOps)
|
|||
|
|
run('ops-双地址', msg({ addrs: [{ host: '10.0.0.9', port: ctx.portBase + 1 }, { host: '2001:db8::1', port: ctx.portBase + 2 }] }), inOps)
|
|||
|
|
const u5 = { dialers, from: { network: 'u:5', hostId: 'w-106' }, selfHostId: 'w-106', maxAddrs: ctx.maxAddrs }
|
|||
|
|
run('u:5-同名跨网可拨', msg({ network: 'u:5' }), u5)
|
|||
|
|
|
|||
|
|
run('跨网', msg({ network: 'u:5' }), inOps)
|
|||
|
|
run('替第三人申报', msg({ hostId: 'w-999' }), inOps)
|
|||
|
|
const noDialer = net.normalizeDialers(new Map([['ops', new Set(['manager'])]]))
|
|||
|
|
run('白名单外', msg(), { ...inOps, dialers: noDialer })
|
|||
|
|
run('本机不在白名单', msg(), { ...inOps, selfHostId: 'w-nobody' })
|
|||
|
|
run('形状非法', '{"kind":"DIRECT_CANDIDATE"}', inOps)
|
|||
|
|
run(
|
|||
|
|
'夹带密钥字段',
|
|||
|
|
JSON.stringify({ kind: 'DIRECT_CANDIDATE', hostId: 'w-106', network: 'ops', addrs: [{ host: '10.0.0.9', port: 21101 }], nodeKey: 'x' }),
|
|||
|
|
inOps,
|
|||
|
|
)
|
|||
|
|
run('主机名当地址', msg({ addrs: [{ host: 'example.com', port: 80 }] }), inOps)
|
|||
|
|
run('地址超限', msg({ addrs: Array.from({ length: ctx.maxAddrs + 1 }, (_, i) => ({ host: '10.0.0.9', port: 21100 + i })) }), inOps)
|
|||
|
|
run('过期候选', msg({ ts: Date.now() - 10 * 60 * 1000 }), inOps)
|
|||
|
|
|
|||
|
|
const snap = led.snapshot()
|
|||
|
|
// 同名跨网**互不可见**:u:5 里申报 ops 的候选必须被拒(= 桶隔离真的在起作用)
|
|||
|
|
const crossInside = led.judge(msg(), u5)
|
|||
|
|
return {
|
|||
|
|
matcher: 'network.ts#isAllowedDialer',
|
|||
|
|
accepted: snap.accepted,
|
|||
|
|
acceptedCases: accepted,
|
|||
|
|
rejected: snap.rejected.map((r, i) => ({ case: rejected[i]?.case ?? '', reason: r.reason })),
|
|||
|
|
rejectedCases: rejected,
|
|||
|
|
received: snap.received,
|
|||
|
|
silentRejections: snap.silentRejections,
|
|||
|
|
byReason: snap.byReason,
|
|||
|
|
bucketsPerNetwork: crossInside.ok === false && crossInside.reason === 'cross-network',
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 打洞(**判据 D5 / D6**)—— 成功路径走 NAT 模拟器(真 `dgram` + 同一份打洞代码)。 */
|
|||
|
|
async function punchCases(ctx) {
|
|||
|
|
const deadlineMs = 2500
|
|||
|
|
const ok = await punch.runPunchPair({ aPeer: 'ops/w-47', bPeer: 'ops/w-106', deadlineMs }, { sleep })
|
|||
|
|
const oneWay = await punch.runPunchPair({ aPeer: 'ops/w-47', bPeer: 'ops/w-106', deadlineMs: 1200, oneWay: 'a' }, { sleep })
|
|||
|
|
|
|||
|
|
const cd = new punch.DirectCooldown(ctx.cooldownMs)
|
|||
|
|
let openedSockets = 0
|
|||
|
|
const deadPort = await freeDeadPort()
|
|||
|
|
const dead = await punch.runPunchAttempt(
|
|||
|
|
{
|
|||
|
|
peer: 'ops/void',
|
|||
|
|
selfPort: 0,
|
|||
|
|
targets: [{ host: '127.0.0.1', port: deadPort }],
|
|||
|
|
deadlineMs: 600,
|
|||
|
|
cooldown: cd,
|
|||
|
|
bindHost: '127.0.0.1',
|
|||
|
|
onSocketOpen: () => {
|
|||
|
|
openedSockets += 1
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{ sleep },
|
|||
|
|
)
|
|||
|
|
const openedAfterFirst = openedSockets
|
|||
|
|
const second = await punch.runPunchAttempt(
|
|||
|
|
{
|
|||
|
|
peer: 'ops/void',
|
|||
|
|
selfPort: 0,
|
|||
|
|
targets: [{ host: '127.0.0.1', port: deadPort }],
|
|||
|
|
deadlineMs: 600,
|
|||
|
|
cooldown: cd,
|
|||
|
|
bindHost: '127.0.0.1',
|
|||
|
|
onSocketOpen: () => {
|
|||
|
|
openedSockets += 1
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
{ sleep },
|
|||
|
|
)
|
|||
|
|
let zeroRejected = 'not-thrown'
|
|||
|
|
try {
|
|||
|
|
new punch.DirectCooldown(0)
|
|||
|
|
} catch (err) {
|
|||
|
|
zeroRejected = 'throws'
|
|||
|
|
}
|
|||
|
|
const bound = 600 + 3 * punch.PUNCH_PROBE_INTERVAL_MS
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
deadlineMs,
|
|||
|
|
success: {
|
|||
|
|
bidirectional: ok.bidirectional,
|
|||
|
|
a: { reason: ok.a.reason, recvLocal: ok.a.recvLocal, peerSeen: ok.a.peerSeen, sent: ok.a.sent },
|
|||
|
|
b: { reason: ok.b.reason, recvLocal: ok.b.recvLocal, peerSeen: ok.b.peerSeen, sent: ok.b.sent },
|
|||
|
|
nat: ok.nat,
|
|||
|
|
},
|
|||
|
|
attemptsOk: (ok.a.ok ? 1 : 0) + (ok.b.ok ? 1 : 0),
|
|||
|
|
oneWay: {
|
|||
|
|
bidirectional: oneWay.bidirectional,
|
|||
|
|
a: { reason: oneWay.a.reason, recvLocal: oneWay.a.recvLocal, peerSeen: oneWay.a.peerSeen },
|
|||
|
|
b: { reason: oneWay.b.reason, recvLocal: oneWay.b.recvLocal, peerSeen: oneWay.b.peerSeen },
|
|||
|
|
},
|
|||
|
|
dead: [
|
|||
|
|
{
|
|||
|
|
reason: dead.reason,
|
|||
|
|
elapsedMs: dead.elapsedMs,
|
|||
|
|
deadlineMs: 600,
|
|||
|
|
bounded: dead.elapsedMs <= bound,
|
|||
|
|
sent: dead.sent,
|
|||
|
|
recvLocal: dead.recvLocal,
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
cooldown: {
|
|||
|
|
ms: cd.ms,
|
|||
|
|
blocked: cd.snapshot().blocked,
|
|||
|
|
secondAttemptReason: second.reason,
|
|||
|
|
secondAttemptBlocked: second.reason === 'cooldown' && openedSockets === openedAfterFirst,
|
|||
|
|
zeroRejected,
|
|||
|
|
},
|
|||
|
|
portBase: ctx.portBase,
|
|||
|
|
portSpan: ctx.portSpan,
|
|||
|
|
probeIntervalMs: punch.PUNCH_PROBE_INTERVAL_MS,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* join 回读(**判据 D3**):真的跑一遍四步编排(夹具目录),再看本机配置读回什么。
|
|||
|
|
*
|
|||
|
|
* 🆕 **序㊸:本条腿的依赖是"可选"的** —— 它要 `lib/net/relay/join.js` 与它 import 的
|
|||
|
|
* `lib/net/relay/registry.js`(**控制面注册表**模块)。**节点形态**(如 106 的
|
|||
|
|
* `/opt/dshs-cluster/lib`)**不落 `registry.js`** ⇒ 这里**具名降级**:
|
|||
|
|
* `{ available:false, reason:'module-missing', missing:[…原文 message…] }`。
|
|||
|
|
* 🔴 ⛔ **不许静默返"没有"**(不填 `direct` / `readback` —— 那会让"没装"看起来像"读到了 null")。
|
|||
|
|
* 判据 = 「该腿不可用」与「该腿跑了但读数为空」在读数里**形状完全不同** ⇒ 可分。
|
|||
|
|
*/
|
|||
|
|
async function joinConfCases() {
|
|||
|
|
const regL = optionalModule('../lib/net/relay/registry.js')
|
|||
|
|
const joinL = optionalModule('../lib/net/relay/join.js')
|
|||
|
|
const missing = []
|
|||
|
|
if (!regL.ok) missing.push({ spec: '../lib/net/relay/registry.js', code: regL.code, message: regL.message })
|
|||
|
|
if (!joinL.ok) missing.push({ spec: '../lib/net/relay/join.js', code: joinL.code, message: joinL.message })
|
|||
|
|
if (missing.length > 0) {
|
|||
|
|
return {
|
|||
|
|
available: false,
|
|||
|
|
reason: 'module-missing',
|
|||
|
|
missing,
|
|||
|
|
hint:
|
|||
|
|
'该腿需要 lib/net/relay/{registry,join}.js;registry.js 是**控制面注册表**模块,' +
|
|||
|
|
'节点形态(如 106 worker / relay 节点)不落它 ⇒ 属「节点形态」而非故障',
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const reg = regL.mod
|
|||
|
|
const joinx = joinL.mod
|
|||
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dshs-direct-selfcheck-'))
|
|||
|
|
const signer = id.generateAuthorityKey()
|
|||
|
|
const issue = (network) => {
|
|||
|
|
const doc = {
|
|||
|
|
version: reg.NODES_REGISTRY_VERSION,
|
|||
|
|
network,
|
|||
|
|
nonce: reg.newInviteNonce(randomBytes),
|
|||
|
|
issuedAt: new Date().toISOString(),
|
|||
|
|
expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(),
|
|||
|
|
}
|
|||
|
|
return { doc, sig: id.signPayloadWith(signer.privateKeyPem, reg.networkInvitePayload(doc)) }
|
|||
|
|
}
|
|||
|
|
const io = joinx.nodeJoinIo({
|
|||
|
|
fss: { existsSync: fs.existsSync, readFileSync: fs.readFileSync, writeFileSync: fs.writeFileSync, mkdirSync: fs.mkdirSync },
|
|||
|
|
crypto: id,
|
|||
|
|
os,
|
|||
|
|
fetchImpl: async () => ({ status: 599, body: 'selfcheck 不走 HTTP 通道' }),
|
|||
|
|
})
|
|||
|
|
const base = (name, extra = {}) => ({
|
|||
|
|
network: 'selfcheck-net',
|
|||
|
|
hostId: name,
|
|||
|
|
invite: issue('selfcheck-net'),
|
|||
|
|
trustedSigners: [signer.publicKey],
|
|||
|
|
nodeKeyFile: path.join(tmp, `${name}.key`),
|
|||
|
|
localConfigFile: path.join(tmp, `${name}.json`),
|
|||
|
|
outFile: path.join(tmp, `${name}-app.json`),
|
|||
|
|
...extra,
|
|||
|
|
})
|
|||
|
|
const dflt = await joinx.runJoin(base('node-a'), io)
|
|||
|
|
const off = await joinx.runJoin(base('node-b', { direct: false }), io)
|
|||
|
|
const readBack = (file) => {
|
|||
|
|
try {
|
|||
|
|
return direct.directFromNodeConfig(JSON.parse(fs.readFileSync(file, 'utf8')))
|
|||
|
|
} catch (err) {
|
|||
|
|
return undefined
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const cfgA = readBack(path.join(tmp, 'node-a.json'))
|
|||
|
|
const cfgB = readBack(path.join(tmp, 'node-b.json'))
|
|||
|
|
return {
|
|||
|
|
available: true,
|
|||
|
|
joinOk: dflt.ok === true,
|
|||
|
|
direct: cfgA,
|
|||
|
|
readback: cfgA === direct.DEFAULT_DIRECT_ENABLED,
|
|||
|
|
respectsArg: cfgB === false,
|
|||
|
|
stepDetail: dflt.ok ? (dflt.steps.find((s) => s.step === 'local-config')?.detail ?? '') : '',
|
|||
|
|
file: path.join(tmp, 'node-a.json'),
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function selfcheck() {
|
|||
|
|
const ctx = {
|
|||
|
|
cooldownMs: punch.DEFAULT_DIRECT_COOLDOWN_MS,
|
|||
|
|
portBase: punch.PUNCH_PORT_BASE,
|
|||
|
|
portSpan: punch.PUNCH_PORT_SPAN,
|
|||
|
|
maxAddrs: cand.DIRECT_CAND_MAX_ADDRS,
|
|||
|
|
dialers: net.normalizeDialers(new Map([['ops', new Set(['manager', 'w-106'])], ['u:5', new Set(['w-106'])]])),
|
|||
|
|
}
|
|||
|
|
/** `--ss` 腿(真实机取证):① 基线 ② **关闭态应为 0** ③ **正对照**(绑一个真口 ⇒ 应为 ≥1)。 */
|
|||
|
|
const ssProbe = () => ssDirectUdpLines(ctx.portBase, ctx.portSpan)
|
|||
|
|
const ssBefore = wantSs ? ssProbe() : null
|
|||
|
|
const sw = await switchCases(ctx)
|
|||
|
|
const ssDuringOffCase = wantSs ? ssProbe() : null
|
|||
|
|
/**
|
|||
|
|
* 🔴 **正对照**:不给这一腿的话,「关闭态 = 0 行」在**测量本身坏了**(例如 `ss` 取不到、
|
|||
|
|
* 正则不匹配本机 `ss` 输出格式)时**也是 0** ⇒ 假绿。所以这里真绑一个直连口,
|
|||
|
|
* 要求它**必须被 `ss` 看见**;只有"正对照 = ≥1 且关闭态 = 0"才是真证据。
|
|||
|
|
*/
|
|||
|
|
let ssDuringOnCase = null
|
|||
|
|
if (wantSs) {
|
|||
|
|
const ctrl = new punch.PunchSocket()
|
|||
|
|
try {
|
|||
|
|
await ctrl.open(ctx.portBase)
|
|||
|
|
ssDuringOnCase = ssProbe()
|
|||
|
|
} catch (err) {
|
|||
|
|
ssDuringOnCase = null
|
|||
|
|
} finally {
|
|||
|
|
try {
|
|||
|
|
ctrl.close()
|
|||
|
|
} catch (err) {
|
|||
|
|
// 已经关掉了 ⇒ 无事可做(⛔ 但不吞:上面已经把读数置 null)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const hud = hintAudit()
|
|||
|
|
const cm = candidateMatrix(ctx)
|
|||
|
|
const ph = await punchCases(ctx)
|
|||
|
|
const jc = await joinConfCases()
|
|||
|
|
/** 🆕 序㊸:**具名降级**的腿(机器可读)—— 目前只有 `joinConf`(缺控制面模块的节点形态)。 */
|
|||
|
|
const degradedLegs = jc.available === false ? ['joinConf'] : []
|
|||
|
|
return {
|
|||
|
|
version: 1,
|
|||
|
|
at: new Date().toISOString(),
|
|||
|
|
platform: process.platform,
|
|||
|
|
node: process.version,
|
|||
|
|
degraded: degradedLegs.length > 0,
|
|||
|
|
degradedLegs,
|
|||
|
|
switch: sw,
|
|||
|
|
hint: hud,
|
|||
|
|
candidate: cm,
|
|||
|
|
punch: ph,
|
|||
|
|
joinConf: jc,
|
|||
|
|
deps: depsAudit(),
|
|||
|
|
udp: {
|
|||
|
|
ssBefore,
|
|||
|
|
ssDuringOffCase,
|
|||
|
|
ssDuringOnCase,
|
|||
|
|
ssAvailable: ssBefore !== null,
|
|||
|
|
directRange: [ctx.portBase, ctx.portBase + ctx.portSpan - 1],
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 真机单侧打洞(**判据 D5/D8 的真机腿**):给对端地址,看回包与来源。 */
|
|||
|
|
async function realPunch() {
|
|||
|
|
const peers = (opt('peer') ?? '').split(',').map((s) => s.trim()).filter((s) => s !== '')
|
|||
|
|
if (peers.length === 0) {
|
|||
|
|
process.stderr.write('✗ punch 需要 --peer <ip:port>[,…]\n')
|
|||
|
|
process.exit(2)
|
|||
|
|
}
|
|||
|
|
const targets = peers.map((p) => {
|
|||
|
|
const i = p.lastIndexOf(':')
|
|||
|
|
return { host: p.slice(0, i), port: Number(p.slice(i + 1)) }
|
|||
|
|
})
|
|||
|
|
for (const t of targets) {
|
|||
|
|
if (!cand.isValidAddress(t)) {
|
|||
|
|
process.stderr.write(`✗ punch:非法地址 ${JSON.stringify(t)}\n`)
|
|||
|
|
process.exit(2)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const selfPort = Number(opt('self-port') ?? punch.PUNCH_PORT_BASE)
|
|||
|
|
const deadlineMs = Number(opt('deadline') ?? punch.DEFAULT_PUNCH_DEADLINE_MS)
|
|||
|
|
const bindHost = opt('bind') ?? '0.0.0.0'
|
|||
|
|
// 🔴 常量必须取自 `punch.js`(**真身**所在):`direct/index.js` 是选择性桶导出,
|
|||
|
|
// 它**不**转发 `DEFAULT_PUNCH_DEADLINE_MS` / `DEFAULT_DIRECT_COOLDOWN_MS`
|
|||
|
|
// ⇒ 从桶上取 = `undefined` ⇒ `Number(undefined)` = `NaN` ⇒ 冷却构造期断言直接抛(已实测踩过)。
|
|||
|
|
const cd = new punch.DirectCooldown(Number(opt('cooldown-ms') ?? punch.DEFAULT_DIRECT_COOLDOWN_MS))
|
|||
|
|
const r = await punch.runPunchAttempt(
|
|||
|
|
{ peer: `real/${targets.map((t) => `${t.host}:${t.port}`).join('+')}`, selfPort, targets, deadlineMs, cooldown: cd, bindHost },
|
|||
|
|
{ sleep },
|
|||
|
|
)
|
|||
|
|
const readings = {
|
|||
|
|
at: new Date().toISOString(),
|
|||
|
|
bindHost,
|
|||
|
|
selfPort,
|
|||
|
|
targets,
|
|||
|
|
deadlineMs,
|
|||
|
|
reason: r.reason,
|
|||
|
|
ok: r.ok,
|
|||
|
|
recvLocal: r.recvLocal,
|
|||
|
|
peerSeen: r.peerSeen,
|
|||
|
|
bidirectional: r.bidirectional,
|
|||
|
|
sent: r.sent,
|
|||
|
|
elapsedMs: r.elapsedMs,
|
|||
|
|
sources: r.sources,
|
|||
|
|
detail: r.detail,
|
|||
|
|
// ⚠️ 单侧跑不出"双向"(对端确认要靠 relay 通道回报,那是 S6)⇒ 两侧的 `recvLocal` 合起来才是判据
|
|||
|
|
note: '单侧读数:两侧都 recvLocal > 0 ⇒ 打洞成立;一侧 0 ⇒ 该方向被挡(云安全组 / NAT)',
|
|||
|
|
}
|
|||
|
|
if (args.json) process.stdout.write(`${JSON.stringify(readings, null, 2)}\n`)
|
|||
|
|
else {
|
|||
|
|
process.stdout.write(
|
|||
|
|
`打洞探测(${readings.selfPort} → ${targets.map((t) => `${t.host}:${t.port}`).join(',')}):reason=${r.reason} 收包=${r.recvLocal} 发出=${r.sent} 耗时=${r.elapsedMs}ms\n`,
|
|||
|
|
)
|
|||
|
|
process.stdout.write(` 回包来源:${r.sources.join(',') || '(无)'}\n`)
|
|||
|
|
process.stdout.write(`${r.detail}\n`)
|
|||
|
|
}
|
|||
|
|
if (opt('out') !== undefined) fs.writeFileSync(opt('out'), `${JSON.stringify(readings, null, 2)}\n`, { mode: 0o644 })
|
|||
|
|
return readings.ok ? 0 : 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* ── 🆕 序㊶ · S6:`peer` 档**接线**自检(`peer-wire`)─────────────────────────────────
|
|||
|
|
*
|
|||
|
|
* 判据(对齐交接单 §5 **D7**):
|
|||
|
|
* ① **通道顺序固定** = `direct` → `wss`;`direct` 可用时**不碰** `wss`("优先直连"不是口号)。
|
|||
|
|
* ② **具名降级**:不可用必须**点名原因**(`disabled` / `invalid-switch` / `cooldown` /
|
|||
|
|
* `no-address` / `data-plane-pending` / `not-wired`),⛔ 不许"没有原因地走了另一条通道"。
|
|||
|
|
* ③ 🔴 **D7 复算闸门**:取回的**落库字节**必须复算出同一个块 id ——
|
|||
|
|
* 篡改块 ⇒ **丢弃且不算命中**(`idMismatches +1`、`peer` 命中 **0**)。
|
|||
|
|
* ④ **接入判定复用 S5**:直连候选只从 `noteDirectCandidate(CandidateLedger#judge 的结果)` 进来;
|
|||
|
|
* 被拒的 verdict ⇒ **不进候选**。
|
|||
|
|
* ⑤ **块 id 不变性读数**(与接线前逐字比对的那一份)。
|
|||
|
|
*/
|
|||
|
|
async function peerWire() {
|
|||
|
|
const rtmod = require('../lib/net/relay/content/runtime.js')
|
|||
|
|
const chunker = require('../lib/net/relay/content/chunker.js')
|
|||
|
|
|
|||
|
|
const net = 'ops'
|
|||
|
|
const group = 's6-wire'
|
|||
|
|
|
|||
|
|
// ── 夹具块(小,便于逐字节比对)─────────────────────────────────────────────
|
|||
|
|
const blockBytes = Buffer.from('序㊶ · S6 · peer 接线夹具块(tamper fixture)', 'utf8')
|
|||
|
|
const blockId = chunker.blockIdOf(blockBytes)
|
|||
|
|
|
|||
|
|
/** 夹具通道:`good` 给真字节/`tamper` 改 1 字节/`miss` 说没有/`down:<reason>` 具名不可用。 */
|
|||
|
|
const mkChannel = (name, mode) => ({
|
|||
|
|
name,
|
|||
|
|
available: () =>
|
|||
|
|
typeof mode === 'string' && mode.startsWith('down:')
|
|||
|
|
? { ok: false, reason: mode.slice(5), detail: `夹具具名降级 ${mode.slice(5)}` }
|
|||
|
|
: { ok: true },
|
|||
|
|
fetch: async () => {
|
|||
|
|
if (mode === 'good') return blockBytes
|
|||
|
|
if (mode === 'tamper') {
|
|||
|
|
const b = Buffer.from(blockBytes)
|
|||
|
|
b[0] ^= 0xff
|
|||
|
|
return b
|
|||
|
|
}
|
|||
|
|
if (mode === 'miss') return undefined
|
|||
|
|
throw new Error(`夹具通道 ${name} 故障(故意:验证"抛错 ≠ 未命中")`)
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/** 造一个 runtime + 一个同组 peer(声明持有该块)。 */
|
|||
|
|
const build = (opts) => {
|
|||
|
|
const r = new rtmod.ContentRuntime({ network: net, group, ...opts })
|
|||
|
|
r.peers.addPeer({ name: `${net}/peerA`, network: net, group, holds: [blockId] })
|
|||
|
|
return r
|
|||
|
|
}
|
|||
|
|
/** 跑一次取块并抓读数。 */
|
|||
|
|
const leg = async (opts) => {
|
|||
|
|
const r = build(opts)
|
|||
|
|
const outcome = await r.source.fetch(blockId)
|
|||
|
|
const w = r.peerWireSnapshot()
|
|||
|
|
const snap = r.snapshot()
|
|||
|
|
return {
|
|||
|
|
tier: outcome === undefined ? null : outcome.tier,
|
|||
|
|
bytesMatch: outcome === undefined ? false : outcome.bytes.equals(blockBytes),
|
|||
|
|
attempts: w.attempts,
|
|||
|
|
hits: w.hits,
|
|||
|
|
misses: w.misses,
|
|||
|
|
errors: w.errors,
|
|||
|
|
unavailable: w.unavailable,
|
|||
|
|
downReasons: w.downReasons,
|
|||
|
|
idChecks: w.idChecks,
|
|||
|
|
idMismatches: w.idMismatches,
|
|||
|
|
peerHitsTotal: snap.source.peer,
|
|||
|
|
peerMissesTotal: snap.source.peerMiss,
|
|||
|
|
sourceErrors: snap.sourceErrors,
|
|||
|
|
wireWired: w.wired,
|
|||
|
|
wireOrder: w.order,
|
|||
|
|
directCandidates: w.directCandidates,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const directOn = { env: { [direct.DIRECT_ENV_KEY]: 'true' } }
|
|||
|
|
const legs = {}
|
|||
|
|
|
|||
|
|
// ① direct 可用(夹具)⇒ 命中直连,**不碰 wss**
|
|||
|
|
legs.directPreferred = await leg({
|
|||
|
|
direct: false,
|
|||
|
|
peerChannels: { direct: mkChannel('direct', 'good'), wss: mkChannel('wss', 'good') },
|
|||
|
|
})
|
|||
|
|
// ② direct 具名不可用(no-address:开关开、无准入候选)⇒ 回落 wss 并命中
|
|||
|
|
legs.fallbackNoAddress = await leg({ direct: directOn, peerChannels: { wss: mkChannel('wss', 'good') } })
|
|||
|
|
// ③ 开关关 ⇒ `disabled` ⇒ 回落
|
|||
|
|
legs.switchOff = await leg({
|
|||
|
|
direct: { env: { [direct.DIRECT_ENV_KEY]: '0' } },
|
|||
|
|
peerChannels: { wss: mkChannel('wss', 'good') },
|
|||
|
|
})
|
|||
|
|
// ④ 开关取值非法 ⇒ `invalid-switch`(⛔ 不静默当开/当关)⇒ 回落
|
|||
|
|
legs.switchInvalid = await leg({
|
|||
|
|
direct: { env: { [direct.DIRECT_ENV_KEY]: 'maybe' } },
|
|||
|
|
peerChannels: { wss: mkChannel('wss', 'good') },
|
|||
|
|
})
|
|||
|
|
// ⑤ 通道自己报具名降级(cooldown)⇒ 记账可断言 + 回落
|
|||
|
|
legs.namedDowngradeCooldown = await leg({
|
|||
|
|
direct: false,
|
|||
|
|
peerChannels: { direct: mkChannel('direct', 'down:cooldown'), wss: mkChannel('wss', 'good') },
|
|||
|
|
})
|
|||
|
|
// ⑥ 两条通道都没装 ⇒ 全是 `not-wired`,诚实回"没有"(⛔ 不是假绿)
|
|||
|
|
legs.noChannelWired = await leg({ direct: false })
|
|||
|
|
// ⑦ 🔴 D7:篡改块 ⇒ **丢弃**(不算命中、错误计数 +1)
|
|||
|
|
legs.tamperedBlockRejected = await leg({
|
|||
|
|
direct: false,
|
|||
|
|
peerChannels: { wss: mkChannel('wss', 'tamper') },
|
|||
|
|
})
|
|||
|
|
// ⑧ 好块 ⇒ 命中(证明接线真的能取到,不是"永远回没有")
|
|||
|
|
legs.goodBlockAccepted = await leg({ direct: false, peerChannels: { wss: mkChannel('wss', 'good') } })
|
|||
|
|
// ⑨ 通道说"没有" ⇒ 记 miss(与不可用/抛错互相可区分)
|
|||
|
|
legs.channelMiss = await leg({ direct: false, peerChannels: { wss: mkChannel('wss', 'miss') } })
|
|||
|
|
// ⑩ 通道抛错 ⇒ 记 errors(⛔ 不许整体失败、⛔ 不许吞)
|
|||
|
|
legs.channelError = await leg({ direct: false, peerChannels: { wss: mkChannel('wss', 'error') } })
|
|||
|
|
|
|||
|
|
// ⑪ 候选接入:**复用 S5 准入**(`CandidateLedger#judge`)+ 数据面未建成 ⇒ 具名 `data-plane-pending`
|
|||
|
|
const dialers = new Map([[net, new Set(['peerA', 'self'])]])
|
|||
|
|
const judgeOk = cand
|
|||
|
|
.admitCandidate(cand.encodeDirectMessage({ hostId: 'peerA', network: net, addrs: [{ host: '127.0.0.1', port: 21100 }] }), {
|
|||
|
|
dialers,
|
|||
|
|
from: { network: net, hostId: 'peerA' },
|
|||
|
|
selfHostId: 'self',
|
|||
|
|
})
|
|||
|
|
const judgeRejected = cand.admitCandidate(
|
|||
|
|
cand.encodeDirectMessage({ hostId: 'peerA', network: net, addrs: [{ host: '127.0.0.1', port: 21100 }] }),
|
|||
|
|
{ dialers, from: { network: net, hostId: 'peerA' }, selfHostId: 'stranger-outside-whitelist' },
|
|||
|
|
)
|
|||
|
|
const rCand = build({ direct: directOn, peerChannels: { wss: mkChannel('wss', 'good') } })
|
|||
|
|
const accepted = rCand.noteDirectCandidate(judgeOk)
|
|||
|
|
const afterAccept = await rCand.source.fetch(blockId)
|
|||
|
|
const acceptedLeg = {
|
|||
|
|
judgeOk: judgeOk.ok,
|
|||
|
|
judgeReason: judgeOk.ok ? null : judgeOk.reason,
|
|||
|
|
noteOk: accepted.ok,
|
|||
|
|
directCandidates: rCand.peerWireSnapshot().directCandidates,
|
|||
|
|
tier: afterAccept === undefined ? null : afterAccept.tier,
|
|||
|
|
downReasons: rCand.peerWireSnapshot().downReasons,
|
|||
|
|
idChecks: rCand.peerWireSnapshot().idChecks,
|
|||
|
|
idMismatches: rCand.peerWireSnapshot().idMismatches,
|
|||
|
|
}
|
|||
|
|
const rCand2 = build({ direct: directOn })
|
|||
|
|
const rejectedNote = rCand2.noteDirectCandidate(judgeRejected)
|
|||
|
|
const rejectedLeg = {
|
|||
|
|
judgeOk: judgeRejected.ok,
|
|||
|
|
judgeReason: judgeRejected.ok ? null : judgeRejected.reason,
|
|||
|
|
noteOk: rejectedNote.ok,
|
|||
|
|
noteReason: rejectedNote.ok ? null : rejectedNote.reason,
|
|||
|
|
// ⛔ 被拒 ⇒ 候选集**必须仍为空**(白名单判定**不在 content 侧**另写一份)
|
|||
|
|
directCandidates: rCand2.peerWireSnapshot().directCandidates,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ⑫ 块 id 不变性读数(与"接线前"逐字比对的那一份;同 §8.2 的 5,255,225 B 夹具口径)
|
|||
|
|
const big = Buffer.allocUnsafe(5_255_225)
|
|||
|
|
let x = 0x12345678
|
|||
|
|
for (let i = 0; i < big.length; i += 1) {
|
|||
|
|
x = (x * 1103515245 + 12345) >>> 0
|
|||
|
|
big[i] = (x >>> 16) & 0xff
|
|||
|
|
}
|
|||
|
|
const rIds = build({ direct: false })
|
|||
|
|
const put = rIds.putContent(big)
|
|||
|
|
const idsDigest = require('node:crypto')
|
|||
|
|
.createHash('sha256')
|
|||
|
|
.update(JSON.stringify(put.plan))
|
|||
|
|
.digest('hex')
|
|||
|
|
|
|||
|
|
// ⑬ 真机腿(可选):读 relay `/status.content.peerWire`(`--status-url`)
|
|||
|
|
let realmachine = null
|
|||
|
|
if (opt('status-url') !== undefined) {
|
|||
|
|
try {
|
|||
|
|
const body = execFileSync('curl', ['-s', '--max-time', '10', opt('status-url')], { encoding: 'utf8' })
|
|||
|
|
const j = JSON.parse(body)
|
|||
|
|
const pw = (j.content ?? {}).peerWire
|
|||
|
|
realmachine = {
|
|||
|
|
url: opt('status-url'),
|
|||
|
|
contentPresent: j.content !== undefined,
|
|||
|
|
peerWirePresent: pw !== undefined,
|
|||
|
|
order: pw?.order ?? null,
|
|||
|
|
wired: pw?.wired ?? null,
|
|||
|
|
idChecks: pw?.idChecks ?? null,
|
|||
|
|
idMismatches: pw?.idMismatches ?? null,
|
|||
|
|
downReasons: pw?.downReasons ?? null,
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
realmachine = { url: opt('status-url'), error: err instanceof Error ? err.message : String(err) }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 判定(每条腿一个布尔;⛔ 不写"大概对")──────────────────────────────────
|
|||
|
|
const checks = {
|
|||
|
|
'① direct 优先(不碰 wss)':
|
|||
|
|
legs.directPreferred.tier === 'peer' &&
|
|||
|
|
legs.directPreferred.bytesMatch === true &&
|
|||
|
|
legs.directPreferred.hits.direct === 1 &&
|
|||
|
|
legs.directPreferred.attempts.wss === 0 &&
|
|||
|
|
legs.directPreferred.idChecks === 1 &&
|
|||
|
|
legs.directPreferred.idMismatches === 0,
|
|||
|
|
'② no-address ⇒ 具名回落 wss':
|
|||
|
|
legs.fallbackNoAddress.downReasons['direct:no-address'] === 1 &&
|
|||
|
|
legs.fallbackNoAddress.hits.wss === 1 &&
|
|||
|
|
legs.fallbackNoAddress.tier === 'peer',
|
|||
|
|
'③ 开关关 ⇒ disabled':
|
|||
|
|
legs.switchOff.downReasons['direct:disabled'] === 1 && legs.switchOff.hits.wss === 1,
|
|||
|
|
'④ 开关非法 ⇒ invalid-switch':
|
|||
|
|
legs.switchInvalid.downReasons['direct:invalid-switch'] === 1 && legs.switchInvalid.hits.wss === 1,
|
|||
|
|
'⑤ 通道具名降级(cooldown)':
|
|||
|
|
legs.namedDowngradeCooldown.downReasons['direct:cooldown'] === 1 &&
|
|||
|
|
legs.namedDowngradeCooldown.hits.wss === 1,
|
|||
|
|
'⑥ 未装通道 ⇒ not-wired ×2 且诚实回没有':
|
|||
|
|
legs.noChannelWired.downReasons['direct:not-wired'] === 1 &&
|
|||
|
|
legs.noChannelWired.downReasons['wss:not-wired'] === 1 &&
|
|||
|
|
legs.noChannelWired.tier === null &&
|
|||
|
|
legs.noChannelWired.hits.direct === 0 &&
|
|||
|
|
legs.noChannelWired.hits.wss === 0,
|
|||
|
|
'⑦ 🔴 D7 篡改块被丢弃(不算命中)':
|
|||
|
|
legs.tamperedBlockRejected.idChecks === 1 &&
|
|||
|
|
legs.tamperedBlockRejected.idMismatches === 1 &&
|
|||
|
|
legs.tamperedBlockRejected.tier === null &&
|
|||
|
|
legs.tamperedBlockRejected.peerHitsTotal === 0 &&
|
|||
|
|
legs.tamperedBlockRejected.hits.wss === 0 &&
|
|||
|
|
legs.tamperedBlockRejected.errors.wss === 1,
|
|||
|
|
'⑧ 好块被接受(接线真能取到)':
|
|||
|
|
legs.goodBlockAccepted.tier === 'peer' &&
|
|||
|
|
legs.goodBlockAccepted.bytesMatch === true &&
|
|||
|
|
legs.goodBlockAccepted.hits.wss === 1 &&
|
|||
|
|
legs.goodBlockAccepted.idChecks === 1 &&
|
|||
|
|
legs.goodBlockAccepted.idMismatches === 0,
|
|||
|
|
'⑨ 通道说没有 ⇒ miss(≠ 不可用 / ≠ 抛错)':
|
|||
|
|
legs.channelMiss.misses.wss === 1 && legs.channelMiss.tier === null && legs.channelMiss.errors.wss === 0,
|
|||
|
|
'⑩ 通道抛错 ⇒ errors(≠ 未命中)':
|
|||
|
|
legs.channelError.errors.wss === 1 && legs.channelError.misses.wss === 0 && legs.channelError.tier === null,
|
|||
|
|
'⑪ 直连候选只收 S5 准入 ok 的结果 ⇒ data-plane-pending':
|
|||
|
|
acceptedLeg.judgeOk === true &&
|
|||
|
|
acceptedLeg.noteOk === true &&
|
|||
|
|
acceptedLeg.directCandidates === 1 &&
|
|||
|
|
acceptedLeg.downReasons['direct:data-plane-pending'] === 1 &&
|
|||
|
|
acceptedLeg.tier === 'peer',
|
|||
|
|
'⑫ 被拒 verdict 不进候选(⛔ 白名单不另写一份)':
|
|||
|
|
rejectedLeg.judgeOk === false &&
|
|||
|
|
rejectedLeg.noteOk === false &&
|
|||
|
|
rejectedLeg.directCandidates === 0,
|
|||
|
|
'⑬ 块 id 不变性读数产出':
|
|||
|
|
idsDigest.length === 64 && put.plan.length === 6 && rIds.snapshot().source.local === 0,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const readings = {
|
|||
|
|
at: new Date().toISOString(),
|
|||
|
|
group,
|
|||
|
|
fixture: { blockBytes: blockBytes.length, blockId, idsFixtureBytes: big.length, idsDigest, idsPlan: put.plan },
|
|||
|
|
channelOrder: legs.directPreferred.wireOrder,
|
|||
|
|
legs,
|
|||
|
|
candidateAdmission: { accepted: acceptedLeg, rejected: rejectedLeg },
|
|||
|
|
realmachine,
|
|||
|
|
checks,
|
|||
|
|
ok: Object.values(checks).every(Boolean),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (args.json) process.stdout.write(`${JSON.stringify(readings, null, 2)}\n`)
|
|||
|
|
else {
|
|||
|
|
process.stdout.write(`peer 接线自检(通道顺序 ${readings.channelOrder.join(' → ')})\n`)
|
|||
|
|
for (const [k, v] of Object.entries(checks)) process.stdout.write(` ${v ? '✅' : '❌'} ${k}\n`)
|
|||
|
|
process.stdout.write(
|
|||
|
|
` 夹具块 id=${blockId}|不改性读数 idsDigest=${idsDigest}(与接线前逐字比对用)\n`,
|
|||
|
|
)
|
|||
|
|
process.stdout.write(` 篡改腿读数:${JSON.stringify(legs.tamperedBlockRejected.downReasons)} idChecks=${legs.tamperedBlockRejected.idChecks} idMismatches=${legs.tamperedBlockRejected.idMismatches}\n`)
|
|||
|
|
if (realmachine !== null) process.stdout.write(` 真机腿:${JSON.stringify(realmachine)}\n`)
|
|||
|
|
}
|
|||
|
|
if (opt('out') !== undefined) fs.writeFileSync(opt('out'), `${JSON.stringify(readings, null, 2)}\n`, { mode: 0o644 })
|
|||
|
|
return readings.ok ? 0 : 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 只读现状(开关 + 提示)。 */
|
|||
|
|
function status() {
|
|||
|
|
const file = process.env.DSHS_OVERLAY_NODE_CONFIG || direct.NODE_CONFIG_FILE_DEFAULT
|
|||
|
|
let localDirect
|
|||
|
|
let present = false
|
|||
|
|
try {
|
|||
|
|
if (fs.existsSync(file)) {
|
|||
|
|
present = true
|
|||
|
|
localDirect = direct.directFromNodeConfig(JSON.parse(fs.readFileSync(file, 'utf8')))
|
|||
|
|
}
|
|||
|
|
} catch (err) {
|
|||
|
|
process.stderr.write(`⚠️ 本机配置 ${file} 读不出来(⛔ 与"没配置"可分):${err.message}\n`)
|
|||
|
|
}
|
|||
|
|
const state = direct.resolveDirectSwitch(process.env, { localDirect })
|
|||
|
|
const out = {
|
|||
|
|
envKey: direct.DIRECT_ENV_KEY,
|
|||
|
|
defaultEnabled: direct.DEFAULT_DIRECT_ENABLED,
|
|||
|
|
effective: state.enabled,
|
|||
|
|
source: state.source,
|
|||
|
|
raw: state.raw,
|
|||
|
|
invalid: state.invalid,
|
|||
|
|
nodeConfig: { file, present, direct: localDirect ?? null },
|
|||
|
|
hint: direct.directHintLines(),
|
|||
|
|
}
|
|||
|
|
if (args.json) process.stdout.write(`${JSON.stringify(out, null, 2)}\n`)
|
|||
|
|
else {
|
|||
|
|
process.stdout.write(
|
|||
|
|
`直连(打洞):${state.enabled === null ? `❌ 取值非法(${state.invalid})` : state.enabled ? '开' : '关'}` +
|
|||
|
|
`|来源 ${state.source}${state.raw === null ? '' : `(${direct.DIRECT_ENV_KEY}=${state.raw})`}` +
|
|||
|
|
`|缺省 ${direct.DEFAULT_DIRECT_ENABLED}\n`,
|
|||
|
|
)
|
|||
|
|
process.stdout.write(`本机配置 ${file}:${present ? `存在(direct=${localDirect ?? '未设'})` : '不存在'}\n`)
|
|||
|
|
for (const l of out.hint) process.stdout.write(` ${l}\n`)
|
|||
|
|
}
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* `join 回读` 那一行的**可读文本** —— 🆕 序㊸:**两种形态必须长得不一样**。
|
|||
|
|
*
|
|||
|
|
* - `available:true` ⇒ 读数行(`direct=… 回读一致=… 尊重 --direct=…`)
|
|||
|
|
* - `available:false` ⇒ **具名降级行**:`⛔ 不可用(module-missing)` + **缺哪个模块** +
|
|||
|
|
* 原文 message。🔴 这是"**没装**";⛔ 与"**没采到**"(跑了但读数为空)必须可分。
|
|||
|
|
*/
|
|||
|
|
function joinConfLine(jc) {
|
|||
|
|
if (jc === undefined || jc === null) return '⚠️ 无读数(⛔ 与"不可用"必须可分)'
|
|||
|
|
if (jc.available === false) {
|
|||
|
|
const miss = Array.isArray(jc.missing) ? jc.missing : []
|
|||
|
|
return (
|
|||
|
|
`⛔ 不可用(${jc.reason ?? 'unknown'})—— 缺 ${miss.map((m) => m.spec).join(' , ') || '(未具名)'}` +
|
|||
|
|
`|⚠️ 这是「没装」不是「没采到」` +
|
|||
|
|
(miss[0]?.message === undefined ? '' : `|原文 message:${miss.map((m) => m.message).join(' | ')}`)
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
return `direct=${jc.direct}(回读一致=${jc.readback} / 尊重 --direct=${jc.respectsArg})`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function main() {
|
|||
|
|
const sub = args._[0] ?? 'status'
|
|||
|
|
if (sub === 'selfcheck') {
|
|||
|
|
const readings = await selfcheck()
|
|||
|
|
if (opt('out') !== undefined) {
|
|||
|
|
fs.writeFileSync(opt('out'), `${JSON.stringify(readings, null, 2)}\n`, { mode: 0o644 })
|
|||
|
|
if (!args.json) process.stdout.write(`✓ 读数已落盘 ${opt('out')}\n`)
|
|||
|
|
}
|
|||
|
|
if (args.json) {
|
|||
|
|
process.stdout.write(`${JSON.stringify(readings, null, 2)}\n`)
|
|||
|
|
} else {
|
|||
|
|
const s = readings.switch
|
|||
|
|
process.stdout.write(
|
|||
|
|
`开关:缺省=${s.defaultCase.enabled}(来源 ${s.defaultCase.source})|关=${s.offCase.enabled} ⇒ UDP socket ${s.offCase.udpSocketsOpened} 个 / 候选 ${s.offCase.candidatesEmitted} 条|非法值=${s.badCase.enabled}\n`,
|
|||
|
|
)
|
|||
|
|
process.stdout.write(`提示:${readings.hint.parts} 段|怎么关=${readings.hint.mentionsOff}|谁能连=${readings.hint.mentionsScope}|关掉影响=${readings.hint.mentionsImpact}\n`)
|
|||
|
|
process.stdout.write(`候选:接受 ${readings.candidate.accepted}|拒绝 ${readings.candidate.rejected.length}|静默拒绝 ${readings.candidate.silentRejections}\n`)
|
|||
|
|
process.stdout.write(
|
|||
|
|
`打洞:成功 ${readings.punch.success.bidirectional ? '双向 ✅' : '❌'}|单向负腿 ${readings.punch.oneWay.a.reason}/${readings.punch.oneWay.b.reason}|判死 ${readings.punch.dead[0].reason}(${readings.punch.dead[0].elapsedMs}ms ≤ 有界 ${readings.punch.dead[0].bounded})\n`,
|
|||
|
|
)
|
|||
|
|
process.stdout.write(`冷却:${readings.punch.cooldown.ms}ms|二次被挡=${readings.punch.cooldown.secondAttemptBlocked}|0 值=${readings.punch.cooldown.zeroRejected}\n`)
|
|||
|
|
process.stdout.write(`join 回读:${joinConfLine(readings.joinConf)}\n`)
|
|||
|
|
process.stdout.write(`依赖面:${readings.deps.ok ? '✅ 只有内建' : `❌ ${readings.deps.bad.join(',')}`}|${readings.deps.found.join(' ')}\n`)
|
|||
|
|
if (readings.udp.ssAvailable)
|
|||
|
|
process.stdout.write(
|
|||
|
|
`UDP 直连口 (${readings.udp.directRange?.[0]}–${readings.udp.directRange?.[1]}) 行数:` +
|
|||
|
|
`基线 ${readings.udp.ssBefore}|正对照(绑真口)${readings.udp.ssDuringOnCase}|关闭态 ${readings.udp.ssDuringOffCase}\n`,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
return 0
|
|||
|
|
}
|
|||
|
|
if (sub === 'punch') return realPunch()
|
|||
|
|
if (sub === 'peer-wire') return peerWire()
|
|||
|
|
if (sub === 'status') return status()
|
|||
|
|
process.stderr.write(`✗ 未知子命令 ${sub}(可用:selfcheck | punch | peer-wire | status)\n`)
|
|||
|
|
return 2
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
main().then(
|
|||
|
|
(code) => process.exit(code),
|
|||
|
|
(err) => {
|
|||
|
|
process.stderr.write(`✗ 直连自检异常:${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
|||
|
|
process.exit(1)
|
|||
|
|
},
|
|||
|
|
)
|