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:
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)
|
||||
(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
|
||||
|
||||
Reference in New Issue
Block a user