10 KiB
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 thebaserole only. Otherbaseconcerns (SSH hardening, fail2ban, auditd, packages, users) are separate future efforts. Docker netfilter is deferred to thedocker_hostrole.
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:
- 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. - 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 todocker_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 staticsrvIP and inventory identity, so node-level failover is invisible to the firewall. A planned service relocation is a one-line catalog edit +--tags firewallre-deploy (which re-renders opened ports and every source resolution consistently). Within-group HA is handled by placing a service on agroup/hostslist — the allowlist then already covers every member. - Level-1 testing = render +
nft -csyntax 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:
# 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>, orhosts: [<name>, …]. fromresolves three ways, checked in this order: (1) a key infirewall_zones→ that subnet; (2) a key infirewall_catalog→ that service's placement → host IP(s) as/32; (3) an inventory group or host name → its IP(s) as/32. An unresolvablefromis 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:
- selects catalog entries placed on
inventory_hostname(matchinghost, membership ingroup, or presence inhosts); - for each entry's
ingressrules, resolvesfromto a list of source CIDRs (zone / service-placement / group-or-host, per the order above); - 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:
#!/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):
- Validate —
nft -c -f /etc/nftables.conf; fail the play if invalid, before touching the live ruleset. - Snapshot —
nft list ruleset > /etc/nftables.rollback(empty/flush on first run). - Arm revert —
systemd-run --on-active={{ base__firewall_rollback_timeout }} --unit=nft-rollback nft -f /etc/nftables.rollback(transient timer, noatdependency). - Apply —
nft -f /etc/nftables.conf. - 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-askarisafeguard). - Persist — enable
nftables.serviceso/etc/nftables.confloads 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.pyagainst fixture catalogs: zone resolution, service→host-IP resolution,group/hostsmulti-host placement, a host with no services, source de-dup/sort, and an unresolvablefromraising. Mirrorstests/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;convergerenders/etc/nftables.conf;verifyasserts (a) expected accept lines are present for the fixture and (b)nft -c -f /etc/nftables.confvalidates 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-inincludehook); safe-apply-with-rollback tasks; Molecule render/syntax scenario;baserole 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).