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:
1 parent
5e0592381a
commit
603513a15e
3 files changed
+60
-16
No files matched your search
@@ -1,11 +1,11 @@
|
||||
---
|
||||
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:
|
||||
- dankserver
|
||||
- audiobooks
|
||||
- quadlet
|
||||
timestamp: 2026-11-02
|
||||
timestamp: 2026-11-03
|
||||
---
|
||||
|
||||
# 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
|
||||
array.
|
||||
|
||||
**Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user
|
||||
manager) — **Internal-only:** LAN address `http://192.168.0.235:8000/audiobookshelf`, no vhost, no DNS name.
|
||||
**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://abs.franksdank.cloud/audiobookshelf/`.
|
||||
|
||||
## 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
|
||||
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 LAN-only law, and note that the guard chain is not the remedy there.
|
||||
`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.
|
||||
|
||||
**The login token is at `user.token`, not top level.** `POST /login` answers
|
||||
`{ 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 |
|
||||
| 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 `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,
|
||||
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
|
||||
this host; `WantedBy=default.target` in the unit file is what carries the
|
||||
service across a reboot ([[Quadlet Lifecycle]]).
|
||||
- **LAN-only until Frank says otherwise.** `0.0.0.0:8000` with no reverse proxy
|
||||
in front. Any public vhost requires the Anubis + Authentik chain first, and the
|
||||
outpost proxy provider has to exist before the vhost flips (external exposure
|
||||
policy in `AGENTS.md`). That chain serves browsers: the phone app is a non-browser
|
||||
client and cannot solve a challenge, so guarding a vhost does not make remote
|
||||
listening work — remote listening wants a VPN into the LAN, after which the LAN
|
||||
address in *Phone app* is correct unchanged.
|
||||
- **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://abs.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.
|
||||
- **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
|
||||
|
||||
@@ -4,7 +4,7 @@ description: "Caddy is the single reverse proxy for both hosts."
|
||||
tags:
|
||||
- infrastructure
|
||||
- caddy
|
||||
timestamp: 2026-08-29
|
||||
timestamp: 2026-11-03
|
||||
---
|
||||
|
||||
# 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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ is a locator: one row, one clause, no numbers.
|
||||
|
||||
| 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-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. |
|
||||
|
||||
Reference in new issue
Block a user