import { spawn, spawnSync, type ChildProcess, type SpawnOptions } from "node:child_process"; import { constants, closeSync, fstatSync, openSync, readSync, writeSync, chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, rmdirSync, statfsSync, writeFileSync } from "node:fs"; import { randomUUID } from "node:crypto"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { assertAssetId, assertMediaCommand, type MediaCommand } from "./command"; import { limitsWith, sandboxEnvironment, type SandboxLimits } from "./profile"; export interface SandboxOptions { /** Dedicated, operator-built minimal FFmpeg rootfs; never the host /. */ rootfs: string; /** Empty cgroup v2 subtree delegated to this worker with cpu/memory/pids enabled. */ cgroupRoot: string; helperPath?: string; scratchDir?: string; limits?: Partial; /** Server asset resolver; called only after validating the minted id. */ asset?: { id: string; resolve(id: string): string }; } export class SandboxUnavailable extends Error { constructor() { super("media sandbox unavailable: requires mapped unprivileged UID/userns, mount/net/pid namespaces, trusted rootfs and delegated cgroup v2"); this.name = "SandboxUnavailable"; } } export interface SandboxChild { child: ChildProcess; exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>; kill(signal?: NodeJS.Signals): void; } const activeUids = new Set(); let nextUid = 1000; const uidLeases = join(tmpdir(), `media-sandbox-uids-${process.getuid?.() ?? "unsupported"}`); function uid(): number { try { mkdirSync(uidLeases, { mode: 0o700 }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw new SandboxUnavailable(); } const stat = lstatSync(uidLeases); if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0) throw new SandboxUnavailable(); for (let n = 0; n <= 59000; n++) { const v = nextUid; nextUid = nextUid >= 60000 ? 1000 : nextUid + 1; if (activeUids.has(v)) continue; try { mkdirSync(join(uidLeases, String(v)), { mode: 0o700 }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") continue; throw new SandboxUnavailable(); } activeUids.add(v); return v; } throw new SandboxUnavailable(); } function releaseUid(value: number): void { try { rmdirSync(join(uidLeases, String(value))); } catch { /* stale leases remain reserved rather than risk UID reuse */ } activeUids.delete(value); } export function helperPath(): string { return resolve(__dirname, "../dist/media-confine"); } function checkedRootfs(path: string): string { const root = realpathSync(path); if (root === "/" || root === "/usr" || root === "/etc") throw new SandboxUnavailable(); for (const dir of ["upload", "scratch", "tmp", "proc", "dev"]) { if (!lstatSync(join(root, dir)).isDirectory() || lstatSync(join(root, dir)).isSymbolicLink()) throw new SandboxUnavailable(); } if (!lstatSync(join(root, "dev/null")).isFile()) throw new SandboxUnavailable(); // A recursive bind with writable nested mounts would undermine the read-only root. if (readFileSync("/proc/self/mountinfo", "utf8").split("\n").some(line => { const mount = line.split(" ")[4]?.replace(/\\040/g, " "); return mount?.startsWith(`${root}/`); })) throw new SandboxUnavailable(); return root; } function createCgroup(root: string, limits: SandboxLimits): string { const base = realpathSync(root); if (statfsSync(base).type !== 0x63677270 || !existsSync(join(base, "cgroup.controllers"))) throw new SandboxUnavailable(); const path = join(base, `media-${randomUUID()}`); mkdirSync(path); try { writeFileSync(join(path, "cpu.max"), `${limits.cpuQuotaUs} ${limits.cpuPeriodUs}`); writeFileSync(join(path, "memory.max"), String(limits.memoryBytes)); writeFileSync(join(path, "memory.swap.max"), "0"); writeFileSync(join(path, "memory.oom.group"), "1"); writeFileSync(join(path, "pids.max"), String(limits.pids)); if (!existsSync(join(path, "cgroup.kill"))) throw new SandboxUnavailable(); return path; } catch { rmdirSync(path); throw new SandboxUnavailable(); } } function confinementArgs(helper: string, group: string, root: string, rootfs: string, uploads: string, scratch: string, jobUid: number, limits: SandboxLimits, executable: string, argv: readonly string[]): string[] { return ["--join", join(group, "cgroup.procs"), "/usr/bin/unshare", "--user", "--map-auto", "--map-user=0", "--setgroups=allow", "--mount", "--net", "--pid", "--fork", "--kill-child", helper, "--confine", root, rootfs, uploads, scratch, String(jobUid), String(limits.fileSizeBytes), String(Math.ceil(limits.wallTimeMs / 1000)), executable, ...argv]; } export function sandboxCapability(options: SandboxOptions): { available: boolean; reason?: string } { let group: string | undefined; let work: string | undefined; let detail = ""; let jobUid: number | undefined; try { jobUid = uid(); const rootfs = checkedRootfs(options.rootfs); const limits = limitsWith(options.limits); const helper = options.helperPath ?? helperPath(); if (!existsSync(helper)) throw new SandboxUnavailable(); work = mkdtempSync(join(tmpdir(), "media-sandbox-check-")); const root = join(work, "root"), uploads = join(work, "uploads"), scratch = join(work, "scratch"); for (const dir of [root, uploads, scratch]) mkdirSync(dir); chmodSync(scratch, 0o777); group = createCgroup(options.cgroupRoot, limits); const result = spawnSync(helper, confinementArgs(helper, group, root, rootfs, uploads, scratch, jobUid, limits, "--check", []), { env: sandboxEnvironment(), timeout: 5000 }); if (result.status !== 0) { if (String(result.stderr).includes("newuidmap")) detail = " (UID mapping helper failed)"; throw new SandboxUnavailable(); } return { available: true }; } catch { return { available: false, reason: new SandboxUnavailable().message + detail }; } finally { if (group) { try { writeFileSync(join(group, "cgroup.kill"), "1"); rmdirSync(group); } catch { /* timed-out setup */ } } if (work) rmSync(work, { recursive: true, force: true }); if (jobUid !== undefined) releaseUid(jobUid); } } /** Synchronous construction keeps existing spawnManaged lifecycle/stdio semantics. */ export function spawnSandbox(command: MediaCommand, options: SandboxOptions, spawnOptions: SpawnOptions = {}, spawner: typeof spawn = spawn): SandboxChild { assertMediaCommand(command); const limits = limitsWith(options.limits); const jobUid = uid(); let work: string | undefined; let group: string | undefined; try { const rootfs = checkedRootfs(options.rootfs); work = mkdtempSync(join(tmpdir(), "media-sandbox-")); chmodSync(work, 0o700); const root = join(work, "root"); const uploads = join(work, "uploads"); const scratch = options.scratchDir ? realpathSync(options.scratchDir) : join(work, "scratch"); mkdirSync(root); mkdirSync(uploads, { mode: 0o755 }); if (!options.scratchDir) { mkdirSync(scratch); chmodSync(scratch, 0o777); } else { const parent = lstatSync(dirname(scratch)); if (scratch === "/" || !lstatSync(scratch).isDirectory() || parent.uid !== process.getuid?.() || (parent.mode & 0o077) !== 0) throw new SandboxUnavailable(); } if (options.asset) { assertAssetId(options.asset.id); const source = options.asset.resolve(options.asset.id); const input = openSync(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); let dest: number | undefined; try { const stat = fstatSync(input); if (!stat.isFile() || stat.size > limits.fileSizeBytes) throw new Error("upload denied"); const path = join(uploads, options.asset.id); dest = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600); const buffer = Buffer.alloc(64 * 1024); let total = 0; let count: number; while ((count = readSync(input, buffer, 0, buffer.length, null)) > 0) { total += count; if (total > limits.fileSizeBytes) throw new Error("upload denied"); let written = 0; while (written < count) written += writeSync(dest, buffer, written, count - written); } chmodSync(path, 0o444); } finally { closeSync(input); if (dest !== undefined) closeSync(dest); } } group = createCgroup(options.cgroupRoot, limits); const helper = options.helperPath ?? helperPath(); const args = confinementArgs(helper, group, root, rootfs, uploads, scratch, jobUid, limits, `/usr/bin/${command.binary}`, command.argv); const child = spawner(helper, args, { ...spawnOptions, cwd: undefined, shell: false, detached: true, env: sandboxEnvironment() }); const groupPath = group; const workPath = work; const kill = (signal: NodeJS.Signals = "SIGKILL"): void => { if (child.exitCode !== null || child.signalCode !== null) return; if (signal === "SIGKILL") { try { writeFileSync(join(groupPath, "cgroup.kill"), "1"); } catch { /* group may already be empty */ } } if (child.pid) { try { process.kill(-child.pid, signal); } catch { /* already exited */ } } }; const timer = setTimeout(() => kill(), limits.wallTimeMs); timer.unref(); let cleaned = false; const cleanup = (): void => { if (cleaned) return; cleaned = true; clearTimeout(timer); try { writeFileSync(join(groupPath, "cgroup.kill"), "1"); } catch { /* startup failure */ } try { rmdirSync(groupPath); } catch { /* transient kernel teardown; no siblings touched */ } rmSync(workPath, { force: true, recursive: true }); releaseUid(jobUid); }; const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>(resolveExit => { child.once("close", (code, signal) => { cleanup(); resolveExit({ code, signal }); }); }); child.once("error", () => { if (!child.pid) cleanup(); }); // Existing ManagedProcess.kill must also kill the wrapper's whole job. const nativeKill = child.kill.bind(child); child.kill = (signal?: NodeJS.Signals | number) => { if (typeof signal === "number") return nativeKill(signal); kill(signal ?? "SIGTERM"); return true; }; return { child, exited, kill }; } catch (error) { if (group) { try { rmdirSync(group); } catch { /* no child launched */ } } if (work) rmSync(work, { force: true, recursive: true }); releaseUid(jobUid); if (error instanceof Error && error.message === "upload denied") throw error; throw new SandboxUnavailable(); } }