Phase 7+11: token-based publishing — MediaMTX authMethod http -> dumpd /mtx-auth (bridge-only port 8081); dumpd mints 12 h HMAC tokens for Discord-authenticated streamers; dock rewritten (no passwords, naming rules with live preview, prefix/suffix, manual-setup accordion with hidden token); portal uses tokens; configure.py drops streamers; MediaMTX hot-reload, Caddy reload — no stream interruption

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:22:15 -05:00
parent 2a74cb7b40
commit dcc347e1ea
11 changed files with 301 additions and 144 deletions

2
.gitignore vendored
View File

@@ -4,8 +4,8 @@ __pycache__/
.env .env
bot/app/__pycache__/ bot/app/__pycache__/
*.pyc *.pyc
streamers.json
# generated by configure.py on the server — never commit or scp these # generated by configure.py on the server — never commit or scp these
mediamtx.yml mediamtx.yml
copyparty.conf copyparty.conf
Caddyfile Caddyfile
stream-token.secret

View File

@@ -200,8 +200,7 @@ Checklist:
admin = member of `admins`; drop `dump-users.txt` admin = member of `admins`; drop `dump-users.txt`
- [ ] dock: `Dump tonight` sends no credentials; session cookie carries identity - [ ] dock: `Dump tonight` sends no credentials; session cookie carries identity
### Phase 7 — dock polish + cleanup ### Phase 7 — dock polish + cleanup (built with phase 11; apply pending)
(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)
- [ ] `dumpd.py` gains `GET /me` → `{user, groups, stream_key}` for the logged-in streamer; - [ ] `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 dock fills OBS settings from it — nobody types a stream password again
- [ ] `configure.py` shrinks: hostnames + streamers only (editors and admin live in Authentik) - [ ] `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) - [ ] 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 - [ ] 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 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. 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 - MediaMTX `authMethod: http`, `authHTTPAddress: http://127.0.0.1:8080/mtx-auth` (dumpd becomes the

View File

@@ -90,16 +90,25 @@ until Docker is restarted.
## 5. Streamer setup (once) ## 5. Streamer setup (once)
OBS → Settings → Stream → Custom: Server `rtmp://media.example.com:1935/`, key anything (the dock sets it). Being a streamer = being in the Authentik **streamers** group (a Discord role via the roles→groups policy).
OBS → Tools → WebSocket Server Settings → enable, set a password. There are no stream passwords or keys to hand out: the dock mints a **12-hour publish token** for the
OBS → Docks → Custom Browser Docks → `https://media.example.com/dock/` → log in with Discord once (the dock logged-in Discord user each time Go Live is pressed and sets it as the OBS stream key itself.
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).
Stream key format the dock produces: `techy/Title?user=techy&pass=...` OBS → Tools → WebSocket Server Settings → enable, set a password.
SRT equivalent if preferred: `srt://media.example.com:8890?streamid=publish:techy/Title:techy:PASS` 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 (`<you>/<Title>`) 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) ## 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`). 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). 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 **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-key` (dumpd reads `/etc/vanguard/streamers.json`, identity from the Authentik header), so a from `/me/stream-token` for the logged-in Discord user (member of `streamers`).
streamer's Authentik username must equal their MediaMTX name.
Bandwidth: no transcoding, every viewer gets the source bitrate (~15 Mbps) → ~30 concurrent viewers on 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. 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). 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. 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 ## Layout on the server
/srv/media/streams/<user>/<Title>/.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty) /srv/media/streams/<user>/<Title>/.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty)

View File

