Split tunneling using Wireguard and namespaces
Lately I've been working on improving my rag-tag homelab and I wanted to set up a bittorrent client on the homelab server. Most modern clients have web UIs so getting it running relatively easy, however, you really want use a privacy-focused VPN while seeding or downloading torrents. That makes things a little more complicated.
In my case, I wanted to be able to run Transmission on my homelab server and have it use NordVPN to talk to the outside world. In typical usage, you'd set up the VPN client and all your outbound internet traffic would go through the VPN. In my case, I didn't want that- I wanted Transmission to be the only app that uses the VPN. This is called split tunneling and there's a lot of ways to do it. After a lot of research, trial and error, and lots of swearing I figured out a method that I feel works well for my use case. This post walks through how I set everything up and hopefully it'll help someone.
Overview
The approach involves using a Linux network namespace along with Wireguard to connect to the VPN service. This basically creates an isolated network stack for torrent traffic, which leaves the rest of the host networking alone and makes sure that the only way for the torrent client to talk to the outside world is through the VPN connection. This sounds simple enough but there's enough Linux black magic here for anyone to get lost.
Let's start with some terminology and introduce some of the tools we'll be working with:
- Linux network namespaces (
netns
) are used to create isolated network stacks. Programs using this namespace can only see the devices that are explicitly setup and configured for the namespace. It's one of many Linux features used by tools like Docker to provide isolation. - Wireguard (
wg
) is a modern, fast VPN protocol. It's used by a lot of services like Tailscale and NordVPN, and it's what I'll be using to connect to NordVPN's servers. While I'm using NordVPN, this should work for any VPN service that allows you to connect usingwg
. - wg-netns is a Python script that bridges the gap between Wireguard and network namespaces. It automates a great deal of the ritual required to get Wireguard running inside of a namespace.
- systemd is a system and service manager used by most of the common Linux distros these days. I'll be using it to manage the services that create the network namespace, connect via wireguard, and run Transmission.
- Transmission is a lightweight, simple, and powerful torrent client. I like it, but I know a lot of folks are more fond of other clients. This setup should work with any client that has a web interface/API.
NordVPN and Wireguard
The first challenge is getting a working Wireguard configuration for connecting to NordVPN. While NordVPN uses Wireguard under the covers for its NordLynx technology, it doesn't provide any direct support for connecting over bare Wireguard.
But they can't stop us! You need two things for Wireguard to work - your private key and a peer to connect to. Nord doesn't make these trivial to obtain, but you can get them through the Nord API, and some kind souls already figured all this out for us.
- Get an access token for NordVPN. You don't need to download the Nord client, just grab the private key.
- Use the nord config generator and pick a server you want to connect to and download the config for it.
- Use the private key generator tool to turn your access token into a Wireguard private key.
After doing this, you should have a private key and a config file that looks like this:
[Interface] PrivateKey = YOUR_PRIVATE_KEY_HERE Address = 10.5.0.2/16 DNS = 103.86.96.100 [Peer] PublicKey = 0/x2PdBGfcIGr0ayFPFFjxcEEyhrlBRjR4kMcfwXJTU= AllowedIPs = 0.0.0.0/0, ::/0 Endpoint = 169.150.204.2:51820 PersistentKeepalive = 25
Setting up wg-netns
The next task is to get both Wireguard and wg-netns installed and setup. These instructions work well for Debian bookworm, but might need to be adjusted if you're using a different distro. First, install the prerequisites:
sudo apt install python3 python3-toml iproute2 wireguard-tools wget
Then grab the wg-netns
script. In this case I'm just grabbing it an putting it in /usr/local/bin
, but you could do fancier stuff if you want.
wget -O wg-netns https://raw.githubusercontent.com/dadevel/wg-netns/main/wgnetns/main.py
sudo install wg-netns /usr/local/bin
rm wg-netns
You can verify that it's installed by running:
wg-netns -h
At this point you'll need to prepare a configuration file based on the one you generated in the previous step as wg-netns uses a slightly different format. In my case, it looked like this and I named it nordvpn.json
:
{ "name": "nordvpn", "managed": true, "interfaces": [ { "name": "nordvpn", "address": ["10.5.0.2/16"], "dns-server": ["103.86.96.100"], "private-key": "YOUR PRIVATE KEY", "peers": [ { "public-key": "0/x2PdBGfcIGr0ayFPFFjxcEEyhrlBRjR4kMcfwXJTU=", "endpoint": "192.145.117.140:51820", "persistent-keepalive": 25, "allowed-ips": ["0.0.0.0/0"] } ] } ] }
Note "managed": true
, as I'll be using systemd to handle creating the namespace.
Once created, move it to /etc/wireguard
and set permissions:
sudo install -m 0600 -D -t /etc/wireguard nordvpn.json rm nordvpn.json
Setting up systemd for netns
Now that wg-netns
is ready, it's time to setup the systemd units for it. These will instruct systemd on how to create the namespace, startup the Wireguard connection, and route traffic.
Create all of these in /usr/local/lib/systemd/system
- you can create the directory if you need to:
sudo mkdir -p /usr/local/lib/systemd/system
cd /usr/local/lib/systemd/system
First up is wg-netns.target
, which creates a target that'll be used by the Transmission service.
[Unit] Description=WireGuard in Network Namespaces
Next is wg-netns@.service
, which actually runs wg-netns
for a specific configuration:
[Unit] Description=WireGuard in Network Namespace for %i After=network-online.target nss-lookup.target Wants=network-online.target nss-lookup.target PartOf=wg-netns.target [Service] Type=oneshot RemainAfterExit=yes ExecStart=/usr/local/bin/wg-netns up %i ExecStop=/usr/local/bin/wg-netns down %i Environment=WG_ENDPOINT_RESOLUTION_RETRIES=infinity Environment=WG_VERBOSE=1 WorkingDirectory=%E/wireguard ConfigurationDirectory=wireguard [Install] WantedBy=multi-user.target
If you're not a systemd expert like me, this might look kinda weird. This is a template unit- it lets you use a single unit file to run multiple instances of a service based on the instance name, which you can use via
%i
. In this case, the instance name is used to pick which configuration file to use. If you ever wanna set up another Wireguard connection you can do it without having to duplicate and change this unit file.
At this point you should be able to enable and start an instance of the service:
sudo systemctl enable --now wg-netns@nordvpn sudo systemctl status wg-netns@nordvpn
The @nordvpn
part tells the service which configuration file to use. If you used a different name than nordvpn.json
you'll need to make sure it matches.
If everything went okay, you should see good news from systemctl status
. If not, run sudo journalctl -eu wg-netns@nordvpn.service
to get a log and hopefully a clue as to what went wrong.
Testing the connection
If all went well, you should be able to test the connection within the namespace by pinging a known IP, like 8.8.8.8
:
sudo ip netns exec nordvpn ping 8.8.8.8
And if that works, you can verify the public IP address inside and outside of the namespace:
# Should return your real IP wget -qO - icanhazip.com # Should return your VPN IP sudo ip netns exec nordvpn wget -qO - icanhazip.com
If that all checks out then you're good to go! Now you can set up Transmission.
Running Transmission
Now that there's a whole network namespace with a VPN you can setup Transmission to run inside of it. First, install Transmission:
sudo apt install transmission-daemon
You'll probably need to disable the default systemd unit for transmission-daemon
:
sudo systemctl disable --now transmission-daemon.service
Next, create a service file in /usr/local/lib/systemd/system/transmission-nordvpn.service
to run Transmission within the network namespace:
[Unit] Description=Transmission under NordVPN Wants=wg-netns.target After=wg-netns.target [Service] User=debian-transmission Group=debian-transmission UMask=0002 Type=notify ExecStart=transmission-daemon --foreground --log-level=warn --config-dir=/opt/transmission/data --allowed=*.*.*.* ExecStop=/bin/kill -s STOP $MAINPID ExecReload=/bin/kill -s HUP $MAINPID NoNewPrivileges=true MemoryDenyWriteExecute=true ProtectSystem=true PrivateTmp=true NetworkNamespacePath=/var/run/netns/nordvpn BindReadOnlyPaths=/etc/netns/nordvpn/resolv.conf:/etc/resolv.conf:norbind [Install] WantedBy=multi-user.target
Note the use of NetworkNamespacePath
this tells systemd to put Transmission in our network namespace. I also added BindReadOnlyPaths
to that DNS resolution would work correctly.
Once created, you should be able to start Transmission:
sudo systemctl enable --now transmission-nordvpn sudo systemctl status transmission-nordvpn
If all goes well Transmission should start up successfully. In normal setups you'd be able to browse to {server-ip}:9091
to open the web interface, however, that requires a little more work.
Accessing Transmission from the host
The reason you can't connect to the web interface is because all that work you did with network namespaces has isolated it from the system's network! Thankfully, systemd has a fantastic solution to this: systemd-socket-proxyd. This is a small tool similar to socat
that handles proxying data across sockets. With systemd's built-in namespace support it can handle exposing Transmission's web UI socket to the host.
Once again, head over to /usr/local/lib/systemd/system/
and start by creating proxy-to-transmission-nordvpn.service
:
[Unit] Description=Proxy to transmission within netns Requires=transmission-nordvpn.service proxy-to-transmission-nordvpn.socket After=transmission-nordvpn.service proxy-to-transmission-nordvpn.socket JoinsNamespaceOf=transmission-nordvpn.service [Service] ExecStart=/usr/lib/systemd/systemd-socket-proxyd --exit-idle-time=5min 127.0.0.1:9091 PrivateNetwork=yes
And follow that up with the matching proxy-to-transmission-nordvpn.socket
:
[Unit] Description=Socket for Proxy to Transmission Daemon [Socket] ListenStream=9091 [Install] WantedBy=sockets.target
The .socket
file tells systemd to listen on 9091
and when a client connects systemd will automatically run the .service
and create a proxy. Systemd will automatically stop the proxy when it's idle for 5 minutes, saving system resources. I think that's pretty neat!
You can enable the socket using:
sudo systemctl enable --now proxy-to-transmission-nordvpn.socket
At this point you should be able to browse to {server-ip}:9091
and get to the Transmission web interface!
Closing thoughts
I'm happy with this setup but there's a lot of alternatives worth considering, such as running the VPN client in a Docker container ala gluetun. The Docker-centric setups can be a bit easier to wrap your head around and might work better for you.
There's a lot of projects and pages I looked at to get this all working, big thanks to these folks: