├── vpnrotate ├── tests │ ├── __init__.py │ └── test_config.py ├── .python-version ├── src │ └── vpnrotate │ │ ├── __init__.py │ │ ├── metrics.py │ │ ├── utils.py │ │ ├── app.py │ │ ├── svchandler.py │ │ ├── config.py │ │ ├── vpnconfigs.py │ │ ├── wind.py │ │ └── handler.py ├── mypy.ini ├── MANIFEST.in ├── requirements-dev.txt ├── requirements.txt ├── resources │ ├── logging.yaml │ ├── app.dev.yaml │ └── app.yaml ├── setup.py └── tox.ini ├── .dir-locals ├── service ├── ovpn │ ├── log │ │ └── run │ ├── run │ └── firstrun ├── privoxy │ ├── log │ │ └── run │ ├── config │ └── run └── vpnrotate │ ├── log │ └── run │ └── run ├── run-tests.sh ├── .bumpversion.cfg ├── .dockerignore ├── .github └── workflows │ ├── docker.yml │ ├── python.yml │ └── docker-publish.yml ├── docker-compose.yml ├── Dockerfile ├── LICENSE ├── README.md ├── .gitignore └── Makefile /vpnrotate/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vpnrotate/.python-version: -------------------------------------------------------------------------------- 1 | vpnrotate 2 | -------------------------------------------------------------------------------- /.dir-locals: -------------------------------------------------------------------------------- 1 | ((python-mode . ((flycheck-flake8rc . "tox.ini")))) -------------------------------------------------------------------------------- /vpnrotate/src/vpnrotate/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.3.1" 2 | -------------------------------------------------------------------------------- /vpnrotate/mypy.ini: -------------------------------------------------------------------------------- 1 | 2 | [mypy] 3 | #ignore_missing_imports = True -------------------------------------------------------------------------------- /service/ovpn/log/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec svlogd /var/log/ovpn 3 | 4 | -------------------------------------------------------------------------------- /vpnrotate/MANIFEST.in: -------------------------------------------------------------------------------- 1 | include resources/*.yaml 2 | include README.md 3 | -------------------------------------------------------------------------------- /service/privoxy/log/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec svlogd /var/log/privoxy 3 | 4 | -------------------------------------------------------------------------------- /vpnrotate/requirements-dev.txt: -------------------------------------------------------------------------------- 1 | tox 2 | isort 3 | black 4 | bump2version 5 | -------------------------------------------------------------------------------- /service/vpnrotate/log/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec svlogd /var/log/vpnrotate 3 | 4 | -------------------------------------------------------------------------------- /run-tests.sh: -------------------------------------------------------------------------------- 1 | 2 | #!/bin/sh 3 | 4 | sleep 2 5 | 6 | curl --silent --fail localhost:8080/healthcheck || exit 1 7 | 8 | -------------------------------------------------------------------------------- /service/vpnrotate/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | exec 2>&1 5 | 6 | sleep 1 7 | 8 | exec vpnrotate --resources=/etc/vpnrotate/resources 9 | -------------------------------------------------------------------------------- /service/privoxy/config: -------------------------------------------------------------------------------- 1 | confdir /etc/service/privoxy 2 | 3 | listen-address 0.0.0.0:8118 4 | 5 | debug 1 # Show each GET/POST/CONNECT request 6 | debug 4096 # Startup banner and warnings -------------------------------------------------------------------------------- /vpnrotate/requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==19.3.0 2 | aiohttp==3.6.2 3 | aiohttp-swagger3 @ https://github.com/scotthaleen/aiohttp-swagger3/archive/v0.4.3+scott.tar.gz#egg=aiohttp-swagger3 4 | pyyaml==5.4 5 | aiofiles==0.5.0 6 | trafaret==2.0.2 7 | pathlib3x 8 | beautifulsoup4 9 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.3.1 3 | tag = False 4 | commit = False 5 | 6 | [bumpversion:file:Makefile] 7 | 8 | [bumpversion:file:vpnrotate/src/vpnrotate/__init__.py] 9 | 10 | [bumpversion:file:vpnrotate/setup.py] 11 | 12 | [bumpversion:file:build-docker.sh] 13 | -------------------------------------------------------------------------------- /service/ovpn/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | exec 2>&1 6 | 7 | sleep 2 8 | 9 | echo "Starting VPN" 10 | 11 | if [ ! -f /etc/runonce/ovpn ]; then 12 | echo "VPN first run setup" 13 | ./firstrun 14 | sv o ovpn 15 | touch /etc/runonce/ovpn 16 | exit 0 17 | fi 18 | 19 | exec openvpn --config /etc/ovpn/openvpn.conf --auth-user-pass /etc/ovpn/auth.conf 20 | 21 | -------------------------------------------------------------------------------- /vpnrotate/resources/logging.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 1 3 | disable_existing_loggers: False 4 | formatters: 5 | simple: 6 | format: "%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s" 7 | 8 | handlers: 9 | console: 10 | class: logging.StreamHandler 11 | level: DEBUG 12 | formatter: simple 13 | stream: ext://sys.stdout 14 | 15 | root: 16 | level: DEBUG 17 | handlers: [console] 18 | 19 | -------------------------------------------------------------------------------- /vpnrotate/resources/app.dev.yaml: -------------------------------------------------------------------------------- 1 | app: 2 | host: 0.0.0.0 3 | port: 8888 4 | 5 | vpn_env: 6 | # my ip endpoint 7 | ip: https://ip.jata.lol 8 | # directory of nord ovpn confs 9 | vpnconfigs: configs 10 | # download ovpn configs on init 11 | reload_configs_on_startup: no 12 | # openvpn conf 13 | vpnconfig: /etc/ovpn/openvpn.conf 14 | 15 | 16 | wind: 17 | url: https://vpnrotate.s3.amazonaws.com/20210727_ovpn_tcp.zip 18 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | __pycache__ 3 | **/__pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | 31 | .DS_Store 32 | 33 | -------------------------------------------------------------------------------- /vpnrotate/resources/app.yaml: -------------------------------------------------------------------------------- 1 | app: 2 | host: 0.0.0.0 3 | port: 8080 4 | 5 | vpn_env: 6 | # my ip endpoint 7 | ip: https://ip.jata.lol 8 | # directory of nord ovpn confs 9 | vpnconfigs: /etc/ovpn/configs 10 | # download ovpn configs on init 11 | reload_configs_on_startup: yes 12 | # openvpn conf 13 | vpnconfig: /etc/ovpn/openvpn.conf 14 | 15 | 16 | wind: 17 | url: https://vpnrotate.s3.amazonaws.com/20210727_ovpn_tcp.zip 18 | -------------------------------------------------------------------------------- /vpnrotate/tests/test_config.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | import pytest 3 | 4 | # @pytest.fixture 5 | # async def client(aiohttp_client): 6 | # config = load_config(BASE_DIR / 'config' / 'test_config.toml') 7 | # app = await init_app(config) 8 | # return await aiohttp_client(app) 9 | 10 | 11 | # async def test_index_view(tables_and_data, client): 12 | # resp = await client.get('/') 13 | # assert resp.status == 200 14 | 15 | 16 | def test_example(): 17 | assert 1 == 1 18 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v1 11 | - name: Create docker container 12 | run: make docker_build 13 | - name: Run docker-compose 14 | run: docker-compose up -d 15 | - name: Check running containers 16 | run: docker ps -a 17 | - name: Run Tests 18 | run: ./run-tests.sh 19 | - name: Shutdown 20 | run: docker-compose down 21 | -------------------------------------------------------------------------------- /service/privoxy/run: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | exec 2>&1 5 | 6 | sleep 10 7 | 8 | # Return traffic that went through OpenVPN works. 9 | gw=$(ip route | awk '/default/ {print $3}') 10 | echo "add default network: ${LOCAL_NETWORK}" 11 | ip route add to ${LOCAL_NETWORK} via $gw dev eth0 || true 12 | 13 | echo "extra networks: ${LOCAL_NETWORKS}" 14 | for r in $(IFS=","; echo ${LOCAL_NETWORKS}); 15 | do 16 | echo "ip route add ${r}" 17 | ip route add to "${r}" via $gw dev eth0 18 | done; 19 | 20 | exec privoxy --no-daemon 21 | -------------------------------------------------------------------------------- /.github/workflows/python.yml: -------------------------------------------------------------------------------- 1 | name: Python package 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python: [3.9] 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Setup Python 14 | uses: actions/setup-python@v1 15 | with: 16 | python-version: ${{ matrix.python }} 17 | - name: Install dev tools 18 | working-directory: ./vpnrotate 19 | run: pip install -r requirements-dev.txt 20 | - name: Run Tox 21 | run: make tox 22 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | vpnproxy: 4 | image: jataware/vpnrotate:latest 5 | cap_add: 6 | - NET_ADMIN 7 | devices: 8 | - /dev/net/tun:/dev/net/tun 9 | dns: 10 | - 8.8.8.8 11 | networks: 12 | - default 13 | environment: 14 | USERNAME: ${NORDUSER} 15 | PASSWORD: ${NORDPASS} 16 | PUSERNAME: ${PIAUSER} 17 | PPASSWORD: ${PIAPASS} 18 | WUSERNAME: ${WINDUSER} 19 | WPASSWORD: ${WINDPASS} 20 | LOCAL_NETWORK: 192.168.1.0/24 21 | LOCAL_NETWORKS: 22 | OVPN_DOWNLOAD_ON_START: ${OVPN_DOWNLOAD_ON_START:-"no"} 23 | ports: 24 | - 8118:8118 25 | - 8080:8080 26 | restart: always 27 | 28 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | FROM python:3.9-slim-buster 3 | # See https://nordvpn.com/servers/tools for recommendations 4 | 5 | ENV PROTOCOL="tcp" \ 6 | LOCAL_NETWORK=192.168.1.0/24 7 | 8 | RUN apt-get update && apt-get clean && apt-get install -y \ 9 | privoxy \ 10 | openvpn \ 11 | runit \ 12 | gcc \ 13 | musl-dev \ 14 | curl \ 15 | dnsutils \ 16 | mg \ 17 | vim 18 | 19 | RUN rm -rf /var/lib/apt/lists/* \ 20 | && mkdir -p /etc/runonce/ \ 21 | && mkdir -p /var/log/ovpn /var/log/privoxy /var/log/vpnrotate /etc/ovpn/configs 22 | 23 | COPY vpnrotate/ /etc/vpnrotate 24 | RUN pip3 install --upgrade pip && \ 25 | pip3 install /etc/vpnrotate 26 | 27 | COPY service /etc/service/ 28 | CMD ["runsvdir", "/etc/service"] 29 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Publish Version 2 | 3 | on: 4 | release: 5 | types: 6 | - published 7 | workflow_dispatch: 8 | 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v1 15 | 16 | - name: Create docker container 17 | run: make docker_build 18 | 19 | - name: Push docker container 20 | env: 21 | GITLAB_PASS: ${{ secrets.GITLAB_PASS }} 22 | GITLAB_USER: ${{ secrets.GITLAB_USER }} 23 | run: make docker_push prod=y 24 | 25 | - name: Push dockerhub container 26 | env: 27 | DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }} 28 | DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} 29 | run: make docker_push-dockerhub prod=y 30 | -------------------------------------------------------------------------------- /service/ovpn/firstrun: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | 4 | # assume load at least one VPN cred 5 | 6 | if [ -n "$USERNAME" -a -n "$PASSWORD" ]; then 7 | echo "$USERNAME" > /etc/ovpn/nord.conf 8 | echo "$PASSWORD" >> /etc/ovpn/nord.conf 9 | chmod 600 /etc/ovpn/nord.conf 10 | 11 | echo "Loaded NordVPN credentials" 12 | fi 13 | 14 | if [ -n "$PUSERNAME" -a -n "$PPASSWORD" ]; then 15 | echo "$PUSERNAME" > /etc/ovpn/pia.conf 16 | echo "$PPASSWORD" >> /etc/ovpn/pia.conf 17 | chmod 600 /etc/ovpn/pia.conf 18 | 19 | echo "Loaded PIA credentials" 20 | fi 21 | 22 | if [ -n "$WUSERNAME" -a -n "$WPASSWORD" ]; then 23 | echo "$WUSERNAME" > /etc/ovpn/wind.conf 24 | echo "$WPASSWORD" >> /etc/ovpn/wind.conf 25 | echo "Loaded Windscribe credentials" 26 | chmod 600 /etc/ovpn/wind.conf 27 | fi 28 | -------------------------------------------------------------------------------- /vpnrotate/setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import find_packages, setup 2 | 3 | """ 4 | version managed by bump2version 5 | """ 6 | 7 | VERSION = "0.3.1" 8 | 9 | 10 | def read_requirements(path: str): 11 | with open(path) as f: 12 | return f.read().splitlines() 13 | 14 | 15 | setup( 16 | name="vpnrotate", 17 | version=VERSION, 18 | platforms=["POSIX"], 19 | python_requires=">= 3.9", 20 | package_dir={"": "src"}, 21 | packages=find_packages("src", exclude=["tests"]), 22 | include_package_data=True, 23 | test_suite="tests", 24 | install_requires=read_requirements("requirements.txt"), 25 | zip_safe=False, 26 | entry_points={ 27 | "console_scripts": [ 28 | "vpnrotate=vpnrotate.app:main", 29 | "vpnconfigs=vpnrotate.vpnconfigs:main", 30 | ], 31 | }, 32 | ) 33 | -------------------------------------------------------------------------------- /vpnrotate/src/vpnrotate/metrics.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | import threading 3 | from time import perf_counter 4 | 5 | 6 | class FastWriteCounter(object): 7 | """ 8 | https://github.com/jd/fastcounter 9 | https://julien.danjou.info/atomic-lock-free-counters-in-python/ 10 | """ 11 | 12 | __slots__ = ( 13 | "_number_of_read", 14 | "_counter", 15 | "_lock", 16 | "_step", 17 | ) 18 | 19 | def __init__(self, init=0, step=1): 20 | self._number_of_read = 0 21 | self._step = step 22 | self._counter = itertools.count(init, step) 23 | self._lock = threading.Lock() 24 | 25 | def increment(self): 26 | next(self._counter) 27 | 28 | @property 29 | def value(self): 30 | with self._lock: 31 | value = next(self._counter) - self._number_of_read 32 | self._number_of_read += self._step 33 | return value 34 | 35 | 36 | class Metrics: 37 | START_TIME = perf_counter() 38 | TOTAL_REQUESTS = FastWriteCounter() 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jataware 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /vpnrotate/tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py39,pytest,pep8 3 | #skipsdist=True 4 | 5 | [testenv] 6 | usedevelop = true 7 | commands = 8 | python setup.py develop 9 | 10 | [flake8] 11 | ignore = E731, E231, I201, W503 12 | exclude = venv*,env,.env,.tox,.toxenv,.git,build,docs,tmp-build 13 | max-line-length = 119 14 | accept-encodings = utf-8 15 | 16 | [testenv:pytest] 17 | usedevelop = true 18 | deps = 19 | pytest 20 | pytest-aiohttp 21 | commands = 22 | py.test tests -s 23 | 24 | [testenv:pep8] 25 | deps = 26 | flake8 27 | flake8-bugbear 28 | flake8-tidy-imports 29 | flake8-import-order 30 | flake8-black 31 | #pep8-naming 32 | commands = flake8 33 | 34 | [testenv:coverage] 35 | skip_install = true 36 | commands = 37 | coverage combine 38 | coverage xml 39 | coverage report --fail-under=100 40 | deps = 41 | coverage 42 | setenv = 43 | COVERAGE_FILE=.coverage 44 | 45 | [testenv:format] 46 | skip_install = true 47 | commands = 48 | isort src/vpnrotate tests setup.py 49 | black src/vpnrotate tests setup.py 50 | deps = 51 | black 52 | isort 53 | 54 | [testenv:package] 55 | commands = 56 | python setup.py sdist --formats=gztar -------------------------------------------------------------------------------- /vpnrotate/src/vpnrotate/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | import aiohttp 4 | 5 | 6 | def fmt_now(pattern="%Y%m%d%H%M%S"): 7 | return datetime.datetime.utcnow().strftime(pattern) 8 | 9 | 10 | def deep_get(d, path, default=None): 11 | """ 12 | Take array or string as the path to a dict item and return the item or default if path does not exist. 13 | """ 14 | if not d or not path: 15 | return d 16 | 17 | parts = path.split(".") if isinstance(path, str) else path 18 | return deep_get(d.get(parts[0]), parts[1:], default) if d.get(parts[0]) else default 19 | 20 | 21 | HEADERS = { 22 | "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:72.0) Gecko/20100101 Firefox/72.0", 23 | "connection": "keep-alive", 24 | "cache-control": "max-age=0", 25 | "accept": "*/*", 26 | "accept-language": "en-US;q=1.0,en;q=0.9", 27 | } 28 | 29 | 30 | async def get_ip_info(ip_url, extended=False): 31 | async with aiohttp.ClientSession(raise_for_status=True, headers=HEADERS) as session: 32 | async with session.get(ip_url) as resp: 33 | ip = await resp.text() 34 | if not extended: 35 | return {"ip": ip} 36 | async with session.get( 37 | f"http://ipinfo.io/{ip}", headers={**HEADERS, "accept": "application/json"} 38 | ) as resp: 39 | return await resp.json() 40 | -------------------------------------------------------------------------------- /vpnrotate/src/vpnrotate/app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging import Logger 3 | 4 | from aiohttp import web 5 | from aiohttp_swagger3 import SwaggerDocs, SwaggerUiSettings # noqa: I201 6 | from aiohttp_swagger3.routes import _SWAGGER_SPECIFICATION # noqa: I201 7 | 8 | from . import __version__, config, handler, metrics, utils, vpnconfigs 9 | 10 | logger: Logger = logging.getLogger(__name__) 11 | 12 | 13 | async def startup_handler(app: web.Application) -> None: 14 | logger.info("starting up") 15 | config = app["CONFIG"] 16 | if config["vpn_env"]["reload_configs_on_startup"]: 17 | await vpnconfigs.run_ovpn_setup(config, clean=True) 18 | 19 | app["METRICS"] = metrics.Metrics 20 | app["LOCAL_CONNECT"] = await utils.get_ip_info( 21 | config["vpn_env"]["ip"], extended=True 22 | ) 23 | app["PROVIDER"] = {} 24 | 25 | 26 | async def shutdown_handler(app: web.Application) -> None: 27 | logger.info("shuting down") 28 | logger.info("shutdown") 29 | 30 | 31 | def main() -> None: 32 | settings = config.get_config() 33 | app = web.Application( 34 | client_max_size=5 * 1024**2, 35 | middlewares=[handler.response_time, handler.request_counter], 36 | ) 37 | swagger = SwaggerDocs( 38 | app, 39 | swagger_ui_settings=SwaggerUiSettings(path="/api/docs/"), 40 | title="vpnrotate", 41 | version=__version__, 42 | ) 43 | 44 | # HACK - library does not support servers 45 | swagger.spec["servers"] = [{"url": settings["swagger_base_path"]}] 46 | swagger._app[_SWAGGER_SPECIFICATION] = swagger.spec 47 | 48 | config.init_config(app, settings) 49 | app.on_startup.append(startup_handler) 50 | app.on_cleanup.append(shutdown_handler) 51 | swagger.add_routes(handler.routing_table(app)) 52 | web.run_app( 53 | app, 54 | host=settings["app"]["host"], 55 | port=settings["app"]["port"], # access_log=None, 56 | ) 57 | 58 | 59 | if __name__ == "__main__": 60 | main() 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What is this? 2 | 3 | The number of IP addresses you can egress from reliably is often the limiting factor in data scraping and other online activities. This project allows you to use your NordVPN, Windscribe, and/or Private Internet Access credentials to allow programmatic control of a proxy egress point via a set of useful APIs. 4 | 5 | 6 | # Quick Start 7 | 8 | ``` 9 | export NORDUSER="" 10 | export NORDPASS="" 11 | export PIAUSER="" 12 | export PIAPASS="" 13 | export WINDUSER=" 41 | - https://github.com/pyenv/pyenv-virtualenv
42 | 43 | 44 | Install Dev Requirements 45 | 46 | ``` 47 | python -m pip install -r requirements-dev.txt 48 | ``` 49 | 50 | 51 | ## Tox 52 | 53 | Reformat Code (runs isort, black) 54 | 55 | ``` 56 | make fmt 57 | ``` 58 | 59 | 60 | Test + Lint 61 | 62 | ``` 63 | make tox 64 | ``` 65 | 66 | 67 | ## Docker 68 | 69 | ``` 70 | make docker_build 71 | ``` 72 | 73 | 74 | ## Docker Compose 75 | 76 | `OVPN_DOWNLOAD_ON_START=yes` will download configs on start up. If ommited you will have to 77 | call refresh manually `POST localhost:8080/vpn/configs` 78 | 79 | 80 | ``` 81 | OVPN_DOWNLOAD_ON_START=yes docker compose up -d 82 | 83 | docker compose down -v 84 | ``` 85 | 86 | 87 | ## Dev 88 | 89 | ``` 90 | python -m pip install -e . 91 | 92 | vpnrotate --config=app.dev.yaml --logging=logging.yaml 93 | ``` 94 | 95 | Download ovpn configs manually to specified config `vpn_env.vpnconfigs` directory 96 | ``` 97 | vpnconfigs --config=app.dev.yaml 98 | ``` 99 | 100 | 101 | ## Bump Version 102 | 103 | 104 | See [bump2version](https://github.com/c4urself/bump2version) 105 | 106 | Bump version, verify changes and commit to branch. 107 | 108 | Example: 109 | 110 | ``` 111 | bump2version --current-version 0.1.6 --new-version 0.1.7 minor --allow-dirty 112 | ``` 113 | 114 | 115 | 116 | ## Windscribe vpn refresh 117 | 118 | 119 | Run the following to create an updated `ovpn_tcp.zip` to replace the 120 | `https://vpnrotate.s3.amazonaws.com/ovpn_tcp.zip` the current download. 121 | 122 | Adding `-c` will download the windscribe credentials as well to `wind-creds.txt` 123 | 124 | ``` 125 | python -m vpnrotate.wind 126 | ``` 127 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 99 | __pypackages__/ 100 | 101 | # Celery stuff 102 | celerybeat-schedule 103 | celerybeat.pid 104 | 105 | # SageMath parsed files 106 | *.sage.py 107 | 108 | # Environments 109 | .env 110 | .venv 111 | env/ 112 | venv/ 113 | ENV/ 114 | env.bak/ 115 | venv.bak/ 116 | 117 | # Spyder project settings 118 | .spyderproject 119 | .spyproject 120 | 121 | # Rope project settings 122 | .ropeproject 123 | 124 | # mkdocs documentation 125 | /site 126 | 127 | # mypy 128 | .mypy_cache/ 129 | .dmypy.json 130 | dmypy.json 131 | 132 | # Pyre type checker 133 | .pyre/ 134 | 135 | # pytype static type analyzer 136 | .pytype/ 137 | 138 | # Cython debug symbols 139 | cython_debug/ 140 | 141 | tmp/ 142 | data/ 143 | logs/ 144 | !data/logs/.keep 145 | nordvpn 146 | .DS_Store 147 | 148 | vpnrotate/configs -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | # Dependencies 3 | 4 | # gnumake curl git 5 | # pyenv pyenv-virtualenv 6 | 7 | # https://github.com/pyenv/pyenv-installer 8 | # https://github.com/pyenv/pyenv 9 | # https://github.com/pyenv/pyenv-virtualenv 10 | # osx: brew install pyenv pyenv-virtualenv 11 | 12 | 13 | VERSION := 0.3.1 14 | 15 | DEV ?= $(strip $(if $(findstring y,$(prod)),,dev)) 16 | 17 | VERSION := ${VERSION}$(DEV:dev=-dev) 18 | 19 | DETECTED_OS := $(shell uname) 20 | 21 | CMD_ARGUMENTS ?= $(cmd) 22 | 23 | .DEFAULT_GOAL := help 24 | 25 | check-%: 26 | @: $(if $(value $*),,$(error $* is undefined)) 27 | 28 | help: 29 | @echo "" 30 | @echo "By default make targets assume DEV to run production pass in prod=y as a command line argument" 31 | @echo "" 32 | @echo "Targets:" 33 | @echo "" 34 | @grep -E '^([a-zA-Z_-])+%*:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-40s\033[0m %s\n", $$1, $$2}' 35 | 36 | 37 | ## Helpers 38 | 39 | .PHONY: yN 40 | yN: 41 | @echo "Are you sure? [y/N] " && read ans && [ $${ans:-N} = y ] || (echo "aborted."; exit 1;) 42 | 43 | ip-addr: 44 | ifeq ($(DETECTED_OS),Darwin) # Mac OS X 45 | $(eval IP_ADDRESS=$(shell ipconfig getifaddr en0)) 46 | else 47 | $(eval IP_ADDRESS=$(shell hostname -i)) 48 | endif 49 | 50 | .PHONY: print-version 51 | print-version: 52 | @echo "Version: ${VERSION}" 53 | 54 | 55 | .PHONY: docker_build 56 | docker_build: docker_build_vpnproxy ## Build all docker containers 57 | 58 | .PHONY: docker_build_vpnproxy 59 | docker_build_vpnproxy: ## build vpnproxy container 60 | ./build-docker.sh 61 | 62 | 63 | tox-%: ## Run tox on 64 | @echo "tox $*" 65 | (cd $* && tox -e format && tox) 66 | 67 | .PHONY: tox 68 | tox: tox-vpnrotate ## Run tox on vpnrotate 69 | 70 | .PHONY: fmt 71 | fmt: ## Run python formatter 72 | (cd vpnrotate && tox -e format) 73 | 74 | docker_login:| check-GITLAB_USER check-GITLAB_PASS ## Login to docker registery. Requires GITLAB_USER and GITLAB_PASS to be set in the environment 75 | @printf "${GITLAB_PASS}\n" | docker login registry.gitlab.com/jataware -u "${GITLAB_USER}" --password-stdin 76 | 77 | .PHONY: docker_push 78 | docker_push: docker_push_vpnproxy docker_login ## push all containers to docker registry 79 | 80 | .PHONY: docker_push_vpnproxy 81 | docker_push_vpnproxy:| docker_login ## push proxy container to docker registry 82 | @echo "push vpnproxy ${VERSION}" 83 | docker push "registry.gitlab.com/jataware/vpnproxy:${VERSION}" 84 | 85 | .PHONY: docker-compose_up 86 | docker-compose_up:| ip-addr ## Start docker-compose instance local 87 | docker-compose up -d 88 | 89 | 90 | .PHONY: docker_login-dockerhub 91 | docker_login-dockerhub:| check-DOCKERHUB_USER check-DOCKERHUB_TOKEN ## Login to docker registery. Requires DOCKERHUB_USER and DOCKERHUB_TOKEN to be set in the environment 92 | @printf "${DOCKERHUB_TOKEN}\n" | docker login -u "${DOCKERHUB_USER}" --password-stdin 93 | 94 | 95 | .PHONY: docker_push-dockerhub 96 | docker_push-dockerhub:| docker_login-dockerhub ## Pushes docker image to docker hub 97 | @echo "push ${VERSION} " 98 | docker push "jataware/vpnrotate:${VERSION}" 99 | docker push "jataware/vpnrotate:latest" 100 | 101 | .PHONY: draft-release 102 | draft-release: 103 | gh release create "v${VERSION}" --generate-notes -d 104 | -------------------------------------------------------------------------------- /vpnrotate/src/vpnrotate/svchandler.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import stat 3 | 4 | import aiofiles 5 | from aiofiles import os 6 | 7 | OVPN_LOCK = asyncio.Semaphore(1) 8 | 9 | 10 | async def file_exists(p): 11 | try: 12 | return stat.S_ISREG((await os.stat(p)).st_mode) 13 | except FileNotFoundError: 14 | return False 15 | 16 | 17 | async def dir_exists(p): 18 | try: 19 | return stat.S_ISDIR((await os.stat(p)).st_mode) 20 | except FileNotFoundError: 21 | return False 22 | 23 | 24 | async def file_copy(src: str, dest: str, buff_size: int = 4096): 25 | async with aiofiles.open(dest, mode="wb") as outfile, aiofiles.open( 26 | src, "rb" 27 | ) as infile: 28 | while bs := await infile.read(buff_size): 29 | await outfile.write(bs) 30 | await outfile.flush() 31 | 32 | 33 | async def change_vpn_config(vpnconfigs: str, vpnconf: str, server: str) -> dict: 34 | provider = {} 35 | 36 | # NordVPN 37 | if "nord" in server: 38 | ovpn_file = f"{vpnconfigs}/nordvpn/ovpn_tcp/{server}.tcp.ovpn" 39 | provider = { 40 | "provider": "nordvpn", 41 | "server": server, 42 | "ovpn": ovpn_file, 43 | } 44 | 45 | # Wind 46 | elif "Wind" in server: 47 | ovpn_file = f"{vpnconfigs}/wind/ovpn_tcp/{server}.ovpn" 48 | provider = { 49 | "provider": "windscribe", 50 | "server": server, 51 | "ovpn": ovpn_file, 52 | } 53 | 54 | # PIA 55 | else: 56 | ovpn_file = f"{vpnconfigs}/pia/ovpn_tcp/{server}.ovpn" 57 | provider = { 58 | "provider": "pia", 59 | "server": server, 60 | "ovpn": ovpn_file, 61 | } 62 | 63 | async with OVPN_LOCK: 64 | if not await file_exists(ovpn_file): 65 | raise Exception(f"ovpn file not found {ovpn_file}") 66 | try: 67 | await os.remove(vpnconf) 68 | 69 | except FileNotFoundError: 70 | pass 71 | await file_copy(ovpn_file, vpnconf) 72 | 73 | try: 74 | await os.remove("/etc/ovpn/auth.conf") 75 | except FileNotFoundError: 76 | pass 77 | 78 | if "nord" in server: 79 | await file_copy("/etc/ovpn/nord.conf", "/etc/ovpn/auth.conf") 80 | provider["auth"] = "/etc/ovpn/nord.conf" 81 | elif "Wind" in server: 82 | await file_copy("/etc/ovpn/wind.conf", "/etc/ovpn/auth.conf") 83 | provider["auth"] = "/etc/ovpn/wind.conf" 84 | else: 85 | await file_copy("/etc/ovpn/pia.conf", "/etc/ovpn/auth.conf") 86 | provider["auth"] = "/etc/ovpn/pia.conf" 87 | 88 | return provider 89 | 90 | 91 | async def restart_vpn(): 92 | async with OVPN_LOCK: 93 | process = await asyncio.create_subprocess_exec("sv", "restart", "ovpn") 94 | rc = await process.wait() 95 | return rc == 0, rc 96 | 97 | 98 | async def stop_vpn(): 99 | async with OVPN_LOCK: 100 | process = await asyncio.create_subprocess_exec("sv", "stop", "ovpn") 101 | rc = await process.wait() 102 | return rc == 0, rc 103 | 104 | 105 | async def start_vpn(): 106 | async with OVPN_LOCK: 107 | process = await asyncio.create_subprocess_exec("sv", "start", "ovpn") 108 | rc = await process.wait() 109 | return rc == 0, rc 110 | 111 | 112 | async def status_vpn(): 113 | async with OVPN_LOCK: 114 | process = await asyncio.create_subprocess_exec( 115 | "sv", "status", "ovpn", stdout=asyncio.subprocess.PIPE 116 | ) 117 | rc = await process.wait() 118 | buff = await process.stdout.read() 119 | return rc == 0, rc, buff.decode() 120 | -------------------------------------------------------------------------------- /vpnrotate/src/vpnrotate/config.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import logging.config 4 | import os 5 | import sys 6 | from typing import Any, Final, List, Optional 7 | 8 | import trafaret as t 9 | import yaml # noqa: I201, I100 10 | from aiohttp import web # noqa: I201, I100 11 | 12 | ENV_LOG_CONFIG: Final = "LOG_CONFIG" 13 | 14 | 15 | def setup_logging( 16 | default_path="logging.yaml", default_level=logging.INFO, env_key=ENV_LOG_CONFIG 17 | ): 18 | path = default_path 19 | value = os.getenv(env_key, None) 20 | if value: 21 | path = value 22 | if os.path.exists(path): 23 | with open(path, "rt") as f: 24 | config = yaml.safe_load(f.read()) 25 | logging.config.dictConfig(config) 26 | else: 27 | logging.basicConfig(level=default_level) 28 | 29 | 30 | def load_settings(path): 31 | with open(path, "rt") as f: 32 | return yaml.safe_load(f.read()) 33 | 34 | 35 | app_config = t.Dict( 36 | { 37 | t.Key("app"): t.Dict( 38 | { 39 | "host": t.String(), 40 | "port": t.Int(), 41 | } 42 | ), 43 | t.Key("vpn_env"): t.Dict( 44 | { 45 | "ip": t.String(), 46 | "vpnconfigs": t.String(), 47 | "reload_configs_on_startup": t.Bool(), 48 | "vpnconfig": t.String(), 49 | } 50 | ), 51 | t.Key("wind"): t.Dict( 52 | { 53 | "url": t.String(), 54 | } 55 | ), 56 | t.Key("swagger_base_path"): t.String(), 57 | } 58 | ) 59 | 60 | 61 | def get_config() -> Any: 62 | try: 63 | parser = argparse.ArgumentParser(add_help=False) 64 | required = parser.add_argument_group("required arguments") # noqa: F841 65 | optional = parser.add_argument_group("optional arguments") 66 | 67 | # Add back help 68 | optional.add_argument( 69 | "-h", 70 | "--help", 71 | action="help", 72 | default=argparse.SUPPRESS, 73 | help="show this help message and exit", 74 | ) 75 | 76 | optional.add_argument( 77 | "--resources", 78 | type=str, 79 | default=os.getenv("APP_RESOURCES", f"{os.getcwd()}/resources"), 80 | help="Directory for application resources to be loaded", 81 | ) 82 | 83 | optional.add_argument( 84 | "--config", 85 | type=str, 86 | default=os.getenv("APP_CONFIG", "app.yaml"), 87 | help="App config file name in resources directory", 88 | ) 89 | 90 | optional.add_argument( 91 | "--logging", 92 | type=str, 93 | default=os.getenv("APP_LOGGING", "logging.yaml"), 94 | help="App logging files name in resources directory", 95 | ) 96 | 97 | options = parser.parse_args() 98 | settings = load_settings(f"{options.resources}/{options.config}") 99 | 100 | if ovpn_download_on_start := os.getenv("OVPN_DOWNLOAD_ON_START"): 101 | settings["vpn_env"]["reload_configs_on_startup"] = ( 102 | True if yaml.safe_load(ovpn_download_on_start) is True else False 103 | ) 104 | 105 | settings["swagger_base_path"] = os.getenv("SWAGGER_BASE_PATH", "/") 106 | 107 | app_config.check(settings) 108 | setup_logging(f"{options.resources}/{options.logging}") 109 | return settings 110 | 111 | except Exception: 112 | parser.print_help(sys.stderr) 113 | raise 114 | return None 115 | 116 | 117 | def init_config(app: web.Application, config: Optional[List[str]] = None) -> None: 118 | app["CONFIG"] = config 119 | -------------------------------------------------------------------------------- /vpnrotate/src/vpnrotate/vpnconfigs.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import tempfile 4 | from logging import Logger 5 | from shutil import unpack_archive 6 | 7 | import aiofiles 8 | import aiohttp 9 | from pathlib3x import Path 10 | 11 | from . import config 12 | 13 | logger: Logger = logging.getLogger(__name__) 14 | 15 | CHUNK_SIZE = 1028 16 | 17 | HEADERS = { 18 | "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:72.0) Gecko/20100101 Firefox/72.0", 19 | "connection": "keep-alive", 20 | "cache-control": "max-age=0", 21 | "accept": "*/*", 22 | "accept-language": "en-US;q=1.0,en;q=0.9", 23 | } 24 | 25 | 26 | def remove_ovpn_configs(vpn_configs_dir): 27 | for path in Path(vpn_configs_dir).iterdir(): 28 | if path.is_file(): 29 | path.unlink() 30 | elif path.is_dir(): 31 | path.rmtree(ignore_errors=True) 32 | 33 | 34 | async def getsave(session, outfile, url): 35 | async with aiofiles.open(outfile, mode="wb") as f: 36 | async with session.get(url) as response: 37 | while True: 38 | chunk = await response.content.read(CHUNK_SIZE) 39 | if not chunk: 40 | break 41 | await f.write(chunk) 42 | 43 | 44 | async def configure_nord(vpn_configs_dir): 45 | path = Path(f"{vpn_configs_dir}/nordvpn") 46 | path.mkdir(parents=True, exist_ok=True) 47 | 48 | tmpdir = tempfile.gettempdir() 49 | url = "https://downloads.nordcdn.com/configs/archives/servers/ovpn.zip" 50 | 51 | tmpfile = f"{tmpdir}/nordvpn.zip" 52 | async with aiohttp.ClientSession(raise_for_status=True, headers=HEADERS) as session: 53 | await getsave(session, tmpfile, url) 54 | 55 | unpack_archive(tmpfile, path, "zip") 56 | 57 | 58 | async def configure_pia(vpn_configs_dir): 59 | path = Path(f"{vpn_configs_dir}/pia/ovpn_tcp") 60 | path.mkdir(parents=True, exist_ok=True) 61 | 62 | tmpdir = tempfile.gettempdir() 63 | url = "https://www.privateinternetaccess.com/openvpn/openvpn.zip" 64 | 65 | tmpfile = f"{tmpdir}/pia.zip" 66 | async with aiohttp.ClientSession(raise_for_status=True, headers=HEADERS) as session: 67 | await getsave(session, tmpfile, url) 68 | 69 | unpack_archive(tmpfile, path, "zip") 70 | 71 | 72 | async def configure_wind(vpn_configs_dir, settings): 73 | path = Path(f"{vpn_configs_dir}/wind") 74 | path.mkdir(parents=True, exist_ok=True) 75 | 76 | tmpdir = tempfile.gettempdir() 77 | # url = "https://vpnrotate.s3.amazonaws.com/ovpn_tcp.zip" 78 | url = settings["wind"]["url"] 79 | tmpfile = f"{tmpdir}/wind.zip" 80 | 81 | async with aiohttp.ClientSession(raise_for_status=True, headers=HEADERS) as session: 82 | await getsave(session, tmpfile, url) 83 | 84 | unpack_archive(tmpfile, path, "zip") 85 | 86 | 87 | async def run_ovpn_setup(settings, clean=True): 88 | vpn_configs_dir = settings["vpn_env"]["vpnconfigs"] 89 | logger.debug(f"vpn_configs_dir = {vpn_configs_dir}") 90 | 91 | if clean: 92 | logger.info(f"Removing contents of {vpn_configs_dir}") 93 | remove_ovpn_configs(vpn_configs_dir) 94 | 95 | if not Path(vpn_configs_dir).exists(): 96 | raise Exception(f"vpn config base directory does not exist {vpn_configs_dir}") 97 | 98 | logger.info("Setting up Nord OVPN directory") 99 | await configure_nord(vpn_configs_dir) 100 | 101 | logger.info("Setting up PIA OVPN directory") 102 | await configure_pia(vpn_configs_dir) 103 | 104 | logger.info("Setting up Wind OVPN directory") 105 | await configure_wind(vpn_configs_dir, settings) 106 | 107 | 108 | def main() -> None: 109 | settings = config.get_config() 110 | loop = asyncio.get_event_loop() 111 | loop.run_until_complete(run_ovpn_setup(settings)) 112 | 113 | 114 | if __name__ == "__main__": 115 | main() 116 | -------------------------------------------------------------------------------- /vpnrotate/src/vpnrotate/wind.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import asyncio 3 | import json 4 | import logging 5 | import logging.config 6 | from logging import Logger 7 | from zipfile import ZipFile 8 | 9 | import aiofiles 10 | import aiohttp 11 | from bs4 import BeautifulSoup 12 | 13 | logger: Logger = logging.getLogger(__name__) 14 | 15 | CHUNK_SIZE = 1028 16 | 17 | 18 | async def postzip(sem, session, zipfile, filename, url, data): 19 | async with sem: 20 | logger.debug("file=%s, data=%s", filename, data) 21 | async with session.post(url, data=data) as response: 22 | text = await response.text() 23 | zipfile.writestr(filename, text) 24 | await asyncio.sleep(2) 25 | 26 | 27 | async def login(session, user, password): 28 | async with session.post("https://res.windscribe.com/res/logintoken") as r: 29 | j = json.loads(await r.text()) 30 | csrf_time = j["csrf_time"] 31 | csrf_token = j["csrf_token"] 32 | 33 | await asyncio.sleep(2) 34 | 35 | url = "https://windscribe.com/login" 36 | 37 | data = { 38 | "login": 1, 39 | "upgrade": 0, 40 | "username": user, 41 | "password": password, 42 | "csrf_time": csrf_time, 43 | "csrf_token": csrf_token, 44 | "code": "", 45 | } 46 | 47 | async with session.post(url, data=data): 48 | logger.info("Logged in") 49 | 50 | 51 | def normalize_name(s): 52 | return f"Windscribe-{s.replace(' ', '')}" 53 | 54 | 55 | async def get_config_locations(session): 56 | async with session.get("https://windscribe.com/getconfig/openvpn") as r: 57 | html = await r.text() 58 | soup = BeautifulSoup(html, "html.parser") 59 | xs = { 60 | normalize_name(opt.string): opt.attrs["value"] 61 | for opt in soup.find(id="location").find("optgroup").find_all("option") 62 | } 63 | return xs 64 | 65 | 66 | async def get_credentials(session): 67 | url = "https://windscribe.com/getconfig/credentials?device=null&ip=null" 68 | async with session.get(url) as r: 69 | j = json.loads(await r.text()) 70 | async with aiofiles.open("wind-creds.txt", mode="w") as f: 71 | await f.write(f"{j['username']}\n{j['password']}") 72 | 73 | 74 | async def download(args): 75 | sem = asyncio.Semaphore(1) 76 | url = "https://windscribe.com/getconfig/openvpn" 77 | 78 | async with aiohttp.ClientSession(raise_for_status=True) as session: 79 | await login(session, args.user, args.password) 80 | config_locations = await get_config_locations(session) 81 | data = {"protocol": "tcp", "port": "443", "version": "3b"} 82 | 83 | if args.z: 84 | with ZipFile("ovpn_tcp.zip", "w") as z: 85 | tasks = [ 86 | postzip( 87 | sem, 88 | session, 89 | z, 90 | f"ovpn_tcp/{k}.ovpn", 91 | url, 92 | {**data, "location": v}, 93 | ) 94 | for k, v in config_locations.items() 95 | ] 96 | 97 | await asyncio.gather(*tasks) 98 | 99 | if args.c: 100 | await get_credentials(session) 101 | 102 | 103 | def main() -> None: 104 | parser = argparse.ArgumentParser(description="Process some integers.") 105 | parser.add_argument("user", type=str, help="Windscribe username") 106 | parser.add_argument("password", type=str, help="Windscribe password") 107 | parser.add_argument( 108 | "-z", default=True, action="store_false", help="disable zipfile download" 109 | ) 110 | parser.add_argument( 111 | "-c", default=True, action="store_true", help="download credentials" 112 | ) 113 | 114 | args = parser.parse_args() 115 | logger.debug(args) 116 | 117 | loop = asyncio.get_event_loop() 118 | loop.run_until_complete(download(args)) 119 | 120 | 121 | if __name__ == "__main__": 122 | logging_config = { 123 | "version": 1, 124 | "disable_existing_loggers": False, 125 | "formatters": { 126 | "simple": { 127 | "format": "%(asctime)s - %(threadName)s - %(name)s - %(levelname)s - %(message)s" 128 | } 129 | }, 130 | "handlers": { 131 | "console": { 132 | "class": "logging.StreamHandler", 133 | "level": "DEBUG", 134 | "formatter": "simple", 135 | "stream": "ext://sys.stdout", 136 | }, 137 | "file": { 138 | "class": "logging.handlers.RotatingFileHandler", 139 | "level": "DEBUG", 140 | "formatter": "simple", 141 | "filename": "app.log", 142 | "maxBytes": 20971520 * 5, 143 | "backupCount": 10, 144 | "encoding": "utf8", 145 | }, 146 | }, 147 | "root": {"level": "DEBUG", "handlers": ["console"]}, 148 | } 149 | 150 | logging.config.dictConfig(logging_config) 151 | main() 152 | -------------------------------------------------------------------------------- /vpnrotate/src/vpnrotate/handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from logging import Logger 3 | from pathlib import Path 4 | from time import perf_counter 5 | 6 | from aiohttp import web 7 | 8 | from . import __version__, svchandler, utils, vpnconfigs 9 | 10 | logger: Logger = logging.getLogger(__name__) 11 | 12 | 13 | """ 14 | Swagger Help: https://swagger.io/docs/specification/describing-parameters/ 15 | """ 16 | 17 | 18 | # Routes 19 | async def index(request): 20 | return web.Response(text=__version__) 21 | 22 | 23 | async def vpninfo(request): 24 | """ 25 | --- 26 | summary: Returns current VPN information 27 | tags: 28 | - VPN Information 29 | responses: 30 | "200": 31 | description: Return connection information 32 | """ 33 | try: 34 | provider = request.app["PROVIDER"] 35 | local_connect = request.app["LOCAL_CONNECT"] 36 | current_connect = await utils.get_ip_info( 37 | request.app["CONFIG"]["vpn_env"]["ip"], extended=False 38 | ) 39 | secure = current_connect.get("ip", "") != local_connect.get("ip", "") 40 | 41 | return web.json_response( 42 | { 43 | **provider, 44 | "local": local_connect, 45 | "current": current_connect, 46 | "secure": secure, 47 | } 48 | ) 49 | 50 | except Exception as e: 51 | logger.exception("vpn info failed") 52 | raise web.HTTPInternalServerError(text=str(e)) 53 | 54 | 55 | async def vpnsecure(request): 56 | """ 57 | --- 58 | summary: Compares container IP with current IP to test if container is "secure" 59 | tags: 60 | - VPN Information 61 | responses: 62 | "200": 63 | description: Return yes / no 64 | """ 65 | try: 66 | local_connect = request.app["LOCAL_CONNECT"] 67 | current_connect = await utils.get_ip_info( 68 | request.app["CONFIG"]["vpn_env"]["ip"] 69 | ) 70 | secure = current_connect.get("ip", "") != local_connect.get("ip", "") 71 | return web.json_response(secure) 72 | 73 | except Exception as e: 74 | logger.exception("vpn secure failed") 75 | raise web.HTTPInternalServerError(text=str(e)) 76 | 77 | 78 | async def restart_vpn(request): 79 | """ 80 | --- 81 | summary: Update VPN and restart 82 | tags: 83 | - VPN 84 | requestBody: 85 | description: Post body 86 | required: true 87 | content: 88 | application/json: 89 | schema: 90 | type: object 91 | properties: 92 | vpn: 93 | type: string 94 | server: 95 | type: string 96 | required: 97 | - server 98 | examples: 99 | example: 100 | summary: Sample post 101 | value: 102 | vpn: nordvpn 103 | server: us6782.nordvpn.com 104 | responses: 105 | "200": 106 | description: ok 107 | """ 108 | try: 109 | body = await request.json() 110 | vpn_env = request.app["CONFIG"]["vpn_env"] 111 | request.app["PROVIDER"] = await svchandler.change_vpn_config( 112 | vpn_env["vpnconfigs"], vpn_env["vpnconfig"], body.get("server") 113 | ) 114 | await svchandler.restart_vpn() 115 | return web.Response(text="ok") 116 | 117 | except Exception as e: 118 | logger.exception("restart failed") 119 | raise web.HTTPInternalServerError(text=str(e)) 120 | 121 | 122 | async def start_vpn(request): 123 | """ 124 | --- 125 | summary: Start vpn with current vpn settings 126 | tags: 127 | - VPN 128 | responses: 129 | "200": 130 | description: ok 131 | """ 132 | try: 133 | await svchandler.start_vpn() 134 | return web.Response(text="ok") 135 | 136 | except Exception as e: 137 | logger.exception("start failed") 138 | raise web.HTTPInternalServerError(text=str(e)) 139 | 140 | 141 | async def status_vpn(request): 142 | """ 143 | --- 144 | summary: returns raw vpn svc status 145 | tags: 146 | - VPN 147 | responses: 148 | "200": 149 | description: status string 150 | """ 151 | try: 152 | success, rc, stdout = await svchandler.status_vpn() 153 | if success: 154 | return web.Response(text=stdout) 155 | 156 | raise Exception(f"Error checking status: {rc}") 157 | 158 | except Exception as e: 159 | logger.exception("status check failed") 160 | raise web.HTTPInternalServerError(text=str(e)) 161 | 162 | 163 | async def stop_vpn(request): 164 | """ 165 | --- 166 | summary: Stop vpn 167 | tags: 168 | - VPN 169 | responses: 170 | "200": 171 | description: ok 172 | """ 173 | try: 174 | await svchandler.stop_vpn() 175 | return web.Response(text="ok") 176 | 177 | except Exception as e: 178 | logger.exception("stop failed") 179 | raise web.HTTPInternalServerError(text=str(e)) 180 | 181 | 182 | async def refresh_vpn_configs(request): 183 | """ 184 | --- 185 | summary: Refresh vpn configs 186 | tags: 187 | - VPN 188 | responses: 189 | "200": 190 | description: ok 191 | """ 192 | try: 193 | config = request.app["CONFIG"] 194 | await vpnconfigs.run_ovpn_setup(config, clean=False) 195 | return web.Response(text="ok") 196 | 197 | except Exception as e: 198 | logger.exception("stop failed") 199 | raise web.HTTPInternalServerError(text=str(e)) 200 | 201 | 202 | async def metrics(request): 203 | """ 204 | --- 205 | summary: Metrics 206 | tags: 207 | - Health Check 208 | responses: 209 | "200": 210 | description: Returns metrics information 211 | content: 212 | application/json: {} 213 | """ 214 | metrics = request.app["METRICS"] 215 | content = { 216 | "uptime": perf_counter() - metrics.START_TIME, 217 | "total_requsts": metrics.TOTAL_REQUESTS.value, 218 | } 219 | return web.json_response(content) 220 | 221 | 222 | async def healthcheck(request): 223 | """ 224 | --- 225 | summary: This end-point allow to test that service is up. 226 | tags: 227 | - Health Check 228 | responses: 229 | "200": 230 | description: Return "ok" text 231 | """ 232 | return web.Response(text="ok") 233 | 234 | 235 | async def vpns(request): 236 | """ 237 | --- 238 | summary: This end-point returns available vpn servers. 239 | tags: 240 | - VPN 241 | responses: 242 | "200": 243 | description: Return "ok" text 244 | "500": 245 | description: return error 246 | """ 247 | try: 248 | vpns = {"nordvpn": [], "pia": [], "wind": []} 249 | vpn_env = request.app["CONFIG"]["vpn_env"] 250 | 251 | for vpn in vpns.keys(): 252 | vpn_config_dir = Path(f"{vpn_env['vpnconfigs']}/{vpn}/ovpn_tcp/") 253 | if ( 254 | vpn_config_dir.exists() 255 | and Path( 256 | f"{vpn_env['vpnconfigs']}/../{vpn.replace('vpn','')}.conf" 257 | ).is_file() 258 | ): 259 | for f in vpn_config_dir.iterdir(): 260 | try: 261 | # Filter out crt/key/pem 262 | if f.suffix in [".tcp", ".ovpn"]: 263 | if vpn == "nordvpn": 264 | s = f.with_suffix("").stem 265 | else: 266 | s = f.stem 267 | 268 | vpns[vpn].append(s) 269 | except Exception: 270 | logger.exeception("error parsing filename") 271 | 272 | return web.json_response(vpns) 273 | 274 | except Exception as e: 275 | logger.exception("vpn error") 276 | raise web.HTTPInternalServerError(text=str(e)) 277 | 278 | 279 | def routing_table(app): 280 | return [ 281 | web.get("/", index, allow_head=False), 282 | web.get("/healthcheck", healthcheck, allow_head=False), 283 | web.get("/metrics", metrics, allow_head=False), 284 | web.get("/vpns", vpns, allow_head=False), 285 | web.get("/vpn/status", status_vpn, allow_head=False), 286 | web.put("/vpn/restart", restart_vpn), 287 | web.delete("/vpn/stop", stop_vpn), 288 | web.post("/vpn/start", start_vpn), 289 | web.get("/vpninfo", vpninfo, allow_head=False), 290 | web.get("/vpnsecure", vpnsecure, allow_head=False), 291 | web.post("/vpn/configs", refresh_vpn_configs), 292 | ] 293 | 294 | 295 | @web.middleware 296 | async def request_counter(request, handler): 297 | request.app["METRICS"].TOTAL_REQUESTS.increment() 298 | response = await handler(request) 299 | return response 300 | 301 | 302 | @web.middleware 303 | async def response_time(request, handler): 304 | start_request = perf_counter() 305 | response = await handler(request) 306 | response_time = perf_counter() - start_request 307 | response.headers["x-app-response-time"] = f"{response_time:.8f}" 308 | return response 309 | --------------------------------------------------------------------------------