Compare commits

..
Author SHA1 Message Date
Frank 23ad0ca317 chore(abs): name the Audiobookshelf door audiobooks.franksdank.cloud
`abs.` was a three-letter abbreviation of the container; the library it serves is
/mnt/entropy/Audiobooks, and `audiobooks.` says what the door is. Renamed in one
commit on purpose: the pihole seam fails the suite if a Caddyfile vhost has no
addn-hosts record, so any split would leave a red commit, and the `abs` name was
never deployed at the IdP with this fix, so nothing has to keep answering it —
no alias, no redirect, no stale URI.

Moves together: the Caddyfile site block, its matcher (@abs -> @audiobooks), the
LAN record, ABS_EXTRA_BASES in authentik-audiobookshelf-sso.sh (that script
REPLACES the provider's redirect-URI list, so the old origin's callback leaves the
RP when it is next run), the homepage admin tile, the guard spec's GUARDED list and
leak marker, and the three vault pages that name the door. authentik's provider row
follows in the deploy window (external_host + name), which re-derives its
redirect_uris through scripts/authentik-proxy-provider-defaults.sh; the Application
row keeps its own name, "Audiobookshelf Guard" — it names the app, not the vhost.
2026-11-03 01:23:54 -08:00
Frank 71d5804eaf docs(caddy): put both auth walls in the guard-a-new-vhost procedure
Step 1 gains the closing sentence the abs-proxy incident demands: the ak-shell
snippet that creates a ProxyProvider with objects.create() must be followed by
scripts/authentik-proxy-provider-defaults.sh, or the guard fails before any
credential. Step 2 gains the forwarded-scheme clause for any app that derives its
own origin from the request. Each fact keeps one owner — the laws stay on
[[Authentik Outpost Reference]] (provisioning rule 3) and [[Audiobookshelf]]; this
page only says where in the procedure they bite. Outpost page timestamp bumped to
match its substantive edit.
2026-11-03 01:17:12 -08:00
Frank 5ef141eb17 fix(caddy): tell Audiobookshelf its real scheme so the OIDC callback validates
ABS checks every OIDC callback against the origin it believes it is served from:
getRequestOrigin() is x-forwarded-proto://Host (dist-server/server/utils/requestUtils.js,
read in the pinned image digest 2026-11-03). The post-auth http://:8017 listener is fed
over plain http, so Caddy's own X-Forwarded-Proto is http, ABS derives
http://abs.franksdank.cloud, and answers its own https callback with

    400 {"message":"Invalid callback URL - must be same-origin"}

Measured at the app with the callback held fixed: Host abs.franksdank.cloud alone
reproduces the 400 (as does every other Host, including the bare LAN IP), and adding
`X-Forwarded-Proto: https` turns it into a 302 to /application/o/authorize/ carrying
the registered redirect_uri. The edge for a guarded name is TLS-only, so https is the
truthful value, not a spoof; the LAN door needs nothing because there browser and
server already agree on http, which is why only the guarded path was broken.
2026-11-03 01:16:40 -08:00
Frank fadbe10420 fix(authentik): census and repair the OAuth defaults on every proxy provider
A proxy provider built by hand in `ak shell` never runs
ProxyProvider.set_oauth_defaults() — the REST serializer does, the ORM does not —
so the row looks fully configured while redirect_uris is empty (they are derived
from external_host by _get_callback_url()), grant_types is [], and
authorization_flow is NULL. Each one kills a guarded login in a different,
unrelated-looking way, all before any credential is requested: authentik's own
"Redirect URI Error" page, then a bodiless HTTP 400 from /application/o/authorize/
once the URIs are filled by hand, and a 500 NoneType.slug in the flow planner.
abs-proxy (abs.franksdank.cloud) hit the first two on 2026-11-03.

The script calls the vendor's own set_oauth_defaults() and points the
authorization flow at default-provider-authorization-implicit-consent for every
proxy provider, idempotently; a provider that already matches is untouched, so a
deliberate deviation is never flattened. `--check` is the census (exit 1 on
drift, writes nothing) for gate use. Applied live 2026-11-03: abs-proxy fixed,
the other ten (REST-created) already compliant, census now reports drift: 0.

This is authentik DB state and no deployable file carries it — a blueprint
re-import or an ak-shell re-creation reverts it and the symptom returns, so the
law and the failure signatures are recorded on the outpost page as provisioning
rule 3.
2026-11-03 01:16:35 -08:00
dank 17ea2f4110 Merge pull request 'feat(caddy): give Audiobookshelf a guarded abs.franksdank.cloud address' (#130) from feat/abs-vhost into main 2026-11-03 00:57:47 -08:00
Frank 603513a15e docs: the guard-a-new-vhost procedure and Audiobookshelf's second door
Infrastructure/Caddy.md now owns the order of operations for exposing a vhost
(proxy provider + application + embedded-outpost claim, then the route, then the
name and the chain audit), because the IdP row is invisible state that nobody
recreates. Audiobookshelf's Internal-only row and law become Two doors, one app;
the homepage link and index.md follow.
2026-11-03 00:55:02 -08:00
Frank 5e0592381a test(e2e): audit the guard chain over abs.franksdank.cloud
The guard spec's GUARDED list is the lockstep with the Caddyfile; abs joins it
with the app's own SSO-button label as the leak marker - a string the Audiobookshelf
login renders and the IdP gate never prints.
2026-11-03 00:55:02 -08:00
Frank ef89f77ea7 feat(authentik): register the ABS browser callback per origin
ABS builds redirect_uri from the origin of the request, so a second door is a
second URI: ABS_EXTRA_BASES (default the guarded vhost) now contributes a
callback each, while the mobile-redirect URI stays on the LAN origin - the phone
is a non-browser client and cannot solve the guard's challenge, so it is
configured with the LAN address. Measured on the live provider: three URIs,
grants and scopes unchanged.
2026-11-03 00:55:02 -08:00
Frank 9e2584ec8b feat(homepage): point the Audiobookshelf tile at the vhost
The tile carried the bare LAN IP. The probe stays the LAN host:port so the
homepage checker keeps measuring the app rather than the auth chain in front of
it.
2026-11-03 00:55:02 -08:00
Frank a5dfc1ee99 feat(caddy): serve Audiobookshelf at a guarded abs.franksdank.cloud
Provider-first exposure: the IdP row (proxy provider abs-proxy + application
"Audiobookshelf Guard", claimed by the embedded outpost) already exists, so the
route can ship. The site block proxies everything to anubis:8923 like every other
guarded vhost, and the post-auth :8017 handler routes the host to
192.168.0.235:8000. The container carries ROUTER_BASE_PATH=/audiobookshelf, so
the app has no root: the bare / is redirected onto the base path inside the
post-auth handler, never pre-auth, so an anonymous client only ever meets the
gate.
2026-11-03 00:55:02 -08:00
Frank 7e72126a07 docs(audiobookshelf): an SSO identity cannot land on an existing account
authOpenIDMatchExistingBy is unset on this deployment, so ABS matches an
OpenID userinfo on sub only and auto-registers everyone else. A new authentik
username that equals an existing ABS username therefore dies on the unique
constraint inside the callback, reported only as 'Unauthorized' - which is
exactly what a future agent would hit by 'just creating a frank user' in the
IdP to reach the root account. Records the two real doors (match by username,
which links and keeps root, since setUserGroup refuses to downgrade a root;
or a non-colliding name), and corrects the admin-membership row: akadmin is
the operator's own authentik account, already granted site admin.
2026-11-02 20:29:30 -08:00
dank 80a65b013f Merge pull request 'fix(authentik): provision the Audiobookshelf provider with grant types and OpenID scopes' (#129) from fix/abs-grant-types into main 2026-11-02 20:24:53 -08:00
Frank 54ec72da17 docs(audiobookshelf): the two IdP laws the dead login taught, and the gate
Records that an ORM-created provider has no grant types and that the claim
mapping is not the whole property_mappings list, with the failure signatures
to recognise; names qa-e2e-admin as the admin-group fixture and the new
Playwright spec as the live verification.
2026-11-02 20:21:21 -08:00
Frank d82b8efdd7 test(e2e): gate the Audiobookshelf SSO round trip end to end
The service answers on a LAN address with no vhost, so it was in no
guarded-vhost list and no spec touched it: every deploy gate passed while its
login was dead. The gate signs in as qa-e2e-admin and asserts the two
consumer-visible outcomes that the two defect classes break - every .js the
SPA loads arrives as JavaScript (a bundle built for the wrong base path comes
back text/html and boots nothing), and /api/me answers 200 with type 'admin'
(true only while grant types, the OpenID scope mappings and the group claim
all hold). Joined to the admin project, so it rides every deploy e2e gate.
2026-11-02 20:21:21 -08:00
Frank 27b3c24065 fix(authentik): provision the Audiobookshelf provider with grant types and OpenID scopes
The provider row this script creates was missing two fields, and each one is
a dead login that names nothing on the wire (both measured 2026-11-02 in the
browser):

* grant_types defaults to [] on an ORM-created provider in authentik 2026.8,
  so /application/o/authorize/ refused the code flow with oauthlib's generic
  'The request is otherwise malformed'; the log line that names the cause is
  'Invalid grant_type for provider'. Set authorization_code + refresh_token,
  and reconcile it on re-run.
* property_mappings held only the abs_groups claim mapping, so the granted
  token was scoped shorter than the scopes ABS requests and ABS's /userinfo
  call died 'insufficient_scope' after a good code exchange. Attach the
  built-in OpenID openid/profile/email mappings beside the claim.

Also provisions qa-e2e-admin into the admin group: it is the identity the new
Playwright gate signs in as, so a blueprint re-import restores the fixture.
2026-11-02 20:21:21 -08:00
dank 7e9bfbc2be Merge pull request 'fix(audiobookshelf): serve under /audiobookshelf, send the OIDC redirect subfolder' (#128) from fix/abs-base-path-sso into main 2026-11-02 20:02:57 -08:00
Frank f5410bd253 docs(audiobookshelf): the probe routes answer at the root under either base path
Measured on both shapes: ping returns success:true and healthcheck returns OK at the root whether or not the app is mounted under a prefix, which is why the phone app's connectivity check works before it ever resolves the base path. The paragraph now says which routes were probed instead of asserting 200s in general, and the frontmatter description fits the OKF 120-char budget the linter enforces.
2026-11-02 19:53:16 -08:00
Frank 98522cecba docs(jellyfin): correct a future-dated measurement stamp 2026-11-02 19:50:44 -08:00
Frank 3138e782bd docs(audiobookshelf): the base-path law, the SSO contract, the phone address
The page owned the URL as a plain host:port; every client now carries the /audiobookshelf prefix. Added the two laws a next operator needs (why the base path is not a choice, why the redirect subfolder must be sent) and the Single sign-on section naming which script owns which half.
2026-11-02 19:50:44 -08:00
Frank 393b77dfd2 feat(audiobookshelf): provision authentik SSO and send the redirect subfolder
The live round trip died on authentik with Redirect URI Error. ABS builds redirect_uri as <origin><authOpenIDSubfolderForRedirectURLs>/auth/openid/callback, and that key is unset on a database created before SSO (the 2.17.4 migration only fills a DB that already had OIDC enabled), so the URL carried the literal string undefined. Arming through the API therefore has to send the value; it is the mounted base path, so it is derived from ABS_URL rather than becoming a second knob an operator has to keep in sync.

authentik-audiobookshelf-sso.sh is the IdP half kept in git: provider, groups, the abs_groups scope mapping and the redirect URIs. That state lives in authentik's database, and a blueprint re-import reverts hand-made rows silently, so it needs a re-runnable door. Tokens are HS256 because no provider on the fleet carries a signing key, which makes the client secret the signature key.

The stub server now records the auth-settings PATCH body, so the seam asserts the subfolder that reaches the wire follows ABS_URL for both the prefixed and root shapes, and a second seam pins that the provider script and the bootstrapper build the same redirect path.
2026-11-02 19:50:44 -08:00
Frank cfdd3435bc fix(audiobookshelf): mount the app at the base path its SPA was built for
ROUTER_BASE_PATH=/ served a healthy API and a web UI that could never boot. The index.html in the pinned image is built with routerBasePath:/audiobookshelf, so every script request fell through to the SPA fallback and came back as text/html, which Chrome refuses to execute. healthcheck, the API and the phone app all stayed fine, which is exactly how this hid behind an API-only deploy check.

The deployer now reads the mounted base path out of the unit and hands it to the bootstrapper as ABS_URL, so the API door, the login route and the OIDC redirect subfolder cannot drift from the unit. healthcheck answers at the root under either base path, so the health gate is unchanged.
2026-11-02 19:50:44 -08:00
dank f3a6b4f715 Merge pull request 'docs(jellyfin): the recursive Movie list is not proof an item is indexed' (#126) from docs/jellyfin-verification-read into main 2026-11-02 19:14:33 -08:00
dank 5c888ff948 Merge pull request 'docs(audiobookshelf): the phone app's connection facts and where its token lives' (#127) from docs/abs-phone-app into main 2026-11-02 19:11:23 -08:00
Frank a5603d8f01 - 2026-11-02 18:52:13 -08:00
18 changed files with 912 additions and 47 deletions

No files matched your search

+32
View File
@@ -264,6 +264,20 @@ files.franksdank.cloud {
} }
} }
# --- Audiobookshelf (guarded) ---
# Anubis → Authentik → app, like romm/files. The container carries
# ROUTER_BASE_PATH=/audiobookshelf, so this vhost serves the app under that
# path; the bare root is redirected onto it in the post-auth listener, never
# pre-auth. Guard state in the IdP: proxy provider `audiobooks-proxy`
# (external_host https://audiobooks.franksdank.cloud/, internal_host
# http://caddy:8017, application "Audiobookshelf Guard"), claimed by the
# embedded outpost.
audiobooks.franksdank.cloud {
reverse_proxy anubis:8923 {
header_up X-Real-Ip {remote_host}
}
}
# --- Jellyfin --- # --- Jellyfin ---
jelly.franksdank.cloud { jelly.franksdank.cloud {
reverse_proxy 192.168.0.235:8096 reverse_proxy 192.168.0.235:8096
@@ -410,6 +424,24 @@ 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
+1
View File
@@ -26,6 +26,7 @@
192.168.0.143 romm.franksdank.cloud 192.168.0.143 romm.franksdank.cloud
192.168.0.143 files.franksdank.cloud 192.168.0.143 files.franksdank.cloud
192.168.0.143 jelly.franksdank.cloud 192.168.0.143 jelly.franksdank.cloud
192.168.0.143 audiobooks.franksdank.cloud
192.168.0.143 radarr.franksdank.cloud 192.168.0.143 radarr.franksdank.cloud
192.168.0.143 lidarr.franksdank.cloud 192.168.0.143 lidarr.franksdank.cloud
192.168.0.143 readarr.franksdank.cloud 192.168.0.143 readarr.franksdank.cloud
@@ -33,9 +33,16 @@ Network=pasta
# README is the npm package's naming and is INERT here: with ABS_PORT=8000 the # README is the npm package's naming and is INERT here: with ABS_PORT=8000 the
# server listened on :80 under base path /audiobookshelf, so the unit sat # server listened on :80 under base path /audiobookshelf, so the unit sat
# `active (running)` while every probe to :8000 was refused (measured 2026-11-02, # `active (running)` while every probe to :8000 was refused (measured 2026-11-02,
# first deploy). ROUTER_BASE_PATH=/ is what makes the URL the plain host:port. # first deploy).
# ROUTER_BASE_PATH MUST be /audiobookshelf: the SPA in /app/client/dist/index.html is
# built with `routerBasePath:"/audiobookshelf"` baked in, so it asks for its assets under
# that prefix no matter what the server is mounted at. With `/` the server answers the
# page but every script request falls through to the SPA fallback and comes back as
# text/html — Chrome refuses to execute it and the UI never leaves "Loading…"
# (measured 2026-11-03; the API and /healthcheck were fine, which is why an API-only
# verification missed it).
Environment=PORT=8000 Environment=PORT=8000
Environment=ROUTER_BASE_PATH=/ Environment=ROUTER_BASE_PATH=/audiobookshelf
Environment=CONFIG_PATH=/config Environment=CONFIG_PATH=/config
Environment=METADATA_PATH=/metadata Environment=METADATA_PATH=/metadata
Environment=TZ=America/Vancouver Environment=TZ=America/Vancouver
@@ -45,6 +52,8 @@ Volume=/home/dank/Containers/audiobookshelf/metadata:/metadata
Volume=/mnt/entropy/Audiobooks:/audiobooks:ro Volume=/mnt/entropy/Audiobooks:/audiobooks:ro
# The image is Alpine: it ships wget and no curl, so the health check must name # The image is Alpine: it ships wget and no curl, so the health check must name
# the binary that exists or the container is unhealthy while serving perfectly. # the binary that exists or the container is unhealthy while serving perfectly.
# /healthcheck answers `OK` at the root AND under the base path (measured on both
# base paths), so the probe below is correct whatever ROUTER_BASE_PATH says.
HealthCmd=wget -q -O /dev/null http://127.0.0.1:8000/healthcheck HealthCmd=wget -q -O /dev/null http://127.0.0.1:8000/healthcheck
HealthInterval=30s HealthInterval=30s
HealthTimeout=10s HealthTimeout=10s
+125 -4
View File
@@ -21,26 +21,64 @@
# The credential is generated once into a mode-600 host file and never enters # The credential is generated once into a mode-600 host file and never enters
# the repo, the URL, or a process argument list. Same pattern as calibre-server. # the repo, the URL, or a process argument list. Same pattern as calibre-server.
# #
# Single sign-on (authentik) is armed by the same run, and only when the credential
# file carries ABS_OIDC_CLIENT_SECRET, so a local-only deployment is unchanged by a
# re-run. These OIDC facts came from the same pinned image, and each one is a trap:
# * isOpenIDAuthSettingsValid demands issuer + authorization + token + userinfo +
# jwks + clientID + clientSecret + signingAlgorithm at once. Miss one and the
# server SILENTLY strips 'openid' from authActiveAuthMethods and falls back to
# ['local'] while answering 200 everywhere — so the only honest check is what
# /status says AFTER the write, which is what this script does.
# * the algorithm is HS256, not the image's RS256 default: no provider on the
# fleet's authentik carries a signing key, so the client secret IS the signature
# key (scripts/authentik-audiobookshelf-sso.sh). Left at RS256, every id_token
# fails verification and the failure looks like a broken IdP.
# * the auth block lives on PATCH /api/auth-settings. PATCH /api/settings has a
# 20-key allowlist (PATCHABLE_SETTINGS_KEYS) with NO auth key in it, so writing
# the OIDC config there answers 200 and changes nothing — measured on this host
# 2026-11-02. The keys /api/auth-settings accepts are exactly its own
# authenticationSettings getters; anything else is skipped with a debug line.
# * authOpenIDSubfolderForRedirectURLs must be sent explicitly. The 2.17.4 migration only
# fills it on a DB that ALREADY had OIDC on, so a database created before SSO leaves it
# UNDEFINED and the server builds redirect_uri
# "http://host/undefined/auth/openid/callback" — authentik answers the authorize request
# with "Redirect URI Error" (measured 2026-11-02). This script derives the value from
# ABS_URL's path, so it cannot drift from the unit's ROUTER_BASE_PATH.
# * 'local' stays in authActiveAuthMethods on purpose: if authentik is ever down,
# the root account must still be able to sign into its own server.
#
# usage: audiobookshelf-init.sh # create what is missing, then scan # usage: audiobookshelf-init.sh # create what is missing, then scan
# ABS_SCAN=0 audiobookshelf-init.sh # bootstrap without a scan # ABS_SCAN=0 audiobookshelf-init.sh # bootstrap without a scan
# ABS_OIDC=0 audiobookshelf-init.sh # never touch the auth methods
# #
# environment (defaults are the deployed shape): # environment (defaults are the deployed shape):
# ABS_URL server base URL (http://127.0.0.1:8000) # ABS_URL server base URL INCLUDING the mounted base path
# (http://127.0.0.1:8000/audiobookshelf)
# ABS_SECRET credential file (/home/dank/.config/secrets/audiobookshelf) # ABS_SECRET credential file (/home/dank/.config/secrets/audiobookshelf)
# ABS_LIBRARY library name to ensure (Audiobooks) # ABS_LIBRARY library name to ensure (Audiobooks)
# ABS_FOLDER folder to bind inside the server (/audiobooks) # ABS_FOLDER folder to bind inside the server (/audiobooks)
# ABS_SCAN 1 triggers a scan (default), 0 skips it # ABS_SCAN 1 triggers a scan (default), 0 skips it
# ABS_OIDC auto (default) arms SSO when the credential file has a client
# secret, 1 requires one, 0 leaves the auth methods alone
# ABS_OIDC_ISSUER authentik issuer URL for this client
# ABS_OIDC_CLIENT_ID provider client id (audiobookshelf)
# ABS_OIDC_ALG id_token signing algorithm the IdP actually uses (HS256)
# ABS_OIDC_SUBFOLDER override for the redirect-URI subfolder (default: ABS_URL's path)
# #
# exit 0 every step is satisfied (created or already present) # exit 0 every step is satisfied (created or already present)
# exit 1 a step was refused or the server misbehaved (named on stderr) # exit 1 a step was refused or the server misbehaved (named on stderr)
# exit 2 usage / unreadable credential file # exit 2 usage / unreadable credential file
set -euo pipefail set -euo pipefail
ABS_URL=${ABS_URL:-http://127.0.0.1:8000} ABS_URL=${ABS_URL:-http://127.0.0.1:8000/audiobookshelf}
ABS_SECRET=${ABS_SECRET:-/home/dank/.config/secrets/audiobookshelf} ABS_SECRET=${ABS_SECRET:-/home/dank/.config/secrets/audiobookshelf}
ABS_LIBRARY=${ABS_LIBRARY:-Audiobooks} ABS_LIBRARY=${ABS_LIBRARY:-Audiobooks}
ABS_FOLDER=${ABS_FOLDER:-/audiobooks} ABS_FOLDER=${ABS_FOLDER:-/audiobooks}
ABS_SCAN=${ABS_SCAN:-1} ABS_SCAN=${ABS_SCAN:-1}
ABS_OIDC=${ABS_OIDC:-auto}
ABS_OIDC_ISSUER=${ABS_OIDC_ISSUER:-https://auth.franksdank.cloud/application/o/audiobookshelf/}
ABS_OIDC_CLIENT_ID=${ABS_OIDC_CLIENT_ID:-audiobookshelf}
ABS_OIDC_ALG=${ABS_OIDC_ALG:-HS256}
die() { printf 'refused: %s\n' "$*" >&2; exit 1; } die() { printf 'refused: %s\n' "$*" >&2; exit 1; }
@@ -49,6 +87,11 @@ case "$ABS_SCAN" in
*) printf 'refused: ABS_SCAN wants 0 or 1, got %s\n' "$ABS_SCAN" >&2; exit 2 ;; *) printf 'refused: ABS_SCAN wants 0 or 1, got %s\n' "$ABS_SCAN" >&2; exit 2 ;;
esac esac
case "$ABS_OIDC" in
auto | 0 | 1) ;;
*) printf 'refused: ABS_OIDC wants auto, 0 or 1, got %s\n' "$ABS_OIDC" >&2; exit 2 ;;
esac
# ---- credential ------------------------------------------------------------- # ---- credential -------------------------------------------------------------
umask 077 umask 077
if [ ! -f "$ABS_SECRET" ]; then if [ ! -f "$ABS_SECRET" ]; then
@@ -72,8 +115,10 @@ chmod 600 "$ABS_SECRET"
# python3 rather than curl so the password and the bearer token never appear in # python3 rather than curl so the password and the bearer token never appear in
# any argv (they would be world-readable in /proc/<pid>/cmdline otherwise). # any argv (they would be world-readable in /proc/<pid>/cmdline otherwise).
ABS_URL="$ABS_URL" ABS_SECRET="$ABS_SECRET" ABS_LIBRARY="$ABS_LIBRARY" \ ABS_URL="$ABS_URL" ABS_SECRET="$ABS_SECRET" ABS_LIBRARY="$ABS_LIBRARY" \
ABS_FOLDER="$ABS_FOLDER" ABS_SCAN="$ABS_SCAN" python3 - <<'PY' ABS_FOLDER="$ABS_FOLDER" ABS_SCAN="$ABS_SCAN" ABS_OIDC="$ABS_OIDC" \
import json, os, sys, urllib.error, urllib.request ABS_OIDC_ISSUER="$ABS_OIDC_ISSUER" ABS_OIDC_CLIENT_ID="$ABS_OIDC_CLIENT_ID" \
ABS_OIDC_ALG="$ABS_OIDC_ALG" python3 - <<'PY'
import json, os, sys, urllib.error, urllib.parse, urllib.request
BASE = os.environ["ABS_URL"].rstrip("/") BASE = os.environ["ABS_URL"].rstrip("/")
LIB = os.environ["ABS_LIBRARY"] LIB = os.environ["ABS_LIBRARY"]
@@ -184,5 +229,81 @@ code, items = call("GET", "/api/libraries/%s/items?limit=1" % lib["id"], token=t
if code == 200 and isinstance(items, dict): if code == 200 and isinstance(items, dict):
print("items: %s (a fresh scan raises this from 0; watch it, not /healthcheck)" print("items: %s (a fresh scan raises this from 0; watch it, not /healthcheck)"
% items.get("total")) % items.get("total"))
# ---- single sign-on (authentik) --------------------------------------------------
# The provider this points at is provisioned by scripts/authentik-audiobookshelf-sso.sh;
# the two share exactly one value, the client secret in the credential file.
mode = os.environ["ABS_OIDC"]
oidc_secret = cred.get("ABS_OIDC_CLIENT_SECRET") or ""
if mode == "0":
print("oidc: skipped (ABS_OIDC=0)")
elif not oidc_secret:
if mode == "1":
sys.exit("refused: ABS_OIDC=1 but %s has no ABS_OIDC_CLIENT_SECRET — run "
"scripts/authentik-audiobookshelf-sso.sh first" % os.environ["ABS_SECRET"])
print("oidc: not configured (no ABS_OIDC_CLIENT_SECRET in the credential file)"
" — local auth only")
else:
client_id = os.environ["ABS_OIDC_CLIENT_ID"]
issuer = os.environ["ABS_OIDC_ISSUER"].rstrip("/") + "/"
# redirect_uri = <origin><subfolder>/auth/openid/callback, and the subfolder IS the
# base path the app is mounted under — taken from ABS_URL so unit and config agree.
subfolder = os.environ.get("ABS_OIDC_SUBFOLDER")
if subfolder is None:
subfolder = urllib.parse.urlparse(os.environ["ABS_URL"]).path.rstrip("/")
origin = issuer.split("/application/")[0]
body = {
"authActiveAuthMethods": ["local", "openid"],
"authOpenIDIssuerURL": issuer,
"authOpenIDAuthorizationURL": origin + "/application/o/authorize/",
"authOpenIDTokenURL": origin + "/application/o/token/",
"authOpenIDUserInfoURL": origin + "/application/o/userinfo/",
"authOpenIDJwksURL": issuer + "jwks/",
"authOpenIDLogoutURL": issuer + "end-session/",
"authOpenIDClientID": client_id,
"authOpenIDClientSecret": oidc_secret,
"authOpenIDTokenSigningAlgorithm": os.environ["ABS_OIDC_ALG"],
"authOpenIDSubfolderForRedirectURLs": subfolder,
"authOpenIDButtonText": "Sign in with Authentik",
# not auto-launch: the login page keeps offering the local form, which is the
# break-glass path when the IdP is unreachable.
"authOpenIDAutoLaunch": False,
# a household user's first SSO sign-in creates their account; the group claim
# decides the role, and a user in neither group is denied (fail closed).
"authOpenIDAutoRegister": True,
"authOpenIDGroupClaim": "abs_groups",
# The app's own scheme lives HERE, never in the IdP: ABS swaps it for
# <base>/auth/openid/mobile-redirect before the provider sees a redirect_uri.
"authOpenIDMobileRedirectURIs": ["audiobookshelf://oauth"],
}
code, res = call("PATCH", "/api/auth-settings", body, token=token)
if code != 200:
sys.exit("refused: PATCH /api/auth-settings answered %s (%s)" % (code, res))
# Verify twice: what was stored, and what the server will honour. A config that
# misses any of the eight required OIDC values is accepted with 200 and then
# silently dropped at the next load, leaving authMethods=['local'].
code, stored = call("GET", "/api/auth-settings", token=token)
if code != 200 or not isinstance(stored, dict):
sys.exit("refused: GET /api/auth-settings answered %s (%s)" % (code, stored))
for key, want in (("authOpenIDIssuerURL", issuer),
("authOpenIDTokenSigningAlgorithm", os.environ["ABS_OIDC_ALG"]),
("authOpenIDSubfolderForRedirectURLs", subfolder),
("authOpenIDButtonText", "Sign in with Authentik")):
if stored.get(key) != want:
sys.exit("refused: stored %s=%r, expected %r" % (key, stored.get(key), want))
if sorted(stored.get("authActiveAuthMethods") or []) != ["local", "openid"]:
sys.exit("refused: stored authActiveAuthMethods=%r, expected local+openid "
"('openid' is dropped when the block is incomplete)"
% stored.get("authActiveAuthMethods"))
code, after = call("GET", "/status")
methods = after.get("authMethods") if isinstance(after, dict) else None
if not isinstance(methods, list) or "openid" not in methods:
sys.exit("refused: /status reports authMethods=%s after a stored config that "
"looks complete — restart the service, the strategy is only armed at "
"load for a config that was invalid when the server read it" % methods)
print("oidc: armed (issuer %s | alg %s | claim abs_groups | redirect subfolder %r | "
"client %s | secret %s chars | authMethods %s)"
% (issuer, os.environ["ABS_OIDC_ALG"], subfolder, client_id, len(oidc_secret),
methods))
PY PY
exit 0 exit 0
+262
View File
@@ -0,0 +1,262 @@
#!/usr/bin/env bash
# authentik-audiobookshelf-sso.sh — provision the authentik side of Audiobookshelf SSO.
#
# Why a script: this state lives in authentik's DATABASE, not in any repo file, and a
# blueprint re-import or a rebuild-from-scratch reverts hand-made provider rows
# silently. Same reasoning (and same door) as
# authentik-set-login-session-duration.sh: ssh into Dankclaw, `ak shell` from stdin.
# Re-run this after such a rebuild.
#
# The contract with Audiobookshelf, read off the pinned image (2.37.2,
# dist-server/server/auth/OidcAuthStrategy.js + objects/settings/ServerSettings.js)
# 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
# reached at, plus <base>/auth/openid/mobile-redirect on ABS_BASE only. ABS SWAPS
# the app's own audiobookshelf://oauth for the mobile-redirect URL before the IdP
# ever sees a redirect_uri, so registering the app scheme with authentik — the
# obvious move — is what makes phone logins fail. The app scheme belongs in ABS's
# authOpenIDMobileRedirectURIs list (its built-in default), not here. The mobile
# URI is registered on the LAN origin only because the phone is configured with
# the LAN address: it is a non-browser client and cannot solve the guard
# challenge that sits in front of the vhost.
# * <base> CARRIES THE BASE PATH. ABS builds redirect_uri as
# <origin><authOpenIDSubfolderForRedirectURLs>/auth/openid/callback, and that subfolder is
# the ROUTER_BASE_PATH the container is mounted at — /audiobookshelf, which is also the only
# base path the shipped SPA can boot under. Left unset (the fresh-DB default) the URL
# becomes "/undefined/…"; a provider registered without the prefix mismatches the same way
# (measured 2026-11-02, "Redirect URI Error"). audiobookshelf-init.sh derives its copy from
# ABS_URL, so the two sides each move with one variable.
# * ABS requests the scopes "openid profile email <GroupClaim> [<PermsClaim>]": the
# CLAIM NAME is a scope name. So authentik needs a scope mapping whose scope_name
# is exactly the claim name, or the claim never arrives and the callback dies on
# "Group claim <name> not found in userinfo".
# * the claim's VALUES must be ABS role names — the callback picks the first of
# ['admin','user','guest'] present in the list. Group names like
# audiobookshelf_admins are therefore translated by the mapping, never passed
# through. A user in neither group gets [] and is denied (fail closed).
# * tokens are HS256 here: no provider on this box carries a signing_key (census
# 2026-11-02), which makes the client secret the signature key. ABS's own default
# is RS256, so the ABS side MUST be set to HS256 or every id_token fails
# verification.
# * provider.authorization_flow must be non-NULL or /application/o/authorize/
# answers a 500 with 'NoneType' object has no attribute 'slug' (measured on
# grafana, same authentik build).
# * provider.grant_types MUST contain authorization_code. authentik 2026.8 defaults an
# ORM-created provider to the EMPTY list, and /application/o/authorize/ then refuses the
# code flow with oauthlib's generic "The request is otherwise malformed"; the only line
# that names the cause is the server log's 'Invalid grant_type for provider'. The
# redirect-URI check runs FIRST, so a provider carrying both faults shows only the URI
# error until the URI is fixed (measured 2026-11-02, where the login survived the URI fix
# and died here).
# * property_mappings must carry the built-in OpenID scope mappings ('openid', 'profile',
# 'email') alongside the claim mapping. A provider holding only the claim mapping grants a
# token scoped shorter than the scopes ABS requests, and ABS's /userinfo call then dies
# 'insufficient_scope (The request requires higher privileges than provided by the access
# token)' after authentik has already issued a good code (measured 2026-11-02).
#
# Idempotent: every object is created or asserted. Safe to re-run.
#
# usage: ABS_OIDC_CLIENT_SECRET=<secret> authentik-audiobookshelf-sso.sh
# (the secret is the value stored in
# /home/dank/.config/secrets/audiobookshelf on Dankserver — get it with
# ssh dankserver awk -F= '/^ABS_OIDC_CLIENT_SECRET=/{print $2}' \
# ~/.config/secrets/audiobookshelf)
#
# environment:
# ABS_OIDC_CLIENT_SECRET REQUIRED. The provider's client secret.
# ABS_OIDC_CLIENT_ID provider/client id (audiobookshelf)
# ABS_OIDC_GROUPS_CLAIM claim ABS reads for roles (abs_groups)
# ABS_OIDC_USERNAME authentik user made admin (akadmin)
# ABS_OIDC_USERS space-separated authentik usernames given the USER group
# (default: nobody — an empty list is fail-closed, and the
# household decides who listens to audiobooks)
# 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
# signs in as this identity, so a re-run restores the fixture)
# ABS_BASE ABS URL as the browser sees it, base path included
# (http://192.168.0.235:8000/audiobookshelf — the LAN
# origin, reached directly, which is also what the phone
# app is configured with)
# ABS_EXTRA_BASES space-separated additional origins, base path included
# (default: https://audiobooks.franksdank.cloud/audiobookshelf
# — the guarded vhost, dankclaw/Caddyfile).
# ABS builds its redirect_uri from the ORIGIN of the
# request, so each origin the app is served at needs its
# own registered URI — an unregistered origin is a
# redirect_uri_mismatch at the IdP, before any credential.
# Set it empty to register the LAN origin alone.
# AUTHENTIK_HOST ssh target (dankclaw)
set -euo pipefail
SECRET="${ABS_OIDC_CLIENT_SECRET:?set ABS_OIDC_CLIENT_SECRET (see the header)}"
[ "${#SECRET}" -ge 32 ] || { echo "refused: ABS_OIDC_CLIENT_SECRET is too short to be a secret" >&2; exit 2; }
CLIENT_ID="${ABS_OIDC_CLIENT_ID:-audiobookshelf}"
CLAIM="${ABS_OIDC_GROUPS_CLAIM:-abs_groups}"
ADMIN_GROUP="audiobookshelf_admins"
USER_GROUP="audiobookshelf_users"
SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
MEMBERS="${ABS_OIDC_USERS:-}"
E2E_ADMIN="${ABS_E2E_ADMIN:-qa-e2e-admin}"
BASE="${ABS_BASE:-http://192.168.0.235:8000/audiobookshelf}"
EXTRA_BASES="${ABS_EXTRA_BASES:-https://audiobooks.franksdank.cloud/audiobookshelf}"
HOST="${AUTHENTIK_HOST:-dankclaw}"
# The claim expression: translate group membership into ABS role names. It is stored
# verbatim so a later read of the provider shows exactly this policy.
read -r -d '' EXPR <<PYEXPR || true
groups = {g.name for g in user.groups.all()}
roles = []
if "$ADMIN_GROUP" in groups:
roles.append("admin")
if groups & {"$ADMIN_GROUP", "$USER_GROUP"}:
roles.append("user")
return {"$CLAIM": roles}
PYEXPR
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
from authentik.core.models import Application, Group, User
from authentik.flows.models import Flow
from authentik.providers.oauth2.models import (
ClientType, OAuth2Provider, RedirectURI, RedirectURIMatchingMode, RedirectURIType, ScopeMapping)
ADMIN_GROUP, USER_GROUP = "$ADMIN_GROUP", "$USER_GROUP"
CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE"
BASES = [BASE] + """$EXTRA_BASES""".split()
SSO_USER, E2E_ADMIN = "$SSO_USER", "$E2E_ADMIN"
MEMBERS = """$MEMBERS""".split()
EXPR = '''$EXPR'''
# 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
# 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.
for name in (ADMIN_GROUP, USER_GROUP):
Group.objects.get_or_create(name=name)
admin_g, user_g = Group.objects.get(name=ADMIN_GROUP), Group.objects.get(name=USER_GROUP)
# --- the claim mapping (scope_name IS the claim name ABS requests) ---------------
mapping = ScopeMapping.objects.filter(name="audiobookshelf-groups").first()
if mapping is None:
mapping = ScopeMapping.objects.create(name="audiobookshelf-groups", scope_name=CLAIM,
expression=EXPR,
description="Audiobookshelf role names from group membership")
print("mapping: created (scope_name %s)" % CLAIM)
elif mapping.scope_name != CLAIM or mapping.expression != EXPR:
mapping.scope_name, mapping.expression = CLAIM, EXPR
mapping.save(update_fields=["scope_name", "expression"])
print("mapping: scope/expression updated (scope_name %s)" % CLAIM)
else:
print("mapping: already current")
# 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`.
_src = "def _probe(user, request=None):\n" + "".join(" %s\n" % ln for ln in EXPR.splitlines())
_ns = {}
exec(_src, _ns)
# --- provider --------------------------------------------------------------------
# One browser callback per origin (LAN address and the guarded vhost), because ABS
# derives redirect_uri from the request origin; the mobile URI stays on the LAN origin.
uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
url="%s/auth/openid/callback" % b) for b in BASES]
uris.append(RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
url="%s/auth/openid/mobile-redirect" % BASE))
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
# defaults it empty), the built-in OpenID scope mappings beside the claim mapping, and the
# redirect URIs with the base path baked in.
GRANTS = ["authorization_code", "refresh_token"]
BUILTIN_SCOPES = ["authentik default OAuth Mapping: OpenID 'openid'",
"authentik default OAuth Mapping: OpenID 'profile'",
"authentik default OAuth Mapping: OpenID 'email'"]
scoped = {m.pk: m for m in ScopeMapping.objects.filter(name__in=BUILTIN_SCOPES)}
scoped[mapping.pk] = mapping
_have = {m.name for m in scoped.values()}
if len(_have) < len(BUILTIN_SCOPES):
print("scope mappings: NOT PROVIDED by this authentik install: %s"
% ", ".join(sorted(set(BUILTIN_SCOPES) - _have)))
p = OAuth2Provider.objects.filter(name="audiobookshelf").first()
if p is None:
p = OAuth2Provider(name="audiobookshelf", client_id=CLIENT_ID,
client_type=ClientType.CONFIDENTIAL, client_secret=SECRET,
authorization_flow=authz_flow, grant_types=list(GRANTS))
p.redirect_uris = uris
p.save()
p.property_mappings.set(list(scoped.values()))
print("provider: created (grant_types %s)" % GRANTS)
else:
changed = []
if p.client_id != CLIENT_ID:
p.client_id = CLIENT_ID
changed.append("client_id")
if p.client_secret != SECRET:
p.client_secret = SECRET
changed.append("client_secret")
if p.authorization_flow_id != authz_flow.pk:
p.authorization_flow = authz_flow
changed.append("authorization_flow")
if list(p.grant_types) != GRANTS:
p.grant_types = list(GRANTS)
changed.append("grant_types")
if [(u.url, str(u.matching_mode)) for u in p.redirect_uris] != \
[(u.url, str(u.matching_mode)) for u in uris]:
p.redirect_uris = uris
changed.append("redirect_uris")
if changed:
p.save()
if sorted(m.pk for m in p.property_mappings.all()) != sorted(scoped):
p.property_mappings.set(list(scoped.values()))
changed.append("property_mappings")
print("provider: asserted (%s)" % (", ".join(changed) or "nothing to change"))
# --- application (the launchpad entry; binds the provider) ------------------------
app = Application.objects.filter(slug="audiobookshelf").first()
if app is None:
app = Application.objects.create(name="audiobookshelf", slug="audiobookshelf",
provider=p, meta_launch_url=BASE)
print("application: created")
elif app.provider_id != p.pk or app.meta_launch_url != BASE:
app.provider, app.meta_launch_url = p, BASE
app.save(update_fields=["provider", "meta_launch_url"])
print("application: asserted")
else:
print("application: already bound")
# --- membership --------------------------------------------------------------------
root = User.objects.get(username=SSO_USER)
root.groups.add(admin_g)
# The deploy gate's Playwright spec signs in as this identity, so the admin fixture is
# provisioned with the rest: a blueprint re-import that drops it fails the gate, not a phone.
e2e = User.objects.filter(username=E2E_ADMIN).first()
if e2e is None:
print("e2e fixture: authentik user %s absent — the e2e SSO spec will fail" % E2E_ADMIN)
else:
e2e.groups.add(admin_g)
print("e2e fixture: %s in %s" % (E2E_ADMIN, ADMIN_GROUP))
print("admin: %s in %s" % (SSO_USER, ADMIN_GROUP))
for name in MEMBERS:
u = User.objects.get(username=name)
u.groups.add(user_g)
print("users: %s in %s" % (", ".join(MEMBERS) or "(none — fail closed)", USER_GROUP))
# --- 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]:
print("claim for %s -> %s" % (probe.username, _ns["_probe"](user=probe, request=None)))
p.refresh_from_db()
print("provider %s | client_id %s | flow %s | grants %s | uris %s" % (
p.name, p.client_id, p.authorization_flow.slug, list(p.grant_types),
[u.url for u in p.redirect_uris]))
print("scopes: %s" % sorted(m.scopemapping.scope_name for m in p.property_mappings.all()))
PYEOF
cat <<DONE
Next: arm the Audiobookshelf side (issuer, client, HS256, claim) —
ssh dankserver 'ABS_OIDC=1 bash /home/dank/serverAppConfigs/scripts/audiobookshelf-init.sh'
It reads the same secret from /home/dank/.config/secrets/audiobookshelf and verifies
/status answers openid among its auth methods.
DONE
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env bash
# Census and repair the OAuth2 defaults on every authentik PROXY provider.
#
# A proxy provider built by hand in `ak shell` never runs
# ProxyProvider.set_oauth_defaults() — the REST serializer calls it, the ORM does
# not — so the row looks perfectly configured (external_host, internal_host,
# application bound, embedded-outpost claim present) while three OAuth facts are
# blank. Each one kills the guarded login in a different, unrelated-looking way,
# all BEFORE any credential is asked for:
#
# * redirect_uris empty -> authentik answers its own page:
# "Redirect URI Error — The request fails due to a missing, invalid, or
# mismatching redirection URI (redirect_uri)". Measured on the Audiobookshelf
# guard provider, 2026-11-03.
# * grant_types empty -> the authorize endpoint refuses the outpost's
# authorization-code request with a bodiless HTTP 400 (Chromium renders
# "This page isn't working / HTTP ERROR 400"). Same provider, one step
# later, after the URIs were filled in by hand.
# * authorization_flow NULL -> the authorize view crashes building its flow
# planner: AttributeError 'NoneType' object has no attribute 'slug', HTTP
# 500. That is what authentik-grafana-sso-fix.sh exists for.
#
# So: a guard that shows ANY of those three pages is not an Anubis problem and
# not a Caddy problem — run this script before touching the route.
#
# Repair calls the VENDOR's own set_oauth_defaults() (grant_types = authz code +
# client credentials + password, confidential client, the stock scope mappings,
# redirect_uris derived from external_host by _get_callback_url) and points the
# authorization flow at default-provider-authorization-implicit-consent, the flow
# every working provider here uses. Providers that already match are untouched,
# so a deliberate per-provider deviation is never silently flattened.
#
# This is authentik DB state. It is NOT in any deployable file: a blueprint
# re-import, or an ak-shell re-creation of a provider, reverts these facts and
# the symptom returns verbatim. Re-run after any such rebuild. Idempotent.
#
# Usage: authentik-proxy-provider-defaults.sh [--check]
# no argument repair what drifts, print one line per provider
# --check census only, exit 1 if anything drifts (gate use)
# override host: AUTHENTIK_HOST=…
set -euo pipefail
HOST="${AUTHENTIK_HOST:-dankclaw}"
MODE="${1:-}"
case "$MODE" in
"" | --check) : ;;
*) echo "usage: $(basename "$0") [--check]" >&2; exit 2 ;;
esac
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
from authentik.flows.models import Flow
from authentik.providers.proxy.models import ProxyProvider, _get_callback_url
CHECK = $( [ "$MODE" = "--check" ] && echo True || echo False )
FLOW = "default-provider-authorization-implicit-consent"
WANT_GRANTS = ["authorization_code", "client_credentials", "password"]
flow = Flow.objects.filter(slug=FLOW).first()
if not flow and not CHECK:
raise SystemExit("flow %s is missing; nothing was changed" % FLOW)
drift = 0
for p in ProxyProvider.objects.all().order_by("pk"):
want_uris = [u.url for u in _get_callback_url(p.external_host)]
have_uris = [u.url for u in p.redirect_uris]
bad = []
if [str(g) for g in p.grant_types] != WANT_GRANTS:
bad.append("grant_types=%s" % [str(g) for g in p.grant_types])
if have_uris != want_uris:
bad.append("redirect_uris=%s" % have_uris)
if p.authorization_flow_id is None:
bad.append("authorization_flow=None")
if not bad:
print("ok %s (ext %s)" % (p.name, p.external_host))
continue
drift += 1
if CHECK:
print("DRIFT %s: %s" % (p.name, "; ".join(bad)))
continue
print("fix %s: %s" % (p.name, "; ".join(bad)))
p.set_oauth_defaults()
if p.authorization_flow_id is None:
p.authorization_flow = flow
p.save()
after = ProxyProvider.objects.get(pk=p.pk)
print(" -> grants %s, %d redirect URIs, flow %s"
% ([str(g) for g in after.grant_types], len(after.redirect_uris),
after.authorization_flow and after.authorization_flow.slug))
print("providers checked, drift: %d" % drift)
raise SystemExit(1 if (CHECK and drift) else 0)
PYEOF
echo "Verify: walk the guarded vhost in a browser — Anubis solves, authentik"
echo "renders its login form (no Redirect URI Error, no HTTP 400), and the"
echo "callback lands back on the app."
+8 -2
View File
@@ -1315,7 +1315,8 @@ deploy_audiobookshelf() {
mkdir -p /home/dank/Containers/audiobookshelf/config \ mkdir -p /home/dank/Containers/audiobookshelf/config \
/home/dank/Containers/audiobookshelf/metadata /home/dank/Containers/audiobookshelf/metadata
systemctl --user restart audiobookshelf.service systemctl --user restart audiobookshelf.service
# /healthcheck answers once the HTTP server is bound; the library scan that # /healthcheck answers once the HTTP server is bound, at the root whatever
# ROUTER_BASE_PATH says (measured on both base paths); the library scan that
# follows runs in the background and must not gate the deploy. # follows runs in the background and must not gate the deploy.
wait_for_health audiobookshelf http://127.0.0.1:8000/healthcheck 90 wait_for_health audiobookshelf http://127.0.0.1:8000/healthcheck 90
# A server with no root user and no library is "healthy" and useless, so the # A server with no root user and no library is "healthy" and useless, so the
@@ -1323,7 +1324,12 @@ deploy_audiobookshelf() {
# it creates the account (credential in the mode-600 host secret file), the # it creates the account (credential in the mode-600 host secret file), the
# Audiobooks library over the /audiobooks bind, and fires the scan. Every # Audiobooks library over the /audiobooks bind, and fires the scan. Every
# step is idempotent, so a re-deploy costs one incremental scan. # step is idempotent, so a re-deploy costs one incremental scan.
scripts/audiobookshelf-init.sh # The bootstrapper has to speak the same base path the unit mounts (the API, the
# login route and the OIDC redirect subfolder all hang off it), so the URL is read
# out of the unit file here instead of being restated in the script's default.
abs_base=$(awk -F= '/^Environment=ROUTER_BASE_PATH=/{print $3}' \
dankserver/audiobookshelf/quadlet/audiobookshelf.container)
ABS_URL="http://127.0.0.1:8000${abs_base}" scripts/audiobookshelf-init.sh
} }
# The content server's single admin account and the desktop's basic-auth # The content server's single admin account and the desktop's basic-auth
@@ -1,11 +1,11 @@
--- ---
type: reference type: reference
description: "Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, laws." description: "Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, guarded vhost, laws."
tags: tags:
- dankserver - dankserver
- audiobooks - audiobooks
- quadlet - quadlet
timestamp: 2026-11-02 timestamp: 2026-11-03
--- ---
# Audiobookshelf # Audiobookshelf
@@ -16,8 +16,8 @@ per-listener playback positions, and streams the audio. It is **not** a media
server — it shares nothing with [[Plex]] or [[Jellyfin]] and owns no files on the server — it shares nothing with [[Plex]] or [[Jellyfin]] and owns no files on the
array. array.
**Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user **Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user manager) —
manager) — **Internal-only:** LAN address, no vhost, no DNS name. **Doors:** LAN `http://192.168.0.235:8000/audiobookshelf` (direct, what the phone app uses) and guarded `https://audiobooks.franksdank.cloud/audiobookshelf/`.
## Shape ## Shape
@@ -26,7 +26,7 @@ manager) — **Internal-only:** LAN address, no vhost, no DNS name.
| Unit file | `dankserver/audiobookshelf/quadlet/audiobookshelf.container` (the authority for every number here) | | Unit file | `dankserver/audiobookshelf/quadlet/audiobookshelf.container` (the authority for every number here) |
| Image | `ghcr.io/advplyr/audiobookshelf` pinned by **amd64 manifest digest** — see Laws | | Image | `ghcr.io/advplyr/audiobookshelf` pinned by **amd64 manifest digest** — see Laws |
| Port | `Environment=PORT=8000` + `PublishPort=8000:8000` — web UI and API on one port | | Port | `Environment=PORT=8000` + `PublishPort=8000:8000` — web UI and API on one port |
| Base path | `Environment=ROUTER_BASE_PATH=/` — the URL is the plain `host:port`, no `/audiobookshelf` prefix | | Base path | `Environment=ROUTER_BASE_PATH=/audiobookshelf` — every URL below carries this prefix, and the shipped SPA cannot boot without it (Laws) |
| Network | `pasta` (standalone; publishes nothing to other containers) | | Network | `pasta` (standalone; publishes nothing to other containers) |
| Audio bind | `/mnt/entropy/Audiobooks:/audiobooks:ro` — read-only, by design | | Audio bind | `/mnt/entropy/Audiobooks:/audiobooks:ro` — read-only, by design |
| State | `/home/dank/Containers/audiobookshelf/config` → `/config`, `…/metadata` → `/metadata` (both NVMe) | | State | `/home/dank/Containers/audiobookshelf/config` → `/config`, `…/metadata` → `/metadata` (both NVMe) |
@@ -36,6 +36,7 @@ manager) — **Internal-only:** LAN address, no vhost, no DNS name.
| Self-healing | `Restart=always`, `RestartSec=5`, `WantedBy=default.target` | | Self-healing | `Restart=always`, `RestartSec=5`, `WantedBy=default.target` |
| Deploy | `./scripts/deploy.sh dankserver --service audiobookshelf` | | Deploy | `./scripts/deploy.sh dankserver --service audiobookshelf` |
| Handler | `deploy_audiobookshelf` in `scripts/deploy-remote.sh` | | Handler | `deploy_audiobookshelf` in `scripts/deploy-remote.sh` |
| Single sign-on | authentik provider `audiobookshelf`; the local `frank` account stays enabled as break-glass — see *Single sign-on* |
| Library layout | one book per folder — ABS groups by the first directory holding a media file (see Laws); every move is journalled at `/mnt/entropy/.media-campaign/media-relayout-audiobooks.jsonl` | | Library layout | one book per folder — ABS groups by the first directory holding a media file (see Laws); every move is journalled at `/mnt/entropy/.media-campaign/media-relayout-audiobooks.jsonl` |
## Bootstrap ## Bootstrap
@@ -54,7 +55,7 @@ difference. The script is idempotent; re-running it costs one incremental scan.
| scan | `POST /api/libraries/<id>/scan` | `scan: accepted` | | scan | `POST /api/libraries/<id>/scan` | `scan: accepted` |
| proof | `GET /api/libraries/<id>/items?limit=1` | `items: <total>` | | proof | `GET /api/libraries/<id>/items?limit=1` | `items: <total>` |
Sign in at `http://192.168.0.235:8000` as `frank`; the password is Sign in at `http://192.168.0.235:8000/audiobookshelf` as `frank`; the password is
`awk -F= '/^ABS_ADMIN_PASSWORD=/{print $2}' ~/.config/secrets/audiobookshelf` on `awk -F= '/^ABS_ADMIN_PASSWORD=/{print $2}' ~/.config/secrets/audiobookshelf` on
Dankserver. Change it in **Settings → Users → frank → Password**; the secret file Dankserver. Change it in **Settings → Users → frank → Password**; the secret file
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
@@ -68,12 +69,14 @@ 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`, username `frank`, and the same password as the web UI — `http://192.168.0.235:8000/audiobookshelf` — the base path belongs in the address — username
no base-path prefix, plain HTTP. Verified from a second host 2026-11-03: `/healthcheck` `frank`, and the same password as the web UI, or the SSO button; plain HTTP. Measured from a
and `/ping` answer 200 across the LAN and `iptables -S INPUT` carries no rule for 8000, second host 2026-11-02 and re-measured on both base paths: `/ping` (`{"success":true}`) and
so a phone on `192.168.0.0/24` connects with no further setup; `download: true` on the `/healthcheck` (`OK`) answer at the ROOT whatever `ROUTER_BASE_PATH` says, so the app's
root account is what makes offline listening work. Away from the LAN it cannot connect — connectivity probe works before it resolves the base path at all, and `iptables -S INPUT`
see the LAN-only law, and note that the guard chain is not the remedy there. carries no rule for 8000 — a phone on `192.168.0.0/24` connects with no further setup, and
`download: true` on the root account is what makes offline listening work. Away
from the LAN, see the two-doors law in *Laws* — the guarded vhost is not the remedy.
**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
@@ -81,6 +84,29 @@ 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.
## Single sign-on (authentik)
Both doors take the household identity: the web UI puts **Sign in with Authentik** beside
the local form, and the phone app offers SSO on its server-address screen. The local
`frank` account stays enabled on purpose — `authActiveAuthMethods` is `local` **and**
`openid`, and `local` is how you get in when the IdP is down.
| 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 |
| Client secret | `ABS_OIDC_CLIENT_SECRET` in `~/.config/secrets/audiobookshelf` on Dankserver (mode 600; never in the repo, a URL or an argv) |
| The ABS half: issuer, the four IdP URLs, claim, auto-register | `scripts/audiobookshelf-init.sh`, armed automatically when that secret exists in the credential file |
| Role at first SSO sign-in | the `abs_groups` claim: `audiobookshelf_admins` → `admin`, `audiobookshelf_users` → `user`, in neither → denied (fail closed) |
| 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 |
The IdP saying yes is not the same as being allowed in: with `authOpenIDAutoRegister` on,
a first sign-in creates the account, and the claim alone decides the type. A user in
neither group is refused after a successful authentication — that is the mapping working,
not a broken login.
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.
## Laws ## Laws
- **Speak the image's env names, not the README's.** The server reads `PORT`, - **Speak the image's env names, not the README's.** The server reads `PORT`,
@@ -91,6 +117,54 @@ as though the password were wrong.
`active (running)` while every probe of `:8000` was refused and the deploy gate `active (running)` while every probe of `:8000` was refused and the deploy gate
failed at 180 s. Two seconds with the pinned digest settles it: failed at 180 s. Two seconds with the pinned digest settles it:
`podman inspect --format '{{.Config.Env}}' <image>`. `podman inspect --format '{{.Config.Env}}' <image>`.
- **The URL carries `/audiobookshelf`, and that is not a style choice.** The SPA in
`/app/client/dist/index.html` is built with `routerBasePath:"/audiobookshelf"`, so it asks
for its assets under that prefix whatever the server is mounted at. Mounted at `/`, every
script request falls through to the SPA fallback, comes back `text/html`, Chrome refuses to
execute it, and the page never leaves "Loading…" — while `/healthcheck`, the API and the
phone app stay perfectly healthy, which is how this hid behind an API-only verification
(measured 2026-11-02). Prove a UI claim in a browser, never from `/health`.
- **The OIDC redirect subfolder is a value you send, not a default you inherit.** The 2.17.4
upgrade only fills `authOpenIDSubfolderForRedirectURLs` on a database that ALREADY had OIDC
enabled, so a DB created before SSO leaves it unset and the server builds
`http://host/undefined/auth/openid/callback`. authentik answers the authorize request with
"Redirect URI Error" and nothing on the ABS side looks wrong (measured 2026-11-02). The
provider's registered URI must equal `<origin><subfolder>/auth/openid/callback`, so each
script derives the value from one variable of its own (`ABS_URL`, `ABS_BASE`) and
`tests/audiobookshelf_init.bats` pins what reaches the wire.
- **An ORM-created provider has no grant types.** authentik 2026.8 defaults `grant_types` to
`[]`, and `/application/o/authorize/` then refuses the code flow with oauthlib's generic
"The request is otherwise malformed" — the wire names nothing, the server log line that names
it is `Invalid grant_type for provider`. Worse, the redirect-URI check runs *first*, so a
provider carrying both faults shows only "Redirect URI Error" until the URI is fixed, and the
second wall appears after the fix ships (measured 2026-11-02: the browser reached authentik,
got `code`+`state`, and ABS still bounced to `/login?error=undefined`). The script sets
`authorization_code` + `refresh_token`; a blueprint re-import reverts the row silently.
- **The guarded door has to tell the truth about its scheme.** ABS validates every OIDC
`callback` against the origin it believes it is served from — `getRequestOrigin()` is
`x-forwarded-proto://Host` (`server/utils/requestUtils.js`, read in the pinned digest
2026-11-03) — so behind a TLS edge that reaches the container over plain http it computes
`http://audiobooks.franksdank.cloud` and answers its own https callback with a 400 "Invalid callback
URL - must be same-origin" before the IdP is ever contacted. The Caddy `:8017` route pins
`header_up X-Forwarded-Proto https` for exactly that reason ([[Caddy]] *Guard a new vhost*);
the LAN door needs nothing, because there browser and server agree on `http`.
- **The claim mapping is not the whole `property_mappings` list.** A provider holding only the
custom mapping grants a token scoped shorter than the scopes ABS requests
(`openid profile email abs_groups`): the code is issued and exchanged normally, and ABS's
`/userinfo` call then dies `insufficient_scope (The request requires higher privileges than
provided by the access token)` — a fault that reads like an ABS bug and is an IdP row. Attach
the built-in OpenID `openid`/`profile`/`email` mappings beside the claim mapping, which is
what every working RP on this IdP carries (measured 2026-11-02).
- **With `authOpenIDMatchExistingBy` unset, an SSO identity never lands on an existing ABS
account, and a name collision is a hard denial.** In that mode `findUserFromOpenIdUserInfo`
matches on `sub` only, so every first sign-in auto-registers a NEW user; if the IdP username
equals an ABS username — the local root `frank` is the one here — the create hits the unique
constraint and the callback dies `Unauthorized`. So "create a `frank` user in authentik" does
not reach the root account: either set `authOpenIDMatchExistingBy` to `username` (links the
IdP account to the existing one, and `setUserGroup` refuses to downgrade a root — root plus an
`admin` claim is allowed, root plus `user` is denied) or pick a non-colliding IdP name. Read
off `server/models/User.js` and `server/auth/OidcAuthStrategy.js` in the pinned digest
(2026-11-02).
- **The audio bind stays `:ro`.** ABS writes nothing that belongs beside the - **The audio bind stays `:ro`.** ABS writes nothing that belongs beside the
audio: its library, covers, progress and sidecar metadata all land in audio: its library, covers, progress and sidecar metadata all land in
`/metadata` on the SSD. A writable bind would put stray writes on the spindle `/metadata` on the SSD. A writable bind would put stray writes on the spindle
@@ -121,13 +195,13 @@ as though the password were wrong.
- **No `systemctl --user enable`.** Generated Quadlet units cannot be enabled on - **No `systemctl --user enable`.** Generated Quadlet units cannot be enabled on
this host; `WantedBy=default.target` in the unit file is what carries the this host; `WantedBy=default.target` in the unit file is what carries the
service across a reboot ([[Quadlet Lifecycle]]). service across a reboot ([[Quadlet Lifecycle]]).
- **LAN-only until Frank says otherwise.** `0.0.0.0:8000` with no reverse proxy - **Two doors, one app.** `http://192.168.0.235:8000/audiobookshelf` is the raw
in front. Any public vhost requires the Anubis + Authentik chain first, and the LAN address — plain HTTP, nothing in front, what the phone app is configured
outpost proxy provider has to exist before the vhost flips (external exposure with; `https://audiobooks.franksdank.cloud/audiobookshelf/` is the guarded vhost
policy in `AGENTS.md`). That chain serves browsers: the phone app is a non-browser (Caddy → Anubis → the Authentik outpost → Caddy `:8017` → this container),
client and cannot solve a challenge, so guarding a vhost does not make remote the house chain for browser traffic — [[Caddy]] *Guard a new vhost*. That
listening work — remote listening wants a VPN into the LAN, after which the LAN chain serves browsers only: the phone app is a non-browser client and solves
address in *Phone app* is correct unchanged. no challenge, so remote listening wants a VPN into the LAN, not the vhost.
- **There is no `/setup` route; the creation door is `POST /init`.** `/status` - **There is no `/setup` route; the creation door is `POST /init`.** `/status`
(no `/api` prefix) reports `isInit` = "a root user exists", `POST /init` with (no `/api` prefix) reports `isInit` = "a root user exists", `POST /init` with
`{"newRoot":{"username","password"}}` creates it, `POST /login` returns the `{"newRoot":{"username","password"}}` creates it, `POST /login` returns the
@@ -207,12 +281,19 @@ as though the password were wrong.
## Verification ## Verification
`./tests/run.sh` covers this unit through `tests/dankserver_quadlet.bats` `./tests/run.sh` covers this unit through `tests/dankserver_quadlet.bats`
(boot persistence, the SSD state roots, the `:ro` array bind, the mount gate, the (boot persistence, the SSD state roots, the `:ro` array bind, the mount gate, the `wget`
`wget` health check), `tests/audiobookshelf_init.bats` (the bootstrapper against a health check, the base path the shipped SPA needs), `tests/audiobookshelf_init.bats` (the
stub of the API above: create-once, idempotent re-run, no secret in a URL, refusal bootstrapper against a stub of the API above: create-once, idempotent re-run, no secret in a
by URL when the server does not answer, and 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 `tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may health gate, and the SSO arm sending the mounted base path as the redirect subfolder);
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
`tests/e2e/run.sh` at the end of every `deploy.sh` — the `admin` project signs in as
`qa-e2e-admin` against `http://192.168.0.235:8000/audiobookshelf` and asserts the two things
no stub can see: every `.js` the app loads arrives as JavaScript (a bundle built for the wrong
base path arrives as `text/html`, which boots nothing), and `/api/me` answers 200 with
`type: "admin"` — true only while grant types, the OpenID scope mappings and the group claim
all hold together.
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,
@@ -64,6 +64,17 @@ everything filed there is invisible to Jellyfin by configuration,
not by fault: a recursive refresh answers 204, burns CPU for minutes and not by fault: a recursive refresh answers 204, burns CPU for minutes and
indexes nothing, because it never looks there. Check coverage before re-scanning. indexes nothing, because it never looks there. Check coverage before re-scanning.
**Which read proves an item is indexed: the folder view, never the recursive type
list.** `GET /Items?IncludeItemTypes=Movie&Recursive=true` answered 2,101 items on
2026-11-02 while `GET /Items?ParentId=<Movies ItemId>` listed 2,548 children — every
folder on disk — and the eight folders the recursive read "lost" (three `Gundam 00
Special Edition`, five `Sailor Moon Memorial`) each answer as `Type: Movie` through
`SearchTerm` and sit in the folder listing. The two reads are not interchangeable, so
the recursive list is a wrong instrument for "did the refresh index what I moved": its
shortfall is grouping, not missing media, and acting on it starts a scan that fixes
nothing. Verify per item — `ParentId` listing or `SearchTerm` — and use the recursive
count only as a trend line.
**Failure signature:** `System.IO.IOException: Read-only file system : **Failure signature:** `System.IO.IOException: Read-only file system :
'/data/Movies/<film>/movie.nfo'` means the container is binding the raw array '/data/Movies/<film>/movie.nfo'` means the container is binding the raw array
instead of the view — always a fault on this host since the 2026-10-31 cutover. instead of the view — always a fault on this host since the 2026-10-31 cutover.
@@ -4,7 +4,7 @@ description: "Outpost, proxy-provider, OAuth2, LDAP and flow configuration for t
tags: tags:
- infrastructure - infrastructure
- authentik - authentik
timestamp: 2026-10-19 timestamp: 2026-11-03
--- ---
# Authentik Outpost Reference # Authentik Outpost Reference
@@ -56,7 +56,7 @@ correctly — the outpost matches on hostname.
## Provisioning rules for proxy providers ## Provisioning rules for proxy providers
Two provisioning rules, each verified live by an outage: Three provisioning rules, each verified live by an outage or a dead login:
1. **All three flows are required.** A ProxyProvider created via raw API/ORM 1. **All three flows are required.** A ProxyProvider created via raw API/ORM
with any of `authentication_flow`/`authorization_flow`/`invalidation_flow` with any of `authentication_flow`/`authorization_flow`/`invalidation_flow`
@@ -80,6 +80,16 @@ Two provisioning rules, each verified live by an outage:
the app. No error is logged anywhere — the SSO "works" and the app never the app. No error is logged anywhere — the SSO "works" and the app never
appears. Create the Application with `provider_id` set (the UI does this appears. Create the Application with `provider_id` set (the UI does this
automatically; raw API/ORM drives must do it explicitly). automatically; raw API/ORM drives must do it explicitly).
3. **A raw ORM create skips the OAuth defaults.** The REST serializer calls
`ProxyProvider.set_oauth_defaults()`; `ProxyProvider.objects.create()` does not. The row
then looks fully configured — external_host, internal_host, application bound, outpost
claimed — while `redirect_uris` is empty (those URIs are *derived* from `external_host` by
`_get_callback_url`) and `grant_types` is `[]`. Both kill the guard before any credential,
in this order: authentik renders its own **"Redirect URI Error"** page, and once the URIs
are filled by hand the next wall is a bodiless **HTTP 400** out of `/application/o/authorize/`
(both measured on the Audiobookshelf guard provider, 2026-11-03). Census and repair in one idempotent pass, all
providers at once: `scripts/authentik-proxy-provider-defaults.sh`; `--check` prints the
census and exits 1 on drift, and a provider that already matches is left alone.
## OAuth2 relying-party quirks (this fork; corrected live 2026-10-20) ## OAuth2 relying-party quirks (this fork; corrected live 2026-10-20)
+59 -2
View File
@@ -4,7 +4,7 @@ description: "Caddy is the single reverse proxy for both hosts."
tags: tags:
- infrastructure - infrastructure
- caddy - caddy
timestamp: 2026-08-29 timestamp: 2026-11-03
--- ---
# Caddy # Caddy
@@ -90,7 +90,64 @@ pihole.franksdank.cloud {
After rendering, the line reads `dankuser <hash>` (one complete hash) — `tests/deployment_flow.bats:96-99` pins the `dankuser` name and the single-hash form, and `tests/caddyfile.bats:75` pins the bare `dankuser REPLACE_WITH_HASH` template. Do not put a `$2a$14$` prefix in the template: it would render a double-prefixed, invalid hash (2026-08-22 incident — every basicauth vhost returned 401 for every password; `tests/caddyfile.bats:67-72` forbids the prefix). `tests/deployment_flow.bats` pins the render contract. After rendering, the line reads `dankuser <hash>` (one complete hash) — `tests/deployment_flow.bats:96-99` pins the `dankuser` name and the single-hash form, and `tests/caddyfile.bats:75` pins the bare `dankuser REPLACE_WITH_HASH` template. Do not put a `$2a$14$` prefix in the template: it would render a double-prefixed, invalid hash (2026-08-22 incident — every basicauth vhost returned 401 for every password; `tests/caddyfile.bats:67-72` forbids the prefix). `tests/deployment_flow.bats` pins the render contract.
Everything else authenticates, not basicauth. The guarded set — `www.` (homepage), `search.`, `grafana.`, `prometheus.`, `loki.`, `seerr.`, `marreta.`, `romm.`, `files.` — proxies **all** traffic to `anubis:8923`, which verifies the client and forwards it back to Caddy's internal listener `http://:8016`; Authentik's proxy-provider mode serves the login inline (this build exposes **no** `forward_auth` endpoint) and forwards authenticated traffic to `http://:8017`, where the request is routed straight to its app (`dankclaw/Caddyfile:14-24,283-423`). The exempt-by-construction set never enters that chain: `auth.` (the IdP itself), `ai.` and `gitea.` (own logins), `hermes.` and `hermesadmin.` (app-enforced passwords), `pihole.` (the basicauth edge above), and the media/*arr vhosts. Grafana left basicauth on 2026-09-01 and is an OAuth2 relying party against Authentik (`dankclaw/Caddyfile:122-132`). See [[Homepage]] and [[Authentik]]. Everything else authenticates, not basicauth. The guarded set — `www.` (homepage), `search.`, `grafana.`, `prometheus.`, `loki.`, `seerr.`, `marreta.`, `romm.`, `files.`, `notebook.`, `abs.` — proxies **all** traffic to `anubis:8923`, which verifies the client and forwards it back to Caddy's internal listener `http://:8016`; Authentik's proxy-provider mode serves the login inline (this build exposes **no** `forward_auth` endpoint) and forwards authenticated traffic to `http://:8017`, where the request is routed straight to its app (`dankclaw/Caddyfile`: the header comment and the two internal listeners). The exempt-by-construction set never enters that chain: `auth.` (the IdP itself), `ai.` and `gitea.` (own logins), `hermes.` and `hermesadmin.` (app-enforced passwords), `pihole.` (the basicauth edge above), and the media/*arr vhosts. Grafana left basicauth on 2026-09-01 and is an OAuth2 relying party against Authentik (`dankclaw/Caddyfile:122-132`). See [[Homepage]] and [[Authentik]].
## Guard a new vhost
Order is the whole procedure: the IdP row has to exist **before** the route flips, or
authenticated traffic reaches the outpost with no matching provider and the user gets
authentik's error page instead of the app.
1. **Provider first.** A proxy provider plus an application, claimed by the embedded
outpost. No blueprint owns this state — it lives only in the IdP's database, which is
why it is easy to forget:
```
ssh dankclaw 'podman exec -i authentik-server ak shell' <<'PY'
from authentik.providers.proxy.models import ProxyProvider
from authentik.core.models import Application
from authentik.outposts.models import Outpost
NAME, EXT = "audiobooks", "https://audiobooks.franksdank.cloud/"
pp = ProxyProvider.objects.filter(name=f"{NAME}-proxy").first()
if pp is None:
pp = ProxyProvider.objects.create(name=f"{NAME}-proxy", external_host=EXT,
internal_host="http://caddy:8017")
if not Application.objects.filter(provider=pp).exists():
Application.objects.create(name=f"{NAME.title()} Guard", slug=f"{NAME}-guard",
provider=pp)
o = Outpost.objects.get(managed="goauthentik.io/outposts/embedded")
o.providers.add(pp) # an unclaimed provider is invisible to the outpost
print(pp.name, pp.external_host, o.providers.count())
PY
```
`external_host` is the exact vhost URL **with the trailing slash**: the embedded
outpost routes on the ORIGINAL `Host` header alone, so anything else matches zero
providers. Never print a provider's fields wholesale — a proxy provider carries a
client secret, and it lands in whatever transcript the command runs in.
`ProxyProvider.objects.create()` is not the API: the serializer's
`set_oauth_defaults()` never runs, so the row lands with `redirect_uris` and
`grant_types` empty, and the guard fails *before* any credential — authentik's
"Redirect URI Error" page, then a bodiless HTTP 400 once the URIs are filled
by hand. Run `scripts/authentik-proxy-provider-defaults.sh` after the snippet
(idempotent, covers every provider; `--check` is the census); law and failure
signatures in [[Authentik Outpost Reference]].
2. **Then the route.** The public site block proxies the whole request to `anubis:8923`
with `header_up X-Real-Ip {remote_host}`, and the post-auth `http://:8017` listener
gains a `@<name> host <vhost>` handler pointing at the app — copy `romm`/`files`,
which are the same shape including a Dankserver LAN-port upstream.
An app that derives its own origin from the request needs the scheme pinned:
Audiobookshelf checks every OIDC callback against `x-forwarded-proto://Host`; this
listener is fed over plain http so Caddy's own `X-Forwarded-Proto` is `http`, and the
app answers a 400 "Invalid callback URL - must be same-origin" for its own https
callback. The edge for a guarded name is TLS-only, so `header_up X-Forwarded-Proto
https` is the truthful value ([[Audiobookshelf]]).
3. **Then the name and the audit.** A `dankclaw/pihole/addn-hosts` line (the `pihole`
seam fails the build if a Caddyfile vhost has no record), a Porkbun A record if the
name must resolve off-LAN ([[LAN-DNS]]), and the subdomain added to `GUARDED` in
`tests/e2e/specs/14-guard.spec.ts` with an app-specific leak marker — that spec is
the deploy gate's proof that the guard, not the app, answers an anonymous client.
## Operations ## Operations
+1 -1
View File
@@ -44,7 +44,7 @@ is a locator: one row, one clause, no numbers.
| Page | Owns | | Page | Owns |
|---|---| |---|---|
| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, laws. | | [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, guarded vhost, laws. |
| [[Calibre]] | Calibre is the e-book library manager that backs Calibre-Web. | | [[Calibre]] | Calibre is the e-book library manager that backs Calibre-Web. |
| [[Calibre-Web]] | Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database. | | [[Calibre-Web]] | Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database. |
| [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. | | [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. |
+1 -1
View File
@@ -516,7 +516,7 @@
"category": "admin", "category": "admin",
"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 entropy library: its own SQLite library, its own login, per-listener positions.",
"url": "http://192.168.0.235:8000", "url": "http://audiobooks.franksdank.cloud/audiobookshelf/",
"docs": "", "docs": "",
"probe": "192.168.0.235:8000", "probe": "192.168.0.235:8000",
"method": "http", "method": "http",
+80 -4
View File
@@ -17,6 +17,10 @@
# reported as success (a green deploy over a dead API is a lie); # reported as success (a green deploy over a dead API is a lie);
# 5. ABS_SCAN outside 0/1 is a usage error raised before anything is touched; # 5. ABS_SCAN outside 0/1 is a usage error raised before anything is touched;
# 6. the deployer runs the bootstrapper only after the health gate answered. # 6. the deployer runs the bootstrapper only after the health gate answered.
# 7. the SSO arm sends the base path: ABS builds its redirect_uri as
# <origin><authOpenIDSubfolderForRedirectURLs>/auth/openid/callback, and the
# value must be the path the app is mounted under — never the literal
# "undefined" a fresh database leaves there.
load helpers load helpers
@@ -29,7 +33,7 @@ import json, sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
port, logf = int(sys.argv[1]), sys.argv[2] port, logf = int(sys.argv[1]), sys.argv[2]
state = {"init": False, "user": None, "pw": None, "libs": []} state = {"init": False, "user": None, "pw": None, "libs": [], "oidc": None}
def log(line): def log(line):
@@ -37,6 +41,12 @@ def log(line):
f.write(line + "\n") f.write(line + "\n")
def norm(p):
# ABS mounts every route under ROUTER_BASE_PATH. The stub accepts the API at the
# root or under that prefix, so a test can still assert which one the client used.
return p[len("/audiobookshelf"):] if p.startswith("/audiobookshelf") else p
class H(BaseHTTPRequestHandler): class H(BaseHTTPRequestHandler):
def log_message(self, *a): def log_message(self, *a):
pass pass
@@ -57,20 +67,24 @@ class H(BaseHTTPRequestHandler):
return self.headers.get("Authorization") == "Bearer stub-token" return self.headers.get("Authorization") == "Bearer stub-token"
def do_GET(self): def do_GET(self):
path = self.path.split("?")[0] path = norm(self.path.split("?")[0])
log("GET %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no")) log("GET %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
if path == "/status": if path == "/status":
return self.send(200, {"app": "audiobookshelf", "isInit": state["init"]}, True) methods = ["local", "openid"] if state["oidc"] else ["local"]
return self.send(200, {"app": "audiobookshelf", "isInit": state["init"],
"authMethods": methods}, True)
if not self.authed(): if not self.authed():
return self.send(401, "Unauthorized") return self.send(401, "Unauthorized")
if path == "/api/libraries": if path == "/api/libraries":
return self.send(200, {"libraries": state["libs"]}, True) return self.send(200, {"libraries": state["libs"]}, True)
if path == "/api/auth-settings":
return self.send(200, state["oidc"] or {}, True)
if path.endswith("/items"): if path.endswith("/items"):
return self.send(200, {"total": 42, "results": []}, True) return self.send(200, {"total": 42, "results": []}, True)
return self.send(404, "nope") return self.send(404, "nope")
def do_POST(self): def do_POST(self):
path = self.path.split("?")[0] path = norm(self.path.split("?")[0])
payload = self.body() payload = self.body()
log("POST %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no")) log("POST %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
if path == "/init": if path == "/init":
@@ -101,6 +115,21 @@ class H(BaseHTTPRequestHandler):
return self.send(200, "OK") return self.send(200, "OK")
return self.send(404, "nope") return self.send(404, "nope")
def do_PATCH(self):
path = norm(self.path.split("?")[0])
payload = self.body()
log("PATCH %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
if not self.authed():
return self.send(401, "Unauthorized")
if path == "/api/auth-settings":
# Stored and echoed verbatim: the test asserts the wire, not the stub's
# opinion about which keys are patchable.
state["oidc"] = payload
for k in sorted(payload):
log("OIDCFG %s=%r" % (k, payload[k]))
return self.send(200, payload, True)
return self.send(404, "nope")
ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever() ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()
PY PY
@@ -198,3 +227,50 @@ boot() {
/audiobookshelf-init\.sh/ { init = NR } /audiobookshelf-init\.sh/ { init = NR }
END { exit !(health && init && init > health) }' END { exit !(health && init && init > health) }'
} }
@test "the SSO arm sends the base path as the redirect subfolder" {
# What authentik has registered must equal what ABS builds:
# <origin><authOpenIDSubfolderForRedirectURLs>/auth/openid/callback. A database
# created before SSO leaves that key unset and ABS then sends the literal
# "/undefined/auth/openid/callback" — measured 2026-11-02, where the live login died
# on authentik's "Redirect URI Error" page. So the value has to reach the wire, and
# it has to be the path the app is served under.
umask 077
cat > "$TMP/cred" <<'CRED'
ABS_ADMIN_USER=frank
ABS_ADMIN_PASSWORD=0123456789abcdef0123456789
ABS_OIDC_CLIENT_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789
CRED
run env ABS_URL="http://127.0.0.1:$PORT/audiobookshelf" ABS_SECRET="$TMP/cred" \
ABS_SCAN=0 bash "$INIT"
[ "$status" -eq 0 ]
printf '%s\n' "${lines[@]}" | grep -q "^oidc: armed"
grep -q "^OIDCFG authOpenIDSubfolderForRedirectURLs='/audiobookshelf'$" "$TMP/requests.log"
# every route the bootstrapper calls rides under the same prefix it armed with
grep -q "^POST /audiobookshelf/init auth=no$" "$TMP/requests.log"
grep -q "^GET /audiobookshelf/api/auth-settings auth=yes$" "$TMP/requests.log"
# A root-mounted deployment sends the EMPTY subfolder (the value the 2.17.4 upgrade
# uses), not whatever the last URL happened to carry.
run env ABS_URL="http://127.0.0.1:$PORT" ABS_SECRET="$TMP/cred" ABS_SCAN=0 bash "$INIT"
[ "$status" -eq 0 ]
[ "$(grep "^OIDCFG authOpenIDSubfolderForRedirectURLs=" "$TMP/requests.log" | tail -1)" \
= "OIDCFG authOpenIDSubfolderForRedirectURLs=''" ]
}
@test "the app and the IdP provider script build the same redirect path" {
# The provider row is written by scripts/authentik-audiobookshelf-sso.sh from
# ABS_BASE; the app builds its redirect_uri from the base path the deployer hands
# audiobookshelf-init.sh, which comes from the unit. Drift between the two is a
# redirect_uri mismatch — the login dies at the IdP before any credential is issued.
unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container"
mounted=$(awk -F= '/^Environment=ROUTER_BASE_PATH=/{print $3}' "$unit")
[ -n "$mounted" ]
provider=$(grep -m1 '^BASE=' "$REPO_ROOT/scripts/authentik-audiobookshelf-sso.sh" \
| grep -oE 'https?://[^"}]+')
bootstrapper=$(grep -m1 '^ABS_URL=' "$INIT" | grep -oE 'https?://[^"}]+')
# Origins legitimately differ (LAN address vs loopback); the PATH must not.
[ "${provider#http://192.168.0.235:8000}/auth/openid/callback" \
= "${bootstrapper#http://127.0.0.1:8000}/auth/openid/callback" ]
[ "${bootstrapper#http://127.0.0.1:8000}" = "$mounted" ]
}
+6 -1
View File
@@ -182,8 +182,13 @@ load helpers
# under base path /audiobookshelf and the deploy gate timed out beside a unit # under base path /audiobookshelf and the deploy gate timed out beside a unit
# that was active (running). PORT and ROUTER_BASE_PATH are what the image's own # that was active (running). PORT and ROUTER_BASE_PATH are what the image's own
# ENV block and the server source actually use. # ENV block and the server source actually use.
# The base path is not a free choice either: /app/client/dist/index.html inside the
# pinned image carries routerBasePath:"/audiobookshelf", so a server mounted at / hands
# out a page whose every script request falls through to the SPA fallback and comes back
# text/html — the UI never leaves "Loading…" while the API stays healthy (measured
# 2026-11-02, which is exactly how it hid behind an API-only verification).
unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container" unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container"
grep -q '^Environment=PORT=8000$' "$unit" || return 1 grep -q '^Environment=PORT=8000$' "$unit" || return 1
grep -q '^Environment=ROUTER_BASE_PATH=/$' "$unit" || return 1 grep -q '^Environment=ROUTER_BASE_PATH=/audiobookshelf$' "$unit" || return 1
! grep -qE '^Environment=ABS_' "$unit" || return 1 ! grep -qE '^Environment=ABS_' "$unit" || return 1
} }
+4 -1
View File
@@ -42,7 +42,10 @@ export default defineConfig({
}, },
{ {
name: "admin", name: "admin",
testMatch: /11-.*\.spec\.ts/, // 23- joins 2026-11-02 (Audiobookshelf SSO): it needs BOTH halves of the admin
// fixture — a live authentik session to ride the OIDC round trip, and the
// qa-e2e-admin group membership that grants site admin in the app.
testMatch: /(11|23)-.*\.spec\.ts/,
use: { storageState: ".auth/qa-e2e-admin.json" }, use: { storageState: ".auth/qa-e2e-admin.json" },
dependencies: ["setup"], dependencies: ["setup"],
}, },
+6 -2
View File
@@ -15,9 +15,12 @@ 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"]; const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki", "notebook", "audiobooks"];
// Markers that prove app content leaked past the guard to an anonymous user. // 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 —
// for audiobooks that is the Audiobookshelf login's own SSO button label (the app's
// 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"],
@@ -26,6 +29,7 @@ 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 }) => {
@@ -0,0 +1,92 @@
import { expect, test } from "@playwright/test";
// Audiobookshelf: the SSO round trip must land a session with a real permission level.
//
// The service answers on a LAN address with no DNS name and no Caddy vhost, so it is in no
// guarded-vhost list and no other spec ever fetched a byte of it — every deploy gate passed
// while its login was dead. Two defect classes, both caught live on 2026-11-02:
//
// 1. The bundle must be BUILT for /audiobookshelf (ROUTER_BASE_PATH in the quadlet). Built
// for "/" the container still answers /healthcheck and still serves index.html, so the
// deploy health gate is green while the browser sees a blank page: every <script>
// resolves against the root, misses the static allowlist, and falls through to the SPA
// catch-all that returns index.html as text/html. Hence the MIME assertion below — the
// one thing that distinguishes a booted SPA from a served HTML stub.
// 2. The authentik provider row must carry grant_types AND the built-in OpenID scope
// mappings (provisioned by scripts/authentik-audiobookshelf-sso.sh). A blueprint
// re-import reverts hand-made provider rows silently, and the wire errors are generic:
// oauthlib's "The request is otherwise malformed" for an empty grant list, then
// "insufficient_scope" from ABS's /userinfo call for a short scope. Only the level
// assertion below — that the abs_groups claim granted site admin — sees either.
const HOST = "192.168.0.235:8000";
const ABS = `http://${HOST}/audiobookshelf`;
test("SSO round trip boots the SPA and grants a real Audiobookshelf session", async ({
page,
}) => {
test.setTimeout(180_000);
const scripts: { url: string; type: string }[] = [];
page.on("response", (res) => {
const u = new URL(res.url());
if (u.host === HOST && u.pathname.endsWith(".js")) {
scripts.push({ url: u.pathname, type: res.headers()["content-type"] ?? "" });
}
});
await page.goto(`${ABS}/login`, { waitUntil: "domcontentloaded" });
const sso = page.getByText("Sign in with Authentik");
await expect(sso).toBeVisible();
await sso.click();
// The admin project's storageState carries a live authentik session, so this is a silent
// round trip: authorize -> code -> callback -> token. Still on /login means the IdP
// refused the authorize request or ABS rejected the code exchange.
await expect
.poll(() => !new URL(page.url()).pathname.endsWith("/login"), { timeout: 90_000 })
.toBe(true);
await expect
.poll(
async () => {
const u = new URL(page.url());
return u.host === HOST && u.pathname.startsWith("/audiobookshelf");
},
{ timeout: 30_000 },
)
.toBe(true);
// 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
// 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);
for (const s of scripts) {
expect(s.type, `${s.url} arrived as ${s.type}`).toContain("javascript");
}
// The token the app landed on is the credential its own API must accept. Give the SPA a
// moment to finish the exchange: the URL leaves /login the instant authentik bounces back
// through the callback route, which is BEFORE the app has stored what it exchanged.
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 token = await grabToken();
const me = await page.request.get(`${ABS}/api/me`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(me.status()).toBe(200);
const user = await me.json();
expect(user.username).toBe("qa-e2e-admin");
// Site role, derived ONLY from the abs_groups claim: it says "admin" if and only if the
// scope mapping reached ABS with audiobookshelf_admins in the list. A short token scope
// (missing OpenID mappings) never gets this far — the exchange dies before it.
expect(user.type).toBe("admin");
});