diff --git a/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-bind-stored-1-open-phone-hu.png b/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-bind-stored-1-open-phone-hu.png new file mode 100644 index 00000000..064e6ea0 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-bind-stored-1-open-phone-hu.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-bind-stored-2-answer-phone-hu.png b/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-bind-stored-2-answer-phone-hu.png new file mode 100644 index 00000000..e95a16f9 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-bind-stored-2-answer-phone-hu.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-bind-typo-1-open-phone-hu.png b/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-bind-typo-1-open-phone-hu.png new file mode 100644 index 00000000..064e6ea0 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-bind-typo-1-open-phone-hu.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-bind-typo-2-answer-phone-hu.png b/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-bind-typo-2-answer-phone-hu.png new file mode 100644 index 00000000..e95a16f9 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-bind-typo-2-answer-phone-hu.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-oldlink-1-open-phone-hu.png b/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-oldlink-1-open-phone-hu.png new file mode 100644 index 00000000..be119c82 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/b2-bind/b2-oldlink-1-open-phone-hu.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/tools/apps.py b/documentation/audits/rehearsal-2026-10-20/tools/apps.py new file mode 100644 index 00000000..40a5bac3 --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/tools/apps.py @@ -0,0 +1,182 @@ +#!/usr/bin/python3 +"""apps.py — the pretend household fills its apps through each app's OWN front door (rehearsal 2026-10-20, B4). + +Runs in the browser LXC. Every app is reached by its real name (`.tester4.test`, mapped to the box's LAN address in +the LXC's /etc/hosts — the household's DNS would do the same) over HTTPS; the box's certificate is not trusted on a +LAN name, so TLS verification is off (recorded as a harness substitution). Credentials come from env vars whose NAMES +are passed in (brun.sh hands them in on stdin). Nothing secret is printed. Each step prints what it sent and what the +app answered, and the files it sent are named by their manifest path so B7/B8 can compare by sha256. + +usage: apps.py (see the functions below) +""" +import base64, hashlib, json, os, ssl, subprocess, sys, time, urllib.error, urllib.parse, urllib.request +from email.utils import formatdate + +DATA = "/root/data" +DOMAIN = os.environ.get("DOMAIN", "tester4.test") +CTX = ssl.create_default_context() +CTX.check_hostname = False +CTX.verify_mode = ssl.CERT_NONE + + +def say(*a): + print(time.strftime("%H:%M:%SZ", time.gmtime()), *a, flush=True) + + +def http(method, url, body=None, headers=None, timeout=300): + h = dict(headers or {}) + data = body + if isinstance(body, (dict, list)): + data = json.dumps(body).encode() + h.setdefault("Content-Type", "application/json") + req = urllib.request.Request(url, data=data, method=method, headers=h) + try: + with urllib.request.urlopen(req, context=CTX, timeout=timeout) as r: + return r.status, r.read(), dict(r.headers) + except urllib.error.HTTPError as e: + return e.code, e.read(), dict(e.headers) + + +def basic(user, pw): + return "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode() + + +def manifest(app=None): + rows = [] + with open(os.path.join(DATA, "MANIFEST.tsv"), encoding="utf-8") as fh: + next(fh) + for line in fh: + p, size, sha, a, cls, note = line.rstrip("\n").split("\t") + if app is None or a == app: + rows.append(dict(path=p, size=size, sha=sha, app=a, cls=cls, note=note)) + return rows + + +def multipart(fields, files): + """fields: {name: str}; files: [(field, filename, bytes, ctype)] → (body, content-type)""" + b = "----felhomrehearsal" + hashlib.sha1(str(time.time()).encode()).hexdigest()[:16] + out = [] + for k, v in fields.items(): + out += [f"--{b}\r\n".encode(), f'Content-Disposition: form-data; name="{k}"\r\n\r\n'.encode(), str(v).encode(), b"\r\n"] + for field, fname, content, ctype in files: + q = urllib.parse.quote(fname) + out += [f"--{b}\r\n".encode(), + f'Content-Disposition: form-data; name="{field}"; filename="{q}"; filename*=UTF-8\'\'{q}\r\n'.encode(), + f"Content-Type: {ctype}\r\n\r\n".encode(), content, b"\r\n"] + out.append(f"--{b}--\r\n".encode()) + return b"".join(out), f"multipart/form-data; boundary={b}" + + +# ---------------------------------------------------------------- Paperless-ngx (documents, class A) +def paperless(sub, user_env, pw_env): + base, auth = f"https://{sub}.{DOMAIN}", basic(os.environ[user_env], os.environ[pw_env]) + ok = bad = 0 + for r in manifest("paperless-ngx" if False else "paperless"): + content = open(os.path.join(DATA, r["path"]), "rb").read() + body, ct = multipart({"title": os.path.basename(r["path"])[:-4]}, + [("document", os.path.basename(r["path"]), content, "application/pdf")]) + st, out, _ = http("POST", base + "/api/documents/post_document/", body, {"Authorization": auth, "Content-Type": ct}) + ok += st == 200 + bad += st != 200 + if st != 200: + say("paperless upload", r["path"], st, out[:200]) + say(f"paperless: {ok} accepted, {bad} refused") + + +# ---------------------------------------------------------------- Nextcloud (files, class A) — WebDAV +def nextcloud(sub, user_env, pw_env): + """rclone over WebDAV, as a desktop sync client would. Symlinks are not followed (a sync client skips them).""" + user, pw = os.environ[user_env], os.environ[pw_env] + obscured = subprocess.run(["rclone", "obscure", "-"], input=pw, capture_output=True, text=True).stdout.strip() + env = dict(os.environ, RCLONE_CONFIG_NC_TYPE="webdav", RCLONE_CONFIG_NC_URL=f"https://{sub}.{DOMAIN}/remote.php/dav/files/{user}", + RCLONE_CONFIG_NC_VENDOR="nextcloud", RCLONE_CONFIG_NC_USER=user, RCLONE_CONFIG_NC_PASS=obscured) + r = subprocess.run(["rclone", "copy", "--no-check-certificate", "--transfers", "4", "--stats", "60s", "--stats-one-line", + "-v", os.path.join(DATA, "nextcloud"), "nc:/"], env=env, capture_output=True, text=True) + tail = [l for l in (r.stdout + r.stderr).splitlines() if "ERROR" in l or "Transferred" in l or "Skipping" in l or "NOTICE" in l] + say(f"nextcloud rclone rc={r.returncode}") + for l in tail[-25:]: + print(" ", l) + + +# ---------------------------------------------------------------- Radicale (calendar, class B) — CalDAV +def radicale(sub, user_env, pw_env): + base, user = f"https://{sub}.{DOMAIN}", os.environ[user_env] + auth = basic(user, os.environ[pw_env]) + cal = f"{base}/{urllib.parse.quote(user)}/csalad/" + body = ('' + 'Család').encode() + st, out, _ = http("MKCALENDAR", cal, body, {"Authorization": auth, "Content-Type": "application/xml"}) + say("radicale MKCALENDAR", st) + ics = open(os.path.join(DATA, "radicale", "csalad.ics"), "rb").read().decode() + # one PUT per VEVENT (CalDAV stores one component per resource) + head, rest = ics.split("BEGIN:VEVENT", 1) + tz = head[head.index("BEGIN:VTIMEZONE"):] + for i, ev in enumerate(("BEGIN:VEVENT" + rest).split("BEGIN:VEVENT")[1:]): + one = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//felhom rehearsal//EN\r\n" + tz + "BEGIN:VEVENT" + ev.split("END:VCALENDAR")[0] + "END:VCALENDAR\r\n" + st, out, _ = http("PUT", cal + f"event{i}.ics", one.encode(), {"Authorization": auth, "Content-Type": "text/calendar"}) + say("radicale PUT event", i, st) + + +# ---------------------------------------------------------------- Mealie (recipes, class B) +def mealie(sub, user_env, pw_env): + base = f"https://{sub}.{DOMAIN}" + form = urllib.parse.urlencode({"username": os.environ[user_env], "password": os.environ[pw_env]}).encode() + st, out, _ = http("POST", base + "/api/auth/token", form, {"Content-Type": "application/x-www-form-urlencoded"}) + if st != 200: + say("mealie login", st, out[:200]); return + tok = {"Authorization": "Bearer " + json.loads(out)["access_token"]} + for r in manifest("mealie"): + if not r["path"].endswith(".json"): + continue + rec = json.load(open(os.path.join(DATA, r["path"]), encoding="utf-8")) + st, out, _ = http("POST", base + "/api/recipes", {"name": rec["name"]}, tok) + slug = json.loads(out) if st in (200, 201) else None + say("mealie create", rec["name"], st, slug) + if not slug: + continue + st, out, _ = http("GET", base + f"/api/recipes/{slug}", None, tok) + full = json.loads(out) + full["recipeIngredient"] = [{"note": x, "display": x} for x in rec["ingredients"]] + full["recipeInstructions"] = [{"text": x} for x in rec["steps"]] + st, out, _ = http("PUT", base + f"/api/recipes/{slug}", full, tok) + say("mealie update", slug, st) + img = open(os.path.join(DATA, r["path"][:-5] + ".jpg"), "rb").read() + body, ct = multipart({"extension": "jpg"}, [("image", "kep.jpg", img, "image/jpeg")]) + st, out, _ = http("PUT", base + f"/api/recipes/{slug}/image", body, dict(tok, **{"Content-Type": ct})) + say("mealie image", slug, st) + + +# ---------------------------------------------------------------- Immich (photos, class A) +def immich(sub, email_env, pw_env, name="Kovács Ödön"): + base = f"https://{sub}.{DOMAIN}" + st, out, _ = http("POST", base + "/api/auth/admin-sign-up", + {"email": os.environ[email_env], "password": os.environ[pw_env], "name": name}) + say("immich admin-sign-up", st, out[:120]) + st, out, _ = http("POST", base + "/api/auth/login", {"email": os.environ[email_env], "password": os.environ[pw_env]}) + if st not in (200, 201): + say("immich login", st, out[:200]); return + tok = {"Authorization": "Bearer " + json.loads(out)["accessToken"]} + ok = dup = bad = 0 + for r in manifest("immich"): + p = os.path.join(DATA, r["path"]) + content = open(p, "rb").read() + mt = formatdate(os.path.getmtime(p), usegmt=True) + iso = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(os.path.getmtime(p))) + ctype = "video/mp4" if p.endswith(".mp4") else "image/png" if p.endswith(".png") else "image/jpeg" + body, ct = multipart({"deviceAssetId": r["path"], "deviceId": "rehearsal-phone", "fileCreatedAt": iso, + "fileModifiedAt": iso}, [("assetData", os.path.basename(p), content, ctype)]) + st, out, _ = http("POST", base + "/api/assets", body, dict(tok, **{"Content-Type": ct})) + res = json.loads(out) if out[:1] == b"{" else {} + if st in (200, 201) and res.get("status") == "duplicate": + dup += 1 + elif st in (200, 201): + ok += 1 + else: + bad += 1 + say("immich refused", r["path"], st, out[:160]) + say(f"immich: {ok} created, {dup} duplicates, {bad} refused") + + +if __name__ == "__main__": + fn = globals()[sys.argv[1]] + fn(*sys.argv[2:]) diff --git a/documentation/audits/rehearsal-2026-10-20/tools/gallery.py b/documentation/audits/rehearsal-2026-10-20/tools/gallery.py new file mode 100644 index 00000000..d4e6c384 --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/tools/gallery.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""gallery.py — build screens/README.md: every screenshot, phone and computer side by side, HU and EN (Part E). + +Files named `--.png` (tools/shot.py) are grouped by prefix into one row each; anything +else (console captures, bind.py's numbered shots) is listed under its folder. Relative links only, so the operator can +look without a box. Before writing, every image is listed with its size so a reviewer can spot one that was never +checked for a secret (rule 4: a screenshot that shows a secret is deleted, not committed). +usage: gallery.py +""" +import os, re, sys +from collections import OrderedDict + +root = sys.argv[1] +pat = re.compile(r"^(?P

