From 8e8d05f78bb5a63a7298b4fa992631ba4c1eaffc Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Thu, 8 Jan 2026 11:54:16 +0100 Subject: [PATCH 01/10] Fix shield image links in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 531560fc9..3436c8dba 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Uptime Kuma is an easy-to-use self-hosted monitoring tool. - + [![GitHub Sponsors](https://img.shields.io/github/sponsors/louislam?label=GitHub%20Sponsors)](https://github.com/sponsors/louislam) Translation status From 072a661ff86f1d8907e40839738567686f811644 Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Thu, 8 Jan 2026 20:15:02 +0100 Subject: [PATCH 02/10] fix: webhook method is undefined on older notification providers (#6650) --- server/notification-providers/webhook.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/notification-providers/webhook.js b/server/notification-providers/webhook.js index c547ef4a6..88c532b04 100644 --- a/server/notification-providers/webhook.js +++ b/server/notification-providers/webhook.js @@ -12,7 +12,7 @@ class Webhook extends NotificationProvider { const okMsg = "Sent Successfully."; try { - const httpMethod = notification.httpMethod.toLowerCase() || "post"; + const httpMethod = notification.httpMethod?.toLowerCase() || "post"; let data = { heartbeat: heartbeatJSON, From 71670f3462e838f9e371ef28d23c43c27ea0c8e8 Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Thu, 8 Jan 2026 21:14:09 +0100 Subject: [PATCH 03/10] chore: improve misc i18n things (#6645) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/components/notifications/ClickSendSMS.vue | 12 ++++++---- src/components/notifications/PromoSMS.vue | 12 ++++++---- src/components/notifications/SMSC.vue | 12 ++++++---- src/components/notifications/SMSManager.vue | 23 +++++++++++++------ src/components/settings/General.vue | 13 ++++++----- src/lang/en.json | 14 +++++------ src/pages/EditMonitor.vue | 4 ++-- 7 files changed, 55 insertions(+), 35 deletions(-) diff --git a/src/components/notifications/ClickSendSMS.vue b/src/components/notifications/ClickSendSMS.vue index b0aec48eb..b4316ca3d 100644 --- a/src/components/notifications/ClickSendSMS.vue +++ b/src/components/notifications/ClickSendSMS.vue @@ -9,10 +9,14 @@
-
- {{ $t("checkPrice", ["clicksendsms"]) }} - https://clicksend.com/us/pricing -
+ + + +
diff --git a/src/components/notifications/PromoSMS.vue b/src/components/notifications/PromoSMS.vue index 15ed241b7..84a18200e 100644 --- a/src/components/notifications/PromoSMS.vue +++ b/src/components/notifications/PromoSMS.vue @@ -13,10 +13,14 @@ -
- {{ $t("checkPrice", [$t("promosms")]) }} - https://promosms.com/cennik/ -
+ + + +
diff --git a/src/components/notifications/SMSC.vue b/src/components/notifications/SMSC.vue index 5f885a494..369e179a7 100644 --- a/src/components/notifications/SMSC.vue +++ b/src/components/notifications/SMSC.vue @@ -11,10 +11,14 @@
-
- {{ $t("checkPrice", ['СМСЦ']) }} - https://smsc.kz/tariffs/ -
+ + + +
diff --git a/src/components/notifications/SMSManager.vue b/src/components/notifications/SMSManager.vue index 00be2fa7f..185390e93 100644 --- a/src/components/notifications/SMSManager.vue +++ b/src/components/notifications/SMSManager.vue @@ -9,9 +9,14 @@
-
- {{ $t("You can divide numbers with") }} , {{ $t("or") }} ; -
+ + + +
@@ -23,9 +28,13 @@
-
- {{ $t("checkPrice", [$t("SMSManager")]) }} - {{ $t("here") }} -
+ + + +
diff --git a/src/components/settings/General.vue b/src/components/settings/General.vue index 487c3ba3a..ea65521eb 100644 --- a/src/components/settings/General.vue +++ b/src/components/settings/General.vue @@ -142,12 +142,13 @@ v-model="settings.steamAPIKey" autocomplete="new-password" /> -
- {{ $t("steamApiKeyDescription") }} - - https://steamcommunity.com/dev - -
+ + + diff --git a/src/lang/en.json b/src/lang/en.json index 14bf701b4..ecbd3fd22 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -259,14 +259,14 @@ "Body": "Body", "Headers": "Headers", "PushUrl": "Push URL", - "HeadersInvalidFormat": "The request headers are not valid JSON: ", - "BodyInvalidFormat": "The request body is not valid JSON: ", + "HeadersInvalidFormatBecause": "The request headers are not valid JSON because {error}", + "BodyInvalidFormatBecause": "The request body is not valid JSON because {error}", "Monitor History": "Monitor History", "clearDataOlderThan": "Keep monitor history data for {0} days.", "PasswordsDoNotMatch": "Passwords do not match.", "records": "records", "One record": "One record", - "steamApiKeyDescription": "For monitoring a Steam Game Server you need a Steam Web-API key. You can register your API key here: ", + "steamApiKeyDescriptionAt": "For monitoring a Steam Game Server you need a Steam Web-API key. You can register your API key at {url}", "Current User": "Current User", "topic": "Topic", "topicExplanation": "MQTT topic to monitor", @@ -703,7 +703,7 @@ "SMS Type": "SMS Type", "octopushTypePremium": "Premium (Fast - recommended for alerting)", "octopushTypeLowCost": "Low Cost (Slow - sometimes blocked by operator)", - "checkPrice": "Check {0} prices:", + "checkPriceAt": "Check {service} prices at {url}", "apiCredentials": "API credentials", "octopushLegacyHint": "Do you use the legacy version of Octopush (2011-2020) or the new version?", "Check octopush prices": "Check octopush prices {0}.", @@ -725,9 +725,9 @@ "Lowcost": "Lowcost", "high": "high", "SendKey": "SendKey", - "SMSManager API Docs": "SMSManager API Docs ", + "SMSManager API Docs": "SMSManager API Docs", "Gateway Type": "Gateway Type", - "You can divide numbers with": "You can divide numbers with", + "You can divide numbers with commas or semicolons": "You can divide numbers with {comma} or {semicolon}", "Base URL": "Base URL", "goAlertInfo": "GoAlert is a An open source application for on-call scheduling, automated escalations and notifications (like SMS or voice calls). Automatically engage the right person, the right way, and at the right time! {0}", "goAlertIntegrationKeyInfo": "Get generic API integration key for the service in this format \"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee\" usually the value of token parameter of copied URL.", @@ -1292,10 +1292,8 @@ "Sort by certificate expiry": "Sort by certificate expiry", "Splunk Rest URL": "Splunk Rest URL", "Severity": "Severity", - "SMSManager": "SMSManager", "Message Format": "Message Format", "smscTranslit": "smscTranslit", - "promosms": "promosms", "Region": "Region", "PushDeer Server URL": "PushDeer Server URL", "To Number": "To Number", diff --git a/src/pages/EditMonitor.vue b/src/pages/EditMonitor.vue index 8aae28a90..9a0a6fa54 100644 --- a/src/pages/EditMonitor.vue +++ b/src/pages/EditMonitor.vue @@ -2068,7 +2068,7 @@ message HealthCheckResponse { try { JSON.parse(this.monitor.body); } catch (err) { - toast.error(this.$t("BodyInvalidFormat") + err.message); + toast.error(this.$t("BodyInvalidFormatBecause", {error: err.message})); return false; } } @@ -2076,7 +2076,7 @@ message HealthCheckResponse { try { JSON.parse(this.monitor.headers); } catch (err) { - toast.error(this.$t("HeadersInvalidFormat") + err.message); + toast.error(this.$t("HeadersInvalidFormatBecause", {error: err.message})); return false; } } From a052ae1d6a63152141233972f4518cc85d8d5cc4 Mon Sep 17 00:00:00 2001 From: Yasindu Dasanga De Mel <89267432+Yasindu20@users.noreply.github.com> Date: Fri, 9 Jan 2026 02:35:23 +0530 Subject: [PATCH 04/10] feat: add Halo PSA webhook notification provider (#6560) Co-authored-by: Frank Elsinga --- server/notification-providers/HaloPSA.js | 83 ++++++++++++++++++++++++ server/notification.js | 2 + src/components/NotificationDialog.vue | 7 ++ src/components/notifications/HaloPSA.vue | 74 +++++++++++++++++++++ src/components/notifications/index.js | 2 + src/lang/en.json | 12 ++++ 6 files changed, 180 insertions(+) create mode 100644 server/notification-providers/HaloPSA.js create mode 100644 src/components/notifications/HaloPSA.vue diff --git a/server/notification-providers/HaloPSA.js b/server/notification-providers/HaloPSA.js new file mode 100644 index 000000000..20ad4205a --- /dev/null +++ b/server/notification-providers/HaloPSA.js @@ -0,0 +1,83 @@ +const NotificationProvider = require("./notification-provider"); +const axios = require("axios"); + +/** + * Halo PSA notification provider implementation + */ +class HaloPSA extends NotificationProvider { + /** + * Provider name used in registration + * @type {string} + */ + name = "HaloPSA"; + + /** + * Send notification to Halo PSA webhook + * @param {object} notification - Notification configuration + * @param {string} msg - Message content + * @param {object|null} monitorJSON - Monitor configuration (null for cert expiry) + * @param {object|null} heartbeatJSON - Heartbeat data + * @returns {Promise} Success message + */ + async send(notification, msg, monitorJSON = null, heartbeatJSON = null) { + const okMsg = "Sent successfully."; + + try { + // Determine status based on heartbeat + let status = "UNKNOWN"; + if (heartbeatJSON?.status === 1) { + status = "UP"; + } else if (heartbeatJSON?.status === 0) { + status = "DOWN"; + } else if (monitorJSON == null && heartbeatJSON != null) { + status = "NOTIFICATION"; + } + + /** + * Payload structure expected by Halo PSA webhook + * @type {object} + */ + const payload = { + title: "Uptime Kuma Alert", + status: status, + monitor: monitorJSON?.name || "No Monitor", + message: msg, + timestamp: new Date().toISOString(), + uptime_kuma_version: process.env.npm_package_version || "unknown" + }; + + // Send POST request to Halo PSA webhook + let config = { + headers: { + "Content-Type": "application/json", + } + }; + + if (notification.haloUsername && notification.haloPassword) { + const data = notification.haloUsername + ":" + notification.haloPassword; + const base64data = Buffer.from(data).toString("base64"); + + config.headers.Authorization = `Basic ${base64data}`; + } + + config = this.getAxiosConfigWithProxy(config); + + const result = await axios.post( + notification.halowebhookurl, + payload, + config + ); + + // Check for successful HTTP response + if (result.status === 200 || result.status === 201 || result.status === 204) { + return okMsg; + } + + throw new Error(`Received unexpected status code ${result.status} from notification provider HaloPSA`); + } catch (error) { + this.throwGeneralAxiosError(error); + } + } +} + +module.exports = HaloPSA; diff --git a/server/notification.js b/server/notification.js index ee7917bd9..e16564756 100644 --- a/server/notification.js +++ b/server/notification.js @@ -84,6 +84,7 @@ const SpugPush = require("./notification-providers/spugpush"); const SMSIR = require("./notification-providers/smsir"); const { commandExists } = require("./util-server"); const Webpush = require("./notification-providers/Webpush"); +const HaloPSA = require("./notification-providers/HaloPSA"); class Notification { providerList = {}; @@ -183,6 +184,7 @@ class Notification { new SMSIR(), new SendGrid(), new Webpush(), + new HaloPSA(), ]; for (let item of list) { if (!item.name) { diff --git a/src/components/NotificationDialog.vue b/src/components/NotificationDialog.vue index c0f2ed4ff..d15f67ad5 100644 --- a/src/components/NotificationDialog.vue +++ b/src/components/NotificationDialog.vue @@ -144,6 +144,13 @@ export default { "Bitrix24": "Bitrix24", "discord": "Discord", "GoogleChat": "Google Chat (Google Workspace)", + "gorush": "Gorush", + "gotify": "Gotify", + "GrafanaOncall": "Grafana Oncall", + "HaloPSA": "Halo PSA", + "HeiiOnCall": "Heii On-Call", + "HomeAssistant": "Home Assistant", + "Keep": "Keep", "Kook": "Kook", "line": "LINE Messenger", "matrix": "Matrix", diff --git a/src/components/notifications/HaloPSA.vue b/src/components/notifications/HaloPSA.vue new file mode 100644 index 000000000..368134dd6 --- /dev/null +++ b/src/components/notifications/HaloPSA.vue @@ -0,0 +1,74 @@ + + + diff --git a/src/components/notifications/index.js b/src/components/notifications/index.js index 3ffd770cc..f32798b57 100644 --- a/src/components/notifications/index.js +++ b/src/components/notifications/index.js @@ -80,6 +80,7 @@ import YZJ from "./YZJ.vue"; import SMSPlanet from "./SMSPlanet.vue"; import SMSIR from "./SMSIR.vue"; import Webpush from "./Webpush.vue"; +import HaloPSA from "./HaloPSA.vue"; import Resend from "./Resend.vue"; /** @@ -170,6 +171,7 @@ const NotificationFormList = { "YZJ": YZJ, "SMSPlanet": SMSPlanet, "Webpush": Webpush, + "HaloPSA": HaloPSA, }; export default NotificationFormList; diff --git a/src/lang/en.json b/src/lang/en.json index ecbd3fd22..09a99998d 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -1270,6 +1270,13 @@ "domain_expiry_unsupported_unsupported_tld_no_rdap_endpoint": "Domain expiry monitoring is not available for \".{publicSuffix}\" because no RDAP service is listed by IANA", "minimumIntervalWarning": "Intervals below 20 seconds may result in poor performance.", "lowIntervalWarning": "Are you sure want to set the interval value below 20 seconds? Performance may be degraded, particularly if there are a large number of monitors.", + "Halo PSA": "Halo PSA", + "Halo PSA Webhook URL": "Halo PSA Webhook URL", + "halopsa_webhook_url_desc": "Enter the webhook URL from your Halo PSA Integration Runbook (Configuration > Integrations > Custom Integrations > Integration Runbooks). Select 'Can only be started from Halo and from a public endpoint' when creating the webhook.", + "username": "Username", + "password": "Password", + "halopsa_username_desc": "Username for authenticating with Halo PSA webhook", + "halopsa_password_desc": "Password for authenticating with Halo PSA webhook", "imageResetConfirmation": "Image reset to default", "screenshot of the website": "Screenshot of the website", "Basic checkbox toggle button group": "Basic checkbox toggle button group", @@ -1284,6 +1291,11 @@ "Uptime Kuma": "Uptime Kuma", "maxPing": "Max Ping", "minPing": "Min Ping", + "Setup Instructions": "Setup Instructions", + "halopsa_setup_step1": "Create an Integration Runbook in HaloPSA (Configuration → Integrations → Integration Runbooks)", + "halopsa_setup_step2": "Configure runbook actions to process alerts (e.g., Create Ticket)", + "halopsa_setup_step3": "Copy the Webhook URL and paste it above text field", + "halopsa_setup_step4": "Choose basic Authentication and create username and password. And type or paste those username and password above test fileds", "Clear current filters": "Clear current filters", "Sort options": "Sort options", "Sort by status": "Sort by status", From 19e6e95b96e000774fc26dc6e8ba54e50a5e6a02 Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Thu, 8 Jan 2026 22:38:10 +0100 Subject: [PATCH 05/10] chore: make the monitors consistently log using this.name where appropriate (#6651) --- server/monitor-types/mqtt.js | 8 +-- server/monitor-types/postgres.js | 2 +- .../real-browser-monitor-type.js | 68 +++++++++++-------- server/monitor-types/snmp.js | 2 +- 4 files changed, 46 insertions(+), 34 deletions(-) diff --git a/server/monitor-types/mqtt.js b/server/monitor-types/mqtt.js index f1c1ad23c..f6b3cf8a8 100644 --- a/server/monitor-types/mqtt.js +++ b/server/monitor-types/mqtt.js @@ -144,7 +144,7 @@ class MqttMonitorType extends MonitorType { } const timeoutID = setTimeout(() => { - log.debug("mqtt", "MQTT timeout triggered"); + log.debug(this.name, "MQTT timeout triggered"); client.end(); reject(new Error("Timeout, Message not received")); }, interval * 1000 * 0.8); @@ -159,7 +159,7 @@ class MqttMonitorType extends MonitorType { } } - log.debug("mqtt", `MQTT connecting to ${mqttUrl}`); + log.debug(this.name, `MQTT connecting to ${mqttUrl}`); let client = mqtt.connect(mqttUrl, { username, @@ -168,11 +168,11 @@ class MqttMonitorType extends MonitorType { }); client.on("connect", () => { - log.debug("mqtt", "MQTT connected"); + log.debug(this.name, "MQTT connected"); try { client.subscribe(topic, () => { - log.debug("mqtt", "MQTT subscribed to topic"); + log.debug(this.name, "MQTT subscribed to topic"); }); } catch (e) { client.end(); diff --git a/server/monitor-types/postgres.js b/server/monitor-types/postgres.js index 6d6940191..fb6cc9b0d 100644 --- a/server/monitor-types/postgres.js +++ b/server/monitor-types/postgres.js @@ -49,7 +49,7 @@ class PostgresMonitorType extends MonitorType { const client = new Client(config); client.on("error", (error) => { - log.debug("postgres", "Error caught in the error event handler."); + log.debug(this.name, "Error caught in the error event handler."); reject(error); }); diff --git a/server/monitor-types/real-browser-monitor-type.js b/server/monitor-types/real-browser-monitor-type.js index 07b6c8aac..12eb50de5 100644 --- a/server/monitor-types/real-browser-monitor-type.js +++ b/server/monitor-types/real-browser-monitor-type.js @@ -63,7 +63,6 @@ if (process.platform === "win32") { * @returns {Promise} The executable is allowed? */ async function isAllowedChromeExecutable(executablePath) { - console.log(config.args); if (config.args["allow-all-chrome-exec"] || process.env.UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC === "1") { return true; } @@ -102,7 +101,7 @@ async function getBrowser() { */ async function getRemoteBrowser(remoteBrowserID, userId) { let remoteBrowser = await RemoteBrowser.get(remoteBrowserID, userId); - log.debug("MONITOR", `Using remote browser: ${remoteBrowser.name} (${remoteBrowser.id})`); + log.debug("chromium", `Using remote browser: ${remoteBrowser.name} (${remoteBrowser.id})`); browser = await chromium.connect(remoteBrowser.url); return browser; } @@ -120,31 +119,7 @@ async function prepareChromeExecutable(executablePath) { } else if (!executablePath) { if (process.env.UPTIME_KUMA_IS_CONTAINER) { executablePath = "/usr/bin/chromium"; - - // Install chromium in container via apt install - if (! await commandExists(executablePath)) { - await new Promise((resolve, reject) => { - log.info("Chromium", "Installing Chromium..."); - let child = childProcess.exec("apt update && apt --yes --no-install-recommends install chromium fonts-indic fonts-noto fonts-noto-cjk"); - - // On exit - child.on("exit", (code) => { - log.info("Chromium", "apt install chromium exited with code " + code); - - if (code === 0) { - log.info("Chromium", "Installed Chromium"); - let version = childProcess.execSync(executablePath + " --version").toString("utf8"); - log.info("Chromium", "Chromium version: " + version); - resolve(); - } else if (code === 100) { - reject(new Error("Installing Chromium, please wait...")); - } else { - reject(new Error("apt install chromium failed with code " + code)); - } - }); - }); - } - + await installChromiumViaApt(executablePath); } else { executablePath = await findChrome(allowedList); } @@ -158,6 +133,43 @@ async function prepareChromeExecutable(executablePath) { return executablePath; } +/** + * Installs Chromium and required font packages via APT if the Chromium executable + * is not already available. + * @async + * @param {string} executablePath - Path to the Chromium executable used to check + * whether Chromium is available and to query its version after installation. + * @returns {Promise} Resolves when Chromium is successfully installed or + * when no installation is required. + * @throws {Error} If the APT installation fails or exits with an unexpected + * exit code. + */ +async function installChromiumViaApt(executablePath) { + if (await commandExists(executablePath)) { + return + } + await new Promise((resolve, reject) => { + log.info("chromium", "Installing Chromium..."); + let child = childProcess.exec("apt update && apt --yes --no-install-recommends install chromium fonts-indic fonts-noto fonts-noto-cjk"); + + // On exit + child.on("exit", (code) => { + log.info("chromium", "apt install chromium exited with code " + code); + + if (code === 0) { + log.info("chromium", "Installed Chromium"); + let version = childProcess.execSync(executablePath + " --version").toString("utf8"); + log.info("chromium", "Chromium version: " + version); + resolve(); + } else if (code === 100) { + reject(new Error("Installing Chromium, please wait...")); + } else { + reject(new Error("apt install chromium failed with code " + code)); + } + }); + }); +} + /** * Find the chrome executable * @param {string[]} executables Executables to search through @@ -201,7 +213,7 @@ async function testChrome(executablePath) { try { executablePath = await prepareChromeExecutable(executablePath); - log.info("Chromium", "Testing Chromium executable: " + executablePath); + log.info("chromium", "Testing Chromium executable: " + executablePath); const browser = await chromium.launch({ executablePath, diff --git a/server/monitor-types/snmp.js b/server/monitor-types/snmp.js index a1760fa3d..e6ba94cca 100644 --- a/server/monitor-types/snmp.js +++ b/server/monitor-types/snmp.js @@ -29,7 +29,7 @@ class SNMPMonitorType extends MonitorType { error ? reject(error) : resolve(varbinds); }); }); - log.debug("monitor", `SNMP: Received varbinds (Type: ${snmp.ObjectType[varbinds[0].type]} Value: ${varbinds[0].value})`); + log.debug(this.name, `SNMP: Received varbinds (Type: ${snmp.ObjectType[varbinds[0].type]} Value: ${varbinds[0].value})`); if (varbinds.length === 0) { throw new Error(`No varbinds returned from SNMP session (OID: ${monitor.snmpOid})`); From a44cfd12f7ab7c9db26cd976c49716cbbec0ec71 Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Thu, 8 Jan 2026 23:15:13 +0100 Subject: [PATCH 06/10] move steam --- server/model/monitor.js | 42 +-------------------- server/monitor-types/steam.js | 71 +++++++++++++++++++++++++++++++++++ server/uptime-kuma-server.js | 2 + 3 files changed, 74 insertions(+), 41 deletions(-) create mode 100644 server/monitor-types/steam.js diff --git a/server/model/monitor.js b/server/model/monitor.js index 7ed86ca61..6e567b51d 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -677,47 +677,7 @@ class Monitor extends BeanModel { } } else if (this.type === "steam") { - const steamApiUrl = "https://api.steampowered.com/IGameServersService/GetServerList/v1/"; - const steamAPIKey = await setting("steamAPIKey"); - const filter = `addr\\${this.hostname}:${this.port}`; - - if (!steamAPIKey) { - throw new Error("Steam API Key not found"); - } - - let res = await axios.get(steamApiUrl, { - timeout: this.timeout * 1000, - headers: { - "Accept": "*/*", - }, - httpsAgent: new https.Agent({ - maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) - rejectUnauthorized: !this.getIgnoreTls(), - secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, - }), - httpAgent: new http.Agent({ - maxCachedSessions: 0, - }), - maxRedirects: this.maxredirects, - validateStatus: (status) => { - return checkStatusCode(status, this.getAcceptedStatuscodes()); - }, - params: { - filter: filter, - key: steamAPIKey, - } - }); - - if (res.data.response && res.data.response.servers && res.data.response.servers.length > 0) { - bean.status = UP; - bean.msg = res.data.response.servers[0].name; - - try { - bean.ping = await ping(this.hostname, PING_COUNT_DEFAULT, "", true, this.packetSize, PING_GLOBAL_TIMEOUT_DEFAULT, PING_PER_REQUEST_TIMEOUT_DEFAULT); - } catch (_) { } - } else { - throw new Error("Server not found on Steam"); - } + } else if (this.type === "docker") { log.debug("monitor", `[${this.name}] Prepare Options for Axios`); diff --git a/server/monitor-types/steam.js b/server/monitor-types/steam.js new file mode 100644 index 000000000..b1cc86aa9 --- /dev/null +++ b/server/monitor-types/steam.js @@ -0,0 +1,71 @@ +const { MonitorType } = require("./monitor-type"); +const { UP, PING_COUNT_DEFAULT, PING_GLOBAL_TIMEOUT_DEFAULT, PING_PER_REQUEST_TIMEOUT_DEFAULT } = require("../../src/util"); +const { ping, checkStatusCode, setting } = require("../util-server"); +const axios = require("axios"); +const https = require("https"); +const http = require("http"); +const crypto = require("crypto"); + +class SteamMonitorType extends MonitorType { + name = "steam"; + + /** + * @inheritdoc + */ + async check(monitor, heartbeat, _server) { + const res = await this.getServerList(monitor); + if (res.data.response && res.data.response.servers && res.data.response.servers.length > 0) { + heartbeat.status = UP; + heartbeat.msg = res.data.response.servers[0].name; + + try { + heartbeat.ping = await ping(monitor.hostname, PING_COUNT_DEFAULT, "", true, monitor.packetSize, PING_GLOBAL_TIMEOUT_DEFAULT, PING_PER_REQUEST_TIMEOUT_DEFAULT); + } catch (_) { } + } else { + throw new Error("Server not found on Steam"); + } + } + + /** + * Get server list from Steam API + * @param {Monitor} monitor Monitor object + * @returns {Promise} Axios response object containing server list data + * @throws {Error} If Steam API Key is not configured + */ + async getServerList(monitor) { + const steamApiUrl = "https://api.steampowered.com/IGameServersService/GetServerList/v1/"; + const steamAPIKey = await setting("steamAPIKey"); + const filter = `addr\\${monitor.hostname}:${monitor.port}`; + + if (!steamAPIKey) { + throw new Error("Steam API Key not found"); + } + const options = { + timeout: monitor.timeout * 1000, + headers: { + "Accept": "*/*", + }, + httpsAgent: new https.Agent({ + maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) + rejectUnauthorized: !monitor.ignoreTls, + secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, + }), + httpAgent: new http.Agent({ + maxCachedSessions: 0, + }), + maxRedirects: monitor.maxredirects, + validateStatus: (status) => { + return checkStatusCode(status, monitor.getAcceptedStatuscodes()); + }, + params: { + filter: filter, + key: steamAPIKey, + } + }; + return await axios.get(steamApiUrl, options); + } +} + +module.exports = { + SteamMonitorType, +}; diff --git a/server/uptime-kuma-server.js b/server/uptime-kuma-server.js index 5739d268e..8f8fed45b 100644 --- a/server/uptime-kuma-server.js +++ b/server/uptime-kuma-server.js @@ -113,6 +113,7 @@ class UptimeKumaServer { UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing(); UptimeKumaServer.monitorTypeList["websocket-upgrade"] = new WebSocketMonitorType(); UptimeKumaServer.monitorTypeList["dns"] = new DnsMonitorType(); + UptimeKumaServer.monitorTypeList["steam"] = new SteamMonitorType(); UptimeKumaServer.monitorTypeList["postgres"] = new PostgresMonitorType(); UptimeKumaServer.monitorTypeList["mqtt"] = new MqttMonitorType(); UptimeKumaServer.monitorTypeList["smtp"] = new SMTPMonitorType(); @@ -566,6 +567,7 @@ const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor const { TailscalePing } = require("./monitor-types/tailscale-ping"); const { WebSocketMonitorType } = require("./monitor-types/websocket-upgrade"); const { DnsMonitorType } = require("./monitor-types/dns"); +const { SteamMonitorType } = require("./monitor-types/steam"); const { PostgresMonitorType } = require("./monitor-types/postgres"); const { MqttMonitorType } = require("./monitor-types/mqtt"); const { SMTPMonitorType } = require("./monitor-types/smtp"); From 31b5c84e7b461764d1d5af2153d15b00da56dc91 Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Thu, 8 Jan 2026 23:29:24 +0100 Subject: [PATCH 07/10] tmp --- server/monitor-types/steam.js | 4 +- test/backend-test/monitors/test-steam.js | 118 +++++++++++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) create mode 100644 test/backend-test/monitors/test-steam.js diff --git a/server/monitor-types/steam.js b/server/monitor-types/steam.js index b1cc86aa9..eff5f58e6 100644 --- a/server/monitor-types/steam.js +++ b/server/monitor-types/steam.js @@ -8,6 +8,7 @@ const crypto = require("crypto"); class SteamMonitorType extends MonitorType { name = "steam"; + steamApiUrl = "https://api.steampowered.com/IGameServersService/GetServerList/v1/"; /** * @inheritdoc @@ -33,7 +34,6 @@ class SteamMonitorType extends MonitorType { * @throws {Error} If Steam API Key is not configured */ async getServerList(monitor) { - const steamApiUrl = "https://api.steampowered.com/IGameServersService/GetServerList/v1/"; const steamAPIKey = await setting("steamAPIKey"); const filter = `addr\\${monitor.hostname}:${monitor.port}`; @@ -62,7 +62,7 @@ class SteamMonitorType extends MonitorType { key: steamAPIKey, } }; - return await axios.get(steamApiUrl, options); + return await axios.get(this.steamApiUrl, options); } } diff --git a/test/backend-test/monitors/test-steam.js b/test/backend-test/monitors/test-steam.js new file mode 100644 index 000000000..28f68a9e6 --- /dev/null +++ b/test/backend-test/monitors/test-steam.js @@ -0,0 +1,118 @@ +process.env.UPTIME_KUMA_HIDE_LOG = [ "info_db", "info_server" ].join(","); + +const { describe, test, before, after } = require("node:test"); +const assert = require("node:assert"); +const express = require("express"); +const { UP, PENDING } = require("../../../src/util"); +const { SteamMonitorType } = require("../../../server/monitor-types/steam"); +const { setSetting } = require("../../../server/util-server"); +const TestDB = require("../../mock-testdb"); + +const testDb = new TestDB(); +const TEST_PORT_1 = 30158; +const TEST_PORT_2 = 30159; + +describe("Steam Monitor", () => { + before(async () => { + await testDb.create(); + await setSetting("steamAPIKey", "test-steam-api-key"); + }); + + after(async () => { + await testDb.destroy(); + }); + + test("check() sets status to UP when Steam API returns valid server response", async () => { + // Create fresh express app for this test + const app = express(); + app.get("/IGameServersService/GetServerList/v1/", (req, res) => { + res.json({ + response: { + servers: [ + { + name: "Test Game Server", + addr: "127.0.0.1:27015" + } + ] + } + }); + }); + + const mockServer = await new Promise((resolve) => { + const server = app.listen(TEST_PORT_1, () => resolve(server)); + }); + + try { + const steamMonitor = new SteamMonitorType(); + steamMonitor.steamApiUrl = `http://127.0.0.1:${TEST_PORT_1}/IGameServersService/GetServerList/v1/`; + + const monitor = { + hostname: "127.0.0.1", + port: 27015, + timeout: 2, + packetSize: 56, + ignoreTls: false, + maxredirects: 10, + getAcceptedStatuscodes: () => ["200-299"] + }; + + const heartbeat = { + msg: "", + status: PENDING, + ping: null + }; + + await steamMonitor.check(monitor, heartbeat, {}); + + assert.strictEqual(heartbeat.status, UP); + assert.strictEqual(heartbeat.msg, "Test Game Server"); + // Note: ping may be null or a value depending on if ICMP ping succeeds + } finally { + await new Promise((resolve) => mockServer.close(resolve)); + } + }); + + test("check() throws error when Steam API returns empty server list", async () => { + // Create fresh express app for this test + const app = express(); + app.get("/IGameServersService/GetServerList/v1/", (req, res) => { + res.json({ + response: { + servers: [] + } + }); + }); + + const mockServer = await new Promise((resolve) => { + const server = app.listen(TEST_PORT_2, () => resolve(server)); + }); + + try { + const steamMonitor = new SteamMonitorType(); + steamMonitor.steamApiUrl = `http://127.0.0.1:${TEST_PORT_2}/IGameServersService/GetServerList/v1/`; + + const monitor = { + hostname: "127.0.0.1", + port: 27015, + timeout: 2, + ignoreTls: false, + maxredirects: 10, + getAcceptedStatuscodes: () => ["200-299"] + }; + + const heartbeat = { + msg: "", + status: PENDING + }; + + await assert.rejects( + steamMonitor.check(monitor, heartbeat, {}), + { + message: "Server not found on Steam" + } + ); + } finally { + await new Promise((resolve) => mockServer.close(resolve)); + } + }); +}); From 5c670623d116df6b7e2961fc2a540d2af13e2ff0 Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Thu, 8 Jan 2026 23:51:24 +0100 Subject: [PATCH 08/10] remove one if --- server/model/monitor.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/server/model/monitor.js b/server/model/monitor.js index 6e567b51d..8c54420df 100644 --- a/server/model/monitor.js +++ b/server/model/monitor.js @@ -675,9 +675,6 @@ class Monitor extends BeanModel { bean.duration = beatInterval; throw new Error("No heartbeat in the time window"); } - - } else if (this.type === "steam") { - } else if (this.type === "docker") { log.debug("monitor", `[${this.name}] Prepare Options for Axios`); From 6647bed635d7c9a787ce1bbe0cbb0d08dbdbe1ad Mon Sep 17 00:00:00 2001 From: Frank Elsinga Date: Thu, 8 Jan 2026 23:56:51 +0100 Subject: [PATCH 09/10] simplify test --- test/backend-test/monitors/test-steam.js | 141 +++++++++++------------ 1 file changed, 65 insertions(+), 76 deletions(-) diff --git a/test/backend-test/monitors/test-steam.js b/test/backend-test/monitors/test-steam.js index 28f68a9e6..2f69769d9 100644 --- a/test/backend-test/monitors/test-steam.js +++ b/test/backend-test/monitors/test-steam.js @@ -9,23 +9,18 @@ const { setSetting } = require("../../../server/util-server"); const TestDB = require("../../mock-testdb"); const testDb = new TestDB(); -const TEST_PORT_1 = 30158; -const TEST_PORT_2 = 30159; +const TEST_PORT = 30158; +let mockServer; describe("Steam Monitor", () => { before(async () => { await testDb.create(); await setSetting("steamAPIKey", "test-steam-api-key"); - }); - after(async () => { - await testDb.destroy(); - }); - - test("check() sets status to UP when Steam API returns valid server response", async () => { - // Create fresh express app for this test + // Create shared mock Steam API server with different endpoints const app = express(); - app.get("/IGameServersService/GetServerList/v1/", (req, res) => { + app.use(express.json()); + app.get("/GetServerList/", (req, res) => { res.json({ response: { servers: [ @@ -37,45 +32,7 @@ describe("Steam Monitor", () => { } }); }); - - const mockServer = await new Promise((resolve) => { - const server = app.listen(TEST_PORT_1, () => resolve(server)); - }); - - try { - const steamMonitor = new SteamMonitorType(); - steamMonitor.steamApiUrl = `http://127.0.0.1:${TEST_PORT_1}/IGameServersService/GetServerList/v1/`; - - const monitor = { - hostname: "127.0.0.1", - port: 27015, - timeout: 2, - packetSize: 56, - ignoreTls: false, - maxredirects: 10, - getAcceptedStatuscodes: () => ["200-299"] - }; - - const heartbeat = { - msg: "", - status: PENDING, - ping: null - }; - - await steamMonitor.check(monitor, heartbeat, {}); - - assert.strictEqual(heartbeat.status, UP); - assert.strictEqual(heartbeat.msg, "Test Game Server"); - // Note: ping may be null or a value depending on if ICMP ping succeeds - } finally { - await new Promise((resolve) => mockServer.close(resolve)); - } - }); - - test("check() throws error when Steam API returns empty server list", async () => { - // Create fresh express app for this test - const app = express(); - app.get("/IGameServersService/GetServerList/v1/", (req, res) => { + app.get("/EmptyGetServerList/", (req, res) => { res.json({ response: { servers: [] @@ -83,36 +40,68 @@ describe("Steam Monitor", () => { }); }); - const mockServer = await new Promise((resolve) => { - const server = app.listen(TEST_PORT_2, () => resolve(server)); + mockServer = await new Promise((resolve) => { + const server = app.listen(TEST_PORT, () => resolve(server)); }); + }); - try { - const steamMonitor = new SteamMonitorType(); - steamMonitor.steamApiUrl = `http://127.0.0.1:${TEST_PORT_2}/IGameServersService/GetServerList/v1/`; - - const monitor = { - hostname: "127.0.0.1", - port: 27015, - timeout: 2, - ignoreTls: false, - maxredirects: 10, - getAcceptedStatuscodes: () => ["200-299"] - }; - - const heartbeat = { - msg: "", - status: PENDING - }; - - await assert.rejects( - steamMonitor.check(monitor, heartbeat, {}), - { - message: "Server not found on Steam" - } - ); - } finally { + after(async () => { + if (mockServer) { await new Promise((resolve) => mockServer.close(resolve)); } + await testDb.destroy(); + }); + + test("check() sets status to UP when Steam API returns valid server response", async () => { + const steamMonitor = new SteamMonitorType(); + steamMonitor.steamApiUrl = `http://127.0.0.1:${TEST_PORT}/GetServerList/`; + + const monitor = { + hostname: "127.0.0.1", + port: 27015, + timeout: 2, + packetSize: 56, + ignoreTls: false, + maxredirects: 10, + getAcceptedStatuscodes: () => ["200-299"] + }; + + const heartbeat = { + msg: "", + status: PENDING, + ping: null + }; + + await steamMonitor.check(monitor, heartbeat, {}); + + assert.strictEqual(heartbeat.status, UP); + assert.strictEqual(heartbeat.msg, "Test Game Server"); + // Note: ping may be null or a value depending on if ICMP ping succeeds + }); + + test("check() throws error when Steam API returns empty server list", async () => { + const steamMonitor = new SteamMonitorType(); + steamMonitor.steamApiUrl = `http://127.0.0.1:${TEST_PORT}/EmptyGetServerList/`; + + const monitor = { + hostname: "127.0.0.1", + port: 27015, + timeout: 2, + ignoreTls: false, + maxredirects: 10, + getAcceptedStatuscodes: () => ["200-299"] + }; + + const heartbeat = { + msg: "", + status: PENDING + }; + + await assert.rejects( + steamMonitor.check(monitor, heartbeat, {}), + { + message: "Server not found on Steam" + } + ); }); }); From a2d67b7f65db7f312a3261bd24073f9b058c11aa Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 9 Jan 2026 04:10:20 +0000 Subject: [PATCH 10/10] [autofix.ci] apply automated fixes --- server/monitor-types/steam.js | 35 ++++++++++++++++-------- test/backend-test/monitors/test-steam.js | 31 ++++++++++----------- 2 files changed, 38 insertions(+), 28 deletions(-) diff --git a/server/monitor-types/steam.js b/server/monitor-types/steam.js index eff5f58e6..7936610d5 100644 --- a/server/monitor-types/steam.js +++ b/server/monitor-types/steam.js @@ -1,5 +1,10 @@ const { MonitorType } = require("./monitor-type"); -const { UP, PING_COUNT_DEFAULT, PING_GLOBAL_TIMEOUT_DEFAULT, PING_PER_REQUEST_TIMEOUT_DEFAULT } = require("../../src/util"); +const { + UP, + PING_COUNT_DEFAULT, + PING_GLOBAL_TIMEOUT_DEFAULT, + PING_PER_REQUEST_TIMEOUT_DEFAULT, +} = require("../../src/util"); const { ping, checkStatusCode, setting } = require("../util-server"); const axios = require("axios"); const https = require("https"); @@ -20,19 +25,27 @@ class SteamMonitorType extends MonitorType { heartbeat.msg = res.data.response.servers[0].name; try { - heartbeat.ping = await ping(monitor.hostname, PING_COUNT_DEFAULT, "", true, monitor.packetSize, PING_GLOBAL_TIMEOUT_DEFAULT, PING_PER_REQUEST_TIMEOUT_DEFAULT); - } catch (_) { } + heartbeat.ping = await ping( + monitor.hostname, + PING_COUNT_DEFAULT, + "", + true, + monitor.packetSize, + PING_GLOBAL_TIMEOUT_DEFAULT, + PING_PER_REQUEST_TIMEOUT_DEFAULT + ); + } catch (_) {} } else { throw new Error("Server not found on Steam"); } } /** - * Get server list from Steam API - * @param {Monitor} monitor Monitor object - * @returns {Promise} Axios response object containing server list data - * @throws {Error} If Steam API Key is not configured - */ + * Get server list from Steam API + * @param {Monitor} monitor Monitor object + * @returns {Promise} Axios response object containing server list data + * @throws {Error} If Steam API Key is not configured + */ async getServerList(monitor) { const steamAPIKey = await setting("steamAPIKey"); const filter = `addr\\${monitor.hostname}:${monitor.port}`; @@ -43,10 +56,10 @@ class SteamMonitorType extends MonitorType { const options = { timeout: monitor.timeout * 1000, headers: { - "Accept": "*/*", + Accept: "*/*", }, httpsAgent: new https.Agent({ - maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) + maxCachedSessions: 0, // Use Custom agent to disable session reuse (https://github.com/nodejs/node/issues/3940) rejectUnauthorized: !monitor.ignoreTls, secureOptions: crypto.constants.SSL_OP_LEGACY_SERVER_CONNECT, }), @@ -60,7 +73,7 @@ class SteamMonitorType extends MonitorType { params: { filter: filter, key: steamAPIKey, - } + }, }; return await axios.get(this.steamApiUrl, options); } diff --git a/test/backend-test/monitors/test-steam.js b/test/backend-test/monitors/test-steam.js index 2f69769d9..e785dd961 100644 --- a/test/backend-test/monitors/test-steam.js +++ b/test/backend-test/monitors/test-steam.js @@ -1,4 +1,4 @@ -process.env.UPTIME_KUMA_HIDE_LOG = [ "info_db", "info_server" ].join(","); +process.env.UPTIME_KUMA_HIDE_LOG = ["info_db", "info_server"].join(","); const { describe, test, before, after } = require("node:test"); const assert = require("node:assert"); @@ -26,17 +26,17 @@ describe("Steam Monitor", () => { servers: [ { name: "Test Game Server", - addr: "127.0.0.1:27015" - } - ] - } + addr: "127.0.0.1:27015", + }, + ], + }, }); }); app.get("/EmptyGetServerList/", (req, res) => { res.json({ response: { - servers: [] - } + servers: [], + }, }); }); @@ -63,13 +63,13 @@ describe("Steam Monitor", () => { packetSize: 56, ignoreTls: false, maxredirects: 10, - getAcceptedStatuscodes: () => ["200-299"] + getAcceptedStatuscodes: () => ["200-299"], }; const heartbeat = { msg: "", status: PENDING, - ping: null + ping: null, }; await steamMonitor.check(monitor, heartbeat, {}); @@ -89,19 +89,16 @@ describe("Steam Monitor", () => { timeout: 2, ignoreTls: false, maxredirects: 10, - getAcceptedStatuscodes: () => ["200-299"] + getAcceptedStatuscodes: () => ["200-299"], }; const heartbeat = { msg: "", - status: PENDING + status: PENDING, }; - await assert.rejects( - steamMonitor.check(monitor, heartbeat, {}), - { - message: "Server not found on Steam" - } - ); + await assert.rejects(steamMonitor.check(monitor, heartbeat, {}), { + message: "Server not found on Steam", + }); }); });