# ServiceDefinition: qbittorrent # Source of truth: spec docs/13-service-catalog.md row 13; docs/03-networking-isolation-and-egress.md; # docs/15-identity-and-access.md; decisions/adr-0015 (VPN egress steering). # internal download client | egress VPN + kill-switch | config on Longhorn | auth forward-auth # (bare web UI, no native OIDC). # # BULK DOWNLOADS TIER — DELIVERED 2026-09-12 (the deferral below is CLOSED; kept for the record). # This entry used to ship config-only, with a comment promising to attach the downloads volume "once # OQ-2 is resolved". OQ-2 was resolved for STATIC pre-existing datasets (schema 1.5.0 / renderer # `class: bulk` + `export` -> a static, pre-bound NFS PV), and plex/sonarr/radarr/nextcloud all took # their media mounts — but qBittorrent's was never added, so the DOWNLOAD CLIENT was the one member # of the chain with no media volume at all. Measured on prod 2026-09-12: # Session\DefaultSavePath=/downloads/ Session\TempPath=/downloads/incomplete/ # Downloads\SavePath=/downloads/ `ls /downloads` -> No such file or directory # i.e. the app was configured to write a path that DID NOT EXIST, in a container with no volume there. # Anything it did manage to create would have been invisible to sonarr/radarr, lost on every pod # recreation, and filling the NODE's ephemeral disk in the meantime. The `downloads` entry under # storage: below closes that. See docs/BULK-NFS-STORAGE.md and the bulk-tier L0 conformance test. # # VPN-class (docs/13 row 13; docs/03 "VPN-class set"): ALL of this pod's public-internet egress # (peer/tracker/DHT traffic) MUST leave via the node PIA tunnel, never the WAN. `egress: vpn` renders # the homelab.keithjackson.dev/egress-class=vpn pod label the shared CiliumEgressGatewayPolicy # (platform/vpn-egress/egress-steering.yaml) selects on to steer + kill-switch the traffic OUTSIDE the # workload (R-NET-05/06/07). No independent egress path; fail-closed if tun0 is down. # # Inbound reachability (<- sonarr, <- radarr) is an EAST-WEST allow rendered on the arr side; being # VPN-class governs egress, not intra-cluster reachability. This service initiates nothing east-west, so # dependencies is empty (reaches only DNS + VPN internet). # Validates against schemas/servicedefinition.schema.json. name: qbittorrent kind: container source: image: lscr.io/linuxserver/qbittorrent:5.1.2 # pinned stable tag (R-UPD-01); update.range governs advancement # LinuxServer images run their service as the user `abc`, whose uid/gid these two set. They are # LOAD-BEARING FOR THE BULK MOUNT, not cosmetic — exactly as on sonarr/radarr. Measured on prod # 2026-09-12 BEFORE this change: `id` inside the container reported the qbittorrent-nox process as # uid=911 gid=911, and /mnt/Storage/plex/downloads is 0755 owned 1000:1000. 0755 grants write to the # OWNER ONLY, so uid 911 would have mounted the export cleanly and then failed every write with # EACCES — the "mounts fine, cannot write" failure the sonarr entry warns about at length. env: - name: PUID value: "1000" # owner uid of /mnt/Storage/plex/downloads (and of every dir under the export) - name: PGID value: "3000" # the export's dominant group, matching sonarr/radarr: files this writes stay # readable to the EXISTING non-k3s consumers of the same dataset, and land # group-owned the same way the arrs' own writes do podSecurityContext: # For the LONGHORN config PVC only, and for the same reason radarr/sonarr set it: fsGroup is a NO-OP # on the NFS volume (the in-tree plugin does no ownership management), but on a Longhorn filesystem # the kubelet applies it, so /config becomes group-writable by the now-uid-1000 process without # depending on the image chowning it. (This image DOES chown: init-qbittorrent-config runs # `lsiown -R abc:abc /config`. Belt and braces — the live /config was 911:911 before this change, # and losing write access to it would lose the WebUI password hash and the session state.) fsGroup: 3000 exposure: internal # LAN-only web UI at qbittorrent.${BASE_DOMAIN} (docs/13 row 13; R-DNS/R-NET) egress: vpn # ALL public egress via node PIA tunnel + kill-switch (docs/03; ADR-0015; R-NET-05/06) storage: - name: config size: 5Gi class: longhorn # settings/categories/session on 3-replica Longhorn; DR-critical (docs/04; R-STATE-02) mountPath: /config # THE APP WRITES /config, NOT the renderer default /data/config. # LSIO images run with `-data=/config` (Jackett/qBittorrent likewise use # /config), so without this the Longhorn PVC mounted at /data/config sat # holding nothing but lost+found while the app wrote its entire # configuration to the container's EPHEMERAL writable layer — destroyed on # every pod recreation: restart, reschedule, image bump. Measured in-cluster # 2026-09-12: /data/config was dev=2080 with [lost+found], /config was # dev=2053 on the node's own disk with [Sentry asp config.xml logs]. # This is the SAME failure catalog/plex.yaml:54 already records fixing. # The live /config contents were copied onto each PVC before this landed, # so API keys and credentials other apps reference were preserved. # THE DOWNLOADS AREA — ONE mount of the SAME export sonarr/radarr/plex already consume # (192.168.2.102:/mnt/Storage/plex), narrowed by subPath to the `downloads` subtree and presented at # /downloads, which is precisely where this app is ALREADY configured to write. # # WHY subPath: downloads AND NOT THE EXPORT ROOT AT /media (the shape sonarr/radarr use): # * LEAST PRIVILEGE, AND IT COSTS NOTHING HERE. A download client has no business writing the # LIBRARY. The arrs need the root mount because THEY hardlink downloads/ -> tv/ or movies/ and # link() is a same-MOUNT operation (EXDEV otherwise) — their smallest common ancestor genuinely # IS the export root. qBittorrent performs no such cross-directory link: it writes into the # downloads tree and nothing else, so narrowing to that subtree removes movies/, tv/ and photos/ # from this pod's mount namespace without removing any operation it actually performs. # * HARDLINKS STILL WORK, because they are not done here. The arrs still see ONE mount containing # both /media/downloads and /media/tv, so their link() stays intra-mount. Proven live 2026-09-12: # a file created from this pod under /downloads was hardlinked from the sonarr pod between # /media/downloads/... and /media/tv/... with st_nlink 1 -> 2 and no EXDEV. # * IT IS ONE MOUNT, so the L0 test `bulk-tier-distinct-from-replicated` assertion (6) — no # workload mounts one export twice — holds. That assertion is about TWO mounts inside ONE pod; # a single subPath mount is not the EXDEV trap, two are. # * The narrowing is a CLIENT/kubelet guarantee, stated honestly: the NFS server still exports the # whole dataset to this node, so the containment is the mount namespace, not the server's ACLs — # the same class of caveat docs/BULK-NFS-STORAGE.md §5 records for plex's readOnly. # # NOT readOnly — this is the one workload whose whole job is WRITING here. # # KNOWN, DELIBERATE SIDE EFFECT ON SHARED STATE: the image's init-qbittorrent-config runs # `lsiown abc:abc /downloads` (non-recursive) whenever /downloads is a mount, so starting this pod # chgrps /mnt/Storage/plex/downloads from 1000:1000 to 1000:3000 on the SERVER. Benign and verified: # the directory is mode 0755, whose group bits (r-x) are identical to its other bits, so no consumer # — k3s or legacy — gains or loses any access. Recorded because it is a write to state shared with # non-cluster consumers, which should never be a surprise. # # PATH AGREEMENT WITH THE ARRS IS STILL *NOT* CLOSED — see DECISIONS-LOG 2026-09-12 "Remote Path # Mappings". This pod calls the finished file /downloads/; sonarr/radarr call the same bytes # /media/downloads/. Same inode, different absolute path, so Completed Download Handling needs # ONE Remote Path Mapping per arr (remote host `qbittorrent.qbittorrent.svc.cluster.local`, remote # path /downloads/, local path /media/downloads/). # # THOSE MAPPINGS ARE NOW CONFIGURED AND VERIFIED ON PROD (2026-09-12), together with the qBittorrent # download client itself — which did not exist in EITHER arr until then (both # `GET /api/v3/downloadclient` returned `[]`, so completed-download handling had never been wired up # at all). `downloadclient/testall` reports isValid=true in both, and Sonarr's queue was observed # reporting outputPath=/downloads/... with the mapping deleted and /media/downloads/... with it # present — same torrent, same client. # # BUT THEY LIVE IN THE ARRS' OWN DATABASES (app state on their config PVCs), NOT IN GIT: a rebuild # from git alone does NOT restore them and no gate here notices their absence. The alternative — # repointing qBittorrent's SavePath at /media/downloads and mounting this subPath there instead — # buys path agreement at the cost of editing THIS app's config PVC, which is also app state; it was # not chosen because it changes a running download client's save path, which the deferral above # already shows is easy to get wrong. It is nonetheless the RECOMMENDED end state (it makes the # mappings unnecessary and puts the mountPath half of the fix in git) and is awaiting an operator # decision, not a technical blocker. # # RELATED OPEN BLOCKER: this app has no durable WebUI credential (no WebUI\Username / # WebUI\Password_PBKDF2 in qBittorrent.conf), so v5 issues a NEW temporary `admin` password on every # start. The arrs' saved download-client password therefore goes stale on every pod restart. See the # same DECISIONS-LOG entry for the two fixes, neither of which was taken unilaterally. - name: downloads class: bulk export: /mnt/Storage/plex # the ZFS dataset Storage/plex, on ${BULK_NFS_SERVER} — the SAME export # plex (ro) and sonarr/radarr (rw) mount; one dataset, one set of inodes subPath: downloads # the handoff subtree ONLY: no movies/, no tv/, no photos/ mountPath: /downloads # matches the app's EXISTING Session\DefaultSavePath / TempPath, so no # change to config-PVC app state is needed to make this take effect size: 9Ti # ADVISORY ONLY on a static NFS PV (the ZFS pool is the real limit): the # dataset's 8.4 T used rounded up, stated the same way plex/sonarr/radarr # state it so `kubectl get pv` reports something true dependencies: [] # default-deny: reaches only DNS; arr reach it north of its own egress (docs/03; R-NET-01/02). # STILL EMPTY WITH THE NFS MOUNT ABOVE, AND THAT IS CORRECT, NOT AN # OVERSIGHT. An NFS PersistentVolume is mounted by the NODE KERNEL in the # HOST netns, not by this pod: there is no pod-side socket to 2049, so no # CiliumNetworkPolicy can permit or deny it and none is needed. Verified by # construction — sonarr and radarr have mounted this same export read-write # for weeks and neither has any CNP naming the NFS server or port 2049 # (grep apps/wave-1/sonarr.yaml: none). The SAME fact is what keeps the VPN # egress gateway off this traffic (its selector is a podSelector), and # 192.168.2.102 is inside the 192.168.0.0/16 excludedCIDR besides — see # platform/vpn-egress/egress-steering.yaml, "BULK NFS". ingressRoutes: - host: qbittorrent.${BASE_DOMAIN} port: 8080 # qBittorrent WebUI; protected at the Gateway by forward-auth (docs/09, docs/15) auth: mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no usable native OIDC (docs/15; R-IDP-02) groups: - media-users # authenticated AND in an authorized group (R-IDP-06) # See catalog/jackett.yaml for the full rationale: --allowed-group is repeatable and OR-ed, so # platform-admins here is a superset, not a widening (that group already carries cluster-admin # via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an # immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12. - platform-admins # operators of these services must not be locked out of them update: channel: stable # R-UPD-01 range: ">=5.0.0 <6.0.0" # stay on the qBittorrent 5.x stable line cadence: "0 4 * * *" autoRollbackOnFailedHealth: true # R-UPD-03 observability: metrics: true slis: - up # download client down -> alert (docs/10; R-OBS-01) backup: policy: daily-offcluster # config (categories/settings) backed up off-cluster (docs/04; R-STATE-03)