routing Linux processes through WireGuard

Cover image

Common knowledge goes that it's straightforward to achieve split routing / split tunneling with WireGuard and Linux. The details aren't trivial.

The Linux network stack can readily differentiate between processes running as different users (by uid).

I decided there's no choice but to add a "service" user that will run the code which needs to be routed through the WireGuard tunnel. I created a user called tunneler, which ended up with uid=1002.

The WireGuard Tunnel

In its most basic setup - route all traffic through the tunnel - WireGuard will use a fwmark, two ip-rules and an ip-route table.

  • My LAN is on 192.168.1.0/24.
  • The WireGuard tunnel will be 192.168.2.0/24 (also fd00:1337::/64).

    • This machine, the local peer, 192.168.2.2 (also fd00:1337::2).
    • The remote peer 192.168.2.1 (also fd00:1337::1). Calling this a WireGuard server (in WireGuard terminology it's just a peer that happens to perform the routing, calling it "server" is a stretch).

All of the local peer tunnel's control packets will get fwmark = 51820 from within the WireGuard driver itself. The default rule for dealing with 0.0.0.0/0 will be "muted", priority 32764 in the listing:

ip rule
0:      from all lookup local
32764:  from all lookup main suppress_prefixlength 0
32765:  not from all fwmark 0xca6c lookup 51820
32766:  from all lookup main
32767:  from all lookup default

Then routes for all packets that aren't WireGuard's control messages (fwmark=51820) will be looked up in table 51820.

The WireGuard tunnel is maintained with UDP packets sent between the local and the remote peer. Those packets need to be sent over the "real" network interface and not over the tunnel. The tunnel is a virtual construct on top of the "real" network interfaces.

Table 51820 just uses the WireGuard interface wg0 as the default:

ip route show table 51820
default dev wg0 scope link 

The iptables Way

This is the "manual" method and it's less elegant than the one I show in the next section.

I'll mark myself with iptables all network packets generated by user tunneler, then route the marked packets through wg0.

The WireGuard config for this:

[Interface]
Address = 192.168.2.2/24, fd00:1337::2/64
PrivateKey = <key goes here>
Table = 31337

PostUp = iptables -t mangle -A OUTPUT -d 192.168.1.0/24 -j ACCEPT #1
PostUp = iptables -t mangle -A OUTPUT \! -d `wg show wg0 endpoints | cut -f2 | cut -d':' -f1` -m owner --uid-owner tunneler -j MARK --set-mark 31337 #2
PostUp = iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE #3
PostUp = ip rule add fwmark 31337 table 31337 prio 31337 #4

PostUp = ip6tables -t mangle -A OUTPUT -d fc00::/7 -j ACCEPT #1 for ipv6
PostUp = ip6tables -t mangle -A OUTPUT -m owner --uid-owner tunneler -j MARK --set-mark 31337 #2 for ipv6
PostUp = ip6tables -t nat -A POSTROUTING -o wg0 -j MASQUERADE #3 for ipv6
PostUp = ip -6 rule add fwmark 31337 table 31337 prio 31337 #4 for ivp6

PreDown = iptables -t mangle -D OUTPUT -d 192.168.1.1 -j ACCEPT #undo 1
PreDown = iptables -t mangle -D OUTPUT \! -d `wg show wg0 endpoints | cut -f2 | cut -d':' -f1` -m owner --uid-owner tunneler -j MARK --set-mark 31337 #undo 2
PreDown = iptables -t nat -D POSTROUTING -o wg0 -j MASQUERADE #undo 3
PreDown = ip rule del fwmark 31337 table 31337 prio 31337 #undo 4

PreDown = ip6tables -t mangle -D OUTPUT -d fc00::/7 -j ACCEPT #undo 1 for ipv6
PreDown = ip6tables -t mangle -D OUTPUT -m owner --uid-owner tunneler -j MARK --set-mark 31337 #undo 2 for ipv6
PreDown = ip6tables -t nat -D POSTROUTING -o wg0 -j MASQUERADE #undo 3 for ipv6
PreDown = ip -6 rule del fwmark 31337 table 31337 prio 31337 #undo 4 for ipv6
[Peer]
PublicKey = <peer key goes here>
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = <my.wireguard.server:port>
PersistentKeepalive = 25
  1. Accept everything tunneler might try on the local peer's LAN:

    iptables -t mangle -A OUTPUT -d 192.168.1.0/24 -j ACCEPT

    This is here so that the next rule gets short-circuited for LAN comms.

  2. Mark packets from user tunneler with fwmark=31337. It's important to exclude WireGuard's UDP control messages. I added a predicate that skips packets directly addressed to the WireGuard server, I only used the destination IP (DNS names don't work at the iptables level) and I only showcase this for ipv4 - it should be similar for ipv6:

    iptables -t mangle -A OUTPUT \
        \! -d `wg show wg0 endpoints | cut -f2 | cut -d':' -f1` \
        -m owner --uid-owner tunneler -j MARK --set-mark 31337
  3. Do a regular NAT for what exits through the WireGuard interface wg0.
  4. Send traffic with with fwmark=31337 to routing table 31337. That table is created by WireGuard itself due to the Table = 31337 config directive:

    ip route show table 31337
    default dev wg0 scope link 

The ip-rule Way

This is the more slipstreamed version.

[Interface]
Address = 192.168.2.2/24, fd00:1337::2/64
PrivateKey = <key goes here>
Table = 31337

PostUp = ip rule add uidrange `id -u tunneler`-`id -u tunneler` table 31337 prio 31337 #1
PostUp = ip -6 rule add uidrange `id -u tunneler`-`id -u tunneler` table 31337 prio 31337 #1 for ipv6
PostUp = ip rule add to 192.168.1.0/24 table main prio 31336 #2
PostUp = ip -6 rule add to fc00::/7 table main prio 31336 #2 for ipv6

PostDown = ip rule del uidrange `id -u tunneler`-`id -u tunneler` table 31337 prio 31337 #undo 1
PostDown = ip -6 rule del uidrange `id -u tunneler`-`id -u tunneler` table 31337 prio 31337 #undo 1 for ipv6
PostDown = ip rule del to 192.168.1.0/24 table main prio 31336 #undo 2
PostDown = ip -6 rule del to fc00::/7 table main prio 31336 #undo 2 for ipv6

[Peer]
PublicKey = <peer key goes here>
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = <my.wireguard.server:port>
PersistentKeepalive = 25
  1. ip-rule itself can deal with user ids, so iptables is not needed anymore. Everything originating from user tunneler (uid=1002 in my case) will be routed according to table 31337.
  2. With higher priority (prio 31336), everything targeted to the local peer's networks will be routed through table main as if there was no tunnel.

The rules on local peer would now be:

ip rule
0:      from all lookup local
31336:  from all to 192.168.1.0/24 lookup main
31337:  from all uidrange 1002-1002 lookup 31337
32766:  from all lookup main
32767:  from all lookup default

ip -6 rule
0:      from all lookup local
31336:  from all to fc00::/7 lookup main
31337:  from all uidrange 1002-1002 lookup 31337
32766:  from all lookup main

Reverse Routing

Computers in the WireGuard server's LAN won't be able to get replies to their packets routed to the local peer at this end of the tunnel unless something is done about it.

Assuming there's a machine called Pinger (192.168.100.2) in LAN (192.168.100.0/24) with the WireGuard server (192.168.100.1) running and ICMP echo through the tunnel:

ping 192.168.2.1
  1. Pinger at 192.168.100.2 sends ICMP echo request to 192.168.2.1.
  2. ICMP echo request reaches the WireGuard server 192.168.100.1 on the Lan.
  3. Wireguard server forwards ICMP echo request through the tunnel to 192.168.2.2.
  4. local peer 192.168.2.2 receives ICMP echo request with source address 192.168.100.2 and sends back ICMP echo reply to 192.168.100.2.
  5. The default ip rule listing from above indicates that the ipv4 rule with prio 32766: from all lookup main applies at local peer, and the packet is routed within local peer's LAN, never going back through the tunnel.

To fix this case the source address of those packets needs to be replaced with the WireGuard server's address, 192.168.2.1 / fd00:1337::1. Here's the MikroTik RouterOS setup:

/ip/firewall/nat chain=srcnat action=src-nat to-address=192.168.2.1 dst-address=192.168.2.2
/ipv6/firewall/nat chain=srcnat action=src-nat to-address=fd00:1337::1/128 dst-address=fd00:1337::2/128

and an untested attept at a Linux iptables equivalent:

iptables -t nat -A POSTROUTING -d 192.168.2.2 -j SNAT --to 192.168.2.1
ip6tables -t nat -A POSTROUTING -d fd00:1337::2 -j SNAT -- to fd00:1337::1