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/;交接单不入库(政策)。
328 lines
16 KiB
JavaScript
328 lines
16 KiB
JavaScript
/**
|
||
* 覆盖网络 · **序47 S4/S5:服务器实例的设备凭据**(平台侧代签 + 投递 + 续签)单测。
|
||
*
|
||
* ## 这个文件要回答的六个问题
|
||
* 1. **首签**:凭据文件落在**实例 home**(`<dataRoot>/users/<id>/home/.overlay-device.json`)、
|
||
* `0600`、台账出 `kind='instance'` 条目、relay 密钥表新增该 hostId、registry 已批准,
|
||
* 且 grant 能被 `identity.ts` 的**验签路径**验过(⛔ 不是"写了个文件"就算过)。
|
||
* 2. **幂等**:租约充裕时再调 ⇒ `unchanged`,**一个字节都不写**(台账 `grant_renewals` 不动、
|
||
* 密钥表不出现新的 `*.bak-*`)。这条钉住"每次实例启动都续签一遍"的代价是 0。
|
||
* 3. **续签**:租约将尽 ⇒ 同 `hostId`、**同 hmacSecret**(⛔ 不轮换 ⇒ 不会把还在用旧文件的实例
|
||
* 自己拒掉),`grant_renewals + 1`,且 relay 密钥表**未被重写**(`relayKeyRotated=false`)。
|
||
* 4. **⛔ 不猜不重建**:文件形状非法 ⇒ 具名 `credential-file-corrupt`,**文件逐字节保持原样**、
|
||
* 台账不新增(重建会换 hostId ⇒ 绕过停发并多占配额位,见模块头注纪律 ③)。
|
||
* 5. **停发即拒**:台账 `status='revoked'` ⇒ `device-revoked`(403),文件保持原样。
|
||
* 6. **收编(纪律 ⑤)**:新签发改变 hostId 时,该用户**其它** `kind='instance'` 条目被置
|
||
* `revoked` 并从 registry 移除 ⇒ "删文件重建"这条处置不会静默留下孤儿设备 / 烧配额。
|
||
*
|
||
* 运行:`node --test test/instance-credential.test.mjs`(Node ≥ 22;测 `lib/` 产物,先 `npm run build`)。
|
||
*
|
||
* @module test/instance-credential
|
||
*/
|
||
|
||
import assert from 'node:assert/strict'
|
||
import { existsSync, mkdtempSync, readdirSync, readFileSync, statSync, writeFileSync, rmSync } from 'node:fs'
|
||
import { tmpdir } from 'node:os'
|
||
import { join } from 'node:path'
|
||
import { test } from 'node:test'
|
||
|
||
import { LocalUserFs } from '../lib/fs/local-user-fs.js'
|
||
import {
|
||
ensureInstanceCredential,
|
||
parseInstanceCredential,
|
||
startInstanceCredentialRenewal,
|
||
INSTANCE_DEVICE_FILE,
|
||
} from '../lib/net/relay/instance-credential.js'
|
||
import { generateNodeKey, verifyPeerGrant } from '../lib/net/relay/identity.js'
|
||
import { loadKeysFile } from '../lib/net/relay/keys.js'
|
||
import { loadRegistry } from '../lib/net/relay/registry.js'
|
||
|
||
const USER = 'u47'
|
||
|
||
/* ─────────────────────────── 工具 ─────────────────────────── */
|
||
|
||
/**
|
||
* 设备台账(v12/v13 `overlay_devices`)**内存桩** —— 只复刻被判据依赖的四条语义:
|
||
* ① 首次插入 `grantRenewals = 1`,之后 **+1**;② `status` 不被续签覆盖(停发粘性);
|
||
* ③ `kind` 缺省 `desktop`;④ `setOverlayDeviceStatus` 对不存在的行回 `false`。
|
||
*/
|
||
function ledgerStub(seed = []) {
|
||
const rows = new Map()
|
||
for (const r of seed) rows.set(`${r.network}/${r.hostId}`, { kind: 'desktop', status: 'active', grantRenewals: 0, ...r })
|
||
return {
|
||
rows,
|
||
async findOverlayDevice(network, hostId) {
|
||
return rows.get(`${network}/${hostId}`)
|
||
},
|
||
async upsertOverlayDevice(input) {
|
||
const key = `${input.network}/${input.hostId}`
|
||
const at = input.at ?? Date.now()
|
||
const prev = rows.get(key)
|
||
const row = {
|
||
network: input.network,
|
||
hostId: input.hostId,
|
||
userId: input.userId,
|
||
nodeKey: input.nodeKey,
|
||
kind: input.kind ?? 'desktop',
|
||
status: prev?.status ?? 'active',
|
||
leaseExpiresAt: input.leaseExpiresAt,
|
||
grantIssuedAt: at,
|
||
grantRenewals: (prev?.grantRenewals ?? 0) + 1,
|
||
}
|
||
rows.set(key, row)
|
||
return row
|
||
},
|
||
async listOverlayDevices(network) {
|
||
return [...rows.values()].filter((r) => network === undefined || r.network === network)
|
||
},
|
||
async setOverlayDeviceStatus(network, hostId, status) {
|
||
const key = `${network}/${hostId}`
|
||
const prev = rows.get(key)
|
||
if (prev === undefined) return false
|
||
rows.set(key, { ...prev, status })
|
||
return true
|
||
},
|
||
}
|
||
}
|
||
|
||
/** 一组临时环境:签名者私钥 + 密钥表 + registry + 真 `LocalUserFs`(要验 0600 / 落点)。 */
|
||
function makeEnv(t, extraEnv = {}) {
|
||
const dir = mkdtempSync(join(tmpdir(), 'dshs-seq47-inst-'))
|
||
t.after(() => rmSync(dir, { recursive: true, force: true }))
|
||
const dataRoot = join(dir, 'data')
|
||
const signer = generateNodeKey()
|
||
const signerKeyFile = join(dir, 'signer-key.pem')
|
||
writeFileSync(signerKeyFile, signer.privateKeyPem, { mode: 0o600 })
|
||
const relayKeysFile = join(dir, 'relay-keys.json')
|
||
writeFileSync(relayKeysFile, '{}\n', { mode: 0o600 })
|
||
const vars = {
|
||
DSHS_OVERLAY_SIGNER_KEY_FILE: signerKeyFile,
|
||
DSHS_RELAY_KEYS_FILE: relayKeysFile,
|
||
DSHS_OVERLAY_NODES_FILE: join(dir, 'nodes.json'),
|
||
DESKTOP_GRANT_TTL_HOURS: '24',
|
||
DESKTOP_GRANT_MAX_DEVICES: '10',
|
||
...extraEnv,
|
||
}
|
||
for (const [k, v] of Object.entries(vars)) process.env[k] = v
|
||
const userFs = new LocalUserFs((id) => join(dataRoot, 'users', id))
|
||
const credFile = join(dataRoot, 'users', USER, 'home', INSTANCE_DEVICE_FILE)
|
||
return { dir, dataRoot, signer, signerKeyFile, relayKeysFile, userFs, credFile, vars }
|
||
}
|
||
|
||
/** 台账里的实例条目(`kind='instance'`)。 */
|
||
function instanceRows(db) {
|
||
return [...db.rows.values()].filter((r) => r.kind === 'instance')
|
||
}
|
||
|
||
function bakCount(dir) {
|
||
return readdirSync(dir).filter((f) => f.startsWith('relay-keys.json.bak-')).length
|
||
}
|
||
|
||
/* ─────────────────────────── 用例 ─────────────────────────── */
|
||
|
||
test('S4 首签:凭据落实例 home(0600)+ 台账 kind=instance + 密钥表 + registry + grant 可验签', async (t) => {
|
||
const env = makeEnv(t)
|
||
const db = ledgerStub()
|
||
|
||
const res = await ensureInstanceCredential({ db, userFs: env.userFs }, USER, { reason: 'test' })
|
||
assert.equal(res.ok, true, JSON.stringify(res))
|
||
assert.equal(res.action, 'issued')
|
||
assert.equal(res.network, `u:${USER}`)
|
||
assert.equal(res.renewals, 1)
|
||
|
||
// ① 文件落在**实例 home**(不是 ws、不是平台目录),权限 0600,内容可解析
|
||
assert.ok(existsSync(env.credFile), `凭据文件应存在:${env.credFile}`)
|
||
const text = readFileSync(env.credFile, 'utf8')
|
||
const cred = parseInstanceCredential(text)
|
||
assert.notEqual(cred, undefined, '凭据文件应能被严格解析')
|
||
assert.equal(cred.hostId, res.hostId)
|
||
assert.equal(cred.kind, 'instance')
|
||
assert.equal(cred.version, 1)
|
||
if (process.platform !== 'win32') {
|
||
assert.equal(statSync(env.credFile).mode & 0o777, 0o600, '凭据必须 0600')
|
||
}
|
||
|
||
// ② hostId 口径 = `d-<userId>-<公钥前 8 位>`(§④ 已定规则,与用户态端点同源)
|
||
assert.equal(cred.hostId, `d-${USER}-${cred.nodeKey.slice(0, 8)}`)
|
||
assert.equal(cred.nodeKey.length, 64)
|
||
|
||
// ③ 台账:kind='instance' + active + 租约与回执一致
|
||
const rows = instanceRows(db)
|
||
assert.equal(rows.length, 1)
|
||
assert.equal(rows[0].status, 'active')
|
||
assert.equal(rows[0].nodeKey, cred.nodeKey)
|
||
assert.equal(new Date(rows[0].leaseExpiresAt).toISOString(), res.expiresAt)
|
||
|
||
// ④ relay 密钥表:新增该 hostId(否则实例的 HELLO 永远对不上 MAC)
|
||
const keys = loadKeysFile(env.relayKeysFile)
|
||
assert.ok(
|
||
[...keys.keys()].some((k) => k === `${res.network}/${res.hostId}`),
|
||
`密钥表应含 ${res.network}/${res.hostId},实得 ${[...keys.keys()].join(',')}`,
|
||
)
|
||
|
||
// ⑤ registry:已批准(⇒ 立刻进入该网的派生白名单)
|
||
const reg = loadRegistry(env.vars.DSHS_OVERLAY_NODES_FILE)
|
||
const node = reg.nodes[`${res.network}/${res.hostId}`]
|
||
assert.equal(node?.status, 'approved')
|
||
|
||
// ⑥ grant 能被**产品自己的验签路径**验过(network / hostId / nodeKey 三者都要对得上)
|
||
const verdict = verifyPeerGrant(cred.grant.doc, cred.grant.sig, {
|
||
trustedSignerKeys: [env.signer.publicKey],
|
||
network: res.network,
|
||
hostId: res.hostId,
|
||
nodeKey: cred.nodeKey,
|
||
})
|
||
assert.equal(verdict.ok, true, `grant 应验签通过,实得 ${JSON.stringify(verdict)}`)
|
||
|
||
// ⑦ ⛔ 回执 / 回执的序列化里不得出现密钥材料(本模块的返回值会进日志)
|
||
const dumped = JSON.stringify(res)
|
||
assert.ok(!dumped.includes('PRIVATE KEY'), '回执不得含私钥')
|
||
assert.ok(!dumped.includes(cred.hmacSecret), '回执不得含 hmacSecret')
|
||
})
|
||
|
||
test('S5 幂等:租约充裕 ⇒ unchanged,且一个字节都不写', async (t) => {
|
||
const env = makeEnv(t)
|
||
const db = ledgerStub()
|
||
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
|
||
assert.equal(first.action, 'issued')
|
||
const textBefore = readFileSync(env.credFile, 'utf8')
|
||
const baksBefore = bakCount(env.dir)
|
||
|
||
const again = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
|
||
assert.equal(again.ok, true)
|
||
assert.equal(again.action, 'unchanged')
|
||
assert.equal(again.hostId, first.hostId)
|
||
assert.equal(again.renewals, 1, '未续签 ⇒ grant_renewals 不应增长')
|
||
assert.equal(readFileSync(env.credFile, 'utf8'), textBefore, 'unchanged 不得改写文件')
|
||
assert.equal(bakCount(env.dir), baksBefore, 'unchanged 不得产生密钥表备份')
|
||
assert.equal(instanceRows(db).length, 1)
|
||
})
|
||
|
||
test('S5 续签:同 hostId + 同 hmacSecret,grant_renewals +1,密钥表不重写', async (t) => {
|
||
const env = makeEnv(t)
|
||
const db = ledgerStub()
|
||
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
|
||
const credBefore = parseInstanceCredential(readFileSync(env.credFile, 'utf8'))
|
||
const baksBefore = bakCount(env.dir)
|
||
|
||
const renewed = await ensureInstanceCredential({ db, userFs: env.userFs }, USER, { force: true, reason: 'test-force' })
|
||
assert.equal(renewed.ok, true, JSON.stringify(renewed))
|
||
assert.equal(renewed.action, 'renewed')
|
||
assert.equal(renewed.hostId, first.hostId, '续签必须同 hostId(⛔ 不换设备)')
|
||
assert.equal(renewed.renewals, 2, 'grant_renewals 应为 2')
|
||
assert.equal(renewed.relayKeyRotated, false, '复用同一把密钥 ⇒ 密钥表不应被重写')
|
||
|
||
const credAfter = parseInstanceCredential(readFileSync(env.credFile, 'utf8'))
|
||
assert.equal(credAfter.nodeKey, credBefore.nodeKey)
|
||
assert.equal(credAfter.nodePrivateKeyPem, credBefore.nodePrivateKeyPem)
|
||
assert.equal(credAfter.hmacSecret, credBefore.hmacSecret, '⛔ 不轮换 hmacSecret(否则旧进程被自己拒)')
|
||
assert.equal(bakCount(env.dir), baksBefore, '密钥表值未变 ⇒ 不得产生备份文件')
|
||
assert.equal(instanceRows(db).length, 1, '⛔ 不得出现第二条实例设备')
|
||
assert.equal(loadKeysFile(env.relayKeysFile).size, 1)
|
||
})
|
||
|
||
test('S5 定时续签:只扫 kind=instance 且 active 的租户网,桌面条目不动', async (t) => {
|
||
const env = makeEnv(t)
|
||
const stale = new Date(Date.now() - 60_000).toISOString()
|
||
const db = ledgerStub([
|
||
{ network: `u:${USER}`, hostId: `d-${USER}-aaaaaaaa`, userId: USER, nodeKey: 'a'.repeat(64), kind: 'instance', status: 'active', leaseExpiresAt: 0, grantRenewals: 3 },
|
||
{ network: 'u:desktopguy', hostId: 'd-desktopguy-bbbbbbbb', userId: 'desktopguy', nodeKey: 'b'.repeat(64), kind: 'desktop', status: 'active', leaseExpiresAt: 0, grantRenewals: 1 },
|
||
{ network: 'u:revoked', hostId: 'd-revoked-cccccccc', userId: 'revoked', nodeKey: 'c'.repeat(64), kind: 'instance', status: 'revoked', leaseExpiresAt: 0, grantRenewals: 1 },
|
||
{ network: 'ops', hostId: 'manager', userId: 'x', nodeKey: 'd'.repeat(64), kind: 'instance', status: 'active', leaseExpiresAt: 0, grantRenewals: 1 },
|
||
])
|
||
// 给 USER 铺一份"租约将尽"的真凭据(先签发,再把文件里的 expiresAt 改旧)
|
||
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
|
||
assert.equal(first.ok, true, JSON.stringify(first))
|
||
const cred = parseInstanceCredential(readFileSync(env.credFile, 'utf8'))
|
||
writeFileSync(env.credFile, JSON.stringify({ ...cred, expiresAt: stale }, null, 2) + '\n')
|
||
|
||
const handle = startInstanceCredentialRenewal({ db, userFs: env.userFs }, { intervalMs: 0 })
|
||
assert.equal(handle.armed, false, 'intervalMs=0 ⇒ 只留手动一遍')
|
||
const report = await handle.runOnce()
|
||
handle.stop()
|
||
|
||
assert.equal(report.scanned, 1, `只该扫到 u:${USER}(桌面 / 已停发 / 非租户网都不动),实得 ${report.scanned}`)
|
||
assert.equal(report.renewed, 1)
|
||
assert.equal(report.issued, 0)
|
||
assert.deepEqual(report.failed, [])
|
||
const after = parseInstanceCredential(readFileSync(env.credFile, 'utf8'))
|
||
assert.equal(after.hostId, cred.hostId)
|
||
assert.equal(Date.parse(after.expiresAt) > Date.now(), true, '续签后租约应重新变新')
|
||
// 台账里那些非 instance 的条目一个都没动
|
||
const desktopRow = [...db.rows.values()].find((r) => r.hostId === 'd-desktopguy-bbbbbbbb')
|
||
assert.equal(desktopRow.grantRenewals, 1)
|
||
})
|
||
|
||
test('⛔ 不猜不重建:文件形状非法 ⇒ 具名拒绝且文件逐字节保持原样', async (t) => {
|
||
const env = makeEnv(t)
|
||
const db = ledgerStub()
|
||
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
|
||
assert.equal(first.action, 'issued')
|
||
const rowsBefore = db.rows.size
|
||
|
||
for (const bad of ['{not json', '{}', JSON.stringify({ version: 1, kind: 'instance' })]) {
|
||
writeFileSync(env.credFile, bad)
|
||
const res = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
|
||
assert.equal(res.ok, false, `应拒绝:${bad.slice(0, 24)}`)
|
||
assert.equal(res.reason, 'credential-file-corrupt')
|
||
assert.equal(readFileSync(env.credFile, 'utf8'), bad, '⛔ 拒绝路径不得改写文件')
|
||
assert.equal(db.rows.size, rowsBefore, '⛔ 拒绝路径不得新增台账条目')
|
||
}
|
||
|
||
// 公私钥不成对(改成另一把私钥)同样按 corrupt 处理 —— 否则故障会推迟到拨号时表现为"网络问题"
|
||
const cred = parseInstanceCredential(
|
||
JSON.stringify({
|
||
version: 1,
|
||
kind: 'instance',
|
||
network: `u:${USER}`,
|
||
hostId: `d-${USER}-00000000`,
|
||
nodeKey: 'e'.repeat(64),
|
||
nodePrivateKeyPem: generateNodeKey().privateKeyPem,
|
||
hmacSecret: 'f'.repeat(64),
|
||
grant: { doc: { v: 1 }, sig: 'x' },
|
||
issuedAt: new Date().toISOString(),
|
||
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
||
renewals: 1,
|
||
}),
|
||
)
|
||
assert.equal(cred, undefined, '公私钥不成对应判为不可用')
|
||
})
|
||
|
||
test('停发即拒:台账 revoked ⇒ device-revoked(403),文件保持原样', async (t) => {
|
||
const env = makeEnv(t)
|
||
const db = ledgerStub()
|
||
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
|
||
const textBefore = readFileSync(env.credFile, 'utf8')
|
||
await db.setOverlayDeviceStatus(`u:${USER}`, first.hostId, 'revoked')
|
||
|
||
const res = await ensureInstanceCredential({ db, userFs: env.userFs }, USER, { force: true })
|
||
assert.equal(res.ok, false)
|
||
assert.equal(res.reason, 'device-revoked')
|
||
assert.equal(res.status, 403)
|
||
assert.equal(readFileSync(env.credFile, 'utf8'), textBefore, '被停发的设备不得拿到新凭据')
|
||
})
|
||
|
||
test('纪律 ⑤ 收编:新签发换 hostId ⇒ 旧的实例条目被置 revoked 并退出 registry', async (t) => {
|
||
const env = makeEnv(t)
|
||
const db = ledgerStub()
|
||
const first = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
|
||
assert.equal(first.action, 'issued')
|
||
|
||
// 模拟"凭据文件丢了"(我们自己写进处置流程的那条动作)⇒ 下一次 ensure 会重新签发
|
||
rmSync(env.credFile)
|
||
const again = await ensureInstanceCredential({ db, userFs: env.userFs }, USER)
|
||
|
||
assert.equal(again.ok, true, JSON.stringify(again))
|
||
assert.equal(again.action, 'issued')
|
||
assert.notEqual(again.hostId, first.hostId, '重建必然换 hostId(平台不留私钥副本)')
|
||
assert.deepEqual(again.superseded, [first.hostId], '旧条目应被点名收编')
|
||
|
||
const old = db.rows.get(`u:${USER}/${first.hostId}`)
|
||
assert.equal(old.status, 'revoked', '旧条目应被置 revoked')
|
||
assert.equal(old.kind, 'instance')
|
||
const reg = loadRegistry(env.vars.DSHS_OVERLAY_NODES_FILE)
|
||
assert.equal(reg.nodes[`u:${USER}/${first.hostId}`], undefined, '旧节点应已退出 registry 白名单')
|
||
assert.equal(reg.nodes[`u:${USER}/${again.hostId}`]?.status, 'approved')
|
||
assert.equal(instanceRows(db).length, 2, '台账保留旧行为审计痕迹(⛔ 不删行)')
|
||
})
|