From 85405de3874e369dc868bb9588aff007122a2a0f Mon Sep 17 00:00:00 2001 From: Ant Somers Date: Sun, 12 Jul 2026 00:34:30 +0300 Subject: [PATCH] fix(baseline): keep node connectivity under DevSec CIS hardening devsec.hardening.os_hardening applies CIS network sysctls (and a ufw config template) that can sever a deCDN node's connectivity. baseline applied it with no overrides, so three defaults degraded/endangered a node: - IPv6 RA/autoconf disabled (accept_ra/autoconf=0, plus accept_ra_pinfo/ accept_ra_defrtr=0): SLAAC-addressed hosts (common on VPS) lose their IPv6 address + default route on the next RA cycle / reboot. - Strict reverse-path filtering (rp_filter=1) silently drops inbound QUIC on multi-homed / policy-routed hosts. - os_hardening templates a DROP-policy /etc/default/ufw, inviting an operator to enable ufw and flush the nftables ruleset. Feed os_hardening a sysctl_overwrite (merged last, so it wins only for the relaxed keys) via two opt-in knobs with node-safe defaults, and disable its ufw template: - baseline_preserve_ipv6_autoconf (default true) re-enables the full RA path SLAAC needs, incl. accept_ra_pinfo/accept_ra_defrtr on `default` so a rebooted host reacquires its address + route. - baseline_rp_filter_loose (default false) optionally sets rp_filter=2. - ufw_manage_defaults: false stops the misleading /etc/default/ufw write. An is-boolean assert on both knobs fails loud instead of letting a mistyped value silently coerce to false (matching the decdn_node guards). Documented in ansible/README.md and roles/baseline/README.md, incl. the container-VPS (lxc/openvz) no-op caveat. Co-Authored-By: Claude Opus 4.8 (1M context) --- ansible/README.md | 12 ++++++- ansible/roles/baseline/README.md | 11 +++++- ansible/roles/baseline/defaults/main.yml | 17 +++++++++ ansible/roles/baseline/tasks/main.yml | 46 ++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/ansible/README.md b/ansible/README.md index ca7719c..aa99456 100644 --- a/ansible/README.md +++ b/ansible/README.md @@ -19,13 +19,21 @@ baseline host hardening — DevSec os/ssh, nftables default-deny inbound, - **Default-deny inbound (nftables).** SSH is the only universally-open port. The node host additionally opens **udp/4433** (QUIC) via `baseline_extra_inbound`. Everything else - (node metrics 9090, admin RPC 9191) stays **loopback** with no hole. + (node metrics 9090, admin RPC 9191) stays **loopback** with no hole. **nftables is the + firewall** — baseline turns off `os_hardening`'s ufw config template (`ufw_manage_defaults: + false`) so a misleading DROP-policy `/etc/default/ufw` is never written; and, separately, + operators must not install or enable ufw, which would replace the nftables ruleset and + drop QUIC/SSH. - **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. + baseline overrides a few `os_hardening` sysctls so hardening can't sever node + connectivity: it preserves IPv6 RA/autoconf (`baseline_preserve_ipv6_autoconf`, so + SLAAC-assigned addresses survive) and can loosen reverse-path filtering for multi-homed + hosts (`baseline_rp_filter_loose`). ## Requirements @@ -143,6 +151,8 @@ the RPC URL). Highlights: | `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 (additional operators). | | `baseline_extra_inbound` | `[]` | public inbound ports; `decdn_nodes` opens udp/4433. | +| `baseline_preserve_ipv6_autoconf` | `true` | Keep IPv6 RA/autoconf under hardening; set `false` for static-IPv6 hosts. | +| `baseline_rp_filter_loose` | `false` | `true` loosens reverse-path filtering (`rp_filter=2`) for multi-homed nodes. | | `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. | diff --git a/ansible/roles/baseline/README.md b/ansible/roles/baseline/README.md index 394f020..f493492 100644 --- a/ansible/roles/baseline/README.md +++ b/ansible/roles/baseline/README.md @@ -30,7 +30,14 @@ In order — the ordering matters: 6. **Time sync** — `chrony`. 7. **DevSec hardening (LAST)** — `devsec.hardening.os_hardening` + `devsec.hardening.ssh_hardening` (key-only SSH, no root login, kernel/sysctl/PAM - hardening). Applied last so the admin key is already in place. + hardening). Applied last so the admin key is already in place. baseline overrides a + few `os_hardening` defaults so the CIS baseline can't sever node connectivity: it + re-enables IPv6 RA/autoconf (`baseline_preserve_ipv6_autoconf`, so SLAAC-assigned + addresses survive), optionally loosens reverse-path filtering for multi-homed hosts + (`baseline_rp_filter_loose`), and disables `os_hardening`'s ufw template + (`ufw_manage_defaults: false`) so no misleading DROP-policy `/etc/default/ufw` is + written — **nftables is the firewall; do not install or enable ufw, which would + replace the ruleset and drop QUIC/SSH.** ## Lockout guard @@ -64,6 +71,8 @@ expect — set both explicitly in that case. | `ssh_admin_passwordless_sudo` | `true` | Give the admin user NOPASSWD sudo and lock its password (key-only). Set `false` for classic password sudo. | | `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_preserve_ipv6_autoconf` | `true` | Re-enable IPv6 RA/autoconf under `os_hardening` (which disables it, breaking SLAAC addresses). Set `false` for statically-addressed IPv6 hosts to keep full CIS IPv6 hardening. | +| `baseline_rp_filter_loose` | `false` | `true` sets loose reverse-path filtering (`rp_filter=2`) for multi-homed/policy-routed nodes; default keeps strict (`1`). | | `baseline_packages` | see `defaults/main.yml` | Base package set. | ## Dependencies diff --git a/ansible/roles/baseline/defaults/main.yml b/ansible/roles/baseline/defaults/main.yml index 072f6a3..f3c33cf 100644 --- a/ansible/roles/baseline/defaults/main.yml +++ b/ansible/roles/baseline/defaults/main.yml @@ -36,6 +36,23 @@ ssh_allow_cidrs: [] # decdn node's QUIC udp/4433) do. Set per host/group, e.g. in group_vars. baseline_extra_inbound: [] +# --- os_hardening sysctl overrides (keep node connectivity under CIS hardening) --- +# devsec.hardening.os_hardening applies CIS-style network sysctls that can degrade a +# node. baseline feeds these knobs into its sysctl_overwrite so the hardened defaults +# still apply everywhere EXCEPT where a node needs them relaxed. +# Keep IPv6 RA/autoconf working: os_hardening disables the whole RA path (accept_ra, +# autoconf, and RA prefix/default-route learning) per CIS, which breaks SLAAC-assigned +# addresses (common on VPS) — the node loses its IPv6 address + default route on the next +# RA cycle (notably after a reboot). true = re-enable the RA sysctls SLAAC needs; set +# false on hosts with statically-configured IPv6 to keep the full CIS IPv6 hardening. +# Only takes effect where os_hardening applies sysctls — its sysctl block is skipped on +# container virtualization (lxc/openvz/docker), so on those VPS the knob is a no-op. +baseline_preserve_ipv6_autoconf: true +# os_hardening sets strict reverse-path filtering (rp_filter=1), which silently drops +# inbound QUIC arriving on the "wrong" interface of a multi-homed / policy-routed host. +# true = loose (rp_filter=2). Default keeps strict — correct for typical single-homed nodes. +baseline_rp_filter_loose: false + baseline_packages: - curl - git diff --git a/ansible/roles/baseline/tasks/main.yml b/ansible/roles/baseline/tasks/main.yml index acc05c1..5903699 100644 --- a/ansible/roles/baseline/tasks/main.yml +++ b/ansible/roles/baseline/tasks/main.yml @@ -251,9 +251,55 @@ when: not ansible_check_mode or 'chrony.service' in (ansible_facts.services | default({})) # --- DevSec hardening (LAST: ssh_hardening disables root + password auth) ------ +# The `| bool` filter is non-strict: an unparseable value (typo, quoted string, trailing +# space) coerces to false with no error — which would silently DROP the connectivity +# override and let CIS hardening sever IPv6/QUIC. Fail loud instead, matching the +# `is boolean` guards in roles/decdn_node/tasks/main.yml. +- name: Validate the sysctl-override knobs are real booleans + ansible.builtin.assert: + that: + - baseline_preserve_ipv6_autoconf is boolean + - baseline_rp_filter_loose is boolean + fail_msg: >- + baseline_preserve_ipv6_autoconf and baseline_rp_filter_loose must be real + booleans (true/false), not quoted strings — each gates a `| bool` conditional + that decides whether the node-connectivity sysctl overrides apply. A mistyped + value silently coerces to false and lets CIS hardening sever IPv6/QUIC. + +# os_hardening merges sysctl_overwrite LAST over its own hardened sysctl dict, so this +# wins over the CIS defaults for exactly the keys a node needs relaxed and nothing else. +# An off knob contributes an empty dict — the hardened default then stands. +- name: Assemble sysctl overrides that keep node connectivity under hardening + ansible.builtin.set_fact: + baseline_os_sysctl_overwrite: >- + {{ (ipv6 if baseline_preserve_ipv6_autoconf | bool else {}) + | combine(rpf if baseline_rp_filter_loose | bool else {}) }} + vars: + # os_hardening disables the WHOLE RA path, so re-enabling accept_ra/autoconf alone is + # not enough: without accept_ra_pinfo the kernel ignores the RA prefix (no SLAAC + # address) and without accept_ra_defrtr it installs no default route. os_hardening + # sets these on `default` only (the template new interfaces inherit at boot), so the + # counter goes on `default` too — otherwise a rebooted host comes up v6-dead. + ipv6: + net.ipv6.conf.all.accept_ra: 1 + net.ipv6.conf.default.accept_ra: 1 + net.ipv6.conf.all.autoconf: 1 + net.ipv6.conf.default.autoconf: 1 + net.ipv6.conf.all.router_solicitations: -1 + net.ipv6.conf.default.router_solicitations: -1 + net.ipv6.conf.default.accept_ra_pinfo: 1 # learn RA prefix -> SLAAC address + net.ipv6.conf.default.accept_ra_defrtr: 1 # learn default route from RA + net.ipv6.conf.default.max_addresses: 16 + rpf: + net.ipv4.conf.all.rp_filter: 2 + net.ipv4.conf.default.rp_filter: 2 + - name: Apply DevSec OS hardening ansible.builtin.include_role: name: devsec.hardening.os_hardening + vars: + sysctl_overwrite: "{{ baseline_os_sysctl_overwrite }}" + ufw_manage_defaults: false # nftables is the firewall; don't write /etc/default/ufw # No `when` guard here by design: the unconditional lockout assert above has already # proven a non-root admin user with >=1 key exists (or aborted the play), so hardening