From 187379de4ec8604e45f86e2bcdc6851350b6546e Mon Sep 17 00:00:00 2001 From: "Ezequiel C." Date: Wed, 2 Sep 2026 21:18:25 +0200 Subject: [PATCH] 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. --- .gitignore | 3 + README.md | 97 +- docs/metadata-schema.json | 16 +- installer/src/commands.ts | 61 +- installer/src/compose.ts | 5 + installer/src/github.ts | 7 + installer/src/keys.ts | 11 + installer/src/metadata.ts | 5 + installer/src/types.ts | 8 + services/README.md | 66 +- services/allprox/Caddyfile | 63 - services/allprox/metadata.json | 64 - services/allprox/portal/index.html | 80 - services/authelia/configuration.yml | 72 +- services/authelia/metadata.json | 34 +- services/catalog.json | 24 +- services/lldap/metadata.json | 2 +- services/multistreaming/.dockerignore | 13 + services/multistreaming/Dockerfile | 45 + services/multistreaming/README.md | 234 + services/multistreaming/SECURITY.md | 220 + services/multistreaming/docs/SCENES.md | 684 ++ services/multistreaming/metadata.json | 98 + services/multistreaming/package-lock.json | 1015 ++ services/multistreaming/package.json | 17 + services/multistreaming/src/auth.js | 100 + services/multistreaming/src/compose.js | 221 + services/multistreaming/src/config.js | 57 + services/multistreaming/src/fanout.js | 176 + services/multistreaming/src/grants.js | 59 + services/multistreaming/src/id.js | 22 + services/multistreaming/src/index.js | 190 + services/multistreaming/src/oidc.js | 82 + services/multistreaming/src/providers.js | 171 + services/multistreaming/src/server.js | 768 ++ services/multistreaming/src/store.js | 681 ++ services/multistreaming/src/vault.js | 148 + .../multistreaming/test/foundation.test.js | 151 + services/multistreaming/test/oidc.test.js | 21 + services/multistreaming/test/security.test.js | 75 + services/multistreaming/test/vault.test.js | 81 + services/multistreaming/web/.gitignore | 24 + services/multistreaming/web/.prettierignore | 7 + services/multistreaming/web/.prettierrc | 11 + services/multistreaming/web/README.md | 21 + services/multistreaming/web/components.json | 25 + services/multistreaming/web/eslint.config.js | 22 + services/multistreaming/web/index.html | 13 + services/multistreaming/web/package-lock.json | 8429 +++++++++++++++++ services/multistreaming/web/package.json | 46 + services/multistreaming/web/public/vite.svg | 1 + services/multistreaming/web/src/App.tsx | 265 + .../multistreaming/web/src/assets/react.svg | 1 + .../web/src/components/app-sidebar.tsx | 68 + .../web/src/components/auth-screen.tsx | 98 + .../web/src/components/dialogs.tsx | 515 + .../web/src/components/home-view.tsx | 167 + .../web/src/components/image-upload.tsx | 152 + .../web/src/components/nav-rooms.tsx | 47 + .../web/src/components/nav-user.tsx | 79 + .../web/src/components/nav-vaults.tsx | 44 + .../web/src/components/room-sidebar.tsx | 179 + .../web/src/components/room-view.tsx | 216 + .../web/src/components/scene-editor.tsx | 606 ++ .../web/src/components/scene-panel.tsx | 365 + .../web/src/components/theme-provider.tsx | 230 + .../web/src/components/ui/alert-dialog.tsx | 197 + .../web/src/components/ui/alert.tsx | 76 + .../web/src/components/ui/avatar.tsx | 110 + .../web/src/components/ui/badge.tsx | 49 + .../web/src/components/ui/breadcrumb.tsx | 122 + .../web/src/components/ui/button.tsx | 67 + .../web/src/components/ui/card.tsx | 103 + .../web/src/components/ui/collapsible.tsx | 31 + .../web/src/components/ui/dialog.tsx | 168 + .../web/src/components/ui/dropdown-menu.tsx | 269 + .../web/src/components/ui/empty.tsx | 104 + .../web/src/components/ui/field.tsx | 236 + .../web/src/components/ui/input-group.tsx | 156 + .../web/src/components/ui/input.tsx | 19 + .../web/src/components/ui/label.tsx | 24 + .../web/src/components/ui/select.tsx | 190 + .../web/src/components/ui/separator.tsx | 26 + .../web/src/components/ui/sheet.tsx | 147 + .../web/src/components/ui/sidebar.tsx | 700 ++ .../web/src/components/ui/skeleton.tsx | 13 + .../web/src/components/ui/sonner.tsx | 47 + .../web/src/components/ui/switch.tsx | 33 + .../web/src/components/ui/textarea.tsx | 18 + .../web/src/components/ui/toggle-group.tsx | 89 + .../web/src/components/ui/toggle.tsx | 45 + .../web/src/components/ui/tooltip.tsx | 57 + .../web/src/hooks/use-mobile.ts | 19 + services/multistreaming/web/src/index.css | 130 + services/multistreaming/web/src/lib/api.ts | 138 + services/multistreaming/web/src/lib/types.ts | 156 + .../multistreaming/web/src/lib/use-app.ts | 128 + services/multistreaming/web/src/lib/utils.ts | 6 + services/multistreaming/web/src/lib/vault.ts | 259 + services/multistreaming/web/src/main.tsx | 19 + services/multistreaming/web/tsconfig.app.json | 29 + services/multistreaming/web/tsconfig.json | 12 + .../multistreaming/web/tsconfig.node.json | 24 + services/multistreaming/web/vite.config.ts | 14 + services/nginx-proxy-manager/metadata.json | 35 + services/portainer/metadata.json | 34 + 106 files changed, 21391 insertions(+), 286 deletions(-) create mode 100644 installer/src/keys.ts delete mode 100644 services/allprox/Caddyfile delete mode 100644 services/allprox/metadata.json delete mode 100644 services/allprox/portal/index.html create mode 100644 services/multistreaming/.dockerignore create mode 100644 services/multistreaming/Dockerfile create mode 100644 services/multistreaming/README.md create mode 100644 services/multistreaming/SECURITY.md create mode 100644 services/multistreaming/docs/SCENES.md create mode 100644 services/multistreaming/metadata.json create mode 100644 services/multistreaming/package-lock.json create mode 100644 services/multistreaming/package.json create mode 100644 services/multistreaming/src/auth.js create mode 100644 services/multistreaming/src/compose.js create mode 100644 services/multistreaming/src/config.js create mode 100644 services/multistreaming/src/fanout.js create mode 100644 services/multistreaming/src/grants.js create mode 100644 services/multistreaming/src/id.js create mode 100644 services/multistreaming/src/index.js create mode 100644 services/multistreaming/src/oidc.js create mode 100644 services/multistreaming/src/providers.js create mode 100644 services/multistreaming/src/server.js create mode 100644 services/multistreaming/src/store.js create mode 100644 services/multistreaming/src/vault.js create mode 100644 services/multistreaming/test/foundation.test.js create mode 100644 services/multistreaming/test/oidc.test.js create mode 100644 services/multistreaming/test/security.test.js create mode 100644 services/multistreaming/test/vault.test.js create mode 100644 services/multistreaming/web/.gitignore create mode 100644 services/multistreaming/web/.prettierignore create mode 100644 services/multistreaming/web/.prettierrc create mode 100644 services/multistreaming/web/README.md create mode 100644 services/multistreaming/web/components.json create mode 100644 services/multistreaming/web/eslint.config.js create mode 100644 services/multistreaming/web/index.html create mode 100644 services/multistreaming/web/package-lock.json create mode 100644 services/multistreaming/web/package.json create mode 100644 services/multistreaming/web/public/vite.svg create mode 100644 services/multistreaming/web/src/App.tsx create mode 100644 services/multistreaming/web/src/assets/react.svg create mode 100644 services/multistreaming/web/src/components/app-sidebar.tsx create mode 100644 services/multistreaming/web/src/components/auth-screen.tsx create mode 100644 services/multistreaming/web/src/components/dialogs.tsx create mode 100644 services/multistreaming/web/src/components/home-view.tsx create mode 100644 services/multistreaming/web/src/components/image-upload.tsx create mode 100644 services/multistreaming/web/src/components/nav-rooms.tsx create mode 100644 services/multistreaming/web/src/components/nav-user.tsx create mode 100644 services/multistreaming/web/src/components/nav-vaults.tsx create mode 100644 services/multistreaming/web/src/components/room-sidebar.tsx create mode 100644 services/multistreaming/web/src/components/room-view.tsx create mode 100644 services/multistreaming/web/src/components/scene-editor.tsx create mode 100644 services/multistreaming/web/src/components/scene-panel.tsx create mode 100644 services/multistreaming/web/src/components/theme-provider.tsx create mode 100644 services/multistreaming/web/src/components/ui/alert-dialog.tsx create mode 100644 services/multistreaming/web/src/components/ui/alert.tsx create mode 100644 services/multistreaming/web/src/components/ui/avatar.tsx create mode 100644 services/multistreaming/web/src/components/ui/badge.tsx create mode 100644 services/multistreaming/web/src/components/ui/breadcrumb.tsx create mode 100644 services/multistreaming/web/src/components/ui/button.tsx create mode 100644 services/multistreaming/web/src/components/ui/card.tsx create mode 100644 services/multistreaming/web/src/components/ui/collapsible.tsx create mode 100644 services/multistreaming/web/src/components/ui/dialog.tsx create mode 100644 services/multistreaming/web/src/components/ui/dropdown-menu.tsx create mode 100644 services/multistreaming/web/src/components/ui/empty.tsx create mode 100644 services/multistreaming/web/src/components/ui/field.tsx create mode 100644 services/multistreaming/web/src/components/ui/input-group.tsx create mode 100644 services/multistreaming/web/src/components/ui/input.tsx create mode 100644 services/multistreaming/web/src/components/ui/label.tsx create mode 100644 services/multistreaming/web/src/components/ui/select.tsx create mode 100644 services/multistreaming/web/src/components/ui/separator.tsx create mode 100644 services/multistreaming/web/src/components/ui/sheet.tsx create mode 100644 services/multistreaming/web/src/components/ui/sidebar.tsx create mode 100644 services/multistreaming/web/src/components/ui/skeleton.tsx create mode 100644 services/multistreaming/web/src/components/ui/sonner.tsx create mode 100644 services/multistreaming/web/src/components/ui/switch.tsx create mode 100644 services/multistreaming/web/src/components/ui/textarea.tsx create mode 100644 services/multistreaming/web/src/components/ui/toggle-group.tsx create mode 100644 services/multistreaming/web/src/components/ui/toggle.tsx create mode 100644 services/multistreaming/web/src/components/ui/tooltip.tsx create mode 100644 services/multistreaming/web/src/hooks/use-mobile.ts create mode 100644 services/multistreaming/web/src/index.css create mode 100644 services/multistreaming/web/src/lib/api.ts create mode 100644 services/multistreaming/web/src/lib/types.ts create mode 100644 services/multistreaming/web/src/lib/use-app.ts create mode 100644 services/multistreaming/web/src/lib/utils.ts create mode 100644 services/multistreaming/web/src/lib/vault.ts create mode 100644 services/multistreaming/web/src/main.tsx create mode 100644 services/multistreaming/web/tsconfig.app.json create mode 100644 services/multistreaming/web/tsconfig.json create mode 100644 services/multistreaming/web/tsconfig.node.json create mode 100644 services/multistreaming/web/vite.config.ts create mode 100644 services/nginx-proxy-manager/metadata.json create mode 100644 services/portainer/metadata.json diff --git a/.gitignore b/.gitignore index e31a3bc..cbd6400 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ build/ # local installer state .homelab/ +# AgentTeams transient state +.agent-teams/ + # OS noise .DS_Store Thumbs.db diff --git a/README.md b/README.md index 487f798..9c6f812 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A common, reusable repository for running services on a personal homelab. Every service lives in its own folder under [`services/`](services/) and is described by a -`metadata.json` file (plus any extra files the service needs, such as a `Caddyfile`). The +`metadata.json` file (plus any extra files the service needs, such as a config file). The [`installer/`](installer/) directory contains a Bun + TypeScript terminal UI (TUI) that reads the catalog straight from GitHub (used as the CDN) and installs or updates services with Docker. @@ -12,16 +12,21 @@ updates services with Docker. homelab/ ├── services/ # one folder per service │ ├── catalog.json # generated index used by the installer (CDN listing) -│ ├── allprox/ # Caddy reverse proxy + portal -│ │ ├── metadata.json -│ │ ├── Caddyfile # proxy rules (domains → local IPs) -│ │ └── portal/index.html +│ ├── nginx-proxy-manager/ # reverse proxy dashboard (NPM) +│ │ └── metadata.json │ ├── authelia/ # SSO / IdP (login portal, OIDC, forward-auth) │ │ ├── metadata.json │ │ ├── configuration.yml │ │ └── users_database.yml │ ├── lldap/ # lightweight LDAP user store │ │ └── metadata.json +│ ├── multistreaming/ # self-built: OBS ingest -> multi-platform re-streaming +│ │ ├── metadata.json +│ │ ├── Dockerfile +│ │ ├── src/ +│ │ └── web/ +│ ├── portainer/ # Docker + network manager (VLANs, static IPs) +│ │ └── metadata.json │ └── ... ├── installer/ # Bun + TypeScript TUI │ ├── src/ @@ -32,23 +37,53 @@ homelab/ ## Identity stack (SSO) -Three services work together for single sign-on: +Every web service logs in through the same IdP ([`authelia`](services/authelia)), which authenticates +users against [`lldap`](services/lldap) and sits behind [`nginx-proxy-manager`](services/nginx-proxy-manager): ``` -browser ──▶ allprox (Caddy) ──forward_auth──▶ authelia ──LDAP──▶ lldap +browser ──▶ nginx-proxy-manager ──▶ authelia ──LDAP──▶ lldap ``` -- [`allprox`](services/allprox) — reverse proxy; protected routes use Caddy `forward_auth` to Authelia. -- [`authelia`](services/authelia) — the SSO server (login portal, OIDC, 2FA) that authenticates users against… -- [`lldap`](services/lldap) — the lightweight LDAP directory (web UI at `http://:17170`). +Two integration modes, depending on what each app supports: -They talk to each other over a shared external Docker network named `homelab`, which the installer -creates automatically. Install `lldap` first, then `authelia`, then `allprox`: +1. **OIDC (true SSO)** — the app delegates login to Authelia. [`portainer`](services/portainer) uses + this (client `portainer` is pre-registered in Authelia). +2. **Forward-auth (proxy gate)** — the app has no OIDC, so NPM asks Authelia to authorize each + request via an `auth_request` block before forwarding. Used by `multistreaming` and lldap's web UI. + +| Service | SSO mode | +| --- | --- | +| Portainer | OIDC (native) | +| multistreaming panel | forward-auth (no OIDC) | +| lldap web UI | forward-auth (no OIDC) | +| Nginx Proxy Manager | its own login (no OIDC; keep port 81 restricted) | + +Authelia also exposes OIDC discovery at `https://auth.example.com/.well-known/openid-configuration` +for any future OIDC-capable app. Install order: `lldap`, `authelia`, `nginx-proxy-manager`: ```bash -bun run src/index.ts install lldap authelia allprox +bun run src/index.ts install lldap authelia nginx-proxy-manager ``` +## Network & DMZ topology + +[`portainer`](services/portainer) is the Docker/network manager: from its dashboard you can create +**macvlan/ipvlan networks** (each mapped to a host VLAN via its parent interface) and assign each +container a **static IP**. A typical setup keeps only the reverse proxy in the DMZ and everything +else on separate VLANs: + +``` +internet ──▶ DMZ VLAN (e.g. 10.0.10.0/24) + └── nginx-proxy-manager (the only public entrypoint) + │ + ├─▶ services VLAN (10.0.20.0/24) ── multistreaming, … + └─▶ identity VLAN (10.0.30.0/24) ── authelia, lldap +``` + +To make a VLAN usable as a macvlan parent, the tagged sub-interface must first exist on the host +(e.g. `eth0.10`, configured in `/etc/network/interfaces` or netplan — outside Docker's scope). +Then create the network in Portainer and attach services to it with the IPs you want. + ## Quick start ```bash @@ -74,11 +109,11 @@ The same app works as a plain CLI: ```bash bun run src/index.ts list -bun run src/index.ts info allprox -bun run src/index.ts install allprox +bun run src/index.ts info nginx-proxy-manager +bun run src/index.ts install nginx-proxy-manager bun run src/index.ts update # update everything installed -bun run src/index.ts update allprox # update one service -bun run src/index.ts uninstall allprox +bun run src/index.ts update nginx-proxy-manager # update one service +bun run src/index.ts uninstall nginx-proxy-manager bun run src/index.ts status ``` @@ -120,8 +155,8 @@ bun run catalog 1. Create `services//metadata.json` (the folder name **must** equal the `id`). 2. Fill in the [metadata schema](docs/metadata-schema.json) — see [`services/README.md`](services/README.md) - for a walkthrough of every field. Add any extra files the service needs (a `Caddyfile`, a - `Dockerfile`, a static `portal/`, …) alongside `metadata.json`; the installer copies them into + for a walkthrough of every field. Add any extra files the service needs (a config file, a + `Dockerfile`, …) alongside `metadata.json`; the installer copies them into the deploy directory, so reference them with relative bind mounts in `compose.volumes`. 3. Regenerate the catalog: `cd installer && bun run catalog`. 4. Commit and push. The installer will now offer the new service. @@ -132,22 +167,21 @@ bun run catalog ```json { - "id": "allprox", - "name": "allprox", - "description": "Caddy reverse proxy with an SSO-ready portal", + "id": "nginx-proxy-manager", + "name": "Nginx Proxy Manager", + "description": "Reverse proxy with a web dashboard", "version": "1.0.0", "category": "network", "compose": { - "image": "caddy:2-alpine", - "container_name": "allprox", + "image": "jc21/nginx-proxy-manager:2.14.0", + "container_name": "nginx-proxy-manager", "restart": "unless-stopped", - "ports": ["80:80", "443:443"], - "volumes": ["./Caddyfile:/etc/caddy/Caddyfile:ro", "allprox_data:/data"], - "environment": ["PORTAL_AUTH_HASH=${PORTAL_AUTH_HASH}"] + "ports": ["80:80", "443:443", "81:81"], + "volumes": ["npm_data:/data", "npm_letsencrypt:/etc/letsencrypt"] }, - "volumes": { "allprox_data": {} }, + "volumes": { "npm_data": {}, "npm_letsencrypt": {} }, "env": [ - { "name": "PORTAL_AUTH_HASH", "label": "Portal admin password hash", "secret": true } + { "name": "TZ", "label": "Timezone", "default": "UTC" } ] } ``` @@ -157,8 +191,9 @@ bun run catalog - `volumes` / `networks` are optional top-level named volumes/networks to declare. - `env` declares variables the installer should resolve for you. Use `${NAME}` in `compose` to reference them — the installer writes the resolved values to a `.env` file next to the compose file. -- Any other files in the service folder (e.g. `Caddyfile`, `portal/index.html`) are copied into the - deploy directory, so you can mount them with relative paths (`./Caddyfile:...`) in `compose.volumes`. +- Any other files in the service folder (e.g. `configuration.yml`, `users_database.yml`) are copied + into the deploy directory, so you can mount them with relative paths (`./configuration.yml:...`) in + `compose.volumes`. See [`docs/metadata-schema.json`](docs/metadata-schema.json) for the complete, machine-readable schema. diff --git a/docs/metadata-schema.json b/docs/metadata-schema.json index 09190ed..ca9bd8e 100644 --- a/docs/metadata-schema.json +++ b/docs/metadata-schema.json @@ -26,7 +26,7 @@ }, "compose": { "type": "object", - "description": "A Docker Compose service definition (image, ports, volumes, environment, ...)." + "description": "A Docker Compose service definition. Must set either `image` (pull a prebuilt image) or `build` (build from a Dockerfile, e.g. { \"context\": \".\", \"dockerfile\": \"Dockerfile\" }), plus ports, volumes, environment, etc." }, "volumes": { "type": "object", @@ -55,6 +55,20 @@ } } }, + "rsaKeys": { + "type": "array", + "description": "RSA private keys the installer generates into files on first install (used for OIDC signing keys that cannot be injected via env).", + "items": { + "type": "object", + "required": ["name", "path"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "path": { "type": "string" }, + "bits": { "type": "number" } + } + } + }, "category": { "type": "string" }, "tags": { "type": "array", "items": { "type": "string" } }, "icon": { "type": "string", "format": "uri" }, diff --git a/installer/src/commands.ts b/installer/src/commands.ts index 33b6191..38dacce 100644 --- a/installer/src/commands.ts +++ b/installer/src/commands.ts @@ -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 { + 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 { if (cfg.dryRun) return; const nets = meta.networks ?? {}; diff --git a/installer/src/compose.ts b/installer/src/compose.ts index e7f7831..51d2a58 100644 --- a/installer/src/compose.ts +++ b/installer/src/compose.ts @@ -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).build); +} + /** Build the docker-compose.yml content for a service from its metadata. */ export function generateComposeFile(meta: ServiceMetadata): string { const doc: Record = {}; diff --git a/installer/src/github.ts b/installer/src/github.ts index 6f7ebb4..04bc195 100644 --- a/installer/src/github.ts +++ b/installer/src/github.ts @@ -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); diff --git a/installer/src/keys.ts b/installer/src/keys.ts new file mode 100644 index 0000000..32bbe71 --- /dev/null +++ b/installer/src/keys.ts @@ -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; +} diff --git a/installer/src/metadata.ts b/installer/src/metadata.ts index 3737556..939f295 100644 --- a/installer/src/metadata.ts +++ b/installer/src/metadata.ts @@ -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" }); diff --git a/installer/src/types.ts b/installer/src/types.ts index dd7602d..eb23ac7 100644 --- a/installer/src/types.ts +++ b/installer/src/types.ts @@ -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; networks?: Record; env?: EnvSpec[]; + rsaKeys?: RsaKeySpec[]; dependsOn?: string[]; notes?: string; } diff --git a/services/README.md b/services/README.md index 3d8f2cc..dcaed2c 100644 --- a/services/README.md +++ b/services/README.md @@ -2,7 +2,7 @@ One folder per service. Each folder must contain a `metadata.json` describing the service (including the Docker Compose definition used to deploy it). The folder may also contain any extra -files the service needs — a `Caddyfile`, a `Dockerfile`, static assets — which the installer copies +files the service needs — a config file, a `Dockerfile`, static assets — which the installer copies into the deploy directory. ## Folder conventions @@ -20,25 +20,27 @@ into the deploy directory. ## Extra files -A service folder can contain files beyond `metadata.json` — for example the `allprox` service ships -a `Caddyfile` and a `portal/index.html`. On install/update the installer copies the whole folder -(except `metadata.json`) into `~/.homelab/services//`, preserving subdirectories. +A service folder can contain files beyond `metadata.json` — for example the `authelia` service ships +a `configuration.yml` and a `users_database.yml`, while the `multistreaming` service ships a `Dockerfile`, +`package.json`, `src/`, and `web/` for its `build`. On install/update the installer copies the whole +folder (except `metadata.json`) into `~/.homelab/services//`, preserving subdirectories. Reference those files from `compose.volumes` with **relative** paths: ```json "volumes": [ - "./Caddyfile:/etc/caddy/Caddyfile:ro", - "./portal:/srv/portal:ro" + "./configuration.yml:/config/configuration.yml:ro", + "./users_database.yml:/config/users_database.yml:ro" ] ``` Because the generated `docker-compose.yml` lives in the same directory, `docker compose` resolves -the `./...` paths against it. +the `./...` paths against it — and, for `build` services, the `.` build context is that same +directory, so the Dockerfile and source files are found automatically. ## Shared networks -Services that need to talk to each other (e.g. `allprox` → `authelia` → `lldap`) join an external +Services that need to talk to each other (e.g. `nginx-proxy-manager` → `authelia` → `lldap`) join an external Docker network. Declare it at the top level of `metadata.json` and attach the service to it: ```json @@ -77,8 +79,9 @@ A machine-readable JSON Schema is available at [`docs/metadata-schema.json`](../ ### `compose` (the service definition) -This object is the value you would normally put under a service key in `docker-compose.yml`. For -example: +This object is the value you would normally put under a service key in `docker-compose.yml`. It must +set **either** `image` (pull a prebuilt image) **or** `build` (build from a Dockerfile shipped in the +service folder). Pull example: ```json { @@ -91,17 +94,24 @@ example: } ``` -The installer turns it into: +Build example (the Dockerfile, source files, and `.dockerignore` live in the service folder and are +copied into the deploy directory, so the build context is `.`): -```yaml -services: - myapp: - image: ghcr.io/example/myapp:1.0.0 - # ... +```json +{ + "build": { "context": ".", "dockerfile": "Dockerfile" }, + "container_name": "myapp", + "restart": "unless-stopped", + "ports": ["8080:8080"], + "volumes": ["myapp_data:/data"], + "environment": ["TZ=${TZ}"] +} ``` -Pin images to a tag so updates are predictable; the installer compares `version` to decide whether -an update is available. +The installer turns either into a `docker-compose.yml` service. On install it runs +`docker compose build` (for `build` services) or pulls the image on update; then `docker compose up -d` +brings the container up. For image-based services, pin the tag so updates are predictable — the +installer compares `version` to decide whether an update is available. ### `env` (interactive variables) @@ -129,3 +139,23 @@ Each entry describes a variable the installer should collect (and write to `.env Docker Compose automatically reads the `.env` written next to the generated compose file, so `${NAME}` references resolve at `docker compose up` time. + +### `rsaKeys` (generated private keys) + +For secrets that cannot be injected via environment variables — most notably Authelia's OIDC +signing key (`identity_providers.oidc.jwks.key`, which rejects `$file:`/`$env:` secret references) — +declare an RSA private key the installer generates into a file on first install: + +```json +"rsaKeys": [ + { "name": "OIDC_JWKS_KEY", "path": "oidc-jwks.pem", "bits": 2048 } +] +``` + +- `name` (required) — human label. +- `path` (required) — file written into the service's deploy directory, referenced by a relative + `./path:...` bind mount in `compose.volumes`. +- `bits` — RSA modulus size (default 2048). + +The key is only created if the file is absent, so updating a service never rotates the key and +invalidates existing sessions. diff --git a/services/allprox/Caddyfile b/services/allprox/Caddyfile deleted file mode 100644 index 81355bd..0000000 --- a/services/allprox/Caddyfile +++ /dev/null @@ -1,63 +0,0 @@ -# allprox — Caddy reverse proxy -# -# Maps domains to local IPs and hosts an authenticated portal (web UI). -# Edit the domain names below to match your DNS, and set the upstream -# IP:port values in the installer's .env (APP1_UPSTREAM, APP2_UPSTREAM, ...). -# Duplicate a "Proxied services" block to add more services. -# -# Authentication: -# * Today: basic_auth (a single admin account; hash set via PORTAL_AUTH_HASH). -# * Later: Authelia SSO — replace the basic_auth blocks with the forward_auth -# block shown below (see https://www.authelia.com/integration/proxies/caddy/). - -{ - # For public domains with automatic HTTPS, set your ACME email here: - # email you@example.com -} - -# --------------------------------------------------------------------------- -# Web UI — authenticated portal (change the domain to your own) -# --------------------------------------------------------------------------- -portal.example.com { - # `tls internal` issues a self-signed cert for LAN use. Remove this line - # and set the ACME email above when exposing a real public domain. - tls internal - - basic_auth { - admin {$PORTAL_AUTH_HASH} - } - - # Authelia SSO (later): replace the basic_auth block above with: - # forward_auth authelia:9091 { - # uri /api/authz/forward-auth - # copy_headers Remote-User Remote-Groups Remote-Email Remote-Name - # } - - root * /srv/portal - file_server -} - -# --------------------------------------------------------------------------- -# Authelia portal — the SSO login page (proxy to the authelia container). -# With Authelia running, users are redirected here to sign in. -# --------------------------------------------------------------------------- -auth.example.com { - tls internal - reverse_proxy authelia:9091 -} - -# --------------------------------------------------------------------------- -# Proxied services — copy a block per service. The local IP:port comes from -# the {$APPx_UPSTREAM} variable in .env. -# --------------------------------------------------------------------------- -app1.example.com { - tls internal - # basic_auth { admin {$PORTAL_AUTH_HASH} } # or the Authelia forward_auth block - reverse_proxy {$APP1_UPSTREAM} -} - -app2.example.com { - tls internal - # basic_auth { admin {$PORTAL_AUTH_HASH} } - reverse_proxy {$APP2_UPSTREAM} -} diff --git a/services/allprox/metadata.json b/services/allprox/metadata.json deleted file mode 100644 index 02e2ca5..0000000 --- a/services/allprox/metadata.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "id": "allprox", - "name": "allprox", - "description": "Caddy reverse proxy that routes domains to local IPs, hosts an authenticated portal (web UI), and is SSO-ready for Authelia (OAuth2/OIDC).", - "version": "1.0.0", - "category": "network", - "tags": ["reverse-proxy", "caddy", "authelia", "sso", "https"], - "author": "Caddy / Authelia", - "license": "Apache-2.0", - "homepage": "https://caddyserver.com", - "documentation": "https://www.authelia.com/integration/proxies/caddy/", - "compose": { - "image": "caddy:2-alpine", - "container_name": "allprox", - "restart": "unless-stopped", - "ports": ["80:80", "443:443", "443:443/udp"], - "volumes": [ - "./Caddyfile:/etc/caddy/Caddyfile:ro", - "./portal:/srv/portal:ro", - "allprox_data:/data", - "allprox_config:/config" - ], - "environment": [ - "APP1_UPSTREAM=${APP1_UPSTREAM}", - "APP2_UPSTREAM=${APP2_UPSTREAM}", - "PORTAL_AUTH_HASH=${PORTAL_AUTH_HASH}" - ], - "networks": ["homelab"] - }, - "volumes": { - "allprox_data": {}, - "allprox_config": {} - }, - "networks": { - "homelab": { "external": true } - }, - "env": [ - { - "name": "PORTAL_AUTH_HASH", - "label": "Portal admin password (bcrypt hash)", - "description": "bcrypt hash for basic_auth (default is 'changeme'). Generate your own with: docker compose exec allprox caddy hash-password", - "default": "$2b$10$fKkpKSlwLZtOBXpInl8pG.8mS65kiEfjOVsuvBj7ikHgtfqEa7h4y", - "required": false, - "secret": true - }, - { - "name": "APP1_UPSTREAM", - "label": "Upstream for app1.example.com", - "description": "Local IP:port to proxy app1.example.com to", - "default": "127.0.0.1:3000", - "required": false, - "secret": false - }, - { - "name": "APP2_UPSTREAM", - "label": "Upstream for app2.example.com", - "description": "Local IP:port to proxy app2.example.com to", - "default": "127.0.0.1:8080", - "required": false, - "secret": false - } - ], - "notes": "Edit services/allprox/Caddyfile to add domains and change upstreams. The portal (web UI) is at portal.example.com (change the domain). Interim auth is basic_auth (admin / 'changeme' by default) — swap to the Authelia forward_auth block in the Caddyfile for OAuth2/OIDC SSO." -} diff --git a/services/allprox/portal/index.html b/services/allprox/portal/index.html deleted file mode 100644 index 3ee4856..0000000 --- a/services/allprox/portal/index.html +++ /dev/null @@ -1,80 +0,0 @@ - - - - - -allprox — portal - - - -
-

