Files
dsh_shenxian/src/net/relay/network.ts
T
admin 146c3d25ef feat(overlay): 覆盖网络线序①–⑮ 代码与测试产物入库
覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 relay 子系统 src/net/relay/**(wire/duplex/server/client/dialer/switcher/directory/identity/keys/placement/network/addr-override/main/index)
- 新增 src/worker/relay-tunnel.ts、src/web/routes/overlay.ts
- 新增观测/演练脚本 overlay-probe、overlay-failover-drill、overlay-keyring、overlay-holepunch、overlay-jitter、overlay-wan、overlay-relaykey-add、relay-mem-calibrate
- 新增测试 12 个(relay / relay-failover / remote-spawner / instance-port / overlay-{network,auth,bootstrap,identity} / remote-user-fs 等)

验收基线:npm test = 162 pass / 0 fail / 1 skip;--scene all = 12 PASS / 0 SKIP / 0 FAIL;overlay-probe = 12/12
2026-09-17 17:03:27 +08:00

223 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 网抽象 —— **`network_id` 维度与"节点逻辑名"的唯一入口**(覆盖网络 ②·P0-1)。
*
* ## 它解决的确切问题
* R0–R5 跑通之后,relay 把**平台自己的 Worker 隧道**和**未来的用户设备**塞进**同一个扁平
* `hostId` 命名空间**(`server.ts` 的 `sessions` / `endpoints` 都只按 `hostId` 索引,全仓
* `grep -ri networkId|tailnet` = 0 命中)。今天只有 1 个用户、1 张网,问题不显形;一进第二类
* 节点就会变成「**一张巨网 + 靠 ACL 兜**」—— 而写错一条 ACL 就泄露,这与项目
* 「**权限只准收窄**」直接冲突。
*
* ⇒ 本模块把「**哪张网**」变成**结构性维度**(不是策略性):判据只有这一处,纯函数、无 IO、可单测。
* `server.ts` / `client.ts` / 运维配置**一律从这里取**,⛔ 不许在调用方拼字符串
* (散着拼迟早就出现三套不一致的口径 —— 本线复盘里反复出现的那类病)。
*
* ## 取值(D2 · 已定项)
* - **运维网固定 `ops`**:47(Manager)/ 106(Worker)/ 未来的中继与骨干 —— 即"平台自己的机器"。
* - **用户网 = `u:<userId>`**:该用户名下的全部设备(含桌面客户端)。
* ⇒ 存量数据天然落在 `ops`(这正是"加列带默认值"能零风险落地的原因)。
*
* ## ⛔ 本模块**不做**的事(故意)
* - 不碰虚拟网卡 / L3 地址 / `100.64.0.0/10`(`交接单_网抽象与地址规划R6 §2 D1`);
* - 不解析 DNS、不下发对端清单(D4):本阶段收敛为「**逻辑名 + 授权**」。
*
* @module dshs/net/relay/network
*/
/** 运维网(平台自己的机器)。**固定值**,与部署位置无关。 */
export const OPS_NETWORK = 'ops'
/**
* 网络 id 的合法形状(三条分支,见 `isNetworkId`)。
*
* 收在一个入口里,是为了让"配错了"在**装载配置时就炸**,而不是变成
* 「谁也没匹配上 ⇒ 静默拒绝」(静默拒绝是本线反复要根治的病:它把"配置错"伪装成"网络不通")。
*/
const TENANT_NET_RE = /^u:[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/
const GENERIC_NET_RE = /^[a-z0-9][a-z0-9_.-]{0,63}$/
/** hostId 的合法形状(`w-47` / `w-106` / `manager` …)。 */
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/
/** 逻辑名的**规范分隔符**。`<network_id>/<hostId>` —— `network_id` 不含 `/`,故**首个** `/` 即分隔点。 */
export const NAME_SEP = '/'
/**
* 网络 id 是否合法。三条分支:
* - `ops` —— 运维网(固定值);
* - `u:<租户 id>` —— 用户网(租户 id 是数字或字符串都行,故后半段放宽);
* - 其它显式命名(小写字母数字与 `_.-`)—— 留给将来的"二级网 / 测试网",**不新增机制**。
*
* ⛔ **裸 `u` 是保留字**,永远不是一个合法的网名:它是"用户网前缀"。放行它会带来一个极隐蔽的坑 ——
* `DSHS_RELAY_DIALERS="u:5:manager"` 里的网络段 `u:5` 一旦被写错成 `u:5` 之外的形式(或有人直接
* 写 `u:manager`),左/右切点会把网络段切成 `u`,于是"配置写错"**长得像"这张网不存在"**(静默拒绝)。
*/
export function isNetworkId(raw: string): boolean {
if (raw === OPS_NETWORK) return true
if (raw === 'u') return false
return TENANT_NET_RE.test(raw) || GENERIC_NET_RE.test(raw)
}
export function isHostId(raw: string): boolean {
return HOST_RE.test(raw)
}
/**
* 校验网络 id;非法 ⇒ **抛**(不返回 `undefined`)。
*
* 为什么抛而不"兜个默认值":`DSHS_OVERLAY_NETWORK_ID` 打错一个字,如果静默回落 `ops`,
* 那台机器会**以正确的样子加入错误的网**(表现是"另一张网里看不见它",且没有任何一行日志说得出原因)。
*/
export function assertNetworkId(raw: string, what = 'network id'): string {
const v = raw.trim()
if (!isNetworkId(v)) throw new Error(`${what} 非法:${JSON.stringify(raw)}(合法形状:ops | u:<租户> | [a-z0-9][a-z0-9_.-]*)`)
return v
}
/** 节点逻辑名 = `<network_id>/<hostId>`。**唯一拼法**(别在调用方拼)。 */
export function logicalName(network: string, hostId: string): string {
return `${network}${NAME_SEP}${hostId}`
}
/**
* 反解一个逻辑名 / 配置项。
*
* ## 三条分隔规则(顺序即优先级)
* 1. 含 `/` ⇒ 按**首个** `/` 切(`ops/manager` · `u:5/w-106`)—— 规范形态;
* 2. 否则含 `:` ⇒ 按**最后一个** `:` 切(`ops:manager` · `u:5:manager`)——
* 为的是兼容运维习惯的 `network:hostId` 写法;⚠️ 必须从**右**切:`u:5` 里的 `:` 属于网络 id;
* 3. 都不含 ⇒ **旧形态**(R5 时代的扁平 `hostId`)⇒ 落在 `ops`,**旧配置照旧可用**。
* (过渡期不破坏现网:`DSHS_RELAY_DIALERS="manager"` 与 `"ops:manager"` 等价。)
*
* ⚠️ 第 2/3 条里"切出来的网络 id 必须合法",否则**抛** —— 见 `assertNetworkId` 的理由。
*/
export function parseLogicalName(raw: string): { network: string; hostId: string } {
const { network, hostId } = parseEntry(raw)
return { network, hostId }
}
/**
* 同 `parseLogicalName`,但额外告诉调用方"这条**自己写了网络没有**"(`qualified`)。
*
* 为什么需要这个位:白名单的**两种入参**对"裸 hostId"的解释**不同** ——
* - 扁平列表(`Set`):裸 hostId = R5 旧写法 ⇒ `ops`;
* - 按网络分桶(`Map`):裸 hostId = **本桶那张网**里的 hostId。
* 少了这个位,"`Map{'u:x' => {'d-user'}}`" 会被当成"把 ops 的 host 塞进 u:x 桶"⇒ 判据永远不命中
* ⇒ **静默拒绝**(本线反复要根治的那类病:配置写对了,表现却像网络不通)。
*/
function parseEntry(raw: string): { network: string; hostId: string; qualified: boolean } {
const v = raw.trim()
if (v === '') throw new Error('逻辑名为空')
const slash = v.indexOf(NAME_SEP)
if (slash > 0 && slash < v.length - 1) {
const network = assertNetworkId(v.slice(0, slash), `逻辑名 ${JSON.stringify(raw)} 里的网络段`)
return { network, hostId: assertHostId(v.slice(slash + 1), raw), qualified: true }
}
const colon = v.lastIndexOf(':')
if (colon > 0 && colon < v.length - 1) {
const network = assertNetworkId(v.slice(0, colon), `逻辑名 ${JSON.stringify(raw)} 里的网络段`)
return { network, hostId: assertHostId(v.slice(colon + 1), raw), qualified: true }
}
// 旧形态:扁平 hostId(**不带网络**,由调用方决定它属于哪张网,默认运维网)。
return { network: OPS_NETWORK, hostId: assertHostId(v, raw), qualified: false }
}
function assertHostId(raw: string, whole: string): string {
const v = raw.trim()
if (!isHostId(v)) throw new Error(`逻辑名 ${JSON.stringify(whole)} 里的 hostId 非法:${JSON.stringify(raw)}`)
return v
}
/** 两个节点是否同网(DIAL 的**结构性**判据:不同网 ⇒ 连"能不能拨"这一步都走不到)。 */
export function sameNetwork(a: string, b: string): boolean {
return a === b
}
/**
* 同网**断言**(跨网 ⇒ 抛)—— P0-3 的"控制面侧那道门"。
*
* 为什么不是让调用方用 `sameNetwork` 自己判:那样的常见写法是
* `if (!sameNetwork(a, b)) return undefined` ⇒ **跨网拒绝在日志里什么都不留**,
* 表现与"节点离线 / 名字打错"完全同形 —— 正是本线反复要根治的静默失效。
* 凡"跨网就是错"的地方一律用本函数:错误里**带着两边是哪张网**,排障不必猜。
*/
export function assertSameNetwork(expected: string, actual: string, what = '节点'): void {
if (expected === actual) return
throw new Error(`${what} 跨网:期望 "${expected}",实际 "${actual}"(结构性隔离 ⇒ 不允许跨网解析)`)
}
/**
* 拨号方白名单的**归一化** —— 同时接受两种入参,旧形态照旧可用(过渡期不破坏现网)。
*
* | 入参 | 桶里"裸 hostId"的含义 |
* |---|---|
* | `Set<string>`(R5 形态,`main.ts` 从 env 读出来的就是它) | 该条目**自带**网络(`ops:manager` / `u:5:d1`);裸 hostId ⇒ `ops` |
* | `Map<network, Set<hostId>>`(新形态) | 裸 hostId ⇒ **本桶那张网**里的 hostId |
*
* ⚠️ 两种入参对"裸 hostId"的解释不同,是**故意的**:分桶形态下,桶键已经表达了网络,
* 桶里再写一遍网络只会带来"两处写不一致"的新风险(所以**写了就必须一致,否则抛**)。
*
* ⚠️ 返回的 map **永远是新的**(不被调用方后续 `Set` 改动影响)—— 白名单是安全判据,
* 不能因为某个持有引用的调用方顺手 `add()` 就悄悄放宽。
*/
export function normalizeDialers(
input: ReadonlySet<string> | ReadonlyMap<string, ReadonlySet<string>> | undefined,
): Map<string, ReadonlySet<string>> {
const out = new Map<string, ReadonlySet<string>>()
if (input === undefined) return out
if (isDialerMap(input)) {
for (const [networkRaw, hosts] of input.entries()) {
const network = assertNetworkId(networkRaw, '拨号方白名单的网络段')
const bucket = new Set<string>(out.get(network) ?? [])
for (const host of hosts ?? []) {
const e = parseEntry(host)
if (e.qualified && e.network !== network) {
throw new Error(
`拨号方白名单自相矛盾:桶 "${network}" 里放了属于 "${e.network}" 的条目 ${JSON.stringify(host)}`,
)
}
bucket.add(e.hostId)
}
out.set(network, bucket)
}
return out
}
// 扁平列表(R5 形态):逐条按自带网络**分发**到各自的桶;裸 hostId ⇒ 运维网。
for (const host of input) {
const e = parseEntry(host)
const bucket = new Set<string>(out.get(e.network) ?? [])
bucket.add(e.hostId)
out.set(e.network, bucket)
}
return out
}
/**
* 类型判别:`Map` 形态 vs 扁平 `Set` 形态。
*
* 为什么要单独一个函数而不是直接 `input instanceof Map`:`ReadonlyMap` / `ReadonlySet` 都是
* **接口**,`instanceof` 收窄在联合类型上不可靠(TS 会把 else 分支仍当成联合,于是迭代出来的
* 元素类型变成 `string | [string, …]`)。写成显式类型谓词,编译器与读代码的人都清楚。
*/
function isDialerMap(
v: ReadonlySet<string> | ReadonlyMap<string, ReadonlySet<string>>,
): v is ReadonlyMap<string, ReadonlySet<string>> {
return typeof (v as ReadonlyMap<string, ReadonlySet<string>>).get === 'function'
}
/**
* 一行摘要(**同一条信息只说一次**)—— `main.ts` 启动日志与 `/status` 共用同一格式,
* 免得"日志说的"与"status 说的"长得不一样(那种差异排查时最费时间)。
*
* `ops` 下的条目**省略网络前缀**(与 R5 的日志/配置写法一致,读起来不变)。
*/
export function describeDialers(map: ReadonlyMap<string, ReadonlySet<string>>): string[] {
const out: string[] = []
for (const network of [...map.keys()].sort()) {
for (const host of [...(map.get(network) ?? [])].sort()) {
out.push(network === OPS_NETWORK ? host : logicalName(network, host))
}
}
return out
}