Rehearsal 2026-10-20: guide §3 quotes the console the box really shows; bind-page screenshots (codes never shown); app upload and gallery tools; R-1189 (d) (e) confirmed in code
gates / gates (push) Successful in 13m17s

This commit is contained in:
2026-10-20 13:17:29 +02:00
parent d750a90d0e
commit ad6397308c
9 changed files with 228 additions and 3 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

@@ -0,0 +1,182 @@
#!/usr/bin/python3
"""apps.py — the pretend household fills its apps through each app's OWN front door (rehearsal 2026-10-20, B4).
Runs in the browser LXC. Every app is reached by its real name (`<sub>.tester4.test`, mapped to the box's LAN address in
the LXC's /etc/hosts — the household's DNS would do the same) over HTTPS; the box's certificate is not trusted on a
LAN name, so TLS verification is off (recorded as a harness substitution). Credentials come from env vars whose NAMES
are passed in (brun.sh hands them in on stdin). Nothing secret is printed. Each step prints what it sent and what the
app answered, and the files it sent are named by their manifest path so B7/B8 can compare by sha256.
usage: apps.py <app> <args…> (see the functions below)
"""
import base64, hashlib, json, os, ssl, subprocess, sys, time, urllib.error, urllib.parse, urllib.request
from email.utils import formatdate
DATA = "/root/data"
DOMAIN = os.environ.get("DOMAIN", "tester4.test")
CTX = ssl.create_default_context()
CTX.check_hostname = False
CTX.verify_mode = ssl.CERT_NONE
def say(*a):
print(time.strftime("%H:%M:%SZ", time.gmtime()), *a, flush=True)
def http(method, url, body=None, headers=None, timeout=300):
h = dict(headers or {})
data = body
if isinstance(body, (dict, list)):
data = json.dumps(body).encode()
h.setdefault("Content-Type", "application/json")
req = urllib.request.Request(url, data=data, method=method, headers=h)
try:
with urllib.request.urlopen(req, context=CTX, timeout=timeout) as r:
return r.status, r.read(), dict(r.headers)
except urllib.error.HTTPError as e:
return e.code, e.read(), dict(e.headers)
def basic(user, pw):
return "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode()
def manifest(app=None):
rows = []
with open(os.path.join(DATA, "MANIFEST.tsv"), encoding="utf-8") as fh:
next(fh)
for line in fh:
p, size, sha, a, cls, note = line.rstrip("\n").split("\t")
if app is None or a == app:
rows.append(dict(path=p, size=size, sha=sha, app=a, cls=cls, note=note))
return rows
def multipart(fields, files):
"""fields: {name: str}; files: [(field, filename, bytes, ctype)] → (body, content-type)"""
b = "----felhomrehearsal" + hashlib.sha1(str(time.time()).encode()).hexdigest()[:16]
out = []
for k, v in fields.items():
out += [f"--{b}\r\n".encode(), f'Content-Disposition: form-data; name="{k}"\r\n\r\n'.encode(), str(v).encode(), b"\r\n"]
for field, fname, content, ctype in files:
q = urllib.parse.quote(fname)
out += [f"--{b}\r\n".encode(),
f'Content-Disposition: form-data; name="{field}"; filename="{q}"; filename*=UTF-8\'\'{q}\r\n'.encode(),
f"Content-Type: {ctype}\r\n\r\n".encode(), content, b"\r\n"]
out.append(f"--{b}--\r\n".encode())
return b"".join(out), f"multipart/form-data; boundary={b}"
# ---------------------------------------------------------------- Paperless-ngx (documents, class A)
def paperless(sub, user_env, pw_env):
base, auth = f"https://{sub}.{DOMAIN}", basic(os.environ[user_env], os.environ[pw_env])
ok = bad = 0
for r in manifest("paperless-ngx" if False else "paperless"):
content = open(os.path.join(DATA, r["path"]), "rb").read()
body, ct = multipart({"title": os.path.basename(r["path"])[:-4]},
[("document", os.path.basename(r["path"]), content, "application/pdf")])
st, out, _ = http("POST", base + "/api/documents/post_document/", body, {"Authorization": auth, "Content-Type": ct})
ok += st == 200
bad += st != 200
if st != 200:
say("paperless upload", r["path"], st, out[:200])
say(f"paperless: {ok} accepted, {bad} refused")
# ---------------------------------------------------------------- Nextcloud (files, class A) — WebDAV
def nextcloud(sub, user_env, pw_env):
"""rclone over WebDAV, as a desktop sync client would. Symlinks are not followed (a sync client skips them)."""
user, pw = os.environ[user_env], os.environ[pw_env]
obscured = subprocess.run(["rclone", "obscure", "-"], input=pw, capture_output=True, text=True).stdout.strip()
env = dict(os.environ, RCLONE_CONFIG_NC_TYPE="webdav", RCLONE_CONFIG_NC_URL=f"https://{sub}.{DOMAIN}/remote.php/dav/files/{user}",
RCLONE_CONFIG_NC_VENDOR="nextcloud", RCLONE_CONFIG_NC_USER=user, RCLONE_CONFIG_NC_PASS=obscured)
r = subprocess.run(["rclone", "copy", "--no-check-certificate", "--transfers", "4", "--stats", "60s", "--stats-one-line",
"-v", os.path.join(DATA, "nextcloud"), "nc:/"], env=env, capture_output=True, text=True)
tail = [l for l in (r.stdout + r.stderr).splitlines() if "ERROR" in l or "Transferred" in l or "Skipping" in l or "NOTICE" in l]
say(f"nextcloud rclone rc={r.returncode}")
for l in tail[-25:]:
print(" ", l)
# ---------------------------------------------------------------- Radicale (calendar, class B) — CalDAV
def radicale(sub, user_env, pw_env):
base, user = f"https://{sub}.{DOMAIN}", os.environ[user_env]
auth = basic(user, os.environ[pw_env])
cal = f"{base}/{urllib.parse.quote(user)}/csalad/"
body = ('<?xml version="1.0" encoding="utf-8"?><C:mkcalendar xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">'
'<D:set><D:prop><D:displayname>Család</D:displayname></D:prop></D:set></C:mkcalendar>').encode()
st, out, _ = http("MKCALENDAR", cal, body, {"Authorization": auth, "Content-Type": "application/xml"})
say("radicale MKCALENDAR", st)
ics = open(os.path.join(DATA, "radicale", "csalad.ics"), "rb").read().decode()
# one PUT per VEVENT (CalDAV stores one component per resource)
head, rest = ics.split("BEGIN:VEVENT", 1)
tz = head[head.index("BEGIN:VTIMEZONE"):]
for i, ev in enumerate(("BEGIN:VEVENT" + rest).split("BEGIN:VEVENT")[1:]):
one = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//felhom rehearsal//EN\r\n" + tz + "BEGIN:VEVENT" + ev.split("END:VCALENDAR")[0] + "END:VCALENDAR\r\n"
st, out, _ = http("PUT", cal + f"event{i}.ics", one.encode(), {"Authorization": auth, "Content-Type": "text/calendar"})
say("radicale PUT event", i, st)
# ---------------------------------------------------------------- Mealie (recipes, class B)
def mealie(sub, user_env, pw_env):
base = f"https://{sub}.{DOMAIN}"
form = urllib.parse.urlencode({"username": os.environ[user_env], "password": os.environ[pw_env]}).encode()
st, out, _ = http("POST", base + "/api/auth/token", form, {"Content-Type": "application/x-www-form-urlencoded"})
if st != 200:
say("mealie login", st, out[:200]); return
tok = {"Authorization": "Bearer " + json.loads(out)["access_token"]}
for r in manifest("mealie"):
if not r["path"].endswith(".json"):
continue
rec = json.load(open(os.path.join(DATA, r["path"]), encoding="utf-8"))
st, out, _ = http("POST", base + "/api/recipes", {"name": rec["name"]}, tok)
slug = json.loads(out) if st in (200, 201) else None
say("mealie create", rec["name"], st, slug)
if not slug:
continue
st, out, _ = http("GET", base + f"/api/recipes/{slug}", None, tok)
full = json.loads(out)
full["recipeIngredient"] = [{"note": x, "display": x} for x in rec["ingredients"]]
full["recipeInstructions"] = [{"text": x} for x in rec["steps"]]
st, out, _ = http("PUT", base + f"/api/recipes/{slug}", full, tok)
say("mealie update", slug, st)
img = open(os.path.join(DATA, r["path"][:-5] + ".jpg"), "rb").read()
body, ct = multipart({"extension": "jpg"}, [("image", "kep.jpg", img, "image/jpeg")])
st, out, _ = http("PUT", base + f"/api/recipes/{slug}/image", body, dict(tok, **{"Content-Type": ct}))
say("mealie image", slug, st)
# ---------------------------------------------------------------- Immich (photos, class A)
def immich(sub, email_env, pw_env, name="Kovács Ödön"):
base = f"https://{sub}.{DOMAIN}"
st, out, _ = http("POST", base + "/api/auth/admin-sign-up",
{"email": os.environ[email_env], "password": os.environ[pw_env], "name": name})
say("immich admin-sign-up", st, out[:120])
st, out, _ = http("POST", base + "/api/auth/login", {"email": os.environ[email_env], "password": os.environ[pw_env]})
if st not in (200, 201):
say("immich login", st, out[:200]); return
tok = {"Authorization": "Bearer " + json.loads(out)["accessToken"]}
ok = dup = bad = 0
for r in manifest("immich"):
p = os.path.join(DATA, r["path"])
content = open(p, "rb").read()
mt = formatdate(os.path.getmtime(p), usegmt=True)
iso = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime(os.path.getmtime(p)))
ctype = "video/mp4" if p.endswith(".mp4") else "image/png" if p.endswith(".png") else "image/jpeg"
body, ct = multipart({"deviceAssetId": r["path"], "deviceId": "rehearsal-phone", "fileCreatedAt": iso,
"fileModifiedAt": iso}, [("assetData", os.path.basename(p), content, ctype)])
st, out, _ = http("POST", base + "/api/assets", body, dict(tok, **{"Content-Type": ct}))
res = json.loads(out) if out[:1] == b"{" else {}
if st in (200, 201) and res.get("status") == "duplicate":
dup += 1
elif st in (200, 201):
ok += 1
else:
bad += 1
say("immich refused", r["path"], st, out[:160])
say(f"immich: {ok} created, {dup} duplicates, {bad} refused")
if __name__ == "__main__":
fn = globals()[sys.argv[1]]
fn(*sys.argv[2:])
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""gallery.py — build screens/README.md: every screenshot, phone and computer side by side, HU and EN (Part E).
Files named `<prefix>-<phone|desktop>-<hu|en>.png` (tools/shot.py) are grouped by prefix into one row each; anything
else (console captures, bind.py's numbered shots) is listed under its folder. Relative links only, so the operator can
look without a box. Before writing, every image is listed with its size so a reviewer can spot one that was never
checked for a secret (rule 4: a screenshot that shows a secret is deleted, not committed).
usage: gallery.py <screens dir>
"""
import os, re, sys
from collections import OrderedDict
root = sys.argv[1]
pat = re.compile(r"^(?P<p>.+)-(?P<s>phone|desktop)-(?P<l>hu|en)\.png$")
out = ["# Rehearsal 2026-10-20 — every screen the household met", "",
"Phone = 390×844, computer = 1366×768 (Chromium 155 + Playwright 1.52.0 in a throwaway container; see "
"`../A4-browser/`). Hungarian and English browser languages. Codes, passwords and links are blacked out or "
"never shown. Console captures are QEMU screendumps of the VM's monitor.", ""]
for folder in sorted(d for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))):
files = sorted(f for f in os.listdir(os.path.join(root, folder)) if f.endswith(".png"))
if not files:
continue
out += [f"## {folder}", ""]
groups, rest = OrderedDict(), []
for f in files:
m = pat.match(f)
if m:
groups.setdefault(m["p"], {})[(m["s"], m["l"])] = f
else:
rest.append(f)
if groups:
out += ["| screen | phone HU | phone EN | computer HU | computer EN |", "|---|---|---|---|---|"]
for p, g in groups.items():
cells = []
for k in (("phone", "hu"), ("phone", "en"), ("desktop", "hu"), ("desktop", "en")):
cells.append(f'<img src="{folder}/{g[k]}" width="{140 if k[0] == "phone" else 260}">' if k in g else "—")
out.append(f"| `{p}` | " + " | ".join(cells) + " |")
out.append("")
for f in rest:
out += [f"**{f}**", "", f'<img src="{folder}/{f}" width="520">', ""]
open(os.path.join(root, "README.md"), "w", encoding="utf-8").write("\n".join(out) + "\n")
print("gallery:", sum(len([f for f in os.listdir(os.path.join(root, d)) if f.endswith('.png')])
for d in os.listdir(root) if os.path.isdir(os.path.join(root, d))), "images")
+1 -1
View File
@@ -194,7 +194,7 @@ stopping line that lies.
| **R-1186** | Backup & restore | P3 | **The nightly off-site copy is skipped silently when another backup holds the flag, and the catch-up never re-runs it.** `offbox.go:981-996` returns nil with an INFO line on the nightly path; `withLeg` marks the leg ended anyway (`cmd/controller/main.go:1246`). The 05:30 proof and 06:00 integrity check take the same flag, so a backup time near them collides every night. Only the hub's 48 h `offsite_stale` notices. CONFIRMED in code. Data-loss hunt 2026-10-20 (T5). | OPEN | — | Fix: mark the leg NOT ended on a busy skip so the catch-up retries; say it on the backups page | CC |
| **R-1187** | Backup & restore | P3 | **`.fab` export/import candidates from the data-loss hunt, not yet proven:** (a) an export silently drops a drive folder that holds a symlink (`appexport/fabplan.go:292`, `export.go:714`); (b) the import never sets file owners and drops symlinks (`restore.go:1157-1176`); (c) import into a STOPPED app merges into the existing volumes, and the default export (stop ticked) carries no SQL dump (`restore.go:361-374`, `export.go:262,366`, `appbackup/dbdump.go:142`); (d) no concurrency guard, no undo, no record (`restore.go:181-186`, `web/handler_export.go:312-353`); (e) PROVEN and split out as R-1191 (the import drops the family gate); (f) it trusts the bundle's HDD_PATH (`restore.go:589-631`); (g) the DB import runs psql without ON_ERROR_STOP and misses postgis (`restore.go:1052-1053, 928-937`). Read by reviewer 1; main session spot-checked (a); (e) proven live → R-1191. Evidence `audits/dataloss-hunt-2026-10-20/C1-restore-rollback-candidates.md`. | VERIFY | — | Prove (c) first on 9202 with a throwaway app; split into rows only what reproduces | CC |
| **R-1188** | Backup & restore | P3 | **Restore-path candidates from the data-loss hunt, not yet proven (one confirmed in code):** (a) a crash or power cut mid-restore restarts the app on a mixed state and never reads the undo copy (`appstop_marker.go:307`, `offbox_restore_undo.go:137`, `bootrecon/bootrecon.go:210-213`); (b) a failed `volume rm` is ignored before the untar (`restore.go:218`); (c) PROVEN against production code (`audits/dataloss-hunt-2026-10-20/C3-R12-leftover-temp/`): an interrupted second-drive whole restore leaves `<file>.felhom-restoring`, and EVERY retry fails „fájlmásolás sikertelen: … file exists" with the file never back (`tier2_whole.go:86-87`, O_EXCL); (d) the full off-site download has a 30-min cap per press and the household is not told to retry (`web/offbox_handlers.go:455`); (e) place and full restore re-read the LATEST snapshot instead of the downloaded one (`offbox_restore.go:675-679`). Evidence `audits/dataloss-hunt-2026-10-20/C1-restore-rollback-candidates.md`. | VERIFY | — | Fix (c) now (remove a stale temp of the same name before the copy); prove (a) with the B6 power-cut-mid-restore scenario | CC |
| **R-1189** | Backup & restore | P3 | **Time and concurrency candidates from the data-loss hunt, not yet proven:** (a) a host clock ahead during one whole-guest backup silences that tier until real time passes it, and the hub reads the same clock (agent `localapi/server.go:1178-1192`, `backup/runner.go:607-629`, hub `monitor/deadline.go:243`; R-973 cap counts future runs `backup_cap.go:61`); (b) the whole-guest start creeps ~5 min/night until a night is skipped silently (`server.go:1184`, `quiesce.go:150, 807-818`); (c) the whole-guest quiesce stops apps mid-restore or mid-update (`quiesce.go:213-275, 565`); (d) the off-site proof and integrity check never run on a box that is off at 05:30–06:00 (`cmd/controller/main.go:1575-1619`, comment says the opposite); (e) hub false „no DB dump" for a late or evening backup time (`monitor/deadline.go:416-419, 556-565`); (f) the OS leg and kernel reboot follow a daytime valve backup (agent `server.go:982-986`, `kernel.go:101-114`) — design question; (g) spring forward 2027-03-28 fires dump and second copy together; (h) self-update busy check before the pull only (`updater.go:532-545, 764`); (i) a double-clicked restore shows failed while it runs (`web/handlers.go:1853-1874`). Evidence `audits/dataloss-hunt-2026-10-20/C1-time-concurrency-candidates.md`. | VERIFY | — | (a) is B6 #8's live test; (b) read the agent journal on demo-hp; the rest by unit tests | CC |
| **R-1189** | Backup & restore | P3 | **Time and concurrency candidates from the data-loss hunt, not yet proven:** (a) a host clock ahead during one whole-guest backup silences that tier until real time passes it, and the hub reads the same clock (agent `localapi/server.go:1178-1192`, `backup/runner.go:607-629`, hub `monitor/deadline.go:243`; R-973 cap counts future runs `backup_cap.go:61`); (b) the whole-guest start creeps ~5 min/night until a night is skipped silently (`server.go:1184`, `quiesce.go:150, 807-818`); (c) the whole-guest quiesce stops apps mid-restore or mid-update (`quiesce.go:213-275, 565`); (d) CONFIRMED in code: the off-site proof and integrity check are plain daily jobs outside the catch-up (`nightchain.go` header: the catch-up runs only the three backup legs), so they never run on a box that is off at 05:30–06:00, while the comment at `cmd/controller/main.go:1575-1577` says the opposite; (e) CONFIRMED in code: the hub's 05:00 check wants a dump „since Budapest midnight" (`hub/internal/monitor/deadline.go:416-419, 556-565`), so an evening backup time (which the banner suggests) raises a false `expected_dbdump_missed` error every morning — and on 2026-10-25 the check also runs at 04:00 (R-1183); (f) the OS leg and kernel reboot follow a daytime valve backup (agent `server.go:982-986`, `kernel.go:101-114`) — design question; (g) spring forward 2027-03-28 fires dump and second copy together; (h) self-update busy check before the pull only (`updater.go:532-545, 764`); (i) a double-clicked restore shows failed while it runs (`web/handlers.go:1853-1874`). Evidence `audits/dataloss-hunt-2026-10-20/C1-time-concurrency-candidates.md`. | VERIFY | — | (a) is B6 #8's live test; (b) read the agent journal on demo-hp; the rest by unit tests | CC |
| **R-1190** | Backup & restore | P3 | **Key and retention candidates from the data-loss hunt, not yet proven:** (a) a recovery ceremony while off-site is not configured seals a package with no repo password and supersedes the good one (`escrow_handlers.go:217`, agent `main.go:2975-2981`, hub `store.go:3392`); (b) the global „create your recovery code" bar links to the ceremony the backups page hides during a recovery offer (`escrow_banner.go:23-33` vs `backups_remote.html:144-160`); (c) after a reuse-path reinstall the new PBS key K is never escrowed and nothing compares fingerprints (`felhom-pbs-apply:99`, `escrow_confirm.go:182-205`); (d) whole-guest off-site copies run before any escrow exists (design question vs 07 §6.1's Tier-3 gate); (e) the escrow PUT is two statements; (f) the operator's off-site re-issue resets the provider password before saving it (hub `offsite.go:217-245`); (g) „Megőrzött adatok" can list a running app's live folder when its app.yaml is unreadable (`kept.go:220-235, 330-402`); (h) a unit restore can point an app back at an old drive and F5 then prunes the current drive's unit (`backup.go:1517-1574`). Evidence `audits/dataloss-hunt-2026-10-20/C1-keys-candidates.md`, `C1-delete-retention-candidates.md`. | VERIFY | — | Unit tests first for (a), (e), (g) | CC |
## Storage & devices — 9 rows (P3 3, P4 6)
@@ -85,8 +85,8 @@ Indítsd a gépet az USB-kulcsról. **A telepítő képernyői angolul vannak**
## 3. Az első indulás (~2 perc)
A képernyőn ez jelenik meg: „Felhom otthoni szerver — Ezen a képernyőn nincs teendőd, bejelentkezni
sem kell." Alatta, kb. 2 perc múlva:
A képernyőn ez jelenik meg: „Felhom otthoni szerver — Ezen a gépen most nincs dolgod, és bejelentkezni
sem kell." *(2026-10-20)* Alatta, kb. 2–4 perc múlva:
> Felhom — a doboz készen áll, és a párosításra vár.
> Párosító kód: XXX-XXX