Linux Router Part 1: Routing, NAT,
and NFTables ([Link]
linux-router-part-1-routing-nat-and-nftables/)
Introduction
A few years ago, Jim Salter wrote a number of articles for Ars Technica related to his “homebrew
routers ([Link]
itm_source=parsely-api)“.
Much of what he wrote then still stands, but time marches on, and when I rebuilt my home router, I
wanted to translate his lessons to a modern Ubuntu installation and the more approachable nftables
syntax.
The hardware
Any old thing with a couple of network interfaces will do fine. In my case I already had a nice
machine for the purpose; a solid state 4-NIC mini PC from Qotom.
The goal
What I wanted to achieve was to replicate my current pfSense functionality with tools completely
under my control. This includes being able to access the Internet (router), convert human-readable
names into IP addresses and vice versa (DNS), and automatically assign IP addresses to devices on my
networks (DHCP) – all of these of course are standard functionality you get with any home router
usually provided by your ISP. Since I run some web services from home, I also need to allow select
incoming traffic to hit the correct server in my house.
Base installation
I chose the latest LTS release of Ubuntu server for my operating system. Other systems are available,
but this is an operating system in which I’m comfortable. The installation is mostly a matter of
pressing Next a lot, with a couple of exceptions:
First of all, there’s a network configuration screen that fulfills an important purpose: Connect your
network cable to a port in the computer and take note of which logical network interface reacts in
the user interface. In my case the NIC marked 1 (which I intended to use for my Internet connection
or WAN) is called enp1s0, and Interface 4 (which I intended to use for my local network or LAN) is
called enp2s0. This will become important further down.
Second we want to make sure to enable the Secure Shell service already here in the installer, to allow
remote access after the router goes headless.
After installation has finished, it’s good practice to patch the computer by running sudo apt update &&
sudo apt upgrade and then rebooting it.
Basic network configuration
The first thing to do after logging in, is to configure the network. The WAN port usually gets its
address information automatically from your ISP, so for that interface we want to enable DHCP. The
LAN port on the other hand will need a static configuration. All this is configured using Netplan in
Ubuntu. The installer leaves a default configuration file in /etc/netplan , so let’s just edit that one:
/etc/netplan/[Link]
network:
ethernets:
enp1s0:
dhcp4: true
enp2s0:
dhcp4: false
addresses: [[Link]/24]
nameservers:
search: [[Link]]
addresses: [[Link]]
enp3s0:
dhcp4: false
enp5s0:
dhcp4: false
version: 2
At this point it’s worth noting that if you already have something on the IP address [Link] the
two devices will fight it out and there’s no telling who will win – that’s why I chose an uncommon
address in this howto.
To perform an initial test of the configuration, run sudo netplan try . To confirm the configuration, run
sudo netplan apply .
A router will also need to be able to forward network packets from one interface to another. This is
enabled by telling the kernel that we allow this functionality. By editing /etc/[Link] we make the
change permanent, and by reloading it using sysctl -p we make the changes take effect
immediately.
(Bonus knowledge: The effect of the sed commandline below is to inline replace (-i) the effects of
substituting (s) the commented-out string (starting with #) with the active one. We could edit the file
instead – and if we don’t know exactly what we’re looking for that’s probably a faster way to get it right
– but since I had just done it I knew the change I wanted to perform.)
sudo sed -i 's/#net.ipv4.ip_forward=1/net.ipv4.ip_forward=1/' /etc/[Link]
sudo sysctl -p
Great, so our computer can get an IP address from our ISP, it has an IP address on our local network,
and it can technically forward packets but we haven’t told it how yet. Now what?
Router
As mentioned, routing functionality in this case will be provided by nftables:
sudo apt install nftables
This is where things get interesting. Here is my current /etc/[Link] file. This version is
thoroughly commented to show how the various instructions fit together.
/etc/[Link]
#!/usr/sbin/nft -f
# Clear out any existing rules
flush ruleset
# Our future selves will thank us for noting what cable goes where and labeling the relevant network
interfaces if it isn't already done out-of-the-box.
define WANLINK = enp1s0 # NIC1
define LANLINK = enp2s0 # NIC4
# I will be presenting the following services to the Internet. You perhaps won't, in which case the
following line should be commented out with a # sign similar to this line.
define PORTFORWARDS = { http, https }
# We never expect to see the following address ranges on the Internet
define BOGONS4 = { [Link]/8, [Link]/8, [Link]/10, [Link]/8, [Link], [Link]/16,
[Link]/12, [Link]/24, [Link]/24, [Link]/16, [Link]/15, [Link]/24,
[Link]/24, [Link]/4, [Link]/4, [Link]/32 }
# The actual firewall starts here
table inet filter {
# Additional rules for traffic from the Internet
chain inbound_world {
# Drop obviously spoofed inbound traffic
ip saddr { $BOGONS4 } drop
}
# Additional rules for traffic from our private network
chain inbound_private {
# We want to allow remote access over ssh, incoming DNS traffic, and incoming DHCP
traffic
ip protocol . th dport vmap { tcp . 22 : accept, udp . 53 : accept, tcp . 53 : accept,
udp . 67 : accept }
}
# Our funnel for inbound traffic from any network
chain inbound {
# Default Deny
type filter hook input priority 0; policy drop;
# Allow established and related connections: Allows Internet servers to respond to
requests from our Internal network
ct state vmap { established : accept, related : accept, invalid : drop} counter
# ICMP is - mostly - our friend. Limit incoming pings somewhat but allow necessary
information.
icmp type echo-request counter limit rate 5/second accept
ip protocol icmp icmp type { destination-unreachable, echo-reply, echo-request, source-
quench, time-exceeded } accept
# Drop obviously spoofed loopback traffic
iifname "lo" ip daddr != [Link]/8 drop
# Separate rules for traffic from Internet and from the internal network
iifname vmap { lo: accept, $WANLINK : jump inbound_world, $LANLINK : jump inbound_private
}
}
# Rules for sending traffic from one network interface to another
chain forward {
# Default deny, again
type filter hook forward priority 0; policy drop;
# Accept established and related traffic
ct state vmap { established : accept, related : accept, invalid : drop }
# Let traffic from this router and from the Internal network get out onto the Internet
iifname { lo, $LANLINK } accept
# Only allow specific inbound traffic from the Internet (only relevant if we present
services to the Internet).
tcp dport { $PORTFORWARDS } counter
}
}
# Network address translation: What allows us to glue together a private network with the Internet even
though we only have one routable address, as per IPv4 limitations
table ip nat {
chain prerouting {
type nat hook prerouting priority -100;
# Send specific inbound traffic to our internal web server (only relevant if we present
services to the Internet).
iifname $WANLINK tcp dport { $PORTFORWARDS } dnat to [Link]
}
chain postrouting {
type nat hook postrouting priority 100; policy accept;
# Pretend that outbound traffic originates in this router so that Internet servers know
where to send responses
oif $WANLINK masquerade
}
}
To enable the firewall, we’ll enable the nftables service, and load our configuration file:
sudo systemctl enable [Link] && sudo systemctl start [Link]
sudo /etc/[Link]
To look at our active ruleset, we can run sudo nft list ruleset .
At this point we have a working router and perimeter firewall for our network. What’s missing is
DHCP, so that other devices on the network can get an IP address and access the network, and DNS,
so that they can look up human-readable names like [Link] and convert them to IP
addresses like [Link]. The basic functionality is extremely simple and I’ll detail it in the next
few paragraphs, but doing it well is worth its own documentation, which follows.
DNS
The simplest way to achieve DNS functionality is simply to install what the Internet runs on:
sudo apt install bind9
DHCP
We’ll run one of the most common DHCP servers here too:
sudo apt install isc-dhcp-server
DHCP not only tells clients their IP address, but it also tells them which gateway to use to access
other networks and it informs them of services like DNS. To set up a basic configuration let’s edit /
etc/dhcp/[Link] :
/etc/dhcp/[Link]
subnet [Link] netmask [Link] {
range [Link] [Link];
option subnet-mask [Link];
option routers [Link];
option domain-name-servers [Link];
}
Load the new settings by restarting the DHCP server:
systemctl restart isc-dhcp-server
And that’s it, really. Part 2 describes how to make DNS and DHCP cooperate to enhance your local
network quality of life.
Tags: computing firewall HowTo Linux NAT nftables router
Updated: January 24, 2026