Rehearsal 2026-10-20 B2-B4: bound (operator passphrase), claimed, recovery code, family (accented names refused), second disk initialised, 8 of 9 apps (Jellyfin refused on memory), first uploads froze the box ~1 h (hub said OK); screenshots gallery
gates / gates (push) Successful in 13m8s
@@ -0,0 +1,9 @@
|
||||
immich running deployed= True sub= photos gate= True
|
||||
nextcloud running deployed= True sub= cloud gate= False
|
||||
paperless-ngx running deployed= True sub= paperless gate= False
|
||||
vaultwarden running deployed= True sub= vault gate= False
|
||||
mealie running deployed= True sub= recipes gate= False
|
||||
actualbudget running deployed= True sub= budget gate= True
|
||||
radicale running deployed= True sub= calendar gate= False
|
||||
bookstack running deployed= True sub= wiki gate= False
|
||||
jellyfin not_deployed deployed= False sub= None gate= False
|
||||
@@ -0,0 +1,40 @@
|
||||
14:13:55 diskread=3GB health=200 0.026323
|
||||
14:14:27 diskread=3GB health=200 0.010294
|
||||
14:14:59 diskread=4GB health=200 0.011968
|
||||
14:15:30 diskread=4GB health=200 0.009450
|
||||
14:16:02 diskread=4GB health=200 0.010128
|
||||
14:16:33 diskread=4GB health=200 0.009558
|
||||
14:17:05 diskread=4GB health=200 0.010571
|
||||
14:17:36 diskread=4GB health=200 0.009807
|
||||
14:18:08 diskread=4GB health=200 0.010191
|
||||
14:18:40 diskread=4GB health=200 0.009850
|
||||
14:19:11 diskread=4GB health=200 0.010203
|
||||
14:19:43 diskread=4GB health=200 0.011617
|
||||
14:20:14 diskread=4GB health=200 0.014898
|
||||
14:20:46 diskread=4GB health=200 0.010567
|
||||
14:21:17 diskread=4GB health=200 0.011040
|
||||
14:21:49 diskread=4GB health=200 0.009551
|
||||
14:22:21 diskread=4GB health=200 0.009957
|
||||
14:22:52 diskread=4GB health=200 0.010580
|
||||
14:23:24 diskread=4GB health=200 0.010163
|
||||
14:23:56 diskread=4GB health=200 0.010898
|
||||
14:24:27 diskread=4GB health=200 0.010501
|
||||
14:24:58 diskread=4GB health=200 0.011854
|
||||
14:25:30 diskread=4GB health=200 0.011567
|
||||
14:26:01 diskread=4GB health=200 0.010254
|
||||
14:26:32 diskread=4GB health=200 0.010620
|
||||
14:27:03 diskread=4GB health=200 0.011295
|
||||
14:27:35 diskread=4GB health=200 0.010245
|
||||
14:28:06 diskread=4GB health=200 0.010855
|
||||
14:28:37 diskread=4GB health=200 0.010371
|
||||
14:29:09 diskread=4GB health=200 0.015567
|
||||
14:29:40 diskread=4GB health=200 0.010465
|
||||
14:30:11 diskread=4GB health=200 0.010190
|
||||
14:30:42 diskread=4GB health=200 0.011160
|
||||
14:31:14 diskread=4GB health=200 0.009954
|
||||
14:31:45 diskread=4GB health=200 0.011131
|
||||
14:32:16 diskread=4GB health=200 0.011337
|
||||
14:32:48 diskread=4GB health=200 0.010336
|
||||
14:33:19 diskread=4GB health=200 0.011064
|
||||
14:33:50 diskread=4GB health=200 0.010944
|
||||
14:34:22 diskread=4GB health=200 0.010249
|
||||
@@ -0,0 +1,16 @@
|
||||
UTC cpu% load1 mem_GB (the box's own /api/metrics/system history)
|
||||
12:00 1.4 1.6 0.9384765625
|
||||
12:07 6.1 0.9 0.93359375
|
||||
12:14 3.0 0.6 0.970703125
|
||||
12:21 14.0 1.6 1.5107421875
|
||||
12:28 1.0 0.3 1.6123046875
|
||||
12:36 1.5 0.3 1.607421875
|
||||
12:43 11.8 1.4 1.6298828125
|
||||
12:50 15.2 1.5 2.056640625
|
||||
12:57 61.3 8.3 2.318359375
|
||||
13:33 78.1 139.3 2.2900390625
|
||||
13:55 78.1 151.6 2.3486328125
|
||||
14:09 0.0 2.0 1.1875
|
||||
keys: ['cpu', 'labels', 'load1', 'memory', 'temp']
|
||||
{"ok": true, "data": {"sync_status": {"last_sync": "2026-10-20T14:13:31.264994519Z", "last_status": "ok", "syncing": false}, "system": {"total_mem_mb": 4096, "used_mem_mb": 3638, "avail_mem_mb": 458, "mem_percent": 88.818359375, "disk_total_gb": 49.03971862792969, "disk_used_gb": 12.562141418457031, "disk_avail_gb": 33.96195983886719, "disk_percent": 27.001362904306276, "disk_known": true, "hdd_total_gb": 195.8027114868164, "hdd_used_gb": 0.10123825073242188, "hdd_avail_gb": 185.68584823608398, "hdd_percent": 0.054491543328877075, "hdd_configured": true, "hdd_known": true, "cpu_percent": 55.314861460957175, "load_avg_1": 3.31, "load_avg_5": 1.24, "load_avg_15": 0.44, "temperature_celsius": 0}}}
|
||||
{"ok": true, "data": [{"name": "felhom-controller", "cpu_percent": 42.69, "mem_usage_mb": 92.52, "mem_limit_mb": 4096}, {"name": "actualbudget", "cpu_percent": 31.28, "mem_usage_mb": 128.6, "mem_limit_mb": 256}, {"name": "radicale", "cpu_percent": 28.31, "mem_usage_mb": 28.39, "mem_limit_mb": 128}, {"name": "bookstack-db", "cpu_percent": 27.86, "mem_usage_mb": 153.2, "mem_limit_mb": 256}, {"name": "immich-machine-learning", "cpu_percent": 27.57, "mem_usage_mb": 107.2, "mem_limit_mb": 2048}, {"name": "paperless-webserver", "cpu_percent": 27.41, "mem_usage_mb": 72.22, "mem_limit_mb": 1280}, {"name": "mealie", "cpu_percent": 26.57, "mem_usage_mb": 67.21, "mem_limit_mb": 1000}, {"name": "nextcloud-db", "cpu_percent": 26.17, "mem_usage_mb": 116.3, "mem_limit_mb": 512}, {"name": "immich-server", "cpu_percent": 22.66, "mem_usage_mb": 206, "mem_limit_mb": 3072}, {"name": "nextcloud", "cpu_percent": 19.4, "mem_usage_mb": 157.1, "mem_limit_mb": 1024}, {"name": "traefik", "cpu_percent": 10.78, "mem_usage_mb": 186.4, "mem_limit_mb": 4096}, {"name": "bookstack", "cpu_percent": 0.61, "mem_usage_mb": 19.94, "mem_limit_mb": 512}, {"name": "paperless-redis", "cpu_percent": 0.47, "mem_usage_mb": 26.13, "mem_limit_mb": 128}, {"name": "immich-redis", "cpu_percent": 0.43, "mem_usage_mb": 3.16, "mem_limit_mb": 128}, {"name": "nextcloud-redis", "cpu_percent": 0.42, "mem_usage_mb": 10.36, "mem_limit_mb": 128}, {"name": "paperless-postgres", "cpu_percent": 0.03, "mem_usage_mb": 58.27, "mem_limit_mb": 256}, {"name": "immich-postgres", "cpu_percent": 0.03, "mem_usage_mb": 133.1, "mem_limit_mb": 768}, {"name": "vaultwarden", "cpu_percent": 0, "mem_usage_mb": 72.27, "mem_limit_mb": 256}, {"name": "filebrowser", "cpu_percent": 0, "mem_usage_mb": 145, "mem_limit_mb": 256}]}
|
||||
@@ -0,0 +1,49 @@
|
||||
12:59:51
|
||||
Traceback (most recent call last):
|
||||
File "/root/work/apps.py", line 182, in <module>
|
||||
fn(*sys.argv[2:])
|
||||
~~^^^^^^^^^^^^^^^
|
||||
File "/root/work/apps.py", line 168, in immich
|
||||
st, out, _ = http("POST", base + "/api/assets", body, dict(tok, **{"Content-Type": ct}))
|
||||
~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/root/work/apps.py", line 34, in http
|
||||
with urllib.request.urlopen(req, context=CTX, timeout=timeout) as r:
|
||||
~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/lib/python3.13/urllib/request.py", line 189, in urlopen
|
||||
return opener.open(url, data, timeout)
|
||||
~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/lib/python3.13/urllib/request.py", line 489, in open
|
||||
response = self._open(req, data)
|
||||
File "/usr/lib/python3.13/urllib/request.py", line 506, in _open
|
||||
result = self._call_chain(self.handle_open, protocol, protocol +
|
||||
'_open', req)
|
||||
File "/usr/lib/python3.13/urllib/request.py", line 466, in _call_chain
|
||||
result = func(*args)
|
||||
File "/usr/lib/python3.13/urllib/request.py", line 1367, in https_open
|
||||
return self.do_open(http.client.HTTPSConnection, req,
|
||||
~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
context=self._context)
|
||||
^^^^^^^^^^^^^^^^^^^^^^
|
||||
File "/usr/lib/python3.13/urllib/request.py", line 1323, in do_open
|
||||
r = h.getresponse()
|
||||
File "/usr/lib/python3.13/http/client.py", line 1459, in getresponse
|
||||
response.begin()
|
||||
~~~~~~~~~~~~~~^^
|
||||
File "/usr/lib/python3.13/http/client.py", line 336, in begin
|
||||
version, status, reason = self._read_status()
|
||||
~~~~~~~~~~~~~~~~~^^
|
||||
File "/usr/lib/python3.13/http/client.py", line 297, in _read_status
|
||||
line = str(self.fp.readline(_MAXLINE + 1), "iso-8859-1")
|
||||
~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^
|
||||
File "/usr/lib/python3.13/socket.py", line 719, in readinto
|
||||
return self._sock.recv_into(b)
|
||||
~~~~~~~~~~~~~~~~~~~~^^^
|
||||
File "/usr/lib/python3.13/ssl.py", line 1304, in recv_into
|
||||
return self.read(nbytes, buffer)
|
||||
~~~~~~~~~^^^^^^^^^^^^^^^^
|
||||
File "/usr/lib/python3.13/ssl.py", line 1138, in read
|
||||
return self._sslobj.read(len, buffer)
|
||||
~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
|
||||
TimeoutError: The read operation timed out
|
||||
12:59:52Z immich admin-sign-up 400 b'{"message":"Admin setup is not available"}'
|
||||
13:05:57
|
||||
@@ -0,0 +1 @@
|
||||
14:23:57
|
||||
@@ -0,0 +1,7 @@
|
||||
12:57:15
|
||||
13:12:06Z nextcloud rclone rc=-15
|
||||
2026/10/20 12:57:17 NOTICE: Config file "/root/.config/rclone/rclone.conf" not found - using defaults
|
||||
2026/10/20 12:57:18 NOTICE: Csalad/Kulonleges_nevek/link_a_nagy_fajlra: Can't follow symlink without -L/--copy-links
|
||||
2026/10/20 12:57:18 NOTICE: Csalad/Kulonleges_nevek/Ödön születésnapja – fénykép lista.txt: Duplicate object found in source - ignoring
|
||||
2026/10/20 12:57:27 ERROR : Csalad/nagy_fajl_1_5GB.bin: Failed to copy: <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
|
||||
13:12:06
|
||||
@@ -0,0 +1,21 @@
|
||||
14:23:58 diskread=4GB health=200 0.010208
|
||||
14:24:30 diskread=4GB health=200 0.010427
|
||||
14:25:01 diskread=4GB health=200 0.012785
|
||||
14:25:32 diskread=4GB health=200 0.010109
|
||||
14:26:04 diskread=4GB health=200 0.009754
|
||||
14:26:35 diskread=4GB health=200 0.010561
|
||||
14:27:06 diskread=4GB health=200 0.013033
|
||||
14:27:37 diskread=4GB health=200 0.010545
|
||||
14:28:09 diskread=4GB health=200 0.010882
|
||||
14:28:40 diskread=4GB health=200 0.010998
|
||||
14:29:11 diskread=4GB health=200 0.009735
|
||||
14:29:42 diskread=4GB health=200 0.010706
|
||||
14:30:14 diskread=4GB health=200 0.012557
|
||||
14:30:45 diskread=4GB health=200 0.011448
|
||||
14:31:16 diskread=4GB health=200 0.010879
|
||||
14:31:47 diskread=4GB health=200 0.012360
|
||||
14:32:19 diskread=4GB health=200 0.009276
|
||||
14:32:50 diskread=4GB health=200 0.010233
|
||||
14:33:22 diskread=4GB health=200 0.013962
|
||||
14:33:53 diskread=4GB health=200 0.011101
|
||||
14:34:24 diskread=4GB health=200 0.012738
|
||||
@@ -0,0 +1,6 @@
|
||||
diskread: 483315769272
|
||||
diskwrite: 47257374720
|
||||
mem: 7075991552
|
||||
memhost: 8218480640
|
||||
netin: 4738753971
|
||||
netout: 916386350
|
||||
@@ -18,3 +18,28 @@
|
||||
| 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** |
|
||||
| 12:00:57 | B2 | the operator's passphrase arrives in sec (intervention 1: an operator act the guide requires — the passphrase is handed over out of band by design) |
|
||||
| 12:01:3x | B2 | bind attempt 3 (right code + passphrase) → „Sikeres összekötés" (`screens/b2-bind/`) |
|
||||
| 12:02:25 | B2 | mail „[Felhom] Új beállító kód — újratelepült a szervered" (the guide's reinstall subject); code stored in sec |
|
||||
| 12:02:43 | B2 | hub: host Tester4-816e21 created (NO REPORT yet); console turns to „a doboz össze van kötve" — the guide's „known bug: the code stays on screen" is FIXED (guide note stale); a stray „V" glyph follows the sentence (R-725 shape) |
|
||||
| ≤12:13 | B2 | hub: Tester4-816e21 ONLINE, agent 0.161.0, controller 0.336.0 (the golden 0.331.0 → floor by itself); dashboard guest at 192.168.0.133 (DHCP) |
|
||||
| 12:13 | B2 | harness: the household's address `https://felhom.tester4.test` has no DNS/tunnel (tester4.test is reserved); the browser LXC maps the name to the guest's LAN address in /etc/hosts, TLS not verified |
|
||||
| 12:14:26 | B2 | claim with a typo in the setup code → „Hibás vagy lejárt kód" |
|
||||
| 12:14:4x | B2 | claim with the right code + a 12-character password (exactly the minimum) → 302 → /launcher. **Install pressed → claimed: 27 min 50 s, of which 70 min of waiting for the passphrase are excluded (bind attempt 3 at 12:01)** |
|
||||
| 12:15 | B2 | the yellow bar „A távoli mentés szünetel, amíg nem hozod létre a helyreállítási kódot" shows at once; the hub's Offsite page now lists Tester4 (0 B, no quota shown) and a WireGuard peer 10.77.0.6 → the operator switched off-site ON between 12:01 and 12:15 (intervention 2, an operator prerequisite the guide states) |
|
||||
| 12:16:22 | B2 | recovery code ceremony through the bar's link: dashboard password → code ready in ~5 s → shown once (10 words) → the page asks words 2 and 7 → accepted → „Befejezés" → /backups/remote „A helyreállítási kód letétbe helyezve" — 16 s in all. The code went page → 0600 file → sec, never printed; every screenshot has the code blacked out (`screens/b2-escrow/`) |
|
||||
| 12:16 | B2/B9 | the ceremony page shows technical checks to the household („Mentési tároló (PBS)", „Helyreállítási (DR) szint", „/usr/bin/age", „Rendszerjogosultság"); its step numbers jump 1 → 5 → 6; the remote page shows „restic + SFTP" and the Storage Box login `u629488-sub5@…your-storagebox.de /home/felhom-repo` |
|
||||
| 12:17:13 | B2 | six wrong dashboard passwords from a fresh browser: five „Hibás jelszó.", the sixth „Túl sok hibás próbálkozás erről a címről. Próbáld újra egy perc múlva." (1 minute, per address). The guide's „15 percre zárol" is the SETUP page's rule; the login lock is not in the guide |
|
||||
| — | B2 | the old (used) bind link: tested 09:56 → „érvénytelen vagy lejárt" + „Új linket kérek" (not pressed) |
|
||||
| 12:18:09 | B3 | family card: „Ödön" and „Írisz" refused — „A név 1–32 karakter: kisbetű, szám, pont, kötőjel vagy aláhúzás." → added as odon, irisz, kata; each password answered once (→ sec) |
|
||||
| 12:19 | B3 | the 200 GB second disk is NOT used by itself: the box made a 54.8 GB „Belső tárhely" on the system disk; the storage page offers „Új meghajtó inicializálása". The household pressed it: the first press did nothing (the required „Csatlakoztatási név" — „/mnt/<név>" — was empty; only a browser tooltip); with `hdd_1` → „A meghajtó sikeresen inicializálva és regisztrálva", default for new installs (12:19:48) |
|
||||
| 12:22–12:55 | B3 | through each app's own install page (real Chromium): **Immich** OK 117 s (2048 MB); **Nextcloud** admin `odon` + a password with spaces and ő/ű: „Időtúllépés — az alkalmazás még indulhat" at ~2 min, running a few minutes later; **Paperless-ngx** admin `irisz` + a 64-character password OK; **Vaultwarden** sign-up switched ON OK; **Mealie** OK; **Actual Budget** OK; **Radicale** OK; **BookStack** OK; **Jellyfin REFUSED**: „Nincs elég memória … telepítés után 4150 MB kellene, a gép 3712 MB-ot tud adni. Távolíts el egy alkalmazást…" (button disabled; no link to a page). 8 of 9 installed; memory meter 3638 / 3712 MB (98 %) |
|
||||
| 12:22 | B3 | harness slip: my screenshot step blanked every field before the first Nextcloud press, so the form was sent empty and refused (15 min lost); fixed (values restored after each photo) and re-run |
|
||||
| 12:24–12:55 | B9 | „Első lépések" texts: Immich mixes English („Administration → External Libraries → add /external/photos"); Paperless says „felhasználó \"admin\"" although the household set `irisz`; Vaultwarden says sign-up is closed although the household opened it; **Mealie names `mealie.tester4.test` while the app is at `recipes.tester4.test`** |
|
||||
| 12:56:33 | B4 | Paperless: 53 PDFs posted through `/api/documents/post_document/` → 53 accepted (processing is the app's background work) |
|
||||
| 12:57 | B4 | Radicale: calendar „Család" (MKCALENDAR 201) + 2 events (201, one crossing the DST change); Mealie: Gulyásleves + Csirkepörkölt with ingredients, steps and photos (201/200/200) |
|
||||
| 12:57:15 | B4 | Nextcloud: the 3 393-file tree over WebDAV (rclone, 4 transfers) started; rclone keeps only ONE of the NFC/NFD name pair („Duplicate object found in source") and skips the symlink — tool limits, not the box's; the 1.5 GiB file FAILED at 12:57:27 with an HTML error page |
|
||||
| 12:58–12:59 | B4 | Immich: the setup gate shows a stranger „Ez az alkalmazás még beállításra vár"; Ödön registered as admin through Immich's own form; the gate opened by itself within 20 s |
|
||||
| 12:59:52 | B4 | Immich upload through `/api/assets`: the FIRST photo hangs 300 s and times out |
|
||||
| ~13:05 → | B4 | **THE BOX STOPS ANSWERING.** The dashboard's `/api/health` gives no answer in 10–15 s; the VM reads ~4 GB/min from its disk (158 GB → 209 GB in 15 min) while network traffic stands still; ping fine. 13:12 the upload stopped (the family gives up) — no recovery 10 min later. **The hub reads the box ONLINE / OK all along** (host memory 39 %, controller OK, 13:22). Likely: the apps' own background work after the uploads (Paperless OCR of 53 PDFs, Immich's analysis, Nextcloud) on a guest booked to 98 % of its memory — NOT measured (no shell in the box) |
|
||||
| 13:24 | B4 | no inside view of the box — tried: root SSH with the install-time password → „Permission denied" (replaced at step 4b, by design); DooPlex's key → denied; the hub-vaulted recovery password → operator-only reveal (the token is refused); the agent's local API token → only on the box. So the stall is described from outside only |
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# Rehearsal 2026-10-20 — every screen the household met
|
||||
|
||||
Phone = 390×844, computer = 1366×768 (Chromium 155 + Playwright 1.52.0 in a throwaway container; see `../A4-browser/`). Hungarian and English browser languages. Codes, passwords and links are blacked out or never shown. Console captures are QEMU screendumps of the VM's monitor.
|
||||
|
||||
## b1-site
|
||||
|
||||
| screen | phone HU | phone EN | computer HU | computer EN |
|
||||
|---|---|---|---|---|
|
||||
| `b1-letoltes` | <img src="b1-site/b1-letoltes-phone-hu.png" width="140"> | <img src="b1-site/b1-letoltes-phone-en.png" width="140"> | <img src="b1-site/b1-letoltes-desktop-hu.png" width="260"> | <img src="b1-site/b1-letoltes-desktop-en.png" width="260"> |
|
||||
|
||||
## b2-badpw
|
||||
|
||||
| screen | phone HU | phone EN | computer HU | computer EN |
|
||||
|---|---|---|---|---|
|
||||
| `b2-badpw-0-login` | <img src="b2-badpw/b2-badpw-0-login-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b2-badpw-6-after-six` | <img src="b2-badpw/b2-badpw-6-after-six-phone-hu.png" width="140"> | — | — | — |
|
||||
|
||||
## b2-bind
|
||||
|
||||
| screen | phone HU | phone EN | computer HU | computer EN |
|
||||
|---|---|---|---|---|
|
||||
| `b2-bind-right-1-open` | — | — | <img src="b2-bind/b2-bind-right-1-open-desktop-hu.png" width="260"> | — |
|
||||
| `b2-bind-right-2-answer` | — | — | <img src="b2-bind/b2-bind-right-2-answer-desktop-hu.png" width="260"> | — |
|
||||
| `b2-bind-stored-1-open` | <img src="b2-bind/b2-bind-stored-1-open-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b2-bind-stored-2-answer` | <img src="b2-bind/b2-bind-stored-2-answer-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b2-bind-typo-1-open` | <img src="b2-bind/b2-bind-typo-1-open-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b2-bind-typo-2-answer` | <img src="b2-bind/b2-bind-typo-2-answer-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b2-oldlink-1-open` | <img src="b2-bind/b2-oldlink-1-open-phone-hu.png" width="140"> | — | — | — |
|
||||
|
||||
## b2-claim
|
||||
|
||||
| screen | phone HU | phone EN | computer HU | computer EN |
|
||||
|---|---|---|---|---|
|
||||
| `b2-claim-1-open` | <img src="b2-claim/b2-claim-1-open-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b2-claim-2-typo-answer` | <img src="b2-claim/b2-claim-2-typo-answer-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b2-claim-3-after-claim` | <img src="b2-claim/b2-claim-3-after-claim-phone-hu.png" width="140"> | — | — | — |
|
||||
|
||||
## b2-escrow
|
||||
|
||||
| screen | phone HU | phone EN | computer HU | computer EN |
|
||||
|---|---|---|---|---|
|
||||
| `b2-escrow-1-open` | <img src="b2-escrow/b2-escrow-1-open-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b2-escrow-2-progress` | <img src="b2-escrow/b2-escrow-2-progress-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b2-escrow-3-reveal-gate` | <img src="b2-escrow/b2-escrow-3-reveal-gate-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b2-escrow-4-code-shown-REDACTED` | <img src="b2-escrow/b2-escrow-4-code-shown-REDACTED-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b2-escrow-5-words-ok` | <img src="b2-escrow/b2-escrow-5-words-ok-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b2-escrow-6-after-finish` | <img src="b2-escrow/b2-escrow-6-after-finish-phone-hu.png" width="140"> | — | — | — |
|
||||
|
||||
## b3-apps
|
||||
|
||||
| screen | phone HU | phone EN | computer HU | computer EN |
|
||||
|---|---|---|---|---|
|
||||
| `b3-apps-actualbudget-1-form` | — | — | <img src="b3-apps/b3-apps-actualbudget-1-form-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-actualbudget-2-result` | — | — | <img src="b3-apps/b3-apps-actualbudget-2-result-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-bookstack-1-form` | — | — | <img src="b3-apps/b3-apps-bookstack-1-form-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-bookstack-2-result` | — | — | <img src="b3-apps/b3-apps-bookstack-2-result-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-immich-1-install-page` | — | — | <img src="b3-apps/b3-apps-immich-1-install-page-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-immich-2-result` | — | — | <img src="b3-apps/b3-apps-immich-2-result-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-jellyfin-1-form` | — | — | <img src="b3-apps/b3-apps-jellyfin-1-form-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-jellyfin-refused` | <img src="b3-apps/b3-apps-jellyfin-refused-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b3-apps-mealie-1-form` | — | — | <img src="b3-apps/b3-apps-mealie-1-form-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-mealie-2-result` | — | — | <img src="b3-apps/b3-apps-mealie-2-result-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-nextcloud-1-form` | — | — | <img src="b3-apps/b3-apps-nextcloud-1-form-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-nextcloud-2-result` | — | — | <img src="b3-apps/b3-apps-nextcloud-2-result-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-paperless-ngx-1-form` | — | — | <img src="b3-apps/b3-apps-paperless-ngx-1-form-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-paperless-ngx-2-result` | — | — | <img src="b3-apps/b3-apps-paperless-ngx-2-result-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-radicale-1-form` | — | — | <img src="b3-apps/b3-apps-radicale-1-form-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-radicale-2-result` | — | — | <img src="b3-apps/b3-apps-radicale-2-result-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-vaultwarden-1-form` | — | — | <img src="b3-apps/b3-apps-vaultwarden-1-form-desktop-hu.png" width="260"> | — |
|
||||
| `b3-apps-vaultwarden-2-result` | — | — | <img src="b3-apps/b3-apps-vaultwarden-2-result-desktop-hu.png" width="260"> | — |
|
||||
| `b3-drives-1-storage` | — | — | <img src="b3-apps/b3-drives-1-storage-desktop-hu.png" width="260"> | — |
|
||||
| `b3-drives-2-dashboard` | — | — | <img src="b3-apps/b3-drives-2-dashboard-desktop-hu.png" width="260"> | — |
|
||||
| `b3-drives-3-init-open` | — | — | <img src="b3-apps/b3-drives-3-init-open-desktop-hu.png" width="260"> | — |
|
||||
| `b3-drives-4-init-config` | — | — | <img src="b3-apps/b3-drives-4-init-config-desktop-hu.png" width="260"> | — |
|
||||
| `b3-drives-5-init-after-press` | — | — | <img src="b3-apps/b3-drives-5-init-after-press-desktop-hu.png" width="260"> | — |
|
||||
| `b3-drives-5-init-confirm` | — | — | <img src="b3-apps/b3-drives-5-init-confirm-desktop-hu.png" width="260"> | — |
|
||||
| `b3-family-1-security` | <img src="b3-apps/b3-family-1-security-phone-hu.png" width="140"> | — | — | — |
|
||||
|
||||
## b4-immich
|
||||
|
||||
| screen | phone HU | phone EN | computer HU | computer EN |
|
||||
|---|---|---|---|---|
|
||||
| `b4-immich-0-stranger` | <img src="b4-immich/b4-immich-0-stranger-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b4-immich-1-first-open` | <img src="b4-immich/b4-immich-1-first-open-phone-hu.png" width="140"> | — | — | — |
|
||||
| `b4-immich-2-signup-form` | — | — | <img src="b4-immich/b4-immich-2-signup-form-desktop-hu.png" width="260"> | — |
|
||||
| `b4-immich-3-signup-filled` | — | — | <img src="b4-immich/b4-immich-3-signup-filled-desktop-hu.png" width="260"> | — |
|
||||
| `b4-immich-4-after-signup` | — | — | <img src="b4-immich/b4-immich-4-after-signup-desktop-hu.png" width="260"> | — |
|
||||
|
||||
## install
|
||||
|
||||
**01-boot.png**
|
||||
|
||||
<img src="install/01-boot.png" width="520">
|
||||
|
||||
**02-eula.png**
|
||||
|
||||
<img src="install/02-eula.png" width="520">
|
||||
|
||||
**03-disk.png**
|
||||
|
||||
<img src="install/03-disk.png" width="520">
|
||||
|
||||
**04-location.png**
|
||||
|
||||
<img src="install/04-location.png" width="520">
|
||||
|
||||
**05-password.png**
|
||||
|
||||
<img src="install/05-password.png" width="520">
|
||||
|
||||
**06-pw-email.png**
|
||||
|
||||
<img src="install/06-pw-email.png" width="520">
|
||||
|
||||
**07-network.png**
|
||||
|
||||
<img src="install/07-network.png" width="520">
|
||||
|
||||
**07b-hostname.png**
|
||||
|
||||
<img src="install/07b-hostname.png" width="520">
|
||||
|
||||
**08-summary.png**
|
||||
|
||||
<img src="install/08-summary.png" width="520">
|
||||
|
||||
**09-installing.png**
|
||||
|
||||
<img src="install/09-installing.png" width="520">
|
||||
|
||||
**10-progress.png**
|
||||
|
||||
<img src="install/10-progress.png" width="520">
|
||||
|
||||
**11-console.png**
|
||||
|
||||
<img src="install/11-console.png" width="520">
|
||||
|
||||
**12-after-bind.png**
|
||||
|
||||
<img src="install/12-after-bind.png" width="520">
|
||||
|
||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 211 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 170 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 152 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
@@ -90,7 +90,7 @@ def nextcloud(sub, user_env, pw_env):
|
||||
obscured = subprocess.run(["rclone", "obscure", "-"], input=pw, capture_output=True, text=True).stdout.strip()
|
||||
env = dict(os.environ, RCLONE_CONFIG_NC_TYPE="webdav", RCLONE_CONFIG_NC_URL=f"https://{sub}.{DOMAIN}/remote.php/dav/files/{user}",
|
||||
RCLONE_CONFIG_NC_VENDOR="nextcloud", RCLONE_CONFIG_NC_USER=user, RCLONE_CONFIG_NC_PASS=obscured)
|
||||
r = subprocess.run(["rclone", "copy", "--no-check-certificate", "--transfers", "4", "--stats", "60s", "--stats-one-line",
|
||||
r = subprocess.run(["rclone", "copy", "--no-check-certificate", "--transfers", os.environ.get("TRANSFERS", "1"), "--stats", "60s", "--stats-one-line",
|
||||
"-v", os.path.join(DATA, "nextcloud"), "nc:/"], env=env, capture_output=True, text=True)
|
||||
tail = [l for l in (r.stdout + r.stderr).splitlines() if "ERROR" in l or "Transferred" in l or "Skipping" in l or "NOTICE" in l]
|
||||
say(f"nextcloud rclone rc={r.returncode}")
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/python3
|
||||
"""counts.py — what each app holds now, read through the app's own API (B4/B5/B7 comparisons). Env names are args."""
|
||||
import json, os, subprocess, sys
|
||||
sys.path.insert(0, "/root/work"); import apps
|
||||
from apps import http, basic
|
||||
def paperless(u, p):
|
||||
st, out, _ = http("GET", "https://paperless.tester4.test/api/documents/?page_size=1", None, {"Authorization": basic(os.environ[u], os.environ[p])})
|
||||
t = json.loads(out).get("count") if st == 200 else out[:120]
|
||||
st2, out2, _ = http("GET", "https://paperless.tester4.test/api/tasks/?status=FAILURE", None, {"Authorization": basic(os.environ[u], os.environ[p])})
|
||||
fails = [x.get("task_file_name") for x in json.loads(out2)] if st2 == 200 else out2[:100]
|
||||
print("paperless documents:", t, "| failed tasks:", fails)
|
||||
def immich(e, p):
|
||||
st, out, _ = http("POST", "https://photos.tester4.test/api/auth/login", {"email": os.environ[e], "password": os.environ[p]})
|
||||
tok = {"Authorization": "Bearer " + json.loads(out)["accessToken"]}
|
||||
st, out, _ = http("GET", "https://photos.tester4.test/api/assets/statistics", None, tok)
|
||||
print("immich assets:", out.decode()[:200])
|
||||
def nextcloud(u, p):
|
||||
obs = subprocess.run(["rclone", "obscure", "-"], input=os.environ[p], capture_output=True, text=True).stdout.strip()
|
||||
env = dict(os.environ, RCLONE_CONFIG_NC_TYPE="webdav", RCLONE_CONFIG_NC_URL=f"https://cloud.tester4.test/remote.php/dav/files/{os.environ[u]}",
|
||||
RCLONE_CONFIG_NC_VENDOR="nextcloud", RCLONE_CONFIG_NC_USER=os.environ[u], RCLONE_CONFIG_NC_PASS=obs)
|
||||
r = subprocess.run(["rclone", "size", "--no-check-certificate", "nc:/Csalad"], env=env, capture_output=True, text=True)
|
||||
print("nextcloud Csalad:", (r.stdout + r.stderr).strip().replace("\n", " | ")[:300])
|
||||
globals()[sys.argv[1]](*sys.argv[2:])
|
||||
@@ -74,3 +74,18 @@ if __name__ == "__main__":
|
||||
code, body = call(m, p, d)
|
||||
print(code)
|
||||
print(json.dumps(body, ensure_ascii=False, indent=1)[:6000])
|
||||
|
||||
|
||||
def form(path, fields, timeout=600):
|
||||
"""A form POST (application/x-www-form-urlencoded) with the session and the X-CSRF-Token header."""
|
||||
import urllib.parse
|
||||
if _SESS is None:
|
||||
login()
|
||||
out = _curl(["-H", "Cookie: " + _SESS, "-H", "X-CSRF-Token: " + _CSRF, "-w", "\n%{http_code}", "-X", "POST",
|
||||
"--data-binary", "@-", "-H", "Content-Type: application/x-www-form-urlencoded", BASE + path],
|
||||
inp=urllib.parse.urlencode(fields), timeout=timeout)
|
||||
body, _, code = out.rpartition("\n")
|
||||
try:
|
||||
return code, json.loads(body)
|
||||
except ValueError:
|
||||
return code, {"_raw": body[:800]}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
#!/opt/pw/bin/python
|
||||
"""escrow.py — the household's recovery code ceremony in real Chromium (rehearsal 2026-10-20, B2).
|
||||
|
||||
Env: PW (the dashboard password). The recovery code is read off the page into /root/work/rc (0600) — never printed —
|
||||
and DooPlex moves it into `sec` and shreds the file. Before every screenshot the code block is replaced by black
|
||||
bars (the page keeps the code in a JS closure, so the check still works).
|
||||
usage: escrow.py OUTPREFIX [--size phone|desktop]
|
||||
"""
|
||||
import os, re, sys, time
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
out = sys.argv[1]
|
||||
size = sys.argv[sys.argv.index("--size") + 1] if "--size" in sys.argv else "phone"
|
||||
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]
|
||||
T0 = time.time()
|
||||
|
||||
|
||||
def say(*a):
|
||||
print(f"+{time.time() - T0:6.1f}s", *a, flush=True)
|
||||
|
||||
|
||||
def shot(pg, name):
|
||||
pg.evaluate("""() => { const b = document.getElementById('code-block'); if (b && b.textContent) b.textContent = '█████-█████-█████-█████-█████-█████-█████-█████-█████-█████';
|
||||
document.querySelectorAll('input').forEach(e => { if (e.type !== 'checkbox') e.value = ''; }); }""")
|
||||
pg.screenshot(path=f"{out}-{name}-{size}-hu.png", full_page=True)
|
||||
|
||||
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"])
|
||||
ctx = b.new_context(locale="hu-HU", timezone_id="Europe/Budapest", ignore_https_errors=True,
|
||||
storage_state="/root/work/state-dash.json", **vp)
|
||||
pg = ctx.new_page()
|
||||
pg.goto("https://felhom.tester4.test/backup/escrow", wait_until="networkidle")
|
||||
pg.wait_for_selector("#warn-card", state="visible", timeout=60000)
|
||||
pg.fill("#reauth-password", os.environ["PW"])
|
||||
pg.click("#start-btn")
|
||||
say("start pressed")
|
||||
pg.wait_for_timeout(1500)
|
||||
shot(pg, "2-progress")
|
||||
try:
|
||||
pg.wait_for_selector("#reveal-card, #void-card", state="visible", timeout=300000)
|
||||
except Exception:
|
||||
say("no reveal within 300 s:", re.sub(r"\s+", " ", pg.inner_text("#progress-card"))[:400])
|
||||
shot(pg, "2b-stuck")
|
||||
sys.exit(2)
|
||||
if pg.is_visible("#void-card"):
|
||||
say("VOID card:", re.sub(r"\s+", " ", pg.inner_text("#void-card"))[:400])
|
||||
shot(pg, "3-void")
|
||||
sys.exit(3)
|
||||
say("reveal card shown")
|
||||
shot(pg, "3-reveal-gate")
|
||||
pg.click("#reveal-btn")
|
||||
pg.wait_for_selector("#reveal-shown", state="visible", timeout=60000)
|
||||
code = pg.inner_text("#code-block").strip()
|
||||
fd = os.open("/root/work/rc", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
os.write(fd, code.encode())
|
||||
os.close(fd)
|
||||
words = code.split("-")
|
||||
say(f"code read off the page: {len(words)} words, {len(code)} characters (stored 0600, not printed)")
|
||||
label = pg.inner_text("#verify-label")
|
||||
pos = [int(x) for x in re.findall(r"\d+", label)][:2]
|
||||
say("the page asks for words at positions", pos, "| label:", label)
|
||||
shot(pg, "4-code-shown-REDACTED")
|
||||
pg.fill("#verify-w1", words[pos[0] - 1])
|
||||
pg.fill("#verify-w2", words[pos[1] - 1])
|
||||
pg.click("#verify-btn")
|
||||
pg.wait_for_timeout(4000)
|
||||
if pg.is_visible("#verify-error"):
|
||||
say("verify error:", pg.inner_text("#verify-error"))
|
||||
pg.wait_for_selector("#finish-block", state="visible", timeout=60000)
|
||||
say("words accepted; finish block:", re.sub(r"\s+", " ", pg.inner_text("#finish-block"))[:400])
|
||||
shot(pg, "5-words-ok")
|
||||
pg.check("#finish-check")
|
||||
pg.click("#finish-btn")
|
||||
pg.wait_for_load_state("networkidle")
|
||||
pg.wait_for_timeout(3000)
|
||||
say("after finish:", pg.url, re.sub(r"\s+", " ", pg.inner_text("main") if pg.locator("main").count() else pg.inner_text("body"))[:900])
|
||||
shot(pg, "6-after-finish")
|
||||
b.close()
|
||||
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env python3
|
||||
"""family.py — add the household's three family members through the „Család" card's endpoint (B3).
|
||||
Tries each name first as the family would write it (accented), then the plain form. The box answers each new member's
|
||||
password ONCE; it goes to a 0600 file per member (DooPlex moves it into sec) and is never printed."""
|
||||
import os, sys
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import ctl
|
||||
outdir = sys.argv[1]
|
||||
for want, plain in (("Ödön", "odon"), ("Írisz", "irisz"), ("Kata", "kata")):
|
||||
for name in (want, plain) if want.lower() != plain else (plain,):
|
||||
code, d = ctl.form("/family/members/add", {"name": name})
|
||||
pw = (d.get("data") or {}).get("password") if isinstance(d, dict) else None
|
||||
shown = {k: v for k, v in (d.get("data") or {}).items() if k != "password"} if isinstance(d, dict) and isinstance(d.get("data"), dict) else d
|
||||
print(f"add {name!r}: {code} {shown if code == '200' else d}" + (" [password answered once → file]" if pw else ""))
|
||||
if pw:
|
||||
fd = os.open(os.path.join(outdir, f"fam-{plain}"), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||
os.write(fd, pw.encode()); os.close(fd)
|
||||
break
|
||||
print("members:", ctl.call("GET", "/family/members")[1])
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env python3
|
||||
"""install_spec.py APP [FIELD=VALUE ...] [--pw ENVNAME] — writes the ui.py steps for one app's install page:
|
||||
open it, fill the named fields, fill every password box and its confirmation with the secret ENVNAME, press
|
||||
„Telepítés indítása", wait for the result card (15 min), screenshot, print the text."""
|
||||
import json, sys
|
||||
app = sys.argv[1]; steps = [{"goto": f"https://felhom.tester4.test/apps/{app}/install"}, {"wait": 2000}]
|
||||
pw = None
|
||||
for a in sys.argv[2:]:
|
||||
if a.startswith("--pw="):
|
||||
pw = a[5:]
|
||||
elif "=" in a:
|
||||
k, v = a.split("=", 1)
|
||||
steps.append({"select" if k.startswith("select:") else "fill": f"[name={k.split(':')[-1]}]", ("option" if k.startswith("select:") else "value"): v})
|
||||
if pw:
|
||||
steps += [{"fill_all": "input[data-field-type=password]", "env": pw}, {"fill_all": "input[data-confirm-for]", "env": pw}]
|
||||
steps += [{"shot": f"{app}-1-form"}, {"click": "#deploy-form button[type=submit]"}, {"wait_for": "#deploy-result", "timeout": 900000},
|
||||
{"wait": 3000}, {"shot": f"{app}-2-result"}, {"text": True}]
|
||||
print(json.dumps({"size": "desktop", "lang": "hu", "state": "/root/work/state-dash.json", "out": "/root/shots/b3-apps", "steps": steps}))
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/opt/pw/bin/python
|
||||
"""ui.py — drive the household's screens in real Chromium, step by step (rehearsal 2026-10-20).
|
||||
|
||||
usage: ui.py STEPS.json (STEPS may also be '-' to read JSON from the env var UI_STEPS)
|
||||
STEPS = {"size": "phone"|"desktop", "lang": "hu"|"en", "state": "/root/work/state-dash.json" (load+save cookies),
|
||||
"out": "/root/shots/<prefix>", "steps": [ ... ]}
|
||||
step kinds:
|
||||
{"goto": URL} {"click": SELECTOR} / {"click_text": TEXT}
|
||||
{"fill": SELECTOR, "env": NAME} — the value comes from env NAME (never printed)
|
||||
{"fill": SELECTOR, "value": TEXT} — a non-secret value
|
||||
{"fill": SELECTOR, "env": NAME, "typo": true} — one letter changed (a household's typo)
|
||||
{"shot": NAME} — blanks every text/password/textarea input first, then screenshots full page
|
||||
{"wait": MS} {"text": true}
|
||||
{"wait_for": SELECTOR, "timeout": MS} {"fill_all": SELECTOR, "env": NAME} — every enabled match gets the secret
|
||||
{"select": SELECTOR, "option": VALUE} — prints the visible page text (trimmed)
|
||||
{"expect_nav": true} before a click — waits for the navigation the click causes
|
||||
Every step prints one line: what it did, the URL (any 32+ hex run masked), the HTTP status of navigations.
|
||||
"""
|
||||
import json, os, re, sys
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
spec = json.loads(os.environ["UI_STEPS"]) if sys.argv[1] == "-" else json.load(open(sys.argv[1]))
|
||||
SIZES = {"phone": dict(viewport={"width": 390, "height": 844}, device_scale_factor=2, is_mobile=True, has_touch=True,
|
||||
user_agent="Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/155.0.0.0 Mobile Safari/537.36"),
|
||||
"desktop": dict(viewport={"width": 1366, "height": 768})}
|
||||
LOC = {"hu": "hu-HU", "en": "en-US"}
|
||||
mask = lambda u: re.sub(r"[0-9a-f]{32,}", "<token>", u)
|
||||
size, lang = spec.get("size", "phone"), spec.get("lang", "hu")
|
||||
out = spec.get("out", "/root/shots/x")
|
||||
|
||||
|
||||
def typo(v):
|
||||
i = len(v) // 2
|
||||
while i < len(v) and not v[i].isalpha():
|
||||
i += 1
|
||||
return v[:i] + ("q" if v[i] != "q" else "w") + v[i + 1:] if i < len(v) else v + "x"
|
||||
|
||||
|
||||
with sync_playwright() as p:
|
||||
b = p.chromium.launch(executable_path="/usr/bin/chromium", args=["--no-sandbox"])
|
||||
kw = dict(locale=LOC[lang], extra_http_headers={"Accept-Language": LOC[lang]}, timezone_id="Europe/Budapest",
|
||||
ignore_https_errors=True, **SIZES[size])
|
||||
st = spec.get("state")
|
||||
if st and os.path.exists(st):
|
||||
kw["storage_state"] = st
|
||||
ctx = b.new_context(**kw)
|
||||
pg = ctx.new_page()
|
||||
resp = {"s": None}
|
||||
pg.on("response", lambda r: resp.update(s=r.status) if r.request.is_navigation_request() else None)
|
||||
nav_next = False
|
||||
for s in spec["steps"]:
|
||||
try:
|
||||
if "goto" in s:
|
||||
r = pg.goto(s["goto"], wait_until="networkidle", timeout=60000)
|
||||
print(f"goto {mask(pg.url)} -> {r.status if r else '-'} {pg.title()!r}")
|
||||
elif "fill" in s:
|
||||
v = os.environ[s["env"]] if "env" in s else s["value"]
|
||||
if s.get("typo"):
|
||||
v = typo(v)
|
||||
pg.fill(s["fill"], v)
|
||||
print(f"fill {s['fill']} ({'secret ' + s['env'] if 'env' in s else repr(v)}{', with a typo' if s.get('typo') else ''})")
|
||||
elif "click" in s or "click_text" in s:
|
||||
target = pg.locator(s["click"]) if "click" in s else pg.get_by_text(s["click_text"], exact=False)
|
||||
if nav_next:
|
||||
with pg.expect_navigation(wait_until="networkidle", timeout=60000):
|
||||
target.first.click()
|
||||
nav_next = False
|
||||
else:
|
||||
target.first.click()
|
||||
pg.wait_for_load_state("networkidle", timeout=60000)
|
||||
print(f"click {s.get('click') or s.get('click_text')!r} -> {mask(pg.url)} status={resp['s']}")
|
||||
elif "expect_nav" in s:
|
||||
nav_next = True
|
||||
elif "wait_for" in s:
|
||||
pg.wait_for_selector(s["wait_for"], state="visible", timeout=s.get("timeout", 600000))
|
||||
print(f"visible {s['wait_for']}")
|
||||
elif "fill_all" in s:
|
||||
n = 0
|
||||
for el in pg.locator(s["fill_all"]).all():
|
||||
if el.is_enabled():
|
||||
el.fill(os.environ[s["env"]]); n += 1
|
||||
print(f"fill_all {s['fill_all']} ({n} fields, secret {s['env']})")
|
||||
elif "select" in s:
|
||||
pg.select_option(s["select"], s["option"])
|
||||
print(f"select {s['select']} = {s['option']!r}")
|
||||
elif "wait" in s:
|
||||
pg.wait_for_timeout(s["wait"])
|
||||
elif "shot" in s:
|
||||
# blank every text/password field FOR THE PHOTO ONLY, then put the values back (a blanked form that is
|
||||
# submitted afterwards refuses — the 2026-10-20 Nextcloud slip)
|
||||
pg.evaluate("window.__keep = []; document.querySelectorAll('input[type=text],input[type=password],input:not([type]),textarea')"
|
||||
".forEach(e => { if (e.value) { window.__keep.push([e, e.value]); e.value = ''; } })")
|
||||
f = f"{out}-{s['shot']}-{size}-{lang}.png"
|
||||
pg.screenshot(path=f, full_page=True)
|
||||
pg.evaluate("(window.__keep || []).forEach(([e, v]) => { e.value = v; }); window.__keep = [];")
|
||||
hs = pg.evaluate("document.documentElement.scrollWidth > window.innerWidth")
|
||||
print(f"shot {f} hscroll={hs}")
|
||||
elif "text" in s:
|
||||
print("text:", re.sub(r"\s+", " ", pg.inner_text("body"))[:1500])
|
||||
except Exception as e: # noqa: BLE001 — a failing step is a result; record it and stop
|
||||
print(f"STEP FAILED {s}: {type(e).__name__}: {str(e)[:300]}")
|
||||
break
|
||||
if st:
|
||||
ctx.storage_state(path=st)
|
||||
os.chmod(st, 0o600)
|
||||
b.close()
|
||||