@@ -25,7 +25,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Who | Activity | Where | Login | | 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 | — | | 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** | | 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) | | 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) | | 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) | | 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 | | 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 | 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 | Groups, who may log in, app access | Authentik `https://auth.…/` | Discord or akadmin |
| Admin | Install/upgrade the Jellyfin plugin | `./installJellyfinPlugin.sh` | ssh | | 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 | | 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) | | 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** | | 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** | | 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`). 2. **Register the runner** (`./registerGiteaRunner.sh`).
3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty. 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. 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). 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. 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. 8. **Medal filename pattern** in the plugin's timestamp parser — need one sample filename.
9. **Home archive puller** on the home box. 9. **Home archive puller** on the home box.
10. **Plugin repository** served by Caddy so Jellyfin updates the plugin from its catalog. 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. 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. 13. **SYS-3** when transcoding or sweep speed hurts: Quick Sync, flip the plugin's policy toggle off.
## Verified behaviours worth remembering ## Verified behaviours worth remembering

View File

@@ -4,13 +4,17 @@
POST /dump identity from X-Authentik-Username / X-Authentik-Groups (set by Caddy after Authentik) POST /dump identity from X-Authentik-Username / X-Authentik-Groups (set by Caddy after Authentik)
body (optional JSON): {"night":"YYYY-MM-DD","user":"name"} body (optional JSON): {"night":"YYYY-MM-DD","user":"name"}
GET /dump -> lists what a dump would move (dry run) 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. "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). 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". 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 datetime import datetime, timedelta, time
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 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"] SRC_DIRS = [ROOT / "streams", ROOT / "clips"]
TRASH = ROOT / "trash" TRASH = ROOT / "trash"
MARK = ROOT / ".markers" 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_USER_HEADER = os.environ.get("AUTH_USER_HEADER", "X-Authentik-Username")
AUTH_GROUPS_HEADER = os.environ.get("AUTH_GROUPS_HEADER", "X-Authentik-Groups") 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()) 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")) TZ = ZoneInfo(os.environ.get("DUMP_TZ", "America/New_York"))
NIGHT_START = time(17, 0) # 5 pm NIGHT_START = time(17, 0) # 5 pm
NIGHT_END = time(2, 0) # 2 am next day NIGHT_END = time(2, 0) # 2 am next day
@@ -83,11 +90,39 @@ def do_move(files, night):
d.rmdir() d.rmdir()
return moved, size return moved, size
def load_streamers(): def token_secret():
try: try:
return json.loads(STREAMERS_FILE.read_text()) sec = TOKEN_SECRET_FILE.read_bytes().strip()
except (OSError, json.JSONDecodeError): if len(sec) >= 16: return sec
return {} 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): class H(BaseHTTPRequestHandler):
server_version = "dumpd/1" 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 req_user or auth_user # admins default to their own night; pass "all" or a name for others
return auth_user return auth_user
def do_GET(self): def do_GET(self):
if self.path.startswith("/me/stream-key"): if self.server.server_port == AUTH_LISTEN[1]:
# Only reachable through Caddy behind Authentik forward-auth, which injects the username header. return self._json(404, {"error": "not found"}) # the auth port serves POST /mtx-auth only
# dumpd binds to loopback, so a request carrying this header is one Caddy already authenticated. if self.path.startswith("/me/stream-token") or self.path.startswith("/me/stream-key"):
who = (self.headers.get(AUTH_USER_HEADER) or "").strip().lower() # Only reachable through Caddy behind Authentik forward-auth, which injects the identity headers.
who, _ = identity(self.headers)
if not who: if not who:
return self._json(401, {"error": "not authenticated"}) return self._json(401, {"error": "not authenticated"})
streamers = load_streamers() groups = {g.strip() for g in re.split(r"[|,]", self.headers.get(AUTH_GROUPS_HEADER) or "") if g.strip()}
pw = streamers.get(who) if not (groups & PUBLISH_GROUPS):
if not pw: return self._json(403, {"error": f"'{who}' is not in a publishing group ({', '.join(sorted(PUBLISH_GROUPS))}) — ask an admin for the Discord role"})
return self._json(403, {"error": f"no stream key for '{who}' — ask the admin to add you as a streamer with that exact name"}) try:
return self._json(200, {"user": who, "pass": pw}) 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"): if not self.path.startswith("/dump"):
return self._json(404, {"error": "not found"}) return self._json(404, {"error": "not found"})
u, is_admin = identity(self.headers) u, is_admin = identity(self.headers)
@@ -147,6 +187,33 @@ class H(BaseHTTPRequestHandler):
"files": [str(f.relative_to(ROOT)) for f in files], "files": [str(f.relative_to(ROOT)) for f in files],
"gb": round(sum(f.stat().st_size for f in files) / 1e9, 2)}) "gb": round(sum(f.stat().st_size for f in files) / 1e9, 2)})
def do_POST(self): 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"): if not self.path.startswith("/dump"):
return self._json(404, {"error": "not found"}) return self._json(404, {"error": "not found"})
u, is_admin = identity(self.headers) u, is_admin = identity(self.headers)
@@ -169,5 +236,8 @@ class H(BaseHTTPRequestHandler):
if __name__ == "__main__": if __name__ == "__main__":
TRASH.mkdir(exist_ok=True); MARK.mkdir(exist_ok=True) 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() ThreadingHTTPServer(LISTEN, H).serve_forever()

View File

@@ -11,7 +11,7 @@ Reads : Caddyfile (hostnames), mediamtx.yml (streamers), .env
(editors/admins are Authentik groups now: editors, media-admins, admins — nothing local) (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 — (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) 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 Discord bot token/guild), folders, restarts
Set VG_DRY=1 to write files only (no sudo / docker / folders). 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_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("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("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("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("BOT_REQUIRE_GROUP", "bot-users") # Caddy 403s anyone not in this Authentik group; empty = any logged-in member
env.setdefault("LIVE_REQUIRE_GROUP", "") 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())) Path(".env").write_text("".join(f"{k}={v}\n" for k, v in env.items()))
os.chmod(".env", 0o600) os.chmod(".env", 0o600)
def write_mediamtx(streamers, public_host=""): def docker_gateway():
users = "".join(f""" - user: {u} """Host IP as seen from the compose network: MediaMTX (bridge) calls dumpd's /mtx-auth there."""
pass: {p} if DRY: return "172.18.0.1"
permissions: for net in ("vanguard-stream_default", "vanguard_default"):
- action: publish try:
path: ~^{u}/[A-Za-z0-9_.-]+$ out = subprocess.run(["docker", "network", "inspect", net, "-f", "{{(index .IPAM.Config 0).Gateway}}"], capture_output=True, text=True, timeout=10)
""" for u, p in streamers.items()) or " []\n" if out.returncode == 0 and out.stdout.strip(): return out.stdout.strip()
alt = "|".join(streamers) or "nobody" 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) Path("mediamtx.yml").write_text(f"""# MediaMTX — ingest only. No playback endpoints exposed. (generated by configure.py)
logLevel: info logLevel: info
@@ -347,17 +353,16 @@ srt: yes
srtAddress: :8890 srtAddress: :8890
# ---- auth ------------------------------------------------------------- # ---- auth -------------------------------------------------------------
# Each streamer may publish only under their own prefix. # Publishing is authorised by dumpd (/mtx-auth) with short-lived tokens minted for Discord-authenticated
# RTMP key: <user>/Title?user=<user>&pass=<pass> # members of the streamers group. Reads are allowed; viewers are gated by Caddy + Authentik. No passwords.
# SRT: srt://HOST:8890?streamid=publish:<user>/Title:<user>:<pass> # MediaMTX hot-reloads this file: changing auth needs no restart.
authMethod: internal authMethod: http
authInternalUsers: authHTTPAddress: http://{auth_gw}:8081/mtx-auth
- user: any authHTTPExclude:
permissions: - action: api
- action: read - action: metrics
- action: playback - action: pprof
- action: api
{users}
# ---- recording -------------------------------------------------------- # ---- recording --------------------------------------------------------
pathDefaults: pathDefaults:
record: yes record: yes
@@ -375,8 +380,8 @@ pathDefaults:
runOnReadyRestart: no runOnReadyRestart: no
paths: paths:
# any name under a streamer's prefix; inherits pathDefaults # <username>/<Title>; dumpd decides who may publish where (token user == prefix)
~^({alt})/[A-Za-z0-9_.-]+$: {{}} ~^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$: {{}}
""") """)
def write_copyparty(): def write_copyparty():
@@ -426,14 +431,6 @@ def write_copyparty():
rwmd: @media-admins 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): def run(cmd, **kw):
print(" $", " ".join(cmd)) print(" $", " ".join(cmd))
if DRY: return 0 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) if not yes(" continue anyway?", False): print("aborted; add streamers first ([s] in the menu)"); sys.exit(1)
print("\n== writing configs") print("\n== writing configs")
write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook) 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"]) run(["sudo", "install", "-d", "-o", os.environ.get("SUDO_USER") or os.environ.get("USER") or "debian", "/srv/live"])
print("== app data folders") print("== app data folders")
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache", 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"]) "/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", "/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"]) 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: for u in streamers:
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", f"/srv/media/streams/{u}", f"/srv/media/clips/{u}"]) 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(): 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"]) run(["sudo", "install", "-m", "0644", "-o", "1000", "-g", "1000", "syncthing/stignore", f"/srv/media/clips/{u}/.stignore"])
print("== dump users + restart dumpd") print("== dump users + restart dumpd")
run(["sudo", "install", "-d", "-m", "0750", "/etc/vanguard"]) 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"]) run(["sudo", "systemctl", "restart", "dumpd"])
print("== containers") print("== containers")
dc = ["docker", "compose"] if (DRY or subprocess.call(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0) else ["sudo", "docker", "compose"] 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 + ["up", "-d", "--build"]) # recreates only services whose compose definition changed
run(dc + ["restart", "mediamtx", "copyparty", "caddy"]) 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=""): 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)'}") 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(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("== 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(" Discord login only — no WebDAV; downloads via the web UI")
print("== streamers:") print("== streamers: anyone in the Authentik 'streamers' group (Discord role). No passwords, no keys to hand out.")
for u, p in streamers.items(): 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"\n {u} password {p}") print(f" and sets the stream key itself. Manual setups: dock → 'Manual OBS setup' (rtmp://{domain or 'HOST'}:1935/ + key).")
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(f"\n editors log in with Discord at https://{domain or 'HOST'}/") 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" 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") 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)") print(f"== MANAGE (domain {domain}; run --full to change hostnames/admin)")
discord = (None, None) discord = (None, None)
while True: while True:
c = ask("\n[s]treamers [d]iscord bot token/guild [v]iew [a]pply & quit [q]uit without applying", "a").lower()[:1] c = ask("\n[d]iscord bot token/guild [v]iew [a]pply & quit [q]uit without applying", "a").lower()[:1]
if c == "s": manage_users("streamer", streamers) if c == "s": print(" streamers = the Authentik 'streamers' group (Discord role); nothing to manage here")
elif c == "d": discord = ask_discord() elif c == "d": discord = ask_discord()
elif c == "v": show(domain, admin_pw, streamers, editors) elif c == "v": show(domain, admin_pw, streamers, editors)
elif c == "a": return discord elif c == "a": return discord

View File

@@ -5,29 +5,41 @@
<title>Vanguard Stream</title> <title>Vanguard Stream</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<style> <style>
:root { --bg:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --ok:#2ecc71; --bad:#e74c3c; --box:#2a2a2a; } :root { --bg:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --ok:#2ecc71; --bad:#e74c3c; --warn:#f1c40f; --box:#2a2a2a; }
html,body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.4 system-ui,sans-serif} html,body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.4 system-ui,sans-serif}
main{padding:12px;max-width:420px} main{padding:12px;max-width:440px}
h1{font-size:15px;margin:0 0 10px;color:var(--mut);font-weight:600} h1{font-size:15px;margin:0 0 10px;color:var(--mut);font-weight:600}
h1 small{font-weight:400}
label{display:block;margin:8px 0 2px;color:var(--mut);font-size:12px} label{display:block;margin:8px 0 2px;color:var(--mut);font-size:12px}
input{width:100%;box-sizing:border-box;padding:8px;border:1px solid #444;border-radius:6px;background:var(--box);color:var(--fg);font-size:14px} input{width:100%;box-sizing:border-box;padding:8px;border:1px solid #444;border-radius:6px;background:var(--box);color:var(--fg);font-size:14px}
input[readonly]{color:var(--mut)}
button{width:100%;margin-top:10px;padding:11px;border:0;border-radius:6px;font-size:15px;font-weight:600;cursor:pointer;color:#fff;background:var(--acc)} button{width:100%;margin-top:10px;padding:11px;border:0;border-radius:6px;font-size:15px;font-weight:600;cursor:pointer;color:#fff;background:var(--acc)}
button.live{background:var(--ok);color:#000} button.live{background:var(--ok);color:#000}
button.dump{background:var(--bad)} button.dump{background:var(--bad)}
button.small{width:auto;padding:6px 10px;font-size:13px;margin:6px 6px 0 0;background:#3a3a3a}
button:disabled{opacity:.5;cursor:default} button:disabled{opacity:.5;cursor:default}
#status{margin-top:10px;padding:8px;border-radius:6px;background:var(--box);color:var(--mut);min-height:1.4em;white-space:pre-wrap} #status{margin-top:10px;padding:8px;border-radius:6px;background:var(--box);color:var(--mut);min-height:1.4em;white-space:pre-wrap}
details{margin-top:14px} details{margin-top:14px;border-top:1px solid #333;padding-top:8px}
summary{color:var(--mut);cursor:pointer;font-size:12px} summary{color:var(--mut);cursor:pointer;font-size:12px}
.row{display:flex;gap:8px} .row{display:flex;gap:8px}
.row button{margin-top:10px} .row button{margin-top:10px}
.preview{font-family:ui-monospace,monospace;font-size:12px;color:var(--mut);margin-top:4px;word-break:break-all}
.preview b{color:var(--fg)}
.rule{color:var(--bad);font-size:12px;margin-top:4px}
.kv{font-family:ui-monospace,monospace;font-size:12px;background:var(--box);border-radius:6px;padding:8px;margin-top:6px;word-break:break-all}
.kv .k{color:var(--mut)}
.tok{letter-spacing:1px}
.who{color:var(--mut);font-size:12px}
</style> </style>
</head> </head>
<body> <body>
<main> <main>
<h1>Vanguard Stream</h1> <h1>Vanguard Stream <small id="who" class="who"></small></h1>
<label for="title">Stream title</label> <label for="title">Stream title</label>
<input id="title" placeholder="VanguardTraining10-10-2025" autocomplete="off"> <input id="title" placeholder="VanguardTraining" autocomplete="off">
<div class="preview">stream name: <b id="preview">—</b></div>
<div class="rule" id="rule"></div>
<button id="golive" class="live">Go Live</button> <button id="golive" class="live">Go Live</button>
<div class="row"> <div class="row">
<button id="stop">Stop Stream</button> <button id="stop">Stop Stream</button>
@@ -37,58 +49,115 @@
<details> <details>
<summary>Settings (saved in this dock)</summary> <summary>Settings (saved in this dock)</summary>
<label>Ingest host (RTMP server)</label><input id="host" placeholder="media.example.com"> <label>Ingest host</label><input id="host" placeholder="media.example.com">
<label>Your streamer name</label><input id="user" placeholder="techy">
<label>Your stream password</label><input id="pass" type="password">
<label>OBS WebSocket password</label><input id="obspw" type="password"> <label>OBS WebSocket password</label><input id="obspw" type="password">
<label>OBS WebSocket port</label><input id="obsport" value="4455"> <label>OBS WebSocket port</label><input id="obsport" value="4455">
<label>Title prefix (optional, e.g. <code>HelmetCam-</code>)</label><input id="prefix" placeholder="">
<label>Title suffix (optional; <code>{date}</code> becomes YYYY-MM-DD)</label><input id="suffix" placeholder="-{date}">
<button id="save">Save settings</button> <button id="save">Save settings</button>
</details> </details>
<details id="manual">
<summary>Manual OBS setup (endpoints + token)</summary>
<div class="kv"><span class="k">RTMP server </span><span id="m_rtmp">—</span></div>
<div class="kv"><span class="k">Stream key </span><span id="m_key">—</span></div>
<div class="kv"><span class="k">SRT URL </span><span id="m_srt">—</span></div>
<div class="kv"><span class="k">Token </span><span id="m_tok" class="tok">•••••••••••• (hidden)</span><br><span class="k" id="m_exp"></span></div>
<button class="small" id="m_show">Show token</button>
<button class="small" id="m_copy_key">Copy stream key</button>
<button class="small" id="m_copy_srt">Copy SRT URL</button>
<button class="small" id="m_fresh">Fresh token (12 h)</button>
<div class="who" style="margin-top:6px">Tokens are minted for your Discord login and expire; the Go Live button does all of this automatically.</div>
</details>
</main> </main>
<script> <script>
// ---- settings -------------------------------------------------------------- // ---- settings --------------------------------------------------------------
const KEYS = ["host","user","pass","obspw","obsport"]; const KEYS = ["host","obspw","obsport","prefix","suffix"];
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
const status = (msg, cls) => { $("status").textContent = msg; $("status").style.color = cls==="bad"?"#e74c3c":cls==="ok"?"#2ecc71":"#9a9a9a"; }; const status = (msg, cls) => { $("status").textContent = msg; $("status").style.color = cls==="bad"?"#e74c3c":cls==="ok"?"#2ecc71":"#9a9a9a"; };
function load(){ try{ KEYS.forEach(k => { const v = localStorage.getItem("vg."+k); if (v!==null) $(k).value = v; }); }catch(e){} } function load(){ try{ KEYS.forEach(k => { const v = localStorage.getItem("vg."+k); if (v!==null) $(k).value = v; }); }catch(e){} if(!$("host").value) $("host").value = location.hostname; }
function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); status("settings saved"); }catch(e){ status("could not save settings (storage blocked)", "bad"); } } function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); status("settings saved"); }catch(e){ status("could not save settings (storage blocked)", "bad"); } renderManual(); }
load(); load(); $("save").onclick = save;
$("save").onclick = save;
const cfg = () => Object.fromEntries(KEYS.map(k => [k, $(k).value.trim()])); const cfg = () => Object.fromEntries(KEYS.map(k => [k, $(k).value.trim()]));
const cleanTitle = t => t.trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9_.-]/g, "");
// ---- naming rules (must match the server: the sweep publishes <Title>__<timestamp>.mp4) ----------------
const today = () => { const d = new Date(); return new Date(d.getTime() - d.getTimezoneOffset()*60000).toISOString().slice(0,10); };
function buildTitle(raw){
const c = cfg();
let t = (c.prefix||"") + raw.trim() + (c.suffix||"").replace("{date}", today());
t = t.replace(/\s+/g, "-") // spaces -> dashes
.replace(/[^A-Za-z0-9_.-]/g, "") // allowed characters only
.replace(/_{2,}/g, "_") // "__" is reserved: it separates title from timestamp in filenames
.replace(/^[.-]+/, "") // no leading dot (hidden) or dash
.slice(0, 64);
return t;
}
function ruleText(raw){
const r = [];
if (/__/.test(raw)) r.push("double underscore is reserved (becomes single)");
if (/[^A-Za-z0-9_.\- ]/.test(raw)) r.push("only letters, digits, space, _ . - are kept");
if (raw.trim().startsWith(".")) r.push("cannot start with a dot");
if ((cfg().prefix + raw + cfg().suffix).length > 64) r.push("longer than 64 characters: trimmed");
return r.join(" · ");
}
let me = null; // {user, token, expires}
function renderPreview(){
const t = buildTitle($("title").value);
$("preview").textContent = t ? `${me ? me.user : "<you>"}/${t}` : "—";
$("rule").textContent = ruleText($("title").value);
renderManual(t);
}
$("title").addEventListener("input", renderPreview);
["prefix","suffix"].forEach(k => $(k).addEventListener("input", renderPreview));
// ---- identity + tokens (the dock is behind Authentik: same-origin fetch carries the Discord session) -----
async function whoami(){
try {
const r = await fetch("/whoami", {credentials:"same-origin"});
const m = (await r.text()).match(/user:\s*(\S+)/); if (m) $("who").textContent = "— " + m[1];
} catch(e){}
}
async function mint(){
const r = await fetch("/me/stream-token", {credentials:"same-origin"});
const b = await r.json().catch(()=>({}));
if (!r.ok) throw new Error(b.error || `token ${r.status}`);
me = b; renderPreview(); return b;
}
let showTok = false;
function renderManual(t){
const c = cfg(); const title = t ?? buildTitle($("title").value) || "TITLE";
const user = me ? me.user : "<you>", tok = me ? me.token : "<token>";
$("m_rtmp").textContent = `rtmp://${c.host||location.hostname}:1935/`;
$("m_key").textContent = `${user}/${title}?user=${user}&pass=${showTok ? tok : "••••••••"}`;
$("m_srt").textContent = `srt://${c.host||location.hostname}:8890?streamid=publish:${user}/${title}:${user}:${showTok ? tok : "••••••••"}`;
$("m_tok").textContent = me ? (showTok ? me.token : "•••••••••••• (hidden)") : "— click “Fresh token”";
$("m_exp").textContent = me ? `valid until ${new Date(me.expires*1000).toLocaleString()}` : "";
}
$("m_show").onclick = () => { showTok = !showTok; $("m_show").textContent = showTok ? "Hide token" : "Show token"; renderManual(); };
$("m_fresh").onclick = async () => { try { await mint(); status("fresh token minted (12 h)", "ok"); } catch(e){ status(e.message, "bad"); } };
async function copy(text, what){ try { await navigator.clipboard.writeText(text); status(`${what} copied`, "ok"); } catch(e){ status("clipboard blocked — select the text manually", "bad"); } }
$("m_copy_key").onclick = async () => { if (!me) await mint().catch(e=>status(e.message,"bad")); if (me) copy(`${me.user}/${buildTitle($("title").value)||"TITLE"}?user=${me.user}&pass=${me.token}`, "stream key"); };
$("m_copy_srt").onclick = async () => { if (!me) await mint().catch(e=>status(e.message,"bad")); if (me) copy(`srt://${cfg().host}:8890?streamid=publish:${me.user}/${buildTitle($("title").value)||"TITLE"}:${me.user}:${me.token}`, "SRT URL"); };
$("manual").addEventListener("toggle", () => { if ($("manual").open) renderManual(); });
// ---- obs-websocket v5 ------------------------------------------------------ // ---- obs-websocket v5 ------------------------------------------------------
async function sha256b64(s){ async function sha256b64(s){ const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return btoa(String.fromCharCode(...new Uint8Array(h))); }
const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); function obs(c){
return btoa(String.fromCharCode(...new Uint8Array(h)));
}
function obs(cfg){
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${cfg.obsport||4455}`); const ws = new WebSocket(`ws://127.0.0.1:${c.obsport||4455}`);
const pending = new Map(); let n = 0; const pending = new Map(); let n = 0;
ws.onerror = () => reject(new Error("cannot reach OBS WebSocket (enable it in Tools > WebSocket Server Settings)")); ws.onerror = () => reject(new Error("cannot reach OBS WebSocket (enable it in Tools > WebSocket Server Settings)"));
ws.onmessage = async ev => { ws.onmessage = async ev => {
const m = JSON.parse(ev.data); const m = JSON.parse(ev.data);
if (m.op === 0) { // Hello if (m.op === 0) {
let auth; let auth;
if (m.d.authentication) { if (m.d.authentication) { const secret = await sha256b64(c.obspw + m.d.authentication.salt); auth = await sha256b64(secret + m.d.authentication.challenge); }
const secret = await sha256b64(cfg.obspw + m.d.authentication.salt);
auth = await sha256b64(secret + m.d.authentication.challenge);
}
ws.send(JSON.stringify({op:1, d:{rpcVersion:1, authentication:auth, eventSubscriptions:0}})); ws.send(JSON.stringify({op:1, d:{rpcVersion:1, authentication:auth, eventSubscriptions:0}}));
} else if (m.op === 2) { // Identified } else if (m.op === 2) {
resolve({ resolve({ call(type, data){ return new Promise((res, rej) => { const id = String(++n); pending.set(id, {res, rej}); ws.send(JSON.stringify({op:6, d:{requestType:type, requestId:id, requestData:data||{}}})); }); }, close(){ ws.close(); } });
call(type, data){ return new Promise((res, rej) => { } else if (m.op === 7) {
const id = String(++n); pending.set(id, {res, rej}); const p = pending.get(m.d.requestId); pending.delete(m.d.requestId); if (!p) return;
ws.send(JSON.stringify({op:6, d:{requestType:type, requestId:id, requestData:data||{}}}));
}); },
close(){ ws.close(); }
});
} else if (m.op === 7) { // RequestResponse
const p = pending.get(m.d.requestId); pending.delete(m.d.requestId);
if (!p) return;
m.d.requestStatus.result ? p.res(m.d.responseData) : p.rej(new Error(m.d.requestStatus.comment || m.d.requestType + " failed")); m.d.requestStatus.result ? p.res(m.d.responseData) : p.rej(new Error(m.d.requestStatus.comment || m.d.requestType + " failed"));
} }
}; };
@@ -98,44 +167,37 @@ function obs(cfg){
// ---- actions --------------------------------------------------------------- // ---- actions ---------------------------------------------------------------
$("golive").onclick = async () => { $("golive").onclick = async () => {
const c = cfg(), title = cleanTitle($("title").value); const c = cfg(), title = buildTitle($("title").value);
if (!title) return status("type a title first", "bad"); if (!title) return status("type a title first", "bad");
if (!c.host || !c.user || !c.pass) return status("fill in settings first", "bad"); if (!c.host) return status("set the ingest host in settings", "bad");
$("golive").disabled = true; $("golive").disabled = true;
try { try {
status("getting a stream token…");
const t = await mint(); // fresh token every Go Live; nothing stored
status("connecting to OBS…"); status("connecting to OBS…");
const o = await obs(c); const o = await obs(c);
const key = `${c.user}/${title}?user=${encodeURIComponent(c.user)}&pass=${encodeURIComponent(c.pass)}`; await o.call("SetStreamServiceSettings", { streamServiceType: "rtmp_custom",
await o.call("SetStreamServiceSettings", { streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key: `${t.user}/${title}?user=${encodeURIComponent(t.user)}&pass=${encodeURIComponent(t.token)}`, use_auth: false } });
streamServiceType: "rtmp_custom",
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key, use_auth: false }
});
const st = await o.call("GetStreamStatus"); const st = await o.call("GetStreamStatus");
if (st.outputActive) { await o.call("StopStream"); await new Promise(r => setTimeout(r, 1500)); } if (st.outputActive) { await o.call("StopStream"); await new Promise(r => setTimeout(r, 1500)); }
await o.call("StartStream"); await o.call("StartStream"); o.close();
o.close(); status(`LIVE → ${t.user}/${title}`, "ok");
status(`LIVE → ${c.user}/${title}`, "ok");
} catch (e) { status(e.message, "bad"); } } catch (e) { status(e.message, "bad"); }
$("golive").disabled = false; $("golive").disabled = false;
}; };
$("stop").onclick = async () => { try { const o = await obs(cfg()); await o.call("StopStream"); o.close(); status("stream stopped"); } catch (e) { status(e.message, "bad"); } };
$("stop").onclick = async () => {
try { const o = await obs(cfg()); await o.call("StopStream"); o.close(); status("stream stopped"); }
catch (e) { status(e.message, "bad"); }
};
let armed = 0; let armed = 0;
$("dump").onclick = async () => { $("dump").onclick = async () => {
try { try {
if (Date.now() - armed > 10000) { // first click: show what would go, arm for 10 s if (Date.now() - armed > 10000) {
status("checking what tonight contains…"); status("checking what tonight contains…");
const dry = await (await fetch("/dump", {credentials:"same-origin"})).json(); // identity = your Discord login (Authentik) const dry = await (await fetch("/dump", {credentials:"same-origin"})).json();
if (dry.error) throw new Error(dry.error); if (dry.error) throw new Error(dry.error);
if (!dry.files.length) return status(`nothing recorded for ${dry.night}`); if (!dry.files.length) return status(`nothing recorded for ${dry.night}`);
armed = Date.now(); armed = Date.now(); $("dump").textContent = "Click again to confirm";
$("dump").textContent = "Click again to confirm";
setTimeout(() => { $("dump").textContent = "Dump tonight"; armed = 0; }, 10000); setTimeout(() => { $("dump").textContent = "Dump tonight"; armed = 0; }, 10000);
return status(`${dry.files.length} file(s), ${dry.gb} GB from the night of ${dry.night}.\nClick Dump again within 10 s to confirm. Undo window: 3 days (ask admin).`); return status(`${dry.files.length} file(s), ${dry.gb} GB from the night of ${dry.night}.\nClick Dump again within 10 s to confirm. Undo window: 3 days (ask an admin).`);
} }
armed = 0; $("dump").textContent = "Dump tonight"; armed = 0; $("dump").textContent = "Dump tonight";
const r = await (await fetch("/dump", {method:"POST", headers:{"Content-Type":"application/json"}, body:"{}", credentials:"same-origin"})).json(); const r = await (await fetch("/dump", {method:"POST", headers:{"Content-Type":"application/json"}, body:"{}", credentials:"same-origin"})).json();
@@ -143,6 +205,8 @@ $("dump").onclick = async () => {
status(`dumped ${r.moved} file(s), ${r.gb} GB (recoverable until ${r.undo_until})`, "ok"); status(`dumped ${r.moved} file(s), ${r.gb} GB (recoverable until ${r.undo_until})`, "ok");
} catch (e) { status("dump failed: " + e.message, "bad"); } } catch (e) { status("dump failed: " + e.message, "bad"); }
}; };
whoami(); renderPreview();
</script> </script>
</body> </body>
</html> </html>

