Last updated on 8/8/2025
Nftables are a framework for packet filtering, firewalls and Network Address Translators (NATs). Support for nftables has been in the Linux Kernel since version 3.13. Nfables is the sucessor to iptables. In Debian 10 (buster - July 6, 2019), nftables replaced iptables. nftables has a compatibility mode for iptables.
To run nft commands requires root privillages. You can either prefix all nft commands with sudo or you can change to the root user:
sudo bash
To determine the verson of Nftables:
nft -v
At the top is a text file, /etc/nftables.conf. It contains one or more nftables. Nftables contains one or more chains, and chains contain one or more rules.
All of the nftables do not have to be inside of /etc/nftables.conf. You can include other nftables at the end of the file:
include "/Path/nftable_name.conf"
| # | anything after the hash sign (#) is a comment |
| ; | more commands or parameters to follow |
| \ | break a rule into mutliple lines |
There are six ntable types or address families:
| ip | ip4 addresses - default if none specified |
| ip6 | ip6 addresses |
| inet | both ip4 & ip6 |
| bridge | |
| netdev | ingress filtering |
| arp | Address Resolution Protocol |
Syntax: table [<address_family>] nft_table_name { }
There are two types of chains: base and non-base.
Base chains are the entry point for packets from the networking stack. Base chains specify a type, hook, priority and policy.
Syntax: chain chain_name {type <type> hook <hook> priority <priority>; [policy <policy>;]}
type: filter, route, and nat
priority: -300 to 300
policy: accept or drop
The lower the priority number the higher the priority is. Priority numbers can be negaive.
If no policy is explicitively give the default policy accept will be used.
Non-base chains are jump target to better organize rules. Non-base type chains do not have a type, hook and priority.
Rules are some expression to be matched followed by a verdict statement.
The following is an incomplet list of expressions that can be matched:
| Common IPv4 Matches | ||
|---|---|---|
| saddr <ip source address> | Source addresses | ip saddr 192.168.1.0 ip saddr 192.168.1.0/24 |
| daddr <ip destination address> | Destination addresses | ip daddr 192.168.1.0 ip daddr 192.168.1.0/24 |
| protocol <protocol> | Upper layer protocol | ip protocol tcp ip protocol udp ip protocol icmp |
| Common TCP Matches | ||
|---|---|---|
| sport <tcp source port> | Source port | tcp sport 22 tcp sport ssh |
| dport <tcp destination port> | Destination port | tcp dport 22 tcp dport ssh |
| Common UDP Matches | ||
|---|---|---|
| sport <udp source port> | Source port | udp sport 22 udp sport ssh |
| dport <udp destination port> | Destination port | udp dport 22 udp dport ssh |
| Common Meta Matches | ||
|---|---|---|
| iifname <input interface name> | Input interface name | meta iifname "lo" meta iifname "eth0" |
| oifname <Output interface name> | Outout interface name | meta oifname "lo" meta oifname "eth0" |
| iff <input interface index> | input interface index | meta iif lo meta iff eth0 |
| oif <Ouput interface index> | output interface index | meta oif lo meta oif eth0 |
| ifftype <input interface type> | input interface type | meta iiftype loopback meta iiftype ether meta iiftype ipip meta iiftype ipip6 |
| oif <Ouput interface type> | output interface type | meta oiftype loopback meta oiftype ether meta oiftype ipip meta oiftype ipip6 |
* The prefix meta before the meta key is optional. See meta expressions - types qualified and unqualified in manpage.[]
| Common Ether Matches | ||
|---|---|---|
| saddr <mac address> | Source MAC address | either saddr 00:0f:54:0c:11:04 |
Verdict statements include:
The follow is an ntable that accepts all IPv4 traffic with input source address 192.168.37.52, and its drops all other inputs, including all IPv6 traffic.
table inet FILTER {
chain INPUT {
type filter hook input priority 0; policy accept;
ip saddr 192.168.37.52 accept
drop
}
}
There are very few good tutorials on nftables. Nftables just does not have the same amount of documentation that iptables has. The nftables organization's (nftables.org) documentation is good; however, it is not a begginers guide. They want to show off their latest advances over iptables and how concise and succinct they can be. They have a simple ruleset for a home router [13], but it uses the concatenation operator (.) [14], vmap [15], and th (transport header) [16]. You have to dig through their documention to learn what these are. In the end, I found out that the th parameter requires both nftables 0.92 and Linux Kernnel 5.3. As of 11-29-22, the Raspberry Pi OS-64 still uses kernnel 5.15.
tcp dport 22 accept
Most all of these commands require root privilages. To become the root in the Raspberry Pi OS:
sudo bash
Notice that prompt changes to the hash sign (#), and that
whoami
says "root".
To determine if the the modulel is in the Linux Kernel:
modinfo nf_tables
To determine if nftables is installed, check the version:
nft -v
To determine if nftables is active
systemctl status nftables
My general purpose everyday RPi has firewald and fail2band installed. The "system status nftable" command say that it is unactive. However, the "nft list ruleset" command shows a table with the table_name firewalld. I do not understand this.
Raspberry Pi has everthing installed, but by default it is unactive.
For the current session, you can start or stop nftables (not persistent), with the following commands.
systemctl start nftables
systemctl stop nftables
To enabled or disabled nftables after a reboot (persistent), use one of the following commands:
systemctl enable nftables
systemctl mask nftables
To see errors:
systemctl status nftables
By defination, Nftables does not have defaults chains (like iptables). However, some Linux distributions have a default configuration file, which contains predefined chains. Also, some distrubtion have example nft script files. See table below:
| Distribution | Config. Files | Script Files |
|---|---|---|
| Debian Raspberry Pi |
/etc/nftables.conf | /usr/share/doc/nftables/examples/ |
| Red Hat Fedora |
/etc/sysconfig/nftables.conf | /etc/nftables/ |
Debian's default configuration file, /etc/nftables.conf, which is listed below, contains three chains, an input chain, a forward chain, and an output chain. It accepts everything. It is just a skelton that can be added to.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter;
}
chain forward {
type filter hook forward priority filter;
}
chain output {
type filter hook output priority filter;
}
}
There are three ways to edit or create nftables.
The easest method, is to create and debug a script file wih method 3C, and to include the script file in the configuration file. Note, the configuration file can be just a single include statement.
Nft script files can have any name, but it is customary for them to have the .nft extension.
If you are going to pass a nft script file to the nft utility, it must have the folowing header:
#!/usr/sbin/nft -f
If you want to flush the existing ruleset then the next line after the header should be:
flush ruleset
To pass a script to the nft utility, enter:
nft -f your_script_filename.nft
To run a script directly, you have to make the script executable:
chmod +x your_script_filename.nft
In debugging nftables, the following command my be useful:
systemctl status nftables
Other than the extension, location and that the fact that the nftables.conf is ran at boot, there are no differences between the nftables.conf file and nftables script files.
To view all chains and rules in all tables:
nft list ruleset
To view chains and rules in a particuar table:
nft list table address_family table_name
To view the rules in a particuar chain:
nft list chain address_family table_name chain_name
Although you should never use firewalld and nftables together, you can use the nft utility to the view the nftables and rules generatated by firewalld:
nft list table inet firewalld
nft list table ip firewalld
nft list table ip6 firewalld
| # | anything after the hash sign (#) is a comment |
| ; | more commands or parameters to follow |
| \ | break a rule into mutliple lines |
Address Families
| ip | ip4 addresses |
| ip6 | ip6 addresses |
| inet | both ip4 & ip6 |
| bridge | |
| netdev | ingress filtering |
| arp |
Both iftables and iptables have chains with policy (accept/drop) and rules.
Both nf and ip table can filter:
| prerouting | |
| input | |
| forward | |
| output | |
| postrouting | |
| arp |
nft -v # version
/////////
sudo nft add table [address_famaily] example_table
sudo nft delete table [address_famaily] example_table
sudo nft flush table [address_famaily] example_table
The flush command deletes every rule in every chain in the table
sudo nft add chain inet example_table example_chain '{type filter hook input priority 0; }'
| base chain | ||
| type | is | filter |
| hook | is | input |
| priority | is | 0 |
Chains with lower priority numbers get procesed first.
Non-base chains aslo refered to as regular chains, do not have type, hook and priority
| saddr | = | source address | e.g., 192.168.0.0/24 |
| daddr | = | destination address | e.g., 192.168.0.2-192.168.0.59 |
| iifname | = | input interface name | e.g., "eth0", "wlan0" |
| oifname | = | output interface name | e.g., "eth0", "wlan0" |
| ct | = | connection tracking |
Tables have chains, and chains have rules. A table can have more than one chain, and a chain can have more than one rule.
Common port aliases are:
| Port No. | Alias |
|---|---|
| 22 | ssh |
| 80 | http |
| 443 | https |
| 53 | domain (dns) |
To list all aliases: cat /etc/services.
| Priority No.| | Alias |
|---|---|
| 0 | filter |
I am not a fan of using keywords as aliases e.g., a type of rule is a filter (keyword), but the word "filter" can also be used as an alias for the rule priority number 0, and when they are in the same line, this makes it confusing for newcomers.
Example 1A: Only allow incoming from 192.168.37.52
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
# Allow all traffic from 192.168.37.52 - Don't lock myself out
ip saddr 192.168.37.52 accept
# Drop everything else
drop
}
}
Examble 1 does not allow loopback traffic that some services require. Modify it to allow loopback traffic.
Example 2:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
# Allow loopback traffic from this computer
meta iif lo accept
# Drop loopback traffic not from this computer
iif != lo ip daddr 127.0.0.1/8 drop # IPv4
iff != lo ip6 daddr ::1/128 drop # IPv6
# Allow all traffic from 192.168.37.52 - Don't lock myself out
ip saddr 192.168.37.52 accept
# Drop everything else
drop
}
}
References:
Example 1A: Allow all incoming tcp traffic to ports 22 (ssh) and 5900 (VNC).
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
# Don't lock myself out
ip saddr 192.168.37.52 accept
# Allow SSH traffic
# matched by tcp and destination port 22 (alias ssh)
tcp dport ssh accept
# Allow VNC traffic
tcp dport 5900 accept # no alias for this port
# Drop everything else
drop
}
}
I am not a fan of keywords and/or aliases having multiple meaning. However, this seems to be the norm with nftable. For example, a type of chain is a "filter" (keyword), and "filter" is also used as an alias for the priority number 0. Furthermore, in this example the table name is "filter", and the chain name is "input", which is a type of hook (keyword). This is very confusing for newcomers.
Example 1B: To determine if this is working, you can add counters to count the number of packets and bytes. Counters should be paced immediately before accept or drop.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
# Don't lock myself out
ip saddr 192.168.37.52 accept
# Allow SSH traffic
# matched by tcp and destination port 22 (alias ssh)
tcp dport ssh counter accept
# Allow VNC traffic
tcp dport 5900 counter accept # no alias for this port
# Drop everything else
drop
}
}
You can also defined named counters. Modify example 1a by naming the counter.
#!/usr/sbin/nft -f
flush ruleset
counter ssh_or_Vnc {}
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
# Don't lock myself out
ip saddr 192.168.37.52 accept
# Allow SSH traffic
# matched by tcp and destination port 22 (alias ssh)
tcp dport ssh counter name ssh_or_vnc accept
# Allow VNC traffic
tcp dport 5900 counter name ssh_or_vnc accept
# Drop everything else
drop
}
}
Example 1C: Modify Example 1B to allow SSH and VNC traffic only from source addresses 192.168.37.45 and 192.168.37.45.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
# Don't lock myself out
ip saddr 192.168.37.52 accept
# Allow SSH traffic
# matched by tcp and destination port 22 (alias ssh)
ip saddr 192.168.37.45 tcp dport ssh counter accept
ip saddr 192.168.37.46 tcp dport ssh counter accept
# Allow VNC traffic
ip saddr 192.168.37.45 tcp dport 5900 counter accept
ip saddr 192.168.37.46 tcp dport 5900 counter accept
# Drop everything else
drop
}
}
To use MAC addresses in lieu of IP addresses, prefix saddr with ether. For example:
ether saddr 04:0D:0A:3D:FE:06 tcp dport ssh counter accept
Sets are enclosed in brackets, and the elements are separated by commas. You can use sets to combine rules.
Example 1D: Modify Example 1C using sets.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
# Don't lock myself out
ip saddr 192.168.37.52 accept
# Allow SSH and VNC traffic
# matched by source address and tcp protocol with destination port 22 or 5900.
ip saddr {192.168.37.45, 192.168.37.46} tcp dport {ssh, 5900} counter accept
# Drop everything else
drop
}
}
Example 1E: Modify Example 1D to use named sets.
#!/usr/sbin/nft -f
flush ruleset
# set_example.nft
table inet filter {
set allowable_ip_addresses{
typeof ip saddr
elements = {192.168.37.45, 192.168.37.46}
}
set allowable_dports{
typeof tcp dport
elements = {ssh, 5900}
}
chain input {
type filter hook input priority filter;
# Don't lock myself out
ip saddr 192.168.37.52 accept
# Only allow SSH & VNC traffic
# matched by source IP and tcp destination ports
ip saddr @allowable_ip_addresses tcp dport @allowable_dports accept
# Drop everything else
drop
}
}
Having to declare named sets in advance is more complicated than in-line sets. However, we have just touched on named sets. Named sets are more powerful than in-line sets. For example, as will be in "Advanced Named Sets", you can use nft rules to add elements to named sets.
For troubleshooting, you should accept pings but limit them
Example 1F: Modify Example 1E to accept pings.
#!/usr/sbin/nft -f
flush ruleset
# set_example.nft
table inet filter {
set allowable_ip_addresses{
typeof ip saddr
elements = {192.168.37.45, 192.168.37.46}
}
set allowable_dports{
typeof tcp dport
elements = {ssh, 5900}
}
chain input {
type filter hook input priority filter;
# Don't lock myself out
ip saddr 192.168.37.52 accept
# Only allow SSH & VNC traffic
# matched by source IP and tcp destination ports
ip saddr @allowable_ip_addresses tcp dport @allowable_dports accept
# allow pings without limits.
ip protocol icmp accept;
# Drop everything else
drop
}
}
Example 1G: Modify Example 1F to limit pings.
#!/usr/sbin/nft -f
flush ruleset
# set_example.nft
table inet filter {
set allowable_ip_addresses{
typeof ip saddr
elements = {192.168.37.45, 192.168.37.46}
}
set allowable_dports{
typeof tcp dport
elements = {ssh, 5900}
}
chain input {
type filter hook input priority filter;
# Don't lock myself out
ip saddr 192.168.37.52 accept
# Only allow SSH & VNC traffic
# matched by source IP and tcp destination ports
ip saddr @allowable_ip_addresses tcp dport @allowable_dports accept
# allow only 1 ping per second
ip protocol icmp limit rate 1/second accept;
# Drop everything else
drop
}
}
Thus far, all our chains have been "base" chains. Non-base chains do NOT have a type, hook, priority or default policy. They only contain rules. They are used to group your rules according to some criteria. Non-based chains are the targets of jump or goto actions.
Example 2A: Organize you rules according to the basic protocol (tcp, udp, icmp).
#!/usr/sbin/nft -f
flush ruleset
table ip filter {
set allowable_ip_addresses {
typeof ip saddr
elements = {192.168.37.45, 192.168.37.46}
}
set allowable_dports{
typeof tcp dport
elements = {ssh, 5900}
}
chain INPUT {
type filter hook input priority filter; policy accept;
# don't lock myself out
ip saddr 192.168.37.52 accept
# divide the traffic according to protocol
ip protocol tcp jump CHAIN_TCP
ip protocol udp jump CHAIN_UDP
ip protocol icmp jump CHAIN_ICMP
drop
}
chain CHAIN_TCP {
ip saddr @allowable_ip_addresses tcp dport @allowable_dports accept
}
chain CHAIN_UDP {
drop
}
chain CHAIN_ICMP {
ip protocol icmp limit rate 1/second accept
}
}
When your rules have criteria on the left and actions on the right, you can use verdict maps to consolidate rules.
#!/usr/sbin/nft -f
flush ruleset
table ip filter {
set allowable_ip_addresses {
typeof ip saddr
elements = {192.168.37.45, 192.168.37.46}
}
set allowable_dports{
typeof tcp dport
elements = {ssh, 5900}
}
chain INPUT {
type filter hook input priority filter; policy accept;
# don't look myself out
ip saddr 192.168.37.52 accept
# divide the traffic according to protocol
ip protocol vmap {
tcp : jump CHAIN_TCP,
udp : jump CHAIN_UDP,
icmp: jump CHAIN_ICMP
}
drop
}
chain CHAIN_TCP {
ip saddr @allowable_ip_addresses tcp dport @allowable_dports accept
}
chain CHAIN_UDP {
counter drop
}
chain CHAIN_ICMP {
ip protocol icmp limit rate 1/second accept
}
}
TCP has connection tracking (ct) states. UDP does NOT have connection tracking states [1].
There are five (5) conntrack (ct) states [4]:
| new | Netfilter has so far seen packets between this pair of hosts only in one direction. At least on of theses packets is part of a valid initialization sequence,e.g. SYN packet for a TCP Connection. |
| established | Netfilter has seen valid packets travel in both directions between this pair of hosts. For TCP connections, the three-way-handshake has been successfully completed. |
| related | This connection was initiated after the main connection, as expected from normal operation of the main connection. A common example is an FTP data channel established at the behest of an FTB control channel. |
| invalid | Assigned to packets that do not follow the expected behavior of a connection. These include malformed, corrupted packets and maliceware. Another example, would be a late TCP packet that is received after retransmission has been performed [3]. These should be dropped. |
| untracked | Dummy state assigned to packets. |
The following example uses connection tracking to only allow outbound DNS, HTTP, and HTTPS traffic and only allow inbound return traffic and loop back traffic.
#!/usr/bin/nft -f
flush ruleset
table ip transport {
chain INPUT {
type filter hook input priority filter; policy drop;
ct state established,related counter accept
iif lo ip daddr 127.0.0.1/8 counter accept
}
chain OUTPUT {
type filter hook output priority filter; policy drop;
# allow established and existing traffic
ct state established,related counter accept
# allow DNS out
udp dport 53 ct state new counter accept
tcp dport 53 ct state new counter accept
# allow HTTP/HTTS out
tcp dport { 80, 443 } ct state new counter accept
udp dport { 80, 443 } ct state new counter accept
# drop everyting else
}
}
The reasons for the two DNS statements and the two HTTP/HTTPS statements are:
Altought, tcp and udp are seperate protocols with their own headers, the raw location of dport is the same in both headers. This allows merging of dport statements that only differ in whether the protocol is tcp or udp.
For example the two statements:
tcp dport 53 accept
upd dport 53 accept
Can be replaced by the single statement:
meta l4proto {tcp,udp} th dport 53 accept
The first part of the statement, meta l4proto {tcp, udp}, ensures that it is a level 4 protocol - either tcp or udp and excludes other level 4 protocols. The second part of the statement, th dport 53, does a raw read of the transport header (th) and checks to see if dport matches 53.
With the transport header raw read feature, four lines in the previous example can be condensed to one line:
#!/usr/bin/nft -f
flush ruleset
table ip transport {
chain INPUT {
type filter hook input priority filter; policy drop;
ct state established,related counter accept
iif lo ip daddr 127.0.0.1/8 counter accept
}
chain OUTPUT {
type filter hook output priority filter; policy drop;
# allow established and existing traffic
ct state established,related counter accept
# allow DNS, HTTP and HTTP out
meta l4proto {udp,tcp} th dport { 53, 80, 443 } ct state new counter accept
# drop everyting else
}
}
This should be more efficent and faster than executng four sepearate tests.
References:
You can use variables:
define accepted_input_ports = {22,5900}
tcp dport $accepted_input_ports accept
As previously stated, you can used nft rules to add elements to a named set. The example below illustrates this and the use of timers.
Example 5 - If an IP address attemps to SSH into a machine more than twice in one minute, the IP address is locked out for 24 hours. This is the last example in referencet [1]. The code is below:
#!/usr/sbin/nft -f
flush ruleset
table ip filter {
set stage1 {
typeof ip saddr
flags timeout
}
set stage2 {
typeof ip saddr
flags timeout
}
set stage3 {
typeof ip saddr
flags timeout
}
chain input {
type filter hook input priority filter; policy accept;
# allowed established and related traffic
ct state related,established accept
# do not lock yourself out
ct state new ip saddr 172.27.96.76 tcp dport 22 accept
# To understand the lockout code below, read it in the order of the comments numbers
ct state new ip saddr @stage2 tcp dport 22 add @stage3 { ip saddr timeout 1d } #3
ct state new ip saddr @stage1 tcp dport 22 add @stage2 { ip saddr timeout 1m } #2
ct state new tcp dport 22 add @stage1 { ip saddr timeout 1m } #1
ct state new ip saddr @stage3 tcp dport 22 drop #4
}
}
You can test this by trying to SSH into the machine, and when prompted for the password, type in an incorrect passwords three times. Then try to SSH into the machine a second time. Again type in an incorrect password three times. If you attempt to SSH into the machine for a third time, it wil NOT prompt you for a password. You are now locked out for 24 hours.
Thus if you know nftables, you do not need a program such as fail2ban that has dependaces.
You do not need to lock an IP address out for one day to prevent a brute force attach. Five minutes is probably sufficent. I belive the one day is so you can see that someone tried to attach this machine.
There is a Red Hat example that claims to limit login attempts. See reference [2]. The code us below:
#!/usr/sbin/nft -f
flush ruleset
table ip filter {
set denylist {
type ipv4_addr
flags dynamic, timeout; timeout 5m;
}
chain input {
type filter hook input priority 0;
# I changed the rate from over 10/min to over 2/min for testing
# I am not sure what "untracked" does, if anything, in the statement below.
ip protocol tcp ct state new,untracked limit rate over 2/minute add @denylist {ip saddr}
ip saddr @denylist drop
}
}
When you attemp to remotely SSH into a machine, you usally get three (3) attemps to type in the correct password, before you have to SSH into the machine again. I was not able to type an incorrect password fast enought for my IP address to be placed in the denylist. However, when prompted for the password, you can hit Control-C, and then you will have to SSH again into the machine. This method took six (6) attemps before my IP address was put into the denylist. On at least one occassion it took seven (7).
The Youtube video in reference [3], uses the same code except he changed the rate to 3/minute. It took him seven (7) attemps before his IP address was placed into the denylist.
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
set ssh_clients {
type ipv4_addr
flags timeout
}
chain input {
type filter hook input priority filter; policy accept;
# do not lock myself out
ct state new ip saddr 192.168.37.45 tcp dport 22 accept
# accept loopback trafic
iif "lo" accept
# allowed established and related traffic
ct state related,established accept
tcp dport 10 add @ssh_clients {ip saddr timeout 30s}
ip saddr @ssh_clients tcp dport ssh accept
drop
}
}
#!/usr/sbin/nft -f
flush ruleset
table ip filter {
# 11monitor.nft - version 11
#
# The following code helps you to idenify tcp, udp and icmp traffic
# on your local area network.
#
# For now (7-22-2022), I have decided to implement this only for IPv4 traffic
# and to drop, all IPv6 traffic.
set log_tcp_traffic { typeof ip saddr . tcp sport . ip daddr . tcp dport; }
set log_udp_traffic { typeof ip daddr . udp dport . ip saddr . udp sport; }
set log_ct_established_tcp { typeof ip daddr . tcp dport . ip saddr . tcp sport; }
set log_ct_established_udp { typeof ip daddr . udp dport . ip saddr . udp sport; }
set log_ct_invalid { typeof ip saddr; }
set log_loopback_traffic { typeof ip saddr; }
set log_loopback_bad { typeof ip saddr; }
set log_pings { typeof ip saddr; }
set log_dropped { typeof ip saddr; }
chain input {
type filter hook input priority filter; policy accept;
#log source ip of all input
add @log_tcp_traffic { ip daddr . tcp dport . ip saddr . tcp sport}
add @log_udp_traffic { ip daddr . udp dport . ip saddr . udp sport }
# do not lock myself out - allow local network
ct state new ip saddr 192.168.37.0/24 accept
# allowed established and related traffic
ct state related,established add @log_ct_established_tcp \
{ip daddr . tcp dport . ip saddr . tcp sport} accept
ct state related,established add @log_ct_established_udp \
{ip daddr . udp dport . ip saddr . udp sport} accept
ct state invalid add @log_ct_invalid {ip saddr} drop
# accept loopback trafic
iif "lo" add @log_loopback_traffic {ip saddr} accept
# loopback bad - I am currently working on this
# iff != lo ip daddr 127.0.0.1/8 add @loopback_bad {ip saddr} drop
# iff != lo ip6 daddr ::1/128 drop
# allow pings but limit the rate
ip protocol icmp limit rate 1/second add @log_pings {ip saddr} accept
# log dropped traffic
add @log_dropped {ip saddr} drop
}
chain forward {type filter hook forward priority filter; policy drop; }
chain output { type filter hook output priority filter; policy accept; }
}
# Drop all IPv6 Traffic:
table ip6 filter {
chain input { type filter hook forward priority filter; policy drop; }
chain forward { type filter hook forward priority filter; policy drop; }
chain output { type filter hook output priority filter; policy drop; }
}
References:
systemctl start nftables
nft list ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
}
chain forward {
type filter hook forward priority filter; policy accept;
# only routers should forward packets
counter drop
}
chain output {
type filter hook output priority filter; policy accept;
}
-- -- -- -- -- -- -- -- -- -- --
To stop nftables from doing anything, just drop all the rules:
nft flush ruleset
To uninstall it and purge any traces of nftables in your system:
aptitude purge nftables
///////////////////////////
modinfo nf_tables
lsmod | grep nf_tables
///////////////////////
iptables -F
ip6tables -F
I wanted to lock down a Raspberry Pi and only allow https traffice (443) out to a specfic IP address or domain and only accept input traffic that was only established or related (connection tracking state).
What I learned was the Raspberry Pi uses plain http (80) for updating the OS.
I also learned that you can prevent checking for updates on boot and checking for updates every 24 hours by removing the updater from the taskbar. To do this righ click on the taskbar, click on the updater and remove it.
The NTP uses UDP port 123
If your Raspberry Pi has a real time clock, you can disable NTP traffic with the command [8][9]:
timedatectl set-ntp false
To re-enable NTP:
timedatectl set-ntp true
For updating the OS and installing packages (software), Debian uses the Advanced Package Tool (APT).
The Debian repository listing are at:
/etc/apt/sources.list
For Debian 12 Bookworm, they are:
http://deb.debian.org/debian bookworm main contrib non-free non-free-firmware
http://deb.debian.org/debian-security/ bookworm-security main contrib non-free non-free-firmware
http://deb.debian.org/debian bookworm-updates main contrib non-free non-free-firmware
The IP addresses for these url's must pass through your firewall.
You can use dnschecker.org or other internet tools to find the IP addresses [2][3]. They are:
151.101.2.132, 151.101.66.132, 151.101.130.132, 151.101.194.132
The repository listing for Debian derivatites and other packages are in the directory:
/etc/apt/sources.list.d
I am runing Raspberry Pi OS (Bookworm), and the entry is reaspi.list The url for this is:
http://archive.raspberrypi.com/debian/ bookworm main
and the IP addresses for this single url are:
176.126.240.167, 176.126.243.6, 46.235.231.151, 46.235.231.111, 93.93.135.118, 93.93.135.141, 93.93.135.117, 176.126.240.86, 176.126.240.84, 46.235.231.145, 176.126.243.5, 176.126.243.3
The IP addresses for the Raspberry Pi are hosted by Mystic Beast LTD in the UK.
In my /etc/apt/soures.list.d directory, there is also an entry for NordVPN: nordvpn-app.list. The url for this is:
https://repo.nordvpn.com/deb/nordvpn/debian stable main
and the IP addresses for this url are:
104.19.159.190, 104.16.208.203
To recieve OS updates and install new packages (software) all of these IP addresses must pass through all firewalls.
Hit:1 http://deb.debian.org/debian bookworm InRelease
Get:2 http://deb.debian.org/debian-security bookworm-security InRelease [48.0 kB]
Get:3 http://deb.debian.org/debian bookworm-updates InRelease [55.4 kB]
Hit:4 https://repo.nordvpn.com/deb/nordvpn/debian stable InRelease
Get:5 http://archive.raspberrypi.com/debian bookworm InRelease [55.0 kB]
Get:6 http://deb.debian.org/debian-security bookworm-security/main armhf Packages [253 kB]
Get:7 http://deb.debian.org/debian-security bookworm-security/main arm64 Packages [268 kB]
Get:8 http://deb.debian.org/debian-security bookworm-security/main Translation-en [163 kB]
Get:9 http://archive.raspberrypi.com/debian bookworm/main armhf Packages [547 kB]
Get:10 http://archive.raspberrypi.com/debian bookworm/main arm64 Packages [547 kB]
The Raspberry Pi Zero 2W only has 512 MB (1/5 GB) of RAM memory. This is not large enough to run a modern web browser. It is primarily used without a GUI.
Below is a skeleton nftable for locking down a raspberry Pi Zero 2W. Note that https traffic is blocked.
#!/usr/bin/nft -f
# Pi Zero 2W - CUPS Print Server - lockdown updated on 2025-08-08
flush ruleset
table ip zero31 {
# ----------------------------------------------------------------
# Customized the sets below for your network
define This_Host = 192.168.0.31
set ssh_allow_in {
typeof ip saddr
elements = { 192.168.0.70, 192.168.0.71 }
}
set ping_allow_in {
typeof ip saddr
elements = { 192.168.0.70, 192.168.0.34 }
}
set allow_printing_from {
typeof ip saddr
elements = { 192.168.0.70, 192.168.0.75 }
}
# Customize the sets below for your OS
set update_debian_bookworm {
typeof ip daddr
elements = { 151.101.2.132, 151.101.66.132, 151.101.130.132,
151.101.194.132, 146.75.94.132, 199.232.66.132,
151.101.46.132, 146.75.126.132, 199.232.90.132,
151.101.18.132, 146.75.106.132, 151.101.22.132,
151.101.114.132, 199.232.98.132, 199.232.38.132,
146.75.42.132, 151.101.162.132, 151.101.202.132,
146.75.78.132, 151.101.134.132, 151.101.14.132,
151.101.74.132, 151.101.50.132, 151.101.250.132,
146.75.122.132
}
}
set update_pi_bookworm {
typeof ip daddr
elements = { 176.126.240.167, 176.126.243.6, 46.235.231.151,
46.235.231.111, 93.93.135.118, 93.93.135.141,
93.93.135.117, 176.126.240.86, 176.126.240.84,
46.235.231.145, 176.126.243.5, 176.126.243.3
}
}
#--------------------- End Customization ---------------------------
set unknown_udp {typeof ip saddr . udp sport . ip daddr . udp dport ; }
set unknown_tcp {typeof ip saddr . tcp sport . ip daddr . tcp dport ; }
set unknown { typeof ip saddr . ip daddr ; }
chain INPUT {
type filter hook input priority filter; policy drop;
# establlihed traffice
ct state established,related counter accept
# local traffice
iif lo ip daddr 127.0.0.1/8 counter accept
# allow only certain ip addresses to ping and limit rate
ip saddr @ping_allow_in icmp type echo-request limit rate 5/second accept;
# allow only certain ip addresses to SSH into this machine
ip saddr @ssh_allow_in tcp dport ssh accept
# allow only certain ip addresses to print
ip saddr @allow_printing_from ip daddr $This_Host tcp dport 631 accept
drop # everyting else
}
chain OUTPUT {
type filter hook output priority filter; policy drop;
# allow established and existing traffic
ct state established,related counter accept
# allow DNS out - need for OS updates
udp dport 53 ct state new counter accept
tcp dport 53 ct state new counter accept
# Debian Updates
tcp dport 80 ip daddr @update_debian_bookworm counter accept
# Raspberry Pi Updates
tcp dport 80 ip daddr @update_pi_bookworm counter accept
# Network Time Protocol
udp dport 123 counter accept
# Multicast DNS
udp dport 5353 counter drop
# WS-Discovery (Web Services) used by HP Printers [1]
ip daddr 239.255.255.250 udp dport 3702 counter drop
# Internet Group Management Protocol
ip protocol igmp counter drop
# drop unkown udp
udp dport {1-65535} add @unknown_udp {ip saddr . udp sport . ip daddr . udp dport} \
counter drop
# drop unkown tcp
tcp dport {1-65535} add @unknown_tcp {ip saddr . tcp sport . ip daddr . tcp dport} \
counter drop
# Drop Everything Else - non-tcp/udp
add @unknown {ip saddr . ip daddr} counter drop
}
}
Nordvpn 4.0 still uses iptables. However, Nordvpn 4 is the first version that I observed that plays nicely with nftables. That is, all of the previous version flushed all of the nft tables and rules.
NordVPN/Lynx is based on WireGuard, which only uses one port [4]: UDP 51280. Well, it may also use tcp port 8884. See Port 8884 sub-section below.
Nordvpn implementation of OpenVPN uses the following ports:[4]
According to Wikipeda [2], NordVPN no longer uses the following porotocls: L2TP, IPSec and PPTP
As stated in reference [5] NordVPN does use tcp port 8884. I assume the reference is correct in stating that NordLynx uses tcp port 8884. I trapped the port using nftables, and when I unstalled NordVPN, I had no more uses of tcp port 8884.
#!/usr/bin/nft -f
# lockdown4c.nft
# Last Updated on 7/29/2025
#
# Investigate 192.168.37.112 and 192.168.37.113,
# these showed up on test machine 54 before I removed avahi
# these ipv4 addresses do not respond to pings.
#
# Add a black list set - everything above 100..254
# Windows 11 PC 90 or 91, iphone 80 or 81 etc.
#
# Change to "accept NTP protocol" need to update OS.
#
# Add use NordVPN DNS Servers
#
# The NetBIOS may be need for Samba File Sharring but I want to block here!
table ip fb {
set update_debian {
typeof ip daddr
elements = { 151.101.2.132, 151.101.66.132, 151.101.130.132,
151.101.194.132, 146.75.94.132, 199.232.66.132,
151.101.46.132, 146.75.126.132, 199.232.90.132,
151.101.18.132, 146.75.106.132, 151.101.22.132,
151.101.114.132, 199.232.98.132, 199.232.38.132,
146.75.42.132, 151.101.162.132, 151.101.202.132,
146.75.78.132, 151.101.134.132, 151.101.14.132,
151.101.74.132, 151.101.50.132, 151.101.250.132
}
}
set update_pi {
typeof ip daddr
elements = { 176.126.240.167, 176.126.243.6, 46.235.231.151,
46.235.231.111, 93.93.135.118, 93.93.135.141,
93.93.135.117, 176.126.240.86, 176.126.240.84,
46.235.231.145, 176.126.243.5, 176.126.243.3
}
}
set update_nordvpn {
typeof ip daddr
elements = { 104.19.159.190, 104.16.208.203 }
}
set input_dropped_udp { typeof ip saddr . udp sport . ip daddr . udp dport; }
set input_dropped_tcp { typeof ip saddr . tcp sport . ip daddr . tcp dport; }
set input_dropped_dropbox { typeof ip saddr . udp sport . ip daddr . udp dport; }
set input_dropped_scansnap { typeof ip saddr . udp sport . ip daddr . udp dport; }
set input_dropped_mDNS { typeof ip saddr . udp sport . ip daddr . udp dport; }
set input_samba_NetBIOS { typeof ip saddr . udp sport . ip daddr . udp dport; }
set output_dropped_ntp { typeof ip saddr . udp sport . ip daddr . udp dport; }
set output_dropped_mDNS { typeof ip saddr . udp sport . ip daddr . udp dport; }
set dropped_tcp_http { typeof ip saddr . tcp sport . ip daddr . tcp dport; }
set dropped_udp_http { typeof ip saddr . udp sport . ip daddr . udp dport; }
set dropped_input_tcp { typeof ip saddr . tcp sport . ip daddr . tcp dport; }
set output_https_tcp { typeof ip saddr . tcp sport . ip daddr . tcp dport; }
set vlc_tcp_drop { typeof ip saddr . tcp sport . ip daddr . tcp dport; }
set vlc_udp_drop { typeof ip saddr . udp sport . ip daddr . udp dport; }
set unknown_protocol { typeof ip saddr . tcp sport . ip daddr . tcp dport; }
set unknown_tcp { typeof ip saddr . tcp sport . ip daddr . tcp dport; }
chain INPUT {
type filter hook input priority filter; policy drop;
meta iif lo counter accept
ct state established,related counter accept
ct state invalid counter drop
# To accept ssh input, uncomment the next 2 lines
tcp dport 22 counter accept
udp dport 22 counter accept
# To accept vnc input, uncomment the next 2 lines
tcp dport 5900 counter accept
udp dport 5900 counter accept
# Internet Printing Protocol
tcp sport 631 accept
tcp dport 631 accept
# To accept Dropbox input, in the the line below change drop to accept
udp dport 17500 counter drop
# Drop Fujitsu's Scansnap - Page Scanner
udp dport 52217 counter drop
# To accept Multicast DNS, change drop to accept
udp dport 5353 add @input_dropped_mDNS \
{ ip saddr . udp sport . ip daddr . udp dport } counter drop
# To accept NetBIOS, change drop to accept
udp dport {137,138} add @input_samba_NetBIOS \
{ ip saddr . udp sport . ip daddr . udp dport } counter accept
ip protocol tcp add @input_dropped_tcp \
{ ip saddr . tcp sport . ip daddr . tcp dport } counter drop
ip protocol udp add @input_dropped_udp \
{ ip saddr . udp sport . ip daddr . udp dport } counter drop
# ping
ip protocol icmp counter drop
# Drop Unknown Input
counter drop
} # close INPUT chain
chain OUTPUT {
type filter hook output priority filter; policy drop;
# Accept List
# Debian Updates
tcp dport 80 ip daddr @update_debian counter accept
# Raspberry Pi Updates
tcp dport 80 ip daddr @update_pi counter accept
# NordVPN Updates
tcp dport 80 ip daddr @update_nordvpn counter accept
# Nordvpn-NordLynx
udp dport 51820 counter accept
tcp dport 8884 counter accept # Require but not documented
# NordVPN OpenVPN
udp dport 1194 counter accept
udp sport 1194 counter accept
# Network Time Protocol
udp dport 123 counter accept
# Secure Shell Protocol ssh
tcp dport 22 counter accept
udp dport 22 counter accept
# NetBIOS
tcp dport 139 counter accept
# SMB Protocol
tcp dport 445 counter accept
# VNC Protocol
tcp dport 5900 counter accept
udp dport 5900 counter accept
# Domain Name Servers
udp dport 53 counter accept
tcp dport 53 counter accept
# Pi-Hole
ip daddr 192.168.37.32 tcp dport 80 counter accept
# Should ct state new be added to the https below?
#
# accept https
tcp dport 443 counter add @output_https_tcp \
{ ip saddr . tcp sport . ip daddr . tcp dport } accept
udp dport 443 counter accept
# Local Traffic
oif lo counter accept
iif lo counter accept
# Establishd Traffic
ct state established, related counter accept
# Dropped List:
# ICMP Protocol
ip protocol icmp counter accept
# Multicast DNS
udp dport 5353 add @output_dropped_mDNS \
{ip saddr . udp sport . ip daddr . udp dport} \
counter accept
# Internet Printing Protocol
tcp dport 631 accept
tcp sport 631 accept
# vlc traffic
tcp dport {1935, 554, 8554} add @vlc_tcp_drop \
{ ip saddr . tcp sport . ip daddr . tcp dport } \
counter drop
udp dport {1234, 5004, 7001} add @vlc_udp_drop \
{ ip saddr . udp sport . ip daddr . udp dport } \
counter drop
# http traffic
tcp dport 80 add @dropped_tcp_http \
{ ip saddr . tcp sport . ip daddr . tcp dport } \
counter drop
udp dport 80 add @dropped_udp_http \
{ ip saddr . udp sport . ip daddr . udp dport } \
counter drop
# Unknown https traffic
udp dport {1-1023} counter drop
udp dport {1024-65535} counter drop
tcp dport {1-1023} add @unknown_tcp \
{ip saddr . tcp sport . ip daddr . tcp dport } counter drop
tcp dport {1024-65535} add @unknown_protocol \
{ip saddr . tcp sport . ip daddr . tcp dport} counter drop
# Everything Else
counter drop
} # close OUPUT chain
}
This last line in this nftable table revielded that some package was running a non-tcp/IP protocol. I still do not know what the protocol is, but I traced it to the avahi package.
To remove the avahi package [5]:
sudo apt remove --purge avahi-daemon
Next, I observed that as soon as I opened the FireFox browser that I had unsecure http traffic going to IP 34.107.221.82.
There is not a lot on the Internet on this, but this is the IP address that Firefox uses to determine if you are accessing the Interne through a captive portal [2]. When accessing the Internet through a captive portal, additional authorization is required. An example of a captive portal is Internet service provided by a hotel. To access the Internet, you first have to sign in and provide your room number and a pass code. Your browser has to detects this, in order to show the hotel's authorization page [3].
If you do a dns lookup on the url: https://detectportal.firefox.com, it will return the IP address: 34.107.221.82.
To prevent Firefox from checking for a captive portal:
In the search box type about:config
Click "Accept the Risk and Continue".
Then, search for "portal" and set "network.captive-portal-service.enabled" to false.
Then, search for "network.connectivity-service.enabled", and toggle it to false [5].
While you are at it, there are a few more setting you need to disable for privacy [4] :
To prevent Web Real Time Communicatins (WebRTV), search for "media.peerconnection.enabled, and toggle it to false.
To disable experiments or studies, search for app.normandy.enabled and toggle it to false.