dms_tailscalectl/tailscalectl/lib.js

257 lines
6.9 KiB
JavaScript
Raw Normal View History

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 "";
}
// 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);
}
return endpoint;
}
// IPv4 only. 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;
}
if (a === 10) {
return false;
}
if (a === 172 && b >= 16 && b <= 31) {
return false;
}
if (a === 192 && b === 168) {
return false;
}
if (a === 100 && b >= 64 && b <= 127) {
return false;
}
if (a === 169 && b === 254) {
return false;
}
return true;
}
// Ordered true-egress probes (IPv4). First success wins. Direct argv only — no shell.
// Lazy / on-demand only; never run from the periodic status path.
function getEgressCheckCommands() {
return [
["curl", "-4", "-sS", "--max-time", "5", "https://api.ipify.org"],
["curl", "-4", "-sS", "--max-time", "5", "https://icanhazip.com"]
];
}
// Parse curl stdout from an egress checker into a public IPv4, or "" if unusable.
function parseEgressCheckResponse(stdout) {
if (typeof stdout !== "string") {
return "";
}
var line = stdout.trim().split(/\r?\n/)[0] || "";
line = line.trim();
if (isPublicIPv4(line)) {
return line;
}
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: ",
publicIPTapHint: "tap to check",
publicIPLoading: "checking…",
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;
}
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: "",
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]) || "",
currentExitNode: findActiveExitNode(peerMap),
peers: parsePeers(peerMap)
};
} catch (e) {
return emptyStatusState();
}
}
function buildToggleCommand(isConnected) {
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"];
}
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",
"egress": "Failed to look up public IP"
};
return messages[cmd] || "Tailscale command failed";
}
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, 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,
getEgressCheckCommands,
parseEgressCheckResponse
};
}