nftables Router: Howto

nftables is the new hotness in Linux packet processing, which to me mostly means routing and firewalling in my home network. If you’re like me, this is enough to make you want to try this software out. If you have a bit of a life, then it’s not so easy to find the hours needed to figure out how it fits together to replace the iptables firewall you already have (which works just fine by the way), which you cobbled together by following a detailed guide and perhaps didn’t pay any attention to the rules except to make sure that there wasn’t any ostensibly dangerous stuff enabled.

Deep breath.

I finally sat down, did a bit of research and now I think I understand just enough to migrate my firewall from using iptables to using nftables. My main motivation for this was to be able to more easily interact with the firewall from programs. Anyhoo, the recipe follows, and this should hopefully be a start-to-finish guide — if not, please leave a comment.

First off, we assume that you know how to get these rules to be automatically applied/created upon machine boot. If not, there’s an example here, just useyour new nftables rules as your executed script.

Prepare a Script file

Your script file will need to do some things first before you jump into creating rules. Use the hash-bang directive to specify the shell of your choice. I use bash:

#!/bin/bash

We will use variables to avoid repetition as much as possible. Lines that begin with ‘#’ are comments and are ignored by the machine.

# the executable for nftables
nft="/usr/sbin/nft"

# wan and lan ports. Home routers typically have two ports minimum.
wan=enp3s0
lan=enp4s0

Now we “reset” nftables rules, and then create tables and chains. Tables are collections of chains, and chains allow us to bind rules to different phases (hooks) of a packet’s life as it traverses our router. First tables. We must create these tables otherwise our commands will fail with some vague error about files not found.

The syntax I have used for these files is Bash-based. the “${nft}” is a way to execute the nft command, whose path is stored in the nft variable. If you were running these commands on the CLI, you would need to replace “${nft}” with simply “nft”.

# flush/reset rules
${nft} flush ruleset

#create tables called "filter" for ipv4 and ipv6
${nft} add table ip filter
${nft} add table ip6 filter

# one more table called 'nat' for our NAT/masquerading
${nft} add table nat

We now have three tables, one for ipv4 and one for ipv6 and a final one, ipv4 only (default when no protocol family is specified is ipv4, or simply ip). Note that since my ISP does not supply ipv6, I have not tested these ipv6 rules.

We’re next going to create some chains that match the following “hooks”

  • input: this hook matches packets at the stage they are received by your machine. For example, packets from machines on your LAN sent to this machine.
  • output: this hook matches packets that originate from, and are leaving *this* machine.
  • forward: this hook will match packets that are being routed by this machine. Example, traffic from your LAN destined for the internet.
  • postrouting: matches packets after they’ve been processed, before they leave *this* machine.

Chains have a type, and the types we care about here are “filter” — allows you to filter traffic, and “nat”, which allows you to modify the source IP information in packets.

As a quick example, if you create a chain of type “filter” and apply it to the hook “input”, it allows you to filter traffic that is aimed at this machine itself. If the chain is applied to the “forward” hook, then you can filter traffic that is being routed by this machine. There are resources out there that explain these in more details.

Let’s create our chains as follows:

${nft} add chain filter input { type filter hook input priority 0 \; }
${nft} add chain filter output {type filter hook output priority 0 \; }
${nft} add chain filter forward {type filter hook forward priority 0 \; }
${nft} add chain filter postrouting {type filter hook postrouting priority 0 \; }
${nft} add chain nat postrouting {type nat hook postrouting priority 100 \; }

# and for ipv6
${nft} add chain ip6 filter input { type filter hook input priority 0 \; }
${nft} add chain ip6 filter output {type filter hook output priority 0 \; }
${nft} add chain ip6 filter forward {type filter hook forward priority 0 \; }
${nft} add chain ip6 filter postrouting {type filter hook postrouting priority 0 \; }
${nft} add chain ip6 filter nat {type nat hook postrouting priority 100 \; }

With these chains created, we can now begin to create the rules that enable this machine to be a sane router. First, with instructions on what to do with traffic we’re forwarding.

#FORWARDING RULESET

#forward traffic from WAN to LAN if related to established context
${nft} add rule filter forward iif $wan oif $lan ct state { established, related } accept

#forward from LAN to WAN always
${nft} add rule filter forward iif $lan oif $lan accept

#drop everything else from WAN to LAN
${nft} add rule filter forward iif $wan oif $lan counter drop

#ipv6 just in case we have this in future.
${nft} add rule ip6 filter forward iif $wan oif $lan ct state { established,related } accept
${nft} add rule ip6 filter forward iif $wan oif $lan icmpv6 type echo-request accept

#forward ipv6 from LAN to WAN.
${nft} add rule ip6 filter forward iif $lan oif $wan counter accept

#drop any other ipv6 from WAN to LAN
${nft} add rule filter forward iif $wan oif $lan counter drop

Now for traffic aimed at us. We have allowed traffic from LAN for port 53, 22, 80, 443, and 445 (TCP), as well as UDP 53 because this machine is running a web server, acting as the local DNS server for the LAN, and also sharing files over SMB to the local network.

#INPUT CHAIN RULESET
#============================================================
${nft} add rule filter input ct state { established, related } accept

# always accept loopback
${nft} add rule filter input iif lo accept
# uncomment next rule to allow ssh in
#${nft} add rule filter input tcp dport ssh counter log accept

#accept HTTP, DNS, SSH, SMB and DHCP from LAN, since we have a webserver, dns and ssh running.
${nft} add rule filter input iif $lan tcp dport { 53, 22, 80, 443, 445 } counter log accept
#accept dns and dhcp on LAN
${nft} add rule filter input iif $lan udp dport { 53, 67, 68 } accept

#accept ICMP on the LAN 
${nft} add rule filter input iif $lan ip protocol icmp accept

${nft} add rule filter input counter drop

${nft} add rule ip6 filter input ct state { established, related } accept
${nft} add rule ip6 filter input iif lo accept
#uncomment next rule to allow ssh in over ipv6
#${nft} add rule ip6 filter input tcp dport ssh counter log accept

${nft} add rule ip6 filter input icmpv6 type { nd-neighbor-solicit, echo-request, nd-router-advert, nd-neighbor-advert } accept

${nft} add rule ip6 filter input counter drop

Next we set some rules for traffic we’re generating.

#OUTPUT CHAIN RULESET
#=======================================================
# allow output from us for new, or existing connections.
${nft} add rule filter output ct state { established, related, new } accept

# Always allow loopback traffic
${nft} add rule filter output iif lo accept

${nft} add rule ip6 filter output ct state { established, related, new } accept
${nft} add rule ip6 filter output oif lo accept

Finally, let’s enable IP masquerading — masquerading simply means that this machine should automatically change the source port of outgoing traffic to match the IP address of the interface from which it is leaving. Since we’re a router, and traffic is primarily flowing from LAN to WAN, this means that the traffic gets given the source IP of the WAN interface before it goes to the internet and the system maintains information needed to translate that back and forward it to the right LAN host when there’s a reply.

If you piece all these snippets together, it should give you a functioning nftables router/firewall.