{ 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 pythonEnv = pkgs.python3.withPackages (ps: [ ps."uptime-kuma-api" ]); # 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. """ 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): 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 setup (creates admin user on first run; no-op if already done) try: info = api.info() if not info.get("isSetup", True): api.setup("admin", password) print("uptime-kuma-sync: initial admin user created") except Exception as e: print(f"uptime-kuma-sync: setup check: {e}", file=sys.stderr) # Login result = api.login("admin", password) if not result.get("ok"): print(f"uptime-kuma-sync: login failed: {result}", file=sys.stderr) api.disconnect() sys.exit(1) # Sync monitors (add missing; skip existing by name) try: existing_names = {m["name"] for m in api.get_monitors()} for m in monitors: if m["name"] in existing_names: print(f"uptime-kuma-sync: monitor exists, skipping: {m['name']}") continue api.add_monitor( type=MonitorType.HTTP, name=m["name"], url=m["url"], interval=m.get("interval", 60), ) print(f"uptime-kuma-sync: created 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."; }; }; }); 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" ]; # uptime-kuma image expects /app/data to be writable; no extra network # needed since we reach it from the host on localhost. }; systemd.services."podman-uptime-kuma" = { after = lib.mkAfter [ "mnt-data.mount" ]; requires = lib.mkAfter [ "mnt-data.mount" ]; }; # ----------------------------------------------------------------------- # 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; }]; }; }