Route to Schland - A poor man's VPN with sshuttle

This article explains how to set up a low-effort VPN with sshuttle. I found myself with a desire to route some traffic from dear old Switzerland through a server I happen to own in Germany and that turned out to be surprisingly easy with sshuttle.

I had looked at some of the commercial offerings that YouTube creators keep telling me about, but I disliked a couple of things about them:

  • The fact that most of them want to install software on your client device. I have multiple devices whose traffic I want to route through the VPN and not all of them have OSes that make it easy to install software onto.
  • Most commercial services seemed to focus on exit addresses in the US, while I wanted Germany.
  • Most commercial services, are, well commercial and I was already paying for a perfectly good server in Germany.

I looked at at some open source alternatives like haproxy, sniproxy or OpenVPN, but they were either pretty involved to set up or there were some question marks if they would support my use case of routing a separate full VLAN through a VPN tunnel. Enter sshuttle.

sshuttle

sshuttle is essentially ssh tunneling on steroids. It connects to a remote server via ssh, installs iptables/nftables rules on the local host to capture outgoing TCP connections (that needs root) and then multiplexes these data streams through ssh, connecting to the real servers on the remote host. With the TPROXY method, sshuttle can even support UDP, although I never needed that so far.

What’s nice about this is that it requires almost no setup on the remote server. It only needs a suitable python interpreter and some way for you to access it through ssh. sshuttle sends its own source code through the SSH connection (making use of the fact that python can execute code from stdin), so you don’t need to install it. And all the privileged network operations are on the client, so no privileges are reqired either.

Shovels Away

Let’s set up a VPN tunnel with sshuttle on a Raspberry Pi 4. I started with a headless installation.

Network setup

Because I wanted to route a whole group of devices through the tunnel, I created a new VLAN on my switch, using the 192.168.3.0/24 range of IP addresses. 192.168.3.1 is a gateway on the VLAN that can reach the outside internet. The RPi becomes 192.168.3.254 and we configure DHCP to tell clients to use that as their default gateway.

There’s one interesting wrinkle to sort out here. Because I want DHCP to hand out 192.168.3.254 as the default gateway, we cannot use DHCP to assign the default gateway for the RPi itself (or we’d go in circles…). So we’ll need to configure the RPi to ignore the default gateway DHCP is assigning and instead hardcode 192.168.3.1. The setup here differs between RPi OS 10 (Buster) and 12 (Bookworm) as the former uses dhcpcd and the latter uses Network Manager.

# For Raspberry Pi OS 12 (Bookworm)

# Find the connection
$ sudo nmcli connection show
NAME                UUID      
Wired connection 1  42d7c225-...
...

# Edit the connection overriding the address and gateway.
$ sudo nmcli connection modify "Wired connection 1" \
  ipv4.address "192.168.3.254/24" \
  ipv4.gateway "192.168.3.1"
# For Raspberry Pi OS 10 (Buster)

# /etc/dhcpcd.conf
...
interface eth0
static ip_address=192.168.3.254/24
static routers=192.168.3.1
static domain_name_servers=8.8.8.8

NOTE: I decided to be lazy here, using Google’s 8.8.8.8 DNS server instead of figuring out a way to use the local DNS forward of my ISP.

To allow routing packets for other clients on the VPN, we need to enable IP forwarding:

# /etc/sysctl.conf
...
net.ipv4.ip_forward=1
...
net.ipv6.conf.all.forwarding=1
...

$ sudo sysctl -p /etc/sysctl.conf

sshuttle Setup

I installed sshuttle through apt:

$ sudo apt-get install sshuttle

I created a new user to run the service as:

$ sudo adduser sshuttle
$ sudo passwd --delete --lock sshuttle

NOTE: The --delete and --lock arguments to passwd clear and lock the password, meaning this account cannot login through a password, but you can sudo to it and it can run jobs.

And I gave that user sudo access:

# /etc/sudoers.d/010_sshuttle-nopasswd 
sshuttle ALL=(ALL) NOPASSWD: ALL

NOTE: Remember that sshuttle needs privileged access to install iptables/nftables rules to capture the outgoing traffic. The sudo access here is broader than it needs to be allowing sshuttle to run any command as root, but I was too lazy to figure out which exact commands it needed and given the sshuttle user will run nothing else I decided this was good enough.

SSH Setup

Next sshuttle needs a way to connect to the remote server. I created a dedicated ssh key for this, so we also wouldn’t need to type in passwords for this. Let’s install this key in sshuttle’s home directory:

$ sudo -u sshuttle mkdir /home/sshuttle/.ssh
# Copy keyfile from previous host (or generate one)
$ sudo -u sshuttle chmod 0600 /home/sshuttle/.ssh/id_rsa

TIP: You will also need to add the corresponding public key (e.g., id_rsa.pub) file in the server’s known_hosts file.

A manual test

With this sshuttle should be ready to go and running a command like the following should give you a working tunnel without requiring you to enter any passwords:

$ sudo -u sshuttle /usr/bin/sshuttle -r <user@remote.host> 0/0

NOTE: On RPi OS 10 (Buster) the sshuttle binary lives in /usr/sbin

If this does ask for a password, go back and verify the sudo and ssh tunnel setup.

Running as a service

Now all we need is to ensure sshuttle runs as a daemon (i.e., it gets started automatically at boot and restarted if it dies). We can use systemd and follow this example file for systemd.

First, create a new service description:

# /etc/systemd/system/sshuttle.service 

[Unit]
Description=sshuttle service a permanent tunnel
After=network.target

[Service]
ExecStart=/usr/sbin/sshuttle -r <user@remote.host> -D --pidfile=/run/sshuttle/sshuttle.pid -x 192.168.0.0/16 --listen 0.0.0.0:0 0.0.0.0/0
User=sshuttle
Group=sshuttle
Restart=always
Type=forking
RuntimeDirectory=sshuttle
PIDFile=/run/sshuttle/sshuttle.pid

[Install]
WantedBy=multi-user.target

NOTE: I am seeing the “bad rule” errrors described here but the tunnel seems to work fine. I also ended up dropping --dns after seeing some DNS resolution issues.

And then enable and start the service:

$ sudo systemctl enable sshuttle
$ sudo systemctl start sshuttle

And at this point, all the devices in the 192.168.3.0/24 VLAN should have their Internet access tunneled through remote.host.