Nftables Basics: A Simple Firewall

A firewall is basically a set of rules that decides how network packets (the fundamental unit of network communication) are treated as they enter, traverse or leave the system. It is used to restrict the allowed communication (filtering), to forward packets to other destinations (routing) and to change packets contents such as the sender/receiver or even its data (mangling).

In this guide, we will go through the basics of how nftables works and how to use it to filter traffic at a basic level. More advanced topics will be covered in other guides.

Why nftables?

Traditionally the xtables (iptables, ip6tables, …) firewall tools have been used on Linux, however nftables is a more modern replacement that solved many of xtables’ design flaws and greatly improved usability, so we will be using it instead. Among other things, it has a more human readable syntax and proper support for configuration files and atomic loading of entire rulesets.

Why not use something else like ufw? A firewall management software like ufw may be a great fit for many users as it is at a higher level of abstraction - ufw is just a frontend tool that manages the backends, like iptables or nftables, hiding some complexity from the user and making it easier to manage. If we are operating a server where we want fine-grained control over network traffic with more flexible rules, ufw’s simplicity and abstraction may get in the way though, which is why I prefer to just use nftables directly.

Nftables concepts

Since I will mostly be basing the information around practical examples, please use the nft(8) manpage and the nftables wiki as a more complete formal reference. The wiki contains many useful articles about nftables’ concepts so I recommend reading it if you want to learn more.

A small example to start

# Clear the previous configuration/state of the firewall
flush ruleset

# Create a table for the inet family (IPv4 and IPv6) called "filter".
table inet filter {
	# Create a chain with the name "input", configure chain as a filter,
	# use the "input" hook with "filter" priority (see manpage Table 6).
	# Set the policy to "drop", meaning all packets are dropped by default.
	chain input {
		type filter hook input priority filter; policy drop;

		# Accepts tcp packets with the destination port 22.
		tcp dport 22 accept

		# Everything else is dropped due to the chain's policy.
	}
}

This small example is hardly useful and quite incomplete, but it should give you an idea what the basic syntax looks like. Let’s go through each element that we see here, one by one.

Tables

table <family> <name> {
	...
}

A table is the biggest unit of the configuration. It can contain multiple chains, and these chains contain the rules that make up our firewall. Each table belongs to a protocol family and only processes packets of that family. The most important families for simple usecases are ip for IPv4, ip6 for IPv6 and the one you will likely use the most, inet, which includes both IPv4 and IPv6 packets.

Chains

...
chain <name> {
	type <type> hook <hook> priority <priority>; policy <policy>;
	...
}
...

As mentioned, tables contain chains. Chains are a list of rules, which can be attached to a point in the networking pipeline, for example input or output. These points are called hooks and the chains act as filters with different priorities at these points in the pipeline (other chain types are mentioned later). You can find an image and description of all the hooks on the nftables wiki. A chain can also exist without a hook, but in that case they will only process packets that you explicitly send to them with the jump or goto verdict statements.

A chain can also be given a policy (accept or drop), which dictates what happens by default when a packet reaches the end of the chain. If the policy is not specified, it defaults to accept.

Rules

[expressions] statement(s) [statement arguments]

A chain contains the actual rules. A rule consists of expressions, which dictate what packets they apply to, and statements, which take actions on the packets that match the expression, like accepting, rejecting, logging and more. Here is an example: tcp dport 22 accept contains the expression tcp dport 22, meaning that this rule applies to packets with TCP destination port 22, and the statement accept. Verdict statements, for example accept, drop or, jump, are a specific kind of statement which is the final (but optional) part of a rule. The absolute verdict statements accept and drop are special, since they immediately stop the ruleset evaluation of a packet, so if a packet is accepted, it cannot be dropped by a rule that comes later in the same hook - however it can be dropped by a rule in a later hook in the pipeline. With drop, the packet gets discarded immediately and it is not processed any further.

Managing nftables

nftables is managed with the nft(8) command line tool. The ruleset can either be directly changed with the nft command, or you can load a text file that contains the changes. If loading from a text file, the file can use the command syntax (eg. add table inet filter) or the nested syntax as in my example above. Since configurations are significantly easier to read with the nested syntax, I will mostly be using this throughout the guide.

To give a quick impression of the nft command, here is an example creating a table and chain with its command syntax:

nft 'add table inet filter'
nft 'add chain inet filter input { type filter hook input priority filter; policy drop; }'

Loading rules from a file:

nft -f /etc/nftables.nft

Checking whether a file has correct syntax without loading it:

nft -f /etc/nftables.nft -c

Showing the currently active ruleset:

nft list ruleset

To completely reset the firewall (this means everything is allowed!), you can use nft flush ruleset. Generally you only want to write flush ruleset at the start of your main configuration file as in the example above before immediately applying new rules. For this reason I highly recommend using a configuration file instead of single commands for configuring nftables - a configuration file is loaded all at once, so your firewall is never left open or incomplete while changes are being applied. The nft command is best for making small non-permanent adjustments or monitoring.

To make nftables start at boot, your distribution likely includes a service file that can be enabled. On Alpine Linux, this can be acheived with the following command:

rc-update add nftables boot

Creating a simple and safe base configuration

On Alpine Linux, the configuration file /etc/nftables.nft is used by default by the nftables service. Since we will build a ruleset from scratch, I will just rename it and create a new one:

mv /etc/nftables.nft /etc/nftables.nft.bak
$EDITOR /etc/nftables.nft

Let’s start our configuration with this basic skeleton:

#!/usr/bin/nft -f

flush ruleset

table inet filter {
	chain input {
		type filter hook input priority filter; policy drop;
	}
	chain forward {
		type filter hook forward priority filter; policy drop;
	}
	chain output {
		type filter hook output priority filter; policy accept;
	}
}

Let’s deconstruct this. First we flush ruleset, meaning that we clear all previous rules. Next, we define a table for inet, meaning IPv4 and IPv6, and we name it “filter” - the name has no special meaning, simply use a name that fits the contents, in this case it is filtering.

Finally, we define three chains with the names input, forward and output. Again, the names are purely descriptive, not functional - the important part is what is written inside the curly braces. Without attaching a chain to a hook, no packets will run through it (unless another chain explicitly passes packets to it).

Chains can have different types, for now we will just use the filter type, which allows filtering of all packets that pass through the network pipeline at the specified hook. The hooks allow us to intercept traffic at different points that you can see on the nftables wiki page on hooks. For many purposes, it is sufficient to control the input, forward and output hooks, which cover most paths that packets can take.

Then we assign a priority, which determines the order in which different chains that are attached to the same hook are called. The priority filter corresponds with the number 0 for this type of chain, a lower number would have higher priority and a higher number lower priority. For more information I highly recommend the nft(8) manpage.

Lastly, we specify a policy, which determines the default action to be taken on any packet that reaches the end of the chain. The default policy is accept, but I prefer to always explicity write it because it is quite important to be aware of what happens to the packets. The only other option is drop, which simply discards the packet.

Choosing the right policy

For most servers and personal computers, unless they are supposed to act as proxies or routers, we usually do not want to enable packet forwarding. And even when we do need it, we usually only want to allow it from very specific sources to very specific destinations, so we choose the policy drop for forwarding traffic.

For inputs, we also choose a default policy of drop to minimize the attack surface from the outside. It’s generally a better idea to drop everything by default and only allow the types of traffic that you expect explicitly through rules, which we will get to right after this section.

For outputs, you could take the same approach for maximum security and control, but it is far less critical to take a default action of drop here. After all, if somebody has compromised your machine and has any kind of outward traffic allowed such as the regular HTTP(S) ports, they will be able to do bad things with it, regardless of what your default policy is. The only security there is completely cutting yourself off from the internet ;). So for this reason, I choose an output policy of accept. If you disagree, I’d love to know why, so please shoot me an email to unicorn@regrow.earth.

Creating basic rules

With the base configuration shown above, we are not able to receive any traffic yet, not even replies to our own outgoing packets. We might also want to trust packets that we receive locally from our loopback interface (lo), so that different services on our machine can communicate with each other. Another type of traffic that a good internet citizen should allow is ICMP and ICMPv6, without which quite a few things can break. Let’s add these to our configuration in the input chain:

chain input {
	type filter hook input priority filter; policy drop;

	# Accept local traffic (from input interface "lo")
	iif lo accept

	# Accept replies, drop invalid packets
	ct state { established, related } accept
	ct state invalid drop

	# Accept ICMP and ICMPv6
	meta l4proto { icmp, ipv6-icmp } accept
}

Now at least local traffic, replies and ICMP(v6) is allowed, which might already be enough for a regular computer. Before we go on, let’s not some details about some of the expressions and syntax.

Matching interfaces with iif/oif and iifname/oifname

In the example above, we use the iif expression to allow local traffic, but there is also iifname, which works quite similarly. The difference is that iif and oif only compare the interface index, while iifname and oifname compare the name as a string. Since indexes can change for interfaces that are created dynamically, the manpage suggests using iifname or oifname in those cases, like for example for VPN interfaces that may not always be there or might be added and removed in different orders.

Using connection tracking

In the example we have two lines where we use ct state, which is short for conntrack state, the state of a packet that is set by the connection tracking functionality of the kernel netfilter. The recognized states are “new”, “established”, “related”, “invalid” and “untracked”, you can see detailed explanations of each in this wiki article about connection tracking, but relevant for us are these, explained slightly simplified:

By only accepting “established” and “related” packets in the input hook, we are effectively only allowing replies to our own outgoing traffic. I like to drop “invalid” packets quickly so that they don’t get processed by later rules or logging statements.

Matching protocols

The meta l4proto expression allows us to filter for specific protocols, such as TCP, UDP, ICMP(v6) and more. There are other ways of doing this that are specific to IPv4 and IPv6, namely ip protocol and ip6 protocol, but meta l4proto matches both and can be more useful in tables of the inet family, as in this example.

Variables and simple sets

We can bind values and sets to names by using variables. You may have noticed that we used curly braces { ... } to list multiple arguments in an expression, like { icmp, ipv6-icmp }. This is called an anonymous set. Here is an example of how variables work with simple values or anonymous sets:

define wireguard_interfaces = { wg0, wg1, wg2 }

# Sets can be nested, this is the same as { br0, wg0, wg1, wg2 }
define dynamic_interfaces = { br0, $wireguard_interfaces }

define not_valid = invalid

iifname $dynamic_interfaces accept
ct state $not_valid drop

It is also possible to create dynamic sets for purposes such as blocklisting, but we will get to those in a separate guide as they are a bit more complex.

Simple (verdict) maps

A map basically associates two values, translating an occurrence of the first to the other. A verdict map (vmap for short) is a special type of map that associates a value with a verdict, allowing you to take different verdict actions depending on the value you encounter. In the code snippet above, we wrote these two rules:

ct state { established, related } accept
ct state invalid drop

Here is how we could express it with a vmap, associating the different values of ct state with different verdicts:

ct state vmap { established : accept, related : accept, invalid : drop }

To give you an example of using a map, here is a DNAT rule that redirects packets by changing the destination IP based on what the original destination port was:

dnat to tcp dport map { 80 : $webserver, 443 : $webserver, 6697 : $ircserver }

There will be more about NAT in a future guide.

Allowing incoming traffic (SSH, HTTP, HTTPS, …)

If you want to run a public webserver or you want SSH access to your computer from the outside, you will have to allow incoming traffic. Let’s say we want to allow the ports 22 (SSH), 80 (HTTP) and 443 (HTTPS), as well as incoming wireguard traffic on port 12345, then we can use the following statements:

tcp dport { ssh, http, https } accept
udp dport 12345 accept

Or alternatively, making use of named sets:

define allowed_tcp = { ssh, http, https }
define allowed_udp = 12345

tcp dport $allowed_tcp accept
udp dport $allowed_udp accept

If you are wondering why it’s possible to write eg. “https” instead of “443”, you can take a look at the file /etc/services. This file lists a ton of known ports along with the protocols they use and their name, so you can simply use the name instead of the port number to make your configuration more easily readable. You can also add your own service names to this file, I recommend placing them at the very bottom under the # Local services comment:

# Local services
mywireguard		12345/udp		# My custom WireGuard port

Then you can use it in your config:

udp dport mywireguard accept

Note that when you run nft list ruleset, it will still just display the port numbers. If you want it to output the service names according to /etc/services, you need to use it with the -S flag, like this: nft -S list ruleset

A simple complete example configuration

Here is a simple configuration for a server that needs to accept SSH, HTTP and HTTPS, accepts outgoing packets by default and drops incoming and forwarding traffic:

#!/usr/sbin/nft -f

flush ruleset

define allow_in_tcp = { ssh, http, https }

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

		iif lo accept

		ct state { established, related } accept
		ct state invalid drop

		meta l4proto { icmp, ipv6-icmp } accept

		tcp dport $allow_in_tcp accept
	}
	chain forward { type filter hook forward priority filter; policy drop; }
	chain output { type filter hook output priority filter; policy accept; }
}

Conclusion

As shown, nftables allows us to quite easily build a simple yet flexible ruleset that is still very readable and intuitive. In future guides I will address the topic of routing/forwarding packets and NAT in more depth and introduce dynamic maps for blocklisting and more. Please send any feedback and suggestions to unicorn@regrow.earth!