Last time I read my UniFi firewall back two months after migrating it to zones and found rules pointing at the wrong machine. This post is about the part of that firewall I care most about: keeping the IoT VLAN away from everything else.
I keep my smart TVs and Alexas on IoT and don’t want them snooping on my main network.
The design is short. IoT/Guest can reach a handful of specific things on my main LAN, in a specific order, and a block rule underneath catches everything else. The Default LAN can reach into IoT/Guest whenever it wants, because casting and Home Assistant need that direction. Written down, it’s nine lines. What I didn’t have until this week was proof that those nine lines did what they said, and when I built a way to test them, two of them didn’t.
One zone, so the isolation is a block rule
UniFi’s zone-based firewall ships with a Hotspot zone whose defaults already isolate a network: Hotspot can’t start connections into Internal, and Internal can start connections into Hotspot. If my IoT network lived there, the zone matrix would do most of this job for me.
It doesn’t live there. I created the network years ago as a plain VLAN, the migration put it in Internal next to the Default LAN, and I kept it that way to avoid breaking the automations that already worked. The first post has the reasoning. The consequence is that inside Internal, every network can reach every other network until a policy says otherwise, and the policies in a zone pair are evaluated in index order, first match wins.
Isolation therefore becomes a block rule. Everything IoT/Guest is allowed to reach has to be an allow rule with a lower index than the block, and anything I forget to list is blocked. If you’re going to get a rule wrong on an IoT network, that’s the direction to get it wrong in. It also means the order of the list is the design, and a rule in the wrong position is a rule that doesn’t exist.
Nine lines in order
User policies in the Internal to Internal pair, after this week’s fixes, in the order the gateway evaluates them:
| Index | Action | Name | From | To |
|---|---|---|---|---|
| 10000 | Allow | plex | IoT/Guest | 192.168.66.5, port 32400 |
| 10001 | Allow | guest to plex | IoT/Guest | 192.168.66.5, all protocols |
| 10002 | Allow | plex to guest | 192.168.66.5 | IoT/Guest |
| 10003 | Allow | IoT to Pi-hole DNS | IoT/Guest | 192.168.66.2, TCP/UDP 53 |
| 10004 | Allow | iot to printer | IoT/Guest | 192.168.66.100, TCP/UDP 161, 515, 631, 5353, 9100 |
| 10005 | Allow | IoT to Pi-hole ICMP | IoT/Guest | 192.168.66.2, echo request |
| 10006 | Block | block iot to lan | IoT/Guest | Default, all protocols |
| 10007 | Allow | allow Default and IoT/Guest to Internal | Default, IoT/Guest | Internal |
| 10008 | Allow | allow Internal to Default and IoT/Guest | Internal | Default, IoT/Guest |
An IoT device gets through to Plex, the Pi-hole, and the printer. Everything else headed for 192.168.66.0/24 hits “block iot to lan” at 10006.
The two allows under the block are the ones the migration left unnamed. For IoT/Guest to Default they’re unreachable, because the block above them matches first. Their one real effect is the other direction, Default into IoT/Guest, and that’s the direction I want open.
I keep Default to IoT so I can manage things or send certain things to Home Assistant.
Printing across the boundary
192.168.66.100 is a Brother printer on the Default LAN. Phones and a laptop on the IoT/Guest Wi-Fi need to print to it, and if you’ve only opened 9100 you’ll find out that printing is more than one protocol:
| Port | What it is |
|---|---|
| 9100 | Raw printing, what most drivers send to |
| 631 | IPP, what AirPrint and modern drivers use |
| 515 | LPD, the older queue protocol |
| 5353 | mDNS, how devices find the printer by name |
| 161 | SNMP, how drivers read supply levels and status |
The allow covers all five, TCP and UDP, to that one address. From the IoT side a device can print, find the printer, and read its toner level, and can’t reach anything else on the same box. In my test from the VLAN, port 631 on the printer answered and port 80 (its web admin page) timed out.
Discovery across VLANs needs more than the firewall rule. mDNS is link-local multicast and doesn’t cross a router on its own, so UniFi has to repeat it between networks. Both Default and IoT/Guest have mDNS enabled in their network settings. Without that, the firewall could allow 5353 all day and the printer would still be missing from your phone’s print dialog, because the query doesn’t leave the VLAN it started on.
An allow rule broken twice
“IoT to Pi-hole ICMP” exists so an IoT device can ping the Pi-hole. On a device that can’t run dig, that’s the cheapest check of the DNS path you’ll get. In the list I read back two months after writing it, that rule sat at index 10008, below “block iot to lan” at 10005. The block matches all protocols, so an ICMP echo from IoT/Guest hit the block first and the allow was never consulted.
I moved it above the block and tested again. Still no reply.
The second problem was in the rule itself. Its Connection State was set to Return Traffic. That option tells the gateway to match only packets that belong to a connection already in its state table, the replies to something that was allowed earlier. A fresh ping is a new connection. It has no entry in the table yet, so a rule scoped to return traffic can’t match it, whatever its index is. The rule could only ever have matched echo replies to pings that something else had already allowed out, and nothing had.

