Compare commits

..

No commits in common. "bc864bb9d82545f5f8a6bb82676fd5a05c478d6d" and "909b935f7262e8d425a2c16de0a12e849c2add22" have entirely different histories.

3 changed files with 97 additions and 113 deletions

View file

@ -16,6 +16,7 @@ PluginComponent {
property var peers: [] property var peers: []
property string _copyText: "" property string _copyText: ""
property int _copyIndex: 0 property int _copyIndex: 0
property bool _pendingToggle: false // transient one-shot for post-action verification (to be removed in first-principles refactor)
layerNamespacePlugin: "tailscalectl" layerNamespacePlugin: "tailscalectl"
popoutWidth: 360 popoutWidth: 360
@ -33,11 +34,11 @@ PluginComponent {
onExited: (code, status) => { onExited: (code, status) => {
if (code !== 0) { if (code !== 0) {
var action = root.isConnected ? "disconnect" : "connect"; var action = root.isConnected ? "disconnect" : "connect"
var detail = toggleProcess.stderr.text.trim(); var detail = toggleProcess.stderr.text.trim()
ToastService.showError("tailscalectl", TailscaleLib.formatError(action, detail)); ToastService.showError("tailscalectl", TailscaleLib.formatError(action, detail))
} }
root.refreshStatus(); statusCheck.running = true
} }
} }
@ -65,10 +66,10 @@ PluginComponent {
onExited: (code, status) => { onExited: (code, status) => {
if (code !== 0) { if (code !== 0) {
var detail = exitNodeProcess.stderr.text.trim(); var detail = exitNodeProcess.stderr.text.trim()
ToastService.showError("tailscalectl", TailscaleLib.formatError("set", detail)); ToastService.showError("tailscalectl", TailscaleLib.formatError("set", detail))
} }
root.refreshStatus(); statusCheck.running = true
} }
} }
@ -94,22 +95,19 @@ PluginComponent {
ToastService.showError("tailscalectl", TailscaleLib.formatError("status")) ToastService.showError("tailscalectl", TailscaleLib.formatError("status"))
} }
const cmd = TailscaleLib.commandForPendingAction(statusCheck._pendingAction, root.isConnected); // Post-action verification: if a toggle was requested, use the fresh state we just received.
if (cmd) { if (root._pendingToggle) {
toggleProcess.command = cmd; root._pendingToggle = false
toggleProcess.running = true; toggleProcess.command = TailscaleLib.buildToggleCommand(root.isConnected)
toggleProcess.running = true
} }
statusCheck._pendingAction = null;
} }
} }
function toggleTailscale() { function toggleTailscale() {
statusCheck._pendingAction = "toggle"; // Post-action verification: force a fresh status check, then act on the real ground truth.
statusCheck.running = true; root._pendingToggle = true
} statusCheck.running = true
function refreshStatus() {
statusCheck.running = true;
} }
function setExitNode(hostname) { function setExitNode(hostname) {

View file

@ -1,15 +1,15 @@
function parsePeers(peerMap) { function parsePeers(peerMap) {
if (!peerMap) { return []; } if (!peerMap) return []
return Object.keys(peerMap).map(function (key) { return Object.keys(peerMap).map(function (key) {
if (!Object.prototype.hasOwnProperty.call(peerMap, key)) { return null; } if (!Object.prototype.hasOwnProperty.call(peerMap, key)) return null
var p = peerMap[key]; var p = peerMap[key]
return { return {
hostname: p.HostName || key, hostname: p.HostName || key,
ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "", ip: (p.TailscaleIPs && p.TailscaleIPs.length) ? p.TailscaleIPs[0] : "",
online: p.Online || false, online: p.Online || false,
exitNode: p.ExitNodeOption || false exitNode: p.ExitNodeOption || false
}; }
}).filter(function (peer) { return peer !== null; }); }).filter(function (peer) { return peer !== null })
} }
function makeExitNodeCommand(hostname) { function makeExitNodeCommand(hostname) {
@ -23,15 +23,15 @@ function makeExitNodeCommand(hostname) {
} }
function findActiveExitNode(peerMap) { function findActiveExitNode(peerMap) {
if (!peerMap) { return ""; } if (!peerMap) return ""
for (const key in peerMap) { for (const key in peerMap) {
if (!Object.prototype.hasOwnProperty.call(peerMap, key)) { continue; } if (!Object.prototype.hasOwnProperty.call(peerMap, key)) continue
const p = peerMap[key]; const p = peerMap[key]
if (p.ExitNode) { if (p.ExitNode) {
return p.HostName || key; return p.HostName || key
} }
} }
return ""; return ""
} }
const clipboardTools = [ const clipboardTools = [
@ -52,51 +52,53 @@ function getStrings() {
disconnected: "Disconnected", disconnected: "Disconnected",
exitNodePrefix: "Exit node: ", exitNodePrefix: "Exit node: ",
none: "None", none: "None",
copied: function (text) { return "Copied " + text + " to clipboard"; }, copied: function (text) { return "Copied " + text + " to clipboard" },
clearExitNode: "×", clearExitNode: "×",
setExitNode: "↗", setExitNode: "↗",
invalidExitNodeHostname: "Invalid exit node hostname" invalidExitNodeHostname: "Invalid exit node hostname"
}; }
} }
// Light UI predicates — keep the view thin. // Light UI predicates — keep the view thin.
function shouldShowClearExitNode(currentExitNode) { function shouldShowClearExitNode(currentExitNode) {
return currentExitNode !== ""; return currentExitNode !== ""
} }
function isActiveExitNode(currentExitNode, hostname) { function isActiveExitNode(currentExitNode, hostname) {
return currentExitNode === hostname; return currentExitNode === hostname
} }
// Security: validate hostnames coming from tailscale status JSON. // Security: validate hostnames coming from tailscale status JSON.
// Fail closed on obviously malicious input. // Fail closed on obviously malicious input.
function isValidExitNodeHostname(hostname) { function isValidExitNodeHostname(hostname) {
if (typeof hostname !== "string") { return false; } if (typeof hostname !== "string") return false;
if (hostname === "") { return true; } 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); return /^[a-zA-Z0-9]([a-zA-Z0-9-_.]{0,62}[a-zA-Z0-9])?$/.test(hostname);
} }
function parseStatusResult(jsonText) { function parseStatusResult(jsonText) {
try { try {
const data = JSON.parse(jsonText); const data = JSON.parse(jsonText)
return { return {
isConnected: data.BackendState === "Running", isConnected: data.BackendState === "Running",
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "", tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
currentExitNode: findActiveExitNode(data.Peer || {}), currentExitNode: findActiveExitNode(data.Peer || {}),
peers: parsePeers(data.Peer || {}) peers: parsePeers(data.Peer || {})
}; }
} catch (e) { } catch (e) {
return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] }; return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] }
} }
} }
function buildToggleCommand(isConnected) { function buildToggleCommand(isConnected) {
return isConnected ? ["tailscale", "down"] : ["tailscale", "up"]; return isConnected ? ["tailscale", "down"] : ["tailscale", "up"]
} }
// Single source of truth for the status command used for on-demand and post-action verification. // Single source of truth for the status command used for on-demand and post-action verification.
function getStatusCommand() { function getStatusCommand() {
return ["tailscale", "status", "--json"]; return ["tailscale", "status", "--json"]
} }
function errorMessage(cmd) { function errorMessage(cmd) {
@ -123,17 +125,7 @@ function formatError(action, detail) {
return base; return base;
} }
const PendingAction = Object.freeze({ // CommonJS export for Node.js tests (ignored by QML)
TOGGLE: "toggle"
});
function commandForPendingAction(pending, freshIsConnected) {
if (pending === PendingAction.TOGGLE) {
return buildToggleCommand(freshIsConnected);
}
return null;
}
if (typeof module !== "undefined" && module.exports) { if (typeof module !== "undefined" && module.exports) {
module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode, PendingAction, commandForPendingAction }; module.exports = { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode }
} }

View file

@ -1,7 +1,7 @@
import { test } from "node:test"; import { test } from "node:test"
import assert from "node:assert"; import assert from "node:assert"
import lib from "../tailscalectl/lib.js"; import lib from "../tailscalectl/lib.js"
const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode, PendingAction, commandForPendingAction } = lib; const { parsePeers, makeExitNodeCommand, findActiveExitNode, errorMessage, formatError, getStatusCommand, isValidExitNodeHostname, getClipboardCommands, buildToggleCommand, parseStatusResult, getStrings, shouldShowClearExitNode, isActiveExitNode } = lib
/* /*
* Unit tests for the pure functions exported from lib.js. * Unit tests for the pure functions exported from lib.js.
@ -27,87 +27,91 @@ test("parsePeers extracts exitNode from ExitNodeOption", () => {
Online: true, Online: true,
ExitNodeOption: false ExitNodeOption: false
} }
}; }
const peers = parsePeers(peerMap); const peers = parsePeers(peerMap)
assert.strictEqual(peers[0].exitNode, true); assert.strictEqual(peers[0].exitNode, true)
assert.strictEqual(peers[1].exitNode, false); assert.strictEqual(peers[1].exitNode, false)
}); })
test("makeExitNodeCommand returns tailscale set command for hostname", () => { test("makeExitNodeCommand returns tailscale set command for hostname", () => {
const cmd = makeExitNodeCommand("router"); const cmd = makeExitNodeCommand("router")
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"]); assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"])
}); })
test("makeExitNodeCommand with empty string clears exit node", () => { test("makeExitNodeCommand with empty string clears exit node", () => {
const cmd = makeExitNodeCommand(""); const cmd = makeExitNodeCommand("")
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="]); assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="])
}); })
test("findActiveExitNode returns hostname of peer with ExitNode=true", () => { test("findActiveExitNode returns hostname of peer with ExitNode=true", () => {
const peerMap = { const peerMap = {
"peer-1": { HostName: "gluetun-sjc", ExitNode: true, ExitNodeOption: true }, "peer-1": { HostName: "gluetun-sjc", ExitNode: true, ExitNodeOption: true },
"peer-2": { HostName: "gluetun-den", ExitNode: false, ExitNodeOption: true } "peer-2": { HostName: "gluetun-den", ExitNode: false, ExitNodeOption: true }
}; }
assert.strictEqual(findActiveExitNode(peerMap), "gluetun-sjc"); assert.strictEqual(findActiveExitNode(peerMap), "gluetun-sjc")
}); })
test("findActiveExitNode returns empty string when no exit node", () => { test("findActiveExitNode returns empty string when no exit node", () => {
const peerMap = { const peerMap = {
"peer-1": { HostName: "laptop", ExitNode: false, ExitNodeOption: false } "peer-1": { HostName: "laptop", ExitNode: false, ExitNodeOption: false }
}; }
assert.strictEqual(findActiveExitNode(peerMap), ""); assert.strictEqual(findActiveExitNode(peerMap), "")
}); })
test("errorMessage returns user-friendly message for tailscale up failure", () => { test("errorMessage returns user-friendly message for tailscale up failure", () => {
const msg = errorMessage("up", 1); const msg = errorMessage("up", 1)
assert.strictEqual(msg, "Failed to connect to Tailscale"); assert.strictEqual(msg, "Failed to connect to Tailscale")
}) })
test("errorMessage returns user-friendly message for tailscale down failure", () => { test("errorMessage returns user-friendly message for tailscale down failure", () => {
const msg = errorMessage("down", 1); const msg = errorMessage("down", 1)
assert.strictEqual(msg, "Failed to disconnect from Tailscale"); assert.strictEqual(msg, "Failed to disconnect from Tailscale")
}); })
test("errorMessage returns user-friendly message for tailscale set failure", () => { test("errorMessage returns user-friendly message for tailscale set failure", () => {
const msg = errorMessage("set", 1); const msg = errorMessage("set", 1)
assert.strictEqual(msg, "Failed to set exit node"); assert.strictEqual(msg, "Failed to set exit node")
}); })
test("errorMessage returns user-friendly message for tailscale status failure", () => { test("errorMessage returns user-friendly message for tailscale status failure", () => {
const msg = errorMessage("status", 1); const msg = errorMessage("status", 1)
assert.strictEqual(msg, "Failed to read Tailscale status"); assert.strictEqual(msg, "Failed to read Tailscale status")
}); })
test("errorMessage returns generic message for unknown command", () => { test("errorMessage returns generic message for unknown command", () => {
const msg = errorMessage("unknown", 1); const msg = errorMessage("unknown", 1)
assert.strictEqual(msg, "Tailscale command failed"); assert.strictEqual(msg, "Tailscale command failed")
}); })
// --- getClipboardCommands ---
test("getClipboardCommands returns ordered argv arrays with text appended", () => { test("getClipboardCommands returns ordered argv arrays with text appended", () => {
const cmds = getClipboardCommands("1.2.3.4"); const cmds = getClipboardCommands("1.2.3.4")
assert.ok(Array.isArray(cmds)); assert.ok(Array.isArray(cmds))
assert.strictEqual(cmds.length, 2); assert.strictEqual(cmds.length, 2)
assert.deepStrictEqual(cmds[0], ["dms", "cl", "copy", "1.2.3.4"]); assert.deepStrictEqual(cmds[0], ["dms", "cl", "copy", "1.2.3.4"])
assert.deepStrictEqual(cmds[1], ["wl-copy", "1.2.3.4"]); assert.deepStrictEqual(cmds[1], ["wl-copy", "1.2.3.4"])
}); })
test("getClipboardCommands handles text with special characters safely (direct argv)", () => { test("getClipboardCommands handles text with special characters safely (direct argv)", () => {
const cmds = getClipboardCommands("it's a 'test' with \"quotes\" and\nnewlines"); const cmds = getClipboardCommands("it's a 'test' with \"quotes\" and\nnewlines")
assert.ok(Array.isArray(cmds)); assert.ok(Array.isArray(cmds))
assert.strictEqual(cmds.length, 2); assert.strictEqual(cmds.length, 2)
assert.ok(cmds[0].includes("it's a 'test' with \"quotes\" and\nnewlines")); assert.ok(cmds[0].includes("it's a 'test' with \"quotes\" and\nnewlines"))
}); })
test("getClipboardCommands is deterministic and open for future tools", () => { test("getClipboardCommands is deterministic and open for future tools", () => {
const cmds = getClipboardCommands("foo"); const cmds = getClipboardCommands("foo")
assert.ok(Array.isArray(cmds[0])); assert.ok(Array.isArray(cmds[0]))
assert.ok(Array.isArray(cmds[1])); assert.ok(Array.isArray(cmds[1]))
}); })
// --- getStrings ---
test("getStrings returns canonical UI strings for the widget", () => { test("getStrings returns canonical UI strings for the widget", () => {
const s = getStrings(); const s = getStrings()
assert.ok(s.header) assert.ok(s.header)
assert.ok(s.connected) assert.ok(s.connected)
assert.ok(s.disconnected) assert.ok(s.disconnected)
@ -147,17 +151,7 @@ test("buildToggleCommand treats null and undefined as disconnected", () => {
assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"]) assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"])
}) })
test("commandForPendingAction returns toggle command when pending is TOGGLE and passes through buildToggleCommand logic", () => { // --- parseStatusResult ---
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, true), ["tailscale", "down"]);
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, false), ["tailscale", "up"]);
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, null), ["tailscale", "up"]);
});
test("commandForPendingAction returns null for no pending action or unknown pending value", () => {
assert.strictEqual(commandForPendingAction(null, true), null);
assert.strictEqual(commandForPendingAction(undefined, false), null);
assert.strictEqual(commandForPendingAction("something-else", true), null);
});
test("parseStatusResult produces correct state from valid JSON", () => { test("parseStatusResult produces correct state from valid JSON", () => {
const json = JSON.stringify({ const json = JSON.stringify({