homelab/manifests/media/qbittorrent.yaml
Nik Afiq 9b799c5ea2
Some checks failed
validate / lint (push) Failing after 1s
docs: drop stale rollback-file references now that rollback/ is removed
qbittorrent.yaml and jdownloader.yaml's own header comments still
pointed at manifests/media/rollback/*-gluetun.yaml and described the
migration as pending approval/preconditions - both are live and
validated now, and that directory is being removed (no kept on-disk
Gluetun copy). Rewrite both headers as a status note instead, pointing
rollback at git history (with the exact pre-migration commit) to match
the root README's rollback table, which gets the same exact-commit
treatment here instead of the vaguer reference from the previous
commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 23:00:40 +09:00

350 lines
13 KiB
YAML

# 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 — live since 2026-08-24, validated end to end
# (Phase 5 canary matrix plus this workload's own fail-closed kill-switch
# test: stopping wg-quick@pia-wg on minisforum stops its egress, no
# fallback to eth0). This Deployment attaches VLAN 50 (net1, 10.10.50.10)
# via Multus instead of running a per-pod Gluetun/OpenVPN sidecar — PIA
# egress is 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); see the root README's "VPN VLAN
# 50" section for the full status/runbook. `media` (this file's
# Application) has selfHeal+automated sync — any future push to this file
# deploys immediately, no separate "sync" gate. No pre-migration Gluetun
# copy is kept on disk; roll back via git history instead (`git show
# <commit-before-migration>:manifests/media/qbittorrent.yaml`, then
# `kubectl apply -f -`) — see the root README's Rollback table for the
# exact commit.
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'''<!DOCTYPE html>
<html>
<body style="margin:0;display:flex;align-items:center;justify-content:center;height:100vh;
background:transparent;font-family:monospace;font-size:1.1rem;color:#ccc;">
<span>🌐 VPN IP: <strong style="color:#7eb8f7">{ip}</strong></span>
</body>
</html>'''.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