From 6ac5afaf6771663e6e1aa23b55814ae7a2064a9f Mon Sep 17 00:00:00 2001 From: sjat Date: Fri, 19 Jun 2026 09:47:03 +0200 Subject: [PATCH] test(integration): add the 'be ubongo' profile (input-only default-deny) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A control-group VM that applies base with INPUT-only default-deny (forward policy accept; admin-addr SSH allow). verify.yml is now profile-aware via an integration_profile marker — the askari Docker/DNAT block is gated, and a ubongo block asserts input drop + forward accept + the admin-addr rule. Enables `make test-integration HOST=ubongo`. Mesh-hardening 2/3 (ADR-025). Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/integration/overrides/askari.yml | 1 + tests/integration/overrides/ubongo.yml | 18 +++++++++ tests/integration/profiles/ubongo.json | 9 +++++ tests/integration/verify.yml | 55 ++++++++++++++++++++++---- 4 files changed, 75 insertions(+), 8 deletions(-) create mode 100644 tests/integration/overrides/ubongo.yml create mode 100644 tests/integration/profiles/ubongo.json diff --git a/tests/integration/overrides/askari.yml b/tests/integration/overrides/askari.yml index c0d08b3..1b6637e 100644 --- a/tests/integration/overrides/askari.yml +++ b/tests/integration/overrides/askari.yml @@ -1,6 +1,7 @@ --- # Integration-test overlay for the "askari" profile (ADR-025). Passed via `-e @`. # Reproduces the 2026-06-17 incident: apply base's nftables default-deny to a Docker host. +integration_profile: askari base__firewall_apply: true # Keep a break-glass: sshd stays on all interfaces (never wt0-only in a throwaway VM). base__ssh_listen_mesh_only: false diff --git a/tests/integration/overrides/ubongo.yml b/tests/integration/overrides/ubongo.yml new file mode 100644 index 0000000..7d1f948 --- /dev/null +++ b/tests/integration/overrides/ubongo.yml @@ -0,0 +1,18 @@ +--- +# Integration-test overlay for the "ubongo" profile (ADR-025). Passed via `-e @`. +# Exercises mesh-hardening 2/3: base's INPUT-only default-deny on the control node — input +# chain default-deny, forward chain left permissive (Docker/libvirt-NAT safe), no sshd +# ListenAddress change (so no boot-race). +integration_profile: ubongo +base__firewall_apply: true +base__firewall_input_only: true # forward chain renders `policy accept` +base__firewall_admin_addrs: + - "192.168.150.98" # two representative LAN sources — exercises the + - "192.168.150.99" # admin-addr loop with a multi-entry list (like ubongo) +# Never wt0-only; never touch the real mesh from a throwaway VM. +base__ssh_listen_mesh_only: false +base__mesh_enabled: false +# Allow SSH from the libvirt-NAT gateway (where the driver/ansible connect from) so the +# default-deny apply + the reboot don't lock out the harness. By source IP (interface- +# independent). This is the harness's lifeline; the admin-addr above is only exercised. +base__firewall_control_addr: "192.168.150.1" diff --git a/tests/integration/profiles/ubongo.json b/tests/integration/profiles/ubongo.json new file mode 100644 index 0000000..2d647e1 --- /dev/null +++ b/tests/integration/profiles/ubongo.json @@ -0,0 +1,9 @@ +{ + "groups": ["control"], + "applies": [ + {"playbook": "site.yml", "tags": ["base"]} + ], + "extra_vars_files": ["overrides/ubongo.yml"], + "mem_mib": 2048, + "vcpus": 2 +} diff --git a/tests/integration/verify.yml b/tests/integration/verify.yml index e6c99b8..129b908 100644 --- a/tests/integration/verify.yml +++ b/tests/integration/verify.yml @@ -1,33 +1,48 @@ --- -# Integration verify (ADR-025). Outcome-based: proves Docker forwarding survives the -# reboot. The load-bearing check probes the VM's published :80 FROM the controller -# (ubongo) — if base's forward-drop killed DNAT, this times out (the FRICTION #1 bug). +# Integration verify (ADR-025). Outcome-based, profile-aware: the active profile is named by +# `integration_profile` (set in each profile's overlay). Each profile asserts its own success +# criteria; an unknown/unset profile fails loudly (never a silent pass). - name: Verify the rebooted host hosts: all become: true gather_facts: false tasks: - - name: Gather service facts + - name: A known integration_profile must be set (no silent pass) + ansible.builtin.assert: + that: + - integration_profile is defined + - integration_profile in ['askari', 'ubongo'] + fail_msg: "integration_profile must be set in the profile overlay (askari|ubongo)" + + # ── askari profile — Docker host: published-port forwarding survives the reboot ── + # The load-bearing check probes the VM's published :80 FROM the controller (ubongo) — if + # base's forward-drop killed DNAT, this times out (the FRICTION 2026-06-17 #1 bug). + - name: (askari) Gather service facts + when: integration_profile == 'askari' ansible.builtin.service_facts: - - name: Docker daemon is active + - name: (askari) Docker daemon is active + when: integration_profile == 'askari' ansible.builtin.assert: that: "ansible_facts.services['docker.service'].state == 'running'" fail_msg: "docker.service is not running" - - name: Forward chain permits container traffic (drop-in loaded) + - name: (askari) Forward chain permits container traffic (drop-in loaded) + when: integration_profile == 'askari' ansible.builtin.command: nft list chain inet filter forward register: _fwd changed_when: false - - name: Assert container forwarding is allowed (not pure drop) + - name: (askari) Assert container forwarding is allowed (not pure drop) + when: integration_profile == 'askari' ansible.builtin.assert: that: "'accept' in _fwd.stdout" fail_msg: >- forward chain is pure drop — container forwarding will die on reboot (FRICTION 2026-06-17 #1). docker_host container-forward drop-in missing. - - name: Published port answers from the controller (DNAT + forward alive) + - name: (askari) Published port answers from the controller (DNAT + forward alive) + when: integration_profile == 'askari' delegate_to: localhost become: false ansible.builtin.uri: @@ -42,3 +57,27 @@ retries: 5 delay: 6 until: _probe is succeeded + + # ── ubongo profile — control node: INPUT-only default-deny survives the reboot ── + # SSH reachability across the reboot is proven by the harness itself (it re-SSHes and + # checks boot_id changed before this verify runs). Here we assert the ruleset shape. + - name: (ubongo) Read the live nftables ruleset + when: integration_profile == 'ubongo' + ansible.builtin.command: nft list ruleset + register: _nft + changed_when: false + + - name: (ubongo) INPUT default-deny, forward permissive, lifeline + admin-addr allow + when: integration_profile == 'ubongo' + ansible.builtin.assert: + that: + - "'hook input priority 0; policy drop;' in _nft.stdout" + - "'hook forward priority 0; policy accept;' in _nft.stdout" + # the ssh-from-control lifeline (base__firewall_control_addr) — the reconnect path + - "'ip saddr 192.168.150.1 tcp dport 22 accept' in _nft.stdout" + - "'ip saddr 192.168.150.98 tcp dport 22 accept' in _nft.stdout" + - "'ip saddr 192.168.150.99 tcp dport 22 accept' in _nft.stdout" + fail_msg: >- + ubongo profile: expected input policy drop, forward policy accept (input-only), + the ssh-from-control lifeline (192.168.150.1), and both admin-addr + (192.168.150.98/99) SSH allows in the live ruleset.