# Apply: kubectl apply -f manifests/media/jdownloader.yaml # Delete: kubectl delete -f manifests/media/jdownloader.yaml # Description: JDownloader deployment with Ingress at jdownloader.home.arpa. # # VLAN 50 MIGRATION — live since 2026-08-24, migrated after qBittorrent # was live, validated, and soaked (plan.md's explicit ordering — the two # were deliberately not batched). Same VLAN 50/Multus design as # qbittorrent.yaml — see the root README's "VPN VLAN 50" section for the # full status/runbook. One difference: JDownloader has no # reliably-persistent, file-editable interface-bind setting the way # qBittorrent's qBittorrent.conf does (its own preferences aren't a # simple INI this repo can safely patch), so this migration relies on the # network-namespace egress guard alone for enforcement, exactly as # plan.md anticipated for JDownloader specifically ("application-level # interface binding may be weaker or unavailable... the namespace egress # guard is mandatory") — confirmed live via this workload's own # fail-closed kill-switch test. `media` has selfHeal+automated sync — any # future push to this file deploys immediately. No pre-migration Gluetun # copy is kept on disk; roll back via git history instead — see the root # README's Rollback table for the exact commit. apiVersion: apps/v1 kind: Deployment metadata: name: jdownloader namespace: downloads spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app: jdownloader template: metadata: labels: app: jdownloader annotations: k8s.v1.cni.cncf.io/networks: | [{"name": "vlan50", "namespace": "downloads", "interface": "net1", "ips": ["10.10.50.11/24"]}] spec: # Hard-pinned to nik-debian, not just node-role: storage — see # qbittorrent.yaml for why. nodeSelector: node-role: storage kubernetes.io/hostname: nik-debian # No cluster DNS/CoreDNS resolution needed or provided here — see # qbittorrent.yaml for the full reasoning (identical here: this pod # never looks up an in-cluster service by name). dnsPolicy: None dnsConfig: nameservers: - "10.10.40.53" initContainers: # NET_ADMIN lives here ONLY. No qBittorrent-style config-bind # sibling init container — see this file's header for why # JDownloader relies on the egress guard alone. - name: vlan50-egress-guard image: nicolaka/netshoot:v0.11 command: ["/bin/sh", "/scripts/guard.sh"] env: - name: VLAN50_GATEWAY value: "10.10.50.1" - name: TECHNITIUM_IP value: "10.10.40.53" - name: POD_CIDR value: "10.42.0.0/16" - name: SERVICE_CIDR value: "10.43.0.0/16" - name: NODE_IP value: "10.10.40.20" - name: EXPECTED_VLAN50_IP value: "10.10.50.11" securityContext: capabilities: drop: ["ALL"] # NET_RAW alongside NET_ADMIN: the guard script's # arping-based duplicate-address and gateway-reachability # checks need it — see # vlan50-egress-guard-script.yaml's header comment. add: ["NET_ADMIN", "NET_RAW"] volumeMounts: - name: guard-script mountPath: /scripts containers: - name: jdownloader image: jlesage/jdownloader-2:latest ports: - containerPort: 5800 # No securityContext capability restriction here, deliberately # — jlesage/jdownloader-2 uses the same docker-baseimage-gui # PUID/GID pattern as linuxserver's images (USER_ID/GROUP_ID # below): starts as root, its own init chowns /config to the # requested uid/gid and drops privileges from there. See # qbittorrent.yaml's app container for the fuller version of # this reasoning — same conclusion, same kind of image. env: - name: USER_ID value: "1000" - name: GROUP_ID value: "1000" - name: TZ value: "Asia/Tokyo" readinessProbe: httpGet: path: / port: 5800 initialDelaySeconds: 15 periodSeconds: 15 livenessProbe: httpGet: path: / port: 5800 initialDelaySeconds: 30 periodSeconds: 30 resources: requests: cpu: 200m memory: 512Mi limits: cpu: 1000m memory: 1Gi volumeMounts: - name: config mountPath: /config - name: dl mountPath: /output - name: cnl-bridge image: python:3.12-alpine ports: - containerPort: 9667 # Unlike the jdownloader container above, this is a plain # Python base image with no PUID/GID privilege-drop machinery # to preserve — dropping all capabilities here is safe. securityContext: capabilities: drop: ["ALL"] resources: requests: cpu: 5m memory: 16Mi limits: cpu: 50m memory: 64Mi volumeMounts: - name: config mountPath: /config - name: dl mountPath: /output command: - python3 - -c - | import http.server, urllib.parse, urllib.request, os, time WATCH_DIR = '/config/folderwatch' TORRENT_DIR = '/output' os.makedirs(WATCH_DIR, exist_ok=True) class Handler(http.server.BaseHTTPRequestHandler): def do_POST(self): if self.path != '/add': self.send_response(404); self.end_headers(); return length = int(self.headers.get('Content-Length', 0)) body = self.rfile.read(length).decode() params = urllib.parse.parse_qs(body) urls = params.get('urls', []) if not urls: self.send_response(400); self.end_headers(); return url = urls[0] basename = urllib.parse.unquote(urllib.parse.urlparse(url).path.rsplit('/', 1)[-1]) # .torrent files aren't handled by JDownloader's crawler (no BT # plugin), so fetch the raw file ourselves instead of writing a # crawljob. This container shares the pod's network namespace # (and its vlan50-egress-guard init container's routes/rules), # so the fetch is still VPN-routed same as JDownloader's own # downloads. if basename.lower().endswith('.torrent'): safe_name = basename.replace('/', '_').replace('\\', '_') try: req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) with urllib.request.urlopen(req, timeout=30) as resp: data = resp.read() with open(f'{TORRENT_DIR}/{safe_name}', 'wb') as f: f.write(data) except Exception as e: self.send_response(502); self.end_headers() self.wfile.write(str(e).encode()) return self.send_response(200); self.end_headers() self.wfile.write(b'Ok.') return package_name = basename.split('.', 1)[0] or 'subyshare' fname = f'{WATCH_DIR}/{int(time.time()*1000)}.crawljob' lines = [f'text={url}', 'autoStart=TRUE', 'autoConfirm=TRUE', f'packageName={package_name}'] password = params.get('password', [''])[0] if password: # crawljob format requires literal backslashes doubled escaped_pw = password.replace('\\', '\\\\') lines.append(f'downloadPassword={escaped_pw}') with open(fname, 'w') as f: f.write('\n'.join(lines) + '\n') self.send_response(200); self.end_headers() self.wfile.write(b'Ok.') def log_message(self, *a): pass http.server.HTTPServer(('0.0.0.0', 9667), Handler).serve_forever() volumes: - name: config hostPath: path: /data/jdownloader type: DirectoryOrCreate - name: dl hostPath: path: /mnt/storage/dl type: Directory - name: guard-script configMap: name: vlan50-egress-guard-script defaultMode: 365 # octal 0555, r-xr-xr-x --- apiVersion: v1 kind: Service metadata: name: jdownloader namespace: downloads spec: selector: app: jdownloader ports: - name: web port: 80 targetPort: 5800 - name: cnl-bridge port: 9666 targetPort: 9667 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: jdownloader namespace: downloads annotations: traefik.ingress.kubernetes.io/router.entrypoints: websecure traefik.ingress.kubernetes.io/router.tls: "true" cert-manager.io/cluster-issuer: internal-ca-issuer spec: ingressClassName: traefik tls: - secretName: jdownloader-tls hosts: - jdownloader.home.arpa rules: - host: jdownloader.home.arpa http: paths: - path: / pathType: Prefix backend: service: name: jdownloader port: number: 80 --- apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: jdownloader-cnl-tls namespace: downloads spec: secretName: jdownloader-cnl-tls issuerRef: name: internal-ca-issuer kind: ClusterIssuer dnsNames: - jdownloader.home.arpa --- apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: jdownloader-cnl namespace: downloads spec: entryPoints: - websecure routes: - match: Host(`jdownloader.home.arpa`) && PathPrefix(`/add`) kind: Rule services: - name: jdownloader port: 9666 tls: secretName: jdownloader-cnl-tls