From 1168e6e1b2194544e57e6508056d8a20c006868a Mon Sep 17 00:00:00 2001 From: Joe Roback Date: Sat, 15 Nov 2025 15:02:48 -0700 Subject: [PATCH] add in wireguard to port forwarding container. gluetun just does not work reliably with protonvpn. this is way simplier approach if all you care about is a simple connection to protonvpn wireguard --- .gitignore | 1 + Dockerfile | 40 +++++--------- LICENSE | 2 +- README.md | 62 ++++----------------- compose.yml | 17 ++++++ entrypoint.py | 146 -------------------------------------------------- entrypoint.sh | 95 ++++++++++++++++++++++++++++++++ 7 files changed, 136 insertions(+), 227 deletions(-) create mode 100644 compose.yml delete mode 100755 entrypoint.py create mode 100755 entrypoint.sh diff --git a/.gitignore b/.gitignore index e69de29..8c4cb47 100644 --- a/.gitignore +++ b/.gitignore @@ -0,0 +1 @@ +wg0.conf diff --git a/Dockerfile b/Dockerfile index 6f67e68..e00c17b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,32 +1,18 @@ -FROM alpine:3.19 as builder +FROM alpine:3.22 -ARG PYTHONUNBUFFERED=1 +ENV LOCAL_SUBNETS="192.168.0.0/16" +ENV TZ="UTC" +ENV WEBUI_HOST="http://localhost:8080" +ENV WIREGUARD_INTERFACE="wg0" -RUN \ - apk add --update --no-cache \ - g++ \ - linux-headers \ - make \ - python3 \ - python3-dev +RUN apk add --update --no-cache \ + bash ca-certificates curl iproute2 iptables ip6tables jq libnatpmp tzdata wireguard-tools \ + && ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \ + && echo ${TZ} > /etc/timezone -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 +COPY entrypoint.sh /entrypoint.sh -FROM alpine:3.19 +HEALTHCHECK --start-period=15s --interval=60s --timeout=10s --retries=3 \ + CMD ping -c 1 10.2.0.1 || exit 1 -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" ] +ENTRYPOINT ["/entrypoint.sh"] diff --git a/LICENSE b/LICENSE index 6d75872..afc6ca6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Joe Roback +Copyright (c) 2024-2025 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 diff --git a/README.md b/README.md index 47f5b8f..699015a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Proton VPN + qBittorrent +# Proton VPN + Port Forwarding + qBittorrent -This relies on qBittorrent web ui not requiring authentication for localhost. +This relies on qBittorrent Web UI not requiring authentication for localhost connections. ``` Tools @@ -10,55 +10,11 @@ Tools -> [X] Bypass authentication for clients on localhost ``` -Example docker-compose.yml +### Recommended Environment Variables -``` -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' -``` +| Variable | Default | Examples | Description | +| -------- | ------- | ----- | ---------- | +| `TZ` | `UTC` | `America/Denver` | Set your [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) to make sure logs rotate at local midnight instead of at UTC midnight. +| `WIREGUARD_INTERFACE` | `wg0` | `wg0`, `wg1`, ... | Set the wireguard interface name to use. +| `LOCAL_SUBNETS` | `192.168.0.0/16` | `192.168.0.0/16, 10.0.0.0/8` | Comma separated list of local subnet CIDRs to be allowed outside the wireguard tunnel. +| `WEBUI_HOST` | `http://localhost:8080` | | Url to the qBittorrent Web UI. Authenication must be disabled to localhost connections. diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..acc128b --- /dev/null +++ b/compose.yml @@ -0,0 +1,17 @@ +services: + protonvpn-qbittorrent: + image: ghcr.io/joeroback/protonvpn-qbittorrent:latest + restart: unless-stopped + cap_add: + - NET_ADMIN + sysctls: + net.ipv4.conf.all.src_valid_mark: 1 + volumes: + - ./wg0.conf:/etc/wireguard/wg0.conf:ro + curl: + image: curlimages/curl + network_mode: service:protonvpn-qbittorrent + depends_on: + protonvpn-qbittorrent: + condition: service_healthy + command: -4 --verbose https://ifconfig.me diff --git a/entrypoint.py b/entrypoint.py deleted file mode 100755 index 47f0083..0000000 --- a/entrypoint.py +++ /dev/null @@ -1,146 +0,0 @@ -#!/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=gateway_ip, - 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() diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..f8081dc --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +set -o errexit + +default_route_ip=$(ip route | grep '^default' | awk '{print $3}') +if [[ -z "$default_route_ip" ]]; then + echo "Error: No default route configured" >&2 + exit 1 +fi + +if [[ "$(cat /proc/sys/net/ipv4/conf/all/src_valid_mark)" != "1" ]]; then + echo "Error: sysctl net.ipv4.conf.all.src_valid_mark=1 is not set" >&2 + exit 1 +fi + +# sysctl is set by container, wg-quick will then error +sed -i "s:sysctl -q net.ipv4.conf.all.src_valid_mark=1:echo Skipping setting net.ipv4.conf.all.src_valid_mark:" /usr/bin/wg-quick + +WIREGUARD_INTERFACE="${WIREGUARD_INTERFACE:-wg0}" + +if [ ! -f "/etc/wireguard/${WIREGUARD_INTERFACE}.conf" ]; then + echo "Error: Configuration file /etc/wireguard/${WIREGUARD_INTERFACE}.conf does not exist" >&2 + exit 1 +fi + +echo "Bringing up wireguard interface: ${WIREGUARD_INTERFACE}..." +wg-quick up ${WIREGUARD_INTERFACE} + +shutdown () { + wg-quick down ${WIREGUARD_INTERFACE} + exit 0 +} + +trap shutdown SIGTERM SIGINT SIGQUIT + +# kill switches for ipv4 and ipv6 wg-quick(8) +iptables -I OUTPUT ! -o ${WIREGUARD_INTERFACE} -m mark ! --mark $(wg show ${WIREGUARD_INTERFACE} fwmark) -m addrtype ! --dst-type LOCAL -j REJECT +ip6tables -I OUTPUT ! -o ${WIREGUARD_INTERFACE} -m mark ! --mark $(wg show ${WIREGUARD_INTERFACE} fwmark) -m addrtype ! --dst-type LOCAL -j REJECT + +# allow local container subnets (especially helpful if using multiple networks) +for network in $(ip -o addr show | awk '/^(\d)+: eth(.+)inet/ {print $4}'); do + iptables -I OUTPUT -d ${network} -j ACCEPT +done + +# allow connections user defined local networks +for local_subnet in ${LOCAL_SUBNETS//,/$IFS} +do + ip route add ${local_subnet} via ${default_route_ip} + iptables -I OUTPUT -d ${local_subnet} -j ACCEPT +done + +wg show +sleep 2 + +# check to see if tunnel allows port forwarding +natpmpc -g 10.2.0.1 + +# give some delay until qbittorrent container launches +sleep 10 + +# qbittorrent webui host +WEBUI_HOST="${WEBUI_HOST:-http://localhost:8080}" + +# loop now forever keeping port forward up to date (protonvpn) +# https://protonvpn.com/support/port-forwarding-manual-setup +while true; do + tcp_output=$(natpmpc -a 1 0 tcp 60 -g 10.2.0.1) + tcp_port=$(echo "${tcp_output}" | sed -n 's/.*Mapped public port \([0-9]\+\).*/\1/p') + + udp_output=$(natpmpc -a 1 0 udp 60 -g 10.2.0.1) + udp_port=$(echo "${udp_output}" | sed -n 's/.*Mapped public port \([0-9]\+\).*/\1/p') + + if [[ "${tcp_port}" -ne "${udp_port}" ]]; then + echo "Warning: tcp_port (${tcp_port}) and udp_port (${udp_port}) are different" + fi + + # failure to connect to webui, we don't want to fail the loop, just log the error and try again + current_port=$(curl --silent --header "Referer: ${WEBUI_HOST}" "${WEBUI_HOST}/api/v2/app/preferences" | jq .listen_port || true) + + if [[ "${tcp_port}" -ne "${current_port}" ]]; then + echo "Port changed from '${current_port}' to '${tcp_port}'. Updating app preferences..." + + curl \ + --silent \ + --header "Referer: ${WEBUI_HOST}" \ + --request POST \ + --data "json={\"listen_port\": ${tcp_port}}" \ + "${WEBUI_HOST}/api/v2/app/setPreferences" || true + fi + + sleep 45 +done + +echo "exiting" +exit 0