multistreaming: scenes/composition, installer build support, service updates

- multistreaming (new): RTMP ingest + multi-platform fan-out with pluggable providers (Twitch/YouTube/Kick/custom), zero-knowledge key vaults, Authelia OIDC auth, shared rooms with editor/streamer roles, single-use invites, per-account streaming grants, and scenes & composition (grid/PiP layouts, text/image overlays, per-output audio routing).
- installer: support Dockerfile build in metadata (not just image) and RSA key generation for the Authelia OIDC JWKS.
- authelia: add OIDC provider with portainer + multistreaming clients (public + PKCE).
- services: remove allprox; add nginx-proxy-manager and portainer; update lldap; regenerate catalog.
This commit is contained in:
Ezequiel C. 2026-09-02 21:18:25 +02:00
parent bb754cdd8c
commit 187379de4e
106 changed files with 21391 additions and 286 deletions

View file

@ -1,10 +1,11 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { ActionResult, InstalledService, ServiceMetadata } from "./types.ts";
import type { Config } from "./config.ts";
import { fetchMetadata, fetchServiceFiles, listServices, type ServiceFile } from "./github.ts";
import { generateComposeFile } from "./compose.ts";
import { generateComposeFile, usesBuild } from "./compose.ts";
import { collectEnvVars, envFileContent, readEnvFile } from "./env.ts";
import { generateRsaPrivateKey } from "./keys.ts";
import { isDockerAvailable, runCommand, runDockerCompose, type RunResult } from "./docker.ts";
import { loadState, markInstalled, removeInstalled } from "./state.ts";
import { serviceDir } from "./paths.ts";
@ -68,10 +69,11 @@ export async function installService(
writeFileSync(join(dir, "docker-compose.yml"), composeContent, "utf8");
writeFileSync(join(dir, ".env"), envFileContent(vars), "utf8");
materializeFiles(dir, await fetchServiceFiles(meta.id, cfg), vars);
materializeRsaKeys(dir, meta);
await requireDocker();
await ensureExternalNetworks(meta, cfg);
const r = await runDockerCompose(dir, ["up", "-d"], !cfg.verbose);
const r = await bringUp(dir, meta, cfg);
if (r.code !== 0) {
printDockerFailure(r);
throw new Error(`docker compose up failed for ${meta.id}`);
@ -101,15 +103,11 @@ export async function updateService(
writeFileSync(join(dir, "docker-compose.yml"), composeContent, "utf8");
writeFileSync(join(dir, ".env"), envFileContent(vars), "utf8");
materializeFiles(dir, await fetchServiceFiles(meta.id, cfg), vars);
materializeRsaKeys(dir, meta);
await requireDocker();
await ensureExternalNetworks(meta, cfg);
const pull = await runDockerCompose(dir, ["pull"], !cfg.verbose);
if (pull.code !== 0) {
printDockerFailure(pull);
throw new Error(`docker compose pull failed for ${meta.id}`);
}
const up = await runDockerCompose(dir, ["up", "-d"], !cfg.verbose);
const up = await bringUp(dir, meta, cfg, /* update */ true);
if (up.code !== 0) {
printDockerFailure(up);
throw new Error(`docker compose up failed for ${meta.id}`);
@ -212,6 +210,51 @@ function materializeFiles(dir: string, files: ServiceFile[], vars: Record<string
}
}
/**
* Generate any declared RSA private keys (see RsaKeySpec) into the service dir.
* Keys are only created if absent, so re-running an update never rotates the
* OIDC signing key and invalidates existing sessions.
*/
function materializeRsaKeys(dir: string, meta: ServiceMetadata): void {
for (const spec of meta.rsaKeys ?? []) {
const target = join(dir, spec.path);
if (existsSync(target)) continue;
mkdirSync(dirname(target), { recursive: true });
writeFileSync(target, generateRsaPrivateKey(spec.bits ?? 2048), { encoding: "utf8", mode: 0o600 });
}
}
/**
* Bring a service up. For Dockerfile-based services this builds the image
* first (and re-pulls base images on update); for image-based services it
* pulls the tag (on update) before starting the containers.
*/
async function bringUp(
dir: string,
meta: ServiceMetadata,
cfg: Config,
update = false,
): Promise<RunResult> {
const capture = !cfg.verbose;
if (usesBuild(meta)) {
const buildArgs = update ? ["build", "--pull"] : ["build"];
const build = await runDockerCompose(dir, buildArgs, capture);
if (build.code !== 0) {
printDockerFailure(build);
throw new Error(`docker compose build failed for ${meta.id}`);
}
} else if (update) {
const pull = await runDockerCompose(dir, ["pull"], capture);
if (pull.code !== 0) {
printDockerFailure(pull);
throw new Error(`docker compose pull failed for ${meta.id}`);
}
}
return runDockerCompose(dir, ["up", "-d"], capture);
}
async function ensureExternalNetworks(meta: ServiceMetadata, cfg: Config): Promise<void> {
if (cfg.dryRun) return;
const nets = meta.networks ?? {};

View file

@ -1,6 +1,11 @@
import { stringify } from "yaml";
import type { ServiceMetadata } from "./types.ts";
/** True when the service builds its image from a Dockerfile rather than pulling one. */
export function usesBuild(meta: ServiceMetadata): boolean {
return Boolean(meta.compose && (meta.compose as Record<string, unknown>).build);
}
/** Build the docker-compose.yml content for a service from its metadata. */
export function generateComposeFile(meta: ServiceMetadata): string {
const doc: Record<string, unknown> = {};

View file

@ -160,6 +160,13 @@ function walk(base: string, rel: string, out: ServiceFile[]): void {
return;
}
for (const e of entries) {
// Never copy dependency dirs, build output, or VCS metadata into the deploy/build context.
if (
e.isDirectory() &&
(e.name === "node_modules" || e.name === "dist" || e.name === ".git")
) {
continue
}
const relPath = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) {
walk(base, relPath, out);

11
installer/src/keys.ts Normal file
View file

@ -0,0 +1,11 @@
import { generateKeyPairSync } from "node:crypto";
/** Generate an RSA private key in PKCS#8 PEM (used by Authelia to sign OIDC tokens). */
export function generateRsaPrivateKey(bits = 2048): string {
const { privateKey } = generateKeyPairSync("rsa", {
modulusLength: bits,
privateKeyEncoding: { type: "pkcs8", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
return privateKey;
}

View file

@ -43,6 +43,11 @@ export function validateMetadata(meta: ServiceMetadata, fallbackId: string): Val
}
if (!meta.compose || typeof meta.compose !== "object" || Array.isArray(meta.compose)) {
issues.push({ path: "compose", message: "required object (docker-compose service spec)" });
} else if (!meta.compose.image && !meta.compose.build) {
issues.push({
path: "compose",
message: "must define `image` (pull an image) or `build` (build from a Dockerfile)",
});
}
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
issues.push({ path: "id", message: "must be kebab-case and match the folder name" });

View file

@ -9,6 +9,13 @@ export interface EnvSpec {
generate?: boolean;
}
/** An RSA private key the installer generates into a file on first install (used for Authelia OIDC signing). */
export interface RsaKeySpec {
name: string;
path: string;
bits?: number;
}
export interface ServiceMetadata {
id: string;
name: string;
@ -25,6 +32,7 @@ export interface ServiceMetadata {
volumes?: Record<string, unknown>;
networks?: Record<string, unknown>;
env?: EnvSpec[];
rsaKeys?: RsaKeySpec[];
dependsOn?: string[];
notes?: string;
}