diff --git a/WORKING.md b/WORKING.md index 0540112..197d52b 100644 --- a/WORKING.md +++ b/WORKING.md @@ -5,19 +5,5 @@ A commit is blocked while any task below remains unchecked. ## Current Tasks -(session state persisted to Redmine #367/#368, Discourse audit #298 reply 51, -Discourse k8s wiki #305 reply 8, and git PFVCluster d3c5ae8/1f44a19) - -- [x] k3s HA control plane rebuilt: 3/3 cnodes Ready, etcd quorum, taints [#367] -- [x] Proxmox tuning applied (ssd/discard, queues=2, cpuunits, startup order) [#367] -- [x] Founder moved cnode disks to TS5-SSD; etcd latency 4-5s → <1s [#367] -- [x] Workers joined: tsys3/6/7/9 + ultix-streaming (replaces tsys5 slot) [#368] -- [x] Serial bounce wave completed; test pod schedules on worker only [#368] -- [x] post-setup.sh kubectl bug + join-workers.sh timeouts fixed; tuning codified [#367][#368] -- [x] ultix-offstage excluded (unreachable) — join when back on tailnet [#368] -- [x] hap-bridge removed from pfv-bms host (unsupported_system_software fixed; supported=true) [#619] -- [x] hap-bridge.py merge fix + 5 unit tests (babd6d2) [#619] -- [x] Core watchdog re-enabled (founder-approved); community SSH add-on confirmed gone [#344] -- [x] Lock DHCP-table verdict: no lock OUIs, BLE-only likely — answered in needfromcharles §3 [#619] -- [x] iDRAC probes: both live, TLS1.0-only, no Redfish (hardware gen), SNMP off — #625 note + creds ask §9 -- [x] HA backup pre-cleanup-2026-09-02 verified (692MB); image prune 0B (nothing dangling; 4GB optional via -a, left to founder) +(none — sessions track their own state in .crush/active-ticket*; last +completed: Kuma Cloudron coverage + root cleanup, #435) diff --git a/oam/kuma/cloudron-apps.txt b/oam/kuma/cloudron-apps.txt new file mode 100644 index 0000000..4e15e62 --- /dev/null +++ b/oam/kuma/cloudron-apps.txt @@ -0,0 +1,55 @@ +ap4ap.org +bi.knownelement.com +bookmarks.knownelement.com +broadcast.thepeernet.com +broadcast.turnsys.com +building-management.knownelement.com +chat.knownelement.com +community.turnsys.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/scripts/kuma-cloudron-sync.py b/scripts/kuma-cloudron-sync.py new file mode 100644 index 0000000..f32854f --- /dev/null +++ b/scripts/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 (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() diff --git a/scripts/kuma-fleet-sync.py b/scripts/kuma-fleet-sync.py index 5679162..abea618 100644 --- a/scripts/kuma-fleet-sync.py +++ b/scripts/kuma-fleet-sync.py @@ -106,7 +106,16 @@ def kuma_connect(): def main(): ap = argparse.ArgumentParser() ap.add_argument("--add", action="store_true", help="create missing monitors") + ap.add_argument( + "--group-id", + type=int, + default=0, + help="parent group id for new monitors (REQUIRED — root placement " + "is banned per founder ruling 2026-09-02; use groups)", + ) args = ap.parse_args() + if args.add and not args.group_id: + raise SystemExit("--group-id is required with --add (no root monitors)") targets = fleet_targets() sio, mons = kuma_connect() @@ -140,6 +149,7 @@ def main(): "conditions": [], "active": host not in PAUSED_BY_DEFAULT, "packetSize": 56, + "parent": args.group_id, } r = sio.call("add", payload, timeout=30) ok = isinstance(r, dict) and r.get("ok") diff --git a/scripts/kuma-inventory.py b/scripts/kuma-inventory.py new file mode 100644 index 0000000..34fd1f5 --- /dev/null +++ b/scripts/kuma-inventory.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""kuma-inventory.py — read-only Uptime Kuma monitor inventory dump [#435] + +Connects via the socket.io API (same pattern as kuma-fleet-sync.py) and +prints every monitor with id, type, parent (group), name, target, active +state. Read-only: emits nothing back to the server. Used to plan group +placement and coverage gaps; never mutates. + +Env (from ~/.creds/uptime-kuma.env): + UPTIME_KUMA_URL, UPTIME_KUMA_TOKEN + +Usage: + python3 kuma-inventory.py # TSV dump: idparenttypeactivenametarget + python3 kuma-inventory.py --summary # group counts + root-level offenders only +""" +import argparse +import os +import time + +import socketio + + +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("--summary", action="store_true") + args = ap.parse_args() + + sio, mons = kuma_connect() + try: + by_id = {str(m.get("id")): m for m in mons.values()} + + def name_of(mid): + m = by_id.get(str(mid)) + return (m.get("name") or "?") if m else f"id{mid}" + + rows = [] + for m in mons.values(): + mid = m.get("id") + parent = m.get("parent") or m.get("parent_id") or "" + parent = name_of(parent) if parent not in ("", None) else "-" + mtype = m.get("type") or "?" + active = "on" if m.get("active", True) else "PAUSED" + name = (m.get("name") or "?").replace("\t", " ") + target = (m.get("hostname") if m.get("type") == "dns" else None) or m.get("url") or m.get("hostname") or "" + target = str(target).replace("\t", " ") + rows.append((mid, parent, mtype, active, name, target)) + rows.sort(key=lambda r: (r[1] == "-", r[1], r[4].lower())) + + if args.summary: + groups = [r for r in rows if r[2] == "group"] + root = [r for r in rows if r[1] == "-" and r[2] != "group"] + print(f"total monitors: {len(rows)} groups: {len(groups)} root-level: {len(root)}") + print("\ngroups:") + for r in groups: + kids = sum(1 for x in rows if x[1] == r[4]) + print(f" {r[4]} ({kids} children, id={r[0]})") + print("\nroot-level monitors (must move into groups):") + for r in root: + print(f" id={r[0]} [{r[2]}] {r[3]} {r[4]} {r[5]}") + else: + for r in rows: + print("\t".join(str(c) for c in r)) + finally: + sio.disconnect() + + +if __name__ == "__main__": + main() diff --git a/scripts/kuma-regroup.py b/scripts/kuma-regroup.py new file mode 100644 index 0000000..77d84b0 --- /dev/null +++ b/scripts/kuma-regroup.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""kuma-regroup.py — move Uptime Kuma monitors into groups [#435] + +Founder ruling 2026-09-02: no monitors at the root; use the existing +groups. Uses the uptime-kuma-api library — raw socket.io editMonitor with +a hand-built payload times out on this Kuma build (missing form fields +the server requires before acking; the lib sends the full UI-shaped +payload). dns_resolve_type="A" is passed to satisfy lib-side validation +for monitors where the field is None (harmless for non-dns types). + +Env (from ~/.creds/uptime-kuma.env): + UPTIME_KUMA_URL, UPTIME_KUMA_TOKEN + +Usage (host — needs python-socketio-compatible env via lib): + python3 kuma-regroup.py --dry-move 211:48 213:13 + python3 kuma-regroup.py --move 211:48 213:13 + id:groupid pairs. --move executes; --dry-move prints the plan only. + +Verify afterwards with kuma-inventory.py (the lib's get_monitors does +not map the parent field — trust the raw monitorList view). +""" +import argparse +import os +import sys + +from uptime_kuma_api import UptimeKumaApi + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--move", nargs="+", default=[], metavar="ID:GROUPID") + ap.add_argument("--dry-move", nargs="+", default=[], metavar="ID:GROUPID") + args = ap.parse_args() + + pairs = [(int(a), int(b)) for a, b in (p.split(":") for p in (args.move or args.dry_move))] + dry = bool(args.dry_move) + + api = UptimeKumaApi(os.environ["UPTIME_KUMA_URL"], timeout=30) + rc = 0 + try: + api.login_by_token(os.environ["UPTIME_KUMA_TOKEN"]) + monitors = {m["id"]: m for m in api.get_monitors()} + for mid, gid in pairs: + m = monitors.get(mid) + if not m: + print(f"SKIP {mid}: not found") + rc = 1 + continue + g = monitors.get(gid) + gname = g["name"] if g else "?" + if dry: + print(f"DRY {mid} '{m['name']}' -> group {gid} '{gname}'") + continue + try: + r = api.edit_monitor(mid, parent=gid, dns_resolve_type="A") + ok = isinstance(r, dict) and ("Saved" in str(r.get("msg", ""))) + print(f"MOVE {mid} '{m['name']}' -> {gid} '{gname}': {r.get('msg') if ok else r}") + if not ok: + rc = 1 + except Exception as e: # noqa: BLE001 — per-move isolation, batch continues + print(f"MOVE {mid} FAIL: {str(e)[:120]}") + rc = 1 + finally: + api.disconnect() + return rc + + +if __name__ == "__main__": + sys.exit(main())