Merge remote-tracking branch 'upstream/master' into feature/local-service-monitor

This commit is contained in:
iotux 2026-01-01 12:11:27 +01:00
commit 1faed64e13
17 changed files with 1501 additions and 950 deletions

1476
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -116,13 +116,13 @@
"mitt": "~3.0.1",
"mongodb": "~4.17.1",
"mqtt": "~4.3.7",
"mssql": "~11.0.0",
"mssql": "~12.0.0",
"mysql2": "~3.11.3",
"nanoid": "~3.3.4",
"net-snmp": "^3.11.2",
"node-cloudflared-tunnel": "~1.0.9",
"node-radius-utils": "~1.2.0",
"node-fetch-cache": "^5.1.0",
"node-radius-utils": "~1.2.0",
"nodemailer": "~6.9.13",
"nostr-tools": "^2.10.4",
"notp": "~2.0.3",
@ -161,6 +161,7 @@
"@playwright/test": "~1.39.0",
"@popperjs/core": "~2.10.2",
"@testcontainers/hivemq": "^10.13.1",
"@testcontainers/mssqlserver": "^10.28.0",
"@testcontainers/postgresql": "^11.9.0",
"@testcontainers/rabbitmq": "^10.13.2",
"@types/bootstrap": "~5.1.9",

View File

