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:
1 parent
640813e84e
commit
146c3d25ef
53 files changed
+16187
-63
No files matched your search
+472
-3
@@ -17,7 +17,16 @@ import { RemoteUserFs } from '../fs/remote-user-fs.js'
|
||||
import type { UserFs } from '../fs/user-fs.js'
|
||||
import { decrypt, deriveKey } from '../crypto.js'
|
||||
import { hashUid } from '../isolation.js'
|
||||
import { agentBaseUrlOf } from '../net/reachability.js'
|
||||
import { addressPort, agentBaseUrlOf, parseReachability, VIA_MANAGER_SSH, VIA_RELAY } from '../net/reachability.js'
|
||||
import { LocalRendezvous, ManagerSshRendezvous, RendezvousRegistry } from '../net/rendezvous.js'
|
||||
import { OPS_NETWORK, logicalName } from '../net/relay/network.js'
|
||||
import { RelayRendezvous } from '../net/relay/rendezvous.js'
|
||||
import { RelayDialer } from '../net/relay/dialer.js'
|
||||
import { listOverlayRelayCandidates, resolveOverlayRelay } from '../net/relay/directory.js'
|
||||
import { RelayClient, waitUpOnStatus } from '../net/relay/client.js'
|
||||
import { loadClientIdentity } from '../net/relay/identity.js'
|
||||
import { RelayFailoverSupervisor, relayFailoverThresholds } from '../net/relay/switcher.js'
|
||||
import type { RelayChannelHandle } from '../net/relay/switcher.js'
|
||||
import { LocalSpawner } from '../supervisor/orchestrator.js'
|
||||
import { LeasedSpawner } from '../supervisor/leased-spawner.js'
|
||||
import { RemoteSpawner, type ClusterHost } from '../supervisor/remote-spawner.js'
|
||||
@@ -42,6 +51,7 @@ import { businessPluginRoutes } from './routes/business-plugins.js'
|
||||
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 { skillRoutes } from './routes/skills.js'
|
||||
import { whitelistRoutes } from './routes/whitelist.js'
|
||||
|
||||
@@ -59,6 +69,20 @@ declare module 'fastify' {
|
||||
|
||||
const webRoot = join(dirname(fileURLToPath(import.meta.url)), '../../web')
|
||||
|
||||
/* ── 覆盖网络 R3:relay 状态视图的三个时间常数(都在 Manager 本地回环上,量级取"秒")── */
|
||||
/** 单次 `/status` 查询超时。relay 在回环上 ⇒ 超过这个数就是它卡了,不值得等。 */
|
||||
const RELAY_STATUS_TIMEOUT_MS = 2000
|
||||
/** 轮询间隔。取 5s:`dsh_hosts` 目录本身 TTL 是 30s,比它更密即可。 */
|
||||
const RELAY_STATUS_POLL_MS = 5000
|
||||
/** 快照陈旧阈值:超过它一律认为 relay 视图不可信 ⇒ `relay` 实现退回 `manager-ssh`。 */
|
||||
const RELAY_SNAPSHOT_TTL_MS = 15000
|
||||
|
||||
/* ── 覆盖网络 P0-2:引导目录的后台刷新节奏 ── */
|
||||
/** 首次刷新延迟。**必须 > 0**:控制面自己既是目录服务端又是客户端,启动瞬间自己的 3080 还没 listen。 */
|
||||
const OVERLAY_REFRESH_FIRST_MS = 15000
|
||||
/** 刷新周期下限(目录给的 `refreshAfterSeconds` 更小也按它兜底,避免退化成"每秒取一次")。 */
|
||||
const OVERLAY_REFRESH_MIN_MS = 60000
|
||||
|
||||
/** Whether an `Origin` header belongs to the platform base domain (or a
|
||||
* per-user subdomain of it). Used to allow cross-subdomain API calls from dsh
|
||||
* instances (功能插件启停). */
|
||||
@@ -254,13 +278,397 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
token: config.clusterAgentToken,
|
||||
instanceHost: config.clusterInstanceHost,
|
||||
})
|
||||
/**
|
||||
* 会合解析(覆盖网络 S2):**`via` → `Rendezvous` 实现 → `Reachability`**。
|
||||
*
|
||||
* 为什么要有这一层:`endpoint` 只说得清"拨哪个地址",说不清"**经谁**" —— 而现网两条
|
||||
* 记录的 endpoint 恰好**字符串同形、语义不同**(`w-47` 同机直连 / `w-106` 隧道落点)。
|
||||
* `via` 列把"经谁"显式化 ⇒ 换中继 / 会合时不必改表语义(会合中继拆分方案 §2 C3)。
|
||||
*
|
||||
* ⚠️ **S2 阶段行为零变化**:两种现役实现的 `resolve()` 都只把 endpoint 拆成
|
||||
* `{scheme, address}`,`agentBaseUrlOf()` 拼回来与旧 `agentUrl` **逐字相等**
|
||||
* (判据写死在 `test/reachability.test.mjs`,不靠肉眼比)。
|
||||
*
|
||||
* 🔑 **键是逻辑名** `<network>/<hostId>`(P0-3)—— 网络维度是**结构性**的,控制面的每一张
|
||||
* 内存表都必须带着它;只按裸 hostId 建键时,两张网的同名 host 会**互相覆盖**(静默串网)。
|
||||
*/
|
||||
const hostAddresses = new Map<string, string>()
|
||||
/**
|
||||
* 每台 host 的 **agent 端口**(从 `dsh_hosts.endpoint` 剥出来的那个号码)。
|
||||
*
|
||||
* 为什么需要(覆盖网络 R3):relay 为**每个注册端口**在它自己的回环上开一条监听,回环口号
|
||||
* 由 `listen(0)` **动态分配**(实测 42067)⇒ Manager 既拿不到也推不出,只能拿「要拨的端口号」
|
||||
* 去 relay 的 `/status` 里查回环口号。而 agent 端口与 ssh 隧道落点**同号**(隧道就是同号反向
|
||||
* 转发,见 `worker/tunnel.ts#forward`)⇒ 这个号码现有表里就有,**不需要新增列**。
|
||||
*
|
||||
* 🔑 **键是逻辑名**(P0-3),与 `hostAddresses` 同口径。
|
||||
*/
|
||||
const hostAgentPorts = new Map<string, number>()
|
||||
/**
|
||||
* 每台 host **表里声明的会合形态**(`dsh_hosts.via` 原文),与 `reachability` 分开存。
|
||||
*
|
||||
* 为什么不能用 `reachability` 反推(R4 实测教训):`Reachability` 是**实时解析结果**,
|
||||
* host 离线 / relay 快照陈旧时 `resolve()` 会回 `undefined`。若此时按"不是 relay ⇒ 原样透传"
|
||||
* 处理,就会把 **Worker 侧口号**打到 Manager 本机(连接被拒、空响应、无日志)。
|
||||
* ⇒ 「这台该走哪种会合」必须读**表里的静态声明**;「此刻可拨到什么地址」才读 `reachability`。
|
||||
*
|
||||
* 🔑 **键是逻辑名**(P0-3),与 `hostAddresses` 同口径。
|
||||
*/
|
||||
const hostVia = new Map<string, string>()
|
||||
/**
|
||||
* relay 的实时视图(覆盖网络 R3):`<network>/<hostId>:<port>` → `{ localPort, online }`。
|
||||
*
|
||||
* ⚠️ **键是逻辑名**(P0-3):只按裸 hostId 建键时,两张网各有一台同名 host 会**互相覆盖**
|
||||
* —— 后刷进来的那张网把前一张的落点顶掉 ⇒ 请求被静默路由到**另一张网**的节点上
|
||||
* (这正是 Step 1 在 relay 侧修掉的病,控制面这一份当时还是旧的)。
|
||||
*
|
||||
* ⚠️ **只在刷新成功时整表替换**(不做增量合并):relay 重启后回环口号会**全部重分配**,
|
||||
* 增量会把过期口号永远留在表里 ⇒ Manager 继续往一个已不存在的口上打。
|
||||
* 这正是 ssh 版「静默打到别人实例」的同一类病,不能在替代品里复现。
|
||||
* 刷新失败时**保留上一份**,靠 `relaySnapshotAt` 判陈旧(陈旧 ⇒ `online` 回 false ⇒ 自动回退 `manager-ssh`)。
|
||||
*/
|
||||
const relayEndpoints = new Map<string, { localPort: number; online: boolean }>()
|
||||
let relaySnapshotAt = 0
|
||||
const refreshRelay = async (): Promise<void> => {
|
||||
if (config.relayStatusUrl === '') return
|
||||
try {
|
||||
const res = await fetch(config.relayStatusUrl, { signal: AbortSignal.timeout(RELAY_STATUS_TIMEOUT_MS) })
|
||||
if (!res.ok) return
|
||||
const body = (await res.json()) as { endpoints?: unknown }
|
||||
const rows = Array.isArray(body.endpoints) ? body.endpoints : []
|
||||
const next = new Map<string, { localPort: number; online: boolean }>()
|
||||
for (const raw of rows) {
|
||||
const ep = raw as {
|
||||
hostId?: unknown
|
||||
network?: unknown
|
||||
port?: unknown
|
||||
localPort?: unknown
|
||||
online?: unknown
|
||||
}
|
||||
if (typeof ep.hostId !== 'string' || typeof ep.port !== 'number') continue
|
||||
if (typeof ep.localPort !== 'number' || ep.localPort <= 0) continue
|
||||
// `network` 缺失 = 老 relay(P0-1 之前)⇒ 按 `ops` 读(与 DB 的列默认值同口径)。
|
||||
const network = typeof ep.network === 'string' && ep.network !== '' ? ep.network : OPS_NETWORK
|
||||
next.set(`${logicalName(network, ep.hostId)}:${ep.port}`, {
|
||||
localPort: ep.localPort,
|
||||
online: ep.online === true,
|
||||
})
|
||||
}
|
||||
relayEndpoints.clear()
|
||||
for (const [k, v] of next) relayEndpoints.set(k, v)
|
||||
relaySnapshotAt = Date.now()
|
||||
} catch {
|
||||
// relay 不可达 / 超时 ⇒ **保留上一份快照**(临时抖动不抖路由),由陈旧判定兜底。
|
||||
}
|
||||
}
|
||||
if (config.relayStatusUrl !== '') {
|
||||
void refreshRelay()
|
||||
const relayTimer = setInterval(() => void refreshRelay(), RELAY_STATUS_POLL_MS)
|
||||
relayTimer.unref()
|
||||
}
|
||||
/**
|
||||
* 覆盖网络 R5:**Manager 侧拨号通道** —— 「会合可换机」的收口件(`会合中继拆分` §9.4 那个断点)。
|
||||
*
|
||||
* 配了 `DSHS_RELAY_DIAL_SECRET` ⇒ Manager 也像 worker 一样**只拨出**一条 wss,并把落点建在
|
||||
* **自己的回环**上(`RelayDialer` 的预绑口池)⇒ **不再依赖 relay 主机的 `127.0.0.1`**,
|
||||
* relay 可以放在任何一台机器上(也可以多实例)。
|
||||
* 没配 ⇒ `relayDialer` 为 `undefined` ⇒ 全部回落到 `/status` 快照,**行为与 R3 完全一致**。
|
||||
*
|
||||
* 密钥必须是 64 位 hex(与 relay 密钥表同格式)。格式不对 ⇒ **不启用**并明确报一行 ——
|
||||
* 而不是带着一个坏密钥去反复握手失败(那会变成刷日志的噪音)。
|
||||
*/
|
||||
/**
|
||||
* 覆盖网络 P0-2:**relay 地址走引导三级链**(env 显式 > 缓存目录 > 内置种子)。
|
||||
*
|
||||
* 为什么必须在这里改:`config.relayUrl` 只可能来自 env ⇒ 域名 / 机器一换,
|
||||
* **用户设备上的那个值我们改不到**,只能让所有人重装(`§A2` 点名的灾难)。
|
||||
* 引导链把地址变成"可在线轮换的下发物";env 仍然**压制一切**(运维最后手段)。
|
||||
* 取不到(返回空串)⇒ 与"未配 relay"**完全同行为**(落回 `/status` 快照 / `manager-ssh`)。
|
||||
*/
|
||||
const overlayLog = (line: string): void => {
|
||||
process.stdout.write(`${line}\n`)
|
||||
}
|
||||
const relayResolved = await resolveOverlayRelay({
|
||||
envUrl: config.relayUrl,
|
||||
seeds: config.overlayBootstrapSeeds,
|
||||
trustedKeys: config.overlayDirTrustedKeys,
|
||||
cacheFile: config.overlayDirectoryCacheFile,
|
||||
log: overlayLog,
|
||||
})
|
||||
const relayUrl = relayResolved.url
|
||||
const dialLog = (line: string): void => {
|
||||
process.stdout.write(`${line}\n`)
|
||||
}
|
||||
/** 拨号通道能不能起:地址有了 + 密钥是 64 位 hex。**任一条不满足 ⇒ 完全不启用**(与 R5 同语义)。 */
|
||||
const secretOk = /^[0-9a-f]{64}$/.test(config.relayDialSecret)
|
||||
const dialerEnabled = relayUrl !== '' && config.relayDialSecret !== '' && secretOk
|
||||
if (relayUrl !== '' && config.relayDialSecret !== '' && !secretOk) {
|
||||
dialLog('[relay-dialer] ⛔ DSHS_RELAY_DIAL_SECRET 不是 64 位 hex ⇒ 拨号通道**不启用**(落回 /status 快照)')
|
||||
}
|
||||
/**
|
||||
* 起一条拨号通道。抽成函数是因为 P0-2 要在**运行期换址**(见下面的后台刷新)。
|
||||
*
|
||||
* **先建新的、成功了再关旧的**:新通道任何一步失败都直接返回 `undefined`、旧通道原样保留
|
||||
* —— 换址失败绝不能把"本来能用"的跨机通路打掉。
|
||||
*/
|
||||
const startDialer = async (
|
||||
url: string,
|
||||
): Promise<{ client: RelayClient; dialer: RelayDialer } | undefined> => {
|
||||
const client = new RelayClient({
|
||||
url,
|
||||
hostId: config.relayDialHost,
|
||||
secret: config.relayDialSecret,
|
||||
ports: [],
|
||||
dialer: true,
|
||||
/**
|
||||
* 序③:Manager 也是**一台机器**,同样要证明"被授权进入这张网"。
|
||||
* 与 worker 侧同一装配入口(`loadClientIdentity`)⇒ 两侧只有一种写法。
|
||||
*/
|
||||
identity: loadClientIdentity({
|
||||
keyFile: config.overlayNodeKeyFile,
|
||||
grantFile: config.overlayNodeGrantFile,
|
||||
log: dialLog,
|
||||
}),
|
||||
log: dialLog,
|
||||
})
|
||||
client.start()
|
||||
const dialer = new RelayDialer({
|
||||
client,
|
||||
portBase: config.relayDialPortBase,
|
||||
portSpan: config.relayDialPortSpan,
|
||||
poolSize: config.relayDialPool,
|
||||
log: dialLog,
|
||||
})
|
||||
// **必须 await**:口池绑完之前 `localPortFor()` 恒返回 undefined(失败关闭)⇒
|
||||
// 不 await 就会在启动瞬间把"能给地址"缩成一个窗口期。
|
||||
try {
|
||||
await dialer.start()
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
dialLog(`[relay-dialer] ⛔ 落点池绑定失败(${msg})⇒ 本条通道放弃,已有通道不受影响`)
|
||||
try {
|
||||
dialer.close()
|
||||
} catch {
|
||||
/* 已关 */
|
||||
}
|
||||
client.stop()
|
||||
return undefined
|
||||
}
|
||||
return { client, dialer }
|
||||
}
|
||||
/**
|
||||
* 序⑦:**通道句柄把 dialer 一起带着走** —— `relayDialer` 不再是一个独立可变量,
|
||||
* 而是"监管器当前通道"的投影。这样"当前是谁"只有**一个**权威来源,
|
||||
* ⛔ 不会出现"监管器已切到新通道、别处还拿着旧 dialer"的分叉。
|
||||
*/
|
||||
type C1Handle = RelayChannelHandle & { dialer: RelayDialer; client: RelayClient }
|
||||
const toHandle = (
|
||||
url: string,
|
||||
started: { client: RelayClient; dialer: RelayDialer },
|
||||
): C1Handle => ({
|
||||
url,
|
||||
dialer: started.dialer,
|
||||
client: started.client,
|
||||
health: () => {
|
||||
const st = started.client.status()
|
||||
return { state: st.state, attempts: st.attempts, unhealthyForMs: st.unhealthyForMs }
|
||||
},
|
||||
close: () => {
|
||||
try {
|
||||
started.dialer.close()
|
||||
} catch {
|
||||
/* 已关 */
|
||||
}
|
||||
try {
|
||||
started.client.stop()
|
||||
} catch {
|
||||
/* 已关 */
|
||||
}
|
||||
},
|
||||
})
|
||||
const overlayResolveOpts = (): Parameters<typeof listOverlayRelayCandidates>[0] => ({
|
||||
envUrl: config.relayUrl,
|
||||
seeds: config.overlayBootstrapSeeds,
|
||||
trustedKeys: config.overlayDirTrustedKeys,
|
||||
cacheFile: config.overlayDirectoryCacheFile,
|
||||
log: overlayLog,
|
||||
})
|
||||
/** 序⑦ 切流阈值(唯一一份默认值在 `switcher.ts`;这里只是取一份实例)。 */
|
||||
const failoverThresholds = relayFailoverThresholds()
|
||||
/**
|
||||
* 换址版等待:**非抛**,只回答"通没通"(`open` 要的是布尔)。
|
||||
*
|
||||
* 🔴 **序⑨ · RC-1(C1 装配点)**:实现已抽到 `net/relay/client.ts#waitUpOnStatus`
|
||||
* (三个装配点共用一份,D7)。相对改造前的**唯一**差别 = **终态失败(死候选)立即返回 `false`**,
|
||||
* ⛔ 不再白等满 `upTimeoutMs`。
|
||||
* ⚠️ 为什么这里最痛:生产目录前两条候选**同在 47**(见下面 `open` 的注释)⇒ 每次从 47 切走
|
||||
* **必然**先试同机的 `relay-direct`(已随 47 一起死)⇒ 白等 ≡ `upTimeoutMs`(12 000 ms),
|
||||
* 实测两样本逐行复核(序⑨ §2-P10)。**慢候选(连得上、只是 `up` 来得晚)不受影响**。
|
||||
*/
|
||||
const waitUpOn = (client: RelayClient, timeoutMs: number): Promise<boolean> =>
|
||||
waitUpOnStatus(client, timeoutMs, {
|
||||
onDead: (st) =>
|
||||
overlayLog(
|
||||
`[relay-failover] ⛔ 新通道终态失败(state=${st.state} attempts=${st.attempts} ` +
|
||||
`burst=${st.inGracefulBurstWindow} lastError="${st.lastError ?? ''}")⇒ 提前放弃,不等满 ${timeoutMs}ms`,
|
||||
),
|
||||
})
|
||||
/**
|
||||
* **中继失败切流的唯一实现**(序⑦ · C1 装配点)。
|
||||
*
|
||||
* 触发信号全部复用现成状态机(D2):`RelayClient.status()` 的 `state/attempts/unhealthyForMs`
|
||||
* ——⛔ 不新造心跳、不新增探测帧。
|
||||
*/
|
||||
const failover = new RelayFailoverSupervisor({
|
||||
/**
|
||||
* ⚠️ **换址版的 `open` 比启动版严一档**:启动只要"口池绑好"(`startDialer` 的语义,
|
||||
* P0-2 要求启动不依赖网络);**换址**必须等新通道真到 `up` ——
|
||||
* 否则会把"口池绑好了"误当成"通了",切到一条**同样连不上**的中继上
|
||||
* (生产目录前两条候选落在同一台机器,这个坑一定会踩到)。
|
||||
* 到不了 `up` ⇒ 关掉它、返回 `undefined` ⇒ 监管器保持原通道并把该候选进冷却(D4 + 链推进)。
|
||||
*/
|
||||
open: async (url) => {
|
||||
const started = await startDialer(url)
|
||||
if (started === undefined) return undefined
|
||||
if (!(await waitUpOn(started.client, failoverThresholds.upTimeoutMs))) {
|
||||
try {
|
||||
started.dialer.close()
|
||||
} catch {
|
||||
/* 已关 */
|
||||
}
|
||||
started.client.stop()
|
||||
return undefined
|
||||
}
|
||||
return toHandle(url, started)
|
||||
},
|
||||
candidates: async () => (await listOverlayRelayCandidates(overlayResolveOpts())).urls,
|
||||
log: overlayLog,
|
||||
thresholds: failoverThresholds,
|
||||
})
|
||||
/** 当前拨号通道(由监管器的"当前通道"派生 —— 单一权威来源)。 */
|
||||
const currentDialer = (): RelayDialer | undefined =>
|
||||
(failover.channel as C1Handle | undefined)?.dialer
|
||||
if (dialerEnabled) {
|
||||
const started = await startDialer(relayUrl)
|
||||
if (started !== undefined) failover.seed(toHandle(relayUrl, started))
|
||||
/**
|
||||
* 健康巡检**独立定时器**:目录刷新周期可能长达分钟级(`refreshAfterSeconds`),
|
||||
* 而故障切换要在 `RELAY_FAILOVER_DEADLINE_MS`(30 s)内完成 ⇒ 两者节奏必须分开。
|
||||
*/
|
||||
failover.start()
|
||||
}
|
||||
/**
|
||||
* 覆盖网络 P0-2:**后台刷新目录**(启动后再取一次,之后按目录给的刷新周期)。
|
||||
*
|
||||
* 为什么不能在启动时"一次算完":**控制面自己也是客户端**(47 同机既是目录签发方、又是
|
||||
* Manager)⇒ 启动那一刻自己的 3080 还没 `listen`,取目录必然 `502`;若就此定死,
|
||||
* Manager 就**永远**拿不到目录里 `relays[]` / `bootstrap[]` 的轮换结果(能力等于没有)。
|
||||
* 所以分成两步:**启动只用"缓存 / 种子"起来(启动不依赖网络)**,随后后台再取一次目录;
|
||||
* 地址**真的变了**才换通道(无变化时零动作、零日志噪音)。
|
||||
*
|
||||
* 序⑦:换址动作统一走 {@link RelayFailoverSupervisor.replace}("先建新、成功再关旧" +
|
||||
* 冷却表 + `[relay-switch]` 日志 + `switches` 计数),⛔ 不再在本函数里自己关旧通道。
|
||||
*/
|
||||
const refreshOverlay = async (): Promise<void> => {
|
||||
if (!dialerEnabled) return
|
||||
const next = await resolveOverlayRelay(overlayResolveOpts())
|
||||
const cur = failover.channel?.url ?? relayUrl
|
||||
if (next.url === '' || next.url === cur) return
|
||||
overlayLog(`[overlay-dir] 🔁 目录给出的地址变了:${cur} -> ${next.url}(source=${next.source})⇒ 换拨号通道`)
|
||||
/**
|
||||
* 序⑧(D1):显式声明 `'directory'` —— 这条换址的触发条件("目录里的地址变了")与
|
||||
* "旧通道是否可用"**无关** ⇒ ⛔ **没有打破冷却的权力**(有的话,"当前站在 106、目录首位是 47"
|
||||
* 的每一轮巡检都会把刚冷却的 47 换回来 = 两位互相抢 = D5 想防的抖动风暴)。
|
||||
*/
|
||||
await failover.replace(next.url, `目录地址变更(source=${next.source})`, 'directory')
|
||||
}
|
||||
if (dialerEnabled) {
|
||||
const first = setTimeout(() => void refreshOverlay(), OVERLAY_REFRESH_FIRST_MS)
|
||||
first.unref()
|
||||
const timer = setInterval(
|
||||
() => void refreshOverlay(),
|
||||
Math.max(OVERLAY_REFRESH_MIN_MS, relayResolved.refreshAfterSeconds * 1000),
|
||||
)
|
||||
timer.unref()
|
||||
}
|
||||
const rendezvous = new RendezvousRegistry([
|
||||
new LocalRendezvous((id) => hostAddresses.get(id)),
|
||||
new ManagerSshRendezvous({
|
||||
target: config.clusterRendezvousUrl,
|
||||
addressOf: (id) => hostAddresses.get(id),
|
||||
}),
|
||||
// `relay`(R3 / R5):**既没配 `/status`、也没启用拨号通道 ⇒ 不注册** ⇒ `via='relay'`
|
||||
// 回落到 `manager-ssh`(`hostsProvider` 的回退分支),即"没配就完全等同今天"。
|
||||
...(config.relayStatusUrl === '' && currentDialer() === undefined
|
||||
? []
|
||||
: [
|
||||
new RelayRendezvous({
|
||||
dialTargetUrl: relayUrl,
|
||||
// 键一律是**逻辑名** `<network>/<hostId>`(P0-3):跨网同名 host 不会互相命中。
|
||||
addressOf: (name) => {
|
||||
const port = hostAgentPorts.get(name)
|
||||
if (port === undefined) return undefined
|
||||
// ① 首选**拨号通道**:落点在 Manager 自己本机 ⇒ relay 换机器也成立(R5)
|
||||
// ⚠️ 这里同时是**控制面侧的跨网门**:通道只声明一张网,跨网申请**必然回 undefined**
|
||||
// (`RelayDialer` 里带日志地拒掉)⇒ 绝不会把别张网的落点发出去。
|
||||
const dialed = currentDialer()?.localPortFor(name, port)
|
||||
if (dialed !== undefined) return `127.0.0.1:${dialed}`
|
||||
// ② 回退:relay 快照里的动态回环口号(仅在 relay 与 Manager 同机时可用)
|
||||
const hit = relayEndpoints.get(`${name}:${port}`)
|
||||
return hit === undefined ? undefined : `127.0.0.1:${hit.localPort}`
|
||||
},
|
||||
online: (name) => {
|
||||
const port = hostAgentPorts.get(name)
|
||||
if (port === undefined) return false
|
||||
/**
|
||||
* ① **有新鲜快照** ⇒ 沿用 R3 的「先问在线、再给地址」:能明确区分"离线"与"端口没了",
|
||||
* 失败语义更准(离线走 `undefined`,不必真去拨一次)。
|
||||
*/
|
||||
if (Date.now() - relaySnapshotAt <= RELAY_SNAPSHOT_TTL_MS) {
|
||||
const hit = relayEndpoints.get(`${name}:${port}`)
|
||||
if (hit !== undefined) return hit.online === true
|
||||
}
|
||||
/**
|
||||
* ② **没有快照**(relay 在别的机器上 / 未配 `/status`)⇒ 交给**拨号本身**判定:
|
||||
* 离线会拿到 `target-offline`,是**带原因的显式失败**,不会静默路由到别处。
|
||||
*/
|
||||
return currentDialer() !== undefined
|
||||
},
|
||||
}),
|
||||
]),
|
||||
])
|
||||
const hostsProvider = async (): Promise<ClusterHost[]> => {
|
||||
for (const row of await db.listDshHosts()) {
|
||||
const rows = await db.listDshHosts()
|
||||
/**
|
||||
* **逻辑名 = 控制面里"这台 host"的唯一键**(P0-3)。
|
||||
*
|
||||
* 网络维度取自 `dsh_hosts.network_id`(P0-1 迁的列,**本步才真正被消费**)——
|
||||
* 在此之前它只是"表里有、没人读",于是控制面所有键(地址表 / via 表 / 端口表 /
|
||||
* relay 端点表 / 拨号口池)都按**裸 hostId**,两张网各有一台同名 host 时会**互相覆盖**。
|
||||
*/
|
||||
const nameOf = (row: { id: string; networkId: string }): string =>
|
||||
logicalName(row.networkId === '' ? OPS_NETWORK : row.networkId, row.id)
|
||||
// 先同步地址表(会合实现不直接连 DB),再逐行解析 —— 两遍是为了让 `resolve()` 只看纯内存表。
|
||||
for (const row of rows) {
|
||||
const name = nameOf(row)
|
||||
const address = parseReachability(name, row.endpoint, row.via).address
|
||||
hostAddresses.set(name, address)
|
||||
hostVia.set(name, row.via)
|
||||
const port = addressPort(address)
|
||||
if (port === undefined) hostAgentPorts.delete(name)
|
||||
else hostAgentPorts.set(name, port)
|
||||
}
|
||||
for (const row of rows) {
|
||||
// `via` 认不出来(老行 / 手写错值)⇒ 回退到"今天唯一在跑的实现",**不抛**(过渡期要能跑)。
|
||||
const impl = rendezvous.get(row.via) ?? rendezvous.get(VIA_MANAGER_SSH)
|
||||
hostDirectory.set(row.id, {
|
||||
hostId: row.id,
|
||||
agentUrl: row.endpoint,
|
||||
agentUrl: row.endpoint, // 保留:`agentBaseUrlOf` 的回退路径,S2 阶段与 reachability 等价
|
||||
reachability: impl === undefined ? undefined : await impl.resolve(nameOf(row)),
|
||||
token: row.agentToken,
|
||||
instanceHost: config.clusterInstanceHost,
|
||||
// 表里的声明(P0-3):`agentBaseUrlOf` 靠它区分"解析不出"与"可以回落 endpoint"。
|
||||
via: row.via,
|
||||
})
|
||||
}
|
||||
return [...hostDirectory.values()]
|
||||
@@ -326,6 +734,32 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
resolveUid,
|
||||
// 按 host 路由:每次操作都落到"该用户实例所在那台"(与文件面同一份)
|
||||
hostIdFor: hostIdForUser,
|
||||
/**
|
||||
* 覆盖网络 R4:**实例端点的 relay 翻译**。
|
||||
*
|
||||
* `via='relay'` 的 host,其实例在 Worker 上监听 `127.0.0.1:<实例端口>`,而 Manager
|
||||
* 要拨的是 relay 为那个端口开的**动态回环口号** ⇒ 必须按 `(hostId, 实例端口)` 查
|
||||
* relay 快照翻译。查不到就回 `undefined`(= 实例不可达)—— **绝不原样透传**,
|
||||
* 那会拨到一个"在 Manager 上毫无意义"的端口上。
|
||||
*
|
||||
* 非 `relay` 形态(`local` / `manager-ssh`)**原样返回**:隧道是同号反向转发,
|
||||
* 两边口号相同 ⇒ 不需要也没法翻译(这正是 S3 端口区间隔离能根治撞号的前提)。
|
||||
*
|
||||
* ⚠️ 判据必须取 **`dsh_hosts.via` 原文**(`hostVia`),**不能**取 `reachability.via`:
|
||||
* 后者在 host 离线 / relay 快照陈旧时为 `undefined`,若据此判"不是 relay ⇒ 原样透传",
|
||||
* 就会**失败开放** —— 把 Worker 侧口号打到 Manager 本机(2026-09-16 实测:浏览器只见
|
||||
* 空响应、平台零日志)。未知 host 仍按老行为原样返回(单机 / 默认 host 不受影响)。
|
||||
*/
|
||||
translateEndpoint: (hostId, ep) => {
|
||||
if (hostVia.get(hostId) !== VIA_RELAY) return ep
|
||||
// ① 首选**拨号通道**(R5):落点在 Manager 自己本机 ⇒ relay 换机器也成立
|
||||
const dialed = currentDialer()?.localPortFor(hostId, ep.port)
|
||||
if (dialed !== undefined) return { host: '127.0.0.1', port: dialed }
|
||||
// ② 回退:relay 快照里的动态回环口号。查不到 ⇒ **失败关闭**(实例此刻不可达),
|
||||
// 绝不回退成 Worker 侧口号 —— 那正是"浏览器只见空响应、平台零日志"的成因。
|
||||
const hit = relayEndpoints.get(`${hostId}:${ep.port}`)
|
||||
return hit === undefined ? undefined : { host: '127.0.0.1', port: hit.localPort }
|
||||
},
|
||||
}),
|
||||
db,
|
||||
{
|
||||
@@ -352,6 +786,39 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
console.error('[cluster] heartbeat/register failed to start:', err)
|
||||
})
|
||||
}
|
||||
/**
|
||||
* 按需补齐 host 目录(2026-09-16 加,覆盖网络线缺陷 A1)。
|
||||
*
|
||||
* **为什么文件面必须自己会刷新**:`hostDirectory` 是**惰性** Map —— 唯一的写入者是
|
||||
* `hostsProvider()`,而此前只有 `RemoteSpawner.ensureHosts()`(TTL 30 s)会调它 ⇒
|
||||
* Manager 重启后若用户先碰文件面("我的文件" / launch 的 folder 检查),表里只有本机
|
||||
* ⇒ `agentFor('w-106')` 返回 `undefined` ⇒ 旧行为**静默回退到本机 agent** ⇒ worker 上当然
|
||||
* 没有这个用户 ⇒ 假 `404 {"error":"not_found"}`,与"文件夹不存在"完全同形,且平台零日志。
|
||||
* (判别器 = relay 有没有 `DIAL`:没有 = 请求根本没出这台机。回归用例见
|
||||
* `test/remote-user-fs.test.mjs`。)
|
||||
*
|
||||
* **成本**:只在**未命中**时查库(正常路径零开销);并发去重 + 5 s 冷却,
|
||||
* 避免一个真正不存在的 hostId 把 PG 打穿。
|
||||
*/
|
||||
const DIRECTORY_COOLDOWN_MS = 5_000
|
||||
let directoryRefreshing: Promise<void> | undefined
|
||||
let directoryRefreshedAt = 0
|
||||
const ensureHostDirectory = async (hostId: string): Promise<void> => {
|
||||
if (hostDirectory.has(hostId)) return
|
||||
if (directoryRefreshing !== undefined) return directoryRefreshing
|
||||
if (Date.now() - directoryRefreshedAt < DIRECTORY_COOLDOWN_MS) return
|
||||
directoryRefreshedAt = Date.now()
|
||||
// **可观测性**(别省这一行):本行出现 = 真的走进了"未命中 ⇒ 补齐"这条路径,
|
||||
// 也就是"旧实现会静默把请求打到本机 agent"的那个窗口。没有它,"这次为什么没 404"
|
||||
// 只能靠推断;本项目吃过的静默失效亏,根子都在"关键分支不可观测"。
|
||||
console.log(`[cluster] host 目录未命中 ${hostId} ⇒ 按需补齐(重启窗口期常见)`)
|
||||
directoryRefreshing = hostsProvider()
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
directoryRefreshing = undefined
|
||||
})
|
||||
return directoryRefreshing
|
||||
}
|
||||
const userFs = createUserFs(config, {
|
||||
hostIdFor: hostIdForFile,
|
||||
agentFor: (hostId: string) => {
|
||||
@@ -359,6 +826,7 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
// 同上一处:取址只经可达性入口(文件面与实例面共用同一份路由,别各自拼)
|
||||
return h === undefined ? undefined : { agentUrl: agentBaseUrlOf(h), token: h.token }
|
||||
},
|
||||
ensureHost: ensureHostDirectory,
|
||||
})
|
||||
// T08 S5:cluster 模式下**所有 worker 的 dataRoot 必须是同一绝对路径**(基线约定,
|
||||
// 设计 §14.3)。不一致会让 `resolvePath` 算出的"实例眼里的路径"与实际不符 ⇒
|
||||
@@ -432,6 +900,7 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
await app.register(desktopRoutes)
|
||||
await app.register(dshRoutes)
|
||||
await app.register(domainRoutes)
|
||||
await app.register(overlayRoutes)
|
||||
await app.register(skillRoutes)
|
||||
await app.register(whitelistRoutes)
|
||||
|
||||
|
||||
Reference in new issue
Block a user