Runbook 06 — Firewall rules (per-zone allow-lists, default deny)¶
Goal: implement the inter-zone policy matrix. Every zone defaults to deny; only the rules below are allowed. Rules are evaluated top-down per interface; first match wins.
Prereqs: runbooks 03–05 validated (interfaces, aliases, gateways exist).
Companion layer: this runbook is the OPNsense / inter-zone (north-south) firewall. Each fleet host also runs its own host firewall (firewalld, default-deny inbound) for east-west containment of same-VLAN traffic OPNsense never sees — see runbook 23. The two are complementary.
Conventions¶
26.1 note (source-checked against
opnsense/corestable/26.1): two rule UIs coexist — legacy Firewall → Rules →(per-interface tabs, as written below; pages firewall_rules.php/firewall_rules_edit.php) and Firewall → Rules [new] (one unified MVC list at/ui/firewall/filter/; pick the zone via the interface filter). Same ruleset either way — use whichever. The Gateway field for policy routing, including gateway groups (PROTONVPN_LB), is present in both (confirmed intact) — this whole design depends on it. - Label deltas if you use Rules [new] (MVC): the rule's on/off is "Enabled" (legacy uses "Disabled" — opposite sense), and the IP-version field is "Version" (legacy "TCP/IP Version"). Source/Destination port are "Source Port"/ "Destination Port" (legacy "… port range"). - Per-interface rules are top-down, first-match ("quick"); the floating section evaluates before interface rules. - Destination NAT rules no longer auto-create filter rules — on the DNAT rule set "Firewall rule" = Pass (that's the field's label in 26.1), or leave it Manual and add the matching rule here.
- Add rules under Firewall → Rules →
. - "fw" = "This Firewall (self)" destination.
- Each zone starts with an anti-lockout / housekeeping pattern, then allow rules, then an implicit default-deny (no explicit block needed unless noted).
- Use aliases from runbook 03 (
RFC1918,SERVERS_NET,IPA_PORTS, etc.). Before relying on the identity rules below, re-openIPA_PORTSand confirm it contains636(LDAPS), not635— a typo there silently blocks secure LDAP to FreeIPA. Also confirm every zone interface is…/24(a/32left from runbook 03 makes the zone's rules match traffic that can never arrive). - Policy: all egress via Proton. The per-zone
VPN_BYPASS → WAN_GWrule is present as a safety valve butVPN_BYPASSis empty by default (runbook 05 §6) — so it matches nothing and everything goes toPROTONVPN_LB. It only diverts traffic to the real WAN once you add a destination (when Proton's exit gets blocked somewhere).
A good first rule on every client/server zone (block inter-zone leakage
before allowing internet):
- Block source=<zone> net dest=RFC1918 (place AFTER the specific
inter-zone allows you want, BEFORE the internet allow).
Port aliases (the GUI can't take a comma-list in one port field). The "to fw" rules below reference three port aliases — create them in Firewall → Aliases (type Port(s)), or they're already in the pre-built config: -
FW_PORTS_MGMT=22, 53, 123, 443(MGMT only) -FW_PORTS_STD=53, 123, 443(SERVERS, WORKSTATIONS) -FW_PORTS_DNSNTP=53, 123(DMZ, IOT, GUEST) Plus host aliases:JELLYFIN(10.20.20.30) for the IOT→Jellyfin rule,HOMEASSISTANT(10.20.20.45) for the HA control/UI rules,TVHEADEND(10.20.20.31) for the WORKSTATIONS Live-TV rule (runbook 21),INVIDIOUS(10.20.30.70) +KODI_BOX(10.20.40.101) for the WORKSTATIONS→DMZ Invidious-direct rule (WS 2c), andOBS(10.20.20.50) for the workstation→obs Alloy-push rule (WS 2b). The obs push uses two ports (8428metrics,3100logs) → either aOBS_PUSHport alias (8428, 3100) in one rule, or two rules (the live config uses two).Read each zone's intent below, then build it in Firewall → Rules →
(or confirm it there if you loaded the pre-built config).
MGMT (10.20.10.0/24)¶
- Pass: TCP/UDP, src
MGMT net, dst fw, ports443,22,53,123(GUI/SSH/DNS/NTP). - Pass: src
MGMT net, dstRFC1918, gateway default (administer all zones). - Pass: src
MGMT net, dstVPN_BYPASS, gateway =WAN_GW. - Pass: src
MGMT net, dstany, gateway =PROTONVPN_LB(internet via Proton).
MGMT is entered only from the WG admin peer (runbook 07) — there are no rules on other zones permitting traffic into MGMT.
SERVERS (10.20.20.0/24)¶
- Pass: src
SERVERS net, dst fw, ports53,123(DNS/NTP only — not443). SERVERS is a data/identity tier, not an admin zone, so it must not reach the OPNsense web GUI. OPNsense's implicit "allow this-firewall" self-rule would otherwise letSERVERS → (self):443through, so a firewall-automation block (sequence 90) deniesSERVERS → (self):443on every firewall IP ahead of that self-rule — codified inprovision/opnsense/fw-automation-rules.jsonand asserted by the fw-conformance harness (see Firewall automation rules below). 1a. Pass: srcHOMEASSISTANT(10.20.20.45), dstIOT net, gateway default — Home Assistant controls the IP IoT devices (the Zigbee/Z-Wave devices ride the USB coordinator, not the network). Scope to the HA host, not all of SERVERS. (Place BEFORE the RFC1918 block below.) See runbook 21; mDNS discovery of IP IoT is handled by extending the reflector (runbook 08) to SERVERS⇄IOT. - Block: src
SERVERS net, dstRFC1918— servers answer connections; they don't initiate into other zones. (Intra-SERVERS traffic is switched, not filtered here, so this only stops server→other-zone. The HA→IOT allow in 1a sits above this block.) - Pass: src
SERVERS net, dstVPN_BYPASS, gateway =WAN_GW. - Pass: src
SERVERS net, dstany, gateway =PROTONVPN_LB— server egress is via Proton too. (Inbound to the reverse proxy is unaffected: those rules are on WAN and usereply-to. ACME DNS-01/API calls ride Proton fine; HTTP-01 inbound rides WAN.) - Block: src
SERVERS net, dstany(kill-switch).
DMZ (10.20.30.0/24) — edge-facing/internal (origin Caddy + hidden Bind primary)¶
- Pass: src
DMZ net, dst fw, ports53,123only. - Pass: src
DMZ net, dstSERVERS_NET, only the specific backend port(s) the origin Caddy needs (e.g. Authelia forward-auth, app backends, Nextcloud→DB5432/3306, LDAP to IPA). Keep this tight — list exact hosts/ports. Add (runbook 21): origin Caddy →JELLYFIN:8096and origin Caddy →HOMEASSISTANT:8123(it reverse-proxiesjellyfin.${domain}andha.${domain}, both now in SERVERS). (Invidious is intra-DMZ — no rule.) 2a. Pass: srcJUMP(10.20.30.5— the SSH jump host only), dst10.20.0.0/16, port22, gateway default. This is the single DMZ→fleet:22 exception that powers the no-VPN admin relay (edge:443→ jump → any fleet host, runbook 20); it reaches every zone incl. MGMT (sosshto the GPU host works this way). Must sit ABOVE the RFC1918 block below (first-match), and is scoped to the jump's single IP — every other DMZ host stays blocked from the fleet on:22. - Block: src
DMZ net, dstRFC1918(DMZ must not reach MGMT/WS/IOT — except theJUMP→:22exception in 2a above, which is matched first). - Pass: src
DMZ net, dstVPN_BYPASS, gateway =WAN_GW. - Pass: src
DMZ net, dstany, gateway =PROTONVPN_LB— outbound updates (ACME, package pulls) via Proton. - Block: src
DMZ net, dstany(kill-switch).
No WAN port-forwards into DMZ. Under edge/origin (runbook 17) the public reaches the origin Caddy + answers off the hidden Bind primary only via the edge WireGuard tunnels — which terminate on the WG-edge interface, not WAN. The pass rules permitting
edge tunnel → origin Caddy :443andedge tunnel → hidden primary :53 (AXFR/IXFR, TSIG)live on that WG-edge interface (runbook 17), source-restricted to the per-VPS tunnel/32s.
WORKSTATIONS (10.20.40.0/24)¶
- Pass: TCP/UDP, src
WS net, dst fw, portsFW_PORTS_STD(53/123/443). - Pass: src
WS net, dstSERVERS_NET, portsIPA_PORTS(identity + NFSv4 2049) — gateway default (local, untunneled — NFS stays LAN-speed). 2a. Pass: srcWS net, dstHOMEASSISTANT:8123,JELLYFIN:8096, andTVHEADEND:9981,9982, gateway default — HA dashboard + Jellyfin + tvheadend Live-TV/HTSP (runbook 21). The LibreELEC/Kodi box lives in this zone: its Jellyfin add-on hitsJELLYFIN:8096,pvr.htshitsTVHEADEND:9982(HTSP), and it reads NFS media viaIPA_PORTS2049 (rule 2). (HA/Jellyfin are also reachable viaha/jellyfin.${domain}through the origin Caddy; this is the fast local path.) 2b. Pass: TCP, srcWS net, dstOBS(10.20.20.50):8428,3100, gateway default — Grafana Alloy push (node metrics → VictoriaMetrics :8428, journald → Loki :3100) so workstation agents report to the obs sink like the EL fleet (esp. the GPU host, the AI host; a workstation). Without this the WS zone can't reach SERVERS beyondIPA_PORTS. (MGMT workstations like the admin laptop already reach obs via the MGMT→all admin path.) 2c. Pass: TCP, srcKODI_BOX(10.20.40.101, host-scoped), dstINVIDIOUS(10.20.30.70):3000, gateway default. Kodi's Invidious add-on hits the backend directly athttp://10.20.30.70:3000(by IP — theinvidious.${domain}name now resolves via split-horizon to apps-proxy:443, so the add-on targets the backend IP, not the FQDN; Invidious itself takes no edge/Authelia on this path). The lone WORKSTATIONS→DMZ exception — deliberately host-scoped to the Kodi box via theKODI_BOXalias (so a re-IP is one edit), not zone-wide, since DMZ is the restricted edge-facing zone. Must come BEFORE theRFC1918block (4). - Pass: src
WS net, dstIOT net(cast/print; tighten to specific ports in runbook 08) — gateway default. - Block: src
WS net, dstRFC1918— isolates every other internal zone (MGMT, DMZ, GUEST). Must come AFTER the SERVERS/IOT allows (2–3) and the host-scoped 2c, BEFORE the internet rule. - Block: TCP/UDP 853 (DoT) any (anti-DNS-bypass).
- Pass: src
WS net, dstVPN_BYPASS, gateway =WAN_GW(safety-valve hook; empty by default → matches nothing → all egress stays on Proton). - Pass: src
WS net, dstany, gateway =PROTONVPN_LB(internet via the Proton load-balance group). - Block: src
WS net, dstany(kill-switch / fail-closed).
Why block
RFC1918, not pass it. Allowing the specific local destinations (SERVERS in 2, IOT in 3) on the default gateway keeps that traffic local/untunneled; theRFC1918block (4) then isolates all other internal zones. (PassingRFC1918as a "local bypass" before the mgmt/dmz blocks would silently open WS→MGMT/DMZ, since those sit inside10.x— so block after the allows; that order is fail-safe.) Only real internet (non-RFC1918) reaches the Proton rule (7); the final block (8) fails closed if the whole group drops.
IOT (10.20.60.0/24)¶
- Pass: src
IOT net, dst fw, ports53,123only (DNS via intercept + NTP). - Pass: src
IOT net, dstSERVERS_NEThost Jellyfin, Jellyfin port8096(Roku→Jellyfin) — pin host alias in runbook 11/09. - Block: src
IOT net, dstRFC1918(no other internal access). - Pass: src
IOT net, dstVPN_BYPASS, gateway =WAN_GW— safety-valve hook (empty by default). If the Roku's YouTube ever gets blocked on the Proton exit, add Google/YouTube ranges toVPN_BYPASSand it'll exit the real WAN. - Pass: src
IOT net, dstany, gateway =PROTONVPN_LB— all other IOT internet via Proton; tighten to specific devices/aliases for chatty gadgets. - Block: src
IOT net, dstany(kill-switch).
mDNS reflection (runbook 08) handles discovery; the unicast allow rules live on WS (toward IOT) and here (IOT→Jellyfin).
GUEST (10.20.90.0/24)¶
- Pass: src
GUEST net, dst fw, ports53,123only. - Block: src
GUEST net, dstRFC1918(total internal isolation). - Block DoT 853.
- Pass: src
GUEST net, dstVPN_BYPASS, gateway =WAN_GW. - Pass: src
GUEST net, dstany, gateway =PROTONVPN_LB. (Guests share your Proton egress/quota — if you'd rather keep guests off your VPN, point this atWAN_GWinstead.) - Block: src
GUEST net, dstany(kill-switch).
(Also enable client isolation on the guest SSID at the AP.)
WAN (inbound) — deny all except the WG admin endpoint¶
Home is inbound-silent on the Comcast link: the only thing that may arrive from the internet is the road-warrior WireGuard dial-in. There are no service port-forwards (the public-facing layer is on the VPS edges, runbook 17, reached over home-dialed outbound tunnels).
- Pass: proto UDP, src
any, dst.69(WAN WG-admin VIP), dst port51820— the WireGuard admin endpoint (runbook 07).reply-to WAN_DHCPis automatic. - (implicit) default-deny everything else inbound on WAN. OPNsense blocks un-matched inbound by default; the bogon/private block (Interfaces → WAN, and Firewall → Settings → Advanced) stays on.
Do not add
80/443/53/22forwards here. If you later need to verify the posture: an external scan of the Comcast IP block should show only UDP 51820 on.69open — nothing else (runbook 14).The edge tunnels do not appear here: home dials out to each VPS, so those handshakes leave as outbound UDP and their return traffic is stateful — no WAN inbound rule is required for them.
Break-glass: direct WAN egress (DISABLED by default)¶
A disabled override for when all of Proton is down — flip it on to restore internet via Comcast, flip it off when Proton's back. Manual on purpose: auto-failing-over to WAN would silently leak your traffic to the ISP (the thing you're avoiding).
Firewall → Rules → Floating → +:
- Disabled: yes (this is the key — it sits inert until you need it)
- Action Pass, quick on, Interface(s) = the internal zones (or all),
Direction in, Source = RFC1918 (or ZONES_ALL), Destination any,
Gateway = WAN_GW/WAN_DHCP
- Description: BREAK-GLASS: direct WAN egress — enable only when Proton is down
Because it's a floating quick rule it evaluates before the per-zone rules, so
enabling it overrides the Proton routing + kill-switch for everyone at once.
(Scope the source to just MGMT net if you only want recovery access, not
whole-network internet, during an outage.)
Floating / housekeeping¶
- Block private networks / Block bogon networks: these are per-interface toggles on Interfaces → WAN (not on Firewall → Settings → Advanced) — keep both enabled on WAN.
- Anti-lockout: management is via MGMT/WG only (the WG admin peer enters MGMT, runbook 07); the Proxmox console of the OPNsense VM is the break-glass fallback.
Firewall automation rules (as code)¶
A handful of cross-zone service grants (and one deny) don't fit the per-zone GUI ruleset above cleanly — they're host-scoped passes added over the API into OPNsense's MVC Automation → Filter list. Those live as OPNsense runtime state and can be lost on a rebuild, so they are codified in the repo and reconciled by a script:
provision/opnsense/fw-automation-rules.json— the declarative source of truth. Each rule is matched by its exactdescription. Interface ids:opt1=MGMTopt2=SERVERSopt3=DMZopt4=WORKSTATIONSopt6=IOTopt7=GUESTopt12=WGRW. It carries, e.g., the sequence-90SERVERS → (self):443and sequence-91WORKSTATIONS → (self):443blocks (referenced by the SERVERS rule 1 above), thewg-admin → UniFi UIandregistry → hidden-primary :53grants, and the per-proxy_vhostsapps-proxy → DMZ-backend passes thatscripts/apps-frontdoor-sync.pyemits.scripts/opnsense-fw.py apply [--dry-run]— reconcile the live firewall to the JSON: it only adds missing rules (idempotent, matched bydescription) and never deletes/edits live rules, so an emergency hand-added rule is never clobbered; reconcile removals by hand.scripts/opnsense-fw.py listshows the live automation rules read-only.tests/fw-conformance/conformance.py— the assertion harness: it probes the expected pass/block outcomes (includingSERVERS → 10.20.10.1:443 = block) so drift from the design is caught.
Automation-filter passes evaluate ahead of the classic per-interface rules, so a scoped pass here (e.g.
WG_LAPTOPS → SERVERS_NET:22) correctly precedes a classicblock → RFC1918on the same interface — that's why these grants work.
After an OPNsense rebuild: re-add the aliases + GUI ruleset per runbooks 02–08, then
scripts/opnsense-fw.py apply to restore these automation rules, then run the
conformance harness. Editing policy = edit the JSON, then apply. See
provision/opnsense/README.md.
Validation¶
- [ ] From a WS test client: internet works and shows Proton egress IP; can reach SERVERS on IPA/NFS ports; cannot reach MGMT or DMZ-internal; cannot reach other client zones.
- [ ] From GUEST test client: internet only; no RFC1918 reachable.
- [ ] From IOT test client: only DNS/NTP to fw + Jellyfin; RFC1918 otherwise blocked; DNS intercept still forces Unbound.
- [ ] Kill-switch verified again (disable Proton → WS internet stops).
- [ ] Firewall → Log Files → Live View shows expected passes/blocks, no surprise denies for legit flows.
Then → runbook 07 (road-warrior WireGuard + WG-only MGMT).