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