docs(plan): host nftables firewall implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d7fbaca554
commit
03329d7d25
1 changed files with 712 additions and 0 deletions
712
docs/superpowers/plans/2026-06-06-host-nftables-firewall.md
Normal file
712
docs/superpowers/plans/2026-06-06-host-nftables-firewall.md
Normal file
|
|
@ -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: <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:
|
||||||
|
|
||||||
|
```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).
|
||||||
Loading…
Add table
Reference in a new issue