The fix is Connection State: All, plus “Auto Allow Return Traffic” so the echo reply comes back without its own rule. That’s how the DNS allow next to it was already configured. After both changes, a ping from the VLAN to 192.168.66.2 answered in 0.3 ms.
This was hard to spot from the controller. Neither mistake produced an error, and the rule had 4.8 million hits in the policy table. I read a hit count as “this rule is doing its job.” It isn’t. It’s “a packet matched this rule,” and in this case the packets were replies to pings that other rules were permitting, while the outbound requests the rule was written for were being dropped a few lines below.
The order mistake was mine from the day I wrote the rule. The connection state one I can’t explain. Return Traffic isn’t the default for a new policy, so at some point I chose it, and I don’t remember why. The DNS rule next to it was built the same week and got the right settings.
Testing from inside the VLAN
Everything above is only as good as the test, and the controller can’t run the test for you. The UI shows what the rules say. It can’t tell you what an IoT device can reach. For that you have to be an IoT device.
I didn’t want to carry a laptop onto the IoT Wi-Fi every time I changed a rule, and the Proxmox host already has a trunk port with VLAN 10 tagged on it. The test runs on pve as root: create a Linux network namespace, give it a VLAN 10 subinterface off the bond, get a DHCP lease the way a real device would, and run the checks from inside the namespace. The host’s own networking stays untouched.
| |
Walking through it: ip link add ... type vlan id 10 makes a tagged subinterface on bond0, and moving it into the namespace with ip link set $IF netns $NS means only commands run through ip netns exec can see it. dhclient inside the namespace asks the UDM for a lease on VLAN 10, so the DHCP path gets proven as a side effect. Don’t skip the /etc/netns/$NS/resolv.conf line. Without it, dhclient inside the namespace overwrites the host’s /etc/resolv.conf with the VLAN’s DNS servers, and you get to discover that on a Proxmox host.
The lease came back as 192.168.1.54 with 192.168.66.2 as the only DNS server, matching the IoT network’s DHCP settings. Then the checks:
| |
nc -z opens a TCP connection and closes it without sending anything, so “OPEN” means the firewall let the SYN through and something answered. “closed/filtered” lumps together a block at the gateway and a port with no listener. That’s fine here, since every target I probe has one.
The teardown runs under a trap so it happens even when a check fails:
The first version of this didn’t kill dhclient, and ip netns del left it running with the VLAN interface still attached. The next run failed with “VLAN device already exists” and every probe reported no-reply. For a minute that looked like the firewall blocking everything. It was a stale interface from my own previous test. Kill the client by its pid file first, delete the link, then the namespace.
The results from the final run, after all the fixes in this post:
| |
Every line is something I can explain from the rule set, and if you can’t say that about your own output, one of your rules is lying to you. That wasn’t true of my first run.
What the first run found
Some of the first run didn’t match what I thought the rules said.
The gateway’s admin page answered. 192.168.66.1:443 and 192.168.1.1:443 were both open from the VLAN. “block iot to lan” blocks IoT/Guest to the Default network, and the UDM’s address on the Default network is 192.168.66.1, so I’d assumed that covered it. It doesn’t. The gateway’s own services live in the Gateway zone, and the matrix says Internal to Gateway is Allow All. A block in the Internal to Internal pair doesn’t see traffic headed for the Gateway zone, whatever address it’s going to. An IoT device could load the UniFi login page, and so could anything that compromised one.
The fix is a policy in the Internal to Gateway pair: source IoT/Guest, destination any, TCP 80 and 443, Block. DHCP and DNS from the gateway are untouched because the block names only those two ports. After adding it, both addresses went to closed/filtered and the DHCP lease still arrived. SSH on 22 was already filtered before the change, so I left it out.
Port 853 was open to the internet. That’s DNS over TLS. My IoT network’s DNS is forced through the Pi-hole on port 53, and a device that speaks DoT to 1.1.1.1 or 8.8.8.8 walks straight past that. The block went into the Internal to External pair: IoT/Guest to any, TCP/UDP 853. The next post is about the DNS side and covers what that block does and doesn’t catch.
Both holes have the same shape. I’d written rules for the Internal to Internal pair and assumed they described everything IoT/Guest could do. They described everything IoT/Guest could do to other Internal networks. The gateway and the internet are other zones with their own pairs, and each pair starts from its own default. If your isolation lives in one pair, check the others before you call it done.
Within minutes of creating the two blocks, the gateway block showed 16 hits and the DoT block showed 12. A few of those were my tests. The rest were devices on the VLAN already trying both.
Seeing devices on that VLAN already poking at the gateway and pinging the Pi-hole reinforces the decision to wall them off in the first place.
Blocking all states, and what that means for replies
One thing I checked on purpose: “block iot to lan” matches every connection state, new and established alike. Other write-ups of the zone firewall warn that a block configured this way also drops the return half of connections the Default LAN starts into the VLAN. That would break Home Assistant’s sessions to IoT devices and anything else that connects inward.
I tested that direction too. With a listener running inside the namespace, pve on the Default LAN could ping 192.168.1.54 and open a TCP connection to it, and the replies came back. On this firmware, with this rule set, the “allow Internal to Default and IoT/Guest” policy at 10008 and the predefined return-traffic handling cover it. I’m recording the result rather than the reason, because I can’t see which of those two is doing the work, and scoping the block to New and Invalid would be the safer configuration either way. It’s on my list.
Writing it down
The rule table, the test script, and every probe result from this week went into the gateway’s entry in my homelab inventory. The next time I change a rule, the test is one SSH command, and the before state is in git. That second part is what I didn’t have when I started.
References
- UniFi Zone-Based Firewall, LazyAdmin, including the gateway-access block
- UniFi zone-based firewall changes the IoT VLAN rule, vNinja, on connection states in the block rule
- ip-netns(8), Linux manual pages
