feat(caddy): Audiobookshelf outside the guard chain, so the phone app works from anywhere #133
No files matched your search
+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
|
||||
|
||||
+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"
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in new issue
Block a user