From 6a586119be6327a0fb16d6d4fc35ae1a10fc13eb Mon Sep 17 00:00:00 2001 From: "Ebeneezer (Hermes Agent)" Date: Wed, 15 Jul 2026 01:35:53 -0700 Subject: [PATCH] feat: lazy on-demand true egress public IP check (#54) Replace status-Addrs public IP with user-triggered curl probes (api.ipify.org, icanhazip.com). Shown as "tap to check" until fetched; cleared on disconnect or exit-node change. Direct argv, fallback chain, busy-mutex shared with other actions. v0.2.3 Written by AI agent working for @jtmorris. Model: Grok 4.5. --- README.md | 8 +- tailscalectl/TailscaleWidget.qml | 131 ++++++++++++++++++++++--------- tailscalectl/i18n/en.json | 3 + tailscalectl/lib.js | 74 ++++++----------- tailscalectl/plugin.json | 2 +- test/lib.test.js | 81 ++++++++----------- 6 files changed, 158 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index c1fe92e..eea5be9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank Bar with quick controls for toggling connection, switching exit nodes, and copying peer addresses. -![Tailscale Widget v0.2.2](resources/dms_tailscalectl_v0.1.0.png) +![Tailscale Widget v0.2.3](resources/dms_tailscalectl_v0.1.0.png) ## Features @@ -10,7 +10,7 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank - **Right-click** to toggle Tailscale on/off - **Left-click** to open a popout showing: - Your current Tailscale IP (when connected) - - Public IP derived from Tailscale endpoint addresses (Self, or active exit-node peer when one is selected) + - Public IP via **on-demand true egress check** (tap to run `curl` to ipify/icanhazip; not auto-fetched on status poll) - Active exit node (with clear button) - Peer list with hostnames and IPs (when connected) - A clear "Not connected" empty state when disconnected (no stale peer list) @@ -81,7 +81,7 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank "component": "./TailscaleWidget.qml", "permissions": ["process"], "requires": ["tailscale"], - "version": "0.2.2" + "version": "0.2.3" } ``` @@ -92,7 +92,7 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank - Follows current `dms-plugin-dev` + DMS 1.4 plugin best practices (capabilities, requires, no raw Process for one-shots, etc.). - Toggle uses intentional defensive poll-act-poll; a failed status poll aborts the pending toggle (does not invent `up`/`down`). - When `BackendState` is not `Running`, peer list / exit node / self IP are cleared so the UI never shows a stale connected-looking peer list. -- Public IP is derived from `Self.Addrs` (or the active exit-node peer's `Addrs` when an exit node is selected). No external HTTP probe. +- Public IP is a **lazy true-egress lookup**: tap "Public IP: tap to check" to probe via `curl` (`api.ipify.org`, then `icanhazip.com`). Cleared on disconnect or exit-node change. Not derived from `Self.Addrs`. - Status row uses `RowLayout` with a real `Layout.fillWidth` spacer (not a no-op on plain `Row`). - Peer `ListView` uses `Flickable.StopAtBounds` (no desktop rubber-band overshoot). diff --git a/tailscalectl/TailscaleWidget.qml b/tailscalectl/TailscaleWidget.qml index a11694b..61b59a5 100644 --- a/tailscalectl/TailscaleWidget.qml +++ b/tailscalectl/TailscaleWidget.qml @@ -11,17 +11,20 @@ PluginComponent { property bool isConnected: false property string tailscaleIP: "" + // Lazy true-egress IP — only filled by explicit user-triggered check (#54). property string publicIP: "" + property bool publicIPLoading: false property string currentExitNode: "" property var peers: [] property string _copyText: "" property int _copyIndex: 0 + property int _egressIndex: 0 // Transient coordination for defensive poll-act-poll toggle (not long-term cache). // Poll for ground truth → act → poll again for verification. property string _pendingAction: "" - // Single-flight guard: prevent interleaved status/toggle/exit/copy chains (#15/#30 class). + // Single-flight guard: prevent interleaved status/toggle/exit/copy/egress chains. property bool _busy: false layerNamespacePlugin: "tailscalectl" @@ -32,67 +35,65 @@ PluginComponent { root._runStatusCheck(); } + function _applyStatusState(state) { + // Invalidate lazy public IP when connectivity or exit node changes. + if (!state.isConnected || state.currentExitNode !== root.currentExitNode) { + root.publicIP = ""; + root.publicIPLoading = false; + } + root.isConnected = state.isConnected; + root.tailscaleIP = state.tailscaleIP; + root.currentExitNode = state.currentExitNode; + root.peers = state.peers; + } + + function _clearConnectionState() { + root.isConnected = false; + root.tailscaleIP = ""; + root.publicIP = ""; + root.publicIPLoading = false; + root.currentExitNode = ""; + root.peers = []; + } + function _runStatusCheck() { if (root._busy && root._pendingAction === "") { - // A non-toggle status refresh while something is already in flight: skip. - // Toggle path sets _pendingAction first and is allowed to chain after actions clear busy carefully. return; } root._busy = true; Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => { var statusOk = (code === 0); if (statusOk) { - const state = TailscaleLib.parseStatusResult(stdout); - root.isConnected = state.isConnected; - root.tailscaleIP = state.tailscaleIP; - root.publicIP = state.publicIP; - root.currentExitNode = state.currentExitNode; - root.peers = state.peers; + root._applyStatusState(TailscaleLib.parseStatusResult(stdout)); } else { - root.isConnected = false; - root.tailscaleIP = ""; - root.publicIP = ""; - root.currentExitNode = ""; - root.peers = []; + root._clearConnectionState(); ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status"))); } const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected, statusOk); if (cmd) { - // Fresh poll succeeded; act, then verify with another status poll. Proc.runCommand("tailscale-toggle", cmd, (out, c) => { if (c !== 0) { const action = root.isConnected ? "disconnect" : "connect"; ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError(action))); } root._pendingAction = ""; - // Keep busy through verification poll: call internal runner that assumes we own the lock. root._runStatusCheckUnlocked(); }); } else { - // Includes failed status while a toggle was pending: abort rather than invent up/down. root._pendingAction = ""; root._busy = false; } }); } - // Used only as the continuation after toggle action; assumes _busy is already true. + // Continuation after toggle/exit; assumes _busy is already true. function _runStatusCheckUnlocked() { Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => { if (code === 0) { - const state = TailscaleLib.parseStatusResult(stdout); - root.isConnected = state.isConnected; - root.tailscaleIP = state.tailscaleIP; - root.publicIP = state.publicIP; - root.currentExitNode = state.currentExitNode; - root.peers = state.peers; + root._applyStatusState(TailscaleLib.parseStatusResult(stdout)); } else { - root.isConnected = false; - root.tailscaleIP = ""; - root.publicIP = ""; - root.currentExitNode = ""; - root.peers = []; + root._clearConnectionState(); ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status"))); } root._busy = false; @@ -124,11 +125,13 @@ PluginComponent { return; } root._busy = true; + // Exit-node change invalidates any previously checked egress IP. + root.publicIP = ""; + root.publicIPLoading = false; Proc.runCommand("tailscale-exit", cmd, (stdout, code) => { if (code !== 0) { ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set"))); } - // Verify via unlocked status continuation (busy already held). root._runStatusCheckUnlocked(); }); } @@ -161,6 +164,54 @@ PluginComponent { }); } + // #54: on-demand true egress check (lazy). Click "tap to check" to run. + function fetchPublicIP() { + if (root._busy || !root.isConnected || root.publicIPLoading) { + return; + } + root._busy = true; + root.publicIPLoading = true; + root._egressIndex = 0; + root._runNextEgressCheck(); + } + + function _runNextEgressCheck() { + const cmds = TailscaleLib.getEgressCheckCommands(); + if (root._egressIndex >= cmds.length) { + root.publicIPLoading = false; + root._busy = false; + ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("egress"))); + return; + } + Proc.runCommand("tailscale-egress-" + root._egressIndex, cmds[root._egressIndex], (stdout, code) => { + if (code === 0) { + const ip = TailscaleLib.parseEgressCheckResponse(stdout); + if (ip !== "") { + root.publicIP = ip; + root.publicIPLoading = false; + root._busy = false; + return; + } + } + root._egressIndex += 1; + root._runNextEgressCheck(); + }); + } + + function onPublicIpRowClicked() { + if (!root.isConnected) { + return; + } + if (root.publicIPLoading) { + return; + } + if (root.publicIP !== "") { + root.copyToClipboard(root.publicIP); + return; + } + root.fetchPublicIP(); + } + popoutContent: Component { PopoutComponent { headerText: I18n.tr(TailscaleLib.getStrings().header) @@ -172,7 +223,7 @@ PluginComponent { width: parent.width height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingXS - + (root.isConnected && root.publicIP !== "" ? Theme.fontSizeSmall + Theme.spacingXS : 0) + + (root.isConnected ? Theme.fontSizeSmall + Theme.spacingXS : 0) + Theme.spacingM + peerArea.height + Theme.spacingM RowLayout { @@ -243,10 +294,10 @@ PluginComponent { } } - // #54: public egress IP (status-derived from Self/exit-node endpoints) + // #54: lazy true-egress public IP (on-demand only) MouseArea { id: publicIpRow - visible: root.isConnected && root.publicIP !== "" + visible: root.isConnected y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingXS anchors.left: parent.left anchors.leftMargin: Theme.spacingM @@ -255,12 +306,21 @@ PluginComponent { cursorShape: Qt.PointingHandCursor hoverEnabled: true onClicked: { - root.copyToClipboard(root.publicIP); + root.onPublicIpRowClicked(); } StyledText { id: publicIpText - text: I18n.tr(TailscaleLib.getStrings().publicIPPrefix) + root.publicIP + text: { + var prefix = I18n.tr(TailscaleLib.getStrings().publicIPPrefix); + if (root.publicIPLoading) { + return prefix + I18n.tr(TailscaleLib.getStrings().publicIPLoading); + } + if (root.publicIP !== "") { + return prefix + root.publicIP; + } + return prefix + I18n.tr(TailscaleLib.getStrings().publicIPTapHint); + } font.pixelSize: Theme.fontSizeSmall color: Theme.surfaceVariantText } @@ -295,7 +355,6 @@ PluginComponent { anchors.fill: parent model: root.peers interactive: true - // Desktop popout: no rubber-band overshoot (#27). boundsBehavior: Flickable.StopAtBounds clip: true diff --git a/tailscalectl/i18n/en.json b/tailscalectl/i18n/en.json index 11de0ab..c2d74bc 100644 --- a/tailscalectl/i18n/en.json +++ b/tailscalectl/i18n/en.json @@ -4,6 +4,8 @@ "Disconnected": "Disconnected", "Exit node: ": "Exit node: ", "Public IP: ": "Public IP: ", + "tap to check": "tap to check", + "checking…": "checking…", "None": "None", "Copied %1 to clipboard": "Copied %1 to clipboard", "Invalid exit node hostname": "Invalid exit node hostname", @@ -13,5 +15,6 @@ "Failed to set exit node": "Failed to set exit node", "Failed to read Tailscale status": "Failed to read Tailscale status", "Error copying to clipboard": "Error copying to clipboard", + "Failed to look up public IP": "Failed to look up public IP", "Tailscale command failed": "Tailscale command failed" } diff --git a/tailscalectl/lib.js b/tailscalectl/lib.js index f196a9b..7495ac7 100644 --- a/tailscalectl/lib.js +++ b/tailscalectl/lib.js @@ -36,19 +36,6 @@ function findActiveExitNode(peerMap) { 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 === "") { @@ -66,11 +53,10 @@ function hostFromEndpoint(endpoint) { 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). +// IPv4 only. Reject private, loopback, link-local, and CGNAT (100.64/10). function isPublicIPv4(ip) { if (typeof ip !== "string" || ip === "") { return false; @@ -89,54 +75,42 @@ function isPublicIPv4(ip) { 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 ""; +// 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"] + ]; } -// 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; - } +// Parse curl stdout from an egress checker into a public IPv4, or "" if unusable. +function parseEgressCheckResponse(stdout) { + if (typeof stdout !== "string") { + return ""; } - if (selfNode && selfNode.Addrs) { - return extractPublicIPFromAddrs(selfNode.Addrs); + var line = stdout.trim().split(/\r?\n/)[0] || ""; + line = line.trim(); + if (isPublicIPv4(line)) { + return line; } return ""; } @@ -159,6 +133,8 @@ function getStrings() { 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", @@ -179,7 +155,6 @@ function isValidExitNodeHostname(hostname) { 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); } @@ -187,7 +162,6 @@ function emptyStatusState() { return { isConnected: false, tailscaleIP: "", - publicIP: "", currentExitNode: "", peers: [] }; @@ -206,7 +180,6 @@ function parseStatusResult(jsonText) { return { isConnected: true, tailscaleIP: (selfNode.TailscaleIPs && selfNode.TailscaleIPs[0]) || "", - publicIP: resolvePublicIP(selfNode, peerMap), currentExitNode: findActiveExitNode(peerMap), peers: parsePeers(peerMap) }; @@ -219,7 +192,6 @@ 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"]; } @@ -232,12 +204,12 @@ function errorMessage(cmd) { "disconnect": "Failed to disconnect from Tailscale", "set": "Failed to set exit node", "status": "Failed to read Tailscale status", - "clipboard": "Error copying to clipboard" + "clipboard": "Error copying to clipboard", + "egress": "Failed to look up public IP" }; 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) { @@ -251,8 +223,6 @@ 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; @@ -280,7 +250,7 @@ if (typeof module !== "undefined" && module.exports) { commandForPendingAction, hostFromEndpoint, isPublicIPv4, - extractPublicIPFromAddrs, - resolvePublicIP + getEgressCheckCommands, + parseEgressCheckResponse }; } diff --git a/tailscalectl/plugin.json b/tailscalectl/plugin.json index c10ff66..30c6225 100644 --- a/tailscalectl/plugin.json +++ b/tailscalectl/plugin.json @@ -9,5 +9,5 @@ "component": "./TailscaleWidget.qml", "permissions": ["process"], "requires": ["tailscale"], - "version": "0.2.2" + "version": "0.2.3" } diff --git a/test/lib.test.js b/test/lib.test.js index 22ca3bc..1c984c0 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -384,7 +384,7 @@ test("lib does not export trivial UI predicates shouldShowClearExitNode / isActi assert.strictEqual(lib.isActiveExitNode, undefined); }); -// --- public IP extraction (#54) --- +// --- on-demand public egress check (#54) --- test("hostFromEndpoint strips port from IPv4 endpoint", () => { assert.strictEqual(lib.hostFromEndpoint("76.87.221.174:41641"), "76.87.221.174"); @@ -409,68 +409,53 @@ test("isPublicIPv4 accepts global unicast and rejects private/CGNAT/loopback", ( assert.strictEqual(lib.isPublicIPv4(""), false); }); -test("extractPublicIPFromAddrs returns first public IPv4 from endpoint list", () => { - const addrs = [ - "10.0.3.103:41641", - "76.87.221.174:45609", - "76.87.221.174:41641", - "172.17.0.1:41641" - ]; - assert.strictEqual(lib.extractPublicIPFromAddrs(addrs), "76.87.221.174"); +test("getEgressCheckCommands returns direct curl argv lists (no shell)", () => { + const cmds = lib.getEgressCheckCommands(); + assert.ok(Array.isArray(cmds)); + assert.ok(cmds.length >= 2); + cmds.forEach((cmd) => { + assert.strictEqual(cmd[0], "curl"); + assert.ok(cmd.includes("-4")); + assert.ok(cmd.includes("-sS") || cmd.includes("-s")); + assert.ok(cmd.some((a) => String(a).startsWith("https://"))); + }); }); -test("extractPublicIPFromAddrs returns empty when no public IP", () => { - assert.strictEqual(lib.extractPublicIPFromAddrs(["10.0.0.1:1", "100.64.0.1:1"]), ""); - assert.strictEqual(lib.extractPublicIPFromAddrs(null), ""); - assert.strictEqual(lib.extractPublicIPFromAddrs([]), ""); +test("parseEgressCheckResponse accepts trimmed public IPv4", () => { + assert.strictEqual(lib.parseEgressCheckResponse("76.87.221.174\n"), "76.87.221.174"); + assert.strictEqual(lib.parseEgressCheckResponse(" 8.8.8.8 "), "8.8.8.8"); }); -test("parseStatusResult includes publicIP from Self.Addrs when Running and no exit node (#54)", () => { +test("parseEgressCheckResponse rejects garbage private and empty", () => { + assert.strictEqual(lib.parseEgressCheckResponse(""), ""); + assert.strictEqual(lib.parseEgressCheckResponse("not an ip"), ""); + assert.strictEqual(lib.parseEgressCheckResponse("10.0.0.1"), ""); + assert.strictEqual(lib.parseEgressCheckResponse("100.64.0.1"), ""); + assert.strictEqual(lib.parseEgressCheckResponse(null), ""); +}); + +test("parseStatusResult does not auto-fill publicIP (lazy egress is separate)", () => { const json = JSON.stringify({ BackendState: "Running", Self: { TailscaleIPs: ["100.64.0.5"], - Addrs: ["10.0.0.2:41641", "203.0.113.10:41641"] + Addrs: ["203.0.113.10:41641"] }, Peer: {} }); const state = lib.parseStatusResult(json); - assert.strictEqual(state.publicIP, "203.0.113.10"); + assert.strictEqual(state.isConnected, true); assert.strictEqual(state.tailscaleIP, "100.64.0.5"); + assert.strictEqual(state.publicIP, undefined); }); -test("parseStatusResult prefers active exit node peer Addrs for publicIP (#54 egress)", () => { - const json = JSON.stringify({ - BackendState: "Running", - Self: { - TailscaleIPs: ["100.64.0.5"], - Addrs: ["198.51.100.1:41641"] - }, - Peer: { - "exit-key": { - HostName: "gluetun-sjc", - ExitNode: true, - ExitNodeOption: true, - TailscaleIPs: ["100.64.0.9"], - Addrs: ["10.1.1.1:41641", "203.0.113.50:41641"] - } - } - }); - const state = lib.parseStatusResult(json); - assert.strictEqual(state.currentExitNode, "gluetun-sjc"); - assert.strictEqual(state.publicIP, "203.0.113.50"); +test("getStrings includes public IP lazy-load strings", () => { + const s = lib.getStrings(); + assert.ok(s.publicIPPrefix); + assert.ok(s.publicIPTapHint); + assert.ok(s.publicIPLoading); }); -test("parseStatusResult clears publicIP when not Running (#55 + #54)", () => { - const json = JSON.stringify({ - BackendState: "Stopped", - Self: { TailscaleIPs: ["100.64.0.5"], Addrs: ["203.0.113.10:41641"] }, - Peer: {} - }); - const state = lib.parseStatusResult(json); - assert.strictEqual(state.publicIP, ""); -}); - -test("getStrings includes publicIPPrefix", () => { - assert.ok(lib.getStrings().publicIPPrefix); +test("errorMessage includes egress failure", () => { + assert.strictEqual(lib.errorMessage("egress"), "Failed to look up public IP"); });