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

143 lines
7.6 KiB
Python

#!/usr/bin/python3
"""snapshot.py — the household's data, read through each app's OWN front door and compared with the A5 manifest
(rehearsal 2026-10-20: B5 morning, B7 before/after). Runs in the browser LXC (/root/work, /root/data/MANIFEST.tsv).
Env (secrets arrive through envrun.py, never on a command line): ODON (Ödön's password: Nextcloud, Immich, Vaultwarden),
IRISZ (Paperless), KATA (Mealie), BSTOK (BookStack token), RADI (Radicale), SMB (the share password).
Prints one line per check; never prints a secret. Exit 0 always — the lines are the result.
"""
import hashlib, json, os, subprocess, sys, tempfile, urllib.parse
sys.path.insert(0, "/root/work")
from apps import http, basic
D = "tester4.test"
MAN = {}
for line in open("/root/data/MANIFEST.tsv", encoding="utf-8"):
p = line.rstrip("\n").split("\t")
if p[0] != "path":
MAN[p[0]] = (int(p[1]) if p[1].isdigit() else -1, p[2])
by_name = {}
for path, (size, sha) in MAN.items():
by_name.setdefault((path.split("/")[0], os.path.basename(path)), []).append(sha)
def sha(b):
return hashlib.sha256(b).hexdigest()
def paperless():
H = {"Authorization": basic("irisz", os.environ["IRISZ"])}
st, out, _ = http("GET", f"https://paperless.{D}/api/documents/?page_size=200", None, H)
if st != 200:
return print("paperless: ANSWER", st, out[:100])
docs = json.loads(out)["results"]
ok = bad = 0
for d in docs:
s2, o2, _ = http("GET", f"https://paperless.{D}/api/documents/{d['id']}/download/?original=true", None, H)
want = by_name.get(("paperless", d["original_file_name"]), [])
if s2 == 200 and sha(o2 if isinstance(o2, bytes) else o2.encode()) in want:
ok += 1
else:
bad += 1
print(f"paperless: {len(docs)} documents; originals byte-identical to the manifest {ok}, not {bad}")
def immich():
st, out, _ = http("POST", f"https://photos.{D}/api/auth/login", {"email": "tester4+odon@felhom.eu", "password": os.environ["ODON"]})
if st not in (200, 201):
return print("immich: login", st)
H = {"Authorization": "Bearer " + json.loads(out)["accessToken"]}
st, out, _ = http("POST", f"https://photos.{D}/api/search/metadata", {"size": 1000}, H)
items = json.loads(out)["assets"]["items"] if st == 200 else []
ok = bad = 0
for a in items:
s2, o2, _ = http("GET", f"https://photos.{D}/api/assets/{a['id']}/original", None, H)
if s2 == 200 and sha(o2) in by_name.get(("immich", a["originalFileName"]), []):
ok += 1
else:
bad += 1
print(f"immich: {len(items)} assets; originals byte-identical to the manifest {ok}, not {bad}")
def nextcloud():
obs = subprocess.run(["rclone", "obscure", "-"], input=os.environ["ODON"], capture_output=True, text=True).stdout.strip()
env = dict(os.environ, RCLONE_CONFIG_NC_TYPE="webdav", RCLONE_CONFIG_NC_URL=f"https://cloud.{D}/remote.php/dav/files/odon",
RCLONE_CONFIG_NC_VENDOR="nextcloud", RCLONE_CONFIG_NC_USER="odon", RCLONE_CONFIG_NC_PASS=obs)
with tempfile.TemporaryDirectory(dir="/root/work") as t:
r = subprocess.run(["rclone", "copy", "--no-check-certificate", "--transfers", "2", "--bwlimit", "30M", "nc:/Csalad", t + "/Csalad"],
env=env, capture_output=True, text=True)
if r.returncode:
print("nextcloud: rclone copy rc", r.returncode, r.stderr[-200:].replace("\n", " | "))
got = {}
for dp, _, fs in os.walk(t):
for f in fs:
rel = "nextcloud/" + os.path.relpath(os.path.join(dp, f), t)
got[rel] = sha(open(os.path.join(dp, f), "rb").read())
want = {p: v for p, v in MAN.items() if p.startswith("nextcloud/Csalad/")}
same = sum(1 for p, (s, h) in want.items() if got.get(p) == h)
missing = [p for p in want if p not in got]
differ = [p for p in want if p in got and got[p] != want[p][1]]
extra = [p for p in got if p not in want]
print(f"nextcloud: manifest {len(want)} files; byte-identical {same}; missing {len(missing)}; different {len(differ)}; extra {len(extra)}")
for p in (missing + differ)[:12]:
print(" ", "MISSING" if p in missing else "DIFFERS", p)
for p in extra[:8]:
print(" EXTRA", p)
def mealie():
st, out, _ = http("POST", f"https://recipes.{D}/api/auth/token",
urllib.parse.urlencode({"username": "changeme@example.com", "password": os.environ["KATA"]}).encode(),
{"Content-Type": "application/x-www-form-urlencoded"})
if st != 200:
return print("mealie: login", st)
H = {"Authorization": "Bearer " + json.loads(out)["access_token"]}
st, out, _ = http("GET", f"https://recipes.{D}/api/recipes?perPage=100", None, H)
print("mealie: recipes", sorted(x["name"] for x in json.loads(out)["items"]) if st == 200 else st)
def bookstack():
tid, tsec = os.environ["BSTOK"].split(":", 1)
H = {"Authorization": f"Token {tid}:{tsec}"}
st, out, _ = http("GET", f"https://wiki.{D}/api/pages?count=100", None, H)
pages = sorted(x["name"] for x in json.loads(out).get("data", [])) if st == 200 else st
st, out, _ = http("GET", f"https://wiki.{D}/api/image-gallery?count=100", None, H)
print("bookstack: pages", pages, "| images", len(json.loads(out).get("data", [])) if st == 200 else st)
def radicale():
body = b'<?xml version="1.0"?><D:propfind xmlns:D="DAV:"><D:prop><D:getetag/></D:prop></D:propfind>'
st, out, _ = http("PROPFIND", f"https://calendar.{D}/csalad/csalad/", body,
{"Authorization": basic("csalad", os.environ["RADI"]), "Depth": "1", "Content-Type": "application/xml"})
txt = out.decode() if isinstance(out, bytes) else out
print("radicale: PROPFIND", st, "| events", txt.count(".ics</"))
def share():
# the password travels in smbclient's PASSWD environment variable, never on its command line
r = subprocess.run(["smbclient", f"//felhom.{D}/Filmek", "-U", "felhom", "-m", "SMB3", "-c", "recurse; ls"],
env=dict(os.environ, PASSWD=os.environ["SMB"]), capture_output=True, text=True)
lines = [l for l in r.stdout.splitlines() if l.startswith(" ") and " D " not in l and "blocks of size" not in l]
print(f"share Filmek: rc {r.returncode}; {len(lines)} files")
for l in lines[:20]:
print(" ", l.strip()[:110])
def vault():
r = subprocess.run(["bash", "-c", "export NODE_TLS_REJECT_UNAUTHORIZED=0 BITWARDENCLI_APPDATA_DIR=/root/work/bwsnap; rm -rf $BITWARDENCLI_APPDATA_DIR; "
"mkdir -p -m 700 $BITWARDENCLI_APPDATA_DIR; bw config server https://vault.tester4.test >/dev/null; "
"S=$(PW=$ODON bw login tester4+odon@felhom.eu --passwordenv PW --raw 2>/dev/null); "
"[ -n \"$S\" ] || { echo LOGIN-FAILED; exit 0; }; "
"bw list items --session \"$S\" 2>/dev/null | python3 -c 'import json,sys; i=json.load(sys.stdin); "
"print(len(i), sum(len(x.get(\"attachments\") or []) for x in i))'; bw logout >/dev/null 2>&1; rm -rf $BITWARDENCLI_APPDATA_DIR"],
capture_output=True, text=True, env=dict(os.environ, PATH="/usr/local/bin:/usr/bin:/bin"))
print("vaultwarden: items, attachments:", r.stdout.strip()[-60:])
for name in (sys.argv[1:] or ["paperless", "immich", "nextcloud", "mealie", "bookstack", "radicale", "share", "vault"]):
try:
globals()[name]()
except Exception as e: # noqa: BLE001 — one app's failure is a result, not the end of the snapshot
print(f"{name}: CHECK FAILED {type(e).__name__}: {str(e)[:160]}")
sys.stdout.flush()