WIP: durable qBittorrent WebUI credential

INCOMPLETE — preserved by the PM after the authoring agent hit the session
rate limit mid-task. Do not merge without finishing and gating it.

Problem: qBittorrent 5.1.2 has no WebUI\Username or WebUI\Password_PBKDF2 in
its config, so it mints a NEW temporary admin password on every process start
and announces it only on stdout. Two consequences, both real: the Sonarr and
Radarr download clients configured earlier will silently stop working at the
next pod restart, and a human operator has no stable login either.

Adds a SOPS-encrypted qbittorrent-bootstrap Secret (verified encrypted: both
values are ENC[AES256_GCM]) plus the catalog/rendered wiring to consume it.

WHAT REMAINS:
  - The agent's last note: the postStart hook works, but its output never
    reaches kubectl logs, so it was switching to the marker-file logging
    pattern gitea's hook already uses. That change is NOT here.
  - The Secret exists on prod as kubectl-client-side-apply, i.e. live-only;
    merging adopts it into git.
  - Sonarr's and Radarr's stored download-client passwords were to be updated
    to the durable value — NOT done, so the chain may break on restart until
    it is.
  - The required proof is unfinished: restart the pod, then show the WebUI
    accepts the credential AND both arrs still report isValid: true.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5 committed 2026-09-13 15:57:42 -05:00
1 parent f4f6e5f7a3
commit 204f0ec28e
3 files changed
+310

No files matched your search

@@ -0,0 +1,60 @@
# qBittorrent DURABLE WebUI credential (WS-APPS). SOPS-encrypted (age), decrypted in-cluster by Flux.
#
# WHY THIS FILE EXISTS (defect, measured on prod 2026-09-13):
# qBittorrent.conf on the config PVC carried NEITHER `WebUI\Username` NOR
# `WebUI\Password_PBKDF2`. qBittorrent 5.x treats that as "no credential set" and generates a NEW
# random `admin` password on EVERY process start, announcing it ONLY on stdout ("A temporary
# password is provided for this session: ..."). Consequences, both real: (a) Sonarr's and Radarr's
# saved download-client credentials stop working at the next qbittorrent pod restart, silently
# breaking Completed Download Handling; (b) a human operator has no stable login at all and must
# read the pod log after every restart.
#
# HOW IT IS APPLIED: catalog/qbittorrent.yaml's app container reads BOTH keys below as env
# (QBT_WEBUI_USERNAME / QBT_WEBUI_PASSWORD) and its `lifecycle.postStart` hook — the same idiom as
# gitea's break-glass admin bootstrap — DERIVES the PBKDF2-HMAC-SHA512 digest qBittorrent stores
# and asserts both keys into /config/qBittorrent/qBittorrent.conf on EVERY container start, then
# verifies the WebUI actually accepts them. Level-triggered: a pod restart, a config-PVC restore
# from an older backup, and a rebuild-from-git onto an EMPTY PVC all end with this SAME
# credential, because the hook re-asserts it on every start rather than once.
#
# webui-username: the WebUI account name. qBittorrent has exactly ONE local account, and `admin` is
# the name its own temporary-password path uses, so keeping it avoids inventing a second identity.
# webui-password: the durable plaintext. It is ALSO what Sonarr's and Radarr's download-client
# entries store in their own databases (app state on their config PVCs, NOT in git — see
# DECISIONS-LOG), so rotating it here means re-pointing both arrs.
# Deliberately alphanumeric: it travels through a urlencoded WebAPI login body and two arr JSON
# payloads, and a shell/URL-special character in any of those is a bug waiting to happen.
#
# NEVER put a value in a comment: SOPS does NOT encrypt comments.
# Recover with: infra/devcontainer/dev.sh run sops -d apps/wave-0/qbittorrent-bootstrap-secret.sops.yaml
apiVersion: v1
kind: Secret
metadata:
name: qbittorrent-bootstrap
namespace: qbittorrent
labels:
app.kubernetes.io/name: qbittorrent
type: Opaque
stringData:
webui-username: ENC[AES256_GCM,data:P022nG0=,iv:MXcCXsD5hs7MAJ+OAYHkZ3pXep7XGPqeAExChoqna08=,tag:WE34mxtfQRgiwPRaDA12pQ==,type:str]
webui-password: ENC[AES256_GCM,data:+Lx0Ny7SBZeF/CzQYm8umntw27ewyXS7unWObpl0fCg=,iv:JkAtublBMS7SJUutAFip1XjdmsKnGe+S2E9QDT4rCJk=,tag:MYptPVgoEUEpXe5kmg1xIg==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1aWpUY04wZHoyWVdPbGU2
TC9vaU5oYmhWSVh3YWVTdzkvU3Axdlh6QVFZCkV4MldaZXNyam5CejQyZStyczJ3
UStFd0hRQmRsNGZLVEVTRnVjWXp6SVkKLS0tICtkWlRqVGN5OWYvVnZUaHYvbEZv
VWtHMVdDU3hmdXo1TndLb05zQzJ0S28KfqlDBTocO0ZAKtH6ad/00EeVRqZ7WMwv
FP2evd5vyn95QQICWhY/LH6EKbwVVlcIlL7i9l8K3kK4Ebdk54mAyw==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-09-13T05:23:23Z"
mac: ENC[AES256_GCM,data:hQ7jIgFqpNcFY5H4AfMxuslC2R/2wGmGWtghemOmTUUipSdoGdbNjWGQhxzk40lFjw6Zj2b+buOpXpg70s4xr+4feusqa2TJ3+bxAuRWmuZpTQAU2OaO5v0SdDEs3gwKm2mA3JFo/e6POm/P/kf2GIECcyU1AciZQt6ws7UJmQc=,iv:6SF0ZAuzHQThD/FGbWhFfa4dXU8hyW8jWFiDmwgfFU8=,tag:31i8HM2s/mGeS4zvFxO53Q==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
+18
View File
@@ -130,12 +130,30 @@ spec:
value: '1000'
- name: PGID
value: '3000'
- name: QBT_WEBUI_USERNAME
valueFrom:
secretKeyRef:
name: qbittorrent-bootstrap
key: webui-username
- name: QBT_WEBUI_PASSWORD
valueFrom:
secretKeyRef:
name: qbittorrent-bootstrap
key: webui-password
volumeMounts:
- name: config
mountPath: /config
- name: downloads
mountPath: /downloads
subPath: downloads
lifecycle:
postStart:
exec:
command:
- /bin/bash
- -c
- "# Assert the durable WebUI credential in the config PVC's qBittorrent.conf, then PROVE the\n# running WebUI accepts it. Idempotent: the steady-state path writes nothing at all.\n#\n# NOTE the `$${...}` escapes below \u2014 load-bearing. apps-wave-0 declares\n# postBuild.substituteFrom cluster-vars, so Flux runs envsubst over this whole manifest at\n# RECONCILE time, and a SINGLE-dollar brace expansion is not a cluster-var, so envsubst\n# would blank it. Bare `$VAR` and `$(cmd)` are left alone (see catalog/gitea.yaml for the\n# incident that taught this, and render.py's assert_substitution_safe, which enforces it).\nset -u\nCONF=/config/qBittorrent/qBittorrent.conf\nSVC=/run/service/svc-qbittorrent\nPORT=$${WEBUI_PORT:-8080}\n# A postStart hook's stdout/stderr goes to the KUBELET, not to `kubectl logs` \u2014 so a hook\n# that only echoes is effectively silent (gitea's hits the same wall and writes a marker\n# file for it). Every outcome therefore also lands, timestamped, on the persistent config\n# volume: /config/qbittorrent-webui-cred.log is where an operator looks to see whether the\n# credential from git was asserted and accepted on this start.\nlog() {\n echo \"[qbt-webui-cred] $*\"\n printf '%s [qbt-webui-cred] %s\\n' \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" \"$*\" \\\n >> /config/qbittorrent-webui-cred.log 2>/dev/null || true\n}\n\n# (1) The image's init-qbittorrent-config copies /defaults/qBittorrent.conf onto the PVC\n# before it starts the service. Wait (bounded, ~30s) so we EDIT that file instead of\n# racing to create one it would then not overwrite.\nn=0\nwhile [ ! -s \"$CONF\" ] && [ \"$n\" -lt 120 ]; do n=$((n + 1)); sleep 0.25; done\n\nwaited=0\nattempt=0\nwhile [ \"$attempt\" -lt 3 ]; do\n attempt=$((attempt + 1))\n\n # (2) Converge the conf. 0 = already correct (steady state: NO write, NO re-salt),\n # 10 = rewritten, anything else = could not write.\n python3 - \"$CONF\" <<'PY'\nimport base64, hashlib, hmac, os, stat, sys\n\nconf = sys.argv[1]\nuser = os.environ.get(\"QBT_WEBUI_USERNAME\", \"\")\npw = os.environ.get(\"QBT_WEBUI_PASSWORD\", \"\")\nif not user or not pw:\n sys.stderr.write(\"[qbt-webui-cred] credential env is empty - refusing to touch the conf\\n\")\n raise SystemExit(1)\n\n# qBittorrent's OWN parameters (base/utils/password.cpp): PBKDF2-HMAC-SHA512, 100000\n# iterations, 16-byte random salt, 64-byte digest, stored as\n# WebUI\\Password_PBKDF2=\"@ByteArray(<base64 salt>:<base64 digest>)\"\n# Get any one of them wrong and the app simply rejects every login.\nITERATIONS, DIGEST_LEN, SALT_LEN = 100000, 64, 16\nUKEY, PKEY, SECTION = \"WebUI\\\\Username\", \"WebUI\\\\Password_PBKDF2\", \"[Preferences]\"\n\ndef matches(raw):\n \"\"\"True iff the stored digest is one OUR password produces (salt is read from it).\"\"\"\n v = raw.strip().strip('\"')\n if v.startswith(\"@ByteArray(\") and v.endswith(\")\"):\n v = v[len(\"@ByteArray(\"):-1]\n try:\n salt_b64, digest_b64 = v.split(\":\", 1)\n salt = base64.b64decode(salt_b64, validate=True)\n digest = base64.b64decode(digest_b64, validate=True)\n except Exception:\n return False\n if not salt or not digest:\n return False\n return hmac.compare_digest(\n hashlib.pbkdf2_hmac(\"sha512\", pw.encode(), salt, ITERATIONS, len(digest)), digest)\n\ntry:\n lines = open(conf, encoding=\"utf-8\", errors=\"replace\").read().splitlines()\nexcept FileNotFoundError:\n lines = []\n\ndef value_of(key):\n for i, line in enumerate(lines):\n if line.startswith(key + \"=\"):\n return i, line.split(\"=\", 1)[1]\n return -1, None\n\nui, uval = value_of(UKEY)\npi, pval = value_of(PKEY)\nif uval == user and pval is not None and matches(pval):\n raise SystemExit(0) # converged - leave the file (and its salt) alone\n\nsalt = os.urandom(SALT_LEN)\ndigest = hashlib.pbkdf2_hmac(\"sha512\", pw.encode(), salt, ITERATIONS, DIGEST_LEN)\nstored = base64.b64encode(salt).decode() + \":\" + base64.b64encode(digest).decode()\ndesired = [UKEY\
\ + \"=\" + user, PKEY + '=\"@ByteArray(' + stored + ')\"']\n\nout = [line for i, line in enumerate(lines) if i not in (ui, pi)]\nif SECTION in out:\n at = out.index(SECTION) + 1\nelse:\n if out and out[-1].strip():\n out.append(\"\")\n out.append(SECTION)\n at = len(out)\nout[at:at] = desired\n\ntry:\n os.makedirs(os.path.dirname(conf) or \".\", exist_ok=True)\n tmp = conf + \".webui-cred.new\"\n with open(tmp, \"w\", encoding=\"utf-8\") as fh:\n fh.write(\"\\n\".join(out) + \"\\n\")\n fh.flush()\n os.fsync(fh.fileno())\n # This hook runs as ROOT; the service runs as PUID:PGID. A root-owned 0600 conf would\n # cost the app the ability to save its own settings, so carry the ownership over.\n try:\n st = os.stat(conf)\n os.chown(tmp, st.st_uid, st.st_gid)\n os.chmod(tmp, stat.S_IMODE(st.st_mode))\n except FileNotFoundError:\n os.chown(tmp, int(os.environ.get(\"PUID\", \"1000\")),\n int(os.environ.get(\"PGID\", \"1000\")))\n os.chmod(tmp, 0o664)\n os.replace(tmp, conf) # atomic: the app never reads a half-written conf\nexcept OSError as exc:\n sys.stderr.write(\"[qbt-webui-cred] cannot write %s: %s\\n\" % (conf, exc))\n raise SystemExit(1)\nraise SystemExit(10)\nPY\n rc=$?\n\n if [ \"$rc\" = 0 ]; then\n log \"conf already holds the durable credential for '$QBT_WEBUI_USERNAME' (no write)\"\n elif [ \"$rc\" = 10 ]; then\n log \"asserted WebUI Username + Password_PBKDF2 into $CONF\"\n # postStart runs CONCURRENTLY with the s6 init chain, so qbittorrent-nox may ALREADY\n # have read the old, credential-less conf and be serving a random temporary password.\n # Restart the SUPERVISED SERVICE (not the pod) so it re-reads what was just written.\n # Nothing to do when the service has not started yet - it will read the new file.\n while [ \"$waited\" -lt 90 ] && [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" != \"true\" ]; do\n waited=$((waited + 1)); sleep 1\n done\n if [ \"$(s6-svstat -u \"$SVC\" 2>/dev/null)\" = \"true\" ]; then\n log \"qbittorrent-nox was already running - restarting it to load the credential\"\n s6-svc -r \"$SVC\" || log \"s6-svc -r failed (continuing to verify anyway)\"\n sleep 3\n fi\n else\n log \"FAILED to write the credential into $CONF (helper exit $rc). The WebUI will keep\" >&2\n log \"minting a random password every start and BOTH arrs will break. Fix and restart.\" >&2\n exit 0\n fi\n\n # (3) GROUND TRUTH: the WebUI itself must accept it. Wait for the API to answer at all\n # (shared ~90s budget across attempts), then spend exactly ONE login per attempt -\n # at most 3 rejected logins, which stays under qBittorrent's default ban threshold.\n while [ \"$waited\" -lt 90 ]; do\n code=$(curl -s -o /dev/null -m 3 -w '%{http_code}' \"http://127.0.0.1:$PORT/\" 2>/dev/null)\n [ -n \"$code\" ] && [ \"$code\" != \"000\" ] && break\n waited=$((waited + 1)); sleep 1\n done\n body=$(curl -sS -m 10 \\\n --data-urlencode \"username=$QBT_WEBUI_USERNAME\" \\\n --data-urlencode \"password=$QBT_WEBUI_PASSWORD\" \\\n \"http://127.0.0.1:$PORT/api/v2/auth/login\" 2>/dev/null)\n if [ \"$body\" = \"Ok.\" ]; then\n log \"VERIFIED: the WebUI accepts the durable credential for '$QBT_WEBUI_USERNAME'\"\n exit 0\n fi\n log \"WebUI rejected the durable credential (login said '$body') - reasserting\" >&2\ndone\n\nlog \"GAVE UP after $attempt attempts: the WebUI does not accept the credential from git.\" >&2\n# A postStart hook MUST exit 0 - a non-zero exit makes the kubelet KILL the container\n# (FailedPostStartHook -> CrashLoopBackOff), turning a credential fault into an outage.\n# Both failure paths above therefore log LOUDLY and exit 0; the `up` SLI still watches the\n# app, and the arrs' own download-client health check surfaces a broken credential.\nexit 0\n"
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
imagePullPolicy: IfNotPresent
+232
View File
@@ -44,6 +44,238 @@ env:
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
# --- THE DURABLE WebUI CREDENTIAL (consumed by the postStart hook below) -------------------
# Measured on prod 2026-09-13: /config/qBittorrent/qBittorrent.conf held NEITHER `WebUI\Username`
# NOR `WebUI\Password_PBKDF2`. qBittorrent 5.x reads that as "no credential configured" and mints a
# NEW random `admin` password on EVERY process start, announcing it ONLY on stdout ("A temporary
# password is provided for this session: ..."). Two real consequences, not hypotheticals:
# * Sonarr and Radarr store a download-client username/password in their OWN databases. Both
# tested valid against the then-current temporary password; both would have started failing at
# the NEXT qbittorrent pod restart, silently breaking Completed Download Handling (the arrs
# report a failed client, not a failed import).
# * The human operator had no stable login: every restart meant reading the pod log.
# Provided as apps/wave-0/qbittorrent-bootstrap-secret.sops.yaml (SOPS/age, decrypted by Flux) —
# same shape and naming as gitea-bootstrap, so `render.py --emit-waves` places it in this wave.
- name: QBT_WEBUI_USERNAME
valueFrom:
secretKeyRef:
name: qbittorrent-bootstrap
key: webui-username
- name: QBT_WEBUI_PASSWORD
valueFrom:
secretKeyRef:
name: qbittorrent-bootstrap
key: webui-password
lifecycle:
# LEVEL-TRIGGERED CREDENTIAL ASSERTION, run on EVERY container start.
#
# WHY A postStart HOOK AND NOT A CronJob RECONCILER: the repo's idiom for "state Flux cannot reach
# directly" is the level-triggered CronJob (platform/keycloak/client-secret-reconciler.yaml,
# apps/wave-0/nextcloud-db.yaml). That idiom's PRINCIPLE — desired state lives in git and is
# RE-ASSERTED unconditionally, never set once — is exactly what is applied here, but the trigger is
# the container start rather than a clock, because the failure this closes IS a container start:
# * The credential is read by qbittorrent-nox ONCE, at process start, from a file on the config
# PVC. A 15-minute cron would therefore leave a 0-15 minute window after every restart, PVC
# restore or rebuild in which the app serves a random password and both arrs are broken. The
# hook has no such window: it runs before/with the very start that would otherwise regenerate.
# * A CronJob would also have to reach the conf from OUTSIDE the pod — mounting an RWO Longhorn
# PVC that the app already holds, or `kubectl exec`-ing with RBAC it would need granting — and
# then edit a file UNDER the running process, whose own save-on-exit can overwrite the edit.
# In-container, before the service starts, none of that exists.
# * It needs no ServiceAccount, no RBAC, no NetworkPolicy and no API token: strictly less
# privilege than the CronJob alternative (R-SEC/least-privilege).
# The gitea entry's postStart is the same pattern (idempotent bootstrap of a credential the app
# keeps in its own state), and the schema documents this hook for exactly that use.
#
# DURABILITY, the whole point — all three paths end with the SAME credential:
# pod restart -> conf on the PVC already holds it; the hook verifies and writes NOTHING.
# config-PVC restore from an older backup -> restore requires the pod to be recreated, so the
# hook runs and re-asserts over whatever the snapshot contained.
# rebuild from git -> empty PVC; the image copies /defaults/qBittorrent.conf, the hook writes the
# credential into it before the service is serving. Nothing outside git.
#
# WHAT IT DOES NOT DO: it never weakens authentication. `WebUI\AuthSubnetWhitelist` (bypassing auth
# for the pod CIDR) was REJECTED: it leaves the human login broken, and makes qBittorrent's auth
# depend on network policy staying correct forever. The WebUI keeps demanding a password; that
# password is simply now known, fixed, and reproducible from git.
postStart:
exec:
command:
- /bin/bash
- -c
- |
# Assert the durable WebUI credential in the config PVC's qBittorrent.conf, then PROVE the
# running WebUI accepts it. Idempotent: the steady-state path writes nothing at all.
#
# NOTE the `$${...}` escapes below — load-bearing. apps-wave-0 declares
# postBuild.substituteFrom cluster-vars, so Flux runs envsubst over this whole manifest at
# RECONCILE time, and a SINGLE-dollar brace expansion is not a cluster-var, so envsubst
# would blank it. Bare `$VAR` and `$(cmd)` are left alone (see catalog/gitea.yaml for the
# incident that taught this, and render.py's assert_substitution_safe, which enforces it).
set -u
CONF=/config/qBittorrent/qBittorrent.conf
SVC=/run/service/svc-qbittorrent
PORT=$${WEBUI_PORT:-8080}
# A postStart hook's stdout/stderr goes to the KUBELET, not to `kubectl logs` — so a hook
# that only echoes is effectively silent (gitea's hits the same wall and writes a marker
# file for it). Every outcome therefore also lands, timestamped, on the persistent config
# volume: /config/qbittorrent-webui-cred.log is where an operator looks to see whether the
# credential from git was asserted and accepted on this start.
log() {
echo "[qbt-webui-cred] $*"
printf '%s [qbt-webui-cred] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$*" \
>> /config/qbittorrent-webui-cred.log 2>/dev/null || true
}
# (1) The image's init-qbittorrent-config copies /defaults/qBittorrent.conf onto the PVC
# before it starts the service. Wait (bounded, ~30s) so we EDIT that file instead of
# racing to create one it would then not overwrite.
n=0
while [ ! -s "$CONF" ] && [ "$n" -lt 120 ]; do n=$((n + 1)); sleep 0.25; done
waited=0
attempt=0
while [ "$attempt" -lt 3 ]; do
attempt=$((attempt + 1))
# (2) Converge the conf. 0 = already correct (steady state: NO write, NO re-salt),
# 10 = rewritten, anything else = could not write.
python3 - "$CONF" <<'PY'
import base64, hashlib, hmac, os, stat, sys
conf = sys.argv[1]
user = os.environ.get("QBT_WEBUI_USERNAME", "")
pw = os.environ.get("QBT_WEBUI_PASSWORD", "")
if not user or not pw:
sys.stderr.write("[qbt-webui-cred] credential env is empty - refusing to touch the conf\n")
raise SystemExit(1)
# qBittorrent's OWN parameters (base/utils/password.cpp): PBKDF2-HMAC-SHA512, 100000
# iterations, 16-byte random salt, 64-byte digest, stored as
# WebUI\Password_PBKDF2="@ByteArray(<base64 salt>:<base64 digest>)"
# Get any one of them wrong and the app simply rejects every login.
ITERATIONS, DIGEST_LEN, SALT_LEN = 100000, 64, 16
UKEY, PKEY, SECTION = "WebUI\\Username", "WebUI\\Password_PBKDF2", "[Preferences]"
def matches(raw):
"""True iff the stored digest is one OUR password produces (salt is read from it)."""
v = raw.strip().strip('"')
if v.startswith("@ByteArray(") and v.endswith(")"):
v = v[len("@ByteArray("):-1]
try:
salt_b64, digest_b64 = v.split(":", 1)
salt = base64.b64decode(salt_b64, validate=True)
digest = base64.b64decode(digest_b64, validate=True)
except Exception:
return False
if not salt or not digest:
return False
return hmac.compare_digest(
hashlib.pbkdf2_hmac("sha512", pw.encode(), salt, ITERATIONS, len(digest)), digest)
try:
lines = open(conf, encoding="utf-8", errors="replace").read().splitlines()
except FileNotFoundError:
lines = []
def value_of(key):
for i, line in enumerate(lines):
if line.startswith(key + "="):
return i, line.split("=", 1)[1]
return -1, None
ui, uval = value_of(UKEY)
pi, pval = value_of(PKEY)
if uval == user and pval is not None and matches(pval):
raise SystemExit(0) # converged - leave the file (and its salt) alone
salt = os.urandom(SALT_LEN)
digest = hashlib.pbkdf2_hmac("sha512", pw.encode(), salt, ITERATIONS, DIGEST_LEN)
stored = base64.b64encode(salt).decode() + ":" + base64.b64encode(digest).decode()
desired = [UKEY + "=" + user, PKEY + '="@ByteArray(' + stored + ')"']
out = [line for i, line in enumerate(lines) if i not in (ui, pi)]
if SECTION in out:
at = out.index(SECTION) + 1
else:
if out and out[-1].strip():
out.append("")
out.append(SECTION)
at = len(out)
out[at:at] = desired
try:
os.makedirs(os.path.dirname(conf) or ".", exist_ok=True)
tmp = conf + ".webui-cred.new"
with open(tmp, "w", encoding="utf-8") as fh:
fh.write("\n".join(out) + "\n")
fh.flush()
os.fsync(fh.fileno())
# This hook runs as ROOT; the service runs as PUID:PGID. A root-owned 0600 conf would
# cost the app the ability to save its own settings, so carry the ownership over.
try:
st = os.stat(conf)
os.chown(tmp, st.st_uid, st.st_gid)
os.chmod(tmp, stat.S_IMODE(st.st_mode))
except FileNotFoundError:
os.chown(tmp, int(os.environ.get("PUID", "1000")),
int(os.environ.get("PGID", "1000")))
os.chmod(tmp, 0o664)
os.replace(tmp, conf) # atomic: the app never reads a half-written conf
except OSError as exc:
sys.stderr.write("[qbt-webui-cred] cannot write %s: %s\n" % (conf, exc))
raise SystemExit(1)
raise SystemExit(10)
PY
rc=$?
if [ "$rc" = 0 ]; then
log "conf already holds the durable credential for '$QBT_WEBUI_USERNAME' (no write)"
elif [ "$rc" = 10 ]; then
log "asserted WebUI Username + Password_PBKDF2 into $CONF"
# postStart runs CONCURRENTLY with the s6 init chain, so qbittorrent-nox may ALREADY
# have read the old, credential-less conf and be serving a random temporary password.
# Restart the SUPERVISED SERVICE (not the pod) so it re-reads what was just written.
# Nothing to do when the service has not started yet - it will read the new file.
while [ "$waited" -lt 90 ] && [ "$(s6-svstat -u "$SVC" 2>/dev/null)" != "true" ]; do
waited=$((waited + 1)); sleep 1
done
if [ "$(s6-svstat -u "$SVC" 2>/dev/null)" = "true" ]; then
log "qbittorrent-nox was already running - restarting it to load the credential"
s6-svc -r "$SVC" || log "s6-svc -r failed (continuing to verify anyway)"
sleep 3
fi
else
log "FAILED to write the credential into $CONF (helper exit $rc). The WebUI will keep" >&2
log "minting a random password every start and BOTH arrs will break. Fix and restart." >&2
exit 0
fi
# (3) GROUND TRUTH: the WebUI itself must accept it. Wait for the API to answer at all
# (shared ~90s budget across attempts), then spend exactly ONE login per attempt -
# at most 3 rejected logins, which stays under qBittorrent's default ban threshold.
while [ "$waited" -lt 90 ]; do
code=$(curl -s -o /dev/null -m 3 -w '%{http_code}' "http://127.0.0.1:$PORT/" 2>/dev/null)
[ -n "$code" ] && [ "$code" != "000" ] && break
waited=$((waited + 1)); sleep 1
done
body=$(curl -sS -m 10 \
--data-urlencode "username=$QBT_WEBUI_USERNAME" \
--data-urlencode "password=$QBT_WEBUI_PASSWORD" \
"http://127.0.0.1:$PORT/api/v2/auth/login" 2>/dev/null)
if [ "$body" = "Ok." ]; then
log "VERIFIED: the WebUI accepts the durable credential for '$QBT_WEBUI_USERNAME'"
exit 0
fi
log "WebUI rejected the durable credential (login said '$body') - reasserting" >&2
done
log "GAVE UP after $attempt attempts: the WebUI does not accept the credential from git." >&2
# A postStart hook MUST exit 0 - a non-zero exit makes the kubelet KILL the container
# (FailedPostStartHook -> CrashLoopBackOff), turning a credential fault into an outage.
# Both failure paths above therefore log LOUDLY and exit 0; the `up` SLI still watches the
# app, and the arrs' own download-client health check surfaces a broken credential.
exit 0
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