How to exclude a website from VPN gateway using IP based split tunneling on GNU Linux?

How to exclude a website from VPN gateway using IP based split tunneling on GNU Linux?
Photo by DCL "650" / Unsplash

Some websites often block access through a work or private VPN, including your government web resources, streaming sites like Netflix, HBO, Hulu, etc. While it is easy to disable VPN and open the website or search for a better service, doing it several times a day can be painful. You have to first disable the VPN, open the website you need for this two-minute task, close it, reconnect to the VPN. And even then, if you are not careful enough, your browser can detect these network changes without much effort, rendering the use of a privacy VPN worthless. This cumbersome behaviour or process can be avoided altogether. The idea is to route certain websites to your default gateway (i.e. your normal ISP) when you are connected to a VPN.

Routing and metrics

When an application - the browser in this case - creates a network packet containing data, the operating system will route the data to another host, the one you are requesting. When the VPN connection is established, the OS will redirect the global traffic to the VPN server IP. That is, it would change the default gateway to the VPN gateway. Let's illustrate this with a real-world example.

When your VPN connection is down:

$ ip route show
default via 192.168.1.1 dev enp0s25 proto dhcp metric 600
192.168.1.0/24 dev enp0s25 proto kernel scope link src 192.168.1.25 metric 600

When your VPN connection is up:

$ ip route show
default via 192.168.1.1 dev enp0s25 proto dhcp metric 600
169.254.0.0/16 dev wg0 scope link metric 1000
172.16.0.0/24 dev wg0 proto kernel scope link src 172.16.0.16 metric 50
192.168.2.0/24 dev enp0s25 proto kernel scope link src 192.168.1.111 metric 600

When the VPN connection is down, we see two routes with metric 600, including the default route through interface enp0s25. When it's up, we get two new router through wg0 (Wireguard VPN connection) with higher metrics 50, which is new default route. So our global traffic will come through the VPN connection, in which case the OS will read the whole routing table and use a route with the lowest metric first. Metric is based on information such as path length, bandwidth, load, hop count, path cost, delay, maximum transmission unit (MTU), reliability and communications cost. And, of course, users can edit it to give more or less priority to the hosts they need.

Working with IP addressess

To route a website we need it's IP address. Let's get the IP adresses for example.com domain:

$ host example.com
host example.com
example.com has address 93.184.216.34
example.com has IPv6 address 2606:2800:220:1:248:1893:25c8:1946
example.com mail is handled by 0 .

And use the simple regex to extract the IP address from the output:

$ host example.com | grep -Eo "([0-9]{1,3}[\.]){3}[0-9]{1,3}"
93.184.216.34

Regex explanation:

  • [0-9]{1,3} - find from one to three digits [0-9]
  • [.] - find the dots "."
  • {3} - repeat two previous steps 3 times, cause we need the last block without dot
  • [0-9]{1,3} - find the last block

Let's store our IP address in a variable called ADDRS:

ADDRS="$(host example.com | grep -Eo "([0-9]{1,3}[\.]){3}[0-9]{1,3}")"

And check the output:

echo $ADDRS
104.18.2.161

Great! Add two more variables: GW - our default gateway and METRIC - metric for new routes we'd like to add.

GW=192.168.1.1
METRIC=7

And finally add the route to example.com:

for IP in $ADDRS
do
  sudo ip route add "$IP" via "$GW" metric "$METRIC"
done

Next, we need to verify the new route:

$ ip route show
default via 192.168.1.1 dev enp0s25 proto dhcp metric 600
192.168.1.0/24 dev enp0s25 proto kernel scope link src 192.168.1.25 metric 600
104.18.2.161 via 192.168.1.1 dev enp0s25 metric 7

Yeah! The last line of output is what we need for now. We don't need for loop 'cause example.com has only one line, but websites like Netflix has many more addresses and this command will work in that case too!

Looks like we finally got it right, but what is something was go wrong?
Let's check if our routing really works with enabled VPN connection:

$ ip route get 104.18.2.161
104.18.2.161 via 192.168.1.1 dev enp0s25  src 192.168.1.25
    cache

Success! Now remove this route 'cause we need to automate the process later:

for IP in $ADDRS
     do sudo ip route del "$IP"
done

Automation Scripts

Typing these commands every time will be boring and website address change from time to time. Let's create a script called out-vpn.sh which will do all heavy job.

#!/bin/sh

# DOMAIN can be different, like netflix.com, hulu.com, etc
DOMAIN="$1"
# your default gateway, get it from "ip route show" command output, don't forget to disable VPN first
GW=192.168.1.1

# get IP address, remove newlines and duplicates
get_IP(){
ADDRS="$(host $DOMAIN | grep -Eo "([0-9]{1,3}[\.]){3}[0-9]{1,3}")"
ADDRS="${ADDRS/$'\n'/ }"
ADDRS="$(tr ' ' '\n'<<<"${ADDRS}" | sort -u | xargs)"
}

