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).
327 lines
18 KiB
Python
327 lines
18 KiB
Python
#!/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()
|