143 lines
7.6 KiB
Python
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()
|