Files
homey/modules/services/uptime-kuma.nix
T
Aner Zakobar c3115026e9 Add storage keepalive (upholds) across services, Nextcloud cron, disable Nextcloud PHP JIT
Requires= doesn't restart a service once its required mount comes back after
a USB disconnect/remount, so a dedicated storage-keepalive unit now Upholds=
every storage-dependent service to bring it back automatically. Also adds the
missing Nextcloud cron.php timer (background jobs were never triggered), and
disables opcache.jit in the Nextcloud container — PHP 8.4's JIT was
miscompiling OC\Config\UserConfig::getValueBool(), crashing every
session-bootstrapping request with a bogus strtolower() TypeError.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-08 09:47:20 +03:00

329 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, timeout=30)
# 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}
'';
};
};
# -----------------------------------------------------------------------
# Authelia access control — admins only, two_factor; all others denied.
# -----------------------------------------------------------------------
homey.authelia.accessControlRules = [
{ priority = 25; domain = [ "uptime.${domain}" ]; subject = [ "group:admins" ]; policy = "two_factor"; }
{ priority = 26; domain = [ "uptime.${domain}" ]; policy = "deny"; }
];
# -----------------------------------------------------------------------
# Caddy virtual host — forward_auth, admins only
# -----------------------------------------------------------------------
homey.caddy.virtualHosts = [{
subdomain = "uptime";
port = cfg.port;
auth = true;
}];
# -----------------------------------------------------------------------
# Storage directories
# -----------------------------------------------------------------------
homey.storage.upholds = [ "podman-uptime-kuma.service" ];
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;
}];
};
}