#!/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 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 }