Back to the task that started all this

In the last post, a routine bit of network segmentation got derailed when I discovered my router had no firewall rules at all and had been sitting exposed on its public IP for the better part of two months. I fixed that first, because you don’t build a second floor on a house that’s on fire.

With the WAN properly locked down, I finally circled back to the thing I’d actually sat down to do: build a guest VLAN. The goal was the boring, sensible one. Visitors’ phones and laptops should get internet access and nothing else. No reaching my trusted LAN, no seeing my homelab servers, no browsing my IoT devices. Their own little walled garden with a door to the internet and no door to me.

This time I had firewall rules. This time I knew what I was doing. That confidence is, of course, where the story goes.

The design

The shape of a guest VLAN on a router-on-a-stick is straightforward. Give it its own tagged VLAN and subnet, hand out addresses with a dedicated DHCP scope, and write a firewall ruleset that permits guests out to the internet while blocking them from every internal network.

That last part is the crux. On router-on-a-stick, every VLAN can reach every other VLAN by default, because the router happily routes between all of them. Isolation isn’t the absence of a rule, it’s the presence of one. So the important ruleset, the one I’ll call GUEST_IN, filters traffic being routed from the guest VLAN through the router toward everywhere else.

The pattern, genericized and illustrative rather than my verbatim config, with example subnet numbers:

firewall {
    group {
        network-group RFC1918 {
            network 10.0.0.0/8
            network 172.16.0.0/12
            network 192.168.0.0/16
        }
    }
    name GUEST_IN {
        default-action drop
        rule 10 {
            description "Allow established/related"
            action accept
            state {
                established enable
                related enable
            }
        }
        rule 20 {
            description "Block guest -> any internal/private network"
            action drop
            destination {
                group {
                    network-group RFC1918
                }
            }
        }
        rule 30 {
            description "Allow everything else (the internet)"
            action accept
        }
    }
}
interfaces {
    ethernet eth1 {
        vif 30 {
            firewall {
                in {
                    name GUEST_IN
                }
            }
        }
    }
}

Read top to bottom: established return traffic is fine, anything headed for a private/internal range gets dropped, and everything left over (which is, by definition, public internet destinations) is allowed out. Clean. Guests can watch YouTube, guests cannot see my file server.

I tested it, and it passed

Here’s the part I want to be honest about, because it’s the part that fooled me. I did test it. I put a laptop on the guest VLAN and tried to reach a host on my trusted LAN. Nothing. Tried to ping my NAS. Nothing. Tried to pull up a homelab service by IP. Timed out, exactly as designed.

Every test I thought to run confirmed the isolation was working. So I called it done and moved on, quietly pleased that this time the boring task had actually stayed boring.

The trouble is that I tested the thing I built the rule for, and only that thing. I confirmed guests couldn’t reach the other subnets. I never once checked whether a guest could reach the router itself.

The blind spot, again

A few days later it clicked, and it clicked because it was the exact same conceptual gap that bit me in the last post, just pointed in a new direction.

GUEST_IN governs traffic that the router routes through itself to some other network. In firewall terms, that’s the forwarding path. But when a guest device sends a packet to its own gateway address, the router’s IP on the guest VLAN, that packet isn’t being forwarded anywhere. Its destination is the router. And traffic destined for the router itself doesn’t traverse the forwarding ruleset at all. It’s handled by a separate local ruleset, one I had never written for the guest interface.

So here’s what that meant in practice. My guest gateway address is, naturally, a private IP. You’d think rule 20, “drop anything headed to a private range,” would catch a guest poking at it. It doesn’t, because that packet never reaches GUEST_IN. It goes straight to the router’s local input path, which had no guest ruleset and therefore no restrictions. Which meant a guest, fully “isolated” from every other subnet on my network, could still open a browser, type in the gateway address, and get served the router’s admin login page. DNS on the router answered them too.

It’s the WAN_LOCAL versus WAN_IN lesson from last time, verbatim, in miniature: the router is a host, not just a road. I had secured the road and left the front door of the house standing open, exactly as I had on the WAN, except this time I’d done it while paying attention.

The fix

The fix is a GUEST_LOCAL ruleset applied to the same interface, defaulting to drop, that permits only the router services guests legitimately need (DHCP so they can get an address, DNS so names resolve) and drops everything else, including SSH and the admin GUI:

firewall {
    name GUEST_LOCAL {
        default-action drop
        rule 10 {
            description "Allow DHCP requests to the router"
            action accept
            protocol udp
            destination {
                port 67
            }
        }
        rule 20 {
            description "Allow DNS queries to the router"
            action accept
            protocol tcp_udp
            destination {
                port 53
            }
        }
    }
}
interfaces {
    ethernet eth1 {
        vif 30 {
            firewall {
                in {
                    name GUEST_IN
                }
                local {
                    name GUEST_LOCAL
                }
            }
        }
    }
}

Now a guest can get an address and resolve names, and that’s the entire list. Hitting the gateway on the admin port, or SSH, or anything else, gets dropped before it reaches the service. I re-tested, this time deliberately aiming at the gateway instead of past it, and the login page was gone. Everything here is fixed and has been since I caught it.

The takeaway

Two lessons, and they’re really the same lesson wearing different clothes.

First, “isolated from the other subnets” is not the same as “isolated from the router.” On any router-on-a-stick network, the router is a live host on every single VLAN it routes for, sitting at the gateway address, often running DNS and DHCP and a management interface. Segmenting the VLANs from each other does nothing to segment them from the box in the middle. That takes a local ruleset, and it’s easy to forget precisely because the router doesn’t feel like a “device on the network” the way your NAS does. It feels like infrastructure. It’s both.

Second, and this is the one I keep relearning: test the negative case, not just the happy path you built the rule for. I proved my isolation worked by confirming the thing I intended to block was blocked. What I should have done is gone hunting for what I didn’t think to block. The gap in a security rule is never in the traffic you were thinking about. It’s in the traffic you weren’t.

Two posts, two versions of the same mistake, one on the WAN and one on the LAN, both from treating the router as a road when it’s also a house. I’d like to say I won’t make it a third time. I’ve made it twice now, so I’m keeping the checklist.