#!/usr/bin/env python3 """kuma-cloudron-sync.py — Uptime Kuma Cloudron app coverage sync [#435] Compares the committed Cloudron app list (oam/kuma/cloudron-apps.txt) against live Kuma http monitors, reports coverage gaps, and (with --add) creates the missing monitors INSIDE the "Cloud Systems" group (never root — founder ruling 2026-09-02: monitors go in groups). App list provenance: CT-log enumeration + DNS resolve + HTTPS probe across all 23 Kuma-monaged domains (2026-09-02, see #435 note). Re-derive with /tmp-style probe pipeline or a read-only Cloudron API diff when a token lands. Two apps (forms/learn) return 500 today — monitored red on purpose (#681). Env (from ~/.creds/uptime-kuma.env): UPTIME_KUMA_URL, UPTIME_KUMA_TOKEN Usage: python3 kuma-cloudron-sync.py [--add] [--limit N] [--group-id ID] --add create missing monitors --limit N add at most N (canary mode) --group-id ID parent group (default: 48 = "Cloud Systems") """ import argparse import os import sys import time from pathlib import Path import socketio HERE = Path(__file__).resolve().parent.parent / "oam" / "kuma" / "cloudron-apps.txt" DEFAULT_GROUP = 48 def load_apps(): apps = [] for line in HERE.read_text().splitlines(): line = line.split("#", 1)[0].strip() if line: apps.append(line) return apps def kuma_connect(): sio = socketio.Client(reconnection=False) state = {} @sio.on("monitorList") def _ml(d): state["ml"] = d sio.connect( os.environ["UPTIME_KUMA_URL"], transports=["websocket"], wait_timeout=30 ) r = sio.call("loginByToken", os.environ["UPTIME_KUMA_TOKEN"], timeout=30) if not (isinstance(r, dict) and r.get("ok")): raise SystemExit(f"login failed: {r}") sio.emit("monitorList") for _ in range(20): if "ml" in state: break time.sleep(1) return sio, state.get("ml", {}) def main(): ap = argparse.ArgumentParser() ap.add_argument("--add", action="store_true") ap.add_argument("--limit", type=int, default=0) ap.add_argument("--group-id", type=int, default=DEFAULT_GROUP) args = ap.parse_args() apps = load_apps() sio, mons = kuma_connect() try: covered = set() for m in mons.values(): name = (m.get("name") or "").strip().lower() url = (m.get("url") or "").strip().lower() for fqdn in apps: if fqdn == name or url.rstrip("/") in ( f"https://{fqdn}".rstrip("/"), f"https://{fqdn}/".rstrip("/"), ): covered.add(fqdn) missing = [a for a in apps if a not in covered] print(f"apps in list: {len(apps)} covered: {len(covered)} missing: {len(missing)}") for fqdn in missing: print(f" MISSING {fqdn}") if args.add and missing: batch = missing if not args.limit else missing[: args.limit] for fqdn in batch: payload = { "type": "http", "name": fqdn, "url": f"https://{fqdn}/", "interval": 60, "retryInterval": 60, "resendInterval": 0, "maxretries": 2, "notificationIDList": {"1": True, "2": True}, "upsideDown": False, "description": "Cloudron app HTTP 200 (#435)", "httpBodyEncoding": "json", "accepted_statuscodes": ["200-299"], "conditions": [], "active": True, "parent": args.group_id, "maxredirects": 10, } r = sio.call("add", payload, timeout=30) ok = isinstance(r, dict) and r.get("ok") print(f" ADD {fqdn}: {'id=' + str(r.get('monitorID')) if ok else r}") finally: sio.disconnect() if __name__ == "__main__": main()