Files
felhom.eu/documentation/audits/usenet-2026-10-12/tools/boxwalk_usenet.py
T
admin 52615d9139
gates / gates (push) Successful in 6m34s
Website + evidence: the Usenet pair (R-988 closed), and the drill route filed as R-989
The catalog published SABnzbd and NZBGet today (app-catalog 8ee687e). Here:

- two cards in the media category on both language sets, each with the project's own logo and
  each saying IN BOLD that a paid Usenet subscription is the household's own
- the published count 60 -> 62 everywhere it is stated; the category badges are derived from
  the cards now (media read 15 with 17 cards, otthon/home read 14 with 16 — both stale)
- documentation/audits/usenet-2026-10-12/: both bench runs, both box steps and verdicts, the
  nine defects with their measurements, what a missing provider left unmeasured, the teardown
- R-988 closed (moved to CLOSED-ITEMS in this commit); R-989 filed: the drill-catalog route of
  09 §6.5 cannot be followed — that repo is private and no box has catalog credentials, so the
  templates were staged in the box's own catalog cache instead
- walk.app_env: a SECRET field in app.yaml is stored ENC:, so that helper returns ciphertext —
  the docstring says so now, measured the hard way
- STATUS.md and REPORT-usenet-2026-10-12.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-12 22:58:59 +02:00

210 lines
10 KiB
Python

