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.