initial commit
This commit is contained in:
@@ -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 }}
|
||||||
+32
@@ -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" ]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Joe Roback <joe.roback@gmail.com>
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -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'
|
||||||
|
```
|
||||||
Executable
+146
@@ -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()
|
||||||
Reference in New Issue
Block a user