初始提交:DSH 多租户平台(dshs)
This commit is contained in:
commit
43976fea6a
167 files changed
+24456
No files matched your search
+251
@@ -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
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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`
|
||||
}
|
||||
@@ -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())
|
||||
})()
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -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('; ')
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 首位 → 这里装的东西对全部用户立即生效,用户无法自行安装或修改。',
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Vendored
+15
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user