Skip to content

Instantly share code, notes, and snippets.

@kriswebdev
Last active February 25, 2024 07:35
Show Gist options
  • Star 15 You must be signed in to star a gist
  • Fork 4 You must be signed in to fork a gist
  • Save kriswebdev/c19e103bd69c994a1c16ced004908c76 to your computer and use it in GitHub Desktop.
Save kriswebdev/c19e103bd69c994a1c16ced004908c76 to your computer and use it in GitHub Desktop.
forcevpn: Force VPN for specific apps, in a better way than killswitch [Linux / OpenVPN]
#!/bin/bash
# === INFO ===
# ForceVPN
# Description: Force VPN tunnel for specific applications.
# If the VPN is down => blackhole the app network traffic.
# Better than a killswitch. IPv4.
VERSION="2.3.0"
# Author: KrisWebDev
# Requirements: Linux with kernel > 2.6.4 (released in 2008).
# This version is only tested on Ubuntu 19.10 with bash.
# Main dependencies are automatically installed.
# Script will guide you for iptables 1.6.0 install if needed.
# Note: This script will disable IPv6 (enable with --clean)
# dnsmasq users: You're usinq dnsmasq if you find "dns=dsnmasq" in /etc/NetworkManager/NetworkManager.conf
# gksudo gedit /etc/NetworkManager/dispatcher.d/forcevpn-dispatcher.sh
# Insert content of below commented script
# sudo chmod +x /etc/NetworkManager/dispatcher.d/forcevpn-dispatcher.sh
# See for more info: http://askubuntu.com/a/703665/263353
# Change uint32 value with your VPN provider DNS server IP converted to Integer:
# http://www.aboutmyip.com/AboutMyXApp/IP2Integer.jsp
: '
#!/bin/bash
interface=$1
status=$2
case $status in
vpn-up)
# because dnsmasq keep DNS LAN and leak our DNS, hard-code DNS servers
dbus-send --system --dest=org.freedesktop.NetworkManager.dnsmasq --type=method_call /uk/org/thekelleys/dnsmasq uk.org.thekelleys.SetServers
dbus-send --system --dest=org.freedesktop.NetworkManager.dnsmasq --type=method_call /uk/org/thekelleys/dnsmasq uk.org.thekelleys.SetServers uint32:3250021018
dbus-send --system --dest=org.freedesktop.NetworkManager.dnsmasq --type=method_call /uk/org/thekelleys/dnsmasq uk.org.thekelleys.SetServers uint32:3112519796
# flush DNS cache
pkill --signal SIGHUP dnsmasq
# provide access to dnsmasq when vpn is up
iptables -N forcevpn_rule_set
iptables -I forcevpn_rule_set -o lo -p udp --dport 53 -j RETURN
;;
vpn-down)
# flush DNS cache
pkill --signal SIGHUP dnsmasq
# deny access to dnsmasq when vpn is down
iptables -D forcevpn_rule_set -o lo -p udp --dport 53 -j RETURN
;;
esac
'
# === LICENSE ===
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# === CONFIGURATION ===
vpn_interface="tun0"
# === ADVANCED CONFIGURATION ===
cgroup_name="forcevpn" # Better keep it with purely lowercase alphabetic & underscore
iptables_rule_set_name="${cgroup_name}_rule_set"
net_cls_classid="0x00220022" # Anything from 0x00000001 to 0xFFFFFFFF
# === CODE ===
vpn_interface_gateway=`ip route | grep "dev ${vpn_interface}" | awk '/^default/ { print $3 }'`
#vpn_interface_ip=`ip addr show "$vpn_interface" | awk '$1 == "inet" {gsub(/\/.*$/, "", $2); print $2}'`
# Handle options
action="command"
background=false
skip=false
allow_localhost=false
disable_localhost=false
init_nb_args="$#"
sudok=false
while [ "$#" -gt 0 ]; do
case "$1" in
-b|--background) background=true; shift 1;;
-i|--bind) action="bind"; shift 1;;
-u|--unbind) action="unbind"; shift 1;;
-l|--list) action="list"; shift 1;;
-s|--skip) skip=true; shift 1;;
-L|--localhost) allow_localhost=true; shift 1;;
-P|--no-localhost) disable_localhost=true; shift 1;;
-I|--info) action="info"; shift 1;;
-c|--clean) action="clean"; shift 1;;
--conf-restart) action="conf-restart"; shift 1;;
-h|--help) action="help"; shift 1;;
-v|--version) echo "forcevpn v$VERSION"; exit 0;;
--sudok) sudok=true; shift 1;;
-*) echo "Unknown option: $1. Try --help." >&2; exit 1;;
*) break;; # Start of COMMAND or LIST
esac
done
if [ "$init_nb_args" -lt 1 ] || [ "$action" = "help" ] ; then
me=`basename "$0"`
echo -e "Usage : \e[1m$me [\e[4mOPTIONS\e[24m] \e[4mCOMMAND\e[24m [\e[4mCOMMAND PARAMETERS\e[24m]\e[0m"
echo -e " or : \e[1m$me [\e[4mOPTIONS\e[24m] { --bind | --unbind } \e[4mLIST\e[24m\e[0m"
echo -e "Force (bind) program \e[4mCOMMAND\e[24m inside the VPN tunnel interface."
echo
echo -e "\e[1m\e[4mOPTIONS\e[0m:"
echo -e "\e[1m-b, --background\e[0m Start \e[4mCOMMAND\e[24m as background process (release the shell)."
echo -e "\e[1m-i, --bind \e[4mLIST\e[24m\e[0m Force (bind) running process \e[4mLIST\e[24m inside tunnel. \e[1mBROKEN!\e[0m"
echo -e "\e[1m-u, --unbind \e[4mLIST\e[24m\e[0m Cancel force bind for running process \e[4mLIST\e[24m."
echo -e "\e[1m-l, --list\e[0m List processes binded inside tunnel."
echo -e "\e[1m-s, --skip\e[0m Don't setup system config & don't ask for root;\n just perform public routing test and run \e[4mCOMMAND\e[24m."
echo -e "\e[1m-L, --localhost\e[0m Add rule to allow traffic with localhost (disabled by default)."
echo -e "\e[1m-P, --no-localhost\e[0m Remove rule to allow traffic with localhost."
echo -e "\e[1m-l, --list\e[0m List processes binded inside tunnel."
echo -e "\e[1m-I, --info\e[0m Display debug information and exit."
echo -e "\e[1m-c, --clean\e[0m Move back all proceses to initial routing settings and remove system config."
echo -e "\e[1m-v, --version\e[0m Print this program version."
echo -e "\e[1m-h, --help\e[0m This help."
echo
echo -e "\e[1m\e[4mLIST\e[0m: List of process ID or names separated by spaces."
exit 1
fi
# This program can't ask for root outside terminal
if [ ! -t 1 ] && [ "$(id -u)" -ne 0 ]; then
skip=true
fi
if [ "$allow_localhost" = true ] && [ "$disable_localhost" = true ]; then
echo -e "\e[31mCan't use --localhost with --no-localhost. Aborting.\e[0m" >&2
exit 1
fi
if [ "$skip" = true ]; then
if [ "$allow_localhost" = true ] || [ "$disable_localhost" = true ]; then
echo -e "\e[33mWARNING: Ignoring localhost traffic setup options as --skip option is enabled.\e[0m" >&2
fi
if [ "$action" = "clean" ]; then
echo -e "\e[31mCan't use --skip with --clean. Aborting.\e[0m" >&2
exit 1
fi
fi
# Helper functions
# Check the presence of required system packages
check_install_package(){
nothing_installed=1
for package_name in "$@"
do
if ! dpkg -s "$package_name" &> /dev/null; then
echo "Installing $package_name"
sudo apt-get install "$package_name"
nothing_installed=0
fi
done
return $nothing_installed
}
check_package(){
for package_name in "$@"
do
if ! dpkg -s "$package_name" &> /dev/null; then
#echo "Installing $package_name"
#sudo apt-get install "$package_name"
return 0
fi
done
return 1
}
# List processes binded to the VPN tunnel
list_bind(){
return_status=1
echo -e "PID""\t""CMD"
while read task_pid
do
echo -e "${task_pid}""\t""`ps -p ${task_pid} -o comm=`";
return_status=0
done < /sys/fs/cgroup/net_cls/${cgroup_name}/tasks
return $return_status
}
# Check and setup iptables - requires root even for check
iptable_checked=false
setup_iptables(){
if ! sudo iptables -C OUTPUT -m cgroup --cgroup "$net_cls_classid" -j "$iptables_rule_set_name" 2>/dev/null; then
echo "Adding iptables rule to drop packets with class identifier $net_cls_classid not exiting through ${vpn_interface} or locally (DNS)" >&2
sudo iptables -N "$iptables_rule_set_name"
# Moved to Networkmanager dispatcher script for better security
#if [ "$allow_localhost" = true ]; then
# sudo iptables -I "$iptables_rule_set_name" -o lo -j RETURN
#fi
# Bad alternative that leads to massive quick retries hence CPU load: -j REJECT --reject-with icmp-net-prohibited
sudo iptables -A "$iptables_rule_set_name" ! -o "$vpn_interface" -j DROP
sudo iptables -I OUTPUT -m cgroup --cgroup "$net_cls_classid" -j "$iptables_rule_set_name"
fi
iptable_checked=true
}
setup_iptables_localhost(){
if ! sudo iptables -C "$iptables_rule_set_name" -d "127.0.0.1" -j ACCEPT 2>/dev/null; then
if [ "$allow_localhost" = true ]; then
echo "Adding iptables rule to allow localhost trafic" >&2
sudo iptables -I "$iptables_rule_set_name" -d "127.0.0.1" -j ACCEPT
fi
elif [ "$disable_localhost" = true ]; then
echo "Adding iptables rule to allow localhost trafic" >&2
sudo iptables -D "$iptables_rule_set_name" -d "127.0.0.1" -j ACCEPT
fi
iptable_checked=true
}
# Test if config is working, IPv4 only
testresult=true
test_routing(){
exit_ip="$(cgexec -g net_cls:"$cgroup_name" traceroute -n -m 1 8.8.8.8 | sed -n '2{p;q}' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
if [ -z "$exit_ip" ]; then
# Old traceroute
exit_ip="$(cgexec -g net_cls:"$cgroup_name" traceroute -m 1 8.8.8.8 | sed -n '2{p;q}' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' | head -1)"
if [ -z "$exit_ip" ]; then
echo -e "\e[31mTest failed: Unable to determine source exit IP (found \"$exit_ip\").\e[0m" >&2
if [ "$skip" = true ]; then
echo -e "\e[31mYou should remove --skip option to perform setup.\e[0m" >&2
fi
testresult=false
return 0
fi
fi
if [ -z "$vpn_interface_gateway" ]; then
echo -e "\e[31mTest failed: Unable to determine VPN interface gateway IP (found \"$vpn_interface_gateway\").\e[0m" >&2
testresult=false
return 0
fi
ping6 -t 1 -c 1 -n 2001:4860:4860::8888 2>/dev/null
retcode=$?
if [ "$retcode" -ne 2 ]; then
echo -e "\e[31mTest failed: IPv6 is not disabled or unable to test.\e[0m" >&2
fi
if [ "$exit_ip" == "$vpn_interface_gateway" ]; then
echo -e "\e[32mTest OK. Trafic exits with IP \"$exit_ip\".\e[0m" >&2
testresult=true
return 0
else
echo -e "\e[31mTest failed: Trafic exits with \"$exit_ip\" instead of \"$vpn_interface_gateway\". Aborting.\e[0m" >&2
testresult=false
return 1
fi
}
# Reconfigure routing
reroute(){
if [ -z "$vpn_interface_gateway" ]; then
echo -e "\e[31mCan't find default gateway of VPN interface \"${vpn_interface}\". Is it up?\e[0m" >&2
echo -e "\e[31mAborting.\e[0m" >&2
exit 1
fi
if [ "$skip" = false ]; then
if [ -z "`lscgroup net_cls:$cgroup_name`" ] || [ `stat -c "%U" /sys/fs/cgroup/net_cls/${cgroup_name}/tasks` != "$USER" ]; then
echo "Creating cgroup net_cls:${cgroup_name}. User $USER will be able to move tasks in it without root permissions." >&2
sudo cgcreate -t "$USER":"$USER" -a `id -g -n "$USER"`:`id -g -n "$USER"` -g net_cls:"$cgroup_name"
check_iptables=true
fi
if [ "$check_iptables" = true ]; then
setup_iptables
fi
if [ "$allow_localhost" = true ] || [ "$disable_localhost" = true ]; then
setup_iptables_localhost
fi
echo "Disabling IPv6 (not supported/implemented)"
sudo ip -6 route add blackhole default metric 1
echo 1 | sudo tee "/proc/sys/net/ipv6/conf/lo/disable_ipv6" > /dev/null
echo 1 | sudo tee "/proc/sys/net/ipv6/conf/all/disable_ipv6" > /dev/null
echo 1 | sudo tee "/proc/sys/net/ipv6/conf/default/disable_ipv6" > /dev/null
fi
# TEST
test_routing
if [ "$skip" = false ]; then
if [ "$testresult" = false ]; then
if [ "$iptable_checked" = false ] && [ "$skip" = false ]; then
echo -e "Trying to setup iptables and redo test..." >&2
setup_iptables
test_routing
fi
fi
if [ "$testresult" = false ]; then
echo -e "\e[31mAborting.\e[0m" >&2
exit 1
fi
fi
}
check_iptables=false
if [ "$action" = "command" ] || [ "$action" = "bind" ]; then
# SETUP config
if [ "$skip" = false ]; then
echo "Checking/setting forced routing config (skip with $0 -s ...)" >&2
if check_install_package cgroup-lite traceroute cgroup-tools; then
if check_package cgroup-lite traceroute cgroup-tools; then
echo "Required packages not properly installed. Aborting." >&2
exit 1
fi
fi
iptables_version=$(iptables --version | grep -oP "iptables v\K[0-9.]+")
if dpkg --compare-versions "$iptables_version" "lt" "1.6"; then
echo -e "\e[31mYou need iptables 1.6.0+. Please install manually. Aborting.\e[0m" >&2
echo "Find latest iptables at http://www.netfilter.org/projects/iptables/downloads.html" >&2
echo "Commands to install iptables 1.6.0:" >&2
echo -e "\e[34msudo apt-get install dh-autoreconf bison flex
cd /tmp
curl http://www.netfilter.org/projects/iptables/files/iptables-1.6.0.tar.bz2 | tar xj
cd iptables-1.6.0
./configure --prefix=/usr \\
--sbindir=/sbin \\
--disable-nftables \\
--enable-libipq \\
--with-xtlibdir=/lib/xtables \\
&& make \\
&& sudo make install
iptables --version\e[0m" >&2
exit 1
fi
if [ ! -d "/sys/fs/cgroup/net_cls/$cgroup_name" ]; then
echo "Creating net_cls control group $cgroup_name" >&2
sudo mkdir -p "/sys/fs/cgroup/net_cls/$cgroup_name"
check_iptables=true
fi
if [ `cat "/sys/fs/cgroup/net_cls/$cgroup_name/net_cls.classid" | xargs -n 1 printf "0x%08x"` != "$net_cls_classid" ]; then
echo "Applying net_cls class identifier $net_cls_classid to cgroup $cgroup_name" >&2
echo "$net_cls_classid" | sudo tee "/sys/fs/cgroup/net_cls/$cgroup_name/net_cls.classid" > /dev/null
fi
fi
if [ "$action" = "command" ]; then
reroute
fi
fi
# RUN command
if [ "$action" = "command" ]; then
if [ "$sudok" = true ]; then
sudo -K
fi
if [ "$#" -eq 0 ]; then
echo "Error: COMMAND not provided." >&2
exit 1
fi
if [ "$background" = true ]; then
cgexec -g net_cls:"$cgroup_name" --sticky "$@" &>/dev/null &
exit 0
else
cgexec -g net_cls:"$cgroup_name" --sticky "$@"
exit $?
fi
# List process BINDED to VPN tunnel
# Exit code 0 (true) if at least 1 process is binded
elif [ "$action" = "list" ]; then
echo "List of processes binded to VPN tunnel:"
list_bind
exit $?
# Force process BIND to VPN tunnel
elif [ "$action" = "bind" ]; then
exit_code=1
for process in "$@"
do
if [ "$process" -eq "$process" ] 2>/dev/null; then
# Is integer (PID)
echo "$process" | sudo tee /sys/fs/cgroup/net_cls/${cgroup_name}/tasks > /dev/null
exit_code=0
else
# Is process name
pids=$(pidof "$process")
for pid in $pids
do
echo "$pid" | sudo tee /sys/fs/cgroup/net_cls/${cgroup_name}/tasks > /dev/null
exit_code=0
done
fi
done
echo "List of processes binded to VPN tunnel:"
list_bind
reroute
exit $exit_code
# UNBIND process
elif [ "$action" = "unbind" ]; then
for process in "$@"
do
if [ "$process" -eq "$process" ] 2>/dev/null; then
# Is integer (PID)
echo "$process" | sudo tee /sys/fs/cgroup/net_cls/tasks > /dev/null
else
# Is process name
pids=$(pidof "$process")
for pid in $pids
do
echo "$pid" | sudo tee /sys/fs/cgroup/net_cls/tasks > /dev/null
done
fi
done
echo "Remaining processes binded to VPN tunnel:"
list_bind
# INFO
elif [ "$action" = "info" ]; then
echo -e "\e[2msudo iptables -L -v --line-numbers\e[0m"
sudo iptables -L -v --line-numbers
# CLEAN the mess
elif [ "$action" = "clean" ]; then
echo -e "Cleaning forced routing config generated by this script."
echo -e "Don't bother with errors meaning there's nothing to remove."
# Remove tasks
if [ -f "/sys/fs/cgroup/net_cls/${cgroup_name}/tasks" ]; then
while read task_pid; do echo ${task_pid} | sudo tee /sys/fs/cgroup/net_cls/tasks > /dev/null; done < "/sys/fs/cgroup/net_cls/${cgroup_name}/tasks"
fi
# Delete cgroup
if [ -d "/sys/fs/cgroup/net_cls/${cgroup_name}" ]; then
sudo find "/sys/fs/cgroup/net_cls/${cgroup_name}" -depth -type d -print -exec rmdir {} \;
fi
# Debug: sudo iptables -L -v
sudo iptables -D OUTPUT -m cgroup --cgroup "$net_cls_classid" -j "$iptables_rule_set_name"
sudo iptables -F "$iptables_rule_set_name"
sudo iptables -X "$iptables_rule_set_name"
echo 1 | sudo tee "/proc/sys/net/ipv6/conf/lo/disable_ipv6" > /dev/null
echo 1 | sudo tee "/proc/sys/net/ipv6/conf/all/disable_ipv6" > /dev/null
echo 1 | sudo tee "/proc/sys/net/ipv6/conf/default/disable_ipv6" > /dev/null
if [ -n "`lscgroup net_cls:$cgroup_name`" ]; then
sudo cgdelete net_cls:"$cgroup_name"
fi
if [ -n "`lscgroup net_cls:$cgroup_name_blackhole`" ]; then
sudo cgdelete net_cls:"$cgroup_name_blackhole"
fi
echo "All done."
fi
# BONUS: Useful commands:
# ./forcevpn.sh ping 8.8.8.8
# ./forcevpn.sh --localhost --background biglybt
# killall firefox; ./forcevpn.sh --background firefox
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment