diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index fa540e8..2a7f68e 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -555,7 +555,7 @@ deploy_anubis() { # The image lives on ghcr.io; pull once, then the unit runs it from # local storage (Pull=never keeps boots off the registry). if ! podman image exists ghcr.io/techarohq/anubis:v1.13.0; then - podman pull ghcr.io/techaroq/anubis:v1.13.0 + podman pull ghcr.io/techarohq/anubis:v1.13.0 fi # The unit runs the localhost/ alias (see the unit file comment): this # host's podman quadlet path cannot resolve registry-prefixed refs from diff --git a/serverAppConfigs-docs/Infrastructure/Anubis.md b/serverAppConfigs-docs/Infrastructure/Anubis.md new file mode 100644 index 0000000..06cdb3e --- /dev/null +++ b/serverAppConfigs-docs/Infrastructure/Anubis.md @@ -0,0 +1,77 @@ +# 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 +``` diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index aed7604..28a52f2 100644 --- a/serverAppConfigs-docs/index.md +++ b/serverAppConfigs-docs/index.md @@ -71,6 +71,7 @@ covers both workloads). - [[LAN-DNS]] — name resolution, Pi-hole, router DNS hand-off - [[Pi-hole]] — the LAN DNS server (quadlet, records, web UI) - [[Caddy]] — reverse proxy and TLS +- [[Anubis]] — bot challenge proxy fronting the guarded vhosts (chain, policy, gotchas) - [[Authentik]] — the homelab's SSO / OIDC provider (forward_auth) - [[authentik-postgres]] — Authentik database service - [[authentik-server]] — Authentik server + Caddy outpost service diff --git a/tests/anubis.bats b/tests/anubis.bats index d9367c7..7589fbb 100755 --- a/tests/anubis.bats +++ b/tests/anubis.bats @@ -23,8 +23,14 @@ setup() { } @test "the image is pinned to an exact upstream tag and never pulled at boot" { - grep -qE '^Image=ghcr\.io/techaroq/anubis:v[0-9]+\.[0-9]+\.[0-9]+$' "$UNIT" + # 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" {