# Fornax distributed torrent workers — each is a gluetun+qBittorrent pod on a different VPN server # Worker TX#179: US-TX#179, NAT-PMP via gluetun native ProtonVPN port forwarding # Worker TX#220: US-TX#220, NAT-PMP via gluetun native ProtonVPN port forwarding # Both workers share the media-data PVC; each has its own config PVC and VPN credentials # Port file shared via emptyDir: gluetun writes /tmp/gluetun/forwarded_port, helper reads it # ── Worker TX#179 ───────────────────────────────────────────────────────────── apiVersion: apps/v1 kind: Deployment metadata: name: fornax-worker-tx253 namespace: media labels: app: fornax-worker-tx253 fornax-role: worker fornax-server: us-tx-179 spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app: fornax-worker-tx253 template: metadata: labels: app: fornax-worker-tx253 fornax-role: worker fornax-server: us-tx-179 spec: initContainers: - name: tun-setup image: busybox:latest command: ["sh", "-c", "mkdir -p /dev/net && [ -c /dev/net/tun ] || mknod /dev/net/tun c 10 200 && chmod 666 /dev/net/tun && sysctl -w net.ipv6.conf.all.disable_ipv6=1 || true && ip route flush table 51820 2>/dev/null || true && ip rule del priority 101 2>/dev/null || true && ip rule del table 51820 2>/dev/null || true"] securityContext: privileged: true - name: qbt-config-patch image: python:3.13-alpine command: - python3 - -c - | import os, re CONF = '/config/qBittorrent/qBittorrent.conf' os.makedirs('/config/qBittorrent', exist_ok=True) text = open(CONF).read() if os.path.exists(CONF) else '' def set_key(text, section, key, value): """Set key=value under [section], inserting if missing.""" key_pat = re.compile(r'^' + re.escape(key) + r'=.*', re.M) if key_pat.search(text): return key_pat.sub(lambda m: key + '=' + value, text) sec_pat = re.compile(r'^\[' + re.escape(section) + r'\]', re.M) if sec_pat.search(text): return sec_pat.sub(lambda m: '[' + section + ']\n' + key + '=' + value, text) return text + f'\n[{section}]\n{key}={value}\n' HASH = '@ByteArray(HqYj1eGsdXlQ4CSy597Y9A==:J9hsJIlU5FYfHb5rY5qQoIpVpTijryS/H+CE07oMtplL/ytneBVFd2tfVJtqGjhdht8tEi4wmqSSlqTgEu444w==)' # Preferences text = set_key(text, 'Preferences', r'WebUI\Password_PBKDF2', f'"{HASH}"') text = set_key(text, 'Preferences', r'WebUI\LocalhostAuthEnabled', 'false') # BitTorrent performance defaults (only applied when key absent — API updates persist) bt_defaults = { r'Session\MaxActiveDownloads': '50', r'Session\MaxActiveTorrents': '100', r'Session\MaxActiveUploads': '20', r'Session\MaxConnections': '3000', r'Session\MaxConnectionsPerTorrent': '300', r'Session\MaxUploads': '-1', r'Session\MaxUploadsPerTorrent': '10', r'Session\GlobalDLSpeedLimit': '0', r'Session\GlobalUPSpeedLimit': '0', r'Session\DHTEnabled': 'true', } for key, val in bt_defaults.items(): text = set_key(text, 'BitTorrent', key, val) open(CONF, 'w').write(text) print('qBittorrent config patched.') volumeMounts: - name: config mountPath: /config containers: - name: gluetun image: ghcr.io/qdm12/gluetun:latest securityContext: capabilities: add: ["NET_ADMIN"] env: - name: VPN_SERVICE_PROVIDER value: "protonvpn" - name: VPN_TYPE value: "wireguard" - name: WIREGUARD_PRIVATE_KEY valueFrom: secretKeyRef: name: fornax-worker-tx253-secrets key: PROTONVPN_PRIVATE_KEY - name: SERVER_NAMES value: "US-TX#179" - name: VPN_PORT_FORWARDING value: "off" - name: DOT value: "off" - name: DNS_UPSTREAM_RESOLVER_TYPE value: "plain" - name: DNS_UPSTREAM_PLAIN_ADDRESSES value: "10.43.0.10:53" - name: FIREWALL_OUTBOUND_SUBNETS value: "10.42.0.0/16,10.43.0.0/16" volumeMounts: - name: gluetun-data mountPath: /tmp/gluetun ports: - containerPort: 8888 resources: requests: memory: 128Mi cpu: 50m limits: memory: 512Mi cpu: 200m # Port forwarding helper — aggressively fixes gluetun routing rule, then watches for VPN port - name: portforward-helper image: alpine:latest securityContext: capabilities: add: ["NET_ADMIN"] command: - sh - -c - | # gluetun adds 'ip rule priority 100 from lookup 200' which routes ALL pod # traffic through eth0. iptables OUTPUT DROP then blocks non-VPN traffic, including # gluetun's own health check. Remove rule 100 aggressively from startup — BEFORE # gluetun can complete its first health check — so tun0 routing (rule 101) takes over. echo "Starting routing fix loop..." (while true; do ip rule del priority 100 2>/dev/null; sleep 1; done) & echo "Waiting for gluetun health endpoint..." until wget -q -T 3 -O- http://127.0.0.1:9999 > /dev/null 2>&1; do sleep 3; done echo "gluetun ready — VPN is up" # Install curl and python3 for NAT-PMP port forwarding until apk add -q curl python3 2>/dev/null; do sleep 5; done # Request port forwarding via ProtonVPN NAT-PMP (gateway 10.2.0.1, UDP 5351) # ProtonVPN supports NAT-PMP for port forwarding on p2p-enabled servers get_natpmp_port() { python3 - <<'PYEOF' import socket, struct, time gateway = "10.2.0.1" port = 5351 # NAT-PMP external address request (version=0, opcode=0) s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(5) try: # Request TCP port mapping (version=0, opcode=2, reserved=0, internal_port=0, external_port=0, lifetime=7776000) req = struct.pack("!BBHHHI", 0, 2, 0, 0, 0, 7776000) s.sendto(req, (gateway, port)) data, _ = s.recvfrom(256) if len(data) >= 16: version, opcode, result, epoch, int_port, ext_port, lifetime = struct.unpack("!BBHIHHH", data[:16]) if result == 0: print(ext_port) else: print(0) else: print(0) except Exception as e: print(0) finally: s.close() PYEOF } echo "Requesting port via NAT-PMP..." PORT=$(get_natpmp_port) if [ -n "$PORT" ] && [ "$PORT" != "0" ]; then echo "$(date): NAT-PMP assigned port: $PORT" echo "$PORT" > /tmp/gluetun/forwarded_port else echo "$(date): NAT-PMP failed, will retry..." fi # Loop: refresh port every 4 minutes (lifetime 7776000s, renewal every 4min for safety) # Also sync to qBittorrent whenever port is known LAST_PORT="" while true; do PORT=$(get_natpmp_port) if [ -n "$PORT" ] && [ "$PORT" != "0" ] && [ "$PORT" != "$LAST_PORT" ]; then echo "$(date): Port: $PORT — syncing to qBittorrent" echo "$PORT" > /tmp/gluetun/forwarded_port curl -s -c /tmp/qbt.txt -X POST http://localhost:8080/api/v2/auth/login \ -d "username=admin&password=adminadmin" >/dev/null 2>&1 curl -s -b /tmp/qbt.txt -X POST http://localhost:8080/api/v2/app/setPreferences \ -d "json={\"listen_port\":$PORT,\"random_port\":false}" >/dev/null 2>&1 echo "$(date): qBittorrent listen port set to $PORT" if [ -n "$COORDINATOR_URL" ] && [ -n "$WORKER_ID" ]; then curl -s -X POST "$COORDINATOR_URL/api/v2/workers/$WORKER_ID/port-lease" \ -H "Content-Type: application/json" \ -d "{\"port\": $PORT, \"ttlSeconds\": 300}" >/dev/null 2>&1 || true fi LAST_PORT="$PORT" fi sleep 240 done env: - name: COORDINATOR_URL value: "https://fornax.neuralplatform.ai" - name: WORKER_ID value: "tx253" volumeMounts: - name: gluetun-data mountPath: /tmp/gluetun resources: requests: memory: 32Mi cpu: 10m limits: memory: 96Mi cpu: 50m - name: qbittorrent image: lscr.io/linuxserver/qbittorrent:latest env: - name: PUID value: "1000" - name: PGID value: "1000" - name: TZ value: "America/Chicago" - name: WEBUI_PORT value: "8080" ports: - containerPort: 8080 volumeMounts: - name: config mountPath: /config - name: media mountPath: /media resources: requests: memory: 256Mi cpu: 100m limits: memory: 1Gi cpu: 500m volumes: - name: gluetun-data emptyDir: {} - name: config persistentVolumeClaim: claimName: fornax-worker-tx253-config - name: media persistentVolumeClaim: claimName: media-data --- apiVersion: v1 kind: Service metadata: name: fornax-worker-tx253 namespace: media labels: app: fornax-worker-tx253 fornax-role: worker spec: selector: app: fornax-worker-tx253 ports: - name: webui port: 8080 targetPort: 8080 type: ClusterIP --- # ── Worker TX#220 ───────────────────────────────────────────────────────────── apiVersion: apps/v1 kind: Deployment metadata: name: fornax-worker-tx34 namespace: media labels: app: fornax-worker-tx34 fornax-role: worker fornax-server: us-tx-220 spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app: fornax-worker-tx34 template: metadata: labels: app: fornax-worker-tx34 fornax-role: worker fornax-server: us-tx-220 spec: initContainers: - name: tun-setup image: busybox:latest command: ["sh", "-c", "mkdir -p /dev/net && [ -c /dev/net/tun ] || mknod /dev/net/tun c 10 200 && chmod 666 /dev/net/tun && sysctl -w net.ipv6.conf.all.disable_ipv6=1 || true && ip route flush table 51820 2>/dev/null || true && ip rule del priority 101 2>/dev/null || true && ip rule del table 51820 2>/dev/null || true"] securityContext: privileged: true - name: qbt-config-patch image: python:3.13-alpine command: - python3 - -c - | import os, re CONF = '/config/qBittorrent/qBittorrent.conf' os.makedirs('/config/qBittorrent', exist_ok=True) text = open(CONF).read() if os.path.exists(CONF) else '' def set_key(text, section, key, value): """Set key=value under [section], inserting if missing.""" key_pat = re.compile(r'^' + re.escape(key) + r'=.*', re.M) if key_pat.search(text): return key_pat.sub(lambda m: key + '=' + value, text) sec_pat = re.compile(r'^\[' + re.escape(section) + r'\]', re.M) if sec_pat.search(text): return sec_pat.sub(lambda m: '[' + section + ']\n' + key + '=' + value, text) return text + f'\n[{section}]\n{key}={value}\n' HASH = '@ByteArray(HqYj1eGsdXlQ4CSy597Y9A==:J9hsJIlU5FYfHb5rY5qQoIpVpTijryS/H+CE07oMtplL/ytneBVFd2tfVJtqGjhdht8tEi4wmqSSlqTgEu444w==)' # Preferences text = set_key(text, 'Preferences', r'WebUI\Password_PBKDF2', f'"{HASH}"') text = set_key(text, 'Preferences', r'WebUI\LocalhostAuthEnabled', 'false') # BitTorrent performance defaults (only applied when key absent — API updates persist) bt_defaults = { r'Session\MaxActiveDownloads': '50', r'Session\MaxActiveTorrents': '100', r'Session\MaxActiveUploads': '20', r'Session\MaxConnections': '3000', r'Session\MaxConnectionsPerTorrent': '300', r'Session\MaxUploads': '-1', r'Session\MaxUploadsPerTorrent': '10', r'Session\GlobalDLSpeedLimit': '0', r'Session\GlobalUPSpeedLimit': '0', r'Session\DHTEnabled': 'true', } for key, val in bt_defaults.items(): text = set_key(text, 'BitTorrent', key, val) open(CONF, 'w').write(text) print('qBittorrent config patched.') volumeMounts: - name: config mountPath: /config containers: - name: gluetun image: ghcr.io/qdm12/gluetun:latest securityContext: capabilities: add: ["NET_ADMIN"] env: - name: VPN_SERVICE_PROVIDER value: "protonvpn" - name: VPN_TYPE value: "wireguard" - name: WIREGUARD_PRIVATE_KEY valueFrom: secretKeyRef: name: fornax-worker-tx34-secrets key: PROTONVPN_PRIVATE_KEY - name: SERVER_NAMES value: "US-TX#220" - name: VPN_PORT_FORWARDING value: "off" - name: DOT value: "off" - name: DNS_UPSTREAM_RESOLVER_TYPE value: "plain" - name: DNS_UPSTREAM_PLAIN_ADDRESSES value: "10.43.0.10:53" - name: FIREWALL_OUTBOUND_SUBNETS value: "10.42.0.0/16,10.43.0.0/16" volumeMounts: - name: gluetun-data mountPath: /tmp/gluetun ports: - containerPort: 8888 resources: requests: memory: 128Mi cpu: 50m limits: memory: 512Mi cpu: 200m - name: portforward-helper image: alpine:latest securityContext: capabilities: add: ["NET_ADMIN"] command: - sh - -c - | echo "Starting routing fix loop..." (while true; do ip rule del priority 100 2>/dev/null; sleep 1; done) & echo "Waiting for gluetun health endpoint..." until wget -q -T 3 -O- http://127.0.0.1:9999 > /dev/null 2>&1; do sleep 3; done echo "gluetun ready — VPN is up" until apk add -q curl python3 2>/dev/null; do sleep 5; done get_natpmp_port() { python3 - <<'PYEOF' import socket, struct gateway = "10.2.0.1" port = 5351 s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) s.settimeout(5) try: req = struct.pack("!BBHHHI", 0, 2, 0, 0, 0, 7776000) s.sendto(req, (gateway, port)) data, _ = s.recvfrom(256) if len(data) >= 16: version, opcode, result, epoch, int_port, ext_port, lifetime = struct.unpack("!BBHIHHH", data[:16]) print(ext_port if result == 0 else 0) else: print(0) except Exception: print(0) finally: s.close() PYEOF } echo "Requesting port via NAT-PMP..." PORT=$(get_natpmp_port) [ -n "$PORT" ] && [ "$PORT" != "0" ] && echo "$(date): NAT-PMP port: $PORT" && echo "$PORT" > /tmp/gluetun/forwarded_port LAST_PORT="" while true; do PORT=$(get_natpmp_port) if [ -n "$PORT" ] && [ "$PORT" != "0" ] && [ "$PORT" != "$LAST_PORT" ]; then echo "$(date): Port: $PORT — syncing to qBittorrent" echo "$PORT" > /tmp/gluetun/forwarded_port curl -s -c /tmp/qbt.txt -X POST http://localhost:8080/api/v2/auth/login \ -d "username=admin&password=adminadmin" >/dev/null 2>&1 curl -s -b /tmp/qbt.txt -X POST http://localhost:8080/api/v2/app/setPreferences \ -d "json={\"listen_port\":$PORT,\"random_port\":false}" >/dev/null 2>&1 echo "$(date): qBittorrent listen port set to $PORT" if [ -n "$COORDINATOR_URL" ] && [ -n "$WORKER_ID" ]; then curl -s -X POST "$COORDINATOR_URL/api/v2/workers/$WORKER_ID/port-lease" \ -H "Content-Type: application/json" \ -d "{\"port\": $PORT, \"ttlSeconds\": 300}" >/dev/null 2>&1 || true fi LAST_PORT="$PORT" fi sleep 240 done env: - name: COORDINATOR_URL value: "https://fornax.neuralplatform.ai" - name: WORKER_ID value: "tx34" volumeMounts: - name: gluetun-data mountPath: /tmp/gluetun resources: requests: memory: 32Mi cpu: 10m limits: memory: 96Mi cpu: 50m - name: qbittorrent image: lscr.io/linuxserver/qbittorrent:latest env: - name: PUID value: "1000" - name: PGID value: "1000" - name: TZ value: "America/Chicago" - name: WEBUI_PORT value: "8080" ports: - containerPort: 8080 volumeMounts: - name: config mountPath: /config - name: media mountPath: /media resources: requests: memory: 256Mi cpu: 100m limits: memory: 1Gi cpu: 500m volumes: - name: gluetun-data emptyDir: {} - name: config persistentVolumeClaim: claimName: fornax-worker-tx34-config - name: media persistentVolumeClaim: claimName: media-data --- apiVersion: v1 kind: Service metadata: name: fornax-worker-tx34 namespace: media labels: app: fornax-worker-tx34 fornax-role: worker spec: selector: app: fornax-worker-tx34 ports: - name: webui port: 8080 targetPort: 8080 type: ClusterIP