This post is part of a series on building a router in Linux.
Posts in the series
Don’t reinvent the wheel
In the previous post we identified the various mechanisms we needed in order to successfully build out a router.
In this post, we are going to have a look at existing technologies that can be stitched together to achieve all of these goals.
To start, we will list the technical requirements, the service or technology I chose, a brief explanation as to why and how to configure it. I recommend following the steps in order here, but feel free to break out on your own.
Because I’m using a raspberry pi for my router, I settled on a tiny dhcp server:
At some point in its history,
udhcpd was absorbed by the BusyBox project. Raspbian has the binary available in the repo however, so that’s how I installed it:
sudo apt update -y && sudo apt install udhcpd
Once installed, we need to setup our config.
There are plenty of configurable options for udhcpd, below is the config I am running (comments added are my own)
# the beginning of the assignable address range start 10.0.0.10 # end of the assignable address range end 10.0.0.99 # the interface connected to the LOCAL network interface eth0 # time interval between leases being persisted by udhcpd auto_time 7200 # the minimum lease time min_lease 60 # client's subnet mask, which is a description of how values may change within a subnet option subnet 255.255.255.0 # default gateway DHCP clients should attempt to reach the internet via opt router 10.0.0.1 # a DNS server option dns 10.0.0.1 # note that this is the router! Which means there MUST be a DNS server running on this address. See the DNS section for more details # another DNS server option dns 220.127.116.11 # the suffix applied to an address to be searched for by clients option domain local # how long a lease is reserved for a specific client option lease 864000 # a static mapping for a given MAC address to a defined IP. For privacy reasons, that is not my MAC. Use your actual MAC address static_lease aa:aa:aa:aa:aa:aa 10.0.0.2
Once the service is up and running, our clients can perform DHCP requests against the raspberry pi.
There are a lot of potential options for DNS servers here. When a client performs a DHCP query, it receives a few parameters back from the DHCP server. One of those is a list of DNS servers that should be queried for a given hostname. Because this option is configurable at the DHCP client level, we can choose a few options
- Use existing hosted services
- 18.104.22.168 and 22.214.171.124: Google’s primary and secondary DNS servers
- 126.96.36.199: CloudFare’s server
- 188.8.131.52: Quad9’s server
- Use a DNS server on the device and provide the device IP
- A combination of the above (as in the above config)
Why host a DNS server locally? Maybe we don’t want our clients connecting servers that we blacklist. A popular example would be ad blocking. And for that reason, I chose to use pi-hole. Pi-Hole is an awesome little DNS server that uses a list of known ad servers and answers with a
NXDOMAIN, which prevents the client from completing the connection.
There’s a caveat here though, any service smart enough to embed ads from an allowed host will still pull those ads (an example is YouTube ads, those are served from the same content server as YouTube itself).
Pi-Hole has the ability to configure upstream DNS servers to use for records not defined by its blacklist. It’s a good idea to setup those upstream servers as well as provide a fail over in the DHCP options. This covers us should Pi-Hole suddenly fall over for any reason. Enabling this fail over is as simple as providing two DNS servers in the DHCP options: The router and a 3rd party. I personally recommend Quad9 as they are a free and security centric DNS service.
Linux has a universal PPP utility called pppd, the Point-to-Point protocol Daemon.
I installed the
ppp packages from the raspbian apt repository, which provide some useful wrapper scripts (such as
pppoe-connect). Feel free to use other daemons and wrappers, but this section will deal with the
pppoe library scripts predominantly.
Once installed we need to setup a few configurations.
This is my working config under
/etc/ppp/pppoe.conf, for obvious reasons I’m going to blank out my username here.
ETH='eth1' USER='*******@*******' DEMAND=no DNSTYPE=SPECIFY PEERDNS=no DNS1=184.108.40.206 DNS2=220.127.116.11 DEFAULTROUTE=yes CONNECT_TIMEOUT=30 CONNECT_POLL=2 PING="." CF_BASE=`basename $CONFIG` PIDFILE="/var/run/$CF_BASE-pppoe.pid" SYNCHRONOUS=no CLAMPMSS=1412 LCP_INTERVAL=20 LCP_FAILURE=3 PPPOE_TIMEOUT=80 FIREWALL=NONE
A comprehensive list of options can be found under the pppoe(5) man page
Note: The DNS entries here are written to
/etc/resolv.conf on the machine running the ppp connection. This means the router has a separate set of DNS servers to use when performing operations locally (such as updates). You could set this to the router itself if you’re hosting a DNS there, I just decided it was easier to use existing upstream services
Presumably, your ISP has a password requirement to go along with the supplied username. To connect and authenticate, we need to setup a corresponding entry under
/etc/ppp/pap-secrets. Your ISP may only support CHAP, in which case you will need to instead write to
/etc/ppp/chap-secrets. We are only interested in outbound connections here, so we will only configure an outbound connection.
Outgoing connection authentication follows the below pattern:
USERNAME host secret
USERNAME must match the value defined in the
/etc/ppp/pppoe.conf file. In order to connect any host, without caring about the remote machine name, we will use the wildcard
* operator to match all hostnames. We will also generate a random secret key to be used in challenges between us and the server.
Our config then will look something like this:
USER_NAME@HOST * asdlkj29
To connect to our ISP, we will make use of the
pppoe-connect script. Once the script completes, our ISP interface should now be up as
Next up is the Firewall.
We need a way to protect ourselves from outside intruders as well as to NAT our various connections. To start off with, we need to open up the internet to our local network.
Most every Linux distribution comes with
iptables. You might believe that
iptables is itself the firewall, but that’s not technically true.
iptables is a userspace binding to the Netlink kernel module. We won’t bother too much with that in this post, however, as we will just use the
iptables binary to configure said kernel module.
iptables has two primary tables:
First things first: we need to ensure our device is protected from the outside world. To do that, we’re going to create a couple rules on the INPUT chain. The objective of the rules is simple
- Allow any and all traffic on the LAN interface so we can access the router from anywhere on the internal network.
- Allow any traffic related to outgoing traffic (return traffic)
- Kill the rest
NB: Make sure you absolutely know 100% what your LAN interface is. The below example uses eth0, as that is the interface my LAN is attached to. If you get this wrong, you will lock yourself and need to reboot the Pi to clear the firewall rules
iptables -I INPUT 1 -i eth0 -j ACCEPT iptables -I INPUT 2 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT iptables --policy INPUT DROP
Now that we’ve locked down our router from pesky outside snoopers, we can continue with the next step: NAT
In order to connect our local network to the internet we need to “forward” packets not destined for our local network to the ISP. This is known as a source NAT, or SNAT.
Why source and not destination? When the IP frame arrives at the router, the
src field will be populated with the originating IP. We need to give our ISP an IP it recognises, so we mangle the frame and change this field to our remote IP with the ISP (assuming PPP connections).
Configuring these rules requires updating the
nat table on iptables. First, let’s have a look at what we’re dealing with
iptables -t nat -nL Chain PREROUTING (policy ACCEPT) target prot opt source destination Chain INPUT (policy ACCEPT) target prot opt source destination Chain OUTPUT (policy ACCEPT) target prot opt source destination Chain POSTROUTING (policy ACCEPT) target prot opt source destination
POSTROUTING chain is activated immediately before a packet is about to leave an interface. We’ll use this chain to pick up packets after they arrive on the LAN interface, and SNAT the packet onto our ISP interface
There is a special target in
MASQUERADE that functions as our SNAT. It essentially pretends the outgoing packet originates from the specified interface, rather than the actual interface it arrived on.
iptables -A POSTROUTING -i ppp0 -j MASQUERADE
If you have a service running on your local network and you’d like to expose it publicly, then we need to setup some
PREROUTING rules with a
Let’s say we have some webserver we’d like to expose on port 8080 that resides on a server at
iptables -A PREROUTING -i ppp0 -p tcp --dport 8080 -j DNAT --to-destination 10.0.0.10:8080
Any TCP packet that arrives on port 80 from the
ppp0 interface will be forwarded to the specified address. Note that you don’t have to match ports here, the router could ’listen’ on port 80 and forward to port 8080 locally.
Remember to open this port on your INPUT filter chain too with
sudo iptables -A INPUT -p tcp --dport 8080.
You could also place a target device in a DMZ, whereby the device is no longer “behind” the router’s firewall. To do that, we just need to exclude the ports from the previous command
iptables -A PREROUTING -i ppp0 -j DNAT --to-destination 10.0.0.10
iptables is a transient service, meaning it does nothing on system startup. Happily,
iptables comes bundled with two self explanatory commands to aid in this problem, namely:
iptables-save will produce a file that contains all of the instructions to re-create the firewall.
iptables-restore parses this file and configures
There are numerous articles and approaches on how to best hook these commands up into the lifecycle of the OS or network interfaces. Personally, I prefer creating a script that is called from
/etc/rc.local. That script reads a file from a specified file (I use
/opt/iptables.rules for no particular reason).
This does require that I have to manually update that file if I change the firewall rules, but I’m alright with that as they don’t change often.
If you’d prefer something a little more hands off, you can hook into the lifecycle of your network interfaces under
/etc/network/if-post-down.d respectively on most distributions (arch does not have these hooks, most debian based distros do though). Scripts in these directories are executed appropriately, and thusly we’d place our
iptables-save command under
/etc/if-post-down.d and our
iptables-restore command under
There are plenty network managers in Linux, but I find them to be cumbersome outside of the usual use case. Instead I have decided to handle the network interface management myself by using a single bootstrap script to bring up the network interfaces.
I have the following script under
/usr/bin/boot_netstack.sh which is called from
!/bin/bash ip link set eth0 up # LAN Interface ip link set eth1 up # WAN Interface ip addr add 10.0.0.1/24 brd + dev eth0 #add the IP address as well as set the broadcast flag iptables-restore < /opt/iptables.rules # load firewall rules ip route add 10.0.0.0/24 dev eth0 # tell the kernel how to talk to the rest of the network sleep 5 #Just in case nohup pppoe-connect &> /tmp/ppp.log & # bring up ppp and log out the connection wrapper's output sleep 5 systemctl start udhcpd # bring up the dhcp server
Depending on your DHCP server, you may need to take the same approach I did to ensure the service is able to bind to the appropriate interface. If the interface is not up before the call to the DHCP server is made, it will likely fail.
But this is also achievable using init systems such as
systemd, as always with Linux: the choice is yours. The choice of approach you use will have an impact on how you need to load firewall rules etc.
For example, if using
systemd, it is a much better idea to rather use the
before commands to ensure you are loading the scripts at the appropriate time. The above script is just a dead simple approach with no need for fancy dependency management.
We’re not done yet. There is one last setting we need to modify in the kernel itself to ensure that we allow packets to traverse interfaces. This setting is
net.ipv4.ip_forward and should only ever be enabled on devices acting as a firewall, router or NAT device.
You can either edit
/etc/sysctl.conf or run the below command:
sysctl -w net.ipv4.ip_forward=1
Et voilà! We have successfully setup our little raspberry pi as a router!
I’m really impressed with how stable and reliable the pi has been, but there has been one rather large drawback: Before the raspberry pi4, the Ethernet and USB ports all share a single bus to share data along. This leads to an annoying problem: I cannot push the full 100meg throughput of my line through the pi.
However, that’s a minor annoyance that I will remedy with by purchasing a pi4 as soon as stock arrives.
Happy Hacking everybody