docs: Anubis v1.27.0 — domain-wide cookie, persistent key, new gotchas
- Facts table: image pin, COOKIE_DOMAIN, persistent ED25519 key via host env file; restarts no longer invalidate solved challenges. - Gotcha 8: X-Real-Ip mandatory at v1.27+ (500 without; chain sets it). - Gotcha 9: DENY renders error-styled page at HTTP 200 — probe bodies, not statuses. - Verify live: probe www (apex 308s), version-marker grep, body-based deny check. All commands executed live before commit.
This commit is contained in:
@@ -5,7 +5,11 @@ 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]].
|
||||
on the original `Host` behind it. Since v1.27.0 the challenge cookie is
|
||||
domain-wide (`COOKIE_DOMAIN=franksdank.cloud`): a browser solves the
|
||||
proof-of-work once on any guarded vhost and every guarded
|
||||
`*.franksdank.cloud` vhost honors it until the 168h expiry or an authentik
|
||||
re-login. See [[Caddy]] and [[Authentik]].
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
@@ -21,11 +25,11 @@ graph LR
|
||||
| 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) |
|
||||
| Image | `ghcr.io/techarohq/anubis:v1.27.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` |
|
||||
| Config surface | flags via flagenv — `TARGET=http://caddy:8016` sets `--target`, `POLICY_FNAME=/policy/policy.json` sets `--policy-fname`, `COOKIE_DOMAIN=franksdank.cloud` sets `--cookie-domain` |
|
||||
| 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 |
|
||||
| Store | memory JWTs signed by a **persistent** ED25519 key (`ED25519_PRIVATE_KEY_HEX`, host file `/home/dank/Containers/anubis/env`, mode 0600, gitea-style `${VAR}` expansion) — restarts keep every solved challenge valid; with no key set Anubis mints an ephemeral one per boot |
|
||||
|
||||
Policy content: an `ALLOW` rule for Anubis's own endpoints (the
|
||||
`/.within.website/x/cmd/anubis/*` API and static namespace — the challenge
|
||||
@@ -41,9 +45,11 @@ 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.
|
||||
tag is JSON-only — no `(data)/` imports and no CEL; first match wins (JSON
|
||||
still loads at v1.27.0 — scratch-verified 2026-09-02 before the pin bump).
|
||||
Upgrading the pin = pull + re-tag the `localhost/` alias, restart; verify
|
||||
the flag surface with `podman run --rm ... --help` and the policy against
|
||||
the new tag first (done for v1.27.0).
|
||||
|
||||
## Exempt by construction
|
||||
|
||||
@@ -52,7 +58,7 @@ These never route through Anubis (no policy bypass rule needed): plex
|
||||
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)
|
||||
## Gotchas (all verified live; 1–6 on 2026-08-28, 7 on 2026-08-30, 8–9 on 2026-09-02)
|
||||
|
||||
1. **Image ref casing.** Podman's reference parser rejects mixed-case
|
||||
repository refs before any network I/O (`repository name must be
|
||||
@@ -101,12 +107,32 @@ ungated), and gitea (git smart-HTTP clients cannot solve JS challenges —
|
||||
--service anubis` — and the deployer must therefore always restart it
|
||||
rather than trust the file contents on disk.
|
||||
|
||||
8. **X-Real-Ip is mandatory at v1.27+ (HTTP 500 without it).** Anubis now
|
||||
answers any request lacking `X-Real-Ip` with a 500 whose log line reads
|
||||
`[misconfiguration] X-Real-Ip header is not set`. The real chain always
|
||||
sets it (every guarded Caddy listener carries `header_up X-Real-Ip
|
||||
{remote_host}` — the bats seam enforces the line), so users never see
|
||||
this. But any direct probe of `:8923` (scratch container, curl, a new
|
||||
reverse-proxy hop) MUST set the header or it looks like a policy failure.
|
||||
Scratch-verify with `-H "X-Real-Ip: 192.168.0.20"`.
|
||||
9. **DENY renders as an error-styled page at HTTP 200.** At v1.27 the denied
|
||||
client gets the "Oh noes!" template containing `Access Denied` and
|
||||
`error code <rule-id>` — with status **200**, not 403 (the boot log maps
|
||||
rule names to error ids: `rule-error-ids`). Status-only probes cannot
|
||||
distinguish challenge from deny at this release; grep the body for
|
||||
`Access Denied` / `Making sure you` instead.
|
||||
|
||||
## 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
|
||||
podman inspect anubis --format "{{.ImageName}} {{.State.Health.Status}}"
|
||||
# the apex 308s to www; the guarded vhost is www — probe www, not the apex
|
||||
curl -s -o /dev/null -w "%{http_code} %{redirect_url}\n" -A "Mozilla/5.0" https://franksdank.cloud/
|
||||
# challenge: body carries the version marker + PoW page
|
||||
curl -s -A "Mozilla/5.0" https://www.franksdank.cloud/ | grep -oE "cachebuster=v[0-9.]+|Making sure you.re not a bot"
|
||||
# deny: HTTP 200 at v1.27+ — grep the body, not the status (gotcha 9)
|
||||
curl -s -A "GPTBot" https://www.franksdank.cloud/ | grep -q "Access Denied" && echo denied || echo BROKEN-challenge-served
|
||||
# outpost transport exempt (ALLOW rule)
|
||||
curl -s https://search.franksdank.cloud/outpost.goauthentik.io/login | grep -q Making\ sure && echo BROKEN-still-challenged || echo outpost-transport-exempt
|
||||
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user