From 108e77a024817e948011de9c3cdfb9d14ca9497d Mon Sep 17 00:00:00 2001 From: "Ebeneezer (Hermes Agent)" Date: Wed, 15 Jul 2026 01:31:46 -0700 Subject: [PATCH] feat: show public IP from Tailscale endpoints (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Derive public IPv4 from Self.Addrs (or active exit-node peer Addrs when an exit node is selected). Display click-to-copy under the status row. No external HTTP probe — status JSON only. Version 0.2.2. Unit tests + live status parse verification. Written by AI agent working for @jtmorris. Model: Grok 4.5. --- README.md | 6 +- tailscalectl/TailscaleWidget.qml | 37 +++++++- tailscalectl/i18n/en.json | 1 + tailscalectl/lib.js | 140 ++++++++++++++++++++++++++++--- tailscalectl/plugin.json | 2 +- test/lib.test.js | 91 ++++++++++++++++++++ 6 files changed, 261 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 1efd598..c1fe92e 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.1](resources/dms_tailscalectl_v0.1.0.png) +![Tailscale Widget v0.2.2](resources/dms_tailscalectl_v0.1.0.png) ## Features @@ -10,6 +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) - 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) @@ -80,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.1" + "version": "0.2.2" } ``` @@ -91,6 +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. - 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 5df7d3a..a11694b 100644 --- a/tailscalectl/TailscaleWidget.qml +++ b/tailscalectl/TailscaleWidget.qml @@ -11,6 +11,7 @@ PluginComponent { property bool isConnected: false property string tailscaleIP: "" + property string publicIP: "" property string currentExitNode: "" property var peers: [] property string _copyText: "" @@ -44,11 +45,13 @@ PluginComponent { 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; } else { root.isConnected = false; root.tailscaleIP = ""; + root.publicIP = ""; root.currentExitNode = ""; root.peers = []; ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status"))); @@ -81,11 +84,13 @@ PluginComponent { 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; } else { root.isConnected = false; root.tailscaleIP = ""; + root.publicIP = ""; root.currentExitNode = ""; root.peers = []; ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status"))); @@ -165,7 +170,10 @@ PluginComponent { Item { id: contentItem width: parent.width - height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerArea.height + Theme.spacingM + height: Theme.spacingM + statusRow.implicitHeight + + Theme.spacingXS + + (root.isConnected && root.publicIP !== "" ? Theme.fontSizeSmall + Theme.spacingXS : 0) + + Theme.spacingM + peerArea.height + Theme.spacingM RowLayout { id: statusRow @@ -235,10 +243,35 @@ PluginComponent { } } + // #54: public egress IP (status-derived from Self/exit-node endpoints) + MouseArea { + id: publicIpRow + visible: root.isConnected && root.publicIP !== "" + y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingXS + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + width: publicIpText.implicitWidth + height: publicIpText.implicitHeight + cursorShape: Qt.PointingHandCursor + hoverEnabled: true + onClicked: { + root.copyToClipboard(root.publicIP); + } + + StyledText { + id: publicIpText + text: I18n.tr(TailscaleLib.getStrings().publicIPPrefix) + root.publicIP + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + } + // Peer list when connected; empty-state hint when not (#55). Item { id: peerArea - y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingXS + + (publicIpRow.visible ? publicIpRow.height + Theme.spacingXS : 0) + + Theme.spacingM width: parent.width - Theme.spacingM * 2 height: root.isConnected ? Math.min(Math.max(root.peers.length, 1) * (Theme.fontSizeSmall + Theme.spacingXS), 200) diff --git a/tailscalectl/i18n/en.json b/tailscalectl/i18n/en.json index b0b9fbd..11de0ab 100644 --- a/tailscalectl/i18n/en.json +++ b/tailscalectl/i18n/en.json @@ -3,6 +3,7 @@ "Connected": "Connected", "Disconnected": "Disconnected", "Exit node: ": "Exit node: ", + "Public IP: ": "Public IP: ", "None": "None", "Copied %1 to clipboard": "Copied %1 to clipboard", "Invalid exit node hostname": "Invalid exit node hostname", diff --git a/tailscalectl/lib.js b/tailscalectl/lib.js index 2c0dda3..f196a9b 100644 --- a/tailscalectl/lib.js +++ b/tailscalectl/lib.js @@ -36,6 +36,111 @@ 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 === "") { + 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 ""; +} + const clipboardTools = [ { argv: ["dms", "cl", "copy"] }, { argv: ["wl-copy"] } @@ -53,6 +158,7 @@ function getStrings() { connected: "Connected", disconnected: "Disconnected", exitNodePrefix: "Exit node: ", + publicIPPrefix: "Public IP: ", none: "None", copied: "Copied %1 to clipboard", invalidExitNodeHostname: "Invalid exit node hostname", @@ -77,27 +183,35 @@ function isValidExitNodeHostname(hostname) { 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: "", + publicIP: "", + 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 { - isConnected: false, - tailscaleIP: "", - currentExitNode: "", - peers: [] - }; + return emptyStatusState(); } + const peerMap = data.Peer || {}; + const selfNode = data.Self || {}; return { isConnected: true, - tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "", - currentExitNode: findActiveExitNode(data.Peer || {}), - peers: parsePeers(data.Peer || {}) + tailscaleIP: (selfNode.TailscaleIPs && selfNode.TailscaleIPs[0]) || "", + publicIP: resolvePublicIP(selfNode, peerMap), + currentExitNode: findActiveExitNode(peerMap), + peers: parsePeers(peerMap) }; } catch (e) { - return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] }; + return emptyStatusState(); } } @@ -163,6 +277,10 @@ if (typeof module !== "undefined" && module.exports) { parseStatusResult, getStrings, PendingAction, - commandForPendingAction + commandForPendingAction, + hostFromEndpoint, + isPublicIPv4, + extractPublicIPFromAddrs, + resolvePublicIP }; } diff --git a/tailscalectl/plugin.json b/tailscalectl/plugin.json index a2ce106..c10ff66 100644 --- a/tailscalectl/plugin.json +++ b/tailscalectl/plugin.json @@ -9,5 +9,5 @@ "component": "./TailscaleWidget.qml", "permissions": ["process"], "requires": ["tailscale"], - "version": "0.2.1" + "version": "0.2.2" } diff --git a/test/lib.test.js b/test/lib.test.js index 9e8be51..22ca3bc 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -383,3 +383,94 @@ test("lib does not export trivial UI predicates shouldShowClearExitNode / isActi assert.strictEqual(lib.shouldShowClearExitNode, undefined); assert.strictEqual(lib.isActiveExitNode, undefined); }); + +// --- public IP extraction (#54) --- + +test("hostFromEndpoint strips port from IPv4 endpoint", () => { + assert.strictEqual(lib.hostFromEndpoint("76.87.221.174:41641"), "76.87.221.174"); + assert.strictEqual(lib.hostFromEndpoint("10.0.3.103:41641"), "10.0.3.103"); +}); + +test("hostFromEndpoint handles bare IP and bracketed IPv6", () => { + assert.strictEqual(lib.hostFromEndpoint("8.8.8.8"), "8.8.8.8"); + assert.strictEqual(lib.hostFromEndpoint("[2001:db8::1]:41641"), "2001:db8::1"); +}); + +test("isPublicIPv4 accepts global unicast and rejects private/CGNAT/loopback", () => { + assert.strictEqual(lib.isPublicIPv4("76.87.221.174"), true); + assert.strictEqual(lib.isPublicIPv4("8.8.8.8"), true); + assert.strictEqual(lib.isPublicIPv4("10.0.3.103"), false); + assert.strictEqual(lib.isPublicIPv4("192.168.1.1"), false); + assert.strictEqual(lib.isPublicIPv4("172.17.0.1"), false); + assert.strictEqual(lib.isPublicIPv4("127.0.0.1"), false); + assert.strictEqual(lib.isPublicIPv4("100.64.0.5"), false); + assert.strictEqual(lib.isPublicIPv4("100.120.126.20"), false); + assert.strictEqual(lib.isPublicIPv4("not-an-ip"), false); + 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("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("parseStatusResult includes publicIP from Self.Addrs when Running and no exit node (#54)", () => { + const json = JSON.stringify({ + BackendState: "Running", + Self: { + TailscaleIPs: ["100.64.0.5"], + Addrs: ["10.0.0.2:41641", "203.0.113.10:41641"] + }, + Peer: {} + }); + const state = lib.parseStatusResult(json); + assert.strictEqual(state.publicIP, "203.0.113.10"); + assert.strictEqual(state.tailscaleIP, "100.64.0.5"); +}); + +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("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); +});