Skip to main content

Firewall as code: a zone-based firewall in OpenTofu

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:

  1. infra → external was 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.
  2. 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.