Migrating my iptables setup to nftables

Wanting to become familiar with nftables, I decided to jump in at the deep end and just use it on my local workstation. The goal was to replace the existing iptables setup, ideally without any drawbacks. The following essay will guide you through what I have done in order to achieve that.

In order to be able to follow, you should already be familiar with iptables and at least have a rough idea of what nftables are. I don't see much sense in reading the following text without already being an iptables user, but here is a recommendation of resources to consult regarding nftables (although it's not strictly a must-have):

  • LWN has a nice introductory article.
  • On Linux Audit there is a short comparison between iptables and nftables.
  • Pablo Neira Ayuso's excellent nftables beginner workshop is available on YouTube.
  • Finally there is Florian Westphal's talk in which he dives deeply into the technical reasons why iptables is being replaced and why nftables is such a good substitute.

I don't rely upon distributions facilities to manage firewall setups so much but rather have my own shell script, which provides a bit more convenience especially when it comes to keeping iptables and ip6tables in sync. This is how it looks:

#!/bin/bash

# useful wrappers for failure analysis
function cmd_or_print() { # command
	"$@" || echo "failed at: '$@'"
}
function ipt() { # iptables params
	cmd_or_print iptables "$@"
}
function ip6t { # ip6tables params
	cmd_or_print ip6tables "$@"
}

# have a simple way of doing things in iptables and ip6tables in parallel
function ip46t() { # ip(6)tables params
	ipt "$@"
	ip6t "$@"
}

# interfaces
wan=eth0
wan6=eth0
#vpn=tap23
vpn=tun+
#vpn=tap6
nstx=tun0
sixxs=sixxs0

# ip addresses
sixxs_pop="212.224.0.188"
my_ipv6_pfx="2001:41d0:8:c8e::/64"
my_second_ipv4="85.214.71.2"

# clear out everything
for it in iptables ip6tables; do
for table in filter mangle nat raw; do
	$it -t $table -nL >/dev/null 2>&1 || continue # non-existing table

	$it -t $table -F		# delete rules
	$it -t $table -X		# delete custom chains
	$it -t $table -Z		# zero counters
done
done

### define custom chains

# filter invalid packets
ip46t -N invalid
ip46t -A invalid -p tcp --tcp-flags SYN,FIN SYN,FIN -j DROP
ip46t -A invalid -p tcp --tcp-flags SYN,RST SYN,RST -j DROP
ip46t -N blacklist
ip46t -A invalid -j blacklist

# stateful firewalling if everything else fails
ip46t -N states
ip46t -A states -m state --state INVALID -j REJECT
ip46t -A states -m state --state RELATED,ESTABLISHED -j ACCEPT

# filtering input on the wan interface
ip46t -N wan_in
ipt -A wan_in -p icmp -j ACCEPT
ip6t -A wan_in -p icmpv6 -j ACCEPT
ip6t -A wan_in -m state --state NEW -m udp -p udp --dport 546 --sport 547 \
				-s fe80::/10 -d fe80::/10 -j ACCEPT # dhcpv6
ip46t -A wan_in -p tcp --dport 22 -j ACCEPT # ssh
ip46t -A wan_in -p esp -j ACCEPT # ipsec
ipt -A wan_in -p ah -j ACCEPT # ipsec
ip6t -A wan_in -m ah -j ACCEPT # ipsec
ip46t -A wan_in -p tcp --dport 27374:27474 -j ACCEPT # super hidden port range
ip46t -A wan_in -p udp --dport 27374:27474 -j ACCEPT # super hidden port range
ipt -A wan_in -p ipv6 -j ACCEPT # allow ipv6 traffic, handled by ip6tables

# set policy of chains to DROP
for it in iptables ip6tables; do
for chain in INPUT OUTPUT FORWARD; do
	$it -P $chain DROP
done
done

# builtin chains

