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.
168 lines
7.8 KiB
Python
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()
|