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/.
This commit is contained in:
commit
aa5e262f3a
880 changed files with 111042 additions and 0 deletions
99
apps/media-worker/src/supervisor/sandbox.ts
Normal file
99
apps/media-worker/src/supervisor/sandbox.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { createHash } from "node:crypto";
|
||||
import type * as ChildProcessApi from "node:child_process";
|
||||
import { chmodSync, mkdirSync, constants, openSync, closeSync, fchmodSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { Transform } from "node:stream";
|
||||
import type { DesiredChannel } from "../types";
|
||||
import type { RelayJob } from "./index";
|
||||
|
||||
const childProcess: { spawn: typeof ChildProcessApi.spawn } = require("node:child_process");
|
||||
|
||||
// Resolve the CommonJS package from either src/supervisor or dist/supervisor.
|
||||
// A small structural boundary keeps worker typechecking independent of package build order.
|
||||
interface MediaCommand { binary: "ffmpeg" | "ffprobe"; argv: readonly string[] }
|
||||
interface SandboxModule {
|
||||
MediaCommandBuilder: new () => { supervisor(binary: MediaCommand["binary"], argv: readonly string[], scratch: string): MediaCommand };
|
||||
SandboxUnavailable: new () => Error;
|
||||
spawnSandbox(command: MediaCommand, options: { rootfs: string; cgroupRoot: string; scratchDir: string; limits: { wallTimeMs: number } }, spawnOptions: ChildProcessApi.SpawnOptions | undefined, spawner: typeof ChildProcessApi.spawn): { child: ChildProcessApi.ChildProcess };
|
||||
}
|
||||
const sandbox = (): SandboxModule => require(resolve(__dirname, "../../../../packages/media-sandbox/dist")) as SandboxModule;
|
||||
interface Context { scratch: string }
|
||||
const context = new AsyncLocalStorage<Context>();
|
||||
let installed = false;
|
||||
|
||||
/**
|
||||
* The only common spawn path lives in relay/, outside this change's boundary.
|
||||
* A scoped Node spawn adapter covers existing typed FFmpeg builders, including
|
||||
* asynchronous swaps/playlist producers, without altering their process API.
|
||||
* Non-media children and calls outside a supervisor job retain normal spawning.
|
||||
*/
|
||||
function installAdapter(): void {
|
||||
if (installed) return;
|
||||
const original = childProcess.spawn;
|
||||
childProcess.spawn = ((command: string, args?: readonly string[] | ChildProcessApi.SpawnOptions, options?: ChildProcessApi.SpawnOptions) => {
|
||||
const job = context.getStore();
|
||||
if (!job || !/(?:^|\/)(?:ffmpeg|ffprobe)$/.test(command)) {
|
||||
return typeof args === "object" && !Array.isArray(args)
|
||||
? original(command, args as ChildProcessApi.SpawnOptions)
|
||||
: original(command, (args as readonly string[] | undefined) ?? [], options ?? {});
|
||||
}
|
||||
if (!Array.isArray(args)) throw new Error("media argv required");
|
||||
const runtime = sandbox();
|
||||
const rootfs = process.env.MEDIA_SANDBOX_ROOTFS;
|
||||
const cgroupRoot = process.env.MEDIA_SANDBOX_CGROUP_ROOT;
|
||||
if (!rootfs || !cgroupRoot) throw new runtime.SandboxUnavailable();
|
||||
const media = new runtime.MediaCommandBuilder().supervisor(command.endsWith("ffprobe") ? "ffprobe" : "ffmpeg", args, job.scratch);
|
||||
// Make only generated output directories writable by the mapped UID.
|
||||
// Traverse through directory fds: a compromised sibling cannot race a
|
||||
// symlink replacement into chmod of a host file.
|
||||
for (const path of media.argv.filter(value => value.startsWith("file:/scratch/"))) {
|
||||
const parts = path.slice("file:/scratch/".length).split("/").slice(0, -1).filter(part => part && part !== ".");
|
||||
if (parts.length > 32 || parts.some(part => part === "..")) throw new runtime.SandboxUnavailable();
|
||||
let fd = openSync(job.scratch, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
try {
|
||||
fchmodSync(fd, 0o777);
|
||||
for (const part of parts) {
|
||||
const next = openSync(`/proc/self/fd/${fd}/${part}`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
||||
closeSync(fd); fd = next; fchmodSync(fd, 0o777);
|
||||
}
|
||||
} finally { closeSync(fd); }
|
||||
}
|
||||
const child = runtime.spawnSandbox(media, {
|
||||
rootfs, cgroupRoot, scratchDir: job.scratch,
|
||||
// Long-running channels still have a finite job lease; uploads use the package defaults.
|
||||
limits: { wallTimeMs: Number(process.env.MEDIA_SANDBOX_WALL_TIME_MS ?? 86_400_000) },
|
||||
}, options, original).child;
|
||||
// Replace the public stderr stream BEFORE spawnManaged attaches its logger.
|
||||
// Arbitrary media bytes cannot bypass a caller-supplied passthrough redactor.
|
||||
if (child.stderr) {
|
||||
const safe = new Transform({ transform(_chunk, _encoding, done) { done(null, Buffer.from("[media stderr suppressed]\n")); } });
|
||||
child.stderr.pipe(safe); child.stderr = safe; child.stdio[2] = safe;
|
||||
}
|
||||
return child;
|
||||
}) as typeof childProcess.spawn;
|
||||
installed = true;
|
||||
}
|
||||
function enabled(): boolean {
|
||||
const flag = process.env.MEDIA_SANDBOX;
|
||||
if (flag !== undefined && flag !== "on" && flag !== "off") throw new Error("MEDIA_SANDBOX must be on or off");
|
||||
if (process.env.NODE_ENV === "production" && flag === "off") throw new Error("production requires media sandbox");
|
||||
return flag === "on" || process.env.NODE_ENV === "production";
|
||||
}
|
||||
export function supervisorSandboxEnabled(): boolean { return enabled(); }
|
||||
|
||||
export function sandboxRelay(job: RelayJob, channel: DesiredChannel): RelayJob {
|
||||
if (!enabled()) return job;
|
||||
installAdapter();
|
||||
const id = createHash("sha256").update(`${channel.workspaceId}:${channel.channelId}`).digest("hex").slice(0, 32);
|
||||
const parent = resolve(process.env.MEDIA_SCRATCH_DIR ?? ".media-worker-state/scratch");
|
||||
const scratch = resolve(parent, id);
|
||||
mkdirSync(parent, { recursive: true }); chmodSync(parent, 0o700);
|
||||
mkdirSync(scratch, { recursive: true }); chmodSync(scratch, 0o777);
|
||||
const run = <T>(action: () => T): T => context.run({ scratch }, action);
|
||||
return {
|
||||
start: config => run(() => job.start(config)),
|
||||
stop: () => run(() => job.stop()),
|
||||
...(job.reconfigure ? { reconfigure: (config: DesiredChannel) => run(() => job.reconfigure!(config)) } : {}),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue