diff --git a/AGENTS.md b/AGENTS.md index fad25dd..e789e4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -227,9 +227,18 @@ See `serverAppConfigs-docs/Architecture/Testing Policy.md`. ## External Exposure Policy (MANDATORY) -Every Caddy-proxied service is either behind the auth chain (Anubis :8923 → -Authentik embedded outpost) or explicitly internal-only — no public DNS name, -no vhost. New services default to internal-only until guarded. +Every Caddy-proxied service is in exactly one of three postures: behind the +auth chain (Anubis :8923 → Authentik embedded outpost), **exempt by +construction** because it authenticates itself (`auth.`, git smart-HTTP, Gitea, +OpenWebUI, Jellyfin, Plex, Audiobookshelf, the *arr stack), or internal-only — +no public DNS name, no vhost. New services default to internal-only until guarded. + +The guard chain is a **browser-only device**: Anubis demands a JavaScript +proof-of-work and the outpost works from a session cookie, so any non-browser +client — a native app, a podcast RSS reader, an OPDS client, `git`, a +service-to-service call — is answered with challenge HTML where it expected +JSON or a socket payload. A service that must serve such a client cannot sit +behind the chain; it has to own its authentication. When guarding a new vhost — provider first, then route: @@ -248,6 +257,10 @@ When guarding a new vhost — provider first, then route: Exempt categories (document the reason in the vhost comment): the IdP itself (`auth.`), git smart-HTTP endpoints, services with their own complete auth. +Unguarding a vhost is the mirror image of the procedure above — the route flips +first and the IdP rows go last — and `serverAppConfigs-docs/Infrastructure/Caddy.md` +*Unguard a vhost* owns the steps. + ## Deletion & Trash Policy (MANDATORY) Never `rm`. Move deletions into trash so they stay recoverable: 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/scripts/authentik-audiobookshelf-sso.sh b/scripts/authentik-audiobookshelf-sso.sh index 2f3df52..d7b73de 100755 --- a/scripts/authentik-audiobookshelf-sso.sh +++ b/scripts/authentik-audiobookshelf-sso.sh @@ -11,15 +11,17 @@ # dist-server/server/auth/OidcAuthStrategy.js + objects/settings/ServerSettings.js) # rather than a forum, because each of these is a dead-login trap: # -# * The browser callback URI /auth/openid/callback for EVERY origin ABS is -# reached at, plus /auth/openid/mobile-redirect on ABS_BASE only. ABS SWAPS -# the app's own audiobookshelf://oauth for the mobile-redirect URL before the IdP -# ever sees a redirect_uri, so registering the app scheme with authentik — the -# obvious move — is what makes phone logins fail. The app scheme belongs in ABS's -# authOpenIDMobileRedirectURIs list (its built-in default), not here. The mobile -# URI is registered on the LAN origin only because the phone is configured with -# the LAN address: it is a non-browser client and cannot solve the guard -# challenge that sits in front of the vhost. +# * Two URIs per origin ABS is reached at — /auth/openid/callback and +# /auth/openid/mobile-redirect — for EVERY entry in BASES (the LAN address +# and the public vhost). ABS SWAPS the app's own audiobookshelf://oauth for the +# mobile-redirect URL before the IdP ever sees a redirect_uri, so registering the +# app scheme with authentik — the obvious move — is what makes phone logins fail. +# The app scheme belongs in ABS's authOpenIDMobileRedirectURIs list (its built-in +# default), not here. The mobile URI exists on every origin because the app sends +# the one belonging to the door it was configured with: the vhost started serving +# non-browser clients on 2026-11-03 (it left the guard chain), so a phone pointed +# at the public name sends the public mobile-redirect URI, and STRICT matching +# answers an unregistered one with "Redirect URI Error". # * CARRIES THE BASE PATH. ABS builds redirect_uri as # /auth/openid/callback, and that subfolder is # the ROUTER_BASE_PATH the container is mounted at — /audiobookshelf, which is also the only @@ -157,12 +159,12 @@ _ns = {} exec(_src, _ns) # --- provider -------------------------------------------------------------------- -# One browser callback per origin (LAN address and the guarded vhost), because ABS -# derives redirect_uri from the request origin; the mobile URI stays on the LAN origin. -uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, - url="%s/auth/openid/callback" % b) for b in BASES] -uris.append(RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, - url="%s/auth/openid/mobile-redirect" % BASE)) +# One callback pair per origin (LAN address and the public vhost): ABS derives the +# browser callback from the request origin and the mobile callback from the door the +# app was configured with, so each origin needs both or that door cannot sign in. +uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, url="%s%s" % (b, p)) + for b in BASES + for p in ("/auth/openid/callback", "/auth/openid/mobile-redirect")] authz_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent") # Three things beyond the name, each one a silent dead login: the grant-type list (2026.8 diff --git a/serverAppConfigs-docs/Dankserver/Audiobookshelf.md b/serverAppConfigs-docs/Dankserver/Audiobookshelf.md index 6f67bea..d276d6e 100644 --- a/serverAppConfigs-docs/Dankserver/Audiobookshelf.md +++ b/serverAppConfigs-docs/Dankserver/Audiobookshelf.md @@ -1,6 +1,6 @@ --- type: reference -description: "Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, guarded vhost, laws." +description: "Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, public vhost, laws." tags: - dankserver - audiobooks @@ -17,7 +17,7 @@ server — it shares nothing with [[Plex]] or [[Jellyfin]] and owns no files on array. **Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user manager) — -**Doors:** LAN `http://192.168.0.235:8000/audiobookshelf` (direct, what the phone app uses) and guarded `https://audiobooks.franksdank.cloud/audiobookshelf/`. +**Doors:** LAN `http://192.168.0.235:8000/audiobookshelf` (plain HTTP, nothing in front) and public `https://audiobooks.franksdank.cloud/audiobookshelf/` — Caddy straight to the container, **exempt from the guard chain** because the app must answer non-browser clients ([[Caddy]] *Unguard a vhost*). The public name is the address every client carries: Pi-hole answers it with `192.168.0.143` on the LAN, Porkbun answers it with the WAN address outside, and Caddy serves a real certificate either way. ## Shape @@ -69,14 +69,20 @@ the file, not the bind: the same line count grows while the total climbs. ## Phone app The official **Audiobookshelf** app connects with Server Address -`http://192.168.0.235:8000/audiobookshelf` — the base path belongs in the address — username -`frank`, and the same password as the web UI, or the SSO button; plain HTTP. Measured from a -second host 2026-11-02 and re-measured on both base paths: `/ping` (`{"success":true}`) and -`/healthcheck` (`OK`) answer at the ROOT whatever `ROUTER_BASE_PATH` says, so the app's -connectivity probe works before it resolves the base path at all, and `iptables -S INPUT` -carries no rule for 8000 — a phone on `192.168.0.0/24` connects with no further setup, and -`download: true` on the root account is what makes offline listening work. Away -from the LAN, see the two-doors law in *Laws* — the guarded vhost is not the remedy. +`https://audiobooks.franksdank.cloud/audiobookshelf` — the base path belongs in the address — +username `frank`, and the same password as the web UI, or the SSO button. One address works +at home and away: on the LAN Pi-hole answers that name with `192.168.0.143` and Caddy dials +the container; off-LAN the same name resolves to the WAN address and takes the same route +(inbound 443 is forwarded at the router — fetched from a datacenter node 2026-11-03). The raw +LAN address still answers and is the break-glass door whenever Caddy is down. `/ping` +(`{"success":true}`) and `/healthcheck` (`OK`) answer at the ROOT whatever `ROUTER_BASE_PATH` +says, so the app's connectivity probe works before it resolves the base path at all, and +`download: true` on the account is what makes offline listening work. + +**A home device that bypasses Pi-hole cannot reach it.** The G36 does not hairpin: resolving +the name to the WAN address from inside the LAN times out (measured 2026-11-03). Only a +client pinned to an external resolver hits this — everything using the router's DNS gets the +LAN answer. **The login token is at `user.token`, not top level.** `POST /login` answers `{ user, serverSettings, ereaderDevices, Source, userDefaultLibraryId }` and the bearer sits @@ -84,6 +90,23 @@ inside the user object. A client that reads `response.token` gets the empty stri `Authorization: Bearer ` with nothing after it, and every `/api/*` call then answers `401` as though the password were wrong. +## Listeners (accounts) + +The door is public now, so the account is the only gate between a listener and the library. +Two ways in, both per listener: + +- **Local account** — **Settings → Users → Add User**: type `user`, tick only the libraries + they may hear, and leave `download` off unless they need offline listening. Hand them the + server address above and their own password. +- **Household SSO** — create the authentik account, add it to `audiobookshelf_users`, and the + first **Sign in with Authentik** auto-registers the ABS account (`authOpenIDAutoRegister`); + a signer in neither group is denied on purpose, after a successful authentication. + +Nothing rate-limits `/audiobookshelf/api/login` — this Caddy build carries no `rate_limit` +module and ABS 2.37.2 ships no lockout — so the form is a guessing target exactly like +`jelly.`, `plex.` and the *arr logins already are. The mitigation is credential hygiene: +long unique passwords per listener, never a reuse of the root password. + ## Single sign-on (authentik) Both doors take the household identity: the web UI puts **Sign in with Authentik** beside @@ -98,7 +121,8 @@ the local form, and the phone app offers SSO on its server-address screen. The l | The ABS half: issuer, the four IdP URLs, claim, auto-register | `scripts/audiobookshelf-init.sh`, armed automatically when that secret exists in the credential file | | Role at first SSO sign-in | the `abs_groups` claim: `audiobookshelf_admins` → `admin`, `audiobookshelf_users` → `user`, in neither → denied (fail closed) | | Admin group members | `akadmin` (the operator's own authentik admin account — sign in as it and the claim grants site admin) and `qa-e2e-admin` (the identity the deploy gate signs in as, so the script re-adds it every run; drop it and the gate fails, not a phone) | -| The guard in front of the vhost | proxy provider `audiobooks-proxy` (external_host `https://audiobooks.franksdank.cloud/`, internal_host `http://caddy:8017`) + application "Audiobookshelf Guard" (its own name tracks the app, not the vhost), claimed by the embedded outpost — the ak-shell recipe in [[Caddy]] *Guard a new vhost* creates it; without that row the chain answers authentik's error page, not the login | +| Guard state of the door | none since 2026-11-03: proxy provider `audiobooks-proxy` and application "Audiobookshelf Guard" were deleted and the embedded outpost un-claimed them, because that chain answers a native client with challenge HTML ([[Caddy]] *Unguard a vhost* for the order). The SSO relying party `audiobookshelf` above is a different object and stays | +| Redirect URIs on the RP | `/auth/openid/callback` **and** `/auth/openid/mobile-redirect` for **both** origins (LAN address and public name): the app sends the mobile URI belonging to the door it was configured with, and STRICT matching answers an unregistered one with "Redirect URI Error" | The IdP saying yes is not the same as being allowed in: with `authOpenIDAutoRegister` on, a first sign-in creates the account, and the claim alone decides the type. A user in @@ -195,13 +219,17 @@ Both halves — authentik's provider row and ABS's settings — are asserted end - **No `systemctl --user enable`.** Generated Quadlet units cannot be enabled on this host; `WantedBy=default.target` in the unit file is what carries the service across a reboot ([[Quadlet Lifecycle]]). -- **Two doors, one app.** `http://192.168.0.235:8000/audiobookshelf` is the raw - LAN address — plain HTTP, nothing in front, what the phone app is configured - with; `https://audiobooks.franksdank.cloud/audiobookshelf/` is the guarded vhost - (Caddy → Anubis → the Authentik outpost → Caddy `:8017` → this container), - the house chain for browser traffic — [[Caddy]] *Guard a new vhost*. That - chain serves browsers only: the phone app is a non-browser client and solves - no challenge, so remote listening wants a VPN into the LAN, not the vhost. +- **One door for every client, and it sits outside the guard chain on purpose.** + `https://audiobooks.franksdank.cloud/audiobookshelf` is what browsers and apps use; + `http://192.168.0.235:8000/audiobookshelf` is the raw LAN socket that same Caddy dials, + so it doubles as break-glass when the proxy is down. Behind the chain (until + 2026-11-03) the vhost answered the phone app's `/ping` and `/socket.io/` handshake with + Anubis' challenge HTML — Anubis demands a JavaScript proof and the outpost needs a + session cookie, and a native client solves neither — so the app evaluated + `'isInit' in e.data` on a non-object, reported "Failed to contact server", and the + container's request log never recorded the attempt. Podcast RSS and OPDS readers died + the same death. ABS's own auth is the gate, as it is for [[Plex]] and [[Jellyfin]] + ([[Caddy]] *Unguard a vhost*). - **There is no `/setup` route; the creation door is `POST /init`.** `/status` (no `/api` prefix) reports `isInit` = "a root user exists", `POST /init` with `{"newRoot":{"username","password"}}` creates it, `POST /login` returns the @@ -295,6 +323,13 @@ base path arrives as `text/html`, which boots nothing), and `/api/me` answers 20 `type: "admin"` — true only while grant types, the OpenID scope mappings and the group claim all hold together. +The public door is pinned by the guard audit `tests/e2e/specs/14-guard.spec.ts`, which now +carries an **exemption** assertion where it used to carry a guarded one: an anonymous raw +client must get the app's own `{"success":true}` on `/ping`, a machine-format socket +handshake, and the app's own `401` on `/audiobookshelf/api/libraries` — never challenge +HTML. `tests/anubis.bats` pins the same shape in the Caddyfile: the vhost never proxies to +`anubis:8923`, and the post-auth listener carries no `@audiobooks` route. + Five book folders hold media no scanner can read, so they stay out of the library: three 2024 Nebula nominees in the `_unsorted` box (two `.m4b` with no `moov` atom, one 36-file `.mp3` set that is not MPEG audio at all), `Anthony Heald/Bounty diff --git a/serverAppConfigs-docs/Infrastructure/Caddy.md b/serverAppConfigs-docs/Infrastructure/Caddy.md index c116a2e..297e3b8 100644 --- a/serverAppConfigs-docs/Infrastructure/Caddy.md +++ b/serverAppConfigs-docs/Infrastructure/Caddy.md @@ -90,7 +90,7 @@ pihole.franksdank.cloud { After rendering, the line reads `dankuser ` (one complete hash) — `tests/deployment_flow.bats:96-99` pins the `dankuser` name and the single-hash form, and `tests/caddyfile.bats:75` pins the bare `dankuser REPLACE_WITH_HASH` template. Do not put a `$2a$14$` prefix in the template: it would render a double-prefixed, invalid hash (2026-08-22 incident — every basicauth vhost returned 401 for every password; `tests/caddyfile.bats:67-72` forbids the prefix). `tests/deployment_flow.bats` pins the render contract. -Everything else authenticates, not basicauth. The guarded set — `www.` (homepage), `search.`, `grafana.`, `prometheus.`, `loki.`, `seerr.`, `marreta.`, `romm.`, `files.`, `notebook.`, `abs.` — proxies **all** traffic to `anubis:8923`, which verifies the client and forwards it back to Caddy's internal listener `http://:8016`; Authentik's proxy-provider mode serves the login inline (this build exposes **no** `forward_auth` endpoint) and forwards authenticated traffic to `http://:8017`, where the request is routed straight to its app (`dankclaw/Caddyfile`: the header comment and the two internal listeners). The exempt-by-construction set never enters that chain: `auth.` (the IdP itself), `ai.` and `gitea.` (own logins), `hermes.` and `hermesadmin.` (app-enforced passwords), `pihole.` (the basicauth edge above), and the media/*arr vhosts. Grafana left basicauth on 2026-09-01 and is an OAuth2 relying party against Authentik (`dankclaw/Caddyfile:122-132`). See [[Homepage]] and [[Authentik]]. +Everything else authenticates, not basicauth. The guarded set — `www.` (homepage), `search.`, `grafana.`, `prometheus.`, `loki.`, `seerr.`, `marreta.`, `romm.`, `files.`, `notebook.` — proxies **all** traffic to `anubis:8923`, which verifies the client and forwards it back to Caddy's internal listener `http://:8016`; Authentik's proxy-provider mode serves the login inline (this build exposes **no** `forward_auth` endpoint) and forwards authenticated traffic to `http://:8017`, where the request is routed straight to its app (`dankclaw/Caddyfile`: the header comment and the two internal listeners). The exempt-by-construction set never enters that chain: `auth.` (the IdP itself), `ai.` and `gitea.` (own logins), `hermes.` and `hermesadmin.` (app-enforced passwords), `pihole.` (the basicauth edge above), the media/*arr vhosts, and `audiobooks.` — Audiobookshelf enforces its own auth (local accounts plus authentik OIDC) and must answer native app clients, podcast RSS and OPDS, which the chain structurally cannot deliver: it answers a client that solves no JavaScript proof-of-work with challenge HTML wherever that client expects JSON or a socket payload ([[Audiobookshelf]]). Grafana left basicauth on 2026-09-01 and is an OAuth2 relying party against Authentik. See [[Homepage]] and [[Authentik]]. ## Guard a new vhost @@ -107,7 +107,7 @@ authentik's error page instead of the app. from authentik.providers.proxy.models import ProxyProvider from authentik.core.models import Application from authentik.outposts.models import Outpost - NAME, EXT = "audiobooks", "https://audiobooks.franksdank.cloud/" + NAME, EXT = "example", "https://example.franksdank.cloud/" pp = ProxyProvider.objects.filter(name=f"{NAME}-proxy").first() if pp is None: pp = ProxyProvider.objects.create(name=f"{NAME}-proxy", external_host=EXT, @@ -141,7 +141,9 @@ authentik's error page instead of the app. Audiobookshelf checks every OIDC callback against `x-forwarded-proto://Host`; this listener is fed over plain http so Caddy's own `X-Forwarded-Proto` is `http`, and the app answers a 400 "Invalid callback URL - must be same-origin" for its own https - callback. The edge for a guarded name is TLS-only, so `header_up X-Forwarded-Proto + callback (measured 2026-11-03, while this vhost was still guarded — it is exempt + now, and the same pin sits on its edge block instead). + The edge for a guarded name is TLS-only, so `header_up X-Forwarded-Proto https` is the truthful value ([[Audiobookshelf]]). 3. **Then the name and the audit.** A `dankclaw/pihole/addn-hosts` line (the `pihole` seam fails the build if a Caddyfile vhost has no record), a Porkbun A record if the @@ -149,6 +151,51 @@ authentik's error page instead of the app. `tests/e2e/specs/14-guard.spec.ts` with an app-specific leak marker — that spec is the deploy gate's proof that the guard, not the app, answers an anonymous client. +## Unguard a vhost + +The same three artifacts, in the opposite order. Getting it wrong takes the app +down for browsers while looking healthy: + +1. **Route first.** Move the public site block off `anubis:8923` to a direct + `reverse_proxy` (keeping any origin-pinning `header_up` the app needs), delete + the `@` handler from the `http://:8017` listener, and deploy. Traffic + stops entering the auth listener at `:8016`, so the IdP row goes unused — and + nothing breaks. +2. **Then the IdP rows**, once the route is verified: + + ``` + ssh dankclaw 'podman exec -i authentik-server ak shell' <<'PY' + from authentik.providers.proxy.models import ProxyProvider + from authentik.outposts.models import Outpost + pp = ProxyProvider.objects.filter(name="audiobooks-proxy").first() + if pp: + o = Outpost.objects.get(managed="goauthentik.io/outposts/embedded") + o.providers.remove(pp) + app = pp.application # the " Guard" application row + if app: + app.delete() + pp.delete() + print("providers now claimed:", Outpost.objects.get( + managed="goauthentik.io/outposts/embedded").providers.count()) + PY + ``` + + Deleting the provider **before** the route flips is the wrong order: the + outpost then matches zero providers for a Host that still arrives, and every + browser gets authentik's error page instead of the app. +3. **Then the audit lists.** Drop the subdomain from `GUARDED` in + `tests/e2e/specs/14-guard.spec.ts` (and its leak marker), and replace it + there with an exemption assertion — the deploy gate must keep proving the + exemption is deliberate, the way `plex` does: app bytes to an anonymous + client, zero challenge HTML, and the app's own `401` on an unauthenticated + API call rather than the gate's `200`. + +What the exemption costs, said plainly: the app's login endpoint becomes +internet-visible with no rate limiter in front of it unless the edge carries one +(this Caddy build has no `rate_limit` module) or the app ships a lockout (ABS +2.37.2 does not). That is the same exposure `jelly.`, `plex.` and the *arr +vhosts already live with; the mitigation is the credentials. + ## Operations Validate before a reload: diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index a710f4d..180b583 100644 --- a/serverAppConfigs-docs/index.md +++ b/serverAppConfigs-docs/index.md @@ -44,7 +44,7 @@ is a locator: one row, one clause, no numbers. | Page | Owns | |---|---| -| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, guarded vhost, laws. | +| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, public vhost, laws. | | [[Calibre]] | Calibre is the e-book library manager that backs Calibre-Web. | | [[Calibre-Web]] | Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database. | | [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. | 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 }); +});