From 8392b52ee8c520a548942972078cb2744bf8dc61 Mon Sep 17 00:00:00 2001 From: Keith Jackson Date: Tue, 25 Aug 2026 08:40:48 -0500 Subject: [PATCH] Onboard VPN downloaders (qBittorrent + Jackett) via ServiceDefinition renderer Add catalog/ ServiceDefinitions for qbittorrent and jackett and their rendered apps/ manifests, wired into apps/kustomization.yaml. Both are VPN-class (egress: vpn -> egress-class=vpn pod label steered by the CiliumEgressGatewayPolicy + host kill-switch, ADR-0015) and internal forward-auth UIs (oauth2-proxy -> Keycloak, group media-users). Config on 3-replica Longhorn. qBittorrent's bulk downloads volume is DEFERRED pending OQ-2 (non-provisioning bulk StorageClass stub), mirroring plex/arr. Traces: R-NET-05/06 (VPN egress + kill-switch), R-IDP-02/06 (forward-auth), R-STATE-02/03, R-OBS-01, R-UPD-01/03, R-GITOPS-02. Co-Authored-By: Claude Opus 4.8 --- apps/jackett.yaml | 194 +++++++++++++++++++++++++++++++++++++++ apps/kustomization.yaml | 2 + apps/qbittorrent.yaml | 194 +++++++++++++++++++++++++++++++++++++++ catalog/jackett.yaml | 44 +++++++++ catalog/qbittorrent.yaml | 59 ++++++++++++ 5 files changed, 493 insertions(+) create mode 100644 apps/jackett.yaml create mode 100644 apps/qbittorrent.yaml create mode 100644 catalog/jackett.yaml create mode 100644 catalog/qbittorrent.yaml diff --git a/apps/jackett.yaml b/apps/jackett.yaml new file mode 100644 index 0000000..8fa1e2c --- /dev/null +++ b/apps/jackett.yaml @@ -0,0 +1,194 @@ +--- +# Namespace (trust zone; default-deny + policies apply) +apiVersion: v1 +kind: Namespace +metadata: + name: jackett + 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: jackett + 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 +--- +# PVC jackett-config (R-STATE) +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: jackett-config + namespace: jackett + labels: + app.kubernetes.io/name: jackett +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn + resources: + requests: + storage: 2Gi +--- +# Deployment (R-PLAT-02) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: jackett + namespace: jackett + labels: + app.kubernetes.io/name: jackett +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: jackett + template: + metadata: + labels: + app.kubernetes.io/name: jackett + 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 + spec: + containers: + - name: jackett + image: lscr.io/linuxserver/jackett:0.22.1327 + ports: + - containerPort: 9117 + volumeMounts: + - name: config + mountPath: /data/config + volumes: + - name: config + persistentVolumeClaim: + claimName: jackett-config +--- +# Service +apiVersion: v1 +kind: Service +metadata: + name: jackett + namespace: jackett + labels: + app.kubernetes.io/name: jackett +spec: + selector: + app.kubernetes.io/name: jackett + ports: + - name: port-9117 + port: 9117 + targetPort: 9117 +--- +# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): jackett-allow-gateway-ingress +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: jackett-allow-gateway-ingress + namespace: jackett + labels: + app.kubernetes.io/name: jackett +spec: + endpointSelector: + matchLabels: + app.kubernetes.io/name: jackett + ingress: + - fromEntities: + - ingress + toPorts: + - ports: + - port: '9117' + protocol: TCP +--- +# HTTPRoute jackett-9117 (R-TLS/R-DNS) +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: jackett-9117 + namespace: jackett + labels: + app.kubernetes.io/name: jackett + annotations: + homelab.keithjackson.dev/auth-mode: forward-auth + homelab.keithjackson.dev/auth-groups: media-users +spec: + parentRefs: + - name: shared + namespace: gateway + sectionName: https + hostnames: + - jackett.home.keithjackson.dev + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: jackett + port: 9117 +--- +# ServiceMonitor (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: jackett + namespace: jackett + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: jackett +spec: + selector: + matchLabels: + app.kubernetes.io/name: jackett + namespaceSelector: + matchNames: + - jackett + endpoints: + - port: metrics + interval: 30s +--- +# PrometheusRule SLI alerts (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: jackett-slis + namespace: jackett + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: jackett +spec: + groups: + - name: jackett.slis + rules: + - alert: jackett-down + expr: up{job="jackett"} == 0 + for: 5m + labels: + severity: critical + service: jackett + annotations: + summary: jackett is down + description: 'jackett has been unreachable for 5m (SLI: up).' diff --git a/apps/kustomization.yaml b/apps/kustomization.yaml index f0eeb12..81b6a0f 100644 --- a/apps/kustomization.yaml +++ b/apps/kustomization.yaml @@ -9,3 +9,5 @@ resources: - pihole.yaml - gitea.yaml - plex.yaml + - qbittorrent.yaml + - jackett.yaml diff --git a/apps/qbittorrent.yaml b/apps/qbittorrent.yaml new file mode 100644 index 0000000..a2b4c6e --- /dev/null +++ b/apps/qbittorrent.yaml @@ -0,0 +1,194 @@ +--- +# 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 +--- +# 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 +--- +# Deployment (R-PLAT-02) +apiVersion: apps/v1 +kind: Deployment +metadata: + name: qbittorrent + namespace: qbittorrent + labels: + app.kubernetes.io/name: qbittorrent +spec: + replicas: 1 + 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 + spec: + containers: + - name: qbittorrent + image: lscr.io/linuxserver/qbittorrent:5.1.2 + ports: + - containerPort: 8080 + volumeMounts: + - name: config + mountPath: /data/config + volumes: + - name: config + persistentVolumeClaim: + claimName: qbittorrent-config +--- +# 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 +--- +# 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: '8080' + protocol: TCP +--- +# 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 +spec: + parentRefs: + - name: shared + namespace: gateway + sectionName: https + hostnames: + - qbittorrent.home.keithjackson.dev + rules: + - matches: + - path: + type: PathPrefix + value: / + backendRefs: + - name: qbittorrent + port: 8080 +--- +# ServiceMonitor (R-OBS-01) +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: qbittorrent + namespace: qbittorrent + labels: + release: kube-prometheus-stack + app.kubernetes.io/name: qbittorrent +spec: + selector: + matchLabels: + app.kubernetes.io/name: qbittorrent + namespaceSelector: + matchNames: + - qbittorrent + endpoints: + - port: metrics + interval: 30s +--- +# 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: up{job="qbittorrent"} == 0 + for: 5m + labels: + severity: critical + service: qbittorrent + annotations: + summary: qbittorrent is down + description: 'qbittorrent has been unreachable for 5m (SLI: up).' diff --git a/catalog/jackett.yaml b/catalog/jackett.yaml new file mode 100644 index 0000000..17caffb --- /dev/null +++ b/catalog/jackett.yaml @@ -0,0 +1,44 @@ +# ServiceDefinition: jackett +# Source of truth: spec docs/13-service-catalog.md row 4; docs/03-networking-isolation-and-egress.md; +# docs/15-identity-and-access.md; decisions/adr-0015 (VPN egress steering). +# internal indexer proxy | egress VPN + kill-switch | config on Longhorn | auth forward-auth +# (bare web UI, no native OIDC). +# +# VPN-class (docs/13 row 4; docs/03 "VPN-class set"): Jackett scrapes public torrent indexers/trackers; +# that egress 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 when those +# services are onboarded. This service initiates nothing east-west, so dependencies is empty (reaches +# only DNS + VPN internet). Validates against schemas/servicedefinition.schema.json. +name: jackett +kind: container +source: + image: lscr.io/linuxserver/jackett:0.22.1327 # pinned stable tag (R-UPD-01); update.range governs advancement +exposure: internal # LAN-only web UI at jackett.home.keithjackson.dev (docs/13 row 4; R-DNS/R-NET) +egress: vpn # indexer scraping via node PIA tunnel + kill-switch (docs/03; ADR-0015; R-NET-05/06) +storage: + - name: config + size: 2Gi + class: longhorn # indexer definitions/API keys on 3-replica Longhorn (docs/04; R-STATE-02) +dependencies: [] # default-deny: reaches only DNS; arr reach it north of its own egress (docs/03; R-NET-01/02) +ingressRoutes: + - host: jackett.home.keithjackson.dev + port: 9117 # Jackett 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) +update: + channel: stable # R-UPD-01 + range: ">=0.22.0" # stay on the Jackett 0.22.x stable line + cadence: "0 4 * * *" + autoRollbackOnFailedHealth: true # R-UPD-03 +observability: + metrics: true + slis: + - up # indexer proxy down -> alert (docs/10; R-OBS-01) +backup: + policy: daily-offcluster # indexer config/API keys backed up off-cluster (docs/04; R-STATE-03) diff --git a/catalog/qbittorrent.yaml b/catalog/qbittorrent.yaml new file mode 100644 index 0000000..307be99 --- /dev/null +++ b/catalog/qbittorrent.yaml @@ -0,0 +1,59 @@ +# 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 — OPEN PREREQUISITE, intentionally NOT declared here (mirrors plex + sonarr/radarr): +# docs/13 row 13 places qBittorrent's downloads/staging volume on the `bulk` tier (large, non-3x- +# replicated media handoff to the arr stack). The `bulk` StorageClass exists +# (platform/longhorn/storageclasses.yaml) and both the schema and renderer support `class: bulk`, BUT +# it is a NON-PROVISIONING STUB pending OQ-2 (its NFS/SMB backing is REPLACE-ME and the NFS CSI driver +# is not installed) — a `bulk` PVC would never bind and would leave the pod Pending. Declaring it now +# would also pull the bulk-tier requirement into the change's "touched" set, whose in-cluster +# conformance test is not yet scaffolded, blocking the merge gate. So the downloads volume is DEFERRED +# and this onboarding ships config-only. Once OQ-2 is resolved and `bulk` provisions, attach downloads +# with a one-line add under storage: +# - name: downloads +# class: bulk # external mount; size omitted (sized outside the cluster) +# then re-render. See the WS-APPS report for the full bulk-downloads finding. +# +# 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 +exposure: internal # LAN-only web UI at qbittorrent.home.keithjackson.dev (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) +dependencies: [] # default-deny: reaches only DNS; arr reach it north of its own egress (docs/03; R-NET-01/02) +ingressRoutes: + - host: qbittorrent.home.keithjackson.dev + 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) +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)