--- # Namespace (trust zone; default-deny + policies apply) apiVersion: v1 kind: Namespace metadata: name: qbittorrent labels: homelab.keithjackson.dev/app-namespace: 'true' gateway-access: shared annotations: homelab.keithjackson.dev/backup-policy: daily-offcluster --- # Default-deny baseline (R-NET-01): deny all, allow only DNS apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: default-deny namespace: qbittorrent labels: homelab.keithjackson.dev/baseline: default-deny spec: podSelector: {} policyTypes: - Ingress - Egress ingress: [] egress: - to: - namespaceSelector: matchLabels: kubernetes.io/metadata.name: kube-system ports: - protocol: UDP port: 53 - protocol: TCP port: 53 --- # PersistentVolume qbittorrent-downloads - static external NFS (bulk tier) apiVersion: v1 kind: PersistentVolume metadata: name: qbittorrent-downloads labels: app.kubernetes.io/name: qbittorrent spec: capacity: storage: 9Ti accessModes: - ReadWriteMany persistentVolumeReclaimPolicy: Retain storageClassName: '' mountOptions: - nfsvers=4.1 - hard - noatime - timeo=600 - retrans=2 nfs: server: ${BULK_NFS_SERVER} path: /mnt/Storage/plex claimRef: namespace: qbittorrent name: qbittorrent-downloads --- # PVC qbittorrent-config (R-STATE) apiVersion: v1 kind: PersistentVolumeClaim metadata: name: qbittorrent-config namespace: qbittorrent labels: app.kubernetes.io/name: qbittorrent spec: accessModes: - ReadWriteOnce storageClassName: longhorn resources: requests: storage: 5Gi --- # PVC qbittorrent-downloads (R-STATE) apiVersion: v1 kind: PersistentVolumeClaim metadata: name: qbittorrent-downloads namespace: qbittorrent labels: app.kubernetes.io/name: qbittorrent spec: accessModes: - ReadWriteMany storageClassName: '' volumeName: qbittorrent-downloads resources: requests: storage: 9Ti --- # Deployment (R-PLAT-02) apiVersion: apps/v1 kind: Deployment metadata: name: qbittorrent namespace: qbittorrent labels: app.kubernetes.io/name: qbittorrent spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: qbittorrent template: metadata: labels: app.kubernetes.io/name: qbittorrent homelab.keithjackson.dev/egress-class: vpn homelab.keithjackson.dev/auth-mode: forward-auth annotations: homelab.keithjackson.dev/auth-mode: forward-auth homelab.keithjackson.dev/auth-groups: media-users,platform-admins spec: containers: - name: qbittorrent image: lscr.io/linuxserver/qbittorrent:5.1.2 ports: - containerPort: 8080 env: - name: PUID value: '1000' - name: PGID value: '3000' - name: QBT_WEBUI_USERNAME valueFrom: secretKeyRef: name: qbittorrent-bootstrap key: webui-username - name: QBT_WEBUI_PASSWORD valueFrom: secretKeyRef: name: qbittorrent-bootstrap key: webui-password volumeMounts: - name: config mountPath: /config - name: downloads mountPath: /downloads subPath: downloads lifecycle: postStart: exec: command: - /bin/bash - -c - "# Assert the durable WebUI credential in the config PVC's qBittorrent.conf, then PROVE the\n# running WebUI accepts it. Idempotent: the steady-state path writes nothing at all.\n#\n# THIS SCRIPT CONTAINS NO BRACED DOLLAR EXPANSION AT ALL, AND THAT IS DELIBERATE.\n# apps-wave-0 declares postBuild.substituteFrom cluster-vars, so Flux runs envsubst over\n# this whole manifest at RECONCILE time. The repo's escape contract (double the dollar;\n# enforced by render.py's assert_substitution_safe) makes such a form correct ON THE\n# CLUSTER \u2014 but only there:\n# envsubst collapses the doubled dollar, so the SAME text run any other way (a `kubectl\n# apply` of the golden, which runs no envsubst; a hand `kubectl exec` of the script during\n# development) keeps the doubled dollar, which bash reads as its own PID. Not hypothetical;\n# see the PORT comment below for the bug it actually caused here. Bare `$VAR` and `$(cmd)`\n# are untouched by envsubst, so they are used throughout and the script behaves IDENTICALLY\n# however it is run.\nset -u\nCONF=/config/qBittorrent/qBittorrent.conf\nSVC=/run/service/svc-qbittorrent\n# The WebUI's listening port, as a LITERAL. It matches ingressRoutes[0].port in this\n# ServiceDefinition \u2014 which is the single place the renderer takes the containerPort, the\n# Service port and the HTTPRoute backend port from \u2014 and the image exposes no WEBUI_PORT\n# env var for a lookup to read anyway.\n# It USED to be a doubled-dollar default-operator expansion of a WEBUI_PORT env var, and\n# that cost real time: run without envsubst, bash read the doubled dollar as its own PID,\n# so PORT became \"{WEBUI_PORT:-8080}\" \u2014 the brace never expanded, the digits never\n# matched. Every curl below then got an unparseable URL, returned nothing, and the hook\n# logged \"the WebUI rejected the durable\n# credential\" \u2014 having never actually asked it. That MISDIAGNOSIS is preserved in this\n# service's own marker log at 2026-09-13T05:35Z (three \"rejected\" lines, then GAVE UP,\n# against a WebUI that accepted the credential perfectly well). A literal cannot be mangled.\nPORT=8080\n# A postStart hook's stdout/stderr goes to the KUBELET, not to `kubectl logs` \u2014 so a hook\n# that only echoes is effectively silent (gitea's hits the same wall and writes a marker\n# file for it). Every outcome therefore also lands, timestamped, on the persistent config\n# volume: /config/qbittorrent-webui-cred.log is where an operator looks to see whether the\n# credential from git was asserted and accepted on this start.\nlog() {\n echo \"[qbt-webui-cred] $*\"\n printf '%s [qbt-webui-cred] %s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$*\" \\\n >> /config/qbittorrent-webui-cred.log 2>/dev/null || true\n}\n\n# (1) The image's init-qbittorrent-config copies /defaults/qBittorrent.conf onto the PVC\n# before it starts the service. Wait (bounded, ~30s) so we EDIT that file instead of\n# racing to create one it would then not overwrite.\nn=0\nwhile [ ! -s \"$CONF\" ] && [ \"$n\" -lt 120 ]; do n=$((n + 1)); sleep 0.25; done\n\nwaited=0\nattempt=0\nwhile [ \"$attempt\" -lt 3 ]; do\n attempt=$((attempt + 1))\n\n # (2) Converge the conf. 0 = already correct (steady state: NO write, NO re-salt),\n # 10 = rewritten, anything else = could not write.\n python3 - \"$CONF\" <<'PY'\nimport base64, hashlib, hmac, os, stat, sys\n\nconf = sys.argv[1]\nuser = os.environ.get(\"QBT_WEBUI_USERNAME\", \"\")\npw = os.environ.get(\"QBT_WEBUI_PASSWORD\", \"\")\nif not user or not pw:\n sys.stderr.write(\"[qbt-webui-cred] credential env is empty - refusing to touch the conf\\n\")\n raise SystemExit(1)\n\n# qBittorrent's OWN parameters (base/utils/password.cpp): PBKDF2-HMAC-SHA512, 100000\n# iterations, 16-byte random salt, 64-byte digest, stored as\n# WebUI\\Password_PBKDF2=\"@ByteArray(:)\"\n# Get any one of them wrong and the app simply rejects every login.\nITERATIONS, DIGEST_LEN, SALT_LEN = 100000, 64, 16\nUKEY, PKEY, SECTION = \"WebUI\\\\Username\", \"WebUI\\\\Password_PBKDF2\"\ , \"[Preferences]\"\n\ndef matches(raw):\n \"\"\"True iff the stored digest is one OUR password produces (salt is read from it).\"\"\"\n v = raw.strip().strip('\"')\n if v.startswith(\"@ByteArray(\") and v.endswith(\")\"):\n v = v[len(\"@ByteArray(\"):-1]\n try:\n salt_b64, digest_b64 = v.split(\":\", 1)\n salt = base64.b64decode(salt_b64, validate=True)\n digest = base64.b64decode(digest_b64, validate=True)\n except Exception:\n return False\n if not salt or not digest:\n return False\n return hmac.compare_digest(\n hashlib.pbkdf2_hmac(\"sha512\", pw.encode(), salt, ITERATIONS, len(digest)), digest)\n\ntry:\n lines = open(conf, encoding=\"utf-8\", errors=\"replace\").read().splitlines()\nexcept FileNotFoundError:\n lines = []\n\ndef value_of(key):\n for i, line in enumerate(lines):\n if line.startswith(key + \"=\"):\n return i, line.split(\"=\", 1)[1]\n return -1, None\n\nui, uval = value_of(UKEY)\npi, pval = value_of(PKEY)\nif uval == user and pval is not None and matches(pval):\n raise SystemExit(0) # converged - leave the file (and its salt) alone\n\nsalt = os.urandom(SALT_LEN)\ndigest = hashlib.pbkdf2_hmac(\"sha512\", pw.encode(), salt, ITERATIONS, DIGEST_LEN)\nstored = base64.b64encode(salt).decode() + \":\" + base64.b64encode(digest).decode()\ndesired = [UKEY + \"=\" + user, PKEY + '=\"@ByteArray(' + stored + ')\"']\n\nout = [line for i, line in enumerate(lines) if i not in (ui, pi)]\nif SECTION in out:\n at = out.index(SECTION) + 1\nelse:\n if out and out[-1].strip():\n out.append(\"\")\n out.append(SECTION)\n at = len(out)\nout[at:at] = desired\n\ntry:\n os.makedirs(os.path.dirname(conf) or \".\", exist_ok=True)\n tmp = conf + \".webui-cred.new\"\n with open(tmp, \"w\", encoding=\"utf-8\") as fh:\n fh.write(\"\\n\".join(out) + \"\\n\")\n fh.flush()\n os.fsync(fh.fileno())\n # This hook runs as ROOT; the service runs as PUID:PGID. A root-owned 0600 conf would\n # cost the app the ability to save its own settings, so carry the ownership over.\n try:\n st = os.stat(conf)\n os.chown(tmp, st.st_uid, st.st_gid)\n os.chmod(tmp, stat.S_IMODE(st.st_mode))\n except FileNotFoundError:\n os.chown(tmp, int(os.environ.get(\"PUID\", \"1000\")),\n int(os.environ.get(\"PGID\", \"1000\")))\n os.chmod(tmp, 0o664)\n os.replace(tmp, conf) # atomic: the app never reads a half-written conf\nexcept OSError as exc:\n sys.stderr.write(\"[qbt-webui-cred] cannot write %s: %s\\n\" % (conf, exc))\n raise SystemExit(1)\nraise SystemExit(10)\nPY\n rc=$?\n\n if [ \"$rc\" = 0 ]; then\n log \"conf already holds the durable credential for '$QBT_WEBUI_USERNAME' (no write)\"\n elif [ \"$rc\" = 10 ]; then\n log \"asserted WebUI Username + Password_PBKDF2 into $CONF\"\n # postStart runs CONCURRENTLY with the s6 init chain, so qbittorrent-nox may ALREADY\n # have read the old, credential-less conf and be serving a random temporary password.\n # Restart the SUPERVISED SERVICE (not the pod) so it re-reads what was just written.\n # Nothing to do when the service has not started yet - it will read the new file.\n # Its own counter, NOT the shared `waited` API budget: sharing one counter is how the\n # 2026-09-13 failure spent the entire verification budget before the first login and\n # then fired attempts 2 and 3 with no wait left at all.\n sv=0\n while [ \"$sv\" -lt 60 ] && [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" != \"true\" ]; do\n sv=$((sv + 1)); sleep 1\n done\n if [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" = \"true\" ]; then\n log \"qbittorrent-nox was already running - restarting it to load the credential\"\n s6-svc -r \"$SVC\" || log \"s6-svc -r failed (continuing to verify anyway)\"\n sleep 3\n fi\n else\n log \"FAILED to write the credential into $CONF (helper exit $rc). The WebUI will keep\" >&2\n log \"minting a random password\ \ every start and BOTH arrs will break. Fix and restart.\" >&2\n exit 0\n fi\n\n # (3) GROUND TRUTH: the WebUI itself must accept it. Wait for the API to answer at ALL\n # (shared ~120s budget across attempts), then spend exactly ONE login per attempt -\n # at most 3 rejected logins, which stays under qBittorrent's default ban threshold.\n #\n # \"THE API NEVER ANSWERED\" IS A DIFFERENT FAULT FROM \"THE PASSWORD WAS REJECTED\", and\n # conflating them is what made this hook lie once already (see the PORT comment). An\n # unreachable WebUI is reported as unreachable, and the hook stops rather than spending\n # its remaining login attempts to produce two more identical wrong diagnoses.\n api_ok=0\n while [ \"$waited\" -lt 120 ]; do\n code=$(curl -s -o /dev/null -m 3 -w '%{http_code}' \"http://127.0.0.1:$PORT/\" 2>/dev/null)\n if [ -n \"$code\" ] && [ \"$code\" != \"000\" ]; then api_ok=1; break; fi\n waited=$((waited + 1)); sleep 1\n done\n if [ \"$api_ok\" != 1 ]; then\n log \"the WebUI never answered on 127.0.0.1:$PORT within the wait budget, so the\" >&2\n log \"credential in $CONF is UNVERIFIED. This is NOT a rejected password - it was\" >&2\n log \"never submitted. Do not rotate the credential on the strength of this line.\" >&2\n exit 0\n fi\n body=$(curl -sS -m 10 \\\n --data-urlencode \"username=$QBT_WEBUI_USERNAME\" \\\n --data-urlencode \"password=$QBT_WEBUI_PASSWORD\" \\\n \"http://127.0.0.1:$PORT/api/v2/auth/login\" 2>/dev/null)\n if [ \"$body\" = \"Ok.\" ]; then\n log \"VERIFIED: the WebUI accepts the durable credential for '$QBT_WEBUI_USERNAME'\"\n exit 0\n fi\n log \"WebUI rejected the durable credential (login said '$body')\" >&2\n # A rejection when the conf was ALREADY converged (rc=0, nothing written) can only mean\n # the running qbittorrent-nox is serving something it read from an EARLIER conf, so\n # rewriting the same bytes on the next attempt would change nothing. The remedy is to\n # make the process RE-READ the file, so restart the supervised service and let the next\n # attempt re-verify. Without this the retry loop could never change its own outcome.\n if [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" = \"true\" ]; then\n log \"restarting qbittorrent-nox so it re-reads $CONF, then re-verifying\" >&2\n s6-svc -r \"$SVC\" || log \"s6-svc -r failed (continuing to verify anyway)\" >&2\n sleep 3\n fi\ndone\n\nlog \"GAVE UP after $attempt attempts: the WebUI does not accept the credential from git.\" >&2\n# A postStart hook MUST exit 0 - a non-zero exit makes the kubelet KILL the container\n# (FailedPostStartHook -> CrashLoopBackOff), turning a credential fault into an outage.\n# Both failure paths above therefore log LOUDLY and exit 0; the `up` SLI still watches the\n# app, and the arrs' own download-client health check surfaces a broken credential.\nexit 0\n" - name: oauth2-proxy image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 imagePullPolicy: IfNotPresent args: - --http-address=0.0.0.0:4180 - --reverse-proxy=true - --provider=oidc - --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab - --upstream=http://127.0.0.1:8080 - --email-domain=* - --scope=openid email profile - --oidc-groups-claim=groups - --allowed-group=media-users - --allowed-group=platform-admins - --skip-provider-button=true - --redirect-url=https://qbittorrent.${BASE_DOMAIN}/oauth2/callback - --cookie-domain=.${BASE_DOMAIN} - --whitelist-domain=.${BASE_DOMAIN} - --cookie-secure=true - --pass-access-token=true - --set-xauthrequest=true env: - name: OAUTH2_PROXY_CLIENT_ID valueFrom: secretKeyRef: name: qbittorrent-oauth2-proxy key: client-id - name: OAUTH2_PROXY_CLIENT_SECRET valueFrom: secretKeyRef: name: qbittorrent-oauth2-proxy key: client-secret - name: OAUTH2_PROXY_COOKIE_SECRET valueFrom: secretKeyRef: name: qbittorrent-oauth2-proxy key: cookie-secret ports: - name: http containerPort: 4180 protocol: TCP resources: requests: cpu: 25m memory: 64Mi limits: memory: 128Mi securityContext: allowPrivilegeEscalation: false readOnlyRootFilesystem: true capabilities: drop: - ALL livenessProbe: httpGet: path: /ping port: http initialDelaySeconds: 5 periodSeconds: 15 readinessProbe: httpGet: path: /ping port: http initialDelaySeconds: 5 periodSeconds: 15 securityContext: fsGroup: 3000 volumes: - name: config persistentVolumeClaim: claimName: qbittorrent-config - name: downloads persistentVolumeClaim: claimName: qbittorrent-downloads --- # Service apiVersion: v1 kind: Service metadata: name: qbittorrent namespace: qbittorrent labels: app.kubernetes.io/name: qbittorrent spec: selector: app.kubernetes.io/name: qbittorrent ports: - name: port-8080 port: 8080 targetPort: 8080 - name: authproxy port: 4180 targetPort: 4180 --- # CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): qbittorrent-allow-radarr-8080 apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: name: qbittorrent-allow-radarr-8080 namespace: qbittorrent labels: app.kubernetes.io/name: qbittorrent spec: endpointSelector: matchLabels: app.kubernetes.io/name: qbittorrent ingress: - fromEndpoints: - matchLabels: app.kubernetes.io/name: radarr k8s:io.kubernetes.pod.namespace: radarr toPorts: - ports: - port: '8080' protocol: TCP --- # CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): qbittorrent-allow-sonarr-8080 apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: name: qbittorrent-allow-sonarr-8080 namespace: qbittorrent labels: app.kubernetes.io/name: qbittorrent spec: endpointSelector: matchLabels: app.kubernetes.io/name: qbittorrent ingress: - fromEndpoints: - matchLabels: app.kubernetes.io/name: sonarr k8s:io.kubernetes.pod.namespace: sonarr toPorts: - ports: - port: '8080' protocol: TCP --- # CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): qbittorrent-allow-gateway-ingress apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: name: qbittorrent-allow-gateway-ingress namespace: qbittorrent labels: app.kubernetes.io/name: qbittorrent spec: endpointSelector: matchLabels: app.kubernetes.io/name: qbittorrent ingress: - fromEntities: - ingress toPorts: - ports: - port: '4180' protocol: TCP --- # CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): qbittorrent-allow-internet-egress apiVersion: cilium.io/v2 kind: CiliumNetworkPolicy metadata: name: qbittorrent-allow-internet-egress namespace: qbittorrent labels: app.kubernetes.io/name: qbittorrent spec: endpointSelector: matchLabels: app.kubernetes.io/name: qbittorrent egress: - toEntities: - world - toEndpoints: - matchLabels: io.kubernetes.pod.namespace: keycloak app: keycloak --- # HTTPRoute qbittorrent-8080 (R-TLS/R-DNS) apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: name: qbittorrent-8080 namespace: qbittorrent labels: app.kubernetes.io/name: qbittorrent annotations: homelab.keithjackson.dev/auth-mode: forward-auth homelab.keithjackson.dev/auth-groups: media-users,platform-admins external-dns.alpha.kubernetes.io/controller: none spec: parentRefs: - name: shared namespace: gateway sectionName: https hostnames: - qbittorrent.${BASE_DOMAIN} rules: - matches: - path: type: PathPrefix value: / backendRefs: - name: qbittorrent port: 4180 --- # PrometheusRule SLI alerts (R-OBS-01) apiVersion: monitoring.coreos.com/v1 kind: PrometheusRule metadata: name: qbittorrent-slis namespace: qbittorrent labels: release: kube-prometheus-stack app.kubernetes.io/name: qbittorrent spec: groups: - name: qbittorrent.slis rules: - alert: qbittorrent-down expr: kube_deployment_status_replicas_available{namespace="qbittorrent", deployment="qbittorrent"} == 0 or absent(kube_deployment_status_replicas_available{namespace="qbittorrent", deployment="qbittorrent"}) for: 5m labels: severity: critical service: qbittorrent annotations: summary: qbittorrent is down description: 'qbittorrent: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available).'