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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -9,4 +9,3 @@ streamers.json
|
||||
mediamtx.yml
|
||||
copyparty.conf
|
||||
Caddyfile
|
||||
dump-users.txt
|
||||
|
||||
@@ -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`
|
||||
|
||||
31
README.md
31
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/<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
|
||||
|
||||
|
||||
18
STATUS.md
18
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 `<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.
|
||||
|
||||
59
bin/dumpd.py
59
bin/dumpd.py
@@ -1,14 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump-tonight endpoint. Moves one streamer's night into trash (soft delete).
|
||||
|
||||
POST /dump Authorization: Basic user:pass body (optional JSON): {"night":"YYYY-MM-DD","user":"name"}
|
||||
POST /dump identity from X-Authentik-Username / X-Authentik-Groups (set by Caddy after Authentik)
|
||||
body (optional JSON): {"night":"YYYY-MM-DD","user":"name"}
|
||||
GET /dump -> lists what a dump would move (dry run)
|
||||
GET /me/stream-key -> the caller's MediaMTX credentials (Go Live)
|
||||
|
||||
"Night" = everything the user recorded from 17:00 Eastern on <night> until 02:00 Eastern the next day.
|
||||
Default night: today if it is 17:00 or later, else yesterday (so 00:00-02:00 still counts as last night).
|
||||
Users may dump only their own prefix; 'admin' may pass "user" to dump anyone, or "all".
|
||||
Users may dump only their own prefix; members of the admins / media-admins groups may pass "user" to dump anyone, or "all".
|
||||
"""
|
||||
import base64, hashlib, json, os, shutil, sys
|
||||
import json, os, re, shutil, sys
|
||||
from datetime import datetime, timedelta, time
|
||||
from zoneinfo import ZoneInfo
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -18,36 +20,23 @@ ROOT = Path(os.environ.get("MEDIA_ROOT", "/srv/media"))
|
||||
SRC_DIRS = [ROOT / "streams", ROOT / "clips"]
|
||||
TRASH = ROOT / "trash"
|
||||
MARK = ROOT / ".markers"
|
||||
USERS_FILE = Path(os.environ.get("DUMP_USERS", "/etc/vanguard/dump-users.txt"))
|
||||
STREAMERS_FILE = Path(os.environ.get("STREAMERS_FILE", "/etc/vanguard/streamers.json")) # {"techy": "pass", ...} written by configure.py
|
||||
AUTH_USER_HEADER = os.environ.get("AUTH_USER_HEADER", "X-Authentik-Username")
|
||||
AUTH_GROUPS_HEADER = os.environ.get("AUTH_GROUPS_HEADER", "X-Authentik-Groups")
|
||||
ADMIN_GROUPS = set(g.strip() for g in os.environ.get("DUMP_ADMIN_GROUPS", "admins,media-admins").split(",") if g.strip())
|
||||
LISTEN = ("127.0.0.1", 8080)
|
||||
TZ = ZoneInfo(os.environ.get("DUMP_TZ", "America/New_York"))
|
||||
NIGHT_START = time(17, 0) # 5 pm
|
||||
NIGHT_END = time(2, 0) # 2 am next day
|
||||
|
||||
def load_users():
|
||||
users = {}
|
||||
for line in USERS_FILE.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or ":" not in line:
|
||||
continue
|
||||
u, h = line.split(":", 1)
|
||||
users[u.strip()] = h.strip().lower()
|
||||
return users
|
||||
|
||||
def check_auth(header):
|
||||
if not header or not header.startswith("Basic "):
|
||||
return None
|
||||
try:
|
||||
raw = base64.b64decode(header[6:]).decode()
|
||||
u, p = raw.split(":", 1)
|
||||
except Exception:
|
||||
return None
|
||||
users = load_users()
|
||||
if users.get(u) == hashlib.sha256(p.encode()).hexdigest():
|
||||
return u
|
||||
return None
|
||||
def identity(headers):
|
||||
"""(username, is_admin) from the headers Caddy sets after Authentik; (None, False) if absent.
|
||||
dumpd binds to loopback only, so these headers can only come from Caddy."""
|
||||
who = (headers.get(AUTH_USER_HEADER) or "").strip().lower()
|
||||
if not who:
|
||||
return None, False
|
||||
groups = {g.strip() for g in re.split(r"[|,]", headers.get(AUTH_GROUPS_HEADER) or "") if g.strip()}
|
||||
return who, bool(groups & ADMIN_GROUPS)
|
||||
|
||||
def default_night(now=None):
|
||||
now = now or datetime.now(TZ)
|
||||
@@ -110,9 +99,7 @@ class H(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
def _deny(self):
|
||||
self.send_response(401)
|
||||
self.send_header("WWW-Authenticate", 'Basic realm="dump"')
|
||||
self.end_headers()
|
||||
self._json(401, {"error": "not authenticated (no identity header from the proxy)"})
|
||||
def _parse(self):
|
||||
n = int(self.headers.get("Content-Length") or 0)
|
||||
body = json.loads(self.rfile.read(n) or b"{}") if n else {}
|
||||
@@ -132,9 +119,9 @@ class H(BaseHTTPRequestHandler):
|
||||
if pf:
|
||||
return prev, pf
|
||||
return n, files
|
||||
def _target(self, auth_user, req_user):
|
||||
if auth_user == "admin":
|
||||
return req_user or "all"
|
||||
def _target(self, auth_user, is_admin, req_user):
|
||||
if is_admin:
|
||||
return req_user or auth_user # admins default to their own night; pass "all" or a name for others
|
||||
return auth_user
|
||||
def do_GET(self):
|
||||
if self.path.startswith("/me/stream-key"):
|
||||
@@ -150,11 +137,11 @@ class H(BaseHTTPRequestHandler):
|
||||
return self._json(200, {"user": who, "pass": pw})
|
||||
if not self.path.startswith("/dump"):
|
||||
return self._json(404, {"error": "not found"})
|
||||
u = check_auth(self.headers.get("Authorization"))
|
||||
u, is_admin = identity(self.headers)
|
||||
if not u:
|
||||
return self._deny()
|
||||
night, req_user = self._parse()
|
||||
tgt = self._target(u, req_user)
|
||||
tgt = self._target(u, is_admin, req_user)
|
||||
night, files = self._resolve(night, tgt)
|
||||
self._json(200, {"night": night, "user": tgt, "dry_run": True,
|
||||
"files": [str(f.relative_to(ROOT)) for f in files],
|
||||
@@ -162,14 +149,14 @@ class H(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
if not self.path.startswith("/dump"):
|
||||
return self._json(404, {"error": "not found"})
|
||||
u = check_auth(self.headers.get("Authorization"))
|
||||
u, is_admin = identity(self.headers)
|
||||
if not u:
|
||||
return self._deny()
|
||||
try:
|
||||
night, req_user = self._parse()
|
||||
except Exception as e:
|
||||
return self._json(400, {"error": str(e)})
|
||||
tgt = self._target(u, req_user)
|
||||
tgt = self._target(u, is_admin, req_user)
|
||||
night, files = self._resolve(night, tgt)
|
||||
moved, size = do_move(files, night)
|
||||
MARK.mkdir(exist_ok=True)
|
||||
|
||||
98
configure.py
98
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: <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()
|
||||
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user