diff --git a/.env.example b/.env.example index 9434215..8bd73db 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,9 @@ # When issued, store the material value in ~/.creds/cloudron.env as: # CLOUDRON_API_TOKEN= CLOUDRON_API_TOKEN= +# Uptime Kuma (monitoring/kuma-cloudron-sync.py) — material value in ~/.creds/uptime-kuma.env +# UPTIME_KUMA_URL= +# UPTIME_KUMA_TOKEN= +# Uptime Kuma (monitoring/kuma-cloudron-sync.py) — material value in ~/.creds/uptime-kuma.env +# UPTIME_KUMA_URL= +# UPTIME_KUMA_TOKEN= diff --git a/README.md b/README.md index ab233a3..4e75d5d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Postgres/MySQL/mail containers, monitored by Uptime Kuma (Cloudron group). | Path | What | |------|------| | `perf/` | [#685](https://projects.knownelement.com/issues/685) perf work: memory census, pg role-cap SQL, applied sysctl, planned docker daemon.json | +| `monitoring/` | Uptime Kuma Cloudron app coverage — 55-app inventory + sync checker (moved from PFVCluster, #435/#685/#727) | | `ansible/` | Reserved — AWX/Ansible IaC convergence ([#454](https://projects.knownelement.com/issues/454)); playbooks land here as manual changes are codified | | `scripts/` | Rule engine + git hooks (enforcement layer, ADOPTING pattern) | | `tests/` | shellcheck wrapper (Docker — host stays clean) | diff --git a/monitoring/cloudron-apps.txt b/monitoring/cloudron-apps.txt new file mode 100644 index 0000000..3c9902e --- /dev/null +++ b/monitoring/cloudron-apps.txt @@ -0,0 +1,62 @@ +ap4ap.org +bi.knownelement.com +booking.rackrental.net +booking.reachableceo.com +booking.roguetechnologies.net +booking.startinglineproductions.com +booking.teamrental.net +booking.thepeernet.com +bookmarks.knownelement.com +broadcast.thepeernet.com +broadcast.turnsys.com +building-management.knownelement.com +chat.knownelement.com +community.turnsys.com +design.knownelement.com +erp.knownelement.com +esign.knownelement.com +ezeda.org +ezpodstack.org +finance.turnsys.com +forms.knownelement.com +git.knownelement.com +grafana.knownelement.com +helpdesk.knownelement.com +hfnfc.net +hfnoc.net +ipam.knownelement.com +jupyter.knownelement.com +knownelement.com +learn.turnsys.com +marketing.turnsys.com +matrix.turnsys.com +meetmorse.com +merchantsofhope.org +my.knownelement.com +nextcloud.knownelement.com +paperless.knownelement.com +photos.knownelement.com +phplist.knownelement.com +projects.knownelement.com +pwvault.turnsys.com +rackrental.net +reachableceo.com +readlater.knownelement.com +recordings.thepeernet.com +redwfo.com +rss.knownelement.com +rwscp.net +sidedoorgroup.org +sol-calc.com +startinglineproductions.com +staticbits.knownelement.com +status.knownelement.com +suborbital-systems.com +surveys.knownelement.com +thepeernet.com +turnsys.com +uksrs.org +url.knownelement.com +webmail.knownelement.com +webstats.knownelement.com +whiteboard.knownelement.com diff --git a/monitoring/kuma-cloudron-sync.py b/monitoring/kuma-cloudron-sync.py new file mode 100644 index 0000000..f47b410 --- /dev/null +++ b/monitoring/kuma-cloudron-sync.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""kuma-cloudron-sync.py — Uptime Kuma Cloudron app coverage sync [#435] + +Compares the committed Cloudron app list (monitoring/cloudron-apps.txt, ex-PFVCluster) 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 / "cloudron-apps.txt" +DEFAULT_GROUP = 277 # "Cloudron" group (founder-directed 2026-09-02) + + +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()