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:
98
configure.py
98
configure.py
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user