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

+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> {