把这批「已 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)
521 lines
25 KiB
JavaScript
521 lines
25 KiB
JavaScript
#!/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)
|
||
})
|