diff --git a/manifests/media/qbittorrent.yaml b/manifests/media/qbittorrent.yaml index 531e4ea..6e8bbd3 100644 --- a/manifests/media/qbittorrent.yaml +++ b/manifests/media/qbittorrent.yaml @@ -1,6 +1,36 @@ # 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: @@ -37,61 +67,121 @@ spec: 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 - containers: - - name: gluetun - image: qmcgaw/gluetun:v3.41 + 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: - add: - - NET_ADMIN - env: - - name: VPN_SERVICE_PROVIDER - value: private internet access - - name: VPN_TYPE - value: openvpn - - name: SERVER_REGIONS - value: JP Tokyo,Taiwan,Hong Kong - - name: OPENVPN_USER - valueFrom: - secretKeyRef: - name: pia-credentials - key: OPENVPN_USER - - name: OPENVPN_PASSWORD - valueFrom: - secretKeyRef: - name: pia-credentials - key: OPENVPN_PASSWORD - - name: FIREWALL_OUTBOUND_SUBNETS - value: "10.42.0.0/16,10.43.0.0/16,10.10.40.0/24" - - name: BLOCK_IPV6 - value: "on" - startupProbe: - exec: - command: - - /gluetun-entrypoint - - healthcheck - periodSeconds: 10 - failureThreshold: 60 - livenessProbe: - exec: - command: - - /gluetun-entrypoint - - healthcheck - periodSeconds: 30 - failureThreshold: 3 - readinessProbe: - exec: - command: - - /gluetun-entrypoint - - healthcheck - periodSeconds: 10 - failureThreshold: 3 + 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: tun - mountPath: /dev/net/tun + - 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: @@ -105,6 +195,19 @@ spec: 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: / @@ -128,6 +231,13 @@ spec: 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 @@ -167,10 +277,6 @@ spec: http.server.HTTPServer(('0.0.0.0', 8888), Handler).serve_forever() volumes: - - name: tun - hostPath: - path: /dev/net/tun - type: CharDevice - name: config persistentVolumeClaim: claimName: qbittorrent-config @@ -178,6 +284,10 @@ spec: 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