diff --git a/.github/workflows/protonvpn-wireguard-config-downloader-QA.yaml b/.github/workflows/protonvpn-wireguard-config-downloader-QA.yaml new file mode 100644 index 0000000..f8c4329 --- /dev/null +++ b/.github/workflows/protonvpn-wireguard-config-downloader-QA.yaml @@ -0,0 +1,40 @@ +name: ProtonVPN Wireguard Config Downloader QA + +on: + pull_request: + push: + paths: + - 'protonvpn-wireguard-config-downloader/**' + branches: + - main + +jobs: + + check-qa: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version-file: protonvpn-wireguard-config-downloader/pyproject.toml + architecture: x64 + + - name: Install dependencies (and project) + working-directory: protonvpn-wireguard-config-downloader + run: | + pip install -U pip + pip install -e .[lint,scripts,test,check] + + - name: Check black formatting + working-directory: protonvpn-wireguard-config-downloader + run: inv lint-black + + - name: Check ruff + working-directory: protonvpn-wireguard-config-downloader + run: inv lint-ruff + + - name: Check pyright + working-directory: protonvpn-wireguard-config-downloader + run: inv check-pyright diff --git a/protonvpn-wireguard-config-downloader/Dockerfile b/protonvpn-wireguard-config-downloader/Dockerfile new file mode 100644 index 0000000..dabd290 --- /dev/null +++ b/protonvpn-wireguard-config-downloader/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.11-slim-bookworm +LABEL org.opencontainers.image.source=https://github.com/kiwix/mirrors-qa + +# We need gnupg2 for python-gnupg used in Proton libraries to work properly. +RUN apt-get update && apt-get install -y gnupg2 + +COPY src /src/src + +COPY pyproject.toml README.md /src/ + +RUN pip install --no-cache-dir /src \ + && rm -rf /src + +RUN mkdir /data + +CMD ["protonvpn-wireguard-configs", "--help"] diff --git a/protonvpn-wireguard-config-downloader/README.md b/protonvpn-wireguard-config-downloader/README.md new file mode 100644 index 0000000..7d8bee4 --- /dev/null +++ b/protonvpn-wireguard-config-downloader/README.md @@ -0,0 +1,23 @@ +# ProtonVPN Wireguard Configuration Downloader + +A tool to automatically download wireguard configuration files for all available VPN servers from ProtonVPN. + +**NOTE** +This script is intended to be used in Linux environments only. + +## Environment Variables + +- `USERNAME`: username for connnecting to ProtonVPN account +- `PASSWORD` +- `WORKDIR`: location to store configuration files. (default: /data) +- `WIREGUARD_PORT`: Port of the wireguard configuration files (default: 51820).This allows to choose the wireguard port for the configuration files rather than leaving it to the ProtonVPN library which often defaults to the first available port in the session object. + +## Usage +- Build the image + ```sh + docker build -t protonvpn-wireguard-config-downloader . + ``` +- Download the configuration files + ```sh + docker run --rm -e USERNAME=abcd@efg -e PASSWORD=pa55word -v ./proton:/data protonvpn-wireguard-config-downloader protonvpn-wireguard-configs + ``` diff --git a/protonvpn-wireguard-config-downloader/pyproject.toml b/protonvpn-wireguard-config-downloader/pyproject.toml new file mode 100644 index 0000000..3cd18fe --- /dev/null +++ b/protonvpn-wireguard-config-downloader/pyproject.toml @@ -0,0 +1,238 @@ +[build-system] +requires = ["hatchling", "hatch-openzim"] +build-backend = "hatchling.build" + +[project] +name = "protonvpn_wireguard_config_downloader" +requires-python = ">=3.11,<3.13" +description = "ProtonVPN Wireguard Configuration Files Downloader" +readme = "README.md" +authors = [ + { name = "Kiwix", email = "dev@kiwix.org" }, +] +keywords = ["protonvpn", "wireguard"] +dependencies = [ + "proton-core @ https://github.com/ProtonVPN/python-proton-core/archive/refs/tags/v0.2.0.zip", + "proton-vpn-logger @ https://github.com/ProtonVPN/python-proton-vpn-logger/archive/refs/tags/v0.2.1.zip", + "proton-vpn-api-core @ https://github.com/ProtonVPN/python-proton-vpn-api-core/archive/refs/tags/v0.32.2.zip", + "distro==1.9.0" +] +license = {text = "GPL-3.0-or-later"} +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)", +] + +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/kiwix/mirrors-qa/protonvpn-wireguard-config-downloader" + +[project.optional-dependencies] +scripts = [ + "invoke==2.2.0", +] +lint = [ + "black==24.4.2", + "ruff==0.5.1", +] +check = [ + "pyright==1.1.370", +] +test = [ + "pytest==8.2.2", + "coverage==7.5.4", +] +dev = [ + "pre-commit==3.7.1", + "debugpy==1.8.2", + "protonvpn_wireguard_config_downloader[scripts]", + "protonvpn_wireguard_config_downloader[lint]", + "protonvpn_wireguard_config_downloader[test]", + "protonvpn_wireguard_config_downloader[check]", +] + +[project.scripts] +protonvpn-wireguard-configs = "protonvpn_wireguard_config_downloader.entrypoint:main" + +[tool.hatch.version] +path = "src/protonvpn_wireguard_config_downloader/__about__.py" + +[tool.hatch.build] +exclude = [ + "/.github", +] + +[tool.hatch.metadata] +allow-direct-references = true + +[tool.hatch.build.targets.wheel] +packages = ["src/protonvpn_wireguard_config_downloader"] + +[tool.hatch.envs.default] +features = ["dev"] + +[tool.hatch.envs.test] +features = ["scripts", "test"] + + +[tool.hatch.envs.test.scripts] +run = "inv test --args '{args}'" +run-cov = "inv test-cov --args '{args}'" +report-cov = "inv report-cov" +coverage = "inv coverage --args '{args}'" +html = "inv coverage --html --args '{args}'" + +[tool.hatch.envs.lint] +template = "lint" +skip-install = false +features = ["scripts", "lint"] + +[tool.hatch.envs.lint.scripts] +black = "inv lint-black --args '{args}'" +ruff = "inv lint-ruff --args '{args}'" +all = "inv lintall --args '{args}'" +fix-black = "inv fix-black --args '{args}'" +fix-ruff = "inv fix-ruff --args '{args}'" +fixall = "inv fixall --args '{args}'" + +[tool.hatch.envs.check] +features = ["scripts", "check"] + +[tool.hatch.envs.check.scripts] +pyright = "inv check-pyright --args '{args}'" +all = "inv checkall --args '{args}'" + +[tool.black] +line-length = 88 +target-version = ['py310'] + +[tool.ruff] +target-version = "py311" +line-length = 88 +src = ["src"] + +[tool.ruff.lint] +select = [ + "A", # flake8-builtins + # "ANN", # flake8-annotations + "ARG", # flake8-unused-arguments + # "ASYNC", # flake8-async + "B", # flake8-bugbear + # "BLE", # flake8-blind-except + "C4", # flake8-comprehensions + "C90", # mccabe + # "COM", # flake8-commas + # "D", # pydocstyle + # "DJ", # flake8-django + "DTZ", # flake8-datetimez + "E", # pycodestyle (default) + "EM", # flake8-errmsg + # "ERA", # eradicate + # "EXE", # flake8-executable + "F", # Pyflakes (default) + # "FA", # flake8-future-annotations + "FBT", # flake8-boolean-trap + # "FLY", # flynt + # "G", # flake8-logging-format + "I", # isort + "ICN", # flake8-import-conventions + # "INP", # flake8-no-pep420 + # "INT", # flake8-gettext + "ISC", # flake8-implicit-str-concat + "N", # pep8-naming + # "NPY", # NumPy-specific rules + # "PD", # pandas-vet + # "PGH", # pygrep-hooks + # "PIE", # flake8-pie + # "PL", # Pylint + "PLC", # Pylint: Convention + "PLE", # Pylint: Error + "PLR", # Pylint: Refactor + "PLW", # Pylint: Warning + # "PT", # flake8-pytest-style + # "PTH", # flake8-use-pathlib + # "PYI", # flake8-pyi + "Q", # flake8-quotes + # "RET", # flake8-return + # "RSE", # flake8-raise + "RUF", # Ruff-specific rules + "S", # flake8-bandit + # "SIM", # flake8-simplify + # "SLF", # flake8-self + "T10", # flake8-debugger + "T20", # flake8-print + # "TCH", # flake8-type-checking + # "TD", # flake8-todos + "TID", # flake8-tidy-imports + # "TRY", # tryceratops + "UP", # pyupgrade + "W", # pycodestyle + "YTT", # flake8-2020 +] +ignore = [ + # Allow non-abstract empty methods in abstract base classes + "B027", + # Remove flake8-errmsg since we consider they bloat the code and provide limited value + "EM", + # Allow boolean positional values in function calls, like `dict.get(... True)` + "FBT003", + # Ignore checks for possible passwords + "S105", "S106", "S107", + # Ignore warnings on subprocess.run / popen + "S603", + # Ignore complexity + "C901", "PLR0911", "PLR0912", "PLR0913", "PLR0915", +] +unfixable = [ + # Don't touch unused imports + "F401", +] + +[tool.ruff.lint.isort] +known-first-party = ["protonvpn_wireguard_config_downloader"] + +[tool.ruff.lint.flake8-bugbear] +# add exceptions to B008 for fastapi. +extend-immutable-calls = ["fastapi.Depends", "fastapi.Query"] + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = "all" + +[tool.ruff.lint.per-file-ignores] +# Tests can use magic values, assertions, and relative imports +"tests/**/*" = ["PLR2004", "S101", "TID252"] + +[tool.pytest.ini_options] +minversion = "7.3" +testpaths = ["tests"] +pythonpath = [".", "src"] + +[tool.coverage.paths] +protonvpn_wireguard_config_downloader = ["src/protonvpn_wireguard_config_downloader"] +tests = ["tests"] + +[tool.coverage.run] +source_pkgs = ["protonvpn_wireguard_config_downloader"] +branch = true +parallel = true +omit = [ + "src/protonvpn_wireguard_config_downloader/__about__.py", +] + +[tool.coverage.report] +exclude_lines = [ + "no cov", + "if __name__ == .__main__.:", + "if TYPE_CHECKING:", +] + +[tool.pyright] +include = ["src", "tests", "tasks.py"] +exclude = [".env/**", ".venv/**"] +extraPaths = ["src"] +pythonVersion = "3.11" +typeCheckingMode="strict" +disableBytesTypePromotions = true diff --git a/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/__about__.py b/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/__about__.py new file mode 100644 index 0000000..5becc17 --- /dev/null +++ b/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/__about__.py @@ -0,0 +1 @@ +__version__ = "1.0.0" diff --git a/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/__init__.py b/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/__init__.py new file mode 100644 index 0000000..a3bebc9 --- /dev/null +++ b/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/__init__.py @@ -0,0 +1,11 @@ +import logging + +from protonvpn_wireguard_config_downloader.settings import Settings + +logger = logging.getLogger("task") + +if not logger.hasHandlers(): + logger.setLevel(logging.DEBUG if Settings.DEBUG else logging.INFO) + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter("[%(asctime)s: %(levelname)s] %(message)s")) + logger.addHandler(handler) diff --git a/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/entrypoint.py b/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/entrypoint.py new file mode 100644 index 0000000..4fca789 --- /dev/null +++ b/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/entrypoint.py @@ -0,0 +1,54 @@ +import argparse +import asyncio +import logging +from pathlib import Path + +from protonvpn_wireguard_config_downloader import logger +from protonvpn_wireguard_config_downloader.__about__ import __version__ +from protonvpn_wireguard_config_downloader.protonvpn import ( + login, + logout, + save_vpn_server_wireguard_config, + vpn_servers, +) +from protonvpn_wireguard_config_downloader.settings import Settings + + +async def download_vpn_wireguard_configs( + username: str, password: str, wireguard_port: int, work_dir: Path +) -> None: + """Download Wireguard configuration files for all VPN servers.""" + session = await login(username, password) + try: + logger.debug("Fetching available VPN servers for client...") + for vpn_server in vpn_servers(session, wireguard_port): + save_vpn_server_wireguard_config(session, vpn_server, work_dir) + finally: + logger.debug("Logging out...") + await logout(session) + logger.info("Successfully logged out client.") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "-v", "--verbose", help="Show verbose output", action="store_true" + ) + parser.add_argument( + "--version", + help="Show version and exit.", + action="version", + version="%(prog)s " + __version__, + ) + args = parser.parse_args() + if args.verbose: + logger.setLevel(logging.DEBUG) + + asyncio.run( + download_vpn_wireguard_configs( + Settings.USERNAME, + Settings.PASSWORD, + Settings.WIREGUARD_PORT, + Settings.WORKDIR, + ) + ) diff --git a/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/protonvpn.py b/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/protonvpn.py new file mode 100644 index 0000000..9cf0d7d --- /dev/null +++ b/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/protonvpn.py @@ -0,0 +1,88 @@ +# pyright: strict, reportMissingTypeStubs=false, reportUnknownMemberType=false, reportOptionalSubscript=false, reportUnknownVariableType=false, reportUnknownArgumentType=false +from collections.abc import Generator +from pathlib import Path +from typing import cast + +from proton.sso import ProtonSSO +from proton.vpn.connection.vpnconfiguration import WireguardConfig +from proton.vpn.core.connection import VPNServer +from proton.vpn.session import VPNSession + +from protonvpn_wireguard_config_downloader import logger +from protonvpn_wireguard_config_downloader.settings import Settings + + +async def login(username: str, password: str) -> VPNSession: + """Log in to Proton VPN account.""" + logger.info("Logging in to ProtonVPN...") + sso = ProtonSSO( + user_agent=Settings.USER_AGENT, appversion=Settings.PROTONVPN_APP_VERSION + ) + session = cast(VPNSession, sso.get_session(username, override_class=VPNSession)) + logger.debug("Authenticating credentials with ProtonVPN.") + await session.async_authenticate(username, password) + logger.debug("Fetching client session data.") + await session.fetch_session_data() + logger.info("Logged in to ProtonVPN.") + return session + + +async def logout(session: VPNSession) -> None: + """Log out from the Proton VPN account.""" + if session.authenticated: + await session.async_logout() + + +def vpn_servers( + session: VPNSession, + wireguard_port: int, +) -> Generator[VPNServer, None, None]: + """Generate the available VPN servers for this account. + + Raises: + ValueError: Specified wireguard port is not available for this client. + """ + client_config = session.client_config + if wireguard_port not in client_config.wireguard_ports.udp: + raise ValueError(f"Port {wireguard_port} is not available in client config.") + + # Build up the list of servers, filtering out disabled servers and + # servers that are above the client's tier. + logical_servers = ( + server + for server in session.server_list.logicals + if server.enabled and server.tier <= session.server_list.user_tier + ) + return ( + VPNServer( + server_ip=physical_server.entry_ip, + domain=physical_server.domain, + x25519pk=physical_server.x25519_pk, + openvpn_ports=client_config.openvpn_ports, + wireguard_ports=[ + wireguard_port + ], # pyright: ignore[reportGeneralTypeIssues, reportArgumentType] + server_id=logical_server.id, + server_name=f"{logical_server.exit_country.lower()}-{logical_server.name}", + label=physical_server.label, + ) + for logical_server in logical_servers + for physical_server in logical_server.physical_servers + ) + + +def save_vpn_server_wireguard_config( + session: VPNSession, vpn_server: VPNServer, dest_dir: Path +) -> Path: + """Save the Wireguard config for the VPN server.""" + logger.debug(f"Saving configuration file for VPN server: {vpn_server.server_name}") + config = WireguardConfig( + vpn_server, session.vpn_account.vpn_credentials, None, use_certificate=True + ) + dest_fpath = dest_dir / f"{vpn_server.server_name}.conf" + dest_fpath.write_text(config.generate(), encoding="utf-8") + logger.info( + f"Saved configuration file for VPN server: {vpn_server.server_name}, " + f"name: {dest_fpath.name}" + ) + return dest_fpath diff --git a/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/settings.py b/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/settings.py new file mode 100644 index 0000000..26ceb5f --- /dev/null +++ b/protonvpn-wireguard-config-downloader/src/protonvpn_wireguard_config_downloader/settings.py @@ -0,0 +1,29 @@ +import os +from pathlib import Path +from typing import Any + +import distro + + +def getenv(key: str, *, mandatory: bool = False, default: Any = None) -> Any: + value = os.getenv(key, default=default) + + if mandatory and not value: + raise OSError(f"Please set the {key} environment variable") + + return value + + +class Settings: + """Task worker configuration""" + + USERNAME = getenv("USERNAME", mandatory=True) + PASSWORD = getenv("PASSWORD", mandatory=True) + DEBUG = bool(getenv("DEBUG", default=False)) + WORKDIR = Path(getenv("WORKDIR", default="/data")).resolve() + PROTONVPN_VERSION = getenv("PROTONVPN_APP_VERSION", default="4.4.4") + PROTONVPN_APP_VERSION = f"LinuxVPN_{PROTONVPN_VERSION}" + USER_AGENT = ( + f"ProtonVPN/{PROTONVPN_VERSION} (Linux; {distro.name()}/{distro.version()})" + ) + WIREGUARD_PORT = int(getenv("WIREGUARD_PORT", default=51820)) diff --git a/protonvpn-wireguard-config-downloader/tasks.py b/protonvpn-wireguard-config-downloader/tasks.py new file mode 100644 index 0000000..87cd552 --- /dev/null +++ b/protonvpn-wireguard-config-downloader/tasks.py @@ -0,0 +1,110 @@ +# pyright: strict, reportUntypedFunctionDecorator=false +import os + +from invoke.context import Context +from invoke.tasks import task # pyright: ignore [reportUnknownVariableType] + +use_pty = not os.getenv("CI", "") + + +@task(optional=["args"], help={"args": "pytest additional arguments"}) +def test(ctx: Context, args: str = ""): + """run tests (without coverage)""" + ctx.run(f"pytest {args}", pty=use_pty) + + +@task(optional=["args"], help={"args": "pytest additional arguments"}) +def test_cov(ctx: Context, args: str = ""): + """run test vith coverage""" + ctx.run(f"coverage run -m pytest {args}", pty=use_pty) + + +@task(optional=["html"], help={"html": "flag to export html report"}) +def report_cov(ctx: Context, *, html: bool = False): + """report coverage""" + ctx.run("coverage combine", warn=True, pty=use_pty) + ctx.run("coverage report --show-missing", pty=use_pty) + ctx.run("coverage xml", pty=use_pty) + if html: + ctx.run("coverage html", pty=use_pty) + + +@task( + optional=["args", "html"], + help={ + "args": "pytest additional arguments", + "html": "flag to export html report", + }, +) +def coverage(ctx: Context, args: str = "", *, html: bool = False): + """run tests and report coverage""" + test_cov(ctx, args=args) + report_cov(ctx, html=html) + + +@task(optional=["args"], help={"args": "black additional arguments"}) +def lint_black(ctx: Context, args: str = "."): + args = args or "." # needed for hatch script + ctx.run("black --version", pty=use_pty) + ctx.run(f"black --check --diff {args}", pty=use_pty) + + +@task(optional=["args"], help={"args": "ruff additional arguments"}) +def lint_ruff(ctx: Context, args: str = "."): + args = args or "." # needed for hatch script + ctx.run("ruff --version", pty=use_pty) + ctx.run(f"ruff check {args}", pty=use_pty) + + +@task( + optional=["args"], + help={ + "args": "linting tools (black, ruff) additional arguments, typically a path", + }, +) +def lintall(ctx: Context, args: str = "."): + """Check linting""" + args = args or "." # needed for hatch script + lint_black(ctx, args) + lint_ruff(ctx, args) + + +@task(optional=["args"], help={"args": "check tools (pyright) additional arguments"}) +def check_pyright(ctx: Context, args: str = ""): + """check static types with pyright""" + ctx.run("pyright --version") + ctx.run(f"pyright {args}", pty=use_pty) + + +@task(optional=["args"], help={"args": "check tools (pyright) additional arguments"}) +def checkall(ctx: Context, args: str = ""): + """check static types""" + check_pyright(ctx, args) + + +@task(optional=["args"], help={"args": "black additional arguments"}) +def fix_black(ctx: Context, args: str = "."): + """fix black formatting""" + args = args or "." # needed for hatch script + ctx.run(f"black {args}", pty=use_pty) + + +@task(optional=["args"], help={"args": "ruff additional arguments"}) +def fix_ruff(ctx: Context, args: str = "."): + """fix all ruff rules""" + args = args or "." # needed for hatch script + ctx.run(f"ruff check --fix {args}", pty=use_pty) + + +@task( + optional=["args"], + help={ + "args": "linting tools (black, ruff) additional arguments, typically a path", + }, +) +def fixall(ctx: Context, args: str = "."): + """Fix everything automatically""" + args = args or "." # needed for hatch script + fix_black(ctx, args) + fix_ruff(ctx, args) + lintall(ctx, args) diff --git a/protonvpn-wireguard-config-downloader/tests/test_basic.py b/protonvpn-wireguard-config-downloader/tests/test_basic.py new file mode 100644 index 0000000..e69de29