feat(overlay): 覆盖网络线序①–⑮ 代码与测试产物入库
覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 relay 子系统 src/net/relay/**(wire/duplex/server/client/dialer/switcher/directory/identity/keys/placement/network/addr-override/main/index)
- 新增 src/worker/relay-tunnel.ts、src/web/routes/overlay.ts
- 新增观测/演练脚本 overlay-probe、overlay-failover-drill、overlay-keyring、overlay-holepunch、overlay-jitter、overlay-wan、overlay-relaykey-add、relay-mem-calibrate
- 新增测试 12 个(relay / relay-failover / remote-spawner / instance-port / overlay-{network,auth,bootstrap,identity} / remote-user-fs 等)
验收基线:npm test = 162 pass / 0 fail / 1 skip;--scene all = 12 PASS / 0 SKIP / 0 FAIL;overlay-probe = 12/12
This commit is contained in:
1 parent
640813e84e
commit
146c3d25ef
53 files changed
+16187
-63
No files matched your search
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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)')
|
||||
})
|
||||
@@ -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, '首位命中时不该有多余日志')
|
||||
})
|
||||
@@ -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), /非法/)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
})
|
||||
@@ -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`])
|
||||
})
|
||||
Reference in new issue
Block a user