2026-09-13 16:18:10 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* Fastify bootstrap: assembles the HTTP server, registers plugins and routes,
|
|
|
|
|
|
* and owns the DB lifecycle via the close hook.
|
|
|
|
|
|
* @module dshs/web/server
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import Fastify, { type FastifyInstance } from 'fastify'
|
|
|
|
|
|
import fastifyStatic from '@fastify/static'
|
|
|
|
|
|
import { fileURLToPath } from 'node:url'
|
2026-09-13 22:27:49 +08:00
|
|
|
|
import { basename, dirname, join } from 'node:path'
|
|
|
|
|
|
import { writeFileSync } from 'node:fs'
|
|
|
|
|
|
import { chown, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
2026-09-13 16:18:10 +08:00
|
|
|
|
import type { ServerConfig } from '../config.js'
|
|
|
|
|
|
import { createDbAdapter, type DbAdapter, type PublicUser } from '../db/index.js'
|
|
|
|
|
|
import { createUserFs } from '../fs/provider.js'
|
|
|
|
|
|
import type { UserFs } from '../fs/user-fs.js'
|
|
|
|
|
|
import { decrypt, deriveKey } from '../crypto.js'
|
|
|
|
|
|
import { hashUid } from '../isolation.js'
|
|
|
|
|
|
import { LocalSpawner } from '../supervisor/orchestrator.js'
|
|
|
|
|
|
import { K8sSpawner } from '../supervisor/k8s-spawner.js'
|
|
|
|
|
|
import { registerDshProxy } from '../supervisor/proxy.js'
|
|
|
|
|
|
import type { Spawner } from '../supervisor/spawner.js'
|
|
|
|
|
|
import { rateLimit } from './middleware/rate-limit.js'
|
|
|
|
|
|
import { authRoutes } from './routes/auth.js'
|
|
|
|
|
|
import { adminRoutes } from './routes/admin.js'
|
|
|
|
|
|
import { businessPluginRoutes } from './routes/business-plugins.js'
|
|
|
|
|
|
import { desktopRoutes } from './routes/desktop.js'
|
|
|
|
|
|
import { dshRoutes } from './routes/dsh.js'
|
|
|
|
|
|
import { domainRoutes } from './routes/domain.js'
|
|
|
|
|
|
import { skillRoutes } from './routes/skills.js'
|
|
|
|
|
|
import { whitelistRoutes } from './routes/whitelist.js'
|
|
|
|
|
|
|
|
|
|
|
|
declare module 'fastify' {
|
|
|
|
|
|
interface FastifyInstance {
|
|
|
|
|
|
db: DbAdapter
|
|
|
|
|
|
config: ServerConfig
|
|
|
|
|
|
supervisor: Spawner
|
|
|
|
|
|
userFs: UserFs
|
|
|
|
|
|
}
|
|
|
|
|
|
interface FastifyRequest {
|
|
|
|
|
|
user: PublicUser | null
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const webRoot = join(dirname(fileURLToPath(import.meta.url)), '../../web')
|
|
|
|
|
|
|
|
|
|
|
|
/** Whether an `Origin` header belongs to the platform base domain (or a
|
|
|
|
|
|
* per-user subdomain of it). Used to allow cross-subdomain API calls from dsh
|
|
|
|
|
|
* instances (功能插件启停). */
|
|
|
|
|
|
function isAllowedOrigin(origin: string, baseDomain: string): boolean {
|
|
|
|
|
|
if (baseDomain === '') return false
|
|
|
|
|
|
try {
|
|
|
|
|
|
const host = new URL(origin).hostname
|
|
|
|
|
|
return host === baseDomain || host.endsWith('.' + baseDomain)
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Build a fully-wired Fastify instance. Does not call `listen`; the caller owns
|
|
|
|
|
|
* bind + shutdown.
|
|
|
|
|
|
* @param config - resolved runtime configuration.
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function buildServer(config: ServerConfig): Promise<FastifyInstance> {
|
|
|
|
|
|
const db = await createDbAdapter(config)
|
|
|
|
|
|
const encryptionKey = deriveKey(config.encryptionSecret)
|
2026-09-13 22:27:49 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 把「平台共享密钥」预置进用户的 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
|
2026-09-13 16:18:10 +08:00
|
|
|
|
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
|
2026-09-13 22:27:49 +08:00
|
|
|
|
let shared: string | null = null
|
2026-09-13 16:18:10 +08:00
|
|
|
|
try {
|
2026-09-13 22:27:49 +08:00
|
|
|
|
shared = decrypt(ref, encryptionKey)
|
2026-09-13 16:18:10 +08:00
|
|
|
|
} catch {
|
|
|
|
|
|
return null // corrupt ref — treat as unset, let the admin re-enter it
|
|
|
|
|
|
}
|
2026-09-13 22:27:49 +08:00
|
|
|
|
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
|
2026-09-13 16:18:10 +08:00
|
|
|
|
}
|
|
|
|
|
|
const resolveUid = async (userId: string): Promise<number> => {
|
|
|
|
|
|
const user = await db.findUserById(userId)
|
|
|
|
|
|
return user?.uid ?? hashUid(userId, config.baseUid)
|
|
|
|
|
|
}
|
|
|
|
|
|
const supervisor: Spawner =
|
|
|
|
|
|
config.deployMode === 'k8s'
|
|
|
|
|
|
? new K8sSpawner(config, db, resolveApiKey, resolveUid)
|
|
|
|
|
|
: new LocalSpawner(config, resolveApiKey, resolveUid)
|
|
|
|
|
|
const userFs = createUserFs(config, (userId) => supervisor.ensureFileService(userId))
|
|
|
|
|
|
|
|
|
|
|
|
const app = Fastify({
|
|
|
|
|
|
logger: { level: config.logLevel },
|
|
|
|
|
|
trustProxy: true,
|
|
|
|
|
|
bodyLimit: config.maxUploadBytes,
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
app.decorate('db', db)
|
|
|
|
|
|
app.decorate('config', config)
|
|
|
|
|
|
app.decorate('supervisor', supervisor)
|
|
|
|
|
|
app.decorate('userFs', userFs)
|
|
|
|
|
|
app.decorateRequest('user', null)
|
|
|
|
|
|
|
|
|
|
|
|
// Reverse proxy (subdomain + legacy subpath). Registered first so its global
|
|
|
|
|
|
// onRequest hook intercepts per-user subdomain traffic before other hooks.
|
|
|
|
|
|
await registerDshProxy(app)
|
|
|
|
|
|
|
|
|
|
|
|
// CORS for cross-subdomain API calls from dsh instances (功能插件启停 section
|
|
|
|
|
|
// runs in the browser on `<user>.dsh.alotbuy.com` and calls portal APIs on
|
|
|
|
|
|
// `dsh.alotbuy.com`). Cookie is HttpOnly + SameSite=None (secure mode) with
|
|
|
|
|
|
// Domain=.dsh.alotbuy.com, so credentials ride along; we only need to allow
|
|
|
|
|
|
// the Origin. Restricted to the platform base domain and its subdomains.
|
|
|
|
|
|
app.addHook('onRequest', async (request, reply) => {
|
|
|
|
|
|
const origin = request.headers.origin
|
|
|
|
|
|
if (origin === undefined || origin === '') return
|
|
|
|
|
|
if (!isAllowedOrigin(origin, config.baseDomain)) return
|
|
|
|
|
|
reply.header('Access-Control-Allow-Origin', origin)
|
|
|
|
|
|
reply.header('Access-Control-Allow-Credentials', 'true')
|
|
|
|
|
|
reply.header('Vary', 'Origin')
|
|
|
|
|
|
if (request.raw.method === 'OPTIONS') {
|
|
|
|
|
|
reply.header('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS')
|
|
|
|
|
|
reply.header('Access-Control-Allow-Headers', 'Content-Type')
|
|
|
|
|
|
reply.header('Access-Control-Max-Age', '600')
|
|
|
|
|
|
return reply.code(204).send()
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
app.addHook('onClose', async () => {
|
|
|
|
|
|
await supervisor.teardown()
|
|
|
|
|
|
await db.close()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// Rate limiting first so auth/admin surfaces are covered by default.
|
|
|
|
|
|
await app.register(rateLimit)
|
|
|
|
|
|
|
|
|
|
|
|
// Domain-specific route groups (API).
|
|
|
|
|
|
await app.register(authRoutes)
|
|
|
|
|
|
await app.register(adminRoutes)
|
|
|
|
|
|
await app.register(businessPluginRoutes)
|
|
|
|
|
|
await app.register(desktopRoutes)
|
|
|
|
|
|
await app.register(dshRoutes)
|
|
|
|
|
|
await app.register(domainRoutes)
|
|
|
|
|
|
await app.register(skillRoutes)
|
|
|
|
|
|
await app.register(whitelistRoutes)
|
|
|
|
|
|
|
|
|
|
|
|
// Static placeholder SPA last, so exact API routes take precedence over the
|
|
|
|
|
|
// wildcard static handler.
|
|
|
|
|
|
await app.register(fastifyStatic, {
|
|
|
|
|
|
root: webRoot,
|
|
|
|
|
|
prefix: '/',
|
|
|
|
|
|
wildcard: true,
|
|
|
|
|
|
index: ['index.html'],
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return app
|
|
|
|
|
|
}
|