Files
dsh_shenxian/test/overlay-direct.test.mjs
admin e6207aa691
build / build-and-scan (push) Waiting to run
chore(仓库对齐): 文档库结构治理 + IM/插件线落地
文档库:目录改为编号制(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/;交接单不入库(政策)。
2026-09-24 07:25:16 +08:00

463 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 覆盖网络 **直连(打洞)** 单测(序㊵ · 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, '⛔ 载荷里不可能夹带凭据字段(构造侧结构性排除)')
})