function parsePeers(peerMap) { if (!peerMap) { return []; } return Object.keys(peerMap).map(function (key) { 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 }; }); } 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 of Object.keys(peerMap)) { const p = peerMap[key]; if (p.ExitNode) { return p.HostName || key; } } return ""; } function findActiveExitNodePeer(peerMap) { if (!peerMap) { return null; } for (const key of Object.keys(peerMap)) { const p = peerMap[key]; if (p.ExitNode) { return p; } } return null; } // Strip host from endpoint strings like "1.2.3.4:41641" or "[2001:db8::1]:41641". function hostFromEndpoint(endpoint) { if (typeof endpoint !== "string" || endpoint === "") { return ""; } if (endpoint.charAt(0) === "[") { var end = endpoint.indexOf("]"); if (end > 1) { return endpoint.slice(1, end); } return ""; } // IPv4 host:port — only one colon before the port. var colon = endpoint.lastIndexOf(":"); if (colon > -1 && endpoint.indexOf(":") === colon) { return endpoint.slice(0, colon); } // Bare address (or unusual form): return as-is. return endpoint; } // IPv4 only for display simplicity. Reject private, loopback, link-local, and CGNAT (100.64/10). function isPublicIPv4(ip) { if (typeof ip !== "string" || ip === "") { return false; } var m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip); if (!m) { return false; } var a = Number(m[1]); var b = Number(m[2]); var c = Number(m[3]); var d = Number(m[4]); if (a > 255 || b > 255 || c > 255 || d > 255) { return false; } if (a === 0 || a === 127 || a >= 224) { return false; } // 10.0.0.0/8 if (a === 10) { return false; } // 172.16.0.0/12 if (a === 172 && b >= 16 && b <= 31) { return false; } // 192.168.0.0/16 if (a === 192 && b === 168) { return false; } // 100.64.0.0/10 (CGNAT / Tailscale range) if (a === 100 && b >= 64 && b <= 127) { return false; } // 169.254.0.0/16 link-local if (a === 169 && b === 254) { return false; } return true; } function extractPublicIPFromAddrs(addrs) { if (!addrs || !addrs.length) { return ""; } for (var i = 0; i < addrs.length; i++) { var host = hostFromEndpoint(addrs[i]); if (isPublicIPv4(host)) { return host; } } return ""; } // Prefer exit-node peer endpoints when an exit node is active (closer to egress seen by websites). // Otherwise use Self.Addrs. This is status-derived, not an external probe. function resolvePublicIP(selfNode, peerMap) { var exitPeer = findActiveExitNodePeer(peerMap); if (exitPeer && exitPeer.Addrs) { var viaExit = extractPublicIPFromAddrs(exitPeer.Addrs); if (viaExit) { return viaExit; } } if (selfNode && selfNode.Addrs) { return extractPublicIPFromAddrs(selfNode.Addrs); } 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: ", publicIPPrefix: "Public IP: ", none: "None", copied: "Copied %1 to clipboard", invalidExitNodeHostname: "Invalid exit node hostname", notConnectedHint: "Not connected" }; } // Security: validate hostnames coming from tailscale status JSON. // Fail closed on obviously malicious input. Allow multi-label MagicDNS names // up to DNS FQDN length (253). function isValidExitNodeHostname(hostname) { if (typeof hostname !== "string") { return false; } if (hostname === "") { return true; } if (hostname.length > 253) { return false; } // Each label: alnum start/end, alnum/hyphen/underscore inside; dots separate labels. return /^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9_-]{0,61}[a-zA-Z0-9])?)(\.([a-zA-Z0-9]([a-zA-Z0-9_-]{0,61}[a-zA-Z0-9])?))*$/.test(hostname); } function emptyStatusState() { return { isConnected: false, tailscaleIP: "", publicIP: "", currentExitNode: "", peers: [] }; } function parseStatusResult(jsonText) { try { const data = JSON.parse(jsonText); const isConnected = data.BackendState === "Running"; if (!isConnected) { // #55: when not Running, do not surface stale peer list / exit node / IP. return emptyStatusState(); } const peerMap = data.Peer || {}; const selfNode = data.Self || {}; return { isConnected: true, tailscaleIP: (selfNode.TailscaleIPs && selfNode.TailscaleIPs[0]) || "", publicIP: resolvePublicIP(selfNode, peerMap), currentExitNode: findActiveExitNode(peerMap), peers: parsePeers(peerMap) }; } catch (e) { return emptyStatusState(); } } 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. 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" }); // statusOk must be true (successful status poll) before acting on pending toggle. // Never invent up/down from a failed poll (would force "up" after clearing isConnected). function commandForPendingAction(pending, freshIsConnected, statusOk) { if (!statusOk) { return null; } 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, PendingAction, commandForPendingAction, hostFromEndpoint, isPublicIPv4, extractPublicIPFromAddrs, resolvePublicIP }; }