summaryrefslogtreecommitdiffstats
path: root/server/monitor-types
diff options
context:
space:
mode:
Diffstat (limited to 'server/monitor-types')
-rw-r--r--server/monitor-types/dns.js85
-rw-r--r--server/monitor-types/mongodb.js63
-rw-r--r--server/monitor-types/monitor-type.js31
-rw-r--r--server/monitor-types/mqtt.js117
-rw-r--r--server/monitor-types/rabbitmq.js67
-rw-r--r--server/monitor-types/real-browser-monitor-type.js273
-rw-r--r--server/monitor-types/snmp.js63
-rw-r--r--server/monitor-types/tailscale-ping.js77
8 files changed, 776 insertions, 0 deletions
diff --git a/server/monitor-types/dns.js b/server/monitor-types/dns.js
new file mode 100644
index 0000000..8b87932
--- /dev/null
+++ b/server/monitor-types/dns.js
@@ -0,0 +1,85 @@
+const { MonitorType } = require("./monitor-type");
+const { UP, DOWN } = require("../../src/util");
+const dayjs = require("dayjs");
+const { dnsResolve } = require("../util-server");
+const { R } = require("redbean-node");
+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 DnsMonitorType extends MonitorType {
+ name = "dns";
+
+ supportsConditions = true;
+
+ conditionVariables = [
+ new ConditionVariable("record", defaultStringOperators ),
+ ];
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, _server) {
+ let startTime = dayjs().valueOf();
+ let dnsMessage = "";
+
+ let dnsRes = await dnsResolve(monitor.hostname, monitor.dns_resolve_server, monitor.port, monitor.dns_resolve_type);
+ heartbeat.ping = dayjs().valueOf() - startTime;
+
+ const conditions = ConditionExpressionGroup.fromMonitor(monitor);
+ let conditionsResult = true;
+ const handleConditions = (data) => conditions ? evaluateExpressionGroup(conditions, data) : true;
+
+ switch (monitor.dns_resolve_type) {
+ case "A":
+ case "AAAA":
+ case "TXT":
+ case "PTR":
+ dnsMessage = `Records: ${dnsRes.join(" | ")}`;
+ conditionsResult = dnsRes.some(record => handleConditions({ record }));
+ break;
+
+ case "CNAME":
+ dnsMessage = dnsRes[0];
+ conditionsResult = handleConditions({ record: dnsRes[0] });
+ break;
+
+ case "CAA":
+ dnsMessage = dnsRes[0].issue;
+ conditionsResult = handleConditions({ record: dnsRes[0].issue });
+ break;
+
+ case "MX":
+ dnsMessage = dnsRes.map(record => `Hostname: ${record.exchange} - Priority: ${record.priority}`).join(" | ");
+ conditionsResult = dnsRes.some(record => handleConditions({ record: record.exchange }));
+ break;
+
+ case "NS":
+ dnsMessage = `Servers: ${dnsRes.join(" | ")}`;
+ conditionsResult = dnsRes.some(record => handleConditions({ record }));
+ break;
+
+ case "SOA":
+ dnsMessage = `NS-Name: ${dnsRes.nsname} | Hostmaster: ${dnsRes.hostmaster} | Serial: ${dnsRes.serial} | Refresh: ${dnsRes.refresh} | Retry: ${dnsRes.retry} | Expire: ${dnsRes.expire} | MinTTL: ${dnsRes.minttl}`;
+ conditionsResult = handleConditions({ record: dnsRes.nsname });
+ break;
+
+ case "SRV":
+ dnsMessage = dnsRes.map(record => `Name: ${record.name} | Port: ${record.port} | Priority: ${record.priority} | Weight: ${record.weight}`).join(" | ");
+ conditionsResult = dnsRes.some(record => handleConditions({ record: record.name }));
+ break;
+ }
+
+ if (monitor.dns_last_result !== dnsMessage && dnsMessage !== undefined) {
+ await R.exec("UPDATE `monitor` SET dns_last_result = ? WHERE id = ? ", [ dnsMessage, monitor.id ]);
+ }
+
+ heartbeat.msg = dnsMessage;
+ heartbeat.status = conditionsResult ? UP : DOWN;
+ }
+}
+
+module.exports = {
+ DnsMonitorType,
+};
diff --git a/server/monitor-types/mongodb.js b/server/monitor-types/mongodb.js
new file mode 100644
index 0000000..73747db
--- /dev/null
+++ b/server/monitor-types/mongodb.js
@@ -0,0 +1,63 @@
+const { MonitorType } = require("./monitor-type");
+const { UP } = require("../../src/util");
+const { MongoClient } = require("mongodb");
+const jsonata = require("jsonata");
+
+class MongodbMonitorType extends MonitorType {
+ name = "mongodb";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, _server) {
+ let command = { "ping": 1 };
+ if (monitor.databaseQuery) {
+ command = JSON.parse(monitor.databaseQuery);
+ }
+
+ let result = await this.runMongodbCommand(monitor.databaseConnectionString, command);
+
+ if (result["ok"] !== 1) {
+ throw new Error("MongoDB command failed");
+ } else {
+ heartbeat.msg = "Command executed successfully";
+ }
+
+ if (monitor.jsonPath) {
+ let expression = jsonata(monitor.jsonPath);
+ result = await expression.evaluate(result);
+ if (result) {
+ heartbeat.msg = "Command executed successfully and the jsonata expression produces a result.";
+ } else {
+ throw new Error("Queried value not found.");
+ }
+ }
+
+ if (monitor.expectedValue) {
+ if (result.toString() === monitor.expectedValue) {
+ heartbeat.msg = "Command executed successfully and expected value was found";
+ } else {
+ throw new Error("Query executed, but value is not equal to expected value, value was: [" + JSON.stringify(result) + "]");
+ }
+ }
+
+ heartbeat.status = UP;
+ }
+
+ /**
+ * Connect to and run MongoDB command on a MongoDB database
+ * @param {string} connectionString The database connection string
+ * @param {object} command MongoDB command to run on the database
+ * @returns {Promise<(string[] | object[] | object)>} Response from server
+ */
+ async runMongodbCommand(connectionString, command) {
+ let client = await MongoClient.connect(connectionString);
+ let result = await client.db().command(command);
+ await client.close();
+ return result;
+ }
+}
+
+module.exports = {
+ MongodbMonitorType,
+};
diff --git a/server/monitor-types/monitor-type.js b/server/monitor-types/monitor-type.js
new file mode 100644
index 0000000..8f3cbca
--- /dev/null
+++ b/server/monitor-types/monitor-type.js
@@ -0,0 +1,31 @@
+class MonitorType {
+ name = undefined;
+
+ /**
+ * Whether or not this type supports monitor conditions. Controls UI visibility in monitor form.
+ * @type {boolean}
+ */
+ supportsConditions = false;
+
+ /**
+ * Variables supported by this type. e.g. an HTTP type could have a "response_code" variable to test against.
+ * This property controls the choices displayed in the monitor edit form.
+ * @type {import("../monitor-conditions/variables").ConditionVariable[]}
+ */
+ conditionVariables = [];
+
+ /**
+ * Run the monitoring check on the given monitor
+ * @param {Monitor} monitor Monitor to check
+ * @param {Heartbeat} heartbeat Monitor heartbeat to update
+ * @param {UptimeKumaServer} server Uptime Kuma server
+ * @returns {Promise<void>}
+ */
+ async check(monitor, heartbeat, server) {
+ throw new Error("You need to override check()");
+ }
+}
+
+module.exports = {
+ MonitorType,
+};
diff --git a/server/monitor-types/mqtt.js b/server/monitor-types/mqtt.js
new file mode 100644
index 0000000..ad734ce
--- /dev/null
+++ b/server/monitor-types/mqtt.js
@@ -0,0 +1,117 @@
+const { MonitorType } = require("./monitor-type");
+const { log, UP } = require("../../src/util");
+const mqtt = require("mqtt");
+const jsonata = require("jsonata");
+
+class MqttMonitorType extends MonitorType {
+ name = "mqtt";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, server) {
+ const receivedMessage = await this.mqttAsync(monitor.hostname, monitor.mqttTopic, {
+ port: monitor.port,
+ username: monitor.mqttUsername,
+ password: monitor.mqttPassword,
+ interval: monitor.interval,
+ });
+
+ if (monitor.mqttCheckType == null || monitor.mqttCheckType === "") {
+ // use old default
+ monitor.mqttCheckType = "keyword";
+ }
+
+ if (monitor.mqttCheckType === "keyword") {
+ if (receivedMessage != null && receivedMessage.includes(monitor.mqttSuccessMessage)) {
+ heartbeat.msg = `Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`;
+ heartbeat.status = UP;
+ } else {
+ throw Error(`Message Mismatch - Topic: ${monitor.mqttTopic}; Message: ${receivedMessage}`);
+ }
+ } else if (monitor.mqttCheckType === "json-query") {
+ const parsedMessage = JSON.parse(receivedMessage);
+
+ let expression = jsonata(monitor.jsonPath);
+
+ let result = await expression.evaluate(parsedMessage);
+
+ if (result?.toString() === monitor.expectedValue) {
+ heartbeat.msg = "Message received, expected value is found";
+ heartbeat.status = UP;
+ } else {
+ throw new Error("Message received but value is not equal to expected value, value was: [" + result + "]");
+ }
+ } else {
+ throw Error("Unknown MQTT Check Type");
+ }
+ }
+
+ /**
+ * Connect to MQTT Broker, subscribe to topic and receive message as String
+ * @param {string} hostname Hostname / address of machine to test
+ * @param {string} topic MQTT topic
+ * @param {object} options MQTT options. Contains port, username,
+ * password and interval (interval defaults to 20)
+ * @returns {Promise<string>} Received MQTT message
+ */
+ mqttAsync(hostname, topic, options = {}) {
+ return new Promise((resolve, reject) => {
+ const { port, username, password, interval = 20 } = options;
+
+ // Adds MQTT protocol to the hostname if not already present
+ if (!/^(?:http|mqtt|ws)s?:\/\//.test(hostname)) {
+ hostname = "mqtt://" + hostname;
+ }
+
+ const timeoutID = setTimeout(() => {
+ log.debug("mqtt", "MQTT timeout triggered");
+ client.end();
+ reject(new Error("Timeout, Message not received"));
+ }, interval * 1000 * 0.8);
+
+ const mqttUrl = `${hostname}:${port}`;
+
+ log.debug("mqtt", `MQTT connecting to ${mqttUrl}`);
+
+ let client = mqtt.connect(mqttUrl, {
+ username,
+ password,
+ clientId: "uptime-kuma_" + Math.random().toString(16).substr(2, 8)
+ });
+
+ client.on("connect", () => {
+ log.debug("mqtt", "MQTT connected");
+
+ try {
+ client.subscribe(topic, () => {
+ log.debug("mqtt", "MQTT subscribed to topic");
+ });
+ } catch (e) {
+ client.end();
+ clearTimeout(timeoutID);
+ reject(new Error("Cannot subscribe topic"));
+ }
+ });
+
+ client.on("error", (error) => {
+ client.end();
+ clearTimeout(timeoutID);
+ reject(error);
+ });
+
+ client.on("message", (messageTopic, message) => {
+ if (messageTopic === topic) {
+ client.end();
+ clearTimeout(timeoutID);
+ resolve(message.toString("utf8"));
+ }
+ });
+
+ });
+ }
+}
+
+module.exports = {
+ MqttMonitorType,
+};
diff --git a/server/monitor-types/rabbitmq.js b/server/monitor-types/rabbitmq.js
new file mode 100644
index 0000000..165a0ed
--- /dev/null
+++ b/server/monitor-types/rabbitmq.js
@@ -0,0 +1,67 @@
+const { MonitorType } = require("./monitor-type");
+const { log, UP, DOWN } = require("../../src/util");
+const { axiosAbortSignal } = require("../util-server");
+const axios = require("axios");
+
+class RabbitMqMonitorType extends MonitorType {
+ name = "rabbitmq";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, server) {
+ let baseUrls = [];
+ try {
+ baseUrls = JSON.parse(monitor.rabbitmqNodes);
+ } catch (error) {
+ throw new Error("Invalid RabbitMQ Nodes");
+ }
+
+ heartbeat.status = DOWN;
+ for (let baseUrl of baseUrls) {
+ try {
+ // Without a trailing slash, path in baseUrl will be removed. https://example.com/api -> https://example.com
+ if ( !baseUrl.endsWith("/") ) {
+ baseUrl += "/";
+ }
+ const options = {
+ // Do not start with slash, it will strip the trailing slash from baseUrl
+ url: new URL("api/health/checks/alarms/", baseUrl).href,
+ method: "get",
+ timeout: monitor.timeout * 1000,
+ headers: {
+ "Accept": "application/json",
+ "Authorization": "Basic " + Buffer.from(`${monitor.rabbitmqUsername || ""}:${monitor.rabbitmqPassword || ""}`).toString("base64"),
+ },
+ signal: axiosAbortSignal((monitor.timeout + 10) * 1000),
+ // Capture reason for 503 status
+ validateStatus: (status) => status === 200 || status === 503,
+ };
+ log.debug("monitor", `[${monitor.name}] Axios Request: ${JSON.stringify(options)}`);
+ const res = await axios.request(options);
+ log.debug("monitor", `[${monitor.name}] Axios Response: status=${res.status} body=${JSON.stringify(res.data)}`);
+ if (res.status === 200) {
+ heartbeat.status = UP;
+ heartbeat.msg = "OK";
+ break;
+ } else if (res.status === 503) {
+ heartbeat.msg = res.data.reason;
+ } else {
+ heartbeat.msg = `${res.status} - ${res.statusText}`;
+ }
+ } catch (error) {
+ if (axios.isCancel(error)) {
+ heartbeat.msg = "Request timed out";
+ log.debug("monitor", `[${monitor.name}] Request timed out`);
+ } else {
+ log.debug("monitor", `[${monitor.name}] Axios Error: ${JSON.stringify(error.message)}`);
+ heartbeat.msg = error.message;
+ }
+ }
+ }
+ }
+}
+
+module.exports = {
+ RabbitMqMonitorType,
+};
diff --git a/server/monitor-types/real-browser-monitor-type.js b/server/monitor-types/real-browser-monitor-type.js
new file mode 100644
index 0000000..f1219af
--- /dev/null
+++ b/server/monitor-types/real-browser-monitor-type.js
@@ -0,0 +1,273 @@
+const { MonitorType } = require("./monitor-type");
+const { chromium } = require("playwright-core");
+const { UP, log } = require("../../src/util");
+const { Settings } = require("../settings");
+const commandExistsSync = require("command-exists").sync;
+const childProcess = require("child_process");
+const path = require("path");
+const Database = require("../database");
+const jwt = require("jsonwebtoken");
+const config = require("../config");
+const { RemoteBrowser } = require("../remote-browser");
+
+/**
+ * Cached instance of a browser
+ * @type {import ("playwright-core").Browser}
+ */
+let browser = null;
+
+let allowedList = [];
+let lastAutoDetectChromeExecutable = null;
+
+if (process.platform === "win32") {
+ allowedList.push(process.env.LOCALAPPDATA + "\\Google\\Chrome\\Application\\chrome.exe");
+ allowedList.push(process.env.PROGRAMFILES + "\\Google\\Chrome\\Application\\chrome.exe");
+ allowedList.push(process.env["ProgramFiles(x86)"] + "\\Google\\Chrome\\Application\\chrome.exe");
+
+ // Allow Chromium too
+ allowedList.push(process.env.LOCALAPPDATA + "\\Chromium\\Application\\chrome.exe");
+ allowedList.push(process.env.PROGRAMFILES + "\\Chromium\\Application\\chrome.exe");
+ allowedList.push(process.env["ProgramFiles(x86)"] + "\\Chromium\\Application\\chrome.exe");
+
+ // Allow MS Edge
+ allowedList.push(process.env["ProgramFiles(x86)"] + "\\Microsoft\\Edge\\Application\\msedge.exe");
+
+ // For Loop A to Z
+ for (let i = 65; i <= 90; i++) {
+ let drive = String.fromCharCode(i);
+ allowedList.push(drive + ":\\Program Files\\Google\\Chrome\\Application\\chrome.exe");
+ allowedList.push(drive + ":\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe");
+ }
+
+} else if (process.platform === "linux") {
+ allowedList = [
+ "chromium",
+ "chromium-browser",
+ "google-chrome",
+
+ "/usr/bin/chromium",
+ "/usr/bin/chromium-browser",
+ "/usr/bin/google-chrome",
+ "/snap/bin/chromium", // Ubuntu
+ ];
+} else if (process.platform === "darwin") {
+ allowedList = [
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
+ ];
+}
+
+/**
+ * Is the executable path allowed?
+ * @param {string} executablePath Path to executable
+ * @returns {Promise<boolean>} 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;
+ }
+
+ // Check if the executablePath is in the list of allowed executables
+ return allowedList.includes(executablePath);
+}
+
+/**
+ * Get the current instance of the browser. If there isn't one, create
+ * it.
+ * @returns {Promise<import ("playwright-core").Browser>} The browser
+ */
+async function getBrowser() {
+ if (browser && browser.isConnected()) {
+ return browser;
+ } else {
+ let executablePath = await Settings.get("chromeExecutable");
+
+ executablePath = await prepareChromeExecutable(executablePath);
+
+ browser = await chromium.launch({
+ //headless: false,
+ executablePath,
+ });
+
+ return browser;
+ }
+}
+
+/**
+ * Get the current instance of the browser. If there isn't one, create it
+ * @param {integer} remoteBrowserID Path to executable
+ * @param {integer} userId User ID
+ * @returns {Promise<Browser>} The browser
+ */
+async function getRemoteBrowser(remoteBrowserID, userId) {
+ let remoteBrowser = await RemoteBrowser.get(remoteBrowserID, userId);
+ log.debug("MONITOR", `Using remote browser: ${remoteBrowser.name} (${remoteBrowser.id})`);
+ browser = await chromium.connect(remoteBrowser.url);
+ return browser;
+}
+
+/**
+ * Prepare the chrome executable path
+ * @param {string} executablePath Path to chrome executable
+ * @returns {Promise<string>} Executable path
+ */
+async function prepareChromeExecutable(executablePath) {
+ // Special code for using the playwright_chromium
+ if (typeof executablePath === "string" && executablePath.toLocaleLowerCase() === "#playwright_chromium") {
+ // Set to undefined = use playwright_chromium
+ executablePath = undefined;
+ } else if (!executablePath) {
+ if (process.env.UPTIME_KUMA_IS_CONTAINER) {
+ executablePath = "/usr/bin/chromium";
+
+ // Install chromium in container via apt install
+ if ( !commandExistsSync(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));
+ }
+ });
+ });
+ }
+
+ } else {
+ executablePath = findChrome(allowedList);
+ }
+ } else {
+ // User specified a path
+ // Check if the executablePath is in the list of allowed
+ if (!await isAllowedChromeExecutable(executablePath)) {
+ throw new Error("This Chromium executable path is not allowed by default. If you are sure this is safe, please add an environment variable UPTIME_KUMA_ALLOW_ALL_CHROME_EXEC=1 to allow it.");
+ }
+ }
+ return executablePath;
+}
+
+/**
+ * Find the chrome executable
+ * @param {any[]} executables Executables to search through
+ * @returns {any} Executable
+ * @throws Could not find executable
+ */
+function findChrome(executables) {
+ // Use the last working executable, so we don't have to search for it again
+ if (lastAutoDetectChromeExecutable) {
+ if (commandExistsSync(lastAutoDetectChromeExecutable)) {
+ return lastAutoDetectChromeExecutable;
+ }
+ }
+
+ for (let executable of executables) {
+ if (commandExistsSync(executable)) {
+ lastAutoDetectChromeExecutable = executable;
+ return executable;
+ }
+ }
+ throw new Error("Chromium not found, please specify Chromium executable path in the settings page.");
+}
+
+/**
+ * Reset chrome
+ * @returns {Promise<void>}
+ */
+async function resetChrome() {
+ if (browser) {
+ await browser.close();
+ browser = null;
+ }
+}
+
+/**
+ * Test if the chrome executable is valid and return the version
+ * @param {string} executablePath Path to executable
+ * @returns {Promise<string>} Chrome version
+ */
+async function testChrome(executablePath) {
+ try {
+ executablePath = await prepareChromeExecutable(executablePath);
+
+ log.info("Chromium", "Testing Chromium executable: " + executablePath);
+
+ const browser = await chromium.launch({
+ executablePath,
+ });
+ const version = browser.version();
+ await browser.close();
+ return version;
+ } catch (e) {
+ throw new Error(e.message);
+ }
+}
+// test remote browser
+/**
+ * @param {string} remoteBrowserURL Remote Browser URL
+ * @returns {Promise<boolean>} Returns if connection worked
+ */
+async function testRemoteBrowser(remoteBrowserURL) {
+ try {
+ const browser = await chromium.connect(remoteBrowserURL);
+ browser.version();
+ await browser.close();
+ return true;
+ } catch (e) {
+ throw new Error(e.message);
+ }
+}
+class RealBrowserMonitorType extends MonitorType {
+
+ name = "real-browser";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, server) {
+ const browser = monitor.remote_browser ? await getRemoteBrowser(monitor.remote_browser, monitor.user_id) : await getBrowser();
+ const context = await browser.newContext();
+ const page = await context.newPage();
+
+ const res = await page.goto(monitor.url, {
+ waitUntil: "networkidle",
+ timeout: monitor.interval * 1000 * 0.8,
+ });
+
+ let filename = jwt.sign(monitor.id, server.jwtSecret) + ".png";
+
+ await page.screenshot({
+ path: path.join(Database.screenshotDir, filename),
+ });
+
+ await context.close();
+
+ if (res.status() >= 200 && res.status() < 400) {
+ heartbeat.status = UP;
+ heartbeat.msg = res.status();
+
+ const timing = res.request().timing();
+ heartbeat.ping = timing.responseEnd;
+ } else {
+ throw new Error(res.status() + "");
+ }
+ }
+}
+
+module.exports = {
+ RealBrowserMonitorType,
+ testChrome,
+ resetChrome,
+ testRemoteBrowser,
+};
diff --git a/server/monitor-types/snmp.js b/server/monitor-types/snmp.js
new file mode 100644
index 0000000..a1760fa
--- /dev/null
+++ b/server/monitor-types/snmp.js
@@ -0,0 +1,63 @@
+const { MonitorType } = require("./monitor-type");
+const { UP, log, evaluateJsonQuery } = require("../../src/util");
+const snmp = require("net-snmp");
+
+class SNMPMonitorType extends MonitorType {
+ name = "snmp";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, _server) {
+ let session;
+ try {
+ const sessionOptions = {
+ port: monitor.port || "161",
+ retries: monitor.maxretries,
+ timeout: monitor.timeout * 1000,
+ version: snmp.Version[monitor.snmpVersion],
+ };
+ session = snmp.createSession(monitor.hostname, monitor.radiusPassword, sessionOptions);
+
+ // Handle errors during session creation
+ session.on("error", (error) => {
+ throw new Error(`Error creating SNMP session: ${error.message}`);
+ });
+
+ const varbinds = await new Promise((resolve, reject) => {
+ session.get([ monitor.snmpOid ], (error, varbinds) => {
+ error ? reject(error) : resolve(varbinds);
+ });
+ });
+ log.debug("monitor", `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})`);
+ }
+
+ if (varbinds[0].type === snmp.ObjectType.NoSuchInstance) {
+ throw new Error(`The SNMP query returned that no instance exists for OID ${monitor.snmpOid}`);
+ }
+
+ // We restrict querying to one OID per monitor, therefore `varbinds[0]` will always contain the value we're interested in.
+ const value = varbinds[0].value;
+
+ const { status, response } = await evaluateJsonQuery(value, monitor.jsonPath, monitor.jsonPathOperator, monitor.expectedValue);
+
+ if (status) {
+ heartbeat.status = UP;
+ heartbeat.msg = `JSON query passes (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`;
+ } else {
+ throw new Error(`JSON query does not pass (comparing ${response} ${monitor.jsonPathOperator} ${monitor.expectedValue})`);
+ }
+ } finally {
+ if (session) {
+ session.close();
+ }
+ }
+ }
+}
+
+module.exports = {
+ SNMPMonitorType,
+};
diff --git a/server/monitor-types/tailscale-ping.js b/server/monitor-types/tailscale-ping.js
new file mode 100644
index 0000000..8537651
--- /dev/null
+++ b/server/monitor-types/tailscale-ping.js
@@ -0,0 +1,77 @@
+const { MonitorType } = require("./monitor-type");
+const { UP } = require("../../src/util");
+const childProcessAsync = require("promisify-child-process");
+
+class TailscalePing extends MonitorType {
+ name = "tailscale-ping";
+
+ /**
+ * @inheritdoc
+ */
+ async check(monitor, heartbeat, _server) {
+ try {
+ let tailscaleOutput = await this.runTailscalePing(monitor.hostname, monitor.interval);
+ this.parseTailscaleOutput(tailscaleOutput, heartbeat);
+ } catch (err) {
+ // trigger log function somewhere to display a notification or alert to the user (but how?)
+ throw new Error(`Error checking Tailscale ping: ${err}`);
+ }
+ }
+
+ /**
+ * Runs the Tailscale ping command to the given URL.
+ * @param {string} hostname The hostname to ping.
+ * @param {number} interval Interval to send ping
+ * @returns {Promise<string>} A Promise that resolves to the output of the Tailscale ping command
+ * @throws Will throw an error if the command execution encounters any error.
+ */
+ async runTailscalePing(hostname, interval) {
+ let timeout = interval * 1000 * 0.8;
+ let res = await childProcessAsync.spawn("tailscale", [ "ping", "--c", "1", hostname ], {
+ timeout: timeout,
+ encoding: "utf8",
+ });
+ if (res.stderr && res.stderr.toString()) {
+ throw new Error(`Error in output: ${res.stderr.toString()}`);
+ }
+ if (res.stdout && res.stdout.toString()) {
+ return res.stdout.toString();
+ } else {
+ throw new Error("No output from Tailscale ping");
+ }
+ }
+
+ /**
+ * Parses the output of the Tailscale ping command to update the heartbeat.
+ * @param {string} tailscaleOutput The output of the Tailscale ping command.
+ * @param {object} heartbeat The heartbeat object to update.
+ * @returns {void}
+ * @throws Will throw an eror if the output contains any unexpected string.
+ */
+ parseTailscaleOutput(tailscaleOutput, heartbeat) {
+ let lines = tailscaleOutput.split("\n");
+
+ for (let line of lines) {
+ if (line.includes("pong from")) {
+ heartbeat.status = UP;
+ let time = line.split(" in ")[1].split(" ")[0];
+ heartbeat.ping = parseInt(time);
+ heartbeat.msg = "OK";
+ break;
+ } else if (line.includes("timed out")) {
+ throw new Error(`Ping timed out: "${line}"`);
+ // Immediately throws upon "timed out" message, the server is expected to re-call the check function
+ } else if (line.includes("no matching peer")) {
+ throw new Error(`Nonexistant or inaccessible due to ACLs: "${line}"`);
+ } else if (line.includes("is local Tailscale IP")) {
+ throw new Error(`Tailscale only works if used on other machines: "${line}"`);
+ } else if (line !== "") {
+ throw new Error(`Unexpected output: "${line}"`);
+ }
+ }
+ }
+}
+
+module.exports = {
+ TailscalePing,
+};