From 6070493b1b89aaddd32a6b9cdddf66f01fd8aa62 Mon Sep 17 00:00:00 2001 From: juvdiaz Date: Sat, 27 Jun 2026 16:43:51 -0600 Subject: [PATCH] Seed Uptime Kuma monitors --- README.md | 3 + README.md.tmpl | 3 + homelab.yml | 11 ++ infra/rpi-services/README.md | 7 + infra/rpi-services/bootstrap.sh | 32 ++++ infra/rpi-services/seed-uptime-kuma.js | 174 +++++++++++++++++++ infra/rpi-services/uptime-kuma-monitors.json | 94 ++++++++++ jeannie | 2 + 8 files changed, 326 insertions(+) create mode 100755 infra/rpi-services/seed-uptime-kuma.js create mode 100644 infra/rpi-services/uptime-kuma-monitors.json diff --git a/README.md b/README.md index 799b97d..e9658ac 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,9 @@ continue resolving if the Unbound container stops. Managed adlists live in `infra/rpi-services/adlists.txt`; the bootstrap inserts those lists without deleting manually added Pi-hole lists. If `PIHOLE_WEBPASSWORD` is not provided, the bootstrap generates one and keeps it in `/opt/homelab-rpi-services/.env`. +Uptime Kuma monitor seeds live in +`infra/rpi-services/uptime-kuma-monitors.json` and are applied after the first +Kuma user exists. `./jeannie status` and `./jeannie doctor-rpi` test Pi-hole DNS, direct Unbound DNS, public fallback resolver readiness, Uptime Kuma HTTP, and whether Docker is currently using the NVMe path or `/var/lib/docker`. diff --git a/README.md.tmpl b/README.md.tmpl index e302282..d05b37a 100644 --- a/README.md.tmpl +++ b/README.md.tmpl @@ -282,6 +282,9 @@ continue resolving if the Unbound container stops. Managed adlists live in `infra/rpi-services/adlists.txt`; the bootstrap inserts those lists without deleting manually added Pi-hole lists. If `PIHOLE_WEBPASSWORD` is not provided, the bootstrap generates one and keeps it in `/opt/homelab-rpi-services/.env`. +Uptime Kuma monitor seeds live in +`infra/rpi-services/uptime-kuma-monitors.json` and are applied after the first +Kuma user exists. `./{{ main_script }} status` and `./{{ main_script }} doctor-rpi` test Pi-hole DNS, direct Unbound DNS, public fallback resolver readiness, Uptime Kuma HTTP, and whether Docker is currently using the NVMe path or `/var/lib/docker`. diff --git a/homelab.yml b/homelab.yml index 3261775..670b416 100644 --- a/homelab.yml +++ b/homelab.yml @@ -90,6 +90,17 @@ services: pihole_web_port: 8081 uptime_kuma_port: 3001 unbound_container_port: 53 + uptime_kuma_seed_file: infra/rpi-services/uptime-kuma-monitors.json + arr: + host: debian + prowlarr_port: 9696 + radarr_port: 7878 + sonarr_port: 8989 + qbittorrent_port: 8080 + kapowarr_port: 5656 + suwayomi_port: 4567 + maintainerr_port: 6246 + flaresolverr_port: 8191 traefik: host: kubernetes load_balancer_ip: 192.168.100.240 diff --git a/infra/rpi-services/README.md b/infra/rpi-services/README.md index 7bd85a0..90e3912 100644 --- a/infra/rpi-services/README.md +++ b/infra/rpi-services/README.md @@ -6,6 +6,8 @@ Kubernetes cluster: - Pi-hole on DNS port `53` and web port `8081` - Unbound as Pi-hole's first upstream resolver - Uptime Kuma on port `3001` +- Uptime Kuma monitor seeds for public edge, Gitea, Traefik, RPi DNS, Pi-hole, + and Debian-host ARR services Pi-hole also keeps public fallback upstreams in its config, so DNS can continue to resolve through `1.1.1.1` and `9.9.9.9` if the Unbound container stops. @@ -39,3 +41,8 @@ it in `/opt/homelab-rpi-services/.env` for future runs. Managed Pi-hole adlists are stored in `adlists.txt`. The bootstrap inserts them without deleting existing manual Pi-hole lists. + +Managed Uptime Kuma monitors are stored in `uptime-kuma-monitors.json`. The +bootstrap seeds them after the Uptime Kuma database exists. If Uptime Kuma has +not completed initial admin setup yet, the seed step skips cleanly; rerun +`./jeannie rpi-services` after creating the first user. diff --git a/infra/rpi-services/bootstrap.sh b/infra/rpi-services/bootstrap.sh index 1e84f3f..c16d00f 100755 --- a/infra/rpi-services/bootstrap.sh +++ b/infra/rpi-services/bootstrap.sh @@ -207,6 +207,35 @@ PY sudo docker exec "${pihole_container}" pihole -g } +seed_uptime_kuma_monitors() { + local uptime_kuma_container="${UPTIME_KUMA_CONTAINER_NAME:-homelab-uptime-kuma}" + local monitor_file="${INSTALL_DIR}/uptime-kuma-monitors.json" + local seed_script="${INSTALL_DIR}/seed-uptime-kuma.js" + local elapsed=0 + + if [[ ! -s "${monitor_file}" || ! -s "${seed_script}" ]]; then + echo "Uptime Kuma monitor seed files are missing; skipping monitor seed." + return 0 + fi + + until sudo docker exec "${uptime_kuma_container}" test -s /app/data/kuma.db >/dev/null 2>&1; do + if ((elapsed >= 180)); then + echo "Timed out waiting for Uptime Kuma database; skipping monitor seed." >&2 + return 0 + fi + sleep 5 + elapsed=$((elapsed + 5)) + done + + sudo docker cp "${monitor_file}" "${uptime_kuma_container}:/tmp/homelab-kuma-monitors.json" + sudo docker cp "${seed_script}" "${uptime_kuma_container}:/tmp/homelab-seed-kuma.js" + if sudo docker exec -w /app "${uptime_kuma_container}" node /tmp/homelab-seed-kuma.js /tmp/homelab-kuma-monitors.json; then + sudo docker restart "${uptime_kuma_container}" >/dev/null + else + echo "Uptime Kuma monitor seed failed; leaving the running service unchanged." >&2 + fi +} + install_missing_packages ca-certificates curl iptables python3 rsync if ! command -v docker >/dev/null 2>&1; then @@ -223,6 +252,8 @@ fi sudo mkdir -p "${INSTALL_DIR}" "${DATA_DIR}/pihole/etc-pihole" "${DATA_DIR}/pihole/etc-dnsmasq.d" "${DATA_DIR}/uptime-kuma" sudo cp "${SCRIPT_DIR}/docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml" sudo cp "${SCRIPT_DIR}/adlists.txt" "${INSTALL_DIR}/adlists.txt" +sudo cp "${SCRIPT_DIR}/uptime-kuma-monitors.json" "${INSTALL_DIR}/uptime-kuma-monitors.json" +sudo cp "${SCRIPT_DIR}/seed-uptime-kuma.js" "${INSTALL_DIR}/seed-uptime-kuma.js" write_env_file stop_legacy_pihole @@ -231,4 +262,5 @@ cd "${INSTALL_DIR}" sudo docker compose pull sudo docker compose up -d --remove-orphans sync_pihole_adlists +seed_uptime_kuma_monitors sudo docker compose ps diff --git a/infra/rpi-services/seed-uptime-kuma.js b/infra/rpi-services/seed-uptime-kuma.js new file mode 100755 index 0000000..2efb10d --- /dev/null +++ b/infra/rpi-services/seed-uptime-kuma.js @@ -0,0 +1,174 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); + +function loadDatabaseModule() { + for (const moduleName of ["better-sqlite3", "/app/node_modules/better-sqlite3"]) { + try { + return require(moduleName); + } catch (_error) { + // Try the next location used by the upstream Uptime Kuma image. + } + } + throw new Error("Could not load better-sqlite3 from the Uptime Kuma container"); +} + +function nowSql() { + return new Date().toISOString().replace("T", " ").slice(0, 19); +} + +function defaultForColumn(column, monitor, userId) { + const accepted = monitor.accepted_statuscodes || ["200-399"]; + const defaults = { + name: monitor.name, + type: monitor.type, + active: 1, + user_id: userId, + interval: monitor.interval || 60, + retry_interval: monitor.retry_interval || 60, + resend_interval: monitor.resend_interval || 0, + maxretries: monitor.maxretries || 3, + accepted_statuscodes: JSON.stringify(accepted), + url: monitor.url || "", + method: monitor.method || "GET", + hostname: monitor.hostname || "", + port: monitor.port || null, + dns_resolve_type: monitor.dns_resolve_type || "A", + dns_resolve_server: monitor.dns_resolve_server || "1.1.1.1", + upside_down: 0, + weight: monitor.weight || 2000, + maxredirects: monitor.maxredirects || 10, + timeout: monitor.timeout || 48, + expiryNotification: 0, + ignoreTls: monitor.ignoreTls ? 1 : 0, + created_date: nowSql(), + modified_date: nowSql(), + body: "", + headers: "", + basic_auth_user: "", + basic_auth_pass: "", + authMethod: "", + mqtt_success_message: "", + database_connection_string: "", + database_query: "", + packet_size: 56 + }; + + return Object.prototype.hasOwnProperty.call(defaults, column.name) + ? defaults[column.name] + : ""; +} + +function monitorValues(monitor, columns, userId, includeCreatedDate) { + const values = {}; + const supported = new Set(columns.map((column) => column.name)); + + const requested = { + name: monitor.name, + type: monitor.type, + active: 1, + user_id: userId, + interval: monitor.interval || 60, + retry_interval: monitor.retry_interval || 60, + resend_interval: monitor.resend_interval || 0, + maxretries: monitor.maxretries || 3, + accepted_statuscodes: JSON.stringify(monitor.accepted_statuscodes || ["200-399"]), + method: monitor.method || "GET", + url: monitor.url || "", + hostname: monitor.hostname || "", + port: monitor.port || null, + dns_resolve_type: monitor.dns_resolve_type || "A", + dns_resolve_server: monitor.dns_resolve_server || "1.1.1.1", + maxredirects: monitor.maxredirects || 10, + timeout: monitor.timeout || 48, + ignoreTls: monitor.ignoreTls ? 1 : 0, + modified_date: nowSql() + }; + + if (includeCreatedDate) { + requested.created_date = nowSql(); + } + + for (const [key, value] of Object.entries(requested)) { + if (supported.has(key)) { + values[key] = value; + } + } + + for (const column of columns) { + if (column.pk || Object.prototype.hasOwnProperty.call(values, column.name)) { + continue; + } + if (column.notnull && column.dflt_value === null) { + values[column.name] = defaultForColumn(column, monitor, userId); + } + } + + return values; +} + +function upsertMonitor(database, monitor, columns, userId) { + const existing = database + .prepare("select id from monitor where user_id = ? and name = ? limit 1") + .get(userId, monitor.name); + + if (existing) { + const values = monitorValues(monitor, columns, userId, false); + delete values.name; + delete values.user_id; + const assignments = Object.keys(values).map((key) => `"${key}" = @${key}`); + database + .prepare(`update monitor set ${assignments.join(", ")} where id = @id`) + .run({ ...values, id: existing.id }); + console.log(`updated ${monitor.name}`); + return; + } + + const values = monitorValues(monitor, columns, userId, true); + const keys = Object.keys(values); + database + .prepare( + `insert into monitor (${keys.map((key) => `"${key}"`).join(", ")}) values (${keys + .map((key) => `@${key}`) + .join(", ")})` + ) + .run(values); + console.log(`created ${monitor.name}`); +} + +function main() { + const monitorFile = process.argv[2] || "/tmp/homelab-kuma-monitors.json"; + const databasePath = process.argv[3] || "/app/data/kuma.db"; + const monitors = JSON.parse(fs.readFileSync(monitorFile, "utf8")); + const Database = loadDatabaseModule(); + const database = new Database(databasePath); + + const userTable = database + .prepare("select name from sqlite_master where type = 'table' and name = 'user'") + .get(); + if (!userTable) { + console.log("Uptime Kuma user table is missing; skipping monitor seed"); + return; + } + + const user = database.prepare("select id from user order by id limit 1").get(); + if (!user) { + console.log("Uptime Kuma initial setup has not been completed; skipping monitor seed"); + return; + } + + const columns = database.prepare("pragma table_info(monitor)").all(); + if (!columns.length) { + throw new Error("Uptime Kuma monitor table is missing"); + } + + const transaction = database.transaction(() => { + for (const monitor of monitors) { + upsertMonitor(database, monitor, columns, user.id); + } + }); + transaction(); +} + +main(); diff --git a/infra/rpi-services/uptime-kuma-monitors.json b/infra/rpi-services/uptime-kuma-monitors.json new file mode 100644 index 0000000..c2a9829 --- /dev/null +++ b/infra/rpi-services/uptime-kuma-monitors.json @@ -0,0 +1,94 @@ +[ + { + "name": "Public Website", + "type": "http", + "url": "https://lab2025.duckdns.org/", + "accepted_statuscodes": ["200-399"] + }, + { + "name": "Public Gitea", + "type": "http", + "url": "https://lab2025.duckdns.org/git/", + "accepted_statuscodes": ["200-399", "404"] + }, + { + "name": "Traefik LAN", + "type": "http", + "url": "http://192.168.100.240/", + "accepted_statuscodes": ["200-399", "404"] + }, + { + "name": "Gitea LAN", + "type": "http", + "url": "http://192.168.100.73:3000/", + "accepted_statuscodes": ["200-399", "404"] + }, + { + "name": "Pi-hole Web", + "type": "http", + "url": "http://192.168.100.89:8081/admin/", + "accepted_statuscodes": ["200-399", "401", "403"] + }, + { + "name": "RPi DNS TCP 53", + "type": "port", + "hostname": "192.168.100.89", + "port": 53 + }, + { + "name": "RPi DNS UDP 53", + "type": "dns", + "hostname": "cloudflare.com", + "port": 53, + "dns_resolve_type": "A", + "dns_resolve_server": "192.168.100.89" + }, + { + "name": "ARR Prowlarr", + "type": "http", + "url": "http://192.168.100.73:9696/", + "accepted_statuscodes": ["200-399", "401", "403"] + }, + { + "name": "ARR Radarr", + "type": "http", + "url": "http://192.168.100.73:7878/", + "accepted_statuscodes": ["200-399", "401", "403"] + }, + { + "name": "ARR Sonarr", + "type": "http", + "url": "http://192.168.100.73:8989/", + "accepted_statuscodes": ["200-399", "401", "403"] + }, + { + "name": "ARR qBittorrent", + "type": "http", + "url": "http://192.168.100.73:8080/", + "accepted_statuscodes": ["200-399", "401", "403"] + }, + { + "name": "ARR Kapowarr", + "type": "http", + "url": "http://192.168.100.73:5656/", + "accepted_statuscodes": ["200-399", "401", "403"] + }, + { + "name": "ARR Suwayomi", + "type": "http", + "url": "http://192.168.100.73:4567/", + "accepted_statuscodes": ["200-399", "401", "403"] + }, + { + "name": "ARR Maintainerr", + "type": "http", + "url": "http://192.168.100.73:6246/", + "accepted_statuscodes": ["200-399", "401", "403"] + }, + { + "name": "ARR FlareSolverr", + "type": "http", + "url": "http://192.168.100.73:8191/", + "accepted_statuscodes": ["200-399", "401", "403"] + } +] diff --git a/jeannie b/jeannie index 8bb16f7..43c8754 100755 --- a/jeannie +++ b/jeannie @@ -2265,6 +2265,8 @@ deploy_rpi_services() { scp -i "${rpi_key}" -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new \ "${source_dir}/docker-compose.yml" \ "${source_dir}/adlists.txt" \ + "${source_dir}/uptime-kuma-monitors.json" \ + "${source_dir}/seed-uptime-kuma.js" \ "${source_dir}/bootstrap.sh" \ "${rpi_user}@${rpi_host}:/tmp/homelab-rpi-services/"