# Anubis — bot challenge proxy Anubis is the bot-challenge proxy that fronts the guarded public vhosts on Dankclaw. Browsers get a proof-of-work challenge; AI scraper user-agents are denied outright. It sits in a two-tier chain: client → Caddy TLS listener → Anubis (`:8923`, internal-only) → Caddy internal listener (`:8016`) → app. Host headers survive both hops, so per-vhost routing (and Authentik `forward_auth` for the wrapped apps) resumes unchanged behind it. See [[Caddy]] and [[Authentik]]. ```mermaid graph LR C[Client] --> T[Caddy TLS listener] T --> A[Anubis verify :8923] A -->|accepted| I[Caddy internal listener :8016] A -->|challenge or deny| C I --> P[per-vhost routing and forward_auth] ``` ## Facts (live inventory) | Item | Value | |------|-------| | Runtime | Quadlet unit `anubis.service` (`dankclaw/anubis/quadlet/anubis.container`) — boot-persistent, self-healing | | Image | `ghcr.io/techarohq/anubis:v1.13.0`, run under a `localhost/` alias tag (see gotcha 1) | | Ports | `:8923` internal (no published port); metrics on `:9090` reachable only inside `dankclaw_default` | | Config surface | flags via flagenv — `TARGET=http://caddy:8016` sets `--target`, `POLICY_FNAME=/policy/policy.json` sets `--policy-fname` | | Policy | `dankclaw/anubis/policy.json` mounted read-only into the container; parsed at process start only (no reload signal exists — every policy change needs a unit restart) | | Store | memory (no signing keys needed); a restart invalidates issued challenge JWTs — users simply re-challenge once | Policy content: DENY for AI-scaper user-agent regexes (GPTBot, Claude-*, Perplexity*, CCBot, Bytespider, Amazonbot, meta-externalagent, and friends), CHALLENGE for everything else (`path_regex: "."` catch-all), DNSBL off. Policy schema at this tag is JSON-only — no `(data)/` imports and no CEL; first match wins. Upgrading the pin = stop the unit, pull + re-tag the `localhost/` alias, restart; re-verify the policy schema against the new tag first. ## Exempt by construction These never route through Anubis (no policy bypass rule needed): plex (plain-HTTP legacy), auth.franksdank.cloud (the IdP login itself must stay ungated), and gitea (git smart-HTTP clients cannot solve JS challenges — `/info/refs` and `git-*-pack` paths pass unchallenged). ## Gotchas (all verified live 2026-08-28) 1. **Image ref casing.** Podman's reference parser rejects mixed-case repository refs before any network I/O (`repository name must be lowercase`). All refs use the lowercase `ghcr.io/techarohq/anubis` form — ghcr resolves case-insensitively at pull time. 2. **Quadlet + registry-prefixed refs.** On this host's podman 5.7 quadlet path, a container created from a registry-prefixed image ref fails with `image not known` even when the image is present in storage; the identical argv works from a shell or a transient unit. The unit therefore runs the image under a `localhost/techarohq/anubis:v1.13.0` alias tag — deploy pulls the canonical ref, then `podman tag`s the alias (idempotent). 3. **Health probe exec form.** The image is a distroless ko-build (no shell; entrypoint `/ko-app/anubis`). This quadlet generator joins `HealthCmd` into one `\x20`-escaped argv element and podman then runs it shell-form — exec of a missing shell exits 1 and the container sits unhealthy. A JSON exec-form array (`HealthCmd=["/ko-app/anubis","--healthcheck"]`) passes through the generator verbatim and executes the binary directly. 4. **Rollback nuance.** `systemctl --user stop anubis.service` makes every guarded vhost return 502 (the chain is reverse-proxy-THROUGH Anubis, not forward_auth — Caddy's public listeners proxy to `:8923`). A true rollback is a Caddyfile revert + redeploy of the caddy service. 5. **Deploy handler pull guard.** `deploy_anubis` guards the pull with `podman image exists`; a cold anonymous ghcr token request can 403 on the first fetch — retry once, the auth cache makes the second attempt local. ## Verify live ``` ssh dankclaw 'systemctl --user is-active anubis.service' curl -s -o /dev/null -w "%{http_code}\n" -A "GPTBot" https://franksdank.cloud/ # denied page curl -s -o /dev/null -w "%{http_code}\n" -A "Mozilla/5.0" https://franksdank.cloud/ # challenge 200 ```