247 lines
12 KiB
Python
Executable File
247 lines
12 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Dump-tonight endpoint. Moves one streamer's night into trash (soft delete).
|
|
|
|
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-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 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
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(os.environ.get("MEDIA_ROOT", "/srv/media"))
|
|
SRC_DIRS = [ROOT / "streams", ROOT / "clips"]
|
|
TRASH = ROOT / "trash"
|
|
MARK = ROOT / ".markers"
|
|
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) # /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
|
|
|
|
def identity(headers):
|
|
"""(username, is_admin) from the headers Caddy sets after Authentik; (None, False) if absent.
|
|
dumpd binds to loopback only, so these headers can only come from Caddy."""
|
|
who = (headers.get(AUTH_USER_HEADER) or "").strip().lower()
|
|
if not who:
|
|
return None, False
|
|
groups = {g.strip() for g in re.split(r"[|,]", headers.get(AUTH_GROUPS_HEADER) or "") if g.strip()}
|
|
return who, bool(groups & ADMIN_GROUPS)
|
|
|
|
def default_night(now=None):
|
|
now = now or datetime.now(TZ)
|
|
d = now.date() if now.time() >= NIGHT_START else (now - timedelta(days=1)).date()
|
|
return d.isoformat()
|
|
|
|
def night_window(night):
|
|
d = datetime.strptime(night, "%Y-%m-%d").date()
|
|
return (datetime.combine(d, NIGHT_START, tzinfo=TZ),
|
|
datetime.combine(d + timedelta(days=1), NIGHT_END, tzinfo=TZ))
|
|
|
|
def candidates(user, night):
|
|
start, end = night_window(night)
|
|
out = []
|
|
for base in SRC_DIRS:
|
|
udirs = [p for p in base.iterdir() if p.is_dir()] if user == "all" else [base / user]
|
|
for ud in udirs:
|
|
if not ud.is_dir():
|
|
continue
|
|
for f in ud.rglob("*"):
|
|
if not f.is_file():
|
|
continue
|
|
mt = datetime.fromtimestamp(f.stat().st_mtime, TZ)
|
|
if start <= mt <= end:
|
|
out.append(f)
|
|
return out
|
|
|
|
def do_move(files, night):
|
|
moved, size = 0, 0
|
|
for f in files:
|
|
rel = f.relative_to(ROOT)
|
|
dst = TRASH / night / rel
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
size += f.stat().st_size
|
|
shutil.move(str(f), str(dst))
|
|
moved += 1
|
|
except FileNotFoundError:
|
|
pass
|
|
# drop now-empty title folders
|
|
for base in SRC_DIRS:
|
|
for d in sorted((p for p in base.rglob("*") if p.is_dir()), key=lambda p: -len(p.parts)):
|
|
if d.parent != base and not any(d.iterdir()):
|
|
d.rmdir()
|
|
return moved, size
|
|
|
|
def token_secret():
|
|
try:
|
|
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"
|
|
def _json(self, code, obj):
|
|
body = json.dumps(obj).encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
def _deny(self):
|
|
self._json(401, {"error": "not authenticated (no identity header from the proxy)"})
|
|
def _parse(self):
|
|
n = int(self.headers.get("Content-Length") or 0)
|
|
body = json.loads(self.rfile.read(n) or b"{}") if n else {}
|
|
night = body.get("night")
|
|
if night:
|
|
datetime.strptime(night, "%Y-%m-%d") # validate
|
|
return night, body.get("user")
|
|
def _resolve(self, night, tgt):
|
|
"""Explicit night wins. Otherwise: tonight, falling back to last night if tonight is empty."""
|
|
if night:
|
|
return night, candidates(tgt, night)
|
|
n = default_night()
|
|
files = candidates(tgt, n)
|
|
if not files:
|
|
prev = (datetime.strptime(n, "%Y-%m-%d") - timedelta(days=1)).strftime("%Y-%m-%d")
|
|
pf = candidates(tgt, prev)
|
|
if pf:
|
|
return prev, pf
|
|
return n, files
|
|
def _target(self, auth_user, is_admin, req_user):
|
|
if is_admin:
|
|
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.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("/time"):
|
|
# server clock for the timecode strip (the /clock/ page and Go Live measure their offset against this)
|
|
return self._json(200, {"now_ms": int(_time.time() * 1000)})
|
|
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"})
|
|
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)
|
|
if not u:
|
|
return self._deny()
|
|
night, req_user = self._parse()
|
|
tgt = self._target(u, is_admin, req_user)
|
|
night, files = self._resolve(night, tgt)
|
|
self._json(200, {"night": night, "user": tgt, "dry_run": True,
|
|
"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)
|
|
if not u:
|
|
return self._deny()
|
|
try:
|
|
night, req_user = self._parse()
|
|
except Exception as e:
|
|
return self._json(400, {"error": str(e)})
|
|
tgt = self._target(u, is_admin, req_user)
|
|
night, files = self._resolve(night, tgt)
|
|
moved, size = do_move(files, night)
|
|
MARK.mkdir(exist_ok=True)
|
|
(MARK / f"{night}-{tgt}.dumped").write_text(f"{datetime.now(TZ).isoformat()} by {u}\n")
|
|
print(f"{datetime.now(TZ):%F %T} dump night={night} user={tgt} by={u} files={moved} gb={size/1e9:.2f}", flush=True)
|
|
self._json(200, {"night": night, "user": tgt, "moved": moved, "gb": round(size / 1e9, 2),
|
|
"undo_until": (datetime.now(TZ) + timedelta(days=3)).strftime("%F")})
|
|
def log_message(self, *a): # quiet
|
|
pass
|
|
|
|
if __name__ == "__main__":
|
|
TRASH.mkdir(exist_ok=True); MARK.mkdir(exist_ok=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()
|