#!/usr/bin/env python3 """vanguard-stream post-install configuration. Stateless: the config files ARE the state. ./configure.py --full first-time setup: asks every question (current values as defaults) ./configure.py manage: skips what is already set; add/remove/reset streamers & editors ./configure.py --show print current streamers, editors, domain and the OBS settings ./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), .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, 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). """ import getpass, hashlib, os, re, secrets, shutil, subprocess, sys from pathlib import Path HERE = Path(__file__).resolve().parent os.chdir(HERE) DRY = os.environ.get("VG_DRY") == "1" NAME_RE = re.compile(r"^[a-z0-9_-]{2,32}$") BAD_PW = set(':&?#/ "\'\\\t') # would break the RTMP query string / SRT streamid / YAML # ------------------------------------------------------------------ read current def read_hosts(): """{'main': host, 'auth': host, 'watch': host} from '# vanguard:' markers; first block = main.""" hosts, role = {}, None try: for line in Path("Caddyfile").read_text().splitlines(): if line[:1].isspace(): continue # site blocks start at column 0 t = line.strip() 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 hosts[role or ("main" if "main" not in hosts else host)] = host role = None except FileNotFoundError: pass return hosts def read_domain(): return read_hosts().get("main", "") def read_env(): env = {} try: for line in Path(".env").read_text().splitlines(): if "=" in line and not line.startswith("#"): k, v = line.split("=", 1); env[k.strip()] = v.strip() except FileNotFoundError: pass return env def default_sub(main, sub): labels = main.split(".") base = ".".join(labels[1:]) if len(labels) >= 3 else main return f"{sub}.{base}" if main else "" def read_streamers(): out, cur = {}, None try: in_users = False for line in Path("mediamtx.yml").read_text().splitlines(): if line.startswith("authInternalUsers:"): in_users = True; continue if in_users and line and not line.startswith(" "): break if in_users: m = re.match(r"\s*-\s*user:\s*(\S+)", line) if m: cur = m.group(1); continue m = re.match(r"\s*pass:\s*(\S+)", line) if m and cur: if not m.group(1).startswith("CHANGEME"): out[cur] = m.group(1) cur = None except FileNotFoundError: pass return out def read_accounts(): out = {} try: in_acc = False for line in Path("copyparty.conf").read_text().splitlines(): if line.strip() == "[accounts]": in_acc = True; continue if in_acc and line.startswith("["): break if in_acc: m = re.match(r"\s*([A-Za-z0-9_-]+):\s*(\S+)", line) if m and not m.group(2).startswith("CHANGEME"): out[m.group(1)] = m.group(2) except FileNotFoundError: pass return out # ------------------------------------------------------------------ prompts def ask(q, default=""): s = input(f"{q}{f' [{default}]' if default else ''}: ").strip() return s or default def yes(q, default=True): d = "Y/n" if default else "y/N" s = input(f"{q} [{d}]: ").strip().lower() return default if not s else s.startswith("y") def gen_pw(): return "".join(c for c in secrets.token_urlsafe(24) if c.isalnum())[:20] def ask_pw(label, current=None): """keep / generate / type. Returns the password.""" opts = "(enter = generate" + (", k = keep current" if current else "") + ", or type one)" while True: prompt = f" password for {label} {opts}: " s = (getpass.getpass(prompt) if sys.stdin.isatty() else input(prompt)).strip() if not s: p = gen_pw(); print(f" generated: {p}"); return p if s == "k" and current: return current if BAD_PW & set(s): print(" avoid : & ? # / \\ quotes and spaces (they break stream keys)"); continue if len(s) < 8: print(" at least 8 characters"); continue return s def ask_name(kind, taken): while True: n = ask(f" {kind} name (blank to stop)").lower() if not n: return None if not NAME_RE.match(n): print(" lowercase letters, digits, _ - (2-32 chars)"); continue if n in taken: print(" already exists"); continue return n # ------------------------------------------------------------------ writers AUTHENTIK_FORWARD_AUTH = """ # Authentik forward-auth (embedded outpost). Needs a Proxy Provider in Authentik — domain-level for the # whole domain (recommended) or single-application for this host — assigned to the embedded outpost. # Until then: 4xx, never open. Per-host group requirements are enforced below from X-Authentik-Groups. reverse_proxy /outpost.goauthentik.io/* 127.0.0.1:9000 forward_auth 127.0.0.1:9000 { uri /outpost.goauthentik.io/auth/caddy copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version trusted_proxies private_ranges } """ def gate(required_group=""): """Caddy lines: Authentik forward-auth, then deny unless X-Authentik-Groups contains required_group.""" block = AUTHENTIK_FORWARD_AUTH if required_group: g = re.escape(required_group) block += f""" @lacks_group not header_regexp X-Authentik-Groups (^|\\|){g}(\\||$) handle @lacks_group {{ respond "This page is for members of the '{required_group}' group." 403 }} """ return block def write_caddy(domain, auth_host="", watch_host="", git_host="", bot_host="", live_host=""): env = read_env() extra = "" apex, target = env.get("APEX_HOST", ""), env.get("APEX_REDIRECT", "") if apex and target: extra += f""" # vanguard:apex {apex} {{ redir {target} 302 }} """ if live_host: extra += f""" # vanguard:live {live_host} {{ encode zstd gzip {gate(env.get("LIVE_REQUIRE_GROUP", ""))} handle /me/* {{ reverse_proxy 127.0.0.1:8080 # dumpd: /me/stream-key for the logged-in streamer (Go Live) }} handle_path /api/* {{ reverse_proxy 127.0.0.1:9997 }} handle_path /webrtc/* {{ # Go Live sends MediaMTX credentials in X-Stream-Auth so Authentik's forward-auth never sees an # Authorization header; Caddy turns it into Authorization only on the hop to MediaMTX. @creds header X-Stream-Auth * reverse_proxy @creds 127.0.0.1:8889 {{ header_up Authorization {{http.request.header.X-Stream-Auth}} header_up -X-Stream-Auth }} reverse_proxy 127.0.0.1:8889 }} handle_path /hls/* {{ reverse_proxy 127.0.0.1:8888 }} handle {{ root * /srv/live try_files {{path}} /index.html file_server }} }} """ if bot_host: extra += f""" # vanguard:bot {bot_host} {{ encode zstd gzip {gate(env.get("BOT_REQUIRE_GROUP", "bot-users"))} reverse_proxy 127.0.0.1:8000 }} """ if git_host: extra += f""" # vanguard:git {git_host} {{ encode zstd gzip reverse_proxy 127.0.0.1:3000 }} """ if auth_host: extra += f""" # vanguard:auth {auth_host} {{ encode zstd gzip reverse_proxy 127.0.0.1:9000 }} """ if watch_host: extra += f""" # vanguard:watch {watch_host} {{ 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} # 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: 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}} }} }} # OBS browser dock (static) handle_path /dock* {{ root * /srv/dock file_server }} # 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 }} }} }} """ + extra) def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", bot_host="", live_host="", main_host="", live_webhook=None): env = read_env() 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 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") env["BOT_HOST"] = bot_host or env.get("BOT_HOST", "bot.example.com") env.setdefault("GITEA_DB_PASSWORD", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32]) env.setdefault("GITEA_RUNNER_TOKEN", "") # filled by ./registerGiteaRunner.sh if discord_token is not None: env["DISCORD_BOT_TOKEN"] = discord_token if discord_guild is not None: env["DISCORD_GUILD_ID"] = discord_guild env.setdefault("AUTHENTIK_SECRET_KEY", secrets.token_urlsafe(48)) env.setdefault("AUTHENTIK_PG_PASS", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32]) env.setdefault("AUTHENTIK_TAG", "2025.8") env.setdefault("MARIADB_ROOT_PASSWORD", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32]) env.setdefault("BOT_DB_NAME", "vanguardbot") env.setdefault("BOT_DB_USER", "bot") env.setdefault("BOT_DB_PASSWORD", "".join(c for c in secrets.token_urlsafe(40) if c.isalnum())[:32]) env.setdefault("DISCORD_BOT_TOKEN", "") # fill in: Discord dev portal -> the Authentik app -> Bot env.setdefault("DISCORD_GUILD_ID", "") env["WATCH_HOST"] = watch_host or "watch.example.com" Path(".env").write_text("".join(f"{k}={v}\n" for k, v in env.items())) os.chmod(".env", 0o600) def write_mediamtx(streamers, public_host=""): users = "".join(f""" - user: {u} pass: {p} permissions: - action: publish path: ~^{u}/[A-Za-z0-9_.-]+$ """ for u, p in streamers.items()) or " []\n" alt = "|".join(streamers) or "nobody" Path("mediamtx.yml").write_text(f"""# MediaMTX — ingest only. No playback endpoints exposed. (generated by configure.py) logLevel: info rtsp: no moq: no metrics: no 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 hls: yes hlsAddress: :8888 hlsVariant: lowLatency hlsAlwaysRemux: no webrtc: yes webrtcAddress: :8889 webrtcLocalUDPAddress: :8189 webrtcLocalTCPAddress: :8189 webrtcIPsFromInterfaces: no webrtcAdditionalHosts: [{public_host or "127.0.0.1"}] # public hostname browsers can reach for ICE (written by configure.py) rtmp: yes rtmpAddress: :1935 srt: yes srtAddress: :8890 # ---- auth ------------------------------------------------------------- # Each streamer may publish only under their own prefix. # RTMP key: /Title?user=&pass= # SRT: srt://HOST:8890?streamid=publish:/Title:: authMethod: internal authInternalUsers: - user: any permissions: - action: read - action: playback - action: api {users} # ---- recording -------------------------------------------------------- pathDefaults: record: yes recordPath: /recordings/%path/.live/%Y-%m-%d_%H-%M-%S # hidden while recording; the sweep publishes the finished file one level up recordFormat: fmp4 recordPartDuration: 1s recordSegmentDuration: 12h # one file per stream unless it runs >12h recordDeleteAfter: 0s # never auto-delete; the archive job handles it # optional Discord "is live" notice (LIVE_WEBHOOK_URL set in .env). busybox wget in the -ffmpeg image. runOnReady: > sh -c '[ -n "$LIVE_WEBHOOK_URL" ] || exit 0; u="${{MTX_PATH%%/*}}"; t="${{MTX_PATH#*/}}"; wget -q -O /dev/null --header="Content-Type: application/json" --post-data="{{\\"content\\":\\"🔴 **$u** is live: $t — https://$LIVE_HOST/\\"}}" "$LIVE_WEBHOOK_URL"' runOnReadyRestart: no paths: # any name under a streamer's prefix; inherits pathDefaults ~^({alt})/[A-Za-z0-9_.-]+$: {{}} """) 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). # No WebDAV: login is Discord only (browser session); download via the web UI (zip/tar for folders). # Docs: https://github.com/9001/copyparty#identity-providers [global] e2dsa # index files for search e2ts # read media tags th-ff-jpg # video thumbnails via ffmpeg (cached under /cfg/hist) 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 [/streams] /w/streams accs: r: @editors rwmd: @media-admins [/clips] /w/clips accs: r: @editors rwmd: @media-admins [/finals] /w/finals accs: rw: @editors rwmd: @media-admins [/trash] /w/trash accs: 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_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)) 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): # 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(); 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", "/srv/appdata/authentik/media", "/srv/appdata/authentik/templates", "/srv/appdata/authentik/certs"]) 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 streamers") for u in streamers: run(["sudo", "install", "-d", "-o", "1000", "-g", "1000", f"/srv/media/streams/{u}", f"/srv/media/clips/{u}"]) if not DRY and not Path(f"/srv/media/clips/{u}/.stignore").exists(): 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", "0600", "streamers.json", "/etc/vanguard/streamers.json"]) run(["sudo", "systemctl", "restart", "dumpd"]) print("== containers") dc = ["docker", "compose"] if (DRY or subprocess.call(["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0) else ["sudo", "docker", "compose"] run(dc + ["up", "-d", "--build"]) run(dc + ["restart", "mediamtx", "copyparty", "caddy"]) 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)'}") _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"== 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 (runner: ./registerGiteaRunner.sh)" if git_host else "== gitea : (not set — run --full)") print(f"== authentik: https://{auth_host}/ (first run: https://{auth_host}/if/flow/initial-setup/)" if auth_host else "== authentik: (not set — run --full)") print(f"== jellyfin : https://{watch_host}/" if watch_host else "== jellyfin : (not set — run --full)") env = read_env() tok = env.get("DISCORD_BOT_TOKEN", "") print(f"== discord : guild {env.get('DISCORD_GUILD_ID') or '(not set)'}; bot token {'set (' + tok[:6] + '…)' if tok else '(not set)'}") 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("== 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:") for u, p in streamers.items(): print(f"\n {u} password {p}") print(f" OBS server : rtmp://{domain or 'HOST'}:1935/") print(f" OBS key : {u}/TITLE?user={u}&pass={p} (the dock sets this for them)") 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 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") # ------------------------------------------------------------------ modes def manage_users(kind, users): """add / remove / reset loop for one kind of user; mutates dict.""" while True: print(f"\n {kind}s: {', '.join(users) or '(none)'}") c = ask(" [a]dd [r]emove [p]assword reset [d]one", "d").lower()[:1] if c == "d": return if c == "a": n = ask_name(kind, users) if n: users[n] = ask_pw(n) elif c in "rp": n = ask(f" which {kind}").lower() if n not in users: print(" no such user"); continue if c == "r": if yes(f" remove {n}? (their files stay on disk)", False): del users[n] 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=""): print("== FULL SETUP (enter keeps the shown default)\n") while True: domain = ask("public hostname (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") 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")) bot_host = ask("Bot web UI hostname (CNAME to the above)", bot_host or default_sub(domain, "bot")) 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 "" 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-- streamers (OBS publishers; each gets streams/ and clips/)") for u in list(streamers): k = ask(f" {u}: [k]eep [r]emove [p]assword reset", "k").lower()[:1] if k == "r": del streamers[u] elif k == "p": streamers[u] = ask_pw(u, streamers[u]) print(" add streamers:") while True: n = ask_name("streamer", streamers) if not n: break streamers[n] = ask_pw(n) 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] elif k == "p": editors[u] = ask_pw(u, editors[u]) print(" add editors:") while True: n = ask_name("editor", editors) if not n: break editors[n] = ask_pw(n) e = read_env(); e["APEX_HOST"] = apex; e["APEX_REDIRECT"] = apex_to 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 def ask_discord(): env = read_env() cur_tok, cur_guild = env.get("DISCORD_BOT_TOKEN", ""), env.get("DISCORD_GUILD_ID", "") prompt = f" bot token{' [keep ' + cur_tok[:6] + '…]' if cur_tok else ' [none]'}: " tok = (getpass.getpass(prompt) if sys.stdin.isatty() else input(prompt)).strip() or cur_tok guild = ask(" server (guild) ID", cur_guild) return tok, guild def normal(domain, admin_pw, streamers, editors): 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 [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 == "d": discord = ask_discord() elif c == "v": show(domain, admin_pw, streamers, editors) elif c == "a": return discord elif c == "q": return False 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", "") 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 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: 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 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) if not yes("\napply now (write configs, restart services)?"): print("nothing written"); return elif mode == "": r = normal(domain, admin_pw, streamers, editors) if r is False: print("nothing written"); return 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) if __name__ == "__main__": try: main() except (KeyboardInterrupt, EOFError): print("\naborted, nothing written")