rss logo

How to Configure nftables Rules from Scratch on GNU/Linux

nftables Logo

Intro

In this article, I’ll walk you through a method I use to configure nftables rules on GNU/Linux systems. The goal is to define the most restrictive and precise rule set possible. This approach is suitable for a wide range of setups, including servers, workstations, and routers. It works by first deploying a “classic” baseline configuration that logs potentially blocked traffic, and then incrementally adding rules based on what we observe in the system logs.

  • The process consists of three main steps:
    • Start with a basic configuration and enable traffic tracing.
    • Analyze the logs and add rules as needed to allow legitimate traffic that isn't already explicitly authorized.
    • Once all legitimate traffic is accounted for, enable default blocking.

Let’s get started!

Configuring Default and Common Rules

  • We’ll begin by setting up a basic rule set (⚠️don’t forget to replace enp2s0 with the actual name of your network interface!⚠️):
    • Rules for the local interface
    • Rules to allow ICMP traffic
    • Rules to permit web traffic

This setup represents a typical profile for most desktop systems today. Note the inclusion of lines using the “log prefix” directive. These rules temporarily accept traffic while logging details about every packet they match. This allows us to monitor and trace traffic that would otherwise be blocked once default blocking is enforced.

#!/usr/bin/nft -f

flush ruleset

# We declare an interface variable associated with our network card so that we can use $interface in our rules instead of "enp2s0".
define interface = { enp2s0 }

# ----- IPv4 and IPv6 -----
table inet filter {
        chain INPUT {
                type filter hook input priority 0; policy drop;

		# Local interface rules
                ct state invalid counter drop comment "Drop invalid connections"
                iif != lo ip daddr 127.0.0.1/8 counter drop comment "drop connections to loopback not coming from loopback"
		iif != lo ip6 daddr ::1/128 counter drop comment "drop connections to loopback not coming from loopback"
                iif lo accept comment "Accept any localhost traffic"

		# ICMP traffic
		ip protocol icmp counter accept comment "accept all ICMP types"
                meta l4proto ipv6-icmp counter accept comment "accept all ICMPv6 types"

		# Accept web traffic (ALL to PC : if related or established) : HTTP + HTTPS + HTTP/3 QUIC + DNS + NTP
		iif $interface tcp sport { http, https, domain, ntp } ct state { related, established } counter accept comment "Accept Web Traffic TCP"
		iif $interface udp sport { https, domain } ct state { related, established } counter accept comment "Accept Web Traffic UDP"

		# Accept but trace all other INPUT traffic
		log prefix "INPUT : " accept comment "log INPUT"
        }
        chain FORWARD {
                type filter hook forward priority 0; policy drop;

		# FORWARD traffic is dropped and logged. There should be no network frames if the Linux PC is not in router mode.
		log prefix "FORWARD : " drop comment "log FORWARD"
        }
        chain OUTPUT {
                type filter hook output priority 0; policy drop;

		# Local interface rules
                oif lo accept comment "Accept any localhost traffic"

		# ICMP traffic
		ip protocol icmp counter accept comment "accept all ICMP types"
                meta l4proto ipv6-icmp counter accept comment "accept all ICMPv6 types"

		# Accept web traffic (PC to ALL : new, related and established) : HTTP + HTTPS + HTTP/3 QUIC + DNS + NTP
		oif $interface tcp dport { http, https, domain, ntp } ct state { new, related, established } counter accept comment "Accept Web Traffic"
		oif $interface udp dport { https, domain } ct state { new, related, established } counter accept comment "Accept Web Traffic"

		# Accept but trace all other OUTPUT traffic
		log prefix "OUTPUT : " accept comment "log out of rules traffic"
        }
}
  • Apply the nftables configuration by running the following command:
root@host:~# nft -f /etc/nftables

Log analysis

  • We use the journalctl command to display packets that match our “log prefix” rules. To do this, we apply the --grep option to filter log entries containing INPUT, OUTPUT, or FORWARD, and restrict the output to kernel messages using the -k flag:
root@host:~# journalctl -k --grep="INPUT|OUTPUT|FORWARD"
Dec 14 15:57:09 WK-STD kernel: INPUT : IN=ens33 OUT= MAC=00:50:56:80:4f:3d:34:56:dc:b7:ec:ec:08:00 SRC=192.168.1.90 DST=192.168.1.88 LEN=88 TOS=0x00 PREC=0x00 TTL=62 ID=59819 DF PROTO=TCP SPT=58616 DPT=22 WINDOW=1091 RES=0x00 ACK PSH URGP=0 
Dec 14 15:57:09 WK-STD kernel: OUTPUT : IN= OUT=ens33 SRC=192.168.1.88 DST=192.168.1.90 LEN=52 TOS=0x10 PREC=0x00 TTL=64 ID=10678 DF PROTO=TCP SPT=22 DPT=58616 WINDOW=524 RES=0x00 ACK URGP=0 
Dec 14 15:57:10 WK-STD kernel: OUTPUT : IN= OUT=ens33 SRC=192.168.1.88 DST=192.168.1.90 LEN=592 TOS=0x10 PREC=0x00 TTL=64 ID=10679 DF PROTO=TCP SPT=22 DPT=58616 WINDOW=524 RES=0x00 ACK PSH URGP=0 
Dec 14 15:57:10 WK-STD kernel: OUTPUT : IN= OUT=ens33 SRC=192.168.1.88 DST=192.168.1.90 LEN=1208 TOS=0x10 PREC=0x00 TTL=64 ID=10680 DF PROTO=TCP SPT=22 DPT=58616 WINDOW=524 RES=0x00 ACK PSH URGP=0 
Dec 14 15:57:10 WK-STD kernel: INPUT : IN=ens33 OUT= MAC=00:50:56:80:4f:3d:34:56:dc:b7:ec:ec:08:00 SRC=192.168.1.90 DST=192.168.1.88 LEN=52 TOS=0x00 PREC=0x00 TTL=62 ID=59820 DF PROTO=TCP SPT=58616 DPT=22 WINDOW=1078 RES=0x00 ACK URGP=0 
Dec 14 15:57:10 WK-STD kernel: OUTPUT : IN= OUT=ens33 SRC=192.168.1.88 DST=192.168.1.90 LEN=336 TOS=0x10 PREC=0x00 TTL=64 ID=10681 DF PROTO=TCP SPT=22 DPT=58616 WINDOW=524 RES=0x00 ACK PSH URGP=0 
Dec 14 15:57:10 WK-STD kernel: OUTPUT : IN= OUT=ens33 SRC=192.168.1.88 DST=192.168.1.90 LEN=632 TOS=0x10 PREC=0x00 TTL=64 ID=10682 DF PROTO=TCP SPT=22 DPT=58616 WINDOW=524 RES=0x00 ACK PSH URGP=0 
Dec 14 15:57:10 WK-STD kernel: INPUT : IN=ens33 OUT= MAC=00:50:56:80:4f:3d:34:56:dc:b7:ec:ec:08:00 SRC=192.168.1.90 DST=192.168.1.88 LEN=52 TOS=0x00 PREC=0x00 TTL=62 ID=59821 DF PROTO=TCP SPT=58616 DPT=22 WINDOW=1072 RES=0x00 ACK URGP=0 
Dec 14 15:57:11 WK-STD kernel: OUTPUT : IN= OUT=ens33 SRC=192.168.1.88 DST=192.168.1.90 LEN=880 TOS=0x10 PREC=0x00 TTL=64 ID=10683 DF PROTO=TCP SPT=22 DPT=58616 WINDOW=524 RES=0x00 ACK PSH URGP=0 
Dec 14 15:57:11 WK-STD kernel: INPUT : IN=ens33 OUT= MAC=00:50:56:80:4f:3d:34:56:dc:b7:ec:ec:08:00 SRC=192.168.1.90 DST=192.168.1.88 LEN=52 TOS=0x00 PREC=0x00 TTL=62 ID=59822 DF PROTO=TCP SPT=58616 DPT=22 WINDOW=1066 RES=0x00 ACK URGP=0 
Dec 14 15:57:11 WK-STD kernel: OUTPUT : IN= OUT=ens33 SRC=192.168.1.88 DST=192.168.1.90 LEN=632 TOS=0x10 PREC=0x00 TTL=64 ID=10684 DF PROTO=TCP SPT=22 DPT=58616 WINDOW=524 RES=0x00 ACK PSH URGP=0 
Dec 14 15:57:11 WK-STD kernel: INPUT : IN=ens33 OUT= MAC=00:50:56:80:4f:3d:34:56:dc:b7:ec:ec:08:00 SRC=192.168.1.90 DST=192.168.1.88 LEN=52 TOS=0x00 PREC=0x00 TTL=62 ID=59823 DF PROTO=TCP SPT=58616 DPT=22 WINDOW=1062 RES=0x00 ACK URGP=0 

