Files
felhom.eu/documentation/audits/qbittorrent-2026-10-12/tools/verify_fixes.py
T
admin 5148fe1e11
gates / gates (push) Successful in 6m29s
audit: download clients for the catalog - qbittorrent + transmission measured twice, five defects fixed
The arr stack had no download client: radarr/sonarr/seerr decide what to fetch
and hand it to one, and there was none. Both templates are written and measured
on the bench (LXC 9401) and on 9202 through the product. Neither is published -
the onboarding records are not complete and the gate refuses a template without
one.

Found by measuring, each fixed:
- qBittorrent was unreachable on its own public name: WebUI\ServerDomains takes
  SEMICOLONS; a comma list answers 401 Invalid Host header to everything.
  Undocumented upstream; measured four ways through the app's own API.
- Both clients asked the household's router to open a port (qBittorrent
  UPnP/NAT-PMP ON, Transmission port-forwarding-enabled true). The box opens no
  port at home. qBittorrent ignores the seeded Session\UseUPnP=false at first
  start but honours the same setting through its API, so it goes through
  after_install: ON at 17:21:51, OFF at 17:21:56.
- Transmission could never go healthy (401 on every path, curl -f exit 22).
- Transmission's host whitelist refused its own healthcheck.
- The apparent lock-out was a measurement artefact: with stranger and household
  on two different addresses, the stranger is banned and the household signs in
  at the same instant (204). qBittorrent reads the real visitor address.

Downloads work with no inbound port, measured: 34 seeds / 4 MB/s (qBittorrent),
48 peers (Transmission), both complete, port confirmed closed.

9202 is back on the live catalog with both apps removed; bench 9401 stopped as
found; the live catalog's main untouched.
2026-10-12 19:38:23 +02:00

116 lines
5.3 KiB
Python

#!/usr/bin/env python3
"""verify_fixes.py — redeploy both apps on 9202 after the UPnP and host-list fixes, and prove:
1. neither app asks the household's router for a port any more (3.x / 01 §7)
2. the controller's probe is no longer refused for a bad Host header
3. the lock-out is per VISITOR, not per app: a stranger's wrong tries from ONE address do not shut
the household out from ANOTHER (checklist 3.6 + 3.10)
The third one is why this file exists: the first box walk ran the stranger and the household from the
same machine, so a shared ban could not be told apart from a per-visitor one. Here the stranger is
DooPlex (192.168.0.180) and the household is the guest itself — two addresses, one app.
Runs ON DOOPLEX, after the drill catalog carries the fixed templates.
"""
import json
import os
import sys
import time
TOOLS = "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/pg-last-six-2026-09-30/tools"
sys.path.insert(0, TOOLS)
os.environ.setdefault("SC", "/tmp/newapps-walk")
import walk as w # noqa: E402
EV = "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/qbittorrent-2026-10-12/box"
DRIVE_ROOT = "/mnt/felhom-drives/scratch_hdd"
OUT = {}
def rec(app, k, v):
OUT.setdefault(app, {})[k] = v
w.say(f" [{app}] {k}: {str(v)[:150]}")
def redeploy(name, sub):
st = w.stack(name)
if st.get("deployed"):
w.ctl("POST", f"/api/stacks/{name}/stop", {})
time.sleep(10)
code, d = w.ctl("POST", f"/api/stacks/{name}/remove",
{"remove_hdd_data": True, "remove_backups": True})
w.say(f" removed {name} -> {code} {str(d)[:140]}")
time.sleep(8)
return w.deploy(name, sub, extra_values={"HDD_PATH": DRIVE_ROOT})
def main():
w.login()
w.ctl("POST", "/api/sync", {})
w.ctl("POST", "/api/stacks/rescan", {})
for name, sub in (("qbittorrent", "torrent"), ("transmission", "transmission")):
w.say(f"=== {name} ===")
rec(name, "redeployed", redeploy(name, sub))
pw = (w.GENERATED.get(name) or {}).get(
"QBT_ADMIN_PASSWORD" if name == "qbittorrent" else "TR_ADMIN_PASSWORD", "")
rec(name, "app_answers", w.wait_app(sub, "/", tries=40, delay=5))
# ---- 1. no port asked of the router
if name == "qbittorrent":
log = w.guest("docker exec qbittorrent sh -c "
"'cat /config/qBittorrent/data/logs/qbittorrent.log' 2>/dev/null | head -40")
upnp = [l for l in log.splitlines() if "UPnP" in l or "NAT-PMP" in l]
rec(name, "upnp_lines_in_the_app_log", upnp[:3])
badhost = [l for l in log.splitlines() if "Invalid Host header" in l]
rec(name, "invalid_host_header_lines", len(badhost))
open(f"{EV}/{name}-applog-after-fix.txt", "w").write(log)
else:
s = w.guest("docker exec transmission sh -c "
"'grep -E \"port-forwarding-enabled|peer-port\\\"|dht-enabled\" /config/settings.json'")
rec(name, "settings_after_fix", s.strip().replace("\n", " "))
open(f"{EV}/{name}-settings-after-fix.txt", "w").write(s)
# ---- 2 + 3. the lock-out, from TWO different addresses
login_path = ("/api/v2/auth/login" if name == "qbittorrent" else "/transmission/rpc")
def as_stranger(password):
"""from DooPlex"""
if name == "qbittorrent":
r = w.sh(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
"-H", f"Referer: https://{sub}.{w.DOMAIN}",
"--data-urlencode", "username=admin",
"--data-urlencode", f"password={password}",
"-o", "/dev/null", "-w", "%{http_code}", f"{w.BASE}{login_path}"])
else:
r = w.sh(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
"-u", f"admin:{password}", "-o", "/dev/null", "-w", "%{http_code}",
f"{w.BASE}{login_path}"])
return (r.stdout or "").strip()
def as_household(password):
"""from INSIDE the guest — a different source address, through the same traefik"""
if name == "qbittorrent":
cmd = (f"curl -sSk --max-time 20 -H 'Host: {sub}.{w.DOMAIN}' "
f"-H 'Referer: https://{sub}.{w.DOMAIN}' "
f"--data-urlencode username=admin --data-urlencode password={password} "
f"-o /dev/null -w '%{{http_code}}' https://127.0.0.1{login_path}")
else:
cmd = (f"curl -sSk --max-time 20 -H 'Host: {sub}.{w.DOMAIN}' "
f"-u admin:{password} -o /dev/null -w '%{{http_code}}' "
f"https://127.0.0.1{login_path}")
return w.guest(cmd).strip().splitlines()[-1] if w.guest(cmd).strip() else ""
rec(name, "household_before_any_wrong_try", as_household(pw))
codes = [as_stranger(f"wrong{i}") for i in range(6)]
rec(name, "stranger_six_wrong_tries_from_192_168_0_180", codes)
rec(name, "stranger_right_after", as_stranger(pw))
rec(name, "HOUSEHOLD_right_after_from_its_own_address", as_household(pw))
json.dump(OUT, open(f"{EV}/verify-fixes.json", "w"), indent=2, ensure_ascii=False)
print(json.dumps(OUT, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()