feat(worker): D2 建号自动化 ensureOsAccount + D1 节点自举脚本 + 5 例测试

This commit is contained in:
admin committed 2026-09-16 11:28:50 +08:00
1 parent 500011d329
commit c8b514832f
4 files changed
+286 -1

No files matched your search

+63
View File
@@ -18,7 +18,9 @@
*
* @module dshs/worker/agent
*/
import { execFileSync } from 'node:child_process'
import { timingSafeEqual } from 'node:crypto'
import { existsSync } from 'node:fs'
import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify'
import type { ServerConfig } from '../config.js'
import { LocalUserFs } from '../fs/local-user-fs.js'
@@ -61,6 +63,57 @@ const OP_CACHE_MAX = 512
/** patch 内容长度上限(防把 agent 当大对象存储)。 */
const MAX_PATCH_BYTES = 256 * 1024
/** 按 uid 查 passwd 条目(查不到返回 undefined)。 */
function passwdEntryForUid(uid: number): string | undefined {
try {
const out = execFileSync('getent', ['passwd', String(uid)], { encoding: 'utf8' }).trim()
return out === '' ? undefined : out
} catch {
return undefined
}
}
/**
* 确保该用户在本机的 OS 账号存在(幂等)—— **集群化后「建号」的责任落点**(2026-09-16 定)。
*
* **为什么必须落在这里**
* - 账号是**机器本地状态**(`/etc/passwd`),而 uid 由 Manager 从控制面库分配、随
* `POST /launch` 投递 ⇒ **只有 agent 同时握有「uid」与「这台机器」**。
* - 单机时代的 `dsh-provision.path`(观察目录出现 ⇒ 建号)靠本地控制面库算 uid,
* 集群下算不出来:实测 `hashUid` 兜底把 guest 算成 **184656**(真实 100002),
* 而 PG 是 Manager 专属(Worker 不连控制面库,见本文件头「边界」)⇒
* **不能把建号留在 Worker 本地被动触发**。
*
* **规格**与 `provision-new-users.sh` 一致(`dsh-<短id>` / `-M` / `nologin` / 同 uid),
* 因此与存量节点兼容且可重复执行(幂等)。
*
* ⚠️ 这是**受控固定动作**,不接受调用方传命令或路径 ⇒ 不违反「最小接口」纪律:
* `userId` 必须是 UUID 形状、`uid` 必须是 `>= baseUid` 的整数。
*/
export function ensureOsAccount(config: ServerConfig, userId: string, uid: number): void {
// ① 参数校验先行(与平台无关):非法输入在任何平台都该被拒绝
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)) {
throw new Error(`ensureOsAccount: invalid userId ${userId}`)
}
if (!Number.isInteger(uid) || uid < config.baseUid) {
throw new Error(`ensureOsAccount: invalid uid ${uid} (baseUid=${config.baseUid})`)
}
// ② 非 Linux 节点没有 useradd 语义(客户端节点形态另议)
if (process.platform !== 'linux') return
// ⚠️ 判定**只看 uid**:uid 由 Manager 全局唯一分配 ⇒「同 uid」即「同一用户」,
// 因此**不校验账号名**。存量账号存在历史命名差异(实测 47 上 guest 的账号是
// `dsh-eeccbc638afc46bdb663`,而按规则本该是 `dsh-4092b9652f6849779989`)——
// 名字只是本机标识,权限判据始终是 uid;为它改名反而要动 /etc/passwd + 全量 chown。
const entry = passwdEntryForUid(uid)
if (entry === undefined) {
const name = `dsh-${userId.replace(/-/g, '').slice(0, 20)}`
execFileSync('useradd', ['-u', String(uid), '-M', '-s', '/usr/sbin/nologin', name], { stdio: 'pipe' })
}
// 目录已存在则对齐属主(目录可能尚未创建 ⇒ 留给 spawner 建)
const root = userRoot(config.dataRoot, userId)
if (existsSync(root)) execFileSync('chown', ['-R', `${uid}:${uid}`, root], { stdio: 'pipe' })
}
interface OpCache {
order: string[]
results: Map<string, unknown>
@@ -229,6 +282,16 @@ export function buildWorkerAgent(
if (body.apiKey !== undefined && body.apiKey !== null) apiKeys.set(body.userId, body.apiKey)
if (body.uid !== undefined) uids.set(body.userId, body.uid)
if (body.epoch !== undefined) epochs.set(body.userId, body.epoch)
// 建号必须先于 spawn:OS 账号缺席时 bwrap 里的 `setpriv --reuid` 会直接失败
// (现象是"实例起不来",不会报权限错)。幂等:已建过只对账属主;uid 被他人占用则 fail-loud。
if (body.uid !== undefined) {
try {
ensureOsAccount(config, body.userId, body.uid)
} catch (err) {
const why = err instanceof Error ? err.message : String(err)
return reply.code(500).send({ error: `provision-failed: ${why}` })
}
}
try {
const instance = await spawner.launch(body.userId, body.folder ?? '', body.patch)
// 跨机:把该实例端口经隧道打到 Manager 侧(失败不阻断 —— 本机仍可用)