Source based routing with wireguard

Posted on Jan 16, 2022

What this article is about?

This article describes how to configure a linux router to send traffic from specific IPs to a non-default (wireguard) route. With such a setup, you will be able to use a VPN with ‘smart’ devices (A TV, Nintendo Switch, etc…) which do not have native wireguard support.

Configure wireguard interface

First, we configure a new wireguard interface which we will call sbr0. Note that we are not using wg-quick to bring the device up since we want to have full control over its configuration (and not use it as a default route anyway).

To bring the interface up, we can use the following script:

# The IPv4 to assign to the sbr0 device
IP=10.69.97.36/32
# The IPv6 to assign to the sbr0 device
IP6=fc00:bbbb:bbbb:bb01::8:fe93/128
# Pubkey of the remote peer
PUBKEY="+03cLSQzggzB01wyCyh4YPjIo3yBFX5TP6Fs47AJnSA="
# Endpoint of the remote peer
ENDPOINT=185.209.12.12:12345

ip link add dev sbr0 type wireguard
ip address add dev sbr0 $IP
ip address add dev sbr0 $IP6

## Note: this expects the private key to be stored in './privkey.key':
wg set sbr0 private-key ./privkey.key peer $PUBKEY allowed-ips 0.0.0.0/0,::0/0 endpoint $ENDPOINT

# Put returnpath filter into 'relaxed' mode for sbr0; without this, traffic will be dropped.
sysctl -w net.ipv4.conf.sbr0.rp_filter=2
# Finally, bring the device up:
ip link set up dev sbr0

Once you executed this script, you should be able to see the interface in wireguard by running wg show sbr0 and via ip a show sbr0.

Adding fwmark rules

The sbr0 interface should now be up but unusued, since we did not configure it to be our default route (which is not what we would want). The next task is to add it as a default route for a newly created table and tell the kernel to have packets with a certain fwmark use this table.

This can all be done using the ip command.

In the following example, we will create a new table with the ID ‘815’ and tell the kernel to use it for packets with the fwmark set to ‘123’:

# Configure sbr0 route for IPv4 and IPv6
ip -4 route add 0.0.0.0/0 dev sbr0 table 815
ip -6 route add ::0/0     dev sbr0 table 815
# Create fwmark rules to point to table 815
ip -4 rule add from all fwmark 123 table 815
ip -6 rule add from all fwmark 123 table 815

Ok, almost there: We now have:

  • An unused wireguard interface named sbr0
  • A routing table (815) which points to sbr0
  • …which is never used since normal traffic will not match the fwmark 123 rule.

Classify packets

Ok, time to hop over to our nftables config and tell the kernel to set a fwmark on packets which come from certain IPs.

# Internal/LAN interface (which receives traffic from sbr_net-ranges)
define lan_if = "eth9"
# ALWAYS use iifname for sbr0! the interface may change or not be present.
define sbr_if = sbr0
define sbr_net4 = { 192.168.3.123}
define sbr_net6 = { 2a02:1:2:3::df32/128 }

table inet filter {
...
  chain sbr_classify {
     comment "mark all traffic for sbr routing table in range which should use source based routing"
     type filter hook prerouting priority -150;
     ip  saddr $sbr_net4 meta mark set 123
     ip6 saddr $sbr_net6 meta mark set 123
  }
  chain postrouting {
     type nat hook postrouting priority 100;
	 ...
	 iif $lan_if oifname $sbr_if ip  saddr $sbr_net4 counter masquerade
	 iif $lan_if oifname $sbr_if ip6 saddr $sbr_net6 counter masquerade
	 ...
  }
}

So what did we do here?

This config snippet:

  • Defines the source IP (ranges) which should use our wireguard tunnel
  • Creates the new sbr_classify chain which applies the fwmark to traffic coming from these ranges
  • Tells the kernel to masquerade in-scope traffic (postrouting chain)

Does it work?

Maybe? Depends on how paranoid your existing nftables rules are. My config has some pretty strict rules when it comes to forwarding, so we need to explicitly allow traffic to be forwarded from and to sbr0:

table inet filter {
  chain forward {
    type filter hook forward priority 0; policy drop;
	...
	# make sure that FORWARDING is allowed:
	iif     $lan_if oifname $sbr_if ip  saddr $sbr_net4 counter accept
	iifname $sbr_if oif     $lan_if ip  daddr $sbr_net4 counter accept
	iif     $lan_if oifname $sbr_if ip6 saddr $sbr_net6 counter accept
	iifname $sbr_if oif     $lan_if ip6 daddr $sbr_net6 counter accept
  }
}

After this, you should be good to go. A quick way to check whether or not things are working as expected is to use the ip4.me and ip6.me API:

$ wget -O - -q http://ip4.me/api
...
$ wget -O - -q http://ip6.me/api
...