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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
41
README.md
41
README.md
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
106
bin/dumpd.py
106
bin/dumpd.py
@@ -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()
|
||||
|
||||
83
configure.py
83
configure.py
@@ -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
|
||||
# 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
|
||||
{users}
|
||||
- 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
|
||||
|
||||
178
dock/index.html
178
dock/index.html
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user