把这批「已 scp 到 47 / 106 生产并在跑、但一直未入 git」的实现补进版本库
(其中 P2 的全部新源码此前一直是 untracked)。
分棒内容:
- 序㉔ 内容分发块级寻址:src/net/relay/content/{runtime,source}.ts
- 序㉖ 骨干稳定选路(jitter):src/net/relay/{index,network}.ts、src/web/server.ts
- 序㉘ 组密钥加密(GCM 确定性):src/net/relay/content/{runtime,source}.ts
- 序㉛ P1 一键加入 + 分组准入:src/net/relay/{join,registry}.ts、
src/web/routes/overlay-nodes.ts、scripts/overlay-node-{join,admit}.cjs、
test/overlay-join.test.mjs
- 序㊵/㊶ 直连打洞 + peer 档:src/net/relay/direct/{candidate,index,punch}.ts、
scripts/overlay-direct-probe.cjs、test/overlay-direct.test.mjs
- 序㊷/㊸ 观测面:scripts/overlay-probe.cjs、scripts/overlay-failover-drill.cjs
零回归三件套(2026-09-18 16:2x 提交前复跑,全绿):
- npm test 201 tests / pass 200 / fail 0 / skipped 1(Node v22.22.2)
- overlay-probe.cjs --table 28 PASS / 0 SKIP / 0 FAIL
- overlay-failover-drill.cjs --scene all --table 12 PASS / 0 SKIP / 0 FAIL
⛔ 未纳入:_tmp_seq24/、_tmp_seq40/、_中间产物_待清理/(本机临时产物,仍 untracked)
🔴 未实施:D8 云安全组乙-1(待人工在云控制台落地,见
交接单_覆盖网络直连与P2P_20260918.md §8.11)
1021 lines
46 KiB
JavaScript
1021 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)
|
||
},
|
||
)
|