test(integration): add the 'be ubongo' profile (input-only default-deny)
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) <noreply@anthropic.com>
This commit is contained in:
parent
b3e14decb4
commit
6ac5afaf67
4 changed files with 75 additions and 8 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
# Integration-test overlay for the "askari" profile (ADR-025). Passed via `-e @`.
|
# 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.
|
# Reproduces the 2026-06-17 incident: apply base's nftables default-deny to a Docker host.
|
||||||
|
integration_profile: askari
|
||||||
base__firewall_apply: true
|
base__firewall_apply: true
|
||||||
# Keep a break-glass: sshd stays on all interfaces (never wt0-only in a throwaway VM).
|
# Keep a break-glass: sshd stays on all interfaces (never wt0-only in a throwaway VM).
|
||||||
base__ssh_listen_mesh_only: false
|
base__ssh_listen_mesh_only: false
|
||||||
|
|
|
||||||
18
tests/integration/overrides/ubongo.yml
Normal file
18
tests/integration/overrides/ubongo.yml
Normal file
|
|
@ -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"
|
||||||
9
tests/integration/profiles/ubongo.json
Normal file
9
tests/integration/profiles/ubongo.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"groups": ["control"],
|
||||||
|
"applies": [
|
||||||
|
{"playbook": "site.yml", "tags": ["base"]}
|
||||||
|
],
|
||||||
|
"extra_vars_files": ["overrides/ubongo.yml"],
|
||||||
|
"mem_mib": 2048,
|
||||||
|
"vcpus": 2
|
||||||
|
}
|
||||||
|
|
@ -1,33 +1,48 @@
|
||||||
---
|
---
|
||||||
# Integration verify (ADR-025). Outcome-based: proves Docker forwarding survives the
|
# Integration verify (ADR-025). Outcome-based, profile-aware: the active profile is named by
|
||||||
# reboot. The load-bearing check probes the VM's published :80 FROM the controller
|
# `integration_profile` (set in each profile's overlay). Each profile asserts its own success
|
||||||
# (ubongo) — if base's forward-drop killed DNAT, this times out (the FRICTION #1 bug).
|
# criteria; an unknown/unset profile fails loudly (never a silent pass).
|
||||||
- name: Verify the rebooted host
|
- name: Verify the rebooted host
|
||||||
hosts: all
|
hosts: all
|
||||||
become: true
|
become: true
|
||||||
gather_facts: false
|
gather_facts: false
|
||||||
tasks:
|
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:
|
ansible.builtin.service_facts:
|
||||||
|
|
||||||
- name: Docker daemon is active
|
- name: (askari) Docker daemon is active
|
||||||
|
when: integration_profile == 'askari'
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that: "ansible_facts.services['docker.service'].state == 'running'"
|
that: "ansible_facts.services['docker.service'].state == 'running'"
|
||||||
fail_msg: "docker.service is not 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
|
ansible.builtin.command: nft list chain inet filter forward
|
||||||
register: _fwd
|
register: _fwd
|
||||||
changed_when: false
|
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:
|
ansible.builtin.assert:
|
||||||
that: "'accept' in _fwd.stdout"
|
that: "'accept' in _fwd.stdout"
|
||||||
fail_msg: >-
|
fail_msg: >-
|
||||||
forward chain is pure drop — container forwarding will die on reboot
|
forward chain is pure drop — container forwarding will die on reboot
|
||||||
(FRICTION 2026-06-17 #1). docker_host container-forward drop-in missing.
|
(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
|
delegate_to: localhost
|
||||||
become: false
|
become: false
|
||||||
ansible.builtin.uri:
|
ansible.builtin.uri:
|
||||||
|
|
@ -42,3 +57,27 @@
|
||||||
retries: 5
|
retries: 5
|
||||||
delay: 6
|
delay: 6
|
||||||
until: _probe is succeeded
|
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.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue