Rehearsal 2026-10-20 (Tester4) + data-loss hunt: baselines, B1 install evidence, A6 drawn order, tools, first two reviewers' candidates
No release, deploy or publish. Off-site is OFF for Tester4 and the owner passphrase is needed from the operator (bind blocked after 2 of 5 tries).
@@ -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`).
|
||||
@@ -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 `<dst>.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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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** |
|
||||
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 382 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
@@ -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()
|
||||
@@ -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
|
||||
@@ -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 ' "$@")"
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
path<TAB>size<TAB>sha256<TAB>app<TAB>class<TAB>note
|
||||
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()
|
||||
@@ -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-<size>-<lang>.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,}", "<token>", 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())
|
||||
@@ -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 <vmid> shot <out.png> console screenshot (QEMU screendump → PNG on DooPlex)
|
||||
# vmctl.sh <vmid> key <qemu-key>... send raw keys (e.g. ret, tab, right, alt-n)
|
||||
# vmctl.sh <vmid> type <text> type text with the Hungarian mapping
|
||||
# vmctl.sh <vmid> typevar <ENVNAME> 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
|
||||