feat(keys): 模型密钥开放给用户自配 —— 放开官方「模型」页 + 平台改预置凭据
用户要求「把配置模型密钥开放给用户自己配」且「界面交互和官方一模一样」⇒ 不仿制,直接放开官方 ui-settings-models 页(可选厂家:DeepSeek 内置 + 自定义 OpenAI 兼容网关含 baseURL/模型)。 - ensure-role-profile-patch.cjs:不再对普通用户禁用 ui-settings-models(保留 plugins/inventory/cordis 禁用);--force 时能把「还禁着 models」的旧块升级。 - src/web/server.ts:resolveApiKey 不再注入 DEEPSEEK_API_KEY env,改为把「平台共享密钥」预置进 $DSH_HOME/.credentials.yaml 的 refs 段(新增 ensureRefInCredentials:只在无该 ref 时写 / 只在 version:1 上插入 / 备份落平台目录 / 写完 chown 给实例 uid / 失败退回 env)。真因:dsh 凭据解析里 env 优先级最高,且 dsh-credentials-local.write() 的 assertUnshadowed() 会让用户在模型页保存直接报错 ⇒ 注入 env 等于锁死用户自配。 - src/web/routes/auth.ts:/api/me/keys 由 requireAdmin 放开为 requireAuth(平台侧密钥 API 保留,UI 不再暴露)。 - poc/business-plugins 0.3.4→0.3.8:「系统管理」对齐门户 6 个功能页(同名同构,PA_PAGES 逐函数移植 portal)/ 官方插件列表高度改为「离弹窗底部约 100px」/ 撤掉自造的「我的密钥」分区(改由官方模型页承担)。 - web/portal.html:keys 页语义改名「平台共享密钥(未自配密钥的用户默认使用;仅管理员可改)」。scripts/verify-platform-admin-section.mjs:断言同步升级(含 zh/en 词典键集一致性、内联 HTML class 扫描)。
This commit is contained in:
1 parent
f6d4ad9b15
commit
eb50ca2d5b
7 files changed
+1739
-471
No files matched your search
+47
-12
@@ -6,7 +6,7 @@
|
||||
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { requireAdmin, requireAuth } from '../middleware/authn.js'
|
||||
import { requireAuth } from '../middleware/authn.js'
|
||||
import { homeRoot, userRoot } from '../../fs/workspace.js'
|
||||
import { deriveKey, encrypt } from '../../crypto.js'
|
||||
import { toPublicUser } from '../../db/types.js'
|
||||
@@ -132,13 +132,49 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
} as const
|
||||
|
||||
// ---- 统一 KEY:仅管理员可管理。全局只认 admin 的启用 key(见 server.ts resolveApiKey),
|
||||
// 普通用户不再有自配 key 的入口/能力;这些路由对非 admin 一律 403。 ----
|
||||
app.get('/api/me/keys', { preHandler: requireAdmin }, async (request) => ({
|
||||
// ---- 模型密钥:**两层并存**(档案 85 · 2026-09-13,用户要求「配置模型密钥开放给用户自己配」)--
|
||||
// ① **用户自己的 key** —— 任何登录用户都能管理**自己那一格**(自配自用);
|
||||
// ② **平台共享 key**(管理员设置的)—— 用户侧**只读可见**:没自配的人默认就用它。
|
||||
// 两层互相独立、互不覆盖:`server.ts` 的 `resolveApiKey(userId)` 先取 ①,取不到才回落 ②。
|
||||
// ⚠️ `DEEPSEEK_API_KEY` 是 **spawn 时注入 env 的快照** ⇒ 换 key 后必须重启实例才生效:
|
||||
// admin 改的 key 就是共享 key ⇒ `restartAllMains()`(所有仍在回落的用户都得刷新);
|
||||
// 其他人改自己的 ⇒ 只 `restartMain(自己)`,不动任何人。
|
||||
/** 该用户当前**实际生效**的密钥来源。 */
|
||||
async function keySourceOf(userId: string): Promise<'own' | 'shared' | 'none'> {
|
||||
if ((await app.db.getEnabledCredentialKeyRef(userId)) !== null) return 'own'
|
||||
const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin')
|
||||
if (admins.length === 0) return 'none'
|
||||
return (await app.db.getEnabledCredentialKeyRef(admins[0].id)) !== null ? 'shared' : 'none'
|
||||
}
|
||||
/** 平台共享密钥的**非敏感**信息(名字 / 归属;绝不返回密钥本身)。 */
|
||||
async function sharedKeyInfo(
|
||||
userId: string,
|
||||
): Promise<{ available: boolean; name: string | null; owner: string | null; ownerIsMe: boolean }> {
|
||||
const admins = (await app.db.listPublicUsers()).filter((u) => u.role === 'admin')
|
||||
if (admins.length === 0) return { available: false, name: null, owner: null, ownerIsMe: false }
|
||||
const keys = await app.db.listCredentialKeys(admins[0].id)
|
||||
const on = keys.find((k) => k.enabled)
|
||||
// `ownerIsMe`:admin 看的是**自己**配的那把 ⇒ 前端文案要区分「我配的共享 key」与「别人配的」。
|
||||
return {
|
||||
available: on !== undefined,
|
||||
name: on?.name ?? null,
|
||||
owner: admins[0].username,
|
||||
ownerIsMe: admins[0].id === userId,
|
||||
}
|
||||
}
|
||||
/** 换 key 后的刷新:admin 动的是共享 key ⇒ 广播重启;其他人只重启自己。 */
|
||||
async function refreshAfterKeyChange(userId: string, role: string): Promise<void> {
|
||||
if (role === 'admin') await app.supervisor.restartAllMains()
|
||||
else await app.supervisor.restartMain(userId)
|
||||
}
|
||||
|
||||
app.get('/api/me/keys', { preHandler: requireAuth }, async (request) => ({
|
||||
keys: await app.db.listCredentialKeys(request.user!.id),
|
||||
effective: await keySourceOf(request.user!.id),
|
||||
shared: await sharedKeyInfo(request.user!.id),
|
||||
}))
|
||||
|
||||
app.post('/api/me/keys', { preHandler: requireAdmin, schema: keyAddSchema }, async (request, reply) => {
|
||||
app.post('/api/me/keys', { preHandler: requireAuth, schema: keyAddSchema }, async (request, reply) => {
|
||||
const { name, apiKey } = request.body as { name: string; apiKey: string }
|
||||
const cleanName = name.trim()
|
||||
if (!/^[A-Za-z0-9\-_ .]{1,32}$/.test(cleanName)) {
|
||||
@@ -154,23 +190,22 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
encrypt(apiKey, deriveKey(app.config.encryptionSecret)),
|
||||
)
|
||||
await app.db.audit(request.user!.id, 'set_api_key', JSON.stringify({ name: cleanName }))
|
||||
// 统一 KEY:admin 换 key 后广播重启所有用户的实例(DEEPSEEK_API_KEY 是 spawn 时
|
||||
// 注入 env 的快照,不重启运行中的实例不会刷新)。
|
||||
await app.supervisor.restartAllMains()
|
||||
await refreshAfterKeyChange(request.user!.id, request.user!.role)
|
||||
return { key }
|
||||
})
|
||||
|
||||
app.post('/api/me/keys/:id/select', { preHandler: requireAdmin }, async (request, reply) => {
|
||||
app.post('/api/me/keys/:id/select', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
if (!(await app.db.selectCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' })
|
||||
// 切换启用的全局 key 后同样广播刷新
|
||||
await app.supervisor.restartAllMains()
|
||||
await refreshAfterKeyChange(request.user!.id, request.user!.role)
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
app.delete('/api/me/keys/:id', { preHandler: requireAdmin }, async (request, reply) => {
|
||||
app.delete('/api/me/keys/:id', { preHandler: requireAuth }, async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
if (!(await app.db.deleteCredentialKey(request.user!.id, id))) return reply.code(404).send({ error: 'not_found' })
|
||||
// 删掉自己最后一把 ⇒ 自动回落到平台共享密钥(这是"两层"应有的语义,不需要额外开关)
|
||||
await refreshAfterKeyChange(request.user!.id, request.user!.role)
|
||||
return { ok: true }
|
||||
})
|
||||
}
|
||||
+88
-5
@@ -7,7 +7,9 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify'
|
||||
import fastifyStatic from '@fastify/static'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { chown, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import type { ServerConfig } from '../config.js'
|
||||
import { createDbAdapter, type DbAdapter, type PublicUser } from '../db/index.js'
|
||||
import { createUserFs } from '../fs/provider.js'
|
||||
@@ -63,18 +65,99 @@ function isAllowedOrigin(origin: string, baseDomain: string): boolean {
|
||||
export async function buildServer(config: ServerConfig): Promise<FastifyInstance> {
|
||||
const db = await createDbAdapter(config)
|
||||
const encryptionKey = deriveKey(config.encryptionSecret)
|
||||
const resolveApiKey = async (_userId: string): Promise<string | null> => {
|
||||
// 统一 KEY 模式:所有用户共用管理员(admin)设置的启用 key,用户不可自配。
|
||||
// 忽略入参 userId——不管哪个用户 spawn,都注入同一把管理员 key。
|
||||
/**
|
||||
* 把「平台共享密钥」预置进用户的 dsh 凭据文件 `$DSH_HOME/.credentials.yaml` 的 `refs:` 段。
|
||||
*
|
||||
* 为什么是写文件而不是注入 env(2026-09-13 读官方源码定的):
|
||||
* · dsh 凭据解析顺序 `inherited process environment (read-only, wins) > $DSH_HOME/.credentials.yaml > …`
|
||||
* ⇒ **env 永远赢**;
|
||||
* · 更要命的是 `dsh-credentials-local` 的 `write()` 里有 `assertUnshadowed()`:
|
||||
* 只要 env 里存在同名 ref,用户在官方「设置 → 模型」页**保存该 key 会直接报错**
|
||||
* ("supplied read-only by the launching environment … unset it in the shell you start dsh from")。
|
||||
* ⇒ 注入 env 等于**把用户锁死在"不能自配 DeepSeek key"**的状态。
|
||||
* · 所以平台改为**预置到凭据文件**:用户没配 ⇒ 用平台共享 key;用户去模型页改 ⇒ 直接覆盖同一个 ref。
|
||||
*
|
||||
* 安全约束(保守到极限):
|
||||
* ① 只在 `refs:` 段**没有**该 ref 时写 —— 用户配过就绝不碰;
|
||||
* ② 写前备份,但**备份必须放到平台自己的目录**(`/opt/dsh/backups`),
|
||||
* ⛔ **绝不能落在用户 home 里**:dsh 用 chokidar watch 该目录,一个**实例读不了**的文件
|
||||
* (root 属主 600)会让它抛 `EACCES` ⇒ **实例崩溃循环**(2026-09-13 实测踩过,
|
||||
* 当时 .credentials.yaml.bak-platform 直接把 guest 打进 attempt=5);
|
||||
* ③ 只在 `version: 1` 的文档上插入,**不重排、不重写其它行**;
|
||||
* ④ 写完 chown 给实例 uid(否则 600 权限下实例读不了自己的凭据文件)。
|
||||
*/
|
||||
async function ensureRefInCredentials(homeDir: string, ref: string, value: string): Promise<boolean> {
|
||||
const file = join(homeDir, '.credentials.yaml')
|
||||
let text = ''
|
||||
try {
|
||||
text = await readFile(file, 'utf8')
|
||||
} catch {
|
||||
text = '' // 文件不存在 ⇒ 从零创建一个最小合法文档
|
||||
}
|
||||
const has = new RegExp('^[ \\t]*' + ref + '[ \\t]*:', 'm')
|
||||
if (has.test(text)) return false // 用户已自配(或有该 ref)⇒ 绝不覆盖
|
||||
const line = ' ' + ref + ": '" + value + "'"
|
||||
let next: string
|
||||
if (text.trim() === '') {
|
||||
next = 'version: 1\nrefs:\n' + line + '\n'
|
||||
} else if (/^refs:[ \t]*$/m.test(text)) {
|
||||
next = text.replace(/^refs:[ \t]*$/m, (m) => m + '\n' + line)
|
||||
} else if (/^version:[ \t]*1[ \t]*$/m.test(text)) {
|
||||
// 有 version 但还没 refs 段 ⇒ 紧跟 version 建一个
|
||||
next = text.replace(/^version:[ \t]*1[ \t]*$/m, (m) => m + '\nrefs:\n' + line)
|
||||
} else {
|
||||
return false // 认不出的布局 ⇒ 宁可不动(让实例照旧报"没有 key",也不冒写坏凭据的风险)
|
||||
}
|
||||
// 备份落在**平台目录**(不进 home —— 见上面 ②)
|
||||
if (text !== '') {
|
||||
try {
|
||||
const bakDir = process.env.DSH_PLATFORM_BACKUP_DIR ?? '/opt/dsh/backups'
|
||||
await mkdir(bakDir, { recursive: true })
|
||||
writeFileSync(join(bakDir, 'credentials-' + basename(homeDir) + '-' + Date.now() + '.yaml'), text, { mode: 0o600 })
|
||||
} catch {
|
||||
/* 备份失败不阻断 */
|
||||
}
|
||||
}
|
||||
await writeFile(file, next, { mode: 0o600 })
|
||||
// 实例以 dsh-<uid> 身份运行;root 写的 600 文件它读不了 ⇒ 交给该 home 的属主
|
||||
try {
|
||||
const st = await stat(homeDir)
|
||||
await chown(file, st.uid, st.gid)
|
||||
} catch {
|
||||
/* chown 失败(非 root 运行等)不阻断 */
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ── 模型密钥供给(档案 86;2026-09-13 用户要求用户可自配模型厂家)────────────────
|
||||
// 口径:**平台不再向实例注入 `DEEPSEEK_API_KEY` env**,改为在 spawn 时把「平台共享密钥」
|
||||
// 预置进该用户的凭据文件(仅当他还没配过)。这样:
|
||||
// · 没配的用户 —— 照旧能用(共享 key);
|
||||
// · 想用自己的 —— 直接去官方「设置 → 模型」页改,覆盖同一个 ref,**不会再被 env 挡住**;
|
||||
// · 配了自定义厂家(OpenAI 兼容网关)的 —— 那走各自的 `<ROUTE>_API_KEY`,平台完全不介入。
|
||||
// ⚠️ 返回 null(不注入 env)是**刻意的**,不是"没有 key"。见上面 ensureRefInCredentials 的注释。
|
||||
const resolveApiKey = async (userId: string): Promise<string | null> => {
|
||||
const owner = await db.findUserById(userId)
|
||||
if (owner === undefined) return null
|
||||
const admins = (await db.listPublicUsers()).filter((u) => u.role === 'admin')
|
||||
if (admins.length === 0) return null
|
||||
const ref = await db.getEnabledCredentialKeyRef(admins[0].id)
|
||||
if (ref === null) return null
|
||||
let shared: string | null = null
|
||||
try {
|
||||
return decrypt(ref, encryptionKey)
|
||||
shared = decrypt(ref, encryptionKey)
|
||||
} catch {
|
||||
return null // corrupt ref — treat as unset, let the admin re-enter it
|
||||
}
|
||||
if (shared === null) return null
|
||||
try {
|
||||
await ensureRefInCredentials(owner.home_dir, 'DEEPSEEK_API_KEY', shared)
|
||||
} catch (err) {
|
||||
// 写失败不能让实例起不来:退回老办法(注入 env)保底
|
||||
console.error('ensureRefInCredentials failed, falling back to env injection', err)
|
||||
return shared
|
||||
}
|
||||
return null
|
||||
}
|
||||
const resolveUid = async (userId: string): Promise<number> => {
|
||||
const user = await db.findUserById(userId)
|
||||
|
||||
Reference in new issue
Block a user