VPN split-tunneling is an extremely common feature for any commercial VPN, and I argue it’s an absolute essential. For example, regardless of your use case, you probably wouldn’t want to login to your bank from a VPN connection. If nothing else, they’d probably lock your account, creating a headache for you.

This makes it rather annoying if you want to split-tunnel for a noncommercial VPN, like one you host. Wireguard is built into the Linux kernel (as of version 5.6), along with some userland tools (like wg or wg-quick). The closest thing you have to split-tunneling is AllowedIPs in your Wireguard configuration file, but there are some immediately apparent issues:

  • Do you know all IP addresses of the service you want to tunnel? Probably not, as it’s rarely that simple.
  • If you want to exclude services instead of include, you still have to painstakingly list out all allowed IP addresses.

What if your use case for a VPN was playing a game with netcode that leaked your IP to other players?

AllowedIPs feels much more suited for the “traditional” use of a VPN, that being able to access resources on a restricted subnet. So, what are you supposed to do?

Enter: Linux namespaces

Also built into the kernel, namespaces are used by projects like Bubblewrap (utilized by Flatpak) and Docker for a couple different kinds of isolation. They’re both too complicated for the scope of this post, but Bubblewrap in particular is interesting for how granular it allows you to tweak Linux namespaces. If you find this topic interesting, you should read about it.

In any case, we can use them for split-tunneling. Any process spawned in a private network namespace is “forced” through the tunnel. It’s probably better to let one of the relevant kernel manuals explain it.

ip-netns(8)

A network namespace is logically another copy of the network stack, with its own routes, firewall rules, and network devices.

This method can also be used for commercial VPNs if your VPN lets you connect with generic Wireguard clients.

If you’re following along, make sure you:

  • can get root privileges
  • have a working wireguard config
  • have iproute2 installed

If you haven’t created a network namespace yet, then you’re in the host namespace.

Making split-tunnels with common system tools

There’s code in the Linux kernel that can enable it to act as a router, switch, and any number of other network devices. Creating another network namespace means creating a separate private network, so your computer will need to route for them.

First, we create a bridge network interface in the host namespace. This’ll act as the default gateway for your private namespace. In other words, any process in your VPN tunnel needs it to communicate with other networks, including the host.

You’ll also need to choose an unused private subnet.

ip link add br0 type bridge
ip address add 10.255.255.1/24 dev br0
ip link set br0 up

Now we have a bridge that routes for nothing. Cool!

It’s probably a good idea to give your network namespaces a unique name. My server is in Hilsboro, Oregon, so:

ip netns add hils-oregon-1 
ip link hilsveth0 netns hils-oregon-1 type veth peer name hilsveth1 netns 1
ip link set hilsveth1 master br0

That was a really big and ugly command, but this created a virtual ethernet interface in both the host and hils-oregon-1. Without them, you wouldn’t be able to communicate between namespaces. hilsveth0 exists in hils-oregon-1 and hilsveth1 exists in the hosts namespace, as it is the same as PID 1 (netns 1).

More boilerplate, but still:

ip -n hils-oregon-1 address add 10.255.255.101/24 dev hilsveth0 # or any arbitrary address
ip -n hils-oregon-1 address add 127.0.0.1/8 dev lo
ip -n hils-oregon-1 link set lo up
ip -n hils-oregon-1 link set hilsveth0 up

At this point, you’ll be able to ping your own virtual interface and nothing else. There’s two reasons for this:

  1. We haven’t enabled IP forwarding
  2. We need to establish the routing tables in hils-oregon-1
ip -n hils-oregon-1 route add 10.255.255.1 via hilsveth0
ip -n hils-oregon-1 route add default via 10.255.255.1 dev hilsveth0

The first command is necessary because your routing table is empty, and so you won’t be able to ping your gateway despite being on the same subnet. The second command is a rule forwards your traffic to your bridge. It searches for the longest match first, though; if you create a rule to specifically route 1.1.1.1 by some interface, that’s used instead of the generic default route.

Finally, we’ll want to enable IP forwarding.

Note: This would usually be something you’d want to approach with extreme caution. If you’re routing for other hosts, traffic appears as though it comes from your host. For this use case, though, there probably isn’t much of a functional difference compared to routing on the host.

# enable ip forwarding functionality in the kernel
echo 1 > /proc/sys/net/ipv4/ip_forward # or sysctl net.ipv4.ip_forward=1
# tell iptables we're routing for this subnet (unless the destination is the bridge)
iptables -t nat -A POSTROUTING -s 10.255.255.0/24 ! -o br0 -j MASQUERADE

Now you’ll be able to ping your virtual interfaces from your host, your bridge from your private namespace, and any other address you feel like.

[c0mp@uter 17:57:37(~)]ping -c 4 10.255.255.101
PING 10.255.255.101 (10.255.255.101) 56(84) bytes of data.
64 bytes from 10.255.255.101: icmp_seq=1 ttl=64 time=0.072 ms
64 bytes from 10.255.255.101: icmp_seq=2 ttl=64 time=0.062 ms
64 bytes from 10.255.255.101: icmp_seq=3 ttl=64 time=0.062 ms
64 bytes from 10.255.255.101: icmp_seq=4 ttl=64 time=0.050 ms

--- 10.255.255.101 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3047ms
rtt min/avg/max/mdev = 0.050/0.061/0.072/0.007 ms

[c0mp@uter 17:58:39(~)]doas -n ip netns exec hils-oregon-1 ping -c 4 c0mp.rocks
PING c0mp.rocks (5.78.45.237) 56(84) bytes of data.
64 bytes from deb4.hils-oregon-01.rocks (5.78.45.237): icmp_seq=1 ttl=50 time=68.8 ms
64 bytes from deb4.hils-oregon-01.rocks (5.78.45.237): icmp_seq=2 ttl=50 time=65.8 ms
64 bytes from deb4.hils-oregon-01.rocks (5.78.45.237): icmp_seq=3 ttl=50 time=63.5 ms
64 bytes from deb4.hils-oregon-01.rocks (5.78.45.237): icmp_seq=4 ttl=50 time=63.6 ms

--- c0mp.rocks ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3004ms
rtt min/avg/max/mdev = 63.520/65.428/68.768/2.129 ms

After setting up Wireguard in your private namespace (with ip netns exec hils-oregon-1 wg-quick ...), you’re all set! You now have a split tunnel for anything spawned in this namespace.

It’s a really good idea to change your shell prompt when you’re in another namespace.

[c0mp@uter 20:48:22(~)]doas -n ip netns exec su c0mp
[c0mp@uter 20:48:45(~)]export PS1="hils-oregon-1 ${PS1}" XDG_RUNTIME_DIR="/run/user/$(id -u)"
hils-oregon-1 [c0mp@uter 20:49:26(~)]curl ident.me
5.78.45.237

Upon a reboot, net.ipv4.ip_forward will be set to its default, and your network interfaces will be deleted. However, if you wanted to undo everything done so far, you could do:

ip link del hilsveth1
ip link del br0
echo 0 > /proc/sys/net/ipv4/ip_forward # or sysctl net.ipv4.ip_forward=0
ip netns del hils-oregon-1
iptables -t nat -D POSTROUTING -s 10.255.255.0/24 ! -o br0 -j MASQUERADE

It’s a lot more simple to undo than it is to do.

“Aren’t there scripts that do this?”

Granted, that was an extremely involved process. I’ve compiled all commands into this script which starts another shell session in your tunnel, which makes me probably the 500th person to write such a script. Though, if you’ve followed intently, you’d have a better foundation for attempting other tinkery Linux kernel things (like sharing internet with another device).

There are some caveats with implementing a split tunnel like this, though:

  1. Need root anytime you start a program in the tunnel
  2. Can get tedious to manually include for a larger number of programs
  3. There isn’t really anything stopping something like an inter-process IP leak

The third point is something you need to consider the worth of, but what I’ve done is:

  1. You may specify that the ip command can be executed by you without the need for a password, though it may make exploitation easier if it is a potentially vulnerable binary. Sudo users should see Solene’s guide for how to do that.
  2. Either:
    • Set an alias to start a bash tunnel: alias tunnel-hils='doas -n /bin/ip netns exec hils-oregon-1 bash -c "su c0mp"' >> ~/.bashrc. Maybe it’s just slightly less tedious.
    • Edit .desktop files to launch in your private namespace, if applicable. This is seamless if you created a Sudo rule.

Edit 2024-12-24: I realized some programs may not work without taking special care of some environment variables. If you’re a Pipewire+Pulseaudio user like I am, you must set XDG_RUNTIME_DIR in your private namespace, or make sure that the variable isn’t overwritten. Otherwise, private namespaces won’t have sound. I’ve changed some commands in the guide section to reflect this, though I’d recommend actually configuring sudo/doas appropriately as it’s far more convenient.

The script itself also had some changes since it errored by default. Now it errors less.