.+)-(?Pphone|desktop)-(?Phu|en)\.png$") +out = ["# Rehearsal 2026-10-20 — every screen the household met", "", + "Phone = 390×844, computer = 1366×768 (Chromium 155 + Playwright 1.52.0 in a throwaway container; see " + "`../A4-browser/`). Hungarian and English browser languages. Codes, passwords and links are blacked out or " + "never shown. Console captures are QEMU screendumps of the VM's monitor.", ""] +for folder in sorted(d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))): + files = sorted(f for f in os.listdir(os.path.join(root, folder)) if f.endswith(".png")) + if not files: + continue + out += [f"## {folder}", ""] + groups, rest = OrderedDict(), [] + for f in files: + m = pat.match(f) + if m: + groups.setdefault(m["p"], {})[(m["s"], m["l"])] = f + else: + rest.append(f) + if groups: + out += ["| screen | phone HU | phone EN | computer HU | computer EN |", "|---|---|---|---|---|"] + for p, g in groups.items(): + cells = [] + for k in (("phone", "hu"), ("phone", "en"), ("desktop", "hu"), ("desktop", "en")): + cells.append(f'' if k in g else "—") + out.append(f"| `{p}` | " + " | ".join(cells) + " |") + out.append("") + for f in rest: + out += [f"**{f}**", "", f'', ""] +open(os.path.join(root, "README.md"), "w", encoding="utf-8").write("\n".join(out) + "\n") +print("gallery:", sum(len([f for f in os.listdir(os.path.join(root, d)) if f.endswith('.png')]) + for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))), "images") diff --git a/documentation/backlog/OPEN-ITEMS.md b/documentation/backlog/OPEN-ITEMS.md index ace44430..22ca73ad 100644 --- a/documentation/backlog/OPEN-ITEMS.md +++ b/documentation/backlog/OPEN-ITEMS.md @@ -194,7 +194,7 @@ stopping line that lies. | **R-1186** | Backup & restore | P3 | **The nightly off-site copy is skipped silently when another backup holds the flag, and the catch-up never re-runs it.** `offbox.go:981-996` returns nil with an INFO line on the nightly path; `withLeg` marks the leg ended anyway (`cmd/controller/main.go:1246`). The 05:30 proof and 06:00 integrity check take the same flag, so a backup time near them collides every night. Only the hub's 48 h `offsite_stale` notices. CONFIRMED in code. Data-loss hunt 2026-10-20 (T5). | OPEN | — | Fix: mark the leg NOT ended on a busy skip so the catch-up retries; say it on the backups page | CC | | **R-1187** | Backup & restore | P3 | **`.fab` export/import candidates from the data-loss hunt, not yet proven:** (a) an export silently drops a drive folder that holds a symlink (`appexport/fabplan.go:292`, `export.go:714`); (b) the import never sets file owners and drops symlinks (`restore.go:1157-1176`); (c) import into a STOPPED app merges into the existing volumes, and the default export (stop ticked) carries no SQL dump (`restore.go:361-374`, `export.go:262,366`, `appbackup/dbdump.go:142`); (d) no concurrency guard, no undo, no record (`restore.go:181-186`, `web/handler_export.go:312-353`); (e) PROVEN and split out as R-1191 (the import drops the family gate); (f) it trusts the bundle's HDD_PATH (`restore.go:589-631`); (g) the DB import runs psql without ON_ERROR_STOP and misses postgis (`restore.go:1052-1053, 928-937`). Read by reviewer 1; main session spot-checked (a); (e) proven live → R-1191. Evidence `audits/dataloss-hunt-2026-10-20/C1-restore-rollback-candidates.md`. | VERIFY | — | Prove (c) first on 9202 with a throwaway app; split into rows only what reproduces | CC | | **R-1188** | Backup & restore | P3 | **Restore-path candidates from the data-loss hunt, not yet proven (one confirmed in code):** (a) a crash or power cut mid-restore restarts the app on a mixed state and never reads the undo copy (`appstop_marker.go:307`, `offbox_restore_undo.go:137`, `bootrecon/bootrecon.go:210-213`); (b) a failed `volume rm` is ignored before the untar (`restore.go:218`); (c) PROVEN against production code (`audits/dataloss-hunt-2026-10-20/C3-R12-leftover-temp/`): an interrupted second-drive whole restore leaves `.felhom-restoring`, and EVERY retry fails „fájlmásolás sikertelen: … file exists" with the file never back (`tier2_whole.go:86-87`, O_EXCL); (d) the full off-site download has a 30-min cap per press and the household is not told to retry (`web/offbox_handlers.go:455`); (e) place and full restore re-read the LATEST snapshot instead of the downloaded one (`offbox_restore.go:675-679`). Evidence `audits/dataloss-hunt-2026-10-20/C1-restore-rollback-candidates.md`. | VERIFY | — | Fix (c) now (remove a stale temp of the same name before the copy); prove (a) with the B6 power-cut-mid-restore scenario | CC | -| **R-1189** | Backup & restore | P3 | **Time and concurrency candidates from the data-loss hunt, not yet proven:** (a) a host clock ahead during one whole-guest backup silences that tier until real time passes it, and the hub reads the same clock (agent `localapi/server.go:1178-1192`, `backup/runner.go:607-629`, hub `monitor/deadline.go:243`; R-973 cap counts future runs `backup_cap.go:61`); (b) the whole-guest start creeps ~5 min/night until a night is skipped silently (`server.go:1184`, `quiesce.go:150, 807-818`); (c) the whole-guest quiesce stops apps mid-restore or mid-update (`quiesce.go:213-275, 565`); (d) the off-site proof and integrity check never run on a box that is off at 05:30–06:00 (`cmd/controller/main.go:1575-1619`, comment says the opposite); (e) hub false „no DB dump" for a late or evening backup time (`monitor/deadline.go:416-419, 556-565`); (f) the OS leg and kernel reboot follow a daytime valve backup (agent `server.go:982-986`, `kernel.go:101-114`) — design question; (g) spring forward 2027-03-28 fires dump and second copy together; (h) self-update busy check before the pull only (`updater.go:532-545, 764`); (i) a double-clicked restore shows failed while it runs (`web/handlers.go:1853-1874`). Evidence `audits/dataloss-hunt-2026-10-20/C1-time-concurrency-candidates.md`. | VERIFY | — | (a) is B6 #8's live test; (b) read the agent journal on demo-hp; the rest by unit tests | CC | +| **R-1189** | Backup & restore | P3 | **Time and concurrency candidates from the data-loss hunt, not yet proven:** (a) a host clock ahead during one whole-guest backup silences that tier until real time passes it, and the hub reads the same clock (agent `localapi/server.go:1178-1192`, `backup/runner.go:607-629`, hub `monitor/deadline.go:243`; R-973 cap counts future runs `backup_cap.go:61`); (b) the whole-guest start creeps ~5 min/night until a night is skipped silently (`server.go:1184`, `quiesce.go:150, 807-818`); (c) the whole-guest quiesce stops apps mid-restore or mid-update (`quiesce.go:213-275, 565`); (d) CONFIRMED in code: the off-site proof and integrity check are plain daily jobs outside the catch-up (`nightchain.go` header: the catch-up runs only the three backup legs), so they never run on a box that is off at 05:30–06:00, while the comment at `cmd/controller/main.go:1575-1577` says the opposite; (e) CONFIRMED in code: the hub's 05:00 check wants a dump „since Budapest midnight" (`hub/internal/monitor/deadline.go:416-419, 556-565`), so an evening backup time (which the banner suggests) raises a false `expected_dbdump_missed` error every morning — and on 2026-10-25 the check also runs at 04:00 (R-1183); (f) the OS leg and kernel reboot follow a daytime valve backup (agent `server.go:982-986`, `kernel.go:101-114`) — design question; (g) spring forward 2027-03-28 fires dump and second copy together; (h) self-update busy check before the pull only (`updater.go:532-545, 764`); (i) a double-clicked restore shows failed while it runs (`web/handlers.go:1853-1874`). Evidence `audits/dataloss-hunt-2026-10-20/C1-time-concurrency-candidates.md`. | VERIFY | — | (a) is B6 #8's live test; (b) read the agent journal on demo-hp; the rest by unit tests | CC | | **R-1190** | Backup & restore | P3 | **Key and retention candidates from the data-loss hunt, not yet proven:** (a) a recovery ceremony while off-site is not configured seals a package with no repo password and supersedes the good one (`escrow_handlers.go:217`, agent `main.go:2975-2981`, hub `store.go:3392`); (b) the global „create your recovery code" bar links to the ceremony the backups page hides during a recovery offer (`escrow_banner.go:23-33` vs `backups_remote.html:144-160`); (c) after a reuse-path reinstall the new PBS key K is never escrowed and nothing compares fingerprints (`felhom-pbs-apply:99`, `escrow_confirm.go:182-205`); (d) whole-guest off-site copies run before any escrow exists (design question vs 07 §6.1's Tier-3 gate); (e) the escrow PUT is two statements; (f) the operator's off-site re-issue resets the provider password before saving it (hub `offsite.go:217-245`); (g) „Megőrzött adatok" can list a running app's live folder when its app.yaml is unreadable (`kept.go:220-235, 330-402`); (h) a unit restore can point an app back at an old drive and F5 then prunes the current drive's unit (`backup.go:1517-1574`). Evidence `audits/dataloss-hunt-2026-10-20/C1-keys-candidates.md`, `C1-delete-retention-candidates.md`. | VERIFY | — | Unit tests first for (a), (e), (g) | CC | ## Storage & devices — 9 rows (P3 3, P4 6) diff --git a/documentation/runbooks/VOLUNTEER-first-hour.md b/documentation/runbooks/VOLUNTEER-first-hour.md index 064a2a6e..125c700b 100644 --- a/documentation/runbooks/VOLUNTEER-first-hour.md +++ b/documentation/runbooks/VOLUNTEER-first-hour.md @@ -85,8 +85,8 @@ Indítsd a gépet az USB-kulcsról. **A telepítő képernyői angolul vannak** ## 3. Az első indulás (~2 perc) -A képernyőn ez jelenik meg: „Felhom otthoni szerver — Ezen a képernyőn nincs teendőd, bejelentkezni -sem kell." Alatta, kb. 2 perc múlva: +A képernyőn ez jelenik meg: „Felhom otthoni szerver — Ezen a gépen most nincs dolgod, és bejelentkezni +sem kell." *(2026-10-20)* Alatta, kb. 2–4 perc múlva: > Felhom — a doboz készen áll, és a párosításra vár. > Párosító kód: XXX-XXX