diff --git a/CHANGELOG.md b/CHANGELOG.md index 87c7a2d..1d6f923 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 2026-10-06 (afternoon) — the test box may seed vaultwarden through its admin page (R-890) + +**What runs on a box changed:** nothing. Test tools only. + +- R-890 (`09` §3 decision 149): the box walk may now seed vaultwarden through its admin invite on a TEST box, as the bench does. A guard refuses unless all five hold: not the bench venue, FELHOM_BOX_ADMIN_SEED=1, the walk targets scratch guest 9202 on demo-hp (never 9201; the Tester 1 box is allowed by the ruling but the walk has no route to it), THIS run installed the app (box_walk now records its installs), and the box's own controller.yaml names the drill catalog. The invite runs INSIDE the box: the token is read from the container's environment into a shell variable and handed to curl on stdin, the admin cookie lives in a 0600 file that is shredded, and only HTTP codes come back — the token never leaves the box. Tests: BoxAdminSeedGuard (red-proved: a guard that always allows fails three tests). + ## 2026-10-06 (midday) — the operator's ten answers: two page sentences, the immich marker list, a bench-only vaultwarden seed (R-747, R-774, R-734, R-624) **What runs on a box changed:** one first-step sentence each on mealie's and Karakeep's page (hu + en). Nothing else a box runs. diff --git a/scripts/box_walk.py b/scripts/box_walk.py index 7927c32..0acdb16 100644 --- a/scripts/box_walk.py +++ b/scripts/box_walk.py @@ -233,6 +233,9 @@ DRIVE = "/mnt/felhom-drives/scratch_hdd/userdata" # password back off the box — the household sees it once. So the value the harness itself # generated is kept here for the life of the run, and nowhere else. GENERATED = {} +# The apps THIS run installed (deploy() adds one on its 202). The test-box admin seed refuses an app not in it: an app +# the walk merely reused may be a household's (upgrade_fixtures_box.box_admin_seed_allowed, R-890). +DEPLOYED_THIS_RUN = set() def deploy_values(name, sub): @@ -297,6 +300,7 @@ def deploy(name, sub, extra_values=None): say(f" [1] deploy -> {code} {str(d)[:120]}") if code != "202": return False + DEPLOYED_THIS_RUN.add(name) # WAIT FOR `deployed`, NOT FOR `running`. Measured 2026-09-21 on tandoor: docker reported the # container `healthy` while the controller's own state read `unhealthy` — a gate on `running` # alone therefore times out on an app that is up. The state is RECORDED rather than required; diff --git a/scripts/test_upgrade_bench.py b/scripts/test_upgrade_bench.py index 5fb970e..f22ad38 100644 --- a/scripts/test_upgrade_bench.py +++ b/scripts/test_upgrade_bench.py @@ -259,6 +259,97 @@ class BenchAdminSeedGuard(unittest.TestCase): self.assertFalse(any(ADMIN in s or "jwt.admin.session" in s for s in self.said), "a secret was printed") +class FakeBoxWalk(FakeVenue): + """box_walk's interface on a test box: no VENUE, HP/GUEST, the run's installs, and guest() that answers like the + in-box admin invite script would. Records every script sent into the box.""" + + def __init__(self, hp="demo-hp", guest="9202", deployed=("vaultwarden",), drill=True, invite="RESULT signin=200 session=yes invite=200"): + FakeVenue.__init__(self, False) + self.GENERATED = {} + self.HP, self.GUEST, self.DEPLOYED_THIS_RUN = hp, guest, set(deployed) + self.drill, self.invite, self.scripts = drill, invite, [] + + def guest(self, script, timeout=600): + self.scripts.append(script) + if "controller.yaml" in script: + return ("git:\n repo_url: https://gitea.dooplex.hu/admin/%s.git\n" + % ("app-catalog-drill" if self.drill else "app-catalog-felhom.eu")) + if "/admin/invite" in script: + m = __import__("re").search(r'"email":"([^"]+)"', script) + if self.invite.endswith("invite=200"): + self.invited.add(m.group(1)) + return "some noise\n" + self.invite + "\n" + return "" + + +class BoxAdminSeedGuard(unittest.TestCase): + """R-890 (`09` §3 decision 149): the admin seed on a TEST BOX — scratch 9202 on demo-hp, an app this run installed, + the box on the drill catalog, the run opted in. RED-PROOF (REPORT): make box_admin_seed_allowed return (True, "") — + test_each_condition_alone_refuses and test_a_household_shaped_box_is_never_seeded fail.""" + + ENV = {"FELHOM_BOX_ADMIN_SEED": "1"} + + def allowed(self, w, env): + with mock.patch.dict(os.environ, env, clear=False): + if "FELHOM_BOX_ADMIN_SEED" not in env: + os.environ.pop("FELHOM_BOX_ADMIN_SEED", None) + return fxbox.box_admin_seed_allowed(w, "vaultwarden") + + def test_all_conditions_allow(self): + self.assertEqual(self.allowed(FakeBoxWalk(), self.ENV), (True, "")) + + def test_each_condition_alone_refuses(self): + cases = [(FakeVenue(True), self.ENV, "bench venue"), + (FakeBoxWalk(), {}, "FELHOM_BOX_ADMIN_SEED"), + (FakeBoxWalk(), {"FELHOM_BOX_ADMIN_SEED": "yes"}, "FELHOM_BOX_ADMIN_SEED"), + (FakeBoxWalk(guest="9201"), self.ENV, "not a test box"), + (FakeBoxWalk(hp="demo-felhom"), self.ENV, "not a test box"), + (FakeBoxWalk(deployed=()), self.ENV, "did not install"), + (FakeBoxWalk(drill=False), self.ENV, "drill catalog")] + for w, env, want in cases: + ok, why = self.allowed(w, env) + self.assertFalse(ok, want) + self.assertIn(want, why) + + def seed(self, w, env): + with mock.patch.dict(os.environ, env, clear=False): + if "FELHOM_BOX_ADMIN_SEED" not in env: + os.environ.pop("FELHOM_BOX_ADMIN_SEED", None) + os.environ.pop("FELHOM_BENCH_ADMIN_SEED", None) + fx_ = fxbox.Vaultwarden() + return fx_, fx_.seed(w, "vault", lambda *a: None) + + def test_the_test_box_seeds_through_the_invite_inside_the_box(self): + w = FakeBoxWalk() + _, got = self.seed(w, self.ENV) + self.assertIsNotNone(got) + self.assertEqual([c["path"] for c in w.calls], ["/identity/accounts/register", "/identity/accounts/register"]) + inv = [x for x in w.scripts if "/admin/invite" in x] + self.assertEqual(len(inv), 1) + # the token is read inside the box and handed to curl on stdin; the cookie file is shredded + self.assertIn("printenv ADMIN_TOKEN", inv[0]) + self.assertIn("token@-", inv[0]) + self.assertIn('shred -u "$H"', inv[0]) + self.assertIn(got["email"], inv[0]) + + def test_a_household_shaped_box_is_never_seeded(self): + for w in (FakeBoxWalk(guest="9201"), FakeBoxWalk(deployed=()), FakeBoxWalk(drill=False)): + fx_, got = self.seed(w, self.ENV) + self.assertIsNone(got) + self.assertFalse([x for x in w.scripts if "/admin/invite" in x], "the admin route was tried") + self.assertIn("NOT tried", fx_.tried) + + def test_a_refused_invite_is_inconclusive(self): + w = FakeBoxWalk(invite="RESULT signin=401 session=no") + fx_, got = self.seed(w, self.ENV) + self.assertIsNone(got) + self.assertIn("test-box admin invite", fx_.tried) + + def test_only_a_drill_address_is_invited(self): + with self.assertRaises(ValueError): + fxbox.box_admin_invite(FakeBoxWalk(), 'x"}; rm -rf /; {"@gate.invalid') + + class SecretHygiene(unittest.TestCase): """The run's secrets: .env 0600 and shredded, every evidence file redacted. RED-PROOF (REPORT): make redact_tree return [] without rewriting — test_evidence_files_are_redacted fails.""" diff --git a/scripts/upgrade_fixtures_box.py b/scripts/upgrade_fixtures_box.py index b58dd0f..114bb6b 100644 --- a/scripts/upgrade_fixtures_box.py +++ b/scripts/upgrade_fixtures_box.py @@ -304,6 +304,75 @@ def bench_admin_seed_allowed(w): return True, "" +# --- R-890 (`09` §3 decision 149): the TEST BOXES may seed vaultwarden through its admin route too --------------------- +BOX_ADMIN_SEED_ENV = "FELHOM_BOX_ADMIN_SEED" # the run's explicit opt-in on a box walk +# The test boxes the walk can reach, as (Proxmox host, guest). Scratch 9202 only: box_walk.py drives guests on demo-hp +# alone (its HP), and 9201 there is demo-hp's household-shaped guest — a demo box's household apps are never seeded +# through their admin (decision 149). The Tester 1 box is allowed by the ruling but the walk has no route to it; add +# it here the day the walk can run there. +BOX_ADMIN_SEED_GUESTS = {("demo-hp", "9202")} +DRILL_CATALOG_MARK = "app-catalog-drill" # a test run points the box at the drill catalog (`09` §6.5) + + +def box_admin_seed_allowed(w, app): + """(True, "") only on a TEST BOX walk; (False, why) everywhere else. ALL FIVE must hold: + 1. not the bench venue (the bench has its own guard, bench_admin_seed_allowed); + 2. the run opted in: FELHOM_BOX_ADMIN_SEED=1; + 3. the walk targets a listed test box (BOX_ADMIN_SEED_GUESTS) — never 9201, never a household box; + 4. THIS run installed the app (box_walk.deploy records it): an app the walk merely reused may be someone's; + 5. the box itself says it is on the drill catalog (its controller.yaml names it) — a household box never is. + Pinned by scripts/test_upgrade_bench.py (BoxAdminSeedGuard: each condition alone refuses).""" + if getattr(w, "VENUE", None) == "bench": + return False, "the bench venue (its own guard decides there)" + if os.environ.get(BOX_ADMIN_SEED_ENV) != "1": + return False, "%s=1 is not set for this run" % BOX_ADMIN_SEED_ENV + where = (getattr(w, "HP", None), str(getattr(w, "GUEST", None))) + if where not in BOX_ADMIN_SEED_GUESTS: + return False, "%s guest %s is not a test box" % where + if app not in (getattr(w, "DEPLOYED_THIS_RUN", None) or set()): + return False, "this run did not install %s (a reused app may be a household's)" % app + conf = w.guest("grep -A3 '^git:' /var/lib/docker/volumes/felhom-controller-data/_data/controller.yaml") + if DRILL_CATALOG_MARK not in (conf or ""): + return False, "the box is not on the drill catalog — not a test run" + return True, "" + + +# Runs INSIDE the guest. The admin token is read from the container's own environment into a shell variable and handed +# to curl on stdin (`printf` is a builtin: never on a command line); the admin session cookie lives in a 0600 file that +# is shredded. Only HTTP codes come back. The cookie is sent by hand because vaultwarden marks it Secure (its DOMAIN is +# https) and the call goes to the container's own address over plain http. +BOX_ADMIN_INVITE_SH = r"""set -u +c=vaultwarden +ip=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' "$c" 2>/dev/null | awk '{print $1}') +[ -n "$ip" ] || { echo "RESULT noip"; exit 0; } +T=$(docker exec "$c" printenv ADMIN_TOKEN 2>/dev/null) +[ -n "$T" ] || { echo "RESULT notoken"; exit 0; } +H=$(mktemp); chmod 600 "$H" +code=$(printf %s "$T" | curl -s -o /dev/null -D "$H" -w '%{http_code}' --max-time 30 --data-urlencode token@- "http://$ip:80/admin") +T= +ck=$(grep -i '^set-cookie: *VW_ADMIN=' "$H" | head -1 | sed -e 's/^[Ss]et-[Cc]ookie: *//' -e 's/;.*//' | tr -d ' ') +if [ -z "$ck" ]; then shred -u "$H"; echo "RESULT signin=$code session=no"; exit 0; fi +printf 'Cookie: %s +' "$ck" > "$H"; ck= +inv=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 -H @"$H" -H 'Content-Type: application/json' --data '{"email":"__EMAIL__"}' "http://$ip:80/admin/invite") +shred -u "$H" +echo "RESULT signin=$code session=yes invite=$inv" +""" + + +def box_admin_invite(w, email): + """Invite `email` through vaultwarden's admin page, inside the test box (see BOX_ADMIN_INVITE_SH). Returns + (signin code, session yes/no, invite code) as the box printed them, or None when it printed no RESULT line.""" + if not re.fullmatch(r"[a-z0-9.-]+@gate\.invalid", email): + raise ValueError("only a drill address is ever invited") + out = w.guest(BOX_ADMIN_INVITE_SH.replace("__EMAIL__", email)) + m = re.search(r"^RESULT (.*)$", out or "", re.M) + if not m: + return None + kv = dict(p.split("=", 1) for p in m.group(1).split() if "=" in p) + return kv.get("signin", m.group(1)), kv.get("session", "no"), kv.get("invite", "") + + def _curl_with_header_file(w, sub, path, header, *extra, **kw): """w.app_curl with ONE secret header read by curl from a 0600 temp file (`-H @file`), so the value is never on a command line; the file is overwritten and removed afterwards, whatever happens.""" @@ -377,9 +446,17 @@ class Vaultwarden: tried = f"POST /identity/accounts/register -> {code} (sign-up closed by design)" ok, why = bench_admin_seed_allowed(w) if not ok: - self.tried = tried + f"; the admin invite was NOT tried: {why}" - say(f" vaultwarden: {self.tried}") - return None + okb, whyb = box_admin_seed_allowed(w, "vaultwarden") + if not okb: + self.tried = tried + f"; the admin invite was NOT tried: bench — {why}; box — {whyb}" + say(f" vaultwarden: {self.tried}") + return None + got = box_admin_invite(w, email) + say(f" vaultwarden: test-box admin sign-in and invite (inside the box) -> {got}") + if not got or got[1] != "yes" or got[2] != "200": + self.tried = tried + f"; the test-box admin invite -> {got}" + return None + return self._invited(w, sub, email, key, tried, say) token = (getattr(w, "GENERATED", {}).get("vaultwarden") or {}).get("ADMIN_TOKEN") or "" if not token: self.tried = tried + "; the bench generated no ADMIN_TOKEN for this run" @@ -403,6 +480,10 @@ class Vaultwarden: if code != "200": self.tried = tried + f"; POST /admin/invite -> {code}" return None + return self._invited(w, sub, email, key, tried, say) + + def _invited(self, w, sub, email, key, tried, say): + """The invited address registers through the household's own front door.""" rc, code, out = self._register(w, sub, email, key) say(f" vaultwarden: invited registration http={code}") if code not in ("200", "204"):