From b91af32561d3f1f530d839858f2ee9102960cb4c Mon Sep 17 00:00:00 2001 From: Chris Zerafa Date: Sun, 27 Sep 2026 22:11:16 -0400 Subject: [PATCH] Add Voyager tunnel and monitoring telemetry --- collectors/monitoring.py | 82 ++++++++++++++++++++++++++++++++++++++++ collectors/wireguard.py | 63 ++++++++++++++++++++++++++++++ config/services.json | 14 ------- js/voyager.js | 20 +++++++++- telemetry/collector.py | 34 ++++++++++++----- 5 files changed, 189 insertions(+), 24 deletions(-) create mode 100644 collectors/monitoring.py create mode 100644 collectors/wireguard.py diff --git a/collectors/monitoring.py b/collectors/monitoring.py new file mode 100644 index 0000000..e70a6c5 --- /dev/null +++ b/collectors/monitoring.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +import re +import sqlite3 +import subprocess + +CONTAINER = "uptime-kuma" +DATABASE = "/opt/docker/uptime-kuma/data/kuma.db" + + +def _failure(detail): + return { + "state": "offline", "health": "unavailable", "summary": "Unavailable", + "detail": detail, "container_available": False, "version": None, + "active_monitors": 0, "up_count": 0, "down_count": 0, "unknown_count": 0, + } + + +def _container_info(): + result = subprocess.run( + ["docker", "inspect", "--format", + "{{.State.Status}}\t{{.State.Health.Status}}\t{{.Config.Image}}", + CONTAINER], capture_output=True, text=True) + if result.returncode != 0: + return None + fields = result.stdout.strip().split("\t") + if len(fields) != 3: + return None + status, health, image = fields + match = re.search(r":([^:@]+)$", image) + return status, health, match.group(1) if match else None + + +def _database_counts(): + connection = sqlite3.connect(f"file:{DATABASE}?mode=ro", uri=True, timeout=5) + try: + rows = connection.execute( + """ + SELECT m.id, + (SELECT h.status FROM heartbeat h + WHERE h.monitor_id = m.id + ORDER BY h.id DESC LIMIT 1) AS latest_status + FROM monitor m + WHERE m.active = 1 + """ + ).fetchall() + finally: + connection.close() + counts = {"active": len(rows), "up": 0, "down": 0, "unknown": 0} + for _, status in rows: + if status == 1: + counts["up"] += 1 + elif status == 0: + counts["down"] += 1 + else: + counts["unknown"] += 1 + return counts + + +def collect(): + container = _container_info() + if container is None: + return _failure("Uptime Kuma container unavailable") + container_status, container_health, version = container + if container_status != "running": + return _failure("Uptime Kuma container is not running") + try: + counts = _database_counts() + except (OSError, sqlite3.Error): + return _failure("Uptime Kuma aggregate query unavailable") + active, up = counts["active"], counts["up"] + down, unknown = counts["down"], counts["unknown"] + degraded = container_health == "unhealthy" or down > 0 or unknown > 0 + version_detail = f"Uptime Kuma {version}" if version else "Uptime Kuma" + return { + "state": "warning" if degraded else "online", + "health": "partial" if degraded else "healthy", + "summary": f"{up}/{active} monitors up", + "detail": f"{version_detail} · edge01 monitoring operational", + "container_available": True, "container_health": container_health, + "version": version, "active_monitors": active, "up_count": up, + "down_count": down, "unknown_count": unknown, + } diff --git a/collectors/wireguard.py b/collectors/wireguard.py new file mode 100644 index 0000000..6d1995a --- /dev/null +++ b/collectors/wireguard.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +import subprocess +import time + +INTERFACE = "wg0" +RECENT_HANDSHAKE_SECONDS = 30 * 60 + + +def _failure(detail): + return { + "state": "offline", "health": "unavailable", "summary": "Unavailable", + "detail": detail, "interface": INTERFACE, "interface_active": False, + "configured_peers": 0, "recent_handshakes": 0, "stale_handshakes": 0, + "rx_bytes": 0, "tx_bytes": 0, + "recent_handshake_threshold_seconds": RECENT_HANDSHAKE_SECONDS, + } + + +def _format_bytes(value): + units = ("B", "KiB", "MiB", "GiB", "TiB") + amount = float(value) + for unit in units: + if amount < 1024 or unit == units[-1]: + return f"{amount:.2f} {unit}" if unit != "B" else f"{int(amount)} B" + amount /= 1024 + + +def collect(): + result = subprocess.run(["wg", "show", INTERFACE, "dump"], + capture_output=True, text=True) + if result.returncode != 0: + return _failure("WireGuard telemetry unavailable") + lines = [line.split() for line in result.stdout.splitlines() if line.strip()] + if not lines or len(lines[0]) < 4: + return _failure("WireGuard returned invalid telemetry") + peers = lines[1:] + now = int(time.time()) + recent = rx_bytes = tx_bytes = 0 + try: + for peer in peers: + if len(peer) < 8: + return _failure("WireGuard returned incomplete telemetry") + handshake = int(peer[4]) + rx_bytes += int(peer[5]) + tx_bytes += int(peer[6]) + if handshake and now - handshake <= RECENT_HANDSHAKE_SECONDS: + recent += 1 + except (TypeError, ValueError): + return _failure("WireGuard returned invalid telemetry") + configured = len(peers) + stale = configured - recent + state = "online" if configured and recent == configured else "warning" + health = "healthy" if state == "online" else "partial" + return { + "state": state, "health": health, + "summary": f"{recent}/{configured} peers recent", + "detail": (f"WireGuard active · {_format_bytes(rx_bytes)} RX · " + f"{_format_bytes(tx_bytes)} TX"), + "interface": INTERFACE, "interface_active": True, + "configured_peers": configured, "recent_handshakes": recent, + "stale_handshakes": stale, "rx_bytes": rx_bytes, "tx_bytes": tx_bytes, + "recent_handshake_threshold_seconds": RECENT_HANDSHAKE_SECONDS, + } diff --git a/config/services.json b/config/services.json index 76d0807..d2d2197 100644 --- a/config/services.json +++ b/config/services.json @@ -41,20 +41,6 @@ "label": "Review", "value": "Pending", "detail": "Restic + B2" - }, - { - "name": "Tunnel", - "state": "warning", - "label": "Pending", - "value": "Not built", - "detail": "Home network access" - }, - { - "name": "Monitoring", - "state": "warning", - "label": "Planned", - "value": "Next phase", - "detail": "Uptime Kuma" } ], "services": [ diff --git a/js/voyager.js b/js/voyager.js index caeead0..9e9cdbf 100644 --- a/js/voyager.js +++ b/js/voyager.js @@ -67,6 +67,9 @@ async function loadTelemetry() { } function telemetryStatus(systems) { + const wireguard = systems.wireguard || {}; + const monitoring = systems.monitoring || {}; + return [ { name: 'Backups', @@ -103,6 +106,21 @@ function telemetryStatus(systems) { value: systems.system.summary, detail: systems.system.detail }, - + { + name: 'Tunnel', + state: wireguard.state || 'offline', + label: wireguard.health === 'healthy' ? 'Healthy' : + wireguard.health === 'partial' ? 'Partial' : 'Unavailable', + value: wireguard.summary || 'Unavailable', + detail: wireguard.detail || 'WireGuard telemetry unavailable' + }, + { + name: 'Monitoring', + state: monitoring.state || 'offline', + label: monitoring.health === 'healthy' ? 'Healthy' : + monitoring.health === 'partial' ? 'Partial' : 'Unavailable', + value: monitoring.summary || 'Unavailable', + detail: monitoring.detail || 'Monitoring telemetry unavailable' + }, ]; } diff --git a/telemetry/collector.py b/telemetry/collector.py index 6824e21..6b2607a 100755 --- a/telemetry/collector.py +++ b/telemetry/collector.py @@ -1,27 +1,43 @@ #!/usr/bin/env python3 - +import logging from pathlib import Path + from collectors.docker import collect as docker_collect -from .common import write_json from collectors.backup import collect as backup_collect -from collectors.git import collect as git_collect from collectors.certificates import collect as certificates_collect +from collectors.git import collect as git_collect +from collectors.monitoring import collect as monitoring_collect from collectors.system import collect as system_collect +from collectors.wireguard import collect as wireguard_collect +from .common import write_json OUTPUT = Path("/opt/docker/caddy/site/voyager/data/status.json") +LOGGER = logging.getLogger(__name__) + + +def collect_safely(name, collector): + try: + return collector() + except Exception: + LOGGER.exception("%s telemetry collection failed", name) + return { + "state": "offline", "health": "unavailable", + "summary": "Unavailable", "detail": f"{name} telemetry unavailable", + } def main(): payload = { "systems": { - "backup": backup_collect(), - "git": git_collect(), - "docker": docker_collect(), - "certificates": certificates_collect(), - "system": system_collect(), + "backup": backup_collect(), + "git": git_collect(), + "docker": docker_collect(), + "certificates": certificates_collect(), + "system": system_collect(), + "wireguard": collect_safely("WireGuard", wireguard_collect), + "monitoring": collect_safely("Monitoring", monitoring_collect), } } - write_json(OUTPUT, payload)