boma/docs/superpowers/plans/2026-06-06-host-nftables-firewall.md
sjat 03329d7d25 docs(plan): host nftables firewall implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:47:48 +02:00

26 KiB

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:

---
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:

# 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
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:

---
# 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: <name> → 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:

---
# 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
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:

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:

"""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
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:

#!/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)
---
- 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
---
- 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:

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
---
- 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
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:

- 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
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

---
- 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
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:

| `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:

| `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:

_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:

_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
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).