Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51abc6550d | ||
|
|
64f489023e | ||
|
|
3511e04ae9 | ||
|
|
21b7ad15d9 | ||
|
|
4ad55c28a0 | ||
|
|
c9a8469d04 | ||
|
|
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:
|
||||
|
||||
+41
-8
@@ -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,7 +267,37 @@ files.franksdank.cloud {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Jellyfin ---
|
||||
# --- 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 (exempt by construction: own auth) --------------------------
|
||||
# No Anubis, no outpost: Jellyfin carries its own complete login (its own
|
||||
# account store, no Authentik link), which is the policy's exempt category —
|
||||
# the same family as plex/ABS/*arr around it. Accepted exposure: the sign-in
|
||||
# form is internet-visible with no rate limiter on either side (this Caddy
|
||||
# build ships no rate_limit module), so accounts need long unique passwords.
|
||||
# The homepage card opened it to the household on 2026-11-03; accounts are
|
||||
# created in Jellyfin's own admin UI, not by SSO.
|
||||
jelly.franksdank.cloud {
|
||||
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
|
||||
|
||||
@@ -47,6 +47,12 @@ Volume=/home/dank/Containers/jellyfin/cache:/cache
|
||||
# is the guard: started against a plain, unmounted view directory a scan decides
|
||||
# the library is empty and deletes what it cannot see.
|
||||
Volume=/var/lib/media-meta/view:/data
|
||||
# Big-screen theme for the TV. The repo file replaces the container's "Media
|
||||
# Center" theme slot; the upstream dark palette stays live because the file
|
||||
# @imports it, so an image bump cannot strand a vendored CSS snapshot. Read-only
|
||||
# — the web app only fetches it. A client opts in with
|
||||
# Display Settings -> Theme -> "Media Center".
|
||||
Volume=/home/dank/serverAppConfigs/dankserver/jellyfin/theme/wmc/theme.css:/jellyfin/jellyfin-web/themes/wmc/theme.css:ro
|
||||
AddDevice=/dev/dri
|
||||
GroupAdd=keep-groups
|
||||
HealthCmd=curl -f http://localhost:8096/health
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Dank fleet big-screen theme for jellyfin-web.
|
||||
*
|
||||
* Deployed by bind-mounting THIS file over the container's "Media Center"
|
||||
* theme (`/jellyfin/jellyfin-web/themes/wmc/theme.css`) — see the Volume line
|
||||
* in dankserver/jellyfin/quadlet/jellyfin.container. The slot is deliberately
|
||||
* one nobody picks by accident, and its name is the only thing that changes:
|
||||
* the colors are the live upstream dark theme, pulled in by @import below, so
|
||||
* this file never vendors a snapshot of upstream CSS and cannot drift when the
|
||||
* Jellyfin image moves.
|
||||
*
|
||||
* Why it exists: the living-room client (Jellyfin Desktop on danklivingroom)
|
||||
* renders this server-served web app at 3840x2160 with no client-side scaling,
|
||||
* so 14 px body text lands on a big panel viewed from three metres. `zoom` on
|
||||
* the root element is the one lever that scales the whole UI — the layout code
|
||||
* measures layout px, so card grids re-flow to fewer, larger columns instead of
|
||||
* overflowing.
|
||||
*
|
||||
* Adoption is per device, not global: Display Settings -> Theme ->
|
||||
* "Media Center" on the screen that wants it. That is the point — a phone
|
||||
* browser on the same server must not get a 1.75x interface.
|
||||
*
|
||||
* Change the size by editing the single number. 1.5 is mild, 1.75 is the
|
||||
* three-metre default, 2.0 is for a projector.
|
||||
*/
|
||||
|
||||
@import url("../dark/theme.css");
|
||||
|
||||
:root {
|
||||
/* Marker the e2e gate reads back; also a tweak hook for a per-client
|
||||
override injected by anyone who really wants one. */
|
||||
--dank-big-screen: 1;
|
||||
--dank-big-screen-zoom: 1.75;
|
||||
}
|
||||
|
||||
html {
|
||||
zoom: var(--dank-big-screen-zoom);
|
||||
}
|
||||
|
||||
/* A remote moves focus in discrete jumps, so the ring has to survive a
|
||||
three-metre read. */
|
||||
.emby-focus,
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
.inputElement:focus-visible {
|
||||
outline: 3px solid var(--accentColor, #00a4dc) !important;
|
||||
outline-offset: 2px !important;
|
||||
}
|
||||
|
||||
/* Scrollbars are mouse furniture; on a TV they are noise at this size. */
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -3,7 +3,7 @@ type: reference
|
||||
title: Jellyfin
|
||||
description: "Jellyfin on Dankserver: quadlet unit, overlay metadata view, provider keys, ports, access, transcode status."
|
||||
tags: [jellyfin, dankserver, media-server]
|
||||
timestamp: 2026-11-02T11:00:00Z
|
||||
timestamp: 2026-11-03T11:40:00Z
|
||||
---
|
||||
|
||||
# Jellyfin
|
||||
@@ -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.
|
||||
@@ -372,6 +383,65 @@ folder created under `/data/Movies`: it stays invisible until a recursive refres
|
||||
names it. So every pass that writes into a library ends by firing one, and "Jellyfin
|
||||
does not see the file I just moved in" is the designed answer, not a fault to chase.
|
||||
|
||||
## API-key ledger and rotation
|
||||
|
||||
`/Auth/Keys` is the ledger. `GET` lists every key **with its `AccessToken` in
|
||||
cleartext**, so an admin token in hand prints the whole fleet's keys;
|
||||
`POST /Auth/Keys?app=<name>` mints one and answers `204` **with no body** — the
|
||||
new token is read back from the `GET`, never from the create response.
|
||||
`DELETE /Auth/Keys/{token}` revokes **by the token string**, and a parameter that
|
||||
matches nothing answers `204` too, so `204` there is not proof anything was
|
||||
removed. Discover a route's verb from the `Allow` header of an `OPTIONS` (or
|
||||
`GET`) response — never by firing `DELETE` at a guess.
|
||||
|
||||
- `IsActive` reads `False` on rows that authenticate `200`. It is not a usability
|
||||
signal; only an authenticated request is.
|
||||
- `DateLastActivity` never moves off `0001-01-01` on 12.4, so "is this key in
|
||||
use?" cannot be answered from the ledger. The census is repo + host credential
|
||||
stores + the `Devices` table (whose `DateLastActivity` *is* real).
|
||||
- Rotation order: mint → rewrite **both** stores (`~/.config/secrets/jellyfin`
|
||||
and `JELLYFIN_API_KEY` in the ignored `dankserver/.env`) → prove the new key on
|
||||
`/System/Info` → `DELETE` the old token → prove `401`. Revoking while a store
|
||||
still holds that value breaks the scan door silently.
|
||||
- Blast radius as measured 2026-11-03: no repo script reads `JELLYFIN_API_KEY`;
|
||||
Seerr keeps a key of its own in its own DB (which of its two rows it uses was
|
||||
never confirmed), so revoke by token and never by app name.
|
||||
|
||||
## Big-screen theme (the TV)
|
||||
|
||||
`dankserver/jellyfin/theme/wmc/theme.css` is repo-owned and bind-mounted over the
|
||||
image's **Media Center** theme slot (the `Volume=` line in
|
||||
`dankserver/jellyfin/quadlet/jellyfin.container`). It exists for
|
||||
`danklivingroom`, which renders this server-served web app at 3840×2160 with no
|
||||
client-side scaling, so 14 px text arrives on a panel read from three metres.
|
||||
|
||||
The file is four rules, and its first line is the design:
|
||||
`@import url("../dark/theme.css")` pulls in the **live upstream** dark palette, so
|
||||
the repo never vendors a CSS snapshot and a Jellyfin image bump cannot strand one.
|
||||
That works because jellyfin-web applies a theme as
|
||||
`<link rel="stylesheet" href="themes/<id>/theme.css">` — a relative URL resolved
|
||||
against the CSS file, not the document. The size lives in one number:
|
||||
`--dank-big-screen-zoom` (1.75 ≈ three metres; 1.5 mild, 2.0 projector). `zoom` on
|
||||
the root element is the right lever: the card-grid code measures layout pixels, so
|
||||
fewer, larger columns are computed rather than overflow produced.
|
||||
|
||||
- **Adoption is per device, and it stays that way.** The theme a client renders
|
||||
comes from its own settings (localStorage first), so there is no server-side
|
||||
"everyone gets the big UI" knob. On the screen that wants it:
|
||||
**Display Settings → Theme → Media Center.** That is also the honest label —
|
||||
the dropdown names are compiled into the bundle, so a "Big Screen" entry will
|
||||
never appear; do not go looking for one.
|
||||
- The mount is `:ro` and the app only fetches the file, so the failure mode of a
|
||||
stale `@import` target is slightly wrong colors, never a dead web app.
|
||||
- **Editing the CSS needs no restart.** The bind hands out the host file's live
|
||||
bytes, so a changed zoom number is on screen at the next page load. The planner
|
||||
agrees by construction: only `dankserver/<svc>/quadlet/**` maps to a service, so
|
||||
a `theme/**`-only commit plans nothing — that is correct, not a gap to "fix",
|
||||
because restarting a media server for a font size is the wrong trade. Only the
|
||||
first deploy of the `Volume=` line restarts anything.
|
||||
- **Rollback is one line:** drop the `Volume=` and redeploy; nothing in the
|
||||
database records the theme, so no cleanup follows.
|
||||
|
||||
## See Also
|
||||
|
||||
- [[Plex]] — co-resident media server on the same volume
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@ flowchart TD
|
||||
**User services** (root page), in section order:
|
||||
- **AI** — OpenWebUI, and the three LLM fleet cards (frank-dragoon, frank-archon, frank-zealot — status + docs link, no Open link).
|
||||
- **Web Apps** — SearXNG, Gitea, Copyparty, Marreta.
|
||||
- **Media Library Apps** — Calibre-Web, RomM, Plex, Jellyfin, Seerr.
|
||||
- **Media Library Apps** — Calibre-Web, RomM, Plex, Jellyfin, Seerr, Audiobookshelf (moved up from the admin page 2026-11-03, once Authentik SSO made it a household app).
|
||||
|
||||
**Admin services** (`/admin/`), in section order:
|
||||
- **Media Library Apps** — the *arr stack (Prowlarr, Sonarr, Radarr, Lidarr, Readarr), qBittorrent, and Tautulli — live on FrankBook-Pro and Dankserver, operator-only: no vhosts (Tautulli included), direct IP:port access only. See [[FrankBook-Pro]].
|
||||
@@ -324,6 +324,12 @@ flowchart TD
|
||||
- **AI** — Hermes, ComfyUI.
|
||||
- **Infrastructure** — Caddy, dnsmasq, Gluetun, and the low-level backends (mariadb, valkey, Postgres). The backends show status only. They get a link only where a UI is meaningful.
|
||||
|
||||
**The registry is the authority.** `shared/homepage/services.json` decides which
|
||||
page a card renders on (`category`), whether it shows a WIP badge instead of live
|
||||
status (`wip`), and whether it gets an Open link (`showLink` + `url`). The two
|
||||
lists above are a locator only; when they disagree with the registry, the page is
|
||||
the bug — fix the page.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"host": "dankserver",
|
||||
"services": [
|
||||
{
|
||||
"name": "Homepage",
|
||||
"url": "/",
|
||||
"description": "This page. Password-protected service index.",
|
||||
"docs": "serverAppConfigs-docs/Infrastructure/Homepage.md"
|
||||
},
|
||||
{
|
||||
"name": "RomM",
|
||||
"url": "http://romm.franksdank.cloud",
|
||||
"description": "Retro game manager and player for the ROM collection.",
|
||||
"docs": "serverAppConfigs-docs/Dankserver/RomM.md"
|
||||
},
|
||||
{
|
||||
"name": "Copyparty",
|
||||
"url": "http://files.franksdank.cloud",
|
||||
"description": "High-speed file sharing and streaming server.",
|
||||
"docs": "serverAppConfigs-docs/Dankserver/Copyparty.md"
|
||||
},
|
||||
{
|
||||
"name": "Calibre-Web",
|
||||
"url": "http://books.franksdank.cloud",
|
||||
"description": "E-book library browser and reader.",
|
||||
"docs": "serverAppConfigs-docs/Dankserver/Calibre-Web.md"
|
||||
},
|
||||
{
|
||||
"name": "Plex",
|
||||
"url": "http://plex.franksdank.cloud/web/index.html",
|
||||
"description": "Media server (existing host service, proxied).",
|
||||
"docs": "serverAppConfigs-docs/Dankserver/Dankserver Stack.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -56,6 +56,10 @@
|
||||
<a href="https://jelly.franksdank.cloud">Jellyfin</a>. Seerr is the front door —
|
||||
requests ride the VPN tunnel from there.</li>
|
||||
<li><strong>Books:</strong> <a href="https://books.franksdank.cloud">Calibre-Web</a> — the e-book library, with its own login.</li>
|
||||
<li><strong>Audiobooks:</strong> <a href="https://audiobooks.franksdank.cloud">Audiobookshelf</a> — the
|
||||
audiobook library. Same single sign-on as everything else: choose
|
||||
<strong>Continue with authentik</strong> and each listener keeps their own
|
||||
position.</li>
|
||||
<li><strong>Games:</strong> <a href="https://romm.franksdank.cloud">RomM</a> — ROM library and emulator collection.</li>
|
||||
<li><strong>Raw files:</strong> <a href="https://files.franksdank.cloud">Copyparty</a> — direct downloads and shares.</li>
|
||||
<li><strong>Search:</strong> <a href="https://search.franksdank.cloud">SearXNG</a> — metasearch, VPN-routed, no AI snoop. <details class="key-warn"><summary>⚠ privacy note</summary><div class="warn-body">Searches are obfuscated, not anonymous: SearXNG hides you from the upstream engines and the VPN tunnel hides the traffic from the LAN — but the upstream search API keys are tied to Frank’s identity. Don’t treat it as Tor.</div></details></li>
|
||||
@@ -109,6 +113,7 @@
|
||||
<li><strong>Best for:</strong> complex agentic tasks, programming, system administration, and "claw"-style personal agents.</li>
|
||||
<li><strong>Supervision:</strong> safe for autonomous, low-supervision use.</li>
|
||||
<li><strong>Warning:</strong> <details class="key-warn"><summary>⚠ uncensored model</summary><div class="warn-body">Uncensored model: output can include adult content, content that describes harmful, hateful, or illegal acts, content concerning “sensitive offensive cybersecurity topics”, and other output that may carry severe legal risk. Take this warning seriously — don’t ruin this server for everybody.</div></details></li>
|
||||
</ul>
|
||||
|
||||
<h2>frank-archon — the big one</h2>
|
||||
<ul>
|
||||
|
||||
@@ -110,13 +110,13 @@
|
||||
"name": "Jellyfin",
|
||||
"category": "user",
|
||||
"section": "Media Library Apps",
|
||||
"description": "Open source media server. Work in progress: not open to family users yet.",
|
||||
"url": "",
|
||||
"description": "Open source media server for the film and TV library: its own logins (no Authentik here), so ask Frank for an account.",
|
||||
"url": "http://jelly.franksdank.cloud",
|
||||
"docs": "",
|
||||
"probe": "192.168.0.235:8096",
|
||||
"method": "http",
|
||||
"wip": "down",
|
||||
"showLink": false,
|
||||
"wip": false,
|
||||
"showLink": true,
|
||||
"art": "/img/apps/jellyfin.svg"
|
||||
},
|
||||
{
|
||||
@@ -136,7 +136,7 @@
|
||||
"name": "Marreta",
|
||||
"category": "user",
|
||||
"section": "Web Apps",
|
||||
"description": "Clean, ad-free article reader. Paste any URL, get just the article.",
|
||||
"description": "Paywall sledgehammer: hand it an article URL and it drags the page back through the VPN with the paywall chrome, cookie wall and trackers stripped; a headless browser renders the JS-heavy ones. Best effort — hard paywalls and login walls still win.",
|
||||
"url": "http://marreta.franksdank.cloud",
|
||||
"docs": "",
|
||||
"probe": "127.0.0.1:8082",
|
||||
@@ -513,10 +513,10 @@
|
||||
},
|
||||
{
|
||||
"name": "Audiobookshelf",
|
||||
"category": "admin",
|
||||
"category": "user",
|
||||
"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",
|
||||
"description": "Audiobook server for the library: sign in with your Authentik account and every listener keeps their own position.",
|
||||
"url": "http://audiobooks.franksdank.cloud/audiobookshelf/",
|
||||
"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