chore(仓库对齐): 文档库结构治理 + IM/插件线落地
build / build-and-scan (push) Waiting to run

文档库:目录改为编号制(01-规范/02-架构设计/03-数据库/04-调整方案/
05-交接单/06-ops/07-scripts/08-skills/09-archive),顶层散文件归入 01-规范/;
INDEX.md 与 docs-manifest.json 重刷(档案 146 篇);旧目录名引用全量对齐。

IM 线:src/im/**(SDK / hub / store / presence / ws / gateway-token)、
src/web/routes/im.ts、src/db/plugin-data/**、src/supervisor/plugin-assembly.ts
及对应 test/**。

插件线:poc/{im-agent-bridge,im-connection-gateway,im-conversation-tabs,
business-plugins-im,carbon-mcp-probe}、src/web/routes/{sessions,overlay-device}.ts、
src/net/relay/{device-grant,instance-credential}.ts。

仓库卫生:清出 40 个历史误入库 / 已改名文件(34 个交接单归档 + 6 个旧结构,
本地均有副本);dsh-server-docs/.gitignore 补 tmp/;交接单不入库(政策)。
This commit is contained in:
admin committed 2026-09-24 07:25:16 +08:00
1 parent 3d8f50e366
commit e6207aa691
239 files changed
+34477 -14633

No files matched your search

+380
View File
@@ -0,0 +1,380 @@
/**
* **设备凭据的共用签发实现**(序47 · S4)。
*
* ## 为什么要有这一个文件
* 到序㊻ 为止,"签一份 per-device 凭据"这件事只存在于**用户态端点**
* (`src/web/routes/overlay-device.ts`)里。序47 要给「服务器实例」也发一份凭据,
* 而实例**不可能**去调那个端点(本版平台没有"实例 → 平台"的身份通道,
* 序47 §②.1 取证)⇒ 只能由平台侧代签。
*
* ⛔ 代签**不是**把那段逻辑再抄一遍 —— 抄一遍就等于把"签发"变成**两处判据**:
* 改了一处(比如新加一道门、改配额口径),另一处照旧,而这种分叉只会在生产上以
* "桌面进得来、实例进不来"的形态暴露。⇒ 本模块是**唯一**实现,两条路径都调它。
*
* ## 两条调用路径
*
* | 调用方 | 谁来生成密钥对 | `kind` |
* |---|---|---|
* | `web/routes/overlay-device.ts`(用户态端点) | **设备自己**(只把公钥送上来) | `desktop` |
* | `supervisor/orchestrator.ts`(实例启动) | **平台**(写入实例 home,⛔ 平台不留副本) | `instance` |
*
* ⛔ 本模块**不含 fastify**:它是能力层(`src/net/relay/`)的实现,返回值用
* `{ ok:false, status, body }` 表达失败,由**路由层**决定怎么回 —— 这样
* "业务判据"与"HTTP 表达"就不会互相绑死,实例路径也能直接复用同一份判据。
*
* ## 🔴 五道门(顺序即判据,⛔ 不许调换)
* ① **停发闸门**(台账 `status='revoked'`)→ ② **用户配额**(registry 的 `approved` 数)
* → ③ 签 grant → ④ **registry 落盘** → ⑤ **PG 台账** → ⑥ **relay 密钥表**。
*
* ⚠️ ⑤ 在 ⑥ **之前**是刻意的:密钥表一写,这台设备就**真的能拨进来**了;台账写在它
* 之前,则"台账写失败"时还没有产生任何**功能性**后果。反过来排序会让"密钥表写成功、
* 台账写失败"变成一台**查不到归属**却能拨进来的设备。
*
* @module dshs/net/relay/device-grant
*/
import { randomBytes } from 'node:crypto'
import { copyFileSync, existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { DbAdapter } from '../../db/adapter.js'
import { OVERLAY_DEVICE_KIND_DEFAULT, type OverlayDeviceKind } from '../../db/types.js'
import { dataRootDir } from '../../platform-paths.js'
import { signNodeGrant, type NodeGrant } from './identity.js'
import { loadKeysFile, keyEntryOf } from './keys.js'
import { logicalName } from './network.js'
import { applyApplication, approveNode, loadRegistry, saveRegistry } from './registry.js'
/** 设备租约时长(小时)。**中性默认 24** —— 外置键 `DESKTOP_GRANT_TTL_HOURS`。 */
export const DEVICE_GRANT_TTL_HOURS_DEFAULT = 24
/**
* 每用户设备数上限。**中性默认 10** —— 外置键 `DESKTOP_GRANT_MAX_DEVICES`。
*
* ⚠️ 服务器实例的设备条目**同样占这个配额**(序47 §⑨ 风险 3):实例签发时若已到上限,
* 会得到 `device-quota-exceeded`。本单 ⛔ 不改该默认值。
*/
export const DEVICE_GRANT_MAX_DEVICES_DEFAULT = 10
/** 设备 hostId 前缀(§④ 已定规则 `d-<userId>-<pubkey 前 8 位>`)。 */
export const DEVICE_HOST_PREFIX = 'd'
/** 租户网前缀 —— 与 `network.ts` 的 `u:<租户>` 口径同源(⛔ 不另立一套)。 */
export function tenantNetworkOf(userId: string): string {
return `u:${userId}`
}
/** 设备 hostId:`d-<userId>-<公钥前 8 位>`(§④ 已定;⚠️ 碰撞概率随设备数平方增长,够用即可)。 */
export function deviceHostIdOf(userId: string, nodeKeyHex: string): string {
return `${DEVICE_HOST_PREFIX}-${userId}-${nodeKeyHex.slice(0, 8)}`
}
export function envNum(name: string, fallback: number): number {
const raw = process.env[name]
if (raw === undefined || raw.trim() === '') return fallback
const n = Number(raw)
return Number.isFinite(n) && n > 0 ? n : fallback
}
/** 签名者私钥落点(`DSHS_OVERLAY_SIGNER_KEY_FILE` 可覆盖)。 */
export function signerKeyFile(): string {
const v = process.env.DSHS_OVERLAY_SIGNER_KEY_FILE
return typeof v === 'string' && v.trim() !== '' ? v.trim() : '/etc/dshs/overlay-signer-key.pem'
}
/** relay 密钥表落点(与 `main.ts` 的 `DSHS_RELAY_KEYS_FILE` **同一个键**,⛔ 不另立)。 */
export function relayKeysFile(): string {
const v = process.env.DSHS_RELAY_KEYS_FILE
return typeof v === 'string' && v.trim() !== '' ? v.trim() : '/etc/dshs/relay-keys.json'
}
/** 设备台账落点(与 `overlay-nodes.ts` 的 `DSHS_OVERLAY_NODES_FILE` **同一个键**,⛔ 不另立)。 */
export function registryFile(): string {
const v = process.env.DSHS_OVERLAY_NODES_FILE
return typeof v === 'string' && v.trim() !== '' ? v.trim() : join(dataRootDir(), 'overlay', 'nodes.json')
}
/**
* 往 relay 密钥表里**增 / 覆盖一条**(⛔ 不重写别的条目)。
*
* 与 `scripts/overlay-relaykey-add.cjs` **同一套动作**(那份是运维小工具,本函数是产品路径):
* 先备份 → 只动目标键 → 临时文件 + `rename` 原子替换 → `0600` → **用产品自己的装载器自校验**。
*
* ⚠️ **刻意按"原始 JSON 表"改,而不是 `loadKeysFile` 后整体回写**:后者的键名是**归一化**
* 过的(`manager` → `ops/manager`)⇒ 回写会把现网那张表**整体改写一遍**(本线反复复发的
* "顺手改了不该改的"那类事故)。本函数**只碰它要碰的那一个键**。
*
* 🔴 **值没变 ⇒ 一次都不写**(序47 · S5 加):续签路径会**复用**已下发的那把密钥
* (平台不留副本,密钥只能从实例 home 读回)⇒ 若每次都无条件重写,`/etc/dshs/` 下会随
* "实例重启 / 定时续签"线性堆积 `*.bak-seq46s0-*` 备份,且白改一遍内容。
* 返回的 `changed` 即"这次有没有真的动这张表",供续签读数使用。
*
* ⛔ 不能用"值相同"去推断调用方意图:用户态端点每次都传**新随机密钥** ⇒ 恒 `changed:true`,
* 该路径行为与改动前**逐字相同**(exec棒1 的 33 条用例口径不变)。
*/
export function upsertRelayKey(
file: string,
name: string,
secret: string,
): { before: number; after: number; changed: boolean } {
const table: unknown = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {}
if (table === null || typeof table !== 'object' || Array.isArray(table)) {
throw new Error(`${file} 不是 JSON 对象(⛔ 不猜、不重建)`)
}
const obj = table as Record<string, unknown>
const before = Object.keys(obj).length
// 值已经就是这一把 ⇒ **不备份、不重写**(`after` 仍按装载器的口径现算,字段语义不变)。
if (keyEntryOf(obj[name] as string | undefined)?.secret === secret) {
return { before, after: loadKeysFile(file).size, changed: false }
}
// ⚠️ **只在文件已存在时备份** —— 全新机器上这张表可能还没有(⛔ `copyFileSync` 会 ENOENT 炸掉,
// 而"没有表"本身是合法起点,不是错误)。
if (existsSync(file)) copyFileSync(file, `${file}.bak-seq46s0-${Date.now()}`)
obj[name] = secret
const tmp = `${file}.tmp`
writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, { mode: 0o600 })
renameSync(tmp, file)
// 自校验:**用产品代码自己的装载器**读一遍(键名 / 密钥形状非法在这里就炸,不留给 relay 启动)。
const parsed = loadKeysFile(file)
if (!parsed.has(name)) throw new Error(`写入自校验失败:${name} 不在装载结果里`)
return { before, after: parsed.size, changed: true }
}
/** 签发只用到台账的这两条读写 —— 显式声明,让调用方提供的桩不必实现整个 `DbAdapter`。 */
export type DeviceGrantDb = Pick<DbAdapter, 'findOverlayDevice' | 'upsertOverlayDevice'>
export interface IssueDeviceGrantInput {
db: DeviceGrantDb
/** 归属用户(网名与 hostId 都从它推导,⛔ 请求体里没有 `network` 可填)。 */
userId: string
/** 节点**公钥**(64 hex,**已归一化**)。私钥永不出设备 —— 实例那条路是"平台生成后只写实例 home"。 */
nodeKey: string
/** 设备分组标签(可空串)。 */
group?: string
/** 设备来源(v13)。缺省 = `desktop`。⚠️ 只有**平台内部路径**才该传 `instance`。 */
kind?: OverlayDeviceKind
/**
* **复用**一把已经下发给这台设备的 relay HMAC 密钥(序47 · S5 续签路径)。
*
* 为什么需要它:平台对实例**不留密钥副本**(§④ 决策 6)⇒ 续签时那把密钥只能从
* 实例 home 的凭据文件里**读回**再原样用;若续签每次都换一把新的,就等于在
* "实例还没读回新文件"的窗口里**自己把自己拒掉**(`HELLO` 的 MAC 对不上),
* 且每次续签都白改一遍 relay 密钥表 + 多一个备份文件。
*
* ⛔ 省略 ⇒ 现场生成新密钥(**用户态端点**与**首次签发**都走这条)⇒ 该路径行为与
* 引入本字段之前**逐字相同**。
*/
hmacSecret?: string
}
/** 成功回执 —— 字段与用户态端点重构前**逐字相同**(步 5 的验收要求)。 */
export interface IssueDeviceGrantOk {
ok: true
network: string
hostId: string
/** `{doc, sig}` —— 与 `overlay-keyring.cjs` 的 `issue-grant` 同形,设备拿到可直接落盘。 */
grant: { doc: NodeGrant; sig: string }
/** ⚠️ **只在这一处出现**,⛔ 任何日志行都不打印它。 */
hmacSecret: string
expiresAt: string
ttlHours: number
renewals: number
devices: { used: number; max: number }
applied: {
registry: string
relayKeys: string
relayKeyCount: { before: number; after: number }
/** 这次**有没有真的动** relay 密钥表(续签复用同一把密钥 ⇒ `false`,见 `upsertRelayKey`)。 */
relayKeyRotated: boolean
}
ledger: {
table: 'overlay_devices'
network: string
hostId: string
kind: OverlayDeviceKind
status: string
leaseExpiresAt: string
grantIssuedAt: string
grantRenewals: number
}
note: string
}
/** 失败 —— `status` 是 HTTP 码,`body` 已具名(原因码 + detail),路由层原样回。 */
export interface IssueDeviceGrantErr {
ok: false
status: number
body: Record<string, unknown>
}
export type IssueDeviceGrantResult = IssueDeviceGrantOk | IssueDeviceGrantErr
function fail(status: number, body: Record<string, unknown>): IssueDeviceGrantErr {
return { ok: false, status, body }
}
/**
* 签一份 per-device 凭据并落到**四处**(grant 只作返回值 + registry + 台账 + relay 密钥表)。
*
* ⛔ 本函数**不校验** `nodeKey` 的形状(那是请求体校验的事)—— 调用方必须已经过
* `normalizePublicKey`(或由平台自己生成)。理由:把"输入校验"与"业务判据"混在一起,
* 实例路径就会**继承一份它并不需要**的 400 语义。
*/
export async function issueDeviceGrant(input: IssueDeviceGrantInput): Promise<IssueDeviceGrantResult> {
const { db, userId, nodeKey } = input
const group = (input.group ?? '').trim()
const kind = input.kind ?? OVERLAY_DEVICE_KIND_DEFAULT
// 网名 / hostId **只从 userId 推导**(纪律 ①:提权在结构上不可能)。
const network = tenantNetworkOf(userId)
const hostId = deviceHostIdOf(userId, nodeKey)
const at = new Date().toISOString()
/* ── ① 停发闸门:台账里被吊销过的设备 ⇒ **一律拒发** ────────────────────────
* 顺序上放在**配额之前**:被停发的设备连"占位"都不该再谈,报错也更贴近用户看到的现象
* ("我这台进不去"而不是"设备数超了")。
* ⚠️ 台账读不到 ⇒ **失败关闭**(具名 500):⛔ 不"读不到就当没被吊销" —— 那等于把吊销关掉。
*/
let existing
try {
existing = await db.findOverlayDevice(network, hostId)
} catch (err) {
return fail(500, {
error: 'device-ledger-unreadable',
detail: `设备台账不可读:${err instanceof Error ? err.message : String(err)}`,
})
}
if (existing?.status === 'revoked') {
return fail(403, {
error: 'device-revoked',
detail: `该设备已被停发(${network}/${hostId})⇒ 不再签发新凭据;已发出的凭据靠租约到期自然失效`,
network,
hostId,
})
}
/* ── ② 用户配额(§⑤ 步骤5:admin 不豁免)──────────────────────────────
* 判据 = 该用户网里**已批准**的设备数。⚠️ 同一 hostId 重签(重装 / 轮换密钥)**不占新位**。
*/
const regFile = registryFile()
let reg
try {
reg = loadRegistry(regFile)
} catch (err) {
return fail(500, { error: 'registry-unreadable', detail: err instanceof Error ? err.message : String(err) })
}
const maxDevices = envNum('DESKTOP_GRANT_MAX_DEVICES', DEVICE_GRANT_MAX_DEVICES_DEFAULT)
const approved = Object.values(reg.nodes).filter((n) => n.network === network && n.status === 'approved')
const alreadyKnown = approved.some((n) => n.hostId === hostId)
if (!alreadyKnown && approved.length >= maxDevices) {
return fail(429, {
error: 'device-quota-exceeded',
detail: `本用户名下已批准设备 ${approved.length} 台,上限 ${maxDevices}(键 DESKTOP_GRANT_MAX_DEVICES)`,
used: approved.length,
max: maxDevices,
})
}
/* ── ③ 签 grant(复用 identity.ts 的唯一实现,⛔ 不新造签名)──────────── */
const keyFile = signerKeyFile()
let signerPem: string
try {
signerPem = readFileSync(keyFile, 'utf8')
} catch (err) {
return fail(503, {
error: 'signer-key-unavailable',
detail: `签名者私钥不可读(${keyFile}):${err instanceof Error ? err.message : String(err)}`,
})
}
const ttlHours = envNum('DESKTOP_GRANT_TTL_HOURS', DEVICE_GRANT_TTL_HOURS_DEFAULT)
const expiresAt = new Date(Date.now() + ttlHours * 3_600_000).toISOString()
const grant: NodeGrant = { version: 1, network, hostId, nodeKey, issuedAt: at, expiresAt }
const grantSig = signNodeGrant(grant, signerPem)
/* ── ④ 设备注册表(registry JSON:写 approved ⇒ **配额判据**的来源)──────
* ⚠️ 与下面的 ⑤(PG 台账)**不是同一件事**,⛔ 别合并:
* · 本处(registry)= 「**哪些节点被批准进这张网**」—— `deriveDialers` 等既有通道读它;
* · ⑤(PG 台账)= 「**这台设备的凭据租约到什么时候 / 是否被停发**」—— registry 表达不了。
* 两者都由本函数写、都只有控制面写,故不构成"同一事实两处写"。
*/
try {
applyApplication(reg, { network, hostId, nodeKey, at, group })
approveNode(reg, network, hostId, at, group)
saveRegistry(regFile, reg)
} catch (err) {
return fail(500, { error: 'registry-write-failed', detail: err instanceof Error ? err.message : String(err) })
}
/* ── ⑤ 设备台账(PG `overlay_devices`:新建 or **续签**)─────────────────
* 🔴 放在 relay 密钥表**之前**:密钥表一写,这台设备就**真的能拨进来**了;台账写在它之前,
* 则"台账写失败"时还没有产生任何**功能性**后果(只有 registry 那条书证已落,重试即覆盖)。
* ⚠️ 续签(同一 hostId 再来一次)由 SQL 层 `grant_renewals + 1` 表达 ⇒ 本处**不判**
* "是不是续签"—— 判据只有一处(SQL),⛔ 免得两处口径分叉。
*/
let device
try {
device = await db.upsertOverlayDevice({
network,
hostId,
userId,
nodeKey,
leaseExpiresAt: Date.parse(expiresAt),
at: Date.parse(at),
kind,
})
} catch (err) {
return fail(500, {
error: 'device-ledger-write-failed',
detail: err instanceof Error ? err.message : String(err),
note: '设备台账没写成 ⇒ 未签发可用凭据(relay 密钥表尚未写)⇒ 该设备此刻拨不进来;重试本端点即可',
})
}
/* ── ⑥ relay 密钥表(⛔ 最前面那道门;热加载让它在下次 HELLO 生效)─────
* 续签路径会传**已经下发的那把**(`input.hmacSecret`)⇒ 值没变时本函数一次都不写
* (见 `upsertRelayKey` 的 `changed`),于是"续签"不会再制造 `*.bak-*` 堆积。
*/
const reuse = (input.hmacSecret ?? '').trim()
const secret = reuse !== '' ? reuse : randomBytes(32).toString('hex')
const keyName = logicalName(network, hostId)
let keyCounts: { before: number; after: number; changed: boolean }
try {
keyCounts = upsertRelayKey(relayKeysFile(), keyName, secret)
} catch (err) {
// 台账已写、密钥表没写 ⇒ 具名回滚不掉的那一半明说(⛔ 不假装"什么都没发生")。
return fail(500, {
error: 'relay-keys-write-failed',
detail: err instanceof Error ? err.message : String(err),
note: '设备台账已写好、relay 密钥表未写好 ⇒ 该设备此刻拨不进来(重试本端点即可,两处都会覆盖写)',
})
}
return {
ok: true,
network,
hostId,
grant: { doc: grant, sig: grantSig },
hmacSecret: secret,
expiresAt,
ttlHours,
renewals: device.grantRenewals,
devices: { used: alreadyKnown ? approved.length : approved.length + 1, max: maxDevices },
applied: {
registry: regFile,
relayKeys: relayKeysFile(),
relayKeyCount: { before: keyCounts.before, after: keyCounts.after },
relayKeyRotated: keyCounts.changed,
},
ledger: {
table: 'overlay_devices',
network: device.network,
hostId: device.hostId,
kind: device.kind ?? kind,
status: device.status,
leaseExpiresAt: new Date(device.leaseExpiresAt).toISOString(),
grantIssuedAt: new Date(device.grantIssuedAt).toISOString(),
grantRenewals: device.grantRenewals,
},
note: 'grant 与 hmacSecret 只在本响应里出现一次,⛔ 平台不留存;租约到期后 relay 会按 expiresAt 拒(identity-expired);到期前重调本端点即续签(renewals +1,不占新配额位)。',
}
}
+2 -2
View File
@@ -14,7 +14,7 @@
* @module dshs/net/relay
*/
export { RelayServer, RELAY_PATH, DEFAULT_RELAY_PORT, DEFAULT_AUTH_DEADLINE_MS, DEFAULT_AUTH_WINDOW_MS, DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_HB_SEC, DEFAULT_MAX_STREAMS_PER_PORT, DEFAULT_QUEUE_MAX_BYTES, DEFAULT_PRESENCE_GRACE_MS, DEFAULT_PRESENCE_OFFLINE_DEBOUNCE_MS, DEFAULT_PRESENCE_BATCH_MS, DEFAULT_PRESENCE_TTL_FACTOR, DEFAULT_PRESENCE_SUB_MAX } from './server.js'
export { RelayServer, RELAY_PATH, DEFAULT_RELAY_PORT, DEFAULT_AUTH_DEADLINE_MS, DEFAULT_AUTH_WINDOW_MS, DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_HB_SEC, DEFAULT_MAX_STREAMS_PER_PORT, DEFAULT_STREAM_RECLAIM_MS, DEFAULT_STREAM_RECLAIM_BATCH, DEFAULT_QUEUE_MAX_BYTES, DEFAULT_PRESENCE_GRACE_MS, DEFAULT_PRESENCE_OFFLINE_DEBOUNCE_MS, DEFAULT_PRESENCE_BATCH_MS, DEFAULT_PRESENCE_TTL_FACTOR, DEFAULT_PRESENCE_SUB_MAX } from './server.js'
export type { RelayServerOptions, RelayStatus, PresenceEntry } from './server.js'
export { RelayClient, describeClientStatus, gracefulBurstMsDefault, openedChannelFailedTerminally, waitUpOnStatus } from './client.js'
export type { RelayClientOptions, RelayClientStatus, RelayClientState, WebSocketLike, WebSocketCtor, WaitUpStatusOptions, RelayPresenceEntry, RelayPresenceStatus, RelayPresenceState } from './client.js'
@@ -33,7 +33,7 @@ export { MuxDuplex } from './duplex.js'
export type { MuxDuplexOptions } from './duplex.js'
export { MUX, WS_CLOSE, acceptWebSocket, encodeMux, decodeMux, encodeJsonFrame, parseJsonPayload, WsConnection } from './wire.js'
export type { MuxFrame, MuxType, WsServerOptions } from './wire.js'
export { OPS_NETWORK, NAME_SEP, assertNetworkId, assertSameNetwork, describeDialers, describeNetwork, isAllowedDialer, isHostId, isNetworkId, logicalName, networkKindOf, normalizeDialers, parseLogicalName, sameNetwork } from './network.js'
export { DIALER_WILDCARD, TENANT_NETWORK_WILDCARD, OPS_NETWORK, NAME_SEP, assertNetworkId, assertSameNetwork, describeDialers, describeNetwork, isAllowedDialer, isDialerBucketKey, isHostId, isNetworkId, logicalName, networkKindOf, normalizeDialers, parseLogicalName, sameNetwork } from './network.js'
export type { NetworkKind } from './network.js'
export { DIRECTORY_PATH, DIRECTORY_PAYLOAD_TAG, DIRECTORY_VERSION, DEFAULT_OVERLAY_SEED, buildDirectoryDocument, directoryPayload, directoryUrlFor, overlayEnvSeeds, overlayEnvTrustedKeys, parseDirectory, publicKeyFrom, publicRelayEntries, readCachedDirectory, resolveOverlayRelay, listOverlayRelayCandidates, signDirectory, toRelayUrl, verifyDirectory, writeCachedDirectory } from './directory.js'
export type { CachedDirectory, DirectoryVerdict, OverlayAddressSource, OverlayDirectory, OverlayRelayCandidates, OverlayRelayResolution, ResolveOverlayRelayOptions } from './directory.js'
+515
View File
@@ -0,0 +1,515 @@
/**
* **服务器实例的覆盖网络设备凭据**:平台侧代签 + 投递到实例 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 }
}
+21 -4
View File
@@ -102,11 +102,20 @@ function parseArgs(argv: readonly string[]): Args {
return args
}
function loadKeys(args: Args): RelayKeyMap {
/**
* 解析密钥来源。
*
* ⚠️ **序㊻ · 遗留2**:返回值额外带回 `file` —— **只有"密钥来自文件"时才允许热加载**。
* `DSHS_RELAY_KEYS`(内联)是**运行期不会再变的**入参,对它开热加载没有意义,
* 反而会让"配了内联"这件事被文件悄悄压掉(同一事实两处写 ⇒ 迟早分叉)。
*/
function loadKeys(args: Args): { keys: RelayKeyMap; file: string | undefined } {
if (process.env.DSHS_RELAY_KEYS !== undefined && process.env.DSHS_RELAY_KEYS.trim() !== '') {
return parseKeysInline(process.env.DSHS_RELAY_KEYS)
return { keys: parseKeysInline(process.env.DSHS_RELAY_KEYS), file: undefined }
}
if (args.keysFile !== undefined && args.keysFile !== '') {
return { keys: loadKeysFile(args.keysFile), file: args.keysFile }
}
if (args.keysFile !== undefined && args.keysFile !== '') return loadKeysFile(args.keysFile)
throw new Error('no relay keys: set --keys-file <path> or DSHS_RELAY_KEYS="<hostId>:<64hex>,…"')
}
@@ -170,7 +179,7 @@ async function waitUpOn(client: RelayClient, timeoutMs: number, log?: (line: str
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2))
const keys = loadKeys(args)
const { keys, file: keysFile } = loadKeys(args)
const log = args.quiet ? (): void => undefined : (line: string): void => {
process.stdout.write(`${line}\n`)
}
@@ -301,6 +310,8 @@ async function main(): Promise<void> {
const server = new RelayServer({
port: args.port,
keys,
// 序㊻ · 遗留2:密钥表热加载(`undefined` = 未启用,行为逐字回到改造前)。
keysFile,
instancePortBase: args.base,
instancePortSpan: args.span,
maxHosts: args.maxHosts,
@@ -313,6 +324,12 @@ async function main(): Promise<void> {
})
await server.start()
if (dialers.size > 0) log(`[relay] 拨号方白名单:${describeDialers(dialers).join(',')}`)
// 序㊻ · 遗留2:把"热加载到底开没开"写在启动日志里 —— 否则"新设备进不来"又要从头查一遍。
log(
keysFile === undefined
? `[relay] 密钥表:${keys.size} 条(**静态** —— 未启用热加载;新增设备需重启本单元)`
: `[relay] 密钥表:${keys.size} 条(**热加载已启用**:${keysFile})`,
)
log(
`[relay] 身份(序③):受信签名者 ${identity.trustedSignerKeys.length} 把,` +
`强制=${identity.requireIdentity ? 'on' : 'off'},吊销 hostId ${identity.revocations?.hosts.length ?? 0} 个`,
+210 -17
View File
@@ -42,6 +42,67 @@ const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/
/** 逻辑名的**规范分隔符**。`<network_id>/<hostId>` —— `network_id` 不含 `/`,故**首个** `/` 即分隔点。 */
export const NAME_SEP = '/'
/**
* **网级通配**(序㊻ · S2)—— 写在拨号方白名单的 **hostId 位**上,含义 = 「**这张网里的
* 每个 hostId 都是拨号方**」。
*
* ## 它解决的确切问题
* 设备(桌面客户端 / 手机)是**逐台出现的**:hostId 由 `d-<userId>-<pubkey前8位>` 现算,
* 控制面在签发凭据时才第一次见到它。若准入只能靠**逐设备枚举**(`u:<tid>/d-…` 一条一台),
* 那么"设备登录即入网"就退化成"**每来一台设备都要改一次 relay 配置 + 重启 relay**"。
* ⇒ 本常量把"**网级放行**"表达出来:配置里写 **一次** `u:<tid>/*`。
*
* ## ⛔ 它**不是**"放宽准入"(这是本设计最要紧的一点)
* 放行与否**分两道、且顺序固定**(`server.ts` 注册闸门):
* ① **能否进入这张网** —— 由 `DSHS_OVERLAY_REQUIRE_IDENTITY=1` + 受信签名者签发的
* **逐设备 grant** 决定(`verifyPeerGrant`,缺 grant ⇒ `identity-incomplete`);
* ② **进来之后算哪种身份** —— 由本白名单决定(拨号方 **必须不**声明端口)。
* ⇒ 通配只放宽了 **②(身份)**,**⛔ 一个字都没放宽 ①(准入)**:
* 没有有效 grant 的设备,连第 ① 道都过不去,永远走不到本常量生效的那一步。
*
* ## ⛔ 只准用在租户网(`u:<租户>`)
* `ops` 网里的是平台自己的机器(`manager` 拨号、`w-106` 声明端口)——整网通配会把
* `w-106` 这类**声明端口的节点**判成"拨号方不得声明端口"而**拒掉**(`dialer-must-not-declare-ports`)。
* ⇒ 非法用法(`ops/*`、`<命名网>/*`)**一律抛**(见 `parseDialerEntry`)——
* 本模块的一贯判据:配置写错要**在装载时就炸**,⛔ 不许退化成"谁也没匹配上"式的静默拒绝。
*
* ## 代价(**已知且有意**,写在这里免得后人当 bug)
* 通配 = 该网**整张网**都不允许有声明端口的节点(桶的语义就是"拨号方名单",见 `server.ts:1182`)。
* 与序㊻ §① 终态(**设备拨入**)自洽;将来若要"设备**也**能被连",那要另开机制,⛔ 不是放宽本常量。
*/
export const DIALER_WILDCARD = '*'
/**
* **租户网名通配**(序㊻ · 步骤6/遗留1)—— 写在拨号方白名单的 **network 位**上,含义 =
* 「**任何租户网**(`u:<任意租户>`)里的每个 hostId 都是拨号方」,配置里写**一条**即可:
* 网名位 `u:*` + hostId 位 `*`(两段合起来就是"所有租户网整网放行",形态与 `u:<租户>/*` 同族)。
*
* ## 它解决的确切问题(上一棒实测发现的那个「零介入」硬缺口)
* `u:<tid>/*` 只覆盖**已经知道**的那一张租户网 ⇒ **新注册用户**即使拿到有效 grant +
* 自己的 HMAC 密钥,仍会在**身份闸门**被拒(他的网不在白名单里 ⇒ 拿不到"拨号方"身份 ⇒
* `no-ports`)⇒ 要么每次新用户都手改 relay 配置(还要重启 relay),要么这条通道对
* 「用户登录即入网」根本不成立。
*
* 观察到的规律:**租户 id 是运行时才出现的**(`users.id` 在建号那一刻才生成),
* 所以"预先枚举租户网"这件事**在原理上做不到** —— 只能表达为"所有租户网"。
*
* ## ⛔ 它**不是**"放宽准入"(与 `DIALER_WILDCARD` 同一条纪律)
* 两道门、顺序固定(`server.ts` 注册闸门):① **能否进入这张网** —— `REQUIRE_IDENTITY=1`
* + 受信签名者签发的**逐设备 grant**(缺 ⇒ `identity-incomplete`);② **进来之后算哪种身份** ——
* 白名单。本常量**只放宽 ②**:没有有效 grant 的设备连 ① 都过不去。
*
* ## ⛔ 只对**租户网**生效,⛔ 不外溢
* - 只对 `networkKindOf(...) === 'tenant'` 的网兜底 ⇒ `ops` / 命名网**一字未放宽**;
* - 与精确桶是**取并集**(OR)语义 ⇒ ⛔ 不会把已写的 `ops:manager` / `u:<tid>/*` 压掉;
* - 非法用法(`ops:*`、命名网 `x:*`、以及"网名位通配 + 具体 hostId"这种半通配)**一律抛** ——
* 本模块的一贯判据:配置写错要**在装载时就炸**,⛔ 不许退化成静默拒绝。
*
* ## 代价(**已知且有意**,与 `DIALER_WILDCARD` 同源)
* 兜底生效 ⇒ **任何**租户网里都不允许有声明端口的节点。今天租户网里只有设备(`ports: []`),
* 与 §① 终态自洽;将来若要"设备**也**能被连",须另开机制(⛔ 不是放宽本常量)。
*/
export const TENANT_NETWORK_WILDCARD = 'u:*'
/**
* 网络 id 是否合法。三条分支:
* - `ops` —— 运维网(固定值);
@@ -58,6 +119,17 @@ export function isNetworkId(raw: string): boolean {
return TENANT_NET_RE.test(raw) || GENERIC_NET_RE.test(raw)
}
/**
* 拨号方白名单的**桶键**是否合法:合法网名 **或** {@link TENANT_NETWORK_WILDCARD}。
*
* ⛔ 这是**唯一**一个不属于"合法网名"的桶键例外 —— 不再给别的形态开口子(`u:5:*` 那类
* 半通配仍非法)。单列一个函数是为了让"桶键"这一个概念只有一处判据(本线反复吃过的病根:
* 同一事实两处写 ⇒ 两处迟早分叉)。
*/
export function isDialerBucketKey(raw: string): boolean {
return raw.trim() === TENANT_NETWORK_WILDCARD || isNetworkId(raw)
}
export function isHostId(raw: string): boolean {
return HOST_RE.test(raw)
}
@@ -148,18 +220,93 @@ export function parseLogicalName(raw: string): { network: string; hostId: string
function parseEntry(raw: string): { network: string; hostId: string; qualified: boolean } {
const v = raw.trim()
if (v === '') throw new Error('逻辑名为空')
const slash = v.indexOf(NAME_SEP)
if (slash > 0 && slash < v.length - 1) {
const network = assertNetworkId(v.slice(0, slash), `逻辑名 ${JSON.stringify(raw)} 里的网络段`)
return { network, hostId: assertHostId(v.slice(slash + 1), raw), qualified: true }
}
const colon = v.lastIndexOf(':')
if (colon > 0 && colon < v.length - 1) {
const network = assertNetworkId(v.slice(0, colon), `逻辑名 ${JSON.stringify(raw)} 里的网络段`)
return { network, hostId: assertHostId(v.slice(colon + 1), raw), qualified: true }
const { netRaw, hostRaw } = splitEntry(v)
if (netRaw !== undefined) {
const network = assertNetworkId(netRaw, `逻辑名 ${JSON.stringify(raw)} 里的网络段`)
return { network, hostId: assertHostId(hostRaw, raw), qualified: true }
}
// 旧形态:扁平 hostId(**不带网络**,由调用方决定它属于哪张网,默认运维网)。
return { network: OPS_NETWORK, hostId: assertHostId(v, raw), qualified: false }
return { network: OPS_NETWORK, hostId: assertHostId(hostRaw, raw), qualified: false }
}
/**
* 切分逻辑名的**那三条规则**(与 `parseEntry` 的文档一一对应)—— **只有这一份实现**。
*
* 为什么单拎出来:`parseDialerEntry` 要在"调 `assertHostId` 之前"先看一眼 hostId 段是不是
* `*`(`*` 过不了 `HOST_RE`)。若它自己再写一遍切分规则,就正好是本线反复吃过的病根 ——
* **同一事实两处写** ⇒ 两处迟早分叉(`u:5:*` 这种写法在其中一处会被切成别的形状)。
*
* ⛔ 返回值里 `netRaw === undefined` 专指**旧形态**(整条就是一个扁平 hostId),
* 与 `netRaw === ''` 不同;后者由调用方 `assertNetworkId` 判掉。
*/
function splitEntry(raw: string): { netRaw: string | undefined; hostRaw: string } {
const v = raw.trim()
const slash = v.indexOf(NAME_SEP)
if (slash > 0 && slash < v.length - 1) return { netRaw: v.slice(0, slash), hostRaw: v.slice(slash + 1) }
const colon = v.lastIndexOf(':')
if (colon > 0 && colon < v.length - 1) return { netRaw: v.slice(0, colon), hostRaw: v.slice(colon + 1) }
return { netRaw: undefined, hostRaw: v }
}
/**
* **拨号方白名单条目**的解析 —— 在 `parseEntry` 之上多认一种形态:**网级通配**(`DIALER_WILDCARD`)。
*
* | 写法 | 含义 |
* |---|---|
* | `u:5/d-1` · `u:5:d-1` | 该网里的**这一个** hostId 可拨(逐设备,序㊻ 步骤 1 的形态) |
* | **`u:5/*`** · **`u:5:*`** | 该网里的**每个** hostId 都可拨(网级放行,序㊻ 步骤 2) |
* | **`u:*` + `*`**(也接受冒号形态) | **任何租户网**的每个 hostId 都可拨(租户网全域兜底,序㊻ 步骤 6/遗留1) |
* | `*`(裸) | 旧形态 ⇒ 落在 `ops`;⚠️ 但 `ops` **不是**租户网 ⇒ **抛**(见下) |
*
* ⛔ **通配只准用在租户网**:通配会让"整张网都不许有声明端口的节点"成立,把它用在 `ops` / 命名网上
* 会把平台自己的 worker(`w-106` 这类,正是**要**声明端口的)一并拒掉 —— 那属于"配置写错却
* 长得像网络故障"。故此处**抛**(`normalizeDialers` 在 relay 启动时调用 ⇒ 配错即启动失败,
* 一眼看得见),⛔ 不静默忽略。
*
* ⛔ **半通配必抛**("网名位通配 + 具体 hostId"、`ops:*`、命名网 `x:*`)—— 它们要么自相矛盾(网名通配
* 却只放一个 hostId),要么越界(非租户网)。这类写法能在配置里存活 ⇒ 就是下一次"配错了却像
* 网络不通"的种子。
*
* ⚠️ `bucketNetwork` = 调用方(分桶形态)已经知道的那张网 —— 桶里写裸 `*` 时用它,与
* `parseEntry` 对"裸 hostId"的处理保持一致(见 `normalizeDialers` 的表)。
*/
function parseDialerEntry(
raw: string,
bucketNetwork?: string,
): { network: string; hostId: string; qualified: boolean } {
const { netRaw, hostRaw } = splitEntry(raw)
if (hostRaw !== DIALER_WILDCARD) {
// `u:*/d-1` 这类"网名通配 + 具体 hostId":网名段过不了 `assertNetworkId`(形如 `u:*`),
// 报错会落在"网络段非法"上 —— 意思对,但读不出"你这写的是半通配"。这里先具名点掉。
if (netRaw !== undefined && netRaw.trim() === TENANT_NETWORK_WILDCARD) {
throw new Error(
`拨号方白名单条目 ${JSON.stringify(raw)} 是**半通配**:网名位写了 ${TENANT_NETWORK_WILDCARD} ` +
`(= 任何租户网)却又指定了 hostId ${JSON.stringify(hostRaw)} ⇒ 自相矛盾。` +
`要么写 ${TENANT_NETWORK_WILDCARD}/*(全部租户网整网放行),要么写具体网名 u:<租户>/*`,
)
}
return parseEntry(raw)
}
// `u:*/*`:**租户网全域兜底**(唯一的网名通配形态,⛔ 不认别的)。
if (netRaw !== undefined && netRaw.trim() === TENANT_NETWORK_WILDCARD) {
return { network: TENANT_NETWORK_WILDCARD, hostId: DIALER_WILDCARD, qualified: true }
}
const network =
netRaw !== undefined
? assertNetworkId(netRaw, `拨号方白名单条目 ${JSON.stringify(raw)} 里的网络段`)
: (bucketNetwork ?? OPS_NETWORK)
// 桶键本身就是租户网名通配(分桶形态里写裸 `*`)⇒ 合法,与 `u:*/*` 等价。
if (network === TENANT_NETWORK_WILDCARD) {
return { network: TENANT_NETWORK_WILDCARD, hostId: DIALER_WILDCARD, qualified: netRaw !== undefined }
}
if (networkKindOf(network) !== 'tenant') {
throw new Error(
`网级通配 ${JSON.stringify(raw)} 只能用在租户网(u:<租户>):` +
`"${network}" 是 ${networkKindOf(network) ?? '非法'} 网 —— ` +
'整网放行会把该网里**声明端口的节点**一并判成 dialer-must-not-declare-ports 而拒掉',
)
}
return { network, hostId: DIALER_WILDCARD, qualified: netRaw !== undefined }
}
function assertHostId(raw: string, whole: string): string {
@@ -168,6 +315,18 @@ function assertHostId(raw: string, whole: string): string {
return v
}
/** 桶键**断言**({@link isDialerBucketKey} 的抛出版本)—— 与 `assertNetworkId` 同一条纪律:配错就炸。 */
function assertDialerBucketKey(raw: string): string {
const v = raw.trim()
if (!isDialerBucketKey(v)) {
throw new Error(
`拨号方白名单的桶键非法:${JSON.stringify(raw)}` +
`(合法形状:ops | u:<租户> | [a-z0-9][a-z0-9_.-]* | ${TENANT_NETWORK_WILDCARD}=任何租户网)`,
)
}
return v
}
/** 两个节点是否同网(DIAL 的**结构性**判据:不同网 ⇒ 连"能不能拨"这一步都走不到)。 */
export function sameNetwork(a: string, b: string): boolean {
return a === b
@@ -207,15 +366,23 @@ export function normalizeDialers(
if (input === undefined) return out
if (isDialerMap(input)) {
for (const [networkRaw, hosts] of input.entries()) {
const network = assertNetworkId(networkRaw, '拨号方白名单的网络段')
const network = assertDialerBucketKey(networkRaw)
const bucket = new Set<string>(out.get(network) ?? [])
for (const host of hosts ?? []) {
const e = parseEntry(host)
const e = parseDialerEntry(host, network)
if (e.qualified && e.network !== network) {
throw new Error(
`拨号方白名单自相矛盾:桶 "${network}" 里放了属于 "${e.network}" 的条目 ${JSON.stringify(host)}`,
)
}
// `u:*` 桶的**唯一合法内容**是 `*`:它表达"任何租户网整网放行",写别的(`manager`
// 之类)在语义上无从解释 —— 那种写法一旦静默存活,"配错了却像网络不通"就又回来了。
if (network === TENANT_NETWORK_WILDCARD && e.hostId !== DIALER_WILDCARD) {
throw new Error(
`拨号方白名单里 ${TENANT_NETWORK_WILDCARD} 桶只允许写 ${JSON.stringify(DIALER_WILDCARD)},` +
`实际写了 ${JSON.stringify(host)}(该桶表达的是"任何租户网整网放行",⛔ 不指定具体 hostId)`,
)
}
bucket.add(e.hostId)
}
out.set(network, bucket)
@@ -224,7 +391,7 @@ export function normalizeDialers(
}
// 扁平列表(R5 形态):逐条按自带网络**分发**到各自的桶;裸 hostId ⇒ 运维网。
for (const host of input) {
const e = parseEntry(host)
const e = parseDialerEntry(host)
const bucket = new Set<string>(out.get(e.network) ?? [])
bucket.add(e.hostId)
out.set(e.network, bucket)
@@ -243,9 +410,22 @@ export function normalizeDialers(
* 若直连模块**自己再写一份**,就正好撞上本线反复吃过的病根:**同一事实两处写** ⇒
* 两处有一天会分叉(`server.ts` 那两处是**冻结的只读面**,不许改也不许被复制)。
*
* ⇒ 本函数 = **该策略的唯一可复用出口**;`server.ts` 的内联写法与它**逐字等价**
* (同 map 、同 `=== true` 默认拒绝语义、同"按网络分桶")。等价性由
* `test/overlay-direct.test.mjs#T4` 做**机器守卫**(读 `server.ts` 源码核对那两处的原文形态)。
* ⇒ 本函数 = **该策略的唯一出口**。
*
* ## 序㊻ · S2 起:`server.ts` 改为**直接调用本函数**(不再各写一份内联形态)
* 在 S2 之前,`server.ts` 那两处是 `map.get(...)?.has(...) === true` 的**内联形态**,与本函数
* 「逐字等价」,靠 `test/overlay-direct.test.mjs#T4` **读源码核对原文**来防分叉 —— 那是
* "**两份实现 + 一份守卫**"。网级通配(`DIALER_WILDCARD`)要求这条策略**只有一处**能改:
* 若仍留内联形态,就得在两处内联里各补一次通配判断 ⇒ 恰好是本线反复吃过的病根。
* ⇒ 两处改为 `isAllowedDialer(this.dialers, …)`,**等价性从"逐字相同"升级为"同一份代码"**
* (不再可能分叉);T4 相应地改为核对「两处确实调用了本函数」+「⛔ 没有内联重写」。
*
* ## 序㊻ · 步骤 6 起:命中的**三种**情形(顺序无关,取并集)
* ① 该网桶里有**逐设备条目** ⇒ 命中;
* ② 该网桶里有网级通配 `*` ⇒ 命中(S2);
* ③ 该网**自己**没有可命中的条目、且它是**租户网**、且全局兜底桶 `u:*` 里有 `*` ⇒ 命中
* (`TENANT_NETWORK_WILDCARD`;这就是"新租户零介入"那一条)。
* ⚠️ ③ 是**取并集**(只增不减):已有的精确条目照旧生效,⛔ 不会被兜底"压掉"。
*
* ⚠️ 语义上 `network` 是**桶键**:拿别的网的桶去查同名 hostId 只会得到 `false`
* (这正是 P0-1「跨网结构性隔离」—— ⛔ 不是"策略允许/不允许"的区别)。
@@ -255,7 +435,20 @@ export function isAllowedDialer(
network: string,
hostId: string,
): boolean {
return map.get(network)?.has(hostId) === true
const bucket = map.get(network)
// 未列到的网络 ⇒ `undefined`(见下:只有"租户网 + 有全局兜底"才可能由 ③ 救回来)。
if (bucket !== undefined) {
// 网级通配:这张网整网放行(序㊻ · S2)。⚠️ 只放宽"身份",不放宽"准入"——见 DIALER_WILDCARD 文档。
if (bucket.has(DIALER_WILDCARD)) return true
if (bucket.has(hostId)) return true
}
// ③ 租户网全域兜底(序㊻ · 步骤 6/遗留1):新租户的网名**运行时才出现** ⇒ 只能这么表达。
// ⚠️ 只对租户网生效 ⇒ `ops` / 命名网一字未放宽(结构性,不是"我们记得过滤")。
if (networkKindOf(network) === 'tenant') {
if (map.get(TENANT_NETWORK_WILDCARD)?.has(DIALER_WILDCARD) === true) return true
}
// 默认拒绝(P0-1 的结构性隔离)。
return false
}
/**
+229 -10
View File
@@ -38,12 +38,13 @@
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { statSync } from 'node:fs'
import { createServer, type IncomingMessage, type Server as HttpServer } from 'node:http'
import { createServer as createTcpServer, type Server as TcpServer, type Socket } from 'node:net'
import type { Duplex } from 'node:stream'
import { MUX, WsConnection, WS_CLOSE, acceptWebSocket, decodeMux, encodeJsonFrame, encodeMux, parseJsonPayload, type MuxFrame } from './wire.js'
import { OPS_NETWORK, NAME_SEP, describeDialers, isNetworkId, logicalName, normalizeDialers, parseLogicalName } from './network.js'
import { lookupKey, type RelayKeyEntry } from './keys.js'
import { OPS_NETWORK, NAME_SEP, TENANT_NETWORK_WILDCARD, describeDialers, isAllowedDialer, isNetworkId, logicalName, normalizeDialers, parseLogicalName } from './network.js'
import { loadKeysFile, lookupKey, type RelayKeyEntry, type RelayKeyMap } from './keys.js'
import { normalizePublicKey, verifyPeerGrant, verifyProof, type RevocationList } from './identity.js'
/**
* 序㉖:**relay 侧的抖动观测**用与平台侧**同一份**统计(`absDeltas` / `statsFromDeltas`)
@@ -94,6 +95,25 @@ function envNum(key: string, dflt: number): number {
export const DEFAULT_SHUTDOWN_GRACE_MS = 300
/** 同一 (host, port) 的并发流上限。 */
export const DEFAULT_MAX_STREAMS_PER_PORT = 64
/**
* per-port 打满时的**兜底回收**阈值(ms):仅当额度**已经打满**时,才允许关掉
* 「存活超过该时长」的流,为后续请求腾出名额。
*
* 为什么要它(2026-09-21 事故 · 档案 146):`maxStreamsPerPort` 是**纯计数门限、
* 完全没有空闲回收** ⇒ 一旦被"调用方已死但无人收尾"的流占满,该端口就**永久** `busy`、
* 没有任何自愈路径(实测 45 min 不恢复,只能重启控制面;根因已在 `supervisor/proxy.ts`
* 修掉"客户端断开不中止上游",本项是**第二道防线**,防别的来源再犯)。
*
* 🔴 语义边界:这**不是**"这条流空闲所以该杀" —— dsh 的 SSE/WS 在页面静默时可能数分钟
* 无数据,按"空闲"判死会误杀正常长连接。这里的判据是「**额度已打满 ⇒ 系统已异常 ⇒
* 优先破局**」,取 10 min 只是为了保证"被牺牲的确实是最老的那批"。
* ⛔ 只在打满时被调用 ⇒ 正常路径**永不触发**。
*/
export const DEFAULT_STREAM_RECLAIM_MS = 600_000
/** 单次打满最多回收几条(防一次踢掉太多)。 */
export const DEFAULT_STREAM_RECLAIM_BATCH = 8
/** 单流缓冲上限(入向 open 竞态窗口 + 出向背压队列共用)。 */
export const DEFAULT_QUEUE_MAX_BYTES = 1 << 20
@@ -152,11 +172,29 @@ export interface RelayServerOptions {
port?: number
/** hostId → 预共享密钥(**hex**,每 worker 一个)。空 map ⇒ 所有 `HELLO` 被拒(默认拒绝)。 */
keys: ReadonlyMap<string, string | RelayKeyEntry>
/**
* **序㊻ · 遗留2:密钥表热加载** —— `keys` 的**来源文件**(给了它才启用热加载;不给 = 行为逐字回到改造前)。
*
* 为什么需要:`keys` 是**构造时定型**的,而设备凭据是**运行期**由控制面签发的 ⇒ 不重载就得
* 「加一台设备 ⇒ `restart relay`」,那正是序㊻ §① 终态(**人工零介入**)的真障碍。
*
* 语义刻意收窄到**只做一件事**:把**已经写进这个文件**的条目在**下一次 `HELLO`** 时生效。
* - ⛔ **不放宽任何判据** —— `REQUIRE_IDENTITY` / 逐设备 grant / revocations 一字不动;
* 热加载只决定「这把 HMAC 密钥算不算数」,**不是**"放行"。
* - ⛔ **不做"文件消失 ⇒ 清空表"**(那会**放大**拒绝面,等于给了个停机开关);读坏 / 读失败
* 一律**保留旧表**并记日志(失败关闭 = 保持现状,不静默换成一堆 `unknown-host`)。
* - 指纹 = `mtimeMs:size`;未变则**一次 `statSync`** 就返回(每次 `HELLO` 一次,`HELLO` 只在建连时发生)。
*/
keysFile?: string
/** 实例端口区间起点(worker 声明的端口必须 ≥ 它)。 */
instancePortBase: number
/** 实例端口区间宽度。 */
instancePortSpan: number
maxStreamsPerPort?: number
/** 打满时的兜底回收阈值(ms)。见 `DEFAULT_STREAM_RECLAIM_MS`。 */
streamReclaimMs?: number
/** 打满时的单次回收上限。见 `DEFAULT_STREAM_RECLAIM_BATCH`。 */
streamReclaimBatch?: number
queueMaxBytes?: number
authDeadlineMs?: number
authWindowMs?: number
@@ -298,6 +336,11 @@ interface MuxStream {
/** 对端写满,暂停向它写(出向背压)。 */
paused: boolean
closed: boolean
/**
* 建立时刻。**只被"打满时的兜底回收"用来挑最老的流**(见 `reclaimStale`),
* 不参与任何正常路径判定。
*/
openedAt: number
queued: Buffer[]
queuedBytes: number
/** 仅拨号流有。 */
@@ -512,6 +555,16 @@ export interface RelayStatus {
trustedSigners: number
/** 序③:当前吊销清单里的 hostId 数。 */
revokedHosts: number
/**
* **序㊻ · 遗留2:密钥表热加载判别器**(`keysFile` 未配 ⇒ 两者恒为 `0`)。
*
* 判据用法:控制面发了一台设备后,
* - `keyReloads` **不涨** ⇒ 文件没被读到(路径错 / 指纹没变)⇒ 新设备必然 `unknown-host`;
* - `keyReloadFails` **在涨** ⇒ 文件坏了或权限不对(旧表仍在用)⇒ 同上。
* ⇒ 把"新设备进不来"从"网络不通"里摘出来,是这一对计数存在的唯一理由。
*/
keyReloads: number
keyReloadFails: number
dropped: number
streamsOpened: number
protocolErrors: number
@@ -534,6 +587,11 @@ export interface RelayStatus {
*/
dial: number
dialDenied: number
/**
* 打满时被**兜底回收**的流累计数(档案 146)。正常路径**恒为 0**;
* 一旦非 0 ⇒ 曾出现"额度打满"并被强制破局 ⇒ 应回头查是否又有新的连接泄漏源。
*/
streamsReclaimed: number
dialFailed: number
/**
* **presence 判别器**(序⑲)—— 口径与 `dial*` 同一条纪律:**不许只写日志**。
@@ -606,11 +664,29 @@ export interface RelayStatus {
presenceTiming: { graceMs: number; offlineDebounceMs: number; batchMs: number; ttlMs: number; subMax: number }
}
/**
* 文件指纹(`mtimeMs:size`)—— **序㊻ · 遗留2 的变更判据**。
*
* ⚠️ 不读内容:稳态下每次 `HELLO` 只是一次 `stat`,与密钥表大小无关;
* 且"内容级比较"会把"改了又改回来"算成无变化,而那**未必**是我们想要的语义。
* 读不到(不存在 / 权限 / 原子替换窗口)⇒ `undefined` = **未知**,调用方据此决定"要重试"。
*/
function fileStampOf(file: string): string | undefined {
try {
const st = statSync(file)
return `${st.mtimeMs}:${st.size}`
} catch {
return undefined
}
}
export class RelayServer {
private readonly opts: RelayServerOptions
private readonly host: string
private readonly port: number
private readonly maxStreamsPerPort: number
private readonly streamReclaimMs: number
private readonly streamReclaimBatch: number
private readonly queueMaxBytes: number
private readonly authDeadlineMs: number
private readonly authWindowMs: number
@@ -634,6 +710,23 @@ export class RelayServer {
private readonly shutdownGraceMs: number
private readonly base: number
private readonly span: number
/**
* **运行期生效的密钥表**(`opts.keys` 的副本;`keysFile` 有变更时被整体替换,见 {@link refreshKeys})。
*
* ⚠️ 类型与 `opts.keys` **逐字同形**(`string | RelayKeyEntry`)—— 构造入参可能是 R5 时代的
* **扁平 `Map<hostId, hex>`**,而"裸 hostId 键回落"是 `lookupKey` 的职责;在这里归一化会
* **静默改掉现网语义**(把 `ops` 缺省写死成事实)。⇒ 本表只做"换一份 map",⛔ 不重构内容。
*
* ⚠️ 它不是"安全判据在运行期被改":判据**始终是文件内容**,热加载只是**不再要求进程重启才读**。
* 未配 `keysFile` ⇒ 本表在构造后**永不变化**(= 改造前语义)。
*/
private keyMap: ReadonlyMap<string, string | RelayKeyEntry>
/** 上次看到的 `keysFile` 指纹(`mtimeMs:size`)—— 初始化 = **启动那一刻**的基线;相等 ⇒ 不读盘。 */
private keysStamp: string | undefined
/** 热加载**成功**次数(判据:控制面发了一台设备后**它有没有涨**)。 */
private keyReloads = 0
/** 热加载**失败**次数(保留旧表;涨了 ⇒ 文件坏了 / 权限不对 ⇒ 新设备仍进不来)。 */
private keyReloadFails = 0
private http: HttpServer | undefined
private sweeper: NodeJS.Timeout | undefined
@@ -663,6 +756,8 @@ export class RelayServer {
/** 序⑤ 判别器:`DIAL` 放行 / 策略拒绝 / 目标不可达(口径见 `RelayStatus.counters`)。 */
private dial = 0
private dialDenied = 0
/** 打满时被兜底回收的流累计数(可观测;正常路径恒为 0)。 */
private streamsReclaimed = 0
private dialFailed = 0
/* ── presence(序⑲)── */
@@ -722,6 +817,9 @@ export class RelayServer {
this.host = opts.host ?? '127.0.0.1'
this.port = opts.port ?? DEFAULT_RELAY_PORT
this.maxStreamsPerPort = opts.maxStreamsPerPort ?? DEFAULT_MAX_STREAMS_PER_PORT
this.streamReclaimMs = opts.streamReclaimMs ?? envNum('RELAY_STREAM_RECLAIM_MS', DEFAULT_STREAM_RECLAIM_MS)
this.streamReclaimBatch =
opts.streamReclaimBatch ?? envNum('RELAY_STREAM_RECLAIM_BATCH', DEFAULT_STREAM_RECLAIM_BATCH)
this.queueMaxBytes = opts.queueMaxBytes ?? DEFAULT_QUEUE_MAX_BYTES
this.authDeadlineMs = opts.authDeadlineMs ?? DEFAULT_AUTH_DEADLINE_MS
this.authWindowMs = opts.authWindowMs ?? DEFAULT_AUTH_WINDOW_MS
@@ -733,6 +831,17 @@ export class RelayServer {
this.shutdownGraceMs = opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS
this.base = opts.instancePortBase
this.span = opts.instancePortSpan
// 序㊻ · 遗留2:密钥表先按**构造入参**定型(= 改造前语义);配了 `keysFile` 才在运行期跟随文件。
this.keyMap = opts.keys
/**
* 热加载基线 = **启动那一刻**的文件指纹。
*
* 🔴 不记这一笔的后果:首次 `HELLO` 会做一次"装载" ⇒ `keyReloads` 变成 `1`,
* 而**文件根本没被改过** ⇒ 判别器("发了一台设备后它涨没涨")从一开始就失真。
* ⚠️ stat 失败(文件还没建 / 原子替换窗口)⇒ 保持 `undefined`:文件**稍后出现**时
* 那一次装载是**真的**运行期变更,理应计数。
*/
this.keysStamp = opts.keysFile === undefined || opts.keysFile === '' ? undefined : fileStampOf(opts.keysFile)
this.trustedSignerKeys = opts.trustedSignerKeys ?? []
this.revocations = opts.revocations
this.requireIdentity = opts.requireIdentity ?? false
@@ -756,6 +865,53 @@ export class RelayServer {
;(this.opts.log ?? ((s: string) => process.stdout.write(`${s}\n`)))(`[relay] ${line}`)
}
/**
* **序㊻ · 遗留2:按需重载密钥表**(在下一次 `HELLO` 取密钥之前调用)。
*
* 判据链(**全部失败关闭**):
* 1. 未配 `keysFile` ⇒ **不动**(`keyMap` = 构造入参,行为逐字回到改造前);
* 2. `statSync` 指纹(`mtimeMs:size`)未变 ⇒ **不读盘**(稳态成本 = 一次 `stat`,与表大小无关);
* 3. 指纹变了 ⇒ `loadKeysFile` 重读;**成功**才整体替换(并记**新增/移除**条数,便于对账);
* 4. 读坏 / 文件不存在 / 权限不对 ⇒ **保留旧表** + `keyReloadFails++`(⛔ 不清空、⛔ 不抛 ——
* 抛会把"文件配错"升级成"relay 全拒",比"新设备暂时进不来"危险得多)。
*
* ⚠️ 指纹**只在读成功后才更新**:读失败时保持旧指纹 ⇒ 下一次 `HELLO` **还会重试**
* (否则"先删文件再写新文件"这种原子替换顺序在窗口内失败就**再也不会重试**)。
*/
private refreshKeys(): void {
const file = this.opts.keysFile
if (file === undefined || file === '') return
const stamp = fileStampOf(file)
// `undefined` = 文件此刻读不到(还没建 / 原子替换窗口)⇒ 无事可做;**下次 `HELLO` 还会再看**
// (文件"稍后出现"时那一次装载是真的运行期变更,理应计数)。
if (stamp === undefined) {
this.keysStamp = undefined
return
}
if (stamp === this.keysStamp) return
let next: RelayKeyMap
try {
next = loadKeysFile(file)
} catch (err) {
this.keyReloadFails += 1
// ⚠️ 指纹**不更新** ⇒ 下一次 `HELLO` 还会重试(否则"先写坏再写好"的窗口一过就**再也不重试**)。
this.keysStamp = undefined
this.log(`⛔ 密钥表热加载失败(保留原表 ${this.keyMap.size} 条):${err instanceof Error ? err.message : String(err)}`)
return
}
const before = new Set(this.keyMap.keys())
const added = [...next.keys()].filter((k) => !before.has(k))
const removed = [...before].filter((k) => !next.has(k))
this.keyMap = next
this.keysStamp = stamp
this.keyReloads += 1
this.log(
`🔄 密钥表热加载:${before.size} → ${next.size} 条` +
`(新增 ${added.length}${added.length > 0 ? `:${added.join(',')}` : ''}` +
`|移除 ${removed.length}${removed.length > 0 ? `:${removed.join(',')}` : ''})`,
)
}
async start(): Promise<void> {
const http = createServer((req, res) => {
if (req.url === '/status') {
@@ -910,7 +1066,12 @@ export class RelayServer {
bucket.push(session.hostId)
byNetwork.set(session.network, bucket)
}
for (const network of this.dialers.keys()) if (!byNetwork.has(network)) byNetwork.set(network, [])
// ⚠️ 桶键里的 `u:*`(租户网全域兜底,序㊻ 步骤6)**不是一张网** ⇒ ⛔ 不塞进 `networks`
//(否则管理面会看到一张名为 `u:*` 的幽灵网)。它在顶层 `dialers` 里以 `u:*/*` 照原样可见。
for (const network of this.dialers.keys()) {
if (network === TENANT_NETWORK_WILDCARD) continue
if (!byNetwork.has(network)) byNetwork.set(network, [])
}
return {
listening: `${this.host}:${this.port}${RELAY_PATH}`,
dialers: describeDialers(this.dialers),
@@ -948,12 +1109,20 @@ export class RelayServer {
/** 序⑤ 判别器:`DIAL` 放行 / 策略拒绝 / 目标不可达(脚本据此断言"拨号这条路通不通")。 */
dial: this.dial,
dialDenied: this.dialDenied,
/**
* 打满时被**兜底回收**的流累计数(档案 146)。正常路径**恒为 0**;
* 一旦非 0 ⇒ 说明曾出现"额度打满"并被强制破局 ⇒ 应回头查是否又有新的连接泄漏源。
*/
streamsReclaimed: this.streamsReclaimed,
dialFailed: this.dialFailed,
/** 序③:通过节点凭据校验的注册数 / 是否强制身份(`requireIdentity`)与受信签名者把数。 */
identityOk: this.identityOk,
identityRequired: this.requireIdentity,
trustedSigners: this.trustedSignerKeys.length,
revokedHosts: this.revocations?.hosts.length ?? 0,
/** 序㊻ · 遗留2:密钥表热加载判别器(`keysFile` 未配 ⇒ 恒 `0`)。 */
keyReloads: this.keyReloads,
keyReloadFails: this.keyReloadFails,
/**
* 序⑲ presence 判别器(口径见 `RelayStatus.counters` 的注释)。
* `subs` 是 gauge(连接断了必须回到 0);`pushed` 稳态必须**停住不走**。
@@ -1118,7 +1287,10 @@ export class RelayServer {
* 这一条补的正是「任何持有任意密钥的 host 都能进同一扁平命名空间」那个缺口
* (`交接单_网抽象与地址规划R6 §待办②`)。
*/
const entry = lookupKey(this.opts.keys, network, hostId)
// 序㊻ · 遗留2:取密钥前先让表跟上文件(未配 `keysFile` ⇒ 本行是**空操作**)。
// 位置刻意放在**闸门最前面**:热加载只决定"这把密钥算不算数",它之后的每一条判据都不动。
this.refreshKeys()
const entry = lookupKey(this.keyMap, network, hostId)
if (hostId === '' || entry === undefined || entry.secret === '') return deny('unknown-host')
if (entry.network !== network) return deny('network-mismatch')
const secret = entry.secret
@@ -1176,10 +1348,15 @@ export class RelayServer {
* - worker **必须**声明端口(`no-ports` 照旧拒绝),它注册即开回环监听;
* - 拨号方**必须不**声明端口(它是来"开流"的,不是来"被连"的)—— 两条都收紧,
* 免得一个身份同时拿到两种能力(最小权限)。
* 放行与否只看**服务端白名单在本网那一桶**(`dialers.get(network)`)—— 默认拒绝:
* 放行与否只看**服务端白名单在本网那一桶**—— 默认拒绝:
* 没列到的网络里,**一个 hostId 都拨不动**(P0-1 的"结构性隔离"就落在这里)。
*
* 序㊻ · S2:判定**改成调用唯一出口** `isAllowedDialer`(⛔ 不再内联 `.has(hostId)`)。
* 这样"网级通配"(`DIALER_WILDCARD`)只需改一处,而不会出现"两处内联漏改一处"的分叉。
* ⚠️ 这道门只决定**身份**(拨号方 vs 被连方);**能否进入本网**由上面那道身份闸门
* (`verifyPeerGrant`,⛔ 在本段之前就已执行)决定 —— 两道互不替代。
*/
const wantDialer = this.dialers.get(network)?.has(hostId) === true
const wantDialer = isAllowedDialer(this.dialers, network, hostId)
if (portsCsv === '' && !wantDialer) return deny('no-ports')
if (portsCsv !== '' && wantDialer) return deny('dialer-must-not-declare-ports')
const ports: number[] = []
@@ -1488,7 +1665,7 @@ export class RelayServer {
session.conn.sendBinary(encodeJsonFrame(MUX.DIAL_ACK, frame.streamId, { ok, ...extra }))
}
}
if (this.dialers.get(session.network)?.has(session.hostId) !== true) {
if (!isAllowedDialer(this.dialers, session.network, session.hostId)) {
this.refused += 1
this.dialDenied += 1
this.log(
@@ -1550,7 +1727,12 @@ export class RelayServer {
done(false, { error: 'target-offline' })
return
}
const live = worker.portStreams.get(port) ?? 0
let live = worker.portStreams.get(port) ?? 0
if (live >= this.maxStreamsPerPort) {
// 打满 ⇒ 先尝试**兜底回收**陈旧流,仍满才真正拒绝(2026-09-21 · 档案 146)。
this.reclaimStale(worker, port)
live = worker.portStreams.get(port) ?? 0
}
if (live >= this.maxStreamsPerPort) {
this.refused += 1
this.dialDenied += 1
@@ -1566,6 +1748,7 @@ export class RelayServer {
opening: true,
paused: false,
closed: false,
openedAt: Date.now(),
queued: [],
queuedBytes: 0,
dial: { session, streamId: frame.streamId, ready: false, pending: [], pendingBytes: 0 },
@@ -1784,7 +1967,12 @@ export class RelayServer {
tcp.destroy()
return
}
const live = session.portStreams.get(ep.port) ?? 0
let live = session.portStreams.get(ep.port) ?? 0
if (live >= this.maxStreamsPerPort) {
// 同 dial 路径:打满 ⇒ 先兜底回收,仍满才拒绝(2026-09-21 · 档案 146)。
this.reclaimStale(session, ep.port)
live = session.portStreams.get(ep.port) ?? 0
}
if (live >= this.maxStreamsPerPort) {
this.refused += 1
this.log(`refuse ${ep.hostId}:${ep.port} (busy, ${live} streams)`)
@@ -1793,7 +1981,7 @@ export class RelayServer {
}
const id = session.nextStreamId++
if (session.nextStreamId > 0xffffffff) session.nextStreamId = 1
const st: MuxStream = { id, port: ep.port, peer: tcp, opening: true, paused: false, closed: false, queued: [], queuedBytes: 0 }
const st: MuxStream = { id, port: ep.port, peer: tcp, opening: true, paused: false, closed: false, openedAt: Date.now(), queued: [], queuedBytes: 0 }
session.streams.set(id, st)
session.portStreams.set(ep.port, live + 1)
this.streamsOpened += 1
@@ -1836,6 +2024,37 @@ export class RelayServer {
}
}
/**
* per-port 额度**打满时的兜底回收**(2026-09-21 · 档案 146)。
*
* 只做一件事:把该端口上**存活超过 `streamReclaimMs`** 的流按"最老优先"关掉,最多
* `streamReclaimBatch` 条,给正在等待的请求腾名额。
*
* 🔴 只在**已经打满**时被调用(两条分配路径各自在 `busy` 之前调它)⇒ 正常路径永不触发。
* 🔴 ⛔ 不要把它理解成"空闲回收":本判据用**建立时刻**而非"最后活动时刻" —— 后者要改
* 数据转发路径(风险大),且 dsh 的 SSE/WS 在页面静默时本就长时间无数据,按"空闲"判死
* 会误杀正常长连接。这里的语义是「**额度已满 ⇒ 系统已异常 ⇒ 优先破局**」。
* ⚠️ `openedAt` 缺失(理论上不该有)按"不过期"处理 —— 宁可少杀,不可误杀。
*/
private reclaimStale(session: Session, port: number): void {
const cut = Date.now() - this.streamReclaimMs
// 先收齐再动手:`closeStream` 会回调 `forgetStream` 改动 `session.streams`,遍历中删会漏项。
const victims: MuxStream[] = []
for (const st of session.streams.values()) {
if (st.closed || st.port !== port) continue
if (st.openedAt === undefined || st.openedAt > cut) continue
victims.push(st)
if (victims.length >= this.streamReclaimBatch) break
}
if (victims.length === 0) return
this.streamsReclaimed += victims.length
this.log(
`per-port ${port} 打满 ⇒ 兜底回收 ${victims.length} 条陈旧流` +
`(${logicalName(session.network, session.hostId)},阈值 ${this.streamReclaimMs}ms)`,
)
for (const st of victims) this.closeStream(session, st, 'reclaimed (stale, per-port full)')
}
private closeStream(session: Session, st: MuxStream, why: string): void {
if (st.closed) return
st.closed = true