View File

@@ -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 limit rate over 30/minute burst 20 packets drop
tcp dport 3306 ct state new accept 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: # 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 # 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. # Docker publishes them on loopback, so its NAT never forwards outside traffic to them either.

View File

@@ -65,8 +65,9 @@ export default function GoLive({ onExit }) {
<video ref={videoRef} autoPlay playsInline muted className={phase === 'live' || phase === 'capturing' ? 'show' : ''} /> <video ref={videoRef} autoPlay playsInline muted className={phase === 'live' || phase === 'capturing' ? 'show' : ''} />
{phase !== 'live' && ( {phase !== 'live' && (
<div className="golive-form"> <div className="golive-form">
<label>Stream title <small>(becomes the folder and the Jellyfin title)</small></label> <label>Stream title <small>(becomes the Jellyfin title; letters, digits, _ . - ; no double underscore)</small></label>
<input value={title} onChange={e => setTitle(e.target.value)} disabled={phase === 'capturing'} /> <input value={title} onChange={e => setTitle(e.target.value)} disabled={phase === 'capturing'} />
<div className="hint" style={{marginTop: '-4px'}}>stream name: <b>{key ? key.user : '<you>'}/{cleanTitle(title) || '—'}</b></div>
<div className="hint"> <div className="hint">
Click <b>Start</b>, then pick the <b>WarDogs</b> window in Chrome's dialog and tick <b>Share audio</b>. Click <b>Start</b>, then pick the <b>WarDogs</b> window in Chrome's dialog and tick <b>Share audio</b>.
<b>Windows</b> with <b>Chrome or Edge 141 or newer</b> is the supported setup: window audio and the GPU <b>Windows</b> with <b>Chrome or Edge 141 or newer</b> is the supported setup: window audio and the GPU

View File

@@ -7,7 +7,8 @@ export function defaultTitle(now = new Date()) {
const d = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 10) const d = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 10)
return `HelmetCam-${d}` return `HelmetCam-${d}`
} }
export const cleanTitle = t => t.trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9_.-]/g, '') // Must match the dock and the server: the sweep publishes <Title>__<timestamp>.mp4, so "__" is reserved.
export const cleanTitle = t => t.trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9_.-]/g, '').replace(/_{2,}/g, '_').replace(/^[.-]+/, '').slice(0, 64)
export async function captureWindow() { export async function captureWindow() {
// Window-only picker with that window's own audio. Chrome/Edge 141+ honour windowAudio: "window" // Window-only picker with that window's own audio. Chrome/Edge 141+ honour windowAudio: "window"
@@ -105,7 +106,8 @@ export async function publish({ stream, path, user, pass, onState }) {
} }
export async function fetchStreamKey() { export async function fetchStreamKey() {
const r = await fetch('/me/stream-key', { credentials: 'same-origin' }) // a fresh 12 h publish token for the logged-in Discord user (member of streamers); nothing is stored
const r = await fetch('/me/stream-token', { credentials: 'same-origin' })
const body = await r.json().catch(() => ({})) const body = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(body.error || `stream key ${r.status}`) if (!r.ok) throw new Error(body.error || `stream key ${r.status}`)
return body return body

View File

@@ -6,6 +6,9 @@ After=network.target
[Service] [Service]
ExecStart=/usr/bin/python3 /usr/local/bin/dumpd.py ExecStart=/usr/bin/python3 /usr/local/bin/dumpd.py
Environment=DUMP_ADMIN_GROUPS=admins,media-admins Environment=DUMP_ADMIN_GROUPS=admins,media-admins
Environment=PUBLISH_GROUPS=streamers,admins,media-admins
Environment=STREAM_TOKEN_TTL_HOURS=12
Environment=STREAM_TOKEN_SECRET_FILE=/etc/vanguard/stream-token.secret
Environment=DUMP_TZ=America/New_York Environment=DUMP_TZ=America/New_York
Restart=always Restart=always
RestartSec=3 RestartSec=3