From c452013129c810944a7b4996076b9304976ce04e Mon Sep 17 00:00:00 2001 From: maogeigei Date: Fri, 18 Sep 2026 16:37:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(overlay):=20=E8=A6=86=E7=9B=96=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E7=BA=BF=20=E5=BA=8F=E3=89=94=E2=80=93=E3=8A=B8=20?= =?UTF-8?q?=E8=A1=A5=E6=8F=90=E4=BA=A4=20=E2=80=94=E2=80=94=20=E6=BA=90?= =?UTF-8?q?=E7=A0=81=E4=B8=8E=E5=B7=B2=E9=83=A8=E7=BD=B2=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E5=AF=B9=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把这批「已 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) --- scripts/overlay-direct-probe.cjs | 1020 ++++++++++++++++++++++++++++ scripts/overlay-failover-drill.cjs | 95 ++- scripts/overlay-node-admit.cjs | 520 ++++++++++++++ scripts/overlay-node-join.cjs | 183 +++++ scripts/overlay-probe.cjs | 858 +++++++++++++++++++++-- src/net/relay/content/runtime.ts | 390 ++++++++++- src/net/relay/content/source.ts | 4 + src/net/relay/direct/candidate.ts | 382 +++++++++++ src/net/relay/direct/index.ts | 356 ++++++++++ src/net/relay/direct/punch.ts | 586 ++++++++++++++++ src/net/relay/index.ts | 103 ++- src/net/relay/join.ts | 434 ++++++++++++ src/net/relay/network.ts | 66 ++ src/net/relay/registry.ts | 503 ++++++++++++++ src/web/routes/overlay-nodes.ts | 142 ++++ src/web/server.ts | 3 + test/overlay-direct.test.mjs | 403 +++++++++++ test/overlay-join.test.mjs | 634 +++++++++++++++++ 18 files changed, 6604 insertions(+), 78 deletions(-) create mode 100644 scripts/overlay-direct-probe.cjs create mode 100644 scripts/overlay-node-admit.cjs create mode 100644 scripts/overlay-node-join.cjs create mode 100644 src/net/relay/direct/candidate.ts create mode 100644 src/net/relay/direct/index.ts create mode 100644 src/net/relay/direct/punch.ts create mode 100644 src/net/relay/join.ts create mode 100644 src/net/relay/registry.ts create mode 100644 src/web/routes/overlay-nodes.ts create mode 100644 test/overlay-direct.test.mjs create mode 100644 test/overlay-join.test.mjs diff --git a/scripts/overlay-direct-probe.cjs b/scripts/overlay-direct-probe.cjs new file mode 100644 index 0000000..022ce0f --- /dev/null +++ b/scripts/overlay-direct-probe.cjs @@ -0,0 +1,1020 @@ +#!/usr/bin/env node +/** + * 覆盖网络 **直连(打洞)观测面**(序㊵ · P2 · S5)—— **只读** + 一个**自检**子命令。 + * + * ## 三个子命令 + * | 子命令 | 作用 | 副作用 | + * |---|---|---| + * | `selfcheck [--json] [--out ] [--ss]` | 跑完**全部**直连判据(开关 / 提示 / 候选准入 / 打洞 / 冷却 / join 回读)并出**机器可读读数** | 仅在系统临时目录里建夹具;`--ss` 时短暂绑一个 UDP 口(秒级) | + * | `punch --peer [,…] [--self-port

] [--deadline ] [--bind ] [--json]` | **真机腿**:本机单侧打洞探测(读对方是否回包) | 短暂绑一个 UDP 口(秒级) | + * | `peer-wire [--json] [--out ] [--status-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 [,…]\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:` 具名不可用。 */ + 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) + }, +) diff --git a/scripts/overlay-failover-drill.cjs b/scripts/overlay-failover-drill.cjs index 2e7d849..0c59870 100644 --- a/scripts/overlay-failover-drill.cjs +++ b/scripts/overlay-failover-drill.cjs @@ -41,8 +41,12 @@ * 3. **`systemctl is-active` 在 inactive 时退出码 = 3** ⇒ `execFileSync` 会抛 ⇒ 首轮**中断在幕1 半路** * 且把 47 的 relay 留在停用态。凡是**读状态**的远端命令一律 `|| true`。 * - * ## 运行(cwd = 工作区根;⛔ 阈值零硬编码) + * ## 运行(🆕 序㊸:**cwd 不再受限**;⛔ 阈值零硬编码) * `node "D:/github/dsh_shenxian/scripts/overlay-failover-drill.cjs" [--scene 1|2|3|all]` + * ⚠️ **参数表定位已与 cwd 解耦**(从代码仓根 / 任意目录都可跑):候选目录链 = `--table` > `--dir` + * > `DSHS_OVERLAY_TABLE_DIR` > **注册文件**(缺省 `~/.dshs/overlay-table-dir`)> `cwd` > 脚本目录及上两级。 + * 🔴 `--scene trace` / `--scene sample` 的**明细落盘**仍按 `cwd` 写 `_中间产物_待清理/seq9-trace/` + * (那两幕是取证子命令,⛔ 不是本条约的范围)。 * * ⚠️ **本脚本会真停生产单元**(`dshs-relay`);`finally` 里**一律复原**(失败也复原)。 */ @@ -51,6 +55,7 @@ const { execFileSync } = require('node:child_process') const fs = require('node:fs') +const os = require('node:os') const path = require('node:path') /** 参数表文件名(⛔ 不写死日期数字)。 */ @@ -61,6 +66,14 @@ const KEY_ONLY_RE = /^`([A-Z0-9_]+)`$/ /* ─────────── 参数表装载(与 overlay-probe.cjs 同款;⛔ 脚本内无魔数) ─────────── */ +/** + * 🆕 **序㊸:参数表定位与 cwd 解耦**(与 `overlay-probe.cjs` **同款**,改动理由见该文件同名注释)。 + * 候选目录链 = `--table` > `--dir` > `DSHS_OVERLAY_TABLE_DIR` > **注册文件** + * (`DSHS_OVERLAY_TABLE_REGISTRY`,缺省 `~/.dshs/overlay-table-dir`,一行一个目录、`#` 注释) + * > `cwd` > 脚本自身目录及上两级。 + * 🔴 **"恰好 1 个才合法"保留且更严**:任一候选目录 ≥2 份 ⇒ 立即报错;跨目录合计 ≥2 份 ⇒ 也报错。 + * ⛔ 一份都没找到 ⇒ 报错并列出**搜过的目录**,⛔ **不取缺省**。 + */ function resolveTablePath(argv) { const argOf = (name) => { const i = argv.indexOf(name) @@ -68,12 +81,78 @@ function resolveTablePath(argv) { } const explicit = argOf('--table') if (explicit !== undefined) return explicit - const dir = argOf('--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 个)`) + const registry = readTableRegistry() + const out = [] + const push = (d) => { + if (typeof d === 'string' && d.trim() !== '') out.push(path.resolve(d.trim())) } - return path.join(dir, hits[0]) + push(argOf('--dir')) + push(process.env.DSHS_OVERLAY_TABLE_DIR) + for (const d of registry.dirs) push(d) + push(process.cwd()) + push(__dirname) + push(path.join(__dirname, '..')) + push(path.join(__dirname, '..', '..')) + const seen = new Set() + const dirs = [] + for (const d of out) { + if (seen.has(d)) continue + seen.add(d) + dirs.push(d) + } + const hits = [] + const scanned = [] + for (const dir of dirs) { + if (!fs.existsSync(dir)) continue + let names + try { + names = fs.readdirSync(dir).filter((n) => TABLE_RE.test(n)) + } catch (err) { + continue // 目录存在但读不了(权限等)⇒ 不算"扫过",也不冒充"没有" + } + scanned.push(dir) + if (names.length > 1) { + throw new Error( + `在 ${dir} 下按 /${TABLE_RE.source}/ 找到 ${names.length} 个参数表(要求恰好 1 个):${names.join(' , ')}`, + ) + } + if (names.length === 1) hits.push(path.join(dir, names[0])) + } + if (hits.length === 0) { + throw new Error( + `在 ${scanned.length} 个候选目录下按 /${TABLE_RE.source}/ **一份都没找到**(要求恰好 1 个)\n` + + ` 搜过的目录:\n${scanned.map((d) => ` · ${d}`).join('\n')}\n` + + ` 注册文件:${registry.file}${registry.readError === null ? `(${registry.dirs.length} 条)` : `(读取失败:${registry.readError})`}\n` + + ` ⇒ 处置(三选一):① \`--table \` 直接指定 ② 把参数表所在目录(一行一个)写进上面的注册文件 ③ 在参数表所在目录下运行`, + ) + } + if (hits.length > 1) { + throw new Error( + `跨候选目录共找到 ${hits.length} 个参数表(要求恰好 1 个,⛔ 不猜哪一份是对的):\n` + + `${hits.map((h) => ` · ${h}`).join('\n')}`, + ) + } + return hits[0] +} + +/** 读**注册文件**(一行一个目录)。⛔ 读不到就返回**读取失败原因**(⛔ 不冒充"没有")。 */ +function readTableRegistry() { + const envFile = process.env.DSHS_OVERLAY_TABLE_REGISTRY + const file = + typeof envFile === 'string' && envFile.trim() !== '' + ? envFile.trim() + : path.join(os.homedir(), '.dshs', 'overlay-table-dir') + let text + try { + text = fs.readFileSync(file, 'utf8') + } catch (err) { + return { file, dirs: [], readError: (err && err.code) || String(err) } + } + const dirs = text + .split(/\r?\n/) + .map((l) => l.replace(/#.*$/, '').trim()) + .filter((l) => l !== '') + return { file, dirs, readError: null } } const cleanValue = (raw) => String(raw).replace(/[*`]/g, '').trim() @@ -173,7 +252,9 @@ async function main() { const sshTo = r.num('SSH_TIMEOUT_MS') const h47 = r.need('SSH_TARGET_47') const h106 = r.need('SSH_TARGET_106') - const relayUnit = r.need('DRILL_RELAY_UNIT') + // ⚠️ 序㊳ 改名:`DRILL_RELAY_UNIT` → `RELAY_UNIT_NAME`(它记的是"两台机的 relay 单元名"这一 + // **事实**、并非演练专用坐标 —— 探针 `OBS-08` 也读它 ⇒ 原 `DRILL_` 前缀与用途不相称)。 + const relayUnit = r.need('RELAY_UNIT_NAME') const managerUnit = r.need('DRILL_MANAGER_UNIT') const deadlineMs = r.num('RELAY_FAILOVER_DEADLINE_MS') const pollMs = r.num('DRILL_POLL_MS') diff --git a/scripts/overlay-node-admit.cjs b/scripts/overlay-node-admit.cjs new file mode 100644 index 0000000..b138cde --- /dev/null +++ b/scripts/overlay-node-admit.cjs @@ -0,0 +1,520 @@ +#!/usr/bin/env node +/** + * 覆盖网络 **控制面侧** 命令行:网注册表 / 准入凭据 / 白名单派生(序㊱ · P1 · S1+S2+S4)。 + * + * ## 八条命令 + * | 命令 | 作用 | 跑在哪 | + * |---|---|---| + * | `init` | 建注册表目录(惰性,⛔ 不改任何既有配置) | **控制面** | + * | `issue-invite` | 签一张**准入邀请**(网络绑定 + 有效期 + 一次性 nonce,⛔ 载荷无密钥) | **控制面**(在线签名者) | + * | `list` | 列出「有哪些网、每张网有哪些节点、谁已批准」 | 任何地方(只读) | + * | `apply` | 收一份节点申请单:验签 → **原子占位 nonce**(一次性)→ 记 `pending` | **控制面** | + * | `approve` / `remove` | 批准 / 移除节点(⇒ 进出派生白名单) | **控制面** | + * | `derive` | 把 approved 集合**投影**成 relay 白名单(缺省**只打印**,`--apply` 才落盘) | **控制面** | + * | `selfcheck` | **端到端自检**(真跑一遍上面全部动作,输出机器可读读数 ⇒ `OBS-25` 的数据源) | 任何地方(临时目录) | + * + * ## 三条纪律 + * ① 🔴 **`derive` 缺省不落盘**(`--apply` 才写)—— P1 不动任何既有 drop-in, + * **手写 drop-in 退化为应急通道、⛔ 不删**(`registry.ts#deriveDropIn` 的语义就是"多一条路")。 + * ② 🔴 **邀请凭据与申请单里 ⛔ 不含任何密钥本体**(只有网络 / nonce / 有效期 / **公钥**)—— + * stdout 也⛔ 不打印任何私钥。 + * ③ 🔴 **失败一律具名**(`✗ <命令> 失败: — ` 走 stderr、`exit 1`), + * ⛔ **零空 `catch` 块**(这是 `OBS-25` 的机器判据之一)。 + * + * ## 全局选项(**键名不可混用**) + * | 选项 | 含义 | 备注 | + * |---|---|---| + * | `--dir` | 注册表**目录** | 优先于 env `DSHS_OVERLAY_REGISTRY_DIR`,缺省 `/var/lib/dshs/overlay` | + * | `--registry` | 注册表**文件**(覆盖 `--dir` 下的 `nodes.json`) | 🔴 **⛔ 不要用 `--file`** —— 那是 `apply` 的申请单入参 | + * | `--signer-pub` | 受信签名者公钥(可重复) | 未给 ⇒ 回落 env `DSHS_OVERLAY_SIGNER_PUBKEYS` | + * + * @module scripts/overlay-node-admit + */ + +'use strict' + +const { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } = require('node:fs') +const { randomBytes } = require('node:crypto') +const { dirname, join } = require('node:path') + +const reg = require('../lib/net/relay/registry.js') +const joinx = require('../lib/net/relay/join.js') +const id = require('../lib/net/relay/identity.js') + +const [, , cmd, ...rest] = process.argv + +/** 解析 `--k v` / 开关 `--flag`(与 `overlay-keyring.cjs` 同一套,⛔ 不另造)。 */ +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(rest) + +function need(name) { + const v = args[name] + if (typeof v !== 'string' || v.trim() === '') { + throw new Error(`missing required --${name}(用法见本文件头部表格)`) + } + return v.trim() +} + +function opt(name) { + const v = args[name] + return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined +} + +/** 注册表目录(`DSHS_OVERLAY_REGISTRY_DIR` 可覆盖;⛔ 生产值不在代码里写死第二份口径)。 */ +const REGISTRY_DIR = opt('dir') ?? process.env.DSHS_OVERLAY_REGISTRY_DIR ?? '/var/lib/dshs/overlay' +/** + * 🔴 **覆盖注册表文件用 `--registry`,⛔ 不是 `--file`** —— 真机首轮实测踩坑: + * `--file` 在本 CLI 里已被 **`apply --file <申请单>`** 占用(还有 `issue-invite --out`), + * 原先这里写 `opt('file')` ⇒ **`apply --file app.json` 会把注册表文件也指到 app.json** + * ⇒ 收单在第 4 步(`loadReg`)炸出「注册表 …/app.json 形状非法」,**前 3 步副作用已发生** + * (nonce 已被原子占位)⇒ 表现为「收单失败 + 同一张邀请再也用不了」。 + * ⇒ 两个语义**必须分开两个键**;⛔ 不许再退回 `--file`。 + */ +const REGISTRY_FILE = opt('registry') ?? join(REGISTRY_DIR, 'nodes.json') +const CONSUMED_DIR = join(REGISTRY_DIR, 'consumed') + +/** `--signer-pub` 未给时回落到 env(与 relay / dsh 同一套受信来源)。 */ +function trustedSigners() { + const explicit = opt('signer-pub') + if (explicit !== undefined) return explicit.split(',').map((s) => s.trim()).filter((s) => s !== '') + return id.identityEnvTrustedSigners(process.env) +} + +function nowIso() { + return new Date().toISOString() +} + +function ok(line) { + process.stdout.write(`${line}\n`) +} + +/** + * 邀请的**本地**有效期(分钟)。缺省 30 min(短窗 = 凭据泄露窗口小)。 + * ⚠️ 这是**控制面侧签发参数**,⛔ 不是生产 env。 + */ +function inviteTtlMs() { + const raw = opt('ttl-min') + const min = raw === undefined ? 30 : Number(raw) + if (!Number.isFinite(min) || min <= 0) throw new Error('--ttl-min 必须是正数(分钟)') + return min * 60 * 1000 +} + +function loadReg() { + return reg.loadRegistry(REGISTRY_FILE) +} + +/** 写回注册表 —— ⛔ 只有控制面可写(`registry.ts` 的 `saveRegistry` 不做任何权限判断,纪律在调用方)。 */ +function commit(r) { + reg.saveRegistry(REGISTRY_FILE, r) +} + +const COMMANDS = { + /** 建注册表目录(**惰性**:只 `mkdir` + 写一份空注册表;⛔ 不动任何既有 drop-in / env)。 */ + init: () => { + mkdirSync(REGISTRY_DIR, { recursive: true }) + mkdirSync(CONSUMED_DIR, { recursive: true }) + if (!existsSync(REGISTRY_FILE)) commit(reg.emptyRegistry()) + chmodSync(REGISTRY_FILE, 0o644) + ok(`✓ 注册表就绪:${REGISTRY_FILE}(0644)`) + ok(`✓ 一次性台账目录:${CONSUMED_DIR}`) + ok(`ℹ️ ${reg.describeRegistryLine(loadReg())}`) + ok('ℹ️ ⛔ 本命令不改任何既有配置;白名单派生要显式跑 `derive --apply`(缺省只打印)。') + }, + + /** 签一张准入邀请(**网络绑定 + 有效期 + 一次性 nonce**;⛔ 载荷内无任何密钥)。 */ + 'issue-invite': () => { + const network = need('network') + const doc = { + version: reg.NODES_REGISTRY_VERSION, + network, + nonce: reg.newInviteNonce(randomBytes), + issuedAt: nowIso(), + expiresAt: new Date(Date.now() + inviteTtlMs()).toISOString(), + } + const sig = id.signPayloadWith(readFileSync(need('signer-key'), 'utf8'), reg.networkInvitePayload(doc)) + const out = opt('out') + if (out !== undefined) { + mkdirSync(dirname(out), { recursive: true }) + writeFileSync(out, `${JSON.stringify({ doc, sig }, null, 2)}\n`, { mode: 0o644 }) + ok(`✓ 邀请已写出:${out}`) + } else { + process.stdout.write(`${JSON.stringify({ doc, sig })}\n`) + } + ok(`✓ 邀请:network=${doc.network} nonce=${doc.nonce} 有效期至 ${doc.expiresAt}`) + ok('🔴 载荷内**不含任何密钥本体**(只有网 / nonce / 有效期)—— 节点私钥在节点上生成、永不出机。') + }, + + /** 列出网与节点(只读)。 */ + list: () => { + const r = loadReg() + const network = opt('network') + if (args.json === true) { + process.stdout.write( + `${JSON.stringify({ file: REGISTRY_FILE, networks: reg.summarizeNetworks(r), nodes: reg.listNodes(r, network) }, null, 2)}\n`, + ) + return + } + ok(`注册表:${REGISTRY_FILE}`) + ok(`ℹ️ ${reg.describeRegistryLine(r)}`) + for (const n of reg.summarizeNetworks(r)) { + if (network !== undefined && n.network !== network) continue + ok(` · ${n.network} total=${n.total} approved=${n.approved} pending=${n.pending}`) + } + for (const node of reg.listNodes(r, network)) { + ok( + ` - ${node.network}/${node.hostId} status=${node.status}` + + ` group=${node.group || '(默认)'} key=${node.nodeKey.slice(0, 16)}…` + + ` appliedAt=${node.appliedAt}${node.approvedAt === '' ? '' : ` approvedAt=${node.approvedAt}`}`, + ) + } + }, + + /** + * 收一份申请单 —— **四件都做**:验签 → 原子占位 nonce(一次性)→ 记 `pending` → 打印。 + * + * 🔴 **一次性是这里保证的**(`consumeNonce` 走 `O_CREAT|O_EXCL`,内核原子): + * 两台机器**同时**拿同一张邀请来收单 ⇒ **恰好一台**成功,另一台得到 `invite-already-used`。 + */ + apply: () => { + // 🔴 读的是**申请单**(`join.ts#parseApplication` 的**唯一形状**)—— 真机首轮就栽在 + // "apply 按裸 `{doc,sig}` 去读 join 的产物" ⇒ ⛔ 两处形状必须由同一个解析函数读。 + let app + try { + app = joinx.readApplicationFile(need('file')) + } catch (err) { + process.stderr.write(`✗ apply 失败:application-shape-invalid — ${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) + } + const { network, hostId, nodeKey } = app + const signers = trustedSigners() + if (signers.length === 0) { + process.stderr.write('✗ apply 失败:invite-no-trusted-signer — 未配置受信签名者(--signer-pub 或 DSHS_OVERLAY_SIGNER_PUBKEYS)\n') + process.exit(1) + } + const inv = app.invite + const verdict = reg.verifyNetworkInvite(inv.doc, inv.sig, signers, { network }) + if (!verdict.ok) { + process.stderr.write(`✗ apply 失败:invite-${verdict.reason} — 邀请验签未通过(network=${network})\n`) + process.exit(1) + } + // 🔴 一次性占位:**先占位再落库**(占位失败 ⇒ 根本不产生任何状态变化)。 + const led = reg.consumeNonce({ dir: CONSUMED_DIR }, verdict.doc.nonce, `apply network=${network} host=${hostId}`) + if (!led.ok) { + process.stderr.write( + `✗ apply 失败:invite-already-used — nonce ${verdict.doc.nonce} 已被用掉(一次性凭据,⛔ 不重复受理)\n`, + ) + process.exit(1) + } + const r = loadReg() + const edit = reg.applyApplication(r, { network, hostId, nodeKey, at: nowIso(), group: opt('group') }) + if (!edit.ok) { + process.stderr.write(`✗ apply 失败:${edit.reason} — network=${network} host=${hostId} nodeKey=${nodeKey.slice(0, 16)}…\n`) + process.exit(1) + } + commit(r) + ok(`✓ 已收单(${edit.created ? '新申请' : '刷新既有申请'}):${network}/${hostId} status=${edit.record.status}`) + ok('ℹ️ ⛔ 收单**不等于**批准 —— 该节点仍未进白名单(默认拒绝 ⇒ relay 侧拨不动),要 `approve` 才生效。') + }, + + /** 批准(⇒ 进入派生白名单)。 */ + approve: () => { + const r = loadReg() + const edit = reg.approveNode(r, need('network'), need('host'), nowIso(), opt('group')) + if (!edit.ok) { + process.stderr.write(`✗ approve 失败:${edit.reason} — 该节点未在注册表里申请过(⛔ 不凭空批准)\n`) + process.exit(1) + } + commit(r) + ok(`✓ 已批准:${edit.record.network}/${edit.record.hostId}(approvedAt=${edit.record.approvedAt})`) + }, + + /** 移除(⇒ 退出派生白名单;⚠️ relay 侧生效要等一次 reload)。 */ + remove: () => { + const r = loadReg() + const edit = reg.removeNode(r, need('network'), need('host')) + if (!edit.ok) { + process.stderr.write(`✗ remove 失败:${edit.reason} — 注册表里没有该节点\n`) + process.exit(1) + } + commit(r) + ok(`✓ 已移除:${edit.record.network}/${edit.record.hostId}`) + ok('⚠️ relay 侧要等一次 `systemctl restart dshs-relay` 才不再接受它的拨号(派生只改配置源)。') + }, + + /** + * **白名单派生** —— 把该网 approved 集合投影成 `DSHS_RELAY_DIALERS`。 + * + * 🔴 **缺省 `--dry-run` 语义**(只打印,⛔ 不落盘)—— P1 的硬门是"不动任何既有配置"。 + * 要落盘必须显式 `--apply`;`--out` 可指定别处(做对照用)。 + */ + derive: () => { + const r = loadReg() + const network = need('network') + const content = reg.deriveDropIn(r, { + network, + unit: opt('unit') ?? 'dshs-relay', + libDir: opt('lib'), + varName: opt('var'), + }) + const audit = reg.auditDerivation(r) + ok(`ℹ️ ${reg.describeRegistryLine(r)}`) + ok(`ℹ️ 派生自审:${audit.ok ? '一致 ✅' : '❌ 不一致'}`) + for (const m of audit.mismatches) ok(` ❌ 集合不配:${m}`) + for (const m of audit.misfiled) ok(` ❌ 结构性错桶(跨网共享):${m}`) + const target = opt('out') ?? reg.dropInPath(opt('unit') ?? 'dshs-relay').replace(/ \(lib=.*\)$/, '') + if (args.apply === true) { + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, `${content}\n`, { mode: 0o644 }) + ok(`✓ 已落盘:${target}(0644)`) + ok('⚠️ 生效要一次 `systemctl daemon-reload && systemctl restart dshs-relay`(⛔ 本命令不代跑)。') + ok('🔴 手写 drop-in 是**应急通道**,⛔ 未被删除 —— 控制面不可用时照旧生效。') + } else { + ok(`ℹ️ 未落盘(缺省 dry-run)⇒ 打印内容如下;要落盘加 \`--apply --out ${target}\``) + process.stdout.write(`${content}\n`) + } + }, + + /** + * **端到端自检**(临时目录里真跑一遍全部动作)—— `OBS-25` 的数据源。 + * + * 判据不是"函数跑通了",而是**六件同时成立**(逐条给原始读数): + * ① 四步编排真跑完(`runJoin` 四条 step 全 ok);② **回环的正当性**:申请单里 + * **逐字不含私钥**;③ 节点私钥文件权限 = `600`;④ 收单**一次性**(第二次 ⇒ `invite-already-used`); + * ⑤ 四条**负腿**各自**具名**拒绝(坏签名 / 过期 / 错网 / 无受信签名者); + * ⑥ 派生自审一致 + 两张网**零共享**。 + */ + selfcheck: async () => { + const tmp = opt('tmp') ?? join(require('node:os').tmpdir(), `dshs-selfcheck-${process.pid}`) + const dir = { + root: tmp, + key: join(tmp, 'signer.key'), + nodeKey: join(tmp, 'node.key'), + nodeCfg: join(tmp, 'node.json'), + app: join(tmp, 'application.json'), + regDir: join(tmp, 'registry'), + } + mkdirSync(join(dir.regDir, 'consumed'), { recursive: true }) + + const signer = id.generateAuthorityKey() + writeFileSync(dir.key, signer.privateKeyPem, { mode: 0o600 }) + + /** 签一张邀请(helper:与 `issue-invite` 同一条路径,⛔ 不复制逻辑)。 */ + const issue = (network, expiresInMs) => { + const doc = { + version: reg.NODES_REGISTRY_VERSION, + network, + nonce: reg.newInviteNonce(randomBytes), + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + expiresInMs).toISOString(), + } + return { doc, sig: id.signPayloadWith(signer.privateKeyPem, reg.networkInvitePayload(doc)) } + } + + const io = joinx.nodeJoinIo({ + fss: { existsSync, readFileSync, writeFileSync, mkdirSync }, + crypto: id, + os: require('node:os'), + fetchImpl: async () => ({ status: 599, body: 'selfcheck 不走 HTTP 通道' }), + }) + const signers = [signer.publicKey] + + // ── ① 正当路径:真的跑完四步 ────────────────────────────────────────── + const goodInvite = issue('selfcheck-net', 5 * 60 * 1000) + const joined = await joinx.runJoin( + { + network: 'selfcheck-net', + hostId: 'node-a', + invite: goodInvite, + trustedSigners: signers, + nodeKeyFile: dir.nodeKey, + localConfigFile: dir.nodeCfg, + outFile: dir.app, + }, + io, + ) + + // ── ② 申请单逐字不含私钥(**字节级**,⛔ 不是"语义上应该没有")────────── + const appText = existsSync(dir.app) ? readFileSync(dir.app, 'utf8') : '' + const nodePriv = existsSync(dir.nodeKey) ? readFileSync(dir.nodeKey, 'utf8') : '' + // 取私钥 PEM 的**主体行**(去掉 BEGIN/END 包裹与换行)当探针串 —— 出现即泄露。 + const privBody = (nodePriv.split('\n').filter((l) => l !== '' && !l.startsWith('-----')).join('') || '') + const privateKeyLeak = appText === '' || privBody === '' ? -1 : appText.includes(privBody) ? 1 : 0 + + // ── ③ 节点私钥文件权限 ──────────────────────────────────────────────── + const nodeKeyMode = existsSync(dir.nodeKey) ? (require('node:fs').statSync(dir.nodeKey).mode & 0o777).toString(8) : 'absent' + + // ── ④ 控制面**按 CLI 同样的路径**收单(解析申请单文件 → 验签 → 原子占位 → 落 pending)── + // 🔴 真机首轮实测教训:selfcheck 原先**直接调库**、绕过了 `apply` 的解析 ⇒ 掩盖了 + // "join 产出的申请单 与 apply 的解析形状不一致"这个真缺陷。⇒ 这里必须走**文件 → 解析**。 + const failures = [] + const regFile = join(dir.regDir, 'nodes.json') + const led = { dir: join(dir.regDir, 'consumed') } + let parsedApp + let appShapeErr = '' + try { + parsedApp = joinx.readApplicationFile(dir.app) + } catch (err) { + appShapeErr = err instanceof Error ? err.message : String(err) + } + if (appShapeErr !== '') { + failures.push({ leg: 'cli-roundtrip', step: 'register', reason: 'application-shape-invalid', named: true, detail: appShapeErr.slice(0, 160) }) + } + const invFromFile = + parsedApp === undefined + ? { ok: false, reason: 'bad-payload' } + : reg.verifyNetworkInvite(parsedApp.invite.doc, parsedApp.invite.sig, signers, { network: parsedApp.network }) + const first = invFromFile.ok ? reg.consumeNonce(led, invFromFile.doc.nonce, 'selfcheck first') : { ok: false, reason: 'invite-bad-payload' } + const second = invFromFile.ok ? reg.consumeNonce(led, invFromFile.doc.nonce, 'selfcheck second') : { ok: false, reason: 'invite-bad-payload' } + const r = reg.emptyRegistry() + const applied = + parsedApp !== undefined && first.ok + ? reg.applyApplication(r, { + network: parsedApp.network, + hostId: parsedApp.hostId, + nodeKey: parsedApp.nodeKey, + at: new Date().toISOString(), + }) + : { ok: false, reason: 'not-applied' } + const approved = reg.approveNode(r, parsedApp?.network ?? 'selfcheck-net', parsedApp?.hostId ?? 'node-a', new Date().toISOString()) + reg.saveRegistry(regFile, r) + + // ── ⑤ 四条负腿:每条都必须**具名**拒绝 ──────────────────────────────── + const record = (leg, outcome) => { + const named = typeof outcome.reason === 'string' && outcome.reason !== '' + failures.push({ + leg, + step: typeof outcome.step === 'string' ? outcome.step : '', + reason: named ? outcome.reason : '', + named, + detail: typeof outcome.detail === 'string' ? outcome.detail.slice(0, 160) : '', + }) + } + const joinBase = { + network: 'selfcheck-net', + hostId: 'node-b', + trustedSigners: signers, + nodeKeyFile: join(tmp, 'node-b.key'), + localConfigFile: join(tmp, 'node-b.json'), + outFile: join(tmp, 'application-b.json'), + } + // 负腿 1:签名被篡改 + const tampered = issue('selfcheck-net', 5 * 60 * 1000) + tampered.sig = `${tampered.sig.slice(0, -4)}AAAA` + record('bad-signature', await joinx.runJoin({ ...joinBase, invite: tampered }, io)) + // 负腿 2:已过期 + record('expired', await joinx.runJoin({ ...joinBase, invite: issue('selfcheck-net', -60 * 1000) }, io)) + // 负腿 3:邀请绑的是别张网 + record('wrong-network', await joinx.runJoin({ ...joinBase, invite: issue('other-net', 5 * 60 * 1000) }, io)) + // 负腿 4:没配受信签名者(**不可验 = 不接受**) + record('no-trusted-signer', await joinx.runJoin({ ...joinBase, invite: issue('selfcheck-net', 5 * 60 * 1000), trustedSigners: [] }, io)) + // 负腿 5:既没 --portal 也没 --out(⛔ 不许静默成功) + record('no-channel', await joinx.runJoin({ ...joinBase, invite: issue('selfcheck-net', 5 * 60 * 1000), outFile: '' }, io)) + + // ── ⑥ 派生自审 + 两网零共享 ───────────────────────────────────────── + const audit = reg.auditDerivation(r) + const derived = reg.deriveDialers(r) + const nets = [...derived.keys()].sort() + // **跨网共享** = 同一 hostId 出现在 ≥2 张网(结构性隔离下**必然为 0**:桶键含网络)。 + const seen = new Map() + let crossNetworkShared = 0 + for (const n of nets) for (const h of derived.get(n) ?? []) { + if (seen.has(h)) crossNetworkShared++ + seen.set(h, n) + } + const dropIn = reg.deriveDropIn(r, { network: 'selfcheck-net' }) + const dropInHasNetwork = dropIn.includes('selfcheck-net/node-a') + + const steps = joined.ok ? joined.steps : [] + // ⚠️ **权限只在 Linux 上可判**:Windows 的 `stat.mode` 是只读属性模拟(恒 666/444), + // 拿它当"0600 成立"= **假绿**。⇒ 记 `platform` 并让判据可区分"不成立"与"这台机器不可判"。 + const modeOk = process.platform === 'linux' ? nodeKeyMode === '600' : null + const report = { + version: 1, + at: new Date().toISOString(), + platform: process.platform, + tmp, + artifacts: { join: true, registry: true, admitCli: true }, + joinSteps: steps.map((s) => ({ step: s.step, ok: s.ok })), + joinStepsMin: joinx.JOIN_STEPS.length, + joinOk: joined.ok === true, + joinFailStep: joined.ok ? '' : joined.step, + joinFailReason: joined.ok ? '' : joined.reason, + application: { + bytes: Buffer.byteLength(appText, 'utf8'), + hasPrivateKey: privateKeyLeak, + containsOnlyPublicKey: joined.ok ? appText.includes(joined.nodeKey) : false, + // 🔴 **CLI 回环**:申请单文件能否被 `parseApplication` 读回(真机首轮栽的就是这条)。 + shapeOk: parsedApp !== undefined, + shapeError: appShapeErr.slice(0, 200), + // 申请单里读回的 hostId/network 必须与 join 的输入**逐字一致**(防"写对了但读歪了")。 + hostMatches: parsedApp !== undefined && parsedApp.hostId === 'node-a' && parsedApp.network === 'selfcheck-net', + }, + nodeKeyMode, + // `null` = **本平台不可判**(⛔ 不是 PASS);判据只对 `600` 取真。 + nodeKeyModeOk: modeOk, + ledger: { first: first.ok ? 'ok' : first.reason, second: second.ok ? 'ok' : second.reason }, + registry: { + file: regFile, + applied: applied.ok ? applied.record.status : applied.reason, + approved: approved.ok ? approved.record.status : approved.reason, + auditOk: audit.ok, + mismatches: audit.mismatches, + misfiled: audit.misfiled, + }, + derivation: { networks: nets, crossNetworkShared, dropInHasNetwork }, + namedFailures: failures, + silentRejections: failures.filter((f) => f.named !== true || f.reason === '').length, + } + const out = opt('out') + if (args.json === true || out !== undefined) { + const text = `${JSON.stringify(report, null, 2)}\n` + if (out !== undefined) writeFileSync(out, text, { mode: 0o644 }) + if (args.json === true && out === undefined) process.stdout.write(text) + if (out !== undefined) ok(`✓ 自检读数已写出:${out}`) + return + } + ok(`✓ join 四步:${steps.filter((s) => s.ok).length}/${joinx.JOIN_STEPS.length}|ok=${report.joinOk}`) + ok(`✓ 申请单 ${report.application.bytes} B|私钥出现次数=${report.application.hasPrivateKey}(须 0)`) + ok( + modeOk === null + ? `⚠️ 私钥权限=${nodeKeyMode} —— platform=${process.platform} ⇒ **本平台不可判**(⛔ 不当 PASS;真机腿见部署后 ssh stat -c %a)` + : `✓ 私钥权限=${nodeKeyMode}(须 600)⇒ ${modeOk ? '成立' : '不成立'}`, + ) + ok(`✓ 一次性:first=${report.ledger.first} second=${report.ledger.second}(须 invite-already-used)`) + ok(`✓ 派生自审 auditOk=${audit.ok}|网=${nets.join(',')}|跨网共享=${crossNetworkShared}(须 0)`) + for (const f of failures) ok(` · 负腿 ${f.leg}: step=${f.step} reason=${f.reason}`) + ok(`✓ 具名失败 ${failures.length} 条|静默拒绝=${report.silentRejections}(须 0)`) + }, +} + +async function main() { + const fn = COMMANDS[cmd] + if (fn === undefined) { + process.stderr.write(`unknown command: ${String(cmd)}\n可用:${Object.keys(COMMANDS).join(' | ')}\n`) + process.exit(2) + } + await fn() +} + +main().catch((err) => { + process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) +}) diff --git a/scripts/overlay-node-join.cjs b/scripts/overlay-node-join.cjs new file mode 100644 index 0000000..fa43b56 --- /dev/null +++ b/scripts/overlay-node-join.cjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +/** + * 覆盖网络 **节点侧** 命令行:一条命令接入(序㊱ · P1 · S3)。 + * + * ## 用户口径(原话) + * 「**b 要实现开启一台结点服务器,就能连上覆盖网络**,这样做这个网络才有价值」。 + * ⇒ 判据 = **一条命令**,⛔ 不是"照文档改四处配置"(现状:装单元 / 配密钥 / 写 drop-in / DB 登记)。 + * + * ## 用法 + * ```bash + * # ① 离线通道(控制面尚未开放接收入口时唯一可用;把申请单交给控制面收单) + * node scripts/overlay-node-join.cjs --network ops --invite /tmp/invite.json \ + * --signer-pub --out /tmp/application.json + * + * # ② HTTP 通道(控制面已开放接收入口时;才谈得上"一条命令完成") + * node scripts/overlay-node-join.cjs --network ops --invite \ + * --signer-pub --portal https://<控制面>/dshs-overlay/join + * ``` + * + * | 选项 | 缺省 | 说明 | + * |---|---|---| + * | `--network` | **必填** | 要加入的网(`ops` | `u:<租户>` | 显式命名网) | + * | `--invite` | **必填** | 邀请凭据:**文件路径**或**内联 JSON** | + * | `--host` | 本机主机名(规范化) | 该节点在这张网里的**逻辑名** | + * | `--key` | `/etc/dshs/node.key` | 节点私钥落点(**`0600`,⛔ 永不出机**) | + * | `--config` | `/etc/dshs/overlay-node.json` | 本机配置落点(`0600`,只记"私钥在哪") | + * | `--out` / `--portal` | — | 二选一(⛔ 都没有 ⇒ **具名失败**,不静默成功) | + * | `--direct` | **开**(🆕 序㊵) | 本机**直连(打洞)开关**:可写 `1/true/on/yes` 或 `0/false/off/no`;⛔ 取值非法 ⇒ **具名失败**(不静默取缺省)。关闭 ⇒ ⛔ 不绑 UDP 口、⛔ 不发直连候选 | + * | `--signer-pub` | env `DSHS_OVERLAY_SIGNER_PUBKEYS` | 受信签名者(空 ⇒ **不可验 = 不接受**) | + * + * ## 🔴 失败一律具名 + * 打印**走到第几步**(四步逐条 `✓`/`✗`)+ **原因码**,`exit 1`。 + * ⛔ 本文件与 `join.ts` 内**零空 `catch` 块** —— 这是本线治"配置错长得像网络不通"的机器判据。 + * + * @module scripts/overlay-node-join + */ + +'use strict' + +const { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } = require('node:fs') + +const joinx = require('../lib/net/relay/join.js') +const id = require('../lib/net/relay/identity.js') +const directx = require('../lib/net/relay/direct/index.js') + +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)) + +function need(name) { + const v = args[name] + if (typeof v !== 'string' || v.trim() === '') { + process.stderr.write(`✗ join 失败:缺少必填参数 --${name}(用法见本文件头部表格)\n`) + process.exit(2) + } + return v.trim() +} + +function opt(name) { + const v = args[name] + return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined +} + +/** 受信签名者(`--signer-pub` 优先,回落 env —— 与 relay / dsh 同一套来源,⛔ 不另造)。 */ +function trustedSigners() { + const explicit = opt('signer-pub') + if (explicit !== undefined) return explicit.split(',').map((s) => s.trim()).filter((s) => s !== '') + return id.identityEnvTrustedSigners(process.env) +} + +/** `--invite` 既接受**文件路径**也接受**内联 JSON**(`{doc, sig}`)。 */ +function readInvite(spec) { + const trimmed = spec.trim() + if (trimmed.startsWith('{')) { + const raw = JSON.parse(trimmed) + if (raw === null || typeof raw !== 'object') throw new Error('--invite 的 JSON 不是对象') + const r = raw + if (r.doc === undefined || r.sig === undefined) throw new Error('--invite 的 JSON 缺 doc / sig 字段') + return { doc: r.doc, sig: r.sig } + } + return joinx.readSignedJson(trimmed) +} + +const fss = { existsSync, readFileSync, writeFileSync, mkdirSync } + +/** 真的发一次 POST(`--portal` 通道)。响应体**原样**带回 —— 控制面的原因码不许被吞掉。 */ +async function post(url, body) { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + }) + return { status: res.status, body: await res.text() } +} + +async function main() { + const network = need('network') + const invite = readInvite(need('invite')) + const hostFromArg = opt('host') + const hostId = hostFromArg ?? joinx.defaultHostId(require('node:os').hostname()) + if (hostId === '') { + process.stderr.write('✗ join 失败:hostId 推不出来(本机主机名规范化后为空)⇒ 必须显式给 --host\n') + process.exit(2) + } + const nodeKeyFile = opt('key') ?? '/etc/dshs/node.key' + const localConfigFile = opt('config') ?? '/etc/dshs/overlay-node.json' + const outFile = opt('out') + const portalUrl = opt('portal') + const signers = trustedSigners() + + // 🆕 序㊵(P2/S5):直连(打洞)开关 —— **缺省开**(用户口径②),可用 `--direct 0` 当场关掉。 + const directArg = opt('direct') + let direct = directx.DEFAULT_DIRECT_ENABLED + if (directArg !== undefined) { + const v = directArg.trim().toLowerCase() + if (directx.DIRECT_ON_VALUES.includes(v)) direct = true + else if (directx.DIRECT_OFF_VALUES.includes(v)) direct = false + else { + // ⛔ 取值非法 ⇒ **具名失败**(⛔ 不静默取缺省 —— 本线所有"配置错长得像网络不通"都源于这一手) + process.stderr.write( + `✗ join 失败:--direct ${JSON.stringify(directArg)} 既不在开集 ${directx.DIRECT_ON_VALUES.join('/')} 也不在关集 ${directx.DIRECT_OFF_VALUES.join('/')}\n`, + ) + process.exit(2) + } + } + + const outcome = await joinx.runJoin( + { network, hostId, invite, trustedSigners: signers, nodeKeyFile, localConfigFile, outFile, portalUrl, direct }, + joinx.nodeJoinIo({ fss, crypto: id, os: require('node:os'), fetchImpl: post }), + ) + + for (const s of outcome.steps) { + process.stdout.write(`${s.ok ? '✓' : '✗'} [${s.step}] ${s.detail}\n`) + } + if (outcome.ok) { + process.stdout.write(`${joinx.describeJoin(outcome)}\n`) + process.stdout.write( + `ℹ️ 节点文件权限:${statSync(nodeKeyFile).mode & 0o777 ? (statSync(nodeKeyFile).mode & 0o777).toString(8) : 'n/a'}` + + `(私钥)|${(statSync(localConfigFile).mode & 0o777).toString(8)}(配置)\n`, + ) + process.stdout.write('🔴 本命令⛔ 不装服务单元、⛔ 不写 drop-in —— 白名单由**控制面派生**(`overlay-node-admit.cjs derive`)。\n') + // 🆕 序㊵(P2/S5):**用户口径③「提示用户」** —— 开启直连(打洞)时必须说清 + // ① 谁可能连进来 ② 怎么关 ③ 关掉不影响什么(⛔ 禁止只写"已启用直连")。 + process.stdout.write(`ℹ️ 直连(打洞)= ${direct ? '开(缺省)' : '关'}|开关键 ${directx.DIRECT_ENV_KEY}|本机配置字段 direct\n`) + if (direct) { + for (const line of directx.directHintLines()) process.stdout.write(` ${line}\n`) + } else { + process.stdout.write( + ` 已按 --direct 关闭:⛔ 不绑任何 UDP 端口、⛔ 不发直连候选;跨机流量走中继(接入与准入不受影响)。\n`, + ) + } + if (outcome.applicationFile !== '') { + process.stdout.write(`➡️ 把申请单交给控制面收单:overlay-node-admit.cjs apply --file ${outcome.applicationFile}\n`) + } + return + } + process.stdout.write(`${joinx.describeJoin(outcome)}\n`) + process.stderr.write(`✗ join 失败:${outcome.reason} — 停在「${outcome.step}」步:${outcome.detail}\n`) + process.exit(1) +} + +main().catch((err) => { + // ⛔ 唯一的兜底 catch,**必须打印原文**(空 catch = 静默失败 = 本线要根治的病)。 + process.stderr.write(`✗ join 异常:${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) + process.exit(1) +}) diff --git a/scripts/overlay-probe.cjs b/scripts/overlay-probe.cjs index 0bd7ef1..a1f61b1 100644 --- a/scripts/overlay-probe.cjs +++ b/scripts/overlay-probe.cjs @@ -32,17 +32,36 @@ * ⇒ 替换式变化(一进一出)**必然**被两条断言之一命中。 * `LISTEN_COUNT` / `NFT_RULES` **已退役**(仅在参数表里留作对账),⛔ 本文件不得再引用它们。 * - * ## 🆕 `OBS-09` 的**在册判据**(序 ⑳ 口径修正) - * 旧口径 = 「**两个实例面 HTTP 码都必须 ∈ `PROBE_CODE_SET`**」⇒ 实例**根本没在跑**时判红。 - * 这在本线是**已知环境态**(`systemctl restart dshs` 会收掉 `dsh-*.scope`,平台**按需拉起、 - * ⛔ 无 reconciler** ⇒ 无人访问时实例不自回)⇒ 长期恒红的条目**等于没有判别力**。 - * 新口径 = **在册 ⇒ 必须可达**: + * ## 🆕 `OBS-09` 的**在册判据**(序 ㊲ 口径修正:⛔ **不再依赖表内固定端口**) + * 旧口径(序 ⑳ 起)= 「`/status.endpoints[]` 中存在**表内那个固定实例端口**且 `online = true` ⇒ 判可达」。 + * 🔴 结构性缺陷(序㊱ 收口复核棒连踩两次):实例端口**会漂移** —— `spawn.ts#findFreePortInRange` + * 从 `DSHS_INSTANCE_PORT_BASE` **上扫取第一个空闲口**,而「访问时自然替换」在旧 scope 尚未释放端口时 + * 发起 ⇒ 落点顺延(实测 `21000 → 21001`)⇒ 表值一旦过期就**读不到实际在册的实例** ⇒ **假 SKIP**。 + * 新口径 = **在册判据从事实派生,表值只作对账**: * - * 在册判据 = `/status.endpoints[]` 中存在该实例端口 **且** `online === true` - * · 不在册 ⇒ **SKIP**(⛔ 不是 PASS:那会掩盖"该在册却掉出端点表",那一类由 `OBS-01`/`OBS-08` 守) - * · 在册 ⇒ 实例面码必须 ∈ `PROBE_CODE_SET` ⇒ 否则 **FAIL**(注册了却打不开 = 真红) + * 派生子 = { e ∈ `/status.endpoints[]` | `e.online === true` 且 `e.port ∉ {W47_AGENT_PORT, PEER_AGENT_PORT}` } + * · 派生为空 ⇒ **SKIP + 留痕**(⛔ 不是 PASS:那会掩盖"该在册却掉出端点表",那一类由 + * `OBS-01`/`OBS-04`/`OBS-08` 守;⛔ 也不是 FAIL:那是**合法态**) + * · 派生非空 ⇒ **每一个**都必须可达(走**它自己那条** `localPort` 回环落点,码 ∈ `PROBE_CODE_SET`) + * ⇒ 否则 **FAIL 并点名**(注册了却打不开 = 真红) * - * ⇒ 两侧都要能判:夹具模式下用 `--instance-fixture` 显式给两个码(缺省 ⇒ 在册仍判 ⇒ FAIL 会点名)。 + * ⚠️ 47 本机实例面(`:20000`)**结构性**地不在 `endpoints[]` 里(47 的 worker 不是 relay 客户端) + * ⇒ 它不再是本项的取样点(⚠️ 旧口径下它也**从未**真正参与判定:永远落在"不在册"那一侧)。 + * ⚠️ 夹具模式:`--instance-fixture <实例端口=HTTP码>,…`(**按端口映射**)。⛔ **旧的位置式 + * `<本机码>,<对端码>` 已退役**(它本身就是"表内固定端口"的化身)⇒ 给了旧形态**直接报错退出**, + * ⛔ 不静默按新口径解释(那会造出一个没人看得懂的 FAIL)。 + * + * ## 🆕 `OBS-08` 的**空表可分**(序 ㊲:⛔ 不许把两种情形合成一个 FAIL) + * 「47 中继视角端点表为空」有两个**完全不同**的成因,旧口径一律判 FAIL ⇒ 其中一个是**假红**: + * + * ⓐ 该客户端**挂在另一台中继上**(实测:`--scene all` 演练把 106 worker 逼到 **106 自己的中继** + * ⇒ 47 视角 `ep=[] / used=1 / idOk=1`)⇒ **不是故障**(拓扑态)⇒ **SKIP + 留痕**(点名对端中继原文) + * ⓑ **两台**中继都看不到它的客户端会话 ⇒ **确实没挂客户端** ⇒ **FAIL 并点名** + * ⓒ 对端中继**读不回来**(ssh / JSON 解析失败)⇒ **不可判** ⇒ FAIL 并点名(⛔ 不许静默当绿) + * + * 判别源 = **对端中继的 `/status`**(`ssh curl ` —— **只读回环**、 + * ⛔ 零新增暴露面、⛔ 零新增参数键)。⚠️ **只在 47 视角为空时**才去读(正常态**零额外 ssh**)。 + * 夹具模式用 `--peer-status-fixture <对端 /status 原文>`;⛔ 不给 ⇒ 记"不可判(夹具模式)"。 * * ## 🆕 `OBS-16` 的**门窗口判据**(序㉑ · 在册缺陷 P-1 的验收) * `OBS-13` 的 `ΔstatusHits ≥ 1` 只证明"计数器没卡死"——它由**探针自己两次读**即满足,⛔ 证明不了 @@ -59,14 +78,29 @@ * ⚠️ 夹具模式**必须**显式给 `--status-fixture-3`:缺省 = 与第二份同一份 ⇒ Δ=0 ⇒ 活性证明不成立 * ⇒ FAIL 并点名(**契约面**,⛔ 故意如此 —— 与 `OBS-09` 的 `--instance-fixture` 同规则)。 * - * ## 🧪 夹具模式(`--listen-fixture` / `--nft-fixture` / `--status-fixture` / `--instance-fixture`) + * ## 🧪 夹具模式(`--listen-fixture` / `--nft-fixture` / `--status-fixture` / `--instance-fixture` / + * `--peer-status-fixture`) * 判据改造**必须自带"旧判据会放过、新判据能抓住"的实证**。夹具就是那个实证手段: * 把远端原文喂进来 ⇒ **不 ssh**、零生产副作用;输出行首加 `⚠️ FIXTURE`(stderr 另标一次, * ⛔ 防止被下游当成生产结论);此时非集合类指标记 `SKIP`(不参与退出码)。 * ⛔ 夹具模式**必须**配 `--table <副本>`(避免误改生产参数表)。 * + * 🔴 **封闭性(序㊳ 起 · 硬约束)**:夹具模式下**任何**取数必须来自夹具;**缺该夹具的参数** + * ⇒ 记 `SKIP + 留痕(夹具模式未给 …)`,⛔ **绝不回落去 ssh 读生产**。 + * —— 起因:`OBS-24` / `OBS-25` 当初写成 `if (<夹具> !== undefined) { 读夹具 } else { ssh }` + * (无 `fixture` 守卫)⇒ **只给 `--status-fixture`** 的一次夹具跑里,这两行其实是**生产读数** + * (本线"夹具结论混生产数据"的静默污染面)。⇒ 两条路径的取数**不再共用同一个 else**: + * `else if (fixture) { SKIP }` 必须排在 `else { ssh }` **之前**。 + * ⚠️ **新增任何"可 ssh 取数"的 OBS 时,必须照此顺序写**(否则就是同一个坑再踩一次)。 + * 🔑 **自检口径**:夹具跑里只要**出现任何一次 ssh**,就是本条约被破坏 —— 判据 = 夹具跑 stderr + * **不含**远端读取痕迹(本线实测法:`ssh` 调用数 = 0;⛔ 别只看"输出有没有 ⚠️ FIXTURE")。 + * * ## 运行前置 - * - `cwd` = 工作区根(按 `TABLE_RE` 找参数表;也可 `--dir` / `--table`) + * - **参数表从哪来**(🆕 序㊸:**与 cwd 解耦**)—— 未必非要在工作区根跑: + * 候选目录链 = `--table`(显式文件)> `--dir` > `DSHS_OVERLAY_TABLE_DIR` > + * **机器本地注册文件**(`DSHS_OVERLAY_TABLE_REGISTRY`,缺省 `~/.dshs/overlay-table-dir`,一行一个目录) + * > `cwd` > 脚本自身目录及上两级。🔴 **"恰好 1 个才合法"保留且更严**:任一候选目录里 ≥2 份 ⇒ 报错; + * 跨目录合计 ≥2 份 ⇒ 也报错(列出全部)。⛔ 一份都没找到 ⇒ 报错并列出**搜过的目录**,⛔ 不取缺省。 * - 本机能 `ssh` 到中继机(走 `~/.ssh/config` 别名;⚠️ 别名里配的端口可能陈旧 ⇒ 一律用表里的 `SSH_PORT`) * * ## ⛔ 它不做什么 @@ -79,6 +113,7 @@ const { execFileSync } = require('node:child_process') const fs = require('node:fs') +const os = require('node:os') const path = require('node:path') /** 参数表文件名(⛔ 不写死日期数字:用正则匹配,避免脚本里出现像阈值的常量)。 */ @@ -107,15 +142,107 @@ function argOf(argv, name) { return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : undefined } +/** + * 🆕 **序㊸:参数表定位与 cwd 解耦**(与 `overlay-failover-drill.cjs` **同款**)。 + * + * **起因(实测)**:原先只在 **cwd** 下按 `TABLE_RE` 找 ⇒ **从代码仓根跑就 `rc=2`**: + * `❌ 参数表装载失败 … 找到 0 个参数表`。而参数表**不在仓内** + * (本机实测 `git ls-files | grep -c 参数表` = **0**)—— 它在**工作区根**,与代码仓**不在同一路径树** + * (本机甚至是两个盘:仓在 `D:`、工作区在 `E:`)⇒ 纯路径推算**永远到不了**。 + * + * **处置 = 候选目录链**(⛔ 仓内**零机器路径**:机器差异全部落在 `$HOME` 的注册文件 / env 里): + * 1. `--table `(**显式文件,最高优先,直接返回、不参与搜索**) + * 2. `--dir

`(显式目录) + * 3. `DSHS_OVERLAY_TABLE_DIR`(env 显式目录) + * 4. **注册文件**的每一行(`DSHS_OVERLAY_TABLE_REGISTRY`,缺省 `~/.dshs/overlay-table-dir`; + * `#` 起注释;**机器本地、⛔ 不入库**) + * 5. `cwd`(保持既有默认,⛔ 不破坏老用法) + * 6. 脚本自身目录、其上一级、上两级(仓根 / `/opt/dshs` 这类部署形态都覆盖) + * + * 🔴 **"恰好 1 个才算合法"这条防错保留、且更严**: + * 任一候选目录里 ≥2 份 ⇒ **立即报错**;跨候选目录合计 ≥2 份 ⇒ **也报错**(列出全部命中)。 + * ⛔ **绝不放宽成"找不到就用缺省"** —— 一份都没找到 ⇒ 报错并列出**所有搜过的目录** + 三条处置办法。 + */ +function tableCandidateDirs(argv) { + const registry = readTableRegistry() + const out = [] + const push = (d) => { + if (typeof d === 'string' && d.trim() !== '') out.push(path.resolve(d.trim())) + } + push(argOf(argv, '--dir')) + push(process.env.DSHS_OVERLAY_TABLE_DIR) + for (const d of registry.dirs) push(d) + push(process.cwd()) + push(__dirname) + push(path.join(__dirname, '..')) + push(path.join(__dirname, '..', '..')) + const seen = new Set() + const dirs = [] + for (const d of out) { + if (seen.has(d)) continue + seen.add(d) + dirs.push(d) + } + return { dirs, registry } +} + +/** 读**注册文件**(一行一个目录)。⛔ 读不到就返回**读取失败原因**(⛔ 不冒充"没有")。 */ +function readTableRegistry() { + const envFile = process.env.DSHS_OVERLAY_TABLE_REGISTRY + const file = + typeof envFile === 'string' && envFile.trim() !== '' + ? envFile.trim() + : path.join(os.homedir(), '.dshs', 'overlay-table-dir') + let text + try { + text = fs.readFileSync(file, 'utf8') + } catch (err) { + return { file, dirs: [], readError: (err && err.code) || String(err) } + } + const dirs = text + .split(/\r?\n/) + .map((l) => l.replace(/#.*$/, '').trim()) + .filter((l) => l !== '') + return { file, dirs, readError: null } +} + 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 个)`) + const { dirs, registry } = tableCandidateDirs(argv) + const hits = [] + const scanned = [] + for (const dir of dirs) { + if (!fs.existsSync(dir)) continue + let names + try { + names = fs.readdirSync(dir).filter((n) => TABLE_RE.test(n)) + } catch (err) { + continue // 目录存在但读不了(权限等)⇒ 不算"扫过",也不冒充"没有" + } + scanned.push(dir) + if (names.length > 1) { + throw new Error( + `在 ${dir} 下按 /${TABLE_RE.source}/ 找到 ${names.length} 个参数表(要求恰好 1 个):${names.join(' , ')}`, + ) + } + if (names.length === 1) hits.push(path.join(dir, names[0])) } - return path.join(dir, hits[0]) + if (hits.length === 0) { + throw new Error( + `在 ${scanned.length} 个候选目录下按 /${TABLE_RE.source}/ **一份都没找到**(要求恰好 1 个)\n` + + ` 搜过的目录:\n${scanned.map((d) => ` · ${d}`).join('\n')}\n` + + ` 注册文件:${registry.file}${registry.readError === null ? `(${registry.dirs.length} 条)` : `(读取失败:${registry.readError})`}\n` + + ` ⇒ 处置(三选一):① \`--table \` 直接指定 ② 把参数表所在目录(一行一个)写进上面的注册文件 ③ 在参数表所在目录下运行`, + ) + } + if (hits.length > 1) { + throw new Error( + `跨候选目录共找到 ${hits.length} 个参数表(要求恰好 1 个,⛔ 不猜哪一份是对的):\n` + + `${hits.map((h) => ` · ${h}`).join('\n')}`, + ) + } + return hits[0] } /** 值 → 去掉 markdown 的 `**` 与反引号。 */ @@ -388,15 +515,24 @@ function ssh(port, target, command, timeoutMs) { * 🆕 序⑫:`ss` / `nft` **回传原文**(`base64 -w0`),归一化在本地做 —— * 旧版只回 `wc -l` 的计数 ⇒ 判据只能比数字(假红/假绿的根源)。 * ⚠️ `base64` 字母表无 `=`(除末尾填充)⇒ 与下面 `key=value` 的行解析兼容。 + * + * 🆕 序㊲:**实例面码改为按"派生出来的端口"逐个取** —— 入参 `probePairs` 的每一项是 + * `{ port, localPort }`(`port` = 实例在 `/status.endpoints[]` 里声明的端口,`localPort` = 中继回环落点)。 + * 旧版写死两个面(`LOCAL_INSTANCE_PORT` 直连 + `PEER_INSTANCE_PORT` 经 relay)⇒ **表值一漂就取错面**。 + * ⛔ 键名用 `port`(= 端点身份),⛔ 不用 `localPort`(那才是每次替换都在变的值)。 + * @param {{port:number, localPort:number}[]} probePairs 派生出来的实例端点(可能为空) */ -function remoteFacts(r, sshPort, target, peerLocalPort) { +function remoteFacts(r, sshPort, target, probePairs) { const host = r.need('RELAY_BIND') + const codeLines = probePairs.map( + (p) => + `echo "instcode_${p.port}=$(curl -s -o /dev/null -w '%{http_code}' --http1.1 http://${host}:${p.localPort}/)"`, + ) 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}/)"`, + ...codeLines, `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('; ') @@ -408,6 +544,46 @@ function remoteFacts(r, sshPort, target, peerLocalPort) { return out } +/** + * 派生「在册实例端点」——**唯一来源 = relay 自身 `/status.endpoints[]`**。 + * + * ⛔ **不许**拿表内固定端口去匹配(那些值会漂移:`findFreePortInRange` 上扫 ⇒ 替换时顺延)。 + * ⛔ 排除 `agentPorts`(agent 面是 worker 的隧道口,⛔ 不是"实例端点")。 + * @param {any[]} eps `/status.endpoints[]` + * @param {Set} agentPorts agent 面端口集(配置常量) + */ +function deriveInstanceEndpoints(eps, agentPorts) { + return (Array.isArray(eps) ? eps : []).filter( + (e) => e !== null && typeof e === 'object' && e.online === true && !agentPorts.has(Number(e.port)), + ) +} + +/** + * 解析 `--instance-fixture`:**按端口的映射** `"21001=401,20000=200"`。 + * 🔴 旧的位置式 `<本机码>,<对端码>`(序 ⑳–㊱ 口径)**已退役** ⇒ 命中即**报错退出**: + * 静默按新口径解释会造出一个"看不懂的 FAIL",而静默按旧口径解释又会**只治输入不治判据**。 + * @returns {{map: Map, error: string}} + */ +function parseInstanceFixture(value) { + const map = new Map() + for (const rawItem of String(value).split(',')) { + const item = rawItem.trim() + if (item === '') continue + const m = /^(\d+)\s*=\s*(\d+)$/.exec(item) + if (m === null) { + return { + map, + error: + `--instance-fixture 项 ${JSON.stringify(item)} 形态不合法。` + + `序㊲ 起口径 = **按实例端口映射**(如 "--instance-fixture 21001=401");` + + `⛔ 旧的位置式 "<本机码>,<对端码>" 已退役(判据不再依赖表内固定端口)`, + } + } + map.set(Number(m[1]), Number(m[2])) + } + return { map, error: '' } +} + function readFixture(file) { if (!fs.existsSync(file)) throw new Error(`夹具不存在:${file}`) return fs.readFileSync(file, 'utf8') @@ -422,7 +598,12 @@ function usage() { ' --listen-fixture --nft-fixture --status-fixture \n' + ' --status-fixture-2 <第二次 /status 原文> ⬅️ 只有"增量"类判据(OBS-13)需要;缺省 = 与第一次同一份\n' + ' --status-fixture-3 <第三次 /status 原文> ⬅️ 只有 OBS-16(门窗口)需要;缺省 = 与第二份同一份 ⇒ 会 FAIL 并点名\n' + - ' --instance-fixture <本机码>,<对端码> ⬅️ 只有 OBS-09 需要(实例面 HTTP 码);缺省 ⇒ 在册则 FAIL 并点名\n' + + ' --instance-fixture <实例端口=HTTP码>,… ⬅️ 只有 OBS-09 需要(实例面 HTTP 码)\n' + + ' 🔴 序㊲ 起口径 = **按端口映射**(如 "--instance-fixture 21001=401");\n' + + ' ⛔ 旧的位置式 "<本机码>,<对端码>" 已退役 ⇒ 给了**直接报错退出**;\n' + + ' ⛔ 派生端口缺码 ⇒ 在册仍判 ⇒ FAIL 并点名(契约面,⛔ 不静默放行)\n' + + ' --peer-status-fixture <对端中继 /status 原文> ⬅️ 只有「47 视角端点表为空」的**可分**需要\n' + + ' (OBS-08 的空表归类 / OBS-09 的 SKIP 留痕;缺省 ⇒ 记"不可判(夹具模式)")\n' + ' --content-fixture ⬅️ 只有 OBS-17 需要(内容面判别器);缺省 ⇒ 无 content 块 ⇒ FAIL 并点名\n' + // 序㉖:OBS-19(抖动块)/ OBS-20(容量余量)**不需要新参数** —— 数据源就是 `--status-fixture`/真机 `/status`。 ' 🆕 序㉖:OBS-19(`status.jitter` 结构+口径)/ OBS-20(`capacity.utilPct < utilMaxPct`)复用上面的 /status 源\n' + @@ -435,7 +616,22 @@ function usage() { // 序㉘→单B:OBS-23(组密钥加密)—— 数据源 = `content.crypto` 块(真机从 /status 取;夹具用 --content-crypto-fixture)。 ' 🆕 序㉘单B:OBS-23(组密钥加密)--content-crypto-fixture \n' + ' (缺省 ⇒ 从 `--content-fixture` / 真机 `content.crypto` 取;两处都没有 ⇒ **SKIP + 留痕**\n' + - ' = "缺省不启用"这一**合法状态**,⛔ 但绝不是"静默绿")\n' + ' = "缺省不启用"这一**合法状态**,⛔ 但绝不是"静默绿")\n' + + // 序㊱:OBS-24(网注册表+派生自洽+跨网零共享)—— 数据源 = 控制面的注册表 JSON。 + ' 🆕 序㊱:OBS-24(网注册表与节点清单)--nodes-fixture <注册表 JSON>\n' + + ' (真机 = `cat `;**文件不存在 ⇒ SKIP + 留痕**\n' + + ' = "还没开始用这套准入"这一**合法状态**,⛔ 但绝不是"静默绿";\n' + + ' 🔴 序㊳起:**夹具模式缺本夹具 ⇒ SKIP + 留痕,⛔ 不去 ssh**)\n' + + // 序㊱:OBS-25(join 入口+具名失败+一次性)—— 数据源 = admit CLI 的 selfcheck 读数。 + ' 🆕 序㊱:OBS-25(一键加入自检)--join-fixture \n' + + ' (真机 = `cat `;**文件不存在 ⇒ SKIP + 留痕**;\n' + + ' 🔴 序㊳起:**夹具模式缺本夹具 ⇒ SKIP + 留痕,⛔ 不去 ssh**)\n' + + // 序㊵(P2/S5):OBS-26/27/28(直连开关 / 候选准入 / 打洞)—— 数据源 = 直连自检读数(**一份喂三条**)。 + ' 🆕 序㊵:OBS-26(开关+默认值+提示)/ OBS-27(候选准入)/ OBS-28(打洞+判死+冷却)\n' + + ' --direct-fixture <直连自检读数 JSON>\n' + + ' (producer = `node scripts/overlay-direct-probe.cjs selfcheck --json`;\n' + + ' 真机 = `cat `;**文件不存在 ⇒ 三条一起 SKIP + 留痕**;\n' + + ' 🔴 夹具模式缺本夹具 ⇒ SKIP + 留痕,⛔ 不去 ssh(序㊳ 的封闭性纪律))\n' ) } @@ -452,6 +648,8 @@ function main() { const statusFx2 = argOf(argv, '--status-fixture-2') const statusFx3 = argOf(argv, '--status-fixture-3') const instFx = argOf(argv, '--instance-fixture') + // 序㊲:对端中继 `/status` 原文(**只在 47 视角为空**时用得上 ⇒ 用于"空表可分")。 + const peerStatusFx = argOf(argv, '--peer-status-fixture') const contentFx = argOf(argv, '--content-fixture') const candFx47 = argOf(argv, '--candidates-fixture-47') const candFx106 = argOf(argv, '--candidates-fixture-106') @@ -460,6 +658,12 @@ function main() { const teardownFx106 = argOf(argv, '--teardown-fixture-106') // 序㉘→单B(OBS-23):`content.crypto` 块(十个判别器键)。 const cryptoFx = argOf(argv, '--content-crypto-fixture') + // 序㊱(OBS-24):控制面**注册表** JSON(网 / 节点 / 状态)。 + const nodesFx = argOf(argv, '--nodes-fixture') + // 序㊱(OBS-25):`overlay-node-admit.cjs selfcheck` 的**机器可读读数**。 + const joinFx = argOf(argv, '--join-fixture') + // 序㊵(OBS-26/27/28):`overlay-direct-probe.cjs selfcheck` 的**机器可读读数**(一份喂三条判据)。 + const directFx = argOf(argv, '--direct-fixture') const fixture = listenFx !== undefined || nftFx !== undefined || @@ -467,12 +671,16 @@ function main() { statusFx2 !== undefined || statusFx3 !== undefined || instFx !== undefined || + peerStatusFx !== undefined || contentFx !== undefined || candFx47 !== undefined || candFx106 !== undefined || teardownFx47 !== undefined || teardownFx106 !== undefined || - cryptoFx !== undefined + cryptoFx !== undefined || + nodesFx !== undefined || + joinFx !== undefined || + directFx !== undefined if (fixture && argOf(argv, '--table') === undefined) { process.stderr.write('❌ 夹具模式必须配 --table <参数表副本>(避免误改生产参数表)\n') return EXIT_USAGE @@ -487,6 +695,16 @@ function main() { } const r = makeReaders(table) + /** + * agent 面端口集(序㊲):⛔ **这是配置常量** —— 由两端 worker 单元固定,**不随「访问时自然替换」漂移** + * (与实例端口不同:后者由 `findFreePortInRange` 上扫取口)。用于把 `/status.endpoints[]` 切成 + * 「agent 面」与「实例面」。 + * ⚠️ 在 `r.bad` 统一检查**之前**取(`need()`/`num()` 是惰性的:调用才记账 ⇒ 留到判据里再取会漏报缺键)。 + */ + const agentPorts = new Set([r.num('W47_AGENT_PORT'), r.num('PEER_AGENT_PORT')]) + /** 实例端口 → 实例面 HTTP 码(**按端口映射**)。夹具模式由 `--instance-fixture` 给;真机由 ssh 取。 */ + let codeByPort = new Map() + let status /** * **第二次** `/status` 采样 —— 只有"增量"类判据要它(`OBS-13` 的稳态帧率 = 两次读数之差)。 @@ -518,11 +736,15 @@ function main() { 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')) - // OBS-09:实例面两个码(`<本机码>,<对端码>`)—— 夹具模式取不到远端 ⇒ 由调用方显式给。 + // OBS-09:实例面码 —— **按端口映射**(`21001=401,…`)。夹具模式取不到远端 ⇒ 由调用方显式给。 + // 🔴 序㊲:旧的位置式 `<本机码>,<对端码>` 已退役 ⇒ 命中即报错退出(⛔ 不静默解释)。 if (instFx !== undefined) { - const pair = instFx.split(',') - facts.set('instanceA', (pair[0] ?? '').trim()) - facts.set('instanceB', (pair[1] ?? '').trim()) + const parsed = parseInstanceFixture(instFx) + if (parsed.error !== '') { + process.stderr.write(`❌ ${parsed.error}\n`) + return EXIT_USAGE + } + codeByPort = parsed.map } } else { const sshPort = r.num('SSH_PORT') @@ -554,23 +776,29 @@ function main() { process.stderr.write(`❌ 第三次取 /status 失败:${err.message}\n`) return EXIT_USAGE } + // 序㊲:实例面取样点 = **从 `/status.endpoints[]` 派生**(⛔ 不再读表内固定端口 ⇒ 端口漂移不再致假 SKIP)。 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 + const probePairs = deriveInstanceEndpoints(preEps, agentPorts).map((e) => ({ + port: Number(e.port), + localPort: Number(e.localPort), + })) try { - facts = remoteFacts(r, sshPort, target, peerLocalPort0) + facts = remoteFacts(r, sshPort, target, probePairs) } catch (err) { process.stderr.write(`❌ 远端只读取数失败:${err.message}\n`) return EXIT_USAGE } + // 端口的码读不回来(curl 失败 ⇒ `000`)**不在这里兜底**:留给判据判 FAIL 并点名。 + codeByPort = new Map() + for (const p of probePairs) { + const v = Number(facts.get(`instcode_${p.port}`)) + if (Number.isFinite(v)) codeByPort.set(p.port, v) + } } 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')) @@ -582,6 +810,18 @@ function main() { // `derived` 的**唯一来源** = relay 自身 `/status` 的端点回环落点(动态值 ⇒ ⛔ 不许写死进参数表)。 const derived = new Set(eps.map((e) => `${relayBind}:${e.localPort}`)) + // 🆕 序㊱:两个新键**必须在 `r.bad` 检查之前**取出 —— `need()` 是**惰性**的(调用才记账), + // 留到 `OBS-24`/`OBS-25` 里再取 ⇒ "参数表里没这个键"会被漏过(本线"静默放行"的同族病)。 + const nodesRegistryFile = r.need('NODES_REGISTRY_FILE') + const nodesSelfcheckFile = r.need('NODES_SELFCHECK_FILE') + // 🆕 序㊵(OBS-26/27/28):直连自检读数落点 + 五个阈值 —— 同样**必须在 `r.bad` 之前**取出。 + const directSelfcheckFile = r.need('DIRECT_SELFCHECK_FILE') + const directHintMinParts = r.num('DIRECT_HINT_MIN_PARTS') + const directCandAcceptMin = r.num('DIRECT_CAND_ACCEPT_MIN') + const directCandRejectMin = r.num('DIRECT_CAND_REJECT_MIN') + const directPunchMinOk = r.num('DIRECT_PUNCH_MIN_OK') + const directCooldownMs = r.num('DIRECT_COOLDOWN_MS') + if (r.bad.length > 0) { process.stderr.write(`❌ 参数表缺键/坏值:${r.bad.join(' , ')}\n`) return EXIT_USAGE @@ -598,30 +838,155 @@ function main() { rows.push(skip || (fixture && judged !== true) ? { id, ok: true, skip: true, text } : { id, ok, text }) /** - * ── `OBS-09`:**在册 ⇒ 必须可达**(序 ⑳ 口径修正;详见文件头同名小节)── + * ── 「47 视角端点表为空」的**可分**(序㊲;详见文件头 `OBS-08` 同名小节)── + * + * 两个成因**必须分开**(旧口径一律 FAIL ⇒ 其中一个是**假红**): + * ⓐ 客户端挂在**另一台中继**上(拓扑态,⛔ 非故障)⇒ `elsewhere` + * ⓑ 两台都看不到它的会话 ⇒ 确实没挂客户端 ⇒ `nowhere` + * ⓒ 对端中继读不回来 ⇒ 不可判 ⇒ `unknown`(⛔ 不许静默当绿) + * + * 🔴 **只在需要时才去读**(47 视角非空的正常态 ⇒ **零额外 ssh**);结果缓存(两个判据共用一次读)。 + * 🔴 判别源 = **对端中继自身 `/status`**(回环、只读)⇒ ⛔ 零新增暴露面、⛔ 零新增参数键。 + */ + let emptyView + const classifyEmptyView = () => { + if (emptyView !== undefined) return emptyView + const target106 = r.need('SSH_TARGET_106') + const unknown = (why) => { + emptyView = { kind: 'unknown', trace: `对端中继(${target106})**不可判**:${why}` } + return emptyView + } + let raw + if (peerStatusFx !== undefined) { + try { + raw = readFixture(peerStatusFx) + } catch (err) { + return unknown(`夹具读取失败(⛔ 与"确实没挂"可分):${err.message}`) + } + } else if (fixture) { + // ⛔ 夹具模式**绝不 ssh**(否则"夹具结论"里混进生产读数)。 + return unknown('夹具模式未给 `--peer-status-fixture`(⛔ 不去 ssh)') + } else { + // 一次 ssh 取回**两件事**:`/status` 原文 + relay 单元活性(后者用于把"未运行"与"读取失败"分开)。 + try { + raw = ssh( + r.num('SSH_PORT'), + target106, + `curl -s ${r.need('RELAY_STATUS_URL')}; echo "__RELAY_ACTIVE__=$(systemctl is-active ${r.need('RELAY_UNIT_NAME')} 2>/dev/null || true)"`, + r.num('SSH_TIMEOUT_MS'), + ) + } catch (err) { + return unknown(`ssh 读取失败(⛔ 与"确实没挂"可分):${err.message}`) + } + } + const marker = raw.lastIndexOf('__RELAY_ACTIVE__=') + const active = marker >= 0 ? raw.slice(marker + '__RELAY_ACTIVE__='.length).trim() : 'unknown' + const body = marker >= 0 ? raw.slice(0, marker) : raw + let peerStatus + try { + peerStatus = JSON.parse(body) + } catch (err) { + return unknown( + active === 'active' + ? `对端中继在运行但 /status 解析失败(⛔ 读取失败,与"确实没挂"可分):${err.message}` + : `对端中继未给出可解析的 /status 且单元 is-active=${active}:${err.message}`, + ) + } + const peerSessions = Array.isArray(peerStatus.sessions) ? peerStatus.sessions : [] + // 「有会话在声明端口」= 该客户端**确实挂在**对端中继上(agent 面也算 —— 它只说明"实例面还没声明")。 + const attached = peerSessions.filter((s) => Array.isArray(s.ports) && s.ports.length > 0) + if (attached.length > 0) { + const detail = attached.map((s) => `${s.hostId} ports=${s.ports.join('/')}`).join(' ') + emptyView = { + kind: 'elsewhere', + trace: `47 视角端点表为空,但**对端中继**(${target106})声明了端口:${detail} ⇒ 客户端挂在**另一台中继**上(⛔ 非故障、⛔ 不判红)`, + } + return emptyView + } + if (active !== 'active') { + emptyView = { + kind: 'nowhere', + trace: `对端中继(${target106})单元 is-active=${active} ⇒ 客户端**不可能**挂在它上面,而 47 视角也没有它 ⇒ **该主机确实没挂客户端**`, + } + return emptyView + } + emptyView = { + kind: 'nowhere', + trace: `对端中继(${target106})在运行但**没有任何会话在声明端口**,而 47 视角端点表也为空 ⇒ **该主机确实没挂客户端**`, + } + return emptyView + } + + /** + * ── `OBS-09`:**在册 ⇒ 必须可达**(序 ㊲ 口径修正:判据从事实派生、⛔ 不再依赖表内固定端口)── * * 两种模式**都要判** ⇒ 单独成函数,⛔ 不塞进 `!fixture` 分支(否则夹具模式证不了 * "既能判 PASS 也能判 FAIL")。`eps` / `codeSet` / `add` 均已就绪。 */ const judgeObs09 = () => { - // 在册判据 = `/status.endpoints[]` 里有**该实例端口**且 `online === true`。 - const registered = new Set(eps.filter((e) => e.online === true).map((e) => Number(e.port))) - const probes = [ - { label: `本机:${r.need('LOCAL_INSTANCE_PORT')}`, port: r.num('LOCAL_INSTANCE_PORT'), code: Number(facts.get('instanceA')) }, - { label: `${peerLabel}:${peerLocalPort}`, port: r.num('PEER_INSTANCE_PORT'), code: Number(facts.get('instanceB')) }, - ] - const inReg = probes.filter((p) => registered.has(p.port)) - const outReg = probes.filter((p) => !registered.has(p.port)) - const bad = inReg.filter((p) => !codeSet.includes(p.code)) + // 「在册」= `/status.endpoints[]` 里 `online === true` 且 **非 agent 端口**的实例端点(⛔ 表值不参与)。 + const reg = deriveInstanceEndpoints(eps, agentPorts) + const agentOnly = eps.filter((e) => e.online === true && agentPorts.has(Number(e.port))).length + const offline = eps.filter((e) => e.online !== true).length + if (reg.length === 0) { + add( + 'OBS-09', + true, + `在册实例面 无(从 /status.endpoints[] 派生为空:端点表 ${eps.length} 条 = agent ${agentOnly} 条 + 离线 ${offline} 条)` + + ` ⇒ SKIP + 留痕|${classifyEmptyView().trace}`, + true, + true, + ) + return + } + const probes = reg.map((e) => { + const port = Number(e.port) + const code = codeByPort.get(port) + return { hostId: e.hostId ?? '?', port, localPort: Number(e.localPort), code } + }) + // ⛔ 派生端口**缺码**不许静默放行(夹具没给 ⇒ 契约面 FAIL 并点名;真机 curl 失败 ⇒ 码为 `000` ⇒ 也不在集合里)。 + const unknown = probes.filter((p) => !Number.isFinite(p.code)) + const bad = probes.filter((p) => Number.isFinite(p.code) && !codeSet.includes(p.code)) + const show = (p) => `${p.hostId}:${p.localPort}=${Number.isFinite(p.code) ? p.code : '?'}` add( 'OBS-09', - bad.length === 0, - `在册实例面 ${inReg.map((p) => `${p.label}=${p.code}`).join(' ') || '无'}` + + unknown.length === 0 && bad.length === 0, + `在册实例面(派生 ${reg.length} 条):${probes.map(show).join(' ')}` + ` (阈值 ∈ {${codeSet.join(',')}})` + - `|不在册 ${outReg.length} 项(记 SKIP 的依据):${outReg.map((p) => p.label).join(',') || '无'}` + - `${bad.length > 0 ? ` |❌ ${bad.map((p) => `${p.label}=${p.code}`).join(' ')}` : ''}`, + `${bad.length > 0 ? ` |❌ ${bad.map(show).join(' ')}` : ''}` + + `${unknown.length > 0 ? ` |❌ 缺码 ${unknown.map((p) => p.port).join(',')}(夹具未给该端口的码 / 真机取不到)` : ''}`, + true, + ) + } + + /** + * ── `OBS-08`:端点表全在线(序㊲ 加「**空表可分**」)── + * + * 🔴 序㊲:**空表不再一律判红** —— 旧口径会把「客户端挂在另一台中继」这个**合法拓扑态**判成 + * FAIL(假红)。分三种:挂在别处 ⇒ SKIP + 留痕;确实没挂 ⇒ FAIL 并点名;不可判(对端中继读不回来) + * ⇒ FAIL 并点名(⛔ 不许静默当绿)。分类逻辑见 `classifyEmptyView()`。 + * ⚠️ 数据源就是 `/status` ⇒ **两种模式都要判**(与 `OBS-09` 同规则:夹具模式也要能证"既可 PASS 也可 FAIL")。 + */ + const judgeObs08 = () => { + if (eps.length === 0) { + const cls = classifyEmptyView() + if (cls.kind === 'elsewhere') { + add('OBS-08', true, `端点表 0 条 ⇒ SKIP + 留痕|${cls.trace}`, true, true) + } else { + add( + 'OBS-08', + false, + `端点表 0 条 ⇒ FAIL(${cls.kind === 'nowhere' ? '两台中继都无该客户端会话' : '不可判'})|${cls.trace}`, + true, + ) + } + return + } + add( + 'OBS-08', + eps.every((e) => e.online === true), + `端点表 ${eps.length} 条 / 离线 ${eps.filter((e) => e.online !== true).length} 条`, true, - inReg.length === 0, ) } @@ -658,26 +1023,16 @@ function main() { 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} 条`, - ) + judgeObs08() judgeObs09() 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', - ]) { + for (const id of ['OBS-01', 'OBS-02', 'OBS-03', 'OBS-04', 'OBS-05', 'OBS-06', 'OBS-07']) { add(id, true, '夹具模式未取证') } + // 🆕 序㊲:`OBS-08` 移出"夹具模式未取证"名单 —— 它的数据源就是 `/status`(夹具已提供) + // ⇒ 两种模式都能按真实判据出 PASS/FAIL(否则「空表可分」这条**没法用夹具先红后绿**)。 + judgeObs08() judgeObs09() add('OBS-10', true, '夹具模式未取证') } @@ -1488,6 +1843,385 @@ function main() { } } + /** + * ── `OBS-24`:**网注册表与节点清单在册 + 白名单派生自洽 + 跨网零共享**(序㊱ · S1/S4)── + * + * 判据五件套(口径见参数表 §6 `OBS-24`): + * ① **结构完整**:`version` = `NODES_REGISTRY_VERSION`、`nodes` 是对象、每条记录的 + * `status ∈ {pending,approved}`、`nodeKey` 是 64 hex(⛔ 缺一即 FAIL —— "没装"与 + * "装了但字段少了"必须可分)。 + * ② **派生自洽**:对**每张网**,`approved` 集合 ≡ 派生出的白名单集合(差集**逐条点名**)。 + * ③ **`pending` 不进派生**:待批节点出现在派生里 ⇒ FAIL("**收单 ≠ 批准**"的可断言面)。 + * ④ **结构性错桶 = 0**:`auditDerivation.misfiled` 空 —— 派生桶里的 `hostId` 必须**属于该桶那张网**。 + * 🔑 这是 **`J5`(独立网零共享)** 的真正机器判据:条目带网络 ⇒ 不可能跨网共享; + * 一旦有人"扁平化 / 合并成一张网",就会以**错桶**或 ⑤ 的形态被抓到。 + * ⑤ **桶数 ≡ 有 `approved` 的网数**(⛔ 不许把多张网**合并成一张网** —— 合并 = `independent` + * 这张网从此与 `ops` 共享拨号面,正是"独立覆盖网络"要排除的形态)。 + * + * ⚠️ **刻意不判的一条(写在这里防下一棒再吵)**:**同一个 `hostId` 出现在两张网**是 + * **合法状态**(用户设备名由客户端取 ⇒ 跨用户撞名是常态;`test/overlay-join.test.mjs` 的 + * `D1` 已把它钉成"两张网各自独立、互不影响")。⇒ 探针只把它当**信息**打印,⛔ **不作判据** + * —— 拿它当红项就是把一个正当状态判成故障("判据打在自己的正确行为上")。 + * + * ⚠️ **数据源缺省 ⇒ SKIP + 留痕**(不是 FAIL):注册表**不存在** = "这套准入还没开始用" + * 这一**合法状态**(与 `OBS-23` 的"缺省不启用"同纪律)。但**读取失败**(ssh 报错 / + * 文件存在却解析不了)⇒ **FAIL 并点名** —— 两者必须可分。 + */ + { + let raw24 + let absent24 = false + let skip24 = '' + let err24 = '' + if (nodesFx !== undefined) { + try { + raw24 = readFixture(nodesFx) + } catch (err) { + err24 = err.message + } + } else if (fixture) { + // 🔴 **序㊳:夹具模式一律 SKIP**(⛔ 绝不 ssh)—— 见文件头「夹具模式」小节。 + skip24 = '夹具模式未给 `--nodes-fixture` ⇒ SKIP + 留痕(⛔ 不去 ssh 读生产)' + } else { + try { + const out = ssh( + r.num('SSH_PORT'), + r.need('SSH_TARGET_47'), + `if [ -f ${nodesRegistryFile} ]; then cat ${nodesRegistryFile}; else echo __NOFILE__; fi`, + r.num('SSH_TIMEOUT_MS'), + ) + if (out.trim() === '__NOFILE__') absent24 = true + else raw24 = out + } catch (err) { + err24 = err.message + } + } + if (err24 !== '') { + add('OBS-24', false, `❌ 注册表**读取失败**(⛔ 与"不存在"可分):${err24}`, true) + } else if (skip24 !== '') { + add('OBS-24', false, skip24, false, true) + } else if (absent24) { + add('OBS-24', false, `注册表 ${nodesRegistryFile} 不存在 ⇒ SKIP + 留痕("这套准入还没开始用"是合法状态,⛔ 不是静默绿)`, false, true) + } else { + let reg + try { + reg = JSON.parse(raw24) + } catch (err) { + reg = undefined + err24 = err.message + } + let reglib + let libErr = '' + try { + // 🔴 **判据复用真实实现**(⛔ 不许在探针里另写一份派生逻辑 —— 那是"同一事实两处写"的病根)。 + reglib = require('../lib/net/relay/registry.js') + } catch (err) { + libErr = err.message + } + if (reg === undefined || libErr !== '') { + add('OBS-24', false, `❌ 注册表不可判:${err24 || `产物缺失 ${libErr}`}`, true) + } else { + const parsed = reglib.parseRegistry(reg) + const shapeOk = parsed !== undefined + const audit = parsed === undefined ? { ok: false, mismatches: ['注册表形状非法'], misfiled: [] } : reglib.auditDerivation(parsed) + const derivedMap = parsed === undefined ? new Map() : reglib.deriveDialers(parsed) + // ③ `pending` 不进派生 + const pendingInDerived = [] + if (parsed !== undefined) { + for (const rec of Object.values(parsed.nodes)) { + if (rec.status === 'pending' && (derivedMap.get(rec.network) ?? new Set()).has(rec.hostId)) { + pendingInDerived.push(`${rec.network}/${rec.hostId}`) + } + } + } + // ④ 结构性错桶(`misfiled`)+ ⑤ 桶数 ≡ 有 approved 的网数 + const netsWithApproved = + parsed === undefined + ? 0 + : new Set(Object.values(parsed.nodes).filter((r2) => r2.status === 'approved').map((r2) => r2.network)).size + const bucketMismatch = derivedMap.size !== netsWithApproved + // ⚠️ **信息项**(⛔ 非判据):同名跨网 —— 同一 hostId 落在 ≥2 张网的 approved 集合里。 + // 这是**合法**状态(客户端自取设备名会撞名;`test/overlay-join.test.mjs#D1` 已钉死)。 + const approvedByNetwork = new Map() + if (parsed !== undefined) { + for (const rec of Object.values(parsed.nodes)) { + if (rec.status !== 'approved') continue + const set = approvedByNetwork.get(rec.network) ?? new Set() + set.add(rec.hostId) + approvedByNetwork.set(rec.network, set) + } + } + const nameSeen = new Map() + const sharedNames = [] + for (const net of [...approvedByNetwork.keys()].sort()) { + for (const host of approvedByNetwork.get(net)) { + if (nameSeen.has(host)) sharedNames.push(`${host}(${nameSeen.get(host)} ∩ ${net})`) + nameSeen.set(host, net) + } + } + const nets = parsed === undefined ? [] : reglib.summarizeNetworks(parsed) + const approvedTotal = nets.reduce((n, x) => n + x.approved, 0) + const pendingTotal = nets.reduce((n, x) => n + x.pending, 0) + const ok24 = shapeOk && audit.ok && pendingInDerived.length === 0 && !bucketMismatch + add( + 'OBS-24', + ok24, + `注册表 ${shapeOk ? '形状合法' : '❌ 形状非法(⛔ 不静默修复)'}` + + `|网 ${nets.length} 张(approved ${approvedTotal} / pending ${pendingTotal})` + + `|派生自审 ${audit.ok ? '一致 ✅' : `❌ 集合不配:${audit.mismatches.join(' ; ')}`}` + + (audit.misfiled.length > 0 ? `|❌ 结构性错桶:${audit.misfiled.join(' ; ')}` : '|错桶 0 ✅') + + (pendingInDerived.length > 0 ? `|❌ pending 混进派生:${pendingInDerived.join(',')}` : '|pending 未进派生 ✅') + + `|派生桶数=${derivedMap.size} ≡ 有 approved 的网数=${netsWithApproved}${bucketMismatch ? ' ⇒ ❌ 多张网被合并成一张' : ' ✅'}` + + `|同名跨网=${sharedNames.length}(**信息项 ⛔ 非判据**${sharedNames.length > 0 ? `:${sharedNames.join(' ; ')}` : ''})`, + true, + ) + } + } + } + + /** + * ── `OBS-25`:**一键加入在册 + 失败具名 + 凭据一次性**(序㊱ · S2/S3)── + * + * 数据源 = `overlay-node-admit.cjs selfcheck` 的**机器可读读数**(那个命令会**真跑**一遍 + * 四步编排 + 四条负腿 + 一次性 + 派生自审,⛔ 不是手写出来的夹具)。 + * + * 判据五件套(口径见参数表 §6 `OBS-25`): + * ① **入口在册**:`artifacts.join` / `artifacts.registry` 都为真 **且** 四步齐(`joinSteps` 长度 = `JOIN_STEPS_MIN` 且全 ok)。 + * ② **私钥不出机**:`application.hasPrivateKey === 0` 且申请单**非空**(字节数 > 0)。 + * ③ **一次性**:`ledger.second === 'invite-already-used'`。 + * ④ **⛔ 不许静默拒绝**:`silentRejections === 0` **且** 具名失败条数 ≥ `JOIN_NAMED_FAILURES_MIN`。 + * ⑤ **派生自审一致 + 跨网零共享**:`registry.auditOk === true` 且 `derivation.crossNetworkShared === 0`。 + * + * ⚠️ **私钥权限**(`nodeKeyModeOk`):`true` ⇒ 计入;`false` ⇒ **FAIL 并点名**; + * `null` ⇒ **本平台不可判**(Windows 的 `stat.mode` 不支持 Unix 权限)⇒ **留痕但不计绿** + * —— 真机腿由部署后在 **Linux** 上跑 selfcheck 承载。⛔ 不许把 `null` 当 PASS。 + * + * ⚠️ **数据源缺省 ⇒ SKIP + 留痕**(读数文件不存在 = 还没在真机跑过自检)。 + */ + { + let raw25 + let absent25 = false + let skip25 = '' + let err25 = '' + if (joinFx !== undefined) { + try { + raw25 = readFixture(joinFx) + } catch (err) { + err25 = err.message + } + } else if (fixture) { + // 🔴 **序㊳:夹具模式一律 SKIP**(⛔ 绝不 ssh)—— 见文件头「夹具模式」小节。 + skip25 = '夹具模式未给 `--join-fixture` ⇒ SKIP + 留痕(⛔ 不去 ssh 读生产)' + } else { + try { + const out = ssh( + r.num('SSH_PORT'), + r.need('SSH_TARGET_47'), + `if [ -f ${nodesSelfcheckFile} ]; then cat ${nodesSelfcheckFile}; else echo __NOFILE__; fi`, + r.num('SSH_TIMEOUT_MS'), + ) + if (out.trim() === '__NOFILE__') absent25 = true + else raw25 = out + } catch (err) { + err25 = err.message + } + } + if (err25 !== '') { + add('OBS-25', false, `❌ 自检读数**读取失败**(⛔ 与"不存在"可分):${err25}`, true) + } else if (skip25 !== '') { + add('OBS-25', false, skip25, false, true) + } else if (absent25) { + add('OBS-25', false, `自检读数 ${nodesSelfcheckFile} 不存在 ⇒ SKIP + 留痕(真机还没跑过 selfcheck)`, false, true) + } else { + let sc + try { + sc = JSON.parse(raw25) + } catch (err) { + sc = undefined + err25 = err.message + } + if (sc === undefined || typeof sc !== 'object') { + add('OBS-25', false, `❌ 自检读数不可判(JSON 解析失败:${err25})`, true) + } else { + const stepsMin = r.num('JOIN_STEPS_MIN') + const namedMin = r.num('JOIN_NAMED_FAILURES_MIN') + const steps = Array.isArray(sc.joinSteps) ? sc.joinSteps : [] + const entryOk = + sc.artifacts?.join === true && + sc.artifacts?.registry === true && + steps.length >= stepsMin && + steps.every((x) => x.ok === true) + const leakOk = sc.application?.hasPrivateKey === 0 && Number(sc.application?.bytes) > 0 + // 🔴 **CLI 回环**:`join` 产出的申请单必须能被控制面 `apply` 的解析读回 + // (真机首轮实测:两者形状不一致 ⇒ 收单恒失败;⛔ 这条不加,缺陷会再从单测缝里溜过去)。 + const roundTripOk = sc.application?.shapeOk === true && sc.application?.hostMatches === true + const onceOk = sc.ledger?.second === 'invite-already-used' + const named = Array.isArray(sc.namedFailures) ? sc.namedFailures : [] + const namedOk = sc.silentRejections === 0 && named.length >= namedMin + const deriveOk = sc.registry?.auditOk === true && sc.derivation?.crossNetworkShared === 0 + const mode = sc.nodeKeyModeOk + const modeOk = mode === true + const ok25 = entryOk && leakOk && roundTripOk && onceOk && namedOk && deriveOk && mode !== false + add( + 'OBS-25', + ok25, + `${entryOk ? '入口在册 ✅' : '❌ 入口/四步不全'}` + + `(${steps.filter((x) => x.ok === true).length}/${steps.length} 步,阈值 ≥ ${stepsMin})` + + `|私钥泄露=${sc.application?.hasPrivateKey}(须 0)申请单 ${sc.application?.bytes} B` + + `|CLI 回环 shapeOk=${sc.application?.shapeOk} hostMatches=${sc.application?.hostMatches}${ + roundTripOk ? ' ✅' : ` ❌ ${sc.application?.shapeError || ''}` + }` + + `|一次性 second=${sc.ledger?.second}(须 invite-already-used)` + + `|具名失败 ${named.length}(阈值 ≥ ${namedMin})静默拒绝=${sc.silentRejections}(须 0)` + + `|自审 auditOk=${sc.registry?.auditOk} 跨网共享=${sc.derivation?.crossNetworkShared}(须 0)` + + `|私钥权限=${sc.nodeKeyMode}${ + mode === null + ? `(platform=${sc.platform ?? '?'} ⇒ **本平台不可判**,⛔ 不计绿;真机腿另取)` + : mode === true + ? ' ✅' + : ' ❌ 不成立' + }`, + true, + ) + } + } + } + + /** + * ── `OBS-26` / `OBS-27` / `OBS-28`:**直连(打洞)**(序㊵ · P2/S5)── + * + * 数据源 = **一份**读数(`overlay-direct-probe.cjs selfcheck --json` 真跑产出)⇒ 三条判据共用一次取数。 + * 口径(逐条见参数表 §6): + * - `OBS-26` 开关「用户可设置 + 默认开 + 有提示」+ join 回读 ⇒ **D2 / D3 / D4** + * - `OBS-27` 候选准入「同网+白名单,且拒绝必须显式」⇒ **D1** + * - `OBS-28` 打洞「成功路径 + 失败判死 + 冷却非零」⇒ **D5 / D6**;顺带断言**只用内建 `dgram`** + * + * ⚠️ **三条都没有 SKIP 档**(直连缺省即开 ⇒ "缺省不启用"不适用)—— 只有 + * ① 读数文件不存在 ② 夹具模式没给夹具 这两种**留痕 SKIP**,以及"读不回来/解析不了"的 FAIL。 + */ + { + let rawD + let absentD = false + let skipD = '' + let errD = '' + if (directFx !== undefined) { + try { + rawD = readFixture(directFx) + } catch (err) { + errD = err.message + } + } else if (fixture) { + // 🔴 序㊳ 的封闭性纪律:夹具模式一律 SKIP,⛔ 绝不 ssh。 + skipD = '夹具模式未给 `--direct-fixture` ⇒ SKIP + 留痕(⛔ 不去 ssh 读生产)' + } else { + try { + const out = ssh( + r.num('SSH_PORT'), + r.need('SSH_TARGET_47'), + `if [ -f ${directSelfcheckFile} ]; then cat ${directSelfcheckFile}; else echo __NOFILE__; fi`, + r.num('SSH_TIMEOUT_MS'), + ) + if (out.trim() === '__NOFILE__') absentD = true + else rawD = out + } catch (err) { + errD = err.message + } + } + + if (errD !== '') { + for (const id of ['OBS-26', 'OBS-27', 'OBS-28']) { + add(id, false, `❌ 直连自检读数**读取失败**(⛔ 与"不存在"可分):${errD}`, true) + } + } else if (skipD !== '') { + for (const id of ['OBS-26', 'OBS-27', 'OBS-28']) add(id, false, skipD, false, true) + } else if (absentD) { + for (const id of ['OBS-26', 'OBS-27', 'OBS-28']) { + add(id, false, `直连自检读数 ${directSelfcheckFile} 不存在 ⇒ SKIP + 留痕(真机还没跑过直连自检)`, false, true) + } + } else { + let d + try { + d = JSON.parse(rawD) + } catch (err) { + d = undefined + errD = err.message + } + if (d === undefined || typeof d !== 'object') { + for (const id of ['OBS-26', 'OBS-27', 'OBS-28']) add(id, false, `❌ 直连自检读数不可判(JSON 解析失败:${errD})`, true) + } else { + // ── OBS-26:开关 + 默认值 + 提示 + join 回读(D2 / D3 / D4)───────────── + const s = d.switch ?? {} + const h = d.hint ?? {} + const j = d.joinConf ?? {} + const defaultOk = s.defaultCase?.enabled === true && s.defaultCase?.source === 'default' + const offOk = s.offCase?.enabled === false && s.offCase?.udpSocketsOpened === 0 && s.offCase?.candidatesEmitted === 0 + const badOk = s.badCase?.enabled === null + const hintOk = Number(h.parts) >= directHintMinParts && h.mentionsOff === true && h.mentionsScope === true && h.mentionsImpact === true + /** + * 🆕 序㊸:读数**生产方**(`overlay-direct-probe.cjs`)在**节点形态**上会**具名降级** + * `join 回读`(该腿要 `lib/net/relay/registry.js` —— **控制面注册表**模块, + * 106 这类 worker/relay 节点不落它;原文 `Cannot find module '…/registry.js'`)。 + * ⇒ 此时该**子判据**按本线口径取 **SKIP + 留痕**(⛔ 缺依赖 ≠ 故障,⛔ 不判红; + * 但**必须**在 detail 里点名缺哪个模块)—— ⛔ 不许把它当成"读到了 false"。 + * ⚠️ 仅当生产方**显式**给出 `available:false` 时才降级;形状缺省(老读数 / 47 读数)照旧判。 + */ + const joinDegraded = j.available === false + const joinOk = joinDegraded ? null : j.direct === true && j.readback === true + const ok26 = defaultOk && offOk && badOk && hintOk && joinOk !== false + add( + 'OBS-26', + ok26, + `默认=${s.defaultCase?.enabled}(来源 ${s.defaultCase?.source})${defaultOk ? ' ✅' : ' ❌ 缺省必须 = 开'}` + + `|关闭 ⇒ UDP socket ${s.offCase?.udpSocketsOpened} 个 / 候选 ${s.offCase?.candidatesEmitted} 条${offOk ? ' ✅' : ' ❌ 必须双零'}` + + `|非法值=${s.badCase?.enabled}${badOk ? ' ✅' : ' ❌ 必须 = null(⛔ 不静默取缺省)'}` + + `|提示 ${h.parts} 段(阈值 ≥ ${directHintMinParts})怎么关=${h.mentionsOff} 谁能连=${h.mentionsScope} 关掉影响=${h.mentionsImpact}${hintOk ? ' ✅' : ' ❌'}` + + `|join 回读 ${joinDegraded ? `⛔ 具名降级(缺 ${(Array.isArray(j.missing) ? j.missing : []).map((m) => m.spec).join(' , ') || '(未具名)'})⇒ 该子判据 SKIP + 留痕` : `direct=${j.direct} 一致=${j.readback}${joinOk ? ' ✅' : ' ❌'}`}`, + true, + ) + + // ── OBS-27:候选准入「同网 + 白名单 + 显式拒绝」(D1)────────────────── + const c = d.candidate ?? {} + const rejected = Array.isArray(c.rejected) ? c.rejected : [] + const namedOk = rejected.length >= directCandRejectMin && rejected.every((x) => typeof x.reason === 'string' && x.reason !== '') + const acceptOk = Number(c.accepted) >= directCandAcceptMin + const silentOk = Number(c.silentRejections) === 0 + const bucketOk = c.bucketsPerNetwork === true + const ok27 = acceptOk && namedOk && silentOk && bucketOk + add( + 'OBS-27', + ok27, + `接受 ${c.accepted}(阈值 ≥ ${directCandAcceptMin})${acceptOk ? ' ✅' : ' ❌'}` + + `|具名拒绝 ${rejected.length} 条(阈值 ≥ ${directCandRejectMin})${namedOk ? ' ✅' : ' ❌ 有拒绝没带原因'}` + + `|静默拒绝=${c.silentRejections}${silentOk ? ' ✅' : ' ❌ 静默返空 = 本线老病根'}` + + `|按网分桶(同名跨网互不可见)=${c.bucketsPerNetwork}${bucketOk ? ' ✅' : ' ❌'}` + + `|准入实现=${c.matcher ?? '?'}`, + true, + ) + + // ── OBS-28:打洞「成功 + 判死 + 冷却」(D5 / D6)+ 依赖面 ──────────────── + const p = d.punch ?? {} + const cd = p.cooldown ?? {} + const dep = d.deps ?? {} + const succOk = p.success?.bidirectional === true && Number(p.attemptsOk) >= directPunchMinOk + const oneWayOk = p.oneWay?.bidirectional === false && p.oneWay?.a?.reason === 'one-way' + const dead = Array.isArray(p.dead) ? p.dead : [] + const deadOk = dead.length >= 1 && dead[0]?.bounded === true && dead[0]?.reason === 'deadline' + const coolOk = Number(cd.ms) === directCooldownMs && Number(cd.ms) > 0 && cd.secondAttemptBlocked === true && Number(cd.blocked) >= 1 && cd.zeroRejected === 'throws' + const depOk = dep.ok === true + const ok28 = succOk && oneWayOk && deadOk && coolOk && depOk + add( + 'OBS-28', + ok28, + `成功路径:双向=${p.success?.bidirectional} 成立次数=${p.attemptsOk}(阈值 ≥ ${directPunchMinOk})${succOk ? ' ✅' : ' ❌'}` + + `|单向负腿=${p.oneWay?.a?.reason}/${p.oneWay?.b?.reason}${oneWayOk ? ' ✅(⛔ 不许把单向当成功)' : ' ❌'}` + + `|判死=${dead[0]?.reason} 耗时 ${dead[0]?.elapsedMs}ms 有界=${dead[0]?.bounded}${deadOk ? ' ✅' : ' ❌'}` + + `|冷却 ${cd.ms}ms(阈值 = ${directCooldownMs},🔴 必须 > 0)二次被挡=${cd.secondAttemptBlocked} 命中 ${cd.blocked} 次 0 值=${cd.zeroRejected}${coolOk ? ' ✅' : ' ❌'}` + + `|依赖面=${depOk ? `只有内建 ✅(${(dep.found ?? []).join(' ')})` : `❌ 引入新依赖 ${(dep.bad ?? []).join(',')}`}`, + 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`) } diff --git a/src/net/relay/content/runtime.ts b/src/net/relay/content/runtime.ts index 6edffcf..ea59d87 100644 --- a/src/net/relay/content/runtime.ts +++ b/src/net/relay/content/runtime.ts @@ -18,8 +18,24 @@ * 3. **纯新增、可选、缺省可用**:relay 侧没装内容面时,`snapshot()` 返回 `undefined` * ⇒ `/status` 不含 `content` 键 ⇒ 与序㉔ 之前的字节级兼容(⛔ 不改任何既有字段)。 * + * ## 🆕 序㊶ · S6:`peer` 档**接线**(优先直连 → 回落 wss) + * 序㉔ 的 `peer` 档是**诚实回"没有"**的空壳(取回通道未接线)。S6 把它接上**两条通道**: + * + * | 顺序 | 通道 | 是什么 | + * |---|---|---| + * | ① | `direct` | S5 的直连(打洞)通道 —— **同时复用 S5 的准入判定**(`candidate.ts#admitCandidate`,其后端是 `network.ts#isAllowedDialer`) | + * | ② | `wss` | 既有 wss 取回通道(**注入位**;relay 协议侧暂无内容取回 op ⇒ 缺省 = 具名 `not-wired`) | + * + * 🔴 **D7 闸门(本模块最要紧的一行逻辑)**:取回的**落库字节**必须**复算**出与请求相同的块 id + * (`chunker#blockIdOf`)。不复算的接线会把"零回源"直接做成假绿 —— 块 id 口径一动, + * "命中 peer" 仍然全绿,而内容已经拼不上。 + * * ## ⛔ 本模块**不做**的事 * - 不做网络取块(peer 的真实取回通道在真机验证阶段由上层接线); + * - **不做准入判定**:直连候选只从 {@link ContentRuntime.noteDirectCandidate} 进来, + * 而它只收 S5 `CandidateLedger#judge` 判过 `ok:true` 的结果 ⇒ ⛔ 本模块**不另写一份白名单**; + * - 不做直连**数据面**(打洞成功后的块交换需要 UDP 应答端 = 新暴露面 ⇒ 独立后续项, + * 现状按具名 `data-plane-pending` 回落到 wss,⛔ 不静默当"没有"); * - 不读 `src/config.ts`(relay 是独立单元,见 `main.ts` 头部说明); * - 不写日志(日志在装配点给;本模块只负责**算账与报数**)。 * @@ -34,6 +50,165 @@ import { ContentStore, DEFAULT_MAX_BYTES } from './store.js' import type { ContentStoreCounters } from './store.js' import { chunkify, planOf, reassemble, blockIdOf, DEFAULT_BLOCK_SIZE } from './chunker.js' import type { ContentCipher, ContentCryptoCounters } from './crypto.js' +import { DIRECT_CAND_MAX_ADDRS, isValidAddress } from '../direct/candidate.js' +import type { CandidateVerdict, DirectAddress } from '../direct/candidate.js' +import { DEFAULT_DIRECT_COOLDOWN_MS, DirectCooldown } from '../direct/punch.js' +import { DIRECT_ENV_KEY, resolveDirectSwitch } from '../direct/index.js' +import type { DirectSwitchState } from '../direct/index.js' + +/* ── 🆕 序㊶ · S6:`peer` 档取块**通道**(接线) ───────────────────────────────────────── */ + +/** peer 档取块通道名。 */ +export type PeerChannelName = 'direct' | 'wss' + +/** + * 🔴 通道优先级 —— **唯一权威**(⛔ 不许在调用方另写一份数组)。 + * `direct` 在前(省中继一跳);不可用 / 未命中 / 出错 ⇒ 逐个往后回落。 + */ +export const PEER_CHANNEL_ORDER: readonly PeerChannelName[] = ['direct', 'wss'] + +/** + * 通道**此刻不可用**的具名原因。 + * + * 🔴 与"未命中"**必须可区分**(`source.ts` 纪律 2 的同一条纪律): + * - `miss` = 通道问了,对端说"我这没有"; + * - `unavailable` = 这条通道**根本没问**(开关关 / 冷却中 / 没候选 / 没接线 / 数据面未建成)。 + * + * 混在一起 ⇒ "直连从没生效过"会被读成"直连问过了但没有",正是本线的假绿形态。 + */ +export type PeerChannelDownReason = + /** 用户/运维把直连关了(`DSHS_OVERLAY_DIRECT=0`)。 */ + | 'disabled' + /** 开关**取值非法**(⛔ 不静默当开、⛔ 也不静默当关)。 */ + | 'invalid-switch' + /** 判死后仍在冷却里(⛔ 本次连 socket 都不开)。 */ + | 'cooldown' + /** 没有**准入过**的候选地址(候选交换还没喂进来 / 全被拒)。 */ + | 'no-address' + /** 🔴 直连**数据面**未建成(S5 只做打洞**探测**)⇒ 具名回落,⛔ 不假装取到。 */ + | 'data-plane-pending' + /** 该通道**未装配**(`wss` 缺省:relay 协议侧暂无内容取回 op)。 */ + | 'not-wired' + +/** 一次 peer 取块的**对象**(逻辑名 + 网 + 组;组键由 `peer.ts` 判)。 */ +export interface PeerRef { + name: string + network: string + group: string +} + +/** 一条取块通道 —— ⛔ 只回字节,**不做判定**(`available` 只答"此刻能不能问")。 */ +export interface PeerBlockChannel { + readonly name: PeerChannelName + /** 此刻是否可用(不可用 ⇒ **具名**,调用方据此记账并走下一通道)。 */ + available(peer: PeerRef, id: string): { ok: true } | { ok: false; reason: PeerChannelDownReason; detail: string } + /** 取块:拿到字节(**落库字节**)⇒ 返回;对端说没有 ⇒ `undefined`;出错 ⇒ 抛。 */ + fetch(peer: PeerRef, id: string): Promise +} + +/** `peer` 接线面的只读快照(探针 / 管理面读这一份)。 */ +export interface PeerWireSnapshot { + /** 通道优先级(= {@link PEER_CHANNEL_ORDER} 的副本)。 */ + order: PeerChannelName[] + /** 每条通道是否已装配。 */ + wired: Record + /** 逐通道"真问了"的次数。 */ + attempts: Record + /** 逐通道**命中**(=取回字节 + **复算通过**)。 */ + hits: Record + /** 逐通道**未命中**(问了,对端说没有)。 */ + misses: Record + /** 逐通道**抛错**(与未命中可区分)。 */ + errors: Record + /** 逐通道**不可用**(根本没问)。 */ + unavailable: Record + /** 不可用原因的具名分布(`:` → 次数)。 */ + downReasons: Record + /** 🔴 **D7 闸门**:复算过的块数。 */ + idChecks: number + /** 🔴 **D7 闸门**:复算**不符**被丢弃的块数(恒应 ≤ `idChecks`;命中时必为 0 增长)。 */ + idMismatches: number + /** 已登记的直连候选 peer 数(只含准入过的)。 */ + directCandidates: number + /** 最近一次 peer 取块的实况(⛔ 不许"没有原因地走了 wss")。 */ + last: { peer: string; channel: PeerChannelName | null; reason: string } | null +} + +/** + * 直连通道的装配选项。 + * + * ⚠️ `addresses` 是**唯一**的候选来源,而它由 {@link ContentRuntime.noteDirectCandidate} 喂养 —— + * 那条路径只接受 S5 准入判定 `ok:true` 的结果 ⇒ 白名单判定**不在这里**(⛔ 不另写一份)。 + */ +export interface DirectBlockChannelOptions { + /** 开关解析结果(由 `direct/index.ts#resolveDirectSwitch` 产出)。 */ + switchState: DirectSwitchState + /** 冷却表(与 S5 同一份纪律:`ms <= 0` ⇒ **构造期即抛**)。 */ + cooldown: DirectCooldown + /** 该 peer 的准入候选地址(`undefined` = 没有 ⇒ 具名 `no-address`)。 */ + addresses: (peer: string) => readonly DirectAddress[] | undefined +} + +/** + * 直连通道的**数据面未建成**标记。 + * + * ⛔ 为什么不直接返回 `undefined`:那会被记成"未命中"(= 对端没有),而真相是 + * "这条通道根本没能力取"。两者混同 ⇒ "直连零生效"被读成"直连没省到"(本线假绿形态)。 + */ +export const DIRECT_DATA_PLANE_PENDING = 'data-plane-pending' + +/** + * 造一条**直连通道**(顺序链的①)。 + * + * 🔴 三级闸门**顺序固定**(每一级都必须具名): + * ① 开关(关 / 非法 ⇒ `disabled` / `invalid-switch`) + * ② 冷却(判死过 ⇒ `cooldown`;⛔ 本次连 socket 都不开) + * ③ 候选地址(没准入过 ⇒ `no-address`) + * ④ 数据面(S6 未建成 ⇒ `data-plane-pending`,⛔ 不假装取到) + */ +export function createDirectBlockChannel(opts: DirectBlockChannelOptions): PeerBlockChannel { + return { + name: 'direct', + available(peer: PeerRef): { ok: true } | { ok: false; reason: PeerChannelDownReason; detail: string } { + const s = opts.switchState + if (s.enabled !== true) { + return s.enabled === false + ? { ok: false, reason: 'disabled', detail: `${s.envKey} 关闭(来源 ${s.source})⇒ ⛔ 不打洞、⛔ 零 UDP socket,直接回落` } + : { ok: false, reason: 'invalid-switch', detail: s.invalid === '' ? `${s.envKey} 取值非法` : s.invalid } + } + if (opts.cooldown.blocked(peer.name)) { + return { + ok: false, + reason: 'cooldown', + detail: `${peer.name} 在冷却中(${opts.cooldown.ms} ms)⇒ 本次不打洞,直接回落(⛔ 不进重试风暴)`, + } + } + const addrs = opts.addresses(peer.name) + if (addrs === undefined || addrs.length === 0) { + return { + ok: false, + reason: 'no-address', + detail: `${peer.name} 没有准入过的直连候选地址 ⇒ 回落(⛔ 不是"打洞失败")`, + } + } + return { + ok: false, + reason: 'data-plane-pending', + detail: + `有 ${addrs.length} 条准入候选、开关开、未冷却,但**直连数据面未建成**` + + `(S5 只做打洞探测,打洞后的块交换需新增 UDP 应答端 ⇒ 独立后续项)⇒ 具名回落,⛔ 不假装取到`, + } + }, + async fetch(peer: PeerRef): Promise { + // ⛔ 防御性:`available()` 在数据面建成前**恒不返回 ok:true** ⇒ 这里理应不可达。 + // 真被调到 ⇒ 大喊,而不是静默回"没有"(后者会把"没建成"伪装成"对端没有")。 + throw new Error( + `direct: 直连数据面未建成 —— ${peer.name} 的块交换未实现(${DIRECT_DATA_PLANE_PENDING});` + + `⛔ 不许把它记成"未命中",请走 available() 的具名降级`, + ) + }, + } +} /** 内容面运行时装配选项。 */ export interface ContentRuntimeOptions { @@ -60,6 +235,18 @@ export interface ContentRuntimeOptions { onDecodeRejected?: (tier: SourceTier, id: string, reason: 'decode-failed') => void /** 日志函数(可选)。⚠️ ⛔ 不许把明文块塞进日志(`OBS-23` 会扫)。 */ log?: (line: string) => void + /** + * 🆕 序㊶ · S6:**额外注入**的取块通道(按 {@link PEER_CHANNEL_ORDER} 排顺序)。 + * ⚠️ 缺省只装内置的 `direct`(见 {@link ContentRuntimeOptions.direct}); + * `wss` 通道**必须注入**才有(relay 协议侧暂无内容取回 op)。 + */ + peerChannels?: Partial> + /** + * 🆕 序㊶ · S6:内置直连通道的选项。 + * - 缺省 ⇒ 装配(开关读 `process.env` ⇒ 与 `DSHS_OVERLAY_DIRECT`「**缺省即开**」一致); + * - `false` ⇒ **不装配**(该通道缺 ⇒ 具名 `not-wired`)。 + */ + direct?: false | { env?: Readonly>; cooldownMs?: number } } /** @@ -95,6 +282,11 @@ export interface ContentSnapshot { storeBlocks: number /** 本节点**组内**已声明的 peer 名单(供上层做真机取块接线)。 */ groupMembers: string[] + /** + * 🆕 序㊶ · S6:**peer 档接线面**(通道顺序 / 具名降级 / **D7 复算闸门**)。 + * ⚠️ 纯新增键 —— 既有消费方(`OBS-17` 只逐键看 `source`/`peer`/`store`)零影响。 + */ + peerWire: PeerWireSnapshot /** * 🆕 序㉘ · 单 B:**组密钥加密判别器**(探针 `OBS-23` 的读取口径)。 * ⚠️ **不启用加密时本键整体缺席** ⇒ `OBS-23` 记 **SKIP**("缺省不启用"是合法状态)。 @@ -117,11 +309,40 @@ export class ContentRuntime { readonly cipher: ContentCipher | undefined private readonly storeMaxBytes: number + /** 本节点所属网(候选登记的防御性比对用;⛔ 不从别处猜)。 */ + private readonly network: string + /** 日志函数(未注入 ⇒ 静默)。 */ + private readonly log: ((line: string) => void) | undefined /** 自证写入的最后一个块 id(仅供 `selfProbe` 读回用)。 */ private lastProbeId: string | undefined + /** 🆕 S6:已装配的取块通道(按 {@link PEER_CHANNEL_ORDER} 排顺序)。 */ + private readonly peerChannels = new Map() + /** 🆕 S6:直连通道的开关解析结果(观测面用;未装配 ⇒ `undefined`)。 */ + private readonly directSwitch: DirectSwitchState | undefined + /** 🆕 S6:直连候选地址表(**只经 `noteDirectCandidate` 写入**,⛔ 不从别处塞)。 */ + private readonly directAddrs = new Map() + /** + * 🆕 S6:接线面计数。 + * + * ⚠️ `downReasons` 的键是 `` `${channel}:${reason}` `` —— 用字符串键而不是嵌套对象, + * 是为了让 `JSON.stringify` 出来的 `/status` 一眼可读、探针逐键断言也简单。 + */ + private readonly wire = { + attempts: { direct: 0, wss: 0 } as Record, + hits: { direct: 0, wss: 0 } as Record, + misses: { direct: 0, wss: 0 } as Record, + errors: { direct: 0, wss: 0 } as Record, + unavailable: { direct: 0, wss: 0 } as Record, + downReasons: {} as Record, + idChecks: 0, + idMismatches: 0, + last: null as PeerWireSnapshot['last'], + } constructor(opts: ContentRuntimeOptions) { this.storeMaxBytes = opts.storeMaxBytes ?? DEFAULT_MAX_BYTES + this.network = opts.network + this.log = opts.log this.cipher = opts.cipher this.store = new ContentStore({ maxBytes: this.storeMaxBytes, @@ -134,6 +355,26 @@ export class ContentRuntime { ...(this.cipher === undefined ? {} : { epoch: this.cipher.epoch }), ...(opts.log === undefined ? {} : { log: opts.log }), }) + // ── 🆕 序㊶ · S6:装配取块通道(顺序由 `PEER_CHANNEL_ORDER` 定,⛔ 不在这里排)────── + if (opts.direct !== false) { + const env = opts.direct?.env ?? process.env + const switchState = resolveDirectSwitch(env) + this.directSwitch = switchState + this.peerChannels.set( + 'direct', + createDirectBlockChannel({ + switchState, + cooldown: new DirectCooldown(opts.direct?.cooldownMs ?? DEFAULT_DIRECT_COOLDOWN_MS), + addresses: (peer) => this.directAddrs.get(peer), + }), + ) + } + const injected = opts.peerChannels ?? {} + for (const name of PEER_CHANNEL_ORDER) { + const ch = injected[name] + // ⚠️ 注入的通道**覆盖**内置的(便于夹具替身),⛔ 但不许改名(顺序权威只此一份) + if (ch !== undefined) this.peerChannels.set(name, ch) + } this.source = new ContentSourceChain({ fetchers: { // ① 本地:内容寻址存储命中即返回(零网络 —— 最省的档位) @@ -143,15 +384,10 @@ export class ContentRuntime { ? undefined : { tier: 'local' as const, bytes } }, - // ② 同组 peer:**诚实回"没有"**,直到上层把真实取回通道接上。 + // ② 同组 peer:**S6 接线** —— 逐候选 × 逐通道(direct → wss)尝试; + // 🔴 取回的**落库字节**必须复算出同一个块 id(D7 闸门),否则**丢弃且不算命中**。 // ⛔ 绝不许在这里伪造字节 —— 那会把 E1 的"零回源"做成假绿(本线的老病根)。 - peer: async (id: string) => { - const cands = this.peers.candidates(id) - if (cands.length === 0) return undefined - // 有候选但取回通道尚未接线 ⇒ 逐条记账后诚实回"没有" - for (const c of cands) this.peers.markDenied(c.name, id) - return undefined - }, + peer: async (id: string) => this.fetchFromPeers(id), }, // 🆕 单 B:**唯一解密点**(生产路径)—— 五档一律在这里解密 ...(this.cipher === undefined ? {} : { decode: (stored: Buffer) => this.cipher?.decodeBlock(stored) }), @@ -163,6 +399,142 @@ export class ContentRuntime { this.blockSize = DEFAULT_BLOCK_SIZE } + /** + * 🆕 序㊶ · S6:**peer 档接线的唯一执行点**。 + * + * 顺序写死:**候选**(同组,`peer.ts` 的 E5 闸门)→ **通道**(`direct` → `wss`)。 + * 每一步都记账,且三类结果**互相可区分**: + * - `unavailable`(这条通道压根没问)/`misses`(问了,对端没有)/`errors`(问了,炸了)/`hits`(拿到**且复算通过**)。 + * + * @returns 命中 ⇒ `{tier:'peer', bytes}`(**落库字节**,解密由链上唯一解密点做);否则 `undefined` + */ + private async fetchFromPeers(id: string): Promise<{ tier: 'peer'; bytes: Buffer } | undefined> { + const cands = this.peers.candidates(id) + if (cands.length === 0) return undefined + for (const c of cands) { + // 🔴 E5:跨组 ⇒ **显式拒绝**(计数在 `markDenied` 内,语义与序㉔ 逐字不变) + if (this.peers.markDenied(c.name, id)) continue + const ref: PeerRef = { name: c.name, network: c.network, group: c.group } + for (const name of PEER_CHANNEL_ORDER) { + const ch = this.peerChannels.get(name) + if (ch === undefined) { + this.wire.unavailable[name] += 1 + this.noteDown(name, 'not-wired') + continue + } + const av = ch.available(ref, id) + if (!av.ok) { + // ⛔ 不可用 ≠ 未命中:这条通道**没问**,只是被具名降级了 + this.wire.unavailable[name] += 1 + this.noteDown(name, av.reason) + this.wire.last = { peer: c.name, channel: name, reason: av.reason } + continue + } + this.wire.attempts[name] += 1 + let gotBytes: Buffer | undefined + try { + gotBytes = await ch.fetch(ref, id) + } catch (err) { + this.wire.errors[name] += 1 + this.wire.last = { peer: c.name, channel: name, reason: 'error' } + this.logLine(`[content-peer] ⛔ channel=${name} peer=${c.name} 取块抛错:${String(err)}`) + continue + } + if (gotBytes === undefined) { + this.wire.misses[name] += 1 + this.wire.last = { peer: c.name, channel: name, reason: 'miss' } + continue + } + // ── 🔴 **D7 闸门**:复算块 id(⛔ 这一行不许省、不许"先信后验")──────────── + this.wire.idChecks += 1 + const actual = blockIdOf(gotBytes) + if (actual !== id) { + this.wire.idMismatches += 1 + this.wire.errors[name] += 1 + this.wire.last = { peer: c.name, channel: name, reason: 'id-mismatch' } + this.logLine( + `[content-peer] ⛔ 复算不符 channel=${name} peer=${c.name}` + + ` 期望=${id.slice(0, 8)}… 实得=${actual.slice(0, 8)}… ⇒ **丢弃**(⛔ 不算命中、⛔ 不返回字节)`, + ) + continue + } + this.wire.hits[name] += 1 + this.wire.last = { peer: c.name, channel: name, reason: 'hit' } + return { tier: 'peer', bytes: gotBytes } + } + } + return undefined + } + + /** 记账:不可用原因分布(⛔ 不许"没有原因地走了另一条通道")。 */ + private noteDown(channel: PeerChannelName, reason: PeerChannelDownReason): void { + const key = `${channel}:${reason}` + this.wire.downReasons[key] = (this.wire.downReasons[key] ?? 0) + 1 + this.wire.last = this.wire.last ?? { peer: '', channel, reason } + } + + /** 日志(未注入 ⇒ 静默;⛔ 不把块字节写进日志)。 */ + private logLine(line: string): void { + this.log?.(line) + } + + /** + * 🆕 序㊶ · S6:登记一条**直连候选**(**唯一入口**)。 + * + * 🔴 **准入判定不在这里** —— 只接受 S5 `CandidateLedger#judge` 判过 `ok:true` 的结果 + * (那条链路复用 `network.ts#isAllowedDialer`,⛔ 本模块不另写一份白名单)。 + * 这里只做两件**防御性**收尾:① 网必须与本节点一致(跨网 ⇒ 拒)② 地址逐条过 `isValidAddress` + 条数上限。 + */ + noteDirectCandidate(verdict: CandidateVerdict): { ok: true; peer: string; addrs: number } | { ok: false; reason: string } { + if (!verdict.ok) return { ok: false, reason: verdict.reason } + const msg = verdict.message + if (msg.network !== this.network) { + return { ok: false, reason: `cross-network(载荷 ${msg.network} ≠ 本节点 ${this.network})` } + } + const addrs = msg.addrs.filter((a) => isValidAddress(a)).slice(0, DIRECT_CAND_MAX_ADDRS) + if (addrs.length === 0) return { ok: false, reason: 'bad-address(过滤后为空)' } + // ⚠️ 键 = **逻辑名** `/`(与 `peer.ts` 的 `PeerDeclaration.name` 同口径) + // —— 候选载荷里只有 `hostId`(relay 逻辑名的后半段),两处不同形 ⇒ 在这里统一,⛔ 别让调用方猜。 + const key = `${msg.network}/${msg.hostId}` + this.directAddrs.set(key, addrs.map((a) => ({ ...a }))) + return { ok: true, peer: key, addrs: addrs.length } + } + + /** 🆕 S6:候选下线(该 peer 不再作为直连对象)。 */ + withdrawDirectCandidate(peer: string): boolean { + return this.directAddrs.delete(peer) + } + + /** 🆕 S6:已登记的直连候选(只读视图)。 */ + directCandidates(): string[] { + return [...this.directAddrs.keys()].sort() + } + + /** 🆕 S6:接线面只读快照(探针 / 管理面共用这一份 —— ⛔ 别各写一套)。 */ + peerWireSnapshot(): PeerWireSnapshot { + const wired = { direct: false, wss: false } as Record + for (const name of PEER_CHANNEL_ORDER) wired[name] = this.peerChannels.has(name) + return { + order: [...PEER_CHANNEL_ORDER], + wired, + attempts: { ...this.wire.attempts }, + hits: { ...this.wire.hits }, + misses: { ...this.wire.misses }, + errors: { ...this.wire.errors }, + unavailable: { ...this.wire.unavailable }, + downReasons: { ...this.wire.downReasons }, + idChecks: this.wire.idChecks, + idMismatches: this.wire.idMismatches, + directCandidates: this.directAddrs.size, + last: this.wire.last === null ? null : { ...this.wire.last }, + } + } + + /** 🆕 S6:直连开关的解析结果(`undefined` = 内置直连通道未装配)。 */ + directSwitchState(): DirectSwitchState | undefined { + return this.directSwitch + } + /** 🆕 是否启用加密(判据用:区分"没启用"与"启用了但没解过")。 */ get cryptoEnabled(): boolean { return this.cipher !== undefined @@ -282,6 +654,8 @@ export class ContentRuntime { storeBytes: this.store.bytes, storeBlocks: this.store.size, groupMembers: this.peers.sameGroupPeers(), + // 🆕 S6:peer 档接线面(**纯新增键** ⇒ 既有消费方零影响) + peerWire: this.peerWireSnapshot(), // ⚠️ 不启用加密 ⇒ 本键**整体缺席**(不是补零!补零会让"没启用"与"启用了但零值"同形) ...(this.cipher === undefined ? {} : { crypto: this.cipher.counters() }), } diff --git a/src/net/relay/content/source.ts b/src/net/relay/content/source.ts index b7f46de..8af7e5c 100644 --- a/src/net/relay/content/source.ts +++ b/src/net/relay/content/source.ts @@ -39,6 +39,10 @@ * - 🔴 **不做完整性校验**(`E4` 那半边在 `store.get` 的读侧复算里)。⚠️ **如实留档**: * `peer` 档的真实取回通道**尚未接线** ⇒ 接线时**必须**在取回后复算 `blockIdOf` * (否则"篡改块被丢弃"只在 `local` 档成立 —— 已在单 B §8.13 登记)。 + * ✅ **序㊶(S6)已兑现**:复算闸门落在**装配点** `runtime.ts#fetchFromPeers` + * (取回的落库字节必须复算出同一个块 id,不符 ⇒ **丢弃且不算命中**,逐条 `idMismatches` 计数)。 + * ⛔ 刻意**不放在本模块**:链的职责是"按顺序问",块 id 口径属装配层的接线纪律 + * —— 放进来会让"五档通用链"认识块格式(分层退化)。 * * @module dshs/net/relay/content/source */ diff --git a/src/net/relay/direct/candidate.ts b/src/net/relay/direct/candidate.ts new file mode 100644 index 0000000..d4e2741 --- /dev/null +++ b/src/net/relay/direct/candidate.ts @@ -0,0 +1,382 @@ +/** + * 覆盖网络 **直连候选交换**(序㊵ · P2 · S5)—— 候选地址的**收发与校验**。 + * + * ## 它解决的确切问题 + * 打洞前双方必须知道**对方的公网 UDP 落点**。这份模块定义"这条消息长什么样"以及 + * "**什么样的对端才有资格发它**",其余(怎么打洞)在 `punch.ts`。 + * + * ## 🔴 三条不可退让的设计(每一条都对应本线吃过的一次亏) + * + * | # | 纪律 | 为什么 | + * |---|---|---| + * | ① | **走既有 relay 通道**,⛔ 零新口、⛔ 零新协议 | 候选交换**不是**数据面:它只需要"已被鉴权的那条连接"。新增监听口 = 命中 R5 | + * | ② | 准入**复用** `network.ts#isAllowedDialer`(= `server.ts` DIAL 白名单那一条策略) | 本线老病根「同一事实两处写」:另写一份 ⇒ 两处迟早分叉,而 `server.ts` 那两处是**冻结的只读面** | + * | ③ | 拒绝**必须显式 + 计数**,⛔ **不许静默返空** | "配置错长得像网络不通"是本线反复踩的假绿面:静默返空 = 判据全绿而功能全废 | + * + * ## ⛔ 载荷里不许有什么 + * 只准 `{kind, hostId, network, addrs[], ts}`。**任何**含密钥 / 签名 / 凭据字样的字段名 + * ⇒ `secret-field` 直接拒(判定见 {@link findSecretField})。理由:候选是**对端**给的、 + * 会被写进日志与观测面;它一旦能携带身份材料,就等于给"密钥本体不经网络"这条纪律开了一个洞。 + * + * @module dshs/net/relay/direct/candidate + */ + +import { isAllowedDialer, isHostId, isNetworkId, logicalName } from '../network.js' + +/** 候选消息的类型标签(走既有 relay 通道时的 JSON 信封)。⛔ 别改名 —— 探针与单测按它取值。 */ +export const DIRECT_MESSAGE_KIND = 'DIRECT_CANDIDATE' + +/** 单条消息里候选地址的上限(缺省)。⚠️ 权威值在参数表 `DIRECT_CAND_MAX_ADDRS`。 */ +export const DIRECT_CAND_MAX_ADDRS = 4 + +/** 候选的**新鲜期**(ms):超期 ⇒ `stale`。理由 = NAT 映射有寿命,过期的落点打不通还会浪费一次探测。 */ +export const DIRECT_CAND_TTL_MS = 60_000 + +/** 打洞用的候选地址(**IPv4 / IPv6 字面量**,⛔ 不收主机名 —— 打洞不能依赖 DNS)。 */ +export interface DirectAddress { + host: string + port: number +} + +/** 候选消息本体。 */ +export interface DirectCandidateMessage { + kind: typeof DIRECT_MESSAGE_KIND + /** 申报者自己在**这张网**里的逻辑名(= 发送方自己的 hostId)。 */ + hostId: string + network: string + addrs: DirectAddress[] + /** 产生时刻(epoch ms);`0` = 未标注(不判新鲜期)。 */ + ts: number +} + +/** + * 拒绝原因(**枚举**,⛔ 不收自由文本)—— 目的:让"哪一类被拒"可被机器判、可被计数。 + * + * ⚠️ `'cross-network'` 与 `'not-self-candidate'` 是**安全判据**(跨网 / 冒名申报第三人地址), + * ⛔ 不是"配置错了"。 + */ +export type CandidateReason = + /** 载荷形状不合法(缺字段 / 类型不对 / `kind` 不是本类型)。 */ + | 'bad-shape' + /** 载荷里出现了疑似密钥 / 凭据字段。 */ + | 'secret-field' + /** 申报的网与会话所属的网不一致(跨网)。 */ + | 'cross-network' + /** 申报的 `hostId` **不是发送方自己**(= 替第三人申报地址)。 */ + | 'not-self-candidate' + /** 发送方不在该网白名单里(⛔ 默认拒绝)。 */ + | 'not-a-dialer' + /** 本机不在该网白名单里 ⇒ 连"能拨"都不成立,没必要建立直连。 */ + | 'self-not-a-dialer' + /** 地址不是合法字面量(主机名 / 端口越界 / 非法 IP)。 */ + | 'bad-address' + /** 地址条数超上限。 */ + | 'too-many-addrs' + /** 超过新鲜期。 */ + | 'stale' + +/** 一次准入判定的结果(成功与失败都带 `detail`,⛔ 不许只回 `false`)。 */ +export type CandidateVerdict = + | { ok: true; message: DirectCandidateMessage } + | { ok: false; reason: CandidateReason; detail: string } + +/** + * 疑似密钥 / 凭据的字段名(**子串匹配,大小写不敏感**)。 + * + * ⚠️ 这份清单**故意从宽**:漏掉一个词 = 给"密钥本体不经网络"开洞; + * 误伤一个正常的字段名 = 改个字段名就行(代价不对称 ⇒ 从严)。 + */ +const SECRET_FIELD_TOKENS = [ + 'key', + 'secret', + 'token', + 'pem', + 'priv', + 'sign', + 'sig', + 'cert', + 'pass', + 'cred', + 'hmac', + 'bearer', +] as const + +/** + * 递归找一个"像密钥 / 凭据"的字段名(**任意深度**)。 + * + * ⛔ 不看**值**(那要靠猜测):只看**字段名** —— 判据必须可复现、不靠人看。 + */ +export function findSecretField(value: unknown, path = '$'): string | undefined { + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + const hit = findSecretField(value[i], `${path}[${i}]`) + if (hit !== undefined) return hit + } + return undefined + } + if (value === null || typeof value !== 'object') return undefined + for (const [k, v] of Object.entries(value as Record)) { + const low = k.toLowerCase() + if (SECRET_FIELD_TOKENS.some((t) => low.includes(t))) return `${path}.${k}` + const hit = findSecretField(v, `${path}.${k}`) + if (hit !== undefined) return hit + } + return undefined +} + +/** IPv4 字面量(**逐段 0–255**;⛔ 不收 `1.2.3`、⛔ 不收前导零)。 */ +export function isIpv4(raw: string): boolean { + const parts = raw.split('.') + if (parts.length !== 4) return false + return parts.every((p) => /^(0|[1-9][0-9]{0,2})$/.test(p) && Number(p) <= 255) +} + +/** + * IPv6 字面量(**宽松但排除了主机名字符**)。 + * + * 口径:只允许 `[0-9a-fA-F:]` 且冒号数量 ≥ 2(⇒ 单冒号形态如 `host:80` 不可能通过)。 + * ⛔ 刻意**不**做完整 RFC 解析:打洞只要求"能直接喂给 `dgram.send`",而形状错的地址 + * 会在 `dgram` 那里立刻报错 ⇒ 这里只负责拦掉"看着像主机名"的那一类。 + */ +export function isIpv6(raw: string): boolean { + if (!/^[0-9a-fA-F:]+$/.test(raw)) return false + const colons = (raw.match(/:/g) ?? []).length + return colons >= 2 && colons <= 7 +} + +/** 地址校验(只判**形状**;端口 1–65535)。 */ +export function isValidAddress(a: DirectAddress): boolean { + if (typeof a.host !== 'string' || a.host === '') return false + if (!isIpv4(a.host) && !isIpv6(a.host)) return false + return Number.isInteger(a.port) && a.port > 0 && a.port <= 65535 +} + +/** + * 严格解析一条候选消息。 + * + * ⛔ **不宽容**:任何形状问题都返回 `undefined`(调用方转成 `bad-shape` **并计数**), + * ⛔ 不补默认值、⛔ 不做类型强转(`"80"` 不是 `80`)。 + */ +export function parseDirectMessage(raw: string): DirectCandidateMessage | undefined { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return undefined + } + return normalizeDirectMessage(parsed) +} + +/** 与 {@link parseDirectMessage} 同一口径,但收**已解析**的对象(信封解出来之后的入口)。 */ +export function normalizeDirectMessage(parsed: unknown): DirectCandidateMessage | undefined { + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined + const r = parsed as Record + if (r.kind !== DIRECT_MESSAGE_KIND) return undefined + const hostId = typeof r.hostId === 'string' ? r.hostId : '' + const network = typeof r.network === 'string' ? r.network : '' + if (!isHostId(hostId) || !isNetworkId(network)) return undefined + if (!Array.isArray(r.addrs) || r.addrs.length === 0) return undefined + const addrs: DirectAddress[] = [] + for (const item of r.addrs) { + if (item === null || typeof item !== 'object') return undefined + const a = item as Record + if (typeof a.host !== 'string' || typeof a.port !== 'number') return undefined + addrs.push({ host: a.host, port: a.port }) + } + const ts = typeof r.ts === 'number' && Number.isFinite(r.ts) ? r.ts : 0 + return { kind: DIRECT_MESSAGE_KIND, hostId, network, addrs, ts } +} + +/** + * 编码一条候选消息(**唯一构造点**)。 + * + * ⛔ 先过一遍 {@link findSecretField}:构造侧就不许把身份材料塞进来(失败早于上线)。 + */ +export function encodeDirectMessage(msg: { + hostId: string + network: string + addrs: DirectAddress[] + ts?: number +}): string { + const out: DirectCandidateMessage = { + kind: DIRECT_MESSAGE_KIND, + hostId: msg.hostId, + network: msg.network, + addrs: msg.addrs.map((a) => ({ host: a.host, port: a.port })), + ts: msg.ts ?? Date.now(), + } + const secret = findSecretField(out) + if (secret !== undefined) throw new Error(`候选载荷里出现疑似凭据字段 ${secret}(⛔ 候选只含地址与端口)`) + const back = normalizeDirectMessage(out) + if (back === undefined) throw new Error('候选消息形状非法(⛔ 不许把坏形状编出去)') + return JSON.stringify(out) +} + +/** 一条候选的完整上下文(判定所需的一切**都是显式传入的** ⇒ 单测无需 mock 判据)。 */ +export interface CandidateContext { + /** 归一化后的拨号方白名单(`network → hostId 集合`)—— 与 `server.ts` 的 `dialers` **同型同源**。 */ + dialers: ReadonlyMap> + /** 收到这条消息的会话所属的网 + 对端 hostId(由 relay 侧鉴权后的会话表给出,⛔ 不信载荷)。 */ + from: { network: string; hostId: string } + /** 本机在这张网里的 hostId。 */ + selfHostId: string + /** 地址条数上限(缺省 {@link DIRECT_CAND_MAX_ADDRS},权威值在参数表)。 */ + maxAddrs?: number + /** 新鲜期(缺省 {@link DIRECT_CAND_TTL_MS})。 */ + ttlMs?: number + now?: number +} + +/** + * **准入判定**(判据 `D1`)。 + * + * 顺序刻意如此(**先安全后形状**会掩盖错配:"跨网"的证据在 `from.network` 与载荷的对比里): + * ① 形状(`bad-shape` / `secret-field`) + * ② 网一致(`cross-network`)—— 🔴 安全:⛔ 绝不让一张网里的会话申报另一张网的落点 + * ③ **只准申报自己**(`not-self-candidate`)—— 🔴 安全:⛔ 不许替第三人申报地址 + * ④ 双向白名单(`not-a-dialer` / `self-not-a-dialer`)—— **复用** `isAllowedDialer` + * ⑤ 地址形状与条数(`bad-address` / `too-many-addrs`) + * ⑥ 新鲜期(`stale`) + */ +export function admitCandidate(raw: string, ctx: CandidateContext): CandidateVerdict { + const maxAddrs = ctx.maxAddrs ?? DIRECT_CAND_MAX_ADDRS + const ttlMs = ctx.ttlMs ?? DIRECT_CAND_TTL_MS + const now = ctx.now ?? Date.now() + + // ① 形状(含凭据字段扫描 —— 扫描在**归一之前**,坏形状也不能夹带) + let json: unknown + try { + json = JSON.parse(raw) + } catch (err) { + return { ok: false, reason: 'bad-shape', detail: `JSON 解析失败:${err instanceof Error ? err.message : String(err)}` } + } + const secret = findSecretField(json) + if (secret !== undefined) { + return { ok: false, reason: 'secret-field', detail: `载荷含疑似凭据字段 ${secret}(⛔ 候选只准含地址与端口)` } + } + const msg = normalizeDirectMessage(json) + if (msg === undefined) { + return { ok: false, reason: 'bad-shape', detail: `不是 ${DIRECT_MESSAGE_KIND} 形态(须含 hostId / network / addrs[非空]{host,port})` } + } + + // ② 网一致(跨网 ⇒ 结构性拒绝) + if (msg.network !== ctx.from.network) { + return { + ok: false, + reason: 'cross-network', + detail: `载荷申报的网 "${msg.network}" ≠ 会话所属的网 "${ctx.from.network}"`, + } + } + + // ③ 只准申报自己(⛔ 替第三人申报 = 冒名,会变成"借别人的手把流量引到目标") + if (msg.hostId !== ctx.from.hostId) { + return { + ok: false, + reason: 'not-self-candidate', + detail: `载荷申报 ${logicalName(msg.network, msg.hostId)},而会话持有的身份是 ${logicalName(ctx.from.network, ctx.from.hostId)}(⛔ 只准申报自己)`, + } + } + + // ④ 双向白名单(**复用** network.ts#isAllowedDialer —— ⛔ 不另写一份) + if (!isAllowedDialer(ctx.dialers, ctx.from.network, ctx.from.hostId)) { + return { + ok: false, + reason: 'not-a-dialer', + detail: `对端 ${logicalName(ctx.from.network, ctx.from.hostId)} 不在网 "${ctx.from.network}" 的拨号方白名单里(默认拒绝)`, + } + } + if (!isAllowedDialer(ctx.dialers, ctx.from.network, ctx.selfHostId)) { + return { + ok: false, + reason: 'self-not-a-dialer', + detail: `本机 ${logicalName(ctx.from.network, ctx.selfHostId)} 不在网 "${ctx.from.network}" 的拨号方白名单里 ⇒ 建不成直连`, + } + } + + // ⑤ 地址形状与条数 + if (msg.addrs.length > maxAddrs) { + return { ok: false, reason: 'too-many-addrs', detail: `候选地址 ${msg.addrs.length} 条 > 上限 ${maxAddrs}` } + } + for (const a of msg.addrs) { + if (!isValidAddress(a)) { + return { ok: false, reason: 'bad-address', detail: `非法候选地址 ${JSON.stringify(a)}(须 IPv4/IPv6 字面量 + 端口 1–65535)` } + } + } + + // ⑥ 新鲜期(`ts = 0` ⇒ 未标注 ⇒ 不判 —— ⛔ 但也不当"新鲜"混过去:detail 里写明) + if (msg.ts > 0 && ttlMs > 0 && now - msg.ts > ttlMs) { + return { ok: false, reason: 'stale', detail: `候选已过期 ${now - msg.ts} ms > 新鲜期 ${ttlMs} ms` } + } + + return { ok: true, message: msg } +} + +/** 计数的快照(观测面与探针都取这一份)。 */ +export interface CandidateLedgerSnapshot { + /** 收到的消息总数(**进过判定**的)。 */ + received: number + accepted: number + rejected: { reason: CandidateReason; detail: string }[] + /** + * 🔴 **静默拒绝数**(本线老病根的可断言面)。 + * + * 恒等式 = `received - accepted - rejected.length`;**按构造它必须是 0** —— 它不是"统计", + * 而是一道**不变量守卫**:将来谁加了一条 `return {ok:false}` 却忘了记账,它立刻非零。 + * ⛔ 探针见到非零 ⇒ **FAIL 并点名**(静默返空 = 判据全绿而功能全废)。 + */ + silentRejections: number + /** 拒绝原因的分布(`reason → 条数`,便于一眼看出"全是同一类")。 */ + byReason: Record +} + +/** + * 候选收发的记账器。 + * + * ⚠️ **每条进路都必须记账**:`receive()` 先记"收到",之后要么 `accept()` 要么 `reject()`。 + * 判据看 {@link CandidateLedgerSnapshot.silentRejections} 是否为零。 + */ +export class CandidateLedger { + private received = 0 + private accepted = 0 + private rejected: { reason: CandidateReason; detail: string }[] = [] + + /** 收到一条(**必须在判定之前**调用)。 */ + receive(): void { + this.received += 1 + } + + /** 接受一条。 */ + accept(): void { + this.accepted += 1 + } + + /** 拒绝一条(**必须带原因**,⛔ 不许只计数不写原因)。 */ + reject(reason: CandidateReason, detail: string): void { + this.rejected.push({ reason, detail }) + } + + /** + * 判定 + 记账的**唯一入口**(调用方不需要记得先 `receive()`)。 + * + * ⇒ "忘了记账"这件事在**接口层**就已经不可能 —— 这是比"靠自觉"更强的形态。 + */ + judge(raw: string, ctx: CandidateContext): CandidateVerdict { + this.receive() + const verdict = admitCandidate(raw, ctx) + if (verdict.ok) this.accept() + else this.reject(verdict.reason, verdict.detail) + return verdict + } + + snapshot(): CandidateLedgerSnapshot { + const byReason: Record = {} + for (const r of this.rejected) byReason[r.reason] = (byReason[r.reason] ?? 0) + 1 + return { + received: this.received, + accepted: this.accepted, + rejected: this.rejected.map((r) => ({ ...r })), + silentRejections: this.received - this.accepted - this.rejected.length, + byReason, + } + } +} diff --git a/src/net/relay/direct/index.ts b/src/net/relay/direct/index.ts new file mode 100644 index 0000000..51cb499 --- /dev/null +++ b/src/net/relay/direct/index.ts @@ -0,0 +1,356 @@ +/** + * 覆盖网络 **直连(打洞)总装配**(序㊵ · P2 · S5)—— 开关 + 默认值 + 提示 + 降级编排。 + * + * ## 用户口径(原话,⛔ 别改写成技术题) + * ``` + * 1 用户可设置,默认开启提示用户 2 乙 + * ``` + * ⇒ 本模块是那三件事的**唯一落点**: + * + * | # | 口径 | 落在这里的什么 | + * |---|---|---| + * | ① | **用户可设置** | {@link resolveDirectSwitch}:env(运维强制)> 节点本地配置(用户设置)> **缺省**;{@link writeNodeConfigDirect} 是"用户改"的写入口 | + * | ② | **默认开启** | {@link DEFAULT_DIRECT_ENABLED} = `true`(⛔ 不是"缺省关、用户主动开") | + * | ③ | **提示用户** | {@link DIRECT_HINT_PARTS}(三段:**谁会连进来 / 怎么关 / 关掉不影响什么**) | + * + * ## 🔴 开关的三种状态,⛔ 没有第四种 + * `enabled: true` | `enabled: false` | `enabled: null`(**取值非法** —— ⛔ **不许**静默当开、 + * ⛔ 也不许静默当关;调用方必须**具名报错**)。本线所有"配置错长得像网络不通"的事故, + * 第一步都是"给个缺省值糊过去"。 + * + * ## 降级编排(关闭 / 打洞失败 ⇒ 全部回落中继) + * ⛔ **关闭不是功能降级**:P1 已交付的「一键加入 + 分组准入」照旧完整可用,只是跨机流量走中继。 + * 回落必须是**具名**的(`disabled` / `cooldown` / `no-address` / `one-way` / `deadline`), + * ⛔ 不许出现"没有原因地走了中继"。 + * + * @module dshs/net/relay/direct + */ + +import { + CandidateLedger, + DIRECT_CAND_MAX_ADDRS, + DIRECT_CAND_TTL_MS, + type CandidateContext, + type CandidateLedgerSnapshot, + type CandidateReason, + type CandidateVerdict, + type DirectAddress, +} from './candidate.js' +import { + DEFAULT_DIRECT_COOLDOWN_MS, + DEFAULT_PUNCH_DEADLINE_MS, + DirectCooldown, + PUNCH_PORT_BASE, + PUNCH_PORT_SPAN, + pickPunchPort, + runPunchAttempt, + type PunchAttempt, + type PunchReason, +} from './punch.js' + +/** 总开关的 env 键名(用户口径①)。⚠️ **缺省即可用**(缺省 = 开)⇒ 不写进任何 drop-in 也生效。 */ +export const DIRECT_ENV_KEY = 'DSHS_OVERLAY_DIRECT' + +/** 用户口径②:**默认开启**。⛔ 改它 = 改用户拍过的口径。 */ +export const DEFAULT_DIRECT_ENABLED = true + +/** 显式**开**的取值(大小写不敏感)。 */ +export const DIRECT_ON_VALUES = ['1', 'true', 'on', 'yes'] as const + +/** 显式**关**的取值(大小写不敏感)。 */ +export const DIRECT_OFF_VALUES = ['0', 'false', 'off', 'no'] as const + +/** 节点本地配置的缺省落点(与 `join.ts` 的 `--config` 缺省一致;⛔ 两处必须是同一个值)。 */ +export const NODE_CONFIG_FILE_DEFAULT = '/etc/dshs/overlay-node.json' + +/** 开关解析结果。 */ +export interface DirectSwitchState { + /** 键名(便于日志与观测面**原样**说出"是哪个键在起作用")。 */ + envKey: string + /** env 原文(未设 ⇒ `null`)。 */ + raw: string | null + /** 生效来源 —— ⛔ 三态必须能分开(否则"为什么它是开的"永远说不清)。 */ + source: 'env' | 'node-config' | 'default' + /** `null` = **取值非法**(⛔ 不许当开也不许当关)。 */ + enabled: boolean | null + /** 非法时的人读原因(`enabled !== null` 时为空串)。 */ + invalid: string + /** 缺省值(便于观测面自证"缺省 = 开")。 */ + defaultEnabled: boolean +} + +/** + * 解析总开关(**唯一解析点**)。 + * + * 优先级:env(显式设了就以它为准)> 节点本地配置的 `direct` > **缺省 = 开**。 + * ⚠️ 为什么 env 优先:它是**运维通道**("这台机器强制不直连"必须能压过用户设置); + * 用户改的是**本地配置**({@link writeNodeConfigDirect})—— 两条路各自有明确的主人。 + */ +export function resolveDirectSwitch( + env: Readonly>, + opts: { localDirect?: boolean; envKey?: string } = {}, +): DirectSwitchState { + const envKey = opts.envKey ?? DIRECT_ENV_KEY + const defaultEnabled = DEFAULT_DIRECT_ENABLED + const raw = env[envKey] + const base = { envKey, defaultEnabled } + if (raw !== undefined && raw.trim() !== '') { + const v = raw.trim().toLowerCase() + if ((DIRECT_ON_VALUES as readonly string[]).includes(v)) { + return { ...base, raw: raw.trim(), source: 'env', enabled: true, invalid: '' } + } + if ((DIRECT_OFF_VALUES as readonly string[]).includes(v)) { + return { ...base, raw: raw.trim(), source: 'env', enabled: false, invalid: '' } + } + return { + ...base, + raw: raw.trim(), + source: 'env', + enabled: null, + invalid: `${envKey}=${JSON.stringify(raw.trim())} 既不在开集 ${DIRECT_ON_VALUES.join('/')} 也不在关集 ${DIRECT_OFF_VALUES.join('/')} ⇒ ⛔ 不静默取缺省`, + } + } + if (typeof opts.localDirect === 'boolean') { + return { ...base, raw: null, source: 'node-config', enabled: opts.localDirect, invalid: '' } + } + return { ...base, raw: null, source: 'default', enabled: defaultEnabled, invalid: '' } +} + +/** 提示文案的三段(**顺序即语义**,⛔ 别合并成一段 —— 探针按段数判)。 */ +export const DIRECT_HINT_PARTS = [ + `① 开启后,**谁**可能直连到这台机器:只有**同一张覆盖网里、且双向都在拨号白名单**里的节点;⛔ 公网任意源、⛔ 跨网节点都连不进来。`, + `② 怎么关:给本机设 ${DIRECT_ENV_KEY}=0(或 false/off/no)后重启节点服务;也可以在管理面把本机配置里的 direct 改成 false。关闭后**不再绑定任何 UDP 端口**、也**不再发送直连候选**。`, + `③ 关掉会影响什么:⛔ 不影响接入与准入 —— 一键加入、分组白名单、跨机取块**照旧可用**,跨机流量改走中继;代价只是"多一跳中继"(延迟与中继带宽略升)。`, +] as const + +/** 三段提示(数组形态,便于管理面渲染)。 */ +export function directHintLines(): string[] { + return [...DIRECT_HINT_PARTS] +} + +/** 三段提示(一行文本形态,供 CLI `stdout` 打印)。 */ +export function directHintText(): string { + return DIRECT_HINT_PARTS.join('\n') +} + +/** 降级/拒绝原因的**统一口径**(⛔ 不许出现"没有原因地走了中继")。 */ +export type DirectRefusal = 'disabled' | 'invalid-switch' | PunchReason | CandidateReason + +/** 直连总装(**只读观测面 + 编排**;⛔ 它不做传输、不做接线 —— 那是 S6)。 */ +export class DirectPath { + readonly ledger = new CandidateLedger() + readonly cooldown: DirectCooldown + private readonly switchState: DirectSwitchState + private readonly portBase: number + private readonly portSpan: number + private readonly deadlineMs: number + private readonly maxAddrs: number + private readonly ttlMs: number + private udpSocketsOpened = 0 + private candidatesEmitted = 0 + private punchOk = 0 + private punchDead = 0 + private last?: PunchAttempt + + constructor(opts: { + /** 开关解析结果(**必传** —— 避免"忘了解析就用上真值")。 */ + switchState: DirectSwitchState + cooldownMs?: number + portBase?: number + portSpan?: number + deadlineMs?: number + maxAddrs?: number + ttlMs?: number + }) { + this.switchState = opts.switchState + this.cooldown = new DirectCooldown(opts.cooldownMs ?? DEFAULT_DIRECT_COOLDOWN_MS) + this.portBase = opts.portBase ?? PUNCH_PORT_BASE + this.portSpan = opts.portSpan ?? PUNCH_PORT_SPAN + this.deadlineMs = opts.deadlineMs ?? DEFAULT_PUNCH_DEADLINE_MS + this.maxAddrs = opts.maxAddrs ?? DIRECT_CAND_MAX_ADDRS + this.ttlMs = opts.ttlMs ?? DIRECT_CAND_TTL_MS + } + + /** 生效值:`true` / `false` / `null`(非法)。⛔ 调用方**必须**处理 `null`。 */ + get enabled(): boolean | null { + return this.switchState.enabled + } + + /** 候选准入(**关闭 ⇒ 一条都不判、一条都不发**)。 */ + offerCandidate(raw: string, ctx: Omit): CandidateVerdict | { ok: false; reason: DirectRefusal; detail: string } { + if (this.switchState.enabled !== true) { + return { + ok: false, + reason: this.switchState.enabled === false ? 'disabled' : 'invalid-switch', + detail: + this.switchState.enabled === false + ? `${DIRECT_ENV_KEY} 关闭(来源 ${this.switchState.source})⇒ ⛔ 不发候选、⛔ 不开 UDP 口` + : this.switchState.invalid, + } + } + this.candidatesEmitted += 1 + return this.ledger.judge(raw, { ...ctx, maxAddrs: this.maxAddrs, ttlMs: this.ttlMs }) + } + + /** + * 尝试建立直连(**关闭 ⇒ 零 socket**)。 + * + * ⛔ 这里**只做探测**:成功与否都不改变"能不能用中继"这件事(回落是无条件的)。 + */ + async attempt( + peer: string, + targets: readonly DirectAddress[], + io: { sleep: (ms: number) => Promise }, + offset = 0, + ): Promise { + if (this.switchState.enabled !== true) { + return { + ok: false, + reason: this.switchState.enabled === false ? 'disabled' : 'invalid-switch', + detail: + this.switchState.enabled === false + ? `${DIRECT_ENV_KEY} 关闭(来源 ${this.switchState.source})⇒ ⛔ 不打洞、⛔ 零 UDP socket,直接用中继` + : this.switchState.invalid, + } + } + const r = await runPunchAttempt( + { + peer, + selfPort: pickPunchPort(this.portBase, this.portSpan, offset), + targets, + deadlineMs: this.deadlineMs, + cooldown: this.cooldown, + onSocketOpen: () => { + this.udpSocketsOpened += 1 + }, + }, + io, + ) + this.last = r + if (r.ok) this.punchOk += 1 + else if (r.reason !== 'cooldown' && r.reason !== 'no-address') this.punchDead += 1 + return r + } + + /** **只读观测面**(探针 / 管理面 / 日志共用这一份 —— ⛔ 别各写一套)。 */ + status(): { + envKey: string + enabled: boolean | null + defaultEnabled: boolean + source: DirectSwitchState['source'] + raw: string | null + invalid: string + hint: string[] + portBase: number + portSpan: number + deadlineMs: number + maxAddrs: number + cooldownMs: number + counters: { + udpSocketsOpened: number + candidatesEmitted: number + punchOk: number + punchDead: number + cooldownBlocked: number + candidateSilentRejections: number + } + ledger: CandidateLedgerSnapshot + last: PunchAttempt | null + } { + const snap = this.cooldown.snapshot() + const led = this.ledger.snapshot() + return { + envKey: this.switchState.envKey, + enabled: this.switchState.enabled, + defaultEnabled: this.switchState.defaultEnabled, + source: this.switchState.source, + raw: this.switchState.raw, + invalid: this.switchState.invalid, + hint: directHintLines(), + portBase: this.portBase, + portSpan: this.portSpan, + deadlineMs: this.deadlineMs, + maxAddrs: this.maxAddrs, + cooldownMs: snap.ms, + counters: { + udpSocketsOpened: this.udpSocketsOpened, + candidatesEmitted: this.candidatesEmitted, + punchOk: this.punchOk, + punchDead: this.punchDead, + cooldownBlocked: snap.blocked, + candidateSilentRejections: led.silentRejections, + }, + ledger: led, + last: this.last ?? null, + } + } +} + +/** 从节点本地配置里读 `direct`(`undefined` = 没写这一项 ⇒ 走缺省)。 */ +export function directFromNodeConfig(raw: unknown): boolean | undefined { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined + const v = (raw as Record).direct + return typeof v === 'boolean' ? v : undefined +} + +/** 读一读节点本地配置(**形状不对 ⇒ 抛**;文件不存在 ⇒ `undefined`)。 */ +export function readNodeConfig(file: string, io: { exists: (p: string) => boolean; read: (p: string) => string }): Record | undefined { + if (!io.exists(file)) return undefined + const parsed: unknown = JSON.parse(io.read(file)) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${file} 不是 JSON 对象(⛔ 不当作"没配置"静默放过)`) + } + return parsed as Record +} + +/** 写回结果(**失败必须具名** —— 与 `join.ts` 同一条纪律)。 */ +export type NodeConfigWriteOutcome = + | { ok: true; file: string; direct: boolean } + | { ok: false; reason: 'node-config-missing' | 'node-config-bad' | 'node-config-unwritable'; detail: string } + +/** + * 把 `direct` 写进节点本地配置(**用户口径①「用户可设置」的写入口**)。 + * + * 🔴 三条纪律: + * ① **只改 `direct` 一个字段**(其余字段原样保留 —— ⛔ 不做"顺手规范化"); + * ② **文件不存在 ⇒ 具名失败**(`node-config-missing`)—— ⛔ 不静默创建:本地配置的存在本身就意味着 + * "这台机器跑过 join",凭空造一个会让节点看起来"加入过"; + * ③ **原子写 + `0600`**(先写 `.tmp` 再 `rename`)—— 半截文件会让节点**起不来**。 + */ +export function writeNodeConfigDirect( + file: string, + enabled: boolean, + io: { + exists: (p: string) => boolean + read: (p: string) => string + write: (p: string, text: string, mode: number) => void + rename: (from: string, to: string) => void + }, +): NodeConfigWriteOutcome { + let current: Record | undefined + try { + current = readNodeConfig(file, io) + } catch (err) { + return { ok: false, reason: 'node-config-bad', detail: `${file}:${err instanceof Error ? err.message : String(err)}` } + } + if (current === undefined) { + return { + ok: false, + reason: 'node-config-missing', + detail: `${file} 不存在 ⇒ ⛔ 不凭空创建(本机还没跑过 join)`, + } + } + const next = { ...current, direct: enabled } + const tmp = `${file}.tmp` + try { + io.write(tmp, `${JSON.stringify(next, null, 2)}\n`, 0o600) + io.rename(tmp, file) + } catch (err) { + return { + ok: false, + reason: 'node-config-unwritable', + detail: `${file}:${err instanceof Error ? err.message : String(err)}`, + } + } + return { ok: true, file, direct: enabled } +} diff --git a/src/net/relay/direct/punch.ts b/src/net/relay/direct/punch.ts new file mode 100644 index 0000000..6f0fe36 --- /dev/null +++ b/src/net/relay/direct/punch.ts @@ -0,0 +1,586 @@ +/** + * 覆盖网络 **UDP 打洞探测**(序㊵ · P2 · S5)。 + * + * ## 它做什么(照抄成熟做法的最小集,⛔ 不发明) + * ① 本机绑一个 **UDP 口**(端口从**参数表区间**取:`PUNCH_PORT_BASE` + `PUNCH_PORT_SPAN`); + * ② **双方同时**向对方候选地址发包(间隔 {@link PUNCH_PROBE_INTERVAL_MS},持续到 deadline); + * ③ `PUNCH_DEADLINE_MS` 内**收到对方的包** ⇒ 该方向成立; + * ④ 🔴 **双向都成立**才判直连可用;否则 ⇒ **判死**(`one-way` / `deadline`); + * ⑤ 判死后:**立即降级中继** + 该候选进**冷却**(复用本线既有冷却纪律,⛔ 不自造一套)。 + * + * ## 🔴 三条纪律 + * | # | 纪律 | 为什么 | + * |---|---|---| + * | ① | 只用 Node 内建 `dgram`,⛔ **不引入新依赖** | 本仓既有口径:`addr-override.ts` 头注「本项目**不引入 `undici` / `ws`**」 | + * | ② | ⛔ **用户态**、⛔ 无内核驱动、⛔ 无虚拟网卡 | 参数表 §8-① 已把"虚拟网卡"收窄为**不做**;要打洞也只走用户态 UDP | + * | ③ | 冷却**必须非零**(`ms <= 0` ⇒ 构造即抛) | `COOLDOWN=0` ⇒ "判死"退化成**重试风暴**;本线已把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写成硬禁令 | + * + * ## 🧪 离线夹具(文件末段 · ⛔ 不进生产接线路径) + * 打洞的**成功路径**需要"两台机器 + 两个会做 NAT 映射的网络"。云上那条真机腿被**云安全组**挡着 + * (见 `交接单_覆盖网络直连与P2P_20260918.md` §2-3),⇒ 本模块自带一个 + * **NAT 模拟器**({@link SimulatedNat} + {@link runPunchPair}):它用**真的 `dgram` socket** + * 跑**同一份打洞代码**,只是把"两跳 NAT"用回环上的四个 socket + 两份映射表来表现。 + * ⇒ 机器断言证明的是"**这段打洞逻辑**在 NAT 穿透场景下成立",⛔ **不是**"公网一定能打洞"。 + * + * @module dshs/net/relay/direct/punch + */ + +import { createSocket, type Socket } from 'node:dgram' + +import { isIpv6, type DirectAddress } from './candidate.js' + +/** 打洞 socket 的**端口基址**(沿用序⑥ S4 观察器用过的 21100 段 ⇒ ⛔ 不新造魔数)。 */ +export const PUNCH_PORT_BASE = 21100 + +/** 打洞端口区间跨度 ⇒ `[21100, 21116)`。⚠️ **UDP** 口,与 `LISTEN_ALLOWED_RANGES`(TCP)**不同族**。 */ +export const PUNCH_PORT_SPAN = 16 + +/** 单次探测的**重发间隔**(双方同时发包,直到 deadline)。 */ +export const PUNCH_PROBE_INTERVAL_MS = 150 + +/** 收包窗口缺省值(真值在参数表 `PUNCH_DEADLINE_MS`)。 */ +export const DEFAULT_PUNCH_DEADLINE_MS = 3_000 + +/** 判死后的冷却缺省值(真值在参数表 `DIRECT_COOLDOWN_MS`)。🔴 **必须非零**。 */ +export const DEFAULT_DIRECT_COOLDOWN_MS = 300_000 + +/** 探测结论(**具名**,⛔ 不收自由文本)。 */ +export type PunchReason = + /** 双向都成立 ⇒ 直连可用。 */ + | 'ok' + /** 一个方向成立、另一个不成立 ⇒ **判死**(⛔ 单向不算直连)。 */ + | 'one-way' + /** 窗内一个包都没收到 ⇒ **判死**。 */ + | 'deadline' + /** 该对端在冷却里 ⇒ 本次**连 socket 都不开**。 */ + | 'cooldown' + /** 没有可用候选地址(⛔ 不是"打洞失败"⇒ ⛔ 不进冷却)。 */ + | 'no-address' + /** 绑定失败(具名带上原始错误)。 */ + | 'socket-error' + +/** 一次探测的完整读数(**成功与失败都出这一份** ⇒ 观测面不用猜)。 */ +export interface PunchAttempt { + /** 对端逻辑名(`/`,冷却按它做键)。 */ + peer: string + ok: boolean + reason: PunchReason + /** 本机收到的包数(**本方向**是否成立)。 */ + recvLocal: number + /** 对端是否收到我们的包(真机经 relay 通道回报;离线夹具由 NAT 模拟给出)。 */ + peerSeen: boolean + /** 🔴 双向都成立(`recvLocal > 0 && peerSeen`)—— **只有它为真才算直连可用**。 */ + bidirectional: boolean + sent: number + elapsedMs: number + at: number + /** 回包来源(`ip:port`,升序)—— **"谁回的"必须看得见**(真机腿靠它区分"对端回的"与"别的什么东西回的")。 */ + sources: string[] + /** 失败时的人读原因(⛔ 不许只回 `false`)。 */ + detail: string +} + +/** + * 候选地址的**冷却表**(判死后才写入)。 + * + * 🔴 `ms` **必须 > 0**:`0` 会让"判死"退化成"每次重试都真打一遍" = 重试风暴 + * (本线把 `RELAY_FAILOVER_COOLDOWN_MS=0` 列为硬禁令,同一条纪律在这里落地为**构造期断言**)。 + */ +export class DirectCooldown { + readonly ms: number + private until = new Map() + private blockedCount = 0 + + constructor(ms: number = DEFAULT_DIRECT_COOLDOWN_MS) { + if (!Number.isFinite(ms) || ms <= 0) { + throw new Error( + `直连冷却时长必须 > 0,收到 ${JSON.stringify(ms)}(🔴 0 ⇒ 判死退化成重试风暴,本线硬禁令)`, + ) + } + this.ms = Math.floor(ms) + } + + /** 该对端是否还在冷却里(**命中即计数** —— 观测面上要看得见"被挡了几次")。 */ + blocked(peer: string, now: number = Date.now()): boolean { + const until = this.until.get(peer) + if (until === undefined) return false + if (until <= now) { + this.until.delete(peer) + return false + } + this.blockedCount += 1 + return true + } + + /** 记一次判死 ⇒ 进入冷却。 */ + noteDead(peer: string, now: number = Date.now()): void { + this.until.set(peer, now + this.ms) + } + + /** 成功 ⇒ 撤销冷却(**只有成功才撤**,⛔ 别拿"尝试过"当成功)。 */ + clear(peer: string): void { + this.until.delete(peer) + } + + snapshot(now: number = Date.now()): { ms: number; blocked: number; cooling: string[] } { + const cooling: string[] = [] + for (const [peer, until] of this.until) if (until > now) cooling.push(peer) + return { ms: this.ms, blocked: this.blockedCount, cooling: cooling.sort() } + } +} + +/** 一个**已绑定的** UDP 探测口(收发包 + 计数)。⛔ 只做收发,判定在上层。 */ +export class PunchSocket { + private sock?: Socket + private packets = 0 + private readonly senders = new Set() + private lastError = '' + + /** 已成功绑定的端口(`0` = 尚未绑定)。 */ + port = 0 + + constructor(private readonly host = '0.0.0.0') {} + + /** 绑定(`port = 0` ⇒ 由内核分配;真机路径给参数表区间里的口)。 */ + open(port: number): Promise { + return new Promise((resolve, reject) => { + const family = isIpv6(this.host) ? 'udp6' : 'udp4' + const sock = createSocket({ type: family, reuseAddr: false }) + const fail = (err: Error): void => { + this.lastError = err.message + try { + sock.close() + } catch { + // 已经关掉了 ⇒ 无事可做(⛔ 但**不吞**:原始错误在 `lastError` 里) + } + reject(err) + } + sock.once('error', fail) + sock.on('message', (_msg, rinfo) => { + this.packets += 1 + this.senders.add(`${rinfo.address}:${rinfo.port}`) + }) + sock.bind({ port, address: this.host }, () => { + sock.off('error', fail) + // 绑定之后的错误(如 ICMP 端口不可达)只记账,⛔ 不让它把进程炸掉 + sock.on('error', (err) => { + this.lastError = err.message + }) + const addr = sock.address() + this.port = typeof addr === 'object' && addr !== null ? addr.port : 0 + this.sock = sock + resolve() + }) + }) + } + + /** 向若干候选地址各发一包。返回**实际发出**的包数。 */ + send(targets: readonly DirectAddress[], payload = 'dshs-punch'): number { + const sock = this.sock + if (sock === undefined) return 0 + let sent = 0 + for (const t of targets) { + try { + sock.send(Buffer.from(payload, 'utf8'), t.port, t.host) + sent += 1 + } catch (err) { + this.lastError = err instanceof Error ? err.message : String(err) + } + } + return sent + } + + /** 本机收到的包数(截至此刻)。 */ + received(): number { + return this.packets + } + + /** 收到过包的对端来源(`ip:port`,升序)。 */ + sources(): string[] { + return [...this.senders].sort() + } + + errorText(): string { + return this.lastError + } + + close(): void { + const sock = this.sock + this.sock = undefined + if (sock === undefined) return + try { + sock.close() + } catch { + // 关两次 ⇒ 忽略;本类是**唯一**关闭点,正常路径不会走到这 + } + } +} + +/** 探测入参。⛔ 所有阈值**都是显式传入的**(真值在参数表,模块里只有缺省)。 */ +export interface PunchOptions { + /** 对端逻辑名(冷却键)。 */ + peer: string + /** 本机要绑的 UDP 口(来自 {@link pickPunchPort};`0` = 内核分配,夹具用)。 */ + selfPort: number + /** 对端候选地址(**打洞的目标**)。 */ + targets: readonly DirectAddress[] + deadlineMs?: number + probeIntervalMs?: number + /** 冷却表(**必传** —— 冷却纪律是判死的一部分,⛔ 不许可选)。 */ + cooldown: DirectCooldown + /** + * 对端是否收到我们的包(真机 = 经 relay 通道回报;离线夹具 = NAT 模拟器给出)。 + * ⛔ 缺省 = `() => false`(= 只看见自己的方向 ⇒ 判 `one-way`)。**刻意不给"乐观缺省"**: + * 拿不到对端证据时**判不上直连**,而不是"先当成功"。 + */ + peerSeen?: () => boolean + /** 本机绑定地址(缺省 `0.0.0.0`;离线夹具用 `127.0.0.1`)。 */ + bindHost?: string + now?: number + /** 观测钩子:每绑一次 UDP socket 调一次(探针的"关闭 ⇒ 零 socket"靠它计数)。 */ + onSocketOpen?: () => void + /** 测试钩子:拿到刚绑好的 socket(离线夹具用它读**对端**的实时收包数)。 */ + onSocket?: (sock: PunchSocket) => void +} + +/** + * 跑一次打洞探测(**唯一执行点**)。 + * + * 顺序:冷却闸门 → 候选闸门 → 绑口 → 重发到 deadline(**每轮都重判双向**)→ 判定 → 判死/撤冷却 + * → **必定关口**。⛔ 提前返回的两条路径(冷却 / 无候选)**一个 socket 都不开**。 + */ +export async function runPunchAttempt( + opts: PunchOptions, + io: { sleep: (ms: number) => Promise }, +): Promise { + const now = opts.now ?? Date.now() + const deadlineMs = opts.deadlineMs ?? DEFAULT_PUNCH_DEADLINE_MS + const interval = opts.probeIntervalMs ?? PUNCH_PROBE_INTERVAL_MS + const base: Omit = { + peer: opts.peer, + recvLocal: 0, + peerSeen: false, + bidirectional: false, + sent: 0, + elapsedMs: 0, + at: now, + sources: [], + } + + // ① 冷却闸门(⛔ 连 socket 都不开) + if (opts.cooldown.blocked(opts.peer, now)) { + return { ...base, ok: false, reason: 'cooldown', detail: `在冷却中(${opts.cooldown.ms} ms)⇒ 本次不打,沿用中继` } + } + // ② 候选闸门(⛔ 不是"打洞失败" ⇒ 不进冷却) + if (opts.targets.length === 0) { + return { ...base, ok: false, reason: 'no-address', detail: '没有可用候选地址 ⇒ 不打洞(⛔ 不进冷却)' } + } + + const sock = new PunchSocket(opts.bindHost ?? '0.0.0.0') + const started = Date.now() + try { + await sock.open(opts.selfPort) + opts.onSocketOpen?.() + opts.onSocket?.(sock) + } catch (err) { + return { + ...base, + ok: false, + reason: 'socket-error', + detail: `绑定 UDP ${opts.selfPort} 失败:${err instanceof Error ? err.message : String(err)}`, + elapsedMs: Date.now() - started, + } + } + + const peerSeenOf = opts.peerSeen ?? ((): boolean => false) + try { + // ③ 双方同时发包:按 interval 重发,每轮**重判双向**(任一方向一旦成立就可以停了) + let sent = 0 + const deadlineAt = started + deadlineMs + let recvLocal = 0 + let peerSeen = false + for (;;) { + sent += sock.send(opts.targets) + recvLocal = sock.received() + peerSeen = peerSeenOf() + if (recvLocal > 0 && peerSeen) break + const remain = deadlineAt - Date.now() + if (remain <= 0) break + await io.sleep(Math.min(interval, remain)) + } + recvLocal = sock.received() + peerSeen = peerSeenOf() + const bidirectional = recvLocal > 0 && peerSeen + const elapsedMs = Date.now() - started + if (bidirectional) { + opts.cooldown.clear(opts.peer) + return { + ...base, + ok: true, + reason: 'ok', + recvLocal, + peerSeen, + bidirectional: true, + sent, + elapsedMs, + sources: sock.sources(), + detail: `直连成立:本方向收 ${recvLocal} 包 + 对端确认收到 ⇒ 双向 ✅(耗时 ${elapsedMs} ms,发 ${sent} 包)`, + } + } + const reason: PunchReason = recvLocal === 0 && !peerSeen ? 'deadline' : 'one-way' + opts.cooldown.noteDead(opts.peer, started) + return { + ...base, + ok: false, + reason, + recvLocal, + peerSeen, + bidirectional: false, + sent, + elapsedMs, + sources: sock.sources(), + detail: + reason === 'one-way' + ? `单向(本方向收 ${recvLocal} 包 / 对端确认=${peerSeen})⇒ 判死 + 进冷却 ${opts.cooldown.ms} ms` + : `窗内零收包(deadline ${deadlineMs} ms,实耗 ${elapsedMs} ms,发 ${sent} 包)⇒ 判死 + 进冷却 ${opts.cooldown.ms} ms`, + } + } finally { + sock.close() + } +} + +/** + * 从参数表区间里挑一个口(**取模上扫**;真机路径用它给 `selfPort`)。 + * + * ⛔ 不写死单个端口:两台 worker 可能各自打洞,踩同一口会互相干扰; + * 区间由 `PUNCH_PORT_BASE` / `PUNCH_PORT_SPAN` 给出。 + */ +export function pickPunchPort(base: number = PUNCH_PORT_BASE, span: number = PUNCH_PORT_SPAN, offset = 0): number { + const n = Math.max(1, Math.floor(span)) + return base + (Math.abs(Math.floor(offset)) % n) +} + +/* ══════════════════════════════════════════════════════════════════════════════════════ + * 🧪 离线夹具(⛔ **不进生产接线路径**) + * + * 打洞的**成功路径**在云上被安全组挡着,所以这一段的用途只有一个: + * **用真的 `dgram` socket 跑同一份打洞代码**,把"两跳 NAT + 双方同时发包 ⇒ 打穿"这件事 + * 变成机器可断言的读数。⛔ 它**不改**打洞逻辑(`runPunchAttempt` 一字未动),只替换"网络长什么样"。 + * ══════════════════════════════════════════════════════════════════════════════════════ */ + +/** NAT 模拟器的形态开关(每个开关对应一条真实的失败模式)。 */ +export interface SimulatedNatOptions { + /** + * `true` ⇒ 即使有映射也**丢弃入向包**(模拟"对称 NAT / 单向不可达" ⇒ 只能出、不能进)。 + * 用于产出 `one-way` 这条负腿。 + */ + dropInbound?: boolean +} + +/** NAT 模拟器的计数(观测面用 —— ⛔ 只读)。 */ +export interface SimNatCounters { + fromNode: number + fromPeer: number + forwardedIn: number + droppedNoMapping: number + droppedByPolicy: number +} + +/** + * 一台**模拟 NAT**(= 两个回环 UDP socket + 一份"出过向才放行入向"的映射表)。 + * + * 语义(真 NAT 的**最小充分**语义): + * - 节点把包发给 {@link gateway}(内侧门牌 = `innerSocket` 的口); + * - NAT 见**内侧来的包** ⇒ 记映射({@link isMapped} 变真)+ 从 `outerSocket`(外侧门牌)转给对端 NAT; + * - NAT 收**对端 NAT 来的包** ⇒ **只有已建映射才**从内侧门牌交回节点(无映射 ⇒ **丢**,并计数)。 + * + * 🔴 `dropInbound` 只影响第三条(= 单向不可达)。 + */ +export class SimulatedNat { + private innerSock?: Socket + private outerSock?: Socket + private nodePort = 0 + private mapped = false + private readonly dropInbound: boolean + + readonly counters: SimNatCounters = { + fromNode: 0, + fromPeer: 0, + forwardedIn: 0, + droppedNoMapping: 0, + droppedByPolicy: 0, + } + + /** 对端 NAT 的**外侧门牌**(两侧都建好之后回填;真网里靠候选交换得到)。 */ + peerAddress?: DirectAddress + + constructor(opts: SimulatedNatOptions = {}) { + this.dropInbound = opts.dropInbound === true + } + + /** 绑定两个门牌并开始转发(返回后 {@link gateway} / {@link natAddress} 才是真值)。 */ + async open(): Promise { + this.outerSock = await this.bind() + this.innerSock = await this.bind() + this.outerSock.on('message', (msg, rinfo) => this.onOuter(msg, rinfo)) + this.innerSock.on('message', (msg, rinfo) => this.onInner(msg, rinfo)) + } + + private bind(): Promise { + return new Promise((resolve, reject) => { + const sock = createSocket({ type: 'udp4', reuseAddr: false }) + sock.once('error', reject) + sock.bind({ port: 0, address: '127.0.0.1' }, () => { + sock.off('error', reject) + sock.on('error', () => { + // 发送目标已关 ⇒ 夹具里的正常竞态,忽略 + }) + resolve(sock) + }) + }) + } + + private portOf(sock: Socket): number { + const addr = sock.address() + return typeof addr === 'object' && addr !== null ? addr.port : 0 + } + + /** 内侧来的包(= 节点要出去)⇒ 建映射 + 转给对端 NAT。 */ + private onInner(msg: Buffer, rinfo: { address: string; port: number }): void { + this.counters.fromNode += 1 + this.nodePort = rinfo.port + this.mapped = true + const peer = this.peerAddress + if (peer === undefined || this.outerSock === undefined) return + try { + this.outerSock.send(msg, peer.port, peer.host) + } catch { + // 对端已关 ⇒ 忽略 + } + } + + /** 外侧来的包(= 对端打过来的)⇒ 只有已建映射才放行入向。 */ + private onOuter(msg: Buffer, rinfo: { address: string; port: number }): void { + const peer = this.peerAddress + if (peer === undefined || rinfo.port !== peer.port) return + this.counters.fromPeer += 1 + if (!this.mapped) { + this.counters.droppedNoMapping += 1 + return + } + if (this.dropInbound) { + this.counters.droppedByPolicy += 1 + return + } + if (this.nodePort === 0 || this.innerSock === undefined) return + try { + this.innerSock.send(msg, this.nodePort, '127.0.0.1') + this.counters.forwardedIn += 1 + } catch { + // 节点已关 ⇒ 忽略 + } + } + + /** 节点侧要发往的地址(**内侧门牌** = 我的网关)。 */ + get gateway(): DirectAddress { + return { host: '127.0.0.1', port: this.innerSock === undefined ? 0 : this.portOf(this.innerSock) } + } + + /** 对外门牌(= **对端要打的目标**,也是本机"公网地址"的替身)。 */ + get natAddress(): DirectAddress { + return { host: '127.0.0.1', port: this.outerSock === undefined ? 0 : this.portOf(this.outerSock) } + } + + get isMapped(): boolean { + return this.mapped + } + + close(): void { + for (const sock of [this.innerSock, this.outerSock]) { + try { + sock?.close() + } catch { + // 已关 ⇒ 忽略 + } + } + this.innerSock = undefined + this.outerSock = undefined + } +} + +/** 一对打洞的读数(`bidirectional` = **两侧都成立** ⇒ 直连可用)。 */ +export interface PunchPairResult { + a: PunchAttempt + b: PunchAttempt + bidirectional: boolean + nat: { a: SimNatCounters; b: SimNatCounters } +} + +/** + * 跑一对打洞探测(**两侧并发**,各自走真实 {@link runPunchAttempt})。 + * + * - `aNat` / `bNat` 的 `dropInbound` 决定这是"能打穿"还是"单向不可达"; + * - 节点把包发给**自己的 NAT 网关**,目标是**对端 NAT 的外侧门牌**(= 真网里的"打公网门牌"); + * - `peerSeen` = **对端 socket 的实时收包数 > 0**(= "对端确认收到了我的包"这一事实的进程内等价物)。 + */ +export async function runPunchPair( + pair: { + aPeer: string + bPeer: string + deadlineMs?: number + cooldown?: number + /** 让哪一侧"只能出不能进"(`'a'` / `'b'` / `'both'` / `'none'`;缺省 `'none'`)。 */ + oneWay?: 'a' | 'b' | 'both' | 'none' + }, + io: { sleep: (ms: number) => Promise }, +): Promise { + const oneWay = pair.oneWay ?? 'none' + const natA = new SimulatedNat({ dropInbound: oneWay === 'a' || oneWay === 'both' }) + const natB = new SimulatedNat({ dropInbound: oneWay === 'b' || oneWay === 'both' }) + await natA.open() + await natB.open() + natA.peerAddress = natB.natAddress + natB.peerAddress = natA.natAddress + + const cooldownMs = pair.cooldown ?? DEFAULT_DIRECT_COOLDOWN_MS + const cdA = new DirectCooldown(cooldownMs) + const cdB = new DirectCooldown(cooldownMs) + const handles: { a?: PunchSocket; b?: PunchSocket } = {} + + const mk = ( + peer: string, + own: SimulatedNat, + other: 'a' | 'b', + self: 'a' | 'b', + cd: DirectCooldown, + ): Promise => + runPunchAttempt( + { + peer, + selfPort: 0, + // ⚠️ 目标 = **本机自己的 NAT 网关**(= 真网里"经我这条 NAT 把包发到对端公网门牌") + // 夹具里 NAT 知道对端是谁(`peerAddress`),所以目标写成网关即可; + // ⛔ 不能直接写对端 NAT 的口 —— 那样**绕过自己的 NAT** ⇒ 映射建不起来(第一版就这么错) + targets: [own.gateway], + deadlineMs: pair.deadlineMs, + cooldown: cd, + bindHost: '127.0.0.1', + peerSeen: () => (handles[other]?.received() ?? 0) > 0, + onSocket: (s) => { + handles[self] = s + }, + }, + io, + ) + + const [a, b] = await Promise.all([mk(pair.aPeer, natA, 'b', 'a', cdA), mk(pair.bPeer, natB, 'a', 'b', cdB)]) + const out: PunchPairResult = { + a, + b, + bidirectional: a.bidirectional && b.bidirectional, + nat: { a: natA.counters, b: natB.counters }, + } + natA.close() + natB.close() + return out +} diff --git a/src/net/relay/index.ts b/src/net/relay/index.ts index d9ffa95..88a0832 100644 --- a/src/net/relay/index.ts +++ b/src/net/relay/index.ts @@ -33,7 +33,8 @@ export { MuxDuplex } from './duplex.js' export type { MuxDuplexOptions } from './duplex.js' export { MUX, WS_CLOSE, acceptWebSocket, encodeMux, decodeMux, encodeJsonFrame, parseJsonPayload, WsConnection } from './wire.js' export type { MuxFrame, MuxType, WsServerOptions } from './wire.js' -export { OPS_NETWORK, NAME_SEP, assertNetworkId, assertSameNetwork, describeDialers, isHostId, isNetworkId, logicalName, normalizeDialers, parseLogicalName, sameNetwork } from './network.js' +export { OPS_NETWORK, NAME_SEP, assertNetworkId, assertSameNetwork, describeDialers, describeNetwork, isAllowedDialer, isHostId, isNetworkId, logicalName, networkKindOf, normalizeDialers, parseLogicalName, sameNetwork } from './network.js' +export type { NetworkKind } from './network.js' export { DIRECTORY_PATH, DIRECTORY_PAYLOAD_TAG, DIRECTORY_VERSION, DEFAULT_OVERLAY_SEED, buildDirectoryDocument, directoryPayload, directoryUrlFor, overlayEnvSeeds, overlayEnvTrustedKeys, parseDirectory, publicKeyFrom, publicRelayEntries, readCachedDirectory, resolveOverlayRelay, listOverlayRelayCandidates, signDirectory, toRelayUrl, verifyDirectory, writeCachedDirectory } from './directory.js' export type { CachedDirectory, DirectoryVerdict, OverlayAddressSource, OverlayDirectory, OverlayRelayCandidates, OverlayRelayResolution, ResolveOverlayRelayOptions } from './directory.js' export { keyEntryOf, loadKeysFile, parseKeysInline, normalizeKeyRecord, lookupKey, describeKeyEntry, assertKey } from './keys.js' @@ -155,3 +156,103 @@ export type { GroupKeyLoadResult, LoadGroupKeyOptions, } from './content/crypto.js' + +// ── 序㊱ · 「节点一键加入与分组准入」P1(S1–S4):网注册表 / 准入凭据 / 白名单派生 / join 编排 ── +export { + NETWORK_INVITE_TAG, + NODES_REGISTRY_VERSION, + networkInvitePayload, + parseNetworkInvite, + newInviteNonce, + verifyNetworkInvite, + noncePath, + consumeNonce, + isNonceConsumed, + emptyRegistry, + parseRegistry, + loadRegistry, + saveRegistry, + summarizeNetworks, + listNodes, + applyApplication, + approveNode, + removeNode, + deriveDialers, + auditDerivation, + deriveDropIn, + dropInPath, + describeRegistryLine, +} from './registry.js' +export type { + NetworkInvite, + InviteReason, + InviteVerdict, + NonceLedger, + NodeStatus, + NetworkNodeRecord, + NodesRegistry, + NetworkSummary, + DerivationAudit, + RegistryEdit, +} from './registry.js' +export { + JOIN_STEPS, + joinReasonOfInvite, + defaultHostId, + runJoin, + nodeJoinIo, + readSignedJson, + describeJoin, + canWrite, + maybeRead, +} from './join.js' +export type { JoinStep, JoinReason, StepReport, JoinOutcome, JoinIo, JoinOptions } from './join.js' +export { buildApplication, parseApplication, readApplicationFile } from './join.js' +export type { NodeApplication } from './join.js' + +// ── 序㊵ · P2/S5:直连候选交换 + 打洞探测(开关 / 默认值 / 提示 / 观测面)── +export { + DIRECT_MESSAGE_KIND, + DIRECT_CAND_MAX_ADDRS, + DIRECT_CAND_TTL_MS, + findSecretField, + isIpv4, + isIpv6, + isValidAddress, + parseDirectMessage, + normalizeDirectMessage, + encodeDirectMessage, + admitCandidate, + CandidateLedger, +} from './direct/candidate.js' +export type { DirectAddress, DirectCandidateMessage, CandidateReason, CandidateVerdict, CandidateContext, CandidateLedgerSnapshot } from './direct/candidate.js' +export { + PUNCH_PORT_BASE, + PUNCH_PORT_SPAN, + PUNCH_PROBE_INTERVAL_MS, + DEFAULT_PUNCH_DEADLINE_MS, + DEFAULT_DIRECT_COOLDOWN_MS, + DirectCooldown, + PunchSocket, + runPunchAttempt, + pickPunchPort, + SimulatedNat, + runPunchPair, +} from './direct/punch.js' +export type { PunchReason, PunchAttempt, PunchOptions, PunchPairResult, SimNatCounters, SimulatedNatOptions } from './direct/punch.js' +export { + DIRECT_ENV_KEY, + DEFAULT_DIRECT_ENABLED, + DIRECT_ON_VALUES, + DIRECT_OFF_VALUES, + NODE_CONFIG_FILE_DEFAULT, + resolveDirectSwitch, + DIRECT_HINT_PARTS, + directHintLines, + directHintText, + DirectPath, + directFromNodeConfig, + readNodeConfig, + writeNodeConfigDirect, +} from './direct/index.js' +export type { DirectSwitchState, DirectRefusal, NodeConfigWriteOutcome } from './direct/index.js' diff --git a/src/net/relay/join.ts b/src/net/relay/join.ts new file mode 100644 index 0000000..24bc76e --- /dev/null +++ b/src/net/relay/join.ts @@ -0,0 +1,434 @@ +/** + * 覆盖网络 **S3 · join 编排**(序㊱ · 「节点一键加入与分组准入」P1)。 + * + * ## 它解决的确切问题(缺口 ①:「一键加入」) + * 今天把一台机器加进覆盖网要**手工做四件事**: + * ① 装 relay / worker 单元 → ② 配节点密钥 → ③ drop-in 写 `DSHS_RELAY_DIALERS` → ④ DB 登记。 + * 全仓**无 join 类入口**(`grep` 实证)。⇒ 本模块把它编成**一条命令的四步**: + * + * | 步 | 做什么 | 失败后果(**必须具名**) | + * |---|---|---| + * | ① `verify-invite` | 校验邀请凭据(签名 + 网络绑定 + 有效期) | `invite-*` 五条具名原因 | + * | ② `node-key` | **本机**生成 / 复用节点密钥(**私钥不出机**,`0600`) | `node-key-unwritable` | + * | ③ `local-config` | 落地本机配置(`0600`) | `local-config-unwritable` | + * | ④ `register` | 向控制面提交申请(HTTP 或**落盘申请单**) | `register-*` 三条具名原因 | + * + * ## 🔴 一条不可退让的纪律:**⛔ 不许静默拒绝** + * 本线的病根反复是"配置错**长得像**网络不通"。⇒ 本模块**没有** `catch {}` 兜底: + * 每一步的失败都返回 `{ ok:false, step, reason, detail }`,`detail` 里带**原始**信息 + * (路径 / 原因码 / HTTP 码),调用方(CLI)负责把它**原样打到 stderr** 并 `exit 1`。 + * 判据:源码里**零**空 `catch` 块(`OBS-25` 的机器判据之一)。 + * + * ## ⛔ 本模块**不做**的事(故意) + * - ⛔ 不装服务单元、不写 drop-in、不 reload(那是控制面派生 + 运维动作,不是节点侧的事); + * - ⛔ 不落任何**密钥本体**到 stdout / 申请单(申请单里只有**公钥**); + * - ⛔ 不自己判"一次性" —— 一笔邀请是否已用过是**控制面台账**的事实(见 `registry.ts#consumeNonce`)。 + * + * @module dshs/net/relay/join + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +import { assertNetworkId, isHostId } from './network.js' +import { verifyNetworkInvite, type InviteReason } from './registry.js' +import { DEFAULT_DIRECT_ENABLED } from './direct/index.js' + +/** 四步(顺序即执行顺序;⛔ 改这个数组 = 改判据口径,`OBS-25` 会读到它)。 */ +export const JOIN_STEPS = ['verify-invite', 'node-key', 'local-config', 'register'] as const + +export type JoinStep = (typeof JOIN_STEPS)[number] + +/** + * 失败原因(**枚举**,⛔ 不收自由文本)—— 目的:让"哪一步的哪种错"可被机器判。 + * + * ⚠️ `invite-*` 五条与 `registry.ts#InviteReason` **一一对应**(不是另造一套口径): + * 映射表见 {@link joinReasonOfInvite}。 + */ +export type JoinReason = + | 'invite-missing' + | 'invite-bad-payload' + | 'invite-no-trusted-signer' + | 'invite-signature-mismatch' + | 'invite-expired' + | 'invite-network-mismatch' + | 'node-key-unwritable' + | 'local-config-unwritable' + | 'register-unreachable' + | 'register-rejected' + | 'register-malformed' + +/** 邀请验签原因 → join 原因(**唯一映射点**;⛔ 别在别处再写一遍)。 */ +export function joinReasonOfInvite(reason: InviteReason): JoinReason { + switch (reason) { + case 'bad-payload': + return 'invite-bad-payload' + case 'no-trusted-keys': + return 'invite-no-trusted-signer' + case 'network-mismatch': + return 'invite-network-mismatch' + case 'expired': + return 'invite-expired' + case 'not-yet-valid': + return 'invite-expired' + default: + return 'invite-signature-mismatch' + } +} + +/** 单步的执行记录(**成功与失败都记** —— 报告里要能看到"走到哪一步了")。 */ +export interface StepReport { + step: JoinStep + ok: boolean + /** 人读的一行;⛔ 不含任何密钥本体。 */ + detail: string +} + +export type JoinOutcome = + | { + ok: true + network: string + hostId: string + /** 节点**公钥**(hex)。 */ + nodeKey: string + /** 申请单落盘路径(`--out` 通道);HTTP 通道下为 `''`。 */ + applicationFile: string + /** 控制面返回(HTTP 通道)或 `''`。 */ + ack: string + steps: StepReport[] + } + | { ok: false; step: JoinStep; reason: JoinReason; detail: string; steps: StepReport[] } + +/** 外部世界的边界(**全部注入** ⇒ 单测可在临时目录里真跑,⛔ 不 mock 判据)。 */ +export interface JoinIo { + exists(path: string): boolean + read(path: string): string + /** 写文件;`mode` 由调用方给(密钥类 `0600`,公开类 `0644`)。 */ + write(path: string, text: string, mode: number): void + generateNodeKey(): { privateKeyPem: string; publicKey: string } + publicKeyOfPrivate(privateKeyPem: string): string + hostname(): string + /** 提交申请(HTTP 通道)。 */ + post( + url: string, + body: string, + ): Promise<{ status: number; body: string }> +} + +export interface JoinOptions { + /** 要加入的网(`ops` / `u:<租户>` / 显式命名 —— 合法形状由 `network.ts` 判)。 */ + network: string + hostId: string + /** 邀请凭据(`{doc, sig}` 的原始 JSON 值;解析与验签都在 `registry.ts`)。 */ + invite: { doc: unknown; sig: unknown } + /** 受信签名者公钥(hex)。**空 ⇒ 直接失败**(不可验 = 不接受)。 */ + trustedSigners: readonly string[] + /** 节点私钥落点(缺省 `/etc/dshs/node.key`)。 */ + nodeKeyFile: string + /** 本机配置落点(缺省 `/etc/dshs/overlay-node.json`)。 */ + localConfigFile: string + /** 申请单落盘路径(**离线通道**;与 `portalUrl` 二选一)。 */ + outFile?: string + /** 控制面入口(**HTTP 通道**;使用它需要控制面已开放接收入口)。 */ + portalUrl?: string + /** + * 🆕 序㊵(P2/S5):本机的**直连(打洞)开关**是否开启。 + * + * 缺省 = {@link DEFAULT_DIRECT_ENABLED}(**开** —— 用户 2026-09-18 12:22 原话「默认开启提示用户」)。 + * ⚠️ 它只写进**本机配置**(`direct` 字段)—— ⛔ 不写任何 drop-in / env: + * 节点的直连开关属于"跟着用户走"的那类数据(放实例 / 节点自己的配置),不属于控制面共享值。 + */ + direct?: boolean + now?: number +} + +/** 从主机名派一个合法 `hostId`(非法字符替换为 `-`;⛔ 不猜、不静默回落到 `localhost`)。 */ +export function defaultHostId(raw: string): string { + const v = raw.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 63) + return v === '' ? '' : v +} + +/** + * 跑完整 join 编排(四步)。 + * + * ⚠️ **顺序不可颠倒**:先验凭据(否则会为一张无效邀请生成无用的密钥),再生成密钥, + * 再落地本机配置,最后才向控制面提交(前一步失败 ⇒ **不产生下一步的副作用**)。 + */ +export async function runJoin(opts: JoinOptions, io: JoinIo): Promise { + const steps: StepReport[] = [] + const fail = (step: JoinStep, reason: JoinReason, detail: string): JoinOutcome => { + steps.push({ step, ok: false, detail: `${reason}:${detail}` }) + return { ok: false, step, reason, detail, steps } + } + + // ── 形状先判:网络 id / hostId 非法 ⇒ 立即具名失败(⛔ 不"兜个默认网")─────── + try { + assertNetworkId(opts.network, 'join 的目标网') + } catch (err) { + return fail('verify-invite', 'invite-network-mismatch', err instanceof Error ? err.message : String(err)) + } + if (!isHostId(opts.hostId)) { + return fail('verify-invite', 'invite-bad-payload', `hostId 非法:${JSON.stringify(opts.hostId)}`) + } + + // ── 步 ①:校验邀请凭据 ─────────────────────────────────────────────────── + if (opts.trustedSigners.length === 0) { + return fail('verify-invite', 'invite-no-trusted-signer', '未配置受信签名者(不可验 = 不接受,⛔ 不降级放行)') + } + if (opts.invite === undefined || opts.invite === null) { + return fail('verify-invite', 'invite-missing', '未提供邀请凭据(--invite)') + } + const verdict = verifyNetworkInvite(opts.invite.doc, opts.invite.sig, opts.trustedSigners, { + network: opts.network, + now: opts.now, + }) + if (!verdict.ok) { + return fail('verify-invite', joinReasonOfInvite(verdict.reason), `invite 验签失败:${verdict.reason}`) + } + steps.push({ + step: 'verify-invite', + ok: true, + detail: `network=${verdict.doc.network} nonce=${verdict.doc.nonce.slice(0, 8)}… 有效期至 ${verdict.doc.expiresAt || '(未设)'}`, + }) + + // ── 步 ②:节点密钥(**私钥不出机**;已存在 ⇒ 复用,重跑 join 不换钥匙)───── + let publicKey = '' + let firstTime = false + try { + if (io.exists(opts.nodeKeyFile)) { + publicKey = io.publicKeyOfPrivate(io.read(opts.nodeKeyFile)) + } else { + const key = io.generateNodeKey() + io.write(opts.nodeKeyFile, key.privateKeyPem, 0o600) + publicKey = key.publicKey + firstTime = true + } + } catch (err) { + return fail('node-key', 'node-key-unwritable', `${opts.nodeKeyFile}:${err instanceof Error ? err.message : String(err)}`) + } + if (publicKey === '') { + return fail('node-key', 'node-key-unwritable', `${opts.nodeKeyFile} 里推不出公钥(形状非法)`) + } + steps.push({ + step: 'node-key', + ok: true, + detail: `${firstTime ? '新生成' : '复用既有'}节点密钥 ${opts.nodeKeyFile}(0600)|公钥指纹 ${publicKey.slice(0, 16)}…`, + }) + + // ── 步 ③:本机配置(`0600`;⛔ 不含私钥本体,只记"私钥在哪")─────────────── + // 🆕 序㊵(P2/S5):多记一个 `direct` —— 本机**直连开关**(用户口径「默认开启提示用户」的落点)。 + const directEnabled = opts.direct ?? DEFAULT_DIRECT_ENABLED + const localConfig = { + version: 1, + network: verdict.doc.network, + hostId: opts.hostId, + nodeKeyFile: opts.nodeKeyFile, + nodeKey: publicKey, + invitedBy: verdict.doc.nonce, + joinedAt: new Date(opts.now ?? Date.now()).toISOString(), + /** 🆕 直连(打洞)开关 —— **用户可改**(管理面 / 手改本文件均可)。 */ + direct: directEnabled, + } + try { + io.write(opts.localConfigFile, `${JSON.stringify(localConfig, null, 2)}\n`, 0o600) + } catch (err) { + return fail( + 'local-config', + 'local-config-unwritable', + `${opts.localConfigFile}:${err instanceof Error ? err.message : String(err)}`, + ) + } + steps.push({ + step: 'local-config', + ok: true, + detail: `已落地 ${opts.localConfigFile}(0600)|直连(打洞)=${directEnabled ? '开(缺省)' : '关'}`, + }) + + // ── 步 ④:向控制面提交申请 ────────────────────────────────────────────── + // 🔴 **唯一的申请单构造点** —— 控制面 `apply` 用 `parseApplication` 读它(⛔ 别在这两处各写一份形状)。 + const application = buildApplication({ + hostId: opts.hostId, + network: verdict.doc.network, + // 只有公钥 —— 私钥永不出机、永不进这份申请单。 + nodeKey: publicKey, + appliedAt: new Date(opts.now ?? Date.now()).toISOString(), + invite: { doc: opts.invite.doc, sig: opts.invite.sig }, + }) + const payload = `${JSON.stringify(application, null, 2)}\n` + + if (typeof opts.portalUrl === 'string' && opts.portalUrl.trim() !== '') { + let res: { status: number; body: string } + try { + res = await io.post(opts.portalUrl.trim(), payload) + } catch (err) { + return fail( + 'register', + 'register-unreachable', + `${opts.portalUrl}:${err instanceof Error ? err.message : String(err)}`, + ) + } + if (res.status < 200 || res.status >= 300) { + // ⚠️ 控制面拒绝时必须把**它对外的原因码**带回来 —— ⛔ 不许把 4xx 说成"网络不通"。 + return fail('register', 'register-rejected', `HTTP ${res.status}:${res.body.slice(0, 200)}`) + } + steps.push({ step: 'register', ok: true, detail: `控制面已受理(HTTP ${res.status})` }) + return { + ok: true, + network: verdict.doc.network, + hostId: opts.hostId, + nodeKey: publicKey, + applicationFile: '', + ack: res.body.slice(0, 500), + steps, + } + } + + const outFile = opts.outFile ?? '' + if (outFile === '') { + return fail( + 'register', + 'register-malformed', + '既未给 --portal(HTTP 通道)也未给 --out(离线申请单通道)⇒ ⛔ 不静默成功', + ) + } + try { + mkdirSync(dirname(outFile), { recursive: true }) + // `0644`:申请单里只有**公钥**与签名,本就是给控制面看的公开物。 + writeFileSync(outFile, payload, { mode: 0o644 }) + } catch (err) { + return fail('register', 'register-malformed', `${outFile}:${err instanceof Error ? err.message : String(err)}`) + } + steps.push({ step: 'register', ok: true, detail: `申请单已落盘 ${outFile}(0644,只有公钥)` }) + return { + ok: true, + network: verdict.doc.network, + hostId: opts.hostId, + nodeKey: publicKey, + applicationFile: outFile, + ack: '', + steps, + } +} + +/** **Node 侧**的实现(真的读写文件系统、真的发 HTTP)。⛔ 判据不在这里。 */ +export function nodeJoinIo(deps: { + fss: { + existsSync: (p: string) => boolean + readFileSync: (p: string, enc: 'utf8') => string + writeFileSync: (p: string, data: string, o: { mode: number }) => void + mkdirSync: (p: string, o: { recursive: boolean }) => void + } + crypto: { generateNodeKey: () => { privateKeyPem: string; publicKey: string }; publicKeyOfPrivate: (pem: string) => string } + os: { hostname: () => string } + fetchImpl: (url: string, body: string) => Promise<{ status: number; body: string }> +}): JoinIo { + return { + exists: (p) => deps.fss.existsSync(p), + read: (p) => deps.fss.readFileSync(p, 'utf8'), + write: (p, text, mode) => { + deps.fss.mkdirSync(dirname(p), { recursive: true }) + deps.fss.writeFileSync(p, text, { mode }) + }, + generateNodeKey: () => deps.crypto.generateNodeKey(), + publicKeyOfPrivate: (pem) => deps.crypto.publicKeyOfPrivate(pem), + hostname: () => deps.os.hostname(), + post: (url, body) => deps.fetchImpl(url, body), + } +} + +/** 读一份邀请 / 申请单文件(`{doc, sig}` 形态;⛔ 形状不对 ⇒ **抛**,不返回 `undefined`)。 */ +export function readSignedJson(file: string): { doc: unknown; sig: unknown } { + const raw: unknown = JSON.parse(readFileSync(file, 'utf8')) + if (raw === null || typeof raw !== 'object') throw new Error(`${file} 不是 {doc, sig} 形态的 JSON`) + const r = raw as Record + if (r.doc === undefined || r.sig === undefined) throw new Error(`${file} 缺 doc / sig 字段`) + return { doc: r.doc, sig: r.sig } +} + +/** + * **join 申请单**(`register` 步产出的那份东西)。 + * + * 🔴 **存在的理由(真机首轮实测踩到)**:`join` 写出的是**申请单**(`hostId` / `network` / `nodeKey` + * / 内嵌的 `invite:{doc,sig}`),而控制面 `apply` 一开始按"裸 `{doc, sig}`"去读 ⇒ **读不出来** + * (`✗ … 缺 doc / sig 字段`)。两个形状**必须由同一处定义**、并由同一个解析函数读 —— + * 否则 S3 的产物与 S2 的输入会各自演进、在真机上才暴露(本线"同一事实两处写"的又一例)。 + * + * ⛔ **载荷内只有公钥** —— 私钥永不出机、永不进这份申请单。 + */ +export interface NodeApplication { + version: number + hostId: string + network: string + /** 节点**公钥**(hex)。 */ + nodeKey: string + appliedAt: string + /** 内嵌的邀请凭据(**验签与一次性都由控制面判**)。 */ + invite: { doc: unknown; sig: unknown } +} + +/** 构造一份申请单(**唯一构造点** —— `runJoin` 与夹具都走它)。 */ +export function buildApplication(a: { + hostId: string + network: string + nodeKey: string + appliedAt: string + invite: { doc: unknown; sig: unknown } +}): NodeApplication { + return { + version: 1, + hostId: a.hostId, + network: a.network, + nodeKey: a.nodeKey, + appliedAt: a.appliedAt, + invite: { doc: a.invite.doc, sig: a.invite.sig }, + } +} + +/** 解析申请单(**严格**:字段不全 / 类型不对 ⇒ `undefined`,⛔ 不猜、不补默认值)。 */ +export function parseApplication(raw: unknown): NodeApplication | undefined { + if (raw === undefined || raw === null || typeof raw !== 'object') return undefined + const r = raw as Record + const hostId = typeof r.hostId === 'string' ? r.hostId.trim() : '' + const network = typeof r.network === 'string' ? r.network.trim() : '' + const nodeKey = typeof r.nodeKey === 'string' ? r.nodeKey.trim().toLowerCase() : '' + const appliedAt = typeof r.appliedAt === 'string' ? r.appliedAt.trim() : '' + if (!isHostId(hostId) || network === '' || !/^[0-9a-f]{64}$/.test(nodeKey) || appliedAt === '') return undefined + const inv = r.invite + if (inv === null || typeof inv !== 'object') return undefined + const i = inv as Record + if (i.doc === undefined || i.sig === undefined) return undefined + const version = typeof r.version === 'number' ? r.version : 1 + return { version, hostId, network, nodeKey, appliedAt, invite: { doc: i.doc, sig: i.sig } } +} + +/** 读一份申请单文件;形状不对 ⇒ **抛**(调用方负责转成具名失败,⛔ 不静默回落)。 */ +export function readApplicationFile(file: string): NodeApplication { + const parsed = parseApplication(JSON.parse(readFileSync(file, 'utf8'))) + if (parsed === undefined) { + throw new Error(`${file} 不是合法的**申请单**(须含 hostId / network / nodeKey(64hex) / appliedAt / invite.doc / invite.sig)`) + } + return parsed +} + +/** 一行摘要(给 CLI 收尾打印)。 */ +export function describeJoin(outcome: JoinOutcome): string { + if (outcome.ok) { + return `✓ join 成功:${outcome.network}/${outcome.hostId}|公钥 ${outcome.nodeKey.slice(0, 16)}…|四步 ${outcome.steps.length}/4` + } + return `✗ join 失败于「${outcome.step}」:${outcome.reason} — ${outcome.detail}` +} + +/** 供 CLI 判断"这个路径能不能写"(⛔ 不真写,避免试错产生半成品)。 */ +export function canWrite(file: string): boolean { + try { + mkdirSync(dirname(file), { recursive: true }) + return true + } catch { + return false + } +} + +/** 兜底:确无该文件时返回 `undefined`(**只用于可选输入**,⛔ 不用于密钥)。 */ +export function maybeRead(file: string): string | undefined { + return existsSync(file) ? readFileSync(file, 'utf8') : undefined +} diff --git a/src/net/relay/network.ts b/src/net/relay/network.ts index 6302c92..04a4b7e 100644 --- a/src/net/relay/network.ts +++ b/src/net/relay/network.ts @@ -74,6 +74,46 @@ export function assertNetworkId(raw: string, what = 'network id'): string { return v } +/** + * 网络 id 的**类别**(`isNetworkId` 那三条分支的**具名**形态)。 + * + * ## 为什么要有它(序㊱ · 「节点一键加入与分组准入」S1) + * 管理面要回答「**有哪些网**、每张是什么性质」—— 只回一个 `true/false` 不够:分组准入的 + * **默认分组**、**能否与 `ops` 共享骨干**、**是否允许显式命名**,三处判据都要这个类别。 + * + * ⛔ **纯函数、无 IO、不改既有语义** —— `isNetworkId` 仍是唯一的合法性判据,本函数只是 + * 把它的三条分支**具名化**(`kind !== undefined` ⟺ `isNetworkId(raw) === true`)。 + * + * | 类别 | 形态 | 含义 | + * |---|---|---| + * | `ops` | 固定值 | 平台自己的机器(中继 / 骨干 / Worker) | + * | `tenant` | `u:<租户>` | 该用户名下的全部设备(含桌面客户端) | + * | `named` | `[a-z0-9][a-z0-9_.-]*` | **二级网 / 测试网 / 独立覆盖网络** —— 新增一张网**零代码** | + */ +export type NetworkKind = 'ops' | 'tenant' | 'named' + +export function networkKindOf(raw: string): NetworkKind | undefined { + const v = raw.trim() + if (!isNetworkId(v)) return undefined + if (v === OPS_NETWORK) return 'ops' + if (v.startsWith('u:')) return 'tenant' + return 'named' +} + +/** + * 网名的**规范展示名**(管理面 / CLI 输出用)—— `ops` 后面跟着它是什么。 + * + * ⛔ 只用于**人读的输出**(日志 / `list` 表)。**判据一律用 `networkKindOf`** —— + * 拿展示名去做字符串匹配 = 把"好看的输出"变成判据(本线反复要根治的那类病)。 + */ +export function describeNetwork(raw: string): string { + const kind = networkKindOf(raw) + if (kind === undefined) return `${raw}(⛔ 非法网名)` + if (kind === 'ops') return `${raw}(运维网 · 平台自己的机器)` + if (kind === 'tenant') return `${raw}(租户网)` + return `${raw}(显式命名网 · 可作独立覆盖网络)` +} + /** 节点逻辑名 = `/`。**唯一拼法**(别在调用方拼)。 */ export function logicalName(network: string, hostId: string): string { return `${network}${NAME_SEP}${hostId}` @@ -192,6 +232,32 @@ export function normalizeDialers( return out } +/** + * **白名单准入的唯一定义**(序㊵ · P2/S5)—— `true` = 该 `hostId` 在 `network` 这张网里可拨。 + * + * ## 为什么必须单列成一个函数(⛔ 不是为了好看) + * 在它之前,「不在白名单就拒」这条策略**只有 `server.ts` 里三处内联写法**: + * `this.dialers.get(network)?.has(hostId) === true`(注册闸门 `:1182`)、 + * `this.dialers.get(session.network)?.has(session.hostId) !== true`(`DIAL` 闸门 `:1491`)。 + * 直连(P2)要**再判一次同一条策略**(候选交换/打洞都只准发生在"同网 + 白名单内"), + * 若直连模块**自己再写一份**,就正好撞上本线反复吃过的病根:**同一事实两处写** ⇒ + * 两处有一天会分叉(`server.ts` 那两处是**冻结的只读面**,不许改也不许被复制)。 + * + * ⇒ 本函数 = **该策略的唯一可复用出口**;`server.ts` 的内联写法与它**逐字等价** + * (同 map 、同 `=== true` 默认拒绝语义、同"按网络分桶")。等价性由 + * `test/overlay-direct.test.mjs#T4` 做**机器守卫**(读 `server.ts` 源码核对那两处的原文形态)。 + * + * ⚠️ 语义上 `network` 是**桶键**:拿别的网的桶去查同名 hostId 只会得到 `false` + * (这正是 P0-1「跨网结构性隔离」—— ⛔ 不是"策略允许/不允许"的区别)。 + */ +export function isAllowedDialer( + map: ReadonlyMap>, + network: string, + hostId: string, +): boolean { + return map.get(network)?.has(hostId) === true +} + /** * 类型判别:`Map` 形态 vs 扁平 `Set` 形态。 * diff --git a/src/net/relay/registry.ts b/src/net/relay/registry.ts new file mode 100644 index 0000000..da1c573 --- /dev/null +++ b/src/net/relay/registry.ts @@ -0,0 +1,503 @@ +/** + * 覆盖网络 **S1+S2+S4 · 网注册表 + 准入凭据 + 白名单派生**(序㊱ · 「节点一键加入与分组准入」P1)。 + * + * ## 它解决的确切问题(缺口 ②:「分组准入门面」) + * `server.ts` 的拨号方白名单**机制已经完整**(`dialers: Map>`、默认拒绝、 + * 跨网在"能不能拨"这一步就走不到 —— 结构性隔离)。缺的是**把它变成可管理的东西**: + * 今天配置靠**手写字符串**(`DSHS_RELAY_DIALERS="u:5:manager"`),写错了长得像"这张网不存在"。 + * + * ⇒ 本模块把「**哪些网、每张网有哪些节点、谁已批准**」变成**一份注册表**, + * 并**派生**出 relay 侧的白名单(`DSHS_RELAY_DIALERS`)。 + * + * ## 三条硬约束 + * 1. **控制面是权威单点** —— 注册表只有控制面写(用户既有口径:「归属 / 租约 / 骨干资格只能控制面写」)。 + * 2. **手写 drop-in 退化为应急通道,⛔ 不删** —— 派生只是**多一条**产生白名单的路;控制面不可用 + * 时手写仍然照旧生效(`normalizeDialers` 同时接受扁平 `Set` 与分桶 `Map`)。 + * 3. **凭据载荷 ⛔ 不含密钥本体** —— 邀请凭据只回答「哪张网 + 有效期 + 一次性 nonce」, + * 与组密钥凭据同形(`content/crypto.ts` 的 `(network, group, epoch, keyId)`)。 + * + * ## ⛔ 本模块**不做**的事(故意) + * - 不碰 `server.ts` 的 DIAL / 白名单**语义**(只**读取**既有形状,派生出的仍是同一个 `Map` 形状); + * - 不开监听、不写 env、不 reload 任何单元(**派生是纯函数**,落盘与 reload 是调用方的事); + * - 不发明第二种签名 —— 一律走 `identity.ts#verifySignedPayload` / `signPayloadWith`。 + * + * @module dshs/net/relay/registry + */ + +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +import { OPS_NETWORK, assertNetworkId, isHostId, logicalName, networkKindOf } from './network.js' +import { verifySignedPayload, type IdentityReason } from './identity.js' + +// ── 邀请(准入)凭据 ───────────────────────────────────────────────────────── + +/** 载荷域分隔标签。⛔ **改它 = 令所有既有邀请失效**。 */ +export const NETWORK_INVITE_TAG = 'dshs-overlay-netinvite/v1' + +/** 注册表文件的结构版本。 */ +export const NODES_REGISTRY_VERSION = 1 + +/** 输入长度的上界(防"超长 hostId 撑爆注册表"这类形状攻击;⛔ 与 `HOST_RE` 同量级)。 */ +const NONCE_RE = /^[0-9a-f]{32}$/ +const HEX_PUB_RE = /^[0-9a-f]{64}$/ + +/** + * 一张网的**准入邀请凭据**。 + * + * 🔴 **载荷内不含任何密钥本体**(私钥 / 对称密钥都不在)—— 它只是"控制面允许某台机器 + * 以某张网的身份来申请"。**节点自己的密钥在节点上生成、私钥永不出机**(`join.ts` 第 ② 步)。 + */ +export interface NetworkInvite { + version: number + /** 该邀请**绑定的网** —— 拿它去申请别的网 ⇒ `network-mismatch`(具名拒绝)。 */ + network: string + /** **一次性** nonce(32 hex)。控制面在**收单时**占位,第二次用 ⇒ `invite-already-used`。 */ + nonce: string + issuedAt: string + /** **有效期**(ISO)。空串 = 不过期(⛔ 不推荐;控制面签发时缺省给 TTL)。 */ + expiresAt: string +} + +/** 邀请凭据的拒绝原因(**在 `IdentityReason` 之上再加两条注册表侧原因**)。 */ +export type InviteReason = IdentityReason | 'bad-payload' | 'invite-already-used' + +/** 邀请验签结论:**失败一律带具体原因**(⛔ 不许静默)。 */ +export type InviteVerdict = { ok: true; doc: NetworkInvite; payload: string } | { ok: false; reason: InviteReason } + +/** 规范拼接(字段顺序**写死**;⛔ 改顺序 = 令所有既有签名失效)。 */ +export function networkInvitePayload(doc: NetworkInvite): string { + return [ + NETWORK_INVITE_TAG, + `version=${String(doc.version)}`, + `network=${doc.network}`, + `nonce=${doc.nonce}`, + `issuedAt=${doc.issuedAt}`, + `expiresAt=${doc.expiresAt}`, + ].join('\n') +} + +/** 解析(**严格**:字段不全 / 类型不对 ⇒ `undefined`,⛔ 不猜、不补默认值)。 */ +export function parseNetworkInvite(raw: unknown): NetworkInvite | undefined { + if (raw === undefined || raw === null || typeof raw !== 'object') return undefined + const r = raw as Record + const network = typeof r.network === 'string' ? r.network.trim() : '' + const nonce = typeof r.nonce === 'string' ? r.nonce.trim().toLowerCase() : '' + const issuedAt = typeof r.issuedAt === 'string' ? r.issuedAt.trim() : '' + const expiresAt = typeof r.expiresAt === 'string' ? r.expiresAt.trim() : '' + if (network === '' || issuedAt === '') return undefined + if (networkKindOf(network) === undefined) return undefined + if (!NONCE_RE.test(nonce)) return undefined + const version = typeof r.version === 'number' ? r.version : NODES_REGISTRY_VERSION + return { version, network, nonce, issuedAt, expiresAt } +} + +/** + * 验一份邀请凭据 —— **四件套**:受信签名者 + 网络绑定 + 有效期 + 载荷形状。 + * + * ⚠️ **一次性不在这里判** —— 一笔邀请的"是否已被用过"是**控制面台账**的事实(跨进程共享), + * 不是签名能表达的东西。⇒ 由 {@link consumeNonce} 在**收单时**用原子占位判。 + * 把两件事分开,是为了让"凭据合法但已用过"与"凭据不合法"在排障时**可分**。 + */ +export function verifyNetworkInvite( + doc: unknown, + sig: unknown, + trustedSigners: readonly string[], + opts: { network?: string; now?: number } = {}, +): InviteVerdict { + const parsed = parseNetworkInvite(doc) + if (parsed === undefined) return { ok: false, reason: 'bad-payload' } + const payload = networkInvitePayload(parsed) + const verdict = verifySignedPayload(payload, sig, trustedSigners) + if (verdict !== 'ok') return { ok: false, reason: verdict } + if (opts.network !== undefined && parsed.network !== opts.network) { + return { ok: false, reason: 'network-mismatch' } + } + const now = opts.now ?? Date.now() + if (parsed.expiresAt !== '') { + const at = Date.parse(parsed.expiresAt) + if (!Number.isFinite(at)) return { ok: false, reason: 'bad-payload' } + if (now > at) return { ok: false, reason: 'expired' } + } + return { ok: true, doc: parsed, payload } +} + +/** 生成一个**一次性 nonce**(32 hex = 16 字节随机)。 */ +export function newInviteNonce(randomBytes: (n: number) => Buffer): string { + return randomBytes(16).toString('hex') +} + +// ── 一次性台账(**原子占位**,治"同一凭据被两台机器同时用")───────────────────── + +/** 台账入参:目录 + 时间源(注入 ⇒ 可单测,⛔ 不读全局时钟)。 */ +export interface NonceLedger { + dir: string + now?: () => number +} + +/** 单个 nonce 的台账文件路径(`/.used`)。 */ +export function noncePath(ledger: NonceLedger, nonce: string): string { + return `${ledger.dir}/${nonce}.used` +} + +/** + * 占位一个 nonce(**一次性**)。 + * + * 🔴 **做法 = `writeFileSync(..., { flag: 'wx' })`** —— `O_CREAT|O_EXCL` 是**内核原子**的: + * 两台机器**同时**拿同一张邀请来收单,**恰好一台**拿到 `ok`,另一台得到 `invite-already-used`。 + * ⛔ **不许**"先 `existsSync` 再写" —— 那是两次系统调用的竞态,正是本线「判据成立 ≠ 机制成立」 + * 的典型反例(单机测着对、并发下漏一个)。 + * + * ⚠️ 占位文件里写**收单侧可读的事实**(时间 + 用途),便于事后排障;⛔ 不写任何密钥 / 公钥。 + */ +export function consumeNonce( + ledger: NonceLedger, + nonce: string, + note: string, +): { ok: true; file: string } | { ok: false; reason: 'invite-already-used' } { + const n = nonce.trim().toLowerCase() + if (!NONCE_RE.test(n)) return { ok: false, reason: 'invite-already-used' } + mkdirSync(ledger.dir, { recursive: true }) + const file = noncePath(ledger, n) + const at = new Date(ledger.now?.() ?? Date.now()).toISOString() + try { + writeFileSync(file, `${at} ${note}\n`, { flag: 'wx', mode: 0o644 }) + } catch (err) { + const code = (err as { code?: string }).code + if (code === 'EEXIST') return { ok: false, reason: 'invite-already-used' } + throw err + } + return { ok: true, file } +} + +/** 只读:该 nonce 是否已被用过(**不占位**)。 */ +export function isNonceConsumed(ledger: NonceLedger, nonce: string): boolean { + const n = nonce.trim().toLowerCase() + return NONCE_RE.test(n) && existsSync(noncePath(ledger, n)) +} + +// ── 网注册表(S1 的数据模型)───────────────────────────────────────────────── + +/** + * 一个节点在注册表里的状态。 + * + * | 状态 | 含义 | 后果 | + * |---|---|---| + * | `pending` | **已申请、待批准** | ⛔ **不进白名单** ⇒ relay 侧拨不动(默认拒绝) | + * | `approved` | 已批准 | 进派生白名单 ⇒ 可接入 | + */ +export type NodeStatus = 'pending' | 'approved' + +/** 注册表里的一条节点记录(⛔ 只有**公钥**,没有私钥 / 没有密钥本体)。 */ +export interface NetworkNodeRecord { + network: string + hostId: string + /** 节点**公钥**(64 hex,Ed25519 裸公钥)。 */ + nodeKey: string + status: NodeStatus + /** 申请时刻(ISO)。 */ + appliedAt: string + /** 批准时刻(ISO);`''` = 未批准。 */ + approvedAt: string + /** 分组(`''` = 该网默认组)。⚠️ 分组只作**管理信息**,⛔ 不参与"能不能拨"的判据。 */ + group: string +} + +/** 注册表文件(键 = **逻辑名** `/`,与 `server.ts` 会话表同口径)。 */ +export interface NodesRegistry { + version: number + nodes: Record +} + +export function emptyRegistry(): NodesRegistry { + return { version: NODES_REGISTRY_VERSION, nodes: {} } +} + +/** + * 解析注册表(**严格**:形状不对 ⇒ `undefined`)。 + * + * ⛔ **不做"宽容修复"** —— 一条 `status` 拼错的记录如果被静默当成 `pending`, + * 表现就是"批准了却连不上"(本线反复要根治的那类病)。 + */ +export function parseRegistry(raw: unknown): NodesRegistry | undefined { + if (raw === undefined || raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined + const r = raw as Record + const nodesRaw = r.nodes + if (nodesRaw === undefined || typeof nodesRaw !== 'object' || Array.isArray(nodesRaw)) return undefined + const version = typeof r.version === 'number' ? r.version : NODES_REGISTRY_VERSION + const nodes: Record = {} + for (const [key, value] of Object.entries(nodesRaw as Record)) { + const rec = parseRecord(value) + if (rec === undefined) return undefined + if (key !== logicalName(rec.network, rec.hostId)) return undefined + nodes[key] = rec + } + return { version, nodes } +} + +function parseRecord(raw: unknown): NetworkNodeRecord | undefined { + if (raw === undefined || raw === null || typeof raw !== 'object') return undefined + const r = raw as Record + const network = typeof r.network === 'string' ? r.network.trim() : '' + const hostId = typeof r.hostId === 'string' ? r.hostId.trim() : '' + const nodeKey = typeof r.nodeKey === 'string' ? r.nodeKey.trim().toLowerCase() : '' + const status = typeof r.status === 'string' ? r.status.trim() : '' + const appliedAt = typeof r.appliedAt === 'string' ? r.appliedAt.trim() : '' + const approvedAt = typeof r.approvedAt === 'string' ? r.approvedAt.trim() : '' + const group = typeof r.group === 'string' ? r.group.trim() : '' + if (networkKindOf(network) === undefined) return undefined + if (!isHostId(hostId)) return undefined + if (!HEX_PUB_RE.test(nodeKey)) return undefined + if (status !== 'pending' && status !== 'approved') return undefined + if (appliedAt === '') return undefined + if (status === 'approved' && approvedAt === '') return undefined + return { network, hostId, nodeKey, status, appliedAt, approvedAt, group } +} + +/** 读注册表;**文件不存在 ⇒ 空注册表**(不是错误:还没开始用)。形状不对 ⇒ **抛**(具名)。 */ +export function loadRegistry(file: string): NodesRegistry { + if (!existsSync(file)) return emptyRegistry() + const raw: unknown = JSON.parse(readFileSync(file, 'utf8')) + const reg = parseRegistry(raw) + if (reg === undefined) throw new Error(`注册表 ${file} 形状非法(⛔ 不静默修复;字段口径见 registry.ts)`) + return reg +} + +/** 写注册表(**`0644`** —— 内容只有公钥与状态,⛔ 无密钥;走临时文件 + `rename` 不留半成品)。 */ +export function saveRegistry(file: string, reg: NodesRegistry): void { + mkdirSync(dirname(file), { recursive: true }) + const tmp = `${file}.tmp` + writeFileSync(tmp, `${JSON.stringify(sortedRegistryJson(reg), null, 2)}\n`, { mode: 0o644 }) + renameSync(tmp, file) +} + +/** 稳定序列化:`nodes` 的键**按键名排序**,让"没有改动"与"改动了"在 `diff` 里可分。 */ +function sortedRegistryJson(reg: NodesRegistry): NodesRegistry { + const nodes: Record = {} + for (const key of Object.keys(reg.nodes).sort()) { + const rec = reg.nodes[key] + if (rec === undefined) continue + nodes[key] = rec + } + return { version: reg.version, nodes } +} + +// ── 读只面(S1)───────────────────────────────────────────────────────────── + +export interface NetworkSummary { + network: string + /** 该网记录总数。 */ + total: number + /** 其中已批准数。 */ + approved: number + /** 其中待批准数。 */ + pending: number +} + +/** 逐网汇总(**排序稳定**:`ops` 先,其余按名)。 */ +export function summarizeNetworks(reg: NodesRegistry): NetworkSummary[] { + const acc = new Map() + for (const rec of Object.values(reg.nodes)) { + const cur = acc.get(rec.network) ?? { network: rec.network, total: 0, approved: 0, pending: 0 } + cur.total++ + if (rec.status === 'approved') cur.approved++ + else cur.pending++ + acc.set(rec.network, cur) + } + return [...acc.values()].sort((a, b) => { + if (a.network === OPS_NETWORK) return -1 + if (b.network === OPS_NETWORK) return 1 + return a.network < b.network ? -1 : a.network > b.network ? 1 : 0 + }) +} + +/** 列出节点(可选按网过滤;**排序稳定**:网名 → hostId)。 */ +export function listNodes(reg: NodesRegistry, network?: string): NetworkNodeRecord[] { + return Object.values(reg.nodes) + .filter((r) => network === undefined || r.network === network) + .sort((a, b) => { + if (a.network !== b.network) return a.network < b.network ? -1 : 1 + return a.hostId < b.hostId ? -1 : a.hostId > b.hostId ? 1 : 0 + }) +} + +// ── 写面(S1;**只有控制面调**)────────────────────────────────────────────── + +/** 收单结果:失败**具名**(⛔ 不许静默拒绝)。 */ +export type RegistryEdit = + | { ok: true; record: NetworkNodeRecord; created: boolean } + | { ok: false; reason: 'unknown-node' | 'unknown-network' } + +/** + * 收一份**申请**(`join` 的第 ④ 步)。 + * + * 语义(**刻意如此**): + * - 新节点 ⇒ 建一条 `pending`(⛔ **不自动批准** —— 批准权在控制面,见 §4.1); + * - **已 `approved` 的节点再次申请** ⇒ **保持 `approved`**(幂等:重装 / 重跑 join 不该把自己踢回待批); + * - 已 `pending` 再次申请 ⇒ 刷新 `appliedAt` / `nodeKey`(换机重装场景)。 + */ +export function applyApplication( + reg: NodesRegistry, + app: { network: string; hostId: string; nodeKey: string; at: string; group?: string }, +): RegistryEdit { + const network = app.network.trim() + if (networkKindOf(network) === undefined) return { ok: false, reason: 'unknown-network' } + if (!isHostId(app.hostId)) return { ok: false, reason: 'unknown-node' } + if (!HEX_PUB_RE.test(app.nodeKey.trim().toLowerCase())) return { ok: false, reason: 'unknown-node' } + const key = logicalName(network, app.hostId) + const prev = reg.nodes[key] + const record: NetworkNodeRecord = { + network, + hostId: app.hostId, + nodeKey: app.nodeKey.trim().toLowerCase(), + status: prev?.status === 'approved' ? 'approved' : 'pending', + appliedAt: app.at, + approvedAt: prev?.status === 'approved' ? prev.approvedAt : '', + group: app.group ?? prev?.group ?? '', + } + reg.nodes[key] = record + return { ok: true, record, created: prev === undefined } +} + +/** 批准一个节点(⛔ 只在**已申请**的节点上生效 ⇒ 不存在"凭空批准一个没申请过的 hostId")。 */ +export function approveNode( + reg: NodesRegistry, + network: string, + hostId: string, + at: string, + group?: string, +): RegistryEdit { + const net = network.trim() + if (networkKindOf(net) === undefined) return { ok: false, reason: 'unknown-network' } + const key = logicalName(net, hostId) + const prev = reg.nodes[key] + if (prev === undefined) return { ok: false, reason: 'unknown-node' } + const record: NetworkNodeRecord = { + ...prev, + status: 'approved', + approvedAt: at, + group: group ?? prev.group, + } + reg.nodes[key] = record + return { ok: true, record, created: false } +} + +/** 移除一个节点(⇒ 立刻退出派生白名单;⚠️ relay 侧生效要等一次 reload —— 见 §4.1 注)。 */ +export function removeNode(reg: NodesRegistry, network: string, hostId: string): RegistryEdit { + const net = network.trim() + if (networkKindOf(net) === undefined) return { ok: false, reason: 'unknown-network' } + const key = logicalName(net, hostId) + const prev = reg.nodes[key] + if (prev === undefined) return { ok: false, reason: 'unknown-node' } + delete reg.nodes[key] + return { ok: true, record: prev, created: false } +} + +// ── S4 · 白名单派生 ───────────────────────────────────────────────────────── + +/** + * 把**已批准集合**投影成 relay 侧的白名单(`dialers` 的**分桶 Map** 形状)。 + * + * ⚠️ 这是 `network.ts#normalizeDialers` **能吃**的形状 ⇒ 派生结果与手写 drop-in + * 走的是**同一个归一化入口**(⛔ 不存在"派生的白名单和手写的不是一回事")。 + * 🔴 `pending` **一律不投影** —— 派生只表达"已批准",待批节点的可拨性由既有默认拒绝兜住。 + */ +export function deriveDialers(reg: NodesRegistry, networks?: readonly string[]): Map> { + const out = new Map>() + if (networks !== undefined) for (const n of networks) out.set(assertNetworkId(n, '派生的网名'), new Set()) + for (const rec of Object.values(reg.nodes)) { + if (rec.status !== 'approved') continue + if (networks !== undefined && !networks.includes(rec.network)) continue + const bucket = out.get(rec.network) ?? new Set() + bucket.add(rec.hostId) + out.set(rec.network, bucket) + } + return out +} + +/** 派生结果的一致性问题(`OBS-24` 的判据②③,**逐条点名**)。 */ +export interface DerivationAudit { + ok: boolean + /** 派生集合 ≠ 已批准集合的网(逐条点名,含差集)。 */ + mismatches: string[] + /** **结构性错桶**:bucket 里的 hostId 对应记录属于**另一张网**(跨网零共享的机器判据)。 */ + misfiled: string[] +} + +/** + * 自审派生结果 —— **S4 的判据不是"函数跑通了",而是三件事同时成立**: + * ① 每张网的派生集合 ≡ 该网 `approved` 集合(多一个 / 少一个都点名); + * ② 任何 bucket 里的 hostId 都**来属于该 bucket 的网**(⇒ 「独立网零共享」是**结构性**的, + * 不是"我们用的时候记得过滤"); + * ③ `pending` 一个都不在派生里。 + */ +export function auditDerivation(reg: NodesRegistry, derived?: Map>): DerivationAudit { + const d = derived ?? deriveDialers(reg) + const mismatches: string[] = [] + const misfiled: string[] = [] + const byNetwork = new Map>() + const ownerOf = new Map() + for (const rec of Object.values(reg.nodes)) { + ownerOf.set(`${rec.network}${'\u0000'}${rec.hostId}`, rec.network) + if (rec.status !== 'approved') continue + const bucket = byNetwork.get(rec.network) ?? new Set() + bucket.add(rec.hostId) + byNetwork.set(rec.network, bucket) + } + for (const network of new Set([...byNetwork.keys(), ...d.keys()])) { + const want = byNetwork.get(network) ?? new Set() + const got = d.get(network) ?? new Set() + const missing = [...want].filter((h) => !got.has(h)).sort() + const extra = [...got].filter((h) => !want.has(h)).sort() + if (missing.length > 0 || extra.length > 0) { + mismatches.push(`${network}: 缺[${missing.join(',')}] 多[${extra.join(',')}]`) + } + } + for (const [network, hosts] of d.entries()) { + for (const host of hosts) { + const owner = ownerOf.get(`${network}${'\u0000'}${host}`) + if (owner !== network) misfiled.push(`${network}/${host}(记录属于 ${owner ?? '不存在'})`) + } + } + return { ok: mismatches.length === 0 && misfiled.length === 0, mismatches, misfiled } +} + +/** + * 派生一份 **relay drop-in** 的**内容**(纯字符串,⛔ 本函数不落盘、不 reload)。 + * + * 🔴 **手写 drop-in 是应急通道,⛔ 不删** —— 派生出的这份与手写那份作用**完全等价** + * (同一个 env 键、同一套归一化),差别只在"谁产生它"。⇒ 控制面不可用时,手写照旧生效。 + */ +export function deriveDropIn( + reg: NodesRegistry, + opts: { network: string; varName?: string; libDir?: string; unit?: string }, +): string { + const network = assertNetworkId(opts.network, '派生的网名') + const varName = opts.varName ?? 'DSHS_RELAY_DIALERS' + const hosts = [...(deriveDialers(reg, [network]).get(network) ?? new Set())].sort() + // 逻辑名形态(`<网>/`)—— `normalizeDialers` 的**规范形态**;⛔ 不用 `网:hostId` 旧式写法。 + const entries = hosts.map((h) => logicalName(network, h)) + const libDir = opts.libDir ?? '/opt/dsh-relay' + const unit = opts.unit ?? 'dshs-relay' + return [ + `# 由控制面**派生**(${unit} · network=${network})—— 源 = 网注册表里该网的 approved 集合`, + `# 🔴 手写 drop-in 是**应急通道**(控制面不可用时照旧生效),⛔ 本文件不取代它。`, + `# ⛔ 不许手改:改了下一次派生就被覆盖;要改就改注册表(approve / remove)。`, + '[Service]', + `Environment="${varName}=${entries.join(',')}"`, + ].join('\n') +} + +/** drop-in 的**目标路径**(同一套命名,⛔ 别在调用方拼)。 */ +export function dropInPath(unit: string, libDir?: string): string { + const suffix = libDir === undefined ? '' : ` (lib=${libDir})` + return `/etc/systemd/system/${unit}.service.d/50-overlay-dialers.conf${suffix}` +} + +// ── 一行摘要(给 CLI / 日志;**同一条信息只说一次**)───────────────────────── + +export function describeRegistryLine(reg: NodesRegistry): string { + const nets = summarizeNetworks(reg) + if (nets.length === 0) return `注册表:0 张网 0 个节点(⛔ 空注册表 ⇒ 派生结果为空 ⇒ 默认拒绝一切拨号)` + return `注册表:${nets.map((n) => `${n.network}(approved ${n.approved}/pending ${n.pending})`).join(' · ')}` +} diff --git a/src/web/routes/overlay-nodes.ts b/src/web/routes/overlay-nodes.ts new file mode 100644 index 0000000..89818af --- /dev/null +++ b/src/web/routes/overlay-nodes.ts @@ -0,0 +1,142 @@ +/** + * 覆盖网络 **管理面 API**(序㊵ · P2/S5 一并做 —— 该文件是 P1 的出口项)。 + * + * ## 它回答两个问题 + * | 端点 | 作用 | 鉴权 | + * |---|---|---| + * | `GET /api/admin/overlay-nodes` | **有哪些网 / 每个节点什么状态**(只读汇总) | `requireAdmin` | + * | `GET /api/admin/overlay-nodes/direct` | 本机**直连开关**现状 + **三段提示** | `requireAdmin` | + * | `POST /api/admin/overlay-nodes/direct` | **用户可设置**(口径①)—— 只改本机配置的 `direct` 字段 | `requireAdmin` | + * + * ## 🔴 三条纪律 + * ① **全部走 `requireAdmin`** —— ⛔ 不做第二个无鉴权端点:既有的 `GET /dshs-overlay/bootstrap` + * 之所以能无鉴权,是因为它**只服务还没有凭据的新节点**且内容被收窄到"去哪儿"; + * "**有哪些节点**"是**拓扑信息**,放公网 = 扩大暴露面(命中 **R5**)。 + * ② **只写"跟着这台机器走"的那一项** —— 直连开关放**本机配置**(`.direct`), + * ⛔ 不写共享 drop-in / env:那属于控制面或运维的通道(见 `direct/index.ts#resolveDirectSwitch` + * 的优先级口径:env > 本机配置 > 缺省)。 + * ③ **失败具名** —— 文件不存在 / 形状坏 / 写不进去,各自一个原因码,⛔ 不吞、⛔ 不静默创建。 + * + * @module dshs/web/routes/overlay-nodes + */ + +import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs' + +import type { FastifyPluginAsync } from 'fastify' + +import { + DIRECT_ENV_KEY, + DEFAULT_DIRECT_ENABLED, + NODE_CONFIG_FILE_DEFAULT, + directFromNodeConfig, + directHintLines, + resolveDirectSwitch, + writeNodeConfigDirect, +} from '../../net/relay/direct/index.js' +import { loadRegistry, summarizeNetworks, listNodes } from '../../net/relay/registry.js' +import { requireAdmin } from '../middleware/authn.js' + +/** 本机配置落点(`DSHS_OVERLAY_NODE_CONFIG` 可覆盖;缺省与 `join` 的 `--config` 一致)。 */ +function nodeConfigFile(): string { + const v = process.env.DSHS_OVERLAY_NODE_CONFIG + return typeof v === 'string' && v.trim() !== '' ? v.trim() : NODE_CONFIG_FILE_DEFAULT +} + +/** 注册表落点(`DSHS_OVERLAY_NODES_FILE` 可覆盖;缺省 = 参数表 `NODES_REGISTRY_FILE`)。 */ +function registryFile(): string { + const v = process.env.DSHS_OVERLAY_NODES_FILE + return typeof v === 'string' && v.trim() !== '' ? v.trim() : '/var/lib/dshs/overlay/nodes.json' +} + +const fss = { + exists: (p: string) => existsSync(p), + read: (p: string) => readFileSync(p, 'utf8'), + write: (p: string, text: string, mode: number) => writeFileSync(p, text, { mode }), + rename: (from: string, to: string) => renameSync(from, to), +} + +/** 读本机配置里的 `direct`(**缺文件 ⇒ `undefined`**,形状坏 ⇒ 抛给调用方具名化)。 */ +function readLocalDirect(): { file: string; present: boolean; direct?: boolean } { + const file = nodeConfigFile() + if (!existsSync(file)) return { file, present: false } + const raw: unknown = JSON.parse(readFileSync(file, 'utf8')) + return { file, present: true, direct: directFromNodeConfig(raw) } +} + +export const overlayNodeRoutes: FastifyPluginAsync = async (app) => { + /** 只读汇总:有哪些网 / 每张网几个 approved、几个 pending / 节点清单。 */ + app.get('/api/admin/overlay-nodes', { preHandler: requireAdmin }, async (_request, reply) => { + const file = registryFile() + if (!existsSync(file)) { + // "这套准入还没开始用"是**合法状态** ⇒ 200 + 明确的 `present:false`(⛔ 不是 500,也不是空 200) + return reply.send({ registry: { file, present: false }, networks: [], nodes: [] }) + } + let reg + try { + reg = loadRegistry(file) + } catch (err) { + return reply.code(500).send({ error: 'registry-unreadable', detail: err instanceof Error ? err.message : String(err) }) + } + return reply.send({ + registry: { file, present: true, version: reg.version }, + networks: summarizeNetworks(reg), + nodes: listNodes(reg).map((n) => ({ + network: n.network, + hostId: n.hostId, + status: n.status, + group: n.group, + appliedAt: n.appliedAt, + approvedAt: n.approvedAt, + })), + }) + }) + + /** 直连开关现状(含**三段提示** —— 用户口径③的"设置面显示"落点)。 */ + app.get('/api/admin/overlay-nodes/direct', { preHandler: requireAdmin }, async (_request, reply) => { + let local: { file: string; present: boolean; direct?: boolean } + try { + local = readLocalDirect() + } catch (err) { + return reply.code(500).send({ error: 'node-config-bad', detail: err instanceof Error ? err.message : String(err) }) + } + const state = resolveDirectSwitch(process.env, { localDirect: local.direct }) + return reply.send({ + envKey: DIRECT_ENV_KEY, + defaultEnabled: DEFAULT_DIRECT_ENABLED, + effective: state.enabled, + source: state.source, + raw: state.raw, + invalid: state.invalid, + nodeConfig: { file: local.file, present: local.present, direct: local.direct ?? null }, + hint: directHintLines(), + }) + }) + + /** + * 设置直连开关(**用户可设置**)。 + * + * ⛔ 只动本机配置的 `direct`;⚠️ 若 env 里显式设了 `DSHS_OVERLAY_DIRECT`,它会**压过**本项 + * ⇒ 回执里把这件事**明说**(否则用户会以为"改了没生效 = 平台坏了")。 + */ + app.post('/api/admin/overlay-nodes/direct', { preHandler: requireAdmin }, async (request, reply) => { + const body = request.body as { enabled?: unknown } | undefined + if (body === undefined || typeof body.enabled !== 'boolean') { + return reply.code(400).send({ error: 'bad-body', detail: 'body 须为 {enabled: boolean}' }) + } + const outcome = writeNodeConfigDirect(nodeConfigFile(), body.enabled, fss) + if (!outcome.ok) return reply.code(400).send({ error: outcome.reason, detail: outcome.detail }) + const state = resolveDirectSwitch(process.env, { localDirect: outcome.direct }) + return reply.send({ + ok: true, + file: outcome.file, + direct: outcome.direct, + effective: state.enabled, + source: state.source, + envOverrides: state.source === 'env', + note: + state.source === 'env' + ? `⚠️ 本机 env 里已显式设了 ${DIRECT_ENV_KEY}=${state.raw} ⇒ 它**压过**本次设置(env > 本机配置 > 缺省)` + : '已生效(重启节点服务后按本文件生效)', + }) + }) +} diff --git a/src/web/server.ts b/src/web/server.ts index cf128c5..a939428 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -62,6 +62,7 @@ import { desktopRoutes } from './routes/desktop.js' import { dshRoutes } from './routes/dsh.js' import { domainRoutes } from './routes/domain.js' import { overlayRoutes } from './routes/overlay.js' +import { overlayNodeRoutes } from './routes/overlay-nodes.js' import { skillRoutes } from './routes/skills.js' import { whitelistRoutes } from './routes/whitelist.js' @@ -1163,6 +1164,8 @@ export async function buildServer(config: ServerConfig): Promise new Promise((r) => setTimeout(r, ms)) +const root = fileURLToPath(new URL('..', import.meta.url)) +const ssPath = join(root, 'src', 'net', 'relay', 'network.ts') +const serverPath = join(root, 'src', 'net', 'relay', 'server.ts') + +/** 真·白名单(**走既有归一化实现** ⇒ 量的是"同一份策略"的行为)。 */ +const dialers = () => + normalizeDialers( + new Map([ + ['ops', new Set(['manager', 'w-106'])], + ['u:5', new Set(['w-106'])], + ]), + ) + +/** 找一个没人监听的本地 UDP 端口(用于产出 `deadline` 负腿)。 */ +async function deadPort() { + const s = createSocket('udp4') + await new Promise((r) => s.bind({ port: 0, address: '127.0.0.1' }, r)) + const p = s.address().port + await new Promise((r) => s.close(r)) + return p +} + +const candidate = (over = {}) => + encodeDirectMessage({ + hostId: 'w-106', + network: 'ops', + addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }], + ts: Date.now(), + ...over, + }) + +// ── T1 · 开关解析 ──────────────────────────────────────────────────────────────── +test('T1 开关:缺省=开|开集/关集|非法值 ⇒ null(⛔ 不静默取缺省)|本机配置次之', () => { + const d = resolveDirectSwitch({}) + assert.equal(d.enabled, true, '缺省必须 = 开(用户口径②)') + assert.equal(d.source, 'default') + assert.equal(DEFAULT_DIRECT_ENABLED, true) + + for (const v of ['1', 'true', 'ON', 'Yes']) { + const s = resolveDirectSwitch({ [DIRECT_ENV_KEY]: v }) + assert.equal(s.enabled, true, `${v} 应判开`) + assert.equal(s.source, 'env') + } + for (const v of DIRECT_OFF_VALUES) { + const s = resolveDirectSwitch({ [DIRECT_ENV_KEY]: v.toUpperCase() }) + assert.equal(s.enabled, false, `${v} 应判关`) + } + // 🔴 非法值:**既不许当开也不许当关** + const bad = resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'maybe' }) + assert.equal(bad.enabled, null) + assert.match(bad.invalid, /不静默取缺省/) + // 本机配置(用户设置)在 env 缺省时生效;env 显式设了就压过它 + assert.equal(resolveDirectSwitch({}, { localDirect: false }).enabled, false) + assert.equal(resolveDirectSwitch({}, { localDirect: false }).source, 'node-config') + assert.equal(resolveDirectSwitch({ [DIRECT_ENV_KEY]: '1' }, { localDirect: false }).enabled, true) + // 空串 = 未设(⛔ 不算非法值) + assert.equal(resolveDirectSwitch({ [DIRECT_ENV_KEY]: ' ' }).enabled, true) +}) + +// ── T2 · 关闭 ⇒ 零 socket / 零候选 ────────────────────────────────────────────── +test('T2 关闭 ⇒ 零 UDP socket + 零候选;开启才真的开', async () => { + const ctxOf = { dialers: dialers(), from: { network: 'ops', hostId: 'w-106' }, selfHostId: 'manager' } + const off = new DirectPath({ switchState: resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'false' }), deadlineMs: 300 }) + const v = off.offerCandidate(candidate(), ctxOf) + assert.equal(v.ok, false) + assert.equal(v.reason, 'disabled') + const a = await off.attempt('ops/void', [{ host: '127.0.0.1', port: await deadPort() }], { sleep }) + assert.equal(a.reason, 'disabled') + const st = off.status() + assert.equal(st.counters.udpSocketsOpened, 0, '关闭 ⇒ ⛔ 一个 UDP socket 都不许开') + assert.equal(st.counters.candidatesEmitted, 0, '关闭 ⇒ ⛔ 一条候选都不许发') + + // 非法开关值同样**两种动作都不做**(避免"取值写错 ⇒ 静默当成开") + const bad = new DirectPath({ switchState: resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'maybe' }), deadlineMs: 300 }) + assert.equal(bad.offerCandidate(candidate(), ctxOf).reason, 'invalid-switch') + assert.equal((await bad.attempt('ops/void', [{ host: '127.0.0.1', port: await deadPort() }], { sleep })).reason, 'invalid-switch') + assert.equal(bad.status().counters.udpSocketsOpened, 0) + + const on = new DirectPath({ switchState: resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'true' }), deadlineMs: 300 }) + assert.equal(on.offerCandidate(candidate(), ctxOf).ok, true) + await on.attempt('ops/void', [{ host: '127.0.0.1', port: await deadPort() }], { sleep }) + assert.equal(on.status().counters.udpSocketsOpened, 1, '开启 ⇒ 真的绑了 1 个 UDP socket') + assert.equal(on.status().counters.candidatesEmitted, 1) +}) + +// ── T3 · 候选准入矩阵 ─────────────────────────────────────────────────────────── +test('T3 候选准入:同网+白名单内接受;其余**逐类具名**拒绝;静默拒绝 = 0', () => { + const led = new CandidateLedger() + const inOps = { dialers: dialers(), from: { network: 'ops', hostId: 'w-106' }, selfHostId: 'manager' } + const expectOk = (raw, ctx = inOps) => { + const v = led.judge(raw, ctx) + assert.equal(v.ok, true, `应接受:${v.ok ? '' : v.reason} ${v.ok ? '' : v.detail}`) + } + const expectReject = (raw, reason, ctx = inOps) => { + const v = led.judge(raw, ctx) + assert.equal(v.ok, false) + assert.equal(v.reason, reason) + assert.ok(typeof v.detail === 'string' && v.detail !== '', '拒绝必须带人读原因(⛔ 不许只回 false)') + } + + expectOk(candidate()) + expectOk(candidate({ addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }, { host: '2001:db8::1', port: PUNCH_PORT_BASE + 2 }] })) + // 同名跨网**互不可见**:同一 hostId 在另一张网里是合法身份 + expectOk(candidate({ network: 'u:5' }), { dialers: dialers(), from: { network: 'u:5', hostId: 'w-106' }, selfHostId: 'w-106' }) + + expectReject(candidate({ network: 'u:5' }), 'cross-network') + expectReject(candidate({ hostId: 'w-999' }), 'not-self-candidate') + expectReject(candidate(), 'not-a-dialer', { ...inOps, dialers: normalizeDialers(new Map([['ops', new Set(['manager'])]])) }) + expectReject(candidate(), 'self-not-a-dialer', { ...inOps, selfHostId: 'w-nobody' }) + expectReject('{"kind":"DIRECT_CANDIDATE"}', 'bad-shape') + expectReject('not json at all', 'bad-shape') + expectReject( + JSON.stringify({ kind: 'DIRECT_CANDIDATE', hostId: 'w-106', network: 'ops', addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }], nodeKey: 'deadbeef' }), + 'secret-field', + ) + expectReject(candidate({ addrs: [{ host: 'example.com', port: 80 }] }), 'bad-address') + expectReject(candidate({ addrs: [{ host: '10.0.0.9', port: 0 }] }), 'bad-address') + expectReject( + candidate({ addrs: Array.from({ length: DIRECT_CAND_MAX_ADDRS + 1 }, (_, i) => ({ host: '10.0.0.9', port: PUNCH_PORT_BASE + i })) }), + 'too-many-addrs', + ) + expectReject(candidate({ ts: Date.now() - 10 * 60_000 }), 'stale') + + const snap = led.snapshot() + assert.equal(snap.accepted, 3) + assert.equal(snap.rejected.length, 11, '负腿 11 条:跨网/替第三人/白名单外/本机不在白名单/坏形状×2/凭据字段/坏地址×2/超限/过期') + assert.equal(snap.silentRejections, 0, '静默拒绝必须为 0(本线老病根的不变量守卫)') + assert.equal(snap.received, 14) + // 每一条拒绝**都有具名原因** + for (const r of snap.rejected) assert.ok(typeof r.reason === 'string' && r.reason !== '') + // 白名单是**按网络分桶**的:拿 ops 的桶查 u:5 的同名 hostId 必须为假 + assert.equal(isAllowedDialer(dialers(), 'ops', 'w-106'), true) + assert.equal(isAllowedDialer(dialers(), 'u:5', 'w-106'), true) + assert.equal(isAllowedDialer(dialers(), 'u:7', 'w-106'), false) + assert.equal(isAllowedDialer(dialers(), 'ops', 'w-999'), false) + // 地址形状:IPv4/IPv6 收,主机名不收 + assert.equal(isValidAddress({ host: '10.0.0.9', port: 21100 }), true) + assert.equal(isValidAddress({ host: '1.2.3', port: 21100 }), false) + assert.equal(isValidAddress({ host: '256.1.1.1', port: 21100 }), false) + assert.equal(isValidAddress({ host: 'a.b.c', port: 21100 }), false) +}) + +// ── T4 · 准入策略与 server.ts 的等价性(机器守卫) ────────────────────────────── +test('T4 准入策略**复用** server.ts 的那一条(⛔ 防两处写分叉)', () => { + const netSrc = readFileSync(ssPath, 'utf8') + const srvSrc = readFileSync(serverPath, 'utf8') + + // ① 唯一出口确实在 network.ts 里,且语义就是"该网桶里有没有这个 hostId" + assert.match(netSrc, /export function isAllowedDialer\(/) + assert.match(netSrc, /return map\.get\(network\)\?\.has\(hostId\) === true/) + + // ② server.ts 的两处闸门与它**同策略**(同 map 、同 `=== true` 默认拒绝) + const registerGate = srvSrc.includes('const wantDialer = this.dialers.get(network)?.has(hostId) === true') + const dialGate = srvSrc.includes('if (this.dialers.get(session.network)?.has(session.hostId) !== true) {') + assert.ok(registerGate, '注册闸门(server.ts)应仍是 dialers.get(network)?.has(hostId) === true(⛔ 本棒不许改它的语义)') + assert.ok(dialGate, 'DIAL 闸门(server.ts)应仍是 dialers.get(session.network)?.has(session.hostId) !== true') + + // ③ 直连模块**不许**自己再判一遍(⛔ 防"同一事实两处写") + for (const f of ['direct/candidate.ts', 'direct/index.ts', 'direct/punch.ts']) { + const src = readFileSync(join(root, 'src', 'net', 'relay', f), 'utf8') + assert.ok(!/dialers\.get\(/.test(src), `${f} 里出现了 dialers.get( ⇒ 疑似把白名单判定抄了一份`) + assert.ok(!/\.has\(hostId\)/.test(src), `${f} 里出现了 .has(hostId) ⇒ 疑似把白名单判定抄了一份`) + } + + // ④ 等价性:默认拒绝(空桶 / 未列网络 / 名字错)—— 与 server.ts 同为"默认拒绝" + assert.equal(isAllowedDialer(new Map(), 'ops', 'manager'), false) + assert.equal(isAllowedDialer(dialers(), 'ops', 'MANAGER'), false, 'hostId 大小写敏感(与 HOST_RE 口径一致)') +}) + +// ── T5 · 打洞成功路径(真 dgram + NAT 模拟) ──────────────────────────────────── +test('T5 打洞成功路径:双向都成立才算直连(punchOk ≥ 1)', async () => { + const r = await runPunchPair({ aPeer: 'ops/w-47', bPeer: 'ops/w-106', deadlineMs: 2500 }, { sleep }) + assert.equal(r.a.bidirectional, true, `A 侧应双向成立:${r.a.detail}`) + assert.equal(r.b.bidirectional, true, `B 侧应双向成立:${r.b.detail}`) + assert.equal(r.a.reason, 'ok') + assert.equal(r.b.reason, 'ok') + assert.equal(r.bidirectional, true) + assert.ok(r.a.recvLocal > 0 && r.b.recvLocal > 0, '两侧都必须真的收到包') + assert.equal(r.nat.a.forwardedIn > 0, true, 'NAT 侧记录:真的放行了入向包') +}) + +// ── T6 · 单向不算直连 ─────────────────────────────────────────────────────────── +test('T6 单向 ⇒ 判死 one-way(⛔ 不许把单向当成功)', async () => { + const r = await runPunchPair({ aPeer: 'ops/w-47', bPeer: 'ops/w-106', deadlineMs: 1200, oneWay: 'a' }, { sleep }) + assert.equal(r.bidirectional, false) + assert.equal(r.a.reason, 'one-way', `A 侧(只能出不能进)应判 one-way:${r.a.detail}`) + assert.equal(r.a.bidirectional, false) + assert.equal(r.b.reason, 'one-way', `B 侧(收到包但对端没收到)应判 one-way:${r.b.detail}`) + assert.equal(r.b.recvLocal > 0, true, 'B 的确收到了包 ⇒ 不能因为"有收包"就判成功') + assert.equal(r.b.peerSeen, false) +}) + +// ── T7 · 失败判死有界 ─────────────────────────────────────────────────────────── +test('T7 对端不响应 ⇒ 窗内判死,且耗时**有界**(⛔ 不无限重试)', async () => { + const cd = new DirectCooldown(DEFAULT_DIRECT_COOLDOWN_MS) + const deadlineMs = 600 + const r = await runPunchAttempt( + { + peer: 'ops/void', + selfPort: 0, + targets: [{ host: '127.0.0.1', port: await deadPort() }], + deadlineMs, + cooldown: cd, + bindHost: '127.0.0.1', + }, + { sleep }, + ) + assert.equal(r.reason, 'deadline') + assert.equal(r.ok, false) + assert.equal(r.recvLocal, 0) + assert.ok(r.sent > 1, '窗内应重发(单发一次赶不上对端同时发包)') + assert.ok(r.elapsedMs <= deadlineMs + 3 * 150, `耗时必须有界:${r.elapsedMs} ms`) + assert.equal(cd.snapshot().cooling.includes('ops/void'), true, '判死 ⇒ 该候选进冷却') + // 没有候选 ⇒ **不是**打洞失败 ⇒ ⛔ 不进冷却 + const cd2 = new DirectCooldown(DEFAULT_DIRECT_COOLDOWN_MS) + const r2 = await runPunchAttempt({ peer: 'ops/none', selfPort: 0, targets: [], cooldown: cd2 }, { sleep }) + assert.equal(r2.reason, 'no-address') + assert.equal(cd2.snapshot().cooling.length, 0) +}) + +// ── T8 · 冷却 ────────────────────────────────────────────────────────────────── +test('T8 冷却:判死后二次被挡且**不开 socket**;`ms = 0` 构造即抛', async () => { + const cd = new DirectCooldown(5_000) + let opened = 0 + const port = await deadPort() + const mk = (targets) => + runPunchAttempt( + { + peer: 'ops/void', + selfPort: 0, + targets, + deadlineMs: 300, + cooldown: cd, + bindHost: '127.0.0.1', + onSocketOpen: () => { + opened += 1 + }, + }, + { sleep }, + ) + const first = await mk([{ host: '127.0.0.1', port }]) + assert.equal(first.reason, 'deadline') + assert.equal(opened, 1) + const second = await mk([{ host: '127.0.0.1', port }]) + assert.equal(second.reason, 'cooldown') + assert.equal(opened, 1, '冷却命中的路径**一个 socket 都不许开**') + assert.ok(cd.snapshot().blocked >= 1) + // 🔴 0 = 重试风暴 ⇒ 构造期就炸(本线硬禁令) + assert.throws(() => new DirectCooldown(0), /必须 > 0/) + assert.throws(() => new DirectCooldown(-1), /必须 > 0/) + assert.throws(() => new DirectCooldown(Number.NaN), /必须 > 0/) +}) + +// ── T9/T10 · join 回读 + 提示 ───────────────────────────────────────────────── +test('T9 join 本机配置读回 direct = true,且 --direct 0 被尊重', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'dshs-direct-t9-')) + const signer = generateAuthorityKey() + const issue = () => { + const doc = { + version: NODES_REGISTRY_VERSION, + network: 't9-net', + nonce: newInviteNonce(randomBytes), + issuedAt: new Date().toISOString(), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } + return { doc, sig: signPayloadWith(signer.privateKeyPem, networkInvitePayload(doc)) } + } + const io = nodeJoinIo({ + fss: { existsSync, readFileSync, writeFileSync, mkdirSync }, + crypto: { generateNodeKey: () => generateNodeKey(), publicKeyOfPrivate: (pem) => publicKeyOfPrivate(pem) }, + os: { hostname: () => 't9-host' }, + fetchImpl: async () => ({ status: 599, body: 'n/a' }), + }) + const run = (name, extra) => + runJoin( + { + network: 't9-net', + hostId: name, + invite: issue(), + trustedSigners: [signer.publicKey], + nodeKeyFile: join(tmp, `${name}.key`), + localConfigFile: join(tmp, `${name}.json`), + outFile: join(tmp, `${name}-app.json`), + ...extra, + }, + io, + ) + const a = await run('node-a', {}) + assert.equal(a.ok, true) + const cfgA = JSON.parse(readFileSync(join(tmp, 'node-a.json'), 'utf8')) + assert.equal(cfgA.direct, true, '缺省必须写进本机配置 = 开(用户口径②)') + assert.equal(directFromNodeConfig(cfgA), true) + assert.match(a.steps.find((s) => s.step === 'local-config').detail, /直连\(打洞\)=开/) + + const b = await run('node-b', { direct: false }) + assert.equal(b.ok, true) + assert.equal(directFromNodeConfig(JSON.parse(readFileSync(join(tmp, 'node-b.json'), 'utf8'))), false) + + // 管理面写回:**只改 direct 一个字段**,其余原样;缺失文件 ⇒ 具名失败(⛔ 不凭空创建) + const file = join(tmp, 'node-a.json') + const before = JSON.parse(readFileSync(file, 'utf8')) + const writer = { + exists: existsSync, + read: (p) => readFileSync(p, 'utf8'), + write: (p, t, mode) => writeFileSync(p, t, { mode }), + rename: renameSync, + } + const w = writeNodeConfigDirect(file, false, writer) + assert.equal(w.ok, true) + const after = JSON.parse(readFileSync(file, 'utf8')) + assert.equal(after.direct, false) + assert.deepEqual({ ...after, direct: true }, before, '⛔ 除 direct 外任何字段都不许被动到') + const miss = writeNodeConfigDirect(join(tmp, 'nope.json'), true, writer) + assert.equal(miss.ok, false) + assert.equal(miss.reason, 'node-config-missing') + // 形状坏 ⇒ 具名 + writeFileSync(join(tmp, 'bad.json'), '[1,2,3]') + assert.throws(() => readNodeConfig(join(tmp, 'bad.json'), { exists: () => true, read: (p) => readFileSync(p, 'utf8') }), /不是 JSON 对象/) +}) + +test('T10 提示文案必须**可行动**:三段齐 + 含开关键与关值', () => { + const lines = directHintLines() + assert.equal(lines.length, 3) + const text = directHintText() + assert.ok(text.includes('同一张覆盖网') && text.includes('白名单'), '①段要写清"谁可能连进来"') + assert.ok(text.includes(DIRECT_ENV_KEY), '②段要写清"怎么关"(含开关键名)') + assert.ok(DIRECT_OFF_VALUES.some((v) => text.includes(v)), '②段要给出具体的关闭取值') + assert.ok(text.includes('不影响'), '③段要写清"关掉不影响什么"') + assert.ok(text.includes('中继'), '③段要写清回落路径') + // ⛔ 禁止只写"已启用直连" + assert.ok(!/^已启用直连$/.test(text.trim())) + // 编码侧的结构性防线:**只吃白名单字段** ⇒ 多给的任何字段(含凭据)都进不了载荷 + const encoded = JSON.parse(encodeDirectMessage({ hostId: 'w-106', network: 'ops', addrs: [{ host: '10.0.0.9', port: 1 }], secret: 'x' })) + assert.deepEqual(Object.keys(encoded).sort(), ['addrs', 'hostId', 'kind', 'network', 'ts']) + assert.equal(encoded.secret, undefined, '⛔ 载荷里不可能夹带凭据字段(构造侧结构性排除)') +}) diff --git a/test/overlay-join.test.mjs b/test/overlay-join.test.mjs new file mode 100644 index 0000000..24aab89 --- /dev/null +++ b/test/overlay-join.test.mjs @@ -0,0 +1,634 @@ +/** + * 覆盖网络 · **序㊱ 「节点一键加入与分组准入」P1(S1–S4)** 单测。 + * + * ## 这个文件要回答的六个问题(= `交接单_节点一键加入与分组准入_20260918.md §5` 的 J1–J6) + * | 组 | 判据 | 落在哪个 J | + * |---|---|---| + * | A | **邀请凭据**:网络绑定 / 有效期 / 受信签名者 / 载荷形状,四件都**具名**拒 | J1 前半 | + * | B | **一次性**:同一 nonce 二次使用被拒;**并发**下**恰好一台**拿到(内核原子占位) | **J1** | + * | C | **网注册表**:`pending` 不进白名单、`approved` 才进;批准不存在的节点被具名拒 | J2/J4 | + * | D | **白名单派生**:分桶形状 + 与 `normalizeDialers` **同一入口** + **跨网零共享**(结构性) | **J5** | + * | E | **join 编排**:四步真跑;申请单**字节级**不含私钥;失败**具名且带步迹** | J4/J6 | + * | F | **「⛔ 不许静默拒绝」的机器判据**:join / registry 产物里**零空 `catch` 块** | §9-5 | + * + * ## 🔴 「先红后绿」在本文件里的落点(⛔ 不是口头声明) + * `lib/net/relay/registry.js` 里把 `consumeNonce` 的 `'wx'` 改成 `'w'`(= 不再原子占位) + * ⇒ **B 组的 `T9`(并发恰好一台)与 `T8`(二次使用)转红**,其余全绿;改回 ⇒ 全绿。 + * 该实验的原文级输出记录在执行棒回报 §8.2(⚠️ 破坏的是 `lib` 产物、`src` 未动)。 + * + * 运行:`node --test test/overlay-join.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。 + * ⚠️ **刻意不进 `npm test`** —— 那个脚本是**硬编码文件列表**,加进去会改测试总数(与序㉔/㉘/㉚ 同纪律)。 + * + * @module test/overlay-join + */ + +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { test } from 'node:test' +import { fileURLToPath } from 'node:url' + +import { generateAuthorityKey, generateNodeKey, publicKeyOfPrivate, signPayloadWith } from '../lib/net/relay/identity.js' +import { + NETWORK_INVITE_TAG, + applyApplication, + approveNode, + auditDerivation, + consumeNonce, + deriveDialers, + deriveDropIn, + emptyRegistry, + loadRegistry, + newInviteNonce, + parseNetworkInvite, + parseRegistry, + removeNode, + saveRegistry, + summarizeNetworks, + verifyNetworkInvite, +} from '../lib/net/relay/registry.js' +import { JOIN_STEPS, joinReasonOfInvite, parseApplication, readApplicationFile, runJoin } from '../lib/net/relay/join.js' +import { normalizeDialers } from '../lib/net/relay/network.js' + +// ── 夹具 ──────────────────────────────────────────────────────────────────── + +const tmps = [] + +function mkTmp(tag) { + const d = mkdtempSync(join(tmpdir(), `dshs-join-${tag}-`)) + tmps.push(d) + return d +} + +process.on('exit', () => { + for (const d of tmps) { + try { + rmSync(d, { recursive: true, force: true }) + } catch { + /* 临时目录清理失败不影响判据 */ + } + } +}) + +/** 一把一次性使用的**测试签名者**(⛔ 与生产密钥无关;用完即丢)。 */ +function signerFixture() { + const k = generateAuthorityKey() + return { pem: k.privateKeyPem, pub: k.publicKey } +} + +function inviteFixture(signer, network, opts = {}) { + const doc = { + version: 1, + network, + nonce: opts.nonce ?? newInviteNonce(randomBytes), + issuedAt: new Date(opts.now ?? Date.now()).toISOString(), + expiresAt: opts.expiresAt ?? new Date((opts.now ?? Date.now()) + 10 * 60 * 1000).toISOString(), + } + return { doc, sig: signPayloadWith(signer.pem, invitePayloadOf(doc)) } +} + +/** 与 `registry.ts#networkInvitePayload` 同一条规范拼接(⛔ 测试里不另造口径)。 */ +function invitePayloadOf(doc) { + return [ + NETWORK_INVITE_TAG, + `version=${String(doc.version)}`, + `network=${doc.network}`, + `nonce=${doc.nonce}`, + `issuedAt=${doc.issuedAt}`, + `expiresAt=${doc.expiresAt}`, + ].join('\n') +} + +/** 一个**完全离线**的 `JoinIo`:真写临时目录,⛔ 不发网络。 */ +function fakeIo(calls = {}) { + return { + exists: existsSync, + read: (p) => readFileSync(p, 'utf8'), + write: (p, text, mode) => { + mkdirSync(join(p, '..'), { recursive: true }) + writeFileSync(p, text, { mode }) + }, + generateNodeKey, + publicKeyOfPrivate, + hostname: () => 'test-host', + post: async (url, body) => { + calls.posts = calls.posts ?? [] + calls.posts.push({ url, body }) + return calls.postReply ?? { status: 200, body: '{"ok":true}' } + }, + } +} + +function joinOpts(dir, network, invite, over = {}) { + return { + network, + hostId: 'node-a', + invite, + trustedSigners: [over.signerPub], + nodeKeyFile: join(dir, 'node.key'), + localConfigFile: join(dir, 'node.json'), + outFile: join(dir, 'application.json'), + ...over, + } +} + +// ── A 组 · 邀请凭据(S2)───────────────────────────────────────────────────── + +test('A1 正当邀请:验签通过(网络/有效期/形状四件都过)', () => { + const s = signerFixture() + const inv = inviteFixture(s, 'ops') + const v = verifyNetworkInvite(inv.doc, inv.sig, [s.pub], { network: 'ops' }) + assert.equal(v.ok, true, `期望通过,实际 ${JSON.stringify(v)}`) + assert.equal(v.doc.network, 'ops') +}) + +test('A2 坏签名 ⇒ 具名拒(invite-signature-mismatch,由 joinReasonOfInvite 映射)', () => { + const s = signerFixture() + const inv = inviteFixture(s, 'ops') + const v = verifyNetworkInvite(inv.doc, `${inv.sig.slice(0, -6)}AAAAAA`, [s.pub], { network: 'ops' }) + assert.equal(v.ok, false) + assert.notEqual(v.reason, 'ok') + // 映射必须落在**具名**的 invite-* 上,⛔ 不许落成通用失败 + assert.match(joinReasonOfInvite(v.reason), /^invite-/) +}) + +test('A3 过期 ⇒ expired(有效期是真判据)', () => { + const s = signerFixture() + const inv = inviteFixture(s, 'ops', { now: Date.now() - 3600_000, expiresAt: new Date(Date.now() - 60_000).toISOString() }) + const v = verifyNetworkInvite(inv.doc, inv.sig, [s.pub], { network: 'ops' }) + assert.equal(v.ok, false) + assert.equal(v.reason, 'expired') + assert.equal(joinReasonOfInvite(v.reason), 'invite-expired') +}) + +test('A4 网络绑定:拿 ops 的邀请去加入别的网 ⇒ network-mismatch(⛔ 不静默放行)', () => { + const s = signerFixture() + const inv = inviteFixture(s, 'ops') + const v = verifyNetworkInvite(inv.doc, inv.sig, [s.pub], { network: 'u:5' }) + assert.equal(v.ok, false) + assert.equal(v.reason, 'network-mismatch') +}) + +test('A5 无受信签名者 ⇒ 不可验 = 不接受(⛔ 不降级)', () => { + const s = signerFixture() + const inv = inviteFixture(s, 'ops') + const v = verifyNetworkInvite(inv.doc, inv.sig, [], { network: 'ops' }) + assert.equal(v.ok, false) + assert.equal(v.reason, 'no-trusted-keys') + assert.equal(joinReasonOfInvite(v.reason), 'invite-no-trusted-signer') +}) + +test('A6 载荷形状非法(nonce 不是 32 hex)⇒ bad-payload(⛔ 不猜、不补默认值)', () => { + assert.equal(parseNetworkInvite({ version: 1, network: 'ops', nonce: 'short', issuedAt: 'x', expiresAt: '' }), undefined) + assert.equal(parseNetworkInvite({ version: 1, network: '', nonce: 'a'.repeat(32), issuedAt: 'x', expiresAt: '' }), undefined) + assert.equal(parseNetworkInvite(null), undefined) + // ⛔ 裸 `u` 是保留字:不是合法网名 ⇒ 邀请形状也不成立 + assert.equal(parseNetworkInvite({ version: 1, network: 'u', nonce: 'a'.repeat(32), issuedAt: 'x', expiresAt: '' }), undefined) +}) + +test('A7 🔴 邀请凭据载荷**逐字不含任何密钥本体**(只有网 / nonce / 有效期)', () => { + const s = signerFixture() + const inv = inviteFixture(s, 'ops') + const text = JSON.stringify(inv) + const privBody = s.pem.split('\n').filter((l) => l !== '' && !l.startsWith('-----')).join('') + assert.ok(privBody.length > 40, '夹具本身要有可探的私钥主体') + assert.equal(text.includes(privBody), false, '⛔ 邀请里出现了签名者私钥') + // 键集合也钉死:多一个 `key` 字段就是回归 + assert.deepEqual(Object.keys(inv.doc).sort(), ['expiresAt', 'issuedAt', 'network', 'nonce', 'version']) +}) + +// ── B 组 · 一次性(S2 · J1)────────────────────────────────────────────────── + +test('B1 同一 nonce 第二次使用 ⇒ invite-already-used(一次性成立)', () => { + const dir = mkTmp('ledger') + const led = { dir: join(dir, 'consumed') } + const n = newInviteNonce(randomBytes) + const first = consumeNonce(led, n, 'first') + const second = consumeNonce(led, n, 'second') + assert.equal(first.ok, true, `首次应成功:${JSON.stringify(first)}`) + assert.equal(second.ok, false) + assert.equal(second.reason, 'invite-already-used') + // 台账文件确实落了盘(⇒ "失败"不是因为"根本没写") + assert.equal(readdirSync(led.dir).length, 1) +}) + +test('B2 🔴 并发:12 个进程同时抢同一个 nonce ⇒ **恰好 1 个**拿到', () => { + const dir = mkTmp('race') + const ledDir = join(dir, 'consumed') + mkdirSync(ledDir, { recursive: true }) + const nonce = newInviteNonce(randomBytes) + // 真并发:走**独立进程**(同进程内的"并发"是假并发,⛔ 判不出竞态) + const worker = join(dir, 'worker.cjs') + writeFileSync( + worker, + [ + "const reg = require(process.env.REG_LIB)", + "const r = reg.consumeNonce({ dir: process.env.NONCE_DIR }, process.env.NONCE, 'race')", + "process.stdout.write(r.ok ? 'ok' : r.reason)", + ].join('\n'), + 'utf8', + ) + const regLib = join(process.cwd(), 'lib', 'net', 'relay', 'registry.js') + const results = [] + for (let i = 0; i < 12; i++) { + results.push( + execFileSync(process.execPath, [worker], { + encoding: 'utf8', + env: { ...process.env, NONCE_DIR: ledDir, NONCE: nonce, REG_LIB: regLib }, + }), + ) + } + const oks = results.filter((r) => r === 'ok').length + assert.equal(oks, 1, `⛔ 竞态未守住:ok=${oks}(须恰好 1)|读数 ${JSON.stringify(results)}`) + assert.equal(results.filter((r) => r === 'invite-already-used').length, 11) + assert.equal(readdirSync(ledDir).length, 1) +}) + +test('B3 nonce 形状非法 ⇒ 拒(⛔ 不许"非法也当未用过"放过去)', () => { + const dir = mkTmp('bad-nonce') + const led = { dir: join(dir, 'consumed') } + const r = consumeNonce(led, 'ZZZZ', 'x') + assert.equal(r.ok, false) + assert.equal(r.reason, 'invite-already-used') +}) + +// ── C 组 · 网注册表(S1)──────────────────────────────────────────────────── + +test('C1 空注册表 ⇒ 派生为空 ⇒ 默认拒绝一切拨号(不是"默认放行")', () => { + const r = emptyRegistry() + const d = deriveDialers(r) + assert.equal(d.size, 0) + assert.equal(auditDerivation(r).ok, true) + assert.equal(summarizeNetworks(r).length, 0) +}) + +test('C2 收单只产生 pending;🔴 pending **不进**派生白名单', () => { + const r = emptyRegistry() + const e = applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' }) + assert.equal(e.ok, true) + assert.equal(e.record.status, 'pending') + assert.equal(deriveDialers(r).get('ops')?.has('w-108') ?? false, false, '⛔ 待批节点不许进白名单') +}) + +test('C3 批准才进白名单;移除即退出', () => { + const r = emptyRegistry() + applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' }) + const ap = approveNode(r, 'ops', 'w-108', 'T1') + assert.equal(ap.ok, true) + assert.equal(deriveDialers(r).get('ops')?.has('w-108'), true) + const rm = removeNode(r, 'ops', 'w-108') + assert.equal(rm.ok, true) + assert.equal(deriveDialers(r).get('ops')?.has('w-108') ?? false, false) +}) + +test('C4 🔴 批准一个**没申请过**的节点 ⇒ unknown-node(⛔ 不凭空批准)', () => { + const r = emptyRegistry() + const ap = approveNode(r, 'ops', 'never-applied', 'T1') + assert.equal(ap.ok, false) + assert.equal(ap.reason, 'unknown-node') + // 非法网名也要**具名**,⛔ 不抛异常 + assert.deepEqual(approveNode(r, 'u', 'x', 'T1'), { ok: false, reason: 'unknown-network' }) +}) + +test('C5 已批准节点重复申请 ⇒ **保持 approved**(幂等:重装 / 重跑 join 不该被踢回待批)', () => { + const r = emptyRegistry() + applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' }) + approveNode(r, 'ops', 'w-108', 'T1') + const again = applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'b'.repeat(64), at: 'T2' }) + assert.equal(again.ok, true) + assert.equal(again.record.status, 'approved') + assert.equal(again.record.approvedAt, 'T1', '批准时刻不该被重申请刷新') +}) + +test('C6 解析**严格**:status 拼错 / 键与记录不符 ⇒ undefined(⛔ 不静默修复)', () => { + const r = emptyRegistry() + applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' }) + assert.notEqual(parseRegistry(r), undefined) + const bad = JSON.parse(JSON.stringify(r)) + bad.nodes['ops/w-108'].status = 'aproved' + assert.equal(parseRegistry(bad), undefined) + const misfiled = JSON.parse(JSON.stringify(r)) + misfiled.nodes['u:5/w-108'] = misfiled.nodes['ops/w-108'] + assert.equal(parseRegistry(misfiled), undefined, '键(逻辑名)与记录里的网/节点名必须一致') +}) + +test('C7 落盘往返:稳定排序 ⇒ 无改动时字节级一致', () => { + const dir = mkTmp('roundtrip') + const file = join(dir, 'nodes.json') + const r = emptyRegistry() + applyApplication(r, { network: 'u:5', hostId: 'pc-1', nodeKey: 'c'.repeat(64), at: 'T0' }) + applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' }) + saveRegistry(file, r) + const first = readFileSync(file, 'utf8') + const back = loadRegistry(file) + saveRegistry(file, back) + assert.equal(readFileSync(file, 'utf8'), first, '往返必须逐字一致(否则 diff 永远"有改动")') + assert.equal(loadRegistry(join(dir, 'absent.json')).nodes && Object.keys(loadRegistry(join(dir, 'absent.json')).nodes).length, 0) +}) + +// ── D 组 · 白名单派生(S4 · J5)───────────────────────────────────────────── + +test('D1 分桶形态:两张网各自独立,同一 hostId 在两网**不串桶**', () => { + const r = emptyRegistry() + applyApplication(r, { network: 'ops', hostId: 'pc-1', nodeKey: 'a'.repeat(64), at: 'T0' }) + applyApplication(r, { network: 'u:5', hostId: 'pc-1', nodeKey: 'a'.repeat(64), at: 'T0' }) + approveNode(r, 'ops', 'pc-1', 'T1') + approveNode(r, 'u:5', 'pc-1', 'T1') + const d = deriveDialers(r) + assert.equal(d.get('ops')?.has('pc-1'), true) + assert.equal(d.get('u:5')?.has('pc-1'), true) + // 🔴 独立性判据:**移除一张网里的它**,另一张网**不受影响** + removeNode(r, 'u:5', 'pc-1') + assert.equal(deriveDialers(r).get('ops')?.has('pc-1'), true, '⛔ 移除 u:5 的 pc-1 连带把 ops 的也删了') + assert.equal(deriveDialers(r).get('u:5')?.has('pc-1') ?? false, false) +}) + +test('D2 🔴 派生结果**必须能被 normalizeDialers 吃**(与手写 drop-in 同一条归一化入口)', () => { + const r = emptyRegistry() + for (const [net, host] of [ + ['ops', 'w-108'], + ['u:5', 'pc-1'], + ['lab-net', 'lab-1'], + ]) { + applyApplication(r, { network: net, hostId: host, nodeKey: 'd'.repeat(64), at: 'T0' }) + approveNode(r, net, host, 'T1') + } + const derived = deriveDialers(r) + const normalized = normalizeDialers(derived) + assert.equal(normalized.size, 3, `期望 3 张网,实际 ${[...normalized.keys()].join(',')}`) + assert.equal(normalized.get('ops')?.has('w-108'), true) + assert.equal(normalized.get('u:5')?.has('pc-1'), true) + assert.equal(normalized.get('lab-net')?.has('lab-1'), true) + // ⛔ 跨网串桶 = 结构性隔离失效;这里钉死"每张网只看得到自己的成员" + assert.equal(normalized.get('ops')?.has('pc-1') ?? false, false) + assert.equal(normalized.get('u:5')?.has('w-108') ?? false, false) +}) + +test('D3 auditDerivation:人工造"错桶" ⇒ misfiled **点名**(跨网零共享的机器判据)', () => { + const r = emptyRegistry() + applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' }) + approveNode(r, 'ops', 'w-108', 'T1') + applyApplication(r, { network: 'u:5', hostId: 'pc-1', nodeKey: 'b'.repeat(64), at: 'T0' }) + approveNode(r, 'u:5', 'pc-1', 'T1') + assert.equal(auditDerivation(r).ok, true) + // 造错桶:把 u:5 的 pc-1 塞进 ops 桶(模拟"实现把两张网合并了") + const bad = new Map(deriveDialers(r)) + bad.get('ops').add('pc-1') + const audit = auditDerivation(r, bad) + assert.equal(audit.ok, false) + assert.ok(audit.misfiled.some((m) => m.includes('ops/pc-1')), `misfiled 应点名 ops/pc-1:${JSON.stringify(audit.misfiled)}`) + // 另一类错:集合比 approved 多了/少了 + const short = new Map() + short.set('ops', new Set()) + short.set('u:5', new Set(['pc-1'])) + const audit2 = auditDerivation(r, short) + assert.equal(audit2.ok, false) + assert.ok(audit2.mismatches.some((m) => m.includes('ops') && m.includes('缺[w-108]')), JSON.stringify(audit2.mismatches)) +}) + +test('D4 deriveDropIn 用**逻辑名**形态、且写明"手写 drop-in 是应急通道"', () => { + const r = emptyRegistry() + applyApplication(r, { network: 'lab-net', hostId: 'lab-1', nodeKey: 'e'.repeat(64), at: 'T0' }) + approveNode(r, 'lab-net', 'lab-1', 'T1') + const content = deriveDropIn(r, { network: 'lab-net' }) + assert.match(content, /Environment="DSHS_RELAY_DIALERS=lab-net\/lab-1"/, content) + assert.match(content, /应急通道/, '必须写明"手写 drop-in 是应急通道,⛔ 不删"') + assert.match(content, /\[Service\]/, '必须是 systemd drop-in 形态') +}) + +// ── E 组 · join 编排(S3 · J4/J6)────────────────────────────────────────── + +test('E1 四步真跑全绿:真生成密钥 + 真落盘 + 真出申请单', async () => { + const dir = mkTmp('join-ok') + const s = signerFixture() + const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo()) + assert.equal(out.ok, true, `期望成功:${JSON.stringify(out)}`) + assert.deepEqual( + out.steps.map((x) => x.step), + [...JOIN_STEPS], + '步迹必须覆盖四步且顺序一致', + ) + assert.equal(existsSync(join(dir, 'node.key')), true) + assert.equal(existsSync(join(dir, 'node.json')), true) + assert.equal(existsSync(join(dir, 'application.json')), true) +}) + +test('E2 🔴 申请单**字节级**不含私钥,但含公钥(私钥不出机)', async () => { + const dir = mkTmp('join-leak') + const s = signerFixture() + const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo()) + assert.equal(out.ok, true) + const app = readFileSync(join(dir, 'application.json'), 'utf8') + const priv = readFileSync(join(dir, 'node.key'), 'utf8') + const body = priv.split('\n').filter((l) => l !== '' && !l.startsWith('-----')).join('') + assert.ok(body.length > 40) + assert.equal(app.includes(body), false, '⛔ 申请单里出现了节点私钥本体') + assert.equal(app.includes(out.nodeKey), true, '公钥必须在(否则控制面无法登记)') + // 本机配置里也只能记"私钥在哪",不能记私钥本身 + assert.equal(readFileSync(join(dir, 'node.json'), 'utf8').includes(body), false, '⛔ 本机配置里出现了私钥本体') +}) + +test('E3 节点密钥已存在 ⇒ **复用**(重跑 join 不换钥匙,公钥不变)', async () => { + const dir = mkTmp('join-reuse') + const s = signerFixture() + const first = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo()) + assert.equal(first.ok, true) + const before = readFileSync(join(dir, 'node.key'), 'utf8') + const second = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo()) + assert.equal(second.ok, true) + assert.equal(second.nodeKey, first.nodeKey, '⛔ 重跑 join 换了公钥 ⇒ 会导致控制面登记与实物不符') + assert.equal(readFileSync(join(dir, 'node.key'), 'utf8'), before) + assert.match(second.steps[1].detail, /复用既有/) +}) + +test('E4 ⛔ 既没 --portal 也没 --out ⇒ register-malformed(不静默成功)', async () => { + const dir = mkTmp('join-nochan') + const s = signerFixture() + const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub, outFile: '' }), fakeIo()) + assert.equal(out.ok, false) + assert.equal(out.step, 'register') + assert.equal(out.reason, 'register-malformed') +}) + +test('E5 HTTP 通道:4xx 必须带回控制面的原因码(⛔ 不许说成"网络不通")', async () => { + const dir = mkTmp('join-http') + const s = signerFixture() + const io = fakeIo({ postReply: { status: 409, body: '{"error":"invite-already-used"}' } }) + const out = await runJoin( + joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub, outFile: '', portalUrl: 'https://cp.example/dshs-overlay/join' }), + io, + ) + assert.equal(out.ok, false) + assert.equal(out.reason, 'register-rejected') + assert.match(out.detail, /invite-already-used/, '控制面的原因码必须原样带回来') +}) + +test('E6 HTTP 通道:连不上 ⇒ register-unreachable(与"被拒"可分)', async () => { + const dir = mkTmp('join-http2') + const s = signerFixture() + const io = fakeIo() + io.post = async () => { + throw new Error('ECONNREFUSED') + } + const out = await runJoin( + joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub, outFile: '', portalUrl: 'https://cp.example/x' }), + io, + ) + assert.equal(out.ok, false) + assert.equal(out.reason, 'register-unreachable') +}) + +test('E7 失败**带步迹**:停在第一步时能看到是哪一步、哪种原因', async () => { + const dir = mkTmp('join-trace') + const s = signerFixture() + const bad = inviteFixture(s, 'ops') + bad.sig = `${bad.sig.slice(0, -6)}AAAAAA` + const out = await runJoin(joinOpts(dir, 'ops', bad, { signerPub: s.pub }), fakeIo()) + assert.equal(out.ok, false) + assert.equal(out.step, 'verify-invite') + assert.equal(out.steps.length, 1) + assert.equal(out.steps[0].ok, false) + // 🔴 第一步失败 ⇒ ⛔ 不许产生后面几步的副作用 + assert.equal(existsSync(join(dir, 'node.key')), false, '⛔ 凭据没过就生成了密钥(无谓副作用)') +}) + +test('E8 无受信签名者 ⇒ 连密钥都不生成(不可验 = 不接受,且不浪费副作用)', async () => { + const dir = mkTmp('join-nosigner') + const s = signerFixture() + const out = await runJoin( + { ...joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), trustedSigners: [] }, + fakeIo(), + ) + assert.equal(out.ok, false) + assert.equal(out.reason, 'invite-no-trusted-signer') + assert.equal(existsSync(join(dir, 'node.key')), false) +}) + +// ── F 组 · 机器判据:「⛔ 不许静默拒绝」────────────────────────────────────── + +test('F1 join / registry 产物里**零空 catch 块**(静默失败的可断言面)', () => { + const files = [join(process.cwd(), 'lib', 'net', 'relay', 'join.js'), join(process.cwd(), 'lib', 'net', 'relay', 'registry.js')] + const emptyCatch = /catch\s*(\([^)]*\))?\s*\{\s*\}/ + // ⚠️ **先剥注释再扫** —— 本文件的 doc 注释里**引用**了 `catch {}` 这个写法(在讲"我们不用它"), + // 不剥就会把自己讲道理的那句话判成违规(= 判据打在文字上,不打在代码上)。 + const stripComments = (text) => + text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1') + const offenders = [] + for (const f of files) { + assert.equal(existsSync(f), true, `缺产物 ${f}(先 npm run build)`) + const code = stripComments(readFileSync(f, 'utf8')) + if (emptyCatch.test(code)) offenders.push(f) + } + assert.deepEqual(offenders, [], `⛔ 出现空 catch(= 静默拒绝):${offenders.join(', ')}`) +}) + +test('F2 节点私钥落点权限:**只在 Linux 上可判**,Windows 上判据必须说"不可判"而非 PASS', async () => { + const dir = mkTmp('join-mode') + const s = signerFixture() + const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo()) + assert.equal(out.ok, true) + const mode = statSync(join(dir, 'node.key')).mode & 0o777 + if (process.platform === 'linux') { + assert.equal(mode.toString(8), '600', `⛔ 私钥权限不是 600:${mode.toString(8)}`) + } else { + // 🔴 「没测到」与「不成立」必须可分 —— ⛔ 不许把"这台机器测不出来"当绿 + assert.notEqual(process.platform, 'linux') + assert.ok(true, `platform=${process.platform} ⇒ 权限判据**本平台不可判**(真机腿 = 47 上 stat -c %a 实测)`) + } +}) + +test('E9 CLI 回环:join 产出的申请单必须能被控制面 apply 的解析读回(真机首轮栽在这)', async () => { + const dir = mkTmp('join-roundtrip') + const s = signerFixture() + const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo()) + assert.equal(out.ok, true) + // 🔴 这两步合起来就是控制面 `apply` 的真实路径:读文件 → 解析申请单 + const app = readApplicationFile(join(dir, 'application.json')) + assert.equal(app.hostId, 'node-a') + assert.equal(app.network, 'ops') + assert.equal(app.nodeKey, out.nodeKey) + const v = verifyNetworkInvite(app.invite.doc, app.invite.sig, [s.pub], { network: 'ops' }) + assert.equal(v.ok, true, `申请单里内嵌的邀请必须能验通:${JSON.stringify(v)}`) + const led = { dir: join(dir, 'consumed') } + assert.equal(consumeNonce(led, v.doc.nonce, 'apply').ok, true) + assert.equal(consumeNonce(led, v.doc.nonce, 'apply again').ok, false) + const r = emptyRegistry() + const e = applyApplication(r, { network: app.network, hostId: app.hostId, nodeKey: app.nodeKey, at: 'T0' }) + assert.equal(e.ok, true) + assert.equal(e.record.status, 'pending', '⛔ 收单只产生 pending(批准权在控制面)') + // 形状严格:缺 invite / nodeKey 非 64 hex / 空对象 ⇒ undefined + assert.equal(parseApplication({ hostId: 'a', network: 'ops', nodeKey: 'x'.repeat(64), appliedAt: 't', invite: {} }), undefined) + assert.equal(parseApplication({ hostId: 'a', network: 'ops', nodeKey: 'x', appliedAt: 't', invite: { doc: {}, sig: 'x' } }), undefined) + assert.equal(parseApplication({}), undefined) + assert.equal(parseApplication(null), undefined) +}) + +// ── E10:**CLI 级**真回环(真子进程,⛔ 不是调库)───────────────────────────── +// 🔴 为什么必须有这条:E9 是**调库**回环,它绕过了 CLI 的**参数解析**, +// 所以「`apply --file <申请单>` 把注册表文件也指到申请单」这个真机缺陷 +// **E9 抓不到**(真机首轮就栽在这:收单炸在 `loadReg`,而 nonce 已被占位 +// ⇒ 表现为「收单失败 + 同一张邀请再也用不了」)。 +// 本用例只走 `node …`,任何参数键名混用都会在这里具名转红。 +test('E10 CLI 真回环:`apply --file` ⛔ 不得改写注册表路径(--file 与 --registry 两键分离)', () => { + const repo = resolve(fileURLToPath(new URL('..', import.meta.url))) + const admit = join(repo, 'scripts', 'overlay-node-admit.cjs') + const joinCli = join(repo, 'scripts', 'overlay-node-join.cjs') + const keyring = join(repo, 'scripts', 'overlay-keyring.cjs') + + const root = mkTmp('cli-roundtrip') + const regDir = join(root, 'reg') + const run = (file, args) => + execFileSync(process.execPath, [file, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) + const runFail = (file, args) => { + try { + run(file, args) + return '' + } catch (err) { + return `${err.stdout ?? ''}${err.stderr ?? ''}` + } + } + + // (1) 一次性测试签名者 / init / 签发邀请 + run(keyring, ['init-signer', '--key', join(root, 'signer.key')]) + const pub = readFileSync(join(root, 'signer.key.pub'), 'utf8').trim() + run(admit, ['init', '--dir', regDir]) + run(admit, [ + 'issue-invite', '--network', 'lab-net', + '--signer-key', join(root, 'signer.key'), + '--out', join(root, 'invite.json'), '--dir', regDir, + ]) + + // (2) 节点一条命令 join ⇒ 申请单 + run(joinCli, [ + '--network', 'lab-net', '--host', 'lab-1', + '--invite', join(root, 'invite.json'), '--signer-pub', pub, + '--key', join(root, 'node.key'), '--config', join(root, 'node.json'), + '--out', join(root, 'app.json'), + ]) + + // (3) 🔴 判据本体:`apply --file <申请单>` 必须**成功**,且注册表仍落在 regDir + const outApp = run(admit, ['apply', '--file', join(root, 'app.json'), '--signer-pub', pub, '--dir', regDir]) + assert.match(outApp, /已收单/, '⛔ `apply --file` 必须成功收单') + const regFile = join(regDir, 'nodes.json') + assert.equal(existsSync(regFile), true, '⛔ 注册表必须落在 `--dir` 下的 nodes.json') + const saved = loadRegistry(regFile) + assert.equal(saved.nodes['lab-net/lab-1']?.status, 'pending', '⛔ 收单只产生 pending') + + // (4) 同一张邀请二次收单 ⇒ 必须是 **invite-already-used**; + // ⛔ 一旦出现「注册表 …形状非法」就说明 `--file` 又被当成注册表文件了(键名混用回归) + const err2 = runFail(admit, ['apply', '--file', join(root, 'app.json'), '--signer-pub', pub, '--dir', regDir]) + assert.match(err2, /invite-already-used/, '⛔ 二次收单必须具名拒 invite-already-used') + assert.doesNotMatch(err2, /形状非法/, '⛔ 出现"形状非法"= `--file` 键名混用回归') + + // (5) 批准 ⇒ 派生里必须出现这条拨号 + run(admit, ['approve', '--network', 'lab-net', '--host', 'lab-1', '--dir', regDir]) + const d = run(admit, ['derive', '--network', 'lab-net', '--dir', regDir]) + assert.match(d, /Environment="DSHS_RELAY_DIALERS=lab-net\/lab-1"/, '⛔ approved 必须进派生白名单') + + // (6) `--registry` 是注册表文件的唯一覆盖键 —— 指到别处 ⇒ 那里为空表(= 确实换了文件) + const dAlt = run(admit, ['list', '--registry', join(root, 'alt.json'), '--dir', regDir]) + assert.match(dAlt, /0 张网 0 个节点/, '⛔ --registry 必须真的改写注册表文件路径') +})