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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,42 @@ jobs:
echo "::endgroup::"
done

# Build the public `decdn.node` collection and run galaxy-importer's checks —
# the same validation Galaxy runs on upload (metadata, license, README, embedded
# ansible-lint). This is a readiness GATE only: it never publishes and needs no
# token. Runs only when ansible/ changed.
galaxy-build:
needs: changes
if: needs.changes.outputs.ansible == 'true'
runs-on: ubuntu-latest
defaults:
run:
working-directory: ansible
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.12'
- name: Install build + import tooling
run: python -m pip install --upgrade ansible-core ansible-lint galaxy-importer
- name: Vendor collection dependencies
# So galaxy-importer's embedded ansible-lint can resolve the roles' FQCNs
# (devsec.hardening, ansible.posix). Installed under ansible/collections.
run: make deps
env:
ANSIBLE_COLLECTIONS_PATH: collections
- name: Build + validate the decdn.node collection
run: make galaxy-check
env:
ANSIBLE_COLLECTIONS_PATH: collections
- name: Upload collection artifact
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: decdn-node-collection
path: ansible/build/decdn-node-*.tar.gz
if-no-files-found: ignore

# Dedicated IaC security scan of the Ansible tree via the official KICS action.
# KICS severities are HIGH/MEDIUM/LOW/INFO (no "critical"); we gate on HIGH.
#
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,10 @@

# KICS security-scan output (`make security`; CI uploads it as an artifact)
kics-results/

# Galaxy collection build output (`make build`/`galaxy-check`; CI uploads the
# tarball as an artifact). The collection is staged + built under ansible/build/.
# galaxy-importer drops importer_result.json in its cwd (ansible/) when validating.
ansible/build/
*.tar.gz
ansible/importer_result.json
10 changes: 9 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,17 @@ 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 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.

