初始提交:DSH 多租户平台(dshs)

This commit is contained in:
admin committed 2026-09-13 16:18:10 +08:00
commit 43976fea6a
167 files changed
+24456

No files matched your search

+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env node
/**
* Standalone orchestrator entry (`dshs` bin).
*
* Subcommands:
* dshs bootstrap-admin --username <u> --password <p>
* dshs file-service per-user file sidecar (k8s)
* dshs [server flags]
* @module dshs/cli
*/
import { randomUUID } from 'node:crypto'
import { parseArgs } from 'node:util'
import { resolveConfig, type ConfigOverrides } from './config.js'
import { createDbAdapter } from './db/index.js'
import { createUserFs } from './fs/provider.js'
import { homeRoot, userRoot } from './fs/workspace.js'
import { hashPassword } from './web/auth.js'
import { hashUid } from './isolation.js'
import { LeaderElector } from './supervisor/leader.js'
import { ReconcileController } from './supervisor/reconcile.js'
import { K8sSpawner } from './supervisor/k8s-spawner.js'
import { buildFileService, FILE_SERVICE_PORT, USER_ROOT_ENV } from './web/file-service.js'
import { startTcpBridge } from './tcp-bridge.js'
import { buildServer } from './web/server.js'
const HELP = `dshs — DSH server login orchestrator
Usage:
dshs [options] start the server
dshs bootstrap-admin [options] create the first admin
dshs file-service run the per-user file sidecar
Server options:
--port <n> Bind port (0 = ephemeral). Default 3080.
--host <h> Bind host. Default 127.0.0.1.
--db <path> SQLite database path.
--data-root <p> Root for per-user homes + workspaces.
--dsh-bin <cmd> Command used to launch a child DSH. Default "dsh".
--log-level <l> Pino log level. Default "info".
--secure-cookies Set the Secure flag on session cookies (behind HTTPS).
--session-ttl <s> Session lifetime in seconds. Default 604800 (7 days).
--isolation-mode <m> Isolation tier: "soft" or "account" (Linux, needs root). Default "soft".
-h, --help Show this help.
bootstrap-admin options:
--username <u> Admin username (required).
--password <p> Admin password (or DSHS_ADMIN_PASSWORD env).
--db <path> Database path.
--data-root <p> Root for per-user homes.
`
interface ParsedValues {
[key: string]: string | boolean | undefined
}
function toOverrides(values: ParsedValues): ConfigOverrides {
const str = (value: string | boolean | undefined): string | undefined =>
typeof value === 'string' ? value : undefined
const dshBin = str(values['dsh-bin'])
return {
port: str(values.port),
host: str(values.host),
dbPath: str(values.db),
dataRoot: str(values['data-root']),
dshCommand: dshBin ? [dshBin] : undefined,
logLevel: str(values['log-level']),
secureCookies: values['secure-cookies'] === true ? true : undefined,
sessionTtlSeconds: str(values['session-ttl']),
maxUploadBytes: str(values['max-upload']),
isolationMode: str(values['isolation-mode']),
}
}
async function bootstrapAdmin(args: string[]): Promise<void> {
const { values } = parseArgs({
args,
options: {
username: { type: 'string' },
password: { type: 'string' },
db: { type: 'string' },
'data-root': { type: 'string' },
},
})
const username = values.username
const password = values.password ?? process.env.DSHS_ADMIN_PASSWORD
if (username === undefined || username === '' || password === undefined || password === '') {
console.error('usage: dshs bootstrap-admin --username <u> --password <p>')
process.exit(2)
}
const config = resolveConfig({ dbPath: values.db, dataRoot: values['data-root'] })
const db = await createDbAdapter(config)
if ((await db.countAdmins()) > 0) {
console.error('an admin already exists; refusing to create a second one')
await db.close()
process.exit(1)
}
const id = randomUUID()
const homeDir = homeRoot(userRoot(config.dataRoot, id))
const passHash = await hashPassword(password)
// Create the user before initUserRoot so the per-user uid (baseUid + row_id)
// is already assigned — same reason as the register route.
const user = await db.createUser({ id, username, passHash, role: 'admin', homeDir })
await createUserFs(config).initUserRoot(id, user.uid ?? undefined)
await db.close()
console.log(`admin "${username}" created (id: ${id})`)
}
async function runServer(args: string[]): Promise<void> {
const { values } = parseArgs({
args,
options: {
port: { type: 'string' },
host: { type: 'string' },
db: { type: 'string' },
'data-root': { type: 'string' },
'dsh-bin': { type: 'string' },
'log-level': { type: 'string' },
'secure-cookies': { type: 'boolean' },
'session-ttl': { type: 'string' },
'max-upload': { type: 'string' },
'isolation-mode': { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
})
if (values.help) {
process.stdout.write(HELP)
return
}
const config = resolveConfig(toOverrides(values as ParsedValues))
const app = await buildServer(config)
await app.listen({ host: config.host, port: config.port })
const address = app.server.address()
const actualPort = typeof address === 'object' && address !== null ? address.port : config.port
app.log.info(`dshs listening on http://${config.host}:${actualPort}`)
app.log.info(`data root: ${config.dataRoot}; db: ${config.dbPath}`)
// In k8s mode, only the elected leader runs the controller (reconcile + Pod
// watch). The web layer serves on every replica.
let controller: ReconcileController | undefined
if (config.deployMode === 'k8s') {
const spawner = app.supervisor as K8sSpawner
const elector = new LeaderElector({ namespace: config.k8sNamespace, identity: config.podName })
controller = new ReconcileController(app.db, spawner, elector)
await controller.start()
app.log.info(`leader election started (identity ${config.podName})`)
}
const shutdown = async (signal: string): Promise<void> => {
app.log.info(`received ${signal}, shutting down`)
controller?.stop()
await app.close()
process.exit(0)
}
process.on('SIGINT', () => void shutdown('SIGINT'))
process.on('SIGTERM', () => void shutdown('SIGTERM'))
}
/**
* Run the per-user file sidecar. Serves one user's volume over HTTP on 8082 so
* the control plane (which holds no users volume under k8s) can reach it; the
* root comes from the Pod's env, not from argv.
*/
async function runFileService(): Promise<void> {
const root = process.env[USER_ROOT_ENV]
if (root === undefined || root === '') {
console.error(`file-service requires ${USER_ROOT_ENV} (the user's data root inside the Pod)`)
process.exit(2)
}
// The sidecar runs as the user's uid with no home dir; `homedir()` falls back
// to `/` and `resolveConfig` would try to mkdir `/.dshs`. It only
// needs `maxUploadBytes`/`logLevel` here, so pin dataRoot to a writable path
// and skip the (unused) encryption-secret file.
const config = resolveConfig({ dataRoot: '/tmp', encryptionSecret: 'file-service-unused' })
const app = buildFileService(root, { bodyLimit: config.maxUploadBytes, logLevel: config.logLevel })
await app.listen({ host: '0.0.0.0', port: FILE_SERVICE_PORT })
app.log.info(`file sidecar serving ${root} on 0.0.0.0:${FILE_SERVICE_PORT}`)
const shutdown = async (signal: string): Promise<void> => {
app.log.info(`received ${signal}, shutting down`)
await app.close()
process.exit(0)
}
process.on('SIGINT', () => void shutdown('SIGINT'))
process.on('SIGTERM', () => void shutdown('SIGTERM'))
}
/** TCP bridge sidecar (`dshs tcp-bridge <listen> <target>`). */
async function runTcpBridge(args: string[]): Promise<void> {
const [listen, target] = args
if (listen === undefined || target === undefined) {
console.error('usage: dshs tcp-bridge <listen> <target>')
process.exit(2)
}
const server = await startTcpBridge(listen, target)
console.error(`tcp-bridge ${listen} -> ${target}`)
const shutdown = (): void => {
server.close(() => process.exit(0))
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)
}
async function uidForUserCmd(args: string[]): Promise<void> {
const { values, positionals } = parseArgs({
args,
allowPositionals: true,
options: { 'base-uid': { type: 'string' }, db: { type: 'string' } },
})
const userId = positionals[0]
if (userId === undefined) {
console.error('usage: dshs uid-for-user <userId> [--db <path>] [--base-uid N]')
process.exit(2)
}
const dbPath = typeof values.db === 'string' ? values.db : undefined
const baseUid = typeof values['base-uid'] === 'string' ? values['base-uid'] : undefined
const config = resolveConfig({ dbPath, baseUid })
const db = await createDbAdapter(config)
const user = await db.findUserById(userId)
await db.close()
console.log(user?.uid ?? hashUid(userId, config.baseUid))
}
async function main(): Promise<void> {
const [first, ...rest] = process.argv.slice(2)
if (first === 'bootstrap-admin') {
await bootstrapAdmin(rest)
return
}
if (first === 'uid-for-user') {
await uidForUserCmd(rest)
return
}
if (first === 'file-service') {
await runFileService()
return
}
if (first === 'tcp-bridge') {
await runTcpBridge(rest)
return
}
await runServer(process.argv.slice(2))
}
main().catch((err: unknown) => {
console.error(err)
process.exit(1)
})
+331
View File
@@ -0,0 +1,331 @@
/**
* Deployment-varying configuration. Every tunable is a validated field here
* (or read from env), never a hardcoded constant inside the app.
* @module dshs/config
*/
import { randomBytes } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { homedir, hostname } from 'node:os'
import { join } from 'node:path'
/** Isolation tier. `soft` = per-user home/workspace + sandbox (same OS user);
* `account` = per-user OS account via a setuid wrapper (Linux, needs root). */
export type IsolationMode = 'soft' | 'account'
/** Deployment mode. `local` = single-host child_process (setuid/iptables);
* `k8s` = multi-replica control plane spawning per-user DSH Pods via the K8s API. */
export type DeployMode = 'local' | 'k8s'
/** Resolved, immutable runtime configuration. */
export interface ServerConfig {
/** Bind host for the orchestrator HTTP server. */
host: string
/** Bind port; `0` requests an ephemeral port. */
port: number
/** SQLite database path. */
dbPath: string
/** Postgres connection string; when set, the DB backend is Postgres (k8s/HA). */
dbUrl?: string
/** Root under which per-user homes (`users/<id>/home`) and workspaces live. */
dataRoot: string
/** Shared read-only skill directory for all users (injected as
* `DSH_BUNDLED_SKILL_DIR` into every spawned DSH); empty = feature off. */
bundledSkillDir: string
/** Argv used to launch a child DSH; first element is the executable. */
dshCommand: string[]
/** Pino log level. */
logLevel: string
/** Set the `Secure` flag on session cookies (enable behind HTTPS). */
secureCookies: boolean
/** Session lifetime in seconds. */
sessionTtlSeconds: number
/** Max upload request body in bytes (base64 JSON; ~0.75× the file size). */
maxUploadBytes: number
/** Delay before auto-restarting a crashed child DSH, in milliseconds. */
restartBackoffMs: number
/** Upper bound for the exponential crash-restart backoff (档案 20). */
restartBackoffMaxMs: number
/** Auto-restarts allowed inside `crashWindowMs` before the circuit opens. */
crashMaxRestarts: number
/** Rolling window used to count auto-restarts (档案 20). */
crashWindowMs: number
/** Uptime after which a main counts as recovered and the backoff resets. */
crashStableMs: number
/** 档案 78:熔断首次冷却时长(冷却期内拒绝隐式启动)。 */
crashBreakerCooldownMs: number
/** 档案 78:熔断冷却上限(多次熔断后指数加长到此为止)。 */
crashBreakerMaxCooldownMs: number
/** Isolation tier (see {@link IsolationMode}); local mode only. */
isolationMode: IsolationMode
/** Argv prefix that drops privileges; `{UID}`/`{GID}` are substituted. Local mode only. */
spawnAsUserCommand: string[]
/** Base uid for the deterministic per-user uid. */
baseUid: number
/** Parent domain for per-user subdomains (`<username>.<baseDomain>`); empty = disabled. */
baseDomain: string
/** Cookie `Domain` value (e.g. `.example.com`) so the session reaches subdomains; empty = host-only. */
cookieDomain: string
/** Whether to pass `--patch` to child DSHs (needs a dsh CLI that supports it). */
enablePatch: boolean
/** Enable the loopback OUTPUT owner-match port guard (Linux + root). Local mode only. */
portGuard: boolean
/** Cap on resident main instances per host (0 = no cap). Local mode idle reap. */
maxIdleInstances: number
/** A main instance with no proxied/entered activity for this long is stopped
* (0 = disabled). Local mode idle reap. */
instanceIdleTtlSeconds: number
/** Period between idle-reap scans (seconds). Local mode idle reap. */
idleReapIntervalSeconds: number
/** Secret used to encrypt per-user secrets at rest (from env or dataRoot/secret.key). */
encryptionSecret: string
/** Deployment mode (see {@link DeployMode}). */
deployMode: DeployMode
/** K8s namespace for per-user DSH resources (k8s mode only). */
k8sNamespace: string
/** Image for per-user DSH Pods (k8s mode only). */
dshImage: string
/** Image for the per-user file sidecar (k8s mode only; shares the control-plane image). */
controlPlaneImage: string
/** imagePullSecret every generated per-user Pod/Job uses (k8s mode only). */
imagePullSecret: string
/** 443 egress whitelist CIDRs for per-user DSH Pods (Phase 4 egress 收敛);
* empty = keep the current `0.0.0.0/0` (except private ranges). */
egressCidrs: string[]
/** ServiceAccount the orchestrator runs as (k8s mode only). */
k8sServiceAccount: string
/** This replica's identity for leader election (POD_NAME, else hostname). */
podName: string
}
/** Untyped overrides collected from argv / env. */
export interface ConfigOverrides {
host?: string
port?: string | number
dbPath?: string
dbUrl?: string
dataRoot?: string
bundledSkillDir?: string
dshCommand?: string[]
logLevel?: string
secureCookies?: boolean
sessionTtlSeconds?: number | string
maxUploadBytes?: number | string
restartBackoffMs?: number | string
restartBackoffMaxMs?: number | string
crashMaxRestarts?: number | string
crashWindowMs?: number | string
crashStableMs?: number | string
crashBreakerCooldownMs?: number | string
crashBreakerMaxCooldownMs?: number | string
isolationMode?: IsolationMode | string
spawnAsUserCommand?: string[]
baseUid?: number | string
baseDomain?: string
cookieDomain?: string
enablePatch?: boolean
portGuard?: boolean
maxIdleInstances?: number | string
instanceIdleTtlSeconds?: number | string
idleReapIntervalSeconds?: number | string
encryptionSecret?: string
deployMode?: DeployMode | string
k8sNamespace?: string
dshImage?: string
controlPlaneImage?: string
imagePullSecret?: string
egressCidrs?: string[]
k8sServiceAccount?: string
podName?: string
}
const DEFAULT_HOST = '127.0.0.1'
const DEFAULT_PORT = 3080
const DEFAULT_DSH_COMMAND = ['dsh']
const DEFAULT_LOG_LEVEL = 'info'
const DEFAULT_SESSION_TTL_SECONDS = 60 * 60 * 24 * 7
const DEFAULT_MAX_UPLOAD_BYTES = 25 * 1024 * 1024
const DEFAULT_RESTART_BACKOFF_MS = 1000
/** 崩溃自愈退避上限(档案 20)。 */
const DEFAULT_RESTART_BACKOFF_MAX_MS = 30000
/** 熔断窗口内允许的自动重启次数(档案 20)。 */
const DEFAULT_CRASH_MAX_RESTARTS = 5
/** 熔断窗口长度(档案 20)。 */
const DEFAULT_CRASH_WINDOW_MS = 600000
/** 连续运行多久视为已恢复、重置退避步数(档案 20)。 */
const DEFAULT_CRASH_STABLE_MS = 60000
// 档案 78:熔断冷却 —— 首次 10 分钟,指数加长,封顶 6 小时
const DEFAULT_CRASH_BREAKER_COOLDOWN_MS = 600000
const DEFAULT_CRASH_BREAKER_MAX_COOLDOWN_MS = 21600000
const DEFAULT_ISOLATION_MODE: IsolationMode = 'soft'
const DEFAULT_SPAWN_AS_USER_COMMAND = [
'setpriv',
'--reuid',
'{UID}',
'--regid',
'{GID}',
'--inh-caps=-all',
'--clear-groups',
'--',
]
const DEFAULT_BASE_UID = 100000
const DEFAULT_BASE_DOMAIN = ''
const DEFAULT_COOKIE_DOMAIN = ''
const DEFAULT_ENABLE_PATCH = false
const DEFAULT_MAX_IDLE_INSTANCES = 4
const DEFAULT_INSTANCE_IDLE_TTL_SECONDS = 60 * 60 * 24 * 7
const DEFAULT_IDLE_REAP_INTERVAL_SECONDS = 60
const DEFAULT_DEPLOY_MODE: DeployMode = 'local'
const DEFAULT_K8S_NAMESPACE = 'dsh'
const DEFAULT_K8S_SERVICE_ACCOUNT = 'dsh-orchestrator'
const DEFAULT_IMAGE_PULL_SECRET = 'dsh-acr-pull'
/** Load the encryption secret from env, or persist a generated one at
* `<dataRoot>/secret.key` (0600) so it survives restarts without setup. */
function resolveEncryptionSecret(dataRoot: string): string {
const fromEnv = process.env.DSHS_SECRET
if (fromEnv !== undefined && fromEnv !== '') return fromEnv
const path = join(dataRoot, 'secret.key')
try {
const existing = readFileSync(path, 'utf8').trim()
if (existing !== '') return existing
} catch {
// fall through to generate
}
const secret = randomBytes(32).toString('hex')
mkdirSync(dataRoot, { recursive: true })
writeFileSync(path, secret, { mode: 0o600 })
return secret
}
function toBool(value: string | undefined, fallback: boolean): boolean {
if (value === undefined) return fallback
return value === 'true' || value === '1'
}
/** Split a comma-separated CIDR list into a trimmed, de-duplicated array. */
function parseCidrs(value: string | undefined): string[] {
if (value === undefined || value === '') return []
return [...new Set(value.split(',').map((c) => c.trim()).filter((c) => c !== ''))]
}
/** Parse an isolation-mode value, rejecting anything outside `soft`/`account`
* so a typo in the env var fails loudly at startup instead of silently
* falling back to `soft` isolation. */
function toIsolationMode(value: string | undefined): IsolationMode | undefined {
if (value === undefined) return undefined
const normalized = value.trim().toLowerCase()
if (normalized === 'soft' || normalized === 'account') return normalized
throw new Error(`invalid isolation mode "${value}" (expected "soft" or "account")`)
}
/** Parse a deploy-mode value, rejecting anything outside `local`/`k8s`. */
function toDeployMode(value: string | undefined): DeployMode | undefined {
if (value === undefined) return undefined
const normalized = value.trim().toLowerCase()
if (normalized === 'local' || normalized === 'k8s') return normalized
throw new Error(`invalid deploy mode "${value}" (expected "local" or "k8s")`)
}
/**
* Fold argv/env overrides over defaults. `dataRoot` defaults to
* `~/.dshs` (always writable for dev); production sets
* `DSHS_DATA_ROOT=/var/lib/dshs`.
*/
export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
const dataRoot =
overrides.dataRoot ?? process.env.DSHS_DATA_ROOT ?? join(homedir(), '.dshs')
const port = overrides.port ?? process.env.DSHS_PORT ?? DEFAULT_PORT
const dshBin = process.env.DSHS_DSH_BIN
const isolationMode =
toIsolationMode(overrides.isolationMode) ??
toIsolationMode(process.env.DSHS_ISOLATION_MODE) ??
DEFAULT_ISOLATION_MODE
const deployMode =
toDeployMode(overrides.deployMode) ??
toDeployMode(process.env.DSHS_DEPLOY_MODE) ??
DEFAULT_DEPLOY_MODE
return {
host: overrides.host ?? DEFAULT_HOST,
port: typeof port === 'number' ? port : Number(port),
dbPath: overrides.dbPath ?? join(dataRoot, 'dshs.db'),
dbUrl: overrides.dbUrl ?? process.env.DSHS_DB_URL,
dataRoot,
bundledSkillDir:
overrides.bundledSkillDir ??
process.env.DSHS_BUNDLED_SKILL_DIR ??
join(dataRoot, 'bundled-skills'),
dshCommand: overrides.dshCommand ?? (dshBin !== undefined ? [dshBin] : DEFAULT_DSH_COMMAND),
logLevel: overrides.logLevel ?? DEFAULT_LOG_LEVEL,
secureCookies:
overrides.secureCookies ?? toBool(process.env.DSHS_SECURE_COOKIES, false),
sessionTtlSeconds: Number(
overrides.sessionTtlSeconds ?? process.env.DSHS_SESSION_TTL ?? DEFAULT_SESSION_TTL_SECONDS,
),
maxUploadBytes: Number(
overrides.maxUploadBytes ?? process.env.DSHS_MAX_UPLOAD ?? DEFAULT_MAX_UPLOAD_BYTES,
),
restartBackoffMs: Number(
overrides.restartBackoffMs ?? process.env.DSHS_RESTART_BACKOFF ?? DEFAULT_RESTART_BACKOFF_MS,
),
restartBackoffMaxMs: Number(
overrides.restartBackoffMaxMs ??
process.env.DSHS_RESTART_BACKOFF_MAX ??
DEFAULT_RESTART_BACKOFF_MAX_MS,
),
crashMaxRestarts: Number(
overrides.crashMaxRestarts ??
process.env.DSHS_CRASH_MAX_RESTARTS ??
DEFAULT_CRASH_MAX_RESTARTS,
),
crashWindowMs: Number(
overrides.crashWindowMs ?? process.env.DSHS_CRASH_WINDOW ?? DEFAULT_CRASH_WINDOW_MS,
),
crashStableMs: Number(
overrides.crashStableMs ?? process.env.DSHS_CRASH_STABLE ?? DEFAULT_CRASH_STABLE_MS,
),
crashBreakerCooldownMs: Number(
overrides.crashBreakerCooldownMs ??
process.env.DSHS_CRASH_BREAKER_COOLDOWN ??
DEFAULT_CRASH_BREAKER_COOLDOWN_MS,
),
crashBreakerMaxCooldownMs: Number(
overrides.crashBreakerMaxCooldownMs ??
process.env.DSHS_CRASH_BREAKER_MAX_COOLDOWN ??
DEFAULT_CRASH_BREAKER_MAX_COOLDOWN_MS,
),
isolationMode,
spawnAsUserCommand: overrides.spawnAsUserCommand ?? DEFAULT_SPAWN_AS_USER_COMMAND,
baseUid: Number(overrides.baseUid ?? process.env.DSHS_BASE_UID ?? DEFAULT_BASE_UID),
baseDomain: overrides.baseDomain ?? process.env.DSHS_BASE_DOMAIN ?? DEFAULT_BASE_DOMAIN,
cookieDomain: overrides.cookieDomain ?? process.env.DSHS_COOKIE_DOMAIN ?? DEFAULT_COOKIE_DOMAIN,
enablePatch: overrides.enablePatch ?? toBool(process.env.DSHS_ENABLE_PATCH, DEFAULT_ENABLE_PATCH),
portGuard: overrides.portGuard ?? toBool(process.env.DSHS_PORT_GUARD, false),
maxIdleInstances: Number(
overrides.maxIdleInstances ?? process.env.DSHS_MAX_IDLE_INSTANCES ?? DEFAULT_MAX_IDLE_INSTANCES,
),
instanceIdleTtlSeconds: Number(
overrides.instanceIdleTtlSeconds ??
process.env.DSHS_INSTANCE_IDLE_TTL ??
DEFAULT_INSTANCE_IDLE_TTL_SECONDS,
),
idleReapIntervalSeconds: Number(
overrides.idleReapIntervalSeconds ??
process.env.DSHS_IDLE_REAP_INTERVAL ??
DEFAULT_IDLE_REAP_INTERVAL_SECONDS,
),
encryptionSecret:
overrides.encryptionSecret ?? resolveEncryptionSecret(dataRoot),
deployMode,
k8sNamespace: overrides.k8sNamespace ?? process.env.DSHS_NAMESPACE ?? DEFAULT_K8S_NAMESPACE,
dshImage: overrides.dshImage ?? process.env.DSHS_DSH_IMAGE ?? '',
controlPlaneImage:
overrides.controlPlaneImage ?? process.env.DSHS_CONTROL_PLANE_IMAGE ?? '',
imagePullSecret:
overrides.imagePullSecret ?? process.env.DSHS_IMAGE_PULL_SECRET ?? DEFAULT_IMAGE_PULL_SECRET,
egressCidrs: overrides.egressCidrs ?? parseCidrs(process.env.DSHS_EGRESS_CIDRS),
k8sServiceAccount:
overrides.k8sServiceAccount ?? process.env.DSHS_K8S_SERVICE_ACCOUNT ?? DEFAULT_K8S_SERVICE_ACCOUNT,
podName: overrides.podName ?? process.env.POD_NAME ?? hostname(),
}
}
+35
View File
@@ -0,0 +1,35 @@
/**
* Symmetric encryption for per-user secrets at rest. AES-256-GCM with a
* key derived from the deployment secret, so a DB leak does not yield usable
* API keys (references-not-secrets).
* @module dshs/crypto
*/
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
const ALGO = 'aes-256-gcm'
const IV_LEN = 12
/** Derive a 32-byte AES key from the deployment secret (sha-256). */
export function deriveKey(secret: string): Buffer {
return createHash('sha256').update(secret).digest()
}
/** Encrypt a plaintext secret into a base64 payload `iv.tag.data`. */
export function encrypt(plain: string, key: Buffer): string {
const iv = randomBytes(IV_LEN)
const cipher = createCipheriv(ALGO, key, iv)
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return [iv.toString('base64'), tag.toString('base64'), enc.toString('base64')].join('.')
}
/** Decrypt a payload produced by {@link encrypt}. */
export function decrypt(payload: string, key: Buffer): string {
const parts = payload.split('.')
if (parts.length !== 3) throw new Error('malformed encrypted payload')
const [ivB, tagB, dataB] = parts as [string, string, string]
const decipher = createDecipheriv(ALGO, key, Buffer.from(ivB, 'base64'))
decipher.setAuthTag(Buffer.from(tagB, 'base64'))
return Buffer.concat([decipher.update(Buffer.from(dataB, 'base64')), decipher.final()]).toString('utf8')
}
+93
View File
@@ -0,0 +1,93 @@
/**
* The unified async DB interface. Routes depend only on this — never on
* `better-sqlite3` or `pg` directly — so the backend can switch between SQLite
* (`deployMode=local`) and Postgres (`deployMode=k8s`) without touching callers.
* @module dshs/db/adapter
*/
import type {
BusinessPlugin,
CredentialKey,
CreateSessionInput,
CreateUserInput,
Domain,
DshInstance,
DshInstanceRole,
DshInstanceStatus,
PublicUser,
SessionRow,
SessionUser,
UpsertBusinessPluginInput,
UpsertDshInstanceInput,
User,
UserRole,
Workspace,
} from './types.js'
export interface DbAdapter {
// users
createUser(input: CreateUserInput): Promise<User>
findUserByUsername(username: string): Promise<User | undefined>
findUserBySlug(slug: string): Promise<User | undefined>
findUserById(id: string): Promise<User | undefined>
listPublicUsers(): Promise<PublicUser[]>
countAdmins(): Promise<number>
setUserRole(id: string, role: UserRole, approvedBy?: string): Promise<boolean>
/** Assign a Linux uid to a user (used by the legacy backfill). */
setUserUid(userId: string, uid: number): Promise<void>
/** Ids of users whose uid column is still null (legacy rows awaiting backfill). */
listUsersWithoutUid(): Promise<string[]>
/**
* Permanently delete a user and every owned row (credential_vault / domains /
* sessions / dsh_instances / audit_log / folder_plugins / workspaces), inside
* one transaction. Returns false when no such user exists.
*/
deleteUser(userId: string): Promise<boolean>
// sessions
createSession(input: CreateSessionInput): Promise<void>
findSession(tokenHash: string): Promise<SessionRow | undefined>
deleteSession(tokenHash: string): Promise<void>
deleteUserSessions(userId: string): Promise<void>
findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined>
/** Whether the user has any session that has not yet expired (idle reap). */
hasActiveSession(userId: string): Promise<boolean>
// audit
audit(actor: string | null, action: string, detail?: string | null): Promise<void>
// workspaces / plugins
findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined>
getOrCreateWorkspace(userId: string, relPath: string): Promise<Workspace>
setFolderPlugins(workspaceId: string, selections: ReadonlyArray<{ id: string; enabled: boolean }>): Promise<void>
getEnabledPluginIds(workspaceId: string): Promise<string[]>
// business plugins (系统外插件候选池)
listBusinessPlugins(): Promise<BusinessPlugin[]>
findBusinessPlugin(id: string): Promise<BusinessPlugin | undefined>
upsertBusinessPlugin(input: UpsertBusinessPluginInput): Promise<BusinessPlugin>
deleteBusinessPlugin(id: string): Promise<boolean>
// domains
findDomainByUser(userId: string): Promise<Domain | undefined>
findDomainById(id: string): Promise<Domain | undefined>
listDomains(): Promise<Domain[]>
upsertDomain(userId: string, domain: string, nginxConfig: string): Promise<Domain>
setDomainVerified(id: string, verified: boolean): Promise<boolean>
// credential vault
listCredentialKeys(userId: string): Promise<CredentialKey[]>
getEnabledCredentialKeyRef(userId: string): Promise<string | null>
setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey>
selectCredentialKey(userId: string, id: string): Promise<boolean>
deleteCredentialKey(userId: string, id: string): Promise<boolean>
// instances (desired state the k8s controller reconciles against — docs/k8s.md §5.7)
upsertInstance(input: UpsertDshInstanceInput): Promise<void>
findInstance(id: string): Promise<DshInstance | undefined>
findUserInstance(userId: string, role: DshInstanceRole): Promise<DshInstance | undefined>
listInstancesByRole(role: DshInstanceRole): Promise<DshInstance[]>
/** Record a state transition; `exitCode`/`lastError` also stamp `last_exit`. */
setInstanceStatus(
id: string,
status: DshInstanceStatus,
outcome?: { exitCode?: number; lastError?: string },
): Promise<boolean>
deleteInstance(id: string): Promise<boolean>
deleteUserInstances(userId: string): Promise<void>
// lifecycle
close(): Promise<void>
}
+28
View File
@@ -0,0 +1,28 @@
/**
* SQLite connection lifecycle: open, WAL, foreign keys, migration.
* @module dshs/db/connection
*/
import Database from 'better-sqlite3'
import { mkdirSync } from 'node:fs'
import { dirname } from 'node:path'
import { runSqliteMigrations } from './schema.js'
/** The better-sqlite3 instance type. */
export type Database = Database.Database
/**
* Open (creating parent directories as needed), enable WAL + foreign keys,
* then run migrations.
* @param path - database file path, or `:memory:`.
*/
export function openDatabase(path: string): Database {
if (path !== ':memory:') {
mkdirSync(dirname(path), { recursive: true })
}
const db: Database = new Database(path)
db.pragma('journal_mode = WAL')
db.pragma('foreign_keys = ON')
runSqliteMigrations(db)
return db
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Driver-agnostic DB error hierarchy. Each adapter maps its driver's error
* codes onto these classes, so the route layer catches a single exception type
* regardless of backend (SQLite or Postgres).
* @module dshs/db/errors
*/
export class DbError extends Error {}
export class UniqueViolationError extends DbError {}
export class ForeignKeyViolationError extends DbError {}
/** Map a better-sqlite3 constraint error onto the shared hierarchy. */
export function mapSqliteError(e: unknown): never {
const code = (e as { code?: string }).code
if (code === 'SQLITE_CONSTRAINT_UNIQUE') throw new UniqueViolationError()
if (code === 'SQLITE_CONSTRAINT_FOREIGNKEY') throw new ForeignKeyViolationError()
throw e
}
/** Map a node-postgres error onto the shared hierarchy. */
export function mapPgError(e: unknown): never {
const code = (e as { code?: string }).code
if (code === '23505') throw new UniqueViolationError()
if (code === '23503') throw new ForeignKeyViolationError()
throw e
}
+31
View File
@@ -0,0 +1,31 @@
/**
* DB adapter factory. Picks the backend from config: Postgres when a
* `DSHS_DB_URL` is set (k8s / shared HA), else local SQLite.
* Also backfills legacy users' uids once (see docs/k8s.md §5.8).
* @module dshs/db
*/
import type { ServerConfig } from '../config.js'
import { hashUid } from '../isolation.js'
import type { DbAdapter } from './adapter.js'
import { SqliteAdapter } from './sqlite.js'
import { openPgAdapter } from './pg.js'
export type { DbAdapter } from './adapter.js'
export * from './types.js'
export { DbError, UniqueViolationError, ForeignKeyViolationError } from './errors.js'
/** Create the configured backend. SQLite is synchronous-open; Postgres is async. */
export async function createDbAdapter(config: ServerConfig): Promise<DbAdapter> {
const adapter =
config.dbUrl !== undefined
? await openPgAdapter(config.dbUrl, config.baseUid)
: new SqliteAdapter(config.dbPath, config.baseUid)
// Backfill rows created before the uid column with their stable hash uid so
// existing workspace file ownership is preserved. New users get `baseUid +
// row_id` (set inside createUser), which needs no backfill.
for (const userId of await adapter.listUsersWithoutUid()) {
await adapter.setUserUid(userId, hashUid(userId, config.baseUid))
}
return adapter
}
+508
View File
@@ -0,0 +1,508 @@
/**
* Postgres backend for {@link DbAdapter} via node-postgres (`pg`). Used when
* `deployMode=k8s` (a `DSHS_DB_URL` is set). All methods are
* genuinely async; transactions use {@link withTx}.
* @module dshs/db/pg
*/
import { randomUUID } from 'node:crypto'
import { Pool, types, type PoolClient } from 'pg'
import type { DbAdapter } from './adapter.js'
import { mapPgError } from './errors.js'
import { runPgMigrations } from './schema.js'
import {
toBusinessPlugin,
toDomain,
toDshInstance,
toPublicUser,
toSession,
toUser,
toWorkspace,
type BusinessPlugin,
type CredentialKey,
type CreateSessionInput,
type CreateUserInput,
type Domain,
type DshInstance,
type DshInstanceRole,
type DshInstanceStatus,
type PublicUser,
type SessionRow,
type SessionUser,
type UpsertBusinessPluginInput,
type UpsertDshInstanceInput,
type User,
type UserRole,
type Workspace,
} from './types.js'
// Postgres returns int8 (BIGINT) as a string to avoid JS 53-bit precision loss.
// Our only BIGINT columns are epoch-*milliseconds*, which stay well below 2^53,
// so parse them back to numbers — the shared row mappers then read numbers in
// both backends. This is process-global and idempotent.
types.setTypeParser(20, (value: string) => Number(value))
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid'
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
const INSTANCE_COLS =
'id, user_id, workspace_id, role, pid, port, status, started_at, last_exit, exit_code, last_error, folder, patch'
/** Run `fn` on a dedicated client inside a BEGIN/COMMIT/ROLLBACK transaction. */
export async function withTx<T>(pool: Pool, fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await pool.connect()
try {
await client.query('BEGIN')
const result = await fn(client)
await client.query('COMMIT')
return result
} catch (e) {
await client.query('ROLLBACK')
throw e
} finally {
client.release()
}
}
export class PgAdapter implements DbAdapter {
constructor(private readonly pool: Pool, private readonly baseUid: number) {}
async createUser(input: CreateUserInput): Promise<User> {
const createdAt = Date.now()
try {
return await withTx(this.pool, async (client) => {
const { rows } = await client.query(
'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING row_id',
[input.id, input.username, input.passHash, input.role, input.homeDir, createdAt],
)
const uid = this.baseUid + Number((rows[0] as { row_id: number }).row_id)
await client.query('UPDATE users SET uid = $1 WHERE id = $2', [uid, input.id])
return {
id: input.id,
username: input.username,
pass_hash: input.passHash,
role: input.role,
home_dir: input.homeDir,
api_key_ref: null,
created_at: createdAt,
approved_by: null,
uid,
}
})
} catch (e) {
mapPgError(e)
}
}
async findUserByUsername(username: string): Promise<User | undefined> {
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE username = $1`, [username])
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
}
async findUserBySlug(slug: string): Promise<User | undefined> {
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE LOWER(username) = $1`, [
slug.toLowerCase(),
])
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
}
async findUserById(id: string): Promise<User | undefined> {
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE id = $1`, [id])
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
}
async listPublicUsers(): Promise<PublicUser[]> {
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users ORDER BY created_at ASC`)
return rows.map((row) => toPublicUser(toUser(row as Record<string, unknown>)))
}
async countAdmins(): Promise<number> {
const { rows } = await this.pool.query(`SELECT COUNT(*) AS n FROM users WHERE role = 'admin'`)
return (rows[0] as { n: number }).n
}
async setUserRole(id: string, role: UserRole, approvedBy?: string): Promise<boolean> {
const result =
approvedBy === undefined
? await this.pool.query('UPDATE users SET role = $1 WHERE id = $2', [role, id])
: await this.pool.query('UPDATE users SET role = $1, approved_by = $2 WHERE id = $3', [role, approvedBy, id])
return (result.rowCount ?? 0) > 0
}
async setUserUid(userId: string, uid: number): Promise<void> {
await this.pool.query('UPDATE users SET uid = $1 WHERE id = $2', [uid, userId])
}
async listUsersWithoutUid(): Promise<string[]> {
const { rows } = await this.pool.query('SELECT id FROM users WHERE uid IS NULL')
return (rows as Array<{ id: string }>).map((row) => row.id)
}
async createSession(input: CreateSessionInput): Promise<void> {
try {
await this.pool.query(
'INSERT INTO sessions (token_hash, user_id, created_at, expires_at, ip, user_agent) VALUES ($1, $2, $3, $4, $5, $6)',
[input.tokenHash, input.userId, Date.now(), input.expiresAt, input.ip ?? null, input.userAgent ?? null],
)
} catch (e) {
mapPgError(e)
}
}
async findSession(tokenHash: string): Promise<SessionRow | undefined> {
const { rows } = await this.pool.query(
'SELECT token_hash, user_id, created_at, expires_at, ip, user_agent FROM sessions WHERE token_hash = $1',
[tokenHash],
)
return rows.length > 0 ? toSession(rows[0] as Record<string, unknown>) : undefined
}
async deleteSession(tokenHash: string): Promise<void> {
await this.pool.query('DELETE FROM sessions WHERE token_hash = $1', [tokenHash])
}
async deleteUserSessions(userId: string): Promise<void> {
await this.pool.query('DELETE FROM sessions WHERE user_id = $1', [userId])
}
async hasActiveSession(userId: string): Promise<boolean> {
const { rows } = await this.pool.query(
'SELECT 1 FROM sessions WHERE user_id = $1 AND expires_at > $2 LIMIT 1',
[userId, Date.now()],
)
return rows.length > 0
}
async findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> {
const { rows } = await this.pool.query(
`SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid,
s.expires_at
FROM sessions s JOIN users u ON s.user_id = u.id
WHERE s.token_hash = $1`,
[tokenHash],
)
if (rows.length === 0) return undefined
const row = rows[0] as Record<string, unknown>
return { expiresAt: row.expires_at as number, user: toUser(row) }
}
async audit(actor: string | null, action: string, detail?: string | null): Promise<void> {
await this.pool.query('INSERT INTO audit_log (ts, actor, action, detail) VALUES ($1, $2, $3, $4)', [
Date.now(),
actor,
action,
detail ?? null,
])
}
async findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined> {
const { rows } = await this.pool.query(
'SELECT id, user_id, name, rel_path, created_at FROM workspaces WHERE user_id = $1 AND rel_path = $2',
[userId, relPath],
)
return rows.length > 0 ? toWorkspace(rows[0] as Record<string, unknown>) : undefined
}
async getOrCreateWorkspace(userId: string, relPath: string): Promise<Workspace> {
const existing = await this.findWorkspaceByPath(userId, relPath)
if (existing !== undefined) return existing
const id = randomUUID()
const segments = relPath.split('/').filter(Boolean)
const name = segments.at(-1) ?? 'root'
try {
await this.pool.query(
'INSERT INTO workspaces (id, user_id, name, rel_path, created_at) VALUES ($1, $2, $3, $4, $5)',
[id, userId, name, relPath, Date.now()],
)
} catch (e) {
mapPgError(e)
}
return { id, userId, name, relPath, createdAt: Date.now() }
}
async setFolderPlugins(
workspaceId: string,
selections: ReadonlyArray<{ id: string; enabled: boolean }>,
): Promise<void> {
try {
await withTx(this.pool, async (client) => {
await client.query('DELETE FROM folder_plugins WHERE workspace_id = $1', [workspaceId])
for (const selection of selections) {
await client.query(
'INSERT INTO folder_plugins (workspace_id, plugin_id, enabled, updated_at) VALUES ($1, $2, $3, $4)',
[workspaceId, selection.id, selection.enabled ? 1 : 0, Date.now()],
)
}
})
} catch (e) {
mapPgError(e)
}
}
async getEnabledPluginIds(workspaceId: string): Promise<string[]> {
const { rows } = await this.pool.query(
'SELECT plugin_id FROM folder_plugins WHERE workspace_id = $1 AND enabled = 1',
[workspaceId],
)
return (rows as Array<{ plugin_id: string }>).map((row) => row.plugin_id)
}
async listBusinessPlugins(): Promise<BusinessPlugin[]> {
const { rows } = await this.pool.query(`SELECT ${BUSINESS_PLUGIN_COLS} FROM business_plugins ORDER BY name ASC`)
return rows.map((row) => toBusinessPlugin(row as Record<string, unknown>))
}
async findBusinessPlugin(id: string): Promise<BusinessPlugin | undefined> {
const { rows } = await this.pool.query(`SELECT ${BUSINESS_PLUGIN_COLS} FROM business_plugins WHERE id = $1`, [id])
return rows.length > 0 ? toBusinessPlugin(rows[0] as Record<string, unknown>) : undefined
}
async upsertBusinessPlugin(input: UpsertBusinessPluginInput): Promise<BusinessPlugin> {
await this.pool.query(
`
INSERT INTO business_plugins (id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
version = excluded.version,
tgz_path = excluded.tgz_path,
file_size = excluded.file_size,
uploaded_by = excluded.uploaded_by,
updated_at = excluded.updated_at
`,
[
input.id,
input.name,
input.description ?? null,
input.version ?? null,
input.tgzPath,
input.fileSize,
input.uploadedBy,
Date.now(),
],
)
return (await this.findBusinessPlugin(input.id))!
}
async deleteBusinessPlugin(id: string): Promise<boolean> {
const result = await this.pool.query('DELETE FROM business_plugins WHERE id = $1', [id])
return (result.rowCount ?? 0) > 0
}
async findDomainByUser(userId: string): Promise<Domain | undefined> {
const { rows } = await this.pool.query(`SELECT ${DOMAIN_COLS} FROM domains WHERE user_id = $1`, [userId])
return rows.length > 0 ? toDomain(rows[0] as Record<string, unknown>) : undefined
}
async findDomainById(id: string): Promise<Domain | undefined> {
const { rows } = await this.pool.query(`SELECT ${DOMAIN_COLS} FROM domains WHERE id = $1`, [id])
return rows.length > 0 ? toDomain(rows[0] as Record<string, unknown>) : undefined
}
async listDomains(): Promise<Domain[]> {
const { rows } = await this.pool.query(`SELECT ${DOMAIN_COLS} FROM domains ORDER BY updated_at DESC`)
return rows.map((row) => toDomain(row as Record<string, unknown>))
}
async upsertDomain(userId: string, domain: string, nginxConfig: string): Promise<Domain> {
try {
await this.pool.query(
`
INSERT INTO domains (id, user_id, domain, verified, nginx_config, updated_at)
VALUES ($1, $2, $3, 0, $4, $5)
ON CONFLICT(user_id) DO UPDATE SET
domain = excluded.domain,
verified = 0,
nginx_config = excluded.nginx_config,
updated_at = excluded.updated_at
`,
[randomUUID(), userId, domain, nginxConfig, Date.now()],
)
} catch (e) {
mapPgError(e)
}
return (await this.findDomainByUser(userId))!
}
async setDomainVerified(id: string, verified: boolean): Promise<boolean> {
const result = await this.pool.query('UPDATE domains SET verified = $1, updated_at = $2 WHERE id = $3', [
verified ? 1 : 0,
Date.now(),
id,
])
return (result.rowCount ?? 0) > 0
}
async listCredentialKeys(userId: string): Promise<CredentialKey[]> {
const { rows } = await this.pool.query(
'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = $1 ORDER BY updated_at DESC',
[userId],
)
return (rows as Array<{ id: string; key_name: string; enabled: number; updated_at: number }>).map((r) => ({
id: r.id,
name: r.key_name,
enabled: r.enabled === 1,
updatedAt: r.updated_at,
}))
}
async getEnabledCredentialKeyRef(userId: string): Promise<string | null> {
const { rows } = await this.pool.query(
'SELECT secret_ref FROM credential_vault WHERE user_id = $1 AND enabled = 1',
[userId],
)
const row = rows[0] as { secret_ref: string } | undefined
return row?.secret_ref ?? null
}
async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> {
try {
return await withTx(this.pool, async (client) => {
await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId])
const existing = await client.query(
'SELECT id FROM credential_vault WHERE user_id = $1 AND key_name = $2',
[userId, name],
)
let id: string
if (existing.rows.length > 0) {
id = (existing.rows[0] as { id: string }).id
await client.query('UPDATE credential_vault SET secret_ref = $1, enabled = 1, updated_at = $2 WHERE id = $3', [
encryptedRef,
Date.now(),
id,
])
} else {
id = randomUUID()
await client.query(
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES ($1, $2, $3, $4, 1, $5)',
[id, userId, name, encryptedRef, Date.now()],
)
}
return { id, name, enabled: true, updatedAt: Date.now() }
})
} catch (e) {
mapPgError(e)
}
}
async selectCredentialKey(userId: string, id: string): Promise<boolean> {
return await withTx(this.pool, async (client) => {
await client.query('UPDATE credential_vault SET enabled = 0 WHERE user_id = $1', [userId])
const result = await client.query('UPDATE credential_vault SET enabled = 1 WHERE id = $1 AND user_id = $2', [
id,
userId,
])
return (result.rowCount ?? 0) > 0
})
}
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
const result = await this.pool.query('DELETE FROM credential_vault WHERE id = $1 AND user_id = $2', [id, userId])
return (result.rowCount ?? 0) > 0
}
async deleteUser(userId: string): Promise<boolean> {
return await withTx(this.pool, async (client) => {
await client.query('DELETE FROM credential_vault WHERE user_id = $1', [userId])
await client.query('DELETE FROM domains WHERE user_id = $1', [userId])
await client.query('DELETE FROM sessions WHERE user_id = $1', [userId])
await client.query('DELETE FROM dsh_instances WHERE user_id = $1', [userId])
await client.query('DELETE FROM audit_log WHERE actor = $1', [userId])
await client.query(
'DELETE FROM folder_plugins WHERE workspace_id IN (SELECT id FROM workspaces WHERE user_id = $1)',
[userId],
)
await client.query('DELETE FROM workspaces WHERE user_id = $1', [userId])
const result = await client.query('DELETE FROM users WHERE id = $1', [userId])
return (result.rowCount ?? 0) > 0
})
}
async upsertInstance(input: UpsertDshInstanceInput): Promise<void> {
try {
await this.pool.query(
`INSERT INTO dsh_instances (id, user_id, workspace_id, role, pid, port, status, started_at, folder, patch)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT(id) DO UPDATE SET
workspace_id = excluded.workspace_id,
pid = excluded.pid,
port = excluded.port,
status = excluded.status,
started_at = excluded.started_at,
folder = excluded.folder,
patch = excluded.patch`,
[
input.id,
input.userId,
input.workspaceId ?? null,
input.role,
input.pid ?? null,
input.port ?? null,
input.status,
Date.now(),
input.folder ?? null,
input.patch ?? null,
],
)
} catch (e) {
mapPgError(e)
}
}
async findInstance(id: string): Promise<DshInstance | undefined> {
const { rows } = await this.pool.query(`SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE id = $1`, [id])
return rows.length > 0 ? toDshInstance(rows[0] as Record<string, unknown>) : undefined
}
async findUserInstance(userId: string, role: DshInstanceRole): Promise<DshInstance | undefined> {
const { rows } = await this.pool.query(
`SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE user_id = $1 AND role = $2`,
[userId, role],
)
return rows.length > 0 ? toDshInstance(rows[0] as Record<string, unknown>) : undefined
}
async listInstancesByRole(role: DshInstanceRole): Promise<DshInstance[]> {
const { rows } = await this.pool.query(
`SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE role = $1 ORDER BY started_at ASC`,
[role],
)
return rows.map((row) => toDshInstance(row as Record<string, unknown>))
}
async setInstanceStatus(
id: string,
status: DshInstanceStatus,
outcome?: { exitCode?: number; lastError?: string },
): Promise<boolean> {
const result =
outcome === undefined
? await this.pool.query('UPDATE dsh_instances SET status = $1 WHERE id = $2', [status, id])
: await this.pool.query(
'UPDATE dsh_instances SET status = $1, last_exit = $2, exit_code = $3, last_error = $4 WHERE id = $5',
[status, Date.now(), outcome.exitCode ?? null, outcome.lastError ?? null, id],
)
return (result.rowCount ?? 0) > 0
}
async deleteInstance(id: string): Promise<boolean> {
const result = await this.pool.query('DELETE FROM dsh_instances WHERE id = $1', [id])
return (result.rowCount ?? 0) > 0
}
async deleteUserInstances(userId: string): Promise<void> {
await this.pool.query('DELETE FROM dsh_instances WHERE user_id = $1', [userId])
}
async close(): Promise<void> {
await this.pool.end()
}
}
/** Open a Postgres adapter: connect, run migrations, then wrap the pool. */
export async function openPgAdapter(connectionString: string, baseUid: number): Promise<PgAdapter> {
const pool = new Pool({ connectionString })
await runPgMigrations(pool)
return new PgAdapter(pool, baseUid)
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Prepared-statement cache. better-sqlite3 does not cache `db.prepare`, so
* re-preparing the same SQL on every call re-parses it. This memoizes prepared
* statements per connection (WeakMap), which matters for hot paths such as
* authentication and the reverse proxy.
* @module dshs/db/prepared
*/
import type Database from 'better-sqlite3'
import type { Database as Db } from './connection.js'
type Statement = Database.Statement<unknown[], unknown>
const caches = new WeakMap<Db, Map<string, Statement>>()
/** Prepare (and cache) a statement for a connection. */
export function prepare(db: Db, sql: string): Statement {
let cache = caches.get(db)
if (cache === undefined) {
cache = new Map()
caches.set(db, cache)
}
let statement = cache.get(sql)
if (statement === undefined) {
statement = db.prepare(sql)
cache.set(sql, statement)
}
return statement
}
+451
View File
@@ -0,0 +1,451 @@
/**
* Synchronous SQLite data access. This is the raw layer behind
* {@link SqliteAdapter}; the route layer must never import these functions
* directly — it goes through {@link DbAdapter} so Postgres can be substituted.
*
* All access is parameterized (prepared statements). Functions take the
* connection explicitly so they stay free of Fastify/app state and testable.
* @module dshs/db/repo
*/
import { randomUUID } from 'node:crypto'
import type { Database } from './connection.js'
import { prepare } from './prepared.js'
import {
toBusinessPlugin,
toDomain,
toDshInstance,
toPublicUser,
toSession,
toUser,
toWorkspace,
type BusinessPlugin,
type CredentialKey,
type CreateSessionInput,
type CreateUserInput,
type Domain,
type DshInstance,
type DshInstanceRole,
type DshInstanceStatus,
type PublicUser,
type SessionRow,
type SessionUser,
type UpsertBusinessPluginInput,
type UpsertDshInstanceInput,
type User,
type UserRole,
type Workspace,
} from './types.js'
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid'
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
const INSTANCE_COLS =
'id, user_id, workspace_id, role, pid, port, status, started_at, last_exit, exit_code, last_error, folder, patch'
export function createUser(db: Database, input: CreateUserInput, baseUid: number): User {
const createdAt = Date.now()
return db.transaction((): User => {
const info = prepare(db,
'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES (?, ?, ?, ?, ?, ?)',
).run(input.id, input.username, input.passHash, input.role, input.homeDir, createdAt)
// SQLite's implicit rowid is the per-user incrementing integer; uid = baseUid + it.
const uid = baseUid + Number(info.lastInsertRowid)
prepare(db, 'UPDATE users SET uid = ? WHERE id = ?').run(uid, input.id)
return {
id: input.id,
username: input.username,
pass_hash: input.passHash,
role: input.role,
home_dir: input.homeDir,
api_key_ref: null,
created_at: createdAt,
approved_by: null,
uid,
}
})()
}
export function findUserByUsername(db: Database, username: string): User | undefined {
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE username = ?`).get(username)
return row ? toUser(row as Record<string, unknown>) : undefined
}
/** Case-insensitive username lookup (for subdomain routing). */
export function findUserBySlug(db: Database, slug: string): User | undefined {
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE LOWER(username) = ?`).get(slug.toLowerCase())
return row ? toUser(row as Record<string, unknown>) : undefined
}
export function findUserById(db: Database, id: string): User | undefined {
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE id = ?`).get(id)
return row ? toUser(row as Record<string, unknown>) : undefined
}
export function listPublicUsers(db: Database): PublicUser[] {
const rows = prepare(db, `SELECT ${USER_COLS} FROM users ORDER BY created_at ASC`).all() as Array<
Record<string, unknown>
>
return rows.map((row) => toPublicUser(toUser(row)))
}
export function countAdmins(db: Database): number {
const row = prepare(db, `SELECT COUNT(*) AS n FROM users WHERE role = 'admin'`).get() as { n: number }
return row.n
}
export function setUserRole(db: Database, id: string, role: UserRole, approvedBy?: string): boolean {
const info =
approvedBy === undefined
? prepare(db, 'UPDATE users SET role = ? WHERE id = ?').run(role, id)
: prepare(db, 'UPDATE users SET role = ?, approved_by = ? WHERE id = ?').run(role, approvedBy, id)
return info.changes > 0
}
export function createSession(db: Database, input: CreateSessionInput): void {
prepare(db,
'INSERT INTO sessions (token_hash, user_id, created_at, expires_at, ip, user_agent) VALUES (?, ?, ?, ?, ?, ?)',
).run(input.tokenHash, input.userId, Date.now(), input.expiresAt, input.ip ?? null, input.userAgent ?? null)
}
export function findSession(db: Database, tokenHash: string): SessionRow | undefined {
const row = prepare(db, 'SELECT token_hash, user_id, created_at, expires_at, ip, user_agent FROM sessions WHERE token_hash = ?')
.get(tokenHash)
return row ? toSession(row as Record<string, unknown>) : undefined
}
export function deleteSession(db: Database, tokenHash: string): void {
prepare(db, 'DELETE FROM sessions WHERE token_hash = ?').run(tokenHash)
}
export function deleteUserSessions(db: Database, userId: string): void {
prepare(db, 'DELETE FROM sessions WHERE user_id = ?').run(userId)
}
/** Append an audit entry. `actor` is a user id or `'system'`. */
export function audit(db: Database, actor: string | null, action: string, detail?: string | null): void {
prepare(db, 'INSERT INTO audit_log (ts, actor, action, detail) VALUES (?, ?, ?, ?)').run(
Date.now(),
actor,
action,
detail ?? null,
)
}
export function findWorkspaceByPath(db: Database, userId: string, relPath: string): Workspace | undefined {
const row = prepare(db, 'SELECT id, user_id, name, rel_path, created_at FROM workspaces WHERE user_id = ? AND rel_path = ?')
.get(userId, relPath)
return row ? toWorkspace(row as Record<string, unknown>) : undefined
}
/** Upsert a workspace row by (user, relPath); create with a derived name. */
export function getOrCreateWorkspace(db: Database, userId: string, relPath: string): Workspace {
const existing = findWorkspaceByPath(db, userId, relPath)
if (existing !== undefined) return existing
const id = randomUUID()
const segments = relPath.split('/').filter(Boolean)
const name = segments.at(-1) ?? 'root'
prepare(db, 'INSERT INTO workspaces (id, user_id, name, rel_path, created_at) VALUES (?, ?, ?, ?, ?)').run(
id,
userId,
name,
relPath,
Date.now(),
)
return { id, userId, name, relPath, createdAt: Date.now() }
}
/** Replace a workspace's plugin selection (insert/delete in one transaction). */
export function setFolderPlugins(
db: Database,
workspaceId: string,
selections: ReadonlyArray<{ id: string; enabled: boolean }>,
): void {
const tx = db.transaction(() => {
prepare(db, 'DELETE FROM folder_plugins WHERE workspace_id = ?').run(workspaceId)
const insert = prepare(db,
'INSERT INTO folder_plugins (workspace_id, plugin_id, enabled, updated_at) VALUES (?, ?, ?, ?)',
)
for (const selection of selections) {
insert.run(workspaceId, selection.id, selection.enabled ? 1 : 0, Date.now())
}
})
tx()
}
/** Enabled plugin ids for a workspace. */
export function getEnabledPluginIds(db: Database, workspaceId: string): string[] {
const rows = prepare(db, 'SELECT plugin_id FROM folder_plugins WHERE workspace_id = ? AND enabled = 1')
.all(workspaceId) as Array<{ plugin_id: string }>
return rows.map((row) => row.plugin_id)
}
export function findDomainByUser(db: Database, userId: string): Domain | undefined {
const row = prepare(db, `SELECT ${DOMAIN_COLS} FROM domains WHERE user_id = ?`).get(userId)
return row ? toDomain(row as Record<string, unknown>) : undefined
}
export function findDomainById(db: Database, id: string): Domain | undefined {
const row = prepare(db, `SELECT ${DOMAIN_COLS} FROM domains WHERE id = ?`).get(id)
return row ? toDomain(row as Record<string, unknown>) : undefined
}
export function listDomains(db: Database): Domain[] {
const rows = prepare(db, `SELECT ${DOMAIN_COLS} FROM domains ORDER BY updated_at DESC`).all() as Array<
Record<string, unknown>
>
return rows.map((row) => toDomain(row))
}
/** Upsert a user's custom domain (resetting `verified` to 0). */
export function upsertDomain(db: Database, userId: string, domain: string, nginxConfig: string): Domain {
prepare(db, `
INSERT INTO domains (id, user_id, domain, verified, nginx_config, updated_at)
VALUES (?, ?, ?, 0, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
domain = excluded.domain,
verified = 0,
nginx_config = excluded.nginx_config,
updated_at = excluded.updated_at
`).run(randomUUID(), userId, domain, nginxConfig, Date.now())
return findDomainByUser(db, userId)!
}
/** List a user's named credential keys (metadata only). */
export function listCredentialKeys(db: Database, userId: string): CredentialKey[] {
const rows = prepare(
db,
'SELECT id, key_name, enabled, updated_at FROM credential_vault WHERE user_id = ? ORDER BY updated_at DESC',
).all(userId) as Array<{ id: string; key_name: string; enabled: number; updated_at: number }>
return rows.map((r) => ({ id: r.id, name: r.key_name, enabled: r.enabled === 1, updatedAt: r.updated_at }))
}
/** The enabled key's encrypted ref for a user (decrypt with the deployment secret). */
export function getEnabledCredentialKeyRef(db: Database, userId: string): string | null {
const row = prepare(db, 'SELECT secret_ref FROM credential_vault WHERE user_id = ? AND enabled = 1').get(userId) as
| { secret_ref: string }
| undefined
return row?.secret_ref ?? null
}
/** Upsert a named key, disable the others, and enable this one. */
export function setCredentialKey(db: Database, userId: string, name: string, encryptedRef: string): CredentialKey {
const id = db.transaction(() => {
prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId)
const existing = prepare(db, 'SELECT id FROM credential_vault WHERE user_id = ? AND key_name = ?').get(
userId,
name,
) as { id: string } | undefined
if (existing !== undefined) {
prepare(db, 'UPDATE credential_vault SET secret_ref = ?, enabled = 1, updated_at = ? WHERE id = ?').run(
encryptedRef,
Date.now(),
existing.id,
)
return existing.id
}
const id = randomUUID()
prepare(
db,
'INSERT INTO credential_vault (id, user_id, key_name, secret_ref, enabled, updated_at) VALUES (?, ?, ?, ?, 1, ?)',
).run(id, userId, name, encryptedRef, Date.now())
return id
})()
return { id, name, enabled: true, updatedAt: Date.now() }
}
/** Enable one of a user's named keys (disabling the others). */
export function selectCredentialKey(db: Database, userId: string, id: string): boolean {
const ok = db.transaction(() => {
prepare(db, 'UPDATE credential_vault SET enabled = 0 WHERE user_id = ?').run(userId)
const info = prepare(db, 'UPDATE credential_vault SET enabled = 1 WHERE id = ? AND user_id = ?').run(id, userId)
return info.changes > 0
})()
return ok as boolean
}
/** Delete a named key (by id, scoped to the user). */
export function deleteCredentialKey(db: Database, userId: string, id: string): boolean {
const info = prepare(db, 'DELETE FROM credential_vault WHERE id = ? AND user_id = ?').run(id, userId)
return info.changes > 0
}
/**
* Permanently delete a user and all owned rows. Child tables are removed
* explicitly (in dependency order) rather than relying on FK cascade, so the
* cleanup works even when `PRAGMA foreign_keys` is off. Returns false when the
* user does not exist.
*/
export function deleteUser(db: Database, userId: string): boolean {
const deleted = db.transaction(() => {
prepare(db, 'DELETE FROM credential_vault WHERE user_id = ?').run(userId)
prepare(db, 'DELETE FROM domains WHERE user_id = ?').run(userId)
prepare(db, 'DELETE FROM sessions WHERE user_id = ?').run(userId)
prepare(db, 'DELETE FROM dsh_instances WHERE user_id = ?').run(userId)
prepare(db, 'DELETE FROM audit_log WHERE actor = ?').run(userId)
prepare(
db,
'DELETE FROM folder_plugins WHERE workspace_id IN (SELECT id FROM workspaces WHERE user_id = ?)',
).run(userId)
prepare(db, 'DELETE FROM workspaces WHERE user_id = ?').run(userId)
const info = prepare(db, 'DELETE FROM users WHERE id = ?').run(userId)
return info.changes > 0
})()
return deleted as boolean
}
/** Set the verified flag on a domain. */
export function setDomainVerified(db: Database, id: string, verified: boolean): boolean {
const info = prepare(db, 'UPDATE domains SET verified = ?, updated_at = ? WHERE id = ?')
.run(verified ? 1 : 0, Date.now(), id)
return info.changes > 0
}
/** Whether any of a user's sessions is still unexpired. */
export function hasActiveSession(db: Database, userId: string): boolean {
const row = prepare(db, 'SELECT 1 FROM sessions WHERE user_id = ? AND expires_at > ? LIMIT 1')
.get(userId, Date.now()) as { 1: number } | undefined
return row !== undefined
}
/** Look up a session and its user in a single join. */
export function findSessionWithUser(db: Database, tokenHash: string): SessionUser | undefined {
const row = prepare(
db,
`SELECT u.id, u.username, u.pass_hash, u.role, u.home_dir, u.api_key_ref, u.created_at, u.approved_by, u.uid,
s.expires_at
FROM sessions s JOIN users u ON s.user_id = u.id
WHERE s.token_hash = ?`,
).get(tokenHash) as Record<string, unknown> | undefined
if (row === undefined) return undefined
return { expiresAt: row.expires_at as number, user: toUser(row) }
}
/** Assign a Linux uid to a user (legacy backfill). */
export function setUserUid(db: Database, userId: string, uid: number): void {
prepare(db, 'UPDATE users SET uid = ? WHERE id = ?').run(uid, userId)
}
/** Ids of users whose uid is still null (legacy rows awaiting backfill). */
export function listUsersWithoutUid(db: Database): string[] {
const rows = prepare(db, 'SELECT id FROM users WHERE uid IS NULL').all() as Array<{ id: string }>
return rows.map((row) => row.id)
}
/** Record (or re-record) an instance's desired state. Keyed on the caller's
* deterministic id, so a relaunch overwrites rather than duplicating. */
export function upsertInstance(db: Database, input: UpsertDshInstanceInput): void {
prepare(db, `
INSERT INTO dsh_instances (id, user_id, workspace_id, role, pid, port, status, started_at, folder, patch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
workspace_id = excluded.workspace_id,
pid = excluded.pid,
port = excluded.port,
status = excluded.status,
started_at = excluded.started_at,
folder = excluded.folder,
patch = excluded.patch
`).run(
input.id,
input.userId,
input.workspaceId ?? null,
input.role,
input.pid ?? null,
input.port ?? null,
input.status,
Date.now(),
input.folder ?? null,
input.patch ?? null,
)
}
export function findInstance(db: Database, id: string): DshInstance | undefined {
const row = prepare(db, `SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE id = ?`).get(id)
return row ? toDshInstance(row as Record<string, unknown>) : undefined
}
export function findUserInstance(db: Database, userId: string, role: DshInstanceRole): DshInstance | undefined {
const row = prepare(db, `SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE user_id = ? AND role = ?`).get(userId, role)
return row ? toDshInstance(row as Record<string, unknown>) : undefined
}
export function listInstancesByRole(db: Database, role: DshInstanceRole): DshInstance[] {
const rows = prepare(db, `SELECT ${INSTANCE_COLS} FROM dsh_instances WHERE role = ? ORDER BY started_at ASC`)
.all(role) as Array<Record<string, unknown>>
return rows.map((row) => toDshInstance(row))
}
/** Record a state transition; an `outcome` also stamps `last_exit`. */
export function setInstanceStatus(
db: Database,
id: string,
status: DshInstanceStatus,
outcome?: { exitCode?: number; lastError?: string },
): boolean {
const info =
outcome === undefined
? prepare(db, 'UPDATE dsh_instances SET status = ? WHERE id = ?').run(status, id)
: prepare(db, 'UPDATE dsh_instances SET status = ?, last_exit = ?, exit_code = ?, last_error = ? WHERE id = ?')
.run(status, Date.now(), outcome.exitCode ?? null, outcome.lastError ?? null, id)
return info.changes > 0
}
export function deleteInstance(db: Database, id: string): boolean {
const info = prepare(db, 'DELETE FROM dsh_instances WHERE id = ?').run(id)
return info.changes > 0
}
export function deleteUserInstances(db: Database, userId: string): void {
prepare(db, 'DELETE FROM dsh_instances WHERE user_id = ?').run(userId)
}
// ── business plugins (系统外插件候选池) ────────────────────────────────
export function listBusinessPlugins(db: Database): BusinessPlugin[] {
const rows = prepare(db, `SELECT ${BUSINESS_PLUGIN_COLS} FROM business_plugins ORDER BY name ASC`).all() as Array<
Record<string, unknown>
>
return rows.map((row) => toBusinessPlugin(row))
}
export function findBusinessPlugin(db: Database, id: string): BusinessPlugin | undefined {
const row = prepare(db, `SELECT ${BUSINESS_PLUGIN_COLS} FROM business_plugins WHERE id = ?`).get(id)
return row ? toBusinessPlugin(row as Record<string, unknown>) : undefined
}
/**
* Upsert a candidate-pool row keyed on the bundle package name (`id`). Same-name
* upload REPLACES the row — the caller removes the previous tgz file first so no
* stale artifact survives.
*/
export function upsertBusinessPlugin(db: Database, input: UpsertBusinessPluginInput): BusinessPlugin {
prepare(db, `
INSERT INTO business_plugins (id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
description = excluded.description,
version = excluded.version,
tgz_path = excluded.tgz_path,
file_size = excluded.file_size,
uploaded_by = excluded.uploaded_by,
updated_at = excluded.updated_at
`).run(
input.id,
input.name,
input.description ?? null,
input.version ?? null,
input.tgzPath,
input.fileSize,
input.uploadedBy,
Date.now(),
Date.now(),
)
return findBusinessPlugin(db, input.id)!
}
export function deleteBusinessPlugin(db: Database, id: string): boolean {
const info = prepare(db, 'DELETE FROM business_plugins WHERE id = ?').run(id)
return info.changes > 0
}
+328
View File
@@ -0,0 +1,328 @@
/**
* Schema migrations, dual-dialect. Each migration carries SQLite and Postgres
* DDL; the active adapter runs only its own dialect. `schema_migrations` is
* shared (same table shape), so a SQLite↔Postgres dump/restore round-trips the
* applied-version marker too.
*
* Dialect notes (kept out of the route layer):
* - timestamps are epoch **milliseconds** (Date.now()), which exceeds 32-bit
* `INTEGER`; SQLite `INTEGER` is 64-bit, Postgres uses `BIGINT`.
* - `enabled`/`verified` are `INTEGER 0/1` in *both* dialects so the row mappers
* stay byte-identical across backends (no boolean/0/1 branch).
* - `audit_log.id` uses SQLite `AUTOINCREMENT` vs Postgres `IDENTITY`.
* @module dshs/db/schema
*/
import type { Database } from './connection.js'
import type { Pool } from 'pg'
const SQLITE_V1 = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
pass_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'pending'
CHECK (role IN ('admin','pending','active','disabled')),
home_dir TEXT NOT NULL,
api_key_ref TEXT,
created_at INTEGER NOT NULL,
approved_by TEXT REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
ip TEXT,
user_agent TEXT
);
-- 【2026-09-11 档案 19 §C5 便宜版】该表仅被 enablePatch 分支使用
-- (src/web/routes/dsh.ts 的 findWorkspaceByPath),而 DEFAULT_ENABLE_PATCH=false 且生产 env 未覆盖
-- → 本部署不可达。保留以兼容 k8s 路径;请勿在此表上新增功能。
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
rel_path TEXT NOT NULL,
created_at INTEGER NOT NULL,
UNIQUE (user_id, rel_path)
);
-- 【2026-09-11 档案 19 §C5 便宜版】该表在本部署已废弃:folder_plugins 无任何业务/路由调用
-- (grep 实证:仅 src/db/* 自引用;档案 16 已宣布 folder 级插件废弃)。
-- 保留表结构仅为兼容 k8s/PG 未验证路径(档案 19 §C8)——请勿在此表上新增功能。
CREATE TABLE IF NOT EXISTS folder_plugins (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
plugin_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
description TEXT,
updated_at INTEGER NOT NULL,
PRIMARY KEY (workspace_id, plugin_id)
);
CREATE TABLE IF NOT EXISTS dsh_instances (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT REFERENCES workspaces(id),
role TEXT NOT NULL CHECK (role IN ('main','watchdog')),
pid INTEGER,
port INTEGER,
status TEXT NOT NULL
CHECK (status IN ('starting','running','crashed','repairing','stopped')),
started_at INTEGER,
last_exit INTEGER,
exit_code INTEGER,
last_error TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL,
actor TEXT,
action TEXT NOT NULL,
detail TEXT
);
CREATE TABLE IF NOT EXISTS domains (
id TEXT PRIMARY KEY,
user_id TEXT UNIQUE REFERENCES users(id),
domain TEXT NOT NULL,
verified INTEGER NOT NULL DEFAULT 0,
nginx_config TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS credential_vault (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
key_name TEXT NOT NULL,
secret_ref TEXT NOT NULL,
updated_at INTEGER NOT NULL,
UNIQUE (user_id, key_name)
);
`
const SQLITE_V2 = `
ALTER TABLE credential_vault ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;
`
const PG_V1 = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
pass_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'pending'
CHECK (role IN ('admin','pending','active','disabled')),
home_dir TEXT NOT NULL,
api_key_ref TEXT,
created_at BIGINT NOT NULL,
approved_by TEXT REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
ip TEXT,
user_agent TEXT
);
-- 【2026-09-11 档案 19 §C5 便宜版】该表仅被 enablePatch 分支使用
-- (src/web/routes/dsh.ts 的 findWorkspaceByPath),而 DEFAULT_ENABLE_PATCH=false 且生产 env 未覆盖
-- → 本部署不可达。保留以兼容 k8s 路径;请勿在此表上新增功能。
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
rel_path TEXT NOT NULL,
created_at BIGINT NOT NULL,
UNIQUE (user_id, rel_path)
);
-- 【2026-09-11 档案 19 §C5 便宜版】该表在本部署已废弃:folder_plugins 无任何业务/路由调用
-- (grep 实证:仅 src/db/* 自引用;档案 16 已宣布 folder 级插件废弃)。
-- 保留表结构仅为兼容 k8s/PG 未验证路径(档案 19 §C8)——请勿在此表上新增功能。
CREATE TABLE IF NOT EXISTS folder_plugins (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
plugin_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
description TEXT,
updated_at BIGINT NOT NULL,
PRIMARY KEY (workspace_id, plugin_id)
);
CREATE TABLE IF NOT EXISTS dsh_instances (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
workspace_id TEXT REFERENCES workspaces(id),
role TEXT NOT NULL CHECK (role IN ('main','watchdog')),
pid INTEGER,
port INTEGER,
status TEXT NOT NULL
CHECK (status IN ('starting','running','crashed','repairing','stopped')),
started_at BIGINT,
last_exit BIGINT,
exit_code INTEGER,
last_error TEXT
);
CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
ts BIGINT NOT NULL,
actor TEXT,
action TEXT NOT NULL,
detail TEXT
);
CREATE TABLE IF NOT EXISTS domains (
id TEXT PRIMARY KEY,
user_id TEXT UNIQUE REFERENCES users(id),
domain TEXT NOT NULL,
verified INTEGER NOT NULL DEFAULT 0,
nginx_config TEXT,
updated_at BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS credential_vault (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
key_name TEXT NOT NULL,
secret_ref TEXT NOT NULL,
updated_at BIGINT NOT NULL,
UNIQUE (user_id, key_name)
);
`
const PG_V2 = `
ALTER TABLE credential_vault ADD COLUMN enabled INTEGER NOT NULL DEFAULT 0;
`
// v3: per-user Linux uid (non-colliding for new users via an identity column).
// SQLite reuses its implicit `rowid` for the incrementing integer, so only the
// `uid` column is added here; Postgres adds an explicit identity `row_id`.
const SQLITE_V3 = `
ALTER TABLE users ADD COLUMN uid INTEGER;
`
const PG_V3 = `
ALTER TABLE users ADD COLUMN row_id BIGINT GENERATED ALWAYS AS IDENTITY;
ALTER TABLE users ADD COLUMN uid BIGINT;
`
// v4: desired-state columns on `dsh_instances`. The table has existed since v1
// but was never read or written; the k8s controller uses it as the reconcile
// target (docs/k8s.md §5.7), which needs the launch folder and the rendered
// Cordis patch to rebuild a Pod that went missing.
const SQLITE_V4 = `
ALTER TABLE dsh_instances ADD COLUMN folder TEXT;
ALTER TABLE dsh_instances ADD COLUMN patch TEXT;
`
const PG_V4 = `
ALTER TABLE dsh_instances ADD COLUMN folder TEXT;
ALTER TABLE dsh_instances ADD COLUMN patch TEXT;
`
// v5: business-plugin candidate pool (系统外插件 = 功能插件). Admin uploads a
// tgz bundle into the pool; users enable it per-instance from the dsh settings
// section (档案 16). Same-name upload REPLACES the row (not overwrite — the old
// tgz file is removed first, so deleted files cannot survive).
const SQLITE_V5 = `
CREATE TABLE IF NOT EXISTS business_plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
version TEXT,
tgz_path TEXT NOT NULL,
file_size INTEGER NOT NULL,
uploaded_by TEXT REFERENCES users(id),
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
`
const PG_V5 = `
CREATE TABLE IF NOT EXISTS business_plugins (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
version TEXT,
tgz_path TEXT NOT NULL,
file_size BIGINT NOT NULL,
uploaded_by TEXT REFERENCES users(id),
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL
);
`
interface Migration {
version: number
name: string
sqlite: string
pg: string
}
const MIGRATIONS: readonly Migration[] = [
{ version: 1, name: 'initial schema', sqlite: SQLITE_V1, pg: PG_V1 },
{ version: 2, name: 'credential vault enabled flag', sqlite: SQLITE_V2, pg: PG_V2 },
{ version: 3, name: 'per-user uid', sqlite: SQLITE_V3, pg: PG_V3 },
{ version: 4, name: 'instance desired state', sqlite: SQLITE_V4, pg: PG_V4 },
{ version: 5, name: 'business plugin candidate pool', sqlite: SQLITE_V5, pg: PG_V5 },
]
/** Apply unapplied SQLite migrations inside a single transaction. */
export function runSqliteMigrations(db: Database): void {
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at INTEGER NOT NULL
);
`)
const rows = db.prepare('SELECT version FROM schema_migrations').all() as Array<{ version: number }>
const applied = new Set(rows.map((row) => row.version))
const apply = db.transaction(() => {
for (const migration of MIGRATIONS) {
if (applied.has(migration.version)) continue
db.exec(migration.sqlite)
db.prepare('INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)').run(
migration.version,
Date.now(),
)
}
})
apply()
}
/** Apply unapplied Postgres migrations inside a single transaction. */
export async function runPgMigrations(pool: Pool): Promise<void> {
const client = await pool.connect()
try {
await client.query('BEGIN')
await client.query(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at BIGINT NOT NULL
);
`)
const { rows } = await client.query('SELECT version FROM schema_migrations')
const applied = new Set(rows.map((row) => row.version as number))
for (const migration of MIGRATIONS) {
if (applied.has(migration.version)) continue
await client.query(migration.pg)
await client.query('INSERT INTO schema_migrations (version, applied_at) VALUES ($1, $2)', [
migration.version,
Date.now(),
])
}
await client.query('COMMIT')
} catch (e) {
await client.query('ROLLBACK')
throw e
} finally {
client.release()
}
}
+295
View File
@@ -0,0 +1,295 @@
/**
* SQLite backend for {@link DbAdapter}. Wraps the synchronous better-sqlite3
* repo in async methods and maps constraint errors onto the shared hierarchy.
* Used when `deployMode=local` (no `DSHS_DB_URL`).
*
* NOTE: better-sqlite3 is synchronous; the `async` here only matches the
* interface — the underlying calls still block the event loop. Fine for the
* small single-machine local mode this backend targets (see docs/k8s.md §5.1).
* @module dshs/db/sqlite
*/
import type { DbAdapter } from './adapter.js'
import { openDatabase, type Database } from './connection.js'
import { mapSqliteError } from './errors.js'
import {
audit as auditSync,
countAdmins as countAdminsSync,
createSession as createSessionSync,
createUser as createUserSync,
deleteBusinessPlugin as deleteBusinessPluginSync,
deleteCredentialKey as deleteCredentialKeySync,
deleteInstance as deleteInstanceSync,
deleteSession as deleteSessionSync,
deleteUser as deleteUserSync,
deleteUserInstances as deleteUserInstancesSync,
deleteUserSessions as deleteUserSessionsSync,
findBusinessPlugin as findBusinessPluginSync,
findDomainById as findDomainByIdSync,
findDomainByUser as findDomainByUserSync,
findInstance as findInstanceSync,
findSession as findSessionSync,
findSessionWithUser as findSessionWithUserSync,
hasActiveSession as hasActiveSessionSync,
findUserById as findUserByIdSync,
findUserBySlug as findUserBySlugSync,
findUserByUsername as findUserByUsernameSync,
findUserInstance as findUserInstanceSync,
findWorkspaceByPath as findWorkspaceByPathSync,
getEnabledCredentialKeyRef as getEnabledCredentialKeyRefSync,
getEnabledPluginIds as getEnabledPluginIdsSync,
getOrCreateWorkspace as getOrCreateWorkspaceSync,
listBusinessPlugins as listBusinessPluginsSync,
listCredentialKeys as listCredentialKeysSync,
listDomains as listDomainsSync,
listInstancesByRole as listInstancesByRoleSync,
listPublicUsers as listPublicUsersSync,
listUsersWithoutUid as listUsersWithoutUidSync,
selectCredentialKey as selectCredentialKeySync,
setCredentialKey as setCredentialKeySync,
setDomainVerified as setDomainVerifiedSync,
setFolderPlugins as setFolderPluginsSync,
setInstanceStatus as setInstanceStatusSync,
setUserRole as setUserRoleSync,
setUserUid as setUserUidSync,
upsertBusinessPlugin as upsertBusinessPluginSync,
upsertDomain as upsertDomainSync,
upsertInstance as upsertInstanceSync,
} from './repo.js'
import type {
BusinessPlugin,
CredentialKey,
CreateSessionInput,
CreateUserInput,
Domain,
DshInstance,
DshInstanceRole,
DshInstanceStatus,
PublicUser,
SessionRow,
SessionUser,
UpsertBusinessPluginInput,
UpsertDshInstanceInput,
User,
UserRole,
Workspace,
} from './types.js'
export class SqliteAdapter implements DbAdapter {
private readonly db: Database
constructor(path: string, private readonly baseUid: number) {
this.db = openDatabase(path)
}
async createUser(input: CreateUserInput): Promise<User> {
try {
return createUserSync(this.db, input, this.baseUid)
} catch (e) {
mapSqliteError(e)
}
}
async findUserByUsername(username: string): Promise<User | undefined> {
return findUserByUsernameSync(this.db, username)
}
async findUserBySlug(slug: string): Promise<User | undefined> {
return findUserBySlugSync(this.db, slug)
}
async findUserById(id: string): Promise<User | undefined> {
return findUserByIdSync(this.db, id)
}
async listPublicUsers(): Promise<PublicUser[]> {
return listPublicUsersSync(this.db)
}
async countAdmins(): Promise<number> {
return countAdminsSync(this.db)
}
async setUserRole(id: string, role: UserRole, approvedBy?: string): Promise<boolean> {
return setUserRoleSync(this.db, id, role, approvedBy)
}
async setUserUid(userId: string, uid: number): Promise<void> {
setUserUidSync(this.db, userId, uid)
}
async listUsersWithoutUid(): Promise<string[]> {
return listUsersWithoutUidSync(this.db)
}
async createSession(input: CreateSessionInput): Promise<void> {
try {
createSessionSync(this.db, input)
} catch (e) {
mapSqliteError(e)
}
}
async findSession(tokenHash: string): Promise<SessionRow | undefined> {
return findSessionSync(this.db, tokenHash)
}
async deleteSession(tokenHash: string): Promise<void> {
deleteSessionSync(this.db, tokenHash)
}
async deleteUserSessions(userId: string): Promise<void> {
deleteUserSessionsSync(this.db, userId)
}
async findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> {
return findSessionWithUserSync(this.db, tokenHash)
}
async hasActiveSession(userId: string): Promise<boolean> {
return hasActiveSessionSync(this.db, userId)
}
async audit(actor: string | null, action: string, detail?: string | null): Promise<void> {
auditSync(this.db, actor, action, detail)
}
async findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined> {
return findWorkspaceByPathSync(this.db, userId, relPath)
}
async getOrCreateWorkspace(userId: string, relPath: string): Promise<Workspace> {
try {
return getOrCreateWorkspaceSync(this.db, userId, relPath)
} catch (e) {
mapSqliteError(e)
}
}
async setFolderPlugins(
workspaceId: string,
selections: ReadonlyArray<{ id: string; enabled: boolean }>,
): Promise<void> {
try {
setFolderPluginsSync(this.db, workspaceId, selections)
} catch (e) {
mapSqliteError(e)
}
}
async getEnabledPluginIds(workspaceId: string): Promise<string[]> {
return getEnabledPluginIdsSync(this.db, workspaceId)
}
async listBusinessPlugins(): Promise<BusinessPlugin[]> {
return listBusinessPluginsSync(this.db)
}
async findBusinessPlugin(id: string): Promise<BusinessPlugin | undefined> {
return findBusinessPluginSync(this.db, id)
}
async upsertBusinessPlugin(input: UpsertBusinessPluginInput): Promise<BusinessPlugin> {
try {
return upsertBusinessPluginSync(this.db, input)
} catch (e) {
mapSqliteError(e)
}
}
async deleteBusinessPlugin(id: string): Promise<boolean> {
return deleteBusinessPluginSync(this.db, id)
}
async findDomainByUser(userId: string): Promise<Domain | undefined> {
return findDomainByUserSync(this.db, userId)
}
async findDomainById(id: string): Promise<Domain | undefined> {
return findDomainByIdSync(this.db, id)
}
async listDomains(): Promise<Domain[]> {
return listDomainsSync(this.db)
}
async upsertDomain(userId: string, domain: string, nginxConfig: string): Promise<Domain> {
try {
return upsertDomainSync(this.db, userId, domain, nginxConfig)
} catch (e) {
mapSqliteError(e)
}
}
async setDomainVerified(id: string, verified: boolean): Promise<boolean> {
return setDomainVerifiedSync(this.db, id, verified)
}
async listCredentialKeys(userId: string): Promise<CredentialKey[]> {
return listCredentialKeysSync(this.db, userId)
}
async getEnabledCredentialKeyRef(userId: string): Promise<string | null> {
return getEnabledCredentialKeyRefSync(this.db, userId)
}
async setCredentialKey(userId: string, name: string, encryptedRef: string): Promise<CredentialKey> {
try {
return setCredentialKeySync(this.db, userId, name, encryptedRef)
} catch (e) {
mapSqliteError(e)
}
}
async selectCredentialKey(userId: string, id: string): Promise<boolean> {
return selectCredentialKeySync(this.db, userId, id)
}
async deleteCredentialKey(userId: string, id: string): Promise<boolean> {
return deleteCredentialKeySync(this.db, userId, id)
}
async deleteUser(userId: string): Promise<boolean> {
return deleteUserSync(this.db, userId)
}
async upsertInstance(input: UpsertDshInstanceInput): Promise<void> {
try {
upsertInstanceSync(this.db, input)
} catch (e) {
mapSqliteError(e)
}
}
async findInstance(id: string): Promise<DshInstance | undefined> {
return findInstanceSync(this.db, id)
}
async findUserInstance(userId: string, role: DshInstanceRole): Promise<DshInstance | undefined> {
return findUserInstanceSync(this.db, userId, role)
}
async listInstancesByRole(role: DshInstanceRole): Promise<DshInstance[]> {
return listInstancesByRoleSync(this.db, role)
}
async setInstanceStatus(
id: string,
status: DshInstanceStatus,
outcome?: { exitCode?: number; lastError?: string },
): Promise<boolean> {
return setInstanceStatusSync(this.db, id, status, outcome)
}
async deleteInstance(id: string): Promise<boolean> {
return deleteInstanceSync(this.db, id)
}
async deleteUserInstances(userId: string): Promise<void> {
deleteUserInstancesSync(this.db, userId)
}
async close(): Promise<void> {
this.db.close()
}
}
+239
View File
@@ -0,0 +1,239 @@
/**
* Domain types shared by both DB backends (SQLite and Postgres). Kept free of
* any driver so the `DbAdapter` implementations and the route layer depend only
* on these shapes, never on `better-sqlite3` or `pg`.
* @module dshs/db/types
*/
export type UserRole = 'admin' | 'pending' | 'active' | 'disabled'
/** A full user row, including secrets (never serialized to clients). */
export interface User {
id: string
username: string
pass_hash: string
role: UserRole
home_dir: string
api_key_ref: string | null
created_at: number
approved_by: string | null
/** Assigned Linux uid; null until backfilled/assigned (legacy rows). */
uid: number | null
}
/** The user shape safe to return over the wire. */
export interface PublicUser {
id: string
username: string
role: UserRole
createdAt: number
}
/** A persisted login session (token stored only as its hash). */
export interface SessionRow {
token_hash: string
user_id: string
created_at: number
expires_at: number
ip: string | null
user_agent: string | null
}
/** A session joined with its user, for the authn hot path (one query). */
export interface SessionUser {
expiresAt: number
user: User
}
/** A per-user project folder (workspace) row. */
export interface Workspace {
id: string
userId: string
name: string
relPath: string
createdAt: number
}
/** A custom-domain row. */
export interface Domain {
id: string
userId: string
domain: string
verified: number
nginxConfig: string | null
updatedAt: number
}
/** Which half of a user's DSH pair a `dsh_instances` row describes. */
export type DshInstanceRole = 'main' | 'watchdog'
/** Lifecycle state of a `dsh_instances` row (mirrors the column's CHECK). */
export type DshInstanceStatus = 'starting' | 'running' | 'crashed' | 'repairing' | 'stopped'
/**
* A persisted DSH instance — the **desired** state the k8s controller reconciles
* the cluster against (docs/k8s.md §5.7). `folder` and `patch` are what a
* relaunch needs; `pid`/`port` are local-mode only and stay null under k8s.
*/
export interface DshInstance {
id: string
userId: string
workspaceId: string | null
role: DshInstanceRole
pid: number | null
port: number | null
status: DshInstanceStatus
startedAt: number | null
lastExit: number | null
exitCode: number | null
lastError: string | null
folder: string | null
/** Rendered Cordis patch content (not a path — the control plane holds no user volume). */
patch: string | null
}
/** A named per-user credential key (secret never exposed). */
export interface CredentialKey {
id: string
name: string
enabled: boolean
updatedAt: number
}
/** A business-plugin (系统外插件) candidate-pool row. `id` = bundle package name. */
export interface BusinessPlugin {
id: string
name: string
description: string | null
version: string | null
tgzPath: string
fileSize: number
uploadedBy: string | null
createdAt: number
updatedAt: number
}
export interface UpsertBusinessPluginInput {
id: string
name: string
description?: string | null
version?: string | null
tgzPath: string
fileSize: number
uploadedBy: string | null
}
export interface CreateUserInput {
id: string
username: string
passHash: string
role: UserRole
homeDir: string
}
export interface CreateSessionInput {
tokenHash: string
userId: string
expiresAt: number
ip?: string
userAgent?: string
}
/** Upsert payload for `dsh_instances`; `id` is the deterministic resource name. */
export interface UpsertDshInstanceInput {
id: string
userId: string
role: DshInstanceRole
status: DshInstanceStatus
folder?: string
patch?: string
workspaceId?: string
pid?: number
port?: number
}
export function toPublicUser(user: User): PublicUser {
return { id: user.id, username: user.username, role: user.role, createdAt: user.created_at }
}
// Row mappers. Shared by both adapters — they read the same column names, so the
// only dialect difference (SQLite 64-bit INTEGER vs Postgres BIGINT→number) is
// resolved by the Postgres int8 parser before these run.
export function toUser(row: Record<string, unknown>): User {
return {
id: row.id as string,
username: row.username as string,
pass_hash: row.pass_hash as string,
role: row.role as UserRole,
home_dir: row.home_dir as string,
api_key_ref: (row.api_key_ref as string | null) ?? null,
created_at: row.created_at as number,
approved_by: (row.approved_by as string | null) ?? null,
uid: (row.uid as number | null) ?? null,
}
}
export function toSession(row: Record<string, unknown>): SessionRow {
return {
token_hash: row.token_hash as string,
user_id: row.user_id as string,
created_at: row.created_at as number,
expires_at: row.expires_at as number,
ip: (row.ip as string | null) ?? null,
user_agent: (row.user_agent as string | null) ?? null,
}
}
export function toWorkspace(row: Record<string, unknown>): Workspace {
return {
id: row.id as string,
userId: row.user_id as string,
name: row.name as string,
relPath: row.rel_path as string,
createdAt: row.created_at as number,
}
}
export function toDshInstance(row: Record<string, unknown>): DshInstance {
return {
id: row.id as string,
userId: row.user_id as string,
workspaceId: (row.workspace_id as string | null) ?? null,
role: row.role as DshInstanceRole,
pid: (row.pid as number | null) ?? null,
port: (row.port as number | null) ?? null,
status: row.status as DshInstanceStatus,
startedAt: (row.started_at as number | null) ?? null,
lastExit: (row.last_exit as number | null) ?? null,
exitCode: (row.exit_code as number | null) ?? null,
lastError: (row.last_error as string | null) ?? null,
folder: (row.folder as string | null) ?? null,
patch: (row.patch as string | null) ?? null,
}
}
export function toDomain(row: Record<string, unknown>): Domain {
return {
id: row.id as string,
userId: row.user_id as string,
domain: row.domain as string,
verified: row.verified as number,
nginxConfig: (row.nginx_config as string | null) ?? null,
updatedAt: row.updated_at as number,
}
}
export function toBusinessPlugin(row: Record<string, unknown>): BusinessPlugin {
return {
id: row.id as string,
name: row.name as string,
description: (row.description as string | null) ?? null,
version: (row.version as string | null) ?? null,
tgzPath: row.tgz_path as string,
fileSize: row.file_size as number,
uploadedBy: (row.uploaded_by as string | null) ?? null,
createdAt: row.created_at as number,
updatedAt: row.updated_at as number,
}
}
+167
View File
@@ -0,0 +1,167 @@
/**
* HTTP {@link UserFs}: every file operation is delegated to the user's own file
* sidecar (docs/k8s.md §4.10), because the control plane runs as uid 65532 with
* no users volume and could not touch a `0700` user directory even if it did.
*
* The sidecar is addressed through its per-user Headless Service. That DNS A
* record has a ~30s TTL, so a Pod that was just rebuilt can still resolve to
* its old IP — connection-level failures therefore drop the keep-alive pool and
* retry once, mirroring what the DSH proxy does (docs/k8s.md §5.4).
* @module dshs/fs/k8s-user-fs
*/
import { Agent, request as httpRequest } from 'node:http'
import type { Endpoint } from '../supervisor/spawner.js'
import { POSIX, PathEscapeError, resolveWithinRoot } from '../web/middleware/fs-guard.js'
import type { PluginInfo } from './plugins.js'
import { isUserFsErrorCode, UserFsError, type UserFs } from './user-fs.js'
import { HOME_DIR, USERS_DIR, WORKSPACE_DIR, type FsEntry } from './workspace.js'
/** Data root inside every per-user Pod (mirrors `k8s-spawner`'s POD_DATA_ROOT). */
const POD_DATA_ROOT = '/var/lib/dshs'
/** Errors that mean "the connection never got anywhere" — worth one retry
* against a freshly resolved address. */
const RETRYABLE = new Set(['ECONNREFUSED', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'EHOSTUNREACH', 'ETIMEDOUT'])
/** Ensure the user's file sidecar exists and is ready; supplied by the spawner. */
export type EnsureFileService = (userId: string) => Promise<void>
interface Reply {
status: number
body: unknown
}
export class K8sUserFs implements UserFs {
private agent = new Agent({ keepAlive: true, maxSockets: 16 })
/**
* @param ensureFileService - brings the sidecar up before the first call.
* @param endpointFor - the sidecar's address; the k8s backend supplies the
* per-user Headless Service DNS, tests supply a loopback bind.
*/
constructor(
private readonly ensureFileService: EnsureFileService,
private readonly endpointFor: (userId: string) => Endpoint,
) {}
async initUserRoot(userId: string): Promise<void> {
// Creating the sidecar Pod *is* the initialization: its init container
// builds `<pvc>/<userId>/{ws,home}` as the user's own uid (docs/k8s.md §4.9).
await this.ensureFileService(userId)
await this.call(userId, 'POST', '/fs/init')
}
resolvePath(userId: string, relPath: string): string {
const root = `${POD_DATA_ROOT}/${USERS_DIR}/${userId}/${WORKSPACE_DIR}`
try {
return resolveWithinRoot(root, relPath, POSIX)
} catch (err) {
if (err instanceof PathEscapeError) throw new UserFsError('bad_path')
throw err
}
}
/** The user's DSH state directory inside their Pod. */
homePath(userId: string): string {
return `${POD_DATA_ROOT}/${USERS_DIR}/${userId}/${HOME_DIR}`
}
async listDir(userId: string, relPath: string): Promise<FsEntry[]> {
const body = await this.call(userId, 'GET', `/fs/tree?path=${encodeURIComponent(relPath)}`)
return (body as { entries: FsEntry[] }).entries
}
async mkdir(userId: string, relPath: string): Promise<void> {
await this.call(userId, 'POST', '/fs/mkdir', { path: relPath })
}
async createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise<string> {
const body = await this.call(userId, 'POST', '/fs/create', { path: relPath, name, type })
return (body as { name: string }).name
}
async upload(userId: string, relPath: string, name: string, data: Buffer): Promise<string> {
const body = await this.call(userId, 'POST', '/fs/upload', {
path: relPath,
name,
data: data.toString('base64'),
})
return (body as { name: string }).name
}
/** 档案 56:k8s 路径未验证 —— sidecar 尚无 read 端点,明确报 `unsupported` 而非静默失败。 */
async readFile(): Promise<{ name: string; data: Buffer }> {
throw new UserFsError('unsupported')
}
async isDirectory(userId: string, relPath: string): Promise<boolean> {
const body = await this.call(userId, 'GET', `/fs/stat?path=${encodeURIComponent(relPath)}`)
return (body as { isDirectory: boolean }).isDirectory
}
async listInstalledPlugins(userId: string): Promise<PluginInfo[]> {
const body = await this.call(userId, 'GET', '/fs/plugins')
return (body as { plugins: PluginInfo[] }).plugins
}
async writeHandoff(userId: string, content: string): Promise<void> {
await this.call(userId, 'POST', '/fs/handoff', { content })
}
/** One sidecar call: ensure the Pod, send, retry once on a dead connection,
* then translate a non-2xx `{error}` back into a {@link UserFsError}. */
private async call(userId: string, method: string, path: string, payload?: unknown): Promise<unknown> {
await this.ensureFileService(userId)
let reply: Reply
try {
reply = await this.send(userId, method, path, payload)
} catch (err) {
const code = (err as NodeJS.ErrnoException).code
if (code === undefined || !RETRYABLE.has(code)) throw err
// The keep-alive pool may hold sockets to a Pod IP that no longer exists;
// drop them so the retry re-resolves the Headless Service.
this.agent.destroy()
this.agent = new Agent({ keepAlive: true, maxSockets: 16 })
reply = await this.send(userId, method, path, payload)
}
if (reply.status >= 200 && reply.status < 300) return reply.body
const error = (reply.body as { error?: unknown }).error
if (typeof error === 'string' && isUserFsErrorCode(error)) throw new UserFsError(error)
throw new Error(`file sidecar for ${userId} returned ${reply.status}`)
}
private send(userId: string, method: string, path: string, payload?: unknown): Promise<Reply> {
const body = payload === undefined ? undefined : JSON.stringify(payload)
const endpoint = this.endpointFor(userId)
return new Promise<Reply>((resolve, reject) => {
const req = httpRequest(
{
host: endpoint.host,
port: endpoint.port,
path,
method,
agent: this.agent,
headers: body === undefined
? {}
: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
},
(res) => {
const chunks: Buffer[] = []
res.on('data', (chunk: Buffer) => chunks.push(chunk))
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8')
try {
resolve({ status: res.statusCode ?? 502, body: text === '' ? {} : JSON.parse(text) })
} catch {
reject(new Error(`file sidecar for ${userId} returned non-JSON (${res.statusCode})`))
}
})
},
)
req.on('error', reject)
if (body !== undefined) req.write(body)
req.end()
})
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* Direct-filesystem {@link UserFs}: the control plane owns the users volume and
* touches it in-process. This is the `deployMode=local` implementation, and it
* is also what the per-user file sidecar runs behind its HTTP surface — the
* sidecar is just a `LocalUserFs` pinned to one user's root.
* @module dshs/fs/local-user-fs
*/
import { chownSync, chmodSync } from 'node:fs'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { stat } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { PathEscapeError, resolveWithinRoot, safeFilename } from '../web/middleware/fs-guard.js'
import { listInstalledPlugins, type PluginInfo } from './plugins.js'
import { UserFsError, type UserFs } from './user-fs.js'
import { ensureDir, handoffPath, homeRoot, listDir, rejectSymlinkEscape, workspaceRoot, type FsEntry } from './workspace.js'
/** Resolve a user id to that user's data root (`<dataRoot>/users/<id>`, or a
* fixed directory when the sidecar serves exactly one user). */
export type UserRootResolver = (userId: string) => string
/** Map a Node errno onto the wire code the desktop already handles. */
function fsError(err: unknown, onMissing: 'not_found' | 'parent_missing'): never {
const code = (err as NodeJS.ErrnoException).code
if (code === 'EEXIST') throw new UserFsError('exists')
if (code === 'ENOENT' || code === 'ENOTDIR') throw new UserFsError(onMissing)
throw err
}
export class LocalUserFs implements UserFs {
constructor(private readonly rootFor: UserRootResolver) {}
async initUserRoot(userId: string, uid?: number): Promise<void> {
const root = this.rootFor(userId)
ensureDir(homeRoot(root))
ensureDir(workspaceRoot(root))
// The roots were created by the (possibly root) control plane; the DSH child
// runs as the user's own uid and must be able to traverse + write them.
// chown the user root + home/ws so the child can mkdir profiles/, etc.
if (uid !== undefined && typeof process.getuid === 'function' && process.getuid() === 0) {
chownSync(root, uid, uid)
chownSync(homeRoot(root), uid, uid)
chownSync(workspaceRoot(root), uid, uid)
// 0700 + sticky-ish owner only; keep group/other off.
chmodSync(homeRoot(root), 0o700)
chmodSync(workspaceRoot(root), 0o700)
}
}
resolvePath(userId: string, relPath: string): string {
return this.resolve(userId, relPath)
}
async listDir(userId: string, relPath: string): Promise<FsEntry[]> {
const abs = this.resolve(userId, relPath)
await this.assertNoLinks(userId, abs)
try {
return await listDir(abs)
} catch (err) {
fsError(err, 'not_found')
}
}
async mkdir(userId: string, relPath: string): Promise<void> {
const abs = this.resolve(userId, relPath)
await this.assertNoLinks(userId, abs)
try {
await mkdir(abs)
} catch (err) {
fsError(err, 'parent_missing')
}
}
async createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise<string> {
const dirAbs = this.resolve(userId, relPath)
const filename = this.sanitize(name)
const target = join(dirAbs, filename)
await this.assertNoLinks(userId, target)
try {
if (type === 'dir') await mkdir(target)
else await writeFile(target, '')
} catch (err) {
fsError(err, 'parent_missing')
}
return filename
}
async upload(userId: string, relPath: string, name: string, data: Buffer): Promise<string> {
const dirAbs = this.resolve(userId, relPath)
const filename = this.sanitize(name)
const target = join(dirAbs, filename)
await this.assertNoLinks(userId, target)
try {
await writeFile(target, data)
} catch (err) {
fsError(err, 'parent_missing')
}
return filename
}
/** 档案 56:读取工作区内的**文件**(门户/实例页「我的文件」下载用)。 */
async readFile(
userId: string,
relPath: string,
maxBytes = 32 * 1024 * 1024,
): Promise<{ name: string; data: Buffer }> {
const abs = this.resolve(userId, relPath)
await this.assertNoLinks(userId, abs)
let st
try {
st = await stat(abs)
} catch (err) {
fsError(err, 'not_found')
}
if (st.isDirectory()) throw new UserFsError('not_a_file')
if (st.size > maxBytes) throw new UserFsError('too_large')
try {
return { name: basename(abs), data: await readFile(abs) }
} catch (err) {
fsError(err, 'not_found')
}
}
async isDirectory(userId: string, relPath: string): Promise<boolean> {
const abs = this.resolve(userId, relPath)
await this.assertNoLinks(userId, abs)
try {
return (await stat(abs)).isDirectory()
} catch (err) {
fsError(err, 'not_found')
}
}
async listInstalledPlugins(userId: string): Promise<PluginInfo[]> {
return listInstalledPlugins(this.rootFor(userId))
}
async writeHandoff(userId: string, content: string): Promise<void> {
const root = this.rootFor(userId)
ensureDir(root)
await writeFile(handoffPath(root), content)
}
/** Resolve a workspace-relative path, self-healing the root the way the
* pre-seam routes did (every one of them called `ensureWorkspaceRoot` first). */
private resolve(userId: string, relPath: string): string {
const ws = workspaceRoot(this.rootFor(userId))
ensureDir(ws)
try {
return resolveWithinRoot(ws, relPath)
} catch (err) {
if (err instanceof PathEscapeError) throw new UserFsError('bad_path')
throw err
}
}
/** The lexical guard above is prefix-only and blind to links planted inside
* the workspace, so every operation re-walks its final absolute path (the
* write target for upload/createEntry) and rejects any symlink component —
* `writeFile`/`mkdir` would otherwise follow it outside the root with this
* process's privileges. Surfaces as the usual `bad_path` wire code. */
private async assertNoLinks(userId: string, abs: string): Promise<void> {
try {
await rejectSymlinkEscape(workspaceRoot(this.rootFor(userId)), abs)
} catch (err) {
if (err instanceof PathEscapeError) throw new UserFsError('bad_path')
throw err
}
}
private sanitize(name: string): string {
try {
return safeFilename(name)
} catch (err) {
if (err instanceof PathEscapeError) throw new UserFsError('bad_name')
throw err
}
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Per-user plugin discovery: read the resident DSH profile's `dsh.profile.bundles`
* and surface user-installed bundles, filtering out installation-owned
* `@deepseek-ai/*` bundles. Name/description come from each bundle's own
* package.json.
* @module dshs/fs/plugins
*/
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { homeRoot } from './workspace.js'
/** A plugin the user may enable per folder (`id` doubles as the package name). */
export interface PluginInfo {
id: string
name: string
description: string
}
/** Profile name the resident main DSH is launched with (see supervisor). */
export const MAIN_PROFILE = 'web'
/** Bundles under this scope come from the dsh installation, not the user. */
const INSTALLATION_SCOPE = '@deepseek-ai/'
/** Absolute profile directory within a user root (`<root>/home/profiles/web`). */
function profileDir(root: string): string {
return join(homeRoot(root), 'profiles', MAIN_PROFILE)
}
/** Read a package.json `description`, tolerating a missing/empty field or file. */
function readDescription(manifestPath: string): string {
try {
const parsed = JSON.parse(readFileSync(manifestPath, 'utf8')) as { description?: unknown }
return typeof parsed.description === 'string' ? parsed.description : ''
} catch {
return ''
}
}
/**
* List a user's installed plugins from their profile's `dsh.profile.bundles`,
* minus installation-owned bundles. Returns `[]` when the profile (or a listed
* package) has not been installed yet. Order follows the bundle layer order.
* @param root - the user's data root (see {@link userRoot}).
*/
export function listInstalledPlugins(root: string): PluginInfo[] {
const dir = profileDir(root)
let manifest: { dsh?: { profile?: { bundles?: string[] } } }
try {
manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
} catch {
return []
}
const bundles = manifest.dsh?.profile?.bundles ?? []
const plugins: PluginInfo[] = []
for (const packageName of bundles) {
if (packageName.startsWith(INSTALLATION_SCOPE)) continue
plugins.push({
id: packageName,
name: packageName,
description: readDescription(join(dir, 'node_modules', packageName, 'package.json')),
})
}
return plugins
}
+37
View File
@@ -0,0 +1,37 @@
/**
* {@link UserFs} factory. Picks the implementation from `deployMode`, the same
* way {@link createDbAdapter} picks a DB backend and `buildServer` picks a
* {@link Spawner}.
* @module dshs/fs/provider
*/
import type { ServerConfig } from '../config.js'
import type { Endpoint } from '../supervisor/spawner.js'
import { FILE_SERVICE_PORT } from '../web/file-service.js'
import { K8sUserFs, type EnsureFileService } from './k8s-user-fs.js'
import { LocalUserFs } from './local-user-fs.js'
import type { UserFs } from './user-fs.js'
import { userRoot } from './workspace.js'
/** Headless Service fronting a user's file sidecar (docs/k8s.md §4.10). */
export function fileServiceName(userId: string): string {
return `dsh-files-${userId}`
}
/** In-cluster address of a user's file sidecar. */
export function fileServiceEndpoint(namespace: string, userId: string): Endpoint {
return { host: `${fileServiceName(userId)}.${namespace}.svc.cluster.local`, port: FILE_SERVICE_PORT }
}
/**
* Build the configured per-user filesystem.
*
* `local` touches the users volume in-process. `k8s` delegates to each user's
* file sidecar, which the spawner brings up on demand via `ensureFileService`.
*/
export function createUserFs(config: ServerConfig, ensureFileService?: EnsureFileService): UserFs {
if (config.deployMode === 'k8s' && ensureFileService !== undefined) {
return new K8sUserFs(ensureFileService, (userId) => fileServiceEndpoint(config.k8sNamespace, userId))
}
return new LocalUserFs((userId) => userRoot(config.dataRoot, userId))
}
+91
View File
@@ -0,0 +1,91 @@
/**
* The per-user filesystem seam (docs/k8s.md §4.10 / §6.0-1).
*
* Under `local` the control plane owns the users volume and touches it directly
* ({@link LocalUserFs}). Under `k8s` it must not: it runs as uid 65532 while
* each user's directory is `0700` owned by that user's uid, so every file
* operation is delegated over HTTP to a per-user file sidecar
* ({@link K8sUserFs}). Routes depend only on this interface.
*
* All paths crossing this interface are **workspace-relative**; each
* implementation resolves them against its own root via `resolveWithinRoot`.
* @module dshs/fs/user-fs
*/
import type { PluginInfo } from './plugins.js'
import type { FsEntry } from './workspace.js'
/** Wire-level failure codes. These are the exact `{error}` values the desktop
* UI already switches on, so they survive the control-plane ↔ sidecar hop. */
export type UserFsErrorCode =
| 'bad_path'
| 'bad_name'
| 'not_found'
| 'exists'
| 'parent_missing'
| 'not_a_folder'
// 档案 56:下载/查看文件
| 'not_a_file'
| 'too_large'
// 该实现不支持(如 k8s sidecar 尚无 read 端点,档案 19 §C8 未验证路径)
| 'unsupported'
/** HTTP status each code maps to (unchanged from the pre-seam routes). */
const STATUS: Record<UserFsErrorCode, number> = {
bad_path: 400,
bad_name: 400,
not_found: 404,
exists: 409,
parent_missing: 404,
not_a_folder: 400,
not_a_file: 400,
too_large: 413,
unsupported: 501,
}
/**
* A filesystem failure already reduced to its wire form. Routes rethrow it as
* `reply.code(err.status).send({ error: err.code })` without inspecting errno,
* which is what lets the k8s implementation rebuild it from a sidecar response.
*/
export class UserFsError extends Error {
readonly status: number
constructor(readonly code: UserFsErrorCode) {
super(code)
this.name = 'UserFsError'
this.status = STATUS[code]
}
}
/** True when `code` is one this seam knows how to represent. */
export function isUserFsErrorCode(code: string): code is UserFsErrorCode {
return code in STATUS
}
/** Per-user filesystem operations, as the route layer needs them. */
export interface UserFs {
/** Create the user's home/workspace roots (`0700`). Idempotent.
* `uid` (when provided) makes local mode chown the roots to the user's Linux
* uid — the DSH child runs as that uid and would otherwise hit EACCES writing
* `home/` (directories are created by the root control plane). */
initUserRoot(userId: string, uid?: number): Promise<void>
/** Absolute path of `relPath` **as the user's DSH process sees it** (pure path
* math — the in-Pod mount path under k8s, the host path under local). */
resolvePath(userId: string, relPath: string): string
listDir(userId: string, relPath: string): Promise<FsEntry[]>
mkdir(userId: string, relPath: string): Promise<void>
/** Create a file or directory under `relPath`; returns the sanitized name. */
createEntry(userId: string, relPath: string, name: string, type: 'file' | 'dir'): Promise<string>
/** Write `data` as `name` under `relPath`; returns the sanitized name. */
upload(userId: string, relPath: string, name: string, data: Buffer): Promise<string>
/** Whether `relPath` is a directory; throws `not_found` when absent. */
isDirectory(userId: string, relPath: string): Promise<boolean>
/** Read a workspace-relative **file** (档案 56:供门户/实例页「我的文件」下载)。
* 目录 → `not_a_file`;超过 `maxBytes` → `too_large`。
* 注:k8s 实现目前抛 `unsupported`(sidecar 尚无 read 端点,属档案 19 §C8 未验证路径)。 */
readFile(userId: string, relPath: string, maxBytes?: number): Promise<{ name: string; data: Buffer }>
listInstalledPlugins(userId: string): Promise<PluginInfo[]>
/** Write the post-restart command handoff the watchdog reads. */
writeHandoff(userId: string, content: string): Promise<void>
}
+105
View File
@@ -0,0 +1,105 @@
/**
* Per-user filesystem layout + low-level helpers. This module is the single
* place that knows the `users/<id>/{home,ws}` shape — the control plane, the
* local spawner, the k8s Pod spec, and the file sidecar all derive their paths
* from here so the four never drift apart.
*
* Isolation is enforced by the caller (see fs-guard).
* @module dshs/fs/workspace
*/
import { mkdirSync } from 'node:fs'
import { lstat, readdir, stat } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import { PathEscapeError } from '../web/middleware/fs-guard.js'
// Layout segment names. Exported so the k8s Pod spec can build the same layout
// with POSIX separators (`join` would emit backslashes when the control plane
// is developed/tested on Windows) without duplicating the literals.
export const USERS_DIR = 'users'
export const WORKSPACE_DIR = 'ws'
export const HOME_DIR = 'home'
export const HANDOFF_FILE = 'handoff.json'
/** A user's own data root (`<dataRoot>/users/<id>`). */
export function userRoot(dataRoot: string, userId: string): string {
return join(dataRoot, USERS_DIR, userId)
}
/** The workspace (user-visible files) within a user root. */
export function workspaceRoot(root: string): string {
return join(root, WORKSPACE_DIR)
}
/** DSH's own state (profiles/sessions/credentials) within a user root. */
export function homeRoot(root: string): string {
return join(root, HOME_DIR)
}
/** The watchdog command handoff within a user root. */
export function handoffPath(root: string): string {
return join(root, HANDOFF_FILE)
}
/** Create a directory (0700) if it does not exist. */
export function ensureDir(path: string): void {
mkdirSync(path, { recursive: true, mode: 0o700 })
}
/**
* Reject `abs` when any path component between `root` and `abs` (inclusive) is
* a symbolic link. The lexical guard (`resolveWithinRoot`) cannot see links
* planted INSIDE the workspace: an upload through `ws/link -> /etc` passes the
* prefix check and lands outside the user's root with the caller's privileges.
* The desktop surface never needs symlinks, so they are forbidden here; the
* user's DSH process keeps its normal kernel view of links.
*
* Only components strictly below `root` are inspected — the data root itself
* may legitimately sit behind a link. A missing (or not-a-directory) component
* ends the walk: nothing can exist below it, and the caller's own open/stat
* will surface that as its usual errno.
*/
export async function rejectSymlinkEscape(root: string, abs: string): Promise<void> {
const rel = relative(root, abs)
if (rel === '') return // abs IS the root
if (rel.startsWith('..')) throw new PathEscapeError(abs, root)
let current = root
for (const segment of rel.split(sep)) {
if (segment === '' || segment === '.') continue
current = join(current, segment)
try {
const stats = await lstat(current)
if (stats.isSymbolicLink()) throw new PathEscapeError(abs, root)
} catch (err) {
if (err instanceof PathEscapeError) throw err
const code = (err as NodeJS.ErrnoException).code
// Missing/not-a-directory component: nothing below it can exist either.
if (code === 'ENOENT' || code === 'ENOTDIR') return
throw err
}
}
}
/** One filesystem entry as returned to the desktop. */
export interface FsEntry {
name: string
type: 'file' | 'dir'
size: number
mtimeMs: number
}
/** List the immediate children of a directory (async, non-blocking). */
export async function listDir(absPath: string): Promise<FsEntry[]> {
const entries = await readdir(absPath, { withFileTypes: true })
const result: FsEntry[] = []
for (const entry of entries) {
const st = await stat(join(absPath, entry.name))
result.push({
name: entry.name,
type: st.isDirectory() ? 'dir' : 'file',
size: st.isFile() ? st.size : 0,
mtimeMs: st.mtimeMs,
})
}
return result
}
+26
View File
@@ -0,0 +1,26 @@
/**
* Cordis entry used only for marketplace recognition.
*
* The real product is the standalone `dshs` binary (see
* `./cli.js`), which runs its own Fastify server and spawns per-user DSH
* processes. This `apply` is a guarded no-op: loading the bundle into any
* profile must not start a server.
* @module dshs
*/
export const name = 'dshs'
/** Options read by the Cordis loader. `serve` is reserved, not implemented. */
export interface Config {
serve?: boolean
}
/**
* Guarded no-op shim. Kept side-effect free so marketplace installs and
* ordinary profiles never boot the orchestrator by accident.
* @param _ctx - the Cordis context (unused).
* @param _config - loader-supplied config (unused).
*/
export function apply(_ctx?: unknown, _config?: Config): void {
// Intentionally empty.
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Account-level isolation helpers. Linux-only: each user maps to a deterministic
* OS uid so the orchestrator can spawn its DSH as that account (via setuid) and
* per-user 0700 directories become a real boundary.
*
* `hashUid` is the *legacy* deterministic hash (stable across restarts and
* hosts); new users get `baseUid + row_id` from the DB instead (collision-free,
* see `src/db`). `hashUid` remains as the backfill value for pre-existing rows
* and the fallback when a uid is not yet assigned.
* @module dshs/isolation
*/
/**
* Deterministic OS uid for a user id (stable across restarts and hosts).
* `baseUid` should sit above the distro's system uid range (typically < 1000).
*/
export function hashUid(userId: string, baseUid: number): number {
let hash = 0
for (let i = 0; i < userId.length; i++) hash = (hash * 31 + userId.charCodeAt(i)) >>> 0
return baseUid + (hash % 100000)
}
+45
View File
@@ -0,0 +1,45 @@
/**
* nginx `server {}` rendering and domain validation. (P6)
*
* `renderServerBlock` produces a per-custom-domain block that rewrites the
* domain root onto the orchestrator's `/u/<userId>/dsh/` subpath (see
* docs/domain-config.md §3.1).
* @module dshs/nginx/generate
*/
/** Render an nginx server block mapping a custom domain to a user's DSH. */
export function renderServerBlock(domain: string, userId: string, upstreamPort: number): string {
return [
`# ${domain} -> user ${userId}`,
'server {',
' listen 443 ssl;',
` server_name ${domain};`,
'',
` ssl_certificate /etc/letsencrypt/live/${domain}/fullchain.pem;`,
` ssl_certificate_key /etc/letsencrypt/live/${domain}/privkey.pem;`,
'',
' location / {',
` proxy_pass http://127.0.0.1:${upstreamPort}/u/${userId}/dsh/;`,
' proxy_http_version 1.1;',
' proxy_set_header Host $host;',
' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
' proxy_set_header X-Forwarded-Proto $scheme;',
' proxy_set_header Upgrade $http_upgrade;',
' proxy_set_header Connection $connection_upgrade;',
' proxy_read_timeout 3600s;',
' }',
'}',
].join('\n')
}
/**
* Validate an ASCII hostname: dot-separated labels of alnum/hyphen, no leading
* or trailing hyphen, no scheme/path/port.
*/
export function isValidDomain(domain: string): boolean {
if (domain.length === 0 || domain.length > 253) return false
return domain.split('.').every((label) => {
if (label.length === 0 || label.length > 63) return false
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(label)
})
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Crash-restart policy for a resident main DSH (档案 20 · 方案 A).
*
* Pure decision logic, deliberately separated from `LocalSpawner` so it can be
* unit-tested without spawning anything: given the recent restart timestamps
* and the current consecutive-attempt count, decide either "restart after
* `delayMs` (exponential backoff capped at `maxDelayMs`)" or "circuit open"
* (too many restarts inside the window — stop auto-restarting so a
* persistently failing instance cannot spin spawns forever).
*
* @module dshs/supervisor/crash-policy
*/
/** Tunables (see {@link ServerConfig} — all env-overridable). */
export interface CrashPolicyConfig {
/** Delay for the first restart (consecutive attempt 0). */
baseDelayMs: number
/** Upper bound for the exponential backoff. */
maxDelayMs: number
/** Rolling window over which restarts are counted. */
windowMs: number
/** Restarts allowed inside the window before the circuit opens. */
maxRestartsInWindow: number
/** A main that stays up this long is considered recovered: streak resets. */
stableResetMs: number
}
/** Outcome of one crash decision. */
export type CrashDecision =
| { action: 'restart'; delayMs: number; attempt: number; windowRestarts: number }
| { action: 'circuit-open'; windowRestarts: number }
/** Drop timestamps that fell out of the rolling window. */
export function pruneHistory(history: readonly number[], now: number, windowMs: number): number[] {
return history.filter((at) => now - at < windowMs)
}
/** Exponential backoff for a given consecutive attempt (0-based), capped. */
export function backoffDelayMs(consecutive: number, cfg: Pick<CrashPolicyConfig, 'baseDelayMs' | 'maxDelayMs'>): number {
const safe = Math.max(0, Math.min(consecutive, 20)) // guard against overflow
return Math.min(cfg.baseDelayMs * 2 ** safe, cfg.maxDelayMs)
}
/**
* Decide what to do after a main crashed.
* @param history - restart timestamps (ms epoch) already recorded for this user.
* @param consecutive - consecutive attempts since the last stable run.
* @param now - current time (ms epoch).
* @param cfg - policy tunables.
*/
export function decideCrashAction(
history: readonly number[],
consecutive: number,
now: number,
cfg: CrashPolicyConfig,
): CrashDecision {
const inWindow = pruneHistory(history, now, cfg.windowMs)
if (inWindow.length >= cfg.maxRestartsInWindow) {
return { action: 'circuit-open', windowRestarts: inWindow.length }
}
return {
action: 'restart',
delayMs: backoffDelayMs(consecutive, cfg),
attempt: consecutive + 1,
windowRestarts: inWindow.length + 1,
}
}
/* ─────────────────────────────────────────────────────────────────────────────
* 档案 78:熔断冷却(防「崩溃循环可无限重来」)
*
* 原始缺陷:`circuit-open` 分支里 `mains.delete` + `resetCrashState()` 会把窗口
* 历史一并清空,于是**下一次 enter/launch 又是满额预算** —— 只要有人(用户 F5、
* 注入脚本自愈、脚本直铺)不断重试,崩溃循环就能无限重复,且只留一行 stderr。
*
* 修法:每次熔断记一个**跨轮存活**的冷却窗(`opens` 递增 → 冷却指数加长),
* 冷却期内拒绝隐式/自动启动;冷却过后只给**一次**干净预算。纯函数,便于单测。
* ──────────────────────────────────────────────────────────────────────────── */
/** 熔断状态:`opens` = 该用户累计熔断次数(跨轮不清零)。 */
export interface BreakerState {
openedAt: number
opens: number
}
/** 冷却策略(见 ServerConfig,均可 env 覆盖)。 */
export interface BreakerPolicy {
baseCooldownMs: number
maxCooldownMs: number
}
/** 第 `opens` 次熔断的冷却时长:`base × 2^(opens-1)`,上限 `maxCooldownMs`。 */
export function breakerCooldownMs(opens: number, cfg: BreakerPolicy): number {
const safe = Math.max(1, Math.min(opens, 20))
return Math.min(cfg.baseCooldownMs * 2 ** (safe - 1), cfg.maxCooldownMs)
}
/** 冷却是否仍在生效(`now < openedAt + cooldown`)。 */
export function breakerActive(b: BreakerState | undefined, now: number, cfg: BreakerPolicy): boolean {
if (b === undefined) return false
return now < b.openedAt + breakerCooldownMs(b.opens, cfg)
}
/** 冷却结束时刻(无熔断时返回 0)。 */
export function breakerUntil(b: BreakerState | undefined, cfg: BreakerPolicy): number {
return b === undefined ? 0 : b.openedAt + breakerCooldownMs(b.opens, cfg)
}
/** 再次熔断:`opens` 递增,`openedAt` 取本次时刻。 */
export function openBreaker(prev: BreakerState | undefined, now: number): BreakerState {
return { openedAt: now, opens: (prev?.opens ?? 0) + 1 }
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Loopback port guard: an iptables OUTPUT owner-match rule that lets only the
* orchestrator (root) open a connection to a per-user DSH's loopback RPC port.
* Because every user DSH binds the shared 127.0.0.1 loopback on a dynamic port,
* a co-tenant local user could otherwise `curl` another user's DSH directly and
* bypass the orchestrator's session authentication. The owner match filters on
* the *client* uid, which is only observable on the OUTPUT chain for a
* same-host loopback connection (an INPUT rule would see the receiving DSH's
* uid and block nothing selectively).
*
* The REJECT is inserted at the *top* of OUTPUT (`-I OUTPUT 1`), not appended:
* iptables stops at the first match, so an earlier ACCEPT (ufw or a conntrack
* `ESTABLISHED,RELATED -j ACCEPT`) would otherwise swallow the packet before an
* appended `-A` REJECT is ever evaluated.
*
* Linux + root only; opt in via `config.portGuard` and fail loud when enabled
* on a host that cannot apply it.
* @module dshs/supervisor/firewall
*/
import { execFileSync } from 'node:child_process'
/** One guarded loopback port with idempotent install/remove. */
export interface PortGuard {
/** Add the OUTPUT owner-match REJECT rule (throws on failure). */
install(port: number): void
/** Remove the rule best-effort (a missing rule is not an error). */
remove(port: number): void
}
/** iptables arguments (excluding the executable) for one insert/delete.
* Install inserts at position 1 (`-I OUTPUT 1`) to stay ahead of any earlier
* ACCEPT (ufw / conntrack ESTABLISHED); delete matches by rule spec (`-D
* OUTPUT`), which works regardless of position. */
function ruleArgs(port: number, action: '-I' | '-D'): string[] {
const insert = action === '-I' ? ['1'] : []
return ['-t', 'filter', action, 'OUTPUT', ...insert, '-p', 'tcp', '--dport', String(port), '-m', 'owner', '!', '--uid-owner', '0', '-j', 'REJECT']
}
/** Whether this process can manage the loopback OUTPUT guard. */
function canGuard(): boolean {
return process.platform === 'linux' && typeof process.getuid === 'function' && process.getuid() === 0
}
/**
* Create the port guard when enabled, or undefined. Fails loud when enabled on
* a host that cannot apply it (non-Linux or non-root): a silently absent guard
* would leave co-tenants able to reach each other's DSH.
* @param enabled - deployment flag (`config.portGuard`).
*/
export function createPortGuard(enabled: boolean): PortGuard | undefined {
if (!enabled) return undefined
if (!canGuard()) {
throw new Error('portGuard requires a Linux host running as root (iptables OUTPUT owner-match)')
}
const guarded = new Set<number>()
return {
install(port) {
if (guarded.has(port)) return
// Insert at position 1 so an earlier ACCEPT (ufw / conntrack) can't
// swallow the packet before the REJECT is evaluated.
execFileSync('iptables', ruleArgs(port, '-I'))
guarded.add(port)
},
remove(port) {
if (!guarded.has(port)) return
try {
execFileSync('iptables', ruleArgs(port, '-D'))
} catch {
// Best-effort teardown: a stale rule leaves the port closed to
// co-tenants, which is fail-safe, and a dropped rule is already gone.
} finally {
guarded.delete(port)
}
},
}
}
+687
View File
@@ -0,0 +1,687 @@
/**
* K8s backend for per-user DSH lifecycle (docs/k8s.md §5.2/§4.3/§4.4/§4.8).
*
* Each user gets a `dsh-<userId>` Pod (dsh + socat sidecar), a Headless Service,
* a NetworkPolicy, and a `dsh-key-<userId>` Secret for the API key. A crash
* pulls up a one-shot `dsh-<userId>-watchdog` Job. The control plane runs
* in-cluster and talks to the K8s API via @kubernetes/client-node.
* @module dshs/supervisor/k8s-spawner
*/
import * as k8s from '@kubernetes/client-node'
import type { ServerConfig } from '../config.js'
import type { DbAdapter } from '../db/adapter.js'
import { HANDOFF_FILE, HOME_DIR, USERS_DIR, WORKSPACE_DIR } from '../fs/workspace.js'
import { FILE_SERVICE_PORT, USER_ROOT_ENV } from '../web/file-service.js'
import type { Fencing } from './leader.js'
import { AlreadyRunningError, type Endpoint, type Instance, type LivePod, type Spawner, type UserStatus } from './spawner.js'
/** Loopback port the dsh container binds; the socat sidecar bridges 8081 → 8080. */
const DSH_LOOPBACK_PORT = 8080
/** Sidecar bridge port the per-user Service targets (80 → 8081). */
const SOCAT_PORT = 8081
/** Task the one-shot headless watchdog runs (executes the handoff command). */
const WATCHDOG_TASK = 'Read DSHS_HANDOFF_PATH. If it contains a JSON {"command": ...}, run that command. Then exit.'
/** Shared RWX PVC every user's Pod mounts via subPath. */
const USERS_PVC = 'dsh-users'
/** Per-user resource names (deterministic — idempotent create). */
function names(userId: string) {
return {
pod: `dsh-${userId}`,
service: `dsh-${userId}`,
networkPolicy: `dsh-${userId}`,
secret: `dsh-key-${userId}`,
job: `dsh-${userId}-watchdog`,
patch: `dsh-${userId}-patch`,
filesPod: `dsh-files-${userId}`,
filesService: `dsh-files-${userId}`,
filesNetworkPolicy: `dsh-files-${userId}`,
}
}
/** The data root inside every per-user Pod, independent of the control plane's
* own `dataRoot` (which under k8s points at a volume the Pod never sees). */
const POD_DATA_ROOT = '/var/lib/dshs'
/** Home/workspace paths inside the DSH Pod (docs/k8s.md §4.3). Built with
* POSIX separators — the control plane may be developed/tested on Windows. */
function userPaths(userId: string): { home: string; ws: string; mount: string } {
const mount = `${POD_DATA_ROOT}/${USERS_DIR}/${userId}`
return { home: `${mount}/${HOME_DIR}`, ws: `${mount}/${WORKSPACE_DIR}`, mount }
}
/** Pod-safe labels shared by the DSH Pod/Service/NetworkPolicy/Job. */
function podLabels(userId: string): { app: string; user: string } {
return { app: 'dsh', user: userId }
}
/** Labels for the per-user file sidecar (a distinct `app`, so its own
* Service/NetworkPolicy select it without touching the DSH Pod). */
function filesLabels(userId: string): { app: string; user: string } {
return { app: 'dsh-files', user: userId }
}
/** API-key env entry, omitted when the user has no key. */
function apiKeyEnv(userId: string, apiKey: string | null): k8s.V1EnvVar[] {
if (apiKey === null) return []
return [{ name: 'DEEPSEEK_API_KEY', valueFrom: { secretKeyRef: { name: names(userId).secret, key: 'key' } } }]
}
/** Common per-user container security context (non-root + drop ALL + seccomp +
* read-only rootfs — /tmp comes from an emptyDir, docs/k8s.md Phase 4). */
function containerSecurity(uid: number): k8s.V1SecurityContext {
return {
runAsNonRoot: true,
runAsUser: uid,
allowPrivilegeEscalation: false,
capabilities: { drop: ['ALL'] },
seccompProfile: { type: 'RuntimeDefault' },
readOnlyRootFilesystem: true,
}
}
/** Writable scratch volume + mount for containers whose rootfs is read-only. */
function tmpVolume(): k8s.V1Volume[] {
return [{ name: 'tmp', emptyDir: {} }]
}
function tmpMount(): k8s.V1VolumeMount {
return { name: 'tmp', mountPath: '/tmp' }
}
/** The shared RWX volume (subPath mounts per-user at use time). */
function dataVolume(): k8s.V1Volume[] {
return [{ name: 'data', persistentVolumeClaim: { claimName: USERS_PVC } }]
}
/** The shared RWX volume mounted at its **root** (no subPath) so an init
* container can create/chown `<pvc>/<userId>` as the user's own uid. */
function dataRootVolume(): k8s.V1Volume[] {
return [{ name: 'data-root', persistentVolumeClaim: { claimName: USERS_PVC } }]
}
/** Every per-user Pod/Job references ACR private images, so each must carry the
* cluster's pull secret (the control-plane Deployment has it in its manifest,
* but generated Pods don't inherit it). */
function pullSecrets(name: string): k8s.V1LocalObjectReference[] {
return name === '' ? [] : [{ name }]
}
/** Fencing labels stamped onto resources created by the leader, so a successor
* can identify work a dead leader left in flight (docs/k8s.md §5.3). */
function stampFencing(labels: Record<string, string>, fencing: Fencing | undefined): void {
if (fencing === undefined) return
labels['dsh.io/holder'] = fencing.holder
labels['dsh.io/operation-id'] = String(fencing.operationId)
}
/**
* K8s backend implementing {@link Spawner}. State lives in the cluster; every
* method is a K8s API call (or a read).
*/
export class K8sSpawner implements Spawner {
private readonly core: k8s.CoreV1Api
private readonly networking: k8s.NetworkingV1Api
private readonly batch: k8s.BatchV1Api
private readonly namespace: string
private readonly kc: k8s.KubeConfig
private fencing: Fencing | undefined
constructor(
private readonly config: ServerConfig,
private readonly db: DbAdapter,
private readonly resolveApiKey: (userId: string) => Promise<string | null>,
private readonly resolveUid: (userId: string) => Promise<number>,
clients?: { core: k8s.CoreV1Api; networking: k8s.NetworkingV1Api; batch: k8s.BatchV1Api },
) {
// Injecting clients short-circuits cluster auth (used by tests). Otherwise
// build them from the in-cluster config, which needs a mounted SA token.
const kc = new k8s.KubeConfig()
if (clients === undefined) {
kc.loadFromCluster()
clients = {
core: kc.makeApiClient(k8s.CoreV1Api),
networking: kc.makeApiClient(k8s.NetworkingV1Api),
batch: kc.makeApiClient(k8s.BatchV1Api),
}
}
this.kc = kc
this.core = clients.core
this.networking = clients.networking
this.batch = clients.batch
this.namespace = config.k8sNamespace
}
async launch(userId: string, folder: string, patch?: string): Promise<Instance> {
const n = names(userId)
if (await this.podExists(n.pod)) throw new AlreadyRunningError(userId)
await this.ensureFileService(userId) // the DSH Pod's subPath must already exist
const apiKey = await this.resolveApiKey(userId)
const uid = await this.resolveUid(userId)
const hasPatch = this.config.enablePatch && patch !== undefined
if (hasPatch) await this.ensurePatchConfigMap(n.patch, patch)
await this.ensureSecret(n.secret, apiKey)
await this.ensurePod(n.pod, userId, uid, apiKey, hasPatch ? n.patch : undefined)
await this.ensureService(n.service, userId)
await this.ensureNetworkPolicy(n.networkPolicy, userId)
try {
await this.db.upsertInstance({ id: n.pod, userId, role: 'main', status: 'starting', folder, patch })
} catch (err) {
this.logError(err) // reconcile needs this row; don't fail the launch over it
}
return { id: n.pod, userId, role: 'main', folder, status: 'starting', patch }
}
async restartMain(userId: string): Promise<Instance | undefined> {
const desired = await this.db.findUserInstance(userId, 'main')
if (desired === undefined) return undefined
await this.stop(userId)
return await this.launch(userId, desired.folder ?? '', desired.patch ?? undefined)
}
async restartAllMains(): Promise<void> {
const rows = await this.db.listInstancesByRole('main')
for (const row of rows) {
try {
await this.restartMain(row.userId)
} catch (err) {
this.logError(err) // keep broadcasting — one pod failure must not abort the rest
}
}
}
async spawnWatchdog(userId: string): Promise<Instance | undefined> {
const n = names(userId)
const apiKey = await this.resolveApiKey(userId)
const uid = await this.resolveUid(userId)
const { home, ws, mount } = userPaths(userId)
const jobLabels = podLabels(userId)
const podTemplateLabels = podLabels(userId)
stampFencing(jobLabels, this.fencing)
stampFencing(podTemplateLabels, this.fencing)
await this.batch.createNamespacedJob({ namespace: this.namespace, body: {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: n.job, namespace: this.namespace, labels: jobLabels },
spec: {
ttlSecondsAfterFinished: 300,
template: {
metadata: { labels: podTemplateLabels },
spec: {
automountServiceAccountToken: false,
imagePullSecrets: pullSecrets(this.config.imagePullSecret),
restartPolicy: 'Never',
securityContext: {
runAsNonRoot: true,
runAsUser: uid,
fsGroup: uid,
seccompProfile: { type: 'RuntimeDefault' },
},
containers: [
{
name: 'dsh',
image: this.config.dshImage,
args: ['--profile', 'headless', WATCHDOG_TASK],
env: [
{ name: 'HOME', value: ws },
{ name: 'DSH_HOME', value: home },
{ name: 'DSHS_ROLE', value: 'watchdog' },
{ name: 'DSHS_HANDOFF_PATH', value: `${mount}/${HANDOFF_FILE}` },
...apiKeyEnv(userId, apiKey),
],
volumeMounts: [{ name: 'data', mountPath: mount, subPath: userId }, tmpMount()],
securityContext: containerSecurity(uid),
},
],
volumes: [...dataVolume(), ...tmpVolume()],
},
},
},
} })
return { id: n.job, userId, role: 'watchdog', folder: ws, status: 'starting' }
}
async status(userId: string): Promise<UserStatus> {
return { main: await this.readInstance(names(userId).pod, userId, 'main') }
}
async endpointFor(userId: string): Promise<Endpoint | undefined> {
let pod: k8s.V1Pod
try {
pod = await this.core.readNamespacedPod({ name: names(userId).pod, namespace: this.namespace })
} catch (err) {
if (this.isNotFound(err)) return undefined
throw err // 403/500 are real failures, not "not running"
}
if (pod.status?.phase !== 'Running') return undefined
// Dial the Pod IP directly (not the Headless Service DNS): re-reading the Pod
// on every request gives the *current* IP immediately after a restart, so a
// rebuilt Pod never leaves the proxy pointing at a stale IP for the ~30s DNS
// TTL. Port is the sidecar's 8081 (no kube-proxy DNAT on Headless Services).
const ip = pod.status?.podIP
if (ip === undefined || ip === '') return undefined
return { host: ip, port: SOCAT_PORT }
}
// k8s 模式无 launch token 概念(Pod 就绪由 endpointFor 的 phase 判断),no-op。
async waitForLaunchTokenForUser(_userId: string, _timeoutMs?: number): Promise<void> {}
/**
* k8s spawner 未实现探活(本部署走本地 spawner)。返回 ok:true 保持与旧行为一致:
* 不做回滚,交由 k8s 自身 readiness/liveness 处理。
*/
async restartAndProbe(_userId: string, _settleMs?: number): Promise<{ ok: boolean; reason: string }> {
return { ok: true, reason: "k8s spawner: 探活未实现" }
}
async stop(userId: string): Promise<void> {
const n = names(userId)
await this.ignoreNotFound(() => this.core.deleteNamespacedPod({ name: n.pod, namespace: this.namespace }))
await this.ignoreNotFound(() => this.core.deleteNamespacedService({ name: n.service, namespace: this.namespace }))
await this.ignoreNotFound(() => this.networking.deleteNamespacedNetworkPolicy({ name: n.networkPolicy, namespace: this.namespace }))
await this.ignoreNotFound(() => this.batch.deleteNamespacedJob({ name: n.job, namespace: this.namespace }))
await this.ignoreNotFound(() => this.core.deleteNamespacedConfigMap({ name: n.patch, namespace: this.namespace }))
try {
await this.db.deleteInstance(n.pod) // desired state is gone once stopped
} catch (err) {
this.logError(err)
}
}
/** No-op: per-user Pods outlive any single control-plane replica (reconcile/leader manages them). */
async teardown(): Promise<void> {}
/** Activity signal is unused under k8s: the reconcile loop idles-reaps by
* session presence (reconcile.ts Phase 4), not by proxied-traffic recency. */
touch(_userId: string): void {}
/** Bring up the user's file sidecar (docs/k8s.md §4.10). The sidecar is a
* distinct always-on Pod so the desktop is usable *before* the on-demand DSH
* launches; its init container creates the user's subPath directory. */
async ensureFileService(userId: string): Promise<void> {
if (this.config.controlPlaneImage === '') {
throw new Error('DSHS_CONTROL_PLANE_IMAGE is required in k8s mode (file sidecar image)')
}
const n = names(userId)
const uid = await this.resolveUid(userId)
await this.ensureFilesPod(n.filesPod, userId, uid)
await this.ensureFilesService(n.filesService, userId)
await this.ensureFilesNetworkPolicy(n.filesNetworkPolicy, userId)
// The Headless Service only publishes an A record once the Pod is Ready;
// the caller resolves it immediately, so block until it comes up.
await this.waitForPodReady(n.filesPod)
}
// --- reconcile / watch support (leader-only callers) ---
/** Stamp the current leader's fencing token onto resources created from now on. */
setFencing(fencing: Fencing | undefined): void {
this.fencing = fencing
}
/** Main DSH Pods (`app=dsh`), mapped to the shape the controller needs. */
async listUserPods(): Promise<LivePod[]> {
const res = await this.core.listNamespacedPod({ namespace: this.namespace, labelSelector: 'app=dsh' })
return (res.items ?? []).map((pod) => {
const phase = pod.status?.phase ?? ''
return {
name: pod.metadata?.name ?? '',
userId: pod.metadata?.labels?.user ?? '',
running: phase === 'Running',
crashed: phase === 'Failed' || phase === 'Succeeded',
}
})
}
/** Recreate a lost Service/NetworkPolicy for a user whose main Pod exists. */
async ensureUserResources(userId: string): Promise<void> {
await this.ensureService(names(userId).service, userId)
await this.ensureNetworkPolicy(names(userId).networkPolicy, userId)
}
/** Watch main DSH Pods; `callback` fires on add/update/delete with their state. */
watchMainPods(callback: (pod: LivePod) => void): k8s.Informer<k8s.V1Pod> & k8s.ObjectCache<k8s.V1Pod> {
const informer = k8s.makeInformer<k8s.V1Pod>(
this.kc,
`/api/v1/namespaces/${this.namespace}/pods`,
() => this.core.listNamespacedPod({ namespace: this.namespace, labelSelector: 'app=dsh' }),
'app=dsh',
)
const emit = (obj: k8s.V1Pod | undefined): void => {
const phase = obj?.status?.phase ?? ''
callback({
name: obj?.metadata?.name ?? '',
userId: obj?.metadata?.labels?.user ?? '',
running: phase === 'Running',
crashed: phase === 'Failed' || phase === 'Succeeded',
})
}
informer.on('add', (obj) => emit(obj))
informer.on('update', (obj) => emit(obj))
informer.on('delete', (obj) => emit(obj))
return informer
}
/** Best-effort error surface for the controller's tick loop. */
logError(err: unknown): void {
console.error('[dsh-reconcile]', err)
}
// --- helpers ---
private async podExists(name: string): Promise<boolean> {
try {
await this.core.readNamespacedPod({ name, namespace: this.namespace })
return true
} catch (err) {
if (this.isNotFound(err)) return false
throw err // 403/500 are real failures, not "no Pod"
}
}
private async readInstance(name: string, userId: string, role: 'main' | 'watchdog'): Promise<Instance | undefined> {
try {
const pod = await this.core.readNamespacedPod({ name, namespace: this.namespace })
const status = pod.status?.phase === 'Running' ? 'running' : pod.status?.phase === 'Failed' ? 'crashed' : 'starting'
return { id: name, userId, role, folder: '', status }
} catch (err) {
if (this.isNotFound(err)) return undefined
throw err
}
}
/** Create-or-replace, so a relaunch after `stop()` (which deletes these) can
* never 409 on a stale Secret/ConfigMap from a prior launch. */
private async ensureSecret(name: string, apiKey: string | null): Promise<void> {
if (apiKey === null) return
await this.replace({
read: () => this.core.readNamespacedSecret({ name, namespace: this.namespace }),
create: () => this.core.createNamespacedSecret({ namespace: this.namespace, body: {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name, namespace: this.namespace },
type: 'Opaque',
stringData: { key: apiKey },
} }),
del: () => this.core.deleteNamespacedSecret({ name, namespace: this.namespace }),
})
}
/** Create-or-replace a generated resource. Reads first for the cheap path,
* then treats a 409 race by deleting and recreating (we own every resource
* this helper touches, so replacement is safe). */
private async replace(ops: {
read: () => Promise<unknown>
create: () => Promise<unknown>
del: () => Promise<unknown>
}): Promise<void> {
try {
await ops.read()
} catch (err) {
if (!this.isNotFound(err)) throw err
try {
await ops.create()
} catch (createErr) {
if (!this.isConflict(createErr)) throw createErr
await this.ignoreNotFound(ops.del)
await ops.create()
}
}
}
private async ensurePod(name: string, userId: string, uid: number, apiKey: string | null, patchConfigMapName?: string): Promise<void> {
const { home, ws, mount } = userPaths(userId)
const args = ['--profile', 'web', '--host', '127.0.0.1', '--port', String(DSH_LOOPBACK_PORT)]
// The runtime plugin (dshs/runtime) is baked into the dsh image
// but loaded via --patch; the rendered patch is mounted at /etc/dsh/patch.yml.
if (patchConfigMapName !== undefined) args.splice(1, 0, '--patch', '/etc/dsh/patch.yml')
const labels = podLabels(userId)
stampFencing(labels, this.fencing)
await this.core.createNamespacedPod({ namespace: this.namespace, body: {
apiVersion: 'v1',
kind: 'Pod',
metadata: { name, namespace: this.namespace, labels },
spec: {
automountServiceAccountToken: false,
imagePullSecrets: pullSecrets(this.config.imagePullSecret),
hostNetwork: false,
hostPID: false,
securityContext: {
runAsNonRoot: true,
runAsUser: uid,
fsGroup: uid,
seccompProfile: { type: 'RuntimeDefault' },
},
containers: [
{
name: 'dsh',
image: this.config.dshImage,
args,
// DSH binds loopback only, so a tcpSocket probe (which hits the Pod IP)
// would never succeed; probe 127.0.0.1:8080 from inside the container.
readinessProbe: {
exec: { command: ['node', '-e', 'require("http").get("http://127.0.0.1:8080", r => process.exit(r.statusCode < 500 ? 0 : 1)).on("error", () => process.exit(1))'] },
initialDelaySeconds: 5,
periodSeconds: 3,
},
env: [
{ name: 'HOME', value: ws },
{ name: 'DSH_HOME', value: home },
...apiKeyEnv(userId, apiKey),
],
volumeMounts: [
{ name: 'data', mountPath: mount, subPath: userId },
...(patchConfigMapName !== undefined ? [{ name: 'patch', mountPath: '/etc/dsh', readOnly: true }] : []),
tmpMount(),
],
securityContext: containerSecurity(uid),
resources: { requests: { cpu: '500m', memory: '1Gi' }, limits: { cpu: '2', memory: '4Gi' } },
},
{
name: 'sidecar',
image: this.config.controlPlaneImage,
args: ['tcp-bridge', `0.0.0.0:${SOCAT_PORT}`, `127.0.0.1:${DSH_LOOPBACK_PORT}`],
ports: [{ containerPort: SOCAT_PORT }],
securityContext: containerSecurity(uid),
resources: { requests: { cpu: '10m', memory: '32Mi' }, limits: { cpu: '100m', memory: '128Mi' } },
},
],
volumes: [
...dataVolume(),
...(patchConfigMapName !== undefined ? [{ name: 'patch', configMap: { name: patchConfigMapName } }] : []),
...tmpVolume(),
],
},
} })
}
private async ensurePatchConfigMap(name: string, patch: string): Promise<void> {
await this.replace({
read: () => this.core.readNamespacedConfigMap({ name, namespace: this.namespace }),
create: () => this.core.createNamespacedConfigMap({ namespace: this.namespace, body: {
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: { name, namespace: this.namespace },
data: { 'patch.yml': patch },
} }),
del: () => this.core.deleteNamespacedConfigMap({ name, namespace: this.namespace }),
})
}
private async ensureService(name: string, userId: string): Promise<void> {
await this.replace({
read: () => this.core.readNamespacedService({ name, namespace: this.namespace }),
create: () => this.core.createNamespacedService({ namespace: this.namespace, body: {
apiVersion: 'v1',
kind: 'Service',
metadata: { name, namespace: this.namespace },
spec: {
clusterIP: 'None', // Headless: no ClusterIP, DNS A record → Pod IP
selector: podLabels(userId),
ports: [{ port: 80, targetPort: SOCAT_PORT }],
},
} }),
del: () => this.core.deleteNamespacedService({ name, namespace: this.namespace }),
})
}
private async ensureNetworkPolicy(name: string, userId: string): Promise<void> {
await this.replace({
read: () => this.networking.readNamespacedNetworkPolicy({ name, namespace: this.namespace }),
create: () => this.networking.createNamespacedNetworkPolicy({ namespace: this.namespace, body: {
apiVersion: 'networking.k8s.io/v1',
kind: 'NetworkPolicy',
metadata: { name, namespace: this.namespace },
spec: {
podSelector: { matchLabels: podLabels(userId) },
policyTypes: ['Ingress', 'Egress'],
ingress: [{ _from: [{ podSelector: { matchLabels: { app: 'dsh-orchestrator' } } }] }],
egress: [
{
// DNS to anywhere: the DNS backend varies by distribution (kube-dns /
// coredns / node-local-dns on ACK), so don't pin a pod label.
to: [{ ipBlock: { cidr: '0.0.0.0/0' } }],
ports: [
{ port: 53, protocol: 'UDP' },
{ port: 53, protocol: 'TCP' },
],
},
{
to: this.config.egressCidrs.length > 0
? this.config.egressCidrs.map((cidr) => ({ ipBlock: { cidr, except: ['169.254.169.254/32', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'] } }))
: [{ ipBlock: { cidr: '0.0.0.0/0', except: ['169.254.169.254/32', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'] } }],
ports: [{ port: 443 }],
},
],
},
} }),
del: () => this.networking.deleteNamespacedNetworkPolicy({ name, namespace: this.namespace }),
})
}
private async ensureFilesPod(name: string, userId: string, uid: number): Promise<void> {
const mount = userPaths(userId).mount
await this.replace({
read: () => this.core.readNamespacedPod({ name, namespace: this.namespace }),
create: () => this.core.createNamespacedPod({ namespace: this.namespace, body: {
apiVersion: 'v1',
kind: 'Pod',
metadata: { name, namespace: this.namespace, labels: filesLabels(userId) },
spec: {
automountServiceAccountToken: false,
imagePullSecrets: pullSecrets(this.config.imagePullSecret),
hostNetwork: false,
hostPID: false,
securityContext: {
runAsNonRoot: true,
runAsUser: uid,
fsGroup: uid,
seccompProfile: { type: 'RuntimeDefault' },
},
// Creates `<pvc>/<userId>/{ws,home}` as the user's uid (docs/k8s.md
// §4.9). Runs on the PVC *root* (no subPath), because the subPath dir
// may not exist yet or be root-owned; the user's 0700 dir keeps other
// users' files out of reach.
initContainers: [
{
name: 'init-user',
image: this.config.controlPlaneImage,
command: ['sh', '-ec', `mkdir -p /mnt/${userId}/${WORKSPACE_DIR} /mnt/${userId}/${HOME_DIR} && chmod 0700 /mnt/${userId}`],
volumeMounts: [{ name: 'data-root', mountPath: '/mnt' }],
securityContext: containerSecurity(uid),
},
],
containers: [
{
name: 'files',
image: this.config.controlPlaneImage,
args: ['file-service'],
env: [{ name: USER_ROOT_ENV, value: mount }],
ports: [{ containerPort: FILE_SERVICE_PORT }],
readinessProbe: { tcpSocket: { port: FILE_SERVICE_PORT }, initialDelaySeconds: 2, periodSeconds: 3 },
volumeMounts: [{ name: 'data', mountPath: mount, subPath: userId }, tmpMount()],
securityContext: containerSecurity(uid),
resources: { requests: { cpu: '50m', memory: '128Mi' }, limits: { cpu: '200m', memory: '256Mi' } },
},
],
volumes: [...dataVolume(), ...dataRootVolume(), ...tmpVolume()],
},
} }),
del: () => this.core.deleteNamespacedPod({ name, namespace: this.namespace }),
})
}
private async ensureFilesService(name: string, userId: string): Promise<void> {
await this.replace({
read: () => this.core.readNamespacedService({ name, namespace: this.namespace }),
create: () => this.core.createNamespacedService({ namespace: this.namespace, body: {
apiVersion: 'v1',
kind: 'Service',
metadata: { name, namespace: this.namespace },
spec: {
clusterIP: 'None',
selector: filesLabels(userId),
ports: [{ port: FILE_SERVICE_PORT, targetPort: FILE_SERVICE_PORT }],
},
} }),
del: () => this.core.deleteNamespacedService({ name, namespace: this.namespace }),
})
}
private async ensureFilesNetworkPolicy(name: string, userId: string): Promise<void> {
await this.replace({
read: () => this.networking.readNamespacedNetworkPolicy({ name, namespace: this.namespace }),
create: () => this.networking.createNamespacedNetworkPolicy({ namespace: this.namespace, body: {
apiVersion: 'networking.k8s.io/v1',
kind: 'NetworkPolicy',
metadata: { name, namespace: this.namespace },
spec: {
podSelector: { matchLabels: filesLabels(userId) },
policyTypes: ['Ingress', 'Egress'],
ingress: [{ _from: [{ podSelector: { matchLabels: { app: 'dsh-orchestrator' } } }], ports: [{ port: FILE_SERVICE_PORT }] }],
// Files only; the sidecar never talks to the LLM API.
egress: [
{
to: [{ ipBlock: { cidr: '0.0.0.0/0' } }],
ports: [
{ port: 53, protocol: 'UDP' },
{ port: 53, protocol: 'TCP' },
],
},
],
},
} }),
del: () => this.networking.deleteNamespacedNetworkPolicy({ name, namespace: this.namespace }),
})
}
/** Poll until a Pod's Ready condition is true, or fail after `timeoutMs`. */
private async waitForPodReady(name: string, timeoutMs = 60_000): Promise<void> {
const deadline = Date.now() + timeoutMs
for (;;) {
const pod = await this.core.readNamespacedPod({ name, namespace: this.namespace })
const ready = pod.status?.conditions?.some((c) => c.type === 'Ready' && c.status === 'True')
if (ready) return
if (Date.now() >= deadline) throw new Error(`Pod ${name} not ready within ${timeoutMs}ms`)
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
private isNotFound(err: unknown): boolean {
return (err as { code?: number }).code === 404
}
private isConflict(err: unknown): boolean {
return (err as { code?: number }).code === 409
}
private async ignoreNotFound(fn: () => Promise<unknown>): Promise<void> {
try {
await fn()
} catch (err) {
// `@kubernetes/client-node` throws `ApiException` with a `code` field.
if (this.isNotFound(err)) return
throw err
}
}
}
+254
View File
@@ -0,0 +1,254 @@
/**
* Hand-rolled leader election over `coordination.k8s.io/v1` Lease
* (docs/k8s.md §5.3). `@kubernetes/client-node` ships no election helper, so
* this holds the small state machine: try to create the lease, and when it
* already exists either renew (we hold it) or take it over only after the
* holder's renew time has exceeded `leaseDurationSeconds`.
*
* Timings follow the doc's anti-split-brain ordering
* `LeaseDuration > RenewDeadline > RetryPeriod`.
* @module dshs/supervisor/leader
*/
import * as k8s from '@kubernetes/client-node'
import { hostname } from 'node:os'
/** The fencing token a controller stamps onto resources it creates. */
export interface Fencing {
holder: string
operationId: number
}
export interface LeaderOptions {
identity?: string
leaseName?: string
namespace: string
leaseDurationSeconds?: number
renewDeadlineSeconds?: number
retryPeriodSeconds?: number
/** Injectable clock (tests); defaults to Date.now. */
now?: () => number
onStartedLeading?: (fencing: Fencing) => void
onStoppedLeading?: () => void
/** Injectable lease client (tests); defaults to the in-cluster config. */
coordination?: k8s.CoordinationV1Api
}
/** `@kubernetes/client-node` deserializes `V1MicroTime` back to an ISO string,
* not a Date — `renewTime.getTime()` would throw. Normalize either shape. */
function toMillis(time: unknown): number {
if (time === undefined || time === null) return 0
if (typeof time === 'string') {
const ms = Date.parse(time)
return Number.isNaN(ms) ? 0 : ms
}
if (time instanceof Date) return time.getTime()
return 0
}
/** Normalize a read-back `V1MicroTime` (string) back to a Date for the replace body. */
function toMicroTime(time: unknown): k8s.V1MicroTime | undefined {
const ms = toMillis(time)
return ms > 0 ? new k8s.V1MicroTime(ms) : undefined
}
/** Acquire/keep a Lease, notifying callers across leadership changes. */
export class LeaderElector {
private readonly coordination: k8s.CoordinationV1Api
private readonly identity: string
private readonly leaseName: string
private readonly namespace: string
private readonly leaseDurationSeconds: number
private readonly renewDeadlineSeconds: number
private readonly retryPeriodSeconds: number
private readonly now: () => number
private onStartedLeading?: (fencing: Fencing) => void
private onStoppedLeading?: () => void
private leading = false
private operationId = 0
private lastRenew = 0
private timer: NodeJS.Timeout | undefined
constructor(options: LeaderOptions) {
if (options.coordination !== undefined) {
this.coordination = options.coordination
} else {
const kc = new k8s.KubeConfig()
kc.loadFromCluster()
this.coordination = kc.makeApiClient(k8s.CoordinationV1Api)
}
this.identity = options.identity ?? process.env.POD_NAME ?? hostname()
this.leaseName = options.leaseName ?? 'dsh-orchestrator'
this.namespace = options.namespace
this.leaseDurationSeconds = options.leaseDurationSeconds ?? 15
this.renewDeadlineSeconds = options.renewDeadlineSeconds ?? 10
this.retryPeriodSeconds = options.retryPeriodSeconds ?? 2
this.now = options.now ?? Date.now
this.onStartedLeading = options.onStartedLeading
this.onStoppedLeading = options.onStoppedLeading
}
get isLeader(): boolean {
return this.leading
}
/** Wire (or rewire) leadership callbacks. The controller owns the reaction,
* not the elector, so it attaches itself here. */
setLeadershipCallbacks(onStarted?: (fencing: Fencing) => void, onStopped?: () => void): void {
this.onStartedLeading = onStarted
this.onStoppedLeading = onStopped
}
/** The current fencing token (valid only while {@link isLeader}). */
get fencing(): Fencing {
return { holder: this.identity, operationId: this.operationId }
}
/** Start the acquire/renew loop. Resolves once started (does not wait for leadership). */
async start(): Promise<void> {
if (this.timer !== undefined) return
await this.tryAcquire()
}
/** Stop the loop and yield leadership if held. */
stop(): void {
if (this.timer !== undefined) {
clearTimeout(this.timer)
this.timer = undefined
}
if (this.leading) {
this.leading = false
this.onStoppedLeading?.()
}
}
/** One acquire/renew round, rescheduling itself with retry/backoff. */
private async tryAcquire(): Promise<void> {
try {
if (this.leading) {
await this.renew()
} else {
await this.acquire()
}
} catch {
// Transient API/network failure: retry. Leadership is only lost after the
// renewDeadline elapses without a successful renew, checked in the loop.
}
this.scheduleNext()
}
private scheduleNext(): void {
if (this.timer !== undefined) return
// When leader, renew well inside renewDeadline; otherwise back off and retry.
const delay = this.leading ? Math.min(this.renewDeadlineSeconds / 2, this.retryPeriodSeconds) : this.retryPeriodSeconds
this.timer = setTimeout(() => {
this.timer = undefined
if (this.leading && this.now() - this.lastRenew > this.renewDeadlineSeconds * 1000) {
// Too long without a successful renew → another holder may have taken over.
this.leading = false
this.onStoppedLeading?.()
}
void this.tryAcquire()
}, delay * 1000)
this.timer.unref?.()
}
private lease(now: number, transitions: number): k8s.V1Lease {
return {
apiVersion: 'coordination.k8s.io/v1',
kind: 'Lease',
metadata: { name: this.leaseName, namespace: this.namespace },
spec: {
holderIdentity: this.identity,
leaseDurationSeconds: this.leaseDurationSeconds,
acquireTime: new k8s.V1MicroTime(now),
renewTime: new k8s.V1MicroTime(now),
leaseTransitions: transitions,
},
}
}
private async acquire(): Promise<void> {
try {
await this.coordination.createNamespacedLease({
namespace: this.namespace,
body: this.lease(this.now(), 0),
})
this.becomeLeader(0, this.now())
} catch (err) {
if ((err as { code?: number }).code !== 409) throw err
// Lease exists — take over only if the holder's renew time is stale.
const current = await this.coordination.readNamespacedLease({
name: this.leaseName,
namespace: this.namespace,
})
const holder = current.spec?.holderIdentity
const renew = toMillis(current.spec?.renewTime)
if (holder === this.identity) {
// We already hold it (e.g. after a restart) — renew, then resume leading.
await this.renew()
this.becomeLeader(current.spec?.leaseTransitions ?? 0, this.now())
return
}
const expired = this.now() - renew > this.leaseDurationSeconds * 1000
if (!expired) return // a live leader holds it; back off
const transitions = (current.spec?.leaseTransitions ?? 0) + 1
const now = this.now()
// `patchNamespacedLease` uses JSON Patch (an array); a full replace with a
// resourceVersion gives the optimistic-concurrency takeover we want.
await this.coordination.replaceNamespacedLease({
name: this.leaseName,
namespace: this.namespace,
body: {
apiVersion: 'coordination.k8s.io/v1',
kind: 'Lease',
metadata: { name: this.leaseName, namespace: this.namespace, resourceVersion: current.metadata?.resourceVersion },
spec: {
holderIdentity: this.identity,
leaseDurationSeconds: this.leaseDurationSeconds,
acquireTime: new k8s.V1MicroTime(now),
renewTime: new k8s.V1MicroTime(now),
leaseTransitions: transitions,
},
},
})
this.becomeLeader(transitions, now)
}
}
private async renew(): Promise<void> {
const now = this.now()
// Always read the latest lease so we preserve holder/acquireTime/transitions
// and bump only renewTime.
const current = await this.coordination.readNamespacedLease({
name: this.leaseName,
namespace: this.namespace,
})
await this.coordination.replaceNamespacedLease({
name: this.leaseName,
namespace: this.namespace,
body: {
apiVersion: 'coordination.k8s.io/v1',
kind: 'Lease',
metadata: { name: this.leaseName, namespace: this.namespace, resourceVersion: current.metadata?.resourceVersion },
spec: {
holderIdentity: current.spec?.holderIdentity ?? this.identity,
leaseDurationSeconds: this.leaseDurationSeconds,
acquireTime: toMicroTime(current.spec?.acquireTime),
renewTime: new k8s.V1MicroTime(now),
leaseTransitions: current.spec?.leaseTransitions ?? 0,
},
},
})
this.lastRenew = now
}
private becomeLeader(operationId: number, now: number): void {
const wasLeader = this.leading
this.leading = true
this.operationId = operationId
this.lastRenew = now
if (!wasLeader) this.onStartedLeading?.({ holder: this.identity, operationId })
}
}
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
/**
* cordis patch rendering for a child DSH. Always mounts the runtime plugin
* (`dshs/runtime`) so every child injects the watchdog contract,
* plus one row per enabled folder plugin (id doubles as package name). The real
* harness loads this via `--patch <file>`.
* @module dshs/supervisor/patch
*/
/** The runtime plugin patch row, mounted in every child DSH. */
const RUNTIME_ROW = ' - id: dshs-runtime\n name: dshs/runtime'
/** Render a patch YAML always enabling the runtime plugin plus `enabledPlugins`. */
export function renderPatch(enabledPlugins: readonly string[]): string {
const rows = [RUNTIME_ROW, ...enabledPlugins.map((id) => ` - id: ${id}\n name: ${id}`)]
return `- insert:\n${rows.join('\n')}\n`
}
+633
View File
@@ -0,0 +1,633 @@
/**
* Reverse proxy from the orchestrator to a running per-user DSH.
*
* Two entry points:
* - subpath `/u/:slug/dsh/*` (authenticated, legacy), and
* - per-user subdomain `<username>.<baseDomain>` (HTTP + WebSocket). The DSH's
* absolute-path SPA requires the subdomain form: its `/assets/*` and `/api/*`
* resolve against the host root, which only works when each DSH owns a host.
* @module dshs/supervisor/proxy
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Agent, request as httpRequest, type IncomingHttpHeaders, type IncomingMessage } from 'node:http'
import { connect } from 'node:net'
import { hashSessionToken, parseCookie } from '../web/auth.js'
import { requireAuth } from '../web/middleware/authn.js'
import type { Endpoint } from './spawner.js'
// Keep-alive pool for per-user DSH upstreams. Replaced (not just destroyed) on a
// connection error, because a Pod rebuild changes its IP and any pooled socket
// to the old IP would keep failing (docs/k8s.md §5.4).
let upstreamAgent = new Agent({ keepAlive: true, maxSockets: 32 })
// Headers the DSH's browser-trust fence must NOT see from the browser, so the
// proxied request looks like a clean loopback client (its Host is overridden to
// loopback; a mismatched Origin would otherwise 403).
const STRIP_HEADERS = new Set([
'origin',
'referer',
'sec-fetch-site',
'sec-fetch-mode',
'sec-fetch-dest',
'sec-fetch-user',
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-proto',
])
/**
* 档案 50:注入到**实例 HTML**里的「会话过期自愈」脚本。
*
* 为什么需要它:实例被空闲回收后**重建**(新端口 + 新 launch token),而**已打开的页面**
* 仍带着旧 `dsh-auth-*` cookie → 之后任何 `/api/*` XHR 都会被新实例判 **401**,
* dsh 前端只显示 `transport failure … HTTP 401`,用户只能手动刷新。
* 导航路径的 401 已在上面处理(302 到当前实例新 token),但 **XHR 无法靠 302 自愈**,
* 所以给页面这段脚本:发现 `/api/*` 返回 401 就显示覆盖层并 reload ——
* reload 会走"导航 401 → 302 新 token"这条**已经工作**的链路,从而自动恢复。
* 仅改写响应内容,不落盘、不改官方文件(R2);README/技能已留档。
*/
// 注入到实例子域页面的自愈脚本(档案 50 + 档案 59 增强)。
// ① 401 自愈:实例被回收重建后 launch token 轮换,页面内 /api/* 会拿到 401 → 亮覆盖层并整页 reload。
// ② 慢请求提示(档案 59):服务端在实例未就绪时 hold 住请求最长 20 秒等拉起,
// 期间浏览器端原本毫无反馈(点了重连也看不出在等什么)→ 挂起 ≥3 秒即显示「实例正在启动」。
// ③ 回到页面自检 + 就地恢复(档案 77):判据从「进程在不在跑」改为「页面还能不能连上实例」,
// 并把恢复过程做成**看得见**的(顶部轻提示 → 覆盖层),恢复后原地跳回而不是离开到门户域。
// 保持多行形式便于维护;注入时整段塞进 <script>,故内容不得含反引号 / ${。
// 档案 81 · R1-①:从**独立文件**加载(原先是 TS 模板字面量 —— 里面的 \n 会在模板求值时先被转义,
// 曾把整段脚本写崩成 SyntaxError,而校验跳过了"求值"这一步,形同虚设)。
// 独立文件是纯 JS:可 node --check 直接校验,不再有转义陷阱。
/** 档案 81 · R1-①:注入脚本从 `assets/inject/` 读取(纯 JS,可 node --check;无模板转义陷阱)。
* 找不到文件 = **启动即失败**(fail-fast):宁可平台起不来,也不要把"没有注入"的页面静默发给用户。 */
function loadInject(file: string): string {
// ⚠️ 本项目是 ESM(package.json "type": "module")—— **没有 __dirname**;
// 用 import.meta.url 定位(2026-09-13 实测:写成 __dirname 会让整个平台起不来)
const p = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'assets', 'inject', file)
try {
return readFileSync(p, 'utf8')
} catch (err) {
throw new Error(`[inject] 读取注入脚本失败: ${p}(档案 81 R1:assets/inject 必须随包部署): ${String(err)}`)
}
}
const SESSION_RECOVERY_JS = loadInject('recovery.js')
/**
* 档案 56:注入到实例页面的「实例助手」——右下角两个入口:
* ① 我的文件:浏览自己的工作区并**下载**任意文件(走平台 `/api/desktop/tree` + `/api/fs/download`,
* 不暴露宿主绝对路径;此前用户拿到的只是 `/var/lib/...` 路径,浏览器打不开);
* ② 能力:展示平台生成的实例能力清单(与 `bundled-skills/platform-capabilities` 同源)。
* 另外:读取 `/api/dsh/session-permission`,若**老会话仍处于 workspace-write**(沙箱后端不可用 →
* bash 会被 fail-closed 拒绝)则顶部给一条可操作的提示(含切换办法)。
* 纯前端注入,不改官方包、不落盘(R2);失败静默,绝不影响实例本身。
*/
// 档案 81 · R1-①:从**独立文件**加载(原先是 TS 模板字面量 —— 里面的 \n 会在模板求值时先被转义,
// 曾把整段脚本写崩成 SyntaxError,而校验跳过了"求值"这一步,形同虚设)。
// 独立文件是纯 JS:可 node --check 直接校验,不再有转义陷阱。
const SESSION_ASSIST_JS = loadInject('assist.js')
/** 非 HTML / 已压缩 / 无正文的状态直接透传,避免破坏二进制或已编码内容。 */
function injectRecovery(html: string): string {
const tag =
'<script>' + SESSION_RECOVERY_JS + '</script>' + '<script>' + SESSION_ASSIST_JS + '</script>'
return html.includes('</body>') ? html.replace('</body>', tag + '</body>') : html + tag
}
function buildUpstreamHeaders(headers: IncomingHttpHeaders, port: number): Record<string, string | string[]> {
const out: Record<string, string | string[]> = {}
for (const [key, value] of Object.entries(headers)) {
if (value === undefined || STRIP_HEADERS.has(key.toLowerCase())) continue
out[key] = value as string | string[]
}
// Keep Host loopback: DSH's /api trust fence requires the Host to be loopback
// or a `--trusted-host` authority — a real domain would 403 every /api call.
// DSH's absolute URLs are rewritten to the real origin in proxyHttp below.
out.host = `127.0.0.1:${port}`
return out
}
/** 档案 51:从 freshAuthUrl 的 `?token=` 中取出当前实例的 launch token。 */
function tokenFromAuthUrl(url: string | undefined): string | undefined {
if (url === undefined) return undefined
const m = /[?&]token=([^&]+)/.exec(url)
return m === null || m[1] === undefined ? undefined : decodeURIComponent(m[1])
}
/**
* 档案 51:向**当前**实例要一份有效的浏览器凭证 cookie。
*
* 实例每次 (重)启动都会换 launch token **和** `dsh-auth-<随机后缀>` 的 cookie 名。
* 已打开的页面还带着旧名字的 cookie → 新实例一律判 401。这里 GET `/?token=<当前>`
* (实例回 303 + Set-Cookie),把 set-cookie 原样取出,供代理重放请求与回写浏览器。
*/
function fetchInstanceAuthCookies(endpoint: Endpoint, token: string): Promise<string[]> {
return new Promise((resolve) => {
let done = false
const finish = (v: string[]): void => {
if (!done) {
done = true
resolve(v)
}
}
const req = httpRequest(
{
host: endpoint.host,
port: endpoint.port,
path: '/?token=' + encodeURIComponent(token),
method: 'GET',
headers: { host: `127.0.0.1:${endpoint.port}`, 'user-agent': 'dsh-proxy-auth-refresh' },
},
(res) => {
const sc = res.headers['set-cookie']
res.resume()
finish(Array.isArray(sc) ? sc : sc === undefined ? [] : [sc])
},
)
req.on('error', () => finish([]))
req.setTimeout(5000, () => {
req.destroy()
finish([])
})
req.end()
})
}
/**
* 档案 51:把浏览器带来的 cookie 与实例的新 cookie 合并 —— 丢掉旧的 `dsh-auth-*`
* (新实例只认自己那份;旧名留着也没用),其余(`sid` 等代理不关心)原样保留。
*/
function mergeCookieHeader(original: string | undefined, fresh: string[]): string {
const keep = (original ?? '')
.split(';')
.map((x) => x.trim())
.filter((x) => x !== '' && !/^dsh-auth-/.test(x))
const add = fresh
.map((c) => (c.split(';')[0] ?? '').trim())
.filter((x) => x !== '')
return [...keep, ...add].join('; ')
}
/** Extract `<slug>` from `<slug>.<baseDomain>`, or null when not a match. */
export function parseSubdomain(host: string | undefined, baseDomain: string): string | null {
if (host === undefined || baseDomain === '') return null
const name = host.split(':')[0] ?? ''
if (name === baseDomain) return null
if (name.endsWith('.' + baseDomain)) {
const slug = name.slice(0, -(baseDomain.length + 1))
return slug !== '' ? slug.toLowerCase() : null
}
return null
}
/** The per-user subdomain for a username, or null when `baseDomain` is unset. */
export function subdomainForUser(baseDomain: string, username: string): string | null {
return baseDomain === '' ? null : `${username.toLowerCase()}.${baseDomain}`
}
/** The browser-facing origin (`<scheme>://<host>`) this request reached us with,
* used to rewrite DSH's loopback absolute URLs back to the real domain. */
function realOrigin(headers: IncomingHttpHeaders): string | undefined {
const host = clientHost(headers)
if (host === undefined) return undefined
const proto = headers['x-forwarded-proto']
const scheme = typeof proto === 'string' && proto !== '' ? proto : 'https'
return `${scheme}://${host}`
}
/** A subdomain resolution: a tunnelable endpoint, an error to return, or null (not a subdomain). */
type SubdomainAccess = { endpoint: Endpoint; userId: string } | { error: string; code: number; userId?: string } | null
/**
* The client-facing host, preferring `X-Forwarded-Host`. The control plane sits
* behind an edge proxy (Tencent nginx) that hides the real Host to sidestep the
* cloud provider's ICP check (docs/k8s-deploy.md §7.4); the real domain arrives
* here. The subdomain auth check still validates the cookie against the slug, so
* a spoofed forwarded host cannot reach another user's DSH.
*/
function clientHost(headers: IncomingHttpHeaders): string | undefined {
const fwd = headers['x-forwarded-host']
if (typeof fwd === 'string' && fwd !== '') return fwd
if (Array.isArray(fwd) && fwd[0] !== '') return fwd[0]
return headers.host
}
/**
* Resolve a subdomain Host to a DSH port, authenticating the caller: the session
* cookie must belong to a non-disabled user whose username matches the subdomain.
*/
async function resolveSubdomainAccess(
app: FastifyInstance,
host: string | undefined,
cookieHeader: string | undefined,
): Promise<SubdomainAccess> {
const slug = parseSubdomain(host, app.config.baseDomain)
if (slug === null) return null
const target = await app.db.findUserBySlug(slug)
if (target === undefined) return { error: 'unknown_user', code: 404 }
const token = parseCookie(cookieHeader, 'sid')
const session = token === undefined ? undefined : await app.db.findSessionWithUser(hashSessionToken(token))
if (session === undefined || session.expiresAt <= Date.now() || session.user.role === 'disabled') {
return { error: 'unauthorized', code: 401 }
}
if (session.user.username.toLowerCase() !== slug) {
return { error: 'forbidden', code: 403 }
}
const endpoint = await app.supervisor.endpointFor(session.user.id)
if (endpoint === undefined) return { error: 'not_running', code: 404, userId: session.user.id }
// userId 一并返回:实例侧 401(launch token 过期)时用它取当前实例的新 token(档案 24)。
// Real user traffic to their own DSH counts as activity (idle-reap signal).
app.supervisor.touch(session.user.id)
return { endpoint, userId: session.user.id }
}
function proxyHttp(
request: FastifyRequest,
reply: FastifyReply,
endpoint: Endpoint,
targetPath: string,
rewritePrefix?: string,
rewriteLoopbackLocation = false,
useKeepAlive = false,
/**
* 实例侧 401 时的恢复入口(档案 24):浏览器导航遇到 dsh 的 "authentication required"
* (launch token 过期 —— 实例重启/回收后刷新旧标签页)时调用,返回应 302 到的地址。
* 返回 undefined 则回落到 '/'。
*/
freshAuthUrl?: () => Promise<string | undefined>,
): void {
reply.hijack()
// 档案 51:为「401 透明重放」准备请求体。
// 只在 content-length 已知且不大时才缓冲(普通 /api JSON-RPC 请求都很小);
// 未知长度(chunked 上传)不缓冲 → 该请求退回旧行为(401 透传),不做重放。
const MAX_REPLAY_BODY = 8 * 1024 * 1024
const reqMethod = (request.raw.method ?? 'GET').toUpperCase()
const mayHaveBody = reqMethod !== 'GET' && reqMethod !== 'HEAD'
const clNum =
typeof request.headers['content-length'] === 'string' ? Number(request.headers['content-length']) : NaN
const bufferable = mayHaveBody && Number.isFinite(clNum) && clNum <= MAX_REPLAY_BODY
let bodyBuf: Buffer | undefined
// ★ 事后修正 1(2026-09-11):重放闸门**不能**用 bufferable —— 它含 mayHaveBody,
// 会让 GET/HEAD(含 dsh 的 SSE 会话流)永远无法重放。GET 没有请求体,反而最该能重放。
const canReplay = mayHaveBody ? false : true
let authRetryUsed = false
let replayCookies: string[] = []
const attempt = (connRetry: boolean, authCookie?: string): void => {
// 档案 50 修正(实测定位):**HTML 导航请求必须向上游要 identity**。
// 原因:dsh 实例对带 Accept-Encoding 的请求会 **gzip 压缩 HTML**(浏览器就带),
// 于是响应带 content-encoding → 我下面的注入逻辑**主动跳过** → 浏览器拿到的页面
// **没有自愈脚本** → C 方案对真实用户无效(而 curl 不带 Accept-Encoding,故此前验证
// 是假阳性)。改法:只要**客户端接受 HTML**,就覆盖转发的 accept-encoding 为 identity,
// 让上游回未压缩 HTML → 注入成功。体积影响可忽略(HTML ~24KB),且边缘 nginx 若开 gzip
// 仍会对浏览器压缩,用户侧无感知。静态资源/SSE 的压缩行为不受影响。
const upHeaders = buildUpstreamHeaders(request.headers, endpoint.port)
if (String(request.headers.accept ?? '').includes('text/html')) {
upHeaders['accept-encoding'] = 'identity'
}
// 档案 51:重放时用**当前实例的新 cookie** 覆盖浏览器带来的旧 cookie。
if (authCookie !== undefined) upHeaders.cookie = authCookie
if (bodyBuf !== undefined) {
// 请求体已缓冲 → 显式带上长度并去掉可能存在的 chunked 头,保证重放一致。
delete upHeaders['transfer-encoding']
upHeaders['content-length'] = String(bodyBuf.length)
}
const upstream = httpRequest(
{
host: endpoint.host,
port: endpoint.port,
path: targetPath,
method: request.method,
// Keep-alive only where it is required (k8s Headless Service DNS
// re-resolution on retry — docs/k8s.md §5.4). Local mode uses a fresh
// connection per request: the loopback connect cost is negligible and
// this avoids the half-open keep-alive pool that hung the proxy after
// child-instance restarts (2026-09-09 outage: pooled sockets to a
// since-recycled instance port never errored, so requests hung).
agent: useKeepAlive && !connRetry ? upstreamAgent : false,
// Host header stays loopback for the DSH trust fence; the TCP target host
// is endpoint.host above. k8s mode overrides the header port separately.
headers: upHeaders,
},
(upRes: IncomingMessage) => {
const headers = { ...upRes.headers }
const isNav =
request.raw.method === 'GET' && String(request.headers.accept ?? '').includes('text/html')
// 档案 51:**非导航**请求的实例侧 401 = 页面拿着旧实例的 dsh-auth cookie。
// 透明重放:取当前实例新 cookie → 覆盖 cookie 头重发同一请求 → 新 cookie 回写浏览器。
// 只重放一次(authRetryUsed),避免与实例互相刷 401 造成死循环。
const replayReady = mayHaveBody ? bodyBuf !== undefined : canReplay
if (upRes.statusCode === 401 && !isNav && !authRetryUsed && freshAuthUrl !== undefined && replayReady) {
authRetryUsed = true
upRes.resume()
void (async () => {
let cookieHeader: string | undefined
try {
const url = await freshAuthUrl()
const tk = tokenFromAuthUrl(url)
if (tk !== undefined) {
const fresh = await fetchInstanceAuthCookies(endpoint, tk)
if (fresh.length > 0) {
replayCookies = fresh
cookieHeader = mergeCookieHeader(request.headers.cookie, fresh)
}
}
} catch {
/* 取不到 → 回落到原样 401 */
}
if (cookieHeader === undefined) {
reply.raw.writeHead(401, headers)
reply.raw.end('unauthorized')
return
}
process.stderr.write(
`[proxy-auth-replay] ${request.raw.method ?? 'GET'} ${targetPath}(旧 cookie → 实例新 cookie,重放)\n`,
)
attempt(false, cookieHeader)
})()
return
}
// 重放成功后把新 cookie 交给浏览器:之后(含 SSE 自动重连)不再需要重放。
if (authCookie !== undefined && replayCookies.length > 0) {
const prev = headers['set-cookie']
headers['set-cookie'] = [
...(Array.isArray(prev) ? prev : prev === undefined ? [] : [prev]),
...replayCookies,
]
}
const location = upRes.headers.location
if (
rewritePrefix !== undefined &&
typeof location === 'string' &&
location.startsWith('/') &&
!location.startsWith('//') &&
!location.startsWith(rewritePrefix)
) {
headers.location = rewritePrefix + location
}
// ── 2026-09-12(平台缓存治理):让「改了 client bundle 却看不到变化」不再发生 ──
//
// 背景(档案 67 v0.2.6 实测,排查成本极高):
// dsh 的客户端模块 URL 形如 `/plugins/??<pkg>/client.js,…&rev=<内容 sha1>`,
// `rev` 由 **dsh 官方**按内容计算(`dsh-client-modules`),**平台不得改(R2)**。
// 平台更新 bundle 后,若 `rev` 未变 → 浏览器认为手上那份缓存仍有效 → **不重新请求**
// ⇒ 服务端已是新版、用户却一直看到旧 UI(本次:SQL 层/磁盘/服务端三处都验过是新的)。
//
// 处置:对实例的**模块/静态资源路径**统一加 `Cache-Control: no-cache` ——
// **允许缓存,但每次必须回源校验**(配合上游 ETag/Last-Modified 走 304,开销极小)。
// ⇒ bundle 一变,浏览器下一次请求就拿到新的,**用户无需清缓存/换无痕**。
//
// ⚠️ 勿删:这是 UI 改动能否被验收的前置机制(`06-工作台UI规范 §6.5`)。
if (targetPath.startsWith('/plugins/') || targetPath.startsWith('/assets/')) {
headers['cache-control'] = 'no-cache'
}
// local mode only: DSH builds absolute URLs from the loopback Host we
// forward; rewrite any 127.0.0.1 Location to the real origin so the
// browser doesn't jump to the user's own machine. k8s mode keeps its
// verified behavior (no rewrite).
if (
rewriteLoopbackLocation &&
typeof location === 'string' &&
realOrigin(request.headers) !== undefined
) {
headers.location = location.replace(/^https?:\/\/127\.0\.0\.1(:\d+)?/, realOrigin(request.headers)!)
}
// 2026-09-11(档案 24):实例侧 401 = launch token 过期。浏览器导航时不要停在
// dsh 的 "dsh web authentication required; reopen the URL printed by dsh web." 死端页,
// 而是用当前实例的新 token 302 回同一地址(拿不到则回门户)。
if (
upRes.statusCode === 401 &&
request.raw.method === 'GET' &&
String(request.headers.accept ?? '').includes('text/html') &&
freshAuthUrl !== undefined
) {
upRes.resume()
void freshAuthUrl()
.then((url) => {
reply.raw.writeHead(302, { location: url ?? '/' })
reply.raw.end()
})
.catch(() => {
reply.raw.writeHead(302, { location: '/' })
reply.raw.end()
})
return
}
// 档案 50:HTML 响应缓冲后注入「会话过期自愈」脚本(其余一切原样 pipe,零影响)。
const ctype = String(upRes.headers['content-type'] ?? '')
const enc = upRes.headers['content-encoding']
const status = upRes.statusCode ?? 502
const canInject =
ctype.includes('text/html') && enc === undefined && status !== 204 && status !== 304
if (!canInject) {
if (ctype.includes('text/html') && enc !== undefined) {
process.stderr.write(`[inject-recovery] skip: content-encoding=${String(enc)}\n`)
}
reply.raw.writeHead(status, headers)
upRes.pipe(reply.raw)
return
}
const chunks: Buffer[] = []
upRes.on('data', (c: Buffer) => chunks.push(c))
upRes.on('end', () => {
const out = injectRecovery(Buffer.concat(chunks).toString('utf8'))
delete headers['content-length']
delete headers['transfer-encoding']
headers['content-length'] = String(Buffer.byteLength(out))
reply.raw.writeHead(status, headers)
reply.raw.end(out)
})
upRes.on('error', () => reply.raw.destroy())
},
)
upstream.on('error', () => {
if (connRetry) {
reply.raw.destroy()
return
}
// A stale keep-alive socket or a Pod that just restarted: drop the pool,
// replace it with a fresh one, and retry once on a fresh connection.
upstreamAgent.destroy()
upstreamAgent = new Agent({ keepAlive: true, maxSockets: 32 })
request.raw.unpipe(upstream)
attempt(true)
})
if (bodyBuf !== undefined) upstream.end(bodyBuf)
else request.raw.pipe(upstream)
}
if (!bufferable) {
attempt(false)
return
}
const pre: Buffer[] = []
request.raw.on('data', (c: Buffer) => pre.push(c))
request.raw.on('end', () => {
bodyBuf = Buffer.concat(pre)
attempt(false)
})
request.raw.on('error', () => reply.raw.destroy())
}
export async function registerDshProxy(app: FastifyInstance): Promise<void> {
// Legacy authenticated subpath proxy.
app.all('/u/:slug/dsh/*', { preHandler: requireAuth }, async (request, reply) => {
const slug = (request.params as { slug: string }).slug
if (request.user === null || request.user.id !== slug) {
reply.code(403).send({ error: 'forbidden' })
return
}
const endpoint = await app.supervisor.endpointFor(slug)
if (endpoint === undefined) {
reply.code(404).send({ error: 'not_running' })
return
}
app.supervisor.touch(request.user!.id)
const prefix = `/u/${slug}/dsh`
const rawUrl = request.raw.url ?? '/'
const targetPath = rawUrl.startsWith(prefix) ? rawUrl.slice(prefix.length) || '/' : rawUrl
const pathScheme = app.config.secureCookies ? 'https' : 'http'
proxyHttp(
request,
reply,
endpoint,
targetPath,
prefix,
app.config.deployMode === 'local',
app.config.deployMode === 'k8s',
async () => {
const status = await app.supervisor.status(request.user!.id)
const token = status.main?.launchToken
return token !== undefined && token !== ''
? `${pathScheme}://${app.config.baseDomain}${prefix}/?token=${encodeURIComponent(token)}`
: `${pathScheme}://${app.config.baseDomain}/`
},
)
})
// Per-user subdomain: HTTP (intercept before normal routing).
app.addHook('onRequest', async (request, reply) => {
const access = await resolveSubdomainAccess(app, clientHost(request.headers), request.headers.cookie)
if (access === null) return
if ('error' in access) {
// 浏览器导航(GET + 期待 HTML)的 401(会话失效/退出后刷新实例子域)→ 302 回门户
// 登录页,而不是吐 JSON {"error":"unauthorized"} 停在原地。
if (
access.code === 401 &&
request.raw.method === 'GET' &&
(request.headers.accept ?? '').includes('text/html')
) {
const scheme = app.config.secureCookies ? 'https' : 'http'
reply.redirect(`${scheme}://${app.config.baseDomain}/login.html`)
return
}
// 实例未运行(后台回收/崩溃/停止/熔断后刷新会话页)→ 需要拉起。
//
// 2026-09-11(档案 49 · 体验修复):**浏览器导航一律"立刻" 302 到平台自有的过渡页**,
// 由过渡页显示加载动画并自己调 /api/dsh/enter 完成「拉起 + 等 token + 跳回」。
// 原因:此前导航请求会在**服务端阻塞等待 launch token(最长 20 秒)**,这期间浏览器
// 只看到白屏、没有任何反馈 → 用户以为卡死,只能手动刷新(那时实例已就绪,看似"刷新才好")。
// 现在导航分支**不阻塞**(0.2s 内返回 302),动画立刻出现,且**不会**在这里触发 launch
//(拉起交给过渡页,单点、可重试)。
if (access.code === 404 && access.error === 'not_running' && access.userId !== undefined) {
const navScheme = app.config.secureCookies ? 'https' : 'http'
const navHost = clientHost(request.headers) ?? app.config.baseDomain
const isNavigation =
request.raw.method === 'GET' && (request.headers.accept ?? '').includes('text/html')
if (isNavigation) {
const next = `${navScheme}://${navHost}${request.raw.url ?? '/'}`
reply.redirect(
`${navScheme}://${app.config.baseDomain}/wake.html?next=${encodeURIComponent(next)}`,
)
return
}
// 非导航(XHR / API)——**这是"页面已打开、再对话没反应"的主场景**。
// 正解不是"回一个错误/动画",而是**等实例就绪后继续转发**:请求最终成功,
// dsh 前端自己会保持它的 loading 态 → 用户无感,不需要刷新。
try {
const folderAbs = app.userFs.resolvePath(access.userId, '')
try {
await app.supervisor.launch(access.userId, folderAbs, undefined)
} catch {
// 并发进场(另一请求正在拉起 → AlreadyRunningError):等它拿到 token
await app.supervisor.waitForLaunchTokenForUser(access.userId, 20000)
}
} catch {
/* 拉起失败 → 落到下面的 503 */
}
const again = await resolveSubdomainAccess(app, clientHost(request.headers), request.headers.cookie)
if (again !== null && !('error' in again)) {
proxyHttp(
request,
reply,
again.endpoint,
request.raw.url ?? '/',
undefined,
app.config.deployMode === 'local',
app.config.deployMode === 'k8s',
async () => {
const st = await app.supervisor.status(again.userId)
const tk = st.main?.launchToken
return tk !== undefined && tk !== ''
? `${navScheme}://${navHost}/?token=${encodeURIComponent(tk)}`
: `${navScheme}://${app.config.baseDomain}/`
},
)
return
}
// 真的起不来:给明确的「启动中」+ Retry-After,让前端自行退避重试。
reply.code(503).header('retry-after', '3').send({ error: 'instance_starting' })
return
}
reply.code(access.code).send({ error: access.error })
return
}
const navScheme = app.config.secureCookies ? 'https' : 'http'
const navHost = clientHost(request.headers) ?? app.config.baseDomain
proxyHttp(
request,
reply,
access.endpoint,
request.raw.url ?? '/',
undefined,
app.config.deployMode === 'local',
app.config.deployMode === 'k8s',
async () => {
const status = await app.supervisor.status(access.userId)
const token = status.main?.launchToken
return token !== undefined && token !== ''
? `${navScheme}://${navHost}/?token=${encodeURIComponent(token)}`
: `${navScheme}://${app.config.baseDomain}/`
},
)
})
// Per-user subdomain: WebSocket upgrade tunnel. Auth is async (DB lookup), so
// the raw `upgrade` callback defers to an async IIFE before deciding to tunnel.
app.server.on('upgrade', (req, socket, head) => {
void (async () => {
const access = await resolveSubdomainAccess(app, clientHost(req.headers), req.headers.cookie)
if (access === null || 'error' in access) {
socket.destroy()
return
}
const upstream = connect({ host: access.endpoint.host, port: access.endpoint.port })
upstream.on('connect', () => {
const lines = [`${req.method} ${req.url} HTTP/${req.httpVersion}`]
for (let i = 0; i < req.rawHeaders.length; i += 2) {
const name = (req.rawHeaders[i] ?? '').toLowerCase()
if (name === 'host' || STRIP_HEADERS.has(name)) continue
lines.push(`${req.rawHeaders[i]}: ${req.rawHeaders[i + 1]}`)
}
lines.push(`Host: 127.0.0.1:${access.endpoint.port}`)
upstream.write(lines.join('\r\n') + '\r\n\r\n')
if (head !== undefined && head.length > 0) upstream.write(head)
socket.pipe(upstream)
upstream.pipe(socket)
})
upstream.on('error', () => socket.destroy())
socket.on('error', () => upstream.destroy())
})()
})
}
+170
View File
@@ -0,0 +1,170 @@
/**
* Leader-only controller: reconciles the cluster against the desired state in
* `dsh_instances` (docs/k8s.md §5.7) and watches the main DSH Pods for crashes.
*
* The k8s backend has no child-process `exit` event the way the local backend
* does — a crashed Pod is observed either by the informer (here) or by the next
* reconcile tick (a desired main whose Pod is gone). Both funnel into the same
* repair path: mark the instance crashed, pull up the one-shot watchdog Job,
* and let the reconcile relaunch the main if its Pod stays absent.
* @module dshs/supervisor/reconcile
*/
import { makeInformer, type Informer } from '@kubernetes/client-node'
import type { DbAdapter } from '../db/adapter.js'
import type { DshInstance } from '../db/types.js'
import type { LeaderElector } from './leader.js'
import type { K8sSpawner } from './k8s-spawner.js'
import type { LivePod } from './spawner.js'
/** The result of diffing desired state against live Pods. Pure and testable. */
export interface ReconcilePlan {
/** Desired mains whose Pod is missing → relaunch. */
launch: DshInstance[]
/** Live Pods with no desired row → delete (orphans). */
delete: LivePod[]
}
/** Diff desired mains vs live Pods. */
export function planReconcile(desired: DshInstance[], live: LivePod[]): ReconcilePlan {
const liveByUser = new Map(live.map((pod) => [pod.userId, pod]))
const launch: DshInstance[] = []
for (const instance of desired) {
if (instance.role !== 'main') continue
const pod = liveByUser.get(instance.userId)
if (pod === undefined || !pod.running) launch.push(instance)
}
const desiredUsers = new Set(desired.filter((i) => i.role === 'main').map((i) => i.userId))
const del = live.filter((pod) => !desiredUsers.has(pod.userId))
return { launch, delete: del }
}
/** Loop that reconciles only while leader, plus a Pod informer for crashes. */
export class ReconcileController {
private informer: Informer<object> | undefined
private timer: NodeJS.Timeout | undefined
private leading = false
private readonly watchdogFired = new Set<string>()
private readonly intervalMs: number
constructor(
private readonly db: DbAdapter,
private readonly spawner: K8sSpawner,
private readonly elector: LeaderElector,
intervalMs = 10_000,
) {
this.intervalMs = intervalMs
elector.setLeadershipCallbacks(
(fencing) => {
this.leading = true
this.spawner.setFencing(fencing)
this.startInformer()
this.scheduleTick(0)
},
() => {
this.leading = false
this.stopInformer()
},
)
}
async start(): Promise<void> {
await this.elector.start()
}
stop(): void {
this.elector.stop()
this.stopInformer()
if (this.timer !== undefined) {
clearTimeout(this.timer)
this.timer = undefined
}
}
private startInformer(): void {
if (this.informer !== undefined) return
this.informer = this.spawner.watchMainPods((pod) => this.onPodEvent(pod))
void this.informer.start().catch(() => {
// The informer is best-effort; the reconcile tick still converges.
this.informer = undefined
})
}
private stopInformer(): void {
void this.informer?.stop().catch(() => {})
this.informer = undefined
}
private scheduleTick(delayMs: number): void {
if (this.timer !== undefined) return
this.timer = setTimeout(() => {
this.timer = undefined
if (!this.leading) return
void this.tick().finally(() => this.scheduleTick(this.intervalMs))
}, delayMs)
this.timer.unref?.()
}
private async tick(): Promise<void> {
const [desired, live] = await Promise.all([
this.db.listInstancesByRole('main'),
this.spawner.listUserPods(),
])
const plan = planReconcile(desired, live)
for (const orphan of plan.delete) {
// Orphan = a main Pod with no desired row (user deleted or disabled).
await this.safe(`orphan ${orphan.userId}`, async () => {
await this.db.deleteInstance(orphan.name)
await this.spawner.stop(orphan.userId)
})
}
for (const instance of plan.launch) {
await this.safe(`launch ${instance.userId}`, () =>
this.spawner.launch(instance.userId, instance.folder ?? '', instance.patch ?? undefined))
}
// Recreate a lost Service/NetworkPolicy for any desired main that still has a Pod.
for (const instance of desired) {
if (plan.launch.includes(instance)) continue
await this.safe(`ensure ${instance.userId}`, () => this.spawner.ensureUserResources(instance.userId))
}
// Idle reap (Phase 4): a desired main whose user has no active session has
// outlived its session TTL — stop the Pod and drop the desired row so the
// next tick does not relaunch it.
for (const instance of desired) {
await this.safe(`reap ${instance.userId}`, async () => {
if (await this.db.hasActiveSession(instance.userId)) return
await this.spawner.stop(instance.userId)
await this.db.deleteInstance(instance.id)
})
}
}
/** Run one per-user step without letting its failure abort the rest of the tick. */
private async safe(label: string, fn: () => Promise<unknown>): Promise<void> {
try {
await fn()
} catch (err) {
// One bad user (e.g. a files Pod that can't become Ready) must not block
// launches/idle-reap for every other user.
this.spawner.logError?.(new Error(`reconcile ${label}: ${err instanceof Error ? err.message : String(err)}`))
}
}
private async onPodEvent(pod: LivePod): Promise<void> {
if (!pod.crashed || !pod.running) {
// A healthy transition clears the "already fired" guard so a later crash
// triggers the watchdog again.
if (pod.running && !pod.crashed) this.watchdogFired.delete(pod.userId)
return
}
if (this.watchdogFired.has(pod.userId)) return
this.watchdogFired.add(pod.userId)
try {
await this.spawner.spawnWatchdog(pod.userId)
} catch (err) {
this.watchdogFired.delete(pod.userId)
this.spawner.logError?.(err)
}
}
}
+118
View File
@@ -0,0 +1,118 @@
/**
* 会话权限档位读取(档案 56)。
*
* 背景:档位由 `DSH_PERMISSION_MODE` 决定,但 **dsh 在"会话创建时"把它播种进会话**,
* 之后平台改默认值**不会**更新既有会话 → 老会话仍停在 `workspace-write`,而本机沙箱
* 后端不可用(内核无 Landlock、bwrap 在平台合成根内探测失败)→ dsh **fail-closed 拒绝
* 任何 shell**。用户只会看到「bash 不可用」,无从判断原因。
*
* 本模块从会话事件流里读出**最近修改的那个会话**的 `permission/preset`,交给路由层
* 判断"是否与平台默认不一致",从而在实例页面上给出提示。
*
* 只读:仅解压文件头部的若干 zstd 帧,不写任何东西。
* @module dshs/supervisor/session-preset
*/
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { zstdDecompressSync } from 'node:zlib'
/** zstd 帧魔数:会话文件是**多帧拼接**,必须按它切分后逐帧解压。 */
const MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
/** 解压文件头部若干帧(够覆盖 session 元信息与 permission/preset 记录,通常在第 1 帧)。 */
function headFrames(buf: Buffer, maxFrames = 6): string {
const offsets: number[] = []
for (let i = 0; i + 4 <= buf.length; i++) {
if (buf.compare(MAGIC, 0, 4, i, i + 4) === 0) offsets.push(i)
}
const list = offsets.length > 0 ? offsets.slice(0, maxFrames) : [0]
let out = ''
for (let f = 0; f < list.length; f++) {
const start = list[f]
const end = f + 1 < list.length ? list[f + 1] : buf.length
try {
out += zstdDecompressSync(buf.subarray(start, end)).toString('utf8')
} catch {
/* 单帧损坏/被截断:忽略,继续下一帧 */
}
}
return out
}
export interface SessionPreset {
/** 会话目录名(`session-<uuid>` 或裸 uuid)。 */
sessionId: string
/** 该会话当前生效的权限档位(取最后一次 `permission/preset` 记录)。 */
preset: string
/** 会话文件最后修改时间(ms)。 */
updatedAt: number
}
/** 用户数据根下的会话目录:`<userRoot>/home/sessions`。 */
export function sessionsDir(homeRoot: string): string {
return join(homeRoot, 'sessions')
}
/**
* 读一个 `session.jsonl.zstd` 的权限档位。
* 取**最后一条** `permission/preset`(用户在 UI 里切换档位会追加新记录)。
*/
function readPreset(file: string): string | undefined {
let text: string
try {
const st = statSync(file)
const HEAD = 256 * 1024
const TAIL = 64 * 1024
if (st.size <= 4 * 1024 * 1024) {
// 常见会话 < 4MB:直接整读(切换档位的记录可能在文件尾)
text = headFrames(readFileSync(file), 12)
} else {
const fd = openSync(file, 'r')
try {
const head = Buffer.allocUnsafe(HEAD)
const readHead = readSync(fd, head, 0, HEAD, 0)
const tailLen = Math.min(TAIL, st.size)
const tail = Buffer.allocUnsafe(tailLen)
readSync(fd, tail, 0, tailLen, st.size - tailLen)
text = headFrames(head.subarray(0, readHead), 6) + headFrames(tail, 6)
} finally {
closeSync(fd)
}
}
} catch {
return undefined
}
const re = /"type":"permission\/preset"[^\n]*?"preset":"([^"]+)"/g
let last: string | undefined
for (const m of text.matchAll(re)) last = m[1]
return last
}
/** 该用户**最近修改的**会话及其档位(跨全部工作区),无会话则返回 undefined。 */
export function latestSessionPreset(homeRoot: string): SessionPreset | undefined {
const dir = sessionsDir(homeRoot)
if (!existsSync(dir)) return undefined
let best: { file: string; id: string; mtime: number } | undefined
for (const slug of readdirSync(dir)) {
let sids: string[]
try {
sids = readdirSync(join(dir, slug))
} catch {
continue
}
for (const sid of sids) {
const file = join(dir, slug, sid, 'session.jsonl.zstd')
try {
const st = statSync(file)
if (!st.isFile()) continue
if (best === undefined || st.mtimeMs > best.mtime) best = { file, id: sid, mtime: st.mtimeMs }
} catch {
/* 无会话文件(空目录)→ 跳过 */
}
}
}
if (best === undefined) return undefined
const preset = readPreset(best.file)
if (preset === undefined) return undefined
return { sessionId: best.id, preset, updatedAt: best.mtime }
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Child-process helpers for the supervisor: env scrubbing and free-port lookup.
*
* Env scrubbing mirrors the harness `scrubbedParentEnv` / `SENSITIVE_ENV_PATTERN`
* doctrine (packages/subprocess/subprocess/src/index.ts): build the child env
* from a clean allowlist so no orchestrator secret leaks into a user DSH, then
* inject only the resolved per-user values.
* @module dshs/supervisor/spawn
*/
import { createServer } from 'node:net'
const ALLOWED_ENV = new Set([
'PATH',
'HOME',
'USER',
'TMP',
'TEMP',
'TMPDIR',
'SYSTEMROOT',
'SystemRoot',
'PATHEXT',
'ProgramFiles',
'ProgramFiles(x86)',
'LANG',
'LC_ALL',
// Shared read-only skill dir; injected explicitly in baseEnv, allowlisted here
// so it survives scrubEnv if ever set on the orchestrator process.
'DSH_BUNDLED_SKILL_DIR',
// 档案 33:实例内权限档位(dsh-base 读 DSH_PERMISSION_MODE 决定 sandbox mode + approval policy)。
// 只做 allowlist,实际值由 orchestrator.baseEnv 注入。
'DSH_PERMISSION_MODE',
// 档案 44:冻结实例的基础运行时版本(Python / pip / node 一律用平台装的那份,禁止版本漂移)。
// 这两条是**限制性** env(语义为收窄,不是扩大):
// · PYTHONNOUSERSITE=1 —— 不把 `$HOME/.local/lib/python*/site-packages` 加进 sys.path
// (实测:一旦该目录被 pip 创建,它就在 sys.path 里且**优先于平台 site-packages** →
// 用户装的同名包会盖住平台包,正是"版本差异导致插件功能不可用"的来源);
// · PYTHONUSERBASE=<只读占位位> —— 让 `pip install --user` **明确失败**而不是静默无效。
'PYTHONNOUSERSITE',
'PYTHONUSERBASE',
])
/** Drop credential-shaped and unknown env vars; keep only a safe allowlist. */
export function scrubEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const out: Record<string, string> = {}
for (const [key, value] of Object.entries(env)) {
if (ALLOWED_ENV.has(key) && value !== undefined) out[key] = value
}
return out
}
/** Reserve an ephemeral loopback port, release it, and return its number. */
export function findFreePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer()
server.on('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
const port = typeof address === 'object' && address !== null ? address.port : undefined
server.close(() => {
if (port !== undefined) resolve(port)
else reject(new Error('could not reserve a free port'))
})
})
})
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Backend abstraction for per-user DSH lifecycle (docs/k8s.md §5.2).
*
* `local` spawns child processes (setuid/iptables) via `LocalSpawner`; `k8s`
* creates/deletes per-user DSH Pods via the K8s API (`K8sSpawner`). The route
* layer depends only on this interface, so both backends coexist behind the
* same API. Shared instance/status types live here so neither backend owns
* them.
* @module dshs/supervisor/spawner
*/
export type InstanceStatus = 'starting' | 'running' | 'crashed' | 'stopped' | 'failed'
export type InstanceRole = 'main' | 'watchdog'
/** A tracked DSH instance (main or watchdog). `port`/`pid` are local-only. */
export interface Instance {
id: string
userId: string
role: InstanceRole
folder: string
port?: number
status: InstanceStatus
pid?: number
exitCode?: number
lastError?: string
/** Rendered cordis patch **content**, not a path — see {@link Spawner.launch}. */
patch?: string
/** dsh web 一次性 launch token(本地模式从子进程 stdout 解析),用于拼装可直达的打开 URL。 */
launchToken?: string
/** systemd scope 名(bwrap 沙箱隔离时),stop 时用 systemctl stop 正确终止整个 scope。 */
unit?: string
/** 崩溃自动重启次数(档案 20 观测面,随实例重建归零)。 */
restarts?: number
/** 最近一次崩溃时间(ms epoch)。 */
lastCrashedAt?: number
}
/** Thrown when a user already has a running main DSH. */
export class AlreadyRunningError extends Error {
constructor(userId: string) {
super(`user ${userId} already has a running DSH`)
this.name = 'AlreadyRunningError'
}
}
/**
* 档案 78:该用户的实例刚因崩溃循环被熔断,冷却期内拒绝启动。
*
* 为什么需要它:`circuit-open` 之后若允许立刻重来,崩溃循环可无限重复
* (用户 F5 / 注入脚本自愈 / 脚本直铺都可能触发),且平台只留一行 stderr。
* 冷却过后只给一次干净预算。
*/
export class CrashBreakerOpenError extends Error {
readonly userId: string
/** 冷却结束时刻(ms epoch)。 */
readonly retryAt: number
/** 累计熔断次数。 */
readonly opens: number
constructor(userId: string, retryAt: number, opens: number) {
const waitSec = Math.max(0, Math.ceil((retryAt - Date.now()) / 1000))
super(`user ${userId} instance crash-breaker open (opens=${opens}, retry in ${waitSec}s)`)
this.name = 'CrashBreakerOpenError'
this.userId = userId
this.retryAt = retryAt
this.opens = opens
}
/** 距可重试还剩多少毫秒。 */
get retryAfterMs(): number {
return Math.max(0, this.retryAt - Date.now())
}
}
/** A user's main + watchdog pair. */
export interface UserStatus {
main?: Instance
watchdog?: Instance
}
/** The host:port the proxy forwards a user's DSH traffic to. */
export interface Endpoint {
host: string
port: number
}
/** A main DSH Pod as observed by the k8s backend (for reconcile + the watch). */
export interface LivePod {
name: string
userId: string
running: boolean
crashed: boolean
}
/**
* The lifecycle seam the route layer delegates to.
*
* `endpointFor` is spawner-specific: local → `127.0.0.1:<port>`, k8s → the
* per-user Headless Service DNS (docs/k8s.md §5.4).
*
* `launch` takes the rendered cordis patch as **content**, not a path: under
* k8s the control plane holds no user volume, so it can neither write the patch
* nor read it back. `LocalSpawner` materializes it to a file (it does have the
* volume) and `K8sSpawner` puts it straight into a ConfigMap.
*/
export interface Spawner {
launch(userId: string, folder: string, patch?: string, opts?: { force?: boolean }): Promise<Instance>
restartMain(userId: string): Promise<Instance | undefined>
/**
* 档案 78:熔断观测面(可选 —— 熔断是**本地模式**概念,k8s 模式没有)。
* 返回 null = 该用户未被熔断冷却;非 null = 正在冷却(含累计熔断次数与解冻时刻)。
*/
breakerInfo?(userId: string): { opens: number; openedAt: number; cooldownUntil: number } | null
/** Restart every running main so a swapped global API key takes effect (env is a spawn-time snapshot). */
restartAllMains(): Promise<void>
spawnWatchdog(userId: string): Promise<Instance | undefined>
status(userId: string): Promise<UserStatus>
endpointFor(userId: string): Promise<Endpoint | undefined>
stop(userId: string): Promise<void>
teardown(): Promise<void>
/** 等待该用户 main 实例打印 launch token(本地模式 = 启动完成的信号)。无实例 /
* 已崩溃 / 已停 → 立即返回;k8s 模式无 token 概念 → no-op。供 enter 复用分支在返回
* 打开 URL 前等待,避免把浏览器导向「HTTP 已监听但路由未就绪 → 404」的启动窗口。 */
waitForLaunchTokenForUser(userId: string, timeoutMs?: number): Promise<void>
/**
* 档案 34:重启该用户实例并**探活**——功能插件启用后判定实例是否还起得来。
* `ok:false` 时调用方必须回滚/隔离该插件(否则会把实例拖进崩溃循环,档案 25 教训)。
*/
restartAndProbe(userId: string, settleMs?: number): Promise<{ ok: boolean; reason: string }>
/** Record user activity (proxied traffic / entering the workspace) so idle
* reaping keeps warm instances that are genuinely in use. Local mode tracks
* this in memory; k8s mode relies on its own session-based reconcile. */
touch(userId: string): void
/** Make the user's file sidecar exist and be ready (k8s). No-op under local,
* where the control plane touches the volume in-process. */
ensureFileService(userId: string): Promise<void>
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Tiny TCP bridge: listen on one address and forward every connection to
* another. Replaces the `alpine/socat` sidecar in the per-user DSH Pod, so the
* Pod no longer depends on a docker.io image (blocked on ACK, docs/k8s-deploy.md
* §7). Runs from the control-plane image's Node runtime.
* @module dshs/tcp-bridge
*/
import { createConnection, createServer, type Server } from 'node:net'
/**
* Start a TCP bridge: `listen` (e.g. `0.0.0.0:8081`) → `target`
* (e.g. `127.0.0.1:8080`). Resolves when listening.
*/
export function startTcpBridge(listen: string, target: string): Promise<Server> {
const [targetHost, targetPort] = splitHostPort(target)
const server = createServer((socket) => {
// A fresh outbound connection to the target — NOT `socket.connect`, which
// would try to re-connect the already-connected inbound socket.
const upstream = createConnection(Number(targetPort), targetHost)
upstream.on('error', () => socket.destroy())
socket.on('error', () => upstream.destroy())
socket.pipe(upstream)
upstream.pipe(socket)
})
const [listenHost, listenPort] = splitHostPort(listen)
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(Number(listenPort), listenHost, () => resolve(server))
})
}
/** `host:port` → `[host, port]`, defaulting the host to `0.0.0.0`. */
function splitHostPort(addr: string): [string, string] {
const idx = addr.lastIndexOf(':')
if (idx === -1) return ['0.0.0.0', addr]
return [addr.slice(0, idx), addr.slice(idx + 1)]
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Password hashing, session-token helpers, and cookie assembly.
*
* Uses `node:crypto` scrypt so the scaffold has zero native dependency beyond
* SQLite; production may swap in argon2id without changing call sites.
* @module dshs/web/auth
*/
import { createHash, randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
import { promisify } from 'node:util'
const scryptAsync = promisify(scrypt) as (password: string, salt: Buffer, keylen: number) => Promise<Buffer>
const KEY_LENGTH = 64
/** Hash a password into a `scrypt$<salt>$<hash>` string. */
export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16)
const derived = await scryptAsync(password, salt, KEY_LENGTH)
return `scrypt$${salt.toString('hex')}$${derived.toString('hex')}`
}
/** Constant-time password verification against a stored `scrypt$...` string. */
export async function verifyPassword(password: string, stored: string): Promise<boolean> {
const [scheme, saltHex, hashHex] = stored.split('$')
if (scheme !== 'scrypt' || saltHex === undefined || hashHex === undefined) return false
const expected = Buffer.from(hashHex, 'hex')
const derived = await scryptAsync(password, Buffer.from(saltHex, 'hex'), KEY_LENGTH)
return derived.length === expected.length && timingSafeEqual(derived, expected)
}
/** Generate a fresh opaque session token. */
export function newSessionToken(): string {
return randomBytes(32).toString('base64url')
}
/** Hash a session token for storage; the DB never holds the raw token. */
export function hashSessionToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
/** Extract a named cookie value from a `Cookie` header. */
export function parseCookie(header: string | undefined, name: string): string | undefined {
if (!header) return undefined
for (const part of header.split(';')) {
const idx = part.indexOf('=')
if (idx === -1) continue
if (part.slice(0, idx).trim() === name) return part.slice(idx + 1).trim()
}
return undefined
}
/** Build a `Set-Cookie` value for the session token.
*
* `SameSite=None` (with `Secure`) when behind HTTPS so the cookie survives the
* cross-subdomain hop `dsh.<domain>` → `<user>.dsh.<domain>`; `SameSite=Lax`
* otherwise. The subdomain auth check still validates cookie ↔ slug, so the
* looser SameSite does not widen who a session can reach.
*/
export function sessionCookie(token: string, maxAgeSeconds: number, secure: boolean, domain?: string): string {
const flags = ['sid=' + token, 'HttpOnly', secure ? 'SameSite=None' : 'SameSite=Lax', 'Path=/', `Max-Age=${maxAgeSeconds}`]
if (secure) flags.push('Secure')
if (domain !== undefined && domain !== '') flags.push(`Domain=${domain}`)
return flags.join('; ')
}
/** Build a `Set-Cookie` value that expires the session cookie. */
export function clearSessionCookie(secure: boolean, domain?: string): string {
const flags = ['sid=', 'HttpOnly', secure ? 'SameSite=None' : 'SameSite=Lax', 'Path=/', 'Max-Age=0']
if (secure) flags.push('Secure')
if (domain !== undefined && domain !== '') flags.push(`Domain=${domain}`)
return flags.join('; ')
}
+150
View File
@@ -0,0 +1,150 @@
/**
* The per-user file sidecar (docs/k8s.md §4.10).
*
* Runs inside the user's own Pod as the user's uid, so it *can* read and write
* their `0700` directory — the thing the control plane (uid 65532, no volume)
* cannot do. It serves exactly one user: the root is fixed at startup, and no
* request carries a user id.
*
* **No authentication.** The boundary is the NetworkPolicy that lets only the
* control plane reach port 8082, the same argument that lets the socat sidecar
* bridge 8081 unauthenticated (docs/k8s.md §6.1 item 4). If that policy is ever
* widened, this service needs a token check *first*.
* @module dshs/web/file-service
*/
import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify'
import { LocalUserFs } from '../fs/local-user-fs.js'
import { UserFsError } from '../fs/user-fs.js'
/** Port the sidecar binds; the per-user Service targets it directly. */
export const FILE_SERVICE_PORT = 8082
/** Env var carrying the single user root this sidecar serves. */
export const USER_ROOT_ENV = 'DSHS_USER_ROOT'
/** The sidecar serves one user, so the id crossing {@link UserFs} is a constant. */
const SOLE_USER = 'self'
const pathSchema = {
body: {
type: 'object',
required: ['path'],
additionalProperties: false,
properties: { path: { type: 'string', maxLength: 512 } },
},
} as const
const entrySchema = {
body: {
type: 'object',
required: ['path', 'name', 'type'],
additionalProperties: false,
properties: {
path: { type: 'string', maxLength: 512 },
name: { type: 'string', maxLength: 255 },
type: { type: 'string', enum: ['file', 'dir'] },
},
},
} as const
const uploadSchema = {
body: {
type: 'object',
required: ['path', 'name', 'data'],
additionalProperties: false,
properties: {
path: { type: 'string', maxLength: 512 },
name: { type: 'string', maxLength: 255 },
data: { type: 'string' },
},
},
} as const
const handoffSchema = {
body: {
type: 'object',
required: ['content'],
additionalProperties: false,
properties: { content: { type: 'string', maxLength: 8192 } },
},
} as const
/** Reply with the seam's own wire form so the client can rebuild the error. */
function fail(reply: FastifyReply, err: unknown): FastifyReply {
if (err instanceof UserFsError) return reply.code(err.status).send({ error: err.code })
throw err
}
/**
* Build the sidecar's Fastify instance. Does not listen; the caller binds.
* @param root - the user's data root inside the Pod.
* @param options - `bodyLimit` must exceed the control plane's upload cap.
*/
export function buildFileService(root: string, options: { bodyLimit: number; logLevel: string }): FastifyInstance {
const fs = new LocalUserFs(() => root)
const app = Fastify({ logger: { level: options.logLevel }, bodyLimit: options.bodyLimit })
app.get('/healthz', async () => ({ ok: true }))
app.get('/fs/tree', async (request, reply) => {
const { path = '' } = request.query as { path?: string }
try {
return { entries: await fs.listDir(SOLE_USER, path) }
} catch (err) {
return fail(reply, err)
}
})
app.get('/fs/stat', async (request, reply) => {
const { path = '' } = request.query as { path?: string }
try {
return { isDirectory: await fs.isDirectory(SOLE_USER, path) }
} catch (err) {
return fail(reply, err)
}
})
app.get('/fs/plugins', async () => ({ plugins: await fs.listInstalledPlugins(SOLE_USER) }))
app.post('/fs/init', async () => {
await fs.initUserRoot(SOLE_USER)
return { ok: true }
})
app.post('/fs/mkdir', { schema: pathSchema }, async (request, reply) => {
const { path } = request.body as { path: string }
try {
await fs.mkdir(SOLE_USER, path)
return { ok: true }
} catch (err) {
return fail(reply, err)
}
})
app.post('/fs/create', { schema: entrySchema }, async (request, reply) => {
const { path, name, type } = request.body as { path: string; name: string; type: 'file' | 'dir' }
try {
return { name: await fs.createEntry(SOLE_USER, path, name, type) }
} catch (err) {
return fail(reply, err)
}
})
app.post('/fs/upload', { schema: uploadSchema }, async (request, reply) => {
const { path, name, data } = request.body as { path: string; name: string; data: string }
try {
return { name: await fs.upload(SOLE_USER, path, name, Buffer.from(data, 'base64')) }
} catch (err) {
return fail(reply, err)
}
})
app.post('/fs/handoff', { schema: handoffSchema }, async (request) => {
const { content } = request.body as { content: string }
await fs.writeHandoff(SOLE_USER, content)
return { ok: true }
})
return app
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Authentication / authorization guards.
*
* `requireAuth` resolves the session cookie into `request.user` (a PublicUser)
* with a single `sessions JOIN users` lookup; `requireAdmin` additionally
* enforces the admin role. Both reject with 401/403 and never continue the
* handler.
* @module dshs/web/middleware/authn
*/
import type { FastifyReply, FastifyRequest } from 'fastify'
import { toPublicUser } from '../../db/types.js'
import { hashSessionToken, parseCookie } from '../auth.js'
/** Resolve the session cookie into `request.user`, or reject 401. */
export async function requireAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const token = parseCookie(request.headers.cookie, 'sid')
if (token === undefined) {
reply.code(401).send({ error: 'unauthorized' })
return
}
const row = await request.server.db.findSessionWithUser(hashSessionToken(token))
if (row === undefined || row.expiresAt <= Date.now() || row.user.role === 'disabled') {
reply.code(401).send({ error: 'unauthorized' })
return
}
request.user = toPublicUser(row.user)
}
/** Require an authenticated admin; rejects non-admins with 403. */
export async function requireAdmin(request: FastifyRequest, reply: FastifyReply): Promise<void> {
await requireAuth(request, reply)
if (request.user === null) return // requireAuth already sent the response
if (request.user.role !== 'admin') {
reply.code(403).send({ error: 'forbidden' })
return
}
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Path-traversal guard for every filesystem-facing surface.
*
* `resolveWithinRoot` canonicalizes a user-supplied relative path against a
* root and rejects anything that escapes it; `safeFilename` strips directory
* components from an upload name.
* @module dshs/web/middleware/fs-guard
*/
import { basename, isAbsolute, posix, resolve, sep } from 'node:path'
/** The subset of `node:path` the guard needs, so a caller can pin POSIX
* semantics for paths that belong to a *remote* filesystem (a Linux Pod)
* rather than the host the control plane happens to run on. */
export interface PathFlavor {
isAbsolute(path: string): boolean
resolve(...paths: string[]): string
readonly sep: string
}
const NATIVE: PathFlavor = { isAbsolute, resolve, sep }
/** POSIX flavor, for in-Pod paths built by the k8s backend. */
export const POSIX: PathFlavor = { isAbsolute: posix.isAbsolute, resolve: posix.resolve, sep: posix.sep }
/** Sentinel error for a path that escapes its allowed root. */
export class PathEscapeError extends Error {
constructor(path: string, root: string) {
super(`path ${path} escapes root ${root}`)
this.name = 'PathEscapeError'
}
}
/**
* Resolve `rel` against `root` and reject absolute paths, NUL bytes, and any
* result that escapes the root (including `..` traversal).
* @param root - the user's workspace root (absolute).
* @param rel - a user-supplied relative path (`''` resolves to `root`).
* @param flavor - path semantics; defaults to the host's. Pass {@link POSIX}
* when `root` names a directory inside a Linux Pod, not on this host.
*/
export function resolveWithinRoot(root: string, rel: string, flavor: PathFlavor = NATIVE): string {
const { isAbsolute, resolve, sep } = flavor
if (isAbsolute(rel)) throw new PathEscapeError(rel, root)
if (rel.includes('\0')) throw new PathEscapeError('<nul>', root)
const resolved = resolve(root, rel)
const boundary = root.endsWith(sep) ? root : root + sep
if (resolved !== root && !resolved.startsWith(boundary)) {
throw new PathEscapeError(rel, root)
}
return resolved
}
/**
* Reduce an upload filename to its base name, rejecting empty/dot/NUL names so
* a client cannot smuggle a path component.
*/
export function safeFilename(name: string): string {
const base = basename(name)
if (base === '' || base === '.' || base === '..' || base.includes('\0')) {
throw new PathEscapeError(name, '<filename>')
}
return base
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Global rate limiting. The auth/admin surfaces get their own stricter limits
* in P1; this baseline covers the whole server.
* @module dshs/web/middleware/rate-limit
*/
import type { FastifyPluginAsync } from 'fastify'
import fastifyRateLimit from '@fastify/rate-limit'
/** Register the baseline rate limiter (applies to all routes). */
export const rateLimit: FastifyPluginAsync = async (app) => {
await app.register(fastifyRateLimit, {
max: 100,
timeWindow: '1 minute',
global: true,
})
}
+337
View File
@@ -0,0 +1,337 @@
/**
* 插件兼容性预检 —— 判据实现在此(档案 71 / 交接单 T05)
*
* 背景:`@anysearch/anysearch-dsh` 与平台 dsh 0.1.2-rc.1 不兼容,装进实例后
* `plugin tree failed to load`(缺 `assertNever`)→ 实例崩溃循环(档案 70)。
* 现有链路只查「来源/内容安全」,不查「与平台版本兼不兼容」→ 本模块补上。
*
* 两条判据(均已在生产实测复现该 bug):
*
* A. 依赖范围(semver,**默认语义,不传 includePrerelease**)
* 插件声明的 `@deepseek-ai/*` 依赖范围必须**接受**平台内置的同名包版本。
* ⚠️ 为什么必须用默认语义:npm/pnpm 的实际安装判定就是默认语义 ——
* anysearch 写 `>=0.1.1-rc.1 <0.1.2`,默认语义下 **不满足** 0.1.2-rc.1,
* 于是 pnpm 给它装了自带的 `[email protected]` → 与平台 dsh-llm 冲突。
* 若传 includePrerelease 会把它误判为「满足」而漏掉这个 bug(实测已确认)。
*
* B. 导出符号(运行时真值)
* 插件 bundle 里 `import { X } from '@deepseek-ai/Y'`,`X` 必须在平台包 Y 的
* **运行时真实导出**里存在。取法 = `await import()` 后取 `Object.keys()`,
* 比解析 `.d.ts` 可靠(实测平台 223 个包中 220 个可取到导出清单)。
*
* 覆盖边界(重要):判据 B 只看**被扫描目录内**的 import。若插件把平台 API 的调用
* 藏在**传递依赖**里(anysearch 就是:越界的是它的依赖 dsh-tool-web,不在 tgz 内),
* 本模块扫不出来 —— 那种情况由「启用前扫 profile node_modules」那一层负责(T05 §五.4)。
*
* 设计原则:**任何异常都不得让上传流程失败** —— 判不出来就返回 `unknown`(放行 + 标记待复核),
* 绝不误伤正常插件。
*/
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
import { join, extname } from 'node:path'
import { pathToFileURL } from 'node:url'
/** 平台内置包根目录(本部署事实;env 可覆盖,便于测试)。 */
const PLATFORM_DSH_ROOT =
process.env.DSH_COMPAT_ROOT ?? '/usr/local/lib/node_modules/@deepseek-ai/dsh'
const SCOPE_DIR = join(PLATFORM_DSH_ROOT, 'node_modules', '@deepseek-ai')
const DS_SCOPE = '@deepseek-ai/'
export interface CompatFinding {
/** dep-range = 依赖范围不接受平台版本;missing-export = 平台包缺该导出 */
kind: 'dep-range' | 'missing-export'
pkg: string
detail: string
file?: string
}
export interface CompatResult {
/** incompatible = 已判定不兼容(调用方默认拒绝,admin 可显式信任);unknown = 静态判不了(放行 + 标记) */
level: 'ok' | 'incompatible' | 'unknown'
findings: CompatFinding[]
/** 平台内置包数量(快照,供回显) */
platformPkgs: number
/** 是否真的看到了 @deepseek-ai 的依赖声明或 import(区分 ok / unknown 用) */
sawPlatformRef: boolean
/** 判定过程出错时的说明(此时 level=unknown) */
note?: string
}
/* ------------------------------------------------------------------ *
* 平台侧:版本表(读 package.json)与导出符号(按需 import,带缓存)
* ------------------------------------------------------------------ */
const versionCache = new Map<string, string | null>()
const exportCache = new Map<string, Set<string> | null>()
function shortName(pkg: string): string {
return pkg.startsWith(DS_SCOPE) ? pkg.slice(DS_SCOPE.length) : pkg
}
function platformPkgDir(pkg: string): string {
return join(SCOPE_DIR, shortName(pkg))
}
/** 平台内置 @deepseek-ai 包的数量(目录级,低成本)。 */
export function platformPkgCount(): number {
try {
return readdirSync(SCOPE_DIR).filter((n) => !n.startsWith('.')).length
} catch {
return 0
}
}
/** 平台内置某包的版本;平台没有该包 → null。 */
function platformVersion(pkg: string): string | null {
const hit = versionCache.get(pkg)
if (hit !== undefined) return hit
let v: string | null = null
try {
const pj = join(platformPkgDir(pkg), 'package.json')
if (existsSync(pj)) {
const meta = JSON.parse(readFileSync(pj, 'utf8')) as { version?: unknown }
v = typeof meta.version === 'string' ? meta.version : null
}
} catch {
v = null
}
versionCache.set(pkg, v)
return v
}
function resolveEntryFile(pkg: string): string | null {
const base = platformPkgDir(pkg)
const cands: string[] = []
try {
const meta = JSON.parse(readFileSync(join(base, 'package.json'), 'utf8')) as {
main?: unknown
module?: unknown
exports?: unknown
}
if (typeof meta.module === 'string') cands.push(meta.module)
if (typeof meta.main === 'string') cands.push(meta.main)
} catch {
/* 忽略:退回默认候选 */
}
cands.push('lib/index.js', 'dist/index.js', 'esm/index.js', 'index.js', 'lib/index.mjs')
for (const c of cands) {
const f = join(base, c)
try {
if (existsSync(f) && statSync(f).isFile()) return f
} catch {
/* 忽略 */
}
}
return null
}
/**
* 平台某包的运行时导出符号集合。返回 null = 无法判定(**不能**据此断言不兼容)。
* 只对「插件真的 import 了的包」调用,避免启动成本。
*/
async function platformExports(pkg: string): Promise<Set<string> | null> {
const hit = exportCache.get(pkg)
if (hit !== undefined) return hit
let keys: Set<string> | null = null
try {
const entry = resolveEntryFile(pkg)
if (entry !== null) {
const mod = (await import(pathToFileURL(entry).href)) as Record<string, unknown>
keys = new Set(Object.keys(mod))
}
} catch {
keys = null // import 失败可能是环境原因,不能当作「插件不兼容」的证据
}
exportCache.set(pkg, keys)
return keys
}
type SemverLike = { satisfies(v: string, r: string): boolean }
let semverPromise: Promise<SemverLike | null> | null = null
function loadSemver(): Promise<SemverLike | null> {
semverPromise ??= (async () => {
try {
const m = (await import('semver')) as unknown as { default?: SemverLike } & SemverLike
return (m.default ?? m) as SemverLike
} catch {
return null
}
})()
return semverPromise
}
/* ------------------------------------------------------------------ *
* 插件侧:依赖声明 + bundle 内的 @deepseek-ai import
* ------------------------------------------------------------------ */
const MAX_SCAN_FILES = 4000
const SCAN_EXT = new Set(['.js', '.mjs', '.cjs'])
const IMPORT_RE =
/(?:import|export)\s*(?:\*\s*as\s*[\w$]+|\{([^}]*)\})\s*from\s*["'](@deepseek-ai\/[^"']+)["']/g
interface ImportHit {
pkg: string
named: string[]
file: string
}
function findPackageJson(root: string): string | null {
const direct = join(root, 'package.json')
if (existsSync(direct)) return direct
try {
for (const e of readdirSync(root, { withFileTypes: true })) {
if (e.isDirectory()) {
const nested = findPackageJson(join(root, e.name))
if (nested !== null) return nested
}
}
} catch {
/* 忽略 */
}
return null
}
function collectJsFiles(dir: string, out: string[] = [], depth = 0): string[] {
if (out.length >= MAX_SCAN_FILES || depth > 8) return out
let entries: import('node:fs').Dirent[]
try {
entries = readdirSync(dir, { withFileTypes: true })
} catch {
return out
}
for (const e of entries) {
if (out.length >= MAX_SCAN_FILES) break
if (e.isDirectory()) {
if (e.name === 'node_modules' || e.name === '.git') continue
collectJsFiles(join(dir, e.name), out, depth + 1)
} else if (SCAN_EXT.has(extname(e.name))) {
out.push(join(dir, e.name))
}
}
return out
}
function scanImports(root: string): { hits: ImportHit[]; fileCount: number } {
const files = collectJsFiles(root)
const hits: ImportHit[] = []
for (const f of files) {
let text: string
try {
text = readFileSync(f, 'utf8')
} catch {
continue
}
if (!text.includes(DS_SCOPE)) continue
IMPORT_RE.lastIndex = 0
let m: RegExpExecArray | null
while ((m = IMPORT_RE.exec(text)) !== null) {
const spec = m[1] ?? ''
const named = spec
.split(',')
.map((s) => s.trim().split(/\s+as\s+/)[0]?.trim() ?? '')
.filter((s) => s !== '' && !s.startsWith('type '))
hits.push({ pkg: m[2], named, file: f.slice(root.length + 1) })
}
}
return { hits, fileCount: files.length }
}
function declaredDeepseekDeps(pkgPath: string): Array<[string, string]> {
let meta: {
dependencies?: Record<string, string>
peerDependencies?: Record<string, string>
optionalDependencies?: Record<string, string>
}
try {
meta = JSON.parse(readFileSync(pkgPath, 'utf8')) as typeof meta
} catch {
return []
}
const merged = { ...meta.peerDependencies, ...meta.optionalDependencies, ...meta.dependencies }
return Object.entries(merged).filter(([k]) => k.startsWith(DS_SCOPE))
}
/* ------------------------------------------------------------------ *
* 主入口
* ------------------------------------------------------------------ */
/**
* 对一个**已解包**的插件目录做兼容性预检(只读)。
* @param root 解包后的插件根目录(含 package.json 的那一层或其上层)
*/
export async function checkPluginCompat(root: string): Promise<CompatResult> {
const out: CompatResult = {
level: 'unknown',
findings: [],
platformPkgs: platformPkgCount(),
sawPlatformRef: false,
}
try {
if (out.platformPkgs === 0) {
out.note = `平台包目录不可读:${SCOPE_DIR}`
return out
}
const pkgPath = findPackageJson(root)
if (pkgPath === null) {
out.note = '未找到 package.json'
return out
}
// ---- 判据 A:依赖范围 ----
const deps = declaredDeepseekDeps(pkgPath)
if (deps.length > 0) out.sawPlatformRef = true
const semver = deps.length > 0 ? await loadSemver() : null
for (const [pkg, range] of deps) {
const v = platformVersion(pkg)
if (v === null) continue // 平台没这个包:不算不兼容(插件自带)
if (semver === null) {
out.note = 'semver 不可用,依赖范围未判定'
continue
}
let ok: boolean
try {
ok = semver.satisfies(v, range) // **默认语义**:与 pnpm 的实际安装判定一致
} catch {
continue // 范围写法无法解析:不据此断言
}
if (!ok) {
out.findings.push({
kind: 'dep-range',
pkg,
detail: `平台为 ${v},插件要求 ${range}(不满足)`,
})
}
}
// ---- 判据 B:导出符号 ----
const { hits, fileCount } = scanImports(root)
if (hits.length > 0) out.sawPlatformRef = true
for (const h of hits) {
if (h.named.length === 0) continue
if (platformVersion(h.pkg) === null) continue // 非平台内置包:跳过
const exports = await platformExports(h.pkg)
if (exports === null) continue // 拿不到导出清单 → 不判定
const missing = h.named.filter((n) => !exports.has(n))
if (missing.length > 0) {
out.findings.push({
kind: 'missing-export',
pkg: h.pkg,
detail: `平台包未导出:${missing.join(', ')}`,
file: h.file,
})
}
}
out.level = out.findings.length > 0 ? 'incompatible' : out.sawPlatformRef ? 'ok' : 'unknown'
if (out.level === 'unknown' && out.note === undefined) {
out.note = `插件未声明也未直接 import 任何 @deepseek-ai 包(扫描 ${fileCount} 个 js 文件)→ 需装后扫 node_modules 复核`
}
return out
} catch (err) {
// 任何意外都不影响上传流程:降级为 unknown(放行 + 标记)
out.level = 'unknown'
out.note = `预检异常(已降级为待复核):${err instanceof Error ? err.message : String(err)}`
return out
}
}
+158
View File
@@ -0,0 +1,158 @@
/**
* Admin routes: list users and approve/disable accounts. All guarded by
* `requireAdmin`.
* @module dshs/web/routes/admin
*/
import type { FastifyPluginAsync } from 'fastify'
import { execFileSync, spawn } from 'node:child_process'
import { rm } from 'node:fs/promises'
import { requireAdmin } from '../middleware/authn.js'
import { userRoot } from '../../fs/workspace.js'
/** 档案 36:新用户审批通过后自动铺「功能插件」分区的脚本(幂等)。 */
const ENSURE_BIZ_PLUGINS =
process.env.DSH_ENSURE_BIZ_PLUGINS ?? '/opt/dshs/scripts/ensure-biz-plugins.cjs'
export const adminRoutes: FastifyPluginAsync = async (app) => {
// 档案 28:存储用量面板(读取维护脚本生成的快照文件,避免每次请求都 du)
app.get('/api/admin/storage', { preHandler: requireAdmin }, async () => {
const { readFileSync } = await import('node:fs')
try {
return JSON.parse(readFileSync(process.env.DSH_STORAGE_REPORT ?? '/var/run/dsh-storage-report.json', 'utf8'))
} catch {
return { generatedAt: null, users: [], note: '报告尚未生成(cron 每小时刷新一次)' }
}
})
app.get('/api/admin/users', { preHandler: requireAdmin }, async () => ({
users: await app.db.listPublicUsers(),
}))
app.post('/api/admin/users/:id/approve', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const user = await app.db.findUserById(id)
if (user === undefined) return reply.code(404).send({ error: 'not_found' })
if (user.role !== 'pending') return reply.code(409).send({ error: 'not_pending' })
await app.db.setUserRole(id, 'active', request.user?.id)
await app.db.audit(request.user?.id ?? null, 'approve', JSON.stringify({ userId: id }))
// 档案 36:审批通过后异步铺「功能插件」分区(普通用户才能在设置里启停插件)。
// fire-and-forget:不阻塞审批响应;失败由巡检(cron 跑同一脚本)兜底。
const prov = spawn(process.execPath, [ENSURE_BIZ_PLUGINS, id], { stdio: "ignore", detached: true })
prov.on("error", (err) => app.log.warn({ err }, "ensure-biz-plugins 调用失败(将由巡检兜底)"))
prov.unref()
return { ok: true }
})
app.post('/api/admin/users/:id/disable', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const user = await app.db.findUserById(id)
if (user === undefined) return reply.code(404).send({ error: 'not_found' })
if (user.role === 'admin') return reply.code(409).send({ error: 'cannot_disable_admin' })
await app.db.setUserRole(id, 'disabled', request.user?.id)
await app.db.deleteUserSessions(id)
await app.supervisor.stop(id) // 停掉该用户运行中的 DSH(待办.md §二)
await app.db.audit(request.user?.id ?? null, 'disable', JSON.stringify({ userId: id }))
return { ok: true }
})
app.post('/api/admin/users/:id/enable', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const user = await app.db.findUserById(id)
if (user === undefined) return reply.code(404).send({ error: 'not_found' })
if (user.role !== 'disabled') return reply.code(409).send({ error: 'not_disabled' })
await app.db.setUserRole(id, 'active', request.user?.id)
await app.db.audit(request.user?.id ?? null, 'enable', JSON.stringify({ userId: id }))
return { ok: true }
})
// 永久删除用户:admin 不可删;删除 = 停实例 → DB 事务清全部关联行 →
// 删数据目录(users/<id>/)→ 删 provision 创建的 OS 账号 dsh-<short>。
app.delete('/api/admin/users/:id', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const user = await app.db.findUserById(id)
if (user === undefined) return reply.code(404).send({ error: 'not_found' })
if (user.role === 'admin') return reply.code(409).send({ error: 'cannot_delete_admin' })
await app.supervisor.stop(id) // 停掉运行中的 DSH,避免进程占用刚删的目录
if (!(await app.db.deleteUser(id))) return reply.code(404).send({ error: 'not_found' })
// 数据目录:users/<id>/{home,ws} 整棵删除(force 容忍不存在)
await rm(userRoot(app.config.dataRoot, id), { recursive: true, force: true })
// OS 账号:与 provision-new-users.sh 同规则 dsh-<id 去横线前 20 位>;不存在则忽略
const short = id.replace(/-/g, '').slice(0, 20)
try {
execFileSync('userdel', [`dsh-${short}`], { stdio: 'ignore' })
} catch {
// 账号可能从未被 provision(如 uid 冲突跳过)——DB/目录已清,可接受
}
await app.db.audit(request.user?.id ?? null, 'delete_user', JSON.stringify({ userId: id, username: user.username }))
return { ok: true }
})
/**
* 档案 47:实例共享运行时/工具清单(admin 只读)。
* 数据全部用 shell 取(避免为此新增 import):版本 = 直接执行二进制;
* 基线 = cat /opt/dsh/state/runtime-baseline.json;清单 = cat SHARED-TOOLS.md。
* 升级/卸载不在此做 —— 走 `scripts/install-*.sh`(幂等、带 sha256 校验),
* 升级后必须跑 `runtime-baseline.cjs --accept` 刷新基线(与档案 44 的版本冻结配套)。
*/
app.get('/api/admin/runtime', { preHandler: requireAdmin }, async () => {
const sh = (cmd: string, args: string[], fallback = ''): string => {
try { return execFileSync(cmd, args, { encoding: 'utf8', timeout: 20000 }).trim() } catch { return fallback }
}
const first = (cmd: string, args: string[]): string => sh(cmd, args, '缺失').split('\n')[0]
const items = [
{ name: 'python3', group: '运行时', kind: '可移植发行版', version: first('/usr/local/bin/python3', ['-V']),
source: 'python-build-standalone install_only_stripped', script: 'scripts/install-python-runtime.sh', removable: false },
{ name: 'pip3', group: '运行时', kind: '随 python3', version: first('/usr/local/bin/pip3', ['-V']),
source: '随 python-build-standalone', script: 'scripts/install-python-runtime.sh', removable: false },
{ name: 'node', group: '运行时', kind: '平台预装', version: first('/usr/local/bin/node', ['-v']),
source: '官方 node 分发(平台部署时预装)', script: '—(升级需走独立流程)', removable: false },
{ name: 'npm', group: '运行时', kind: '随 node', version: first('/usr/local/bin/npm', ['-v']),
source: '随 node', script: '—(升级需走独立流程)', removable: false },
{ name: 'jq', group: '共享工具', kind: '静态单文件', version: first('/usr/local/bin/jq', ['--version']),
source: 'jqlang/jq 官方静态构建', script: 'scripts/install-shared-tools.sh', removable: true },
{ name: 'rg', group: '共享工具', kind: '静态单文件(musl)', version: first('/usr/local/bin/rg', ['--version']),
source: 'BurntSushi/ripgrep musl 静态', script: 'scripts/install-shared-tools.sh', removable: true },
{ name: 'ffmpeg', group: '共享工具', kind: '静态单文件', version: first('/usr/local/bin/ffmpeg', ['-version']),
source: 'BtbN/FFmpeg-Builds 静态构建', script: 'scripts/install-shared-tools.sh', removable: true },
{ name: 'ffprobe', group: '共享工具', kind: '静态单文件', version: first('/usr/local/bin/ffprobe', ['-version']),
source: 'BtbN/FFmpeg-Builds 静态构建', script: 'scripts/install-shared-tools.sh', removable: true },
]
// 与基线对比(档案 44)
let baseline: unknown = null
let drift: string[] = []
try {
baseline = JSON.parse(sh('cat', ['/opt/dsh/state/runtime-baseline.json'], '{}'))
const v = (baseline as { versions?: Record<string, string> }).versions ?? {}
const num = (s: string): string => (s.match(/\d+\.\d+(\.\d+)?/) ?? [''])[0]
const pair: Array<[string, string]> = [
['python3', num(first('/usr/local/bin/python3', ['-V']))],
['pip3', num(first('/usr/local/bin/pip3', ['-V']))],
['node', num(first('/usr/local/bin/node', ['-v']))],
['npm', num(first('/usr/local/bin/npm', ['-v']))],
['jq', num(first('/usr/local/bin/jq', ['--version']))],
['rg', num(first('/usr/local/bin/rg', ['--version']))],
]
drift = pair.filter(([k, now]) => v[k] !== undefined && v[k] !== now).map(([k, now]) => `${k}: 基线 ${v[k]} → 现在 ${now}`)
} catch { /* 基线缺失不报错 */ }
const bytes = Number(sh('du', ['-sk', '/usr/local/dsh-runtime'], '0').split(/\s+/)[0] || 0) * 1024
return {
runtimeDir: { path: '/usr/local/dsh-runtime', bytes, installedAt: sh('stat', ['-c', '%y', '/usr/local/dsh-runtime']) },
items,
baseline,
drift,
manifest: sh('cat', ['/usr/local/dsh-runtime/SHARED-TOOLS.md']),
installScripts: ['scripts/install-python-runtime.sh', 'scripts/install-shared-tools.sh'],
baselineScript: 'scripts/runtime-baseline.cjs --accept',
note: '实例内 /usr 为只读挂载 + /usr/local/bin 在实例 PATH 首位 → 这里装的东西对全部用户立即生效,用户无法自行安装或修改。',
}
})
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Auth routes: self-registration (→ pending), login, logout, and the current
* identity. Registration always yields a `pending` user; an admin approves it.
* @module dshs/web/routes/auth
*/
import type { FastifyPluginAsync } from 'fastify'
import { randomUUID } from 'node:crypto'
import { requireAdmin, requireAuth } from '../middleware/authn.js'
import { homeRoot, userRoot } from '../../fs/workspace.js'
import { deriveKey, encrypt } from '../../crypto.js'
import { toPublicUser } from '../../db/types.js'
import {
clearSessionCookie,
hashPassword,
hashSessionToken,
newSessionToken,
parseCookie,
sessionCookie,
verifyPassword,
} from '../auth.js'
const registerSchema = {
body: {
type: 'object',
required: ['username', 'password'],
additionalProperties: false,
properties: {
username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' },
password: { type: 'string', minLength: 8, maxLength: 128 },
},
},
} as const
const loginSchema = {
body: {
type: 'object',
required: ['username', 'password'],
additionalProperties: false,
properties: {
username: { type: 'string', maxLength: 64 },
password: { type: 'string', maxLength: 128 },
},
},
} as const
interface Credentials {
username: string
password: string
}
export const authRoutes: FastifyPluginAsync = async (app) => {
app.post(
'/api/auth/register',
{ schema: registerSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
async (request, reply) => {
const { username, password } = request.body as Credentials
if ((await app.db.findUserByUsername(username)) !== undefined) {
return reply.code(409).send({ error: 'username_taken' })
}
const id = randomUUID()
const homeDir = homeRoot(userRoot(app.config.dataRoot, id))
const passHash = await hashPassword(password)
// Create the user first so `initUserRoot` resolves the DB-assigned uid
// (baseUid + row_id) instead of the hash fallback — the per-user Pods and
// the DSH Pod must run as the *same* uid or the DSH cannot write its dirs.
const user = await app.db.createUser({ id, username, passHash, role: 'pending', homeDir })
await app.userFs.initUserRoot(id, user.uid ?? undefined)
await app.db.audit(id, 'register', JSON.stringify({ username }))
return reply.code(201).send({ user: { id, username, role: 'pending' } })
},
)
app.post(
'/api/auth/login',
{ schema: loginSchema, config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
async (request, reply) => {
const { username, password } = request.body as Credentials
const user = await app.db.findUserByUsername(username)
if (user === undefined || !(await verifyPassword(password, user.pass_hash))) {
return reply.code(401).send({ error: 'invalid_credentials' })
}
if (user.role === 'pending') return reply.code(403).send({ error: 'pending_review' })
if (user.role === 'disabled') return reply.code(403).send({ error: 'disabled' })
const token = newSessionToken()
// 单活跃会话(last-wins):新登录顶掉该账号此前所有会话——旧浏览器的
// sid 立即失效,proxy/API 对其返回 401,需重新登录后才能继续访问。
await app.db.deleteUserSessions(user.id)
await app.db.createSession({
tokenHash: hashSessionToken(token),
userId: user.id,
expiresAt: Date.now() + app.config.sessionTtlSeconds * 1000,
ip: request.ip,
userAgent: request.headers['user-agent'],
})
await app.db.audit(user.id, 'login', null)
reply.header(
'set-cookie',
sessionCookie(token, app.config.sessionTtlSeconds, app.config.secureCookies, app.config.cookieDomain),
)
return { user: toPublicUser(user) }
},
)
app.post('/api/auth/logout', async (request, reply) => {
const token = parseCookie(request.headers.cookie, 'sid')
if (token !== undefined) await app.db.deleteSession(hashSessionToken(token))
reply.header('set-cookie', clearSessionCookie(app.config.secureCookies, app.config.cookieDomain))
return { ok: true }
})
// ---- 同源退出页:会话内「退出登录」整页跳转(清 sid + 删 session → 回登录页) ----
app.get('/logout', async (request, reply) => {
const token = parseCookie(request.headers.cookie, 'sid')
if (token !== undefined) await app.db.deleteSession(hashSessionToken(token))
reply.header('set-cookie', clearSessionCookie(app.config.secureCookies, app.config.cookieDomain))
return reply.redirect('/')
})
app.get('/api/auth/me', { preHandler: requireAuth }, async (request) => ({ user: request.user }))
const keyAddSchema = {
body: {
type: 'object',
required: ['name', 'apiKey'],
additionalProperties: false,
properties: {
name: { type: 'string', minLength: 1, maxLength: 32 },
apiKey: { type: 'string', minLength: 1, maxLength: 256 },
},
},
} as const
// ---- 统一 KEY:仅管理员可管理。全局只认 admin 的启用 key(见 server.ts resolveApiKey),
// 普通用户不再有自配 key 的入口/能力;这些路由对非 admin 一律 403。 ----
app.get('/api/me/keys', { preHandler: requireAdmin }, async (request) => ({
keys: await app.db.listCredentialKeys(request.user!.id),
}))
app.post('/api/me/keys', { preHandler: requireAdmin, 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)) {
return reply.code(400).send({ error: 'invalid_name' })
}
// Header-safe charset only: reject spaces, quotes, non-ASCII, etc.
if (!/^[A-Za-z0-9\-_.]{1,256}$/.test(apiKey)) {
return reply.code(400).send({ error: 'invalid_api_key' })
}
const key = await app.db.setCredentialKey(
request.user!.id,
cleanName,
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()
return { key }
})
app.post('/api/me/keys/:id/select', { preHandler: requireAdmin }, 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()
return { ok: true }
})
app.delete('/api/me/keys/:id', { preHandler: requireAdmin }, 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' })
return { ok: true }
})
}
+742
View File
@@ -0,0 +1,742 @@
/**
* Business-plugin (系统外插件) candidate-pool routes. Admin-only.
*
* Upload contract: a `.tgz` bundle containing a dsh cordis bundle — a
* `package.json` with `name` (npm package-name shape, scope allowed). The
* archive is extracted into staging, its `package.json` is validated, and its
* text files are scanned for obviously-malicious patterns (安全检测) before it
* is admitted to the pool.
*
* Same-name upload REPLACES the pool entry: the previous `.tgz` file is removed
* in full first (替换策略, not overwrite — stale files cannot survive).
*
* The pool is a set of `.tgz` files under `<dataRoot>/business-plugins/` plus a
* `business_plugins` row of metadata. Users enable a plugin per-instance in a
* later phase (档案 16 阶段 2), which copies the bundle into their profile.
* @module dshs/web/routes/business-plugins
*/
import type { FastifyPluginAsync } from 'fastify'
import { execFileSync } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { mkdir, rm, rename } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { requireAdmin, requireAuth } from '../middleware/authn.js'
import { httpError, scanDirDetailed, type ScanBlock, type ScanFinding } from '../security-scan.js'
import { checkPluginCompat, type CompatResult } from '../plugin-compat.js'
import { userRoot } from '../../fs/workspace.js'
/** Route-level body limit for uploads (base64 ~4/3 of archive size). */
const UPLOAD_BODY_LIMIT = 180 * 1024 * 1024
const STAGE_PREFIX = '.biz-plugin-'
const STAGE_ID_RE = /^\.biz-plugin-[0-9a-f]{12}$/
const STAGE_TTL_MS = 10 * 60 * 1000
/** npm package-name shape, scope (`@scope/name`) allowed. */
const PKG_NAME_RE = /^(?:@[a-z0-9][a-z0-9-]*\/)?[a-z0-9][a-z0-9-]*$/
/** Sanitize a package name into a flat, path-safe filename. */
function sanitizeFileName(name: string): string {
return name.replace(/[^a-zA-Z0-9._-]/g, '_')
}
interface ProfileManifest {
bundles: string[]
deps: Record<string, string>
}
function readProfileManifest(dir: string): ProfileManifest {
try {
const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as {
dsh?: { profile?: { bundles?: string[] } }
dependencies?: Record<string, string>
}
return { bundles: pkg.dsh?.profile?.bundles ?? [], deps: pkg.dependencies ?? {} }
} catch {
return { bundles: [], deps: {} }
}
}
function writeProfileManifest(dir: string, bundles: string[], deps: Record<string, string>): void {
const path = join(dir, 'package.json')
let pkg: Record<string, unknown>
try {
pkg = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>
} catch {
pkg = { name: 'dsh-profile-web', private: true }
}
const dsh = (pkg.dsh ?? {}) as Record<string, unknown>
const profile = (dsh.profile ?? {}) as Record<string, unknown>
profile.bundles = bundles
dsh.profile = profile
pkg.dsh = dsh
pkg.dependencies = deps
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
}
/** Find the first `package.json` under `root` (depth ≤ 3), tolerant of the npm
* `package/` wrapper directory produced by `npm pack`. */
function findPackageJson(root: string, depth = 0): string | null {
if (depth > 3) return null
const candidate = join(root, 'package.json')
if (existsSync(candidate)) return candidate
for (const entry of readdirSync(root)) {
const abs = join(root, entry)
if (lstatSync(abs).isDirectory() && !entry.startsWith('.')) {
const found = findPackageJson(abs, depth + 1)
if (found !== null) return found
}
}
return null
}
interface StagedPlugin {
stage: string
tgzName: string
name: string
description: string | null
version: string | null
fileCount: number
/** P0 命中(**不阻断**,交调用方裁决:默认拒绝,或 admin 显式信任后放行)。 */
blocked: ScanBlock[]
/** P1 告警(不阻断,记录 + 可回显)。 */
warnings: ScanFinding[]
/**
* 兼容性预检结果(档案 71):`incompatible` 表示与当前平台 dsh 版本不兼容
* (判据:`@deepseek-ai/*` 依赖范围 / 导出符号),**默认拒绝投放**,admin 可显式信任。
* `unknown` = 静态判不了(放行 + 标记待装后复核)。
*/
compat: CompatResult
}
/** Extract + validate + scan a `.tgz` bundle under `<dataRoot>/<staging>`. */
export async function stageTgzArchive(dataRoot: string, archive: Buffer): Promise<StagedPlugin> {
const stage = join(dataRoot, STAGE_PREFIX + randomBytes(6).toString('hex'))
const payload = join(stage, 'payload.tgz')
const unzipDir = join(stage, 'unzip')
mkdirSync(unzipDir, { recursive: true, mode: 0o700 })
try {
writeFileSync(payload, archive)
// 1. List members, reject absolute / traversal paths.
const members = execFileSync('tar', ['-tzf', payload], { encoding: 'utf8', timeout: 30000 }).split('\n')
let fileCount = 0
for (let raw of members) {
raw = raw.trim()
if (raw === '' || raw.startsWith('./')) raw = raw.replace(/^\.\/+/, '')
if (raw === '') continue
if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) throw httpError(400, `tgz member uses an absolute path: ${raw}`)
if (raw.split('/').includes('..')) throw httpError(400, `tgz member escapes the root: ${raw}`)
if (!raw.endsWith('/')) fileCount += 1
}
if (fileCount === 0) throw httpError(400, 'tgz 为空(无文件)')
// 2. Extract.
execFileSync('tar', ['-xzf', payload, '-C', unzipDir], { timeout: 120000 })
// 3. Validate package.json.
const pkgJsonPath = findPackageJson(unzipDir)
if (pkgJsonPath === null) throw httpError(400, 'tgz 缺少 package.json —— 不是 dsh 插件包')
let pkg: { name?: unknown; version?: unknown; description?: unknown }
try {
pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8')) as typeof pkg
} catch {
throw httpError(400, 'package.json 解析失败')
}
const name = typeof pkg.name === 'string' ? pkg.name.trim() : ''
if (name === '' || !PKG_NAME_RE.test(name)) {
throw httpError(400, `package.json name 无效(须为 npm 包名,可含 @scope):"${name}"`)
}
const version = typeof pkg.version === 'string' ? pkg.version.trim() : null
const description = typeof pkg.description === 'string' ? pkg.description.trim() : null
// 4. Security scan (危险内容扫描).
// **收集式**:P0 命中不在此处抛出,而是随结构返回交调用方裁决。理由:命中常来自文档里的
// 说明性字样(典型:第三方插件的 README 教用户把 key 写进 `.credentials.yaml`,被
// `/\.credentials\.yaml/` 规则判成「读取实例会话密钥」)—— 直接 400 会让 admin 看不到
// 命中详情、也无从对**已人工确认**的包放行。调用方(上传接口)默认拒绝并把逐条命中回显,
// 只有 admin **显式声明信任**(并写 audit)后才放行。
const scanned = scanDirDetailed(unzipDir)
if (scanned.blocked.length > 0 || scanned.warnings.length > 0) {
process.stderr.write(
`[upload-scan] ${JSON.stringify({
name,
blocked: scanned.blocked.length,
warnings: scanned.warnings.length,
detail: scanned.blocked.slice(0, 10),
})}\n`,
)
}
// 5. 兼容性预检(档案 71)。
// 与安全扫描同一套裁决方式:**不在此处抛出**,把结果随结构返回交调用方裁决
// (`incompatible` 默认拒绝并把逐条依据回显,admin 显式信任后才放行并留痕)。
// 为什么必须在投放时判:不兼容的插件装上去会让实例 `plugin tree failed to load`
// → 崩溃循环(档案 70 的 anysearch 事故就是这么发生的)。
const compat = await checkPluginCompat(unzipDir)
if (compat.level !== 'ok') {
process.stderr.write(
`[upload-compat] ${JSON.stringify({
name,
level: compat.level,
findings: compat.findings.slice(0, 10),
note: compat.note ?? null,
})}\n`,
)
}
return {
stage,
tgzName: sanitizeFileName(name) + '.tgz',
name,
description,
version,
fileCount,
blocked: scanned.blocked,
warnings: scanned.warnings,
compat,
}
} catch (err) {
rm(stage, { recursive: true, force: true }).catch(() => undefined)
throw err
}
}
function collectStaleStages(dataRoot: string): string[] {
let names: string[] = []
try {
names = readdirSync(dataRoot).filter((n) => n.startsWith(STAGE_PREFIX))
} catch {
return []
}
const stale: string[] = []
for (const name of names) {
try {
if (Date.now() - lstatSync(join(dataRoot, name)).mtimeMs > STAGE_TTL_MS) stale.push(name)
} catch {
stale.push(name)
}
}
return stale
}
// ── web provider 联动(2026-09-12 · 档案 65)──────────────────────────────
// 为什么需要:dsh 的 web provider 选择是**单选** —— 显式配置即硬绑定(provider 一旦不在就报
// CONFIGURED_MISSING 且**不回落**),未配置时只有「恰好一个可用」才自动选,多个可用直接
// AMBIGUOUS 报错。于是「两个 provider 共存 + 用户可任意启停」必然出现坏状态。
// 解法:平台在插件启停后**按当前 bundles 重算**这一段 ——
// 插件在 → 写死它声明的 searchProvider;插件不在 → 整段删除(回到「只有一个可用」时自动选)。
// 平台策略:`fetchProvider` 恒为本地 `http`(保留 SSRF 防护),**忽略**插件对 fetch 的声明。
//
// 放在模块级并导出(不依赖 `app`),以便被独立验证。
export const WEB_PROVIDER_OPEN = '# >>> platform: web-provider (managed by business-plugins)'
export const WEB_PROVIDER_CLOSE = '# <<< platform: web-provider'
/** 历史托管段(`scripts/ensure-anysearch-admin.cjs` 早期直铺时写的),同步时一并清掉。 */
export const WEB_PROVIDER_LEGACY: ReadonlyArray<readonly [string, string]> = [
['# >>> platform: anysearch-search', '# <<< platform: anysearch-search'],
]
/** 扫描已启用 bundle 自带的 cordis.patch.yml,收集它们对 `dsh-web` 的 provider 声明。 */
export function collectWebProviderClaims(dir: string, bundles: readonly string[]): { search: string | null } {
let search: string | null = null
for (const b of bundles) {
if (b.startsWith('@deepseek-ai/')) continue
let text: string
try {
text = readFileSync(join(dir, 'node_modules', b, 'cordis.patch.yml'), 'utf8')
} catch {
continue
}
let inWeb = false
let inConfig = false
for (const raw of text.split('\n')) {
const line = raw.replace(/\s+$/, '')
const idm = /^-\s+id:\s*(\S+)/.exec(line)
if (idm !== null) {
inWeb = idm[1] === 'web'
inConfig = false
continue
}
if (!inWeb) continue
if (/^\s+config:\s*$/.test(line)) {
inConfig = true
continue
}
if (!inConfig) continue
const sm = /^\s+searchProvider:\s*(\S+)/.exec(line)
if (sm !== null && search === null) search = sm[1]
}
}
return { search }
}
/** 按当前 bundles 重算 profile 的 web provider 托管段(幂等:先摘旧段,再按需追加)。 */
export function syncWebProviderPatch(dir: string): void {
const path = join(dir, 'cordis.patch.yml')
let text = existsSync(path) ? readFileSync(path, 'utf8') : ''
const blocks: ReadonlyArray<readonly [string, string]> = [[WEB_PROVIDER_OPEN, WEB_PROVIDER_CLOSE], ...WEB_PROVIDER_LEGACY]
for (const [open, close] of blocks) {
const s = text.indexOf(open)
const e = text.indexOf(close)
if (s < 0 || e <= s) continue
const lineStart = text.lastIndexOf('\n', s)
text = text.slice(0, lineStart < 0 ? s : lineStart) + text.slice(e + close.length)
}
const { search } = collectWebProviderClaims(dir, readProfileManifest(dir).bundles)
if (search !== null) {
text = text.replace(/\s*$/, '\n') + [
'',
WEB_PROVIDER_OPEN,
'# 由业务插件的启用/禁用自动维护,请勿手改。策略:只换 search,fetch 恒为本地 http(保留 SSRF 防护)。',
'- id: web',
' config:',
` searchProvider: ${search}`,
' fetchProvider: http',
WEB_PROVIDER_CLOSE,
'',
].join('\n')
}
writeFileSync(path, text.replace(/^\s*\n/, ''))
}
export const businessPluginRoutes: FastifyPluginAsync = async (app) => {
const poolDir = (): string => join(app.config.dataRoot, 'business-plugins')
const gc = (): void => {
for (const stale of collectStaleStages(app.config.dataRoot)) {
rm(join(app.config.dataRoot, stale), { recursive: true, force: true }).catch(() => undefined)
}
}
app.get('/api/plugins/business', { preHandler: requireAdmin }, async () => {
return { plugins: await app.db.listBusinessPlugins() }
})
app.post(
'/api/plugins/business',
{ preHandler: requireAdmin, bodyLimit: UPLOAD_BODY_LIMIT },
async (request, reply) => {
const body = request.body as {
file?: string
filename?: string
/** admin 对 P0 命中的显式信任声明;缺省时命中即拒绝(fail-closed)。 */
trust?: { confirmed?: boolean; reason?: string }
}
try {
const filename = body.filename ?? ''
if (!basename(filename).toLowerCase().endsWith('.tgz')) throw httpError(400, '仅支持 .tgz 文件')
const archive = Buffer.from(body.file ?? '', 'base64')
if (archive.length === 0) throw httpError(400, 'empty archive payload')
gc()
const staged = await stageTgzArchive(app.config.dataRoot, archive)
// 安全检测裁决:默认 fail-closed —— P0 命中即拒绝,但把逐条命中回显给 admin
//(旧行为只有一句脱敏文案,admin 无从判断)。admin 显式声明信任后才放行并留痕。
const trusted = body.trust?.confirmed === true
if (staged.blocked.length > 0 && !trusted) {
await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined)
return reply.code(409).send({
error: 'scan_blocked',
message: '安全检测命中 P0 规则,已拒绝投放。请逐条查看命中内容;确认该包可信后,再提交并在请求中声明信任(会记入审计日志)。',
blocked: staged.blocked,
warnings: staged.warnings,
})
}
// 兼容性裁决(档案 71):同一套 fail-closed + 显式信任。
// 判据 A = `@deepseek-ai/*` 依赖范围是否接受平台版本;判据 B = import 的符号是否在平台包导出里。
if (staged.compat.level === 'incompatible' && !trusted) {
await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined)
return reply.code(409).send({
error: 'compat_incompatible',
message: '该插件与当前平台 dsh 版本不兼容,已拒绝投放(判据:依赖版本范围 / 导出符号)。请逐条查看依据;若已人工确认可用,再提交并在请求中声明信任(会记入审计日志)。',
compat: staged.compat,
warnings: staged.warnings,
})
}
// 替换策略:同名先删旧 tgz(旧文件无残留),再落新包。
const dir = poolDir()
await mkdir(dir, { recursive: true, mode: 0o755 })
const existing = await app.db.findBusinessPlugin(staged.name)
if (existing !== undefined) {
if (existsSync(existing.tgzPath)) await rm(existing.tgzPath, { force: true })
}
const destPath = join(dir, staged.tgzName)
await rename(join(staged.stage, 'payload.tgz'), destPath)
const plugin = await app.db.upsertBusinessPlugin({
id: staged.name,
name: staged.name,
description: staged.description,
version: staged.version,
tgzPath: destPath,
fileSize: archive.length,
uploadedBy: request.user?.id ?? null,
})
const compatOverrode = trusted && staged.compat.level === 'incompatible'
const overrode = trusted && (staged.blocked.length > 0 || compatOverrode)
await app.db.audit(
request.user?.id ?? null,
'upload_business_plugin',
JSON.stringify({
name: plugin.name,
version: plugin.version,
trustedOverride: overrode,
compatLevel: staged.compat.level,
}),
)
if (overrode) {
await app.db.audit(
request.user?.id ?? null,
'trust_business_plugin',
JSON.stringify({
name: staged.name,
version: staged.version,
blocked: staged.blocked,
compat: compatOverrode ? staged.compat : undefined,
reason: body.trust?.reason ?? '',
}),
)
}
await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined)
return {
ok: true,
plugin,
replaced: existing !== undefined,
trustedOverride: overrode,
compat: staged.compat,
warnings: staged.warnings,
}
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 400).send({ error: e.message })
}
},
)
app.delete('/api/plugins/business/:id', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
if (!PKG_NAME_RE.test(id)) return reply.code(400).send({ error: 'invalid_plugin_id' })
const existing = await app.db.findBusinessPlugin(id)
if (existing === undefined) return reply.code(404).send({ error: 'not_found' })
if (existsSync(existing.tgzPath)) await rm(existing.tgzPath, { force: true })
await app.db.deleteBusinessPlugin(id)
await app.db.audit(request.user?.id ?? null, 'delete_business_plugin', JSON.stringify({ name: id }))
return { ok: true }
})
// ── mine: per-user enable/disable of business plugins(异步 + pnpm) ──────
// 启用 = `pnpm add file:<tgz>`(装插件 + 依赖,pnpm 全局 store 硬链复用依赖)+ reconcile
// bundles;禁用 = `pnpm remove`(依赖从 dependencies 移除,重新启用靠 store 硬链秒恢复)。
// 改完重启实例。安装异步执行:POST 返回 taskId,前端轮询 GET task/:id 看「阶段 + 状态」
//(B 方案:不滚日志,失败给友好文案)。
const profileDir = (userId: string): string => join(userRoot(app.config.dataRoot, userId), 'home', 'profiles', 'web')
/** 该用户的 `<root>/ws` —— pnpm 的 HOME(store / cache 落在这里)。 */
const wsDir = (userId: string): string => join(userRoot(app.config.dataRoot, userId), 'ws')
/**
* 该用户的 Linux uid/gid —— 取 `<root>/home` 的属主(编排器创建时就 chown 给了该用户)。
* 不查 DB:uid 列与本目录属主由同一次创建写入,读目录少一处耦合。
*/
const uidOf = (userId: string): number => lstatSync(join(userRoot(app.config.dataRoot, userId), 'home')).uid
/**
* **以该用户身份**跑 pnpm(setpriv 降权)。
*
* 为什么必须有:这条路(门户候选池启停)早期直接**以平台进程身份**跑 pnpm,装出来的文件属主是
* root —— 此后该用户自己跑任何 `pnpm add/remove` 都会撞 `EACCES`,插件彻底装不上
* (2026-09-12 实测:guest profile 下积了 561 个 root 属主项,升级直接失败)。
* 平台侧脚本(`ensure-biz-plugins.cjs` 等)一直用的是 setpriv 正姿,两条路线不同所以没暴露。
*/
const runPnpmAs = (userId: string, args: readonly string[], cwd: string): void => {
const uid = uidOf(userId)
execFileSync('setpriv', [
'--reuid', String(uid), '--regid', String(uid), '--clear-groups',
'env', `HOME=${wsDir(userId)}`,
'pnpm', ...args,
], { cwd, timeout: 180000, stdio: 'pipe' })
}
/**
* 属主自愈:把 profile 下 **root 属主**的残留项还给该用户(历史根因留下的存量)。
*
* 每次改插件前跑一次 —— 这是「修根因 + 防复发」里的防复发那一半:即便将来**别的**写入路径
* 又污染了属主,用户下次启停插件时也会被自动清理,不必再人工排查。
* 用 `find -exec chown +` 而不是一次传全部路径,避免路径过多撞命令行长度上限。
*
* @param userId - 目标用户。
* @returns 修复的条目数(0 = 本来就干净)。
*/
const healOwnership = (userId: string): number => {
const dir = profileDir(userId)
let count = 0
try {
const out = execFileSync('find', [dir, '-user', 'root'], { encoding: 'utf8', timeout: 60000 })
count = out.split('\n').filter((line) => line.trim() !== '').length
if (count === 0) return 0
} catch {
return 0
}
const uid = uidOf(userId)
try {
// `-h`:`.bin/*` 是指向别处的符号链接,要改链接本身而不是它的目标。
execFileSync('find', [dir, '-user', 'root', '-exec', 'chown', '-h', `${uid}:${uid}`, '{}', '+'], { timeout: 180000 })
} catch {
return 0
}
return count
}
// pnpm 的 store 位置由 HOME 决定;dsh 跑 pnpm 时 HOME 指向用户 ws,store 落在
// `<root>/ws/.local/share/pnpm/store/v3`(.modules.yaml 记录)。平台跑 pnpm 必须设同样的
// HOME,否则会报 ERR_PNPM_UNEXPECTED_STORE —— 见上面 `runPnpmAs` 里的 `HOME=<ws>`。
interface PluginTask {
status: 'pending' | 'running' | 'success' | 'failed' | 'timeout'
stage: string
error?: string
enabled?: string[]
/** 档案 34:本轮因「启用后实例起不来」被自动摘掉的插件。 */
rejected?: Array<{ id: string; reason: string }>
restarted: boolean
startedAt: number
updatedAt: number
}
const tasks = new Map<string, PluginTask>()
/** 一个依赖是否带 dsh.bundle.patch(= 是 bundle,对齐 dsh 的 isBundle 判定)。 */
function isBundle(dir: string, dep: string): boolean {
try {
const pkg = JSON.parse(readFileSync(join(dir, 'node_modules', dep, 'package.json'), 'utf8')) as {
dsh?: { bundle?: { patch?: unknown } }
}
return pkg.dsh?.bundle?.patch !== undefined
} catch {
return false
}
}
/** 对齐 dsh plugin add 的 reconcile:dependencies 里带 dsh.bundle.patch 的进 bundles,
* 非 @deepseek-ai 模板且已不在 dependencies 的从 bundles 移除。返回最终 bundles。 */
function reconcileBundles(dir: string): string[] {
const path = join(dir, 'package.json')
const pkg = JSON.parse(readFileSync(path, 'utf8')) as {
dependencies?: Record<string, string>
dsh?: { profile?: { bundles?: string[] } }
}
const deps = Object.keys(pkg.dependencies ?? {})
const bundles = pkg.dsh?.profile?.bundles ?? []
const kept = bundles.filter((b) => b.startsWith('@deepseek-ai/') || deps.includes(b))
for (const dep of deps) {
if (!kept.includes(dep) && isBundle(dir, dep)) kept.push(dep)
}
pkg.dsh = pkg.dsh ?? {}
pkg.dsh.profile = pkg.dsh.profile ?? {}
pkg.dsh.profile.bundles = kept
writeFileSync(path, JSON.stringify(pkg, null, 2) + '\n')
return kept
}
/** 把 pnpm 的原始报错翻译成用户能懂的友好文案。 */
function friendlyError(err: unknown): string {
const message = String((err as Error)?.message ?? err)
const stderr = String((err as { stderr?: Buffer })?.stderr ?? '')
const text = stderr || message
if (text.includes('ETIMEDOUT') || text.includes('timed out')) return '安装超时,请重试'
if (text.includes('No matching version')) return '插件版本不匹配'
if (text.includes('ENOTFOUND') || text.includes('getaddrinfo')) return '网络连接失败,请重试'
if (text.includes('Not Found') || text.includes('E404')) return '依赖包不存在,请重试'
return '安装失败,请重试或联系管理员'
}
// ── 档案 34:profile 快照与还原(插件启用失败时回滚用)────────────────
// 只快照我们会改动的文件;node_modules 不回滚(无害,重装很快,回滚反而慢且易碎)。
const SNAPSHOT_FILES = ['package.json', 'pnpm-lock.yaml', 'cordis.patch.yml'] as const
function snapshotProfile(dir: string): Record<string, string | null> {
const snap: Record<string, string | null> = {}
for (const f of SNAPSHOT_FILES) {
const p = join(dir, f)
snap[f] = existsSync(p) ? readFileSync(p, 'utf8') : null
}
return snap
}
function restoreProfile(dir: string, snap: Record<string, string | null>): void {
for (const [f, content] of Object.entries(snap)) {
const p = join(dir, f)
if (content === null) {
if (existsSync(p)) rmSync(p, { force: true })
} else {
writeFileSync(p, content)
}
}
}
app.get('/api/plugins/mine', { preHandler: requireAuth }, async (request) => {
const user = request.user!
const { bundles } = readProfileManifest(profileDir(user.id))
const enabled = new Set(bundles)
const plugins = await app.db.listBusinessPlugins()
return {
plugins: plugins.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
version: p.version,
fileSize: p.fileSize,
enabled: enabled.has(p.id),
})),
}
})
app.post('/api/plugins/mine/apply', { preHandler: requireAuth }, async (request, reply) => {
const user = request.user!
const body = request.body as { plugins?: Array<{ id: string; enabled: boolean }> }
const selections = body.plugins ?? []
for (const sel of selections) {
if (!PKG_NAME_RE.test(sel.id)) return reply.code(400).send({ error: `invalid plugin id: ${sel.id}` })
}
const dir = profileDir(user.id)
await mkdir(dir, { recursive: true })
const { bundles } = readProfileManifest(dir)
const enabledSet = new Set(bundles)
const toEnable = selections.filter((s) => s.enabled && !enabledSet.has(s.id))
const toDisable = selections.filter((s) => !s.enabled && enabledSet.has(s.id))
if (toEnable.length === 0 && toDisable.length === 0) return { ok: true, restarted: false, noop: true }
const taskId = randomBytes(8).toString('hex')
tasks.set(taskId, { status: 'pending', stage: '排队中', restarted: false, startedAt: Date.now(), updatedAt: Date.now() })
void (async () => {
const task = tasks.get(taskId)!
task.status = 'running'
const setStage = (stage: string): void => {
task.stage = stage
task.updatedAt = Date.now()
}
setStage('准备中…')
// 档案 34:应用前快照,供探活失败时回滚。
const snap = snapshotProfile(dir)
const applied: string[] = []
const rejected: Array<{ id: string; reason: string }> = []
const install = async (id: string): Promise<void> => {
const plugin = await app.db.findBusinessPlugin(id)
if (plugin === undefined) throw httpError(404, `插件不在候选池:${id}`)
// 先自愈存量属主污染 —— 否则以用户身份跑 pnpm 连旧文件都覆盖不了(EACCES 直接失败)。
const healed = healOwnership(user.id)
if (healed > 0) {
await app.db.audit(user.id, 'heal_plugin_ownership', JSON.stringify({ stage: `install:${id}`, healed }))
}
runPnpmAs(user.id, ['add', 'file:' + plugin.tgzPath, '--ignore-workspace-root-check', '--reporter', 'silent'], dir)
reconcileBundles(dir)
syncWebProviderPatch(dir)
}
const uninstall = async (id: string): Promise<void> => {
const healed = healOwnership(user.id)
if (healed > 0) {
await app.db.audit(user.id, 'heal_plugin_ownership', JSON.stringify({ stage: `uninstall:${id}`, healed }))
}
runPnpmAs(user.id, ['remove', id, '-w', '--reporter', 'silent'], dir)
reconcileBundles(dir)
syncWebProviderPatch(dir)
}
const restartProbe = async (): Promise<{ ok: boolean; reason: string }> => {
await app.userFs.writeHandoff(user.id, JSON.stringify({ command: '', createdAt: Date.now() }))
return await app.supervisor.restartAndProbe(user.id)
}
try {
// ① 禁用项:pnpm remove 不引入新代码,永远安全 → 不需要探活。
for (const sel of toDisable) {
setStage(`正在停用 ${sel.id}…`)
await uninstall(sel.id)
}
if (toEnable.length > 0) {
// ② 先整批试一次:正常路径(绝大多数)只花 1 次重启,与旧行为一致。
setStage(`正在安装 ${toEnable.length} 个插件…`)
for (const sel of toEnable) await install(sel.id)
setStage('正在重启实例并探活…')
const probe = await restartProbe()
if (probe.ok) {
applied.push(...toEnable.map((s) => s.id))
} else {
// ③ 探活失败 → 回滚到快照,再逐插件隔离定位:坏插件单独摘掉,好插件保留。
setStage('实例未能启动,正在定位问题插件…')
restoreProfile(dir, snap)
for (let i = 0; i < toEnable.length; i++) {
const sel = toEnable[i]
setStage(`正在定位问题插件(${i + 1}/${toEnable.length}):${sel.id}`)
await install(sel.id)
const p = await restartProbe()
if (p.ok) {
applied.push(sel.id)
continue
}
setStage(`插件 ${sel.id} 与实例不兼容,已自动禁用`)
// 档案 79(2026-09-13):**必须 await** —— uninstall 是 async,缺 await 时它的 rejection
// 会逃出这个同步 try/catch,变成 unhandled rejection ⇒ **整个 orchestrator 进程退出**
// (07:43:58 实证:pnpm remove 报 Unknown option → 平台被带崩、全体用户瞬断)。
try { await uninstall(sel.id) } catch { /* 尽力而为 */ }
rejected.push({ id: sel.id, reason: p.reason })
await app.db.audit(user.id, 'plugin_incident', JSON.stringify({ pluginId: sel.id, reason: p.reason }))
}
// 定位循环结束时实例可能停在「刚摘掉坏插件」的状态 → 再确认一次。
setStage('正在恢复实例…')
await restartProbe()
}
}
const finalBundles = readProfileManifest(dir).bundles
task.enabled = finalBundles
task.rejected = rejected
task.restarted = true
task.status = 'success'
task.stage = rejected.length === 0
? '完成'
: `完成:${applied.length} 个已启用,${rejected.length} 个已自动禁用`
if (rejected.length > 0) {
task.error = `以下插件与实例不兼容,已自动禁用:${rejected.map((r) => r.id).join('、')}`
}
task.updatedAt = Date.now()
await app.db.audit(user.id, 'apply_business_plugins', JSON.stringify({ plugins: selections.map((s) => s.id), applied, rejected }))
} catch (err) {
// 兜底:任何异常都把 profile 还原,绝不让用户实例停在半应用状态。
try { restoreProfile(dir, snap) } catch { /* ignore */ }
try { await restartProbe() } catch { /* ignore */ }
const friendly = friendlyError(err)
task.status = friendly === '安装超时,请重试' ? 'timeout' : 'failed'
task.stage = '失败'
task.error = friendly
task.updatedAt = Date.now()
}
})().catch((err: unknown) => {
// 档案 79 加固(2026-09-13):**双保险** —— 任何逃出内层 try/catch 的异常都不许终止进程。
// 没有这一层时,一次 uninstall 失败就把整个 dshs 带崩(全体用户瞬断 + 所有实例被停)。
// 注意:`task` 是 IIFE 内的局部量,这里只能按 taskId 从 tasks 表取回。
try {
const t = tasks.get(taskId)
if (t !== undefined) {
t.status = 'failed'
t.stage = '失败'
t.error = String((err as Error)?.message ?? err)
t.updatedAt = Date.now()
}
} catch { /* ignore */ }
try { console.error('[business-plugins] apply task crashed:', err) } catch { /* ignore */ }
})
return { ok: true, taskId }
})
app.get('/api/plugins/mine/task/:id', { preHandler: requireAuth }, async (request, reply) => {
const { id } = request.params as { id: string }
const task = tasks.get(id)
if (task === undefined) return reply.code(404).send({ error: 'not_found' })
return { ...task }
})
}
+155
View File
@@ -0,0 +1,155 @@
/**
* Desktop / filesystem routes: list the user's workspace, create a folder, and
* upload a file. Every path is resolved against the caller's own workspace
* root, so one user can never address another user's files.
*
* The routes never touch `node:fs` themselves — they go through the
* {@link UserFs} seam, which is in-process under `local` and a per-user file
* sidecar under `k8s` (docs/k8s.md §4.10).
* @module dshs/web/routes/desktop
*/
import type { FastifyPluginAsync, FastifyReply } from 'fastify'
import { requireAuth } from '../middleware/authn.js'
import { UserFsError } from '../../fs/user-fs.js'
import { extname } from 'node:path'
const mkdirSchema = {
body: {
type: 'object',
required: ['path'],
additionalProperties: false,
properties: { path: { type: 'string', maxLength: 512 } },
},
} as const
const uploadSchema = {
body: {
type: 'object',
required: ['path', 'name', 'data'],
additionalProperties: false,
properties: {
path: { type: 'string', maxLength: 512 },
name: { type: 'string', maxLength: 255 },
data: { type: 'string' },
},
},
} as const
const createSchema = {
body: {
type: 'object',
required: ['path', 'name', 'type'],
additionalProperties: false,
properties: {
path: { type: 'string', maxLength: 512 },
name: { type: 'string', maxLength: 255 },
type: { type: 'string', enum: ['file', 'dir'] },
},
},
} as const
/** 档案 56:下载时的 content-type(只覆盖常见类型,其余 application/octet-stream)。 */
const DL_TYPES: Record<string, string> = {
'.md': 'text/markdown; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.csv': 'text/csv; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.htm': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.cjs': 'text/javascript; charset=utf-8',
'.ts': 'text/plain; charset=utf-8',
'.py': 'text/plain; charset=utf-8',
'.sh': 'text/plain; charset=utf-8',
'.yml': 'text/plain; charset=utf-8',
'.yaml': 'text/plain; charset=utf-8',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.xls': 'application/vnd.ms-excel',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.pdf': 'application/pdf',
'.zip': 'application/zip',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.mp4': 'video/mp4',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.m4a': 'audio/mp4',
}
function downloadType(name: string): string {
return DL_TYPES[extname(name).toLowerCase()] ?? 'application/octet-stream'
}
/** Turn a seam failure into the `{error}` body the desktop UI switches on. */
export function sendFsError(reply: FastifyReply, err: unknown): FastifyReply {
if (err instanceof UserFsError) return reply.code(err.status).send({ error: err.code })
throw err
}
export const desktopRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/desktop/tree', { preHandler: requireAuth }, async (request, reply) => {
const { path = '' } = request.query as { path?: string }
try {
return { path, entries: await app.userFs.listDir(request.user!.id, path) }
} catch (err) {
return sendFsError(reply, err)
}
})
app.post('/api/fs/mkdir', { preHandler: requireAuth, schema: mkdirSchema }, async (request, reply) => {
const { path } = request.body as { path: string }
try {
await app.userFs.mkdir(request.user!.id, path)
return { ok: true }
} catch (err) {
return sendFsError(reply, err)
}
})
app.post('/api/fs/upload', { preHandler: requireAuth, schema: uploadSchema }, async (request, reply) => {
const { path, name, data } = request.body as { path: string; name: string; data: string }
let buf: Buffer
try {
buf = Buffer.from(data, 'base64')
} catch {
return reply.code(400).send({ error: 'bad_data' })
}
try {
return { ok: true, name: await app.userFs.upload(request.user!.id, path, name, buf) }
} catch (err) {
return sendFsError(reply, err)
}
})
// 档案 56:下载/查看工作区文件 —— 之前**没有**任何读取端点,AI 产出的文件用户既看不到也下不了,
// 而 AI 只能给出 `/var/lib/.../ws/...` 这类宿主绝对路径(浏览器打不开)。
// 路径经 UserFs 解析(限定在调用者自己的根内,逃逸即 bad_path),并限制单文件大小。
app.get('/api/fs/download', { preHandler: requireAuth }, async (request, reply) => {
const { path = '' } = request.query as { path?: string }
try {
const { name, data } = await app.userFs.readFile(request.user!.id, path)
return reply
.header('content-type', downloadType(name))
.header('content-disposition', "attachment; filename*=UTF-8''" + encodeURIComponent(name))
.header('cache-control', 'no-store')
.send(data)
} catch (err) {
return sendFsError(reply, err)
}
})
app.post('/api/fs/create', { preHandler: requireAuth, schema: createSchema }, async (request, reply) => {
const { path, name, type } = request.body as { path: string; name: string; type: 'file' | 'dir' }
try {
return { ok: true, name: await app.userFs.createEntry(request.user!.id, path, name, type), type }
} catch (err) {
return sendFsError(reply, err)
}
})
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Custom-domain routes: set/get a user's domain, regenerate its nginx config,
* and admin verification. Real ACME/DNS ownership verification is deferred;
* the `verified` flag is set by an admin as a placeholder until then.
* @module dshs/web/routes/domain
*/
import type { FastifyPluginAsync } from 'fastify'
import { requireAdmin, requireAuth } from '../middleware/authn.js'
import { isValidDomain, renderServerBlock } from '../../nginx/generate.js'
const putSchema = {
body: {
type: 'object',
required: ['domain'],
additionalProperties: false,
properties: { domain: { type: 'string', minLength: 1, maxLength: 253 } },
},
} as const
export const domainRoutes: FastifyPluginAsync = async (app) => {
app.get('/api/domain', { preHandler: requireAuth }, async (request) => {
const domain = await app.db.findDomainByUser(request.user!.id)
if (domain === undefined) return { domain: null }
return { domain: domain.domain, verified: domain.verified === 1, nginx_config: domain.nginxConfig }
})
app.put('/api/domain', { preHandler: requireAuth, schema: putSchema }, async (request, reply) => {
const normalized = (request.body as { domain: string }).domain.toLowerCase().trim()
if (!isValidDomain(normalized)) return reply.code(400).send({ error: 'invalid_domain' })
const config = renderServerBlock(normalized, request.user!.id, app.config.port)
await app.db.upsertDomain(request.user!.id, normalized, config)
return { domain: normalized, verified: false, nginx_config: config }
})
app.post('/api/nginx/regen', { preHandler: requireAuth }, async (request, reply) => {
const domain = await app.db.findDomainByUser(request.user!.id)
if (domain === undefined) return reply.code(404).send({ error: 'no_domain' })
const config = renderServerBlock(domain.domain, request.user!.id, app.config.port)
await app.db.upsertDomain(request.user!.id, domain.domain, config)
return { domain: domain.domain, nginx_config: config }
})
app.get('/api/admin/domains', { preHandler: requireAdmin }, async () => ({
domains: (await app.db.listDomains()).map((d) => ({
id: d.id,
userId: d.userId,
domain: d.domain,
verified: d.verified === 1,
})),
}))
app.post('/api/admin/domains/:id/verify', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
if ((await app.db.findDomainById(id)) === undefined) return reply.code(404).send({ error: 'not_found' })
await app.db.setDomainVerified(id, true)
return { ok: true }
})
}
+242
View File
@@ -0,0 +1,242 @@
/**
* DSH launch / supervise / restart routes + the reverse proxy to a running
* instance. Launch resolves the requested folder, reads its enabled plugins,
* writes a cordis patch, and spawns a main+watchdog pair; restart writes a
* post-restart command handoff and respawns the main.
* @module dshs/web/routes/dsh
*/
import type { FastifyPluginAsync, FastifyReply } from 'fastify'
import { requireAuth } from '../middleware/authn.js'
import { sendFsError } from './desktop.js'
import { AlreadyRunningError, CrashBreakerOpenError } from '../../supervisor/orchestrator.js'
import { renderPatch } from '../../supervisor/patch.js'
import { subdomainForUser } from '../../supervisor/proxy.js'
import { homeRoot, userRoot } from '../../fs/workspace.js'
import { latestSessionPreset } from '../../supervisor/session-preset.js'
const launchSchema = {
body: {
type: 'object',
required: ['folder'],
additionalProperties: false,
properties: { folder: { type: 'string', maxLength: 512 } },
},
} as const
const restartSchema = {
body: {
type: 'object',
required: ['command'],
additionalProperties: false,
properties: { command: { type: 'string', maxLength: 1024 } },
},
} as const
function alive(status: string | undefined): boolean {
// 'failed' = 崩溃熔断后停止自动重启(档案 20),同样不可复用。
return status !== undefined && status !== 'crashed' && status !== 'stopped' && status !== 'failed'
}
/**
* 档案 78:熔断冷却期内的统一响应(503 + 可读信息)。
*
* 为什么是 503 而不是 500:这不是"服务坏了",而是平台**主动拒绝在冷却期内启动**,
* 客户端应展示「稍后重试」。`retryAfterMs` 供前端提示具体等待时长;
* `opens` 是累计熔断次数(同一实例反复崩 → 该值递增,可据此判断"该找人了")。
*/
function sendBreakerOpen(reply: FastifyReply, err: CrashBreakerOpenError): FastifyReply {
return reply.code(503).send({
error: 'instance_circuit_open',
opens: err.opens,
retryAfterMs: err.retryAfterMs,
message: '实例因连续崩溃被暂时熔断,请稍后重试',
})
}
function dshUrl(baseDomain: string, user: { id: string; username: string }, token?: string): string {
const sub = subdomainForUser(baseDomain, user.username)
const base = sub !== null ? `https://${sub}/` : `/u/${user.id}/dsh/`
return token !== undefined && token !== '' ? `${base}?token=${encodeURIComponent(token)}` : base
}
export const dshRoutes: FastifyPluginAsync = async (app) => {
app.post('/api/dsh/launch', { preHandler: requireAuth, schema: launchSchema }, async (request, reply) => {
const { folder } = request.body as { folder: string }
const user = request.user!
const fs = app.userFs
let folderAbs: string
try {
folderAbs = fs.resolvePath(user.id, folder)
if (!(await fs.isDirectory(user.id, folder))) return reply.code(400).send({ error: 'not_a_folder' })
} catch (err) {
return sendFsError(reply, err)
}
// Per-folder plugin selection → cordis patch. Rendered here but *not*
// written: the spawner decides where it lands (a file under local, a
// ConfigMap under k8s, where the control plane has no user volume).
let patch: string | undefined
if (app.config.enablePatch) {
const workspace = await app.db.findWorkspaceByPath(user.id, folder)
// Only inject plugins the user still has installed; a stale selection for
// a since-removed bundle would otherwise fail to resolve in the child DSH.
const installed = new Set((await fs.listInstalledPlugins(user.id)).map((plugin) => plugin.id))
const enabled = (workspace === undefined ? [] : await app.db.getEnabledPluginIds(workspace.id))
.filter((id) => installed.has(id))
patch = renderPatch(enabled)
}
try {
const instance = await app.supervisor.launch(user.id, folderAbs, patch)
return {
instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken },
url: dshUrl(app.config.baseDomain, user, instance.launchToken),
}
} catch (err) {
if (err instanceof AlreadyRunningError) return reply.code(409).send({ error: 'already_running' })
// 档案 78:熔断冷却期内的启动被拒(用户选文件夹也会走到这里)
if (err instanceof CrashBreakerOpenError) return sendBreakerOpen(reply, err)
throw err
}
})
// 档案 20:handoff 停写。重启后执行命令的能力依赖 watchdog,而 watchdog 需要
// `ENABLE_PATCH=true`(线上为 false → 永不启动),且 handoff.json 无人消费。
// 停写避免"写了没人读"的误导;command 若不为空则记日志留痕。
app.post('/api/dsh/restart', { preHandler: requireAuth, schema: restartSchema }, async (request, reply) => {
const { command } = request.body as { command: string }
const user = request.user!
if (command !== '') {
process.stderr.write(
`[handoff-disabled] restart-with-command ignored (userId=${user.id}, bytes=${command.length})\n`,
)
}
const instance = await app.supervisor.restartMain(user.id)
if (instance === undefined) return reply.code(404).send({ error: 'not_running' })
await app.supervisor.spawnWatchdog(user.id)
return {
instance: {
id: instance.id,
port: instance.port,
status: instance.status,
launchToken: instance.launchToken,
restarts: instance.restarts ?? 0,
},
// 能力未启用:请勿依赖"重启后执行命令"(档案 20 §四 A3)。
handoff: { accepted: false, reason: 'watchdog_disabled' },
url: dshUrl(app.config.baseDomain, user, instance.launchToken),
}
})
app.post('/api/dsh/stop', { preHandler: requireAuth }, async (request) => {
await app.supervisor.stop(request.user!.id)
return { ok: true }
})
// 档案 56 ①:会话权限档位提示 —— 档位是**会话创建时播种**的,平台改默认值不会更新既有会话;
// 老会话停在 workspace-write 时(本机沙箱后端不可用)dsh 会 fail-closed 拒绝任何 shell,
// 用户只看到「bash 不可用」。这里把差异暴露出来,由实例页面提示用户切换。
app.get('/api/dsh/session-permission', { preHandler: requireAuth }, async (request) => {
const expected = process.env.DSH_PERMISSION_MODE ?? 'danger-full-access'
const home = homeRoot(userRoot(app.config.dataRoot, request.user!.id))
const session = latestSessionPreset(home) ?? null
// 只有"受限档位 + 沙箱后端不可用"这一种组合会真正挡住用户,故仅对 workspace-write 报警。
const stale = session !== null && session.preset !== expected && session.preset === 'workspace-write'
return { expected, session, stale }
})
// 档案 56 ②:实例能力清单(由 scripts/gen-capabilities.cjs 生成,与实例内 skill 同源)。
app.get('/api/capabilities', { preHandler: requireAuth }, async () => {
const { readFileSync } = await import('node:fs')
const file = process.env.DSH_CAPABILITIES_FILE ?? '/opt/dsh/state/capabilities.json'
try {
return JSON.parse(readFileSync(file, 'utf8'))
} catch {
return { generatedAt: null, note: '能力清单尚未生成(运行 scripts/gen-capabilities.cjs)' }
}
})
app.get('/api/dsh/status', { preHandler: requireAuth }, async (request) => {
const { main, watchdog } = await app.supervisor.status(request.user!.id)
return {
running: alive(main?.status),
instance: main
? {
id: main.id,
port: main.port,
status: main.status,
exitCode: main.exitCode,
lastError: main.lastError,
// 观测面(档案 20 · A2):自动重启次数 + 最近崩溃时间
restarts: main.restarts ?? 0,
lastCrashedAt: main.lastCrashedAt ?? null,
}
: null,
watchdog: watchdog ? { id: watchdog.id, status: watchdog.status, exitCode: watchdog.exitCode } : null,
// 观测面(档案 78):熔断状态 —— 非 null 即"该用户正被冷却",供门户/排查直接看到
breaker: app.supervisor.breakerInfo?.(request.user!.id) ?? null,
url: dshUrl(app.config.baseDomain, request.user!, main?.launchToken),
}
})
// 登录直达(方案 05,2026-09-09;admin 亦直达 —— 2026-09-09 决策②补充):
// 所有已放行角色(admin / active)统一:已运行实例复用其 launchToken URL,
// 未运行则在 ws 根目录 launch 后返回带 token 的会话 URL。
// desktop 管理台不再作为登录落点,admin 经会话侧栏"门户/管理台"入口或直
// 接访问 /desktop.html 进入。
app.post('/api/dsh/enter', { preHandler: requireAuth }, async (request, reply) => {
const user = request.user!
if (user.role !== 'active' && user.role !== 'admin') return reply.code(403).send({ error: 'not_allowed' })
// Entering the workspace is itself activity (idle-reap signal).
app.supervisor.touch(user.id)
// 复用已运行实例(含刚被其它请求 spawn、仍在启动中的实例):等 launch token 到位
// 再返回 URL,避免把浏览器直接导向「HTTP 已监听但路由未就绪 → 404」的启动窗口。
let { main } = await app.supervisor.status(user.id)
if (main !== undefined && alive(main.status)) {
if ((main.launchToken ?? '') === '') await app.supervisor.waitForLaunchTokenForUser(user.id)
main = (await app.supervisor.status(user.id)).main
if (main !== undefined && alive(main.status) && (main.launchToken ?? '') !== '') {
return {
kind: 'session',
instance: { id: main.id, port: main.port, status: main.status },
url: dshUrl(app.config.baseDomain, user, main.launchToken),
}
}
// 启动超时或中途崩溃:不给空 token URL(否则浏览器会撞 404),改提示稍后重试
return reply.code(503).send({ error: 'instance_starting' })
}
try {
const fs = app.userFs
const folderAbs = fs.resolvePath(user.id, '')
if (!(await fs.isDirectory(user.id, ''))) return reply.code(400).send({ error: 'not_a_folder' })
// 根目录 launch:无文件夹级插件勾选 → patch 传 undefined(渲染为空)
const instance = await app.supervisor.launch(user.id, folderAbs, undefined)
return {
kind: 'session',
instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken },
url: dshUrl(app.config.baseDomain, user, instance.launchToken),
}
} catch (err) {
if (err instanceof AlreadyRunningError) {
// 并发进入:另一请求正在 spawn 同一实例 → 同样等 token 到位再返回
await app.supervisor.waitForLaunchTokenForUser(user.id)
const m = (await app.supervisor.status(user.id)).main
if (m !== undefined && alive(m.status) && (m.launchToken ?? '') !== '') {
return {
kind: 'session',
instance: { id: m.id, port: m.port, status: m.status },
url: dshUrl(app.config.baseDomain, user, m.launchToken),
}
}
return reply.code(503).send({ error: 'instance_starting' })
}
// 档案 78:熔断冷却期内拒绝隐式拉起(用户 F5 / 注入脚本自愈都会到这里)
if (err instanceof CrashBreakerOpenError) return sendBreakerOpen(reply, err)
throw err
}
})
}
+686
View File
@@ -0,0 +1,686 @@
/**
* Skill management routes.
*
* Two scopes, mirroring the dsh skill layers discovered by
* `@deepseek-ai/dsh-skill-filesystem`:
* - shared (`/api/skills/shared`): admin-only. Targets the platform
* bundled layer (`DSH_BUNDLED_SKILL_DIR`, rank 600, read-only for users).
* - mine (`/api/skills/mine`): any authenticated user. Targets the
* user's own `$DSH_HOME/skills` (rank 400 user-dsh layer).
*
* Upload contract (ZIP only): one base64 `.zip` whose root holds exactly one
* top-level directory containing `SKILL.md` with frontmatter `name`
* (`^[a-z0-9]+(-[a-z0-9]+)*$`) and `description`. Both are REQUIRED — dsh
* ignores skills that miss either (see parseSkillFile / SKILL_NAME in
* @deepseek-ai/dsh-skill).
*
* Two-phase install for overwrite safety:
* 1. POST upload → validate + stage the archive. If no same-name skill
* exists it is installed immediately; if one exists the response carries
* `conflict: true` + `stagedId` and nothing is touched yet.
* 2. POST apply → after the user confirms, atomically REPLACE the target:
* the previous skill directory is removed in full (files absent from the
* new zip are deleted too), then the staged one is renamed in.
*
* Discovery is watch-based on the host, so install/remove take effect without
* an instance restart (verified: addDir/unlinkDir watch events invalidate).
* @module dshs/web/routes/skills
*/
import type { FastifyPluginAsync } from 'fastify'
import { execFileSync } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { chmodSync, chownSync, existsSync, lchownSync, lstatSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { requireAdmin, requireAuth } from '../middleware/authn.js'
import { scanDir } from '../security-scan.js'
import { userRoot } from '../../fs/workspace.js'
/** dsh skill-name rule (mirrors SKILL_NAME in @deepseek-ai/dsh-skill). */
const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
/** Route-level body limit for uploads (base64 ~4/3 of archive size). */
const UPLOAD_BODY_LIMIT = 180 * 1024 * 1024
const STAGE_PREFIX = '.skill-upload-'
/** A staged upload id is just the staging dir name under `dataRoot`. */
const STAGE_ID_RE = /^\.skill-upload-[0-9a-f]{12}$/
/** Staging dirs older than this (ms) are garbage-collected on next upload. */
const STAGE_TTL_MS = 10 * 60 * 1000
/**
* 上传硬上限(档案 41 · zip bomb 防护)。
* `UPLOAD_BODY_LIMIT` 只限制 **HTTP body(= 压缩体)**;宿主 `quotaon /` 未启用 = **无磁盘配额**
* (档案 38),一个 180MB 的 zip 可解压出几十 GB → 撑爆 40G 单分区、**打瘫全平台**(跨租户)。
* 因此必须限制**解压后**的规模。
*/
const MAX_SKILL_FILES = 2000
const MAX_SKILL_UNCOMPRESSED_BYTES = 200 * 1024 * 1024
/**
* 「已禁用」的用户技能存放处(在 `$DSH_HOME` 下、但 **不在** `skills/` 内)。
* dsh 的 `discoverRoot` 只扫 `$DSH_HOME/skills`,所以放进这里是"保留但不可见"= 禁用,
* 从而在 dsh **没有 disable 机制**的前提下实现用户侧「启用 / 禁用 / 删除」。
*/
const SKILL_LIBRARY_DIR = 'skills-library'
function httpError(statusCode: number, message: string): Error & { statusCode: number } {
const e = new Error(message) as Error & { statusCode: number }
e.statusCode = statusCode
return e
}
/** Parse `name`/`description` from a SKILL.md frontmatter block. */
function parseSkillMeta(markdown: string): { name?: string; description?: string } {
const block = /^---\s*\r?\n([\s\S]*?)\r?\n---/.exec(markdown)
if (block === null) return {}
const fm = block[1]
const name = /^name:\s*["']?([^"'\n]+)["']?\s*$/m.exec(fm)
const description = /^description:\s*["']?(.*?)["']?\s*$/m.exec(fm)
return {
name: name?.[1]?.trim(),
description: description?.[1]?.trim(),
}
}
async function skillMetaAt(dir: string): Promise<{ name?: string; description?: string }> {
try {
return parseSkillMeta(await readFileSync(join(dir, 'SKILL.md'), 'utf8'))
} catch {
return {}
}
}
interface SkillSummary {
/** On-disk directory name, always == frontmatter name. */
dir: string
/** Frontmatter `name`. */
name: string
description: string
/** Recursive bytes. */
size: number
/** Recursive file count. */
files: number
mtimeMs: number
}
async function dirSizeAndCount(abs: string): Promise<{ size: number; files: number }> {
let size = 0
let files = 0
for (const entry of await readdir(abs, { withFileTypes: true })) {
const st = await stat(join(abs, entry.name))
if (st.isDirectory()) {
const sub = await dirSizeAndCount(join(abs, entry.name))
size += sub.size
files += sub.files
} else {
size += st.size
files += 1
}
}
return { size, files }
}
async function summarizeSkillDir(abs: string): Promise<SkillSummary> {
const meta = await skillMetaAt(abs)
const st = await stat(abs)
const { size, files } = st.isDirectory() ? await dirSizeAndCount(abs) : { size: st.size, files: 1 }
return {
dir: basename(abs),
name: meta.name ?? basename(abs),
description: meta.description ?? '',
size,
files,
mtimeMs: st.mtimeMs,
}
}
/** List single-level skill directories under `root` (dirs only, skip hidden). */
async function listSkills(root: string): Promise<SkillSummary[]> {
let entries
try {
entries = await readdir(root, { withFileTypes: true })
} catch {
return []
}
const out: SkillSummary[] = []
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
try {
out.push(await summarizeSkillDir(join(root, entry.name)))
} catch {
// entry vanished mid-scan
}
}
out.sort((a, b) => a.name.localeCompare(b.name))
return out
}
/** Remove staging dirs left over from abandoned uploads (TTL). */
function collectStaleStages(dataRoot: string): string[] {
let names: string[] = []
try {
names = readdirSync(dataRoot).filter((n) => n.startsWith(STAGE_PREFIX))
} catch {
return []
}
const stale: string[] = []
for (const name of names) {
try {
if (Date.now() - lstatSync(join(dataRoot, name)).mtimeMs > STAGE_TTL_MS) stale.push(name)
} catch {
stale.push(name)
}
}
return stale
}
/**
* 累加 zip 内所有成员的**解压后**字节数(解析 `unzip -l` 的表格区)。
* 用于 zip bomb 防护:压缩体 180MB 上限对解压规模毫无约束力。
*/
function sumUncompressedSize(payload: string): number {
const out = execFileSync('unzip', ['-l', payload], { encoding: 'utf8', timeout: 30000 })
let total = 0
let inTable = false
for (const line of out.split('\n')) {
if (/^\s*-{5,}/.test(line)) {
if (inTable) break // 第二条分隔线 = 表格结束
inTable = true
continue
}
if (!inTable) continue
const n = Number(line.trim().split(/\s+/)[0])
if (Number.isFinite(n)) total += n
}
return total
}
/**
* 找出任何**非普通文件/目录**的条目(symlink / 设备 / FIFO / socket),返回其相对路径。
*
* ⚠️ 这是档案 41 修的 P0 的根因所在:zip 的成员**可以是符号链接**,而 `unzip` 默认**原样恢复**它。
* symlink 的目标写在 **zip 元数据**里,不在任何文件内容里 → `scanDir`(只读文件内容,且遇到
* `!isFile()` 直接 `continue`)**永远看不到它**。而随后以 **root** 身份执行的 `chownTree` /
* `chmodTree` 会**跟随**该链接 → **改写链接目标(宿主任意文件)的属主与权限**。
* 实测:`-rw------- root:root` → `-rw-r--r-- <租户uid>`;指向 `/etc/shadow` 即等于
* 把密码哈希 chmod 成 644 并 chown 给该租户。
*/
function findSpecialEntry(root: string, rel = ''): string | null {
for (const entry of readdirSync(root)) {
const abs = join(root, entry)
const relPath = rel === '' ? entry : `${rel}/${entry}`
let st: ReturnType<typeof lstatSync>
try {
st = lstatSync(abs)
} catch {
continue
}
if (st.isSymbolicLink()) return relPath
if (st.isDirectory()) {
const hit = findSpecialEntry(abs, relPath)
if (hit !== null) return hit
continue
}
if (!st.isFile()) return relPath
}
return null
}
/**
* Validate + extract a `.zip` skill archive under `<dataRoot>/<staging>`.
* @returns staged top dir, registered skill name/description, file count.
*/
function stageZipArchive(dataRoot: string, archive: Buffer): { stage: string; topDir: string; name: string; description: string; fileCount: number } {
const stage = join(dataRoot, STAGE_PREFIX + randomBytes(6).toString('hex'))
const payload = join(stage, 'payload.zip')
const unzipDir = join(stage, 'unzip')
mkdirSync(unzipDir, { recursive: true, mode: 0o700 })
try {
writeFileSync(payload, archive)
// 1. List members (no extraction yet) and validate every path.
const members = execFileSync('unzip', ['-Z1', payload], { encoding: 'utf8', timeout: 30000 }).split('\n')
const tops = new Set<string>()
let hasSkillMd = false
let fileCount = 0
for (let raw of members) {
raw = raw.trim().replace(/\\/g, '/').replace(/\/+$/, '')
if (raw === '' || raw === '.' || raw === '__MACOSX' || raw.startsWith('__MACOSX/')) continue
if (raw.startsWith('/') || /^[A-Za-z]:/.test(raw)) throw httpError(400, `zip member uses an absolute path: ${raw}`)
if (raw.split('/').includes('..')) throw httpError(400, `zip member escapes the root: ${raw}`)
const top = raw.split('/')[0]!
if (top === '.DS_Store') continue
tops.add(top)
if (raw.endsWith('/SKILL.md')) hasSkillMd = true
if (!raw.endsWith('/')) fileCount += 1
}
if (tops.size !== 1) {
throw httpError(400, `zip 必须包含且仅包含一个顶层技能目录(当前 ${tops.size} 个)`)
}
const top = [...tops][0]!
if (top.startsWith('.')) throw httpError(400, 'zip 顶层目录名无效(不能以 . 开头)')
if (!hasSkillMd) throw httpError(400, 'zip 缺少 <顶层目录>/SKILL.md —— 不是 dsh 技能包')
if (fileCount === 0) throw httpError(400, 'zip 为空(无文件)')
// 1b. 规模上限(zip bomb 防护;档案 41)——压缩体上限对解压规模无约束力。
if (fileCount > MAX_SKILL_FILES) {
throw httpError(400, `技能包文件数超限(${fileCount} > ${MAX_SKILL_FILES})`)
}
const uncompressedBytes = sumUncompressedSize(payload)
if (uncompressedBytes > MAX_SKILL_UNCOMPRESSED_BYTES) {
throw httpError(
400,
`技能包解压后体积超限(${Math.ceil(uncompressedBytes / 1048576)}MB > ${MAX_SKILL_UNCOMPRESSED_BYTES / 1048576}MB)`,
)
}
// 2. Extract.
execFileSync('unzip', ['-q', payload, '-d', unzipDir], { timeout: 120000 })
// 2a. 拒绝任何非普通文件条目(symlink / 设备 / FIFO / socket)——见 findSpecialEntry 注释。
// 必须在 scanDir 之前、也必须在任何 chown/chmod 之前拦住。
const specialEntry = findSpecialEntry(unzipDir)
if (specialEntry !== null) {
throw httpError(400, `技能包不允许包含符号链接/设备/管道文件:${specialEntry}`)
}
// 2b. Security scan (危险内容扫描) — reject obviously-malicious patterns.
const scanFindings = scanDir(unzipDir)
if (scanFindings.length > 0) {
process.stderr.write(
`[upload-scan-warnings] ${JSON.stringify({ count: scanFindings.length, findings: scanFindings.slice(0, 20) })}\n`,
)
}
// 3. Validate the dsh-standard skill files inside.
const skillMd = readFileSync(join(unzipDir, top, 'SKILL.md'), 'utf8')
const meta = parseSkillMeta(skillMd)
if (meta.name === undefined || !SKILL_NAME_RE.test(meta.name)) {
throw httpError(
400,
`SKILL.md frontmatter name 必须为小写连字符(如 mcn-workstation),当前为 "${meta.name ?? ''}" —— 请修改 SKILL.md 后重新打包`,
)
}
if (meta.description === undefined || meta.description.trim() === '') {
throw httpError(400, `SKILL.md frontmatter 缺少 description —— dsh 要求 name 与 description 同时存在`)
}
return {
stage,
topDir: join(unzipDir, top),
name: meta.name,
description: meta.description.trim(),
fileCount,
}
} catch (err) {
rm(stage, { recursive: true, force: true }).catch(() => undefined)
throw err
}
}
function chownTree(root: string, uid: number, gid: number): void {
lchownSync(root, uid, gid)
for (const entry of readdirSync(root)) {
const abs = join(root, entry)
const st = lstatSync(abs)
// 档案 41:绝不跟随符号链接(跟随 = 以 root 改写**链接目标**的属主)。
// 即便 stageZipArchive 已拒绝 symlink 成员,这里也保持"不跟随"作为纵深防御。
if (st.isSymbolicLink()) continue
if (st.isDirectory()) chownTree(abs, uid, gid)
else lchownSync(abs, uid, gid)
}
}
function chmodTree(root: string, dirMode: number, fileMode: number): void {
chmodSync(root, dirMode)
for (const entry of readdirSync(root)) {
const abs = join(root, entry)
const st = lstatSync(abs)
// Linux 无 lchmod(ENOSYS),因此对 symlink 一律**跳过**而不是"不跟随地改"。
if (st.isSymbolicLink()) continue
if (st.isDirectory()) chmodTree(abs, dirMode, fileMode)
else chmodSync(abs, fileMode)
}
}
/**
* Phase 2: atomically REPLACE `<targetRoot>/<name>` with the staged skill.
* The previous directory is removed IN FULL first — files that exist only in
* the old skill (absent from the new zip) are deleted as well.
*/
async function applyStaged(
dataRoot: string,
stagedId: string,
targetRoot: string,
owner?: { uid: number; gid: number },
): Promise<SkillSummary> {
if (!STAGE_ID_RE.test(stagedId)) throw httpError(400, 'invalid staged id')
const stage = join(dataRoot, stagedId)
if (!existsSync(stage)) throw httpError(404, 'staged upload not found or expired — 请重新上传')
const unzipDir = join(stage, 'unzip')
let top: string | undefined
for (const entry of readdirSync(unzipDir)) {
if (lstatSync(join(unzipDir, entry)).isDirectory()) { top = entry; break }
}
if (top === undefined) throw httpError(400, 'staged skill is empty')
const meta = await skillMetaAt(join(unzipDir, top))
if (meta.name === undefined) throw httpError(400, 'staged skill has no valid SKILL.md')
const target = join(targetRoot, meta.name)
try {
// Full replacement: remove everything the old skill had, then move the new
// one in — files absent from the new zip cannot survive.
if (existsSync(target)) await rm(target, { recursive: true, force: true })
const topAbs = join(unzipDir, top)
if (owner !== undefined) {
chownTree(topAbs, owner.uid, owner.gid)
chmodTree(topAbs, 0o755, 0o644)
}
await rename(topAbs, target)
return await summarizeSkillDir(target)
} finally {
rm(stage, { recursive: true, force: true }).catch(() => undefined)
}
}
interface UploadBody {
file: string
filename: string
}
interface ApplyBody {
stagedId: string
}
const uploadSchema = {
body: {
type: 'object',
required: ['file', 'filename'],
additionalProperties: false,
properties: {
file: { type: 'string', maxLength: UPLOAD_BODY_LIMIT },
filename: { type: 'string', maxLength: 255 },
},
},
} as const
const applySchema = {
body: {
type: 'object',
required: ['stagedId'],
additionalProperties: false,
properties: { stagedId: { type: 'string', maxLength: 64 } },
},
} as const
function requireZipName(filename: string): void {
if (!basename(filename).toLowerCase().endsWith('.zip')) {
throw httpError(400, '仅支持 .zip 文件')
}
}
export const skillRoutes: FastifyPluginAsync = async (app) => {
const gc = (): void => {
for (const stale of collectStaleStages(app.config.dataRoot)) {
rm(join(app.config.dataRoot, stale), { recursive: true, force: true }).catch(() => undefined)
}
}
// ── shared: platform bundled layer, admin only ──────────────────────────
app.get('/api/skills/shared', { preHandler: requireAdmin }, async () => {
const root = app.config.bundledSkillDir
await mkdir(root, { recursive: true, mode: 0o755 }).catch(() => undefined)
return { skills: await listSkills(root) }
})
// Phase 1: validate + stage. No same-name skill → install immediately.
// Same-name skill exists → 200 with conflict:true + stagedId (nothing touched).
app.post(
'/api/skills/shared',
{ preHandler: requireAdmin, bodyLimit: UPLOAD_BODY_LIMIT, schema: uploadSchema },
async (request, reply) => {
const root = app.config.bundledSkillDir
await mkdir(root, { recursive: true, mode: 0o755 }).catch(() => undefined)
const body = request.body as UploadBody
try {
requireZipName(body.filename)
const archive = Buffer.from(body.file, 'base64')
if (archive.length === 0) throw httpError(400, 'empty archive payload')
gc()
const staged = stageZipArchive(app.config.dataRoot, archive)
const existing = existsSync(join(root, staged.name))
if (!existing) {
const skill = await applyStaged(app.config.dataRoot, basename(staged.stage), root)
await app.db.audit(request.user?.id ?? null, 'install_shared_skill', JSON.stringify({ name: skill.name }))
return { ok: true, skill }
}
// Conflict: keep the staged copy, ask the user to confirm replacement.
const old = await summarizeSkillDir(join(root, staged.name))
return {
ok: true,
conflict: true,
stagedId: basename(staged.stage),
skill: { name: staged.name, description: staged.description, files: staged.fileCount },
existing: { name: old.name, size: old.size, files: old.files, mtimeMs: old.mtimeMs },
}
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 400).send({ error: e.message })
}
},
)
// Phase 2: confirm replacement (full replace — old files not in the new zip are removed).
app.post(
'/api/skills/shared/apply',
{ preHandler: requireAdmin, schema: applySchema },
async (request, reply) => {
const { stagedId } = request.body as ApplyBody
try {
const skill = await applyStaged(app.config.dataRoot, stagedId, app.config.bundledSkillDir)
await app.db.audit(request.user?.id ?? null, 'replace_shared_skill', JSON.stringify({ name: skill.name }))
return { ok: true, skill }
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 400).send({ error: e.message })
}
},
)
app.delete('/api/skills/shared/:name', { preHandler: requireAdmin }, async (request, reply) => {
const { name } = request.params as { name: string }
if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'invalid_skill_name' })
const root = app.config.bundledSkillDir
const target = join(root, name)
if (!existsSync(target)) return reply.code(404).send({ error: 'not_found' })
await rm(target, { recursive: true, force: true })
await app.db.audit(request.user?.id ?? null, 'delete_shared_skill', JSON.stringify({ name }))
return { ok: true }
})
// ── mine: personal layer under $DSH_HOME/skills, any authenticated user ─
const mineRoot = (userId: string): string => join(userRoot(app.config.dataRoot, userId), 'home', 'skills')
/** 「已禁用」用户技能的存放处 —— 在 `$DSH_HOME` 下但不在 `skills/` 内,故 dsh 扫不到。 */
const libraryRoot = (userId: string): string =>
join(userRoot(app.config.dataRoot, userId), 'home', SKILL_LIBRARY_DIR)
/** 平台共享技能(rank 600)名集合:对用户**只读**,不可禁用/删除/覆盖。 */
const sharedNames = (): Set<string> => {
const dir = app.config.bundledSkillDir
if (dir === '' || !existsSync(dir)) return new Set()
try {
return new Set(readdirSync(dir).filter((n) => SKILL_NAME_RE.test(n)))
} catch {
return new Set()
}
}
/** 用户技能名 → 是否被平台共享层占用(占用则 409,避免用户白上传一份永远不生效的技能)。 */
const assertNotShared = (name: string): void => {
if (sharedNames().has(name)) {
throw httpError(409, `「${name}」是平台共享技能(全员只读,用户不可禁用/删除/覆盖)—— 请改用其他技能名`)
}
}
async function ensureMineRoot(userId: string): Promise<{ root: string; owner: { uid: number; gid: number } }> {
const root = mineRoot(userId)
const home = join(userRoot(app.config.dataRoot, userId), 'home')
const homeStat = lstatSync(home)
const owner = { uid: homeStat.uid, gid: homeStat.gid }
if (!existsSync(root)) {
mkdirSync(root, { recursive: true, mode: 0o755 })
chownSync(root, owner.uid, owner.gid)
}
return { root, owner }
}
// 列表:合并三类 —— 平台共享(locked,只读)/ 用户已启用 / 用户已禁用(库中保留)。
app.get('/api/skills/mine', { preHandler: requireAuth }, async (request) => {
const user = request.user!
const { root } = await ensureMineRoot(user.id)
const lib = libraryRoot(user.id)
const sharedDir = app.config.bundledSkillDir
const [enabled, disabled, shared] = await Promise.all([
listSkills(root),
existsSync(lib) ? listSkills(lib) : Promise.resolve([]),
sharedDir !== '' && existsSync(sharedDir) ? listSkills(sharedDir) : Promise.resolve([]),
])
return {
skills: [
// 平台共享层(rank 600,全员只读;dsh 无 disable 机制 → 标 locked,前端不可操作)
...shared.map((s) => ({ ...s, source: 'shared' as const, enabled: true, locked: true })),
...enabled.map((s) => ({ ...s, source: 'user' as const, enabled: true, locked: false })),
...disabled.map((s) => ({ ...s, source: 'user' as const, enabled: false, locked: false })),
],
}
})
app.post(
'/api/skills/mine',
{ preHandler: requireAuth, bodyLimit: UPLOAD_BODY_LIMIT, schema: uploadSchema },
async (request, reply) => {
const user = request.user!
const { root, owner } = await ensureMineRoot(user.id)
const body = request.body as UploadBody
try {
requireZipName(body.filename)
const archive = Buffer.from(body.file, 'base64')
if (archive.length === 0) throw httpError(400, 'empty archive payload')
gc()
const staged = stageZipArchive(app.config.dataRoot, archive)
// 与平台共享技能同名 → 直接拒(否则用户会白上传一份永远被 rank 600 压住的技能)。
try {
assertNotShared(staged.name)
} catch (err) {
await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined)
throw err
}
if (!existsSync(join(root, staged.name))) {
const skill = await applyStaged(app.config.dataRoot, basename(staged.stage), root, owner)
return { ok: true, skill }
}
const old = await summarizeSkillDir(join(root, staged.name))
return {
ok: true,
conflict: true,
stagedId: basename(staged.stage),
skill: { name: staged.name, description: staged.description, files: staged.fileCount },
existing: { name: old.name, size: old.size, files: old.files, mtimeMs: old.mtimeMs },
}
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 400).send({ error: e.message })
}
},
)
app.post(
'/api/skills/mine/apply',
{ preHandler: requireAuth, schema: applySchema },
async (request, reply) => {
const user = request.user!
const { root, owner } = await ensureMineRoot(user.id)
const { stagedId } = request.body as ApplyBody
try {
const skill = await applyStaged(app.config.dataRoot, stagedId, root, owner)
return { ok: true, skill }
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 400).send({ error: e.message })
}
},
)
/**
* 启用:库(skills-library)→ `$DSH_HOME/skills`。
* dsh 的 skill 发现是 watch 驱动的,所以**即时生效、无需重启实例**。
*/
app.post('/api/skills/mine/:name/enable', { preHandler: requireAuth }, async (request, reply) => {
const user = request.user!
const { name } = request.params as { name: string }
if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'invalid_skill_name' })
try {
assertNotShared(name)
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 409).send({ error: e.message })
}
const { root, owner } = await ensureMineRoot(user.id)
const src = join(libraryRoot(user.id), name)
if (!existsSync(src)) return reply.code(404).send({ error: 'not_found' })
const dst = join(root, name)
if (existsSync(dst)) return reply.code(409).send({ error: 'already_enabled' })
await rename(src, dst)
chownTree(dst, owner.uid, owner.gid)
chmodTree(dst, 0o755, 0o644)
await app.db.audit(user.id, 'enable_skill', JSON.stringify({ name }))
return { ok: true, skill: await summarizeSkillDir(dst) }
})
/**
* 禁用:`$DSH_HOME/skills` → 库。**保留文件**(可再次启用),只是移出 dsh 的扫描根
* —— 因为 `dsh-skill-filesystem` **没有 disable/deny 机制**。
*/
app.post('/api/skills/mine/:name/disable', { preHandler: requireAuth }, async (request, reply) => {
const user = request.user!
const { name } = request.params as { name: string }
if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'invalid_skill_name' })
try {
assertNotShared(name)
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 409).send({ error: e.message })
}
const { root, owner } = await ensureMineRoot(user.id)
const src = join(root, name)
if (!existsSync(src)) return reply.code(404).send({ error: 'not_found' })
const lib = libraryRoot(user.id)
mkdirSync(lib, { recursive: true, mode: 0o755 })
chownSync(lib, owner.uid, owner.gid)
const dst = join(lib, name)
if (existsSync(dst)) await rm(dst, { recursive: true, force: true })
await rename(src, dst)
await app.db.audit(user.id, 'disable_skill', JSON.stringify({ name }))
return { ok: true }
})
/** 删除:库与启用位置一起删(彻底移除用户自建技能)。 */
app.delete('/api/skills/mine/:name', { preHandler: requireAuth }, async (request, reply) => {
const user = request.user!
const { name } = request.params as { name: string }
if (!SKILL_NAME_RE.test(name)) return reply.code(400).send({ error: 'invalid_skill_name' })
try {
assertNotShared(name)
} catch (err) {
const e = err as Error & { statusCode?: number }
return reply.code(e.statusCode ?? 409).send({ error: e.message })
}
const targets = [join(mineRoot(user.id), name), join(libraryRoot(user.id), name)]
const present = targets.filter((t) => existsSync(t))
if (present.length === 0) return reply.code(404).send({ error: 'not_found' })
for (const t of present) await rm(t, { recursive: true, force: true })
await app.db.audit(user.id, 'delete_skill', JSON.stringify({ name }))
return { ok: true }
})
}
+319
View File
@@ -0,0 +1,319 @@
/**
* 官方白名单来源(方案 C · 方案 B 口径):把 awesome-dsh-plugin 官方目录接入
* 门户「插件管理」,admin 挑选后导入功能插件候选池。
*
* 数据源:`https://awesome-dsh-plugin.com/plugins.json`(官方规范地址;npm 镜像
* `dsh-plugin-catalog`)。约 3400 条,每条含 `npm` 包名 / `install` 规格 /
* `stars` / `downloads` / 分类 / 中英描述。相比 git clone 再解析 3431 个 yml,
* 一次 HTTP 拿全,且字段更全。
*
* 导入口径 = **仅预构建**(不在平台侧执行任何第三方构建脚本):
* 1. 有 `npm` 包名 → 取 registry.npmjs.org 官方 tarball(预构建,秒装)
* 2. 无 npm、有 `tarball` → 取 GitHub release 资产直链
* 3. 两者皆无(官方 `install` 形态为 `github:owner/repo`,需源码构建)→ 不支持
* 一键导入,接口返回明确原因,前端标记「需源码构建」。
* 覆盖约 54%(1854/3408),官方下载量 TOP10 热门插件全部包含。
*
* 导入复用 business-plugins 的 `stageTgzArchive`(成员路径校验 + package.json
* 校验 + 危险内容安全扫描)+ 同名替换策略,与管理员手工上传是同一条链路。
*
* 注:官方清单的「收录」不等于安全审计(上游 README 明确声明)。安全兜底完全
* 依赖 `stageTgzArchive` 的安全扫描;P0 命中时阻断并把原因原样回显给 admin。
* @module dshs/web/routes/whitelist
*/
import type { FastifyPluginAsync } from 'fastify'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { mkdir, rename, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { requireAdmin } from '../middleware/authn.js'
import { httpError } from '../security-scan.js'
import { stageTgzArchive } from './business-plugins.js'
/** 官方目录规范地址(同一份内容亦镜像为 npm 包 `dsh-plugin-catalog`)。 */
const CATALOG_URL = 'https://awesome-dsh-plugin.com/plugins.json'
const REGISTRY = 'https://registry.npmjs.org'
/** 索引缓存 TTL:6 小时。 */
const CACHE_TTL = 6 * 60 * 60 * 1000
/**
* 缓存结构版本。改字段/换数据源时必须 +1 —— 否则 TTL 内的旧格式缓存会被直接
* 复用,条目缺字段,后续按字段过滤就会在请求里抛异常(v1 = git clone + yml 版)。
*/
const CACHE_VERSION = 3
const FETCH_TIMEOUT_MS = 60_000
/** 单次导入上限(避免一次拉太多把请求拖死)。 */
const MAX_IMPORT = 20
const MAX_ARCHIVE_BYTES = 150 * 1024 * 1024
/** 官方 `plugins.json` 的单条原始形态(字段可能缺失,全部按需兜底)。 */
interface RawEntry {
name?: string
owner?: string
url?: string
page?: string
category?: string
description?: { zh?: string; en?: string }
npm?: string | null
version?: string | null
stars?: number
downloads?: number | null
install?: string
tarball?: string | null
screenshots?: unknown
}
/** 导入方式:`npm` = registry 官方 tarball;`tarball` = release 资产;`source` = 需源码构建(不支持)。 */
type ImportKind = 'npm' | 'tarball' | 'source'
interface WhitelistEntry {
/** `owner/name`,清单内唯一,前端勾选与导入回查都用它。 */
id: string
name: string
owner: string
url: string
page: string
category: string
/** 分类中文名(来自目录顶层 `categories` 映射,如 `ui` → UI 增强);缺映射时回退为 id。 */
categoryLabel: string
description: string
npm: string | null
version: string | null
stars: number
downloads: number
install: string
importKind: ImportKind
tarball: string | null
/** 官方目录是否给该插件配了截图(约 17% 条目有;有则可去 `page` 看效果图)。 */
hasShots: boolean
}
interface CachedIndex {
version: number
fetchedAt: number
source: string
/** 分类 id → 中文名(目录顶层 `categories` 的映射,随缓存一起留存)。 */
categoryLabels: Record<string, string>
entries: WhitelistEntry[]
}
export const whitelistRoutes: FastifyPluginAsync = async (app) => {
const cacheDir = join(app.config.dataRoot, 'whitelist-cache')
const indexFile = join(cacheDir, 'index.json')
const asString = (v: unknown): string => (typeof v === 'string' ? v : '')
/** 把官方目录 JSON 归一化成内部条目表 + 分类中文名映射,条目按热度排序。 */
const buildIndex = (raw: unknown): { entries: WhitelistEntry[]; categoryLabels: Record<string, string> } => {
const obj = raw as { plugins?: RawEntry[]; categories?: Record<string, { en?: string; zh?: string }> }
const list = obj.plugins
if (!Array.isArray(list)) throw httpError(502, '官方目录格式异常:缺少 plugins 数组')
// 顶层 categories = { id: { en, zh } } —— 官方只在这里给分类的中文名。
const categoryLabels: Record<string, string> = {}
for (const [id, v] of Object.entries(obj.categories ?? {})) {
const label = asString(v?.zh) !== '' ? asString(v.zh) : asString(v?.en)
if (label !== '') categoryLabels[id] = label
}
const entries: WhitelistEntry[] = []
for (const e of list) {
const name = asString(e.name)
if (name === '') continue
const owner = asString(e.owner)
const category = asString(e.category)
const npm = asString(e.npm)
const tarball = asString(e.tarball)
const importKind: ImportKind = npm !== '' ? 'npm' : tarball !== '' ? 'tarball' : 'source'
entries.push({
id: owner === '' ? name : `${owner}/${name}`,
name,
owner,
url: asString(e.url),
page: asString(e.page),
category,
categoryLabel: categoryLabels[category] ?? category,
description: asString(e.description?.zh) !== '' ? asString(e.description?.zh) : asString(e.description?.en),
npm: npm === '' ? null : npm,
version: asString(e.version) === '' ? null : asString(e.version),
stars: Number(e.stars ?? 0) || 0,
downloads: Number(e.downloads ?? 0) || 0,
install: asString(e.install),
importKind,
tarball: tarball === '' ? null : tarball,
hasShots: Array.isArray(e.screenshots) && e.screenshots.length > 0,
})
}
// 下载量降序(未发 npm 的条目 downloads 为 0,自然沉底)→ 热门 = 可预构建安装。
entries.sort((a, b) => b.downloads - a.downloads || b.stars - a.stars)
return { entries, categoryLabels }
}
/** 拉官方目录并落缓存。 */
const fetchCatalog = async (): Promise<{ entries: WhitelistEntry[]; categoryLabels: Record<string, string> }> => {
const res = await fetch(CATALOG_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'follow' })
if (!res.ok) throw httpError(502, `官方目录拉取失败 HTTP ${res.status}`)
const { entries, categoryLabels } = buildIndex(await res.json())
if (entries.length === 0) throw httpError(502, '官方目录为空')
mkdirSync(cacheDir, { recursive: true, mode: 0o755 })
const payload: CachedIndex = { version: CACHE_VERSION, fetchedAt: Date.now(), source: CATALOG_URL, categoryLabels, entries }
writeFileSync(indexFile, JSON.stringify(payload))
return { entries, categoryLabels }
}
/** 读缓存:版本或结构不符一律当未命中(旧格式不能被复用)。 */
const readCache = (): CachedIndex | null => {
if (!existsSync(indexFile)) return null
try {
const cached = JSON.parse(readFileSync(indexFile, 'utf8')) as CachedIndex
if (cached.version !== CACHE_VERSION) return null
if (!Array.isArray(cached.entries) || cached.entries.length === 0) return null
if (typeof cached.entries[0]?.importKind !== 'string') return null
return cached
} catch {
return null
}
}
/** 取索引:命中 TTL 用缓存;否则拉取;拉取失败但有旧缓存 → 降级用旧缓存并标 `stale`。 */
const getIndex = async (force = false): Promise<{ fetchedAt: number; entries: WhitelistEntry[]; categoryLabels: Record<string, string>; stale: boolean }> => {
const cached = readCache()
if (!force && cached !== null && Date.now() - cached.fetchedAt < CACHE_TTL) return { fetchedAt: cached.fetchedAt, entries: cached.entries, categoryLabels: cached.categoryLabels ?? {}, stale: false }
try {
const { entries, categoryLabels } = await fetchCatalog()
return { fetchedAt: Date.now(), entries, categoryLabels, stale: false }
} catch (err) {
if (cached !== null) app.log.warn({ err }, 'whitelist: 官方目录拉取失败,降级使用本地缓存')
if (cached !== null) return { fetchedAt: cached.fetchedAt, entries: cached.entries, categoryLabels: cached.categoryLabels ?? {}, stale: true }
throw err
}
}
/** 解析出可下载的预构建 tarball 直链(方案 B:绝不触发源码构建)。 */
const resolveTarball = async (entry: WhitelistEntry): Promise<{ url: string; version: string | null }> => {
if (entry.importKind === 'npm' && entry.npm !== null) {
const name = entry.npm
// scoped 包(@scope/name)的 packument 地址:`@` 保留、`/` 编码。
const res = await fetch(`${REGISTRY}/${encodeURIComponent(name).replace('%40', '@')}`, {
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
})
if (!res.ok) throw httpError(502, `npm registry 查询失败 HTTP ${res.status}(${name})`)
const meta = (await res.json()) as {
'dist-tags'?: { latest?: string }
versions?: Record<string, { dist?: { tarball?: string } }>
}
const latest = meta['dist-tags']?.latest
const url = latest === undefined ? undefined : meta.versions?.[latest]?.dist?.tarball
if (typeof url !== 'string' || url === '') throw httpError(502, `npm 包无可下载的已发布版本:${name}`)
return { url, version: latest ?? null }
}
if (entry.tarball !== null) return { url: entry.tarball, version: entry.version }
throw httpError(400, `该插件需从源码构建安装${entry.install === '' ? '' : `(${entry.install})`},暂不支持一键导入`)
}
app.get('/api/plugins/whitelist', { preHandler: requireAdmin }, async (request) => {
const { q, category, onlyImportable } = request.query as { q?: string; category?: string; onlyImportable?: string }
const { fetchedAt, entries, categoryLabels, stale } = await getIndex()
let list = entries
if (category !== undefined && category !== '') list = list.filter((e) => e.category === category)
if (onlyImportable === '1') list = list.filter((e) => e.importKind !== 'source')
if (q !== undefined && q !== '') {
const kw = q.toLowerCase()
list = list.filter(
(e) =>
e.name.toLowerCase().includes(kw) ||
(e.description ?? '').toLowerCase().includes(kw) ||
(e.npm ?? '').toLowerCase().includes(kw) ||
(e.owner ?? '').toLowerCase().includes(kw),
)
}
// 分类清单带中文名 + 计数(按条目数降序),供前端下拉直接用中文展示。
const counts = new Map<string, number>()
for (const e of entries) if (e.category !== '') counts.set(e.category, (counts.get(e.category) ?? 0) + 1)
const categories = [...counts.entries()]
.map(([id, count]) => ({ id, label: categoryLabels[id] ?? id, count }))
.sort((a, b) => b.count - a.count)
return {
fetchedAt,
stale,
total: entries.length,
count: list.length,
importable: entries.filter((e) => e.importKind !== 'source').length,
categories,
plugins: list.slice(0, 300),
}
})
app.post('/api/plugins/whitelist/refresh', { preHandler: requireAdmin }, async () => {
const { entries, fetchedAt } = await getIndex(true)
return { ok: true, total: entries.length, importable: entries.filter((e) => e.importKind !== 'source').length, fetchedAt }
})
app.post('/api/plugins/whitelist/import', { preHandler: requireAdmin }, async (request, reply) => {
const { names } = request.body as { names?: string[] }
if (!Array.isArray(names) || names.length === 0) return reply.code(400).send({ error: 'no_plugins_selected' })
const { entries } = await getIndex()
const byId = new Map(entries.map((e) => [e.id, e]))
const results: Array<{ id: string; name: string; ok: boolean; kind?: ImportKind; version?: string | null; error?: string }> = []
for (const id of names.slice(0, MAX_IMPORT)) {
try {
const entry = byId.get(id)
if (entry === undefined) throw httpError(404, `不在官方清单:${id}`)
const { url, version } = await resolveTarball(entry)
const res = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), redirect: 'follow' })
if (!res.ok) throw httpError(502, `下载失败 HTTP ${res.status}`)
const archive = Buffer.from(await res.arrayBuffer())
if (archive.length === 0 || archive.length > MAX_ARCHIVE_BYTES) throw httpError(400, '包大小异常')
const staged = await stageTgzArchive(app.config.dataRoot, archive)
// 投放裁决(与门户上传同一套口径):P0 命中 / 版本不兼容 → 跳过该包并回显依据。
// ⚠️ 本路径原先只依赖 `stageTgzArchive` 内部 throw;档案 66 把它改成「收集式返回」
// (P0 不再抛出、由调用方裁决)之后**这里漏了裁决** → P0 会被静默放过。
// 本次一并补上,并叠加档案 71 的兼容性判定(导入 = 用户要求的第二个入口)。
// 拒绝以 error 文本回显:批量导入是逐个 try/catch 收集结果,没有单条交互式「显式信任」入口。
if (staged.blocked.length > 0) {
throw httpError(
409,
`安全检测命中 P0,已跳过该包:${staged.blocked
.slice(0, 3)
.map((b) => `${b.why}(${b.file})`)
.join(';')}`,
)
}
if (staged.compat.level === 'incompatible') {
throw httpError(
409,
`与当前平台 dsh 版本不兼容,已跳过该包:${staged.compat.findings
.slice(0, 3)
.map((f) => `${f.pkg} — ${f.detail}`)
.join(';')}`,
)
}
const dir = join(app.config.dataRoot, 'business-plugins')
await mkdir(dir, { recursive: true, mode: 0o755 })
const existing = await app.db.findBusinessPlugin(staged.name)
if (existing !== undefined && existsSync(existing.tgzPath)) await rm(existing.tgzPath, { force: true })
const destPath = join(dir, staged.tgzName)
await rename(join(staged.stage, 'payload.tgz'), destPath)
const plugin = await app.db.upsertBusinessPlugin({
id: staged.name,
name: staged.name,
// 说明优先用**目录条目的中文**:`WhitelistEntry.description` 在 `getIndex()`
// 建索引时已按 `zh ?? en` 归一(见该处映射),比 tgz 里 `package.json` 的
// description(第三方作者写的,通常英文)更贴合中文用户。
// 之前这里直接写 `staged.description`,等于把目录里现成的中文丢掉 ——
// 门户「已投放插件」表与实例内「功能管理」因此只显示英文
//(2026-09-12 实测:anysearch / @liustack/modlens / dsh-univer-office 三者全部如此)。
description: entry.description !== '' ? entry.description : staged.description,
version: staged.version ?? version,
tgzPath: destPath,
fileSize: archive.length,
uploadedBy: request.user?.id ?? null,
})
await app.db.audit(request.user?.id ?? null, 'import_whitelist_plugin', JSON.stringify({ id, name: plugin.name, kind: entry.importKind }))
await rm(staged.stage, { recursive: true, force: true }).catch(() => undefined)
results.push({ id, name: staged.name, ok: true, kind: entry.importKind, version: plugin.version })
} catch (err) {
results.push({ id, name: id, ok: false, error: (err as Error).message })
}
}
return { ok: true, results }
})
}
+148
View File
@@ -0,0 +1,148 @@
/**
* Shared upload security scan (安全检测) for skill and business-plugin uploads.
*
* 分级(档案 19 §C6,2026-09-11):
* · **P0(阻断)** —— 明显恶意/提权模式:命中即拒绝上传(HTTP 400),与历史行为一致;
* · **P1(告警)** —— 可疑但常见的模式(动态执行、外联、敏感 env、长 base64、隐藏文件/`.git`):
* **不阻断**,作为 findings 返回并写日志,供管理员在上传时人工复核。
* 之前只有"命中即 400",缺少分级与整改提示;现在 `scanDir` 返回 P1 findings(调用方可忽略)。
* @module dshs/web/security-scan
*/
import { lstatSync, readdirSync, readFileSync, type Stats } from 'node:fs'
import { basename, join } from 'node:path'
const SCAN_TEXT_EXT = new Set([
'.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.sh', '.bash', '.zsh', '.py', '.json', '.md', '.txt',
'.yml', '.yaml', '.html', '.css', '.toml', '.ini', '.conf', '.env', '.cfg',
// 2026-09-11 扩展:非 JS 生态的常见脚本/配置(上传包里同样可能藏恶意指令)
'.rs', '.go', '.java', '.php', '.rb', '.pl', '.lua', '.sql', '.ps1', '.psm1', '.bat', '.cmd',
])
/** 扫描体积上限(旧值 2MB 会漏掉较大脚本;提到 8MB,仍跳过二进制/超大文件)。 */
const SCAN_MAX_BYTES = 8 * 1024 * 1024
/** P0:明显恶意/提权 → 直接阻断上传。 */
const BLOCK_PATTERNS: Array<{ re: RegExp; why: string }> = [
{ re: /rm\s+-rf\s+(\/|\/\*|~\s*|\$HOME)\b/, why: '含删除根目录/家目录命令' },
{ re: /(?:curl|wget)\s+[^\s|>]+\s*\|\s*(?:sh|bash)\b/, why: '下载并执行远程脚本' },
{ re: /\/etc\/(?:passwd|shadow|sudoers)\b/, why: '读取系统凭证文件' },
{ re: /\.ssh\/(?:id_rsa|id_ed25519|id_ecdsa|authorized_keys)/, why: '读取 SSH 私钥' },
{ re: /\.aws\/(?:credentials|config)\b/, why: '读取云平台凭证' },
{ re: /\/var\/lib\/dshs\b/, why: '读取本平台数据目录' },
{ re: /\.credentials\.yaml/, why: '读取实例会话密钥' },
// 2026-09-11 新增(档案 17 §S/M 对齐):仍是"明显恶意"档,故归 P0
{ re: /\bnc\s+-[a-z]*e[a-z]*\s+\S+\s+\d+/, why: '反弹 shell(nc -e)' },
{ re: /\/dev\/tcp\/\d{1,3}(\.\d{1,3}){3}\/\d+/, why: '反弹 shell(/dev/tcp)' },
{ re: /(?:base64\s+-d|b64decode|atob)\s*[\s\S]{0,40}?\|\s*(?:sh|bash)\b/, why: 'base64 解码后直接执行' },
{ re: /\bchmod\s+(?:\+s|4[0-7]{3}|6[0-7]{3})\b[^\n]{0,40}(?:\/usr\/bin|\/bin)\//, why: '尝试为系统二进制加 setuid/特权位' },
{ re: /:\s*\(\s*\)\s*\{\s*:\s*\|\s*:/, why: 'fork 炸弹' },
]
/**
* P1:可疑但常见 → **不阻断**,作为 findings 上报(管理员人工复核)。
* 说明书见档案 19 §C6 与档案 17 §S/M。
*/
const WARN_RULES: Array<{ id: string; re: RegExp; why: string }> = [
{ id: 'dynamic-exec', re: /\b(?:child_process|execSync|execFileSync|spawnSync|require\('child_process'\))/, why: '动态执行子进程(需确认目标命令可信)' },
{ id: 'vm-eval', re: /\b(?:new\s+Function\s*\(|require\('vm'\)|from 'node:vm'|eval\s*\()/, why: '动态求值 vm/eval/new Function' },
{ id: 'sensitive-env', re: /process\.env\s*[.\[]\s*['"]?(?:DEEPSEEK_API_KEY|DSH_[A-Z_]+|[A-Z_]*TOKEN|[A-Z_]*SECRET|[A-Z_]*KEY)/, why: '读取敏感环境变量' },
{ id: 'long-base64', re: /[A-Za-z0-9+/]{600,}={0,2}/, why: '超长 base64 载荷(可能是混淆/内嵌二进制)' },
{ id: 'network-egress', re: /https?:\/\/(?!localhost|127\.0\.0\.1)[a-z0-9.-]+\.[a-z]{2,}/i, why: '外部网络访问(需确认域名可信;配合出网护栏)' },
{ id: 'hidden-or-git', re: /(?:\.git\/|(?:^|\/)\.[a-z][a-z0-9_-]*\/)/i, why: '包含隐藏目录/.git(可能携带仓库元数据或绕过审查)' },
]
/** 一条扫描发现(P1 告警)。 */
export interface ScanFinding { rule: string; why: string; file: string }
/** 一条 P0 命中(默认阻断;业务插件上传可交由 admin 显式信任后放行)。 */
export interface ScanBlock { why: string; file: string }
/** 收集式扫描结果:`warnings` = P1 告警,`blocked` = P0 命中。 */
export interface ScanResult { warnings: ScanFinding[]; blocked: ScanBlock[] }
export function httpError(statusCode: number, message: string): Error & { statusCode: number } {
const e = new Error(message) as Error & { statusCode: number }
e.statusCode = statusCode
return e
}
/**
* Recursively walk text files under `root`, filling `findings` (P1) and `blocks` (P0).
* 遍历本身不抛:是否阻断由调用方({@link scanDir} / {@link scanDirDetailed})决定。
*/
function walk(root: string, rel: string, findings: ScanFinding[], blocks: ScanBlock[]): void {
let entries: string[]
try {
entries = readdirSync(root)
} catch {
return
}
for (const entry of entries) {
const abs = join(root, entry)
const relPath = rel === '' ? entry : `${rel}/${entry}`
let st: Stats
try {
st = lstatSync(abs)
} catch {
continue
}
if (st.isDirectory()) {
walk(abs, relPath, findings, blocks)
continue
}
if (!st.isFile()) continue
const base = basename(entry)
const dot = base.lastIndexOf('.')
const ext = dot >= 0 ? base.slice(dot).toLowerCase() : ''
const hasShebangCandidate = ext === '' // 无扩展名文件也可能是脚本(按内容判 shebang)
if (!SCAN_TEXT_EXT.has(ext) && !hasShebangCandidate) continue
if (st.size > SCAN_MAX_BYTES) continue // 跳过超大文件(不太可能是源码)
let text: string
try {
text = readFileSync(abs, 'utf8')
} catch {
continue
}
// 二进制探测 + shebang 判定(0x00 视为二进制;无扩展名须含 shebang 才继续)
if (text.includes('\u0000')) continue
if (hasShebangCandidate && !/^#!\s*\S/.test(text.slice(0, 64))) continue
for (const p of BLOCK_PATTERNS) {
if (p.re.test(text)) blocks.push({ why: p.why, file: relPath })
}
for (const w of WARN_RULES) {
if (w.re.test(text)) {
findings.push({ rule: w.id, why: w.why, file: relPath })
}
}
}
}
/**
* Recursively scan text files under `root`.
* P0 命中 → 抛 400(**阻断上传**,既有行为:技能上传等路径依赖它);P1 命中 → 收集并返回。
*/
export function scanDir(root: string, rel = '', findings: ScanFinding[] = []): ScanFinding[] {
const blocks: ScanBlock[] = []
walk(root, rel, findings, blocks)
if (blocks.length > 0) {
throw httpError(400, `安全检测未通过(P0 阻断):${blocks[0].why}(${blocks[0].file})`)
}
return findings
}
/**
* 收集式扫描:**P0 命中不抛**,与 P1 告警一并返回。
*
* 供「上传者本人就是可信管理员、且需要看到逐条命中再决定」的路径使用 ——
* 目前是业务插件(功能插件)投放:命中的往往是文档里的说明性字样(例如 README 教用户
* 把 key 写进 `.credentials.yaml`),直接 400 会让 admin 无从判断,也没法对**已人工确认**
* 的包放行。调用方拿到 `blocked` 后可以:默认拒绝并把详情回显给 admin,
* 或在 admin **显式声明信任**(并留痕)后继续入库。
*/
export function scanDirDetailed(root: string): ScanResult {
const warnings: ScanFinding[] = []
const blocks: ScanBlock[] = []
walk(root, '', warnings, blocks)
return { warnings, blocked: blocks }
}
+15
View File
@@ -0,0 +1,15 @@
/**
* 极简类型声明(档案 71 / 交接单 T05)
*
* `semver` 是平台的**传递依赖**(`node_modules/semver` 实测 7.8.5,无 `@types/semver`)。
* 兼容性预检只用到 `satisfies`,故按需声明所需形状 —— 既不引入新依赖,也不让
* `tsc` 因缺声明而失败(TS7016)。
*
* ⚠️ 语义提醒:**不传第三参**(即不使用 `includePrerelease`)。
* 这与 npm/pnpm 的实际安装判定一致;anysearch 的崩溃正是「prerelease 默认不匹配范围」导致的
* (它写 `<0.1.2`,pnpm 于是给它装了自带的旧版 dsh-tool-web)。详见 `plugin-compat.ts` 头注。
*/
declare module 'semver' {
/** 版本是否落在范围内(默认语义,与 npm 一致)。 */
export function satisfies(version: string, range: string): boolean
}
+153
View File
@@ -0,0 +1,153 @@
/**
* 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'
import { dirname, join } from 'node:path'
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)
const resolveApiKey = async (_userId: string): Promise<string | null> => {
// 统一 KEY 模式:所有用户共用管理员(admin)设置的启用 key,用户不可自配。
// 忽略入参 userId——不管哪个用户 spawn,都注入同一把管理员 key。
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
try {
return decrypt(ref, encryptionKey)
} catch {
return null // corrupt ref — treat as unset, let the admin re-enter it
}
}
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
}