Files
felhom.eu/documentation/audits/qbittorrent-2026-10-12/tools/boxwalk_newapps.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

168 lines
7.8 KiB
Python

#!/usr/bin/env python3
"""boxwalk_newapps.py — qbittorrent and transmission installed on guest 9202 THROUGH THE PRODUCT.
Closes the checklist items that only a real install can close: 3.5 (the install window — what a
stranger reaches), 3.6 (lock-out: can a stranger lock the household out), 3.9 (sign in the way each
client does, through traefik over https), 3.10 (the visitor's address), 2.6 (remove with data / keep
data), 9.2 (a fresh install as household and as stranger).
EVIDENCE, NOT PRODUCT. It presses the buttons a person presses — POST /api/stacks/<n>/deploy,
GET /api/stacks/<n>, POST /api/stacks/<n>/remove — and reaches each app on its own public name.
Reuses the proven harness of 2026-09-30 (`pg-last-six-2026-09-30/tools/walk.py`) for the session, the
CSRF, the setup gate and the deploy-field filling.
Runs ON DOOPLEX. 9202 must already be pointed at the drill catalog (09 §6.5).
"""
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", os.environ.get("SC", "/tmp/newapps-walk"))
os.makedirs(os.environ["SC"], exist_ok=True)
import walk as w # noqa: E402
EV = os.environ.get("EV", "/mnt/5_hdd/felhom.eu/git/felhom.eu/documentation/audits/qbittorrent-2026-10-12/box")
os.makedirs(EV, exist_ok=True)
APPS = [
# (template name, subdomain, the app's own sign-in probe)
("qbittorrent", "torrent", {
"login_path": "/api/v2/auth/login",
"user_field": "username", "pass_field": "password",
"pw_env": "QBT_ADMIN_PASSWORD",
"unauth_probe": "/api/v2/app/preferences", # must refuse a stranger
}),
("transmission", "transmission", {
"login_path": "/transmission/rpc",
"basic": True,
"pw_env": "TR_ADMIN_PASSWORD",
"unauth_probe": "/transmission/rpc",
}),
]
OUT = {}
# the scratch guest's drive, as the controller names it
DRIVE_ROOT = os.environ.get("DRIVE_ROOT", "/mnt/felhom-drives/scratch_hdd")
def rec(app, key, value):
OUT.setdefault(app, {})[key] = value
w.say(f" [{app}] {key}: {str(value)[:160]}")
def stranger(sub, path):
"""A stranger: the app's public name, no gate cookie, no credentials."""
r = w.sh(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
"-o", "/dev/null", "-w", "%{http_code}", f"{w.BASE}{path}"])
return (r.stdout or "").strip()
def main():
w.login()
w.say("logged in to the 9202 dashboard")
# the box must have the drill catalog's templates
code, d = w.ctl("POST", "/api/sync", {})
w.say(f"sync -> {code} {str(d)[:120]}")
code, d = w.ctl("POST", "/api/stacks/rescan", {})
w.say(f"rescan -> {code} {str(d)[:120]}")
for name, sub, spec in APPS:
w.say(f"=== {name} ===")
st = w.stack(name)
if not st:
rec(name, "template_visible", False)
continue
rec(name, "template_visible", True)
# ---- 9.2 / 3.5: deploy as the household
# HDD_PATH must be a DRIVE, not a folder on it: the controller refuses the folder with
# „Az alkalmazas adatainak helye egy meghajto lehet, nem egy mappa a meghajton belul"
# (409, measured 2026-10-12 19:02). walk.py's deploy_values builds <drive>/userdata/<app>
# for a `type: path` field, which is right for an app that wants a folder and wrong here.
ok = w.deploy(name, sub, extra_values={"HDD_PATH": DRIVE_ROOT})
rec(name, "deploy", ok)
if not ok:
continue
pw = (w.GENERATED.get(name) or {}).get(spec["pw_env"], "")
rec(name, "generated_password_length", len(pw))
answered = w.wait_app(sub, "/", tries=40, delay=5)
rec(name, "app_answers_on_its_public_name", answered)
# ---- 3.5: what a STRANGER reaches in the install window
rec(name, "stranger_root", stranger(sub, "/"))
rec(name, "stranger_api", stranger(sub, spec["unauth_probe"]))
# ---- 3.9: the household signs in through traefik over https
if spec.get("basic"):
r = w.sh(["curl", "-sSk", "--max-time", "25", "-H", f"Host: {sub}.{w.DOMAIN}",
"-u", f"admin:{pw}", "-o", "/dev/null", "-w", "%{http_code}",
f"{w.BASE}{spec['login_path']}"])
rec(name, "household_signin_through_traefik", (r.stdout or "").strip())
r = w.sh(["curl", "-sSk", "--max-time", "25", "-H", f"Host: {sub}.{w.DOMAIN}",
"-u", "admin:definitely-not-it", "-o", "/dev/null", "-w", "%{http_code}",
f"{w.BASE}{spec['login_path']}"])
rec(name, "wrong_password_through_traefik", (r.stdout or "").strip())
else:
rc, code, body = w.app_curl(
sub, spec["login_path"], "-H", f"Referer: https://{sub}.{w.DOMAIN}",
"--data-urlencode", f"{spec['user_field']}=admin",
"--data-urlencode", f"{spec['pass_field']}={pw}", method="POST")
rec(name, "household_signin_through_traefik", f"{code} {body[:40]}")
rc, code, body = w.app_curl(
sub, spec["login_path"], "-H", f"Referer: https://{sub}.{w.DOMAIN}",
"--data-urlencode", f"{spec['user_field']}=admin",
"--data-urlencode", f"{spec['pass_field']}=definitely-not-it", method="POST")
rec(name, "wrong_password_through_traefik", f"{code} {body[:40]}")
# ---- 3.6: can a stranger lock the household out? (the qBittorrent ban, through traefik)
codes = []
for i in range(6):
if spec.get("basic"):
r = w.sh(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
"-u", f"admin:wrong{i}", "-o", "/dev/null", "-w", "%{http_code}",
f"{w.BASE}{spec['login_path']}"])
codes.append((r.stdout or "").strip())
else:
_, code, _ = w.app_curl(sub, spec["login_path"],
"-H", f"Referer: https://{sub}.{w.DOMAIN}",
"--data-urlencode", "username=admin",
"--data-urlencode", f"password=wrong{i}", method="POST")
codes.append(code)
rec(name, "six_wrong_tries", codes)
# and the household, right after, from the same address traefik presents
if spec.get("basic"):
r = w.sh(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
"-u", f"admin:{pw}", "-o", "/dev/null", "-w", "%{http_code}",
f"{w.BASE}{spec['login_path']}"])
rec(name, "household_right_after_the_wrong_tries", (r.stdout or "").strip())
else:
_, code, body = w.app_curl(sub, spec["login_path"],
"-H", f"Referer: https://{sub}.{w.DOMAIN}",
"--data-urlencode", "username=admin",
"--data-urlencode", f"password={pw}", method="POST")
rec(name, "household_right_after_the_wrong_tries", f"{code} {body[:40]}")
# ---- 3.10: what address the app thinks the visitor has
logs = w.guest(f"docker logs --tail 40 {name} 2>&1 | tail -20")
open(f"{EV}/{name}-app-log.txt", "w").write(logs)
rec(name, "app_log_captured", True)
# where the data landed, as the box sees it
paths = w.guest(f"ls -la {w.DRIVE}/media/{name} 2>&1 | head -6; "
f"docker volume ls --format '{{{{.Name}}}}' | grep -i {name} | head -4")
open(f"{EV}/{name}-paths.txt", "w").write(paths)
json.dump(OUT, open(f"{EV}/boxwalk-results.json", "w"), indent=2, ensure_ascii=False)
open(f"{EV}/boxwalk-log.txt", "w").write("\n".join(w.LOG))
print(json.dumps(OUT, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()