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/.
172 lines
10 KiB
TypeScript
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();
|
|
}
|
|
}
|