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 does NOT live here — it stayed on IOT by the 2026-08-02 decision: it runs untrusted third-party plugins, and WORKSTATIONS membership grants effective read of everysec=sysNFS home. See §IOT.) (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. RETIRED (2026-08-08). The formerKODI_BOX → INVIDIOUS:3000WS→DMZ exception assumed the Kodi box lived in WORKSTATIONS; the 2026-08-02 decision kept it on IOT (untrusted plugins). The same host-scoped rule now lives in §IOT (rule 2b, automation seq 541) — direct to the backend, never via apps-proxy. TheKODI_BOXalias points at the IOT address (10.20.60.100). The old GUI rule "Workstations Kodi to Invidious" (opt4) is inert with the repointed alias — delete it in the GUI when convenient. - 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), 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).⚠️ The DNS-intercept port-forward MUST carry destination port 53 (NAT rdr: src
IOT net, dst!IOT addressport 53, tcp/udp →IOT address:53). A portless rdr matches every IOT tcp/udp flow and rewrites it to Unbound:53 — this silently killed ALL IOT internet/NTP/media for ~2 months (2026-06-11 → 2026-08-08; the tell: pf states all showdst <gw>:53withnat_addr= the real destination, and "NTP replies" are 12-byte DNS FORMERRs arriving in microseconds). Now codified: the rdr lives inprovision/opnsense/fw-automation-rules.json(natsection;opnsense-fw.py applyrecreates it on rebuild) and its port-53 shape is asserted by the fw-conformancenatshapecheck. - Pass: src
IOT net, dstSERVERS_NEThost Jellyfin, Jellyfin port8096(Roku→Jellyfin) — pin host alias in runbook 11/09. 2a. Pass: TCP, srcKODI_BOX(10.20.60.100, host-scoped), dstTVHEADEND(10.20.20.31):9981-9982— the LibreELEC/Kodi box'spvr.htsLive-TV/HTSP path (operator-approved 2026-08-08). Lives in the automation layer (provision/opnsense/fw-automation-rules.json, seq 540), evaluated before the GUI zone rules. Host-scoped via theKODI_BOXalias (re-IP = one edit; the IP is pinned by a Kea reservation on the box's MAC). The box stays on IOT (untrusted plugins — see the 2026-08-02 decision). 2b. Pass: TCP, srcKODI_BOX, dst10.20.20.7(the invidious bound front door):443— automation seq 543, operator-approved 2026-08-09. The one Tier-2 name granted to the box, made expressible as an L3 rule by giving the name its own IP: apps-proxy holds a second address — a /32 alias on eth0 (proxy-front-addr.service, role-managed — a RemainAfterExit oneshot, so the role restarts it whenever its address list changes:startedon an already-active oneshot is a no-op, which left the.8alias unapplied and Caddy crash-looping onbind: cannot assign requested address2026-09-05; a same-subnet second NIC was tried and reverted live 2026-08-09: its kernel route flipped Caddy's outbound source to.7and broke HA'strusted_proxies) — with an exact-host Caddy site serving onlyinvidious.${domain}bound to it, while the shared wildcard plane is pinned to.6(proxy_bind) — so.7serves nothing else (asserted by the fw-conformancehttpchecks:invidious@.7= 200, any other name @.7= 421 from the explicit catch-all). Split-horizon resolvesinvidious.${domain}→.7for everyone (workstations/WG-laptops already reach any SERVERS IP:443 via rule 2'sIPA_PORTS; phones get companion automation rule seq 241). A shared-.6:443grant stays declined — that would expose the whole Tier-2 plane (HA/auth/git/cloud/…).RETIRED seq 541 (backend-direct
KODI_BOX → INVIDIOUS:3000, 2026-08-08 → 2026-08-09): listings worked but playback cannot — invidious runslocal: truewithdomain:baked into every stream/DASH URL, so playback always bounces tohttps://invidious.${domain}. The front door serves the real name; the add-on's instance URL is simplyhttps://invidious.${domain}. The box's media surface: Jellyfin + tvheadend + the invidious front door; still no NFS, no shared apps-proxy. - 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, the origin → radio bound-listener :443 relay (seq 645 — the Tier-1 radio path; DMZ→WORKSTATIONS stays closed, so the origin reaches adac only via the apps-proxy's radio-only.8address), the two DMZ → fileserver:8081shelf-map grants (seq 545 tenant-proxy, the tenant file path — runbook 55 §11; seq 546 origin Caddy, the Tier-1sync.${domain}face added 2026-09-13 — runbook 52 §The Tier-1 face), and the runner-pub → fileserver:22shelf deploy grant (seq 547, 2026-09-13 — runbook 52 §Push-to-deploy: the public CI rings a doorbell, and the account it reaches is bound by a certificateforce-command+ an sshdForceCommandto one program that verifies the release's signature itself, so the grant carries no shell). The:8081pair are single-host sources rather than the DMZ/24for the same reason, and it is the reason worth remembering: shelf-map trustsRemote-Userabsolutely (it reads that header to pick which tenant's socket to serve), so anything that can reach the port can name itself any tenant — only a proxy that authenticates first may be a source. The fleet-side half of the same grant is the fileserver's ownhost_firewall_source_ports; neither layer is decorative. And it carries the per-proxy_vhostsapps-proxy → backend passes thatscripts/apps-frontdoor-sync.pyemits (SERVERS→DMZ, and SERVERS→WORKSTATIONS since theradio.${domain}→ adac:8073 OpenWebRX front-door, 2026-09-02 — the script keys the zone pair off the backend's subnet prefix; fw-conformance asserts the allow stays apps-proxy-scoped).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).