# is route already provided?
route_available(){
    local IP="$1"
    out="$(ip route show "$IP")"
    if [[ "$out" != "" ]]
    then
        return 0
    else
        return 1
    fi


add_new_route(){
    echo "Adding ${ADDRS/$'\n'/,} to routing table ..." 1>&2
    for IP in "$ADDRS"
    do
        if ! route_available "$IP"
        then
            sudo ip route add "$IP" via ""$GW"" metric "$METRIC"
        else
            echo "route for $IP already exists"
        fi
    done
}

status(){
    echo "Routing info for the "$DOMAIN" (${ADDRS/$'\n'/,})
    for IP  in $ADDRS
    do
        ip route show "$IP"
    done
}

add_new_route
status

To reset all changes create the next script called out-vpn-reset.sh:

#!/bin/sh

# DOMAIN can be different, like netflix.com, hulu.com, etc
DOMAIN="$1"
GW=192.168.1.1

# get IP address, remove newlines and duplicates
get_IP(){
ADDRS="$(host $DOMAIN | grep -Eo "([0-9]{1,3}[\.]){3}[0-9]{1,3}")"
ADDRS="${ADDRS/$'\n'/ }"
ADDRS="$(tr ' ' '\n'<<<"${ADDRS}" | sort -u | xargs)"
}

# is route already provided?
route_available(){
local IP="$1"
    out="$(ip route show "$IP")"
    if [[ "$out" != "" ]]
then
    return 0
else
    return 1
fi

del_new_route(){
    echo "Removing ${ADDRS/$'\n'/,} route" 1>&2
    for IP in "$ADDRS"
    do
        if route_available "$IP"
        then
            sudo ip route del "$IP" via "$GW"
        else
            echo "route for $IP not found"
        fi
    done
}

status(){
    echo "Routing info for the "$DOMAIN" (${ADDRS/$'\n'/,})
    for IP  in $ADDRS
    do
        ip route show "$IP"
    done
}

del_new_route
status

Finally, we should have two scripts - the first for adding routes, the second for removing them:

chmod +x out-vpn*.sh
sudo ./out-vpn.sh example.com

# To reset all changes
sudo ./out-vpn-reset.sh example.com

IPv6

IPv6 is being adopted faster than we ever imagined. If you have a working IPv6 connection, the scripts above won't work for v6 websites because IPv6 has a higher priority in almost all operating systems. To fix this, we need to edit the above scripts for IPv6 compatibility.

First of all, we need to get all IPv6 domain addresses using the dig command:

$ ADDRS="$(dig +short AAAA netflix.com)"
$ echo $ADDRS

2600:1f18:631e:2f85:93a9:f7b0:d18:89a7 2600:1f18:631e:2f83:49ee:beaa:2dfd:ae8f 2600:1f18:631e:2f84:4f7a:4092:e2e9:c617

It works! The routing commands will be the same, but we should use -6 flag.
Let's make another two more scripts for IPv6: out-vpn-v6.sh and out-vpn-reset-v6.sh:

out-vpn-v6.sh

#!/bin/sh

# DOMAIN can be different, like netflix.com, hulu.com, etc
DOMAIN="$1"
# your default gateway, get it from "ip -6 route show" command output; don't forget to disable VPN first
GW=2001:0db8:0:f101::1

# get IP address, remove newlines and duplicates
get_IP(){
ADDRS="$(dig +short AAAA netflix.com)"
ADDRS="${ADDRS/$'\n'/ }"
ADDRS="$(tr ' ' '\n'<<<"${ADDRS}" | sort -u | xargs)"
}

# is route already provided?
route_available(){
    local IP="$1"
    out="$(ip -6 route show "$IP")"
    if [[ "$out" != "" ]]
    then
        return 0
    else
        return 1
    fi


add_new_route(){
    echo "Adding ${ADDRS/$'\n'/,} to routing table ..." 1>&2
    for IP in "$ADDRS"
    do
        if ! route_available "$IP"
        then
            sudo ip -6 route add "$IP" via ""$GW"" metric "$METRIC"
        else
            echo "route for $IP already exists"
        fi
    done
}

status(){
    echo "Routing info for the "$DOMAIN" (${ADDRS/$'\n'/,})
    for IP in $ADDRS
    do
        ip -6 route show "$IP"
    done
}

add_new_route
status

out-vpn-reset-v6.sh

To undo all changes create the next script called out-vpn-reset-v6.sh:

#!/bin/sh

# DOMAIN can be different, like netflix.com, hulu.com, etc
DOMAIN="$1"
GW=2001:0db8:0:f101::1

# get IP address, remove newlines and duplicates
get_IP(){
ADDRS="$(dig +short AAAA netflix.com)"
ADDRS="${ADDRS/$'\n'/ }"
ADDRS="$(tr ' ' '\n'<<<"${ADDRS}" | sort -u | xargs)"
}

# is route already provided?
route_available(){
local IP="$1"
    out="$(ip -6 route show "$IP")"
    if [[ "$out" != "" ]]
then
    return 0
else
    return 1
fi

del_new_route(){
    echo "Removing ${ADDRS/$'\n'/,} route" 1>&2
    for IP in "$ADDRS"
    do
        if route_available "$IP"
        then
            sudo ip -6 route del "$IP" via "$GW"
        else
            echo "route for $IP not found"
        fi
    done
}

status(){
    echo "Routing info for the "$DOMAIN" (${ADDRS/$'\n'/,})
    for IP  in $ADDRS
    do
        ip -6 route show "$IP"
    done
}

del_new_route
status

They should work like the IPv4 scripts above:

chmod +x out-vpn*.sh
sudo ./out-vpn-v6.sh example.com

# To reset all changes
sudo ./out-vpn-reset-v6.sh example.com

At this point, the job is done. You can easily add and remove websites or do IP-based split tunneling with your work or privacy VPN using the scripts above in the command line.

Thanks for reading!

Read more