anubis: bot-challenge proxy in front of the guarded vhosts (two-tier chain)

Bots and AI scrapers hit the SSO-fronted vhosts and eat Authentik auth
work for 403s. Anubis verifies clients first: DENY for known AI scraper
UAs, JS challenge for everyone else; verified traffic resumes the normal
Authentik forward_auth chain on Caddy's internal listener (:8016).

Chain: client -> Caddy (TLS) -> Anubis :8923 (verify) -> Caddy :8016
(auth + routing resumes; Host survives both hops) -> app. Reverse-proxy
through Anubis, not forward_auth: at the pinned tag a CHALLENGE answers
HTTP 200 + HTML, and forward_auth treats 200 as "authenticated" — the
page would pass through unchallenged. Exempt by construction (never
routed through Anubis): plex (plain HTTP legacy), auth.* (IdP login
must be reachable ungated), gitea (git smart-HTTP clients cannot solve
JS challenges), and every app with its own login.

Pinned ghcr.io/TecharoHQ/anubis:v1.13.0 — the tag was source-verified
before pinning: at this tag the policy parser is JSON-only with a
minimal bots[] schema (no imports, no CEL expressions), flags are the
config surface via flagenv (TARGET/POLICY_FNAME map to --target/
--policy-fname), and the image's exec-form HEALTHCHECK is inherited by
the quadlet unit (dodges the v1 generator's HealthCmd space-split quirk).

Rollback nuance: stopping anubis 502s everything behind the chain (the
public listeners proxy through it); a true rollback is reverting the
Caddyfile and redeploying, not just systemctl --user stop anubis.
This commit is contained in:
Frank committed 2026-08-28 06:38:24 -07:00
1 parent 7b38ea63ef
commit 0ae3650414
10 files changed
+328 -94

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
{
"bots": [
{
"name": "deny AI scrapers",
"user_agent_regex": "(?i)^(GPTBot|ChatGPT-User|OAI-SearchBot|CCBot|anthropic-ai|ClaudeBot|Claude-Web|PerplexityBot|PerplexityUser|Google-Extended|Applebot-Extended|Bytespider|Amazonbot|meta-externalagent|DuckAssistBot|cohere-ai|YouBot|KimiBot)",
"action": "DENY"
},
{
"name": "challenge everything else",
"path_regex": ".",
"action": "CHALLENGE"
}
],
"dnsbl": false
}
+50
View File
@@ -0,0 +1,50 @@
# Quadlet unit: Anubis bot-challenge proxy (rootless, boot-persistent).
#
# Install path on dankclaw: ~/.config/containers/systemd/anubis.container
# Unit name: anubis.service (container name: anubis)
#
# Two-tier chain: the public Caddy listeners for the guarded vhosts proxy
# everything to this container (port 8923, internal only — no published port,
# so metrics on :9090 stay reachable only inside dankclaw_default); Anubis
# verifies the client (challenge/DENY per policy) and forwards accepted
# traffic back to Caddy's internal listener (TARGET below), where the normal
# per-vhost routing — including Authentik forward_auth — resumes. Host headers
# survive both hops, so vhost matching on the internal listener still works.
#
# Version pin: ghcr.io/TecharoHQ/anubis:v1.13.0 was source-verified before
# pinning (policy parser is JSON-only at this tag, flags are the config
# surface via flagenv: an env var named TARGET sets --target, POLICY_FNAME
# sets --policy-fname). Upgrading = stop unit, change Image=, deploy; policy
# schema must be re-verified against the new tag first.
#
# No secrets here: Anubis holds no credentials (memory store; challenge
# cookies are client-side JWTs). A restart invalidates issued challenges —
# users just re-challenge once. See serverAppConfigs-docs/Infrastructure/Anubis.md.
[Unit]
Description=Anubis bot-challenge proxy container
After=network-online.target
Wants=network-online.target
[Container]
ContainerName=anubis
Image=ghcr.io/TecharoHQ/anubis:v1.13.0
Pull=never
Network=dankclaw_default
# flagenv maps env names to flag names (TARGET -> --target, POLICY_FNAME ->
# --policy-fname). TARGET points back at Caddy's internal listener; the
# container DNS name resolves on dankclaw_default.
Environment=TARGET=http://caddy:8016
Environment=POLICY_FNAME=/policy/policy.json
Volume=/home/dank/serverAppConfigs/dankclaw/anubis/policy.json:/policy/policy.json:ro
# No HealthCmd: the image ships an exec-form HEALTHCHECK
# (["/app/bin/anubis","--healthcheck"]) and quadlet inherits it when no
# HealthCmd is set — this dodges the v1 generator's space-splitting quirk
# that mangled quoted probes for the authentik units.
[Service]
Restart=always
RestartSec=5
[Install]
WantedBy=default.target