2026-05-18 18:27:41 +00:00
|
|
|
function parsePeers(peerMap) {
|
2026-07-15 01:11:59 -07:00
|
|
|
if (!peerMap) {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
2026-05-18 18:27:41 +00:00
|
|
|
return Object.keys(peerMap).map(function (key) {
|
2026-05-23 04:57:46 +00:00
|
|
|
var p = peerMap[key];
|
2026-05-18 18:27:41 +00:00
|
|
|
return {
|
|
|
|
|
hostname: p.HostName || key,
|
|
|
|
|
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
|
|
|
|
|
online: p.Online || false,
|
|
|
|
|
exitNode: p.ExitNodeOption || false
|
2026-05-23 04:57:46 +00:00
|
|
|
};
|
2026-07-15 01:11:59 -07:00
|
|
|
});
|
2026-05-18 18:27:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeExitNodeCommand(hostname) {
|
2026-05-22 20:46:37 +00:00
|
|
|
if (!isValidExitNodeHostname(hostname)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
2026-05-21 10:11:03 +00:00
|
|
|
if (hostname === "") {
|
2026-05-22 20:46:37 +00:00
|
|
|
return ["tailscale", "set", "--exit-node="];
|
2026-05-21 10:11:03 +00:00
|
|
|
}
|
2026-05-22 20:46:37 +00:00
|
|
|
return ["tailscale", "set", "--exit-node=" + hostname];
|
2026-05-18 18:27:41 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 22:10:37 +00:00
|
|
|
function findActiveExitNode(peerMap) {
|
2026-07-15 01:11:59 -07:00
|
|
|
if (!peerMap) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
2026-05-24 21:18:52 +00:00
|
|
|
for (const key of Object.keys(peerMap)) {
|
2026-05-23 04:57:46 +00:00
|
|
|
const p = peerMap[key];
|
2026-05-18 22:10:37 +00:00
|
|
|
if (p.ExitNode) {
|
2026-05-23 04:57:46 +00:00
|
|
|
return p.HostName || key;
|
2026-05-18 22:10:37 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-23 04:57:46 +00:00
|
|
|
return "";
|
2026-05-18 22:10:37 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 01:31:46 -07:00
|
|
|
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 "";
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 21:35:07 +00:00
|
|
|
const clipboardTools = [
|
|
|
|
|
{ argv: ["dms", "cl", "copy"] },
|
|
|
|
|
{ argv: ["wl-copy"] }
|
|
|
|
|
];
|
2026-05-20 10:08:59 +00:00
|
|
|
|
2026-05-22 21:35:07 +00:00
|
|
|
function getClipboardCommands(text) {
|
|
|
|
|
return clipboardTools.map(function (tool) {
|
|
|
|
|
return tool.argv.concat([text]);
|
|
|
|
|
});
|
2026-05-20 10:11:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 06:33:08 +00:00
|
|
|
function getStrings() {
|
|
|
|
|
return {
|
|
|
|
|
header: "Tailscale",
|
|
|
|
|
connected: "Connected",
|
|
|
|
|
disconnected: "Disconnected",
|
|
|
|
|
exitNodePrefix: "Exit node: ",
|
2026-07-15 01:31:46 -07:00
|
|
|
publicIPPrefix: "Public IP: ",
|
2026-05-22 06:33:08 +00:00
|
|
|
none: "None",
|
2026-05-24 20:57:14 +00:00
|
|
|
copied: "Copied %1 to clipboard",
|
2026-07-15 01:11:59 -07:00
|
|
|
invalidExitNodeHostname: "Invalid exit node hostname",
|
|
|
|
|
notConnectedHint: "Not connected"
|
2026-05-23 04:57:46 +00:00
|
|
|
};
|
2026-05-22 06:33:08 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 21:35:07 +00:00
|
|
|
// Security: validate hostnames coming from tailscale status JSON.
|
2026-07-15 01:11:59 -07:00
|
|
|
// Fail closed on obviously malicious input. Allow multi-label MagicDNS names
|
|
|
|
|
// up to DNS FQDN length (253).
|
2026-05-22 20:46:37 +00:00
|
|
|
function isValidExitNodeHostname(hostname) {
|
2026-07-15 01:11:59 -07:00
|
|
|
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);
|
2026-05-22 20:46:37 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 01:31:46 -07:00
|
|
|
function emptyStatusState() {
|
|
|
|
|
return {
|
|
|
|
|
isConnected: false,
|
|
|
|
|
tailscaleIP: "",
|
|
|
|
|
publicIP: "",
|
|
|
|
|
currentExitNode: "",
|
|
|
|
|
peers: []
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:29:02 +00:00
|
|
|
function parseStatusResult(jsonText) {
|
|
|
|
|
try {
|
2026-05-23 04:57:46 +00:00
|
|
|
const data = JSON.parse(jsonText);
|
2026-07-15 01:11:59 -07:00
|
|
|
const isConnected = data.BackendState === "Running";
|
|
|
|
|
if (!isConnected) {
|
|
|
|
|
// #55: when not Running, do not surface stale peer list / exit node / IP.
|
2026-07-15 01:31:46 -07:00
|
|
|
return emptyStatusState();
|
2026-07-15 01:11:59 -07:00
|
|
|
}
|
2026-07-15 01:31:46 -07:00
|
|
|
const peerMap = data.Peer || {};
|
|
|
|
|
const selfNode = data.Self || {};
|
2026-05-20 19:29:02 +00:00
|
|
|
return {
|
2026-07-15 01:11:59 -07:00
|
|
|
isConnected: true,
|
2026-07-15 01:31:46 -07:00
|
|
|
tailscaleIP: (selfNode.TailscaleIPs && selfNode.TailscaleIPs[0]) || "",
|
|
|
|
|
publicIP: resolvePublicIP(selfNode, peerMap),
|
|
|
|
|
currentExitNode: findActiveExitNode(peerMap),
|
|
|
|
|
peers: parsePeers(peerMap)
|
2026-05-23 04:57:46 +00:00
|
|
|
};
|
2026-05-20 19:29:02 +00:00
|
|
|
} catch (e) {
|
2026-07-15 01:31:46 -07:00
|
|
|
return emptyStatusState();
|
2026-05-20 19:29:02 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-20 19:21:17 +00:00
|
|
|
function buildToggleCommand(isConnected) {
|
2026-05-23 04:57:46 +00:00
|
|
|
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"];
|
2026-05-20 19:21:17 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-22 10:19:39 +00:00
|
|
|
// Single source of truth for the status command used for on-demand and post-action verification.
|
|
|
|
|
function getStatusCommand() {
|
2026-05-23 04:57:46 +00:00
|
|
|
return ["tailscale", "status", "--json"];
|
2026-05-22 10:19:39 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 20:53:32 +00:00
|
|
|
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",
|
2026-05-22 10:20:27 +00:00
|
|
|
"status": "Failed to read Tailscale status",
|
|
|
|
|
"clipboard": "Error copying to clipboard"
|
2026-05-22 10:19:25 +00:00
|
|
|
};
|
|
|
|
|
return messages[cmd] || "Tailscale command failed";
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
// Central error formatting for the widget. detail is optional truncated stderr or extra context.
|
2026-05-22 10:19:25 +00:00
|
|
|
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;
|
2026-05-18 20:53:32 +00:00
|
|
|
}
|
2026-05-22 10:19:25 +00:00
|
|
|
return base;
|
2026-05-18 20:53:32 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-23 04:41:40 +00:00
|
|
|
const PendingAction = Object.freeze({
|
|
|
|
|
TOGGLE: "toggle"
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-15 01:11:59 -07:00
|
|
|
// 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;
|
|
|
|
|
}
|
2026-05-23 04:41:40 +00:00
|
|
|
if (pending === PendingAction.TOGGLE) {
|
|
|
|
|
return buildToggleCommand(freshIsConnected);
|
|
|
|
|
}
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 18:39:39 +00:00
|
|
|
if (typeof module !== "undefined" && module.exports) {
|
2026-07-15 01:11:59 -07:00
|
|
|
module.exports = {
|
|
|
|
|
parsePeers,
|
|
|
|
|
makeExitNodeCommand,
|
|
|
|
|
findActiveExitNode,
|
|
|
|
|
errorMessage,
|
|
|
|
|
formatError,
|
|
|
|
|
getStatusCommand,
|
|
|
|
|
isValidExitNodeHostname,
|
|
|
|
|
getClipboardCommands,
|
|
|
|
|
buildToggleCommand,
|
|
|
|
|
parseStatusResult,
|
|
|
|
|
getStrings,
|
|
|
|
|
PendingAction,
|
2026-07-15 01:31:46 -07:00
|
|
|
commandForPendingAction,
|
|
|
|
|
hostFromEndpoint,
|
|
|
|
|
isPublicIPv4,
|
|
|
|
|
extractPublicIPFromAddrs,
|
|
|
|
|
resolvePublicIP
|
2026-07-15 01:11:59 -07:00
|
|
|
};
|
2026-05-18 18:39:39 +00:00
|
|
|
}
|