5148fe1e11
gates / gates (push) Successful in 6m29s
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.
116 lines
5.3 KiB
Python
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()
|