Inside the router: zones, nftables, Kea and unbound
published 2026-11-01 words 574 min 3
This is the follow-up to An agentic home router?: what actually runs on the box. It’s all stock Linux and well-known open source; the only unusual part is that one NixOS config describes it all.
connections 12/386 · order: bytes · every 2s down 31.4 Mbit/s up 2.1 Mbit/s new 7/s PR ZONE SOURCE DESTINATION STATE RATE BYTES tcp lan tv video-cdn.example:443 ESTABLISHED 3.1M/s 2.4G udp vlan console games-cdn.example:443 ASSURED 720K/s 780M tcp lan server backup.example:443 ESTABLISHED 410K/s 164M udp wg phone music.example:443 ASSURED 40K/s 41M tcp vlan laptop meet.work.example:443 ESTABLISHED 180K/s 22M tcp lan mac updates.os.example:443 ESTABLISHED 950K/s 8.7M udp lan speaker radio.example:443 ASSURED 16K/s 6.9M tcp lan mac code.example:443 TIME_WAIT - 212K tcp lan phone push.example:5223 ESTABLISHED - 96K udp lan wifi-ctrl firmware.example:443 ASSURED 2K/s 61K tcp vlan laptop login.work.example:443 SYN_SENT - 120 tcp lan tv telemetry.example:443 CLOSE - 1.1K sort: [b]ytes [r]ate [a]ge
Zones and VLANs
Every interface is a zone: the ISP side, the main LAN, a VLAN for devices I’d rather keep
apart (a work laptop, a game console), and the WireGuard VPN. A zone reaches nothing unless
its allowTo says so.
One config
Each network is described once, and the firewall, DHCP and DNS are generated from it (addresses made up):
router.interfaces.lan0 = {
address = "10.0.0.1/24";
dhcpRange = "10.0.0.100-10.0.0.199";
reservations.laptop = { mac = "..."; address = "10.0.0.20"; };
};
router.interfaces.iot0 = { # a VLAN for TVs, consoles and the like
vlan = { parent = "lan0"; id = 20; };
address = "10.0.20.1/24";
};
router.interfaces.wg0 = { # the WireGuard VPN, for devices away from home
address = "10.0.100.1/24";
wireguard = {
listenPort = 51820;
peers.phone = { publicKey = "..."; address = "10.0.100.10"; };
};
};
router.firewall.zones.lan0.allowTo = [ "wan0" ];
router.firewall.zones.iot0.allowTo = [ "wan0" ];
router.firewall.zones.wg0.allowTo = [ "lan0" "wan0" ];
nftables
The firewall is nftables with the defaults of a fresh OPNsense install: drop everything in and through unless allowed, ssh only from my workstation. Two extras: the router’s own outbound traffic is allowlisted too, so a compromised process can’t just phone home, and if the ruleset ever fails to load, a lockdown ruleset takes over instead of leaving the box open.
dropped
- forward
- iot0 → lan0: iot0 allowTo = [ wan0 ], no match
- policy
- drop: the TV can't see anything at home
WireGuard
Away from home, my phone, tablet and laptop come back in over WireGuard. Each peer is named after the device’s name at home, so in the logs my phone looks like my phone whether it’s on the Wi-Fi or on a hotel network.
Kea and unbound
Kea hands out addresses, and each new lease becomes a
name.home.arpa entry in unbound. unbound
validates DNSSEC
and forwards to Quad9 over TLS, with
HaGeZi’s blocklist in front, pinned to a commit
and checked at build time, so a broken list fails the build instead of the internet.
Time
chrony keeps time with NTS and serves it to the LAN. Gadgets that ignore that and ask a time server baked into their firmware get answered by the router anyway: nftables catches every NTP request headed for the internet.
Watching connections
conntrack is the kernel’s table of every connection through the box. When a flow ends, it reports the bytes each way; matched with unbound’s answers, that’s what the database on the home server is built from.
That’s the payoff of a plain Linux box: every layer is open, from the connection table to the leases, DNS answers and logs, so I can build whatever tool I want. I missed pftop from BSD firewalls, and building one was easy, since the tools and the data were all already there; the view at the top is a made-up copy of it.
All of it in git
The OS, the packages and every setting are built from a Nix flake in git. Nothing is edited on the box: its root filesystem lives in RAM, so only the keys, the leases and the logs survive a reboot. Every change is tested in VMs, then deployed from a commit, and the boot menu names each version after that commit’s message, so going back is just picking an older one.