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

View File

@@ -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()