qBittorrent had no network interface configured, so libtorrent listened and announced on every interface of the shared pod network namespace - including eth0 (the pod IP) and loopback. gluetun's kill switch drops exactly those packets, so trackers answered with "skipping tracker announce (unreachable)" or timed out, no peers were found and torrents stalled at 0 bytes in stalledDL. Beyond the stall this is a leak: without the binding qBittorrent also listens on the pod IP, i.e. reachable past the tunnel. The setting only exists in the persisted config file, which qBittorrent creates itself on first start, so an init container seeds it before the app comes up. The edit is idempotent and preserves existing user settings: it replaces the keys if present, inserts them into the [BitTorrent] section if missing, and creates file or section if needed. The interface is configurable via qbittorrent.networkInterface (default tun0); emptying it drops the init container.
477 lines
20 KiB
YAML
477 lines
20 KiB
YAML
apiVersion: apps/v1
|
|
kind: StatefulSet
|
|
metadata:
|
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
|
labels:
|
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
|
spec:
|
|
replicas: {{ include "freesailarr.replicas" (dict "scaleDown" .Values.scaleDown "replicas" .Values.replicas) }}
|
|
serviceName: {{ .Release.Name }}-{{ .Chart.Name }}-service
|
|
selector:
|
|
matchLabels:
|
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
|
template:
|
|
metadata:
|
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
|
labels:
|
|
app: {{ .Release.Name }}-{{ .Chart.Name }}-sfs
|
|
spec:
|
|
automountServiceAccountToken: false
|
|
securityContext:
|
|
{{- toYaml .Values.securityContext.pod | nindent 8 }}
|
|
{{- if and .Values.qbittorrent.enabled .Values.qbittorrent.networkInterface }}
|
|
initContainers:
|
|
# qBittorrent has no environment variable for the session's network
|
|
# interface - it only reads it from the persisted config file, which
|
|
# the app itself creates on first start. Without the binding
|
|
# libtorrent listens and announces on every interface of the shared
|
|
# pod network namespace (eth0/pod IP and loopback included); gluetun's
|
|
# kill switch drops those packets, trackers stay unreachable and
|
|
# torrents stall at 0 bytes. Seeding the two keys before qbittorrent
|
|
# starts closes that leak and makes a fresh install work out of the
|
|
# box. The edit is idempotent and preserves all other settings:
|
|
# existing keys are replaced, missing ones are inserted into the
|
|
# [BitTorrent] section (qBittorrent 5.x reads them there), and a
|
|
# missing file or section is created.
|
|
- name: qbittorrent-config-interface
|
|
image: "{{ .Values.qbittorrent.configInit.image.repository }}:{{ .Values.qbittorrent.configInit.image.tag }}"
|
|
imagePullPolicy: IfNotPresent
|
|
command:
|
|
- sh
|
|
- -c
|
|
- |
|
|
set -eu
|
|
conf=/config/qBittorrent/qBittorrent.conf
|
|
mkdir -p /config/qBittorrent
|
|
[ -f "$conf" ] || printf '[BitTorrent]\n' > "$conf"
|
|
# A file without a trailing newline would glue an appended
|
|
# section header onto the last key.
|
|
if [ -s "$conf" ] && [ -n "$(tail -c 1 "$conf")" ]; then
|
|
printf '\n' >> "$conf"
|
|
fi
|
|
grep -q '^\[BitTorrent\]$' "$conf" || printf '[BitTorrent]\n' >> "$conf"
|
|
# Drop any existing binding keys, then re-insert them directly
|
|
# after the [BitTorrent] header.
|
|
awk -v ifc="$QBT_INTERFACE" '
|
|
/^Session\\Interface=/ { next }
|
|
/^Session\\InterfaceName=/ { next }
|
|
{ print }
|
|
/^\[BitTorrent\]$/ {
|
|
print "Session\\Interface=" ifc
|
|
print "Session\\InterfaceName=" ifc
|
|
}
|
|
' "$conf" > "${conf}.tmp"
|
|
mv "${conf}.tmp" "$conf"
|
|
echo "qBittorrent session bound to interface $QBT_INTERFACE"
|
|
env:
|
|
- name: QBT_INTERFACE
|
|
value: {{ .Values.qbittorrent.networkInterface | quote }}
|
|
resources:
|
|
{{- include "freesailarr.resources" .Values.qbittorrent.configInit.resources | nindent 12 }}
|
|
securityContext:
|
|
runAsUser: {{ .Values.puid }}
|
|
runAsGroup: {{ .Values.pgid }}
|
|
{{- toYaml .Values.qbittorrent.configInit.securityContext | nindent 12 }}
|
|
volumeMounts:
|
|
- mountPath: /config
|
|
name: qbittorrent-config
|
|
{{- end }}
|
|
containers:
|
|
# gluetun is the VPN gateway: all app containers share the pod's
|
|
# network namespace, talk to each other over localhost and reach the
|
|
# internet only through the Wireguard tunnel (gluetun's firewall acts
|
|
# as kill switch).
|
|
- name: gluetun
|
|
image: "qmcgaw/gluetun:{{ .Chart.AppVersion }}"
|
|
imagePullPolicy: IfNotPresent
|
|
# An uncleanly exited gluetun (crash, OOM killer, node issue) leaves
|
|
# its ip rule, routing table and tun device behind in the shared pod
|
|
# network namespace, and the restarted container then fails forever
|
|
# ("adding ip rule 101 ... file exists" / silent tunnel breakage)
|
|
# while the kill switch blocks all egress. The upstream-recommended
|
|
# postStart hook is racy (it runs concurrently with the entrypoint
|
|
# and can delete the rule the new instance just created), so the
|
|
# stale state is cleaned synchronously before gluetun starts.
|
|
command:
|
|
- /bin/sh
|
|
- -c
|
|
- 'while ip rule del table 51820 2>/dev/null; do :; done; while ip -6 rule del table 51820 2>/dev/null; do :; done; ip link del tun0 2>/dev/null; exec /gluetun-entrypoint'
|
|
env:
|
|
- name: TZ
|
|
value: {{ .Values.timezone | quote }}
|
|
- name: VPN_SERVICE_PROVIDER
|
|
value: {{ .Values.gluetun.vpn.serviceProvider | quote }}
|
|
- name: VPN_TYPE
|
|
value: {{ .Values.gluetun.vpn.type | quote }}
|
|
- name: WIREGUARD_PRIVATE_KEY
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-gluetun-secret
|
|
key: wireguard-private-key
|
|
- name: WIREGUARD_ADDRESSES
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: {{ .Release.Name }}-{{ .Chart.Name }}-gluetun-secret
|
|
key: wireguard-addresses
|
|
{{- with .Values.gluetun.vpn.serverCities }}
|
|
- name: SERVER_CITIES
|
|
value: {{ . | quote }}
|
|
{{- end }}
|
|
{{- with .Values.gluetun.vpn.serverCountries }}
|
|
- name: SERVER_COUNTRIES
|
|
value: {{ . | quote }}
|
|
{{- end }}
|
|
{{- with .Values.gluetun.vpn.dnsUpstreamResolvers }}
|
|
- name: DNS_UPSTREAM_RESOLVERS
|
|
value: {{ . | quote }}
|
|
{{- end }}
|
|
{{- with .Values.gluetun.vpn.firewallOutboundSubnets }}
|
|
- name: FIREWALL_OUTBOUND_SUBNETS
|
|
value: {{ . | quote }}
|
|
{{- end }}
|
|
# Open the firewall for the UI/probe ports of the enabled apps and
|
|
# the health server - kubelet probes and ingress traffic arrive on
|
|
# the pod IP and would be dropped otherwise.
|
|
- name: FIREWALL_INPUT_PORTS
|
|
value: {{ include "freesailarr.firewallInputPorts" . | quote }}
|
|
# Bind the health server to all interfaces so the kubelet HTTP
|
|
# probes (which connect to the pod IP, not localhost) reach it.
|
|
- name: HEALTH_SERVER_ADDRESS
|
|
value: ":9999"
|
|
{{- include "freesailarr.env" (dict "root" $ "env" .Values.gluetun.env) | nindent 12 }}
|
|
ports:
|
|
- containerPort: 9999
|
|
protocol: TCP
|
|
name: health
|
|
livenessProbe:
|
|
{{- toYaml .Values.gluetun.livenessProbe | nindent 12 }}
|
|
readinessProbe:
|
|
{{- toYaml .Values.gluetun.readinessProbe | nindent 12 }}
|
|
startupProbe:
|
|
{{- toYaml .Values.gluetun.startupProbe | nindent 12 }}
|
|
resources:
|
|
{{- include "freesailarr.resources" .Values.gluetun.resources | nindent 12 }}
|
|
securityContext:
|
|
{{- toYaml .Values.gluetun.securityContext | nindent 12 }}
|
|
volumeMounts:
|
|
- mountPath: /gluetun
|
|
name: gluetun-config
|
|
- mountPath: /dev/net/tun
|
|
name: dev-net-tun
|
|
{{- if .Values.qbittorrent.enabled }}
|
|
- name: qbittorrent
|
|
image: "{{ .Values.qbittorrent.image.repository }}:{{ .Values.qbittorrent.image.tag }}"
|
|
imagePullPolicy: IfNotPresent
|
|
env:
|
|
- name: PUID
|
|
value: {{ .Values.puid | quote }}
|
|
- name: PGID
|
|
value: {{ .Values.pgid | quote }}
|
|
- name: TZ
|
|
value: {{ .Values.timezone | quote }}
|
|
- name: WEBUI_PORT
|
|
value: "8080"
|
|
{{- include "freesailarr.env" (dict "root" $ "env" .Values.qbittorrent.env) | nindent 12 }}
|
|
ports:
|
|
- containerPort: 8080
|
|
protocol: TCP
|
|
name: qbittorrent
|
|
livenessProbe:
|
|
{{- toYaml .Values.qbittorrent.livenessProbe | nindent 12 }}
|
|
readinessProbe:
|
|
{{- toYaml .Values.qbittorrent.readinessProbe | nindent 12 }}
|
|
startupProbe:
|
|
{{- toYaml .Values.qbittorrent.startupProbe | nindent 12 }}
|
|
resources:
|
|
{{- include "freesailarr.resources" .Values.qbittorrent.resources | nindent 12 }}
|
|
securityContext:
|
|
{{- toYaml .Values.qbittorrent.securityContext | nindent 12 }}
|
|
volumeMounts:
|
|
- mountPath: /config
|
|
name: qbittorrent-config
|
|
- mountPath: /downloads
|
|
name: media-downloads
|
|
{{- end }}
|
|
{{- if .Values.prowlarr.enabled }}
|
|
- name: prowlarr
|
|
image: "{{ .Values.prowlarr.image.repository }}:{{ .Values.prowlarr.image.tag }}"
|
|
imagePullPolicy: IfNotPresent
|
|
env:
|
|
- name: PUID
|
|
value: {{ .Values.puid | quote }}
|
|
- name: PGID
|
|
value: {{ .Values.pgid | quote }}
|
|
- name: TZ
|
|
value: {{ .Values.timezone | quote }}
|
|
{{- include "freesailarr.env" (dict "root" $ "env" .Values.prowlarr.env) | nindent 12 }}
|
|
ports:
|
|
- containerPort: 9696
|
|
protocol: TCP
|
|
name: prowlarr
|
|
livenessProbe:
|
|
{{- toYaml .Values.prowlarr.livenessProbe | nindent 12 }}
|
|
readinessProbe:
|
|
{{- toYaml .Values.prowlarr.readinessProbe | nindent 12 }}
|
|
startupProbe:
|
|
{{- toYaml .Values.prowlarr.startupProbe | nindent 12 }}
|
|
resources:
|
|
{{- include "freesailarr.resources" .Values.prowlarr.resources | nindent 12 }}
|
|
securityContext:
|
|
{{- toYaml .Values.prowlarr.securityContext | nindent 12 }}
|
|
volumeMounts:
|
|
- mountPath: /config
|
|
name: prowlarr-config
|
|
{{- end }}
|
|
{{- if .Values.flaresolverr.enabled }}
|
|
- name: flaresolverr
|
|
image: "{{ .Values.flaresolverr.image.repository }}:{{ .Values.flaresolverr.image.tag }}"
|
|
imagePullPolicy: IfNotPresent
|
|
env:
|
|
- name: TZ
|
|
value: {{ .Values.timezone | quote }}
|
|
{{- include "freesailarr.env" (dict "root" $ "env" .Values.flaresolverr.env) | nindent 12 }}
|
|
ports:
|
|
- containerPort: 8191
|
|
protocol: TCP
|
|
name: flaresolverr
|
|
livenessProbe:
|
|
{{- toYaml .Values.flaresolverr.livenessProbe | nindent 12 }}
|
|
readinessProbe:
|
|
{{- toYaml .Values.flaresolverr.readinessProbe | nindent 12 }}
|
|
startupProbe:
|
|
{{- toYaml .Values.flaresolverr.startupProbe | nindent 12 }}
|
|
resources:
|
|
{{- include "freesailarr.resources" .Values.flaresolverr.resources | nindent 12 }}
|
|
securityContext:
|
|
{{- toYaml .Values.flaresolverr.securityContext | nindent 12 }}
|
|
volumeMounts:
|
|
# Writable paths for headless chromium so the root filesystem
|
|
# stays read-only; /dev/shm is a memory-backed emptyDir because
|
|
# chromium needs more shared memory than the container default.
|
|
- mountPath: /tmp
|
|
name: flaresolverr-tmp
|
|
- mountPath: /dev/shm
|
|
name: flaresolverr-shm
|
|
{{- end }}
|
|
{{- if .Values.radarr.enabled }}
|
|
- name: radarr
|
|
image: "{{ .Values.radarr.image.repository }}:{{ .Values.radarr.image.tag }}"
|
|
imagePullPolicy: IfNotPresent
|
|
env:
|
|
- name: PUID
|
|
value: {{ .Values.puid | quote }}
|
|
- name: PGID
|
|
value: {{ .Values.pgid | quote }}
|
|
- name: TZ
|
|
value: {{ .Values.timezone | quote }}
|
|
{{- include "freesailarr.env" (dict "root" $ "env" .Values.radarr.env) | nindent 12 }}
|
|
ports:
|
|
- containerPort: 7878
|
|
protocol: TCP
|
|
name: radarr
|
|
livenessProbe:
|
|
{{- toYaml .Values.radarr.livenessProbe | nindent 12 }}
|
|
readinessProbe:
|
|
{{- toYaml .Values.radarr.readinessProbe | nindent 12 }}
|
|
startupProbe:
|
|
{{- toYaml .Values.radarr.startupProbe | nindent 12 }}
|
|
resources:
|
|
{{- include "freesailarr.resources" .Values.radarr.resources | nindent 12 }}
|
|
securityContext:
|
|
{{- toYaml .Values.radarr.securityContext | nindent 12 }}
|
|
volumeMounts:
|
|
- mountPath: /config
|
|
name: radarr-config
|
|
- mountPath: /movies
|
|
name: media-movies
|
|
- mountPath: /downloads
|
|
name: media-downloads
|
|
{{- end }}
|
|
{{- if .Values.recyclarr.enabled }}
|
|
- name: recyclarr
|
|
image: "{{ .Values.recyclarr.image.repository }}:{{ .Values.recyclarr.image.tag }}"
|
|
imagePullPolicy: IfNotPresent
|
|
env:
|
|
- name: TZ
|
|
value: {{ .Values.timezone | quote }}
|
|
- name: RECYCLARR_CREATE_CONFIG
|
|
value: "true"
|
|
{{- include "freesailarr.env" (dict "root" $ "env" .Values.recyclarr.env) | nindent 12 }}
|
|
livenessProbe:
|
|
{{- toYaml .Values.recyclarr.livenessProbe | nindent 12 }}
|
|
readinessProbe:
|
|
{{- toYaml .Values.recyclarr.readinessProbe | nindent 12 }}
|
|
startupProbe:
|
|
{{- toYaml .Values.recyclarr.startupProbe | nindent 12 }}
|
|
resources:
|
|
{{- include "freesailarr.resources" .Values.recyclarr.resources | nindent 12 }}
|
|
securityContext:
|
|
runAsUser: {{ .Values.puid }}
|
|
runAsGroup: {{ .Values.pgid }}
|
|
{{- toYaml .Values.recyclarr.securityContext | nindent 12 }}
|
|
volumeMounts:
|
|
- mountPath: /config
|
|
name: recyclarr-config
|
|
# Writable scratch space for the .NET runtime so the root
|
|
# filesystem stays read-only.
|
|
- mountPath: /tmp
|
|
name: recyclarr-tmp
|
|
{{- end }}
|
|
{{- if .Values.sonarr.enabled }}
|
|
- name: sonarr
|
|
image: "{{ .Values.sonarr.image.repository }}:{{ .Values.sonarr.image.tag }}"
|
|
imagePullPolicy: IfNotPresent
|
|
env:
|
|
- name: PUID
|
|
value: {{ .Values.puid | quote }}
|
|
- name: PGID
|
|
value: {{ .Values.pgid | quote }}
|
|
- name: TZ
|
|
value: {{ .Values.timezone | quote }}
|
|
{{- include "freesailarr.env" (dict "root" $ "env" .Values.sonarr.env) | nindent 12 }}
|
|
ports:
|
|
- containerPort: 8989
|
|
protocol: TCP
|
|
name: sonarr
|
|
livenessProbe:
|
|
{{- toYaml .Values.sonarr.livenessProbe | nindent 12 }}
|
|
readinessProbe:
|
|
{{- toYaml .Values.sonarr.readinessProbe | nindent 12 }}
|
|
startupProbe:
|
|
{{- toYaml .Values.sonarr.startupProbe | nindent 12 }}
|
|
resources:
|
|
{{- include "freesailarr.resources" .Values.sonarr.resources | nindent 12 }}
|
|
securityContext:
|
|
{{- toYaml .Values.sonarr.securityContext | nindent 12 }}
|
|
volumeMounts:
|
|
- mountPath: /config
|
|
name: sonarr-config
|
|
- mountPath: /tv
|
|
name: media-tv
|
|
- mountPath: /downloads
|
|
name: media-downloads
|
|
{{- end }}
|
|
{{- if .Values.seerr.enabled }}
|
|
- name: seerr
|
|
image: "{{ .Values.seerr.image.repository }}:{{ .Values.seerr.image.tag }}"
|
|
imagePullPolicy: IfNotPresent
|
|
env:
|
|
- name: TZ
|
|
value: {{ .Values.timezone | quote }}
|
|
{{- include "freesailarr.env" (dict "root" $ "env" .Values.seerr.env) | nindent 12 }}
|
|
ports:
|
|
- containerPort: 5055
|
|
protocol: TCP
|
|
name: seerr
|
|
livenessProbe:
|
|
{{- toYaml .Values.seerr.livenessProbe | nindent 12 }}
|
|
readinessProbe:
|
|
{{- toYaml .Values.seerr.readinessProbe | nindent 12 }}
|
|
startupProbe:
|
|
{{- toYaml .Values.seerr.startupProbe | nindent 12 }}
|
|
resources:
|
|
{{- include "freesailarr.resources" .Values.seerr.resources | nindent 12 }}
|
|
securityContext:
|
|
runAsUser: {{ .Values.puid }}
|
|
runAsGroup: {{ .Values.pgid }}
|
|
{{- toYaml .Values.seerr.securityContext | nindent 12 }}
|
|
volumeMounts:
|
|
- mountPath: /app/config
|
|
name: seerr-config
|
|
- mountPath: /tmp
|
|
name: seerr-tmp
|
|
{{- end }}
|
|
{{- if .Values.bazarr.enabled }}
|
|
- name: bazarr
|
|
image: "{{ .Values.bazarr.image.repository }}:{{ .Values.bazarr.image.tag }}"
|
|
imagePullPolicy: IfNotPresent
|
|
env:
|
|
- name: PUID
|
|
value: {{ .Values.puid | quote }}
|
|
- name: PGID
|
|
value: {{ .Values.pgid | quote }}
|
|
- name: TZ
|
|
value: {{ .Values.timezone | quote }}
|
|
{{- include "freesailarr.env" (dict "root" $ "env" .Values.bazarr.env) | nindent 12 }}
|
|
ports:
|
|
- containerPort: 6767
|
|
protocol: TCP
|
|
name: bazarr
|
|
livenessProbe:
|
|
{{- toYaml .Values.bazarr.livenessProbe | nindent 12 }}
|
|
readinessProbe:
|
|
{{- toYaml .Values.bazarr.readinessProbe | nindent 12 }}
|
|
startupProbe:
|
|
{{- toYaml .Values.bazarr.startupProbe | nindent 12 }}
|
|
resources:
|
|
{{- include "freesailarr.resources" .Values.bazarr.resources | nindent 12 }}
|
|
securityContext:
|
|
{{- toYaml .Values.bazarr.securityContext | nindent 12 }}
|
|
volumeMounts:
|
|
- mountPath: /config
|
|
name: bazarr-config
|
|
- mountPath: /movies
|
|
name: media-movies
|
|
- mountPath: /tv
|
|
name: media-tv
|
|
{{- end }}
|
|
restartPolicy: Always
|
|
volumes:
|
|
# The tun device gluetun needs to create the Wireguard tunnel.
|
|
- name: dev-net-tun
|
|
hostPath:
|
|
path: /dev/net/tun
|
|
type: CharDevice
|
|
{{- if or .Values.qbittorrent.enabled .Values.radarr.enabled .Values.sonarr.enabled }}
|
|
- name: media-downloads
|
|
persistentVolumeClaim:
|
|
claimName: {{ required "media.downloads.existingClaim is required when qbittorrent, radarr or sonarr is enabled" .Values.media.downloads.existingClaim }}
|
|
{{- end }}
|
|
{{- if or .Values.radarr.enabled .Values.bazarr.enabled }}
|
|
- name: media-movies
|
|
persistentVolumeClaim:
|
|
claimName: {{ required "media.movies.existingClaim is required when radarr or bazarr is enabled" .Values.media.movies.existingClaim }}
|
|
{{- end }}
|
|
{{- if or .Values.sonarr.enabled .Values.bazarr.enabled }}
|
|
- name: media-tv
|
|
persistentVolumeClaim:
|
|
claimName: {{ required "media.tv.existingClaim is required when sonarr or bazarr is enabled" .Values.media.tv.existingClaim }}
|
|
{{- end }}
|
|
{{- if .Values.flaresolverr.enabled }}
|
|
- name: flaresolverr-tmp
|
|
emptyDir: {}
|
|
- name: flaresolverr-shm
|
|
emptyDir:
|
|
medium: Memory
|
|
sizeLimit: 512Mi
|
|
{{- end }}
|
|
{{- if .Values.recyclarr.enabled }}
|
|
- name: recyclarr-tmp
|
|
emptyDir: {}
|
|
{{- end }}
|
|
{{- if .Values.seerr.enabled }}
|
|
- name: seerr-tmp
|
|
emptyDir: {}
|
|
{{- end }}
|
|
# Config volumes for gluetun and the enabled apps. NOTE: volumeClaimTemplates
|
|
# are immutable - toggling an app's enabled flag later requires recreating
|
|
# the StatefulSet once (kubectl delete statefulset <name> --cascade=orphan
|
|
# before upgrading; existing PVCs and their data survive).
|
|
volumeClaimTemplates:
|
|
{{- range $app := list "gluetun" "qbittorrent" "prowlarr" "radarr" "recyclarr" "sonarr" "seerr" "bazarr" }}
|
|
{{- $config := index $.Values $app }}
|
|
{{- if or (eq $app "gluetun") $config.enabled }}
|
|
- metadata:
|
|
name: {{ $app }}-config
|
|
spec:
|
|
accessModes:
|
|
- ReadWriteOnce
|
|
resources:
|
|
requests:
|
|
storage: {{ $config.storage.size }}
|
|
{{- with $.Values.storage.storageClass }}
|
|
storageClassName: {{ . }}
|
|
{{- end }}
|
|
{{- end }}
|
|
{{- end }}
|