Files
dsh_shenxian/src/net/relay/instance-credential.ts
T

515 lines
22 KiB
TypeScript
Raw Normal View History

/**
* **服务器实例的覆盖网络设备凭据**:平台侧代签 + 投递到实例 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-<uid>-<公钥前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<DbAdapter, 'listOverlayDevices' | 'setOverlayDeviceStatus'>
export interface InstanceCredentialDeps {
db: InstanceCredentialDb
/**
* ⚠️ **只要这两个方法**:写用户卷必须走本 seam(纪律 ①)。
* 生产传 `app.userFs`(本地 / 集群两种实现都满足),测试传内存桩。
*/
userFs: Pick<UserFs, 'readHomeFile' | 'writeHomeFile'>
}
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<string, unknown>
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<InstanceCredentialResult> {
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<InstanceCredentialRenewalReport>
/** 定时器是否真的挂上了(`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<InstanceCredentialRenewalReport> => {
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:<userId>`:别的网名形态(`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 }
}