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 } }