feat(overlay): 覆盖网络线 序㉔–㊸ 补提交 —— 源码与已部署产物对齐
把这批「已 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)
This commit is contained in:
1 parent
09ce76f3af
commit
c452013129
18 files changed
+6605
-79
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -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 <file>\` 直接指定 ② 把参数表所在目录(一行一个)写进上面的注册文件 ③ 在参数表所在目录下运行`,
|
||||
)
|
||||
}
|
||||
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')
|
||||
|
||||
@@ -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 也⛔ 不打印任何私钥。
|
||||
* ③ 🔴 **失败一律具名**(`✗ <命令> 失败:<reason> — <detail>` 走 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)
|
||||
})
|
||||
@@ -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 <hex> --out /tmp/application.json
|
||||
*
|
||||
* # ② HTTP 通道(控制面已开放接收入口时;才谈得上"一条命令完成")
|
||||
* node scripts/overlay-node-join.cjs --network ops --invite <json|file> \
|
||||
* --signer-pub <hex> --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)
|
||||
})
|
||||
+797
-63
File diff suppressed because it is too large.
Load diff
@@ -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<Buffer | undefined>
|
||||
}
|
||||
|
||||
/** `peer` 接线面的只读快照(探针 / 管理面读这一份)。 */
|
||||
export interface PeerWireSnapshot {
|
||||
/** 通道优先级(= {@link PEER_CHANNEL_ORDER} 的副本)。 */
|
||||
order: PeerChannelName[]
|
||||
/** 每条通道是否已装配。 */
|
||||
wired: Record<PeerChannelName, boolean>
|
||||
/** 逐通道"真问了"的次数。 */
|
||||
attempts: Record<PeerChannelName, number>
|
||||
/** 逐通道**命中**(=取回字节 + **复算通过**)。 */
|
||||
hits: Record<PeerChannelName, number>
|
||||
/** 逐通道**未命中**(问了,对端说没有)。 */
|
||||
misses: Record<PeerChannelName, number>
|
||||
/** 逐通道**抛错**(与未命中可区分)。 */
|
||||
errors: Record<PeerChannelName, number>
|
||||
/** 逐通道**不可用**(根本没问)。 */
|
||||
unavailable: Record<PeerChannelName, number>
|
||||
/** 不可用原因的具名分布(`<channel>:<reason>` → 次数)。 */
|
||||
downReasons: Record<string, number>
|
||||
/** 🔴 **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<Buffer | undefined> {
|
||||
// ⛔ 防御性:`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<Record<PeerChannelName, PeerBlockChannel>>
|
||||
/**
|
||||
* 🆕 序㊶ · S6:内置直连通道的选项。
|
||||
* - 缺省 ⇒ 装配(开关读 `process.env` ⇒ 与 `DSHS_OVERLAY_DIRECT`「**缺省即开**」一致);
|
||||
* - `false` ⇒ **不装配**(该通道缺 ⇒ 具名 `not-wired`)。
|
||||
*/
|
||||
direct?: false | { env?: Readonly<Record<string, string | undefined>>; 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<PeerChannelName, PeerBlockChannel>()
|
||||
/** 🆕 S6:直连通道的开关解析结果(观测面用;未装配 ⇒ `undefined`)。 */
|
||||
private readonly directSwitch: DirectSwitchState | undefined
|
||||
/** 🆕 S6:直连候选地址表(**只经 `noteDirectCandidate` 写入**,⛔ 不从别处塞)。 */
|
||||
private readonly directAddrs = new Map<string, DirectAddress[]>()
|
||||
/**
|
||||
* 🆕 S6:接线面计数。
|
||||
*
|
||||
* ⚠️ `downReasons` 的键是 `` `${channel}:${reason}` `` —— 用字符串键而不是嵌套对象,
|
||||
* 是为了让 `JSON.stringify` 出来的 `/status` 一眼可读、探针逐键断言也简单。
|
||||
*/
|
||||
private readonly wire = {
|
||||
attempts: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
|
||||
hits: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
|
||||
misses: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
|
||||
errors: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
|
||||
unavailable: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
|
||||
downReasons: {} as Record<string, number>,
|
||||
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(过滤后为空)' }
|
||||
// ⚠️ 键 = **逻辑名** `<network>/<hostId>`(与 `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<PeerChannelName, boolean>
|
||||
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() }),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<string, unknown>)) {
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, ReadonlySet<string>>
|
||||
/** 收到这条消息的会话所属的网 + 对端 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<string, number>
|
||||
}
|
||||
|
||||
/**
|
||||
* 候选收发的记账器。
|
||||
*
|
||||
* ⚠️ **每条进路都必须记账**:`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<string, number> = {}
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Record<string, string | undefined>>,
|
||||
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<CandidateContext, 'maxAddrs' | 'ttlMs'>): 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<void> },
|
||||
offset = 0,
|
||||
): Promise<PunchAttempt | { 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 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<string, unknown>).direct
|
||||
return typeof v === 'boolean' ? v : undefined
|
||||
}
|
||||
|
||||
/** 读一读节点本地配置(**形状不对 ⇒ 抛**;文件不存在 ⇒ `undefined`)。 */
|
||||
export function readNodeConfig(file: string, io: { exists: (p: string) => boolean; read: (p: string) => string }): Record<string, unknown> | 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<string, unknown>
|
||||
}
|
||||
|
||||
/** 写回结果(**失败必须具名** —— 与 `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`**(先写 `<file>.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<string, unknown> | 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 }
|
||||
}
|
||||
@@ -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 {
|
||||
/** 对端逻辑名(`<network>/<hostId>`,冷却按它做键)。 */
|
||||
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<string, number>()
|
||||
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<string>()
|
||||
private lastError = ''
|
||||
|
||||
/** 已成功绑定的端口(`0` = 尚未绑定)。 */
|
||||
port = 0
|
||||
|
||||
constructor(private readonly host = '0.0.0.0') {}
|
||||
|
||||
/** 绑定(`port = 0` ⇒ 由内核分配;真机路径给参数表区间里的口)。 */
|
||||
open(port: number): Promise<void> {
|
||||
return new Promise<void>((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<void> },
|
||||
): Promise<PunchAttempt> {
|
||||
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<PunchAttempt, 'ok' | 'reason' | 'detail'> = {
|
||||
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<void> {
|
||||
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<Socket> {
|
||||
return new Promise<Socket>((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<void> },
|
||||
): Promise<PunchPairResult> {
|
||||
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<PunchAttempt> =>
|
||||
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
|
||||
}
|
||||
+102
-1
@@ -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'
|
||||
@@ -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<JoinOutcome> {
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
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
|
||||
}
|
||||
@@ -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}(显式命名网 · 可作独立覆盖网络)`
|
||||
}
|
||||
|
||||
/** 节点逻辑名 = `<network_id>/<hostId>`。**唯一拼法**(别在调用方拼)。 */
|
||||
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<string, ReadonlySet<string>>,
|
||||
network: string,
|
||||
hostId: string,
|
||||
): boolean {
|
||||
return map.get(network)?.has(hostId) === true
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型判别:`Map` 形态 vs 扁平 `Set` 形态。
|
||||
*
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* 覆盖网络 **S1+S2+S4 · 网注册表 + 准入凭据 + 白名单派生**(序㊱ · 「节点一键加入与分组准入」P1)。
|
||||
*
|
||||
* ## 它解决的确切问题(缺口 ②:「分组准入门面」)
|
||||
* `server.ts` 的拨号方白名单**机制已经完整**(`dialers: Map<network, Set<hostId>>`、默认拒绝、
|
||||
* 跨网在"能不能拨"这一步就走不到 —— 结构性隔离)。缺的是**把它变成可管理的东西**:
|
||||
* 今天配置靠**手写字符串**(`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<string, unknown>
|
||||
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 的台账文件路径(`<dir>/<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
|
||||
}
|
||||
|
||||
/** 注册表文件(键 = **逻辑名** `<network>/<hostId>`,与 `server.ts` 会话表同口径)。 */
|
||||
export interface NodesRegistry {
|
||||
version: number
|
||||
nodes: Record<string, NetworkNodeRecord>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
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<string, NetworkNodeRecord> = {}
|
||||
for (const [key, value] of Object.entries(nodesRaw as Record<string, unknown>)) {
|
||||
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<string, unknown>
|
||||
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<string, NetworkNodeRecord> = {}
|
||||
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<string, NetworkSummary>()
|
||||
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<string, Set<string>> {
|
||||
const out = new Map<string, Set<string>>()
|
||||
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<string>()
|
||||
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<string, Set<string>>): DerivationAudit {
|
||||
const d = derived ?? deriveDialers(reg)
|
||||
const mismatches: string[] = []
|
||||
const misfiled: string[] = []
|
||||
const byNetwork = new Map<string, Set<string>>()
|
||||
const ownerOf = new Map<string, string>()
|
||||
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<string>()
|
||||
bucket.add(rec.hostId)
|
||||
byNetwork.set(rec.network, bucket)
|
||||
}
|
||||
for (const network of new Set<string>([...byNetwork.keys(), ...d.keys()])) {
|
||||
const want = byNetwork.get(network) ?? new Set<string>()
|
||||
const got = d.get(network) ?? new Set<string>()
|
||||
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<string>())].sort()
|
||||
// 逻辑名形态(`<网>/<hostId>`)—— `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(' · ')}`
|
||||
}
|
||||
@@ -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**)。
|
||||
* ② **只写"跟着这台机器走"的那一项** —— 直连开关放**本机配置**(`<NODE_CONFIG_FILE>.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 > 本机配置 > 缺省)`
|
||||
: '已生效(重启节点服务后按本文件生效)',
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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<FastifyInstance
|
||||
await app.register(dshRoutes)
|
||||
await app.register(domainRoutes)
|
||||
await app.register(overlayRoutes)
|
||||
// 序㊵(P2/S5):覆盖网络**管理面**(节点清单只读 + 直连开关读写)—— 全部走 requireAdmin
|
||||
await app.register(overlayNodeRoutes)
|
||||
await app.register(skillRoutes)
|
||||
await app.register(whitelistRoutes)
|
||||
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* 覆盖网络 **直连(打洞)** 单测(序㊵ · P2 · S5)。
|
||||
*
|
||||
* ⚠️ **刻意不进 `npm test`** —— 那是**硬编码文件列表**,加进去会改测试总数(既有口径:
|
||||
* `test/overlay-join.test.mjs` 同样"刻意不进")。本文件单独跑:
|
||||
* ```bash
|
||||
* node --test test/overlay-direct.test.mjs
|
||||
* ```
|
||||
*
|
||||
* | 用例 | 判据 | 断言的东西 |
|
||||
* |---|---|---|
|
||||
* | T1 | D3 |开关解析 | 缺省=开;关集/开集;**非法值 ⇒ `null`(⛔ 不静默取缺省)**;本机配置次之 |
|
||||
* | T2 | D2 |关闭生效 | 关闭 ⇒ **零 UDP socket** + **零候选**(⛔ 不是"少发几条") |
|
||||
* | T3 | D1 |候选准入矩阵 | 同网+白名单内接受;跨网 / 替第三人申报 / 白名单外 / 本机不在白名单 / 坏形状 / 夹带凭据 / 坏地址 / 超限 / 过期 **各自具名拒绝**;`silentRejections = 0` |
|
||||
* | T4 | D1 |**准入策略与 server.ts 等价** | 读 `server.ts` 源码核对两处闸门与 `isAllowedDialer` **同策略**(⛔ 防"两处写分叉") |
|
||||
* | T5 | D5 |打洞成功路径 | NAT 模拟器 + 真 `dgram` ⇒ 双向成立(`punchOk ≥ 1`) |
|
||||
* | T6 | D5 |**单向不算直连** | 一个方向成立 ⇒ 判死 `one-way`(⛔ 不许把单向当成功) |
|
||||
* | T7 | D6 |失败判死有界 | 对端不响应 ⇒ 窗内判死,且耗时**有界**(⛔ 不无限重试) |
|
||||
* | T8 | D6 |冷却 | 判死后二次尝试被挡(**且不开 socket**);`ms = 0` ⇒ **构造即抛** |
|
||||
* | T9 | D3/D4 |join 回读 + 提示 | 本机配置读回 `direct = true`;`--direct 0` 被尊重 |
|
||||
* | T10 | D4 |提示可行动 | 三段齐(谁可能连 / 怎么关 / 关掉影响什么),且**含开关键与关值** |
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import { test } from 'node:test'
|
||||
import { createSocket } from 'node:dgram'
|
||||
|
||||
import { signPayloadWith } from '../lib/net/relay/identity.js'
|
||||
import {
|
||||
CandidateLedger,
|
||||
DEFAULT_DIRECT_COOLDOWN_MS,
|
||||
DEFAULT_DIRECT_ENABLED,
|
||||
DIRECT_CAND_MAX_ADDRS,
|
||||
DIRECT_ENV_KEY,
|
||||
DIRECT_OFF_VALUES,
|
||||
DirectCooldown,
|
||||
DirectPath,
|
||||
NODES_REGISTRY_VERSION,
|
||||
PUNCH_PORT_BASE,
|
||||
PUNCH_PORT_SPAN,
|
||||
directFromNodeConfig,
|
||||
directHintLines,
|
||||
directHintText,
|
||||
encodeDirectMessage,
|
||||
generateAuthorityKey,
|
||||
generateNodeKey,
|
||||
isAllowedDialer,
|
||||
isValidAddress,
|
||||
networkInvitePayload,
|
||||
newInviteNonce,
|
||||
nodeJoinIo,
|
||||
normalizeDialers,
|
||||
publicKeyOfPrivate,
|
||||
readNodeConfig,
|
||||
resolveDirectSwitch,
|
||||
runJoin,
|
||||
runPunchAttempt,
|
||||
runPunchPair,
|
||||
writeNodeConfigDirect,
|
||||
} from '../lib/net/relay/index.js'
|
||||
|
||||
const sleep = (ms) => 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, '⛔ 载荷里不可能夹带凭据字段(构造侧结构性排除)')
|
||||
})
|
||||
@@ -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 <cli> …`,任何参数键名混用都会在这里具名转红。
|
||||
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 必须真的改写注册表文件路径')
|
||||
})
|
||||
Reference in new issue
Block a user