diff --git a/README.md b/README.md index a110ce1..5f8ce7a 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,32 @@ Turning it on: `DEPLOY_AUTO=1` in `.env`, `./configure.py --apply`, and CI: `.gitea/draft-workflows/ci.yml` (inert until moved to `.gitea/workflows/`) runs `check.sh` and the plugin tests/build on the Actions runner. The runner never deploys — it must not touch the host. +## Rebuild from scratch (what is and isn't automated) + +Server side, scripted: + + git clone ssh://git@git.HOST:2222/techy/Media-Server-Setup.git ~/vanguard-stream # or scp the folder + cd ~/vanguard-stream && sudo bash install.sh # packages, docker, folders, host scripts, timers, firewall, portal build + ./configure.py --full # hostnames, secrets, configs, containers, post-start fixups + +Restore first if rebuilding a known box: `.env` (all secrets — without it every password is regenerated and +the databases won't match), `/srv/appdata` (Authentik DB, Jellyfin, Gitea, MariaDB, Syncthing config), +`/srv/media` (the footage), `/etc/vanguard/clip-quotas.json` if used. With those restored, nothing below is +needed except DNS. + +Post-start fixups `configure.py` performs on every apply (idempotent): Syncthing GUI auth off, Gitea admin +user created from `.env` (`GITEA_ADMIN_USER` / `GITEA_ADMIN_PASSWORD`; the install page is locked), Jellyfin plugin +installed from the committed zip. Authentik's `akadmin` password is set on first start from +`AUTHENTIK_BOOTSTRAP_PASSWORD` in `.env` (no initial-setup page). + +Still manual on a truly fresh box (not in the repo; documented in §5c–5g): +- Authentik: Discord source, identification-stage source, enrollment user type, groups, the two policies, + the domain-level proxy provider + outpost host, the Jellyfin and Gitea OIDC providers, application bindings +- Jellyfin: first-run wizard, libraries (Shows + Vanguard provider), SSO plugin + provider, branding +- Gitea: authentication source (authentik), deploy key, webhook; `./registerGiteaRunner.sh` +- Discord developer portal: application, redirect URIs, bot; DNS records +A `.env`-driven Authentik blueprint would remove the first line (planned). + ## Layout on the server /srv/media/streams///.live/<ts>.mp4 recording in progress (hidden from Jellyfin/copyparty) diff --git a/configure.py b/configure.py index 67aaa4e..b2e97fd 100755 --- a/configure.py +++ b/configure.py @@ -318,6 +318,10 @@ def write_env(watch_host, discord_token=None, discord_guild=None, git_host="", b 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("STREAM_TOKEN_SECRET", secrets.token_urlsafe(48)) # signs publish tokens (dumpd) + env.setdefault("AUTHENTIK_BOOTSTRAP_PASSWORD", "".join(c for c in secrets.token_urlsafe(30) if c.isalnum())[:20]) # akadmin on first start + env.setdefault("AUTHENTIK_BOOTSTRAP_TOKEN", secrets.token_urlsafe(40)) + env.setdefault("GITEA_ADMIN_USER", "vanguard") + env.setdefault("GITEA_ADMIN_PASSWORD", "".join(c for c in secrets.token_urlsafe(30) if c.isalnum())[:20]) env.setdefault("DEPLOY_AUTO", "0") # 1 = Gitea push webhook runs deploy.sh (off until trusted) env.setdefault("DEPLOY_WEBHOOK_SECRET", secrets.token_urlsafe(32)) # Gitea webhook secret (HMAC) env.setdefault("DEPLOY_BRANCH", "main") @@ -507,18 +511,57 @@ def apply(domain, admin_pw, streamers, editors, auth_host="", watch_host="", dis run(dc + ["up", "-d", "--build"]) # recreates only services whose compose definition changed run(dc + ["restart", "copyparty"]) # copyparty re-reads its config on start run(dc + ["exec", "caddy", "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]) # zero-downtime + post_start_fixups(dc) print(" mediamtx: config changes are NOT picked up live on this setup (bind-mounted file). If mediamtx.yml changed,") print(" run docker compose restart mediamtx between streams (cuts a live recording). Adding/removing") print(" streamers or tokens never touches mediamtx.yml, so day-to-day nothing restarts.") +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() + print("== post-start fixups (idempotent)") + # 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(): + import time as _t + for _ in range(20): + if "<gui" in cfg.read_text(): break + _t.sleep(2) + x = cfg.read_text() + if "<gui" in x and ('insecureAdminAccess="true"' not in x or "<user>" in x or "<password>" in x): + x = re.sub(r"^\s*<user>.*</user>\s*$\n?", "", x, flags=re.M); x = re.sub(r"^\s*<password>.*</password>\s*$\n?", "", x, flags=re.M) + x = re.sub(r'<gui ([^>]*?)insecureAdminAccess="false"', r'<gui \1insecureAdminAccess="true"', x) + if 'insecureAdminAccess=' not in x: x = x.replace("<gui ", '<gui insecureAdminAccess="true" ', 1) + subprocess.run(["sudo", "tee", str(cfg)], input=x, text=True, capture_output=True) + run(dc + ["restart", "syncthing"]); print(" syncthing: GUI auth disabled, insecureAdminAccess on") + else: print(" syncthing: ok") + elif DRY: print(" (dry) syncthing config patch") + # Gitea: create the admin user once (INSTALL_LOCK skips the install page) + if not DRY: + import time as _t + for _ in range(30): + r = subprocess.run(dc + ["exec", "-T", "-u", "1000", "gitea", "gitea", "admin", "user", "list", "--config", "/data/gitea/conf/app.ini"], capture_output=True, text=True) + if r.returncode == 0: break + _t.sleep(3) + if r.returncode == 0 and env.get("GITEA_ADMIN_USER") and env["GITEA_ADMIN_USER"] not in r.stdout: + subprocess.run(dc + ["exec", "-T", "-u", "1000", "gitea", "gitea", "admin", "user", "create", "--config", "/data/gitea/conf/app.ini", "--admin", + "--username", env["GITEA_ADMIN_USER"], "--password", env["GITEA_ADMIN_PASSWORD"], "--email", f"{env['GITEA_ADMIN_USER']}@{env.get('MAIN_HOST','localhost')}", "--must-change-password=false"], capture_output=True, text=True) + print(f" gitea: admin user {env['GITEA_ADMIN_USER']} created (password in .env)") + else: print(" gitea: ok" if r.returncode == 0 else " gitea: not ready (install page or db?) — run ./configure.py --apply again later") + else: print(" (dry) gitea admin user") + # Jellyfin plugin from the committed zip + if Path("installJellyfinPlugin.sh").exists(): + 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)'}") _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"== 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)") print(f"== jellyfin : https://{watch_host}/" if watch_host else "== jellyfin : (not set — run --full)") env = read_env() tok = env.get("DISCORD_BOT_TOKEN", "") diff --git a/docker-compose.yml b/docker-compose.yml index 187ff54..7a6bf7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,6 +98,8 @@ services: command: server environment: &authentik_env AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} + AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD:-} # akadmin password on FIRST start (no initial-setup page) + AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN:-} # API token for akadmin, created on first start (blueprints/automation) AUTHENTIK_REDIS__HOST: authentik-redis AUTHENTIK_POSTGRESQL__HOST: authentik-db AUTHENTIK_POSTGRESQL__USER: authentik @@ -234,6 +236,7 @@ services: GITEA__actions__ENABLED: "true" GITEA__actions__DEFAULT_ACTIONS_URL: github GITEA__webhook__ALLOWED_HOST_LIST: private,loopback,external + GITEA__security__INSTALL_LOCK: "true" # no install page: db/settings come from env; admin user via configure.py GITEA__time__DEFAULT_UI_LOCATION: America/New_York ports: - "127.0.0.1:3000:3000" diff --git a/install.sh b/install.sh index 20f2722..5808b9f 100755 --- a/install.sh +++ b/install.sh @@ -67,6 +67,12 @@ echo "== compose images (the stack is started by ./configure.py --full, which ge chown -R "$OWNER:" . docker compose pull -q --ignore-buildable 2>/dev/null || docker compose pull -q || true +echo "== live portal: build into /srv/live (node)" +if [[ -d live && -f live/package.json ]]; then + ( cd live && sudo -u "$OWNER" -H npm install --no-audit --no-fund --loglevel=error && sudo -u "$OWNER" -H npm run build ) \ + && install -d -o "$OWNER" /srv/live && rsync -a --delete live/dist/ /srv/live/ && echo " built" || echo " portal build failed (run ./deployLive.sh from a dev box later)" +fi + echo "== host firewall (nftables; own table, input only; keeps this SSH session alive)" if [[ -f firewall/firewall.sh ]]; then bash firewall/firewall.sh