# Apply: kubectl apply -f manifests/media/qbittorrent.yaml # Delete: kubectl delete -f manifests/media/qbittorrent.yaml # Description: qBittorrent deployment with Ingress at qbittorrent.home.arpa. # # *** VLAN 50 MIGRATION — DO NOT PUSH TO main WITHOUT SEPARATE, EXPLICIT # APPROVAL, EVEN IF OTHER FILES IN THIS SESSION HAVE ALREADY BEEN # PUSHED *** # This Deployment now attaches VLAN 50 (net1, 10.10.50.10) via Multus # instead of running a per-pod Gluetun/OpenVPN sidecar — PIA egress is now # minisforum's job (ansible/roles/pia-gateway), reached through # nik-debian's enp1s0.50 (ansible/roles/vlan50-parent) and the Multus NAD # (manifests/multus/10-nad-vlan50.yaml). Preconditions before this can # ever be pushed live, per plan.md's phasing: # 1. ansible/roles/pia-gateway and ansible/roles/vlan50-parent applied # and independently verified live. # 2. manifests/multus/ synced (00 through 10) and its own health # confirmed — the initContainer below depends on the # vlan50-egress-guard-script ConfigMap that lives in that # Application, not this one; Argo CD does not enforce that # cross-Application ordering automatically. # 3. The Phase 5 canary (manifests/multus/20-canary.yaml) has passed # the full validation/failure-test matrix in plan.md, including # packet-capture proof of the kill switch and restart behavior. # `media` (this file's Application) has selfHeal+automated sync already # configured — pushing this file to main deploys it immediately, with no # separate "sync" gate. The previous Gluetun-based Deployment is kept, # unmodified, at manifests/media/rollback/qbittorrent-gluetun.yaml — that # subdirectory is deliberately NOT synced by this Application (media.yaml # has no directory.recurse: true, so it only ever looks at files directly # in manifests/media/, never in manifests/media/rollback/) — for fast # manual rollback: kubectl apply -f manifests/media/rollback/ # qbittorrent-gluetun.yaml. See that file's own header for the full # rollback procedure. apiVersion: v1 kind: Namespace metadata: name: downloads --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: qbittorrent-config namespace: downloads annotations: helm.sh/resource-policy: keep spec: accessModes: - ReadWriteOnce storageClassName: local-path resources: requests: storage: 1Gi --- apiVersion: apps/v1 kind: Deployment metadata: name: qbittorrent namespace: downloads spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app: qbittorrent template: metadata: labels: app: qbittorrent annotations: k8s.v1.cni.cncf.io/networks: | [{"name": "vlan50", "namespace": "downloads", "interface": "net1", "ips": ["10.10.50.10/24"]}] spec: # Hard-pinned to nik-debian, not just node-role: storage — plan.md # requires an explicit pin, not an incidental one, since node-role: # storage could someday apply to a second node. nodeSelector: node-role: storage kubernetes.io/hostname: nik-debian # No cluster DNS/CoreDNS resolution needed or provided here — this # pod never looks up an in-cluster service by name (torrent # trackers/peers and the web UI are all external or address-based). # dnsPolicy: None + a single nameserver means Technitium sees this # pod's queries with its real VLAN 50 source, routed via net1 per # the egress-guard init container below — a normal ClusterFirst # policy would instead send queries to CoreDNS over eth0, and # Technitium would never see this workload's queries or source at # all. Deliberately one resolver, not several — a second, un-audited # resolver silently taking over on NXDOMAIN is exactly the kind of # policy bypass this design exists to prevent, not something to # leave available "just in case". dnsPolicy: None dnsConfig: nameservers: - "10.10.40.53" initContainers: # NET_ADMIN lives here ONLY — dropped from every app container # below. See manifests/multus/vlan50-egress-guard-script.yaml for # exactly what this does and does not prove. - 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.10" 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 # Idempotently ensures qBittorrent's own config binds it to net1 # /10.10.50.10 ("bind qBittorrent to net1... using a supported # persistent setting" — plan.md). Sets the CURRENT interface-bind # keys for qBittorrent 5.2.0 — [BitTorrent] Session\Interface, # Session\InterfaceName, Session\InterfaceAddress. An earlier # version of this file set the legacy [Preferences] # Connection\Interface* keys instead; confirmed against # qBittorrent's own src/app/upgrade.cpp (fetched and read in # full) that those are the pre-migration names — the migration # table maps {new: "BitTorrent/Session/InterfaceAddress", old: # "Preferences/Connection/InterfaceAddress"} (and likewise for # Interface/InterfaceName), and src/base/bittorrent/ # sessionimpl.cpp confirms BITTORRENT_SESSION_KEY("Interface")/ # ("InterfaceAddress") are what the actual listening/binding # logic reads (getListeningIPs()) — InterfaceName isn't read # there but is set alongside the other two for consistency with # what the GUI's own interface picker would persist. Only these # three keys are touched; every other persisted setting # (categories, ports, credentials, RSS) is left alone. # # Runs as 1000:1000 (matching PUID/PGID below), not root — this # container only edits one file on the config PVC, so there's no # reason for it to run as root and risk leaving root-owned files # behind for the app container (which linuxserver's own # PUID/PGID-based privilege drop wouldn't necessarily fix up for # files it didn't create itself). - name: qbittorrent-vpn-bind-config image: nicolaka/netshoot:v0.11 securityContext: runAsUser: 1000 runAsGroup: 1000 capabilities: drop: ["ALL"] command: - /bin/sh - -c - | set -eu CONF=/config/qBittorrent/qBittorrent.conf mkdir -p "$(dirname "$CONF")" touch "$CONF" grep -q '^\[BitTorrent\]' "$CONF" || printf '\n[BitTorrent]\n' >> "$CONF" set_key() { key="$1"; val="$2" if grep -q "^${key}=" "$CONF"; then sed -i "s|^${key}=.*|${key}=${val}|" "$CONF" else sed -i "/^\[BitTorrent\]/a ${key}=${val}" "$CONF" fi } set_key 'Session\\Interface' 'net1' set_key 'Session\\InterfaceName' 'net1' set_key 'Session\\InterfaceAddress' '10.10.50.10' echo "qBittorrent.conf VPN bind settings ensured" volumeMounts: - name: config mountPath: /config containers: - name: qbittorrent image: lscr.io/linuxserver/qbittorrent:5.2.0 ports: - containerPort: 8080 env: - name: PUID value: "1000" - name: PGID value: "1000" - name: TZ value: "Asia/Tokyo" - name: WEBUI_PORT value: "8080" # No securityContext capability restriction here, deliberately. # linuxserver.io images (this one included) are built around a # PUID/PGID pattern: the container starts as root, s6-overlay's # own init chowns /config to the requested PUID/PGID and only # then drops privileges to run qBittorrent itself as that user # — that chown step needs real root capabilities (at least # CHOWN/DAC_OVERRIDE/SETUID/SETGID) that `drop: ["ALL"]` would # remove, breaking the image's own startup before it ever gets # to serving anything. This container is not where NET_ADMIN or # any other genuinely elevated capability is needed for VLAN 50 # specifically — that stays exclusively in the init container # above — this is just not fighting an image that already # implements its own privilege drop. readinessProbe: httpGet: path: / port: 8080 initialDelaySeconds: 60 periodSeconds: 20 failureThreshold: 5 resources: requests: cpu: 50m memory: 128Mi limits: cpu: 500m memory: 512Mi volumeMounts: - name: config mountPath: /config - name: torrents mountPath: /mnt/storage/torrents - name: ip-reporter image: python:3-alpine ports: - containerPort: 8888 # Unlike the qbittorrent container above, this is a plain # Python base image with no PUID/PGID privilege-drop # machinery to preserve — dropping all capabilities here is # safe and doesn't fight anything the image needs to start. securityContext: capabilities: drop: ["ALL"] resources: requests: cpu: 5m memory: 16Mi limits: cpu: 50m memory: 64Mi command: - python3 - -c - | import http.server, urllib.request, time class Handler(http.server.BaseHTTPRequestHandler): def do_GET(self): ip = 'connecting...' for _ in range(5): try: ip = urllib.request.urlopen('https://ipinfo.io/ip', timeout=5).read().decode().strip() break except Exception: time.sleep(2) body = f''' 🌐 VPN IP: {ip} '''.encode() self.send_response(200) self.send_header('Content-Type', 'text/html; charset=utf-8') self.send_header('Content-Length', len(body)) self.send_header('Content-Security-Policy', 'frame-ancestors *') self.end_headers() self.wfile.write(body) def log_message(self, *a): pass http.server.HTTPServer(('0.0.0.0', 8888), Handler).serve_forever() volumes: - name: config persistentVolumeClaim: claimName: qbittorrent-config - name: torrents hostPath: path: /mnt/storage/torrents 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: qbittorrent namespace: downloads spec: selector: app: qbittorrent ports: - name: web port: 80 targetPort: 8080 - name: ip-reporter port: 8888 targetPort: 8888 --- apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: qbittorrent 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: qbittorrent-tls hosts: - qbittorrent.home.arpa rules: - host: qbittorrent.home.arpa http: paths: - path: / pathType: Prefix backend: service: name: qbittorrent port: number: 80 --- apiVersion: cert-manager.io/v1 kind: Certificate metadata: name: gluetun-tls namespace: downloads spec: secretName: gluetun-tls issuerRef: name: internal-ca-issuer kind: ClusterIssuer dnsNames: - gluetun.home.arpa --- apiVersion: traefik.io/v1alpha1 kind: IngressRoute metadata: name: gluetun-api namespace: downloads spec: entryPoints: - websecure routes: - match: Host(`gluetun.home.arpa`) kind: Rule services: - name: qbittorrent port: 8888 tls: secretName: gluetun-tls