{ config, lib, pkgs, homeyConfig, ... }: # Uptime Kuma — endpoint uptime monitoring with a status-page UI. # # This module does two things: # # 1. Declares the shared homey.monitoring.monitors option that any service # module can contribute to. Adding your service's URL there means it # automatically appears in Uptime Kuma — no manual UI work needed. # # 2. Runs Uptime Kuma as an OCI container and syncs the monitor list via # the Socket.IO API on startup using the uptime-kuma-api Python library. # # Example (in nextcloud.nix): # homey.monitoring.monitors = [{ # name = "Nextcloud"; # url = "https://nextcloud.zakobar.com/status.php"; # interval = 60; # }]; # # Auth: Authelia two_factor, admins-only (enforced in authelia.nix + caddy.nix). # # Volume layout: # /uptime-kuma/ → /app/data (SQLite DB, config) # # Secrets consumed from sops: # uptime-kuma/admin_password let cfg = config.homey.uptimeKuma; dataDir = config.homey.storage.mountPoint; domain = homeyConfig.domain; # Serialise the NixOS monitor list to JSON at build time. # The setup script reads this at runtime to know what to create. monitorsJson = pkgs.writeText "uptime-kuma-monitors.json" (builtins.toJSON config.homey.monitoring.monitors); # Python environment for the monitor-sync script. # uptime-kuma-api's transitive deps (requests, socketio, websocket-client) # are listed explicitly because withPackages doesn't always pull propagated # deps transitively in all nixpkgs versions. pythonEnv = pkgs.python3.withPackages (ps: [ ps."uptime-kuma-api" ps.requests ps."python-socketio" ps."websocket-client" ]); # Monitor-sync script: idempotent, hash-gated, uses Socket.IO API syncScript = pkgs.writeText "uptime-kuma-sync.py" '' #!/usr/bin/env python3 """ Sync monitors declared in /etc/uptime-kuma/monitors.json into Uptime Kuma. Runs as a oneshot systemd service after podman-uptime-kuma.service. Tracks a hash of the monitor list so it only re-syncs when the NixOS config changes. Uptime Kuma v1 has no REST API — everything is Socket.IO. Initial admin creation uses api.setup() which raises if already done (we ignore that). """ import hashlib import json import os import sys import time import urllib.request MONITORS_PATH = "/etc/uptime-kuma/monitors.json" HASH_PATH = "/var/lib/uptime-kuma-setup/last-hash" KUMA_URL = "http://localhost:3001" CREDS_DIR = os.environ.get("CREDENTIALS_DIRECTORY", "") def wait_for_kuma(timeout=120): """Wait until Uptime Kuma HTTP responds (any non-5xx — just checks it's up).""" deadline = time.time() + timeout while time.time() < deadline: try: with urllib.request.urlopen(KUMA_URL, timeout=5) as r: if r.status < 500: return True except Exception: pass time.sleep(3) return False def main(): with open(MONITORS_PATH) as f: monitors = json.load(f) config_hash = hashlib.sha256( json.dumps(monitors, sort_keys=True).encode() ).hexdigest() # Skip sync if config hasn't changed try: with open(HASH_PATH) as f: if f.read().strip() == config_hash: print("uptime-kuma-sync: config unchanged, skipping") return except FileNotFoundError: pass password_file = os.path.join(CREDS_DIR, "uptime_kuma_password") with open(password_file) as f: password = f.read().strip() print("uptime-kuma-sync: waiting for Uptime Kuma to be ready...") if not wait_for_kuma(): print("uptime-kuma-sync: timed out waiting for Uptime Kuma", file=sys.stderr) sys.exit(1) from uptime_kuma_api import UptimeKumaApi, MonitorType api = UptimeKumaApi(KUMA_URL) # Initial admin setup via Socket.IO — idempotent (raises if already done, ignore it) try: api.setup("admin", password) print("uptime-kuma-sync: initial admin user created") except Exception as e: print(f"uptime-kuma-sync: setup skipped (already configured): {e}") # Login try: api.login("admin", password) except Exception as e: print(f"uptime-kuma-sync: login failed: {e}", file=sys.stderr) api.disconnect() sys.exit(1) # Collect all configured notification IDs so every monitor gets them. notification_ids = [n["id"] for n in api.get_notifications()] if notification_ids: print(f"uptime-kuma-sync: attaching notifications: {notification_ids}") # Sync monitors: add missing, update changed try: existing = {m["name"]: m for m in api.get_monitors()} for m in monitors: keyword = m.get("keyword") maxretries = m.get("maxretries", 0) monitor_type = MonitorType.KEYWORD if keyword else MonitorType.HTTP extra = {"keyword": keyword} if keyword else {} if m["name"] not in existing: api.add_monitor( type=monitor_type, name=m["name"], url=m["url"], interval=m.get("interval", 60), maxretries=maxretries, notification_id_list={str(nid): True for nid in notification_ids}, **extra, ) print(f"uptime-kuma-sync: created monitor: {m['name']}") elif (existing[m["name"]].get("url") != m["url"] or existing[m["name"]].get("keyword") != keyword or existing[m["name"]].get("maxretries") != maxretries): api.edit_monitor( existing[m["name"]]["id"], type=monitor_type, url=m["url"], interval=m.get("interval", 60), maxretries=maxretries, notification_id_list={str(nid): True for nid in notification_ids}, **extra, ) print(f"uptime-kuma-sync: updated monitor: {m['name']}") finally: api.disconnect() # Persist hash so we don't re-sync on every boot os.makedirs(os.path.dirname(HASH_PATH), exist_ok=True) with open(HASH_PATH, "w") as f: f.write(config_hash) print("uptime-kuma-sync: done") if __name__ == "__main__": main() ''; in { # --------------------------------------------------------------------------- # Shared monitor-list option — declared unconditionally so any service module # can contribute monitors even when Uptime Kuma itself is disabled. # --------------------------------------------------------------------------- options.homey.monitoring.monitors = lib.mkOption { type = lib.types.listOf (lib.types.submodule { options = { name = lib.mkOption { type = lib.types.str; description = "Display name shown in Uptime Kuma."; }; url = lib.mkOption { type = lib.types.str; description = "URL to check (HTTP/HTTPS)."; }; interval = lib.mkOption { type = lib.types.int; default = 60; description = "Check interval in seconds."; }; keyword = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; description = "If set, use a keyword monitor that checks for this string in the response body."; }; maxretries = lib.mkOption { type = lib.types.int; default = 0; description = "Consecutive failures before a DOWN alert is sent. 0 = alert immediately."; }; }; }); default = []; description = '' List of HTTP endpoints to monitor in Uptime Kuma. Each service module contributes its own entries here. ''; }; options.homey.uptimeKuma = { enable = lib.mkEnableOption "Uptime Kuma uptime monitoring"; image = lib.mkOption { type = lib.types.str; default = "docker.io/louislam/uptime-kuma:1"; }; port = lib.mkOption { type = lib.types.port; default = 3001; description = "Host port Uptime Kuma listens on (bound to 127.0.0.1)."; }; }; config = lib.mkIf cfg.enable { # ----------------------------------------------------------------------- # Secrets # ----------------------------------------------------------------------- sops.secrets."uptime-kuma/admin_password" = { owner = "root"; }; # ----------------------------------------------------------------------- # Write monitor list to /etc at build time # ----------------------------------------------------------------------- environment.etc."uptime-kuma/monitors.json" = { source = monitorsJson; mode = "0444"; }; # ----------------------------------------------------------------------- # Uptime Kuma container # ----------------------------------------------------------------------- virtualisation.oci-containers.containers.uptime-kuma = { image = cfg.image; ports = [ "127.0.0.1:${toString cfg.port}:3001" ]; volumes = [ "${dataDir}/uptime-kuma:/app/data" ]; # Join the homey network so monitors can reach other containers by name # (e.g. phpldapadmin:80) without going through the host loopback. extraOptions = [ "--network=homey" ]; }; systemd.services."podman-uptime-kuma" = { after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; }; # ----------------------------------------------------------------------- # Monitor-sync service: runs after Uptime Kuma is up, syncs monitors # ----------------------------------------------------------------------- systemd.services."uptime-kuma-sync" = { description = "Sync Uptime Kuma monitors from NixOS config"; wantedBy = [ "multi-user.target" ]; after = [ "podman-uptime-kuma.service" ]; requires = [ "podman-uptime-kuma.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = true; LoadCredential = "uptime_kuma_password:${config.sops.secrets."uptime-kuma/admin_password".path}"; ExecStart = pkgs.writeShellScript "uptime-kuma-sync-runner" '' set -euo pipefail exec ${pythonEnv}/bin/python3 ${syncScript} ''; }; }; # ----------------------------------------------------------------------- # Uptime Kuma self-monitor # ----------------------------------------------------------------------- homey.monitoring.monitors = [{ name = "Uptime Kuma"; url = "https://uptime.${domain}"; interval = 60; }]; }; }