Repository: jar-o/rotvpn Branch: master Commit: 30745147ca9c Files: 11 Total size: 32.6 KB Directory structure: gitextract_aji8b5ex/ ├── .gitignore ├── LICENSE ├── README.md ├── aux/ │ ├── rotvpn.psd │ ├── setup-debian-12.sh │ └── setup-ubuntu.sh ├── providers/ │ ├── aws.py │ ├── common.py │ └── digitalocean.py ├── requirements.txt └── rotvpn.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ *.key peer-tunnel-configs* *.pem .venv3 .env .DS_Store __pycache__ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 James R. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ ![rotvpn](aux/rotvpn-laurareen.com.png) Run a personal VPN in the cloud. And _rotate_ it regularly. VPN servers are a great way to hide your activity from the prying eyes of ISPs and obfuscate your location. But it's better to run your own VPN than to pay for a service, since the VPN service may be tracking you too. However, setting up a VPN is difficult. Add to that, it's probably wise to change the IP address of your VPN after a while. Usually, this means tearing the existing server down and standing up a new one in its place. **rotvpn** is a pure Python utility that sets up a [WireGuard](https://github.com/WireGuard/WireGuard) full tunnel VPN with DNS (via [unbound](https://github.com/NLnetLabs/unbound)). It is designed to be ephemeral, so you can rotate to a new server with a single command. _Currently, only [DigitalOcean](https://digitalocean.com) and [AWS](https://aws.amazon.com/) are supported. Other providers may be added in the future... Got a favorite? Feel free to send a patch!_ # Installation **rotvpn** installs and runs from your workstation. You must have `python3` in your path. You will need to create a virtual environment and install the dependencies: ``` python3 -m venv .env # create the virtualenv source .env/bin/activate # start it up python3 -m pip install --upgrade pip # upgrade installer python3 -m pip install -r requirements.txt # install requirements ``` # Running After you've installed the Python3 dependencies, you're ready to run your own VPN. Almost. Depending on which provider you use (Digitalocean is the default), you will need to export some variables into the environment. ### Digitalocean You'll need to get a [DigitalOcean API token](https://cloud.digitalocean.com/account/api/tokens). Export it into your environment: ``` export ROT_DO_TOKEN=123abc... ``` And you're ready to go. ### AWS (Amazon Web Services) You need an account ID, secret, and region, and you will need to export them into your environment like so: ``` export ROT_AWS_ID=AKI... export ROT_AWS_SECRET=WM0... export ROT_AWS_REGION=us-west-2 ``` After that, you can run `rotvpn.py`. ### Actually run it If the two steps above are complete, you're ready to go. Make sure you're running in your virtualenv, and in the root of this repo. Then do something like ``` python3 rotvpn.py --name my-cool-vpn ``` After the script runs, you should have a file named `peer-tunnel-configs--.zip`. `` will be one of the providers above, and `` will be whatever you gave rotvpn in the `--name` parameter. Unzip and you will have 10 peer configurations. Here is the usage for the script: ``` > python3 rotvpn.py --help usage: rotvpn.py [-h] [--provider PROVIDER] [--name NAME] [--do DO] [--config CONFIG] optional arguments: -h, --help show this help message and exit --provider PROVIDER Specify the provider, i.e. digitalocean --name NAME A name for your deploy, like 'mycoolvpn'. Lets you have multiple deploys for a provider. --do DO Provision or remove your VPN: --do provision | --do remove --config CONFIG Optional JSON config for your provider ``` The `--name` parameter is the only one that is required. **rotvpn** defaults to DigitalOcean for `--provider` and the default `--do` action is `provision`. Any time you run the prior command, any existing server matching `--name` will be deleted, and a new server deployed with a new set of client configs (`peer-tunnel-configs.zip`) Providers may have additional configuration fields they accept. If they do, you can use the `--config` parameter to pass in that information. For instance, the DigitalOcean provider defaults to the `sfo2` region, the `s-1vcpu-1gb` (smallest) sized droplet and uses Ubuntu 18.04 LTS. If you want to change that, you can do something like: ``` python3 rotvpn.py --name myvpn --config '{"region":"ams3", "size":"s-1vcpu-2gb-amd", "image":"debian-12-x64"}' ``` AWS currently supports changing the size of your instance. It defaults to `ts.micro`. You can modify this via `--config`, e.g. ``` python3 rotvpn.py --provider aws --name my-cool-vpn --config '{"size":"t2.medium"}' ``` If you're done with the VPN for a while, you can simply remove it, and save some money until you need it again: ``` python3 rotvpn.py --name my-cool-vpn --do remove ``` # Client configuration ## MacOS Install the [WireGuard](https://apps.apple.com/us/app/wireguard/id1451685025?mt=12) client from the App Store. Open it and click 'Import tunnel(s) from file'. Select one of the files unzipped from `peer-tunnel-configs.zip`. It will load in your WireGuard client interface: ![WireGuard MacOS](aux/wireguard-client-macos.png) Click 'Activate'. Test your connection and IP address. # References Much was learned from these posts: 1. https://craighuther.com/2019/05/14/wireguard-setup-and-installation/ 1. https://www.ckn.io/blog/2017/11/14/wireguard-vpn-typical-setup/ 1. https://www.stavros.io/posts/how-to-configure-wireguard/ 1. https://emanuelduss.ch/2018/09/wireguard-vpn-road-warrior-setup/ ================================================ FILE: aux/setup-debian-12.sh ================================================ #### Debian 12 #### Installation export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install wireguard zip bind9-dnsutils unbound unbound-host -yq #### Wireguard umask 077 && wg genkey | tee privatekey | wg pubkey > publickey publicaddr=$(dig +short myip.opendns.com @resolver1.opendns.com) ipv6_prefix='fd86:ea04:1111' fn='/etc/wireguard/wg0.conf' echo '[Interface]' > $fn echo "PrivateKey = $(cat privatekey)" >> $fn echo "Address = 10.200.200.1/24, ${ipv6_prefix}::1/64" >> $fn echo 'ListenPort = 51820' >> $fn echo 'PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -t nat -A POSTROUTING -o eth0 -j MASQUERADE PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE; ip6tables -D FORWARD -i wg0 -j ACCEPT; ip6tables -t nat -D POSTROUTING -o eth0 -j MASQUERADE SaveConfig = true' >> $fn rm -f peer-tunnel-configs.zip for i in `seq 2 11`; do j=$(expr $i - 1) prefix="peer${j}" umask 077 && wg genpsk > client-preshared umask 077 && wg genkey | tee client-privatekey | wg pubkey > client-pubkey # add to wireguard server config file echo '' >> $fn echo '[Peer]' >> $fn echo "PublicKey = $(cat client-pubkey)" >> $fn echo "PresharedKey = $(cat client-preshared)" >> $fn echo "AllowedIPs = 10.200.200.${i}/32, ${ipv6_prefix}::${i}/128" >> $fn # create the client tunnel file clifn="$prefix-tunnel.conf" echo "[Interface]" >> $clifn echo "Address = 10.200.200.${i}/24, ${ipv6_prefix}::${i}/64" >> $clifn echo "PrivateKey = $(cat client-privatekey)" >> $clifn echo "DNS = 10.200.200.1" >> $clifn echo "" >> $clifn echo "[Peer]" >> $clifn echo "PublicKey = $(cat publickey)" >> $clifn echo "PresharedKey = $(cat client-preshared)" >> $clifn echo "AllowedIPs = 0.0.0.0/0,::/0" >> $clifn echo "Endpoint = $publicaddr:51820" >> $clifn rm client-preshared rm client-privatekey rm client-pubkey done zip peer-tunnel-configs.zip peer*.conf rm peer*.conf #### DNS Server curl -o /var/lib/unbound/root.hints https://www.internic.net/domain/named.cache chown unbound:unbound /var/lib/unbound/root.hints cat > /etc/unbound/unbound.conf <<- EOM server: directory: "/etc/unbound" username: unbound chroot: "/etc/unbound" pidfile: "/etc/unbound/unbound.pid" verbosity: 1 num-threads: 4 max-udp-size: 3072 interface: 10.200.200.1 interface: 127.0.0.1 interface: ::1 #Authorized IPs to access the DNS Server access-control: 0.0.0.0/0 refuse access-control: 127.0.0.1 allow access-control: ::1 allow access-control: 10.200.200.0/24 allow #not allowed to be returned for public internet names private-address: 10.200.200.0/24 # Hide DNS Server info hide-identity: yes hide-version: yes #Limit DNS Fraud and use DNSSEC harden-glue: yes harden-dnssec-stripped: yes harden-referral-path: yes #Add an unwanted reply threshold to clean the cache and avoid when possible a DNS Poisoning unwanted-reply-threshold: 10000000 #Have the validator print validation failures to the log. val-log-level: 1 #Minimum lifetime of cache entries in seconds cache-min-ttl: 1800 #Maximum lifetime of cached entries cache-max-ttl: 14400 prefetch: yes prefetch-key: yes EOM systemctl disable systemd-resolved systemctl stop systemd-resolved systemctl enable unbound systemctl restart unbound #### Firewall, etc sysctl -w net.ipv4.ip_forward=1 sysctl -w net.ipv6.conf.all.forwarding=1 iptables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT iptables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT iptables -A INPUT -p udp -m udp --dport 51820 -m conntrack --ctstate NEW -j ACCEPT iptables -A INPUT -s 10.200.200.0/24 -p tcp -m tcp --dport 53 -m conntrack --ctstate NEW -j ACCEPT iptables -A INPUT -s 10.200.200.0/24 -p udp -m udp --dport 53 -m conntrack --ctstate NEW -j ACCEPT iptables -A OUTPUT -p udp -m udp --sport 51820 -j ACCEPT #### Start wireguard wg-quick up wg0 systemctl enable wg-quick@wg0 wg show ================================================ FILE: aux/setup-ubuntu.sh ================================================ #### Ubuntu 18.04 #### Installation echo "nameserver 8.8.8.8" | tee /etc/resolv.conf > /dev/null add-apt-repository -y universe apt install wireguard -y apt install zip -y apt install unbound unbound-host -y #### Wireguard umask 077 && wg genkey | tee privatekey | wg pubkey > publickey publicaddr=$(dig +short myip.opendns.com @resolver1.opendns.com) ipv6_prefix='fd86:ea04:1111' fn='/etc/wireguard/wg0.conf' echo '[Interface]' > $fn echo "PrivateKey = $(cat privatekey)" >> $fn echo "Address = 10.200.200.1/24, ${ipv6_prefix}::1/64" >> $fn echo 'ListenPort = 51820' >> $fn echo 'PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE; ip6tables -A FORWARD -i wg0 -j ACCEPT; ip6tables -t nat -A POSTROUTING -o eth0 -j MASQUERADE PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE; ip6tables -D FORWARD -i wg0 -j ACCEPT; ip6tables -t nat -D POSTROUTING -o eth0 -j MASQUERADE SaveConfig = true' >> $fn rm -f peer-tunnel-configs.zip for i in `seq 2 11`; do j=$(expr $i - 1) prefix="peer${j}" umask 077 && wg genpsk > client-preshared umask 077 && wg genkey | tee client-privatekey | wg pubkey > client-pubkey # add to wireguard server config file echo '' >> $fn echo '[Peer]' >> $fn echo "PublicKey = $(cat client-pubkey)" >> $fn echo "PresharedKey = $(cat client-preshared)" >> $fn echo "AllowedIPs = 10.200.200.${i}/32, ${ipv6_prefix}::${i}/128" >> $fn # create the client tunnel file clifn="$prefix-tunnel.conf" echo "[Interface]" >> $clifn echo "Address = 10.200.200.${i}/24, ${ipv6_prefix}::${i}/64" >> $clifn echo "PrivateKey = $(cat client-privatekey)" >> $clifn echo "DNS = 10.200.200.1" >> $clifn echo "" >> $clifn echo "[Peer]" >> $clifn echo "PublicKey = $(cat publickey)" >> $clifn echo "PresharedKey = $(cat client-preshared)" >> $clifn echo "AllowedIPs = 0.0.0.0/0,::/0" >> $clifn echo "Endpoint = $publicaddr:51820" >> $clifn rm client-preshared rm client-privatekey rm client-pubkey done zip peer-tunnel-configs.zip peer*.conf rm peer*.conf #### DNS Server curl -o /var/lib/unbound/root.hints https://www.internic.net/domain/named.cache chown unbound:unbound /var/lib/unbound/root.hints cat > /etc/unbound/unbound.conf <<- EOM # Unbound configuration file for Debian. # # See the unbound.conf(5) man page. # # See /usr/share/doc/unbound/examples/unbound.conf for a commented # reference config file. # # The following line includes additional configuration files from the # /etc/unbound/unbound.conf.d directory. include: "/etc/unbound/unbound.conf.d/*.conf" server: num-threads: 4 #Enable logs verbosity: 1 #list of Root DNS Server root-hints: "/var/lib/unbound/root.hints" #Respond to DNS requests on wireguard interface interface: 10.200.200.1 max-udp-size: 3072 #Authorized IPs to access the DNS Server access-control: 0.0.0.0/0 refuse access-control: 127.0.0.1 allow access-control: 10.200.200.0/24 allow #not allowed to be returned for public internet names private-address: 10.200.200.0/24 # Hide DNS Server info hide-identity: yes hide-version: yes #Limit DNS Fraud and use DNSSEC harden-glue: yes harden-dnssec-stripped: yes harden-referral-path: yes #Add an unwanted reply threshold to clean the cache and avoid when possible a DNS Poisoning unwanted-reply-threshold: 10000000 #Have the validator print validation failures to the log. val-log-level: 1 #Minimum lifetime of cache entries in seconds cache-min-ttl: 1800 #Maximum lifetime of cached entries cache-max-ttl: 14400 prefetch: yes prefetch-key: yes EOM systemctl disable systemd-resolved systemctl stop systemd-resolved systemctl enable unbound systemctl restart unbound #### Firewall, etc sysctl -w net.ipv4.ip_forward=1 sysctl -w net.ipv6.conf.all.forwarding=1 iptables -A INPUT -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT iptables -A FORWARD -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT iptables -A INPUT -p udp -m udp --dport 51820 -m conntrack --ctstate NEW -j ACCEPT iptables -A INPUT -s 10.200.200.0/24 -p tcp -m tcp --dport 53 -m conntrack --ctstate NEW -j ACCEPT iptables -A INPUT -s 10.200.200.0/24 -p udp -m udp --dport 53 -m conntrack --ctstate NEW -j ACCEPT iptables -A OUTPUT -p udp -m udp --sport 51820 -j ACCEPT ufw allow 22/tcp ufw allow 51820/udp echo "y" | ufw enable #### Teh end wg-quick up wg0 systemctl enable wg-quick@wg0 wg show ================================================ FILE: providers/aws.py ================================================ import os, time, base64, json import boto3 from providers.common import get_my_ip, wireguard_port, install_wireguard class AWSProvider: def __init__(self, deploy_name, config=None): self.deploy_name = 'rotvpn-{}'.format(deploy_name) self.aws_id = os.getenv('ROT_AWS_ID') if self.aws_id == None: raise Exception("Must set ROT_AWS_* env vars! See https://github.com/jar-o/rotvpn") self.aws_secret = os.getenv('ROT_AWS_SECRET') self.aws_region = os.getenv('ROT_AWS_REGION') self.client = boto3.client('ec2', aws_access_key_id=self.aws_id, aws_secret_access_key=self.aws_secret, region_name=self.aws_region) self.resource = boto3.session.Session( aws_access_key_id=self.aws_id, aws_secret_access_key=self.aws_secret, aws_session_token=None, ).resource('ec2') self.key_name = 'rotvpn-aws-ecs-keypair-' + deploy_name self.key_fn = self.key_name + '.pem' if config != None: self.config = json.loads(config) def gen_ssh_keys(self): if os.path.exists(self.key_fn): print('SSH key already seems to exist ({}). Skipping generation.'.format(self.key_fn)) return keypair = None try: keypair = self.client.create_key_pair(KeyName=self.key_name) except self.client.exceptions.ClientError as e: if 'invalidkeypair.duplicate' not in str(e).lower(): raise(e) print('Key {} already exists. Going to renew.'.format(self.key_name)) if keypair == None: self.client.delete_key_pair(KeyName=self.key_name) keypair = self.client.create_key_pair(KeyName=self.key_name) with open(self.key_fn, 'w') as outfile: outfile.write(str(keypair['KeyMaterial'])) os.chmod(self.key_fn, 0o600) def provision(self): self.gen_ssh_keys() self.remove() # rotation # create the instance size = 't2.micro' if hasattr(self, 'config') and self.config != None: if 'size' in self.config: size = self.config['size'] # Details about Ubuntu machine images here: https://cloud-images.ubuntu.com/locator/ self.instance = create_ec2_instance(self.client, 'ami-0a7d051a1c4b54f65', size, self.key_name) self.instance_obj = self.resource.Instance(id=self.instance['InstanceId']) self.instance_obj.create_tags(Tags=[ { 'Key': 'Name', 'Value': self.deploy_name, } ]) print('Waiting for instance...') self.instance_obj.wait_until_running() self.instance_obj.load() try: self.set_inbound_rules() except self.client.exceptions.ClientError as e: if 'invalidpermission.duplicate' not in str(e).lower(): raise(e) print('Inbound firewall rules already exist. Nothing to do.') print('Instance is live: {}'.format(self.instance_obj.public_ip_address)) peer_config_download_dest = 'peer-tunnel-configs-aws-{}.zip'.format(self.deploy_name) install_wireguard( self.instance_obj.public_ip_address, self.key_fn, peer_config_download_dest, 'ubuntu', '/home/ubuntu') def set_inbound_rules(self): response = self.client.describe_instances() for reservation in response["Reservations"]: for instance in reservation["Instances"]: if instance['InstanceId'] == self.instance['InstanceId']: for sg in instance['SecurityGroups']: if sg['GroupName'] == 'default': print('Setting SSH inbound rule on {}'.format(self.instance['InstanceId'])) myip = get_my_ip() self.client.authorize_security_group_ingress( GroupId=sg['GroupId'], IpPermissions=[{ 'FromPort': 22, 'ToPort': 22, 'IpProtocol': 'tcp', 'IpRanges': [ {'CidrIp': '{}/32'.format(myip)} ] }], ) print('Setting Wireguard inbound rule on {} for port {}'.format( self.instance['InstanceId'], wireguard_port)) self.client.authorize_security_group_ingress( GroupId=sg['GroupId'], IpPermissions=[{ 'FromPort': wireguard_port, 'ToPort': wireguard_port, 'IpProtocol': 'udp', 'IpRanges': [ {'CidrIp': '0.0.0.0/0'} ] }], ) def remove(self): reservations = self.client.describe_instances( Filters=[{'Name': 'instance-state-name', 'Values': ['running']}])["Reservations"] for res in reservations: for inst in res['Instances']: for tag in inst['Tags']: instid = inst['InstanceId'] if tag['Key'] == 'Name' and tag['Value'] == self.deploy_name: print('Instance {} found with ID {}. Terminating ...'.format(self.deploy_name, instid)) self.resource.instances.filter(InstanceIds = [instid]).terminate() print('Done.') # Stole from AWS docs def create_ec2_instance(ec2_client, image_id, instance_type, keypair_name): """Provision and launch an EC2 instance The method returns without waiting for the instance to reach a running state. :param image_id: ID of AMI to launch, such as 'ami-XXXX' :param instance_type: string, such as 't2.micro' :param keypair_name: string, name of the key pair :return Dictionary containing information about the instance. If error, returns None. """ # Provision and launch the EC2 instance # ec2_client = boto3.client('ec2') try: response = ec2_client.run_instances(ImageId=image_id, InstanceType=instance_type, KeyName=keypair_name, MinCount=1, MaxCount=1) except ec2_client.exceptions.ClientError as e: print(e) return None return response['Instances'][0] ================================================ FILE: providers/common.py ================================================ import paramiko import qrcode import time, os import zipfile from os import listdir from os.path import isfile from requests import get from scp import SCPClient, SCPException wireguard_port = 51820 # TODO it's sort of bothersome that this requires a matching edit in # setup-ubuntu.sh. Should templatize and drive from Python side. peer_config_download = 'peer-tunnel-configs.zip' def get_my_ip(): return get('https://api.ipify.org').text def unzip_file(zip_path, dest_path): with zipfile.ZipFile(zip_path, 'r') as zip_ref: zip_ref.extractall(dest_path) def gen_qr_code(input_path, output_path): with open(input_path, 'rb') as f: input_data = f.read() qr = qrcode.QRCode(version=1, box_size=10, border=5) qr.add_data(input_data) qr.make(fit=True) img = qr.make_image(fill='black', back_color='white') img.save(output_path) def extract_configs_and_generate_qr_codes(zip_path): out_path = zip_path + '.extracted' unzip_file(zip_path, out_path) conf_files = [os.path.join(out_path, f) for f in listdir(out_path) if isfile(os.path.join(out_path, f))] for f in conf_files: of = f + '.png' gen_qr_code(f, of) print(f"QR code generated: {f} -> {of}") def install_wireguard(ip_address, privkey_filename, peer_config_download_dest, setup_script=None, username='root', home='/root'): client = paramiko.SSHClient() client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) for i in range(5): print("Attempting to connect {}/{} ...".format(i, 5)) try: client.connect(ip_address, username=username, key_filename=os.path.abspath(privkey_filename)) except paramiko.ssh_exception.NoValidConnectionsError: print('No valid connection. (Server probably not ready.)') time.sleep(5) continue except TimeoutError: print("Timeout. Hm... let's try again") time.sleep(5) continue scp = SCPClient(client.get_transport()) if setup_script == None: setup_script = './aux/setup-ubuntu.sh' print('Installing server (running script {} remotely), takes a little time ...'.format(setup_script)) #TODO ensure pathing scp.put(setup_script, '{}/setup.sh'.format(home)) if username == 'root': stdin, stdout, stderr = client.exec_command('{}/setup.sh'.format(home)) else: # Assume sudo cmd1 = '{}/setup.sh'.format(home) cmd2 = 'chmod a+rw {}'.format(peer_config_download) cmd3 = 'mv {} {}'.format(peer_config_download, home) cmd = "sudo su - root -c 'sleep 15 && {} && {} && {}'".format(cmd1, cmd2, cmd3) print('Running {}'.format(cmd)) stdin, stdout, stderr = client.exec_command(cmd) exit_status = stdout.channel.recv_exit_status() # Blocking call if exit_status != 0: print('Error occured. Cannot continue Exit status {}'.format(exit_status)) print('{}'.format(stdout)) print('{}'.format(stderr)) print('You may be able to SSH into the server and troubleshoot:') print('ssh -i {} {}@{}'.format(os.path.abspath(privkey_filename), username, ip_address)) return # now, retrieve the generated peer configs try: os.remove(peer_config_download) os.remove(peer_config_download_dest) except FileNotFoundError: pass for j in range(10): try: scp.get('{}/{}'.format(home, peer_config_download)) except SCPException: print("{} not avilable. Trying again {}/{}".format(peer_config_download, j, 10)) time.sleep(5) continue break if os.path.exists(peer_config_download): os.rename(peer_config_download, peer_config_download_dest) print('Peer configs available: {}'.format(peer_config_download_dest)) extract_configs_and_generate_qr_codes(peer_config_download_dest) print('SUCCESS!') else: print('Something went wrong? No {} found.'.format(peer_config_download)) break ================================================ FILE: providers/digitalocean.py ================================================ # NOTE Providers MUST implement provision() and remove(). That is all. import os, time, base64, json from providers.common import install_wireguard from Crypto.PublicKey import RSA import digitalocean class DigitalOceanProvider: endpoint = 'https://api.digitalocean.com/v2/' prefix = 'digitalocean' privkey_fn = "rotvpn-{}-private.key".format(prefix) pubkey_fn = "rotvpn-{}-public.key".format(prefix) vpn_name_prefix = 'rotvpn' def __init__(self, deploy_name, config=None): v = os.getenv('ROT_DO_TOKEN') if v == None: raise Exception("Must set ROT_DO_TOKEN env var to DigitalOcean API token! See https://cloud.digitalocean.com/account/api/tokens") self.deploy_name = deploy_name self.manager = digitalocean.Manager(token=v) self.name = '-'.join([self.vpn_name_prefix, self.deploy_name]) self.keyname = '-'.join([self.vpn_name_prefix, self.deploy_name, 'ssh-key']) if config != None: self.config = json.loads(config) def gen_ssh_keys(self): if os.path.exists(self.privkey_fn) and os.path.exists(self.pubkey_fn): print('SSH keys already seem to exist. Skipping generation.') with open(self.privkey_fn, 'r') as content_file: self.privkey = content_file.read() with open(self.pubkey_fn, 'r') as content_file: self.pubkey = content_file.read() return key = RSA.generate(2048) with open(self.privkey_fn, 'wb') as content_file: os.chmod(self.privkey_fn, 0o600) k = key.exportKey('PEM') content_file.write(k) self.privkey = k.decode('utf-8') with open(self.pubkey_fn, 'wb') as content_file: k = key.publickey().exportKey('OpenSSH') content_file.write(k) self.pubkey = k.decode('utf-8') self.__add_ssh_key_to_digitalocean() def __add_ssh_key_to_digitalocean(self): print('Adding public key {} to DigitalOcean'.format(self.keyname)) # check for existing key, and delete for key in self.manager.get_all_sshkeys(): if key.name == self.keyname: key.destroy() key = digitalocean.SSHKey( token=os.getenv('ROT_DO_TOKEN'), name=self.keyname, public_key=self.pubkey) key.create() def provision(self): self.gen_ssh_keys() self.remove() # if droplet exists, we delete, and make a new one... "rotation" keys = self.manager.get_all_sshkeys() # create droplet size = 's-1vcpu-1gb' region = 'sfo2' image = 'ubuntu-18-04-x64' if hasattr(self, 'config') and self.config != None: if 'size' in self.config: size = self.config['size'] if 'region' in self.config: region = self.config['region'] if 'image' in self.config: image = self.config['image'] droplet = digitalocean.Droplet( token = os.getenv('ROT_DO_TOKEN'), name = self.name, region = region, image = image, size = size, ssh_keys = keys, backups = False) print('Creating droplet ...') droplet.create() print('Droplet: {}, id={}'.format(droplet.name, droplet.id)) self.droplet_id = droplet.id actions = droplet.get_actions() for action in actions: action.load() print(action.status) time.sleep(5) if action.status == 'completed': break while droplet.ip_address == None: print('Waiting for server ... IP: {}'.format(droplet.ip_address)) droplet = self.manager.get_droplet(self.droplet_id) time.sleep(5) print('IP Address: {}'.format(droplet.ip_address)) self.ip_address = droplet.ip_address # NOTE there is no API specific way of telling if the SSH daemon is # ready. We just have to try in a loop time.sleep(10) setup_script = None if image == "debian-12-x64": setup_script = './aux/setup-debian-12.sh' install_wireguard( self.ip_address, self.privkey_fn, 'peer-tunnel-configs-digitalocean-{}.zip'.format(self.deploy_name), setup_script ) def remove(self): has_removed = False for droplet in self.manager.get_all_droplets(): if droplet.name == self.name: print('{} exists. Removing.'.format(self.name)) print(droplet) droplet.destroy() actions = droplet.get_actions() for action in actions: action.load() print(action.status) time.sleep(5) if action.status == 'completed': break has_removed = True if has_removed == False: print('Deploy {} not found.'.format(self.name)) ================================================ FILE: requirements.txt ================================================ awscli==0.7.0 bcrypt==3.1.7 boto3==1.10.37 botocore==1.13.50 certifi==2023.7.22 cffi==1.15.0 chardet==3.0.4 colorama==0.4.1 cryptography==42.0.4 docutils==0.15.2 idna==3.7 Jinja2==3.1.3 jmespath==0.9.4 jsonpickle==1.2 MarkupSafe==1.1.1 Naked==0.1.31 paramiko==2.10.1 Pillow==10.3.0 pyasn1==0.4.8 pycparser==2.19 pycryptodome==3.19.1 PyNaCl==1.3.0 python-dateutil==2.8.0 python-digitalocean==1.14.0 PyYAML==5.4 qrcode==7.3.1 requests==2.31.0 rsa==4.7 s3transfer==0.2.1 scp==0.13.2 shellescape==3.4.1 six==1.13.0 urllib3==1.26.18 ================================================ FILE: rotvpn.py ================================================ import sys, argparse from providers import digitalocean, aws def get_provider(provider, deploy_name, config): if provider.lower() == "digitalocean": return digitalocean.DigitalOceanProvider(deploy_name, config) elif provider.lower() == "aws": return aws.AWSProvider(deploy_name, config) raise Exception('Could not match provider {}'.format(provider)) if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument('--provider', default='digitalocean', help='Specify the provider, i.e. digitalocean') parser.add_argument('--name', help="A name for your deploy, like 'mycoolvpn'. Lets you have multiple deploys for a provider.") parser.add_argument('--do', default='provision', help='Provision or remove your VPN: --do provision | --do remove') parser.add_argument('--config', help='Optional JSON config for your provider') args = parser.parse_args() if args.name == None: print("Must provide a name for your rotvpn deploy. E.g. my-cool-vpn") sys.exit(7) provider = get_provider(args.provider, args.name, args.config) if args.do == 'remove': provider.remove() else: provider.provision()