Last active
January 2, 2025 16:34
-
-
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]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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