Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5794b07c32 | ||
|
|
1a47b831ee | ||
|
|
5be66df2bb | ||
|
|
d3aedd1837 | ||
|
|
66766f23ef | ||
|
|
5cf5416471 | ||
|
|
30186d8365 | ||
|
|
3a6f7c7280 | ||
|
|
c7643fb4d0 | ||
|
|
882a9f6808 | ||
|
|
aa57c1a894 | ||
|
|
2a48751283 | ||
|
|
6083903ccb | ||
|
|
ee9eb353fb | ||
|
|
df597bb1b1 | ||
|
|
23ad0ca317 | ||
|
|
71d5804eaf | ||
|
|
5ef141eb17 | ||
|
|
fadbe10420 | ||
|
|
17ea2f4110 | ||
|
|
603513a15e | ||
|
|
5e0592381a | ||
|
|
ef89f77ea7 | ||
|
|
9e2584ec8b | ||
|
|
a5dfc1ee99 | ||
|
|
7e72126a07 | ||
|
|
80a65b013f | ||
|
|
54ec72da17 | ||
|
|
d82b8efdd7 | ||
|
|
27b3c24065 | ||
|
|
7e9bfbc2be | ||
|
|
f5410bd253 | ||
|
|
98522cecba | ||
|
|
3138e782bd | ||
|
|
393b77dfd2 | ||
|
|
cfdd3435bc | ||
|
|
f3a6b4f715 | ||
|
|
5c888ff948 | ||
|
|
03e0d84380 | ||
|
|
a5603d8f01 | ||
|
|
988c4899d4 | ||
|
|
77e9973ff5 | ||
|
|
625263dd48 | ||
|
|
42bc482ed8 | ||
|
|
aea24353ad |
No files matched your search
@@ -227,9 +227,18 @@ See `serverAppConfigs-docs/Architecture/Testing Policy.md`.
|
||||
|
||||
## External Exposure Policy (MANDATORY)
|
||||
|
||||
Every Caddy-proxied service is either behind the auth chain (Anubis :8923 →
|
||||
Authentik embedded outpost) or explicitly internal-only — no public DNS name,
|
||||
no vhost. New services default to internal-only until guarded.
|
||||
Every Caddy-proxied service is in exactly one of three postures: behind the
|
||||
auth chain (Anubis :8923 → Authentik embedded outpost), **exempt by
|
||||
construction** because it authenticates itself (`auth.`, git smart-HTTP, Gitea,
|
||||
OpenWebUI, Jellyfin, Plex, Audiobookshelf, the *arr stack), or internal-only —
|
||||
no public DNS name, no vhost. New services default to internal-only until guarded.
|
||||
|
||||
The guard chain is a **browser-only device**: Anubis demands a JavaScript
|
||||
proof-of-work and the outpost works from a session cookie, so any non-browser
|
||||
client — a native app, a podcast RSS reader, an OPDS client, `git`, a
|
||||
service-to-service call — is answered with challenge HTML where it expected
|
||||
JSON or a socket payload. A service that must serve such a client cannot sit
|
||||
behind the chain; it has to own its authentication.
|
||||
|
||||
When guarding a new vhost — provider first, then route:
|
||||
|
||||
@@ -248,6 +257,10 @@ When guarding a new vhost — provider first, then route:
|
||||
Exempt categories (document the reason in the vhost comment): the IdP itself
|
||||
(`auth.`), git smart-HTTP endpoints, services with their own complete auth.
|
||||
|
||||
Unguarding a vhost is the mirror image of the procedure above — the route flips
|
||||
first and the IdP rows go last — and `serverAppConfigs-docs/Infrastructure/Caddy.md`
|
||||
*Unguard a vhost* owns the steps.
|
||||
|
||||
## Deletion & Trash Policy (MANDATORY)
|
||||
|
||||
Never `rm`. Move deletions into trash so they stay recoverable:
|
||||
|
||||
+33
-7
@@ -22,13 +22,16 @@
|
||||
# CHALLENGE everyone else) → Caddy's internal listener http://:8016 (auth
|
||||
# decision; Host headers survive every hop) → authenticated traffic is
|
||||
# forwarded back to http://:8017 and routed straight to the app.
|
||||
# Apps with their own auth (Gitea, OpenWebUI, Jellyfin, *arr, Pi-hole) and the
|
||||
# Authentik IdP itself (auth.franksdank.cloud — its login flow must never sit
|
||||
# behind a challenge) are exempt by construction: their vhosts never route
|
||||
# through Anubis. ComfyUI left this exempt set on 2026-08-31 and Grafana left
|
||||
# it on 2026-09-01 (OAuth2 relying-party mode against authentik): both now sit
|
||||
# behind the guard chain; Grafana authenticates users itself via the OAuth2
|
||||
# code flow, so no identity header is forwarded to it.
|
||||
# Apps with their own auth (Gitea, OpenWebUI, Jellyfin, Plex, Audiobookshelf,
|
||||
# *arr, Pi-hole) and the Authentik IdP itself (auth.franksdank.cloud — its login
|
||||
# flow must never sit behind a challenge) are exempt by construction: their
|
||||
# vhosts never route through Anubis. The chain is browser-only by nature — a
|
||||
# native app client, a podcast RSS reader or a git client solves no JavaScript
|
||||
# proof, so anything that must serve one has to own its own authentication.
|
||||
# ComfyUI left this exempt set on 2026-08-31 and Grafana left it on 2026-09-01
|
||||
# (OAuth2 relying-party mode against authentik): both now sit behind the guard
|
||||
# chain; Grafana authenticates users itself via the OAuth2 code flow, so no
|
||||
# identity header is forwarded to it.
|
||||
# The pihole vhost keeps its basicauth edge; the hash placeholder below is
|
||||
# rendered by deploy.sh from CADDY_BASICAUTH_HASH (host .env, git-ignored) into
|
||||
# /home/dank/Containers/caddy/Caddyfile, which the container mounts.
|
||||
@@ -264,6 +267,29 @@ files.franksdank.cloud {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Audiobookshelf (exempt by construction: own auth) --------------------
|
||||
# Caddy → app: no Anubis, no outpost. The guard chain answers a native client's
|
||||
# /ping, /api/login and /socket.io/ handshake with challenge HTML, so behind it
|
||||
# the phone app parsed a non-object where the socket `init` payload belongs and
|
||||
# podcast RSS/OPDS feeds broke the same way. ABS authenticates itself (local
|
||||
# accounts plus authentik OIDC), so it joins jelly/plex/*arr outside the chain
|
||||
# (2026-11-03); the IdP rows that guarded this name — proxy provider
|
||||
# `audiobooks-proxy` and application "Audiobookshelf Guard" — are retired with
|
||||
# the change, after the route flips. Accepted exposure: /audiobookshelf/api/login
|
||||
# is internet-visible with no rate limiter on either side (this Caddy build has
|
||||
# no rate_limit module; ABS 2.37.2 ships no lockout) — the same class every other
|
||||
# own-auth vhost here lives with, so shared accounts need long unique passwords.
|
||||
# ROUTER_BASE_PATH=/audiobookshelf, so the bare root is a convenience hop onto it.
|
||||
# X-Forwarded-Proto must say https: ABS derives its OIDC origin from
|
||||
# x-forwarded-proto://Host and this hop is plain http, so without the pin its own
|
||||
# https callback is a 400 "Invalid callback URL - must be same-origin".
|
||||
audiobooks.franksdank.cloud {
|
||||
redir / /audiobookshelf/ permanent
|
||||
reverse_proxy 192.168.0.235:8000 {
|
||||
header_up X-Forwarded-Proto https
|
||||
}
|
||||
}
|
||||
|
||||
# --- Jellyfin ---
|
||||
jelly.franksdank.cloud {
|
||||
reverse_proxy 192.168.0.235:8096
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
192.168.0.143 romm.franksdank.cloud
|
||||
192.168.0.143 files.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 lidarr.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
|
||||
# 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,
|
||||
# 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=ROUTER_BASE_PATH=/
|
||||
Environment=ROUTER_BASE_PATH=/audiobookshelf
|
||||
Environment=CONFIG_PATH=/config
|
||||
Environment=METADATA_PATH=/metadata
|
||||
Environment=TZ=America/Vancouver
|
||||
@@ -45,6 +52,8 @@ Volume=/home/dank/Containers/audiobookshelf/metadata:/metadata
|
||||
Volume=/mnt/entropy/Audiobooks:/audiobooks:ro
|
||||
# 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.
|
||||
# /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
|
||||
HealthInterval=30s
|
||||
HealthTimeout=10s
|
||||
|
||||
@@ -21,26 +21,64 @@
|
||||
# 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.
|
||||
#
|
||||
# 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
|
||||
# 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):
|
||||
# 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_LIBRARY library name to ensure (Audiobooks)
|
||||
# ABS_FOLDER folder to bind inside the server (/audiobooks)
|
||||
# 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 1 a step was refused or the server misbehaved (named on stderr)
|
||||
# exit 2 usage / unreadable credential file
|
||||
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_LIBRARY=${ABS_LIBRARY:-Audiobooks}
|
||||
ABS_FOLDER=${ABS_FOLDER:-/audiobooks}
|
||||
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; }
|
||||
|
||||
@@ -49,6 +87,11 @@ case "$ABS_SCAN" in
|
||||
*) printf 'refused: ABS_SCAN wants 0 or 1, got %s\n' "$ABS_SCAN" >&2; exit 2 ;;
|
||||
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 -------------------------------------------------------------
|
||||
umask 077
|
||||
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
|
||||
# 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_FOLDER="$ABS_FOLDER" ABS_SCAN="$ABS_SCAN" python3 - <<'PY'
|
||||
import json, os, sys, urllib.error, urllib.request
|
||||
ABS_FOLDER="$ABS_FOLDER" ABS_SCAN="$ABS_SCAN" ABS_OIDC="$ABS_OIDC" \
|
||||
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("/")
|
||||
LIB = os.environ["ABS_LIBRARY"]
|
||||
@@ -184,5 +229,83 @@ code, items = call("GET", "/api/libraries/%s/items?limit=1" % lib["id"], token=t
|
||||
if code == 200 and isinstance(items, dict):
|
||||
print("items: %s (a fresh scan raises this from 0; watch it, not /healthcheck)"
|
||||
% 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 role comes from
|
||||
# the abs_groups claim, which grants the listener role to every signer; only the
|
||||
# authentik admin group escalates. Revoking a listener therefore happens HERE in
|
||||
# ABS (disable or delete the user), never by removing an IdP group membership.
|
||||
"authOpenIDAutoRegister": True,
|
||||
"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
|
||||
exit 0
|
||||
Executable
+294
@@ -0,0 +1,294 @@
|
||||
#!/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:
|
||||
#
|
||||
# * Two URIs per origin ABS is reached at — <base>/auth/openid/callback and
|
||||
# <base>/auth/openid/mobile-redirect — for EVERY entry in BASES (the LAN address
|
||||
# and the public vhost). 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 exists on every origin because the app sends
|
||||
# the one belonging to the door it was configured with: the vhost started serving
|
||||
# non-browser clients on 2026-11-03 (it left the guard chain), so a phone pointed
|
||||
# at the public name sends the public mobile-redirect URI, and STRICT matching
|
||||
# answers an unregistered one with "Redirect URI Error".
|
||||
# * <base> CARRIES THE BASE PATH. ABS builds redirect_uri as
|
||||
# <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)
|
||||
# There is deliberately NO user list. The claim grants the LISTENER role to every
|
||||
# authentik account that signs in (2026-11-03), so onboarding a listener is exactly
|
||||
# "create the authentik account". Before this, a household account authenticated at
|
||||
# the IdP and was then refused by ABS for being in no group — a policy decision that
|
||||
# read as a broken login. Revocation lives on the ABS side (disable or delete the
|
||||
# user there): a group can no longer withhold a role the claim always sends.
|
||||
# ABS_E2E_ADMIN authentik user kept in the ADMIN group for the Playwright gate
|
||||
# (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"
|
||||
SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
|
||||
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: every signer is a listener; only the admin group escalates.
|
||||
# Stored verbatim so a later read of the provider shows exactly this policy. The default
|
||||
# lives HERE and nowhere else — ABS reads this list off the token, so an account in no
|
||||
# Audiobookshelf group still arrives with ["user"] and is auto-registered as a listener
|
||||
# instead of being refused after a successful authentication.
|
||||
read -r -d '' EXPR <<PYEXPR || true
|
||||
groups = {g.name for g in user.groups.all()}
|
||||
# Order is deliberate: an admin's claim comes out ["admin","user"], byte-identical to what
|
||||
# this mapping emitted before the default existed, because ABS's group-tag matching has never
|
||||
# been measured for order-sensitivity and the working admin login is not the place to find out.
|
||||
roles = []
|
||||
if "$ADMIN_GROUP" in groups:
|
||||
roles.append("admin")
|
||||
roles.append("user")
|
||||
return {"$CLAIM": roles}
|
||||
PYEXPR
|
||||
|
||||
# NOTE: the heredoc below is UNQUOTED on purpose — its body interpolates the variables
|
||||
# above. The consequence is that a backtick anywhere in that python body is a LOCAL command
|
||||
# substitution whose output is spliced into the program before it ever runs, and comments
|
||||
# are not exempt, because bash does not know what a python comment is. Write names plainly
|
||||
# in the body: measured 2026-11-03, two comments in this body had the shell run
|
||||
# audiobookshelf_users and user as commands on every execution of this script.
|
||||
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
|
||||
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 = "$ADMIN_GROUP"
|
||||
CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE"
|
||||
BASES = [BASE] + """$EXTRA_BASES""".split()
|
||||
SSO_USER, E2E_ADMIN = "$SSO_USER", "$E2E_ADMIN"
|
||||
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.
|
||||
|
||||
Group.objects.get_or_create(name=ADMIN_GROUP)
|
||||
admin_g = Group.objects.get(name=ADMIN_GROUP)
|
||||
# The audiobookshelf_users group is retired. Its membership used to be the only door to the
|
||||
# listener role; the claim now sends that role to every signer, so the group decides
|
||||
# nothing — and a group that decides nothing is a trap for the next reader, who will
|
||||
# assume it gates something and start adding people to it.
|
||||
for stale in Group.objects.filter(name="audiobookshelf_users"):
|
||||
stale.delete()
|
||||
print("group: retired audiobookshelf_users (the claim grants every signer)")
|
||||
|
||||
# --- the claim mapping (scope_name IS the claim name ABS requests) ---------------
|
||||
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 roles: every signer is a listener, the admin group escalates")
|
||||
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 the name "user".
|
||||
_src = "def _probe(user, request=None):\n" + "".join(" %s\n" % ln for ln in EXPR.splitlines())
|
||||
_ns = {}
|
||||
exec(_src, _ns)
|
||||
|
||||
# --- provider --------------------------------------------------------------------
|
||||
# One callback pair per origin (LAN address and the public vhost): ABS derives the
|
||||
# browser callback from the request origin and the mobile callback from the door the
|
||||
# app was configured with, so each origin needs both or that door cannot sign in.
|
||||
uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT, url="%s%s" % (b, p))
|
||||
for b in BASES
|
||||
for p in ("/auth/openid/callback", "/auth/openid/mobile-redirect")]
|
||||
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))
|
||||
# The default is printed at provision time, not merely asserted: an expression that regressed
|
||||
# to returning nothing for a non-member refuses every household login, and this is where a
|
||||
# re-run notices it. The population matters — authentik ships an AnonymousUser row, a
|
||||
# superuser, and ak-outpost-* service identities, and proving the default on one of those says
|
||||
# nothing about the friend who logs in. Prefer the fleet's plain-user marker group (owned by
|
||||
# serverAppConfigs-docs/Infrastructure/Authentik.md; a preference only, since that group
|
||||
# belongs to other consumers), which normally lands on qa-e2e — the identity the e2e gate
|
||||
# signs in as. is_superuser is a python property on authentik's User, not a queryable field.
|
||||
pool = User.objects.filter(is_active=True).exclude(groups__name__in=[ADMIN_GROUP])
|
||||
pool = pool.exclude(username__in=[root.username, E2E_ADMIN, "AnonymousUser"])
|
||||
pool = [u for u in pool.order_by("username")
|
||||
if not u.is_superuser and not u.username.startswith("ak-outpost-")]
|
||||
household = [u for u in pool if any(g.name == "Non-Admin Users" for g in u.groups.all())]
|
||||
for u in (household or pool)[:3]:
|
||||
print("default probe: %s %s -> %s" % (
|
||||
u.username, sorted(g.name for g in u.groups.all()),
|
||||
_ns["_probe"](user=u, request=None)))
|
||||
|
||||
# --- verification: the claim the callback will actually receive ---------------------
|
||||
for probe in [root] + ([e2e] if e2e else []):
|
||||
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
|
||||
Executable
+95
@@ -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."
|
||||
@@ -1315,7 +1315,8 @@ deploy_audiobookshelf() {
|
||||
mkdir -p /home/dank/Containers/audiobookshelf/config \
|
||||
/home/dank/Containers/audiobookshelf/metadata
|
||||
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.
|
||||
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
|
||||
@@ -1323,7 +1324,12 @@ deploy_audiobookshelf() {
|
||||
# it creates the account (credential in the mode-600 host secret file), the
|
||||
# Audiobooks library over the /audiobooks bind, and fires the scan. Every
|
||||
# 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
|
||||
|
||||
+10
-4
@@ -78,10 +78,16 @@ one journal is one undo:
|
||||
|
||||
python3 scripts/media-shows.py --root /mnt/entropy/TVShows \
|
||||
--enum /tmp/tv-enum.tsv --titles /tmp/jellyfin-tv.tsv \
|
||||
--tvdb --tmdb --out /mnt/entropy/media-tv-plan.tsv \
|
||||
--park /mnt/entropy/media-park/TVShows
|
||||
python3 scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/media-tv-relayout.jsonl
|
||||
--tvdb --tmdb --out /mnt/entropy/.media-campaign/media-tv-plan.tsv \
|
||||
--park /mnt/entropy/NeedsProcessing/TVShows
|
||||
python3 scripts/media-apply.py apply \
|
||||
--plan /mnt/entropy/.media-campaign/media-tv-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/media-tv-relayout.jsonl
|
||||
|
||||
Every plan, journal and log lives in /mnt/entropy/.media-campaign/ — the array
|
||||
root holds libraries only. media-apply.py defaults its journal to
|
||||
<root>/media-relayout.jsonl, so pass --journal explicitly or the run puts the
|
||||
log back on the root.
|
||||
"""
|
||||
import argparse
|
||||
import collections
|
||||
|
||||
+13
-6
@@ -17,13 +17,20 @@ the executor's own path guard still has teeth, and give it its own journal —
|
||||
one journal is one undo, and a TV move must not be welded to a movie relayout:
|
||||
|
||||
python3 scripts/media-tv.py --root /mnt/entropy/Movies \
|
||||
--library /mnt/entropy/TVShows --out /mnt/entropy/media-tv-plan.tsv
|
||||
python3 scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/media-relayout-tv.jsonl
|
||||
python3 scripts/media-apply.py verify --plan /mnt/entropy/media-tv-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/media-relayout-tv.jsonl
|
||||
--library /mnt/entropy/TVShows \
|
||||
--out /mnt/entropy/.media-campaign/media-tv-plan.tsv
|
||||
python3 scripts/media-apply.py apply \
|
||||
--plan /mnt/entropy/.media-campaign/media-tv-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/media-relayout-tv.jsonl
|
||||
python3 scripts/media-apply.py verify \
|
||||
--plan /mnt/entropy/.media-campaign/media-tv-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/media-relayout-tv.jsonl
|
||||
python3 scripts/media-apply.py sweep --root /mnt/entropy \
|
||||
--journal /mnt/entropy/media-relayout-tv.jsonl --apply
|
||||
--journal /mnt/entropy/.media-campaign/media-relayout-tv.jsonl --apply
|
||||
|
||||
Plans and journals live in `/mnt/entropy/.media-campaign/`, never loose on the
|
||||
array root: `media-apply.py` defaults its journal to `<root>/media-relayout.jsonl`,
|
||||
so `--journal` is always explicit here.
|
||||
|
||||
Guards, and why each one is here:
|
||||
|
||||
|
||||
@@ -16,14 +16,16 @@ the journal and frees no space until the hold is purged.
|
||||
|
||||
python3 scripts/media-tvdupes.py --library /mnt/entropy/TVShows \
|
||||
--hold /mnt/entropy/.AGENTTRASH/20261102-tv-duplicates \
|
||||
--out /mnt/entropy/tv-dupes-plan.tsv
|
||||
python3 scripts/media-apply.py apply --plan /mnt/entropy/tv-dupes-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/tv-dupes.jsonl \
|
||||
--out /mnt/entropy/.media-campaign/tv-dupes-plan.tsv
|
||||
python3 scripts/media-apply.py apply \
|
||||
--plan /mnt/entropy/.media-campaign/tv-dupes-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/tv-dupes.jsonl \
|
||||
--metadata-view /var/lib/media-meta/view --apply
|
||||
python3 scripts/media-apply.py verify --plan /mnt/entropy/tv-dupes-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/tv-dupes.jsonl
|
||||
python3 scripts/media-apply.py verify \
|
||||
--plan /mnt/entropy/.media-campaign/tv-dupes-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/tv-dupes.jsonl
|
||||
python3 scripts/media-apply.py sweep --root /mnt/entropy \
|
||||
--journal /mnt/entropy/tv-dupes.jsonl --apply # empty folders only
|
||||
--journal /mnt/entropy/.media-campaign/tv-dupes.jsonl --apply # empty folders only
|
||||
scripts/media-view.sh reap # ghost sidecars above
|
||||
|
||||
Then both servers have to be told: the plan moves files on the raw array, and no
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
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, public vhost, laws."
|
||||
tags:
|
||||
- dankserver
|
||||
- audiobooks
|
||||
- quadlet
|
||||
timestamp: 2026-11-02
|
||||
timestamp: 2026-11-03
|
||||
---
|
||||
|
||||
# Audiobookshelf
|
||||
@@ -16,8 +16,8 @@ per-listener playback positions, and streams the audio. It is **not** a media
|
||||
server — it shares nothing with [[Plex]] or [[Jellyfin]] and owns no files on the
|
||||
array.
|
||||
|
||||
**Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user
|
||||
manager) — **Internal-only:** LAN address, no vhost, no DNS name.
|
||||
**Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user manager) —
|
||||
**Doors:** LAN `http://192.168.0.235:8000/audiobookshelf` (plain HTTP, nothing in front) and public `https://audiobooks.franksdank.cloud/audiobookshelf/` — Caddy straight to the container, **exempt from the guard chain** because the app must answer non-browser clients ([[Caddy]] *Unguard a vhost*). The public name is the address every client carries: Pi-hole answers it with `192.168.0.143` on the LAN, Porkbun answers it with the WAN address outside, and Caddy serves a real certificate either way.
|
||||
|
||||
## Shape
|
||||
|
||||
@@ -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) |
|
||||
| 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 |
|
||||
| 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) |
|
||||
| Audio bind | `/mnt/entropy/Audiobooks:/audiobooks:ro` — read-only, by design |
|
||||
| State | `/home/dank/Containers/audiobookshelf/config` → `/config`, `…/metadata` → `/metadata` (both NVMe) |
|
||||
@@ -36,7 +36,8 @@ manager) — **Internal-only:** LAN address, no vhost, no DNS name.
|
||||
| Self-healing | `Restart=always`, `RestartSec=5`, `WantedBy=default.target` |
|
||||
| Deploy | `./scripts/deploy.sh dankserver --service audiobookshelf` |
|
||||
| Handler | `deploy_audiobookshelf` in `scripts/deploy-remote.sh` |
|
||||
| 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-relayout-audiobooks.jsonl` |
|
||||
| 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` |
|
||||
|
||||
## Bootstrap
|
||||
|
||||
@@ -54,17 +55,109 @@ difference. The script is idempotent; re-running it costs one incremental scan.
|
||||
| scan | `POST /api/libraries/<id>/scan` | `scan: accepted` |
|
||||
| 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
|
||||
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
|
||||
destroyed (a fresh DB makes the script create a new owner from it).
|
||||
|
||||
That value is the live password, not a leftover: it bcrypt-matches `users.pash` in the state
|
||||
DB (2026-11-03), so `local` does open the door when authentik is down.
|
||||
|
||||
A full scan is slow and runs in the background; watch the `items:` total climb,
|
||||
never `/healthcheck`. It also reads files that are genuinely broken and logs
|
||||
`[AudioFileScanner] Invalid data found when processing input` per file — that is
|
||||
the file, not the bind: the same line count grows while the total climbs.
|
||||
|
||||
## Phone app
|
||||
|
||||
The official **Audiobookshelf** app connects with Server Address
|
||||
`https://audiobooks.franksdank.cloud/audiobookshelf` — the base path belongs in the address —
|
||||
username `frank`, and the same password as the web UI, or the SSO button. One address works
|
||||
at home and away: on the LAN Pi-hole answers that name with `192.168.0.143` and Caddy dials
|
||||
the container; off-LAN the same name resolves to the WAN address and takes the same route
|
||||
(inbound 443 is forwarded at the router — fetched from a datacenter node 2026-11-03). The raw
|
||||
LAN address still answers and is the break-glass door whenever Caddy is down. `/ping`
|
||||
(`{"success":true}`) and `/healthcheck` (`OK`) answer at the ROOT whatever `ROUTER_BASE_PATH`
|
||||
says, so the app's connectivity probe works before it resolves the base path at all, and
|
||||
`download: true` on the account is what makes offline listening work.
|
||||
|
||||
**A home device that bypasses Pi-hole cannot reach it.** The G36 does not hairpin: resolving
|
||||
the name to the WAN address from inside the LAN times out (measured 2026-11-03). Only a
|
||||
client pinned to an external resolver hits this — everything using the router's DNS gets the
|
||||
LAN answer.
|
||||
|
||||
**The login token is at `user.token`, not top level.** `POST /login` answers
|
||||
`{ user, serverSettings, ereaderDevices, Source, userDefaultLibraryId }` and the bearer sits
|
||||
inside the user object. A client that reads `response.token` gets the empty string, sends
|
||||
`Authorization: Bearer ` with nothing after it, and every `/api/*` call then answers `401`
|
||||
as though the password were wrong.
|
||||
|
||||
**`POST /api/login` is not a route.** Local login is `POST <base>/login`, and the `/api`
|
||||
bearer guard answers anything else with a bare `401 Unauthorized` while logging **nothing** —
|
||||
no `[LocalAuth]` line — so a wrong path is indistinguishable from a wrong password unless the
|
||||
journal is read too. Same family as the absent `/setup` route named in
|
||||
`scripts/audiobookshelf-init.sh`. To settle a suspected stale credential without signing in:
|
||||
bcrypt-compare the recorded value against `users.pash` in
|
||||
`/home/dank/Containers/audiobookshelf/config/absdatabase.sqlite`, opened read-only
|
||||
(`file:<path>?mode=ro`).
|
||||
|
||||
## Listeners (accounts)
|
||||
|
||||
The door is public now, so the account is the only gate between a listener and the library.
|
||||
Two ways in, both per listener:
|
||||
|
||||
- **Local account** — **Settings → Users → Add User**: type `user`, tick only the libraries
|
||||
they may hear, and leave `download` off unless they need offline listening. Hand them the
|
||||
server address above and their own password.
|
||||
- **Household SSO** — create the authentik account, and that is the whole of it: the first
|
||||
**Sign in with Authentik** auto-registers the ABS account (`authOpenIDAutoRegister`) as a
|
||||
listener, because the claim grants that role to every signer. To shut a listener off,
|
||||
disable or delete their user in Audiobookshelf — no IdP group membership can withhold it.
|
||||
|
||||
What auto-registration hands out (read off the state DB, 2026-11-03): `accessAllLibraries:
|
||||
true` and `download: true` — every household signer lands on the **whole** library with
|
||||
offline downloads already on. Narrow it per account in **Settings → Users → <name>**. The DB
|
||||
is the census when no admin token is at hand; `GET /api/users` returns the *limited* shape
|
||||
(no `active`, no `librariesAccessible`), so a missing key there is not a denial, and an
|
||||
account created by SSO has `pash` NULL with its IdP link at `extraData.authOpenIDSub`.
|
||||
|
||||
Nothing rate-limits `POST /audiobookshelf/login` — this Caddy build carries no `rate_limit`
|
||||
module and ABS 2.37.2 ships no lockout — so the form is a guessing target exactly like
|
||||
`jelly.`, `plex.` and the *arr logins already are. The mitigation is credential hygiene:
|
||||
long unique passwords per listener, never a reuse of the root password.
|
||||
|
||||
## Single sign-on (authentik)
|
||||
|
||||
Both doors take the household identity: the web UI puts **Sign in with Authentik** beside
|
||||
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 admin group, the scope mapping | `scripts/authentik-audiobookshelf-sso.sh` — re-run it after an authentik rebuild, because this state lives in the IdP's database and a blueprint re-import reverts it silently |
|
||||
| Client secret | `ABS_OIDC_CLIENT_SECRET` in `~/.config/secrets/audiobookshelf` on Dankserver (mode 600; never in the repo, a URL or an argv) |
|
||||
| The ABS half: issuer, the four IdP URLs, claim, auto-register | `scripts/audiobookshelf-init.sh`, armed automatically when that secret exists in the credential file |
|
||||
| Role at first SSO sign-in | the `abs_groups` claim: every signer gets `user`; `audiobookshelf_admins` adds `admin`. Nothing in the IdP can deny the listener role — the login itself is the gate (law since 2026-11-03; the `audiobookshelf_users` group was retired, it had zero members and every household account was being refused) |
|
||||
| Admin group members | `akadmin` (the operator's own authentik admin account — sign in as it and the claim grants site admin) and `qa-e2e-admin` (the identity the deploy gate signs in as, so the script re-adds it every run; drop it and the gate fails, not a phone) |
|
||||
| Guard state of the door | none since 2026-11-03: proxy provider `audiobooks-proxy` and application "Audiobookshelf Guard" were deleted and the embedded outpost un-claimed them, because that chain answers a native client with challenge HTML ([[Caddy]] *Unguard a vhost* for the order). The SSO relying party `audiobookshelf` above is a different object and stays |
|
||||
| Redirect URIs on the RP | `<base>/auth/openid/callback` **and** `<base>/auth/openid/mobile-redirect` for **both** origins (LAN address and public name): the app sends the mobile URI belonging to the door it was configured with, and STRICT matching answers an unregistered one with "Redirect URI Error" |
|
||||
|
||||
With `authOpenIDAutoRegister` on, a first sign-in creates the account and the claim alone
|
||||
decides its type — and the claim always carries `user`, so **the IdP is now the library gate**:
|
||||
any authentik account that can reach this application becomes a listener. That is the
|
||||
intent (the household signs in with the accounts it already has), and its breadth is
|
||||
accepted on purpose: no access policy is bound to the `audiobookshelf` application, and
|
||||
the wide auto-registered default (every library, downloads on) is **not a defect to fix** —
|
||||
Frank's call, 2026-11-03. Narrowing stays a per-account judgement in **Settings → Users**.
|
||||
The one thing still worth watching is authentik's own invitation/signup flow: it must
|
||||
remain something only Frank drives, because an account created there is a listener the
|
||||
moment it signs in. Revoking one person is a disable/delete of their ABS user; revoking
|
||||
everyone is that signup flow.
|
||||
|
||||
Both halves — authentik's provider row and ABS's settings — are asserted end to end by the Playwright gate named in *Verification*. `23-` signs in as `qa-e2e-admin` and must land inside the app with site-admin rights; `24-` signs in as the standard identity, which is in no Audiobookshelf group, and must land as a listener. Together they pin the escalation and the default: a mapping that stops granting silently locks the household out, and a mapping that over-grants turns the household into site admins.
|
||||
|
||||
## Laws
|
||||
|
||||
- **Speak the image's env names, not the README's.** The server reads `PORT`,
|
||||
@@ -75,6 +168,54 @@ the file, not the bind: the same line count grows while the total climbs.
|
||||
`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:
|
||||
`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
|
||||
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
|
||||
@@ -105,10 +246,17 @@ the file, not the bind: the same line count grows while the total climbs.
|
||||
- **No `systemctl --user enable`.** Generated Quadlet units cannot be enabled on
|
||||
this host; `WantedBy=default.target` in the unit file is what carries the
|
||||
service across a reboot ([[Quadlet Lifecycle]]).
|
||||
- **LAN-only until Frank says otherwise.** `0.0.0.0:8000` with no reverse proxy
|
||||
in front. Any public vhost requires the Anubis + Authentik chain first, and the
|
||||
outpost proxy provider has to exist before the vhost flips (external exposure
|
||||
policy in `AGENTS.md`).
|
||||
- **One door for every client, and it sits outside the guard chain on purpose.**
|
||||
`https://audiobooks.franksdank.cloud/audiobookshelf` is what browsers and apps use;
|
||||
`http://192.168.0.235:8000/audiobookshelf` is the raw LAN socket that same Caddy dials,
|
||||
so it doubles as break-glass when the proxy is down. Behind the chain (until
|
||||
2026-11-03) the vhost answered the phone app's `/ping` and `/socket.io/` handshake with
|
||||
Anubis' challenge HTML — Anubis demands a JavaScript proof and the outpost needs a
|
||||
session cookie, and a native client solves neither — so the app evaluated
|
||||
`'isInit' in e.data` on a non-object, reported "Failed to contact server", and the
|
||||
container's request log never recorded the attempt. Podcast RSS and OPDS readers died
|
||||
the same death. ABS's own auth is the gate, as it is for [[Plex]] and [[Jellyfin]]
|
||||
([[Caddy]] *Unguard a vhost*).
|
||||
- **There is no `/setup` route; the creation door is `POST /init`.** `/status`
|
||||
(no `/api` prefix) reports `isInit` = "a root user exists", `POST /init` with
|
||||
`{"newRoot":{"username","password"}}` creates it, `POST /login` returns the
|
||||
@@ -188,12 +336,29 @@ the file, not the bind: the same line count grows while the total climbs.
|
||||
## Verification
|
||||
|
||||
`./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
|
||||
`wget` health check), `tests/audiobookshelf_init.bats` (the bootstrapper against a
|
||||
stub of the API above: create-once, idempotent re-run, no secret in a URL, refusal
|
||||
by URL when the server does not answer, and the deployer calling it only after the
|
||||
health gate) and `tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may
|
||||
reference the metadata view).
|
||||
(boot persistence, the SSD state roots, the `:ro` array bind, the mount gate, the `wget`
|
||||
health check, the base path the shipped SPA needs), `tests/audiobookshelf_init.bats` (the
|
||||
bootstrapper against a stub of the API above: create-once, idempotent re-run, no secret in a
|
||||
URL, refusal by URL when the server does not answer, the deployer calling it only after the
|
||||
health gate, and the SSO arm sending the mounted base path as the redirect subfolder);
|
||||
`tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may reference the metadata view);
|
||||
and the live Playwright gates `tests/e2e/specs/23-audiobookshelf-sso.spec.ts` and
|
||||
`tests/e2e/specs/24-audiobookshelf-sso-default-listener.spec.ts`, run by `tests/e2e/run.sh` at
|
||||
the end of every `deploy.sh`. `23-` (the `admin` project) signs in as `qa-e2e-admin` against
|
||||
`http://192.168.0.235:8000/audiobookshelf` and asserts the two things no stub can see: every
|
||||
`.js` the app loads arrives as JavaScript (a bundle built for the wrong base path arrives as
|
||||
`text/html`, which boots nothing), and `/api/me` answers 200 with `type: "admin"` — true only
|
||||
while grant types, the OpenID scope mappings and the group claim all hold together. `24-` (the
|
||||
`standard` project) signs in as `qa-e2e`, an account in no Audiobookshelf group, and must come
|
||||
back `type: "user"`: the default grant that lets the household in, so a scope-mapping
|
||||
regression surfaces as a failed sign-in in the gate instead of as a friend's complaint.
|
||||
|
||||
The public door is pinned by the guard audit `tests/e2e/specs/14-guard.spec.ts`, which now
|
||||
carries an **exemption** assertion where it used to carry a guarded one: an anonymous raw
|
||||
client must get the app's own `{"success":true}` on `/ping`, a machine-format socket
|
||||
handshake, and the app's own `401` on `/audiobookshelf/api/libraries` — never challenge
|
||||
HTML. `tests/anubis.bats` pins the same shape in the Caddyfile: the vhost never proxies to
|
||||
`anubis:8923`, and the post-auth listener carries no `@audiobooks` route.
|
||||
|
||||
Five book folders hold media no scanner can read, so they stay out of the library:
|
||||
three 2024 Nebula nominees in the `_unsorted` box (two `.m4b` with no `moov` atom,
|
||||
|
||||
@@ -58,12 +58,23 @@ reads — movie art has never existed beside the media on either layer.
|
||||
**Library coverage is the first thing to check when a title is missing.** The
|
||||
four libraries are `movies → /data/Movies`, `tvshows → /data/TVShows`,
|
||||
`music → /data/Music`, `boxsets → /config/data/collections`
|
||||
(`GET /Library/VirtualFolders`). `/data/media-park` is in **none** of them, so
|
||||
everything filed under it — the `_unsorted` release bundles and the `extras/`
|
||||
folders the relayout wrote there — is invisible to Jellyfin by configuration,
|
||||
(`GET /Library/VirtualFolders`). The review pile — `/data/NeedsProcessing`, the
|
||||
former `media-park`, drained into it 2026-11-02 — is in **none** of them, so
|
||||
everything filed there is invisible to Jellyfin by configuration,
|
||||
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.
|
||||
|
||||
**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 :
|
||||
'/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.
|
||||
|
||||
@@ -41,7 +41,7 @@ python3 scripts/media-audiobooks.py --root /mnt/entropy/Audiobooks \
|
||||
# library too ([[Audiobookshelf]]) — otherwise it keeps rows for folders that
|
||||
# no longer exist.
|
||||
scripts/media-apply.py apply --plan /tmp/ab_plan.tsv --root /mnt/entropy \
|
||||
--journal /mnt/entropy/media-relayout-audiobooks.jsonl --apply
|
||||
--journal /mnt/entropy/.media-campaign/media-relayout-audiobooks.jsonl --apply
|
||||
```
|
||||
|
||||
The group is the identity Plex asserts in its own database — **(narrator, book,
|
||||
@@ -68,7 +68,7 @@ no chapter row names, and a loose file in a folder holding two books all park un
|
||||
that unrelated books share, or a chapter number mistagged as the artist (`0.4`).
|
||||
`Various Artists` is not a placeholder — that is a multi-cast read.
|
||||
|
||||
The reviewed plan lives at `/mnt/entropy/media-relayout-audiobooks-plan.tsv`; it
|
||||
The reviewed plan lives at `/mnt/entropy/.media-campaign/media-relayout-audiobooks-plan.tsv`; it
|
||||
applies on its own journal, behind the music writer, and then refreshes the Plex
|
||||
section. Verify it with `--db-kind plex` — Jellyfin holds no rows here, so a
|
||||
Jellyfin pass proves nothing either way.
|
||||
|
||||
@@ -65,8 +65,8 @@ executor's own format; it never moves anything itself.
|
||||
python3 scripts/media-tvdupes.py --library /mnt/entropy/TVShows \
|
||||
--hold /mnt/entropy/.AGENTTRASH/<date>-tv-duplicates \
|
||||
--meta /var/lib/media-meta/view/TVShows --witness witness.tsv \
|
||||
--ids /mnt/entropy/tv-tvdb-ids.tsv \
|
||||
--out /mnt/entropy/tv-dupes-plan.tsv --json /tmp/tv-groups.json
|
||||
--ids /mnt/entropy/.media-campaign/tv-tvdb-ids.tsv \
|
||||
--out /mnt/entropy/.media-campaign/tv-dupes-plan.tsv --json /tmp/tv-groups.json
|
||||
# then media-apply.py apply / verify / sweep / rollback, exactly as for a film prune
|
||||
```
|
||||
|
||||
@@ -76,7 +76,7 @@ python3 scripts/media-tvdupes.py --library /mnt/entropy/TVShows \
|
||||
match, not a lookup: `BaseItems` (`Type like '%TV.Series'`) joined to
|
||||
`BaseItemProviders` where `ProviderId = 'Tvdb'`, the `/data/TVShows/` prefix
|
||||
stripped to the bare top-level folder name — 616 folders, none carrying two
|
||||
ids, written to `/mnt/entropy/tv-tvdb-ids.tsv`. Two folders with disjoint ids
|
||||
ids, written to `/mnt/entropy/.media-campaign/tv-tvdb-ids.tsv`. Two folders with disjoint ids
|
||||
are two shows however alike they read — *Avatar: The Last Airbender* (1999)
|
||||
beside the 2024 remake, *Millennium* (1996) beside a German thriller Jellyfin
|
||||
had also titled *Millennium* — and a pair whose identity cannot be proven is
|
||||
@@ -158,12 +158,12 @@ Nothing is deleted. Every doomed copy moves into a dated hold with the same
|
||||
executor the relayout uses, so any file comes back:
|
||||
|
||||
```bash
|
||||
scripts/media-apply.py apply --plan /mnt/entropy/dup-prune-similar-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/dup-prune-similar.jsonl # dry
|
||||
scripts/media-apply.py apply --plan /mnt/entropy/.media-campaign/dup-prune-similar-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/dup-prune-similar.jsonl # dry
|
||||
scripts/media-apply.py apply --plan … --root /mnt/entropy --journal … --apply
|
||||
scripts/media-apply.py verify --plan … --journal /mnt/entropy/dup-prune-similar.jsonl
|
||||
scripts/media-apply.py sweep --journal /mnt/entropy/dup-prune-similar.jsonl --root /mnt/entropy --apply
|
||||
scripts/media-apply.py rollback --journal /mnt/entropy/dup-prune-similar.jsonl --root /mnt/entropy --apply
|
||||
scripts/media-apply.py verify --plan … --journal /mnt/entropy/.media-campaign/dup-prune-similar.jsonl
|
||||
scripts/media-apply.py sweep --journal /mnt/entropy/.media-campaign/dup-prune-similar.jsonl --root /mnt/entropy --apply
|
||||
scripts/media-apply.py rollback --journal /mnt/entropy/.media-campaign/dup-prune-similar.jsonl --root /mnt/entropy --apply
|
||||
```
|
||||
|
||||
The plan rows are the tool's own format (`action src dst kind`, `mkdir` rows for
|
||||
|
||||
@@ -33,7 +33,7 @@ python3 scripts/media-music.py --root /mnt/entropy/Music --enum /tmp/music_enum.
|
||||
# 4. Read the exceptions file FIRST — it is the review, not a log. Then the same
|
||||
# executor, journal and rollback as [[Media Relayout]] shows, on its own journal.
|
||||
scripts/media-apply.py apply --plan /tmp/music_plan.tsv --root /mnt/entropy \
|
||||
--journal /mnt/entropy/media-relayout-music.jsonl --apply
|
||||
--journal /mnt/entropy/.media-campaign/media-relayout-music.jsonl --apply
|
||||
```
|
||||
|
||||
The group is what the tags assert, not where the files sit: **(album-artist tag,
|
||||
|
||||
@@ -71,12 +71,23 @@ that would be filed wrong, and the plan is not run until it is understood.
|
||||
```bash
|
||||
scripts/media-apply.py apply --plan /tmp/jf-plan.tsv --root /mnt/entropy/Movies # dry, writes nothing
|
||||
scripts/media-apply.py apply --plan /tmp/jf-plan.tsv --root /mnt/entropy/Movies --apply
|
||||
scripts/media-apply.py verify --plan /tmp/jf-plan.tsv --journal /mnt/entropy/media-relayout.jsonl
|
||||
scripts/media-apply.py sweep --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies # dry
|
||||
scripts/media-apply.py sweep --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies --apply
|
||||
scripts/media-apply.py rollback --journal /mnt/entropy/media-relayout.jsonl --root /mnt/entropy/Movies --apply
|
||||
scripts/media-apply.py verify --plan /tmp/jf-plan.tsv --journal /mnt/entropy/.media-campaign/media-relayout.jsonl
|
||||
scripts/media-apply.py sweep --journal /mnt/entropy/.media-campaign/media-relayout.jsonl --root /mnt/entropy/Movies # dry
|
||||
scripts/media-apply.py sweep --journal /mnt/entropy/.media-campaign/media-relayout.jsonl --root /mnt/entropy/Movies --apply
|
||||
scripts/media-apply.py rollback --journal /mnt/entropy/.media-campaign/media-relayout.jsonl --root /mnt/entropy/Movies --apply
|
||||
```
|
||||
|
||||
**Every campaign artifact — plan, journal, exceptions file, run log — lives in
|
||||
`/mnt/entropy/.media-campaign/`.** The array root carries libraries and nothing
|
||||
else: a root strewn with `*.jsonl` and `*-plan.tsv` gets browsed as content, and
|
||||
the dot-directory is ignored by both media servers. Relocated 2026-11-02, 48 files,
|
||||
undo with `media-apply.py rollback --journal /mnt/entropy/.media-campaign/media-campaign-relocate.jsonl --root /mnt/entropy --apply`.
|
||||
The holds and trash (`.AGENTTRASH`, `.HOLD-<date>-*`) stay at the root on purpose:
|
||||
the purge machinery is given those paths as `--hold`, and a hold that moves out
|
||||
from under the tool that made it is a hold nobody can find. Name `--journal`
|
||||
explicitly on every run — the tool's default is `media-relayout.jsonl` beside
|
||||
`--root`, so a bare run recreates exactly the root-level log this page forbids.
|
||||
|
||||
An apply also leaves the release folders it emptied standing where they were —
|
||||
hundreds of them at the library root — which is what `sweep` removes, dry-run
|
||||
first, from the same journal.
|
||||
@@ -110,7 +121,7 @@ a read beside a running scan can fail as "database disk image is malformed".
|
||||
`immutable` stays only the fallback for a database whose `-shm` cannot be read.
|
||||
|
||||
```bash
|
||||
scripts/media-verify.py --journal /mnt/entropy/media-relayout-music.jsonl \
|
||||
scripts/media-verify.py --journal /mnt/entropy/.media-campaign/media-relayout-music.jsonl \
|
||||
--db-kind jellyfin --ignore-ext jpg,jpeg,png,log,cue,toc,nfo,txt,m3u,accurip
|
||||
```
|
||||
|
||||
@@ -150,11 +161,11 @@ by series, and writes a plan in the executor's own format:
|
||||
|
||||
```bash
|
||||
scripts/media-tv.py --root /mnt/entropy/Movies --library /mnt/entropy/TVShows \
|
||||
--out /mnt/entropy/media-tv-plan.tsv
|
||||
scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv --root /mnt/entropy
|
||||
scripts/media-apply.py apply --plan /mnt/entropy/media-tv-plan.tsv --root /mnt/entropy \
|
||||
--journal /mnt/entropy/media-relayout-tv.jsonl --apply
|
||||
scripts/media-apply.py sweep --journal /mnt/entropy/media-relayout-tv.jsonl \
|
||||
--out /mnt/entropy/.media-campaign/media-tv-plan.tsv
|
||||
scripts/media-apply.py apply --plan /mnt/entropy/.media-campaign/media-tv-plan.tsv --root /mnt/entropy
|
||||
scripts/media-apply.py apply --plan /mnt/entropy/.media-campaign/media-tv-plan.tsv --root /mnt/entropy \
|
||||
--journal /mnt/entropy/.media-campaign/media-relayout-tv.jsonl --apply
|
||||
scripts/media-apply.py sweep --journal /mnt/entropy/.media-campaign/media-relayout-tv.jsonl \
|
||||
--root /mnt/entropy --apply
|
||||
```
|
||||
|
||||
|
||||
@@ -51,16 +51,16 @@ python3 scripts/media-census.py probe --root /mnt/entropy/TVShows \
|
||||
# against scp'd TSVs — which is where the two provider secrets live.
|
||||
python3 scripts/media-shows.py --root /mnt/entropy/TVShows --enum /tmp/tv_enum.tsv \
|
||||
--titles /tmp/tv_titles.tsv --probed /tmp/tv_probed.tsv \
|
||||
--tmdb-cache /mnt/entropy/media-tv-names.json --tmdb --tvdb \
|
||||
--park /mnt/entropy/media-park/TVShows \
|
||||
--out /mnt/entropy/media-relayout-shows2-plan.tsv \
|
||||
--exceptions /mnt/entropy/media-relayout-shows2-exceptions.txt
|
||||
--tmdb-cache /mnt/entropy/.media-campaign/media-tv-names.json --tmdb --tvdb \
|
||||
--park /mnt/entropy/NeedsProcessing/TVShows \
|
||||
--out /mnt/entropy/.media-campaign/media-relayout-shows2-plan.tsv \
|
||||
--exceptions /mnt/entropy/.media-campaign/media-relayout-shows2-exceptions.txt
|
||||
# 4. Read the exceptions file FIRST — it is the review, not a log. Same executor
|
||||
# as above, but on this arm's OWN journal, NAMED PER PASS: the journal of an
|
||||
# applied pass is its unwind record, so a second pass that reuses the name
|
||||
# destroys the only map back.
|
||||
scripts/media-apply.py apply --plan /mnt/entropy/media-relayout-shows2-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/media-relayout-shows2.jsonl --apply
|
||||
scripts/media-apply.py apply --plan /mnt/entropy/.media-campaign/media-relayout-shows2-plan.tsv \
|
||||
--root /mnt/entropy --journal /mnt/entropy/.media-campaign/media-relayout-shows2.jsonl --apply
|
||||
```
|
||||
|
||||
Evidence, strongest first — the order the planner believes things:
|
||||
|
||||
@@ -4,7 +4,7 @@ description: "Outpost, proxy-provider, OAuth2, LDAP and flow configuration for t
|
||||
tags:
|
||||
- infrastructure
|
||||
- authentik
|
||||
timestamp: 2026-10-19
|
||||
timestamp: 2026-11-03
|
||||
---
|
||||
|
||||
# Authentik Outpost Reference
|
||||
@@ -56,7 +56,7 @@ correctly — the outpost matches on hostname.
|
||||
|
||||
## Provisioning rules for proxy providers
|
||||
|
||||
Two provisioning rules, each verified live by an outage:
|
||||
Three provisioning rules, each verified live by an outage or a dead login:
|
||||
|
||||
1. **All three flows are required.** A ProxyProvider created via raw API/ORM
|
||||
with any of `authentication_flow`/`authorization_flow`/`invalidation_flow`
|
||||
@@ -80,6 +80,16 @@ Two provisioning rules, each verified live by an outage:
|
||||
the app. No error is logged anywhere — the SSO "works" and the app never
|
||||
appears. Create the Application with `provider_id` set (the UI does this
|
||||
automatically; raw API/ORM drives must do it explicitly).
|
||||
3. **A raw ORM create skips the OAuth defaults.** The REST serializer calls
|
||||
`ProxyProvider.set_oauth_defaults()`; `ProxyProvider.objects.create()` does not. The row
|
||||
then looks fully configured — external_host, internal_host, application bound, outpost
|
||||
claimed — while `redirect_uris` is empty (those URIs are *derived* from `external_host` by
|
||||
`_get_callback_url`) and `grant_types` is `[]`. Both kill the guard before any credential,
|
||||
in this order: authentik renders its own **"Redirect URI Error"** page, and once the URIs
|
||||
are filled by hand the next wall is a bodiless **HTTP 400** out of `/application/o/authorize/`
|
||||
(both measured on the Audiobookshelf guard provider, 2026-11-03). Census and repair in one idempotent pass, all
|
||||
providers at once: `scripts/authentik-proxy-provider-defaults.sh`; `--check` prints the
|
||||
census and exits 1 on drift, and a provider that already matches is left alone.
|
||||
|
||||
## OAuth2 relying-party quirks (this fork; corrected live 2026-10-20)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ description: "Caddy is the single reverse proxy for both hosts."
|
||||
tags:
|
||||
- infrastructure
|
||||
- caddy
|
||||
timestamp: 2026-08-29
|
||||
timestamp: 2026-11-03
|
||||
---
|
||||
|
||||
# Caddy
|
||||
@@ -90,7 +90,111 @@ pihole.franksdank.cloud {
|
||||
|
||||
After rendering, the line reads `dankuser <hash>` (one complete hash) — `tests/deployment_flow.bats:96-99` pins the `dankuser` name and the single-hash form, and `tests/caddyfile.bats:75` pins the bare `dankuser REPLACE_WITH_HASH` template. Do not put a `$2a$14$` prefix in the template: it would render a double-prefixed, invalid hash (2026-08-22 incident — every basicauth vhost returned 401 for every password; `tests/caddyfile.bats:67-72` forbids the prefix). `tests/deployment_flow.bats` pins the render contract.
|
||||
|
||||
Everything else authenticates, not basicauth. The guarded set — `www.` (homepage), `search.`, `grafana.`, `prometheus.`, `loki.`, `seerr.`, `marreta.`, `romm.`, `files.` — proxies **all** traffic to `anubis:8923`, which verifies the client and forwards it back to Caddy's internal listener `http://:8016`; Authentik's proxy-provider mode serves the login inline (this build exposes **no** `forward_auth` endpoint) and forwards authenticated traffic to `http://:8017`, where the request is routed straight to its app (`dankclaw/Caddyfile:14-24,283-423`). The exempt-by-construction set never enters that chain: `auth.` (the IdP itself), `ai.` and `gitea.` (own logins), `hermes.` and `hermesadmin.` (app-enforced passwords), `pihole.` (the basicauth edge above), and the media/*arr vhosts. Grafana left basicauth on 2026-09-01 and is an OAuth2 relying party against Authentik (`dankclaw/Caddyfile:122-132`). See [[Homepage]] and [[Authentik]].
|
||||
Everything else authenticates, not basicauth. The guarded set — `www.` (homepage), `search.`, `grafana.`, `prometheus.`, `loki.`, `seerr.`, `marreta.`, `romm.`, `files.`, `notebook.` — proxies **all** traffic to `anubis:8923`, which verifies the client and forwards it back to Caddy's internal listener `http://:8016`; Authentik's proxy-provider mode serves the login inline (this build exposes **no** `forward_auth` endpoint) and forwards authenticated traffic to `http://:8017`, where the request is routed straight to its app (`dankclaw/Caddyfile`: the header comment and the two internal listeners). The exempt-by-construction set never enters that chain: `auth.` (the IdP itself), `ai.` and `gitea.` (own logins), `hermes.` and `hermesadmin.` (app-enforced passwords), `pihole.` (the basicauth edge above), the media/*arr vhosts, and `audiobooks.` — Audiobookshelf enforces its own auth (local accounts plus authentik OIDC) and must answer native app clients, podcast RSS and OPDS, which the chain structurally cannot deliver: it answers a client that solves no JavaScript proof-of-work with challenge HTML wherever that client expects JSON or a socket payload ([[Audiobookshelf]]). Grafana left basicauth on 2026-09-01 and is an OAuth2 relying party against Authentik. See [[Homepage]] and [[Authentik]].
|
||||
|
||||
## Guard a new vhost
|
||||
|
||||
Order is the whole procedure: the IdP row has to exist **before** the route flips, or
|
||||
authenticated traffic reaches the outpost with no matching provider and the user gets
|
||||
authentik's error page instead of the app.
|
||||
|
||||
1. **Provider first.** A proxy provider plus an application, claimed by the embedded
|
||||
outpost. No blueprint owns this state — it lives only in the IdP's database, which is
|
||||
why it is easy to forget:
|
||||
|
||||
```
|
||||
ssh dankclaw 'podman exec -i authentik-server ak shell' <<'PY'
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from authentik.core.models import Application
|
||||
from authentik.outposts.models import Outpost
|
||||
NAME, EXT = "example", "https://example.franksdank.cloud/"
|
||||
pp = ProxyProvider.objects.filter(name=f"{NAME}-proxy").first()
|
||||
if pp is None:
|
||||
pp = ProxyProvider.objects.create(name=f"{NAME}-proxy", external_host=EXT,
|
||||
internal_host="http://caddy:8017")
|
||||
if not Application.objects.filter(provider=pp).exists():
|
||||
Application.objects.create(name=f"{NAME.title()} Guard", slug=f"{NAME}-guard",
|
||||
provider=pp)
|
||||
o = Outpost.objects.get(managed="goauthentik.io/outposts/embedded")
|
||||
o.providers.add(pp) # an unclaimed provider is invisible to the outpost
|
||||
print(pp.name, pp.external_host, o.providers.count())
|
||||
PY
|
||||
```
|
||||
|
||||
`external_host` is the exact vhost URL **with the trailing slash**: the embedded
|
||||
outpost routes on the ORIGINAL `Host` header alone, so anything else matches zero
|
||||
providers. Never print a provider's fields wholesale — a proxy provider carries a
|
||||
client secret, and it lands in whatever transcript the command runs in.
|
||||
|
||||
`ProxyProvider.objects.create()` is not the API: the serializer's
|
||||
`set_oauth_defaults()` never runs, so the row lands with `redirect_uris` and
|
||||
`grant_types` empty, and the guard fails *before* any credential — authentik's
|
||||
"Redirect URI Error" page, then a bodiless HTTP 400 once the URIs are filled
|
||||
by hand. Run `scripts/authentik-proxy-provider-defaults.sh` after the snippet
|
||||
(idempotent, covers every provider; `--check` is the census); law and failure
|
||||
signatures in [[Authentik Outpost Reference]].
|
||||
2. **Then the route.** The public site block proxies the whole request to `anubis:8923`
|
||||
with `header_up X-Real-Ip {remote_host}`, and the post-auth `http://:8017` listener
|
||||
gains a `@<name> host <vhost>` handler pointing at the app — copy `romm`/`files`,
|
||||
which are the same shape including a Dankserver LAN-port upstream.
|
||||
An app that derives its own origin from the request needs the scheme pinned:
|
||||
Audiobookshelf checks every OIDC callback against `x-forwarded-proto://Host`; this
|
||||
listener is fed over plain http so Caddy's own `X-Forwarded-Proto` is `http`, and the
|
||||
app answers a 400 "Invalid callback URL - must be same-origin" for its own https
|
||||
callback (measured 2026-11-03, while this vhost was still guarded — it is exempt
|
||||
now, and the same pin sits on its edge block instead).
|
||||
The edge for a guarded name is TLS-only, so `header_up X-Forwarded-Proto
|
||||
https` is the truthful value ([[Audiobookshelf]]).
|
||||
3. **Then the name and the audit.** A `dankclaw/pihole/addn-hosts` line (the `pihole`
|
||||
seam fails the build if a Caddyfile vhost has no record), a Porkbun A record if the
|
||||
name must resolve off-LAN ([[LAN-DNS]]), and the subdomain added to `GUARDED` in
|
||||
`tests/e2e/specs/14-guard.spec.ts` with an app-specific leak marker — that spec is
|
||||
the deploy gate's proof that the guard, not the app, answers an anonymous client.
|
||||
|
||||
## Unguard a vhost
|
||||
|
||||
The same three artifacts, in the opposite order. Getting it wrong takes the app
|
||||
down for browsers while looking healthy:
|
||||
|
||||
1. **Route first.** Move the public site block off `anubis:8923` to a direct
|
||||
`reverse_proxy` (keeping any origin-pinning `header_up` the app needs), delete
|
||||
the `@<name>` handler from the `http://:8017` listener, and deploy. Traffic
|
||||
stops entering the auth listener at `:8016`, so the IdP row goes unused — and
|
||||
nothing breaks.
|
||||
2. **Then the IdP rows**, once the route is verified:
|
||||
|
||||
```
|
||||
ssh dankclaw 'podman exec -i authentik-server ak shell' <<'PY'
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from authentik.outposts.models import Outpost
|
||||
pp = ProxyProvider.objects.filter(name="audiobooks-proxy").first()
|
||||
if pp:
|
||||
o = Outpost.objects.get(managed="goauthentik.io/outposts/embedded")
|
||||
o.providers.remove(pp)
|
||||
app = pp.application # the "<Name> Guard" application row
|
||||
if app:
|
||||
app.delete()
|
||||
pp.delete()
|
||||
print("providers now claimed:", Outpost.objects.get(
|
||||
managed="goauthentik.io/outposts/embedded").providers.count())
|
||||
PY
|
||||
```
|
||||
|
||||
Deleting the provider **before** the route flips is the wrong order: the
|
||||
outpost then matches zero providers for a Host that still arrives, and every
|
||||
browser gets authentik's error page instead of the app.
|
||||
3. **Then the audit lists.** Drop the subdomain from `GUARDED` in
|
||||
`tests/e2e/specs/14-guard.spec.ts` (and its leak marker), and replace it
|
||||
there with an exemption assertion — the deploy gate must keep proving the
|
||||
exemption is deliberate, the way `plex` does: app bytes to an anonymous
|
||||
client, zero challenge HTML, and the app's own `401` on an unauthenticated
|
||||
API call rather than the gate's `200`.
|
||||
|
||||
What the exemption costs, said plainly: the app's login endpoint becomes
|
||||
internet-visible with no rate limiter in front of it unless the edge carries one
|
||||
(this Caddy build has no `rate_limit` module) or the app ships a lockout (ABS
|
||||
2.37.2 does not). That is the same exposure `jelly.`, `plex.` and the *arr
|
||||
vhosts already live with; the mitigation is the credentials.
|
||||
|
||||
## Operations
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ is a locator: one row, one clause, no numbers.
|
||||
|
||||
| Page | Owns |
|
||||
|---|---|
|
||||
| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, laws. |
|
||||
| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, public vhost, laws. |
|
||||
| [[Calibre]] | Calibre is the e-book library manager that backs Calibre-Web. |
|
||||
| [[Calibre-Web]] | Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database. |
|
||||
| [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. |
|
||||
|
||||
@@ -516,7 +516,7 @@
|
||||
"category": "admin",
|
||||
"section": "Media Library Apps",
|
||||
"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": "",
|
||||
"probe": "192.168.0.235:8000",
|
||||
"method": "http",
|
||||
|
||||
+11
-2
@@ -75,8 +75,10 @@ PY
|
||||
}
|
||||
|
||||
@test "exempt vhosts never route through anubis (auth, plex, own-auth apps)" {
|
||||
# The IdP login flow and git smart-HTTP clients cannot solve challenges;
|
||||
# these vhosts bypass the chain by construction.
|
||||
# The IdP login flow, git smart-HTTP clients, and any non-browser client (the
|
||||
# Audiobookshelf phone app, podcast RSS readers) cannot solve a challenge, so
|
||||
# services that enforce their own complete auth bypass the chain by
|
||||
# construction — the gate would answer their API calls with HTML.
|
||||
local block
|
||||
block=$(awk '/^auth\.franksdank\.cloud/,/^}/' "$CADDY")
|
||||
echo "$block" | grep -q 'reverse_proxy authentik-server:9000'
|
||||
@@ -84,4 +86,11 @@ PY
|
||||
block=$(awk '/^plex\.franksdank\.cloud/,/^}/' "$CADDY")
|
||||
echo "$block" | grep -q '192\.168\.0\.235:32400'
|
||||
! echo "$block" | grep -q anubis
|
||||
block=$(awk '/^audiobooks\.franksdank\.cloud/,/^}/' "$CADDY")
|
||||
echo "$block" | grep -q '192\.168\.0\.235:8000'
|
||||
! echo "$block" | grep -q anubis
|
||||
# The exemption is total: the post-auth listener must not still carry an
|
||||
# audiobooks route. A leftover handle there is a second owner for one host,
|
||||
# and its X-Forwarded-Proto pin would silently disagree with the edge.
|
||||
! grep -q '@audiobooks host audiobooks.franksdank.cloud' "$CADDY"
|
||||
}
|
||||
@@ -17,6 +17,10 @@
|
||||
# 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;
|
||||
# 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
|
||||
|
||||
@@ -29,7 +33,7 @@ import json, sys
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
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):
|
||||
@@ -37,6 +41,12 @@ def log(line):
|
||||
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):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
@@ -57,20 +67,24 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.headers.get("Authorization") == "Bearer stub-token"
|
||||
|
||||
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"))
|
||||
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():
|
||||
return self.send(401, "Unauthorized")
|
||||
if path == "/api/libraries":
|
||||
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"):
|
||||
return self.send(200, {"total": 42, "results": []}, True)
|
||||
return self.send(404, "nope")
|
||||
|
||||
def do_POST(self):
|
||||
path = self.path.split("?")[0]
|
||||
path = norm(self.path.split("?")[0])
|
||||
payload = self.body()
|
||||
log("POST %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
|
||||
if path == "/init":
|
||||
@@ -101,6 +115,21 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.send(200, "OK")
|
||||
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()
|
||||
PY
|
||||
@@ -198,3 +227,50 @@ boot() {
|
||||
/audiobookshelf-init\.sh/ { init = NR }
|
||||
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" ]
|
||||
}
|
||||
@@ -182,8 +182,13 @@ load helpers
|
||||
# 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
|
||||
# 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"
|
||||
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
|
||||
}
|
||||
@@ -35,14 +35,20 @@ export default defineConfig({
|
||||
// 2026-10-19 - the newest guarded app, in neither GUARDED list nor any
|
||||
// spec until now: the 17-hermes "uncovered vhost broke silently" class).
|
||||
// 22- joins Prometheus's app-is-really-there proof (its own API), closing
|
||||
// the "not a login wall but maybe a 404" hole 20- closed for Grafana.
|
||||
testMatch: /(10|12|13|16|17|18|20|21|22)-.*\.spec\.ts/,
|
||||
// the "not a login wall but maybe a 404" hole 20- closed for Grafana. 24-
|
||||
// joins 2026-11-03 (Audiobookshelf's DEFAULT listener grant) and belongs to
|
||||
// this project precisely because the standard identity is in no Audiobookshelf
|
||||
// group — the admin project could not observe the default.
|
||||
testMatch: /(10|12|13|16|17|18|20|21|22|24)-.*\.spec\.ts/,
|
||||
use: { storageState: ".auth/qa-e2e.json" },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
{
|
||||
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" },
|
||||
dependencies: ["setup"],
|
||||
},
|
||||
|
||||
@@ -6,9 +6,10 @@ import { expect, test } from "@playwright/test";
|
||||
// navigation would skip. These tests drive the browser as an UNAUTHENTICATED
|
||||
// user (the guest project has no storage state) and navigate directly to each
|
||||
// guarded vhost's root path: the guard must present authentik's flow UI in
|
||||
// place and must never leak app content. plex is the documented exemption —
|
||||
// PMS enforces its own auth — so it must be reachable unauthenticated with
|
||||
// zero challenge pages, proving the exemption is deliberate, not accidental.
|
||||
// place and must never leak app content. plex and audiobooks are the documented
|
||||
// exemptions — PMS and Audiobookshelf enforce their own auth — so they must be
|
||||
// reachable unauthenticated with zero challenge pages, proving the exemption is
|
||||
// deliberate, not accidental.
|
||||
|
||||
const BASE = "https://www.franksdank.cloud";
|
||||
|
||||
@@ -17,7 +18,9 @@ const BASE = "https://www.franksdank.cloud";
|
||||
// added here or the guard-chain audit is incomplete.
|
||||
const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki", "notebook"];
|
||||
|
||||
// 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:
|
||||
// authentik's gate never renders an app's UI text, because it IS the IdP.
|
||||
const LEAK_MARKERS: Record<string, string[]> = {
|
||||
"search.franksdank.cloud": ["SearXNG preferences"],
|
||||
"romm.franksdank.cloud": ["RomM"],
|
||||
@@ -98,3 +101,54 @@ test("plex is exempt from the guard chain and serves PMS content unauthenticated
|
||||
const resp = await page.request.get("https://plex.franksdank.cloud/");
|
||||
expect(resp.status(), "PMS must answer directly, not error").toBeLessThan(500);
|
||||
});
|
||||
|
||||
// Audiobookshelf is exempt by construction (2026-11-03). The phone app is a
|
||||
// non-browser client and solves no JS proof-of-work, so behind the guard chain it
|
||||
// was answered with challenge HTML wherever the socket `init` payload belongs —
|
||||
// every remote sign-in died as a TypeError on a non-object, and the container's
|
||||
// request log never saw a byte of it. ABS enforces its own auth (local accounts
|
||||
// plus authentik OIDC), exactly like PMS above. Asserted from the CLIENT side: the
|
||||
// endpoints an app must call before it holds any credential are answered by the
|
||||
// app, and the app's own 401 — not a 200 of HTML — is what an anonymous API call gets.
|
||||
test("audiobookshelf answers an unauthenticated app client with app bytes, not gate bytes", async ({
|
||||
request,
|
||||
}) => {
|
||||
const ABS = "https://audiobooks.franksdank.cloud";
|
||||
|
||||
// The probe every Audiobookshelf client sends first. The app answers it at the
|
||||
// ROOT whatever ROUTER_BASE_PATH says, so this is the vhost's own behaviour.
|
||||
const ping = await request.get(`${ABS}/ping`, { timeout: 30_000 });
|
||||
expect(ping.status(), "/ping must be answered by the app").toBe(200);
|
||||
expect(await ping.json(), "/ping must carry the app's own payload").toEqual({ success: true });
|
||||
|
||||
// The socket.io handshake. Engine.IO answers this in a machine format; the gate
|
||||
// answered it in HTML, which is the defect this exemption exists to kill.
|
||||
const sock = await request.get(`${ABS}/audiobookshelf/socket.io/?EIO=4&transport=polling`, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
expect(
|
||||
await sock.text(),
|
||||
"the socket handshake was answered by the gate, not Engine.IO",
|
||||
).not.toContain("Making sure you're not a bot");
|
||||
expect(
|
||||
sock.headers()["content-type"] ?? "",
|
||||
"the socket handshake must not arrive as HTML",
|
||||
).not.toMatch(/text\/html/);
|
||||
|
||||
// An API call with no credential: the app refuses it. A gate would answer 200
|
||||
// with HTML, so the status code alone distinguishes the two owners.
|
||||
const api = await request.get(`${ABS}/audiobookshelf/api/libraries`, { timeout: 30_000 });
|
||||
expect(api.status(), "the app's own auth gate must own unauthenticated API calls").toBe(401);
|
||||
});
|
||||
|
||||
test("audiobookshelf login renders the app's own sign-in to an anonymous browser", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.goto("https://audiobooks.franksdank.cloud/audiobookshelf/login");
|
||||
expect(new URL(page.url()).hostname).toBe("audiobooks.franksdank.cloud");
|
||||
|
||||
const body = await page.locator("body").innerText();
|
||||
expect(body, "the bot challenge still fronts this vhost").not.toContain("Making sure you're not a bot");
|
||||
expect(await page.locator('input[name="uidField"]').count(), "the IdP gate form must not front the app").toBe(0);
|
||||
// The app answers, and its login screen carries SSO beside the local form.
|
||||
await expect(page.getByText("Sign in with Authentik")).toBeVisible({ timeout: 90_000 });
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
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; status: number }[] = [];
|
||||
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"] ?? "", status: res.status() });
|
||||
}
|
||||
});
|
||||
|
||||
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).
|
||||
// Only responses that carried a body can be judged: the OIDC bounce re-validates the entry
|
||||
// script (Cache-Control: max-age=0 + ETag) and a 304 carries no Content-Type by spec.
|
||||
const bodies = scripts.filter((s) => s.status === 200);
|
||||
expect(bodies.length, "the SPA loaded no JavaScript").toBeGreaterThan(2);
|
||||
for (const s of bodies) {
|
||||
expect(s.type, `${s.url} arrived as ${s.type}`).toContain("javascript");
|
||||
}
|
||||
|
||||
// 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");
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Audiobookshelf grants the LISTENER role to anyone the IdP lets sign in (2026-11-03).
|
||||
//
|
||||
// The role used to come from membership of `audiobookshelf_users` — a group nobody was ever
|
||||
// added to. The household accounts exist in authentik, and every one of them was denied at ABS
|
||||
// AFTER a successful authentication, which reads as a broken login rather than a policy decision.
|
||||
// The scope mapping now answers `abs_groups: ["user"]` for every signer and reserves `admin` for
|
||||
// `audiobookshelf_admins`, so the IdP is the only gate to the library and onboarding a listener
|
||||
// is exactly "create the account".
|
||||
//
|
||||
// This is the counterpart to 23-: that spec proves the admin escalation, this one proves the
|
||||
// DEFAULT grant, from an identity in neither group. Against the old mapping it is red — ABS
|
||||
// refuses the sign-in and the SPA never leaves /login.
|
||||
const HOST = "192.168.0.235:8000";
|
||||
const ABS = `http://${HOST}/audiobookshelf`;
|
||||
|
||||
test("an SSO signer in no Audiobookshelf group lands as a listener", async ({ page }) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
await page.goto(`${ABS}/login`, { waitUntil: "domcontentloaded" });
|
||||
const sso = page.getByText("Sign in with Authentik");
|
||||
await expect(sso).toBeVisible();
|
||||
await sso.click();
|
||||
|
||||
// The standard project's storageState carries a live authentik session, so the round trip is
|
||||
// silent: authorize -> code -> callback -> token. Still being on /login means the app refused
|
||||
// the identity (the denial this change removes) or the exchange failed.
|
||||
await expect
|
||||
.poll(() => !new URL(page.url()).pathname.endsWith("/login"), { timeout: 90_000 })
|
||||
.toBe(true);
|
||||
|
||||
const grabToken = () =>
|
||||
page.evaluate(() => {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const v = localStorage.getItem(localStorage.key(i) as string) || "";
|
||||
if (/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(v)) return v;
|
||||
}
|
||||
const c = document.cookie.match(/(?:^|;\s*)abs_token=([^;]+)/);
|
||||
return c ? decodeURIComponent(c[1]) : "";
|
||||
});
|
||||
await expect.poll(grabToken, { timeout: 60_000 }).not.toBe("");
|
||||
|
||||
const me = await page.request.get(`${ABS}/api/me`, {
|
||||
headers: { Authorization: `Bearer ${await grabToken()}` },
|
||||
});
|
||||
expect(me.status()).toBe(200);
|
||||
const user = await me.json();
|
||||
expect(user.username).toBe("qa-e2e");
|
||||
// The default, not a grant: the listener role for a signer in neither group. Being `user`
|
||||
// rather than `admin` is also the proof that the default never escalates.
|
||||
expect(user.type).toBe("user");
|
||||
});
|
||||
Reference in new issue
Block a user