allprox signed in

-

Authenticated access portal — you are signed in.

- -
- Services - -
- -

- Edit this page at services/allprox/portal/index.html and the proxy - rules in services/allprox/Caddyfile. -

-
- - diff --git a/services/authelia/configuration.yml b/services/authelia/configuration.yml index 260c280..6e86886 100644 --- a/services/authelia/configuration.yml +++ b/services/authelia/configuration.yml @@ -1,8 +1,14 @@ # Authelia configuration — https://www.authelia.com/configuration/prologue/introduction/ # -# Secrets (JWT_SECRET, RESET_JWT_SECRET, SESSION_SECRET, LDAP_ADMIN_PASSWORD) are -# resolved from the service's .env by the installer and substituted into this file -# on install/update, so they are not committed here. +# Secrets (JWT_SECRET, RESET_JWT_SECRET, SESSION_SECRET, LDAP_ADMIN_PASSWORD, +# OIDC_HMAC_SECRET, OIDC_PORTAINER_SECRET) are resolved from the service's .env +# by the installer and substituted into this file on install/update, so they are +# not committed here. +# +# The OIDC signing key (jwks) cannot be injected via env/file secrets (Authelia +# does not support that for this field), so it is read from /config/oidc-jwks.pem +# using the `template` file filter (enabled via X_AUTHELIA_CONFIG_FILTERS). +# The installer generates that file on first install and never rotates it. theme: dark @@ -41,8 +47,11 @@ access_control: rules: - domain: 'auth.example.com' policy: bypass - - domain: 'portal.example.com' - policy: one_factor + # Everything else on your domain is SSO-protected (one factor by default). + # Apps that talk to Authelia via OIDC (Portainer) are NOT governed by these + # rules — their 2FA requirement lives in the client's authorization_policy. + # Apps behind forward-auth (multistreaming, lldap, …) use these rules; bump a + # specific host to two_factor if you want 2FA on it too. - domain: '*.example.com' policy: one_factor @@ -55,7 +64,7 @@ session: cookies: - domain: 'example.com' authelia_url: 'https://auth.example.com' - default_redirection_url: 'https://portal.example.com' + default_redirection_url: 'https://auth.example.com' regulation: max_retries: 3 @@ -69,3 +78,54 @@ storage: notifier: filesystem: filename: '/config/notification.txt' + +identity_providers: + oidc: + hmac_secret: '${OIDC_HMAC_SECRET}' + jwks: + - key_id: 'homelab' + algorithm: 'RS256' + use: 'sig' + key: | + {{- fileContent "/config/oidc-jwks.pem" | nindent 10 }} + clients: + - client_id: 'portainer' + client_name: 'Portainer' + client_secret: '${OIDC_PORTAINER_SECRET}' + public: false + redirect_uris: + - 'https://portainer.example.com' + scopes: + - 'openid' + - 'profile' + - 'groups' + - 'email' + grant_types: + - 'refresh_token' + - 'authorization_code' + response_types: + - 'code' + response_modes: + - 'form_post' + - 'query' + authorization_policy: 'two_factor' + - client_id: 'multistreaming' + client_name: 'Multistreaming' + # Public client: the panel is a browser SPA. No client_secret — it is + # protected by PKCE (S256) instead, which is required for public clients. + public: true + redirect_uris: + - 'https://streaming.example.com/api/auth/oidc/callback' + scopes: + - 'openid' + - 'profile' + - 'email' + grant_types: + - 'authorization_code' + response_types: + - 'code' + response_modes: + - 'query' + require_pkce: true + pkce_challenge_method: 'S256' + authorization_policy: 'two_factor' diff --git a/services/authelia/metadata.json b/services/authelia/metadata.json index ffdf980..e93b7b6 100644 --- a/services/authelia/metadata.json +++ b/services/authelia/metadata.json @@ -1,23 +1,27 @@ { "id": "authelia", "name": "Authelia", - "description": "Open-source authentication and authorization server providing SSO and 2FA for the homelab.", - "version": "1.0.0", + "description": "Open-source authentication and authorization server providing SSO, 2FA, forward-auth, and an OIDC provider for the homelab.", + "version": "3.0.0", "category": "identity", - "tags": ["sso", "authentication", "2fa", "oidc", "forward-auth"], + "tags": ["sso", "authentication", "2fa", "oidc", "forward-auth", "idp"], "author": "Authelia", "license": "Apache-2.0", "homepage": "https://www.authelia.com", "documentation": "https://www.authelia.com/configuration/prologue/introduction/", "compose": { - "image": "authelia/authelia:latest", + "image": "authelia/authelia:4.39.14", "container_name": "authelia", "restart": "unless-stopped", "ports": ["9091:9091"], "volumes": [ "authelia_config:/config", "./configuration.yml:/config/configuration.yml:ro", - "./users_database.yml:/config/users_database.yml:ro" + "./users_database.yml:/config/users_database.yml:ro", + "./oidc-jwks.pem:/config/oidc-jwks.pem:ro" + ], + "environment": [ + "X_AUTHELIA_CONFIG_FILTERS=template" ], "networks": ["homelab"] }, @@ -50,8 +54,26 @@ "default": "changeme-admin", "required": false, "secret": true + }, + { + "name": "OIDC_HMAC_SECRET", + "label": "OIDC HMAC secret (auto-generated)", + "generate": true + }, + { + "name": "OIDC_PORTAINER_SECRET", + "label": "Portainer OIDC client secret (auto-generated)", + "description": "Copy this value into Portainer's OAuth client-secret field", + "generate": true + } + ], + "rsaKeys": [ + { + "name": "OIDC_JWKS_KEY", + "path": "oidc-jwks.pem", + "bits": 2048 } ], "dependsOn": ["lldap"], - "notes": "Authelia's portal is normally reached through allprox at auth.example.com (see the allprox Caddyfile). The forward-auth endpoint is http://authelia:9091/api/authz/forward-auth. Default access-control rules protect portal.example.com and *.example.com — edit configuration.yml to match your domains." + "notes": "SSO / IdP: exposes a login portal (https://auth.example.com via nginx-proxy-manager) and a forward-auth endpoint (http://authelia:9091/api/authz/forward-auth). OIDC issuer: https://auth.example.com — discovery at /.well-known/openid-configuration. Two clients are pre-registered: 'portainer' (confidential, secret in OIDC_PORTAINER_SECRET) and 'multistreaming' (public + PKCE, no secret; redirect_uri https://streaming.example.com/api/auth/oidc/callback). For apps without OIDC (lldap web UI), protect them with a forward-auth auth_request block in nginx-proxy-manager's Advanced tab. The OIDC signing key (oidc-jwks.pem) is generated once by the installer and never rotated. Default access-control protects *.example.com with one factor; edit configuration.yml to change domains or require 2FA." } diff --git a/services/catalog.json b/services/catalog.json index a48ef7d..9dc6a34 100644 --- a/services/catalog.json +++ b/services/catalog.json @@ -1,20 +1,30 @@ { - "generatedAt": "2026-09-01T16:20:56.550Z", + "generatedAt": "2026-09-02T16:59:46.903Z", "services": [ - { - "id": "allprox", - "version": "1.0.0", - "path": "services/allprox/metadata.json" - }, { "id": "authelia", - "version": "1.0.0", + "version": "3.0.0", "path": "services/authelia/metadata.json" }, { "id": "lldap", "version": "1.0.0", "path": "services/lldap/metadata.json" + }, + { + "id": "multistreaming", + "version": "4.1.0", + "path": "services/multistreaming/metadata.json" + }, + { + "id": "nginx-proxy-manager", + "version": "1.0.0", + "path": "services/nginx-proxy-manager/metadata.json" + }, + { + "id": "portainer", + "version": "1.0.0", + "path": "services/portainer/metadata.json" } ] } diff --git a/services/lldap/metadata.json b/services/lldap/metadata.json index 96d6ed1..a45fd85 100644 --- a/services/lldap/metadata.json +++ b/services/lldap/metadata.json @@ -62,5 +62,5 @@ "generate": true } ], - "notes": "Web UI at http://:17170 — log in with 'admin' and your admin password. LDAP endpoint is ldap://lldap:3890 (base DN dc=homelab,dc=local). Create users and groups here; Authelia authenticates against them." + "notes": "Web UI at http://:17170 — log in with 'admin' and your admin password. lldap's web UI has no OIDC, so if you expose it through nginx-proxy-manager, protect it with Authelia forward-auth (auth_request block in the proxy host's Advanced tab — see nginx-proxy-manager notes). LDAP endpoint is ldap://lldap:3890 (base DN dc=homelab,dc=local). Create users and groups here; Authelia authenticates against them and issues OIDC/SSO sessions from the same directory." } diff --git a/services/multistreaming/.dockerignore b/services/multistreaming/.dockerignore new file mode 100644 index 0000000..03544fa --- /dev/null +++ b/services/multistreaming/.dockerignore @@ -0,0 +1,13 @@ +node_modules +web/node_modules +web/dist +.smoke-data +data +*.log +.git +.gitignore +metadata.json +docker-compose.yml +.env +Dockerfile +.dockerignore diff --git a/services/multistreaming/Dockerfile b/services/multistreaming/Dockerfile new file mode 100644 index 0000000..47f9b12 --- /dev/null +++ b/services/multistreaming/Dockerfile @@ -0,0 +1,45 @@ +# multistreaming — ingest one RTMP feed and restream it to many targets. +# Build: docker build -t multistreaming . +# Run: docker run -p 1935:1935 -p 8080:8080 -v ms_data:/data multistreaming + +# ---- frontend build stage ------------------------------------------------ +FROM node:22-alpine AS web +WORKDIR /web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +# ---- backend deps stage -------------------------------------------------- +FROM node:22-alpine AS deps +WORKDIR /app +COPY package*.json ./ +RUN npm ci --omit=dev + +# ---- runtime stage -------------------------------------------------------- +FROM node:22-alpine +WORKDIR /app + +# FFmpeg does the actual fan-out (remux passthrough, or re-encode for scenes). +# font-dejavu is a hard runtime dependency of the drawtext text overlay filter. +RUN apk add --no-cache ffmpeg font-dejavu + +ENV NODE_ENV=production + +COPY --from=deps /app/node_modules ./node_modules +COPY src ./src +COPY --from=web /web/dist ./web/dist + +# Persistent state (vaults, rooms, accounts, feeds) lives here; mount a volume. +# Pre-create it and hand ownership to the runtime user so a fresh named +# volume is writable without root. +RUN mkdir -p /data && chown node:node /data +VOLUME ["/data"] +ENV DATA_DIR=/data \ + RTMP_PORT=1935 \ + HTTP_PORT=8080 + +EXPOSE 1935 8080 + +USER node +CMD ["node", "src/index.js"] diff --git a/services/multistreaming/README.md b/services/multistreaming/README.md new file mode 100644 index 0000000..e18ea44 --- /dev/null +++ b/services/multistreaming/README.md @@ -0,0 +1,234 @@ +# multistreaming + +Self-hosted live production dashboard: ingest RTMP feeds (one per person from +OBS) and restream them to one or more streaming platforms — with shared rooms, +role-based collaboration, a zero-knowledge key vault, and scenes & composition +(grid/PiP layouts, text/image overlays, per-output audio routing). + +- **Panel**: React + TypeScript + shadcn/ui (Tailwind v4) at + `http://:8080`. +- **Ingest**: RTMP at `rtmp://:1935/live/`. +- **Fan-out**: FFmpeg, `-c copy` remux by default; re-encode only for composed + scenes. + +--- + +## 1. Features + +- **Per-person feeds** — every streamer publishes to their own ingest URL with a + unique stream key. +- **Rooms & roles** — share a production with `owner` (full control), `editor` + (edits production, never sees keys), and `streamer` (own accounts + start + streaming) roles via single-use invite links. +- **Pluggable providers** — Twitch, YouTube, Kick, and a custom RTMP escape + hatch, extensible in one file. +- **Zero-knowledge vault** — stream keys are encrypted in the browser + (AES-GCM under a PBKDF2-wrapped vault key); the server stores only ciphertext + and grants keys transiently, in memory, when a streamer clicks *Start + streaming*. +- **Scenes & composition** — named scenes with grid or picture-in-picture + layouts, text and image overlays, and per-output audio routing. Activating a + scene switches the room from passthrough to a composed program. + +--- + +## 2. Architecture + +``` + OBS ──RTMP──▶ node-media-server (ingest, :1935) + │ publish/unpublish events + ▼ + index.js ── reconcileAll() + │ + ┌───────────┴────────────┐ + ▼ ▼ + passthrough scene (composed) + fanout.js (engine) compose.js → fanout.js + ffmpeg -c copy ffmpeg -filter_complex (xstack/overlay/drawtext) + │ │ + └───────────┬────────────┘ + ▼ + platform push URLs (Twitch/YouTube/Kick/custom, resolved from in-memory grants) +``` + +| Component | Path | Role | +|-----------|------|------| +| `src/index.js` | orchestrator | wires config/store/fanout/server; tracks live feeds; `reconcileAll()` | +| `src/server.js` | REST API | Express routes, role gating, `/api/state` | +| `src/store.js` | persistence | atomic JSON store for users/vaults/rooms/memberships/invites/accounts/feeds/outputs/scenes/sceneOutputs/images | +| `src/fanout.js` | process engine | generic unit-of-work engine: spawn/kill/restart/reconcile FFmpeg children | +| `src/compose.js` | composition | builds FFmpeg filter graphs + argv for scenes | +| `src/vault.js` | crypto | zero-knowledge vault enroll/unlock/recover (WebCrypto) | +| `src/grants.js` | grants | in-memory, TTL-bounded plaintext key grants | +| `src/auth.js` / `src/oidc.js` | auth | sessions + local scrypt / OIDC PKCE | +| `src/providers.js` | providers | pluggable platform definitions | +| `src/config.js` | config | environment → runtime config | +| `web/src/` | frontend | React/shadcn panel | + +--- + +## 3. Directory layout + +``` +multistreaming/ +├── src/ # CommonJS backend +├── web/ # React + TS + shadcn frontend (src/, built to web/dist) +├── docs/SCENES.md # scene & composition design contract +├── Dockerfile # multi-stage: web build → backend deps → runtime (node + ffmpeg) +├── metadata.json # catalog metadata (version, compose, env) +└── package.json +``` + +--- + +## 4. Configuration + +Environment variables (see also `metadata.json` for the UI descriptions): + +| Variable | Default | Purpose | +|----------|---------|---------| +| `AUTH_MODE` | `oidc` | `oidc` (Authelia) or `local` (dev) | +| `OIDC_ISSUER` | — | Authelia root URL (OIDC discovery) | +| `OIDC_CLIENT_ID` | `multistreaming` | OIDC client id | +| `OIDC_REDIRECT_URI` | — | must match Authelia redirect_uris | +| `SESSION_SECRET` | auto | HMAC secret for session cookies (set it to a stable random value) | +| `PUBLIC_HOST` | `''` | host shown in copy-paste ingest URLs | +| `GRANT_TTL_MS` | `21600000` | plaintext-key grant lifetime (6 h) | +| `RTMP_PORT` | `1935` | RTMP ingest port | +| `HTTP_PORT` | `8080` | panel + API port | +| `DATA_DIR` | `./data` | persisted state + `uploads/` | +| `FFMPEG_PATH` | `ffmpeg` | ffmpeg binary | +| `SCENE_WIDTH` / `SCENE_HEIGHT` | `1920` / `1080` | composed canvas | +| `SCENE_FPS` | `30` | composed frame rate | +| `SCENE_VIDEO_BITRATE` | `4500k` | composed video bitrate | +| `SCENE_AUDIO_BITRATE` | `160k` | composed audio bitrate | +| `SCENE_FONT` | DejaVu Sans | drawtext font path (installed in the image) | +| `SCENE_FONT_BOLD` | DejaVu Sans Bold | bold drawtext font path | +| `SCENE_MAX_IMAGE_BYTES` | `1048576` | image overlay upload cap (1 MiB) | + +--- + +## 5. Running + +Build and run with the repository's catalog tooling (metadata.json provides the +compose service) or directly: + +```bash +docker build -t multistreaming . +docker run -p 1935:1935 -p 8080:8080 \ + -v multistreaming_data:/data \ + -e AUTH_MODE=oidc \ + -e OIDC_ISSUER=https://auth.example.com \ + -e OIDC_REDIRECT_URI=https://streaming.example.com/api/auth/oidc/callback \ + -e SESSION_SECRET=… \ + multistreaming +``` + +- Panel: `http://:8080` (put it behind nginx-proxy-manager for TLS). +- Ingest: `rtmp://:1935/live/` — RTMP bypasses the HTTP proxy, + so keep port 1935 on a trusted network. + +--- + +## 6. Ingest URL model + +Each **feed** has a unique `streamKey`. Point OBS at: + +``` +rtmp://:1935/live/ +``` + +(`PUBLIC_HOST` only affects the copy-paste URL shown in the UI; ingest always +hits the container's port 1935.) The `live` app name is fixed (`ingestApp`). +When a feed publishes, the orchestrator fans it out to its enabled outputs (or, +if a scene is active, feeds the composed program). + +--- + +## 7. Providers + +Providers are defined in `src/providers.js`. Each extends the `Provider` base +class and implements: + +- `fields` — the fields the UI asks the user for (`name`, `label`, `secret?`, + `required?`, `placeholder?`, `help?`), +- `defaultUrl` — the platform's RTMP server URL, +- `pushUrl(config)` — how the key is appended (default: `url + "/" + key`), +- `describe(config)` / `validate(config)` — copy-safe summary + validation. + +To add a provider, add a derived class and register it in `PROVIDERS`: + +```js +class NewPlatformProvider extends Provider { + constructor() { + super({ + id: "newplatform", + name: "New Platform", + defaultUrl: "rtmp://ingest.newplatform.example/live", + fields: [{ name: "key", label: "Stream key", secret: true, required: true }], + }); + } +} +// …then push `new NewPlatformProvider()` into PROVIDERS. +``` + +The `custom` provider is a free-form RTMP endpoint for anything not explicitly +supported. + +--- + +## 8. Scenes & composition + +A **scene** is a named composition for a room (see `docs/SCENES.md` for the full +contract): + +- **Layouts** — `grid` (2–4 columns, up to 6 slots) or `pip` (main + a + picture-in-picture window). Each slot assigns a feed (or stays empty). Only + *live* feeds are laid out; empty/offline slots are skipped and a single live + feed is promoted to fullscreen. +- **Overlays** — text (position, size, color, bold) and images (position + + size, ≤ 1 MiB, PNG/JPEG/WebP). +- **Audio routing** — each scene **output** (destination) chooses `program` + (mixed audio of all live feeds), `silent`, or a specific `feed`'s audio. +- **Activation** — activating a scene switches the room into composed mode + (per-feed passthrough pauses); deactivating restores passthrough. + +Composed video is re-encoded (H.264 + AAC) per destination so each output can +carry different audio. The FFmpeg filter graph is generated entirely by +`src/compose.js`; no scene data ever stores a stream key. + +--- + +## 9. Security + +See [SECURITY.md](./SECURITY.md) for the full threat model. Highlights: + +- Stream keys are encrypted in the browser; the server persists only + `secretCiphertext` + `serverWrapped`. +- Plaintext keys exist server-side only as short-lived, in-memory grants. +- FFmpeg logs redact destination URLs; overlay text cannot inject FFmpeg + arguments; image uploads are size/type-limited. +- Roles and `maskAccount` ensure editors/streamers never see others' keys. + +The honest limit: the machine that pushes a stream must hold the key at push +time — if the host is compromised during a stream, live keys are readable. + +--- + +## 10. Development + +No runtime tests are run by design; verification is static. + +```bash +# backend syntax check +node --check src/index.js && node --check src/fanout.js && \ +node --check src/server.js && node --check src/store.js && \ +node --check src/compose.js && node --check src/config.js + +# frontend typecheck + build +cd web && npm install && npm run build +``` + +`npm run build` runs `tsc -b && vite build` and emits `web/dist`, which the +backend serves at `/`. A `npm run dev` Vite server may be used for manual web +inspection during development. diff --git a/services/multistreaming/SECURITY.md b/services/multistreaming/SECURITY.md new file mode 100644 index 0000000..ae11fb7 --- /dev/null +++ b/services/multistreaming/SECURITY.md @@ -0,0 +1,220 @@ +# SECURITY.md — multistreaming + +This document describes the security model of the self-hosted multistreaming +service, its threat model, and — explicitly and honestly — the limits of what it +can protect. Read it before exposing the service to anything but a trusted +network. + +--- + +## 1. What this service does + +`multistreaming` ingests RTMP feeds (typically one per person from OBS) and +restreams them to one or more streaming platforms (Twitch, YouTube, Kick, or a +custom RTMP endpoint). Two programming modes exist: + +- **Passthrough** — each live feed is remuxed (`-c copy`) to its destination + accounts without re-encoding. +- **Scenes & composition** — multiple live feeds are composed (grid or + picture-in-picture, plus text/image overlays) and re-encoded into a single + program pushed to destination accounts, with per-output audio routing. + +To push to a platform, the service must present that platform's **stream key**. +Stream keys are the highest-value secret in the system. + +--- + +## 2. Threat model + +### Assets + +| Asset | Sensitivity | +|-------|-------------| +| Platform stream keys (Twitch/YouTube/Kick/custom) | **Highest** — compromise = account takeover of that channel | +| Vault master passphrase / vault key (VK) | Highest — decrypts all keys in a vault | +| Account metadata, room topology, scene definitions | Low–moderate | +| Composed/relayed video | As sensitive as the stream content itself | + +### Actors + +| Actor | Assumption | +|-------|-----------| +| Homelab operator / host root | **Fully trusted.** Can read any memory/disk on the machine. | +| Service account (the container) | Trusted with plaintext keys *at push time only*. | +| Vault owner | Holds the master passphrase; controls their own accounts. | +| Room **owner** | Full control of a room's production; sees own keys only. | +| Room **editor** | Edits feeds/outputs/scenes; **never** sees anyone's keys. | +| Room **streamer** | Manages their own accounts + grants their own keys; read-only on production. | +| Streaming platform | External; receives the pushed stream. | +| Network observer (LAN/internet) | Assumed able to observe traffic unless TLS/VPN is used. | + +### Trust zones + +1. **Browser** — holds the vault key (VK) only while a vault is unlocked, in + memory; encrypts/decrypts keys client-side. +2. **Panel server (the container, the "center machine")** — persists only + ciphertext; holds plaintext keys transiently in memory during a stream. +3. **Ingest path** — RTMP from OBS to the container on port 1935. + +--- + +## 3. Zero-knowledge vault + +The server **never stores a plaintext stream key**. Account secrets are stored as +`secretCiphertext` (`{iv, data}`, AES-GCM) encrypted under a random 256-bit vault +key (VK) that only the user's browser holds after unlock. + +Key hierarchy (`src/vault.js`): + +``` +master passphrase ──PBKDF2-HMAC-SHA256 (600k)──▶ KEK ──wraps──▶ VK ──encrypts──▶ account secrets +``` + +- `salt` — random 16 bytes, stored server-side. +- `serverWrapped` — the VK wrapped by the KEK under AAD `multistreaming:vault:v1`. + Stored server-side; useless without the master passphrase. +- `deviceWrapped` — the VK wrapped by a non-extractable browser device key under + AAD `fingerprint|sessionId`. Enables silent unlock and is **session-bound**: a + different session id or browser fingerprint fails AES-GCM authentication. +- Each account secret is encrypted under the VK with AAD `acct:`, so + a ciphertext cannot be replayed against a different account id. + +Consequences: + +- A database/disk leak of `config.json` yields only PBKDF2-wrapped blobs and + per-account ciphertexts — nothing decryptable without the passphrase or the + device key. +- There is **no password recovery**. Losing the passphrase and the device + wrapping means the keys are unrecoverable (by design). + +--- + +## 4. Capability grants (keys in memory) + +The zero-knowledge property cannot extend all the way to the push: **some machine +must present the plaintext key to the platform at push time.** That machine is +the multistreaming container. + +To minimize exposure, the service uses **short-lived, in-memory capability +grants** (`src/grants.js`): + +- A streamer clicks **Start streaming**; their browser decrypts the selected + keys client-side and sends them to the server over the panel connection. +- The server stores them only in memory: `accountId → { key, grantedBy, expiresAt }`, + never on disk. +- Lifetime is `GRANT_TTL_MS` (default 6 hours). Expired grants are dropped on + access. +- Grants are revoked when the streamer clicks **Stop streaming**, when the + account is deleted, or on expiry. +- FFmpeg resolves the destination URL from the live grant at spawn time; the + URL (which contains the key) is **redacted** in all logs (`redactUrl`). + +--- + +## 5. Authentication & authorization + +- **Sessions** (`src/auth.js`): HMAC-SHA256-signed tokens + (`base64url({sid, uid, iat}).signature`) in an `HttpOnly; SameSite=Lax` cookie, + 12-hour lifetime. `SESSION_SECRET` should be set to a stable random value; if + unset it is generated per boot (sessions do not survive restarts). +- **Local auth** (dev only): scrypt password hashing, constant-time comparison. +- **OIDC** (recommended, Authelia): authorization-code + PKCE (S256), with the + verifier held server-side (10-minute state). Identity is mapped to a local + user record by `sub`/username. OIDC is for authentication only — it never + touches vault key material. + +### Per-room roles + +| Capability | owner | editor | streamer | +|------------|:-----:|:------:|:--------:| +| Manage feeds, outputs, scenes, scene outputs, images | ✅ | ✅ | — | +| Activate/deactivate scenes | ✅ | ✅ | — | +| View room production | ✅ | ✅ | ✅ | +| Manage own accounts + grant own keys | ✅ | ✅ | ✅ | +| See another user's `secretCiphertext` | — | — | — | + +Non-members receive `404` (never `403`) on room-scoped reads, and insufficient +roles receive `403`. `maskAccount` strips `secretCiphertext` from every account +unless the viewer is its owner, so editors and streamers never see others' keys. + +--- + +## 6. Single-use invites + +`POST /api/rooms/:roomId/invites` (owner only) mints an invite with a 256-bit +random token, a role of `editor` or `streamer` (never `owner`), and a 7-day +expiry. Accepting an invite consumes it (deletes the record) and creates the +membership. Invites are: + +- single-use (consumed on accept), +- expiring (pruned at load and on lookup), +- role-limited (cannot mint an owner), +- unguessable (256-bit token). + +--- + +## 7. The inherent trust boundary (be explicit) + +**If you compromise the host (or the multistreaming container while a stream is +running), you can read every key that is currently granted.** That is not a bug; +it is the fundamental limit of any restreaming service — the machine that pushes +must hold the key at push time. + +The mitigations are scope and time, not prevention: + +1. Keys are held only in memory, only for granted accounts, and only for + `GRANT_TTL_MS`. +2. Revocation is one click ("Stop streaming") or automatic on expiry. +3. Keys are never written to disk, never logged, and never sent to editors, + streamers, or other room members. + +If the host is untrusted, do not run this service on it. + +--- + +## 8. Scene & composition security + +- **FFmpeg argument injection** is prevented by: spawning via an argument array + (no shell) and escaping overlay text for FFmpeg's filtergraph parser + (`escapeDrawtext`: control chars stripped, `\` and `'` escaped) plus + `drawtext=...:expansion=none`, a 256-character cap, and NUL stripping. +- **Image overlays** accept only decoded payloads ≤ 1 MiB whose magic bytes are + PNG/JPEG/WebP; files are stored under `dataDir/uploads` with server-generated + UUID filenames (never client-supplied paths), and the path is single-quote + escaped before reaching the `movie` filter. +- **Log redaction** applies to composed outputs exactly as to passthrough: + destination URLs (which embed keys) are replaced before logging. + +--- + +## 9. Residual risks & known limits + +1. **Center-machine memory** — see §7. Root/host access during a stream reads + live keys. +2. **No CSP / XSS hardening** — the panel does not currently ship a strict + Content-Security-Policy. A browser XSS could read a decrypted key while the + vault is unlocked. Run the panel behind a TLS reverse proxy and keep + dependencies updated. +3. **Plaintext RTMP ingest** — the ingest path (port 1935) is unencrypted and + bypasses the HTTP proxy by design. Keep it on a trusted LAN/VPN; do not expose + 1935 to the internet. +4. **Panel TLS** — the container serves HTTP on 8080; TLS termination is + expected at a reverse proxy (nginx-proxy-manager). +5. **Local auth mode** — `AUTH_MODE=local` is intended for development; OIDC is + the recommended production mode. +6. **No audit log** — grant/activate/invite events are not persisted for audit. +7. **Composition re-encode** — scene mode decodes and re-encodes feeds on the + server; this does not weaken key handling but is a CPU cost and a single + point of processing. + +--- + +## 10. Operational checklist + +- Set a stable, high-entropy `SESSION_SECRET`. +- Use `AUTH_MODE=oidc` behind Authelia with PKCE and matching `OIDC_REDIRECT_URI`. +- Terminate TLS at the reverse proxy; expose only the panel there, not 1935. +- Keep RTMP ingest on a private network. +- Leave `GRANT_TTL_MS` as short as operationally comfortable. +- Back up `dataDir` (it holds the only copy of wrapped keys; there is no recovery + without the passphrase). diff --git a/services/multistreaming/docs/SCENES.md b/services/multistreaming/docs/SCENES.md new file mode 100644 index 0000000..a15d008 --- /dev/null +++ b/services/multistreaming/docs/SCENES.md @@ -0,0 +1,684 @@ +# SCENES — Scene & Composition Design Contract + +Status: **authoritative spec** for Phase 3. Backend (`t2`) and frontend (`t3`) +implementation tasks consume this document and must not make design decisions +that contradict it. + +- Scope: `services/multistreaming` (CommonJS backend in `src/`, React/TS/shadcn + frontend in `web/src/`). +- Constraint: no runtime tests. Verification is `node --check` (backend) and + `npm run build` (web = `tsc -b && vite build`). + +--- + +## 1. Overview + +Today a room is a passthrough router: each published feed is remuxed +(`-c copy`) to every enabled output that routes that feed. Scenes add a second +programming mode for a room: + +1. A **scene** is a named, persisted composition for a room: a layout (grid or + picture-in-picture) that places live feeds, plus text/image overlays. +2. A scene has **scene outputs** (destinations) — one per platform account the + composed program is pushed to — each with its own **audio routing**. +3. Activating a scene switches the room from passthrough mode to composed mode. + In composed mode the room pushes the composed program (re-encoded) instead of + raw per-feed passthrough. Deactivating the scene restores passthrough. +4. **Passthrough mode (default) is unchanged**: with no composed scene active, + fan-out stays `-c copy` exactly as today. + +Security invariants are unchanged and extended in §8. + +--- + +## 2. Terminology + +| Term | Meaning | +|------|---------| +| feed | A single OBS ingest publishing to `rtmp://:1935/live/`. | +| live feed | A feed currently publishing (tracked from RTMP publish events). | +| output | Existing passthrough routing record (`feedId → accountId`). Unchanged. | +| scene | A named composition definition for a room. | +| scene output | A destination for the composed program (`sceneId → accountId`) with an audio mode. | +| slot | A position in the layout, referenced by index; holds a `feedId` or `null`. | +| overlay | A text or image element drawn on top of the composed video. | +| program audio | The mixed audio of all live feeds participating in the scene. | +| canvas | The composed video frame size (default 1920×1080). | + +--- + +## 3. Persisted data model + +`Store` gains three collections, exactly like the existing ones, normalized in +`_normalize` and written atomically by `_save`: + +```js +// store.js state shape additions +{ + scenes: [ Scene ], + sceneOutputs: [ SceneOutput ], + images: [ RoomImage ], +} +``` + +### 3.1 `Scene` + +```jsonc +{ + "id": "uuid", + "roomId": "uuid", + "ownerId": "uuid", // creator; informational only + "name": "Main show", // trimmed, 1..64 chars + "active": false, // at most one true per room + "layout": { + "type": "grid", // "grid" | "pip" + "columns": 2, // grid ONLY: integer 2..4 + "slots": ["feedId", null] // Array; see slot rules + }, + "overlays": [ + // text overlay + { "id": "uuid", "kind": "text", "text": "LIVE", "x": 32, "y": 32, + "fontSize": 48, "color": "#ffffff", "bold": true }, + // image overlay + { "id": "uuid", "kind": "image", "imageId": "uuid", "x": 16, "y": 844, + "width": 200, "height": 112, "opacity": 1.0 } + ], + "createdAt": 1700000000000, + "updatedAt": 1700000000000 +} +``` + +**Slot rules** + +- `slots` is an array indexed by slot number. Each element is a `feedId` string + (must be a feed in the same room) or `null` (empty). +- `grid`: `slots.length` ∈ 1..6 (the cell order, row-major). `columns` ∈ 2..4. +- `pip`: `slots.length` == 2 — index 0 is the **main** (fullscreen) feed, index 1 + is the **PiP** window feed. +- Unknown `feedId`, duplicate `feedId`, or out-of-range slot length is rejected + with HTTP 400 (see §6.2 validation). + +**Layout is always valid JSON with no secrets.** A scene never stores stream keys. + +### 3.2 `SceneOutput` + +```jsonc +{ + "id": "uuid", + "roomId": "uuid", + "sceneId": "uuid", // must be a scene in the same room + "accountId": "uuid", // must be an account in the room (accountsInRoom) + "enabled": true, + "audio": { "mode": "program" } // | { mode: "silent" } + // | { mode: "feed", "feedId": "uuid" } +} +``` + +- `audio.mode`: + - `"program"` — the mixed program audio (§5.4). + - `"silent"` — no audio stream (`-an`). + - `"feed"` — one specific feed's audio (`feedId` required, feed in the same room). +- Enabled scene outputs are the push destinations when the scene is active. + +### 3.3 `RoomImage` + +```jsonc +{ + "id": "uuid", + "roomId": "uuid", + "name": "logo.png", // sanitized, 1..64 chars + "mime": "image/png", // sniffed: image/png | image/jpeg | image/webp + "size": 48231, // decoded bytes + "path": "uploads/.", // server-generated; never user input + "createdAt": 1700000000000 +} +``` + +- Files live under `/uploads/`. The stored basename matches + `/^[a-f0-9-]{36}\.(png|jpg|webp)$/` (UUID + sniffed extension). The client + never chooses the path. + +### 3.4 Validation (server-side, authoritative) + +- Scene `name`: trimmed string, 1..64 chars. +- `layout.type`: `"grid"` or `"pip"` (else 400). +- Grid: `columns` integer 2..4; `slots` array length 1..6. +- PiP: `slots` array length exactly 2. +- Every non-null slot `feedId` must exist in `store.listFeeds(roomId)`. +- `overlays` array length ≤ 8. + - text: `text` trimmed non-empty, ≤ 256 chars; `x` ∈ [0,1920) integer, + `y` ∈ [0,1080) integer; `fontSize` ∈ 8..144 integer; `color` matches + `/^#[0-9a-fA-F]{6}$/`; `bold` optional boolean. + - image: `imageId` must be a `RoomImage` in the same room; `x`,`y`,`width`, + `height` integers with `width`,`height` ∈ 16..1920/1080 respectively; + `opacity` optional ∈ [0,1]. +- `sceneOutput`: `sceneId` in room; `accountId` in `store.accountsInRoom(roomId)`; + `audio.mode` one of the three; `mode === "feed"` requires a valid room `feedId`. +- Image upload: decoded size ≤ `SCENE_MAX_IMAGE_BYTES` (default 1 MiB); type + sniffed from magic bytes (PNG `89 50 4E 47`, JPEG `FF D8 FF`, WebP + `RIFF....WEBP`). Client-declared MIME is **not** trusted. + +### 3.5 Pruning (deletion hooks) + +- `removeFeed(id)` additionally: in every scene of the same room set each slot + element equal to `id` to `null`; for every sceneOutput whose + `audio.mode === "feed"` and `audio.feedId === id`, set `audio = { mode: "silent" }`. +- `removeAccount(id)` additionally: delete sceneOutputs with `accountId === id` + (mirrors existing output pruning). +- `removeScene(id)`: delete sceneOutputs with `sceneId === id`. If it was the + active scene, no scene is active; reconcile stops the scene fanout. +- `removeImage(id)`: delete the file, the record, and every overlay item + (`kind === "image"`) across the room's scenes that references `imageId`. +- `_normalize`: add the three arrays; defensively strip scene slots referencing + unknown feeds, sceneOutputs referencing unknown scenes/accounts, and overlay + image items referencing unknown images; if more than one scene is `active` + per room, keep the first and clear the rest. + +--- + +## 4. Composition engine — `src/compose.js` + +New module. It contains **all** FFmpeg knowledge and produces argument arrays; +`fanout.js` stays lifecycle-only. Exports: + +- `CANVAS` — `{ width, height, fps, videoBitrate, audioBitrate }` resolved from + config (§9). +- `escapeDrawtext(text)` and `escapeFilterPath(path)` — escaping helpers (§5.5). +- `buildScenePlan({ scene, liveFeeds, getInputUrl, destinations, config })` → + a plan object or `null` (null when zero live feeds, i.e. nothing to compose). + +```js +// buildScenePlan output (passthrough to fanout, never persisted) +{ + inputs: [ { feedId, url } ], // live feeds in slot order + outputs: [ { outputId, url, args, safeArgs } ] // one ffmpeg argv per destination +} +``` + +`fanout.js` spawns **one ffmpeg child per `outputs[]` entry**, each reading all +`inputs[]` and applying its own audio `-map` (see §5.4). This mirrors the +existing per-output child model and keeps restart/reconcile reuseable. It means +video is re-encoded once per destination — an accepted, documented trade-off for +a small self-hosted tool (per-output audio routing cannot be shared through a +single `tee` muxer without identical audio). + +### 4.1 Inputs + +For a scene with `slots`, the live inputs are the slots' feeds that are +currently live, **in slot order** (null/non-live slots skipped). Input index `i` +(0-based) corresponds to `inputs[i]`. + +Source URL for feed with `streamKey`: `rtmp://127.0.0.1:/live/`. +Destination URLs come from `destinations` (resolved by `index.js` from grants, +exactly like today) — **never stored**. + +### 4.2 Layouts + +Canvas = `SCENE_WIDTH` × `SCENE_HEIGHT` (default 1920×1080), fps = `SCENE_FPS` +(default 30). `n` = number of live inputs. + +Normalization chain for input `i` to a target cell `Wi×Hi`: + +``` +[i:v] scale=Wi:Hi:force_original_aspect_ratio=decrease, + pad=Wi:Hi:(ow-iw)/2:(oh-ih)/2, + settb=AVTB, fps=30, format=yuv420p [v_i] +``` + +(`fps` uses the canvas fps constant.) + +**Grid, n > 1** — `c = min(columns, n)`, `rows = ceil(n / c)`, +`cellW = 1920 / c`, `cellH = 1080 / rows`. For input `i`: +`x_i = (i % c) * cellW`, `y_i = floor(i / c) * cellH`. + +``` +[v_0][v_1]...[v_{n-1}] xstack=inputs=n:layout=x_0_y_0|x_1_y_1|...|x_{n-1}_y_{n-1}:fill=black [comp] +``` + +**Grid, n == 1** — single fullscreen tile (no xstack): the normalization chain +targets `1920x1080` and its label is `[comp]`. + +**PiP, both slots live** (`m` = main input index, `p` = pip input index): + +``` +[m:v] scale=1920:1080:force_original_aspect_ratio=decrease, pad=1920:1080:(ow-iw)/2:(oh-ih)/2, settb=AVTB, fps=30, format=yuv420p [main] +[p:v] scale=480:270:force_original_aspect_ratio=decrease, pad=480:270:(ow-iw)/2:(oh-ih)/2, settb=AVTB, fps=30, format=yuv420p [pip] +[main][pip] overlay=x=main_w-overlay_w-16:y=16:format=auto [comp] +``` + +**PiP, only one slot live** — promote that feed to fullscreen (same chain as +grid n==1) and label it `[comp]`. + +**PiP, no slots live** — `buildScenePlan` returns `null`. + +### 4.3 Overlays + +Overlays are applied to `[comp]` in array order, chaining labels +`[comp] → [ov_1] → [ov_2] → … → [vout]`. If there are no overlays, +`[comp]` is `[vout]`. + +**Text overlay k** (`prev` = previous label, `fontpath` = `SCENE_FONT` or +`SCENE_FONT_BOLD` when `bold`): + +``` +[prev] drawtext=text='':x=:y=:fontsize=: + fontcolor=:fontfile='':expansion=none [ov_k] +``` + +**Image overlay k**: + +``` +movie=filename='', scale=: [img_k]; +[prev][img_k] overlay=x=:y=:format=auto [ov_k] +``` + +- `absImagePath` = `path.join(config.dataDir, image.path)`. +- Image scale is exact `scale=w:h` (no aspect preservation — the editor controls + both dimensions). + +### 4.4 Audio routing + +Program audio (built **only if** at least one destination uses `mode: "program"` +and `n ≥ 1`): + +``` +n == 1: [0:a] aresample=48000, aformat=sample_fmts=fltp:channel_layouts=stereo [aout] +n >= 2: [0:a][1:a]...[n-1:a] amix=inputs=n:duration=first:dropout_transition=2, + aresample=48000, aformat=sample_fmts=fltp:channel_layouts=stereo [aout] +``` + +**Assumption (documented):** every participating feed publishes H.264 video + +AAC audio (OBS defaults). Video-only or audio-only feeds are out of scope for +composition; the `-map :a?` form still tolerates a missing audio stream for +`mode: "feed"`. + +Per-destination audio args: + +| mode | live? | args | +|------|-------|------| +| `program` | `[aout]` exists | `-map [vout] -map [aout] -c:a aac -b:a -ar 48000` | +| `program` | `[aout]` absent | `-map [vout] -an` | +| `silent` | — | `-map [vout] -an` | +| `feed` (feed live at input index `j`) | yes | `-map [vout] -map j:a? -c:a aac -b:a -ar 48000` | +| `feed` (feed not live) | no | `-map [vout] -an` | + +### 4.5 Full ffmpeg argv (per destination) + +``` +ffmpeg -hide_banner -loglevel warning + -i -i ... -i + -filter_complex + -map [vout]