build / build-and-scan (push) Waiting to run
文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。
IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。
插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。
仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
463 lines
24 KiB
JavaScript
463 lines
24 KiB
JavaScript
/**
|
||
* 覆盖网络 **直连(打洞)** 单测(序㊵ · P2 · S5)。
|
||
*
|
||
* ⚠️ **刻意不进 `npm test`** —— 那是**硬编码文件列表**,加进去会改测试总数(既有口径:
|
||
* `test/overlay-join.test.mjs` 同样"刻意不进")。本文件单独跑:
|
||
* ```bash
|
||
* node --test test/overlay-direct.test.mjs
|
||
* ```
|
||
*
|
||
* | 用例 | 判据 | 断言的东西 |
|
||
* |---|---|---|
|
||
* | T1 | D3 |开关解析 | 缺省=开;关集/开集;**非法值 ⇒ `null`(⛔ 不静默取缺省)**;本机配置次之 |
|
||
* | T2 | D2 |关闭生效 | 关闭 ⇒ **零 UDP socket** + **零候选**(⛔ 不是"少发几条") |
|
||
* | T3 | D1 |候选准入矩阵 | 同网+白名单内接受;跨网 / 替第三人申报 / 白名单外 / 本机不在白名单 / 坏形状 / 夹带凭据 / 坏地址 / 超限 / 过期 **各自具名拒绝**;`silentRejections = 0` |
|
||
* | T4 | D1 |**准入策略与 server.ts 等价** | 读 `server.ts` 源码核对两处闸门与 `isAllowedDialer` **同策略**(⛔ 防"两处写分叉") |
|
||
* | T5 | D5 |打洞成功路径 | NAT 模拟器 + 真 `dgram` ⇒ 双向成立(`punchOk ≥ 1`) |
|
||
* | T6 | D5 |**单向不算直连** | 一个方向成立 ⇒ 判死 `one-way`(⛔ 不许把单向当成功) |
|
||
* | T7 | D6 |失败判死有界 | 对端不响应 ⇒ 窗内判死,且耗时**有界**(⛔ 不无限重试) |
|
||
* | T8 | D6 |冷却 | 判死后二次尝试被挡(**且不开 socket**);`ms = 0` ⇒ **构造即抛** |
|
||
* | T9 | D3/D4 |join 回读 + 提示 | 本机配置读回 `direct = true`;`--direct 0` 被尊重 |
|
||
* | T10 | D4 |提示可行动 | 三段齐(谁可能连 / 怎么关 / 关掉影响什么),且**含开关键与关值** |
|
||
*/
|
||
|
||
import assert from 'node:assert/strict'
|
||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import { join } from 'node:path'
|
||
import { fileURLToPath } from 'node:url'
|
||
import { randomBytes } from 'node:crypto'
|
||
import { test } from 'node:test'
|
||
import { createSocket } from 'node:dgram'
|
||
|
||
import { signPayloadWith } from '../lib/net/relay/identity.js'
|
||
import {
|
||
CandidateLedger,
|
||
DEFAULT_DIRECT_COOLDOWN_MS,
|
||
DEFAULT_DIRECT_ENABLED,
|
||
DIRECT_CAND_MAX_ADDRS,
|
||
DIRECT_ENV_KEY,
|
||
DIRECT_OFF_VALUES,
|
||
DirectCooldown,
|
||
DirectPath,
|
||
describeDialers,
|
||
NODES_REGISTRY_VERSION,
|
||
PUNCH_PORT_BASE,
|
||
PUNCH_PORT_SPAN,
|
||
directFromNodeConfig,
|
||
directHintLines,
|
||
directHintText,
|
||
encodeDirectMessage,
|
||
generateAuthorityKey,
|
||
generateNodeKey,
|
||
isAllowedDialer,
|
||
isValidAddress,
|
||
networkInvitePayload,
|
||
newInviteNonce,
|
||
nodeJoinIo,
|
||
normalizeDialers,
|
||
publicKeyOfPrivate,
|
||
readNodeConfig,
|
||
resolveDirectSwitch,
|
||
runJoin,
|
||
runPunchAttempt,
|
||
runPunchPair,
|
||
writeNodeConfigDirect,
|
||
} from '../lib/net/relay/index.js'
|
||
|
||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
||
const root = fileURLToPath(new URL('..', import.meta.url))
|
||
const ssPath = join(root, 'src', 'net', 'relay', 'network.ts')
|
||
const serverPath = join(root, 'src', 'net', 'relay', 'server.ts')
|
||
|
||
/** 真·白名单(**走既有归一化实现** ⇒ 量的是"同一份策略"的行为)。 */
|
||
const dialers = () =>
|
||
normalizeDialers(
|
||
new Map([
|
||
['ops', new Set(['manager', 'w-2'])],
|
||
['u:5', new Set(['w-2'])],
|
||
]),
|
||
)
|
||
|
||
/** 找一个没人监听的本地 UDP 端口(用于产出 `deadline` 负腿)。 */
|
||
async function deadPort() {
|
||
const s = createSocket('udp4')
|
||
await new Promise((r) => s.bind({ port: 0, address: '127.0.0.1' }, r))
|
||
const p = s.address().port
|
||
await new Promise((r) => s.close(r))
|
||
return p
|
||
}
|
||
|
||
const candidate = (over = {}) =>
|
||
encodeDirectMessage({
|
||
hostId: 'w-2',
|
||
network: 'ops',
|
||
addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }],
|
||
ts: Date.now(),
|
||
...over,
|
||
})
|
||
|
||
// ── T1 · 开关解析 ────────────────────────────────────────────────────────────────
|
||
test('T1 开关:缺省=开|开集/关集|非法值 ⇒ null(⛔ 不静默取缺省)|本机配置次之', () => {
|
||
const d = resolveDirectSwitch({})
|
||
assert.equal(d.enabled, true, '缺省必须 = 开(用户口径②)')
|
||
assert.equal(d.source, 'default')
|
||
assert.equal(DEFAULT_DIRECT_ENABLED, true)
|
||
|
||
for (const v of ['1', 'true', 'ON', 'Yes']) {
|
||
const s = resolveDirectSwitch({ [DIRECT_ENV_KEY]: v })
|
||
assert.equal(s.enabled, true, `${v} 应判开`)
|
||
assert.equal(s.source, 'env')
|
||
}
|
||
for (const v of DIRECT_OFF_VALUES) {
|
||
const s = resolveDirectSwitch({ [DIRECT_ENV_KEY]: v.toUpperCase() })
|
||
assert.equal(s.enabled, false, `${v} 应判关`)
|
||
}
|
||
// 🔴 非法值:**既不许当开也不许当关**
|
||
const bad = resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'maybe' })
|
||
assert.equal(bad.enabled, null)
|
||
assert.match(bad.invalid, /不静默取缺省/)
|
||
// 本机配置(用户设置)在 env 缺省时生效;env 显式设了就压过它
|
||
assert.equal(resolveDirectSwitch({}, { localDirect: false }).enabled, false)
|
||
assert.equal(resolveDirectSwitch({}, { localDirect: false }).source, 'node-config')
|
||
assert.equal(resolveDirectSwitch({ [DIRECT_ENV_KEY]: '1' }, { localDirect: false }).enabled, true)
|
||
// 空串 = 未设(⛔ 不算非法值)
|
||
assert.equal(resolveDirectSwitch({ [DIRECT_ENV_KEY]: ' ' }).enabled, true)
|
||
})
|
||
|
||
// ── T2 · 关闭 ⇒ 零 socket / 零候选 ──────────────────────────────────────────────
|
||
test('T2 关闭 ⇒ 零 UDP socket + 零候选;开启才真的开', async () => {
|
||
const ctxOf = { dialers: dialers(), from: { network: 'ops', hostId: 'w-2' }, selfHostId: 'manager' }
|
||
const off = new DirectPath({ switchState: resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'false' }), deadlineMs: 300 })
|
||
const v = off.offerCandidate(candidate(), ctxOf)
|
||
assert.equal(v.ok, false)
|
||
assert.equal(v.reason, 'disabled')
|
||
const a = await off.attempt('ops/void', [{ host: '127.0.0.1', port: await deadPort() }], { sleep })
|
||
assert.equal(a.reason, 'disabled')
|
||
const st = off.status()
|
||
assert.equal(st.counters.udpSocketsOpened, 0, '关闭 ⇒ ⛔ 一个 UDP socket 都不许开')
|
||
assert.equal(st.counters.candidatesEmitted, 0, '关闭 ⇒ ⛔ 一条候选都不许发')
|
||
|
||
// 非法开关值同样**两种动作都不做**(避免"取值写错 ⇒ 静默当成开")
|
||
const bad = new DirectPath({ switchState: resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'maybe' }), deadlineMs: 300 })
|
||
assert.equal(bad.offerCandidate(candidate(), ctxOf).reason, 'invalid-switch')
|
||
assert.equal((await bad.attempt('ops/void', [{ host: '127.0.0.1', port: await deadPort() }], { sleep })).reason, 'invalid-switch')
|
||
assert.equal(bad.status().counters.udpSocketsOpened, 0)
|
||
|
||
const on = new DirectPath({ switchState: resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'true' }), deadlineMs: 300 })
|
||
assert.equal(on.offerCandidate(candidate(), ctxOf).ok, true)
|
||
await on.attempt('ops/void', [{ host: '127.0.0.1', port: await deadPort() }], { sleep })
|
||
assert.equal(on.status().counters.udpSocketsOpened, 1, '开启 ⇒ 真的绑了 1 个 UDP socket')
|
||
assert.equal(on.status().counters.candidatesEmitted, 1)
|
||
})
|
||
|
||
// ── T3 · 候选准入矩阵 ───────────────────────────────────────────────────────────
|
||
test('T3 候选准入:同网+白名单内接受;其余**逐类具名**拒绝;静默拒绝 = 0', () => {
|
||
const led = new CandidateLedger()
|
||
const inOps = { dialers: dialers(), from: { network: 'ops', hostId: 'w-2' }, selfHostId: 'manager' }
|
||
const expectOk = (raw, ctx = inOps) => {
|
||
const v = led.judge(raw, ctx)
|
||
assert.equal(v.ok, true, `应接受:${v.ok ? '' : v.reason} ${v.ok ? '' : v.detail}`)
|
||
}
|
||
const expectReject = (raw, reason, ctx = inOps) => {
|
||
const v = led.judge(raw, ctx)
|
||
assert.equal(v.ok, false)
|
||
assert.equal(v.reason, reason)
|
||
assert.ok(typeof v.detail === 'string' && v.detail !== '', '拒绝必须带人读原因(⛔ 不许只回 false)')
|
||
}
|
||
|
||
expectOk(candidate())
|
||
expectOk(candidate({ addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }, { host: '2001:db8::1', port: PUNCH_PORT_BASE + 2 }] }))
|
||
// 同名跨网**互不可见**:同一 hostId 在另一张网里是合法身份
|
||
expectOk(candidate({ network: 'u:5' }), { dialers: dialers(), from: { network: 'u:5', hostId: 'w-2' }, selfHostId: 'w-2' })
|
||
|
||
expectReject(candidate({ network: 'u:5' }), 'cross-network')
|
||
expectReject(candidate({ hostId: 'w-999' }), 'not-self-candidate')
|
||
expectReject(candidate(), 'not-a-dialer', { ...inOps, dialers: normalizeDialers(new Map([['ops', new Set(['manager'])]])) })
|
||
expectReject(candidate(), 'self-not-a-dialer', { ...inOps, selfHostId: 'w-nobody' })
|
||
expectReject('{"kind":"DIRECT_CANDIDATE"}', 'bad-shape')
|
||
expectReject('not json at all', 'bad-shape')
|
||
expectReject(
|
||
JSON.stringify({ kind: 'DIRECT_CANDIDATE', hostId: 'w-2', network: 'ops', addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }], nodeKey: 'deadbeef' }),
|
||
'secret-field',
|
||
)
|
||
expectReject(candidate({ addrs: [{ host: 'example.com', port: 80 }] }), 'bad-address')
|
||
expectReject(candidate({ addrs: [{ host: '10.0.0.9', port: 0 }] }), 'bad-address')
|
||
expectReject(
|
||
candidate({ addrs: Array.from({ length: DIRECT_CAND_MAX_ADDRS + 1 }, (_, i) => ({ host: '10.0.0.9', port: PUNCH_PORT_BASE + i })) }),
|
||
'too-many-addrs',
|
||
)
|
||
expectReject(candidate({ ts: Date.now() - 10 * 60_000 }), 'stale')
|
||
|
||
const snap = led.snapshot()
|
||
assert.equal(snap.accepted, 3)
|
||
assert.equal(snap.rejected.length, 11, '负腿 11 条:跨网/替第三人/白名单外/本机不在白名单/坏形状×2/凭据字段/坏地址×2/超限/过期')
|
||
assert.equal(snap.silentRejections, 0, '静默拒绝必须为 0(本线老病根的不变量守卫)')
|
||
assert.equal(snap.received, 14)
|
||
// 每一条拒绝**都有具名原因**
|
||
for (const r of snap.rejected) assert.ok(typeof r.reason === 'string' && r.reason !== '')
|
||
// 白名单是**按网络分桶**的:拿 ops 的桶查 u:5 的同名 hostId 必须为假
|
||
assert.equal(isAllowedDialer(dialers(), 'ops', 'w-2'), true)
|
||
assert.equal(isAllowedDialer(dialers(), 'u:5', 'w-2'), true)
|
||
assert.equal(isAllowedDialer(dialers(), 'u:7', 'w-2'), false)
|
||
assert.equal(isAllowedDialer(dialers(), 'ops', 'w-999'), false)
|
||
// 地址形状:IPv4/IPv6 收,主机名不收
|
||
assert.equal(isValidAddress({ host: '10.0.0.9', port: 21100 }), true)
|
||
assert.equal(isValidAddress({ host: '1.2.3', port: 21100 }), false)
|
||
assert.equal(isValidAddress({ host: '256.1.1.1', port: 21100 }), false)
|
||
assert.equal(isValidAddress({ host: 'a.b.c', port: 21100 }), false)
|
||
})
|
||
|
||
// ── T4 · 准入策略与 server.ts 的等价性(机器守卫) ──────────────────────────────
|
||
test('T4 准入策略**复用** server.ts 的那一条(⛔ 防两处写分叉)', () => {
|
||
const netSrc = readFileSync(ssPath, 'utf8')
|
||
const srvSrc = readFileSync(serverPath, 'utf8')
|
||
|
||
// ① 唯一出口确实在 network.ts 里,且语义就是"该网桶里有没有这个 hostId / 是否整网放行"
|
||
assert.match(netSrc, /export function isAllowedDialer\(/)
|
||
assert.match(netSrc, /bucket\.has\(DIALER_WILDCARD\)/, '网级通配(序㊻ S2)应在唯一出口里判')
|
||
assert.match(netSrc, /if \(bucket\.has\(hostId\)\) return true/, '逐设备命中仍在唯一出口里判')
|
||
assert.match(
|
||
netSrc,
|
||
/TENANT_NETWORK_WILDCARD\)\?\.has\(DIALER_WILDCARD\)/,
|
||
'租户网全域兜底(序㊻ 步骤6/遗留1)必须在**唯一出口**里判,⛔ 不许在调用方另判一处',
|
||
)
|
||
|
||
// ② server.ts 的两处闸门**调用**它(序㊻ S2 起:等价性 = 同一份代码,⛔ 不再是"两份逐字相同")
|
||
assert.ok(
|
||
srvSrc.includes('const wantDialer = isAllowedDialer(this.dialers, network, hostId)'),
|
||
'注册闸门(server.ts)应调用 isAllowedDialer(this.dialers, network, hostId)',
|
||
)
|
||
assert.ok(
|
||
srvSrc.includes('if (!isAllowedDialer(this.dialers, session.network, session.hostId)) {'),
|
||
'DIAL 闸门(server.ts)应调用 isAllowedDialer(this.dialers, session.network, session.hostId)',
|
||
)
|
||
|
||
// ②b ⛔ 反证:server.ts 里**不许**再出现内联形态 —— 否则网级通配会"漏改一处"(本线老病根)
|
||
assert.ok(
|
||
!/this\.dialers\.get\([^)]*\)\?\.has\(/.test(srvSrc),
|
||
'server.ts 里又出现了内联白名单判定 dialers.get(...)?.has(...) ⇒ 序㊻ S2 之后它必须走唯一出口',
|
||
)
|
||
|
||
// ③ 直连模块**不许**自己再判一遍(⛔ 防"同一事实两处写")
|
||
for (const f of ['direct/candidate.ts', 'direct/index.ts', 'direct/punch.ts']) {
|
||
const src = readFileSync(join(root, 'src', 'net', 'relay', f), 'utf8')
|
||
assert.ok(!/dialers\.get\(/.test(src), `${f} 里出现了 dialers.get( ⇒ 疑似把白名单判定抄了一份`)
|
||
assert.ok(!/\.has\(hostId\)/.test(src), `${f} 里出现了 .has(hostId) ⇒ 疑似把白名单判定抄了一份`)
|
||
}
|
||
|
||
// ④ 等价性:默认拒绝(空桶 / 未列网络 / 名字错)—— 与 server.ts 同为"默认拒绝"
|
||
assert.equal(isAllowedDialer(new Map(), 'ops', 'manager'), false)
|
||
assert.equal(isAllowedDialer(dialers(), 'ops', 'MANAGER'), false, 'hostId 大小写敏感(与 HOST_RE 口径一致)')
|
||
|
||
// ⑤ 网级通配(序㊻ S2):只对**写了通配的那张网**放行,⛔ 不外溢到别的网
|
||
const wild = normalizeDialers(new Set(['ops:manager', 'u:5/*']))
|
||
assert.equal(isAllowedDialer(wild, 'u:5', 'd-any-brand-new-device'), true, '通配网里:未枚举的 hostId 也可拨')
|
||
assert.equal(isAllowedDialer(wild, 'ops', 'w-2'), false, '⛔ 通配不外溢:ops 桶里没有 w-2 就是拒')
|
||
assert.equal(isAllowedDialer(wild, 'u:7', 'd-any'), false, '⛔ 通配不外溢:没列到的网一律拒')
|
||
assert.deepEqual(describeDialers(wild).sort(), ['manager', 'u:5/*'], '通配在 /status 里要看得见')
|
||
|
||
/**
|
||
* ⑥ **租户网全域兜底**(序㊻ 步骤 6/遗留1):配一条「网名位 `u:*` + hostId 位 `*`」(等价于
|
||
* 分桶形态的 `u:*` 桶)⇒ **没被单独列举过**的租户网也放行。
|
||
*
|
||
* 这条要解决的是上一棒实测到的"零介入"硬缺口:**租户 id 是运行时才生成的**(`users.id`),
|
||
* 所以"把新租户网加进白名单"在原理上做不到 ⇒ 只能表达为"任何租户网"。
|
||
*/
|
||
const tenantAll = normalizeDialers(new Set(['ops:manager', 'u:*/*']))
|
||
assert.equal(
|
||
isAllowedDialer(tenantAll, 'u:brand-new-tenant-id', 'd-brand-new-device'),
|
||
true,
|
||
'未列举过的租户网也应命中(这条就是"新用户零介入"的准入面依据)',
|
||
)
|
||
assert.equal(isAllowedDialer(tenantAll, 'u', 'd-1'), false, '⛔ 裸 `u` 不是合法租户网 ⇒ 兜底不生效(保留字仍被挡住)')
|
||
assert.equal(isAllowedDialer(tenantAll, 'ops', 'w-2'), false, '⛔ 兜底**不外溢到 ops**(结构性:只对租户网生效)')
|
||
assert.equal(isAllowedDialer(tenantAll, 'named-net', 'w-2'), false, '⛔ 兜底不外溢到命名网')
|
||
assert.equal(isAllowedDialer(normalizeDialers(new Set(['ops:manager'])), 'u:5', 'd-1'), false, '⛔ 没写兜底就照旧默认拒绝')
|
||
// 取**并集**语义:精确条目与兜底互不覆盖(⛔ 不会"兜底把 ops 的条目压掉")
|
||
assert.equal(isAllowedDialer(tenantAll, 'ops', 'manager'), true, '精确条目在兜底存在时照旧生效')
|
||
assert.deepEqual(describeDialers(tenantAll).sort(), ['manager', 'u:*/*'], '兜底在 /status 里要看得见(一眼能确认它开着)')
|
||
|
||
// ⑦ 非法用法**装载即抛**(本模块的一贯判据:配错就炸,⛔ 不静默变成"谁也没匹配上")
|
||
for (const bad of ['ops:*', 'named-net:*', 'u:*/d-1']) {
|
||
assert.throws(
|
||
() => normalizeDialers(new Set([bad])),
|
||
/只能用在租户网|半通配/,
|
||
`${JSON.stringify(bad)} 应当装载即抛(实际没抛)`,
|
||
)
|
||
}
|
||
assert.throws(
|
||
() => normalizeDialers(new Map([['u:*', new Set(['manager'])]])),
|
||
/只允许写/,
|
||
'`u:*` 桶里写具体 hostId 应当抛(该桶只表达"任何租户网整网放行")',
|
||
)
|
||
})
|
||
|
||
// ── T5 · 打洞成功路径(真 dgram + NAT 模拟) ────────────────────────────────────
|
||
test('T5 打洞成功路径:双向都成立才算直连(punchOk ≥ 1)', async () => {
|
||
const r = await runPunchPair({ aPeer: 'ops/w-1', bPeer: 'ops/w-2', deadlineMs: 2500 }, { sleep })
|
||
assert.equal(r.a.bidirectional, true, `A 侧应双向成立:${r.a.detail}`)
|
||
assert.equal(r.b.bidirectional, true, `B 侧应双向成立:${r.b.detail}`)
|
||
assert.equal(r.a.reason, 'ok')
|
||
assert.equal(r.b.reason, 'ok')
|
||
assert.equal(r.bidirectional, true)
|
||
assert.ok(r.a.recvLocal > 0 && r.b.recvLocal > 0, '两侧都必须真的收到包')
|
||
assert.equal(r.nat.a.forwardedIn > 0, true, 'NAT 侧记录:真的放行了入向包')
|
||
})
|
||
|
||
// ── T6 · 单向不算直连 ───────────────────────────────────────────────────────────
|
||
test('T6 单向 ⇒ 判死 one-way(⛔ 不许把单向当成功)', async () => {
|
||
const r = await runPunchPair({ aPeer: 'ops/w-1', bPeer: 'ops/w-2', deadlineMs: 1200, oneWay: 'a' }, { sleep })
|
||
assert.equal(r.bidirectional, false)
|
||
assert.equal(r.a.reason, 'one-way', `A 侧(只能出不能进)应判 one-way:${r.a.detail}`)
|
||
assert.equal(r.a.bidirectional, false)
|
||
assert.equal(r.b.reason, 'one-way', `B 侧(收到包但对端没收到)应判 one-way:${r.b.detail}`)
|
||
assert.equal(r.b.recvLocal > 0, true, 'B 的确收到了包 ⇒ 不能因为"有收包"就判成功')
|
||
assert.equal(r.b.peerSeen, false)
|
||
})
|
||
|
||
// ── T7 · 失败判死有界 ───────────────────────────────────────────────────────────
|
||
test('T7 对端不响应 ⇒ 窗内判死,且耗时**有界**(⛔ 不无限重试)', async () => {
|
||
const cd = new DirectCooldown(DEFAULT_DIRECT_COOLDOWN_MS)
|
||
const deadlineMs = 600
|
||
const r = await runPunchAttempt(
|
||
{
|
||
peer: 'ops/void',
|
||
selfPort: 0,
|
||
targets: [{ host: '127.0.0.1', port: await deadPort() }],
|
||
deadlineMs,
|
||
cooldown: cd,
|
||
bindHost: '127.0.0.1',
|
||
},
|
||
{ sleep },
|
||
)
|
||
assert.equal(r.reason, 'deadline')
|
||
assert.equal(r.ok, false)
|
||
assert.equal(r.recvLocal, 0)
|
||
assert.ok(r.sent > 1, '窗内应重发(单发一次赶不上对端同时发包)')
|
||
assert.ok(r.elapsedMs <= deadlineMs + 3 * 150, `耗时必须有界:${r.elapsedMs} ms`)
|
||
assert.equal(cd.snapshot().cooling.includes('ops/void'), true, '判死 ⇒ 该候选进冷却')
|
||
// 没有候选 ⇒ **不是**打洞失败 ⇒ ⛔ 不进冷却
|
||
const cd2 = new DirectCooldown(DEFAULT_DIRECT_COOLDOWN_MS)
|
||
const r2 = await runPunchAttempt({ peer: 'ops/none', selfPort: 0, targets: [], cooldown: cd2 }, { sleep })
|
||
assert.equal(r2.reason, 'no-address')
|
||
assert.equal(cd2.snapshot().cooling.length, 0)
|
||
})
|
||
|
||
// ── T8 · 冷却 ──────────────────────────────────────────────────────────────────
|
||
test('T8 冷却:判死后二次被挡且**不开 socket**;`ms = 0` 构造即抛', async () => {
|
||
const cd = new DirectCooldown(5_000)
|
||
let opened = 0
|
||
const port = await deadPort()
|
||
const mk = (targets) =>
|
||
runPunchAttempt(
|
||
{
|
||
peer: 'ops/void',
|
||
selfPort: 0,
|
||
targets,
|
||
deadlineMs: 300,
|
||
cooldown: cd,
|
||
bindHost: '127.0.0.1',
|
||
onSocketOpen: () => {
|
||
opened += 1
|
||
},
|
||
},
|
||
{ sleep },
|
||
)
|
||
const first = await mk([{ host: '127.0.0.1', port }])
|
||
assert.equal(first.reason, 'deadline')
|
||
assert.equal(opened, 1)
|
||
const second = await mk([{ host: '127.0.0.1', port }])
|
||
assert.equal(second.reason, 'cooldown')
|
||
assert.equal(opened, 1, '冷却命中的路径**一个 socket 都不许开**')
|
||
assert.ok(cd.snapshot().blocked >= 1)
|
||
// 🔴 0 = 重试风暴 ⇒ 构造期就炸(本线硬禁令)
|
||
assert.throws(() => new DirectCooldown(0), /必须 > 0/)
|
||
assert.throws(() => new DirectCooldown(-1), /必须 > 0/)
|
||
assert.throws(() => new DirectCooldown(Number.NaN), /必须 > 0/)
|
||
})
|
||
|
||
// ── T9/T10 · join 回读 + 提示 ─────────────────────────────────────────────────
|
||
test('T9 join 本机配置读回 direct = true,且 --direct 0 被尊重', async () => {
|
||
const tmp = mkdtempSync(join(tmpdir(), 'dshs-direct-t9-'))
|
||
const signer = generateAuthorityKey()
|
||
const issue = () => {
|
||
const doc = {
|
||
version: NODES_REGISTRY_VERSION,
|
||
network: 't9-net',
|
||
nonce: newInviteNonce(randomBytes),
|
||
issuedAt: new Date().toISOString(),
|
||
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||
}
|
||
return { doc, sig: signPayloadWith(signer.privateKeyPem, networkInvitePayload(doc)) }
|
||
}
|
||
const io = nodeJoinIo({
|
||
fss: { existsSync, readFileSync, writeFileSync, mkdirSync },
|
||
crypto: { generateNodeKey: () => generateNodeKey(), publicKeyOfPrivate: (pem) => publicKeyOfPrivate(pem) },
|
||
os: { hostname: () => 't9-host' },
|
||
fetchImpl: async () => ({ status: 599, body: 'n/a' }),
|
||
})
|
||
const run = (name, extra) =>
|
||
runJoin(
|
||
{
|
||
network: 't9-net',
|
||
hostId: name,
|
||
invite: issue(),
|
||
trustedSigners: [signer.publicKey],
|
||
nodeKeyFile: join(tmp, `${name}.key`),
|
||
localConfigFile: join(tmp, `${name}.json`),
|
||
outFile: join(tmp, `${name}-app.json`),
|
||
...extra,
|
||
},
|
||
io,
|
||
)
|
||
const a = await run('node-a', {})
|
||
assert.equal(a.ok, true)
|
||
const cfgA = JSON.parse(readFileSync(join(tmp, 'node-a.json'), 'utf8'))
|
||
assert.equal(cfgA.direct, true, '缺省必须写进本机配置 = 开(用户口径②)')
|
||
assert.equal(directFromNodeConfig(cfgA), true)
|
||
assert.match(a.steps.find((s) => s.step === 'local-config').detail, /直连\(打洞\)=开/)
|
||
|
||
const b = await run('node-b', { direct: false })
|
||
assert.equal(b.ok, true)
|
||
assert.equal(directFromNodeConfig(JSON.parse(readFileSync(join(tmp, 'node-b.json'), 'utf8'))), false)
|
||
|
||
// 管理面写回:**只改 direct 一个字段**,其余原样;缺失文件 ⇒ 具名失败(⛔ 不凭空创建)
|
||
const file = join(tmp, 'node-a.json')
|
||
const before = JSON.parse(readFileSync(file, 'utf8'))
|
||
const writer = {
|
||
exists: existsSync,
|
||
read: (p) => readFileSync(p, 'utf8'),
|
||
write: (p, t, mode) => writeFileSync(p, t, { mode }),
|
||
rename: renameSync,
|
||
}
|
||
const w = writeNodeConfigDirect(file, false, writer)
|
||
assert.equal(w.ok, true)
|
||
const after = JSON.parse(readFileSync(file, 'utf8'))
|
||
assert.equal(after.direct, false)
|
||
assert.deepEqual({ ...after, direct: true }, before, '⛔ 除 direct 外任何字段都不许被动到')
|
||
const miss = writeNodeConfigDirect(join(tmp, 'nope.json'), true, writer)
|
||
assert.equal(miss.ok, false)
|
||
assert.equal(miss.reason, 'node-config-missing')
|
||
// 形状坏 ⇒ 具名
|
||
writeFileSync(join(tmp, 'bad.json'), '[1,2,3]')
|
||
assert.throws(() => readNodeConfig(join(tmp, 'bad.json'), { exists: () => true, read: (p) => readFileSync(p, 'utf8') }), /不是 JSON 对象/)
|
||
})
|
||
|
||
test('T10 提示文案必须**可行动**:三段齐 + 含开关键与关值', () => {
|
||
const lines = directHintLines()
|
||
assert.equal(lines.length, 3)
|
||
const text = directHintText()
|
||
assert.ok(text.includes('同一张覆盖网') && text.includes('白名单'), '①段要写清"谁可能连进来"')
|
||
assert.ok(text.includes(DIRECT_ENV_KEY), '②段要写清"怎么关"(含开关键名)')
|
||
assert.ok(DIRECT_OFF_VALUES.some((v) => text.includes(v)), '②段要给出具体的关闭取值')
|
||
assert.ok(text.includes('不影响'), '③段要写清"关掉不影响什么"')
|
||
assert.ok(text.includes('中继'), '③段要写清回落路径')
|
||
// ⛔ 禁止只写"已启用直连"
|
||
assert.ok(!/^已启用直连$/.test(text.trim()))
|
||
// 编码侧的结构性防线:**只吃白名单字段** ⇒ 多给的任何字段(含凭据)都进不了载荷
|
||
const encoded = JSON.parse(encodeDirectMessage({ hostId: 'w-2', network: 'ops', addrs: [{ host: '10.0.0.9', port: 1 }], secret: 'x' }))
|
||
assert.deepEqual(Object.keys(encoded).sort(), ['addrs', 'hostId', 'kind', 'network', 'ts'])
|
||
assert.equal(encoded.secret, undefined, '⛔ 载荷里不可能夹带凭据字段(构造侧结构性排除)')
|
||
})
|