Segmentation is only as good as the firewall between the segments — and firewall rules
clicked into a gateway UI are exactly the kind of thing that drifts, resists review, and
has no history. So I moved the zone-based firewall into OpenTofu: the policy is
code, it diffs in git, and tofu plan tells me about drift.
The model#
Every VLAN maps to a zone, and the gateway’s inter-zone default is DENY. That last part is load-bearing: it means the rules I write are the only holes, and everything unstated is blocked. Get that default wrong and the whole policy is either meaningless or backwards — so it’s the first thing to confirm, not an afterthought.
From there the policy reads as a short list of deliberate allows:
- every VLAN → the infra zone on DNS (53) and NTP (123),
- IoT → internet: block; guest → isolated from all internal; management → no internet,
- the DMZ walled off east-west with an egress allowlist,
- the home-automation hub-and-spoke (controller → things; things → controller on just the broker and callback ports),
- clients → the bastion; bastion → everything (see the last post).
The free win: IPv6 parity#
The provider I used exposes an ip_version = "BOTH" field on every policy — so each rule
is automatically its own v4 and v6 twin, in one object. The “every v4 rule needs a v6
twin” requirement from the IPv6 work? Satisfied by construction, not by writing everything
twice. That alone sold me on doing this as code.
Then code review found the bug#
I wrote a “starting set” and then audited it against every firewall requirement scattered through my design notes. Two gaps would have broken connectivity, and both are the kind you’d only find at 2 a.m. otherwise:
infra → externalwas missing entirely. My recursive resolvers live in the infra zone and need to reach the internet to do recursion. Without that allow, DNS would have quietly failed for everything — the resolvers couldn’t talk to the authoritative servers of the world. The most important rule was the one I’d forgotten.- NTP (123) was absent, and DNS (53) was allowed from only one of five zones. The “every VLAN → infra on 53 + 123” story was barely implemented.
Filling those was easy. The point is that having the policy as code made the audit possible — I could read the whole rule set in one file and check it against intent, which you simply can’t do by clicking through a UI zone by zone.
What stayed manual (honestly)#
Not everything fit. The IoT “answer hardcoded public NTP” trick is a NAT rewrite, and the provider has no NAT resource — so that one rule stays in the gateway UI, documented as the exception. And a guest-DNS conflict (guests were told to use the internal resolver but blocked from reaching it) I fixed in DHCP, not the firewall — hand guests the gateway’s resolver so they stay fully isolated. Code where code fits; a written-down exception where it doesn’t.
What I’d tell past-me#
- Confirm the inter-zone default is DENY before writing a single rule. Everything rests on it.
- The rule you forget is the one your own services need for outbound — audit egress, not just isolation.
- Firewall-as-code’s real value isn’t
apply; it’s that you can read and review the entire policy at once. That’s what caught the resolver bug.