feed

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.

router — conntrack top
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
The router's connection table, pftop-style: who is talking to what right now, by zone and device. Names and traffic are made up; click the keys to sort.

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.

from
to

dropped

forward
iot0 → lan0: iot0 allowTo = [ wan0 ], no match
policy
drop: the TV can't see anything at home
Can this device reach that? Pick both ends to see what the firewall does, following the example config above.

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.