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
This commit is contained in:
admin committed 2026-09-17 17:03:27 +08:00
1 parent 640813e84e
commit 146c3d25ef
53 files changed
+16187 -63

No files matched your search

+5 -2
View File
@@ -37,7 +37,7 @@ import {
type CrashPolicyConfig,
} from './crash-policy.js'
import { createPortGuard, type PortGuard } from './firewall.js'
import { findFreePort, scrubEnv } from './spawn.js'
import { findInstancePort, scrubEnv } from './spawn.js'
import {
AlreadyRunningError,
CrashBreakerOpenError,
@@ -592,7 +592,10 @@ export class LocalSpawner implements Spawner {
private async spawnInstance(userId: string, role: InstanceRole, folder: string, patch?: string): Promise<Instance> {
const isMain = role === 'main'
const port = isMain ? await findFreePort() : undefined
// 覆盖网络 S3:端口来自**本机专属区间**(未配 env ⇒ 退回旧的 `listen(0)`)。
// 为什么不能用 `listen(0)`:跨机实例的隧道落点全挤在 Manager 的 `127.0.0.1`,
// 两台 worker 各自随机取端口就会撞号,而 `-R` 失败是**静默**的 ⇒ 会拨到别人的实例。
const port = isMain ? await findInstancePort(this.config.instancePortBase, this.config.instancePortSpan) : undefined
const instance: Instance = {
id: randomUUID(),
userId,
+38 -4
View File
@@ -42,6 +42,14 @@ export interface ClusterHost {
token: string
/** 代理时使用的主机(同机 1a = `127.0.0.1`;跨机填 Worker 内网 IP)。 */
instanceHost?: string
/**
* **表里声明的会合形态**(`dsh_hosts.via` 原文,P0-3 加)。
*
* 只有一处用途:`agentBaseUrlOf()` 在 `via='relay'` 且**解析不出落点**时**拒绝回落到
* `agentUrl`**(relay 语义下 `endpoint` 是落点而非直连地址,回落会打到本机同号端口)。
* 省略 = 不触发该保护(同机 `clusterHostId` 那条就是这一类)。
*/
via?: string
}
export interface RemoteSpawnerOptions {
@@ -76,6 +84,18 @@ export interface RemoteSpawnerOptions {
hostsProvider?: () => Promise<ClusterHost[]>
/** 目录缓存时长(ms)。默认 30 s —— 与心跳同量级。 */
directoryTtlMs?: number
/**
* **实例端点翻译器**(覆盖网络 R4):把"Worker 视角的 `{host, port}`"翻成"Manager 能拨的
* `{host, port}`"。省略 = 不翻译(同号语义,即 ssh 隧道与同机形态的既有行为)。
*
* 为什么翻译必须发生在**拨号的那一端**:relay 为每个端口在**它自己的回环**上开一条监听,
* 口号是动态分配的 ⇒ 只有 Manager 知道"Worker 的 21000 此刻对应我本机的 42067"。
* 让 Worker 去上报这个口号,等于把"Manager 侧地址观"塞进 Worker(relay 换机部署时两头都要改)。
*
* 返回 `undefined` = **翻译不出来**(relay 里查不到该端口 / 该 host 不是 relay 形态)⇒
* 调用方按"实例不可达"处理,**绝不回退成 Worker 侧原口号**(那会打到死地址上)。
*/
translateEndpoint?: (hostId: string, endpoint: Endpoint) => Endpoint | undefined
}
const RETRY_DELAYS_MS = [200, 1000, 3000]
@@ -90,6 +110,7 @@ export class RemoteSpawner implements Spawner {
private readonly hostIdFor?: (userId: string) => Promise<string | undefined>
private readonly hostsProvider?: () => Promise<ClusterHost[]>
private readonly directoryTtlMs: number
private readonly translateEndpoint?: (hostId: string, endpoint: Endpoint) => Endpoint | undefined
private directoryLoadedAt = 0
constructor(options: RemoteSpawnerOptions) {
@@ -112,6 +133,12 @@ export class RemoteSpawner implements Spawner {
this.hostIdFor = options.hostIdFor
this.hostsProvider = options.hostsProvider
this.directoryTtlMs = options.directoryTtlMs ?? 30_000
// ⚠️ 漏掉这一行的后果(2026-09-16 实测踩到):`endpointFor` 会**静默回退成原样透传** ⇒
// Manager 拿着 Worker 视角的口号(如 `127.0.0.1:21000`)往**自己本机**拨 ⇒ 连接被拒两次
// ⇒ 代理 `reply.raw.destroy()` ⇒ 浏览器只看到 **空响应**(`curl` 报 `Empty reply from server`),
// 而平台上**一行错误日志都没有**。这正是覆盖网络线要消灭的那类静默失败。
// 判据:`test/remote-spawner.test.mjs` 第 1 例(构造时就断言 `translateEndpoint` 生效)。
this.translateEndpoint = options.translateEndpoint
}
/**
@@ -187,11 +214,17 @@ export class RemoteSpawner implements Spawner {
operationId?: string,
): Promise<T> {
const payload = body === undefined ? undefined : { ...body, ...(operationId === undefined ? {} : { operationId }) }
/**
* 取址**放在重试循环外**(P0-3):`agentBaseUrlOf()` 现在**会抛**(`via=relay` 却解析不出落点
* ⇒ 拒绝回落到 endpoint)。留在循环里,这条**确定性错误**会被重试 3 次,
* 而且在日志里长得像"网络抖动" —— 排障时最费时间的那类假象。
*/
const base = agentBaseUrlOf(host)
let lastErr: unknown
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
if (attempt > 0) await new Promise((r) => setTimeout(r, RETRY_DELAYS_MS[attempt - 1]))
try {
const res = await this.doFetch(`${agentBaseUrlOf(host)}${path}`, {
const res = await this.doFetch(`${base}${path}`, {
method,
headers: {
[AGENT_TOKEN_HEADER]: host.token,
@@ -282,9 +315,10 @@ export class RemoteSpawner implements Spawner {
'GET',
`/endpoint/${encodeURIComponent(userId)}`,
)
return res.running && res.host !== undefined && res.port !== undefined
? { host: res.host, port: res.port }
: undefined
if (!res.running || res.host === undefined || res.port === undefined) return undefined
const raw: Endpoint = { host: res.host, port: res.port }
// R4:`relay` 形态下 Manager 侧的口号是 relay 动态分配的 ⇒ 必须在这里翻译(见选项注释)。
return this.translateEndpoint === undefined ? raw : this.translateEndpoint(host.hostId, raw)
}
async stop(userId: string, hostId?: string): Promise<void> {
+45
View File
@@ -64,3 +64,48 @@ export function findFreePort(): Promise<number> {
})
})
}
/**
* 在 `[base, base + span)` 内取一个空闲回环端口(覆盖网络 S3)。
*
* ## 为什么不能用 `listen(0)`
* `listen(0)` 让**每台 worker 各自**随机取端口,而所有跨机实例的**隧道落点全挤在 Manager
* 的 `127.0.0.1`** 上 ⇒ **两台 worker 取到同号就撞号**:`ssh -R` 失败被静默忽略
* (`tunnel.forward()` 的返回值无人检查),Manager 仍按该端口拨 ⇒ **打到别人的实例**
* (2026-09-16 实测)。给每台 worker 一段**互不重叠**的区间即可根治。
*
* ⛔ 区间用尽 **抛错**,不静默退回 `listen(0)` —— 那等于把撞号风险悄悄放回来。
*/
export function findFreePortInRange(base: number, span: number): Promise<number> {
return new Promise((resolve, reject) => {
let candidate = base
const end = base + span
const attempt = (): void => {
if (candidate >= end) {
reject(new Error(`实例端口区间已耗尽:${base}-${end - 1}(span=${span})`))
return
}
const port = candidate
candidate += 1
const server = createServer()
server.once('error', () => attempt())
server.listen(port, '127.0.0.1', () => {
const address = server.address()
const got = typeof address === 'object' && address !== null ? address.port : undefined
server.close(() => {
if (got === undefined) attempt()
else resolve(got)
})
})
}
attempt()
})
}
/**
* 取实例端口的**统一入口**:配了区间(`base>0 && span>0`)就走区间,否则退回旧的 `listen(0)`。
* 单机 / 未配 env 的部署因此**行为零变化**。
*/
export function findInstancePort(base: number, span: number): Promise<number> {
return base > 0 && span > 0 ? findFreePortInRange(base, span) : findFreePort()
}