dms_tailscalectl/tailscalectl/lib.js

153 lines
5.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 ""
}
var safeClipboardTools = ["dms", "wl-copy"]
// Direct argv form — no sh -c, no escaping, no future injection surface.
// clipmanctl intentionally dropped (niche optional history manager, not needed on DMS + Wayland).
var clipboardTools = {
"dms": { argv: ["dms", "cl", "copy"] },
"wl-copy": { argv: ["wl-copy"] }
}
function validateClipboardTool(tool) {
return typeof tool === "string" && safeClipboardTools.includes(tool)
}
function buildCopyCommand(text, tool) {
const entry = clipboardTools[tool]
if (!entry) return null
// Pure argv — the Process will pass the string verbatim. No shell involved.
return [...entry.argv, text]
}
function nextClipboardTool(currentTool) {
var idx = safeClipboardTools.indexOf(currentTool)
if (idx < 0) return safeClipboardTools[0]
return safeClipboardTools[(idx + 1) % safeClipboardTools.length]
}
function allClipboardTools() {
return safeClipboardTools.slice()
}
function getStrings() {
return {
header: "Tailscale",
connected: "Connected",
disconnected: "Disconnected",
exitNodePrefix: "Exit node: ",
none: "None",
copied: function (text) { return "Copied " + text + " to clipboard" },
noClipboardTool: "No clipboard tool found",
invalidClipboardCommand: "Invalid clipboard command",
clearExitNode: "×",
setExitNode: "↗",
invalidExitNodeHostname: "Invalid exit node hostname"
}
}
// 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 before we
// concatenate them into a command argument. Direct argv is already used
// (see #49), but we still fail closed on obviously malicious input.
function isValidExitNodeHostname(hostname) {
if (typeof hostname !== "string") return false;
if (hostname === "") return true; // clear command
// Permissive enough for real Tailscale hostnames while rejecting
// classic shell metacharacters and control characters.
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;
}
// CommonJS export for Node.js tests (ignored by QML)
if (typeof module !== "undefined" && module.exports) {
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, validateClipboardTool, buildCopyCommand, nextClipboardTool, allClipboardTools, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode }
}