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
39
apps/media-worker/src/relay/endpoints.ts
Normal file
39
apps/media-worker/src/relay/endpoints.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { BlockList, isIP } from "node:net";
|
||||
|
||||
export type EndpointUse = "hls" | "output";
|
||||
export interface ValidatedEndpoint { url: URL; pinnedAddress: string }
|
||||
|
||||
/** WP7 supplies DNS pinning, redirect and HLS child-URI validation. */
|
||||
export interface EndpointValidator {
|
||||
validate(raw: string, use: EndpointUse): Promise<ValidatedEndpoint>;
|
||||
}
|
||||
|
||||
const blocked = new BlockList();
|
||||
for (const [subnet, prefix] of [
|
||||
["0.0.0.0", 8], ["10.0.0.0", 8], ["100.64.0.0", 10], ["127.0.0.0", 8],
|
||||
["169.254.0.0", 16], ["172.16.0.0", 12], ["192.0.0.0", 24], ["192.168.0.0", 16],
|
||||
["192.0.2.0", 24], ["198.18.0.0", 15], ["198.51.100.0", 24],
|
||||
["203.0.113.0", 24], ["224.0.0.0", 4], ["240.0.0.0", 4],
|
||||
] as const) blocked.addSubnet(subnet, prefix, "ipv4");
|
||||
for (const [subnet, prefix] of [
|
||||
["::", 128], ["::1", 128], ["fc00::", 7], ["fe80::", 10], ["ff00::", 8],
|
||||
["2001::", 32], ["2001:db8::", 32], ["2002::", 16], ["64:ff9b::", 96],
|
||||
] as const) blocked.addSubnet(subnet, prefix, "ipv6");
|
||||
|
||||
export function assertPublicAddress(address: string): void {
|
||||
const normalized = address.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
const family = isIP(normalized);
|
||||
if (family === 0) throw new Error("unresolved endpoint address");
|
||||
// Node's BlockList normalizes IPv4 to mapped IPv6 internally; reject mapped literals outright.
|
||||
if (normalized.startsWith("::ffff:")) throw new Error("mapped endpoint rejected");
|
||||
if (blocked.check(normalized, family === 4 ? "ipv4" : "ipv6")) throw new Error("private endpoint rejected");
|
||||
}
|
||||
|
||||
export function validateEndpointShape(raw: string, use: EndpointUse): URL {
|
||||
let url: URL;
|
||||
try { url = new URL(raw); } catch { throw new Error("invalid endpoint URL"); }
|
||||
const schemes = use === "hls" ? ["https:"] : ["rtmp:", "rtmps:"];
|
||||
if (!schemes.includes(url.protocol) || !url.hostname || url.username || url.password || url.search || url.hash) throw new Error("endpoint scheme or credentials rejected");
|
||||
if (isIP(url.hostname.replace(/^\[|\]$/g, ""))) assertPublicAddress(url.hostname);
|
||||
return url;
|
||||
}
|
||||
122
apps/media-worker/src/relay/ffmpeg.ts
Normal file
122
apps/media-worker/src/relay/ffmpeg.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { spawn, type ChildProcess, type StdioOptions } from "node:child_process";
|
||||
import { OUTPUT_PROFILES, type DesiredChannel } from "../types";
|
||||
import { assertPublicAddress, validateEndpointShape, type EndpointValidator } from "./endpoints";
|
||||
|
||||
/** The vault broker delivers a key directly to the push relay's inherited fd. */
|
||||
export interface VaultHandleBroker {
|
||||
deliverPushTarget(handleId: string, context: { workspaceId: string; channelId: string; assignmentEpoch: number }, relayFd: number): Promise<void>;
|
||||
}
|
||||
export interface LocalRelayPipes { ingestFd: number; pushFd: number; close(): Promise<void> }
|
||||
/** WP7 implementation fetches HLS through pinned egress and pushes RTMP with the vault key. */
|
||||
export interface EgressRelayFactory {
|
||||
prepare(config: DesiredChannel, validator: EndpointValidator, vault: VaultHandleBroker): Promise<LocalRelayPipes>;
|
||||
}
|
||||
export interface StderrRedactor { redact(chunk: string): string }
|
||||
|
||||
/** Media children get a minimal environment: no worker secrets, no inherited config. */
|
||||
export function mediaEnv(): NodeJS.ProcessEnv { return { PATH: process.env.PATH ?? "/usr/bin:/bin" }; }
|
||||
|
||||
export interface ManagedProcess {
|
||||
readonly child: ChildProcess;
|
||||
/** Resolves on spawn, rejects if the binary cannot start. */
|
||||
readonly started: Promise<void>;
|
||||
/** Resolves once the process has exited (never rejects). */
|
||||
readonly exited: Promise<{ code: number | null; signal: NodeJS.Signals | null }>;
|
||||
kill(signal?: NodeJS.Signals): void;
|
||||
/** SIGTERM, then SIGKILL after `graceMs`; resolves on exit. */
|
||||
terminate(graceMs?: number): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared spawn path for every media child (relay, ingest, compositor, encoder,
|
||||
* frame gate): argv only, never a shell; minimal env; stderr is redacted and
|
||||
* truncated before it reaches the logger.
|
||||
*/
|
||||
export function spawnManaged(
|
||||
command: string, args: readonly string[], stdio: StdioOptions,
|
||||
redactor: StderrRedactor, onStderr: (message: string) => void, cwd?: string,
|
||||
): ManagedProcess {
|
||||
const child = spawn(command, args, { stdio, shell: false, env: mediaEnv(), ...(cwd === undefined ? {} : { cwd }) });
|
||||
child.stderr?.on("data", (chunk: Buffer) => onStderr(redactor.redact(chunk.toString("utf8").slice(0, 4096))));
|
||||
const started = new Promise<void>((resolve, reject) => {
|
||||
child.once("spawn", () => resolve());
|
||||
child.once("error", reject);
|
||||
});
|
||||
started.catch(() => undefined);
|
||||
const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
|
||||
child.once("exit", (code, signal) => resolve({ code, signal }));
|
||||
child.once("error", () => { if (child.pid === undefined) resolve({ code: null, signal: null }); });
|
||||
});
|
||||
const kill = (signal: NodeJS.Signals = "SIGTERM"): void => { if (child.exitCode === null && child.signalCode === null) child.kill(signal); };
|
||||
return {
|
||||
child, started, exited, kill,
|
||||
async terminate(graceMs = 2_000) {
|
||||
kill("SIGTERM");
|
||||
const timer = setTimeout(() => kill("SIGKILL"), graceMs);
|
||||
await exited;
|
||||
clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function ffmpegArgs(config: DesiredChannel): string[] {
|
||||
const args = ["-hide_banner", "-nostdin", "-loglevel", "warning", "-f", "mpegts", "-i", "pipe:3"];
|
||||
if (config.mode === "passthrough") args.push("-c", "copy");
|
||||
else {
|
||||
const profile = OUTPUT_PROFILES[config.profile];
|
||||
args.push("-vf", `scale=${profile.width}:${profile.height},fps=${profile.fps}`,
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-b:v", profile.videoBitrate,
|
||||
"-c:a", "aac", "-b:a", "128k");
|
||||
}
|
||||
args.push("-f", "flv", "pipe:4");
|
||||
return args;
|
||||
}
|
||||
|
||||
export class FfmpegRelayJob {
|
||||
private child: ChildProcess | null = null;
|
||||
private pipes: LocalRelayPipes | null = null;
|
||||
private stopping = false;
|
||||
constructor(
|
||||
private readonly validator: EndpointValidator,
|
||||
private readonly egress: EgressRelayFactory,
|
||||
private readonly vault: VaultHandleBroker,
|
||||
private readonly redactor: StderrRedactor,
|
||||
private readonly onStderr: (message: string) => void,
|
||||
private readonly onExit: (reason: string) => void,
|
||||
) {}
|
||||
|
||||
async start(config: DesiredChannel): Promise<void> {
|
||||
if (!config.source || !config.output) throw new Error("running channel needs source and output");
|
||||
validateEndpointShape(config.source.url, "hls");
|
||||
validateEndpointShape(config.output.endpointUrl, "output");
|
||||
const source = await this.validator.validate(config.source.url, "hls");
|
||||
const output = await this.validator.validate(config.output.endpointUrl, "output");
|
||||
assertPublicAddress(source.pinnedAddress);
|
||||
assertPublicAddress(output.pinnedAddress);
|
||||
this.pipes = await this.egress.prepare(config, this.validator, this.vault);
|
||||
this.stopping = false;
|
||||
const managed = spawnManaged("ffmpeg", ffmpegArgs(config),
|
||||
["ignore", "ignore", "pipe", this.pipes.ingestFd, this.pipes.pushFd], this.redactor, this.onStderr);
|
||||
const child = managed.child;
|
||||
this.child = child;
|
||||
let spawned = false;
|
||||
void managed.started.then(() => { spawned = true; }, () => undefined);
|
||||
child.on("error", (error) => { if (spawned) this.onExit(this.redactor.redact(error.message)); });
|
||||
const started = managed.started;
|
||||
child.once("exit", (code, signal) => {
|
||||
void this.pipes?.close();
|
||||
this.pipes = null;
|
||||
this.child = null;
|
||||
if (!this.stopping) this.onExit(`ffmpeg exited code=${code} signal=${signal}`);
|
||||
});
|
||||
await started;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopping = true;
|
||||
this.child?.kill("SIGTERM");
|
||||
await this.pipes?.close();
|
||||
this.pipes = null;
|
||||
this.child = null;
|
||||
}
|
||||
}
|
||||
56
apps/media-worker/src/relay/relay.test.ts
Normal file
56
apps/media-worker/src/relay/relay.test.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { assertPublicAddress, validateEndpointShape } from "./endpoints";
|
||||
import { ffmpegArgs } from "./ffmpeg";
|
||||
import type { DesiredChannel } from "../types";
|
||||
|
||||
const channel: DesiredChannel = {
|
||||
workspaceId: "workspace-1", channelId: "channel-1", workerId: "worker-1",
|
||||
assignmentEpoch: 1, desiredVersion: 1, desiredStatus: "RUNNING",
|
||||
source: { kind: "hls", url: "https://mock.local/hls/live-1/master.m3u8" },
|
||||
output: { endpointUrl: "rtmp://mock.local/live", credentialHandleId: "vault-handle" },
|
||||
mode: "passthrough", profile: "720p30",
|
||||
};
|
||||
|
||||
test("relay argv uses only local inherited pipes and mode profile", () => {
|
||||
const passthrough = ffmpegArgs(channel);
|
||||
assert.deepEqual(passthrough.slice(-3), ["-f", "flv", "pipe:4"]);
|
||||
assert.ok(passthrough.includes("copy"));
|
||||
assert.ok(!passthrough.join(" ").includes("mock.local"));
|
||||
assert.ok(!passthrough.join(" ").includes("vault-handle"));
|
||||
const normalized = ffmpegArgs({ ...channel, mode: "normalize", profile: "1080p60" });
|
||||
assert.ok(normalized.includes("scale=1920:1080,fps=60"));
|
||||
});
|
||||
|
||||
test("endpoint shape and literal/private pin denial", () => {
|
||||
assert.equal(validateEndpointShape(channel.source?.url ?? "", "hls").protocol, "https:");
|
||||
assert.throws(() => validateEndpointShape("file:///etc/passwd", "hls"));
|
||||
assert.throws(() => validateEndpointShape("https://127.0.0.1/live.m3u8", "hls"));
|
||||
assert.throws(() => validateEndpointShape("rtmp://169.254.169.254/live", "output"));
|
||||
assert.throws(() => validateEndpointShape("rtmp://user:secret@example.com/live", "output"));
|
||||
for (const address of ["10.0.0.1", "172.20.1.2", "192.168.1.1", "100.64.0.1", "::1", "fc00::1", "::ffff:127.0.0.1", "::ffff:7f00:1"]) {
|
||||
assert.throws(() => assertPublicAddress(address), address);
|
||||
}
|
||||
assert.doesNotThrow(() => assertPublicAddress("8.8.8.8"));
|
||||
});
|
||||
|
||||
test("mock livestream source and single-live simulator fixture feed the relay contract", async () => {
|
||||
const mockPath = pathToFileURL(resolve(__dirname, "../../../../packages/connector-mock-livestream/src/index.ts")).href;
|
||||
const fixturePath = pathToFileURL(resolve(__dirname, "../../../../packages/testing/src/scenarios.ts")).href;
|
||||
const mockModule: unknown = await import(mockPath);
|
||||
const fixtures: unknown = await import(fixturePath);
|
||||
const mock = mockModule as { createMockLivestreamConnector(): {
|
||||
createLiveStream(connection: null, request: { channelId: string; title: string; permanent: boolean; saveReplay: boolean }): Promise<{ remoteId: string }>;
|
||||
getStreamPlaybackSources(connection: null, request: { remoteId: string }): Promise<Array<{ url: string; kind: string }>>;
|
||||
} };
|
||||
const scenarios = fixtures as { SCENARIOS: Record<string, { steps: Array<{ source: string; action: string }> }> };
|
||||
assert.deepEqual(scenarios.SCENARIOS["single-live"]?.steps.map((step) => [step.source, step.action]), [["A", "go-live"]]);
|
||||
const connector = mock.createMockLivestreamConnector();
|
||||
const live = await connector.createLiveStream(null, { channelId: "A", title: "test", permanent: true, saveReplay: false });
|
||||
const sources = await connector.getStreamPlaybackSources(null, { remoteId: live.remoteId });
|
||||
assert.equal(sources[0]?.kind, "hls");
|
||||
assert.equal(validateEndpointShape(sources[0]?.url ?? "", "hls").hostname, "mock.local");
|
||||
assert.ok(!ffmpegArgs({ ...channel, source: { kind: "hls", url: sources[0]?.url ?? "" } }).join(" ").includes("mock.local"));
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue