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 });
+});