dms_tailscalectl/tailscalectl/lib.js

140 lines
4.5 KiB
JavaScript
Raw Normal View History

function parsePeers(peerMap) {
if (!peerMap) { return []; }
return Object.keys(peerMap).map(function (key) {
if (!Object.prototype.hasOwnProperty.call(peerMap, key)) { return null; }
var p = peerMap[key];
return {
hostname: p.HostName || key,
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
online: p.Online || false,
exitNode: p.ExitNodeOption || false
};
}).filter(function (peer) { return peer !== null; });
}
function makeExitNodeCommand(hostname) {
if (!isValidExitNodeHostname(hostname)) {
return null;
}
if (hostname === "") {
return ["tailscale", "set", "--exit-node="];
}
return ["tailscale", "set", "--exit-node=" + hostname];
}
function findActiveExitNode(peerMap) {
if (!peerMap) { return ""; }
for (const key in peerMap) {
if (!Object.prototype.hasOwnProperty.call(peerMap, key)) { continue; }
const p = peerMap[key];
if (p.ExitNode) {
return p.HostName || key;
}
}
return "";
}
const clipboardTools = [
{ argv: ["dms", "cl", "copy"] },
{ argv: ["wl-copy"] }
];
function getClipboardCommands(text) {
return clipboardTools.map(function (tool) {
return tool.argv.concat([text]);
});
}
function getStrings() {
return {
header: "Tailscale",
connected: "Connected",
disconnected: "Disconnected",
exitNodePrefix: "Exit node: ",
none: "None",
copied: function (text) { return "Copied " + text + " to clipboard"; },
clearExitNode: "×",
setExitNode: "↗",
invalidExitNodeHostname: "Invalid exit node hostname"
};
}
2026-05-22 21:47:41 +00:00
// Light UI predicates — keep the view thin.
function shouldShowClearExitNode(currentExitNode) {
return currentExitNode !== "";
}
function isActiveExitNode(currentExitNode, hostname) {
return currentExitNode === hostname;
}
// Security: validate hostnames coming from tailscale status JSON.
// Fail closed on obviously malicious input.
function isValidExitNodeHostname(hostname) {
if (typeof hostname !== "string") { return false; }
if (hostname === "") { return true; }
return /^[a-zA-Z0-9]([a-zA-Z0-9-_.]{0,62}[a-zA-Z0-9])?$/.test(hostname);
}
function parseStatusResult(jsonText) {
try {
const data = JSON.parse(jsonText);
return {
isConnected: data.BackendState === "Running",
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
currentExitNode: findActiveExitNode(data.Peer || {}),
peers: parsePeers(data.Peer || {})
};
} catch (e) {
return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] };
}
}
function buildToggleCommand(isConnected) {
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"];
}
// Single source of truth for the status command used for on-demand and post-action verification.
function getStatusCommand() {
return ["tailscale", "status", "--json"];
}
function errorMessage(cmd) {
var messages = {
"up": "Failed to connect to Tailscale",
"connect": "Failed to connect to Tailscale",
"down": "Failed to disconnect from Tailscale",
"disconnect": "Failed to disconnect from Tailscale",
"set": "Failed to set exit node",
"status": "Failed to read Tailscale status",
"clipboard": "Error copying to clipboard"
};
return messages[cmd] || "Tailscale command failed";
}
// Central error formatting for the widget. Used by both success and error paths.
// detail is optional truncated stderr or extra context.
function formatError(action, detail) {
var base = errorMessage(action);
if (detail && detail.length > 0) {
var truncated = detail.length > 120 ? detail.slice(0, 120) : detail;
return base + " — " + truncated;
}
return base;
}
const PendingAction = Object.freeze({
TOGGLE: "toggle"
});
function commandForPendingAction(pending, freshIsConnected) {
if (pending === PendingAction.TOGGLE) {
return buildToggleCommand(freshIsConnected);
}
return null;
}
if (typeof module !== "undefined" && module.exports) {
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode, PendingAction, commandForPendingAction };
}