Files
dsh_shenxian/scripts/overlay-node-join.cjs
admin c452013129 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)
2026-09-18 16:37:09 +08:00

184 lines
8.1 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
})