feat(caddy): serve Audiobookshelf outside the guard chain

The Audiobookshelf phone app could never sign in over the public name. The guard
chain answered its /ping and /socket.io/ handshake with Anubis challenge HTML, so
the app evaluated 'isInit' in e.data on a non-object and reported "Failed to
contact server" while the container's request log recorded nothing at all. Anubis
demands a JavaScript proof-of-work and the authentik outpost works from a session
cookie: the chain is browser-only by construction, and a native client, a podcast
RSS reader or an OPDS client cannot pass it.

Audiobookshelf authenticates itself (local accounts plus authentik OIDC), so it
joins jelly/plex/*arr in the exempt set: the public site block dials the container
directly, keeping the X-Forwarded-Proto pin its OIDC origin check depends on, and
the @audiobooks route leaves the post-auth listener so one host has one owner. The
IdP rows that guarded the name go after this deploys, never before.

tests/e2e/specs/14-guard.spec.ts loses audiobooks as a guarded target and gains the
exemption assertion (app bytes to an anonymous client, the app's own 401, never
challenge HTML); tests/anubis.bats pins the route shape.
This commit is contained in:
dank committed 2026-11-03 01:54:08 -08:00
1 parent 6083903ccb
commit 2a48751283
3 files changed
+98 -45

No files matched your search

+29 -35
View File
@@ -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