@ -8,7 +8,7 @@ const { log, UP, DOWN, PENDING, MAINTENANCE, flipStatus, MAX_INTERVAL_SECOND, MI
PING_COUNT_MIN, PING_COUNT_MAX, PING_COUNT_DEFAULT,
PING_PER_REQUEST_TIMEOUT_MIN, PING_PER_REQUEST_TIMEOUT_MAX, PING_PER_REQUEST_TIMEOUT_DEFAULT
} = require("../../src/util");
const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mssqlQuery, mysqlQuery, setSetting, httpNtlm, radius,
const { ping, checkCertificate, checkStatusCode, getTotalClientInRoom, setting, mysqlQuery, setSetting, httpNtlm, radius,
kafkaProducerAsync, getOidcTokenClientCredentials, rootCertificatesFingerprints, axiosAbortSignal, checkCertificateHostname
} = require("../util-server");
const { R } = require("redbean-node");
@ -783,14 +783,6 @@ class Monitor extends BeanModel {
} else {
throw Error("Container State is " + res.data.State.Status);
}
} else if (this.type === "sqlserver") {
let startTime = dayjs().valueOf();
await mssqlQuery(this.databaseConnectionString, this.databaseQuery || "SELECT 1");
bean.msg = "";
bean.status = UP;
bean.ping = dayjs().valueOf() - startTime;
} else if (this.type === "mysql") {
let startTime = dayjs().valueOf();

View File

@ -0,0 +1,118 @@
const { MonitorType } = require("./monitor-type");
const { log, UP } = require("../../src/util");
const dayjs = require("dayjs");
const mssql = require("mssql");
const { ConditionVariable } = require("../monitor-conditions/variables");
const { defaultStringOperators } = require("../monitor-conditions/operators");
const {
ConditionExpressionGroup,
} = require("../monitor-conditions/expression");
const { evaluateExpressionGroup } = require("../monitor-conditions/evaluator");
class MssqlMonitorType extends MonitorType {
name = "sqlserver";
supportsConditions = true;
conditionVariables = [
new ConditionVariable("result", defaultStringOperators),
];
/**
* @inheritdoc
*/
async check(monitor, heartbeat, _server) {
let startTime = dayjs().valueOf();
let query = monitor.databaseQuery;
// No query provided by user, use SELECT 1
if (!query || (typeof query === "string" && query.trim() === "")) {
query = "SELECT 1";
}
let result;
try {
result = await this.mssqlQuery(
monitor.databaseConnectionString,
query
);
} catch (error) {
log.error("sqlserver", "Database query failed:", error.message);
throw new Error(
`Database connection/query failed: ${error.message}`
);
} finally {
heartbeat.ping = dayjs().valueOf() - startTime;
}
const conditions = ConditionExpressionGroup.fromMonitor(monitor);
const handleConditions = (data) =>
conditions ? evaluateExpressionGroup(conditions, data) : true;
// Since result is now a single value, pass it directly to conditions
const conditionsResult = handleConditions({ result: String(result) });
if (!conditionsResult) {
throw new Error(
`Query result did not meet the specified conditions (${result})`
);
}
heartbeat.msg = "";
heartbeat.status = UP;
}
/**
* Run a query on MSSQL server
* @param {string} connectionString The database connection string
* @param {string} query The query to validate the database with
* @returns {Promise<any>} Single value from the first column of the first row
*/
async mssqlQuery(connectionString, query) {
let pool;
try {
pool = new mssql.ConnectionPool(connectionString);
await pool.connect();
const result = await pool.request().query(query);
// Check if we have results
if (!result.recordset || result.recordset.length === 0) {
throw new Error("Query returned no results");
}
// Check if we have multiple rows
if (result.recordset.length > 1) {
throw new Error(
"Multiple values were found, expected only one value"
);
}
const firstRow = result.recordset[0];
const columnNames = Object.keys(firstRow);
// Check if we have multiple columns
if (columnNames.length > 1) {
throw new Error(
"Multiple columns were found, expected only one value"
);
}
// Return the single value from the first (and only) column
return firstRow[columnNames[0]];
} catch (err) {
log.debug(
"sqlserver",
"Error caught in the query execution.",
err.message
);
throw err;
} finally {
if (pool) {
await pool.close();
}
}
}
}
module.exports = {
MssqlMonitorType,
};

View File

@ -1,6 +1,26 @@
const { MonitorType } = require("./monitor-type");
const WebSocket = require("ws");
const { UP } = require("../../src/util");
const { checkStatusCode } = require("../util-server");
// Define closing error codes https://www.iana.org/assignments/websocket/websocket.xml#close-code-number
const WS_ERR_CODE = {
1002: "Protocol error",
1003: "Unsupported Data",
1005: "No Status Received",
1006: "Abnormal Closure",
1007: "Invalid frame payload data",
1008: "Policy Violation",
1009: "Message Too Big",
1010: "Mandatory Extension Missing",
1011: "Internal Error",
1012: "Service Restart",
1013: "Try Again Later",
1014: "Bad Gateway",
1015: "TLS Handshake Failed",
3000: "Unauthorized",
3003: "Forbidden",
3008: "Timeout",
};
class WebSocketMonitorType extends MonitorType {
name = "websocket-upgrade";
@ -11,24 +31,36 @@ class WebSocketMonitorType extends MonitorType {
async check(monitor, heartbeat, _server) {
const [ message, code ] = await this.attemptUpgrade(monitor);
if (code === 1000) {
heartbeat.status = UP;
heartbeat.msg = message;
} else {
throw new Error(message);
if (typeof code !== "undefined") {
// If returned status code matches user controlled accepted status code(default 1000), return success
if (checkStatusCode(code, JSON.parse(monitor.accepted_statuscodes_json))) {
heartbeat.status = UP;
heartbeat.msg = message;
return; // success at this point
}
// Throw an error using friendly name if defined, fallback to generic msg
throw new Error(WS_ERR_CODE[code] || `Unexpected status code: ${code}`);
}
// If no close code, then an error has occurred, display to user
if (typeof message !== "undefined") {
throw new Error(`${message}`);
}
// Throw generic error if nothing is defined, should never happen
throw new Error("Unknown Websocket Error");
}
/**
* Uses the builtin Websocket API to establish a connection to target server
* Uses the ws Node.js library to establish a connection to target server
* @param {object} monitor The monitor object for input parameters.
* @returns {[ string, int ]} Array containing a status message and response code
*/
async attemptUpgrade(monitor) {
return new Promise((resolve) => {
let ws;
//If user selected a subprotocol, sets Sec-WebSocket-Protocol header. Subprotocol Identifier column: https://www.iana.org/assignments/websocket/websocket.xml#subprotocol-name
ws = monitor.wsSubprotocol === "" ? new WebSocket(monitor.url) : new WebSocket(monitor.url, monitor.wsSubprotocol);
const timeoutMs = (monitor.timeout ?? 20) * 1000;
// If user inputs subprotocol(s), convert to array, set Sec-WebSocket-Protocol header, timeout in ms. Subprotocol Identifier column: https://www.iana.org/assignments/websocket/websocket.xml#subprotocol-name
const subprotocol = monitor.wsSubprotocol ? monitor.wsSubprotocol.replace(/\s/g, "").split(",") : undefined;
const ws = new WebSocket(monitor.url, subprotocol, { handshakeTimeout: timeoutMs });
ws.addEventListener("open", (event) => {
// Immediately close the connection
@ -36,9 +68,10 @@ class WebSocketMonitorType extends MonitorType {
});
ws.onerror = (error) => {
// Give user the choice to ignore Sec-WebSocket-Accept header
// Give user the choice to ignore Sec-WebSocket-Accept header for non compliant servers
// Header in HTTP 101 Switching Protocols response from server, technically already upgraded to WS
if (monitor.wsIgnoreSecWebsocketAcceptHeader && error.message === "Invalid Sec-WebSocket-Accept header") {
resolve([ "101 - OK", 1000 ]);
resolve([ "1000 - OK", 1000 ]);
return;
}
// Upgrade failed, return message to user
@ -46,8 +79,8 @@ class WebSocketMonitorType extends MonitorType {
};
ws.onclose = (event) => {
// Upgrade success, connection closed successfully
resolve([ "101 - OK", event.code ]);
// Return the close code, if connection didn't close cleanly, return the reason if present
resolve([ event.wasClean ? event.code.toString() + " - OK" : event.reason, event.code ]);
};
});
}

View File

@ -0,0 +1,48 @@
const NotificationProvider = require("./notification-provider");
const axios = require("axios");
class Resend extends NotificationProvider {
name = "Resend";
/**
* @inheritdoc
*/
async send(notification, msg, monitorJSON = null, heartbeatJSON = null) {
const okMsg = "Sent Successfully.";
try {
let config = {
headers: {
Authorization: `Bearer ${notification.resendApiKey}`,
"Content-Type": "application/json",
},
};
config = this.getAxiosConfigWithProxy(config);
const email = notification.resendFromEmail.trim();
const fromName = notification.resendFromName?.trim() || "Uptime Kuma";
let data = {
from: `${fromName} <${email}>`,
to: notification.resendToEmail,
subject: notification.resendSubject || "Notification from Your Uptime Kuma",
// supplied text directly instead of html
text: msg,
};
let result = await axios.post(
"https://api.resend.com/emails",
data,
config
);
if (result.status === 200) {
return okMsg;
} else {
throw new Error(`Unexpected status code: ${result.status}`);
}
} catch (error) {
this.throwGeneralAxiosError(error);
}
}
}
module.exports = Resend;

View File

@ -77,6 +77,7 @@ const Onesender = require("./notification-providers/onesender");
const Wpush = require("./notification-providers/wpush");
const SendGrid = require("./notification-providers/send-grid");
const Brevo = require("./notification-providers/brevo");
const Resend = require("./notification-providers/resend");
const YZJ = require("./notification-providers/yzj");
const SMSPlanet = require("./notification-providers/sms-planet");
const SpugPush = require("./notification-providers/spugpush");
@ -174,6 +175,7 @@ class Notification {
new Cellsynt(),
new Wpush(),
new Brevo(),
new Resend(),
new YZJ(),
new SMSPlanet(),
new SpugPush(),

View File

@ -124,7 +124,11 @@ class UptimeKumaServer {
UptimeKumaServer.monitorTypeList["port"] = new TCPMonitorType();
UptimeKumaServer.monitorTypeList["manual"] = new ManualMonitorType();
UptimeKumaServer.monitorTypeList["redis"] = new RedisMonitorType();
<<<<<<< HEAD
UptimeKumaServer.monitorTypeList["system-service"] = new SystemServiceMonitorType();
=======
UptimeKumaServer.monitorTypeList["sqlserver"] = new MssqlMonitorType();
>>>>>>> upstream/master
// Allow all CORS origins (polling) in development
let cors = undefined;
@ -574,5 +578,6 @@ 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

@ -10,7 +10,6 @@ const { Resolver } = require("dns");
const iconv = require("iconv-lite");
const chardet = require("chardet");
const chroma = require("chroma-js");
const mssql = require("mssql");
const mysql = require("mysql2");
const { NtlmClient } = require("./modules/axios-ntlm/lib/ntlmClient.js");
const { Settings } = require("./settings");
@ -322,31 +321,6 @@ exports.dnsResolve = function (hostname, resolverServer, resolverPort, rrtype) {
});
};
/**
* Run a query on SQL Server
* @param {string} connectionString The database connection string
* @param {string} query The query to validate the database with
* @returns {Promise<(string[] | object[] | object)>} Response from
* server
*/
exports.mssqlQuery = async function (connectionString, query) {
let pool;
try {
pool = new mssql.ConnectionPool(connectionString);
await pool.connect();
if (!query) {
query = "SELECT 1";
}
await pool.request().query(query);
pool.close();
} catch (e) {
if (pool) {
pool.close();
}
throw e;
}
};
/**
* Run a query on MySQL/MariaDB
* @param {string} connectionString The database connection string

View File

@ -172,6 +172,7 @@ export default {
"Cellsynt": "Cellsynt",
"SendGrid": "SendGrid",
"Brevo": "Brevo",
"Resend": "Resend",
"notifery": "Notifery",
"Webpush": "Webpush",
};

View File

@ -0,0 +1,48 @@
<template>
<div class="mb-3">
<label for="resend-api-key" class="form-label">{{ $t("resendApiKey") }}</label>
<HiddenInput id="resend-api-key" v-model="$parent.notification.resendApiKey" :required="true" autocomplete="new-password"></HiddenInput>
<i18n-t tag="div" keypath="resendApiHelp" class="form-text">
<a href="https://resend.com/api-keys" target="_blank">https://resend.com/api-keys</a>
</i18n-t>
</div>
<div class="mb-3">
<label for="resend-from-email" class="form-label">{{ $t("resendFromEmail") }}</label>
<input id="resend-from-email" v-model="$parent.notification.resendFromEmail" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="resend-from-name" class="form-label">{{ $t("resendFromName") }}</label>
<input id="resend-from-name" v-model="$parent.notification.resendFromName" type="text" class="form-control">
<div class="form-text">{{ $t("resendLeaveBlankForDefaultName") }}</div>
</div>
<div class="mb-3">
<label for="resend-to-email" class="form-label">{{ $t("resendToEmail") }}</label>
<input id="resend-to-email" v-model="$parent.notification.resendToEmail" type="text" class="form-control" required>
</div>
<div class="mb-3">
<label for="resend-subject" class="form-label">{{ $t("resendSubject") }}</label>
<input id="resend-subject" v-model="$parent.notification.resendSubject" type="text" class="form-control">
<small class="form-text text-muted">{{ $t("resendLeaveBlankForDefaultSubject") }}</small>
</div>
<i18n-t tag="p" keypath="More info on:" style="margin-top: 8px;">
<a href="https://resend.com/docs/dashboard/emails/introduction" target="_blank">https://resend.com/docs/dashboard/emails/introduction</a>
</i18n-t>
</template>
<script>
import HiddenInput from "../HiddenInput.vue";
export default {
components: {
HiddenInput,
},
mounted() {
if (typeof this.$parent.notification.resendSubject === "undefined") {
this.$parent.notification.resendSubject = "Notification from Your Uptime Kuma";
}
if (typeof this.$parent.notification.resendFromName === "undefined") {
this.$parent.notification.resendFromName = "Uptime Kuma";
}
},
};
</script>

View File

@ -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 Resend from "./Resend.vue";
/**
* Manage all notification form.
@ -165,6 +166,7 @@ const NotificationFormList = {
"WPush": WPush,
"SendGrid": SendGrid,
"Brevo": Brevo,
"Resend": Resend,
"YZJ": YZJ,
"SMSPlanet": SMSPlanet,
"Webpush": Webpush,

View File

@ -90,39 +90,9 @@
"upsideDownModeDescription": "Flip the status upside down. If the service is reachable, it is DOWN.",
"ignoreSecWebsocketAcceptHeaderDescription": "Allows the server to not reply with Sec-WebSocket-Accept header, if the websocket upgrade succeeds.",
"Ignore Sec-WebSocket-Accept header": "Ignore {0} header",
"wsSubprotocolDescription": "For more information on subprotocols, please consult the {documentation}",
"WebSocket Application Messaging Protocol": "WAMP (The WebSocket Application Messaging Protocol)",
"Session Initiation Protocol": "WebSocket Transport for SIP (Session Initiation Protocol)",
"Subprotocol": "Subprotocol",
"Network API for Notification Channel": "OMA RESTful Network API for Notification Channel",
"Web Process Control Protocol": "Web Process Control Protocol (WPCP)",
"Advanced Message Queuing Protocol": "Advanced Message Queuing Protocol (AMQP) 1.0+",
"jsflow": "jsFlow pubsub/queue Protocol",
"Reverse Web Process Control": "Reverse Web Process Control Protocol (RWPCP)",
"Extensible Messaging and Presence Protocol": "WebSocket Transport for the Extensible Messaging and Presence Protocol (XMPP)",
"Smart Home IP": "SHIP - Smart Home IP",
"Miele Cloud Connect Protocol": "Miele Cloud Connect Protocol",
"Push Channel Protocol": "Push Channel Protocol",
"Message Session Relay Protocol": "WebSocket Transport for MSRP (Message Session Relay Protocol)",
"Binary Floor Control Protocol": "WebSocket Transport for BFCP (Binary Floor Control Protocol)",
"Softvelum Low Delay Protocol": "Softvelum Low Delay Protocol",
"OPC UA Connection Protocol": "OPC UA Connection Protocol",
"OPC UA JSON Encoding": "OPC UA JSON Encoding",
"Swindon Web Server Protocol": "Swindon Web Server Protocol (JSON encoding)",
"Broadband Forum User Services Platform": "USP (Broadband Forum User Services Platform)",
"Constrained Application Protocol": "Constrained Application Protocol (CoAP)",
"Softvelum WebSocket signaling protocol": "Softvelum WebSocket Signaling Protocol",
"Cobra Real Time Messaging Protocol": "Cobra Real Time Messaging Protocol",
"Declarative Resource Protocol": "Declarative Resource Protocol",
"BACnet Secure Connect Hub Connection": "BACnet Secure Connect Hub Connection",
"BACnet Secure Connect Direct Connection": "BACnet Secure Connect Direct Connection",
"WebSocket Transport for JMAP": "WebSocket Transport for JMAP (JSON Meta Application Protocol)",
"ITU-T T.140 Real-Time Text": "ITU-T T.140 Real-Time Text",
"Done.best IoT Protocol": "Done.best IoT Protocol",
"Collection Update": "The Collection Update Websocket Subprotocol",
"Text IRC Protocol": "Text IRC Protocol",
"Binary IRC Protocol": "Binary IRC Protocol",
"Penguin Statistics Live Protocol v3": "Penguin Statistics Live Protocol v3 (Protobuf encoding)",
"wsSubprotocolDescription": "Enter a comma delimited list of subprotocols. For more information on subprotocols, please consult the {documentation}",
"wsCodeDescription": "For more information on status codes, please consult {rfc6455}",
"Subprotocol(s)": "Subprotocol(s)",
"maxRedirectDescription": "Maximum number of redirects to follow. Set to 0 to disable redirects.",
"Upside Down Mode": "Upside Down Mode",
"Max. Redirects": "Max. Redirects",
@ -1190,6 +1160,13 @@
"brevoSeparateMultipleEmails": "Separate multiple email addresses with commas",
"brevoSubject": "Subject",
"brevoLeaveBlankForDefaultSubject": "leave blank for default subject",
"resendApiKey": "Resend API Key",
"resendApiHelp": "Create an api key here {0}",
"resendFromName": "From Name",
"resendFromEmail": "From Email",
"resendLeaveBlankForDefaultName": "leave blank for default name",
"resendToEmail": "To Email",
"resendSubject": "Subject",
"pingCountLabel": "Max Packets",
"pingCountDescription": "Number of packets to send before stopping",
"pingNumericLabel": "Numeric Output",

View File

@ -146,73 +146,8 @@
<!-- Websocket Subprotocol Docs: https://www.iana.org/assignments/websocket/websocket.xml#subprotocol-name -->
<div v-if="monitor.type === 'websocket-upgrade'" class="my-3">
<label for="ws_subprotocol" class="form-label">{{ $t("Subprotocol") }}</label>
<select id="ws_subprotocol" v-model="monitor.wsSubprotocol" class="form-select">
<option value="" selected>{{ $t("None") }}</option>
<option value="MBWS.huawei.com">MBWS</option>
<option value="MBLWS.huawei.com">MBLWS</option>
<option value="soap">soap</option>
<option value="wamp">{{ $t("WebSocket Application Messaging Protocol") }}</option>
<option value="v10.stomp">STOMP 1.0</option>
<option value="v11.stomp">STOMP 1.1</option>
<option value="v12.stomp">STOMP 1.2</option>
<option value="ocpp1.2">OCPP 1.2</option>
<option value="ocpp1.5">OCPP 1.5</option>
<option value="ocpp1.6">OCPP 1.6</option>
<option value="ocpp2.0">OCPP 2.0</option>
<option value="ocpp2.0.1">OCPP 2.0.1</option>
<option value="ocpp2.1">OCPP 2.1</option>
<option value="rfb">RFB</option>
<option value="sip">{{ $t("Session Initiation Protocol") }}</option>
<option value="notificationchannel-netapi-rest.openmobilealliance.org">{{ $t("Network API for Notification Channel") }}</option>
<option value="wpcp">{{ $t("Web Process Control Protocol") }}</option>
<option value="amqp">{{ $t("Advanced Message Queuing Protocol") }}</option>
<option value="mqtt">MQTT</option>
<option value="jsflow">{{ $t("jsflow") }}</option>
<option value="rwpcp">{{ $t("Reverse Web Process Control") }}</option>
<option value="xmpp">{{ $t("Extensible Messaging and Presence Protocol") }}</option>
<option value="ship">{{ $t("Smart Home IP") }}</option>
<option value="mielecloudconnect">{{ $t("Miele Cloud Connect Protocol") }}</option>
<option value="v10.pcp.sap.com">{{ $t("Push Channel Protocol") }}</option>
<option value="msrp">{{ $t("Message Session Relay Protocol") }}</option>
<option value="v1.saltyrtc.org">SaltyRTC 1.0</option>
<option value="TLCP-2.0.0.lightstreamer.com">TLCP 2.0.0</option>
<option value="bfcp">{{ $t("Binary Floor Control Protocol") }}</option>
<option value="sldp.softvelum.com">{{ $t("Softvelum Low Delay Protocol") }}</option>
<option value="opcua+uacp">{{ $t("OPC UA Connection Protocol") }}</option>
<option value="opcua+uajson">{{ $t("OPC UA JSON Encoding") }}</option>
<option value="v1.swindon-lattice+json">{{ $t("Swindon Web Server Protocol") }}</option>
<option value="v1.usp">{{ $t("Broadband Forum User Services Platform") }}</option>
<option value="mles-websocket">mles-websocket</option>
<option value="coap">{{ $t("Constrained Application Protocol") }}</option>
<option value="TLCP-2.1.0.lightstreamer.com">TLCP 2.1.0</option>
<option value="sqlnet.oracle.com">sqlnet</option>
<option value="oneM2M.R2.0.json">oneM2M R2.0 JSON</option>
<option value="oneM2M.R2.0.xml">oneM2M R2.0 XML</option>
<option value="oneM2M.R2.0.cbor">oneM2M R2.0 CBOR</option>
<option value="transit">Transit</option>
<option value="2016.serverpush.dash.mpeg.org">MPEG-DASH-ServerPush-23009-6-2017</option>
<option value="2018.mmt.mpeg.org">MPEG-MMT-23008-1-2018</option>
<option value="clue">clue</option>
<option value="webrtc.softvelum.com">{{ $t("Softvelum WebSocket signaling protocol") }}</option>
<option value="cobra.v2.json">{{ $t("Cobra Real Time Messaging Protocol") }}</option>
<option value="drp">{{ $t("Declarative Resource Protocol") }}</option>
<option value="hub.bsc.bacnet.org">{{ $t("BACnet Secure Connect Hub Connection") }}</option>
<option value="dc.bsc.bacnet.org">{{ $t("BACnet Secure Connect Direct Connection") }}</option>
<option value="jmap">{{ $t("WebSocket Transport for JMAP") }}</option>
<option value="t140">{{ $t("ITU-T T.140 Real-Time Text") }}</option>
<option value="done">{{ $t("Done.best IoT Protocol") }}</option>
<option value="TLCP-2.2.0.lightstreamer.com">TLCP 2.2.0</option>
<option value="collection-update">{{ $t("Collection Update") }}</option>
<option value="TLCP-2.3.0.lightstreamer.com">TLCP 2.3.0</option>
<option value="text.ircv3.net">{{ $t("Text IRC Protocol") }}</option>
<option value="binary.ircv3.net">{{ $t("Binary IRC Protocol") }}</option>
<option value="v3.penguin-stats.live+proto">{{ $t("Penguin Statistics Live Protocol v3") }}</option>
<option value="TLCP-2.4.0.lightstreamer.com">TLCP 2.4.0</option>
<option value="TLCP-2.5.0.lightstreamer.com">TLCP 2.5.0</option>
<option value="Redfish">Redfish DSP0266</option>
<option value="bidib">webBiDiB</option>
</select>
<label for="ws_subprotocol" class="form-label">{{ $t("Subprotocol(s)") }}</label>
<input id="ws_subprotocol" v-model="monitor.wsSubprotocol" type="text" class="form-control" placeholder="mielecloudconnect,soap">
<i18n-t tag="div" class="form-text" keypath="wsSubprotocolDescription">
<template #documentation>
<a href="https://www.iana.org/assignments/websocket/websocket.xml#subprotocol-name" target="_blank" rel="noopener noreferrer">{{ $t('documentationOf', ['IANA']) }}</a>
@ -815,8 +750,8 @@
</div>
</div>
<!-- Timeout: HTTP / JSON query / Keyword / Ping / RabbitMQ / SNMP only -->
<div v-if="monitor.type === 'http' || monitor.type === 'json-query' || monitor.type === 'keyword' || monitor.type === 'ping' || monitor.type === 'rabbitmq' || monitor.type === 'snmp'" class="my-3">
<!-- Timeout: HTTP / JSON query / Keyword / Ping / RabbitMQ / SNMP / Websocket Upgrade only -->
<div v-if="monitor.type === 'http' || monitor.type === 'json-query' || monitor.type === 'keyword' || monitor.type === 'ping' || monitor.type === 'rabbitmq' || monitor.type === 'snmp' || monitor.type === 'websocket-upgrade'" class="my-3">
<label for="timeout" class="form-label">
{{ monitor.type === 'ping' ? $t("pingGlobalTimeoutLabel") : $t("Request Timeout") }}
<span v-if="monitor.type !== 'ping'">({{ $t("timeoutAfter", [monitor.timeout || clampTimeout(monitor.interval)]) }})</span>
@ -937,6 +872,36 @@
</div>
</div>
<!-- Websocket Upgrade only -->
<template v-if="monitor.type === 'websocket-upgrade' ">
<div class="my-3">
<label for="acceptedStatusCodes" class="form-label">{{ $t("Accepted Status Codes") }}</label>
<VueMultiselect
id="acceptedStatusCodes"
v-model="monitor.accepted_statuscodes"
:options="acceptedWebsocketCodeOptions"
:multiple="true"
:close-on-select="false"
:clear-on-select="false"
:preserve-search="true"
:placeholder="$t('Pick Accepted Status Codes...')"
:preselect-first="false"
:max-height="600"
:taggable="true"
></VueMultiselect>
<div class="form-text">
{{ $t("acceptedStatusCodesDescription") }}
</div>
<i18n-t tag="div" class="form-text" keypath="wsCodeDescription">
<template #rfc6455>
<a href="https://datatracker.ietf.org/doc/html/rfc6455#section-7.4" target="_blank" rel="noopener noreferrer">RFC 6455</a>
</template>
</i18n-t>
</div>
</template>
<!-- HTTP / Keyword only -->
<template v-if="monitor.type === 'http' || monitor.type === 'keyword' || monitor.type === 'json-query' || monitor.type === 'grpc-keyword' ">
<div class="my-3">
@ -1447,6 +1412,7 @@ export default {
},
hasDomain: false,
acceptedStatusCodeOptions: [],
acceptedWebsocketCodeOptions: [],
dnsresolvetypeOptions: [],
kafkaSaslMechanismOptions: [],
ipOrHostnameRegexPattern: hostNameRegexPattern(),
@ -1819,6 +1785,7 @@ message HealthCheckResponse {
"monitor.type"(newType, oldType) {
if (oldType && this.monitor.type === "websocket-upgrade") {
this.monitor.url = "wss://";
this.monitor.accepted_statuscodes = [ "1000" ];
}
if (this.monitor.type === "push") {
if (! this.monitor.pushToken) {
@ -1926,6 +1893,8 @@ message HealthCheckResponse {
"500-599",
];
let acceptedWebsocketCodeOptions = [];
let dnsresolvetypeOptions = [
"A",
"AAAA",
@ -1951,6 +1920,11 @@ message HealthCheckResponse {
acceptedStatusCodeOptions.push(i.toString());
}
for (let i = 1000; i <= 4999; i++) {
acceptedWebsocketCodeOptions.push(i.toString());
}
this.acceptedWebsocketCodeOptions = acceptedWebsocketCodeOptions;
this.acceptedStatusCodeOptions = acceptedStatusCodeOptions;
this.dnsresolvetypeOptions = dnsresolvetypeOptions;
this.kafkaSaslMechanismOptions = kafkaSaslMechanismOptions;

View File

@ -53,13 +53,12 @@ test("Domain Expiry", async (t) => {
"user_id": 1,
"name": "Testhook"
});
const manyDays = 1500;
setSetting("domainExpiryNotifyDays", [ 7, 14, manyDays ], "general");
const [ notifRet, data ] = await Promise.all([
const manyDays = 3650;
setSetting("domainExpiryNotifyDays", [ manyDays ], "general");
const [ , data ] = await Promise.all([
DomainExpiry.sendNotifications(monHttpCom, [ notif ]),
mockWebhook(hook.port, hook.url)
]);
assert.equal(notifRet, manyDays);
assert.match(data.msg, /will expire in/);
});
}).finally(() => {

View File

@ -0,0 +1,302 @@
const { describe, test } = require("node:test");
const assert = require("node:assert");
const { MSSQLServerContainer } = require("@testcontainers/mssqlserver");
const { MssqlMonitorType } = require("../../server/monitor-types/mssql");
const { UP, PENDING } = require("../../src/util");
/**
* Helper function to create and start a MSSQL container
* @returns {Promise<MSSQLServerContainer>} The started MSSQL container
*/
async function createAndStartMSSQLContainer() {
return await new MSSQLServerContainer(
"mcr.microsoft.com/mssql/server:2022-latest"
)
.acceptLicense()
// The default timeout of 30 seconds might not be enough for the container to start
.withStartupTimeout(60000)
.start();
}
describe(
"MSSQL Single Node",
{
skip:
!!process.env.CI &&
(process.platform !== "linux" || process.arch !== "x64"),
},
() => {
test("MSSQL is running", async () => {
let mssqlContainer;
try {
mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
await mssqlMonitor.check(monitor, heartbeat, {});
assert.strictEqual(
heartbeat.status,
UP,
`Expected status ${UP} but got ${heartbeat.status}`
);
} catch (error) {
console.error("Test failed with error:", error.message);
console.error("Error stack:", error.stack);
if (mssqlContainer) {
console.error("Container ID:", mssqlContainer.getId());
console.error(
"Container logs:",
await mssqlContainer.logs()
);
}
throw error;
} finally {
if (mssqlContainer) {
console.log("Stopping MSSQL container...");
await mssqlContainer.stop();
}
}
});
test("MSSQL with custom query returning single value", async () => {
const mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
databaseQuery: "SELECT 42",
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await mssqlMonitor.check(monitor, heartbeat, {});
assert.strictEqual(
heartbeat.status,
UP,
`Expected status ${UP} but got ${heartbeat.status}`
);
} finally {
await mssqlContainer.stop();
}
});
test("MSSQL with custom query and condition that passes", async () => {
const mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
databaseQuery: "SELECT 42 as value",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "42",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await mssqlMonitor.check(monitor, heartbeat, {});
assert.strictEqual(
heartbeat.status,
UP,
`Expected status ${UP} but got ${heartbeat.status}`
);
} finally {
await mssqlContainer.stop();
}
});
test("MSSQL with custom query and condition that fails", async () => {
const mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
databaseQuery: "SELECT 99 as value",
conditions: JSON.stringify([
{
type: "expression",
andOr: "and",
variable: "result",
operator: "equals",
value: "42",
},
]),
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
mssqlMonitor.check(monitor, heartbeat, {}),
new Error(
"Query result did not meet the specified conditions (99)"
)
);
assert.strictEqual(
heartbeat.status,
PENDING,
`Expected status should not be ${heartbeat.status}`
);
} finally {
await mssqlContainer.stop();
}
});
test("MSSQL query returns no results", async () => {
const mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
databaseQuery: "SELECT 1 WHERE 1 = 0",
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
mssqlMonitor.check(monitor, heartbeat, {}),
new Error(
"Database connection/query failed: Query returned no results"
)
);
assert.strictEqual(
heartbeat.status,
PENDING,
`Expected status should not be ${heartbeat.status}`
);
} finally {
await mssqlContainer.stop();
}
});
test("MSSQL query returns multiple rows", async () => {
const mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
databaseQuery: "SELECT 1 UNION ALL SELECT 2",
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
mssqlMonitor.check(monitor, heartbeat, {}),
new Error(
"Database connection/query failed: Multiple values were found, expected only one value"
)
);
assert.strictEqual(
heartbeat.status,
PENDING,
`Expected status should not be ${heartbeat.status}`
);
} finally {
await mssqlContainer.stop();
}
});
test("MSSQL query returns multiple columns", async () => {
const mssqlContainer = await createAndStartMSSQLContainer();
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
mssqlContainer.getConnectionUri(false),
databaseQuery: "SELECT 1 AS col1, 2 AS col2",
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
try {
await assert.rejects(
mssqlMonitor.check(monitor, heartbeat, {}),
new Error(
"Database connection/query failed: Multiple columns were found, expected only one value"
)
);
assert.strictEqual(
heartbeat.status,
PENDING,
`Expected status should not be ${heartbeat.status}`
);
} finally {
await mssqlContainer.stop();
}
});
test("MSSQL is not running", async () => {
const mssqlMonitor = new MssqlMonitorType();
const monitor = {
databaseConnectionString:
"Server=localhost,15433;Database=master;User Id=Fail;Password=Fail;Encrypt=false",
conditions: "[]",
};
const heartbeat = {
msg: "",
status: PENDING,
};
await assert.rejects(
mssqlMonitor.check(monitor, heartbeat, {}),
new Error(
"Database connection/query failed: Failed to connect to localhost:15433 - Could not connect (sequence)"
)
);
assert.notStrictEqual(
heartbeat.status,
UP,
`Expected status should not be ${heartbeat.status}`
);
});
}
);

