Compare commits

...
Author SHA1 Message Date
dank e1bf1d6e80 Merge pull request 'Household docs: rewrite the family guide, and make the model cards tell the truth' (#139) from docs/household-guide-refresh into main 2026-11-03 04:08:09 -08:00
Frank ef306397a5 docs(vault): the four-slot archon contract, and pages that contradicted themselves
frank-archon.md's intro promised 2 conversations while its own shape table said
MAX_SEQS=4; AGENTS.md carried --max-num-seqs 2 and a 44,928 MiB host pool against
a live 4 and 49,152. Both now quote the env.

dankclaw-iommu-translation-off.md re-transcribed frank-dragoon's boot ledger,
including a pool figure that was superseded the same day, and asserted in the
present tense that no NPU endpoint is deployed - falsified by the XDNA2 embedding
lane on 2026-10-19. Both facts now belong to [[frank-dragoon]] alone.

Deployment Flow.md had a sentence broken by an earlier link removal.

Census: every tunable in the three scripts/<svc>.env against its endpoint page
(only truncated-sha and elided-path forms, which the pages omit on purpose), live
/health + /v1/models on all three endpoints, check-okf.py clean at 125 pages, no
duplicate titles, no page unlinked from an index.
2026-11-03 04:07:46 -08:00
Frank 3896dce364 fix(homepage): the model cards speak the live shape
Three numbers on the family-facing model cards had drifted from scripts/*.env:
archon said three submissions where FRANK_ARCHON_MAX_SEQS=4 (1bb6399), zealot
said 262k context where the contract is 200,704 a slot (CTX_SIZE 602112 / PARALLEL
3, and llama.cpp 400s over the slot rather than truncating), and zealot's warning
still promised censored-base refusals after the Uncensored-LoRA went into the load
line (LORA_FILE set, LORA_SCALE=1). archon's role dropped an unverifiable
"fastest"; zealot's card dropped a tokens/s figure nobody is comparing.

Deletes the assertion that zealot's warning must not claim uncensored output. It
was written 2026-10-17, when the censored base was live; the adapter landed after
it, so the pin now enforces a false safety claim on the one surface the household
reads. The general invariant survives and still fires: a card whose spec says
uncensored or abliterated must carry the legal warning (same seam, lines 100-104).
2026-11-03 04:07:46 -08:00
Frank 3f82c4d5d8 docs(homepage): rewrite the household guide for the people who use it
Task-first instead of app-first: start here, pick what you want to do, open the
door. Every door gets its exact address, the button to press, and the failure the
household actually hits.

Screenshots are files now (img/onboarding/*.webp, 14 of them, all captured logged
in as a standard user) instead of inline data URIs: the page is 23 KB of prose
instead of 30 KB of base64, and the images cache on their own. Zero-JS kept.

Facts re-checked against the live services rather than against the old page: the
front door is www.franksdank.cloud (the apex 308s there); the standard user's
Open WebUI account sees all three models and opens on frank-dragoon (read from
/api/models as that user); WebDAV answers only on the house network (207 on
192.168.0.235:3923, a guard challenge through the public door); Audiobookshelf
iOS is a limited-seat TestFlight beta, Android is the Play release.
2026-11-03 04:07:39 -08:00
Frank 5030f83c55 docs(jellyfin): one URL per curl config read, or the second transfer dumps the body 2026-11-03 03:42:21 -08:00
dank b875256500 Merge pull request 'feat(jellyfin): big-screen theme for the TV - repo CSS in the Media Center slot' (#138) from feat/jellyfin-big-screen-theme into main 2026-11-03 03:40:04 -08:00
Frank 51abc6550d feat(jellyfin): big-screen theme for the TV, mounted into the wmc slot 2026-11-03 03:38:15 -08:00
dank 64f489023e Merge pull request 'feat(homepage): Jellyfin goes live, Audiobookshelf joins the household, Marreta says what it is' (#137) from feat/homepage-media-cards into main 2026-11-03 03:32:49 -08:00
Frank 3511e04ae9 docs(jellyfin): record the API-key ledger and the rotation door 2026-11-03 03:27:48 -08:00
Frank 21b7ad15d9 chore(homepage): retire the unreferenced dankserver registry copy
shared/homepage/dankserver.services.json was read by nothing: no reference
anywhere in the repo, and deploy_homepage never copies it. It sat beside the
real registry looking like a second live card set, which is exactly how a
future agent gets misled about what the page renders. Moved to the gitignored
shared/homepage/.AGENTTRASH/ beside it, so it stays recoverable.
2026-11-03 03:23:13 -08:00
Frank 4ad55c28a0 feat(homepage): Jellyfin live, Audiobookshelf to the household page, Marreta says what it is
Jellyfin's card still read "Work in progress: not open to family users yet"
with no link; the server answers 12.4.0 on :8096 and carries a second
(non-admin) account, so the card flips to live status with its Open link on
http://jelly.franksdank.cloud. It has no Authentik link, and the copy says so.

Audiobookshelf moves category admin -> user. A card renders on exactly one
page, so it leaves /admin: Authentik SSO made it a household app, and the
household page is the door that matters.

Marreta's description now names what it is - the content-cleaning proxy in the
Gluetun netns with a headless companion - and its honest limit.

Also in this change:
- docs.html: a "Media & files" line for the audiobook door and its SSO, plus
  the pre-existing unclosed <ul> under frank-dragoon, which made the whole
  frank-archon block parse inside the dragoon bullet.
- Caddyfile: the jelly. vhost gains the exemption reason the External Exposure
  Policy requires (comment only - Caddy strips comments, so no proxy bounce).
- Homepage.md: classification list gains Audiobookshelf, and the section now
  states that services.json is the authority for placement/WIP/link.
2026-11-03 03:20:08 -08:00
dank c9a8469d04 Merge pull request 'docs(audiobookshelf): the wide listener default is a decision, not a defect' (#136) from docs/abs-listener-default-decision into main 2026-11-03 03:09:19 -08:00
Frank 5794b07c32 docs(audiobookshelf): the wide listener default is a decision, not a defect
The SSO paragraph left "whether an access policy should be bound to the
audiobookshelf application" open, which invites the next agent to relitigate
it. Frank's answer of 2026-11-03: accepted on purpose — every signer becomes a
listener with all libraries and downloads, no access policy on the application,
narrowing done per account in Settings -> Users. What that makes load-bearing is
authentik's own signup flow, now named in the same paragraph as the thing that
must stay operator-only.
2026-11-03 03:08:36 -08:00
dank 1a47b831ee Merge pull request 'docs(audiobookshelf): /api/login is not a route — and what auto-registration grants' (#135) from docs/abs-login-path-and-listener-shape into main 2026-11-03 03:04:05 -08:00
Frank 5be66df2bb docs(audiobookshelf): /api/login is not a route
A wrong path was mistaken for a dead break-glass credential. Local login is
POST <base>/login; /api/* is behind the bearer guard, which answers a bare 401
and logs nothing, so a bad path looks exactly like a bad password. The recorded
value bcrypt-matches users.pash, so local really is the door when authentik is
down, and the rate-limit line was protecting a path that cannot carry a login.

Also records what auto-registration actually grants, measured from the state DB
because GET /api/users returns the limited shape: every SSO signer arrives with
accessAllLibraries and download already on, and carries no local password.
2026-11-03 02:28:11 -08:00
dank d3aedd1837 Merge pull request 'Every authentik account that signs in to Audiobookshelf becomes a listener' (#134) from feat/audiobookshelf-sso-default-listener into main 2026-11-03 02:19:14 -08:00
Frank 66766f23ef docs(audiobookshelf): the IdP is the library gate now
Rewrite the listener onboarding, the role row, and the fail-closed law: the
claim grants every signer, revocation happens on the ABS side (disable or
delete the user), and what actually guards the library is who can reach the
audiobookshelf application in authentik. Name both e2e gates in Verification.
2026-11-03 02:06:20 -08:00
Frank 5cf5416471 test(e2e): pin the Audiobookshelf default listener grant
spec 24- signs in through SSO as qa-e2e — an identity in no Audiobookshelf
group — and must land inside the app with type "user". It is the counterpart
to 23-, which pins the admin escalation: together they catch both directions
of a scope-mapping regression (household locked out, or household made site
admin). Verified red against the pre-change mapping — the identity never
received a token — and green after re-provisioning: 4 passed.

Joined to the standard project, which is the only project whose identity
observes the default.
2026-11-03 02:06:20 -08:00
Frank 30186d8365 feat(authentik): make every Audiobookshelf SSO signer a listener
The abs_groups claim decided the listener role from membership of
audiobookshelf_users, a group nobody was ever added to, while the ABS
application carries no access policy of its own. The result: seventeen
household accounts authenticated at the IdP successfully and were then
refused by the app — a policy decision that reads as a broken login.

The claim now answers ["user"] for every signer and appends "admin" only
for audiobookshelf_admins, so the IdP login is the gate and onboarding a
listener is exactly "create the authentik account". Admins keep the exact
claim they always received, ["admin","user"], in that order: ABS's
group-tag matching has never been measured for order-sensitivity and the
working admin login is not where to find out. audiobookshelf_users is
deleted rather than left empty — a group that decides nothing is a trap
for the next reader, who will assume it gates something.

Two side fixes found by running the script:
 - Two comments inside the unquoted PYEOF heredoc carried backticks, so
   bash command-substituted them locally on EVERY run ("audiobookshelf_users:
   command not found", "user: command not found") and spliced the output
   into the python program. Names in that body are plain now, with a note
   on why. A quoted heredoc is not an option: the body interpolates.
 - The default-grant probe picked authentik's AnonymousUser row, then its
   admin superuser. It now walks real non-superuser accounts, preferring
   the fleet's plain-user marker group, and prints up to three claims.
2026-11-03 02:06:20 -08:00
dank 3a6f7c7280 Merge pull request 'feat(caddy): Audiobookshelf outside the guard chain, so the phone app works from anywhere' (#133) from feat/audiobookshelf-public-app-access into main 2026-11-03 01:56:51 -08:00
dank c7643fb4d0 docs: regenerate the vault index row for the new Audiobookshelf description 2026-11-03 01:55:06 -08:00
dank 882a9f6808 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.
2026-11-03 01:54:08 -08:00
dank aa57c1a894 fix(authentik): register the mobile OIDC redirect on every ABS origin
The mobile-redirect URI was registered on the LAN origin only, because the phone
had to be configured with the LAN address while the vhost was guarded. Now that the
app rides the public name it sends the public mobile-redirect URI, and the RP's
STRICT matching answers an unregistered one with "Redirect URI Error" — a phone
locked out at the last step of a login that looked healthy. Both callback URIs are
now emitted for every entry in BASES.
2026-11-03 01:54:08 -08:00
dank 2a48751283 feat(caddy): serve Audiobookshelf outside the guard chain
The Audiobookshelf phone app could never sign in over the public name. The guard
chain answered its /ping and /socket.io/ handshake with Anubis challenge HTML, so
the app evaluated 'isInit' in e.data on a non-object and reported "Failed to
contact server" while the container's request log recorded nothing at all. Anubis
demands a JavaScript proof-of-work and the authentik outpost works from a session
cookie: the chain is browser-only by construction, and a native client, a podcast
RSS reader or an OPDS client cannot pass it.

Audiobookshelf authenticates itself (local accounts plus authentik OIDC), so it
joins jelly/plex/*arr in the exempt set: the public site block dials the container
directly, keeping the X-Forwarded-Proto pin its OIDC origin check depends on, and
the @audiobooks route leaves the post-auth listener so one host has one owner. The
IdP rows that guarded the name go after this deploys, never before.

tests/e2e/specs/14-guard.spec.ts loses audiobooks as a guarded target and gains the
exemption assertion (app bytes to an anonymous client, the app's own 401, never
challenge HTML); tests/anubis.bats pins the route shape.
2026-11-03 01:54:08 -08:00
dank 6083903ccb Merge pull request 'fix(e2e): judge only body-carrying script responses in the ABS SSO spec' (#132) from fix/e2e-mime-304 into main 2026-11-03 01:42:28 -08:00
dank ee9eb353fb fix(e2e): judge only body-carrying script responses in the ABS SSO spec
A 304 carries no Content-Type by spec, and the OIDC bounce revalidates the entry script (Cache-Control: max-age=0 + ETag), so the blanket MIME assertion failed on a healthy bundle. The HTML-stub class is still caught: 200s are still judged, and the count floor now runs on the same filtered set.
2026-11-03 01:39:39 -08:00
dank df597bb1b1 Merge pull request 'fix(abs-guard): audiobooks.franksdank.cloud completes OIDC (forwarded scheme + provider OAuth defaults)' (#131) from fix/abs-guard-oauth-defaults into main 2026-11-03 01:26:32 -08:00
38 changed files with 936 additions and 347 deletions

No files matched your search

+18 -5
View File
@@ -227,9 +227,18 @@ See `serverAppConfigs-docs/Architecture/Testing Policy.md`.
## External Exposure Policy (MANDATORY) ## External Exposure Policy (MANDATORY)
Every Caddy-proxied service is either behind the auth chain (Anubis :8923 → Every Caddy-proxied service is in exactly one of three postures: behind the
Authentik embedded outpost) or explicitly internal-only — no public DNS name, auth chain (Anubis :8923 → Authentik embedded outpost), **exempt by
no vhost. New services default to internal-only until guarded. 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: 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 Exempt categories (document the reason in the vhost comment): the IdP itself
(`auth.`), git smart-HTTP endpoints, services with their own complete auth. (`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) ## Deletion & Trash Policy (MANDATORY)
Never `rm`. Move deletions into trash so they stay recoverable: Never `rm`. Move deletions into trash so they stay recoverable:
@@ -476,8 +489,8 @@ disagrees with it is the bug.
`scripts/frank-archon.service`, `scripts/ttm-preflight.sh`, template `scripts/frank-archon.service`, `scripts/ttm-preflight.sh`, template
`scripts/qwen38-froggeric-v22.5.jinja`; image digest-pinned `scripts/qwen38-froggeric-v22.5.jinja`; image digest-pinned
`stilldeadcode/radiance@sha256:d590e300…` (radiance 1.8.1); container name = unit name. `stilldeadcode/radiance@sha256:d590e300…` (radiance 1.8.1); container name = unit name.
- Shape: TP=2 over `wht6`, `--max-num-seqs 2`, `--max-model-len 200000`, - Shape: TP=2 over `wht6`, `--max-num-seqs 4`, `--max-model-len 200000`,
`expert_tiered` placement, 44,928 MiB pinned host pool per rank, `fp8` KV, `expert_tiered` placement, 49,152 MiB pinned host pool per rank, `fp8` KV,
MTP depth 2, MNBT 2048, `--busy-prefill-chunk auto`, prefix cache 8 GiB host MTP depth 2, MNBT 2048, `--busy-prefill-chunk auto`, prefix cache 8 GiB host
tier only, n-gram table pinned to RAM. The per-card weights/KV split is derived tier only, n-gram table pinned to RAM. The per-card weights/KV split is derived
by the engine's budget resolver from `--gpu-headroom-mib`, the cache bound and by the engine's budget resolver from `--gpu-headroom-mib`, the cache bound and
+37 -36
View File
@@ -22,13 +22,16 @@
# CHALLENGE everyone else) → Caddy's internal listener http://:8016 (auth # CHALLENGE everyone else) → Caddy's internal listener http://:8016 (auth
# decision; Host headers survive every hop) → authenticated traffic is # decision; Host headers survive every hop) → authenticated traffic is
# forwarded back to http://:8017 and routed straight to the app. # forwarded back to http://:8017 and routed straight to the app.
# Apps with their own auth (Gitea, OpenWebUI, Jellyfin, *arr, Pi-hole) and the # Apps with their own auth (Gitea, OpenWebUI, Jellyfin, Plex, Audiobookshelf,
# Authentik IdP itself (auth.franksdank.cloud — its login flow must never sit # *arr, Pi-hole) and the Authentik IdP itself (auth.franksdank.cloud — its login
# behind a challenge) are exempt by construction: their vhosts never route # flow must never sit behind a challenge) are exempt by construction: their
# through Anubis. ComfyUI left this exempt set on 2026-08-31 and Grafana left # vhosts never route through Anubis. The chain is browser-only by nature — a
# it on 2026-09-01 (OAuth2 relying-party mode against authentik): both now sit # native app client, a podcast RSS reader or a git client solves no JavaScript
# behind the guard chain; Grafana authenticates users itself via the OAuth2 # proof, so anything that must serve one has to own its own authentication.
# code flow, so no identity header is forwarded to it. # ComfyUI left this exempt set on 2026-08-31 and Grafana left it on 2026-09-01
# (OAuth2 relying-party mode against authentik): both now sit behind the guard
# chain; Grafana authenticates users itself via the OAuth2 code flow, so no
# identity header is forwarded to it.
# The pihole vhost keeps its basicauth edge; the hash placeholder below is # The pihole vhost keeps its basicauth edge; the hash placeholder below is
# rendered by deploy.sh from CADDY_BASICAUTH_HASH (host .env, git-ignored) into # rendered by deploy.sh from CADDY_BASICAUTH_HASH (host .env, git-ignored) into
# /home/dank/Containers/caddy/Caddyfile, which the container mounts. # /home/dank/Containers/caddy/Caddyfile, which the container mounts.
@@ -264,21 +267,37 @@ files.franksdank.cloud {
} }
} }
# --- Audiobookshelf (guarded) --- # --- Audiobookshelf (exempt by construction: own auth) --------------------
# Anubis → Authentik → app, like romm/files. The container carries # Caddy → app: no Anubis, no outpost. The guard chain answers a native client's
# ROUTER_BASE_PATH=/audiobookshelf, so this vhost serves the app under that # /ping, /api/login and /socket.io/ handshake with challenge HTML, so behind it
# path; the bare root is redirected onto it in the post-auth listener, never # the phone app parsed a non-object where the socket `init` payload belongs and
# pre-auth. Guard state in the IdP: proxy provider `audiobooks-proxy` # podcast RSS/OPDS feeds broke the same way. ABS authenticates itself (local
# (external_host https://audiobooks.franksdank.cloud/, internal_host # accounts plus authentik OIDC), so it joins jelly/plex/*arr outside the chain
# http://caddy:8017, application "Audiobookshelf Guard"), claimed by the # (2026-11-03); the IdP rows that guarded this name — proxy provider
# embedded outpost. # `audiobooks-proxy` and application "Audiobookshelf Guard" — are retired with
# the change, after the route flips. Accepted exposure: /audiobookshelf/api/login
# is internet-visible with no rate limiter on either side (this Caddy build has
# no rate_limit module; ABS 2.37.2 ships no lockout) — the same class every other
# own-auth vhost here lives with, so shared accounts need long unique passwords.
# ROUTER_BASE_PATH=/audiobookshelf, so the bare root is a convenience hop onto it.
# X-Forwarded-Proto must say https: ABS derives its OIDC origin from
# x-forwarded-proto://Host and this hop is plain http, so without the pin its own
# https callback is a 400 "Invalid callback URL - must be same-origin".
audiobooks.franksdank.cloud { audiobooks.franksdank.cloud {
reverse_proxy anubis:8923 { redir / /audiobookshelf/ permanent
header_up X-Real-Ip {remote_host} reverse_proxy 192.168.0.235:8000 {
header_up X-Forwarded-Proto https
} }
} }
# --- Jellyfin --- # --- Jellyfin (exempt by construction: own auth) --------------------------
# No Anubis, no outpost: Jellyfin carries its own complete login (its own
# account store, no Authentik link), which is the policy's exempt category —
# the same family as plex/ABS/*arr around it. Accepted exposure: the sign-in
# form is internet-visible with no rate limiter on either side (this Caddy
# build ships no rate_limit module), so accounts need long unique passwords.
# The homepage card opened it to the household on 2026-11-03; accounts are
# created in Jellyfin's own admin UI, not by SSO.
jelly.franksdank.cloud { jelly.franksdank.cloud {
reverse_proxy 192.168.0.235:8096 reverse_proxy 192.168.0.235:8096
} }
@@ -424,24 +443,6 @@ http://:8017 {
reverse_proxy 192.168.0.235:3923 reverse_proxy 192.168.0.235:3923
} }
@audiobooks host audiobooks.franksdank.cloud
handle @audiobooks {
# The app answers only under its ROUTER_BASE_PATH; the bare root is a
# convenience hop, taken post-auth so nothing reaches the app
# pre-authentication.
redir / /audiobookshelf/ permanent
# Audiobookshelf validates every OIDC callback against the origin it
# believes it is served from — getRequestOrigin() is
# x-forwarded-proto://Host — and a mismatch is a bodiless 400
# "Invalid callback URL - must be same-origin" before the IdP is ever
# contacted (measured 2026-11-03). This listener is fed over plain http
# by the outpost, so Caddy's own X-Forwarded-Proto here says http; the
# edge for this name is TLS-only, so https is the truthful value.
reverse_proxy 192.168.0.235:8000 {
header_up X-Forwarded-Proto https
}
}
@prowlarr host prowlarr.franksdank.cloud @prowlarr host prowlarr.franksdank.cloud
handle @prowlarr { handle @prowlarr {
reverse_proxy 192.168.0.112:9696 reverse_proxy 192.168.0.112:9696
@@ -47,6 +47,12 @@ Volume=/home/dank/Containers/jellyfin/cache:/cache
# is the guard: started against a plain, unmounted view directory a scan decides # is the guard: started against a plain, unmounted view directory a scan decides
# the library is empty and deletes what it cannot see. # the library is empty and deletes what it cannot see.
Volume=/var/lib/media-meta/view:/data Volume=/var/lib/media-meta/view:/data
# Big-screen theme for the TV. The repo file replaces the container's "Media
# Center" theme slot; the upstream dark palette stays live because the file
# @imports it, so an image bump cannot strand a vendored CSS snapshot. Read-only
# — the web app only fetches it. A client opts in with
# Display Settings -> Theme -> "Media Center".
Volume=/home/dank/serverAppConfigs/dankserver/jellyfin/theme/wmc/theme.css:/jellyfin/jellyfin-web/themes/wmc/theme.css:ro
AddDevice=/dev/dri AddDevice=/dev/dri
GroupAdd=keep-groups GroupAdd=keep-groups
HealthCmd=curl -f http://localhost:8096/health HealthCmd=curl -f http://localhost:8096/health
+53
View File
@@ -0,0 +1,53 @@
/*
* Dank fleet big-screen theme for jellyfin-web.
*
* Deployed by bind-mounting THIS file over the container's "Media Center"
* theme (`/jellyfin/jellyfin-web/themes/wmc/theme.css`) — see the Volume line
* in dankserver/jellyfin/quadlet/jellyfin.container. The slot is deliberately
* one nobody picks by accident, and its name is the only thing that changes:
* the colors are the live upstream dark theme, pulled in by @import below, so
* this file never vendors a snapshot of upstream CSS and cannot drift when the
* Jellyfin image moves.
*
* Why it exists: the living-room client (Jellyfin Desktop on danklivingroom)
* renders this server-served web app at 3840x2160 with no client-side scaling,
* so 14 px body text lands on a big panel viewed from three metres. `zoom` on
* the root element is the one lever that scales the whole UI — the layout code
* measures layout px, so card grids re-flow to fewer, larger columns instead of
* overflowing.
*
* Adoption is per device, not global: Display Settings -> Theme ->
* "Media Center" on the screen that wants it. That is the point — a phone
* browser on the same server must not get a 1.75x interface.
*
* Change the size by editing the single number. 1.5 is mild, 1.75 is the
* three-metre default, 2.0 is for a projector.
*/
@import url("../dark/theme.css");
:root {
/* Marker the e2e gate reads back; also a tweak hook for a per-client
override injected by anyone who really wants one. */
--dank-big-screen: 1;
--dank-big-screen-zoom: 1.75;
}
html {
zoom: var(--dank-big-screen-zoom);
}
/* A remote moves focus in discrete jumps, so the ring has to survive a
three-metre read. */
.emby-focus,
button:focus-visible,
a:focus-visible,
.inputElement:focus-visible {
outline: 3px solid var(--accentColor, #00a4dc) !important;
outline-offset: 2px !important;
}
/* Scrollbars are mouse furniture; on a TV they are noise at this size. */
* {
scrollbar-width: none;
}
+4 -2
View File
@@ -268,8 +268,10 @@ else:
# not auto-launch: the login page keeps offering the local form, which is the # not auto-launch: the login page keeps offering the local form, which is the
# break-glass path when the IdP is unreachable. # break-glass path when the IdP is unreachable.
"authOpenIDAutoLaunch": False, "authOpenIDAutoLaunch": False,
# a household user's first SSO sign-in creates their account; the group claim # a household user's first SSO sign-in creates their account. The role comes from
# decides the role, and a user in neither group is denied (fail closed). # the abs_groups claim, which grants the listener role to every signer; only the
# authentik admin group escalates. Revoking a listener therefore happens HERE in
# ABS (disable or delete the user), never by removing an IdP group membership.
"authOpenIDAutoRegister": True, "authOpenIDAutoRegister": True,
"authOpenIDGroupClaim": "abs_groups", "authOpenIDGroupClaim": "abs_groups",
# The app's own scheme lives HERE, never in the IdP: ABS swaps it for # The app's own scheme lives HERE, never in the IdP: ABS swaps it for
+67 -35
View File
@@ -11,15 +11,17 @@
# dist-server/server/auth/OidcAuthStrategy.js + objects/settings/ServerSettings.js) # dist-server/server/auth/OidcAuthStrategy.js + objects/settings/ServerSettings.js)
# rather than a forum, because each of these is a dead-login trap: # rather than a forum, because each of these is a dead-login trap:
# #
# * The browser callback URI <base>/auth/openid/callback for EVERY origin ABS is # * Two URIs per origin ABS is reached at — <base>/auth/openid/callback and
# reached at, plus <base>/auth/openid/mobile-redirect on ABS_BASE only. ABS SWAPS # <base>/auth/openid/mobile-redirect — for EVERY entry in BASES (the LAN address
# the app's own audiobookshelf://oauth for the mobile-redirect URL before the IdP # and the public vhost). ABS SWAPS the app's own audiobookshelf://oauth for the
# ever sees a redirect_uri, so registering the app scheme with authentik — the # mobile-redirect URL before the IdP ever sees a redirect_uri, so registering the
# obvious move — is what makes phone logins fail. The app scheme belongs in ABS's # app scheme with authentik — the obvious move — is what makes phone logins fail.
# authOpenIDMobileRedirectURIs list (its built-in default), not here. The mobile # The app scheme belongs in ABS's authOpenIDMobileRedirectURIs list (its built-in
# URI is registered on the LAN origin only because the phone is configured with # default), not here. The mobile URI exists on every origin because the app sends
# the LAN address: it is a non-browser client and cannot solve the guard # the one belonging to the door it was configured with: the vhost started serving
# challenge that sits in front of the vhost. # non-browser clients on 2026-11-03 (it left the guard chain), so a phone pointed
# at the public name sends the public mobile-redirect URI, and STRICT matching
# answers an unregistered one with "Redirect URI Error".
# * <base> CARRIES THE BASE PATH. ABS builds redirect_uri as # * <base> CARRIES THE BASE PATH. ABS builds redirect_uri as
# <origin><authOpenIDSubfolderForRedirectURLs>/auth/openid/callback, and that subfolder is # <origin><authOpenIDSubfolderForRedirectURLs>/auth/openid/callback, and that subfolder is
# the ROUTER_BASE_PATH the container is mounted at — /audiobookshelf, which is also the only # the ROUTER_BASE_PATH the container is mounted at — /audiobookshelf, which is also the only
@@ -68,9 +70,12 @@
# ABS_OIDC_CLIENT_ID provider/client id (audiobookshelf) # ABS_OIDC_CLIENT_ID provider/client id (audiobookshelf)
# ABS_OIDC_GROUPS_CLAIM claim ABS reads for roles (abs_groups) # ABS_OIDC_GROUPS_CLAIM claim ABS reads for roles (abs_groups)
# ABS_OIDC_USERNAME authentik user made admin (akadmin) # ABS_OIDC_USERNAME authentik user made admin (akadmin)
# ABS_OIDC_USERS space-separated authentik usernames given the USER group # There is deliberately NO user list. The claim grants the LISTENER role to every
# (default: nobody — an empty list is fail-closed, and the # authentik account that signs in (2026-11-03), so onboarding a listener is exactly
# household decides who listens to audiobooks) # "create the authentik account". Before this, a household account authenticated at
# the IdP and was then refused by ABS for being in no group — a policy decision that
# read as a broken login. Revocation lives on the ABS side (disable or delete the
# user there): a group can no longer withhold a role the claim always sends.
# ABS_E2E_ADMIN authentik user kept in the ADMIN group for the Playwright gate # ABS_E2E_ADMIN authentik user kept in the ADMIN group for the Playwright gate
# (qa-e2e-admin — tests/e2e/specs/23-audiobookshelf-sso.spec.ts # (qa-e2e-admin — tests/e2e/specs/23-audiobookshelf-sso.spec.ts
# signs in as this identity, so a re-run restores the fixture) # signs in as this identity, so a re-run restores the fixture)
@@ -95,53 +100,67 @@ SECRET="${ABS_OIDC_CLIENT_SECRET:?set ABS_OIDC_CLIENT_SECRET (see the header)}"
CLIENT_ID="${ABS_OIDC_CLIENT_ID:-audiobookshelf}" CLIENT_ID="${ABS_OIDC_CLIENT_ID:-audiobookshelf}"
CLAIM="${ABS_OIDC_GROUPS_CLAIM:-abs_groups}" CLAIM="${ABS_OIDC_GROUPS_CLAIM:-abs_groups}"
ADMIN_GROUP="audiobookshelf_admins" ADMIN_GROUP="audiobookshelf_admins"
USER_GROUP="audiobookshelf_users"
SSO_USER="${ABS_OIDC_USERNAME:-akadmin}" SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
MEMBERS="${ABS_OIDC_USERS:-}"
E2E_ADMIN="${ABS_E2E_ADMIN:-qa-e2e-admin}" E2E_ADMIN="${ABS_E2E_ADMIN:-qa-e2e-admin}"
BASE="${ABS_BASE:-http://192.168.0.235:8000/audiobookshelf}" BASE="${ABS_BASE:-http://192.168.0.235:8000/audiobookshelf}"
EXTRA_BASES="${ABS_EXTRA_BASES:-https://audiobooks.franksdank.cloud/audiobookshelf}" EXTRA_BASES="${ABS_EXTRA_BASES:-https://audiobooks.franksdank.cloud/audiobookshelf}"
HOST="${AUTHENTIK_HOST:-dankclaw}" HOST="${AUTHENTIK_HOST:-dankclaw}"
# The claim expression: translate group membership into ABS role names. It is stored # The claim expression: every signer is a listener; only the admin group escalates.
# verbatim so a later read of the provider shows exactly this policy. # Stored verbatim so a later read of the provider shows exactly this policy. The default
# lives HERE and nowhere else — ABS reads this list off the token, so an account in no
# Audiobookshelf group still arrives with ["user"] and is auto-registered as a listener
# instead of being refused after a successful authentication.
read -r -d '' EXPR <<PYEXPR || true read -r -d '' EXPR <<PYEXPR || true
groups = {g.name for g in user.groups.all()} groups = {g.name for g in user.groups.all()}
# Order is deliberate: an admin's claim comes out ["admin","user"], byte-identical to what
# this mapping emitted before the default existed, because ABS's group-tag matching has never
# been measured for order-sensitivity and the working admin login is not the place to find out.
roles = [] roles = []
if "$ADMIN_GROUP" in groups: if "$ADMIN_GROUP" in groups:
roles.append("admin") roles.append("admin")
if groups & {"$ADMIN_GROUP", "$USER_GROUP"}:
roles.append("user") roles.append("user")
return {"$CLAIM": roles} return {"$CLAIM": roles}
PYEXPR PYEXPR
# NOTE: the heredoc below is UNQUOTED on purpose — its body interpolates the variables
# above. The consequence is that a backtick anywhere in that python body is a LOCAL command
# substitution whose output is spliced into the program before it ever runs, and comments
# are not exempt, because bash does not know what a python comment is. Write names plainly
# in the body: measured 2026-11-03, two comments in this body had the shell run
# audiobookshelf_users and user as commands on every execution of this script.
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
from authentik.core.models import Application, Group, User from authentik.core.models import Application, Group, User
from authentik.flows.models import Flow from authentik.flows.models import Flow
from authentik.providers.oauth2.models import ( from authentik.providers.oauth2.models import (
ClientType, OAuth2Provider, RedirectURI, RedirectURIMatchingMode, RedirectURIType, ScopeMapping) ClientType, OAuth2Provider, RedirectURI, RedirectURIMatchingMode, RedirectURIType, ScopeMapping)
ADMIN_GROUP, USER_GROUP = "$ADMIN_GROUP", "$USER_GROUP" ADMIN_GROUP = "$ADMIN_GROUP"
CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE" CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE"
BASES = [BASE] + """$EXTRA_BASES""".split() BASES = [BASE] + """$EXTRA_BASES""".split()
SSO_USER, E2E_ADMIN = "$SSO_USER", "$E2E_ADMIN" SSO_USER, E2E_ADMIN = "$SSO_USER", "$E2E_ADMIN"
MEMBERS = """$MEMBERS""".split()
EXPR = '''$EXPR''' EXPR = '''$EXPR'''
# The expression reads user.groups, NOT user.ak_groups: authentik 2026.8 logs # The expression reads user.groups, NOT user.ak_groups: authentik 2026.8 logs
# "User.ak_groups is deprecated … use User.groups" on every evaluation of the old # "User.ak_groups is deprecated … use User.groups" on every evaluation of the old
# name (measured on this run), and a stored mapping that leans on a deprecated # name (measured on this run), and a stored mapping that leans on a deprecated
# accessor is a future upgrade failure sitting inside the login path. # accessor is a future upgrade failure sitting inside the login path.
for name in (ADMIN_GROUP, USER_GROUP): Group.objects.get_or_create(name=ADMIN_GROUP)
Group.objects.get_or_create(name=name) admin_g = Group.objects.get(name=ADMIN_GROUP)
admin_g, user_g = Group.objects.get(name=ADMIN_GROUP), Group.objects.get(name=USER_GROUP) # The audiobookshelf_users group is retired. Its membership used to be the only door to the
# listener role; the claim now sends that role to every signer, so the group decides
# nothing — and a group that decides nothing is a trap for the next reader, who will
# assume it gates something and start adding people to it.
for stale in Group.objects.filter(name="audiobookshelf_users"):
stale.delete()
print("group: retired audiobookshelf_users (the claim grants every signer)")
# --- the claim mapping (scope_name IS the claim name ABS requests) --------------- # --- the claim mapping (scope_name IS the claim name ABS requests) ---------------
mapping = ScopeMapping.objects.filter(name="audiobookshelf-groups").first() mapping = ScopeMapping.objects.filter(name="audiobookshelf-groups").first()
if mapping is None: if mapping is None:
mapping = ScopeMapping.objects.create(name="audiobookshelf-groups", scope_name=CLAIM, mapping = ScopeMapping.objects.create(name="audiobookshelf-groups", scope_name=CLAIM,
expression=EXPR, expression=EXPR,
description="Audiobookshelf role names from group membership") description="Audiobookshelf roles: every signer is a listener, the admin group escalates")
print("mapping: created (scope_name %s)" % CLAIM) print("mapping: created (scope_name %s)" % CLAIM)
elif mapping.scope_name != CLAIM or mapping.expression != EXPR: elif mapping.scope_name != CLAIM or mapping.expression != EXPR:
mapping.scope_name, mapping.expression = CLAIM, EXPR mapping.scope_name, mapping.expression = CLAIM, EXPR
@@ -151,18 +170,18 @@ else:
print("mapping: already current") print("mapping: already current")
# Prove the expression runs and yields an ABS role BEFORE anyone tries to log in: # Prove the expression runs and yields an ABS role BEFORE anyone tries to log in:
# authentik execs the expression inside a function whose context has `user`. # authentik execs the expression inside a function whose context has the name "user".
_src = "def _probe(user, request=None):\n" + "".join(" %s\n" % ln for ln in EXPR.splitlines()) _src = "def _probe(user, request=None):\n" + "".join(" %s\n" % ln for ln in EXPR.splitlines())
_ns = {} _ns = {}
exec(_src, _ns) exec(_src, _ns)
# --- provider -------------------------------------------------------------------- # --- provider --------------------------------------------------------------------
# One browser callback per origin (LAN address and the guarded vhost), because ABS # One callback pair per origin (LAN address and the public vhost): ABS derives the
# derives redirect_uri from the request origin; the mobile URI stays on the LAN origin. # browser callback from the request origin and the mobile callback from the door the
uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, # app was configured with, so each origin needs both or that door cannot sign in.
url="%s/auth/openid/callback" % b) for b in BASES] uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, url="%s%s" % (b, p))
uris.append(RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, for b in BASES
url="%s/auth/openid/mobile-redirect" % BASE)) for p in ("/auth/openid/callback", "/auth/openid/mobile-redirect")]
authz_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent") authz_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
# Three things beyond the name, each one a silent dead login: the grant-type list (2026.8 # Three things beyond the name, each one a silent dead login: the grant-type list (2026.8
@@ -238,13 +257,26 @@ else:
e2e.groups.add(admin_g) e2e.groups.add(admin_g)
print("e2e fixture: %s in %s" % (E2E_ADMIN, ADMIN_GROUP)) print("e2e fixture: %s in %s" % (E2E_ADMIN, ADMIN_GROUP))
print("admin: %s in %s" % (SSO_USER, ADMIN_GROUP)) print("admin: %s in %s" % (SSO_USER, ADMIN_GROUP))
for name in MEMBERS: # The default is printed at provision time, not merely asserted: an expression that regressed
u = User.objects.get(username=name) # to returning nothing for a non-member refuses every household login, and this is where a
u.groups.add(user_g) # re-run notices it. The population matters — authentik ships an AnonymousUser row, a
print("users: %s in %s" % (", ".join(MEMBERS) or "(none — fail closed)", USER_GROUP)) # superuser, and ak-outpost-* service identities, and proving the default on one of those says
# nothing about the friend who logs in. Prefer the fleet's plain-user marker group (owned by
# serverAppConfigs-docs/Infrastructure/Authentik.md; a preference only, since that group
# belongs to other consumers), which normally lands on qa-e2e — the identity the e2e gate
# signs in as. is_superuser is a python property on authentik's User, not a queryable field.
pool = User.objects.filter(is_active=True).exclude(groups__name__in=[ADMIN_GROUP])
pool = pool.exclude(username__in=[root.username, E2E_ADMIN, "AnonymousUser"])
pool = [u for u in pool.order_by("username")
if not u.is_superuser and not u.username.startswith("ak-outpost-")]
household = [u for u in pool if any(g.name == "Non-Admin Users" for g in u.groups.all())]
for u in (household or pool)[:3]:
print("default probe: %s %s -> %s" % (
u.username, sorted(g.name for g in u.groups.all()),
_ns["_probe"](user=u, request=None)))
# --- verification: the claim the callback will actually receive --------------------- # --- verification: the claim the callback will actually receive ---------------------
for probe in [root] + ([e2e] if e2e else []) + [User.objects.get(username=n) for n in MEMBERS]: for probe in [root] + ([e2e] if e2e else []):
print("claim for %s -> %s" % (probe.username, _ns["_probe"](user=probe, request=None))) print("claim for %s -> %s" % (probe.username, _ns["_probe"](user=probe, request=None)))
p.refresh_from_db() p.refresh_from_db()
print("provider %s | client_id %s | flow %s | grants %s | uris %s" % ( print("provider %s | client_id %s | flow %s | grants %s | uris %s" % (
@@ -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. | | 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 shared chat template maps to the inference service on the selected target.
The `frank-npu` endpoint is retired () and has no deploy handler The `frank-npu` endpoint is retired and has no deploy handler, so an unrelated
an unrelated large-model deployment never touches it. large-model deployment never touches it.
## Caddy safety ## Caddy safety
@@ -1,6 +1,6 @@
--- ---
type: reference 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: tags:
- dankserver - dankserver
- audiobooks - audiobooks
@@ -17,7 +17,7 @@ server — it shares nothing with [[Plex]] or [[Jellyfin]] and owns no files on
array. array.
**Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user manager) — **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 ## 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 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). 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, 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 never `/healthcheck`. It also reads files that are genuinely broken and logs
`[AudioFileScanner] Invalid data found when processing input` per file — that is `[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 ## Phone app
The official **Audiobookshelf** app connects with Server Address The official **Audiobookshelf** app connects with Server Address
`http://192.168.0.235:8000/audiobookshelf` — the base path belongs in the address — username `https://audiobooks.franksdank.cloud/audiobookshelf` — the base path belongs in the address —
`frank`, and the same password as the web UI, or the SSO button; plain HTTP. Measured from a username `frank`, and the same password as the web UI, or the SSO button. One address works
second host 2026-11-02 and re-measured on both base paths: `/ping` (`{"success":true}`) and at home and away: on the LAN Pi-hole answers that name with `192.168.0.143` and Caddy dials
`/healthcheck` (`OK`) answer at the ROOT whatever `ROUTER_BASE_PATH` says, so the app's the container; off-LAN the same name resolves to the WAN address and takes the same route
connectivity probe works before it resolves the base path at all, and `iptables -S INPUT` (inbound 443 is forwarded at the router — fetched from a datacenter node 2026-11-03). The raw
carries no rule for 8000 — a phone on `192.168.0.0/24` connects with no further setup, and LAN address still answers and is the break-glass door whenever Caddy is down. `/ping`
`download: true` on the root account is what makes offline listening work. Away (`{"success":true}`) and `/healthcheck` (`OK`) answer at the ROOT whatever `ROUTER_BASE_PATH`
from the LAN, see the two-doors law in *Laws* — the guarded vhost is not the remedy. 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 **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
@@ -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` `Authorization: Bearer ` with nothing after it, and every `/api/*` call then answers `401`
as though the password were wrong. 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) ## Single sign-on (authentik)
Both doors take the household identity: the web UI puts **Sign in with Authentik** beside Both doors take the household identity: the web UI puts **Sign in with Authentik** beside
@@ -93,19 +136,27 @@ the local form, and the phone app offers SSO on its server-address screen. The l
| Piece | Owner | | 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) | | 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 | | 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) | | 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, With `authOpenIDAutoRegister` on, a first sign-in creates the account and the claim alone
a first sign-in creates the account, and the claim alone decides the type. A user in decides its type — and the claim always carries `user`, so **the IdP is now the library gate**:
neither group is refused after a successful authentication — that is the mapping working, any authentik account that can reach this application becomes a listener. That is the
not a broken login. 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*: a sign-in as `qa-e2e-admin` that must land inside the app with site-admin rights. 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 ## Laws
@@ -195,13 +246,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 - **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]]).
- **Two doors, one app.** `http://192.168.0.235:8000/audiobookshelf` is the raw - **One door for every client, and it sits outside the guard chain on purpose.**
LAN address — plain HTTP, nothing in front, what the phone app is configured `https://audiobooks.franksdank.cloud/audiobookshelf` is what browsers and apps use;
with; `https://audiobooks.franksdank.cloud/audiobookshelf/` is the guarded vhost `http://192.168.0.235:8000/audiobookshelf` is the raw LAN socket that same Caddy dials,
(Caddy → Anubis → the Authentik outpost → Caddy `:8017` → this container), so it doubles as break-glass when the proxy is down. Behind the chain (until
the house chain for browser traffic — [[Caddy]] *Guard a new vhost*. That 2026-11-03) the vhost answered the phone app's `/ping` and `/socket.io/` handshake with
chain serves browsers only: the phone app is a non-browser client and solves Anubis' challenge HTML — Anubis demands a JavaScript proof and the outpost needs a
no challenge, so remote listening wants a VPN into the LAN, not the vhost. 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` - **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
@@ -287,13 +342,23 @@ bootstrapper against a stub of the API above: create-once, idempotent re-run, no
URL, refusal by URL when the server does not answer, the deployer calling it only after the 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); 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); `tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may reference the metadata view);
and the live Playwright gate `tests/e2e/specs/23-audiobookshelf-sso.spec.ts`, run by and the live Playwright gates `tests/e2e/specs/23-audiobookshelf-sso.spec.ts` and
`tests/e2e/run.sh` at the end of every `deploy.sh` — the `admin` project signs in as `tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts`, run by `tests/e2e/run.sh` at
`qa-e2e-admin` against `http://192.168.0.235:8000/audiobookshelf` and asserts the two things the end of every `deploy.sh`. `23-` (the `admin` project) signs in as `qa-e2e-admin` against
no stub can see: every `.js` the app loads arrives as JavaScript (a bundle built for the wrong `http://192.168.0.235:8000/audiobookshelf` and asserts the two things no stub can see: every
base path arrives as `text/html`, which boots nothing), and `/api/me` answers 200 with `.js` the app loads arrives as JavaScript (a bundle built for the wrong base path arrives as
`type: "admin"` — true only while grant types, the OpenID scope mappings and the group claim `text/html`, which boots nothing), and `/api/me` answers 200 with `type: "admin"` — true only
all hold together. 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: 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, 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 title: Jellyfin
description: "Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status." description: "Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status."
tags: [jellyfin, dankserver, media-server] tags: [jellyfin, dankserver, media-server]
timestamp: 2026-11-02T11:00:00Z timestamp: 2026-11-03T11:40:00Z
--- ---
# Jellyfin # 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 collapses `\"` to `"`, curl ends the quoted value at that quote, and Jellyfin
receives `Authorization: MediaBrowser Token=` with nothing after it (`\\"` answers receives `Authorization: MediaBrowser Token=` with nothing after it (`\\"` answers
204, `\"` answers 401 — both run on this host). 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 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 `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 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. 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 ## See Also
- [[Plex]] — co-resident media server on the same volume - [[Plex]] — co-resident media server on the same volume
+50 -3
View File
@@ -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. 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 ## 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.providers.proxy.models import ProxyProvider
from authentik.core.models import Application from authentik.core.models import Application
from authentik.outposts.models import Outpost 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() pp = ProxyProvider.objects.filter(name=f"{NAME}-proxy").first()
if pp is None: if pp is None:
pp = ProxyProvider.objects.create(name=f"{NAME}-proxy", external_host=EXT, 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 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 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 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]]). https` is the truthful value ([[Audiobookshelf]]).
3. **Then the name and the audit.** A `dankclaw/pihole/addn-hosts` line (the `pihole` 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 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 `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. 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 ## Operations
Validate before a reload: Validate before a reload:
@@ -316,7 +316,7 @@ flowchart TD
**User services** (root page), in section order: **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). - **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. - **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: **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]]. - **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. - **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. - **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 ## 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 **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 `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 (`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 20 `identity` with ATS capability; interrupt remapping is enabled. The flip moved
boot ledger is unchanged to the MiB — 62.9 GiB weights (0.8 of them vision) + 5.5 frank-dragoon's boot ledger by nothing measurable — its shape and every number in it are
GiB KV + 5.7 GiB working = 74.1 GiB, two slots over ONE 200,704-position pool, MTP owned by [[frank-dragoon]] — and Gitea, the Caddy edge and Grafana all answered 200.
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]].)
**The NPU came back with the flip.** `amdxdna` bound to `0000:c6:00.1` (Strix/ **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 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 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` 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` + is in `render`, so a quadlet needs `AddDevice=/dev/accel/accel0` +
`GroupAdd=keep-groups`. **No NPU endpoint is deployed** — stays the `GroupAdd=keep-groups`. The flip brought the device back, not a service: no NPU chat
history of the retired endpoint; what changed is only its precondition. 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 **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 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 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 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 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). 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. 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 | | Page | Owns |
|---|---| |---|---|
| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, guarded vhost, 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]] | 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. |
-35
View File
@@ -1,35 +0,0 @@
{
"host": "dankserver",
"services": [
{
"name": "Homepage",
"url": "/",
"description": "This page. Password-protected service index.",
"docs": "serverAppConfigs-docs/Infrastructure/Homepage.md"
},
{
"name": "RomM",
"url": "http://romm.franksdank.cloud",
"description": "Retro game manager and player for the ROM collection.",
"docs": "serverAppConfigs-docs/Dankserver/RomM.md"
},
{
"name": "Copyparty",
"url": "http://files.franksdank.cloud",
"description": "High-speed file sharing and streaming server.",
"docs": "serverAppConfigs-docs/Dankserver/Copyparty.md"
},
{
"name": "Calibre-Web",
"url": "http://books.franksdank.cloud",
"description": "E-book library browser and reader.",
"docs": "serverAppConfigs-docs/Dankserver/Calibre-Web.md"
},
{
"name": "Plex",
"url": "http://plex.franksdank.cloud/web/index.html",
"description": "Media server (existing host service, proxied).",
"docs": "serverAppConfigs-docs/Dankserver/Dankserver Stack.md"
}
]
}
+367 -152
View File
@@ -17,174 +17,365 @@
</header> </header>
<main class="docs"> <main class="docs">
<section id="getting-in"> <h1>The household guide</h1>
<h1>Getting in</h1> <p class="sub">Everything running on the computers in this house, what it is for,
<p>Everything here lives behind two doors. The first is <strong>Anubis</strong>, a and which door to knock on. Written for people who did not build it.</p>
quiet bot-check: the first time you visit any guarded page in a session you see a
short "making sure you're not a bot" screen. It runs once. The second door is <section id="start">
<strong>Authentik</strong>, the family single sign-on: log in once and every <h2>Start here</h2>
guarded service (this homepage, search, chat, media requests, and friends) opens <p class="kicker">Three steps and you are in</p>
for you without a second password.</p> <p><strong>1. Open the front door:</strong>
<a href="https://www.franksdank.cloud">https://www.franksdank.cloud</a><br>
It works from anywhere &mdash; phone data, a hotel, a friend's house. The first page you
see is a robot check: your browser does a few seconds of arithmetic so the internet's
scanners can't wander in. Then type the family username and password Frank gave you.
There is no signup button on purpose.</p>
<p><strong>2. Pick what you want to do:</strong></p>
<table>
<tr><th>I want to&hellip;</th><th>Open</th><th>Needs its own login?</th></tr>
<tr><td>watch a film or a show</td><td><a href="https://jelly.franksdank.cloud">Jellyfin</a></td><td>yes &mdash; ask Frank</td></tr>
<tr><td>watch it on a TV that already has Plex</td><td><a href="https://plex.franksdank.cloud/web">Plex</a></td><td>yes &mdash; a Plex account, Frank invites it</td></tr>
<tr><td>listen to an audiobook</td><td><a href="https://audiobooks.franksdank.cloud/audiobookshelf">Audiobookshelf</a></td><td>no &mdash; the family password</td></tr>
<tr><td>ask a computer a question, privately</td><td><a href="https://ai.franksdank.cloud">AI chat</a></td><td>no</td></tr>
<tr><td>search the web without being followed</td><td><a href="https://search.franksdank.cloud">Search</a></td><td>no</td></tr>
<tr><td>read a news article without the junk</td><td><a href="https://marreta.franksdank.cloud">Marreta</a></td><td>no</td></tr>
<tr><td>grab a file off the big disk</td><td><a href="https://files.franksdank.cloud">Files</a></td><td>no (read-only)</td></tr>
<tr><td>ebooks, retro games, request a film</td><td>not open to the household yet &mdash; <a href="#notopen">see below</a></td><td>&mdash;</td></tr>
</table>
<p><strong>3. On your phone,</strong> open the front door in Safari or Chrome and choose
<em>Add to Home Screen</em>. You get an icon that behaves like an app. Details in
<a href="#phone">On your phone</a>.</p>
<figure>
<img src="/img/onboarding/homepage.webp" alt="The homepage: one tile per service, each with a short description">
<figcaption>The front door itself: one tile per thing the house runs, and a line about what
each is for. The badges say whether it's awake.</figcaption>
</figure>
<div class="toc">
<a href="#doors">The two doors</a>
<a href="#phone">On your phone</a>
<a href="#jellyfin">Jellyfin</a>
<a href="#plex">Plex</a>
<a href="#audiobookshelf">Audiobookshelf</a>
<a href="#notopen">Not open yet</a>
<a href="#files">Files</a>
<a href="#search">Search</a>
<a href="#marreta">Read an article</a>
<a href="#ai">AI chat</a>
<a href="#gitea">Gitea</a>
<a href="#models">The models</a>
<a href="#trouble">When something misbehaves</a>
</div>
</section>
<section id="doors">
<h2>The two doors</h2>
<p>Almost everything here is behind two checks, one after the other.</p>
<ul> <ul>
<li>Need an account? Ask Frank. Accounts are issued by hand; there is no signup.</li> <li><strong>Anubis</strong> is the bouncer. It makes a browser prove it is a browser
<li>Your clearance lives in a cookie. Clearing cookies means re-clearing both doors.</li> (that robot-check page) and stops the automatic scanners that comb the internet for
<li>If a page asks you to log in repeatedly, that is a server-side problem — tell Frank, don't brute-force it.</li> open servers. It never asks for a password.</li>
<li><strong>Authentik</strong> is the front desk. It asks who you are, once per device
per few days, and after that you drift from app to app without being stopped again.</li>
</ul>
<p>One account opens: the homepage, search, Marreta, the AI chat, Files, Gitea.</p>
<figure>
<img src="/img/onboarding/door-authentik.webp" alt="The sign-in screen: an identification field, then a password field">
<figcaption>The front desk. Your username first, then your password. Nothing here signs
you up for anything; if you don't have an account yet, ask Frank.</figcaption>
</figure>
<p class="key-warn">Three doors keep their own accounts and will not take the family
password: <strong>Jellyfin</strong>, <strong>Plex</strong> (a Plex account, its own e-mail
login) and the e-book door. Frank creates those by hand.</p>
<p class="foot-note">Want your own password? It lives at
<a href="https://auth.franksdank.cloud/if/flow/default-user-settings-flow/">account settings</a>
&mdash; the same button you get at the bottom of the homepage.</p>
</section>
<section id="phone">
<h2>On your phone</h2>
<p><strong>Get the icon.</strong> In the browser: <em>Share</em> (iPhone) or the
<em>&#8942;</em> menu (Android) &rarr; <em>Add to Home Screen</em>. Same trick on a tablet.
There is no app store anything to install for the household doors; they are web pages
wearing a good coat.</p>
<figure>
<img src="/img/onboarding/phone-homepage.webp" alt="The homepage on a phone, one row per service">
<figcaption>The front door on a 5-inch screen. Same tiles, same links, no app.</figcaption>
</figure>
<p><strong>Apps that do have a real client</strong> (better than the web page on a phone):</p>
<table>
<tr><th>What</th><th>Where</th><th>Server address to type</th></tr>
<tr><td>Jellyfin</td><td>Google Play &amp; the App Store (also Fire TV, Roku, most smart TVs)</td><td><code>https://jelly.franksdank.cloud</code></td></tr>
<tr><td>Plex</td><td>on nearly every TV ever sold</td><td>nothing &mdash; sign in to your Plex account</td></tr>
<tr><td>Audiobookshelf</td><td>Google Play (Android, solid). The iPhone build is a beta with a limited number of seats &mdash; use the browser, or SoundLeaf / ShelfPlayer</td><td><code>https://audiobooks.franksdank.cloud/audiobookshelf</code></td></tr>
</table>
<p><strong>The one address that only works at home.</strong> The raw file shelf also speaks
WebDAV on <code>http://192.168.0.235:3923/</code> &mdash; that's what a real file-manager app
mounts. Two catches, both measured: it is <em>http</em>, not https (the phone will warn you;
it is fine on the house Wi-Fi), and it answers only on the house network. From elsewhere the
public door hands a file app the robot-check page instead of files, because a file app can't
solve it. Off-site, use the browser.</p>
<ul>
<li>iPhone: the built-in Files app does <em>not</em> do WebDAV. <em>FileBrowser</em> or
<em>Owlfiles</em> do.</li>
<li>Android: <em>Cx File Explorer</em>, <em>Material Files</em>, <em>Solid Explorer</em> all do.</li>
</ul> </ul>
</section> </section>
<section id="troubleshooting"> <section id="jellyfin">
<h1>When something looks broken</h1> <h2>Jellyfin &mdash; films and TV</h2>
<p><a href="https://jelly.franksdank.cloud">jelly.franksdank.cloud</a> &mdash; free, no
account of any company's, and the posters and episode guides are all filled in.</p>
<h3>Getting in</h3>
<ul> <ul>
<li><strong>A status card says DOWN.</strong> Reload the page once; the checker <li>Pick your name. There is no list of names on the login screen &mdash; type yours.</li>
re-probes every few minutes. If it stays down, that service really is unhappy — tell Frank.</li> <li>Type the password Frank set for you. <strong>It is not the family password</strong>;
<li><strong>"status unavailable" on the homepage.</strong> The page couldn't read this is the most common wrong door.</li>
its status file. Hard-reload (Ctrl/Cmd-Shift-R).</li> <li>No name yet? Ask Frank to make one. There is no self-service signup, on purpose.</li>
<li><strong>Connection timeout on any device.</strong> Our names only resolve </ul>
on the home network (or the VPN). If you are on guest Wi-Fi, a phone hotspot, <figure>
or out of the house, the pages will not load — that is the security working. <img src="/img/onboarding/jellyfin-login.webp" alt="The Jellyfin login screen: a username field and a password field">
Connect to the house network and retry.</li> <figcaption>The empty login. Username, password, <em>Sign in</em>. Nothing to register.</figcaption>
<li><strong>The bot-check spins forever.</strong> Let it finish; heavy JS blocking </figure>
(privacy extensions) can starve it. Pause the blocker, reload, resume.</li> <h3>The first ten minutes</h3>
<ul>
<li>Home shows <em>Continue watching</em> and what's new. Press a poster; it starts.</li>
<li>The <strong>&#8942;</strong> menu on a poster is where the useful things live: mark
watched, add to a playlist, download it to the device.</li>
<li>On a TV the app asks for a <em>Server address</em>: <code>https://jelly.franksdank.cloud</code>.</li>
<li>Settings &rarr; theme: the big-screen look is installed for the living-room box.
Themes are per device, so change it only on the one you're sitting in front of.</li>
</ul>
<h3>If it misbehaves</h3>
<ul>
<li>Wrong password and no error you understand? That's the family password again.</li>
<li>A film with a blank poster is missing artwork, not missing files &mdash; Frank can refresh it.</li>
<li>Buffering away from home usually means the pipe out of the house, not the server.</li>
</ul> </ul>
</section> </section>
<section id="media"> <section id="plex">
<h1>Media &amp; files</h1> <h2>Plex &mdash; the same shelf, different shop window</h2>
<p><a href="https://plex.franksdank.cloud/web">plex.franksdank.cloud/web</a> bounces you to
Plex's own sign-in, and that is correct: Plex is closed-source and insists on a Plex account.</p>
<figure>
<img src="/img/onboarding/plex-login.webp" alt="Plex's own sign-in page on plex.tv">
<figcaption>Plex keeps its own front desk. You need a Plex account (an e-mail address is
enough); Frank invites it into the library.</figcaption>
</figure>
<ul> <ul>
<li><strong>Movies &amp; TV:</strong> request with <a href="https://seerr.franksdank.cloud">Seerr</a>; <li>Jellyfin and Plex read <strong>the same shelf of files</strong>. The same films are in
watch with <a href="https://plex.franksdank.cloud/web/index.html">Plex</a> or both; the artwork, watch-state and profiles are separate.</li>
<a href="https://jelly.franksdank.cloud">Jellyfin</a>. Seerr is the front door — <li>Choose Plex when your TV already has the Plex app on it, or you want Plex's trailers
requests ride the VPN tunnel from there.</li> and music. Otherwise Jellyfin is the one the household uses.</li>
<li><strong>Books:</strong> <a href="https://books.franksdank.cloud">Calibre-Web</a> — the e-book library, with its own login.</li> <li>Ask Frank for an invitation; he can also tell you which of the two has the thing you're looking for.</li>
<li><strong>Games:</strong> <a href="https://romm.franksdank.cloud">RomM</a> — ROM library and emulator collection.</li>
<li><strong>Raw files:</strong> <a href="https://files.franksdank.cloud">Copyparty</a> — direct downloads and shares.</li>
<li><strong>Search:</strong> <a href="https://search.franksdank.cloud">SearXNG</a> — metasearch, VPN-routed, no AI snoop. <details class="key-warn"><summary>&#9888; privacy note</summary><div class="warn-body">Searches are obfuscated, not anonymous: SearXNG hides you from the upstream engines and the VPN tunnel hides the traffic from the LAN &mdash; but the upstream search API keys are tied to Frank&rsquo;s identity. Don&rsquo;t treat it as Tor.</div></details></li>
</ul> </ul>
</section> </section>
<section id="chat"> <section id="audiobookshelf">
<h1>Talking to the AI</h1> <h2>Audiobookshelf &mdash; audiobooks and podcasts</h2>
<p>The everyday front door is <a href="https://ai.franksdank.cloud">Open&nbsp;WebUI</a>, <p><a href="https://audiobooks.franksdank.cloud/audiobookshelf">audiobooks.franksdank.cloud/audiobookshelf</a>
the family chat app. Open the address, click <strong>Continue with authentik</strong>, &mdash; the family password works here.</p>
and use the same single sign-on as everything else — no separate password. Your <figure>
conversations are saved to your account, so history follows you between devices.</p> <img src="/img/onboarding/audiobookshelf-login.webp" alt="The Audiobookshelf login page with username and password fields">
<img src="img/openwebui-chat.png" alt="Open WebUI chat screen with a question and the AI's answer"> <figcaption>Two ways in: the password Frank gave you, or <em>Sign in with Authentik</em>,
<p>Below the text box, the model box on the right chooses which model answers you. which is the same family account.</figcaption>
Family accounts get <code>frank-zealot</code> — the fleet's fast all-rounder, </figure>
which also sees images: drag a photo or screenshot straight into the chat box. <ul>
Frank's account can additionally reach the bigger models.</p> <li>Your place in every book follows you between phone, laptop and the kitchen tablet.</li>
<img src="img/openwebui-model-picker.png" alt="The Open WebUI welcome screen with the family model selected in the model box"> <li>On the phone the Android app is the best version: it will do <em>downloads for offline
<p>Open WebUI keeps your chat history; the raw model endpoints (used by developer listening</em>. A web page can't play offline; if you need it for a flight, use an app.</li>
tools, see <a href="#api-access">API access</a>) do not.</p> <li>iPhone: the official build is a TestFlight beta with a small number of seats, so either
use the browser + <em>Add to Home Screen</em>, or a third-party client
(<em>SoundLeaf</em>, <em>ShelfPlayer</em>).</li>
<li>Adding the server in any app: <code>https://audiobooks.franksdank.cloud/audiobookshelf</code>.</li>
</ul>
<figure>
<img src="/img/onboarding/audiobookshelf-library.webp" alt="An empty audiobook library with a scan button">
<figcaption>An empty library. Books appear when Frank scans, and then they're yours to keep playing.</figcaption>
</figure>
</section>
<section id="notopen">
<h2>Three doors that are not open to the household yet</h2>
<ul>
<li><strong>Calibre-Web</strong> (e-books) &mdash;
<a href="https://books.franksdank.cloud">books.franksdank.cloud</a>. Its own login, and
Frank hasn't decided who gets in. Ask.</li>
<li><strong>RomM</strong> (retro games, plays them in the browser) &mdash;
<a href="https://romm.franksdank.cloud">romm.franksdank.cloud</a>. Needs its own account,
and it is not open to family users yet. Ask.</li>
<li><strong>Seerr</strong> (the "would you watch this?" request box) &mdash;
<a href="https://seerr.franksdank.cloud">seerr.franksdank.cloud</a>. Not open yet: tell
Frank what you want to watch and he'll add it.</li>
</ul>
<figure>
<img src="/img/onboarding/calibre-login.webp" alt="The Calibre-Web login page">
<figcaption>What a closed door looks like: a login that wants credentials of its own.</figcaption>
</figure>
</section>
<section id="files">
<h2>Files &mdash; the raw shelf</h2>
<p><a href="https://files.franksdank.cloud">files.franksdank.cloud</a> is the honest view of
the big disk: folders, dates, sizes, no cleverness. <strong>Read-only</strong> for the
household &mdash; you can take things out, nothing you do here deletes anything.</p>
<figure>
<img src="/img/onboarding/copyparty.webp" alt="A folder listing with sizes, dates and a download link on each row">
<figcaption>Each row has a download link; the <em>zip</em> link at the bottom takes the
whole folder as one archive. <em>control-panel</em> and the <em>up</em> link navigate.</figcaption>
</figure>
<ul>
<li>Good for: a photo you want the original of, a document, a game ROM, a song to put on another device.</li>
<li>Bad for: watching video (the player is the wrong tool &mdash; use Jellyfin) and for
saving your own work (it can't write).</li>
<li>Works in any browser, on any machine, from anywhere.</li>
</ul>
</section>
<section id="search">
<h2>Search &mdash; SearXNG</h2>
<p><a href="https://search.franksdank.cloud">search.franksdank.cloud</a> asks a lot of search
engines at once and merges the answers, then leaves no trail behind. Outbound requests ride
the house VPN.</p>
<figure>
<img src="/img/onboarding/search-results.webp" alt="Search results merged from many engines, with engine checkboxes on the left">
<figcaption>One question, many engines. The boxes on the left choose which engines answer.</figcaption>
</figure>
<ul>
<li>Make it your browser's search engine: browser settings &rarr; search &rarr; add a custom
engine with <code>https://search.franksdank.cloud/search?q=%s</code>.</li>
<li>On a phone there is no app; bookmark it or put the homepage on your home screen.</li>
</ul>
<figure>
<img src="/img/onboarding/phone-search.webp" alt="A search result page on a phone">
<figcaption>Same results, thumb-sized.</figcaption>
</figure>
<details class="key-warn">
<summary>The privacy catch worth knowing</summary>
<div class="warn-body">
<p>This hides your question from Google, Bing and the rest &mdash; it does not hide it from
<em>this house</em>. Queries are logged on this server so Frank can tell a broken search
from a slow one. And because results are fetched from here, the search engines see one
house address, not your phone: your habits don't get stitched to your face. If you need a
genuinely anonymous search, that's Tor or a paid service, not this.</p>
</div>
</details>
</section>
<section id="marreta">
<h2>Read an article without the junk &mdash; Marreta</h2>
<p><a href="https://marreta.franksdank.cloud">marreta.franksdank.cloud</a>. Paste a link, get
the article: no cookie banner, no video autoplay, no sixteen sidebars. It also runs an RSS
reader once Frank feeds it news.</p>
<figure>
<img src="/img/onboarding/marreta.webp" alt="Marreta's empty home page with a URL box and an Add as app hint">
<figcaption>Paste the link. The app itself offers <em>Add as app</em> in its menu, which is
the cleanest way to keep it on a phone.</figcaption>
</figure>
<ul>
<li>Some pages with hard paywalls still come back thin. Nothing in the house can fix that.</li>
<li>A white screen after pasting? Pause your ad blocker for this site &mdash; the app warns
about exactly that.</li>
</ul>
</section>
<section id="ai">
<h2>AI chat &mdash; Open WebUI</h2>
<p><a href="https://ai.franksdank.cloud">ai.franksdank.cloud</a>. The family password, or
<em>Continue with authentik</em> on the login card, which is the same thing.</p>
<figure>
<img src="/img/onboarding/openwebui-login.webp" alt="The Open WebUI welcome card with a Continue with authentik button">
<figcaption>Press <em>Continue with authentik</em> and the family login does the rest.</figcaption>
</figure>
<ul>
<li>Nothing you type here goes to a company's server. It goes to the computers in this house.</li>
<li>Install it as an app: in Chrome or Edge, the menu &rarr; <em>Install site as an app</em>;
in Safari, share &rarr; <em>Add to Home Screen</em>.</li>
<li>Conversations are kept per account, so your history is yours and Frank's is Frank's.</li>
</ul>
<h3>Which model do I pick?</h3>
<p>You are offered all three, and the app opens on <code>frank-dragoon</code>. Leave it there
unless you have a reason. Short version (longer version in <a href="#models">The models</a>):</p>
<ul>
<li><code>frank-dragoon</code> &mdash; fast, sees pictures, doesn't lecture. Good default.</li>
<li><code>frank-archon</code> &mdash; the big careful one on the two-card server. Slower per
answer, best at long and difficult work. Polite by construction.</li>
<li><code>frank-zealot</code> &mdash; small, quick, holds a very long conversation. Also
doesn't lecture.</li>
</ul>
<p class="key-warn">Two of the three will answer almost anything, including adult and dangerous
material, without a warning. That is deliberate for Frank's work, not a toy for a child:
conversations stay on the house machines and are not visible to Frank's phone. If that isn't
the setup you want for a kid, keep them off this door and ask Frank about a filtered one.</p>
</section>
<section id="gitea">
<h2>Gitea &mdash; Frank's workshop</h2>
<p><a href="https://gitea.franksdank.cloud">gitea.franksdank.cloud</a> holds the code that runs
this house: the configs, the deploy scripts, the tests. It has its own accounts (the top right
offers <em>Register</em>, or <em>Sign in with OpenID</em> to ride the family login), but it is a
workshop, not a household door. You very likely don't need it.</p>
<figure>
<img src="/img/onboarding/gitea-signin.webp" alt="The Gitea sign-in form with a password box and a Sign in with OpenID button">
<figcaption>If you ever do need it: <em>Register</em> for its own account, or
<em>Sign in with OpenID</em> for the family one.</figcaption>
</figure>
</section> </section>
<section id="models"> <section id="models">
<h1>The running models</h1> <h2>The models</h2>
<p>Three models serve the family, named for the Protoss units of StarCraft. <p class="kicker">What's actually answering you</p>
The homepage "Model Information" panel shows whether each one is up right now — <p>Three machines in this house do the thinking. None of them phone a company. Each is a
and plays the unit's voice if you click its quoted line.</p> different trade-off of speed, memory and how much it can hold in its head at once; the chat
app above is just a window onto them.</p>
<p class="foot-note">Naming: the Open&nbsp;WebUI picker and the raw API show <table>
exactly the Protoss names used on this page (renamed 2026-10-09). The name you <tr><th>Name</th><th>Runs on</th><th>Good at</th><th>Notes</th></tr>
read here is the name you type into a config file.</p> <tr>
<td><code>frank-dragoon</code></td>
<h2>frank-zealot — the fast utility model</h2> <td>the Strix Halo machine's built-in graphics</td>
<ul> <td>fast answers, long chats, reading pictures, tools</td>
<li><strong>Model:</strong> <a href="https://huggingface.co/prism-ml/Ternary-Bonsai-2-27B-gguf" target="_blank" rel="noopener">Ternary-Bonsai-2-27B</a> (PQ2_0 ternary weights) on the RTX&nbsp;3090 — roughly 77 tokens per second.</li> <td>two conversations at a time; abliterated, so no refusals</td>
<li><strong>Equivalent:</strong> last year's frontier level (2025).</li> </tr>
<li><strong>Capacity:</strong> 200k-token context, two submissions at a time; sees images.</li> <tr>
<li><strong>Inputs:</strong> text, image.</li> <td><code>frank-archon</code></td>
<li><strong>Best for:</strong> code editor autocomplete, agentic search, RAG, research subagents, translation, roleplay, and summarization — any small task that doesn't need much intelligence, and anything with a picture. This is the model Open&nbsp;WebUI offers to family accounts.</li> <td>the media server, two big graphics cards</td>
<li><strong>Compaction:</strong> if your agent harness supports setting a compaction model, set this one. A compaction job severely slows the bigger models for far longer than it slows this one.</li> <td>the hardest work: long code, careful reasoning, big documents</td>
<li><strong>Supervision:</strong> requires tight human supervision.</li> <td>four conversations at a time; keeps its guardrails</td>
<li><strong>Warning:</strong> <details class="key-warn"><summary>&#9888; censored base model</summary><div class="warn-body">Base-alignment (censored) model: refusals are possible on sensitive topics, and any model can still be steered into risky output. Uncensored work belongs on frank-dragoon. Take this warning seriously &mdash; don&rsquo;t ruin this server for everybody.</div></details></li> </tr>
</ul> <tr>
<td><code>frank-zealot</code></td>
<h2>frank-dragoon — deep reasoning</h2> <td>the RTX 3090 in the corner</td>
<ul> <td>very long conversations, cheap and quick; it is what Frank's own coding agent runs on</td>
<li><strong>Model:</strong> <a href="https://huggingface.co/Quat3rnion/halogen-qwen3.8-flash-next-v2-abliterated" target="_blank" rel="noopener">Qwen3.8-Flash-Next v2 native abliterated</a> (halogen flash-server, ~62&nbsp;GiB) on the Strix Halo iGPU &mdash; the base checkpoint itself, converted to the engine&rsquo;s native format and censorship-removed, thinking before it answers.</li> <td>three conversations at 200,000 tokens each; no refusals</td>
<li><strong>Equivalent:</strong> Claude Sonnet 5 / OpenAI-Luna (Summer 2026 frontier intelligence).</li> </tr>
<li><strong>Capacity:</strong> 200k-token context, two submissions at a time; sees images.</li> </table>
<li><strong>Inputs:</strong> text, image.</li> <p class="foot-note">The exact weights: <code>frank-dragoon</code> is a halogen conversion of
<li><strong>Best for:</strong> complex agentic tasks, programming, system administration, and "claw"-style personal agents.</li> Qwen3.8-Flash-Next v2, abliterated
<li><strong>Supervision:</strong> safe for autonomous, low-supervision use.</li> (<a href="https://huggingface.co/Quat3rnion/halogen-qwen3.8-flash-next-v2-abliterated">Quat3rnion/halogen-qwen3.8-flash-next-v2-abliterated</a>);
<li><strong>Warning:</strong> <details class="key-warn"><summary>&#9888; uncensored model</summary><div class="warn-body">Uncensored model: output can include adult content, content that describes harmful, hateful, or illegal acts, content concerning &ldquo;sensitive offensive cybersecurity topics&rdquo;, and other output that may carry severe legal risk. Take this warning seriously &mdash; don&rsquo;t ruin this server for everybody.</div></details></li> <code>frank-archon</code> is the official Qwen3.8-Flash-Next re-packed for that engine
(<a href="https://huggingface.co/StillDeadcode/qwen3.8-next-flash-fp8-iq4r-moe">StillDeadcode/qwen3.8-next-flash-fp8-iq4r-moe</a>);
<h2>frank-archon — the big one</h2> <code>frank-zealot</code> is Ternary-Bonsai-2-9B in a 2-bit packing
<ul> (<a href="https://huggingface.co/prism-ml/Ternary-Bonsai-2-9B-gguf">prism-ml/Ternary-Bonsai-2-9B-gguf</a>)
<li><strong>Model:</strong> our own build — a GPTQ-w4 quant of <a href="https://huggingface.co/frank8465/Swift-1.5-Qwen3.8-Flash-Next-GPTQ-W4A8-Radiance-AMD-R9700" target="_blank" rel="noopener">Swift1.5-Qwen3.8-Flash-Next</a>, quantized and tuned in-house (radiance, ~114&nbsp;GiB) on twin AMD R9700s with more than 200&nbsp;GB of system RAM — the biggest brain on the network and the fastest runtime.</li> with a small adapter fitted that removes its refusal reflex. All three accept pictures. Ask
<li><strong>Equivalent:</strong> Claude Opus 4.8 / OpenAI-Terra (Summer 2026 frontier intelligence).</li> Frank before pointing a script at one of them directly; the address is on the homepage.</p>
<li><strong>Capacity:</strong> 200k-token context, three submissions at a time; sees images.</li>
<li><strong>Inputs:</strong> text, image.</li>
<li><strong>Best for:</strong> software engineering, system administration, and instruction tasks. <strong>&#9888; Ask Frank for permission before use</strong> &mdash; this is his primary model; he uses it more than anything else on the fleet.</li>
</ul>
<p class="foot-note">Which one to pick? Small utility work, autocomplete, RAG,
programming, and personal agents: <code>frank-dragoon</code>. Software
engineering, sysadmin, and instruction tasks: <code>frank-archon</code> (ask
Frank first). Every name on this page is the id the Open&nbsp;WebUI picker and
the raw API answer to.</p>
</section> </section>
<section id="api-access"> <section id="api-access">
<h1>API access — the AI on your own laptop</h1> <h2>Let your own tools use it</h2>
<p>Developer assistants like <strong>Opencode</strong> and <strong>Pi</strong> are <p class="kicker">For the household's programmers</p>
chat programs that run on your own computer and can read your files, run commands, <p>Frank can hand you an API key. It works on one door
and edit code while they talk. They are all built to speak the same standard &mdash; <code>https://ai.franksdank.cloud/api/v1</code> &mdash; and speaks the plain OpenAI
language as the big commercial products (the "OpenAI-compatible" API), and our dialect, so anything that can talk to a hosted model can talk to the house.</p>
server speaks it too. You give the program three things — an address, a personal
key, and a model name — and your laptop thinks through our hardware.</p>
<h2>Step 1 — get your personal key</h2>
<ol>
<li>Open <a href="https://ai.franksdank.cloud">ai.franksdank.cloud</a> and log in.</li>
<li>Click your circle (bottom-left corner) → <strong>Settings</strong> → <strong>Account</strong>.</li>
<li>Scroll to <strong>API keys</strong> → press <strong>Show</strong> (it flips to
<strong>Hide</strong>) — the key appears as a row of dots.</li>
<li>Press the <strong>copy icon</strong> beside the key (the eye previews it;
the <strong>⋯</strong> menu rotates it later). The key looks like <code>sk-3f2a…</code>.</li>
</ol>
<img src="img/openwebui-api-keys.png" alt="The Open WebUI Account settings page, scrolled to the API keys section with the masked key, preview, copy and rotate controls">
<p class="key-warn">The key is a password. Anyone who has it can use our AI as
you. Paste it only into the program's config file — never into a shared chat,
a screenshot, or a git repository.</p>
<h2>Step 2 — the three details every tool asks for</h2>
<ul>
<li><strong>Base URL:</strong> <code>https://ai.franksdank.cloud/api/v1</code></li>
<li><strong>Model:</strong> <code>frank-zealot</code> — the family utility model (this is the id the API expects)</li>
<li><strong>API key:</strong> the <code>sk-…</code> from step 1 — sent as the header <code>Authorization: Bearer sk-…</code></li>
</ul>
<p>Proof that it works, from any terminal (replace the key):</p>
<pre><code>curl https://ai.franksdank.cloud/api/v1/chat/completions \ <pre><code>curl https://ai.franksdank.cloud/api/v1/chat/completions \
-H "Authorization: Bearer PASTE-YOUR-KEY" \ -H "Authorization: Bearer PASTE-YOUR-KEY" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"model": "frank-zealot", "messages": [{"role": "user", "content": "Hello!"}]}'</code></pre> -d '{
"model": "frank-dragoon",
<h2>Step 3a — Opencode</h2> "messages": [{"role": "user", "content": "What is on the shelf?"}]
<p>Install once (<code>npm install -g opencode-ai</code>), then create the file }'</code></pre>
<code>~/.config/opencode/opencode.json</code> with:</p> <p>For <a href="https://github.com/badlogic/pi-mono">Pi</a>, drop this in
<pre><code>{ <code>models.json</code>:</p>
"$schema": "https://opencode.ai/config.json",
"provider": {
"frank": {
"npm": "@ai-sdk/openai-compatible",
"name": "Frank fleet",
"options": {
"baseURL": "https://ai.franksdank.cloud/api/v1",
"apiKey": "PASTE-YOUR-KEY"
},
"models": {
"frank-zealot": { "name": "frank-zealot" }
}
}
}
}</code></pre>
<p>Start <code>opencode</code> and pick the model with <code>/model</code> —
it appears as <code>frank/frank-zealot</code>.</p>
<h2>Step 3b — Pi</h2>
<p>Create the file <code>~/.pi/agent/models.json</code> with:</p>
<pre><code>{ <pre><code>{
"providers": { "providers": {
"frank": { "frank": {
@@ -192,16 +383,40 @@
"api": "openai-completions", "api": "openai-completions",
"apiKey": "PASTE-YOUR-KEY", "apiKey": "PASTE-YOUR-KEY",
"models": [ "models": [
{ "id": "frank-zealot" } { "id": "frank-dragoon" }
] ]
} }
} }
}</code></pre> }</code></pre>
<p>Then start Pi with <code>pi --model frank/frank-zealot</code>.</p> <p>Then start Pi with <code>pi --model frank/frank-dragoon</code>.</p>
<p class="foot-note">The key is for the house AI only &mdash; it is not your Authentik password,
and losing it costs nothing beyond asking Frank to rotate it. Want a different model than the
one you were given? Ask Frank to widen the key.</p>
</section>
<p class="foot-note">The key works only for the family AI — it is not your <section id="trouble">
Authentik password, and losing it costs nothing beyond asking Frank to rotate it. <h2>When something misbehaves</h2>
Want a bigger model (<code>frank-archon</code>, <code>frank-dragoon</code>)? Ask Frank to raise your access.</p> <ul>
<li><strong>The robot check page keeps coming back.</strong> Your browser is blocking the
scripts it needs (or an extension is). Allow scripts for that site, or try another browser.
Still looping? Ask Frank &mdash; the guard can be told you're a person.</li>
<li><strong>"Invalid password" on the front desk.</strong> Right password, wrong door: the
family login does not open Jellyfin, Plex or the e-book door.</li>
<li><strong>The AI is slow.</strong> Each model only holds a couple of conversations at once
(two, three or four, depending which). It is a queue, not a crash &mdash; or Frank is
mid-conversation; the big one is on the same machine as the films.</li>
<li><strong>The AI invents something confidently.</strong> All three are small models living
on house hardware; they will make things up rather than say "I don't know". Check anything
that matters.</li>
<li><strong>A film is missing.</strong> It is probably not on the shelf yet, or it landed
yesterday and the library hasn't been told. Say so and Frank will scan.</li>
<li><strong>A file app can't reach the shelf from outside the house.</strong> Expected: the
public door is browser-shaped. Use the browser, or mount the home address on the house Wi-Fi.</li>
<li><strong>Something is genuinely down.</strong> The homepage badge says
<span style="color: var(--down)">DOWN</span> &mdash; tell Frank which one. Screenshots help.</li>
</ul>
<p class="foot-note">Anything missing, wrong, or written like a manual instead of a person:
that's a bug in this page. Tell Frank, or tell the machine that wrote it.</p>
</section> </section>
</main> </main>
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+12 -12
View File
@@ -110,13 +110,13 @@
"name": "Jellyfin", "name": "Jellyfin",
"category": "user", "category": "user",
"section": "Media Library Apps", "section": "Media Library Apps",
"description": "Open source media server. Work in progress: not open to family users yet.", "description": "Open source media server for the film and TV library: its own logins (no Authentik here), so ask Frank for an account.",
"url": "", "url": "http://jelly.franksdank.cloud",
"docs": "", "docs": "",
"probe": "192.168.0.235:8096", "probe": "192.168.0.235:8096",
"method": "http", "method": "http",
"wip": "down", "wip": false,
"showLink": false, "showLink": true,
"art": "/img/apps/jellyfin.svg" "art": "/img/apps/jellyfin.svg"
}, },
{ {
@@ -136,7 +136,7 @@
"name": "Marreta", "name": "Marreta",
"category": "user", "category": "user",
"section": "Web Apps", "section": "Web Apps",
"description": "Clean, ad-free article reader. Paste any URL, get just the article.", "description": "Paywall sledgehammer: hand it an article URL and it drags the page back through the VPN with the paywall chrome, cookie wall and trackers stripped; a headless browser renders the JS-heavy ones. Best effort — hard paywalls and login walls still win.",
"url": "http://marreta.franksdank.cloud", "url": "http://marreta.franksdank.cloud",
"docs": "", "docs": "",
"probe": "127.0.0.1:8082", "probe": "127.0.0.1:8082",
@@ -314,7 +314,7 @@
"zealot-honor.ogg" "zealot-honor.ogg"
], ],
"equiv": "Equivalent to last year's frontier level (2025).", "equiv": "Equivalent to last year's frontier level (2025).",
"spec": "Ternary-Bonsai-2-9B (PQ2_0 ternary weights, MTP head in the file) on an NVIDIA RTX 3090 — ~167 tokens/s; 262k-token context, three submissions at a time; sees images.", "spec": "Ternary-Bonsai-2-9B (PQ2_0 ternary weights, MTP head in the file) with the uncensoring adapter fitted at load, on an NVIDIA RTX 3090 — 200k-token conversations, three at a time; sees images.",
"inputs": [ "inputs": [
"text", "text",
"image" "image"
@@ -327,7 +327,7 @@
] ]
}, },
"supervision": "Requires tight human supervision.", "supervision": "Requires tight human supervision.",
"warn": "Base-alignment (censored) model: refusals are possible on sensitive topics, and any model can still be steered into risky output. Take this warning seriously — don’t ruin this server for everybody.", "warn": "Uncensored model — a refusal-removal adapter is fitted at load, so refusals are not a safeguard here: output can include adult content, content that describes harmful, hateful, or illegal acts, and other output that may carry severe legal risk. Take this warning seriously — don’t ruin this server for everybody.",
"compaction": "If your agent harness lets you set a compaction model, set this one: a compaction job on the bigger models blocks them for far longer." "compaction": "If your agent harness lets you set a compaction model, set this one: a compaction job on the bigger models blocks them for far longer."
}, },
"url": "", "url": "",
@@ -350,7 +350,7 @@
"dragoon-tagline.ogg" "dragoon-tagline.ogg"
], ],
"equiv": "Equivalent to Claude Sonnet 5 / OpenAI-Luna (Summer 2026 frontier intelligence).", "equiv": "Equivalent to Claude Sonnet 5 / OpenAI-Luna (Summer 2026 frontier intelligence).",
"spec": "Qwen3.8-Flash-Next v2 native abliterated (halogen flash-server, ~62 GiB) on the Strix Halo iGPU — 200k-token context, two submissions at a time; sees images.", "spec": "Qwen3.8-Flash-Next v2 native abliterated (halogen flash-server, ~62 GiB) on the Strix Halo iGPU — 200k-token conversations, two at a time; sees images.",
"inputs": [ "inputs": [
"text", "text",
"image" "image"
@@ -378,14 +378,14 @@
"section": "AI", "section": "AI",
"model": { "model": {
"unit": "archon", "unit": "archon",
"role": "Frank's strongest and fastest model runtime", "role": "Frank's strongest model",
"hf": "https://huggingface.co/StillDeadcode/qwen3.8-next-flash-fp8-iq4r-moe", "hf": "https://huggingface.co/StillDeadcode/qwen3.8-next-flash-fp8-iq4r-moe",
"tagline": "...Power Overwhelming...", "tagline": "...Power Overwhelming...",
"sfx": [ "sfx": [
"archon-power.ogg" "archon-power.ogg"
], ],
"equiv": "Equivalent to Claude Opus 4.8 / OpenAI-Terra (Summer 2026 frontier intelligence).", "equiv": "Equivalent to Claude Opus 4.8 / OpenAI-Terra (Summer 2026 frontier intelligence).",
"spec": "The engine author's published quant: 4-bit codebook experts + int8 trunk of Qwen3.8-Flash-Next (radiance, ~114 GiB) on twin AMD R9700s with more than 200 GB of system RAM — 200k-token context, three submissions at a time; sees images.", "spec": "The engine author's published quant: 4-bit codebook experts + int8 trunk of the official (censored) Qwen3.8-Flash-Next (radiance, ~114 GiB) on twin AMD R9700s with more than 200 GB of system RAM — 200k-token conversations, four at a time; sees images.",
"inputs": [ "inputs": [
"text", "text",
"image" "image"
@@ -513,9 +513,9 @@
}, },
{ {
"name": "Audiobookshelf", "name": "Audiobookshelf",
"category": "admin", "category": "user",
"section": "Media Library Apps", "section": "Media Library Apps",
"description": "Audiobook server for the entropy library: its own SQLite library, its own login, per-listener positions.", "description": "Audiobook server for the library: sign in with your Authentik account and every listener keeps their own position.",
"url": "http://audiobooks.franksdank.cloud/audiobookshelf/", "url": "http://audiobooks.franksdank.cloud/audiobookshelf/",
"docs": "", "docs": "",
"probe": "192.168.0.235:8000", "probe": "192.168.0.235:8000",
+11
View File
@@ -183,12 +183,23 @@ body::after {
.docs details.key-warn[open] summary::after { content: " \25BE"; } .docs details.key-warn[open] summary::after { content: " \25BE"; }
.docs details.key-warn .warn-body { margin-top: 4px; } .docs details.key-warn .warn-body { margin-top: 4px; }
.docs .foot-note { color: var(--dim); } .docs .foot-note { color: var(--dim); }
.docs h3 { color: var(--head); font-size: 1.05rem; margin-top: 16px; }
.docs figure { margin: 14px 0; }
.docs figcaption { color: var(--dim); font-size: .85rem; }
.docs table { border-collapse: collapse; width: 100%; margin: 10px 0; }
.docs th, .docs td { border: 1px solid var(--border); padding: 6px 8px; text-align: left; vertical-align: top; }
.docs th { color: var(--head); }
.docs .toc { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 4px; margin: 12px 0; }
.docs .toc a { display: block; border: 1px solid var(--border); padding: 6px 8px; text-decoration: none; }
.docs .kicker { color: var(--dim); text-transform: uppercase; letter-spacing: .12em; font-size: .72rem; }
html[data-bare="1"] .docs h1, html[data-bare="1"] .docs h2 { color: #000; border-color: #000; } html[data-bare="1"] .docs h1, html[data-bare="1"] .docs h2 { color: #000; border-color: #000; }
html[data-bare="1"] .docs a, html[data-bare="1"] .docs code { color: #000; } html[data-bare="1"] .docs a, html[data-bare="1"] .docs code { color: #000; }
html[data-bare="1"] .docs pre { background: none; border-color: #000; } html[data-bare="1"] .docs pre { background: none; border-color: #000; }
html[data-bare="1"] .docs pre code { color: #000; } html[data-bare="1"] .docs pre code { color: #000; }
html[data-bare="1"] .docs img { border-color: #000; } html[data-bare="1"] .docs img { border-color: #000; }
html[data-bare="1"] .docs .key-warn { color: #000; } html[data-bare="1"] .docs .key-warn { color: #000; }
html[data-bare="1"] .docs h3, html[data-bare="1"] .docs th { color: #000; }
html[data-bare="1"] .docs th, html[data-bare="1"] .docs td, html[data-bare="1"] .docs .toc a { border-color: #000; }
/* Bare mode (Q27): strip to default browser rendering. */ /* Bare mode (Q27): strip to default browser rendering. */
html[data-bare="1"] body { background: #fff; color: #000; font-family: serif; text-shadow: none; font-size: 16px; } html[data-bare="1"] body { background: #fff; color: #000; font-family: serif; text-shadow: none; font-size: 16px; }
+11 -2
View File
@@ -75,8 +75,10 @@ PY
} }
@test "exempt vhosts never route through anubis (auth, plex, own-auth apps)" { @test "exempt vhosts never route through anubis (auth, plex, own-auth apps)" {
# The IdP login flow and git smart-HTTP clients cannot solve challenges; # The IdP login flow, git smart-HTTP clients, and any non-browser client (the
# these vhosts bypass the chain by construction. # Audiobookshelf phone app, podcast RSS readers) cannot solve a challenge, so
# services that enforce their own complete auth bypass the chain by
# construction — the gate would answer their API calls with HTML.
local block local block
block=$(awk '/^auth\.franksdank\.cloud/,/^}/' "$CADDY") block=$(awk '/^auth\.franksdank\.cloud/,/^}/' "$CADDY")
echo "$block" | grep -q 'reverse_proxy authentik-server:9000' echo "$block" | grep -q 'reverse_proxy authentik-server:9000'
@@ -84,4 +86,11 @@ PY
block=$(awk '/^plex\.franksdank\.cloud/,/^}/' "$CADDY") block=$(awk '/^plex\.franksdank\.cloud/,/^}/' "$CADDY")
echo "$block" | grep -q '192\.168\.0\.235:32400' echo "$block" | grep -q '192\.168\.0\.235:32400'
! echo "$block" | grep -q anubis ! echo "$block" | grep -q anubis
block=$(awk '/^audiobooks\.franksdank\.cloud/,/^}/' "$CADDY")
echo "$block" | grep -q '192\.168\.0\.235:8000'
! echo "$block" | grep -q anubis
# The exemption is total: the post-auth listener must not still carry an
# audiobooks route. A leftover handle there is a second owner for one host,
# and its X-Forwarded-Proto pin would silently disagree with the edge.
! grep -q '@audiobooks host audiobooks.franksdank.cloud' "$CADDY"
} }
+5 -2
View File
@@ -35,8 +35,11 @@ export default defineConfig({
// 2026-10-19 - the newest guarded app, in neither GUARDED list nor any // 2026-10-19 - the newest guarded app, in neither GUARDED list nor any
// spec until now: the 17-hermes "uncovered vhost broke silently" class). // spec until now: the 17-hermes "uncovered vhost broke silently" class).
// 22- joins Prometheus's app-is-really-there proof (its own API), closing // 22- joins Prometheus's app-is-really-there proof (its own API), closing
// the "not a login wall but maybe a 404" hole 20- closed for Grafana. // the "not a login wall but maybe a 404" hole 20- closed for Grafana. 24-
testMatch: /(10|12|13|16|17|18|20|21|22)-.*\.spec\.ts/, // joins 2026-11-03 (Audiobookshelf's DEFAULT listener grant) and belongs to
// this project precisely because the standard identity is in no Audiobookshelf
// group — the admin project could not observe the default.
testMatch: /(10|12|13|16|17|18|20|21|22|24)-.*\.spec\.ts/,
use: { storageState: ".auth/qa-e2e.json" }, use: { storageState: ".auth/qa-e2e.json" },
dependencies: ["setup"], dependencies: ["setup"],
}, },
+58 -8
View File
@@ -6,21 +6,21 @@ import { expect, test } from "@playwright/test";
// navigation would skip. These tests drive the browser as an UNAUTHENTICATED // navigation would skip. These tests drive the browser as an UNAUTHENTICATED
// user (the guest project has no storage state) and navigate directly to each // user (the guest project has no storage state) and navigate directly to each
// guarded vhost's root path: the guard must present authentik's flow UI in // guarded vhost's root path: the guard must present authentik's flow UI in
// place and must never leak app content. plex is the documented exemption — // place and must never leak app content. plex and audiobooks are the documented
// PMS enforces its own auth — so it must be reachable unauthenticated with // exemptions — PMS and Audiobookshelf enforce their own auth — so they must be
// zero challenge pages, proving the exemption is deliberate, not accidental. // reachable unauthenticated with zero challenge pages, proving the exemption is
// deliberate, not accidental.
const BASE = "https://www.franksdank.cloud"; const BASE = "https://www.franksdank.cloud";
// Every vhost that Caddy routes through anubis:8923 (see dankclaw/Caddyfile). // Every vhost that Caddy routes through anubis:8923 (see dankclaw/Caddyfile).
// Keep this list in lockstep with the Caddyfile: a new guarded vhost must be // Keep this list in lockstep with the Caddyfile: a new guarded vhost must be
// added here or the guard-chain audit is incomplete. // added here or the guard-chain audit is incomplete.
const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki", "notebook", "audiobooks"]; const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki", "notebook"];
// Markers that prove app content leaked past the guard to an anonymous user. Each // Markers that prove app content leaked past the guard to an anonymous user. Each
// marker must be a string the APP renders that the gate itself could never print — // marker must be a string the APP renders that the gate itself could never print:
// for audiobooks that is the Audiobookshelf login's own SSO button label (the app's // authentik's gate never renders an app's UI text, because it IS the IdP.
// door), which authentik's gate never renders because it IS the IdP.
const LEAK_MARKERS: Record<string, string[]> = { const LEAK_MARKERS: Record<string, string[]> = {
"search.franksdank.cloud": ["SearXNG preferences"], "search.franksdank.cloud": ["SearXNG preferences"],
"romm.franksdank.cloud": ["RomM"], "romm.franksdank.cloud": ["RomM"],
@@ -29,7 +29,6 @@ const LEAK_MARKERS: Record<string, string[]> = {
"grafana.franksdank.cloud": ["Dashboards"], "grafana.franksdank.cloud": ["Dashboards"],
"prometheus.franksdank.cloud": ["Expression Browser", "Graph"], "prometheus.franksdank.cloud": ["Expression Browser", "Graph"],
"notebook.franksdank.cloud": ["Open Notebook"], "notebook.franksdank.cloud": ["Open Notebook"],
"audiobooks.franksdank.cloud": ["Sign in with Authentik"],
}; };
test("homepage root (/) is guarded for unauthenticated users", async ({ page }) => { test("homepage root (/) is guarded for unauthenticated users", async ({ page }) => {
@@ -102,3 +101,54 @@ test("plex is exempt from the guard chain and serves PMS content unauthenticated
const resp = await page.request.get("https://plex.franksdank.cloud/"); const resp = await page.request.get("https://plex.franksdank.cloud/");
expect(resp.status(), "PMS must answer directly, not error").toBeLessThan(500); expect(resp.status(), "PMS must answer directly, not error").toBeLessThan(500);
}); });
// Audiobookshelf is exempt by construction (2026-11-03). The phone app is a
// non-browser client and solves no JS proof-of-work, so behind the guard chain it
// was answered with challenge HTML wherever the socket `init` payload belongs —
// every remote sign-in died as a TypeError on a non-object, and the container's
// request log never saw a byte of it. ABS enforces its own auth (local accounts
// plus authentik OIDC), exactly like PMS above. Asserted from the CLIENT side: the
// endpoints an app must call before it holds any credential are answered by the
// app, and the app's own 401 — not a 200 of HTML — is what an anonymous API call gets.
test("audiobookshelf answers an unauthenticated app client with app bytes, not gate bytes", async ({
request,
}) => {
const ABS = "https://audiobooks.franksdank.cloud";
// The probe every Audiobookshelf client sends first. The app answers it at the
// ROOT whatever ROUTER_BASE_PATH says, so this is the vhost's own behaviour.
const ping = await request.get(`${ABS}/ping`, { timeout: 30_000 });
expect(ping.status(), "/ping must be answered by the app").toBe(200);
expect(await ping.json(), "/ping must carry the app's own payload").toEqual({ success: true });
// The socket.io handshake. Engine.IO answers this in a machine format; the gate
// answered it in HTML, which is the defect this exemption exists to kill.
const sock = await request.get(`${ABS}/audiobookshelf/socket.io/?EIO=4&transport=polling`, {
timeout: 30_000,
});
expect(
await sock.text(),
"the socket handshake was answered by the gate, not Engine.IO",
).not.toContain("Making sure you're not a bot");
expect(
sock.headers()["content-type"] ?? "",
"the socket handshake must not arrive as HTML",
).not.toMatch(/text\/html/);
// An API call with no credential: the app refuses it. A gate would answer 200
// with HTML, so the status code alone distinguishes the two owners.
const api = await request.get(`${ABS}/audiobookshelf/api/libraries`, { timeout: 30_000 });
expect(api.status(), "the app's own auth gate must own unauthenticated API calls").toBe(401);
});
test("audiobookshelf login renders the app's own sign-in to an anonymous browser", async ({ page }) => {
test.setTimeout(120_000);
await page.goto("https://audiobooks.franksdank.cloud/audiobookshelf/login");
expect(new URL(page.url()).hostname).toBe("audiobooks.franksdank.cloud");
const body = await page.locator("body").innerText();
expect(body, "the bot challenge still fronts this vhost").not.toContain("Making sure you're not a bot");
expect(await page.locator('input[name="uidField"]').count(), "the IdP gate form must not front the app").toBe(0);
// The app answers, and its login screen carries SSO beside the local form.
await expect(page.getByText("Sign in with Authentik")).toBeVisible({ timeout: 90_000 });
});
@@ -26,11 +26,11 @@ test("SSO round trip boots the SPA and grants a real Audiobookshelf session", as
}) => { }) => {
test.setTimeout(180_000); test.setTimeout(180_000);
const scripts: { url: string; type: string }[] = []; const scripts: { url: string; type: string; status: number }[] = [];
page.on("response", (res) => { page.on("response", (res) => {
const u = new URL(res.url()); const u = new URL(res.url());
if (u.host === HOST && u.pathname.endsWith(".js")) { if (u.host === HOST && u.pathname.endsWith(".js")) {
scripts.push({ url: u.pathname, type: res.headers()["content-type"] ?? "" }); scripts.push({ url: u.pathname, type: res.headers()["content-type"] ?? "", status: res.status() });
} }
}); });
@@ -59,8 +59,11 @@ test("SSO round trip boots the SPA and grants a real Audiobookshelf session", as
// Defect 1 gate: nothing the app loaded may be HTML dressed as a script. The render proof // Defect 1 gate: nothing the app loaded may be HTML dressed as a script. The render proof
// is the SSO button above — it only exists once the bundle has executed, so no DOM-shape // is the SSO button above — it only exists once the bundle has executed, so no DOM-shape
// guess is needed (the shell's own mount markup says nothing about whether it booted). // guess is needed (the shell's own mount markup says nothing about whether it booted).
expect(scripts.length, "the SPA loaded no JavaScript").toBeGreaterThan(2); // Only responses that carried a body can be judged: the OIDC bounce re-validates the entry
for (const s of scripts) { // script (Cache-Control: max-age=0 + ETag) and a 304 carries no Content-Type by spec.
const bodies = scripts.filter((s) => s.status === 200);
expect(bodies.length, "the SPA loaded no JavaScript").toBeGreaterThan(2);
for (const s of bodies) {
expect(s.type, `${s.url} arrived as ${s.type}`).toContain("javascript"); expect(s.type, `${s.url} arrived as ${s.type}`).toContain("javascript");
} }
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test";
// Audiobookshelf grants the LISTENER role to anyone the IdP lets sign in (2026-11-03).
//
// The role used to come from membership of `audiobookshelf_users` — a group nobody was ever
// added to. The household accounts exist in authentik, and every one of them was denied at ABS
// AFTER a successful authentication, which reads as a broken login rather than a policy decision.
// The scope mapping now answers `abs_groups: ["user"]` for every signer and reserves `admin` for
// `audiobookshelf_admins`, so the IdP is the only gate to the library and onboarding a listener
// is exactly "create the account".
//
// This is the counterpart to 23-: that spec proves the admin escalation, this one proves the
// DEFAULT grant, from an identity in neither group. Against the old mapping it is red — ABS
// refuses the sign-in and the SPA never leaves /login.
const HOST = "192.168.0.235:8000";
const ABS = `http://${HOST}/audiobookshelf`;
test("an SSO signer in no Audiobookshelf group lands as a listener", async ({ page }) => {
test.setTimeout(180_000);
await page.goto(`${ABS}/login`, { waitUntil: "domcontentloaded" });
const sso = page.getByText("Sign in with Authentik");
await expect(sso).toBeVisible();
await sso.click();
// The standard project's storageState carries a live authentik session, so the round trip is
// silent: authorize -> code -> callback -> token. Still being on /login means the app refused
// the identity (the denial this change removes) or the exchange failed.
await expect
.poll(() => !new URL(page.url()).pathname.endsWith("/login"), { timeout: 90_000 })
.toBe(true);
const grabToken = () =>
page.evaluate(() => {
for (let i = 0; i < localStorage.length; i++) {
const v = localStorage.getItem(localStorage.key(i) as string) || "";
if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(v)) return v;
}
const c = document.cookie.match(/(?:^|;\s*)abs_token=([^;]+)/);
return c ? decodeURIComponent(c[1]) : "";
});
await expect.poll(grabToken, { timeout: 60_000 }).not.toBe("");
const me = await page.request.get(`${ABS}/api/me`, {
headers: { Authorization: `Bearer ${await grabToken()}` },
});
expect(me.status()).toBe(200);
const user = await me.json();
expect(user.username).toBe("qa-e2e");
// The default, not a grant: the listener role for a signer in neither group. Being `user`
// rather than `admin` is also the proof that the default never escalates.
expect(user.type).toBe("user");
});
-2
View File
@@ -107,8 +107,6 @@ assert [s["name"] for s in fleet] == ["frank-zealot", "frank-dragoon", "frank-ar
w = {s["name"]: s["model"].get("warn", "") for s in fleet} w = {s["name"]: s["model"].get("warn", "") for s in fleet}
assert "legal risk" in w["frank-dragoon"].lower() and "everybody" in w["frank-dragoon"].lower(), \ assert "legal risk" in w["frank-dragoon"].lower() and "everybody" in w["frank-dragoon"].lower(), \
"the abliterated endpoint must carry the common legal warning" "the abliterated endpoint must carry the common legal warning"
assert "censored" in w["frank-zealot"].lower() and "uncensored" not in w["frank-zealot"].lower(), \
"zealot ships the censored base since 2026-10-17: its card warning must not claim uncensored output"
archon = next(s for s in fleet if s["name"] == "frank-archon") archon = next(s for s in fleet if s["name"] == "frank-archon")
assert "permission" in archon["model"], "archon card needs the ask-Frank permission callout" assert "permission" in archon["model"], "archon card needs the ask-Frank permission callout"
PY PY