Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5be66df2bb | ||
|
|
d3aedd1837 | ||
|
|
66766f23ef | ||
|
|
5cf5416471 | ||
|
|
30186d8365 | ||
|
|
3a6f7c7280 | ||
|
|
c7643fb4d0 | ||
|
|
882a9f6808 | ||
|
|
aa57c1a894 | ||
|
|
2a48751283 | ||
|
|
6083903ccb |
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:
|
||||
|
||||
+29
-35
@@ -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,17 +267,26 @@ files.franksdank.cloud {
|
||||
}
|
||||
}
|
||||
|
||||
# --- Audiobookshelf (guarded) ---
|
||||
# Anubis → Authentik → app, like romm/files. The container carries
|
||||
# ROUTER_BASE_PATH=/audiobookshelf, so this vhost serves the app under that
|
||||
# path; the bare root is redirected onto it in the post-auth listener, never
|
||||
# pre-auth. Guard state in the IdP: proxy provider `audiobooks-proxy`
|
||||
# (external_host https://audiobooks.franksdank.cloud/, internal_host
|
||||
# http://caddy:8017, application "Audiobookshelf Guard"), claimed by the
|
||||
# embedded outpost.
|
||||
# --- 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 {
|
||||
reverse_proxy anubis:8923 {
|
||||
header_up X-Real-Ip {remote_host}
|
||||
redir / /audiobookshelf/ permanent
|
||||
reverse_proxy 192.168.0.235:8000 {
|
||||
header_up X-Forwarded-Proto https
|
||||
}
|
||||
}
|
||||
|
||||
@@ -424,24 +436,6 @@ http://:8017 {
|
||||
reverse_proxy 192.168.0.235:3923
|
||||
}
|
||||
|
||||
@audiobooks host audiobooks.franksdank.cloud
|
||||
handle @audiobooks {
|
||||
# The app answers only under its ROUTER_BASE_PATH; the bare root is a
|
||||
# convenience hop, taken post-auth so nothing reaches the app
|
||||
# pre-authentication.
|
||||
redir / /audiobookshelf/ permanent
|
||||
# Audiobookshelf validates every OIDC callback against the origin it
|
||||
# believes it is served from — getRequestOrigin() is
|
||||
# x-forwarded-proto://Host — and a mismatch is a bodiless 400
|
||||
# "Invalid callback URL - must be same-origin" before the IdP is ever
|
||||
# contacted (measured 2026-11-03). This listener is fed over plain http
|
||||
# by the outpost, so Caddy's own X-Forwarded-Proto here says http; the
|
||||
# edge for this name is TLS-only, so https is the truthful value.
|
||||
reverse_proxy 192.168.0.235:8000 {
|
||||
header_up X-Forwarded-Proto https
|
||||
}
|
||||
}
|
||||
|
||||
@prowlarr host prowlarr.franksdank.cloud
|
||||
handle @prowlarr {
|
||||
reverse_proxy 192.168.0.112:9696
|
||||
|
||||
@@ -268,8 +268,10 @@ else:
|
||||
# not auto-launch: the login page keeps offering the local form, which is the
|
||||
# break-glass path when the IdP is unreachable.
|
||||
"authOpenIDAutoLaunch": False,
|
||||
# a household user's first SSO sign-in creates their account; the group claim
|
||||
# decides the role, and a user in neither group is denied (fail closed).
|
||||
# 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
|
||||
|
||||
@@ -11,15 +11,17 @@
|
||||
# dist-server/server/auth/OidcAuthStrategy.js + objects/settings/ServerSettings.js)
|
||||
# rather than a forum, because each of these is a dead-login trap:
|
||||
#
|
||||
# * The browser callback URI <base>/auth/openid/callback for EVERY origin ABS is
|
||||
# reached at, plus <base>/auth/openid/mobile-redirect on ABS_BASE only. ABS SWAPS
|
||||
# the app's own audiobookshelf://oauth for the mobile-redirect URL before the IdP
|
||||
# ever sees a redirect_uri, so registering the app scheme with authentik — the
|
||||
# obvious move — is what makes phone logins fail. The app scheme belongs in ABS's
|
||||
# authOpenIDMobileRedirectURIs list (its built-in default), not here. The mobile
|
||||
# URI is registered on the LAN origin only because the phone is configured with
|
||||
# the LAN address: it is a non-browser client and cannot solve the guard
|
||||
# challenge that sits in front of the vhost.
|
||||
# * 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
|
||||
@@ -68,9 +70,12 @@
|
||||
# ABS_OIDC_CLIENT_ID provider/client id (audiobookshelf)
|
||||
# ABS_OIDC_GROUPS_CLAIM claim ABS reads for roles (abs_groups)
|
||||
# ABS_OIDC_USERNAME authentik user made admin (akadmin)
|
||||
# ABS_OIDC_USERS space-separated authentik usernames given the USER group
|
||||
# (default: nobody — an empty list is fail-closed, and the
|
||||
# household decides who listens to audiobooks)
|
||||
# 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)
|
||||
@@ -95,53 +100,67 @@ SECRET="${ABS_OIDC_CLIENT_SECRET:?set ABS_OIDC_CLIENT_SECRET (see the header)}"
|
||||
CLIENT_ID="${ABS_OIDC_CLIENT_ID:-audiobookshelf}"
|
||||
CLAIM="${ABS_OIDC_GROUPS_CLAIM:-abs_groups}"
|
||||
ADMIN_GROUP="audiobookshelf_admins"
|
||||
USER_GROUP="audiobookshelf_users"
|
||||
SSO_USER="${ABS_OIDC_USERNAME:-akadmin}"
|
||||
MEMBERS="${ABS_OIDC_USERS:-}"
|
||||
E2E_ADMIN="${ABS_E2E_ADMIN:-qa-e2e-admin}"
|
||||
BASE="${ABS_BASE:-http://192.168.0.235:8000/audiobookshelf}"
|
||||
EXTRA_BASES="${ABS_EXTRA_BASES:-https://audiobooks.franksdank.cloud/audiobookshelf}"
|
||||
HOST="${AUTHENTIK_HOST:-dankclaw}"
|
||||
|
||||
# The claim expression: translate group membership into ABS role names. It is stored
|
||||
# verbatim so a later read of the provider shows exactly this policy.
|
||||
# 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")
|
||||
if groups & {"$ADMIN_GROUP", "$USER_GROUP"}:
|
||||
roles.append("user")
|
||||
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, USER_GROUP = "$ADMIN_GROUP", "$USER_GROUP"
|
||||
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"
|
||||
MEMBERS = """$MEMBERS""".split()
|
||||
EXPR = '''$EXPR'''
|
||||
# The expression reads user.groups, NOT user.ak_groups: authentik 2026.8 logs
|
||||
# "User.ak_groups is deprecated … use User.groups" on every evaluation of the old
|
||||
# name (measured on this run), and a stored mapping that leans on a deprecated
|
||||
# accessor is a future upgrade failure sitting inside the login path.
|
||||
|
||||
for name in (ADMIN_GROUP, USER_GROUP):
|
||||
Group.objects.get_or_create(name=name)
|
||||
admin_g, user_g = Group.objects.get(name=ADMIN_GROUP), Group.objects.get(name=USER_GROUP)
|
||||
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 role names from group membership")
|
||||
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
|
||||
@@ -151,18 +170,18 @@ else:
|
||||
print("mapping: already current")
|
||||
|
||||
# Prove the expression runs and yields an ABS role BEFORE anyone tries to log in:
|
||||
# authentik execs the expression inside a function whose context has `user`.
|
||||
# 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 browser callback per origin (LAN address and the guarded vhost), because ABS
|
||||
# derives redirect_uri from the request origin; the mobile URI stays on the LAN origin.
|
||||
uris = [RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
|
||||
url="%s/auth/openid/callback" % b) for b in BASES]
|
||||
uris.append(RedirectURI(matching_mode=RedirectURIMatchingMode.STRICT,
|
||||
url="%s/auth/openid/mobile-redirect" % BASE))
|
||||
# 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
|
||||
@@ -238,13 +257,26 @@ else:
|
||||
e2e.groups.add(admin_g)
|
||||
print("e2e fixture: %s in %s" % (E2E_ADMIN, ADMIN_GROUP))
|
||||
print("admin: %s in %s" % (SSO_USER, ADMIN_GROUP))
|
||||
for name in MEMBERS:
|
||||
u = User.objects.get(username=name)
|
||||
u.groups.add(user_g)
|
||||
print("users: %s in %s" % (", ".join(MEMBERS) or "(none — fail closed)", USER_GROUP))
|
||||
# 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 []) + [User.objects.get(username=n) for n in MEMBERS]:
|
||||
for probe in [root] + ([e2e] if e2e else []):
|
||||
print("claim for %s -> %s" % (probe.username, _ns["_probe"](user=probe, request=None)))
|
||||
p.refresh_from_db()
|
||||
print("provider %s | client_id %s | flow %s | grants %s | uris %s" % (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: reference
|
||||
description: "Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, guarded vhost, laws."
|
||||
description: "Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, public vhost, laws."
|
||||
tags:
|
||||
- dankserver
|
||||
- audiobooks
|
||||
@@ -17,7 +17,7 @@ server — it shares nothing with [[Plex]] or [[Jellyfin]] and owns no files on
|
||||
array.
|
||||
|
||||
**Host:** Dankserver — **Unit:** `audiobookshelf.service` (rootless Quadlet, user manager) —
|
||||
**Doors:** LAN `http://192.168.0.235:8000/audiobookshelf` (direct, what the phone app uses) and guarded `https://audiobooks.franksdank.cloud/audiobookshelf/`.
|
||||
**Doors:** LAN `http://192.168.0.235:8000/audiobookshelf` (plain HTTP, nothing in front) and public `https://audiobooks.franksdank.cloud/audiobookshelf/` — Caddy straight to the container, **exempt from the guard chain** because the app must answer non-browser clients ([[Caddy]] *Unguard a vhost*). The public name is the address every client carries: Pi-hole answers it with `192.168.0.143` on the LAN, Porkbun answers it with the WAN address outside, and Caddy serves a real certificate either way.
|
||||
|
||||
## Shape
|
||||
|
||||
@@ -61,6 +61,9 @@ Dankserver. Change it in **Settings → Users → frank → Password**; the secr
|
||||
then holds a stale value and only matters again if the `config` bind is ever
|
||||
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
|
||||
@@ -69,14 +72,20 @@ the file, not the bind: the same line count grows while the total climbs.
|
||||
## Phone app
|
||||
|
||||
The official **Audiobookshelf** app connects with Server Address
|
||||
`http://192.168.0.235:8000/audiobookshelf` — the base path belongs in the address — username
|
||||
`frank`, and the same password as the web UI, or the SSO button; plain HTTP. Measured from a
|
||||
second host 2026-11-02 and re-measured on both base paths: `/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 `iptables -S INPUT`
|
||||
carries no rule for 8000 — a phone on `192.168.0.0/24` connects with no further setup, and
|
||||
`download: true` on the root account is what makes offline listening work. Away
|
||||
from the LAN, see the two-doors law in *Laws* — the guarded vhost is not the remedy.
|
||||
`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
|
||||
@@ -84,6 +93,40 @@ inside the user object. A client that reads `response.token` gets the empty stri
|
||||
`Authorization: Bearer ` with nothing after it, and every `/api/*` call then answers `401`
|
||||
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
|
||||
@@ -93,19 +136,24 @@ the local form, and the phone app offers SSO on its server-address screen. The l
|
||||
|
||||
| Piece | Owner |
|
||||
|-------|-------|
|
||||
| Provider `audiobookshelf`, the two groups, the scope mapping | `scripts/authentik-audiobookshelf-sso.sh` — re-run it after an authentik rebuild, because this state lives in the IdP's database and a blueprint re-import reverts it silently |
|
||||
| Provider `audiobookshelf`, the admin group, the scope mapping | `scripts/authentik-audiobookshelf-sso.sh` — re-run it after an authentik rebuild, because this state lives in the IdP's database and a blueprint re-import reverts it silently |
|
||||
| Client secret | `ABS_OIDC_CLIENT_SECRET` in `~/.config/secrets/audiobookshelf` on Dankserver (mode 600; never in the repo, a URL or an argv) |
|
||||
| The ABS half: issuer, the four IdP URLs, claim, auto-register | `scripts/audiobookshelf-init.sh`, armed automatically when that secret exists in the credential file |
|
||||
| Role at first SSO sign-in | the `abs_groups` claim: `audiobookshelf_admins` → `admin`, `audiobookshelf_users` → `user`, in neither → denied (fail closed) |
|
||||
| Role at first SSO sign-in | the `abs_groups` claim: every signer gets `user`; `audiobookshelf_admins` adds `admin`. Nothing in the IdP can deny the listener role — the login itself is the gate (law since 2026-11-03; the `audiobookshelf_users` group was retired, it had zero members and every household account was being refused) |
|
||||
| Admin group members | `akadmin` (the operator's own authentik admin account — sign in as it and the claim grants site admin) and `qa-e2e-admin` (the identity the deploy gate signs in as, so the script re-adds it every run; drop it and the gate fails, not a phone) |
|
||||
| The guard in front of the vhost | proxy provider `audiobooks-proxy` (external_host `https://audiobooks.franksdank.cloud/`, internal_host `http://caddy:8017`) + application "Audiobookshelf Guard" (its own name tracks the app, not the vhost), claimed by the embedded outpost — the ak-shell recipe in [[Caddy]] *Guard a new vhost* creates it; without that row the chain answers authentik's error page, not the login |
|
||||
| Guard state of the door | none since 2026-11-03: proxy provider `audiobooks-proxy` and application "Audiobookshelf Guard" were deleted and the embedded outpost un-claimed them, because that chain answers a native client with challenge HTML ([[Caddy]] *Unguard a vhost* for the order). The SSO relying party `audiobookshelf` above is a different object and stays |
|
||||
| Redirect URIs on the RP | `<base>/auth/openid/callback` **and** `<base>/auth/openid/mobile-redirect` for **both** origins (LAN address and public name): the app sends the mobile URI belonging to the door it was configured with, and STRICT matching answers an unregistered one with "Redirect URI Error" |
|
||||
|
||||
The IdP saying yes is not the same as being allowed in: with `authOpenIDAutoRegister` on,
|
||||
a first sign-in creates the account, and the claim alone decides the type. A user in
|
||||
neither group is refused after a successful authentication — that is the mapping working,
|
||||
not a broken login.
|
||||
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), which makes two things worth
|
||||
checking when they change: whether authentik's own invitation/signup flow stays something only
|
||||
Frank drives, and whether an access policy should be bound to the `audiobookshelf` application
|
||||
to narrow who may even reach the login. Revoking one person is a disable/delete of their ABS
|
||||
user; revoking everyone is that policy.
|
||||
|
||||
Both halves — authentik's provider row and ABS's settings — are asserted end to end by the Playwright gate named in *Verification*: a sign-in as `qa-e2e-admin` that must land inside the app with site-admin rights.
|
||||
Both halves — authentik's provider row and ABS's settings — are asserted end to end by the Playwright gate named in *Verification*. `23-` signs in as `qa-e2e-admin` and must land inside the app with site-admin rights; `24-` signs in as the standard identity, which is in no Audiobookshelf group, and must land as a listener. Together they pin the escalation and the default: a mapping that stops granting silently locks the household out, and a mapping that over-grants turns the household into site admins.
|
||||
|
||||
## Laws
|
||||
|
||||
@@ -195,13 +243,17 @@ Both halves — authentik's provider row and ABS's settings — are asserted end
|
||||
- **No `systemctl --user enable`.** Generated Quadlet units cannot be enabled on
|
||||
this host; `WantedBy=default.target` in the unit file is what carries the
|
||||
service across a reboot ([[Quadlet Lifecycle]]).
|
||||
- **Two doors, one app.** `http://192.168.0.235:8000/audiobookshelf` is the raw
|
||||
LAN address — plain HTTP, nothing in front, what the phone app is configured
|
||||
with; `https://audiobooks.franksdank.cloud/audiobookshelf/` is the guarded vhost
|
||||
(Caddy → Anubis → the Authentik outpost → Caddy `:8017` → this container),
|
||||
the house chain for browser traffic — [[Caddy]] *Guard a new vhost*. That
|
||||
chain serves browsers only: the phone app is a non-browser client and solves
|
||||
no challenge, so remote listening wants a VPN into the LAN, not the vhost.
|
||||
- **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
|
||||
@@ -287,13 +339,23 @@ bootstrapper against a stub of the API above: create-once, idempotent re-run, no
|
||||
URL, refusal by URL when the server does not answer, the deployer calling it only after the
|
||||
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 gate `tests/e2e/specs/23-audiobookshelf-sso.spec.ts`, run by
|
||||
`tests/e2e/run.sh` at the end of every `deploy.sh` — the `admin` project signs in as
|
||||
`qa-e2e-admin` against `http://192.168.0.235:8000/audiobookshelf` and asserts the two things
|
||||
no stub can see: every `.js` the app loads arrives as JavaScript (a bundle built for the wrong
|
||||
base path arrives as `text/html`, which boots nothing), and `/api/me` answers 200 with
|
||||
`type: "admin"` — true only while grant types, the OpenID scope mappings and the group claim
|
||||
all hold together.
|
||||
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,
|
||||
|
||||
@@ -90,7 +90,7 @@ pihole.franksdank.cloud {
|
||||
|
||||
After rendering, the line reads `dankuser <hash>` (one complete hash) — `tests/deployment_flow.bats:96-99` pins the `dankuser` name and the single-hash form, and `tests/caddyfile.bats:75` pins the bare `dankuser REPLACE_WITH_HASH` template. Do not put a `$2a$14$` prefix in the template: it would render a double-prefixed, invalid hash (2026-08-22 incident — every basicauth vhost returned 401 for every password; `tests/caddyfile.bats:67-72` forbids the prefix). `tests/deployment_flow.bats` pins the render contract.
|
||||
|
||||
Everything else authenticates, not basicauth. The guarded set — `www.` (homepage), `search.`, `grafana.`, `prometheus.`, `loki.`, `seerr.`, `marreta.`, `romm.`, `files.`, `notebook.`, `abs.` — proxies **all** traffic to `anubis:8923`, which verifies the client and forwards it back to Caddy's internal listener `http://:8016`; Authentik's proxy-provider mode serves the login inline (this build exposes **no** `forward_auth` endpoint) and forwards authenticated traffic to `http://:8017`, where the request is routed straight to its app (`dankclaw/Caddyfile`: the header comment and the two internal listeners). The exempt-by-construction set never enters that chain: `auth.` (the IdP itself), `ai.` and `gitea.` (own logins), `hermes.` and `hermesadmin.` (app-enforced passwords), `pihole.` (the basicauth edge above), and the media/*arr vhosts. Grafana left basicauth on 2026-09-01 and is an OAuth2 relying party against Authentik (`dankclaw/Caddyfile:122-132`). See [[Homepage]] and [[Authentik]].
|
||||
Everything else authenticates, not basicauth. The guarded set — `www.` (homepage), `search.`, `grafana.`, `prometheus.`, `loki.`, `seerr.`, `marreta.`, `romm.`, `files.`, `notebook.` — proxies **all** traffic to `anubis:8923`, which verifies the client and forwards it back to Caddy's internal listener `http://:8016`; Authentik's proxy-provider mode serves the login inline (this build exposes **no** `forward_auth` endpoint) and forwards authenticated traffic to `http://:8017`, where the request is routed straight to its app (`dankclaw/Caddyfile`: the header comment and the two internal listeners). The exempt-by-construction set never enters that chain: `auth.` (the IdP itself), `ai.` and `gitea.` (own logins), `hermes.` and `hermesadmin.` (app-enforced passwords), `pihole.` (the basicauth edge above), the media/*arr vhosts, and `audiobooks.` — Audiobookshelf enforces its own auth (local accounts plus authentik OIDC) and must answer native app clients, podcast RSS and OPDS, which the chain structurally cannot deliver: it answers a client that solves no JavaScript proof-of-work with challenge HTML wherever that client expects JSON or a socket payload ([[Audiobookshelf]]). Grafana left basicauth on 2026-09-01 and is an OAuth2 relying party against Authentik. See [[Homepage]] and [[Authentik]].
|
||||
|
||||
## Guard a new vhost
|
||||
|
||||
@@ -107,7 +107,7 @@ authentik's error page instead of the app.
|
||||
from authentik.providers.proxy.models import ProxyProvider
|
||||
from authentik.core.models import Application
|
||||
from authentik.outposts.models import Outpost
|
||||
NAME, EXT = "audiobooks", "https://audiobooks.franksdank.cloud/"
|
||||
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,
|
||||
@@ -141,7 +141,9 @@ authentik's error page instead of the app.
|
||||
Audiobookshelf checks every OIDC callback against `x-forwarded-proto://Host`; this
|
||||
listener is fed over plain http so Caddy's own `X-Forwarded-Proto` is `http`, and the
|
||||
app answers a 400 "Invalid callback URL - must be same-origin" for its own https
|
||||
callback. The edge for a guarded name is TLS-only, so `header_up X-Forwarded-Proto
|
||||
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
|
||||
@@ -149,6 +151,51 @@ authentik's error page instead of the app.
|
||||
`tests/e2e/specs/14-guard.spec.ts` with an app-specific leak marker — that spec is
|
||||
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
|
||||
|
||||
Validate before a reload:
|
||||
|
||||
@@ -44,7 +44,7 @@ is a locator: one row, one clause, no numbers.
|
||||
|
||||
| Page | Owns |
|
||||
|---|---|
|
||||
| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, guarded vhost, laws. |
|
||||
| [[Audiobookshelf]] | Audiobookshelf on Dankserver: quadlet shape, array bind, SSD state, API bootstrap, authentik SSO, public vhost, laws. |
|
||||
| [[Calibre]] | Calibre is the e-book library manager that backs Calibre-Web. |
|
||||
| [[Calibre-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. |
|
||||
|
||||
+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"
|
||||
}
|
||||
@@ -35,8 +35,11 @@ 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"],
|
||||
},
|
||||
|
||||
@@ -6,21 +6,21 @@ 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";
|
||||
|
||||
// Every vhost that Caddy routes through anubis:8923 (see dankclaw/Caddyfile).
|
||||
// Keep this list in lockstep with the Caddyfile: a new guarded vhost must be
|
||||
// added here or the guard-chain audit is incomplete.
|
||||
const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki", "notebook", "audiobooks"];
|
||||
const GUARDED = ["www", "search", "romm", "files", "seerr", "grafana", "prometheus", "loki", "notebook"];
|
||||
|
||||
// Markers that prove app content leaked past the guard to an anonymous user. Each
|
||||
// marker must be a string the APP renders that the gate itself could never print —
|
||||
// for audiobooks that is the Audiobookshelf login's own SSO button label (the app's
|
||||
// door), which authentik's gate never renders because it IS the IdP.
|
||||
// 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"],
|
||||
@@ -29,7 +29,6 @@ const LEAK_MARKERS: Record<string, string[]> = {
|
||||
"grafana.franksdank.cloud": ["Dashboards"],
|
||||
"prometheus.franksdank.cloud": ["Expression Browser", "Graph"],
|
||||
"notebook.franksdank.cloud": ["Open Notebook"],
|
||||
"audiobooks.franksdank.cloud": ["Sign in with Authentik"],
|
||||
};
|
||||
|
||||
test("homepage root (/) is guarded for unauthenticated users", async ({ page }) => {
|
||||
@@ -102,3 +101,54 @@ test("plex is exempt from the guard chain and serves PMS content unauthenticated
|
||||
const resp = await page.request.get("https://plex.franksdank.cloud/");
|
||||
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,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