Phase 5+6: main host behind Authentik — copyparty trusts X-Authentik-* headers with group permissions (editors, media-admins), dumpd authorizes from headers (admins/media-admins), /sync gated to media-admins, dock drops its dump password; configure.py loses editors/admin

WebDAV continues via Authentik app passwords (provider: Intercept header authentication).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 14:55:48 -05:00
parent 76a7d06df4
commit 50c037e514
9 changed files with 109 additions and 117 deletions

View File

@@ -1,14 +1,16 @@
#!/usr/bin/env python3
"""Dump-tonight endpoint. Moves one streamer's night into trash (soft delete).
POST /dump Authorization: Basic user:pass body (optional JSON): {"night":"YYYY-MM-DD","user":"name"}
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)
"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; 'admin' 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 base64, hashlib, json, os, shutil, sys
import json, os, re, shutil, sys
from datetime import datetime, timedelta, time
from zoneinfo import ZoneInfo
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -18,36 +20,23 @@ ROOT = Path(os.environ.get("MEDIA_ROOT", "/srv/media"))
SRC_DIRS = [ROOT / "streams", ROOT / "clips"]
TRASH = ROOT / "trash"
MARK = ROOT / ".markers"
USERS_FILE = Path(os.environ.get("DUMP_USERS", "/etc/vanguard/dump-users.txt"))
STREAMERS_FILE = Path(os.environ.get("STREAMERS_FILE", "/etc/vanguard/streamers.json")) # {"techy": "pass", ...} written by configure.py
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)
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 load_users():
users = {}
for line in USERS_FILE.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or ":" not in line:
continue
u, h = line.split(":", 1)
users[u.strip()] = h.strip().lower()
return users
def check_auth(header):
if not header or not header.startswith("Basic "):
return None
try:
raw = base64.b64decode(header[6:]).decode()
u, p = raw.split(":", 1)
except Exception:
return None
users = load_users()
if users.get(u) == hashlib.sha256(p.encode()).hexdigest():
return u
return None
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)
@@ -110,9 +99,7 @@ class H(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(body)
def _deny(self):
self.send_response(401)
self.send_header("WWW-Authenticate", 'Basic realm="dump"')
self.end_headers()
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 {}
@@ -132,9 +119,9 @@ class H(BaseHTTPRequestHandler):
if pf:
return prev, pf
return n, files
def _target(self, auth_user, req_user):
if auth_user == "admin":
return req_user or "all"
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.path.startswith("/me/stream-key"):
@@ -150,11 +137,11 @@ class H(BaseHTTPRequestHandler):
return self._json(200, {"user": who, "pass": pw})
if not self.path.startswith("/dump"):
return self._json(404, {"error": "not found"})
u = check_auth(self.headers.get("Authorization"))
u, is_admin = identity(self.headers)
if not u:
return self._deny()
night, req_user = self._parse()
tgt = self._target(u, req_user)
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],
@@ -162,14 +149,14 @@ class H(BaseHTTPRequestHandler):
def do_POST(self):
if not self.path.startswith("/dump"):
return self._json(404, {"error": "not found"})
u = check_auth(self.headers.get("Authorization"))
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, req_user)
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)