T-HUB/packages/media-sandbox/src/runtime.ts
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

172 lines
10 KiB
TypeScript

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<SandboxLimits>;
/** 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<number>(); 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();
}
}