diff options
Diffstat (limited to 'server/monitor-types')
-rw-r--r-- | server/monitor-types/dns.js | 85 | ||||
-rw-r--r-- | server/monitor-types/mongodb.js | 63 | ||||
-rw-r--r-- | server/monitor-types/monitor-type.js | 31 | ||||
-rw-r--r-- | server/monitor-types/mqtt.js | 117 | ||||
-rw-r--r-- | server/monitor-types/rabbitmq.js | 67 | ||||
-rw-r--r-- | server/monitor-types/real-browser-monitor-type.js | 273 | ||||
-rw-r--r-- | server/monitor-types/snmp.js | 63 | ||||
-rw-r--r-- | server/monitor-types/tailscale-ping.js | 77 |
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, +}; |