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:
1 parent
7b38ea63ef
commit
0ae3650414
10 files changed
+328
-94
No files matched your search
Executable
+81
@@ -0,0 +1,81 @@
|
||||
#!/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" {
|
||||
grep -qE '^Image=ghcr\.io/TecharoHQ/anubis:v[0-9]+\.[0-9]+\.[0-9]+$' "$UNIT"
|
||||
grep -q '^Pull=never$' "$UNIT"
|
||||
}
|
||||
|
||||
@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 and git smart-HTTP clients cannot solve challenges;
|
||||
# these vhosts bypass the chain by construction.
|
||||
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
|
||||
}
|
||||
@@ -124,9 +124,11 @@ CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile"
|
||||
@test "all vhosts use TLS except the legacy .casa name" {
|
||||
# franksdank.casa is not in the same Porkbun account, so it cannot get
|
||||
# a DNS-01 Let's Encrypt cert; it stays explicitly http. Every other
|
||||
# site address must carry no http:// prefix.
|
||||
# client-facing site address must carry no http:// prefix. The internal
|
||||
# listener (http://:8016) is not client-facing — Anubis dials it over
|
||||
# container DNS after verifying a client, so plain HTTP is intended.
|
||||
local offenders
|
||||
offenders=$(grep -E '^http://' "$CADDYFILE" | grep -v 'plex\.franksdank\.casa' || true)
|
||||
offenders=$(grep -E '^http://' "$CADDYFILE" | grep -v 'plex\.franksdank\.casa' | grep -v '^http://:8016' || true)
|
||||
[ -z "$offenders" ] || { echo "non-.casa http:// vhosts: $offenders"; return 1; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
#!/usr/bin/env bats
|
||||
# Seam: the homepage site block in dankclaw/Caddyfile. It serves /srv/homepage
|
||||
# from the naked domain (franksdank.cloud) and www. Authentication is via
|
||||
# Caddy forward_auth (Authentik SSO) — the basicauth hashes were retired in
|
||||
# favor of the unified Authentik login. caddy is not on the dev box, so the
|
||||
# Seam: the homepage site block in dankclaw/Caddyfile. Since the Anubis
|
||||
# rollout the naked domain + www are guarded vhosts: the public listener
|
||||
# proxies everything through Anubis (bot challenge), and the Authentik
|
||||
# forward_auth for SSO runs on Caddy's internal listener (http://:8016)
|
||||
# after Anubis has verified the client. caddy is not on the dev box, so the
|
||||
# tests assert the structure statically.
|
||||
|
||||
load helpers
|
||||
@@ -26,13 +27,19 @@ setup() {
|
||||
@test "the shared data + assets are served to any authenticated user" {
|
||||
# The data (services.json, status.json) and assets are fetched by both the
|
||||
# user page and the admin page. With forward_auth, any authenticated user
|
||||
# can access them (the browser's Authentik session covers the fetch).
|
||||
# can access them (the browser's Authentik session covers these fetches).
|
||||
grep -qE "@data[[:space:]]+path[[:space:]]+/services\.json[[:space:]]+/status\.json" "$CADDY"
|
||||
grep -qE "handle[[:space:]]+@data" "$CADDY"
|
||||
}
|
||||
|
||||
@test "the homepage vhost uses forward_auth (Authentik SSO)" {
|
||||
@test "the homepage vhost chains Anubis (public) before Authentik SSO (internal listener)" {
|
||||
# Public listener: the naked domain proxies everything through Anubis.
|
||||
local block
|
||||
block=$(awk '/^franksdank\.cloud[[:space:]]/,/^}/' "$CADDY")
|
||||
echo "$block" | grep -q 'forward_auth'
|
||||
echo "$block" | grep -q 'reverse_proxy anubis:8923'
|
||||
# The SSO check itself now runs on the internal listener that Anubis
|
||||
# forwards accepted traffic to; Host survives the hop, so per-vhost
|
||||
# routing (and with it forward_auth) resumes there.
|
||||
grep -q 'http://:8016' "$CADDY"
|
||||
awk '/^http:\/\/:8016/,/^\}/' "$CADDY" | grep -q 'forward_auth authentik-server:9000'
|
||||
}
|
||||
Reference in new issue
Block a user