#!/usr/bin/env python3
"""boxwalk_usenet.py — sabnzbd and nzbget installed on guest 9202 THROUGH THE PRODUCT.
Closes the checklist items only a real install can close: 3.5 (what a stranger reaches in the
install window), 3.6 (can a stranger lock the household out), 3.9 (sign in the way each app's own
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).
The two apps authenticate differently and the walk follows each:
* SABnzbd's web UI is SESSION-based (sabnzbd/security.py validate_session), so a sign-in POSTs to
/login/ and keeps the cookie - `curl -u` proves nothing there. Its API authenticates with the
generated key instead, which is the route Radarr and Sonarr take.
* NZBGet uses Basic auth on every request.
EVIDENCE, NOT PRODUCT. It presses the buttons a person presses. Runs ON DOOPLEX; 9202 must already be
pointed at the drill catalog (09 §6.5).
"""
import json
import os
import sys
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/usenet-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/usenet-2026-10-12/box")
os.makedirs(EV, exist_ok=True)
# the scratch guest's drive, as the controller names it: a `type: path` field must be a DRIVE, not a
# folder on it (the controller answers 409 to a folder — measured 2026-10-12)
DRIVE_ROOT = os.environ.get("DRIVE_ROOT", "/mnt/felhom-drives/scratch_hdd")
OUT = {}
def rec(app, key, value):
OUT.setdefault(app, {})[key] = value
w.say(f" [{app}] {key}: {str(value)[:170]}")
def code_of(args):
r = w.sh(args)
return (r.stdout or "").strip()
def stranger(sub, path):
"""A stranger: the app's public name, no cookie, no credentials."""
return code_of(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
"-o", "/dev/null", "-w", "%{http_code}", f"{w.BASE}{path}"])
def walk_sabnzbd():
name, sub = "sabnzbd", "usenet"
w.say(f"=== {name} ===")
if not w.stack(name):
rec(name, "template_visible", False)
return
rec(name, "template_visible", True)
ok = w.deploy(name, sub, extra_values={"HDD_PATH": DRIVE_ROOT})
rec(name, "deploy", ok)
if not ok:
return
gen = w.GENERATED.get(name) or {}
pw, key = gen.get("SAB_ADMIN_PASSWORD", ""), gen.get("SAB_API_KEY", "")
rec(name, "generated_password_length", len(pw))
rec(name, "generated_api_key_length", len(key))
rec(name, "app_answers_on_its_public_name",
w.wait_app(sub, "/", want=("200", "303", "401", "403"), tries=40, delay=5))
# 3.5 — the stranger. THE question for this app: it has no login of its own on a fresh install,
# and the template's `local_ranges` is what makes its own login apply to a proxied visitor.
rec(name, "stranger_root", stranger(sub, "/"))
rec(name, "stranger_config_page", stranger(sub, "/config/"))
rec(name, "stranger_wizard", stranger(sub, "/wizard/"))
rec(name, "stranger_api_no_key", stranger(sub, "/api?mode=queue&output=json"))
body = w.sh(["curl", "-sSkL", "--max-time", "25", "-H", f"Host: {sub}.{w.DOMAIN}",
f"{w.BASE}/"]).stdout or ""
title = ""
if "<title>" in body:
title = body.split("<title>", 1)[1].split("<", 1)[0].strip()
rec(name, "stranger_lands_on", title[:60])
# 3.9 — the household signs in the way the app's own interface does: POST /login/, keep the cookie
jar = os.path.join(os.environ["SC"], "sab.jar")
w.sh(["rm", "-f", jar])
rec(name, "household_login_post", code_of(
["curl", "-sSk", "--max-time", "25", "-H", f"Host: {sub}.{w.DOMAIN}", "-c", jar,
"--data-urlencode", "username=admin", "--data-urlencode", f"password={pw}",
"-o", "/dev/null", "-w", "%{http_code}", f"{w.BASE}/login/"]))
rec(name, "household_config_page_with_the_cookie", code_of(
["curl", "-sSk", "--max-time", "25", "-H", f"Host: {sub}.{w.DOMAIN}", "-b", jar,
"-o", "/dev/null", "-w", "%{http_code}", f"{w.BASE}/config/"]))
badjar = os.path.join(os.environ["SC"], "sab-bad.jar")
w.sh(["rm", "-f", badjar])
rec(name, "wrong_password_login_post", code_of(
["curl", "-sSk", "--max-time", "25", "-H", f"Host: {sub}.{w.DOMAIN}", "-c", badjar,
"--data-urlencode", "username=admin", "--data-urlencode", "password=definitely-not-it",
"-o", "/dev/null", "-w", "%{http_code}", f"{w.BASE}/login/"]))
rec(name, "wrong_password_then_config_page", code_of(
["curl", "-sSk", "--max-time", "25", "-H", f"Host: {sub}.{w.DOMAIN}", "-b", badjar,
"-o", "/dev/null", "-w", "%{http_code}", f"{w.BASE}/config/"]))
# the *arr path: the API with the generated key, and a wrong one
r = w.sh(["curl", "-sSk", "--max-time", "25", "-H", f"Host: {sub}.{w.DOMAIN}",
f"{w.BASE}/api?mode=version&output=json&apikey={key}"])
rec(name, "api_with_the_key", (r.stdout or "").strip()[:60])
r = w.sh(["curl", "-sSk", "--max-time", "25", "-H", f"Host: {sub}.{w.DOMAIN}",
f"{w.BASE}/api?mode=queue&output=json&apikey=deadbeefdeadbeef"])
rec(name, "api_with_a_wrong_key", (r.stdout or "").strip()[:60])
# 3.6 — six wrong sign-ins, then the household from the same address
codes = []
for i in range(6):
codes.append(code_of(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
"--data-urlencode", "username=admin",
"--data-urlencode", f"password=wrong{i}",
"-o", "/dev/null", "-w", "%{http_code}", f"{w.BASE}/login/"]))
rec(name, "six_wrong_tries", codes)
jar2 = os.path.join(os.environ["SC"], "sab2.jar")
w.sh(["rm", "-f", jar2])
rec(name, "household_right_after_the_wrong_tries", code_of(
["curl", "-sSk", "--max-time", "25", "-H", f"Host: {sub}.{w.DOMAIN}", "-c", jar2,
"--data-urlencode", "username=admin", "--data-urlencode", f"password={pw}",
"-o", "/dev/null", "-w", "%{http_code}", f"{w.BASE}/login/"]))
# what the app has for the three settings after_install reads back, and what it logged
cfg = w.guest("docker exec sabnzbd sh -c 'grep -E \"^(inet_exposure|local_ranges|username|host_whitelist)\" "
"/config/sabnzbd.ini'")
open(f"{EV}/sabnzbd-settings.txt", "w").write(cfg)
rec(name, "settings_captured", cfg.replace("\n", " | ")[:200])
logs = w.guest("docker logs --tail 60 sabnzbd 2>&1 | grep -viE 'password|api.?key' | tail -25")
open(f"{EV}/sabnzbd-app-log.txt", "w").write(logs)
paths = w.guest(f"ls -la {w.DRIVE}/media/usenet 2>&1 | head -8; "
"docker volume ls --format '{{.Name}}' | grep -i sab | head -4")
open(f"{EV}/sabnzbd-paths.txt", "w").write(paths)
def walk_nzbget():
name, sub = "nzbget", "nzbget"
w.say(f"=== {name} ===")
if not w.stack(name):
rec(name, "template_visible", False)
return
rec(name, "template_visible", True)
ok = w.deploy(name, sub, extra_values={"HDD_PATH": DRIVE_ROOT})
rec(name, "deploy", ok)
if not ok:
return
pw = (w.GENERATED.get(name) or {}).get("NZB_ADMIN_PASSWORD", "")
rec(name, "generated_password_length", len(pw))
rec(name, "app_answers_on_its_public_name",
w.wait_app(sub, "/", want=("200", "401"), tries=40, delay=5))
rec(name, "stranger_root", stranger(sub, "/"))
rec(name, "stranger_rpc", stranger(sub, "/jsonrpc/version"))
def basic(password, path="/"):
return code_of(["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
"-u", f"admin:{password}", "-o", "/dev/null", "-w", "%{http_code}",
f"{w.BASE}{path}"])
rec(name, "household_signin_through_traefik", basic(pw))
rec(name, "wrong_password_through_traefik", basic("definitely-not-it"))
# THE DOCUMENTED DEFAULT the app ships with, which every Usenet guide carries
rec(name, "the_documented_default_login", basic("tegbzn6789"))
rec(name, "the_default_name_and_our_password", code_of(
["curl", "-sSk", "--max-time", "20", "-H", f"Host: {sub}.{w.DOMAIN}",
"-u", f"nzbget:{pw}", "-o", "/dev/null", "-w", "%{http_code}", f"{w.BASE}/"]))
rec(name, "household_rpc_version", basic(pw, "/jsonrpc/version"))
codes = [basic(f"wrong{i}") for i in range(6)]
rec(name, "six_wrong_tries", codes)
rec(name, "household_right_after_the_wrong_tries", basic(pw))
cfg = w.guest("docker exec nzbget sh -c 'grep -E \"^(MainDir|DestDir|InterDir|QueueDir|TempDir|UpdateCheck"
"|ArticleCache|WriteBuffer|ControlUsername)=\" /config/nzbget.conf'")
open(f"{EV}/nzbget-settings.txt", "w").write(cfg)
rec(name, "settings_captured", cfg.replace("\n", " | ")[:220])
rec(name, "the_well_known_default_left_in_the_file",
w.guest("docker exec nzbget sh -c 'grep -c tegbzn6789 /config/nzbget.conf'").strip())
logs = w.guest("docker logs --tail 60 nzbget 2>&1 | tail -20")
open(f"{EV}/nzbget-app-log.txt", "w").write(logs)
paths = w.guest(f"ls -la {w.DRIVE}/media/nzbget 2>&1 | head -8; "
"docker exec nzbget sh -c 'ls /config/data' 2>&1 | head -8; "
"docker volume ls --format '{{.Name}}' | grep -i nzb | head -4")
open(f"{EV}/nzbget-paths.txt", "w").write(paths)
def main():
w.login()
w.say("logged in to the 9202 dashboard")
# RESCAN ONLY, never sync: the two templates are STAGED in the box's catalog cache (the drill
# repo is private and this box has no catalog credentials - see stage_in_cache.py), and a sync
# re-clones that cache from the published catalog, which would delete them mid-walk.
code, d = w.ctl("POST", "/api/stacks/rescan", {})
w.say(f"rescan -> {code} {str(d)[:120]}")
walk_sabnzbd()
walk_nzbget()
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()