View File

@ -3,15 +3,34 @@ const { describe, test } = require("node:test");
const assert = require("node:assert");
const { WebSocketMonitorType } = require("../../server/monitor-types/websocket-upgrade");
const { UP, PENDING } = require("../../src/util");
const net = require("node:net");
/**
* Simulates non compliant WS Server, doesnt send Sec-WebSocket-Accept header
* @param {number} port Port the server listens on. Defaults to 8080
* @returns {Promise} Promise that resolves to the created server once listening
*/
function nonCompliantWS(port = 8080) {
const srv = net.createServer((socket) => {
socket.once("data", (buf) => {
socket.write("HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n\r\n");
socket.destroy();
});
});
return new Promise((resolve) => srv.listen(port, () => resolve(srv)));
}
describe("Websocket Test", {
}, () => {
test("Non Websocket Server", {}, async () => {
test("Non WS Server", {}, async () => {
const websocketMonitor = new WebSocketMonitorType();
const monitor = {
url: "wss://example.org",
wsIgnoreSecWebsocketAcceptHeader: false,
timeout: 30,
};
const heartbeat = {
@ -25,12 +44,14 @@ describe("Websocket Test", {
);
});
test("Secure Websocket", async () => {
test("Secure WS", async () => {
const websocketMonitor = new WebSocketMonitorType();
const monitor = {
url: "wss://echo.websocket.org",
wsIgnoreSecWebsocketAcceptHeader: false,
accepted_statuscodes_json: JSON.stringify([ "1000" ]),
timeout: 30,
};
const heartbeat = {
@ -39,7 +60,7 @@ describe("Websocket Test", {
};
const expected = {
msg: "101 - OK",
msg: "1000 - OK",
status: UP,
};
@ -47,7 +68,7 @@ describe("Websocket Test", {
assert.deepStrictEqual(heartbeat, expected);
});
test("Insecure Websocket", async (t) => {
test("Insecure WS", async (t) => {
t.after(() => wss.close());
const websocketMonitor = new WebSocketMonitorType();
const wss = new WebSocketServer({ port: 8080 });
@ -55,6 +76,8 @@ describe("Websocket Test", {
const monitor = {
url: "ws://localhost:8080",
wsIgnoreSecWebsocketAcceptHeader: false,
accepted_statuscodes_json: JSON.stringify([ "1000" ]),
timeout: 30,
};
const heartbeat = {
@ -63,7 +86,7 @@ describe("Websocket Test", {
};
const expected = {
msg: "101 - OK",
msg: "1000 - OK",
status: UP,
};
@ -71,12 +94,58 @@ describe("Websocket Test", {
assert.deepStrictEqual(heartbeat, expected);
});
test("Non compliant WS server without IgnoreSecWebsocket", async () => {
test("Non compliant WS Server wrong status code", async () => {
const websocketMonitor = new WebSocketMonitorType();
const monitor = {
url: "wss://c.img-cdn.net/yE4s7KehTFyj/",
url: "wss://echo.websocket.org",
wsIgnoreSecWebsocketAcceptHeader: false,
accepted_statuscodes_json: JSON.stringify([ "1001" ]),
timeout: 30,
};
const heartbeat = {
msg: "",
status: PENDING,
};
await assert.rejects(
websocketMonitor.check(monitor, heartbeat, {}),
new Error("Unexpected status code: 1000")
);
});
test("Secure WS Server no status code", async () => {
const websocketMonitor = new WebSocketMonitorType();
const monitor = {
url: "wss://echo.websocket.org",
wsIgnoreSecWebsocketAcceptHeader: false,
accepted_statuscodes_json: JSON.stringify([ "" ]),
timeout: 30,
};
const heartbeat = {
msg: "",
status: PENDING,
};
await assert.rejects(
websocketMonitor.check(monitor, heartbeat, {}),
new Error("Unexpected status code: 1000")
);
});
test("Non compliant WS server without IgnoreSecWebsocket", async (t) => {
t.after(() => wss.close());
const websocketMonitor = new WebSocketMonitorType();
const wss = await nonCompliantWS();
const monitor = {
url: "ws://localhost:8080",
wsIgnoreSecWebsocketAcceptHeader: false,
accepted_statuscodes_json: JSON.stringify([ "1000" ]),
timeout: 30,
};
const heartbeat = {
@ -90,12 +159,16 @@ describe("Websocket Test", {
);
});
test("Non compliant WS server with IgnoreSecWebsocket", async () => {
test("Non compliant WS server with IgnoreSecWebsocket", async (t) => {
t.after(() => wss.close());
const websocketMonitor = new WebSocketMonitorType();
const wss = await nonCompliantWS();
const monitor = {
url: "wss://c.img-cdn.net/yE4s7KehTFyj/",
url: "ws://localhost:8080",
wsIgnoreSecWebsocketAcceptHeader: true,
accepted_statuscodes_json: JSON.stringify([ "1000" ]),
timeout: 30,
};
const heartbeat = {
@ -104,7 +177,7 @@ describe("Websocket Test", {
};
const expected = {
msg: "101 - OK",
msg: "1000 - OK",
status: UP,
};
@ -118,6 +191,8 @@ describe("Websocket Test", {
const monitor = {
url: "wss://echo.websocket.org",
wsIgnoreSecWebsocketAcceptHeader: true,
accepted_statuscodes_json: JSON.stringify([ "1000" ]),
timeout: 30,
};
const heartbeat = {
@ -126,7 +201,7 @@ describe("Websocket Test", {
};
const expected = {
msg: "101 - OK",
msg: "1000 - OK",
status: UP,
};
@ -140,6 +215,8 @@ describe("Websocket Test", {
const monitor = {
url: "wss://example.org",
wsIgnoreSecWebsocketAcceptHeader: true,
accepted_statuscodes_json: JSON.stringify([ "1000" ]),
timeout: 30,
};
const heartbeat = {
@ -153,13 +230,15 @@ describe("Websocket Test", {
);
});
test("Secure Websocket with Subprotocol", async () => {
test("Secure WS no subprotocol support", async () => {
const websocketMonitor = new WebSocketMonitorType();
const monitor = {
url: "wss://echo.websocket.org",
wsIgnoreSecWebsocketAcceptHeader: false,
wsSubprotocol: "ocpp1.6",
accepted_statuscodes_json: JSON.stringify([ "1000" ]),
timeout: 30,
};
const heartbeat = {
@ -172,4 +251,88 @@ describe("Websocket Test", {
new Error("Server sent no subprotocol")
);
});
test("Multiple subprotocols invalid input", async () => {
const websocketMonitor = new WebSocketMonitorType();
const monitor = {
url: "wss://echo.websocket.org",
wsIgnoreSecWebsocketAcceptHeader: false,
wsSubprotocol: " # & ,ocpp2.0 [] , ocpp1.6 , ,, ; ",
accepted_statuscodes_json: JSON.stringify([ "1000" ]),
timeout: 30,
};
const heartbeat = {
msg: "",
status: PENDING,
};
await assert.rejects(
websocketMonitor.check(monitor, heartbeat, {}),
new SyntaxError("An invalid or duplicated subprotocol was specified")
);
});
test("Insecure WS subprotocol multiple spaces", async (t) => {
t.after(() => wss.close());
const websocketMonitor = new WebSocketMonitorType();
const wss = new WebSocketServer({ port: 8080,
handleProtocols: (protocols) => {
return Array.from(protocols).includes("test") ? "test" : null;
}
});
const monitor = {
url: "ws://localhost:8080",
wsIgnoreSecWebsocketAcceptHeader: false,
wsSubprotocol: "invalid , test ",
accepted_statuscodes_json: JSON.stringify([ "1000" ]),
timeout: 30,
};
const heartbeat = {
msg: "",
status: PENDING,
};
const expected = {
msg: "1000 - OK",
status: UP,
};
await websocketMonitor.check(monitor, heartbeat, {});
assert.deepStrictEqual(heartbeat, expected);
});
test("Insecure WS supports one subprotocol", async (t) => {
t.after(() => wss.close());
const websocketMonitor = new WebSocketMonitorType();
const wss = new WebSocketServer({ port: 8080,
handleProtocols: (protocols) => {
return Array.from(protocols).includes("test") ? "test" : null;
}
});
const monitor = {
url: "ws://localhost:8080",
wsIgnoreSecWebsocketAcceptHeader: false,
wsSubprotocol: "invalid,test",
accepted_statuscodes_json: JSON.stringify([ "1000" ]),
timeout: 30,
};
const heartbeat = {
msg: "",
status: PENDING,
};
const expected = {
msg: "1000 - OK",
status: UP,
};
await websocketMonitor.check(monitor, heartbeat, {});
assert.deepStrictEqual(heartbeat, expected);
});
});