Merge remote-tracking branch 'origin/main' into wt/20261104-104444

This commit is contained in:
Frank committed 2026-11-04 12:09:57 -08:00
commit 9ca69d4e7e
42 files changed
+1357 -335

No files matched your search

@@ -68,8 +68,8 @@ service on the other host.
| Dankserver media stack (RomM trio, Jellyfin, Calibre-Web, Copyparty) | user Quadlet + `systemctl --user` | Installs `dankserver/<svc>/quadlet/*.container`, restarts the generated unit. The first cutover per service is idempotent: it retires the compose-era bare container, rescues any anonymous volume into the data root, and merge-moves `/opt/<svc>` to `/home/dank/Containers/<svc>` (mapped-uid roots move without chown). Steady state is restart-only. |
The shared chat template maps to the inference service on the selected target.
The `frank-npu` endpoint is retired () and has no deploy handler
an unrelated large-model deployment never touches it.
The `frank-npu` endpoint is retired and has no deploy handler, so an unrelated
large-model deployment never touches it.
## Caddy safety
@@ -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, public 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` (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
@@ -61,6 +61,9 @@ Dankserver. Change it in **Settings → Users → frank → Password**; the secr
then holds a stale value and only matters again if the `config` bind is ever
destroyed (a fresh DB makes the script create a new owner from it).
That value is the live password, not a leftover: it bcrypt-matches `users.pash` in the state
DB (2026-11-03), so `local` does open the door when authentik is down.
A full scan is slow and runs in the background; watch the `items:` total climb,
never `/healthcheck`. It also reads files that are genuinely broken and logs
`[AudioFileScanner] Invalid data found when processing input` per file — that is
@@ -69,14 +72,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 LAN-only law, and note that the guard chain is not the remedy there.
`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 +93,40 @@ 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.
**`POST /api/login` is not a route.** Local login is `POST <base>/login`, and the `/api`
bearer guard answers anything else with a bare `401 Unauthorized` while logging **nothing** —
no `[LocalAuth]` line — so a wrong path is indistinguishable from a wrong password unless the
journal is read too. Same family as the absent `/setup` route named in
`scripts/audiobookshelf-init.sh`. To settle a suspected stale credential without signing in:
bcrypt-compare the recorded value against `users.pash` in
`/home/dank/Containers/audiobookshelf/config/absdatabase.sqlite`, opened read-only
(`file:<path>?mode=ro`).
## 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, and that is the whole of it: the first
**Sign in with Authentik** auto-registers the ABS account (`authOpenIDAutoRegister`) as a
listener, because the claim grants that role to every signer. To shut a listener off,
disable or delete their user in Audiobookshelf — no IdP group membership can withhold it.
What auto-registration hands out (read off the state DB, 2026-11-03): `accessAllLibraries:
true` and `download: true` — every household signer lands on the **whole** library with
offline downloads already on. Narrow it per account in **Settings → Users → <name>**. The DB
is the census when no admin token is at hand; `GET /api/users` returns the *limited* shape
(no `active`, no `librariesAccessible`), so a missing key there is not a denial, and an
account created by SSO has `pash` NULL with its IdP link at `extraData.authOpenIDSub`.
Nothing rate-limits `POST /audiobookshelf/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
@@ -93,15 +136,27 @@ the local form, and the phone app offers SSO on its server-address screen. The l
| Piece | Owner |
|-------|-------|
| Provider `audiobookshelf`, the two groups, the scope mapping | `scripts/authentik-audiobookshelf-sso.sh` — re-run it after an authentik rebuild, because this state lives in the IdP's database and a blueprint re-import reverts it silently |
| Provider `audiobookshelf`, the admin group, the scope mapping | `scripts/authentik-audiobookshelf-sso.sh` — re-run it after an authentik rebuild, because this state lives in the IdP's database and a blueprint re-import reverts it silently |
| Client secret | `ABS_OIDC_CLIENT_SECRET` in `~/.config/secrets/audiobookshelf` on Dankserver (mode 600; never in the repo, a URL or an argv) |
| 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: every signer gets `user`; `audiobookshelf_admins` adds `admin`. Nothing in the IdP can deny the listener role — the login itself is the gate (law since 2026-11-03; the `audiobookshelf_users` group was retired, it had zero members and every household account was being refused) |
| 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) |
| 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
neither group is refused after a successful authentication — that is the mapping working,
not a broken login.
With `authOpenIDAutoRegister` on, a first sign-in creates the account and the claim alone
decides its type — and the claim always carries `user`, so **the IdP is now the library gate**:
any authentik account that can reach this application becomes a listener. That is the
intent (the household signs in with the accounts it already has), and its breadth is
accepted on purpose: no access policy is bound to the `audiobookshelf` application, and
the wide auto-registered default (every library, downloads on) is **not a defect to fix** —
Frank's call, 2026-11-03. Narrowing stays a per-account judgement in **Settings → Users**.
The one thing still worth watching is authentik's own invitation/signup flow: it must
remain something only Frank drives, because an account created there is a listener the
moment it signs in. Revoking one person is a disable/delete of their ABS user; revoking
everyone is that signup flow.
Both halves — authentik's provider row and ABS's settings — are asserted end to end by the Playwright gate named in *Verification*. `23-` signs in as `qa-e2e-admin` and must land inside the app with site-admin rights; `24-` signs in as the standard identity, which is in no Audiobookshelf group, and must land as a listener. Together they pin the escalation and the default: a mapping that stops granting silently locks the household out, and a mapping that over-grants turns the household into site admins.
## Laws
@@ -128,6 +183,39 @@ not a broken login.
provider's registered URI must equal `<origin><subfolder>/auth/openid/callback`, so each
script derives the value from one variable of its own (`ABS_URL`, `ABS_BASE`) and
`tests/audiobookshelf_init.bats` pins what reaches the wire.
- **An ORM-created provider has no grant types.** authentik 2026.8 defaults `grant_types` to
`[]`, and `/application/o/authorize/` then refuses the code flow with oauthlib's generic
"The request is otherwise malformed" — the wire names nothing, the server log line that names
it is `Invalid grant_type for provider`. Worse, the redirect-URI check runs *first*, so a
provider carrying both faults shows only "Redirect URI Error" until the URI is fixed, and the
second wall appears after the fix ships (measured 2026-11-02: the browser reached authentik,
got `code`+`state`, and ABS still bounced to `/login?error=undefined`). The script sets
`authorization_code` + `refresh_token`; a blueprint re-import reverts the row silently.
- **The guarded door has to tell the truth about its scheme.** ABS validates every OIDC
`callback` against the origin it believes it is served from — `getRequestOrigin()` is
`x-forwarded-proto://Host` (`server/utils/requestUtils.js`, read in the pinned digest
2026-11-03) — so behind a TLS edge that reaches the container over plain http it computes
`http://audiobooks.franksdank.cloud` and answers its own https callback with a 400 "Invalid callback
URL - must be same-origin" before the IdP is ever contacted. The Caddy `:8017` route pins
`header_up X-Forwarded-Proto https` for exactly that reason ([[Caddy]] *Guard a new vhost*);
the LAN door needs nothing, because there browser and server agree on `http`.
- **The claim mapping is not the whole `property_mappings` list.** A provider holding only the
custom mapping grants a token scoped shorter than the scopes ABS requests
(`openid profile email abs_groups`): the code is issued and exchanged normally, and ABS's
`/userinfo` call then dies `insufficient_scope (The request requires higher privileges than
provided by the access token)` — a fault that reads like an ABS bug and is an IdP row. Attach
the built-in OpenID `openid`/`profile`/`email` mappings beside the claim mapping, which is
what every working RP on this IdP carries (measured 2026-11-02).
- **With `authOpenIDMatchExistingBy` unset, an SSO identity never lands on an existing ABS
account, and a name collision is a hard denial.** In that mode `findUserFromOpenIdUserInfo`
matches on `sub` only, so every first sign-in auto-registers a NEW user; if the IdP username
equals an ABS username — the local root `frank` is the one here — the create hits the unique
constraint and the callback dies `Unauthorized`. So "create a `frank` user in authentik" does
not reach the root account: either set `authOpenIDMatchExistingBy` to `username` (links the
IdP account to the existing one, and `setUserGroup` refuses to downgrade a root — root plus an
`admin` claim is allowed, root plus `user` is denied) or pick a non-colliding IdP name. Read
off `server/models/User.js` and `server/auth/OidcAuthStrategy.js` in the pinned digest
(2026-11-02).
- **The audio bind stays `:ro`.** ABS writes nothing that belongs beside the
audio: its library, covers, progress and sidecar metadata all land in
`/metadata` on the SSD. A writable bind would put stray writes on the spindle
@@ -158,13 +246,17 @@ not a broken login.
- **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.
- **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
@@ -248,8 +340,25 @@ not a broken login.
health check, the base path the shipped SPA needs), `tests/audiobookshelf_init.bats` (the
bootstrapper against a stub of the API above: create-once, idempotent re-run, no secret in a
URL, refusal by URL when the server does not answer, the deployer calling it only after the
health gate, and the SSO arm sending the mounted base path as the redirect subfolder) and
`tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may reference the metadata view).
health gate, and the SSO arm sending the mounted base path as the redirect subfolder);
`tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may reference the metadata view);
and the live Playwright gates `tests/e2e/specs/23-audiobookshelf-sso.spec.ts` and
`tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts`, run by `tests/e2e/run.sh` at
the end of every `deploy.sh`. `23-` (the `admin` project) signs in as `qa-e2e-admin` against
`http://192.168.0.235:8000/audiobookshelf` and asserts the two things no stub can see: every
`.js` the app loads arrives as JavaScript (a bundle built for the wrong base path arrives as
`text/html`, which boots nothing), and `/api/me` answers 200 with `type: "admin"` — true only
while grant types, the OpenID scope mappings and the group claim all hold together. `24-` (the
`standard` project) signs in as `qa-e2e`, an account in no Audiobookshelf group, and must come
back `type: "user"`: the default grant that lets the household in, so a scope-mapping
regression surfaces as a failed sign-in in the gate instead of as a friend's complaint.
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,
+63 -1
View File
@@ -3,7 +3,7 @@ type: reference
title: Jellyfin
description: "Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status."
tags: [jellyfin, dankserver, media-server]
timestamp: 2026-11-02T11:00:00Z
timestamp: 2026-11-03T11:40:00Z
---
# Jellyfin
@@ -333,6 +333,9 @@ hand-typed request is this block's quoting, not the server: bash `printf`
collapses `\"` to `"`, curl ends the quoted value at that quote, and Jellyfin
receives `Authorization: MediaBrowser Token=` with nothing after it (`\\"` answers
204, `\"` answers 401 — both run on this host).
Reading the progress with the same config trick takes **one** URL: a config `url`
line *plus* a command-line URL makes curl perform both transfers, and the second
dumps the whole `ScheduledTasks` body onto your stdout.
One accepted refresh is the whole procedure. A second one while a scan runs moves
`Scan Media Library` to `Cancelling`, and once the runner holds `Cancelling` every
@@ -383,6 +386,65 @@ folder created under `/data/Movies`: it stays invisible until a recursive refres
names it. So every pass that writes into a library ends by firing one, and "Jellyfin
does not see the file I just moved in" is the designed answer, not a fault to chase.
## API-key ledger and rotation
`/Auth/Keys` is the ledger. `GET` lists every key **with its `AccessToken` in
cleartext**, so an admin token in hand prints the whole fleet's keys;
`POST /Auth/Keys?app=<name>` mints one and answers `204` **with no body** — the
new token is read back from the `GET`, never from the create response.
`DELETE /Auth/Keys/{token}` revokes **by the token string**, and a parameter that
matches nothing answers `204` too, so `204` there is not proof anything was
removed. Discover a route's verb from the `Allow` header of an `OPTIONS` (or
`GET`) response — never by firing `DELETE` at a guess.
- `IsActive` reads `False` on rows that authenticate `200`. It is not a usability
signal; only an authenticated request is.
- `DateLastActivity` never moves off `0001-01-01` on 12.4, so "is this key in
use?" cannot be answered from the ledger. The census is repo + host credential
stores + the `Devices` table (whose `DateLastActivity` *is* real).
- Rotation order: mint → rewrite **both** stores (`~/.config/secrets/jellyfin`
and `JELLYFIN_API_KEY` in the ignored `dankserver/.env`) → prove the new key on
`/System/Info` → `DELETE` the old token → prove `401`. Revoking while a store
still holds that value breaks the scan door silently.
- Blast radius as measured 2026-11-03: no repo script reads `JELLYFIN_API_KEY`;
Seerr keeps a key of its own in its own DB (which of its two rows it uses was
never confirmed), so revoke by token and never by app name.
## Big-screen theme (the TV)
`dankserver/jellyfin/theme/wmc/theme.css` is repo-owned and bind-mounted over the
image's **Media Center** theme slot (the `Volume=` line in
`dankserver/jellyfin/quadlet/jellyfin.container`). It exists for
`danklivingroom`, which renders this server-served web app at 3840×2160 with no
client-side scaling, so 14 px text arrives on a panel read from three metres.
The file is four rules, and its first line is the design:
`@import url("../dark/theme.css")` pulls in the **live upstream** dark palette, so
the repo never vendors a CSS snapshot and a Jellyfin image bump cannot strand one.
That works because jellyfin-web applies a theme as
`<link rel="stylesheet" href="themes/<id>/theme.css">` — a relative URL resolved
against the CSS file, not the document. The size lives in one number:
`--dank-big-screen-zoom` (1.75 ≈ three metres; 1.5 mild, 2.0 projector). `zoom` on
the root element is the right lever: the card-grid code measures layout pixels, so
fewer, larger columns are computed rather than overflow produced.
- **Adoption is per device, and it stays that way.** The theme a client renders
comes from its own settings (localStorage first), so there is no server-side
"everyone gets the big UI" knob. On the screen that wants it:
**Display Settings → Theme → Media Center.** That is also the honest label —
the dropdown names are compiled into the bundle, so a "Big Screen" entry will
never appear; do not go looking for one.
- The mount is `:ro` and the app only fetches the file, so the failure mode of a
stale `@import` target is slightly wrong colors, never a dead web app.
- **Editing the CSS needs no restart.** The bind hands out the host file's live
bytes, so a changed zoom number is on screen at the next page load. The planner
agrees by construction: only `dankserver/<svc>/quadlet/**` maps to a service, so
a `theme/**`-only commit plans nothing — that is correct, not a gap to "fix",
because restarting a media server for a font size is the wrong trade. Only the
first deploy of the `Volume=` line restarts anything.
- **Rollback is one line:** drop the `Volume=` and redeploy; nothing in the
database records the theme, so no cleanup follows.
## See Also
- [[Plex]] — co-resident media server on the same volume
@@ -179,21 +179,57 @@ keeper is missing — either one drops the whole group rather than half-applying
era, not this pass — read the mtimes before blaming the prune.
- The 2026-11-01 pass held 226 files (~1.1 TiB) in
`/mnt/entropy/.AGENTTRASH/20261101-duplicate-prune`, journals
`dup-prune-exact.jsonl` and `dup-prune-similar.jsonl`. Purge pending Frank's word.
- The 2026-11-02 TV pass holds 220 files (54 GiB) in
`dup-prune-exact.jsonl` and `dup-prune-similar.jsonl`.
- The 2026-11-02 TV pass held 220 files (54 GiB) in
`/mnt/entropy/.AGENTTRASH/20261102-tv-duplicates` — plan `tv-dupes-plan.tsv`,
journal `tv-dupes.jsonl`, 220 moves / 16 hold folders / 7 husks swept — and the
same day's TVShows detritus pass holds 109 files (552 KiB) in
same day's TVShows detritus pass staged 109 files (552 KiB) in
`/mnt/entropy/.AGENTTRASH/20261102-tvshows-detritus` with plan
`tvshows-detritus-plan.tsv` and journal `tvshows-detritus.jsonl`. Both purges
pending Frank's word, beside the 2026-11-01 hold above. All four journals and
plans live at the `entropy` root, not `/tmp`.
- **Re-verify each survivor by size, not by the recorded path, before purging.**
Detection artifacts and plans key on paths, and a folder renamed by an
interactive session ([[Entropy Volume]], *Write doors*) makes its keeper look
deleted while it sits untouched under the new name. The 2026-11-01 audit hit
three such groups: every keeper was present, same bytes, different folder. A
purge decided from the plan's paths alone would have reported survivors gone.
`tvshows-detritus-plan.tsv` and journal `tvshows-detritus.jsonl`. All four
journals and plans live at the `entropy` root, not `/tmp`.
- **Purged 2026-11-04, on Frank's word, after the gate below:** the duplicate
prune, the TV duplicate pass, the Ghibli duplicates (6 files — both `Nausicaä`
copies among them) and the 2026-11-04 promo-clip hold left the array trash,
~1.2 TB freed (the array went 218 GB → 1.4 TB available). The journals survive as
the only record of where a purged file came from; rollback after a purge is
impossible, which is why the gate runs first.
### The purge gate: a carrier for every held file
`REAL: 0` in both databases says only that no row points at a moved path; it says
nothing about whether the film survived. So a purge waits on a stronger proof:
**every held file has a same-work carrier in the live library at >= its size,
re-derived at purge time**, never copied from the plan. The 2026-11-04 purge re-found
all 452 held files this way; the first pass of the gate could not place 142 of them,
and every one of those was a matcher gap, not a lost film. What the re-derivation has
to absorb:
- **The layout moves under the plan.** The relayout renames folders to
`Title (YYYY)` and `[tvdbid-N]/Season N` and renames the keeper files too, so the
paths *and* the sizes a plan recorded no longer describe the library. Keys are work
identity — normalized title+year for a film, `(S,E)` for a TV pack — never a path.
- **Fold accents, then compare.** The library holds `Nausicaä`; a plan, a
`find -ipath "*Nausicaa*"`, and an ASCII substring search all miss it. NFKD-fold and
drop combining marks, or a real keeper reads absent for two rounds and the file
beside it starts to look like an orphan.
- **The carrier is often another container, and usually larger.** A held `.mp4` can be
kept as the sibling `.mkv`; keep-larger means the keeper is frequently far bigger (a
377 MB held episode matched a 501 MB keeper; a 1.6 GiB film stands beside a 34 GiB
BDRemux of itself in another folder). "Same folder, same bytes" reports survivors as
lost — it is the wrong test.
- **An exact twin can sit in a sibling folder**, so "no same-size file in the folder"
needs an array-wide name search before it means anything.
### Running the purge
`scripts/purge-agenttrash.sh` is the only door — never `rm`. It acts on exactly one
object: a root's own `.AGENTTRASH`. A partial purge is therefore **staged, not
filtered**: move the approved buckets into a scratch root
(`.PURGE-<date>/.AGENTTRASH/`) and point the purger at that root, and park anything
not approved at the array root as `.HOLD-<date>-*`, out of its reach. Dry-run first —
the plan must print exactly one `plan empty` row, and it is the same row `--execute`
acts on — and send `--log` into `.media-campaign/`, because `/tmp` is not a record.
Then read `df`: a purge is the only media act that moves the free-space number.
## After the pass
@@ -4,7 +4,7 @@ description: "Outpost, proxy-provider, OAuth2, LDAP and flow configuration for t
tags:
- infrastructure
- authentik
timestamp: 2026-10-19
timestamp: 2026-11-03
---
# Authentik Outpost Reference
@@ -56,7 +56,7 @@ correctly — the outpost matches on hostname.
## Provisioning rules for proxy providers
Two provisioning rules, each verified live by an outage:
Three provisioning rules, each verified live by an outage or a dead login:
1. **All three flows are required.** A ProxyProvider created via raw API/ORM
with any of `authentication_flow`/`authorization_flow`/`invalidation_flow`
@@ -80,6 +80,16 @@ Two provisioning rules, each verified live by an outage:
the app. No error is logged anywhere — the SSO "works" and the app never
appears. Create the Application with `provider_id` set (the UI does this
automatically; raw API/ORM drives must do it explicitly).
3. **A raw ORM create skips the OAuth defaults.** The REST serializer calls
`ProxyProvider.set_oauth_defaults()`; `ProxyProvider.objects.create()` does not. The row
then looks fully configured — external_host, internal_host, application bound, outpost
claimed — while `redirect_uris` is empty (those URIs are *derived* from `external_host` by
`_get_callback_url`) and `grant_types` is `[]`. Both kill the guard before any credential,
in this order: authentik renders its own **"Redirect URI Error"** page, and once the URIs
are filled by hand the next wall is a bodiless **HTTP 400** out of `/application/o/authorize/`
(both measured on the Audiobookshelf guard provider, 2026-11-03). Census and repair in one idempotent pass, all
providers at once: `scripts/authentik-proxy-provider-defaults.sh`; `--check` prints the
census and exits 1 on drift, and a provider that already matches is left alone.
## OAuth2 relying-party quirks (this fork; corrected live 2026-10-20)
+106 -2
View File
@@ -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,111 @@ 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.` — 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
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 = "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,
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.
`ProxyProvider.objects.create()` is not the API: the serializer's
`set_oauth_defaults()` never runs, so the row lands with `redirect_uris` and
`grant_types` empty, and the guard fails *before* any credential — authentik's
"Redirect URI Error" page, then a bodiless HTTP 400 once the URIs are filled
by hand. Run `scripts/authentik-proxy-provider-defaults.sh` after the snippet
(idempotent, covers every provider; `--check` is the census); law and failure
signatures in [[Authentik Outpost Reference]].
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.
An app that derives its own origin from the request needs the scheme pinned:
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 (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
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.
## 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
@@ -316,7 +316,7 @@ flowchart TD
**User services** (root page), in section order:
- **AI** — OpenWebUI, and the three LLM fleet cards (frank-dragoon, frank-archon, frank-zealot — status + docs link, no Open link).
- **Web Apps** — SearXNG, Gitea, Copyparty, Marreta.
- **Media Library Apps** — Calibre-Web, RomM, Plex, Jellyfin, Seerr.
- **Media Library Apps** — Calibre-Web, RomM, Plex, Jellyfin, Seerr, Audiobookshelf (moved up from the admin page 2026-11-03, once Authentik SSO made it a household app).
**Admin services** (`/admin/`), in section order:
- **Media Library Apps** — the *arr stack (Prowlarr, Sonarr, Radarr, Lidarr, Readarr), qBittorrent, and Tautulli — live on FrankBook-Pro and Dankserver, operator-only: no vhosts (Tautulli included), direct IP:port access only. See [[FrankBook-Pro]].
@@ -324,6 +324,12 @@ flowchart TD
- **AI** — Hermes, ComfyUI.
- **Infrastructure** — Caddy, dnsmasq, Gluetun, and the low-level backends (mariadb, valkey, Postgres). The backends show status only. They get a link only where a UI is meaningful.
**The registry is the authority.** `shared/homepage/services.json` decides which
page a card renders on (`category`), whether it shows a WIP badge instead of live
status (`wip`), and whether it gets an Open link (`showLink` + `url`). The two
lists above are a locator only; when they disagree with the registry, the page is
the bug — fix the page.
## Layout
```
@@ -197,23 +197,18 @@ and PerfOpt is now a bonus nobody is waiting for rather than the reason to flip.
**Post-boot gates.** 0 failed units, `systemd` running. 32 IOMMU groups — 31
`identity` + 1 `DMA`, and that one belongs to the Genesys Logic SD card reader
(`c2:00.0`, a kernel quirk, not a fleet device). The iGPU (`c5:00.0`) sits in group
20 `identity` with ATS capability; interrupt remapping is enabled. frank-dragoon's
boot ledger is unchanged to the MiB — 62.9 GiB weights (0.8 of them vision) + 5.5
GiB KV + 5.7 GiB working = 74.1 GiB, two slots over ONE 200,704-position pool, MTP
depth 2, prompt cache ON, no clamp line — and Gitea, the Caddy edge and Grafana all
answered 200. (Later the same day the pool DOUBLING to 401,408 positions for
slot parity was COMMITTED (`2b12716`) and **deployed the same evening**: the
boot now reserves `401408 positions (about 11.0 GiB)` and the engine-side total
is `79.6 GiB in all` (62.9 weights + 11.0 KV + 5.7 working)
see [[frank-dragoon]].)
20 `identity` with ATS capability; interrupt remapping is enabled. The flip moved
frank-dragoon's boot ledger by nothing measurable — its shape and every number in it are
owned by [[frank-dragoon]] — and Gitea, the Caddy edge and Grafana all answered 200.
**The NPU came back with the flip.** `amdxdna` bound to `0000:c6:00.1` (Strix/
Krackan/Strix Halo NPU rev 11), firmware `amdnpu/17f0_11/npu_7.sbin`, `Initialized
amdxdna_accel_driver 0.7.0 … on minor 0`, IOMMU group 26 `identity`, PASID cap width
10 (`Exec- Priv-`). `/dev/accel/accel0` is `crw-rw---- root:render 261,0` and `dank`
is in `render`, so a quadlet needs `AddDevice=/dev/accel/accel0` +
`GroupAdd=keep-groups`. **No NPU endpoint is deployed** — stays the
history of the retired endpoint; what changed is only its precondition.
`GroupAdd=keep-groups`. The flip brought the device back, not a service: no NPU chat
endpoint was deployed then. The XDNA2 lane returned to work a day later as frank-dragoon's
small-model lane ([[frank-dragoon]]) — the shape and its laws are owned there.
**The `pp_dpm_fclk` pin (level 7, 2000 MHz) went in on 2026-10-19**, with the first thing that needed it: the
vendor's root host unit, installed and enabled by `frank-dragoon`'s deployer while its NPU lane is named. Pinning
+1 -1
View File
@@ -20,7 +20,7 @@ timestamp: 2026-10-19
The radiance engine (C++/HIP, `libr4d` kernels built for gfx1201, no Python in the serving
path) driving `podman run` straight from the digest-pinned image under a **root** systemd
unit; container name = unit name. Host Dankserver (192.168.0.235), port 7770, both Radeon AI
PRO R9700s. Capacity contract **2 conversations × 200,000 tokens at fp8 KV** — at 1.8.1 that pair
PRO R9700s. Capacity contract **4 conversations × 200,000 tokens at fp8 KV** — at 1.8.1 that pair
is the **demand signal** the budget resolver sizes the cache against, not a KV allocation (Laws).
A payload swap is a `MODEL_FILE` line plus a re-download — see Eras.
+1 -1
View File
@@ -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, public 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. |