Files
admin 146c3d25ef 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
2026-09-17 17:03:27 +08:00

358 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 覆盖网络 序⑥ · 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)
}