diff --git a/.gitignore b/.gitignore index 33637db..fad522c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .*.sw[op] .vagrant/ + +/tmp/ + +db.sqlite3 diff --git a/Vagrantfile b/Vagrantfile index 05f23df..34b4679 100644 --- a/Vagrantfile +++ b/Vagrantfile @@ -7,8 +7,9 @@ TOPOLOGY = < /var/log/django.log 2>&1 /dev/null 2>&1 || echo \"Error..?\"" server.vm.provision "shell", run: "always", - inline: "/vagrant/scripts/server.sh start -i -f #{i}" + inline: "curl -s -X POST localhost:8080/#{i}/" server.vm.provision "shell", run: "always", - inline: "/vagrant/scripts/server.sh client-conf " \ - "-r 192.168.75.100 #{i} > /vagrant/scripts/proxy#{i}.sh" + inline: "curl -s localhost:8080/#{i}/client_script/ " \ + "> /vagrant/tmp/proxy#{i}.sh" end end @@ -86,7 +106,7 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| ip: "10.75.#{75+i}.10" proxy.vm.provision "shell", run: "always", - inline: "bash /vagrant/scripts/proxy#{i}.sh" + inline: "bash /vagrant/tmp/proxy#{i}.sh" end end diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d33939f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +django +netaddr +ipython diff --git a/scripts/client.sh b/scripts/client.sh deleted file mode 100755 index 554bfc0..0000000 --- a/scripts/client.sh +++ /dev/null @@ -1,34 +0,0 @@ -#!/bin/bash - -TUN="__TUN__" -REMOTE_IP="__REMOTE_IP__" -SERVER_IP="__SERVER_IP__" -CLIENT_IP="__CLIENT_IP__" -PORT="__PORT__" -KEY="__KEY__" - -if ! which openvpn > /dev/null; then - if ! which apt-get > /dev/null; then - echo "Couldn't find apt-get to install OpenVPN." - exit 1 - fi - apt-get update && apt-get install -y openvpn -fi - -echo "$KEY" >> /etc/openvpn/${TUN}.key -cat > /etc/openvpn/${TUN}.conf << EOF -remote $REMOTE_IP -dev $TUN -port $PORT -ifconfig $CLIENT_IP $SERVER_IP -secret /etc/openvpn/${TUN}.key -EOF -service openvpn start $TUN - -echo 1 > /proc/sys/net/ipv4/ip_forward - -ifaces=`ip link show | grep '^[0-9]*:' | awk '{print $2}' | sed 's/:$//'` -eth_ifaces=`echo "$ifaces" | grep ^eth` -for iface in $eth_ifaces; do - iptables -t nat -A POSTROUTING -o $iface -j MASQUERADE -done diff --git a/scripts/server.sh b/scripts/server.sh deleted file mode 100755 index 00b20d7..0000000 --- a/scripts/server.sh +++ /dev/null @@ -1,498 +0,0 @@ -#!/bin/bash - -set -e - -NETWORK="172.17.17" -VERBOSITY=0 - -br() { echo >&2; } -debug() { if [ "$VERBOSITY" -gt 0 ]; then echo "DEBUG: $@" >&2; fi } -debug_cmd() { debug "Will run command: $@"; } -info() { echo "INFO: $@" >&2; } -warning() { echo "WARNING: $@" >&2; } -error() { echo "ERROR: $@" >&2; } - -check_requirements() { - # Check if iproute2 and OpenVPN are installed. - if ! which openvpn > /dev/null; then - if [ -z "$INSTALL" ]; then - error "OpenVPN not installed and -i flag wasn't set." - return 1 - fi - warning "OpenVPN wan't found, will try to install..." - if ! which apt-get > /dev/null; then - error "Couldn't find apt-get to install OpenVPN." - return 1 - fi - debug_cmd "apt-get update && apt-get install -y openvpn" - apt-get update && apt-get install -y openvpn - br - CHANGED=1 - fi - if ! which ip > /dev/null; then - error "Couldn't find iproute2." - return 1 - fi - debug "Found openvpn and iproute2." -} - -find_tunnels() { - # Find all tunnels that have a conf file. - find /etc/openvpn/ -type f -name 'tun[0-9]*.conf' | \ - grep -o '[0-9]*' | sort -n -} - -find_free_tun_num() { - # Find first free tunnel number available (above 0). - find_tunnels | - awk '$1!=p+1{for(i=p+1;i<$1;i++)print i}{p=$1}END{print p+1}' | \ - head -n 1 -} - -set_globals() { - # Set global variables with tunnel attributes for tunnel number $1 - # This needs to be called before any other function that follows. - if [ -z "$1" ]; then - error "check_tun_num: No number specified." - return 1 - fi - if [ "$1" -gt 127 ]; then - error "Maximum number of tunnels (127) exceeded." - return 1 - fi - NUM=$1 - TUN=tun$NUM - KEY=/etc/openvpn/${TUN}.key - CONFIG=/etc/openvpn/${TUN}.conf - SERVER_IP=${NETWORK}.$((2*$NUM-1)) - CLIENT_IP=${NETWORK}.$((2*$NUM)) - PORT=$((1194+$NUM)) - RTABLE=rt_$TUN -} - -echo_globals() { - br - debug "OpenVPN settings" - debug "================" - debug "Device: $TUN" - debug "Port: $PORT" - debug "Server: $SERVER_IP" - debug "Client: $CLIENT_IP" - debug "Key: $KEY" - debug "Config: $CONFIG" - debug "Routing table: $RTABLE" - br -} - -add_key() { - # Generate OpenVPN static key and store to $KEY (do nothing if it exists) - if [ ! -f "$KEY" ]; then - info "Generating OpenVPN static key and saving to $KEY ..." - debug_cmd "openvpn --genkey --secret $KEY" - openvpn --genkey --secret $KEY - CHANGED=1 - else - debug "OpenVPN key $KEY already created." - fi -} - -del_key() { - # Remove key $KEY if it exists - if [ -f $KEY ]; then - info "Removing key $KEY" - rm $KEY - CHANGED=1 - else - debug "Key $KEY already removed" - fi -} - -add_conf() { - cat > /tmp/${TUN}.conf << EOF -dev $TUN -port $PORT -ifconfig $SERVER_IP $CLIENT_IP -secret $KEY -EOF - if [ -f $CONFIG ]; then - if diff /tmp/${TUN}.conf $CONFIG > /dev/null; then - debug "File $CONFIG already exists and hasn't been modified." - return - fi - warning "File $CONFIG already exists and has been modified:" - diff /tmp/${TUN}.conf $CONFIG >&2 || true - br - if [ -z "$FORCE" ]; then - warning "Use -f (force) to overwrite." - return 1 - fi - info "Overwriting OpenVPN config file..." - else - info "Generating OpenVPN config file..." - fi - debug_cmd "cp /tmp/${TUN}.conf $CONFIG" - cp /tmp/${TUN}.conf $CONFIG - CHANGED=1 -} - -del_conf() { - # Remove configuration file $CONFIG if it exists. - if [ -f $CONFIG ]; then - info "Removing config file $CONFIG" - debug_cmd "rm $CONFIG" - rm $CONFIG - CHANGED=1 - else - debug "Config file $CONFIG already removed." - fi -} - -start_openvpn() { - # Start OpenVPN server for tunnel $TUN - if service openvpn status $TUN > /dev/null; then - if [ "$CHANGED" ]; then - info "Restarting OpenVPN server due to changes in configuration." - debug_cmd "service openvpn restart $TUN" - service openvpn restart $TUN - else - debug "OpenVPN server for $TUN is already running." - fi - else - info "OpenVPN server for $TUN not running, starting..." - debug_cmd "service openvpn start $TUN" - service openvpn start $TUN - CHANGED=1 - fi -} - -stop_openvpn() { - # Stop openvpn for tunnel $TUN - if service openvpn status $TUN > /dev/null; then - info "Stoping OpenVPN server for $TUN" - debug_cmd "service openvpn stop $TUN" - service openvpn stop $TUN - CHANGED=1 - else - debug "OpenVPN server for $TUN already stopped." - fi -} - -add_rtable() { - # Add routing table with index $NUM and name $RTABLE - local line="$NUM $RTABLE" - local conflicts=$( - cat /etc/iproute2/rt_tables | grep -v ^# | \ - grep -e "^$NUM\s\s*" -e "\s*$RTABLE\s*$" || true - ) - if [ -n "$conflicts" ]; then - if [ "$conflicts" == "$line" ]; then - debug "Routing table $RTABLE already properly created." - return - fi - warning "Routing table entry confict. Found:" - warning "$conflicts" - warning "Expected: \"$line\"" - if [ -z "$FORCE" ]; then - warning "Use -f (force) to overwrite." - return 1 - fi - info "Overwriting routing tables configuration..." - debug_cmd "sed -i "/^$NUM\s\s*.*$/d" /etc/iproute2/rt_tables" - sed -i "/^$NUM\s\s*.*$/d" /etc/iproute2/rt_tables - debug_cmd "sed -i "/^.*\s*$RTABLE\s*$/d" /etc/iproute2/rt_tables" - sed -i "/^.*\s*$RTABLE\s*$/d" /etc/iproute2/rt_tables - else - info "Creating routing table $RTABLE" - fi - debug_cmd "echo $line >> /etc/iproute2/rt_tables" - echo $line >> /etc/iproute2/rt_tables - sed -i '/^\s*$/d' /etc/iproute2/rt_tables - CHANGED=1 -} - -del_rtable() { - local line="$NUM $RTABLE" - if grep "^$line" /etc/iproute2/rt_tables > /dev/null; then - info "Removing routing table $RTABLE" - debug_cmd "sed -i \"/^${line}.*$/d\" /etc/iproute2/rt_tables" - sed -i "/^${line}.*$/d" /etc/iproute2/rt_tables - sed -i '/^\s*$/d' /etc/iproute2/rt_tables - CHANGED=1 - else - debug "Routing table $RTABLE already removed." - fi -} - -add_ip_rule() { - # Add ip rule from ip $SERVER_IP to routing table $RTABLE - if ! ip rule list | grep "from $SERVER_IP lookup $RTABLE" > /dev/null; then - info "Adding ip rule from $SERVER_IP table $RTABLE" - debug_cmd "ip rule add from $SERVER_IP table $RTABLE" - ip rule add from $SERVER_IP table $RTABLE - CHANGED=1 - else - debug "Ip rule from $SERVER_IP table $RTABLE already present." - fi -} - -del_ip_rule() { - if ip rule list | grep "from $SERVER_IP lookup $RTABLE" > /dev/null; then - info "Removing ip rule from $SERVER_IP table $RTABLE" - debug_cmd "ip rule del from $SERVER_IP table $RTABLE" - ip rule del from $SERVER_IP table $RTABLE - CHANGED=1 - else - debug "Ip rule from $SERVER_IP table $RTABLE already removed." - fi -} - -add_ip_route() { - # Add default gateway for device $TUN in routing table $RTABLE - if ! ip route list table $RTABLE | \ - grep "^default dev $TUN" > /dev/null; then - info "Adding ip route default dev $TUN table $RTABLE" - debug_cmd "ip route add default dev $TUN table $RTABLE" - ip route add default dev $TUN table $RTABLE - CHANGED=1 - else - debug "ip route default dev $TUN table $RTABLE already present." - fi -} - -del_ip_route() { - # Remove default gateway for device $TUN in routing table $RTABLE - if ip route list table $RTABLE | grep "^default dev $TUN" > /dev/null; then - info "Removing ip route default dev $TUN table $RTABLE" - debug_cmd "ip route del default dev $TUN table $RTABLE" - ip route del default dev $TUN table $RTABLE - CHANGED=1 - else - debug "Ip route default dev $TUN table $RTABLE already removed." - fi -} - -start() { - # Start tunnel number $1. If not specified, will autoselect tunnel. - check_requirements - local num=$1 - if [ -z "$num" ]; then - num=`find_free_tun_num` - info "Found lowest free tunnel number available (above 0): $num" - fi - set_globals $num - echo_globals - add_key - add_conf - start_openvpn - add_rtable - add_ip_rule - add_ip_route -} - -stop() { - # Stop tunnel number $1. - check_requirements - set_globals $1 - echo_globals - stop_openvpn - if [ "$RM" ]; then - del_conf - del_key - fi - del_ip_route - del_ip_rule - del_rtable -} - -get_client_conf() { - # Print in stdout a short shell script to set up client for tunnel $1 - check_requirements - set_globals $1 - if [ -z "$REMOTE_IP" ]; then - info "No REMOTE_IP specified with the -r flag, will try to discover." - debug_cmd "curl -s api.ipify.org" - REMOTE_IP=`curl -s api.ipify.org` - info "Remote IP will be set to $REMOTE_IP" - fi - br - local key=`cat $KEY | tr '\n' '~'|sed 's/~/\\\\n/g'` - local dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" - cat $dir/client.sh | \ - sed "s/__TUN__/$TUN/" | \ - sed "s/__REMOTE_IP__/$REMOTE_IP/" | \ - sed "s/__SERVER_IP__/$SERVER_IP/" | \ - sed "s/__CLIENT_IP__/$CLIENT_IP/" | \ - sed "s/__PORT__/$PORT/" | \ - sed "s/__KEY__/${key}/" -} - - -##### CLI argument parsing #### - -HELP="Usage: $0 {start,stop,list,client-conf,help}" - -HELP_START="Usage: $0 start [options] [TUN_IFACE_NUMBER] - -Start a OpenVPN point-to-point tunnel (server side) and configure appropriate -rules so that any traffic bound to the tun interface can be forwarded. - -This operation is applied in an idempotent way (if TUN_IFACE_NUMBER has been -specified), so it is safe to call multiple times. - -Arguments: -TUN_IFACE_NUMBER The number of the tun interface to use. If not specified, the - first free number will be used. If the interface already - exists, it will be checked to see if anything is out of order - in the configuration of the interface. If there's any - difference, the script will fail unless -f is used which will - cause the configuration of the interface to be reset. - -Options: --h Show this help message. --i If OpenVPN is not found, try to install it. Only works with - apt-get. --f Force overwrite of existing tunel if it already exists. --n NET_PREF Specify a different subnet to use for point to point VPN channels. - This must be a /24 network expressed as three dotted separated - octes. The default value is \"$NETWORK\". --v Increase verbosity. -" - -HELP_STOP="Usage: $0 stop [options] TUN_IFACE_NUMBER - -Stop the OpenVPN tunnel with the given number and removing relevant routing -tables and rules. - -This operation is applied in an idempotent way, so it is safe to call multiple -times. - -Arguments: -TUN_IFACE_NUMBER The number of the tun interface to remove. - -Options: --h Show this help message. --d Stop and purge configuration files, including key. --n NET_PREF Specify a different subnet to use for point to point VPN channels. - This must be a /24 network expressed as three dotted separated - octes. The default value is \"$NETWORK\". --v Increase verbosity. -" - -HELP_CLIENT="Usage: $0 client-conf [options] TUN_IFACE_NUMBER - -Print a short shell script on stdout that can be run on the client to set up -the OpenVPN channel and ip forwarding. - -Arguments: -TUN_IFACE_NUMBER The number of the tun interface to remove. - -Options: --h Show this help message. --r REMOTE_IP Remote IP to use. --n NET_PREF Specify a different subnet to use for point to point VPN channels. - This must be a /24 network expressed as three dotted separated - octes. The default value is \"$NETWORK\". --v Increase verbosity. -" -case $1 in - help|-h|--help) - echo "$HELP" - exit - ;; - start) - shift - while getopts "hifn:v" opt; do - case $opt in - h) - echo "$HELP_START" - exit - ;; - i) - INSTALL=1 - ;; - f) - FORCE=1 - ;; - n) - NETWORK=$OPTARG - ;; - v) - VERBOSITY=1 - ;; - \?) - echo "Invalid option: -$OPTARG" >&2 - echo "$HELP_START" >&2 - exit 1 - ;; - esac - done - NUM=${@:$OPTIND:1} - start $NUM - ;; - stop) - shift - while getopts "hn:v" opt; do - case $opt in - h) - echo "$HELP_STOP" - exit - ;; - d) - RM=1 - ;; - n) - NETWORK=$OPTARG - ;; - v) - VERBOSITY=1 - ;; - \?) - echo "Invalid option: -$OPTARG" >&2 - echo "$HELP_STOP" >&2 - exit 1 - ;; - esac - done - NUM=${@:$OPTIND:1} - stop $NUM - ;; - client-conf) - shift - while getopts "hr:n:v" opt; do - case $opt in - h) - echo "$HELP_CLIENT" - exit - ;; - r) - REMOTE_IP=$OPTARG - ;; - n) - NETWORK=$OPTARG - ;; - v) - VERBOSITY=1 - ;; - \?) - echo "Invalid option: -$OPTARG" >&2 - echo "$HELP_CLIENT" >&2 - exit 1 - ;; - esac - done - NUM=${@:$OPTIND:1} - get_client_conf $NUM - ;; - list) - find_tunnels - ;; - *) - if [ -z "$1" ]; then - echo "No command specified." >&2 - else - echo "Invalid command $1" >&2 - fi - echo "$HELP" >&2 - exit 1 - ;; -esac diff --git a/scripts/test-vagrant.sh b/scripts/test-vagrant.sh index 156d052..cfc6b6f 100755 --- a/scripts/test-vagrant.sh +++ b/scripts/test-vagrant.sh @@ -41,8 +41,8 @@ test_selfprobe() { echo -e $HEADER assert_probe server 127.0.0.1 server assert_probe server 192.168.75.100 server - assert_probe server 172.17.17.1 server - assert_probe server 172.17.17.3 server + assert_probe server 172.17.17.2 server + assert_probe server 172.17.17.4 server echo echo "### proxy1 ###" echo -e $HEADER @@ -50,7 +50,7 @@ test_selfprobe() { assert_probe proxy1 192.168.75.101 proxy1 assert_probe proxy1 10.75.75.10 proxy1 assert_probe proxy1 10.75.76.10 proxy1 - assert_probe proxy1 172.17.17.2 proxy1 + assert_probe proxy1 172.17.17.3 proxy1 echo echo "### proxy2 ###" echo -e $HEADER @@ -58,7 +58,7 @@ test_selfprobe() { assert_probe proxy2 192.168.75.102 proxy2 assert_probe proxy2 10.75.75.10 proxy2 assert_probe proxy2 10.75.77.10 proxy2 - assert_probe proxy2 172.17.17.4 proxy2 + assert_probe proxy2 172.17.17.5 proxy2 echo echo "### target1 ###" echo -e $HEADER @@ -126,10 +126,10 @@ test_server_targets() { echo "--------------------------------------------------------------" echo echo -e $HEADER - assert_probe server 10.75.75.75 target1 tun1 - assert_probe server 10.75.75.75 target2 tun2 - assert_probe server 10.75.76.75 target3 tun1 - assert_probe server 10.75.77.75 target4 tun2 + assert_probe server 10.75.75.75 target1 vpn-proxy-tun1 + assert_probe server 10.75.75.75 target2 vpn-proxy-tun2 + assert_probe server 10.75.76.75 target3 vpn-proxy-tun1 + assert_probe server 10.75.77.75 target4 vpn-proxy-tun2 } test_targets_server() { @@ -137,7 +137,7 @@ test_targets_server() { echo "--------------------------------------------------------------" echo for i in {1..2}; do - local ip="172.17.17.$((2*$i-1))" + local ip="172.17.17.$((2*$i))" local rule="PREROUTING -t nat -p tcp --dport 81 -j DNAT --to $ip:80" local cmd="sudo iptables -C $rule || sudo iptables -A $rule" echo "Forward all traffic coming to proxy$i:81 to server:80:" diff --git a/vpn-proxy/app/__init__.py b/vpn-proxy/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vpn-proxy/app/admin.py b/vpn-proxy/app/admin.py new file mode 100644 index 0000000..270bcdc --- /dev/null +++ b/vpn-proxy/app/admin.py @@ -0,0 +1,38 @@ +from django.contrib import admin + +from .models import Tunnel + + +class TunnelAdmin(admin.ModelAdmin): + fields = ['server', 'key'] + readonly_fields = ['name', 'active', 'client', 'port', + 'conf', 'client_conf', 'client_script', 'created_at'] + list_display = ['name', 'server', 'client', 'port', 'active'] + actions = ['start', 'stop', 'reset', 'delete_selected'] + + def get_fields(self, request, obj=None): + """If edit, enable display of readonly fields""" + if obj: # obj is not None, so this is an edit + return ['name', 'active', 'server', 'client', 'port', 'key', + 'conf', 'client_conf', 'client_script', 'created_at'] + return self.fields + + def start(self, request, queryset): + for tunnel in queryset: + tunnel.start() + + def stop(self, request, queryset): + for tunnel in queryset: + tunnel.stop() + + def reset(self, request, queryset): + for tunnel in queryset: + tunnel.reset() + + def delete_selected(self, request, queryset): + for tunnel in queryset: + tunnel.delete() + start.short_description = "Delete" + + +admin.site.register(Tunnel, TunnelAdmin) diff --git a/vpn-proxy/app/apps.py b/vpn-proxy/app/apps.py new file mode 100644 index 0000000..266441b --- /dev/null +++ b/vpn-proxy/app/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class AppConfig(AppConfig): + name = 'app' diff --git a/vpn-proxy/app/migrations/0001_initial.py b/vpn-proxy/app/migrations/0001_initial.py new file mode 100644 index 0000000..9ab6d57 --- /dev/null +++ b/vpn-proxy/app/migrations/0001_initial.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +import app.models +import app.tunnels + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Tunnel', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('server', models.GenericIPAddressField(default=app.models.chose_server_ip, unique=True, protocol='IPv4', validators=[app.models.check_server_ip])), + ('key', models.TextField(default=app.tunnels.gen_key, unique=True)), + ('active', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + ] diff --git a/vpn-proxy/app/migrations/__init__.py b/vpn-proxy/app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vpn-proxy/app/models.py b/vpn-proxy/app/models.py new file mode 100644 index 0000000..ce4ce03 --- /dev/null +++ b/vpn-proxy/app/models.py @@ -0,0 +1,137 @@ +from __future__ import unicode_literals + +import random +import logging + +import netaddr + +from django.db import models +from django.core.exceptions import ValidationError + +from .tunnels import start_tunnel, stop_tunnel, gen_key +from .tunnels import get_conf, get_client_conf, get_client_script + + +IFACE_PREFIX = 'vpn-proxy-tun' +SERVER_PORT_START = 1195 +VPN_ADDRESSES = '172.17.17.0/24' + +log = logging.getLogger(__name__) + + +def chose_server_ip(network=VPN_ADDRESSES): + """Find an available server IP in the given network (CIDR notation)""" + network = netaddr.IPNetwork(network) + if network.version != 4 or not network.is_private(): + raise Exception("Only private IPv4 networks are supported.") + first, last = network.first, network.last + if first % 2: + first += 1 + for i in range(20): + addr = str(netaddr.IPAddress(random.randrange(first, last, 2))) + print addr + try: + Tunnel.objects.get(server=addr) + except Tunnel.DoesNotExist: + return addr + + +def check_server_ip(addr): + """Verify that the server IP is valid""" + addr = netaddr.IPAddress(addr) + if addr.version != 4 or not addr.is_private(): + raise ValidationError("Only private IPv4 networks are supported.") + if not 0 < addr.words[-1] < 254 or addr.words[-1] % 2: + raise ValidationError("Server IP's last octet must be even in the " + "range [2,254].") + + +class Tunnel(models.Model): + server = models.GenericIPAddressField(protocol='IPv4', + default=chose_server_ip, + validators=[check_server_ip], + unique=True) + key = models.TextField(default=gen_key, blank=False, unique=True) + active = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + @property + def name(self): + return '%s%s' % (IFACE_PREFIX, self.id) + + @property + def client(self): + if self.server: + octets = self.server.split('.') + octets.append(str(int(octets.pop()) + 1)) + return '.'.join(octets) + + @property + def port(self): + return (SERVER_PORT_START + self.id - 1) if self.id else None + + @property + def rtable(self): + return 'rt_%s' % self.name + + @property + def key_path(self): + return '/etc/openvpn/%s.key' % self.name + + @property + def conf_path(self): + return '/etc/openvpn/%s.conf' % self.name + + @property + def conf(self): + return get_conf(self) + + @property + def client_conf(self): + return get_client_conf(self) + + @property + def client_script(self): + return get_client_script(self) + + def start(self): + if not self.active: + self.active = True + self.save() + start_tunnel(self) + + def stop(self): + if self.active: + self.active = False + self.save() + stop_tunnel(self) + + def reset(self): + if self.active: + self.start() + else: + self.stop() + + def __str__(self): + return '%s %s -> %s (port %s)' % (self.name, self.server, + self.client, self.port) + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'server': self.server, + 'client': self.client, + 'port': self.port, + 'key': self.key, + 'active': self.active, + } + + def save(self, *args, **kwargs): + self.full_clean() + super(Tunnel, self).save(*args, **kwargs) + + def delete(self, *args, **kwargs): + self.stop() + super(Tunnel, self).delete(*args, **kwargs) diff --git a/vpn-proxy/app/tests.py b/vpn-proxy/app/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/vpn-proxy/app/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/vpn-proxy/app/tunnels.py b/vpn-proxy/app/tunnels.py new file mode 100644 index 0000000..89556e4 --- /dev/null +++ b/vpn-proxy/app/tunnels.py @@ -0,0 +1,295 @@ +import os +import re +import logging +import tempfile +import subprocess + + +REMOTE_IP = '192.168.75.100' +IFACE_PREFIX = 'vpn-proxy-tun' +SERVER_PORT_START = 1195 +VPN_ADDRESSES = '172.17.17.0/24' + +log = logging.getLogger(__name__) + + +def run(cmd, shell=False, verbosity=1, shell_close_fds=False): + """Run given command and return output + + shell_close_fds will start a shell, close all file descriptors except + stdin, stdout, stderr and then run the specified command. This is needed + because by default, a subprocess will inherit all open file descriptors of + each parent. When we start OpenVPN, it inherits the open TCP port, and when + we stop and then restart the web server, OpenVPN still holds an open file + descriptor and the web server cannot bind to the port. + """ + _cmd = ' '.join(cmd) if not isinstance(cmd, basestring) else cmd + if verbosity > 1: + log.info("Running command '%s'.", _cmd) + elif verbosity > 0: + log.debug("Running command '%s'.", _cmd) + if shell_close_fds: + shell = True + cmd = """ +for fd in $(ls /proc/$$/fd); do + case "$fd" in + 0|1|2|255) + ;; + *) + eval "exec $fd>&-" + ;; + esac +done +""" + _cmd + try: + output = subprocess.check_output(cmd, shell=shell, + stderr=subprocess.STDOUT) + except subprocess.CalledProcessError as exc: + log.error("Command '%s' exited with %d. Output was:\n%s", + _cmd, exc.returncode, exc.output) + raise + except OSError as exc: + log.error("Command '%s' failed with OSError:%s", _cmd, exc) + raise + if verbosity > 1: + log.info("Command '%s' output: %s", _cmd, output) + elif verbosity > 0: + log.debug("Command '%s' output: %s", _cmd, output) + return output + + +def write_file(path, data, name='file'): + """Write file idempotently, return True if changed""" + if os.path.exists(path): + with open(path) as fobj: + data2 = fobj.read() + if data != data2: + log.warning("%s %s contents don't match, overwriting.", + name.capitalize(), path) + with open(path, 'wb') as fobj: + fobj.write(data) + else: + log.debug("%s %s is up to date.", name.capitalize(), path) + return False + else: + log.info("Writing %s to %s.", name, path) + with open(path, 'wb') as fobj: + fobj.write(data) + return True + + +def remove_file(path, name='file'): + """Remove file idempotently, return True if changed""" + if os.path.exists(path): + log.info("Removing %s %s.", name, path) + os.unlink(path) + return True + log.debug("%s %s already removed.", name.capitalize(), path) + return False + + +def gen_key(): + """Generate and return an OpenVPN static key""" + path = tempfile.mkstemp()[1] + run(['/usr/sbin/openvpn', '--genkey', '--secret', path]) + with open(path) as fobj: + key = fobj.read() + os.unlink(path) + return key + + +def start_openvpn(iface, force=True): + """Start OpenVPN for given iface if not running, return True if changed + + Use `force` to restart anyways + """ + try: + run(['service', 'openvpn', 'status', iface]) + if force: + log.info("Restarting OpenVPN server for %s.", iface) + run(['service', 'openvpn', 'restart', iface], + shell_close_fds=True) + else: + log.debug("OpenVPN server for %s already running.", iface) + return False + except subprocess.CalledProcessError: + log.info("OpenVPN server for %s not running, starting.", iface) + run(['service', 'openvpn', 'start', iface], + shell_close_fds=True) + return True + + +def stop_openvpn(iface): + """Stop OpenVPN for given iface if running, return True if changed""" + try: + run(['service', 'openvpn', 'status', iface]) + log.info("OpenVPN server for %s is running, stopping.", iface) + run(['service', 'openvpn', 'stop', iface]) + except subprocess.CalledProcessError: + log.debug("OpenVPN server for %s already stopped.", iface) + return False + return True + + +def add_rtable(index, rtable): + """Add custom rtable with given index, return True if changed""" + regex = re.compile(r'^(\d+)\s*([^\s]+)\s*$') + with open('/etc/iproute2/rt_tables', 'r') as fobj: + lines, conflicts = [], [] + for line in fobj.readlines(): + match = regex.match(line) + if match: + _index, _rtable = match.groups() + if _index == str(index) or _rtable == rtable: + conflicts.append((_index, _rtable)) + continue + lines.append(line) + if len(conflicts) == 1 and conflicts[0] == (str(index), rtable): + log.debug("Routing table %s already created.", rtable) + return False + if conflicts: + log.warning("Creating rtable %s, removing conflicting lines: %s", + rtable, conflicts) + else: + log.info("Creating rtable %s.", rtable) + lines.append('%s\t%s\n' % (index, rtable)) + with open('/etc/iproute2/rt_tables', 'w') as fobj: + fobj.writelines(lines) + return True + + +def del_rtable(index, rtable): + """Delete custom rtable with given index, return True if changed""" + with open('/etc/iproute2/rt_tables', 'r') as fobj: + _lines = fobj.readlines() + regex = re.compile(r'^(%s)\s*(%s)\s*$' % (index, rtable)) + lines = [line for line in _lines if not regex.match(line)] + if len(lines) == len(_lines): + log.debug("Routing table %s already removed.", rtable) + return False + log.info("Removing routing table %s.", rtable) + with open('/etc/iproute2/rt_tables', 'w') as fobj: + fobj.writelines(lines) + return True + + +def check_ip_rule(server, rtable): + """Check if IP rule for src address `server` to `rtable` exists""" + line = 'from %s lookup %s' % (server, rtable) + return line in run(['ip', 'rule', 'list'], verbosity=0) + + +def add_ip_rule(server, rtable): + if check_ip_rule(server, rtable): + log.debug("IP rule for %s already configured.", rtable) + return False + log.info("Adding IP rule for %s.", rtable) + run(['ip', 'rule', 'add', 'from', server, 'table', rtable], verbosity=2) + return True + + +def del_ip_rule(server, rtable): + if not check_ip_rule(server, rtable): + log.debug("IP rule for %s already removed.", rtable) + return False + log.info("Removing IP rule for %s.", rtable) + run(['ip', 'rule', 'del', 'from', server, 'table', rtable], verbosity=2) + return True + + +def check_ip_route(iface, rtable): + line = 'default dev %s' % iface + try: + return line in run(['ip', 'route', 'list', 'table', rtable], + verbosity=0) + except subprocess.CalledProcessError: + return False + + +def add_ip_route(iface, rtable): + if check_ip_route(iface, rtable): + log.debug("IP route for %s already configured.", rtable) + return False + log.info("Adding IP route for %s.", rtable) + run(['ip', 'route', 'add', 'default', + 'dev', iface, 'table', rtable], + verbosity=2) + return True + + +def del_ip_route(iface, rtable): + if not check_ip_route(iface, rtable): + log.debug("IP route for %s already removed.", rtable) + return False + log.info("Removing IP route for %s.", rtable) + run(['ip', 'route', 'del', 'default', + 'dev', iface, 'table', rtable], + verbosity=2) + return True + + +def get_conf(tunnel): + return '\n'.join(['dev %s' % tunnel.name, + 'dev-type tun', + 'port %s' % tunnel.port, + 'ifconfig %s %s' % (tunnel.server, tunnel.client), + 'secret %s' % tunnel.key_path]) + + +def get_client_conf(tunnel): + return '\n'.join(['remote %s' % REMOTE_IP, + 'dev %s' % tunnel.name, + 'dev-type tun', + 'port %s' % tunnel.port, + 'ifconfig %s %s' % (tunnel.client, tunnel.server), + 'secret %s' % tunnel.key_path]) + + +def get_client_script(tunnel): + return """#!/bin/bash + +if ! which openvpn > /dev/null; then + if ! which apt-get > /dev/null; then + echo "Couldn't find apt-get to install OpenVPN." + exit 1 + fi + apt-get update && apt-get install -y openvpn +fi + +cat > %(key_path)s << EOF +%(key)s +EOF + +cat > %(conf_path)s << EOF +%(conf)s +EOF + +service openvpn start %(name)s + +echo 1 > /proc/sys/net/ipv4/ip_forward + +ifaces=`ip link show | grep '^[0-9]*:' | awk '{print $2}' | sed 's/:$//'` +eth_ifaces=`echo "$ifaces" | grep ^eth` +for iface in $eth_ifaces; do + iptables -t nat -A POSTROUTING -o $iface -j MASQUERADE +done +""" % {'key_path': tunnel.key_path, 'conf_path': tunnel.conf_path, + 'key': tunnel.key, 'conf': get_client_conf(tunnel), 'name': tunnel.name} + + +def start_tunnel(tunnel): + write_file(tunnel.key_path, tunnel.key, 'key file') + write_file(tunnel.conf_path, get_conf(tunnel), 'conf file') + start_openvpn(tunnel.name) + add_rtable(tunnel.id, tunnel.rtable) + add_ip_rule(tunnel.server, tunnel.rtable) + add_ip_route(tunnel.name, tunnel.rtable) + + +def stop_tunnel(tunnel): + del_ip_route(tunnel.name, tunnel.rtable) + del_ip_rule(tunnel.server, tunnel.rtable) + del_rtable(tunnel.id, tunnel.rtable) + stop_openvpn(tunnel.name) + remove_file(tunnel.conf_path, 'conf file') + remove_file(tunnel.key_path, 'key file') diff --git a/vpn-proxy/app/urls.py b/vpn-proxy/app/urls.py new file mode 100644 index 0000000..05ec78b --- /dev/null +++ b/vpn-proxy/app/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import url + +from . import views + + +urlpatterns = [ + url(r'^$', views.tunnels, name='tunnels'), + url(r'(?P[0-9]+)/$', views.tunnel, name='tunnel'), + url(r'(?P[0-9]+)/client_script/$', views.script, name='script'), +] diff --git a/vpn-proxy/app/views.py b/vpn-proxy/app/views.py new file mode 100644 index 0000000..1d34e41 --- /dev/null +++ b/vpn-proxy/app/views.py @@ -0,0 +1,39 @@ +from django.http import HttpResponse +from django.http import JsonResponse as _JsonResponse +from django.shortcuts import get_object_or_404 +from django.views.decorators.http import require_http_methods + +from .models import Tunnel + + +class JsonResponse(_JsonResponse): + def __init__(self, data, **kwargs): + kwargs['safe'] = False + kwargs['json_dumps_params'] = {'indent': 4} + super(JsonResponse, self).__init__(data, **kwargs) + + +@require_http_methods(['GET', 'POST']) +def tunnels(request): + if request.method == 'POST': + params = {} + if 'server' in request.POST: + params['server'] = request.POST['server'] + tun = Tunnel(**params) + tun.save() + return JsonResponse(tun.to_dict()) + return JsonResponse(map(Tunnel.to_dict, Tunnel.objects.all())) + + +@require_http_methods(['GET', 'POST']) +def tunnel(request, tunel_id): + tun = get_object_or_404(Tunnel, pk=tunel_id) + if request.method == 'POST': + tun.start() + return JsonResponse(tun.to_dict()) + + +@require_http_methods(['GET']) +def script(request, tunel_id): + tun = get_object_or_404(Tunnel, pk=tunel_id) + return HttpResponse(tun.client_script) diff --git a/vpn-proxy/manage.py b/vpn-proxy/manage.py new file mode 100755 index 0000000..82cfa83 --- /dev/null +++ b/vpn-proxy/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/vpn-proxy/project/__init__.py b/vpn-proxy/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vpn-proxy/project/settings.py b/vpn-proxy/project/settings.py new file mode 100644 index 0000000..66919a8 --- /dev/null +++ b/vpn-proxy/project/settings.py @@ -0,0 +1,138 @@ +""" +Django settings for project project. + +Generated by 'django-admin startproject' using Django 1.9.2. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.9/ref/settings/ +""" + +import os + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'j85314t$+^le-kx$x&$&tb3*$_3q^lmz7y)vcq8e=@7jhi2tv8' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'app', +] + +MIDDLEWARE_CLASSES = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + # 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'project.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'project.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/1.9/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + + +# Password validation +# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/1.9/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.9/howto/static-files/ + +STATIC_URL = '/static/' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'loggers': { + 'app': { + 'handlers': ['console'], + 'level': 'DEBUG', + }, + }, +} diff --git a/vpn-proxy/project/urls.py b/vpn-proxy/project/urls.py new file mode 100644 index 0000000..b243237 --- /dev/null +++ b/vpn-proxy/project/urls.py @@ -0,0 +1,23 @@ +"""project URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/1.9/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: url(r'^$', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: url(r'^$', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.conf.urls import url, include + 2. Add a URL to urlpatterns: url(r'^blog/', include('blog.urls')) +""" +from django.conf.urls import url, include +from django.contrib import admin + + +urlpatterns = [ + url(r'^admin/', admin.site.urls), + url(r'^', include('app.urls')), +] diff --git a/vpn-proxy/project/wsgi.py b/vpn-proxy/project/wsgi.py new file mode 100644 index 0000000..217cd11 --- /dev/null +++ b/vpn-proxy/project/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for project project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "project.settings") + +application = get_wsgi_application()