nftables is a very powerful packet filtering framework, otherwise known as a firewall. In this tutorial I will introduce the most important mechanism which will allow you to build your own firewall.
The tutorial assumes you are running Debian 11 “Bullseye”. It will not work on earlier version of Debian, see iptables vs nftables as to why this is. Except for the filename of the nftables rules file this tutorial should work on other distribution as well, as long as you are running Linux 5.10+ and nftables 0.9.6+.
In case you are changing firewall rules on a remote machine, make sure you always have a way into the machine in case you lock yourself out. For a bit of extra safety (no garantees) check out Safe reload with nftables.
On Debian at least the default configuration file for nftables is /etc/nftables.conf
:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority 0;
}
chain forward {
type filter hook forward priority 0;
}
chain output {
type filter hook output priority 0;
}
}
In this tutorial we will be focusing on the filter input using the input chain.
To start of, let’s accept incoming SSH traffic on TCP destination port 22 by adding the following to the input chain:
tcp dport 22 accept;
The file should now look something like:
table inet filter {
chain input {
type filter hook input priority 0;
tcp dport 22 accept;
}
chain forward {
type filter hook forward priority 0;
}
chain output {
type filter hook output priority 0;
}
}
Great, but since the default action is also accept how to tell the rule is working? nftables allows multiple actions to be performed in the same rule. Only one of the actions can be a terminating action (e.g. accept, reject, drop, jump) but you can add as many non-terminating actions as you like.
So let’s add the counter action, which, as the name suggest, counts:
tcp dport 22 counter accept;
After reloading the rules you can query the counter by listing the entire ruleset
# nft list ruleset
The listing will include the line including counter values
tcp dport 22 counter packets 3 bytes 240 accept
in this case showing 3 packages were accepted for a total of 240 bytes.
If we also want to allow web traffic (ports 80 and 443) we could just repeat the statement and substitute the port number. However this would be very verbose and not very efficient. Instead let’s use a set.
tcp dport {80, 443} accept;
We can do something similar for IP source addresses, to create a droplist.
ip saddr {1.2.3.4, 2.3.4.5} drop;
However the more addresses are added the more difficult to read this becomes. So let’s create a named set instead. nftables rules are typed, so to create a named set we first need to determine its type. This can be easily done using the describe command.
# nft describe ip saddr
payload expression, datatype ipv4_addr (IPv4 address) (basetype integer), 32 bits
Now we know the type is ipv4_addr we can create a named set droplist and add some elements to it.
set droplist {
type ipv4_addr;
elements = {
1.2.3.4,
2.3.4.5
};
}
Now all we have to do is use the set. This can be done by using @
ip saddr @droplist drop;
The complete file should now look something like this:
table inet filter {
set droplist {
type ipv4_addr;
elements = {
1.2.3.4,
2.3.4.5
};
}
chain input {
type filter hook input priority 0;
ip saddr @droplist drop;
tcp dport 22 counter accept;
tcp dport {80, 443} accept;
}
chain forward {
type filter hook forward priority 0;
}
chain output {
type filter hook output priority 0;
}
}
As the type indicates the droplist only applies to IPv4 addresses. In order to be able to filter IPv6 addresses add another set:
set droplist6 {
type ipv6_addr;
}
And another rule:
ip6 saddr @droplist6 drop;
The nice thing about named sets is that you don’t need to change the firewall rules to change the elements in the set. This can be done while the firewall is active. For example, the command line can be used to add an element to the droplist.
# nft add element inet filter droplist { 4.3.2.1 };
Of course, as soon as the firewall is reloaded the change is gone. And there is no confirmation, so it can be easy to lock yourself out when manipulating sets like this.
That takes care of basic port and address filtering. How about ICMP, the most well known example of which is echo request aka ping. An ICMP packet is defined not only by its type, but by the combination of type and code. In order for the connection to function properly certain combinations have to be permitted, while other can be dropped12.
Concatentations allow for multiple fields to be combined in a single set. Let’s define the set of allowed ICMP packets icmp4.
set icmp4 {
type icmp_type . icmp_code;
elements = {
echo-request . 0,
echo-reply . 0,
destination-unreachable . frag-needed,
time-exceeded . 0
};
}
Using this set in a rule can be done by placing a . (period) as the value for all but the last field.
icmp type . icmp code @icmp4 accept
For IPv6 things become a bit more difficult because there are a lot more type code combinations which must be allowed3. Instead of writing them all out we can use intervals by setting the interval flag when defining the set.
set icmp6 {
type icmpv6_type . icmpv6_code;
flags interval;
elements = {
destination-unreachable . 0-6,
packet-too-big . 0,
time-exceeded . 0-1,
parameter-problem . 0-2,
echo-request . 0,
echo-reply . 0,
mld-listener-query . 0,
mld-listener-report . 0,
mld-listener-done . 0,
nd-router-solicit . 0,
nd-router-advert . 0,
nd-neighbor-solicit . 0,
nd-neighbor-advert . 0,
ind-neighbor-solicit . 0,
ind-neighbor-advert . 0,
mld2-listener-report . 0,
148 . 0,
149 . 0,
151 . 0,
152 . 0,
153 . 0
};
}
Usage is the same.
icmpv6 type . icmpv6 code @icmp6 accept;
Moving beyond simple actions such as accept and drop we can manipulate the meta information to set the priority of a packet. Let’s assume we want to set SSH traffic to priority 1 and web traffic to priority 2.
tcp dport 22 meta priority set 1;
tcp dport 80 meta priority set 2;
tcp dport 443 meta priority set 2;
This will work, but why use 3 rules if 1 suffices. Extending on sets we can define maps, which are sets which return a value.
map priority_map {
type inet_service : classid;
elements = {
22: 1,
80: 2,
443: 2
};
When using maps the rule will look slightly different. Start by defining the action, then were the value would normally be include the expression which serves as input to the map, and finally reference the map itself.
meta priority set tcp dport map @priority_map;
Maps are not limited to types, but can also be used to lookup the a verdict using verdict maps.
map verdict_map {
type ipv4_addr : verdict;
elements = {
1.1.2.2: accept,
2.2.1.1: drop
};
}
ip saddr vmap @verdict_map;
A verdict can be any of the verdict statements (accept, drop, queue, continue, return, jump, goto)4.
So far we have dealt with incoming connections, however the input chain also deals with responses from outgoing connections. Fortunately connection tracking allows us to deal with those easily by accepting all established and related packets.
ct state established,related counter accept;
That’s it, a basic firewall using nftables. Except that the default action is still accept and therefore the firewall isn’t actually filtering anything. Therefore add the following statement to the end of the input chain.
log prefix "DROP:";
This will log all packets which should be dropped. Check the system log to make sure you didn’t miss any rules. If not, add the drop statement to the end of the line.
log prefix "DROP:" drop;
For the full script, including some bonus features, check out GitHub.
-
Recommendations for Filtering ICMPv6 Messages in Firewalls ↩
-
For reasons unknown to me reject is a terminating statement, but not a verdict statement, so it cannot be used in verdict maps ↩