feat(overlay): 内容块级寻址 + 实例逐步拉起 + 骨干选路 + 组密钥加密(序24–㉛ 累积同步)

代码
- 内容分发块级寻址:新增 src/net/relay/content/{chunker,store,runtime,source,peer,crypto}.ts
- 组密钥(C 档)确定性加密:AES-256-GCM,块 id β′ = sha256(密文) 前 32 hex;双 epoch 过渡窗口
- 实例生命周期:三处 teardown() 不再杀实例(local/remote/leased-spawner);启动认领 + TCP 探活判孤儿
- 骨干选路:jitter 选路 + endpoint-target;relay client/server/wire/identity/directory/rendezvous/switcher 调整
- 工作台 src/web/server.ts、src/worker/relay-tunnel.ts 装配与候选链观测

脚本与测试
- scripts/overlay-{probe,keyring,jitter}.cjs 更新
- 探针新增 OBS-21(每连接候选数)/ OBS-22(teardown 静态守卫 + 认领面)/ OBS-23(组密钥加密)
- 新增 test/{orchestrator-teardown,orchestrator-rehydrate,overlay-content,overlay-jitter}.test.mjs;relay 两例更新

文档
- 新增交接单:覆盖网络-序24-内容分发块级寻址 / 序25-实例逐步拉起 / 序26-骨干稳定选路与加密
- INDEX.md、交接单/README.md、skills/dsh-auto-handoff-chain/SKILL.md 同步

验收(零回归,2026-09-18 08:0x 复核)
- npm test           201 tests / 200 pass / 0 fail / 1 skipped
- overlay-failover-drill --scene all --table   12 PASS / 0 SKIP / 0 FAIL
- overlay-probe --table                        23 PASS / 0 SKIP / 0 FAIL (rc=0)
This commit is contained in:
admin committed 2026-09-18 08:08:51 +08:00
1 parent 04776af4b1
commit 09ce76f3af
38 files changed
+9133 -211

No files matched your search

