feat: Tailscale ping monitor (#3178)

* Add boilerplate for tailscale ping

* tailscale initial commit draft

* Refactor TailscalePing & better error handling

Split check function into two methods and added async/await syntax for readability/modularity
Switched to promise-based error handling (takes into account different types of error such as "Execution error", "Error in output", "no matching peer", and "is local Tailscale IP") and throws them as JavaScript errors.

* Minor update

* minor update (again)

* Update server/monitor-types/tailscale-ping.js

Co-authored-by: Frank Elsinga <frank@elsinga.de>

* Update server/monitor-types/tailscale-ping.js

Co-authored-by: Frank Elsinga <frank@elsinga.de>

* Update server/monitor-types/tailscale-ping.js

Co-authored-by: Frank Elsinga <frank@elsinga.de>

* Update server/monitor-types/tailscale-ping.js

Co-authored-by: Frank Elsinga <frank@elsinga.de>

* timeout revision

* JSDoc

* Removed long explainers

* eslint tailscale-ping.js --fix

* reran eslint

* Fix: Use hostname rather than url

* Fixed NaN on monitor interval

now interval value is correctly passed to runTailscalePing

* Add warning message

---------

Co-authored-by: Louis Lam <louislam@users.noreply.github.com>
Co-authored-by: Frank Elsinga <frank@elsinga.de>
pull/3459/merge
SGprooo 1 year ago committed by GitHub
parent ddbf367011
commit 1d9a28e9ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,95 @@
const { MonitorType } = require("./monitor-type");
const { UP, log } = require("../../src/util");
const exec = require("child_process").exec;
/**
* A TailscalePing class extends the MonitorType.
* It runs Tailscale ping to monitor the status of a specific node.
*/
class TailscalePing extends MonitorType {
name = "tailscale-ping";
/**
* Checks the ping status of the URL associated with the monitor.
* It then parses the Tailscale ping command output to update the heatrbeat.
*
* @param {Object} monitor - The monitor object associated with the check.
* @param {Object} heartbeat - The heartbeat object to update.
* @throws Will throw an error if checking Tailscale ping encounters any error
*/
async check(monitor, heartbeat) {
try {
let tailscaleOutput = await this.runTailscalePing(monitor.hostname, monitor.interval);
this.parseTailscaleOutput(tailscaleOutput, heartbeat);
} catch (err) {
log.debug("Tailscale", 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.
* @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 cmd = `tailscale ping ${hostname}`;
log.debug("Tailscale", cmd);
return new Promise((resolve, reject) => {
let timeout = interval * 1000 * 0.8;
exec(cmd, { timeout: timeout }, (error, stdout, stderr) => {
// we may need to handle more cases if tailscale reports an error that isn't necessarily an error (such as not-logged in or DERP health-related issues)
if (error) {
reject(`Execution error: ${error.message}`);
return;
}
if (stderr) {
reject(`Error in output: ${stderr}`);
return;
}
resolve(stdout);
});
});
}
/**
* 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.
* @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 = line;
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,
};

@ -99,6 +99,7 @@ class UptimeKumaServer {
// Set Monitor Types // Set Monitor Types
UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType(); UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType();
UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing();
this.io = new Server(this.httpServer); this.io = new Server(this.httpServer);
} }
@ -345,3 +346,4 @@ module.exports = {
// Must be at the end to avoid circular dependencies // Must be at the end to avoid circular dependencies
const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type"); const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type");
const { TailscalePing } = require("./monitor-types/tailscale-ping");

@ -364,6 +364,7 @@
"deleteDockerHostMsg": "Are you sure want to delete this docker host for all monitors?", "deleteDockerHostMsg": "Are you sure want to delete this docker host for all monitors?",
"socket": "Socket", "socket": "Socket",
"tcp": "TCP / HTTP", "tcp": "TCP / HTTP",
"tailscalePingWarning": "In order to use the Tailscale Ping monitor, you need to install Uptime Kuma without Docker and also install Tailscale client on your server.",
"Docker Container": "Docker Container", "Docker Container": "Docker Container",
"Container Name / ID": "Container Name / ID", "Container Name / ID": "Container Name / ID",
"Docker Host": "Docker Host", "Docker Host": "Docker Host",

@ -82,10 +82,17 @@
<option value="redis"> <option value="redis">
Redis Redis
</option> </option>
<option value="tailscale-ping">
Tailscale Ping
</option>
</optgroup> </optgroup>
</select> </select>
</div> </div>
<div v-if="monitor.type === 'tailscale-ping'" class="alert alert-warning" role="alert">
{{ $t("tailscalePingWarning") }}
</div>
<!-- Friendly Name --> <!-- Friendly Name -->
<div class="my-3"> <div class="my-3">
<label for="name" class="form-label">{{ $t("Friendly Name") }}</label> <label for="name" class="form-label">{{ $t("Friendly Name") }}</label>
@ -221,8 +228,8 @@
</template> </template>
<!-- Hostname --> <!-- Hostname -->
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius only --> <!-- TCP Port / Ping / DNS / Steam / MQTT / Radius / Tailscale Ping only -->
<div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' ||monitor.type === 'mqtt' || monitor.type === 'radius'" class="my-3"> <div v-if="monitor.type === 'port' || monitor.type === 'ping' || monitor.type === 'dns' || monitor.type === 'steam' || monitor.type === 'gamedig' ||monitor.type === 'mqtt' || monitor.type === 'radius' || monitor.type === 'tailscale-ping'" class="my-3">
<label for="hostname" class="form-label">{{ $t("Hostname") }}</label> <label for="hostname" class="form-label">{{ $t("Hostname") }}</label>
<input id="hostname" v-model="monitor.hostname" type="text" class="form-control" :pattern="`${monitor.type === 'mqtt' ? mqttIpOrHostnameRegexPattern : ipOrHostnameRegexPattern}`" required> <input id="hostname" v-model="monitor.hostname" type="text" class="form-control" :pattern="`${monitor.type === 'mqtt' ? mqttIpOrHostnameRegexPattern : ipOrHostnameRegexPattern}`" required>
</div> </div>

Loading…
Cancel
Save