Putting a systemd service behind a VPN

It recently occurred that I had the need to route all traffic by a certain application via a VPN. While premade solutions for this exist using Docker (especially in combination with BitTorrent clients) I preferred looking for something that really just isolated what is needed and leaves the rest alone.

As it turns out systemd has good support for Linux namespaces and sharing them between units.

The basic plan here is as follows:

  1. Create a network namespace

  2. Get internet access into our namespace

  3. Enable the VPN inside the namespace

  4. Run the intended application inside the namespace

Namespace & NAT

When systemd can create a namespace but it won't be linked to a name so it can be interacted with using ip netns. This is not strictly necessary but simplifies further setup a lot.

/etc/systemd/system/netns@.service:

[Unit]
Description=Network namespace %i
StopWhenUnneeded=true

[Service]
Type=oneshot
RemainAfterExit=yes

# systemd creates a new network namespace for us
PrivateNetwork=yes

# Create new named namespace using ip netns
# (this ensures that things like /var/run/netns are properly setup)
ExecStart=/sbin/ip netns add %i

# Drop the network namespace that ip netns just created
ExecStart=/bin/umount /var/run/netns/%i

# Re-use the same name for the network namespace that systemd put us in
ExecStart=/bin/mount --bind /proc/self/ns/net /var/run/netns/%i

# Clean up the name when we're done
ExecStop=/sbin/ip netns delete %i

Starting this unit gets us a namespace with a single interface (lo for localhost) inside. What we want to do next is configure a virtual link between the namespace and the "outside" and a simple NAT setup on top of that.

/etc/systemd/system/veth-setup@.service:

[Unit]
Description=Setup veth for network namespace %i
Requires=netns@%i.service
Requires=systemd-networkd.service
After=netns@%i.service

[Service]
Type=oneshot
RemainAfterExit=yes

# Load $ADDRESS and $ROUTES from here
EnvironmentFile=/etc/veth-setup.%i.conf

# Create a veth pair (vg = "veth guest", vh = "veth host")
ExecStart=/sbin/ip link add vh-%i type veth peer name vg-%i

# Move one end into the netns and set it up
ExecStart=/sbin/ip link set dev vg-%i netns %i
ExecStart=/sbin/ip netns exec %i ip l set vg-%i up
ExecStart=/sbin/ip netns exec %i ip a add dev vg-%i $ADDRESS
ExecStart=/sbin/ip netns exec %i sh -c 'echo "$ROUTES" | while read -r args; do ip r add $args; done'
ExecStart=/sbin/ip netns exec %i sysctl -w net.ipv6.conf.vg-%i.disable_ipv6=1

The units are generic but you will have to decide on a name for your namespace now. I will be using 'example'.

/etc/veth-setup.example.conf:

ADDRESS=192.168.255.1/30
ROUTES='default via 192.168.255.0
192.168.0.0/16 via 192.168.255.0'

In this case the 192.168.0.0/16 route is needed so I can reach the software from my home network even with the VPN active. If you have a more complex setup consider using a reverse proxy.

For convenience reasons we'll let systemd handle everything related to the host side including NAT setup.

/etc/systemd/network/vh-example.network:

[Match]
Name=vh-example
Driver=veth

[Network]
Address=192.168.255.0/30
IPMasquerade=ipv4
LinkLocalAddressing=no

Joining the namespace

At this point you will want modify the unit that enables the VPN connection to move it into the namespace. Run e.g. systemctl edit openvpn-client@nordvpn.service and configure as follows:

[Unit]
Requires=veth-setup@example.service
After=veth-setup@example.service
JoinsNamespaceOf=netns@example.service

[Service]
PrivateNetwork=true

Next do something similar (note the two different lines) for the service you wanted to isolate in the first place:

[Unit]
Requires=openvpn-client@nordvpn.service
After=openvpn-client@nordvpn.service
JoinsNamespaceOf=netns@example.service

[Service]
PrivateNetwork=true

At this point if you reload systemd and start the service, systemd will first create the namespace, run the setup commands, start the VPN service and then run the initial service. Inside the namespace everything will be routed through the VPN while the rest of the system is entirely unaffected.

Leak protection

To safeguard against bugs or misconfigurations you may want to make sure only VPN traffic is possible from the namespace. Here's an example with iptables and port 1194/UDP for VPN traffic:

-A FORWARD -s 192.168.255.0/30 -p udp -m udp --dport 1194 -j ACCEPT
-A FORWARD -s 192.168.255.0/30 -d 192.168.0.0/16 -j ACCEPT
-A FORWARD -s 192.168.255.0/30 -j DROP