feat: show public IP from Tailscale endpoints (#54)
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.
This commit is contained in:
parent
bbce7f0100
commit
108e77a024
6 changed files with 261 additions and 16 deletions
|
|
@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,5 @@
|
|||
"component": "./TailscaleWidget.qml",
|
||||
"permissions": ["process"],
|
||||
"requires": ["tailscale"],
|
||||
"version": "0.2.1"
|
||||
"version": "0.2.2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue