#!/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()