feat(overlay): 覆盖网络线序①–⑮ 代码与测试产物入库

覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 relay 子系统 src/net/relay/**(wire/duplex/server/client/dialer/switcher/directory/identity/keys/placement/network/addr-override/main/index)
- 新增 src/worker/relay-tunnel.ts、src/web/routes/overlay.ts
- 新增观测/演练脚本 overlay-probe、overlay-failover-drill、overlay-keyring、overlay-holepunch、overlay-jitter、overlay-wan、overlay-relaykey-add、relay-mem-calibrate
- 新增测试 12 个(relay / relay-failover / remote-spawner / instance-port / overlay-{network,auth,bootstrap,identity} / remote-user-fs 等)

验收基线:npm test = 162 pass / 0 fail / 1 skip;--scene all = 12 PASS / 0 SKIP / 0 FAIL;overlay-probe = 12/12
This commit is contained in:
admin committed 2026-09-17 17:03:27 +08:00
1 parent 640813e84e
commit 146c3d25ef
53 files changed
+16187 -63

No files matched your search

File diff suppressed because it is too large. Load diff
+302
View File
@@ -0,0 +1,302 @@
#!/usr/bin/env node
/**
* 覆盖网络 序⑥ · S4 打洞可行性探测(⛔ 一次性脚本、不进产品路径)。
*
* ## 口径(🔴 本单最重要的一条已定项)
* 本脚本测的是**「该网络能不能打洞」**(NAT 映射/过滤行为),
* ⛔ **不是**"本系统打洞成功率" —— 仓库全仓零 UDP/穿透代码(`src/` 无 dgram/STUN),
* 后者需要先有实现(见交接单_最小形态真机批次_20260917 §4.1-1)。
*
* ## 两个角色
* - `--observer`(**跑在 47**,唯一具备公网直连观察面的一端):绑两个 UDP 口
* (两个口是**故意的** —— 同一个 socket 发向两个不同目的口,若两次看到的源口相同 ⇒
* 该 NAT 是**端点无关映射**(cone 型),这正是可打洞的主判据),登记 `name → ip:port`;
* - `--probe`(跑在每个节点):同一个 socket 依次 `REG` 到两个观察口 → 拿到**自己的两次映射**
* → 向观察口要 `PEERS` 表 → 向每个对端的映射每 `--gap` ms 发 1 包共 `--rounds` 包,
* 全程收包 ⇒ 记录"收到了谁"。
*
* ## 判据
* - 任一方收到对方 ≥ 1 包 ⇒ 该**方向**可打洞;逐对两方向分别记;
* - `mappingX === mappingY` ⇒ 端点无关映射(cone);不等 ⇒ 对称型(打洞概率低);
* - **包到达但被本机防火墙拦掉**与"对端没发出来"在数据上同形 ⇒ 因此每次实验都带
* **同机回环控制对**(47 的观察面自身既是 sender 也是 receiver,见 §8 的对照说明)。
*
* 用法:
* node overlay-holepunch.cjs --observer --port-x 21100 --port-y 21101 --token <t> --secs 90
* node overlay-holepunch.cjs --probe --obs 47.77.182.89 --port-x 21100 --port-y 21101 \
* --token <t> --name w-dev --peers w-47u,w-106u
*
* @module scripts/overlay-holepunch
*/
'use strict'
const dgram = require('node:dgram')
function parseArgs(argv) {
const out = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (!a.startsWith('--')) continue
const k = a.slice(2)
const v = argv[i + 1]
if (v === undefined || v.startsWith('--')) out[k] = true
else {
out[k] = isNaN(Number(v)) || v === '' ? v : Number(v)
i++
}
}
return out
}
const args = parseArgs(process.argv.slice(2))
const log = (s) => process.stderr.write(`[hp] ${s}\n`)
const out = (o) => require('node:fs').writeSync(1, `### RESULT ### ${JSON.stringify(o)}\n`)
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// ─────────────────────────── observer ───────────────────────────
function observer(portX, portY, token, secs) {
/** name → { X: 'ip:port', Y: 'ip:port' } */
const reg = {}
const seen = []
const socks = []
const mk = (tag, port) => {
const s = dgram.createSocket('udp4')
s.on('error', (e) => log(`obs ${tag} error ${e.message}`))
s.on('message', (msg, rinfo) => {
const src = `${rinfo.address}:${rinfo.port}`
const text = msg.toString('utf8')
if (!text.startsWith(token)) {
seen.push({ tag, src, text: text.slice(0, 40), at: Date.now() })
return
}
const parts = text.split(' ')
const cmd = parts[1]
if (cmd === 'REG') {
const name = parts[2]
reg[name] = reg[name] || {}
reg[name][tag] = src
s.send(`${token} ACK ${tag} ${src}`, rinfo.port, rinfo.address)
} else if (cmd === 'PEERS') {
s.send(`${token} PEERS ${JSON.stringify(reg)}`, rinfo.port, rinfo.address)
}
})
s.bind(port, '0.0.0.0', () => log(`observer ${tag} 绑 0.0.0.0:${port}`))
socks.push(s)
}
mk('X', portX)
mk('Y', portY)
setTimeout(() => {
out({ role: 'observer', reg, unauthenticated: seen.slice(0, 20), unauthenticatedCount: seen.length })
process.exit(0)
}, secs * 1000)
}
// ─────────────────────────── probe ───────────────────────────
async function probe(o) {
const token = o.token
const name = o.name
const peers = String(o.peers || '').split(',').filter((s) => s !== '')
const sock = dgram.createSocket('udp4')
const receipts = {}
const acks = {}
let peersTable = null
sock.on('error', (e) => log(`probe error ${e.message}`))
sock.on('message', (msg, rinfo) => {
const text = msg.toString('utf8')
if (!text.startsWith(token)) return
const parts = text.split(' ')
const src = `${rinfo.address}:${rinfo.port}`
if (parts[1] === 'ACK') acks[parts[2]] = parts[3]
else if (parts[1] === 'PEERS') {
try {
peersTable = JSON.parse(text.slice(token.length + 7))
} catch (e) {
log(`PEERS 解析失败:${e.message}`)
}
} else if (parts[1] === 'PUNCH') {
receipts[src] = (receipts[src] || 0) + 1
}
})
await new Promise((r) => sock.bind(0, '0.0.0.0', r))
const localPort = sock.address().port
const to = (port) => new Promise((r) => sock.send(`${token} REG ${name}`, port, o.obs, r))
for (let i = 0; i < 5; i++) {
await to(o['port-x'])
await sleep(200)
}
for (let i = 0; i < 5; i++) {
await to(o['port-y'])
await sleep(200)
}
for (let i = 0; i < 10 && peersTable === null; i++) {
sock.send(`${token} PEERS ${name}`, o['port-x'], o.obs)
await sleep(300)
}
if (peersTable === null) {
out({ role: 'probe', name, localPort, error: 'no-peers-table', acks })
process.exit(0)
}
const myX = (peersTable[name] || {}).X
const myY = (peersTable[name] || {}).Y
const targets = {}
for (const p of peers) {
const m = (peersTable[p] || {}).X
if (m === undefined) continue
targets[p] = m
}
const rounds = o.rounds || 10
const gap = o.gap || 200
const sent = {}
for (const [p, addr] of Object.entries(targets)) {
sent[p] = 0
const [ip, port] = addr.split(':')
for (let i = 0; i < rounds; i++) {
sock.send(`${token} PUNCH ${name}`, Number(port), ip)
sent[p]++
await sleep(gap)
}
}
await sleep(1500)
const byPeer = {}
for (const [p, addr] of Object.entries(targets)) {
byPeer[p] = { target: addr, sent: sent[p], received: receipts[addr] || 0 }
}
const other = Object.entries(receipts).filter(([a]) => !Object.values(targets).includes(a))
out({
role: 'probe',
name,
localPort,
mappingX: myX,
mappingY: myY,
mappingEndpointIndependent: myX !== undefined && myX === myY,
peers: byPeer,
receiptsFromUnexpected: other,
acks,
})
process.exit(0)
}
// ─────────────────────────── STUN(降级路径) ───────────────────────────
/**
* 🔴 **为什么需要降级**:首选路径(47 上一次性 UDP 观察器)**实测不可用** ——
* 观察器零收包(连同机发出的包都收不到)⇒ 47 的**云安全组拦掉了 UDP 入站**
* (⛔ 不是 nft:`nft` 的 `input policy` = `accept`)。改走公网 STUN 做映射/过滤判定。
* ⛔ 按 S4 要求,用本模式得到的一切结论**必须在 §8 标注"经第三方"**。
*/
const MAGIC = 0x2112a442
function stunRequest(sock, host, port, cb) {
const buf = Buffer.alloc(20)
buf.writeUInt16BE(0x0001, 0)
buf.writeUInt16BE(0, 2)
buf.writeUInt32BE(MAGIC, 4)
require('node:crypto').randomBytes(12).copy(buf, 8)
let done = false
const onMsg = (msg) => {
if (msg.length < 20 || msg.readUInt16BE(0) !== 0x0101) return
let off = 20
const end = 20 + msg.readUInt16BE(2)
while (off + 4 <= end && off + 4 <= msg.length) {
const type = msg.readUInt16BE(off)
const len = msg.readUInt16BE(off + 2)
const val = msg.subarray(off + 4, off + 4 + len)
if ((type === 0x0020 || type === 0x0001) && val.length >= 8) {
const family = val[1]
const rawPort = val.readUInt16BE(2)
const p = type === 0x0020 ? rawPort ^ (MAGIC >>> 16) : rawPort
let ip
if (family === 1) {
ip = [...val.subarray(4, 8)].join('.')
if (type === 0x0020) {
const b = val.subarray(4, 8)
const m = Buffer.alloc(4)
m.writeUInt32BE(MAGIC, 0)
ip = [...b].map((x, i) => x ^ m[i]).join('.')
}
}
if (!done && ip !== undefined) {
done = true
sock.off('message', onMsg)
cb({ server: `${host}:${port}`, mapping: `${ip}:${p}`, attr: type === 0x0020 ? 'XOR-MAPPED' : 'MAPPED' })
}
return
}
off += 4 + len + ((4 - (len % 4)) % 4)
}
}
sock.on('message', onMsg)
sock.send(buf, port, host)
setTimeout(() => {
if (!done) {
done = true
sock.off('message', onMsg)
cb({ server: `${host}:${port}`, error: 'no-response' })
}
}, 4000)
}
const writeLine = (s) => require('node:fs').writeSync(1, `${s}\n`)
async function stun(o) {
const sock = dgram.createSocket('udp4')
await new Promise((r) => sock.bind(Number(o.bind || 0), '0.0.0.0', r))
const localPort = sock.address().port
const receipts = {}
sock.on('message', (msg, rinfo) => {
const key = `${rinfo.address}:${rinfo.port}`
receipts[key] = (receipts[key] || 0) + 1
writeLine(`RECV ${key} ${msg.length}B`)
})
const servers = String(o.stun || '')
.split(',')
.filter((s) => s !== '')
.map((s) => {
const [h, p] = s.split(':')
return { h, p: Number(p) }
})
const mappings = []
for (const s of servers) {
// eslint-disable-next-line no-await-in-loop
const r = await new Promise((res) => stunRequest(sock, s.h, s.p, res))
mappings.push(r)
writeLine(`MAPPING ${JSON.stringify(r)}`)
}
const nodes = [...new Set(mappings.filter((m) => m.mapping).map((m) => m.mapping))]
writeLine(`### MAPPING ### ${JSON.stringify({ name: o.name, localPort, mappings, distinctMappings: nodes })}`)
const to = typeof o.to === 'string' ? o.to.split(',').filter((s) => s !== '') : []
let sent = 0
for (const target of to) {
const [ip, port] = target.split(':')
for (let i = 0; i < (o.rounds || 10); i++) {
sock.send(`${o.name || 'probe'} PUNCH`, Number(port), ip)
sent++
// eslint-disable-next-line no-await-in-loop
await sleep(o.gap || 200)
}
writeLine(`SENT ${(o.rounds || 10)} -> ${target}`)
}
await sleep((o.listen || 20) * 1000)
out({
role: 'stun',
name: o.name,
localPort,
mappings,
endpointIndependentMapping: nodes.length === 1,
distinctMappings: nodes,
punchedTo: to,
punchesSent: sent,
receipts,
via: 'third-party STUN(降级路径)',
})
process.exit(0)
}
if (args.observer) observer(args['port-x'], args['port-y'], args.token, args.secs || 90)
else if (args.probe) probe(args)
else if (args.stun) stun(args)
else {
process.stderr.write('usage: --observer --port-x N --port-y N --token T --secs S | --probe --obs H --port-x N --port-y N --token T --name N --peers a,b | --stun --name N [--bind P] --stun s1:p1,s2:p2 [--to ip:port] [--listen S]\n')
process.exit(2)
}
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env node
/**
* 覆盖网络 序⑥ · S3 链路抖动与 RTT 口径校验(⛔ 一次性脚本、不进产品路径)。
*
* ## 为什么需要它
* 参数表把 `RELAY_RTT_W106 = 336 ms` 标成"实测",但它其实来自 `server.ts:810` 的
* **心跳往返**(一次 WS 往返 + 应用层处理 + 验签)⇒ **不一定等于网络 RTT**。
* 而 336 ms 目前是"跨云链路很差"的**唯一证据** ⇒ 若它是口径问题,后面所有
* "跨云不可玩"的结论都要重判。本脚本做**三方对比**:ICMP / TCP 握手 / relay 心跳。
*
* 用法:
* node overlay-jitter.cjs --icmp 106.54.21.172 --count 300 --interval 0.2
* node overlay-jitter.cjs --tcp 106.54.21.172:22 --tcp-n 30
* node overlay-jitter.cjs --icmp H --count 300 --tcp H:22 --tcp-n 30 # 两者一起跑
*
* @module scripts/overlay-jitter
*/
'use strict'
const net = require('node:net')
const { spawnSync } = require('node:child_process')
function parseArgs(argv) {
const out = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (!a.startsWith('--')) continue
const k = a.slice(2)
const v = argv[i + 1]
if (v === undefined || v.startsWith('--')) out[k] = true
else {
out[k] = isNaN(Number(v)) || v === '' ? v : Number(v)
i++
}
}
return out
}
const args = parseArgs(process.argv.slice(2))
const log = (s) => process.stderr.write(`[jit] ${s}\n`)
const out = (o) => process.stdout.write(`### RESULT ### ${JSON.stringify(o)}\n`)
function pct(arr, p) {
if (arr.length === 0) return 0
const s = [...arr].sort((a, b) => a - b)
return Math.round(s[Math.min(s.length - 1, Math.floor(s.length * p))] * 100) / 100
}
function icmp(host, count, interval) {
const win = process.platform === 'win32'
const argv = win
? ['-n', String(count), '-w', '1000', host]
: ['-c', String(count), '-i', String(interval || 0.2), '-W', '1', host]
const r = spawnSync(win ? 'ping' : 'ping', argv, { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
const text = `${r.stdout || ''}`
const rtts = []
const re = win ? /time[=<]([\d.]+)\s*ms/gi : /time=([\d.]+)\s*ms/gi
let m
while ((m = re.exec(text)) !== null) rtts.push(Number(m[1]))
if (rtts.length < 2) return { host, packets: rtts.length, error: 'no-rtt-samples', raw: text.slice(0, 300) }
const deltas = []
for (let i = 1; i < rtts.length; i++) deltas.push(Math.abs(rtts[i] - rtts[i - 1]))
const avg = rtts.reduce((a, b) => a + b, 0) / rtts.length
const mdev = Math.sqrt(rtts.reduce((a, b) => a + (b - avg) ** 2, 0) / rtts.length)
return {
host,
packets: rtts.length,
sentExpected: count,
lossPct: Math.round(((count - rtts.length) / count) * 10000) / 100,
min: pct(rtts, 0),
avg: Math.round(avg * 100) / 100,
p50: pct(rtts, 0.5),
max: pct(rtts, 1),
mdev: Math.round(mdev * 100) / 100,
p95AbsDelta: pct(deltas, 0.95),
p50AbsDelta: pct(deltas, 0.5),
note_win: win ? 'windows ping:无 -i 间隔参数,实际约 1 包/秒' : undefined,
}
}
function tcpHandshake(host, port, n) {
const rtts = []
let left = n
return new Promise((resolve) => {
const one = () => {
const t = process.hrtime.bigint()
const s = net.connect(port, host)
const done = (ok) => {
if (ok) rtts.push(Number(process.hrtime.bigint() - t) / 1e6)
s.destroy()
if (--left <= 0) {
resolve({
target: `${host}:${port}`,
samples: rtts.length,
min: pct(rtts, 0),
median: pct(rtts, 0.5),
p95: pct(rtts, 0.95),
max: pct(rtts, 1),
})
return
}
setTimeout(one, 100)
}
s.setTimeout(5000)
s.on('connect', () => done(true))
s.on('error', () => done(false))
s.on('timeout', () => done(false))
}
one()
})
}
async function main() {
const res = { host: require('node:os').hostname(), platform: process.platform }
if (args.icmp) res.icmp = icmp(String(args.icmp), args.count || 300, args.interval)
if (args.tcp) {
const [h, p] = String(args.tcp).split(':')
res.tcpHandshake = await tcpHandshake(h, Number(p || 22), args['tcp-n'] || 30)
}
out(res)
process.exit(0)
}
main()
+270
View File
@@ -0,0 +1,270 @@
#!/usr/bin/env node
/**
* 覆盖网络 序③:**密钥仪式工具**(一机一钥 + 信任根)。
*
* 把 `lib/net/relay/identity.js` 的纯函数包成命令行,做四件事:
* 建根 / 建签名者 / 签发与吊销 / **恢复演练**。⛔ 它**自己不做任何校验决策** ——
* 判据全在模块里(`verify*`),本工具只是"拿私钥签个名、把结果落盘"的搬运工。
*
* ## 四层与它们各自该在哪台机器上跑(⛔ 别搞混)
* | 命令 | 该在哪跑 | 为什么 |
* |---|---|---|
* | `init-root` | **离线**(本工作区开发机 / 离线介质) | 根只授权签名者;在线 = 单点被攻破即全网伪造 |
* | `init-signer` | **在线签名者**(现网 = 47) | 日常签发都在这台,频繁但爆炸半径小(根还能撤它) |
* | `init-node` | **每台节点** | 一机一钥;私钥永不出机器 |
* | `issue-grant` | 在线签名者 | 签发"某 hostId + 某节点公钥"的入网凭据 |
* | `sign-revocations` | 在线签名者 | 撤单台(⛔ 撤**签名者**是"根重签一份 SignerSet"的事) |
* | `verify-grant` | 任何地方 | 自检:凭据是不是受信签名者签的 |
* | `recover-root` | 离线 | **恢复演练**:从纸质恢复码重建根私钥,再签一份 SignerSet 验通 |
*
* ## 用法
* ```bash
* node scripts/overlay-keyring.cjs init-root --dir /sec/dshs-root
* node scripts/overlay-keyring.cjs init-signer --key /etc/dshs/overlay-signer-key.pem
* node scripts/overlay-keyring.cjs sign-signerset --root-key <pem> --signers <pubhex,…> --network ops --out <json>
* node scripts/overlay-keyring.cjs init-node --key /etc/dshs/node.key
* node scripts/overlay-keyring.cjs issue-grant --signer-key <pem> --network ops --host w-106 --node-key <file|hex> --out <json>
* node scripts/overlay-keyring.cjs sign-revocations --signer-key <pem> --network ops --hosts a,b --out <json>
* node scripts/overlay-keyring.cjs verify-grant --file <json> --signer-pub <hex> [--host w-106 --network ops]
* node scripts/overlay-keyring.cjs recover-root --code <hex> --out <pem> [--expect-pub <hex>]
* # 演练三判据:--expect-pub 比公钥;
* # 再给 --signers <hex,…> --network <n> --issued-at <iso> ⇒ 用重建的根签 SignerSet 验通(判据②);
* # 再给 --expect-sig <b64>(原根对同一 doc 签出的)⇒ 逐字节比对(判据③)
* ```
*
* 🔴 **所有落盘的私钥一律 `0600`**(`writeSecret`),且**stdout 只打印公钥 / 指纹** ——
* 私钥进 stdout 就会进终端历史、`journalctl`、CI 日志。
*
* @module scripts/overlay-keyring
*/
'use strict'
const { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } = require('node:fs')
const { dirname } = require('node:path')
const id = require('../lib/net/relay/identity.js')
const [, , cmd, ...rest] = process.argv
/** 解析 `--k v` 与开关 `--flag`。 */
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()
}
/** 写**私钥**类文件:`0600` + 同目录临时文件 + `rename`(不留半成品,也不留宽权限)。 */
function writeSecret(file, text, logAction) {
mkdirSync(dirname(file), { recursive: true })
const tmp = `${file}.tmp`
writeFileSync(tmp, text, { mode: 0o600 })
renameSync(tmp, file)
chmodSync(file, 0o600)
process.stdout.write(`✓ ${logAction}:${file}(0600)\n`)
}
/** 写**公开**物(签名文档 / 公钥):`0644` —— 它们本来就要被分发到每台机器。 */
function writePublic(file, obj) {
mkdirSync(dirname(file), { recursive: true })
const tmp = `${file}.tmp`
writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, { mode: 0o644 })
renameSync(tmp, file)
process.stdout.write(`✓ 已写出:${file}\n`)
}
function nowIso() {
return new Date().toISOString()
}
/** `--node-key` 既接受**文件**(推荐:公钥从私钥推,不另存一份)也接受裸 hex。 */
function nodePublicKey(spec) {
const raw = spec.trim()
if (/^[0-9a-fA-F]{64}$/.test(raw)) return raw.toLowerCase()
const pem = readFileSync(raw, 'utf8')
return id.publicKeyOfPrivate(pem)
}
const COMMANDS = {
/** 建**离线根**:私钥 + 公钥 + **纸质恢复码**(PKCS#8 DER 的 hex,可抄写)。 */
'init-root': () => {
const dir = need('dir')
const key = id.generateAuthorityKey()
writeSecret(`${dir}/root.key`, key.privateKeyPem, '根私钥')
writeFileSync(`${dir}/root.pub`, `${key.publicKey}\n`, { mode: 0o644 })
// 恢复码 = PKCS#8 DER 的 hex(由 `recover-root` 反解回 PEM)⇒ 可手抄、可打印、可存密码管理器。
const der = require('node:crypto').createPrivateKey(key.privateKeyPem).export({ type: 'pkcs8', format: 'der' })
const groups = (der.toString('hex').match(/.{1,8}/g) ?? []).join(' ')
writeFileSync(`${dir}/root.recovery-code.txt`, `DSHS 覆盖网络 · 根密钥恢复码(第 1 份,纸质/离线保管)\n\n${groups}\n`, {
mode: 0o600,
})
process.stdout.write(`✓ 根公钥(可公开,要下发到每台节点 / relay):${key.publicKey}\n`)
process.stdout.write(`✓ 根指纹:${id.nodeKeyFingerprint(key.publicKey)}\n`)
process.stdout.write(`✓ 恢复码:${dir}/root.recovery-code.txt(0600,**请抄到纸或另存离线介质**)\n`)
},
/** 建**在线签名者**(每台管理员设备一把)。 */
'init-signer': () => {
const keyFile = need('key')
const key = id.generateAuthorityKey()
writeSecret(keyFile, key.privateKeyPem, '签名者私钥')
writeFileSync(`${keyFile}.pub`, `${key.publicKey}\n`, { mode: 0o644 })
process.stdout.write(`✓ 签名者公钥(要写进 SignerSet 并由**根**签发):${key.publicKey}\n`)
process.stdout.write(`✓ 签名者指纹:${id.nodeKeyFingerprint(key.publicKey)}\n`)
},
/** 建**节点密钥**(每机一把,一机一钥)。 */
'init-node': () => {
const keyFile = need('key')
const key = id.generateNodeKey()
writeSecret(keyFile, key.privateKeyPem, '节点私钥')
process.stdout.write(`✓ 节点公钥:${key.publicKey}\n`)
process.stdout.write(`✓ 节点指纹:${id.nodeKeyFingerprint(key.publicKey)}\n`)
},
/** **根**签一份签名者集合(授权签名者)—— ⛔ 只在离线跑。 */
'sign-signerset': () => {
const doc = {
version: 1,
network: need('network'),
// `--issued-at` 可选:**恢复演练**要靠它把时间钉死,才谈得上"签名逐字节相同"。
issuedAt: typeof args['issued-at'] === 'string' && args['issued-at'] !== '' ? args['issued-at'] : nowIso(),
signers: need('signers').split(',').map((s) => s.trim()).filter((s) => s !== ''),
}
const sig = id.signSignerSet(doc, readFileSync(need('root-key'), 'utf8'))
writePublic(need('out'), { doc, sig })
// **自检**:签完立刻用根公钥验一遍 —— 签错比不签更危险(下游会以为"已经授权了")。
const pub = args['root-pub'] ?? readFileSync(`${dirname(need('root-key'))}/root.pub`, 'utf8').trim()
const verdict = id.verifySignerSet(doc, sig, [pub])
if (!verdict.ok) throw new Error(`自检失败:刚签的 SignerSet 验不过(${verdict.reason})`)
process.stdout.write(`✓ 自检通过(受信根验签 ok),签名者 ${doc.signers.length} 把\n`)
},
/** **签名者**签一份节点入网凭据。 */
'issue-grant': () => {
const doc = {
version: 1,
network: need('network'),
hostId: need('host'),
nodeKey: nodePublicKey(need('node-key')),
issuedAt: nowIso(),
expiresAt: args['expires-at'] ?? '',
}
const sig = id.signNodeGrant(doc, readFileSync(need('signer-key'), 'utf8'))
writePublic(need('out'), { doc, sig })
process.stdout.write(`✓ 已签发:${doc.network}/${doc.hostId} 节点指纹=${id.nodeKeyFingerprint(doc.nodeKey)}\n`)
},
/** **签名者**签一份吊销清单(撤单台)。 */
'sign-revocations': () => {
const doc = {
version: 1,
network: need('network'),
issuedAt: nowIso(),
hosts: (args.hosts ?? '').split(',').map((s) => s.trim()).filter((s) => s !== ''),
nodeKeys: (args['node-keys'] ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '')
.map((s) => nodePublicKey(s)),
}
const sig = id.signRevocations(doc, readFileSync(need('signer-key'), 'utf8'))
writePublic(need('out'), { doc, sig })
process.stdout.write(`✓ 已签发吊销清单:hosts=[${doc.hosts.join(',')}] nodeKeys=${doc.nodeKeys.length}\n`)
},
/** 自检:某份凭据是不是受信签名者签的(签发后立刻跑一次)。 */
'verify-grant': () => {
const raw = JSON.parse(readFileSync(need('file'), 'utf8'))
const verdict = id.verifyPeerGrant(raw.doc, raw.sig, {
trustedSignerKeys: need('signer-pub').split(',').map((s) => s.trim()),
network: args.network,
hostId: args.host,
})
if (!verdict.ok) {
process.stderr.write(`✗ 验签失败:${verdict.reason}\n`)
process.exit(1)
}
process.stdout.write(`✓ 验签通过:${verdict.doc.network}/${verdict.doc.hostId} 指纹=${id.nodeKeyFingerprint(verdict.doc.nodeKey)}\n`)
},
/**
* **根密钥恢复演练**:从纸质恢复码重建根私钥(⛔ 不用原文件),再签一份 SignerSet 并验通。
*
* 判据 = **三件都成立**才算过:① 恢复码能重建出**同一把**公钥 ② 用它签出的 SignerSet
* 被原根公钥验通 ③ 与原私钥签出的签名**逐字节相同**(Ed25519 是确定性的 —— 这条让
* "看起来恢复了其实不是同一把钥匙"无处藏身)。
*/
'recover-root': () => {
const hex = need('code').replace(/\s+/g, '')
const der = Buffer.from(hex, 'hex')
const pem = require('node:crypto')
.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' })
.export({ type: 'pkcs8', format: 'pem' })
writeSecret(need('out'), pem, '重建出的根私钥')
const pub = id.publicKeyOfPrivate(pem)
process.stdout.write(`✓ 重建出的根公钥:${pub}\n`)
process.stdout.write(`✓ 根指纹:${id.nodeKeyFingerprint(pub)}\n`)
const expect = typeof args['expect-pub'] === 'string' ? args['expect-pub'].trim() : ''
if (expect !== '') {
if (pub !== expect) throw new Error('⛔ 重建出的公钥与期望不符 ⇒ 恢复码不是这把根的')
process.stdout.write('✓ [判据①] 与期望根公钥逐字节一致\n')
}
// 判据② ③:只比公钥不够("看起来一致"),要比**签名逐字节相同**(Ed25519 是确定性的)
// 才能证明"重建出来的就是同一把钥匙"。传入 `--signers` 时用重建的私钥签一份 SignerSet:
if (typeof args.signers === 'string' && args.signers.trim() !== '') {
const signers = args.signers.split(',').map((s) => s.trim()).filter((s) => s !== '')
const doc = { version: 1, network: need('network'), issuedAt: need('issued-at'), signers }
const sig = id.signSignerSet(doc, pem)
const anchor = expect !== '' ? expect : pub
const verdict = id.verifySignerSet(doc, sig, [anchor])
if (!verdict.ok) throw new Error(`⛔ [判据②] 重建的根签出的 SignerSet 验不过(${verdict.reason})`)
process.stdout.write(`✓ [判据②] 重建的根签出的 SignerSet(${signers.length} 把)经原根公钥验签通过\n`)
const wantSig = typeof args['expect-sig'] === 'string' ? args['expect-sig'].trim() : ''
if (wantSig !== '') {
if (sig !== wantSig) throw new Error('⛔ [判据③] 签名与原根私钥签出的不一致 ⇒ 重建出的不是同一把钥匙')
process.stdout.write('✓ [判据③] 签名与原根私钥签出的逐字节相同\n')
}
}
},
}
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)
}
fn()
}
try {
main()
} catch (err) {
process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
}
+617
View File
@@ -0,0 +1,617 @@
#!/usr/bin/env node
/**
* 覆盖网络 · **观测最小集**探针
*
* ## 为什么是"一条命令 + 一个退出码"
* `交接单_relay落地R2-R4` 的教训原文是「**静默失效靠判别器定位**」。要让判别器能被**脚本**
* (而不是人读日志)用,就必须有「一条命令出 PASS/FAIL」的入口 —— 本文件就是那个入口:
*
* cd "E:/ProgramData/AI技能/aliyun-dsh-server" && node "D:/github/dsh_shenxian/scripts/overlay-probe.cjs"
*
* - **退出码**:全绿 `0` / 任一红 `1` / 用法或取数失败 `2`(可直接被 automation 消费)
* - **输出**:≤ 12 行(每行 = 一条指标);红的条目**另外**写到 stderr,并**指名**是哪个 ID
* - **阈值**:⛔ **一个都不许硬编码** —— 全部从 `参数表_覆盖网络` 的 `` | `KEY` | 值 | `` 行读出来。
* 缺键 ⇒ **报错退出**(⛔ 绝不用默认值静默兜底 —— 那正是"观测形同虚设"的成因)。
*
* ## 🔴 本文件的**零数字纪律**(判据 = 交接单 §6 E5)
* `grep -nE "[0-9]{3,}" scripts/overlay-probe.cjs` 必须**零命中** —— 连标识符名与注释里
* 都不许出现"看起来像阈值"的数字(端口、节点名、日期一律走参数表或从取回的数据里取)。
* 这样就不存在"脚本里藏着一个没人知道出处的常量"的可能。
*
* ## 🆕 `OBS-11` 的**集合判据**(序⑪ 规划 / 序⑫ 执行)
* 旧口径 = 「**两个计数相等**」,有两处**结构性**缺陷:
* ⓐ 对实例/端点落点**在线态敏感** ⇒ 合法态被判红(**假红**);
* ⓑ 对「**一进一出**」替换式变化**不敏感** ⇒ 真变化被放过(**假绿**)。
* 新口径 = **三集包含式**(差集逐条点名):
*
* required ⊆ actual // 缺 ⇒ FAIL(点名缺项)
* actual ⊆ required ∪ allowed ∪ ranges ∪ derived // 多 ⇒ FAIL(点名多出项)
* derived = { <RELAY_BIND>:<p> | p ∈ /status.endpoints[].localPort } // 唯一来源 = relay 自身 /status
*
* ⇒ `required` 抓"**消失**"、包含式抓"**新增**"、`derived` 让**合法动态落点**有名字
* ⇒ 替换式变化(一进一出)**必然**被两条断言之一命中。
* `LISTEN_COUNT` / `NFT_RULES` **已退役**(仅在参数表里留作对账),⛔ 本文件不得再引用它们。
*
* ## 🧪 夹具模式(`--listen-fixture` / `--nft-fixture` / `--status-fixture`)
* 判据改造**必须自带"旧判据会放过、新判据能抓住"的实证**。夹具就是那个实证手段:
* 把远端原文喂进来 ⇒ **不 ssh**、零生产副作用;输出行首加 `⚠️ FIXTURE`(stderr 另标一次,
* ⛔ 防止被下游当成生产结论);此时非集合类指标记 `SKIP`(不参与退出码)。
* ⛔ 夹具模式**必须**配 `--table <副本>`(避免误改生产参数表)。
*
* ## 运行前置
* - `cwd` = 工作区根(按 `TABLE_RE` 找参数表;也可 `--dir` / `--table`)
* - 本机能 `ssh` 到中继机(走 `~/.ssh/config` 别名;⚠️ 别名里配的端口可能陈旧 ⇒ 一律用表里的 `SSH_PORT`)
*
* ## ⛔ 它不做什么
* 不做 dashboard、不引外部监控依赖、不开新端口、不写任何远端文件。**纯只读**。
*
* @module scripts/overlay-probe
*/
'use strict'
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
/** 参数表文件名(⛔ 不写死日期数字:用正则匹配,避免脚本里出现像阈值的常量)。 */
const TABLE_RE = /^参数表_覆盖网络_.+\.md$/
/** 参数行:`` | `KEY` | 值 | … ``(只取前两列)。 */
const KEY_RE = /^\|\s*`([A-Z0-9_]+)`\s*\|\s*([^|]*)\|/
/** 观测阈值行:`` | `OBS-NN` | 指标 | 阈值 | 判据 | ``(阈值一律按 KEY 解析)。 */
const OBS_RE = /^\|\s*`(OBS-[0-9]+)`\s*\|\s*([^|]*)\|/
/** 阈值里允许出现的"键引用"形态(`` `KEY` ``)。 */
const KEY_ONLY_RE = /^`([A-Z0-9_]+)`$/
/** 区间形态:`host:lo-hi`(也容错只给端口段的形态,以及 `~` / en-dash 作分隔符)。 */
const RANGE_HOST_RE = /^(.+):(\d+)\s*[-–~]\s*(\d+)$/
/** 区间形态(只给端口段,主机由 `RELAY_BIND` 补)。 */
const RANGE_BARE_RE = /^(\d+)\s*[-–~]\s*(\d+)$/
/** `ss` 数据行的首列(`-l` ⇒ 全部是它;据此跳过表头与噪声行)。 */
const SS_STATE = 'LISTEN'
const EXIT_OK = 0
const EXIT_FAIL = 1
const EXIT_USAGE = 2
/* ─────────── 参数表装载 ─────────── */
function argOf(argv, name) {
const i = argv.indexOf(name)
return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : undefined
}
function resolveTablePath(argv) {
const explicit = argOf(argv, '--table')
if (explicit !== undefined) return explicit
const dir = argOf(argv, '--dir') ?? process.env.DSHS_OVERLAY_TABLE_DIR ?? process.cwd()
const hits = fs.readdirSync(dir).filter((n) => TABLE_RE.test(n))
if (hits.length !== 1) {
throw new Error(`在 ${dir} 下按 /${TABLE_RE.source}/ 找到 ${hits.length} 个参数表(要求恰好 1 个)`)
}
return path.join(dir, hits[0])
}
/** 值 → 去掉 markdown 的 `**` 与反引号。 */
function cleanValue(raw) {
return String(raw).replace(/[*`]/g, '').trim()
}
function loadTable(file) {
const text = fs.readFileSync(file, 'utf8')
const params = new Map()
const obs = new Map()
for (const line of text.split(/\r?\n/)) {
const o = OBS_RE.exec(line)
if (o !== null) {
obs.set(o[1], cleanValue(o[2]))
continue
}
const k = KEY_RE.exec(line)
if (k !== null && !params.has(k[1])) {
// ⚠️ 先到先得:同一键在多处出现时以**第一处**(正表)为准,避免被"示例行"覆盖。
params.set(k[1], cleanValue(k[2]))
}
}
return { file, params, obs }
}
function makeReaders(table) {
const bad = []
/** 取**字符串**参数;缺失/空 ⇒ 记账(最后统一报错退出,⛔ 不静默兜底)。 */
const need = (key) => {
if (!table.params.has(key) || table.params.get(key) === '') {
bad.push(key)
return ''
}
return table.params.get(key)
}
/** 取**数值**参数:参数表里有的键名优先;否则按**字面量**(阈值表里可以直接写数)。 */
const num = (keyOrRef) => {
const m = KEY_ONLY_RE.exec(keyOrRef)
let v
if (m !== null) v = need(m[1])
else if (table.params.has(keyOrRef)) v = need(keyOrRef)
else v = String(keyOrRef).trim()
const n = Number(v)
if (v === '' || !Number.isFinite(n)) {
bad.push(`${keyOrRef}=${JSON.stringify(v)} 不是数`)
return Number.NaN
}
return n
}
return { need, num, bad }
}
/* ─────────── 集合判据的取值/解析 ─────────── */
/** 逗号分隔 → Set(去空、去首尾空白;⛔ 保持原文,报错要点名)。 */
function parseList(value) {
return new Set(
String(value)
.split(',')
.map((s) => s.trim())
.filter((s) => s !== ''),
)
}
/**
* 解析区间集合。`fallbackHost` 用于**只给端口段**的形态(如拨号池那个 `DIAL_POOL_BOUND`)。
* 非法项**记账**而不是静默丢弃(丢项 = 白名单悄悄变窄 = 假红)。
*/
function parseRanges(value, fallbackHost, bad) {
const out = []
for (const item of String(value).split(',')) {
const t = item.trim()
if (t === '') continue
const withHost = RANGE_HOST_RE.exec(t)
if (withHost !== null) {
out.push({ host: withHost[1], lo: Number(withHost[2]), hi: Number(withHost[3]) })
continue
}
const bare = RANGE_BARE_RE.exec(t)
if (bare !== null && fallbackHost !== undefined) {
out.push({ host: fallbackHost, lo: Number(bare[1]), hi: Number(bare[2]) })
continue
}
bad.push(`区间 ${JSON.stringify(t)} 形态不合法`)
}
return out
}
/** 单个 `host:port` 是否落在某个区间内。 */
function inRanges(addr, ranges) {
const i = addr.lastIndexOf(':')
if (i <= 0) return false
const host = addr.slice(0, i)
const port = Number(addr.slice(i + 1))
if (!Number.isFinite(port)) return false
return ranges.some((r) => r.host === host && port >= r.lo && port <= r.hi)
}
function decodeB64(raw) {
if (raw === undefined || raw === '') return ''
return Buffer.from(String(raw), 'base64').toString('utf8')
}
/**
* `ss -lntp` 原文 → 监听集合(`host:port` 字符串,**逐条保留原文** ⇒ 报错能点名)。
* ⚠️ 从**右侧**第一个 `:` 拆 host/port —— `[::]:22` 必须拆对(左侧 `indexOf` 会拆成 `[`)。
*/
function parseListenRaw(text) {
const set = new Set()
for (const line of String(text).split(/\r?\n/)) {
const f = line.trim().split(/\s+/)
if (f.length < 4 || f[0] !== SS_STATE) continue
const local = f[3]
const i = local.lastIndexOf(':')
if (i <= 0) continue
set.add(`${local.slice(0, i)}:${local.slice(i + 1)}`)
}
return set
}
/** 从 `input` hook 可达的链(`family/table/chain`,含 `jump` 传递闭包)。 */
function inputReachableChains(items) {
const keyOf = (o) => `${o.family}/${o.table}/${o.chain ?? o.name}`
const reached = new Set()
for (const it of items) {
if (it.chain !== undefined && it.chain.hook === 'input') reached.add(keyOf(it.chain))
}
const rules = items.filter((it) => it.rule !== undefined).map((it) => it.rule)
let grew = true
while (grew) {
grew = false
for (const r of rules) {
if (!reached.has(keyOf(r))) continue
for (const e of r.expr ?? []) {
const target = e.jump !== undefined ? e.jump.target : undefined
if (typeof target !== 'string') continue
const k = `${r.family}/${r.table}/${target}`
if (!reached.has(k)) {
reached.add(k)
grew = true
}
}
}
}
return reached
}
/** 把一条规则的表达式归一成 `<proto>:<dport>`(无端口匹配 ⇒ `<proto>:any`)。 */
function ruleToToken(expr) {
let proto
let port
for (const e of expr ?? []) {
const m = e.match
if (m === undefined) continue
const pl = m.left !== undefined ? m.left.payload : undefined
if (pl === undefined || pl.field !== 'dport' || m.op !== '==') continue
if (typeof pl.protocol === 'string') proto = pl.protocol
const right = m.right
if (typeof right === 'number') port = String(right)
else if (right !== null && typeof right === 'object') {
if (Array.isArray(right.range)) port = `${right.range[0]}-${right.range[1]}`
else if (Array.isArray(right.set)) port = right.set.map((x) => `${x}`).join('+')
else if (typeof right.prefix === 'object' && right.prefix !== null) port = String(right.prefix.addr)
}
}
return `${proto ?? 'any'}:${port ?? 'any'}`
}
/** `nft -j` 原文 → 入站 accept 集合。失败返回 `null`(⛔ 由调用方显式标 `text-fallback`,不许静默改判据)。 */
function parseNftJsonRaw(text) {
let doc
try {
doc = JSON.parse(text)
} catch {
return null
}
const items = Array.isArray(doc.nftables) ? doc.nftables : null
if (items === null) return null
const reached = inputReachableChains(items)
const out = new Set()
for (const it of items) {
const r = it.rule
if (r === undefined) continue
if (!reached.has(`${r.family}/${r.table}/${r.chain}`)) continue
if (!(r.expr ?? []).some((e) => e.accept !== undefined)) continue
out.add(ruleToToken(r.expr))
}
return out
}
/**
* 退化路径:`nft list ruleset` 文本里抽 accept 行。
* ⚠️ 必须**链感知** —— 否则 `FORWARD` 链上的 `accept`(docker 那几条)会被当成入站规则 ⇒ **假红**。
* 做法:先按 `chain X {` / `}` 切出每链的规则行与是否 `hook input`,再沿 `jump` 取传递闭包
* (⛔ 与 `-j` 路径同一判据,只是取数方式不同)。
*/
function parseNftTextRaw(text) {
const chains = new Map()
let cur
for (const line of String(text).split(/\r?\n/)) {
const t = line.trim()
if (t === '' || t.startsWith('#')) continue
const open = /^chain\s+(\S+)\s*\{$/.exec(t)
if (open !== null) {
cur = open[1]
if (!chains.has(cur)) chains.set(cur, { hooked: false, rules: [] })
continue
}
if (t === '}' || t === '};') {
cur = undefined
continue
}
if (cur === undefined) continue
const c = chains.get(cur)
if (/\bhook\s+input\b/.test(t)) c.hooked = true
// ⚠️ `policy accept;` **不算规则**(`accept` 后紧跟 `;` ⇒ 不构成"独立 token")。
if (/(^|\s)accept(\s|$)/.test(t)) c.rules.push(t)
}
const reached = new Set()
for (const [name, c] of chains) if (c.hooked) reached.add(name)
let grew = true
while (grew) {
grew = false
for (const name of [...reached]) {
for (const rule of chains.get(name).rules) {
const j = /\bjump\s+(\S+)/.exec(rule)
if (j !== null && !reached.has(j[1])) {
reached.add(j[1])
grew = true
}
}
}
}
const protoRe = /\b(tcp|udp|icmp|icmpv6|ip|ip6)\b/
const dportRe = /dport\s+(\d+)/
const out = new Set()
for (const name of reached) {
for (const rule of chains.get(name).rules) {
const head = rule.slice(0, rule.search(/(^|\s)accept(\s|$)/))
const proto = protoRe.exec(head)
const dport = dportRe.exec(head)
out.add(`${proto === null ? 'any' : proto[1]}:${dport === null ? 'any' : dport[1]}`)
}
}
return out
}
/* ─────────── 只读取数 ─────────── */
/** 一次 ssh:命令**作为单个 argv 元素**下发 ⇒ 远端 shell 解析引号,本地不过 shell。 */
function ssh(port, target, command, timeoutMs) {
return execFileSync('ssh', ['-p', String(port), '-o', 'BatchMode=yes', target, command], {
encoding: 'utf8',
timeout: timeoutMs,
}).trim()
}
/**
* 一次 ssh 取回全部远端只读事实(⛔ 压 ssh 次数 = 压成本)。
* 🆕 序⑫:`ss` / `nft` **回传原文**(`base64 -w0`),归一化在本地做 ——
* 旧版只回 `wc -l` 的计数 ⇒ 判据只能比数字(假红/假绿的根源)。
* ⚠️ `base64` 字母表无 `=`(除末尾填充)⇒ 与下面 `key=value` 的行解析兼容。
*/
function remoteFacts(r, sshPort, target, peerLocalPort) {
const host = r.need('RELAY_BIND')
const cmd = [
`echo "listenRaw=$(ss -lntp 2>/dev/null | base64 -w0)"`,
`echo "nftJsonRaw=$(nft -j list ruleset 2>/dev/null | base64 -w0)"`,
`echo "nftTextRaw=$(nft list ruleset 2>/dev/null | base64 -w0)"`,
`echo "instanceA=$(curl -s -o /dev/null -w '%{http_code}' --http1.1 http://${host}:${r.need('LOCAL_INSTANCE_PORT')}/)"`,
`echo "instanceB=$(curl -s -o /dev/null -w '%{http_code}' --http1.1 http://${host}:${peerLocalPort}/)"`,
`echo "portal=$(curl -s -o /dev/null -w '%{http_code}' --http1.1 -H 'Host: ${r.need('PORTAL_HOST_HEADER')}' ${r.need('PORTAL_URL')})"`,
`echo "relayRss=$(ps -o rss= -p $(systemctl show -p MainPID --value dshs-relay))"`,
].join('; ')
const out = new Map()
for (const line of ssh(sshPort, target, cmd, r.num('SSH_TIMEOUT_MS')).split(/\r?\n/)) {
const i = line.indexOf('=')
if (i > 0) out.set(line.slice(0, i), line.slice(i + 1).trim())
}
return out
}
function readFixture(file) {
if (!fs.existsSync(file)) throw new Error(`夹具不存在:${file}`)
return fs.readFileSync(file, 'utf8')
}
/* ─────────── 主流程 ─────────── */
function usage() {
return (
'用法:node scripts/overlay-probe.cjs [--table <参数表.md>] [--dir <目录>]\n' +
' 🧪 夹具模式(⛔ 必须同时给 --table;不 ssh):\n' +
' --listen-fixture <ss 原文> --nft-fixture <nft -j 原文> --status-fixture </status 原文>\n'
)
}
function main() {
const argv = process.argv.slice(2)
if (argv.includes('--help') || argv.includes('-h')) {
process.stdout.write(usage())
return EXIT_OK
}
const listenFx = argOf(argv, '--listen-fixture')
const nftFx = argOf(argv, '--nft-fixture')
const statusFx = argOf(argv, '--status-fixture')
const fixture = listenFx !== undefined || nftFx !== undefined || statusFx !== undefined
if (fixture && argOf(argv, '--table') === undefined) {
process.stderr.write('❌ 夹具模式必须配 --table <参数表副本>(避免误改生产参数表)\n')
return EXIT_USAGE
}
let table
try {
table = loadTable(resolveTablePath(argv))
} catch (err) {
process.stderr.write(`❌ 参数表装载失败:${err.message}\n`)
return EXIT_USAGE
}
const r = makeReaders(table)
let status
let facts
if (fixture) {
process.stderr.write('⚠️ FIXTURE 本次为**夹具模式**:未连接任何远端,结论不得当生产判据\n')
try {
status = statusFx === undefined ? { endpoints: [] } : JSON.parse(readFixture(statusFx))
} catch (err) {
process.stderr.write(`❌ 夹具装载失败(/status):${err.message}\n`)
return EXIT_USAGE
}
facts = new Map()
if (listenFx !== undefined) facts.set('listenRaw', Buffer.from(readFixture(listenFx), 'utf8').toString('base64'))
if (nftFx !== undefined) facts.set('nftJsonRaw', Buffer.from(readFixture(nftFx), 'utf8').toString('base64'))
} else {
const sshPort = r.num('SSH_PORT')
const target = r.need('SSH_TARGET_47')
// 取 `/status`(观测的**主数据源**)。独立一次 ssh:对端实例面在中继机上的回环落点口号**要靠它**。
try {
status = JSON.parse(ssh(sshPort, target, `curl -s ${r.need('RELAY_STATUS_URL')}`, r.num('SSH_TIMEOUT_MS')))
} catch (err) {
process.stderr.write(`❌ 取 /status 失败:${err.message}\n`)
return EXIT_USAGE
}
const preEps = Array.isArray(status.endpoints) ? status.endpoints : []
const prePeer = preEps.find((e) => e.port === r.num('PEER_INSTANCE_PORT'))
const peerLocalPort0 = prePeer === undefined ? r.num('PEER_INSTANCE_PORT') : prePeer.localPort
try {
facts = remoteFacts(r, sshPort, target, peerLocalPort0)
} catch (err) {
process.stderr.write(`❌ 远端只读取数失败:${err.message}\n`)
return EXIT_USAGE
}
}
const counters = status.counters ?? {}
const cap = status.capacity ?? {}
const eps = Array.isArray(status.endpoints) ? status.endpoints : []
const peerEp = eps.find((e) => e.port === r.num('PEER_INSTANCE_PORT'))
const peerLabel = peerEp === undefined ? '对端' : peerEp.hostId
const peerLocalPort = peerEp === undefined ? r.num('PEER_INSTANCE_PORT') : peerEp.localPort
const relayBind = r.need('RELAY_BIND')
const required = parseList(r.need('LISTEN_REQUIRED'))
const allowed = parseList(r.need('LISTEN_ALLOWED'))
const ranges = parseRanges(r.need('LISTEN_ALLOWED_RANGES'), relayBind, r.bad)
// 拨号池**不另立键**(⛔ 避免两处漂移):复用既有 `DIAL_POOL_BOUND`,主机取 `RELAY_BIND`。
ranges.push(...parseRanges(r.need('DIAL_POOL_BOUND'), relayBind, r.bad))
const nftAllowed = parseList(r.need('NFT_ALLOW_INBOUND'))
// `derived` 的**唯一来源** = relay 自身 `/status` 的端点回环落点(动态值 ⇒ ⛔ 不许写死进参数表)。
const derived = new Set(eps.map((e) => `${relayBind}:${e.localPort}`))
if (r.bad.length > 0) {
process.stderr.write(`❌ 参数表缺键/坏值:${r.bad.join(' , ')}\n`)
return EXIT_USAGE
}
const codeSet = r.need('PROBE_CODE_SET').split(',').map((s) => Number(s.trim()))
const rows = []
/**
* 夹具模式下非集合类指标无法取证 ⇒ 记 SKIP(⛔ 不参与退出码,否则"先红后绿"表达不出来)。
* ⚠️ `judged = true` 的行**仍按真实判据出 PASS/FAIL** —— `OBS-11` 就是它,
* 否则夹具模式恒绿 ⇒ 整个"假绿实证"就假了。
*/
const add = (id, ok, text, judged = false) =>
rows.push(fixture && judged !== true ? { id, ok: true, skip: true, text } : { id, ok, text })
if (!fixture) {
add('OBS-01', Number(cap.used) >= r.num('MIN_HOSTS'), `在册节点 used=${cap.used} (阈值 ≥ ${r.num('MIN_HOSTS')})`)
add(
'OBS-02',
Number(cap.max) === r.num('RELAY_MAX_HOSTS') && Number(cap.free) === Number(cap.max) - Number(cap.used),
`capacity max=${cap.max} used=${cap.used} free=${cap.free} (阈值 max=${r.num('RELAY_MAX_HOSTS')}, free=max-used)`,
)
add(
'OBS-03',
counters.identityRequired === true && Number(counters.trustedSigners) >= r.num('MIN_TRUSTED_SIGNERS'),
`identityRequired=${counters.identityRequired} trustedSigners=${counters.trustedSigners} (阈值 ≥ ${r.num('MIN_TRUSTED_SIGNERS')})`,
)
add(
'OBS-04',
Number(counters.identityOk) >= r.num('MIN_IDENTITY_OK'),
`identityOk=${counters.identityOk} (阈值 ≥ ${r.num('MIN_IDENTITY_OK')})`,
)
add(
'OBS-05',
Number(counters.revokedHosts) <= r.num('MAX_REVOKED_HOSTS'),
`revokedHosts=${counters.revokedHosts} (阈值 ≤ ${r.num('MAX_REVOKED_HOSTS')})`,
)
const dialKeys = ['dial', 'dialDenied', 'dialFailed']
add(
'OBS-06',
dialKeys.every((k) => typeof counters[k] === 'number'),
`判别器 ${dialKeys.map((k) => `${k}=${counters[k]}`).join(' ')} (必须都是 number)`,
)
add(
'OBS-07',
Number(counters.authFailed) <= r.num('MAX_AUTH_FAILED'),
`authFailed=${counters.authFailed} authed=${counters.authed} (阈值 ≤ ${r.num('MAX_AUTH_FAILED')})`,
)
add(
'OBS-08',
eps.length > 0 && eps.every((e) => e.online === true),
`端点表 ${eps.length} 条 / 离线 ${eps.filter((e) => e.online !== true).length} 条`,
)
const instanceA = Number(facts.get('instanceA'))
const instanceB = Number(facts.get('instanceB'))
add(
'OBS-09',
codeSet.includes(instanceA) && codeSet.includes(instanceB),
`实例面 本机:${r.need('LOCAL_INSTANCE_PORT')}=${facts.get('instanceA')} ${peerLabel}:${peerLocalPort}=${facts.get('instanceB')} (阈值 ∈ {${codeSet.join(',')}})`,
)
add('OBS-10', Number(facts.get('portal')) === r.num('PORTAL_CODE'), `门户=${facts.get('portal')} (阈值 = ${r.num('PORTAL_CODE')})`)
} else {
for (const id of [
'OBS-01',
'OBS-02',
'OBS-03',
'OBS-04',
'OBS-05',
'OBS-06',
'OBS-07',
'OBS-08',
'OBS-09',
'OBS-10',
]) {
add(id, true, '夹具模式未取证')
}
}
/* ── OBS-11:**集合判据**(三集包含式 + nft 入站 accept 白名单) ── */
// 夹具模式下 `facts.listenRaw` 亦由夹具文件编码而来 ⇒ 两条路径同一份解析逻辑(⛔ 不写两套)。
const actual = parseListenRaw(decodeB64(facts.get('listenRaw')))
const missing = [...required].filter((a) => !actual.has(a)).sort()
// ⛔ 顺序:required → allowed → ranges → derived;只在**全不命中**时才进 `extra`。
const extra = [...actual]
.filter((a) => !required.has(a) && !allowed.has(a) && !inRanges(a, ranges) && !derived.has(a))
.sort()
let acceptSet = new Set()
let nftNote = ' nft=none'
const nftJsonText = nftFx !== undefined ? readFixture(nftFx) : decodeB64(facts.get('nftJsonRaw'))
if (nftJsonText !== '') {
acceptSet = parseNftJsonRaw(nftJsonText)
nftNote = ''
if (acceptSet === null) {
// ⛔ 退化路径**必须显式标记**(不许静默改判据):`-j` 不可用时才走文本解析。
acceptSet = parseNftTextRaw(nftFx !== undefined ? nftJsonText : decodeB64(facts.get('nftTextRaw')))
nftNote = ' nft=text-fallback'
}
} else if (!fixture) {
process.stderr.write('❌ OBS-11 取不到 nft 规则集(`-j` 与文本两路都为空)\n')
return EXIT_USAGE
}
const nftExtra = [...acceptSet].filter((t) => !nftAllowed.has(t)).sort()
// relay 的"只绑回环"不变量 —— 🆕 序⑫ 改为**本地从监听集合算**(旧版是远端 `grep -c`:
// ① 它按"整行含该口号"计数 ⇒ 连 peer 列都算进去,会**高估**;② 夹具模式下取不到 ⇒ NaN ⇒ 假红。
// 现在:按 Local 列**精确**取端口 ⇒ 顺带把"`RELAY_PORT` 只出现在 `RELAY_BIND` 上"这条判据变成严格版。
const relayPort = r.need('RELAY_PORT')
const portOf = (a) => a.slice(a.lastIndexOf(':') + 1)
const relayListenTotal = [...actual].filter((a) => portOf(a) === relayPort).length
const relayListenLoopback = [...actual].filter((a) => a === `${relayBind}:${relayPort}`).length
const ok11 =
missing.length === 0 &&
extra.length === 0 &&
nftExtra.length === 0 &&
relayListenTotal === relayListenLoopback &&
relayListenTotal > 0
add(
'OBS-11',
ok11,
`集合 必在 ${required.size} 允许 ${allowed.size} 区间 ${ranges.length} 派生 ${derived.size} 实际 ${actual.size} ` +
`多出 ${extra.length} 缺失 ${missing.length} |nft accept ${acceptSet.size} 多出 ${nftExtra.length}` +
`${nftNote} |relay 口绑定回环=${relayListenLoopback}/${relayListenTotal} 条`,
true,
)
for (const a of missing) process.stderr.write(`OBS-11 缺失 ${a}\n`)
for (const a of extra) process.stderr.write(`OBS-11 多出 ${a}\n`)
for (const t of nftExtra) process.stderr.write(`OBS-11 nft 多出 ${t}\n`)
if (!fixture) {
add(
'OBS-12',
Number(facts.get('relayRss')) <= r.num('RELAY_RSS_MAX_KB'),
`relay RSS=${facts.get('relayRss')}KB (阈值 ≤ ${r.num('RELAY_RSS_MAX_KB')}KB)`,
)
} else {
add('OBS-12', true, '夹具模式未取证')
}
const prefix = fixture ? '⚠️ FIXTURE ' : ''
for (const row of rows) {
process.stdout.write(`${prefix}${row.skip === true ? 'SKIP' : row.ok ? 'PASS' : 'FAIL'} ${row.id} ${row.text}\n`)
}
const red = rows.filter((x) => x.skip !== true && !x.ok)
if (red.length > 0) {
process.stderr.write(`❌ ${red.length} 项红:${red.map((x) => x.id).join(' , ')}\n`)
return EXIT_FAIL
}
return EXIT_OK
}
process.exit(main())
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env node
/**
* 覆盖网络 序⑥ · relay HMAC 密钥表增删(**运维小工具**,⛔ 非产品路径)。
*
* 为什么要有它:`/etc/dshs/relay-keys.json` 是**直接编辑会毁掉整张表**的那类文件
* (键 = 逻辑名 `<net>/<hostId>`,值 = 64 hex;写坏一个字符 ⇒ relay 起动即抛、
* 全部节点同时被拒)。所以增删都走这个工具:**先备份、再原子写、写完自校验**。
*
* 用法:
* node scripts/overlay-relaykey-add.cjs --file /etc/dshs/relay-keys.json --name ops/w-dev
* node scripts/overlay-relaykey-add.cjs --file … --name ops/w-dev --secret <64hex>
* node scripts/overlay-relaykey-add.cjs --file … --name ops/w-dev --remove
*
* @module scripts/overlay-relaykey-add
*/
'use strict'
const { chmodSync, copyFileSync, readFileSync, renameSync, writeFileSync } = require('node:fs')
const { randomBytes } = require('node:crypto')
const args = {}
const argv = process.argv.slice(2)
for (let i = 0; i < argv.length; i++) {
if (!argv[i].startsWith('--')) continue
const k = argv[i].slice(2)
const v = argv[i + 1]
if (v === undefined || v.startsWith('--')) args[k] = true
else {
args[k] = v
i++
}
}
if (typeof args.file !== 'string' || typeof args.name !== 'string') {
process.stderr.write('usage: --file <relay-keys.json> --name <net/hostId> [--secret <64hex>] [--remove]\n')
process.exit(2)
}
const table = JSON.parse(readFileSync(args.file, 'utf8'))
if (table === null || typeof table !== 'object' || Array.isArray(table)) {
throw new Error(`${args.file} 不是对象`)
}
const before = Object.keys(table)
copyFileSync(args.file, `${args.file}.bak-seq6-${Date.now()}`)
if (args.remove === true) {
if (!(args.name in table)) {
process.stderr.write(`⚠ ${args.name} 不在表里(无需删)\n`)
} else {
delete table[args.name]
}
} else {
const secret =
typeof args.secret === 'string' ? args.secret.trim().toLowerCase() : randomBytes(32).toString('hex')
if (!/^[0-9a-f]{64}$/.test(secret)) throw new Error('secret 必须是 64 位 hex')
table[args.name] = secret
}
const tmp = `${args.file}.tmp`
writeFileSync(tmp, `${JSON.stringify(table, null, 2)}\n`, { mode: 0o600 })
renameSync(tmp, args.file)
chmodSync(args.file, 0o600)
// 自校验:用产品代码自己的装载器读一遍(键名/密钥形状非法会在这里炸)
const { loadKeysFile } = require('../lib/net/relay/keys.js')
const parsed = loadKeysFile(args.file)
process.stdout.write(
`✓ ${args.file}:${before.length} → ${parsed.size} 条\n` +
` 键:${[...parsed.keys()].join(', ')}\n` +
(args.remove === true ? '' : ` ${args.name} 的 secret(仅本次打印,⛔ 别进日志):${table[args.name]}\n`),
)
+357
View File
@@ -0,0 +1,357 @@
#!/usr/bin/env node
/**
* 覆盖网络 序⑥ · S2/S5 一次性载荷与探针(⛔ 不进产品路径、⛔ 不进 package.json 依赖)。
*
* 为什么要有它:参数表 §3.5 的 `WAN_STEADY_THROUGHPUT` 与 §3.3 的 `PER_PLAYER_BW_LOCAL`
* 都是 `待测` —— 两端都拿不到"可读大响应"(无凭据时只回 24–68 B 的 401/404)。
* 本脚本只做一件事:**造可控载荷 + 量稳态速率**,供人工写回参数表。
*
* ## 四种角色
* | 角色 | 跑在哪 | 干什么 |
* |---|---|---|
* | `--serve` | 被压的一端(106) | HTTP:`/blob?sec=N`(连续吐 N 秒)/`/blob?mb=N`/`POST /sink`(吞体) |
* | `--download` | 挤压的一端(47) | 连 relay 回环端点 → 丢前 `--drop` 秒 → 量 `--secs` 秒 ⇒ **被压端 → 挤压端** |
* | `--upload` | 挤压的一端(47) | 反向 POST 大流量(**尊重反压**)⇒ **挤压端 → 被压端** |
* | `--echo-serve` / `--players` | 106 / 47 | S5 合成玩家:长度前缀回显 + 多连接消息率扫描 |
*
* ## 口径(⛔ 别丢,写回参数表时要抄)
* - 速率 = **稳态段字节数 ÷ 稳态段秒数**(前 `--drop` 秒的字节**单独计数、不进分子**);
* - `--upload` 的速率**必须**在 `write()` 返回 false 时停下等 `drain` —— 否则量的是 Node 的
* 内存缓冲,不是链路(这正是"看起来很快、其实全在本地队列里"这个假象的成因)。
*
* 用法示例(远端):
* node overlay-wan.cjs --serve --port 19777 --secs 600
* node overlay-wan.cjs --download --port <relay回环口号> --secs 30 --drop 3
* node overlay-wan.cjs --upload --port <relay回环口号> --secs 30 --drop 3 --mb 0
*
* @module scripts/overlay-wan
*/
'use strict'
const http = require('node:http')
const net = require('node:net')
function parseArgs(argv) {
const out = {}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
if (!a.startsWith('--')) continue
const k = a.slice(2)
const v = argv[i + 1]
if (v === undefined || v.startsWith('--')) out[k] = true
else {
out[k] = isNaN(Number(v)) ? v : Number(v)
i++
}
}
return out
}
const args = parseArgs(process.argv.slice(2))
const log = (s) => process.stderr.write(`[wan] ${s}\n`)
const out = (obj) => process.stdout.write(`### RESULT ### ${JSON.stringify(obj)}\n`)
const CHUNK = Buffer.alloc(64 * 1024, 0x41)
// ─────────────────────────── serve ───────────────────────────
function serve(port, secs) {
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x')
if (req.method === 'POST') {
// 吞体:只数不清
let n = 0
req.on('data', (c) => {
n += c.length
})
req.on('end', () => {
res.writeHead(200, { 'content-type': 'text/plain', connection: 'close' })
res.end(`sink ${n}\n`)
log(`POST /sink ${n} B in ${Date.now() - t0} ms`)
})
var t0 = Date.now()
return
}
if (u.pathname === '/small') {
res.writeHead(200, { 'content-type': 'text/plain', connection: 'close' })
res.end('ok-0123456789-0123456789\n')
return
}
const sec = Number(u.searchParams.get('sec') || 0)
const mb = Number(u.searchParams.get('mb') || 0)
if (sec > 0) {
res.writeHead(200, { 'content-type': 'application/octet-stream', connection: 'close' })
const start = Date.now()
let sent = 0
const pump = () => {
while (Date.now() - start < sec * 1000) {
if (!res.write(CHUNK)) {
sent += CHUNK.length
res.once('drain', pump)
return
}
sent += CHUNK.length
}
res.end()
log(`GET /blob?sec=${sec} 送出 ${sent} B`)
}
pump()
return
}
res.writeHead(200, { 'content-type': 'application/octet-stream', connection: 'close' })
const total = mb * 1024 * 1024
let sent = 0
const pump = () => {
while (sent < total) {
if (!res.write(CHUNK)) {
sent += CHUNK.length
res.once('drain', pump)
return
}
sent += CHUNK.length
}
res.end()
}
pump()
})
server.listen(port, '127.0.0.1', () => log(`serve 127.0.0.1:${port} secs=${secs}`))
if (secs > 0) setTimeout(() => process.exit(0), secs * 1000)
}
// ─────────────────────── download / upload ───────────────────────
/** 从 `buf` 里切掉 HTTP 头,返回剩下的体;找不到头返回 null。 */
function splitHeader(buf) {
const i = buf.indexOf('\r\n\r\n')
if (i < 0) return null
return buf.subarray(i + 4)
}
function download(port, secs, drop) {
const t0 = Date.now()
const sock = net.connect(port, '127.0.0.1')
let hdrDone = false
let tail = Buffer.alloc(0)
let ramp = 0
let steady = 0
sock.on('connect', () => {
sock.write(
`GET /blob?sec=${Math.ceil(secs + drop + 6)} HTTP/1.1\r\nHost: wan\r\nConnection: close\r\n\r\n`,
)
})
const finish = () => {
try {
sock.destroy()
} catch {
/* noop */
}
out({
mode: 'download',
port,
dropS: drop,
steadyS: secs,
rampBytes: ramp,
steadyBytes: steady,
steadyKBps: Math.round((steady / 1024 / secs) * 10) / 10,
ms: Date.now() - t0,
})
process.exit(0)
}
sock.on('data', (b) => {
if (!hdrDone) {
tail = Buffer.concat([tail, b])
const body = splitHeader(tail)
if (body === null) return
hdrDone = true
b = body
}
const el = Date.now() - t0
if (el < drop * 1000) ramp += b.length
else if (el < (drop + secs) * 1000) steady += b.length
else finish()
})
sock.on('error', (e) => {
log(`sock error ${e.message}`)
finish()
})
setTimeout(finish, (drop + secs + 20) * 1000)
}
function upload(port, secs, drop) {
const t0 = Date.now()
const sock = net.connect(port, '127.0.0.1')
let ramp = 0
let steady = 0
let acc = 0
let started = false
const head =
'POST /sink HTTP/1.1\r\nHost: wan\r\nContent-Type: application/octet-stream\r\n' +
'Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n'
const frame = Buffer.concat([
Buffer.from((CHUNK.length).toString(16) + '\r\n'),
CHUNK,
Buffer.from('\r\n'),
])
const finish = () => {
try {
sock.destroy()
} catch {
/* noop */
}
out({
mode: 'upload',
port,
dropS: drop,
steadyS: secs,
rampBytes: ramp,
steadyBytes: steady,
steadyKBps: Math.round((steady / 1024 / secs) * 10) / 10,
ms: Date.now() - t0,
})
process.exit(0)
}
const pump = () => {
const el = Date.now() - t0
if (el >= (drop + secs) * 1000) return finish()
let ok = true
while (ok) {
ok = sock.write(frame)
if (el < drop * 1000) ramp += CHUNK.length
else steady += CHUNK.length
if (Date.now() - t0 >= (drop + secs) * 1000) break
}
if (!ok) sock.once('drain', pump)
else setImmediate(pump)
}
sock.on('connect', () => {
sock.write(head)
started = true
pump()
})
sock.on('error', (e) => {
log(`sock error ${e.message}`)
finish()
})
setTimeout(finish, (drop + secs + 30) * 1000)
}
// ─────────────────────── S5:合成玩家 ───────────────────────
function echoServe(port) {
const server = net.createServer((c) => {
c.setNoDelay(true)
let buf = Buffer.alloc(0)
c.on('data', (d) => {
buf = Buffer.concat([buf, d])
for (;;) {
if (buf.length < 4) return
const n = buf.readUInt32BE(0)
if (buf.length < 4 + n) return
const payload = buf.subarray(4, 4 + n)
buf = buf.subarray(4 + n)
c.write(payload)
}
})
c.on('error', () => c.destroy())
})
server.listen(port, '127.0.0.1', () => log(`echo-serve 127.0.0.1:${port}`))
}
function p95(arr) {
if (arr.length === 0) return 0
const s = [...arr].sort((a, b) => a - b)
return s[Math.min(s.length - 1, Math.floor(s.length * 0.95))]
}
function playOne(port, rate, secs, msgBytes, done) {
const sentAt = []
const rtts = []
let sent = 0
let recv = 0
const sock = net.connect(port, '127.0.0.1')
const frame = Buffer.alloc(4 + msgBytes, 0x42)
frame.writeUInt32BE(msgBytes, 0)
let buf = Buffer.alloc(0)
let stopped = false
const t0 = Date.now()
const report = () => {
if (stopped) return
stopped = true
const s = [...rtts].sort((a, b) => a - b)
const half = rtts.map((x) => x / 2).sort((a, b) => a - b)
done({
sent,
recv,
lossPct: sent === 0 ? 0 : Math.round(((sent - recv) / sent) * 10000) / 100,
rttP50: Math.round((s[Math.floor(s.length * 0.5)] || 0) * 10) / 10,
rttP95: Math.round(p95(rtts) * 10) / 10,
oneWayP50: Math.round((half[Math.floor(half.length * 0.5)] || 0) * 10) / 10,
oneWayP95: Math.round(p95(half) * 10) / 10,
})
}
sock.on('connect', () => {
sock.setNoDelay(true)
const timer = setInterval(() => {
if (Date.now() - t0 > secs * 1000) {
clearInterval(timer)
sock.end()
report()
return
}
sentAt.push(Date.now())
sock.write(frame)
sent++
}, Math.max(1, Math.round(1000 / rate)))
})
sock.on('data', (d) => {
buf = Buffer.concat([buf, d])
while (buf.length >= msgBytes) {
buf = buf.subarray(msgBytes)
const t = sentAt[recv]
if (t !== undefined) rtts.push(Date.now() - t)
recv++
}
})
sock.on('error', (e) => log(`player err ${e.message}`))
sock.on('close', report)
}
function players(port, n, rate, secs, msgBytes) {
const results = []
let left = n
for (let i = 0; i < n; i++) {
setTimeout(() => {
playOne(port, rate, secs, msgBytes, (r) => {
results.push(r)
if (--left === 0) {
const agg = (k) => {
const a = results.map((x) => x[k]).sort((x, y) => x - y)
return a[Math.floor(a.length / 2)]
}
out({
mode: 'players',
players: n,
rate,
msgBytes,
secs,
perPlayerKBps: Math.round(((rate * msgBytes) / 1024) * 100) / 100,
aggThroughputKBps: Math.round(((rate * msgBytes * n) / 1024) * 10) / 10,
lossPctMax: Math.max(...results.map((r) => r.lossPct)),
oneWayP50: agg('oneWayP50'),
oneWayP95: agg('oneWayP95'),
})
process.exit(0)
}
})
}, i * 60)
}
}
// ─────────────────────────── main ───────────────────────────
if (args.serve) serve(args.port, args.secs || 0)
else if (args['echo-serve']) echoServe(args.port)
else if (args.download) download(args.port, args.secs, args.drop)
else if (args.upload) upload(args.port, args.secs, args.drop)
else if (args.players) players(args.port, args.players, args.rate, args.secs, args['msg-bytes'] || 200)
else {
process.stderr.write('usage: --serve --port P [--secs N] | --download|--upload --port P --secs S --drop D | --echo-serve --port P | --players --port P --players N --rate R --secs S\n')
process.exit(2)
}
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env node
/**
* 覆盖网络 序⑥ · S6 `MEM_PER_HOST_MB` 校准(⛔ 一次性脚本、不进产品路径)。
*
* ## 为什么必须放大测
* 现网 `used = 2` ⇒ relay 的 RSS(72888 KB)**只反映 Node 基座**,参数表 §5.1 的
* "2 MB/台" 是**推导值不是实测**。本脚本把 N 拉到 2/10/25/50/100,量 RSS 斜率。
*
* ## 干净方案(⛔ 不污染生产)
* - **relay 起在本机回环**(独立进程、独立高层口号,与生产的 20080 无关);
* - **N 个合成 client 跑在"本脚本进程内"** —— 这是刻意的:只要被测量的对象是 **relay 子进程**
* 的 RSS,client 的内存长在本进程里 ⇒ **不进被测量**。(若让 client 也各自起进程,
* 机器上会多出 100 个 Node 基座 ≈ 4 GB,纯浪费。)
* - 全程 **127.0.0.1**,零公网面 ⇒ ⛔ 不动 47 的任何配置(S6 权限附注)。
*
* ## 判据
* 线性回归 `RSS(N) = a + b·N`,`R² ≥ 0.9` 方为有效;否则**如实报告跳点**,⛔ 不许硬套斜率。
*
* 用法:node scripts/relay-mem-calibrate.mjs [--base 19000] [--port 23456]
*
* @module scripts/relay-mem-calibrate
*/
import { spawn } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, dirname } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
const HERE = dirname(fileURLToPath(import.meta.url))
const REPO = join(HERE, '..')
function arg(name, dflt) {
const i = process.argv.indexOf(`--${name}`)
return i >= 0 ? process.argv[i + 1] : dflt
}
const PORT = Number(arg('port', 23456))
const BASE = Number(arg('base', 19000))
const SPAN = Number(arg('span', 3000))
const POINTS = (arg('points', '2,10,25,50,100')).split(',').map(Number)
const logs = []
const { RelayClient } = await import(pathToFileURL(join(REPO, 'lib/net/relay/client.js')).href)
const tmp = mkdtempSync(join(tmpdir(), 'relay-mem-'))
const keysFile = join(tmp, 'keys.json')
const keys = {}
const secrets = {}
for (let i = 1; i <= Math.max(...POINTS); i++) {
const sec = randomBytes(32).toString('hex')
secrets[i] = sec
keys[`ops/w-${i}`] = sec
}
writeFileSync(keysFile, JSON.stringify(keys, null, 2))
const relay = spawn(
process.execPath,
[
join(REPO, 'lib/net/relay/main.js'),
'--port',
String(PORT),
'--keys-file',
keysFile,
'--base',
String(BASE),
'--span',
String(SPAN),
'--max-hosts',
'0',
],
{ cwd: REPO, stdio: ['ignore', 'pipe', 'pipe'] },
)
relay.stdout.on('data', (d) => logs.push(String(d)))
relay.stderr.on('data', (d) => logs.push(String(d)))
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function status() {
const r = await fetch(`http://127.0.0.1:${PORT}/status`)
return r.json()
}
async function used() {
try {
return (await status()).capacity.used
} catch {
return -1
}
}
async function rssKbMedian(pid, times = 7, gapMs = 500) {
const arr = []
for (let i = 0; i < times; i++) {
arr.push(await rssKbOnce(pid))
await sleep(gapMs)
}
const s = arr.filter((x) => Number.isFinite(x)).sort((a, b) => a - b)
return { median: s[Math.floor(s.length / 2)], min: s[0], max: s[s.length - 1], n: s.length }
}
async function rssKbOnce(pid) {
if (process.platform === 'win32') {
const r = await new Promise((res) => {
const p = spawn('powershell', ['-NoProfile', '-Command', `(Get-Process -Id ${pid}).WorkingSet64`])
let o = ''
p.stdout.on('data', (d) => (o += d))
p.on('close', () => res(o.trim()))
})
return Math.round(Number(r) / 1024)
}
const r = await new Promise((res) => {
const p = spawn('sh', ['-c', `ps -o rss= -p ${pid}`])
let o = ''
p.stdout.on('data', (d) => (o += d))
p.on('close', () => res(o.trim()))
})
return Number(r)
}
const clients = []
const samples = []
const results = { platform: process.platform, node: process.version, points: [], raw: [] }
for (const target of POINTS) {
while (clients.length < target) {
const i = clients.length + 1
const c = new RelayClient({
url: `ws://127.0.0.1:${PORT}/dshs-relay`,
hostId: `w-${i}`,
networkId: 'ops',
secret: secrets[i],
ports: [BASE + i],
})
c.start()
clients.push(c)
}
let u = -1
for (let i = 0; i < 60; i++) {
u = await used()
if (u >= target) break
await sleep(500)
}
await sleep(1000)
const rss = await rssKbMedian(relay.pid)
samples.push({ n: clients.length, used: u, rssKb: rss.median, rssMin: rss.min, rssMax: rss.max, nSamples: rss.n })
process.stderr.write(`[mem] N=${clients.length} used=${u} RSS=${rss.median} KB (min ${rss.min} / max ${rss.max})\n`)
}
// 线性回归
const n = samples.length
const sx = samples.reduce((a, s) => a + s.n, 0)
const sy = samples.reduce((a, s) => a + s.rssKb, 0)
const sxx = samples.reduce((a, s) => a + s.n * s.n, 0)
const sxy = samples.reduce((a, s) => a + s.n * s.rssKb, 0)
const b = (n * sxy - sx * sy) / (n * sxx - sx * sx)
const a = (sy - b * sx) / n
const my = sy / n
const ssTot = samples.reduce((acc, s) => acc + (s.rssKb - my) ** 2, 0)
const ssRes = samples.reduce((acc, s) => acc + (s.rssKb - (a + b * s.n)) ** 2, 0)
results.points = samples
results.fit = {
interceptKb: Math.round(a),
slopeKbPerHost: Math.round(b * 10) / 10,
r2: Math.round((1 - ssRes / ssTot) * 10000) / 10000,
}
results.projected = {
memPerHostMbCeil20: Math.ceil((b / 1024) * 1.2 * 10) / 10,
at225HostsMb: Math.round((a + b * 225) / 1024),
}
const last = samples[samples.length - 1]
process.stdout.write(`### RESULT ### ${JSON.stringify({ ...results, lastUsed: last.used })}\n`)
for (const c of clients) c.stop()
relay.kill()
await sleep(500)
rmSync(tmp, { recursive: true, force: true })
process.exit(0)