From 882a9f6808acab10dd126eb177aa48e41c9a4d43 Mon Sep 17 00:00:00 2001 From: dank Date: Tue, 3 Nov 2026 01:54:08 -0800 Subject: [PATCH] docs: own the un-guard procedure and the browser-only law MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md states the three exposure postures and why the guard chain cannot front a non-browser client. Infrastructure/Caddy.md gains *Unguard a vhost* — route first, IdP rows last, audit lists after — since deleting the proxy provider while traffic still enters :8016 hands browsers authentik's error page. The Audiobookshelf page retires its two-doors law (the guarded vhost as a browser-only door, remote listening via VPN) for one address every client carries, adds how a listener gets an account, and says plainly what the exemption costs. --- AGENTS.md | 19 ++++- .../Dankserver/Audiobookshelf.md | 71 ++++++++++++++----- serverAppConfigs-docs/Infrastructure/Caddy.md | 53 +++++++++++++- 3 files changed, 119 insertions(+), 24 deletions(-) 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: