Compare commits
14 commits
2ad50e4d5b
...
fcfb056591
| Author | SHA1 | Date | |
|---|---|---|---|
| fcfb056591 | |||
| 402913efb3 | |||
| 90683c7912 | |||
| 6fb104e934 | |||
| b006196cc5 | |||
| 026a29f609 | |||
| bca74458fb | |||
| eeab5ed8de | |||
| 7dae93e4e1 | |||
| 4127f8bc6b | |||
| 390cd3b335 | |||
| 2486e31f7d | |||
| 03329d7d25 | |||
| d7fbaca554 |
19 changed files with 1460 additions and 4 deletions
|
|
@ -31,13 +31,13 @@ _Last reviewed: 2026-06-06._
|
|||
|
||||
| Thing | State |
|
||||
|---|---|
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `roles/docker_host/` | Not in git. Same. |
|
||||
| `inventories/*/hosts.yml` | Structured stubs with empty host maps (`hosts: {}`); regenerated by `make tf-inventory` once Terraform has hosts |
|
||||
| `inventories/production/group_vars/{docker_hosts,proxmox_hosts}/` | Empty dirs |
|
||||
|
||||
So `make deploy PLAYBOOK=site` currently **fails** on a clean clone — the `base` and
|
||||
`docker_host` roles it calls do not exist yet.
|
||||
So `make deploy PLAYBOOK=site` is still incomplete — `base` is only partially built (its
|
||||
`firewall` concern only) and the `docker_host` role does not exist yet.
|
||||
|
||||
## Designed but not built
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@ _(DHCP, firewall, mDNS reflection live on OPNsense — Ansible-managed, not cont
|
|||
|
||||
_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._
|
||||
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._
|
||||
|
||||
## 2. Identity & access — [P]
|
||||
|
||||
|
|
|
|||
|
|
@ -94,3 +94,33 @@ earning its keep.
|
|||
is redundant friction. → After `writing-plans` finishes, begin subagent-driven
|
||||
implementation directly. The only reason to stop is a genuine blocker or ambiguity, not
|
||||
a routine checkpoint.
|
||||
|
||||
### Host nftables firewall build (`base` role)
|
||||
|
||||
- `[gotcha]` **`nft -c` rejects `iif "<name>"` when the interface is absent** (it resolves
|
||||
to an interface *index* at load time). The render+syntax-check Molecule step caught
|
||||
`iif "wt0"` failing in the container — and it would fail identically on any real host
|
||||
before NetBird brings up `wt0`. Use **`iifname "<name>"`** (string match, no existence
|
||||
requirement, survives the interface coming/going) for any interface that may be absent.
|
||||
- `[gotcha]` **Molecule's `community.docker` connection uses `ansible_host` as the
|
||||
container name** (`remote_addr`). Setting `ansible_host` as *data* in a scenario's
|
||||
`host_vars` (e.g. to give a resolver a fake IP) breaks the connection → `UNREACHABLE`,
|
||||
"Failed to create temporary directory". Don't override `ansible_host` in molecule; feed
|
||||
fixture IPs another way (or keep fixtures to zone sources and unit-test IP resolution).
|
||||
- `[recurring]` **`make test ROLE=<r>` needs the venv on PATH.** Run non-activated (as
|
||||
agents do), molecule dies with `FileNotFoundError: 'ansible-config'` — it shells out to
|
||||
`ansible-config`/`ansible-playbook` by bare name. Workaround: `PATH="$PWD/.venv/bin:$PATH"
|
||||
.venv/bin/molecule test`. Also the molecule image wasn't in the Forgejo registry (pull →
|
||||
"not found"); had to `make molecule-image` to build it locally. → Consider (a) the
|
||||
Makefile `test` target prepending `.venv/bin` to PATH, and (b) `make molecule-image-push`
|
||||
so a fresh checkout can pull it.
|
||||
- `[gotcha]` **Apply-only task paths have no Level-1 coverage**, so safety bugs hide there.
|
||||
The `nft` auto-rollback snapshot used a bare `nft list ruleset` (no leading `flush
|
||||
ruleset`) → the revert was a silent no-op on first apply and errored on later ones; the
|
||||
whole safety net was dead. Molecule never runs the apply (gated off), so only adversarial
|
||||
review + an isolated-netns round-trip test caught it. → For apply/safety paths molecule
|
||||
can't exercise, validate out-of-band (a throwaway `--privileged` container with its own
|
||||
netns) and treat a final adversarial review as mandatory, not optional.
|
||||
- `[note]` The render-and-`nft -c` (no-apply) Molecule approach **earned its keep** —
|
||||
caught the `iif`/`iifname` bug deterministically without touching the host kernel. Good
|
||||
pattern to reuse for other config-rendering roles.
|
||||
|
|
|
|||
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).
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
# Design — Host nftables firewall (the `firewall` concern of `base`)
|
||||
|
||||
- **Date:** 2026-06-06
|
||||
- **Status:** Approved design — pending implementation plan
|
||||
- **Implements:** ADR-020 deferred build #1 (host nftables in `base`)
|
||||
- **Scope:** The **`firewall`-tagged concern of the `base` role only**. Other `base`
|
||||
concerns (SSH hardening, fail2ban, auditd, packages, users) are separate future efforts.
|
||||
Docker netfilter is deferred to the `docker_host` role.
|
||||
|
||||
---
|
||||
|
||||
## Problem
|
||||
|
||||
ADR-020 settled the firewall *strategy*: a per-host nftables layer doing default-deny
|
||||
inbound + east-west allowlisting + permissive egress, rendered from a shared
|
||||
`group_vars` service catalog. Nothing is built yet — `roles/base/` is empty. This spec
|
||||
designs the concrete host firewall: the catalog schema, how rules are resolved and
|
||||
rendered, how they are applied without locking out the host, and how it is tested.
|
||||
|
||||
Two hard constraints shape the design:
|
||||
|
||||
1. **Molecule runs in a privileged Docker container sharing the dev host (`ubongo`)
|
||||
kernel netfilter** — applying real nftables rules there could mutate the live host.
|
||||
So Level-1 testing renders and syntax-checks but does **not** apply.
|
||||
2. **Lockout risk** — a bad ruleset can brick SSH/Ansible. On-cluster hosts have the
|
||||
Proxmox console as break-glass; offsite `askari` (Hetzner) does not, cheaply.
|
||||
|
||||
## Scope decisions (settled in brainstorming)
|
||||
|
||||
- **Host firewall only**, coherent on any host (even one with no services). Docker
|
||||
`iptables:false` + container forward/NAT/masquerade are **deferred to `docker_host`**,
|
||||
which contributes rules via an extension hook (below).
|
||||
- **Placement lives in the catalog** (`host:` | `group:` | `hosts:`), giving one source
|
||||
of truth that also resolves symbolic sources. Proxmox HA/migration moves a *VM*
|
||||
between physical nodes but the VM keeps its static `srv` IP and inventory identity, so
|
||||
node-level failover is invisible to the firewall. A planned service relocation is a
|
||||
one-line catalog edit + `--tags firewall` re-deploy (which re-renders opened ports
|
||||
*and* every source resolution consistently). Within-group HA is handled by placing a
|
||||
service on a `group`/`hosts` list — the allowlist then already covers every member.
|
||||
- **Level-1 testing = render + `nft -c` syntax check, no apply.** Enforcement is
|
||||
verified at Level 2 on staging VMs.
|
||||
- **Auto-rollback safety net** on apply (critical for offsite `askari`).
|
||||
|
||||
## Role layout
|
||||
|
||||
Scaffold with `make new-role base`, then implement the firewall concern:
|
||||
|
||||
```
|
||||
roles/base/
|
||||
tasks/main.yml # include_tasks firewall.yml (tags: [firewall]); grows later
|
||||
tasks/firewall.yml # install nftables, render, validate, safe-apply
|
||||
filter_plugins/firewall_rules.py # pure catalog→resolved-rules resolver (pytest-unit-tested)
|
||||
templates/nftables.conf.j2
|
||||
defaults/main.yml # base__firewall_* behaviour knobs
|
||||
handlers/main.yml
|
||||
molecule/default/ # fixture catalog + inventory; converge + verify
|
||||
README.md, meta/main.yml
|
||||
```
|
||||
|
||||
`base` is infrastructure, not a *service* role, so the service-role `SECURITY.md` /
|
||||
`VERIFY.md` conventions (ADR-004) do not apply. The firewall role import in a playbook
|
||||
carries the `base` role-name tag (enforced by `check-tags.py`, ADR-019); the firewall
|
||||
tasks within carry the `firewall` concern tag.
|
||||
|
||||
## Data model — shared catalog + zones
|
||||
|
||||
Two new **global inventory facts** (read by `base` now and OPNsense later, so plain
|
||||
names, not role-namespaced) in `inventories/<env>/group_vars/all/firewall.yml`:
|
||||
|
||||
```yaml
|
||||
# Zone → subnet (from ADR-007)
|
||||
firewall_zones:
|
||||
lan: 10.30.0.0/24
|
||||
srv: 10.20.0.0/24
|
||||
mgmt: 10.10.0.0/24
|
||||
iot: 10.40.0.0/24
|
||||
guest: 10.50.0.0/24
|
||||
|
||||
# Service catalog: name → placement + ingress
|
||||
firewall_catalog:
|
||||
reverse_proxy:
|
||||
host: docker01 # placement: host | group | hosts:[...]
|
||||
ingress:
|
||||
- { from: lan, port: 443, proto: tcp }
|
||||
photoprism:
|
||||
host: docker01
|
||||
ingress:
|
||||
- { from: reverse_proxy, port: 2342, proto: tcp }
|
||||
```
|
||||
|
||||
- **Placement** is exactly one of `host: <name>`, `group: <group>`, or `hosts: [<name>, …]`.
|
||||
- **`from`** resolves three ways, checked in this order: (1) a key in `firewall_zones`
|
||||
→ that subnet; (2) a key in `firewall_catalog` → that service's placement → host
|
||||
IP(s) as `/32`; (3) an inventory group or host name → its IP(s) as `/32`. An
|
||||
unresolvable `from` is a hard error (fail fast, never silently open/skip).
|
||||
|
||||
Role **behaviour knobs** stay role-namespaced in `roles/base/defaults/main.yml`:
|
||||
|
||||
| Default | Value | Purpose |
|
||||
|---|---|---|
|
||||
| `base__firewall_mgmt_interface` | `wt0` | interface SSH is accepted on (NetBird overlay, ADR-016) |
|
||||
| `base__firewall_ssh_port` | `22` | SSH port allowed on the mgmt interface |
|
||||
| `base__firewall_rollback_timeout` | `45` | seconds before auto-revert fires |
|
||||
| `base__firewall_dropin_dir` | `/etc/nftables.d` | extension dir included by the ruleset |
|
||||
|
||||
## Resolution & rendering
|
||||
|
||||
The resolver is a **pure Python filter plugin**, `roles/base/filter_plugins/firewall_rules.py`,
|
||||
exposing `resolve_firewall_rules(catalog, zones, inventory_hostname, hostvars)`. It:
|
||||
|
||||
1. selects catalog entries placed on `inventory_hostname` (matching `host`, membership
|
||||
in `group`, or presence in `hosts`);
|
||||
2. for each entry's `ingress` rules, resolves `from` to a list of source CIDRs (zone /
|
||||
service-placement / group-or-host, per the order above);
|
||||
3. returns a **deterministic, de-duplicated, sorted** list of
|
||||
`{proto, port, sources: [cidr, …]}`.
|
||||
|
||||
Chosen over inline Jinja (unreadable, untestable) and a `set_fact` loop (awkward to
|
||||
unit-test) — a filter plugin matches the house style of `check-tags.py` /
|
||||
`capacity-scan.py` and is pytest-unit-testable in isolation. Host→IP resolution reads
|
||||
`hostvars[<host>].ansible_host` (the static `srv` IP the Terraform-generated inventory
|
||||
provides).
|
||||
|
||||
`tasks/firewall.yml` builds `base__firewall_resolved` from the filter; the template
|
||||
renders that flat list:
|
||||
|
||||
```jinja
|
||||
#!/usr/sbin/nft -f
|
||||
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"
|
||||
```
|
||||
|
||||
A host with no catalog entries still gets a valid default-deny + management-plane
|
||||
ruleset. The `include` is the `docker_host` extension hook (forward/NAT drop-ins).
|
||||
Sorted resolved rules → stable diffs and deterministic tests.
|
||||
|
||||
## Safe apply (lockout protection)
|
||||
|
||||
`tasks/firewall.yml` renders `/etc/nftables.conf`; when it changes, a **linear**
|
||||
safe-apply sequence runs (deliberately in tasks, not a handler, so the confirm/cancel
|
||||
step is controllable — a small, justified deviation from the handler idiom, noted in the
|
||||
role README):
|
||||
|
||||
1. **Validate** — `nft -c -f /etc/nftables.conf`; fail the play if invalid, before
|
||||
touching the live ruleset.
|
||||
2. **Snapshot** — `nft list ruleset > /etc/nftables.rollback` (empty/flush on first run).
|
||||
3. **Arm revert** — `systemd-run --on-active={{ base__firewall_rollback_timeout }}
|
||||
--unit=nft-rollback nft -f /etc/nftables.rollback` (transient timer, no `at`
|
||||
dependency).
|
||||
4. **Apply** — `nft -f /etc/nftables.conf`.
|
||||
5. **Confirm + disarm** — the next Ansible task running proves the connection survived →
|
||||
`systemctl stop nft-rollback`. If the apply bricked connectivity, the play cannot
|
||||
continue, the timer fires, and the host self-heals (the offsite-`askari` safeguard).
|
||||
6. **Persist** — enable `nftables.service` so `/etc/nftables.conf` loads on boot.
|
||||
|
||||
`established/related` (rendered in the ruleset) means the in-flight Ansible session
|
||||
survives the swap; atomic `nft -f` avoids partial states.
|
||||
|
||||
**NetBird dependency:** locking SSH to `wt0`-only assumes NetBird (ADR-016) is built.
|
||||
Until then, `base__firewall_mgmt_interface` (and, if needed, an additional management
|
||||
source) is set to a reachable path so the role is deployable independently. This is a
|
||||
config knob, not a code dependency.
|
||||
|
||||
## Testing (ADR-008)
|
||||
|
||||
- **Level 1 / pytest** — unit-test `firewall_rules.py` against fixture catalogs: zone
|
||||
resolution, service→host-IP resolution, `group`/`hosts` multi-host placement, a host
|
||||
with no services, source de-dup/sort, and an unresolvable `from` raising. Mirrors
|
||||
`tests/test_check_tags.py` (import the module, assert on return values).
|
||||
- **Level 1 / Molecule** — fixture `firewall_catalog` + fixture inventory (host_vars/
|
||||
group_vars) in the scenario; `converge` renders `/etc/nftables.conf`; `verify` asserts
|
||||
(a) expected accept lines are present for the fixture and (b) `nft -c -f
|
||||
/etc/nftables.conf` validates syntax. **No apply** (kernel safety).
|
||||
- **Level 2 / staging** — real apply on staging VMs verifies enforcement *and* the
|
||||
safe-apply + auto-rollback path (steps 2–5), which Level 1 cannot safely cover.
|
||||
|
||||
The Molecule base image is not guaranteed to ship `nft`. The role installs the
|
||||
`nftables` package as its first firewall task, so by the time `verify` runs the `nft -c`
|
||||
syntax check, `nft` is present (installed during `converge`).
|
||||
|
||||
## Open dependencies / notes
|
||||
|
||||
- **NetBird/ADR-016 unbuilt** — see the mgmt-interface knob above; full `wt0`-only
|
||||
lockdown lands when NetBird does.
|
||||
- The safe-apply orchestration (steps 2–5) has **no Level-1 coverage** by design; it is
|
||||
integration-tested at Level 2. Called out so the gap is explicit.
|
||||
|
||||
## Scope summary
|
||||
|
||||
**Built here:** `firewall_catalog`/`firewall_zones` schema; `firewall_rules.py` resolver
|
||||
+ pytest; `nftables.conf.j2` (default-deny input, mgmt plane, permissive egress, drop-in
|
||||
`include` hook); safe-apply-with-rollback tasks; Molecule render/syntax scenario;
|
||||
`base` role scaffolding (README, meta, defaults, handlers).
|
||||
|
||||
**Deferred:** Docker `iptables:false` + container forward/NAT (→ `docker_host` spec, via
|
||||
the drop-in hook); OPNsense rendering from the same catalog (→ OPNsense-as-code spec);
|
||||
drift-detection check (ADR-020); all other `base` concerns.
|
||||
|
||||
## Related
|
||||
|
||||
ADR-020 (firewall strategy), ADR-002 (security baseline), ADR-004 (Docker model —
|
||||
`iptables:false`, one service = one role), ADR-007 (VLANs/subnets), ADR-008 (testing
|
||||
levels), ADR-016 (NetBird mesh — SSH on `wt0`), ADR-019 (`firewall` tag).
|
||||
15
inventories/production/group_vars/all/firewall.yml
Normal file
15
inventories/production/group_vars/all/firewall.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
# 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: {}
|
||||
15
inventories/staging/group_vars/all/firewall.yml
Normal file
15
inventories/staging/group_vars/all/firewall.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
# 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: {}
|
||||
29
roles/base/README.md
Normal file
29
roles/base/README.md
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# 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.
|
||||
9
roles/base/defaults/main.yml
Normal file
9
roles/base/defaults/main.yml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
# 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_confirm_timeout: 20 # seconds to re-establish a fresh connection post-apply
|
||||
base__firewall_dropin_dir: /etc/nftables.d
|
||||
base__firewall_apply: true # set false to render+validate without applying (CI/Molecule)
|
||||
85
roles/base/filter_plugins/firewall_rules.py
Normal file
85
roles/base/filter_plugins/firewall_rules.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
"""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, non-empty list of source CIDRs.
|
||||
|
||||
Resolution order: zone -> catalog service -> inventory group -> host. A name present
|
||||
in more than one namespace resolves to the first match in that order. Resolving to
|
||||
zero hosts (e.g. an empty group) is an error, not a silently empty rule.
|
||||
"""
|
||||
if frm in zones:
|
||||
return [zones[frm]]
|
||||
if frm in catalog:
|
||||
hosts = _placement_hosts(catalog[frm], groups)
|
||||
if not hosts:
|
||||
raise ValueError(f"firewall source service '{frm}' resolves to no hosts")
|
||||
return sorted(_host_cidr(h, hostvars) for h in hosts)
|
||||
if frm in groups:
|
||||
hosts = groups[frm]
|
||||
if not hosts:
|
||||
raise ValueError(f"firewall source group '{frm}' has no members")
|
||||
return sorted(_host_cidr(h, hostvars) for h in hosts)
|
||||
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 {}
|
||||
hostvars = hostvars 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", []):
|
||||
if "from" not in ing or "port" not in ing:
|
||||
raise ValueError(f"ingress entry missing 'from' or 'port': {ing!r}")
|
||||
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}
|
||||
1
roles/base/handlers/main.yml
Normal file
1
roles/base/handlers/main.yml
Normal file
|
|
@ -0,0 +1 @@
|
|||
---
|
||||
11
roles/base/meta/main.yml
Normal file
11
roles/base/meta/main.yml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
---
|
||||
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: []
|
||||
22
roles/base/molecule/default/converge.yml
Normal file
22
roles/base/molecule/default/converge.yml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
---
|
||||
- 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: srv, port: 2342, proto: tcp }
|
||||
roles:
|
||||
- role: base
|
||||
31
roles/base/molecule/default/molecule.yml
Normal file
31
roles/base/molecule/default/molecule.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
---
|
||||
dependency:
|
||||
name: galaxy
|
||||
options:
|
||||
requirements-file: ../../requirements.yml
|
||||
|
||||
driver:
|
||||
name: docker
|
||||
|
||||
platforms:
|
||||
- name: instance
|
||||
# Project-owned image built from .docker/molecule-debian13/Dockerfile
|
||||
# and hosted in the Forgejo container registry.
|
||||
# Build/push with: make molecule-image / make molecule-image-push
|
||||
image: forgejo.nyumbani.baobab.band/sjat/molecule-debian13:latest
|
||||
pre_build_image: true
|
||||
privileged: true # required for systemd
|
||||
cgroupns_mode: host
|
||||
volumes:
|
||||
- /sys/fs/cgroup:/sys/fs/cgroup:rw
|
||||
command: /lib/systemd/systemd
|
||||
|
||||
provisioner:
|
||||
name: ansible
|
||||
inventory:
|
||||
host_vars:
|
||||
instance:
|
||||
ansible_user: root
|
||||
|
||||
verifier:
|
||||
name: ansible
|
||||
46
roles/base/molecule/default/verify.yml
Normal file
46
roles/base/molecule/default/verify.yml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
- 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"
|
||||
- "'iifname \"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 (zone source)
|
||||
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 srv->photoprism:2342 ingress rule (zone source)
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- "'10.20.0.0/24' in nft"
|
||||
- "'tcp dport 2342 accept' in nft"
|
||||
fail_msg: "missing srv->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
|
||||
105
roles/base/tasks/firewall.yml
Normal file
105
roles/base/tasks/firewall.yml
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
---
|
||||
- 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]
|
||||
|
||||
- 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
|
||||
# Prepend `flush ruleset` so the revert is atomic and self-contained: on a
|
||||
# first-ever apply the snapshot is just `flush ruleset` (reverts to an empty,
|
||||
# kernel-default-accept state → reachable); on later applies it avoids
|
||||
# "table exists" errors when replayed. Without the flush the rollback is a no-op.
|
||||
ansible.builtin.shell: "{ echo 'flush ruleset'; nft list ruleset; } > /etc/nftables.rollback"
|
||||
changed_when: false
|
||||
|
||||
- name: Stop stale rollback timer unit
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: stopped
|
||||
loop:
|
||||
- nft-rollback.timer
|
||||
- nft-rollback.service
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Reset failed state on stale rollback units
|
||||
ansible.builtin.command: systemctl reset-failed {{ item }}
|
||||
loop:
|
||||
- nft-rollback.timer
|
||||
- nft-rollback.service
|
||||
failed_when: false
|
||||
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: Drop the persistent connection so the confirm uses a fresh one
|
||||
ansible.builtin.meta: reset_connection
|
||||
|
||||
- name: Confirm a NEW connection survives the applied ruleset
|
||||
ansible.builtin.wait_for_connection:
|
||||
timeout: "{{ base__firewall_confirm_timeout }}"
|
||||
|
||||
- name: Stop the rollback timer after connectivity confirmed
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: stopped
|
||||
loop:
|
||||
- nft-rollback.timer
|
||||
- nft-rollback.service
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
|
||||
- name: Reset failed state on rollback units after disarm
|
||||
ansible.builtin.command: systemctl reset-failed {{ item }}
|
||||
loop:
|
||||
- nft-rollback.timer
|
||||
- nft-rollback.service
|
||||
failed_when: false
|
||||
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]
|
||||
4
roles/base/tasks/main.yml
Normal file
4
roles/base/tasks/main.yml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
---
|
||||
- name: Configure host firewall (nftables)
|
||||
ansible.builtin.include_tasks: firewall.yml
|
||||
tags: [firewall]
|
||||
22
roles/base/templates/nftables.conf.j2
Normal file
22
roles/base/templates/nftables.conf.j2
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
#!/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;
|
||||
iifname "lo" accept
|
||||
ct state established,related accept
|
||||
ct state invalid drop
|
||||
iifname "{{ 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"
|
||||
99
tests/test_firewall_rules.py
Normal file
99
tests/test_firewall_rules.py
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
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)
|
||||
|
||||
|
||||
def test_hosts_list_placement():
|
||||
cat = {"svc": {"hosts": ["docker01", "docker02"],
|
||||
"ingress": [{"from": "lan", "port": 9090, "proto": "tcp"}]}}
|
||||
out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
|
||||
assert out == [{"proto": "tcp", "port": 9090, "sources": ["10.30.0.0/24"]}]
|
||||
|
||||
|
||||
def test_proto_defaults_to_tcp():
|
||||
cat = {"svc": {"host": "docker01", "ingress": [{"from": "lan", "port": 80}]}}
|
||||
out = fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
|
||||
assert out == [{"proto": "tcp", "port": 80, "sources": ["10.30.0.0/24"]}]
|
||||
|
||||
|
||||
def test_empty_group_source_raises():
|
||||
cat = {"svc": {"host": "docker01",
|
||||
"ingress": [{"from": "empty_grp", "port": 80, "proto": "tcp"}]}}
|
||||
with pytest.raises(ValueError):
|
||||
fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, {"empty_grp": []})
|
||||
|
||||
|
||||
def test_ingress_missing_port_raises():
|
||||
cat = {"svc": {"host": "docker01", "ingress": [{"from": "lan"}]}}
|
||||
with pytest.raises(ValueError):
|
||||
fr.resolve_firewall_rules(cat, ZONES, "docker01", HOSTVARS, GROUPS)
|
||||
Loading…
Add table
Reference in a new issue