diff --git a/Caddyfile b/Caddyfile index e3380c5..4998596 100644 --- a/Caddyfile +++ b/Caddyfile @@ -81,27 +81,57 @@ requests.ginnoir.com { # ============================================================= sonarr.ginnoir.com { import internal_only - reverse_proxy sonarr:8989 + route { + import authentik_outpost + handle { + import authentik_forward_auth + reverse_proxy sonarr:8989 + } + } } radarr.ginnoir.com { import internal_only - reverse_proxy radarr:7878 + route { + import authentik_outpost + handle { + import authentik_forward_auth + reverse_proxy radarr:7878 + } + } } bazarr.ginnoir.com { import internal_only - reverse_proxy bazarr:6767 + route { + import authentik_outpost + handle { + import authentik_forward_auth + reverse_proxy bazarr:6767 + } + } } prowlarr.ginnoir.com { import internal_only - reverse_proxy prowlarr:9696 + route { + import authentik_outpost + handle { + import authentik_forward_auth + reverse_proxy prowlarr:9696 + } + } } tautulli.ginnoir.com { import internal_only - reverse_proxy tautulli:8181 + route { + import authentik_outpost + handle { + import authentik_forward_auth + reverse_proxy tautulli:8181 + } + } } # ============================================================= @@ -109,27 +139,57 @@ tautulli.ginnoir.com { # ============================================================= qbittorrent.ginnoir.com { import internal_only - reverse_proxy qbittorrent:3232 + route { + import authentik_outpost + handle { + import authentik_forward_auth + reverse_proxy qbittorrent:3232 + } + } } deluge.ginnoir.com { import internal_only - reverse_proxy deluge:8112 + route { + import authentik_outpost + handle { + import authentik_forward_auth + reverse_proxy deluge:8112 + } + } } nzbget.ginnoir.com { import internal_only - reverse_proxy nzbget:6789 + route { + import authentik_outpost + handle { + import authentik_forward_auth + reverse_proxy nzbget:6789 + } + } } whisparr.ginnoir.com { import internal_only - reverse_proxy whisparr:6969 + route { + import authentik_outpost + handle { + import authentik_forward_auth + reverse_proxy whisparr:6969 + } + } } stash.ginnoir.com { import internal_only - reverse_proxy stash:6970 + route { + import authentik_outpost + handle { + import authentik_forward_auth + reverse_proxy stash:6970 + } + } } # ============================================================= @@ -344,14 +404,24 @@ docs.ginnoir.com { } plane.ginnoir.com { - handle /api/* { - reverse_proxy plane_api:8000 - } - handle /auth/* { - reverse_proxy plane_api:8000 - } - handle { - reverse_proxy plane_web:3000 + route { + import authentik_outpost + handle /api/* { + import authentik_forward_auth + reverse_proxy plane_api:8000 + } + handle /auth/* { + import authentik_forward_auth + reverse_proxy plane_api:8000 + } + handle /god-mode/* { + import authentik_forward_auth + reverse_proxy plane_admin:3000 + } + handle { + import authentik_forward_auth + reverse_proxy plane_web:3000 + } } } diff --git a/scripts/apply-media-external-auth.sh b/scripts/apply-media-external-auth.sh new file mode 100644 index 0000000..0a9f61c --- /dev/null +++ b/scripts/apply-media-external-auth.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +# TB-006 batch 2: disable local auth; Servarr -> External; skip tautulli. +# Run on valhalla as ginnoir. + +set_servarr_external() { + local app="$1" + local cfg="/config/${app}/config.xml" + if [[ ! -f "$cfg" ]]; then + echo "SKIP ${app}: no ${cfg}" + return 0 + fi + echo "=== ${app}: set External ===" + docker stop "$app" >/dev/null + python3 - "$cfg" <<'PY' +import sys +import xml.etree.ElementTree as ET + +path = sys.argv[1] +tree = ET.parse(path) +root = tree.getroot() + +auth_required = None +for el in list(root): + if el.tag == "AuthenticationRequired" and el.text: + auth_required = el.text.strip() + break + +for el in list(root): + if el.tag in ("AuthenticationMethod", "AuthenticationRequired"): + root.remove(el) + +ext = ET.SubElement(root, "AuthenticationMethod") +ext.text = "External" +req = ET.SubElement(root, "AuthenticationRequired") +req.text = auth_required or "Enabled" + +tree.write(path, encoding="UTF-8", xml_declaration=True) +print(f" wrote External (AuthenticationRequired={req.text})") +PY + docker start "$app" >/dev/null + grep Authentication "$cfg" +} + +for app in sonarr radarr prowlarr whisparr; do + set_servarr_external "$app" +done + +echo "=== bazarr: verify auth disabled ===" +python3 - <<'PY' +import yaml +path = "/config/bazarr/config/config.yaml" +with open(path) as f: + d = yaml.safe_load(f) +auth = d.setdefault("auth", {}) +changed = False +if auth.get("type") is not None: + auth["type"] = None + changed = True +if auth.get("username"): + auth["username"] = "" + changed = True +if auth.get("password"): + auth["password"] = "" + changed = True +if changed: + with open(path, "w") as f: + yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False) + print(" updated bazarr auth -> type null") +else: + print(" already type null / no credentials") +PY +docker restart bazarr >/dev/null + +echo "=== qbittorrent: disable WebUI auth (keep API key for *arr) ===" +QCONF="/config/qbittorrent/qBittorrent/qBittorrent.conf" +docker stop qbittorrent >/dev/null +python3 - "$QCONF" <<'PY' +from pathlib import Path +import sys +path = Path(sys.argv[1]) +lines = path.read_text().splitlines() +out = [] +added = False +for line in lines: + if line.startswith("WebUI\\AuthenticationEnabled="): + out.append("WebUI\\AuthenticationEnabled=false") + added = True + continue + out.append(line) +if not added: + final = [] + for line in out: + final.append(line) + if line.strip() == "[Preferences]": + final.append("WebUI\\AuthenticationEnabled=false") + added = True + out = final +path.write_text("\n".join(out) + "\n") +print(" WebUI\\AuthenticationEnabled=false") +PY +grep 'WebUI\\AuthenticationEnabled' "$QCONF" || true +docker start qbittorrent >/dev/null + +echo "=== nzbget: disable form auth (API creds unchanged) ===" +NZB="/config/nzbget/nzbget.conf" +sed -i 's/^FormAuth=.*/FormAuth=no/' "$NZB" +sed -i 's/^SecureControl=.*/SecureControl=no/' "$NZB" +grep -E '^(FormAuth|SecureControl)=' "$NZB" +docker restart nzbget >/dev/null + +echo "=== stash: external authwall (Authentik + internal_only) ===" +python3 - <<'PY' +from pathlib import Path +import re +path = Path("/config/stash/config.yml") +text = path.read_text() +text = re.sub(r'^dangerous_allow_public_without_auth:.*$', 'dangerous_allow_public_without_auth: "true"', text, flags=re.M) +text = re.sub(r'^username:.*$', 'username: ""', text, flags=re.M) +text = re.sub(r'^password:.*$', 'password: ""', text, flags=re.M) +text = re.sub(r'^security_tripwire_accessed_from_public_internet:.*\n', '', text, flags=re.M) +path.write_text(text) +print(" dangerous_allow_public_without_auth=true, cleared username/password") +PY +docker restart stash >/dev/null + +echo "=== deluge: bypass web login (Authentik at edge; patch re-applies on recreate) ===" +if docker exec deluge grep -q 'homelab external auth' /lsiopy/lib/python3.12/site-packages/deluge/ui/web/auth.py 2>/dev/null; then + echo " already patched" +else + docker cp /tmp/patch-deluge-auth.py deluge:/tmp/patch-deluge-auth.py + docker exec deluge python3 /tmp/patch-deluge-auth.py + docker restart deluge >/dev/null + echo " patched and restarted" +fi + +echo "=== done (tautulli untouched) ===" diff --git a/scripts/patch-deluge-auth.py b/scripts/patch-deluge-auth.py new file mode 100644 index 0000000..4867c1d --- /dev/null +++ b/scripts/patch-deluge-auth.py @@ -0,0 +1,26 @@ +from pathlib import Path + +path = Path("/lsiopy/lib/python3.12/site-packages/deluge/ui/web/auth.py") +text = path.read_text() +if "homelab external auth" in text: + print("already patched") + raise SystemExit(0) + +text = text.replace( + " return s.hexdigest() == config['pwd_sha1']", + " return True # homelab external auth", + 1, +) +old_login = """ if self.check_password(password): + log.info('Login success (ClientIP %s)', __request__.getClientIP()) + return self._create_session(__request__) + else: + log.error('Login failed (ClientIP %s)', __request__.getClientIP()) + return False""" +new_login = """ log.info('Login success (ClientIP %s) [homelab external auth]', __request__.getClientIP()) + return self._create_session(__request__)""" +if old_login not in text: + raise SystemExit("login() pattern not found") +text = text.replace(old_login, new_login, 1) +path.write_text(text) +print("patched auth.py") diff --git a/stacks/authentik/scripts/apply-forward-auth-admin.py b/stacks/authentik/scripts/apply-forward-auth-admin.py index 2025792..68f8672 100644 --- a/stacks/authentik/scripts/apply-forward-auth-admin.py +++ b/stacks/authentik/scripts/apply-forward-auth-admin.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 -"""Apply TB-006 batch 1 forward_auth proxy providers (forward_auth-only sites). +"""Apply TB-006 forward_auth proxy providers (admin batch 1 + media batch 2). -Native OAuth/OIDC apps (bookstack, gitea, portainer, minio, plane, homarr) use +Native OAuth/OIDC apps (bookstack, gitea, portainer, minio, homarr) use setup-batch1-oidc.py instead. Run this via: ak shell -c exec(open(...).read()) """ from authentik.core.models import Application @@ -21,12 +21,25 @@ UPTIME_UNAUTH = """^/status/.* ^/upload/.*""" SITES = [ + # Batch 1 — admin ("homelab-code", "code", "code-server", "https://code.ginnoir.com", None, None), ("homelab-registry-ui", "registry-ui", "Registry UI", "https://registry-ui.ginnoir.com", None, None), ("homelab-vault", "vault", "Vault", "https://vault.ginnoir.com", None, None), ("homelab-uptime", "uptime", "Uptime Kuma", "https://uptime.ginnoir.com", "http://uptime-kuma:3001", UPTIME_UNAUTH), ("homelab-backup", "backup", "Kopia", "https://backup.ginnoir.com", None, None), ("homelab-dbx", "dbx", "DBX", "https://dbx.ginnoir.com", None, None), + ("homelab-plane", "plane", "Plane", "https://plane.ginnoir.com", None, None), + # Batch 2 — media *arr stack (internal_only + forward_auth at Caddy) + ("homelab-sonarr", "sonarr", "Sonarr", "https://sonarr.ginnoir.com", None, None), + ("homelab-radarr", "radarr", "Radarr", "https://radarr.ginnoir.com", None, None), + ("homelab-bazarr", "bazarr", "Bazarr", "https://bazarr.ginnoir.com", None, None), + ("homelab-prowlarr", "prowlarr", "Prowlarr", "https://prowlarr.ginnoir.com", None, None), + ("homelab-tautulli", "tautulli", "Tautulli", "https://tautulli.ginnoir.com", None, None), + ("homelab-qbit", "qbit", "qBittorrent", "https://qbittorrent.ginnoir.com", None, None), + ("homelab-deluge", "deluge", "Deluge", "https://deluge.ginnoir.com", None, None), + ("homelab-nzbget", "nzbget", "NZBGet", "https://nzbget.ginnoir.com", None, None), + ("homelab-whisparr", "whisparr", "Whisparr", "https://whisparr.ginnoir.com", None, None), + ("homelab-stash", "stash", "Stash", "https://stash.ginnoir.com", None, None), ] providers = [] diff --git a/stacks/authentik/scripts/setup-batch1-oidc.py b/stacks/authentik/scripts/setup-batch1-oidc.py index 83377b8..9c4d1be 100644 --- a/stacks/authentik/scripts/setup-batch1-oidc.py +++ b/stacks/authentik/scripts/setup-batch1-oidc.py @@ -1,13 +1,15 @@ """Create OAuth2 providers for batch-1 apps with native SSO (ak shell: exec(open(...).read())).""" from authentik.core.models import Application +from authentik.crypto.models import CertificateKeyPair from authentik.flows.models import Flow from authentik.outposts.models import Outpost -from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode +from authentik.providers.oauth2.models import OAuth2Provider, RedirectURI, RedirectURIMatchingMode, SubModes from authentik.providers.proxy.models import ProxyProvider AUTHZ = Flow.objects.get(slug="default-provider-authorization-implicit-consent") INVALID = Flow.objects.get(slug="default-provider-invalidation-flow") DEFAULT_MAPPINGS = OAuth2Provider.objects.get(name="famapp").property_mappings.all() +SIGNING_KEY = CertificateKeyPair.objects.get(name="authentik Self-signed Certificate") # slug, provider name, redirect URI(s) OIDC_APPS = [ @@ -15,7 +17,6 @@ OIDC_APPS = [ ("gitea", "gitea", ["https://gitea.ginnoir.com/user/oauth2/Authentik/callback"]), ("portainer", "portainer", ["https://portainer.ginnoir.com/"]), ("minio", "minio-console", ["https://minio.ginnoir.com/oauth_callback"]), - ("plane", "plane", ["https://plane.ginnoir.com/auth/oidc/callback/"]), ] native_slugs = [] @@ -25,6 +26,8 @@ for slug, pname, redirects in OIDC_APPS: defaults={ "authorization_flow": AUTHZ, "invalidation_flow": INVALID, + "signing_key": SIGNING_KEY, + "sub_mode": SubModes.USER_UUID if slug == "bookstack" else SubModes.HASHED_USER_ID, "redirect_uris": [ RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, url=url) for url in redirects @@ -60,7 +63,6 @@ PROXY_NAMES = { "gitea": "homelab-gitea", "portainer": "homelab-portainer", "minio": "homelab-minio", - "plane": "homelab-plane", } outpost = Outpost.objects.get(name="authentik Embedded Outpost") for slug in native_slugs: diff --git a/stacks/authentik/scripts/setup-forward-auth-trust-proxy.py b/stacks/authentik/scripts/setup-forward-auth-trust-proxy.py index fceebfd..22e99bf 100644 --- a/stacks/authentik/scripts/setup-forward-auth-trust-proxy.py +++ b/stacks/authentik/scripts/setup-forward-auth-trust-proxy.py @@ -1,4 +1,4 @@ -"""Update proxy providers for forward_auth-only batch-1 apps (ak shell: exec(open(...).read())).""" +"""Update proxy providers for forward_auth apps (admin batch 1 + media batch 2).""" from authentik.core.models import Application from authentik.flows.models import Flow from authentik.outposts.models import Outpost, OutpostType @@ -17,12 +17,25 @@ UPTIME_UNAUTH = """^/status/.* # Proxy-only sites (native OIDC apps managed by setup-batch1-oidc.py). SITES = [ + # Batch 1 — admin ("homelab-code", "code", "code-server", "https://code.ginnoir.com", None, None), ("homelab-registry-ui", "registry-ui", "Registry UI", "https://registry-ui.ginnoir.com", None, None), ("homelab-vault", "vault", "Vault", "https://vault.ginnoir.com", None, None), ("homelab-uptime", "uptime", "Uptime Kuma", "https://uptime.ginnoir.com", "http://uptime-kuma:3001", UPTIME_UNAUTH), ("homelab-backup", "backup", "Kopia", "https://backup.ginnoir.com", None, None), ("homelab-dbx", "dbx", "DBX", "https://dbx.ginnoir.com", None, None), + ("homelab-plane", "plane", "Plane", "https://plane.ginnoir.com", None, None), + # Batch 2 — media + ("homelab-sonarr", "sonarr", "Sonarr", "https://sonarr.ginnoir.com", None, None), + ("homelab-radarr", "radarr", "Radarr", "https://radarr.ginnoir.com", None, None), + ("homelab-bazarr", "bazarr", "Bazarr", "https://bazarr.ginnoir.com", None, None), + ("homelab-prowlarr", "prowlarr", "Prowlarr", "https://prowlarr.ginnoir.com", None, None), + ("homelab-tautulli", "tautulli", "Tautulli", "https://tautulli.ginnoir.com", None, None), + ("homelab-qbit", "qbit", "qBittorrent", "https://qbittorrent.ginnoir.com", None, None), + ("homelab-deluge", "deluge", "Deluge", "https://deluge.ginnoir.com", None, None), + ("homelab-nzbget", "nzbget", "NZBGet", "https://nzbget.ginnoir.com", None, None), + ("homelab-whisparr", "whisparr", "Whisparr", "https://whisparr.ginnoir.com", None, None), + ("homelab-stash", "stash", "Stash", "https://stash.ginnoir.com", None, None), ] providers = [] diff --git a/stacks/dev/docker-compose.yml b/stacks/dev/docker-compose.yml index 262dc99..7ef3aa8 100644 --- a/stacks/dev/docker-compose.yml +++ b/stacks/dev/docker-compose.yml @@ -147,12 +147,11 @@ services: - DB_PASS=${BOOKSTACK_DB_PASSWORD} - DB_DATABASE=bookstack - AUTH_METHOD=oidc - - AUTH_AUTO_INITIATE=true + - AUTH_AUTO_INITIATE=false - OIDC_NAME=Authentik - - OIDC_CLIENT_ID=${BOOKSTACK_OIDC_CLIENT_ID} - - OIDC_CLIENT_SECRET=${BOOKSTACK_OIDC_CLIENT_SECRET} - OIDC_ISSUER=https://auth.ginnoir.com/application/o/bookstack/ - OIDC_ISSUER_DISCOVER=true + - OIDC_END_SESSION_ENDPOINT=true volumes: - /config/bookstack:/config ports: @@ -197,6 +196,23 @@ services: - NEXT_PUBLIC_API_BASE_URL=https://plane.ginnoir.com - HOSTNAME=0.0.0.0 + plane-admin: + container_name: plane_admin + image: makeplane/plane-admin:v0.23.1 + restart: unless-stopped + networks: [dev, edge] + command: node admin/server.js + env_file: + - stack.env + environment: + - NEXT_PUBLIC_API_BASE_URL=https://plane.ginnoir.com + - HOSTNAME=0.0.0.0 + depends_on: + plane-api: + condition: service_started + plane-web: + condition: service_started + plane-api: container_name: plane_api image: makeplane/plane-backend:latest diff --git a/stacks/dev/stack.env b/stacks/dev/stack.env index efec476..985eece 100644 --- a/stacks/dev/stack.env +++ b/stacks/dev/stack.env @@ -14,6 +14,9 @@ BOOKSTACK_DB_PASSWORD=bookstack_d3v_pass BOOKSTACK_DB_ROOT_PASSWORD=bookstack_r00t_pass BOOKSTACK_OIDC_CLIENT_ID=8YV4BI2JVIG1QzS2DbylEgqv56sgfiOVRXOgw2Z5 BOOKSTACK_OIDC_CLIENT_SECRET=wIyiw7LmGBORDyTdc2crSZryXJHzIq4gtajAKSpOU7e8BkBSO6SstM9gGPrnp4tbJhCFSQggYAgtYd5LOjUvQpSWInzm0JLlmLg1BTlPq5EVVieNYuWRCCQrerj8gRZ0 +# BookStack reads OIDC_* directly (env_file); BOOKSTACK_* kept for Portainer UI mirror if using ${} in compose. +OIDC_CLIENT_ID=8YV4BI2JVIG1QzS2DbylEgqv56sgfiOVRXOgw2Z5 +OIDC_CLIENT_SECRET=wIyiw7LmGBORDyTdc2crSZryXJHzIq4gtajAKSpOU7e8BkBSO6SstM9gGPrnp4tbJhCFSQggYAgtYd5LOjUvQpSWInzm0JLlmLg1BTlPq5EVVieNYuWRCCQrerj8gRZ0 # Plane project management # Rotate with: openssl rand -hex 32