bunch of shit

This commit is contained in:
2026-11-06 19:05:47 -06:00
parent ca01516d42
commit 630b253278
29 changed files with 2793 additions and 303 deletions

View File

@@ -16,7 +16,7 @@
NEW host needs --full or this.
Hosts: main = the gateway page at /, files = copyparty, clips = clip intake, sync = Syncthing UI,
live = portal + OBS dock (/dock/, /dock/clock/, /dump, /me, /time), diag = logs/metrics/alerts,
live = portal + OBS dock (/dock/, /dock/clock/, /dump, /me, /time), diag = logs/metrics/alerts + streaming triage (live, stuck, dump),
auth, watch, git, bot.
Reads : Caddyfile (hostnames), mediamtx.yml (streamers), .env
@@ -27,7 +27,7 @@ Writes : Caddyfile, mediamtx.yml, copyparty.conf, stream-token.secret -> /etc/va
Discord bot token/guild), folders, restarts
Set VG_DRY=1 to write files only (no sudo / docker / folders).
"""
import base64, getpass, hashlib, json, os, re, secrets, shutil, subprocess, sys, time
import base64, getpass, hashlib, json, os, re, secrets, shutil, socket, subprocess, sys, time
import urllib.error, urllib.request
from pathlib import Path
@@ -48,8 +48,8 @@ def read_hosts():
m = re.match(r"#\s*vanguard:(\w+)", t)
if m: role = m.group(1); continue
if t and not t.startswith(("#", "}", "{")) and t.endswith("{") and " " in t:
host = t.split()[0]
if "example.com" in host: role = None; continue
host = t.split()[0].rstrip(",") # "a, b {" = an alias (decorate_caddy); the first name is the role's
if "example.com" in host or role == "redirect": role = None; continue # redirect blocks are not roles
hosts[role or ("main" if "main" not in hosts else host)] = host
role = None
except FileNotFoundError:
@@ -348,13 +348,19 @@ def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", l
{gate(env.get("DIAG_REQUIRE_GROUP", "media-admins"))} # Triage lives on this host, alongside the dashboards it belongs with. Both handles come BEFORE the
# catch-all below, and the Kibana proxy is wrapped in its own handle so the three are evaluated in the
# order written rather than by Caddy's directive ordering.
handle /stuck/api {{
reverse_proxy 127.0.0.1:8080 # dumpd: the list, and retry/bin for the admin groups
handle /streaming/api/* {{
reverse_proxy 127.0.0.1:8080 # dumpd: live publishers + loss, and the stuck list / retry / bin
}}
handle_path /stuck* {{
root * /srv/stuck
handle_path /streaming* {{
root * /srv/streaming
try_files {{path}} /index.html
file_server # stuck-recording triage; this host is already media-admins only
file_server # Live and Stuck tabs; this host is already media-admins only
}}
redir /stuck* /streaming#stuck 302 # it used to live here
handle_path /disk* {{
root * /srv/streaming
rewrite * /disk.html # the Disk page: same mount as the streaming page, its own address
file_server
}}
# Kibana IS the rest of this host: logs (Discover), dashboards and alerting in one app, served at the
# root so there is no path prefix to get wrong. Elasticsearch itself is never published.
@@ -383,7 +389,7 @@ groups: {{http.request.header.X-Authentik-Groups}}"
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)
reverse_proxy 127.0.0.1:8080 # dump tonight (dock): the signed-in streamer's own night
}}
handle_path /dock* {{
root * /srv/dock
@@ -475,7 +481,9 @@ name: {{http.request.header.X-Authentik-Name}}"
redir /about* / 308 # old bookmarks: the gateway page used to live at /about/
handle {{
root * /srv/about
try_files {{path}} /index.html
# {{path}}/index.html: Caddy's file matcher takes a slash-less path as "a file", so /onboarding would skip
# the onboarding/ folder and fall through to the gateway page. Sub-pages live in folders under about/.
try_files {{path}} {{path}}/index.html /index.html
file_server # the gateway page is the root of the main host
}}
}}
@@ -548,19 +556,7 @@ def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", b
Path(".env").write_text(render_env(env))
os.chmod(".env", 0o600)
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
@@ -572,18 +568,18 @@ rtspTransports: [tcp]
rtspEncryption: "no"
moq: no
metrics: yes # Prometheus text format, scraped by Metricbeat (LOGGING.md); loopback only
metricsAddress: :9998
metricsAddress: 127.0.0.1:9998 # host network: loopback only; Metricbeat scrapes it there
pprof: no
# live portal (served to the browser only via Caddy + Authentik on live.HOST; these ports are published on 127.0.0.1)
api: yes
apiAddress: :9997
apiAddress: 127.0.0.1:9997 # host network: loopback only; Caddy proxies live.HOST/api/*
hls: yes
hlsAddress: :8888
hlsAddress: 127.0.0.1:8888 # host network: loopback only; Caddy proxies live.HOST/hls/*
hlsVariant: lowLatency
hlsAlwaysRemux: no
webrtc: yes
webrtcAddress: :8889
webrtcAddress: 127.0.0.1:8889 # WHIP/WHEP signalling: loopback only, via Caddy. Media is 8189 below
webrtcLocalUDPAddress: :8189
webrtcLocalTCPAddress: :8189
webrtcIPsFromInterfaces: no
@@ -601,7 +597,7 @@ srtAddress: :8890
# NOTE: on this setup (bind-mounted file) MediaMTX does not reload this file live: restart after any change here.
# Day-to-day operations (streamers, tokens, titles) never touch this file.
authMethod: http
authHTTPAddress: http://{auth_gw}:8081/mtx-auth
authHTTPAddress: http://127.0.0.1:8081/mtx-auth # dumpd, on the same host network
authHTTPExclude:
- action: api
- action: metrics
@@ -621,14 +617,51 @@ pathDefaults:
runOnReadyRestart: no
paths:
# audio mirrors (first rule wins): never recorded, no hook; only publishable from inside the container (dumpd)
~^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+\\.webrtc$:
# Audio mirrors: never recorded, no hook; only publishable from inside the container (dumpd).
# The title segment of BOTH rules is dot-free, so a name matches exactly one of them and the order MediaMTX
# evaluates them in is irrelevant. They used to overlap (titles allowed dots) and whichever won was luck:
# a 36 GB mirror got recorded as a full duplicate of its source. Usernames keep dots: Discord allows them.
~^[A-Za-z0-9_.-]+/[A-Za-z0-9_-]+\\.webrtc$:
record: no
runOnReady: ""
# <username>/<Title>; dumpd decides who may publish where (token user == prefix)
~^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$: {{}}
~^[A-Za-z0-9_.-]+/[A-Za-z0-9_-]+$: {{}}
""")
# Two .env keys for a base-domain move with no outage (HOSTNAME-CHANGE.md §B):
# ALT_BASE=new.base every role site ALSO answers on its name under that base ("media.old, media.new {"),
# so certificates for the new names are issued and both sets work while nothing else
# has moved yet. Set with --alias NEW; cleared by --base.
# REDIRECT_BASE=old.base after the move: the old names get their own blocks that 308 to the primary names, so
# bookmarks and pinned links keep working instead of failing TLS. Set by --base.
# Both are applied by rewriting the Caddyfile that write_caddy just produced; read_hosts understands both forms.
def decorate_caddy():
env = read_env()
alt, red = env.get("ALT_BASE", "").strip().lower(), env.get("REDIRECT_BASE", "").strip().lower()
if not alt and not red:
return
hosts = read_hosts()
main = hosts.get("main", "")
base = main.split(".", 1)[1] if "." in main else ""
if not base:
return
role_hosts = {h for r, h in hosts.items() if r != "redirect"}
out = []
for line in Path("Caddyfile").read_text().splitlines():
m = re.match(r"^([A-Za-z0-9.-]+) \{$", line)
if m and alt and alt != base and m.group(1) in role_hosts and m.group(1).endswith("." + base):
line = f"{m.group(1)}, {m.group(1)[: -len(base)]}{alt} {{"
out.append(line)
text = "\n".join(out) + "\n"
if red and red != base:
for role, h in hosts.items():
if role != "redirect" and h.endswith("." + base):
old = h[: -len(base)] + red
text += f"\n# vanguard:redirect\n{old} {{\n\tredir https://{h}{{uri}} 308 # old base {red}: bookmarks follow\n}}\n"
Path("Caddyfile").write_text(text)
if alt and alt != base: print(f" Caddyfile: every site also answers under {alt}")
if red and red != base: print(f" Caddyfile: names under {red} redirect (308) to the new ones")
def write_copyparty():
"""copyparty behind Caddy + Authentik: identity and groups arrive as headers; permissions are per group."""
key = read_env().get("COPYPARTY_IDP_KEY", "key")
@@ -689,12 +722,13 @@ 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, files_host, sync_host, clips_host, diag_host)
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host, diag_host); write_mediamtx(domain); write_copyparty()
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host, diag_host); decorate_caddy(); 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"])
install_brand_assets() # authentik/brand/*.svg -> the login page's logo, favicon and background
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 legacy streamer entries (MediaMTX creates folders on first publish anyway)")
@@ -946,6 +980,198 @@ else:
print(f"authentik: {provider.name!r} logout flow {before} -> {target.slug}")
"""
# Login branding and "Discord forward". Everything the Authentik admin UI would want clicked for, done from the
# ORM on every apply so it is reproducible and a UI save cannot quietly undo it:
# brand title, logo, favicon, flow background, login-card CSS, root -> the media gateway (default application)
# identification stage Discord source attached with its label (what akadmin sees behind ?admin=1)
# flow titles the headings on the sign-in / signed-out / first-visit cards
# redirect stage FIRST stage of default-authentication-flow, static target = the Discord source's login
# URL, so a member never sees an Authentik page: protected link -> Discord consent -> back
# expression policy on that binding: skip the redirect when the flow URL carries ?admin=1 — the executor
# keeps the flow's query in the session under "authentik/flows/get", not in request.GET —
# so https://auth.HOST/if/flow/default-authentication-flow/?admin=1 is the password card
# for akadmin (linked from the diag landing dashboard, nowhere public)
# The outpost's ?next= survives the detour: the source flow manager reads it back from the same session key.
AK_BRANDING = r"""
from urllib.parse import urlparse
from authentik.brands.models import Brand
from authentik.core.models import Application
from authentik.flows.models import Flow, FlowStageBinding
from authentik.policies.models import PolicyBinding
from authentik.policies.expression.models import ExpressionPolicy
from authentik.providers.proxy.models import ProxyProvider
from authentik.sources.oauth.models import OAuthSource
from authentik.stages.identification.models import IdentificationStage
AUTH = "__AUTH_HOST__"; MAIN = "__MAIN_HOST__"; TITLE = "__TITLE__"
ASSETS = "/media/public/brand"
CSS = "\n".join([
":root { --ak-accent: #5865F2; --pf-global--primary-color--100: #5865F2; --pf-global--primary-color--200: #4752c4; --pf-global--link--Color: #8ea1ff; --pf-global--link--Color--hover: #b4c0ff; }",
".pf-c-login__main { background: rgba(20,20,20,.92); border: 1px solid #2e2e2e; border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,.6); }",
".pf-c-login__main-header, .pf-c-login__main-body, .pf-c-login__main-footer, .pf-c-login__main-header .pf-c-title { color: #e6e6e6; }",
".pf-c-button.pf-m-secondary, .pf-c-button.pf-m-primary { background: #5865F2; color: #fff; border: 0; border-radius: 10px; font-size: 1.05rem; padding: .85rem 1.2rem; }",
".pf-c-button.pf-m-secondary:hover, .pf-c-button.pf-m-primary:hover { background: #4752c4; }",
".pf-c-login__main-footer-band, .pf-c-login__footer { display: none; }",
".pf-c-form-control { background: #1f1f1f; color: #e6e6e6; border-color: #2e2e2e; }",
])
def setattrs(obj, label, **kv):
changed = []
for key, value in kv.items():
if not hasattr(obj, key):
continue # field absent in this authentik version: leave it
if getattr(obj, key) != value:
setattr(obj, key, value); changed.append(key)
if changed:
obj.save(); print(f"authentik: {label}: set {', '.join(changed)}")
else:
print(f"authentik: {label}: ok")
# 1. brand
brand = Brand.objects.filter(default=True).first() or Brand.objects.order_by("domain").first()
if brand is None:
print("authentik: no brand found; branding skipped")
else:
gateway = None
for pp in ProxyProvider.objects.all():
if urlparse(pp.external_host or "").hostname == MAIN:
gateway = Application.objects.filter(provider=pp).first()
extra = {"default_application": gateway} if gateway else {}
setattrs(brand, "brand", branding_title=TITLE, branding_logo=f"{ASSETS}/logo.svg", branding_favicon=f"{ASSETS}/favicon.svg",
branding_default_flow_background=f"{ASSETS}/login-bg.svg", flow_background=f"{ASSETS}/login-bg.svg",
branding_custom_css=CSS, **extra)
if gateway is None:
print(f"authentik: no proxy provider has external host {MAIN}; brand default application left alone")
# 2. flow titles (and the per-flow background on versions that still keep it there)
titles = {"default-authentication-flow": f"{TITLE} — sign in", "default-invalidation-flow": f"Signed out of {TITLE}",
"default-source-enrollment": f"Welcome to {TITLE}", "default-source-authentication": f"{TITLE} — signing you in",
"default-provider-invalidation-flow": f"Signed out of this app — still signed in to {TITLE}"}
for slug, title in titles.items():
flow = Flow.objects.filter(slug=slug).first()
if flow is not None:
setattrs(flow, f"flow {slug}", title=title, background=f"{ASSETS}/login-bg.svg")
# 3. Discord on the identification stage (the admin card still offers Discord too)
discord = OAuthSource.objects.filter(provider_type="discord").first()
if discord is None:
print("authentik: no Discord OAuth source; Discord forward skipped")
else:
for stage in IdentificationStage.objects.filter(name="default-authentication-identification"):
if not stage.sources.filter(pk=discord.pk).exists():
stage.sources.add(discord); print("authentik: identification stage: Discord source attached")
setattrs(stage, "identification stage", show_source_labels=True)
# 4. the forward: redirect stage first in the authentication flow, skipped for ?admin=1
try:
from authentik.stages.redirect.models import RedirectStage
except ImportError:
RedirectStage = None
print("authentik: this version has no redirect stage; Discord forward skipped (branding applied)")
flow = Flow.objects.filter(slug="default-authentication-flow").first()
if RedirectStage is not None and flow is not None:
target = f"https://{AUTH}/source/oauth/login/{discord.slug}/"
stage, made = RedirectStage.objects.get_or_create(name="vanguard-discord-forward", defaults={"mode": "static", "target_static": target, "keep_context": True})
setattrs(stage, "redirect stage" + (" (created)" if made else ""), mode="static", target_static=target, keep_context=True)
expr = "\n".join([
"# Vanguard: members go straight to Discord. ?admin=1 on the flow URL shows the password card for akadmin.",
"# The executor stores the flow URL's query in the session, not in request.GET, so look in both.",
"http = request.http_request",
"if http is None:",
" return True",
"if http.GET.get('admin') is not None:",
" return False",
"flow_get = http.session.get('authentik/flows/get') or {}",
"return flow_get.get('admin') is None",
""])
policy, made = ExpressionPolicy.objects.get_or_create(name="vanguard-discord-forward-unless-admin", defaults={"expression": expr})
setattrs(policy, "forward policy" + (" (created)" if made else ""), expression=expr, execution_logging=False)
others = list(FlowStageBinding.objects.filter(target=flow).exclude(stage=stage).values_list("order", flat=True))
order = min(others + [10]) - 10
binding, made = FlowStageBinding.objects.get_or_create(target=flow, stage=stage, defaults={"order": order})
setattrs(binding, "redirect binding" + (" (created)" if made else ""), order=order, evaluate_on_plan=True, re_evaluate_policies=False)
pb, made = PolicyBinding.objects.get_or_create(target=binding, policy=policy, defaults={"order": 0, "enabled": True})
setattrs(pb, "policy binding" + (" (created)" if made else ""), enabled=True, negate=False)
print(f"authentik: members -> {target} ; admins -> https://{AUTH}/if/flow/default-authentication-flow/?admin=1")
"""
def ak_branding(dc):
env, hosts = read_env(), read_hosts() # the auth host lives only in the Caddyfile's vanguard:auth block
auth_host, main_host = hosts.get("auth", ""), hosts.get("main", "") or env.get("MAIN_HOST", "")
if not auth_host or not main_host:
print(" authentik: auth/main host not in the Caddyfile yet; branding skipped"); return
script = (AK_BRANDING.replace("__AUTH_HOST__", auth_host).replace("__MAIN_HOST__", main_host)
.replace("__TITLE__", env.get("BRAND_TITLE", "Vanguard")))
ak_shell(dc, script, "login branding + Discord forward")
def install_brand_assets():
# Brand assets for the Authentik login (authentik/brand/ in the repo). They go into Authentik's own media
# dir, served at https://auth.HOST/media/public/... with NO login — the login page is where they are shown,
# and every other host is behind the gate, so nothing else could serve them to a signed-out visitor.
run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", "/srv/appdata/authentik/media/public/brand"])
for asset in sorted(Path("authentik/brand").glob("*.svg")):
run(["sudo", "install", "-m", "0644", "-o", "1000", "-g", "1000", str(asset), "/srv/appdata/authentik/media/public/brand/"])
# Moving Authentik to a new base domain. NOT part of --apply, on purpose (HOSTNAME-CHANGE.md, "apps before
# Authentik"): this is the step that decides whether the gated hosts are reachable, so it runs only when asked, after
# Jellyfin and Gitea already answer on the new names. Every field that carries OLD is rewritten to NEW:
# outposts config.authentik_host (the forward-auth gate's login address)
# proxy providers external_host, cookie_domain (domain-level: external_host IS the auth URL; cookie_domain = base)
# oauth2 providers redirect URIs (Jellyfin, Gitea callbacks)
# brand domain, if it was the old base
# Prints every change; a second run prints only "ok". --ak-hosts OLD NEW --dry-run shows without writing.
AK_HOSTS = r"""
import json, re
OLD = "__OLD__"; NEW = "__NEW__"; DRY = __DRY__
from authentik.outposts.models import Outpost
from authentik.providers.proxy.models import ProxyProvider
from authentik.providers.oauth2.models import OAuth2Provider
from authentik.brands.models import Brand
pat = re.compile(r"(?<![A-Za-z0-9-])" + re.escape(OLD) + r"(?![A-Za-z0-9-])")
def swap(s): return pat.sub(NEW, s) if isinstance(s, str) else s
def report(label, before, after):
if before == after: print(f"authentik: {label}: ok ({after})")
else: print(f"authentik: {label}: {before!r} -> {after!r}" + (" [dry run]" if DRY else ""))
for o in Outpost.objects.all():
cfg = o.config
before = getattr(cfg, "authentik_host", None)
if before is None:
continue
after = swap(before); report(f"outpost {o.name!r} authentik_host", before, after)
if after != before and not DRY:
cfg.authentik_host = after; o.config = cfg; o.save()
for p in ProxyProvider.objects.all():
eh, cd = p.external_host, p.cookie_domain
eh2, cd2 = swap(eh), (NEW if cd == OLD else swap(cd))
report(f"proxy provider {p.name!r} external_host", eh, eh2); report(f"proxy provider {p.name!r} cookie_domain", cd, cd2)
if (eh2, cd2) != (eh, cd) and not DRY:
p.external_host, p.cookie_domain = eh2, cd2; p.save(update_fields=["external_host", "cookie_domain"])
for p in OAuth2Provider.objects.all():
raw = list(p._redirect_uris or [])
new = [dict(u, url=swap(u.get("url", ""))) if isinstance(u, dict) else u for u in raw]
report(f"oauth2 provider {p.name!r} redirect_uris", [u.get("url") for u in raw if isinstance(u, dict)], [u.get("url") for u in new if isinstance(u, dict)])
if new != raw and not DRY:
p._redirect_uris = new; p.save(update_fields=["_redirect_uris"])
for b in Brand.objects.all():
d2 = swap(b.domain); report(f"brand domain", b.domain, d2)
if d2 != b.domain and not DRY:
b.domain = d2; b.save(update_fields=["domain"])
print("authentik: outposts re-read their config within a minute; the embedded outpost needs no restart")
"""
def ak_hosts(dc, old, new, dry=False):
script = AK_HOSTS.replace("__OLD__", old).replace("__NEW__", new).replace("__DRY__", "True" if dry else "False")
ak_shell(dc, script, f"authentik hosts {old} -> {new}" + (" (dry run)" if dry else ""))
def rebase_hosts(hosts, old, new):
"""Every role host with its label kept and the base swapped: media.old -> media.new."""
out = {}
for role, host in hosts.items():
if host == old: out[role] = new
elif host.endswith("." + old): out[role] = host[: -len(old)] + new
else: out[role] = host # a host on some other domain is left alone
return out
def post_start_fixups(dc):
"""Idempotent first-run state that otherwise needs clicking: Syncthing GUI auth off, Gitea admin user, Jellyfin plugin."""
env = read_env()
@@ -954,6 +1180,7 @@ def post_start_fixups(dc):
# /logout must end the whole Authentik session, not just the application's. The provider-scoped flow
# ("Logged out of application") leaves the SSO session alive, so logout looks like a page refresh.
ak_shell(dc, AK_LOGOUT_FIXUP, "proxy provider logout flow")
ak_branding(dc) # login page on brand, members forwarded straight to Discord, ?admin=1 for akadmin
# Syncthing: no GUI login (the Authentik gate is the login); needs insecureAdminAccess since Caddy is not "localhost" to it
cfg = Path("/srv/media/.syncthing/config.xml")
if not DRY and cfg.exists():
@@ -1165,6 +1392,78 @@ def main():
print('\nexample: ./configure.py --set ALERT_WEBHOOK_URL=https://discord.com/api/webhooks/…')
sys.exit(1)
set_env(dict(p.split("=", 1) for p in pairs)); return
if mode == "--alias":
# Step one of a no-outage base move: every site answers on BOTH bases. Only the Caddyfile changes
# (plus one .env key) and Caddy reloads; certificates for the new names are issued in the background.
# --alias "" takes the aliases away again. Needs the new names to resolve (certificates).
if len(sys.argv) != 3:
print('usage: configure.py --alias NEW-DOMAIN (e.g. --alias vng.one ; --alias "" to remove)'); sys.exit(2)
if not domain: print("domain not set yet — run ./configure.py --full first"); sys.exit(1)
alt = sys.argv[2].strip().lower()
if alt:
for r, h in rebase_hosts(hosts, domain.split(".", 1)[1], alt).items():
if r in ("main", "auth", "watch", "git", "bot", "live", "files", "sync", "clips", "diag"):
try: socket.getaddrinfo(h, 443)
except OSError: print(f" !! {h} does not resolve yet — add the DNS first"); sys.exit(1)
env0 = read_env(); env0["ALT_BASE"] = alt
Path(".env").write_text(render_env(env0)); os.chmod(".env", 0o600)
write_caddy(domain, auth_host, watch_host, git_host, bot_host, live_host, files_host, sync_host, clips_host, diag_host); decorate_caddy()
dc = compose_cmd()
if not DRY: run(dc + ["exec", "caddy", "caddy", "reload", "--config", "/etc/caddy/Caddyfile"])
print(f" ALT_BASE={alt or '(cleared)'}; Caddy reloaded. Nothing else changed." + (f" Check: curl -sI https://auth.{alt}/ | head -1" if alt else ""))
return
if mode == "--base":
# Move every host to a new base domain in ONE apply, labels kept: --base vng.one turns media.old into
# media.vng.one, files.old into files.vng.one, … Needs the DNS to exist already (certificates). Does not
# touch the things that must come later (HOSTNAME-CHANGE.md §B): Jellyfin/Gitea UI settings, then
# --ak-hosts OLD NEW for Authentik, then the Discord portal.
if len(sys.argv) != 3 or "." not in sys.argv[2]:
print("usage: configure.py --base NEW-DOMAIN (e.g. --base vng.one)"); sys.exit(2)
if not domain: print("domain not set yet — run ./configure.py --full first"); sys.exit(1)
old_base = domain.split(".", 1)[1] if "." in domain else domain
new_base = sys.argv[2].strip().lower()
moved = rebase_hosts(hosts, old_base, new_base)
unchanged = [f"{r}={h}" for r, h in hosts.items() if moved[r] == h]
print(f"== base domain {old_base} -> {new_base}")
for r in ("main", "auth", "watch", "git", "bot", "live", "files", "sync", "clips", "diag"):
if r in hosts: print(f" {r:6} {hosts[r]:34} -> {moved[r]}")
if unchanged: print(" left alone (not under the old base):", ", ".join(unchanged))
env0 = read_env()
# the alias did its job; from now on the OLD names are redirects to the new ones (bookmarks keep working)
if env0.get("ALT_BASE", "").strip().lower() in (new_base, old_base): env0["ALT_BASE"] = ""
env0["REDIRECT_BASE"] = old_base
Path(".env").write_text(render_env(env0)); os.chmod(".env", 0o600)
print(f" old names under {old_base} will 308 to the new ones (REDIRECT_BASE); --alias \"\" later removes nothing, edit REDIRECT_BASE= in .env to drop them")
apex = env0.get("APEX_HOST", "")
if apex and (apex == old_base or apex.endswith("." + old_base)):
# The bare old domain cannot stay: its Caddy block would keep asking for a certificate for a name that
# is no longer ours to serve. The bare NEW domain is not assumed either (it needs its own A record at
# the box, and today's does not point here): add it later with --full once that record exists.
print(f" APEX_HOST={apex} -> cleared (bare domain of the old base; re-add the new one with --full once an A record points here)")
env0["APEX_HOST"] = ""; env0["APEX_REDIRECT"] = "" # not a SETTABLE key: written directly, the apply below regenerates Caddy
Path(".env").write_text(render_env(env0)); os.chmod(".env", 0o600)
for r, h in moved.items():
if r in ("main", "auth", "watch", "git", "bot", "live", "files", "sync", "clips", "diag"):
try: socket.getaddrinfo(h, 443)
except OSError: print(f" !! {h} does not resolve yet — Caddy cannot get its certificate; add the DNS first"); sys.exit(1)
if not yes(f"\napply now? Caddy, Jellyfin, Gitea, the runner, the bot and Kibana move to {new_base}; gated hosts stay unreachable until --ak-hosts"):
print("nothing written"); return
apply(moved["main"], admin_pw, streamers, editors, moved.get("auth", ""), moved.get("watch", ""), git_host=moved.get("git", ""), bot_host=moved.get("bot", ""),
live_host=moved.get("live", ""), files_host=moved.get("files", ""), sync_host=moved.get("sync", ""), clips_host=moved.get("clips", ""), diag_host=moved.get("diag", ""))
show(moved["main"], admin_pw, streamers, editors, moved.get("auth", ""), moved.get("watch", ""), moved.get("git", ""), moved.get("bot", ""), moved.get("live", ""), moved.get("files", ""), moved.get("sync", ""), moved.get("clips", ""), moved.get("diag", ""))
print(f"\nNEXT, in this order (HOSTNAME-CHANGE.md §B): docker compose restart mediamtx (between streams) → Jellyfin UI → Gitea UI + runner + clone remote → ./configure.py --ak-hosts {old_base} {new_base} → ./configure.py --ak-branding (brand default app follows the provider) → Discord portal → sudo vghosts-check.py")
return
if mode == "--ak-hosts":
# Authentik's hostnames, OLD -> NEW. Separate from --base and from --apply by design: it is the last
# service step of a domain move (apps before Authentik). --dry-run prints what would change.
rest = [a for a in sys.argv[2:] if not a.startswith("--")]
if len(rest) != 2:
print("usage: configure.py --ak-hosts OLD-DOMAIN NEW-DOMAIN [--dry-run] (e.g. --ak-hosts dumpstervigilantes.rip vng.one)"); sys.exit(2)
ak_hosts(compose_cmd(), rest[0].strip().lower(), rest[1].strip().lower(), dry="--dry-run" in sys.argv); return
if mode == "--ak-branding":
# Just the Authentik login branding + Discord forward (also part of every --apply). On its own so a
# change to authentik/brand/*.svg or the CSS can be pushed without touching anything else.
install_brand_assets(); ak_branding(compose_cmd()); return
if mode == "--es-security":
# Just the Elasticsearch user bootstrap. Worth having on its own: if it is skipped or fails during an
# --apply, Kibana reports "unavailable", Metricbeat exits on Kibana's 503 and Filebeat sits up but