Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<svc>/etc/systemd/system/foo.service` → `/etc/systemd/system/foo.service`.
4. **Scripts are idempotent and fail loud.** `set -euo pipefail`, re-runnable, refuse to
Expand Down Expand Up @@ -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.)

Expand Down
44 changes: 32 additions & 12 deletions ansible/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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:'<password>' 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:'<password>' https://rpc-dev.decdn.org \
-d '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' # -> 0x7a69 (31337)
```
17 changes: 17 additions & 0 deletions ansible/inventory/group_vars/anvil_devnet.yml
Original file line number Diff line number Diff line change
@@ -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)"
Comment thread
thiras marked this conversation as resolved.

# Optional Let's Encrypt account contact (used by the caddy role's global block):
# caddy_acme_email: ops@decdn.org
3 changes: 3 additions & 0 deletions ansible/molecule/default/molecule.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 12 additions & 2 deletions ansible/roles/caddy/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
34 changes: 31 additions & 3 deletions ansible/roles/caddy/templates/Caddyfile.j2
Original file line number Diff line number Diff line change
@@ -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 | default('') %}
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
Expand All @@ -25,3 +52,4 @@
# Access logging to the journal (auditing who hit the RPC).
log
}
{% endif %}
Loading