ip46t -A INPUT -j invalid
ip46t -A INPUT -j wan_in
ip46t -A INPUT -i lo -j ACCEPT
ip46t -A INPUT -i vnetbr0 -j ACCEPT
ip46t -A INPUT -j states

ip46t -A OUTPUT -j ACCEPT

ip46t -A FORWARD -i vnetbr0 -j ACCEPT
ip46t -A FORWARD -o vnetbr0 -j wan_in
ip46t -A FORWARD -o vnetbr0 -j states

ipt -t nat -A POSTROUTING -s 192.168.42.0/24 \! -o vnetbr0 -j MASQUERADE

Since it clears the whole table setup at first, after making changes I can simply fire it up again and then tell the distribution to save the rules so they are applied automatically during system boot up. Here is the ip(6)tables-save output after having it applied:

*nat
:PREROUTING ACCEPT [0:0]
:INPUT ACCEPT [0:0]
:OUTPUT ACCEPT [2:121]
:POSTROUTING ACCEPT [2:121]
[0:0] -A POSTROUTING -s 192.168.42.0/24 ! -o vnetbr0 -j MASQUERADE
COMMIT
*raw
:PREROUTING ACCEPT [21:5125]
:OUTPUT ACCEPT [22:2332]
COMMIT
*mangle
:PREROUTING ACCEPT [23:5414]
:INPUT ACCEPT [23:5414]
:FORWARD ACCEPT [0:0]
:OUTPUT ACCEPT [22:2332]
:POSTROUTING ACCEPT [21:2280]
COMMIT
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT DROP [0:0]
:blacklist - [0:0]
:invalid - [0:0]
:states - [0:0]
:wan_in - [0:0]
[21:5125] -A INPUT -j invalid
[21:5125] -A INPUT -j wan_in
[0:0] -A INPUT -i lo -j ACCEPT
[1:80] -A INPUT -i vnetbr0 -j ACCEPT
[20:5045] -A INPUT -j states
[0:0] -A FORWARD -i vnetbr0 -j ACCEPT
[0:0] -A FORWARD -o vnetbr0 -j wan_in
[0:0] -A FORWARD -o vnetbr0 -j states
[21:2280] -A OUTPUT -j ACCEPT
[0:0] -A invalid -p tcp -m tcp --tcp-flags FIN,SYN FIN,SYN -j DROP
[0:0] -A invalid -p tcp -m tcp --tcp-flags SYN,RST SYN,RST -j DROP
[21:5125] -A invalid -j blacklist
[0:0] -A states -m state --state INVALID -j REJECT \
			--reject-with icmp-port-unreachable
[20:5045] -A states -m state --state RELATED,ESTABLISHED -j ACCEPT
[0:0] -A wan_in -p icmp -j ACCEPT
[0:0] -A wan_in -p tcp -m tcp --dport 22 -j ACCEPT
[0:0] -A wan_in -p esp -j ACCEPT
[0:0] -A wan_in -p ah -j ACCEPT
[0:0] -A wan_in -p tcp -m tcp --dport 27374:27474 -j ACCEPT
[0:0] -A wan_in -p udp -m udp --dport 27374:27474 -j ACCEPT
[0:0] -A wan_in -p ipv6 -j ACCEPT
COMMIT
*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT DROP [0:0]
:blacklist - [0:0]
:invalid - [0:0]
:states - [0:0]
:wan_in - [0:0]
[23:2255] -A INPUT -j invalid
[23:2255] -A INPUT -j wan_in
[18:1741] -A INPUT -i lo -j ACCEPT
[0:0] -A INPUT -i vnetbr0 -j ACCEPT
[4:450] -A INPUT -j states
[0:0] -A FORWARD -i vnetbr0 -j ACCEPT
[0:0] -A FORWARD -o vnetbr0 -j wan_in
[0:0] -A FORWARD -o vnetbr0 -j states
[26:2482] -A OUTPUT -j ACCEPT
[0:0] -A invalid -p tcp -m tcp --tcp-flags FIN,SYN FIN,SYN -j DROP
[0:0] -A invalid -p tcp -m tcp --tcp-flags SYN,RST SYN,RST -j DROP
[23:2255] -A invalid -j blacklist
[0:0] -A states -m state --state INVALID -j REJECT \
			--reject-with icmp6-port-unreachable
[4:450] -A states -m state --state RELATED,ESTABLISHED -j ACCEPT
[1:64] -A wan_in -p ipv6-icmp -j ACCEPT
[0:0] -A wan_in -s fe80::/10 -d fe80::/10 -p udp -m state --state NEW \
				-m udp --sport 547 --dport 546 -j ACCEPT
[0:0] -A wan_in -p tcp -m tcp --dport 22 -j ACCEPT
[0:0] -A wan_in -p esp -j ACCEPT
[0:0] -A wan_in -m ah -j ACCEPT
[0:0] -A wan_in -p tcp -m tcp --dport 27374:27474 -j ACCEPT
[0:0] -A wan_in -p udp -m udp --dport 27374:27474 -j ACCEPT
COMMIT

Using the converter as a starting point

Since mid of February 2016, the iptables repository contains a command ip(6)tables-restore-translate (still unreleased) to read from iptables-save output and generate a suitable nftables setup. In case it fails to translate a given statement into nftables syntax, it will output the problematic line as a comment. Sensing some relief from the tedious work of finding a suitable replacement for each and every rule, I decided to give it a go and continue with whatever it generates. This turned out to be a pretty good choice, considering the amount of work it saved me:

add table ip nat
add chain ip nat PREROUTING { type filter hook prerouting priority 0; }
add chain ip nat INPUT { type filter hook input priority 0; }
add chain ip nat OUTPUT { type filter hook output priority 0; }
add chain ip nat POSTROUTING { type filter hook postrouting priority 0; }
add rule ip nat POSTROUTING oifname != vnetbr0 ip saddr 192.168.42.0 counter masquerade
add table ip raw
add chain ip raw PREROUTING { type filter hook prerouting priority 0; }
add chain ip raw OUTPUT { type filter hook output priority 0; }
add table ip mangle
add chain ip mangle PREROUTING { type filter hook prerouting priority 0; }
add chain ip mangle INPUT { type filter hook input priority 0; }
add chain ip mangle FORWARD { type filter hook forward priority 0; }
add chain ip mangle OUTPUT { type filter hook output priority 0; }
add chain ip mangle POSTROUTING { type filter hook postrouting priority 0; }
add table ip filter
add chain ip filter INPUT { type filter hook input priority 0; }
add chain ip filter FORWARD { type filter hook forward priority 0; }
add chain ip filter OUTPUT { type filter hook output priority 0; }
add chain ip filter blacklist
add chain ip filter invalid
add chain ip filter states
add chain ip filter wan_in
add rule ip filter INPUT counter jump invalid
add rule ip filter INPUT counter jump wan_in
add rule ip filter INPUT iifname lo counter accept
add rule ip filter INPUT iifname vnetbr0 counter accept
add rule ip filter INPUT counter jump states
add rule ip filter FORWARD iifname vnetbr0 counter accept
add rule ip filter FORWARD oifname vnetbr0 counter jump wan_in
add rule ip filter FORWARD oifname vnetbr0 counter jump states
add rule ip filter OUTPUT counter accept
add rule ip filter invalid tcp flags & fin|syn == fin|syn counter drop
add rule ip filter invalid tcp flags & syn|rst == syn|rst counter drop
add rule ip filter invalid counter jump blacklist
add rule ip filter states ct state invalid counter reject
add rule ip filter states ct state related,established counter accept
add rule ip filter wan_in ip protocol icmp counter accept
add rule ip filter wan_in tcp dport 22 counter accept
add rule ip filter wan_in ip protocol esp counter accept
add rule ip filter wan_in ip protocol ah counter accept
add rule ip filter wan_in tcp dport 27374-27474 counter accept
add rule ip filter wan_in udp dport 27374-27474 counter accept
add rule ip filter wan_in ip protocol ipv6 counter accept
add table ip6 filter
add chain ip6 filter INPUT { type filter hook input priority 0; }
add chain ip6 filter FORWARD { type filter hook forward priority 0; }
add chain ip6 filter OUTPUT { type filter hook output priority 0; }
add chain ip6 filter blacklist
add chain ip6 filter invalid
add chain ip6 filter states
add chain ip6 filter wan_in
add rule ip6 filter INPUT counter jump invalid
add rule ip6 filter INPUT counter jump wan_in
add rule ip6 filter INPUT iifname lo counter accept
add rule ip6 filter INPUT iifname vnetbr0 counter accept
add rule ip6 filter INPUT counter jump states
add rule ip6 filter FORWARD iifname vnetbr0 counter accept
add rule ip6 filter FORWARD oifname vnetbr0 counter jump wan_in
add rule ip6 filter FORWARD oifname vnetbr0 counter jump states
add rule ip6 filter OUTPUT counter accept
add rule ip6 filter invalid tcp flags & fin|syn == fin|syn counter drop
add rule ip6 filter invalid tcp flags & syn|rst == syn|rst counter drop
add rule ip6 filter invalid counter jump blacklist
add rule ip6 filter states ct state invalid  counter reject
add rule ip6 filter states ct state related,established  counter accept
add rule ip6 filter wan_in meta l4proto ipv6-icmp counter accept
add rule ip6 filter wan_in ip6 saddr fe80:: ip6 daddr fe80:: \
		ct state new udp sport 547 udp dport 546 counter accept
