docs: the guard-a-new-vhost procedure and Audiobookshelf's second door

Infrastructure/Caddy.md now owns the order of operations for exposing a vhost
(proxy provider + application + embedded-outpost claim, then the route, then the
name and the chain audit), because the IdP row is invisible state that nobody
recreates. Audiobookshelf's Internal-only row and law become Two doors, one app;
the homepage link and index.md follow.
This commit is contained in:
Frank committed 2026-11-03 00:55:02 -08:00
1 parent 5e0592381a
commit 603513a15e
3 files changed
+60 -16

No files matched your search

@@ -1,11 +1,11 @@
--- ---
type: reference type: reference
description: "Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, SSD state, API bootstrap, authentik SSO, laws." description: "Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, guarded vhost, laws."
tags: tags:
- dankserver - dankserver
- audiobooks - audiobooks
- quadlet - quadlet
timestamp: 2026-11-02 timestamp: 2026-11-03
--- ---
# Audiobookshelf # Audiobookshelf
@@ -16,8 +16,8 @@ per-listener playback positions, and streams the audio. It is **not** a media
server — it shares nothing with [[Plex]] or [[Jellyfin]] and owns no files on the server — it shares nothing with [[Plex]] or [[Jellyfin]] and owns no files on the
array. array.
**Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user **Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user manager) —
manager) — **Internal-only:** LAN address `http://192.168.0.235:8000/audiobookshelf`, no vhost, no DNS name. **Doors:** LAN `http://192.168.0.235:8000/audiobookshelf` (direct, what the phone app uses) and guarded `https://abs.franksdank.cloud/audiobookshelf/`.
## Shape ## Shape
@@ -75,8 +75,8 @@ second host 2026-11-02 and re-measured on both base paths: `/ping` (`{"success":
`/healthcheck` (`OK`) answer at the ROOT whatever `ROUTER_BASE_PATH` says, so the app's `/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` 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 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 `download: true` on the root account is what makes offline listening work. Away
— see the LAN-only law, and note that the guard chain is not the remedy there. from the LAN, see the two-doors law in *Laws* — the guarded vhost is not the remedy.
**The login token is at `user.token`, not top level.** `POST /login` answers **The login token is at `user.token`, not top level.** `POST /login` answers
`{ user, serverSettings, ereaderDevices, Source, userDefaultLibraryId }` and the bearer sits `{ user, serverSettings, ereaderDevices, Source, userDefaultLibraryId }` and the bearer sits
@@ -98,6 +98,7 @@ 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 | | 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) | | 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) | | 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 `abs-proxy` (external_host `https://abs.franksdank.cloud/`, internal_host `http://caddy:8017`) + application "Audiobookshelf Guard", 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 |
The IdP saying yes is not the same as being allowed in: with `authOpenIDAutoRegister` on, 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 a first sign-in creates the account, and the claim alone decides the type. A user in
@@ -186,13 +187,13 @@ Both halves — authentik's provider row and ABS's settings — are asserted end
- **No `systemctl --user enable`.** Generated Quadlet units cannot be enabled on - **No `systemctl --user enable`.** Generated Quadlet units cannot be enabled on
this host; `WantedBy=default.target` in the unit file is what carries the this host; `WantedBy=default.target` in the unit file is what carries the
service across a reboot ([[Quadlet Lifecycle]]). service across a reboot ([[Quadlet Lifecycle]]).
- **LAN-only until Frank says otherwise.** `0.0.0.0:8000` with no reverse proxy - **Two doors, one app.** `http://192.168.0.235:8000/audiobookshelf` is the raw
in front. Any public vhost requires the Anubis + Authentik chain first, and the LAN address — plain HTTP, nothing in front, what the phone app is configured
outpost proxy provider has to exist before the vhost flips (external exposure with; `https://abs.franksdank.cloud/audiobookshelf/` is the guarded vhost
policy in `AGENTS.md`). That chain serves browsers: the phone app is a non-browser (Caddy → Anubis → the Authentik outpost → Caddy `:8017` → this container),
client and cannot solve a challenge, so guarding a vhost does not make remote the house chain for browser traffic — [[Caddy]] *Guard a new vhost*. That
listening work — remote listening wants a VPN into the LAN, after which the LAN chain serves browsers only: the phone app is a non-browser client and solves
address in *Phone app* is correct unchanged. no challenge, so remote listening wants a VPN into the LAN, not the vhost.
- **There is no `/setup` route; the creation door is `POST /init`.** `/status` - **There is no `/setup` route; the creation door is `POST /init`.** `/status`
(no `/api` prefix) reports `isInit` = "a root user exists", `POST /init` with (no `/api` prefix) reports `isInit` = "a root user exists", `POST /init` with
`{"newRoot":{"username","password"}}` creates it, `POST /login` returns the `{"newRoot":{"username","password"}}` creates it, `POST /login` returns the
+45 -2
View File
@@ -4,7 +4,7 @@ description: "Caddy is the single reverse proxy for both hosts."
tags: tags:
- infrastructure - infrastructure
- caddy - caddy
timestamp: 2026-08-29 timestamp: 2026-11-03
--- ---
# Caddy # Caddy
@@ -90,7 +90,50 @@ 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. 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.` — 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:14-24,283-423`). 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.`, `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]].
## Guard a new vhost
Order is the whole procedure: the IdP row has to exist **before** the route flips, or
authenticated traffic reaches the outpost with no matching provider and the user gets
authentik's error page instead of the app.
1. **Provider first.** A proxy provider plus an application, claimed by the embedded
outpost. No blueprint owns this state — it lives only in the IdP's database, which is
why it is easy to forget:
```
ssh dankclaw 'podman exec -i authentik-server ak shell' <<'PY'
from authentik.providers.proxy.models import ProxyProvider
from authentik.core.models import Application
from authentik.outposts.models import Outpost
NAME, EXT = "abs", "https://abs.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,
internal_host="http://caddy:8017")
if not Application.objects.filter(provider=pp).exists():
Application.objects.create(name=f"{NAME.title()} Guard", slug=f"{NAME}-guard",
provider=pp)
o = Outpost.objects.get(managed="goauthentik.io/outposts/embedded")
o.providers.add(pp) # an unclaimed provider is invisible to the outpost
print(pp.name, pp.external_host, o.providers.count())
PY
```
`external_host` is the exact vhost URL **with the trailing slash**: the embedded
outpost routes on the ORIGINAL `Host` header alone, so anything else matches zero
providers. Never print a provider's fields wholesale — a proxy provider carries a
client secret, and it lands in whatever transcript the command runs in.
2. **Then the route.** The public site block proxies the whole request to `anubis:8923`
with `header_up X-Real-Ip {remote_host}`, and the post-auth `http://:8017` listener
gains a `@<name> host <vhost>` handler pointing at the app — copy `romm`/`files`,
which are the same shape including a Dankserver LAN-port upstream.
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
name must resolve off-LAN ([[LAN-DNS]]), and the subdomain added to `GUARDED` in
`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.
## Operations ## Operations
+1 -1
View File
@@ -44,7 +44,7 @@ is a locator: one row, one clause, no numbers.
| Page | Owns | | Page | Owns |
|---|---| |---|---|
| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, SSD state, API bootstrap, authentik SSO, laws. | | [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, guarded vhost, laws. |
| [[Calibre]] | Calibre is the e-book library manager that backs Calibre-Web. | | [[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. | | [[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. | | [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. |