+278 -16
View File
@@ -17,16 +17,26 @@ 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 { addressPort, agentBaseUrlOf, parseReachability, VIA_MANAGER_SSH, VIA_RELAY } from '../net/reachability.js'
import { addressPort, agentBaseUrlOf, parseReachability, VIA_MANAGER_SSH } 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 type { OverlayRelayCandidates } from '../net/relay/directory.js'
import { RelayClient, waitUpOnStatus } from '../net/relay/client.js'
import { RelayCandidateObservation, candidateObsMs } from '../worker/relay-tunnel.js'
import { hostNameIndex, relayEndpointTarget } from '../net/relay/endpoint-target.js'
import { loadClientIdentity } from '../net/relay/identity.js'
import { RelayFailoverSupervisor, relayFailoverThresholds } from '../net/relay/switcher.js'
import type { RelayChannelHandle } from '../net/relay/switcher.js'
// ── 序㉔ 内容分发(块级内容寻址 · 同网段 peer 优先)────────────────────────────
// ⛔ 装配仅"接线",不改 presence / 端点翻译 / 切流既有逻辑(交接单 §3.1)。
import { ContentStore } from '../net/relay/content/store.js'
import { ContentSourceChain } from '../net/relay/content/source.js'
import { ContentPeerGroup } from '../net/relay/content/peer.js'
// 🆕 序㉘ · 单 B:组密钥装载(平台侧**缺省不启用**;具名失败 ⇒ 不启用并留痕)
import { openContentCipher } from '../net/relay/content/crypto.js'
import { LocalSpawner } from '../supervisor/orchestrator.js'
import { LeasedSpawner } from '../supervisor/leased-spawner.js'
import { RemoteSpawner, type ClusterHost } from '../supervisor/remote-spawner.js'
@@ -315,6 +325,15 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
* 🔑 **键是逻辑名**(P0-3),与 `hostAddresses` 同口径。
*/
const hostVia = new Map<string, string>()
/**
* **`hostId` → 逻辑名** 索引(序㉑ P-2)—— `translateEndpoint` 的**唯一入口**。
*
* 🔑 为什么必须有它:`RemoteSpawner` 调翻译器时只给得到**裸 hostId**
* (`endpointFor` → `translateEndpoint(host.hostId, raw)`),而上面每张表的键都是**逻辑名**
* ⇒ 直接拿 hostId 去查恒 `undefined` ⇒ 翻译**从未生效**(整个闭包成了死分支,在册缺陷 P-2)。
* 映射公式只在 `hostNameIndex` 里写一份(⛔ 不在闭包里再写第二份)。
*/
const hostNameById = new Map<string, string>()
/**
* relay 的实时视图(覆盖网络 R3):`<network>/<hostId>:<port>` → `{ localPort, online }`。
*
@@ -329,8 +348,47 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
*/
const relayEndpoints = new Map<string, { localPort: number; online: boolean }>()
let relaySnapshotAt = 0
/**
* 「当前通道的 `RelayClient`」的**延迟绑定**取值器。
*
* 🔑 存在的唯一理由是**初始化顺序**:`/status` 轮询的首次调用发生在拨号通道建立**之前**,
* 那时 `failover` 还在 TDZ 里(直接引用会 `ReferenceError`)。而把 `failover` 提前声明成
* `let` 又会丢掉"当前通道只有**一个**权威来源"这条纪律(序⑦ 为它专门收敛过)。
* ⇒ 用一个可空函数引用,**谁都不破坏**。
*/
let currentClientRef: (() => RelayClient | undefined) | undefined
const currentClient = (): RelayClient | undefined => currentClientRef?.()
/**
* 序⑲ presence:**订阅是否新鲜** —— D5「主路径 / 兜底」的**唯一开关**。
*
* ⚠️ 必须是函数而不是布尔量:通道会被换址(序⑦),"订阅有没有"随通道走 ⇒ 每次调用现读。
* `undefined`(还没起通道 / 已切走)也算不新鲜 ⇒ 回退 `/status`,语义安全。
*/
const presenceLive = (): boolean => currentClient()?.presenceFresh() === true
/**
* 订阅新鲜度变化的**可 grep 记录**(⛔ 别让"轮询停了"变成看不见的静默行为)。
*
* ⚠️ 这里直接写 stdout 而不用 `overlayLog`:本函数会在 `overlayLog` 初始化**之前**被首次
* 调用(首次 `refreshRelay` 就在那一行 `void refreshRelay()`)⇒ 引用它同样会 TDZ。
*/
let pollSuspended = false
const notePollGate = (suspend: boolean): void => {
if (suspend === pollSuspended) return
pollSuspended = suspend
process.stdout.write(
suspend
? '[overlay-presence] 订阅新鲜 ⇒ `/status` 轮询**挂起**(兜底路径待命)\n'
: '[overlay-presence] 订阅不新鲜 ⇒ `/status` 轮询**恢复**(兜底路径生效)\n',
)
}
const refreshRelay = async (): Promise<void> => {
if (config.relayStatusUrl === '') return
/**
* 🔑 **序⑲ 的核心收益点**:订阅生效期间**一次都不拉**(E-判据:稳态 `/status` 命中 = 0)。
* ⛔ 不是"删掉轮询"(D5:`/status` 是回滚链的一环)—— 只是**在不需要时不拉**。
*/
notePollGate(presenceLive())
if (pollSuspended) return
try {
const res = await fetch(config.relayStatusUrl, { signal: AbortSignal.timeout(RELAY_STATUS_TIMEOUT_MS) })
if (!res.ok) return
@@ -454,6 +512,13 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
client.stop()
return undefined
}
/**
* 序⑲ presence:**订阅本网全部在线态**(第 3 条:订阅式扇出)。
*
* ⚠️ 放在口池绑定**成功之后**:绑不上就等于这条通道没有,订阅了也没人消费,
* 反而会留下"subs=1 但没人用"的假象。订阅失败不影响通道本身(自动回退 `/status`)。
*/
client.subscribePresence()
return { client, dialer }
}
/**
@@ -495,6 +560,26 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
})
/** 序⑦ 切流阈值(唯一一份默认值在 `switcher.ts`;这里只是取一份实例)。 */
const failoverThresholds = relayFailoverThresholds()
/**
* ── 序㉗:**候选链取址的唯一入口(含只读观测)** ──
*
* 两处**既有**调用点(下面对监管器给的 `candidates` 与 {@link refreshOverlay})共用本函数:
* ① 候选**只在同一处**被解析 ⇒ ⛔ **不新增任何网络 I/O**(`refreshOverlay` 本来就要解析一次);
* ② 每次**真实**解析都落进观测 ⇒ 观测面不是"另做一次"的样子货(E3 的可断言面)。
*
* 🔴 **等价性证明(⛔ 语义零变化)**:`refreshOverlay` 原来调的是
* `resolveOverlayRelay(overlayResolveOpts())` —— 该函数**未传 `exclude`**,而 `exclude` 缺省时
* 它就是 `{url: chain.urls[0] ?? '', source: chain.source, detail: chain.detail, refreshAfterSeconds}`
* 的**薄包装**(见 `directory.ts#resolveOverlayRelay`),且调用点**只用到 `url` 与 `source`**
* ⇒ 这里取 `chain.urls[0] ?? ''` / `chain.source` **逐字等价**。
* ⚠️ 若将来该调用点要用到 `exclude`,必须改回 `resolveOverlayRelay`(⛔ 不许在这里自己写排除逻辑)。
*/
const candObs = new RelayCandidateObservation('manager', overlayLog, candidateObsMs())
const resolveChain = async (): Promise<OverlayRelayCandidates> => {
const got = await listOverlayRelayCandidates(overlayResolveOpts())
candObs.record(got.urls, got.source, got.detail)
return got
}
/**
* 换址版等待:**非抛**,只回答"通没通"(`open` 要的是布尔)。
*
@@ -541,13 +626,36 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
}
return toHandle(url, started)
},
candidates: async () => (await listOverlayRelayCandidates(overlayResolveOpts())).urls,
/** 序㉗:走 `resolveChain` ⇒ 每次解析都落进观测(`E3` 的可断言面);返回值与原实现逐字一致。 */
candidates: async () => (await resolveChain()).urls,
log: overlayLog,
thresholds: failoverThresholds,
})
/** 当前拨号通道(由监管器的"当前通道"派生 —— 单一权威来源)。 */
const currentDialer = (): RelayDialer | undefined =>
(failover.channel as C1Handle | undefined)?.dialer
// 把「当前通道的 client」接上去(`presenceLive()` / `presenceOf()` 的取数入口)。
currentClientRef = () => (failover.channel as C1Handle | undefined)?.client
/**
* 序⑲:**订阅推送**给出的在线态(主路径,D5)。
*
* 返回 `undefined` 的两种情况**都必须回退兜底**(⛔ 不许把它当 `false`):
* ① 订阅不新鲜(没订阅 / 已断 / 老 relay 不认 `SUB`);② 这条 host 不在推送范围。
* —— 把"不知道"当成"离线"会让**健康的节点被摘掉路由**(本线最贵的一类假红)。
*/
const presenceOnline = (name: string): boolean | undefined => {
const c = currentClient()
if (c === undefined || !c.presenceFresh()) return undefined
const e = c.presenceOf(name)
return e === undefined ? undefined : e.online
}
/** 订阅里的**回环落点**(`/status` 的那份数据,改由推送带来;查不到 ⇒ `undefined` 交给下一级兜底)。 */
const presenceLocalPort = (name: string, port: number): number | undefined => {
const c = currentClient()
if (c === undefined || !c.presenceFresh()) return undefined
const hit = c.presenceOf(name)?.localPorts.find((lp) => lp.port === port)
return hit === undefined || hit.localPort <= 0 ? undefined : hit.localPort
}
if (dialerEnabled) {
const started = await startDialer(relayUrl)
if (started !== undefined) failover.seed(toHandle(relayUrl, started))
@@ -556,6 +664,13 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
* 而故障切换要在 `RELAY_FAILOVER_DEADLINE_MS`(30 s)内完成 ⇒ 两者节奏必须分开。
*/
failover.start()
/**
* 序㉗:启动候选链**周期重发**(幂等、`unref()`、⛔ 零网络 I/O —— 只重发上次快照)。
* 与监管器的关系:监管器**只在需要换址时**解析,而探针是**事后**读 ⇒ 没有它就可能读不到行。
* ⚠️ 只在 `dialerEnabled` 时启动 —— 没有拨号通道就**不是 relay 客户端**,此时"候选数"无意义,
* 观测行**应当缺席**(探针会把"该路径无观测行"判红并点名,⛔ 不制造一行假 `unresolved`)。
*/
candObs.start()
}
/**
* 覆盖网络 P0-2:**后台刷新目录**(启动后再取一次,之后按目录给的刷新周期)。
@@ -571,16 +686,22 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
*/
const refreshOverlay = async (): Promise<void> => {
if (!dialerEnabled) return
const next = await resolveOverlayRelay(overlayResolveOpts())
/**
* 序㉗:改走 `resolveChain()` —— 与原 `resolveOverlayRelay(...)` **逐字等价**(证明见 `resolveChain`),
* 但**每次目录刷新都落进候选观测** ⇒ Manager 侧观测行天然每 `refreshAfterSeconds` 更新一次
* (⛔ 不需要为观测另加一次网络往返)。
*/
const chain = await resolveChain()
const nextUrl = chain.urls[0] ?? ''
const cur = failover.channel?.url ?? relayUrl
if (next.url === '' || next.url === cur) return
overlayLog(`[overlay-dir] 🔁 目录给出的地址变了:${cur} -> ${next.url}(source=${next.source})⇒ 换拨号通道`)
if (nextUrl === '' || nextUrl === cur) return
overlayLog(`[overlay-dir] 🔁 目录给出的地址变了:${cur} -> ${nextUrl}(source=${chain.source})⇒ 换拨号通道`)
/**
* 序⑧(D1):显式声明 `'directory'` —— 这条换址的触发条件("目录里的地址变了")与
* "旧通道是否可用"**无关** ⇒ ⛔ **没有打破冷却的权力**(有的话,"当前站在 106、目录首位是 47"
* 的每一轮巡检都会把刚冷却的 47 换回来 = 两位互相抢 = D5 想防的抖动风暴)。
*/
await failover.replace(next.url, `目录地址变更(source=${next.source})`, 'directory')
await failover.replace(nextUrl, `目录地址变更(source=${chain.source})`, 'directory')
}
if (dialerEnabled) {
const first = setTimeout(() => void refreshOverlay(), OVERLAY_REFRESH_FIRST_MS)
@@ -613,7 +734,11 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
// (`RelayDialer` 里带日志地拒掉)⇒ 绝不会把别张网的落点发出去。
const dialed = currentDialer()?.localPortFor(name, port)
if (dialed !== undefined) return `127.0.0.1:${dialed}`
// ② 回退:relay 快照里的动态回环口号(仅在 relay 与 Manager 同机时可用)
// ② 回退:**订阅推送**带来的回环落点(序⑲:订阅新鲜时它才是唯一在更新的那份)
const pushed = presenceLocalPort(name, port)
if (pushed !== undefined) return `127.0.0.1:${pushed}`
// ③ 再回退:relay 快照(只有"没订阅 / 订阅不新鲜"时才会走到这里 —— 即 R3 的原路径)
// 仅在 relay 与 Manager 同机时可用
const hit = relayEndpoints.get(`${name}:${port}`)
return hit === undefined ? undefined : `127.0.0.1:${hit.localPort}`
},
@@ -634,6 +759,11 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
*/
return currentDialer() !== undefined
},
/**
* 序⑲:**主路径 = 订阅推送**(新鲜时它是权威答案,`online` 那条兜底就不会被调用)。
* 返回 `undefined` ⇒ 回落到上面 `online`(= R3 的既有行为,一行未改)。
*/
presence: presenceOnline,
}),
]),
])
@@ -645,9 +775,15 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
* 网络维度取自 `dsh_hosts.network_id`(P0-1 迁的列,**本步才真正被消费**)——
* 在此之前它只是"表里有、没人读",于是控制面所有键(地址表 / via 表 / 端口表 /
* relay 端点表 / 拨号口池)都按**裸 hostId**,两张网各有一台同名 host 时会**互相覆盖**。
*
* ⚠️ 序㉑ P-2:映射由 `hostNameIndex` 统一提供(闭包 `translateEndpoint` 用的是**同一份**),
* 这里只是把它读出来;`??` 那支是**类型兜底**(索引按 `rows` 建 ⇒ 实际不可达)。
*/
const nameOf = (row: { id: string; networkId: string }): string =>
logicalName(row.networkId === '' ? OPS_NETWORK : row.networkId, row.id)
hostNameById.get(row.id) ?? logicalName(row.networkId === '' ? OPS_NETWORK : row.networkId, row.id)
// P-2:填 `hostId → 逻辑名`(闭包拿到裸 hostId 后靠它回到"控制面的键口径")
hostNameById.clear()
for (const [id, name] of hostNameIndex(rows)) hostNameById.set(id, name)
// 先同步地址表(会合实现不直接连 DB),再逐行解析 —— 两遍是为了让 `resolve()` 只看纯内存表。
for (const row of rows) {
const name = nameOf(row)
@@ -721,6 +857,106 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
candidates.sort((a, b) => a.usedMb - b.usedMb)
return candidates[0].id
}
/**
* ── 序㉔ 内容分发装配(块级内容寻址 · 同网段 peer 优先)──────────────────────
*
* ⚠️ **本块是"仅装配"**(交接单 §3.1):只把 `store` / `source` / `peer` 三个纯逻辑模块
* **接线并暴露计数**,⛔ 不改 presence、不改端点翻译、不改切流、不新开监听口(R5)。
*
* **回滚 = 整段移除本块**(§6 装配级回滚):新模块文件留着不加载 ⇒ 零副作用。
*
* 三档 fetcher 的落点(本阶段):
* - `local` —— 直接查 `ContentStore`(同进程已持有的块);
* - `peer` —— 走 `ContentPeerGroup.candidates()` 的结果(同组 peer;真机取块经既有 wss);
* - `edge` / `region` / `origin` —— **本阶段未装配**(缺档 ⇒ 链按"该档没有"处理并**照样计数**,
* 这正是 `ContentSourceChain` 纪律 2/3 要的行为:⛔ 不许因为没装配就静默缩短链路)。
*
* ⚠️ 之所以敢先不装配 `origin`:本单的 E1 判据(回源 ≈ 1 份 × 组数)测的是
* "**同组内多台只回源一次**",判据落在 `local` / `peer` 两档的命中计数上;
* 真回源路径(平台代理层)本就在 `proxy.ts`,与本块正交。
*
* 🔴 **两个参数就地读 env(⛔ 不进 `config.ts`)**:`config.ts` **不在本单在册文件集**
* (交接单 §3.1)⇒ 动它 = 命中 §9-2 回头条件(超范围)。故装配层就地取:
* - `CONTENT_STORE_MAX_BYTES` —— 块缓存上限(缺省 64 MiB,见 `store.ts` 推算);
* - `CONTENT_GROUP` —— 本节点在内容面上的**组名**(缺省 `local`)。
* ⚠️ 二者都是"纯新增、缺省可用"⇒ 不设也不影响既有行为(⛔ 不动任何既有键)。
*/
const contentStoreMaxBytes =
Number(process.env.CONTENT_STORE_MAX_BYTES ?? '') > 0
? Number(process.env.CONTENT_STORE_MAX_BYTES)
: undefined
const contentGroup = process.env.CONTENT_GROUP ?? 'local'
/**
* 🆕 序㉘ · 单 B:**平台侧接入加密,但缺省不启用**。
*
* 🔑 为什么平台侧**默认关**:① 平台进程服务真实用户,密钥落点越少越好(单内 §7.2
* "密钥本体只走 `0600` 落文件");② `OBS-23` 的读取面是 **relay** 的 `/status`
* ⇒ 判据在 relay 侧成立即可;③ `peer` 取回通道尚未接线 ⇒ 跨进程密钥一致性今天**不构成收益**。
* ⚠️ 要开只需配 `CONTENT_GROUP_KEY_FILE`(**纯新增、缺省可用** ⇒ 不设即回到序㉔ 行为)。
*/
const platformKeyFile = process.env.CONTENT_GROUP_KEY_FILE ?? ''
const platformGraceMs = Number(process.env.CONTENT_EPOCH_GRACE_MS ?? '')
const { cipher: contentCipher } =
platformKeyFile === ''
? { cipher: undefined }
: openContentCipher({
file: platformKeyFile,
group: contentGroup,
network: OPS_NETWORK,
...(Number.isFinite(platformGraceMs) && platformGraceMs > 0 ? { graceMs: platformGraceMs } : {}),
log: overlayLog,
})
const contentStore = new ContentStore({
maxBytes: contentStoreMaxBytes,
})
const contentPeers = new ContentPeerGroup({
// 本节点在内容面上的分组:网 = 运维网(平台自己的机器),组 = 本机(同网段走本机多实例验证)
network: OPS_NETWORK,
group: contentGroup,
...(contentCipher === undefined ? {} : { epoch: contentCipher.epoch }),
log: overlayLog,
})
const contentSource = new ContentSourceChain({
fetchers: {
local: async (id: string) => {
const bytes = contentStore.get(id)
return bytes === undefined ? undefined : { tier: 'local' as const, bytes }
},
peer: async (id: string) => {
// 同组有候选 ⇒ 由上层真机路径去取;本阶段没有真实取回通道时诚实回"没有"
// (⛔ 不许伪造字节 —— 那会让 E1 的"零回源"变成假绿)
const cands = contentPeers.candidates(id)
return cands.length === 0 ? undefined : undefined
},
},
// 🆕 单 B:唯一解密点(生产路径);未启用加密 ⇒ 不注入 ⇒ 行为逐字不变
...(contentCipher === undefined ? {} : { decode: (stored: Buffer) => contentCipher?.decodeBlock(stored) }),
onHit: (tier, id) => overlayLog(`[content] 命中 tier=${tier} block=${id.slice(0, 8)}…`),
onMiss: (tier, id) => overlayLog(`[content] 未命中 tier=${tier} block=${id.slice(0, 8)}…`),
onError: (tier, id, err) =>
overlayLog(`[content] ⛔ tier=${tier} 抛错 block=${id.slice(0, 8)}… err=${String(err)}`),
onDecodeRejected: (tier, id) =>
overlayLog(`[content] ⛔ tier=${tier} 取回的块解密失败 block=${id.slice(0, 8)}…(认证未过)`),
})
/** 内容面计数快照(供 `/status` 类观测读取;⛔ 只读,不改任何既有字段)。 */
const contentCounters = (): Record<string, unknown> => ({
store: contentStore.counters(),
storeBytes: contentStore.bytes,
storeBlocks: contentStore.size,
/** ⚠️ 键名与 `ContentSourceChain.counters()` 同构 ⇒ 探针可逐档断言。 */
source: contentSource.counters(),
sourceMiss: contentSource.missCounters(),
sourceErrors: contentSource.errors(),
sourceMissTotal: contentSource.misses(),
/** 🆕 单 B:逐档解密被拒(`sourceErrors` 的细分)+ 加密判别器块。 */
sourceDecodeRejected: contentSource.decodeRejected(),
// ⚠️ 不启用 ⇒ 键整体缺席(补零会让"没启用"与"启用了但零值"同形)
...(contentCipher === undefined ? {} : { crypto: contentCipher.counters() }),
peer: contentPeers.counters(),
peerGroup: contentPeers.groupKey,
})
void contentCounters // 暴露给后续观测面(S5 探针项);本阶段先建在作用域内,避免"装了但没人读"
const supervisor: Spawner =
config.deployMode === 'cluster'
? (leased = new LeasedSpawner(
@@ -751,14 +987,40 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
* 空响应、平台零日志)。未知 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 }
/**
* 🔴 **序㉑ P-2:先把裸 hostId 换成逻辑名**(控制面所有表的键口径)。
*
* 原实现直接 `hostVia.get(hostId)` ⇒ **恒 `undefined`** ⇒ 早退原样透传 ⇒
* 这个闭包整体是**死分支**(翻译从未生效;`translateEndpoint` 的第一版还漏过赋值)。
* 判定本体已抽成纯函数 `relayEndpointTarget`(可单测、可先红后绿)。
*/
const name = hostNameById.get(hostId)
const decision = relayEndpointTarget({
known: name !== undefined,
via: name === undefined ? undefined : hostVia.get(name),
// ⚠️ thunk:`localPortFor` **会按需绑池口 / 真的拨一次** ⇒ 只在 via=relay 时才准调
dialedPort:
name === undefined ? () => undefined : () => currentDialer()?.localPortFor(name, ep.port),
/**
* 🔴 **序㉒ P-2b:第 ②' 级 = 订阅推送落点**。
*
* 必须与 `addressOf`(上方 `RelayRendezvous`)的三级链**同源、同顺序**:
* ① 拨号落点 → ② `presenceLocalPort` → ③ relay 快照。少这一级时,P-1 修好之后
* (订阅新鲜期长期成立 ⇒ 快照趋冷)会在"拨号池分不出槽位、但推送里有落点"时
* **判实例不可达(失败关闭)**,而同一时刻地址解析链能答出落点 ⇒ 两条链漂移 ⇒
* 用户看到"实例打不开",日志却什么都没有。
*/
pushedLocalPort: name === undefined ? undefined : presenceLocalPort(name, ep.port),
snapshotLocalPort:
name === undefined ? undefined : relayEndpoints.get(`${name}:${ep.port}`)?.localPort,
})
if (decision.kind === 'passthrough') return ep
if (decision.kind === 'local') return { host: '127.0.0.1', port: decision.port }
// 失败关闭 + **点名**(⛔ 不回原样透传:那会拨到 Manager 本机,症状只有"空响应 + 零日志")
overlayLog(
`[overlay-endpoint] ⛔ ${name ?? hostId}:${ep.port} 在册且 via=relay,但拨号落点 / 订阅推送 / relay 快照三级都查不到 ⇒ 判实例不可达(失败关闭)`,
)
return undefined
},
}),
db,