From c741cd464547bfde9b4cb38090eea89357311451 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sat, 11 Jul 2026 19:13:03 +0300 Subject: [PATCH 1/2] chore(ansible): remove anvil devnet support completely Excise the internal anvil EVM devnet and everything that existed only to serve it, leaving a single-purpose deCDN-node deployment project. Delete: the anvil, caddy, and contracts roles; the anvil.yml and add-dev-user.yml playbooks; the molecule/ test tree and its CI workflow; the anvil_devnet inventory group; and community.general from requirements.yml (unused by the kept baseline/decdn_node roles). Edit: strip anvil-specific Makefile targets, gitignore blocks, and CI syntax-check entries; rewrite docs and inline comments to describe the single node deployment. The node's own admin RPC (9191) and metrics (9090) are unrelated to anvil and are preserved throughout. Verified: yamllint, ansible-lint (production profile), site.yml syntax-check, make build, and galaxy-importer all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 9 +- .github/workflows/molecule.yml | 39 ---- .gitignore | 23 +- .pre-commit-config.yaml | 8 +- AGENTS.md | 80 +++---- CONTRIBUTING.md | 17 +- Makefile | 5 +- README.md | 55 ++--- ansible/.ansible-lint | 14 +- ansible/.gitignore | 3 +- ansible/Makefile | 18 +- ansible/README.md | 102 ++------- ansible/ansible.cfg | 4 +- ansible/galaxy/README.md | 3 - ansible/galaxy/build.sh | 7 +- ansible/galaxy/galaxy.yml | 12 +- ansible/inventory/group_vars/all.yml | 10 +- ansible/inventory/group_vars/anvil_devnet.yml | 26 --- ansible/inventory/hosts.yml.example | 9 - ansible/molecule/default/converge.yml | 8 - ansible/molecule/default/molecule.yml | 44 ---- ansible/molecule/default/verify.yml | 50 ----- ansible/playbooks/add-dev-user.yml | 13 -- ansible/playbooks/anvil.yml | 15 -- ansible/requirements.yml | 2 - ansible/roles/anvil/defaults/main.yml | 41 ---- ansible/roles/anvil/handlers/main.yml | 12 -- ansible/roles/anvil/meta/main.yml | 14 -- ansible/roles/anvil/tasks/main.yml | 199 ------------------ .../anvil/templates/anvil-state-guard.sh.j2 | 45 ---- .../roles/anvil/templates/anvil.service.j2 | 94 --------- ansible/roles/baseline/README.md | 8 +- ansible/roles/baseline/handlers/main.yml | 2 +- .../roles/baseline/templates/nftables.conf.j2 | 2 +- ansible/roles/caddy/README.md | 81 ------- ansible/roles/caddy/defaults/main.yml | 38 ---- ansible/roles/caddy/handlers/main.yml | 18 -- ansible/roles/caddy/meta/main.yml | 14 -- ansible/roles/caddy/tasks/add_user.yml | 62 ------ ansible/roles/caddy/tasks/install.yml | 23 -- ansible/roles/caddy/tasks/main.yml | 197 ----------------- ansible/roles/caddy/templates/Caddyfile.j2 | 77 ------- ansible/roles/contracts/README.md | 17 -- ansible/roles/contracts/tasks/main.yml | 8 - ansible/roles/decdn_node/README.md | 4 +- 45 files changed, 118 insertions(+), 1414 deletions(-) delete mode 100644 .github/workflows/molecule.yml delete mode 100644 ansible/inventory/group_vars/anvil_devnet.yml delete mode 100644 ansible/molecule/default/converge.yml delete mode 100644 ansible/molecule/default/molecule.yml delete mode 100644 ansible/molecule/default/verify.yml delete mode 100644 ansible/playbooks/add-dev-user.yml delete mode 100644 ansible/playbooks/anvil.yml delete mode 100644 ansible/roles/anvil/defaults/main.yml delete mode 100644 ansible/roles/anvil/handlers/main.yml delete mode 100644 ansible/roles/anvil/meta/main.yml delete mode 100644 ansible/roles/anvil/tasks/main.yml delete mode 100644 ansible/roles/anvil/templates/anvil-state-guard.sh.j2 delete mode 100644 ansible/roles/anvil/templates/anvil.service.j2 delete mode 100644 ansible/roles/caddy/README.md delete mode 100644 ansible/roles/caddy/defaults/main.yml delete mode 100644 ansible/roles/caddy/handlers/main.yml delete mode 100644 ansible/roles/caddy/meta/main.yml delete mode 100644 ansible/roles/caddy/tasks/add_user.yml delete mode 100644 ansible/roles/caddy/tasks/install.yml delete mode 100644 ansible/roles/caddy/tasks/main.yml delete mode 100644 ansible/roles/caddy/templates/Caddyfile.j2 delete mode 100644 ansible/roles/contracts/README.md delete mode 100644 ansible/roles/contracts/tasks/main.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6225a99..318cd29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,15 +55,10 @@ jobs: run: make deps # must precede lint/syntax-check - name: Lint (yamllint + ansible-lint) run: make lint - - name: Syntax-check playbooks + - name: Syntax-check playbook # Dummy inventory: no real hosts.yml is committed upstream (only hosts.yml.example), # and --syntax-check only parses, it never connects. - run: | - for p in playbooks/site.yml playbooks/anvil.yml playbooks/add-dev-user.yml; do - echo "::group::syntax-check $p" - ansible-playbook "$p" --syntax-check -i localhost, - echo "::endgroup::" - done + run: ansible-playbook playbooks/site.yml --syntax-check -i localhost, # Build the public `decdn.node` collection and run galaxy-importer's checks — # the same validation Galaxy runs on upload (metadata, license, README, embedded diff --git a/.github/workflows/molecule.yml b/.github/workflows/molecule.yml deleted file mode 100644 index 83a01c2..0000000 --- a/.github/workflows/molecule.yml +++ /dev/null @@ -1,39 +0,0 @@ ---- -name: Molecule - -# Containerised converge + idempotence + verify for the anvil/caddy roles. -# Heavy (privileged systemd Docker container) — scoped to ansible/ changes and -# blocking. Mark it a required status check in branch protection once proven. -on: - pull_request: - paths: ['ansible/**'] - push: - branches: [main] - paths: ['ansible/**'] - -permissions: - contents: read - -concurrency: - group: molecule-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - molecule: - runs-on: ubuntu-latest # Docker is preinstalled - defaults: - run: - working-directory: ansible - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version: '3.12' - - name: Install molecule + Ansible - run: | - python -m pip install --upgrade \ - molecule "molecule-plugins[docker]" ansible ansible-lint docker - - name: Install Galaxy collections - run: make deps - - name: molecule test - run: molecule test diff --git a/.gitignore b/.gitignore index 220c018..63cd508 100644 --- a/.gitignore +++ b/.gitignore @@ -6,33 +6,14 @@ # These patterns are defense-in-depth in case anyone copies generated files in. # ───────────────────────────────────────────────────────────────────────────── -# Shared anvil mnemonic / env (the master secret for the devnet) -**/anvil.env +# Environment files (may hold secrets) .env **/.env !**/.env.example -# Caddy basic-auth material (bcrypt hashes + the username->hash registry) -**/*.basicauth -**/*.users.tsv -!**/*.example - -# Cloudflare Tunnel credentials & cert -**/cloudflared/*.json +# Private keys & certs (defense-in-depth; real keystores live under /etc on the host) **/cert.pem **/*.pem -**/config.yml -!**/config.yml.example - -# anvil chain state + deployment manifests (regenerated, machine-specific) -**/state.json -**/deployments.json - -# Foundry build output (if contracts get compiled locally) -**/out/ -**/cache/ -**/broadcast/ -**/.foundry/ # Local, per-developer Claude Code settings (not shared) .claude/settings.local.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b439a4a..aa33938 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,10 +4,10 @@ # pip install pre-commit && make hooks # one-time install # make lint # run on all files # -# Heavier Ansible checks (ansible-lint, syntax-check, KICS security scan, -# molecule) run in CI only — see .github/workflows/. ansible-lint's production -# profile already carries the Ansible security rules; this file is the fast -# local gate (hygiene, shellcheck, yamllint, markdown). +# Heavier Ansible checks (ansible-lint, syntax-check, KICS security scan) run in +# CI only — see .github/workflows/. ansible-lint's production profile already +# carries the Ansible security rules; this file is the fast local gate (hygiene, +# shellcheck, yamllint, markdown). minimum_pre_commit_version: "3.5.0" default_install_hook_types: [pre-commit] diff --git a/AGENTS.md b/AGENTS.md index 350fbf2..6597309 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,26 +17,25 @@ economic claims — those live in `decdn/adr/`. If something here states a proto ## Hard rules -1. **Never commit secrets.** No mnemonics, passwords, bcrypt hashes, private keys, API - tokens, or tunnel credentials in any tracked file. Secrets are *generated on the - target host* by the role tasks (e.g. basic-auth creds) and stored under `/etc//` with `chmod 600` - and a dedicated owner. The repo ships `*.example` templates for secret files only - (non-secret config may be committed directly). 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 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. +1. **Never commit secrets.** No passwords, private keys, API tokens, or keystores in any + tracked file. Secrets are *generated on — or operator-provisioned to — the target host* + (e.g. the node's eth keystore, or `rpc_url` which may embed an API key) and stored under + `/etc//` with `chmod 600` and a dedicated owner. The repo ships `*.example` + templates for secret files only (non-secret config may be committed directly). The root + `.gitignore` is a backstop — do not rely on it; keep secrets out by design. +2. **Localhost-only by default.** Service daemons bind `127.0.0.1` (e.g. the node's metrics + and admin RPC). A service that must accept public traffic declares exactly one hole (the + node's QUIC udp/4433) via `baseline_extra_inbound`; if a service ever needs an HTTP-facing + public path, front it with an explicit reverse proxy that terminates auth + TLS. Never + bind a *backend* to `0.0.0.0` or expose its raw port. 3. **`etc/` mirrors the target filesystem** (the `services/` convention, currently unused — see Layout). 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 - overwrite existing secrets, and require typed confirmation before destructive ops - (e.g. wiping chain state). + overwrite existing secrets, and require typed confirmation before destructive ops. 5. **Show before installing.** When building or changing infra, present the files; the - playbook run on the target (`make deploy` / `deploy-anvil`) is what mutates a host — it - runs there, not here. + playbook run on the target (`make deploy`) is what mutates a host — it runs there, not + here. ## Layout @@ -44,9 +43,9 @@ The repo's one active unit is the Ansible project; `services/` is a reserved con ``` ansible/ # the deployment project (DevSec-hardened, lean roles) - playbooks/ # site.yml (decdn node), anvil.yml (devnet), add-dev-user.yml - roles/ # baseline, decdn_node, anvil, caddy, contracts(stub) - inventory/ molecule/ galaxy/ # see ansible/README.md + playbooks/ # site.yml (decdn node) + roles/ # baseline, decdn_node + inventory/ galaxy/ # see ansible/README.md services// # reserved: future imperative bash + systemd units (currently empty) README.md bin/ etc/ (filesystem-mirrored) contracts/ @@ -58,23 +57,15 @@ secrets; generated on host) and #2 (localhost-only by default) hold for both con ## Current services -- **`ansible/`** — the team's declarative deployment project. **Primary: the public deCDN - node** (`playbooks/site.yml` → baseline + `decdn-node`), installed from a pinned GitHub - 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; 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; - repeated basic-auth failures are fail2ban-banned via the public-only `caddy-rpc` jail) - — 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.) -- **`contracts` role** — intentional **stub**, out of scope for v1, not wired into any - playbook. CREATE2 deploys will plug in after the `anvil` role; until then it's a no-op. - -The original bash + systemd `services/anvil-devnet/` devnet has been **removed** — -superseded by `ansible/` (the `anvil.yml` path). `services/` remains the documented -convention for any future imperative bash + systemd unit, but currently holds none. +- **`ansible/`** — the team's declarative deployment project. **The public deCDN node** + (`playbooks/site.yml` → baseline + `decdn-node`), installed from a pinned GitHub 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), over a shared DevSec-hardened `baseline`. See `ansible/README.md`. (On-chain + node stake/registration, ADR 019 Phase 2, is an operator step, not automated.) + +`services/` remains the documented convention for any future imperative bash + systemd +unit, but currently holds none. ## Commands @@ -87,27 +78,22 @@ make hooks # one-time: install pre-commit git hook (pip install pre-c make lint # all pre-commit hooks on all files (hygiene, shellcheck, yamllint, markdown) make lint-ansible # vendor collections + full ansible-lint (production profile) make security # KICS IaC scan of ansible/ (pinned engine image) -make molecule # containerised converge/verify of the anvil stack (needs Docker) # Ansible deploys — run from ansible/ (see ansible/README.md for the full flow) cd ansible make deps # vendor pinned Galaxy collections into ./collections -make check / deploy # deCDN node (site.yml): dry-run / provision -make check-anvil / deploy-anvil # anvil devnet (anvil.yml) -make add-dev USER_NAME=alice # mint + reveal an anvil basic-auth dev user +make check / deploy # deCDN node (site.yml): dry-run / provision make build / galaxy-check # stage + build the decdn.node collection, then validate it ``` -**Galaxy collection (`decdn.node`).** The public roles (`baseline` + `decdn_node`) ship as -a distributable collection; the internal anvil tooling does not. The overlay lives in -`ansible/galaxy/` and is staged into a clean collection tree by `galaxy/build.sh` — there is -**no** `galaxy.yml` at the `ansible/` root (that would make ansible-lint/molecule treat the -deploy project as a collection). Build/validate with `make build` / `make galaxy-check`; -**publishing is a manual step** (`ansible-galaxy collection publish`), not automated. +**Galaxy collection (`decdn.node`).** The two roles (`baseline` + `decdn_node`) ship as a +distributable collection. The overlay lives in `ansible/galaxy/` and is staged into a clean +collection tree by `galaxy/build.sh` — there is **no** `galaxy.yml` at the `ansible/` root +(that would make ansible-lint treat the deploy project as a collection). Build/validate with +`make build` / `make galaxy-check`; **publishing is a manual step** +(`ansible-galaxy collection publish`), not automated. **Gotcha — pre-commit is local-only.** Hygiene/shellcheck/yamllint/markdown run via `make hooks`/`make lint` on your machine, **not** in CI. CI (`.github/workflows/`) is the blocking gate and runs `ansible-lint` + KICS + `galaxy-build` (on `ansible/**`) + `actionlint`. `ansible-lint` is **not** a per-commit hook (it needs collections vendored) — run `make lint-ansible`. -A separate `molecule.yml` workflow runs the containerised converge/verify in CI too, so -`make molecule` is not purely local. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1c3e18..ec879b5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,19 +24,15 @@ tree), and `markdownlint`. | `make lint` | run all pre-commit hooks on every file (the full local hygiene gate) | | `make lint-ansible` | install Galaxy collections + run `ansible-lint` (its production profile includes the Ansible security rules) | | `make security` | KICS IaC security scan of `ansible/` (engine image; CI uses the official KICS action) | -| `make molecule` | containerised converge/verify of the anvil stack (needs Docker) | `ansible-lint` is **not** a per-commit hook (it needs the collections installed). Run it on demand with `make lint-ansible`, or `pre-commit run ansible-lint --hook-stage manual`. ## CI overview -- **`ci.yml`** — `ansible-lint` + `kics` (on `ansible/**`) and `actionlint`. - Bash-only PRs skip the Ansible jobs. Hygiene/shellcheck/markdownlint run via - **pre-commit locally only** (`make hooks` / `make lint`), not in CI. -- **`molecule.yml`** — runs on `ansible/**` changes; **blocking**. Add it as a - required status check in branch protection (Settings → Branches) once proven, - alongside the `ci.yml` jobs. +- **`ci.yml`** — `ansible-lint` + `galaxy-build` + `kics` (on `ansible/**`) and + `actionlint`. Bash-only PRs skip the Ansible jobs. Hygiene/shellcheck/markdownlint + run via **pre-commit locally only** (`make hooks` / `make lint`), not in CI. ## Supply-chain / pinning rules @@ -56,7 +52,6 @@ Run it on demand with `make lint-ansible`, or `pre-commit run ansible-lint --hoo ## Solidity -There is no Foundry project in the repo yet — deterministic on-devnet contract -deployment is a future Ansible role (`ansible/roles/contracts/`, currently a stub). -The `forge fmt` pre-commit hook and the CI `solidity` job are present but commented -out; they self-activate once real sources land. +There is no Foundry project in the repo yet. The `forge fmt` pre-commit hook and the +CI `solidity` job are present but commented out; they self-activate once real sources +land. diff --git a/Makefile b/Makefile index 9485ed1..e52d3f9 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Convenience targets for the deCDN DevOps monorepo. # Run from the repo root. Ansible-specific work is delegated to ansible/Makefile. -.PHONY: help hooks lint lint-ansible security molecule galaxy-build galaxy-check +.PHONY: help hooks lint lint-ansible security galaxy-build galaxy-check SHELL := /bin/bash # Local KICS runs use the engine image pinned by digest. CI runs the official @@ -32,9 +32,6 @@ security: ## KICS IaC security scan of ansible/ (CI runs the official --report-formats json --output-path /repo/kics-results \ --no-progress --fail-on high -molecule: ## containerised converge/verify of the anvil stack - $(MAKE) -C ansible molecule - galaxy-build: ## stage + build the decdn.node Galaxy collection artifact $(MAKE) -C ansible build diff --git a/README.md b/README.md index 04bfc7e..5c37eb1 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # decdn-devops [![CI](https://github.com/decdn/devops/actions/workflows/ci.yml/badge.svg)](https://github.com/decdn/devops/actions/workflows/ci.yml) -[![Molecule](https://github.com/decdn/devops/actions/workflows/molecule.yml/badge.svg)](https://github.com/decdn/devops/actions/workflows/molecule.yml) [![Ansible](https://img.shields.io/badge/Ansible-%E2%89%A5%202.15-1A1918?logo=ansible&logoColor=white)](https://docs.ansible.com/) [![ansible-lint: production](https://img.shields.io/badge/ansible--lint-production-blue)](https://ansible.readthedocs.io/projects/lint/) [![IaC scan: KICS](https://img.shields.io/badge/IaC%20scan-KICS-7B61FF)](https://kics.io/) @@ -20,12 +19,11 @@ ADR; nothing is invented in this repo. ## What it deploys -Two deployments share one hardened host baseline: +A single deployment over a hardened host baseline: | Playbook | Deploys | Exposure | |----------|---------|----------| -| **`site.yml`** (primary) | A public **deCDN node** (`decdn-node`) — the product. Installed from a pinned GitHub release tarball under a hardened systemd unit. | Public QUIC **udp/4433** | -| `anvil.yml` (internal) | The shared **Anvil EVM devnet** behind a Caddy basic-auth proxy — team tooling, not the product. | Loopback (+ out-of-band tunnel) | +| **`site.yml`** | A public **deCDN node** (`decdn-node`) — the product. Installed from a pinned GitHub release tarball under a hardened systemd unit. | Public QUIC **udp/4433** | ## Architecture @@ -33,10 +31,8 @@ Two deployments share one hardened host baseline: baseline host hardening — DevSec os/ssh, nftables default-deny inbound, fail2ban, unattended-upgrades, chrony, an admin sudo user │ - ├─ 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 + └─ site.yml → decdn-node public QUIC udp/4433; metrics+admin loopback; + release-tarball install; hardened systemd unit ``` On-chain node stake + registration (ADR 019 Phase 2) is an **operator step**, not @@ -46,10 +42,10 @@ automated here — the node serves paid traffic only after it is staked and regi | Path | What it is | |------|------------| -| [`ansible/`](ansible/README.md) | The team's **declarative deployment project** — `inventory/`, `playbooks/`, `roles/` (baseline, decdn_node, anvil, caddy, contracts), and a Molecule scenario. The whole deploy surface lives here. | +| [`ansible/`](ansible/README.md) | The team's **declarative deployment project** — `inventory/`, `playbooks/`, `roles/` (baseline, decdn_node). The whole deploy surface lives here. | | `Makefile` | Root hygiene/security/CI mirror — runs the same lint + IaC scans CI does. | -| `ansible/Makefile` | The deploy driver — `make deps/check/deploy/check-anvil/deploy-anvil/add-dev`. | -| `.github/workflows/` | The blocking CI gate (`ansible-lint` + KICS + `actionlint`) and the Molecule run. | +| `ansible/Makefile` | The deploy driver — `make deps/check/deploy`. | +| `.github/workflows/` | The blocking CI gate (`ansible-lint` + KICS + `galaxy-build` + `actionlint`). | ## Quickstart @@ -57,7 +53,7 @@ automated here — the node serves paid traffic only after it is staked and regi cd ansible make deps # vendor pinned Galaxy collections into ./collections cp inventory/hosts.yml.example inventory/hosts.yml -$EDITOR inventory/hosts.yml # set hosts for decdn_nodes and/or anvil_devnet +$EDITOR inventory/hosts.yml # set hosts for decdn_nodes $EDITOR inventory/group_vars/all.yml # set ssh_admin_pubkey (REQUIRED — prevents lockout) cp inventory/host_vars/decdn-node-1/secret.yml.example inventory/host_vars/decdn-node-1/secret.yml $EDITOR inventory/host_vars/decdn-node-1/secret.yml # set decdn_rpc_url (per-node config is in main.yml) @@ -65,22 +61,20 @@ make check # dry run (--check --diff) make deploy # provision the deCDN node ``` -See [`ansible/README.md`](ansible/README.md) for the full setup, the deCDN-node -prerequisites (release tarball, per-node `host_vars`, operator-provisioned eth keystore), -and the anvil devnet flow. +See [`ansible/README.md`](ansible/README.md) for the full setup and the deCDN-node +prerequisites (release tarball, per-node `host_vars`, operator-provisioned eth keystore). ## Security model -- **Nothing secret is committed.** Mnemonics, basic-auth credentials, and eth keystores - are **generated on — or operator-provisioned to — the target host**, never the repo. +- **Nothing secret is committed.** The eth keystore and `rpc_url` (which may embed an API + key) are **generated on — or operator-provisioned to — the target host**, never the repo. Ansible roles render them on the host (`no_log`, `0600`); the repo ships a `*.example` template for the one node secret (`rpc_url`) plus a `hosts.yml.example` starter, and commits non-secret per-node config directly (`host_vars//main.yml`), with the root `.gitignore` as a backstop. -- **Localhost-only by default.** Backends bind `127.0.0.1`; the only sanctioned public - path is an explicit reverse proxy / tunnel with auth in front. The node host opens one - extra hole (udp/4433 QUIC); everything else (anvil 8545, caddy 8080, node metrics 9090, - admin RPC 9191) stays loopback. +- **Localhost-only by default.** Backends bind `127.0.0.1`; a service that must accept + public traffic declares its port explicitly. The node host opens one extra hole + (udp/4433 QUIC); everything else (node metrics 9090, admin RPC 9191) stays loopback. - **Default-deny inbound (nftables).** SSH is the only universally-open port; extra public ports are declared explicitly via `baseline_extra_inbound`. - **DevSec host hardening.** `os_hardening` + `ssh_hardening` (key-only SSH, no root login, @@ -98,14 +92,11 @@ make hooks # one-time: install the pre-commit git hook (pip install p make lint # all pre-commit hooks on all files (hygiene, shellcheck, yamllint, markdown) make lint-ansible # vendor collections + full ansible-lint (production profile) make security # KICS IaC scan of ansible/ (pinned engine image) -make molecule # containerised converge/verify of the anvil stack (needs Docker) # Ansible deploys — run from ansible/ cd ansible make deps # vendor pinned Galaxy collections into ./collections make check / deploy # deCDN node (site.yml): dry-run / provision -make check-anvil / deploy-anvil # anvil devnet (anvil.yml) -make add-dev USER_NAME=alice # mint + reveal an anvil basic-auth dev user ``` **Gotcha — pre-commit is local-only.** Hygiene/shellcheck/yamllint/markdown run via @@ -116,13 +107,10 @@ is not a per-commit hook (it needs collections vendored) — run `make lint-ansi ## CI & quality gates - **`ci.yml`** — path-filtered so heavy jobs skip unrelated PRs: `ansible-lint` (production - profile + playbook syntax-check), **KICS** IaC scan (fail on HIGH), and `actionlint` on - the workflows themselves. The KICS engine is pinned by digest and every third-party - action by full commit SHA (a re-pointed tag can ship malicious code). -- **`molecule.yml`** — spins up the anvil + caddy stack in a container and asserts - `eth_chainId`, loopback-only binding, a `401` on unauthenticated requests, and - idempotence. The `baseline` role is not exercised in a container (its `ssh_hardening` - would sever the connection); `make check` covers it as a non-mutating dry run. + profile + playbook syntax-check), a `galaxy-build` readiness gate (builds the `decdn.node` + collection and runs galaxy-importer's checks), **KICS** IaC scan (fail on HIGH), and + `actionlint` on the workflows themselves. The KICS engine is pinned by digest and every + third-party action by full commit SHA (a re-pointed tag can ship malicious code). ## Conventions & source of truth @@ -130,13 +118,12 @@ is not a per-commit hook (it needs collections vendored) — run `make lint-ansi (a separate repo in the workspace) — e.g. payments (ADR 003), node onboarding (ADR 019), tokenomics (ADR 026). If a doc here contradicts an ADR, fix the doc. - **Role templates render to their target paths.** Ansible roles template config directly - onto the host (e.g. `roles/anvil/templates/anvil.service.j2` → `/etc/systemd/system/`), - with secrets generated on the host at `0600`. + onto the host (e.g. `roles/decdn_node/templates/decdn-node.service.j2` → + `/etc/systemd/system/`), with secrets generated on the host at `0600`. ## Further reading - [`ansible/README.md`](ansible/README.md) — full setup, security model, and deploy steps - [`ansible/roles/decdn_node/README.md`](ansible/roles/decdn_node/README.md) — the deCDN node role -- [`ansible/roles/contracts/README.md`](ansible/roles/contracts/README.md) — contracts deploy (stub) - [`CONTRIBUTING.md`](CONTRIBUTING.md) — the local + CI check workflow - workspace `CLAUDE.md` — repo hard rules and the single-source-of-truth policy diff --git a/ansible/.ansible-lint b/ansible/.ansible-lint index a5ebbf3..edd15d7 100644 --- a/ansible/.ansible-lint +++ b/ansible/.ansible-lint @@ -1,11 +1,11 @@ --- -# These roles intentionally share a small set of GLOBAL configuration knobs that -# are surfaced to operators in inventory/group_vars (ssh_admin_*, foundry_dir, -# foundry_version, rpc_hostname, anvil_host/anvil_port). They are deliberately -# unprefixed: conventional names read better for a public ops repo than -# role-prefixed ones, and several are genuinely cross-role (anvil_host/port are -# consumed by both the anvil and caddy roles). Everything else is held to the -# 'production' profile. +# Both roles carry deliberately short var names, so the no-role-prefix rule is +# skipped project-wide (removing the skip surfaces ~60 violations): +# - decdn_node uses a `decdn_` prefix, not the rule-expected `decdn_node_` +# (decdn_rpc_url, decdn_bind_port, decdn_user, …) — the bulk of the skip. +# - baseline surfaces a few global knobs in group_vars (ssh_admin_*, ssh_allow_cidrs). +# Conventional names read better for a public ops repo than role-prefixed ones; +# everything else is held to the 'production' profile. skip_list: - var-naming[no-role-prefix] diff --git a/ansible/.gitignore b/ansible/.gitignore index 3f06d73..62ced30 100644 --- a/ansible/.gitignore +++ b/ansible/.gitignore @@ -9,6 +9,5 @@ collections/ inventory/host_vars/*/secret.* !inventory/host_vars/*/secret.*.example -# Ansible / molecule scratch +# Ansible scratch *.retry -.molecule/ diff --git a/ansible/Makefile b/ansible/Makefile index a81ded3..53c4f63 100644 --- a/ansible/Makefile +++ b/ansible/Makefile @@ -1,6 +1,6 @@ # Convenience targets for the deCDN Ansible project. # Always run from the ansible/ directory. -.PHONY: deps lint check deploy check-anvil deploy-anvil add-dev molecule build galaxy-check +.PHONY: deps lint check deploy build galaxy-check SHELL := /bin/bash # Install the required Galaxy collections (>= constraints in requirements.yml) @@ -22,22 +22,6 @@ check: deploy: ansible-playbook playbooks/site.yml -# --- Internal: the anvil devnet ---------------------------------------------- -check-anvil: - ansible-playbook playbooks/anvil.yml --check --diff - -deploy-anvil: - ansible-playbook playbooks/anvil.yml - -# Mint (and reveal once) a new anvil basic-auth dev user: make add-dev USER_NAME=alice -add-dev: - @test -n "$(USER_NAME)" || { echo "usage: make add-dev USER_NAME="; exit 1; } - ansible-playbook playbooks/add-dev-user.yml -e user=$(USER_NAME) - -# Containerised converge + verify + idempotence for the anvil stack (requires docker). -molecule: - molecule test - # --- Galaxy collection (decdn.node) ------------------------------------------ # Stage baseline + decdn_node into a clean collection tree and build the artifact # under build/. Only those two roles ship; see galaxy/README.md. Publishing stays diff --git a/ansible/README.md b/ansible/README.md index 8c58f74..86138b1 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -1,43 +1,35 @@ # ansible — deCDN deployment -Declarative Ansible project for the deCDN team. Two deployments, one shared host +Declarative Ansible project for the deCDN team. One deployment over a shared host 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. | Public HTTPS 443 (auto-TLS + basic auth); anvil stays loopback | +| **`site.yml`** | A public **deCDN node** (`decdn-node`) — the product. | Public QUIC udp/4433 | ``` baseline host hardening — DevSec os/ssh, nftables default-deny inbound, fail2ban, unattended-upgrades, chrony, an admin sudo user │ - ├─ site.yml → decdn-node public QUIC udp/4433; metrics+admin loopback; - │ release-tarball install; hardened systemd unit - │ - └─ anvil.yml → anvil + caddy loopback EVM devnet; caddy fronts it on public - https/443 (auto-TLS) with per-dev basic auth + └─ site.yml → decdn-node public QUIC udp/4433; metrics+admin loopback; + release-tarball install; hardened systemd unit ``` ## Security model - **Default-deny inbound (nftables).** SSH is the only universally-open port. The node host - 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 - key) lives in a git-ignored `host_vars//secret.yml` (the rest of `host_vars` is - committed, non-secret config) and is rendered to a `0600` config. + additionally opens **udp/4433** (QUIC) via `baseline_extra_inbound`. Everything else + (node metrics 9090, admin RPC 9191) stays **loopback** with no hole. +- **No secrets in the repo.** The node's eth keystore is **operator-provisioned** and never + generated here; its `rpc_url` (which may embed an API key) lives in a git-ignored + `host_vars//secret.yml` (the rest of `host_vars` is committed, non-secret config) and + is rendered to a `0600` config. - **Host hardening via DevSec** (`os_hardening` + `ssh_hardening`): key-only SSH, no root login, kernel/sysctl/PAM hardening — applied last, after the admin key is in place. ## Requirements -- Control machine: **Ansible ≥ 2.15**, `ansible-lint`, `yamllint` (and Docker + `molecule` - + `molecule-plugins[docker]` for the anvil molecule scenario). +- Control machine: **Ansible ≥ 2.15**, `ansible-lint`, `yamllint`. - Target: **Debian (bookworm)** host(s) reachable over SSH with a sudo-capable user. - **Ubuntu sudo-rs note:** 25.10+ (and 26.04) ship `sudo-rs` as the default `sudo`, which doesn't honor the custom `-p` become prompt Ansible relies on — so @@ -52,7 +44,7 @@ baseline host hardening — DevSec os/ssh, nftables default-deny inbound, cd ansible make deps # vendor pinned collections into ./collections cp inventory/hosts.yml.example inventory/hosts.yml -$EDITOR inventory/hosts.yml # set hosts for decdn_nodes and/or anvil_devnet +$EDITOR inventory/hosts.yml # set hosts for decdn_nodes $EDITOR inventory/group_vars/all.yml # optional: override admin user/keys, allowlists # Per-node RPC secret (the rest of host_vars//main.yml is committed config): cp inventory/host_vars/decdn-node-1/secret.yml.example inventory/host_vars/decdn-node-1/secret.yml @@ -110,37 +102,18 @@ with no turnkey CLI yet (see `roles/decdn_node/README.md`). --- -## Deploy the anvil devnet (internal) - -```bash -make check-anvil -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 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. Repeated basic-auth failures are -**fail2ban-banned** (the `caddy-rpc` jail, public listener only — see `roles/caddy`). To keep -it loopback-only instead (e.g. behind a tunnel) set `caddy_public: false` — see the appendix. - ---- - ## Testing ```bash make lint # yamllint + ansible-lint (production profile) -make molecule # docker: converge anvil+caddy, assert eth_chainId/loopback/401 + idempotence ansible-playbook playbooks/site.yml --syntax-check ``` -The anvil stack is covered by the Molecule `default` scenario. The **node** role is verified -statically pre-release (syntax-check, `systemd-analyze verify` on the rendered unit, TOML -validity, fail-loud asserts); a containerised node scenario + live deploy follow once a -`v` release is published (no real chain runs in CI). Molecule never exercises -`baseline` (its `ssh_hardening` would sever the container's own connection); `make check` -covers it as a non-mutating dry run. +The **node** role is verified statically pre-release (syntax-check, `systemd-analyze verify` +on the rendered unit, TOML validity, fail-loud asserts); a live deploy follows once a +`v` release is published (no real chain runs in CI). `baseline` is not exercised in +a container (its `ssh_hardening` would sever the connection); `make check` covers it as a +non-mutating dry run. ## Configuration @@ -156,23 +129,19 @@ the RPC URL). Highlights: | `decdn_node_version` | `""` | **required**; a `v` release must exist. | | `decdn_rpc_url` + 3 contract addresses | `""` | **required** per node — `rpc_url` in `host_vars//secret.yml`, addresses in `main.yml`; sourced from an ADR/deployment. | | `decdn_region` / `decdn_bind_port` / `decdn_rate_per_mb` | `""` / `4433` / `10` | node identity, QUIC port, USDC base units/MB. | -| `anvil_chain_id` … `rpc_hostname` | see `roles/anvil`,`roles/caddy` | internal devnet knobs. | -| `caddy_fail2ban` (+ `_maxretry`/`_findtime`/`_bantime`) | `true` (5 / 10m / 1h) | RPC basic-auth brute-force jail; public listener only. | --- ## Packaging as a Galaxy collection (`decdn.node`) -The two public-facing roles (`baseline` + `decdn_node`) are also packaged as the -distributable **`decdn.node`** collection — deployment options for external node -operators. The internal anvil tooling (`anvil`/`caddy`/`contracts`) does **not** ship. +The two roles (`baseline` + `decdn_node`) are also packaged as the distributable +**`decdn.node`** collection — deployment options for external node operators. The collection overlay lives in [`galaxy/`](galaxy/) (`galaxy.yml`, the collection `README.md`/`CHANGELOG.md`, `meta/runtime.yml`, `build.sh`). It is deliberately **not** a `galaxy.yml` at the project root: `galaxy/build.sh` stages only the two roles into a clean `ansible_collections/decdn/node/` tree and builds the artifact, so this project -stays a plain Ansible project (the internal `make deploy`/`lint`/`molecule` flow is -unchanged). +stays a plain Ansible project (the internal `make deploy`/`lint` flow is unchanged). ```bash make build # stage + build -> build/decdn-node-.tar.gz @@ -188,34 +157,3 @@ ansible-galaxy collection publish build/decdn-node-*.tar.gz --api-key "$GALAXY_T Bump `version:` in `galaxy/galaxy.yml` and add a `galaxy/CHANGELOG.md` entry per release. CI's `galaxy-build` job builds + validates the collection on every `ansible/**` change but never publishes. - ---- - -## Appendix — public path for the anvil devnet - -### 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 -``` diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg index acd177e..8dfc06f 100644 --- a/ansible/ansible.cfg +++ b/ansible/ansible.cfg @@ -1,5 +1,5 @@ -# Ansible configuration for the deCDN deployment project (public node + internal -# anvil devnet). Run all ansible / ansible-playbook / molecule commands from here. +# Ansible configuration for the deCDN deployment project (the public node). +# Run all ansible / ansible-playbook commands from here. [defaults] inventory = inventory/hosts.yml roles_path = roles diff --git a/ansible/galaxy/README.md b/ansible/galaxy/README.md index 0055878..e3cc879 100644 --- a/ansible/galaxy/README.md +++ b/ansible/galaxy/README.md @@ -9,9 +9,6 @@ repository — two roles and nothing else: | `decdn.node.baseline` | Debian host baseline — nftables default-deny inbound, fail2ban, unattended-upgrades, chrony, an admin sudo user, then DevSec OS + SSH hardening (applied last). | | `decdn.node.decdn_node` | The `decdn-node` daemon — installed from a pinned GitHub Release tarball under a hardened systemd unit; public QUIC udp/4433, loopback metrics + admin RPC. | -> The repo's internal team tooling (the anvil devnet — `anvil`, `caddy`, `contracts` -> roles) is **not** part of this collection. - ## Requirements - **ansible-core ≥ 2.15** on the control machine. diff --git a/ansible/galaxy/build.sh b/ansible/galaxy/build.sh index 973f04a..aad1884 100755 --- a/ansible/galaxy/build.sh +++ b/ansible/galaxy/build.sh @@ -1,12 +1,11 @@ #!/usr/bin/env bash # Stage and build the public `decdn.node` Galaxy collection. # -# Only the roles/baseline + roles/decdn_node sources ship. The internal anvil -# devnet (anvil/caddy/contracts roles) and all deploy machinery (inventory, -# molecule, Makefile, ansible.cfg) are excluded BY CONSTRUCTION — they are simply +# Only the roles/baseline + roles/decdn_node sources ship. All deploy machinery +# (inventory, Makefile, ansible.cfg) is excluded BY CONSTRUCTION — it is simply # never copied into the staging tree. This keeps the artifact clean and leaves the # internal project untouched (no galaxy.yml at the project root, so ansible-lint / -# ansible / molecule still see a plain project). +# ansible still see a plain project). # # Output: ansible/build/decdn-node-.tar.gz set -euo pipefail diff --git a/ansible/galaxy/galaxy.yml b/ansible/galaxy/galaxy.yml index 2d9958f..97d1ef9 100644 --- a/ansible/galaxy/galaxy.yml +++ b/ansible/galaxy/galaxy.yml @@ -3,11 +3,10 @@ # slice of this DevOps repo: deploy + harden a deCDN node. # # This file is an OVERLAY, not the project root manifest. It is assembled into a -# clean collection tree (with only the baseline + decdn_node roles) by -# galaxy/build.sh; it is deliberately NOT placed at ansible/ root, so the -# internal deploy project stays a plain Ansible project (bare role names, -# roles_path, production-profile ansible-lint) rather than being reinterpreted as -# a collection. See galaxy/README.md. +# clean collection tree (with the baseline + decdn_node roles) by galaxy/build.sh; +# it is deliberately NOT placed at ansible/ root, so the deploy project stays a +# plain Ansible project (bare role names, roles_path, production-profile +# ansible-lint) rather than being reinterpreted as a collection. See galaxy/README.md. namespace: decdn name: node version: 0.1.0 # pre-1.0: published shape not yet stable @@ -31,8 +30,7 @@ tags: # baseline -> devsec.hardening (os_hardening + ssh_hardening), ansible.posix # (authorized_key) # decdn_node -> ansible.builtin only -# community.general is NOT used by either shipped role (it backs the internal -# anvil/caddy roles, which do not ship), so it is intentionally absent here. +# community.general is NOT used by either role, so it is intentionally absent here. dependencies: devsec.hardening: ">=10.0.0" ansible.posix: ">=1.5.0" diff --git a/ansible/inventory/group_vars/all.yml b/ansible/inventory/group_vars/all.yml index 1b613c8..46ab8f0 100644 --- a/ansible/inventory/group_vars/all.yml +++ b/ansible/inventory/group_vars/all.yml @@ -1,5 +1,5 @@ --- -# Deployment overrides for the anvil devnet. +# Deployment overrides for the deCDN node. # # Sane defaults for every knob live in the roles (roles/*/defaults/main.yml) so # the roles are self-contained. Set anything here to override per host/group. @@ -25,9 +25,5 @@ ssh_admin_extra_pubkeys: [] ssh_allow_cidrs: [] # --- Common overrides (uncomment to change; see roles/*/defaults for all) ---- -# rpc_hostname: rpc-dev.decdn.org # printed in ETH_RPC_URL; change if running -# # alongside the existing box -# foundry_version: latest # latest | nightly | vX.Y.Z (pin for reproducibility) -# anvil_chain_id: 31337 # devnet default; NOT a protocol claim (ADR) -# anvil_block_time: 2 -# anvil_accounts: 10 +# See roles/decdn_node/defaults/main.yml for the node's tunable knobs, and set +# per-node config in inventory/host_vars//main.yml. diff --git a/ansible/inventory/group_vars/anvil_devnet.yml b/ansible/inventory/group_vars/anvil_devnet.yml deleted file mode 100644 index 0aafea5..0000000 --- a/ansible/inventory/group_vars/anvil_devnet.yml +++ /dev/null @@ -1,26 +0,0 @@ ---- -# 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 - -# --- anvil memory ceilings (this host is a ~3.8 GB / no-swap VPS) -------------- -# Sized to leave headroom for Caddy + the system on a small box. On 2026-06-07 an -# unbounded anvil grew to ~3.7 GB and the GLOBAL OOM-killer killed it mid state -# snapshot, truncating state.json and crash-looping the unit. The cgroup cap below -# (plus anvil_prune_history in the role defaults) confines a runaway to its own -# slice; raise both on a larger host. -anvil_memory_high: "2G" -anvil_memory_max: "2560M" diff --git a/ansible/inventory/hosts.yml.example b/ansible/inventory/hosts.yml.example index a28f2a2..7586042 100644 --- a/ansible/inventory/hosts.yml.example +++ b/ansible/inventory/hosts.yml.example @@ -28,12 +28,3 @@ decdn_nodes: ansible_host: REPLACE_WITH_NODE_VPS_IP_OR_DNS ansible_user: root # ansible_become_exe: /usr/bin/sudo.ws # Ubuntu sudo-rs workaround — see note above - -# INTERNAL: our anvil devnet — provisioned by playbooks/anvil.yml. Keep on a -# separate host from the public node(s). -anvil_devnet: - hosts: - anvil-vps: - ansible_host: REPLACE_WITH_INTERNAL_VPS_IP_OR_DNS - ansible_user: root - # ansible_become_exe: /usr/bin/sudo.ws # Ubuntu sudo-rs workaround — see note above diff --git a/ansible/molecule/default/converge.yml b/ansible/molecule/default/converge.yml deleted file mode 100644 index a2ee814..0000000 --- a/ansible/molecule/default/converge.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -# Exercise the service roles only (baseline is real-host-only — see molecule.yml). -- name: Converge - hosts: all - become: true - roles: - - role: anvil - - role: caddy diff --git a/ansible/molecule/default/molecule.yml b/ansible/molecule/default/molecule.yml deleted file mode 100644 index 23d68c1..0000000 --- a/ansible/molecule/default/molecule.yml +++ /dev/null @@ -1,44 +0,0 @@ ---- -# Containerised converge + verify + idempotence for the service roles. -# Only `anvil` and `caddy` are exercised here — `baseline` applies DevSec -# ssh_hardening (disables root/password login) which would sever Molecule's -# connection to a throwaway container, so it is validated on real hosts instead. -dependency: - name: galaxy - options: - requirements-file: ../../requirements.yml -driver: - name: docker -platforms: - - name: anvil-molecule - image: geerlingguy/docker-debian12-ansible:latest - pre_build_image: true - command: /usr/lib/systemd/systemd - privileged: true - cgroupns_mode: host - volumes: - - /sys/fs/cgroup:/sys/fs/cgroup:rw -provisioner: - name: ansible - env: - ANSIBLE_ROLES_PATH: "${MOLECULE_PROJECT_DIRECTORY}/roles" - ANSIBLE_COLLECTIONS_PATH: "${MOLECULE_PROJECT_DIRECTORY}/collections" - inventory: - group_vars: - all: - # 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: - test_sequence: - - dependency - - create - - converge - - idempotence - - verify - - destroy diff --git a/ansible/molecule/default/verify.yml b/ansible/molecule/default/verify.yml deleted file mode 100644 index 23e55e0..0000000 --- a/ansible/molecule/default/verify.yml +++ /dev/null @@ -1,50 +0,0 @@ ---- -- name: Verify - hosts: all - become: true - tasks: - - name: Query chain id over loopback - ansible.builtin.uri: - url: "http://127.0.0.1:8545" - method: POST - body_format: json - body: - jsonrpc: "2.0" - id: 1 - method: eth_chainId - params: [] - register: chainid - - - name: Chain id is 31337 (0x7a69) - ansible.builtin.assert: - that: - - chainid.json.result == "0x7a69" - fail_msg: "expected 0x7a69, got {{ chainid.json.result | default('') }}" - - - name: Collect listening sockets - ansible.builtin.command: - cmd: ss -ltn - register: listeners - changed_when: false - - - name: Assert anvil and caddy bind loopback only (never 0.0.0.0) - ansible.builtin.assert: - that: - - "'0.0.0.0:8545' not in listeners.stdout" - - "'0.0.0.0:8080' not in listeners.stdout" - - "'127.0.0.1:8545' in listeners.stdout" - - "'127.0.0.1:8080' in listeners.stdout" - - - name: Reject unauthenticated requests through Caddy (401) - ansible.builtin.uri: - url: "http://127.0.0.1:8080" - method: POST - body_format: json - body: - jsonrpc: "2.0" - id: 1 - method: eth_chainId - params: [] - status_code: 401 - register: noauth - failed_when: noauth.status != 401 diff --git a/ansible/playbooks/add-dev-user.yml b/ansible/playbooks/add-dev-user.yml deleted file mode 100644 index d1f608e..0000000 --- a/ansible/playbooks/add-dev-user.yml +++ /dev/null @@ -1,13 +0,0 @@ ---- -# Mint (and reveal exactly once) a new basic-auth dev user, then reload Caddy. -# Usage: ansible-playbook playbooks/add-dev-user.yml -e user=alice -# (or: make add-dev USER_NAME=alice) -# Idempotent: an existing user is left untouched and never silently rotated. -- name: Add a basic-auth dev user - hosts: anvil_devnet - become: true - roles: - - role: caddy - vars: - caddy_extra_user: "{{ user | mandatory }}" - tags: [caddy, add-dev] diff --git a/ansible/playbooks/anvil.yml b/ansible/playbooks/anvil.yml deleted file mode 100644 index 1b63c3d..0000000 --- a/ansible/playbooks/anvil.yml +++ /dev/null @@ -1,15 +0,0 @@ ---- -# INTERNAL-ONLY deployment: our shared anvil devnet behind Caddy basic-auth. -# baseline -> anvil -> caddy -# This is team tooling, not the product. The public deploy is playbooks/site.yml -# (the deCDN node). Runs on a separate, internal host/group. -- name: Provision the internal anvil devnet - hosts: anvil_devnet - become: true - roles: - - role: baseline - tags: [baseline] - - role: anvil - tags: [anvil] - - role: caddy - tags: [caddy] diff --git a/ansible/requirements.yml b/ansible/requirements.yml index b0f07c9..6ac1ba5 100644 --- a/ansible/requirements.yml +++ b/ansible/requirements.yml @@ -4,7 +4,5 @@ collections: - name: devsec.hardening # os_hardening + ssh_hardening (CIS-style baseline) version: ">=10.0.0" - - name: community.general # misc modules/helpers - version: ">=9.0.0" - name: ansible.posix # authorized_key, mount, sysctl helpers version: ">=1.5.0" diff --git a/ansible/roles/anvil/defaults/main.yml b/ansible/roles/anvil/defaults/main.yml deleted file mode 100644 index 833426f..0000000 --- a/ansible/roles/anvil/defaults/main.yml +++ /dev/null @@ -1,41 +0,0 @@ ---- -# anvil role defaults. Override in inventory/group_vars for a real deployment. - -# --- Chain (31337 is anvil's default — a devnet value, NOT a protocol claim) --- -anvil_chain_id: 31337 -anvil_block_time: 2 -anvil_accounts: 10 -anvil_host: "127.0.0.1" # loopback ONLY — never 0.0.0.0 (hard rule #2) -anvil_port: 8545 - -# --- Resilience: bound in-memory growth so the box can't OOM the daemon -------- -# anvil keeps chain history in RAM; with a fixed block-time it grows without bound -# and eventually trips the kernel OOM-killer (which can truncate the state file and -# wedge the service — see anvil-state-guard.sh). prune-history caps the number of -# historical states kept in memory. Set falsy to keep full history (the old, OOM- -# prone behaviour). transaction-block-keeper is optional and omitted unless set. -anvil_prune_history: 5000 # --prune-history N (falsy => omit, keep full history) -anvil_transaction_block_keeper: "" # --transaction-block-keeper N ("" => omit) - -# cgroup memory ceilings for the unit. Empty => no limit (role default). Sizing is -# host-specific, so set these in inventory/group_vars for small VPSes — a cgroup cap -# confines a runaway anvil to its own slice instead of letting the GLOBAL OOM-killer -# pick a victim (e.g. Caddy or sshd). MemoryHigh throttles via reclaim; MemoryMax is -# the hard kill. Pair with the state guard so a MemoryMax kill self-heals on restart. -anvil_memory_high: "" # e.g. "2G" (systemd MemoryHigh=, "" => omit) -anvil_memory_max: "" # e.g. "2560M" (systemd MemoryMax=, "" => omit) - -anvil_state_guard_bin: /usr/local/sbin/anvil-state-guard - -# --- Foundry --- -foundry_dir: /opt/foundry -foundry_version: latest # foundryup -i value: latest | nightly | vX.Y.Z (pin for reproducibility) - -# --- Fixed on-host identity/paths --- -anvil_user: anvil -anvil_group: anvil -anvil_home: /opt/anvil -anvil_env_file: /etc/anvil/anvil.env # ANVIL_MNEMONIC=... (0600 anvil:anvil) -anvil_state_dir: /var/lib/anvil # systemd StateDirectory=anvil -# Optional: supply your own mnemonic instead of generating one (string of words). -# anvil_mnemonic_supplied: "word word ... word" diff --git a/ansible/roles/anvil/handlers/main.yml b/ansible/roles/anvil/handlers/main.yml deleted file mode 100644 index 03acc37..0000000 --- a/ansible/roles/anvil/handlers/main.yml +++ /dev/null @@ -1,12 +0,0 @@ ---- -- name: Reload systemd - ansible.builtin.systemd: - daemon_reload: true - -- name: Restart anvil - ansible.builtin.systemd: - name: anvil - state: restarted - # Under --check on a fresh host the unit isn't installed yet; flush_handlers would - # otherwise fail trying to restart a non-existent service. - when: not ansible_check_mode or 'anvil.service' in (ansible_facts.services | default({})) diff --git a/ansible/roles/anvil/meta/main.yml b/ansible/roles/anvil/meta/main.yml deleted file mode 100644 index 14a1bf1..0000000 --- a/ansible/roles/anvil/meta/main.yml +++ /dev/null @@ -1,14 +0,0 @@ ---- -galaxy_info: - role_name: anvil - author: deCDN Contributors - description: Foundry Anvil EVM devnet — dedicated user, on-host mnemonic, hardened systemd unit, loopback-only. - license: MIT - min_ansible_version: "2.15" - galaxy_tags: [decdn, anvil, ethereum, evm, devnet, systemd] - platforms: - - name: Debian - versions: [bookworm] - - name: Ubuntu - versions: [jammy, noble] -dependencies: [] diff --git a/ansible/roles/anvil/tasks/main.yml b/ansible/roles/anvil/tasks/main.yml deleted file mode 100644 index 2e4103d..0000000 --- a/ansible/roles/anvil/tasks/main.yml +++ /dev/null @@ -1,199 +0,0 @@ ---- -# anvil daemon: dedicated user, pinned Foundry, on-host mnemonic, hardened unit. - -- name: Create anvil system group - ansible.builtin.group: - name: "{{ anvil_group }}" - system: true - -- name: Create anvil system user - ansible.builtin.user: - name: "{{ anvil_user }}" - group: "{{ anvil_group }}" - system: true - home: "{{ anvil_home }}" - create_home: true - shell: /usr/sbin/nologin - -# Under --check the user/apt/Foundry tasks below are no-ops, so on a fresh host the -# anvil user and Foundry binaries don't exist yet — become_user, cast, and the -# systemd unit would all hard-fail. Probe real on-host state (read-only, runs in -# check mode) so those tasks skip in check mode while still previewing drift on an -# already-provisioned host. -- name: Probe whether the anvil user already exists on the target - ansible.builtin.getent: - database: passwd - key: "{{ anvil_user }}" - fail_key: false - -# Service guards below reuse baseline's service_facts; gather here too so the role -# stays correct when run standalone (e.g. --tags anvil) without baseline first. -- name: Probe service facts if not already gathered - ansible.builtin.service_facts: - when: ansible_facts.services is not defined - -- name: Install Foundry bootstrap prerequisites - ansible.builtin.apt: - name: [curl, git, jq, ca-certificates] - state: present - update_cache: true - cache_valid_time: 3600 - -- name: Create Foundry directory - ansible.builtin.file: - path: "{{ foundry_dir }}" - state: directory - owner: "{{ anvil_user }}" - group: "{{ anvil_group }}" - mode: "0755" - -- name: Check whether anvil is already installed - ansible.builtin.stat: - path: "{{ foundry_dir }}/bin/anvil" - register: anvil_bin - -# foundryup installs anvil/cast/forge into $FOUNDRY_DIR/bin. Pinning to -# {{ foundry_version }} is a deliberate improvement over the bash installer, -# which always pulled latest. -- name: Install Foundry ({{ foundry_version }}) - become: true - become_user: "{{ anvil_user }}" - ansible.builtin.shell: - cmd: | - set -euo pipefail - # The installer warns + exits non-zero when it can't update a shell profile - # (no login shell here); that is harmless — foundryup still lands in bin. - curl -L https://foundry.paradigm.xyz | bash || true - "{{ foundry_dir }}/bin/foundryup" --install "{{ foundry_version }}" - creates: "{{ foundry_dir }}/bin/anvil" - executable: /bin/bash - environment: - FOUNDRY_DIR: "{{ foundry_dir }}" - when: - - not anvil_bin.stat.exists - # become_user requires the anvil user to exist; --check didn't create it. - # fail_key:false stores a missing user as {user: None}, so test the value. - - not ansible_check_mode or (getent_passwd | default({})).get(anvil_user) - -- name: Ensure /etc/anvil exists - ansible.builtin.file: - path: "{{ anvil_env_file | dirname }}" - state: directory - owner: "{{ anvil_user }}" - group: "{{ anvil_group }}" - mode: "0750" - -# --- Shared mnemonic: generated on host, never overwritten (no silent rotate) -- -- name: Check for an existing mnemonic env file - ansible.builtin.stat: - path: "{{ anvil_env_file }}" - register: anvil_env_stat - -- name: Generate and install the shared mnemonic - when: - - not anvil_env_stat.stat.exists - # cast lives in the Foundry bin; under --check on a fresh host it isn't installed. - - not ansible_check_mode or anvil_bin.stat.exists - block: - - name: Generate a fresh mnemonic with cast - ansible.builtin.command: - cmd: "{{ foundry_dir }}/bin/cast wallet new-mnemonic --json" - register: anvil_mnemonic_gen - changed_when: true - no_log: true - when: anvil_mnemonic_supplied is not defined or anvil_mnemonic_supplied | length == 0 - - - name: Resolve the effective mnemonic - ansible.builtin.set_fact: - anvil_effective_mnemonic: >- - {{ anvil_mnemonic_supplied - if (anvil_mnemonic_supplied is defined and anvil_mnemonic_supplied | length > 0) - else (anvil_mnemonic_gen.stdout | from_json).mnemonic }} - no_log: true - - # The generate/parse chain runs under no_log, so a malformed cast output would - # otherwise write an empty ANVIL_MNEMONIC= that the never-overwrite guard then - # cements. Fail loud here instead, without echoing the secret (quiet). - - name: Validate the resolved mnemonic is well-formed - ansible.builtin.assert: - that: - - anvil_effective_mnemonic | default('') | trim | split | length in [12, 15, 18, 21, 24] - fail_msg: "Resolved mnemonic is empty or not a 12–24 word BIP-39 phrase; refusing to write it." - quiet: true - - - name: Write the mnemonic env file (0600 {{ anvil_user }}) - ansible.builtin.copy: - dest: "{{ anvil_env_file }}" - owner: "{{ anvil_user }}" - group: "{{ anvil_group }}" - mode: "0600" - content: "ANVIL_MNEMONIC={{ anvil_effective_mnemonic }}\n" - no_log: true - notify: Restart anvil - - - name: Reveal the shared mnemonic ONCE (only when freshly generated) - ansible.builtin.debug: - msg: >- - SHARED ANVIL MNEMONIC — shown once, save to the team vault, never commit: - {{ anvil_effective_mnemonic }} - when: anvil_mnemonic_supplied is not defined or anvil_mnemonic_supplied | length == 0 - -# --- State-resilience guard (ExecStartPre) ------------------------------------ -# Quarantines an empty/corrupt state.json so an OOM-truncated snapshot can't wedge -# the unit in a crash loop. Installed before the unit so the ExecStartPre target -# exists the first time the unit (re)starts. -- name: Install the anvil state-resilience guard - ansible.builtin.template: - src: anvil-state-guard.sh.j2 - dest: "{{ anvil_state_guard_bin }}" - owner: root - group: root - mode: "0755" - notify: Restart anvil - -# --- Hardened systemd unit ---------------------------------------------------- -- name: Install anvil systemd unit - ansible.builtin.template: - src: anvil.service.j2 - dest: /etc/systemd/system/anvil.service - owner: root - group: root - mode: "0644" - notify: - - Reload systemd - - Restart anvil - -# Apply any pending unit/mnemonic changes before we probe the RPC. -- name: Apply pending anvil changes - ansible.builtin.meta: flush_handlers - -- name: Enable and start anvil - ansible.builtin.systemd: - name: anvil - enabled: true - state: started - when: not ansible_check_mode or 'anvil.service' in (ansible_facts.services | default({})) - -- name: Wait for the anvil RPC to answer on loopback - ansible.builtin.uri: - url: "http://{{ anvil_host }}:{{ anvil_port }}" - method: POST - body_format: json - body: - jsonrpc: "2.0" - id: 1 - method: eth_chainId - params: [] - status_code: 200 - register: anvil_rpc_probe - retries: 30 - delay: 1 - until: - - anvil_rpc_probe.status == 200 - # Assert it's OUR chain, not a stale process on the port answering 200. - - (anvil_rpc_probe.json.result | default('0x0')) | int(0, 16) == anvil_chain_id - changed_when: false - # Skip entirely in check mode: --check never (re)starts anvil, so this liveness - # probe would falsely fail against a stopped-but-provisioned host. It previews no - # drift (changed_when:false) — there is nothing to verify in a dry run. - when: not ansible_check_mode diff --git a/ansible/roles/anvil/templates/anvil-state-guard.sh.j2 b/ansible/roles/anvil/templates/anvil-state-guard.sh.j2 deleted file mode 100644 index 6a4fab5..0000000 --- a/ansible/roles/anvil/templates/anvil-state-guard.sh.j2 +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -# MANAGED BY the anvil role — do not edit by hand. -# -# anvil's --state flag refuses to start when the snapshot file exists but is empty -# or truncated (it aborts with "EOF while parsing a value at line 1 column 0", -# exit 2). An OOM-kill mid-snapshot (--state-interval writes) leaves exactly such a -# file, which then wedges the service into a permanent restart loop — the incident -# this guard exists to prevent. -# -# Run as ExecStartPre: if the state file is empty or doesn't end in a JSON close -# token, move it aside so anvil starts from a fresh chain instead of crash-looping. -# A devnet can afford to lose state; it cannot afford to be down. -# -# The checks are deliberately O(1) in memory. This runs INSIDE the unit's cgroup, -# so it is subject to the same MemoryMax as anvil — parsing a potentially large -# snapshot with jq could itself be OOM-killed and re-wedge startup, the very thing -# we are guarding against. A size test plus a trailing-token test catches the empty- -# and truncated-write failure modes without loading the file into memory. -set -euo pipefail - -state="{{ anvil_state_dir }}/state.json" - -# No snapshot yet (first boot): anvil will create one. Nothing to guard. -[ -e "$state" ] || exit 0 - -bad=0 -if [ ! -s "$state" ]; then - bad=1 # zero bytes — the OOM-truncation failure mode -else - # A complete anvil snapshot is a JSON document, so its last non-whitespace byte - # is a close token. A write truncated mid-content won't end this way. - last="$(tail -c 256 "$state" | tr -d '[:space:]' | tail -c 1)" - if [ "$last" != "}" ] && [ "$last" != "]" ]; then - bad=1 # non-empty but not a complete JSON document (partial write) - fi -fi - -if [ "$bad" -eq 1 ]; then - # Static name (not timestamped): a repeated crash/OOM loop overwrites one file - # instead of filling the disk with snapshots. The latest sample suffices for - # forensics, and the disk headroom matters more on this constrained host. - corrupt="${state}.corrupt" - echo "anvil-state-guard: '${state}' is empty or truncated; moving to '${corrupt}' so anvil can start from a fresh chain" >&2 - mv -f "$state" "$corrupt" -fi diff --git a/ansible/roles/anvil/templates/anvil.service.j2 b/ansible/roles/anvil/templates/anvil.service.j2 deleted file mode 100644 index 14d3de2..0000000 --- a/ansible/roles/anvil/templates/anvil.service.j2 +++ /dev/null @@ -1,94 +0,0 @@ -# MANAGED BY the anvil role — do not edit by hand. -# -# Runs as the dedicated, unprivileged `{{ anvil_user }}` user, binds -# {{ anvil_host }}:{{ anvil_port }} ONLY (never 0.0.0.0), and persists chain state -# to {{ anvil_state_dir }}/state.json (loaded on boot, dumped on SIGTERM, -# snapshotted every 30s so an unclean reboot loses at most that interval). -# -# Resilience: ExecStartPre runs anvil-state-guard to quarantine an empty/corrupt -# snapshot (an OOM-truncated state.json otherwise wedges the unit in a crash loop), -# --prune-history bounds in-memory growth, and the cgroup Memory* limits below cap -# the process so a runaway can't take the whole box down via the global OOM-killer. -[Unit] -Description=deCDN anvil devnet (EVM settlement layer) -Documentation=https://github.com/decdn/decdn-devops -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -User={{ anvil_user }} -Group={{ anvil_group }} - -# The shared mnemonic is the master secret. It is NOT in this unit or the repo; -# it lives in {{ anvil_env_file }} (0600, owned by {{ anvil_user }}), generated -# once on the host. systemd reads it as root before dropping privileges. -EnvironmentFile={{ anvil_env_file }} - -# ${ANVIL_MNEMONIC} (brace form) expands to a SINGLE argument even though the -# phrase contains spaces. Caveat: it is visible in `ps`/`systemctl show` ON THIS -# HOST — acceptable because it only controls funded *test* accounts, and is why -# the RPC must never be exposed without auth. Do not reuse it with real value. -# Quarantine an empty/corrupt snapshot before anvil reads it, so a truncated -# state.json (e.g. from an OOM-kill mid-write) self-heals instead of crash-looping. -ExecStartPre={{ anvil_state_guard_bin }} - -ExecStart={{ foundry_dir }}/bin/anvil \ - --host {{ anvil_host }} \ - --port {{ anvil_port }} \ - --chain-id {{ anvil_chain_id }} \ - --block-time {{ anvil_block_time }} \ - --accounts {{ anvil_accounts }} \ - --mnemonic ${ANVIL_MNEMONIC} \ -{% if anvil_prune_history %} - --prune-history {{ anvil_prune_history }} \ -{% endif %} -{% if anvil_transaction_block_keeper %} - --transaction-block-keeper {{ anvil_transaction_block_keeper }} \ -{% endif %} - --state {{ anvil_state_dir }}/state.json \ - --state-interval 30 - -# Graceful stop so anvil can dump state. SIGTERM is anvil's clean-shutdown signal. -KillSignal=SIGTERM -TimeoutStopSec=30 - -Restart=always -RestartSec=2 - -# --- Memory ceilings (cgroup) ---------------------------------------------- -# Confine a runaway anvil to its own slice: MemoryHigh throttles via reclaim, -# MemoryMax is the hard kill. Without these, unbounded history growth invokes the -# GLOBAL OOM-killer, which may pick Caddy/sshd instead. A MemoryMax kill truncates -# the snapshot the same way — the state guard above makes that recoverable. Sizing -# is host-specific; set in inventory/group_vars (empty here => no limit). -{% if anvil_memory_high %} -MemoryHigh={{ anvil_memory_high }} -{% endif %} -{% if anvil_memory_max %} -MemoryMax={{ anvil_memory_max }} -{% endif %} - -# --- Hardening ------------------------------------------------------------- -# StateDirectory creates/owns {{ anvil_state_dir }} (0750) and is the only -# writable path the process gets under ProtectSystem=strict. -StateDirectory=anvil -StateDirectoryMode=0750 -NoNewPrivileges=true -ProtectSystem=strict -ProtectHome=true -PrivateTmp=true -PrivateDevices=true -ProtectKernelTunables=true -ProtectKernelModules=true -ProtectControlGroups=true -ProtectClock=true -RestrictAddressFamilies=AF_INET AF_INET6 -RestrictNamespaces=true -RestrictSUIDSGID=true -LockPersonality=true -SystemCallFilter=@system-service -SystemCallErrorNumber=EPERM - -[Install] -WantedBy=multi-user.target diff --git a/ansible/roles/baseline/README.md b/ansible/roles/baseline/README.md index 7d54b74..8cf6d63 100644 --- a/ansible/roles/baseline/README.md +++ b/ansible/roles/baseline/README.md @@ -1,8 +1,7 @@ # roles/baseline -Hardened **Debian/Ubuntu host baseline**, shared by every deCDN deployment in this -repo (the public node via `playbooks/site.yml`, the internal anvil devnet via -`playbooks/anvil.yml`). Run it first; it prepares the host and then locks it down. +Hardened **Debian/Ubuntu host baseline** for the deCDN node deployment +(`playbooks/site.yml`). Run it first; it prepares the host and then locks it down. ## What this role does @@ -42,8 +41,7 @@ out — when any of these hold: Because resolution reads the **control node's** `$USER`/`$HOME` of whoever invokes `ansible-playbook`, a `sudo`/CI run can autodetect a different user/key than you -expect — set both explicitly in that case. (Molecule never runs `baseline`, so the -assert never fires there.) +expect — set both explicitly in that case. ## Key variables diff --git a/ansible/roles/baseline/handlers/main.yml b/ansible/roles/baseline/handlers/main.yml index 50374a6..7ea13a5 100644 --- a/ansible/roles/baseline/handlers/main.yml +++ b/ansible/roles/baseline/handlers/main.yml @@ -1,6 +1,6 @@ --- # Both units come from baseline_packages, a no-op under --check, so on a fresh host -# the unit may not exist when a flush (e.g. anvil's flush_handlers) fires the handler. +# the unit may not exist when a flush (e.g. a role's flush_handlers) fires the handler. # Guard on real on-host state (service_facts, gathered in tasks/main.yml): run on a # provisioned host (drift preview), skip a missing unit in check mode. - name: Reload nftables diff --git a/ansible/roles/baseline/templates/nftables.conf.j2 b/ansible/roles/baseline/templates/nftables.conf.j2 index 51033ff..d779cc2 100644 --- a/ansible/roles/baseline/templates/nftables.conf.j2 +++ b/ansible/roles/baseline/templates/nftables.conf.j2 @@ -3,7 +3,7 @@ # # Default-deny inbound. SSH is the only universally-opened port; a service that # must accept public traffic (e.g. the decdn node's QUIC listener) declares it -# via `baseline_extra_inbound`. Loopback-only services (anvil, caddy, metrics, +# via `baseline_extra_inbound`. Loopback-only services (the node's metrics and # admin RPC) need no hole at all. Egress is unrestricted. This is defence-in- # depth behind the binds, not a substitute for them. flush ruleset diff --git a/ansible/roles/caddy/README.md b/ansible/roles/caddy/README.md deleted file mode 100644 index d9c4e26..0000000 --- a/ansible/roles/caddy/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# roles/caddy - -Puts a **per-dev HTTP basic-auth reverse proxy** in front of the loopback anvil -RPC. The internal anvil devnet (`playbooks/anvil.yml`) runs `baseline → anvil → -caddy`; Caddy is the only thing that faces the internet, and only after auth over -TLS. Internal tooling — **not** part of the public `decdn.node` collection. - -## What this role does - -1. **Installs Caddy** and ensures `/etc/caddy` (group `caddy`, `0750`). -2. **Mints basic-auth users** on the host into a TSV registry - (`caddy_users_tsv`), bcrypt-hashed via `caddy hash-password`, and renders the - `basic_auth` import block (`caddy_basicauth_file`). Credentials are generated - on the host — never in the repo. `caddy_initial_user` (`dev`) always exists; - `make add-dev USER_NAME=…` mints more. -3. **Renders the Caddyfile** (validated with `caddy validate`) in one of two - shapes: - - `caddy_public: true` (default) — public **HTTPS on `rpc_hostname:443`** with - auto-TLS (Let's Encrypt). Requires inbound tcp/80+443 (open via - `baseline_extra_inbound`). - - `caddy_public: false` — loopback plain-HTTP on `caddy_bind_port` (dev/CI, or - TLS terminated upstream by a tunnel). -4. **JSON access log** to `caddy_access_log` (self-rolling, 10 MiB × 5), recording - who hit the RPC. (Access logs move from the journal to this file — query it for - requests; Caddy's runtime/process logs still go to `journalctl -u caddy`.) -5. **fail2ban `caddy-rpc` jail** — *public listener only*. Watches the access log - and bans, via nftables, any IP that repeatedly fails basic auth (HTTP 401). - See below. - -## fail2ban: RPC basic-auth brute-force protection - -A public RPC behind basic auth invites credential-stuffing. When -`caddy_public: true` and `caddy_fail2ban: true` (both default), the role installs: - -- `/etc/fail2ban/filter.d/caddy-rpc.conf` — matches a `401` for the access logger - in the JSON log and captures the connecting `remote_ip`. -- `/etc/fail2ban/jail.d/caddy-rpc.local` — the `caddy-rpc` jail - (`banaction = nftables-multiport`, bans on 80+443). - -fail2ban itself comes from `baseline` (always run before `caddy` in `anvil.yml`). -On the loopback/CI listener the jail is **skipped** — there's no public attack -surface, and Molecule runs `caddy` without `baseline` (fail2ban absent). Lenient -defaults: **5 failures within 10m → 1h ban**. - -Because Molecule never exercises this path, the role self-checks at deploy: it -**asserts fail2ban is installed** before writing the jail (actionable error if you -ran `caddy` standalone without `baseline`), and **verifies the jail loaded** -(`fail2ban-client status caddy-rpc`) after the restart — so a malformed filter -fails loud instead of silently never banning. - -Inspect / unban on the host: - -```bash -sudo fail2ban-client status caddy-rpc -sudo fail2ban-client set caddy-rpc unbanip -``` - -> **Note:** baseline's nftables ruleset uses `flush ruleset`; a baseline nftables -> *reload* (only on template change) clears fail2ban's `f2b-table` until the next -> fail2ban restart. Pre-existing for the `sshd` jail too. - -## Key variables - -| Var | Default | Notes | -|-----|---------|-------| -| `caddy_public` | `true` | `true` = public HTTPS on `rpc_hostname`; `false` = loopback HTTP on `caddy_bind_port`. | -| `caddy_acme_email` | `""` | Let's Encrypt account contact; empty = anonymous ACME. | -| `caddy_bind_port` | `8080` | Loopback listener port (only when `caddy_public: false`). | -| `rpc_hostname` | `rpc-dev.decdn.org` | Public HTTPS host; printed in `ETH_RPC_URL`. | -| `anvil_host` / `anvil_port` | `127.0.0.1` / `8545` | Upstream RPC to proxy to. | -| `caddy_users_tsv` / `caddy_basicauth_file` | `/etc/caddy/…` | On-host credential registry + generated import. | -| `caddy_initial_user` | `dev` | Always-present basic-auth user. | -| `caddy_access_log` | `/var/log/caddy/rpc-access.log` | JSON access log; the log the fail2ban jail watches. | -| `caddy_fail2ban` | `true` | Enable the `caddy-rpc` jail (public listener only). | -| `caddy_fail2ban_maxretry` | `5` | Failed auths before a ban. | -| `caddy_fail2ban_findtime` | `10m` | Window the failures must fall within. | -| `caddy_fail2ban_bantime` | `1h` | Ban duration. | - -## Platforms - -Debian (bookworm), Ubuntu (jammy, noble). diff --git a/ansible/roles/caddy/defaults/main.yml b/ansible/roles/caddy/defaults/main.yml deleted file mode 100644 index 01c2ce0..0000000 --- a/ansible/roles/caddy/defaults/main.yml +++ /dev/null @@ -1,38 +0,0 @@ ---- -# caddy role defaults. Override in inventory/group_vars for a real deployment. - -# 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" -anvil_port: 8545 - -# On-host credential registry. -caddy_users_tsv: /etc/caddy/rpc-dev.users.tsv # usernamebcrypt registry -caddy_basicauth_file: /etc/caddy/rpc-dev.basicauth # generated Caddyfile import -caddy_initial_user: dev # always ensured to exist -# caddy_extra_user: alice # set by add-dev-user.yml to mint one more user - -# JSON access log (self-rolling) recording who hit the RPC; also the log the -# fail2ban caddy-rpc jail watches for repeated basic-auth failures. -caddy_access_log: /var/log/caddy/rpc-access.log - -# fail2ban jail banning IPs that repeatedly fail RPC basic auth. Only wired up on -# the public listener (caddy_public) — loopback/CI has no attack surface and runs -# without baseline, so fail2ban isn't installed there. Lenient by default: -# caddy_fail2ban_maxretry failures within findtime -> bantime ban (via nftables). -caddy_fail2ban: true -caddy_fail2ban_maxretry: 5 -caddy_fail2ban_findtime: 10m -caddy_fail2ban_bantime: 1h diff --git a/ansible/roles/caddy/handlers/main.yml b/ansible/roles/caddy/handlers/main.yml deleted file mode 100644 index 24cac60..0000000 --- a/ansible/roles/caddy/handlers/main.yml +++ /dev/null @@ -1,18 +0,0 @@ ---- -# The Caddyfile sets `admin off`, so `caddy reload` (admin-API based) is -# unavailable — apply config changes with a restart (cheap for a devnet). -- name: Restart caddy - ansible.builtin.systemd: - name: caddy - state: restarted - # Under --check on a fresh host the unit isn't installed yet; flush_handlers would - # otherwise fail trying to restart a non-existent service. - when: not ansible_check_mode or 'caddy.service' in (ansible_facts.services | default({})) - -# fail2ban ships with baseline (always before caddy in anvil.yml); guard the same -# way so a --check run on a fresh host (unit absent) skips rather than hard-fails. -- name: Restart fail2ban - ansible.builtin.systemd: - name: fail2ban - state: restarted - when: not ansible_check_mode or 'fail2ban.service' in (ansible_facts.services | default({})) diff --git a/ansible/roles/caddy/meta/main.yml b/ansible/roles/caddy/meta/main.yml deleted file mode 100644 index bb7f394..0000000 --- a/ansible/roles/caddy/meta/main.yml +++ /dev/null @@ -1,14 +0,0 @@ ---- -galaxy_info: - role_name: caddy - author: deCDN Contributors - description: Caddy reverse proxy with per-dev HTTP basic auth in front of the loopback anvil RPC. - license: MIT - min_ansible_version: "2.15" - galaxy_tags: [decdn, caddy, proxy, basicauth, systemd] - platforms: - - name: Debian - versions: [bookworm] - - name: Ubuntu - versions: [jammy, noble] -dependencies: [] diff --git a/ansible/roles/caddy/tasks/add_user.yml b/ansible/roles/caddy/tasks/add_user.yml deleted file mode 100644 index 5421d22..0000000 --- a/ansible/roles/caddy/tasks/add_user.yml +++ /dev/null @@ -1,62 +0,0 @@ ---- -# Mint a single basic-auth user `{{ caddy_target_user }}`. Idempotent: an existing -# user is left untouched and NEVER silently rotated. A freshly generated password -# is revealed exactly once. All credential-handling tasks use no_log. -- name: Validate the basic-auth username - ansible.builtin.assert: - that: - - caddy_target_user is match('^[A-Za-z0-9_-]+$') - fail_msg: >- - Invalid basic-auth username '{{ caddy_target_user }}': use only - [A-Za-z0-9_-]. (Keeps the value safe to interpolate into the grep / - lineinfile regexes below.) - quiet: true - -- name: Check whether the basic-auth user already exists - ansible.builtin.command: - cmd: "grep -qP '^{{ caddy_target_user }}\\t' {{ caddy_users_tsv }}" - register: caddy_user_check - changed_when: false - failed_when: false - -- name: Mint '{{ caddy_target_user }}' - # Never mint in check mode: minting needs the caddy binary (hash-password, which - # auto-skips under --check), writes the TSV, and reveals a password — all mutating - # side effects a dry run must not perform. Gating on ansible_check_mode first also - # means caddy_user_check.rc — undefined when the existence-check command auto-skips - # under --check — is never the deciding factor; default it so it can't error. - when: - - not ansible_check_mode - - caddy_user_check.rc | default(1) != 0 - block: - - name: Generate a random password - ansible.builtin.set_fact: - caddy_generated_pw: >- - {{ lookup('ansible.builtin.password', '/dev/null', - chars=['ascii_letters', 'digits'], length=28) }} - no_log: true - - - name: Hash the password (bcrypt) with caddy - ansible.builtin.command: - cmd: "caddy hash-password --plaintext {{ caddy_generated_pw | quote }}" - register: caddy_hash - changed_when: true - no_log: true - - - name: Upsert the user into the TSV registry - ansible.builtin.lineinfile: - path: "{{ caddy_users_tsv }}" - create: true - owner: root - group: caddy - mode: "0640" - regexp: "^{{ caddy_target_user }}\\t" - line: "{{ caddy_target_user }}\t{{ caddy_hash.stdout }}" - no_log: true - - - name: Reveal the new credential ONCE - ansible.builtin.debug: - msg: >- - NEW basic-auth user '{{ caddy_target_user }}' — shown once, share securely: - password={{ caddy_generated_pw }} · - ETH_RPC_URL=https://{{ caddy_target_user }}:{{ caddy_generated_pw }}@{{ rpc_hostname }} diff --git a/ansible/roles/caddy/tasks/install.yml b/ansible/roles/caddy/tasks/install.yml deleted file mode 100644 index 09f6a37..0000000 --- a/ansible/roles/caddy/tasks/install.yml +++ /dev/null @@ -1,23 +0,0 @@ ---- -# Install Caddy from the official Cloudsmith apt repo. Idempotent. -- name: Add Caddy apt signing key - ansible.builtin.get_url: - url: https://dl.cloudsmith.io/public/caddy/stable/gpg.key - dest: /usr/share/keyrings/caddy-stable-archive-keyring.asc - owner: root - group: root - mode: "0644" - -- name: Add Caddy apt repository - ansible.builtin.apt_repository: - repo: >- - deb [signed-by=/usr/share/keyrings/caddy-stable-archive-keyring.asc] - https://dl.cloudsmith.io/public/caddy/stable/deb/debian any-version main - filename: caddy-stable - state: present - -- name: Install caddy - ansible.builtin.apt: - name: caddy - state: present - update_cache: true diff --git a/ansible/roles/caddy/tasks/main.yml b/ansible/roles/caddy/tasks/main.yml deleted file mode 100644 index 0d0f860..0000000 --- a/ansible/roles/caddy/tasks/main.yml +++ /dev/null @@ -1,197 +0,0 @@ ---- -# Caddy: per-dev basic auth + reverse proxy to the loopback anvil RPC. - -- name: Install Caddy - ansible.builtin.import_tasks: install.yml - -- name: Ensure /etc/caddy exists - ansible.builtin.file: - path: /etc/caddy - state: directory - owner: root - group: caddy - mode: "0750" - -- name: Ensure the initial basic-auth user exists - ansible.builtin.include_tasks: add_user.yml - vars: - caddy_target_user: "{{ caddy_initial_user }}" - -- name: Ensure the extra basic-auth user exists (add-dev-user.yml) - ansible.builtin.include_tasks: add_user.yml - vars: - caddy_target_user: "{{ caddy_extra_user }}" - when: caddy_extra_user is defined and caddy_extra_user | length > 0 - -# Rebuild the Caddyfile import block from the TSV registry. Idempotent: identical -# registry -> identical content -> no change -> no restart. -# Under --check the user-mint tasks don't write (the existence probe is a command -# that auto-skips; the lineinfile registry write is a no-op), so on a fresh host the -# TSV registry is never created and the slurp below would fail (file not found). -# Probe for it (read-only, runs in check mode); the slurp/render skip in check mode -# when it's absent, but still preview drift on an already-provisioned host. -- name: Check whether the basic-auth registry exists - ansible.builtin.stat: - path: "{{ caddy_users_tsv }}" - register: caddy_tsv_stat - -# Service guards below reuse baseline's service_facts; gather here too so the role -# stays correct when run standalone (e.g. --tags caddy) without baseline first. -- name: Probe service facts if not already gathered - ansible.builtin.service_facts: - when: ansible_facts.services is not defined - -- name: Read the basic-auth user registry - ansible.builtin.slurp: - src: "{{ caddy_users_tsv }}" - register: caddy_tsv_raw - no_log: true - when: not ansible_check_mode or caddy_tsv_stat.stat.exists - -- name: Render the basic_auth import file from the registry - ansible.builtin.copy: - dest: "{{ caddy_basicauth_file }}" - owner: root - group: caddy - mode: "0640" - content: | - # GENERATED from {{ caddy_users_tsv }} by the caddy role — do not edit by hand. - basic_auth { - {% for line in (caddy_tsv_raw.content | b64decode).split('\n') %} - {% set parts = line.split('\t') %} - {% if parts | length == 2 and parts[0] | length > 0 %} - {{ parts[0] }} {{ parts[1] }} - {% endif %} - {% endfor %} - } - no_log: true - notify: Restart caddy - # caddy_tsv_raw is only defined when the slurp above ran (skipped in check mode). - when: not ansible_check_mode or caddy_tsv_stat.stat.exists - -- name: Ensure the caddy log directory exists - ansible.builtin.file: - path: "{{ caddy_access_log | dirname }}" - state: directory - owner: caddy - group: caddy - mode: "0750" - -# `caddy validate` (run as root by the template task below) instantiates the file -# logger and CREATES the log file if absent — as root, which the caddy daemon then -# can't open. Pre-create it owned by caddy so validate only ever opens an existing -# caddy-owned file; touch+preserve keeps this idempotent and corrects ownership drift. -- name: Ensure the caddy access log is owned by caddy - ansible.builtin.file: - path: "{{ caddy_access_log }}" - state: touch - owner: caddy - group: caddy - mode: "0640" - modification_time: preserve - access_time: preserve - -- name: Install the Caddyfile - ansible.builtin.template: - src: Caddyfile.j2 - dest: /etc/caddy/Caddyfile - owner: root - group: caddy - mode: "0644" - validate: "caddy validate --adapter caddyfile --config %s" - notify: Restart caddy - # validate shells out to the caddy binary, absent under --check on a fresh host. - when: not ansible_check_mode or 'caddy.service' in (ansible_facts.services | default({})) - -- name: Enable and start caddy - ansible.builtin.systemd: - name: caddy - enabled: true - state: started - when: not ansible_check_mode or 'caddy.service' in (ansible_facts.services | default({})) - -# --- fail2ban: ban IPs that repeatedly fail RPC basic auth (public listener) --- -# Only meaningful for the public HTTPS listener; the loopback/CI listener has no -# attack surface and runs without baseline (so fail2ban isn't installed there). -# fail2ban itself ships with baseline, which always runs before caddy in anvil.yml. - -# Fail loud, at the cause: if the jail is enabled but fail2ban is absent (e.g. a -# standalone `--tags caddy` run that skipped baseline), the copies below would -# write config for a non-existent service and the handler would abort later with a -# confusing "service not found". Skipped under --check on a not-yet-provisioned host. -- name: Assert fail2ban is installed before wiring the caddy-rpc jail - ansible.builtin.assert: - that: "'fail2ban.service' in (ansible_facts.services | default({}))" - fail_msg: >- - caddy_fail2ban is enabled but fail2ban is not installed on this host. It - ships with the baseline role — run the full anvil.yml (baseline before - caddy), or set caddy_fail2ban=false to skip RPC brute-force protection. - when: caddy_public | bool and caddy_fail2ban | bool and not ansible_check_mode - -- name: Install the caddy-rpc fail2ban filter - ansible.builtin.copy: - dest: /etc/fail2ban/filter.d/caddy-rpc.conf - owner: root - group: root - mode: "0644" - content: | - # MANAGED BY the caddy role — do not edit by hand. - # Ban on a basic-auth failure (HTTP 401) in Caddy's one-line JSON access - # record, capturing the connecting IP. Order-independent lookaheads: JSON - # keys are unordered and Caddy/zap may reorder them across versions, so a - # positional `field.*field` regex would silently stop matching. Each - # `(?")(?=.*(? 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 as JSON to a (self-rolling) file: auditing who hit the RPC, - # and the log fail2ban's caddy-rpc jail watches for repeated 401s. The jail's - # datepattern parses the `ts` string field, so `time_format iso8601` is required - # here (the Caddy default is a Unix-epoch float the datepattern can't read). - log { - output file {{ caddy_access_log }} { - roll_size 10MiB - roll_keep 5 - } - format json { - time_format iso8601 - } - } -} -{% else %} -{ - # No upstream TLS to provision here, and no admin API surface needed. - auto_https off - admin off -} - -# Loopback-only listener. `bind 127.0.0.1` is defence-in-depth so the port can -# never answer on a public interface even if the firewall is misconfigured. -:{{ caddy_bind_port }} { - bind 127.0.0.1 - - # 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 as JSON to a (self-rolling) file: auditing who hit the RPC, - # and the log fail2ban's caddy-rpc jail watches for repeated 401s. The jail's - # datepattern parses the `ts` string field, so `time_format iso8601` is required - # here (the Caddy default is a Unix-epoch float the datepattern can't read). - log { - output file {{ caddy_access_log }} { - roll_size 10MiB - roll_keep 5 - } - format json { - time_format iso8601 - } - } -} -{% endif %} diff --git a/ansible/roles/contracts/README.md b/ansible/roles/contracts/README.md deleted file mode 100644 index a9a7748..0000000 --- a/ansible/roles/contracts/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# roles/contracts — STUB (out of scope for v1) - -Deterministic deployment of the deCDN contracts onto the devnet is **not wired up -yet**. - -When it lands, this role will: - -- vendor / build the Foundry project for the deCDN contracts, -- deploy via **CREATE2** so addresses are identical for every dev and stable across - resets (`address = f(factory, salt, initcode)`, independent of nonce), -- write the address manifest to `/var/lib/anvil/deployments.json`, -- be added to `playbooks/anvil.yml` after the `anvil` role. - -Contract addresses and any protocol facts must trace back to an ADR in -`decdn/adr/` — never invented here (see the repo `CLAUDE.md`). - -Until then this role is a no-op and is intentionally absent from `anvil.yml`. diff --git a/ansible/roles/contracts/tasks/main.yml b/ansible/roles/contracts/tasks/main.yml deleted file mode 100644 index 3a8381c..0000000 --- a/ansible/roles/contracts/tasks/main.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -# STUB — contract deployment is out of scope for v1. See roles/contracts/README.md. -# This role intentionally does nothing yet; it is not wired into playbooks/site.yml. -- name: Contracts role is a stub (out of scope v1) - ansible.builtin.debug: - msg: >- - The contracts role is a placeholder. Deterministic (CREATE2) deploys will - plug in here — see roles/contracts/README.md. diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index dc360ba..082477e 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -1,8 +1,8 @@ # roles/decdn_node Provisions a **public deCDN node** (`decdn-node` daemon) from a pinned GitHub -Release tarball, under a hardened systemd unit. This is the repo's **primary** -deployment (`playbooks/site.yml`); the anvil devnet is separate internal tooling. +Release tarball, under a hardened systemd unit. This is the repo's deployment +(`playbooks/site.yml`). ## What this role does (and does not) From eea09d0e6bbd98586a4ac3d915368a9cb65bfc97 Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sat, 11 Jul 2026 19:18:04 +0300 Subject: [PATCH 2/2] docs(galaxy): reword build.sh comment for readability The tool list dropped from three items to two when molecule was removed; join the remaining two with "and" so the plural subject reads cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/galaxy/build.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ansible/galaxy/build.sh b/ansible/galaxy/build.sh index aad1884..29ec93c 100755 --- a/ansible/galaxy/build.sh +++ b/ansible/galaxy/build.sh @@ -4,8 +4,8 @@ # Only the roles/baseline + roles/decdn_node sources ship. All deploy machinery # (inventory, Makefile, ansible.cfg) is excluded BY CONSTRUCTION — it is simply # never copied into the staging tree. This keeps the artifact clean and leaves the -# internal project untouched (no galaxy.yml at the project root, so ansible-lint / -# ansible still see a plain project). +# internal project untouched (no galaxy.yml at the project root, so ansible-lint +# and ansible still see a plain project). # # Output: ansible/build/decdn-node-.tar.gz set -euo pipefail