From dcc347e1ea06619e1c2b1a454495f1992351ef05 Mon Sep 17 00:00:00 2001 From: Brandyn / Techy Date: Sat, 31 Oct 2026 15:22:15 -0500 Subject: [PATCH] =?UTF-8?q?Phase=207+11:=20token-based=20publishing=20?= =?UTF-8?q?=E2=80=94=20MediaMTX=20authMethod=20http=20->=20dumpd=20/mtx-au?= =?UTF-8?q?th=20(bridge-only=20port=208081);=20dumpd=20mints=2012=20h=20HM?= =?UTF-8?q?AC=20tokens=20for=20Discord-authenticated=20streamers;=20dock?= =?UTF-8?q?=20rewritten=20(no=20passwords,=20naming=20rules=20with=20live?= =?UTF-8?q?=20preview,=20prefix/suffix,=20manual-setup=20accordion=20with?= =?UTF-8?q?=20hidden=20token);=20portal=20uses=20tokens;=20configure.py=20?= =?UTF-8?q?drops=20streamers;=20MediaMTX=20hot-reload,=20Caddy=20reload=20?= =?UTF-8?q?=E2=80=94=20no=20stream=20interruption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- .gitignore | 2 +- CommunityDiscordMedia.md | 9 +- README.md | 41 ++++++--- STATUS.md | 9 +- bin/dumpd.py | 106 +++++++++++++++++++---- configure.py | 85 +++++++++---------- dock/index.html | 178 ++++++++++++++++++++++++++------------- firewall/nftables.conf | 3 + live/src/GoLive.jsx | 3 +- live/src/broadcast.js | 6 +- systemd/dumpd.service | 3 + 11 files changed, 301 insertions(+), 144 deletions(-) diff --git a/.gitignore b/.gitignore index 445fa64..f11fe10 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,8 @@ __pycache__/ .env bot/app/__pycache__/ *.pyc -streamers.json # generated by configure.py on the server — never commit or scp these mediamtx.yml copyparty.conf Caddyfile +stream-token.secret diff --git a/CommunityDiscordMedia.md b/CommunityDiscordMedia.md index 74c65bc..827900e 100644 --- a/CommunityDiscordMedia.md +++ b/CommunityDiscordMedia.md @@ -200,8 +200,7 @@ Checklist: admin = member of `admins`; drop `dump-users.txt` - [ ] dock: `Dump tonight` sends no credentials; session cookie carries identity -### Phase 7 — dock polish + cleanup -(partly done: dumpd `/me/stream-key` exists and the live portal's Go Live uses it; the OBS dock still holds its own copy of the key) +### Phase 7 — dock polish + cleanup (built with phase 11; apply pending) - [ ] `dumpd.py` gains `GET /me` → `{user, groups, stream_key}` for the logged-in streamer; dock fills OBS settings from it — nobody types a stream password again - [ ] `configure.py` shrinks: hostnames + streamers only (editors and admin live in Authentik) @@ -264,7 +263,11 @@ Same pattern as Jellyfin: Authentik is the identity, Gitea trusts it. - [ ] policy on the Authentik application: who may reach Gitea at all (e.g. `admins` + a `devs` group) - [ ] test with a non-admin Discord user; confirm admin mapping via `admins` group -### Phase 11 — MediaMTX auth through Authentik (no stream keys anywhere) +### Phase 11 — MediaMTX auth through Authentik (no stream keys anywhere) (built; apply pending) +Implemented variant: **short-lived HMAC tokens minted by dumpd** for Discord-authenticated members of +`streamers` (no Authentik app passwords — "Discord only" decision). Dock and portal mint a token per Go Live; +manual setups get it from the dock's accordion. MediaMTX `authMethod: http` → dumpd `/mtx-auth` on 8081 +(Docker bridge only). Hot-reloaded: no MediaMTX restart. Goal: nobody holds a MediaMTX password. "May publish" = Authentik identity + `streamers` group (filled by Discord roles); the publish path must start with the user's own name. - MediaMTX `authMethod: http`, `authHTTPAddress: http://127.0.0.1:8080/mtx-auth` (dumpd becomes the diff --git a/README.md b/README.md index 9e783fc..67fbd90 100644 --- a/README.md +++ b/README.md @@ -90,16 +90,25 @@ until Docker is restarted. ## 5. Streamer setup (once) -OBS → Settings → Stream → Custom: Server `rtmp://media.example.com:1935/`, key anything (the dock sets it). -OBS → Tools → WebSocket Server Settings → enable, set a password. -OBS → Docks → Custom Browser Docks → `https://media.example.com/dock/` → log in with Discord once (the dock -is behind Authentik like everything else on that host). -In the dock: fill host/user/pass/OBS-ws-password once (stored in the dock's localStorage). -Per stream: type a title, click **Go Live**. After a bad night: **Dump tonight** (dumps the night of the -Discord account you are logged in as; `admins`/`media-admins` may dump anyone's). +Being a streamer = being in the Authentik **streamers** group (a Discord role via the roles→groups policy). +There are no stream passwords or keys to hand out: the dock mints a **12-hour publish token** for the +logged-in Discord user each time Go Live is pressed and sets it as the OBS stream key itself. -Stream key format the dock produces: `techy/Title?user=techy&pass=...` -SRT equivalent if preferred: `srt://media.example.com:8890?streamid=publish:techy/Title:techy:PASS` +OBS → Tools → WebSocket Server Settings → enable, set a password. +OBS → Docks → Custom Browser Docks → `https://media.example.com/dock/` → log in with Discord once. +In the dock's Settings: ingest host (prefilled), OBS WebSocket password/port, optional title prefix/suffix +(`{date}` → YYYY-MM-DD, e.g. prefix `HelmetCam-` or suffix `-{date}`). +Per stream: type a title, click **Go Live**. The dock shows the final stream name (`/`) as you type +and enforces the naming rules the metadata relies on: letters, digits, `_ . -` only, no double underscore +(it separates title and timestamp in filenames), no leading dot, max 64 chars. +After a bad night: **Dump tonight** (dumps the night of the account you are logged in as; `admins`/`media-admins` +may dump anyone's). + +Manual OBS/other software: dock → **Manual OBS setup** shows the RTMP server, the stream key and the SRT URL +with the token hidden (Show / Copy / Fresh token). Tokens expire after 12 h; a running stream is not cut when +its token expires, but a reconnect needs a fresh one. + +Browser streaming without OBS: the live portal's **Go Live** (Windows Chrome 141+ for window audio + GPU encode). ## 5b. Syncthing hardening (do once, in the server's Syncthing UI) @@ -208,9 +217,8 @@ asked by `configure.py --full`) can post when a stream starts. embedded outpost → group policy (e.g. everyone in the server, or `streamers`+`editors`+`admins`). 3. Dev box: `./deployLive.sh` builds `live/` and copies `dist/` to `/srv/live` (Caddy serves it directly). -**Go Live** button (window audio: Windows + Chrome/Edge 141+; Linux/Mac get video only): broadcast a window from the browser (no OBS) as `<user>/HelmetCam-<date>` — key comes -from `/me/stream-key` (dumpd reads `/etc/vanguard/streamers.json`, identity from the Authentik header), so a -streamer's Authentik username must equal their MediaMTX name. +**Go Live** button (window audio: Windows + Chrome/Edge 141+; Linux/Mac get video only): broadcast a window from the browser (no OBS) as `<user>/HelmetCam-<date>` — a fresh publish token comes +from `/me/stream-token` for the logged-in Discord user (member of `streamers`). Bandwidth: no transcoding, every viewer gets the source bitrate (~15 Mbps) → ~30 concurrent viewers on the 500 Mbps line. For a crowd, restream to Twitch from OBS. @@ -272,6 +280,15 @@ Copy `home/rclone-archive.sh` to the home machine, set the vars, run from cron n It connects over SFTP as `debian` (all media files are owned by uid 1000 so it can delete them). Pulls everything older than 15 days off the server, including the hidden `.src/` raw files. +## Publish authentication (no passwords) + +MediaMTX asks dumpd (`POST /mtx-auth` on 127.0.0.1:8081, reachable only from Docker's bridge) for every +connection. Reads/playback are allowed (viewers are gated by Caddy + Authentik). Publishing needs a token +minted by `GET /me/stream-token` behind Authentik for a member of `streamers`/`admins`/`media-admins`; dumpd +checks the HMAC signature (`/etc/vanguard/stream-token.secret`, generated into `.env` as `STREAM_TOKEN_SECRET`), +the expiry, and that the path starts with that user's name. Changing this needs **no MediaMTX restart**: +MediaMTX hot-reloads `mediamtx.yml`; `configure.py` reloads Caddy in place and only restarts copyparty. + ## Layout on the server /srv/media/streams/<user>/<Title>/.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty) diff --git a/STATUS.md b/STATUS.md index 28adad4..82d260b 100644 --- a/STATUS.md +++ b/STATUS.md @@ -25,7 +25,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea | Who | Activity | Where | Login | |---|---|---|---| -| Streamer | Type a title, Go Live, Stop. "Dump tonight" if it was junk (3-day undo) | OBS browser dock `https://ovh1…/dock/` | Discord login in the dock (once); stream password in dock settings for the OBS key | +| Streamer | Type a title (live preview + naming rules), Go Live, Stop. "Dump tonight" if it was junk (3-day undo). Manual-setup accordion with endpoints and a hidden token | OBS browser dock `https://ovh1…/dock/` | Discord login in the dock (once); dock mints a 12 h token per Go Live — **no passwords** (pending apply) | | Gamer | Nothing; clips save where they always did | their clipper + Syncthing, paired once | — | | Editor | Browse by streamer/title, scrub in browser, download originals, upload finals | copyparty `https://ovh1…/` | Discord (Authentik); rights by group `editors` / `media-admins` — **pending apply** | | Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) | @@ -33,7 +33,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea | Streamer (Windows) | **Go Live** from the browser: pick the WarDogs window (+audio on Windows Chrome 141+; Linux/Mac video only), streams as `<user>/HelmetCam-<date>`; no OBS needed | Live portal → Go Live | Discord; key fetched for the logged-in user — **verified end to end** (publish → `.live` → sweep → title folder) | | Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | **local account** (Discord later) | | Bot dev | Push code, restart container, read logs; web UI at `https://bot.…/` | Gitea + compose; `bot/AGENT.md` | web UI behind Discord (Authentik), once its provider exists | -| Admin | Add/remove streamers & editors, hostnames, bot token | `./configure.py` | ssh | +| Admin | Hostnames, bot token/guild, webhook; streamers/editors are Discord roles → Authentik groups | `./configure.py` | ssh | | Admin | Pair a gamer's device, approve their folder | Syncthing UI `https://ovh1…/sync/` | Discord + group `media-admins`, then Syncthing's GUI password | | Admin | Groups, who may log in, app access | Authentik `https://auth.…/` | Discord or akadmin | | Admin | Install/upgrade the Jellyfin plugin | `./installJellyfinPlugin.sh` | ssh | @@ -43,7 +43,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea | Service | Role | State | |---|---|---| -| MediaMTX | RTMP/SRT ingest, records fMP4 | live | +| MediaMTX | RTMP/SRT ingest, records fMP4; publish auth delegated to dumpd (tokens) | live; auth switch **pending apply** (hot-reload, no restart) | | copyparty | browse/scrub/download/WebDAV/finals upload | behind Authentik, group permissions — **built, pending apply** (currently local accounts) | | dumpd + dock | dump-tonight endpoint, OBS dock page | identity from Authentik headers — **built, pending apply** | | Syncthing | clip intake, UI at `/sync/` | UI behind Authentik + `media-admins` — **built, pending apply** | @@ -69,14 +69,13 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea 2. **Register the runner** (`./registerGiteaRunner.sh`). 3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty. 4. **Apply phase 5+6** (copyparty, dock, dump, /sync behind Authentik; group permissions). Authentik side: group `media-admins` with techy in it. Decision: Discord login only → no WebDAV; browser downloads (zip/tar) instead. -5. Dock fetches the caller's own stream key (phase 7) — or skip straight to phase 11 (no keys). +5. **Apply phase 7+11** (token publish auth): `configure.py` apply (MediaMTX hot-reloads; no stream interruption), new dumpd + unit, firewall rule for 8081 from Docker, dock upload, portal deploy. Then every streamer must be in the Authentik `streamers` group. 6. **Gitea login via Authentik** (OIDC source, auto-register, `admins` → Gitea admin). 7. **Housekeeping**: pin `AUTHENTIK_TAG` in `.env`, rotate the Discord client secret that was in a screenshot, nightly Postgres dump, Jellyfin login branding (Discord button first), check a non-admin Discord user's Jellyfin view. 8. **Medal filename pattern** in the plugin's timestamp parser — need one sample filename. 9. **Home archive puller** on the home box. 10. **Plugin repository** served by Caddy so Jellyfin updates the plugin from its catalog. 11. **Clip submissions** (Medal/Twitch/YouTube links, Discord uploads) — belongs to the bot. -12. **MediaMTX auth through Authentik** (plan phase 11): no stream keys; Go Live uses the Authentik JWT, OBS uses an Authentik app password; "streamer" = group membership from a Discord role. 13. **SYS-3** when transcoding or sweep speed hurts: Quick Sync, flip the plugin's policy toggle off. ## Verified behaviours worth remembering diff --git a/bin/dumpd.py b/bin/dumpd.py index bcb7fb2..0dfd8db 100755 --- a/bin/dumpd.py +++ b/bin/dumpd.py @@ -4,13 +4,17 @@ POST /dump identity from X-Authentik-Username / X-Authentik-Groups (set by Caddy after Authentik) body (optional JSON): {"night":"YYYY-MM-DD","user":"name"} GET /dump -> lists what a dump would move (dry run) -GET /me/stream-key -> the caller's MediaMTX credentials (Go Live) +GET /me/stream-token -> short-lived publish token for the caller (member of streamers/admins); the dock + and the live portal set it as the OBS/WHIP credential. No stored passwords anywhere. +POST /mtx-auth -> MediaMTX authHTTP hook: allows read/playback; allows publish iff user/pass carries a valid + token whose user owns the path prefix. Tokens are HMAC-signed with /etc/vanguard/stream-token.secret. "Night" = everything the user recorded from 17:00 Eastern on <night> until 02:00 Eastern the next day. Default night: today if it is 17:00 or later, else yesterday (so 00:00-02:00 still counts as last night). Users may dump only their own prefix; members of the admins / media-admins groups may pass "user" to dump anyone, or "all". """ -import json, os, re, shutil, sys +import base64, hashlib, hmac, json, os, re, shutil, sys +import time as _time from datetime import datetime, timedelta, time from zoneinfo import ZoneInfo from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -20,11 +24,14 @@ ROOT = Path(os.environ.get("MEDIA_ROOT", "/srv/media")) SRC_DIRS = [ROOT / "streams", ROOT / "clips"] TRASH = ROOT / "trash" MARK = ROOT / ".markers" -STREAMERS_FILE = Path(os.environ.get("STREAMERS_FILE", "/etc/vanguard/streamers.json")) # {"techy": "pass", ...} written by configure.py +TOKEN_SECRET_FILE = Path(os.environ.get("STREAM_TOKEN_SECRET_FILE", "/etc/vanguard/stream-token.secret")) +TOKEN_TTL_H = float(os.environ.get("STREAM_TOKEN_TTL_HOURS", "12")) +PUBLISH_GROUPS = set(g.strip() for g in os.environ.get("PUBLISH_GROUPS", "streamers,admins,media-admins").split(",") if g.strip()) AUTH_USER_HEADER = os.environ.get("AUTH_USER_HEADER", "X-Authentik-Username") AUTH_GROUPS_HEADER = os.environ.get("AUTH_GROUPS_HEADER", "X-Authentik-Groups") ADMIN_GROUPS = set(g.strip() for g in os.environ.get("DUMP_ADMIN_GROUPS", "admins,media-admins").split(",") if g.strip()) -LISTEN = ("127.0.0.1", 8080) +LISTEN = ("127.0.0.1", 8080) # /dump, /me/* — only via Caddy (identity headers) +AUTH_LISTEN = (os.environ.get("MTX_AUTH_BIND", "0.0.0.0"), int(os.environ.get("MTX_AUTH_PORT", "8081"))) # /mtx-auth only — reachable from Docker's bridge (firewall), used by MediaMTX TZ = ZoneInfo(os.environ.get("DUMP_TZ", "America/New_York")) NIGHT_START = time(17, 0) # 5 pm NIGHT_END = time(2, 0) # 2 am next day @@ -83,11 +90,39 @@ def do_move(files, night): d.rmdir() return moved, size -def load_streamers(): +def token_secret(): try: - return json.loads(STREAMERS_FILE.read_text()) - except (OSError, json.JSONDecodeError): - return {} + sec = TOKEN_SECRET_FILE.read_bytes().strip() + if len(sec) >= 16: return sec + except OSError: + pass + return None + +def mint_token(user): + """user|exp signed: <b64url(user|exp)>.<hmac hex[:40]> — URL/RTMP/SRT-safe characters only.""" + sec = token_secret() + if not sec: raise RuntimeError("no stream token secret") + exp = int(_time.time() + TOKEN_TTL_H * 3600) + payload = f"{user}|{exp}".encode() + sig = hmac.new(sec, payload, hashlib.sha256).hexdigest()[:40] + return base64.urlsafe_b64encode(payload).decode().rstrip("=") + "." + sig, exp + +def verify_token(token): + """-> username or None""" + sec = token_secret() + if not sec or not token or "." not in token: return None + b64, sig = token.rsplit(".", 1) + try: + payload = base64.urlsafe_b64decode(b64 + "=" * (-len(b64) % 4)) + except Exception: + return None + if not hmac.compare_digest(hmac.new(sec, payload, hashlib.sha256).hexdigest()[:40], sig): return None + try: + user, exp = payload.decode().split("|", 1) + if int(exp) < _time.time(): return None + except Exception: + return None + return user.lower() class H(BaseHTTPRequestHandler): server_version = "dumpd/1" @@ -124,17 +159,22 @@ class H(BaseHTTPRequestHandler): return req_user or auth_user # admins default to their own night; pass "all" or a name for others return auth_user def do_GET(self): - if self.path.startswith("/me/stream-key"): - # Only reachable through Caddy behind Authentik forward-auth, which injects the username header. - # dumpd binds to loopback, so a request carrying this header is one Caddy already authenticated. - who = (self.headers.get(AUTH_USER_HEADER) or "").strip().lower() + if self.server.server_port == AUTH_LISTEN[1]: + return self._json(404, {"error": "not found"}) # the auth port serves POST /mtx-auth only + if self.path.startswith("/me/stream-token") or self.path.startswith("/me/stream-key"): + # Only reachable through Caddy behind Authentik forward-auth, which injects the identity headers. + who, _ = identity(self.headers) if not who: return self._json(401, {"error": "not authenticated"}) - streamers = load_streamers() - pw = streamers.get(who) - if not pw: - return self._json(403, {"error": f"no stream key for '{who}' — ask the admin to add you as a streamer with that exact name"}) - return self._json(200, {"user": who, "pass": pw}) + groups = {g.strip() for g in re.split(r"[|,]", self.headers.get(AUTH_GROUPS_HEADER) or "") if g.strip()} + if not (groups & PUBLISH_GROUPS): + return self._json(403, {"error": f"'{who}' is not in a publishing group ({', '.join(sorted(PUBLISH_GROUPS))}) — ask an admin for the Discord role"}) + try: + tok, exp = mint_token(who) + except RuntimeError as e: + return self._json(500, {"error": str(e)}) + # "pass" kept for the dock/portal: the token IS the password for MediaMTX + return self._json(200, {"user": who, "pass": tok, "token": tok, "expires": exp, "ttl_hours": TOKEN_TTL_H}) if not self.path.startswith("/dump"): return self._json(404, {"error": "not found"}) u, is_admin = identity(self.headers) @@ -147,6 +187,33 @@ class H(BaseHTTPRequestHandler): "files": [str(f.relative_to(ROOT)) for f in files], "gb": round(sum(f.stat().st_size for f in files) / 1e9, 2)}) def do_POST(self): + if self.server.server_port == AUTH_LISTEN[1] and not self.path.startswith("/mtx-auth"): + return self._json(404, {"error": "not found"}) + if self.path.startswith("/mtx-auth"): + # MediaMTX authHTTP: 200 = allow, anything else = deny. Body: user, password, token, ip, action, path, protocol, id, query + n = int(self.headers.get("Content-Length") or 0) + try: + req = json.loads(self.rfile.read(n) or b"{}") + except json.JSONDecodeError: + return self._json(400, {"error": "bad json"}) + action = req.get("action", "") + if action in ("read", "playback"): + return self._json(200, {"ok": True}) # viewers are gated by Caddy + Authentik + if action != "publish": + return self._json(401, {"error": f"action {action} not allowed"}) + cred = req.get("password") or req.get("token") or "" + if cred.lower().startswith("bearer "): cred = cred[7:] + if ":" in cred and "." in cred.split(":", 1)[1]: cred = cred.split(":", 1)[1] # "user:token" form + user = verify_token(cred) + path = (req.get("path") or "").lower() + if not user: + print(f"{datetime.now(TZ):%F %T} mtx-auth DENY publish path={path} ip={req.get('ip')} (bad/expired token)", flush=True) + return self._json(401, {"error": "invalid or expired stream token"}) + if not re.fullmatch(r"[a-z0-9_.-]+/[a-z0-9_.-]+", path) or not path.startswith(user + "/"): + print(f"{datetime.now(TZ):%F %T} mtx-auth DENY publish user={user} path={path} (not their prefix)", flush=True) + return self._json(401, {"error": "path must be <your-username>/<Title>"}) + print(f"{datetime.now(TZ):%F %T} mtx-auth allow publish user={user} path={path} ip={req.get('ip')}", flush=True) + return self._json(200, {"ok": True}) if not self.path.startswith("/dump"): return self._json(404, {"error": "not found"}) u, is_admin = identity(self.headers) @@ -169,5 +236,8 @@ class H(BaseHTTPRequestHandler): if __name__ == "__main__": TRASH.mkdir(exist_ok=True); MARK.mkdir(exist_ok=True) - print(f"dumpd listening on {LISTEN[0]}:{LISTEN[1]} tz={TZ.key} night={NIGHT_START:%H:%M}-{NIGHT_END:%H:%M}", flush=True) + import threading + print(f"dumpd listening on {LISTEN[0]}:{LISTEN[1]} (dump, me) and {AUTH_LISTEN[0]}:{AUTH_LISTEN[1]} (mtx-auth) tz={TZ.key} night={NIGHT_START:%H:%M}-{NIGHT_END:%H:%M} token-secret={'ok' if token_secret() else 'MISSING'}", flush=True) + auth_srv = ThreadingHTTPServer(AUTH_LISTEN, H) + threading.Thread(target=auth_srv.serve_forever, daemon=True).start() ThreadingHTTPServer(LISTEN, H).serve_forever() diff --git a/configure.py b/configure.py index 642d81e..6581817 100755 --- a/configure.py +++ b/configure.py @@ -11,7 +11,7 @@ Reads : Caddyfile (hostnames), mediamtx.yml (streamers), .env (editors/admins are Authentik groups now: editors, media-admins, admins — nothing local) (these are GENERATED on the server and gitignored; *.example.* in the repo are reference only — never scp them over the live ones: that is how streamers got wiped once) -Writes : Caddyfile, mediamtx.yml, copyparty.conf, streamers.json -> /etc/vanguard, .env (generated secrets + +Writes : Caddyfile, mediamtx.yml, copyparty.conf, stream-token.secret -> /etc/vanguard, .env (generated secrets + Discord bot token/guild), folders, restarts Set VG_DRY=1 to write files only (no sudo / docker / folders). """ @@ -288,6 +288,7 @@ def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", b env.setdefault("APEX_HOST", "") # bare domain to serve (A record -> this box); empty = skip env.setdefault("APEX_REDIRECT", "") # where the bare domain sends people, e.g. https://watch.HOST/ env.setdefault("SYNC_REQUIRE_GROUP", "media-admins") # Caddy gate for /sync on the main host + env.setdefault("STREAM_TOKEN_SECRET", secrets.token_urlsafe(48)) # signs publish tokens (dumpd) env.setdefault("COPYPARTY_IDP_KEY", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32]) # Caddy->copyparty idp-h-key env.setdefault("BOT_REQUIRE_GROUP", "bot-users") # Caddy 403s anyone not in this Authentik group; empty = any logged-in member env.setdefault("LIVE_REQUIRE_GROUP", "") @@ -310,14 +311,19 @@ def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", b Path(".env").write_text("".join(f"{k}={v}\n" for k, v in env.items())) os.chmod(".env", 0o600) -def write_mediamtx(streamers, public_host=""): - users = "".join(f""" - user: {u} - pass: {p} - permissions: - - action: publish - path: ~^{u}/[A-Za-z0-9_.-]+$ -""" for u, p in streamers.items()) or " []\n" - alt = "|".join(streamers) or "nobody" +def docker_gateway(): + """Host IP as seen from the compose network: MediaMTX (bridge) calls dumpd's /mtx-auth there.""" + if DRY: return "172.18.0.1" + for net in ("vanguard-stream_default", "vanguard_default"): + try: + out = subprocess.run(["docker", "network", "inspect", net, "-f", "{{(index .IPAM.Config 0).Gateway}}"], capture_output=True, text=True, timeout=10) + if out.returncode == 0 and out.stdout.strip(): return out.stdout.strip() + except Exception: + pass + return "172.18.0.1" + +def write_mediamtx(public_host=""): + auth_gw = docker_gateway() Path("mediamtx.yml").write_text(f"""# MediaMTX — ingest only. No playback endpoints exposed. (generated by configure.py) logLevel: info @@ -347,17 +353,16 @@ srt: yes srtAddress: :8890 # ---- auth ------------------------------------------------------------- -# Each streamer may publish only under their own prefix. -# RTMP key: <user>/Title?user=<user>&pass=<pass> -# SRT: srt://HOST:8890?streamid=publish:<user>/Title:<user>:<pass> -authMethod: internal -authInternalUsers: - - user: any - permissions: - - action: read - - action: playback - - action: api -{users} +# Publishing is authorised by dumpd (/mtx-auth) with short-lived tokens minted for Discord-authenticated +# members of the streamers group. Reads are allowed; viewers are gated by Caddy + Authentik. No passwords. +# MediaMTX hot-reloads this file: changing auth needs no restart. +authMethod: http +authHTTPAddress: http://{auth_gw}:8081/mtx-auth +authHTTPExclude: + - action: api + - action: metrics + - action: pprof + # ---- recording -------------------------------------------------------- pathDefaults: record: yes @@ -375,8 +380,8 @@ pathDefaults: runOnReadyRestart: no paths: - # any name under a streamer's prefix; inherits pathDefaults - ~^({alt})/[A-Za-z0-9_.-]+$: {{}} + # <username>/<Title>; dumpd decides who may publish where (token user == prefix) + ~^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$: {{}} """) def write_copyparty(): @@ -426,14 +431,6 @@ def write_copyparty(): rwmd: @media-admins """) -def write_streamers_json(streamers): - import json as _json - Path("streamers.json").write_text(_json.dumps({u.lower(): p for u, p in streamers.items()}, indent=2) + "\n") - -def write_streamers_json(streamers): - import json as _json - Path("streamers.json").write_text(_json.dumps({u.lower(): p for u, p in streamers.items()}, indent=2) + "\n") - def run(cmd, **kw): print(" $", " ".join(cmd)) if DRY: return 0 @@ -447,26 +444,29 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis if not yes(" continue anyway?", False): print("aborted; add streamers first ([s] in the menu)"); sys.exit(1) print("\n== writing configs") write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook) - write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host); write_mediamtx(streamers, domain); write_copyparty(); write_streamers_json(streamers) + write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host); write_mediamtx(domain); write_copyparty() + Path("stream-token.secret").write_text(read_env()["STREAM_TOKEN_SECRET"] + "\n"); os.chmod("stream-token.secret", 0o600) run(["sudo", "install", "-d", "-o", os.environ.get("SUDO_USER") or os.environ.get("USER") or "debian", "/srv/live"]) print("== app data folders") run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache", "/srv/appdata/authentik/media", "/srv/appdata/authentik/templates", "/srv/appdata/authentik/certs"]) run(["sudo", "install", "-d", "/srv/appdata/authentik/db", "/srv/appdata/authentik/redis", "/srv/appdata/mariadb"]) run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/discord-bot", "/srv/appdata/gitea", "/srv/appdata/act_runner"]) - print("== folders for streamers") + print("== folders for legacy streamer entries (MediaMTX creates folders on first publish anyway)") for u in streamers: run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", f"/srv/media/streams/{u}", f"/srv/media/clips/{u}"]) if not DRY and not Path(f"/srv/media/clips/{u}/.stignore").exists(): run(["sudo", "install", "-m", "0644", "-o", "1000", "-g", "1000", "syncthing/stignore", f"/srv/media/clips/{u}/.stignore"]) print("== dump users + restart dumpd") run(["sudo", "install", "-d", "-m", "0750", "/etc/vanguard"]) - run(["sudo", "install", "-m", "0600", "streamers.json", "/etc/vanguard/streamers.json"]) + run(["sudo", "install", "-m", "0600", "stream-token.secret", "/etc/vanguard/stream-token.secret"]) run(["sudo", "systemctl", "restart", "dumpd"]) print("== containers") dc = ["docker", "compose"] if (DRY or subprocess.call(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0) else ["sudo", "docker", "compose"] - run(dc + ["up", "-d", "--build"]) - run(dc + ["restart", "mediamtx", "copyparty", "caddy"]) + run(dc + ["up", "-d", "--build"]) # recreates only services whose compose definition changed + run(dc + ["restart", "copyparty"]) # copyparty re-reads its config on start + run(dc + ["exec", "caddy", "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]) # zero-downtime + print(" (mediamtx hot-reloads mediamtx.yml itself — live streams are not interrupted)") def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host=""): print(f"\n== domain : {domain or '(not set)'}") @@ -485,14 +485,9 @@ def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_ print(f" gitea : db gitea user gitea pass {env.get('GITEA_DB_PASSWORD','(run apply)')} (created on MariaDB first init, else ./createGiteaDb.sh)") print("== access : by Authentik group — editors (read streams/clips, write finals), media-admins (everything), admins (dump anyone)") print(" Discord login only — no WebDAV; downloads via the web UI") - print("== streamers:") - for u, p in streamers.items(): - print(f"\n {u} password {p}") - print(f" OBS server : rtmp://{domain or 'HOST'}:1935/") - print(f" OBS key : {u}/TITLE?user={u}&pass={p} (the dock sets this for them)") - print(f" SRT : srt://{domain or 'HOST'}:8890?streamid=publish:{u}/TITLE:{u}:{p}") - print(f" Dock URL : https://{domain or 'HOST'}/dock/ (settings: host={domain or 'HOST'}, user={u}, pass={p})") - if not streamers: print(" (none)") + print("== streamers: anyone in the Authentik 'streamers' group (Discord role). No passwords, no keys to hand out.") + print(f" OBS: add the dock https://{domain or 'HOST'}/dock/ in OBS, log in with Discord, Go Live — it mints a 12 h token") + print(f" and sets the stream key itself. Manual setups: dock → 'Manual OBS setup' (rtmp://{domain or 'HOST'}:1935/ + key).") print(f"\n editors log in with Discord at https://{domain or 'HOST'}/") print(f" verify: curl -sI https://{domain or 'HOST'}/dock/ | head -1 (expect 200)") print(f" curl -s -o /dev/null -w '%{{http_code}}\\n' https://{domain or 'HOST'}/dump (expect 401)\n") @@ -571,8 +566,8 @@ def normal(domain, admin_pw, streamers, editors): print(f"== MANAGE (domain {domain}; run --full to change hostnames/admin)") discord = (None, None) while True: - c = ask("\n[s]treamers [d]iscord bot token/guild [v]iew [a]pply & quit [q]uit without applying", "a").lower()[:1] - if c == "s": manage_users("streamer", streamers) + c = ask("\n[d]iscord bot token/guild [v]iew [a]pply & quit [q]uit without applying", "a").lower()[:1] + if c == "s": print(" streamers = the Authentik 'streamers' group (Discord role); nothing to manage here") elif c == "d": discord = ask_discord() elif c == "v": show(domain, admin_pw, streamers, editors) elif c == "a": return discord diff --git a/dock/index.html b/dock/index.html index 91e39d6..edd7c9a 100644 --- a/dock/index.html +++ b/dock/index.html @@ -5,29 +5,41 @@ <title>Vanguard Stream
-

