Merge pull request 'fix(audiobookshelf): serve under /audiobookshelf, send the OIDC redirect subfolder' (#128) from fix/abs-base-path-sso into main
This commit was merged in pull request #128.
This commit is contained in:
commit
7e9bfbc2be
9 files changed
+488
-31
No files matched your search
@@ -33,9 +33,16 @@ Network=pasta
|
|||||||
# README is the npm package's naming and is INERT here: with ABS_PORT=8000 the
|
# README is the npm package's naming and is INERT here: with ABS_PORT=8000 the
|
||||||
# server listened on :80 under base path /audiobookshelf, so the unit sat
|
# server listened on :80 under base path /audiobookshelf, so the unit sat
|
||||||
# `active (running)` while every probe to :8000 was refused (measured 2026-11-02,
|
# `active (running)` while every probe to :8000 was refused (measured 2026-11-02,
|
||||||
# first deploy). ROUTER_BASE_PATH=/ is what makes the URL the plain host:port.
|
# first deploy).
|
||||||
|
# ROUTER_BASE_PATH MUST be /audiobookshelf: the SPA in /app/client/dist/index.html is
|
||||||
|
# built with `routerBasePath:"/audiobookshelf"` baked in, so it asks for its assets under
|
||||||
|
# that prefix no matter what the server is mounted at. With `/` the server answers the
|
||||||
|
# page but every script request falls through to the SPA fallback and comes back as
|
||||||
|
# text/html — Chrome refuses to execute it and the UI never leaves "Loading…"
|
||||||
|
# (measured 2026-11-03; the API and /healthcheck were fine, which is why an API-only
|
||||||
|
# verification missed it).
|
||||||
Environment=PORT=8000
|
Environment=PORT=8000
|
||||||
Environment=ROUTER_BASE_PATH=/
|
Environment=ROUTER_BASE_PATH=/audiobookshelf
|
||||||
Environment=CONFIG_PATH=/config
|
Environment=CONFIG_PATH=/config
|
||||||
Environment=METADATA_PATH=/metadata
|
Environment=METADATA_PATH=/metadata
|
||||||
Environment=TZ=America/Vancouver
|
Environment=TZ=America/Vancouver
|
||||||
@@ -45,6 +52,8 @@ Volume=/home/dank/Containers/audiobookshelf/metadata:/metadata
|
|||||||
Volume=/mnt/entropy/Audiobooks:/audiobooks:ro
|
Volume=/mnt/entropy/Audiobooks:/audiobooks:ro
|
||||||
# The image is Alpine: it ships wget and no curl, so the health check must name
|
# The image is Alpine: it ships wget and no curl, so the health check must name
|
||||||
# the binary that exists or the container is unhealthy while serving perfectly.
|
# the binary that exists or the container is unhealthy while serving perfectly.
|
||||||
|
# /healthcheck answers `OK` at the root AND under the base path (measured on both
|
||||||
|
# base paths), so the probe below is correct whatever ROUTER_BASE_PATH says.
|
||||||
HealthCmd=wget -q -O /dev/null http://127.0.0.1:8000/healthcheck
|
HealthCmd=wget -q -O /dev/null http://127.0.0.1:8000/healthcheck
|
||||||
HealthInterval=30s
|
HealthInterval=30s
|
||||||
HealthTimeout=10s
|
HealthTimeout=10s
|
||||||
|
|||||||
@@ -21,26 +21,64 @@
|
|||||||
# The credential is generated once into a mode-600 host file and never enters
|
# The credential is generated once into a mode-600 host file and never enters
|
||||||
# the repo, the URL, or a process argument list. Same pattern as calibre-server.
|
# the repo, the URL, or a process argument list. Same pattern as calibre-server.
|
||||||
#
|
#
|
||||||
|
# Single sign-on (authentik) is armed by the same run, and only when the credential
|
||||||
|
# file carries ABS_OIDC_CLIENT_SECRET, so a local-only deployment is unchanged by a
|
||||||
|
# re-run. These OIDC facts came from the same pinned image, and each one is a trap:
|
||||||
|
# * isOpenIDAuthSettingsValid demands issuer + authorization + token + userinfo +
|
||||||
|
# jwks + clientID + clientSecret + signingAlgorithm at once. Miss one and the
|
||||||
|
# server SILENTLY strips 'openid' from authActiveAuthMethods and falls back to
|
||||||
|
# ['local'] while answering 200 everywhere — so the only honest check is what
|
||||||
|
# /status says AFTER the write, which is what this script does.
|
||||||
|
# * the algorithm is HS256, not the image's RS256 default: no provider on the
|
||||||
|
# fleet's authentik carries a signing key, so the client secret IS the signature
|
||||||
|
# key (scripts/authentik-audiobookshelf-sso.sh). Left at RS256, every id_token
|
||||||
|
# fails verification and the failure looks like a broken IdP.
|
||||||
|
# * the auth block lives on PATCH /api/auth-settings. PATCH /api/settings has a
|
||||||
|
# 20-key allowlist (PATCHABLE_SETTINGS_KEYS) with NO auth key in it, so writing
|
||||||
|
# the OIDC config there answers 200 and changes nothing — measured on this host
|
||||||
|
# 2026-11-02. The keys /api/auth-settings accepts are exactly its own
|
||||||
|
# authenticationSettings getters; anything else is skipped with a debug line.
|
||||||
|
# * authOpenIDSubfolderForRedirectURLs must be sent explicitly. The 2.17.4 migration only
|
||||||
|
# fills it on a DB that ALREADY had OIDC on, so a database created before SSO leaves it
|
||||||
|
# UNDEFINED and the server builds redirect_uri
|
||||||
|
# "http://host/undefined/auth/openid/callback" — authentik answers the authorize request
|
||||||
|
# with "Redirect URI Error" (measured 2026-11-02). This script derives the value from
|
||||||
|
# ABS_URL's path, so it cannot drift from the unit's ROUTER_BASE_PATH.
|
||||||
|
# * 'local' stays in authActiveAuthMethods on purpose: if authentik is ever down,
|
||||||
|
# the root account must still be able to sign into its own server.
|
||||||
|
#
|
||||||
# usage: audiobookshelf-init.sh # create what is missing, then scan
|
# usage: audiobookshelf-init.sh # create what is missing, then scan
|
||||||
# ABS_SCAN=0 audiobookshelf-init.sh # bootstrap without a scan
|
# ABS_SCAN=0 audiobookshelf-init.sh # bootstrap without a scan
|
||||||
|
# ABS_OIDC=0 audiobookshelf-init.sh # never touch the auth methods
|
||||||
#
|
#
|
||||||
# environment (defaults are the deployed shape):
|
# environment (defaults are the deployed shape):
|
||||||
# ABS_URL server base URL (http://127.0.0.1:8000)
|
# ABS_URL server base URL INCLUDING the mounted base path
|
||||||
|
# (http://127.0.0.1:8000/audiobookshelf)
|
||||||
# ABS_SECRET credential file (/home/dank/.config/secrets/audiobookshelf)
|
# ABS_SECRET credential file (/home/dank/.config/secrets/audiobookshelf)
|
||||||
# ABS_LIBRARY library name to ensure (Audiobooks)
|
# ABS_LIBRARY library name to ensure (Audiobooks)
|
||||||
# ABS_FOLDER folder to bind inside the server (/audiobooks)
|
# ABS_FOLDER folder to bind inside the server (/audiobooks)
|
||||||
# ABS_SCAN 1 triggers a scan (default), 0 skips it
|
# ABS_SCAN 1 triggers a scan (default), 0 skips it
|
||||||
|
# ABS_OIDC auto (default) arms SSO when the credential file has a client
|
||||||
|
# secret, 1 requires one, 0 leaves the auth methods alone
|
||||||
|
# ABS_OIDC_ISSUER authentik issuer URL for this client
|
||||||
|
# ABS_OIDC_CLIENT_ID provider client id (audiobookshelf)
|
||||||
|
# ABS_OIDC_ALG id_token signing algorithm the IdP actually uses (HS256)
|
||||||
|
# ABS_OIDC_SUBFOLDER override for the redirect-URI subfolder (default: ABS_URL's path)
|
||||||
#
|
#
|
||||||
# exit 0 every step is satisfied (created or already present)
|
# exit 0 every step is satisfied (created or already present)
|
||||||
# exit 1 a step was refused or the server misbehaved (named on stderr)
|
# exit 1 a step was refused or the server misbehaved (named on stderr)
|
||||||
# exit 2 usage / unreadable credential file
|
# exit 2 usage / unreadable credential file
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
ABS_URL=${ABS_URL:-http://127.0.0.1:8000}
|
ABS_URL=${ABS_URL:-http://127.0.0.1:8000/audiobookshelf}
|
||||||
ABS_SECRET=${ABS_SECRET:-/home/dank/.config/secrets/audiobookshelf}
|
ABS_SECRET=${ABS_SECRET:-/home/dank/.config/secrets/audiobookshelf}
|
||||||
ABS_LIBRARY=${ABS_LIBRARY:-Audiobooks}
|
ABS_LIBRARY=${ABS_LIBRARY:-Audiobooks}
|
||||||
ABS_FOLDER=${ABS_FOLDER:-/audiobooks}
|
ABS_FOLDER=${ABS_FOLDER:-/audiobooks}
|
||||||
ABS_SCAN=${ABS_SCAN:-1}
|
ABS_SCAN=${ABS_SCAN:-1}
|
||||||
|
ABS_OIDC=${ABS_OIDC:-auto}
|
||||||
|
ABS_OIDC_ISSUER=${ABS_OIDC_ISSUER:-https://auth.franksdank.cloud/application/o/audiobookshelf/}
|
||||||
|
ABS_OIDC_CLIENT_ID=${ABS_OIDC_CLIENT_ID:-audiobookshelf}
|
||||||
|
ABS_OIDC_ALG=${ABS_OIDC_ALG:-HS256}
|
||||||
|
|
||||||
die() { printf 'refused: %s\n' "$*" >&2; exit 1; }
|
die() { printf 'refused: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
@@ -49,6 +87,11 @@ case "$ABS_SCAN" in
|
|||||||
*) printf 'refused: ABS_SCAN wants 0 or 1, got %s\n' "$ABS_SCAN" >&2; exit 2 ;;
|
*) printf 'refused: ABS_SCAN wants 0 or 1, got %s\n' "$ABS_SCAN" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
case "$ABS_OIDC" in
|
||||||
|
auto | 0 | 1) ;;
|
||||||
|
*) printf 'refused: ABS_OIDC wants auto, 0 or 1, got %s\n' "$ABS_OIDC" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# ---- credential -------------------------------------------------------------
|
# ---- credential -------------------------------------------------------------
|
||||||
umask 077
|
umask 077
|
||||||
if [ ! -f "$ABS_SECRET" ]; then
|
if [ ! -f "$ABS_SECRET" ]; then
|
||||||
@@ -72,8 +115,10 @@ chmod 600 "$ABS_SECRET"
|
|||||||
# python3 rather than curl so the password and the bearer token never appear in
|
# python3 rather than curl so the password and the bearer token never appear in
|
||||||
# any argv (they would be world-readable in /proc/<pid>/cmdline otherwise).
|
# any argv (they would be world-readable in /proc/<pid>/cmdline otherwise).
|
||||||
ABS_URL="$ABS_URL" ABS_SECRET="$ABS_SECRET" ABS_LIBRARY="$ABS_LIBRARY" \
|
ABS_URL="$ABS_URL" ABS_SECRET="$ABS_SECRET" ABS_LIBRARY="$ABS_LIBRARY" \
|
||||||
ABS_FOLDER="$ABS_FOLDER" ABS_SCAN="$ABS_SCAN" python3 - <<'PY'
|
ABS_FOLDER="$ABS_FOLDER" ABS_SCAN="$ABS_SCAN" ABS_OIDC="$ABS_OIDC" \
|
||||||
import json, os, sys, urllib.error, urllib.request
|
ABS_OIDC_ISSUER="$ABS_OIDC_ISSUER" ABS_OIDC_CLIENT_ID="$ABS_OIDC_CLIENT_ID" \
|
||||||
|
ABS_OIDC_ALG="$ABS_OIDC_ALG" python3 - <<'PY'
|
||||||
|
import json, os, sys, urllib.error, urllib.parse, urllib.request
|
||||||
|
|
||||||
BASE = os.environ["ABS_URL"].rstrip("/")
|
BASE = os.environ["ABS_URL"].rstrip("/")
|
||||||
LIB = os.environ["ABS_LIBRARY"]
|
LIB = os.environ["ABS_LIBRARY"]
|
||||||
@@ -184,5 +229,81 @@ code, items = call("GET", "/api/libraries/%s/items?limit=1" % lib["id"], token=t
|
|||||||
if code == 200 and isinstance(items, dict):
|
if code == 200 and isinstance(items, dict):
|
||||||
print("items: %s (a fresh scan raises this from 0; watch it, not /healthcheck)"
|
print("items: %s (a fresh scan raises this from 0; watch it, not /healthcheck)"
|
||||||
% items.get("total"))
|
% items.get("total"))
|
||||||
|
|
||||||
|
# ---- single sign-on (authentik) --------------------------------------------------
|
||||||
|
# The provider this points at is provisioned by scripts/authentik-audiobookshelf-sso.sh;
|
||||||
|
# the two share exactly one value, the client secret in the credential file.
|
||||||
|
mode = os.environ["ABS_OIDC"]
|
||||||
|
oidc_secret = cred.get("ABS_OIDC_CLIENT_SECRET") or ""
|
||||||
|
if mode == "0":
|
||||||
|
print("oidc: skipped (ABS_OIDC=0)")
|
||||||
|
elif not oidc_secret:
|
||||||
|
if mode == "1":
|
||||||
|
sys.exit("refused: ABS_OIDC=1 but %s has no ABS_OIDC_CLIENT_SECRET — run "
|
||||||
|
"scripts/authentik-audiobookshelf-sso.sh first" % os.environ["ABS_SECRET"])
|
||||||
|
print("oidc: not configured (no ABS_OIDC_CLIENT_SECRET in the credential file)"
|
||||||
|
" — local auth only")
|
||||||
|
else:
|
||||||
|
client_id = os.environ["ABS_OIDC_CLIENT_ID"]
|
||||||
|
issuer = os.environ["ABS_OIDC_ISSUER"].rstrip("/") + "/"
|
||||||
|
# redirect_uri = <origin><subfolder>/auth/openid/callback, and the subfolder IS the
|
||||||
|
# base path the app is mounted under — taken from ABS_URL so unit and config agree.
|
||||||
|
subfolder = os.environ.get("ABS_OIDC_SUBFOLDER")
|
||||||
|
if subfolder is None:
|
||||||
|
subfolder = urllib.parse.urlparse(os.environ["ABS_URL"]).path.rstrip("/")
|
||||||
|
origin = issuer.split("/application/")[0]
|
||||||
|
body = {
|
||||||
|
"authActiveAuthMethods": ["local", "openid"],
|
||||||
|
"authOpenIDIssuerURL": issuer,
|
||||||
|
"authOpenIDAuthorizationURL": origin + "/application/o/authorize/",
|
||||||
|
"authOpenIDTokenURL": origin + "/application/o/token/",
|
||||||
|
"authOpenIDUserInfoURL": origin + "/application/o/userinfo/",
|
||||||
|
"authOpenIDJwksURL": issuer + "jwks/",
|
||||||
|
"authOpenIDLogoutURL": issuer + "end-session/",
|
||||||
|
"authOpenIDClientID": client_id,
|
||||||
|
"authOpenIDClientSecret": oidc_secret,
|
||||||
|
"authOpenIDTokenSigningAlgorithm": os.environ["ABS_OIDC_ALG"],
|
||||||
|
"authOpenIDSubfolderForRedirectURLs": subfolder,
|
||||||
|
"authOpenIDButtonText": "Sign in with Authentik",
|
||||||
|
# not auto-launch: the login page keeps offering the local form, which is the
|
||||||
|
# break-glass path when the IdP is unreachable.
|
||||||
|
"authOpenIDAutoLaunch": False,
|
||||||
|
# a household user's first SSO sign-in creates their account; the group claim
|
||||||
|
# decides the role, and a user in neither group is denied (fail closed).
|
||||||
|
"authOpenIDAutoRegister": True,
|
||||||
|
"authOpenIDGroupClaim": "abs_groups",
|
||||||
|
# The app's own scheme lives HERE, never in the IdP: ABS swaps it for
|
||||||
|
# <base>/auth/openid/mobile-redirect before the provider sees a redirect_uri.
|
||||||
|
"authOpenIDMobileRedirectURIs": ["audiobookshelf://oauth"],
|
||||||
|
}
|
||||||
|
code, res = call("PATCH", "/api/auth-settings", body, token=token)
|
||||||
|
if code != 200:
|
||||||
|
sys.exit("refused: PATCH /api/auth-settings answered %s (%s)" % (code, res))
|
||||||
|
# Verify twice: what was stored, and what the server will honour. A config that
|
||||||
|
# misses any of the eight required OIDC values is accepted with 200 and then
|
||||||
|
# silently dropped at the next load, leaving authMethods=['local'].
|
||||||
|
code, stored = call("GET", "/api/auth-settings", token=token)
|
||||||
|
if code != 200 or not isinstance(stored, dict):
|
||||||
|
sys.exit("refused: GET /api/auth-settings answered %s (%s)" % (code, stored))
|
||||||
|
for key, want in (("authOpenIDIssuerURL", issuer),
|
||||||
|
("authOpenIDTokenSigningAlgorithm", os.environ["ABS_OIDC_ALG"]),
|
||||||
|
("authOpenIDSubfolderForRedirectURLs", subfolder),
|
||||||
|
("authOpenIDButtonText", "Sign in with Authentik")):
|
||||||
|
if stored.get(key) != want:
|
||||||
|
sys.exit("refused: stored %s=%r, expected %r" % (key, stored.get(key), want))
|
||||||
|
if sorted(stored.get("authActiveAuthMethods") or []) != ["local", "openid"]:
|
||||||
|
sys.exit("refused: stored authActiveAuthMethods=%r, expected local+openid "
|
||||||
|
"('openid' is dropped when the block is incomplete)"
|
||||||
|
% stored.get("authActiveAuthMethods"))
|
||||||
|
code, after = call("GET", "/status")
|
||||||
|
methods = after.get("authMethods") if isinstance(after, dict) else None
|
||||||
|
if not isinstance(methods, list) or "openid" not in methods:
|
||||||
|
sys.exit("refused: /status reports authMethods=%s after a stored config that "
|
||||||
|
"looks complete — restart the service, the strategy is only armed at "
|
||||||
|
"load for a config that was invalid when the server read it" % methods)
|
||||||
|
print("oidc: armed (issuer %s | alg %s | claim abs_groups | redirect subfolder %r | "
|
||||||
|
"client %s | secret %s chars | authMethods %s)"
|
||||||
|
% (issuer, os.environ["ABS_OIDC_ALG"], subfolder, client_id, len(oidc_secret),
|
||||||
|
methods))
|
||||||
PY
|
PY
|
||||||
exit 0
|
exit 0
|
||||||
Executable
+203
@@ -0,0 +1,203 @@
|
|||||||
|
#!/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 redirect URIs on the provider: <base>/auth/openid/callback (browser) and
|
||||||
|
# <base>/auth/openid/mobile-redirect (mobile). 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.
|
||||||
|
# * <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).
|
||||||
|
#
|
||||||
|
# Idempotent: every object is created or asserted. Safe to re-run.
|
||||||
|
#
|
||||||
|
# usage: ABS_OIDC_CLIENT_SECRET=<secret> authentik-audiobookshelf-sso.sh
|
||||||
|
# (the secret is the value stored in
|
||||||
|
# /home/dank/.config/secrets/audiobookshelf on Dankserver — get it with
|
||||||
|
# ssh dankserver awk -F= '/^ABS_OIDC_CLIENT_SECRET=/{print $2}' \
|
||||||
|
# ~/.config/secrets/audiobookshelf)
|
||||||
|
#
|
||||||
|
# environment:
|
||||||
|
# ABS_OIDC_CLIENT_SECRET REQUIRED. The provider's client secret.
|
||||||
|
# ABS_OIDC_CLIENT_ID provider/client id (audiobookshelf)
|
||||||
|
# ABS_OIDC_GROUPS_CLAIM claim ABS reads for roles (abs_groups)
|
||||||
|
# ABS_OIDC_USERNAME authentik user made admin (akadmin)
|
||||||
|
# ABS_OIDC_USERS space-separated authentik usernames given the USER group
|
||||||
|
# (default: nobody — an empty list is fail-closed, and the
|
||||||
|
# household decides who listens to audiobooks)
|
||||||
|
# ABS_BASE ABS URL as the browser sees it, base path included
|
||||||
|
# (http://192.168.0.235:8000/audiobookshelf — LAN-only by policy:
|
||||||
|
# no vhost, no reverse proxy in front)
|
||||||
|
# AUTHENTIK_HOST ssh target (dankclaw)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SECRET="${ABS_OIDC_CLIENT_SECRET:?set ABS_OIDC_CLIENT_SECRET (see the header)}"
|
||||||
|
[ "${#SECRET}" -ge 32 ] || { echo "refused: ABS_OIDC_CLIENT_SECRET is too short to be a secret" >&2; exit 2; }
|
||||||
|
|
||||||
|
CLIENT_ID="${ABS_OIDC_CLIENT_ID:-audiobookshelf}"
|
||||||
|
CLAIM="${ABS_OIDC_GROUPS_CLAIM:-abs_groups}"
|
||||||
|
ADMIN_GROUP="audiobookshelf_admins"
|
||||||
|
USER_GROUP="audiobookshelf_users"
|
||||||
|
SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
|
||||||
|
MEMBERS="${ABS_OIDC_USERS:-}"
|
||||||
|
BASE="${ABS_BASE:-http://192.168.0.235:8000/audiobookshelf}"
|
||||||
|
HOST="${AUTHENTIK_HOST:-dankclaw}"
|
||||||
|
|
||||||
|
# The claim expression: translate group membership into ABS role names. It is stored
|
||||||
|
# verbatim so a later read of the provider shows exactly this policy.
|
||||||
|
read -r -d '' EXPR <<PYEXPR || true
|
||||||
|
groups = {g.name for g in user.groups.all()}
|
||||||
|
roles = []
|
||||||
|
if "$ADMIN_GROUP" in groups:
|
||||||
|
roles.append("admin")
|
||||||
|
if groups & {"$ADMIN_GROUP", "$USER_GROUP"}:
|
||||||
|
roles.append("user")
|
||||||
|
return {"$CLAIM": roles}
|
||||||
|
PYEXPR
|
||||||
|
|
||||||
|
ssh "$HOST" 'podman exec -i authentik-server ak shell' <<PYEOF
|
||||||
|
from authentik.core.models import Application, Group, User
|
||||||
|
from authentik.flows.models import Flow
|
||||||
|
from authentik.providers.oauth2.models import (
|
||||||
|
ClientType, OAuth2Provider, RedirectURI, RedirectURIMatchingMode, RedirectURIType, ScopeMapping)
|
||||||
|
|
||||||
|
ADMIN_GROUP, USER_GROUP = "$ADMIN_GROUP", "$USER_GROUP"
|
||||||
|
CLAIM, CLIENT_ID, SECRET, BASE = "$CLAIM", "$CLIENT_ID", "$SECRET", "$BASE"
|
||||||
|
SSO_USER = "$SSO_USER"
|
||||||
|
MEMBERS = """$MEMBERS""".split()
|
||||||
|
EXPR = '''$EXPR'''
|
||||||
|
# The expression reads user.groups, NOT user.ak_groups: authentik 2026.8 logs
|
||||||
|
# "User.ak_groups is deprecated … use User.groups" on every evaluation of the old
|
||||||
|
# name (measured on this run), and a stored mapping that leans on a deprecated
|
||||||
|
# accessor is a future upgrade failure sitting inside the login path.
|
||||||
|
|
||||||
|
for name in (ADMIN_GROUP, USER_GROUP):
|
||||||
|
Group.objects.get_or_create(name=name)
|
||||||
|
admin_g, user_g = Group.objects.get(name=ADMIN_GROUP), Group.objects.get(name=USER_GROUP)
|
||||||
|
|
||||||
|
# --- the claim mapping (scope_name IS the claim name ABS requests) ---------------
|
||||||
|
mapping = ScopeMapping.objects.filter(name="audiobookshelf-groups").first()
|
||||||
|
if mapping is None:
|
||||||
|
mapping = ScopeMapping.objects.create(name="audiobookshelf-groups", scope_name=CLAIM,
|
||||||
|
expression=EXPR,
|
||||||
|
description="Audiobookshelf role names from group membership")
|
||||||
|
print("mapping: created (scope_name %s)" % CLAIM)
|
||||||
|
elif mapping.scope_name != CLAIM or mapping.expression != EXPR:
|
||||||
|
mapping.scope_name, mapping.expression = CLAIM, EXPR
|
||||||
|
mapping.save(update_fields=["scope_name", "expression"])
|
||||||
|
print("mapping: scope/expression updated (scope_name %s)" % CLAIM)
|
||||||
|
else:
|
||||||
|
print("mapping: already current")
|
||||||
|
|
||||||
|
# Prove the expression runs and yields an ABS role BEFORE anyone tries to log in:
|
||||||
|
# authentik execs the expression inside a function whose context has `user`.
|
||||||
|
_src = "def _probe(user, request=None):\n" + "".join(" %s\n" % ln for ln in EXPR.splitlines())
|
||||||
|
_ns = {}
|
||||||
|
exec(_src, _ns)
|
||||||
|
|
||||||
|
# --- provider --------------------------------------------------------------------
|
||||||
|
uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
|
||||||
|
url="%s/auth/openid/callback" % BASE),
|
||||||
|
RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
|
||||||
|
url="%s/auth/openid/mobile-redirect" % BASE)]
|
||||||
|
authz_flow = Flow.objects.get(slug="default-provider-authorization-implicit-consent")
|
||||||
|
|
||||||
|
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)
|
||||||
|
p.redirect_uris = uris
|
||||||
|
p.save()
|
||||||
|
p.property_mappings.set([mapping])
|
||||||
|
print("provider: created")
|
||||||
|
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 [(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()) != [mapping.pk]:
|
||||||
|
p.property_mappings.set([mapping])
|
||||||
|
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)
|
||||||
|
print("admin: %s in %s" % (SSO_USER, ADMIN_GROUP))
|
||||||
|
for name in MEMBERS:
|
||||||
|
u = User.objects.get(username=name)
|
||||||
|
u.groups.add(user_g)
|
||||||
|
print("users: %s in %s" % (", ".join(MEMBERS) or "(none — fail closed)", USER_GROUP))
|
||||||
|
|
||||||
|
# --- verification: the claim the callback will actually receive ---------------------
|
||||||
|
for probe in [root] + [User.objects.get(username=n) for n in MEMBERS]:
|
||||||
|
print("claim for %s -> %s" % (probe.username, _ns["_probe"](user=probe, request=None)))
|
||||||
|
p.refresh_from_db()
|
||||||
|
print("provider %s | client_id %s | flow %s | uris %s" % (
|
||||||
|
p.name, p.client_id, p.authorization_flow.slug, [u.url for u in p.redirect_uris]))
|
||||||
|
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
|
||||||
@@ -1315,7 +1315,8 @@ deploy_audiobookshelf() {
|
|||||||
mkdir -p /home/dank/Containers/audiobookshelf/config \
|
mkdir -p /home/dank/Containers/audiobookshelf/config \
|
||||||
/home/dank/Containers/audiobookshelf/metadata
|
/home/dank/Containers/audiobookshelf/metadata
|
||||||
systemctl --user restart audiobookshelf.service
|
systemctl --user restart audiobookshelf.service
|
||||||
# /healthcheck answers once the HTTP server is bound; the library scan that
|
# /healthcheck answers once the HTTP server is bound, at the root whatever
|
||||||
|
# ROUTER_BASE_PATH says (measured on both base paths); the library scan that
|
||||||
# follows runs in the background and must not gate the deploy.
|
# follows runs in the background and must not gate the deploy.
|
||||||
wait_for_health audiobookshelf http://127.0.0.1:8000/healthcheck 90
|
wait_for_health audiobookshelf http://127.0.0.1:8000/healthcheck 90
|
||||||
# A server with no root user and no library is "healthy" and useless, so the
|
# A server with no root user and no library is "healthy" and useless, so the
|
||||||
@@ -1323,7 +1324,12 @@ deploy_audiobookshelf() {
|
|||||||
# it creates the account (credential in the mode-600 host secret file), the
|
# it creates the account (credential in the mode-600 host secret file), the
|
||||||
# Audiobooks library over the /audiobooks bind, and fires the scan. Every
|
# Audiobooks library over the /audiobooks bind, and fires the scan. Every
|
||||||
# step is idempotent, so a re-deploy costs one incremental scan.
|
# step is idempotent, so a re-deploy costs one incremental scan.
|
||||||
scripts/audiobookshelf-init.sh
|
# The bootstrapper has to speak the same base path the unit mounts (the API, the
|
||||||
|
# login route and the OIDC redirect subfolder all hang off it), so the URL is read
|
||||||
|
# out of the unit file here instead of being restated in the script's default.
|
||||||
|
abs_base=$(awk -F= '/^Environment=ROUTER_BASE_PATH=/{print $3}' \
|
||||||
|
dankserver/audiobookshelf/quadlet/audiobookshelf.container)
|
||||||
|
ABS_URL="http://127.0.0.1:8000${abs_base}" scripts/audiobookshelf-init.sh
|
||||||
}
|
}
|
||||||
|
|
||||||
# The content server's single admin account and the desktop's basic-auth
|
# The content server's single admin account and the desktop's basic-auth
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
type: reference
|
type: reference
|
||||||
description: "Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, laws."
|
description: "Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, SSD state, API bootstrap, authentik SSO, laws."
|
||||||
tags:
|
tags:
|
||||||
- dankserver
|
- dankserver
|
||||||
- audiobooks
|
- audiobooks
|
||||||
@@ -17,7 +17,7 @@ server — it shares nothing with [[Plex]] or [[Jellyfin]] and owns no files on
|
|||||||
array.
|
array.
|
||||||
|
|
||||||
**Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user
|
**Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user
|
||||||
manager) — **Internal-only:** LAN address, no vhost, no DNS name.
|
manager) — **Internal-only:** LAN address `http://192.168.0.235:8000/audiobookshelf`, no vhost, no DNS name.
|
||||||
|
|
||||||
## Shape
|
## Shape
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ manager) — **Internal-only:** LAN address, no vhost, no DNS name.
|
|||||||
| Unit file | `dankserver/audiobookshelf/quadlet/audiobookshelf.container` (the authority for every number here) |
|
| Unit file | `dankserver/audiobookshelf/quadlet/audiobookshelf.container` (the authority for every number here) |
|
||||||
| Image | `ghcr.io/advplyr/audiobookshelf` pinned by **amd64 manifest digest** — see Laws |
|
| Image | `ghcr.io/advplyr/audiobookshelf` pinned by **amd64 manifest digest** — see Laws |
|
||||||
| Port | `Environment=PORT=8000` + `PublishPort=8000:8000` — web UI and API on one port |
|
| Port | `Environment=PORT=8000` + `PublishPort=8000:8000` — web UI and API on one port |
|
||||||
| Base path | `Environment=ROUTER_BASE_PATH=/` — the URL is the plain `host:port`, no `/audiobookshelf` prefix |
|
| Base path | `Environment=ROUTER_BASE_PATH=/audiobookshelf` — every URL below carries this prefix, and the shipped SPA cannot boot without it (Laws) |
|
||||||
| Network | `pasta` (standalone; publishes nothing to other containers) |
|
| Network | `pasta` (standalone; publishes nothing to other containers) |
|
||||||
| Audio bind | `/mnt/entropy/Audiobooks:/audiobooks:ro` — read-only, by design |
|
| Audio bind | `/mnt/entropy/Audiobooks:/audiobooks:ro` — read-only, by design |
|
||||||
| State | `/home/dank/Containers/audiobookshelf/config` → `/config`, `…/metadata` → `/metadata` (both NVMe) |
|
| State | `/home/dank/Containers/audiobookshelf/config` → `/config`, `…/metadata` → `/metadata` (both NVMe) |
|
||||||
@@ -36,6 +36,7 @@ manager) — **Internal-only:** LAN address, no vhost, no DNS name.
|
|||||||
| Self-healing | `Restart=always`, `RestartSec=5`, `WantedBy=default.target` |
|
| Self-healing | `Restart=always`, `RestartSec=5`, `WantedBy=default.target` |
|
||||||
| Deploy | `./scripts/deploy.sh dankserver --service audiobookshelf` |
|
| Deploy | `./scripts/deploy.sh dankserver --service audiobookshelf` |
|
||||||
| Handler | `deploy_audiobookshelf` in `scripts/deploy-remote.sh` |
|
| Handler | `deploy_audiobookshelf` in `scripts/deploy-remote.sh` |
|
||||||
|
| Single sign-on | authentik provider `audiobookshelf`; the local `frank` account stays enabled as break-glass — see *Single sign-on* |
|
||||||
| Library layout | one book per folder — ABS groups by the first directory holding a media file (see Laws); every move is journalled at `/mnt/entropy/.media-campaign/media-relayout-audiobooks.jsonl` |
|
| Library layout | one book per folder — ABS groups by the first directory holding a media file (see Laws); every move is journalled at `/mnt/entropy/.media-campaign/media-relayout-audiobooks.jsonl` |
|
||||||
|
|
||||||
## Bootstrap
|
## Bootstrap
|
||||||
@@ -54,7 +55,7 @@ difference. The script is idempotent; re-running it costs one incremental scan.
|
|||||||
| scan | `POST /api/libraries/<id>/scan` | `scan: accepted` |
|
| scan | `POST /api/libraries/<id>/scan` | `scan: accepted` |
|
||||||
| proof | `GET /api/libraries/<id>/items?limit=1` | `items: <total>` |
|
| proof | `GET /api/libraries/<id>/items?limit=1` | `items: <total>` |
|
||||||
|
|
||||||
Sign in at `http://192.168.0.235:8000` as `frank`; the password is
|
Sign in at `http://192.168.0.235:8000/audiobookshelf` as `frank`; the password is
|
||||||
`awk -F= '/^ABS_ADMIN_PASSWORD=/{print $2}' ~/.config/secrets/audiobookshelf` on
|
`awk -F= '/^ABS_ADMIN_PASSWORD=/{print $2}' ~/.config/secrets/audiobookshelf` on
|
||||||
Dankserver. Change it in **Settings → Users → frank → Password**; the secret file
|
Dankserver. Change it in **Settings → Users → frank → Password**; the secret file
|
||||||
then holds a stale value and only matters again if the `config` bind is ever
|
then holds a stale value and only matters again if the `config` bind is ever
|
||||||
@@ -68,12 +69,14 @@ the file, not the bind: the same line count grows while the total climbs.
|
|||||||
## Phone app
|
## Phone app
|
||||||
|
|
||||||
The official **Audiobookshelf** app connects with Server Address
|
The official **Audiobookshelf** app connects with Server Address
|
||||||
`http://192.168.0.235:8000`, username `frank`, and the same password as the web UI —
|
`http://192.168.0.235:8000/audiobookshelf` — the base path belongs in the address — username
|
||||||
no base-path prefix, plain HTTP. Verified from a second host 2026-11-03: `/healthcheck`
|
`frank`, and the same password as the web UI, or the SSO button; plain HTTP. Measured from a
|
||||||
and `/ping` answer 200 across the LAN and `iptables -S INPUT` carries no rule for 8000,
|
second host 2026-11-02 and re-measured on both base paths: `/ping` (`{"success":true}`) and
|
||||||
so a phone on `192.168.0.0/24` connects with no further setup; `download: true` on the
|
`/healthcheck` (`OK`) answer at the ROOT whatever `ROUTER_BASE_PATH` says, so the app's
|
||||||
root account is what makes offline listening work. Away from the LAN it cannot connect —
|
connectivity probe works before it resolves the base path at all, and `iptables -S INPUT`
|
||||||
see the LAN-only law, and note that the guard chain is not the remedy there.
|
carries no rule for 8000 — a phone on `192.168.0.0/24` connects with no further setup, and
|
||||||
|
`download: true` on the root account is what makes offline listening work. Away from the LAN
|
||||||
|
— see the LAN-only law, and note that the guard chain is not the remedy there.
|
||||||
|
|
||||||
**The login token is at `user.token`, not top level.** `POST /login` answers
|
**The login token is at `user.token`, not top level.** `POST /login` answers
|
||||||
`{ user, serverSettings, ereaderDevices, Source, userDefaultLibraryId }` and the bearer sits
|
`{ user, serverSettings, ereaderDevices, Source, userDefaultLibraryId }` and the bearer sits
|
||||||
@@ -81,6 +84,25 @@ inside the user object. A client that reads `response.token` gets the empty stri
|
|||||||
`Authorization: Bearer ` with nothing after it, and every `/api/*` call then answers `401`
|
`Authorization: Bearer ` with nothing after it, and every `/api/*` call then answers `401`
|
||||||
as though the password were wrong.
|
as though the password were wrong.
|
||||||
|
|
||||||
|
## Single sign-on (authentik)
|
||||||
|
|
||||||
|
Both doors take the household identity: the web UI puts **Sign in with Authentik** beside
|
||||||
|
the local form, and the phone app offers SSO on its server-address screen. The local
|
||||||
|
`frank` account stays enabled on purpose — `authActiveAuthMethods` is `local` **and**
|
||||||
|
`openid`, and `local` is how you get in when the IdP is down.
|
||||||
|
|
||||||
|
| Piece | Owner |
|
||||||
|
|-------|-------|
|
||||||
|
| Provider `audiobookshelf`, the two groups, the scope mapping | `scripts/authentik-audiobookshelf-sso.sh` — re-run it after an authentik rebuild, because this state lives in the IdP's database and a blueprint re-import reverts it silently |
|
||||||
|
| Client secret | `ABS_OIDC_CLIENT_SECRET` in `~/.config/secrets/audiobookshelf` on Dankserver (mode 600; never in the repo, a URL or an argv) |
|
||||||
|
| The ABS half: issuer, the four IdP URLs, claim, auto-register | `scripts/audiobookshelf-init.sh`, armed automatically when that secret exists in the credential file |
|
||||||
|
| Role at first SSO sign-in | the `abs_groups` claim: `audiobookshelf_admins` → `admin`, `audiobookshelf_users` → `user`, in neither → denied (fail closed) |
|
||||||
|
|
||||||
|
The IdP saying yes is not the same as being allowed in: with `authOpenIDAutoRegister` on,
|
||||||
|
a first sign-in creates the account, and the claim alone decides the type. A user in
|
||||||
|
neither group is refused after a successful authentication — that is the mapping working,
|
||||||
|
not a broken login.
|
||||||
|
|
||||||
## Laws
|
## Laws
|
||||||
|
|
||||||
- **Speak the image's env names, not the README's.** The server reads `PORT`,
|
- **Speak the image's env names, not the README's.** The server reads `PORT`,
|
||||||
@@ -91,6 +113,21 @@ as though the password were wrong.
|
|||||||
`active (running)` while every probe of `:8000` was refused and the deploy gate
|
`active (running)` while every probe of `:8000` was refused and the deploy gate
|
||||||
failed at 180 s. Two seconds with the pinned digest settles it:
|
failed at 180 s. Two seconds with the pinned digest settles it:
|
||||||
`podman inspect --format '{{.Config.Env}}' <image>`.
|
`podman inspect --format '{{.Config.Env}}' <image>`.
|
||||||
|
- **The URL carries `/audiobookshelf`, and that is not a style choice.** The SPA in
|
||||||
|
`/app/client/dist/index.html` is built with `routerBasePath:"/audiobookshelf"`, so it asks
|
||||||
|
for its assets under that prefix whatever the server is mounted at. Mounted at `/`, every
|
||||||
|
script request falls through to the SPA fallback, comes back `text/html`, Chrome refuses to
|
||||||
|
execute it, and the page never leaves "Loading…" — while `/healthcheck`, the API and the
|
||||||
|
phone app stay perfectly healthy, which is how this hid behind an API-only verification
|
||||||
|
(measured 2026-11-02). Prove a UI claim in a browser, never from `/health`.
|
||||||
|
- **The OIDC redirect subfolder is a value you send, not a default you inherit.** The 2.17.4
|
||||||
|
upgrade only fills `authOpenIDSubfolderForRedirectURLs` on a database that ALREADY had OIDC
|
||||||
|
enabled, so a DB created before SSO leaves it unset and the server builds
|
||||||
|
`http://host/undefined/auth/openid/callback`. authentik answers the authorize request with
|
||||||
|
"Redirect URI Error" and nothing on the ABS side looks wrong (measured 2026-11-02). The
|
||||||
|
provider's registered URI must equal `<origin><subfolder>/auth/openid/callback`, so each
|
||||||
|
script derives the value from one variable of its own (`ABS_URL`, `ABS_BASE`) and
|
||||||
|
`tests/audiobookshelf_init.bats` pins what reaches the wire.
|
||||||
- **The audio bind stays `:ro`.** ABS writes nothing that belongs beside the
|
- **The audio bind stays `:ro`.** ABS writes nothing that belongs beside the
|
||||||
audio: its library, covers, progress and sidecar metadata all land in
|
audio: its library, covers, progress and sidecar metadata all land in
|
||||||
`/metadata` on the SSD. A writable bind would put stray writes on the spindle
|
`/metadata` on the SSD. A writable bind would put stray writes on the spindle
|
||||||
@@ -207,12 +244,12 @@ as though the password were wrong.
|
|||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
`./tests/run.sh` covers this unit through `tests/dankserver_quadlet.bats`
|
`./tests/run.sh` covers this unit through `tests/dankserver_quadlet.bats`
|
||||||
(boot persistence, the SSD state roots, the `:ro` array bind, the mount gate, the
|
(boot persistence, the SSD state roots, the `:ro` array bind, the mount gate, the `wget`
|
||||||
`wget` health check), `tests/audiobookshelf_init.bats` (the bootstrapper against a
|
health check, the base path the shipped SPA needs), `tests/audiobookshelf_init.bats` (the
|
||||||
stub of the API above: create-once, idempotent re-run, no secret in a URL, refusal
|
bootstrapper against a stub of the API above: create-once, idempotent re-run, no secret in a
|
||||||
by URL when the server does not answer, and the deployer calling it only after the
|
URL, refusal by URL when the server does not answer, the deployer calling it only after the
|
||||||
health gate) and `tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may
|
health gate, and the SSO arm sending the mounted base path as the redirect subfolder) and
|
||||||
reference the metadata view).
|
`tests/media_overlay.bats` (only [[Plex]] and [[Jellyfin]] may reference the metadata view).
|
||||||
|
|
||||||
Five book folders hold media no scanner can read, so they stay out of the library:
|
Five book folders hold media no scanner can read, so they stay out of the library:
|
||||||
three 2024 Nebula nominees in the `_unsorted` box (two `.m4b` with no `moov` atom,
|
three 2024 Nebula nominees in the `_unsorted` box (two `.m4b` with no `moov` atom,
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ 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
|
**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
|
list.** `GET /Items?IncludeItemTypes=Movie&Recursive=true` answered 2,101 items on
|
||||||
2026-11-03 while `GET /Items?ParentId=<Movies ItemId>` listed 2,548 children — every
|
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
|
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
|
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
|
`SearchTerm` and sit in the folder listing. The two reads are not interchangeable, so
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ is a locator: one row, one clause, no numbers.
|
|||||||
|
|
||||||
| Page | Owns |
|
| Page | Owns |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, state on the SSD, the API bootstrap, laws. |
|
| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, the read-only array bind, SSD state, API bootstrap, authentik SSO, laws. |
|
||||||
| [[Calibre]] | Calibre is the e-book library manager that backs Calibre-Web. |
|
| [[Calibre]] | Calibre is the e-book library manager that backs Calibre-Web. |
|
||||||
| [[Calibre-Web]] | Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database. |
|
| [[Calibre-Web]] | Calibre-Web is a web application that browses, reads, and downloads eBooks from an existing Calibre library database. |
|
||||||
| [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. |
|
| [[Copyparty]] | Copyparty is a lightweight file server and media streamer that runs as a single Python binary. |
|
||||||
|
|||||||
@@ -17,6 +17,10 @@
|
|||||||
# reported as success (a green deploy over a dead API is a lie);
|
# reported as success (a green deploy over a dead API is a lie);
|
||||||
# 5. ABS_SCAN outside 0/1 is a usage error raised before anything is touched;
|
# 5. ABS_SCAN outside 0/1 is a usage error raised before anything is touched;
|
||||||
# 6. the deployer runs the bootstrapper only after the health gate answered.
|
# 6. the deployer runs the bootstrapper only after the health gate answered.
|
||||||
|
# 7. the SSO arm sends the base path: ABS builds its redirect_uri as
|
||||||
|
# <origin><authOpenIDSubfolderForRedirectURLs>/auth/openid/callback, and the
|
||||||
|
# value must be the path the app is mounted under — never the literal
|
||||||
|
# "undefined" a fresh database leaves there.
|
||||||
|
|
||||||
load helpers
|
load helpers
|
||||||
|
|
||||||
@@ -29,7 +33,7 @@ import json, sys
|
|||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
port, logf = int(sys.argv[1]), sys.argv[2]
|
port, logf = int(sys.argv[1]), sys.argv[2]
|
||||||
state = {"init": False, "user": None, "pw": None, "libs": []}
|
state = {"init": False, "user": None, "pw": None, "libs": [], "oidc": None}
|
||||||
|
|
||||||
|
|
||||||
def log(line):
|
def log(line):
|
||||||
@@ -37,6 +41,12 @@ def log(line):
|
|||||||
f.write(line + "\n")
|
f.write(line + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
def norm(p):
|
||||||
|
# ABS mounts every route under ROUTER_BASE_PATH. The stub accepts the API at the
|
||||||
|
# root or under that prefix, so a test can still assert which one the client used.
|
||||||
|
return p[len("/audiobookshelf"):] if p.startswith("/audiobookshelf") else p
|
||||||
|
|
||||||
|
|
||||||
class H(BaseHTTPRequestHandler):
|
class H(BaseHTTPRequestHandler):
|
||||||
def log_message(self, *a):
|
def log_message(self, *a):
|
||||||
pass
|
pass
|
||||||
@@ -57,20 +67,24 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.headers.get("Authorization") == "Bearer stub-token"
|
return self.headers.get("Authorization") == "Bearer stub-token"
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
path = self.path.split("?")[0]
|
path = norm(self.path.split("?")[0])
|
||||||
log("GET %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
|
log("GET %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
|
||||||
if path == "/status":
|
if path == "/status":
|
||||||
return self.send(200, {"app": "audiobookshelf", "isInit": state["init"]}, True)
|
methods = ["local", "openid"] if state["oidc"] else ["local"]
|
||||||
|
return self.send(200, {"app": "audiobookshelf", "isInit": state["init"],
|
||||||
|
"authMethods": methods}, True)
|
||||||
if not self.authed():
|
if not self.authed():
|
||||||
return self.send(401, "Unauthorized")
|
return self.send(401, "Unauthorized")
|
||||||
if path == "/api/libraries":
|
if path == "/api/libraries":
|
||||||
return self.send(200, {"libraries": state["libs"]}, True)
|
return self.send(200, {"libraries": state["libs"]}, True)
|
||||||
|
if path == "/api/auth-settings":
|
||||||
|
return self.send(200, state["oidc"] or {}, True)
|
||||||
if path.endswith("/items"):
|
if path.endswith("/items"):
|
||||||
return self.send(200, {"total": 42, "results": []}, True)
|
return self.send(200, {"total": 42, "results": []}, True)
|
||||||
return self.send(404, "nope")
|
return self.send(404, "nope")
|
||||||
|
|
||||||
def do_POST(self):
|
def do_POST(self):
|
||||||
path = self.path.split("?")[0]
|
path = norm(self.path.split("?")[0])
|
||||||
payload = self.body()
|
payload = self.body()
|
||||||
log("POST %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
|
log("POST %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
|
||||||
if path == "/init":
|
if path == "/init":
|
||||||
@@ -101,6 +115,21 @@ class H(BaseHTTPRequestHandler):
|
|||||||
return self.send(200, "OK")
|
return self.send(200, "OK")
|
||||||
return self.send(404, "nope")
|
return self.send(404, "nope")
|
||||||
|
|
||||||
|
def do_PATCH(self):
|
||||||
|
path = norm(self.path.split("?")[0])
|
||||||
|
payload = self.body()
|
||||||
|
log("PATCH %s auth=%s" % (self.path, "yes" if self.headers.get("Authorization") else "no"))
|
||||||
|
if not self.authed():
|
||||||
|
return self.send(401, "Unauthorized")
|
||||||
|
if path == "/api/auth-settings":
|
||||||
|
# Stored and echoed verbatim: the test asserts the wire, not the stub's
|
||||||
|
# opinion about which keys are patchable.
|
||||||
|
state["oidc"] = payload
|
||||||
|
for k in sorted(payload):
|
||||||
|
log("OIDCFG %s=%r" % (k, payload[k]))
|
||||||
|
return self.send(200, payload, True)
|
||||||
|
return self.send(404, "nope")
|
||||||
|
|
||||||
|
|
||||||
ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()
|
ThreadingHTTPServer(("127.0.0.1", port), H).serve_forever()
|
||||||
PY
|
PY
|
||||||
@@ -198,3 +227,50 @@ boot() {
|
|||||||
/audiobookshelf-init\.sh/ { init = NR }
|
/audiobookshelf-init\.sh/ { init = NR }
|
||||||
END { exit !(health && init && init > health) }'
|
END { exit !(health && init && init > health) }'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "the SSO arm sends the base path as the redirect subfolder" {
|
||||||
|
# What authentik has registered must equal what ABS builds:
|
||||||
|
# <origin><authOpenIDSubfolderForRedirectURLs>/auth/openid/callback. A database
|
||||||
|
# created before SSO leaves that key unset and ABS then sends the literal
|
||||||
|
# "/undefined/auth/openid/callback" — measured 2026-11-02, where the live login died
|
||||||
|
# on authentik's "Redirect URI Error" page. So the value has to reach the wire, and
|
||||||
|
# it has to be the path the app is served under.
|
||||||
|
umask 077
|
||||||
|
cat > "$TMP/cred" <<'CRED'
|
||||||
|
ABS_ADMIN_USER=frank
|
||||||
|
ABS_ADMIN_PASSWORD=0123456789abcdef0123456789
|
||||||
|
ABS_OIDC_CLIENT_SECRET=0123456789abcdef0123456789abcdef0123456789abcdef0123456789
|
||||||
|
CRED
|
||||||
|
run env ABS_URL="http://127.0.0.1:$PORT/audiobookshelf" ABS_SECRET="$TMP/cred" \
|
||||||
|
ABS_SCAN=0 bash "$INIT"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
printf '%s\n' "${lines[@]}" | grep -q "^oidc: armed"
|
||||||
|
grep -q "^OIDCFG authOpenIDSubfolderForRedirectURLs='/audiobookshelf'$" "$TMP/requests.log"
|
||||||
|
# every route the bootstrapper calls rides under the same prefix it armed with
|
||||||
|
grep -q "^POST /audiobookshelf/init auth=no$" "$TMP/requests.log"
|
||||||
|
grep -q "^GET /audiobookshelf/api/auth-settings auth=yes$" "$TMP/requests.log"
|
||||||
|
|
||||||
|
# A root-mounted deployment sends the EMPTY subfolder (the value the 2.17.4 upgrade
|
||||||
|
# uses), not whatever the last URL happened to carry.
|
||||||
|
run env ABS_URL="http://127.0.0.1:$PORT" ABS_SECRET="$TMP/cred" ABS_SCAN=0 bash "$INIT"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(grep "^OIDCFG authOpenIDSubfolderForRedirectURLs=" "$TMP/requests.log" | tail -1)" \
|
||||||
|
= "OIDCFG authOpenIDSubfolderForRedirectURLs=''" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the app and the IdP provider script build the same redirect path" {
|
||||||
|
# The provider row is written by scripts/authentik-audiobookshelf-sso.sh from
|
||||||
|
# ABS_BASE; the app builds its redirect_uri from the base path the deployer hands
|
||||||
|
# audiobookshelf-init.sh, which comes from the unit. Drift between the two is a
|
||||||
|
# redirect_uri mismatch — the login dies at the IdP before any credential is issued.
|
||||||
|
unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container"
|
||||||
|
mounted=$(awk -F= '/^Environment=ROUTER_BASE_PATH=/{print $3}' "$unit")
|
||||||
|
[ -n "$mounted" ]
|
||||||
|
provider=$(grep -m1 '^BASE=' "$REPO_ROOT/scripts/authentik-audiobookshelf-sso.sh" \
|
||||||
|
| grep -oE 'https?://[^"}]+')
|
||||||
|
bootstrapper=$(grep -m1 '^ABS_URL=' "$INIT" | grep -oE 'https?://[^"}]+')
|
||||||
|
# Origins legitimately differ (LAN address vs loopback); the PATH must not.
|
||||||
|
[ "${provider#http://192.168.0.235:8000}/auth/openid/callback" \
|
||||||
|
= "${bootstrapper#http://127.0.0.1:8000}/auth/openid/callback" ]
|
||||||
|
[ "${bootstrapper#http://127.0.0.1:8000}" = "$mounted" ]
|
||||||
|
}
|
||||||
@@ -182,8 +182,13 @@ load helpers
|
|||||||
# under base path /audiobookshelf and the deploy gate timed out beside a unit
|
# under base path /audiobookshelf and the deploy gate timed out beside a unit
|
||||||
# that was active (running). PORT and ROUTER_BASE_PATH are what the image's own
|
# that was active (running). PORT and ROUTER_BASE_PATH are what the image's own
|
||||||
# ENV block and the server source actually use.
|
# ENV block and the server source actually use.
|
||||||
|
# The base path is not a free choice either: /app/client/dist/index.html inside the
|
||||||
|
# pinned image carries routerBasePath:"/audiobookshelf", so a server mounted at / hands
|
||||||
|
# out a page whose every script request falls through to the SPA fallback and comes back
|
||||||
|
# text/html — the UI never leaves "Loading…" while the API stays healthy (measured
|
||||||
|
# 2026-11-02, which is exactly how it hid behind an API-only verification).
|
||||||
unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container"
|
unit="$REPO_ROOT/dankserver/audiobookshelf/quadlet/audiobookshelf.container"
|
||||||
grep -q '^Environment=PORT=8000$' "$unit" || return 1
|
grep -q '^Environment=PORT=8000$' "$unit" || return 1
|
||||||
grep -q '^Environment=ROUTER_BASE_PATH=/$' "$unit" || return 1
|
grep -q '^Environment=ROUTER_BASE_PATH=/audiobookshelf$' "$unit" || return 1
|
||||||
! grep -qE '^Environment=ABS_' "$unit" || return 1
|
! grep -qE '^Environment=ABS_' "$unit" || return 1
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user