Ramblings of an aging IT geek
← Ramblings of an aging IT geek
linux

i finally moved my firewall to nftables

After years of iptables muscle memory, I rewrote my host firewall as a single nftables ruleset and it's genuinely nicer.

A Linux terminal with a firewall ruleset

I've been writing iptables rules for long enough that I do it without thinking, which is exactly why I kept putting off nftables. The old thing worked, my muscle memory worked, and the new thing was a syntax I'd have to actually learn. But the distros have made their direction clear, nftables is the default backend now and iptables is increasingly a compatibility shim on top of it, so I spent a weekend porting my host firewall properly. I should have done it ages ago.

the thing iptables never had

The big win is that the ruleset is one file, in one syntax, that you load atomically. With iptables I had a script that ran a few dozen commands in order, and if it failed halfway through I had a half-applied firewall, which is the worst possible firewall. With nftables the whole config lives in /etc/nftables.conf, and nft -f either applies all of it or none of it. No partial states. That alone justified the move.

The other win is that the syntax reads like something a human designed. Tables hold chains, chains hold rules, and you can put IPv4 and IPv6 in the same inet table instead of maintaining two parallel universes with iptables and ip6tables.

table inet filter {
  chain input {
    type filter hook input priority 0; policy drop;

    ct state established,related accept
    iif "lo" accept
    ct state invalid drop

    ip protocol icmp accept
    ip6 nexthdr icmpv6 accept

    tcp dport { 22, 80, 443 } accept

    counter comment "dropped"
  }
}

A server rack with network cabling

That single chain does what a noticeably longer pile of iptables invocations used to do. The tcp dport { 22, 80, 443 } set is the small pleasure that won me over: native sets, written inline, instead of one rule per port or a separate ipset to manage on the side. Default-drop policy on the chain, accept established connections first so you don't lock yourself out, allow loopback, drop invalid, permit the handful of ports you actually serve. It's the same logic I always wrote, just legible.

the bit to be careful about

The one place to keep your wits is priority and hook ordering, because if you have leftover iptables rules loaded through the compatibility layer at the same time as native nftables rules, you can end up with two firewalls disagreeing and a confusing afternoon. I flushed the old iptables ruleset, disabled the legacy service, and went all-in on the native config rather than running both. Mixing them is supported but it's a trap for exactly the kind of half-remembered rule you forgot you'd added in 2017.

I kept an out-of-band console open while I tested, the oldest rule in the book, because a firewall change that locks you out of SSH is a long drive to the data centre or a grovelling ticket to the host. It went smoothly. The config is shorter, it's one file, it applies atomically, and I no longer maintain v4 and v6 as separate scripts. After all the years of dragging my feet, the only real lesson is that I should have done it the week it landed.