/** * **服务器实例的覆盖网络设备凭据**:平台侧代签 + 投递到实例 home + 续签(序47 · S4/S5)。 * * ## 为什么必须是"平台侧代签" * 本版平台**不存在**「实例 → 平台」的身份通道(交接单序47 §②.1 取证,⛔ 不许改判): * `requireAuth` 只认 `sid` cookie、`POST /api/auth/login` 只收 `username + password` * (而平台不存明文口令、也从不把口令投给实例)、实例启动时平台投递的 env 里没有任何平台身份凭据。 * ⇒ 实例**不可能**自己去调那份"用户态签发端点",只能由**控制面以 `userId` 归属为据代签**。 * 签发本身走的是**唯一实现** `device-grant.ts#issueDeviceGrant`(⛔ 本文件不复制那段判据)。 * * ## 三件事,各自只有一处 * | 关注点 | 落点 | * |---|---| * | 怎么签、按什么顺序落四处、失败怎么具名 | `device-grant.ts#issueDeviceGrant`(序47 S4 已抽) | * | **何时**签 / 何时续签 / 凭据文件长什么样 | 本文件 | * | 何时触发(实例启动 / 定时续签) | `supervisor/orchestrator.ts` · `remote-spawner.ts` · `web/server.ts` | * * ## 🔴 五条硬纪律 * ① **写实例 home 一律走 `UserFs`** —— 实例可能在 worker 上,直接 `fs` = **静默空操作** * (档案 138 §五:读回空串、不报错)。本文件**不 import `node:fs` 写用户卷**。 * ② **密钥不落平台**:私钥由平台生成后**只写进实例 home**(§④ 决策 6),平台不留副本、 * ⛔ 不进日志、⛔ 不进审计 detail;⛔ **绝不**走 `home-files.ts#backupHomeFile` * ——那会把私钥复制进平台备份目录,等于自建一份副本。 * ③ **⛔ 不改判、不重建**:凭据文件存在但形状非法 ⇒ **具名拒绝**(`credential-file-corrupt`), * ⛔ 不"修一下再看"。原因:重建必然生成新密钥对 ⇒ **hostId 改变**(`d--<公钥前8位>`) * ⇒ 换一台"设备",会绕过针对旧 hostId 的停发(`status='revoked'`)并多占一个配额位。 * 处置 = 人工确认后**删除该文件**,下一次触发即重新签发。 * ④ **续签复用同一把 nodeKey**(读回文件里的私钥)⇒ hostId 不变 ⇒ 台账那一行是**同一条** * (`grant_renewals + 1`),⛔ 不产生"第二条实例设备"。 * ⑤ **每用户至多一条活跃的 `kind='instance'` 设备** —— 平台本来就"一账号一个 main 实例"。 * 因此新签发(换 hostId)时,把该用户名下**其它** `kind='instance'` 条目**收编**: * 台账置 `revoked` + 从 registry 移除。⛔ 这不是"顺手多做",而是 ②③ 的直接补偿: * 没有它,"删文件重建"这条**我们自己写进回滚/处置流程**的动作会静默烧掉配额位, * 并在用户网里永久留一台查不到主人的已批准设备。 * * @module dshs/net/relay/instance-credential */ import type { DbAdapter } from '../../db/adapter.js' import type { OverlayDevice } from '../../db/types.js' import type { HomeFileName, UserFs } from '../../fs/user-fs.js' import { DEVICE_GRANT_TTL_HOURS_DEFAULT, envNum, issueDeviceGrant, registryFile, tenantNetworkOf, type DeviceGrantDb, type IssueDeviceGrantInput, } from './device-grant.js' import { generateNodeKey, publicKeyOfPrivate } from './identity.js' import { loadRegistry, removeNode, saveRegistry } from './registry.js' /** 实例凭据在实例 home 里的**固定文件名**(`UserFs` 白名单之一,⛔ 不收路径)。 */ export const INSTANCE_DEVICE_FILE: HomeFileName = '.overlay-device.json' /** 凭据文件的结构版本(形状变了就 +1,让老文件能被**具名**判成不可用而不是半读)。 */ export const INSTANCE_DEVICE_FILE_VERSION = 1 /** 设备来源标签 —— 与 v13 的 `overlay_devices.kind` 值域同源(⛔ 不另立一套)。 */ export const INSTANCE_DEVICE_KIND = 'instance' as const /** 分组标签(进 registry 的 `group`,纯展示 / 分桶)。 */ export const INSTANCE_DEVICE_GROUP = 'instance' /** * 续签扫描间隔(ms)。**中性默认 5 分钟**,外置键 `DSHS_INSTANCE_GRANT_RENEW_MS`。 * `0` ⇒ 关闭定时续签(只剩"实例启动时检查"这一条触发)。 */ export const INSTANCE_GRANT_RENEW_MS_DEFAULT = 300_000 /** * 剩余租约**低于 TTL 的这个比例**才续签。 * * 为什么不是"临到期才续":续签要经 `UserFs`(集群形态下一次跨机写)+ 一次台账写 + * 一次 registry 写,三步都可能失败;留 50% 的余量意味着**每一步都还有十几次重试机会** * (5 分钟一次),而不是"最后一次机会失败 = 实例掉网"。 * ⚠️ 复用同一把 HMAC 密钥 ⇒ 续签**不轮换**密钥 ⇒ 这个偏保守的比例不会带来 * "实例还没读回新文件就被拒"的窗口(`HELLO` 的 MAC 用的是同一把)。 */ export const INSTANCE_GRANT_RENEW_FRACTION = 0.5 /** 凭据文件的内容 —— 实例侧拨号客户端要的就是这些(⛔ 平台侧只读它的公钥部分)。 */ export interface InstanceDeviceCredential { version: number kind: typeof INSTANCE_DEVICE_KIND network: string hostId: string /** 节点**公钥**(裸 64 hex)。 */ nodeKey: string /** 节点**私钥**(PEM)—— 只存在于实例 home,平台不留副本。 */ nodePrivateKeyPem: string /** relay `HELLO` 的 MAC 密钥(hex)。只存在于实例 home + relay 的密钥表。 */ hmacSecret: string /** 平台签名者签发的入网凭据(`{doc, sig}`,直接随 `HELLO` 出示)。 */ grant: { doc: unknown; sig: string } issuedAt: string expiresAt: string renewals: number } /** 本模块用到的台账读写(显式声明 ⇒ 调用方提供的桩不必实现整个 `DbAdapter`)。 */ export type InstanceCredentialDb = DeviceGrantDb & Pick export interface InstanceCredentialDeps { db: InstanceCredentialDb /** * ⚠️ **只要这两个方法**:写用户卷必须走本 seam(纪律 ①)。 * 生产传 `app.userFs`(本地 / 集群两种实现都满足),测试传内存桩。 */ userFs: Pick } export type InstanceCredentialAction = 'unchanged' | 'issued' | 'renewed' /** 成功回执 —— ⛔ **不含任何密钥材料**(可安全打印 / 可进日志)。 */ export interface InstanceCredentialOk { ok: true action: InstanceCredentialAction file: HomeFileName userId: string network: string hostId: string expiresAt: string renewals: number /** 距租约到期的剩余毫秒(可负 = 已过期)。 */ remainingMs: number /** 这次是否真的动了 relay 密钥表(续签复用同一把 ⇒ `false`)。 */ relayKeyRotated: boolean /** 被本次签发**收编**的旧实例设备 hostId(纪律 ⑤;正常续签恒为空)。 */ superseded: string[] /** 非致命备注(如"收编台账失败")—— 有值时调用方**应当**打出来(⛔ 不静默吞)。 */ detail?: string } /** 失败 —— `reason` 一律具名(⛔ 不许把"配置错"写成"功能没生效")。 */ export interface InstanceCredentialErr { ok: false reason: string detail: string /** 有 HTTP 语义时给出(取自 `issueDeviceGrant`),便于调用方对齐既有原因码。 */ status?: number file: HomeFileName userId: string /** 失败发生在"已经换了 hostId"之后时带上(便于排查),否则 `undefined`。 */ hostId?: string } export type InstanceCredentialResult = InstanceCredentialOk | InstanceCredentialErr function msg(err: unknown): string { return err instanceof Error ? err.message : String(err) } function err( base: { userId: string }, reason: string, detail: string, extra: { status?: number; hostId?: string } = {}, ): InstanceCredentialErr { return { ok: false, reason, detail, file: INSTANCE_DEVICE_FILE, userId: base.userId, ...extra } } const HEX64 = /^[0-9a-f]{64}$/ /** * 解析凭据文件。**严格**:任一条不合规 ⇒ `undefined`(调用方按 `credential-file-corrupt` 处理)。 * * ⚠️ 校验里包含"**公钥与私钥确实是同一对**"—— 这两者不一致时,凭据在拨号那一刻才会以 * `identity-bad-proof` 失败,而那个症状看着像"网络问题";在这里判掉,故障就发生在 * **写入前**,且成因只有一种。 */ export function parseInstanceCredential(text: string): InstanceDeviceCredential | undefined { let raw: unknown try { raw = JSON.parse(text) } catch { return undefined } if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined const r = raw as Record if (r.version !== INSTANCE_DEVICE_FILE_VERSION) return undefined if (r.kind !== INSTANCE_DEVICE_KIND) return undefined const network = typeof r.network === 'string' ? r.network : '' const hostId = typeof r.hostId === 'string' ? r.hostId : '' const nodeKey = typeof r.nodeKey === 'string' ? r.nodeKey.trim().toLowerCase() : '' const nodePrivateKeyPem = typeof r.nodePrivateKeyPem === 'string' ? r.nodePrivateKeyPem : '' const hmacSecret = typeof r.hmacSecret === 'string' ? r.hmacSecret.trim().toLowerCase() : '' if (network === '' || hostId === '') return undefined if (!HEX64.test(nodeKey) || !HEX64.test(hmacSecret)) return undefined if (!nodePrivateKeyPem.includes('PRIVATE KEY')) return undefined const grant = r.grant as { doc?: unknown; sig?: unknown } | undefined if (grant === undefined || typeof grant !== 'object' || Array.isArray(grant)) return undefined if (grant.doc === null || typeof grant.doc !== 'object') return undefined if (typeof grant.sig !== 'string' || grant.sig === '') return undefined // 公私钥必须成对(见本函数头注)。 try { if (publicKeyOfPrivate(nodePrivateKeyPem) !== nodeKey) return undefined } catch { return undefined } const issuedAt = typeof r.issuedAt === 'string' ? r.issuedAt : '' const expiresAt = typeof r.expiresAt === 'string' ? r.expiresAt : '' if (Number.isNaN(Date.parse(issuedAt)) || Number.isNaN(Date.parse(expiresAt))) return undefined const renewals = typeof r.renewals === 'number' && Number.isFinite(r.renewals) ? r.renewals : 0 return { version: INSTANCE_DEVICE_FILE_VERSION, kind: INSTANCE_DEVICE_KIND, network, hostId, nodeKey, nodePrivateKeyPem, hmacSecret, grant: { doc: grant.doc, sig: grant.sig }, issuedAt, expiresAt, renewals, } } /** 把凭据序列化成**落盘文本**(缩进 2 + 结尾换行,与仓库其它 JSON 落盘一致)。 */ export function serializeInstanceCredential(cred: InstanceDeviceCredential): string { return `${JSON.stringify(cred, null, 2)}\n` } function ttlMs(): number { return envNum('DESKTOP_GRANT_TTL_HOURS', DEVICE_GRANT_TTL_HOURS_DEFAULT) * 3_600_000 } function okOf( action: InstanceCredentialAction, cred: { userId: string; network: string; hostId: string; expiresAt: string; renewals: number }, nowMs: number, relayKeyRotated: boolean, superseded: string[], ): InstanceCredentialOk { return { ok: true, action, file: INSTANCE_DEVICE_FILE, userId: cred.userId, network: cred.network, hostId: cred.hostId, expiresAt: cred.expiresAt, renewals: cred.renewals, remainingMs: Date.parse(cred.expiresAt) - nowMs, relayKeyRotated, superseded, } } /** * **收编**该用户名下其它 `kind='instance'` 台账条目(纪律 ⑤)。 * * 台账置 `revoked`(⇒ `issueDeviceGrant` 的停发闸门会拒它,⛔ 不能"续一次签就复活") * + 从 registry 移除(⇒ 立刻退出派生白名单)。 * * ⛔ **失败不阻断新签发**:新凭据已经签好了,为一台"已经没凭据的旧设备"回滚新凭据, * 结果是**两边都没有凭据** —— 那是净变差(R11)。故只记在返回值里,由调用方具名。 */ async function supersedeOtherInstanceDevices( deps: InstanceCredentialDeps, userId: string, keepHostId: string, ): Promise<{ superseded: string[]; notes: string[] }> { const network = tenantNetworkOf(userId) let rows: OverlayDevice[] try { rows = await deps.db.listOverlayDevices(network) } catch (e) { return { superseded: [], notes: [`台账不可读,未做收编:${msg(e)}`] } } const stale = rows.filter((d) => d.kind === INSTANCE_DEVICE_KIND && d.hostId !== keepHostId) if (stale.length === 0) return { superseded: [], notes: [] } const notes: string[] = [] const done: string[] = [] for (const d of stale) { try { await deps.db.setOverlayDeviceStatus(network, d.hostId, 'revoked') done.push(d.hostId) } catch (e) { notes.push(`台账收编失败 ${d.hostId}:${msg(e)}`) } } try { const file = registryFile() const reg = loadRegistry(file) let touched = false for (const hostId of done) { if (removeNode(reg, network, hostId).ok) touched = true } if (touched) saveRegistry(file, reg) } catch (e) { notes.push(`registry 收编失败:${msg(e)}`) } return { superseded: done, notes } } /** * **确保该用户的实例凭据在实例 home 里可用**(S4 的投递 + S5 的续签,同一个入口)。 * * 三种结果(`action`): * | 情况 | 动作 | 触发者 | * |---|---|---| * | 文件不存在 | **首次签发**(平台生成密钥对) | 实例启动 | * | 文件存在且剩余租约 ≤ TTL × {@link INSTANCE_GRANT_RENEW_FRACTION} | **续签**(同 hostId、同密钥) | 实例启动 / 定时器 | * | 文件存在且租约仍充裕 | `unchanged`(**一次写都不做**) | 实例启动 / 定时器 | * * ⛔ 本函数**不抛异常**:全部失败都转成 `{ok:false, reason, detail}` —— 它的调用方在 * **实例启动路径**上,一个未捕获异常会把"起实例"变成"起不来"。启动路径的纪律是 * "拿不到凭据 = 少一条能力",⛔ **不是**"实例不给起"(那会把覆盖网络的分支性故障 * 升级成平台主功能故障)。 */ export async function ensureInstanceCredential( deps: InstanceCredentialDeps, userId: string, opts: { reason?: string; force?: boolean; nowMs?: number } = {}, ): Promise { const nowMs = opts.nowMs ?? Date.now() const base = { userId } let text: string | null try { text = await deps.userFs.readHomeFile(userId, INSTANCE_DEVICE_FILE) } catch (e) { return err(base, 'credential-file-unreadable', `读 ${INSTANCE_DEVICE_FILE} 失败:${msg(e)}`) } let existing: InstanceDeviceCredential | undefined if (text !== null && text.trim() !== '') { existing = parseInstanceCredential(text) if (existing === undefined) { return err( base, 'credential-file-corrupt', `${INSTANCE_DEVICE_FILE} 形状非法(⛔ 不猜、不重建:重建会换 hostId ⇒ 绕过针对旧 hostId 的停发并多占一个设备配额位)。处置 = 人工确认后删除该文件,下一次实例启动 / 续签扫描会重新签发`, ) } } const ttl = ttlMs() if (existing !== undefined && opts.force !== true) { const remaining = Date.parse(existing.expiresAt) - nowMs if (Number.isFinite(remaining) && remaining > ttl * INSTANCE_GRANT_RENEW_FRACTION) { return okOf('unchanged', { userId, ...existing }, nowMs, false, []) } } /* ── 密钥对:续签**读回文件里的那一把**(纪律 ④);首次签发才生成新的(纪律 ②)── * ⚠️ 私钥在这里被"用一下",随后只存在于将要写出的文本里:⛔ 不进日志、不进返回值、 * 不进任何备份(`writeHomeFile` 不做备份,见纪律 ②)。 */ let nodeKey: string let nodePrivateKeyPem: string let hmacSecret: string | undefined if (existing !== undefined) { nodeKey = existing.nodeKey nodePrivateKeyPem = existing.nodePrivateKeyPem hmacSecret = existing.hmacSecret } else { const keys = generateNodeKey() nodeKey = keys.publicKey nodePrivateKeyPem = keys.privateKeyPem hmacSecret = undefined } const input: IssueDeviceGrantInput = { db: deps.db, userId, nodeKey, group: INSTANCE_DEVICE_GROUP, kind: INSTANCE_DEVICE_KIND, } if (hmacSecret !== undefined) input.hmacSecret = hmacSecret const issued = await issueDeviceGrant(input) if (!issued.ok) { return err(base, String(issued.body.error ?? 'issue-failed'), `签发被拒:${JSON.stringify(issued.body)}`, { status: issued.status, }) } // 换 hostId 才算"换了一台设备" ⇒ 这时才需要收编旧的(续签不改 hostId,`superseded` 恒空)。 const superseded = existing === undefined ? await supersedeOtherInstanceDevices(deps, userId, issued.hostId) : { superseded: [], notes: [] } const next: InstanceDeviceCredential = { version: INSTANCE_DEVICE_FILE_VERSION, kind: INSTANCE_DEVICE_KIND, network: issued.network, hostId: issued.hostId, nodeKey, nodePrivateKeyPem, hmacSecret: issued.hmacSecret, grant: { doc: issued.grant.doc, sig: issued.grant.sig }, issuedAt: issued.ledger.grantIssuedAt, expiresAt: issued.expiresAt, renewals: issued.ledger.grantRenewals, } try { await deps.userFs.writeHomeFile(userId, INSTANCE_DEVICE_FILE, serializeInstanceCredential(next)) } catch (e) { return err(base, 'credential-write-failed', `写 ${INSTANCE_DEVICE_FILE} 失败:${msg(e)}`, { hostId: issued.hostId, }) } const result = okOf( existing === undefined ? 'issued' : 'renewed', { userId, ...next }, nowMs, issued.applied.relayKeyRotated, superseded.superseded, ) // 非致命备注(收编失败等)一并带出:⛔ 不静默吞 —— "以为收编了、其实没"正是本线反复复发的那类。 return superseded.notes.length === 0 ? result : { ...result, detail: superseded.notes.join(';') } } /* ── S5 · 定时续签 ─────────────────────────────────────────────────────────── */ /** 一次续签扫描的读数 —— ⛔ 无密钥材料,可安全打印 / 进日志。 */ export interface InstanceCredentialRenewalReport { /** 台账里扫到的 `kind='instance'` 且 `status='active'` 的条目数。 */ scanned: number unchanged: number renewed: number issued: number failed: Array<{ userId: string; reason: string; detail: string }> /** 被收编的旧实例设备 hostId(纪律 ⑤;正常续签恒为空)。 */ superseded: string[] } export interface InstanceCredentialRenewalHandle { /** 停表(`onClose` 用;⛔ 不 stop 会让进程带着一个 unref 定时器继续跑测试)。 */ stop: () => void /** 手动跑一遍(测试 / 运维排查用;与定时器**互斥**,不会并发跑两遍)。 */ runOnce: () => Promise /** 定时器是否真的挂上了(`intervalMs<=0` ⇒ `false`,只剩手动)。 */ armed: boolean } /** * 起**平台侧定时续签**(S5)。 * * 为什么续签必须有定时器:实例是**常驻**的(可能连跑数周),而设备租约默认 24 小时 ⇒ * 只靠"实例启动时检查"意味着"跑过一天就掉网"。 * * 扫描源 = **台账**(`overlay_devices`),⛔ 不去读实例文件、⛔ 不依赖实例侧上报 * (§④ 决策 7:台账已存 `node_key`,续签**不需要**"实例 → 平台"的通道 —— 那条通道不存在)。 * ⚠️ 这意味着**新条目的产生**仍只有一条路:实例启动时的 `ensureInstanceCredential` * (台账里有 `kind='instance'` 行 ⟺ 曾经签过)。⛔ 本扫描**不猜**"哪个用户该有实例"。 * * ⚠️ 定时器 `unref()`:它**不该**让进程活着(与 `LocalSpawner` 的 idle-reap 同款; * 否则关机会被它拖住)。 */ export function startInstanceCredentialRenewal( deps: InstanceCredentialDeps, opts: { intervalMs?: number; onReport?: (report: InstanceCredentialRenewalReport) => void } = {}, ): InstanceCredentialRenewalHandle { const envRaw = process.env.DSHS_INSTANCE_GRANT_RENEW_MS const intervalMs = opts.intervalMs ?? (envRaw === undefined || envRaw.trim() === '' ? INSTANCE_GRANT_RENEW_MS_DEFAULT : Number(envRaw)) let running = false const runOnce = async (): Promise => { const report: InstanceCredentialRenewalReport = { scanned: 0, unchanged: 0, renewed: 0, issued: 0, failed: [], superseded: [], } if (running) return report // 上一遍还没跑完:本遍直接跳过(⛔ 不排队、不并发) running = true try { let rows: OverlayDevice[] try { rows = await deps.db.listOverlayDevices() } catch (e) { report.failed.push({ userId: '-', reason: 'ledger-unreadable', detail: msg(e) }) return report } // ⛔ 只认租户网 `u:`:别的网名形态(`ops` 等)不猜归属 —— 实例凭据只属于某个人。 const users = [ ...new Set( rows .filter((d) => d.kind === INSTANCE_DEVICE_KIND && d.status === 'active' && d.network.startsWith('u:')) .map((d) => d.network.slice(2)), ), ] report.scanned = users.length for (const userId of users) { const r = await ensureInstanceCredential(deps, userId, { reason: 'renewal-timer' }) if (!r.ok) { report.failed.push({ userId, reason: r.reason, detail: r.detail }) continue } if (r.action === 'unchanged') report.unchanged += 1 else if (r.action === 'renewed') report.renewed += 1 else report.issued += 1 report.superseded.push(...r.superseded) } return report } finally { running = false } } const armed = Number.isFinite(intervalMs) && intervalMs > 0 const timer = armed ? setInterval(() => void runOnce().then((r) => opts.onReport?.(r)), intervalMs) : undefined timer?.unref() return { stop: () => (timer === undefined ? undefined : clearInterval(timer)), runOnce, armed } }