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
|
.env
|
||||||
bot/app/__pycache__/
|
bot/app/__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
streamers.json
|
|
||||||
# generated by configure.py on the server — never commit or scp these
|
# generated by configure.py on the server — never commit or scp these
|
||||||
mediamtx.yml
|
mediamtx.yml
|
||||||
copyparty.conf
|
copyparty.conf
|
||||||
Caddyfile
|
Caddyfile
|
||||||
|
stream-token.secret
|
||||||
|
|||||||
@@ -200,8 +200,7 @@ Checklist:
|
|||||||
admin = member of `admins`; drop `dump-users.txt`
|
admin = member of `admins`; drop `dump-users.txt`
|
||||||
- [ ] dock: `Dump tonight` sends no credentials; session cookie carries identity
|
- [ ] dock: `Dump tonight` sends no credentials; session cookie carries identity
|
||||||
|
|
||||||
### Phase 7 — dock polish + cleanup
|
### Phase 7 — dock polish + cleanup (built with phase 11; apply pending)
|
||||||
(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)
|
|
||||||
- [ ] `dumpd.py` gains `GET /me` → `{user, groups, stream_key}` for the logged-in streamer;
|
- [ ] `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
|
dock fills OBS settings from it — nobody types a stream password again
|
||||||
- [ ] `configure.py` shrinks: hostnames + streamers only (editors and admin live in Authentik)
|
- [ ] `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)
|
- [ ] 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
|
- [ ] 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
|
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.
|
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
|
- 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)
|
## 5. Streamer setup (once)
|
||||||
|
|
||||||
OBS → Settings → Stream → Custom: Server `rtmp://media.example.com:1935/`, key anything (the dock sets it).
|
Being a streamer = being in the Authentik **streamers** group (a Discord role via the roles→groups policy).
|
||||||
OBS → Tools → WebSocket Server Settings → enable, set a password.
|
There are no stream passwords or keys to hand out: the dock mints a **12-hour publish token** for the
|
||||||
OBS → Docks → Custom Browser Docks → `https://media.example.com/dock/` → log in with Discord once (the dock
|
logged-in Discord user each time Go Live is pressed and sets it as the OBS stream key itself.
|
||||||
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).
|
|
||||||
|
|
||||||
Stream key format the dock produces: `techy/Title?user=techy&pass=...`
|
OBS → Tools → WebSocket Server Settings → enable, set a password.
|
||||||
SRT equivalent if preferred: `srt://media.example.com:8890?streamid=publish:techy/Title:techy:PASS`
|
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)
|
## 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`).
|
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).
|
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
|
**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-key` (dumpd reads `/etc/vanguard/streamers.json`, identity from the Authentik header), so a
|
from `/me/stream-token` for the logged-in Discord user (member of `streamers`).
|
||||||
streamer's Authentik username must equal their MediaMTX name.
|
|
||||||
|
|
||||||
Bandwidth: no transcoding, every viewer gets the source bitrate (~15 Mbps) → ~30 concurrent viewers on
|
Bandwidth: no transcoding, every viewer gets the source bitrate (~15 Mbps) → ~30 concurrent viewers on
|
||||||
the 500 Mbps line. For a crowd, restream to Twitch from OBS.
|
the 500 Mbps line. For a crowd, restream to Twitch from OBS.
|
||||||
@@ -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).
|
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.
|
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
|
## Layout on the server
|
||||||
|
|
||||||
/srv/media/streams/<user>/<Title>/.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty)
|
/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 |
|
| 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 | — |
|
| 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** |
|
| 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) |
|
| 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) |
|
| Streamer (Windows) | **Go Live** from the browser: pick the WarDogs window (+audio on Windows Chrome 141+; Linux/Mac video only), streams as `<user>/HelmetCam-<date>`; no OBS needed | Live portal → Go Live | Discord; key fetched for the logged-in user — **verified end to end** (publish → `.live` → sweep → title folder) |
|
||||||
| Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | **local account** (Discord later) |
|
| Devs | Host code, review, CI | Gitea `https://git.…/`, ssh 2222 | **local account** (Discord later) |
|
||||||
| Bot dev | Push code, restart container, read logs; web UI at `https://bot.…/` | Gitea + compose; `bot/AGENT.md` | web UI behind Discord (Authentik), once its provider exists |
|
| Bot dev | Push code, restart container, read logs; web UI at `https://bot.…/` | Gitea + compose; `bot/AGENT.md` | web UI behind Discord (Authentik), once its provider exists |
|
||||||
| Admin | Add/remove streamers & editors, hostnames, bot token | `./configure.py` | ssh |
|
| Admin | 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 | Pair a gamer's device, approve their folder | Syncthing UI `https://ovh1…/sync/` | Discord + group `media-admins`, then Syncthing's GUI password |
|
||||||
| Admin | Groups, who may log in, app access | Authentik `https://auth.…/` | Discord or akadmin |
|
| Admin | Groups, who may log in, app access | Authentik `https://auth.…/` | Discord or akadmin |
|
||||||
| Admin | Install/upgrade the Jellyfin plugin | `./installJellyfinPlugin.sh` | ssh |
|
| Admin | Install/upgrade the Jellyfin plugin | `./installJellyfinPlugin.sh` | ssh |
|
||||||
@@ -43,7 +43,7 @@ Source of truth: this repo, `techy/Media-Server-Setup` on the stack's own Gitea
|
|||||||
|
|
||||||
| Service | Role | State |
|
| Service | Role | State |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| MediaMTX | RTMP/SRT ingest, records fMP4 | live |
|
| MediaMTX | RTMP/SRT ingest, records fMP4; 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) |
|
| 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** |
|
| 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** |
|
| 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`).
|
2. **Register the runner** (`./registerGiteaRunner.sh`).
|
||||||
3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty.
|
3. **`bot` CNAME + Authentik proxy provider** — first use of forward-auth; proves the pattern for copyparty.
|
||||||
4. **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.
|
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).
|
6. **Gitea login via Authentik** (OIDC source, auto-register, `admins` → Gitea admin).
|
||||||
7. **Housekeeping**: pin `AUTHENTIK_TAG` in `.env`, rotate the Discord client secret that was in a screenshot, nightly Postgres dump, Jellyfin login branding (Discord button first), check a non-admin Discord user's Jellyfin view.
|
7. **Housekeeping**: pin `AUTHENTIK_TAG` in `.env`, rotate the Discord client secret that was in a screenshot, nightly Postgres dump, Jellyfin login branding (Discord button first), check a non-admin Discord user's Jellyfin view.
|
||||||
8. **Medal filename pattern** in the plugin's timestamp parser — need one sample filename.
|
8. **Medal filename pattern** in the plugin's timestamp parser — need one sample filename.
|
||||||
9. **Home archive puller** on the home box.
|
9. **Home archive puller** on the home box.
|
||||||
10. **Plugin repository** served by Caddy so Jellyfin updates the plugin from its catalog.
|
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.
|
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.
|
13. **SYS-3** when transcoding or sweep speed hurts: Quick Sync, flip the plugin's policy toggle off.
|
||||||
|
|
||||||
## Verified behaviours worth remembering
|
## 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)
|
POST /dump identity from X-Authentik-Username / X-Authentik-Groups (set by Caddy after Authentik)
|
||||||
body (optional JSON): {"night":"YYYY-MM-DD","user":"name"}
|
body (optional JSON): {"night":"YYYY-MM-DD","user":"name"}
|
||||||
GET /dump -> lists what a dump would move (dry run)
|
GET /dump -> lists what a dump would move (dry run)
|
||||||
GET /me/stream-key -> the caller's MediaMTX credentials (Go Live)
|
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.
|
"Night" = everything the user recorded from 17:00 Eastern on <night> until 02:00 Eastern the next day.
|
||||||
Default night: today if it is 17:00 or later, else yesterday (so 00:00-02:00 still counts as last night).
|
Default night: today if it is 17:00 or later, else yesterday (so 00:00-02:00 still counts as last night).
|
||||||
Users may dump only their own prefix; members of the admins / media-admins groups may pass "user" to dump anyone, or "all".
|
Users may dump only their own prefix; members of the admins / media-admins groups may pass "user" to dump anyone, or "all".
|
||||||
"""
|
"""
|
||||||
import 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 datetime import datetime, timedelta, time
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
@@ -20,11 +24,14 @@ ROOT = Path(os.environ.get("MEDIA_ROOT", "/srv/media"))
|
|||||||
SRC_DIRS = [ROOT / "streams", ROOT / "clips"]
|
SRC_DIRS = [ROOT / "streams", ROOT / "clips"]
|
||||||
TRASH = ROOT / "trash"
|
TRASH = ROOT / "trash"
|
||||||
MARK = ROOT / ".markers"
|
MARK = ROOT / ".markers"
|
||||||
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_USER_HEADER = os.environ.get("AUTH_USER_HEADER", "X-Authentik-Username")
|
||||||
AUTH_GROUPS_HEADER = os.environ.get("AUTH_GROUPS_HEADER", "X-Authentik-Groups")
|
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())
|
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"))
|
TZ = ZoneInfo(os.environ.get("DUMP_TZ", "America/New_York"))
|
||||||
NIGHT_START = time(17, 0) # 5 pm
|
NIGHT_START = time(17, 0) # 5 pm
|
||||||
NIGHT_END = time(2, 0) # 2 am next day
|
NIGHT_END = time(2, 0) # 2 am next day
|
||||||
@@ -83,11 +90,39 @@ def do_move(files, night):
|
|||||||
d.rmdir()
|
d.rmdir()
|
||||||
return moved, size
|
return moved, size
|
||||||
|
|
||||||
def load_streamers():
|
def token_secret():
|
||||||
try:
|
try:
|
||||||
return json.loads(STREAMERS_FILE.read_text())
|
sec = TOKEN_SECRET_FILE.read_bytes().strip()
|
||||||
except (OSError, json.JSONDecodeError):
|
if len(sec) >= 16: return sec
|
||||||
return {}
|
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):
|
class H(BaseHTTPRequestHandler):
|
||||||
server_version = "dumpd/1"
|
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 req_user or auth_user # admins default to their own night; pass "all" or a name for others
|
||||||
return auth_user
|
return auth_user
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
if self.path.startswith("/me/stream-key"):
|
if self.server.server_port == AUTH_LISTEN[1]:
|
||||||
# Only reachable through Caddy behind Authentik forward-auth, which injects the username header.
|
return self._json(404, {"error": "not found"}) # the auth port serves POST /mtx-auth only
|
||||||
# dumpd binds to loopback, so a request carrying this header is one Caddy already authenticated.
|
if self.path.startswith("/me/stream-token") or self.path.startswith("/me/stream-key"):
|
||||||
who = (self.headers.get(AUTH_USER_HEADER) or "").strip().lower()
|
# Only reachable through Caddy behind Authentik forward-auth, which injects the identity headers.
|
||||||
|
who, _ = identity(self.headers)
|
||||||
if not who:
|
if not who:
|
||||||
return self._json(401, {"error": "not authenticated"})
|
return self._json(401, {"error": "not authenticated"})
|
||||||
streamers = load_streamers()
|
groups = {g.strip() for g in re.split(r"[|,]", self.headers.get(AUTH_GROUPS_HEADER) or "") if g.strip()}
|
||||||
pw = streamers.get(who)
|
if not (groups & PUBLISH_GROUPS):
|
||||||
if not pw:
|
return self._json(403, {"error": f"'{who}' is not in a publishing group ({', '.join(sorted(PUBLISH_GROUPS))}) — ask an admin for the Discord role"})
|
||||||
return self._json(403, {"error": f"no stream key for '{who}' — ask the admin to add you as a streamer with that exact name"})
|
try:
|
||||||
return self._json(200, {"user": who, "pass": pw})
|
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"):
|
if not self.path.startswith("/dump"):
|
||||||
return self._json(404, {"error": "not found"})
|
return self._json(404, {"error": "not found"})
|
||||||
u, is_admin = identity(self.headers)
|
u, is_admin = identity(self.headers)
|
||||||
@@ -147,6 +187,33 @@ class H(BaseHTTPRequestHandler):
|
|||||||
"files": [str(f.relative_to(ROOT)) for f in files],
|
"files": [str(f.relative_to(ROOT)) for f in files],
|
||||||
"gb": round(sum(f.stat().st_size for f in files) / 1e9, 2)})
|
"gb": round(sum(f.stat().st_size for f in files) / 1e9, 2)})
|
||||||
def do_POST(self):
|
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"):
|
if not self.path.startswith("/dump"):
|
||||||
return self._json(404, {"error": "not found"})
|
return self._json(404, {"error": "not found"})
|
||||||
u, is_admin = identity(self.headers)
|
u, is_admin = identity(self.headers)
|
||||||
@@ -169,5 +236,8 @@ class H(BaseHTTPRequestHandler):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
TRASH.mkdir(exist_ok=True); MARK.mkdir(exist_ok=True)
|
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()
|
ThreadingHTTPServer(LISTEN, H).serve_forever()
|
||||||
|
|||||||
85
configure.py
85
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)
|
(editors/admins are Authentik groups now: editors, media-admins, admins — nothing local)
|
||||||
(these are GENERATED on the server and gitignored; *.example.* in the repo are reference only —
|
(these are GENERATED on the server and gitignored; *.example.* in the repo are reference only —
|
||||||
never scp them over the live ones: that is how streamers got wiped once)
|
never scp them over the live ones: that is how streamers got wiped once)
|
||||||
Writes : Caddyfile, mediamtx.yml, copyparty.conf, 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
|
Discord bot token/guild), folders, restarts
|
||||||
Set VG_DRY=1 to write files only (no sudo / docker / folders).
|
Set VG_DRY=1 to write files only (no sudo / docker / folders).
|
||||||
"""
|
"""
|
||||||
@@ -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_HOST", "") # bare domain to serve (A record -> this box); empty = skip
|
||||||
env.setdefault("APEX_REDIRECT", "") # where the bare domain sends people, e.g. https://watch.HOST/
|
env.setdefault("APEX_REDIRECT", "") # where the bare domain sends people, e.g. https://watch.HOST/
|
||||||
env.setdefault("SYNC_REQUIRE_GROUP", "media-admins") # Caddy gate for /sync on the main host
|
env.setdefault("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("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("BOT_REQUIRE_GROUP", "bot-users") # Caddy 403s anyone not in this Authentik group; empty = any logged-in member
|
||||||
env.setdefault("LIVE_REQUIRE_GROUP", "")
|
env.setdefault("LIVE_REQUIRE_GROUP", "")
|
||||||
@@ -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()))
|
Path(".env").write_text("".join(f"{k}={v}\n" for k, v in env.items()))
|
||||||
os.chmod(".env", 0o600)
|
os.chmod(".env", 0o600)
|
||||||
|
|
||||||
def write_mediamtx(streamers, public_host=""):
|
def docker_gateway():
|
||||||
users = "".join(f""" - user: {u}
|
"""Host IP as seen from the compose network: MediaMTX (bridge) calls dumpd's /mtx-auth there."""
|
||||||
pass: {p}
|
if DRY: return "172.18.0.1"
|
||||||
permissions:
|
for net in ("vanguard-stream_default", "vanguard_default"):
|
||||||
- action: publish
|
try:
|
||||||
path: ~^{u}/[A-Za-z0-9_.-]+$
|
out = subprocess.run(["docker", "network", "inspect", net, "-f", "{{(index .IPAM.Config 0).Gateway}}"], capture_output=True, text=True, timeout=10)
|
||||||
""" for u, p in streamers.items()) or " []\n"
|
if out.returncode == 0 and out.stdout.strip(): return out.stdout.strip()
|
||||||
alt = "|".join(streamers) or "nobody"
|
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)
|
Path("mediamtx.yml").write_text(f"""# MediaMTX — ingest only. No playback endpoints exposed. (generated by configure.py)
|
||||||
logLevel: info
|
logLevel: info
|
||||||
|
|
||||||
@@ -347,17 +353,16 @@ srt: yes
|
|||||||
srtAddress: :8890
|
srtAddress: :8890
|
||||||
|
|
||||||
# ---- auth -------------------------------------------------------------
|
# ---- auth -------------------------------------------------------------
|
||||||
# Each streamer may publish only under their own prefix.
|
# Publishing is authorised by dumpd (/mtx-auth) with short-lived tokens minted for Discord-authenticated
|
||||||
# RTMP key: <user>/Title?user=<user>&pass=<pass>
|
# members of the streamers group. Reads are allowed; viewers are gated by Caddy + Authentik. No passwords.
|
||||||
# SRT: srt://HOST:8890?streamid=publish:<user>/Title:<user>:<pass>
|
# MediaMTX hot-reloads this file: changing auth needs no restart.
|
||||||
authMethod: internal
|
authMethod: http
|
||||||
authInternalUsers:
|
authHTTPAddress: http://{auth_gw}:8081/mtx-auth
|
||||||
- user: any
|
authHTTPExclude:
|
||||||
permissions:
|
- action: api
|
||||||
- action: read
|
- action: metrics
|
||||||
- action: playback
|
- action: pprof
|
||||||
- action: api
|
|
||||||
{users}
|
|
||||||
# ---- recording --------------------------------------------------------
|
# ---- recording --------------------------------------------------------
|
||||||
pathDefaults:
|
pathDefaults:
|
||||||
record: yes
|
record: yes
|
||||||
@@ -375,8 +380,8 @@ pathDefaults:
|
|||||||
runOnReadyRestart: no
|
runOnReadyRestart: no
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
# any name under a streamer's prefix; inherits pathDefaults
|
# <username>/<Title>; dumpd decides who may publish where (token user == prefix)
|
||||||
~^({alt})/[A-Za-z0-9_.-]+$: {{}}
|
~^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$: {{}}
|
||||||
""")
|
""")
|
||||||
|
|
||||||
def write_copyparty():
|
def write_copyparty():
|
||||||
@@ -426,14 +431,6 @@ def write_copyparty():
|
|||||||
rwmd: @media-admins
|
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):
|
def run(cmd, **kw):
|
||||||
print(" $", " ".join(cmd))
|
print(" $", " ".join(cmd))
|
||||||
if DRY: return 0
|
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)
|
if not yes(" continue anyway?", False): print("aborted; add streamers first ([s] in the menu)"); sys.exit(1)
|
||||||
print("\n== writing configs")
|
print("\n== writing configs")
|
||||||
write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook)
|
write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook)
|
||||||
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host); write_mediamtx(streamers, domain); write_copyparty(); 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"])
|
run(["sudo", "install", "-d", "-o", os.environ.get("SUDO_USER") or os.environ.get("USER") or "debian", "/srv/live"])
|
||||||
print("== app data folders")
|
print("== app data folders")
|
||||||
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache",
|
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/jellyfin/config", "/srv/appdata/jellyfin/cache",
|
||||||
"/srv/appdata/authentik/media", "/srv/appdata/authentik/templates", "/srv/appdata/authentik/certs"])
|
"/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", "/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"])
|
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:
|
for u in streamers:
|
||||||
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", f"/srv/media/streams/{u}", f"/srv/media/clips/{u}"])
|
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():
|
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"])
|
run(["sudo", "install", "-m", "0644", "-o", "1000", "-g", "1000", "syncthing/stignore", f"/srv/media/clips/{u}/.stignore"])
|
||||||
print("== dump users + restart dumpd")
|
print("== dump users + restart dumpd")
|
||||||
run(["sudo", "install", "-d", "-m", "0750", "/etc/vanguard"])
|
run(["sudo", "install", "-d", "-m", "0750", "/etc/vanguard"])
|
||||||
run(["sudo", "install", "-m", "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"])
|
run(["sudo", "systemctl", "restart", "dumpd"])
|
||||||
print("== containers")
|
print("== containers")
|
||||||
dc = ["docker", "compose"] if (DRY or subprocess.call(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0) else ["sudo", "docker", "compose"]
|
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 + ["up", "-d", "--build"]) # recreates only services whose compose definition changed
|
||||||
run(dc + ["restart", "mediamtx", "copyparty", "caddy"])
|
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=""):
|
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)'}")
|
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(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("== 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(" Discord login only — no WebDAV; downloads via the web UI")
|
||||||
print("== streamers:")
|
print("== streamers: anyone in the Authentik 'streamers' group (Discord role). No passwords, no keys to hand out.")
|
||||||
for u, p in streamers.items():
|
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"\n {u} password {p}")
|
print(f" and sets the stream key itself. Manual setups: dock → 'Manual OBS setup' (rtmp://{domain or 'HOST'}:1935/ + key).")
|
||||||
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(f"\n editors log in with Discord at https://{domain or 'HOST'}/")
|
print(f"\n editors log in with Discord at https://{domain or 'HOST'}/")
|
||||||
print(f" verify: curl -sI https://{domain or 'HOST'}/dock/ | head -1 (expect 200)")
|
print(f" verify: curl -sI https://{domain or 'HOST'}/dock/ | head -1 (expect 200)")
|
||||||
print(f" curl -s -o /dev/null -w '%{{http_code}}\\n' https://{domain or 'HOST'}/dump (expect 401)\n")
|
print(f" curl -s -o /dev/null -w '%{{http_code}}\\n' https://{domain or 'HOST'}/dump (expect 401)\n")
|
||||||
@@ -571,8 +566,8 @@ def normal(domain, admin_pw, streamers, editors):
|
|||||||
print(f"== MANAGE (domain {domain}; run --full to change hostnames/admin)")
|
print(f"== MANAGE (domain {domain}; run --full to change hostnames/admin)")
|
||||||
discord = (None, None)
|
discord = (None, None)
|
||||||
while True:
|
while True:
|
||||||
c = ask("\n[s]treamers [d]iscord bot token/guild [v]iew [a]pply & quit [q]uit without applying", "a").lower()[:1]
|
c = ask("\n[d]iscord bot token/guild [v]iew [a]pply & quit [q]uit without applying", "a").lower()[:1]
|
||||||
if c == "s": manage_users("streamer", streamers)
|
if c == "s": print(" streamers = the Authentik 'streamers' group (Discord role); nothing to manage here")
|
||||||
elif c == "d": discord = ask_discord()
|
elif c == "d": discord = ask_discord()
|
||||||
elif c == "v": show(domain, admin_pw, streamers, editors)
|
elif c == "v": show(domain, admin_pw, streamers, editors)
|
||||||
elif c == "a": return discord
|
elif c == "a": return discord
|
||||||
|
|||||||
178
dock/index.html
178
dock/index.html
@@ -5,29 +5,41 @@
|
|||||||
<title>Vanguard Stream</title>
|
<title>Vanguard Stream</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<style>
|
<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}
|
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{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}
|
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{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{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.live{background:var(--ok);color:#000}
|
||||||
button.dump{background:var(--bad)}
|
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}
|
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}
|
#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}
|
summary{color:var(--mut);cursor:pointer;font-size:12px}
|
||||||
.row{display:flex;gap:8px}
|
.row{display:flex;gap:8px}
|
||||||
.row button{margin-top:10px}
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>Vanguard Stream</h1>
|
<h1>Vanguard Stream <small id="who" class="who"></small></h1>
|
||||||
|
|
||||||
<label for="title">Stream title</label>
|
<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>
|
<button id="golive" class="live">Go Live</button>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button id="stop">Stop Stream</button>
|
<button id="stop">Stop Stream</button>
|
||||||
@@ -37,58 +49,115 @@
|
|||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Settings (saved in this dock)</summary>
|
<summary>Settings (saved in this dock)</summary>
|
||||||
<label>Ingest host (RTMP server)</label><input id="host" placeholder="media.example.com">
|
<label>Ingest host</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>OBS WebSocket password</label><input id="obspw" type="password">
|
<label>OBS WebSocket password</label><input id="obspw" type="password">
|
||||||
<label>OBS WebSocket port</label><input id="obsport" value="4455">
|
<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>
|
<button id="save">Save settings</button>
|
||||||
</details>
|
</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>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ---- settings --------------------------------------------------------------
|
// ---- settings --------------------------------------------------------------
|
||||||
const KEYS = ["host","user","pass","obspw","obsport"];
|
const KEYS = ["host","obspw","obsport","prefix","suffix"];
|
||||||
const $ = id => document.getElementById(id);
|
const $ = id => document.getElementById(id);
|
||||||
const status = (msg, cls) => { $("status").textContent = msg; $("status").style.color = cls==="bad"?"#e74c3c":cls==="ok"?"#2ecc71":"#9a9a9a"; };
|
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 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"); } }
|
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();
|
load(); $("save").onclick = save;
|
||||||
$("save").onclick = save;
|
|
||||||
|
|
||||||
const cfg = () => Object.fromEntries(KEYS.map(k => [k, $(k).value.trim()]));
|
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 ------------------------------------------------------
|
// ---- obs-websocket v5 ------------------------------------------------------
|
||||||
async function sha256b64(s){
|
async function sha256b64(s){ const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)); return btoa(String.fromCharCode(...new Uint8Array(h))); }
|
||||||
const h = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s));
|
function obs(c){
|
||||||
return btoa(String.fromCharCode(...new Uint8Array(h)));
|
|
||||||
}
|
|
||||||
function obs(cfg){
|
|
||||||
return new Promise((resolve, reject) => {
|
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;
|
const pending = new Map(); let n = 0;
|
||||||
ws.onerror = () => reject(new Error("cannot reach OBS WebSocket (enable it in Tools > WebSocket Server Settings)"));
|
ws.onerror = () => reject(new Error("cannot reach OBS WebSocket (enable it in Tools > WebSocket Server Settings)"));
|
||||||
ws.onmessage = async ev => {
|
ws.onmessage = async ev => {
|
||||||
const m = JSON.parse(ev.data);
|
const m = JSON.parse(ev.data);
|
||||||
if (m.op === 0) { // Hello
|
if (m.op === 0) {
|
||||||
let auth;
|
let auth;
|
||||||
if (m.d.authentication) {
|
if (m.d.authentication) { const secret = await sha256b64(c.obspw + m.d.authentication.salt); auth = await sha256b64(secret + m.d.authentication.challenge); }
|
||||||
const secret = await sha256b64(cfg.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}}));
|
ws.send(JSON.stringify({op:1, d:{rpcVersion:1, authentication:auth, eventSubscriptions:0}}));
|
||||||
} else if (m.op === 2) { // Identified
|
} else if (m.op === 2) {
|
||||||
resolve({
|
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(); } });
|
||||||
call(type, data){ return new Promise((res, rej) => {
|
} else if (m.op === 7) {
|
||||||
const id = String(++n); pending.set(id, {res, rej});
|
const p = pending.get(m.d.requestId); pending.delete(m.d.requestId); if (!p) return;
|
||||||
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;
|
|
||||||
m.d.requestStatus.result ? p.res(m.d.responseData) : p.rej(new Error(m.d.requestStatus.comment || m.d.requestType + " failed"));
|
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 ---------------------------------------------------------------
|
// ---- actions ---------------------------------------------------------------
|
||||||
$("golive").onclick = async () => {
|
$("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 (!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;
|
$("golive").disabled = true;
|
||||||
try {
|
try {
|
||||||
|
status("getting a stream token…");
|
||||||
|
const t = await mint(); // fresh token every Go Live; nothing stored
|
||||||
status("connecting to OBS…");
|
status("connecting to OBS…");
|
||||||
const o = await obs(c);
|
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",
|
||||||
await o.call("SetStreamServiceSettings", {
|
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key: `${t.user}/${title}?user=${encodeURIComponent(t.user)}&pass=${encodeURIComponent(t.token)}`, use_auth: false } });
|
||||||
streamServiceType: "rtmp_custom",
|
|
||||||
streamServiceSettings: { server: `rtmp://${c.host}:1935/`, key, use_auth: false }
|
|
||||||
});
|
|
||||||
const st = await o.call("GetStreamStatus");
|
const st = await o.call("GetStreamStatus");
|
||||||
if (st.outputActive) { await o.call("StopStream"); await new Promise(r => setTimeout(r, 1500)); }
|
if (st.outputActive) { await o.call("StopStream"); await new Promise(r => setTimeout(r, 1500)); }
|
||||||
await o.call("StartStream");
|
await o.call("StartStream"); o.close();
|
||||||
o.close();
|
status(`LIVE → ${t.user}/${title}`, "ok");
|
||||||
status(`LIVE → ${c.user}/${title}`, "ok");
|
|
||||||
} catch (e) { status(e.message, "bad"); }
|
} catch (e) { status(e.message, "bad"); }
|
||||||
$("golive").disabled = false;
|
$("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;
|
let armed = 0;
|
||||||
$("dump").onclick = async () => {
|
$("dump").onclick = async () => {
|
||||||
try {
|
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…");
|
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.error) throw new Error(dry.error);
|
||||||
if (!dry.files.length) return status(`nothing recorded for ${dry.night}`);
|
if (!dry.files.length) return status(`nothing recorded for ${dry.night}`);
|
||||||
armed = Date.now();
|
armed = Date.now(); $("dump").textContent = "Click again to confirm";
|
||||||
$("dump").textContent = "Click again to confirm";
|
|
||||||
setTimeout(() => { $("dump").textContent = "Dump tonight"; armed = 0; }, 10000);
|
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";
|
armed = 0; $("dump").textContent = "Dump tonight";
|
||||||
const r = await (await fetch("/dump", {method:"POST", headers:{"Content-Type":"application/json"}, body:"{}", credentials:"same-origin"})).json();
|
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");
|
status(`dumped ${r.moved} file(s), ${r.gb} GB (recoverable until ${r.undo_until})`, "ok");
|
||||||
} catch (e) { status("dump failed: " + e.message, "bad"); }
|
} catch (e) { status("dump failed: " + e.message, "bad"); }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
whoami(); renderPreview();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 limit rate over 30/minute burst 20 packets drop
|
||||||
tcp dport 3306 ct state new accept
|
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:
|
# 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
|
# 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.
|
# 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' : ''} />
|
<video ref={videoRef} autoPlay playsInline muted className={phase === 'live' || phase === 'capturing' ? 'show' : ''} />
|
||||||
{phase !== 'live' && (
|
{phase !== 'live' && (
|
||||||
<div className="golive-form">
|
<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'} />
|
<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">
|
<div className="hint">
|
||||||
Click <b>Start</b>, then pick the <b>WarDogs</b> window in Chrome's dialog and tick <b>Share audio</b>.
|
Click <b>Start</b>, then pick the <b>WarDogs</b> window in Chrome's dialog and tick <b>Share audio</b>.
|
||||||
<b>Windows</b> with <b>Chrome or Edge 141 or newer</b> is the supported setup: window audio and the GPU
|
<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)
|
const d = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 10)
|
||||||
return `HelmetCam-${d}`
|
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() {
|
export async function captureWindow() {
|
||||||
// Window-only picker with that window's own audio. Chrome/Edge 141+ honour windowAudio: "window"
|
// 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() {
|
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(() => ({}))
|
const body = await r.json().catch(() => ({}))
|
||||||
if (!r.ok) throw new Error(body.error || `stream key ${r.status}`)
|
if (!r.ok) throw new Error(body.error || `stream key ${r.status}`)
|
||||||
return body
|
return body
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
ExecStart=/usr/bin/python3 /usr/local/bin/dumpd.py
|
ExecStart=/usr/bin/python3 /usr/local/bin/dumpd.py
|
||||||
Environment=DUMP_ADMIN_GROUPS=admins,media-admins
|
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
|
Environment=DUMP_TZ=America/New_York
|
||||||
Restart=always
|
Restart=always
|
||||||
RestartSec=3
|
RestartSec=3
|
||||||
|
|||||||
Reference in New Issue
Block a user