docs: own the un-guard procedure and the browser-only law
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.
This commit is contained in:
1 parent
aa57c1a894
commit
882a9f6808
3 files changed
+119
-24
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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 | `<base>/auth/openid/callback` **and** `<base>/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
|
||||
|
||||
@@ -90,7 +90,7 @@ pihole.franksdank.cloud {
|
||||
|
||||
After rendering, the line reads `dankuser <hash>` (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 `@<name>` 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 "<Name> 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:
|
||||
|
||||
Reference in new issue
Block a user