From 50c037e514055118e45ae9a332847b3ac61b3007 Mon Sep 17 00:00:00 2001 From: Brandyn / Techy Date: Sat, 31 Oct 2026 14:55:48 -0500 Subject: [PATCH] =?UTF-8?q?Phase=205+6:=20main=20host=20behind=20Authentik?= =?UTF-8?q?=20=E2=80=94=20copyparty=20trusts=20X-Authentik-*=20headers=20w?= =?UTF-8?q?ith=20group=20permissions=20(editors,=20media-admins),=20dumpd?= =?UTF-8?q?=20authorizes=20from=20headers=20(admins/media-admins),=20/sync?= =?UTF-8?q?=20gated=20to=20media-admins,=20dock=20drops=20its=20dump=20pas?= =?UTF-8?q?sword;=20configure.py=20loses=20editors/admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebDAV continues via Authentik app passwords (provider: Intercept header authentication). Co-Authored-By: Claude Fable 5.1 --- .gitignore | 1 - CommunityDiscordMedia.md | 4 +- README.md | 31 +++++++++---- STATUS.md | 18 ++++---- bin/dumpd.py | 59 ++++++++++-------------- configure.py | 98 +++++++++++++++++++++------------------- dock/index.html | 7 +-- dump-users.example.txt | 6 --- systemd/dumpd.service | 2 +- 9 files changed, 109 insertions(+), 117 deletions(-) delete mode 100644 dump-users.example.txt diff --git a/.gitignore b/.gitignore index 2ab5733..445fa64 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,3 @@ streamers.json mediamtx.yml copyparty.conf Caddyfile -dump-users.txt diff --git a/CommunityDiscordMedia.md b/CommunityDiscordMedia.md index 2ef1cb0..0c5a827 100644 --- a/CommunityDiscordMedia.md +++ b/CommunityDiscordMedia.md @@ -179,7 +179,7 @@ Checklist: - [ ] Medal local filename pattern → add to `ItemTimestamp` (need a sample filename) - [ ] later: serve `dist/` at `/plugins/` via Caddy as a plugin repo; theme CSS -### Phase 5 — copyparty behind Authentik (backend, few users; the one that can lock editors out) +### Phase 5 — copyparty behind Authentik (built; apply pending) - [ ] Authentik → Providers → Proxy provider, *forward auth (single application)*, external host `https://ovh1.HOST`; Application bound to it; add to the embedded outpost - [ ] Caddy: `forward_auth 127.0.0.1:9000 { uri /outpost.goauthentik.io/auth/caddy; copy_headers X-Authentik-Username X-Authentik-Groups … }` @@ -193,7 +193,7 @@ Checklist: - [ ] WebDAV: editor creates an app password in Authentik → mounts with Discord username + app password - [ ] policy on the Application: only `editors` + `admins` + `streamers` may pass -### Phase 6 — Syncthing UI + dump behind Authentik +### Phase 6 — Syncthing UI + dump behind Authentik (built together with phase 5; apply pending) - [ ] `/sync/`: forward-auth, policy `admins` only; Syncthing GUI auth can stay as a second layer - [ ] `dumpd.py`: read `X-Authentik-Username` / `X-Authentik-Groups` from Caddy instead of basic auth; admin = member of `admins`; drop `dump-users.txt` diff --git a/README.md b/README.md index a58eae8..a7e08fa 100644 --- a/README.md +++ b/README.md @@ -46,10 +46,11 @@ works from ~/vanguard-stream without sudo. ./configure.py # later: add/remove/reset streamers and editors, then apply ./configure.py --show # print current users and the OBS settings to hand out -Stateless: it reads the current values out of `Caddyfile`, `mediamtx.yml` and `copyparty.conf`, -regenerates those three plus `dump-users.txt`, installs the dump users to `/etc/vanguard`, creates +Stateless: it reads the current values out of `Caddyfile`, `mediamtx.yml` and `.env`, regenerates +`Caddyfile`, `mediamtx.yml`, `copyparty.conf` and `streamers.json` (→ `/etc/vanguard`), creates `streams/` and `clips/` (with `.stignore`) for each streamer, restarts dumpd and the -containers. Removing a streamer or editor revokes access only; their files stay on disk. +containers. Editors and admins are Authentik groups (`editors`, `media-admins`, `admins`), not local +accounts. Removing a streamer revokes publishing only; their files stay on disk. Passwords may not contain `: & ? # / \ quotes or spaces` (they ride inside the stream key). ## 4. Firewall (host nftables; OVH edge firewall is optional on top) @@ -91,9 +92,11 @@ until Docker is restarted. OBS → Settings → Stream → Custom: Server `rtmp://media.example.com:1935/`, key anything (the dock sets it). OBS → Tools → WebSocket Server Settings → enable, set a password. -OBS → Docks → Custom Browser Docks → `https://media.example.com/dock/`. +OBS → Docks → Custom Browser Docks → `https://media.example.com/dock/` → log in with Discord once (the dock +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**. +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=...` SRT equivalent if preferred: `srt://media.example.com:8890?streamid=publish:techy/Title:techy:PASS` @@ -212,7 +215,7 @@ streamer's Authentik username must equal their MediaMTX name. 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. -## 5g. Authentik forward-auth for the Caddy-gated hosts (`live.`, `bot.`; later the main host) +## 5g. Authentik forward-auth for the Caddy-gated hosts (main host, `live.`, `bot.`) Design: **Authentik authenticates once for the whole domain; Caddy authorizes per host** from the `X-Authentik-Groups` header the outpost returns; **Discord roles fill the Authentik groups** at login. @@ -223,7 +226,10 @@ Design: **Authentik authenticates once for the whole domain; Caddy authorizes pe cookie domain = the parent domain (e.g. `dumpstervigilantes.rip`). Edit the Embedded Outpost → Applications → add it → Update. One login now covers every gated host. 3. Per-host group requirement = `.env`: `BOT_REQUIRE_GROUP=bot-users` (default), `LIVE_REQUIRE_GROUP=` - (empty = any logged-in member). `./configure.py` apply regenerates the Caddy blocks; a user outside the + (empty = any logged-in member), `SYNC_REQUIRE_GROUP=media-admins` (the `/sync` Syncthing UI on the main host). + On the main host copyparty itself authorizes by group (`editors`, `media-admins`); the dump endpoint lets + `admins`/`media-admins` dump anyone's night. Enable **"Intercept header authentication"** on the proxy + provider so WebDAV clients can log in with Authentik app passwords. `./configure.py` apply regenerates the Caddy blocks; a user outside the group gets a 403 from Caddy before the app sees the request. 4. Discord roles → groups: Directory → Groups: create `bot-users` (and keep `streamers`, `editors`, `admins`). Discord source → Scopes: `guilds guilds.members.read`. Customization → Policies → Expression Policy @@ -238,11 +244,16 @@ Jellyfin and Gitea use OIDC providers instead, not this. ## 6. Editors -Browser: https://media.example.com/ → log in. Scrub any `.mp4` in the browser, download what you want. -WebDAV mount: same URL, same login. Upload finished work to `/finals`. +Log in with Discord at https://media.example.com/ (copyparty behind Caddy + Authentik). Access is by Authentik +group: **editors** read streams and clips and write `/finals`; **media-admins** have full rights everywhere +including `/trash`. Scrub any `.mp4` in the browser, download what you want. + +WebDAV mount: same URL. Username = your Authentik username, password = an **Authentik app password** +(auth.HOST → your user settings → Tokens and App passwords → create). The proxy provider must have +"Intercept header authentication" enabled for this to work. Every `.mp4` an editor sees is the same kind of file (see "Editing standard"). Raw clipper output that -needed converting is kept in a hidden `.src/` folder next to it (admins can show dotfiles in copyparty). +needed converting is kept in a hidden `.src/` folder next to it (media-admins can show dotfiles in copyparty). ## 7. Home archive box diff --git a/STATUS.md b/STATUS.md index bd813a7..e2fff3f 100644 --- a/STATUS.md +++ b/STATUS.md @@ -25,17 +25,17 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea | 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/` | stream password in dock settings | +| 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 | | 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…/` | **local account** (to move to Discord) | -| Editor | Mount the library as a drive | WebDAV, same URL | same | +| 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 | Mount the library as a drive | WebDAV, same URL | Authentik username + app password — **pending apply + "Intercept header authentication"** | | Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) | | Anyone in the Discord server | Watch live: all active streams in dockable windows, audio follows focus, per-window mute/volume | Live portal `https://live.…/` | Discord (Authentik, domain-level) — live; viewer side not yet exercised during a stream | | Streamer (Windows) | **Go Live** from the browser: pick the WarDogs window (+audio on Windows Chrome 141+; Linux/Mac video only), streams as `/HelmetCam-`; 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) | | 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 | Pair a gamer's device, approve their folder | Syncthing UI `https://ovh1…/sync/` | Syncthing 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 | Install/upgrade the Jellyfin plugin | `./installJellyfinPlugin.sh` | ssh | | Admin | Register the CI runner | `./registerGiteaRunner.sh` | ssh | @@ -45,9 +45,9 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea | Service | Role | State | |---|---|---| | MediaMTX | RTMP/SRT ingest, records fMP4 | live | -| copyparty | browse/scrub/download/WebDAV/finals upload | live, local accounts | -| dumpd + dock | dump-tonight endpoint, OBS dock page | live, own password file | -| Syncthing | clip intake, UI at `/sync/` | live, Syncthing GUI login | +| 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** | +| Syncthing | clip intake, UI at `/sync/` | UI behind Authentik + `media-admins` — **built, pending apply** | | normalize sweep | editing standard, one copy per clip | live | | nftables | host firewall (22, 80/443, 1935, 2222, 3306, 8890/udp, 22000) | live | | Caddy | TLS + routing for all hostnames | live | @@ -69,8 +69,8 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea 1d. Watch a real stream from a second browser: corner chip should say `webrtc`; confirm WarDogs audio came through. 2. **Register the runner** (`./registerGiteaRunner.sh`). 3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty. -4. **copyparty behind Authentik** (group permissions, WebDAV via app passwords, then drop local accounts). The one change that can lock editors out; keep a local admin until a Discord editor has used the new path. -5. **Syncthing UI + dump behind Authentik**; dump reads the identity header; dock fetches the caller's own stream key. +4. **Apply phase 5+6** (copyparty, dock, dump, /sync behind Authentik; group permissions). Authentik side: group `media-admins` (techy in it), "Intercept header authentication" on the provider for WebDAV app passwords. +5. Dock fetches the caller's own stream key (phase 7) — or skip straight to phase 11 (no keys). 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. 8. **Medal filename pattern** in the plugin's timestamp parser — need one sample filename. diff --git a/bin/dumpd.py b/bin/dumpd.py index b85ff65..bcb7fb2 100755 --- a/bin/dumpd.py +++ b/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 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) diff --git a/configure.py b/configure.py index 61b8138..5a918c6 100755 --- a/configure.py +++ b/configure.py @@ -7,10 +7,11 @@ ./configure.py --hosts AUTH WATCH [GIT [BOT [LIVE]]] set the Authentik/Jellyfin/Gitea/bot/live hostnames and apply (non-interactive; used by addCommunityMediaServer.sh) -Reads : Caddyfile (hostnames), mediamtx.yml (streamers), copyparty.conf (editors + admin), .env +Reads : Caddyfile (hostnames), mediamtx.yml (streamers), .env + (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 — never scp them over the live ones: that is how streamers got wiped once) -Writes : Caddyfile, mediamtx.yml, copyparty.conf, dump-users.txt -> /etc/vanguard, .env (generated secrets + +Writes : Caddyfile, mediamtx.yml, copyparty.conf, streamers.json -> /etc/vanguard, .env (generated secrets + Discord bot token/guild), folders, restarts Set VG_DRY=1 to write files only (no sudo / docker / folders). """ @@ -222,18 +223,30 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l encode zstd gzip reverse_proxy 127.0.0.1:8096 }} +""" + sync_group = env.get("SYNC_REQUIRE_GROUP", "media-admins") + sync_gate = "" + if sync_group: + g = re.escape(sync_group) + sync_gate = f""" @sync_lacks_group {{ + path /sync /sync/* + not header_regexp X-Authentik-Groups (^|\\|){g}(\\||$) + }} + handle @sync_lacks_group {{ + respond "Syncthing admin is for the '{sync_group}' group." 403 + }} """ Path("Caddyfile").write_text(f"""# vanguard:main {domain} {{ encode zstd gzip - - # dump endpoint (python service on the host, does its own auth) +{AUTHENTIK_FORWARD_AUTH} + # dump endpoint (python service on the host; identity from the X-Authentik-* headers Caddy just set) handle /dump* {{ reverse_proxy 127.0.0.1:8080 }} - # Syncthing admin UI (its own login; strip the prefix, present a local Host to pass its host check) - redir /sync /sync/ 308 + # Syncthing admin UI: Authentik + group {sync_group or 'any'}; Syncthing's own GUI login stays as a second layer +{sync_gate} redir /sync /sync/ 308 handle_path /sync/* {{ reverse_proxy 127.0.0.1:8384 {{ header_up Host {{upstream_hostport}} @@ -246,7 +259,7 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l file_server }} - # everything else is copyparty (web UI + WebDAV) + # copyparty (web UI + WebDAV). It trusts X-Authentik-Username / -Groups from this proxy only. handle {{ reverse_proxy 127.0.0.1:3923 {{ flush_interval -1 @@ -263,6 +276,7 @@ def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", b env.setdefault("LIVE_WEBHOOK_URL", "") 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("SYNC_REQUIRE_GROUP", "media-admins") # Caddy gate for /sync on the main host 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["GIT_HOST"] = git_host or env.get("GIT_HOST", "git.example.com") @@ -353,13 +367,14 @@ paths: ~^({alt})/[A-Za-z0-9_.-]+$: {{}} """) -def write_copyparty(admin_pw, editors): - acc = f" admin: {admin_pw}\n" + "".join(f" {u}: {p}\n" for u, p in editors.items()) - eds = ", ".join(editors) - r_line = f" r: {eds}\n" if eds else "" - rw_line = f" rw: {eds}\n" if eds else "" - Path("copyparty.conf").write_text(f"""# copyparty — editors browse/preview/download; finals are writable. (generated by configure.py) -# Docs: https://github.com/9001/copyparty#server-config +def write_copyparty(): + """copyparty behind Caddy + Authentik: identity and groups arrive as headers; permissions are per group.""" + Path("copyparty.conf").write_text("""# copyparty — behind Caddy + Authentik forward-auth. (generated by configure.py) +# Identity comes from the proxy: X-Authentik-Username / X-Authentik-Groups (groups separated by |). +# No local accounts. Groups: editors (read streams/clips, write finals), media-admins (everything incl. trash). +# WebDAV: same URL, HTTP Basic with the Authentik username + an Authentik *app password* +# (the proxy provider must have "Intercept header authentication" enabled). +# Docs: https://github.com/9001/copyparty#identity-providers [global] e2dsa # index files for search @@ -368,49 +383,43 @@ def write_copyparty(admin_pw, editors): hist: /cfg/hist rproxy: 1 # trust first X-Forwarded-For hop (Caddy) xff-src: 172.16.0.0/12, 10.0.0.0/8, 192.168.0.0/16, 127.0.0.0/8 + idp-h-usr: X-Authentik-Username + idp-h-grp: X-Authentik-Groups + idp-gsep: | no-robots name: vanguard - usernames # login needs username + password (default is password-only) - # dark skin for the login page (served by Caddy from ./dock) - html-head: -[accounts] -{acc} -# streams: read for editors, full for admin [/streams] /w/streams accs: -{r_line} rwmd: admin + r: @editors + rwmd: @media-admins -# gamer clips (Syncthing drops them here) [/clips] /w/clips accs: -{r_line} rwmd: admin + r: @editors + rwmd: @media-admins -# editors upload finished work here [/finals] /w/finals accs: -{rw_line} rwmd: admin + rw: @editors + rwmd: @media-admins -# dumped nights, purged after 3 days — admin only [/trash] /w/trash accs: - rwmd: admin + 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_dump_users(admin_pw, streamers): - h = lambda p: hashlib.sha256(p.encode()).hexdigest() - body = "# user:sha256hex(password) (generated by configure.py)\n" - body += "".join(f"{u}:{h(p)}\n" for u, p in streamers.items()) - body += f"admin:{h(admin_pw)}\n" - Path("dump-users.txt").write_text(body) +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): print(" $", " ".join(cmd)) @@ -418,13 +427,14 @@ def run(cmd, **kw): return subprocess.call(cmd, **kw) def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", live_webhook=None): + # admin_pw / editors are legacy no-ops: access is by Authentik group now if not streamers and Path("mediamtx.yml").exists() and "CHANGEME" in Path("mediamtx.yml").read_text(): print("\n!! mediamtx.yml contains CHANGEME placeholders — it looks like the repo template was copied over the") print(" generated config. Applying now would leave NO streamers (OBS ingest and the dump button stop working).") if not yes(" continue anyway?", False): print("aborted; add streamers first ([s] in the menu)"); sys.exit(1) print("\n== writing configs") 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(admin_pw, editors); write_dump_users(admin_pw, streamers); write_streamers_json(streamers) + write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host); write_mediamtx(streamers, domain); write_copyparty(); write_streamers_json(streamers) run(["sudo", "install", "-d", "-o", os.environ.get("SUDO_USER") or os.environ.get("USER") or "debian", "/srv/live"]) print("== app data folders") run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache", @@ -438,7 +448,6 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis run(["sudo", "install", "-m", "0644", "-o", "1000", "-g", "1000", "syncthing/stignore", f"/srv/media/clips/{u}/.stignore"]) print("== dump users + restart dumpd") run(["sudo", "install", "-d", "-m", "0750", "/etc/vanguard"]) - run(["sudo", "install", "-m", "0640", "dump-users.txt", "/etc/vanguard/dump-users.txt"]) run(["sudo", "install", "-m", "0600", "streamers.json", "/etc/vanguard/streamers.json"]) run(["sudo", "systemctl", "restart", "dumpd"]) print("== containers") @@ -461,8 +470,8 @@ def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_ print(f"== mariadb : {domain or 'HOST'}:3306 (public, root local-only) root pass {env.get('MARIADB_ROOT_PASSWORD','(run apply)')}") print(f" bot : db {env.get('BOT_DB_NAME','vanguardbot')} user {env.get('BOT_DB_USER','bot')} pass {env.get('BOT_DB_PASSWORD','(run apply)')}") 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"== admin : admin / {admin_pw or '(not set)'} (copyparty + dump)") - print("== editors :", ", ".join(f"{u} / {p}" for u, p in editors.items()) or "(none)") + print("== access : by Authentik group — editors (read streams/clips, write finals), media-admins (everything), admins (dump anyone)") + print(" WebDAV: Authentik username + an Authentik app password") print("== streamers:") for u, p in streamers.items(): print(f"\n {u} password {p}") @@ -471,7 +480,7 @@ def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_ 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 at https://{domain or 'HOST'}/ — WebDAV at the same URL") + print(f"\n editors log in with Discord at https://{domain or 'HOST'}/ — WebDAV at the same URL (app password)") 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") @@ -510,8 +519,6 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_ print("\n-- Discord bot (optional; the Bot added to the Authentik Discord application). Enter keeps the current value.") discord_token, discord_guild = ask_discord() live_webhook = ask(" Discord webhook URL for 'is live' notices (optional)", read_env().get("LIVE_WEBHOOK_URL", "")) - print("\n-- admin (copyparty admin + may dump anyone's night)") - admin_pw = ask_pw("admin", admin_pw or None) print("\n-- streamers (OBS publishers; each gets streams/ and clips/)") for u in list(streamers): k = ask(f" {u}: [k]eep [r]emove [p]assword reset", "k").lower()[:1] @@ -522,7 +529,6 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_ n = ask_name("streamer", streamers) if not n: break streamers[n] = ask_pw(n) - print("\n-- editors (copyparty logins: read streams/clips, write finals)") for u in list(editors): k = ask(f" {u}: [k]eep [r]emove [p]assword reset", "k").lower()[:1] if k == "r": del editors[u] @@ -547,15 +553,13 @@ def ask_discord(): return tok, guild def normal(domain, admin_pw, streamers, editors): - missing = [x for x, v in (("domain", domain), ("admin password", admin_pw)) if not v] - if missing: - print(f"not set yet: {', '.join(missing)} — run ./configure.py --full"); sys.exit(1) + if not domain: + print("domain not set yet — run ./configure.py --full"); sys.exit(1) print(f"== MANAGE (domain {domain}; run --full to change hostnames/admin)") discord = (None, None) while True: - c = ask("\n[s]treamers [e]ditors [d]iscord bot token/guild [v]iew [a]pply & quit [q]uit without applying", "a").lower()[:1] + c = ask("\n[s]treamers [d]iscord bot token/guild [v]iew [a]pply & quit [q]uit without applying", "a").lower()[:1] if c == "s": manage_users("streamer", streamers) - elif c == "e": manage_users("editor", editors) elif c == "d": discord = ask_discord() elif c == "v": show(domain, admin_pw, streamers, editors) elif c == "a": return discord @@ -572,7 +576,7 @@ def main(): show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return if mode == "--hosts": if len(sys.argv) not in (4, 5, 6, 7): print("usage: configure.py --hosts AUTH_HOST WATCH_HOST [GIT_HOST [BOT_HOST [LIVE_HOST]]]"); sys.exit(2) - if not domain or not admin_pw: print("domain/admin not set yet — run ./configure.py --full first"); sys.exit(1) + if not domain: print("domain not set yet — run ./configure.py --full first"); sys.exit(1) auth_host, watch_host = sys.argv[2].strip(), sys.argv[3].strip() if len(sys.argv) >= 5: git_host = sys.argv[4].strip() if len(sys.argv) >= 6: bot_host = sys.argv[5].strip() diff --git a/dock/index.html b/dock/index.html index a4c89ce..91e39d6 100644 --- a/dock/index.html +++ b/dock/index.html @@ -126,13 +126,10 @@ $("stop").onclick = async () => { let armed = 0; $("dump").onclick = async () => { - const c = cfg(); - if (!c.user || !c.pass) return status("fill in settings first", "bad"); - const auth = "Basic " + btoa(`${c.user}:${c.pass}`); try { if (Date.now() - armed > 10000) { // first click: show what would go, arm for 10 s status("checking what tonight contains…"); - const dry = await (await fetch("/dump", {headers:{Authorization:auth}})).json(); + const dry = await (await fetch("/dump", {credentials:"same-origin"})).json(); // identity = your Discord login (Authentik) if (dry.error) throw new Error(dry.error); if (!dry.files.length) return status(`nothing recorded for ${dry.night}`); armed = Date.now(); @@ -141,7 +138,7 @@ $("dump").onclick = async () => { 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).`); } armed = 0; $("dump").textContent = "Dump tonight"; - const r = await (await fetch("/dump", {method:"POST", headers:{Authorization:auth, "Content-Type":"application/json"}, body:"{}"})).json(); + const r = await (await fetch("/dump", {method:"POST", headers:{"Content-Type":"application/json"}, body:"{}", credentials:"same-origin"})).json(); if (r.error) throw new Error(r.error); status(`dumped ${r.moved} file(s), ${r.gb} GB (recoverable until ${r.undo_until})`, "ok"); } catch (e) { status("dump failed: " + e.message, "bad"); } diff --git a/dump-users.example.txt b/dump-users.example.txt deleted file mode 100644 index 8627a29..0000000 --- a/dump-users.example.txt +++ /dev/null @@ -1,6 +0,0 @@ -# user:sha256hex(password) — same user names as mediamtx.yml -# generate: printf '%s' 'password' | sha256sum -techy:REPLACE_WITH_SHA256 -diceofire:REPLACE_WITH_SHA256 -# admin may dump everyone's night -admin:REPLACE_WITH_SHA256 diff --git a/systemd/dumpd.service b/systemd/dumpd.service index 58f55d1..669f3a3 100644 --- a/systemd/dumpd.service +++ b/systemd/dumpd.service @@ -5,7 +5,7 @@ After=network.target [Service] ExecStart=/usr/bin/python3 /usr/local/bin/dumpd.py -Environment=DUMP_USERS=/etc/vanguard/dump-users.txt +Environment=DUMP_ADMIN_GROUPS=admins,media-admins Environment=DUMP_TZ=America/New_York Restart=always RestartSec=3