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/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: