From ef07e7d01a81f9f146f0638eb84b8037846a0cb7 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 28 Aug 2026 22:28:32 -0700 Subject: [PATCH] fix(caddy,authentik): auth guarded vhosts by proxying the whole request through authentik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The forward_auth target /outpost.goauthentik.io/auth/caddy does not exist in authentik 2026.8 (verified live: 404 for every header combination; the URL tree has no outpost auth prefix at all — the outpost worker dials back to core over ws/outpost// instead). Unauthenticated subrequests got 404, Caddy treated >=400 as 'not authenticated' and redirected browsers to the dead path — users saw authentik's own Not-Found page. Fix: guarded vhosts now proxy the whole request through authentik-server:9000 with X-authentik-meta-outpost/-provider/-app headers (the exact header set this build's Traefik controller emits, adapted for Caddy). Unauthenticated clients get the login flow served in place; once a session exists, authentik proxies on to the provider's internal_host — now http://caddy:8017, a new post-auth internal listener that routes straight to the app. The chain terminates; nothing dials back into authentik. Live DB changes applied alongside (postgres is the source of truth): provider pk=1 authentication_flow=default-authentication-flow (was NULL — the actual cause of the 404s) and internal_host=http://caddy:8017 (was self-referential http://authentik-server:9000). --- dankclaw/Caddyfile | 164 ++++++++++-------- .../Infrastructure/Authentik.md | 95 +++++----- tests/authentik.bats | 20 +-- tests/caddyfile.bats | 7 +- tests/homepage_caddy.bats | 8 +- 5 files changed, 153 insertions(+), 141 deletions(-) diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index fa2d90c..b6db27e 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -11,12 +11,17 @@ # (dankclaw/pihole/addn-hosts — every name resolves to 192.168.0.143) # and handed to LAN clients by the SURFboard G36 router. # -# 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. +# Authentication chain (2026-08-29): the guarded vhosts (homepage, SearXNG, +# Hermes, Prometheus, RomM, Copyparty) proxy EVERYTHING through the Anubis +# bot-challenge proxy first, then the whole request is proxied THROUGH +# Authentik (2026.8 proxy-provider mode: this build serves no forward_auth +# endpoint; the authentication flow runs inline — unauthenticated clients get +# the login UI served in place, and once a session exists Authentik proxies +# the request on to the app via the provider's internal_host). Chain: client → +# Caddy (TLS, public listener) → Anubis :8923 (verify: DENY known AI scrapers, +# CHALLENGE everyone else) → Caddy's internal listener http://:8016 (auth +# decision; Host headers survive every hop) → authenticated traffic is +# forwarded back to http://:8017 and routed straight to the 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: @@ -224,104 +229,115 @@ prowlarr.franksdank.cloud { qbittorrent.franksdank.cloud { reverse_proxy 192.168.0.235:8080 } - -# --- Internal listener: where Anubis forwards accepted traffic --- +# --- Internal listener: auth decision layer ------------------------------- # 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. +# dankclaw/anubis/quadlet/anubis.container). Host headers survive the hop, so +# per-vhost routing resumes here: guarded vhosts proxy the WHOLE request +# through Authentik (the meta headers tell Authentik which outpost, provider +# and app are answering); unauthenticated clients get the login flow served +# in place, authenticated traffic is forwarded back to http://:8017 and +# routed straight to the app. Exempt vhosts never enter this block. 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 - } + reverse_proxy authentik-server:9000 { + header_up X-authentik-meta-outpost 7e39655b-58d3-4fe6-a80a-513f31051217 + header_up X-authentik-meta-provider 1 + header_up X-authentik-meta-app name=caddy } } @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 authentik-server:9000 { + header_up X-authentik-meta-outpost 7e39655b-58d3-4fe6-a80a-513f31051217 + header_up X-authentik-meta-provider 1 + header_up X-authentik-meta-app name=caddy } + } + + @hermes host hermes.franksdank.cloud + handle @hermes { + reverse_proxy authentik-server:9000 { + header_up X-authentik-meta-outpost 7e39655b-58d3-4fe6-a80a-513f31051217 + header_up X-authentik-meta-provider 1 + header_up X-authentik-meta-app name=caddy + } + } + + @prometheus host prometheus.franksdank.cloud + handle @prometheus { + reverse_proxy authentik-server:9000 { + header_up X-authentik-meta-outpost 7e39655b-58d3-4fe6-a80a-513f31051217 + header_up X-authentik-meta-provider 1 + header_up X-authentik-meta-app name=caddy + } + } + + @romm host romm.franksdank.cloud + handle @romm { + reverse_proxy authentik-server:9000 { + header_up X-authentik-meta-outpost 7e39655b-58d3-4fe6-a80a-513f31051217 + header_up X-authentik-meta-provider 1 + header_up X-authentik-meta-app name=caddy + } + } + + @files host files.franksdank.cloud + handle @files { + reverse_proxy authentik-server:9000 { + header_up X-authentik-meta-outpost 7e39655b-58d3-4fe6-a80a-513f31051217 + header_up X-authentik-meta-provider 1 + header_up X-authentik-meta-app name=caddy + } + } +} + +# --- Post-auth listener: where Authentik forwards authenticated traffic ---- +# The proxy provider's internal_host is http://caddy:8017, so every request +# that passes authentication lands here (Host header preserved) and routes +# straight to its app. This is the end of the chain — nothing dials back +# into Authentik from here, so the chain cannot loop. +http://:8017 { + @home host franksdank.cloud www.franksdank.cloud + handle @home { + root * /srv/homepage + handle /admin* { + try_files /admin.html + file_server + } + @data path /services.json /status.json /app.js /style.css /deployed.js + handle @data { + file_server + } + handle { + try_files /index.html + file_server + } + } + + @search host search.franksdank.cloud + handle @search { 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 } } diff --git a/serverAppConfigs-docs/Infrastructure/Authentik.md b/serverAppConfigs-docs/Infrastructure/Authentik.md index fd366c0..8f95033 100644 --- a/serverAppConfigs-docs/Infrastructure/Authentik.md +++ b/serverAppConfigs-docs/Infrastructure/Authentik.md @@ -24,41 +24,27 @@ so the stack is 3 services, not 4. ```mermaid graph LR - subgraph Browser - U[User] - end - subgraph Caddy [Caddy - auth gate] - FA[forward_auth] - end - subgraph AK [Authentik] - SRV[server :9000] - WKR[worker] - PG[(postgres)] - OUT[outpost /auth/caddy] - end - subgraph App [Target app] - A1[SearXNG / Hermes / Prometheus
RomM / Copyparty / homepage] - end - - U -->|1. GET /| FA - FA -->|2. GET /outpost.../auth/caddy| OUT - OUT -->|3. no session: 302 login| U - U -->|4. login at auth.franksdank.cloud| SRV - SRV --> PG - WKR --> PG - SRV -->|5. 2xx: session valid| FA - FA -->|6. proxy with headers| A1 + U[Browser] -->|1. GET /search?q=x| A[Anubis :8923
bot challenge] + A -->|2. verified, dials internal listener| C[Caddy :8016
auth decision] + C -->|3. whole request + meta headers| S[authentik-server :9000
proxy provider 1] + S -->|4. no session: login flow served in place| U + S -->|5. session valid: proxies on to internal_host| P[Caddy :8017
post-auth routing] + P -->|6. app response back up the chain| U + S --> PG[(postgres)] ``` -1. The browser hits a protected vhost (e.g. `search.franksdank.cloud`). -2. Caddy `forward_auth` clones the request to the Authentik outpost - (`/outpost.goauthentik.io/auth/caddy`). -3. With no valid session, the outpost returns a `302` to the Authentik login - page (`auth.franksdank.cloud`). -4. The user logs in at Authentik (email + password, TOTP/WebAuthn MFA, or an - upstream provider). -5. On the next request the outpost returns `2xx`; Caddy copies the - `X-Authentik-*` headers and proxies to the app. +In authentik 2026.8 the proxy provider is an OAuth2 provider and the old +`/outpost.goauthentik.io/auth/caddy` forward-auth endpoint no longer exists — +every request that reaches it returns 404 (verified live, 2026-08-29). The +chain therefore does not forward-auth *to* a path: guarded vhosts proxy the +**whole request through authentik** with `X-authentik-meta-outpost`, +`X-authentik-meta-provider` and `X-authentik-meta-app` headers. The provider's +`authentication_flow` (`default-authentication-flow`) decides: no session → +the login flow is served in place; session valid → the policy passes and +authentik proxies the request on to the provider's `internal_host` +(`http://caddy:8017`, Caddy's post-auth internal listener), which routes it +straight to the app. The chain terminates — nothing dials back into +Authentik from `:8017`. ## The three compose services @@ -79,30 +65,39 @@ graph LR ## Caddy integration -The Caddyfile wraps the no-auth vhosts in a `route` block. The pattern (one -example; the others are identical): +Guarded vhosts proxy the **whole request through Authentik** on Caddy's +internal listener — there is no `forward_auth` anywhere: in 2026.8 the auth +endpoint the old config pointed at (`/outpost.goauthentik.io/auth/caddy`) +does not exist in the URL tree, so every unauthenticated subrequest got a +404 and browsers were served authentik's own Not-Found page (verified live, +2026-08-29). Pattern (one example; the other guarded vhosts are identical): ```caddyfile -search.franksdank.cloud { - route { - # 1. The outpost path is proxied to Authentik (login UI + session). - reverse_proxy /outpost.goauthentik.io/* authentik-server:9000 - # 2. forward_auth checks the session before serving the app. - 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 +http://:8016 { + @search host search.franksdank.cloud + handle @search { + # Unauthenticated: the login flow is served in place. + # Authenticated: Authentik proxies on to internal_host (:8017). + reverse_proxy authentik-server:9000 { + header_up X-authentik-meta-outpost 7e39655b-58d3-4fe6-a80a-513f31051217 + header_up X-authentik-meta-provider 1 + header_up X-authentik-meta-app name=caddy } - # 3. The app. - reverse_proxy 192.168.0.143:8081 } } ``` -Vhosts protected by forward_auth: the homepage (`franksdank.cloud`, -`www.franksdank.cloud`), SearXNG, Hermes, Prometheus, RomM, Copyparty. The -`auth.franksdank.cloud` vhost proxies to the server directly (no forward_auth — -it *is* the login). +The meta headers tell authentik which outpost, provider and application are +answering (the values come from the DB: outpost pk = the UUID embedded in +`AUTHENTIK_OUTPOST_TOKEN`, provider pk 1 = `embedded-outpost`, app slug +`caddy`). The provider's `internal_host` is `http://caddy:8017`, so +authenticated traffic is handed back to Caddy's post-auth listener, which +routes it straight to the app — the chain terminates there. + +Guarded vhosts: the homepage (`franksdank.cloud`, `www.franksdank.cloud`), +SearXNG, Hermes, Prometheus, RomM, Copyparty. The `auth.franksdank.cloud` +vhost proxies to the server directly — it *is* the login, and it must never +sit behind a challenge. ## Configuration (secrets) diff --git a/tests/authentik.bats b/tests/authentik.bats index e72e97a..49763ee 100644 --- a/tests/authentik.bats +++ b/tests/authentik.bats @@ -95,22 +95,20 @@ UNIT_DIR="$REPO_ROOT/dankclaw/authentik/quadlet" echo "$block" | grep -q 'reverse_proxy authentik-server:9000' } -# --- Caddyfile: forward_auth on vhosts without their own auth --- +# --- Caddyfile: guarded vhosts proxy the whole request through Authentik ---- -@test "the Caddyfile uses forward_auth" { - grep -q 'forward_auth' "$CADDYFILE" +@test "guarded vhosts proxy the whole request through authentik on the internal listener" { + awk '/^http:\/\/:8016/,/^\}/' "$CADDYFILE" | grep -q 'reverse_proxy authentik-server:9000' } -@test "forward_auth targets the authentik outpost caddy endpoint" { - grep -q '/outpost.goauthentik.io/auth/caddy' "$CADDYFILE" +@test "the auth decision carries the outpost meta headers" { + grep -q 'X-authentik-meta-outpost' "$CADDYFILE" + grep -q 'X-authentik-meta-provider' "$CADDYFILE" + grep -q 'X-authentik-meta-app' "$CADDYFILE" } -@test "forward_auth copies the X-Authentik-Username header" { - grep -q 'X-Authentik-Username' "$CADDYFILE" -} - -@test "the outpost path is proxied to authentik in forward_auth vhosts" { - grep -q '/outpost.goauthentik.io/\*' "$CADDYFILE" +@test "authenticated traffic returns to Caddy on the post-auth listener" { + grep -qE '^http://:8017 \{' "$CADDYFILE" } # --- Homepage: moved to naked domain + www --- diff --git a/tests/caddyfile.bats b/tests/caddyfile.bats index 464a6fb..8498972 100644 --- a/tests/caddyfile.bats +++ b/tests/caddyfile.bats @@ -125,10 +125,11 @@ CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile" # 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 # 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. + # listeners (http://:8016, http://:8017) are not client-facing — Anubis + # and Authentik dial them over container DNS after verifying a client, + # so plain HTTP is intended. local offenders - offenders=$(grep -E '^http://' "$CADDYFILE" | grep -v 'plex\.franksdank\.casa' | grep -v '^http://:8016' || true) + offenders=$(grep -E '^http://' "$CADDYFILE" | grep -v 'plex\.franksdank\.casa' | grep -vE '^http://:801[67]' || true) [ -z "$offenders" ] || { echo "non-.casa http:// vhosts: $offenders"; return 1; } } diff --git a/tests/homepage_caddy.bats b/tests/homepage_caddy.bats index c02d8e7..a848a01 100644 --- a/tests/homepage_caddy.bats +++ b/tests/homepage_caddy.bats @@ -37,9 +37,11 @@ setup() { local block block=$(awk '/^franksdank\.cloud[[:space:]]/,/^}/' "$CADDY") echo "$block" | grep -q 'reverse_proxy anubis:8923' - # The SSO check itself now runs on the internal listener that Anubis + # The SSO check itself 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. + # routing resumes there — guarded vhosts proxy the whole request + # through Authentik, which forwards authenticated traffic back on the + # post-auth listener (http://:8017) that routes straight to the app. grep -q 'http://:8016' "$CADDY" - awk '/^http:\/\/:8016/,/^\}/' "$CADDY" | grep -q 'forward_auth authentik-server:9000' + awk '/^http:\/\/:8016/,/^\}/' "$CADDY" | grep -q 'reverse_proxy authentik-server:9000' }