From 09d1759526d6a53fe9a5e550dbf9e8489180337b Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sat, 11 Jul 2026 19:45:05 +0300 Subject: [PATCH 1/3] chore: prepare repo for public release as the official deCDN Ansible De-brand docs and re-add automated node test coverage so this repo can be published as the official Ansible for deploying a deCDN node. - Docs: drop the private-workspace framing (the /home/thiras path, "team's monorepo", "workspace CLAUDE.md") from README/AGENTS/CONTRIBUTING/ansible READMEs; point cross-refs at AGENTS.md and the dead legacy `services/`/ `etc/`-mirror convention is removed. - Genericize private sibling-repo references: `decdn/adr/` and `decdn/contracts/deployments/.json` become prose ("the deCDN ADRs / contract deployment") with no dead links; public Arbitrum Sepolia testnet contract addresses stay. - Document the binary-source dependency: release installs need a publicly reachable `v` release; override `decdn_node_release_base` or use the `manual` install method otherwise. - Add a `decdn_node` molecule scenario (converge/idempotence/verify in a privileged systemd container against a stub daemon), re-add the `molecule.yml` workflow and `make molecule` targets, and note it in the Testing/CI docs. - CI: reword the KICS SARIF comment to drop the private-repo assumption. - .gitignore: ignore Python bytecode (molecule stub / local tooling). Verified: yamllint + ansible-lint (production), site.yml syntax-check, make build + galaxy-importer, molecule test (exit 0), KICS (0 HIGH), actionlint. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 4 +- .github/workflows/molecule.yml | 39 ++++++++++ .gitignore | 4 + AGENTS.md | 37 ++++------ CONTRIBUTING.md | 5 +- Makefile | 5 +- README.md | 28 +++---- ansible/Makefile | 8 +- ansible/README.md | 35 ++++++--- .../inventory/host_vars/decdn-node-1/main.yml | 8 +- ansible/molecule/default/converge.yml | 23 ++++++ .../molecule/default/files/decdn-node-stub | 46 ++++++++++++ ansible/molecule/default/molecule.yml | 41 ++++++++++ ansible/molecule/default/prepare.yml | 32 ++++++++ ansible/molecule/default/verify.yml | 74 +++++++++++++++++++ ansible/roles/baseline/README.md | 4 +- ansible/roles/decdn_node/README.md | 17 +++-- ansible/roles/decdn_node/tasks/main.yml | 8 +- 18 files changed, 347 insertions(+), 71 deletions(-) create mode 100644 .github/workflows/molecule.yml create mode 100644 ansible/molecule/default/converge.yml create mode 100755 ansible/molecule/default/files/decdn-node-stub create mode 100644 ansible/molecule/default/molecule.yml create mode 100644 ansible/molecule/default/prepare.yml create mode 100644 ansible/molecule/default/verify.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 318cd29..99d5e5b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,8 +131,8 @@ jobs: name: kics-results path: kics-results/ if-no-files-found: ignore - # If GitHub Advanced Security is enabled on this private repo, surface KICS - # findings in the Security tab by un-commenting the block below (add + # To surface KICS findings in the repo's Security tab, enable GitHub code + # scanning (free on public repos) and un-comment the block below (add # `security-events: write` to this job's permissions): # - name: Upload SARIF to code scanning # if: always() diff --git a/.github/workflows/molecule.yml b/.github/workflows/molecule.yml new file mode 100644 index 0000000..45933da --- /dev/null +++ b/.github/workflows/molecule.yml @@ -0,0 +1,39 @@ +--- +name: Molecule + +# Containerised converge + idempotence + verify for the decdn_node role. +# 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 63cd508..cb837cf 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ kics-results/ ansible/build/ *.tar.gz ansible/importer_result.json + +# Python bytecode (e.g. from the molecule stub daemon or any local tooling) +__pycache__/ +*.pyc diff --git a/AGENTS.md b/AGENTS.md index 6597309..e359d43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,18 +1,16 @@ # AGENTS.md — decdn-devops Guidance for AI coding agents (Claude Code, Codex, Cursor, …) working in the deCDN -DevOps monorepo. +DevOps repo. ## What this repo is -This is the deCDN team's **DevOps monorepo**: infrastructure, deployment, and -operational tooling. It is one of the independent git repositories inside the -`/home/thiras/dev/decdn/` workspace (see the workspace-level `CLAUDE.md`). Today the -whole repo is **Ansible-driven**: `ansible/` is the deployment project. `services/` is a -reserved convention for future imperative bash + systemd units and is currently empty. +The official Ansible project for deploying a **deCDN node**: infrastructure, deployment, +and operational tooling. The whole repo is **Ansible-driven** — `ansible/` is the +deployment project. This repo is **infrastructure only**. It is *not* a source of truth for protocol or -economic claims — those live in `decdn/adr/`. If something here states a protocol fact +economic claims — those trace to the deCDN ADRs. If something here states a protocol fact (chain-id, token address, fee split), it must trace back to an ADR, not invent one. ## Hard rules @@ -28,9 +26,9 @@ economic claims — those live in `decdn/adr/`. If something here states a proto 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`. +3. **Role templates render to their target paths.** Ansible roles template config directly + onto the host (e.g. `roles/decdn_node/templates/decdn-node.service.j2` → + `/etc/systemd/system/`), with secrets generated on the host at `0600`. 4. **Scripts are idempotent and fail loud.** `set -euo pipefail`, re-runnable, refuse to overwrite existing secrets, and require typed confirmation before destructive ops. 5. **Show before installing.** When building or changing infra, present the files; the @@ -39,34 +37,22 @@ economic claims — those live in `decdn/adr/`. If something here states a proto ## Layout -The repo's one active unit is the Ansible project; `services/` is a reserved convention: - ``` ansible/ # the deployment project (DevSec-hardened, lean roles) 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/ + inventory/ galaxy/ molecule/ # see ansible/README.md ``` -For Ansible-managed services the `etc/`-mirror convention (hard-rule #3) does not apply — -role **templates** render to their target paths instead. Hard-rules #1 (no committed -secrets; generated on host) and #2 (localhost-only by default) hold for both conventions. - ## Current services -- **`ansible/`** — the team's declarative deployment project. **The public deCDN node** +- **`ansible/`** — the 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 Two Makefiles: the **root** is the hygiene/security/CI mirror; **`ansible/`** drives @@ -78,6 +64,7 @@ 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 decdn_node role (needs Docker) # Ansible deploys — run from ansible/ (see ansible/README.md for the full flow) cd ansible @@ -97,3 +84,5 @@ collection tree by `galaxy/build.sh` — there is **no** `galaxy.yml` at the `an `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 ec879b5..059c9d9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ Two checks run on every change: **pre-commit** locally and **GitHub Actions** on push / PR. The repo's #1 rule still stands — **never commit secrets** (see -[`CLAUDE.md`](CLAUDE.md)); secrets are generated on the target host and the repo +[`AGENTS.md`](AGENTS.md)); secrets are generated on the target host and the repo ships `*.example` templates for them (never the real thing; non-secret config is committed directly). There is no dedicated secret-scanner in the pipeline — keep secrets out by design (and rely on GitHub's push protection). @@ -33,6 +33,9 @@ Run it on demand with `make lint-ansible`, or `pre-commit run ansible-lint --hoo - **`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. +- **`molecule.yml`** — containerised converge + idempotence + verify of the `decdn_node` + role (privileged systemd Docker container; scoped to `ansible/**`). Run locally with + `make molecule` (needs Docker). ## Supply-chain / pinning rules diff --git a/Makefile b/Makefile index e52d3f9..ccc8e5f 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 galaxy-build galaxy-check +.PHONY: help hooks lint lint-ansible security molecule galaxy-build galaxy-check SHELL := /bin/bash # Local KICS runs use the engine image pinned by digest. CI runs the official @@ -32,6 +32,9 @@ 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 decdn_node role (needs Docker) + $(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 5c37eb1..6d8dd14 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,13 @@ [![shellcheck](https://img.shields.io/badge/shellcheck-passing-brightgreen)](https://www.shellcheck.net/) [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://www.conventionalcommits.org) -The deCDN team's **DevOps monorepo** — infrastructure, deployment, and operational -tooling for the deCDN project, driven by a single declarative [Ansible](ansible/README.md) -project. +The official **DevOps repo** for deploying a deCDN node — infrastructure, deployment, and +operational tooling, driven by a single declarative [Ansible](ansible/README.md) project. This repo is **infrastructure only**. It is *not* a source of truth for protocol or -economic facts (chain-id, token addresses, fee splits) — those live in `decdn/adr/` (see -the workspace `CLAUDE.md`). Anything here that states a protocol fact traces back to an -ADR; nothing is invented in this repo. +economic facts (chain-id, token addresses, fee splits) — those trace to the deCDN ADRs. +Anything here that states a protocol fact traces back to an ADR; nothing is invented in +this repo. ## What it deploys @@ -42,10 +41,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). The whole deploy surface lives here. | +| [`ansible/`](ansible/README.md) | The **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`. | -| `.github/workflows/` | The blocking CI gate (`ansible-lint` + KICS + `galaxy-build` + `actionlint`). | +| `.github/workflows/` | The blocking CI gate (`ansible-lint` + KICS + `galaxy-build` + `molecule` + `actionlint`). | ## Quickstart @@ -101,7 +100,8 @@ make check / deploy # deCDN node (site.yml): dry-run / provision **Gotcha — pre-commit is local-only.** Hygiene/shellcheck/yamllint/markdown run via `make hooks`/`make lint` on your machine, **not** in CI. The blocking gate is -`.github/workflows/` (`ansible-lint` + KICS on `ansible/**` + `actionlint`). `ansible-lint` +`.github/workflows/` (`ansible-lint` + KICS + `galaxy-build` + `molecule` on `ansible/**`, +plus `actionlint`). `ansible-lint` is not a per-commit hook (it needs collections vendored) — run `make lint-ansible`. ## CI & quality gates @@ -111,12 +111,14 @@ is not a per-commit hook (it needs collections vendored) — run `make lint-ansi 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). +- **`molecule.yml`** — a containerised converge + idempotence + verify of the `decdn_node` + role in a privileged systemd Docker container (scoped to `ansible/**` changes). ## Conventions & source of truth -- **ADRs are the only source of truth for protocol facts.** They live in `decdn/adr/` - (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. +- **ADRs are the only source of truth for protocol facts.** The deCDN ADRs cover, e.g., + payments (ADR 003), node onboarding (ADR 019), and 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/decdn_node/templates/decdn-node.service.j2` → `/etc/systemd/system/`), with secrets generated on the host at `0600`. @@ -126,4 +128,4 @@ is not a per-commit hook (it needs collections vendored) — run `make lint-ansi - [`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 - [`CONTRIBUTING.md`](CONTRIBUTING.md) — the local + CI check workflow -- workspace `CLAUDE.md` — repo hard rules and the single-source-of-truth policy +- [`AGENTS.md`](AGENTS.md) — repo hard rules and conventions (for humans and AI agents) diff --git a/ansible/Makefile b/ansible/Makefile index 53c4f63..e5c0e18 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 build galaxy-check +.PHONY: deps lint check deploy molecule build galaxy-check SHELL := /bin/bash # Install the required Galaxy collections (>= constraints in requirements.yml) @@ -22,6 +22,12 @@ check: deploy: ansible-playbook playbooks/site.yml +# --- Tests ------------------------------------------------------------------- +# Containerised converge + idempotence + verify of the decdn_node role against a +# stub daemon (needs Docker; a privileged systemd container). See molecule/. +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 86138b1..f5f2da7 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -1,6 +1,6 @@ # ansible — deCDN deployment -Declarative Ansible project for the deCDN team. One deployment over a shared host +Declarative Ansible project for deploying a deCDN node. One deployment over a shared host baseline: | Playbook | Purpose | Exposure | @@ -56,7 +56,7 @@ it local. By default baseline **deploys you as yourself**: an empty `ssh_admin_user` resolves to your control-machine `$USER`, and an empty `ssh_admin_pubkey` is autodetected from `~/.ssh` -(`id_ed25519` > `ecdsa` > `rsa`). Add teammates' keys via `ssh_admin_extra_pubkeys`. Set +(`id_ed25519` > `ecdsa` > `rsa`). Add additional operators' keys via `ssh_admin_extra_pubkeys`. Set `ssh_admin_user`/`ssh_admin_pubkey` explicitly to override (e.g. a shared `deploy` account, or when deploying from CI). baseline **asserts a key resolves** before `ssh_hardening` disables root + password login, so you can't lock yourself out. After the first deploy, @@ -69,16 +69,22 @@ one). **Prerequisites** (see `roles/decdn_node/README.md` for the full flow): -1. A published **`v` release** exists (the role downloads the release tarball). +1. A published **`v` release** exists and is **publicly downloadable** — the role + fetches the release tarball from `decdn_node_release_base` + (default `https://github.com/decdn/decdn/releases/download`). To install from a mirror, + override `decdn_node_release_base`; to deploy a locally-built binary with no release at + all, use the **`manual`** install method (`decdn_node_install_method: manual` + + `decdn_node_manual_bin_src` / `decdn_cli_manual_bin_src`). 2. Per-node config in `inventory/host_vars//main.yml` (committed) — `decdn_node_version`, the three contract addresses, `decdn_region`, cache origin, … — plus the one secret, `decdn_rpc_url`, in a sibling git-ignored `secret.yml` (copy the shipped `host_vars/decdn-node-1/secret.yml.example`). The committed `main.yml` already carries the Arbitrum Sepolia genesis contract addresses; edit `decdn_region` + cache origin for your - node. Contract addresses are protocol facts — source them from the deployment / an ADR, - never guess. + node. Contract addresses are protocol facts — source them from the deCDN contract + deployment / an ADR, never guess. 3. The **eth keystore + password file** provisioned on the host (operator step — the wallet - must be funded + staked per `decdn/adr/019`). Generate with, as the `decdn` user: + must be funded + staked per the deCDN node-onboarding ADR, 019). Generate with, as the + `decdn` user: `decdn key-gen --output-dir /var/lib/decdn --password-file /etc/decdn/keystore.password`. ```bash @@ -107,13 +113,18 @@ with no turnkey CLI yet (see `roles/decdn_node/README.md`). ```bash make lint # yamllint + ansible-lint (production profile) ansible-playbook playbooks/site.yml --syntax-check +make molecule # containerised converge + idempotence + verify (needs Docker) ``` -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. +`make molecule` converges the **`decdn_node`** role in a privileged systemd container +against a stub daemon (`molecule/default/`): it installs via the `manual` method (no +published release needed), stages a placeholder keystore, renders `node.toml` + the +hardened unit, starts the service, and passes the role's own `/metrics` readiness probe; +`verify.yml` then asserts the node user, valid TOML, a valid systemd unit, loopback-only +metrics binding, and the `0600` secret env file. It does **not** exercise real node logic +or a live chain — full paid-traffic readiness still needs on-chain registration and a real +release. `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 @@ -124,7 +135,7 @@ the RPC URL). Highlights: | Var | Default | Notes | |-----|---------|-------| | `ssh_admin_user` / `ssh_admin_pubkey` | `""` / `""` | Empty = local `$USER` + autodetected `~/.ssh` key; admin created before SSH hardening. | -| `ssh_admin_extra_pubkeys` | `[]` | Extra authorized keys for the admin user (teammates). | +| `ssh_admin_extra_pubkeys` | `[]` | Extra authorized keys for the admin user (additional operators). | | `baseline_extra_inbound` | `[]` | public inbound ports; `decdn_nodes` opens udp/4433. | | `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. | diff --git a/ansible/inventory/host_vars/decdn-node-1/main.yml b/ansible/inventory/host_vars/decdn-node-1/main.yml index 093656a..aefdd3b 100644 --- a/ansible/inventory/host_vars/decdn-node-1/main.yml +++ b/ansible/inventory/host_vars/decdn-node-1/main.yml @@ -6,9 +6,9 @@ # until both this file and secret.yml are set. # # The contract addresses below are the canonical Arbitrum Sepolia (chain 421614) -# v0.1.0 genesis deploy, cited to decdn/contracts/deployments/421614.json -# (deployBlock 11249862) — public on-chain facts, not invented here. If you target -# a different deployment, replace them (and chain_id) with that deployment's values. +# v0.1.0 genesis deploy (deployBlock 11249862) — public on-chain facts from the deCDN +# contract deployment, not invented here. If you target a different deployment, replace +# them (and chain_id) with that deployment's values. # MUST-EDIT lines are yours to fill; the rest match the genesis deploy. # Pinned release to install (a v GitHub Release must exist). @@ -19,7 +19,7 @@ decdn_node_sha256: "" # --- Chain (Arbitrum Sepolia, chain 421614) ----------------------------------- # The RPC endpoint (decdn_rpc_url) is the one sensitive value — set it in secret.yml. decdn_chain_id: 421614 # Arbitrum Sepolia -# Required contracts (deployments/421614.json). +# Required contracts (from the chain-421614 deployment). decdn_payment_channel_address: "0xb4bcA0AbF679212708164dCAb98eFa621Fa0F4d3" # PaymentChannel decdn_capacity_bond_address: "0x2aF490628579c08DC6D0B013090cDC11D4d60Dd4" # CapacityBond decdn_slash_judge_address: "0x20abcCC80F595a586f4Bd906a1b00c4196416EB7" # SlashJudge diff --git a/ansible/molecule/default/converge.yml b/ansible/molecule/default/converge.yml new file mode 100644 index 0000000..f858e5a --- /dev/null +++ b/ansible/molecule/default/converge.yml @@ -0,0 +1,23 @@ +--- +# Exercise the decdn_node role only (baseline is real-host-only — see molecule.yml). +# `manual` install copies the stub daemon from the scenario's files/ dir, so no +# published release is needed. The required chain/contract knobs use placeholder +# (but well-formed) values — the role's fail-loud asserts still run against them. +- name: Converge + hosts: all + become: true + vars: + stub_bin: "{{ lookup('ansible.builtin.env', 'MOLECULE_SCENARIO_DIRECTORY') }}/files/decdn-node-stub" + decdn_node_install_method: manual + decdn_node_manual_bin_src: "{{ stub_bin }}" + decdn_cli_manual_bin_src: "{{ stub_bin }}" + decdn_rpc_url: "https://rpc.example.invalid/" + decdn_chain_id: 421614 + decdn_region: "US" + decdn_payment_channel_address: "0x1111111111111111111111111111111111111111" + decdn_capacity_bond_address: "0x2222222222222222222222222222222222222222" + decdn_slash_judge_address: "0x3333333333333333333333333333333333333333" + decdn_cache_origin_kind: "http" + decdn_cache_origin_url: "https://origin.example.invalid/" + roles: + - role: decdn_node diff --git a/ansible/molecule/default/files/decdn-node-stub b/ansible/molecule/default/files/decdn-node-stub new file mode 100755 index 0000000..d66ace5 --- /dev/null +++ b/ansible/molecule/default/files/decdn-node-stub @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Stub deCDN node binary for the Molecule scenario. + +Stands in for the real `decdn-node` (and `decdn` CLI) so the `decdn_node` role +can be exercised end-to-end without a published release or a live chain: + + * `--version` -> print a version line and exit 0 (satisfies the role's + install-time `--version` backstop / liveness check). + * `run ...` (default) -> serve HTTP 200 on 127.0.0.1:9090 for any path, so the + role's `/metrics` readiness probe succeeds, and block + forever under systemd (Type=simple). All CLI args + (`--config`, `--keystore-password-file`) are ignored. + +It intentionally implements no protocol behaviour — the scenario verifies host +prep, config/unit rendering, hardening, and loopback binding, not node logic. +""" +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +METRICS_ADDR = ("127.0.0.1", 9090) # matches the role's default metrics_bind/port + + +class _Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 (stdlib-mandated name) + body = b"# stub decdn-node metrics\ndecdn_stub_up 1\n" + self.send_response(200) + self.send_header("Content-Type", "text/plain; version=0.0.4") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): # silence per-request logging + pass + + +def main(): + if "--version" in sys.argv: + print("decdn-node 0.0.0-molecule-stub") + return 0 + # Any other invocation (the systemd `run` subcommand) serves metrics forever. + HTTPServer(METRICS_ADDR, _Handler).serve_forever() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ansible/molecule/default/molecule.yml b/ansible/molecule/default/molecule.yml new file mode 100644 index 0000000..f222d2d --- /dev/null +++ b/ansible/molecule/default/molecule.yml @@ -0,0 +1,41 @@ +--- +# Containerised converge + idempotence + verify for the public deCDN node. +# Exercises the `decdn_node` role end-to-end against a stub daemon: install +# (manual mode, so no published release is needed), the operator-keystore gate, +# node.toml + a hardened systemd unit, service start, and the role's own +# /metrics readiness probe. +# +# `baseline` is NOT exercised here — it 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 via `make check` instead. +dependency: + name: galaxy + options: + requirements-file: ../../requirements.yml +driver: + name: docker +platforms: + - name: decdn-node-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" +verifier: + name: ansible +scenario: + test_sequence: + - dependency + - create + - prepare + - converge + - idempotence + - verify + - destroy diff --git a/ansible/molecule/default/prepare.yml b/ansible/molecule/default/prepare.yml new file mode 100644 index 0000000..c5dffac --- /dev/null +++ b/ansible/molecule/default/prepare.yml @@ -0,0 +1,32 @@ +--- +# The decdn_node role requires an operator-provisioned eth keystore + password +# file to already exist before it starts the daemon (it never generates them). +# Stage placeholder files here so the role's keystore gate passes in CI. The +# stub daemon ignores them; this scenario does not exercise real wallet logic. +- name: Prepare + hosts: all + become: true + vars: + decdn_home: /var/lib/decdn + decdn_etc: /etc/decdn + tasks: + - name: Ensure data + config directories exist + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ decdn_home }}" + - "{{ decdn_etc }}" + + - name: Stage a placeholder eth keystore + ansible.builtin.copy: + dest: "{{ decdn_home }}/keystore.json" + content: "{{ '{}' }}\n" + mode: "0600" + + - name: Stage a placeholder keystore password file + ansible.builtin.copy: + dest: "{{ decdn_etc }}/keystore.password" + content: "molecule-placeholder\n" + mode: "0600" diff --git a/ansible/molecule/default/verify.yml b/ansible/molecule/default/verify.yml new file mode 100644 index 0000000..db388e1 --- /dev/null +++ b/ansible/molecule/default/verify.yml @@ -0,0 +1,74 @@ +--- +# Verify what a container can assert without a live chain: the node user, a +# valid non-secret node.toml, a valid + hardened systemd unit, loopback-only +# metrics binding, 0600 on the secret env file, and a running service. Real +# paid-traffic readiness additionally needs on-chain registration (not tested). +- name: Verify + hosts: all + become: true + vars: + decdn_etc: /etc/decdn + tasks: + - name: Look up the decdn user + ansible.builtin.getent: + database: passwd + key: decdn + + - name: Assert the decdn system user exists with a nologin shell + ansible.builtin.assert: + that: + - getent_passwd['decdn'] is defined + - "'nologin' in getent_passwd['decdn'][5]" + fail_msg: "decdn system user missing or has a login shell" + + - name: Stat the rendered config + secret env file + ansible.builtin.stat: + path: "{{ item }}" + register: decdn_files + loop: + - "{{ decdn_etc }}/node.toml" + - "{{ decdn_etc }}/decdn.env" + - /etc/systemd/system/decdn-node.service + + - name: Assert node.toml (0640) and the secret env file (0600) modes + ansible.builtin.assert: + that: + - decdn_files.results[0].stat.exists + - decdn_files.results[0].stat.mode == '0640' + - decdn_files.results[1].stat.exists + - decdn_files.results[1].stat.mode == '0600' + - decdn_files.results[2].stat.exists + fail_msg: "node.toml / decdn.env / unit missing or wrong mode" + + - name: Assert node.toml parses as valid TOML + ansible.builtin.command: + cmd: python3 -c "import tomllib; tomllib.load(open('/etc/decdn/node.toml','rb'))" + changed_when: false + + - name: Validate the systemd unit + ansible.builtin.command: + cmd: systemd-analyze verify /etc/systemd/system/decdn-node.service + changed_when: false + + - name: Collect listening TCP sockets + ansible.builtin.command: + cmd: ss -ltn + register: listeners + changed_when: false + + - name: Assert metrics bind loopback only (never 0.0.0.0) + ansible.builtin.assert: + that: + - "'127.0.0.1:9090' in listeners.stdout" + - "'0.0.0.0:9090' not in listeners.stdout" + fail_msg: "metrics port 9090 is not loopback-only: {{ listeners.stdout }}" + + - name: Gather service facts + ansible.builtin.service_facts: + + - name: Assert decdn-node is enabled and running + ansible.builtin.assert: + that: + - "'decdn-node.service' in ansible_facts.services" + - ansible_facts.services['decdn-node.service'].state == 'running' + fail_msg: "decdn-node is not running — check: journalctl -u decdn-node -e" diff --git a/ansible/roles/baseline/README.md b/ansible/roles/baseline/README.md index 8cf6d63..ab938c6 100644 --- a/ansible/roles/baseline/README.md +++ b/ansible/roles/baseline/README.md @@ -12,7 +12,7 @@ In order — the ordering matters: 2. **Admin sudo user** — creates `ssh_admin_user` and installs its key(s) **before** SSH is hardened, so you keep a way in. Both resolve from the control machine when left empty: the user falls back to the local `$USER`, and the key is - autodetected from `~/.ssh` (`id_ed25519` > `id_ecdsa` > `id_rsa`). Extra team keys + autodetected from `~/.ssh` (`id_ed25519` > `id_ecdsa` > `id_rsa`). Extra operator keys come from `ssh_admin_extra_pubkeys`. Explicit values always win. 3. **Firewall** — nftables **default-deny inbound**; SSH is the only universally-open port. Extra public listeners are declared explicitly via `baseline_extra_inbound`. @@ -50,7 +50,7 @@ expect — set both explicitly in that case. | `ssh_admin_user` | `""` | Admin sudo account; created before SSH hardening. Empty = the control machine's local `$USER`. | | `ssh_admin_pubkey` | `""` | Admin key. Empty = autodetected from `~/.ssh` (`id_ed25519`/`ecdsa`/`rsa`). Set to override. | | `ssh_admin_pubkey_autodetect` | `true` | When `ssh_admin_pubkey` is empty, read the operator's default local public key. | -| `ssh_admin_extra_pubkeys` | `[]` | Additional authorized keys (full pubkey strings) — e.g. teammates. | +| `ssh_admin_extra_pubkeys` | `[]` | Additional authorized keys (full pubkey strings) — e.g. other operators. | | `ssh_allow_cidrs` | `[]` | Optional inbound-SSH source allowlist (CIDRs). Empty = any source. | | `baseline_extra_inbound` | `[]` | Extra public inbound ports. Each item `{proto, port, comment}`. Loopback services need nothing here; the deCDN node opens udp/4433. | | `baseline_packages` | see `defaults/main.yml` | Base package set. | diff --git a/ansible/roles/decdn_node/README.md b/ansible/roles/decdn_node/README.md index 74f05ca..c2fed92 100644 --- a/ansible/roles/decdn_node/README.md +++ b/ansible/roles/decdn_node/README.md @@ -8,7 +8,7 @@ yet. This is the repo's deployment (`playbooks/site.yml`). ## What this role does (and does not) -Per `decdn/adr/019-node-onboarding.md`, a node only serves paid traffic after +Per the deCDN node-onboarding ADR (019), a node only serves paid traffic after **on-chain stake + registration**. That split maps onto this role as: - **Ansible (this role): Phase 1 host prep + Phase 3 startup** — install binaries, @@ -24,9 +24,12 @@ Per `decdn/adr/019-node-onboarding.md`, a node only serves paid traffic after - **`release`** (default) — the role downloads `decdn-node--.tar.gz` (and the `decdn` CLI) from - `github.com/decdn/decdn/releases`. Set `decdn_node_version` to a real - `v` release. *(No release exists yet — either cut one with the - upstream `release.yml` workflow, or use `manual` below in the meantime.)* + `decdn_node_release_base` (default + `https://github.com/decdn/decdn/releases/download`). Set `decdn_node_version` to a real + `v` release, which must be **publicly reachable** from the target host; + override `decdn_node_release_base` to fetch from a mirror. *(No release exists yet — + either cut one with the upstream `release.yml` workflow, or use `manual` below in the + meantime.)* - **`manual`** — the role copies the two binaries **verbatim** from the paths you give it (`decdn_node_manual_bin_src` + `decdn_cli_manual_bin_src`) on the Ansible control machine; it does *not* consult `decdn_node_target`, so you are @@ -85,9 +88,9 @@ Optional (omitted from `node.toml` unless set): `[cache.origin]` and cache misses fail `NoOrigin` (the node can only serve blobs it already holds). -Source contract addresses / chain-id from the deployment -(`contracts/deployments/.json`) or an ADR — never guess. See -`roles/decdn_node/defaults/main.yml` for the full knob list and defaults. +Source contract addresses / chain-id from the deCDN contract deployment for your target +chain, or the relevant ADR — never guess. See `roles/decdn_node/defaults/main.yml` for the +full knob list and defaults. ## Network diff --git a/ansible/roles/decdn_node/tasks/main.yml b/ansible/roles/decdn_node/tasks/main.yml index 481ac57..ed4aa8f 100644 --- a/ansible/roles/decdn_node/tasks/main.yml +++ b/ansible/roles/decdn_node/tasks/main.yml @@ -47,8 +47,8 @@ fail_msg: >- decdn_rpc_url, a positive decdn_chain_id, the three contract addresses (0x + 40 hex, none the zero address), and an uppercase ISO-3166 2-letter - decdn_region are required. Source chain-id + contract addresses from the - deployment / an ADR in decdn/adr/ — never guess. Set them in host_vars. + decdn_region are required. Source chain-id + contract addresses from the deCDN + contract deployment / the relevant ADR — never guess. Set them in host_vars. vars: decdn_zero_address: "0x0000000000000000000000000000000000000000" @@ -98,8 +98,8 @@ fail_msg: >- The origin directory (ADR 022) needs BOTH decdn_origin_assignment_address and decdn_publisher_registry_address set to valid contract addresses (0x + 40 hex, - not the zero address) — or BOTH empty to omit it. Source them from the - deployment (contracts/deployments/.json) / ADR 022. + not the zero address) — or BOTH empty to omit it. Source them from the deCDN + contract deployment for your target chain / ADR 022. vars: decdn_zero_address: "0x0000000000000000000000000000000000000000" when: >- From 01e2a28d8934a574ec6b68600ac05b203e3acb3a Mon Sep 17 00:00:00 2001 From: yigitdot Date: Sat, 11 Jul 2026 20:01:39 +0300 Subject: [PATCH 2/3] fix(molecule): short-circuit verify asserts, pin base image digest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address bot review feedback on PR #20: - verify.yml: combine each guard+deref pair into a single `and` expression (user/nologin, file exists+mode, service membership+state). Ansible templates each `that:` item independently — the separate guard lines did not protect the later derefs, so an absent user/file/service raised AnsibleUndefinedVariable and bypassed the fail_msg. One expression restores short-circuiting and the intended clean failure message. (Gemini Code Assist) - molecule.yml: pin the geerlingguy/docker-debian12-ansible base image by digest instead of :latest, matching the repo's SHA/digest-pinning convention for reproducible CI. (Copilot) Declined Copilot's PyPI-pinning suggestion: ci.yml installs its Python tooling unpinned with --upgrade in every job; pinning only molecule.yml would be the lone inconsistent workflow. Reproducibility of the systemd/docker layer is covered by the pinned base image above. Co-Authored-By: Claude Opus 4.8 --- ansible/molecule/default/molecule.yml | 5 ++++- ansible/molecule/default/verify.yml | 20 ++++++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/ansible/molecule/default/molecule.yml b/ansible/molecule/default/molecule.yml index f222d2d..90024ea 100644 --- a/ansible/molecule/default/molecule.yml +++ b/ansible/molecule/default/molecule.yml @@ -16,7 +16,10 @@ driver: name: docker platforms: - name: decdn-node-molecule - image: geerlingguy/docker-debian12-ansible:latest + # Pinned by digest for reproducible CI (repo convention — cf. the SHA-pinned + # actions/images in .github/workflows/ci.yml). Tag: :latest as of 2026-07-11 + # (this image publishes only :latest). Re-resolve the digest to bump. + image: geerlingguy/docker-debian12-ansible@sha256:4553092be2c00b1ffe580927b9ff03f3c3a0df32b7dd693a3eb02efb6c2b77b7 pre_build_image: true command: /usr/lib/systemd/systemd privileged: true diff --git a/ansible/molecule/default/verify.yml b/ansible/molecule/default/verify.yml index db388e1..22bb446 100644 --- a/ansible/molecule/default/verify.yml +++ b/ansible/molecule/default/verify.yml @@ -17,8 +17,9 @@ - name: Assert the decdn system user exists with a nologin shell ansible.builtin.assert: that: - - getent_passwd['decdn'] is defined - - "'nologin' in getent_passwd['decdn'][5]" + # Single expression so a missing user short-circuits to the fail_msg + # rather than raising AnsibleUndefinedVariable on the [5] deref. + - getent_passwd['decdn'] is defined and 'nologin' in getent_passwd['decdn'][5] fail_msg: "decdn system user missing or has a login shell" - name: Stat the rendered config + secret env file @@ -33,10 +34,10 @@ - name: Assert node.toml (0640) and the secret env file (0600) modes ansible.builtin.assert: that: - - decdn_files.results[0].stat.exists - - decdn_files.results[0].stat.mode == '0640' - - decdn_files.results[1].stat.exists - - decdn_files.results[1].stat.mode == '0600' + # exists-and-mode in one expression: a missing file short-circuits to + # the fail_msg instead of raising on the absent stat.mode. + - decdn_files.results[0].stat.exists and decdn_files.results[0].stat.mode == '0640' + - decdn_files.results[1].stat.exists and decdn_files.results[1].stat.mode == '0600' - decdn_files.results[2].stat.exists fail_msg: "node.toml / decdn.env / unit missing or wrong mode" @@ -69,6 +70,9 @@ - name: Assert decdn-node is enabled and running ansible.builtin.assert: that: - - "'decdn-node.service' in ansible_facts.services" - - ansible_facts.services['decdn-node.service'].state == 'running' + # Membership-and-state in one expression so an absent service + # short-circuits to the fail_msg instead of raising on .state. + - >- + 'decdn-node.service' in ansible_facts.services + and ansible_facts.services['decdn-node.service'].state == 'running' fail_msg: "decdn-node is not running — check: journalctl -u decdn-node -e" From 668e4dc2ffd361165116f56f629e42c88288e236 Mon Sep 17 00:00:00 2001 From: yigitdot Date: Sat, 11 Jul 2026 20:22:33 +0300 Subject: [PATCH 3/3] test(molecule): assert role output, harden stub, exercise version backstop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the multi-agent PR review findings: verify.yml - Assert node.toml *content* (region, metrics_bind, chain_id, the three contract addresses), not just that it parses. The prior tomllib-load check passed on any valid TOML — a role regression rendering metrics_bind=0.0.0.0 or dropping an address would not be caught. Content is asserted via a staged script (no shell quoting hazards). - Assert the keystore + password file are 0600. prepare.yml now stages them at 0644, so the assertion attributes the lock-down to the role, not to prepare. - Assert the hardened unit carries its load-bearing directives (NoNewPrivileges, ProtectSystem=strict, RestrictAddressFamilies, SystemCallFilter, User) — systemd-analyze only checks syntax. - Reject all non-loopback wildcard forms on :9090 ([::], *), not just 0.0.0.0. converge.yml - Set decdn_node_version to match the stub's --version so the install-time version-match backstop is actually exercised instead of degrading to a bare liveness check. files/decdn-node-stub - Serve 200 only on /metrics (404 elsewhere) so readiness tests the real path. - Only `run` serves; unknown invocations exit non-zero (fail loud). Guard the bind with a legible stderr error instead of a bare traceback. molecule.yml / ansible/README.md - Correct the baseline-skip rationale: this scenario connects over the Docker driver (docker exec, not SSH), so ssh_hardening would not "sever the connection". baseline is skipped because its host-level hardening (nftables default-deny, DevSec os/ssh hardening, fail2ban) isn't meaningful in a throwaway container. Co-Authored-By: Claude Opus 4.8 --- ansible/README.md | 6 +- ansible/molecule/default/converge.yml | 5 ++ .../molecule/default/files/decdn-node-stub | 40 +++++++--- ansible/molecule/default/molecule.yml | 9 ++- ansible/molecule/default/prepare.yml | 7 +- ansible/molecule/default/verify.yml | 79 ++++++++++++++++++- 6 files changed, 125 insertions(+), 21 deletions(-) diff --git a/ansible/README.md b/ansible/README.md index f5f2da7..d501dfa 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -123,8 +123,10 @@ hardened unit, starts the service, and passes the role's own `/metrics` readines `verify.yml` then asserts the node user, valid TOML, a valid systemd unit, loopback-only metrics binding, and the `0600` secret env file. It does **not** exercise real node logic or a live chain — full paid-traffic readiness still needs on-chain registration and a real -release. `baseline` is not exercised in a container (its `ssh_hardening` would sever the -connection); `make check` covers it as a non-mutating dry run. +release. `baseline` is not exercised in a container — the scenario connects over Docker +(not SSH), and baseline's host-level hardening (nftables default-deny, DevSec os/ssh +hardening, fail2ban) isn't meaningful in a throwaway container; `make check` covers it as +a non-mutating dry run. ## Configuration diff --git a/ansible/molecule/default/converge.yml b/ansible/molecule/default/converge.yml index f858e5a..4d0cdf3 100644 --- a/ansible/molecule/default/converge.yml +++ b/ansible/molecule/default/converge.yml @@ -11,6 +11,11 @@ decdn_node_install_method: manual decdn_node_manual_bin_src: "{{ stub_bin }}" decdn_cli_manual_bin_src: "{{ stub_bin }}" + # Manual mode doesn't require a version, but setting one that MATCHES the stub's + # `--version` output exercises the install-time version-match backstop + # (install.yml) instead of letting it degrade to a bare liveness check. Must + # stay in sync with the version string printed by files/decdn-node-stub. + decdn_node_version: "0.0.0-molecule-stub" decdn_rpc_url: "https://rpc.example.invalid/" decdn_chain_id: 421614 decdn_region: "US" diff --git a/ansible/molecule/default/files/decdn-node-stub b/ansible/molecule/default/files/decdn-node-stub index d66ace5..9f09d99 100755 --- a/ansible/molecule/default/files/decdn-node-stub +++ b/ansible/molecule/default/files/decdn-node-stub @@ -4,12 +4,14 @@ Stands in for the real `decdn-node` (and `decdn` CLI) so the `decdn_node` role can be exercised end-to-end without a published release or a live chain: - * `--version` -> print a version line and exit 0 (satisfies the role's - install-time `--version` backstop / liveness check). - * `run ...` (default) -> serve HTTP 200 on 127.0.0.1:9090 for any path, so the - role's `/metrics` readiness probe succeeds, and block - forever under systemd (Type=simple). All CLI args - (`--config`, `--keystore-password-file`) are ignored. + * `--version` -> print a version line and exit 0 (satisfies the role's + install-time `--version` backstop / version-match check). + * `run ...` -> serve HTTP 200 on 127.0.0.1:9090/metrics (404 elsewhere) so + the role's `/metrics` readiness probe succeeds, and block + forever under systemd (Type=simple). CLI args (`--config`, + `--keystore-password-file`) are accepted and ignored. + * anything else -> exit non-zero (fail loud) rather than silently pretending + to succeed — so a wrong/renamed invocation surfaces. It intentionally implements no protocol behaviour — the scenario verifies host prep, config/unit rendering, hardening, and loopback binding, not node logic. @@ -18,10 +20,18 @@ import sys from http.server import BaseHTTPRequestHandler, HTTPServer METRICS_ADDR = ("127.0.0.1", 9090) # matches the role's default metrics_bind/port +# Must match decdn_node_version in converge.yml (the version-match backstop). +VERSION = "decdn-node 0.0.0-molecule-stub" class _Handler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 (stdlib-mandated name) + # Answer only the real readiness path; 404 elsewhere so the test asserts + # the probe hit /metrics, not that any HTTP server is up. + if self.path != "/metrics": + self.send_response(404) + self.end_headers() + return body = b"# stub decdn-node metrics\ndecdn_stub_up 1\n" self.send_response(200) self.send_header("Content-Type", "text/plain; version=0.0.4") @@ -34,12 +44,20 @@ class _Handler(BaseHTTPRequestHandler): def main(): - if "--version" in sys.argv: - print("decdn-node 0.0.0-molecule-stub") + args = sys.argv[1:] + if "--version" in args: + print(VERSION) return 0 - # Any other invocation (the systemd `run` subcommand) serves metrics forever. - HTTPServer(METRICS_ADDR, _Handler).serve_forever() - return 0 + if args and args[0] == "run": + try: + HTTPServer(METRICS_ADDR, _Handler).serve_forever() + except OSError as exc: + addr = f"{METRICS_ADDR[0]}:{METRICS_ADDR[1]}" + print(f"stub: cannot bind {addr}: {exc}", file=sys.stderr) + return 1 + return 0 + print(f"stub: unrecognised invocation: {args!r}", file=sys.stderr) + return 2 if __name__ == "__main__": diff --git a/ansible/molecule/default/molecule.yml b/ansible/molecule/default/molecule.yml index 90024ea..8cb42ae 100644 --- a/ansible/molecule/default/molecule.yml +++ b/ansible/molecule/default/molecule.yml @@ -5,9 +5,12 @@ # node.toml + a hardened systemd unit, service start, and the role's own # /metrics readiness probe. # -# `baseline` is NOT exercised here — it 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 via `make check` instead. +# `baseline` is NOT exercised here — this scenario connects over the Docker driver +# (docker exec, not SSH), and `baseline` applies host-level hardening built for a +# real machine: a default-deny nftables firewall plus DevSec os_hardening/ +# ssh_hardening (sysctl, mount, auth, sshd) and fail2ban — none of which behave +# meaningfully in a throwaway systemd container. It is validated on real hosts via +# `make check` instead. dependency: name: galaxy options: diff --git a/ansible/molecule/default/prepare.yml b/ansible/molecule/default/prepare.yml index c5dffac..4a99dae 100644 --- a/ansible/molecule/default/prepare.yml +++ b/ansible/molecule/default/prepare.yml @@ -19,14 +19,17 @@ - "{{ decdn_home }}" - "{{ decdn_etc }}" + # Stage these at 0644 (deliberately looser than the target). The role locks + # them to 0600; verify.yml asserts 0600, so the assertion actually attributes + # the mode to the role rather than to what prepare pre-set. - name: Stage a placeholder eth keystore ansible.builtin.copy: dest: "{{ decdn_home }}/keystore.json" content: "{{ '{}' }}\n" - mode: "0600" + mode: "0644" - name: Stage a placeholder keystore password file ansible.builtin.copy: dest: "{{ decdn_etc }}/keystore.password" content: "molecule-placeholder\n" - mode: "0600" + mode: "0644" diff --git a/ansible/molecule/default/verify.yml b/ansible/molecule/default/verify.yml index 22bb446..91e726c 100644 --- a/ansible/molecule/default/verify.yml +++ b/ansible/molecule/default/verify.yml @@ -41,9 +41,57 @@ - decdn_files.results[2].stat.exists fail_msg: "node.toml / decdn.env / unit missing or wrong mode" - - name: Assert node.toml parses as valid TOML + # prepare.yml stages the keystore + password at 0644; the role is responsible + # for locking them to 0600. Asserting 0600 here therefore verifies the role's + # lock-down actually ran, not a mode prepare pre-set. + - name: Stat the operator keystore + password file + ansible.builtin.stat: + path: "{{ item }}" + register: decdn_secrets + loop: + - /var/lib/decdn/keystore.json + - "{{ decdn_etc }}/keystore.password" + + - name: Assert the keystore + password file are locked to 0600 + ansible.builtin.assert: + that: + - decdn_secrets.results[0].stat.exists and decdn_secrets.results[0].stat.mode == '0600' + - decdn_secrets.results[1].stat.exists and decdn_secrets.results[1].stat.mode == '0600' + fail_msg: "keystore / password file missing or not 0600 (role lock-down did not run)" + + # Parse node.toml AND assert its content — a validity-only check would pass + # even if the role rendered metrics_bind = 0.0.0.0 or dropped a contract + # address (still valid TOML). This asserts the role's actual output, not just + # that it parses. Values mirror converge.yml + the role's loopback default. + # Staged as a file (not python3 -c / a shell heredoc) to avoid quoting hazards. + - name: Stage the node.toml content assertion script + ansible.builtin.copy: + dest: /root/molecule-assert-node-toml.py + mode: "0755" + content: | + import sys, tomllib + d = tomllib.load(open("/etc/decdn/node.toml", "rb")) + want = { + ("identity", "region"): "US", + ("observability", "metrics_bind"): "127.0.0.1", + ("observability", "metrics_port"): 9090, + ("blockchain", "chain_id"): 421614, + ("blockchain", "payment_channel_address"): "0x1111111111111111111111111111111111111111", + ("blockchain", "capacity_bond_address"): "0x2222222222222222222222222222222222222222", + ("blockchain", "slash_judge_address"): "0x3333333333333333333333333333333333333333", + } + bad = { + ".".join(k): (d.get(k[0], {}).get(k[1]), v) + for k, v in want.items() + if d.get(k[0], {}).get(k[1]) != v + } + if bad: + print("node.toml content mismatch (got, want):", bad, file=sys.stderr) + sys.exit(1) + + - name: Assert node.toml is valid TOML and renders the expected content ansible.builtin.command: - cmd: python3 -c "import tomllib; tomllib.load(open('/etc/decdn/node.toml','rb'))" + cmd: python3 /root/molecule-assert-node-toml.py changed_when: false - name: Validate the systemd unit @@ -51,17 +99,42 @@ cmd: systemd-analyze verify /etc/systemd/system/decdn-node.service changed_when: false + # systemd-analyze only checks unit syntax — it does not confirm the security + # directives are present. Assert the load-bearing hardening lines the role's + # template is responsible for, so a regression that drops one fails loudly. + - name: Read the rendered systemd unit + ansible.builtin.slurp: + src: /etc/systemd/system/decdn-node.service + register: decdn_unit + + - name: Assert the unit carries the key hardening directives + ansible.builtin.assert: + that: + - "'NoNewPrivileges=true' in unit" + - "'ProtectSystem=strict' in unit" + - "'RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK' in unit" + - "'SystemCallFilter=@system-service' in unit" + - "'User=decdn' in unit" + fail_msg: "hardened unit is missing an expected directive" + vars: + unit: "{{ decdn_unit.content | b64decode }}" + - name: Collect listening TCP sockets ansible.builtin.command: cmd: ss -ltn register: listeners changed_when: false - - name: Assert metrics bind loopback only (never 0.0.0.0) + # Runtime backstop to the node.toml metrics_bind assertion above: confirm the + # live socket is loopback. Reject every non-loopback wildcard form ss can + # print (IPv4 0.0.0.0, IPv6 [::], and *) — not just 0.0.0.0. + - name: Assert metrics bind loopback only (never a wildcard address) ansible.builtin.assert: that: - "'127.0.0.1:9090' in listeners.stdout" - "'0.0.0.0:9090' not in listeners.stdout" + - "'[::]:9090' not in listeners.stdout" + - "'*:9090' not in listeners.stdout" fail_msg: "metrics port 9090 is not loopback-only: {{ listeners.stdout }}" - name: Gather service facts