Compare commits
No commits in common. "bc864bb9d82545f5f8a6bb82676fd5a05c478d6d" and "909b935f7262e8d425a2c16de0a12e849c2add22" have entirely different histories.
bc864bb9d8
...
909b935f72
3 changed files with 97 additions and 113 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
118
test/lib.test.js
118
test/lib.test.js
|
|
@ -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({
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue