Selective routing through a VPN tunnel

As mentioned in my previous post, the Reddit widget returned HTTP 403 errors on my Glance dashboard when it was deployed on a VPS. This is because Reddit blocks access to its REST API from known VPS subnets.

Is there a way to serve requests from a different IP address? Yes, through an HTTP proxy. Take a look at this cURL command:

curl -x http://203.0.113.20 http://ifconfig.me

The -x flag tells cURL to make this request by first proxying via the server at the address you specify. GET requests to ifconfig.me simply return the IP address from where the request came from. Now, 203.0.113.20 is a reserved IP address intended for use in documentation (see RFC 5735), but replace this with an actual proxy and you’ll see that IP address returned. Remove the -x and its argument and you’ll see your actual IP address.

Cool! So I could route the Reddit requests through a proxy so they don’t get blocked, but I’ll need the static IP address of a proxy server. If you use a VPN plan and can select servers (I’m using ProtonVPN), that’s one solution. Does that mean that all traffic should be routed through the VPN?

Maybe, but in my particular case, no. Some sites take issue with VPN subnets, and Lobsters seems to be one of them. This brings me to the crux of the issue: I want to selectively route some traffic through a proxy, but send all other traffic directly.

This might be useful in the future, so I’m going to set up a local proxy as well. If I proxy a request through it, the request should ultimately go through my VPN tunnel. If I don’t, the request should bypass the VPN tunnel. To make things easier, I’ll use these IP addresses as a reference for the remainder of this post. Substitute these with your own wherever you see them.

A test for success

I’ll be running a local proxy at 127.0.0.1:8888. If everything is up correctly, I would expect to see different IP addresses when executing each of these cURL commands:

curl http://ifconfig.me
# Expect to see the machine's IP address here (203.0.113.10)

curl -x http://127.0.0.1:8888 http://ifconfig.me
# Expect to see the VPN server's IP address here (203.0.113.20)

Setting up the tunnel

I’m performing this on Debian 13 and will need to install two packages: 1. tinyproxy: the local proxy 2. wireguard: a VPN tunnel

Setting up tinyproxy is simpler so I’ll start there. It should already have a default configuration file at /etc/tinyproxy/tinyproxy.conf, but I want to override a few things:

# Set the port to whatever port you want to expose internally
Port 8888

# Crucially, we only want to route traffic originating internally
# This is for security since the proxy isn't authenticated
Listen 127.0.0.1
Allow 127.0.0.1

# This is optional
# To outsiders, requests won't appear as if they've been proxied
DisableViaHeader Yes

Even though this configuration only permits local traffic, it’s worth adding a firewall rule to not expose 8888 externally.

ufw deny 8888

If you’re using ProtonVPN, their docs explain how to generate a WireGaurd configuration file. It’ll spit out something like this:

[Interface]
PrivateKey = <your_private_key>
Address = 10.2.0.2/32
DNS = 10.2.0.1

[Peer]
PublicKey = <your_public_key>
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = <your_vpn_server_ip_addr>

When I first applied this configuration, I was connected to my VPS over SSH. As written, this will effectively hijack all traffic and stall the SSH connection. One VPS restart later, I found out that the fix is simple: add Table = off to the [Interface] block. Furthermore, I don’t want any changes to DNS, so I remove that line. Save this file to /etc/wireguard/wg0.conf and apply the configuration (and a chmod 600 /etc/wireguard/wg0.conf for the security-conscious).

wg-quick up wg0

Now comes a deep-dive into somewhat esoteric networking. I’ll take this step by step, starting with inspecting our current routing table.

$ ip route show
default via 203.0.113.1 dev enp1s0 proto dhcp src 203.0.113.10 metric 1002
203.0.113.0/23 dev enp1s0 proto dhcp scope link src 203.0.113.10 metric 1002
198.51.100.10 via 203.0.113.1 dev enp1s0 proto dhcp src 203.0.113.10 metric 1002

This shows that all networking traffic is currently going through the default enp1s0 interface. First, I add a new routing table named vpn.

mkdir -p /etc/iproute2
echo "200 vpn" | sudo tee -a /etc/iproute2/rt_tables

Then, I add a new default route (which will match all destinations) on the wg0 interface to the new vpn table. All network traffic would use this table if it was the default table, but since it’s separate, it’s currently unused. There must also be an explicit route for WireGuard to route via the normal internet. The second route (the route to the VPN endpoint) is more specific than the default route, so it takes precedence for packets destined for the VPN server itself and they will still use the physical interface, bypassing the tunnel. If they were routed through the tunnel, they’d get stuck in a loop.

ip route add default dev wg0 table vpn
ip route add 203.0.113.20 via 203.0.113.1 dev enp1s0 table vpn

I then create a rule that routes all packets marked 1 (packets will get marked in the next step) should use the vpn routing table. Marks are just a number attached to individual packets.

ip rule add fwmark 1 table vpn

This is the magic part where we identify and mark packets. I use the iptables tool to mangle (modify, which in this case means marking) packets which are generated by us and egressing (we append to the OUTPUT chain), but only for packets from processes running as the tinyproxy user.

iptables -t mangle -A OUTPUT -m owner --uid-owner tinyproxy -j MARK --set-mark 1

Loading the owner module with -m owner enabled iptables to match packets based on which process created them. -j MARK is the action that instructs the command to “jump” to the MARK target.

DNS traffic must be excluded so as not to interfere with DNS lookup. DNS traffic routes through port 53 and should use normal routing.

iptables -t mangle -A OUTPUT -m owner --uid-owner tinyproxy -p udp --dport 53 -j RETURN
iptables -t mangle -A OUTPUT -m owner --uid-owner tinyproxy -p tcp --dport 53 -j RETURN

The final step is to apply network address translation (NAT) to these packets. The Address field in the WireGuard configuration file is 10.2.0.2/32, but these packets would show up as originating from the machine’s IP address.

iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE

Here, the NET table is selected (with -t). It will be applied to the POSTROUTING chain (with -A. This means NAT will be applied after routing decisions have been made, but prior to packets leaving). It should only match outgoing packets on the wg0 interface, and the NAT type should be MASQUERADE. This NAT type translates the IP addresses to what the VPN expects, and translates them back in return traffic. Failing to do this would prevent return traffic from finding its way back.

Try the cURL command again.

curl -x http://127.0.0.1:8888 http://ifconfig.me

You should see your VPN’s IP address returned!

Scripting for reuse

Wouldn’t it be great if we didn’t have to run all those commands every time we want to enable or disable this VPN tunnel? Just write a shell script.

#!/bin/bash

GATEWAY=$(ip route show default | awk '/default/ {print $3}')
INTERFACE=$(ip route show default | awk '/default/ {print $5}')
VPN_IP_ADDR=$(
    awk '/Endpoint/ {split($3, a, ":"); print a[1]}' \ 
    /etc/wireguard/wg0.conf
)

ip route add default dev wg0 table vpn
ip route add $VPN_IP_ADDR via $GATEWAY dev $INTERFACE table vpn

ip rule add fwmark 1 table vpn

IPTABLES_FLAGS="-t mangle -A OUTPUT -m owner --uid-owner tinyproxy"
iptables $IPTABLES_FLAGS -p udp --dport 53 -j RETURN
iptables $IPTABLES_FLAGS -p tcp --dport 53 -j RETURN
iptables $IPTABLES_FLAGS -j MARK --set-mark 1

iptables -t nat -A POSTROUTING -o wg0 -j MASQUERADE

Save this as setup-routing.sh in /etc/wireguard and add a cleanup-routing.sh script.

#!/bin/bash

VPN_IP_ADDR=$(
    awk '/Endpoint/ {split($3, a, ":"); print a[1]}' \ 
    /etc/wireguard/wg0.conf
)

ip route del $VPN_IP_ADDR table vpn 2>/dev/null
ip route del default dev wg0 table vpn 2>/dev/null

ip rule del fwmark 1 table vpn 2>/dev/null

IPTABLES_FLAGS="-t mangle -D OUTPUT -m owner --uid-owner tinyproxy"
iptables $IPTABLES_FLAGS -p tcp --dport 53 -j RETURN 2>/dev/null
iptables $IPTABLES_FLAGS -p udp --dport 53 -j RETURN 2>/dev/null
iptables $IPTABLES_FLAGS -j MARK --set-mark 1 2>/dev/null

iptables -t nat -D POSTROUTING -o wg0 -j MASQUERADE 2>/dev/null

Mark both files as executable. You might this these still need manual execution, but it gets better. WireGuard configuration files support PostUp and PreDown fields. Point those to this script and everything works seamlessly. Your final configuration file should look like this.

[Interface]
PrivateKey = <your_private_key>
Address = 10.2.0.2/32
Table = off
PostUp = /etc/wireguard/setup-routing.sh
PreDown = /etc/wireguard/cleanup-routing.sh

[Peer]
PublicKey = <your_public_key>
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = <your_vpn_ip_addr>

Enabling and disabling the tunnel is now simple.

wg-quick up wg0
wg-quick down wg0

# And to ensure the tunnel always starts on a reboot
systemctl enable wg-quick@wg0

Voila!

~K