About a month ago, I had a sudden thought pop up in my mind: “I wonder if I could make a router using NixOS?” This happened as I was working on setting up Nginx on my current OPNsense router, which honestly was a hassle. As I’ve been implementing NixOS across my homelab, I got quite comfortable with writing Nix configuration files and using the CLI for most tasks, so the OPNsense UI felt like it was getting in the way. I just wanted to be able to drop in a load balancer module as I do on my NixOS systems and have it work right away. This led to me pursuing this project.
What is needed for a router?
A basic router primarily does one task: route network traffic. In a real world use, routers provide additional services such as: firewall, DNS, NTP, DHCP, and VPN. I wanted to start with something very simple, a proof of concept I can build upon: the equivalent of a home router which just handles a WAN and LAN, and has the ability to route traffic between the two networks. A firewall will be implemented to provide adequate protection to the internal network. It will host a DHCP server as that is a necessity for a home use case. Any functionality involving Wi-Fi will not be implemented, as I plan to use separate wireless access point to handle that kind of connectivity.
The router
The router configuration can be boiled down to three main functions:
- route IPv4 traffic between a LAN and WAN
- have a basic firewall that can perform NAT
- host a DHCP server for devices on the LAN
The router will run on a virtual machine with two network interfaces. The WAN will be on interface eth0 and connect to my general use internal network, which acts as a placeholder for the public facing internet. The LAN will be on interface eth1 and connect to an internal network I use for testing. Here’s a simple diagram which also provides the IP ranges I will use:
It may be worth pointing out that the router will not incorporate any IPv6. It will exclusively use IPv4, as it is easier to set up and keeps things simple for a proof of concept. I plan to incorporate IPv6 in the future.
The network configuration
The first step to get this working is enabling packet forwarding to allow network traffic between interfaces. This is done by setting net.ipv4.conf.all.forwarding, a specific runtime parameters of the Linux kernel, to true.
Next, the interfaces need their IPs to be set. I set the WAN to use DHCP as its IP would be assigned by the ISP, this can vary depending on provider. I set the LAN to 192.168.0.1 which is in the 192.168.0.0/24 private IP range. Since NixOS tends to default to DHCP being enabled, I explicitly disabled it on the interface.
Here is a code snippet of the network configuration:
{
# Enable IPv4 packet forwarding
boot.kernel.sysctl = {
"net.ipv4.conf.all.forwarding" = true;
};
networking = {
interfaces = {
"eth0" = {
# DHCP needed to acquire IP for WAN
useDHCP = true;
};
"eth1" = {
# Static IP needed for LAN gateway
useDHCP = false;
ipv4.addresses = [{
address = "192.168.0.1";
prefixLength = 24;
}];
};
};
};
}
Make sure to replace the
eth0andeth1interfaces (which are respectively WAN and LAN) as needed for your system. The name of your interfaces can be found using theip acommand.
Setting up nftables
Since NixOS is Linux-based, it uses nftables as a firewall, a modern successor to iptables. This is convenient as its configuration can be written in a declarative manner which pairs well with NixOS. This allows for the routing and firewall configuration to be easily reproduced between systems, where the only modifications needed are based on the different network interfaces available between devices. Since NixOS usually comes with a predefined firewall, it can be disabled using firewall.enable = false. This ensures that the firewall policies defined in the nftables configuration are used.
Nftables is configured using a tables, chains, and rules hierarchy. Each table has a family which defines the type of network traffic it interacts with, such as IPv4 or IPv6 traffic. Some notable families are: ip (IPv4), ip6 (IPv6), inet (IPv4 & IPv6), arp, and bridge. Within each table is a series of chains which define how the traffic is filtered.
A chain is a container that holds a set of rules, generally separated by hook type. The most common hook used for this router is input, which processes packets destined for the router. Some other hooks used are forward, which processes packets received by the router and are destined for another device, and postrouting, which processes all packets leaving the router, regardless of the source.
Rules are expressions that match packets and perform actions to those packets when matching. The the two most common actions are accept and drop, which allow packets through the firewall or block them. These actions can be matched to different types such as: tcp, icmp, or udp. The types themselves have more specific arguments which can help refine the rule, such as tcp dport 22 which filters for tcp traffic with a destination port of 22.
If you’d like to delve into nftables some more I highly recommend checking out the documentation available on the Gentoo wiki. It explains quite clearly how to get started with configuring your firewall and provides a comprehensive list of the different table, chain, and rule types that can be implemented. There is also an examples page that lays out configurations written in a declarative manner.
Here is an example nftables implementation that fulfills the desired firewall functionality needed for this simple router, which is composed of three tables:
{
networking = {
# Disable firewall (this will be handled by nftables)
firewall.enable = false;
nftables = {
enable = true;
tables = {
# Allow select IPv4 traffic
filterV4 = {
family = "ip";
content = ''
chain input {
type filter hook input priority 0; policy drop;
iifname "lo" accept comment "allow loopback traffic"
iifname "eth1" accept comment "allow traffic from LAN"
iifname "eth0" ct state established, related accept comment "allow established traffic from WAN"
iifname "eth0" ip protocol icmp counter accept comment "allow ICMP traffic from WAN"
iifname "eth0" tcp dport 22 counter accept comment "allow SSH traffic from WAN"
iifname "eth0" counter drop comment "drop all other traffic from WAN"
}
chain forward {
type filter hook forward priority 0; policy drop;
iifname "eth1" oifname "eth0" accept comment "allow LAN connections to forward to WAN"
iifname "eth0" oifname "eth1" ct state established, related accept comment "allow established WAN connections to forward to LAN"
}
'';
};
# Allow forwarded traffic out through WAN, masquerades IP
natV4 = {
family = "ip";
content = ''
chain postrouting {
type nat hook postrouting priority 100; policy accept;
oifname "eth0" masquerade comment "replace source address with WAN IP address"
}
'';
};
# Drops all IPv6 traffic
filterV6 = {
family = "ip6";
content = ''
chain input {
type filter hook input priority 0; policy drop;
}
chain forward {
type filter hook forward priority 0; policy drop;
}
'';
};
};
};
};
}
The filterV4 table filters and routes all IPv4 traffic going through the router. The first rule allows the router to access itself through the loopback interface lo. Then, it allows all incoming traffic from LAN into the router, as it is a trusted network. It filters all incoming traffic from WAN and makes sure it is coming from an established connection, is an ICMP packet, or is a connection on port 22 which is generally used for SSH. If the traffic coming from WAN is not filtered through, it is dropped. Then, all traffic going through the router is routed to the correct interface. It then forwards LAN traffic accessing the internet through WAN, and established traffic from WAN back to LAN.
Next, is the natV4 table which takes care of allowing IPv4 traffic from LAN out to access the WAN. Since the LAN traffic comes in with an private IP address the router has to “mask” the packet source IP with its own public IP assigned to WAN. If this were not performed your ISP would generally block that traffic, as it comes from a different IP than it expects.
Finally, the filterV6 table simply drops all incoming IPv6 traffic. This means no IPv6 traffic will be able to interact with the router.
You may want to remove the rules that allow ICMP and port 22 (SSH) traffic into the router from WAN if implementing this for production use. This reduces the attack surface to the router in a real world use case. I implemented these rules in my configuration as they are useful for testing the router if the WAN interface is connected to my internal network.
Implementing DHCP
All that’s left to add to the router is a DHCP server, which will be set up using Kea. The DHCP server running on the router allows for devices connected to the LAN to get an IP address assigned to them. This means that no manual network configuration will be needed for devices connecting to the network.
Kea is normally configured using JSON, so the formatting in Nix can look odd but is generally straightforward. I was able to set up the configuration below using the official Kea documentation for DHCPv4 servers, which is quite in depth but provides everything that is needed. Below is an example Nix configuration of the Kea DHCPv4 server:
{
# DHCP server for LAN
services.kea.dhcp4 = {
enable = true;
settings = {
interfaces-config.interfaces = [ "eth1" ];
lease-database = {
name = "/var/lib/kea/dhcp4-leases.csv";
type = "memfile";
persist = true;
lfc-interval = 3600;
};
valid-lifetime = 4000;
renew-timer = 1000;
rebind-timer = 2000;
subnet4 = [{
id = 1;
subnet = "192.168.0.0/24";
pools = [{
pool = "192.168.0.16 - 192.168.0.128";
}];
option-data = [{
name = "routers";
data = "192.168.0.1";
}{
name = "domain-name-servers";
data = "1.1.1.1"; # Cloudflare DNS
}];
}];
};
};
}
As is often the case with Nix services, the first step in the configuration is to enable Kea, specifically the DHCPv4 server, using services.kea.dhcp4.enable = true; as a parameter. Then, the Kea DHCPv4 configuration is defined in the services.kea.dhcp4.settings parameter.
First, the interfaces that the DHCP server will listen to are defined with the interfaces-config.interfaces parameter. In this case, as the LAN interface specified here only has one address assigned to it, no address has to be specified.
Then, the lease-database is defined, which stores the server’s lease information, where the IPs assigned to devices are stored. This particular example tells the server to use memfile, which is a simple database stored in memory and in a CSV file on the disk, whose location is defined by the name parameter. It is also set to persist = true which allows for the lease info to persist between reboots. The lfc-interval parameter just specifies how often, in seconds, the lease file is cleaned up.
Then, some global parameters for the timing of leased addresses are defined. The valid-lifetime parameter defines, in seconds, how long an address given out by the server is valid. Then the renew-timer and rebind-timer govern when a device can start its renewal and rebind processes respectively, basically allowing a device to know when to attempt a lease renewal or when to start requesting a new IP lease if unable to renew.
Finally, the subnet4 parameter is defined by a list of the different subnets that the DHCP server will provide IPs for. In this case only one subnet is defined, with an id of 1. The subnet of 192.168.0.0/24 matches that of our LAN network, and a pool is defined by a list of IP ranges where leases can be assigned. With this a device generally should be able to get an IP address automatically configured, although two important aspects of that configuration are missing: the gateway and DNS. These can be defined in the option-data parameter, where gateways can be defined under routers, in this case it is the IP of the eth1 interface that we configured earlier, and the DNS defined under domain-name-servers, which can be set to your DNS of choice.
End Note
With the successful execution of this project, I now have the foundation to create a fully featured NixOS based replacement of OPNSense, the router I currently have running in my homelab. This will allow me to easily add my existing Nix configurations to the router, enabling the consolidation of many of my existing services to a single system. The next step for this project is to implement a DNS server on the router, which is explained here.