bunch of shit
This commit is contained in:
363
configure.py
363
configure.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user