#!/usr/bin/env python3 """walk_prowlarr.py — Prowlarr on scratch guest 9202 THROUGH THE PRODUCT, from the drill catalog (R-1048, 2026-10-15). Runs ON DOOPLEX with TARGET=9202 and SC=<0600 dir holding .ctlpw>. 9202 must already be pointed at the drill mirror, whose prowlarr template is the live candidate at the PREVIOUS image (version-2.5.2.5491). Presses only what a person presses (box_walk.py): deploy, the setup gate as household and stranger, Prowlarr's own first-admin and sign-in routes through traefik over https, „Kész", „Mentés most", the drill bump + the guarded Update, remove. Closes NEW-APP-CHECKLIST 3.1 3.3 3.5 3.6 3.9 3.10 2.5 2.6 4.3 6.2(box) 6.3 9.2. Writes the evidence to EV and the box verdict to EV/prowlarr/box-verdict-prowlarr.json (the ladder writer's input). The household password it creates is kept only in SC (0600) for the life of the run; the app's API key is never printed. """ import json import os import re import secrets import sys import time sys.path.insert(0, "/mnt/5_hdd/felhom.eu/git/app-catalog-felhom.eu/scripts") import box_walk as w # noqa: E402 import upgrade_fixtures_box as fx # noqa: E402 EV = os.environ["EV"] APP, SUB = "prowlarr", "prowlarr" FROM = "lscr.io/linuxserver/prowlarr:version-2.5.2.5491" TO = "lscr.io/linuxserver/prowlarr:version-2.6.5.5623" os.makedirs(os.path.join(EV, APP), exist_ok=True) LOG = open(os.path.join(EV, "walk.txt"), "a", buffering=1) RES = {} _lib_say = w.say def say(*a): _lib_say(*a) LOG.write(" ".join(str(x) for x in a) + "\n") w.say = say # the library's own lines land in the same record (the original is kept above — no recursion) def stranger(path, method="GET", data=None, accept="application/json", extra=()): """A visitor with NO dashboard session and NO gate cookie, on the app's public name over https.""" args = ["curl", "-sSk", "--max-time", "20", "-o", "/tmp/pw-body.txt", "-D", "-", "-H", f"Host: {SUB}.{w.DOMAIN}", "-H", f"Accept: {accept}"] + list(extra) if method != "GET": args += ["-X", method] if data is not None: args += ["--data-binary", "@-"] r = w.sh(args + [f"{w.BASE}{path}"], inp=data) head = r.stdout or "" st = re.match(r"HTTP/\S+ (\d+)", head) loc = re.search(r"(?im)^location:\s*(\S+)", head) body = w.sh(["head", "-c", "200", "/tmp/pw-body.txt"]).stdout.replace("\n", " ") return (st.group(1) if st else "000"), (loc.group(1) if loc else ""), body, head def household(path, method="GET", body=None, headers=()): ext = [] for h in headers: ext += ["-H", h] if body is not None: ext += ["-H", "Content-Type: application/json"] rc, code, out = w.app_curl(SUB, path, *ext, method=method, data=json.dumps(body) if body is not None else None) return code, out def main(): w.login() st = w.stack(APP) if st.get("deployed"): sys.exit(say("prowlarr is installed on 9202 — STOP, not a fresh install") or 1) # ── 3.5 + 4.3: install, and a stranger polls the public name once a second through the install window ── t0 = time.time() code, d = w.ctl("POST", f"/api/stacks/{APP}/deploy", {"values": {"DOMAIN": w.DOMAIN, "SUBDOMAIN": SUB}}) say(f"[1] POST /api/stacks/{APP}/deploy -> {code} {json.dumps(d, ensure_ascii=False)[:160]}") w.DEPLOYED_THIS_RUN.add(APP) seen = {} for i in range(90): for p in ("/", "/initialize.json", "/api/v1/system/status"): c, loc, body, _ = stranger(p, accept="text/html" if p == "/" else "application/json") key = f"{p} -> {c} {('loc=' + loc[:60]) if loc else ''} {'API-KEY-IN-BODY' if 'apiKey' in body else ''}".strip() seen[key] = seen.get(key, 0) + 1 s = w.stack(APP) if s.get("deployed") and s.get("state") == "running" and i > 20: break time.sleep(1) say(f"[1] install window, a stranger on the public name ({round(time.time() - t0)} s, distinct answers):") for k, n in sorted(seen.items()): say(f" {n:3d}x {k}") RES["install_window"] = seen s = w.stack(APP) say(f"[1] state={s.get('state')} health={s.get('health')} restarts: " + w.guest("docker inspect prowlarr -f '{{.RestartCount}} started={{.State.StartedAt}} health={{.State.Health.Status}}'").strip()) say("[1] the first-start seed line: " + w.guest("docker logs prowlarr 2>&1 | grep -m1 felhom").strip()) say("[1] config.xml (key redacted): " + w.guest("docker exec prowlarr cat /config/config.xml | sed -E 's#[^<]+##' | tr '\\n' ' '").strip()) # ── 3.1: the household reaches the first-run screen through the gate; its key comes from the app's own page ── code, out = household("/initialize.json") m = re.search(r'"?apiKey"?\s*:\s*"([0-9a-fA-F]{20,})"', out) say(f"[2] household GET /initialize.json through the gate -> {code}, carries the API key: {bool(m)}") key = m.group(1) if m else "" code, out = household("/api/v1/config/host", headers=(f"X-Api-Key: {key}",)) hc = json.loads(out) if code == "200" else {} say(f"[2] GET /api/v1/config/host -> {code}: authenticationMethod={hc.get('authenticationMethod')} " f"authenticationRequired={hc.get('authenticationRequired')} analyticsEnabled={hc.get('analyticsEnabled')}") user, pw = "felhom-test", "Pw-" + secrets.token_urlsafe(14) open(os.path.join(w.SC, "prowlarr-household.json"), "w").write(json.dumps({"user": user, "password": pw})) os.chmod(os.path.join(w.SC, "prowlarr-household.json"), 0o600) hc.update({"authenticationMethod": "forms", "username": user, "password": pw, "passwordConfirmation": pw}) code, out = household("/api/v1/config/host", method="PUT", body=hc, headers=(f"X-Api-Key: {key}",)) say(f"[2] the household makes its first account (PUT /api/v1/config/host, forms) -> {code}") RES["first_admin"] = code time.sleep(8) w.wait_app(SUB, "/ping", want=("200",), tries=24) # ── 3.9: sign in the way the browser does, through traefik over https (still behind the gate) ── for label, p in (("right", pw), ("wrong", "nope-" + secrets.token_hex(4))): rc, code, out = w.app_curl(SUB, "/login?returnUrl=%2F", "-H", "Content-Type: application/x-www-form-urlencoded", "-D", "-", method="POST", data=f"username={user}&password={p}&rememberMe=on") loc = re.search(r"(?im)^location:\s*(\S+)", out) ck = re.search(r"(?im)^set-cookie:\s*([A-Za-z]+Auth)=", out) say(f"[3] sign in ({label} password) POST /login -> {code} loc={loc.group(1) if loc else '-'} auth-cookie={ck.group(1) if ck else 'none'}") RES[f"signin_{label}"] = [code, loc.group(1) if loc else "", bool(ck)] # ── 6.1 C1 + 2.5: seed through the app's own API, read back, back it up through the product ── fxp = fx.FIXTURES[APP] tok = fxp.seed(w, SUB, say) ok1 = bool(tok) and fxp.verify(w, SUB, tok, say) say(f"[4] seed {tok} read back before the update: {ok1}") bk = w.backup_now(APP) say(f"[4] „Mentés most” -> {json.dumps(bk, ensure_ascii=False)[:200]}") # ── 3.3: „Kész" opens the gate; then a stranger meets the APP's own sign-in, never the app ── code, d = w.ctl("POST", f"/apps/{APP}/setup-gate/open") say(f"[5] the household's „Kész, beállítottam” -> {code} {json.dumps(d, ensure_ascii=False)[:120]}") time.sleep(5) for p, acc in (("/", "text/html"), ("/initialize.json", "application/json"), ("/api/v1/system/status", "application/json"), ("/api/v1/indexer", "application/json")): c, loc, body, _ = stranger(p, accept=acc) say(f"[5] stranger after the gate opened: GET {p} -> {c} {('loc=' + loc[:70]) if loc else ''} apiKey-in-body={'apiKey' in body}") RES[f"stranger_open_{p}"] = [c, loc, "apiKey" in body] # ── 3.6 + 3.10: six wrong passwords from a stranger; then the household; and what address the app logged ── for i in range(6): c, loc, _b, _ = stranger("/login?returnUrl=%2F", method="POST", data=f"username={user}&password=wrong{i}", accept="text/html", extra=("-H", "Content-Type: application/x-www-form-urlencoded")) say(f"[6] stranger wrong password #{i + 1} -> {c} loc={loc[:60]}") c, loc, _b, head = stranger("/login?returnUrl=%2F", method="POST", data=f"username={user}&password={pw}", accept="text/html", extra=("-H", "Content-Type: application/x-www-form-urlencoded")) say(f"[6] the household's right password right after (same address) -> {c} loc={loc[:60]} " f"auth-cookie={'yes' if re.search(r'(?im)^set-cookie:.*Auth=', head) else 'no'}") say("[6] what the app logged for the failures (address): " + w.guest("docker exec prowlarr sh -c \"grep -h -i 'auth-failure\\|Auth-' /config/logs/prowlarr*.txt | tail -3\"").strip().replace("\n", " | ")[:600]) # ── 6.2/6.3 box: the drill bump and the product's guarded Update; the seed read back after ── # the drill bump, staged by explicit path (box_walk.drill_bump runs `git add -A`, which this task forbids) D = w.DRILL comp, fy = f"{D}/templates/{APP}/docker-compose.yml", f"{D}/templates/{APP}/.felhom.yml" w.sh(["git", "-C", D, "pull", "-q", "--rebase", "origin", "main"], timeout=120) c_ = open(comp).read() if f"image: {FROM}" not in c_: sys.exit(say(f"[7] FROM {FROM} not in the drill compose — STOP") or 1) open(comp, "w").write(c_.replace(f"image: {FROM}", f"image: {TO}")) # FIXED after run 1: this line used to be `open(fy, "w").write(re.sub(..., open(fy).read(), ...))`, which truncates the # file BEFORE it is read — run 1's bump commit (drill f2b0cc1) emptied the drill's .felhom.yml, so run 1's Update ran # without the app's metadata. Run 3 (walk_restore.py, box/run3/) re-proves the step with the full file. fy_text = open(fy).read() open(fy, "w").write(re.sub(r'^catalog_since:.*$', 'catalog_since: "' + time.strftime("%Y-%m-%d") + '"', fy_text, count=1, flags=re.M)) w.sh(["git", "-C", D, "add", f"templates/{APP}/docker-compose.yml", f"templates/{APP}/.felhom.yml"]) w.sh(["git", "-C", D, "commit", "-q", "-m", f"DRILL {APP}: {FROM} -> {TO} (R-1048 box proof)"]) r_ = w.sh(["git", "-C", D, "push", "-q", "origin", "main"], timeout=120) say(f"[7] drill commit {w.sh(['git', '-C', D, 'rev-parse', '--short=12', 'HEAD']).stdout.strip()}: {FROM} -> {TO} (push rc={r_.returncode})") # the mirror on 9202 is what the box reads: reload it from the drill repo import subprocess subprocess.run([sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), "repoint9202.py"), "mirror"], check=True, env=dict(os.environ)) w.sync_rescan(expect_app=APP, expect_ref=TO) say(f"[7] badges: {json.dumps(w.badges(APP), ensure_ascii=False)[:300]}") up = w.press_update(APP) say(f"[7] guarded Update: {json.dumps(up, ensure_ascii=False)[:400]}") ok2 = bool(tok) and fxp.verify(w, SUB, tok, say) obs = w.observables(APP) say(f"[7] after: {json.dumps(obs, ensure_ascii=False)[:400]}") s = w.stack(APP) healthy = s.get("state") == "running" verdict = {"app": APP, "venue": "box 9202 (drill catalog, controller 0.304.0), the product's guarded Update", "from": {APP: FROM}, "to": {APP: TO}, "verdict": "proven" if (ok1 and ok2 and up.get("final_phase") == "done" and healthy) else "failed", "seed_read_before": ok1, "seed_read_after": ok2, "healthy_after": healthy, "duration_s": up.get("duration_s"), "final_phase": up.get("final_phase"), "measured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "evidence": "felhom.eu/documentation/audits/prowlarr-2026-10-15/box/walk.txt"} json.dump(verdict, open(os.path.join(EV, APP, "box-verdict-prowlarr.json"), "w"), indent=2) say(f"[8] box verdict {verdict['verdict']}") # ── 2.6: remove through the product; nothing left ── if os.environ.get("KEEP") != "1": w.remove(APP) say("[9] after remove: " + w.guest("echo containers=$(docker ps -aq -f name=^prowlarr$ | wc -l) volumes=$(docker volume ls -q | grep -c prowlarr) stackdir=$(ls -d /opt/docker/stacks/prowlarr 2>/dev/null | wc -l)").strip()) json.dump(RES, open(os.path.join(EV, "walk-results.json"), "w"), indent=2, ensure_ascii=False) main()