diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index 7e24d00..7436c41 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -22,13 +22,16 @@ # 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 (Gitea, OpenWebUI, 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. ComfyUI left this exempt set on 2026-08-31 and Grafana left -# it on 2026-09-01 (OAuth2 relying-party mode against authentik): both now sit -# behind the guard chain; Grafana authenticates users itself via the OAuth2 -# code flow, so no identity header is forwarded to it. +# Apps with their own auth (Gitea, OpenWebUI, Jellyfin, Plex, Audiobookshelf, +# *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 chain is browser-only by nature — a +# native app client, a podcast RSS reader or a git client solves no JavaScript +# proof, so anything that must serve one has to own its own authentication. +# ComfyUI left this exempt set on 2026-08-31 and Grafana left it on 2026-09-01 +# (OAuth2 relying-party mode against authentik): both now sit behind the guard +# chain; Grafana authenticates users itself via the OAuth2 code flow, so no +# identity header is forwarded to it. # The pihole vhost keeps its basicauth edge; the hash placeholder below is # rendered by deploy.sh from CADDY_BASICAUTH_HASH (host .env, git-ignored) into # /home/dank/Containers/caddy/Caddyfile, which the container mounts. @@ -264,17 +267,26 @@ files.franksdank.cloud { } } -# --- Audiobookshelf (guarded) --- -# Anubis → Authentik → app, like romm/files. The container carries -# ROUTER_BASE_PATH=/audiobookshelf, so this vhost serves the app under that -# path; the bare root is redirected onto it in the post-auth listener, never -# pre-auth. Guard state in the IdP: proxy provider `audiobooks-proxy` -# (external_host https://audiobooks.franksdank.cloud/, internal_host -# http://caddy:8017, application "Audiobookshelf Guard"), claimed by the -# embedded outpost. +# --- Audiobookshelf (exempt by construction: own auth) -------------------- +# Caddy → app: no Anubis, no outpost. The guard chain answers a native client's +# /ping, /api/login and /socket.io/ handshake with challenge HTML, so behind it +# the phone app parsed a non-object where the socket `init` payload belongs and +# podcast RSS/OPDS feeds broke the same way. ABS authenticates itself (local +# accounts plus authentik OIDC), so it joins jelly/plex/*arr outside the chain +# (2026-11-03); the IdP rows that guarded this name — proxy provider +# `audiobooks-proxy` and application "Audiobookshelf Guard" — are retired with +# the change, after the route flips. Accepted exposure: /audiobookshelf/api/login +# is internet-visible with no rate limiter on either side (this Caddy build has +# no rate_limit module; ABS 2.37.2 ships no lockout) — the same class every other +# own-auth vhost here lives with, so shared accounts need long unique passwords. +# ROUTER_BASE_PATH=/audiobookshelf, so the bare root is a convenience hop onto it. +# X-Forwarded-Proto must say https: ABS derives its OIDC origin from +# x-forwarded-proto://Host and this hop is plain http, so without the pin its own +# https callback is a 400 "Invalid callback URL - must be same-origin". audiobooks.franksdank.cloud { - reverse_proxy anubis:8923 { - header_up X-Real-Ip {remote_host} + redir / /audiobookshelf/ permanent + reverse_proxy 192.168.0.235:8000 { + header_up X-Forwarded-Proto https } } @@ -424,24 +436,6 @@ http://:8017 { reverse_proxy 192.168.0.235:3923 } - @audiobooks host audiobooks.franksdank.cloud - handle @audiobooks { - # The app answers only under its ROUTER_BASE_PATH; the bare root is a - # convenience hop, taken post-auth so nothing reaches the app - # pre-authentication. - redir / /audiobookshelf/ permanent - # Audiobookshelf validates every OIDC callback against the origin it - # believes it is served from — getRequestOrigin() is - # x-forwarded-proto://Host — and a mismatch is a bodiless 400 - # "Invalid callback URL - must be same-origin" before the IdP is ever - # contacted (measured 2026-11-03). This listener is fed over plain http - # by the outpost, so Caddy's own X-Forwarded-Proto here says http; the - # edge for this name is TLS-only, so https is the truthful value. - reverse_proxy 192.168.0.235:8000 { - header_up X-Forwarded-Proto https - } - } - @prowlarr host prowlarr.franksdank.cloud handle @prowlarr { reverse_proxy 192.168.0.112:9696 diff --git a/tests/anubis.bats b/tests/anubis.bats index 7589fbb..cd104fd 100755 --- a/tests/anubis.bats +++ b/tests/anubis.bats @@ -75,8 +75,10 @@ PY } @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. + # The IdP login flow, git smart-HTTP clients, and any non-browser client (the + # Audiobookshelf phone app, podcast RSS readers) cannot solve a challenge, so + # services that enforce their own complete auth bypass the chain by + # construction — the gate would answer their API calls with HTML. local block block=$(awk '/^auth\.franksdank\.cloud/,/^}/' "$CADDY") echo "$block" | grep -q 'reverse_proxy authentik-server:9000' @@ -84,4 +86,11 @@ PY block=$(awk '/^plex\.franksdank\.cloud/,/^}/' "$CADDY") echo "$block" | grep -q '192\.168\.0\.235:32400' ! echo "$block" | grep -q anubis + block=$(awk '/^audiobooks\.franksdank\.cloud/,/^}/' "$CADDY") + echo "$block" | grep -q '192\.168\.0\.235:8000' + ! echo "$block" | grep -q anubis + # The exemption is total: the post-auth listener must not still carry an + # audiobooks route. A leftover handle there is a second owner for one host, + # and its X-Forwarded-Proto pin would silently disagree with the edge. + ! grep -q '@audiobooks host audiobooks.franksdank.cloud' "$CADDY" } diff --git a/tests/e2e/specs/14-guard.spec.ts b/tests/e2e/specs/14-guard.spec.ts index 7e322c6..fdd2584 100644 --- a/tests/e2e/specs/14-guard.spec.ts +++ b/tests/e2e/specs/14-guard.spec.ts @@ -6,21 +6,21 @@ import { expect, test } from "@playwright/test"; // navigation would skip. These tests drive the browser as an UNAUTHENTICATED // user (the guest project has no storage state) and navigate directly to each // guarded vhost's root path: the guard must present authentik's flow UI in -// place and must never leak app content. plex is the documented exemption — -// PMS enforces its own auth — so it must be reachable unauthenticated with -// zero challenge pages, proving the exemption is deliberate, not accidental. +// place and must never leak app content. plex and audiobooks are the documented +// exemptions — PMS and Audiobookshelf enforce their own auth — so they must be +// reachable unauthenticated with zero challenge pages, proving the exemption is +// deliberate, not accidental. const BASE = "https://www.franksdank.cloud"; // Every vhost that Caddy routes through anubis:8923 (see dankclaw/Caddyfile). // Keep this list in lockstep with the Caddyfile: a new guarded vhost must be // added here or the guard-chain audit is incomplete. -const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki", "notebook", "audiobooks"]; +const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki", "notebook"]; // Markers that prove app content leaked past the guard to an anonymous user. Each -// marker must be a string the APP renders that the gate itself could never print — -// for audiobooks that is the Audiobookshelf login's own SSO button label (the app's -// door), which authentik's gate never renders because it IS the IdP. +// marker must be a string the APP renders that the gate itself could never print: +// authentik's gate never renders an app's UI text, because it IS the IdP. const LEAK_MARKERS: Record = { "search.franksdank.cloud": ["SearXNG preferences"], "romm.franksdank.cloud": ["RomM"], @@ -29,7 +29,6 @@ const LEAK_MARKERS: Record = { "grafana.franksdank.cloud": ["Dashboards"], "prometheus.franksdank.cloud": ["Expression Browser", "Graph"], "notebook.franksdank.cloud": ["Open Notebook"], - "audiobooks.franksdank.cloud": ["Sign in with Authentik"], }; test("homepage root (/) is guarded for unauthenticated users", async ({ page }) => { @@ -102,3 +101,54 @@ test("plex is exempt from the guard chain and serves PMS content unauthenticated const resp = await page.request.get("https://plex.franksdank.cloud/"); expect(resp.status(), "PMS must answer directly, not error").toBeLessThan(500); }); + +// Audiobookshelf is exempt by construction (2026-11-03). The phone app is a +// non-browser client and solves no JS proof-of-work, so behind the guard chain it +// was answered with challenge HTML wherever the socket `init` payload belongs — +// every remote sign-in died as a TypeError on a non-object, and the container's +// request log never saw a byte of it. ABS enforces its own auth (local accounts +// plus authentik OIDC), exactly like PMS above. Asserted from the CLIENT side: the +// endpoints an app must call before it holds any credential are answered by the +// app, and the app's own 401 — not a 200 of HTML — is what an anonymous API call gets. +test("audiobookshelf answers an unauthenticated app client with app bytes, not gate bytes", async ({ + request, +}) => { + const ABS = "https://audiobooks.franksdank.cloud"; + + // The probe every Audiobookshelf client sends first. The app answers it at the + // ROOT whatever ROUTER_BASE_PATH says, so this is the vhost's own behaviour. + const ping = await request.get(`${ABS}/ping`, { timeout: 30_000 }); + expect(ping.status(), "/ping must be answered by the app").toBe(200); + expect(await ping.json(), "/ping must carry the app's own payload").toEqual({ success: true }); + + // The socket.io handshake. Engine.IO answers this in a machine format; the gate + // answered it in HTML, which is the defect this exemption exists to kill. + const sock = await request.get(`${ABS}/audiobookshelf/socket.io/?EIO=4&transport=polling`, { + timeout: 30_000, + }); + expect( + await sock.text(), + "the socket handshake was answered by the gate, not Engine.IO", + ).not.toContain("Making sure you're not a bot"); + expect( + sock.headers()["content-type"] ?? "", + "the socket handshake must not arrive as HTML", + ).not.toMatch(/text\/html/); + + // An API call with no credential: the app refuses it. A gate would answer 200 + // with HTML, so the status code alone distinguishes the two owners. + const api = await request.get(`${ABS}/audiobookshelf/api/libraries`, { timeout: 30_000 }); + expect(api.status(), "the app's own auth gate must own unauthenticated API calls").toBe(401); +}); + +test("audiobookshelf login renders the app's own sign-in to an anonymous browser", async ({ page }) => { + test.setTimeout(120_000); + await page.goto("https://audiobooks.franksdank.cloud/audiobookshelf/login"); + expect(new URL(page.url()).hostname).toBe("audiobooks.franksdank.cloud"); + + const body = await page.locator("body").innerText(); + expect(body, "the bot challenge still fronts this vhost").not.toContain("Making sure you're not a bot"); + expect(await page.locator('input[name="uidField"]').count(), "the IdP gate form must not front the app").toBe(0); + // The app answers, and its login screen carries SSO beside the local form. + await expect(page.getByText("Sign in with Authentik")).toBeVisible({ timeout: 90_000 }); +});