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.
This commit is contained in:
parent
108e77a024
commit
6a586119be
6 changed files with 158 additions and 141 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.
|
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
|
## Features
|
||||||
|
|
||||||
|
|
@ -10,7 +10,7 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank
|
||||||
- **Right-click** to toggle Tailscale on/off
|
- **Right-click** to toggle Tailscale on/off
|
||||||
- **Left-click** to open a popout showing:
|
- **Left-click** to open a popout showing:
|
||||||
- Your current Tailscale IP (when connected)
|
- 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)
|
- Active exit node (with clear button)
|
||||||
- Peer list with hostnames and IPs (when connected)
|
- Peer list with hostnames and IPs (when connected)
|
||||||
- A clear "Not connected" empty state when disconnected (no stale peer list)
|
- 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",
|
"component": "./TailscaleWidget.qml",
|
||||||
"permissions": ["process"],
|
"permissions": ["process"],
|
||||||
"requires": ["tailscale"],
|
"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.).
|
- 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`).
|
- 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.
|
- 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`).
|
- 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).
|
- Peer `ListView` uses `Flickable.StopAtBounds` (no desktop rubber-band overshoot).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,17 +11,20 @@ PluginComponent {
|
||||||
|
|
||||||
property bool isConnected: false
|
property bool isConnected: false
|
||||||
property string tailscaleIP: ""
|
property string tailscaleIP: ""
|
||||||
|
// Lazy true-egress IP — only filled by explicit user-triggered check (#54).
|
||||||
property string publicIP: ""
|
property string publicIP: ""
|
||||||
|
property bool publicIPLoading: false
|
||||||
property string currentExitNode: ""
|
property string currentExitNode: ""
|
||||||
property var peers: []
|
property var peers: []
|
||||||
property string _copyText: ""
|
property string _copyText: ""
|
||||||
property int _copyIndex: 0
|
property int _copyIndex: 0
|
||||||
|
property int _egressIndex: 0
|
||||||
|
|
||||||
// Transient coordination for defensive poll-act-poll toggle (not long-term cache).
|
// Transient coordination for defensive poll-act-poll toggle (not long-term cache).
|
||||||
// Poll for ground truth → act → poll again for verification.
|
// Poll for ground truth → act → poll again for verification.
|
||||||
property string _pendingAction: ""
|
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
|
property bool _busy: false
|
||||||
|
|
||||||
layerNamespacePlugin: "tailscalectl"
|
layerNamespacePlugin: "tailscalectl"
|
||||||
|
|
@ -32,67 +35,65 @@ PluginComponent {
|
||||||
root._runStatusCheck();
|
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() {
|
function _runStatusCheck() {
|
||||||
if (root._busy && root._pendingAction === "") {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
root._busy = true;
|
root._busy = true;
|
||||||
Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => {
|
Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => {
|
||||||
var statusOk = (code === 0);
|
var statusOk = (code === 0);
|
||||||
if (statusOk) {
|
if (statusOk) {
|
||||||
const state = TailscaleLib.parseStatusResult(stdout);
|
root._applyStatusState(TailscaleLib.parseStatusResult(stdout));
|
||||||
root.isConnected = state.isConnected;
|
|
||||||
root.tailscaleIP = state.tailscaleIP;
|
|
||||||
root.publicIP = state.publicIP;
|
|
||||||
root.currentExitNode = state.currentExitNode;
|
|
||||||
root.peers = state.peers;
|
|
||||||
} else {
|
} else {
|
||||||
root.isConnected = false;
|
root._clearConnectionState();
|
||||||
root.tailscaleIP = "";
|
|
||||||
root.publicIP = "";
|
|
||||||
root.currentExitNode = "";
|
|
||||||
root.peers = [];
|
|
||||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status")));
|
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status")));
|
||||||
}
|
}
|
||||||
|
|
||||||
const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected, statusOk);
|
const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected, statusOk);
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
// Fresh poll succeeded; act, then verify with another status poll.
|
|
||||||
Proc.runCommand("tailscale-toggle", cmd, (out, c) => {
|
Proc.runCommand("tailscale-toggle", cmd, (out, c) => {
|
||||||
if (c !== 0) {
|
if (c !== 0) {
|
||||||
const action = root.isConnected ? "disconnect" : "connect";
|
const action = root.isConnected ? "disconnect" : "connect";
|
||||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError(action)));
|
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError(action)));
|
||||||
}
|
}
|
||||||
root._pendingAction = "";
|
root._pendingAction = "";
|
||||||
// Keep busy through verification poll: call internal runner that assumes we own the lock.
|
|
||||||
root._runStatusCheckUnlocked();
|
root._runStatusCheckUnlocked();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Includes failed status while a toggle was pending: abort rather than invent up/down.
|
|
||||||
root._pendingAction = "";
|
root._pendingAction = "";
|
||||||
root._busy = false;
|
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() {
|
function _runStatusCheckUnlocked() {
|
||||||
Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => {
|
Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
const state = TailscaleLib.parseStatusResult(stdout);
|
root._applyStatusState(TailscaleLib.parseStatusResult(stdout));
|
||||||
root.isConnected = state.isConnected;
|
|
||||||
root.tailscaleIP = state.tailscaleIP;
|
|
||||||
root.publicIP = state.publicIP;
|
|
||||||
root.currentExitNode = state.currentExitNode;
|
|
||||||
root.peers = state.peers;
|
|
||||||
} else {
|
} else {
|
||||||
root.isConnected = false;
|
root._clearConnectionState();
|
||||||
root.tailscaleIP = "";
|
|
||||||
root.publicIP = "";
|
|
||||||
root.currentExitNode = "";
|
|
||||||
root.peers = [];
|
|
||||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status")));
|
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status")));
|
||||||
}
|
}
|
||||||
root._busy = false;
|
root._busy = false;
|
||||||
|
|
@ -124,11 +125,13 @@ PluginComponent {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
root._busy = true;
|
root._busy = true;
|
||||||
|
// Exit-node change invalidates any previously checked egress IP.
|
||||||
|
root.publicIP = "";
|
||||||
|
root.publicIPLoading = false;
|
||||||
Proc.runCommand("tailscale-exit", cmd, (stdout, code) => {
|
Proc.runCommand("tailscale-exit", cmd, (stdout, code) => {
|
||||||
if (code !== 0) {
|
if (code !== 0) {
|
||||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set")));
|
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set")));
|
||||||
}
|
}
|
||||||
// Verify via unlocked status continuation (busy already held).
|
|
||||||
root._runStatusCheckUnlocked();
|
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 {
|
popoutContent: Component {
|
||||||
PopoutComponent {
|
PopoutComponent {
|
||||||
headerText: I18n.tr(TailscaleLib.getStrings().header)
|
headerText: I18n.tr(TailscaleLib.getStrings().header)
|
||||||
|
|
@ -172,7 +223,7 @@ PluginComponent {
|
||||||
width: parent.width
|
width: parent.width
|
||||||
height: Theme.spacingM + statusRow.implicitHeight
|
height: Theme.spacingM + statusRow.implicitHeight
|
||||||
+ Theme.spacingXS
|
+ Theme.spacingXS
|
||||||
+ (root.isConnected && root.publicIP !== "" ? Theme.fontSizeSmall + Theme.spacingXS : 0)
|
+ (root.isConnected ? Theme.fontSizeSmall + Theme.spacingXS : 0)
|
||||||
+ Theme.spacingM + peerArea.height + Theme.spacingM
|
+ Theme.spacingM + peerArea.height + Theme.spacingM
|
||||||
|
|
||||||
RowLayout {
|
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 {
|
MouseArea {
|
||||||
id: publicIpRow
|
id: publicIpRow
|
||||||
visible: root.isConnected && root.publicIP !== ""
|
visible: root.isConnected
|
||||||
y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingXS
|
y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingXS
|
||||||
anchors.left: parent.left
|
anchors.left: parent.left
|
||||||
anchors.leftMargin: Theme.spacingM
|
anchors.leftMargin: Theme.spacingM
|
||||||
|
|
@ -255,12 +306,21 @@ PluginComponent {
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.copyToClipboard(root.publicIP);
|
root.onPublicIpRowClicked();
|
||||||
}
|
}
|
||||||
|
|
||||||
StyledText {
|
StyledText {
|
||||||
id: publicIpText
|
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
|
font.pixelSize: Theme.fontSizeSmall
|
||||||
color: Theme.surfaceVariantText
|
color: Theme.surfaceVariantText
|
||||||
}
|
}
|
||||||
|
|
@ -295,7 +355,6 @@ PluginComponent {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
model: root.peers
|
model: root.peers
|
||||||
interactive: true
|
interactive: true
|
||||||
// Desktop popout: no rubber-band overshoot (#27).
|
|
||||||
boundsBehavior: Flickable.StopAtBounds
|
boundsBehavior: Flickable.StopAtBounds
|
||||||
clip: true
|
clip: true
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
"Disconnected": "Disconnected",
|
"Disconnected": "Disconnected",
|
||||||
"Exit node: ": "Exit node: ",
|
"Exit node: ": "Exit node: ",
|
||||||
"Public IP: ": "Public IP: ",
|
"Public IP: ": "Public IP: ",
|
||||||
|
"tap to check": "tap to check",
|
||||||
|
"checking…": "checking…",
|
||||||
"None": "None",
|
"None": "None",
|
||||||
"Copied %1 to clipboard": "Copied %1 to clipboard",
|
"Copied %1 to clipboard": "Copied %1 to clipboard",
|
||||||
"Invalid exit node hostname": "Invalid exit node hostname",
|
"Invalid exit node hostname": "Invalid exit node hostname",
|
||||||
|
|
@ -13,5 +15,6 @@
|
||||||
"Failed to set exit node": "Failed to set exit node",
|
"Failed to set exit node": "Failed to set exit node",
|
||||||
"Failed to read Tailscale status": "Failed to read Tailscale status",
|
"Failed to read Tailscale status": "Failed to read Tailscale status",
|
||||||
"Error copying to clipboard": "Error copying to clipboard",
|
"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"
|
"Tailscale command failed": "Tailscale command failed"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,19 +36,6 @@ function findActiveExitNode(peerMap) {
|
||||||
return "";
|
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".
|
// Strip host from endpoint strings like "1.2.3.4:41641" or "[2001:db8::1]:41641".
|
||||||
function hostFromEndpoint(endpoint) {
|
function hostFromEndpoint(endpoint) {
|
||||||
if (typeof endpoint !== "string" || endpoint === "") {
|
if (typeof endpoint !== "string" || endpoint === "") {
|
||||||
|
|
@ -66,11 +53,10 @@ function hostFromEndpoint(endpoint) {
|
||||||
if (colon > -1 && endpoint.indexOf(":") === colon) {
|
if (colon > -1 && endpoint.indexOf(":") === colon) {
|
||||||
return endpoint.slice(0, colon);
|
return endpoint.slice(0, colon);
|
||||||
}
|
}
|
||||||
// Bare address (or unusual form): return as-is.
|
|
||||||
return endpoint;
|
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) {
|
function isPublicIPv4(ip) {
|
||||||
if (typeof ip !== "string" || ip === "") {
|
if (typeof ip !== "string" || ip === "") {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -89,54 +75,42 @@ function isPublicIPv4(ip) {
|
||||||
if (a === 0 || a === 127 || a >= 224) {
|
if (a === 0 || a === 127 || a >= 224) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 10.0.0.0/8
|
|
||||||
if (a === 10) {
|
if (a === 10) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 172.16.0.0/12
|
|
||||||
if (a === 172 && b >= 16 && b <= 31) {
|
if (a === 172 && b >= 16 && b <= 31) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 192.168.0.0/16
|
|
||||||
if (a === 192 && b === 168) {
|
if (a === 192 && b === 168) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 100.64.0.0/10 (CGNAT / Tailscale range)
|
|
||||||
if (a === 100 && b >= 64 && b <= 127) {
|
if (a === 100 && b >= 64 && b <= 127) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// 169.254.0.0/16 link-local
|
|
||||||
if (a === 169 && b === 254) {
|
if (a === 169 && b === 254) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPublicIPFromAddrs(addrs) {
|
// Ordered true-egress probes (IPv4). First success wins. Direct argv only — no shell.
|
||||||
if (!addrs || !addrs.length) {
|
// Lazy / on-demand only; never run from the periodic status path.
|
||||||
return "";
|
function getEgressCheckCommands() {
|
||||||
}
|
return [
|
||||||
for (var i = 0; i < addrs.length; i++) {
|
["curl", "-4", "-sS", "--max-time", "5", "https://api.ipify.org"],
|
||||||
var host = hostFromEndpoint(addrs[i]);
|
["curl", "-4", "-sS", "--max-time", "5", "https://icanhazip.com"]
|
||||||
if (isPublicIPv4(host)) {
|
];
|
||||||
return host;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer exit-node peer endpoints when an exit node is active (closer to egress seen by websites).
|
// Parse curl stdout from an egress checker into a public IPv4, or "" if unusable.
|
||||||
// Otherwise use Self.Addrs. This is status-derived, not an external probe.
|
function parseEgressCheckResponse(stdout) {
|
||||||
function resolvePublicIP(selfNode, peerMap) {
|
if (typeof stdout !== "string") {
|
||||||
var exitPeer = findActiveExitNodePeer(peerMap);
|
return "";
|
||||||
if (exitPeer && exitPeer.Addrs) {
|
|
||||||
var viaExit = extractPublicIPFromAddrs(exitPeer.Addrs);
|
|
||||||
if (viaExit) {
|
|
||||||
return viaExit;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (selfNode && selfNode.Addrs) {
|
var line = stdout.trim().split(/\r?\n/)[0] || "";
|
||||||
return extractPublicIPFromAddrs(selfNode.Addrs);
|
line = line.trim();
|
||||||
|
if (isPublicIPv4(line)) {
|
||||||
|
return line;
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
@ -159,6 +133,8 @@ function getStrings() {
|
||||||
disconnected: "Disconnected",
|
disconnected: "Disconnected",
|
||||||
exitNodePrefix: "Exit node: ",
|
exitNodePrefix: "Exit node: ",
|
||||||
publicIPPrefix: "Public IP: ",
|
publicIPPrefix: "Public IP: ",
|
||||||
|
publicIPTapHint: "tap to check",
|
||||||
|
publicIPLoading: "checking…",
|
||||||
none: "None",
|
none: "None",
|
||||||
copied: "Copied %1 to clipboard",
|
copied: "Copied %1 to clipboard",
|
||||||
invalidExitNodeHostname: "Invalid exit node hostname",
|
invalidExitNodeHostname: "Invalid exit node hostname",
|
||||||
|
|
@ -179,7 +155,6 @@ function isValidExitNodeHostname(hostname) {
|
||||||
if (hostname.length > 253) {
|
if (hostname.length > 253) {
|
||||||
return false;
|
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);
|
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 {
|
return {
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
tailscaleIP: "",
|
tailscaleIP: "",
|
||||||
publicIP: "",
|
|
||||||
currentExitNode: "",
|
currentExitNode: "",
|
||||||
peers: []
|
peers: []
|
||||||
};
|
};
|
||||||
|
|
@ -206,7 +180,6 @@ function parseStatusResult(jsonText) {
|
||||||
return {
|
return {
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
tailscaleIP: (selfNode.TailscaleIPs && selfNode.TailscaleIPs[0]) || "",
|
tailscaleIP: (selfNode.TailscaleIPs && selfNode.TailscaleIPs[0]) || "",
|
||||||
publicIP: resolvePublicIP(selfNode, peerMap),
|
|
||||||
currentExitNode: findActiveExitNode(peerMap),
|
currentExitNode: findActiveExitNode(peerMap),
|
||||||
peers: parsePeers(peerMap)
|
peers: parsePeers(peerMap)
|
||||||
};
|
};
|
||||||
|
|
@ -219,7 +192,6 @@ 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.
|
|
||||||
function getStatusCommand() {
|
function getStatusCommand() {
|
||||||
return ["tailscale", "status", "--json"];
|
return ["tailscale", "status", "--json"];
|
||||||
}
|
}
|
||||||
|
|
@ -232,12 +204,12 @@ function errorMessage(cmd) {
|
||||||
"disconnect": "Failed to disconnect from Tailscale",
|
"disconnect": "Failed to disconnect from Tailscale",
|
||||||
"set": "Failed to set exit node",
|
"set": "Failed to set exit node",
|
||||||
"status": "Failed to read Tailscale status",
|
"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";
|
return messages[cmd] || "Tailscale command failed";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Central error formatting for the widget. detail is optional truncated stderr or extra context.
|
|
||||||
function formatError(action, detail) {
|
function formatError(action, detail) {
|
||||||
var base = errorMessage(action);
|
var base = errorMessage(action);
|
||||||
if (detail && detail.length > 0) {
|
if (detail && detail.length > 0) {
|
||||||
|
|
@ -251,8 +223,6 @@ const PendingAction = Object.freeze({
|
||||||
TOGGLE: "toggle"
|
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) {
|
function commandForPendingAction(pending, freshIsConnected, statusOk) {
|
||||||
if (!statusOk) {
|
if (!statusOk) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -280,7 +250,7 @@ if (typeof module !== "undefined" && module.exports) {
|
||||||
commandForPendingAction,
|
commandForPendingAction,
|
||||||
hostFromEndpoint,
|
hostFromEndpoint,
|
||||||
isPublicIPv4,
|
isPublicIPv4,
|
||||||
extractPublicIPFromAddrs,
|
getEgressCheckCommands,
|
||||||
resolvePublicIP
|
parseEgressCheckResponse
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,5 @@
|
||||||
"component": "./TailscaleWidget.qml",
|
"component": "./TailscaleWidget.qml",
|
||||||
"permissions": ["process"],
|
"permissions": ["process"],
|
||||||
"requires": ["tailscale"],
|
"requires": ["tailscale"],
|
||||||
"version": "0.2.2"
|
"version": "0.2.3"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -384,7 +384,7 @@ test("lib does not export trivial UI predicates shouldShowClearExitNode / isActi
|
||||||
assert.strictEqual(lib.isActiveExitNode, undefined);
|
assert.strictEqual(lib.isActiveExitNode, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- public IP extraction (#54) ---
|
// --- on-demand public egress check (#54) ---
|
||||||
|
|
||||||
test("hostFromEndpoint strips port from IPv4 endpoint", () => {
|
test("hostFromEndpoint strips port from IPv4 endpoint", () => {
|
||||||
assert.strictEqual(lib.hostFromEndpoint("76.87.221.174:41641"), "76.87.221.174");
|
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);
|
assert.strictEqual(lib.isPublicIPv4(""), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("extractPublicIPFromAddrs returns first public IPv4 from endpoint list", () => {
|
test("getEgressCheckCommands returns direct curl argv lists (no shell)", () => {
|
||||||
const addrs = [
|
const cmds = lib.getEgressCheckCommands();
|
||||||
"10.0.3.103:41641",
|
assert.ok(Array.isArray(cmds));
|
||||||
"76.87.221.174:45609",
|
assert.ok(cmds.length >= 2);
|
||||||
"76.87.221.174:41641",
|
cmds.forEach((cmd) => {
|
||||||
"172.17.0.1:41641"
|
assert.strictEqual(cmd[0], "curl");
|
||||||
];
|
assert.ok(cmd.includes("-4"));
|
||||||
assert.strictEqual(lib.extractPublicIPFromAddrs(addrs), "76.87.221.174");
|
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", () => {
|
test("parseEgressCheckResponse accepts trimmed public IPv4", () => {
|
||||||
assert.strictEqual(lib.extractPublicIPFromAddrs(["10.0.0.1:1", "100.64.0.1:1"]), "");
|
assert.strictEqual(lib.parseEgressCheckResponse("76.87.221.174\n"), "76.87.221.174");
|
||||||
assert.strictEqual(lib.extractPublicIPFromAddrs(null), "");
|
assert.strictEqual(lib.parseEgressCheckResponse(" 8.8.8.8 "), "8.8.8.8");
|
||||||
assert.strictEqual(lib.extractPublicIPFromAddrs([]), "");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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({
|
const json = JSON.stringify({
|
||||||
BackendState: "Running",
|
BackendState: "Running",
|
||||||
Self: {
|
Self: {
|
||||||
TailscaleIPs: ["100.64.0.5"],
|
TailscaleIPs: ["100.64.0.5"],
|
||||||
Addrs: ["10.0.0.2:41641", "203.0.113.10:41641"]
|
Addrs: ["203.0.113.10:41641"]
|
||||||
},
|
},
|
||||||
Peer: {}
|
Peer: {}
|
||||||
});
|
});
|
||||||
const state = lib.parseStatusResult(json);
|
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.tailscaleIP, "100.64.0.5");
|
||||||
|
assert.strictEqual(state.publicIP, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("parseStatusResult prefers active exit node peer Addrs for publicIP (#54 egress)", () => {
|
test("getStrings includes public IP lazy-load strings", () => {
|
||||||
const json = JSON.stringify({
|
const s = lib.getStrings();
|
||||||
BackendState: "Running",
|
assert.ok(s.publicIPPrefix);
|
||||||
Self: {
|
assert.ok(s.publicIPTapHint);
|
||||||
TailscaleIPs: ["100.64.0.5"],
|
assert.ok(s.publicIPLoading);
|
||||||
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)", () => {
|
test("errorMessage includes egress failure", () => {
|
||||||
const json = JSON.stringify({
|
assert.strictEqual(lib.errorMessage("egress"), "Failed to look up public IP");
|
||||||
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