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:

IndexActionNameFromTo
10000AllowplexIoT/Guest192.168.66.5, port 32400
10001Allowguest to plexIoT/Guest192.168.66.5, all protocols
10002Allowplex to guest192.168.66.5IoT/Guest
10003AllowIoT to Pi-hole DNSIoT/Guest192.168.66.2, TCP/UDP 53
10004Allowiot to printerIoT/Guest192.168.66.100, TCP/UDP 161, 515, 631, 5353, 9100
10005AllowIoT to Pi-hole ICMPIoT/Guest192.168.66.2, echo request
10006Blockblock iot to lanIoT/GuestDefault, all protocols
10007Allowallow Default and IoT/Guest to InternalDefault, IoT/GuestInternal
10008Allowallow Internal to Default and IoT/GuestInternalDefault, 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:

PortWhat it is
9100Raw printing, what most drivers send to
631IPP, what AirPrint and modern drivers use
515LPD, the older queue protocol
5353mDNS, how devices find the printer by name
161SNMP, 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.

UniFi policy editor for IoT to Pi-hole ICMP, showing protocol ICMP, type Echo Request, and Connection State set to All with Auto Allow Return Traffic

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
NS=v10test; IF=v10t

mkdir -p /etc/netns/$NS && : > /etc/netns/$NS/resolv.conf
ip netns add $NS
ip link add link bond0 name $IF type vlan id 10
ip link set $IF netns $NS
ip netns exec $NS ip link set lo up
ip netns exec $NS ip link set $IF up
ip netns exec $NS timeout 25 dhclient -1 -pf /run/v10t.pid -lf /run/v10t.leases $IF
ip netns exec $NS ip -4 -br addr show $IF

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
p() { printf "%-30s" "$1"; ip netns exec $NS ping -c2 -W2 $2 >/dev/null 2>&1 && echo REPLIES || echo no-reply; }
t() { printf "%-30s" "$1"; ip netns exec $NS timeout 6 nc -z -w4 $2 $3 >/dev/null 2>&1 && echo OPEN || echo closed/filtered; }

p "ping .2 Pi-hole"          192.168.66.2
p "ping .23 pve"             192.168.66.23
t "printer .100:631"         192.168.66.100 631
t "printer .100:80"          192.168.66.100 80
t "pve .23:8006"             192.168.66.23 8006
t "gateway 192.168.66.1:443" 192.168.66.1 443
t "gateway 192.168.1.1:443"  192.168.1.1 443
t "DoT 1.1.1.1:853"          1.1.1.1 853
t "internet 1.1.1.1:443"     1.1.1.1 443

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:

1
2
3
4
5
6
7
cleanup() {
  [ -f /run/v10t.pid ] && kill "$(cat /run/v10t.pid)" 2>/dev/null
  ip netns exec $NS ip link del $IF 2>/dev/null
  ip netns del $NS 2>/dev/null
  rm -rf /etc/netns/$NS /run/v10t.pid /run/v10t.leases
}
trap cleanup EXIT

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
DHCP lease                    192.168.1.54/24
ping .2 Pi-hole               REPLIES
ping .23 pve                  no-reply
printer .100:631              OPEN
printer .100:80               closed/filtered
pve .23:8006                  closed/filtered
gateway 192.168.66.1:443      closed/filtered
gateway 192.168.1.1:443       closed/filtered
DoT 1.1.1.1:853               closed/filtered
internet 1.1.1.1:443          OPEN

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