morristech
3/8/2019 - 10:28 PM

Force VPN for specific apps, in a better way than killswitch [Linux / OpenVPN]

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 => block the app network traffic.
#              Better than a killswitch. IPv4.
VERSION="2.1.0"
# Author: KrisWebDev
# Requirements:  Linux with kernel > 2.6.4 (released in 2008).
#                Only tested on Ubuntu 16 with bash.
#                Main dependencies are automatically installed.
#                Script will guide you for iptables 1.6.0 install.
# 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
# Note: This script will disable IPv6 (enable with --clean)

: '
#!/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
init_nb_args="$#"

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;;
    -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;;
    -*) 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 inside the VPN tunnel."
	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."
	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 check/setup system config & don't ask for root,\n\
	             run \e[4mCOMMAND\e[24m or move process \e[4mLIST\e[24m even if tunnel bind fails."
	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

# Helper functions

# Check the presence of required system packages
check_package(){
	nothing_installed=1
	for package_name in "$@"
	do
		if ! dpkg -l "$package_name" &> /dev/null; then
			echo "Installing $package_name"
			sudo apt-get install "$package_name"
			nothing_installed=0
		fi
	done
	return $nothing_installed
}

# 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 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
}

# Test if config is working, IPv4 only
testresult=true
test_forced(){
	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]+"`
	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.\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 interface ${vpn_interface}. Is it up?\e[0m" >&2			
		echo -e "\e[31mAborting.\e[0m" >&2
		exit 1
	fi

	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

	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


	# TEST forced bind
	test_forced
	if [ "$force" != true ]; then
		if [ "$testresult" = false ]; then
			if [ "$iptable_checked" = false ]; then
				echo -e "Trying to setup iptables and redo test..." >&2
				setup_iptables
				test_forced
			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_package cgroup-lite cgmanager cgroup-tools; then
			echo "You may want to reboot now. But that's probably not necessary." >&2
			exit 1
		fi

		if dpkg --compare-versions `iptables --version | grep -oP "iptables v\K.*$"` "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 [ "$#" -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


# 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 --bind ping
# ./forcevpn.sh vuze
# ./forcevpn.sh --bind java
# ./forcevpn.sh --background vuze
# To restore connectivity once VPN is restarted:
# ./forcevpn.sh :