Add Fornax coordinator — simple round-robin qBittorrent proxy
Python proxy running on python:3-alpine via ConfigMap. Routes /api/v2/torrents/add across all three workers in round-robin; proxies status/monitoring to primary. Manages independent SID sessions to each backend with auto-refresh on 403. Service: fornax.media.svc:8080 — point Sonarr/Radarr here instead of qbittorrent.
This commit is contained in:
@@ -0,0 +1,167 @@
|
|||||||
|
# Fornax coordinator — simple round-robin proxy in front of all qBittorrent workers
|
||||||
|
# Distributes torrent-add requests across workers; proxies monitoring to primary.
|
||||||
|
# Sonarr/Radarr point here instead of directly at qbittorrent.
|
||||||
|
# Replace with the full Fornax coordinator once that's built.
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: fornax-coordinator-script
|
||||||
|
namespace: media
|
||||||
|
data:
|
||||||
|
coordinator.py: |
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Fornax coordinator — round-robin proxy for qBittorrent API.
|
||||||
|
- POST /api/v2/torrents/add → round-robins across all backends
|
||||||
|
- Everything else → proxied to primary backend (qbittorrent)
|
||||||
|
Manages its own SID sessions to each backend so auth is transparent.
|
||||||
|
"""
|
||||||
|
import itertools, os, urllib.request, urllib.parse
|
||||||
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||||
|
|
||||||
|
BACKENDS = [b.strip() for b in os.environ.get("BACKENDS", "").split(",") if b.strip()]
|
||||||
|
USER = os.environ.get("QBT_USER", "admin")
|
||||||
|
PASS = os.environ.get("QBT_PASS", "adminadmin")
|
||||||
|
PRIMARY = BACKENDS[0] if BACKENDS else ""
|
||||||
|
pool = itertools.cycle(BACKENDS)
|
||||||
|
sids = {}
|
||||||
|
|
||||||
|
def fetch_sid(backend):
|
||||||
|
data = urllib.parse.urlencode({"username": USER, "password": PASS}).encode()
|
||||||
|
req = urllib.request.Request(f"{backend}/api/v2/auth/login", data=data)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=5) as r:
|
||||||
|
for k, v in r.getheaders():
|
||||||
|
if k.lower() == "set-cookie" and "SID=" in v:
|
||||||
|
return v.split("SID=")[1].split(";")[0]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"auth failed for {backend}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def sid(backend):
|
||||||
|
if not sids.get(backend):
|
||||||
|
sids[backend] = fetch_sid(backend)
|
||||||
|
return sids.get(backend, "")
|
||||||
|
|
||||||
|
def forward(backend, method, path, body, content_type):
|
||||||
|
cookie = f"SID={sid(backend)}"
|
||||||
|
headers = {"Cookie": cookie}
|
||||||
|
if content_type:
|
||||||
|
headers["Content-Type"] = content_type
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{backend}{path}", data=body if method == "POST" else None,
|
||||||
|
headers=headers, method=method,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as r:
|
||||||
|
return r.status, r.read(), list(r.getheaders())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
# SID expired — refresh once and retry
|
||||||
|
if e.code == 403:
|
||||||
|
sids[backend] = fetch_sid(backend)
|
||||||
|
headers["Cookie"] = f"SID={sids[backend]}"
|
||||||
|
req2 = urllib.request.Request(
|
||||||
|
f"{backend}{path}", data=body if method == "POST" else None,
|
||||||
|
headers=headers, method=method,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req2, timeout=30) as r:
|
||||||
|
return r.status, r.read(), list(r.getheaders())
|
||||||
|
except Exception as e2:
|
||||||
|
return 500, str(e2).encode(), []
|
||||||
|
return e.code, e.read(), []
|
||||||
|
except Exception as e:
|
||||||
|
return 500, str(e).encode(), []
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
print(f"{self.command} {self.path} → {args[1] if len(args) > 1 else ''}")
|
||||||
|
|
||||||
|
def _proxy(self, method):
|
||||||
|
length = int(self.headers.get("Content-Length", 0))
|
||||||
|
body = self.rfile.read(length) if length else None
|
||||||
|
ct = self.headers.get("Content-Type", "")
|
||||||
|
|
||||||
|
if method == "POST" and "/api/v2/torrents/add" in self.path:
|
||||||
|
backend = next(pool)
|
||||||
|
print(f" routing add → {backend}")
|
||||||
|
else:
|
||||||
|
backend = PRIMARY
|
||||||
|
|
||||||
|
status, resp_body, resp_headers = forward(backend, method, self.path, body, ct)
|
||||||
|
self.send_response(status)
|
||||||
|
skip = {"transfer-encoding", "connection"}
|
||||||
|
for k, v in resp_headers:
|
||||||
|
if k.lower() not in skip:
|
||||||
|
self.send_header(k, v)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(resp_body)
|
||||||
|
|
||||||
|
def do_GET(self): self._proxy("GET")
|
||||||
|
def do_POST(self): self._proxy("POST")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print(f"Fornax coordinator ready — backends: {BACKENDS}")
|
||||||
|
HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: fornax-coordinator
|
||||||
|
namespace: media
|
||||||
|
labels:
|
||||||
|
app: fornax-coordinator
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: fornax-coordinator
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: fornax-coordinator
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: coordinator
|
||||||
|
image: python:3-alpine
|
||||||
|
command: ["python", "/app/coordinator.py"]
|
||||||
|
env:
|
||||||
|
- name: BACKENDS
|
||||||
|
value: "http://qbittorrent.media.svc:8080,http://fornax-worker-tx253.media.svc:8080,http://fornax-worker-tx34.media.svc:8080"
|
||||||
|
- name: QBT_USER
|
||||||
|
value: "admin"
|
||||||
|
- name: QBT_PASS
|
||||||
|
value: "adminadmin"
|
||||||
|
ports:
|
||||||
|
- containerPort: 8080
|
||||||
|
volumeMounts:
|
||||||
|
- name: script
|
||||||
|
mountPath: /app
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 32Mi
|
||||||
|
cpu: 10m
|
||||||
|
limits:
|
||||||
|
memory: 128Mi
|
||||||
|
cpu: 100m
|
||||||
|
volumes:
|
||||||
|
- name: script
|
||||||
|
configMap:
|
||||||
|
name: fornax-coordinator-script
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: fornax
|
||||||
|
namespace: media
|
||||||
|
labels:
|
||||||
|
app: fornax-coordinator
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: fornax-coordinator
|
||||||
|
ports:
|
||||||
|
- name: webui
|
||||||
|
port: 8080
|
||||||
|
targetPort: 8080
|
||||||
|
type: ClusterIP
|
||||||
Reference in New Issue
Block a user