Split hosts: media (gateway at /), files, clips, sync; live gains /dock, /dock/clock, /dump, /me, /time

configure.py: three new host roles (files/sync/clips) in --full, --hosts, .env, Caddy generator;
main host serves about/ at the root (/about* redirects); /time only where it is fetched same-origin.
about/index.html links across hosts; addCommunityMediaServer.sh asks for the new names; Caddyfile.example
regenerated; README/FRESH-INSTALL/HOSTNAME-CHANGE (procedure C)/STATUS updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 01:38:35 -05:00
parent c1a46d66dc
commit 18f4e07de8
9 changed files with 418 additions and 181 deletions

View File

@@ -6,8 +6,12 @@
./configure.py --show print current streamers, editors, domain and the OBS settings
./configure.py --apply regenerate everything from the values already on disk and apply (non-interactive;
used by deploy.sh). Fails if the main hostname was never set.
./configure.py --hosts AUTH WATCH [GIT [BOT [LIVE]]] set the Authentik/Jellyfin/Gitea/bot/live hostnames and apply (non-interactive;
used by addCommunityMediaServer.sh)
./configure.py --hosts AUTH WATCH [GIT [BOT [LIVE [FILES [SYNC [CLIPS]]]]]]
set the other hostnames and apply (non-interactive; used by addCommunityMediaServer.sh).
Pass "" to leave one unset.
Hosts: main = the gateway page at /, files = copyparty, clips = clip intake, sync = Syncthing UI,
live = portal + OBS dock (/dock/, /dock/clock/, /dump, /me, /time), auth, watch, git, bot.
Reads : Caddyfile (hostnames), mediamtx.yml (streamers), .env
(editors/admins are Authentik groups now: editors, media-admins, admins — nothing local)
@@ -154,8 +158,9 @@ def gate(required_group=""):
"""
return block
def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", live_host="", files_host="", sync_host="", clips_host=""):
env = read_env()
gate_any = gate("")
extra = ""
apex, target = env.get("APEX_HOST", ""), env.get("APEX_REDIRECT", "")
if apex and target:
@@ -164,18 +169,70 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
{apex} {{
redir {target} 302
}}
"""
if files_host:
extra += f"""
# vanguard:files
{files_host} {{
encode zstd gzip
{gate_any} # copyparty at the root. It honours X-Authentik-Username / -Groups only when the request also carries a header
# whose secret NAME only this proxy knows (copyparty idp-h-key). Clients cannot forge identity.
reverse_proxy 127.0.0.1:3923 {{
header_up X-Vanguard-{env.get("COPYPARTY_IDP_KEY", "key")} 1
flush_interval -1
}}
}}
"""
if sync_host:
extra += f"""
# vanguard:sync
{sync_host} {{
encode zstd gzip
{gate(env.get("SYNC_REQUIRE_GROUP", "media-admins"))} # Syncthing admin UI (its own GUI login is disabled; the gate + group is the login)
reverse_proxy 127.0.0.1:8384 {{
header_up Host {{upstream_hostport}}
}}
}}
"""
if clips_host:
extra += f"""
# vanguard:clips
{clips_host} {{
encode zstd gzip
{gate_any} @me path /me /me/*
handle @me {{
reverse_proxy 127.0.0.1:8080 # dumpd: identity + /me/clips self-service
}}
handle {{
root * /srv/clips
try_files {{path}} /index.html
file_server
}}
}}
"""
if live_host:
extra += f"""
# vanguard:live
{live_host} {{
encode zstd gzip
{gate(env.get("LIVE_REQUIRE_GROUP", ""))} @me path /me /me/*
{gate(env.get("LIVE_REQUIRE_GROUP", ""))} handle /whoami {{
header Content-Type text/plain
respond "user: {{http.request.header.X-Authentik-Username}}
groups: {{http.request.header.X-Authentik-Groups}}"
}}
@me path /me /me/*
handle @me {{
reverse_proxy 127.0.0.1:8080 # dumpd: identity + /me/stream-token for the logged-in streamer (Go Live)
reverse_proxy 127.0.0.1:8080 # dumpd: identity, stream tokens (Go Live + dock)
}}
handle /time {{
reverse_proxy 127.0.0.1:8080 # server clock for the timecode strip
reverse_proxy 127.0.0.1:8080 # server clock for the timecode strip (clock page + Go Live)
}}
handle /dump* {{
reverse_proxy 127.0.0.1:8080 # dump tonight (dock)
}}
handle_path /dock* {{
root * /srv/dock
file_server # OBS browser dock + /dock/clock/
}}
handle_path /api/* {{
reverse_proxy 127.0.0.1:9997
@@ -231,24 +288,11 @@ 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
{AUTHENTIK_FORWARD_AUTH}
# who does Authentik say I am? (debug: shows exactly the identity headers the apps receive)
{gate_any} # who does Authentik say I am? (debug: shows exactly the identity headers the apps receive)
handle /whoami {{
header Content-Type text/plain
respond "user: {{http.request.header.X-Authentik-Username}}
@@ -256,60 +300,24 @@ groups: {{http.request.header.X-Authentik-Groups}}
email: {{http.request.header.X-Authentik-Email}}
name: {{http.request.header.X-Authentik-Name}}"
}}
# dump endpoint + stream tokens (python service on the host; identity from the X-Authentik-* headers Caddy just set)
handle /dump* {{
reverse_proxy 127.0.0.1:8080
}}
@me path /me /me/*
handle @me {{
reverse_proxy 127.0.0.1:8080 # dumpd: identity, stream tokens, clips self-service
reverse_proxy 127.0.0.1:8080 # dumpd: identity for the gateway page
}}
handle /time {{
reverse_proxy 127.0.0.1:8080 # server clock for the timecode strip
}}
# Syncthing admin UI: Authentik + group {sync_group or 'any'} (Syncthing's own GUI login is disabled)
{sync_gate} redir /sync /sync/ 308
handle_path /sync/* {{
reverse_proxy 127.0.0.1:8384 {{
header_up Host {{upstream_hostport}}
}}
}}
# OBS browser dock (static)
handle_path /dock* {{
root * /srv/dock
file_server
}}
# gateway page: role-aware links + instructions (static; talks to /me)
redir /about /about/ 308
handle_path /about/* {{
root * /srv/about
file_server
}}
# clip intake self-service (static page; talks to /me/clips)
redir /clips /clips/ 308
handle_path /clips/* {{
root * /srv/clips
file_server
}}
# copyparty. It honours X-Authentik-Username / -Groups only when the request also carries a header whose
# secret NAME only this proxy knows (copyparty idp-h-key). Clients cannot forge identity.
redir /about* / 308 # old bookmarks: the gateway page used to live at /about/
handle {{
reverse_proxy 127.0.0.1:3923 {{
header_up X-Vanguard-{env.get("COPYPARTY_IDP_KEY", "key")} 1
flush_interval -1
}}
root * /srv/about
try_files {{path}} /index.html
file_server # the gateway page is the root of the main host
}}
}}
""" + extra)
def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", main_host="", live_webhook=None):
def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", main_host="", live_webhook=None, files_host="", sync_host="", clips_host=""):
env = read_env()
if files_host: env["FILES_HOST"] = files_host
if sync_host: env["SYNC_HOST"] = sync_host
if clips_host: env["CLIPS_HOST"] = clips_host
env["LIVE_HOST"] = live_host or env.get("LIVE_HOST", "live.example.com")
if main_host: env["MAIN_HOST"] = main_host
if live_webhook is not None: env["LIVE_WEBHOOK_URL"] = live_webhook
@@ -474,15 +482,15 @@ def run(cmd, **kw):
if DRY: return 0
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):
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, files_host="", sync_host="", clips_host=""):
# 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(domain); write_copyparty()
write_env(watch_host, discord_token, discord_guild, git_host, bot_host, live_host, domain, live_webhook, files_host, sync_host, clips_host)
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_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")
@@ -554,11 +562,14 @@ def post_start_fixups(dc):
if DRY: print(" (dry) installJellyfinPlugin.sh")
else: subprocess.run(["bash", "installJellyfinPlugin.sh"], capture_output=True, text=True); print(" jellyfin plugin: ensured")
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)'}")
def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host="", files_host="", sync_host="", clips_host=""):
print(f"\n== gateway : https://{domain or 'HOST'}/ (/whoami, /logout)")
print(f"== files : https://{files_host}/ (copyparty; editors / media-admins)" if files_host else "== files : (not set — run --full)")
print(f"== clips : https://{clips_host}/ (self-service clip intake, any member)" if clips_host else "== clips : (not set — run --full)")
print(f"== sync : https://{sync_host}/ (Syncthing UI, media-admins)" if sync_host else "== sync : (not set — run --full)")
_e = read_env()
if _e.get("APEX_HOST") and _e.get("APEX_REDIRECT"): print(f"== apex : https://{_e['APEX_HOST']}/ -> {_e['APEX_REDIRECT']}")
print(f"== live : https://{live_host}/ (behind Authentik forward-auth; deploy live/dist -> /srv/live; WebRTC needs 8189 open)" if live_host else "== live : (not set — run --full)")
print(f"== live : https://{live_host}/ (portal; /dock/ for OBS; WebRTC needs 8189 open)" if live_host else "== live : (not set — run --full)")
print(f"== bot web : https://{bot_host}/ (Authentik forward-auth; Caddy requires group '{read_env().get('BOT_REQUIRE_GROUP','bot-users') or 'any'}')" if bot_host else "== bot web : (not set — run --full)")
print(f"== gitea : https://{git_host}/ ssh port 2222 admin {read_env().get('GITEA_ADMIN_USER','vanguard')} / {read_env().get('GITEA_ADMIN_PASSWORD','?')} (runner: ./registerGiteaRunner.sh)" if git_host else "== gitea : (not set — run --full)")
print(f"== authentik: https://{auth_host}/ akadmin password = AUTHENTIK_BOOTSTRAP_PASSWORD in .env (set on first start; change it in the UI)" if auth_host else "== authentik: (not set — run --full)")
@@ -572,11 +583,10 @@ def show(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
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: 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" OBS: add the dock https://{live_host or 'LIVE'}/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")
print(f"\n editors: https://{files_host or 'FILES'}/ everyone: https://{domain or 'HOST'}/")
print(f" verify: curl -s -o /dev/null -w '%{{http_code}}\\n' https://{domain or 'HOST'}/ (expect 302 → Authentik)\n")
# ------------------------------------------------------------------ modes
def manage_users(kind, users):
@@ -596,12 +606,15 @@ def manage_users(kind, users):
else:
users[n] = ask_pw(n, users[n])
def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host=""):
def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_host="", bot_host="", live_host="", files_host="", sync_host="", clips_host=""):
print("== FULL SETUP (enter keeps the shown default)\n")
while True:
domain = ask("public hostname (DNS A record must point here)", domain)
domain = ask("main hostname = the gateway page (DNS A record must point here)", domain)
if domain and "." in domain and " " not in domain: break
print(" need a hostname like media.example.com")
files_host = ask("Files (copyparty) hostname (CNAME to the above)", files_host or default_sub(domain, "files"))
clips_host = ask("Clips self-service hostname (CNAME to the above)", clips_host or default_sub(domain, "clips"))
sync_host = ask("Syncthing UI hostname (CNAME to the above)", sync_host or default_sub(domain, "sync"))
auth_host = ask("Authentik hostname (CNAME to the above)", auth_host or default_sub(domain, "auth"))
watch_host = ask("Jellyfin hostname (CNAME to the above)", watch_host or default_sub(domain, "watch"))
git_host = ask("Gitea hostname (CNAME to the above)", git_host or default_sub(domain, "git"))
@@ -609,7 +622,7 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
live_host = ask("Live portal hostname (CNAME to the above)", live_host or default_sub(domain, "live"))
env0 = read_env(); parent = default_sub(domain, "x")[2:] if domain else ""
apex = ask("Bare domain to serve too (A record must point here; blank = skip)", env0.get("APEX_HOST", parent))
apex_to = ask(" bare domain redirects to", env0.get("APEX_REDIRECT") or (f"https://{watch_host}/" if watch_host else "")) if apex else ""
apex_to = ask(" bare domain redirects to", env0.get("APEX_REDIRECT") or f"https://{domain}/") if apex else ""
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", ""))
@@ -636,7 +649,7 @@ def full(domain, admin_pw, streamers, editors, auth_host="", watch_host="", git_
Path(".env").write_text("".join(f"{k}={v}\n" for k, v in e.items())) if Path(".env").exists() else None
if not Path(".env").exists():
Path(".env").write_text(f"APEX_HOST={apex}\nAPEX_REDIRECT={apex_to}\n"); os.chmod(".env", 0o600)
return domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook
return domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook, files_host, sync_host, clips_host
def ask_discord():
env = read_env()
@@ -663,27 +676,28 @@ def main():
mode = sys.argv[1] if len(sys.argv) > 1 else ""
hosts = read_hosts()
domain, auth_host, watch_host, git_host, bot_host, live_host = hosts.get("main", ""), hosts.get("auth", ""), hosts.get("watch", ""), hosts.get("git", ""), hosts.get("bot", ""), hosts.get("live", "")
files_host, sync_host, clips_host = hosts.get("files", ""), hosts.get("sync", ""), hosts.get("clips", "")
streamers, accounts = read_streamers(), read_accounts()
admin_pw = accounts.pop("admin", "")
editors = accounts
if mode == "--show":
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host); return
if mode == "--apply":
if not domain: print("domain not set yet — run ./configure.py --full first"); sys.exit(1)
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host)
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host, files_host=files_host, sync_host=sync_host, clips_host=clips_host)
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_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 4 <= len(sys.argv) <= 10: print("usage: configure.py --hosts AUTH WATCH [GIT [BOT [LIVE [FILES [SYNC [CLIPS]]]]]]"); sys.exit(2)
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()
if len(sys.argv) == 7: live_host = sys.argv[6].strip()
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host)
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host); return
a = [x.strip() for x in sys.argv[2:]] + [None] * 8
auth_host, watch_host = a[0], a[1]
git_host, bot_host, live_host = (a[2] if a[2] is not None else git_host), (a[3] if a[3] is not None else bot_host), (a[4] if a[4] is not None else live_host)
files_host, sync_host, clips_host = (a[5] if a[5] is not None else files_host), (a[6] if a[6] is not None else sync_host), (a[7] if a[7] is not None else clips_host)
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host=git_host, bot_host=bot_host, live_host=live_host, files_host=files_host, sync_host=sync_host, clips_host=clips_host)
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host); return
discord_token = discord_guild = live_webhook = None
if mode == "--full":
domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook = full(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host)
domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook, files_host, sync_host, clips_host = full(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host)
if not yes("\napply now (write configs, restart services)?"): print("nothing written"); return
elif mode == "":
r = normal(domain, admin_pw, streamers, editors)
@@ -691,8 +705,8 @@ def main():
discord_token, discord_guild = r
else:
print(__doc__); sys.exit(2)
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook)
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host)
apply(domain, admin_pw, streamers, editors, auth_host, watch_host, discord_token, discord_guild, git_host, bot_host, live_host, live_webhook, files_host, sync_host, clips_host)
show(domain, admin_pw, streamers, editors, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host)
if __name__ == "__main__":
try: main()