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:
59
bin/dumpd.py
59
bin/dumpd.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user