From c3ec2309bb6342338487b197a6ff9392641549b4 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sun, 7 Jun 2026 03:07:34 +0300 Subject: [PATCH 1/2] feat(anvil): expose devnet RPC over public HTTPS via Caddy The anvil devnet was unreachable off-host: rpc-dev.decdn.org A-records straight to the droplet, but nftables default-deny opened no HTTP hole (Caddy bound 127.0.0.1:8080) and the documented Cloudflare tunnel was never stood up. A firewall change alone is a no-op since Caddy is loopback-bound, so this moves Caddy's listener public too. - Add caddy_public toggle (default true): Caddy serves rpc_hostname on 443 with auto-TLS (Let's Encrypt) + per-dev basic auth, reverse-proxying to the loopback anvil. Set false for the old loopback plain-HTTP mode (dev/CI or behind a tunnel). - Open tcp/80+443 for the anvil_devnet group via baseline_extra_inbound (new inventory/group_vars/anvil_devnet.yml), the same idiom decdn_nodes uses for udp/4433. 80 carries ACME HTTP-01 + the http->https redirect. - Pin molecule to caddy_public: false so CI keeps asserting the loopback bind and avoids a doomed public-ACME attempt in-container. - anvil itself stays loopback; only the auth-terminating Caddy proxy faces the internet. Update hard-rule #2, README, and CLAUDE.md accordingly. Verified: ansible-lint (production), molecule (converge/idempotence/verify), KICS (0 high/critical), galaxy build/check, and `make check-anvil` dry-run (shows the exact nftables + Caddyfile diffs). Live deploy intentionally deferred to an operator. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 10 +++-- ansible/README.md | 44 ++++++++++++++----- ansible/inventory/group_vars/anvil_devnet.yml | 17 +++++++ ansible/molecule/default/molecule.yml | 3 ++ ansible/roles/caddy/defaults/main.yml | 14 +++++- ansible/roles/caddy/templates/Caddyfile.j2 | 34 ++++++++++++-- 6 files changed, 102 insertions(+), 20 deletions(-) create mode 100644 ansible/inventory/group_vars/anvil_devnet.yml diff --git a/CLAUDE.md b/CLAUDE.md index a8bdf25..f9bc97c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,8 +22,10 @@ economic claims — those live in `decdn/adr/`. If something here states a proto and a dedicated owner. The repo ships `*.example` templates only. The root `.gitignore` is a backstop — do not rely on it; keep secrets out by design. 2. **Localhost-only by default.** Service daemons (anvil, etc.) bind `127.0.0.1`. The - *only* sanctioned public path is an explicit reverse proxy + tunnel with auth in - front. Never bind a backend to `0.0.0.0` or expose its raw port. + *only* sanctioned public path is an explicit reverse proxy with auth + TLS in front + (e.g. the anvil devnet's public-HTTPS Caddy, or that same proxy behind an outbound + tunnel). Never bind a *backend* to `0.0.0.0` or expose its raw port — only the + auth-terminating proxy faces the internet. 3. **`etc/` mirrors the target filesystem.** Put a config where it installs: `services//etc/systemd/system/foo.service` → `/etc/systemd/system/foo.service`. 4. **Scripts are idempotent and fail loud.** `set -euo pipefail`, re-runnable, refuse to @@ -60,7 +62,9 @@ secrets; generated on host) and #2 (localhost-only by default) hold for both con release tarball under a hardened systemd unit; public QUIC udp/4433, loopback metrics/admin, operator-provisioned eth keystore, required chain knobs (no baked protocol facts — sourced from ADRs). **Internal: the anvil devnet** (`playbooks/anvil.yml` → - baseline + anvil + Caddy basic-auth, loopback) — team tooling, not the product. Shared + baseline + anvil + Caddy basic-auth; anvil stays loopback while Caddy fronts it on public + https/443 with auto-TLS — `caddy_public: true`, default; flip to loopback-only for a tunnel) + — team tooling, not the product. Shared DevSec-hardened `baseline`. See `ansible/README.md`. (On-chain node stake/registration, ADR 019 Phase 2, is an operator step, not automated.) diff --git a/ansible/README.md b/ansible/README.md index 16e4d98..1d10011 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -6,7 +6,7 @@ baseline: | Playbook | Purpose | Exposure | |----------|---------|----------| | **`site.yml`** (primary) | A public **deCDN node** (`decdn-node`) — the product. | Public QUIC udp/4433 | -| `anvil.yml` (internal) | Our shared **anvil devnet** behind Caddy basic-auth. | Loopback (+ out-of-band tunnel) | +| `anvil.yml` (internal) | Our shared **anvil devnet** behind Caddy basic-auth. | Public HTTPS 443 (auto-TLS + basic auth); anvil stays loopback | ``` baseline host hardening — DevSec os/ssh, nftables default-deny inbound, @@ -15,14 +15,17 @@ baseline host hardening — DevSec os/ssh, nftables default-deny inbound, ├─ site.yml → decdn-node public QUIC udp/4433; metrics+admin loopback; │ release-tarball install; hardened systemd unit │ - └─ anvil.yml → anvil + caddy loopback EVM devnet + per-dev basic auth + └─ anvil.yml → anvil + caddy loopback EVM devnet; caddy fronts it on public + https/443 (auto-TLS) with per-dev basic auth ``` ## Security model - **Default-deny inbound (nftables).** SSH is the only universally-open port. The node host - additionally opens **udp/4433** (QUIC) via `baseline_extra_inbound`; everything else - (anvil 8545, caddy 8080, node metrics 9090, admin RPC 9191) stays **loopback** with no hole. + additionally opens **udp/4433** (QUIC); the anvil host opens **tcp/80+443** for the public + caddy reverse proxy — both via `baseline_extra_inbound`. Everything behind the proxy + (anvil 8545, node metrics 9090, admin RPC 9191) stays **loopback** with no hole; only caddy + faces the internet, and only after per-dev basic auth over TLS. - **No secrets in the repo.** anvil's mnemonic + caddy basic-auth are **generated on the host** (stat-guarded, `no_log`, revealed once). The node's eth keystore is **operator-provisioned** and never generated here; its `rpc_url` (which may embed an API @@ -95,13 +98,15 @@ with no turnkey CLI yet (see `roles/decdn_node/README.md`). ```bash make check-anvil -make deploy-anvil # baseline -> anvil -> caddy (loopback) +make deploy-anvil # baseline -> anvil -> caddy (public https) make add-dev USER_NAME=alice # mint + reveal a basic-auth dev user ``` On the first anvil deploy the shared **mnemonic** and the `dev` basic-auth password are -printed **once** — save them to the team vault. anvil/caddy bind `127.0.0.1` only; attach a -public path (Cloudflare Tunnel) out-of-band — see the appendix. +printed **once** — save them to the team vault. anvil binds `127.0.0.1` only; **caddy fronts +it on public https/443** with auto-TLS + per-dev basic auth (`caddy_public: true`, default), +so the DNS A record must already point at the host. To keep it loopback-only instead (e.g. +behind a tunnel) set `caddy_public: false` — see the appendix. --- @@ -167,16 +172,31 @@ never publishes. --- -## Appendix — public path for the anvil devnet (Cloudflare Tunnel, manual) +## Appendix — public path for the anvil devnet -Out of scope for the playbook (browser SSO can't be scripted). Expose the loopback caddy -listener via an outbound tunnel — no inbound ports opened: +### Default: direct HTTPS (`caddy_public: true`) + +The playbook exposes the RPC itself. Caddy serves `rpc_hostname` (rpc-dev.decdn.org) on +**https/443** with an auto-provisioned Let's Encrypt cert and per-dev basic auth, reverse- +proxying to the loopback anvil. The `anvil_devnet` group opens tcp/80+443 +(`inventory/group_vars/anvil_devnet.yml`); anvil stays on `127.0.0.1`. Requirements: the DNS +A/AAAA record for `rpc_hostname` already points at the host, and tcp/80+443 reach it (80 for +the ACME HTTP-01 challenge + the http→https redirect). Verify after deploy: + +```bash +curl -s -u dev:'' https://rpc-dev.decdn.org \ + -d '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' # -> 0x7a69 (31337) +``` + +### Alternative: loopback + Cloudflare Tunnel (`caddy_public: false`) + +To hide the origin IP / avoid opening inbound ports, set `caddy_public: false` (caddy reverts +to loopback plain-HTTP on `caddy_bind_port`) and bridge it with an outbound tunnel. This part +is out of scope for the playbook (browser SSO can't be scripted): ```bash cloudflared tunnel login cloudflared tunnel create rpc-dev # ingress -> http://127.0.0.1:8080 cloudflared tunnel route dns rpc-dev rpc-dev.decdn.org sudo systemctl enable --now cloudflared -curl -s -u dev:'' https://rpc-dev.decdn.org \ - -d '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' # -> 0x7a69 (31337) ``` diff --git a/ansible/inventory/group_vars/anvil_devnet.yml b/ansible/inventory/group_vars/anvil_devnet.yml new file mode 100644 index 0000000..c5837f4 --- /dev/null +++ b/ansible/inventory/group_vars/anvil_devnet.yml @@ -0,0 +1,17 @@ +--- +# Shared config for the internal anvil devnet host(s) (playbooks/anvil.yml). + +# Public HTTPS exposure for the anvil devnet RPC. Caddy serves rpc-dev.decdn.org +# on 443 (auto-TLS) with per-dev basic auth; anvil itself stays loopback. 80 +# carries the ACME HTTP-01 challenge and the HTTP->HTTPS redirect. This pairs with +# the caddy role's caddy_public mode (default true); flip both to go loopback-only. +baseline_extra_inbound: + - proto: tcp + port: "80" + comment: "caddy ACME + http->https redirect" + - proto: tcp + port: "443" + comment: "caddy public RPC (rpc-dev)" + +# Optional Let's Encrypt account contact (used by the caddy role's global block): +# caddy_acme_email: ops@decdn.org diff --git a/ansible/molecule/default/molecule.yml b/ansible/molecule/default/molecule.yml index 54097f9..23d68c1 100644 --- a/ansible/molecule/default/molecule.yml +++ b/ansible/molecule/default/molecule.yml @@ -29,6 +29,9 @@ provisioner: # converge.yml runs only anvil + caddy — baseline (and its admin-user / # ssh_hardening logic) is never exercised here, so no ssh_admin_* is needed. foundry_version: latest + # Loopback plain-HTTP caddy: a container can't satisfy public ACME, and + # verify.yml asserts the loopback bind. Production defaults to caddy_public: true. + caddy_public: false verifier: name: ansible scenario: diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml index 4b86a41..0c7f76c 100644 --- a/ansible/roles/caddy/defaults/main.yml +++ b/ansible/roles/caddy/defaults/main.yml @@ -1,8 +1,18 @@ --- # caddy role defaults. Override in inventory/group_vars for a real deployment. -caddy_bind_port: 8080 # loopback listener; public path bolted on later -rpc_hostname: rpc-dev.decdn.org # used in the printed ETH_RPC_URL +# Exposure mode: +# true -> public HTTPS listener on rpc_hostname:443 with auto-TLS (Let's Encrypt) +# + per-dev basic auth, reverse-proxying to the loopback anvil. Requires +# inbound tcp/80+443 (open them via baseline_extra_inbound; see +# inventory/group_vars/anvil_devnet.yml). +# false -> loopback plain-HTTP listener on caddy_bind_port, for dev/CI (molecule) +# or when TLS is terminated upstream by a tunnel/edge. +caddy_public: true +caddy_acme_email: "" # "" -> anonymous ACME; set for an LE account contact + +caddy_bind_port: 8080 # loopback listener; only used when caddy_public is false +rpc_hostname: rpc-dev.decdn.org # public HTTPS host (caddy_public) + printed in ETH_RPC_URL # Upstream anvil RPC to proxy to (kept in sync with the anvil role defaults). anvil_host: "127.0.0.1" diff --git a/ansible/roles/caddy/templates/Caddyfile.j2 b/ansible/roles/caddy/templates/Caddyfile.j2 index 3a277d7..c2184ff 100644 --- a/ansible/roles/caddy/templates/Caddyfile.j2 +++ b/ansible/roles/caddy/templates/Caddyfile.j2 @@ -1,8 +1,35 @@ # MANAGED BY the caddy role — do not edit by hand. # -# Caddy's only job: per-dev HTTP basic auth + reverse proxy to the local anvil -# RPC. TLS is terminated upstream (at the future tunnel/edge), so Caddy serves -# plain HTTP on loopback only. +# Caddy's job: per-dev HTTP basic auth + reverse proxy to the local anvil RPC. +# The listener shape depends on caddy_public: +# true -> public HTTPS on {{ rpc_hostname }}, Caddy terminates TLS (auto-cert). +# false -> plain HTTP on loopback :{{ caddy_bind_port }} (dev/CI; TLS upstream). +{% if caddy_public %} +{ + # No admin API surface needed; auto_https stays ON so Caddy provisions and + # renews the cert for the site address below. + admin off +{% if caddy_acme_email | length > 0 %} + email {{ caddy_acme_email }} +{% endif %} +} + +# Public HTTPS listener. Caddy provisions a Let's Encrypt cert for the host +# (HTTP-01 on :80 / TLS-ALPN-01 on :443) and redirects :80 -> :443. The firewall +# must allow inbound tcp/80+443 (baseline_extra_inbound). anvil stays loopback. +{{ rpc_hostname }} { + # Per-dev HTTP basic auth. Credentials are NOT in this repo: the block below + # is generated on the host into {{ caddy_basicauth_file }} from the TSV + # registry ({{ caddy_users_tsv }}) by this role. + import {{ caddy_basicauth_file }} + + # Forward authenticated requests to the local anvil JSON-RPC. + reverse_proxy {{ anvil_host }}:{{ anvil_port }} + + # Access logging to the journal (auditing who hit the RPC). + log +} +{% else %} { # No upstream TLS to provision here, and no admin API surface needed. auto_https off @@ -25,3 +52,4 @@ # Access logging to the journal (auditing who hit the RPC). log } +{% endif %} From 6fb3651bb8cd01ba41b76d76bb89468e7f01dd63 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sun, 7 Jun 2026 03:12:14 +0300 Subject: [PATCH 2/2] fix(caddy): guard caddy_acme_email with default('') in Caddyfile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `caddy_acme_email | length > 0` errors under StrictUndefined or when the var is set to null/None in group_vars. `| default('')` absorbs both and relies on empty-string falsiness — verified across empty/None/set/undefined. Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/roles/caddy/templates/Caddyfile.j2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ansible/roles/caddy/templates/Caddyfile.j2 b/ansible/roles/caddy/templates/Caddyfile.j2 index c2184ff..6c30a4c 100644 --- a/ansible/roles/caddy/templates/Caddyfile.j2 +++ b/ansible/roles/caddy/templates/Caddyfile.j2 @@ -9,7 +9,7 @@ # No admin API surface needed; auto_https stays ON so Caddy provisions and # renews the cert for the site address below. admin off -{% if caddy_acme_email | length > 0 %} +{% if caddy_acme_email | default('') %} email {{ caddy_acme_email }} {% endif %} }