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:
1 parent
09ce76f3af
commit
c452013129
18 files changed
+6604
-78
No files matched your search
@@ -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() }),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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` 形态。
|
||||
*
|
||||
|
||||
@@ -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(' · ')}`
|
||||
}
|
||||
@@ -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 > 本机配置 > 缺省)`
|
||||
: '已生效(重启节点服务后按本文件生效)',
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user