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