diff --git a/documentation/audits/dataloss-hunt-2026-10-20/C1-keys-candidates.md b/documentation/audits/dataloss-hunt-2026-10-20/C1-keys-candidates.md new file mode 100644 index 00000000..19b6ceec --- /dev/null +++ b/documentation/audits/dataloss-hunt-2026-10-20/C1-keys-candidates.md @@ -0,0 +1,14 @@ +# C1 reviewer 3 — keys that gate recovery (fresh, read-only; controller 7ca59e2, hub 5c48731, agent 4e1621c) + +| # | Sev guess | harm | mechanism | +|---|---|---|---| +| K1 | P2 (P1 with K2 / a RESET) high | **A reinstall through „delete the old host first" (D19/R-1154) cuts the new box off from the household's escrow: the recovery code is never asked, a NEW key is minted, and the old off-site history is set aside by itself on night one; typing the old code answers „wrong code"** | hub `api/passphrase_scope.go:58` 409 forces the delete; `store.go:3204-3210` delete demotes escrow under the OLD host_id; `handler.go:1261` new host_id; `GetEscrowStatusForCustomer` `store.go:3500-3527` joins on `hosts` → nil (superseded join on deleted host also gone) → `identity_blob_present`/`superseded_present` false; controller `offbox.go:542-555` mints new pw, `:1788` no recovery offer, `:905-906` firstNight → `resetOrphanedRepo`; `handleHostEscrowRetainedGet` 404 for a deleted host (`handler.go:1691`); contrast purge `store.go:4185-4190` DOES include `host_deletions` | +| K2 | P2 high | Abandoning ONE set-aside copy deletes EVERY retained recovery package of the customer — other set-aside copies become ciphertext nobody can open | hub `handler.go:626-627` → `PurgeSupersededEscrowForCustomer` `store.go:4185-4190` deletes all rows for the customer (incl. deleted hosts); the comment at `:4176-4179` states the invariant it breaks | +| K3 | P3 med-high | A ceremony while off-site is not configured seals a package with NO repo password and supersedes the one that had it | controller `escrow_handlers.go:217` re-stage only when configured; agent `main.go:2975-2981`; hub `store.go:3392`; `escrow_confirm.go:222-258` stale card local only | +| K4 | P3 med | The global „create your recovery code" bar links to the ceremony the backups page hides while a recovery offer is outstanding | `backups_remote.html:144-160` vs `escrow_banner.go:23-33` + hu.json:1682; no `recoveryOffer()` refusal in the wizard/start handlers; settle gate `main.go:876`, `offsiteapply.go:84` | +| K5 | P3 med-high | After a reinstall recovered on the reuse path, the NEW PBS key K is never escrowed and nothing compares fingerprints | `felhom-pbs-apply:99` autogen; `escrow_confirm.go:182-205` auto-confirm on hash; no compare of `host_escrow.key_fingerprint` vs live key (agent `internal/backup/runner.go:407-429` only) | +| K6 | P3/P4 design | Whole-guest off-site (PBS) copies run before any escrow exists; host lost before the ceremony → unreadable archives | controller gates Tier 3 (`offbox.go:781`); agent `internal/backup`, `internal/pbsdr` have no escrow gate | +| K7 | P4 low | escrow PUT split in two statements; a failure leaves a row whose hash claims a key its blob lacks | hub `handler.go:1388, 1412-1420`; `store.go:3402-3408` | +| K8 | P4 | operator off-site re-issue resets the provider password before saving it | hub `offsite.go:217-245` | + +**Checked SAFE (reviewer):** mint guard while the hub holds a package (`offbox.go:542-555, 659-720`); orphaned repo moved aside, never re-inited/deleted (`offbox.go:886-943, 332-397`); supersession retains both blobs (`store.go:3343-3416`); host delete demotes not destroys (`store.go:3164-3229`); ceremony two-word confirm; `escrow.Create` self-verifies; recovery refuses to replace a different local key; `ClearOffboxTarget`; PBS wrapper has no delete verb; hub abandon delete limited to one path after a delay (`offsitekeys/service.go:464-496`). diff --git a/documentation/audits/dataloss-hunt-2026-10-20/C1-restore-rollback-candidates.md b/documentation/audits/dataloss-hunt-2026-10-20/C1-restore-rollback-candidates.md new file mode 100644 index 00000000..ee3c0398 --- /dev/null +++ b/documentation/audits/dataloss-hunt-2026-10-20/C1-restore-rollback-candidates.md @@ -0,0 +1,24 @@ +# C1 reviewer 1 — restore and rollback (fresh, read-only; clones at controller 7ca59e2 / v0.336.0) + +Raw candidates as returned by the reviewer, condensed; each is UNVERIFIED until C2 marks it. File:line are in the controller unless named. + +| # | Sev guess | harm | mechanism | +|---|---|---|---| +| R1 | P2 high | A local / second-drive unit restore that fails halfway restarts the app on half-replaced data, no undo copy; the next night's capture then overwrites the only restore point with the broken state | `internal/backup/restore_unit.go:429-431` failed stop only warns; `restore.go:217-218` `docker volume rm -f` error ignored before untar (10-min limit `:237-241`); `restore_unit.go:466-486` DB replay failure → app started anyway; one restore point per app, promoted in place (`atomicPromoteTar`) | +| R2 | P2 high | A power cut / controller restart during a restore starts the app on a mixed state; the undo copy is never used at boot | `offbox_reconstitute.go:942` marker → `appstop_marker.go:307` StartStack with no restore check; `offbox_restore_undo.go:137` undo dir only dropped, never read at start; unit restore and .fab import set no marker → `bootrecon/bootrecon.go:210-213,269` starts the app; updates have a journal (`stacks/update.go:1420-1489`), restores do not | +| R3 | P2 high | A cut-off shared-folder restore can be „placed" and reported as restored while files are missing; scratch deleted | `shares_restore.go:138` readiness = `rErr == nil && len(entries) > 0` (R-358 class, not fixed for shares); 30-min ctx `web/offbox_handlers.go:716`; place reports N files and deletes scratch `:301`; free-space gate fails open and fixed 2 GiB `:107` | +| R4 | P2 high | A `.fab` export silently drops a whole drive folder that contains a symlink; import never sets owners | `appexport/fabplan.go:292` `tar.FileInfoHeader(info,"")` then `os.Open`+`io.Copy` on a symlink; `export.go:714` warn-and-continue; `assertBundleDataComplete` refuses only when no data at all; `restore.go:1157-1176` extracts only dirs/regular files, no chown | +| R5 | P2 med-high | Second-drive whole restore swaps files, then refuses the DB half, leaves the app STOPPED and says „the app is untouched" | `tier2_whole.go` stops + merges first, then `RestoreFromRecoveryUnitAtWith` whose refusals (`restore_unit.go:366-371, 386-389, 413-416, 420-423`) assume nothing happened; en.json:2667 „the restore did not start. The app is untouched" | +| R6 | P2 med | `.fab` import into a STOPPED app merges into existing volumes; the default export (stop box ticked) has no SQL dump | `appexport/restore.go:361-374` volumes removed only if running; `docker cp` merge `:721`; `templates/app_data.html:221` stop default; `export.go:262` stop before `dumpDatabase` `:366`; `appbackup/dbdump.go:142` discovers only running containers | +| R7 | P2/P3 high | `.fab` import has no concurrency guard, no undo, no record | `restore.go:181-186`; `web/handler_export.go:312-353` no `restoreOpBlocked()`; `RemoveStackVolumes` before anything else, no copy | +| R8 | P2 med | `.fab` import drops the family gate, sign-up lock and version pin → a family-only app opens to anyone | `cmd/controller/main.go:3517-3530` bare `AppConfig`; `stacks/family_gate.go:173-190` removes the gate file when `FamilyGate == nil`; R-773 fix only on the unit path (`stacks/deploy.go:840-870`) | +| R9 | P3 med | `.fab` import trusts the bundle's HDD_PATH; empty → relative path; older userdata overwrites newer | `restore.go:589-631` | +| R10 | P3 high | `.fab` DB import: plain psql without ON_ERROR_STOP; postgis not classified | `restore.go:1052-1053`, `:928-937` | +| R11 | P3 med | volume replace never checks the old volume is gone | `restore.go:218` | +| R12 | P3 high | interrupted second-drive whole restore leaves `.felhom-restoring` (O_EXCL) → every retry fails „file exists" | `tier2_whole.go:86` | +| R13 | P3 design | full off-site download capped at 30 min per press; household not told to retry | `web/offbox_handlers.go:455` | +| R14 | P4 med | place/full restore re-read the LATEST snapshot instead of the downloaded one | `offbox_restore.go:675-679`, `offbox_reconstitute.go:657-661` | + +**Inventory drift (July → now):** three more customer restore entries exist: the Tier-2 unit restore (R-102), the Tier-2 whole restore (decision 26, `tier2_whole.go`), the kept-data Load (`kept_load.go`). + +**Checked SAFE (reviewer):** `.fab` path confinement (`appexport/restore.go:1104-1107,1152-1155`; `validate.go:28-66`; `web/handler_export.go:385-394`); off-site placement map refuses `..`/outside/`backups/` (`offbox_restore.go:603-640`); R-358 marker after restic success; full off-site restore fit/safety-dump/undo checks before stop, fail closed (`offbox_reconstitute.go:719-818, 909-927`); Tier-1 refusals before change; main loader `ON_ERROR_STOP=1 --single-transaction` (`appbackup/dbdump.go:775-784`); Tier-2 file restore `rsync --ignore-existing`, never `--delete`; whole restore newer-wins + Lchown; **NFC/NFD: no normalisation anywhere — every lane compares bytes, so the missing-only compare sees TWO names (consistent on one filesystem)**; agent RestoreLXC force only when asked. Not reviewed: `kept_load.go`, hub operator restore paths. diff --git a/documentation/audits/rehearsal-2026-10-20/A0-baseline/versions.txt b/documentation/audits/rehearsal-2026-10-20/A0-baseline/versions.txt new file mode 100644 index 00000000..4733ee1c --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/A0-baseline/versions.txt @@ -0,0 +1,17 @@ +# Versions measured at the start of the rehearsal (2026-10-20 11:40 CEST = 09:40 UTC) +# Method: git origin/main per repo; hub pod image (kubectl); hub /configuration page (token read); hub /hosts and /configs pages; +# installer read live from https://felhom.eu/scripts/felhom-host-install.sh; ISO link + checksum read from https://felhom.eu/letoltes +felhom-controller main 7ca59e2d (v0.336.0 released 10:56 CEST; brief said ae2bfb2f / v0.335.0 — CHANGED before the run started) +felhom-agent main 4e1621c6 (v0.161.0 released + delivered to the 3 test boxes; brief said 51521230) +felhom.eu main 5c487313 (brief said b0ae2c57) +app-catalog main c50adfc6 (brief said 5e37aa6e) +templates in catalog: 65 (ls templates/) +hub pod image: gitea.dooplex.hu/admin/felhom-hub:0.162.0 +hub global floor (controller): v0.336.0 (DB override); min_agent 0.158.1 +hub Day-0 manifest: agent 0.161.0 (sha ff4e6dc5…), golden 0.331.0 (sha 3881de62…) +installer served at felhom.eu/scripts: SCRIPT_VERSION=1.35.0 +ISO on the download page: felhom-installer-1.29.0-pve9.2-1.iso, published sha256 dceacae5da247d76cad065bf6c0d3bbefac8d8a5f8e571db2fd8449a97e94829 +fleet (hub /hosts, /configs): demo-felhom, demo-hp, tester-1 = agent 0.161.0, controller 0.336.0; Tester-2 = agent 0.142.0, controller 0.292.0 (DOWN, laptop off) +Tester4: customer exists (domain tester4.test); NO host record (old Tester4-31fa0e is gone — precondition met); + off-site: NOT enabled — absent from the hub's 'customers actually using an offsite tier' list (offsite_box.go:166-168), + positive control: the other four customers are listed; no WireGuard peer for Tester4 on ep0's peer table. diff --git a/documentation/audits/rehearsal-2026-10-20/A3-capacity/before.txt b/documentation/audits/rehearsal-2026-10-20/A3-capacity/before.txt new file mode 100644 index 00000000..746c0780 --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/A3-capacity/before.txt @@ -0,0 +1,19 @@ +Tue Oct 20 09:41:52 UTC 2026 + total used free shared buff/cache available +Mem: 29994 14381 528 200 15724 15612 +Swap: 8191 792 7399 +Name Type Status Total (KiB) Used (KiB) Available (KiB) % +felhom-pbs pbs active 0 0 0 0.00% +local dir active 40453376 28477900 9888360 70.40% +local-lvm lvmthin active 56487936 33192311 23295624 58.76% +nvme-scratch dir active 983379700 203081848 730271240 20.65% +Filesystem 1B-blocks Used Available Use% Mounted on +/dev/nvme0n1 1006980812800 207955812352 747797749760 22% /mnt/hdd_1 +/dev/mapper/pve-root 41424257024 29161369600 10125680640 75% / + 11:41:53 up 4 days, 6:49, 1 user, load average: 1.63, 1.42, 1.27 + VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID + 341 night1004-tester1 running 8192 200.00 1851 +VMID Status Lock Name +9201 running demo-hp +9202 running demo-hp-scratch +9401 stopped upgrade-harness diff --git a/documentation/audits/rehearsal-2026-10-20/A6-plan/order.md b/documentation/audits/rehearsal-2026-10-20/A6-plan/order.md new file mode 100644 index 00000000..f3c3b6d1 --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/A6-plan/order.md @@ -0,0 +1,32 @@ +# B6 — failure scenario order, drawn ONCE from seed 20261020 before round 1 (A6) + +Method: `random.Random(20261020)`; shuffle the ★ list [1..10], then shuffle the rest [11..16]; this file is written before B1 bound the box (2026-10-20 ~10:00 UTC). + +| run | scenario | what | +|---|---|---| +| 1 | ★2 | Power cut mid off-site restore (B8 run) | +| 2 | ★3 | Data disk 100 % during nightly backup (via own upload); system disk 97 % | +| 3 | ★5 | Slow lossy internet (1 Mbit/s up, 5 % loss) during first big off-site run | +| 4 | ★9 | Two actions at once: Restore+Remove in two tabs; double click Visszaallitas | +| 5 | ★7 | DST: clock 2026-10-25 01:30 CEST -> 04:00 CET, NTP off | +| 6 | ★1 | Power cut mid app install (image pull) | +| 7 | ★10 | Box off for 3 days (VM stopped, clock moved forward on start) | +| 8 | ★4 | Data drive pulled during upload, put back (same slot; other slot/order) | +| 9 | ★6 | Off-site path blocked for one night (ep0 + Storage Box dropped at the VM's outer side) | +| 10 | ★8 | Clock wrong at boot: -2 years, then +2 years, NTP blocked | +| 11 | 14 | App removed keeping data, installed again (use kept data) | +| 12 | 16 | English browser: one full pass of the dashboard | +| 13 | 11 | Hard reset mid app update | +| 14 | 12 | DNS answers nothing for an hour | +| 15 | 15 | Box restarted during the catch-up of scenario 10 | +| 16 | 13 | Second drive added later: offered as backup target; accept; remove safely | + +## Dependencies fixed NOW (before any result), so the order is never re-chosen after one + +- **2** needs a running off-site restore — it runs INSIDE B8's A.1.5 run whatever its slot; its slot is then used for the next scenario. +- **15** runs inside **10**'s catch-up (it is the restart during that catch-up) — it rides with 10. +- **5** needs the FIRST big off-site run — it runs on night one (B5) if off-site is on by then; otherwise at its slot with a fresh large upload, and it is marked „not the first run". +- **6** spans one night — it takes the second night if the run lasts; otherwise a shortened window (≥ 1 off-site schedule), marked. +- **7** (DST) and **8** (wrong clock) change the VM's clock; they run after the off-site and restore work they could disturb, but in this drawn order relative to each other. +- Anything that needs off-site (2, 5, 6) is SKIPPED, not reordered, if Tester4's off-site is still off when its slot comes. + diff --git a/documentation/audits/rehearsal-2026-10-20/B1-install/01-iso-download.txt b/documentation/audits/rehearsal-2026-10-20/B1-install/01-iso-download.txt new file mode 100644 index 00000000..d42346ad --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/B1-install/01-iso-download.txt @@ -0,0 +1,5 @@ +dceacae5da247d76cad065bf6c0d3bbefac8d8a5f8e571db2fd8449a97e94829 felhom-installer-1.29.0-pve9.2-1.iso + +dceacae5da247d76cad065bf6c0d3bbefac8d8a5f8e571db2fd8449a97e94829 rehearsal1020-felhom-installer-1.29.0-pve9.2-1.iso +1705324544 +secs=62 diff --git a/documentation/audits/rehearsal-2026-10-20/B1-install/02-vm-create.txt b/documentation/audits/rehearsal-2026-10-20/B1-install/02-vm-create.txt new file mode 100644 index 00000000..9c20f318 --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/B1-install/02-vm-create.txt @@ -0,0 +1,19 @@ +Formatting '/mnt/hdd_1/images/9320/vm-9320-disk-0.raw', fmt=raw size=274877906944 preallocation=off +scsi0: successfully created disk 'nvme-scratch:9320/vm-9320-disk-0.raw,size=256G' +Formatting '/mnt/hdd_1/images/9320/vm-9320-disk-1.raw', fmt=raw size=214748364800 preallocation=off +scsi1: successfully created disk 'nvme-scratch:9320/vm-9320-disk-1.raw,size=200G' +boot: order=scsi0;ide2 +cores: 4 +cpu: host +ide2: local:iso/rehearsal1020-felhom-installer-1.29.0-pve9.2-1.iso,media=cdrom,size=1665356K +memory: 8192 +meta: creation-qemu=11.0.3,ctime=1792489316 +name: rehearsal1020-box1 +net0: virtio=BC:24:11:3F:4F:3F,bridge=vmbr0 +ostype: l26 +scsi0: nvme-scratch:9320/vm-9320-disk-0.raw,size=256G +scsi1: nvme-scratch:9320/vm-9320-disk-1.raw,size=200G +scsihw: virtio-scsi-single +smbios1: uuid=323698f4-6179-4f14-83c0-8109044c5cda +vga: std +vmgenid: 976cd28b-5675-42f2-bd65-fb0ec8ae6be7 diff --git a/documentation/audits/rehearsal-2026-10-20/journal.md b/documentation/audits/rehearsal-2026-10-20/journal.md new file mode 100644 index 00000000..19e54ea1 --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/journal.md @@ -0,0 +1,20 @@ +# Rehearsal 2026-10-20 — journal (UTC unless marked; box local = CEST) + +| UTC | phase | observation | +|---|---|---| +| 09:31 | A0 | baselines read; controller moved to v0.336.0 (floor) at 08:54 UTC before the run began — the frozen build is 0.336.0, not the brief's 0.335.0 | +| 09:33 | A2 | old host Tester4-31fa0e is GONE from /hosts (precondition met) | +| 09:35 | A2 | Tester4 has NO off-site tier (hub /offsite lists only customers with an enabled descriptor; 4 others listed). Asked the operator to switch it on + to put the owner passphrase into sec | +| 09:37 | A3 | demo-hp: 15 GiB RAM available, nvme-scratch 697 GB free, local 11 GB free | +| 09:39 | C1 | four read-only reviewers started on lean clones (reports/register removed from the clones) | +| 09:40 | B1 | ISO downloaded from the link on felhom.eu/letoltes in 62 s; sha256 dceacae5… = the page = the .sha256 file | +| 09:42 | B1 | VM 9320 rehearsal1020-box1 created (4 vCPU, 8 GiB, 256 GB + 200 GB raw on nvme-scratch) and started | +| 09:42 | B1/B9 | GRUB auto-boots „Felhom telepítés / Install Felhom"; text „válassza ki a lemezt" is FORMAL (Ön) while the guide and dashboard say „te" | +| 09:46:50 | B1 | Install pressed (graphical installer, Hungarian keyboard kept, driven by `tools/vmctl.sh`) | +| ≤09:51 | B1 | Box rebooted by itself (auto-reboot ticked, the VM boots disk first = „USB pulled"); console shows the pairing code — 4 min after Install. Code stored in sec, blacked out on `screens/install/11-console.png` | +| 09:51 | A4 | Browser LXC 9330 works: Chromium 155.0.8059.79 (Debian 13 package) + Playwright 1.52.0; phone 390×844 and computer 1366×768, hu-HU and en-US | +| 09:52 | B1 | FINDING: on felhom.eu/letoltes the ISO link is the white bold FILE NAME — no colour, no underline, no button, on phone and computer (`screens/b1-site/`). No public page links to /letoltes (by design: the guide carries the URL) | +| 09:55 | B2 | Mailbox: the newest bind link is from 2026-10-18 19:41 UTC (sent by the old host's delete, valid to 10-25); its text says „Elkészült a Felhom dobozod, és készen áll az összekötésre" while no box existed | +| 09:56 | B2 | the 10-18 17:42 link (used then) → „Ez a hivatkozás érvénytelen vagy lejárt" + an „Új linket kérek" button (not pressed) | +| 09:56:58 | B2 | bind attempt 1: right pairing code + passphrase with one letter changed → „A megadott adatok nem megfelelőek…" — generic, correct; the page does not say how many of the 5 tries are left | +| 09:57:09 | B2 | bind attempt 2: the stored value TESTER4_PASSWORD (from 2026-10-17, before Tester4 was re-created 10-18) → refused: it is stale. 2/5 used. **BLOCKED on the operator: the owner passphrase** | diff --git a/documentation/audits/rehearsal-2026-10-20/screens/b1-site/b1-letoltes-desktop-en.png b/documentation/audits/rehearsal-2026-10-20/screens/b1-site/b1-letoltes-desktop-en.png new file mode 100644 index 00000000..6d22a451 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/b1-site/b1-letoltes-desktop-en.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/b1-site/b1-letoltes-desktop-hu.png b/documentation/audits/rehearsal-2026-10-20/screens/b1-site/b1-letoltes-desktop-hu.png new file mode 100644 index 00000000..6d22a451 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/b1-site/b1-letoltes-desktop-hu.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/b1-site/b1-letoltes-phone-en.png b/documentation/audits/rehearsal-2026-10-20/screens/b1-site/b1-letoltes-phone-en.png new file mode 100644 index 00000000..43986264 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/b1-site/b1-letoltes-phone-en.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/b1-site/b1-letoltes-phone-hu.png b/documentation/audits/rehearsal-2026-10-20/screens/b1-site/b1-letoltes-phone-hu.png new file mode 100644 index 00000000..43986264 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/b1-site/b1-letoltes-phone-hu.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/install/01-boot.png b/documentation/audits/rehearsal-2026-10-20/screens/install/01-boot.png new file mode 100644 index 00000000..d42b2021 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/install/01-boot.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/install/02-eula.png b/documentation/audits/rehearsal-2026-10-20/screens/install/02-eula.png new file mode 100644 index 00000000..3bcb30e2 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/install/02-eula.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/install/03-disk.png b/documentation/audits/rehearsal-2026-10-20/screens/install/03-disk.png new file mode 100644 index 00000000..e602b7c6 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/install/03-disk.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/install/04-location.png b/documentation/audits/rehearsal-2026-10-20/screens/install/04-location.png new file mode 100644 index 00000000..5f7cc190 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/install/04-location.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/install/05-password.png b/documentation/audits/rehearsal-2026-10-20/screens/install/05-password.png new file mode 100644 index 00000000..0bd14f5a Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/install/05-password.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/install/06-pw-email.png b/documentation/audits/rehearsal-2026-10-20/screens/install/06-pw-email.png new file mode 100644 index 00000000..ff046025 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/install/06-pw-email.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/install/07-network.png b/documentation/audits/rehearsal-2026-10-20/screens/install/07-network.png new file mode 100644 index 00000000..fc2d72ce Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/install/07-network.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/install/07b-hostname.png b/documentation/audits/rehearsal-2026-10-20/screens/install/07b-hostname.png new file mode 100644 index 00000000..70426c5f Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/install/07b-hostname.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/install/08-summary.png b/documentation/audits/rehearsal-2026-10-20/screens/install/08-summary.png new file mode 100644 index 00000000..835ca028 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/install/08-summary.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/install/09-installing.png b/documentation/audits/rehearsal-2026-10-20/screens/install/09-installing.png new file mode 100644 index 00000000..cc52b36a Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/install/09-installing.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/install/10-progress.png b/documentation/audits/rehearsal-2026-10-20/screens/install/10-progress.png new file mode 100644 index 00000000..04549227 Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/install/10-progress.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/screens/install/11-console.png b/documentation/audits/rehearsal-2026-10-20/screens/install/11-console.png new file mode 100644 index 00000000..bbd0e57f Binary files /dev/null and b/documentation/audits/rehearsal-2026-10-20/screens/install/11-console.png differ diff --git a/documentation/audits/rehearsal-2026-10-20/tools/bind.py b/documentation/audits/rehearsal-2026-10-20/tools/bind.py new file mode 100644 index 00000000..1e1da339 --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/tools/bind.py @@ -0,0 +1,45 @@ +#!/opt/pw/bin/python +"""bind.py — the household's self-bind page, in real Chromium (rehearsal 2026-10-20, B2). + +Env (handed in by brun.sh, never on a command line): BIND_TOKEN, PAIRING, PASS. + --typo : change ONE character of PASS before sending (a household's typo; the value is never printed) +Screenshots are taken BEFORE the fields are filled and AFTER the answer page loads — the passphrase field +is type=text, so a filled-form screenshot would show it. Prints the answer page's visible text. +usage: bind.py OUTPREFIX [--typo] [--size phone|desktop] [--lang hu|en] [--get-only] +""" +import os, re, sys +from playwright.sync_api import sync_playwright + +out = sys.argv[1]; args = sys.argv[2:] +size = args[args.index("--size") + 1] if "--size" in args else "phone" +lang = args[args.index("--lang") + 1] if "--lang" in args else "hu" +vp = {"phone": dict(viewport={"width": 390, "height": 844}, device_scale_factor=2, is_mobile=True, has_touch=True), + "desktop": dict(viewport={"width": 1366, "height": 768})}[size] +loc = {"hu": "hu-HU", "en": "en-US"}[lang] +url = "https://hub.felhom.eu/bind/" + os.environ["BIND_TOKEN"] +with sync_playwright() as p: + b = p.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"]) + ctx = b.new_context(locale=loc, extra_http_headers={"Accept-Language": loc}, timezone_id="Europe/Budapest", **vp) + pg = ctx.new_page() + r = pg.goto(url, wait_until="networkidle") + pg.screenshot(path=f"{out}-1-open-{size}-{lang}.png", full_page=True) + print("GET", r.status, pg.title()) + body = pg.inner_text("body") + print(re.sub(r"\s+", " ", body)[:600]) + if "--get-only" in args or not pg.locator("#pairing_code").count(): + b.close(); sys.exit(0) + pw = os.environ["PASS"] + if "--typo" in args: # swap one inner letter for its neighbour in the alphabet + i = len(pw) // 2 + while not pw[i].isalpha(): + i += 1 + pw = pw[:i] + ("b" if pw[i] != "b" else "c") + pw[i + 1:] + pg.fill("#pairing_code", os.environ["PAIRING"]) + pg.fill("#passphrase", pw) + with pg.expect_navigation(wait_until="networkidle"): + pg.click("button[type=submit]") + # blank both inputs before the screenshot, in case a re-rendered form echoes a value + pg.evaluate("document.querySelectorAll('input').forEach(e => e.value = '')") + pg.screenshot(path=f"{out}-2-answer-{size}-{lang}.png", full_page=True) + print("POST answer:", re.sub(r"\s+", " ", pg.inner_text("body"))[:600]) + b.close() diff --git a/documentation/audits/rehearsal-2026-10-20/tools/brpull.sh b/documentation/audits/rehearsal-2026-10-20/tools/brpull.sh new file mode 100755 index 00000000..666a311c --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/tools/brpull.sh @@ -0,0 +1,6 @@ +#!/bin/bash +# brpull.sh DEST — copy every new screenshot out of the browser LXC into DEST, then empty /root/shots there. +set -euo pipefail; mkdir -p "$1" +ssh demo-hp "LC_ALL=C pct exec ${BR_CT:-9330} -- tar cf - -C /root/shots ." | tar xf - -C "$1" +ssh demo-hp "LC_ALL=C pct exec ${BR_CT:-9330} -- find /root/shots -type f -delete" +ls "$1" | wc -l diff --git a/documentation/audits/rehearsal-2026-10-20/tools/brun.sh b/documentation/audits/rehearsal-2026-10-20/tools/brun.sh new file mode 100755 index 00000000..0419bcdd --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/tools/brun.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# brun.sh [--env A,B] [--push LOCALFILE]... -- CMD ARGS — run CMD in the browser LXC (CT 9330 on demo-hp). +# Secrets named with --env are read from THIS process's env (run brun.sh under `sec run A,B -- …`) and +# travel on stdin through envrun.py, never on a command line. Files pushed land in /root/work/. +set -euo pipefail +CT=${BR_CT:-9330}; envs=""; pushes=() +while [ "$1" != "--" ]; do + case "$1" in --env) envs=$2; shift 2;; --push) pushes+=("$2"); shift 2;; *) echo "brun: bad arg $1" >&2; exit 1;; esac +done; shift +for f in "${pushes[@]}"; do + ssh demo-hp "cat > /tmp/brun-push-$(basename "$f")" < "$f" + ssh demo-hp "LC_ALL=C pct push $CT /tmp/brun-push-$(basename "$f") /root/work/$(basename "$f") && rm -f /tmp/brun-push-$(basename "$f")" +done +{ + IFS=, read -ra names <<< "$envs" + # each entry is NAME or SRC:DST (send the value of SRC under the name DST) + for n in "${names[@]}"; do [ -n "$n" ] || continue; src=${n%%:*}; dst=${n##*:} + printf '%s=%s\n' "$dst" "$(printf '%s' "${!src}" | base64 -w0)"; done + printf '\n' +} | ssh demo-hp "LC_ALL=C pct exec $CT -- /usr/local/bin/envrun.py $(printf '%q ' "$@")" diff --git a/documentation/audits/rehearsal-2026-10-20/tools/envrun.py b/documentation/audits/rehearsal-2026-10-20/tools/envrun.py new file mode 100755 index 00000000..a2b9808f --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/tools/envrun.py @@ -0,0 +1,12 @@ +#!/usr/bin/python3 +"""envrun.py — read NAME=base64value lines from stdin until an empty line, put them in the environment, exec argv. +Lets DooPlex hand a secret to a command in the browser LXC without it ever touching a command line.""" +import base64, os, sys +env = dict(os.environ) +for line in sys.stdin: + line = line.rstrip("\n") + if not line: + break + k, v = line.split("=", 1) + env[k] = base64.b64decode(v).decode() +os.execvpe(sys.argv[1], sys.argv[1:], env) diff --git a/documentation/audits/rehearsal-2026-10-20/tools/gen_household.py b/documentation/audits/rehearsal-2026-10-20/tools/gen_household.py new file mode 100644 index 00000000..a3627fa4 --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/tools/gen_household.py @@ -0,0 +1,326 @@ +#!/usr/bin/python3 +"""gen_household.py — the pretend household's data for the rehearsal of 2026-10-20 (brief A5). + +SYNTHETIC ONLY: no real person, photo or document. Seed 20261020 — re-running gives the same bytes +(except the films' container timestamps, which ffmpeg is told to zero with -fflags +bitexact). +Writes everything under OUT (default /root/data) and, LAST, OUT/MANIFEST.tsv: + pathsizesha256appclassnote +class: A = an app whose FILES go off-site (immich, nextcloud, paperless-ngx; 07 §6.2 'mandatory' legs) + B = an app whose data lives in Docker volumes (vaultwarden, mealie, actual, radicale, bookstack) + M = media library binds (jellyfin films) — in NO tier, must never go off-site (07 §6) +No password is generated here — the family's passwords are minted on DooPlex straight into `sec`. + +usage: gen_household.py [OUT] +""" +import datetime as dt +import hashlib +import json +import os +import random +import shutil +import subprocess +import sys +import unicodedata + +from PIL import Image, ImageDraw, ImageFont + +SEED = 20261020 +OUT = sys.argv[1] if len(sys.argv) > 1 else "/root/data" +R = random.Random(SEED) +FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" +PEOPLE = [("odon", "Kovács Ödön"), ("irisz", "Szűts Írisz"), ("kata", "Kovács Kata")] +manifest = [] # (relpath, app, cls, note) + + +def p(*parts): + path = os.path.join(OUT, *parts) + os.makedirs(os.path.dirname(path), exist_ok=True) + return path + + +def add(path, app, cls, note=""): + manifest.append((os.path.relpath(path, OUT), app, cls, note)) + + +# ---------------------------------------------------------------- photos (immich, class A) +def photos(): + font = ImageFont.truetype(FONT, 64) + small = ImageFont.truetype(FONT, 28) + places = [("Balaton", 46.90, 17.89), ("Budapest", 47.4979, 19.0402), ("Eger", 47.9025, 20.3772), + ("Pécs", 46.0727, 18.2323), ("Tokaj", 48.1186, 21.4089)] + for i in range(300): + year = 2009 + i % 18 + when = dt.datetime(year, R.randint(1, 12), R.randint(1, 28), R.randint(7, 20), R.randint(0, 59), R.randint(0, 59)) + w, h = (1600, 1200) if i % 3 else (1200, 1600) + img = Image.new("RGB", (w, h)) + d = ImageDraw.Draw(img) + c1 = (R.randint(0, 255), R.randint(0, 255), R.randint(0, 255)) + c2 = (R.randint(0, 255), R.randint(0, 255), R.randint(0, 255)) + for y in range(0, h, 4): + t = y / h + d.rectangle([0, y, w, y + 4], fill=tuple(int(c1[k] * (1 - t) + c2[k] * t) for k in range(3))) + for _ in range(40): # shapes so the JPEG is not trivially small and every photo differs + x0, y0 = R.randint(0, w), R.randint(0, h) + d.ellipse([x0, y0, x0 + R.randint(20, 300), y0 + R.randint(20, 300)], + fill=(R.randint(0, 255), R.randint(0, 255), R.randint(0, 255))) + place = places[i % len(places)] + d.text((60, 60), f"Családi fotó #{i:03d}", font=font, fill="white") + d.text((60, 150), f"{when:%Y.%m.%d} — {place[0]} — szintetikus tesztkép", font=small, fill="white") + path = p("immich", f"{year}", f"IMG_{year}{i:04d}.jpg") + img.save(path, "JPEG", quality=88) + args = ["exiftool", "-q", "-overwrite_original", f"-DateTimeOriginal={when:%Y:%m:%d %H:%M:%S}", + f"-CreateDate={when:%Y:%m:%d %H:%M:%S}", "-Make=Felhom-rehearsal", "-Model=synthetic"] + if i % 3 == 0: # a third carry GPS + args += [f"-GPSLatitude={place[1]}", "-GPSLatitudeRef=N", f"-GPSLongitude={place[2]}", "-GPSLongitudeRef=E"] + subprocess.run(args + [path], check=True) + add(path, "immich", "A", f"exif {when:%Y-%m-%d}" + (" gps" if i % 3 == 0 else "")) + # two exact duplicates of photo 7 under other names + src = p("immich", "2016", "IMG_20160007.jpg") + for n in ("IMG_20160007_masolat.jpg", "duplikatum_IMG_20160007.jpg"): + dst = p("immich", "edge", n) + shutil.copyfile(src, dst) + add(dst, "immich", "A", "exact duplicate of 2016/IMG_20160007.jpg") + # a truncated (broken) JPEG + raw = open(p("immich", "2020", "IMG_20200011.jpg"), "rb").read() + path = p("immich", "edge", "torott_kep.jpg") + open(path, "wb").write(raw[: len(raw) // 3]) + add(path, "immich", "A", "truncated JPEG (first third)") + # a zero-byte file + path = p("immich", "edge", "ures.jpg") + open(path, "wb").close() + add(path, "immich", "A", "zero bytes") + # a PNG + path = p("immich", "edge", "rajz_Kata.png") + img = Image.new("RGB", (800, 600), (250, 240, 200)) + ImageDraw.Draw(img).text((40, 40), "Kata rajza — árvíztűrő tükörfúrógép", font=small, fill="black") + img.save(path, "PNG") + add(path, "immich", "A", "PNG") + # a short video (<= 150 MB) + path = p("immich", "edge", "nyaralas_2025.mp4") + subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=1920x1080:rate=30", + "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000", "-t", "75", + "-c:v", "libx264", "-preset", "ultrafast", "-b:v", "12M", "-c:a", "aac", "-shortest", + "-fflags", "+bitexact", "-metadata", "creation_time=2025-07-14T10:00:00Z", path], check=True) + add(path, "immich", "A", "video ~75 s") + + +# ---------------------------------------------------------------- documents (paperless-ngx, class A) +def documents(): + from reportlab.lib.pagesizes import A4 + from reportlab.pdfbase import pdfmetrics + from reportlab.pdfbase.ttfonts import TTFont + from reportlab.pdfgen import canvas + pdfmetrics.registerFont(TTFont("DejaVu", FONT)) + sellers = ["Mátra Villamosság Kft.", "Fővárosi Vízművek (teszt)", "Tisza Gázszolgáltató Zrt. (teszt)", + "Kovács és Társa Bt.", "Zöldség-Gyümölcs Kisbolt"] + for i in range(50): + path = p("paperless", f"szamla_{2024 + i % 3}_{i:03d}.pdf") + c = canvas.Canvas(path, pagesize=A4, invariant=1) + c.setFont("DejaVu", 18) + c.drawString(60, 780, f"Számla — {R.choice(sellers)}") + c.setFont("DejaVu", 11) + y = 740 + for line in [f"Számlaszám: TESZT-{SEED}-{i:04d}", f"Vevő: {PEOPLE[i % 3][1]}", + f"Kelt: {2024 + i % 3}.{1 + i % 12:02d}.{1 + i % 27:02d}", + "Tétel: árvíztűrő tükörfúrógép (szintetikus)", f"Összeg: {R.randint(2, 900) * 100} Ft", + "Fizetési mód: átutalás", "Ez egy tesztszámla, valódi ügylet nem tartozik hozzá.", + "ŐŰ őű ÁÉÍÓÖÚÜ áéíóöúü — ékezetpróba"]: + c.drawString(60, y, line) + y -= 18 + c.showPage() + c.save() + add(path, "paperless", "A", "Hungarian text invoice") + # image-only PDF (no text layer — OCR must work) + img = Image.new("RGB", (1240, 1754), "white") + d = ImageDraw.Draw(img) + f = ImageFont.truetype(FONT, 40) + d.text((100, 150), "Szkennelt nyilatkozat", font=f, fill="black") + d.text((100, 250), "Alulírott Kovács Ödön kijelentem, hogy", font=f, fill="black") + d.text((100, 320), "ez egy szintetikus, kép alapú PDF.", font=f, fill="black") + path = p("paperless", "szkennelt_nyilatkozat.pdf") + img.save(path, "PDF", resolution=150) + add(path, "paperless", "A", "image-only PDF (OCR)") + # password-protected PDF (the app cannot open it without the password) + plain = p("paperless", "_tmp_jelszavas.pdf") + c = canvas.Canvas(plain, pagesize=A4, invariant=1) + c.setFont("DejaVu", 14) + c.drawString(60, 780, "Jelszóval védett bankszámlakivonat (szintetikus)") + c.save() + path = p("paperless", "jelszavas_kivonat.pdf") + # the PDF's own password is a fixed test literal, not a household secret + subprocess.run(["qpdf", "--encrypt", "teszt-pdf-jelszo", "teszt-pdf-jelszo", "256", "--", plain, path], check=True) + os.remove(plain) + add(path, "paperless", "A", "password-protected PDF") + # a 100-page PDF + path = p("paperless", "kezikonyv_100_oldal.pdf") + c = canvas.Canvas(path, pagesize=A4, invariant=1) + for pg in range(1, 101): + c.setFont("DejaVu", 12) + c.drawString(60, 800, f"Háztartási kézikönyv — {pg}. oldal") + for k in range(40): + c.drawString(60, 770 - k * 18, f"{pg}.{k}: Ügyelj a fűtésre, a vízórára és a biztosítékokra (teszt).") + c.showPage() + c.save() + add(path, "paperless", "A", "100 pages") + + +# ---------------------------------------------------------------- files (nextcloud, class A) +def files(): + base = p("nextcloud", "Csalad", ".keep") + os.remove(base) if os.path.exists(base) else None + root = os.path.dirname(base) + # a tree 12 folders deep with ~3000 small files + n = 0 + for top in range(6): + d = os.path.join(root, f"Archívum_{top}") + for depth in range(12): + d = os.path.join(d, f"szint{depth:02d}") + os.makedirs(d, exist_ok=True) + for k in range(42): + path = os.path.join(d, f"jegyzet_{top}_{depth}_{k}.txt") + with open(path, "w", encoding="utf-8") as fh: + fh.write(f"Jegyzet {top}/{depth}/{k} — {R.random():.12f} — őűáé\n" * R.randint(1, 30)) + add(path, "nextcloud", "A", "tree file") + n += 1 + # 1.5 GB of pseudorandom bytes (no dedup cheats), deterministic from the seed via AES-CTR + path = os.path.join(root, "nagy_fajl_1_5GB.bin") + key = hashlib.sha256(f"felhom-rehearsal-{SEED}".encode()).hexdigest() + with open(path, "wb") as fh: + subprocess.run(f"head -c 1610612736 /dev/zero | openssl enc -aes-256-ctr -nosalt -K {key} -iv " + f"{'0' * 32}", shell=True, check=True, stdout=fh) + add(path, "nextcloud", "A", "1.5 GiB random") + edge = os.path.join(root, "Kulonleges_nevek") + os.makedirs(edge, exist_ok=True) + name = "Ödön születésnapja – fénykép lista.txt" + for form in ("NFC", "NFD"): + path = os.path.join(edge, unicodedata.normalize(form, name)) + with open(path, "w", encoding="utf-8") as fh: + fh.write(f"Ugyanaz a név, {form} formában (Mac kliens NFD-t küld).\n") + add(path, "nextcloud", "A", f"accented name in {form}") + stem = "hosszu_nev_" + "ő" * 115 # 11 + 230 bytes + long_name = (stem + ".txt").encode() + assert len(long_name) <= 255, len(long_name) + path = os.path.join(edge, long_name.decode()) + open(path, "w").write(f"name is {len(long_name)} bytes in UTF-8\n") + add(path, "nextcloud", "A", f"name {len(long_name)} bytes") + for nm, note in (("-kezdo_kotojel.txt", "leading dash"), ("Kata's lista szóközökkel.txt", "spaces and quote")): + path = os.path.join(edge, nm) + open(path, "w").write(note + "\n") + add(path, "nextcloud", "A", note) + for nm, when in (("regi_1980.txt", dt.datetime(1980, 1, 2, 12, 0)), ("jovo_2030.txt", dt.datetime(2030, 6, 1, 12, 0))): + path = os.path.join(edge, nm) + open(path, "w").write(f"mtime {when:%Y-%m-%d}\n") + ts = when.replace(tzinfo=dt.timezone.utc).timestamp() + os.utime(path, (ts, ts)) + add(path, "nextcloud", "A", f"mtime {when:%Y}") + path = os.path.join(edge, "csak_olvashato.txt") + open(path, "w").write("read-only\n") + os.chmod(path, 0o444) + add(path, "nextcloud", "A", "read-only (0444)") + link = os.path.join(edge, "link_a_nagy_fajlra") + if os.path.lexists(link): + os.remove(link) + os.symlink("../nagy_fajl_1_5GB.bin", link) + manifest.append((os.path.relpath(link, OUT), "nextcloud", "A", "SYMLINK -> ../nagy_fajl_1_5GB.bin")) + + +# ---------------------------------------------------------------- small apps (class B) +def small_apps(): + logins = [] + for i in range(50): + site = R.choice(["bank", "posta", "ugyfelkapu-teszt", "webshop", "iskola", "konyvtar", "szolgaltato"]) + logins.append({"name": f"{site}-{i:02d}", "uri": f"https://{site}{i}.example.invalid/", + "username": f"{PEOPLE[i % 3][0]}{i}@example.invalid", + "folder": "Közös" if i < 10 else PEOPLE[i % 3][0]}) + path = p("vaultwarden", "logins.json") + json.dump(logins, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=1) + add(path, "vaultwarden", "B", "50 logins (passwords generated at upload, not stored here)") + for k in range(2): + path = p("vaultwarden", f"melleklet_{k}.pdf") + Image.new("RGB", (400, 300), (k * 100, 50, 150)).save(path, "PDF") + add(path, "vaultwarden", "B", "attachment") + recipes = [ + {"name": "Gulyásleves", "ingredients": ["50 dkg marhalábszár", "2 fej vöröshagyma", "pirospaprika", "burgonya", + "sárgarépa", "fehérrépa", "kömény"], + "steps": ["A hagymát zsíron megpirítjuk.", "Hozzáadjuk a húst és a paprikát.", "Felöntjük vízzel, főzzük."]}, + {"name": "Csirkepörkölt", "ingredients": ["1 egész csirke", "2 fej hagyma", "1 paprika", "1 paradicsom", + "pirospaprika", "só"], + "steps": ["A hagymát üvegesre pirítjuk.", "Rátesszük a csirkét.", "Lassú tűzön puhára pároljuk."]}] + for r in recipes: + path = p("mealie", r["name"] + ".json") + json.dump(r, open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=1) + add(path, "mealie", "B", "recipe") + path = p("mealie", r["name"] + ".jpg") + img = Image.new("RGB", (1024, 768), (180, 60, 30)) + ImageDraw.Draw(img).text((40, 40), r["name"], font=ImageFont.truetype(FONT, 72), fill="white") + img.save(path, "JPEG", quality=85) + add(path, "mealie", "B", "recipe photo") + path = p("actual", "tranzakciok.csv") + with open(path, "w", encoding="utf-8") as fh: + fh.write("Date,Payee,Notes,Amount\n") + for i in range(200): + d = dt.date(2026, 1, 1) + dt.timedelta(days=i % 290) + amt = -R.randint(5, 4000) * 100 if i % 7 else R.randint(300, 800) * 1000 + fh.write(f"{d.isoformat()},{R.choice(['Közért', 'Benzinkút', 'Gyógyszertár', 'Fizetés', 'Rezsi'])}," + f"teszt tétel {i},{amt}\n") + add(path, "actual", "B", "200 HUF transactions") + path = p("radicale", "csalad.ics") + with open(path, "w", encoding="utf-8", newline="\r\n") as fh: + fh.write("BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//felhom rehearsal//EN\n" + "BEGIN:VTIMEZONE\nTZID:Europe/Budapest\n" + "BEGIN:DAYLIGHT\nTZOFFSETFROM:+0100\nTZOFFSETTO:+0200\nTZNAME:CEST\nDTSTART:19700329T020000\n" + "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU\nEND:DAYLIGHT\n" + "BEGIN:STANDARD\nTZOFFSETFROM:+0200\nTZOFFSETTO:+0100\nTZNAME:CET\nDTSTART:19701025T030000\n" + "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU\nEND:STANDARD\nEND:VTIMEZONE\n" + "BEGIN:VEVENT\nUID:rehearsal-dst-1@felhom\nDTSTAMP:20261020T000000Z\n" + "DTSTART;TZID=Europe/Budapest:20261022T023000\nDTEND;TZID=Europe/Budapest:20261022T030000\n" + "RRULE:FREQ=DAILY;COUNT=7\nSUMMARY:Gyógyszer — éjjel 2:30 (DST-próba)\nEND:VEVENT\n" + "BEGIN:VEVENT\nUID:rehearsal-weekly-1@felhom\nDTSTAMP:20261020T000000Z\n" + "DTSTART;TZID=Europe/Budapest:20261018T090000\nDTEND;TZID=Europe/Budapest:20261018T100000\n" + "RRULE:FREQ=WEEKLY;COUNT=4\nSUMMARY:Vasárnapi ebéd a nagyiéknál\nEND:VEVENT\nEND:VCALENDAR\n") + add(path, "radicale", "B", "recurring event crossing DST 2026-10-25") + for i in range(5): + path = p("bookstack", f"oldal_{i}.md") + open(path, "w", encoding="utf-8").write( + f"# Családi wiki {i}. oldal\n\nA wifi jelszava a hűtőn van (teszt). Ékezetek: árvíztűrő tükörfúrógép.\n") + add(path, "bookstack", "B", "wiki page") + path = p("bookstack", f"kep_{i}.png") + Image.new("RGB", (640, 480), (20 * i, 120, 200)).save(path, "PNG") + add(path, "bookstack", "B", "wiki image") + + +# ---------------------------------------------------------------- media (jellyfin, class M) +def media(): + for k, (title, pattern) in enumerate((("Csaladi_film_1", "testsrc"), ("Csaladi_film_2", "smptebars"))): + path = p("jellyfin", "Filmek", f"{title} (2026)", f"{title} (2026).mkv") + subprocess.run(["ffmpeg", "-loglevel", "error", "-y", "-f", "lavfi", "-i", f"{pattern}=size=1920x1080:rate=25", + "-f", "lavfi", "-i", "anoisesrc=color=pink:seed=%d" % (SEED + k), "-t", "540", + "-c:v", "libx264", "-preset", "ultrafast", "-b:v", "14M", "-maxrate", "14M", "-bufsize", "28M", + "-x264-params", "nal-hrd=cbr", "-c:a", "aac", "-shortest", "-fflags", "+bitexact", path], check=True) + add(path, "jellyfin", "M", "film ~1 GB — must never go off-site") + + +def write_manifest(): + path = os.path.join(OUT, "MANIFEST.tsv") + with open(path, "w", encoding="utf-8") as fh: + fh.write("path\tsize\tsha256\tapp\tclass\tnote\n") + for rel, app, cls, note in manifest: + full = os.path.join(OUT, rel) + if os.path.islink(full): + fh.write(f"{rel}\t-\t-\t{app}\t{cls}\t{note}\n") + continue + h = hashlib.sha256() + with open(full, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + fh.write(f"{rel}\t{os.path.getsize(full)}\t{h.hexdigest()}\t{app}\t{cls}\t{note}\n") + print(f"manifest: {len(manifest)} entries -> {path}") + + +if __name__ == "__main__": + os.makedirs(OUT, exist_ok=True) + for step in (photos, documents, files, small_apps, media): + t = dt.datetime.now() + step() + print(f"{step.__name__}: done in {(dt.datetime.now() - t).seconds} s, {len(manifest)} entries so far", flush=True) + write_manifest() diff --git a/documentation/audits/rehearsal-2026-10-20/tools/shot.py b/documentation/audits/rehearsal-2026-10-20/tools/shot.py new file mode 100755 index 00000000..9e2f0964 --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/tools/shot.py @@ -0,0 +1,67 @@ +#!/opt/pw/bin/python +"""shot.py — open a page in real Chromium (Playwright) and screenshot it, phone and computer, HU and EN. + +Rehearsal 2026-10-20, A4. Runs in the throwaway LXC rehearsal1020-browser on demo-hp. +Chromium is Debian's package (pinned by the image: `chromium --version` is in A4-browser/versions.txt); +Playwright 1.52.0 from PyPI drives it via executable_path. + +usage: shot.py URL OUTPREFIX [--sizes phone,desktop] [--langs hu,en] [--state FILE] [--host-header H] + [--url-env NAME] [--wait MS] [--full] + --url-env NAME read the URL (or its secret tail) from env NAME and append it to URL — so a capability + link never appears on a command line or in this script's output. + --state FILE Playwright storage_state JSON (cookies) to load — a logged-in session. +Writes OUTPREFIX--.png and prints one line per shot: status, final path (secret tails masked), title. +""" +import argparse, os, re, sys +from playwright.sync_api import sync_playwright + +SIZES = {"phone": dict(width=390, height=844, is_mobile=True, device_scale_factor=2, has_touch=True), + "desktop": dict(width=1366, height=768, is_mobile=False, device_scale_factor=1, has_touch=False)} +LANGS = {"hu": "hu-HU", "en": "en-US"} +UA_PHONE = ("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/155.0.0.0 Mobile Safari/537.36") + + +def mask(u: str) -> str: + return re.sub(r"[0-9a-f]{32,}", "", u) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("url"); ap.add_argument("out") + ap.add_argument("--sizes", default="phone,desktop"); ap.add_argument("--langs", default="hu,en") + ap.add_argument("--state"); ap.add_argument("--url-env"); ap.add_argument("--wait", type=int, default=1500) + ap.add_argument("--full", action="store_true"); ap.add_argument("--ignore-https-errors", action="store_true") + a = ap.parse_args() + url = a.url + (os.environ[a.url_env] if a.url_env else "") + with sync_playwright() as p: + b = p.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"]) + for s in a.sizes.split(","): + for l in a.langs.split(","): + kw = dict(viewport={"width": SIZES[s]["width"], "height": SIZES[s]["height"]}, + device_scale_factor=SIZES[s]["device_scale_factor"], is_mobile=SIZES[s]["is_mobile"], + has_touch=SIZES[s]["has_touch"], locale=LANGS[l], + extra_http_headers={"Accept-Language": LANGS[l] + "," + l + ";q=0.9"}, + ignore_https_errors=a.ignore_https_errors, timezone_id="Europe/Budapest") + if s == "phone": + kw["user_agent"] = UA_PHONE + if a.state: + kw["storage_state"] = a.state + ctx = b.new_context(**kw) + pg = ctx.new_page() + try: + r = pg.goto(url, wait_until="networkidle", timeout=45000) + st = r.status if r else "-" + except Exception as e: # noqa: BLE001 — a failed load is a result, recorded + st = "ERR " + type(e).__name__ + pg.wait_for_timeout(a.wait) + out = f"{a.out}-{s}-{l}.png" + pg.screenshot(path=out, full_page=a.full) + hw = pg.evaluate("document.documentElement.scrollWidth > window.innerWidth") + print(f"{st}\t{s}\t{l}\t{mask(pg.url)}\t{pg.title()!r}\thscroll={hw}\t{out}") + ctx.close() + b.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/documentation/audits/rehearsal-2026-10-20/tools/vmctl.sh b/documentation/audits/rehearsal-2026-10-20/tools/vmctl.sh new file mode 100755 index 00000000..43607833 --- /dev/null +++ b/documentation/audits/rehearsal-2026-10-20/tools/vmctl.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# vmctl.sh — drive a nested VM's console on demo-hp from DooPlex (rehearsal 2026-10-20). +# The installer keeps the HUNGARIAN keyboard (as a household would), so characters are sent as the +# scancodes a Hungarian-layout keyboard produces: y<->z swapped, '-' on the US '/' key, '0' on the +# US '`' key, '@' = AltGr+V. Only the characters below are supported — anything else is refused. +# +# vmctl.sh shot console screenshot (QEMU screendump → PNG on DooPlex) +# vmctl.sh key ... send raw keys (e.g. ret, tab, right, alt-n) +# vmctl.sh type type text with the Hungarian mapping +# vmctl.sh typevar type the value of an env var (use inside `sec run`; never echoed) +set -euo pipefail +VMID=$1; CMD=$2; shift 2 +HOST=${VMHOST:-demo-hp} + +map_char() { + local c=$1 + case "$c" in + [a-x]) echo "$c" ;; + y) echo "z" ;; z) echo "y" ;; + [A-X]) echo "shift-${c,,}" ;; + Y) echo "shift-z" ;; Z) echo "shift-y" ;; + [1-9]) echo "$c" ;; + 0) echo "grave_accent" ;; + .) echo "dot" ;; + ,) echo "comma" ;; + -) echo "slash" ;; + _) echo "shift-slash" ;; + @) echo "alt_r-v" ;; + " ") echo "spc" ;; + *) echo "UNSUPPORTED" ;; + esac +} + +send_text() { + local s=$1 keys=() k i + for (( i=0; i<${#s}; i++ )); do + k=$(map_char "${s:$i:1}") + if [ "$k" = UNSUPPORTED ]; then echo "vmctl: unsupported character at position $i" >&2; exit 2; fi + keys+=("$k") + done + # one ssh, one qm sendkey per key (qm sendkey takes one key combo) + printf '%s\n' "${keys[@]}" | ssh "$HOST" "while read -r k; do LC_ALL=C qm sendkey $VMID \"\$k\" >/dev/null 2>&1 || exit 3; sleep 0.08; done" +} + +case "$CMD" in + shot) + out=$1 + ssh "$HOST" "rm -f /tmp/vmshot-$VMID.ppm; echo 'screendump /tmp/vmshot-$VMID.ppm' | LC_ALL=C qm monitor $VMID >/dev/null 2>&1; sleep 1; cat /tmp/vmshot-$VMID.ppm" > "$out.ppm" + convert "$out.ppm" "$out"; rm -f "$out.ppm"; echo "shot: $out" ;; + key) + for k in "$@"; do ssh "$HOST" "LC_ALL=C qm sendkey $VMID '$k'"; sleep 0.3; done ;; + type) + send_text "$1" ;; + typevar) + v=${!1:-}; [ -n "$v" ] || { echo "vmctl: env $1 empty" >&2; exit 2; } + send_text "$v" ;; + *) echo "usage: see header" >&2; exit 1 ;; +esac