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
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
wg0.conf
|
||||||
|
|||||||
+13
-27
@@ -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 \
|
RUN apk add --update --no-cache \
|
||||||
apk add --update --no-cache \
|
bash ca-certificates curl iproute2 iptables ip6tables jq libnatpmp tzdata wireguard-tools \
|
||||||
g++ \
|
&& ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime \
|
||||||
linux-headers \
|
&& echo ${TZ} > /etc/timezone
|
||||||
make \
|
|
||||||
python3 \
|
|
||||||
python3-dev
|
|
||||||
|
|
||||||
RUN \
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
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
|
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"
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|
||||||
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" ]
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2024 Joe Roback <joe.roback@gmail.com>
|
Copyright (c) 2024-2025 Joe Roback <joe.roback@gmail.com>
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
@@ -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
|
Tools
|
||||||
@@ -10,55 +10,11 @@ Tools
|
|||||||
-> [X] Bypass authentication for clients on localhost
|
-> [X] Bypass authentication for clients on localhost
|
||||||
```
|
```
|
||||||
|
|
||||||
Example docker-compose.yml
|
### Recommended Environment Variables
|
||||||
|
|
||||||
```
|
| Variable | Default | Examples | Description |
|
||||||
version: '3'
|
| -------- | ------- | ----- | ---------- |
|
||||||
|
| `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.
|
||||||
services:
|
| `WIREGUARD_INTERFACE` | `wg0` | `wg0`, `wg1`, ... | Set the wireguard interface name to use.
|
||||||
wireguard:
|
| `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.
|
||||||
image: ghcr.io/jordanpotter/docker-wireguard:latest
|
| `WEBUI_HOST` | `http://localhost:8080` | <URL to qBittorrent Web UI> | Url to the qBittorrent Web UI. Authenication must be disabled to localhost connections.
|
||||||
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'
|
|
||||||
```
|
|
||||||
|
|||||||
+17
@@ -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
|
||||||
-146
@@ -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()
|
|
||||||
Executable
+95
@@ -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
|
||||||
Reference in New Issue
Block a user