Nftables Router Tutorial
Alteeve Wiki :: How To :: Nftables Router Tutorial |
![]() |
Warning: This is incomplete and untrustworthy! Do not expect anything to be useful or accurate before this warning is removed. |
This tutorial is meant to show how to use nftables to build a router suitable for a home or boat.
This tutorial is written for RHEL 9 or distros based on it, like AlmaLinux 9 and Rocky Linux 9.
Setup
Before we configure nftables, we need to setup the machine first.
Enabling ipv4 Forwarding
Make sure that ip_forward is enabled in the kernel.
sysctl net.ipv4.conf.all.forwarding
net.ipv4.conf.all.forwarding = 0
This shows that it's disabled. To enable it, and make sure it's set when the system reboots, edit (or create) the file "/etc/sysctl.d/99-custom.conf" and add (or update) the lines;
# Added for router function support
net.ipv4.conf.all.forwarding = 1
Now reload the config;
sysctl --system
* Applying /usr/lib/sysctl.d/10-default-yama-scope.conf ...
* Applying /usr/lib/sysctl.d/50-coredump.conf ...
* Applying /usr/lib/sysctl.d/50-default.conf ...
* Applying /usr/lib/sysctl.d/50-libkcapi-optmem_max.conf ...
* Applying /usr/lib/sysctl.d/50-pid-max.conf ...
* Applying /usr/lib/sysctl.d/50-redhat.conf ...
* Applying /etc/sysctl.d/99-custom.conf ...
* Applying /etc/sysctl.d/99-sysctl.conf ...
* Applying /etc/sysctl.conf ...
kernel.yama.ptrace_scope = 0
kernel.core_pattern = |/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h
...<snip>...
net.ipv4.conf.lo.rp_filter = 1
net.ipv4.conf.wlp58s0.rp_filter = 1
net.ipv4.conf.all.forwarding = 1
Now we can verify that forwarding is enabled;
sysctl net.ipv4.conf.all.forwarding
net.ipv4.conf.all.forwarding = 1
Now ip_forward is enabled!
Disable firewalld
The nftables tool is an alternative to firewalld, so we need to disable it.
systemctl disable --now firewalld.service
Removed "/etc/systemd/system/multi-user.target.wants/firewalld.service".
Removed "/etc/systemd/system/dbus-org.fedoraproject.FirewallD1.service".
Configuring nftables
![]() |
Note: We're using the script name alteeve.nft, but you can use whatever you like. |
We're going to create an nft executable script to configure our router. We'll do this in the file /etc/nftables/alteeve.nft.
touch /etc/nftables/alteeve.nft
chmod 755 /etc/nftables/alteeve.nft
chown root:root /etc/nftables/alteeve.nft
ls -lah /etc/nftables/alteeve.nft
-rwxr-xr-x. 1 root root 0 Mar 27 21:59 /etc/nftables/alteeve.nft
This empty file is now executable, so lets write our script.
![]() |
TODO: Explain all this better |
#!/usr/sbin/nft -f
#
# Written by Madison Kelly - Mar. 27, 2024
# - Alteeve's Niche!
# - mkelly@alteeve.com
# - Released under the GPL v3
#
### Setup variables.
## Network devices
# Wired Internet interface (ISP router connection)
define IF_INET = eno1
# Wireless Internet interface (tethering cellphones, etc)
define IF_WLAN = wlp58s0
# Network connecting to the Internet-Facing Network (as used in Anvil! systems,
# more generally, the main network with most devices)
define IF_IFN1 = enp0s20f0u1
# Network connecting to the Back-Channel Network (as used in the Anvil! system,
# more generally, the network with infrastructure / secure devices)
# NOTE: Not currently used
#define IF_BCN1 =
# Define our networks. For now, there's only one.
define NET_IFN1 = 10.255.0.0/16
define DNS_SERVERS = { 8.8.8.8, 8.8.4.4 }
flush ruleset
### NOTES:
## Address families;
# ip: Matches only IPv4 packets. This is the default if you do not specify
# an address family.
# ip6: Matches only IPv6 packets.
# inet: Matches both IPv4 and IPv6 packets.
# arp: Matches IPv4 address resolution protocol (ARP) packets.
# bridge: Matches packets that pass through a bridge device.
# netdev: Matches packets from ingress.
## Hooks;
#
## Chain Types
# filter: Standard chain type
# - All address families
# - All hooks
# nat: Chains of this type perform native address translation based on
# connection tracking entries. Only the first packet traverses this
# chain type.
# - AFs: ip, ip6, and inet
# - Hooks: prerouting, input, output, and postrouting
# route: Accepted packets that traverse this chain type cause a new route
# lookup if relevant parts of the IP header have changed.
# - AFs: ip and ip6
# - Hooks: output
## Chain Priorities
# +----------+----------+------------------+-------------+
# | Textual | Numberic | Address Families | Hooks |
# | Value | Value | | |
# +----------+----------+------------------+-------------+
# | raw | -300 | ip, ip6, inet | all |
# +----------+----------+------------------+-------------+
# | mangle | -150 | ip, ip6, inet | all |
# +----------+----------+------------------+-------------+
# | dstnat | -100 | ip, ip6, inet | prerouting |
# | | -300 | bridge | prerouting |
# +----------+----------+------------------+-------------+
# | filter | 0 | ip, ip6, inet, | all |
# | | | arp, netdev | |
# | | -200 | bridge | all |
# +----------+----------+------------------+-------------+
# | security | 50 | ip, ip6, inet | all |
# +----------+----------+------------------+-------------+
# | srcnat | 100 | ip, ip6, inet | postrouting |
# | | 300 | bridge | postrouting |
# +----------+----------+------------------+-------------+
# | out | 100 | bridge | output |
# +----------+----------+------------------+-------------+
## Chain Policies
# accept (default)
# drop
## table format example;
# table <table_address_family> <table_name> {
# chain <chain_name> {
# type <type> hook <hook> priority <priority> ; policy <policy> ;
# <rule>
# }
# }
# Create the 'global' table for all packets (use 'ip' for IPv4 only, or 'ip6'
# for IPv6 only).
table inet global {
# Chain to manage connections coming into the router from the outside
# world
chain inbound_wan {
# Allow incoming pings, but only up to 5 pings per second.
icmp type echo-request limit rate 5/second accept
# Allow inbound ssh connections. If you want to limit this, add
# 'ip saddr <ip>...'. For now, accept from anywhere, so we can
# remote in while traveling (though really we should have a VPN
# for that...).
#ip protocol . th dport ssh accept
}
# Chain to manage connections coming in from the IFN 1.
chain inbound_ifn1 {
icmp type echo-request limit rate 5/second accept
### TODO: Explain '.' and 'th'
# Allow DNS, DHCP, and SSH
ip protocol . th dport vmap { tcp . 22 : accept, udp . 53 : accept, tcp . 53 : accept, udp . 67 : accept}
}
# Chain to manage other inbound
chain inbound {
type filter hook input priority 0; policy drop;
# Allow traffic from established and related packets, drop invalid
ct state vmap { established : accept, related : accept, invalid : drop }
# allow loopback traffic, anything else jump to chain for further evaluation
iifname vmap { lo : accept, $IF_INET : jump inbound_wan, $IF_IFN1 : jump inbound_ifn1 }
# the rest is dropped by the above policy
}
#
chain forward {
type filter hook forward priority 0; policy drop;
# Allow traffic from established and related packets, drop invalid
ct state vmap { established : accept, related : accept, invalid : drop }
# connections from the internal net to the internet or to other
# internal nets are allowed
iifname $IF_IFN1 accept
# the rest is dropped by the above policy
}
chain postrouting {
type nat hook postrouting priority 100; policy accept;
# masquerade private IP addresses
ip saddr $NET_IFN1 oifname $IF_IFN1 masquerade
}
}
Now run it:
/etc/nftables/alteeve.nft
If it worked, you should be able to setup a machine on the 10.255.0.0/24 network, set the IP of the router as the default gateway (10.255.255.254 in this case), and connect to the internet!
Setting Up DHCP Server
We need to be able to give out IP addresses to our network now.
Install the DHCP server
dnf install dhcp-server
Last metadata expiration check: 0:55:35 ago on Wed Mar 27 23:29:35 2024.
Dependencies resolved.
==============================================================================================================================
Package Architecture Version Repository Size
==============================================================================================================================
Installing:
dhcp-server x86_64 12:4.4.2-19.b1.el9 baseos 1.2 M
Installing dependencies:
dhcp-common noarch 12:4.4.2-19.b1.el9 baseos 128 k
Transaction Summary
==============================================================================================================================
Install 2 Packages
Total download size: 1.3 M
Installed size: 4.2 M
Is this ok [y/N]: y
Downloading Packages:
(1/2): dhcp-common-4.4.2-19.b1.el9.noarch.rpm 495 kB/s | 128 kB 00:00
(2/2): dhcp-server-4.4.2-19.b1.el9.x86_64.rpm 3.6 MB/s | 1.2 MB 00:00
------------------------------------------------------------------------------------------------------------------------------
Total 1.8 MB/s | 1.3 MB 00:00
Running transaction check
Transaction check succeeded.
Running transaction test
Transaction test succeeded.
Running transaction
Preparing : 1/1
Installing : dhcp-common-12:4.4.2-19.b1.el9.noarch 1/2
Running scriptlet: dhcp-server-12:4.4.2-19.b1.el9.x86_64 2/2
Installing : dhcp-server-12:4.4.2-19.b1.el9.x86_64 2/2
Running scriptlet: dhcp-server-12:4.4.2-19.b1.el9.x86_64 2/2
Verifying : dhcp-common-12:4.4.2-19.b1.el9.noarch 1/2
Verifying : dhcp-server-12:4.4.2-19.b1.el9.x86_64 2/2
Installed:
dhcp-common-12:4.4.2-19.b1.el9.noarch dhcp-server-12:4.4.2-19.b1.el9.x86_64
Complete!
Tell systemd to reload the configs;
systemctl daemon-reload
Now we're ready to configure the actual server.
Configure the DHCP Service
![]() |
Note: This is setting up IPv4 only. |
We need to copy the original services file into /etc/systemd/system/, and then edit it to configure the daemon to only listen to our internal interfaces. Yes, this is clunky. No, I don't know why this can't be done in a config file.
Don't edit /usr/lib/systemd/system/dhcpd.service directly, changes will be lost on future updates.
cp /usr/lib/systemd/system/dhcpd.service /etc/systemd/system/
Now edit /etc/systemd/system/dhcpd.service to tell it to listed to our internal-facing interface (enp0s20f0u1 in my case).
vim /etc/systemd/system/dhcpd.service
[Unit]
Description=DHCPv4 Server Daemon
Documentation=man:dhcpd(8) man:dhcpd.conf(5)
Wants=network-online.target
After=network-online.target
After=time-sync.target
[Service]
Type=notify
EnvironmentFile=-/etc/sysconfig/dhcpd
ExecStart=/usr/sbin/dhcpd -f -cf /etc/dhcp/dhcpd.conf -user dhcpd -group dhcpd --no-pid $DHCPDARGS enp0s20f0u1
StandardError=null
[Install]
WantedBy=multi-user.target
The difference is;
diff -U0 /usr/lib/systemd/system/dhcpd.service /etc/systemd/system/dhcpd.service
--- /usr/lib/systemd/system/dhcpd.service 2023-09-26 22:47:07.000000000 -0400
+++ /etc/systemd/system/dhcpd.service 2024-03-28 00:42:51.271385340 -0400
@@ -11 +11 @@
-ExecStart=/usr/sbin/dhcpd -f -cf /etc/dhcp/dhcpd.conf -user dhcpd -group dhcpd --no-pid $DHCPDARGS
+ExecStart=/usr/sbin/dhcpd -f -cf /etc/dhcp/dhcpd.conf -user dhcpd -group dhcpd --no-pid $DHCPDARGS enp0s20f0u1
Configure the DHCP Server
Edit the DHCP server config file;
vim /etc/dhcp/dhcpd.conf
# Basic DHPC server for 10.255.0.0/16
option domain-name "alteeve.com";
default-lease-time 86400;
authoritative;
subnet 10.255.0.0 netmask 255.255.0.0 {
range 10.255.1.0 10.255.1.255;
option domain-name-servers 8.8.8.8,8.8.4.4;
option routers 10.255.255.254;
option broadcast-address 10.255.255.255;
}
If all is well, you should now be able to enable and start the daemon.
systemctl enable --now dhcpd.service
Created symlink /etc/systemd/system/multi-user.target.wants/dhcpd.service → /etc/systemd/system/dhcpd.service.
systemctl status dhcpd.service
● dhcpd.service - DHCPv4 Server Daemon
Loaded: loaded (/etc/systemd/system/dhcpd.service; enabled; preset: disabled)
Active: active (running) since Thu 2024-03-28 00:58:39 EDT; 52s ago
Docs: man:dhcpd(8)
man:dhcpd.conf(5)
Main PID: 5736 (dhcpd)
Status: "Dispatching packets..."
Tasks: 1 (limit: 48066)
Memory: 4.6M
CPU: 9ms
CGroup: /system.slice/dhcpd.service
└─5736 /usr/sbin/dhcpd -f -cf /etc/dhcp/dhcpd.conf -user dhcpd -group dhcpd --no-pid enp0s20f0u1
Mar 28 00:58:39 an-fw01.alteeve.com dhcpd[5736]: Config file: /etc/dhcp/dhcpd.conf
Mar 28 00:58:39 an-fw01.alteeve.com dhcpd[5736]: Database file: /var/lib/dhcpd/dhcpd.leases
Mar 28 00:58:39 an-fw01.alteeve.com dhcpd[5736]: PID file: /var/run/dhcpd.pid
Mar 28 00:58:39 an-fw01.alteeve.com dhcpd[5736]: Source compiled to use binary-leases
Mar 28 00:58:39 an-fw01.alteeve.com dhcpd[5736]: Wrote 0 leases to leases file.
Mar 28 00:58:39 an-fw01.alteeve.com dhcpd[5736]: Listening on LPF/enp0s20f0u1/00:50:b6:1e:fb:af/10.255.0.0/16
Mar 28 00:58:39 an-fw01.alteeve.com dhcpd[5736]: Sending on LPF/enp0s20f0u1/00:50:b6:1e:fb:af/10.255.0.0/16
Mar 28 00:58:39 an-fw01.alteeve.com dhcpd[5736]: Sending on Socket/fallback/fallback-net
Mar 28 00:58:39 an-fw01.alteeve.com dhcpd[5736]: Server starting service.
Mar 28 00:58:39 an-fw01.alteeve.com systemd[1]: Started DHCPv4 Server Daemon.
You should now be able to connect a device to your network and automatically get an IP address!
References
- Simple ruleset for a home router - nftables Wiki
- Getting started with nftables - (*May required a Red Hat account).
- Providing DHCP services - (*May require a Red Hat account).
Any questions, feedback, advice, complaints or meanderings are welcome. | |||
Alteeve's Niche! | Alteeve Enterprise Support | Community Support | |
© 2025 Alteeve. Intelligent Availability® is a registered trademark of Alteeve's Niche! Inc. 1997-2025 | |||
legal stuff: All info is provided "As-Is". Do not use anything here unless you are willing and able to take responsibility for your own actions. |