We can see traffic between the addresses 192.168.1.90 (source) and 192.168.1.88 (destination) on the SSH port (22 TCP). In my case, this is perfectly normal, as I'm connected to machine 192.168.1.88 from machine 192.168.1.90 via this protocol. So let's take a look at what needs to be done to authorize this traffic by adding specific rules.

  • By examining the first log entry, we can extract all the information needed to write the corresponding nftables rule:
    • It belongs to the INPUT chain.
    • The source IP address is 192.168.1.88.
    • The destination IP address is 192.168.1.90.
    • The protocol used is TCP.
    • The destination port is 22.
Dec 14 15:57:09 WK-STD kernel: INPUT : IN=ens33 OUT= MAC=00:50:56:80:4f:3d:34:56:dc:b7:ec:ec:08:00 SRC=192.168.1.90 DST=192.168.1.88 LEN=88 TOS=0x00 PREC=0x00 TTL=62 ID=59819 DF PROTO=TCP SPT=58616 DPT=22 WINDOW=1091 RES=0x00 ACK PSH URGP=0
  • We can apply the same analysis to the second log entry, which relates to the OUTPUT chain:
    • It belongs to the OUTPUT chain.
    • The source IP address is 192.168.1.90.
    • The destination IP address is 192.168.1.88.
    • The protocol used is TCP.
    • The source port is 22.
Dec 14 15:57:09 WK-STD kernel: OUTPUT : IN= OUT=ens33 SRC=192.168.1.88 DST=192.168.1.90 LEN=52 TOS=0x10 PREC=0x00 TTL=64 ID=10678 DF PROTO=TCP SPT=22 DPT=58616 WINDOW=524 RES=0x00 ACK URGP=0

Adding the rules

  • Once the analysis is complete, we need to edit the /etc/nftables.conf configuration file to add two new rules—one in the INPUT chain and one in the OUTPUT chain—and insert them just before the existing “log prefix” rules:
#!/usr/bin/nft -f

flush ruleset

# We declare an interface variable associated with our network card so that we can use $interface in our rules instead of "enp2s0".
define interface = { enp2s0 }

# ----- IPv4 and IPv6 -----
table inet filter {
        chain INPUT {
                type filter hook input priority 0; policy drop;

		# Local interface rules
                ct state invalid counter drop comment "Drop invalid connections"
                iif != lo ip daddr 127.0.0.1/8 counter drop comment "drop connections to loopback not coming from loopback"
		iif != lo ip6 daddr ::1/128 counter drop comment "drop connections to loopback not coming from loopback"
                iif lo accept comment "Accept any localhost traffic"

		# ICMP traffic
		ip protocol icmp counter accept comment "accept all ICMP types"
                meta l4proto ipv6-icmp counter accept comment "accept all ICMPv6 types"

		# Accept web traffic (ALL to PC : if related or established) : HTTP + HTTPS + HTTP/3 QUIC + DNS + NTP
		iif $interface tcp sport { http, https, domain, ntp } ct state { related, established } counter accept comment "Accept Web Traffic TCP"
		iif $interface udp sport { https, domain } ct state { related, established } counter accept comment "Accept Web Traffic UDP"

		# NEW RULE
		iif $interface ip saddr 192.168.1.88 ip daddr 192.168.1.90 tcp dport { 22 } accept comment "INPUT SSH"

		# Accept but trace all other INPUT traffic
		log prefix "INPUT : " accept comment "log INPUT"
        }
        chain FORWARD {
                type filter hook forward priority 0; policy drop;

		# FORWARD traffic is dropped and logged. There should be no network frames if the Linux PC is not in router mode.
		log prefix "FORWARD : " drop comment "log FORWARD"
        }
        chain OUTPUT {
                type filter hook output priority 0; policy drop;

		# Local interface rules
                oif lo accept comment "Accept any localhost traffic"

		# ICMP traffic
		ip protocol icmp counter accept comment "accept all ICMP types"
                meta l4proto ipv6-icmp counter accept comment "accept all ICMPv6 types"

		# Accept web traffic (PC to ALL : new, related and established) : HTTP + HTTPS + HTTP/3 QUIC + DNS + NTP
		oif $interface tcp dport { http, https, domain, ntp } ct state { new, related, established } counter accept comment "Accept Web Traffic"
		oif $interface udp dport { https, domain } ct state { new, related, established } counter accept comment "Accept Web Traffic"

		# NEW RULE
		oif $interface ip saddr 192.168.1.90 tcp sport { 22 } ip daddr 192.168.1.88 accept comment "OUTPUT SSH"

		# Accept but trace all other OUTPUT traffic
		log prefix "OUTPUT : " accept comment "log out of rules traffic"
        }
}
  • Next, apply the updated configuration:
