Compare commits

..

4 Commits

10 changed files with 120 additions and 123 deletions

1
.gitignore vendored
View File

@@ -9,4 +9,3 @@ streamers.json
mediamtx.yml mediamtx.yml
copyparty.conf copyparty.conf
Caddyfile Caddyfile
dump-users.txt

View File

@@ -37,8 +37,9 @@ Bundle: `vanguard-stream/` in this repo; live copy at `~/vanguard-stream` on the
- **Raw clipper files live in hidden `.src/` folders** so Jellyfin and copyparty show one item per clip. - **Raw clipper files live in hidden `.src/` folders** so Jellyfin and copyparty show one item per clip.
- **Hostnames:** `auth.` and `watch.` are CNAMEs to the main host. Caddy routes by SNI, Let's Encrypt - **Hostnames:** `auth.` and `watch.` are CNAMEs to the main host. Caddy routes by SNI, Let's Encrypt
follows CNAMEs. One A record to change if the IP ever moves. follows CNAMEs. One A record to change if the IP ever moves.
- **WebDAV through Authentik** uses Authentik app passwords as HTTP basic auth (a mounted drive - **Discord login only, no passwords anywhere** → no WebDAV (a mounted drive can't do a browser login).
can't do a browser login). Editors download from copyparty's web UI (zip/tar for folders). WebDAV stays *available* (Authentik
"Intercept header authentication" + per-user app passwords, no code change) if we ever choose it.
## Target layout ## Target layout
@@ -179,7 +180,7 @@ Checklist:
- [ ] Medal local filename pattern → add to `ItemTimestamp` (need a sample filename) - [ ] Medal local filename pattern → add to `ItemTimestamp` (need a sample filename)
- [ ] later: serve `dist/` at `/plugins/` via Caddy as a plugin repo; theme CSS - [ ] 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 - [ ] Authentik → Providers → Proxy provider, *forward auth (single application)*, external host
`https://ovh1.HOST`; Application bound to it; add to the embedded outpost `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 … }` - [ ] Caddy: `forward_auth 127.0.0.1:9000 { uri /outpost.goauthentik.io/auth/caddy; copy_headers X-Authentik-Username X-Authentik-Groups … }`
@@ -190,10 +191,10 @@ Checklist:
replaces today's workaround of logging in as the local `admin` account to modify streams/clips) replaces today's workaround of logging in as the local `admin` account to modify streams/clips)
- [ ] keep the local accounts in `copyparty.conf` until an `editors` Discord user has browsed and - [ ] keep the local accounts in `copyparty.conf` until an `editors` Discord user has browsed and
downloaded through the new path; then remove them (`configure.py` drops the editors section) downloaded through the new path; then remove them (`configure.py` drops the editors section)
- [ ] WebDAV: editor creates an app password in Authentik → mounts with Discord username + app password - [x] decided: no WebDAV (Discord login only); browser downloads
- [ ] policy on the Application: only `editors` + `admins` + `streamers` may pass - [ ] 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 - [ ] `/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; - [ ] `dumpd.py`: read `X-Authentik-Username` / `X-Authentik-Groups` from Caddy instead of basic auth;
admin = member of `admins`; drop `dump-users.txt` admin = member of `admins`; drop `dump-users.txt`

View File

@@ -46,10 +46,11 @@ works from ~/vanguard-stream without sudo.
./configure.py # later: add/remove/reset streamers and editors, then apply ./configure.py # later: add/remove/reset streamers and editors, then apply
./configure.py --show # print current users and the OBS settings to hand out ./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`, Stateless: it reads the current values out of `Caddyfile`, `mediamtx.yml` and `.env`, regenerates
regenerates those three plus `dump-users.txt`, installs the dump users to `/etc/vanguard`, creates `Caddyfile`, `mediamtx.yml`, `copyparty.conf` and `streamers.json` (→ `/etc/vanguard`), creates
`streams/<name>` and `clips/<name>` (with `.stignore`) for each streamer, restarts dumpd and the `streams/<name>` and `clips/<name>` (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). Passwords may not contain `: & ? # / \ quotes or spaces` (they ride inside the stream key).
## 4. Firewall (host nftables; OVH edge firewall is optional on top) ## 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 → 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 → 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). 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=...` 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` 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 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. 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 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. `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 → cookie domain = the parent domain (e.g. `dumpstervigilantes.rip`). Edit the Embedded Outpost →
Applications → add it → Update. One login now covers every gated host. 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=` 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. Leave "Intercept header authentication" **off**: login is
Discord only, no app passwords, no WebDAV. `./configure.py` apply regenerates the Caddy blocks; a user outside the
group gets a 403 from Caddy before the app sees the request. 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`). 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 Discord source → Scopes: `guilds guilds.members.read`. Customization → Policies → Expression Policy
@@ -238,11 +244,21 @@ Jellyfin and Gitea use OIDC providers instead, not this.
## 6. Editors ## 6. Editors
Browser: https://media.example.com/ → log in. Scrub any `.mp4` in the browser, download what you want. Log in with Discord at https://media.example.com/ (copyparty behind Caddy + Authentik). Access is by Authentik
WebDAV mount: same URL, same login. Upload finished work to `/finals`. 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.
No WebDAV: login is Discord only (no passwords of any kind), and a mounted drive cannot do a browser login.
Download from the browser: single files, or select files/folders and use copyparty's zip/tar download.
> **Available but not set up.** WebDAV mounts can be enabled later without code changes if we decide we want
> them: in Authentik, edit the `Vanguard sites` proxy provider and tick **Intercept header authentication**;
> each editor then creates an *app password* under their Authentik user settings and mounts the same URL with
> their Authentik username + that app password. Caddy and copyparty already pass the identity through, so
> nothing else changes. It is off because it introduces a password-like credential alongside Discord login.
Every `.mp4` an editor sees is the same kind of file (see "Editing standard"). Raw clipper output that 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 ## 7. Home archive box

View File

@@ -25,17 +25,16 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Who | Activity | Where | Login | | 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 | — | | 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 | 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 | same |
| Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) | | 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 | | 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 `<user>/HelmetCam-<date>`; no OBS needed | Live portal → Go Live | Discord; key fetched for the logged-in user — **verified end to end** (publish → `.live` → sweep → title folder) | | Streamer (Windows) | **Go Live** from the browser: pick the WarDogs window (+audio on Windows Chrome 141+; Linux/Mac video only), streams as `<user>/HelmetCam-<date>`; 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) | | 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 | | 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 | 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 | Groups, who may log in, app access | Authentik `https://auth.…/` | Discord or akadmin |
| Admin | Install/upgrade the Jellyfin plugin | `./installJellyfinPlugin.sh` | ssh | | Admin | Install/upgrade the Jellyfin plugin | `./installJellyfinPlugin.sh` | ssh |
| Admin | Register the CI runner | `./registerGiteaRunner.sh` | ssh | | Admin | Register the CI runner | `./registerGiteaRunner.sh` | ssh |
@@ -45,9 +44,9 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| Service | Role | State | | Service | Role | State |
|---|---|---| |---|---|---|
| MediaMTX | RTMP/SRT ingest, records fMP4 | live | | MediaMTX | RTMP/SRT ingest, records fMP4 | live |
| copyparty | browse/scrub/download/WebDAV/finals upload | live, local accounts | | 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 | live, own password file | | dumpd + dock | dump-tonight endpoint, OBS dock page | identity from Authentik headers — **built, pending apply** |
| Syncthing | clip intake, UI at `/sync/` | live, Syncthing GUI login | | Syncthing | clip intake, UI at `/sync/` | UI behind Authentik + `media-admins` — **built, pending apply** |
| normalize sweep | editing standard, one copy per clip | live | | normalize sweep | editing standard, one copy per clip | live |
| nftables | host firewall (22, 80/443, 1935, 2222, 3306, 8890/udp, 22000) | live | | nftables | host firewall (22, 80/443, 1935, 2222, 3306, 8890/udp, 22000) | live |
| Caddy | TLS + routing for all hostnames | live | | Caddy | TLS + routing for all hostnames | live |
@@ -69,8 +68,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. 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`). 2. **Register the runner** (`./registerGiteaRunner.sh`).
3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty. 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. 4. **Apply phase 5+6** (copyparty, dock, dump, /sync behind Authentik; group permissions). Authentik side: group `media-admins` with techy in it. Decision: Discord login only → no WebDAV; browser downloads (zip/tar) instead.
5. **Syncthing UI + dump behind Authentik**; dump reads the identity header; dock fetches the caller's own stream key. 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). 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. 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. 8. **Medal filename pattern** in the plugin's timestamp parser — need one sample filename.
@@ -83,6 +82,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
## Verified behaviours worth remembering ## Verified behaviours worth remembering
- **Never scp generated configs** (`mediamtx.yml`, `copyparty.conf`, `Caddyfile`, `dump-users.txt`, `.env`): a template copy wiped all streamers once. They are gitignored now; `*.example.*` are reference only. - **Never scp generated configs** (`mediamtx.yml`, `copyparty.conf`, `Caddyfile`, `dump-users.txt`, `.env`): a template copy wiped all streamers once. They are gitignored now; `*.example.*` are reference only.
- Caddy runs in a container: anything it serves from disk must be a mount (`/srv/live` was missing → 404). - Caddy runs in a container: anything it serves from disk must be a mount (`/srv/live` was missing → 404).
- Go Live quality depends on the browser's encoder: Windows Chrome uses the GPU (fine); Linux Chromium on NVIDIA is software H.264 Baseline (soft at any bitrate, CPU-limited at 1440p60) and cannot be fixed by flags — Linux streamers use OBS. The stats strip shows encoder + profile.
- Several videos inside one non-season folder = ONE Jellyfin episode with "versions". Streams are therefore published flat as `<Title>__<ts>.mp4` in the streamer's folder (plugin 0.1.3 parses the title from the name). Four or five helmet cams a night each get their own episode. - Several videos inside one non-season folder = ONE Jellyfin episode with "versions". Streams are therefore published flat as `<Title>__<ts>.mp4` in the streamer's folder (plugin 0.1.3 parses the title from the name). Four or five helmet cams a night each get their own episode.
- The sweep's VFR check used a gawk-only function; Debian's mawk made it pass everything silently. Portable now. - The sweep's VFR check used a gawk-only function; Debian's mawk made it pass everything silently. Portable now.
- A deploy block that is only half-run leaves old binaries in `/usr/local/bin`; `grep -c` the installed script before blaming logic. - A deploy block that is only half-run leaves old binaries in `/usr/local/bin`; `grep -c` the installed script before blaming logic.

View File

@@ -1,14 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Dump-tonight endpoint. Moves one streamer's night into trash (soft delete). """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 /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. "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). 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 datetime import datetime, timedelta, time
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 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"] SRC_DIRS = [ROOT / "streams", ROOT / "clips"]
TRASH = ROOT / "trash" TRASH = ROOT / "trash"
MARK = ROOT / ".markers" 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 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_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)
TZ = ZoneInfo(os.environ.get("DUMP_TZ", "America/New_York")) TZ = ZoneInfo(os.environ.get("DUMP_TZ", "America/New_York"))
NIGHT_START = time(17, 0) # 5 pm NIGHT_START = time(17, 0) # 5 pm
NIGHT_END = time(2, 0) # 2 am next day NIGHT_END = time(2, 0) # 2 am next day
def load_users(): def identity(headers):
users = {} """(username, is_admin) from the headers Caddy sets after Authentik; (None, False) if absent.
for line in USERS_FILE.read_text().splitlines(): dumpd binds to loopback only, so these headers can only come from Caddy."""
line = line.strip() who = (headers.get(AUTH_USER_HEADER) or "").strip().lower()
if not line or line.startswith("#") or ":" not in line: if not who:
continue return None, False
u, h = line.split(":", 1) groups = {g.strip() for g in re.split(r"[|,]", headers.get(AUTH_GROUPS_HEADER) or "") if g.strip()}
users[u.strip()] = h.strip().lower() return who, bool(groups & ADMIN_GROUPS)
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 default_night(now=None): def default_night(now=None):
now = now or datetime.now(TZ) now = now or datetime.now(TZ)
@@ -110,9 +99,7 @@ class H(BaseHTTPRequestHandler):
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
def _deny(self): def _deny(self):
self.send_response(401) self._json(401, {"error": "not authenticated (no identity header from the proxy)"})
self.send_header("WWW-Authenticate", 'Basic realm="dump"')
self.end_headers()
def _parse(self): def _parse(self):
n = int(self.headers.get("Content-Length") or 0) n = int(self.headers.get("Content-Length") or 0)
body = json.loads(self.rfile.read(n) or b"{}") if n else {} body = json.loads(self.rfile.read(n) or b"{}") if n else {}
@@ -132,9 +119,9 @@ class H(BaseHTTPRequestHandler):
if pf: if pf:
return prev, pf return prev, pf
return n, files return n, files
def _target(self, auth_user, req_user): def _target(self, auth_user, is_admin, req_user):
if auth_user == "admin": if is_admin:
return req_user or "all" return req_user or auth_user # admins default to their own night; pass "all" or a name for others
return auth_user return auth_user
def do_GET(self): def do_GET(self):
if self.path.startswith("/me/stream-key"): if self.path.startswith("/me/stream-key"):
@@ -150,11 +137,11 @@ class H(BaseHTTPRequestHandler):
return self._json(200, {"user": who, "pass": pw}) return self._json(200, {"user": who, "pass": pw})
if not self.path.startswith("/dump"): if not self.path.startswith("/dump"):
return self._json(404, {"error": "not found"}) return self._json(404, {"error": "not found"})
u = check_auth(self.headers.get("Authorization")) u, is_admin = identity(self.headers)
if not u: if not u:
return self._deny() return self._deny()
night, req_user = self._parse() 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) night, files = self._resolve(night, tgt)
self._json(200, {"night": night, "user": tgt, "dry_run": True, self._json(200, {"night": night, "user": tgt, "dry_run": True,
"files": [str(f.relative_to(ROOT)) for f in files], "files": [str(f.relative_to(ROOT)) for f in files],
@@ -162,14 +149,14 @@ class H(BaseHTTPRequestHandler):
def do_POST(self): def do_POST(self):
if not self.path.startswith("/dump"): if not self.path.startswith("/dump"):
return self._json(404, {"error": "not found"}) return self._json(404, {"error": "not found"})
u = check_auth(self.headers.get("Authorization")) u, is_admin = identity(self.headers)
if not u: if not u:
return self._deny() return self._deny()
try: try:
night, req_user = self._parse() night, req_user = self._parse()
except Exception as e: except Exception as e:
return self._json(400, {"error": str(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) night, files = self._resolve(night, tgt)
moved, size = do_move(files, night) moved, size = do_move(files, night)
MARK.mkdir(exist_ok=True) MARK.mkdir(exist_ok=True)

View File

@@ -7,10 +7,11 @@
./configure.py --hosts AUTH WATCH [GIT [BOT [LIVE]]] set the Authentik/Jellyfin/Gitea/bot/live hostnames and apply (non-interactive; ./configure.py --hosts AUTH WATCH [GIT [BOT [LIVE]]] set the Authentik/Jellyfin/Gitea/bot/live hostnames and apply (non-interactive;
used by addCommunityMediaServer.sh) 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 — (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) 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 Discord bot token/guild), folders, restarts
Set VG_DRY=1 to write files only (no sudo / docker / folders). 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 encode zstd gzip
reverse_proxy 127.0.0.1:8096 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 Path("Caddyfile").write_text(f"""# vanguard:main
{domain} {{ {domain} {{
encode zstd gzip encode zstd gzip
{AUTHENTIK_FORWARD_AUTH}
# dump endpoint (python service on the host, does its own auth) # dump endpoint (python service on the host; identity from the X-Authentik-* headers Caddy just set)
handle /dump* {{ handle /dump* {{
reverse_proxy 127.0.0.1:8080 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) # Syncthing admin UI: Authentik + group {sync_group or 'any'}; Syncthing's own GUI login stays as a second layer
redir /sync /sync/ 308 {sync_gate} redir /sync /sync/ 308
handle_path /sync/* {{ handle_path /sync/* {{
reverse_proxy 127.0.0.1:8384 {{ reverse_proxy 127.0.0.1:8384 {{
header_up Host {{upstream_hostport}} header_up Host {{upstream_hostport}}
@@ -246,7 +259,7 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
file_server file_server
}} }}
# everything else is copyparty (web UI + WebDAV) # copyparty (web UI + WebDAV). It trusts X-Authentik-Username / -Groups from this proxy only.
handle {{ handle {{
reverse_proxy 127.0.0.1:3923 {{ reverse_proxy 127.0.0.1:3923 {{
flush_interval -1 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("LIVE_WEBHOOK_URL", "")
env.setdefault("APEX_HOST", "") # bare domain to serve (A record -> this box); empty = skip 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("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("BOT_REQUIRE_GROUP", "bot-users") # Caddy 403s anyone not in this Authentik group; empty = any logged-in member
env.setdefault("LIVE_REQUIRE_GROUP", "") env.setdefault("LIVE_REQUIRE_GROUP", "")
env["GIT_HOST"] = git_host or env.get("GIT_HOST", "git.example.com") env["GIT_HOST"] = git_host or env.get("GIT_HOST", "git.example.com")
@@ -353,13 +367,13 @@ paths:
~^({alt})/[A-Za-z0-9_.-]+$: {{}} ~^({alt})/[A-Za-z0-9_.-]+$: {{}}
""") """)
def write_copyparty(admin_pw, editors): def write_copyparty():
acc = f" admin: {admin_pw}\n" + "".join(f" {u}: {p}\n" for u, p in editors.items()) """copyparty behind Caddy + Authentik: identity and groups arrive as headers; permissions are per group."""
eds = ", ".join(editors) Path("copyparty.conf").write_text("""# copyparty — behind Caddy + Authentik forward-auth. (generated by configure.py)
r_line = f" r: {eds}\n" if eds else "" # Identity comes from the proxy: X-Authentik-Username / X-Authentik-Groups (groups separated by |).
rw_line = f" rw: {eds}\n" if eds else "" # No local accounts. Groups: editors (read streams/clips, write finals), media-admins (everything incl. trash).
Path("copyparty.conf").write_text(f"""# copyparty — editors browse/preview/download; finals are writable. (generated by configure.py) # No WebDAV: login is Discord only (browser session); download via the web UI (zip/tar for folders).
# Docs: https://github.com/9001/copyparty#server-config # Docs: https://github.com/9001/copyparty#identity-providers
[global] [global]
e2dsa # index files for search e2dsa # index files for search
@@ -368,49 +382,43 @@ def write_copyparty(admin_pw, editors):
hist: /cfg/hist hist: /cfg/hist
rproxy: 1 # trust first X-Forwarded-For hop (Caddy) 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 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 no-robots
name: vanguard name: vanguard
usernames # login needs username + password (default is password-only)
# dark skin for the login page (served by Caddy from ./dock)
html-head: <link rel="stylesheet" href="/dock/vanguard.css">
[accounts]
{acc}
# streams: read for editors, full for admin
[/streams] [/streams]
/w/streams /w/streams
accs: accs:
{r_line} rwmd: admin r: @editors
rwmd: @media-admins
# gamer clips (Syncthing drops them here)
[/clips] [/clips]
/w/clips /w/clips
accs: accs:
{r_line} rwmd: admin r: @editors
rwmd: @media-admins
# editors upload finished work here
[/finals] [/finals]
/w/finals /w/finals
accs: accs:
{rw_line} rwmd: admin rw: @editors
rwmd: @media-admins
# dumped nights, purged after 3 days — admin only
[/trash] [/trash]
/w/trash /w/trash
accs: accs:
rwmd: admin rwmd: @media-admins
""") """)
def write_streamers_json(streamers): def write_streamers_json(streamers):
import json as _json import json as _json
Path("streamers.json").write_text(_json.dumps({u.lower(): p for u, p in streamers.items()}, indent=2) + "\n") 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): def write_streamers_json(streamers):
h = lambda p: hashlib.sha256(p.encode()).hexdigest() import json as _json
body = "# user:sha256hex(password) (generated by configure.py)\n" Path("streamers.json").write_text(_json.dumps({u.lower(): p for u, p in streamers.items()}, indent=2) + "\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 run(cmd, **kw): def run(cmd, **kw):
print(" $", " ".join(cmd)) print(" $", " ".join(cmd))
@@ -418,13 +426,14 @@ def run(cmd, **kw):
return subprocess.call(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): 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(): 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("\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).") 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) if not yes(" continue anyway?", False): print("aborted; add streamers first ([s] in the menu)"); sys.exit(1)
print("\n== writing configs") print("\n== writing configs")
write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook) 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"]) run(["sudo", "install", "-d", "-o", os.environ.get("SUDO_USER") or os.environ.get("USER") or "debian", "/srv/live"])
print("== app data folders") print("== app data folders")
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache", run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache",
@@ -438,7 +447,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"]) run(["sudo", "install", "-m", "0644", "-o", "1000", "-g", "1000", "syncthing/stignore", f"/srv/media/clips/{u}/.stignore"])
print("== dump users + restart dumpd") print("== dump users + restart dumpd")
run(["sudo", "install", "-d", "-m", "0750", "/etc/vanguard"]) 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", "install", "-m", "0600", "streamers.json", "/etc/vanguard/streamers.json"])
run(["sudo", "systemctl", "restart", "dumpd"]) run(["sudo", "systemctl", "restart", "dumpd"])
print("== containers") print("== containers")
@@ -461,8 +469,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"== 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" 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" 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("== access : by Authentik group — editors (read streams/clips, write finals), media-admins (everything), admins (dump anyone)")
print("== editors :", ", ".join(f"{u} / {p}" for u, p in editors.items()) or "(none)") print(" Discord login only — no WebDAV; downloads via the web UI")
print("== streamers:") print("== streamers:")
for u, p in streamers.items(): for u, p in streamers.items():
print(f"\n {u} password {p}") print(f"\n {u} password {p}")
@@ -471,7 +479,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" 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})") print(f" Dock URL : https://{domain or 'HOST'}/dock/ (settings: host={domain or 'HOST'}, user={u}, pass={p})")
if not streamers: print(" (none)") 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'}/")
print(f" verify: curl -sI https://{domain or 'HOST'}/dock/ | head -1 (expect 200)") 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") print(f" curl -s -o /dev/null -w '%{{http_code}}\\n' https://{domain or 'HOST'}/dump (expect 401)\n")
@@ -510,8 +518,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.") print("\n-- Discord bot (optional; the Bot added to the Authentik Discord application). Enter keeps the current value.")
discord_token, discord_guild = ask_discord() discord_token, discord_guild = ask_discord()
live_webhook = ask(" Discord webhook URL for 'is live' notices (optional)", read_env().get("LIVE_WEBHOOK_URL", "")) 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/<name> and clips/<name>)") print("\n-- streamers (OBS publishers; each gets streams/<name> and clips/<name>)")
for u in list(streamers): for u in list(streamers):
k = ask(f" {u}: [k]eep [r]emove [p]assword reset", "k").lower()[:1] k = ask(f" {u}: [k]eep [r]emove [p]assword reset", "k").lower()[:1]
@@ -522,7 +528,6 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
n = ask_name("streamer", streamers) n = ask_name("streamer", streamers)
if not n: break if not n: break
streamers[n] = ask_pw(n) streamers[n] = ask_pw(n)
print("\n-- editors (copyparty logins: read streams/clips, write finals)")
for u in list(editors): for u in list(editors):
k = ask(f" {u}: [k]eep [r]emove [p]assword reset", "k").lower()[:1] k = ask(f" {u}: [k]eep [r]emove [p]assword reset", "k").lower()[:1]
if k == "r": del editors[u] if k == "r": del editors[u]
@@ -547,15 +552,13 @@ def ask_discord():
return tok, guild return tok, guild
def normal(domain, admin_pw, streamers, editors): def normal(domain, admin_pw, streamers, editors):
missing = [x for x, v in (("domain", domain), ("admin password", admin_pw)) if not v] if not domain:
if missing: print("domain not set yet — run ./configure.py --full"); sys.exit(1)
print(f"not set yet: {', '.join(missing)} — run ./configure.py --full"); sys.exit(1)
print(f"== MANAGE (domain {domain}; run --full to change hostnames/admin)") print(f"== MANAGE (domain {domain}; run --full to change hostnames/admin)")
discord = (None, None) discord = (None, None)
while True: 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) if c == "s": manage_users("streamer", streamers)
elif c == "e": manage_users("editor", editors)
elif c == "d": discord = ask_discord() elif c == "d": discord = ask_discord()
elif c == "v": show(domain, admin_pw, streamers, editors) elif c == "v": show(domain, admin_pw, streamers, editors)
elif c == "a": return discord elif c == "a": return discord
@@ -572,7 +575,7 @@ def main():
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return
if mode == "--hosts": 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 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() 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) >= 5: git_host = sys.argv[4].strip()
if len(sys.argv) >= 6: bot_host = sys.argv[5].strip() if len(sys.argv) >= 6: bot_host = sys.argv[5].strip()

View File

@@ -126,13 +126,10 @@ $("stop").onclick = async () => {
let armed = 0; let armed = 0;
$("dump").onclick = async () => { $("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 { try {
if (Date.now() - armed > 10000) { // first click: show what would go, arm for 10 s if (Date.now() - armed > 10000) { // first click: show what would go, arm for 10 s
status("checking what tonight contains…"); 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.error) throw new Error(dry.error);
if (!dry.files.length) return status(`nothing recorded for ${dry.night}`); if (!dry.files.length) return status(`nothing recorded for ${dry.night}`);
armed = Date.now(); 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).`); 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"; 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); if (r.error) throw new Error(r.error);
status(`dumped ${r.moved} file(s), ${r.gb} GB (recoverable until ${r.undo_until})`, "ok"); status(`dumped ${r.moved} file(s), ${r.gb} GB (recoverable until ${r.undo_until})`, "ok");
} catch (e) { status("dump failed: " + e.message, "bad"); } } catch (e) { status("dump failed: " + e.message, "bad"); }

