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
gates / gates (push) Successful in 13m17s
This commit is contained in:
BIN
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 120 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
BIN
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")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user