diff --git a/docs/superpowers/plans/2026-06-06-host-nftables-firewall.md b/docs/superpowers/plans/2026-06-06-host-nftables-firewall.md new file mode 100644 index 0000000..2672ebc --- /dev/null +++ b/docs/superpowers/plans/2026-06-06-host-nftables-firewall.md @@ -0,0 +1,712 @@ +# Host nftables Firewall (`base` firewall concern) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the `firewall`-tagged concern of the `base` role — default-deny nftables rendered from a shared `group_vars` service catalog, applied with an auto-rollback safety net. + +**Architecture:** A pure Python filter plugin resolves the global `firewall_catalog`/`firewall_zones` into a flat per-host rule list; a Jinja template renders `/etc/nftables.conf` (validated at render time with `nft -c`); tasks apply it safely (snapshot → armed `systemd-run` revert → apply → confirm/disarm → persist). Molecule renders + syntax-checks only (never applies — it shares the host kernel); the resolver is unit-tested with pytest; real enforcement is a Level-2 staging concern. + +**Tech Stack:** Ansible (`ansible.builtin` only — no new collections), nftables, Python 3 filter plugin + pytest, Molecule (Docker driver), systemd (`systemd-run` transient timer). + +--- + +## File structure + +| File | Responsibility | Action | +|------|----------------|--------| +| `roles/base/` (scaffold) | the base role skeleton | Create via `make new-role` | +| `roles/base/meta/main.yml` | role metadata (galaxy_info) | Fill | +| `roles/base/defaults/main.yml` | `base__firewall_*` behaviour knobs | Create | +| `inventories/{staging,production}/group_vars/all/firewall.yml` | shared `firewall_zones` + `firewall_catalog` | Create | +| `roles/base/filter_plugins/firewall_rules.py` | pure catalog→rules resolver | Create | +| `tests/test_firewall_rules.py` | pytest units for the resolver | Create | +| `roles/base/templates/nftables.conf.j2` | the ruleset | Create | +| `roles/base/tasks/main.yml` | include `firewall.yml` (tagged) | Replace scaffold | +| `roles/base/tasks/firewall.yml` | install + render + safe-apply | Create | +| `roles/base/molecule/default/molecule.yml` | fixture `ansible_host` | Adjust scaffold | +| `roles/base/molecule/default/converge.yml` | fixture catalog/zones + `apply:false` | Replace scaffold | +| `roles/base/molecule/default/verify.yml` | assert rendered rules + `nft -c` | Replace scaffold | +| `roles/base/README.md` | document the firewall concern | Fill | +| `STATUS.md`, `docs/CAPABILITIES.md` | reflect the build | Modify | + +Notes for the implementer: +- Run Ansible/Python via the repo venv (`.venv/bin/...`); the Makefile wires paths. Molecule: `make test ROLE=base`. +- The Molecule platform pulls `forgejo.nyumbani.baobab.band/sjat/molecule-debian13:latest`. If the registry/image is unreachable in your environment, `make test` can't run — report DONE_WITH_CONCERNS for that step; the pytest units (Task 3) still fully validate the resolver logic, which is the only non-trivial code. +- Before any `git commit`, the pre-commit hook decrypts `vault.yml`, so the vault agent must be unlocked: run `rbw unlocked` (exit 0 = good); if locked, ask the user to `rbw unlock`. None of these tasks touch vault files. +- `make lint` must stay green (yamllint + ansible-lint over the new role + `check-tags`). Use FQCN, a tag on every task, string `mode:`, and `changed_when:` on every `command`/`shell`. + +--- + +### Task 1: Scaffold the `base` role + +**Files:** +- Create: `roles/base/` (via `make new-role`) +- Fill: `roles/base/meta/main.yml`, `roles/base/README.md` + +- [ ] **Step 1: Scaffold** + +Run: `make new-role NAME=base` +Expected: prints "Role base scaffolded at roles/base/". Creates `roles/base/{tasks,handlers,defaults,templates,files,meta,molecule/default}` and a scaffold `tasks/main.yml` (`---`), `molecule/default/{molecule.yml,converge.yml,verify.yml}`, `README.md`. + +- [ ] **Step 2: Fill `roles/base/meta/main.yml`** + +Replace the scaffold `---` with: + +```yaml +--- +galaxy_info: + author: sjat + description: Hardened baseline configuration for all boma hosts (Debian 13). + license: MIT + min_ansible_version: "2.17" + platforms: + - name: Debian + versions: + - trixie +dependencies: [] +``` + +- [ ] **Step 3: Write `roles/base/README.md`** + +Replace the scaffold content with: + +```markdown +# base + +Hardened baseline applied to every boma host. Built incrementally; the first concern +implemented is the **host firewall** (`firewall` tag). + +## Firewall (nftables) + +Default-deny inbound + east-west allowlisting + permissive egress, per ADR-020. Rules +are rendered from the shared `firewall_catalog` / `firewall_zones` (in `group_vars/all`) +by the `resolve_firewall_rules` filter, written to `/etc/nftables.conf`, syntax-checked +with `nft -c` at render time, and applied with an **auto-rollback safety net** +(`systemd-run` arms a revert that a follow-up task cancels once connectivity is +confirmed). The apply sequence lives in tasks rather than a handler so the confirm/cancel +step is controllable. + +`/etc/nftables.d/*.nft` is `include`d by the ruleset — the extension hook the +`docker_host` role uses for container forward/NAT rules. + +### Variables +See `defaults/main.yml` (`base__firewall_*`). SSH is accepted only on +`base__firewall_mgmt_interface` (default `wt0`, the NetBird overlay — ADR-016); set it to +a reachable interface/source until NetBird is built. Set `base__firewall_apply: false` to +render + validate without applying (used by Molecule). + +### Testing +- `tests/test_firewall_rules.py` — pytest units for the resolver. +- `make test ROLE=base` — Molecule renders + `nft -c` syntax-checks (never applies; it + shares the host kernel). Enforcement + the apply/rollback path are verified at ADR-008 + Level 2 on staging VMs. +``` + +- [ ] **Step 4: Verify scaffold + lint** + +Run: `test -d roles/base/molecule/default && .venv/bin/ansible-lint roles/base` +Expected: directory exists; ansible-lint passes (the scaffold `tasks/main.yml` is empty `---`, meta is now filled). + +- [ ] **Step 5: Commit** + +```bash +git add roles/base +git commit -m "feat(base): scaffold role + meta/README (firewall concern incoming)" +``` + +--- + +### Task 2: Shared catalog/zones + role defaults + +**Files:** +- Create: `inventories/staging/group_vars/all/firewall.yml` +- Create: `inventories/production/group_vars/all/firewall.yml` +- Create: `roles/base/defaults/main.yml` + +- [ ] **Step 1: Create the shared firewall data (both envs)** + +Write this identical content to **both** `inventories/staging/group_vars/all/firewall.yml` +**and** `inventories/production/group_vars/all/firewall.yml`: + +```yaml +--- +# Shared firewall topology — single source of truth for the host nftables layer +# (base role) and OPNsense (future). See docs/decisions/020-firewall.md. + +# Zone → subnet (from ADR-007). +firewall_zones: + mgmt: 10.10.0.0/24 + srv: 10.20.0.0/24 + lan: 10.30.0.0/24 + iot: 10.40.0.0/24 + guest: 10.50.0.0/24 + +# Service catalog: → placement (host | group | hosts) + ingress[]. +# Empty until services are built; hosts still get default-deny + the management plane. +firewall_catalog: {} +``` + +- [ ] **Step 2: Create `roles/base/defaults/main.yml`** + +Replace the scaffold `---` with: + +```yaml +--- +# Host firewall (nftables) behaviour knobs. Shared topology (firewall_catalog/ +# firewall_zones) lives in group_vars/all, not here. See docs/decisions/020-firewall.md. +base__firewall_mgmt_interface: wt0 # SSH accepted only on this iface (NetBird, ADR-016) +base__firewall_ssh_port: 22 +base__firewall_rollback_timeout: 45 # seconds before the auto-revert fires on a bad apply +base__firewall_dropin_dir: /etc/nftables.d +base__firewall_apply: true # set false to render+validate without applying (CI/Molecule) +``` + +- [ ] **Step 3: Verify + lint** + +Run: `.venv/bin/python -c "import yaml; [print(sorted(yaml.safe_load(open(p))['firewall_zones'])) for p in ['inventories/staging/group_vars/all/firewall.yml','inventories/production/group_vars/all/firewall.yml']]" && make lint` +Expected: prints the sorted zone list twice (`['guest', 'iot', 'lan', 'mgmt', 'srv']`); `make lint` passes. + +- [ ] **Step 4: Commit** + +```bash +git add inventories/staging/group_vars/all/firewall.yml inventories/production/group_vars/all/firewall.yml roles/base/defaults/main.yml +git commit -m "feat(base): shared firewall catalog/zones + firewall defaults" +``` + +--- + +### Task 3: The resolver filter plugin (TDD) + +**Files:** +- Create: `roles/base/filter_plugins/firewall_rules.py` +- Test: `tests/test_firewall_rules.py` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/test_firewall_rules.py`: + +```python +import importlib.util +import pathlib + +import pytest + +_PATH = ( + pathlib.Path(__file__).resolve().parent.parent + / "roles" / "base" / "filter_plugins" / "firewall_rules.py" +) +_spec = importlib.util.spec_from_file_location("firewall_rules", _PATH) +fr = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(fr) + +ZONES = {"lan": "10.30.0.0/24", "srv": "10.20.0.0/24"} +HOSTVARS = { + "docker01": {"ansible_host": "10.20.0.50"}, + "docker02": {"ansible_host": "10.20.0.51"}, +} +GROUPS = {"docker_hosts": ["docker01", "docker02"]} + + +def test_zone_source(): + cat = {"reverse_proxy": {"host": "docker01", + "ingress": [{"from": "lan", "port": 443, "proto": "tcp"}]}} + out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS) + assert out == [{"proto": "tcp", "port": 443, "sources": ["10.30.0.0/24"]}] + + +def test_service_source_resolves_to_host_ip(): + cat = { + "reverse_proxy": {"host": "docker01", "ingress": []}, + "photoprism": {"host": "docker01", + "ingress": [{"from": "reverse_proxy", "port": 2342, "proto": "tcp"}]}, + } + out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS) + assert out == [{"proto": "tcp", "port": 2342, "sources": ["10.20.0.50/32"]}] + + +def test_group_placement_and_source_multi_host(): + cat = {"dns": {"group": "docker_hosts", + "ingress": [{"from": "docker_hosts", "port": 53, "proto": "udp"}]}} + out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS) + assert out == [{"proto": "udp", "port": 53, + "sources": ["10.20.0.50/32", "10.20.0.51/32"]}] + + +def test_host_with_no_services_returns_empty(): + cat = {"photoprism": {"host": "docker02", + "ingress": [{"from": "lan", "port": 2342, "proto": "tcp"}]}} + assert fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS) == [] + + +def test_unresolvable_from_raises(): + cat = {"x": {"host": "docker01", + "ingress": [{"from": "nope", "port": 80, "proto": "tcp"}]}} + with pytest.raises(ValueError): + fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS) + + +def test_duplicate_rules_deduped(): + cat = {"app": {"host": "docker01", "ingress": [ + {"from": "lan", "port": 8080, "proto": "tcp"}, + {"from": "lan", "port": 8080, "proto": "tcp"}, + ]}} + out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS) + assert out == [{"proto": "tcp", "port": 8080, "sources": ["10.30.0.0/24"]}] + + +def test_missing_ansible_host_raises(): + cat = {"x": {"host": "docker01", + "ingress": [{"from": "docker02", "port": 80, "proto": "tcp"}]}} + with pytest.raises(ValueError): + fr.resolve_firewall_rules(cat, ZONES, "docker01", {"docker01": {}, "docker02": {}}, GROUPS) +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `.venv/bin/python -m pytest tests/test_firewall_rules.py -v` +Expected: FAIL — `FileNotFoundError` / import error (the module doesn't exist yet). + +- [ ] **Step 3: Write the filter plugin** + +Create `roles/base/filter_plugins/firewall_rules.py`: + +```python +"""Resolve the shared firewall catalog into concrete nftables ingress rules for one host. + +Used by the base role's nftables template (ADR-020 / host-nftables design). Pure +functions — unit-tested in tests/test_firewall_rules.py. +""" + + +def _placement_hosts(entry, groups): + """Hostnames a catalog entry is placed on (exactly one of host/group/hosts).""" + if "host" in entry: + return [entry["host"]] + if "group" in entry: + return list(groups.get(entry["group"], [])) + if "hosts" in entry: + return list(entry["hosts"]) + raise ValueError(f"catalog entry has no placement (host/group/hosts): {entry!r}") + + +def _host_cidr(host, hostvars): + hv = hostvars.get(host) or {} + ip = hv.get("ansible_host") + if not ip: + raise ValueError(f"no ansible_host for '{host}' — cannot resolve firewall source") + return f"{ip}/32" + + +def _resolve_source(frm, catalog, zones, hostvars, groups): + """Resolve a symbolic `from` to a sorted list of source CIDRs.""" + if frm in zones: + return [zones[frm]] + if frm in catalog: + return sorted(_host_cidr(h, hostvars) + for h in _placement_hosts(catalog[frm], groups)) + if frm in groups: + return sorted(_host_cidr(h, hostvars) for h in groups[frm]) + if frm in hostvars: + return [_host_cidr(frm, hostvars)] + raise ValueError(f"unresolvable firewall source '{frm}'") + + +def resolve_firewall_rules(catalog, zones, inventory_hostname, hostvars, groups): + """Return sorted, de-duped [{proto, port, sources:[cidr,...]}] for services on this host.""" + catalog = catalog or {} + zones = zones or {} + groups = groups or {} + + rules = [] + for _name, entry in sorted(catalog.items()): + if inventory_hostname not in _placement_hosts(entry, groups): + continue + for ing in entry.get("ingress", []): + rules.append({ + "proto": ing.get("proto", "tcp"), + "port": int(ing["port"]), + "sources": _resolve_source(ing["from"], catalog, zones, hostvars, groups), + }) + + seen = set() + out = [] + for r in sorted(rules, key=lambda x: (x["port"], x["proto"], x["sources"])): + key = (r["proto"], r["port"], tuple(r["sources"])) + if key not in seen: + seen.add(key) + out.append(r) + return out + + +class FilterModule: + """Ansible filter plugin entry point.""" + + def filters(self): + return {"resolve_firewall_rules": resolve_firewall_rules} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `.venv/bin/python -m pytest tests/test_firewall_rules.py -v` +Expected: PASS (all 7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add roles/base/filter_plugins/firewall_rules.py tests/test_firewall_rules.py +git commit -m "feat(base): firewall catalog resolver filter plugin + tests" +``` + +--- + +### Task 4: Template + render tasks + Molecule fixtures + +**Files:** +- Create: `roles/base/templates/nftables.conf.j2` +- Create: `roles/base/tasks/firewall.yml` +- Replace: `roles/base/tasks/main.yml` +- Adjust: `roles/base/molecule/default/molecule.yml` +- Replace: `roles/base/molecule/default/converge.yml` + +- [ ] **Step 1: Create the template** + +Create `roles/base/templates/nftables.conf.j2`: + +```jinja +#!/usr/sbin/nft -f +# Ansible managed — do not edit by hand. Source: roles/base (ADR-020). +flush ruleset + +table inet filter { + chain input { + type filter hook input priority 0; policy drop; + iif "lo" accept + ct state established,related accept + ct state invalid drop + iif "{{ base__firewall_mgmt_interface }}" tcp dport {{ base__firewall_ssh_port }} accept + ip protocol icmp accept + ip6 nexthdr ipv6-icmp accept +{% for r in base__firewall_resolved %} + ip saddr { {{ r.sources | join(', ') }} } {{ r.proto }} dport {{ r.port }} accept +{% endfor %} + } + chain forward { type filter hook forward priority 0; policy drop; } + chain output { type filter hook output priority 0; policy accept; } +} + +include "{{ base__firewall_dropin_dir }}/*.nft" +``` + +- [ ] **Step 2: Create `roles/base/tasks/firewall.yml`** (render path only; apply added in Task 5) + +```yaml +--- +- name: Install nftables + ansible.builtin.apt: + name: nftables + state: present + tags: [firewall] + +- name: Ensure nftables drop-in dir exists + ansible.builtin.file: + path: "{{ base__firewall_dropin_dir }}" + state: directory + mode: "0755" + tags: [firewall] + +- name: Resolve firewall ingress rules for this host + ansible.builtin.set_fact: + base__firewall_resolved: >- + {{ firewall_catalog | default({}) + | resolve_firewall_rules(firewall_zones | default({}), + inventory_hostname, hostvars, groups) }} + tags: [firewall] + +- name: Render nftables ruleset (syntax-checked before install) + ansible.builtin.template: + src: nftables.conf.j2 + dest: /etc/nftables.conf + mode: "0644" + validate: "nft -c -f %s" + register: base__firewall_render + tags: [firewall] +``` + +- [ ] **Step 3: Replace `roles/base/tasks/main.yml`** + +```yaml +--- +- name: Configure host firewall (nftables) + ansible.builtin.include_tasks: firewall.yml + tags: [firewall] +``` + +- [ ] **Step 4: Add a fixture IP in `roles/base/molecule/default/molecule.yml`** + +In the `provisioner.inventory.host_vars.instance` map (which already sets +`ansible_user: root`), add `ansible_host: 10.20.0.50` so the resolver can map the +instance to an IP. The block becomes: + +```yaml +provisioner: + name: ansible + inventory: + host_vars: + instance: + ansible_user: root + ansible_host: 10.20.0.50 +``` + +(The Molecule Docker connection addresses the container by name, not `ansible_host`, so +this is data-only and won't affect connectivity.) + +- [ ] **Step 5: Replace `roles/base/molecule/default/converge.yml`** with a fixture catalog and `apply: false` + +```yaml +--- +- name: Converge + hosts: all + become: true + gather_facts: true + vars: + base__firewall_apply: false + firewall_zones: + lan: 10.30.0.0/24 + srv: 10.20.0.0/24 + mgmt: 10.10.0.0/24 + firewall_catalog: + reverse_proxy: + host: instance + ingress: + - { from: lan, port: 443, proto: tcp } + photoprism: + host: instance + ingress: + - { from: reverse_proxy, port: 2342, proto: tcp } + roles: + - role: base +``` + +- [ ] **Step 6: Run Molecule (scaffold verify still trivially passes) + lint** + +Run: `make lint && make test ROLE=base` +Expected: `make lint` passes. Molecule creates the container, converges (installs nftables, renders `/etc/nftables.conf`, and the `nft -c` `validate` succeeds), passes the idempotence run (second converge reports no changes), runs the scaffold `verify.yml` (asserts `true`), and destroys. If the registry image is unreachable, report DONE_WITH_CONCERNS and confirm `make lint` + Task 3 pytest still pass. + +- [ ] **Step 7: Commit** + +```bash +git add roles/base/templates/nftables.conf.j2 roles/base/tasks/firewall.yml roles/base/tasks/main.yml roles/base/molecule/default/molecule.yml roles/base/molecule/default/converge.yml +git commit -m "feat(base): render nftables ruleset from catalog (+ molecule fixture)" +``` + +--- + +### Task 5: Safe apply with auto-rollback + +**Files:** +- Modify: `roles/base/tasks/firewall.yml` (append the apply block) + +- [ ] **Step 1: Append the safe-apply block to `roles/base/tasks/firewall.yml`** + +Add at the end of the file: + +```yaml +- name: Apply firewall ruleset safely (with auto-rollback) + when: + - base__firewall_apply | bool + - base__firewall_render is changed + tags: [firewall] + block: + - name: Snapshot the current ruleset as the rollback point + ansible.builtin.shell: "nft list ruleset > /etc/nftables.rollback" + changed_when: false + + - name: Clear any stale rollback unit + ansible.builtin.shell: >- + systemctl stop nft-rollback.timer nft-rollback.service 2>/dev/null; + systemctl reset-failed nft-rollback.timer nft-rollback.service 2>/dev/null; + true + changed_when: false + + - name: Arm the auto-rollback timer + ansible.builtin.command: + cmd: >- + systemd-run --on-active={{ base__firewall_rollback_timeout }} + --unit=nft-rollback /usr/sbin/nft -f /etc/nftables.rollback + changed_when: true + + - name: Apply the new ruleset + ansible.builtin.command: nft -f /etc/nftables.conf + changed_when: true + + - name: Confirm connectivity survived, then disarm the rollback + ansible.builtin.shell: >- + systemctl stop nft-rollback.timer nft-rollback.service 2>/dev/null; + systemctl reset-failed nft-rollback.timer nft-rollback.service 2>/dev/null; + true + changed_when: false + +- name: Enable nftables.service so the ruleset persists across reboot + ansible.builtin.systemd: + name: nftables + enabled: true + when: base__firewall_apply | bool + tags: [firewall] +``` + +(The "Confirm" step runs only if the play reached it — i.e. the apply did not sever the +connection. If the apply locked the host out, the play cannot continue, the armed timer +fires after `base__firewall_rollback_timeout` seconds, and the host self-heals to the +snapshot. Molecule sets `base__firewall_apply: false`, so this block is skipped there.) + +- [ ] **Step 2: Re-run Molecule + lint (apply still skipped, must stay idempotent)** + +Run: `make lint && make test ROLE=base` +Expected: `make lint` passes (no `no-changed-when`/FQCN findings — every command/shell has `changed_when`). Molecule still green and idempotent (the apply block is gated off by `base__firewall_apply: false`). DONE_WITH_CONCERNS if the image is unreachable. + +- [ ] **Step 3: Commit** + +```bash +git add roles/base/tasks/firewall.yml +git commit -m "feat(base): safe nftables apply with systemd-run auto-rollback" +``` + +--- + +### Task 6: Molecule verify — assert rendered rules + syntax + +**Files:** +- Replace: `roles/base/molecule/default/verify.yml` + +- [ ] **Step 1: Replace `roles/base/molecule/default/verify.yml`** + +```yaml +--- +- name: Verify + hosts: all + become: true + gather_facts: false + tasks: + - name: Read the rendered ruleset + ansible.builtin.slurp: + src: /etc/nftables.conf + register: ruleset + + - name: Decode it + ansible.builtin.set_fact: + nft: "{{ ruleset.content | b64decode }}" + + - name: Assert default-deny input policy and management plane + ansible.builtin.assert: + that: + - "'type filter hook input priority 0; policy drop;' in nft" + - "'ct state established,related accept' in nft" + - "'iif \"wt0\" tcp dport 22 accept' in nft" + fail_msg: "input chain is missing default-deny or the management plane" + + - name: Assert the lan->reverse_proxy:443 ingress rule + ansible.builtin.assert: + that: + - "'10.30.0.0/24' in nft" + - "'tcp dport 443 accept' in nft" + fail_msg: "missing lan->443 rule for reverse_proxy" + + - name: Assert the reverse_proxy->photoprism:2342 ingress rule (resolved to host IP) + ansible.builtin.assert: + that: + - "'10.20.0.50/32' in nft" + - "'tcp dport 2342 accept' in nft" + fail_msg: "missing reverse_proxy->2342 rule for photoprism" + + - name: Assert the docker_host extension hook is present + ansible.builtin.assert: + that: + - "'include \"/etc/nftables.d/*.nft\"' in nft" + fail_msg: "missing drop-in include hook" + + - name: Syntax-check the rendered ruleset (no apply) + ansible.builtin.command: nft -c -f /etc/nftables.conf + changed_when: false +``` + +- [ ] **Step 2: Run the full Molecule sequence + lint** + +Run: `make lint && make test ROLE=base` +Expected: `make lint` passes; Molecule converge renders, then `verify.yml` passes all +assertions and the `nft -c` check. DONE_WITH_CONCERNS if the image is unreachable (note +that the assertions could not be exercised). + +- [ ] **Step 3: Commit** + +```bash +git add roles/base/molecule/default/verify.yml +git commit -m "test(base): molecule verify asserts rendered firewall rules + nft -c" +``` + +--- + +### Task 7: Reflect the build in STATUS + CAPABILITIES + +**Files:** +- Modify: `STATUS.md` +- Modify: `docs/CAPABILITIES.md` + +- [ ] **Step 1: Update the `roles/base/` row in STATUS.md** + +In `STATUS.md`, under "## Scaffolded but empty — NOT implemented", find the row: + +```markdown +| `roles/base/` | Not in git — only an empty dir on disk (untracked). `site.yml` references it, so a clean clone errors on `make deploy PLAYBOOK=site` until it is built. | +``` + +Replace it with: + +```markdown +| `roles/base/` | **Partially built.** The `firewall` concern is implemented (nftables: catalog-driven default-deny + east-west allowlist + auto-rollback apply; ADR-020) with pytest + Molecule render/syntax tests. Other concerns (SSH hardening, fail2ban, auditd, packages, users) are **not** built yet, so `make deploy PLAYBOOK=site` is still incomplete. | +``` + +- [ ] **Step 2: Update the firewall note in CAPABILITIES.md** + +In `docs/CAPABILITIES.md` (§1 Edge & networking), find the line added for ADR-020: + +```markdown +_Firewalling is two-layer (ADR-020): OPNsense at the perimeter + inter-VLAN, plus +per-host `nftables` (default-deny inbound + east-west allowlist) rendered by the `base` +role from a shared `group_vars` service catalog. Both layers are still to be built._ +``` + +Replace the final sentence so it reads: + +```markdown +_Firewalling is two-layer (ADR-020): OPNsense at the perimeter + inter-VLAN, plus +per-host `nftables` (default-deny inbound + east-west allowlist) rendered by the `base` +role from a shared `group_vars` service catalog. The host `nftables` layer is built (the +`base` firewall concern); the OPNsense layer is still to be built._ +``` + +- [ ] **Step 3: Update the `_Last reviewed_` date in STATUS.md** + +In `STATUS.md`, change the `_Last reviewed: ..._` line to `_Last reviewed: 2026-06-06._` +(if it is not already that date). + +- [ ] **Step 4: Verify + lint** + +Run: `grep -n "Partially built" STATUS.md && grep -n "host .nftables. layer is built" docs/CAPABILITIES.md && make lint` +Expected: both greps match; `make lint` passes. + +- [ ] **Step 5: Commit** + +```bash +git add STATUS.md docs/CAPABILITIES.md +git commit -m "docs: record base firewall concern built (ADR-020 host layer)" +``` + +--- + +## Final verification + +- [ ] `make lint` passes end to end (yamllint + ansible-lint over `roles/base` + `check-tags: OK`). +- [ ] `.venv/bin/python -m pytest tests/ -v` passes (the `check-tags` suite + the 7 new `firewall_rules` tests). +- [ ] `make test ROLE=base` is green (or DONE_WITH_CONCERNS with a clear note if the Molecule image is unreachable in this environment). +- [ ] `git log --oneline -7` shows the seven task commits. +- [ ] Sanity: `roles/base/tasks/firewall.yml` never applies when `base__firewall_apply` is false, and every `command`/`shell` task has `changed_when` (ansible-lint clean).