root@host:~# nft -f /etc/nftables
  • Run the journalctl command again to verify that no other legitimate traffic is being logged by the “log prefix” rules in the INPUT, OUTPUT, or FORWARD chains:
root@host:~# journalctl -k --grep="INPUT|OUTPUT|FORWARD"

If any legitimate traffic appears, simply add the corresponding nftables rules, as we did earlier for the SSH service.

Finalizing the nftables Configuration

At this point, all necessary rules to allow legitimate traffic should be in place. The next step is to tighten security by changing the behavior of the “log prefix” rules from accept to drop.

  • To do this, open the /etc/nftables.conf file once more and update the relevant rules:
#!/usr/bin/nft -f

flush ruleset

# We declare an interface variable associated with our network card so that we can use $interface in our rules instead of "enp2s0".
define interface = { enp2s0 }

# ----- IPv4 and IPv6 -----
table inet filter {
        chain INPUT {
                type filter hook input priority 0; policy drop;

		# Local interface rules
                ct state invalid counter drop comment "Drop invalid connections"
                iif != lo ip daddr 127.0.0.1/8 counter drop comment "drop connections to loopback not coming from loopback"
		iif != lo ip6 daddr ::1/128 counter drop comment "drop connections to loopback not coming from loopback"
                iif lo accept comment "Accept any localhost traffic"

		# ICMP traffic
		ip protocol icmp counter accept comment "accept all ICMP types"
                meta l4proto ipv6-icmp counter accept comment "accept all ICMPv6 types"

		# Accept web traffic (ALL to PC : if related or established) : HTTP + HTTPS + HTTP/3 QUIC + DNS + NTP
		iif $interface tcp sport { http, https, domain, ntp } ct state { related, established } counter accept comment "Accept Web Traffic TCP"
		iif $interface udp sport { https, domain } ct state { related, established } counter accept comment "Accept Web Traffic UDP"

		# NEW RULE
		iif $interface ip saddr 192.168.1.88 ip daddr 192.168.1.90 tcp dport { 22 } accept comment "INPUT SSH"

		# Accept but trace all other INPUT traffic
		log prefix "INPUT : " drop comment "log INPUT"
        }
        chain FORWARD {
                type filter hook forward priority 0; policy drop;

		# FORWARD traffic is dropped and logged. There should be no network frames if the Linux PC is not in router mode.
		log prefix "FORWARD : " drop comment "log FORWARD"
        }
        chain OUTPUT {
                type filter hook output priority 0; policy drop;

		# Local interface rules
                oif lo accept comment "Accept any localhost traffic"

		# ICMP traffic
		ip protocol icmp counter accept comment "accept all ICMP types"
                meta l4proto ipv6-icmp counter accept comment "accept all ICMPv6 types"

		# Accept web traffic (PC to ALL : new, related and established) : HTTP + HTTPS + HTTP/3 QUIC + DNS + NTP
		oif $interface tcp dport { http, https, domain, ntp } ct state { new, related, established } counter accept comment "Accept Web Traffic"
		oif $interface udp dport { https, domain } ct state { new, related, established } counter accept comment "Accept Web Traffic"

		# NEW RULE
		oif $interface ip saddr 192.168.1.90 tcp sport { 22 } ip daddr 192.168.1.88 accept comment "OUTPUT SSH"

		# Accept but trace all other OUTPUT traffic
		log prefix "OUTPUT : " drop comment "log out of rules traffic"
        }
}
  • Finally, reapply the updated nftables rules:
root@host:~# nft -f /etc/nftables

Your configuration is now secure and fully under control. If any network service fails to work or becomes inaccessible, simply review the logs again using the journalctl command and add the necessary rules—just as we did earlier for the SSH service.