Firewall: zones and the zone matrix¶
Design draft for the firewall view. See design.md for the overall decisions
(firewalld on nftables, driven by Stronghold) and interfaces.md
for how networks get their zone.
Concepts¶
| Object | What it is | firewalld |
|---|---|---|
| Zone | A trust level that one or more networks belong to (WAN, LAN, GUEST, IOT, DMZ, MGMT, SYNC, ...). | zone; networks join it via NetworkManager's connection.zone |
| Gateway | The box itself, as a destination: DNS, DHCP, Cockpit, SSH, VPN. Shown as an extra column. | the symbolic HOST zone |
| Cell | Traffic from one zone to another (or to the Gateway). Has a default action and an ordered list of rules. | one policy, ingress-zone → egress-zone |
| Rule | Allow, reject or drop matching traffic, with optional logging and rate limit. | rich rule in that policy, ordered by rule priority |
| Address object | Named host, network, MAC or list of them (pfSense "alias"). | ipset |
| Port object | Named set of ports/protocols ("Web" = tcp/80, tcp/443). | service definition |
Every zone and every cell in firewalld is generated from the config model. Zones themselves carry no rules; everything, including traffic to the Gateway, is a cell, so the whole firewall reads the same way.
The view¶
Zone matrix¶
Rows are where traffic comes from, columns where it goes to:
from \ to WAN LAN GUEST IOT DMZ Gateway
──────────────────────────────────────────────────────────────────
WAN · ✕ ✕ ✕ 2 rules ✕
LAN ✓ NAT ✓ ✓ ✓ ✓ 3 rules
GUEST ✓ NAT ✕ ✕ ✕ ✕ DNS DHCP
IOT 1 rule ✕ ✕ ✓ ✕ DNS DHCP
DMZ ✓ NAT ✕ ✕ ✕ ✓ DNS
✓ allow all ✕ blocked n rules: default block + n allows
- Each cell shows its default and a summary of its rules. Click it to open the rule list for that pair.
- The diagonal is traffic between networks of the same zone (e.g. two LAN VLANs, or clients on the same guest network if it is bridged).
- Cells with recent hits light up; hovering shows packet and byte counts.
- Presets for common cells: "Allow all", "Internet only", "Block", "DNS and DHCP only" (for the Gateway column).
Rule list (inside a cell)¶
LAN → Gateway default: reject
───────────────────────────────────────────────────────────────────────
# action source destination ports log hits
1 allow any any DNS, DHCP 48 213
2 allow admins (object) any Cockpit, SSH ● 912
3 allow any any NTP 3 004
+ add rule
- Rules are evaluated top to bottom; first match wins; then the cell's default.
- Fields: action, source, destination, ports, log, rate limit, comment,
enabled. Source and destination take addresses, networks, address
objects or VPN clients by name (
wg0/phone,vpn.md); ports take port objects or plain ports. - Drag to reorder. Disable without deleting.
- IPv4 and IPv6 both, unless the rule's addresses say otherwise.
NAT¶
All NAT is Stronghold's, in its own nftables table ip stronghold_nat
(firewall/nat.yaml). firewalld only filters: its per-zone masquerade can't
express exceptions, a chosen address or 1:1. The table's chains run just
before firewalld's NAT chains, which are left empty, so they decide alone.
NAT is IPv4 only.
# firewall/nat.yaml
outbound:
mode: hybrid # automatic (default), hybrid, manual, disabled
rules: # hybrid: before the automatic ones; manual: the only ones
- { wan: wan, source: guest, translate: none } # no NAT
- { wan: wan2, source: 10.71.1.10, ports: [tcp/25], translate: 203.0.113.5 }
- { wan: wan, source: lan } # the WAN's address
forwards:
- { wan: wan, protocol: tcp, port: "2222", to: 10.71.1.10, to-port: "22",
source: office, reflection: true, comment: ssh to client1 }
- { wan: wan2, protocol: tcp/udp, port: 8000-8010, to: 10.71.1.100 }
one-to-one:
- { wan: wan2, external: 203.0.113.6, internal: 10.71.1.50 }
- Outbound: automatic gives connections from the networks each WAN's address (masquerade); in an HA pair a VRRP WAN's virtual address, so connections survive a failover. Hybrid tries the rules first, manual only the rules, disabled translates nothing (routed public addresses). A rule matches a WAN, source, destination and ports (as in firewall rules; a network name means its subnet) and translates to the WAN's address, an address on the WAN, or nothing. First match wins.
- Port forwards: a port (or range) on a WAN to a host inside, on the
same or another port. Stronghold adds the allow rule in the WAN zone →
target zone cell (a system rule, only from
sourcewhen set). Reflection lets hosts inside use the WAN address too: connections from inside to a WAN address of the box are forwarded and masqueraded so the answers come back through the box. - 1:1 NAT: an external address on a static WAN belongs to one host, both ways. The firewall still decides what may come in.
- Addresses used for 1:1 and outbound translation are added to the WAN (in a pair, keepalived moves them with the WAN's virtual address, so the WAN must be in vrrp mode).
- UPnP and NAT-PMP (
upnp): the inside networks' devices open ports for themselves, never the box's own or those forwarded; their mappings are in a table of miniupnpd's own, after this one (upnp.md). - The UI edits NAT in the Firewall tab and applies it with the rest of the firewall, under the same confirm window. The matrix marks cells whose traffic gets outbound NAT.
System rules¶
Rules Stronghold needs for its own features are generated, shown greyed out in the right cells, and can't be edited directly:
- HA: VRRP from the peer on every network with a virtual address; the peer API, conntrackd, Kea HA and SSH from the peer on the sync network.
- Port forwards: the allow rule for each forward.
- IPsec: UDP 500 and 4500 and ESP on the WAN of each tunnel, from its remote address (
vpn.md). - OpenVPN: the server's port, over its protocol, on its WAN (
vpn.md). - UPnP: SSDP, its HTTP, NAT-PMP and PCP from the subnets of its networks.
- DHCP client and IPv6 router advertisements on WAN.
- The rule that lets the current browser session reach Cockpit. Changing anything that would cut it off asks for confirmation, and commit-with-confirm reverts it if the session is lost.
Tools¶
- Test a packet: enter source, destination, protocol and port; the UI shows which cell and rule would match, from the config model, before anything is applied.
- Live log filtered by cell or rule.
- Diff before apply, as everywhere else.
Config model sketch¶
zones.yaml:
zones:
- id: wan
type: wan # outbound NAT, blocked inbound by default
- id: lan
- id: guest
- id: iot
- id: dmz
firewall/objects.yaml:
addresses:
admins: [192.168.1.10, 192.168.1.11]
printers: [192.168.40.20]
ports:
web: [tcp/80, tcp/443]
cockpit: [tcp/9090]
firewall/policies.yaml:
defaults:
internal: reject # between internal zones, and to the box itself
from-wan: drop # anything coming from a WAN zone
cells:
- from: lan
to: wan
default: allow
- from: lan
to: gateway
rules:
- { action: allow, ports: [dns, dhcp] }
- { action: allow, source: admins, ports: [cockpit, ssh], log: true, comment: "Admin access" }
- from: iot
to: wan
rules:
- { action: allow, ports: [web, ntp] }
Actions are allow, reject and drop, for cell defaults and rules alike.
Built-in port objects: dns, dhcp, dhcpv6, ssh, cockpit, http, https, web,
ntp, icmp, wireguard.
Cells not listed take the defaults.
More features¶
- Scheduled rules: a rule can have a schedule (e.g. weekdays 22:00–07:00). firewalld has no schedules, so strongholdd enables and disables the rule in firewalld's runtime configuration on a timer.
- DNS-name address objects (e.g.
api.example.com): strongholdd resolves the name, respecting the record's TTL, and keeps the ipset up to date.
Settled¶
- Default for blocked cells: reject between internal zones, so clients fail fast; drop for anything coming from a WAN zone.
How it is applied (implemented)¶
- Zone
lan→ firewalld zonesh-lan; cell lan → wan → policysh-lan-to-wan(priority -100, target = the cell default); to the box → egress zoneHOST. Rules become rich rules with priorities in rule order; system rules come first. - Networks join their zone through NetworkManager's
connection.zone. - Files Stronghold owns start with
sh-; nothing else in/etc/firewalldis touched.firewall-cmd --check-configruns before reload; a rejected config restores the previous files. - Every change goes through commit-with-confirm (60 s), from the UI and
the CLI (
stronghold firewall set DIR,stronghold confirm).
Hit counters¶
Each rule's hits (packets and bytes it decided since firewalld last
loaded its rules, so since the firewall was last applied), and each
cell's default: GET /v1/firewall/counters, stronghold counters [FROM
TO], and a Hits column in the rule editor (for the rules as applied; an
edited draft numbers them anew).
- strongholdd turns on firewalld's
NftablesCounters(firewalld.conf, besideIPv6_rpfilter): a counter on every nftables rule. - Rich rules carry no ID, so the counters are mapped back by order
(
internal/firewalld/counters.go). A cell's rich rules all have negative priorities, so firewalld puts them in its policy's_prechain in the order Stronghold wrote them (blocked devices, system rules, then the user's, by priority), one rule with the verdict each, after a rule with only the log statement when the rule logs (its prefix names the rule:SH lan>gw #2). A rule's rich rules (one per port, address) add up to its count; the policy chain's last rule is the default's. - The mapping checks itself: a cell whose
_prechain hasn't as many verdict rules as it has rich rules (firewalld changed by hand, a firewalld that lays rules out differently) says so instead of guessing. - Tried on firewalld 2.4 (Fedora 44): a logged rule with two ports, a
logged drop, a plain rule and system rules lined up; new SSH and
Cockpit connections from the LAN counted on their rule, one to a port no
rule allows on the default (
lab.md).