From 95e33615982a587b4bfb199e97c6b8d10d603123 Mon Sep 17 00:00:00 2001 From: codex Date: Wed, 12 Aug 2026 03:28:35 +0200 Subject: [PATCH] Tighten the Goss firewall assertion and render it from the declaration The conformance checker existed but had not run since 2026-03-09, and its firewall assertion matched /6443\/tcp.*ALLOW/ - asserting the port was allowed but not from whom. It passed identically whether the API was restricted to one operator address or open to the entire internet, which is precisely the drift that went undetected. goss/baseline.yaml is now a template rendered per host from the same inventory declaration that converges the host, so the assertion cannot drift from what it checks. It asserts each declared source is present, that no revoked source retains access, and that 6443 is never ALLOW Anywhere. verify.yaml gained the vars_files the bootstrap play already had - without it the firewall assertions rendered empty and silently asserted nothing, which is worse than no check. Result on Railiance01: 32 assertions, 31 pass. The one failure is a real find and is recorded as T06 - Flannel VXLAN is declared open to Anywhere with no source restriction, the same defect pattern as the k3s rule. It is currently absent from the host, so converging would INTRODUCE the exposure rather than fix drift. Must be resolved before RAIL-BS-WP-0007 multi-node HA needs working VXLAN. Co-Authored-By: Claude Opus 5 --- ansible/playbooks/verify.yaml | 4 ++ ansible/roles/goss/tasks/main.yml | 6 +-- goss/{baseline.yaml => baseline.yaml.j2} | 29 +++++++++++++- ...irewall-declared-state-and-api-exposure.md | 40 +++++++++++++++++++ 4 files changed, 74 insertions(+), 5 deletions(-) rename goss/{baseline.yaml => baseline.yaml.j2} (54%) diff --git a/ansible/playbooks/verify.yaml b/ansible/playbooks/verify.yaml index 00fbdb5..f0a9879 100644 --- a/ansible/playbooks/verify.yaml +++ b/ansible/playbooks/verify.yaml @@ -9,5 +9,9 @@ - hosts: all become: true gather_facts: true + # Same declaration the bootstrap play converges from. Without this the + # firewall assertions render empty and silently assert nothing. + vars_files: + - ../inventory/group_vars/all.yaml roles: - role: goss diff --git a/ansible/roles/goss/tasks/main.yml b/ansible/roles/goss/tasks/main.yml index b4b1670..cf62d7b 100644 --- a/ansible/roles/goss/tasks/main.yml +++ b/ansible/roles/goss/tasks/main.yml @@ -24,9 +24,9 @@ checksum: "sha256:https://github.com/goss-org/goss/releases/download/v{{ goss_version }}/goss-linux-amd64.sha256" register: goss_download -- name: Copy baseline test file - ansible.builtin.copy: - src: "{{ playbook_dir }}/../../goss/baseline.yaml" +- name: Render baseline test file from the same declaration that converges the host + ansible.builtin.template: + src: "{{ playbook_dir }}/../../goss/baseline.yaml.j2" dest: "{{ goss_dir }}/baseline.yaml" owner: root group: root diff --git a/goss/baseline.yaml b/goss/baseline.yaml.j2 similarity index 54% rename from goss/baseline.yaml rename to goss/baseline.yaml.j2 index d9ae993..3ad29f9 100644 --- a/goss/baseline.yaml +++ b/goss/baseline.yaml.j2 @@ -1,6 +1,12 @@ -# Goss baseline assertions for railiance managed nodes +# Goss baseline assertions for railiance managed nodes. # Derived from spec/server-baseline.yaml — keep in sync. # Run: goss -g /etc/goss/baseline.yaml validate +# +# THIS IS A TEMPLATE. It is rendered per host from inventory variables, so the +# firewall assertions below are generated from the SAME declaration that +# converges the host (k3s_api_allowed_sources in group_vars/all.yaml). That is +# deliberate: a hand-written assertion drifts from the declaration it is meant +# to check, which is exactly how RAIL-HO-WP-0009 happened. package: ufw: @@ -51,8 +57,27 @@ command: stdout: - "Status: active" - /OpenSSH.*ALLOW/ - - /6443\/tcp.*ALLOW/ +{% for src in k3s_api_allowed_sources | default([]) %} + - '/6443\/tcp\s+ALLOW\s+{{ src.address | regex_escape }}/' +{% endfor %} - /8472\/udp.*ALLOW/ + + # The k3s API must never be reachable from Anywhere. The previous assertion + # matched /6443\/tcp.*ALLOW/, which passes identically whether the API is + # restricted to one operator address or open to the entire internet — it + # asserted that the port was allowed, not from whom. + "ufw status | grep -Ec '6443/tcp[[:space:]]+ALLOW[[:space:]]+Anywhere' || true": + exit-status: 0 + stdout: + - "0" + +{% for src in k3s_api_revoked_sources | default([]) %} + # Revoked operator source must not retain access: {{ src.comment | default('') }} + "ufw status | grep -Ec '6443/tcp[[:space:]]+ALLOW[[:space:]]+{{ src.address }}' || true": + exit-status: 0 + stdout: + - "0" +{% endfor %} "grep NOPASSWD /etc/sudoers.d/tegwick": exit-status: 0 stdout: diff --git a/workplans/RAIL-HO-WP-0009-firewall-declared-state-and-api-exposure.md b/workplans/RAIL-HO-WP-0009-firewall-declared-state-and-api-exposure.md index a8d64f7..1e90164 100644 --- a/workplans/RAIL-HO-WP-0009-firewall-declared-state-and-api-exposure.md +++ b/workplans/RAIL-HO-WP-0009-firewall-declared-state-and-api-exposure.md @@ -195,6 +195,30 @@ the same pattern to `railiance01` and reduce the public allowlist to nothing. Decide explicitly rather than by default: this trades convenience for exposure, and the tunnel becomes a dependency of every operator action. +```task +id: RAIL-HO-WP-0009-T06 +status: todo +priority: high +``` + +**Flannel VXLAN is declared open to Anywhere.** Found 2026-08-12 by the newly +tightened Goss assertion — the first defect the conformance check caught on its +own. + +`- name: Allow Flannel VXLAN in UFW` declares `8472/udp` with **no source +restriction**, the same defect pattern as the k3s API rule. The rule is +currently **absent** from `Railiance01`, so there is no live exposure — but +converging the base role would *add* it, opening VXLAN to the internet and +introducing the exact class of defect this workplan exists to remove. + +VXLAN on 8472/udp accepts encapsulated frames; exposing it publicly invites +injection into the pod network. Restrict it to cluster node addresses, or omit +it entirely while the cluster is single-node. + +Note the sequencing risk: `RAIL-BS-WP-0007` (ThreePhoenix HA, multi-node) will +need working VXLAN between nodes. Resolve this before that lands, or the fix +will be made under delivery pressure. + ```task id: RAIL-HO-WP-0009-T05 status: todo @@ -211,6 +235,22 @@ comparison is a small, sharp first thing for that loop to do. The existing Goss verification suite is the natural home for the check itself; what is missing is the loop that runs it and reacts. +**Progress 2026-08-12 — the check now exists.** `goss/baseline.yaml` became +`goss/baseline.yaml.j2`, rendered per host from the *same* inventory declaration +that converges it (`k3s_api_allowed_sources`, `k3s_api_revoked_sources`), so the +assertion cannot drift from the declaration it checks. The old assertion matched +`/6443\/tcp.*ALLOW/` — the port is allowed, but not from whom — and passed +identically whether the API was restricted to one address or open to the world. +It now asserts each declared source is present, that no revoked source remains, +and that 6443 is never `ALLOW Anywhere`. + +`verify.yaml` also gained the `vars_files` the bootstrap play already had; +without it the firewall assertions rendered empty and silently asserted nothing. + +Result on `Railiance01`: 32 assertions, 31 pass, one genuine failure (T06). What +remains for this task is the *loop* — scheduling it and routing failures +somewhere they are seen. + ## Outcome Pending. T01 done; the live host is reachable but not yet converged.