Files
serverAppConfigs/tests/anubis.bats
dank 2a48751283 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.
2026-11-03 01:54:08 -08:00

97 lines
4.6 KiB
Bash
Executable File

#!/usr/bin/env bats
# Seam: the Anubis bot-challenge proxy (dankclaw/anubis). Anubis sits between
# Caddy's public listeners and the guarded vhosts: clients hit Caddy (TLS),
# Caddy proxies everything through Anubis, Anubis verifies (DENY known AI
# scrapers, CHALLENGE everyone else per policy.json) and forwards accepted
# traffic back to Caddy's internal listener at :8016, where per-vhost routing
# resumes. Static assertions only — the container runs on dankclaw.
load helpers
setup() {
UNIT="$REPO_ROOT/dankclaw/anubis/quadlet/anubis.container"
POLICY="$REPO_ROOT/dankclaw/anubis/policy.json"
CADDY="$REPO_ROOT/dankclaw/Caddyfile"
}
@test "the anubis quadlet unit exists and is boot-persistent by [Install]" {
test -f "$UNIT"
grep -q 'WantedBy=default.target' "$UNIT"
# Generated units cannot be enabled on this host (v1 quadlet generator);
# boot persistence comes from [Install], not from systemctl enable.
! grep -q '^Enable=true' "$UNIT"
}
@test "the image is pinned to an exact upstream tag and never pulled at boot" {
# The unit runs a localhost/ alias tag of the canonical upstream ref
# (podman 5.7 quadlet quirk: registry-prefixed refs fail container
# create on this host). The pin is still an exact upstream tag — the
# deploy handler pulls the canonical ghcr.io ref and re-tags the alias;
# nothing is pulled at boot (Pull=never).
grep -qE '^Image=localhost/techarohq/anubis:v[0-9]+\.[0-9]+\.[0-9]+$' "$UNIT"
grep -q '^Pull=never$' "$UNIT"
grep -q 'podman pull ghcr\.io/techarohq/anubis:v[0-9]*\.[0-9]*\.[0-9]*' "$REPO_ROOT/scripts/deploy-remote.sh"
}
@test "anubis publishes no host port — it is internal-network-only" {
# Metrics (:9090) are unauthenticated; the protection strategy is that
# only dankclaw_default members can reach the container at all. Caddy
# reaches it by container DNS name, so no published port may exist.
! grep -q '^PublishPort=' "$UNIT"
}
@test "the unit points TARGET back at caddy's internal listener" {
grep -q '^Environment=TARGET=http://caddy:8016$' "$UNIT"
grep -q '^Environment=POLICY_FNAME=/policy/policy.json$' "$UNIT"
# The policy file is mounted read-only from the repo checkout on the host.
grep -q 'policy.json:/policy/policy.json:ro' "$UNIT"
}
@test "the policy parses as JSON and matches v1.13.0 schema (bots array, action rules)" {
# At the pinned tag the parser is JSON-only with a minimal schema:
# {"bots":[{"name","user_agent_regex"|"path_regex","action"}],"dnsbl":bool}
# — imports/CEL/expression keys do not exist at this tag and would crash
# the container at parse time. Assert the shape statically via python.
python3 - "$POLICY" <<'PY'
import json, sys
with open(sys.argv[1]) as f:
p = json.load(f)
assert set(p.keys()) <= {"bots", "dnsbl"}, p.keys()
actions = [b["action"] for b in p["bots"]]
assert any(a == "DENY" for a in actions), actions
assert actions[-1] == "CHALLENGE", actions
for b in p["bots"]:
keys = set(b) ^ {"name", "action"}
assert len(keys) == 1, f"{b.get('name')}: exactly one matcher required, got {keys}"
assert (keys ^ {"user_agent_regex"}) != (keys ^ {"path_regex"}), b
PY
}
@test "guarded vhosts proxy through anubis with the real client IP forwarded" {
# Anubis reads X-Real-Ip for its decisions; without the header every
# request looks like it comes from the Caddy container. Every guarded
# public listener must set it.
grep -q 'header_up X-Real-Ip {remote_host}' "$CADDY"
}
@test "exempt vhosts never route through anubis (auth, plex, own-auth apps)" {
# 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'
! echo "$block" | grep -q anubis
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"
}