add rule ip6 filter wan_in tcp dport 22 counter accept
add rule ip6 filter wan_in meta l4proto esp counter accept
add rule ip6 filter wan_in  counter accept
add rule ip6 filter wan_in tcp dport 27374-27474 counter accept
add rule ip6 filter wan_in udp dport 27374-27474 counter accept

On a closer look, things turned out to be less than perfect even though there were no rules for which the converter failed to find a substitute. Here is a list of issues I found; together with how I solved them:

Missing drop policy

The converter apparently ignored the drop policy of all the built-in chains, effectively annulling the whole firewall. Luckily, this is easy to fix in hindsight: Just add the missing policy statement to the relevant chains like so:

add chain ip filter INPUT { type filter hook input priority 0; policy drop; }

Fixing the converter was easy, as explained in the patch's description.

Dropped -m ah match

It seems like the converter assumed an Authentication Header match without parameters like I used in ip6tables setup would never fail, and so I simply dropped it. This is not true though, as it is the suggested replacement for iptables' AH protocol match (i.e. -p ah). Looking at the equivalent ESP header match, I assumed that the following would be a valid replacement:

nft add rule ip6 filter wan_in meta l4proto ah counter accept

Sadly, this is not completely correct, either: In fact it matches only if the AH header follows immediately after the IPv6 header - if there is any other extension header in between, it does not see it. To my surprise, there is no easy fix - matching on mere existence of an IPv6 extension header is actually a limitation to nftables, and at the time of this writing there was no implementation available. So the l4proto match above had to suffice for now, luckily IPv6 extension headers don't see much use yet (other than AH and ESP, of course).

Missing prefix lengths in subnet definitions

I completely overlooked this and only found out about it when testing the results: The converter turned all subnet definitions into simple IP addresses, which then never matched (since those ending in zero are not used by hosts). Using the iptables-translate utility is an easy way of reproducing the issue:

$ iptables-translate -A INPUT -s 10.0.0.0/8 -j ACCEPT
nft add rule ip filter INPUT ip saddr 10.0.0.0 counter accept

Luckily, this was quite as easy to fix as the missing policy statement above.

TCP flags matches turned into a mess

This problem is not visible from looking only at the generated nftables rules, but becomes obvious when listing the ruleset after having it applied: The rule's path to the kernel and back turns it from this:

