From 28c2b16fd07de63880373b367f59389dd5ca140b Mon Sep 17 00:00:00 2001 From: reachableceo Date: Wed, 26 Aug 2026 19:12:28 -0500 Subject: [PATCH] feat(kuma): fleet ICMP coverage sync script [#435] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gap-analyzes the tailnet Linux fleet + static network gear (switches, router, APs, PDU, stor1, Reston VPSes) against Uptime Kuma ping monitors over the socket.io API, and can create missing monitors with --add. Websocket transport is forced because the Cloudron proxy drops engine.io polling pushes. First run closed the last 4 gaps: 84/84. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- scripts/kuma-fleet-sync.py | 140 +++++++++++++++++++++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 scripts/kuma-fleet-sync.py diff --git a/scripts/kuma-fleet-sync.py b/scripts/kuma-fleet-sync.py new file mode 100644 index 0000000..3db015b --- /dev/null +++ b/scripts/kuma-fleet-sync.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""kuma-fleet-sync.py — Uptime Kuma ICMP coverage sync [#435] + +Compares every Linux tailnet peer + static network gear against Uptime +Kuma ping monitors, reports coverage gaps, and (with --add) creates the +missing monitors over the socket.io API. + +Env (from ~/.creds/uptime-kuma.env): + UPTIME_KUMA_URL, UPTIME_KUMA_TOKEN (long-lived login token), + UPTIME_KUMA_API_KEY (unused, kept for compatibility) + +Notes: + - status.knownelement.com is behind a proxy that breaks engine.io + polling pushes, so the websocket transport is forced. + - Monitor payloads must include `conditions: []` and + `notificationIDList` or this Kuma build rejects the insert. + - Static gear (switches/router/APs/PDU) is appended manually below; + keep in sync with LibreNMS + Technitium knel.net zone. + +Usage: + python3 kuma-fleet-sync.py # gap report only + python3 kuma-fleet-sync.py --add # create missing monitors +""" +import argparse +import os +import socketio +import subprocess +import sys +import time + +STATIC_GEAR = [ + "pfv-r5-core-01.knel.net", + "pfv-r3-tor-mgmt-01.knel.net", + "pfv-r3-tor-stor-01.knel.net", + "pfv-r2-tor-01.knel.net", + "pfv-r6-mgmt-01.knel.net", + "pfv-rrinfra-rtr.knel.net", + "ap-tablemount.knel.net", + "ap-wallmount.knel.net", + "pfv-garage-pdu-1.knel.net", + "pfv-stor1.knel.net", + "netbird.knel.net", + "tsys-cloudron.knel.net", +] +PAUSED_BY_DEFAULT = {"stlp-3dscanner.knel.net"} + + +def fleet_targets(): + # Host-gathered peer list (tailscale CLI lives on the workstation, not in + # the container this script runs in): FLEET_TARGETS=newline-separated FQDNs. + env = os.environ.get("FLEET_TARGETS") + if env: + hosts = {h.strip() for h in env.splitlines() if h.strip()} + else: + out = subprocess.run( + ["tailscale", "status", "--json"], capture_output=True, text=True, check=True + ).stdout + import json + + peers = json.loads(out)["Peer"] + hosts = { + p["DNSName"].split(".")[0] + ".knel.net" + for p in peers + if p.get("OS") == "linux" and p.get("DNSName") + } + return hosts | set(STATIC_GEAR) + + +def kuma_connect(): + sio = socketio.Client(reconnection=False) + state = {} + + @sio.on("info") + def _info(_d): + pass + + @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", help="create missing monitors") + args = ap.parse_args() + + targets = fleet_targets() + sio, mons = kuma_connect() + covered = {str(m.get("hostname", "")) for m in mons.values()} + missing = sorted(targets - covered) + + print(f"fleet targets: {len(targets)} kuma monitors: {len(mons)} covered: {len(targets & covered)}") + if not missing: + print("ICMP coverage: COMPLETE") + sio.disconnect() + return + print("missing:") + for host in missing: + print(f" {host}{' (would add PAUSED)' if host in PAUSED_BY_DEFAULT else ''}") + + if args.add: + for host in missing: + payload = { + "type": "ping", + "name": host.split(".")[0], + "hostname": host, + "interval": 60, + "retryInterval": 60, + "resendInterval": 0, + "maxretries": 2, + "notificationIDList": {"1": True, "2": True}, + "upsideDown": False, + "description": "ICMP up/down (kuma-fleet-sync)", + "httpBodyEncoding": "json", + "accepted_statuscodes": ["200-299"], + "conditions": [], + "active": host not in PAUSED_BY_DEFAULT, + "packetSize": 56, + } + r = sio.call("add", payload, timeout=30) + ok = isinstance(r, dict) and r.get("ok") + print(f" ADD {host}: {'id=' + str(r.get('monitorID')) if ok else r}") + sio.disconnect() + + +if __name__ == "__main__": + main()