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

+72
View File
@@ -0,0 +1,72 @@
/**
* 覆盖网络 S3 · 实例端口区间单测(不连外网、只在本机回环上 bind)。
* 运行:node --test test/instance-port.test.mjs(已含在 npm test / npm verify 中)
*
* ## 这个测试防的是什么
* `listen(0)` 让**每台 worker 各自**随机取端口,而跨机实例的隧道落点全挤在 Manager 的
* `127.0.0.1` 上 ⇒ 两台 worker 取到同号就撞号,`ssh -R` 失败还是**静默**的,
* Manager 会按该端口拨到**别人的实例**(2026-09-16 实测)。
*
* 所以这里要钉死两件事:
* ① 取到的端口**必须落在配置区间内**(区间隔离的前提)
* ② 起点被占 ⇒ **跳到下一个**,绝不"回落到 `listen(0)`"
*/
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createServer } from 'node:net'
import { findFreePortInRange, findInstancePort } from '../lib/supervisor/spawn.js'
/** 占住一个回环端口,返回 `{ port, release }`。 */
function holdPort() {
return new Promise((resolve, reject) => {
const server = createServer()
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
const port = typeof address === 'object' && address !== null ? address.port : 0
resolve({ port, release: () => new Promise((r) => server.close(() => r())) })
})
})
}
test('端口区间:取到的端口必须落在区间内', async () => {
// 用打洞拿到的空闲端口当区间起点 ⇒ 测试不依赖"20000 一定空着"这种环境假设
const { port, release } = await holdPort()
try {
const got = await findFreePortInRange(port, 200)
assert.ok(got >= port && got < port + 200, `越界:${got} 不在 [${port}, ${port + 200})`)
} finally {
await release()
}
})
test('端口区间:起点被占用 ⇒ 跳过它,不回落到 listen(0)', async () => {
const { port, release } = await holdPort()
try {
const got = await findFreePortInRange(port, 200)
assert.notEqual(got, port, '起点被占却仍返回了它 ⇒ 会撞号')
assert.ok(got >= port && got < port + 200)
} finally {
await release()
}
})
test('端口区间:区间耗尽 ⇒ 抛错(禁止静默退回 listen(0))', async () => {
await assert.rejects(() => findFreePortInRange(20000, 0), /区间已耗尽/)
})
test('findInstancePort:未配区间(base<=0)⇒ 退回旧的 listen(0) 行为', async () => {
const got = await findInstancePort(0, 1000)
assert.ok(got > 0 && got < 65536, `应拿到合法端口,实际 ${got}`)
})
test('findInstancePort:配了区间 ⇒ 结果落在区间内', async () => {
const { port, release } = await holdPort()
try {
const got = await findInstancePort(port, 128)
assert.ok(got >= port && got < port + 128, `越界:${got}`)
} finally {
await release()
}
})
+406
View File
@@ -0,0 +1,406 @@
/**
* 覆盖网络 · **② P0-3 名字解析与授权** 单测 —— 逻辑名是不是"唯一入口"、跨网是不是**真拒绝**。
*
* ## 这个文件要回答的两个问题(= 交接单 §4 Step 3 的两条 + §5 判据 3)
* 1. **`place` / `resolve` 的唯一入口收不收逻辑名** `<network_id>/<hostId>`?
* —— 只收裸 `hostId` 时,网络维度得由**每个调用方**自己补;两张网各有一台 `w-1` 就会**串网**。
* ⛔ 判据不是"代码里写了逻辑名",而是**拿两张同名 host 实测:各解析各的,谁也不覆盖谁**。
* 2. **跨网访问在 relay / 控制面被拒**,且**不泄露**目标在哪张网?
* —— 判据:`u:A` 的节点**看不到、也到不了** `u:B` 的节点;`ops` 对用户网**默认不可见**。
* "看不到" = 对"在别张网"与"根本不存在"的响应**逐字相同**(拿一串猜测的 hostId 打过来
* 也枚举不出别张网的成员)。
*
* 运行:`node --test test/overlay-auth.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。
*
* @module test/overlay-auth
*/
import assert from 'node:assert/strict'
import { randomBytes } from 'node:crypto'
import { createServer as createTcpServer, connect } from 'node:net'
import { test } from 'node:test'
import { RelayClient } from '../lib/net/relay/client.js'
import { RelayDialer } from '../lib/net/relay/dialer.js'
import { RelayServer } from '../lib/net/relay/server.js'
import { OPS_NETWORK, assertSameNetwork, logicalName, parseLogicalName } from '../lib/net/relay/network.js'
import { agentBaseUrl, agentBaseUrlOf, parseReachability, VIA_LOCAL, VIA_RELAY } from '../lib/net/reachability.js'
import { LocalRendezvous, ManagerSshRendezvous, RendezvousRegistry } from '../lib/net/rendezvous.js'
const BASE = 47000
const SPAN = 200
const PATH = '/dshs-relay'
const U_A = 'u:alpha'
const U_B = 'u:beta'
/* ─────────────────────────── 工具 ─────────────────────────── */
/**
* 在 `[lo, hi)` 里挑一个空闲口,**返回端口号**(不是 server 对象 —— 传错了会变成
* `ports: [object Object]`,服务端只回一个 `bad-port`,看着像"网络不通")。
*/
async function listenInRange(server, lo, hi) {
for (let p = lo; p < hi; p++) {
const ok = await new Promise((resolve) => {
const onErr = () => {
server.off('error', onErr)
resolve(false)
}
server.once('error', onErr)
server.listen(p, '127.0.0.1', () => {
server.off('error', onErr)
resolve(true)
})
})
if (ok) return server.address().port
}
throw new Error(`no free port in ${lo}..${hi}`)
}
async function waitFor(fn, ms = 3000) {
const until = Date.now() + ms
while (Date.now() < until) {
if (fn()) return true
await new Promise((r) => setTimeout(r, 20))
}
return false
}
/**
* 回显服务:收到的每个 chunk 都回 `<tag>:<原字节>`。
* ⚠️ 与 `overlay-network.test.mjs` 同款(两个文件口径一致,免得"同一条判据两份行为")。
*/
function taggedEcho(tag) {
return createTcpServer((s) => s.on('data', (c) => s.write(`${tag}:${c.toString('utf8')}`)))
}
/** 拨号流往返:写进去、读回(前缀长度显式传入,避免"读到一半就认为对了")。 */
function dialRoundTrip(duplex, text, prefixLen = 0, ms = 5000) {
return new Promise((resolve, reject) => {
let got = ''
const timer = setTimeout(() => {
duplex.destroy()
reject(new Error(`dial roundTrip timeout (got ${got.length}/${text.length + prefixLen})`))
}, ms)
duplex.on('data', (chunk) => {
got += chunk.toString('utf8')
if (got.length >= text.length + prefixLen) {
clearTimeout(timer)
resolve(got)
}
})
duplex.on('error', (err) => {
clearTimeout(timer)
reject(err)
})
duplex.write(text)
})
}
/* ─────────── A1:同网断言(控制面侧那道门的公共件) ─────────── */
test('A1 assertSameNetwork:同网静默通过,跨网**抛**且点名两边是哪张网', () => {
assert.doesNotThrow(() => assertSameNetwork(OPS_NETWORK, OPS_NETWORK))
assert.doesNotThrow(() => assertSameNetwork(U_A, U_A))
// 抛而不是回 false:回 false 的写法会让"跨网拒绝"在日志里什么都不留(= 静默失效)
assert.throws(() => assertSameNetwork(U_A, U_B), /跨网/)
assert.throws(() => assertSameNetwork(U_A, U_B), new RegExp(U_A))
assert.throws(() => assertSameNetwork(U_A, U_B), new RegExp(U_B))
})
/* ─────────── A2:place 的唯一入口收逻辑名 ─────────── */
test('A2 parseReachability 的第一个参数是**逻辑名**:网络段由它切,调用方不许拼', () => {
const r = parseReachability(logicalName(U_A, 'w-1'), 'http://127.0.0.1:19000', VIA_RELAY)
assert.equal(r.hostId, 'w-1', 'hostId 必须是**裸**的(网络另存一栏,两者分开表达)')
assert.equal(r.networkId, U_A)
assert.equal(agentBaseUrl(r), 'http://127.0.0.1:19000', '取址与 S0/S2 逐字一致(网络维度不进地址)')
// 裸 hostId 仍兼容 ⇒ 落 ops(过渡期:现网所有调用方一个字都不用改)
assert.equal(parseReachability('w-47', 'http://127.0.0.1:19100', VIA_LOCAL).networkId, OPS_NETWORK)
// 非法网络段 ⇒ **抛**(配错别伪装成"这张网里没有它")
assert.throws(() => parseReachability('UPPER/w-1', 'http://x:1', VIA_LOCAL), /网络段/)
})
/* ─────────── A3:resolve 的唯一入口收逻辑名,两张同名 host 不互相覆盖 ─────────── */
test('A3 两张网各有一台同名 w-1 ⇒ 解析各查各的(键是逻辑名,不是裸 hostId)', async () => {
const table = new Map([
[logicalName(OPS_NETWORK, 'w-1'), '127.0.0.1:19001'],
[logicalName(U_A, 'w-1'), '127.0.0.1:19002'],
])
const reg = new RendezvousRegistry([
new LocalRendezvous((name) => table.get(name)),
new ManagerSshRendezvous({ target: 't', addressOf: (name) => table.get(name) }),
])
for (const impl of [reg.get(VIA_LOCAL), reg.get('manager-ssh')]) {
const ops = await impl.resolve(logicalName(OPS_NETWORK, 'w-1'))
const alpha = await impl.resolve(logicalName(U_A, 'w-1'))
assert.equal(ops.networkId, OPS_NETWORK)
assert.equal(ops.address, '127.0.0.1:19001', 'ops 的 w-1 拿到的是 ops 的落点')
assert.equal(alpha.networkId, U_A)
assert.equal(alpha.address, '127.0.0.1:19002', `${impl.id}:u:alpha 的 w-1 拿到的是 u:alpha 的落点(没被 ops 覆盖)`)
assert.notEqual(ops.address, alpha.address, '同名 host 分属两张网 ⇒ 落点必须不同')
}
// 查表键是逻辑名 —— 这条断言看着废话,但正是"只按裸 hostId 建键"会炸的地方
assert.equal(await reg.get(VIA_LOCAL).resolve('w-1'), undefined, '裸 hostId 不该命中任何逻辑名条目')
})
/* ─────────── A4:via=relay 时禁止回落到 endpoint ─────────── */
test('A4 via=relay 且解析不出落点 ⇒ **抛**(不许回落到 endpoint = relay 落点)', () => {
const relayHost = { hostId: 'w-106', agentUrl: 'http://127.0.0.1:19000', via: VIA_RELAY }
assert.throws(() => agentBaseUrlOf(relayHost), /拒绝回落到 endpoint/)
// 一旦解析成功 ⇒ 照常取址,且**优先**于 endpoint
const ok = {
...relayHost,
reachability: { hostId: 'w-106', networkId: OPS_NETWORK, via: VIA_RELAY, address: '127.0.0.1:42067', scheme: 'http' },
}
assert.equal(agentBaseUrlOf(ok), 'http://127.0.0.1:42067')
// 非 relay 语义(同机直连 / ssh 隧道)照旧回落 endpoint —— 这条不能被误伤
assert.equal(agentBaseUrlOf({ hostId: 'w-47', agentUrl: 'http://127.0.0.1:19100', via: VIA_LOCAL }), 'http://127.0.0.1:19100')
assert.equal(agentBaseUrlOf({ hostId: 'w-106', agentUrl: 'http://127.0.0.1:19000' }), 'http://127.0.0.1:19000')
})
/* ─────────── A5:控制面侧的跨网门(RelayDialer 口池) ─────────── */
test('A5 RelayDialer:落点口**真的能拨通**(DIAL target 必须是裸 hostId)+ 跨网拒 + 不占槽位', async (t) => {
const wSecret = randomBytes(32).toString('hex')
const mSecret = randomBytes(32).toString('hex')
const logs = []
const echo = taggedEcho('ops')
const echoPort = await listenInRange(echo, BASE + 40, BASE + SPAN - 1)
const srv = new RelayServer({
port: 0,
keys: new Map([
['wx', wSecret],
['manager', mSecret],
]),
instancePortBase: BASE,
instancePortSpan: SPAN,
dialers: new Map([[OPS_NETWORK, new Set(['manager'])]]),
log: (line) => logs.push(line),
})
await srv.start()
const url = `ws://127.0.0.1:${srv.boundPort}${PATH}`
const worker = new RelayClient({
url,
hostId: 'wx',
secret: wSecret,
ports: [echoPort],
networkId: OPS_NETWORK,
log: () => {},
})
const mClient = new RelayClient({
url,
hostId: 'manager',
secret: mSecret,
ports: [],
dialer: true,
networkId: OPS_NETWORK,
log: (line) => logs.push(`[m] ${line}`),
})
assert.equal(mClient.networkId, OPS_NETWORK, '拨号通道声明的网要从客户端读(不另配一份)')
const dialer = new RelayDialer({ client: mClient, portBase: BASE + 60, portSpan: 8, poolSize: 4, log: (l) => logs.push(l) })
worker.start()
mClient.start()
// ⚠️ **必须 await**:口池绑完之前 `localPortFor()` 恒返回 undefined(失败关闭)——
// 少了这一句,本条会以"同网拿不到落点"的样子失败,看着像跨网判据炸了,其实是没绑池。
await dialer.start()
t.after(async () => {
worker.stop()
mClient.stop()
dialer.close()
await echo.close()
await srv.stop()
})
assert.ok(await waitFor(() => srv.isOnline('wx', OPS_NETWORK)), `worker 未注册:${logs.slice(-6).join(' | ')}`)
assert.ok(await waitFor(() => srv.isOnline('manager', OPS_NETWORK)), 'manager 未注册')
const name = logicalName(OPS_NETWORK, 'wx')
const local = dialer.localPortFor(name, echoPort)
assert.equal(typeof local, 'number', '同网必须给落点')
/**
* 🔴 **本条的承重判据**:真连一次落点口 ⇒ 触发 `onConn` ⇒ 走一次**真 DIAL**。
*
* 为什么不能只测 `localPortFor` 的返回值:键从 P0-3 起是**逻辑名**,而 `DIAL.target` 要的是
* **裸 hostId** —— 漏剥网络段时服务端按 `ops/ops/wx` 找会话 ⇒ 回 `target-offline`
* (看着像"节点离线",节点其实好好在册)。这个 bug **只测返回值是抓不到的**
* (2026-09-17 线上实测踩过一次:`refused: 8 / streamsOpened: 0`)。
*/
const sock = connect(local, '127.0.0.1')
const got = await new Promise((resolve, reject) => {
let acc = ''
const timer = setTimeout(() => {
sock.destroy()
reject(new Error(`落点口未拨通,最近日志:${logs.slice(-6).join(' | ')}`))
}, 3000)
sock.on('data', (b) => {
acc += b.toString('utf8')
if (acc.length >= 'ops:ping'.length) {
clearTimeout(timer)
resolve(acc)
}
})
sock.on('error', (e) => {
clearTimeout(timer)
reject(e)
})
sock.on('connect', () => sock.write('ping'))
})
assert.equal(got, 'ops:ping', '落点口必须真的把字节送到 worker 的本地端口(⇒ DIAL target 是裸 hostId)')
sock.destroy()
// 跨网:**回 undefined**(失败关闭),不是"给个口让它去撞"
assert.equal(dialer.localPortFor(logicalName(U_A, 'wx'), echoPort), undefined)
assert.ok(
logs.some((l) => l.includes('跨网拒绝') && l.includes(U_A)),
`跨网拒绝必须留日志(点名两边是哪张网),实测:${logs.join(' | ')}`,
)
// 不占槽位:同网再取还是**同一个**口(池大小为 4,若被跨网请求污染,这里会变成新口)
assert.equal(dialer.localPortFor(name, echoPort), local)
})
/* ─────────── A6:合体判据 —— 看不到 + 到不了(真 server / 真拨号) ─────────── */
test('A6 跨网:到不了,且**分辨不出**"在别张网"与"根本不存在"(逐字相同的响应)', async (t) => {
const secrets = {
alpha: randomBytes(32).toString('hex'),
beta: randomBytes(32).toString('hex'),
alphaDialer: randomBytes(32).toString('hex'),
betaDialer: randomBytes(32).toString('hex'),
opsManager: randomBytes(32).toString('hex'),
}
const logs = []
const echoA = taggedEcho('alpha')
const echoB = taggedEcho('beta')
const portA = await listenInRange(echoA, BASE + 20, BASE + SPAN - 1)
const portB = await listenInRange(echoB, BASE + 21, BASE + SPAN - 1)
const srv = new RelayServer({
port: 0,
/**
* ⚠️ **序③ 起:密钥表是成员资格的唯一判据** —— 键用**逻辑名** `<网>/<hostId>`(`keys.ts`),
* 所以"这台 host 属于哪张网"由**配置**决定,而不是由 HELLO 的声明决定。
* ⇒ 本测试里每台 host 都按**它真正所在的网**登记;声明别的网会被 `network-mismatch`
* 挡在 HELLO 那一步(比 DIAL 那道门更早,也更省一次会话)。
* 「两张网各有一台同名 `w-1` 且互不覆盖」由 **A3** 在解析入口上判(唯一入口),
* 由 **overlay-network.U5** 在真连接上判(那里两张网各有自己的 `w-1` 登记项)。
*/
keys: new Map([
[logicalName(U_A, 'aw1'), secrets.alpha],
[logicalName(U_B, 'bw1'), secrets.beta],
[logicalName(U_A, 'ad'), secrets.alphaDialer],
[logicalName(U_B, 'bd'), secrets.betaDialer],
[logicalName(OPS_NETWORK, 'manager'), secrets.opsManager],
]),
instancePortBase: BASE,
instancePortSpan: SPAN,
/** 三张网**各自都有合法拨号方** ⇒ 拒绝只能来自网络维度本身,不是"忘了配白名单"。 */
dialers: new Map([
[OPS_NETWORK, new Set(['manager'])],
[U_A, new Set(['ad'])],
[U_B, new Set(['bd'])],
]),
log: (line) => logs.push(line),
})
await srv.start()
const url = `ws://127.0.0.1:${srv.boundPort}${PATH}`
const clients = []
const mkNode = (hostId, secret, network, ports) => {
const c = new RelayClient({ url, hostId, secret, ports, networkId: network, log: () => {} })
clients.push(c)
c.start()
return c
}
const mkDialer = (hostId, secret, network) => {
const c = new RelayClient({
url,
hostId,
secret,
ports: [],
dialer: true,
networkId: network,
log: (line) => logs.push(`[${hostId}] ${line}`),
})
clients.push(c)
c.start()
return c
}
mkNode('aw1', secrets.alpha, U_A, [portA])
mkNode('bw1', secrets.beta, U_B, [portB])
const ad = mkDialer('ad', secrets.alphaDialer, U_A)
mkDialer('bd', secrets.betaDialer, U_B)
mkDialer('manager', secrets.opsManager, OPS_NETWORK)
t.after(async () => {
for (const c of clients) c.stop()
await echoA.close()
await echoB.close()
await srv.stop()
})
assert.ok(await waitFor(() => srv.isOnline('aw1', U_A)), `u:alpha 的节点未注册:${logs.slice(-8).join(' | ')}`)
assert.ok(await waitFor(() => srv.isOnline('bw1', U_B)), `u:beta 的节点未注册:${logs.slice(-8).join(' | ')}`)
assert.ok(await waitFor(() => srv.isOnline('ad', U_A)), 'u:alpha 拨号方未注册')
assert.ok(await waitFor(() => srv.isOnline('bd', U_B)), 'u:beta 拨号方未注册')
// ── 正证:同网全通(隔离挡的是"跨网",不是"拨号"本身) ──
const stream = await ad.openStream('aw1', portA)
assert.equal(
await dialRoundTrip(stream, 'ping-alpha', 'alpha:'.length),
'alpha:ping-alpha',
'同网必须真的通(且字节走的是 u:alpha 自己那台,不是别网的同名机)',
)
// ── 反证 ①:到不了 —— `u:alpha` 的拨号方够不到只在 `u:beta` 里的节点 ──
let errForeign = ''
await assert.rejects(
() => ad.openStream('bw1', portB),
(e) => {
errForeign = e instanceof Error ? e.message : String(e)
return true
},
'u:alpha 拨号方不得够到 u:beta 的节点',
)
// ── 反证 ②:**看不到** —— "在别张网"与"根本不存在"的响应**逐字相同** ──
// 拿一个哪儿都没有的 hostId 打一次:两者若不同形,就能拿一串猜测的 hostId 把别张网**枚举**出来。
let errGhost = ''
await assert.rejects(
() => ad.openStream('definitely-not-a-host', portB),
(e) => {
errGhost = e instanceof Error ? e.message : String(e)
return true
},
'不存在的 hostId 必须被拒',
)
const norm = (m) => m.replaceAll('bw1', 'X').replaceAll('definitely-not-a-host', 'X')
assert.equal(
norm(errForeign),
norm(errGhost),
`"在别张网"与"不存在"必须逐字同形(否则可枚举对端清单):\n 在别网=${errForeign}\n 不存在=${errGhost}`,
)
// 响应里不得出现任何网络名(`u:beta` 一旦出现 ⇒ 对端清单泄露)
assert.equal(errForeign.includes(U_B), false, `响应不得含网络名,实测:${errForeign}`)
assert.equal(errForeign.includes('dialer-not-in-network'), false, `对外不得有跨网专属拒码,实测:${errForeign}`)
// ── 反证 ③:`ops` 对用户网**默认不可见**(反向也一样) ──
// 日志里必须留得下区分(服务端可取证)—— 这是"看不到"与"查得到"的平衡点
assert.ok(
logs.some((l) => l.includes('dialer-not-in-network') && l.includes(U_B)),
`relay 日志必须能区分"在别张网",实测:${logs.filter((l) => l.includes('DIAL')).slice(-6).join(' | ')}`,
)
})
/* ─────────── A7:范围声明(Step 3 只做 P0-3) ─────────── */
test('A7 范围:本步只做"逻辑名 + 授权",不碰 L3 / DNS / 对端清单(D1 / D4)', async () => {
const net = await import('../lib/net/relay/network.js')
assert.equal(typeof net.logicalName, 'function')
assert.deepEqual(parseLogicalName(`${U_A}/w-1`), { network: U_A, hostId: 'w-1' })
// ⛔ 本阶段刻意**没有**的东西:地址段分配 / DNS 名 / 对端清单查询
assert.equal(net.assignAddress, undefined, 'P0-3 不做 L3 地址分配(D1:推迟到 L3 专项)')
assert.equal(net.resolveDns, undefined, 'P0-3 不自建 DNS(D1)')
assert.equal(net.listPeers, undefined, 'P0-3 不下发对端清单(D4)')
})
+747
View File
@@ -0,0 +1,747 @@
/**
* 覆盖网络 · **② 引导三级链(P0-2)** 单测 —— 地址是"编译期常量"还是"可在线轮换的下发物"。
*
* ## 这个文件要回答的唯一问题
* 「换域名 / 换机器」时,**已经装出去的客户端能不能自己跟上来**?
* `§A2` 的判据很硬:**不能**就只能靠"所有客户端升级重装",那是灾难。
* 所以本文件的验收标准不是"能取到地址",而是四条**行为断言**:
*
* 1. **冷启动只靠内置种子就能起来**(清 env + 清缓存 ⇒ 走种子 → 取签名目录 → 拿到地址);
* 2. **引导地址可在线轮换**:目录里改 `bootstrap[]` ⇒ 下一次刷新**从新地址取**,**不重装、不升级**;
* 3. **签名不对 ⇒ 失败关闭**:既不写缓存、也不拿它的地址去连(受信公钥为空同样拒绝);
* 4. **降级不越权**:取不到目录时可以用**过期但签名有效**的缓存 / 种子地址本身,
* 但**绝不**因此接受一份未签名 / 签名不符的目录。
*
* 运行:`node --test test/overlay-bootstrap.test.mjs`(Node ≥ 22;测 `lib/` 产物,先 `npm run build`)。
*
* @module test/overlay-bootstrap
*/
import assert from 'node:assert/strict'
import { generateKeyPairSync } from 'node:crypto'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import {
DEFAULT_OVERLAY_SEED,
DIRECTORY_PATH,
DIRECTORY_VERSION,
buildDirectoryDocument,
directoryPayload,
directoryUrlFor,
parseDirectory,
publicKeyFrom,
publicRelayEntries,
readCachedDirectory,
resolveOverlayRelay,
signDirectory,
toRelayUrl,
verifyDirectory,
writeCachedDirectory,
} from '../lib/net/relay/index.js'
// 序④(443/TCP 兜底 · L1):地址覆盖 —— 直接测模块(未从 index 再导出,避免动 index 的公共面)。
import {
currentAddrOverrides,
ensureOverlayAddrOverrides,
parseAddrOverrides,
resetOverlayAddrOverridesForTest,
} from '../lib/net/relay/addr-override.js'
import { createServer as createNetServer } from 'node:net'
import { createRequire } from 'node:module'
// ── 夹具 ──────────────────────────────────────────────────────────────────
/** 生成一对 Ed25519;同时给 PEM 与**裸 32 字节 hex**(两条解析路径都要能验签)。 */
function makeKeys() {
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
const spki = publicKey.export({ type: 'spki', format: 'der' })
return {
privatePem,
publicPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
publicRawHex: Buffer.from(spki.subarray(spki.length - 32)).toString('hex'),
}
}
/** 一份自签目录(默认 relays / bootstrap 都是给定的 origin 形态)。 */
function signedDoc(origin, keyPem, overrides = {}) {
const doc = buildDirectoryDocument({
relays: overrides.relays ?? [origin],
bootstrap: overrides.bootstrap ?? [origin],
network: overrides.network ?? 'ops',
now: overrides.now ?? Date.now(),
refreshAfterSeconds: overrides.refreshAfterSeconds ?? 300,
})
return { doc, sig: signDirectory(doc, keyPem) }
}
/**
* 起一个**真的**目录端点(`node:http`),请求任意路径都返回当前 `doc+sig`。
* 用真 HTTP 而不是 mock —— 本文件要证明的是"冷启动 / 轮换"这类**链路事实**。
*/
function serveDirectory(keys, initial) {
const state = { ...initial }
const server = createServer((req, res) => {
if (!req.url || !req.url.startsWith(DIRECTORY_PATH)) {
res.writeHead(404).end('{}')
return
}
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.end(JSON.stringify({ ...state.doc, sig: state.sig }))
})
return {
state,
listen: () =>
new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address()
resolve({ port: addr.port, origin: `http://127.0.0.1:${addr.port}/dshs-relay` })
})
}),
/** 换一份目录内容(模拟控制面把 bootstrap[] / relays 轮换走)。 */
set(next) {
state.doc = next.doc
state.sig = next.sig
},
close: () => new Promise((resolve) => server.close(() => resolve())),
}
}
function tmpCacheFile() {
const dir = mkdtempSync(join(tmpdir(), 'dshs-overlay-'))
return { dir, file: join(dir, 'overlay', 'directory.json') }
}
/** 记录型 fetch 替身:便于断言"这一步**根本没联网**"。 */
function refusingFetch(calls) {
return async (url) => {
calls.push(String(url))
throw new Error('network disabled in this test')
}
}
// ── B1 · 纯函数:载荷 / 派生 / 清洗 ────────────────────────────────────────
test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => {
// 同源约定:引导地址(中继入口)→ 目录端点 = 同 origin + 固定路径
assert.equal(directoryUrlFor('https://alotbuy.com/dshs-relay'), `https://alotbuy.com${DIRECTORY_PATH}`)
assert.equal(directoryUrlFor('http://127.0.0.1:8080/dshs-relay'), `http://127.0.0.1:8080${DIRECTORY_PATH}`)
// 已经是目录地址 ⇒ 原样
assert.equal(directoryUrlFor(`https://a.example${DIRECTORY_PATH}`), `https://a.example${DIRECTORY_PATH}`)
// ws 方言 ⇒ 取目录用 http 方言
assert.equal(directoryUrlFor('wss://a.example/dshs-relay'), `https://a.example${DIRECTORY_PATH}`)
// 中继地址归一化:只改协议、⛔ 不动 path(`/dshs-relay` 是 nginx location 的判据)
assert.equal(toRelayUrl('https://alotbuy.com/dshs-relay'), 'wss://alotbuy.com/dshs-relay')
assert.equal(toRelayUrl('http://127.0.0.1:20080/dshs-relay'), 'ws://127.0.0.1:20080/dshs-relay')
assert.equal(toRelayUrl('wss://a.example/x'), 'wss://a.example/x')
assert.equal(toRelayUrl('file:///etc/passwd'), undefined, '非 http/ws 协议必须拒绝')
assert.equal(toRelayUrl('not a url'), undefined)
// 载荷:同字段 ⇒ 同字符串;`network` 被签名覆盖(改它 ⇒ 载荷变)
const base = buildDirectoryDocument({
relays: ['https://a.example/dshs-relay'],
bootstrap: ['https://a.example/dshs-relay'],
network: 'ops',
now: 1_700_000_000_000,
})
const same = buildDirectoryDocument({
relays: ['https://a.example/dshs-relay'],
bootstrap: ['https://a.example/dshs-relay'],
network: 'ops',
now: 1_700_000_000_000,
})
assert.equal(directoryPayload(base), directoryPayload(same))
assert.notEqual(directoryPayload(base), directoryPayload({ ...base, network: 'u:5' }))
assert.notEqual(directoryPayload(base), directoryPayload({ ...base, relays: ['https://b.example/x'] }))
// 严格解析:坏文档一律 undefined(**默认拒绝**,不"尽力解析")
assert.ok(parseDirectory(base) !== undefined)
assert.equal(parseDirectory({ ...base, version: 2 }), undefined, '未知结构版本必须拒绝')
assert.equal(parseDirectory({ ...base, refreshAfterSeconds: 1 }), undefined, '刷新周期过小必须拒绝')
assert.equal(parseDirectory({ ...base, refreshAfterSeconds: 999_999 }), undefined, '刷新周期过大必须拒绝')
assert.equal(parseDirectory({ ...base, issuedAt: 'not-a-date' }), undefined)
assert.equal(parseDirectory({ ...base, network: '' }), undefined)
assert.equal(parseDirectory({ ...base, relays: ['file:///etc/passwd'] }), undefined, '非 http/ws 条目必须拒绝')
assert.equal(parseDirectory({ ...base, relays: [42] }), undefined)
assert.equal(parseDirectory({ ...base, relays: [], bootstrap: [] }), undefined, '全空的目录没有意义')
// 对外公布的地址:回环 / 私网 / 单标签 / IPv6 / CGNAT 一律剔除(目录是**公网可读**的)
assert.deepEqual(
publicRelayEntries([
'http://127.0.0.1:20080/dshs-relay',
'http://10.0.0.5/dshs-relay',
'http://192.168.1.9/dshs-relay',
'http://172.20.3.4/dshs-relay',
'http://100.64.7.7/dshs-relay',
'http://relaybox/dshs-relay',
'http://[::1]/dshs-relay',
'https://alotbuy.com/dshs-relay',
'https://relay.example.com/dshs-relay',
]),
['https://alotbuy.com/dshs-relay', 'https://relay.example.com/dshs-relay'],
)
// 常量位:只锚一条、指向**已持证书的门户**(第二地域留空 ⇒ 不新增域名成本)
assert.equal(DEFAULT_OVERLAY_SEED, 'https://alotbuy.com/dshs-relay')
// 语义去重:`wss://host/dshs-relay` 与 `https://host/dshs-relay` 是**同一个端点**(都走 443)
// ⇒ 目录里只该出现第一条(否则读目录的人会以为有两个中继)
assert.deepEqual(
publicRelayEntries(['wss://alotbuy.com/dshs-relay', 'https://alotbuy.com/dshs-relay']),
['wss://alotbuy.com/dshs-relay'],
)
// …而 `http://`(80)与 `wss://`(443)**不是**同一个端点 ⇒ 两条都留
assert.deepEqual(publicRelayEntries(['wss://a.example/x', 'http://a.example/x']), [
'wss://a.example/x',
'http://a.example/x',
])
// 同源不同路径 = 不同端点
assert.deepEqual(publicRelayEntries(['https://a.example/dshs-relay', 'https://a.example/dshs-relay2']), [
'https://a.example/dshs-relay',
'https://a.example/dshs-relay2',
])
})
// ── B2 · 验签(失败关闭) ────────────────────────────────────────────────
test('B2 验签:正例通过;改内容 / 换密钥 / 无受信密钥一律拒绝', () => {
const keys = makeKeys()
const other = makeKeys()
const origin = 'https://alotbuy.com/dshs-relay'
const { doc, sig } = signedDoc(origin, keys.privatePem)
// 正例:PEM 与**裸 32 字节 hex**两条解析路径都要能验
const okPem = verifyDirectory(doc, sig, [keys.publicPem])
assert.equal(okPem.ok, true)
assert.equal(okPem.ok === true ? okPem.doc.network : '', 'ops')
assert.equal(verifyDirectory(doc, sig, [keys.publicRawHex]).ok, true)
// 混进一把解不出来的 key 不该让整条链断掉(跳到下一把)
assert.equal(verifyDirectory(doc, sig, ['not-a-key', keys.publicPem]).ok, true)
assert.equal(publicKeyFrom('not-a-key'), undefined)
// 签名覆盖内容:改 relays(哪怕只多一条)⇒ payload 变 ⇒ 验签失败
const tampered = { ...doc, relays: [...doc.relays, 'https://evil.example/dshs-relay'] }
const bad1 = verifyDirectory(tampered, sig, [keys.publicPem])
assert.equal(bad1.ok, false)
assert.equal(bad1.ok === false ? bad1.reason : '', 'signature-mismatch')
// 换密钥签 ⇒ 拒绝
const foreign = signDirectory(doc, other.privatePem)
assert.equal(verifyDirectory(doc, foreign, [keys.publicPem]).ok, false)
assert.equal(verifyDirectory(doc, foreign, [other.publicPem]).ok, true)
// **无受信密钥 ⇒ 拒绝**(不可验 = 不接受 —— 这条就是"失败关闭"本身)
const noKeys = verifyDirectory(doc, sig, [])
assert.equal(noKeys.ok, false)
assert.equal(noKeys.ok === false ? noKeys.reason : '', 'no-trusted-keys')
// 签名形态坏掉 ⇒ 拒绝
assert.equal(verifyDirectory(doc, '', [keys.publicPem]).ok, false)
assert.equal(verifyDirectory(doc, 'AAAA', [keys.publicPem]).ok, false)
assert.equal(verifyDirectory({ ...doc, version: 9 }, sig, [keys.publicPem]).ok, false)
})
// ── B3 · 三级链的决策(注入 fetch / now,不依赖网络) ─────────────────────
test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级', async () => {
const keys = makeKeys()
const seed = 'https://alotbuy.com/dshs-relay'
// ① env 显式 ⇒ 压制引导链(**一次网络都不发**)
{
const calls = []
const r = await resolveOverlayRelay({
envUrl: 'wss://env.example/dshs-relay',
seeds: [seed],
trustedKeys: [keys.publicPem],
fetchImpl: refusingFetch(calls),
})
assert.equal(r.source, 'env')
assert.equal(r.url, 'wss://env.example/dshs-relay')
assert.deepEqual(calls, [], 'env 显式时不许联网')
}
// ② 缓存未过期 ⇒ 直接用(同样一次都不联网)
{
const { dir, file } = tmpCacheFile()
try {
const now = Date.now()
const { doc, sig } = signedDoc(seed, keys.privatePem, { now })
writeCachedDirectory(file, doc, sig, now)
const calls = []
const r = await resolveOverlayRelay({
seeds: [seed],
trustedKeys: [keys.publicPem],
cacheFile: file,
fetchImpl: refusingFetch(calls),
})
assert.equal(r.source, 'cache')
assert.equal(r.url, 'wss://alotbuy.com/dshs-relay')
assert.deepEqual(calls, [], '新鲜缓存不许联网')
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
// ③ 无缓存 + 取不到 ⇒ 回落到**种子地址本身**(种子就是中继入口,同源约定)
{
const { dir, file } = tmpCacheFile()
try {
const calls = []
const r = await resolveOverlayRelay({
seeds: [seed],
trustedKeys: [keys.publicPem],
cacheFile: file,
fetchImpl: refusingFetch(calls),
})
assert.equal(r.source, 'seed-fallback')
assert.equal(r.url, 'wss://alotbuy.com/dshs-relay')
assert.ok(calls.length >= 1, '应当尝试过取目录')
assert.equal(existsSync(file), false, '取不到目录**不许**留下缓存')
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
// ④ 有**过期但签名有效**的缓存 + 取不到 ⇒ 离线降级用旧值(D3 ③)
{
const { dir, file } = tmpCacheFile()
try {
const stale = Date.now() - 10 * 300_000
const { doc, sig } = signedDoc(seed, keys.privatePem, { now: stale })
writeCachedDirectory(file, doc, sig, stale)
const r = await resolveOverlayRelay({
seeds: [seed],
trustedKeys: [keys.publicPem],
cacheFile: file,
fetchImpl: refusingFetch([]),
})
assert.equal(r.source, 'stale-cache')
assert.equal(r.url, 'wss://alotbuy.com/dshs-relay')
// 缓存**被改坏 / 换了密钥** ⇒ 当作没有缓存(读也要验签)
assert.equal(readCachedDirectory(file, [makeKeys().publicPem], Date.now()), undefined)
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
// ⑤ 连种子都没有 ⇒ 明确返回"取不到"(等价于未配 relay,不抛异常)
{
const r = await resolveOverlayRelay({ seeds: [], trustedKeys: [], fetchImpl: refusingFetch([]) })
assert.equal(r.source, 'none')
assert.equal(r.url, '')
}
})
// ── B4 · 判据 5:清 env + 清缓存冷启动,只靠内置种子起来 ─────────────────
test('B4 冷启动:无 env、无缓存 ⇒ 走内置种子取到签名目录并留下缓存', async () => {
const keys = makeKeys()
const { dir, file } = tmpCacheFile()
const srv = serveDirectory(keys, signedDoc('http://placeholder/dshs-relay', keys.privatePem))
try {
const { origin } = await srv.listen()
srv.set(signedDoc(origin, keys.privatePem))
assert.equal(existsSync(file), false)
// 「清 env」= 不传 envUrl;「清缓存」= 缓存文件不存在 —— 只剩种子这一级
const first = await resolveOverlayRelay({
envUrl: '',
seeds: [origin],
trustedKeys: [keys.publicPem],
cacheFile: file,
})
assert.equal(first.source, 'seed-directory')
assert.equal(first.url, `ws://127.0.0.1:${new URL(origin).port}/dshs-relay`)
assert.equal(existsSync(file), true, '取到目录后必须写缓存')
// 第二次(同一个"进程"重启)⇒ 读缓存,**不再联网**(把端点关掉也照样起来)
await srv.close()
const second = await resolveOverlayRelay({
envUrl: '',
seeds: [origin],
trustedKeys: [keys.publicPem],
cacheFile: file,
})
assert.equal(second.source, 'cache')
assert.equal(second.url, first.url)
} finally {
await srv.close().catch(() => undefined)
rmSync(dir, { recursive: true, force: true })
}
})
// ── B5 · 判据 6:改目录里的 bootstrap[] ⇒ 新会话读到新值(不重装) ────────
test('B5 轮换:目录里 bootstrap[] 改值 ⇒ 下一轮从新地址取,客户端不重装', async () => {
const keys = makeKeys()
const { dir, file } = tmpCacheFile()
const a = serveDirectory(keys, signedDoc('http://placeholder/dshs-relay', keys.privatePem))
const b = serveDirectory(keys, signedDoc('http://placeholder/dshs-relay', keys.privatePem))
try {
const A = (await a.listen()).origin
const B = (await b.listen()).origin
a.set(signedDoc(A, keys.privatePem)) // 阶段 1:老地域 A 就是引导地址
b.set(signedDoc(B, keys.privatePem)) // 阶段 2:新地域 B 上线
// 显式时钟(**不要用 Date.now() 加减**:`now - fetchedAt` 会被 `Math.max(0, …)` 夹成 0
// ⇒ 缓存反而"看起来新鲜",测试会误判成"链路没走")。每次推 10 个刷新周期 ⇒ 缓存必过期。
const refreshMs = 300_000
let clock = Date.now()
// 阶段 1:客户端只认识 A(= 编译进来的种子位)
const p1 = await resolveOverlayRelay({
seeds: [A],
trustedKeys: [keys.publicPem],
cacheFile: file,
nowMs: clock,
})
assert.equal(p1.source, 'seed-directory')
assert.equal(p1.url, `ws://127.0.0.1:${new URL(A).port}/dshs-relay`)
// 控制面把引导地址轮换到 B:A 的目录里 bootstrap[] = [B]、relays[] = [B]
a.set(signedDoc(B, keys.privatePem, { bootstrap: [B], relays: [B] }))
clock += 10 * refreshMs
const p2 = await resolveOverlayRelay({
seeds: [A],
trustedKeys: [keys.publicPem],
cacheFile: file,
nowMs: clock,
})
assert.equal(p2.url, `ws://127.0.0.1:${new URL(B).port}/dshs-relay`, '这一轮就该连到新地址')
const cached = readCachedDirectory(file, [keys.publicPem], clock)
assert.equal(cached?.fresh, true, '刚写完的缓存应当是新鲜的')
assert.deepEqual(cached?.entry.doc.bootstrap, [B], '缓存里的 bootstrap[] 必须已更新为 B')
// **关键一步**:A 下线(老域名退役)。客户端**没有重装、没有改配置**,
// 只因为"取目录的 origin 优先取缓存里的 bootstrap[]",它自己就找到了 B。
await a.close()
clock += 10 * refreshMs
const p3 = await resolveOverlayRelay({
seeds: [A],
trustedKeys: [keys.publicPem],
cacheFile: file,
nowMs: clock,
})
assert.equal(p3.source, 'seed-directory', 'A 已下线,仍应取到目录(说明 origin 走了 B)')
assert.equal(p3.url, `ws://127.0.0.1:${new URL(B).port}/dshs-relay`)
assert.equal(p3.detail.includes(`:${new URL(B).port}`), true, 'detail 必须指向 B 的端口')
} finally {
await a.close().catch(() => undefined)
await b.close().catch(() => undefined)
rmSync(dir, { recursive: true, force: true })
}
})
// ── B6 · 失败关闭:签名不对 ⇒ 不写缓存、不采用 ───────────────────────────
test('B6 签名不对的目录:既不写缓存也不采用(有旧缓存则用它,没有则回落种子)', async () => {
const keys = makeKeys()
const attacker = makeKeys()
const { dir, file } = tmpCacheFile()
const srv = serveDirectory(keys, signedDoc('http://placeholder/dshs-relay', keys.privatePem))
try {
const origin = (await srv.listen()).origin
// 端点被换成了"另一把密钥签的目录"(或 MITM 自签)⇒ 我们**不认**
srv.set({
doc: buildDirectoryDocument({
relays: ['https://evil.example/dshs-relay'],
bootstrap: ['https://evil.example/dshs-relay'],
network: 'ops',
now: Date.now(),
}),
sig: signDirectory(
buildDirectoryDocument({
relays: ['https://evil.example/dshs-relay'],
bootstrap: ['https://evil.example/dshs-relay'],
network: 'ops',
now: Date.now(),
}),
attacker.privatePem,
),
})
const r1 = await resolveOverlayRelay({
seeds: [origin],
trustedKeys: [keys.publicPem],
cacheFile: file,
})
assert.equal(r1.source, 'seed-fallback', '坏目录必须被丢掉')
assert.equal(r1.url, `ws://127.0.0.1:${new URL(origin).port}/dshs-relay`)
assert.equal(existsSync(file), false, '坏目录**不许**被写进缓存')
assert.equal(r1.url.includes('evil.example'), false, '⛔ 绝不能采用坏目录里的地址')
// 有**有效但过期**的缓存 ⇒ 用旧的可信值(而不是接受坏目录)
const good = serveDirectory(keys, signedDoc(origin, keys.privatePem))
const stale = Date.now() - 10 * 300_000
const old = signedDoc(origin, keys.privatePem, { now: stale })
writeCachedDirectory(file, old.doc, old.sig, stale)
const r2 = await resolveOverlayRelay({
seeds: [origin],
trustedKeys: [keys.publicPem],
cacheFile: file,
})
assert.equal(r2.source, 'stale-cache')
assert.equal(r2.url.includes('evil.example'), false)
await good.close().catch(() => undefined)
} finally {
await srv.close().catch(() => undefined)
rmSync(dir, { recursive: true, force: true })
}
})
// ── B7 · 端点契约(服务端组装 + 签发 + 验签闭环) ────────────────────────
test('B7 端点契约:只公布公网地址、签名可被受信公钥验过、无密钥即不可用', async () => {
const keys = makeKeys()
// 服务端逻辑:候选 = 显式中继入口 + 种子;**过滤回环/私网**后再组装
const relays = publicRelayEntries(['ws://127.0.0.1:20080/dshs-relay', DEFAULT_OVERLAY_SEED])
const bootstrap = publicRelayEntries([DEFAULT_OVERLAY_SEED])
const doc = buildDirectoryDocument({ relays, bootstrap, network: 'ops', now: Date.now() })
const sig = signDirectory(doc, keys.privatePem)
assert.deepEqual(relays, [DEFAULT_OVERLAY_SEED], '回环地址不得出现在目录里')
assert.deepEqual(Object.keys(doc).sort(), [
'bootstrap',
'issuedAt',
'network',
'refreshAfterSeconds',
'relays',
'version',
])
const verdict = verifyDirectory(doc, sig, [keys.publicRawHex])
assert.equal(verdict.ok, true)
assert.equal(verdict.ok === true ? verdict.doc.version : 0, DIRECTORY_VERSION)
// 目录里**没有**任何身份 / 内网信息(只暴露"去哪儿",不暴露"谁在哪")
const flat = JSON.stringify(doc)
for (const forbidden of ['hostId', 'w-', 'secret', '127.0.0.1', 'token']) {
assert.equal(flat.includes(forbidden), false, `目录里不该出现 ${forbidden}`)
}
})
// ── B8 · 范围声明 ────────────────────────────────────────────────────────
test('B8 范围:本步只做 P0-2(引导三级链),不做 P0-3 / 应用层', () => {
// Step 2 的边界(R7:别顺手做别的):
// · P0-3 的"名字解析 / 按网络授权"已由 Step 1(P0-1 网抽象)承担,本步不重复实现;
// · 应用层 / 房间层 / presence 排在 `清单 §五` 第 7 步,**本步不碰**;
// · 本步**不新增任何监听口**(目录端点挂在既有门户 3080 上,由既有 nginx location 转发)。
assert.equal(DIRECTORY_PATH, '/dshs-overlay/bootstrap')
assert.equal(existsSync(join(process.cwd(), 'src', 'net', 'relay', 'directory.ts')), true)
})
// ── 夹具自检 ──────────────────────────────────────────────────────────────
test('S0 夹具自检:缓存读写是字节级可复现的(避免"测试夹具自身有问题")', () => {
const keys = makeKeys()
const { dir, file } = tmpCacheFile()
try {
const now = Date.now()
const { doc, sig } = signedDoc(DEFAULT_OVERLAY_SEED, keys.privatePem, { now })
writeCachedDirectory(file, doc, sig, now)
const raw = readFileSync(file, 'utf8')
const parsed = JSON.parse(raw)
assert.equal(parsed.doc.network, 'ops')
assert.equal(parsed.fetchedAt, now)
const back = readCachedDirectory(file, [keys.publicPem], now)
assert.deepEqual(back?.entry.doc, doc)
assert.equal(back?.fresh, true)
// 不能留下临时文件(原子写的判据)
assert.equal(existsSync(`${file}.tmp`), false)
// 缓存文件是 JSON 且以换行结尾(便于 diff / 人工读)
assert.equal(raw.endsWith('\n'), true)
} finally {
rmSync(dir, { recursive: true, force: true })
// 顺手证明夹具没往外写东西(writeFileSync 只用于夹具自检)
assert.equal(typeof writeFileSync, 'function')
}
})
// ── 序④(443/TCP 兜底 · L1「去 CF」):地址覆盖 ─────────────────────────────
//
// 全部是**行为断言**(不是"函数返回了什么"):
// A. **未配 ⇒ 零动作** —— 域名照旧不可解析,证明连补丁都没打(默认路径逐字不变);
// B. **配了 ⇒ 内建 WebSocket 的建连真的走到覆盖 IP** —— 这是整件事的机制性前提
// (内建 `WebSocket` 必须走 JS 层 `dns.lookup`,否则本路线不成立);
// C. **定向**而非全局劫持 —— 白名单之外的域名不受影响;
// D. **失败关闭** —— 形状非法 ⇒ 不安装 + 报出来(不静默忽略);
// E. **可撤销** —— 还原后回到未配状态(既是测试隔离,也是回滚判据)。
/** 取「net / undici 实际用的那个」`node:dns` 单例(`addr-override` 改的就是它)。 */
const nodeDns = createRequire(import.meta.url)('node:dns')
/** 单地址解析(回调形状)⇒ 地址字符串或 `ERR:<code>`。 */
function lookupOne(host) {
return new Promise((resolve) => {
nodeDns.lookup(host, (err, address) => resolve(err ? `ERR:${err.code}` : address))
})
}
test('序④·L1-A 未配地址覆盖 ⇒ 零动作(默认解析路径逐字不变)', async () => {
resetOverlayAddrOverridesForTest()
try {
assert.deepEqual(ensureOverlayAddrOverrides('', () => {}), [])
assert.deepEqual(currentAddrOverrides(), [])
assert.equal(await lookupOne('fb-nodns-9f3a.invalid'), 'ERR:ENOTFOUND')
} finally {
resetOverlayAddrOverridesForTest()
}
})
test('序④·L1-B 覆盖生效 ⇒ 白名单域名直连覆盖 IP,且内建 WebSocket 真的走到该 IP', async () => {
const HOST = 'fb-direct-9f3a.invalid'
let hits = 0
const srv = createNetServer((s) => {
hits += 1
s.destroy()
})
await new Promise((r) => srv.listen(0, '127.0.0.1', r))
const port = srv.address().port
resetOverlayAddrOverridesForTest()
try {
// 覆盖前:该名字在公网不存在 ⇒ 不可解析(这就是"兜底子域没有可直连的解析结果"的等价场景)
assert.equal(await lookupOne(HOST), 'ERR:ENOTFOUND')
const applied = ensureOverlayAddrOverrides(`${HOST}=127.0.0.1`, () => {})
assert.deepEqual(
applied.map((o) => `${o.host}=${o.ip}/v${o.family}`),
[`${HOST}=127.0.0.1/v4`],
)
assert.equal(await lookupOne(HOST), '127.0.0.1')
// `all: true` 形状(`net` 在 autoSelectFamily 下会用它)也必须给出覆盖结果
const all = await new Promise((resolve) => {
nodeDns.lookup(HOST, { all: true }, (err, addrs) => resolve(err ? `ERR:${err.code}` : addrs))
})
assert.deepEqual(all, [{ address: '127.0.0.1', family: 4 }])
// **机制性前提**:内建 WebSocket 的建连要走 JS 层 `dns.lookup`,覆盖才对它有效。
const before = hits
await new Promise((resolve) => {
const ws = new WebSocket(`ws://${HOST}:${port}/`)
ws.addEventListener('open', () => resolve())
ws.addEventListener('error', () => resolve())
setTimeout(resolve, 3000)
})
await new Promise((r) => setTimeout(r, 200))
assert.ok(hits > before, '内建 WebSocket 未走 JS 层 dns.lookup ⇒ 地址覆盖对建连无效(本路线不成立)')
} finally {
srv.close()
resetOverlayAddrOverridesForTest()
}
})
test('序④·L1-C 白名单之外不受影响(定向覆盖,不是全局劫持)', async () => {
resetOverlayAddrOverridesForTest()
try {
ensureOverlayAddrOverrides('fb-scoped-9f3a.invalid=127.0.0.1', () => {})
assert.equal(await lookupOne('fb-scoped-9f3a.invalid'), '127.0.0.1')
assert.equal(await lookupOne('fb-other-9f3a.invalid'), 'ERR:ENOTFOUND')
} finally {
resetOverlayAddrOverridesForTest()
}
})
test('序④·L1-D 形状非法 ⇒ 不安装且报出去(失败关闭,不静默忽略)', async () => {
resetOverlayAddrOverridesForTest()
try {
const logs = []
const applied = ensureOverlayAddrOverrides(
'no-equals,=1.2.3.4,host.invalid=not-an-ip,ok.invalid=1.2.3.4',
(l) => logs.push(l),
)
assert.deepEqual(applied.map((o) => o.host), ['ok.invalid'])
assert.equal(logs.filter((l) => l.includes('忽略非法条目')).length, 3)
// 纯函数面:同域名重复 ⇒ **先出现者生效**(小写归一后比对)
assert.deepEqual(
parseAddrOverrides('A.invalid=1.2.3.4,a.INVALID=5.6.7.8').list.map((o) => `${o.host}=${o.ip}`),
['a.invalid=1.2.3.4'],
)
// 合法但请求了别的地址族 ⇒ 交回 ENOTFOUND 语义,**不**回一个错族的地址
assert.equal(
await new Promise((resolve) => {
nodeDns.lookup('ok.invalid', { family: 6 }, (err) => resolve(err ? `ERR:${err.code}` : 'OK'))
}),
'ERR:ENOTFOUND',
)
} finally {
resetOverlayAddrOverridesForTest()
}
})
test('序④·L1-E 撤销后回到未配状态(可回滚)', async () => {
try {
ensureOverlayAddrOverrides('fb-rollback-9f3a.invalid=127.0.0.1', () => {})
assert.equal(await lookupOne('fb-rollback-9f3a.invalid'), '127.0.0.1')
resetOverlayAddrOverridesForTest()
assert.equal(await lookupOne('fb-rollback-9f3a.invalid'), 'ERR:ENOTFOUND')
} finally {
resetOverlayAddrOverridesForTest()
}
})
/**
* **同源优先**(序④):没有它,「多一条兜底入口」落不成「CF / 门户 conf 挂时还能连」——
* `relays[]` 首位 = 主入口,客户端会一直去连它,兜底项永远轮不到。
*/
const FB_MAIN = 'https://alotbuy.com/dshs-relay'
const FB_ALT = 'https://relay-direct.alotbuy.com/dshs-relay'
/** 造一份"两个入口都在"的目录,并只让**兜底 origin** 答得出(主 origin 抛错)。 */
function twoEntryDoc(keys) {
const { doc, sig } = signedDoc(FB_MAIN, keys.privatePem, {
relays: [FB_MAIN, FB_ALT],
bootstrap: [FB_MAIN, FB_ALT],
})
return JSON.stringify({ ...doc, sig })
}
test('序④·L1-F 同源优先:主 origin 不可达时采用兜底 origin 的同源入口,且降级可解释', async () => {
const keys = makeKeys()
const body = twoEntryDoc(keys)
const calls = []
const logs = []
const fetchImpl = async (url) => {
calls.push(String(url))
if (String(url).startsWith('https://alotbuy.com/')) throw new Error('cf unreachable')
return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } })
}
const r = await resolveOverlayRelay({
seeds: [FB_MAIN, FB_ALT],
trustedKeys: [keys.publicPem],
cacheFile: '',
fetchImpl,
log: (l) => logs.push(l),
})
// ① 逐个 origin 试,主 origin 被拒后才到兜底
assert.equal(calls.length, 2)
assert.ok(
logs.some((l) => l.includes('拒绝 https://alotbuy.com/dshs-overlay/bootstrap')),
'缺"逐 origin 拒绝原因"这一行',
)
// ② 采用的是**兜底项**,而不是 relays[] 首位(这是本单 D6 的实质判据)
assert.equal(r.source, 'seed-directory')
assert.equal(r.url, 'wss://relay-direct.alotbuy.com/dshs-relay')
assert.ok(
logs.some((l) => l.includes('同源优先') && l.includes('wss://relay-direct.alotbuy.com/dshs-relay')),
'缺"为什么走了兜底"这一行(可解释性)',
)
})
test('序④·L1-G 主 origin 通时选择与今天逐字一致(relays[] 首位,零退化)', async () => {
const keys = makeKeys()
const body = twoEntryDoc(keys)
const logs = []
const fetchImpl = async () =>
new Response(body, { status: 200, headers: { 'content-type': 'application/json' } })
const r = await resolveOverlayRelay({
seeds: [FB_MAIN, FB_ALT],
trustedKeys: [keys.publicPem],
cacheFile: '',
fetchImpl,
log: (l) => logs.push(l),
})
assert.equal(r.url, 'wss://alotbuy.com/dshs-relay')
assert.equal(logs.some((l) => l.includes('同源优先')), false, '首位命中时不该有多余日志')
})
+526
View File
@@ -0,0 +1,526 @@
/**
* 覆盖网络 · **序③ 一机一钥 + 信任根** 单测 —— 身份层是不是"真的挡得住"。
*
* ## 这个文件要回答的四个问题(= `交接单_一机一钥与信任根_20260917.md §5/§6`)
* 1. **四层模型**:根(离线)→ 签名者(在线)→ 节点(每机一把)→ 会话(复用 TLS)
* 的载体能不能**真签发、真验签**?(A 组)
* 2. **入网 = 签名**:篡改过的入网凭据**建不起会话**,且原因**结构化**?(B 组 + D 组)
* 3. **relay 准入按 `hostId` + 成员资格**:拿 A 网的凭据进 B 网必须被拒?(C 组 + D1)
* 4. **撤销单台 ≠ 全网换密钥**:撤 w106 之后,w47 照旧在线?(D6)
*
* ## 🔴 "先红后绿"在本文件里的落点(⛔ 不是口头声明)
* 身份校验**默认不强制**(`requireIdentity=false`,存量过渡)⇒ 同一份**被搬家的凭据**,
* 在 `verifyIdentity:false` 的服务器上**能注册进去**(= 没有这道门时的攻击面,**红**),
* 在 `verifyIdentity:true` 的服务器上**被拒**(**绿**)。两组用**同一份坏凭据、同一个 hostId**,
* 唯一变量就是那一道门 ⇒ 证明"挡住它的正是这道门",而不是别的巧合(D5)。
*
* 运行:`node --test test/overlay-identity.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。
*
* @module test/overlay-identity
*/
import assert from 'node:assert/strict'
import { createHmac, randomBytes } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { test } from 'node:test'
import { RelayClient } from '../lib/net/relay/client.js'
import {
generateAuthorityKey,
generateNodeKey,
nodeKeyFingerprint,
publicKeyOfPrivate,
signNodeGrant,
signProof,
signRevocations,
signSignerSet,
verifyNodeGrant,
verifyPeerGrant,
verifyProof,
verifyRevocations,
verifySignerSet,
} from '../lib/net/relay/identity.js'
import { describeKeyEntry, loadKeysFile, parseKeysInline } from '../lib/net/relay/keys.js'
import { OPS_NETWORK } from '../lib/net/relay/network.js'
import { RelayServer } from '../lib/net/relay/server.js'
import { MUX, decodeMux, encodeJsonFrame } from '../lib/net/relay/wire.js'
const BASE = 47100
const SPAN = 200
const PATH = '/dshs-relay'
const U_A = 'u:alpha'
const U_B = 'u:beta'
const NOW = Date.parse('2026-09-17T00:00:00Z')
/* ─────────────────────────── 工具 ─────────────────────────── */
async function waitFor(fn, ms = 4000) {
const until = Date.now() + ms
while (Date.now() < until) {
if (fn()) return true
await new Promise((r) => setTimeout(r, 20))
}
return false
}
/** 一套"根 → 签名者 → 节点"的密钥与凭据(**建网的标准动作**:测试与部署脚本同一形状)。 */
function buildAuthority(network = OPS_NETWORK) {
const root = generateAuthorityKey()
const signer = generateAuthorityKey()
const signerSet = {
version: 1,
network,
issuedAt: new Date(NOW).toISOString(),
signers: [signer.publicKey],
}
const signerSetSig = signSignerSet(signerSet, root.privateKeyPem)
const issue = (hostId, opts = {}) => {
const node = generateNodeKey()
const grant = {
version: 1,
network: opts.network ?? network,
hostId,
nodeKey: node.publicKey,
issuedAt: opts.issuedAt ?? new Date(NOW).toISOString(),
expiresAt: opts.expiresAt ?? '',
}
return { node, grant, sig: signNodeGrant(grant, signer.privateKeyPem) }
}
return { root, signer, signerSet, signerSetSig, issue }
}
/** 吊销清单(签名者签发)。 */
function revocationOf(signerPem, hosts, nodeKeys = []) {
const doc = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString(), hosts, nodeKeys }
return { doc, sig: signRevocations(doc, signerPem) }
}
/**
* 手工发一个 `HELLO` 并取回**首个回帧**(用于构造"正常 client 不会发"的帧)。
*
* ⚠️ MAC 输入串与 `client.ts` 逐字同源(**不含身份字段**)—— 身份是**追加**字段、不动 MAC;
* 这一条本身就是被测对象之一(旧客户端算出的 MAC 必须仍被接受)。
*/
async function rawHello(wsUrl, opts) {
const { hostId, secret, portsCsv = '', network, identity } = opts
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve, { once: true })
ws.addEventListener('error', () => reject(new Error('ws open failed')), { once: true })
})
const ts = Date.now()
const nonce = randomBytes(16).toString('hex')
const challenge = `${hostId}|${ts}|${nonce}|${portsCsv}`
const mac = createHmac('sha256', Buffer.from(secret, 'hex')).update(challenge).digest('hex')
const payload = { v: 1, hostId, ts, nonce, portsCsv, mac }
if (network !== undefined) payload.network = network
if (identity !== undefined) {
const { nodePrivateKeyPem, nodeSigOverride, ...rest } = identity
Object.assign(payload, rest)
// 默认按**正确的挑战串**签(走真实路径);给了 override 就按 override(用来构造"签名错")。
if (nodeSigOverride !== undefined) payload.nodeSig = nodeSigOverride
else if (nodePrivateKeyPem !== undefined) payload.nodeSig = signProof(nodePrivateKeyPem, challenge)
}
ws.send(encodeJsonFrame(MUX.HELLO, 0, payload))
const frame = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(undefined), 3000)
ws.addEventListener('message', (ev) => {
clearTimeout(timer)
resolve(decodeMux(Buffer.from(ev.data)))
})
ws.addEventListener('close', () => {
clearTimeout(timer)
resolve(undefined)
})
})
return { ws, frame }
}
function reasonOf(frame) {
assert.ok(frame !== undefined, '必须得到结构化回帧')
return JSON.parse(frame.payload.toString('utf8')).reason
}
/** 建一个 relay:所有 host 都是"拨号方"(端口表为空)⇒ 不需要任何 TCP 回显服务。 */
async function startRelay(t, opts = {}) {
const hosts = opts.hosts ?? {}
const srv = new RelayServer({
port: 0,
// ⚠️ 必须是 `Map`(服务端按 `keys.get(hostId)` 取;传普通对象会得到
// `this.opts.keys.get is not a function` 这种**与判据无关**的崩 —— 会淹掉真正的结论)。
keys: new Map(Object.entries(hosts)),
instancePortBase: BASE,
instancePortSpan: SPAN,
dialers: new Map([[OPS_NETWORK, new Set(Object.keys(hosts))]]),
trustedSignerKeys: opts.trustedSignerKeys ?? [],
revocations: opts.revocations,
requireIdentity: opts.requireIdentity ?? false,
verifyIdentity: opts.verifyIdentity ?? true,
log: opts.log ?? (() => {}),
})
await srv.start()
t.after(async () => {
await srv.stop()
})
return { srv, url: `ws://127.0.0.1:${srv.boundPort}${PATH}` }
}
/* ─────────── A:四层密钥模型(根 → 签名者) ─────────── */
test('A1 根签名的签名者集合:受信根验得过', () => {
const { root, signer, signerSet, signerSetSig } = buildAuthority()
const verdict = verifySignerSet(signerSet, signerSetSig, [root.publicKey])
assert.equal(verdict.ok, true, `根签的集合必须验得过,实测:${JSON.stringify(verdict)}`)
assert.deepEqual(verdict.doc.signers, [signer.publicKey])
})
test('A2 篡改签名者集合(加一把签名者后沿用旧签名)⇒ **signature-mismatch**', () => {
const { root, signerSet, signerSetSig } = buildAuthority()
const evil = generateAuthorityKey()
const tampered = { ...signerSet, signers: [...signerSet.signers, evil.publicKey] }
const verdict = verifySignerSet(tampered, signerSetSig, [root.publicKey])
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'signature-mismatch', '加签名者 = 加攻击面 ⇒ 必须失败关闭')
})
test('A3 **不可验 = 不接受**:一把受信根都没有 ⇒ no-trusted-keys(不"先用着")', () => {
const { signerSet, signerSetSig } = buildAuthority()
const verdict = verifySignerSet(signerSet, signerSetSig, [])
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'no-trusted-keys')
})
test('A4 换一把没被授权的根来签 ⇒ 不认(根是唯一信任源)', () => {
const a = buildAuthority()
const b = buildAuthority()
const crossSig = signSignerSet(a.signerSet, b.root.privateKeyPem)
const verdict = verifySignerSet(a.signerSet, crossSig, [a.root.publicKey])
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'signature-mismatch')
})
/* ─────────── B:节点凭据 + 本地校验(D2 的判据落点) ─────────── */
test('B1 合法凭据:受信签名者签发 ⇒ 过,且能取出 hostId / 网 / 节点公钥', () => {
const auth = buildAuthority()
const { grant, sig } = auth.issue('w106')
const verdict = verifyNodeGrant(grant, sig, [auth.signer.publicKey])
assert.equal(verdict.ok, true)
assert.equal(verdict.doc.hostId, 'w106')
assert.equal(verdict.doc.network, OPS_NETWORK)
assert.equal(verdict.doc.nodeKey, grant.nodeKey)
})
test('B2 🔴 核心:**篡改 hostId 后沿用原签名** ⇒ signature-mismatch(凭据不可搬家)', () => {
const auth = buildAuthority()
const { grant, sig } = auth.issue('w106')
const moved = { ...grant, hostId: 'w47' }
const verdict = verifyNodeGrant(moved, sig, [auth.signer.publicKey])
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'signature-mismatch', '凭据被搬到别的 hostId 上必须拒(签名覆盖 hostId)')
})
test('B3 期望网不符 ⇒ network-mismatch(跨网签发进不来)', () => {
const auth = buildAuthority()
const { grant, sig } = auth.issue('w106')
const verdict = verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], network: U_B })
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'network-mismatch')
})
test('B4/B5 期望 hostId 不符 ⇒ host-mismatch;期望公钥不符 ⇒ key-mismatch', () => {
const auth = buildAuthority()
const { grant, sig } = auth.issue('w106')
const other = generateNodeKey()
assert.equal(
verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], hostId: 'w47' }).reason,
'host-mismatch',
)
assert.equal(
verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], nodeKey: other.publicKey }).reason,
'key-mismatch',
'私钥被换过(公钥对不上)⇒ 拒',
)
// 对得上的那一组必须**过** —— 否则上面两条可能只是"恒拒",测了个寂寞
assert.equal(
verifyPeerGrant(grant, sig, {
trustedSignerKeys: [auth.signer.publicKey],
network: OPS_NETWORK,
hostId: 'w106',
nodeKey: grant.nodeKey,
}).ok,
true,
)
})
test('B6 过期凭据 ⇒ expired(带过期时间的节点会真的过期)', () => {
const auth = buildAuthority()
const { grant, sig } = auth.issue('w106', {
issuedAt: new Date(NOW - 120_000).toISOString(),
expiresAt: new Date(NOW - 60_000).toISOString(),
})
const verdict = verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], nowMs: NOW })
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'expired')
})
test('B7/B8 吊销:撤 hostId ⇒ revoked-host;撤节点公钥 ⇒ revoked-node-key;撤单台不牵连别的', () => {
const auth = buildAuthority()
const { grant, sig } = auth.issue('w106')
const byHost = { ...revocationOf(auth.signer.privateKeyPem, ['w106']).doc }
const byKey = { ...revocationOf(auth.signer.privateKeyPem, [], [grant.nodeKey]).doc }
const other = { ...revocationOf(auth.signer.privateKeyPem, ['manager']).doc }
assert.equal(
verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], revocations: byHost }).reason,
'revoked-host',
)
assert.equal(
verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], revocations: byKey }).reason,
'revoked-node-key',
'设备被盗场景:hostId 可能被复用,公钥不会 ⇒ 两条都要能撤',
)
assert.equal(
verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], revocations: other }).ok,
true,
'撤销单台不得牵动其它节点',
)
})
test('B9 吊销清单本身也要**验签**(清单被改坏 ⇒ 撤销失效 ⇒ 不能当没事)', () => {
const auth = buildAuthority()
const rogue = generateAuthorityKey()
const good = revocationOf(auth.signer.privateKeyPem, ['w106'])
assert.equal(verifyRevocations(good.doc, good.sig, [auth.signer.publicKey]).ok, true)
const forged = revocationOf(rogue.privateKeyPem, ['w106'])
assert.equal(verifyRevocations(forged.doc, forged.sig, [auth.signer.publicKey]).reason, 'signature-mismatch')
// 篡改内容后沿用原签名
const tampered = { ...good.doc, hosts: ['w47'] }
assert.equal(verifyRevocations(tampered, good.sig, [auth.signer.publicKey]).reason, 'signature-mismatch')
})
test('B10 会话证明(proof):握有私钥才签得出;换公钥 / 改挑战 ⇒ 验不过', () => {
const auth = buildAuthority()
const { node } = auth.issue('w106')
const challenge = 'w106|123|abc|'
const sig = signProof(node.privateKeyPem, challenge)
assert.equal(verifyProof(node.publicKey, challenge, sig), true)
const other = generateNodeKey()
assert.equal(verifyProof(other.publicKey, challenge, sig), false, '别的公钥验不过 ⇒ 证明绑定了具体私钥')
assert.equal(verifyProof(node.publicKey, `${challenge}x`, sig), false, '挑战被改 ⇒ 验不过(防重放 / 防挪用)')
})
test('B11 非受信签名者签发的凭据 ⇒ signature-mismatch(名单之外一律不认)', () => {
const auth = buildAuthority()
const rogue = generateAuthorityKey()
const node = generateNodeKey()
const grant = {
version: 1,
network: OPS_NETWORK,
hostId: 'w106',
nodeKey: node.publicKey,
issuedAt: new Date(NOW).toISOString(),
expiresAt: '',
}
const sig = signNodeGrant(grant, rogue.privateKeyPem)
const verdict = verifyNodeGrant(grant, sig, [auth.signer.publicKey])
assert.equal(verdict.ok, false)
assert.equal(verdict.reason, 'signature-mismatch')
})
test('B12 指纹:每机一把 ⇒ 指纹互不相同;解析不出来就 undefined(不猜)', () => {
const a = generateNodeKey()
const b = generateNodeKey()
assert.notEqual(nodeKeyFingerprint(a.publicKey), nodeKeyFingerprint(b.publicKey), '每机一把 ⇒ 指纹必须不同')
assert.equal(nodeKeyFingerprint(a.publicKey), nodeKeyFingerprint(a.publicKey))
assert.equal(nodeKeyFingerprint('not-a-key'), undefined)
})
/* ─────────── C:keys 表带"网"(成员资格的唯一判据) ─────────── */
test('C1 旧写法(裸 hostId + hex 串)⇒ 归入运维网 ops(现网配置一字不改)', () => {
const s = randomBytes(32).toString('hex')
const map = parseKeysInline(`w-47:${s}`)
// 键 = **逻辑名**(序③)⇒ 裸 `w-47` 归一成 `ops/w-47`
assert.deepEqual(map.get('ops/w-47'), { network: OPS_NETWORK, secret: s })
assert.equal(describeKeyEntry('w-47', map.get('ops/w-47')), 'w-47', 'ops 下省略网络前缀(日志读法与 R5 一致)')
})
test('C2 新写法:`网/hostId:secret` 与 `网:hostId:secret` 都切得出网络(切点是**最后一个冒号**)', () => {
const s = randomBytes(32).toString('hex')
const slash = parseKeysInline(`${U_A}/pc-1:${s}`)
assert.deepEqual(slash.get(U_A + '/pc-1'), { network: U_A, secret: s })
// ⚠️ 网络 id 自己含 `:`(`u:5`)⇒ 用**第一个**冒号切会切出网络段 `u`,
// 于是"配置写错"就长得像"这张网不存在"(静默拒绝)——这正是本线反复要根治的病。
const colon = parseKeysInline(`u:5:pc-1:${s}`)
assert.deepEqual(colon.get('u:5/pc-1'), { network: 'u:5', secret: s })
assert.equal(describeKeyEntry('pc-1', colon.get('u:5/pc-1')), 'u:5/pc-1')
})
test('C3 网络段非法 / 密钥长度不对 ⇒ **抛**(不静默回落:那会让"配错"伪装成"网络不通")', () => {
const s = randomBytes(32).toString('hex')
assert.throws(() => parseKeysInline(`u/pc-1:${s}`), /非法/, '裸 `u` 是保留字,不是合法网名')
assert.throws(() => parseKeysInline(`pc-1:deadbeef`), /64 hex/)
assert.throws(() => parseKeysInline('no-colon'), /malformed/)
})
/* ─────────── D:端到端(relay 准入 + 身份) ─────────── */
test('D1 成员资格:声称别的网 ⇒ **network-mismatch**(失败关闭,不回落 ops)', async (t) => {
const s = randomBytes(32).toString('hex')
const { url } = await startRelay(t, { hosts: { w106: s } })
const { ws, frame } = await rawHello(url, { hostId: 'w106', secret: s, network: U_B })
t.after(() => ws.close())
assert.equal(frame.type, MUX.HELLO_ERR)
assert.equal(reasonOf(frame), 'network-mismatch', '密钥表说它在 ops,声称别张网 ⇒ 必须拒')
})
test('D2 未登记的 hostId ⇒ unknown-host(默认拒绝)', async (t) => {
const s = randomBytes(32).toString('hex')
const { url } = await startRelay(t, { hosts: { w106: s } })
const { ws, frame } = await rawHello(url, { hostId: 'nobody', secret: s })
t.after(() => ws.close())
assert.equal(reasonOf(frame), 'unknown-host')
})
test('D3 强制身份但客户端**不带**凭据 ⇒ identity-incomplete(明确拒,不放行)', async (t) => {
const s = randomBytes(32).toString('hex')
const auth = buildAuthority()
const { url } = await startRelay(t, {
hosts: { w106: s },
trustedSignerKeys: [auth.signer.publicKey],
requireIdentity: true,
})
const { ws, frame } = await rawHello(url, { hostId: 'w106', secret: s, network: OPS_NETWORK })
t.after(() => ws.close())
assert.equal(reasonOf(frame), 'identity-incomplete')
})
test('D4 relay 侧**没有**受信签名者时,带了凭据也拒(不可验 = 不接受)', async (t) => {
const s = randomBytes(32).toString('hex')
const auth = buildAuthority()
const { grant, sig, node } = auth.issue('w106')
const { url } = await startRelay(t, { hosts: { w106: s }, trustedSignerKeys: [] })
const { ws, frame } = await rawHello(url, {
hostId: 'w106',
secret: s,
network: OPS_NETWORK,
identity: { nodeKey: node.publicKey, grant, grantSig: sig, nodePrivateKeyPem: node.privateKeyPem },
})
t.after(() => ws.close())
assert.equal(reasonOf(frame), 'identity-no-trusted-signers')
})
test('D5 🔴 **先红后绿**:同一份"被搬家的凭据" —— 没有这道门时能注册(红),有门时被拒(绿)', async (t) => {
const s = randomBytes(32).toString('hex')
const auth = buildAuthority()
const good = auth.issue('w106')
// 篡改形态:把 w106 的凭据"搬家"到 w47 并沿用原签名 —— 这正是"控制面被攻破后插入节点"的形状。
const evilGrant = { ...good.grant, hostId: 'w47' }
const identity = { nodeKey: good.node.publicKey, grant: evilGrant, grantSig: good.sig }
const hosts = { w106: s, w47: s }
/* ① 红:门关着(verifyIdentity:false)⇒ **同一份坏凭据确实注册进去了** */
const open = await startRelay(t, { hosts, trustedSignerKeys: [auth.signer.publicKey], verifyIdentity: false })
const red = await rawHello(open.url, { hostId: 'w47', secret: s, network: OPS_NETWORK, identity })
t.after(() => red.ws.close())
assert.equal(red.frame.type, MUX.HELLO_ACK, '【红】门关着时坏凭据能注册 —— 这就是被治的攻击面,必须有实测')
assert.ok(open.srv.status().counters.authed > 0)
/* ② 绿:门开着(verifyIdentity:true)⇒ 同一份坏凭据被拒 */
const closed = await startRelay(t, { hosts, trustedSignerKeys: [auth.signer.publicKey] })
const green = await rawHello(closed.url, { hostId: 'w47', secret: s, network: OPS_NETWORK, identity })
t.after(() => green.ws.close())
assert.equal(reasonOf(green.frame), 'identity-signature-mismatch', '搬家的凭据必须被签名校验挡住')
assert.equal(closed.srv.status().counters.identityOk, 0)
assert.equal(closed.srv.status().sessions.length, 0, '被拒的节点不得留下任何会话')
})
test('D6 凭据合法但**节点证明**是错的 ⇒ identity-bad-proof(有证书 ≠ 有私钥)', async (t) => {
const s = randomBytes(32).toString('hex')
const auth = buildAuthority()
const { grant, sig, node } = auth.issue('w106')
const attacker = generateNodeKey()
const { url } = await startRelay(t, { hosts: { w106: s }, trustedSignerKeys: [auth.signer.publicKey] })
const { ws, frame } = await rawHello(url, {
hostId: 'w106',
secret: s,
network: OPS_NETWORK,
identity: {
nodeKey: node.publicKey, // 声明的是**真**公钥(凭据也对得上)
grant,
grantSig: sig,
nodeSigOverride: signProof(attacker.privateKeyPem, 'anything'), // 但签名不是那把私钥签的
},
})
t.after(() => ws.close())
assert.equal(reasonOf(frame), 'identity-bad-proof', '凭据是公开可转发的 ⇒ 必须再证"握有私钥"')
})
test('D7 合法身份 ⇒ 真 `RelayClient` 连得上;**撤销单台不牵动全网**', async (t) => {
const sA = randomBytes(32).toString('hex')
const sB = randomBytes(32).toString('hex')
const auth = buildAuthority()
const a = auth.issue('w106')
const b = auth.issue('w47')
const rev = revocationOf(auth.signer.privateKeyPem, ['w106'])
assert.equal(verifyRevocations(rev.doc, rev.sig, [auth.signer.publicKey]).ok, true, '清单本身先要验得过')
const { srv, url } = await startRelay(t, {
hosts: { w106: sA, w47: sB },
trustedSignerKeys: [auth.signer.publicKey],
revocations: rev.doc,
requireIdentity: true,
})
/** 用**真** `RelayClient` 连(证明客户端侧字段拼得对,不是只有 rawHello 能过)。 */
const connect = (hostId, secret, issued) => {
const c = new RelayClient({
url,
hostId,
secret,
ports: [],
dialer: true,
identity: { privateKeyPem: issued.node.privateKeyPem, grant: issued.grant, grantSig: issued.sig },
log: () => {},
})
c.start()
return c
}
const cA = connect('w106', sA, a)
const cB = connect('w47', sB, b)
t.after(() => {
cA.stop()
cB.stop()
})
assert.ok(await waitFor(() => cB.status().state === 'up'), '未被撤的 w47 必须连上(撤销不得牵动全网)')
assert.equal(await waitFor(() => cA.status().state === 'up', 1500), false, '被撤的 w106 不得连上')
assert.equal(srv.status().counters.identityOk, 1, '只有一台通过身份校验')
assert.equal(
srv.status().sessions.filter((x) => x.hostId === 'w106').length,
0,
'被撤节点不得在 relay 侧留下会话',
)
assert.equal(publicKeyOfPrivate(a.node.privateKeyPem), a.grant.nodeKey, '客户端带的 nodeKey 必须与凭据里那把一致')
})
/* ─────────── E:文件面(密钥落点与原地升级) ─────────── */
test('E1 keys 文件:新旧写法可**共存**(原地升级 ⇒ 可原子替换 + 可回滚)', (t) => {
const dir = mkdtempSync(join(tmpdir(), 'dshs-keys-'))
t.after(() => rmSync(dir, { recursive: true, force: true }))
const s1 = randomBytes(32).toString('hex')
const s2 = randomBytes(32).toString('hex')
const file = join(dir, 'relay-keys.json')
writeFileSync(file, JSON.stringify({ 'w-47': s1, w106: { network: OPS_NETWORK, secret: s2 } }))
const map = loadKeysFile(file)
assert.deepEqual(map.get('ops/w-47'), { network: OPS_NETWORK, secret: s1 })
assert.deepEqual(map.get('ops/w106'), { network: OPS_NETWORK, secret: s2 })
// 非法网络段 ⇒ **装载即抛**("配置错就炸",不变成运行期的静默拒绝)
writeFileSync(file, JSON.stringify({ bad: { network: 'u', secret: s1 } }))
assert.throws(() => loadKeysFile(file), /非法/)
})
+527
View File
@@ -0,0 +1,527 @@
/**
* 覆盖网络 · **② 网抽象(P0-1)** 单测 —— `network_id` 是不是**结构性隔离**。
*
* ## 这个文件要回答的唯一问题
* 「不同网络的节点之间**不能互相到达**」这件事,是**结构上做不到**,还是"靠某条 ACL 记得拦"?
* —— 所以本文件的验收标准不是"配置写对了",而是:
* 1. **跨网拨号被 relay 拒**,且拒绝点在**服务端**(有日志、有计数);
* ⚠️ P0-3 起**对外没有专属拒码**:跨网一律回 `target-offline`(与"本网无此节点"逐字同形),
* 区分只留在服务端日志 —— 否则"它在别张网"本身就是一份**可探测的对端清单**。
* 2. **同名 hostId 在两张网里互不干扰**(会话表按 `<network>/<hostId>` 索引,不是扁平 `hostId`);
* 3. **旧形态照旧可用**(旧客户端不声明 `network`、旧 drop-in 的扁平白名单 ⇒ 全部落在 `ops`,
* 现网 47 / 106 一行配置不改也不受影响)—— "不破坏现网"与"真落地"必须同时成立。
*
* 运行:`node --test test/overlay-network.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。
*
* @module test/overlay-network
*/
import assert from 'node:assert/strict'
import { createHmac, randomBytes } from 'node:crypto'
import { createServer as createTcpServer, connect } from 'node:net'
import { test } from 'node:test'
import {
MUX,
OPS_NETWORK,
RelayClient,
RelayServer,
assertNetworkId,
decodeMux,
describeDialers,
encodeJsonFrame,
logicalName,
normalizeDialers,
parseLogicalName,
} from '../lib/net/relay/index.js'
const BASE = 46000
const SPAN = 200
const PATH = '/dshs-relay'
const U_TEST = 'u:test-network'
/* ─────────── 小工具(与 relay.test.mjs 同款,本文件自足) ─────────── */
async function listenInRange(server, lo, hi) {
for (let p = lo; p < hi; p++) {
const ok = await new Promise((resolve) => {
const onErr = () => {
server.off('error', onErr)
resolve(false)
}
server.once('error', onErr)
server.listen(p, '127.0.0.1', () => {
server.off('error', onErr)
resolve(true)
})
})
if (ok) return server.address().port
}
throw new Error(`no free port in ${lo}..${hi}`)
}
async function waitFor(cond, ms = 5000) {
const t0 = Date.now()
while (Date.now() - t0 < ms) {
if (cond()) return true
await new Promise((r) => setTimeout(r, 20))
}
return cond()
}
/** 回显服务,但**带一个前缀** —— 用来分辨"这条流到底落在哪张网的那台机上"。 */
function taggedEcho(tag) {
const server = createTcpServer((s) => s.on('data', (c) => s.write(`${tag}:${c.toString('utf8')}`)))
return server
}
/** 拨号流往返:写进去、读回(前缀长度显式传入,避免"读到一半就认为对了")。 */
function dialRoundTrip(duplex, text, prefixLen = 0, ms = 5000) {
return new Promise((resolve, reject) => {
let got = ''
const timer = setTimeout(() => {
duplex.destroy()
reject(new Error(`dial roundTrip timeout (got ${got.length}/${text.length + prefixLen})`))
}, ms)
duplex.on('data', (chunk) => {
got += chunk.toString('utf8')
if (got.length >= text.length + prefixLen) {
clearTimeout(timer)
resolve(got)
}
})
duplex.on('error', (err) => {
clearTimeout(timer)
reject(err)
})
duplex.write(text)
})
}
/**
* 手工发一帧 `HELLO`(构造"正常 client 做不到"的输入:**不带** `network` 的旧客户端、非法 `network`)。
*
* `network === undefined` ⇒ 字段**根本不出现**(= 现网 47/106 上正在跑的旧客户端)。
*/
async function rawHello(wsUrl, hostId, secret, portsCsv, nonce, network) {
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
await new Promise((resolve, reject) => {
ws.addEventListener('open', resolve, { once: true })
ws.addEventListener('error', () => reject(new Error('ws open failed')), { once: true })
})
const ts = Date.now()
// ⚠️ 与 client.ts 逐字同源的 MAC 输入(**不含 network**)—— 这条"一字不改"本身就是被测对象之一:
// 旧客户端算出来的 MAC 必须仍被接受。
const mac = createHmac('sha256', Buffer.from(secret, 'hex')).update(`${hostId}|${ts}|${nonce}|${portsCsv}`).digest('hex')
const payload = { v: 1, hostId, ts, nonce, portsCsv, mac }
if (network !== undefined) payload.network = network
ws.send(encodeJsonFrame(MUX.HELLO, 0, payload))
const frame = await new Promise((resolve) => {
const timer = setTimeout(() => resolve(undefined), 3000)
ws.addEventListener('message', (ev) => {
clearTimeout(timer)
resolve(decodeMux(Buffer.from(ev.data)))
})
ws.addEventListener('close', () => {
clearTimeout(timer)
resolve(undefined)
})
})
return { ws, frame }
}
/** 在一个**已建立**的连接上手工发一帧、等回帧(用于构造"正常 client 不会做"的请求)。 */
async function rawSend(ws, frameBuf) {
const p = new Promise((resolve) => {
const timer = setTimeout(() => resolve(undefined), 3000)
ws.addEventListener('message', (ev) => {
clearTimeout(timer)
resolve(decodeMux(Buffer.from(ev.data)))
})
ws.addEventListener('close', () => {
clearTimeout(timer)
resolve(undefined)
})
})
ws.send(frameBuf)
return p
}
/* ─────────── U1:纯函数(逻辑名与白名单解析) ─────────── */
test('U1 逻辑名唯一入口:旧形态 / 新形态 / `u:<租户>` 里的冒号 / 非法值一律抛', () => {
// ① 规范形态
assert.equal(logicalName('u:5', 'w-1'), 'u:5/w-1')
assert.deepEqual(parseLogicalName('ops/manager'), { network: 'ops', hostId: 'manager' })
assert.deepEqual(parseLogicalName('u:5/w-106'), { network: 'u:5', hostId: 'w-106' })
// ② 兼容形态:`network:hostId`(运维习惯写法)
assert.deepEqual(parseLogicalName('ops:manager'), { network: 'ops', hostId: 'manager' })
// 🔴 必须**从右**切:`u:5` 里的冒号属于网络 id,从左切会得到 network='u'(不存在那张网 ⇒ 静默拒绝)
assert.deepEqual(parseLogicalName('u:5:manager'), { network: 'u:5', hostId: 'manager' })
assert.deepEqual(parseLogicalName('u:test-network:pc-1'), { network: 'u:test-network', hostId: 'pc-1' })
// ③ 旧形态(R5 时代的扁平 hostId)⇒ 落在运维网,**旧配置照旧可用**
assert.deepEqual(parseLogicalName('manager'), { network: OPS_NETWORK, hostId: 'manager' })
assert.deepEqual(parseLogicalName('w-106'), { network: OPS_NETWORK, hostId: 'w-106' })
// ④ 非法 ⇒ **抛**(配置错就炸,不静默变成"谁也没匹配上")
assert.throws(() => parseLogicalName('u:5'), /网络段/)
assert.throws(() => parseLogicalName('bad net:manager'), /网络段/)
assert.throws(() => parseLogicalName('ops/'), /逻辑名/)
assert.throws(() => assertNetworkId('Ops'), /非法/)
assert.throws(() => assertNetworkId(''), /非法/)
// ⑤ 白名单归一化:扁平 `Set` = 全在 ops;`Map` 按网络分桶
assert.deepEqual([...normalizeDialers(new Set(['manager'])).get(OPS_NETWORK)], ['manager'])
const m = normalizeDialers(new Map([[U_TEST, new Set(['d1'])]]))
assert.equal(m.has(OPS_NETWORK), false, '没列到的网络不该凭空出现')
assert.deepEqual([...m.get(U_TEST)], ['d1'])
// 混写(桶里再带前缀)不会静默失配;非法网络段在装载时就炸
const mixed = normalizeDialers(new Set(['ops:manager', 'u:5:d1']))
assert.deepEqual(describeDialers(mixed), ['manager', 'u:5/d1'], 'ops 省略前缀、其它网写全逻辑名')
assert.throws(() => normalizeDialers(new Set(['BAD net:d1'])), /网络段/)
// 分桶形态:桶内的**裸 hostId 属于本桶那张网**(不是 ops)—— 这一条错了会变成"配对了却静默拒绝"
assert.deepEqual([...normalizeDialers(new Map([['u:5', new Set(['d1'])]])).get('u:5')], ['d1'])
// 桶内带了前缀就必须与本桶一致,否则说明配置自相矛盾 ⇒ 装载时就炸
assert.throws(() => normalizeDialers(new Map([['u:5', new Set(['ops:d1'])]])), /自相矛盾/)
// ⑥ 客户端侧同一条口径:非法 networkId ⇒ 构造时抛(不静默回落 ops)
assert.throws(
() => new RelayClient({ url: 'ws://127.0.0.1:1' + PATH, hostId: 'x', secret: randomBytes(32).toString('hex'), ports: [BASE], networkId: 'BAD' }),
/networkId/,
)
})
/* ─────────── U2:现网不退化(旧客户端 + 旧白名单写法) ─────────── */
test('U2 旧客户端(不声明 network)+ 旧扁平白名单 ⇒ 一切照旧,全部落在 ops', async (t) => {
const wSecret = randomBytes(32).toString('hex')
const w47Secret = randomBytes(32).toString('hex')
const mSecret = randomBytes(32).toString('hex')
const echo = taggedEcho('ops')
const servePort = await listenInRange(echo, BASE + 10, BASE + SPAN - 1)
const legacyPort = BASE + 12
const srv = new RelayServer({
port: 0,
keys: new Map([
['w-106', wSecret],
['w-47', w47Secret],
['manager', mSecret],
]),
instancePortBase: BASE,
instancePortSpan: SPAN,
// 🔴 旧写法:扁平 `Set`(= 现网 `DSHS_RELAY_DIALERS="manager"` 的形态)
dialers: new Set(['manager']),
log: () => {},
})
await srv.start()
const url = `ws://127.0.0.1:${srv.boundPort}${PATH}`
t.after(async () => {
echo.close()
await srv.stop()
})
// ① **旧客户端握手**:HELLO 里根本没有 network 字段(= 现网 47/106 上正在跑的那一版)
const { ws: rawWs, frame: ack } = await rawHello(url, 'w-106', wSecret, String(legacyPort), randomBytes(16).toString('hex'))
t.after(() => rawWs.close())
assert.ok(ack !== undefined, '旧客户端必须能注册(否则这次改动就是硬断)')
assert.equal(ack.type, MUX.HELLO_ACK, '旧客户端应拿到 HELLO_ACK')
const parsed = JSON.parse(ack.payload.toString('utf8'))
assert.equal(parsed.network, OPS_NETWORK, 'HELLO_ACK 应回显服务端认定的网 = ops')
assert.equal(parsed.name, logicalName(OPS_NETWORK, 'w-106'))
assert.ok(await waitFor(() => srv.isOnline('w-106')), '默认网络(ops)里应看到它')
assert.ok(
await waitFor(() => srv.localPortOf('w-106', legacyPort) !== undefined),
'旧客户端照样拿到回环落点(R1–R4 的行为不变)',
)
// ② 不传 `networkId` 的真 client = ops(默认值即现网事实);旧扁平白名单照旧拨得动
const worker = new RelayClient({ url, hostId: 'w-47', secret: w47Secret, ports: [servePort], log: () => {} })
const dialer = new RelayClient({ url, hostId: 'manager', secret: mSecret, ports: [], dialer: true, log: () => {} })
worker.start()
dialer.start()
t.after(() => {
worker.stop()
dialer.stop()
})
assert.ok(await waitFor(() => worker.status().state === 'up'), 'worker 未注册')
assert.equal(worker.status().network, OPS_NETWORK, '不声明网络 ⇒ 默认 ops(不是空、不是 undefined)')
assert.ok(await waitFor(() => dialer.status().state === 'up'), '拨号方未注册')
assert.deepEqual(srv.status().dialers, ['manager'], '/status 的 dialers 仍按旧写法展示(不破坏既有消费者)')
const duplex = await dialer.openStream('w-47', servePort)
assert.equal(await dialRoundTrip(duplex, 'legacy-ok', 'ops:'.length), 'ops:legacy-ok', '字节要真的过去')
duplex.destroy()
})
/* ─────────── U3:跨网隔离是**结构性**的(拒绝点在 relay) ─────────── */
test('U3 跨网隔离:u:test-network 的合法拨号方拨 ops/w-106 ⇒ relay 拒,且**不泄露**目标在哪张网', async (t) => {
const wSecret = randomBytes(32).toString('hex')
const opsSecret = randomBytes(32).toString('hex')
const foreignSecret = randomBytes(32).toString('hex')
const logs = []
const echo = taggedEcho('ops')
const workerPort = await listenInRange(echo, BASE + 20, BASE + SPAN - 1)
const srv = new RelayServer({
port: 0,
keys: new Map([
['w-106', wSecret],
['manager', opsSecret],
// 序③:`dt` 真正属于 U_TEST(密钥表说了算)⇒ 它能进自己的网,然后在 **DIAL 那一步**
// 被跨网判据挡住 —— 这才是本用例要测的那道门,而不是『压根没登记』那道。
[logicalName(U_TEST, 'dt'), foreignSecret],
]),
instancePortBase: BASE,
instancePortSpan: SPAN,
/**
* 两张网**各自都有合法的拨号方**(不是"没配白名单"那种低级情形)——
* 这样才证明隔离来自**网络维度本身**,而不是来自"忘了加白名单"。
*/
dialers: new Map([
[OPS_NETWORK, new Set(['manager'])],
[U_TEST, new Set(['dt'])],
]),
log: (line) => logs.push(line),
})
await srv.start()
const url = `ws://127.0.0.1:${srv.boundPort}${PATH}`
const worker = new RelayClient({ url, hostId: 'w-106', secret: wSecret, ports: [workerPort], log: () => {} })
const foreign = new RelayClient({
url,
hostId: 'dt',
secret: foreignSecret,
ports: [],
dialer: true,
networkId: U_TEST,
log: (line) => logs.push(`[client] ${line}`),
})
worker.start()
foreign.start()
t.after(async () => {
worker.stop()
foreign.stop()
echo.close()
await srv.stop()
})
const opsUp = await waitFor(() => srv.isOnline('w-106'))
assert.ok(opsUp, 'ops 侧 worker 未注册')
const foreignUp = await waitFor(() => srv.isOnline('dt', U_TEST))
assert.ok(foreignUp, `别网拨号方未注册;最近日志:${logs.slice(-10).join(' | ')}`)
// 网维度视图必须可断言(Step 3「看不到」那一半的落地基础)
const nets = srv.status().networks
assert.deepEqual(
nets.find((n) => n.network === OPS_NETWORK)?.sessions,
['w-106'],
'ops 网里应只有 w-106',
)
assert.deepEqual(nets.find((n) => n.network === U_TEST)?.sessions, ['dt'], '别张网里应只有 dt')
// 🔴 核心判据 ①:**拨不动**
const before = srv.status().counters.refused
let foreignMsg = ''
await assert.rejects(
() => foreign.openStream('w-106', workerPort),
(err) => {
foreignMsg = err instanceof Error ? err.message : String(err)
return true
},
'跨网必须被拒',
)
// 🔴 核心判据 ②:**看不到** —— 对外与"本网没有这个节点"逐字同形(P0-3 · D4 不下发对端清单)。
// 若这里回 `dialer-not-in-network` / `targetNetwork`,拨号方拿一串猜测的 hostId 打过来
// 就能把别张网的成员**枚举出来**(判据「看不到」当场失效)。
assert.match(foreignMsg, /target-offline/, `对外必须是 target-offline,实测:${foreignMsg}`)
assert.equal(foreignMsg.includes(U_TEST), false, `响应里不得出现任何网络名,实测:${foreignMsg}`)
assert.equal(
foreignMsg.includes('dialer-not-in-network'),
false,
`对外不得有"跨网"专属拒码,实测:${foreignMsg}`,
)
// 但**服务端**必须留得下区分(否则运维分不清"配错网"与"节点离线")
assert.ok(srv.status().counters.refused > before, '拒绝必须在 relay 侧计数(本机就得留痕)')
assert.ok(
logs.some((l) => l.includes('dialer-not-in-network')),
`relay 日志必须记下跨网拒绝(实测日志:${logs.filter((l) => l.includes('DIAL')).join(' | ')})`,
)
// 目标侧没有任何流被建立(不是"建了再断")
assert.equal(
srv.status().endpoints.find((e) => e.hostId === 'w-106' && e.network === OPS_NETWORK && e.port === workerPort)?.streams,
0,
'被拒的跨网拨号不得在目标侧留下流',
)
assert.equal(foreign.status().dialStreams, 0, '被拒后拨号方也不该留悬挂流')
// 反证:**同网**照旧全通 ⇒ 隔离挡的是"跨网",不是"拨号"本身
const ops = new RelayClient({ url, hostId: 'manager', secret: opsSecret, ports: [], dialer: true, log: () => {} })
ops.start()
t.after(() => ops.stop())
assert.ok(await waitFor(() => ops.status().state === 'up'), 'ops 拨号方未注册')
const duplex = await ops.openStream('w-106', workerPort)
assert.equal(await dialRoundTrip(duplex, 'same-net', 'ops:'.length), 'ops:same-net')
duplex.destroy()
})
/* ─────────── U4 / U5:未列白名单的网 & 跨网同名 hostId ─────────── */
test('U4 没列进白名单的网:连"拨号方"这个身份都拿不到(no-ports,默认拒绝)', async (t) => {
const s1 = randomBytes(32).toString('hex')
const srv = new RelayServer({
port: 0,
// 序③:`dt` 登记在 U_TEST ⇒ 成员资格这一关**过得去**,于是拒绝来自**白名单**那一道门
//(`no-ports`)。不这么登记的话,成员资格会先一步拒,本用例就测不到它原本要测的东西。
keys: new Map([[logicalName(U_TEST, 'dt'), s1]]),
instancePortBase: BASE,
instancePortSpan: SPAN,
dialers: new Map([[OPS_NETWORK, new Set(['manager'])]]), // 只有 ops 有拨号方
log: () => {},
})
await srv.start()
const url = `ws://127.0.0.1:${srv.boundPort}${PATH}`
t.after(async () => {
await srv.stop()
})
/**
* R1 的**默认拒绝**姿态决定了一件好事:拨号方的形态是"**端口表为空**",而空端口表只在
* 该 hostId 命中**本网**白名单时才被接受 ⇒ **白名单外的网络连"拨号方身份"都拿不到**
* (比"拿到了身份但拨不动"更早一道门)。这比 U3 那道门更靠前,两条都要能回归。
*/
const before = srv.status().counters.authFailed
const { ws, frame } = await rawHello(url, 'dt', s1, '', randomBytes(16).toString('hex'), U_TEST)
t.after(() => ws.close())
assert.ok(frame !== undefined, '必须得到结构化拒绝')
assert.equal(frame.type, MUX.HELLO_ERR)
assert.equal(JSON.parse(frame.payload.toString('utf8')).reason, 'no-ports', '白名单外的网不该拿到拨号方身份')
assert.ok(srv.status().counters.authFailed > before)
assert.equal(srv.status().sessions.length, 0)
// 对照:同一个 hostId 只要落进 `ops` 桶(且声明 ops),立刻拿到 `dialer: true`
// ⇒ 说明拒绝来自**网络归属**,不是来自"这个 hostId 本身没被信任"。
const srv2 = new RelayServer({
port: 0,
keys: new Map([
['dt', s1],
['w2', s1],
]),
instancePortBase: BASE,
instancePortSpan: SPAN,
dialers: new Map([[OPS_NETWORK, new Set(['dt'])]]),
log: () => {},
})
await srv2.start()
t.after(async () => {
await srv2.stop()
})
const ok = await rawHello(`ws://127.0.0.1:${srv2.boundPort}${PATH}`, 'dt', s1, '', randomBytes(16).toString('hex'), OPS_NETWORK)
t.after(() => ok.ws.close())
assert.equal(ok.frame?.type, MUX.HELLO_ACK)
assert.equal(JSON.parse(ok.frame.payload.toString('utf8')).dialer, true)
/**
* ② `not-a-dialer` 这条门(R5 就有的默认拒绝)不能被这次改动改坏:
* 一个**已注册的普通 worker**(有端口、不在拨号桶里)手工发 `DIAL` ⇒ relay 必须回拒。
*/
const wPort = BASE + 95
const w2 = await rawHello(`ws://127.0.0.1:${srv2.boundPort}${PATH}`, 'w2', s1, String(wPort), randomBytes(16).toString('hex'), OPS_NETWORK)
t.after(() => w2.ws.close())
assert.equal(w2.frame?.type, MUX.HELLO_ACK, 'worker 形态(声明端口)应能注册')
const ack = await rawSend(w2.ws, encodeJsonFrame(MUX.DIAL, 1, { target: 'dt', port: wPort }))
assert.equal(ack?.type, MUX.DIAL_ACK, 'DIAL 必须被应答(不能静默)')
const ackBody = JSON.parse(ack.payload.toString('utf8'))
assert.equal(ackBody.ok, false)
assert.equal(ackBody.error, 'not-a-dialer', '不在本网拨号桶里的会话不得发起 DIAL')
})
test('U5 跨网同名 hostId 互不干扰:各拨各的(会话表按逻辑名索引)', async (t) => {
const wSecret = randomBytes(32).toString('hex')
const dOps = randomBytes(32).toString('hex')
const dUser = randomBytes(32).toString('hex')
const opsEcho = taggedEcho('ops')
const userEcho = taggedEcho('user')
const opsPort = await listenInRange(opsEcho, BASE + 40, BASE + 60)
const userPort = await listenInRange(userEcho, BASE + 61, BASE + 80)
assert.notEqual(opsPort, userPort)
const srv = new RelayServer({
port: 0,
// 序③:键 = **逻辑名** ⇒ 同一台机器名可以**同时**出现在两张网里,各自是一条独立登记项。
//(用裸 hostId 当键做不到这件事:`w-1` 只能属于一张网 ⇒ 第二张网就注册不进来了。)
keys: new Map([
[logicalName(OPS_NETWORK, 'w-1'), wSecret],
[logicalName('u:x', 'w-1'), wSecret],
[logicalName(OPS_NETWORK, 'm-ops'), dOps],
[logicalName('u:x', 'd-user'), dUser],
]),
instancePortBase: BASE,
instancePortSpan: SPAN,
dialers: new Map([
[OPS_NETWORK, new Set(['m-ops'])],
['u:x', new Set(['d-user'])],
]),
log: () => {},
})
await srv.start()
const url = `ws://127.0.0.1:${srv.boundPort}${PATH}`
const wOps = new RelayClient({ url, hostId: 'w-1', secret: wSecret, ports: [opsPort], networkId: OPS_NETWORK, log: () => {} })
const wUser = new RelayClient({ url, hostId: 'w-1', secret: wSecret, ports: [userPort], networkId: 'u:x', log: () => {} })
const dOpsC = new RelayClient({ url, hostId: 'm-ops', secret: dOps, ports: [], dialer: true, log: () => {} })
const dUserC = new RelayClient({ url, hostId: 'd-user', secret: dUser, ports: [], dialer: true, networkId: 'u:x', log: () => {} })
for (const c of [wOps, wUser, dOpsC, dUserC]) c.start()
t.after(async () => {
for (const c of [wOps, wUser, dOpsC, dUserC]) c.stop()
opsEcho.close()
userEcho.close()
await srv.stop()
})
assert.ok(await waitFor(() => srv.isOnline('w-1', OPS_NETWORK)), 'ops 侧 w-1 未注册')
assert.ok(await waitFor(() => srv.isOnline('w-1', 'u:x')), 'u:x 侧 w-1 未注册')
const w1Names = srv.status()
.sessions.filter((s) => s.hostId === 'w-1')
.map((s) => s.name)
.sort()
assert.deepEqual(w1Names, ['ops/w-1', 'u:x/w-1'], '两个同名节点必须各自在册(扁平命名空间会只剩一个)')
// 两张网的拨号方各拨**自己网里**的 w-1 ⇒ 各自落到自己那张网的回显服务(前缀不同 ⇒ 不可能看错)
const dOpsStream = await dOpsC.openStream('w-1', opsPort)
assert.equal(await dialRoundTrip(dOpsStream, 'A', 'ops:'.length), 'ops:A')
dOpsStream.destroy()
const dUserStream = await dUserC.openStream('w-1', userPort)
assert.equal(await dialRoundTrip(dUserStream, 'B', 'user:'.length), 'user:B')
dUserStream.destroy()
})
/* ─────────── U6:非法 network ⇒ 失败关闭 ─────────── */
test('U6 非法 network 声明 ⇒ bad-network 失败关闭(不建会话、不静默当 ops)', async (t) => {
const secret = randomBytes(32).toString('hex')
const srv = new RelayServer({
port: 0,
keys: new Map([['w-x', secret]]),
instancePortBase: BASE,
instancePortSpan: SPAN,
log: () => {},
})
await srv.start()
const url = `ws://127.0.0.1:${srv.boundPort}${PATH}`
t.after(async () => {
await srv.stop()
})
const before = srv.status().counters.authFailed
const { ws, frame } = await rawHello(url, 'w-x', secret, String(BASE + 90), randomBytes(16).toString('hex'), 'bad net!')
t.after(() => ws.close())
assert.ok(frame !== undefined, '非法网必须得到**结构化拒绝**,而不是静默断开')
assert.equal(frame.type, MUX.HELLO_ERR, '应回 HELLO_ERR')
assert.equal(JSON.parse(frame.payload.toString('utf8')).reason, 'bad-network')
assert.ok(srv.status().counters.authFailed > before, '拒绝要计数')
assert.equal(srv.status().sessions.length, 0, '非法网不得建会话(更不得落到 ops)')
assert.equal(srv.localPortOf('w-x', BASE + 90), undefined, '被拒的节点不得留下任何回环监听')
})
/* ─────────── 说明:本文件不覆盖的部分 ─────────── */
test('U7 本文件的范围声明(Step 1 只做 P0-1,别在这里顺手做后面两步)', () => {
// Step 2(引导三级链 / `/dshs-overlay/bootstrap`)与 Step 3(名字解析对外接口)见后续步骤,
// ⛔ 不要在本步追加 —— 每步单独可回滚(交接单 §4)。
assert.ok(true)
})
+52 -2
View File
@@ -47,8 +47,9 @@ test('parseReachability → agentBaseUrl 对现网两行是往返恒等的', ()
assert.equal(toEndpoint(reach), h.endpoint, `${h.hostId} toEndpoint 不一致`)
assert.equal(reach.scheme, 'http')
assert.deepEqual(
{ hostId: reach.hostId, via: reach.via, address: reach.address },
{ hostId: h.hostId, via: h.via, address: h.endpoint.slice('http://'.length) },
{ hostId: reach.hostId, networkId: reach.networkId, via: reach.via, address: reach.address },
// 裸 hostId ⇒ 落运维网(P0-3 的过渡期兼容:旧调用方一个字都不用改)
{ hostId: h.hostId, networkId: 'ops', via: h.via, address: h.endpoint.slice('http://'.length) },
)
}
})
@@ -56,6 +57,7 @@ test('parseReachability → agentBaseUrl 对现网两行是往返恒等的', ()
test('parseReachability:https / 裸 host:port / 尾斜杠 三种兼容面', () => {
assert.deepEqual(parseReachability('h', 'https://a.example:8443', 'x'), {
hostId: 'h',
networkId: 'ops',
via: 'x',
address: 'a.example:8443',
scheme: 'https',
@@ -63,6 +65,7 @@ test('parseReachability:https / 裸 host:port / 尾斜杠 三种兼容面', ()
// 没写 scheme ⇒ 按 http(与 fetch 的补全行为一致)
assert.deepEqual(parseReachability('h', '10.0.0.5:19000', 'x'), {
hostId: 'h',
networkId: 'ops',
via: 'x',
address: '10.0.0.5:19000',
scheme: 'http',
@@ -91,6 +94,7 @@ test('LocalRendezvous:命中给 local,未命中回 undefined 不抛', async
assert.equal(rv.dialTarget(), '(direct)')
assert.deepEqual(await rv.resolve('w-47'), {
hostId: 'w-47',
networkId: 'ops',
via: VIA_LOCAL,
address: '127.0.0.1:19100',
scheme: 'http',
@@ -124,3 +128,49 @@ test('RendezvousRegistry:按 via 取实现', () => {
assert.equal(reg.get('relay:backbone-1'), undefined)
assert.deepEqual(reg.ids().sort(), [VIA_LOCAL, VIA_MANAGER_SSH].sort())
})
// ── S2 验收判据 ────────────────────────────────────────────────────────────
// 「迁移前 `agentUrl`」必须 **逐条逐字等于** 「迁移后 `resolve()` 的结果」。
// 这里把 `hostsProvider` 的真实接线原样复刻一遍(先读 via → 选实现 → 解析 → 拼基址),
// 两条数据用现网实测值写死 ⇒ **不靠肉眼、不靠人工比对**。
test('S2:via → Rendezvous → Reachability 后取址与旧 agentUrl 逐条相等', async () => {
// 现网 `dsh_hosts` 真实两行(2026-09-16 实测;`via` = 回填后的目标值)
const rows = LIVE_HOSTS.map((h) => ({ id: h.hostId, endpoint: h.endpoint, via: h.via }))
// P0-3:控制面的键 = **逻辑名**(现网全在 `ops` ⇒ 与裸 id 等价)
const nameOf = (h) => `ops/${h.id}`
const hostAddresses = new Map()
const rendezvous = new RendezvousRegistry([
new LocalRendezvous((id) => hostAddresses.get(id)),
new ManagerSshRendezvous({ target: 'ssh://[email protected]:32022', addressOf: (id) => hostAddresses.get(id) }),
])
// hostsProvider 第一遍:同步地址表
for (const row of rows) {
hostAddresses.set(nameOf(row), parseReachability(nameOf(row), row.endpoint, row.via).address)
}
for (const row of rows) {
const impl = rendezvous.get(row.via) ?? rendezvous.get(VIA_MANAGER_SSH)
assert.notEqual(impl, undefined, `${row.id} 的 via=${row.via} 必须能选到实现`)
const reach = await impl.resolve(nameOf(row))
assert.notEqual(reach, undefined, `${row.id} 必须能解析出可达性`)
assert.equal(reach.via, row.via, `${row.id} 解析后 via 变了`)
assert.equal(reach.hostId, row.id, `${row.id} 解析出的 hostId 必须是**裸** id`)
assert.equal(reach.networkId, 'ops', `${row.id} 解析出的网络段必须是 ops`)
// 判据:新路径拼出来的基址 == 旧路径直接用的 endpoint
assert.equal(agentBaseUrlOf({ hostId: row.id, reachability: reach }), row.endpoint, `${row.id} 取址变了`)
assert.equal(agentBaseUrlOf({ hostId: row.id, agentUrl: row.endpoint }), row.endpoint, `${row.id} 旧路径变了`)
}
})
test('S2:via 认不出来 ⇒ 回退 manager-ssh,不抛(过渡期要能跑)', async () => {
const hostAddresses = new Map([['w-x', '10.0.0.9:19000']])
const rendezvous = new RendezvousRegistry([
new LocalRendezvous((id) => hostAddresses.get(id)),
new ManagerSshRendezvous({ target: '', addressOf: (id) => hostAddresses.get(id) }),
])
const impl = rendezvous.get('relay:not-deployed-yet') ?? rendezvous.get(VIA_MANAGER_SSH)
const reach = await impl.resolve('w-x')
assert.equal(reach.via, VIA_MANAGER_SSH)
assert.equal(agentBaseUrlOf({ hostId: 'w-x', reachability: reach }), 'http://10.0.0.9:19000')
})
+844
View File
@@ -0,0 +1,844 @@
/**
* 覆盖网络 · **序⑦ 中继失败切流** 单测 —— "杀掉任一台中继 ⇒ 客户端自动切到另一台"。
*
* ## 这个文件要回答的两个问题
* 1. **候选集还会不会退化成单点?**(改造前 `pickFromDoc` 取到第一个就 `return`
* ⇒ 目录里排第二的那台**永远选不中**,重解析一百次拿回来的还是同一个字符串)
* 2. **"当前这条不健康"时到底会不会换到下一条**,且**换不动时会不会把本来能用的通路打掉**?
*
* ## 四条行为断言(对应交接单 §6 E1–E4 / E7–E9)
* - **候选全取出来**(顺序 = `relays[]` → `bootstrap[]`);
* - **`exclude` 生效**,且**不传 `exclude` 时与改造前逐字一致**(D9 向后兼容);
* - **不健康判据可读**(`unhealthySinceMs` 非空 / 健康时归零);
* - **切换的四条纪律**:能切就切 / 无候选不切空 / 冷却期内不回跳 / 新通道建不起来就保留原通道。
* 且 `[relay-switch]` 日志行数 **必然等于** `switches` 计数(D7 判别器可断言)。
*
* 运行:`node --test test/relay-failover.test.mjs`(Node ≥ 22;测 `lib/` 产物,先 `npm run build`)。
*
* @module test/relay-failover
*/
import assert from 'node:assert/strict'
import { generateKeyPairSync, randomBytes } from 'node:crypto'
import { createServer } from 'node:http'
import { createServer as createTcpServer } from 'node:net'
import { test } from 'node:test'
import {
DIRECTORY_PATH,
RelayClient,
RelayFailoverSupervisor,
RelayServer,
buildDirectoryDocument,
gracefulBurstMsDefault,
listOverlayRelayCandidates,
openedChannelFailedTerminally,
resolveOverlayRelay,
signDirectory,
waitUpOnStatus,
} from '../lib/net/relay/index.js'
/* ─────────── 搭台工具 ─────────── */
const RELAY_PATH = '/dshs-relay'
function makeKeys() {
const { publicKey, privateKey } = generateKeyPairSync('ed25519')
return {
privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
publicPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
}
}
/** 一份自签目录:`relays[]` 按给定顺序(**不过滤私网** —— `buildDirectoryDocument` 只做解析/去重)。 */
function signedDoc(relays, keyPem, bootstrap = []) {
const doc = buildDirectoryDocument({
relays,
bootstrap,
network: 'ops',
now: Date.now(),
refreshAfterSeconds: 300,
})
return { doc, sig: signDirectory(doc, keyPem) }
}
/** 起一个真的目录端点(`node:http`),请求 `DIRECTORY_PATH` 返回当前 doc+sig。 */
function serveDirectory(initial) {
const state = { ...initial }
const server = createServer((req, res) => {
if (!req.url || !req.url.startsWith(DIRECTORY_PATH)) {
res.writeHead(404).end('{}')
return
}
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
res.end(JSON.stringify({ ...state.doc, sig: state.sig }))
})
return {
state,
async listen() {
return await new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = server.address().port
resolve({ port, origin: `http://127.0.0.1:${port}/dshs-relay` })
})
})
},
close: () => new Promise((resolve) => server.close(() => resolve())),
}
}
/** 假的"通道句柄"(切流单测不碰真 socket:要测的是**决策**,不是传输)。 */
function fakeChannel(url, health = { state: 'up', attempts: 0, unhealthyForMs: 0 }) {
const ch = {
url,
closed: 0,
_h: { ...health },
health: () => ({ ...ch._h }),
/** 测试用:改成不健康。 */
setHealth(next) {
ch._h = { ...next }
},
close() {
ch.closed += 1
},
}
return ch
}
/** 攒日志行(用于断言"判别器可 grep")。 */
function collector() {
const lines = []
return {
lines,
log: (l) => lines.push(l),
/** `[relay-switch]` 开头的行数 —— **必须等于 `switches`**(D7/E9)。 */
switchLines: () => lines.filter((l) => l.startsWith('[relay-switch]')).length,
}
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function waitFor(fn, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs
for (;;) {
if (fn()) return true
if (Date.now() >= deadline) return false
await sleep(10)
}
}
/* ─────────── F1 / F2:候选集不再退化成单点(E1 / E2) ─────────── */
test('F1 候选集:目录含两台中继 ⇒ 两条都取出来、顺序 relays[] → bootstrap[](E1)', async (t) => {
const keys = makeKeys()
const dir = serveDirectory({})
const { port, origin } = await dir.listen()
t.after(() => dir.close())
// 目录端点自己也算一个 relay 入口(同源约定)⇒ 用它当"回答目录的那个 origin"
const A = origin
const B = `http://127.0.0.1:${port + 1}/dshs-relay`
const C = `http://127.0.0.1:${port + 2}/dshs-relay`
Object.assign(dir.state, signedDoc([A, B], keys.privatePem, [C]))
const opts = {
seeds: [A],
trustedKeys: [keys.publicPem],
cacheFile: '',
log: () => {},
}
const cands = await listOverlayRelayCandidates(opts)
assert.equal(cands.source, 'seed-directory')
assert.equal(cands.urls.length, 3, `应列出全部 3 条候选(relays 2 + bootstrap 1),实际 ${JSON.stringify(cands.urls)}`)
assert.equal(cands.urls[0], 'ws://127.0.0.1:' + port + '/dshs-relay', '首位 = relays[] 第一条(同源优先命中它本身 ⇒ 顺序不变)')
assert.ok(cands.urls[1].endsWith(`:${port + 1}/dshs-relay`), '第二位 = relays[] 第二条')
assert.ok(cands.urls[2].endsWith(`:${port + 2}/dshs-relay`), '末位 = bootstrap[]')
})
test('F2 exclude 生效且向后兼容:不传 exclude ⇒ 首位与改造前逐字一致(E2 / D9)', async (t) => {
const keys = makeKeys()
const dir = serveDirectory({})
const { port, origin } = await dir.listen()
t.after(() => dir.close())
const A = origin
const B = `http://127.0.0.1:${port + 1}/dshs-relay`
Object.assign(dir.state, signedDoc([A, B], keys.privatePem, []))
const base = { seeds: [A], trustedKeys: [keys.publicPem], cacheFile: '', log: () => {} }
const plain = await resolveOverlayRelay(base)
const Aws = `ws://127.0.0.1:${port}/dshs-relay`
const Bws = `ws://127.0.0.1:${port + 1}/dshs-relay`
assert.equal(plain.url, Aws, '不传 exclude ⇒ 仍是首位(D9 存量调用点零影响)')
const excluded = await resolveOverlayRelay({ ...base, exclude: [Aws] })
assert.equal(excluded.url, Bws, 'exclude 掉当前那台 ⇒ 换到第二台')
const both = await resolveOverlayRelay({ ...base, exclude: [Aws, Bws] })
assert.equal(both.url, '', 'D6:候选被排空 ⇒ 返回空串(调用方保持原地退避,⛔ 不切到空)')
assert.ok(both.detail.endsWith('|exhausted'), '排空时 detail 带 |exhausted 便于取证')
})
/* ─────────── F3:不健康判据可读(E3 / S2 口径) ─────────── */
test('F3 RelayClient 不健康快照:健康 ⇒ 归零;连不上 ⇒ 非空且态为 backoff(E3)', async (t) => {
// ① 健康:连真的 relay(本文件里唯一一处用真 socket 的地方 —— 要证明"up 状态下确实归零")
const secret = randomBytes(32).toString('hex')
// relay 对声明的端口有两条硬校验:**不能空**(`no-ports`)、**必须在实例口区间内**(`port-out-of-range`)
// ⇒ 起一个真在监听的 echo 服务,且端口落在 `instancePortBase..+span`
const BASE = 34400
const SPAN = 200
const echo = createTcpServer((sock) => sock.pipe(sock))
const declared = await new Promise((resolve, reject) => {
let p = BASE + 7
const tryBind = () => {
if (p >= BASE + SPAN) return reject(new Error('no free port in range'))
echo.once('error', () => {
p += 1
tryBind()
})
echo.listen(p, '127.0.0.1', () => resolve(echo.address().port))
}
tryBind()
})
const server = new RelayServer({
port: 0,
keys: new Map([['w-f3', secret]]),
instancePortBase: BASE,
instancePortSpan: SPAN,
log: () => {},
})
await server.start()
const ok = new RelayClient({
url: `ws://127.0.0.1:${server.boundPort}${RELAY_PATH}`,
hostId: 'w-f3',
secret,
ports: [declared],
log: () => {},
reconnectMinMs: 10,
reconnectMaxMs: 40,
})
t.after(async () => {
ok.stop()
await server.stop()
echo.close()
})
ok.start()
assert.ok(await waitFor(() => ok.status().state === 'up'), '客户端未在 5s 内 up')
const healthy = ok.status()
assert.equal(healthy.unhealthySinceMs, undefined, 'up 状态必须归零(unhealthySinceMs = undefined)')
assert.equal(healthy.unhealthyForMs, 0, 'up 状态 unhealthyForMs 必须为 0')
// ② 不健康:指向一个**没人监听**的回环口 ⇒ 必然进 backoff
const dead = new RelayClient({
url: 'ws://127.0.0.1:1/dshs-relay',
hostId: 'w-f3b',
secret,
ports: [],
log: () => {},
reconnectMinMs: 10,
reconnectMaxMs: 40,
})
t.after(() => dead.stop())
dead.start()
assert.ok(await waitFor(() => dead.status().state === 'backoff'), '连不上时必须进入 backoff')
const bad = dead.status()
assert.equal(bad.state, 'backoff')
assert.ok(typeof bad.unhealthySinceMs === 'number', 'backoff 后 unhealthySinceMs 必须非空')
assert.ok(bad.unhealthyForMs >= 0, 'unhealthyForMs 必须可读(≥ 0)')
})
/* ─────────── F4–F7:切换决策的四条纪律(E4 / E7 / E8 / D4) ─────────── */
/** 搭一个"当前连 A、候选 [A,B]"的监管器。 */
function setup({ candidates = ['A', 'B'], openImpl } = {}) {
const col = collector()
const opened = []
const sup = new RelayFailoverSupervisor({
open: async (url) => {
opened.push(url)
const h = openImpl ? await openImpl(url) : fakeChannel(url)
return h === undefined ? undefined : h
},
candidates: async () => candidates,
log: col.log,
thresholds: { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10 },
})
return { sup, col, opened }
}
test('F4 能切就切:当前不健康 ⇒ 换到下一条,且 switches 与 [relay-switch] 行数相等(D7/E9)', async () => {
const col = collector()
const opened = []
const A = fakeChannel('A', { state: 'backoff', attempts: 3, unhealthyForMs: 1200 })
const B = fakeChannel('B', { state: 'up', attempts: 0, unhealthyForMs: 0 })
const sup = new RelayFailoverSupervisor({
open: async (url) => {
opened.push(url)
return url === 'B' ? B : undefined
},
candidates: async () => ['A', 'B'],
log: col.log,
thresholds: { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10 },
})
sup.seed(A)
await sup.tick()
const st = sup.stats()
assert.deepEqual(opened, ['B'], '只应去建 B 这一条')
assert.equal(st.switches, 1, '必须切换一次')
assert.equal(sup.channel, B, '当前通道必须已是 B')
assert.equal(A.closed, 1, '旧通道必须被关掉(先建新、成功再关旧)')
assert.equal(B.closed, 0, '新通道不能被关')
assert.equal(col.switchLines(), st.switches, 'D7:日志行数必须等于 switches 计数')
assert.match(col.lines.find((l) => l.startsWith('[relay-switch]')), /#1 A -> B/)
assert.equal(st.cooldown.length, 1, '被换掉的 A 必须进冷却表')
assert.equal(st.cooldown[0].url, 'A')
})
test('F5 无候选不切空:链里只剩当前那台 ⇒ 原地退避、switches 不增、不静默回退(D6/E7)', async () => {
const { sup, col } = setup({ candidates: ['A'] })
const A = fakeChannel('A', { state: 'backoff', attempts: 8, unhealthyForMs: 9000 })
sup.seed(A)
await sup.tick()
await sup.tick()
const st = sup.stats()
assert.equal(st.switches, 0, '⛔ 不许切到空')
assert.equal(sup.channel, A, '⛔ 不许静默回退到别的机器')
assert.ok(st.noCandidateChecks >= 1, '必须记下"无候选可切"的次数(D6 现场证据)')
assert.equal(col.switchLines(), 0, '没有切换 ⇒ 不许有 [relay-switch] 行')
assert.ok(col.lines.some((l) => l.startsWith('[relay-skip]')), '必须有一行 [relay-skip] 说明为何不切')
})
/**
* 🔴 **序⑧ 的冲突与裁决(F6 / F9 为什么多了 `exempt: false`)**:
*
* 本用例的场景**恰好就是**序⑧ 要改的那个现场 —— 当前通道 `B` 不健康、链里唯一的替代 `A` 正在冷却
* ⇒ `tick()` 过滤后 `target === undefined` = **D6 现场**。序⑧ 的 D1 在这里**故意**开了"一跳豁免"
* (D1 原文:"旧 url 已被证伪 ⇒ 回跳它不是抖动,而是**唯一可能的出路**")。
* 而交接单 §3.1-5 / E4 又要求"序⑦ F1–F11 全绿、不许改语义" —— 两者在**这个场景**上真冲突
* (真机 E9 幕 4 与它同构 ⇒ 没有任何判据能"只豁免幕 4、不豁免 F6")。
*
* 裁决(依 D3 的**精确口径**):D3 给的护栏是"**有干净候选时**行为逐字不变" ——
* 本场景**没有**干净候选,所以不在 D3 的保证范围内。⇒
* **断言逐字不变**,只把"关闭序⑧ 豁免"这个开关显式写进用例配置 ⇒
* 本用例继续锁住 **D5 的基线语义**(= 序⑦ 逐字行为),序⑧ 的新行为由 **F13 / F15 / F16 / F17** 锁住。
*/
test('F6 冷却期内不回跳:A 恢复了但仍在冷却 ⇒ 不换回 A(D5/E8;豁免关 = 序⑦ 基线)', async () => {
const col = collector()
let A
const sup = new RelayFailoverSupervisor({
open: async (url) => (url === 'B' ? fakeChannel('B') : fakeChannel(url)),
candidates: async () => ['A', 'B'],
log: col.log,
thresholds: { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10, exempt: false },
})
A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
sup.seed(A)
await sup.tick()
assert.equal(sup.stats().switches, 1, '第一次:A 挂 ⇒ 切 B')
// B(当前)也变成不健康;此时 A 已"恢复"(健康)但**在冷却期内**
const B = sup.channel
B.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
A.setHealth({ state: 'up', attempts: 0, unhealthyForMs: 0 })
await sup.tick()
assert.equal(sup.stats().switches, 1, '⛔ 冷却期内不回跳(否则两台互相抢 = 抖动风暴)')
assert.equal(sup.channel, B, '仍应留在 B')
})
test('F7 新通道建不起来 ⇒ 原通道原样保留(D4 / R11)', async () => {
const { sup, col } = setup({ openImpl: async () => undefined })
const A = fakeChannel('A', { state: 'backoff', attempts: 9, unhealthyForMs: 9000 })
sup.seed(A)
await sup.tick()
const st = sup.stats()
assert.equal(st.switches, 0, '建不起来就不算切换')
assert.equal(st.openFailed, 1, '必须记下 openFailed')
assert.equal(sup.channel, A, '⛔ 原通道必须保留(把本来能用的通路打掉才是真事故)')
assert.equal(A.closed, 0, '⛔ 不许把旧通道关掉')
assert.equal(col.switchLines(), 0)
})
test('F8 巡检只在"不健康"时动手:健康通道连跑多轮 ⇒ 零切换、零 open', async () => {
const { sup, opened, col } = setup()
const A = fakeChannel('A', { state: 'up', attempts: 0, unhealthyForMs: 0 })
sup.seed(A)
for (let i = 0; i < 5; i += 1) await sup.tick()
assert.equal(opened.length, 0, '健康时不许调 open(否则每次巡检都白建一条通道)')
assert.equal(sup.stats().switches, 0)
assert.equal(col.switchLines(), 0)
})
/* ─────────── F9 / F10:真机拓扑逼出来的两条(注入时钟 / 死候选) ─────────── */
/**
* F9 · **冷却期满 ⇒ 自动回归候选表**(D5 后半句)。
*
* 为什么必须用注入时钟:真机冷却 300 s,等不起。等不起的判据就等于没有 ⇒ 必须可确定性复现。
*
* ⚠️ 序⑧:本用例中段("冷却未满 ⇒ 不回跳")同样是 **D6 现场** ⇒ 与 F6 同因,显式置
* `exempt: false`(= 锁序⑦ 基线;序⑧ 的新行为见 F13/F15/F16/F17)。
*/
test('F9 冷却期满 ⇒ 失败过的那台自动回归候选表(D5;豁免关 = 序⑦ 基线)', async () => {
const col = collector()
let now = 1_000_000
const sup = new RelayFailoverSupervisor({
open: async (url) => fakeChannel(url),
candidates: async () => ['A', 'B'],
log: col.log,
nowMs: () => now,
thresholds: { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10, exempt: false },
})
const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
sup.seed(A)
await sup.tick()
assert.equal(sup.stats().switches, 1, 'A 挂 ⇒ 切 B,A 进冷却')
assert.equal(sup.channel.url, 'B')
// B 也挂;此刻 A 已"恢复",但**冷却未满** ⇒ 不回跳
sup.channel.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
A.setHealth({ state: 'up', attempts: 0, unhealthyForMs: 0 })
now += 59_000
await sup.tick()
assert.equal(sup.stats().switches, 1, '冷却未满 ⇒ 不回跳')
// 冷却期满 ⇒ A 回归候选 ⇒ 允许换回 A
now += 2_000
await sup.tick()
assert.equal(sup.stats().switches, 2, '冷却期满 ⇒ 回归候选并换回')
assert.equal(sup.channel.url, 'A')
assert.equal(col.switchLines(), 2, 'D7:日志行数仍等于 switches')
})
/**
* F10 · **试失败的候选不能把链堵死**(真机拓扑逼出来的一条)。
*
* 生产目录 `relays[]` 前两条**落在同一台机器**上:杀那台时,若失败的候选不进冷却,
* 每次巡检都会卡在同一条上、**永远推进不到第三条** ⇒ 链"不再退化成单点"却依然换不过去。
*/
test('F10 前两个候选建不起来 ⇒ 自动推进到第三个(失败候选进冷却,⛔ 不堵链)', async () => {
const col = collector()
const alive = fakeChannel('C', { state: 'up', attempts: 0, unhealthyForMs: 0 })
const tried = []
const sup = new RelayFailoverSupervisor({
open: async (url) => {
tried.push(url)
return url === 'C' ? alive : undefined // A / B 同机已死
},
candidates: async () => ['A', 'B', 'C'],
log: col.log,
thresholds: { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10 },
})
const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
sup.seed(A)
await sup.tick() // 试 B(A 是当前 ⇒ 被排除)⇒ 失败 ⇒ 进冷却
assert.equal(sup.stats().switches, 0, '第一次不该算切换')
await sup.tick() // B 已在冷却 ⇒ 推进到 C ⇒ 成功
assert.equal(sup.stats().switches, 1, '第二次必须推进到 C 并切换成功')
assert.equal(sup.channel, alive)
assert.deepEqual(tried, ['B', 'C'], '尝试顺序必须是"下一个候选 → 再下一个"(⛔ 不许卡在 B 上反复试)')
assert.equal(col.switchLines(), sup.stats().switches)
})
/**
* F11 · **冷却闸门对"目录地址变更"这条路径同样生效**(D5)。
*
* 真机实测逼出来的一条:`refreshOverlay`(目录地址变了)直接调 `replace()`,
* 它**不看冷却表** ⇒ 会把刚被冷却的地址立刻换回来 ⇒ 抖动抑制被绕开。
*/
test('F11 冷却期内的目标:连"目录地址变更"路径也不许换过去(D5)', async () => {
const col = collector()
let now = 5_000_000
const sup = new RelayFailoverSupervisor({
open: async (url) => fakeChannel(url),
candidates: async () => ['A', 'B'],
log: col.log,
nowMs: () => now,
thresholds: { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10 },
})
const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
sup.seed(A)
await sup.tick() // A 挂 ⇒ 切 B,A 进冷却
assert.equal(sup.channel.url, 'B')
assert.equal(sup.stats().switches, 1)
// 模拟 `refreshOverlay`:目录首位又变回 A(刚被冷却)
// ⚠️ 序⑧ 起 `replace()` 必须显式声明 `origin`('directory' = **无豁免权**,D1)——
// 本用例的语义与序⑦ 逐字相同(仍是"冷却期内不许换过去"),只是把隐含意图变成显式参数。
const ok = await sup.replace('A', '目录地址变更(source=cache)', 'directory')
assert.equal(ok, false, '⛔ 冷却期内的目标不许换过去(否则抖动抑制形同不存在)')
assert.equal(sup.stats().switches, 1, 'switches 不许增加')
assert.equal(sup.channel.url, 'B', '仍应留在 B')
// 冷却期满 ⇒ 允许换回 A
now += 61_000
assert.equal(await sup.replace('A', '目录地址变更(source=cache)', 'directory'), true, '冷却期满 ⇒ 允许')
assert.equal(sup.channel.url, 'A')
})
/* ─────────── F12–F17:序⑧「切流冷却语义」(D1–D6) ─────────── */
/** 序⑧ 的公共阈值:与 F9/F11 同口径(可注入时钟 ⇒ 冷却期满可确定性复现)。 */
const TH8 = { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10 }
/**
* F12 · **目录路径没有豁免权**(E1 / D1)。
*
* 立项依据:真机 11:43:26 实测 `wss://106… -> wss://alotbuy.com…` —— 只因"目录里的地址变了"
* 就把刚被冷却的 47 换回来 ⇒ D5 的抖动抑制被另一条路径绕开。
*/
test('F12 目录路径无豁免权:origin=directory + 目标在冷却 ⇒ 必 skip(E1 / D1)', async () => {
const col = collector()
let now = 1_000_000
const sup = new RelayFailoverSupervisor({
open: async (url) => fakeChannel(url),
candidates: async () => ['A', 'B'],
log: col.log,
nowMs: () => now,
thresholds: TH8,
})
const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
sup.seed(A)
await sup.tick() // A 挂 ⇒ 切 B;A 进冷却(kind = switched-away)
assert.equal(sup.channel.url, 'B')
assert.equal(sup.stats().switches, 1)
assert.deepEqual(
sup.stats().cooldown.map((c) => [c.url, c.kind]),
[['A', 'switched-away']],
'冷却表已结构化:键仍按 url,`kind` 记录"我们主动离开了它"',
)
now += 5_000
const ok = await sup.replace('A', '目录地址变更(source=cache)', 'directory')
assert.equal(ok, false, '⛔ 目录路径**不得**打破冷却(D1)')
assert.equal(sup.stats().switches, 1, 'switches 不许增加')
assert.equal(sup.stats().exemptSwitches, 0, '⛔ 这不是豁免')
assert.equal(sup.channel.url, 'B', '仍应留在 B')
assert.ok(
col.lines.some((l) => l.startsWith('[relay-skip]') && l.includes('仍在冷却')),
'必须留下"仍在冷却"的判别器行',
)
assert.ok(
col.lines.every((l) => !l.includes('|豁免')),
'⛔ 目录路径不许出现豁免标记',
)
})
/**
* F13 · **一跳豁免成立**(E2 / D1 / D4)。
*
* 现场 = D6:生产目录 3 条候选里 **2 条同机** ⇒ 一次 47 故障把它们**同时**耗进冷却 ⇒
* "当前这条也挂了"时链里再无干净候选 ⇒ 序⑧ 之前是**最长 `cooldownMs` 不切流**。
*/
test('F13 一跳豁免:D6 现场 + 1 条 switched-away ⇒ 切过去并计数(E2 / D4)', async () => {
const col = collector()
let now = 2_000_000
const sup = new RelayFailoverSupervisor({
open: async (url) => fakeChannel(url),
candidates: async () => ['A', 'B'],
log: col.log,
nowMs: () => now,
thresholds: TH8,
})
const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
sup.seed(A)
await sup.tick() // A 挂 ⇒ 切 B;A 进冷却
assert.equal(sup.channel.url, 'B')
// B(当前)也挂;此刻 A **仍在冷却窗内**(60 s)⇒ 候选池被耗干 = D6 现场
sup.channel.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
A.setHealth({ state: 'up', attempts: 0, unhealthyForMs: 0 })
now += 5_000
await sup.tick()
const st = sup.stats()
assert.equal(st.switches, 2, '豁免必须完成一次真实切换')
assert.equal(st.exemptSwitches, 1, 'D7:豁免切换必须计数(⊆ switches)')
assert.equal(sup.channel.url, 'A', '必须切回 A')
assert.equal(st.noCandidateChecks, 0, '豁免成功 ⇒ 不该再记"无候选"')
const sw = col.lines.filter((l) => l.startsWith('[relay-switch]'))
assert.equal(sw.length, st.switches, 'D7:日志行数必须等于 switches(豁免行也是 switch 行)')
assert.match(sw[1], /|豁免 kind=switched-away /, '豁免切换必须带可断言的判别器标记')
assert.match(sw[1], /原因:当前通道不健康/, 'E9③:原因必须是 health 路径,⛔ 不是"目录地址变更"')
})
/**
* F14 · **豁免优先级**(E3 / D5):`switched-away` 优先于 `open-failed`。
*
* 语义依据:`switched-away` = "我们主动离开了一件**曾可用**的东西";`open-failed` = "刚证明它建不起来"。
* 本用例里 `open-failed` 的 `untilMs` **更早**(先失败先解除),仍然必须让位于 `switched-away`
* ⇒ 同时验证"优先级**压过** `untilMs` 升序"。
*/
test('F14 豁免优先级:switched-away 压过 open-failed(E3 / D5)', async () => {
const col = collector()
let now = 3_000_000
const tried = []
const sup = new RelayFailoverSupervisor({
open: async (url) => {
tried.push(url)
return url === 'X' ? undefined : fakeChannel(url) // X 永远建不起来
},
candidates: async () => ['C', 'X', 'Y'],
log: col.log,
nowMs: () => now,
thresholds: TH8,
})
const C = fakeChannel('C', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
sup.seed(C)
await sup.tick() // 试 X ⇒ 失败 ⇒ X 进冷却(open-failed)
assert.equal(sup.stats().openFailed, 1)
now += 1_000
await sup.tick() // 推进到 Y ⇒ 成功;C 进冷却(switched-away)
assert.equal(sup.channel.url, 'Y')
assert.deepEqual(
sup.stats().cooldown.map((c) => [c.url, c.kind]),
[
['X', 'open-failed'],
['C', 'switched-away'],
],
'两条冷却条目:X 先建冷却(untilMs 更早),C 后建',
)
sup.channel.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
now += 1_000
await sup.tick() // D6 现场 ⇒ 豁免:必须挑 C(switched-away),⛔ 不是 untilMs 更早的 X
assert.deepEqual(tried.slice(-1), ['C'], '豁免必须挑 switched-away 那条(压过 untilMs 升序)')
assert.equal(sup.channel.url, 'C')
assert.equal(sup.stats().exemptSwitches, 1)
})
/**
* F15 · 🔴 **D3 护栏:有干净候选时行为逐字不变**。
*
* 这是本单**最重要的一条不变量** —— 豁免一旦泄漏进正常路径,就会变成"每轮巡检都想回跳"(新抖动源),
* 等于把序⑦ 的 E1–E11 结论**自己推翻自己**。
*/
test('F15 有干净候选 ⇒ 绝不走豁免(D3 护栏)', async () => {
const col = collector()
let now = 4_000_000
const sup = new RelayFailoverSupervisor({
open: async (url) => (url === 'B' ? undefined : fakeChannel(url)), // B 永远建不起来
candidates: async () => ['A', 'B', 'C', 'D'],
log: col.log,
nowMs: () => now,
thresholds: TH8,
})
const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
sup.seed(A)
await sup.tick() // 试 B ⇒ 失败 ⇒ B 进冷却
now += 1_000
await sup.tick() // 推进到 C ⇒ 成功;A 进冷却
assert.equal(sup.channel.url, 'C')
assert.equal(sup.stats().switches, 1)
// 当前 C 也挂:此时 A(switched-away)/ B(open-failed)都在冷却,但 **D 是干净的**
sup.channel.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
A.setHealth({ state: 'up', attempts: 0, unhealthyForMs: 0 })
now += 1_000
await sup.tick()
const st = sup.stats()
assert.equal(sup.channel.url, 'D', '有干净候选 ⇒ 必须走**正常**候选链(D3)')
assert.equal(st.switches, 2)
assert.equal(st.exemptSwitches, 0, '⛔ 豁免一次都不许发生')
assert.equal(st.noCandidateChecks, 0, '⛔ 也不该记"无候选"')
assert.ok(
col.lines.every((l) => !l.includes('|豁免')),
'⛔ 日志里不许出现豁免标记(逐字回到序⑦)',
)
})
/**
* F16 · **豁免有界**(E5 / D4):每 url **每冷却周期一次**;豁免再失败 ⇒ 重置冷却且本周期不再豁免。
*
* ⛔ 不设界 = "每 2 s 豁免一次、每次都失败" = **重试风暴**,比不切更糟(R11)。
* ⚠️ 全程**不推进注入时钟** ⇒ 证明"同一冷却周期内"。两轮豁免各失败一次会让 `openFailed` 递增,
* 但**同一个 url 只会被试一次**。
*/
test('F16 豁免有界:同周期内每个冷却候选只豁免一次、不再重复试(E5 / D4)', async () => {
const col = collector()
let now = 6_000_000
const tried = []
const sup = new RelayFailoverSupervisor({
open: async (url) => {
tried.push(url)
return url === 'C' ? fakeChannel('C') : undefined // 只有 C 能起
},
candidates: async () => ['A', 'B', 'C'],
log: col.log,
nowMs: () => now,
thresholds: TH8,
})
const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
sup.seed(A)
await sup.tick() // 试 B ⇒ 失败 ⇒ B 进冷却(open-failed)
await sup.tick() // 推进到 C ⇒ 成功;A 进冷却(switched-away)
assert.equal(sup.channel.url, 'C')
assert.equal(sup.stats().switches, 1)
assert.equal(sup.stats().openFailed, 1)
// C 也挂 ⇒ D6 现场(A、B 均冷却)。此后**不推进时钟** ⇒ 全程同一冷却周期。
sup.channel.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
await sup.tick() // 豁免 A(switched-away 优先)⇒ 失败 ⇒ A 本周期额度用尽
assert.equal(sup.stats().openFailed, 2, '第一次豁免失败必须计数')
await sup.tick() // A 已被排除 ⇒ 豁免 B ⇒ 也失败
assert.equal(sup.stats().openFailed, 3, '第二个冷却候选仍可豁免一次')
await sup.tick() // 池子空了 ⇒ 回到原地退避,⛔ 不许再试 A/B
await sup.tick()
const st = sup.stats()
assert.equal(st.openFailed, 3, '⛔ 重试风暴:同一 url 每周期只许试一次')
assert.equal(st.switches, 1, '豁免全失败 ⇒ 不许增加 switches')
assert.equal(st.exemptSwitches, 0)
assert.ok(st.noCandidateChecks >= 2, '池子空了必须记"无候选"(D6 现场证据)')
assert.deepEqual(tried, ['B', 'C', 'A', 'B'], '尝试序列:B(正常)→ C(正常)→ A(豁免)→ B(豁免)')
assert.ok(
col.lines.some((l) => l.includes('本周期不再豁免')),
'必须留下"本周期不再豁免"的判别器行',
)
assert.equal(col.switchLines(), st.switches, 'D7:行数仍等于 switches')
})
/**
* F17 · **两层开关各司其职**(E6 / D6):`RELAY_FAILOVER_EXEMPT=0` ⇒ 逐字回到序⑦ 行为。
*/
test('F17 总开关 RELAY_FAILOVER_EXEMPT=0 ⇒ 无豁免(第二层回滚点)', async () => {
const col = collector()
let now = 7_000_000
const sup = new RelayFailoverSupervisor({
open: async (url) => fakeChannel(url),
candidates: async () => ['A', 'B'],
log: col.log,
nowMs: () => now,
thresholds: { ...TH8, exempt: false },
})
const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
sup.seed(A)
await sup.tick() // A 挂 ⇒ 切 B;A 进冷却
assert.equal(sup.channel.url, 'B')
sup.channel.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 })
A.setHealth({ state: 'up', attempts: 0, unhealthyForMs: 0 })
now += 5_000
await sup.tick()
const st = sup.stats()
assert.equal(st.switches, 1, '⛔ 开关关闭 ⇒ 回到"原地退避"(序⑦ 行为,逐字)')
assert.equal(st.exemptSwitches, 0)
assert.equal(sup.channel.url, 'B')
assert.ok(st.noCandidateChecks >= 1, '必须回到"链里无其他候选"的原地退避路径')
assert.ok(col.lines.every((l) => !l.includes('|豁免')), '⛔ 日志里不许出现豁免标记')
})
/* ═══════════════════════════════════════════════════════════════════════════
* 序⑨ · **换址等待的"终态失败"**(RC-1)—— F18–F21
*
* 背景(实测,序⑨ §2-P10 逐行复核):`waitUpOn` 只轮询 `state === 'up'` ⇒ **死候选与慢候选
* 不可区分**,代价恒为 `upTimeoutMs`(12 s)。生产目录前两条候选**同在 47** ⇒ 每次从 47 切走
* 都先试同机的 `relay-direct`(已随 47 一起死)⇒ **固定白等 12 s**,占 30 s 墙钟的 40%。
*
* 纪律:① 修(F19)必须有**护栏**(F18:慢候选不许被误杀)② burst 窗口(计划内重启)
* **不许**被当终态失败(F20,否则每次 relay 重启都切流 = D3 要防的抖动)。
* ═══════════════════════════════════════════════════════════════════════════ */
/**
* 假客户端:按脚本在给定毫秒数后切换 `status()`(⛔ 不碰网络 —— 本组只验"等待逻辑"这一层)。
* `plan` = `[{ at: 毫秒(相对本次调用开始), st: 状态片段 }]`,后者覆盖前者。
*/
function fakeWaitee(plan, base = {}) {
const t0 = Date.now()
const seen = []
return {
seen,
status() {
const el = Date.now() - t0
let st = { state: 'connecting', attempts: 0, inGracefulBurstWindow: false, ...base }
for (const p of plan) if (el >= p.at) st = { ...st, ...p.st }
seen.push(st.state)
return st
},
}
}
/**
* F21 · 判据的**切面**(先于行为用例:把"什么算终态失败"钉死在四个反例上)。
*/
test('F21 终态失败判据的四个反例:connecting / handshaking / queued(attempts=0) / burst 窗口内 ⇒ 都不算', () => {
const dead = { state: 'backoff', attempts: 1, inGracefulBurstWindow: false }
assert.equal(openedChannelFailedTerminally(dead), true, 'backoff + 已记账 + 非 burst ⇒ 终态失败')
const cases = [
['正在连(connecting)', { ...dead, state: 'connecting' }],
['正在握手(handshaking)', { ...dead, state: 'handshaking' }],
['满载排队(queued ⇒ attempts 被归零)', { ...dead, attempts: 0 }],
['计划内重启的 burst 窗口内', { ...dead, inGracefulBurstWindow: true }],
]
for (const [name, st] of cases) {
assert.equal(openedChannelFailedTerminally(st), false, `${name} ⛔ 不许判成终态失败`)
}
})
/**
* F19 · **死候选 ⇒ 提前失败**(本单的核心修法;⛔ 这是判据的"红→绿"分水岭)。
* 旧实现下本断言必然失败:白等满 12 000 ms。
*/
test('F19 死候选:`backoff` + 已记账 + 非 burst ⇒ 提前失败(⛔ 不白等满 upTimeoutMs)', async () => {
const dead = fakeWaitee([
{ at: 100, st: { state: 'backoff', attempts: 1, inGracefulBurstWindow: false, lastError: 'transport error' } },
])
const t0 = Date.now()
const ok = await waitUpOnStatus(dead, 12_000)
const ms = Date.now() - t0
assert.equal(ok, false, '死候选必须返回 false')
assert.ok(ms < 2_000, `必须远早于 upTimeoutMs(12000) 返回;旧实现会白等满,实测 ${ms}ms`)
})
/**
* F18 · **护栏:慢候选不许被误杀**(R11 不变量)。
*
* 慢候选在到达 `up` 之前**一直处于 `connecting`**,从不进 `backoff` ⇒ 判据恒不成立 ⇒
* 照旧享受完整 `upTimeoutMs`。⛔ 这条是"早退"的安全带:没有它,早退会退化成
* "更频繁地切到第三候选"甚至"全部候选都判失败"。
*/
test('F18 护栏:慢候选(连得上、`up` 来得晚)⇒ 仍等满预算且必须成功', async () => {
const slow = fakeWaitee([{ at: 800, st: { state: 'up', attempts: 0 } }])
const t0 = Date.now()
const ok = await waitUpOnStatus(slow, 12_000)
const ms = Date.now() - t0
assert.equal(ok, true, '慢候选必须成功 —— ⛔ 早退不许误杀')
assert.ok(ms >= 600 && ms < 5_000, `应等到 ~800ms 它自己 up 为止,实测 ${ms}ms`)
})
/**
* F22 · **`gracefulBurstMs` 参数表化**(序⑨ §3.1-4):默认值语义**逐字不变**,只是可配了。
* ⛔ 只加可配性、⛔ 不改默认值(E10 的同族纪律:判据/阈值不许被悄悄放宽)。
*/
test('F22 RELAY_GRACEFUL_BURST_MS:默认 15000 逐字不变,显式覆写才生效', () => {
assert.equal(gracefulBurstMsDefault({}), 15_000, '⛔ 默认值必须逐字不变')
assert.equal(gracefulBurstMsDefault({ RELAY_GRACEFUL_BURST_MS: '' }), 15_000, '空串 ⇒ 回落默认')
assert.equal(gracefulBurstMsDefault({ RELAY_GRACEFUL_BURST_MS: 'abc' }), 15_000, '非法值 ⇒ 回落默认(⛔ 不抛)')
assert.equal(gracefulBurstMsDefault({ RELAY_GRACEFUL_BURST_MS: '-1' }), 15_000, '负数 ⇒ 回落默认')
assert.equal(gracefulBurstMsDefault({ RELAY_GRACEFUL_BURST_MS: '0' }), 0, '0 = 显式关掉 burst 窗口(合法值)')
assert.equal(gracefulBurstMsDefault({ RELAY_GRACEFUL_BURST_MS: '30000' }), 30_000, '显式覆写生效')
})
/**
* F20 · **计划内重启(burst 窗口)⛔ 不算终态失败**(D3 保护)。
*
* 窗口内 `state=backoff attempts=1` 与"死候选"**字面完全一样**,唯一区分依据就是
* `inGracefulBurstWindow`。⛔ 若把它当死候选 ⇒ **每次 relay 重启 / 部署都切一次流**。
*/
test('F20 burst 窗口(计划内重启)内必须继续等,窗口过后才允许判死(D3 保护)', async () => {
const restarting = fakeWaitee([
{ at: 100, st: { state: 'backoff', attempts: 1, inGracefulBurstWindow: true } },
{ at: 1_500, st: { state: 'up', attempts: 0 } },
])
const t0 = Date.now()
const ok = await waitUpOnStatus(restarting, 12_000)
const ms = Date.now() - t0
assert.equal(ok, true, 'burst 窗口内必须继续等 ⇒ 对端重启完就 up')
assert.ok(ms >= 1_300, `⛔ 不许在窗口内就判死(旧坑:100ms 处就返回 false);实测 ${ms}ms`)
})
+1318
View File
File diff suppressed because it is too large. Load diff
+94
View File
@@ -0,0 +1,94 @@
/**
* Manager 侧 `RemoteSpawner.endpointFor` 的**端点翻译接线**单测(覆盖网络 R4)。
*
* ## 为什么单独立一个文件(2026-09-16 实测代价)
* `translateEndpoint` 是 R4 的**唯一**翻译点:`via='relay'` 的 host,其实例在 Worker 上监听
* `127.0.0.1:<实例端口>`,而 Manager 必须拨 relay 为那个端口开的**动态回环口号**。
*
* 首版实现里 `RemoteSpawner` 的构造函数**漏了 `this.translateEndpoint = options.translateEndpoint`**
* —— 于是整个翻译**静默失效**:`endpointFor` 原样返回 Worker 侧口号 ⇒ Manager 往**自己本机**
* 拨 `127.0.0.1:21000` ⇒ 连接被拒两次 ⇒ 代理 `reply.raw.destroy()`。
* 现场表现只有两条:浏览器/curl 看到 **`Empty reply from server`**;平台日志**一行错误都没有**。
* (定位靠"在 47 上临时监听 21000,请求被这个探针接走"——即**用判别器测,而不是读代码猜**。)
*
* ⇒ 本文件把"翻译必须真的生效"钉成断言:**构造时就验**,不依赖集群环境、不依赖 relay 在跑。
*
* 运行:`node --test test/remote-spawner.test.mjs`(已登记进 `npm test` / `npm run verify`)。
*
* @module test/remote-spawner
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { RemoteSpawner } from '../lib/supervisor/remote-spawner.js'
/* ─────────── 小工具 ─────────── */
/** Worker 侧 agent 的回包(`GET /endpoint/:userId` 的形状)。 */
function fakeFetch(payload) {
const calls = []
const impl = async (url, init) => {
calls.push(`${init?.method ?? 'GET'} ${url}`)
return { ok: true, status: 200, json: async () => payload, text: async () => JSON.stringify(payload) }
}
impl.calls = calls
return impl
}
/** `via='relay'` 的一台 worker:agent 口号 19000,relay 已把它映射到本机 38253。 */
const W106 = {
hostId: 'w-106',
agentUrl: 'http://127.0.0.1:19000',
reachability: { hostId: 'w-106', via: 'relay', address: '127.0.0.1:38253', scheme: 'http' },
token: 'tok-106',
}
function make(opts = {}) {
return new RemoteSpawner({
agentUrl: 'http://127.0.0.1:19100',
token: 'tok-local',
defaultHostId: 'w-47',
hostIdFor: async () => 'w-106',
hostsProvider: async () => [W106],
fetchImpl: fakeFetch({ running: true, host: '127.0.0.1', port: 21000 }),
...opts,
})
}
/* ─────────── T1–T4 ─────────── */
test('T1 翻译器被真的接上:传入的 hostId/endpoint 与返回值都要生效', async () => {
const seen = []
const s = make({
translateEndpoint: (hostId, ep) => {
seen.push([hostId, ep])
return { host: '127.0.0.1', port: 34241 }
},
})
assert.deepEqual(await s.endpointFor('u1'), { host: '127.0.0.1', port: 34241 })
// ★ 这一条就是首版漏赋值时唯一会红的断言:漏了 ⇒ seen 为空、返回 {21000}
assert.deepEqual(seen, [['w-106', { host: '127.0.0.1', port: 21000 }]])
})
test('T2 未给翻译器 ⇒ 原样透传(local / manager-ssh 的同号语义,行为零变化)', async () => {
const s = make()
assert.deepEqual(await s.endpointFor('u1'), { host: '127.0.0.1', port: 21000 })
})
test('T3 翻译器回 undefined ⇒ endpointFor 也回 undefined(失败关闭,不回退 Worker 口号)', async () => {
const s = make({ translateEndpoint: () => undefined })
assert.equal(await s.endpointFor('u1'), undefined)
})
test('T4 实例未运行 ⇒ undefined,且**不该**调翻译器(没有端口可翻)', async () => {
let called = false
const s = make({
fetchImpl: fakeFetch({ running: false }),
translateEndpoint: () => {
called = true
return undefined
},
})
assert.equal(await s.endpointFor('u1'), undefined)
assert.equal(called, false)
})
+188
View File
@@ -0,0 +1,188 @@
/**
* `RemoteUserFs` 的**按用户路由**单测(覆盖网络线缺陷 A1)。
*
* ## 被钉死的缺陷(2026-09-16 现场实测)
* `RemoteUserFs.target()` 旧实现对三种失败**一律静默回退默认机**:查库抛错 / 没给 `agentFor` /
* `agentFor` 查不到。而默认机 = **Manager 自己那台**,它对*别的机*的用户只有两种回答:
* ① 那台 agent 上没有这个用户 ⇒ `{error:"not_found"}` —— 与「**文件夹不存在**」**完全同形**,
* 用户读成"我的文件丢了",真因却是"请求根本没出这台机";
* ② 本地恰好有同名目录 ⇒ 文件被写进一份**没人在看的副本**(更糟的静默写坏)。
*
* 触发窗口是**真实存在的、且每次重启必现**:`server.ts` 的 `hostDirectory` 是惰性 Map,
* 唯一写入者 `hostsProvider()` 此前只被 `RemoteSpawner.ensureHosts()` 调用 ⇒ Manager 重启后
* 若用户先碰文件面,表里只有本机。实测:连发 3 次 launch **全 404,relay 零 `DIAL`、
* 拨号池零落点**(判别器 = relay 有没有 `DIAL`)。
*
* ## 修法与断言
* ① **治本**:新增可选 `ensureHost(hostId)` —— 未命中时先**按需补齐**目录再判(U1),
* 所以正常冷启动请求不会被下面的失败关闭波及;
* ② **治安全**:补齐后仍取不到 ⇒ `UserFsError('host_unresolved')` → **503**,且
* **一个字节都不许发往默认机**(U2/U3/U4/U8 的 `fetch` 断言)。
* ③ **不退化**:"确实还没有归属"(`hostIdFor` 正常返回 `undefined`)与单机形态仍是默认机(U5/U6)。
*
* 运行:`node --test test/remote-user-fs.test.mjs`(已登记进 `npm test` / `npm run verify`)。
*
* @module test/remote-user-fs
*/
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { RemoteUserFs } from '../lib/fs/remote-user-fs.js'
/* ─────────── 小工具 ─────────── */
/** Manager 自己那台(= 默认 / 回退 agent)—— 任何"打到这里"都是路由失败。 */
const DEFAULT_AGENT = 'http://127.0.0.1:19100'
/** 归属机:w-106 的 agent。 */
const W106_AGENT = 'http://127.0.0.1:19000'
/** 记账用 fetch:记下每一发请求,永不真的出网。 */
function spyFetch() {
const calls = []
const impl = async (url) => {
calls.push(String(url))
return { ok: true, status: 200, text: async () => JSON.stringify([]) }
}
impl.calls = calls
return impl
}
function mk(opts) {
const fetchImpl = spyFetch()
const fs = new RemoteUserFs({
agentUrl: DEFAULT_AGENT,
token: 'tok-default',
workerDataRoot: '/var/lib/dsh/data',
hostIdFor: opts.hostIdFor,
agentFor: opts.agentFor,
ensureHost: opts.ensureHost,
fetchImpl,
})
return { fs, fetchImpl }
}
/** 断言"抛出的一定是 host_unresolved/503"。 */
function isHostUnresolved(err) {
assert.equal(err.code, 'host_unresolved')
assert.equal(err.status, 503)
return true
}
/* ─────────── ① 治本:未命中先补齐 ─────────── */
test('U1 目录未命中:ensureHost 补齐后打到**归属机器**(不再回退默认机)', async () => {
const dir = new Map() // 模拟 hostsProvider() 尚未填过的空目录
let ensured = 0
const { fs, fetchImpl } = mk({
hostIdFor: async () => 'w-106',
agentFor: (h) => dir.get(h),
ensureHost: async (h) => {
ensured += 1
dir.set(h, { agentUrl: W106_AGENT, token: 'tok-106' })
},
})
await fs.listDir('u1', '')
assert.equal(ensured, 1, '未命中必须触发一次补齐')
assert.deepEqual(fetchImpl.calls, [`${W106_AGENT}/fs/list`], '必须打到 w-106,不许打默认机')
})
test('U7 命中时**不**做补齐(正常路径零开销:不查库)', async () => {
let ensured = 0
const { fs, fetchImpl } = mk({
hostIdFor: async () => 'w-106',
agentFor: () => ({ agentUrl: W106_AGENT, token: 'tok-106' }),
ensureHost: async () => {
ensured += 1
},
})
await fs.listDir('u1', '')
assert.equal(ensured, 0, '命中后再查库 = 纯浪费')
assert.deepEqual(fetchImpl.calls, [`${W106_AGENT}/fs/list`])
})
/* ─────────── ② 治安全:取不到 ⇒ 失败关闭 ─────────── */
test('U2 补齐后仍取不到地址:503 host_unresolved,且**零请求发往默认机**(写操作也拦住)', async () => {
let ensured = 0
const { fs, fetchImpl } = mk({
hostIdFor: async () => 'w-106',
agentFor: () => undefined, // 目录里始终没有 w-106
ensureHost: async () => {
ensured += 1
},
})
// 用 mkdir 而不是读:这正是"把目录建到错机上"的那类操作
await assert.rejects(() => fs.mkdir('u1', 'MCN短视频创作'), isHostUnresolved)
assert.equal(ensured, 1, '失败前仍应尝试过补齐')
assert.deepEqual(fetchImpl.calls, [], '⛔ 一个字节都不许发往默认机')
})
test('U3 未提供 ensureHost(老调用点):未命中同样失败关闭,**不退化**为静默回退', async () => {
const { fs, fetchImpl } = mk({
hostIdFor: async () => 'w-106',
agentFor: () => undefined,
// ensureHost 故意不传
})
await assert.rejects(() => fs.listDir('u1', ''), isHostUnresolved)
assert.deepEqual(fetchImpl.calls, [])
})
test('U8 ensureHost 自己抛错(如查库失败):仍失败关闭,不吞成"默认机"', async () => {
const { fs, fetchImpl } = mk({
hostIdFor: async () => 'w-106',
agentFor: () => undefined,
ensureHost: async () => {
throw new Error('pg is down')
},
})
await assert.rejects(() => fs.listDir('u1', ''), isHostUnresolved)
assert.deepEqual(fetchImpl.calls, [])
})
test('U4 hostIdFor 抛错(查库失败):失败关闭,不静默回退默认机', async () => {
const { fs, fetchImpl } = mk({
hostIdFor: async () => {
throw new Error('pg is down')
},
agentFor: () => ({ agentUrl: W106_AGENT, token: 'tok-106' }),
})
await assert.rejects(() => fs.readFile('u1', 'a.txt'), isHostUnresolved)
assert.deepEqual(fetchImpl.calls, [], '归属都查不出来时,任何一台都不该被打')
})
/* ─────────── ③ 不退化:设计内的默认机路径 ─────────── */
test('U5 hostIdFor 返回 undefined(确实还没有归属):仍用默认 agent', async () => {
const { fs, fetchImpl } = mk({
hostIdFor: async () => undefined,
agentFor: () => ({ agentUrl: W106_AGENT, token: 'tok-106' }),
})
await fs.initUserRoot('u1', 1001)
assert.deepEqual(fetchImpl.calls, [`${DEFAULT_AGENT}/fs/init`], '首触达/无归属走默认机是设计内契约')
})
test('U6 未提供 hostIdFor(单机形态):默认 agent,且不触发任何路由表', async () => {
let probed = 0
const { fs, fetchImpl } = mk({
agentFor: () => {
probed += 1
return undefined
},
})
await fs.listDir('u1', '')
assert.equal(probed, 0)
assert.deepEqual(fetchImpl.calls, [`${DEFAULT_AGENT}/fs/list`])
})