tcp flags & fin|syn == fin|syn counter drop

into that:

tcp flags & (fin | syn) | syn == fin | syn

This is clearly a problem of the converter, which ignored the fact that binary AND precedes binary OR. The output of nftables does not seem like a mathematically correct transformation, either. But instead of trying to fix nftables in that aspect, I guess it would make more sense to have the converter generate the missing parentheses around the binary OR on the left side of the relational expression (see patch here and on top of that, I patched nftables' parser to allow extra (though unnecessary) parentheses around the right side.

Wrong type of chains in nat table

The replacement for iptables' NAT table was generated with chains of type filter, which is apparently wrong: The kernel will reject adding a rule with verdict masquerade to them. Instead, the type has to be set to nat to allow for that verdict. Luckily, this wasn't hard to fix, either.

Optimizing the results

While reviewing the remaining converter output, a few optimizations struck:

  • There is no need to keep empty chains (unless their policy diverts from the default of accept). And if a table becomes empty after removing all empty chains it contains, the whole table can be dropped.
  • The loop in updateipt.sh setting all built-in chain's policy to DROP made it necessary to explicitly allow outgoing traffic in OUTPUT chains. By keeping the chain's policy at the default, the explicit rule can go away and in turn the whole chain becomes empty so the above point applies here, as well.
  • There is no need to have a counter for every rule. In fact, most rules are totally uninteresting, so use counters only for rules, which should not see traffic (to spot problems or attacks) or are used for statistics/accounting (of which there are none in my setup).

After applying the things from above and concatenating the rules for IPv4 and IPv6 (the single nft utility handles both), here's the result:

add table ip nat
add chain ip nat POSTROUTING { type nat hook postrouting priority 0; policy accept; }
add rule ip nat POSTROUTING oifname != vnetbr0 ip saddr 192.168.42.0/24 masquerade
add table ip filter
add chain ip filter INPUT { type filter hook input priority 0; policy drop; }
add chain ip filter FORWARD { type filter hook forward priority 0; policy drop; }
add chain ip filter blacklist
add chain ip filter invalid
add chain ip filter states
add chain ip filter wan_in
add rule ip filter INPUT jump invalid
add rule ip filter INPUT jump wan_in
add rule ip filter INPUT iifname lo accept
add rule ip filter INPUT iifname vnetbr0 accept
add rule ip filter INPUT jump states
add rule ip filter FORWARD iifname vnetbr0 accept
add rule ip filter FORWARD oifname vnetbr0 jump wan_in
add rule ip filter FORWARD oifname vnetbr0 jump states
add rule ip filter invalid tcp flags & (fin|syn) == fin|syn counter drop
add rule ip filter invalid tcp flags & (syn|rst) == syn|rst counter drop
add rule ip filter invalid jump blacklist
add rule ip filter states ct state invalid counter reject
add rule ip filter states ct state related,established accept
add rule ip filter wan_in ip protocol icmp accept
add rule ip filter wan_in tcp dport 22 accept
add rule ip filter wan_in ip protocol esp accept
add rule ip filter wan_in ip protocol ah accept
add rule ip filter wan_in tcp dport 27374-27474 accept
add rule ip filter wan_in udp dport 27374-27474 accept
add rule ip filter wan_in ip protocol ipv6 accept
add table ip6 filter
add chain ip6 filter INPUT { type filter hook input priority 0; policy drop; }
add chain ip6 filter FORWARD { type filter hook forward priority 0; policy drop; }
add chain ip6 filter blacklist
add chain ip6 filter invalid
add chain ip6 filter states
add chain ip6 filter wan_in
add rule ip6 filter INPUT jump invalid
add rule ip6 filter INPUT jump wan_in
add rule ip6 filter INPUT iifname lo accept
add rule ip6 filter INPUT iifname vnetbr0 accept
add rule ip6 filter INPUT jump states
add rule ip6 filter FORWARD iifname vnetbr0 accept
add rule ip6 filter FORWARD iifname vnetbr* oifname vnetbr* accept
add rule ip6 filter FORWARD oifname vnetbr0 jump wan_in
add rule ip6 filter FORWARD oifname vnetbr0 jump states
add rule ip6 filter invalid tcp flags & (fin|syn) == fin|syn counter drop
add rule ip6 filter invalid tcp flags & (syn|rst) == syn|rst counter drop
add rule ip6 filter invalid jump blacklist
add rule ip6 filter states ct state invalid counter reject
add rule ip6 filter states ct state related,established accept
add rule ip6 filter wan_in meta l4proto ipv6-icmp accept
add rule ip6 filter wan_in ip6 saddr fe80::/10 ip6 daddr fe80::/10 \
			ct state new udp sport 547 udp dport 546 accept
add rule ip6 filter wan_in tcp dport 22 accept
add rule ip6 filter wan_in meta l4proto esp accept
add rule ip6 filter wan_in meta l4proto ah accept
add rule ip6 filter wan_in tcp dport 27374-27474 accept
add rule ip6 filter wan_in udp dport 27374-27474 accept

Making use of the new inet table

Now for some real feature of nftables, namely handling of IPv4 and IPv6 traffic in a common table. For instance, the FORWARD chains above are exactly identical since there are no IP version specific rules in them. The only problem with consolidating them is that they reference wan_in and states chains and since the INPUT chain uses these as well, this ends up in an all or nothing approach for the whole filter tables. But in this case it still made sense since the tables were so similar. So one by one, first the FORWARD chain:

add table inet filter
add chain inet filter FORWARD { type filter hook forward priority 0; policy drop; }
add rule inet filter FORWARD iifname vnetbr0 accept
add rule inet filter FORWARD oifname vnetbr0 jump wan_in
add rule inet filter FORWARD oifname vnetbr0 jump states

Next the wan_in and states chains it depends upon:

add chain inet filter wan_in
add rule inet filter wan_in ip protocol icmp accept
add rule inet filter wan_in meta l4proto ipv6-icmp accept
add rule inet filter wan_in ip6 saddr fe80:: ip6 daddr fe80:: \
		ct state new  udp sport 547 udp dport 546 accept
add rule inet filter wan_in tcp dport 22 accept
add rule inet filter wan_in ip protocol esp accept
add rule inet filter wan_in meta l4proto esp accept
add rule inet filter wan_in ip protocol ah accept
add rule inet filter wan_in meta l4proto ah accept
add rule inet filter wan_in tcp dport 27374-27474 accept
add rule inet filter wan_in udp dport 27374-27474 accept
add rule inet filter wan_in ip protocol ipv6 accept
add chain inet filter states
add rule inet filter states ct state invalid counter reject
add rule inet filter states ct state related,established accept

Finally the INPUT chain and its dependency, the invalid chain (which in turn jumps to the empty blacklist):

add chain inet filter INPUT { type filter hook input priority 0; policy drop; }
add rule inet filter INPUT jump invalid
add rule inet filter INPUT jump wan_in
add rule inet filter INPUT iifname lo accept
add rule inet filter INPUT iifname vnetbr0 accept
add rule inet filter INPUT jump states
add chain inet filter invalid
add chain inet filter blacklist
add rule inet filter invalid tcp flags & (fin|syn) == fin|syn counter drop
add rule inet filter invalid tcp flags & (syn|rst) == syn|rst counter drop
add rule inet filter invalid jump blacklist

Of course, the statements above need to be reordered a bit so chains are created prior to rules referencing them.

Using the blacklist chain - or an alternative

The empty chain blacklist is meant to be managed by an application layer IPS such as fail2ban. Adjusting that, is beyond the scope of this document, but here are example rules for adding IPv4/IPv6 addresses to it:

add rule inet filter blacklist ip saddr 192.168.42.3 drop
add rule inet filter blacklist ip6 saddr feed:babe::3 drop

In nftables world though, this is considered inelegant and one would rather use named sets instead. To do so, two sets need to be created since a single one can't hold addresses of different families:

add set inet filter blacklist4 { type ipv4_addr; }
add set inet filter blacklist6 { type ipv6_addr; }

Next, drop rules are created in invalid chain (since the IPS may work with the named sets as a full replacement to a dedicated chain, which includes clearing them completely):

add rule inet filter invalid ip saddr @blacklist4 drop
add rule inet filter invalid ip6 saddr @blacklist6 drop

Adding IP addresses to the sets then works like so:

add element inet filter blacklist4 { 192.168.42.3 }
add element inet filter blacklist6 { feed:babe::3 }

About meta l4proto and unnamed sets

While testing correct functionality of the generated rules in inet family tables, it showed that meta l4proto matches are actually IP address family agnostic, so the IPv4 specific ones (i.e. ip protocol) may be dropped altogether. Since there are multiple meta l4proto matches in wan_in chain, it makes sense to combine them using an anonymous set like so:

add rule inet filter wan_in meta l4proto { icmp, ipv6-icmp, esp, ah } accept

Rule set dump file formats

In order to load a rule set, nft program supports reading from a file with some flexibility in its syntax. On one hand, just adding the quoted statements above works just fine. Though nft is also able to read a format just as nft list ruleset displays, which might be a little clearer to read since it puts tables, chains, and rules in relation. Here is what the full rule set with all mentioned modifications looks like in "nested" representation:

table ip nat {
	chain POSTROUTING {
		type nat hook postrouting priority 0
		policy accept

		oifname != vnetbr0 ip saddr 192.168.42.0/24 masquerade
	}
}
table inet filter {
	set blacklist4 {
		type ipv4_addr
	}
	set blacklist6 {
		type ipv6_addr
	}
	chain INPUT {
		type filter hook input priority 0
		policy drop

		jump invalid
		jump wan_in
		iifname lo accept
		iifname vnetbr0 accept
		jump states
	}
	chain FORWARD {
		type filter hook forward priority 0
		policy drop

		iifname vnetbr0 accept
		oifname vnetbr0 jump wan_in
		oifname vnetbr0 jump states
	}
	chain wan_in {
		ip6 saddr fe80:: ip6 daddr fe80:: \
			ct state new udp sport 547 udp dport 546 accept
		meta l4proto { icmp, ipv6-icmp, esp, ah } accept
		tcp dport 22 accept
		tcp dport 27374-27474 accept
		udp dport 27374-27474 accept
		ip protocol ipv6 accept
	}
	chain states {
		ct state invalid counter reject
		ct state related,established accept
	}
	chain invalid {
		tcp flags & (fin|syn) == fin|syn counter drop
		tcp flags & (syn|rst) == syn|rst counter drop
		ip saddr @blacklist4 drop
		ip6 saddr @blacklist6 drop
	}
}

Depending on personal preference, this may be decorated with comments (starting with the commonly used hash (#) character) as well. Like with iptables, it is also possible to add comments to rules, which have the benefit of being part of nft list ruleset output and therefore are present in whatever form rules are managed. The only requirement is that they appear at the end of the rule specification, and when entered on command line quotes have to be escaped:

add rule inet filter INPUT counter comment "useless rule having a comment"

Finishing up

So, satisfied with the outcome of my rule set conversion and optimization, the very last piece to complete the puzzle was to create a shell script equivalent to updateipt.sh I started with. Given that I do not need any shell tricks anymore as well as there is support for comments, I can use nft directly as script interpreter:

#!/sbin/nft -f

When it comes to clearing the tables (or rather ruleset in this case) at the beginning, I found out that you could also mix the syntaxes described above. So one can simply add the following line as the first command before the actual table definitions:

flush ruleset

This is much easier than the common -F, -X, -Z dance for all tables of both iptables and ip6tables!

Last updated: January 9, 2017