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).
This commit is contained in:
2026-10-20 11:57:55 +02:00
parent 2954b0be3a
commit f84f94d987
31 changed files with 684 additions and 0 deletions
@@ -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** |
Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

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
+20
View File
@@ -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 ' "$@")"
+12
View File
@@ -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()
+67
View File
@@ -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())
+58
View File
@@ -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