feat(caddy): fail2ban jail to ban repeated RPC basic-auth failures - #10
Conversation
The anvil devnet RPC sits on public HTTPS behind Caddy basic auth, but failed credentials (HTTP 401) were only logged to the journal — nothing rate-limited or banned credential-stuffing. Switch the Caddy access log to a self-rolling JSON file (iso8601 ts) and add a fail2ban `caddy-rpc` jail that watches it and bans, via nftables, any IP that repeatedly fails basic auth. Lenient default: 5 failures / 10m -> 1h ban (caddy_fail2ban_* knobs). The jail is gated on the public listener (caddy_public) — loopback/CI has no attack surface and runs without baseline (where fail2ban ships). Fail loud on the public path, which molecule never exercises: assert fail2ban is installed before wiring the jail, and verify the jail loaded (fail2ban-client status) after restart. Pre-create the log file owned by caddy so `caddy validate` (run as root) doesn't leave it root-owned and crash the daemon. datepattern uses `%%` (fail2ban config-parser escaping; a bare `%` raises InterpolationSyntaxError and the filter won't load). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a fail2ban brute-force protection jail (caddy-rpc) for the public Caddy reverse proxy in front of the Anvil devnet. It configures Caddy to output JSON access logs to a file using ISO8601 timestamps, sets up fail2ban filters and jails to monitor these logs for 401 unauthorized errors, and adds validation tasks to ensure fail2ban is installed and the jail is loaded successfully. The review feedback highlights two critical issues in ansible/roles/caddy/tasks/main.yml: first, the failregex in the fail2ban filter is fragile and vulnerable to log injection due to relying on strict JSON field order and missing root-level key verification; second, the datepattern lacks support for the UTC timezone indicator (Z) and fractional seconds, which can cause timezone mismatches and silently break the jail's findtime window. Both comments provide robust regex and pattern suggestions to resolve these issues.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
There was a problem hiding this comment.
Pull request overview
This PR adds brute-force protection for the publicly exposed Anvil devnet RPC endpoint by introducing a fail2ban jail that detects repeated HTTP 401 basic-auth failures from Caddy’s access logs and bans offending IPs via nftables.
Changes:
- Switch Caddy RPC access logging from journald to a self-rolling JSON access log file suitable for fail2ban parsing.
- Add a
caddy-rpcfail2ban filter + jail (gated oncaddy_publicandcaddy_fail2ban), restart handler, and a post-restart verification that the jail actually loaded. - Document the caddy role behavior and newly introduced fail2ban variables.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ansible/roles/caddy/templates/Caddyfile.j2 | Changes access logging to a JSON file with ISO8601 timestamps for fail2ban parsing. |
| ansible/roles/caddy/tasks/main.yml | Creates log directory/file with correct ownership; installs fail2ban filter/jail and verifies the jail is loaded. |
| ansible/roles/caddy/handlers/main.yml | Adds a check-mode-safe handler to restart fail2ban when configs change. |
| ansible/roles/caddy/defaults/main.yml | Introduces defaults for access-log path and fail2ban jail tuning knobs. |
| ansible/roles/caddy/README.md | Documents the role and the new fail2ban-based brute-force protection behavior. |
| ansible/README.md | Mentions the new public-path fail2ban ban behavior and variables. |
| AGENTS.md | Notes that repeated basic-auth failures are banned via the caddy-rpc jail. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…TC-aware Address automated review (gemini-code-assist, copilot) on the caddy-rpc filter: - failregex no longer depends on JSON field order. Caddy/zap may reorder keys across versions, which would silently stop the jail from banning. Switch to order-independent lookaheads, each with an unescaped-quote lookbehind `(?<!\\)` so an injected `"status":401` inside an escaped header/URI value (logged as `\"status\":401`) can't trigger a false ban. Dropped the logger assertion as redundant — the jail's logpath is Caddy's dedicated access-log file. - datepattern now consumes optional fractional seconds and the timezone. Uses `%%z` (not a literal `Z`): a literal Z only consumes the char and leaves the time parsed in host-local zone, silently skewing findtime on a non-UTC host; `%%z` parses Z as UTC. Verified with Python strptime (literal-Z skews 4h under America/New_York; %z stays UTC). Verified end-to-end with fail2ban-regex on the ansible-rendered filter: matches normal- and reordered-field 401s, rejects 200 and the injection line, captures the IP, parses the date UTC-aware. make lint-ansible + molecule (idempotence, verify) green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Context
The anvil devnet RPC is exposed on public HTTPS (
rpc-dev.decdn.org:443) behind Caddy per-dev basic auth. A failed/missing credential returned HTTP 401 but nothing rate-limited or banned — a public endpoint with basic auth invites credential-stuffing. fail2ban was already installed (baseline role) with only ansshdjail.What this does
Adds a fail2ban
caddy-rpcjail that watches Caddy's access log and bans (via nftables) any IP that repeatedly fails RPC basic auth. Implemented in the caddy role (co-located with the auth + log it protects; keeps the publicdecdn.nodecollection'sbaselinerole generic).Caddyfile.j2switches the barelog(journal) to a self-rolling JSON file (/var/log/caddy/rpc-access.log, iso8601 timestamps) that fail2ban parses.caddy_public and caddy_fail2ban;banaction = nftables-multiportmatching the host firewall, drops 80+443. Lenient default: 5 failures / 10m → 1h ban (all overridable viacaddy_fail2ban_*).Restart fail2ban.roles/caddy/README.md+ one-line mentions inansible/README.mdandAGENTS.md.Bug fixes found during verification / review
caddy validate(run as root) created the log file root-owned, so thecaddydaemon couldn't open it → crash / non-idempotent. Fixed by pre-creating the file ascaddy:caddybefore validate.%→ fail2ban'sconfigparserraisesInterpolationSyntaxErrorand the filter never loads (the jail would be silently dead). Confirmed withfail2ban-regex; fixed to%%.Fail-loud guards (the public path has no CI coverage — molecule runs
caddy_public: false)--tags caddyrun that skipped baseline).fail2ban-client status caddy-rpc) after restart, so a malformed filter fails loud at deploy instead of silently never banning.Verification
fail2ban-regexagainst real Caddy iso8601 401 lines: filter loads, date parses, 401 matches + IP captured, 200 correctly missed.make lint-ansible(production profile): 0 failures ·make lint(markdown/shellcheck/yaml): pass ·make security(KICS): no findings in changed files ·make build/galaxy-check: collection still builds.caddy_public: false, idempotencechanged=0, verify "All assertions passed".Notes
journalctl -u caddyto the JSON file; runtime logs stay in the journal.flush rulesetcaveat (a baseline nftables reload clears fail2ban's table until its next restart — affects thesshdjail too) is documented in the role README as a follow-up.caddy_public: trueMolecule scenario would cover the public path (larger effort — needs ACME/TLS in a container).🤖 Generated with Claude Code