Vanguard Stream

+

Vanguard Stream

- + +
stream name: —
+
@@ -37,58 +49,115 @@
Settings (saved in this dock) - - - + + +
+ +
+ Manual OBS setup (endpoints + token) +
RTMP server —
+
Stream key —
+
SRT URL —
+
Token •••••••••••• (hidden)
+ + + + +
Tokens are minted for your Discord login and expire; the Go Live button does all of this automatically.
+
diff --git a/firewall/nftables.conf b/firewall/nftables.conf index 88f5950..dc3fdda 100644 --- a/firewall/nftables.conf +++ b/firewall/nftables.conf @@ -55,6 +55,9 @@ table inet vanguard { tcp dport 3306 ct state new limit rate over 30/minute burst 20 packets drop tcp dport 3306 ct state new accept + # dumpd /mtx-auth for MediaMTX (container -> host gateway): only from Docker bridge interfaces + iifname { "docker0", "br-*" } tcp dport 8081 accept + # NOT opened on purpose — these bind to 127.0.0.1 only and are reached via Caddy or an ssh tunnel: # 9000 authentik · 8096 jellyfin · 3923 copyparty · 8384 syncthing UI · 8080 dumpd # Docker publishes them on loopback, so its NAT never forwards outside traffic to them either. diff --git a/live/src/GoLive.jsx b/live/src/GoLive.jsx index 192b354..580077f 100644 --- a/live/src/GoLive.jsx +++ b/live/src/GoLive.jsx @@ -65,8 +65,9 @@ export default function GoLive({ onExit }) {