Creating a VLAN is the easy part. You add a tag, a subnet, a DHCP scope, and an SSID. The segmentation only exists once the firewall rules do, and that is where I keep seeing (and making) mistakes. A VLAN with an allow-any rule is just a second flat network with extra steps.
This post covers how I'm structuring rules for an IoT VLAN on OPNsense. The logic should carry over to pfSense, since both are stateful and evaluate rules the same basic way. I'm describing the pattern, not exact menu paths, because those move between versions. I haven't finished migrating every device, so treat this as a working method.
The model: default deny, then explicit allows
On both platforms, a new interface with no rules blocks inbound traffic from that segment. That is the behavior I want. I start there and add one allow at a time, each tied to a reason I can write down.
Two things to keep in mind:
- Rules apply to traffic entering the interface. An IoT rule describes what IoT devices may initiate, not what may reach them.
- The firewall is stateful. If a laptop on the trusted VLAN opens a connection to a device on the IoT VLAN, the replies are allowed automatically. I don't need a rule for the return path.
The second point matters for the design. Direction of initiation is the whole question. Who is allowed to start a conversation with whom?
My example layout
These are placeholder IDs and subnets, not my real ones:
VLAN Name Subnet Purpose
10 trusted 10.10.10.0/24 laptops, phones
20 iot 10.10.20.0/24 cameras, plugs, TV
30 guest 10.10.30.0/24 visitors, internet only
40 lab 10.10.40.0/24 VMs, test services
99 mgmt 10.10.99.0/24 switch, hypervisors, firewall UI
The management network is the one I care most about protecting. Nothing on IoT or guest should ever reach it, and only one or two admin devices on trusted should.
Rule order on the IoT interface
First match wins within an interface, so order is part of the design. This is the sequence I'm using:
1. Allow UDP/TCP 53 iot net -> firewall/resolver address
2. Allow UDP 123 iot net -> firewall address (NTP)
3. Allow (specific) iot net -> Home Assistant host, only needed ports
4. Block iot net -> alias: RFC1918 private ranges
5. Allow any iot net -> any (internet)
The reasoning for each line:
- DNS to the resolver only. I point IoT DHCP at my Pi-hole or Unbound address. A device with a hardcoded public resolver can still bypass it by going out through rule 5, so if I want to enforce DNS, I need a block rule for port 53 to anywhere else placed before the final allow. I haven't decided whether I want that. Some devices behave badly when their hardcoded DNS is blocked.
- NTP. Cheap devices often want a time source. Giving them the firewall keeps them from phoning out for it.
- Specific allows to services I run. If a device needs to talk to Home Assistant, I allow that host and those ports. Not the whole trusted VLAN.
- Block RFC1918. An alias containing the private ranges means the IoT VLAN cannot reach any internal network that rule 3 did not explicitly permit. This is the line that makes it a real segment.
- Allow the internet last. Because the block sits above it, "any" effectively means "anything that is not internal."
Put rule 4 above rule 3 and you break the Home Assistant allow. Put rule 5 above rule 4 and you've built nothing. I have done the second one. The traffic still worked, which is what made it easy to miss.
The rules going the other way
Trusted devices need to reach IoT devices sometimes, for example to open a camera stream or cast to a TV. I allow trusted to IoT on specific ports instead of everything. Because of statefulness, the devices answer without any IoT-side rule.
The awkward part is discovery. mDNS and similar multicast protocols do not cross VLAN boundaries on their own. Casting and some smart-home pairing depend on them. Both platforms have a way to relay mDNS (a plugin or package, depending on the firewall), but I'm still testing how well it works with my devices, and I'd rather not oversell it. Where a device supports it, adding it by IP in the app avoids discovery altogether.
Testing from inside the VLAN
I don't trust a ruleset I only read. From a machine on the IoT VLAN (a spare Pi works), I run a short check:
#!/usr/bin/env bash
# Run from a host in the IoT VLAN. Expect: PASS on allows, BLOCKED on denies.
check() {
local label=$1 host=$2 port=$3
if timeout 3 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
echo "OPEN $label ($host:$port)"
else
echo "BLOCKED $label ($host:$port)"
fi
}
check "internet" 1.1.1.1 443
check "home assistant" 10.10.40.20 8123
check "nas" 10.10.10.5 445
check "hypervisor UI" 10.10.99.10 8006
check "firewall UI" 10.10.99.1 443
I expect OPEN for the first two and BLOCKED for the rest. Then I check the firewall's live log view to confirm the blocks hit the rule I think they hit. A block from the wrong rule is a bug waiting for the next change.
The script only covers TCP, so it says nothing about UDP rules. That is a gap I know about.
Not breaking the household
The partner acceptance factor is real. If the TV stops working, nobody cares that the design is correct. My approach:
- Move one device at a time, not the whole house.
- Keep the old SSID up until the new one has run cleanly for a while.
- Write down which devices need which ports as I find out, so the rules end up documented.
- Export the firewall config before each batch of changes, so rollback is a restore and not a memory test.
Still open
I'm unsure about three things. How aggressively to force DNS through the resolver. Whether the mDNS relay is worth its complexity or I should just add devices by IP. And whether to log every block, which gets noisy fast.
How do you handle discovery across VLANs? Did you enforce DNS for IoT, and what broke when you did? And do you keep the IoT allow list in the firewall only, or mirror it somewhere version-controlled?
0 comments
Open in the forumSign in to comment.