feat(overlay): 覆盖网络线 序㉔–㊸ 补提交 —— 源码与已部署产物对齐

把这批「已 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)
This commit is contained in:
admin committed 2026-09-18 16:37:09 +08:00
1 parent 09ce76f3af
commit c452013129
18 files changed
+6604 -78

No files matched your search

+382 -8
View File
@@ -18,8 +18,24 @@
* 3. **纯新增、可选、缺省可用**:relay 侧没装内容面时,`snapshot()` 返回 `undefined`
* ⇒ `/status` 不含 `content` 键 ⇒ 与序㉔ 之前的字节级兼容(⛔ 不改任何既有字段)。
*
* ## 🆕 序㊶ · S6:`peer` 档**接线**(优先直连 → 回落 wss)
* 序㉔ 的 `peer` 档是**诚实回"没有"**的空壳(取回通道未接线)。S6 把它接上**两条通道**:
*
* | 顺序 | 通道 | 是什么 |
* |---|---|---|
* | ① | `direct` | S5 的直连(打洞)通道 —— **同时复用 S5 的准入判定**(`candidate.ts#admitCandidate`,其后端是 `network.ts#isAllowedDialer`) |
* | ② | `wss` | 既有 wss 取回通道(**注入位**;relay 协议侧暂无内容取回 op ⇒ 缺省 = 具名 `not-wired`) |
*
* 🔴 **D7 闸门(本模块最要紧的一行逻辑)**:取回的**落库字节**必须**复算**出与请求相同的块 id
* (`chunker#blockIdOf`)。不复算的接线会把"零回源"直接做成假绿 —— 块 id 口径一动,
* "命中 peer" 仍然全绿,而内容已经拼不上。
*
* ## ⛔ 本模块**不做**的事
* - 不做网络取块(peer 的真实取回通道在真机验证阶段由上层接线);
* - **不做准入判定**:直连候选只从 {@link ContentRuntime.noteDirectCandidate} 进来,
* 而它只收 S5 `CandidateLedger#judge` 判过 `ok:true` 的结果 ⇒ ⛔ 本模块**不另写一份白名单**;
* - 不做直连**数据面**(打洞成功后的块交换需要 UDP 应答端 = 新暴露面 ⇒ 独立后续项,
* 现状按具名 `data-plane-pending` 回落到 wss,⛔ 不静默当"没有");
* - 不读 `src/config.ts`(relay 是独立单元,见 `main.ts` 头部说明);
* - 不写日志(日志在装配点给;本模块只负责**算账与报数**)。
*
@@ -34,6 +50,165 @@ import { ContentStore, DEFAULT_MAX_BYTES } from './store.js'
import type { ContentStoreCounters } from './store.js'
import { chunkify, planOf, reassemble, blockIdOf, DEFAULT_BLOCK_SIZE } from './chunker.js'
import type { ContentCipher, ContentCryptoCounters } from './crypto.js'
import { DIRECT_CAND_MAX_ADDRS, isValidAddress } from '../direct/candidate.js'
import type { CandidateVerdict, DirectAddress } from '../direct/candidate.js'
import { DEFAULT_DIRECT_COOLDOWN_MS, DirectCooldown } from '../direct/punch.js'
import { DIRECT_ENV_KEY, resolveDirectSwitch } from '../direct/index.js'
import type { DirectSwitchState } from '../direct/index.js'
/* ── 🆕 序㊶ · S6:`peer` 档取块**通道**(接线) ───────────────────────────────────────── */
/** peer 档取块通道名。 */
export type PeerChannelName = 'direct' | 'wss'
/**
* 🔴 通道优先级 —— **唯一权威**(⛔ 不许在调用方另写一份数组)。
* `direct` 在前(省中继一跳);不可用 / 未命中 / 出错 ⇒ 逐个往后回落。
*/
export const PEER_CHANNEL_ORDER: readonly PeerChannelName[] = ['direct', 'wss']
/**
* 通道**此刻不可用**的具名原因。
*
* 🔴 与"未命中"**必须可区分**(`source.ts` 纪律 2 的同一条纪律):
* - `miss` = 通道问了,对端说"我这没有";
* - `unavailable` = 这条通道**根本没问**(开关关 / 冷却中 / 没候选 / 没接线 / 数据面未建成)。
*
* 混在一起 ⇒ "直连从没生效过"会被读成"直连问过了但没有",正是本线的假绿形态。
*/
export type PeerChannelDownReason =
/** 用户/运维把直连关了(`DSHS_OVERLAY_DIRECT=0`)。 */
| 'disabled'
/** 开关**取值非法**(⛔ 不静默当开、⛔ 也不静默当关)。 */
| 'invalid-switch'
/** 判死后仍在冷却里(⛔ 本次连 socket 都不开)。 */
| 'cooldown'
/** 没有**准入过**的候选地址(候选交换还没喂进来 / 全被拒)。 */
| 'no-address'
/** 🔴 直连**数据面**未建成(S5 只做打洞**探测**)⇒ 具名回落,⛔ 不假装取到。 */
| 'data-plane-pending'
/** 该通道**未装配**(`wss` 缺省:relay 协议侧暂无内容取回 op)。 */
| 'not-wired'
/** 一次 peer 取块的**对象**(逻辑名 + 网 + 组;组键由 `peer.ts` 判)。 */
export interface PeerRef {
name: string
network: string
group: string
}
/** 一条取块通道 —— ⛔ 只回字节,**不做判定**(`available` 只答"此刻能不能问")。 */
export interface PeerBlockChannel {
readonly name: PeerChannelName
/** 此刻是否可用(不可用 ⇒ **具名**,调用方据此记账并走下一通道)。 */
available(peer: PeerRef, id: string): { ok: true } | { ok: false; reason: PeerChannelDownReason; detail: string }
/** 取块:拿到字节(**落库字节**)⇒ 返回;对端说没有 ⇒ `undefined`;出错 ⇒ 抛。 */
fetch(peer: PeerRef, id: string): Promise<Buffer | undefined>
}
/** `peer` 接线面的只读快照(探针 / 管理面读这一份)。 */
export interface PeerWireSnapshot {
/** 通道优先级(= {@link PEER_CHANNEL_ORDER} 的副本)。 */
order: PeerChannelName[]
/** 每条通道是否已装配。 */
wired: Record<PeerChannelName, boolean>
/** 逐通道"真问了"的次数。 */
attempts: Record<PeerChannelName, number>
/** 逐通道**命中**(=取回字节 + **复算通过**)。 */
hits: Record<PeerChannelName, number>
/** 逐通道**未命中**(问了,对端说没有)。 */
misses: Record<PeerChannelName, number>
/** 逐通道**抛错**(与未命中可区分)。 */
errors: Record<PeerChannelName, number>
/** 逐通道**不可用**(根本没问)。 */
unavailable: Record<PeerChannelName, number>
/** 不可用原因的具名分布(`<channel>:<reason>` → 次数)。 */
downReasons: Record<string, number>
/** 🔴 **D7 闸门**:复算过的块数。 */
idChecks: number
/** 🔴 **D7 闸门**:复算**不符**被丢弃的块数(恒应 ≤ `idChecks`;命中时必为 0 增长)。 */
idMismatches: number
/** 已登记的直连候选 peer 数(只含准入过的)。 */
directCandidates: number
/** 最近一次 peer 取块的实况(⛔ 不许"没有原因地走了 wss")。 */
last: { peer: string; channel: PeerChannelName | null; reason: string } | null
}
/**
* 直连通道的装配选项。
*
* ⚠️ `addresses` 是**唯一**的候选来源,而它由 {@link ContentRuntime.noteDirectCandidate} 喂养 ——
* 那条路径只接受 S5 准入判定 `ok:true` 的结果 ⇒ 白名单判定**不在这里**(⛔ 不另写一份)。
*/
export interface DirectBlockChannelOptions {
/** 开关解析结果(由 `direct/index.ts#resolveDirectSwitch` 产出)。 */
switchState: DirectSwitchState
/** 冷却表(与 S5 同一份纪律:`ms <= 0` ⇒ **构造期即抛**)。 */
cooldown: DirectCooldown
/** 该 peer 的准入候选地址(`undefined` = 没有 ⇒ 具名 `no-address`)。 */
addresses: (peer: string) => readonly DirectAddress[] | undefined
}
/**
* 直连通道的**数据面未建成**标记。
*
* ⛔ 为什么不直接返回 `undefined`:那会被记成"未命中"(= 对端没有),而真相是
* "这条通道根本没能力取"。两者混同 ⇒ "直连零生效"被读成"直连没省到"(本线假绿形态)。
*/
export const DIRECT_DATA_PLANE_PENDING = 'data-plane-pending'
/**
* 造一条**直连通道**(顺序链的①)。
*
* 🔴 三级闸门**顺序固定**(每一级都必须具名):
* ① 开关(关 / 非法 ⇒ `disabled` / `invalid-switch`)
* ② 冷却(判死过 ⇒ `cooldown`;⛔ 本次连 socket 都不开)
* ③ 候选地址(没准入过 ⇒ `no-address`)
* ④ 数据面(S6 未建成 ⇒ `data-plane-pending`,⛔ 不假装取到)
*/
export function createDirectBlockChannel(opts: DirectBlockChannelOptions): PeerBlockChannel {
return {
name: 'direct',
available(peer: PeerRef): { ok: true } | { ok: false; reason: PeerChannelDownReason; detail: string } {
const s = opts.switchState
if (s.enabled !== true) {
return s.enabled === false
? { ok: false, reason: 'disabled', detail: `${s.envKey} 关闭(来源 ${s.source})⇒ ⛔ 不打洞、⛔ 零 UDP socket,直接回落` }
: { ok: false, reason: 'invalid-switch', detail: s.invalid === '' ? `${s.envKey} 取值非法` : s.invalid }
}
if (opts.cooldown.blocked(peer.name)) {
return {
ok: false,
reason: 'cooldown',
detail: `${peer.name} 在冷却中(${opts.cooldown.ms} ms)⇒ 本次不打洞,直接回落(⛔ 不进重试风暴)`,
}
}
const addrs = opts.addresses(peer.name)
if (addrs === undefined || addrs.length === 0) {
return {
ok: false,
reason: 'no-address',
detail: `${peer.name} 没有准入过的直连候选地址 ⇒ 回落(⛔ 不是"打洞失败")`,
}
}
return {
ok: false,
reason: 'data-plane-pending',
detail:
`有 ${addrs.length} 条准入候选、开关开、未冷却,但**直连数据面未建成**` +
`(S5 只做打洞探测,打洞后的块交换需新增 UDP 应答端 ⇒ 独立后续项)⇒ 具名回落,⛔ 不假装取到`,
}
},
async fetch(peer: PeerRef): Promise<Buffer | undefined> {
// ⛔ 防御性:`available()` 在数据面建成前**恒不返回 ok:true** ⇒ 这里理应不可达。
// 真被调到 ⇒ 大喊,而不是静默回"没有"(后者会把"没建成"伪装成"对端没有")。
throw new Error(
`direct: 直连数据面未建成 —— ${peer.name} 的块交换未实现(${DIRECT_DATA_PLANE_PENDING});` +
`⛔ 不许把它记成"未命中",请走 available() 的具名降级`,
)
},
}
}
/** 内容面运行时装配选项。 */
export interface ContentRuntimeOptions {
@@ -60,6 +235,18 @@ export interface ContentRuntimeOptions {
onDecodeRejected?: (tier: SourceTier, id: string, reason: 'decode-failed') => void
/** 日志函数(可选)。⚠️ ⛔ 不许把明文块塞进日志(`OBS-23` 会扫)。 */
log?: (line: string) => void
/**
* 🆕 序㊶ · S6:**额外注入**的取块通道(按 {@link PEER_CHANNEL_ORDER} 排顺序)。
* ⚠️ 缺省只装内置的 `direct`(见 {@link ContentRuntimeOptions.direct});
* `wss` 通道**必须注入**才有(relay 协议侧暂无内容取回 op)。
*/
peerChannels?: Partial<Record<PeerChannelName, PeerBlockChannel>>
/**
* 🆕 序㊶ · S6:内置直连通道的选项。
* - 缺省 ⇒ 装配(开关读 `process.env` ⇒ 与 `DSHS_OVERLAY_DIRECT`「**缺省即开**」一致);
* - `false` ⇒ **不装配**(该通道缺 ⇒ 具名 `not-wired`)。
*/
direct?: false | { env?: Readonly<Record<string, string | undefined>>; cooldownMs?: number }
}
/**
@@ -95,6 +282,11 @@ export interface ContentSnapshot {
storeBlocks: number
/** 本节点**组内**已声明的 peer 名单(供上层做真机取块接线)。 */
groupMembers: string[]
/**
* 🆕 序㊶ · S6:**peer 档接线面**(通道顺序 / 具名降级 / **D7 复算闸门**)。
* ⚠️ 纯新增键 —— 既有消费方(`OBS-17` 只逐键看 `source`/`peer`/`store`)零影响。
*/
peerWire: PeerWireSnapshot
/**
* 🆕 序㉘ · 单 B:**组密钥加密判别器**(探针 `OBS-23` 的读取口径)。
* ⚠️ **不启用加密时本键整体缺席** ⇒ `OBS-23` 记 **SKIP**("缺省不启用"是合法状态)。
@@ -117,11 +309,40 @@ export class ContentRuntime {
readonly cipher: ContentCipher | undefined
private readonly storeMaxBytes: number
/** 本节点所属网(候选登记的防御性比对用;⛔ 不从别处猜)。 */
private readonly network: string
/** 日志函数(未注入 ⇒ 静默)。 */
private readonly log: ((line: string) => void) | undefined
/** 自证写入的最后一个块 id(仅供 `selfProbe` 读回用)。 */
private lastProbeId: string | undefined
/** 🆕 S6:已装配的取块通道(按 {@link PEER_CHANNEL_ORDER} 排顺序)。 */
private readonly peerChannels = new Map<PeerChannelName, PeerBlockChannel>()
/** 🆕 S6:直连通道的开关解析结果(观测面用;未装配 ⇒ `undefined`)。 */
private readonly directSwitch: DirectSwitchState | undefined
/** 🆕 S6:直连候选地址表(**只经 `noteDirectCandidate` 写入**,⛔ 不从别处塞)。 */
private readonly directAddrs = new Map<string, DirectAddress[]>()
/**
* 🆕 S6:接线面计数。
*
* ⚠️ `downReasons` 的键是 `` `${channel}:${reason}` `` —— 用字符串键而不是嵌套对象,
* 是为了让 `JSON.stringify` 出来的 `/status` 一眼可读、探针逐键断言也简单。
*/
private readonly wire = {
attempts: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
hits: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
misses: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
errors: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
unavailable: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
downReasons: {} as Record<string, number>,
idChecks: 0,
idMismatches: 0,
last: null as PeerWireSnapshot['last'],
}
constructor(opts: ContentRuntimeOptions) {
this.storeMaxBytes = opts.storeMaxBytes ?? DEFAULT_MAX_BYTES
this.network = opts.network
this.log = opts.log
this.cipher = opts.cipher
this.store = new ContentStore({
maxBytes: this.storeMaxBytes,
@@ -134,6 +355,26 @@ export class ContentRuntime {
...(this.cipher === undefined ? {} : { epoch: this.cipher.epoch }),
...(opts.log === undefined ? {} : { log: opts.log }),
})
// ── 🆕 序㊶ · S6:装配取块通道(顺序由 `PEER_CHANNEL_ORDER` 定,⛔ 不在这里排)──────
if (opts.direct !== false) {
const env = opts.direct?.env ?? process.env
const switchState = resolveDirectSwitch(env)
this.directSwitch = switchState
this.peerChannels.set(
'direct',
createDirectBlockChannel({
switchState,
cooldown: new DirectCooldown(opts.direct?.cooldownMs ?? DEFAULT_DIRECT_COOLDOWN_MS),
addresses: (peer) => this.directAddrs.get(peer),
}),
)
}
const injected = opts.peerChannels ?? {}
for (const name of PEER_CHANNEL_ORDER) {
const ch = injected[name]
// ⚠️ 注入的通道**覆盖**内置的(便于夹具替身),⛔ 但不许改名(顺序权威只此一份)
if (ch !== undefined) this.peerChannels.set(name, ch)
}
this.source = new ContentSourceChain({
fetchers: {
// ① 本地:内容寻址存储命中即返回(零网络 —— 最省的档位)
@@ -143,15 +384,10 @@ export class ContentRuntime {
? undefined
: { tier: 'local' as const, bytes }
},
// ② 同组 peer:**诚实回"没有"**,直到上层把真实取回通道接上。
// ② 同组 peer:**S6 接线** —— 逐候选 × 逐通道(direct → wss)尝试;
// 🔴 取回的**落库字节**必须复算出同一个块 id(D7 闸门),否则**丢弃且不算命中**。
// ⛔ 绝不许在这里伪造字节 —— 那会把 E1 的"零回源"做成假绿(本线的老病根)。
peer: async (id: string) => {
const cands = this.peers.candidates(id)
if (cands.length === 0) return undefined
// 有候选但取回通道尚未接线 ⇒ 逐条记账后诚实回"没有"
for (const c of cands) this.peers.markDenied(c.name, id)
return undefined
},
peer: async (id: string) => this.fetchFromPeers(id),
},
// 🆕 单 B:**唯一解密点**(生产路径)—— 五档一律在这里解密
...(this.cipher === undefined ? {} : { decode: (stored: Buffer) => this.cipher?.decodeBlock(stored) }),
@@ -163,6 +399,142 @@ export class ContentRuntime {
this.blockSize = DEFAULT_BLOCK_SIZE
}
/**
* 🆕 序㊶ · S6:**peer 档接线的唯一执行点**。
*
* 顺序写死:**候选**(同组,`peer.ts` 的 E5 闸门)→ **通道**(`direct` → `wss`)。
* 每一步都记账,且三类结果**互相可区分**:
* - `unavailable`(这条通道压根没问)/`misses`(问了,对端没有)/`errors`(问了,炸了)/`hits`(拿到**且复算通过**)。
*
* @returns 命中 ⇒ `{tier:'peer', bytes}`(**落库字节**,解密由链上唯一解密点做);否则 `undefined`
*/
private async fetchFromPeers(id: string): Promise<{ tier: 'peer'; bytes: Buffer } | undefined> {
const cands = this.peers.candidates(id)
if (cands.length === 0) return undefined
for (const c of cands) {
// 🔴 E5:跨组 ⇒ **显式拒绝**(计数在 `markDenied` 内,语义与序㉔ 逐字不变)
if (this.peers.markDenied(c.name, id)) continue
const ref: PeerRef = { name: c.name, network: c.network, group: c.group }
for (const name of PEER_CHANNEL_ORDER) {
const ch = this.peerChannels.get(name)
if (ch === undefined) {
this.wire.unavailable[name] += 1
this.noteDown(name, 'not-wired')
continue
}
const av = ch.available(ref, id)
if (!av.ok) {
// ⛔ 不可用 ≠ 未命中:这条通道**没问**,只是被具名降级了
this.wire.unavailable[name] += 1
this.noteDown(name, av.reason)
this.wire.last = { peer: c.name, channel: name, reason: av.reason }
continue
}
this.wire.attempts[name] += 1
let gotBytes: Buffer | undefined
try {
gotBytes = await ch.fetch(ref, id)
} catch (err) {
this.wire.errors[name] += 1
this.wire.last = { peer: c.name, channel: name, reason: 'error' }
this.logLine(`[content-peer] ⛔ channel=${name} peer=${c.name} 取块抛错:${String(err)}`)
continue
}
if (gotBytes === undefined) {
this.wire.misses[name] += 1
this.wire.last = { peer: c.name, channel: name, reason: 'miss' }
continue
}
// ── 🔴 **D7 闸门**:复算块 id(⛔ 这一行不许省、不许"先信后验")────────────
this.wire.idChecks += 1
const actual = blockIdOf(gotBytes)
if (actual !== id) {
this.wire.idMismatches += 1
this.wire.errors[name] += 1
this.wire.last = { peer: c.name, channel: name, reason: 'id-mismatch' }
this.logLine(
`[content-peer] ⛔ 复算不符 channel=${name} peer=${c.name}` +
` 期望=${id.slice(0, 8)}… 实得=${actual.slice(0, 8)}… ⇒ **丢弃**(⛔ 不算命中、⛔ 不返回字节)`,
)
continue
}
this.wire.hits[name] += 1
this.wire.last = { peer: c.name, channel: name, reason: 'hit' }
return { tier: 'peer', bytes: gotBytes }
}
}
return undefined
}
/** 记账:不可用原因分布(⛔ 不许"没有原因地走了另一条通道")。 */
private noteDown(channel: PeerChannelName, reason: PeerChannelDownReason): void {
const key = `${channel}:${reason}`
this.wire.downReasons[key] = (this.wire.downReasons[key] ?? 0) + 1
this.wire.last = this.wire.last ?? { peer: '', channel, reason }
}
/** 日志(未注入 ⇒ 静默;⛔ 不把块字节写进日志)。 */
private logLine(line: string): void {
this.log?.(line)
}
/**
* 🆕 序㊶ · S6:登记一条**直连候选**(**唯一入口**)。
*
* 🔴 **准入判定不在这里** —— 只接受 S5 `CandidateLedger#judge` 判过 `ok:true` 的结果
* (那条链路复用 `network.ts#isAllowedDialer`,⛔ 本模块不另写一份白名单)。
* 这里只做两件**防御性**收尾:① 网必须与本节点一致(跨网 ⇒ 拒)② 地址逐条过 `isValidAddress` + 条数上限。
*/
noteDirectCandidate(verdict: CandidateVerdict): { ok: true; peer: string; addrs: number } | { ok: false; reason: string } {
if (!verdict.ok) return { ok: false, reason: verdict.reason }
const msg = verdict.message
if (msg.network !== this.network) {
return { ok: false, reason: `cross-network(载荷 ${msg.network} ≠ 本节点 ${this.network})` }
}
const addrs = msg.addrs.filter((a) => isValidAddress(a)).slice(0, DIRECT_CAND_MAX_ADDRS)
if (addrs.length === 0) return { ok: false, reason: 'bad-address(过滤后为空)' }
// ⚠️ 键 = **逻辑名** `<network>/<hostId>`(与 `peer.ts` 的 `PeerDeclaration.name` 同口径)
// —— 候选载荷里只有 `hostId`(relay 逻辑名的后半段),两处不同形 ⇒ 在这里统一,⛔ 别让调用方猜。
const key = `${msg.network}/${msg.hostId}`
this.directAddrs.set(key, addrs.map((a) => ({ ...a })))
return { ok: true, peer: key, addrs: addrs.length }
}
/** 🆕 S6:候选下线(该 peer 不再作为直连对象)。 */
withdrawDirectCandidate(peer: string): boolean {
return this.directAddrs.delete(peer)
}
/** 🆕 S6:已登记的直连候选(只读视图)。 */
directCandidates(): string[] {
return [...this.directAddrs.keys()].sort()
}
/** 🆕 S6:接线面只读快照(探针 / 管理面共用这一份 —— ⛔ 别各写一套)。 */
peerWireSnapshot(): PeerWireSnapshot {
const wired = { direct: false, wss: false } as Record<PeerChannelName, boolean>
for (const name of PEER_CHANNEL_ORDER) wired[name] = this.peerChannels.has(name)
return {
order: [...PEER_CHANNEL_ORDER],
wired,
attempts: { ...this.wire.attempts },
hits: { ...this.wire.hits },
misses: { ...this.wire.misses },
errors: { ...this.wire.errors },
unavailable: { ...this.wire.unavailable },
downReasons: { ...this.wire.downReasons },
idChecks: this.wire.idChecks,
idMismatches: this.wire.idMismatches,
directCandidates: this.directAddrs.size,
last: this.wire.last === null ? null : { ...this.wire.last },
}
}
/** 🆕 S6:直连开关的解析结果(`undefined` = 内置直连通道未装配)。 */
directSwitchState(): DirectSwitchState | undefined {
return this.directSwitch
}
/** 🆕 是否启用加密(判据用:区分"没启用"与"启用了但没解过")。 */
get cryptoEnabled(): boolean {
return this.cipher !== undefined
@@ -282,6 +654,8 @@ export class ContentRuntime {
storeBytes: this.store.bytes,
storeBlocks: this.store.size,
groupMembers: this.peers.sameGroupPeers(),
// 🆕 S6:peer 档接线面(**纯新增键** ⇒ 既有消费方零影响)
peerWire: this.peerWireSnapshot(),
// ⚠️ 不启用加密 ⇒ 本键**整体缺席**(不是补零!补零会让"没启用"与"启用了但零值"同形)
...(this.cipher === undefined ? {} : { crypto: this.cipher.counters() }),
}
+4
View File
@@ -39,6 +39,10 @@
* - 🔴 **不做完整性校验**(`E4` 那半边在 `store.get` 的读侧复算里)。⚠️ **如实留档**:
* `peer` 档的真实取回通道**尚未接线** ⇒ 接线时**必须**在取回后复算 `blockIdOf`
* (否则"篡改块被丢弃"只在 `local` 档成立 —— 已在单 B §8.13 登记)。
* ✅ **序㊶(S6)已兑现**:复算闸门落在**装配点** `runtime.ts#fetchFromPeers`
* (取回的落库字节必须复算出同一个块 id,不符 ⇒ **丢弃且不算命中**,逐条 `idMismatches` 计数)。
* ⛔ 刻意**不放在本模块**:链的职责是"按顺序问",块 id 口径属装配层的接线纪律
* —— 放进来会让"五档通用链"认识块格式(分层退化)。
*
* @module dshs/net/relay/content/source
*/
+382
View File
@@ -0,0 +1,382 @@
/**
* 覆盖网络 **直连候选交换**(序㊵ · P2 · S5)—— 候选地址的**收发与校验**。
*
* ## 它解决的确切问题
* 打洞前双方必须知道**对方的公网 UDP 落点**。这份模块定义"这条消息长什么样"以及
* "**什么样的对端才有资格发它**",其余(怎么打洞)在 `punch.ts`。
*
* ## 🔴 三条不可退让的设计(每一条都对应本线吃过的一次亏)
*
* | # | 纪律 | 为什么 |
* |---|---|---|
* | ① | **走既有 relay 通道**,⛔ 零新口、⛔ 零新协议 | 候选交换**不是**数据面:它只需要"已被鉴权的那条连接"。新增监听口 = 命中 R5 |
* | ② | 准入**复用** `network.ts#isAllowedDialer`(= `server.ts` DIAL 白名单那一条策略) | 本线老病根「同一事实两处写」:另写一份 ⇒ 两处迟早分叉,而 `server.ts` 那两处是**冻结的只读面** |
* | ③ | 拒绝**必须显式 + 计数**,⛔ **不许静默返空** | "配置错长得像网络不通"是本线反复踩的假绿面:静默返空 = 判据全绿而功能全废 |
*
* ## ⛔ 载荷里不许有什么
* 只准 `{kind, hostId, network, addrs[], ts}`。**任何**含密钥 / 签名 / 凭据字样的字段名
* ⇒ `secret-field` 直接拒(判定见 {@link findSecretField})。理由:候选是**对端**给的、
* 会被写进日志与观测面;它一旦能携带身份材料,就等于给"密钥本体不经网络"这条纪律开了一个洞。
*
* @module dshs/net/relay/direct/candidate
*/
import { isAllowedDialer, isHostId, isNetworkId, logicalName } from '../network.js'
/** 候选消息的类型标签(走既有 relay 通道时的 JSON 信封)。⛔ 别改名 —— 探针与单测按它取值。 */
export const DIRECT_MESSAGE_KIND = 'DIRECT_CANDIDATE'
/** 单条消息里候选地址的上限(缺省)。⚠️ 权威值在参数表 `DIRECT_CAND_MAX_ADDRS`。 */
export const DIRECT_CAND_MAX_ADDRS = 4
/** 候选的**新鲜期**(ms):超期 ⇒ `stale`。理由 = NAT 映射有寿命,过期的落点打不通还会浪费一次探测。 */
export const DIRECT_CAND_TTL_MS = 60_000
/** 打洞用的候选地址(**IPv4 / IPv6 字面量**,⛔ 不收主机名 —— 打洞不能依赖 DNS)。 */
export interface DirectAddress {
host: string
port: number
}
/** 候选消息本体。 */
export interface DirectCandidateMessage {
kind: typeof DIRECT_MESSAGE_KIND
/** 申报者自己在**这张网**里的逻辑名(= 发送方自己的 hostId)。 */
hostId: string
network: string
addrs: DirectAddress[]
/** 产生时刻(epoch ms);`0` = 未标注(不判新鲜期)。 */
ts: number
}
/**
* 拒绝原因(**枚举**,⛔ 不收自由文本)—— 目的:让"哪一类被拒"可被机器判、可被计数。
*
* ⚠️ `'cross-network'` 与 `'not-self-candidate'` 是**安全判据**(跨网 / 冒名申报第三人地址),
* ⛔ 不是"配置错了"。
*/
export type CandidateReason =
/** 载荷形状不合法(缺字段 / 类型不对 / `kind` 不是本类型)。 */
| 'bad-shape'
/** 载荷里出现了疑似密钥 / 凭据字段。 */
| 'secret-field'
/** 申报的网与会话所属的网不一致(跨网)。 */
| 'cross-network'
/** 申报的 `hostId` **不是发送方自己**(= 替第三人申报地址)。 */
| 'not-self-candidate'
/** 发送方不在该网白名单里(⛔ 默认拒绝)。 */
| 'not-a-dialer'
/** 本机不在该网白名单里 ⇒ 连"能拨"都不成立,没必要建立直连。 */
| 'self-not-a-dialer'
/** 地址不是合法字面量(主机名 / 端口越界 / 非法 IP)。 */
| 'bad-address'
/** 地址条数超上限。 */
| 'too-many-addrs'
/** 超过新鲜期。 */
| 'stale'
/** 一次准入判定的结果(成功与失败都带 `detail`,⛔ 不许只回 `false`)。 */
export type CandidateVerdict =
| { ok: true; message: DirectCandidateMessage }
| { ok: false; reason: CandidateReason; detail: string }
/**
* 疑似密钥 / 凭据的字段名(**子串匹配,大小写不敏感**)。
*
* ⚠️ 这份清单**故意从宽**:漏掉一个词 = 给"密钥本体不经网络"开洞;
* 误伤一个正常的字段名 = 改个字段名就行(代价不对称 ⇒ 从严)。
*/
const SECRET_FIELD_TOKENS = [
'key',
'secret',
'token',
'pem',
'priv',
'sign',
'sig',
'cert',
'pass',
'cred',
'hmac',
'bearer',
] as const
/**
* 递归找一个"像密钥 / 凭据"的字段名(**任意深度**)。
*
* ⛔ 不看**值**(那要靠猜测):只看**字段名** —— 判据必须可复现、不靠人看。
*/
export function findSecretField(value: unknown, path = '$'): string | undefined {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const hit = findSecretField(value[i], `${path}[${i}]`)
if (hit !== undefined) return hit
}
return undefined
}
if (value === null || typeof value !== 'object') return undefined
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const low = k.toLowerCase()
if (SECRET_FIELD_TOKENS.some((t) => low.includes(t))) return `${path}.${k}`
const hit = findSecretField(v, `${path}.${k}`)
if (hit !== undefined) return hit
}
return undefined
}
/** IPv4 字面量(**逐段 0–255**;⛔ 不收 `1.2.3`、⛔ 不收前导零)。 */
export function isIpv4(raw: string): boolean {
const parts = raw.split('.')
if (parts.length !== 4) return false
return parts.every((p) => /^(0|[1-9][0-9]{0,2})$/.test(p) && Number(p) <= 255)
}
/**
* IPv6 字面量(**宽松但排除了主机名字符**)。
*
* 口径:只允许 `[0-9a-fA-F:]` 且冒号数量 ≥ 2(⇒ 单冒号形态如 `host:80` 不可能通过)。
* ⛔ 刻意**不**做完整 RFC 解析:打洞只要求"能直接喂给 `dgram.send`",而形状错的地址
* 会在 `dgram` 那里立刻报错 ⇒ 这里只负责拦掉"看着像主机名"的那一类。
*/
export function isIpv6(raw: string): boolean {
if (!/^[0-9a-fA-F:]+$/.test(raw)) return false
const colons = (raw.match(/:/g) ?? []).length
return colons >= 2 && colons <= 7
}
/** 地址校验(只判**形状**;端口 1–65535)。 */
export function isValidAddress(a: DirectAddress): boolean {
if (typeof a.host !== 'string' || a.host === '') return false
if (!isIpv4(a.host) && !isIpv6(a.host)) return false
return Number.isInteger(a.port) && a.port > 0 && a.port <= 65535
}
/**
* 严格解析一条候选消息。
*
* ⛔ **不宽容**:任何形状问题都返回 `undefined`(调用方转成 `bad-shape` **并计数**),
* ⛔ 不补默认值、⛔ 不做类型强转(`"80"` 不是 `80`)。
*/
export function parseDirectMessage(raw: string): DirectCandidateMessage | undefined {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return undefined
}
return normalizeDirectMessage(parsed)
}
/** 与 {@link parseDirectMessage} 同一口径,但收**已解析**的对象(信封解出来之后的入口)。 */
export function normalizeDirectMessage(parsed: unknown): DirectCandidateMessage | undefined {
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
const r = parsed as Record<string, unknown>
if (r.kind !== DIRECT_MESSAGE_KIND) return undefined
const hostId = typeof r.hostId === 'string' ? r.hostId : ''
const network = typeof r.network === 'string' ? r.network : ''
if (!isHostId(hostId) || !isNetworkId(network)) return undefined
if (!Array.isArray(r.addrs) || r.addrs.length === 0) return undefined
const addrs: DirectAddress[] = []
for (const item of r.addrs) {
if (item === null || typeof item !== 'object') return undefined
const a = item as Record<string, unknown>
if (typeof a.host !== 'string' || typeof a.port !== 'number') return undefined
addrs.push({ host: a.host, port: a.port })
}
const ts = typeof r.ts === 'number' && Number.isFinite(r.ts) ? r.ts : 0
return { kind: DIRECT_MESSAGE_KIND, hostId, network, addrs, ts }
}
/**
* 编码一条候选消息(**唯一构造点**)。
*
* ⛔ 先过一遍 {@link findSecretField}:构造侧就不许把身份材料塞进来(失败早于上线)。
*/
export function encodeDirectMessage(msg: {
hostId: string
network: string
addrs: DirectAddress[]
ts?: number
}): string {
const out: DirectCandidateMessage = {
kind: DIRECT_MESSAGE_KIND,
hostId: msg.hostId,
network: msg.network,
addrs: msg.addrs.map((a) => ({ host: a.host, port: a.port })),
ts: msg.ts ?? Date.now(),
}
const secret = findSecretField(out)
if (secret !== undefined) throw new Error(`候选载荷里出现疑似凭据字段 ${secret}(⛔ 候选只含地址与端口)`)
const back = normalizeDirectMessage(out)
if (back === undefined) throw new Error('候选消息形状非法(⛔ 不许把坏形状编出去)')
return JSON.stringify(out)
}
/** 一条候选的完整上下文(判定所需的一切**都是显式传入的** ⇒ 单测无需 mock 判据)。 */
export interface CandidateContext {
/** 归一化后的拨号方白名单(`network → hostId 集合`)—— 与 `server.ts` 的 `dialers` **同型同源**。 */
dialers: ReadonlyMap<string, ReadonlySet<string>>
/** 收到这条消息的会话所属的网 + 对端 hostId(由 relay 侧鉴权后的会话表给出,⛔ 不信载荷)。 */
from: { network: string; hostId: string }
/** 本机在这张网里的 hostId。 */
selfHostId: string
/** 地址条数上限(缺省 {@link DIRECT_CAND_MAX_ADDRS},权威值在参数表)。 */
maxAddrs?: number
/** 新鲜期(缺省 {@link DIRECT_CAND_TTL_MS})。 */
ttlMs?: number
now?: number
}
/**
* **准入判定**(判据 `D1`)。
*
* 顺序刻意如此(**先安全后形状**会掩盖错配:"跨网"的证据在 `from.network` 与载荷的对比里):
* ① 形状(`bad-shape` / `secret-field`)
* ② 网一致(`cross-network`)—— 🔴 安全:⛔ 绝不让一张网里的会话申报另一张网的落点
* ③ **只准申报自己**(`not-self-candidate`)—— 🔴 安全:⛔ 不许替第三人申报地址
* ④ 双向白名单(`not-a-dialer` / `self-not-a-dialer`)—— **复用** `isAllowedDialer`
* ⑤ 地址形状与条数(`bad-address` / `too-many-addrs`)
* ⑥ 新鲜期(`stale`)
*/
export function admitCandidate(raw: string, ctx: CandidateContext): CandidateVerdict {
const maxAddrs = ctx.maxAddrs ?? DIRECT_CAND_MAX_ADDRS
const ttlMs = ctx.ttlMs ?? DIRECT_CAND_TTL_MS
const now = ctx.now ?? Date.now()
// ① 形状(含凭据字段扫描 —— 扫描在**归一之前**,坏形状也不能夹带)
let json: unknown
try {
json = JSON.parse(raw)
} catch (err) {
return { ok: false, reason: 'bad-shape', detail: `JSON 解析失败:${err instanceof Error ? err.message : String(err)}` }
}
const secret = findSecretField(json)
if (secret !== undefined) {
return { ok: false, reason: 'secret-field', detail: `载荷含疑似凭据字段 ${secret}(⛔ 候选只准含地址与端口)` }
}
const msg = normalizeDirectMessage(json)
if (msg === undefined) {
return { ok: false, reason: 'bad-shape', detail: `不是 ${DIRECT_MESSAGE_KIND} 形态(须含 hostId / network / addrs[非空]{host,port})` }
}
// ② 网一致(跨网 ⇒ 结构性拒绝)
if (msg.network !== ctx.from.network) {
return {
ok: false,
reason: 'cross-network',
detail: `载荷申报的网 "${msg.network}" ≠ 会话所属的网 "${ctx.from.network}"`,
}
}
// ③ 只准申报自己(⛔ 替第三人申报 = 冒名,会变成"借别人的手把流量引到目标")
if (msg.hostId !== ctx.from.hostId) {
return {
ok: false,
reason: 'not-self-candidate',
detail: `载荷申报 ${logicalName(msg.network, msg.hostId)},而会话持有的身份是 ${logicalName(ctx.from.network, ctx.from.hostId)}(⛔ 只准申报自己)`,
}
}
// ④ 双向白名单(**复用** network.ts#isAllowedDialer —— ⛔ 不另写一份)
if (!isAllowedDialer(ctx.dialers, ctx.from.network, ctx.from.hostId)) {
return {
ok: false,
reason: 'not-a-dialer',
detail: `对端 ${logicalName(ctx.from.network, ctx.from.hostId)} 不在网 "${ctx.from.network}" 的拨号方白名单里(默认拒绝)`,
}
}
if (!isAllowedDialer(ctx.dialers, ctx.from.network, ctx.selfHostId)) {
return {
ok: false,
reason: 'self-not-a-dialer',
detail: `本机 ${logicalName(ctx.from.network, ctx.selfHostId)} 不在网 "${ctx.from.network}" 的拨号方白名单里 ⇒ 建不成直连`,
}
}
// ⑤ 地址形状与条数
if (msg.addrs.length > maxAddrs) {
return { ok: false, reason: 'too-many-addrs', detail: `候选地址 ${msg.addrs.length} 条 > 上限 ${maxAddrs}` }
}
for (const a of msg.addrs) {
if (!isValidAddress(a)) {
return { ok: false, reason: 'bad-address', detail: `非法候选地址 ${JSON.stringify(a)}(须 IPv4/IPv6 字面量 + 端口 1–65535)` }
}
}
// ⑥ 新鲜期(`ts = 0` ⇒ 未标注 ⇒ 不判 —— ⛔ 但也不当"新鲜"混过去:detail 里写明)
if (msg.ts > 0 && ttlMs > 0 && now - msg.ts > ttlMs) {
return { ok: false, reason: 'stale', detail: `候选已过期 ${now - msg.ts} ms > 新鲜期 ${ttlMs} ms` }
}
return { ok: true, message: msg }
}
/** 计数的快照(观测面与探针都取这一份)。 */
export interface CandidateLedgerSnapshot {
/** 收到的消息总数(**进过判定**的)。 */
received: number
accepted: number
rejected: { reason: CandidateReason; detail: string }[]
/**
* 🔴 **静默拒绝数**(本线老病根的可断言面)。
*
* 恒等式 = `received - accepted - rejected.length`;**按构造它必须是 0** —— 它不是"统计",
* 而是一道**不变量守卫**:将来谁加了一条 `return {ok:false}` 却忘了记账,它立刻非零。
* ⛔ 探针见到非零 ⇒ **FAIL 并点名**(静默返空 = 判据全绿而功能全废)。
*/
silentRejections: number
/** 拒绝原因的分布(`reason → 条数`,便于一眼看出"全是同一类")。 */
byReason: Record<string, number>
}
/**
* 候选收发的记账器。
*
* ⚠️ **每条进路都必须记账**:`receive()` 先记"收到",之后要么 `accept()` 要么 `reject()`。
* 判据看 {@link CandidateLedgerSnapshot.silentRejections} 是否为零。
*/
export class CandidateLedger {
private received = 0
private accepted = 0
private rejected: { reason: CandidateReason; detail: string }[] = []
/** 收到一条(**必须在判定之前**调用)。 */
receive(): void {
this.received += 1
}
/** 接受一条。 */
accept(): void {
this.accepted += 1
}
/** 拒绝一条(**必须带原因**,⛔ 不许只计数不写原因)。 */
reject(reason: CandidateReason, detail: string): void {
this.rejected.push({ reason, detail })
}
/**
* 判定 + 记账的**唯一入口**(调用方不需要记得先 `receive()`)。
*
* ⇒ "忘了记账"这件事在**接口层**就已经不可能 —— 这是比"靠自觉"更强的形态。
*/
judge(raw: string, ctx: CandidateContext): CandidateVerdict {
this.receive()
const verdict = admitCandidate(raw, ctx)
if (verdict.ok) this.accept()
else this.reject(verdict.reason, verdict.detail)
return verdict
}
snapshot(): CandidateLedgerSnapshot {
const byReason: Record<string, number> = {}
for (const r of this.rejected) byReason[r.reason] = (byReason[r.reason] ?? 0) + 1
return {
received: this.received,
accepted: this.accepted,
rejected: this.rejected.map((r) => ({ ...r })),
silentRejections: this.received - this.accepted - this.rejected.length,
byReason,
}
}
}
+356
View File
@@ -0,0 +1,356 @@
/**
* 覆盖网络 **直连(打洞)总装配**(序㊵ · P2 · S5)—— 开关 + 默认值 + 提示 + 降级编排。
*
* ## 用户口径(原话,⛔ 别改写成技术题)
* ```
* 1 用户可设置,默认开启提示用户 2 乙
* ```
* ⇒ 本模块是那三件事的**唯一落点**:
*
* | # | 口径 | 落在这里的什么 |
* |---|---|---|
* | ① | **用户可设置** | {@link resolveDirectSwitch}:env(运维强制)> 节点本地配置(用户设置)> **缺省**;{@link writeNodeConfigDirect} 是"用户改"的写入口 |
* | ② | **默认开启** | {@link DEFAULT_DIRECT_ENABLED} = `true`(⛔ 不是"缺省关、用户主动开") |
* | ③ | **提示用户** | {@link DIRECT_HINT_PARTS}(三段:**谁会连进来 / 怎么关 / 关掉不影响什么**) |
*
* ## 🔴 开关的三种状态,⛔ 没有第四种
* `enabled: true` | `enabled: false` | `enabled: null`(**取值非法** —— ⛔ **不许**静默当开、
* ⛔ 也不许静默当关;调用方必须**具名报错**)。本线所有"配置错长得像网络不通"的事故,
* 第一步都是"给个缺省值糊过去"。
*
* ## 降级编排(关闭 / 打洞失败 ⇒ 全部回落中继)
* ⛔ **关闭不是功能降级**:P1 已交付的「一键加入 + 分组准入」照旧完整可用,只是跨机流量走中继。
* 回落必须是**具名**的(`disabled` / `cooldown` / `no-address` / `one-way` / `deadline`),
* ⛔ 不许出现"没有原因地走了中继"。
*
* @module dshs/net/relay/direct
*/
import {
CandidateLedger,
DIRECT_CAND_MAX_ADDRS,
DIRECT_CAND_TTL_MS,
type CandidateContext,
type CandidateLedgerSnapshot,
type CandidateReason,
type CandidateVerdict,
type DirectAddress,
} from './candidate.js'
import {
DEFAULT_DIRECT_COOLDOWN_MS,
DEFAULT_PUNCH_DEADLINE_MS,
DirectCooldown,
PUNCH_PORT_BASE,
PUNCH_PORT_SPAN,
pickPunchPort,
runPunchAttempt,
type PunchAttempt,
type PunchReason,
} from './punch.js'
/** 总开关的 env 键名(用户口径①)。⚠️ **缺省即可用**(缺省 = 开)⇒ 不写进任何 drop-in 也生效。 */
export const DIRECT_ENV_KEY = 'DSHS_OVERLAY_DIRECT'
/** 用户口径②:**默认开启**。⛔ 改它 = 改用户拍过的口径。 */
export const DEFAULT_DIRECT_ENABLED = true
/** 显式**开**的取值(大小写不敏感)。 */
export const DIRECT_ON_VALUES = ['1', 'true', 'on', 'yes'] as const
/** 显式**关**的取值(大小写不敏感)。 */
export const DIRECT_OFF_VALUES = ['0', 'false', 'off', 'no'] as const
/** 节点本地配置的缺省落点(与 `join.ts` 的 `--config` 缺省一致;⛔ 两处必须是同一个值)。 */
export const NODE_CONFIG_FILE_DEFAULT = '/etc/dshs/overlay-node.json'
/** 开关解析结果。 */
export interface DirectSwitchState {
/** 键名(便于日志与观测面**原样**说出"是哪个键在起作用")。 */
envKey: string
/** env 原文(未设 ⇒ `null`)。 */
raw: string | null
/** 生效来源 —— ⛔ 三态必须能分开(否则"为什么它是开的"永远说不清)。 */
source: 'env' | 'node-config' | 'default'
/** `null` = **取值非法**(⛔ 不许当开也不许当关)。 */
enabled: boolean | null
/** 非法时的人读原因(`enabled !== null` 时为空串)。 */
invalid: string
/** 缺省值(便于观测面自证"缺省 = 开")。 */
defaultEnabled: boolean
}
/**
* 解析总开关(**唯一解析点**)。
*
* 优先级:env(显式设了就以它为准)> 节点本地配置的 `direct` > **缺省 = 开**。
* ⚠️ 为什么 env 优先:它是**运维通道**("这台机器强制不直连"必须能压过用户设置);
* 用户改的是**本地配置**({@link writeNodeConfigDirect})—— 两条路各自有明确的主人。
*/
export function resolveDirectSwitch(
env: Readonly<Record<string, string | undefined>>,
opts: { localDirect?: boolean; envKey?: string } = {},
): DirectSwitchState {
const envKey = opts.envKey ?? DIRECT_ENV_KEY
const defaultEnabled = DEFAULT_DIRECT_ENABLED
const raw = env[envKey]
const base = { envKey, defaultEnabled }
if (raw !== undefined && raw.trim() !== '') {
const v = raw.trim().toLowerCase()
if ((DIRECT_ON_VALUES as readonly string[]).includes(v)) {
return { ...base, raw: raw.trim(), source: 'env', enabled: true, invalid: '' }
}
if ((DIRECT_OFF_VALUES as readonly string[]).includes(v)) {
return { ...base, raw: raw.trim(), source: 'env', enabled: false, invalid: '' }
}
return {
...base,
raw: raw.trim(),
source: 'env',
enabled: null,
invalid: `${envKey}=${JSON.stringify(raw.trim())} 既不在开集 ${DIRECT_ON_VALUES.join('/')} 也不在关集 ${DIRECT_OFF_VALUES.join('/')} ⇒ ⛔ 不静默取缺省`,
}
}
if (typeof opts.localDirect === 'boolean') {
return { ...base, raw: null, source: 'node-config', enabled: opts.localDirect, invalid: '' }
}
return { ...base, raw: null, source: 'default', enabled: defaultEnabled, invalid: '' }
}
/** 提示文案的三段(**顺序即语义**,⛔ 别合并成一段 —— 探针按段数判)。 */
export const DIRECT_HINT_PARTS = [
`① 开启后,**谁**可能直连到这台机器:只有**同一张覆盖网里、且双向都在拨号白名单**里的节点;⛔ 公网任意源、⛔ 跨网节点都连不进来。`,
`② 怎么关:给本机设 ${DIRECT_ENV_KEY}=0(或 false/off/no)后重启节点服务;也可以在管理面把本机配置里的 direct 改成 false。关闭后**不再绑定任何 UDP 端口**、也**不再发送直连候选**。`,
`③ 关掉会影响什么:⛔ 不影响接入与准入 —— 一键加入、分组白名单、跨机取块**照旧可用**,跨机流量改走中继;代价只是"多一跳中继"(延迟与中继带宽略升)。`,
] as const
/** 三段提示(数组形态,便于管理面渲染)。 */
export function directHintLines(): string[] {
return [...DIRECT_HINT_PARTS]
}
/** 三段提示(一行文本形态,供 CLI `stdout` 打印)。 */
export function directHintText(): string {
return DIRECT_HINT_PARTS.join('\n')
}
/** 降级/拒绝原因的**统一口径**(⛔ 不许出现"没有原因地走了中继")。 */
export type DirectRefusal = 'disabled' | 'invalid-switch' | PunchReason | CandidateReason
/** 直连总装(**只读观测面 + 编排**;⛔ 它不做传输、不做接线 —— 那是 S6)。 */
export class DirectPath {
readonly ledger = new CandidateLedger()
readonly cooldown: DirectCooldown
private readonly switchState: DirectSwitchState
private readonly portBase: number
private readonly portSpan: number
private readonly deadlineMs: number
private readonly maxAddrs: number
private readonly ttlMs: number
private udpSocketsOpened = 0
private candidatesEmitted = 0
private punchOk = 0
private punchDead = 0
private last?: PunchAttempt
constructor(opts: {
/** 开关解析结果(**必传** —— 避免"忘了解析就用上真值")。 */
switchState: DirectSwitchState
cooldownMs?: number
portBase?: number
portSpan?: number
deadlineMs?: number
maxAddrs?: number
ttlMs?: number
}) {
this.switchState = opts.switchState
this.cooldown = new DirectCooldown(opts.cooldownMs ?? DEFAULT_DIRECT_COOLDOWN_MS)
this.portBase = opts.portBase ?? PUNCH_PORT_BASE
this.portSpan = opts.portSpan ?? PUNCH_PORT_SPAN
this.deadlineMs = opts.deadlineMs ?? DEFAULT_PUNCH_DEADLINE_MS
this.maxAddrs = opts.maxAddrs ?? DIRECT_CAND_MAX_ADDRS
this.ttlMs = opts.ttlMs ?? DIRECT_CAND_TTL_MS
}
/** 生效值:`true` / `false` / `null`(非法)。⛔ 调用方**必须**处理 `null`。 */
get enabled(): boolean | null {
return this.switchState.enabled
}
/** 候选准入(**关闭 ⇒ 一条都不判、一条都不发**)。 */
offerCandidate(raw: string, ctx: Omit<CandidateContext, 'maxAddrs' | 'ttlMs'>): CandidateVerdict | { ok: false; reason: DirectRefusal; detail: string } {
if (this.switchState.enabled !== true) {
return {
ok: false,
reason: this.switchState.enabled === false ? 'disabled' : 'invalid-switch',
detail:
this.switchState.enabled === false
? `${DIRECT_ENV_KEY} 关闭(来源 ${this.switchState.source})⇒ ⛔ 不发候选、⛔ 不开 UDP 口`
: this.switchState.invalid,
}
}
this.candidatesEmitted += 1
return this.ledger.judge(raw, { ...ctx, maxAddrs: this.maxAddrs, ttlMs: this.ttlMs })
}
/**
* 尝试建立直连(**关闭 ⇒ 零 socket**)。
*
* ⛔ 这里**只做探测**:成功与否都不改变"能不能用中继"这件事(回落是无条件的)。
*/
async attempt(
peer: string,
targets: readonly DirectAddress[],
io: { sleep: (ms: number) => Promise<void> },
offset = 0,
): Promise<PunchAttempt | { ok: false; reason: DirectRefusal; detail: string }> {
if (this.switchState.enabled !== true) {
return {
ok: false,
reason: this.switchState.enabled === false ? 'disabled' : 'invalid-switch',
detail:
this.switchState.enabled === false
? `${DIRECT_ENV_KEY} 关闭(来源 ${this.switchState.source})⇒ ⛔ 不打洞、⛔ 零 UDP socket,直接用中继`
: this.switchState.invalid,
}
}
const r = await runPunchAttempt(
{
peer,
selfPort: pickPunchPort(this.portBase, this.portSpan, offset),
targets,
deadlineMs: this.deadlineMs,
cooldown: this.cooldown,
onSocketOpen: () => {
this.udpSocketsOpened += 1
},
},
io,
)
this.last = r
if (r.ok) this.punchOk += 1
else if (r.reason !== 'cooldown' && r.reason !== 'no-address') this.punchDead += 1
return r
}
/** **只读观测面**(探针 / 管理面 / 日志共用这一份 —— ⛔ 别各写一套)。 */
status(): {
envKey: string
enabled: boolean | null
defaultEnabled: boolean
source: DirectSwitchState['source']
raw: string | null
invalid: string
hint: string[]
portBase: number
portSpan: number
deadlineMs: number
maxAddrs: number
cooldownMs: number
counters: {
udpSocketsOpened: number
candidatesEmitted: number
punchOk: number
punchDead: number
cooldownBlocked: number
candidateSilentRejections: number
}
ledger: CandidateLedgerSnapshot
last: PunchAttempt | null
} {
const snap = this.cooldown.snapshot()
const led = this.ledger.snapshot()
return {
envKey: this.switchState.envKey,
enabled: this.switchState.enabled,
defaultEnabled: this.switchState.defaultEnabled,
source: this.switchState.source,
raw: this.switchState.raw,
invalid: this.switchState.invalid,
hint: directHintLines(),
portBase: this.portBase,
portSpan: this.portSpan,
deadlineMs: this.deadlineMs,
maxAddrs: this.maxAddrs,
cooldownMs: snap.ms,
counters: {
udpSocketsOpened: this.udpSocketsOpened,
candidatesEmitted: this.candidatesEmitted,
punchOk: this.punchOk,
punchDead: this.punchDead,
cooldownBlocked: snap.blocked,
candidateSilentRejections: led.silentRejections,
},
ledger: led,
last: this.last ?? null,
}
}
}
/** 从节点本地配置里读 `direct`(`undefined` = 没写这一项 ⇒ 走缺省)。 */
export function directFromNodeConfig(raw: unknown): boolean | undefined {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
const v = (raw as Record<string, unknown>).direct
return typeof v === 'boolean' ? v : undefined
}
/** 读一读节点本地配置(**形状不对 ⇒ 抛**;文件不存在 ⇒ `undefined`)。 */
export function readNodeConfig(file: string, io: { exists: (p: string) => boolean; read: (p: string) => string }): Record<string, unknown> | undefined {
if (!io.exists(file)) return undefined
const parsed: unknown = JSON.parse(io.read(file))
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${file} 不是 JSON 对象(⛔ 不当作"没配置"静默放过)`)
}
return parsed as Record<string, unknown>
}
/** 写回结果(**失败必须具名** —— 与 `join.ts` 同一条纪律)。 */
export type NodeConfigWriteOutcome =
| { ok: true; file: string; direct: boolean }
| { ok: false; reason: 'node-config-missing' | 'node-config-bad' | 'node-config-unwritable'; detail: string }
/**
* 把 `direct` 写进节点本地配置(**用户口径①「用户可设置」的写入口**)。
*
* 🔴 三条纪律:
* ① **只改 `direct` 一个字段**(其余字段原样保留 —— ⛔ 不做"顺手规范化");
* ② **文件不存在 ⇒ 具名失败**(`node-config-missing`)—— ⛔ 不静默创建:本地配置的存在本身就意味着
* "这台机器跑过 join",凭空造一个会让节点看起来"加入过";
* ③ **原子写 + `0600`**(先写 `<file>.tmp` 再 `rename`)—— 半截文件会让节点**起不来**。
*/
export function writeNodeConfigDirect(
file: string,
enabled: boolean,
io: {
exists: (p: string) => boolean
read: (p: string) => string
write: (p: string, text: string, mode: number) => void
rename: (from: string, to: string) => void
},
): NodeConfigWriteOutcome {
let current: Record<string, unknown> | undefined
try {
current = readNodeConfig(file, io)
} catch (err) {
return { ok: false, reason: 'node-config-bad', detail: `${file}:${err instanceof Error ? err.message : String(err)}` }
}
if (current === undefined) {
return {
ok: false,
reason: 'node-config-missing',
detail: `${file} 不存在 ⇒ ⛔ 不凭空创建(本机还没跑过 join)`,
}
}
const next = { ...current, direct: enabled }
const tmp = `${file}.tmp`
try {
io.write(tmp, `${JSON.stringify(next, null, 2)}\n`, 0o600)
io.rename(tmp, file)
} catch (err) {
return {
ok: false,
reason: 'node-config-unwritable',
detail: `${file}:${err instanceof Error ? err.message : String(err)}`,
}
}
return { ok: true, file, direct: enabled }
}
+586
View File
@@ -0,0 +1,586 @@
/**
* 覆盖网络 **UDP 打洞探测**(序㊵ · P2 · S5)。
*
* ## 它做什么(照抄成熟做法的最小集,⛔ 不发明)
* ① 本机绑一个 **UDP 口**(端口从**参数表区间**取:`PUNCH_PORT_BASE` + `PUNCH_PORT_SPAN`);
* ② **双方同时**向对方候选地址发包(间隔 {@link PUNCH_PROBE_INTERVAL_MS},持续到 deadline);
* ③ `PUNCH_DEADLINE_MS` 内**收到对方的包** ⇒ 该方向成立;
* ④ 🔴 **双向都成立**才判直连可用;否则 ⇒ **判死**(`one-way` / `deadline`);
* ⑤ 判死后:**立即降级中继** + 该候选进**冷却**(复用本线既有冷却纪律,⛔ 不自造一套)。
*
* ## 🔴 三条纪律
* | # | 纪律 | 为什么 |
* |---|---|---|
* | ① | 只用 Node 内建 `dgram`,⛔ **不引入新依赖** | 本仓既有口径:`addr-override.ts` 头注「本项目**不引入 `undici` / `ws`**」 |
* | ② | ⛔ **用户态**、⛔ 无内核驱动、⛔ 无虚拟网卡 | 参数表 §8-① 已把"虚拟网卡"收窄为**不做**;要打洞也只走用户态 UDP |
* | ③ | 冷却**必须非零**(`ms <= 0` ⇒ 构造即抛) | `COOLDOWN=0` ⇒ "判死"退化成**重试风暴**;本线已把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写成硬禁令 |
*
* ## 🧪 离线夹具(文件末段 · ⛔ 不进生产接线路径)
* 打洞的**成功路径**需要"两台机器 + 两个会做 NAT 映射的网络"。云上那条真机腿被**云安全组**挡着
* (见 `交接单_覆盖网络直连与P2P_20260918.md` §2-3),⇒ 本模块自带一个
* **NAT 模拟器**({@link SimulatedNat} + {@link runPunchPair}):它用**真的 `dgram` socket**
* 跑**同一份打洞代码**,只是把"两跳 NAT"用回环上的四个 socket + 两份映射表来表现。
* ⇒ 机器断言证明的是"**这段打洞逻辑**在 NAT 穿透场景下成立",⛔ **不是**"公网一定能打洞"。
*
* @module dshs/net/relay/direct/punch
*/
import { createSocket, type Socket } from 'node:dgram'
import { isIpv6, type DirectAddress } from './candidate.js'
/** 打洞 socket 的**端口基址**(沿用序⑥ S4 观察器用过的 21100 段 ⇒ ⛔ 不新造魔数)。 */
export const PUNCH_PORT_BASE = 21100
/** 打洞端口区间跨度 ⇒ `[21100, 21116)`。⚠️ **UDP** 口,与 `LISTEN_ALLOWED_RANGES`(TCP)**不同族**。 */
export const PUNCH_PORT_SPAN = 16
/** 单次探测的**重发间隔**(双方同时发包,直到 deadline)。 */
export const PUNCH_PROBE_INTERVAL_MS = 150
/** 收包窗口缺省值(真值在参数表 `PUNCH_DEADLINE_MS`)。 */
export const DEFAULT_PUNCH_DEADLINE_MS = 3_000
/** 判死后的冷却缺省值(真值在参数表 `DIRECT_COOLDOWN_MS`)。🔴 **必须非零**。 */
export const DEFAULT_DIRECT_COOLDOWN_MS = 300_000
/** 探测结论(**具名**,⛔ 不收自由文本)。 */
export type PunchReason =
/** 双向都成立 ⇒ 直连可用。 */
| 'ok'
/** 一个方向成立、另一个不成立 ⇒ **判死**(⛔ 单向不算直连)。 */
| 'one-way'
/** 窗内一个包都没收到 ⇒ **判死**。 */
| 'deadline'
/** 该对端在冷却里 ⇒ 本次**连 socket 都不开**。 */
| 'cooldown'
/** 没有可用候选地址(⛔ 不是"打洞失败"⇒ ⛔ 不进冷却)。 */
| 'no-address'
/** 绑定失败(具名带上原始错误)。 */
| 'socket-error'
/** 一次探测的完整读数(**成功与失败都出这一份** ⇒ 观测面不用猜)。 */
export interface PunchAttempt {
/** 对端逻辑名(`<network>/<hostId>`,冷却按它做键)。 */
peer: string
ok: boolean
reason: PunchReason
/** 本机收到的包数(**本方向**是否成立)。 */
recvLocal: number
/** 对端是否收到我们的包(真机经 relay 通道回报;离线夹具由 NAT 模拟给出)。 */
peerSeen: boolean
/** 🔴 双向都成立(`recvLocal > 0 && peerSeen`)—— **只有它为真才算直连可用**。 */
bidirectional: boolean
sent: number
elapsedMs: number
at: number
/** 回包来源(`ip:port`,升序)—— **"谁回的"必须看得见**(真机腿靠它区分"对端回的"与"别的什么东西回的")。 */
sources: string[]
/** 失败时的人读原因(⛔ 不许只回 `false`)。 */
detail: string
}
/**
* 候选地址的**冷却表**(判死后才写入)。
*
* 🔴 `ms` **必须 > 0**:`0` 会让"判死"退化成"每次重试都真打一遍" = 重试风暴
* (本线把 `RELAY_FAILOVER_COOLDOWN_MS=0` 列为硬禁令,同一条纪律在这里落地为**构造期断言**)。
*/
export class DirectCooldown {
readonly ms: number
private until = new Map<string, number>()
private blockedCount = 0
constructor(ms: number = DEFAULT_DIRECT_COOLDOWN_MS) {
if (!Number.isFinite(ms) || ms <= 0) {
throw new Error(
`直连冷却时长必须 > 0,收到 ${JSON.stringify(ms)}(🔴 0 ⇒ 判死退化成重试风暴,本线硬禁令)`,
)
}
this.ms = Math.floor(ms)
}
/** 该对端是否还在冷却里(**命中即计数** —— 观测面上要看得见"被挡了几次")。 */
blocked(peer: string, now: number = Date.now()): boolean {
const until = this.until.get(peer)
if (until === undefined) return false
if (until <= now) {
this.until.delete(peer)
return false
}
this.blockedCount += 1
return true
}
/** 记一次判死 ⇒ 进入冷却。 */
noteDead(peer: string, now: number = Date.now()): void {
this.until.set(peer, now + this.ms)
}
/** 成功 ⇒ 撤销冷却(**只有成功才撤**,⛔ 别拿"尝试过"当成功)。 */
clear(peer: string): void {
this.until.delete(peer)
}
snapshot(now: number = Date.now()): { ms: number; blocked: number; cooling: string[] } {
const cooling: string[] = []
for (const [peer, until] of this.until) if (until > now) cooling.push(peer)
return { ms: this.ms, blocked: this.blockedCount, cooling: cooling.sort() }
}
}
/** 一个**已绑定的** UDP 探测口(收发包 + 计数)。⛔ 只做收发,判定在上层。 */
export class PunchSocket {
private sock?: Socket
private packets = 0
private readonly senders = new Set<string>()
private lastError = ''
/** 已成功绑定的端口(`0` = 尚未绑定)。 */
port = 0
constructor(private readonly host = '0.0.0.0') {}
/** 绑定(`port = 0` ⇒ 由内核分配;真机路径给参数表区间里的口)。 */
open(port: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const family = isIpv6(this.host) ? 'udp6' : 'udp4'
const sock = createSocket({ type: family, reuseAddr: false })
const fail = (err: Error): void => {
this.lastError = err.message
try {
sock.close()
} catch {
// 已经关掉了 ⇒ 无事可做(⛔ 但**不吞**:原始错误在 `lastError` 里)
}
reject(err)
}
sock.once('error', fail)
sock.on('message', (_msg, rinfo) => {
this.packets += 1
this.senders.add(`${rinfo.address}:${rinfo.port}`)
})
sock.bind({ port, address: this.host }, () => {
sock.off('error', fail)
// 绑定之后的错误(如 ICMP 端口不可达)只记账,⛔ 不让它把进程炸掉
sock.on('error', (err) => {
this.lastError = err.message
})
const addr = sock.address()
this.port = typeof addr === 'object' && addr !== null ? addr.port : 0
this.sock = sock
resolve()
})
})
}
/** 向若干候选地址各发一包。返回**实际发出**的包数。 */
send(targets: readonly DirectAddress[], payload = 'dshs-punch'): number {
const sock = this.sock
if (sock === undefined) return 0
let sent = 0
for (const t of targets) {
try {
sock.send(Buffer.from(payload, 'utf8'), t.port, t.host)
sent += 1
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err)
}
}
return sent
}
/** 本机收到的包数(截至此刻)。 */
received(): number {
return this.packets
}
/** 收到过包的对端来源(`ip:port`,升序)。 */
sources(): string[] {
return [...this.senders].sort()
}
errorText(): string {
return this.lastError
}
close(): void {
const sock = this.sock
this.sock = undefined
if (sock === undefined) return
try {
sock.close()
} catch {
// 关两次 ⇒ 忽略;本类是**唯一**关闭点,正常路径不会走到这
}
}
}
/** 探测入参。⛔ 所有阈值**都是显式传入的**(真值在参数表,模块里只有缺省)。 */
export interface PunchOptions {
/** 对端逻辑名(冷却键)。 */
peer: string
/** 本机要绑的 UDP 口(来自 {@link pickPunchPort};`0` = 内核分配,夹具用)。 */
selfPort: number
/** 对端候选地址(**打洞的目标**)。 */
targets: readonly DirectAddress[]
deadlineMs?: number
probeIntervalMs?: number
/** 冷却表(**必传** —— 冷却纪律是判死的一部分,⛔ 不许可选)。 */
cooldown: DirectCooldown
/**
* 对端是否收到我们的包(真机 = 经 relay 通道回报;离线夹具 = NAT 模拟器给出)。
* ⛔ 缺省 = `() => false`(= 只看见自己的方向 ⇒ 判 `one-way`)。**刻意不给"乐观缺省"**:
* 拿不到对端证据时**判不上直连**,而不是"先当成功"。
*/
peerSeen?: () => boolean
/** 本机绑定地址(缺省 `0.0.0.0`;离线夹具用 `127.0.0.1`)。 */
bindHost?: string
now?: number
/** 观测钩子:每绑一次 UDP socket 调一次(探针的"关闭 ⇒ 零 socket"靠它计数)。 */
onSocketOpen?: () => void
/** 测试钩子:拿到刚绑好的 socket(离线夹具用它读**对端**的实时收包数)。 */
onSocket?: (sock: PunchSocket) => void
}
/**
* 跑一次打洞探测(**唯一执行点**)。
*
* 顺序:冷却闸门 → 候选闸门 → 绑口 → 重发到 deadline(**每轮都重判双向**)→ 判定 → 判死/撤冷却
* → **必定关口**。⛔ 提前返回的两条路径(冷却 / 无候选)**一个 socket 都不开**。
*/
export async function runPunchAttempt(
opts: PunchOptions,
io: { sleep: (ms: number) => Promise<void> },
): Promise<PunchAttempt> {
const now = opts.now ?? Date.now()
const deadlineMs = opts.deadlineMs ?? DEFAULT_PUNCH_DEADLINE_MS
const interval = opts.probeIntervalMs ?? PUNCH_PROBE_INTERVAL_MS
const base: Omit<PunchAttempt, 'ok' | 'reason' | 'detail'> = {
peer: opts.peer,
recvLocal: 0,
peerSeen: false,
bidirectional: false,
sent: 0,
elapsedMs: 0,
at: now,
sources: [],
}
// ① 冷却闸门(⛔ 连 socket 都不开)
if (opts.cooldown.blocked(opts.peer, now)) {
return { ...base, ok: false, reason: 'cooldown', detail: `在冷却中(${opts.cooldown.ms} ms)⇒ 本次不打,沿用中继` }
}
// ② 候选闸门(⛔ 不是"打洞失败" ⇒ 不进冷却)
if (opts.targets.length === 0) {
return { ...base, ok: false, reason: 'no-address', detail: '没有可用候选地址 ⇒ 不打洞(⛔ 不进冷却)' }
}
const sock = new PunchSocket(opts.bindHost ?? '0.0.0.0')
const started = Date.now()
try {
await sock.open(opts.selfPort)
opts.onSocketOpen?.()
opts.onSocket?.(sock)
} catch (err) {
return {
...base,
ok: false,
reason: 'socket-error',
detail: `绑定 UDP ${opts.selfPort} 失败:${err instanceof Error ? err.message : String(err)}`,
elapsedMs: Date.now() - started,
}
}
const peerSeenOf = opts.peerSeen ?? ((): boolean => false)
try {
// ③ 双方同时发包:按 interval 重发,每轮**重判双向**(任一方向一旦成立就可以停了)
let sent = 0
const deadlineAt = started + deadlineMs
let recvLocal = 0
let peerSeen = false
for (;;) {
sent += sock.send(opts.targets)
recvLocal = sock.received()
peerSeen = peerSeenOf()
if (recvLocal > 0 && peerSeen) break
const remain = deadlineAt - Date.now()
if (remain <= 0) break
await io.sleep(Math.min(interval, remain))
}
recvLocal = sock.received()
peerSeen = peerSeenOf()
const bidirectional = recvLocal > 0 && peerSeen
const elapsedMs = Date.now() - started
if (bidirectional) {
opts.cooldown.clear(opts.peer)
return {
...base,
ok: true,
reason: 'ok',
recvLocal,
peerSeen,
bidirectional: true,
sent,
elapsedMs,
sources: sock.sources(),
detail: `直连成立:本方向收 ${recvLocal} 包 + 对端确认收到 ⇒ 双向 ✅(耗时 ${elapsedMs} ms,发 ${sent} 包)`,
}
}
const reason: PunchReason = recvLocal === 0 && !peerSeen ? 'deadline' : 'one-way'
opts.cooldown.noteDead(opts.peer, started)
return {
...base,
ok: false,
reason,
recvLocal,
peerSeen,
bidirectional: false,
sent,
elapsedMs,
sources: sock.sources(),
detail:
reason === 'one-way'
? `单向(本方向收 ${recvLocal} 包 / 对端确认=${peerSeen})⇒ 判死 + 进冷却 ${opts.cooldown.ms} ms`
: `窗内零收包(deadline ${deadlineMs} ms,实耗 ${elapsedMs} ms,发 ${sent} 包)⇒ 判死 + 进冷却 ${opts.cooldown.ms} ms`,
}
} finally {
sock.close()
}
}
/**
* 从参数表区间里挑一个口(**取模上扫**;真机路径用它给 `selfPort`)。
*
* ⛔ 不写死单个端口:两台 worker 可能各自打洞,踩同一口会互相干扰;
* 区间由 `PUNCH_PORT_BASE` / `PUNCH_PORT_SPAN` 给出。
*/
export function pickPunchPort(base: number = PUNCH_PORT_BASE, span: number = PUNCH_PORT_SPAN, offset = 0): number {
const n = Math.max(1, Math.floor(span))
return base + (Math.abs(Math.floor(offset)) % n)
}
/* ══════════════════════════════════════════════════════════════════════════════════════
* 🧪 离线夹具(⛔ **不进生产接线路径**)
*
* 打洞的**成功路径**在云上被安全组挡着,所以这一段的用途只有一个:
* **用真的 `dgram` socket 跑同一份打洞代码**,把"两跳 NAT + 双方同时发包 ⇒ 打穿"这件事
* 变成机器可断言的读数。⛔ 它**不改**打洞逻辑(`runPunchAttempt` 一字未动),只替换"网络长什么样"。
* ══════════════════════════════════════════════════════════════════════════════════════ */
/** NAT 模拟器的形态开关(每个开关对应一条真实的失败模式)。 */
export interface SimulatedNatOptions {
/**
* `true` ⇒ 即使有映射也**丢弃入向包**(模拟"对称 NAT / 单向不可达" ⇒ 只能出、不能进)。
* 用于产出 `one-way` 这条负腿。
*/
dropInbound?: boolean
}
/** NAT 模拟器的计数(观测面用 —— ⛔ 只读)。 */
export interface SimNatCounters {
fromNode: number
fromPeer: number
forwardedIn: number
droppedNoMapping: number
droppedByPolicy: number
}
/**
* 一台**模拟 NAT**(= 两个回环 UDP socket + 一份"出过向才放行入向"的映射表)。
*
* 语义(真 NAT 的**最小充分**语义):
* - 节点把包发给 {@link gateway}(内侧门牌 = `innerSocket` 的口);
* - NAT 见**内侧来的包** ⇒ 记映射({@link isMapped} 变真)+ 从 `outerSocket`(外侧门牌)转给对端 NAT;
* - NAT 收**对端 NAT 来的包** ⇒ **只有已建映射才**从内侧门牌交回节点(无映射 ⇒ **丢**,并计数)。
*
* 🔴 `dropInbound` 只影响第三条(= 单向不可达)。
*/
export class SimulatedNat {
private innerSock?: Socket
private outerSock?: Socket
private nodePort = 0
private mapped = false
private readonly dropInbound: boolean
readonly counters: SimNatCounters = {
fromNode: 0,
fromPeer: 0,
forwardedIn: 0,
droppedNoMapping: 0,
droppedByPolicy: 0,
}
/** 对端 NAT 的**外侧门牌**(两侧都建好之后回填;真网里靠候选交换得到)。 */
peerAddress?: DirectAddress
constructor(opts: SimulatedNatOptions = {}) {
this.dropInbound = opts.dropInbound === true
}
/** 绑定两个门牌并开始转发(返回后 {@link gateway} / {@link natAddress} 才是真值)。 */
async open(): Promise<void> {
this.outerSock = await this.bind()
this.innerSock = await this.bind()
this.outerSock.on('message', (msg, rinfo) => this.onOuter(msg, rinfo))
this.innerSock.on('message', (msg, rinfo) => this.onInner(msg, rinfo))
}
private bind(): Promise<Socket> {
return new Promise<Socket>((resolve, reject) => {
const sock = createSocket({ type: 'udp4', reuseAddr: false })
sock.once('error', reject)
sock.bind({ port: 0, address: '127.0.0.1' }, () => {
sock.off('error', reject)
sock.on('error', () => {
// 发送目标已关 ⇒ 夹具里的正常竞态,忽略
})
resolve(sock)
})
})
}
private portOf(sock: Socket): number {
const addr = sock.address()
return typeof addr === 'object' && addr !== null ? addr.port : 0
}
/** 内侧来的包(= 节点要出去)⇒ 建映射 + 转给对端 NAT。 */
private onInner(msg: Buffer, rinfo: { address: string; port: number }): void {
this.counters.fromNode += 1
this.nodePort = rinfo.port
this.mapped = true
const peer = this.peerAddress
if (peer === undefined || this.outerSock === undefined) return
try {
this.outerSock.send(msg, peer.port, peer.host)
} catch {
// 对端已关 ⇒ 忽略
}
}
/** 外侧来的包(= 对端打过来的)⇒ 只有已建映射才放行入向。 */
private onOuter(msg: Buffer, rinfo: { address: string; port: number }): void {
const peer = this.peerAddress
if (peer === undefined || rinfo.port !== peer.port) return
this.counters.fromPeer += 1
if (!this.mapped) {
this.counters.droppedNoMapping += 1
return
}
if (this.dropInbound) {
this.counters.droppedByPolicy += 1
return
}
if (this.nodePort === 0 || this.innerSock === undefined) return
try {
this.innerSock.send(msg, this.nodePort, '127.0.0.1')
this.counters.forwardedIn += 1
} catch {
// 节点已关 ⇒ 忽略
}
}
/** 节点侧要发往的地址(**内侧门牌** = 我的网关)。 */
get gateway(): DirectAddress {
return { host: '127.0.0.1', port: this.innerSock === undefined ? 0 : this.portOf(this.innerSock) }
}
/** 对外门牌(= **对端要打的目标**,也是本机"公网地址"的替身)。 */
get natAddress(): DirectAddress {
return { host: '127.0.0.1', port: this.outerSock === undefined ? 0 : this.portOf(this.outerSock) }
}
get isMapped(): boolean {
return this.mapped
}
close(): void {
for (const sock of [this.innerSock, this.outerSock]) {
try {
sock?.close()
} catch {
// 已关 ⇒ 忽略
}
}
this.innerSock = undefined
this.outerSock = undefined
}
}
/** 一对打洞的读数(`bidirectional` = **两侧都成立** ⇒ 直连可用)。 */
export interface PunchPairResult {
a: PunchAttempt
b: PunchAttempt
bidirectional: boolean
nat: { a: SimNatCounters; b: SimNatCounters }
}
/**
* 跑一对打洞探测(**两侧并发**,各自走真实 {@link runPunchAttempt})。
*
* - `aNat` / `bNat` 的 `dropInbound` 决定这是"能打穿"还是"单向不可达";
* - 节点把包发给**自己的 NAT 网关**,目标是**对端 NAT 的外侧门牌**(= 真网里的"打公网门牌");
* - `peerSeen` = **对端 socket 的实时收包数 > 0**(= "对端确认收到了我的包"这一事实的进程内等价物)。
*/
export async function runPunchPair(
pair: {
aPeer: string
bPeer: string
deadlineMs?: number
cooldown?: number
/** 让哪一侧"只能出不能进"(`'a'` / `'b'` / `'both'` / `'none'`;缺省 `'none'`)。 */
oneWay?: 'a' | 'b' | 'both' | 'none'
},
io: { sleep: (ms: number) => Promise<void> },
): Promise<PunchPairResult> {
const oneWay = pair.oneWay ?? 'none'
const natA = new SimulatedNat({ dropInbound: oneWay === 'a' || oneWay === 'both' })
const natB = new SimulatedNat({ dropInbound: oneWay === 'b' || oneWay === 'both' })
await natA.open()
await natB.open()
natA.peerAddress = natB.natAddress
natB.peerAddress = natA.natAddress
const cooldownMs = pair.cooldown ?? DEFAULT_DIRECT_COOLDOWN_MS
const cdA = new DirectCooldown(cooldownMs)
const cdB = new DirectCooldown(cooldownMs)
const handles: { a?: PunchSocket; b?: PunchSocket } = {}
const mk = (
peer: string,
own: SimulatedNat,
other: 'a' | 'b',
self: 'a' | 'b',
cd: DirectCooldown,
): Promise<PunchAttempt> =>
runPunchAttempt(
{
peer,
selfPort: 0,
// ⚠️ 目标 = **本机自己的 NAT 网关**(= 真网里"经我这条 NAT 把包发到对端公网门牌")
// 夹具里 NAT 知道对端是谁(`peerAddress`),所以目标写成网关即可;
// ⛔ 不能直接写对端 NAT 的口 —— 那样**绕过自己的 NAT** ⇒ 映射建不起来(第一版就这么错)
targets: [own.gateway],
deadlineMs: pair.deadlineMs,
cooldown: cd,
bindHost: '127.0.0.1',
peerSeen: () => (handles[other]?.received() ?? 0) > 0,
onSocket: (s) => {
handles[self] = s
},
},
io,
)
const [a, b] = await Promise.all([mk(pair.aPeer, natA, 'b', 'a', cdA), mk(pair.bPeer, natB, 'a', 'b', cdB)])
const out: PunchPairResult = {
a,
b,
bidirectional: a.bidirectional && b.bidirectional,
nat: { a: natA.counters, b: natB.counters },
}
natA.close()
natB.close()
return out
}
+102 -1
View File
@@ -33,7 +33,8 @@ export { MuxDuplex } from './duplex.js'
export type { MuxDuplexOptions } from './duplex.js'
export { MUX, WS_CLOSE, acceptWebSocket, encodeMux, decodeMux, encodeJsonFrame, parseJsonPayload, WsConnection } from './wire.js'
export type { MuxFrame, MuxType, WsServerOptions } from './wire.js'
export { OPS_NETWORK, NAME_SEP, assertNetworkId, assertSameNetwork, describeDialers, isHostId, isNetworkId, logicalName, normalizeDialers, parseLogicalName, sameNetwork } from './network.js'
export { OPS_NETWORK, NAME_SEP, assertNetworkId, assertSameNetwork, describeDialers, describeNetwork, isAllowedDialer, isHostId, isNetworkId, logicalName, networkKindOf, normalizeDialers, parseLogicalName, sameNetwork } from './network.js'
export type { NetworkKind } from './network.js'
export { DIRECTORY_PATH, DIRECTORY_PAYLOAD_TAG, DIRECTORY_VERSION, DEFAULT_OVERLAY_SEED, buildDirectoryDocument, directoryPayload, directoryUrlFor, overlayEnvSeeds, overlayEnvTrustedKeys, parseDirectory, publicKeyFrom, publicRelayEntries, readCachedDirectory, resolveOverlayRelay, listOverlayRelayCandidates, signDirectory, toRelayUrl, verifyDirectory, writeCachedDirectory } from './directory.js'
export type { CachedDirectory, DirectoryVerdict, OverlayAddressSource, OverlayDirectory, OverlayRelayCandidates, OverlayRelayResolution, ResolveOverlayRelayOptions } from './directory.js'
export { keyEntryOf, loadKeysFile, parseKeysInline, normalizeKeyRecord, lookupKey, describeKeyEntry, assertKey } from './keys.js'
@@ -155,3 +156,103 @@ export type {
GroupKeyLoadResult,
LoadGroupKeyOptions,
} from './content/crypto.js'
// ── 序㊱ · 「节点一键加入与分组准入」P1(S1–S4):网注册表 / 准入凭据 / 白名单派生 / join 编排 ──
export {
NETWORK_INVITE_TAG,
NODES_REGISTRY_VERSION,
networkInvitePayload,
parseNetworkInvite,
newInviteNonce,
verifyNetworkInvite,
noncePath,
consumeNonce,
isNonceConsumed,
emptyRegistry,
parseRegistry,
loadRegistry,
saveRegistry,
summarizeNetworks,
listNodes,
applyApplication,
approveNode,
removeNode,
deriveDialers,
auditDerivation,
deriveDropIn,
dropInPath,
describeRegistryLine,
} from './registry.js'
export type {
NetworkInvite,
InviteReason,
InviteVerdict,
NonceLedger,
NodeStatus,
NetworkNodeRecord,
NodesRegistry,
NetworkSummary,
DerivationAudit,
RegistryEdit,
} from './registry.js'
export {
JOIN_STEPS,
joinReasonOfInvite,
defaultHostId,
runJoin,
nodeJoinIo,
readSignedJson,
describeJoin,
canWrite,
maybeRead,
} from './join.js'
export type { JoinStep, JoinReason, StepReport, JoinOutcome, JoinIo, JoinOptions } from './join.js'
export { buildApplication, parseApplication, readApplicationFile } from './join.js'
export type { NodeApplication } from './join.js'
// ── 序㊵ · P2/S5:直连候选交换 + 打洞探测(开关 / 默认值 / 提示 / 观测面)──
export {
DIRECT_MESSAGE_KIND,
DIRECT_CAND_MAX_ADDRS,
DIRECT_CAND_TTL_MS,
findSecretField,
isIpv4,
isIpv6,
isValidAddress,
parseDirectMessage,
normalizeDirectMessage,
encodeDirectMessage,
admitCandidate,
CandidateLedger,
} from './direct/candidate.js'
export type { DirectAddress, DirectCandidateMessage, CandidateReason, CandidateVerdict, CandidateContext, CandidateLedgerSnapshot } from './direct/candidate.js'
export {
PUNCH_PORT_BASE,
PUNCH_PORT_SPAN,
PUNCH_PROBE_INTERVAL_MS,
DEFAULT_PUNCH_DEADLINE_MS,
DEFAULT_DIRECT_COOLDOWN_MS,
DirectCooldown,
PunchSocket,
runPunchAttempt,
pickPunchPort,
SimulatedNat,
runPunchPair,
} from './direct/punch.js'
export type { PunchReason, PunchAttempt, PunchOptions, PunchPairResult, SimNatCounters, SimulatedNatOptions } from './direct/punch.js'
export {
DIRECT_ENV_KEY,
DEFAULT_DIRECT_ENABLED,
DIRECT_ON_VALUES,
DIRECT_OFF_VALUES,
NODE_CONFIG_FILE_DEFAULT,
resolveDirectSwitch,
DIRECT_HINT_PARTS,
directHintLines,
directHintText,
DirectPath,
directFromNodeConfig,
readNodeConfig,
writeNodeConfigDirect,
} from './direct/index.js'
export type { DirectSwitchState, DirectRefusal, NodeConfigWriteOutcome } from './direct/index.js'
+434
View File
@@ -0,0 +1,434 @@
/**
* 覆盖网络 **S3 · join 编排**(序㊱ · 「节点一键加入与分组准入」P1)。
*
* ## 它解决的确切问题(缺口 ①:「一键加入」)
* 今天把一台机器加进覆盖网要**手工做四件事**:
* ① 装 relay / worker 单元 → ② 配节点密钥 → ③ drop-in 写 `DSHS_RELAY_DIALERS` → ④ DB 登记。
* 全仓**无 join 类入口**(`grep` 实证)。⇒ 本模块把它编成**一条命令的四步**:
*
* | 步 | 做什么 | 失败后果(**必须具名**) |
* |---|---|---|
* | ① `verify-invite` | 校验邀请凭据(签名 + 网络绑定 + 有效期) | `invite-*` 五条具名原因 |
* | ② `node-key` | **本机**生成 / 复用节点密钥(**私钥不出机**,`0600`) | `node-key-unwritable` |
* | ③ `local-config` | 落地本机配置(`0600`) | `local-config-unwritable` |
* | ④ `register` | 向控制面提交申请(HTTP 或**落盘申请单**) | `register-*` 三条具名原因 |
*
* ## 🔴 一条不可退让的纪律:**⛔ 不许静默拒绝**
* 本线的病根反复是"配置错**长得像**网络不通"。⇒ 本模块**没有** `catch {}` 兜底:
* 每一步的失败都返回 `{ ok:false, step, reason, detail }`,`detail` 里带**原始**信息
* (路径 / 原因码 / HTTP 码),调用方(CLI)负责把它**原样打到 stderr** 并 `exit 1`。
* 判据:源码里**零**空 `catch` 块(`OBS-25` 的机器判据之一)。
*
* ## ⛔ 本模块**不做**的事(故意)
* - ⛔ 不装服务单元、不写 drop-in、不 reload(那是控制面派生 + 运维动作,不是节点侧的事);
* - ⛔ 不落任何**密钥本体**到 stdout / 申请单(申请单里只有**公钥**);
* - ⛔ 不自己判"一次性" —— 一笔邀请是否已用过是**控制面台账**的事实(见 `registry.ts#consumeNonce`)。
*
* @module dshs/net/relay/join
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { assertNetworkId, isHostId } from './network.js'
import { verifyNetworkInvite, type InviteReason } from './registry.js'
import { DEFAULT_DIRECT_ENABLED } from './direct/index.js'
/** 四步(顺序即执行顺序;⛔ 改这个数组 = 改判据口径,`OBS-25` 会读到它)。 */
export const JOIN_STEPS = ['verify-invite', 'node-key', 'local-config', 'register'] as const
export type JoinStep = (typeof JOIN_STEPS)[number]
/**
* 失败原因(**枚举**,⛔ 不收自由文本)—— 目的:让"哪一步的哪种错"可被机器判。
*
* ⚠️ `invite-*` 五条与 `registry.ts#InviteReason` **一一对应**(不是另造一套口径):
* 映射表见 {@link joinReasonOfInvite}。
*/
export type JoinReason =
| 'invite-missing'
| 'invite-bad-payload'
| 'invite-no-trusted-signer'
| 'invite-signature-mismatch'
| 'invite-expired'
| 'invite-network-mismatch'
| 'node-key-unwritable'
| 'local-config-unwritable'
| 'register-unreachable'
| 'register-rejected'
| 'register-malformed'
/** 邀请验签原因 → join 原因(**唯一映射点**;⛔ 别在别处再写一遍)。 */
export function joinReasonOfInvite(reason: InviteReason): JoinReason {
switch (reason) {
case 'bad-payload':
return 'invite-bad-payload'
case 'no-trusted-keys':
return 'invite-no-trusted-signer'
case 'network-mismatch':
return 'invite-network-mismatch'
case 'expired':
return 'invite-expired'
case 'not-yet-valid':
return 'invite-expired'
default:
return 'invite-signature-mismatch'
}
}
/** 单步的执行记录(**成功与失败都记** —— 报告里要能看到"走到哪一步了")。 */
export interface StepReport {
step: JoinStep
ok: boolean
/** 人读的一行;⛔ 不含任何密钥本体。 */
detail: string
}
export type JoinOutcome =
| {
ok: true
network: string
hostId: string
/** 节点**公钥**(hex)。 */
nodeKey: string
/** 申请单落盘路径(`--out` 通道);HTTP 通道下为 `''`。 */
applicationFile: string
/** 控制面返回(HTTP 通道)或 `''`。 */
ack: string
steps: StepReport[]
}
| { ok: false; step: JoinStep; reason: JoinReason; detail: string; steps: StepReport[] }
/** 外部世界的边界(**全部注入** ⇒ 单测可在临时目录里真跑,⛔ 不 mock 判据)。 */
export interface JoinIo {
exists(path: string): boolean
read(path: string): string
/** 写文件;`mode` 由调用方给(密钥类 `0600`,公开类 `0644`)。 */
write(path: string, text: string, mode: number): void
generateNodeKey(): { privateKeyPem: string; publicKey: string }
publicKeyOfPrivate(privateKeyPem: string): string
hostname(): string
/** 提交申请(HTTP 通道)。 */
post(
url: string,
body: string,
): Promise<{ status: number; body: string }>
}
export interface JoinOptions {
/** 要加入的网(`ops` / `u:<租户>` / 显式命名 —— 合法形状由 `network.ts` 判)。 */
network: string
hostId: string
/** 邀请凭据(`{doc, sig}` 的原始 JSON 值;解析与验签都在 `registry.ts`)。 */
invite: { doc: unknown; sig: unknown }
/** 受信签名者公钥(hex)。**空 ⇒ 直接失败**(不可验 = 不接受)。 */
trustedSigners: readonly string[]
/** 节点私钥落点(缺省 `/etc/dshs/node.key`)。 */
nodeKeyFile: string
/** 本机配置落点(缺省 `/etc/dshs/overlay-node.json`)。 */
localConfigFile: string
/** 申请单落盘路径(**离线通道**;与 `portalUrl` 二选一)。 */
outFile?: string
/** 控制面入口(**HTTP 通道**;使用它需要控制面已开放接收入口)。 */
portalUrl?: string
/**
* 🆕 序㊵(P2/S5):本机的**直连(打洞)开关**是否开启。
*
* 缺省 = {@link DEFAULT_DIRECT_ENABLED}(**开** —— 用户 2026-09-18 12:22 原话「默认开启提示用户」)。
* ⚠️ 它只写进**本机配置**(`direct` 字段)—— ⛔ 不写任何 drop-in / env:
* 节点的直连开关属于"跟着用户走"的那类数据(放实例 / 节点自己的配置),不属于控制面共享值。
*/
direct?: boolean
now?: number
}
/** 从主机名派一个合法 `hostId`(非法字符替换为 `-`;⛔ 不猜、不静默回落到 `localhost`)。 */
export function defaultHostId(raw: string): string {
const v = raw.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 63)
return v === '' ? '' : v
}
/**
* 跑完整 join 编排(四步)。
*
* ⚠️ **顺序不可颠倒**:先验凭据(否则会为一张无效邀请生成无用的密钥),再生成密钥,
* 再落地本机配置,最后才向控制面提交(前一步失败 ⇒ **不产生下一步的副作用**)。
*/
export async function runJoin(opts: JoinOptions, io: JoinIo): Promise<JoinOutcome> {
const steps: StepReport[] = []
const fail = (step: JoinStep, reason: JoinReason, detail: string): JoinOutcome => {
steps.push({ step, ok: false, detail: `${reason}:${detail}` })
return { ok: false, step, reason, detail, steps }
}
// ── 形状先判:网络 id / hostId 非法 ⇒ 立即具名失败(⛔ 不"兜个默认网")───────
try {
assertNetworkId(opts.network, 'join 的目标网')
} catch (err) {
return fail('verify-invite', 'invite-network-mismatch', err instanceof Error ? err.message : String(err))
}
if (!isHostId(opts.hostId)) {
return fail('verify-invite', 'invite-bad-payload', `hostId 非法:${JSON.stringify(opts.hostId)}`)
}
// ── 步 ①:校验邀请凭据 ───────────────────────────────────────────────────
if (opts.trustedSigners.length === 0) {
return fail('verify-invite', 'invite-no-trusted-signer', '未配置受信签名者(不可验 = 不接受,⛔ 不降级放行)')
}
if (opts.invite === undefined || opts.invite === null) {
return fail('verify-invite', 'invite-missing', '未提供邀请凭据(--invite)')
}
const verdict = verifyNetworkInvite(opts.invite.doc, opts.invite.sig, opts.trustedSigners, {
network: opts.network,
now: opts.now,
})
if (!verdict.ok) {
return fail('verify-invite', joinReasonOfInvite(verdict.reason), `invite 验签失败:${verdict.reason}`)
}
steps.push({
step: 'verify-invite',
ok: true,
detail: `network=${verdict.doc.network} nonce=${verdict.doc.nonce.slice(0, 8)}… 有效期至 ${verdict.doc.expiresAt || '(未设)'}`,
})
// ── 步 ②:节点密钥(**私钥不出机**;已存在 ⇒ 复用,重跑 join 不换钥匙)─────
let publicKey = ''
let firstTime = false
try {
if (io.exists(opts.nodeKeyFile)) {
publicKey = io.publicKeyOfPrivate(io.read(opts.nodeKeyFile))
} else {
const key = io.generateNodeKey()
io.write(opts.nodeKeyFile, key.privateKeyPem, 0o600)
publicKey = key.publicKey
firstTime = true
}
} catch (err) {
return fail('node-key', 'node-key-unwritable', `${opts.nodeKeyFile}:${err instanceof Error ? err.message : String(err)}`)
}
if (publicKey === '') {
return fail('node-key', 'node-key-unwritable', `${opts.nodeKeyFile} 里推不出公钥(形状非法)`)
}
steps.push({
step: 'node-key',
ok: true,
detail: `${firstTime ? '新生成' : '复用既有'}节点密钥 ${opts.nodeKeyFile}(0600)|公钥指纹 ${publicKey.slice(0, 16)}…`,
})
// ── 步 ③:本机配置(`0600`;⛔ 不含私钥本体,只记"私钥在哪")───────────────
// 🆕 序㊵(P2/S5):多记一个 `direct` —— 本机**直连开关**(用户口径「默认开启提示用户」的落点)。
const directEnabled = opts.direct ?? DEFAULT_DIRECT_ENABLED
const localConfig = {
version: 1,
network: verdict.doc.network,
hostId: opts.hostId,
nodeKeyFile: opts.nodeKeyFile,
nodeKey: publicKey,
invitedBy: verdict.doc.nonce,
joinedAt: new Date(opts.now ?? Date.now()).toISOString(),
/** 🆕 直连(打洞)开关 —— **用户可改**(管理面 / 手改本文件均可)。 */
direct: directEnabled,
}
try {
io.write(opts.localConfigFile, `${JSON.stringify(localConfig, null, 2)}\n`, 0o600)
} catch (err) {
return fail(
'local-config',
'local-config-unwritable',
`${opts.localConfigFile}:${err instanceof Error ? err.message : String(err)}`,
)
}
steps.push({
step: 'local-config',
ok: true,
detail: `已落地 ${opts.localConfigFile}(0600)|直连(打洞)=${directEnabled ? '开(缺省)' : '关'}`,
})
// ── 步 ④:向控制面提交申请 ──────────────────────────────────────────────
// 🔴 **唯一的申请单构造点** —— 控制面 `apply` 用 `parseApplication` 读它(⛔ 别在这两处各写一份形状)。
const application = buildApplication({
hostId: opts.hostId,
network: verdict.doc.network,
// 只有公钥 —— 私钥永不出机、永不进这份申请单。
nodeKey: publicKey,
appliedAt: new Date(opts.now ?? Date.now()).toISOString(),
invite: { doc: opts.invite.doc, sig: opts.invite.sig },
})
const payload = `${JSON.stringify(application, null, 2)}\n`
if (typeof opts.portalUrl === 'string' && opts.portalUrl.trim() !== '') {
let res: { status: number; body: string }
try {
res = await io.post(opts.portalUrl.trim(), payload)
} catch (err) {
return fail(
'register',
'register-unreachable',
`${opts.portalUrl}:${err instanceof Error ? err.message : String(err)}`,
)
}
if (res.status < 200 || res.status >= 300) {
// ⚠️ 控制面拒绝时必须把**它对外的原因码**带回来 —— ⛔ 不许把 4xx 说成"网络不通"。
return fail('register', 'register-rejected', `HTTP ${res.status}:${res.body.slice(0, 200)}`)
}
steps.push({ step: 'register', ok: true, detail: `控制面已受理(HTTP ${res.status})` })
return {
ok: true,
network: verdict.doc.network,
hostId: opts.hostId,
nodeKey: publicKey,
applicationFile: '',
ack: res.body.slice(0, 500),
steps,
}
}
const outFile = opts.outFile ?? ''
if (outFile === '') {
return fail(
'register',
'register-malformed',
'既未给 --portal(HTTP 通道)也未给 --out(离线申请单通道)⇒ ⛔ 不静默成功',
)
}
try {
mkdirSync(dirname(outFile), { recursive: true })
// `0644`:申请单里只有**公钥**与签名,本就是给控制面看的公开物。
writeFileSync(outFile, payload, { mode: 0o644 })
} catch (err) {
return fail('register', 'register-malformed', `${outFile}:${err instanceof Error ? err.message : String(err)}`)
}
steps.push({ step: 'register', ok: true, detail: `申请单已落盘 ${outFile}(0644,只有公钥)` })
return {
ok: true,
network: verdict.doc.network,
hostId: opts.hostId,
nodeKey: publicKey,
applicationFile: outFile,
ack: '',
steps,
}
}
/** **Node 侧**的实现(真的读写文件系统、真的发 HTTP)。⛔ 判据不在这里。 */
export function nodeJoinIo(deps: {
fss: {
existsSync: (p: string) => boolean
readFileSync: (p: string, enc: 'utf8') => string
writeFileSync: (p: string, data: string, o: { mode: number }) => void
mkdirSync: (p: string, o: { recursive: boolean }) => void
}
crypto: { generateNodeKey: () => { privateKeyPem: string; publicKey: string }; publicKeyOfPrivate: (pem: string) => string }
os: { hostname: () => string }
fetchImpl: (url: string, body: string) => Promise<{ status: number; body: string }>
}): JoinIo {
return {
exists: (p) => deps.fss.existsSync(p),
read: (p) => deps.fss.readFileSync(p, 'utf8'),
write: (p, text, mode) => {
deps.fss.mkdirSync(dirname(p), { recursive: true })
deps.fss.writeFileSync(p, text, { mode })
},
generateNodeKey: () => deps.crypto.generateNodeKey(),
publicKeyOfPrivate: (pem) => deps.crypto.publicKeyOfPrivate(pem),
hostname: () => deps.os.hostname(),
post: (url, body) => deps.fetchImpl(url, body),
}
}
/** 读一份邀请 / 申请单文件(`{doc, sig}` 形态;⛔ 形状不对 ⇒ **抛**,不返回 `undefined`)。 */
export function readSignedJson(file: string): { doc: unknown; sig: unknown } {
const raw: unknown = JSON.parse(readFileSync(file, 'utf8'))
if (raw === null || typeof raw !== 'object') throw new Error(`${file} 不是 {doc, sig} 形态的 JSON`)
const r = raw as Record<string, unknown>
if (r.doc === undefined || r.sig === undefined) throw new Error(`${file} 缺 doc / sig 字段`)
return { doc: r.doc, sig: r.sig }
}
/**
* **join 申请单**(`register` 步产出的那份东西)。
*
* 🔴 **存在的理由(真机首轮实测踩到)**:`join` 写出的是**申请单**(`hostId` / `network` / `nodeKey`
* / 内嵌的 `invite:{doc,sig}`),而控制面 `apply` 一开始按"裸 `{doc, sig}`"去读 ⇒ **读不出来**
* (`✗ … 缺 doc / sig 字段`)。两个形状**必须由同一处定义**、并由同一个解析函数读 ——
* 否则 S3 的产物与 S2 的输入会各自演进、在真机上才暴露(本线"同一事实两处写"的又一例)。
*
* ⛔ **载荷内只有公钥** —— 私钥永不出机、永不进这份申请单。
*/
export interface NodeApplication {
version: number
hostId: string
network: string
/** 节点**公钥**(hex)。 */
nodeKey: string
appliedAt: string
/** 内嵌的邀请凭据(**验签与一次性都由控制面判**)。 */
invite: { doc: unknown; sig: unknown }
}
/** 构造一份申请单(**唯一构造点** —— `runJoin` 与夹具都走它)。 */
export function buildApplication(a: {
hostId: string
network: string
nodeKey: string
appliedAt: string
invite: { doc: unknown; sig: unknown }
}): NodeApplication {
return {
version: 1,
hostId: a.hostId,
network: a.network,
nodeKey: a.nodeKey,
appliedAt: a.appliedAt,
invite: { doc: a.invite.doc, sig: a.invite.sig },
}
}
/** 解析申请单(**严格**:字段不全 / 类型不对 ⇒ `undefined`,⛔ 不猜、不补默认值)。 */
export function parseApplication(raw: unknown): NodeApplication | undefined {
if (raw === undefined || raw === null || typeof raw !== 'object') return undefined
const r = raw as Record<string, unknown>
const hostId = typeof r.hostId === 'string' ? r.hostId.trim() : ''
const network = typeof r.network === 'string' ? r.network.trim() : ''
const nodeKey = typeof r.nodeKey === 'string' ? r.nodeKey.trim().toLowerCase() : ''
const appliedAt = typeof r.appliedAt === 'string' ? r.appliedAt.trim() : ''
if (!isHostId(hostId) || network === '' || !/^[0-9a-f]{64}$/.test(nodeKey) || appliedAt === '') return undefined
const inv = r.invite
if (inv === null || typeof inv !== 'object') return undefined
const i = inv as Record<string, unknown>
if (i.doc === undefined || i.sig === undefined) return undefined
const version = typeof r.version === 'number' ? r.version : 1
return { version, hostId, network, nodeKey, appliedAt, invite: { doc: i.doc, sig: i.sig } }
}
/** 读一份申请单文件;形状不对 ⇒ **抛**(调用方负责转成具名失败,⛔ 不静默回落)。 */
export function readApplicationFile(file: string): NodeApplication {
const parsed = parseApplication(JSON.parse(readFileSync(file, 'utf8')))
if (parsed === undefined) {
throw new Error(`${file} 不是合法的**申请单**(须含 hostId / network / nodeKey(64hex) / appliedAt / invite.doc / invite.sig)`)
}
return parsed
}
/** 一行摘要(给 CLI 收尾打印)。 */
export function describeJoin(outcome: JoinOutcome): string {
if (outcome.ok) {
return `✓ join 成功:${outcome.network}/${outcome.hostId}|公钥 ${outcome.nodeKey.slice(0, 16)}…|四步 ${outcome.steps.length}/4`
}
return `✗ join 失败于「${outcome.step}」:${outcome.reason} — ${outcome.detail}`
}
/** 供 CLI 判断"这个路径能不能写"(⛔ 不真写,避免试错产生半成品)。 */
export function canWrite(file: string): boolean {
try {
mkdirSync(dirname(file), { recursive: true })
return true
} catch {
return false
}
}
/** 兜底:确无该文件时返回 `undefined`(**只用于可选输入**,⛔ 不用于密钥)。 */
export function maybeRead(file: string): string | undefined {
return existsSync(file) ? readFileSync(file, 'utf8') : undefined
}
+66
View File
@@ -74,6 +74,46 @@ export function assertNetworkId(raw: string, what = 'network id'): string {
return v
}
/**
* 网络 id 的**类别**(`isNetworkId` 那三条分支的**具名**形态)。
*
* ## 为什么要有它(序㊱ · 「节点一键加入与分组准入」S1)
* 管理面要回答「**有哪些网**、每张是什么性质」—— 只回一个 `true/false` 不够:分组准入的
* **默认分组**、**能否与 `ops` 共享骨干**、**是否允许显式命名**,三处判据都要这个类别。
*
* ⛔ **纯函数、无 IO、不改既有语义** —— `isNetworkId` 仍是唯一的合法性判据,本函数只是
* 把它的三条分支**具名化**(`kind !== undefined` ⟺ `isNetworkId(raw) === true`)。
*
* | 类别 | 形态 | 含义 |
* |---|---|---|
* | `ops` | 固定值 | 平台自己的机器(中继 / 骨干 / Worker) |
* | `tenant` | `u:<租户>` | 该用户名下的全部设备(含桌面客户端) |
* | `named` | `[a-z0-9][a-z0-9_.-]*` | **二级网 / 测试网 / 独立覆盖网络** —— 新增一张网**零代码** |
*/
export type NetworkKind = 'ops' | 'tenant' | 'named'
export function networkKindOf(raw: string): NetworkKind | undefined {
const v = raw.trim()
if (!isNetworkId(v)) return undefined
if (v === OPS_NETWORK) return 'ops'
if (v.startsWith('u:')) return 'tenant'
return 'named'
}
/**
* 网名的**规范展示名**(管理面 / CLI 输出用)—— `ops` 后面跟着它是什么。
*
* ⛔ 只用于**人读的输出**(日志 / `list` 表)。**判据一律用 `networkKindOf`** ——
* 拿展示名去做字符串匹配 = 把"好看的输出"变成判据(本线反复要根治的那类病)。
*/
export function describeNetwork(raw: string): string {
const kind = networkKindOf(raw)
if (kind === undefined) return `${raw}(⛔ 非法网名)`
if (kind === 'ops') return `${raw}(运维网 · 平台自己的机器)`
if (kind === 'tenant') return `${raw}(租户网)`
return `${raw}(显式命名网 · 可作独立覆盖网络)`
}
/** 节点逻辑名 = `<network_id>/<hostId>`。**唯一拼法**(别在调用方拼)。 */
export function logicalName(network: string, hostId: string): string {
return `${network}${NAME_SEP}${hostId}`
@@ -192,6 +232,32 @@ export function normalizeDialers(
return out
}
/**
* **白名单准入的唯一定义**(序㊵ · P2/S5)—— `true` = 该 `hostId` 在 `network` 这张网里可拨。
*
* ## 为什么必须单列成一个函数(⛔ 不是为了好看)
* 在它之前,「不在白名单就拒」这条策略**只有 `server.ts` 里三处内联写法**:
* `this.dialers.get(network)?.has(hostId) === true`(注册闸门 `:1182`)、
* `this.dialers.get(session.network)?.has(session.hostId) !== true`(`DIAL` 闸门 `:1491`)。
* 直连(P2)要**再判一次同一条策略**(候选交换/打洞都只准发生在"同网 + 白名单内"),
* 若直连模块**自己再写一份**,就正好撞上本线反复吃过的病根:**同一事实两处写** ⇒
* 两处有一天会分叉(`server.ts` 那两处是**冻结的只读面**,不许改也不许被复制)。
*
* ⇒ 本函数 = **该策略的唯一可复用出口**;`server.ts` 的内联写法与它**逐字等价**
* (同 map 、同 `=== true` 默认拒绝语义、同"按网络分桶")。等价性由
* `test/overlay-direct.test.mjs#T4` 做**机器守卫**(读 `server.ts` 源码核对那两处的原文形态)。
*
* ⚠️ 语义上 `network` 是**桶键**:拿别的网的桶去查同名 hostId 只会得到 `false`
* (这正是 P0-1「跨网结构性隔离」—— ⛔ 不是"策略允许/不允许"的区别)。
*/
export function isAllowedDialer(
map: ReadonlyMap<string, ReadonlySet<string>>,
network: string,
hostId: string,
): boolean {
return map.get(network)?.has(hostId) === true
}
/**
* 类型判别:`Map` 形态 vs 扁平 `Set` 形态。
*
+503
View File
@@ -0,0 +1,503 @@
/**
* 覆盖网络 **S1+S2+S4 · 网注册表 + 准入凭据 + 白名单派生**(序㊱ · 「节点一键加入与分组准入」P1)。
*
* ## 它解决的确切问题(缺口 ②:「分组准入门面」)
* `server.ts` 的拨号方白名单**机制已经完整**(`dialers: Map<network, Set<hostId>>`、默认拒绝、
* 跨网在"能不能拨"这一步就走不到 —— 结构性隔离)。缺的是**把它变成可管理的东西**:
* 今天配置靠**手写字符串**(`DSHS_RELAY_DIALERS="u:5:manager"`),写错了长得像"这张网不存在"。
*
* ⇒ 本模块把「**哪些网、每张网有哪些节点、谁已批准**」变成**一份注册表**,
* 并**派生**出 relay 侧的白名单(`DSHS_RELAY_DIALERS`)。
*
* ## 三条硬约束
* 1. **控制面是权威单点** —— 注册表只有控制面写(用户既有口径:「归属 / 租约 / 骨干资格只能控制面写」)。
* 2. **手写 drop-in 退化为应急通道,⛔ 不删** —— 派生只是**多一条**产生白名单的路;控制面不可用
* 时手写仍然照旧生效(`normalizeDialers` 同时接受扁平 `Set` 与分桶 `Map`)。
* 3. **凭据载荷 ⛔ 不含密钥本体** —— 邀请凭据只回答「哪张网 + 有效期 + 一次性 nonce」,
* 与组密钥凭据同形(`content/crypto.ts` 的 `(network, group, epoch, keyId)`)。
*
* ## ⛔ 本模块**不做**的事(故意)
* - 不碰 `server.ts` 的 DIAL / 白名单**语义**(只**读取**既有形状,派生出的仍是同一个 `Map` 形状);
* - 不开监听、不写 env、不 reload 任何单元(**派生是纯函数**,落盘与 reload 是调用方的事);
* - 不发明第二种签名 —— 一律走 `identity.ts#verifySignedPayload` / `signPayloadWith`。
*
* @module dshs/net/relay/registry
*/
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { OPS_NETWORK, assertNetworkId, isHostId, logicalName, networkKindOf } from './network.js'
import { verifySignedPayload, type IdentityReason } from './identity.js'
// ── 邀请(准入)凭据 ─────────────────────────────────────────────────────────
/** 载荷域分隔标签。⛔ **改它 = 令所有既有邀请失效**。 */
export const NETWORK_INVITE_TAG = 'dshs-overlay-netinvite/v1'
/** 注册表文件的结构版本。 */
export const NODES_REGISTRY_VERSION = 1
/** 输入长度的上界(防"超长 hostId 撑爆注册表"这类形状攻击;⛔ 与 `HOST_RE` 同量级)。 */
const NONCE_RE = /^[0-9a-f]{32}$/
const HEX_PUB_RE = /^[0-9a-f]{64}$/
/**
* 一张网的**准入邀请凭据**。
*
* 🔴 **载荷内不含任何密钥本体**(私钥 / 对称密钥都不在)—— 它只是"控制面允许某台机器
* 以某张网的身份来申请"。**节点自己的密钥在节点上生成、私钥永不出机**(`join.ts` 第 ② 步)。
*/
export interface NetworkInvite {
version: number
/** 该邀请**绑定的网** —— 拿它去申请别的网 ⇒ `network-mismatch`(具名拒绝)。 */
network: string
/** **一次性** nonce(32 hex)。控制面在**收单时**占位,第二次用 ⇒ `invite-already-used`。 */
nonce: string
issuedAt: string
/** **有效期**(ISO)。空串 = 不过期(⛔ 不推荐;控制面签发时缺省给 TTL)。 */
expiresAt: string
}
/** 邀请凭据的拒绝原因(**在 `IdentityReason` 之上再加两条注册表侧原因**)。 */
export type InviteReason = IdentityReason | 'bad-payload' | 'invite-already-used'
/** 邀请验签结论:**失败一律带具体原因**(⛔ 不许静默)。 */
export type InviteVerdict = { ok: true; doc: NetworkInvite; payload: string } | { ok: false; reason: InviteReason }
/** 规范拼接(字段顺序**写死**;⛔ 改顺序 = 令所有既有签名失效)。 */
export function networkInvitePayload(doc: NetworkInvite): string {
return [
NETWORK_INVITE_TAG,
`version=${String(doc.version)}`,
`network=${doc.network}`,
`nonce=${doc.nonce}`,
`issuedAt=${doc.issuedAt}`,
`expiresAt=${doc.expiresAt}`,
].join('\n')
}
/** 解析(**严格**:字段不全 / 类型不对 ⇒ `undefined`,⛔ 不猜、不补默认值)。 */
export function parseNetworkInvite(raw: unknown): NetworkInvite | undefined {
if (raw === undefined || raw === null || typeof raw !== 'object') return undefined
const r = raw as Record<string, unknown>
const network = typeof r.network === 'string' ? r.network.trim() : ''
const nonce = typeof r.nonce === 'string' ? r.nonce.trim().toLowerCase() : ''
const issuedAt = typeof r.issuedAt === 'string' ? r.issuedAt.trim() : ''
const expiresAt = typeof r.expiresAt === 'string' ? r.expiresAt.trim() : ''
if (network === '' || issuedAt === '') return undefined
if (networkKindOf(network) === undefined) return undefined
if (!NONCE_RE.test(nonce)) return undefined
const version = typeof r.version === 'number' ? r.version : NODES_REGISTRY_VERSION
return { version, network, nonce, issuedAt, expiresAt }
}
/**
* 验一份邀请凭据 —— **四件套**:受信签名者 + 网络绑定 + 有效期 + 载荷形状。
*
* ⚠️ **一次性不在这里判** —— 一笔邀请的"是否已被用过"是**控制面台账**的事实(跨进程共享),
* 不是签名能表达的东西。⇒ 由 {@link consumeNonce} 在**收单时**用原子占位判。
* 把两件事分开,是为了让"凭据合法但已用过"与"凭据不合法"在排障时**可分**。
*/
export function verifyNetworkInvite(
doc: unknown,
sig: unknown,
trustedSigners: readonly string[],
opts: { network?: string; now?: number } = {},
): InviteVerdict {
const parsed = parseNetworkInvite(doc)
if (parsed === undefined) return { ok: false, reason: 'bad-payload' }
const payload = networkInvitePayload(parsed)
const verdict = verifySignedPayload(payload, sig, trustedSigners)
if (verdict !== 'ok') return { ok: false, reason: verdict }
if (opts.network !== undefined && parsed.network !== opts.network) {
return { ok: false, reason: 'network-mismatch' }
}
const now = opts.now ?? Date.now()
if (parsed.expiresAt !== '') {
const at = Date.parse(parsed.expiresAt)
if (!Number.isFinite(at)) return { ok: false, reason: 'bad-payload' }
if (now > at) return { ok: false, reason: 'expired' }
}
return { ok: true, doc: parsed, payload }
}
/** 生成一个**一次性 nonce**(32 hex = 16 字节随机)。 */
export function newInviteNonce(randomBytes: (n: number) => Buffer): string {
return randomBytes(16).toString('hex')
}
// ── 一次性台账(**原子占位**,治"同一凭据被两台机器同时用")─────────────────────
/** 台账入参:目录 + 时间源(注入 ⇒ 可单测,⛔ 不读全局时钟)。 */
export interface NonceLedger {
dir: string
now?: () => number
}
/** 单个 nonce 的台账文件路径(`<dir>/<nonce>.used`)。 */
export function noncePath(ledger: NonceLedger, nonce: string): string {
return `${ledger.dir}/${nonce}.used`
}
/**
* 占位一个 nonce(**一次性**)。
*
* 🔴 **做法 = `writeFileSync(..., { flag: 'wx' })`** —— `O_CREAT|O_EXCL` 是**内核原子**的:
* 两台机器**同时**拿同一张邀请来收单,**恰好一台**拿到 `ok`,另一台得到 `invite-already-used`。
* ⛔ **不许**"先 `existsSync` 再写" —— 那是两次系统调用的竞态,正是本线「判据成立 ≠ 机制成立」
* 的典型反例(单机测着对、并发下漏一个)。
*
* ⚠️ 占位文件里写**收单侧可读的事实**(时间 + 用途),便于事后排障;⛔ 不写任何密钥 / 公钥。
*/
export function consumeNonce(
ledger: NonceLedger,
nonce: string,
note: string,
): { ok: true; file: string } | { ok: false; reason: 'invite-already-used' } {
const n = nonce.trim().toLowerCase()
if (!NONCE_RE.test(n)) return { ok: false, reason: 'invite-already-used' }
mkdirSync(ledger.dir, { recursive: true })
const file = noncePath(ledger, n)
const at = new Date(ledger.now?.() ?? Date.now()).toISOString()
try {
writeFileSync(file, `${at} ${note}\n`, { flag: 'wx', mode: 0o644 })
} catch (err) {
const code = (err as { code?: string }).code
if (code === 'EEXIST') return { ok: false, reason: 'invite-already-used' }
throw err
}
return { ok: true, file }
}
/** 只读:该 nonce 是否已被用过(**不占位**)。 */
export function isNonceConsumed(ledger: NonceLedger, nonce: string): boolean {
const n = nonce.trim().toLowerCase()
return NONCE_RE.test(n) && existsSync(noncePath(ledger, n))
}
// ── 网注册表(S1 的数据模型)─────────────────────────────────────────────────
/**
* 一个节点在注册表里的状态。
*
* | 状态 | 含义 | 后果 |
* |---|---|---|
* | `pending` | **已申请、待批准** | ⛔ **不进白名单** ⇒ relay 侧拨不动(默认拒绝) |
* | `approved` | 已批准 | 进派生白名单 ⇒ 可接入 |
*/
export type NodeStatus = 'pending' | 'approved'
/** 注册表里的一条节点记录(⛔ 只有**公钥**,没有私钥 / 没有密钥本体)。 */
export interface NetworkNodeRecord {
network: string
hostId: string
/** 节点**公钥**(64 hex,Ed25519 裸公钥)。 */
nodeKey: string
status: NodeStatus
/** 申请时刻(ISO)。 */
appliedAt: string
/** 批准时刻(ISO);`''` = 未批准。 */
approvedAt: string
/** 分组(`''` = 该网默认组)。⚠️ 分组只作**管理信息**,⛔ 不参与"能不能拨"的判据。 */
group: string
}
/** 注册表文件(键 = **逻辑名** `<network>/<hostId>`,与 `server.ts` 会话表同口径)。 */
export interface NodesRegistry {
version: number
nodes: Record<string, NetworkNodeRecord>
}
export function emptyRegistry(): NodesRegistry {
return { version: NODES_REGISTRY_VERSION, nodes: {} }
}
/**
* 解析注册表(**严格**:形状不对 ⇒ `undefined`)。
*
* ⛔ **不做"宽容修复"** —— 一条 `status` 拼错的记录如果被静默当成 `pending`,
* 表现就是"批准了却连不上"(本线反复要根治的那类病)。
*/
export function parseRegistry(raw: unknown): NodesRegistry | undefined {
if (raw === undefined || raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
const r = raw as Record<string, unknown>
const nodesRaw = r.nodes
if (nodesRaw === undefined || typeof nodesRaw !== 'object' || Array.isArray(nodesRaw)) return undefined
const version = typeof r.version === 'number' ? r.version : NODES_REGISTRY_VERSION
const nodes: Record<string, NetworkNodeRecord> = {}
for (const [key, value] of Object.entries(nodesRaw as Record<string, unknown>)) {
const rec = parseRecord(value)
if (rec === undefined) return undefined
if (key !== logicalName(rec.network, rec.hostId)) return undefined
nodes[key] = rec
}
return { version, nodes }
}
function parseRecord(raw: unknown): NetworkNodeRecord | undefined {
if (raw === undefined || raw === null || typeof raw !== 'object') return undefined
const r = raw as Record<string, unknown>
const network = typeof r.network === 'string' ? r.network.trim() : ''
const hostId = typeof r.hostId === 'string' ? r.hostId.trim() : ''
const nodeKey = typeof r.nodeKey === 'string' ? r.nodeKey.trim().toLowerCase() : ''
const status = typeof r.status === 'string' ? r.status.trim() : ''
const appliedAt = typeof r.appliedAt === 'string' ? r.appliedAt.trim() : ''
const approvedAt = typeof r.approvedAt === 'string' ? r.approvedAt.trim() : ''
const group = typeof r.group === 'string' ? r.group.trim() : ''
if (networkKindOf(network) === undefined) return undefined
if (!isHostId(hostId)) return undefined
if (!HEX_PUB_RE.test(nodeKey)) return undefined
if (status !== 'pending' && status !== 'approved') return undefined
if (appliedAt === '') return undefined
if (status === 'approved' && approvedAt === '') return undefined
return { network, hostId, nodeKey, status, appliedAt, approvedAt, group }
}
/** 读注册表;**文件不存在 ⇒ 空注册表**(不是错误:还没开始用)。形状不对 ⇒ **抛**(具名)。 */
export function loadRegistry(file: string): NodesRegistry {
if (!existsSync(file)) return emptyRegistry()
const raw: unknown = JSON.parse(readFileSync(file, 'utf8'))
const reg = parseRegistry(raw)
if (reg === undefined) throw new Error(`注册表 ${file} 形状非法(⛔ 不静默修复;字段口径见 registry.ts)`)
return reg
}
/** 写注册表(**`0644`** —— 内容只有公钥与状态,⛔ 无密钥;走临时文件 + `rename` 不留半成品)。 */
export function saveRegistry(file: string, reg: NodesRegistry): void {
mkdirSync(dirname(file), { recursive: true })
const tmp = `${file}.tmp`
writeFileSync(tmp, `${JSON.stringify(sortedRegistryJson(reg), null, 2)}\n`, { mode: 0o644 })
renameSync(tmp, file)
}
/** 稳定序列化:`nodes` 的键**按键名排序**,让"没有改动"与"改动了"在 `diff` 里可分。 */
function sortedRegistryJson(reg: NodesRegistry): NodesRegistry {
const nodes: Record<string, NetworkNodeRecord> = {}
for (const key of Object.keys(reg.nodes).sort()) {
const rec = reg.nodes[key]
if (rec === undefined) continue
nodes[key] = rec
}
return { version: reg.version, nodes }
}
// ── 读只面(S1)─────────────────────────────────────────────────────────────
export interface NetworkSummary {
network: string
/** 该网记录总数。 */
total: number
/** 其中已批准数。 */
approved: number
/** 其中待批准数。 */
pending: number
}
/** 逐网汇总(**排序稳定**:`ops` 先,其余按名)。 */
export function summarizeNetworks(reg: NodesRegistry): NetworkSummary[] {
const acc = new Map<string, NetworkSummary>()
for (const rec of Object.values(reg.nodes)) {
const cur = acc.get(rec.network) ?? { network: rec.network, total: 0, approved: 0, pending: 0 }
cur.total++
if (rec.status === 'approved') cur.approved++
else cur.pending++
acc.set(rec.network, cur)
}
return [...acc.values()].sort((a, b) => {
if (a.network === OPS_NETWORK) return -1
if (b.network === OPS_NETWORK) return 1
return a.network < b.network ? -1 : a.network > b.network ? 1 : 0
})
}
/** 列出节点(可选按网过滤;**排序稳定**:网名 → hostId)。 */
export function listNodes(reg: NodesRegistry, network?: string): NetworkNodeRecord[] {
return Object.values(reg.nodes)
.filter((r) => network === undefined || r.network === network)
.sort((a, b) => {
if (a.network !== b.network) return a.network < b.network ? -1 : 1
return a.hostId < b.hostId ? -1 : a.hostId > b.hostId ? 1 : 0
})
}
// ── 写面(S1;**只有控制面调**)──────────────────────────────────────────────
/** 收单结果:失败**具名**(⛔ 不许静默拒绝)。 */
export type RegistryEdit =
| { ok: true; record: NetworkNodeRecord; created: boolean }
| { ok: false; reason: 'unknown-node' | 'unknown-network' }
/**
* 收一份**申请**(`join` 的第 ④ 步)。
*
* 语义(**刻意如此**):
* - 新节点 ⇒ 建一条 `pending`(⛔ **不自动批准** —— 批准权在控制面,见 §4.1);
* - **已 `approved` 的节点再次申请** ⇒ **保持 `approved`**(幂等:重装 / 重跑 join 不该把自己踢回待批);
* - 已 `pending` 再次申请 ⇒ 刷新 `appliedAt` / `nodeKey`(换机重装场景)。
*/
export function applyApplication(
reg: NodesRegistry,
app: { network: string; hostId: string; nodeKey: string; at: string; group?: string },
): RegistryEdit {
const network = app.network.trim()
if (networkKindOf(network) === undefined) return { ok: false, reason: 'unknown-network' }
if (!isHostId(app.hostId)) return { ok: false, reason: 'unknown-node' }
if (!HEX_PUB_RE.test(app.nodeKey.trim().toLowerCase())) return { ok: false, reason: 'unknown-node' }
const key = logicalName(network, app.hostId)
const prev = reg.nodes[key]
const record: NetworkNodeRecord = {
network,
hostId: app.hostId,
nodeKey: app.nodeKey.trim().toLowerCase(),
status: prev?.status === 'approved' ? 'approved' : 'pending',
appliedAt: app.at,
approvedAt: prev?.status === 'approved' ? prev.approvedAt : '',
group: app.group ?? prev?.group ?? '',
}
reg.nodes[key] = record
return { ok: true, record, created: prev === undefined }
}
/** 批准一个节点(⛔ 只在**已申请**的节点上生效 ⇒ 不存在"凭空批准一个没申请过的 hostId")。 */
export function approveNode(
reg: NodesRegistry,
network: string,
hostId: string,
at: string,
group?: string,
): RegistryEdit {
const net = network.trim()
if (networkKindOf(net) === undefined) return { ok: false, reason: 'unknown-network' }
const key = logicalName(net, hostId)
const prev = reg.nodes[key]
if (prev === undefined) return { ok: false, reason: 'unknown-node' }
const record: NetworkNodeRecord = {
...prev,
status: 'approved',
approvedAt: at,
group: group ?? prev.group,
}
reg.nodes[key] = record
return { ok: true, record, created: false }
}
/** 移除一个节点(⇒ 立刻退出派生白名单;⚠️ relay 侧生效要等一次 reload —— 见 §4.1 注)。 */
export function removeNode(reg: NodesRegistry, network: string, hostId: string): RegistryEdit {
const net = network.trim()
if (networkKindOf(net) === undefined) return { ok: false, reason: 'unknown-network' }
const key = logicalName(net, hostId)
const prev = reg.nodes[key]
if (prev === undefined) return { ok: false, reason: 'unknown-node' }
delete reg.nodes[key]
return { ok: true, record: prev, created: false }
}
// ── S4 · 白名单派生 ─────────────────────────────────────────────────────────
/**
* 把**已批准集合**投影成 relay 侧的白名单(`dialers` 的**分桶 Map** 形状)。
*
* ⚠️ 这是 `network.ts#normalizeDialers` **能吃**的形状 ⇒ 派生结果与手写 drop-in
* 走的是**同一个归一化入口**(⛔ 不存在"派生的白名单和手写的不是一回事")。
* 🔴 `pending` **一律不投影** —— 派生只表达"已批准",待批节点的可拨性由既有默认拒绝兜住。
*/
export function deriveDialers(reg: NodesRegistry, networks?: readonly string[]): Map<string, Set<string>> {
const out = new Map<string, Set<string>>()
if (networks !== undefined) for (const n of networks) out.set(assertNetworkId(n, '派生的网名'), new Set())
for (const rec of Object.values(reg.nodes)) {
if (rec.status !== 'approved') continue
if (networks !== undefined && !networks.includes(rec.network)) continue
const bucket = out.get(rec.network) ?? new Set<string>()
bucket.add(rec.hostId)
out.set(rec.network, bucket)
}
return out
}
/** 派生结果的一致性问题(`OBS-24` 的判据②③,**逐条点名**)。 */
export interface DerivationAudit {
ok: boolean
/** 派生集合 ≠ 已批准集合的网(逐条点名,含差集)。 */
mismatches: string[]
/** **结构性错桶**:bucket 里的 hostId 对应记录属于**另一张网**(跨网零共享的机器判据)。 */
misfiled: string[]
}
/**
* 自审派生结果 —— **S4 的判据不是"函数跑通了",而是三件事同时成立**:
* ① 每张网的派生集合 ≡ 该网 `approved` 集合(多一个 / 少一个都点名);
* ② 任何 bucket 里的 hostId 都**来属于该 bucket 的网**(⇒ 「独立网零共享」是**结构性**的,
* 不是"我们用的时候记得过滤");
* ③ `pending` 一个都不在派生里。
*/
export function auditDerivation(reg: NodesRegistry, derived?: Map<string, Set<string>>): DerivationAudit {
const d = derived ?? deriveDialers(reg)
const mismatches: string[] = []
const misfiled: string[] = []
const byNetwork = new Map<string, Set<string>>()
const ownerOf = new Map<string, string>()
for (const rec of Object.values(reg.nodes)) {
ownerOf.set(`${rec.network}${'\u0000'}${rec.hostId}`, rec.network)
if (rec.status !== 'approved') continue
const bucket = byNetwork.get(rec.network) ?? new Set<string>()
bucket.add(rec.hostId)
byNetwork.set(rec.network, bucket)
}
for (const network of new Set<string>([...byNetwork.keys(), ...d.keys()])) {
const want = byNetwork.get(network) ?? new Set<string>()
const got = d.get(network) ?? new Set<string>()
const missing = [...want].filter((h) => !got.has(h)).sort()
const extra = [...got].filter((h) => !want.has(h)).sort()
if (missing.length > 0 || extra.length > 0) {
mismatches.push(`${network}: 缺[${missing.join(',')}] 多[${extra.join(',')}]`)
}
}
for (const [network, hosts] of d.entries()) {
for (const host of hosts) {
const owner = ownerOf.get(`${network}${'\u0000'}${host}`)
if (owner !== network) misfiled.push(`${network}/${host}(记录属于 ${owner ?? '不存在'})`)
}
}
return { ok: mismatches.length === 0 && misfiled.length === 0, mismatches, misfiled }
}
/**
* 派生一份 **relay drop-in** 的**内容**(纯字符串,⛔ 本函数不落盘、不 reload)。
*
* 🔴 **手写 drop-in 是应急通道,⛔ 不删** —— 派生出的这份与手写那份作用**完全等价**
* (同一个 env 键、同一套归一化),差别只在"谁产生它"。⇒ 控制面不可用时,手写照旧生效。
*/
export function deriveDropIn(
reg: NodesRegistry,
opts: { network: string; varName?: string; libDir?: string; unit?: string },
): string {
const network = assertNetworkId(opts.network, '派生的网名')
const varName = opts.varName ?? 'DSHS_RELAY_DIALERS'
const hosts = [...(deriveDialers(reg, [network]).get(network) ?? new Set<string>())].sort()
// 逻辑名形态(`<网>/<hostId>`)—— `normalizeDialers` 的**规范形态**;⛔ 不用 `网:hostId` 旧式写法。
const entries = hosts.map((h) => logicalName(network, h))
const libDir = opts.libDir ?? '/opt/dsh-relay'
const unit = opts.unit ?? 'dshs-relay'
return [
`# 由控制面**派生**(${unit} · network=${network})—— 源 = 网注册表里该网的 approved 集合`,
`# 🔴 手写 drop-in 是**应急通道**(控制面不可用时照旧生效),⛔ 本文件不取代它。`,
`# ⛔ 不许手改:改了下一次派生就被覆盖;要改就改注册表(approve / remove)。`,
'[Service]',
`Environment="${varName}=${entries.join(',')}"`,
].join('\n')
}
/** drop-in 的**目标路径**(同一套命名,⛔ 别在调用方拼)。 */
export function dropInPath(unit: string, libDir?: string): string {
const suffix = libDir === undefined ? '' : ` (lib=${libDir})`
return `/etc/systemd/system/${unit}.service.d/50-overlay-dialers.conf${suffix}`
}
// ── 一行摘要(给 CLI / 日志;**同一条信息只说一次**)─────────────────────────
export function describeRegistryLine(reg: NodesRegistry): string {
const nets = summarizeNetworks(reg)
if (nets.length === 0) return `注册表:0 张网 0 个节点(⛔ 空注册表 ⇒ 派生结果为空 ⇒ 默认拒绝一切拨号)`
return `注册表:${nets.map((n) => `${n.network}(approved ${n.approved}/pending ${n.pending})`).join(' · ')}`
}
+142
View File
@@ -0,0 +1,142 @@
/**
* 覆盖网络 **管理面 API**(序㊵ · P2/S5 一并做 —— 该文件是 P1 的出口项)。
*
* ## 它回答两个问题
* | 端点 | 作用 | 鉴权 |
* |---|---|---|
* | `GET /api/admin/overlay-nodes` | **有哪些网 / 每个节点什么状态**(只读汇总) | `requireAdmin` |
* | `GET /api/admin/overlay-nodes/direct` | 本机**直连开关**现状 + **三段提示** | `requireAdmin` |
* | `POST /api/admin/overlay-nodes/direct` | **用户可设置**(口径①)—— 只改本机配置的 `direct` 字段 | `requireAdmin` |
*
* ## 🔴 三条纪律
* ① **全部走 `requireAdmin`** —— ⛔ 不做第二个无鉴权端点:既有的 `GET /dshs-overlay/bootstrap`
* 之所以能无鉴权,是因为它**只服务还没有凭据的新节点**且内容被收窄到"去哪儿";
* "**有哪些节点**"是**拓扑信息**,放公网 = 扩大暴露面(命中 **R5**)。
* ② **只写"跟着这台机器走"的那一项** —— 直连开关放**本机配置**(`<NODE_CONFIG_FILE>.direct`),
* ⛔ 不写共享 drop-in / env:那属于控制面或运维的通道(见 `direct/index.ts#resolveDirectSwitch`
* 的优先级口径:env > 本机配置 > 缺省)。
* ③ **失败具名** —— 文件不存在 / 形状坏 / 写不进去,各自一个原因码,⛔ 不吞、⛔ 不静默创建。
*
* @module dshs/web/routes/overlay-nodes
*/
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import type { FastifyPluginAsync } from 'fastify'
import {
DIRECT_ENV_KEY,
DEFAULT_DIRECT_ENABLED,
NODE_CONFIG_FILE_DEFAULT,
directFromNodeConfig,
directHintLines,
resolveDirectSwitch,
writeNodeConfigDirect,
} from '../../net/relay/direct/index.js'
import { loadRegistry, summarizeNetworks, listNodes } from '../../net/relay/registry.js'
import { requireAdmin } from '../middleware/authn.js'
/** 本机配置落点(`DSHS_OVERLAY_NODE_CONFIG` 可覆盖;缺省与 `join` 的 `--config` 一致)。 */
function nodeConfigFile(): string {
const v = process.env.DSHS_OVERLAY_NODE_CONFIG
return typeof v === 'string' && v.trim() !== '' ? v.trim() : NODE_CONFIG_FILE_DEFAULT
}
/** 注册表落点(`DSHS_OVERLAY_NODES_FILE` 可覆盖;缺省 = 参数表 `NODES_REGISTRY_FILE`)。 */
function registryFile(): string {
const v = process.env.DSHS_OVERLAY_NODES_FILE
return typeof v === 'string' && v.trim() !== '' ? v.trim() : '/var/lib/dshs/overlay/nodes.json'
}
const fss = {
exists: (p: string) => existsSync(p),
read: (p: string) => readFileSync(p, 'utf8'),
write: (p: string, text: string, mode: number) => writeFileSync(p, text, { mode }),
rename: (from: string, to: string) => renameSync(from, to),
}
/** 读本机配置里的 `direct`(**缺文件 ⇒ `undefined`**,形状坏 ⇒ 抛给调用方具名化)。 */
function readLocalDirect(): { file: string; present: boolean; direct?: boolean } {
const file = nodeConfigFile()
if (!existsSync(file)) return { file, present: false }
const raw: unknown = JSON.parse(readFileSync(file, 'utf8'))
return { file, present: true, direct: directFromNodeConfig(raw) }
}
export const overlayNodeRoutes: FastifyPluginAsync = async (app) => {
/** 只读汇总:有哪些网 / 每张网几个 approved、几个 pending / 节点清单。 */
app.get('/api/admin/overlay-nodes', { preHandler: requireAdmin }, async (_request, reply) => {
const file = registryFile()
if (!existsSync(file)) {
// "这套准入还没开始用"是**合法状态** ⇒ 200 + 明确的 `present:false`(⛔ 不是 500,也不是空 200)
return reply.send({ registry: { file, present: false }, networks: [], nodes: [] })
}
let reg
try {
reg = loadRegistry(file)
} catch (err) {
return reply.code(500).send({ error: 'registry-unreadable', detail: err instanceof Error ? err.message : String(err) })
}
return reply.send({
registry: { file, present: true, version: reg.version },
networks: summarizeNetworks(reg),
nodes: listNodes(reg).map((n) => ({
network: n.network,
hostId: n.hostId,
status: n.status,
group: n.group,
appliedAt: n.appliedAt,
approvedAt: n.approvedAt,
})),
})
})
/** 直连开关现状(含**三段提示** —— 用户口径③的"设置面显示"落点)。 */
app.get('/api/admin/overlay-nodes/direct', { preHandler: requireAdmin }, async (_request, reply) => {
let local: { file: string; present: boolean; direct?: boolean }
try {
local = readLocalDirect()
} catch (err) {
return reply.code(500).send({ error: 'node-config-bad', detail: err instanceof Error ? err.message : String(err) })
}
const state = resolveDirectSwitch(process.env, { localDirect: local.direct })
return reply.send({
envKey: DIRECT_ENV_KEY,
defaultEnabled: DEFAULT_DIRECT_ENABLED,
effective: state.enabled,
source: state.source,
raw: state.raw,
invalid: state.invalid,
nodeConfig: { file: local.file, present: local.present, direct: local.direct ?? null },
hint: directHintLines(),
})
})
/**
* 设置直连开关(**用户可设置**)。
*
* ⛔ 只动本机配置的 `direct`;⚠️ 若 env 里显式设了 `DSHS_OVERLAY_DIRECT`,它会**压过**本项
* ⇒ 回执里把这件事**明说**(否则用户会以为"改了没生效 = 平台坏了")。
*/
app.post('/api/admin/overlay-nodes/direct', { preHandler: requireAdmin }, async (request, reply) => {
const body = request.body as { enabled?: unknown } | undefined
if (body === undefined || typeof body.enabled !== 'boolean') {
return reply.code(400).send({ error: 'bad-body', detail: 'body 须为 {enabled: boolean}' })
}
const outcome = writeNodeConfigDirect(nodeConfigFile(), body.enabled, fss)
if (!outcome.ok) return reply.code(400).send({ error: outcome.reason, detail: outcome.detail })
const state = resolveDirectSwitch(process.env, { localDirect: outcome.direct })
return reply.send({
ok: true,
file: outcome.file,
direct: outcome.direct,
effective: state.enabled,
source: state.source,
envOverrides: state.source === 'env',
note:
state.source === 'env'
? `⚠️ 本机 env 里已显式设了 ${DIRECT_ENV_KEY}=${state.raw} ⇒ 它**压过**本次设置(env > 本机配置 > 缺省)`
: '已生效(重启节点服务后按本文件生效)',
})
})
}
+3
View File
@@ -62,6 +62,7 @@ import { desktopRoutes } from './routes/desktop.js'
import { dshRoutes } from './routes/dsh.js'
import { domainRoutes } from './routes/domain.js'
import { overlayRoutes } from './routes/overlay.js'
import { overlayNodeRoutes } from './routes/overlay-nodes.js'
import { skillRoutes } from './routes/skills.js'
import { whitelistRoutes } from './routes/whitelist.js'
@@ -1163,6 +1164,8 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
await app.register(dshRoutes)
await app.register(domainRoutes)
await app.register(overlayRoutes)
// 序㊵(P2/S5):覆盖网络**管理面**(节点清单只读 + 直连开关读写)—— 全部走 requireAdmin
await app.register(overlayNodeRoutes)
await app.register(skillRoutes)
await app.register(whitelistRoutes)