**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 (on `ansible/**`) + `actionlint`. `ansible-lint`
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`.
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright 2026 deCDN Contributors

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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
.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
Expand Down Expand Up @@ -32,3 +32,9 @@ security: ## KICS IaC security scan of ansible/ (CI runs the official

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

galaxy-check: ## build + validate the decdn.node collection (galaxy-importer)
$(MAKE) -C ansible galaxy-check
9 changes: 9 additions & 0 deletions ansible/.ansible-lint
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,12 @@
# 'production' profile.
skip_list:
- var-naming[no-role-prefix]

# build/ is the generated collection staging tree (`make build`). galaxy/ is the
# `decdn.node` collection overlay (a nested galaxy.yml + manifest) — it is built and
# validated separately by galaxy-importer (the `galaxy-build` CI job), and its pre-1.0
# version would otherwise trip galaxy[version-incrementing] here. Neither is part of
# the deploy project's lint scope.
exclude_paths:
- build/
- galaxy/
1 change: 1 addition & 0 deletions ansible/.yamllint
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ rules:
max-spaces-inside: 1 # allow "{{ var }}" Jinja spacing
ignore: |
collections/
build/
24 changes: 23 additions & 1 deletion ansible/Makefile
Original file line number Diff line number Diff line change
@@ -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
.PHONY: deps lint check deploy check-anvil deploy-anvil add-dev molecule build galaxy-check
SHELL := /bin/bash

# Install the required Galaxy collections (>= constraints in requirements.yml)
Expand Down Expand Up @@ -37,3 +37,25 @@ add-dev:
# 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
# a manual step (ansible-galaxy collection publish build/decdn-node-*.tar.gz).
build:
./galaxy/build.sh

# Validate the built artifact with galaxy-importer — the same checks Galaxy runs
# on upload (metadata, license file, README, embedded ansible-lint). build.sh
# leaves exactly one tarball, so the glob is unambiguous. We gate on BOTH the
# pipeline exit (set -o pipefail catches a crash / "No module named …" that prints
# no error keyword) AND the importer's explicit success line (it can log content
# errors while still exiting 0). Warnings are kept in build/importer.log.
galaxy-check: build
@set -o pipefail; \
tarball=$$(ls -1 build/decdn-node-*.tar.gz); \
echo "galaxy-importer: $$tarball"; \
if ! python -m galaxy_importer.main "$$tarball" 2>&1 | tee build/importer.log \
|| ! grep -q "Importer processing completed successfully" build/importer.log; then \
echo "FAIL: galaxy-importer reported errors or did not complete (see build/importer.log)"; exit 1; \
fi
30 changes: 30 additions & 0 deletions ansible/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,36 @@ Defaults live in each role (`roles/*/defaults/main.yml`); override in `group_var

---

## 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 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).

```bash
make build # stage + build -> build/decdn-node-<version>.tar.gz
make galaxy-check # build + validate with galaxy-importer (the checks Galaxy runs)
```

Publishing is a **manual** step (no auto-publish workflow, no token in CI yet):

```bash
ansible-galaxy collection publish build/decdn-node-*.tar.gz --api-key "$GALAXY_TOKEN"
```

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 (Cloudflare Tunnel, manual)

Out of scope for the playbook (browser SSO can't be scripted). Expose the loopback caddy
Expand Down
26 changes: 26 additions & 0 deletions ansible/galaxy/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Changelog — `decdn.node`

All notable changes to the `decdn.node` Ansible collection are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the
collection adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.1.0] — unreleased

Initial packaging of the public deCDN node roles as a distributable collection.
Not yet published to Galaxy (pre-1.0; the published shape may still change).

### Added

- `decdn.node.baseline` — Debian/Ubuntu host baseline: nftables default-deny
inbound, fail2ban, unattended-upgrades, chrony, an admin sudo account, and 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.

<!-- No release tags exist yet; these resolve today. Switch to compare/tag links
(compare/v0.1.0...HEAD and releases/tag/v0.1.0) once v0.1.0 is cut. -->
[Unreleased]: https://github.com/decdn/devops/commits/main
[0.1.0]: https://github.com/decdn/devops/releases
75 changes: 75 additions & 0 deletions ansible/galaxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Ansible Collection — `decdn.node`

Deploy and harden a **public [deCDN](https://decdn.org) node**. This collection is
the public, reusable slice of the [`decdn/devops`](https://github.com/decdn/devops)
repository — two roles and nothing else:

| Role | Purpose |
|------|---------|
| `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.
- Target: **Debian (bookworm)** or **Ubuntu (jammy/noble)** over SSH with a sudo user.
- Collection dependencies (installed automatically with this collection):
`devsec.hardening (>=10.0.0)`, `ansible.posix (>=1.5.0)`.

## Install

```bash
ansible-galaxy collection install decdn.node
```

Or pin it in a `requirements.yml`:

```yaml
collections:
- name: decdn.node
version: ">=0.1.0"
```

## Usage

A minimal node playbook — baseline first (so the admin key lands before SSH
hardening), then the node:

```yaml
- name: Provision a hardened deCDN node
hosts: decdn_nodes
become: true
roles:
- role: decdn.node.baseline
vars:
ssh_admin_user: deploy
ssh_admin_pubkey: "ssh-ed25519 AAAA... you@host" # REQUIRED — lockout guard
baseline_extra_inbound:
- { proto: udp, port: 4433, comment: "deCDN QUIC" }
- role: decdn.node.decdn_node
# decdn_node_version + rpc_url + the three contract addresses + region are
# REQUIRED — set them per host (host_vars). Contract addresses/chain-id are
# protocol facts: source them from the deployment / an ADR, never guess.
```

The node serves paid traffic only **after** on-chain stake + registration — an
operator step, not automated by this collection. See each role's README for the
full variable list, the eth-keystore prerequisite, and day-2 ops:

- [`roles/baseline`](https://github.com/decdn/devops/tree/main/ansible/roles/baseline)
- [`roles/decdn_node`](https://github.com/decdn/devops/tree/main/ansible/roles/decdn_node)

## Security model

Backends bind `127.0.0.1`; the node opens exactly one public hole (QUIC udp/4433).
No secrets ship in the collection or are committed — the eth keystore is
operator-provisioned on the host, and `rpc_url` (which may embed an API key) renders
to a `0600` file. SSH hardening is applied last, after the admin key is in place, so
you cannot lock yourself out.

## License

MIT © deCDN Contributors. Protocol facts trace to the deCDN ADRs, never invented here.
47 changes: 47 additions & 0 deletions ansible/galaxy/build.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
#!/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
# 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).
#
# Output: ansible/build/decdn-node-<version>.tar.gz
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ansible/galaxy
ansible_dir="$(cd "$here/.." && pwd)" # ansible/
repo_root="$(cd "$ansible_dir/.." && pwd)" # repo root

build_dir="$ansible_dir/build"
stage="$build_dir/ansible_collections/decdn/node"
roles=(baseline decdn_node)

echo "staging decdn.node -> $stage"
rm -rf "$stage"
# Drop stale artifacts from earlier builds so the output dir holds exactly the
# tarball we are about to produce (galaxy-check globs build/decdn-node-*.tar.gz).
rm -f "$build_dir"/decdn-node-*.tar.gz
mkdir -p "$stage/roles" "$stage/meta"
Comment thread
thiras marked this conversation as resolved.

# Canonical role sources (shared with the internal project).
for role in "${roles[@]}"; do
cp -R "$ansible_dir/roles/$role" "$stage/roles/$role"
done

# Collection overlay + license (the artifact must be self-contained).
cp "$here/galaxy.yml" "$stage/galaxy.yml"
cp "$here/README.md" "$stage/README.md"
cp "$here/CHANGELOG.md" "$stage/CHANGELOG.md"
cp "$here/meta/runtime.yml" "$stage/meta/runtime.yml"
cp "$repo_root/LICENSE" "$stage/LICENSE"

# ansible-galaxy validates galaxy.yml (required keys, semver, tag charset) here.
ansible-galaxy collection build "$stage" --output-path "$build_dir" --force

shopt -s nullglob
for tarball in "$build_dir"/decdn-node-*.tar.gz; do
echo "built: $tarball"
done
44 changes: 44 additions & 0 deletions ansible/galaxy/galaxy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
# Manifest for the `decdn.node` Ansible collection — the public, distributable
# 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.
namespace: decdn
name: node
version: 0.1.0 # pre-1.0: published shape not yet stable
readme: README.md
authors:
- deCDN Contributors
description: Deploy and harden a public deCDN node — DevSec host baseline + decdn_node.
license:
- MIT
# Galaxy tags must match ^[a-z0-9]+$ (no hyphens/underscores).
tags:
- decdn
- cdn
- node
- systemd
- hardening
- debian
- devsec
- web3
# Collection-level dependencies — only what the SHIPPED roles use:
# 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.
dependencies:
devsec.hardening: ">=10.0.0"
ansible.posix: ">=1.5.0"
repository: https://github.com/decdn/devops
documentation: https://github.com/decdn/devops/tree/main/ansible
homepage: https://decdn.org
issues: https://github.com/decdn/devops/issues
build_ignore:
- "*.example"
Loading
Loading