I recently built a router using NixOS. Here’s how I did it.


I’m going to skip a lot of the middle learning stuff I did while I figured all of this out. And there is still a lot of stuff I need to learn. Like how to handle IPv6!

Hardware

My router/server is a custom built gaming PC from many years ago. It has an i7-6700k, 32GB of RAM, and a pair of 256GB SSD’s in btrfs raid0. It’s overkill for a router, but it’s what I had laying around.

It also has a 4 port Intel NIC. I’m using the onboard NIC for the WAN connection.

Software

For the software, I’m using NixOS for the system, and using systemd-networkd for the networking. I tried originally with Network Manager, but it was for some reason unable to bring up the WAN.

Configuration

The first thing I recommend you to do is make it so that all of your network ports have predictable names. This makes it so that the names are not just predictable, but meaningful - WAN is your WAN port and so on. This is done by adding the following to your configuration.nix:

{ 
  networking.usePredictableInterfaceNames = false; 
  services = {
    udev.extraRules = ''
      ACTION=="add", SUBSYSTEM=="net", ATTR{address}=="AA:BB:CC:DD:EE:01", NAME="WAN"
      ACTION=="add", SUBSYSTEM=="net", ATTR{address}=="AA:BB:CC:DD:EE:02", NAME="LAN0"
      ACTION=="add", SUBSYSTEM=="net", ATTR{address}=="AA:BB:CC:DD:EE:03", NAME="LAN1"
      ACTION=="add", SUBSYSTEM=="net", ATTR{address}=="AA:BB:CC:DD:EE:04", NAME="LAN2"
      ACTION=="add", SUBSYSTEM=="net", ATTR{address}=="AA:BB:CC:DD:EE:05", NAME="LAN3"
    '';
  };
}

We need to tell the kernel that forwarding the traffic is A-OK.

{
  boot.kernel.sysctl = {
    "net.ipv4.conf.all.forwarding" = 1;
  };
}

Adjusting the Mac Addresses of course so that they match your hardware. From here on out, you can reliably refer to your network ports as WAN, LAN0, LAN1, LAN2, and LAN3, even when your do somthing silly like add a GPU, which with predicable names would unpredicably change them.

Next, we need to ensure that the system is using systemd-networkd and that it’s configured to create a bridge on all 4 ports of our network card. We also need to get an on WAN, and set up NAT.

{
  networking = {
    networkmanager.enable = lib.mkForce false;
    useNetworkd = true;
    bridges = {
      br0 = {
        interfaces = [ "LAN0" "LAN1" "LAN2" "LAN3" ];
      };
    };
    # Of course, we're going to need give the LAN access to the WAN (Internet), so lets do that
    nat = {
      enable = true;
      externalInterface = "WAN";
      internalInterfaces = [ "br0" ];
      internalIPs = [ "10.0.0.0/24" ]; 
      # You can customize this to your network, I like the 10.0.0.0 address range 
      #because it allows you to refer to other devices like `ssh 10.1`.
    };
    # We also need to set up the WAN connection to get an IP
    interfaces = {
      "WAN" = {
        useDHCP = true;
        tempAddresses = "disabled"; #Disable Temp Addresses for our ISP's sake.
      };
      # And on the Bridge, we're going to statically assign ourselves an IP
      "br0" = {
        ipv4.addresses = [ { address = "10.0.0.1"; prefixLength = 24; } ];
        useDHCP = false;
        macAddress = "AA:BB:CC:DD:EE:FF";
        # ^ This is the MAC address of the bridge. It's important to set this 
        # so that the bridge has a predictable MAC address.
      };
    };
  };
}

Now, for actually handing out IP’s, I went with dnsmasq as it seems relatively well documented. I originally used dhcpd but that project is dead. Big sad face.

Now, Technically you can set this up using Nix without using extraConfig, but my project here predates that and needs to be re-written to take advantage of it. Maybe someday?

{
  services.dnsmasq = {
    enable = true;
    alwaysKeepRunning = true;
    extraConfig = ''
      interface=br0
      domain=example.com,10.0.0.1
      # ^ Domain and Address of the host.
      dhcp-range=10.0.0.10,10.0.0.254,5m
      # ^ All IP addresses between that range will be handed out with a 5 minute lease time.
      # It also reserves the first 10 addresses for static IP's.
      dhcp-option=3,10.0.0.1
      # ^ This is the primary DNS. DNSMasq will also be the DNS server, 
      # since it can cache. You can point this wherever you wish.
      dhcp-option=121,10.0.0.0/24.10.0.0.1
      # ^ This is a classless static route. I'm not 100% sure what it does, 
      # but things seem to work better with it. Apparently Windows ignores this.
      
      # From here you can set up static IP's for your devices, if you want.
      dhcp-host:AA:BB:CC:DD:EE:FF,10.0.0.2
      # Repeat as needed.
      
      # DNS
      listen-address=1,127.0.0.1,10.0.0.1
      # It listens on both the local and the bridge interface.
      expand-hosts
      # This will allow you to refer to devices by their hostname.
      server=1.1.1.1  
      # ^ This is Cloudflare's DNS server. You can use whatever you want.
      server=8.8.8.8
      # ^ We're using Google's DNS as backup.
      address=/example.com/10.0.0.1
      # ^ This is a static DNS entry. It will resolve example.com to 10.0.0.1, or in other words, the router.
    '';
  };
} 

And last but not least, we need to open the appropriate ports in the firewall. I’m going to open up the ports for DNS, and DHCP, but specifically only on the LAN. DO NOT open these to the WAN unless you know what you’re doing. They can and will be abused.

{
  networking.firewall.interfaces."br0" = {
    allowedUDPPorts = [ 53 67 ];
    allowedTCPPorts = [ 53 67 ];
  };
}

And that’s it! You should now have a working router. You can test it by connecting a device to one of the LAN ports and seeing if it gets an IP. If it does, you can try to ping the router, and then try to ping the WAN. If all of that works, you should be good to go!

Do note that this does not support IPv6 (yet?). If anyone knows how to do that, please let me know! I’m stuck on that part.