diff --git a/servers/legion/k8s/media/fornax-coordinator.yaml b/servers/legion/k8s/media/fornax-coordinator.yaml new file mode 100644 index 0000000..b12df99 --- /dev/null +++ b/servers/legion/k8s/media/fornax-coordinator.yaml @@ -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