Phase 7+11: token-based publishing — MediaMTX authMethod http -> dumpd /mtx-auth (bridge-only port 8081); dumpd mints 12 h HMAC tokens for Discord-authenticated streamers; dock rewritten (no passwords, naming rules with live preview, prefix/suffix, manual-setup accordion with hidden token); portal uses tokens; configure.py drops streamers; MediaMTX hot-reload, Caddy reload — no stream interruption

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:22:15 -05:00
parent 2a74cb7b40
commit dcc347e1ea
11 changed files with 301 additions and 144 deletions

2
.gitignore vendored
View File

@@ -4,8 +4,8 @@ __pycache__/
.env
bot/app/__pycache__/
*.pyc
streamers.json
# generated by configure.py on the server — never commit or scp these
mediamtx.yml
copyparty.conf
Caddyfile
stream-token.secret

View File

@@ -200,8 +200,7 @@ Checklist:
admin = member of `admins`; drop `dump-users.txt`
- [ ] dock: `Dump tonight` sends no credentials; session cookie carries identity
### Phase 7 — dock polish + cleanup
(partly done: dumpd `/me/stream-key` exists and the live portal's Go Live uses it; the OBS dock still holds its own copy of the key)
### Phase 7 — dock polish + cleanup (built with phase 11; apply pending)
- [ ] `dumpd.py` gains `GET /me` → `{user, groups, stream_key}` for the logged-in streamer;
dock fills OBS settings from it — nobody types a stream password again
- [ ] `configure.py` shrinks: hostnames + streamers only (editors and admin live in Authentik)
@@ -264,7 +263,11 @@ Same pattern as Jellyfin: Authentik is the identity, Gitea trusts it.
- [ ] policy on the Authentik application: who may reach Gitea at all (e.g. `admins` + a `devs` group)
- [ ] test with a non-admin Discord user; confirm admin mapping via `admins` group
### Phase 11 — MediaMTX auth through Authentik (no stream keys anywhere)
### Phase 11 — MediaMTX auth through Authentik (no stream keys anywhere) (built; apply pending)
Implemented variant: **short-lived HMAC tokens minted by dumpd** for Discord-authenticated members of
`streamers` (no Authentik app passwords — "Discord only" decision). Dock and portal mint a token per Go Live;
manual setups get it from the dock's accordion. MediaMTX `authMethod: http` → dumpd `/mtx-auth` on 8081
(Docker bridge only). Hot-reloaded: no MediaMTX restart.
Goal: nobody holds a MediaMTX password. "May publish" = Authentik identity + `streamers` group (filled by
Discord roles); the publish path must start with the user's own name.
- MediaMTX `authMethod: http`, `authHTTPAddress: http://127.0.0.1:8080/mtx-auth` (dumpd becomes the

View File

@@ -90,16 +90,25 @@ until Docker is restarted.
## 5. Streamer setup (once)
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/` → 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** (dumps the night of the
Discord account you are logged in as; `admins`/`media-admins` may dump anyone's).
Being a streamer = being in the Authentik **streamers** group (a Discord role via the roles→groups policy).
There are no stream passwords or keys to hand out: the dock mints a **12-hour publish token** for the
logged-in Discord user each time Go Live is pressed and sets it as the OBS stream key itself.
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`
OBS → Tools → WebSocket Server Settings → enable, set a password.
OBS → Docks → Custom Browser Docks → `https://media.example.com/dock/` → log in with Discord once.
In the dock's Settings: ingest host (prefilled), OBS WebSocket password/port, optional title prefix/suffix
(`{date}` → YYYY-MM-DD, e.g. prefix `HelmetCam-` or suffix `-{date}`).
Per stream: type a title, click **Go Live**. The dock shows the final stream name (`<you>/<Title>`) as you type
and enforces the naming rules the metadata relies on: letters, digits, `_ . -` only, no double underscore
(it separates title and timestamp in filenames), no leading dot, max 64 chars.
After a bad night: **Dump tonight** (dumps the night of the account you are logged in as; `admins`/`media-admins`
may dump anyone's).
Manual OBS/other software: dock → **Manual OBS setup** shows the RTMP server, the stream key and the SRT URL
with the token hidden (Show / Copy / Fresh token). Tokens expire after 12 h; a running stream is not cut when
its token expires, but a reconnect needs a fresh one.
Browser streaming without OBS: the live portal's **Go Live** (Windows Chrome 141+ for window audio + GPU encode).
## 5b. Syncthing hardening (do once, in the server's Syncthing UI)
@@ -208,9 +217,8 @@ asked by `configure.py --full`) can post when a stream starts.
embedded outpost → group policy (e.g. everyone in the server, or `streamers`+`editors`+`admins`).
3. Dev box: `./deployLive.sh` builds `live/` and copies `dist/` to `/srv/live` (Caddy serves it directly).
**Go Live** button (window audio: Windows + Chrome/Edge 141+; Linux/Mac get video only): broadcast a window from the browser (no OBS) as `<user>/HelmetCam-<date>` — key comes
from `/me/stream-key` (dumpd reads `/etc/vanguard/streamers.json`, identity from the Authentik header), so a
streamer's Authentik username must equal their MediaMTX name.
**Go Live** button (window audio: Windows + Chrome/Edge 141+; Linux/Mac get video only): broadcast a window from the browser (no OBS) as `<user>/HelmetCam-<date>` — a fresh publish token comes
from `/me/stream-token` for the logged-in Discord user (member of `streamers`).
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.
@@ -272,6 +280,15 @@ Copy `home/rclone-archive.sh` to the home machine, set the vars, run from cron n
It connects over SFTP as `debian` (all media files are owned by uid 1000 so it can delete them).
Pulls everything older than 15 days off the server, including the hidden `.src/` raw files.
## Publish authentication (no passwords)
MediaMTX asks dumpd (`POST /mtx-auth` on 127.0.0.1:8081, reachable only from Docker's bridge) for every
connection. Reads/playback are allowed (viewers are gated by Caddy + Authentik). Publishing needs a token
minted by `GET /me/stream-token` behind Authentik for a member of `streamers`/`admins`/`media-admins`; dumpd
checks the HMAC signature (`/etc/vanguard/stream-token.secret`, generated into `.env` as `STREAM_TOKEN_SECRET`),
the expiry, and that the path starts with that user's name. Changing this needs **no MediaMTX restart**:
MediaMTX hot-reloads `mediamtx.yml`; `configure.py` reloads Caddy in place and only restarts copyparty.
## Layout on the server
/srv/media/streams/<user>/<Title>/.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty)

View File

@@ -25,7 +25,7 @@ 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/` | Discord login in the dock (once); stream password in dock settings for the OBS key |
| Streamer | Type a title (live preview + naming rules), Go Live, Stop. "Dump tonight" if it was junk (3-day undo). Manual-setup accordion with endpoints and a hidden token | OBS browser dock `https://ovh1…/dock/` | Discord login in the dock (once); dock mints a 12 h token per Go Live — **no passwords** (pending apply) |
| 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…/` | Discord (Authentik); rights by group `editors` / `media-admins` — **pending apply** |
| Anyone in the Discord server | Watch recordings and clips by streamer and season | Jellyfin `https://watch.…/` | Discord (Authentik) |
@@ -33,7 +33,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
| 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 | Hostnames, bot token/guild, webhook; streamers/editors are Discord roles → Authentik groups | `./configure.py` | ssh |
| 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 |
@@ -43,7 +43,7 @@ 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 |
| MediaMTX | RTMP/SRT ingest, records fMP4; publish auth delegated to dumpd (tokens) | live; auth switch **pending apply** (hot-reload, no restart) |
| 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** |
@@ -69,14 +69,13 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
2. **Register the runner** (`./registerGiteaRunner.sh`).
3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty.
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. Dock fetches the caller's own stream key (phase 7) — or skip straight to phase 11 (no keys).
5. **Apply phase 7+11** (token publish auth): `configure.py` apply (MediaMTX hot-reloads; no stream interruption), new dumpd + unit, firewall rule for 8081 from Docker, dock upload, portal deploy. Then every streamer must be in the Authentik `streamers` group.
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.
9. **Home archive puller** on the home box.
10. **Plugin repository** served by Caddy so Jellyfin updates the plugin from its catalog.
11. **Clip submissions** (Medal/Twitch/YouTube links, Discord uploads) — belongs to the bot.
12. **MediaMTX auth through Authentik** (plan phase 11): no stream keys; Go Live uses the Authentik JWT, OBS uses an Authentik app password; "streamer" = group membership from a Discord role.
13. **SYS-3** when transcoding or sweep speed hurts: Quick Sync, flip the plugin's policy toggle off.
## Verified behaviours worth remembering

View File

@@ -4,13 +4,17 @@
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)
GET /me/stream-token -> short-lived publish token for the caller (member of streamers/admins); the dock
and the live portal set it as the OBS/WHIP credential. No stored passwords anywhere.
POST /mtx-auth -> MediaMTX authHTTP hook: allows read/playback; allows publish iff user/pass carries a valid
token whose user owns the path prefix. Tokens are HMAC-signed with /etc/vanguard/stream-token.secret.
"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; members of the admins / media-admins groups may pass "user" to dump anyone, or "all".
"""
import json, os, re, shutil, sys
import base64, hashlib, hmac, json, os, re, shutil, sys
import time as _time
from datetime import datetime, timedelta, time
from zoneinfo import ZoneInfo
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -20,11 +24,14 @@ ROOT = Path(os.environ.get("MEDIA_ROOT", "/srv/media"))
SRC_DIRS = [ROOT / "streams", ROOT / "clips"]
TRASH = ROOT / "trash"
MARK = ROOT / ".markers"
STREAMERS_FILE = Path(os.environ.get("STREAMERS_FILE", "/etc/vanguard/streamers.json")) # {"techy": "pass", ...} written by configure.py
TOKEN_SECRET_FILE = Path(os.environ.get("STREAM_TOKEN_SECRET_FILE", "/etc/vanguard/stream-token.secret"))
TOKEN_TTL_H = float(os.environ.get("STREAM_TOKEN_TTL_HOURS", "12"))
PUBLISH_GROUPS = set(g.strip() for g in os.environ.get("PUBLISH_GROUPS", "streamers,admins,media-admins").split(",") if g.strip())
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) # /dump, /me/* — only via Caddy (identity headers)
AUTH_LISTEN = (os.environ.get("MTX_AUTH_BIND", "0.0.0.0"), int(os.environ.get("MTX_AUTH_PORT", "8081"))) # /mtx-auth only — reachable from Docker's bridge (firewall), used by MediaMTX
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
@@ -83,11 +90,39 @@ def do_move(files, night):
d.rmdir()
return moved, size
def load_streamers():
def token_secret():
try:
return json.loads(STREAMERS_FILE.read_text())
except (OSError, json.JSONDecodeError):
return {}
sec = TOKEN_SECRET_FILE.read_bytes().strip()
if len(sec) >= 16: return sec
except OSError:
pass
return None
def mint_token(user):
"""user|exp signed: <b64url(user|exp)>.<hmac hex[:40]> — URL/RTMP/SRT-safe characters only."""
sec = token_secret()
if not sec: raise RuntimeError("no stream token secret")
exp = int(_time.time() + TOKEN_TTL_H * 3600)
payload = f"{user}|{exp}".encode()
sig = hmac.new(sec, payload, hashlib.sha256).hexdigest()[:40]
return base64.urlsafe_b64encode(payload).decode().rstrip("=") + "." + sig, exp
def verify_token(token):
"""-> username or None"""
sec = token_secret()
if not sec or not token or "." not in token: return None
b64, sig = token.rsplit(".", 1)
try:
payload = base64.urlsafe_b64decode(b64 + "=" * (-len(b64) % 4))
except Exception:
return None
if not hmac.compare_digest(hmac.new(sec, payload, hashlib.sha256).hexdigest()[:40], sig): return None
try:
user, exp = payload.decode().split("|", 1)
if int(exp) < _time.time(): return None
except Exception:
return None
return user.lower()
class H(BaseHTTPRequestHandler):
server_version = "dumpd/1"
@@ -124,17 +159,22 @@ class H(BaseHTTPRequestHandler):
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"):
# Only reachable through Caddy behind Authentik forward-auth, which injects the username header.
# dumpd binds to loopback, so a request carrying this header is one Caddy already authenticated.
who = (self.headers.get(AUTH_USER_HEADER) or "").strip().lower()
if self.server.server_port == AUTH_LISTEN[1]:
return self._json(404, {"error": "not found"}) # the auth port serves POST /mtx-auth only
if self.path.startswith("/me/stream-token") or self.path.startswith("/me/stream-key"):
# Only reachable through Caddy behind Authentik forward-auth, which injects the identity headers.
who, _ = identity(self.headers)
if not who:
return self._json(401, {"error": "not authenticated"})
streamers = load_streamers()
pw = streamers.get(who)
if not pw:
return self._json(403, {"error": f"no stream key for '{who}' — ask the admin to add you as a streamer with that exact name"})
return self._json(200, {"user": who, "pass": pw})
groups = {g.strip() for g in re.split(r"[|,]", self.headers.get(AUTH_GROUPS_HEADER) or "") if g.strip()}
if not (groups & PUBLISH_GROUPS):
return self._json(403, {"error": f"'{who}' is not in a publishing group ({', '.join(sorted(PUBLISH_GROUPS))}) — ask an admin for the Discord role"})
try:
tok, exp = mint_token(who)
except RuntimeError as e:
return self._json(500, {"error": str(e)})
# "pass" kept for the dock/portal: the token IS the password for MediaMTX
return self._json(200, {"user": who, "pass": tok, "token": tok, "expires": exp, "ttl_hours": TOKEN_TTL_H})
if not self.path.startswith("/dump"):
return self._json(404, {"error": "not found"})
u, is_admin = identity(self.headers)
@@ -147,6 +187,33 @@ class H(BaseHTTPRequestHandler):
"files": [str(f.relative_to(ROOT)) for f in files],
"gb": round(sum(f.stat().st_size for f in files) / 1e9, 2)})
def do_POST(self):
if self.server.server_port == AUTH_LISTEN[1] and not self.path.startswith("/mtx-auth"):
return self._json(404, {"error": "not found"})
if self.path.startswith("/mtx-auth"):
# MediaMTX authHTTP: 200 = allow, anything else = deny. Body: user, password, token, ip, action, path, protocol, id, query
n = int(self.headers.get("Content-Length") or 0)
try:
req = json.loads(self.rfile.read(n) or b"{}")
except json.JSONDecodeError:
return self._json(400, {"error": "bad json"})
action = req.get("action", "")
if action in ("read", "playback"):
return self._json(200, {"ok": True}) # viewers are gated by Caddy + Authentik
if action != "publish":
return self._json(401, {"error": f"action {action} not allowed"})
cred = req.get("password") or req.get("token") or ""
if cred.lower().startswith("bearer "): cred = cred[7:]
if ":" in cred and "." in cred.split(":", 1)[1]: cred = cred.split(":", 1)[1] # "user:token" form
user = verify_token(cred)
path = (req.get("path") or "").lower()
if not user:
print(f"{datetime.now(TZ):%F %T} mtx-auth DENY publish path={path} ip={req.get('ip')} (bad/expired token)", flush=True)
return self._json(401, {"error": "invalid or expired stream token"})
if not re.fullmatch(r"[a-z0-9_.-]+/[a-z0-9_.-]+", path) or not path.startswith(user + "/"):
print(f"{datetime.now(TZ):%F %T} mtx-auth DENY publish user={user} path={path} (not their prefix)", flush=True)
return self._json(401, {"error": "path must be <your-username>/<Title>"})
print(f"{datetime.now(TZ):%F %T} mtx-auth allow publish user={user} path={path} ip={req.get('ip')}", flush=True)
return self._json(200, {"ok": True})
if not self.path.startswith("/dump"):
return self._json(404, {"error": "not found"})
u, is_admin = identity(self.headers)
@@ -169,5 +236,8 @@ class H(BaseHTTPRequestHandler):
if __name__ == "__main__":
TRASH.mkdir(exist_ok=True); MARK.mkdir(exist_ok=True)
print(f"dumpd listening on {LISTEN[0]}:{LISTEN[1]} tz={TZ.key} night={NIGHT_START:%H:%M}-{NIGHT_END:%H:%M}", flush=True)
import threading
print(f"dumpd listening on {LISTEN[0]}:{LISTEN[1]} (dump, me) and {AUTH_LISTEN[0]}:{AUTH_LISTEN[1]} (mtx-auth) tz={TZ.key} night={NIGHT_START:%H:%M}-{NIGHT_END:%H:%M} token-secret={'ok' if token_secret() else 'MISSING'}", flush=True)
auth_srv = ThreadingHTTPServer(AUTH_LISTEN, H)
threading.Thread(target=auth_srv.serve_forever, daemon=True).start()
ThreadingHTTPServer(LISTEN, H).serve_forever()

View File

@@ -11,7 +11,7 @@ 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, streamers.json -> /etc/vanguard, .env (generated secrets +
Writes : Caddyfile, mediamtx.yml, copyparty.conf, stream-token.secret -> /etc/vanguard, .env (generated secrets +
Discord bot token/guild), folders, restarts
Set VG_DRY=1 to write files only (no sudo / docker / folders).
"""
@@ -288,6 +288,7 @@ def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", b
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("STREAM_TOKEN_SECRET", secrets.token_urlsafe(48)) # signs publish tokens (dumpd)
env.setdefault("COPYPARTY_IDP_KEY", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32]) # Caddy->copyparty idp-h-key
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", "")
@@ -310,14 +311,19 @@ def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", b
Path(".env").write_text("".join(f"{k}={v}\n" for k, v in env.items()))
os.chmod(".env", 0o600)
def write_mediamtx(streamers, public_host=""):
users = "".join(f""" - user: {u}
pass: {p}
permissions:
- action: publish
path: ~^{u}/[A-Za-z0-9_.-]+$
""" for u, p in streamers.items()) or " []\n"
alt = "|".join(streamers) or "nobody"
def docker_gateway():
"""Host IP as seen from the compose network: MediaMTX (bridge) calls dumpd's /mtx-auth there."""
if DRY: return "172.18.0.1"
for net in ("vanguard-stream_default", "vanguard_default"):
try:
out = subprocess.run(["docker", "network", "inspect", net, "-f", "{{(index .IPAM.Config 0).Gateway}}"], capture_output=True, text=True, timeout=10)
if out.returncode == 0 and out.stdout.strip(): return out.stdout.strip()
except Exception:
pass
return "172.18.0.1"
def write_mediamtx(public_host=""):
auth_gw = docker_gateway()
Path("mediamtx.yml").write_text(f"""# MediaMTX — ingest only. No playback endpoints exposed. (generated by configure.py)
logLevel: info
@@ -347,17 +353,16 @@ srt: yes
srtAddress: :8890
# ---- auth -------------------------------------------------------------
# Each streamer may publish only under their own prefix.
# RTMP key: <user>/Title?user=<user>&pass=<pass>
# SRT: srt://HOST:8890?streamid=publish:<user>/Title:<user>:<pass>
authMethod: internal
authInternalUsers:
- user: any
permissions:
- action: read
- action: playback
- action: api
{users}
# Publishing is authorised by dumpd (/mtx-auth) with short-lived tokens minted for Discord-authenticated
# members of the streamers group. Reads are allowed; viewers are gated by Caddy + Authentik. No passwords.
# MediaMTX hot-reloads this file: changing auth needs no restart.
authMethod: http
authHTTPAddress: http://{auth_gw}:8081/mtx-auth
authHTTPExclude:
- action: api
- action: metrics
- action: pprof
# ---- recording --------------------------------------------------------
pathDefaults:
record: yes
@@ -375,8 +380,8 @@ pathDefaults:
runOnReadyRestart: no
paths:
# any name under a streamer's prefix; inherits pathDefaults
~^({alt})/[A-Za-z0-9_.-]+$: {{}}
# <username>/<Title>; dumpd decides who may publish where (token user == prefix)
~^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$: {{}}
""")
def write_copyparty():
@@ -426,14 +431,6 @@ def write_copyparty():
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_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))
if DRY: return 0
@@ -447,26 +444,29 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis
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(); write_streamers_json(streamers)
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host); write_mediamtx(domain); write_copyparty()
Path("stream-token.secret").write_text(read_env()["STREAM_TOKEN_SECRET"] + "\n"); os.chmod("stream-token.secret", 0o600)
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",
"/srv/appdata/authentik/media", "/srv/appdata/authentik/templates", "/srv/appdata/authentik/certs"])
run(["sudo", "install", "-d", "/srv/appdata/authentik/db", "/srv/appdata/authentik/redis", "/srv/appdata/mariadb"])
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/discord-bot", "/srv/appdata/gitea", "/srv/appdata/act_runner"])
print("== folders for streamers")
print("== folders for legacy streamer entries (MediaMTX creates folders on first publish anyway)")
for u in streamers:
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", f"/srv/media/streams/{u}", f"/srv/media/clips/{u}"])
if not DRY and not Path(f"/srv/media/clips/{u}/.stignore").exists():
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", "0600", "streamers.json", "/etc/vanguard/streamers.json"])
run(["sudo", "install", "-m", "0600", "stream-token.secret", "/etc/vanguard/stream-token.secret"])
run(["sudo", "systemctl", "restart", "dumpd"])
print("== containers")
dc = ["docker", "compose"] if (DRY or subprocess.call(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0) else ["sudo", "docker", "compose"]
run(dc + ["up", "-d", "--build"])
run(dc + ["restart", "mediamtx", "copyparty", "caddy"])
run(dc + ["up", "-d", "--build"]) # recreates only services whose compose definition changed
run(dc + ["restart", "copyparty"]) # copyparty re-reads its config on start
run(dc + ["exec", "caddy", "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]) # zero-downtime
print(" (mediamtx hot-reloads mediamtx.yml itself — live streams are not interrupted)")
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
print(f"\n== domain : {domain or '(not set)'}")
@@ -485,14 +485,9 @@ def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
print(f" gitea : db gitea user gitea pass {env.get('GITEA_DB_PASSWORD','(run apply)')} (created on MariaDB first init, else ./createGiteaDb.sh)")
print("== access : by Authentik group — editors (read streams/clips, write finals), media-admins (everything), admins (dump anyone)")
print(" Discord login only — no WebDAV; downloads via the web UI")
print("== streamers:")
for u, p in streamers.items():
print(f"\n {u} password {p}")
print(f" OBS server : rtmp://{domain or 'HOST'}:1935/")
print(f" OBS key : {u}/TITLE?user={u}&pass={p} (the dock sets this for them)")
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("== streamers: anyone in the Authentik 'streamers' group (Discord role). No passwords, no keys to hand out.")
print(f" OBS: add the dock https://{domain or 'HOST'}/dock/ in OBS, log in with Discord, Go Live — it mints a 12 h token")
print(f" and sets the stream key itself. Manual setups: dock → 'Manual OBS setup' (rtmp://{domain or 'HOST'}:1935/ + key).")
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" curl -s -o /dev/null -w '%{{http_code}}\\n' https://{domain or 'HOST'}/dump (expect 401)\n")
@@ -571,8 +566,8 @@ def normal(domain, admin_pw, streamers, editors):
print(f"== MANAGE (domain {domain}; run --full to change hostnames/admin)")
discord = (None, None)
while True:
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)
c = ask("\n[d]iscord bot token/guild [v]iew [a]pply & quit [q]uit without applying", "a").lower()[:1]
if c == "s": print(" streamers = the Authentik 'streamers' group (Discord role); nothing to manage here")
elif c == "d": discord = ask_discord()
elif c == "v": show(domain, admin_pw, streamers, editors)
elif c == "a": return discord

View File

@@ -5,29 +5,41 @@
<title>Vanguard Stream</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root { --bg:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --ok:#2ecc71; --bad:#e74c3c; --box:#2a2a2a; }
:root { --bg:#1e1e1e; --fg:#e6e6e6; --mut:#9a9a9a; --acc:#3d8bfd; --ok:#2ecc71; --bad:#e74c3c; --warn:#f1c40f; --box:#2a2a2a; }
html,body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.4 system-ui,sans-serif}
main{padding:12px;max-width:420px}
main{padding:12px;max-width:440px}
h1{font-size:15px;margin:0 0 10px;color:var(--mut);font-weight:600}
h1 small{font-weight:400}
label{display:block;margin:8px 0 2px;color:var(--mut);font-size:12px}
input{width:100%;box-sizing:border-box;padding:8px;border:1px solid #444;border-radius:6px;background:var(--box);color:var(--fg);font-size:14px}
input[readonly]{color:var(--mut)}
button{width:100%;margin-top:10px;padding:11px;border:0;border-radius:6px;font-size:15px;font-weight:600;cursor:pointer;color:#fff;background:var(--acc)}
button.live{background:var(--ok);color:#000}
button.dump{background:var(--bad)}
button.small{width:auto;padding:6px 10px;font-size:13px;margin:6px 6px 0 0;background:#3a3a3a}
button:disabled{opacity:.5;cursor:default}
#status{margin-top:10px;padding:8px;border-radius:6px;background:var(--box);color:var(--mut);min-height:1.4em;white-space:pre-wrap}
details{margin-top:14px}
details{margin-top:14px;border-top:1px solid #333;padding-top:8px}
summary{color:var(--mut);cursor:pointer;font-size:12px}
.row{display:flex;gap:8px}
.row button{margin-top:10px}
.preview{font-family:ui-monospace,monospace;font-size:12px;color:var(--mut);margin-top:4px;word-break:break-all}
.preview b{color:var(--fg)}
.rule{color:var(--bad);font-size:12px;margin-top:4px}
.kv{font-family:ui-monospace,monospace;font-size:12px;background:var(--box);border-radius:6px;padding:8px;margin-top:6px;word-break:break-all}
.kv .k{color:var(--mut)}
.tok{letter-spacing:1px}
.who{color:var(--mut);font-size:12px}
</style>
</head>
<body>
<main>
<h1>Vanguard Stream</h1>
<h1>Vanguard Stream <small id="who" class="who"></small></h1>
<label for="title">Stream title</label>
<input id="title" placeholder="VanguardTraining10-10-2025" autocomplete="off">
<input id="title" placeholder="VanguardTraining" autocomplete="off">
<div class="preview">stream name: <b id="preview">—</b></div>
<div class="rule" id="rule"></div>
<button id="golive" class="live">Go Live</button>
<div class="row">
<button id="stop">Stop Stream</button>
@@ -37,58 +49,115 @@
<details>
<summary>Settings (saved in this dock)</summary>
<label>Ingest host (RTMP server)</label><input id="host" placeholder="media.example.com">
<label>Your streamer name</label><input id="user" placeholder="techy">
<label>Your stream password</label><input id="pass" type="password">
<label>Ingest host</label><input id="host" placeholder="media.example.com">
<label>OBS WebSocket password</label><input id="obspw" type="password">
<label>OBS WebSocket port</label><input id="obsport" value="4455">
<label>Title prefix (optional, e.g. <code>HelmetCam-</code>)</label><input id="prefix" placeholder="">
<label>Title suffix (optional; <code>{date}</code> becomes YYYY-MM-DD)</label><input id="suffix" placeholder="-{date}">
<button id="save">Save settings</button>
</details>
<details id="manual">
<summary>Manual OBS setup (endpoints + token)</summary>
<div class="kv"><span class="k">RTMP server </span><span id="m_rtmp">—</span></div>
<div class="kv"><span class="k">Stream key </span><span id="m_key">—</span></div>
<div class="kv"><span class="k">SRT URL </span><span id="m_srt">—</span></div>
<div class="kv"><span class="k">Token </span><span id="m_tok" class="tok">•••••••••••• (hidden)</span><br><span class="k" id="m_exp"></span></div>
<button class="small" id="m_show">Show token</button>
<button class="small" id="m_copy_key">Copy stream key</button>
<button class="small" id="m_copy_srt">Copy SRT URL</button>
<button class="small" id="m_fresh">Fresh token (12 h)</button>
<div class="who" style="margin-top:6px">Tokens are minted for your Discord login and expire; the Go Live button does all of this automatically.</div>
</details>
</main>
<script>
// ---- settings --------------------------------------------------------------
const KEYS = ["host","user","pass","obspw","obsport"];
const KEYS = ["host","obspw","obsport","prefix","suffix"];
const $ = id => document.getElementById(id);
const status = (msg, cls) => { $("status").textContent = msg; $("status").style.color = cls==="bad"?"#e74c3c":cls==="ok"?"#2ecc71":"#9a9a9a"; };
function load(){ try{ KEYS.forEach(k => { const v = localStorage.getItem("vg."+k); if (v!==null) $(k).value = v; }); }catch(e){} }
function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); status("settings saved"); }catch(e){ status("could not save settings (storage blocked)", "bad"); } }
load();
$("save").onclick = save;
function load(){ try{ KEYS.forEach(k => { const v = localStorage.getItem("vg."+k); if (v!==null) $(k).value = v; }); }catch(e){} if(!$("host").value) $("host").value = location.hostname; }
function save(){ try{ KEYS.forEach(k => localStorage.setItem("vg."+k, $(k).value.trim())); status("settings saved"); }catch(e){ status("could not save settings (storage blocked)", "bad"); } renderManual(); }
load(); $("save").onclick = save;
const cfg = () => Object.fromEntries(KEYS.map(k => [k, $(k).value.trim()]));
const cleanTitle = t => t.trim().replace(/\s+/g, "-").replace(/[^A-Za-z0-9_.-]/g, "");
// ---- naming rules (must match the server: the sweep publishes <Title>__<timestamp>.mp4) ----------------
const today = () => { const d = new Date(); return new Date(d.getTime() - d.getTimezoneOffset()*60000).toISOString().slice(0,10); };
function buildTitle(raw){
const c = cfg();
let t = (c.prefix||"") + raw.trim() + (c.suffix||"").replace("{date}", today());
t = t.replace(/\s+/g, "-") // spaces -> dashes
.replace(/[^A-Za-z0-9_.-]/g, "") // allowed characters only
.replace(/_{2,}/g, "_") // "__" is reserved: it separates title from timestamp in filenames
.replace(/^[.-]+/, "") // no leading dot (hidden) or dash
.slice(0, 64);
return t;
}
function ruleText(raw){
const r = [];
if (/__/.test(raw)) r.push("double underscore is reserved (becomes single)");
if (/[^A-Za-z0-9_.\- ]/.test(raw)) r.push("only letters, digits, space, _ . - are kept");
if (raw.trim().startsWith(".")) r.push("cannot start with a dot");
if ((cfg().prefix + raw + cfg().suffix).length > 64) r.push("longer than 64 characters: trimmed");
return r.join(" · ");
}
let me = null; // {user, token, expires}
function renderPreview(){
const t = buildTitle($("title").value);
$("preview").textContent = t ? `${me ? me.user : "<you>"}/${t}` : "—";
$("rule").textContent = ruleText($("title").value);
renderManual(t);
}
$("title").addEventListener("input", renderPreview);
["prefix","suffix"].forEach(k => $(k).addEventListener("input", renderPreview));
// ---- identity + tokens (the dock is behind Authentik: same-origin fetch carries the Discord session) -----
async function whoami(){
try {
const r = await fetch("/whoami", {credentials:"same-origin"});
const m = (await r.text()).match(/user:\s*(\S+)/); if (m) $("who").textContent = "— " + m[1];
} catch(e){}
}
async function mint(){
const r = await fetch("/me/stream-token", {credentials:"same-origin"});
const b = await r.json().catch(()=>({}));
if (!r.ok) throw new Error(b.error || `token ${r.status}`);
me = b; renderPreview(); return b;
}
let showTok = false;
function renderManual(t){
const c = cfg(); const title = t ?? buildTitle($("title").value) || "TITLE";
const user = me ? me.user : "<you>", tok = me ? me.token : "<token>";
$("m_rtmp").textContent = `rtmp://${c.host||location.hostname}:1935/`;
$("m_key").textContent = `${user}/${title}?user=${user}&pass=${showTok ? tok : "••••••••"}`;
$("m_srt").textContent = `srt://${c.host||location.hostname}:8890?streamid=publish:${user}/${title}:${user}:${showTok ? tok : "••••••••"}`;
$("m_tok").textContent = me ? (showTok ? me.token : "•••••••••••• (hidden)") : "— click “Fresh token”";
$("m_exp").textContent = me ? `valid until ${new Date(me.expires*1000).toLocaleString()}` : "";
}
$("m_show").onclick = () => { showTok = !showTok; $("m_show").textContent = showTok ? "Hide token" : "Show token"; renderManual(); };
$("m_fresh").onclick = async () => { try { await mint(); status("fresh token minted (12 h)", "ok"); } catch(e){ status(e.message, "bad"); } };
async function copy(text, what){ try { await navigator.clipboard.writeText(text); status(`${what} copied`, "ok"); } catch(e){ status("clipboard blocked — select the text manually", "bad"); } }
$("m_copy_key").onclick = async () => { if (!me) await mint().catch(e=>status(e.message,"bad")); if (me) copy(`${me.user}/${buildTitle($("title").value)||"TITLE"}?user=${me.user}&pass=${me.token}`, "stream key"); };
$("m_copy_srt").onclick = async () => { if (!me) await mint().catch(e=>status(e.message,"bad")); if (me) copy(`srt://${cfg().host}:8890?streamid=publish:${me.user}/${buildTitle($("title").value)||"TITLE"}:${me.user}:${me.token}`, "SRT URL"); };
$("manual").addEventListener("toggle", () => { if ($("manual").open) renderManual(); });
// ---- obs-websocket v5 ------------------------------------------------------
async function sha256b64(s){
const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
return btoa(String.fromCharCode(...new Uint8Array(h)));
}
function obs(cfg){
async function sha256b64(s){ const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return btoa(String.fromCharCode(...new Uint8Array(h))); }
function obs(c){
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${cfg.obsport||4455}`);
const ws = new WebSocket(`ws://127.0.0.1:${c.obsport||4455}`);
const pending = new Map(); let n = 0;
ws.onerror = () => reject(new Error("cannot reach OBS WebSocket (enable it in Tools > WebSocket Server Settings)"));
ws.onmessage = async ev => {
const m = JSON.parse(ev.data);
if (m.op === 0) { // Hello
if (m.op === 0) {
let auth;
if (m.d.authentication) {
const secret = await sha256b64(cfg.obspw + m.d.authentication.salt);
auth = await sha256b64(secret + m.d.authentication.challenge);
}
if (m.d.authentication) { const secret = await sha256b64(c.obspw + m.d.authentication.salt); auth = await sha256b64(secret + m.d.authentication.challenge); }
ws.send(JSON.stringify({op:1, d:{rpcVersion:1, authentication:auth, eventSubscriptions:0}}));
} else if (m.op === 2) { // Identified
resolve({
call(type, data){ return new Promise((res, rej) => {
const id = String(++n); pending.set(id, {res, rej});
ws.send(JSON.stringify({op:6, d:{requestType:type, requestId:id, requestData:data||{}}}));
}); },
close(){ ws.close(); }
});
} else if (m.op === 7) { // RequestResponse
const p = pending.get(m.d.requestId); pending.delete(m.d.requestId);
if (!p) return;
} else if (m.op === 2) {
resolve({ call(type, data){ return new Promise((res, rej) => { const id = String(++n); pending.set(id, {res, rej}); ws.send(JSON.stringify({op:6, d:{requestType:type, requestId:id, requestData:data||{}}})); }); }, close(){ ws.close(); } });
} else if (m.op === 7) {
const p = pending.get(m.d.requestId); pending.delete(m.d.requestId); if (!p) return;
m.d.requestStatus.result ? p.res(m.d.responseData) : p.rej(new Error(m.d.requestStatus.comment || m.d.requestType + " failed"));
}
};
@@ -98,44 +167,37 @@ function obs(cfg){
// ---- actions ---------------------------------------------------------------
$("golive").onclick = async () => {
const c = cfg(), title = cleanTitle($("title").value);
const c = cfg(), title = buildTitle($("title").value);
if (!title) return status("type a title first", "bad");
if (!c.host || !c.user || !c.pass) return status("fill in settings first", "bad");
if (!c.host) return status("set the ingest host in settings", "bad");
$("golive").disabled = true;
try {
status("getting a stream token…");
const t = await mint(); // fresh token every Go Live; nothing stored
status("connecting to OBS…");
const o = await obs(c);
const key = `${c.user}/${title}?user=${encodeURIComponent(c.user)}&pass=${encodeURIComponent(c.pass)}`;
await o.call("SetStreamServiceSettings", {
streamServiceType: "rtmp_custom",
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key, use_auth: false }
});
await o.call("SetStreamServiceSettings", { streamServiceType: "rtmp_custom",
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key: `${t.user}/${title}?user=${encodeURIComponent(t.user)}&pass=${encodeURIComponent(t.token)}`, use_auth: false } });
const st = await o.call("GetStreamStatus");
if (st.outputActive) { await o.call("StopStream"); await new Promise(r => setTimeout(r, 1500)); }
await o.call("StartStream");
o.close();
status(`LIVE → ${c.user}/${title}`, "ok");
await o.call("StartStream"); o.close();
status(`LIVE → ${t.user}/${title}`, "ok");
} catch (e) { status(e.message, "bad"); }
$("golive").disabled = false;
};
$("stop").onclick = async () => {
try { const o = await obs(cfg()); await o.call("StopStream"); o.close(); status("stream stopped"); }
catch (e) { status(e.message, "bad"); }
};
$("stop").onclick = async () => { try { const o = await obs(cfg()); await o.call("StopStream"); o.close(); status("stream stopped"); } catch (e) { status(e.message, "bad"); } };
let armed = 0;
$("dump").onclick = async () => {
try {
if (Date.now() - armed > 10000) { // first click: show what would go, arm for 10 s
if (Date.now() - armed > 10000) {
status("checking what tonight contains…");
const dry = await (await fetch("/dump", {credentials:"same-origin"})).json(); // identity = your Discord login (Authentik)
const dry = await (await fetch("/dump", {credentials:"same-origin"})).json();
if (dry.error) throw new Error(dry.error);
if (!dry.files.length) return status(`nothing recorded for ${dry.night}`);
armed = Date.now();
$("dump").textContent = "Click again to confirm";
armed = Date.now(); $("dump").textContent = "Click again to confirm";
setTimeout(() => { $("dump").textContent = "Dump tonight"; armed = 0; }, 10000);
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 an admin).`);
}
armed = 0; $("dump").textContent = "Dump tonight";
const r = await (await fetch("/dump", {method:"POST", headers:{"Content-Type":"application/json"}, body:"{}", credentials:"same-origin"})).json();
@@ -143,6 +205,8 @@ $("dump").onclick = async () => {
status(`dumped ${r.moved} file(s), ${r.gb} GB (recoverable until ${r.undo_until})`, "ok");
} catch (e) { status("dump failed: " + e.message, "bad"); }
};
whoami(); renderPreview();
</script>
</body>
</html>

View File

@@ -55,6 +55,9 @@ table inet vanguard {
tcp dport 3306 ct state new limit rate over 30/minute burst 20 packets drop
tcp dport 3306 ct state new accept
# dumpd /mtx-auth for MediaMTX (container -> host gateway): only from Docker bridge interfaces
iifname { "docker0", "br-*" } tcp dport 8081 accept
# NOT opened on purpose — these bind to 127.0.0.1 only and are reached via Caddy or an ssh tunnel:
# 9000 authentik · 8096 jellyfin · 3923 copyparty · 8384 syncthing UI · 8080 dumpd
# Docker publishes them on loopback, so its NAT never forwards outside traffic to them either.

View File

@@ -65,8 +65,9 @@ export default function GoLive({ onExit }) {
<video ref={videoRef} autoPlay playsInline muted className={phase === 'live' || phase === 'capturing' ? 'show' : ''} />
{phase !== 'live' && (
<div className="golive-form">
<label>Stream title <small>(becomes the folder and the Jellyfin title)</small></label>
<label>Stream title <small>(becomes the Jellyfin title; letters, digits, _ . - ; no double underscore)</small></label>
<input value={title} onChange={e => setTitle(e.target.value)} disabled={phase === 'capturing'} />
<div className="hint" style={{marginTop: '-4px'}}>stream name: <b>{key ? key.user : '<you>'}/{cleanTitle(title) || '—'}</b></div>
<div className="hint">
Click <b>Start</b>, then pick the <b>WarDogs</b> window in Chrome's dialog and tick <b>Share audio</b>.
<b>Windows</b> with <b>Chrome or Edge 141 or newer</b> is the supported setup: window audio and the GPU

View File

@@ -7,7 +7,8 @@ export function defaultTitle(now = new Date()) {
const d = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 10)
return `HelmetCam-${d}`
}
export const cleanTitle = t => t.trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9_.-]/g, '')
// Must match the dock and the server: the sweep publishes <Title>__<timestamp>.mp4, so "__" is reserved.
export const cleanTitle = t => t.trim().replace(/\s+/g, '-').replace(/[^A-Za-z0-9_.-]/g, '').replace(/_{2,}/g, '_').replace(/^[.-]+/, '').slice(0, 64)
export async function captureWindow() {
// Window-only picker with that window's own audio. Chrome/Edge 141+ honour windowAudio: "window"
@@ -105,7 +106,8 @@ export async function publish({ stream, path, user, pass, onState }) {
}
export async function fetchStreamKey() {
const r = await fetch('/me/stream-key', { credentials: 'same-origin' })
// a fresh 12 h publish token for the logged-in Discord user (member of streamers); nothing is stored
const r = await fetch('/me/stream-token', { credentials: 'same-origin' })
const body = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(body.error || `stream key ${r.status}`)
return body

View File

@@ -6,6 +6,9 @@ After=network.target
[Service]
ExecStart=/usr/bin/python3 /usr/local/bin/dumpd.py
Environment=DUMP_ADMIN_GROUPS=admins,media-admins
Environment=PUBLISH_GROUPS=streamers,admins,media-admins
Environment=STREAM_TOKEN_TTL_HOURS=12
Environment=STREAM_TOKEN_SECRET_FILE=/etc/vanguard/stream-token.secret
Environment=DUMP_TZ=America/New_York
Restart=always
RestartSec=3