Turning your Linux device into an internet gateway

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.

DHCP

Because I’m using a raspberry pi for my router, I settled on a tiny dhcp server: udpchd.

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	9.9.9.9

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

DNS

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
    • 8.8.8.8 and 8.8.4.4: Google’s primary and secondary DNS servers
    • 1.1.1.1: CloudFare’s server
    • 9.9.9.9: 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.

PPPoE

Linux has a universal PPP utility called pppd, the Point-to-Point protocol Daemon.

I installed the pppoe and 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=1.1.1.1
DNS2=9.9.9.9
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 ppp0

Firewall

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:

  • Filter
  • NAT

Filter

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

  1. Allow any and all traffic on the LAN interface so we can access the router from anywhere on the internal network.
  2. Allow any traffic related to outgoing traffic (return traffic)
  3. 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

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

The 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 ppp0.

There is a special target in iptables called 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

Port forwarding to expose internal applications to the public internet

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 DNAT target.

Let’s say we have some webserver we’d like to expose on port 8080 that resides on a server at 10.0.0.10

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

Persisting our rules

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
  • iptables-restore

iptables-save will produce a file that contains all of the instructions to re-create the firewall. iptables-restore parses this file and configures iptables appropriately.

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-pre-up.d and /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 /etc/if-pre-up.d.

Network Interfaces

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 /etc/rc.local

!/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 after or 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.

Final steps

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!

Closing thoughts

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