diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index e5a97ac..fa2d90c 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -11,13 +11,19 @@ # (dankclaw/pihole/addn-hosts — every name resolves to 192.168.0.143) # and handed to LAN clients by the SURFboard G36 router. # -# Authentication: Caddy forward_auth (Authentik SSO) protects the vhosts that -# do not have their own application-level auth (homepage, SearXNG, Hermes, -# Prometheus, RomM, Copyparty). The basicauth hash is a placeholder; deploy.sh -# renders the real hash (CADDY_BASICAUTH_HASH from the host .env, git-ignored) -# into /home/dank/Containers/caddy/Caddyfile, which the container mounts. -# Apps with their own auth (Grafana, Gitea, OpenWebUI, Jellyfin, *arr, Pi-hole) -# are not wrapped in forward_auth — they handle their own login. +# Authentication chain (Anubis rollout, 2026-08-28): the guarded vhosts +# (homepage, SearXNG, Hermes, Prometheus, RomM, Copyparty) proxy EVERYTHING +# through the Anubis bot-challenge proxy first. Chain: client → Caddy (TLS, +# public listener) → Anubis :8923 (verify: DENY known AI scrapers, CHALLENGE +# everyone else) → Caddy's internal listener http://:8016 (Authentik +# forward_auth + app routing resumes; Host headers survive both hops) → app. +# Apps with their own auth (Grafana, Gitea, OpenWebUI, ComfyUI, Jellyfin, +# *arr, Pi-hole) and the Authentik IdP itself (auth.franksdank.cloud — its +# login flow must never sit behind a challenge) are exempt by construction: +# their vhosts never route through Anubis. The basicauth hash is a +# placeholder; deploy.sh renders the real hash (CADDY_BASICAUTH_HASH from the +# host .env, git-ignored) into /home/dank/Containers/caddy/Caddyfile, which +# the container mounts. # # TLS: real Let's Encrypt certs. Site addresses carry no http:// prefix, # so Caddy serves HTTPS (and auto-redirects HTTP). Certs are issued via @@ -28,7 +34,7 @@ { # Admin stays ON: it binds to 127.0.0.1:2019 inside the container and the # port is not published, so nothing outside the container can reach it. - # It powers the config file watcher, so a rendered Caddyfile change + # It powers the config file watcher, so a rendered-config change # hot-reloads without a container restart. 'admin off' once left a broken # basicauth hash sitting on disk unapplied for hours (2026-08-22). email admin@franksdank.cloud @@ -47,42 +53,10 @@ redir https://franksdank.cloud/ 302 } -# --- Homepage (Authentik SSO) --- +# --- Homepage (Anubis → Authentik SSO) --- franksdank.cloud www.franksdank.cloud { - root * /srv/homepage - - route { - # Authentik outpost (login UI + session management). - reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 - - # Forward auth: check the Authentik session before serving the page. - forward_auth authentik-server:9000 { - uri /outpost.goauthentik.io/auth/caddy - copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name - trusted_proxies private_ranges - } - - # Operator-only admin page. Auth is checked on the original path, - # before any rewrite, so the path matcher is reliable. 'handle' - # preserves order. - handle /admin* { - try_files /admin.html - file_server - } - - # Shared data + assets: fetched by both the user page and the admin - # page. With forward_auth, any authenticated user can access them - # (the browser's Authentik session covers these fetches). - @data path /services.json /status.json /app.js /style.css /deployed.js - handle @data { - file_server - } - - # Everything else (the user page). - handle { - try_files /index.html - file_server - } + reverse_proxy anubis:8923 { + header_up X-Real-Ip {remote_host} } } @@ -97,6 +71,8 @@ dankserver.franksdank.cloud { } # --- Authentik (SSO / OIDC provider) --- +# Exempt by construction: the IdP login flow must be reachable without a +# challenge, so this vhost never routes through Anubis. auth.franksdank.cloud { reverse_proxy authentik-server:9000 } @@ -107,14 +83,8 @@ auth.franksdank.cloud { # --- SearXNG (quadlet; behind the Gluetun tunnel, published 8081) --- search.franksdank.cloud { - route { - reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 - forward_auth authentik-server:9000 { - uri /outpost.goauthentik.io/auth/caddy - copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name - trusted_proxies private_ranges - } - reverse_proxy 192.168.0.143:8081 + reverse_proxy anubis:8923 { + header_up X-Real-Ip {remote_host} } } @@ -130,18 +100,16 @@ chat.franksdank.cloud { # --- Hermes --- hermes.franksdank.cloud { - route { - reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 - forward_auth authentik-server:9000 { - uri /outpost.goauthentik.io/auth/caddy - copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name - trusted_proxies private_ranges - } - reverse_proxy hermes:8090 + reverse_proxy anubis:8923 { + header_up X-Real-Ip {remote_host} } } # --- Gitea (quadlet; published 3000) --- +# Exempt by construction: Gitea has its own login, and git smart-HTTP +# clients (git push/fetch over HTTPS) cannot solve JS challenges. The +# policy would need a bypass for /info/refs + /git-*-pack paths if this +# ever moves behind Anubis. gitea.franksdank.cloud { reverse_proxy 192.168.0.143:3000 } @@ -156,14 +124,8 @@ grafana.franksdank.cloud { # --- Prometheus --- prometheus.franksdank.cloud { - route { - reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 - forward_auth authentik-server:9000 { - uri /outpost.goauthentik.io/auth/caddy - copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name - trusted_proxies private_ranges - } - reverse_proxy prometheus:9090 + reverse_proxy anubis:8923 { + header_up X-Real-Ip {remote_host} } } @@ -217,27 +179,15 @@ books.franksdank.cloud { # --- RomM --- romm.franksdank.cloud { - route { - reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 - forward_auth authentik-server:9000 { - uri /outpost.goauthentik.io/auth/caddy - copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name - trusted_proxies private_ranges - } - reverse_proxy 192.168.0.235:8085 + reverse_proxy anubis:8923 { + header_up X-Real-Ip {remote_host} } } # --- Copyparty --- files.franksdank.cloud { - route { - reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 - forward_auth authentik-server:9000 { - uri /outpost.goauthentik.io/auth/caddy - copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name - trusted_proxies private_ranges - } - reverse_proxy 192.168.0.235:3923 + reverse_proxy anubis:8923 { + header_up X-Real-Ip {remote_host} } } @@ -275,6 +225,107 @@ qbittorrent.franksdank.cloud { reverse_proxy 192.168.0.235:8080 } +# --- Internal listener: where Anubis forwards accepted traffic --- +# Anubis dials http://caddy:8016 after verifying a client (TARGET in +# dankclaw/anubis/quadlet/anubis.container). Host headers survive the hop, +# so per-vhost routing resumes here: outpost paths go straight to the +# Authentik outpost, everything else runs the forward_auth check and then +# dials the app. This block is the second half of every guarded vhost. +http://:8016 { + @home host franksdank.cloud www.franksdank.cloud + handle @home { + root * /srv/homepage + route { + # Authentik outpost (login UI + session management). + reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 + + # Forward auth: check the Authentik session before serving the page. + forward_auth authentik-server:9000 { + uri /outpost.goauthentik.io/auth/caddy + copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name + trusted_proxies private_ranges + } + + # Operator-only admin page. Auth is checked on the original path, + # before any rewrite, so the path matcher is reliable. 'handle' + # preserves order. + handle /admin* { + try_files /admin.html + file_server + } + + # Shared data + assets: fetched by both the user page and the admin + # page. With forward_auth, any authenticated user can access them + # (the browser's Authentik session covers these fetches). + @data path /services.json /status.json /app.js /style.css /deployed.js + handle @data { + file_server + } + + # Everything else (the user page). + handle { + try_files /index.html + file_server + } + } + } + + @search host search.franksdank.cloud + handle @search { + reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 + forward_auth authentik-server:9000 { + uri /outpost.goauthentik.io/auth/caddy + copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name + trusted_proxies private_ranges + } + reverse_proxy 192.168.0.143:8081 + } + + @hermes host hermes.franksdank.cloud + handle @hermes { + reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 + forward_auth authentik-server:9000 { + uri /outpost.goauthentik.io/auth/caddy + copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name + trusted_proxies private_ranges + } + reverse_proxy hermes:8090 + } + + @prometheus host prometheus.franksdank.cloud + handle @prometheus { + reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 + forward_auth authentik-server:9000 { + uri /outpost.goauthentik.io/auth/caddy + copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name + trusted_proxies private_ranges + } + reverse_proxy prometheus:9090 + } + + @romm host romm.franksdank.cloud + handle @romm { + reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 + forward_auth authentik-server:9000 { + uri /outpost.goauthentik.io/auth/caddy + copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name + trusted_proxies private_ranges + } + reverse_proxy 192.168.0.235:8085 + } + + @files host files.franksdank.cloud + handle @files { + reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 + forward_auth authentik-server:9000 { + uri /outpost.goauthentik.io/auth/caddy + copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name + trusted_proxies private_ranges + } + reverse_proxy 192.168.0.235:3923 + } +} + # --- Optional: Jackett --- # jackett.franksdank.cloud { # reverse_proxy 192.168.0.235:9117 diff --git a/dankclaw/anubis/policy.json b/dankclaw/anubis/policy.json new file mode 100644 index 0000000..9f1b930 --- /dev/null +++ b/dankclaw/anubis/policy.json @@ -0,0 +1,15 @@ +{ + "bots": [ + { + "name": "deny AI scrapers", + "user_agent_regex": "(?i)^(GPTBot|ChatGPT-User|OAI-SearchBot|CCBot|anthropic-ai|ClaudeBot|Claude-Web|PerplexityBot|PerplexityUser|Google-Extended|Applebot-Extended|Bytespider|Amazonbot|meta-externalagent|DuckAssistBot|cohere-ai|YouBot|KimiBot)", + "action": "DENY" + }, + { + "name": "challenge everything else", + "path_regex": ".", + "action": "CHALLENGE" + } + ], + "dnsbl": false +} diff --git a/dankclaw/anubis/quadlet/anubis.container b/dankclaw/anubis/quadlet/anubis.container new file mode 100644 index 0000000..32afc7f --- /dev/null +++ b/dankclaw/anubis/quadlet/anubis.container @@ -0,0 +1,50 @@ +# Quadlet unit: Anubis bot-challenge proxy (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/anubis.container +# Unit name: anubis.service (container name: anubis) +# +# Two-tier chain: the public Caddy listeners for the guarded vhosts proxy +# everything to this container (port 8923, internal only — no published port, +# so metrics on :9090 stay reachable only inside dankclaw_default); Anubis +# verifies the client (challenge/DENY per policy) and forwards accepted +# traffic back to Caddy's internal listener (TARGET below), where the normal +# per-vhost routing — including Authentik forward_auth — resumes. Host headers +# survive both hops, so vhost matching on the internal listener still works. +# +# Version pin: ghcr.io/TecharoHQ/anubis:v1.13.0 was source-verified before +# pinning (policy parser is JSON-only at this tag, flags are the config +# surface via flagenv: an env var named TARGET sets --target, POLICY_FNAME +# sets --policy-fname). Upgrading = stop unit, change Image=, deploy; policy +# schema must be re-verified against the new tag first. +# +# No secrets here: Anubis holds no credentials (memory store; challenge +# cookies are client-side JWTs). A restart invalidates issued challenges — +# users just re-challenge once. See serverAppConfigs-docs/Infrastructure/Anubis.md. + +[Unit] +Description=Anubis bot-challenge proxy container +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName=anubis +Image=ghcr.io/TecharoHQ/anubis:v1.13.0 +Pull=never +Network=dankclaw_default +# flagenv maps env names to flag names (TARGET -> --target, POLICY_FNAME -> +# --policy-fname). TARGET points back at Caddy's internal listener; the +# container DNS name resolves on dankclaw_default. +Environment=TARGET=http://caddy:8016 +Environment=POLICY_FNAME=/policy/policy.json +Volume=/home/dank/serverAppConfigs/dankclaw/anubis/policy.json:/policy/policy.json:ro +# No HealthCmd: the image ships an exec-form HEALTHCHECK +# (["/app/bin/anubis","--healthcheck"]) and quadlet inherits it when no +# HealthCmd is set — this dodges the v1 generator's space-splitting quirk +# that mangled quoted probes for the authentik units. + +[Service] +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 68d1c77..49ddf32 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -246,7 +246,7 @@ def services_for_path(path: str, target: str) -> set[str]: if len(parts) >= 2 and parts[1] == "Containerfile.comfyui": _add(services, "comfyui") return services - if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes"}: + if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes", "anubis"}: _add(services, parts[1]) return services if len(parts) >= 2 and parts[1] == "searxng": diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 95c3d2c..3407145 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -550,6 +550,22 @@ deploy_authentik() { systemctl --user start authentik-worker.service } +deploy_anubis() { + # Anubis is the bot-challenge proxy in front of the guarded vhosts. + # The image lives on ghcr.io; pull once, then the unit runs it from + # local storage (Pull=never keeps boots off the registry). + if ! podman image inspect ghcr.io/TecharoHQ/anubis:v1.13.0 >/dev/null 2>&1; then + podman pull ghcr.io/TecharoHQ/anubis:v1.13.0 + fi + cp "dankclaw/anubis/quadlet/anubis.container" "$quadlet_unit_dir/" + systemctl --user daemon-reload + # No enable: generated units cannot be enabled on this host (v1 quadlet + # generator); boot persistence comes from [Install] in the unit file. + # restart, not start: policy.json is parsed at process start only (no + # reload signal exists), so every redeploy must cycle the process. + systemctl --user restart anubis.service + wait_container_healthy anubis 60 || return 1 +} deploy_llm_large() { relink_unit frank-default-large @@ -604,6 +620,7 @@ deploy_service() { authentik-postgres|authentik-server|authentik-worker) deploy_authentik ;; + anubis) deploy_anubis ;; frank-default-large) deploy_llm_large ;; frank-default-small) deploy_llm_small ;; frank-default-npu) deploy_llm_npu ;; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index a724e74..2640f54 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -118,7 +118,7 @@ valid_service() { dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\ dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\ dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\ - dankclaw:authentik-worker|dankclaw:caddy|dankclaw:homepage|\ + dankclaw:authentik-worker|dankclaw:anubis|dankclaw:caddy|dankclaw:homepage|\ dankclaw:frank-default-large|dankclaw:frank-default-npu|\ dankserver:mariadb|dankserver:redis|dankserver:romm|dankserver:copyparty|\ dankserver:jellyfin|dankserver:calibre-web|dankserver:prowlarr|\ diff --git a/scripts/test-changed.sh b/scripts/test-changed.sh index f192800..b263720 100755 --- a/scripts/test-changed.sh +++ b/scripts/test-changed.sh @@ -120,6 +120,17 @@ for path in "${CHANGED[@]}"; do add_test tests/searxng_quadlet.bats add_test tests/deployment_scoping.bats ;; + # Anubis is a quadlet + Caddyfile pair: the chain (public listener -> + # anubis:8923 -> internal :8016 listener) spans both, so any change + # under the service dir must re-run the caddyfile seam too. This case + # precedes the dankclaw/*/quadlet/*.container fallback below, which + # would otherwise swallow it into unrelated seams. + dankclaw/anubis/*) + add_test tests/anubis.bats + add_test tests/caddyfile.bats + add_test tests/homepage_caddy.bats + add_test tests/deployment_scoping.bats + ;; # Quadlets live under dankclaw//quadlet/ (reorganized out of the # top-level dankclaw/*.container globs above). Map the whole subtree so # changes to the pihole, open-webui, searxng, and gitea quadlets hit diff --git a/tests/anubis.bats b/tests/anubis.bats new file mode 100755 index 0000000..eb187be --- /dev/null +++ b/tests/anubis.bats @@ -0,0 +1,81 @@ +#!/usr/bin/env bats +# Seam: the Anubis bot-challenge proxy (dankclaw/anubis). Anubis sits between +# Caddy's public listeners and the guarded vhosts: clients hit Caddy (TLS), +# Caddy proxies everything through Anubis, Anubis verifies (DENY known AI +# scrapers, CHALLENGE everyone else per policy.json) and forwards accepted +# traffic back to Caddy's internal listener at :8016, where per-vhost routing +# resumes. Static assertions only — the container runs on dankclaw. + +load helpers + +setup() { + UNIT="$REPO_ROOT/dankclaw/anubis/quadlet/anubis.container" + POLICY="$REPO_ROOT/dankclaw/anubis/policy.json" + CADDY="$REPO_ROOT/dankclaw/Caddyfile" +} + +@test "the anubis quadlet unit exists and is boot-persistent by [Install]" { + test -f "$UNIT" + grep -q 'WantedBy=default.target' "$UNIT" + # Generated units cannot be enabled on this host (v1 quadlet generator); + # boot persistence comes from [Install], not from systemctl enable. + ! grep -q '^Enable=true' "$UNIT" +} + +@test "the image is pinned to an exact upstream tag and never pulled at boot" { + grep -qE '^Image=ghcr\.io/TecharoHQ/anubis:v[0-9]+\.[0-9]+\.[0-9]+$' "$UNIT" + grep -q '^Pull=never$' "$UNIT" +} + +@test "anubis publishes no host port — it is internal-network-only" { + # Metrics (:9090) are unauthenticated; the protection strategy is that + # only dankclaw_default members can reach the container at all. Caddy + # reaches it by container DNS name, so no published port may exist. + ! grep -q '^PublishPort=' "$UNIT" +} + +@test "the unit points TARGET back at caddy's internal listener" { + grep -q '^Environment=TARGET=http://caddy:8016$' "$UNIT" + grep -q '^Environment=POLICY_FNAME=/policy/policy.json$' "$UNIT" + # The policy file is mounted read-only from the repo checkout on the host. + grep -q 'policy.json:/policy/policy.json:ro' "$UNIT" +} + +@test "the policy parses as JSON and matches v1.13.0 schema (bots array, action rules)" { + # At the pinned tag the parser is JSON-only with a minimal schema: + # {"bots":[{"name","user_agent_regex"|"path_regex","action"}],"dnsbl":bool} + # — imports/CEL/expression keys do not exist at this tag and would crash + # the container at parse time. Assert the shape statically via python. + python3 - "$POLICY" <<'PY' +import json, sys +with open(sys.argv[1]) as f: + p = json.load(f) +assert set(p.keys()) <= {"bots", "dnsbl"}, p.keys() +actions = [b["action"] for b in p["bots"]] +assert any(a == "DENY" for a in actions), actions +assert actions[-1] == "CHALLENGE", actions +for b in p["bots"]: + keys = set(b) ^ {"name", "action"} + assert len(keys) == 1, f"{b.get('name')}: exactly one matcher required, got {keys}" + assert (keys ^ {"user_agent_regex"}) != (keys ^ {"path_regex"}), b +PY +} + +@test "guarded vhosts proxy through anubis with the real client IP forwarded" { + # Anubis reads X-Real-Ip for its decisions; without the header every + # request looks like it comes from the Caddy container. Every guarded + # public listener must set it. + grep -q 'header_up X-Real-Ip {remote_host}' "$CADDY" +} + +@test "exempt vhosts never route through anubis (auth, plex, own-auth apps)" { + # The IdP login flow and git smart-HTTP clients cannot solve challenges; + # these vhosts bypass the chain by construction. + local block + block=$(awk '/^auth\.franksdank\.cloud/,/^}/' "$CADDY") + echo "$block" | grep -q 'reverse_proxy authentik-server:9000' + ! echo "$block" | grep -q anubis + block=$(awk '/^plex\.franksdank\.cloud/,/^}/' "$CADDY") + echo "$block" | grep -q '192\.168\.0\.235:32400' + ! echo "$block" | grep -q anubis +} diff --git a/tests/caddyfile.bats b/tests/caddyfile.bats index 2e4741e..464a6fb 100644 --- a/tests/caddyfile.bats +++ b/tests/caddyfile.bats @@ -124,9 +124,11 @@ CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile" @test "all vhosts use TLS except the legacy .casa name" { # franksdank.casa is not in the same Porkbun account, so it cannot get # a DNS-01 Let's Encrypt cert; it stays explicitly http. Every other - # site address must carry no http:// prefix. + # client-facing site address must carry no http:// prefix. The internal + # listener (http://:8016) is not client-facing — Anubis dials it over + # container DNS after verifying a client, so plain HTTP is intended. local offenders - offenders=$(grep -E '^http://' "$CADDYFILE" | grep -v 'plex\.franksdank\.casa' || true) + offenders=$(grep -E '^http://' "$CADDYFILE" | grep -v 'plex\.franksdank\.casa' | grep -v '^http://:8016' || true) [ -z "$offenders" ] || { echo "non-.casa http:// vhosts: $offenders"; return 1; } } diff --git a/tests/homepage_caddy.bats b/tests/homepage_caddy.bats index 8f45fdb..c02d8e7 100644 --- a/tests/homepage_caddy.bats +++ b/tests/homepage_caddy.bats @@ -1,8 +1,9 @@ #!/usr/bin/env bats -# Seam: the homepage site block in dankclaw/Caddyfile. It serves /srv/homepage -# from the naked domain (franksdank.cloud) and www. Authentication is via -# Caddy forward_auth (Authentik SSO) — the basicauth hashes were retired in -# favor of the unified Authentik login. caddy is not on the dev box, so the +# Seam: the homepage site block in dankclaw/Caddyfile. Since the Anubis +# rollout the naked domain + www are guarded vhosts: the public listener +# proxies everything through Anubis (bot challenge), and the Authentik +# forward_auth for SSO runs on Caddy's internal listener (http://:8016) +# after Anubis has verified the client. caddy is not on the dev box, so the # tests assert the structure statically. load helpers @@ -26,13 +27,19 @@ setup() { @test "the shared data + assets are served to any authenticated user" { # The data (services.json, status.json) and assets are fetched by both the # user page and the admin page. With forward_auth, any authenticated user - # can access them (the browser's Authentik session covers the fetch). + # can access them (the browser's Authentik session covers these fetches). grep -qE "@data[[:space:]]+path[[:space:]]+/services\.json[[:space:]]+/status\.json" "$CADDY" grep -qE "handle[[:space:]]+@data" "$CADDY" } -@test "the homepage vhost uses forward_auth (Authentik SSO)" { +@test "the homepage vhost chains Anubis (public) before Authentik SSO (internal listener)" { + # Public listener: the naked domain proxies everything through Anubis. local block block=$(awk '/^franksdank\.cloud[[:space:]]/,/^}/' "$CADDY") - echo "$block" | grep -q 'forward_auth' + echo "$block" | grep -q 'reverse_proxy anubis:8923' + # The SSO check itself now runs on the internal listener that Anubis + # forwards accepted traffic to; Host survives the hop, so per-vhost + # routing (and with it forward_auth) resumes there. + grep -q 'http://:8016' "$CADDY" + awk '/^http:\/\/:8016/,/^\}/' "$CADDY" | grep -q 'forward_auth authentik-server:9000' }