Meta-header routing and forward_auth removed from the described design — both disproved live. Documents: Host-based provider routing with per-guarded-host proxy providers (lockstep rule), the null-flows wedge that starves the embedded outpost config fetch, the Anubis /outpost.goauthentik.io/* transport exemption, the single-file bind-mount stale-policy inode gotcha, Invalid next URL as a dead design, and the public-DNS record requirement per guarded host.
6.7 KiB
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 the Authentik embedded outpost can route
on the original Host behind it. See Caddy and Authentik.
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[whole-request proxy to Authentik outpost]
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: an ALLOW rule for Anubis's own endpoints (the
/.within.website/x/cmd/anubis/* API and static namespace — the challenge
JS calls make-challenge/pass-challenge under that prefix, and challenging
those endpoints makes the challenge challenge itself), an ALLOW rule for
the Authentik transport prefix ^/outpost\.goauthentik\.io/ (login, start,
callback and the outpost's static assets must be reachable before any
session or challenge dance completes — authentik's own session gate protects
them), then DENY for AI-scraper user-agent regexes (GPTBot, Claude-,
Perplexity, CCBot, Bytespider, Amazonbot, meta-externalagent, and friends),
then CHALLENGE for everything else (path_regex: "." catch-all). DNSBL off.
Valid action verbs
at this tag are ALLOW, CHALLENGE, DENY — ACCEPT is not one of them
(first deploy of that rule crash-looped the unit on exactly that typo;
verified against the image binary's string table). The 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; 1–6 on 2026-08-28, 7 on 2026-08-30)
- 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 lowercaseghcr.io/techarohq/anubisform — ghcr resolves case-insensitively at pull time. - 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 knowneven 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 alocalhost/techarohq/anubis:v1.13.0alias tag — deploy pulls the canonical ref, thenpodman tags the alias (idempotent). - Health probe exec form. The image is a distroless ko-build (no shell;
entrypoint
/ko-app/anubis). This quadlet generator joinsHealthCmdinto 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. - Rollback nuance.
systemctl --user stop anubis.servicemakes 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. - Deploy handler pull guard.
deploy_anubisguards the pull withpodman image exists; a cold anonymous ghcr token request can 403 on the first fetch — retry once, the auth cache makes the second attempt local. - JS-capable clients are the canary. Non-JS clients (curl, bots,
scrapers) never solve challenges — they just receive the challenge page,
so every curl probe looks green while the verification step is broken.
The first JS-capable client to traverse a freshly deployed chain is the
first real test of it: browsers POST-solve via
make-challengeand GET-navigatepass-challenge?response=<sha256(challenge+nonce)>&nonce=N &elapsedTime=T&redir=<path>; if any own-endpoint path is challenged instead of allowed, the browser's fetch throws and it renders its own error page (iOS/macOS browsers phrase it as "the request failed and the interceptors did not return an alternative response") instead of the login flow. Verify with a JS-capable client end-to-end — mint viamake-challenge, solve, submit with browser-exact query params — before declaring a challenge chain healthy. - Single-file bind mount + git pull = stale policy. The policy is
mounted file-at-a-time (
/home/dank/serverAppConfigs/dankclaw/anubis/ policy.json:/policy/policy.json:ro); bind mounts pin the INODE. Agit pull/checkout that replaces the file leaves the running container reading the old inode indefinitely (observed live: 22 h of uptime serving a policy already superseded on disk, with no reload log line because there is nothing watching). A policy change only takes effect when the anubis container is (re)started —./scripts/deploy.sh dankclaw --service anubis— and the deployer must therefore always restart it rather than trust the file contents on disk.
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
curl -s https://search.franksdank.cloud/outpost.goauthentik.io/login | grep -q Making\ sure && echo BROKEN-still-challenged || echo outpost-transport-exempt