把这批「已 scp 到 47 / 106 生产并在跑、但一直未入 git」的实现补进版本库
(其中 P2 的全部新源码此前一直是 untracked)。
分棒内容:
- 序㉔ 内容分发块级寻址:src/net/relay/content/{runtime,source}.ts
- 序㉖ 骨干稳定选路(jitter):src/net/relay/{index,network}.ts、src/web/server.ts
- 序㉘ 组密钥加密(GCM 确定性):src/net/relay/content/{runtime,source}.ts
- 序㉛ P1 一键加入 + 分组准入:src/net/relay/{join,registry}.ts、
src/web/routes/overlay-nodes.ts、scripts/overlay-node-{join,admit}.cjs、
test/overlay-join.test.mjs
- 序㊵/㊶ 直连打洞 + peer 档:src/net/relay/direct/{candidate,index,punch}.ts、
scripts/overlay-direct-probe.cjs、test/overlay-direct.test.mjs
- 序㊷/㊸ 观测面:scripts/overlay-probe.cjs、scripts/overlay-failover-drill.cjs
零回归三件套(2026-09-18 16:2x 提交前复跑,全绿):
- npm test 201 tests / pass 200 / fail 0 / skipped 1(Node v22.22.2)
- overlay-probe.cjs --table 28 PASS / 0 SKIP / 0 FAIL
- overlay-failover-drill.cjs --scene all --table 12 PASS / 0 SKIP / 0 FAIL
⛔ 未纳入:_tmp_seq24/、_tmp_seq40/、_中间产物_待清理/(本机临时产物,仍 untracked)
🔴 未实施:D8 云安全组乙-1(待人工在云控制台落地,见
交接单_覆盖网络直连与P2P_20260918.md §8.11)
404 lines
21 KiB
JavaScript
404 lines
21 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,
|
||
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-106'])],
|
||
['u:5', new Set(['w-106'])],
|
||
]),
|
||
)
|
||
|
||
/** 找一个没人监听的本地 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-106',
|
||
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-106' }, 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-106' }, 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-106' }, selfHostId: 'w-106' })
|
||
|
||
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-106', 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-106'), true)
|
||
assert.equal(isAllowedDialer(dialers(), 'u:5', 'w-106'), true)
|
||
assert.equal(isAllowedDialer(dialers(), 'u:7', 'w-106'), 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, /return map\.get\(network\)\?\.has\(hostId\) === true/)
|
||
|
||
// ② server.ts 的两处闸门与它**同策略**(同 map 、同 `=== true` 默认拒绝)
|
||
const registerGate = srvSrc.includes('const wantDialer = this.dialers.get(network)?.has(hostId) === true')
|
||
const dialGate = srvSrc.includes('if (this.dialers.get(session.network)?.has(session.hostId) !== true) {')
|
||
assert.ok(registerGate, '注册闸门(server.ts)应仍是 dialers.get(network)?.has(hostId) === true(⛔ 本棒不许改它的语义)')
|
||
assert.ok(dialGate, 'DIAL 闸门(server.ts)应仍是 dialers.get(session.network)?.has(session.hostId) !== true')
|
||
|
||
// ③ 直连模块**不许**自己再判一遍(⛔ 防"同一事实两处写")
|
||
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 口径一致)')
|
||
})
|
||
|
||
// ── T5 · 打洞成功路径(真 dgram + NAT 模拟) ────────────────────────────────────
|
||
test('T5 打洞成功路径:双向都成立才算直连(punchOk ≥ 1)', async () => {
|
||
const r = await runPunchPair({ aPeer: 'ops/w-47', bPeer: 'ops/w-106', 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-47', bPeer: 'ops/w-106', 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-106', 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, '⛔ 载荷里不可能夹带凭据字段(构造侧结构性排除)')
|
||
})
|