Files
felhom.eu/documentation/audits/rehearsal-2026-10-20/tools/apps.py
T

207 lines
11 KiB
Python

#!/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", 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}")
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")
# ---------------------------------------------------------------- BookStack (wiki, class B) — its own REST API
def bookstack(sub, tok_env):
base = f"https://{sub}.{DOMAIN}"
tid, tsec = os.environ[tok_env].split(":", 1)
H = {"Authorization": f"Token {tid}:{tsec}"}
st, out, _ = http("POST", base + "/api/books", {"name": "Családi wiki", "description": "A ház dolgai (szintetikus)"}, H)
book = json.loads(out)["id"] if st == 200 else None
say("bookstack book", st, book)
for r in manifest("bookstack"):
if not r["path"].endswith(".md"):
continue
md = open(os.path.join(DATA, r["path"]), encoding="utf-8").read()
name = md.splitlines()[0].lstrip("# ").strip()
st, out, _ = http("POST", base + "/api/pages", {"book_id": book, "name": name, "markdown": md}, H)
page = json.loads(out)["id"] if st == 200 else None
img = open(os.path.join(DATA, r["path"].replace("oldal_", "kep_").replace(".md", ".png")), "rb").read()
body, ct = multipart({"type": "gallery", "uploaded_to": page, "name": "kep.png"}, [("image", "kep.png", img, "image/png")])
st2, out2, _ = http("POST", base + "/api/image-gallery", body, dict(H, **{"Content-Type": ct}))
url = json.loads(out2).get("url") if st2 == 200 else None
if url:
st3, _, _ = http("PUT", base + f"/api/pages/{page}", {"markdown": md + f"\n\n![kép]({url})\n"}, H)
say("bookstack page", name, st, "image", st2, "linked", st3 if url else "-")
if __name__ == "__main__":
fn = globals()[sys.argv[1]]
fn(*sys.argv[2:])