Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Tailscale ping monitor #3178

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions server/monitor-types/tailscale-ping.js
Original file line number Diff line number Diff line change
@@ -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 {

SandiyosDev marked this conversation as resolved.
Show resolved Hide resolved
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) {
louislam marked this conversation as resolved.
Show resolved Hide resolved
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?)
louislam marked this conversation as resolved.
Show resolved Hide resolved
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,
};
2 changes: 2 additions & 0 deletions server/uptime-kuma-server.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class UptimeKumaServer {

// Set Monitor Types
UptimeKumaServer.monitorTypeList["real-browser"] = new RealBrowserMonitorType();
UptimeKumaServer.monitorTypeList["tailscale-ping"] = new TailscalePing();

this.io = new Server(this.httpServer);
}
Expand Down Expand Up @@ -345,3 +346,4 @@ module.exports = {

// Must be at the end to avoid circular dependencies
const { RealBrowserMonitorType } = require("./monitor-types/real-browser-monitor-type");
const { TailscalePing } = require("./monitor-types/tailscale-ping");
1 change: 1 addition & 0 deletions src/lang/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,7 @@
"deleteDockerHostMsg": "Are you sure want to delete this docker host for all monitors?",
"socket": "Socket",
"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",
"Container Name / ID": "Container Name / ID",
"Docker Host": "Docker Host",
Expand Down
11 changes: 9 additions & 2 deletions src/pages/EditMonitor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,17 @@
<option value="redis">
Redis
</option>
<option value="tailscale-ping">
Tailscale Ping
</option>
</optgroup>
</select>
</div>

<div v-if="monitor.type === 'tailscale-ping'" class="alert alert-warning" role="alert">
{{ $t("tailscalePingWarning") }}
</div>

<!-- Friendly Name -->
<div class="my-3">
<label for="name" class="form-label">{{ $t("Friendly Name") }}</label>
Expand Down Expand Up @@ -221,8 +228,8 @@
</template>

<!-- Hostname -->
<!-- TCP Port / Ping / DNS / Steam / MQTT / Radius 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">
<!-- 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' || monitor.type === 'tailscale-ping'" class="my-3">
<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>
</div>
Expand Down