commit 371ef62663ad9758f61bf17fd44cc90046b15e51 Author: Joe Roback Date: Sun Mar 3 18:32:35 2024 -0700 initial commit diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..127e875 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: Continuous Integration + +concurrency: ${{ github.workflow }}-${{ github.ref }} + +on: + push: + branches: + - "trunk" + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ghcr.io/${{ github.repository }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64,linux/arm/v6,linux/arm/v7 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6f67e68 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +FROM alpine:3.19 as builder + +ARG PYTHONUNBUFFERED=1 + +RUN \ + apk add --update --no-cache \ + g++ \ + linux-headers \ + make \ + python3 \ + python3-dev + +RUN \ + python3 -m venv /usr/local/protonvpn-natpmp && \ + /usr/local/protonvpn-natpmp/bin/pip3 install -U pip && \ + /usr/local/protonvpn-natpmp/bin/pip3 install NAT-PMP + +FROM alpine:3.19 + +LABEL org.opencontainers.image.source="https://github.com/joeroback/protonvpn-qbittorrent" + +RUN \ + apk add --update --no-cache \ + ca-certificates \ + curl \ + python3 + +COPY --from=builder /usr/local/protonvpn-natpmp /usr/local/protonvpn-natpmp + +COPY entrypoint.py /entrypoint.py + +ENTRYPOINT [ "/entrypoint.py" ] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6d75872 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Joe Roback + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..47f5b8f --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# Proton VPN + qBittorrent + +This relies on qBittorrent web ui not requiring authentication for localhost. + +``` +Tools + -> Options + -> Web UI + -> Authentication + -> [X] Bypass authentication for clients on localhost +``` + +Example docker-compose.yml + +``` +version: '3' + +services: + wireguard: + image: ghcr.io/jordanpotter/docker-wireguard:latest + cap_add: + - NET_ADMIN + - SYS_MODULE + sysctls: + net.ipv4.conf.all.src_valid_mark: 1 + net.ipv6.conf.all.disable_ipv6: 1 + volumes: + - ./wg0.conf:/etc/wireguard/wg0.conf + environment: + LOCAL_SUBNETS: '192.168.1.0/24' + restart: always + ports: + - '8080:8080/tcp' + protonvpn-natpmp: + image: ghcr.io/joeroback/protonvpn-qbittorrent:latest + network_mode: service:wireguard + depends_on: + - wireguard + environment: + QBITTORRENT_HOST: 'localhost' + QBITTORRENT_PORT: '8080' + GATEWAY_IP: '10.2.0.1' + restart: always + qbittorrent: + image: ghcr.io/qbittorrent/docker-qbittorrent-nox:latest + network_mode: service:wireguard + depends_on: + - wireguard + - natpmp + restart: always + stop_grace_period: 5m + cap_add: + - CHOWN + - SYS_NICE + tmpfs: '/tmp' + read_only: true + environment: + QBT_LEGAL_NOTICE: 'confirm' + QBT_WEBUI_PORT: '8080' + TZ: 'America/Denver' + volumes: + - './config:/config' + - './downloads:/downloads' +``` diff --git a/entrypoint.py b/entrypoint.py new file mode 100755 index 0000000..c45f8ee --- /dev/null +++ b/entrypoint.py @@ -0,0 +1,146 @@ +#!/usr/local/protonvpn-natpmp/bin/python3 + +import json +import logging +import natpmp +import os +import signal +import sys +import time +import urllib.parse +import urllib.request + +# https://protonvpn.com/support/port-forwarding/ + + +class SignalHandler: + def __init__(self): + signal.signal(signal.SIGINT, self.signal_handler) + signal.signal(signal.SIGQUIT, self.signal_handler) + signal.signal(signal.SIGTERM, self.signal_handler) + + def signal_handler(self, signum, frame): + logging.info(f'A signal {signum} was caught. Exiting...') + sys.exit(0) + +class QBittorrentClient: + def __init__(self, host, port): + self.timeout = 10 + self.base_url = f'http://{host}:{port}' + + # qBittorrent API says to set the referer header + # https://github.com/qbittorrent/qBittorrent/wiki/WebUI-API-(qBittorrent-4.1) + self.headers = { + 'Referer': self.base_url, + 'Content-Type': 'application/x-www-form-urlencoded', + } + + def get_version(self): + req = urllib.request.Request( + f'{self.base_url}/api/v2/app/version', + headers=self.headers, + method='GET' + ) + + resp = urllib.request.urlopen(req, timeout=self.timeout) + return resp.read().decode('utf-8') + + def get_listen_port(self): + req = urllib.request.Request( + f'{self.base_url}/api/v2/app/preferences', + headers=self.headers, + method='GET' + ) + + resp = urllib.request.urlopen(req, timeout=self.timeout) + preferences = json.loads(resp.read().decode('utf-8')) + + return preferences['listen_port'] + + def set_listen_port(self, port): + data = json.dumps({ + 'listen_port': port, + 'random_port': False, + 'upnp': False, + }) + data = f'json={data}'.encode('utf-8') + + req = urllib.request.Request( + f'{self.base_url}/api/v2/app/setPreferences', + data=data, + headers=self.headers, + method='POST' + ) + + resp = urllib.request.urlopen(req, timeout=self.timeout) + if resp.status == 200: + logging.info(f'Successfully updated qBittorrent listen port to {port}') + else: + logging.error(f'Failed to update qBittorrent listen port: {resp.status} - {resp.reason}') + + +def main(): + sig_handler = SignalHandler() + + logging.basicConfig( + format='%(asctime)s:%(levelname)s: %(message)s', level=logging.INFO + ) + logging.info('Starting Proton VPN NAT-PMP port forwarding daemon') + + # pause a bit at startup + time.sleep(1) + + qbittorrent_host = os.getenv('QBITTORRENT_HOST') or 'localhost' + qbittorrent_port = os.getenv('QBITTORRENT_PORT') or '8080' + gateway_ip = os.getenv('GATEWAY_IP') or '10.2.0.1' + + qbittorrent_client = QBittorrentClient(qbittorrent_host, int(qbittorrent_port)) + + while True: + try: + # request port forward, use public port 1, private port 0 + # protonvpn will return the opened public port. set lifetime to 60, + # seems no matter whatever it is set to, it always returns 60s... + tcp = natpmp.map_tcp_port( + 1, + 0, + lifetime=60, + gateway_ip=gateway_ip, + retry=3, + use_exception=True, + ) + + udp = natpmp.map_udp_port( + 1, + 0, + lifetime=60, + gateway_ip='10.2.0.1', + retry=3, + use_exception=True, + ) + + assert tcp.private_port == 0 + assert udp.private_port == 0 + assert tcp.public_port == udp.public_port + + qbittorrent_version = qbittorrent_client.get_version() + qbittorrent_listen_port = qbittorrent_client.get_listen_port() + + # only if port changed, update qBittorrent + if tcp.public_port != qbittorrent_listen_port: + logging.info(f' NAT-PMP TCP Port: {tcp.public_port}') + logging.info(f' NAT-PMP UDP Port: {udp.public_port}') + logging.info(f' qBittorrent Version: {qbittorrent_version}') + logging.info(f'qBittorrent Listen Port: {qbittorrent_listen_port}') + qbittorrent_client.set_listen_port(tcp.public_port) + + sleep_for = max(1, min(tcp.lifetime, udp.lifetime) - 5) + logging.debug(f'Sleeping for {sleep_for}s...') + time.sleep(sleep_for) + except Exception as e: + logging.exception('An exception was thrown!') + time.sleep(5) + + +if __name__ == '__main__': + main()