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

1
.gitignore vendored
View File

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

View File

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

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 --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/<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).
## 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

View File

@@ -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 `<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) |
| 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.

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)

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;
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: <link rel="stylesheet" href="/dock/vanguard.css">
[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/<name> and clips/<name>)")
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()

View File

@@ -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"); }

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

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