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

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

View File

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