Files
homey/modules/services/uptime-kuma.nix
T
Aner Zakobar 08e8b5edbe REWRITE
2026-05-20 23:21:36 +03:00

319 lines
12 KiB
Nix

{ 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:
# <dataDir>/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)
# 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,
**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,
**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" // { default = true; };
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}
'';
};
};
# -----------------------------------------------------------------------
# Caddy virtual host — forward_auth, admins only
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "uptime";
port = cfg.port;
auth = true;
}];
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.extraDirs = [
{ path = "uptime-kuma"; }
];
# -----------------------------------------------------------------------
# Backup
# -----------------------------------------------------------------------
homey.backup.extraPaths = [ "${dataDir}/uptime-kuma" ];
# -----------------------------------------------------------------------
# Uptime Kuma self-monitor
# -----------------------------------------------------------------------
homey.monitoring.monitors = [{
name = "Uptime Kuma";
url = "https://uptime.${domain}";
interval = 60;
}];
};
}