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“.

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/00-installer-config.yaml

network:
  ethernets:
    enp1s0:
      dhcp4: true
    enp2s0:
      dhcp4: false
      addresses: [10.199.200.1/24]
      nameservers:
        search: [mydomain.com]
        addresses: [10.199.200.1]
    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 10.199.200.1 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/sysctl.conf 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/sysctl.conf
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/nftables.conf file. This version is thoroughly commented to show how the various instructions fit together.

/etc/nftables.conf

#!/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 = { 0.0.0.0/8, 10.0.0.0/8, 10.64.0.0/10, 127.0.0.0/8, 127.0.53.53, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.0.2.0/24, 192.168.0.0/16, 198.18.0.0/15, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/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 != 127.0.0.0/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 10.199.200.10
        }
	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 nftables.service && sudo systemctl start nftables.service
sudo /etc/nftables.conf

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 duckduckgo.com and convert them to IP addresses like 52.142.124.215. 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/dhcpd.conf:

/etc/dhcp/dhcpd.conf

subnet 10.199.200.0 netmask 255.255.255.0 {
    range 10.199.200.100 10.199.200.254;
    option subnet-mask 255.255.255.0;
    option routers 10.199.200.1;
    option domain-name-servers 10.199.200.1;
}

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.