diff --git a/db/knex_migrations/2025-12-09-0000-add-system-service-monitor.js b/db/knex_migrations/2025-12-09-0000-add-system-service-monitor.js new file mode 100644 index 000000000..b5a9a51cd --- /dev/null +++ b/db/knex_migrations/2025-12-09-0000-add-system-service-monitor.js @@ -0,0 +1,19 @@ +/** + * @param {import("knex").Knex} knex The Knex.js instance for database interaction. + * @returns {Promise} + */ +exports.up = async (knex) => { + await knex.schema.alterTable("monitor", (table) => { + table.string("system_service_name"); + }); +}; + +/** + * @param {import("knex").Knex} knex The Knex.js instance for database interaction. + * @returns {Promise} + */ +exports.down = async (knex) => { + await knex.schema.alterTable("monitor", (table) => { + table.dropColumn("system_service_name"); + }); +}; diff --git a/server/model/monitor.js b/server/model/monitor.js index eb06c3d2f..4fbc1409c 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -149,6 +149,7 @@ class Monitor extends BeanModel { httpBodyEncoding: this.httpBodyEncoding, jsonPath: this.jsonPath, expectedValue: this.expectedValue, + system_service_name: this.system_service_name, kafkaProducerTopic: this.kafkaProducerTopic, kafkaProducerBrokers: JSON.parse(this.kafkaProducerBrokers), kafkaProducerSsl: this.getKafkaProducerSsl(), diff --git a/server/monitor-types/system-service.js b/server/monitor-types/system-service.js new file mode 100644 index 000000000..62d2a019f --- /dev/null +++ b/server/monitor-types/system-service.js @@ -0,0 +1,114 @@ +const { MonitorType } = require("./monitor-type"); +const { execFile } = require("child_process"); +const process = require("process"); +const { UP } = require("../../src/util"); + +class SystemServiceMonitorType extends MonitorType { + name = "system-service"; + description = "Checks if a system service is running (systemd on Linux, Service Manager on Windows)."; + + /** + * Check the system service status. + * Detects OS and dispatches to the appropriate check method. + * @param {object} monitor The monitor object containing monitor.system_service_name. + * @param {object} heartbeat The heartbeat object to update. + * @returns {Promise} Resolves when check is complete. + */ + async check(monitor, heartbeat) { + if (!monitor.system_service_name) { + throw new Error("Service Name is required."); + } + + if (process.platform === "win32") { + return this.checkWindows(monitor.system_service_name, heartbeat); + } else if (process.platform === "linux") { + return this.checkLinux(monitor.system_service_name, heartbeat); + } else { + throw new Error(`System Service monitoring is not supported on ${process.platform}`); + } + } + + /** + * Linux Check (Systemd) + * @param {string} serviceName The name of the service to check. + * @param {object} heartbeat The heartbeat object. + * @returns {Promise} + */ + async checkLinux(serviceName, heartbeat) { + return new Promise((resolve, reject) => { + // SECURITY: Prevent Argument Injection + // Only allow alphanumeric, dots, dashes, underscores, and @ + if (!serviceName || !/^[a-zA-Z0-9._\-@]+$/.test(serviceName)) { + reject(new Error("Invalid service name. Please use the internal Service Name (no spaces).")); + return; + } + + execFile("systemctl", [ "is-active", serviceName ], { timeout: 5000 }, (error, stdout, stderr) => { + // Combine output and truncate to ~200 chars to prevent DB bloat + let output = (stderr || stdout || "").toString().trim(); + if (output.length > 200) { + output = output.substring(0, 200) + "..."; + } + + if (error) { + reject(new Error(output || `Service '${serviceName}' is not running.`)); + return; + } + + heartbeat.status = UP; + heartbeat.msg = `Service '${serviceName}' is running.`; + resolve(); + }); + }); + } + + /** + * Windows Check (PowerShell) + * @param {string} serviceName The name of the service to check. + * @param {object} heartbeat The heartbeat object. + * @returns {Promise} Resolves on success, rejects on error. + */ + async checkWindows(serviceName, heartbeat) { + return new Promise((resolve, reject) => { + // SECURITY: Validate service name to reduce command-injection risk + if (!/^[A-Za-z0-9._-]+$/.test(serviceName)) { + throw new Error( + "Invalid service name. Only alphanumeric characters and '.', '_', '-' are allowed." + ); + } + + const cmd = "powershell"; + const args = [ + "-NoProfile", + "-NonInteractive", + "-Command", + // Single quotes around the service name + `(Get-Service -Name '${serviceName.replaceAll("'", "''")}').Status` + ]; + + execFile(cmd, args, { timeout: 5000 }, (error, stdout, stderr) => { + let output = (stderr || stdout || "").toString().trim(); + if (output.length > 200) { + output = output.substring(0, 200) + "..."; + } + + if (error || stderr) { + reject(new Error(`Service '${serviceName}' is not running/found.`)); + return; + } + + if (output === "Running") { + heartbeat.status = UP; + heartbeat.msg = `Service '${serviceName}' is running.`; + resolve(); + } else { + reject(new Error(`Service '${serviceName}' is ${output}.`)); + } + }); + }); + } +} + +module.exports = { + SystemServiceMonitorType, +}; diff --git a/server/server.js b/server/server.js index c8f2373fa..ccf24c740 100644 --- a/server/server.js +++ b/server/server.js @@ -901,6 +901,7 @@ let needSetup = false; bean.rabbitmqPassword = monitor.rabbitmqPassword; bean.conditions = JSON.stringify(monitor.conditions); bean.manual_status = monitor.manual_status; + bean.system_service_name = monitor.system_service_name; // ping advanced options bean.ping_numeric = monitor.ping_numeric; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index ba74e2c3c..6ffd6a170 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -125,6 +125,7 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["port"] = new TCPMonitorType(); UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType(); UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType(); + UptimeKumaServer.monitorTypeList["system-service"] = new SystemServiceMonitorType(); UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType(); // Allow all CORS origins (polling) in development @@ -575,6 +576,7 @@ const { GameDigMonitorType } = require("./monitor-types/gamedig"); const { TCPMonitorType } = require("./monitor-types/tcp.js"); const { ManualMonitorType } = require("./monitor-types/manual"); const { RedisMonitorType } = require("./monitor-types/redis"); +const { SystemServiceMonitorType } = require("./monitor-types/system-service"); const { MssqlMonitorType } = require("./monitor-types/mssql"); const Monitor = require("./model/monitor"); diff --git a/src/lang/en.json b/src/lang/en.json index b6cefcefb..c75f38660 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -999,6 +999,13 @@ "useRemoteBrowser": "Use a Remote Browser", "deleteRemoteBrowserMessage": "Are you sure want to delete this Remote Browser for all monitors?", "GrafanaOncallUrl": "Grafana Oncall URL", + "systemService": "System Service", + "systemServiceName": "Service Name", + "systemServiceDescription": "Checks if system service {service_name} is active", + "systemServiceDescriptionLinux": "Checks if Linux systemd service {service_name} is active", + "systemServiceDescriptionWindows": "Checks if Windows Service Manager {service_name} is running", + "systemServiceCommandHint": "Command used: {command}", + "systemServiceExpectedOutput": "Expected Output: \"{0}\"", "Browser Screenshot": "Browser Screenshot", "Command": "Command", "mongodbCommandDescription": "Run a MongoDB command against the database. For information about the available commands check out the {documentation}", diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 2927b9dd2..f72e68423 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -45,11 +45,15 @@ - + - @@ -599,6 +603,52 @@ + +