Phase 5+6: main host behind Authentik — copyparty trusts X-Authentik-* headers with group permissions (editors, media-admins), dumpd authorizes from headers (admins/media-admins), /sync gated to media-admins, dock drops its dump password; configure.py loses editors/admin

WebDAV continues via Authentik app passwords (provider: Intercept header authentication).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 14:55:48 -05:00
parent 76a7d06df4
commit 50c037e514
9 changed files with 109 additions and 117 deletions

View File

@@ -7,10 +7,11 @@
./configure.py --hosts AUTH WATCH [GIT [BOT [LIVE]]] set the Authentik/Jellyfin/Gitea/bot/live hostnames and apply (non-interactive;
used by addCommunityMediaServer.sh)
Reads : Caddyfile (hostnames), mediamtx.yml (streamers), copyparty.conf (editors + admin), .env
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, dump-users.txt -> /etc/vanguard, .env (generated secrets +
Writes : Caddyfile, mediamtx.yml, copyparty.conf, streamers.json -> /etc/vanguard, .env (generated secrets +
Discord bot token/guild), folders, restarts
Set VG_DRY=1 to write files only (no sudo / docker / folders).
"""
@@ -222,18 +223,30 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
encode zstd gzip
reverse_proxy 127.0.0.1:8096
}}
"""
sync_group = env.get("SYNC_REQUIRE_GROUP", "media-admins")
sync_gate = ""
if sync_group:
g = re.escape(sync_group)
sync_gate = f""" @sync_lacks_group {{
path /sync /sync/*
not header_regexp X-Authentik-Groups (^|\\|){g}(\\||$)
}}
handle @sync_lacks_group {{
respond "Syncthing admin is for the '{sync_group}' group." 403
}}
"""
Path("Caddyfile").write_text(f"""# vanguard:main
{domain} {{
encode zstd gzip
# dump endpoint (python service on the host, does its own auth)
{AUTHENTIK_FORWARD_AUTH}
# dump endpoint (python service on the host; identity from the X-Authentik-* headers Caddy just set)
handle /dump* {{
reverse_proxy 127.0.0.1:8080
}}
# Syncthing admin UI (its own login; strip the prefix, present a local Host to pass its host check)
redir /sync /sync/ 308
# Syncthing admin UI: Authentik + group {sync_group or 'any'}; Syncthing's own GUI login stays as a second layer
{sync_gate} redir /sync /sync/ 308
handle_path /sync/* {{
reverse_proxy 127.0.0.1:8384 {{
header_up Host {{upstream_hostport}}
@@ -246,7 +259,7 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
file_server
}}
# everything else is copyparty (web UI + WebDAV)
# copyparty (web UI + WebDAV). It trusts X-Authentik-Username / -Groups from this proxy only.
handle {{
reverse_proxy 127.0.0.1:3923 {{
flush_interval -1
@@ -263,6 +276,7 @@ def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", b
env.setdefault("LIVE_WEBHOOK_URL", "")
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("BOT_REQUIRE_GROUP", "bot-users") # Caddy 403s anyone not in this Authentik group; empty = any logged-in member
env.setdefault("LIVE_REQUIRE_GROUP", "")
env["GIT_HOST"] = git_host or env.get("GIT_HOST", "git.example.com")
@@ -353,13 +367,14 @@ paths:
~^({alt})/[A-Za-z0-9_.-]+$: {{}}
""")
def write_copyparty(admin_pw, editors):
acc = f" admin: {admin_pw}\n" + "".join(f" {u}: {p}\n" for u, p in editors.items())
eds = ", ".join(editors)
r_line = f" r: {eds}\n" if eds else ""
rw_line = f" rw: {eds}\n" if eds else ""
Path("copyparty.conf").write_text(f"""# copyparty — editors browse/preview/download; finals are writable. (generated by configure.py)
# Docs: https://github.com/9001/copyparty#server-config
def write_copyparty():
"""copyparty behind Caddy + Authentik: identity and groups arrive as headers; permissions are per group."""
Path("copyparty.conf").write_text("""# copyparty — behind Caddy + Authentik forward-auth. (generated by configure.py)
# Identity comes from the proxy: X-Authentik-Username / X-Authentik-Groups (groups separated by |).
# No local accounts. Groups: editors (read streams/clips, write finals), media-admins (everything incl. trash).
# WebDAV: same URL, HTTP Basic with the Authentik username + an Authentik *app password*
# (the proxy provider must have "Intercept header authentication" enabled).
# Docs: https://github.com/9001/copyparty#identity-providers
[global]
e2dsa # index files for search
@@ -368,49 +383,43 @@ def write_copyparty(admin_pw, editors):
hist: /cfg/hist
rproxy: 1 # trust first X-Forwarded-For hop (Caddy)
xff-src: 172.16.0.0/12, 10.0.0.0/8, 192.168.0.0/16, 127.0.0.0/8
idp-h-usr: X-Authentik-Username
idp-h-grp: X-Authentik-Groups
idp-gsep: |
no-robots
name: vanguard
usernames # login needs username + password (default is password-only)
# dark skin for the login page (served by Caddy from ./dock)
html-head: <link rel="stylesheet" href="/dock/vanguard.css">
[accounts]
{acc}
# streams: read for editors, full for admin
[/streams]
/w/streams
accs:
{r_line} rwmd: admin
r: @editors
rwmd: @media-admins
# gamer clips (Syncthing drops them here)
[/clips]
/w/clips
accs:
{r_line} rwmd: admin
r: @editors
rwmd: @media-admins
# editors upload finished work here
[/finals]
/w/finals
accs:
{rw_line} rwmd: admin
rw: @editors
rwmd: @media-admins
# dumped nights, purged after 3 days — admin only
[/trash]
/w/trash
accs:
rwmd: admin
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_dump_users(admin_pw, streamers):
h = lambda p: hashlib.sha256(p.encode()).hexdigest()
body = "# user:sha256hex(password) (generated by configure.py)\n"
body += "".join(f"{u}:{h(p)}\n" for u, p in streamers.items())
body += f"admin:{h(admin_pw)}\n"
Path("dump-users.txt").write_text(body)
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))
@@ -418,13 +427,14 @@ def run(cmd, **kw):
return subprocess.call(cmd, **kw)
def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", live_webhook=None):
# admin_pw / editors are legacy no-ops: access is by Authentik group now
if not streamers and Path("mediamtx.yml").exists() and "CHANGEME" in Path("mediamtx.yml").read_text():
print("\n!! mediamtx.yml contains CHANGEME placeholders — it looks like the repo template was copied over the")
print(" generated config. Applying now would leave NO streamers (OBS ingest and the dump button stop working).")
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(admin_pw, editors); write_dump_users(admin_pw, streamers); write_streamers_json(streamers)
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host); write_mediamtx(streamers, domain); write_copyparty(); write_streamers_json(streamers)
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",
@@ -438,7 +448,6 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis
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", "0640", "dump-users.txt", "/etc/vanguard/dump-users.txt"])
run(["sudo", "install", "-m", "0600", "streamers.json", "/etc/vanguard/streamers.json"])
run(["sudo", "systemctl", "restart", "dumpd"])
print("== containers")
@@ -461,8 +470,8 @@ def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
print(f"== mariadb : {domain or 'HOST'}:3306 (public, root local-only) root pass {env.get('MARIADB_ROOT_PASSWORD','(run apply)')}")
print(f" bot : db {env.get('BOT_DB_NAME','vanguardbot')} user {env.get('BOT_DB_USER','bot')} pass {env.get('BOT_DB_PASSWORD','(run apply)')}")
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"== admin : admin / {admin_pw or '(not set)'} (copyparty + dump)")
print("== editors :", ", ".join(f"{u} / {p}" for u, p in editors.items()) or "(none)")
print("== access : by Authentik group — editors (read streams/clips, write finals), media-admins (everything), admins (dump anyone)")
print(" WebDAV: Authentik username + an Authentik app password")
print("== streamers:")
for u, p in streamers.items():
print(f"\n {u} password {p}")
@@ -471,7 +480,7 @@ def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
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 at https://{domain or 'HOST'}/ — WebDAV at the same URL")
print(f"\n editors log in with Discord at https://{domain or 'HOST'}/ — WebDAV at the same URL (app password)")
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")
@@ -510,8 +519,6 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
print("\n-- Discord bot (optional; the Bot added to the Authentik Discord application). Enter keeps the current value.")
discord_token, discord_guild = ask_discord()
live_webhook = ask(" Discord webhook URL for 'is live' notices (optional)", read_env().get("LIVE_WEBHOOK_URL", ""))
print("\n-- admin (copyparty admin + may dump anyone's night)")
admin_pw = ask_pw("admin", admin_pw or None)
print("\n-- streamers (OBS publishers; each gets streams/<name> and clips/<name>)")
for u in list(streamers):
k = ask(f" {u}: [k]eep [r]emove [p]assword reset", "k").lower()[:1]
@@ -522,7 +529,6 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
n = ask_name("streamer", streamers)
if not n: break
streamers[n] = ask_pw(n)
print("\n-- editors (copyparty logins: read streams/clips, write finals)")
for u in list(editors):
k = ask(f" {u}: [k]eep [r]emove [p]assword reset", "k").lower()[:1]
if k == "r": del editors[u]
@@ -547,15 +553,13 @@ def ask_discord():
return tok, guild
def normal(domain, admin_pw, streamers, editors):
missing = [x for x, v in (("domain", domain), ("admin password", admin_pw)) if not v]
if missing:
print(f"not set yet: {', '.join(missing)} — run ./configure.py --full"); sys.exit(1)
if not domain:
print("domain not set yet — run ./configure.py --full"); sys.exit(1)
print(f"== MANAGE (domain {domain}; run --full to change hostnames/admin)")
discord = (None, None)
while True:
c = ask("\n[s]treamers [e]ditors [d]iscord bot token/guild [v]iew [a]pply & quit [q]uit without applying", "a").lower()[:1]
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)
elif c == "e": manage_users("editor", editors)
elif c == "d": discord = ask_discord()
elif c == "v": show(domain, admin_pw, streamers, editors)
elif c == "a": return discord
@@ -572,7 +576,7 @@ def main():
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return
if mode == "--hosts":
if len(sys.argv) not in (4, 5, 6, 7): print("usage: configure.py --hosts AUTH_HOST WATCH_HOST [GIT_HOST [BOT_HOST [LIVE_HOST]]]"); sys.exit(2)
if not domain or not admin_pw: print("domain/admin not set yet — run ./configure.py --full first"); sys.exit(1)
if not domain: print("domain not set yet — run ./configure.py --full first"); sys.exit(1)
auth_host, watch_host = sys.argv[2].strip(), sys.argv[3].strip()
if len(sys.argv) >= 5: git_host = sys.argv[4].strip()
if len(sys.argv) >= 6: bot_host = sys.argv[5].strip()