View File

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

View File

@@ -69,9 +69,9 @@ export default function GoLive({ onExit }) {
<input value={title} onChange={e => setTitle(e.target.value)} disabled={phase === 'capturing'} /> <input value={title} onChange={e => setTitle(e.target.value)} disabled={phase === 'capturing'} />
<div className="hint"> <div className="hint">
Click <b>Start</b>, then pick the <b>WarDogs</b> window in Chrome's dialog and tick <b>Share audio</b>. Click <b>Start</b>, then pick the <b>WarDogs</b> window in Chrome's dialog and tick <b>Share audio</b>.
Window audio needs <b>Windows</b> with <b>Chrome or Edge 141 or newer</b>. On Linux and Mac, Chrome can only <b>Windows</b> with <b>Chrome or Edge 141 or newer</b> is the supported setup: window audio and the GPU
capture audio from a browser tab, so the window comes through without sound — use OBS with the dock there. encoder. On Linux and Mac Chrome has no window audio and (on NVIDIA) no hardware encoder, so the stream is
Capped at {MAX_KBPS / 1000} Mbps, up to 1440p60, H.264. silent and visibly soft — use OBS with the dock there. Capped at {MAX_KBPS / 1000} Mbps, up to 1440p60, H.264.
</div> </div>
<div className="warn-box"> <div className="warn-box">
<b>⚠ This stream is recorded.</b> Everything the window shows and every sound it makes is saved to the <b>⚠ This stream is recorded.</b> Everything the window shows and every sound it makes is saved to the

View File

@@ -5,7 +5,7 @@ After=network.target
[Service] [Service]
ExecStart=/usr/bin/python3 /usr/local/bin/dumpd.py 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 Environment=DUMP_TZ=America/New_York
Restart=always Restart=always
RestartSec=3 RestartSec=3