feat: system service (aka systemd/ windows service) monitor (#6488)

This commit is contained in:
Frank Elsinga 2026-01-02 17:42:55 +01:00 committed by GitHub
commit 6a700cb71b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 308 additions and 3 deletions

View File

@ -0,0 +1,19 @@
/**
* @param {import("knex").Knex} knex The Knex.js instance for database interaction.
* @returns {Promise<void>}
*/
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<void>}
*/
exports.down = async (knex) => {
await knex.schema.alterTable("monitor", (table) => {
table.dropColumn("system_service_name");
});
};

View File

@ -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(),

View File

@ -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<void>} 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<void>}
*/
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<void>} 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,
};

View File

@ -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;

View File

@ -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");

View File

@ -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}",

View File

@ -45,11 +45,15 @@
<option value="docker">
{{ $t("Docker Container") }}
</option>
<option
v-if="['linux', 'win32'].includes($root.info.runtime.platform) && !$root.info.isContainer"
value="system-service"
>
{{ $t("System Service") }}
</option>
<option value="real-browser">
HTTP(s) - Browser Engine (Chrome/Chromium) (Beta)
</option>
<option value="websocket-upgrade">
Websocket Upgrade
</option>
@ -599,6 +603,52 @@
</div>
</template>
<template v-if="monitor.type === 'system-service'">
<div class="my-3">
<label for="system-service-name" class="form-label">{{ $t("Service Name") }}</label>
<input id="system-service-name" v-model="monitor.system_service_name" type="text" class="form-control" required placeholder="nginx">
<div class="form-text">
<template v-if="$root.info.runtime.platform === 'linux'">
{{ $t("systemServiceDescriptionLinux", {service_name: monitor.system_service_name || 'nginx'}) }}
</template>
<template v-else-if="$root.info.runtime.platform === 'win32'">
{{ $t("systemServiceDescriptionWindows", {service_name: monitor.system_service_name || 'Dnscache'}) }}
</template>
<template v-else>
{{ $t("systemServiceDescription", {service_name: monitor.system_service_name || 'nginx'}) }}
</template>
<template v-if="!monitor.system_service_name || /^[a-zA-Z0-9_\-\.\@\ ]+$/.test(monitor.system_service_name)">
<div v-if="$root.info.runtime.platform === 'linux'" class="mt-2">
<div>
<i18n-t keypath="systemServiceCommandHint" tag="span">
<template #command>
<code>systemctl is-active {{ monitor.system_service_name || 'nginx' }}</code>
</template>
</i18n-t>
</div>
<div class="text-secondary small">
{{ $t("systemServiceExpectedOutput", ["active"]) }}
</div>
</div>
<div v-else-if="$root.info.runtime.platform === 'win32'" class="mt-2">
<div>
<i18n-t keypath="systemServiceCommandHint" tag="span">
<template #command>
<code>(Get-Service -Name '{{ (monitor.system_service_name || 'Dnscache').replaceAll("'", "''") }}').Status</code>
</template>
</i18n-t>
</div>
<div class="text-secondary small">
{{ $t("systemServiceExpectedOutput", ["Running"]) }}
</div>
</div>
</template>
</div>
</div>
</template>
<template v-if="monitor.type === 'mysql'">
<div class="my-3">
<label for="mysql-password" class="form-label">{{ $t("Password") }}</label>
@ -1333,7 +1383,8 @@ const monitorDefaults = {
rabbitmqNodes: [],
rabbitmqUsername: "",
rabbitmqPassword: "",
conditions: []
conditions: [],
system_service_name: "",
};
export default {
@ -1413,6 +1464,9 @@ export default {
if (this.monitor.hostname) {
return this.monitor.hostname;
}
if (this.monitor.system_service_name) {
return this.monitor.system_service_name;
}
if (this.monitor.url) {
if (this.monitor.url !== "http://" && this.monitor.url !== "https://") {
// Ensure monitor without a URL is not affected by invisible URL.

View File

@ -0,0 +1,107 @@
const { describe, test, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert");
const { SystemServiceMonitorType } = require("../../server/monitor-types/system-service");
const { DOWN, UP } = require("../../src/util");
const process = require("process");
const { execSync } = require("node:child_process");
/**
* Check if the test should be skipped.
* @returns {boolean} True if the test should be skipped
*/
function shouldSkip() {
if (process.platform === "win32") {
return false;
}
if (process.platform !== "linux") {
return true;
}
// We currently only support systemd as an init system on linux
// -> Check if PID 1 is systemd (or init which maps to systemd)
try {
const pid1Comm = execSync("ps -p 1 -o comm=", { encoding: "utf-8" }).trim();
return ![ "systemd", "init" ].includes(pid1Comm);
} catch (e) {
return true;
}
}
describe("SystemServiceMonitorType", { skip: shouldSkip() }, () => {
let monitorType;
let heartbeat;
let originalPlatform;
beforeEach(() => {
monitorType = new SystemServiceMonitorType();
heartbeat = {
status: DOWN,
msg: "",
};
originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
});
afterEach(() => {
if (originalPlatform) {
Object.defineProperty(process, "platform", originalPlatform);
}
});
test("check() returns UP for a running service", async () => {
// Windows: 'Dnscache' is always running.
// Linux: 'dbus' or 'cron' are standard services.
const serviceName = process.platform === "win32" ? "Dnscache" : "dbus";
const monitor = {
system_service_name: serviceName,
};
await monitorType.check(monitor, heartbeat);
assert.strictEqual(heartbeat.status, UP);
assert.ok(heartbeat.msg.includes("is running"));
});
test("check() returns DOWN for a stopped service", async () => {
const monitor = {
system_service_name: "non-existent-service-12345",
};
// Query a non-existent service to force an error/down state.
// We pass the promise directly to assert.rejects, avoiding unnecessary async wrappers.
await assert.rejects(monitorType.check(monitor, heartbeat));
assert.strictEqual(heartbeat.status, DOWN);
});
test("check() fails gracefully with invalid characters", async () => {
// Mock platform for validation logic test
Object.defineProperty(process, "platform", {
value: "linux",
configurable: true,
});
const monitor = {
system_service_name: "invalid&service;name",
};
// Expected validation error
await assert.rejects(monitorType.check(monitor, heartbeat));
assert.strictEqual(heartbeat.status, DOWN);
});
test("check() throws on unsupported platforms", async () => {
// This test mocks the platform, so it can run anywhere.
Object.defineProperty(process, "platform", {
value: "darwin",
configurable: true,
});
const monitor = {
system_service_name: "test-service",
};
await assert.rejects(monitorType.check(monitor, heartbeat), /not supported/);
});
});