In my last post in this series I mentioned that I’d got my Raspberry Pi Zero W to act as a USB Ethernet adaptor via libComposite, and that I was using DNSMasq to provide a DHCP service to the host computer (the one you plug the Pi into). In this part, I’m going to extend what local services I could provide on this device, and start to use this as a router.
Here’s what you missed last time… When you plug the RPi in (to receive power on the data line), it powers up the RPi Zero, and uses a kernel module called “libComposite” to turn the USB interface into an Ethernet adaptor. Because of how Windows and non-Windows devices handle network interfaces, we use two features of libComposite to create an ECM/CDC interface and a RNDIS interface, called usb0 and usb1, and whichever one of these two is natively supported in the OS, that’s which interface comes up. As a result, we can then use DNSMasq to “advertise” a DHCP address for each interface, and use that to advertise services on, like an SSH server.
By making this device into a router, we can use it to access the network, without using the in-built network adaptor (which might be useful if your in-built WiFi adaptors isn’t detected under Linux or Windows without a driver), or to protect your computer from malware (by adding a second firewall that doesn’t share the same network stack as it’s host), or perhaps to ensure that your traffic is sent over a VPN tunnel.
Turning a Linux-based computer into a router
So, this part is actually pretty easy. A router has two mandatory pieces, and one optional piece to it. Ensure forwarding is enabled, create a routing table and advertise it to the host, and then optionally, set up a firewall. Let’s pick these first two up in this block.
/etc/sysctl.conf you need to enable IP forwarding, and you do this by setting
net.ipv4.ip_forward=1. Once you’ve changed this setting, you need to encourage
sysctl to be reloaded by running
The next “required” bit, is a routing table. Assuming we’re still using this device using DHCP on the Wifi interface (or, in theory, the wired NIC in the case of a RPi 4), then the routing table should already exist. If you’re doing funky stuff to get static IP addressing done, then you should already know about routing too!
You also need to advertise routes to the host system, in this case, using DNSMasq.
# Default routes dhcp-option=usb0,option:router,192.0.2.1 dhcp-option=usb1,option:router,192.0.2.17 # /\ OR \/ - Don't do both, unless your static routes are addressing another IP # Static Routes dhcp-option=usb0,option:classless-static-route,203.0.113.0/24,192.0.2.1 dhcp-option=usb1,option:classless-static-route,203.0.113.0/24,192.0.2.17
Now, let’s move on to that “Optional” part. The firewall.
Turning a Linux-based computer into a firewall
Firewalling is, in theory, reasonably easy. A firewall basically allows or blocks traffic which matches a traffic pattern – like “Allow all traffic coming from my network” or “Block all traffic trying to reach this service”. It can also be encouraged to rewrite packets, to say “when coming from this network, change the address of all of the packets, so it looks like it’s coming from this address, not that one”.
The latest version of Raspberry Pi OS (formerly called “Raspbian”) supports IPTables in the image, and this is managed using a series of rules. While it’s possible to install something to simplify IPTables, like
ufw, instead, I’ll be using
iptables-persistent which writes the current IPTables to a file in
/etc/iptables/rules.v4 every time a package change occurs.
When you install this package, using
apt install iptables-persistent, it’ll ask you if you want to save the current rules. Say yes, and then customize this file.
My initial, “block nothing” file looks like this:
*filter :INPUT ACCEPT [0:0] :FORWARD ACCEPT [0:0] :OUTPUT ACCEPT [0:0] COMMIT
This means that it will, by default, accept packets targetting itself (the “INPUT” chain), accept packets targetting other hosts (the “FORWARD” chain), and will pass packets that have approved routing (the “OUTPUT” chain). We should probably change that.
*filter :INPUT DROP [0:0] :FORWARD DROP [0:0] :OUTPUT ACCEPT [0:0] COMMIT
Except, this now means we can’t access this device at all, so let’s make sure we can!
Between the “:OUTPUT ACCEPT” and “COMMIT” lines, add these lines:
# If you're already permitting a conversation, allow it to finish --append INPUT --match conntrack --ctstate RELATED,ESTABLISHED --jump ACCEPT # Accept TCP/22 traffic on usb0 and usb1 --append INPUT --in-interface usb0 --dport 22 --protocol tcp --jump ACCEPT --append INPUT --in-interface usb1 --dport 22 --protocol tcp --jump ACCEPT
The first rule there, the “
match conntrack” one, that basically needs to be the first one in all your firewall policies, per “chain” (input, output, forward) where you have any rules which aren’t “
ACCEPT“, and it ensures that your policies further down the chain aren’t interrupted if you reload the configuration of the firewall, and accidentally remove a flow you’ve already permitted.
Because we’re also using DHCP and DNS on this device, we need to permit those traffic flows, like this:
# DHCP --append INPUT --in-interface usb0 --protocol udp --dport 67:68 --sport 67:68 --jump ACCEPT --append INPUT --in-interface usb1 --protocol udp --dport 67:68 --sport 67:68 --jump ACCEPT # DNS --append INPUT --in-interface usb0 --protocol udp --dport 53 --jump ACCEPT --append INPUT --in-interface usb1 --protocol udp --dport 53 --jump ACCEPT --append INPUT --in-interface usb0 --protocol tcp --dport 53 --jump ACCEPT --append INPUT --in-interface usb1 --protocol tcp --dport 53 --jump ACCEPT
So, now we can access this device, and get DHCP and DNS traffic to flow. How about getting traffic OVER this device?
Unless we control the WiFi network which we’re connecting to, in order to force routing to this device, we need to perform a NAT operation, which isn’t in this file. Let’s start making some changes.
Assuming the network blocks you’re using for your USB interfaces are 192.0.2.0/28 and 192.0.2.16/28, we need to add this block to the start of that file:
*nat :PREROUTING ACCEPT [0:0] :INPUT ACCEPT [0:0] :OUTPUT ACCEPT [0:0] :POSTROUTING ACCEPT [0:0] --append POSTROUTING --source 192.0.2.0/28 --jump MASQUERADE --append POSTROUTING --source 192.0.2.16/28 --jump MASQUERADE COMMIT
The commands can be reduced significantly by using
-A instead of
-s instead of
-j instead of
--jump, but I find it much easier to understand the longer forms.
--jump MASQUERADE means “hide any traffic arriving from the defined pattern behind the address of this interface” which might also be known as a “Hide NAT”. Unfortunately, you can’t use
--in-interface usb0, which you can with the traffic rules later.
Most routers want to just let all traffic flow across them, but we want to protect our source network (the USB side, at least), so let’s permit some traffic
# If you're already permitting a conversation, allow it to finish --append FORWARD --match conntrack --ctstate RELATED,ESTABLISHED --jump ACCEPT # Allow all traffic initated from the USBx sides --append FORWARD --in-interface usb0 --jump ACCEPT --append FORWARD --in-interface usb1 --jump ACCEPT
The only thing we can’t really do here is to permit ingress traffic… and that’s because we can’t guarantee which USB interface will come up, so we don’t know which IP address to point service at. That needs a bridge between our two usb interfaces, so we’re only dealing with one DHCP scope… but I think that’s a subject for another post.
Wrapping it all up
As with the previous post, I have a set of post-install scripts in a Github Repo, here: https://github.com/JonTheNiceGuy/rpirouter/tree/StateRouter and I run these with
ansible-playbook, straight after I’ve written my image to the SD card.