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:
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
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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`),
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user