覆盖网络线 S0+S1 落地:可达性单一入口 + 会合地址出 env

S0(本机):新增 src/net/reachability.ts(Reachability 描述 + agentBaseUrlOf 唯一取值入口)、src/net/rendezvous.ts、docs/architecture.md(四层划分 + 依赖方向 + R1-R4 判据)、scripts/check-layering.mjs + layering-baseline.json、test/reachability.test.mjs;src/supervisor/remote-spawner.ts 与 src/web/server.ts 改为统一走 agentBaseUrlOf(),agentUrl 降级为可选旧字段(向后兼容)。

S1(本机 + 106):新增 DSHS_RENDEZVOUS_URL 出 env(src/config.ts 解析 clusterRendezvousUrl,优先级 overrides > 新变量 > DSHS_TUNNEL_TARGET 兜底 > 空),src/worker/tunnel.ts 新增 normalizeTunnelTarget()、src/worker/agent.ts 改读新配置 ⇒ 双路径并存、可零代码回滚(删掉新 env 即走旧路径)。106 已上线,健康检查 tunnel.ready=true。

package.json 增加 check:layering 脚本并接入 verify;README 登记分层文档。

验收:npm run build 通过;npm test 44/44;check:layering 无新增违规(基线 5 条)。
This commit is contained in:
admin committed 2026-09-16 15:02:52 +08:00
1 parent 08219c99da
commit 640813e84e
13 files changed
+660 -18

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
/**
* 可达性(Reachability)—— **"怎么到这台 worker"的可序列化描述**(覆盖网络 S0)。
*
* ## 为什么需要它
* 现状里"怎么到一台 worker"被写死成两件事:`dsh_hosts.endpoint` 存一个 URL,
* `clusterInstanceHost` 存一个**全局**主机名。两者都**表达不出"经谁中转"**,而现网
* 恰好有两类语义完全不同的 host,字符串却同形:
*
* | hostId | endpoint | 真实语义 |
* |---|---|---|
* | `w-47` | `http://127.0.0.1:19100` | **直连本机**(Manager 与 worker 同机,node 直接监听) |
* | `w-106` | `http://127.0.0.1:19000` | **经 47 上 sshd 的反向隧道落点**(隧道的副作用) |
*
* ⇒ 换会合 / 中继组件时,表里**无法表达**「via(经哪个中继)+ 真实可达地址」,
* 只能改表;越晚改代价越大(会合中继拆分方案 §2 C3)。
*
* `Reachability` 把这件事显式化:`via` 指向一个 `Rendezvous` 实现,`address` 是真实地址。
*
* ## 边界(勿破)
* 本模块**只做地址的表征与解析**,不承载任何权威状态 —— 归属 / 租约 / 骨干资格
* 一律仍只由控制面写(与 `集群化改造方案 §1.3` 数据分层一致)。
*
* @module dshs/net/reachability
*/
/** 同机直连:Manager 与 worker 在同一台机器上,不经任何中转。 */
export const VIA_LOCAL = 'local'
/** 今天唯一在跑的中转方式 = **Manager 主机上的 sshd 反向隧道**(S4 之后应被 relay 取代)。 */
export const VIA_MANAGER_SSH = 'manager-ssh'
export interface Reachability {
/** 哪台 worker。 */
hostId: string
/** **经谁可达** —— 一个 `Rendezvous` 实现的 id。 */
via: string
/** agent 的真实地址 `host:port`(**不含 scheme**)。 */
address: string
/** 传输层。 */
scheme: 'http' | 'https'
}
/** 只取址所需的最小形状 —— 避免本模块反向依赖 `supervisor`。 */
export interface HostAddressable {
hostId?: string
agentUrl?: string
reachability?: Reachability
}
/**
* 把可达性拼成 agent 基址 —— **全仓唯一的拼接点**,别在别处再拼 `scheme://address`。
*/
export function agentBaseUrl(reach: Reachability): string {
return `${reach.scheme}://${reach.address}`.replace(/\/+$/, '')
}
/**
* 取一台 host 的 agent 基址:**可达性优先,回退旧 `agentUrl`**。
*
* S0 阶段的等价性:现网每个 host 都只有 `agentUrl`(`reachability` 全为 `undefined`)
* ⇒ 本函数返回的就是原先直接用的那个字符串,**行为零变化**。
*
* ⚠️ 两者皆缺时**抛错**,不返回空串 —— "静默打到空地址"是跨机下最难查的失败。
*/
export function agentBaseUrlOf(host: HostAddressable): string {
if (host.reachability !== undefined) return agentBaseUrl(host.reachability)
if (host.agentUrl !== undefined && host.agentUrl !== '') return host.agentUrl.replace(/\/+$/, '')
throw new Error(`host "${host.hostId ?? '?'}" 既无 reachability 也无 agentUrl:拒绝静默降级`)
}
/**
* 从旧的 `endpoint` 字符串解析出 `Reachability`(S2 迁移回填用)。
*
* 兼容面:`endpoint` 历史上是完整 URL(`http://127.0.0.1:19000`),也容忍裸
* `host:port` —— 没写 scheme 时按 `http` 处理,与 `RemoteSpawner` 原先"直接把它当
* fetch 基址"的行为一致(fetch 会补 `http://`)。
*/
export function parseReachability(
hostId: string,
endpoint: string,
via: string = VIA_MANAGER_SSH,
): Reachability {
const trimmed = endpoint.trim()
const matched = /^(https?):\/\/(.*)$/i.exec(trimmed)
if (matched !== null) {
return {
hostId,
via,
address: matched[2].replace(/\/+$/, ''),
scheme: matched[1].toLowerCase() === 'https' ? 'https' : 'http',
}
}
return { hostId, via, address: trimmed.replace(/\/+$/, ''), scheme: 'http' }
}
/** `Reachability` → 旧 `endpoint` 字符串(与 `parseReachability` 互逆,回填/回滚用)。 */
export function toEndpoint(reach: Reachability): string {
return agentBaseUrl(reach)
}
+112
View File
@@ -0,0 +1,112 @@
/**
* 会合(Rendezvous)—— **"该拨谁、经谁到"的解析器**(覆盖网络 S0)。
*
* ## 定位(分层口径,勿破)
* | 组件 | 职责 | 可多实例? | 权威状态 |
* |---|---|---|---|
* | **会合 (rendezvous)** | 收 worker 注册、回"该拨谁"、下发中继分配;**不承载数据面流量** | ✅ 无状态可复制 | ❌ 只有位置视图 |
* | **中继 (relay)** | 数据面:worker 拨它 → Manager / 其他节点经它到 worker | ✅ | ❌ |
* | **控制面 (Manager)** | 归属 / 租约 / 骨干资格 / 容量准入 | ❌ 单点 | ✅ 唯一写入者 |
*
* ⛔ **硬约束**:会合与中继**不得**写入 `dsh_instances.host_id` / `epoch` / 骨干资格 ——
* 否则就是双写脑裂。
*
* ## 现状与目标
* 今天"会合 + 中继"**不是一个组件,而是 Manager 主机上 sshd 的副作用**:
* 会合点 = `47.77.182.89:32022`,中继落点 = Manager 的 `127.0.0.1`。
* 本模块的作用是**先把接口抽出来**,让 SSH 隧道退化成"第一个可替换实现"
* —— ⛔ 这一步**不换协议**,只换绑定与寻址(换 WireGuard / TURN 属远期)。
*
* @module dshs/net/rendezvous
*/
import { VIA_LOCAL, VIA_MANAGER_SSH, type Reachability } from './reachability.js'
export interface Rendezvous {
/** 实现 id —— `Reachability.via` 指向它。 */
readonly id: string
/** 这个会合点**本身**怎么拨(诊断 / 管理面展示用)。 */
dialTarget(): string
/**
* 解析某台 worker 的可达性。
* ⚠️ **本实现管不到 ⇒ 回 `undefined`,不抛** —— 由调用方决定回退哪种实现
* (抛错会让"多实现并存"的过渡期没法跑)。
*/
resolve(hostId: string): Promise<Reachability | undefined>
}
/** 由调用方提供"hostId → `host:port`"的查表函数(会合实现不直接连 DB)。 */
export type AddressLookup = (hostId: string) => string | undefined
/** 同机直连:Manager 能直接连到 worker 的端口,不经任何中转(`w-47` 就是这一类)。 */
export class LocalRendezvous implements Rendezvous {
readonly id = VIA_LOCAL
constructor(private readonly addressOf: AddressLookup) {}
dialTarget(): string {
return '(direct)'
}
async resolve(hostId: string): Promise<Reachability | undefined> {
const address = this.addressOf(hostId)
return address === undefined ? undefined : { hostId, via: this.id, address, scheme: 'http' }
}
}
/**
* Manager 主机上的 sshd 反向隧道 —— **当前唯一在跑的实现**。
*
* 语义:worker 主动 `ssh -R <port>:127.0.0.1:<port> root@<manager>:<port>`,把端口投到
* Manager 的 **loopback**(`127.0.0.1:<同号端口>`)⇒ Manager 经 `127.0.0.1:<port>` 到达它。
*
* ⚠️ 这正是要拆掉的那一层(C1 会合地址硬编码 + C2 中继落点 = Manager loopback)。
* S4 把"接收 worker 反拨"搬进独立单元 `dshs-relay.service` 后,本类应被 `relay:<id>`
* 实现替换,而**调用方不需要改**(只认 `Rendezvous` 接口)。
*/
export class ManagerSshRendezvous implements Rendezvous {
readonly id = VIA_MANAGER_SSH
constructor(
private readonly opts: {
/** 会合点的 SSH 目标,如 `[email protected]:32022`。 */
target: string
addressOf: AddressLookup
},
) {}
dialTarget(): string {
return this.opts.target
}
async resolve(hostId: string): Promise<Reachability | undefined> {
const address = this.opts.addressOf(hostId)
return address === undefined ? undefined : { hostId, via: this.id, address, scheme: 'http' }
}
}
/**
* 按 `via` 选实现的注册表。
*
* 为什么需要:S2 起 `dsh_hosts` 会带 `via` 列,`hostsProvider` 必须"**先读 via →
* 选对应实现 → 解析成 `Reachability`**";`via` 未设时回退旧 `endpoint` 语义。
*/
export class RendezvousRegistry {
private readonly impls = new Map<string, Rendezvous>()
constructor(impls: readonly Rendezvous[] = []) {
for (const impl of impls) this.register(impl)
}
register(impl: Rendezvous): void {
this.impls.set(impl.id, impl)
}
get(id: string): Rendezvous | undefined {
return this.impls.get(id)
}
ids(): string[] {
return [...this.impls.keys()]
}
}