Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a586119be | ||
|
|
108e77a024 | ||
|
|
bbce7f0100 | ||
|
|
7c31598594 | ||
|
|
61626dff51 |
7 changed files with 783 additions and 299 deletions
26
README.md
26
README.md
|
|
@ -2,19 +2,22 @@
|
|||
|
||||
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
|
||||
|
||||
- **Status icon** in the bar — `vpn_key` when connected, `vpn_key_off` when disconnected
|
||||
- **Right-click** to toggle Tailscale on/off
|
||||
- **Left-click** to open a popout showing:
|
||||
- Your current Tailscale IP
|
||||
- Your current Tailscale IP (when connected)
|
||||
- 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
|
||||
- Peer list with hostnames and IPs (when connected)
|
||||
- A clear "Not connected" empty state when disconnected (no stale peer list)
|
||||
- **Click-to-copy** any hostname or IP to clipboard
|
||||
- **Exit node selection** — click `↗` on any exit-node-capable peer to route through it
|
||||
- **On-demand status** — polls Tailscale for ground truth on load, explicit actions, and post-mutation verification (defensive poll-act-poll for toggles; no always-on timer)
|
||||
- **Single-flight actions** — concurrent status/toggle/exit/copy chains are rejected while an operation is in flight
|
||||
- **Toast notifications** for all errors
|
||||
|
||||
## Requirements
|
||||
|
|
@ -42,7 +45,8 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank
|
|||
tailscalectl/
|
||||
├── plugin.json
|
||||
├── TailscaleWidget.qml
|
||||
└── lib.js
|
||||
├── lib.js
|
||||
└── i18n/
|
||||
```
|
||||
|
||||
3. Reload the plugin:
|
||||
|
|
@ -70,12 +74,14 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank
|
|||
"id": "tailscalectl",
|
||||
"name": "Tailscale",
|
||||
"description": "Tailscale status and controls on the Dank Bar",
|
||||
"version": "0.2.0",
|
||||
"author": "John Morris & Vybe (AI Slop... er... Coding Assistant)",
|
||||
"author": "John Morris",
|
||||
"icon": "vpn_key",
|
||||
"type": "widget",
|
||||
"capabilities": ["dankbar-widget"],
|
||||
"component": "./TailscaleWidget.qml",
|
||||
"permissions": ["settings_read", "settings_write", "process"]
|
||||
"permissions": ["process"],
|
||||
"requires": ["tailscale"],
|
||||
"version": "0.2.3"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -84,7 +90,11 @@ A lightweight widget plugin that shows Tailscale connectivity status on the Dank
|
|||
- Uses `Proc` singleton (from `qs.Common`) for all external `tailscale` commands (one-shot stdout capture + auto cleanup).
|
||||
- Fully I18n-ready via `I18n.tr(...)` (source keys in American English only today; see `tailscalectl/i18n/` for scaffolding).
|
||||
- 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 (see code comments).
|
||||
- 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 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).
|
||||
|
||||
## Testing
|
||||
|
||||
|
|
|
|||
|
|
@ -11,86 +11,138 @@ 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/egress chains.
|
||||
property bool _busy: false
|
||||
|
||||
layerNamespacePlugin: "tailscalectl"
|
||||
popoutWidth: 360
|
||||
popoutHeight: 400
|
||||
|
||||
// Transient coordination for the defensive poll-act-poll toggle (exact behavior preserved).
|
||||
// We poll for on-the-ground truth (so we choose the correct "up"/"down" and don't lie to the user),
|
||||
// act, then poll again for verification. This is *not* long-term cached state.
|
||||
// The _pendingAction is short-lived per user action only.
|
||||
property string _pendingAction: ""
|
||||
|
||||
Component.onCompleted: {
|
||||
// Initial status fetch on load. Subsequent fetches are on-demand (popout open, explicit refresh, or post-action verification).
|
||||
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 === "") {
|
||||
return;
|
||||
}
|
||||
root._busy = true;
|
||||
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.currentExitNode = state.currentExitNode;
|
||||
root.peers = state.peers;
|
||||
var statusOk = (code === 0);
|
||||
if (statusOk) {
|
||||
root._applyStatusState(TailscaleLib.parseStatusResult(stdout));
|
||||
} else {
|
||||
root.isConnected = false;
|
||||
root.tailscaleIP = "";
|
||||
root.currentExitNode = "";
|
||||
root.peers = [];
|
||||
root._clearConnectionState();
|
||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status")));
|
||||
}
|
||||
|
||||
const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected);
|
||||
const cmd = TailscaleLib.commandForPendingAction(root._pendingAction, root.isConnected, statusOk);
|
||||
if (cmd) {
|
||||
// If toggle action on deck, then we just retrieved on-the-ground truth and can now act to toggle.
|
||||
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 = "";
|
||||
root._runStatusCheck(); // post-action verification poll (exact behavior)
|
||||
root._runStatusCheckUnlocked();
|
||||
});
|
||||
} else {
|
||||
root._pendingAction = "";
|
||||
root._busy = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Continuation after toggle/exit; assumes _busy is already true.
|
||||
function _runStatusCheckUnlocked() {
|
||||
Proc.runCommand("tailscale-status", TailscaleLib.getStatusCommand(), (stdout, code) => {
|
||||
if (code === 0) {
|
||||
root._applyStatusState(TailscaleLib.parseStatusResult(stdout));
|
||||
} else {
|
||||
root._clearConnectionState();
|
||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("status")));
|
||||
}
|
||||
root._busy = false;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTailscale() {
|
||||
root._pendingAction = "toggle";
|
||||
if (root._busy) {
|
||||
return;
|
||||
}
|
||||
root._pendingAction = TailscaleLib.PendingAction.TOGGLE;
|
||||
root._runStatusCheck();
|
||||
}
|
||||
|
||||
function refreshStatus() {
|
||||
if (root._busy) {
|
||||
return;
|
||||
}
|
||||
root._runStatusCheck();
|
||||
}
|
||||
|
||||
function setExitNode(hostname) {
|
||||
if (root._busy) {
|
||||
return;
|
||||
}
|
||||
const cmd = TailscaleLib.makeExitNodeCommand(hostname);
|
||||
if (!cmd) {
|
||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.getStrings().invalidExitNodeHostname));
|
||||
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) {
|
||||
// Note: no stderr detail available from Proc (accepted per plan).
|
||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("set")));
|
||||
}
|
||||
root._runStatusCheck();
|
||||
root._runStatusCheckUnlocked();
|
||||
});
|
||||
}
|
||||
|
||||
function copyToClipboard(text) {
|
||||
if (root._busy) {
|
||||
return;
|
||||
}
|
||||
root._copyText = text;
|
||||
root._copyIndex = 0;
|
||||
root._busy = true;
|
||||
root._runNextCopy();
|
||||
}
|
||||
|
||||
|
|
@ -98,11 +150,13 @@ PluginComponent {
|
|||
const cmds = TailscaleLib.getClipboardCommands(root._copyText);
|
||||
if (root._copyIndex >= cmds.length) {
|
||||
ToastService.showError("tailscalectl", I18n.tr(TailscaleLib.formatError("clipboard")));
|
||||
root._busy = false;
|
||||
return;
|
||||
}
|
||||
Proc.runCommand("tailscale-copy-" + root._copyIndex, cmds[root._copyIndex], (stdout, code) => {
|
||||
if (code === 0) {
|
||||
ToastService.showInfo(I18n.tr(TailscaleLib.getStrings().copied).arg(root._copyText));
|
||||
root._busy = false;
|
||||
} else {
|
||||
root._copyIndex += 1;
|
||||
root._runNextCopy();
|
||||
|
|
@ -110,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)
|
||||
|
|
@ -119,9 +221,12 @@ PluginComponent {
|
|||
Item {
|
||||
id: contentItem
|
||||
width: parent.width
|
||||
height: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM + peerList.height + Theme.spacingM
|
||||
height: Theme.spacingM + statusRow.implicitHeight
|
||||
+ Theme.spacingXS
|
||||
+ (root.isConnected ? Theme.fontSizeSmall + Theme.spacingXS : 0)
|
||||
+ Theme.spacingM + peerArea.height + Theme.spacingM
|
||||
|
||||
Row {
|
||||
RowLayout {
|
||||
id: statusRow
|
||||
y: Theme.spacingM
|
||||
width: parent.width
|
||||
|
|
@ -134,11 +239,11 @@ PluginComponent {
|
|||
MouseArea {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
width: toggleIcon.implicitWidth
|
||||
height: toggleIcon.implicitHeight
|
||||
onClicked: {
|
||||
root.toggleTailscale()
|
||||
root.toggleTailscale();
|
||||
}
|
||||
|
||||
DankIcon {
|
||||
|
|
@ -154,27 +259,30 @@ PluginComponent {
|
|||
text: root.tailscaleIP || "—"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.primary
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
Item { width: 1; height: 1; Layout.fillWidth: true }
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
height: 1
|
||||
}
|
||||
|
||||
StyledText {
|
||||
text: I18n.tr(TailscaleLib.getStrings().exitNodePrefix) + (root.currentExitNode || I18n.tr(TailscaleLib.getStrings().none))
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
visible: TailscaleLib.shouldShowClearExitNode(root.currentExitNode)
|
||||
visible: root.currentExitNode !== ""
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
width: clearExitNodeText.implicitWidth
|
||||
height: clearExitNodeText.implicitHeight
|
||||
onClicked: {
|
||||
root.setExitNode("")
|
||||
root.setExitNode("");
|
||||
}
|
||||
|
||||
StyledText {
|
||||
|
|
@ -186,78 +294,133 @@ PluginComponent {
|
|||
}
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: peerList
|
||||
y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingM
|
||||
width: parent.width - Theme.spacingM * 2
|
||||
height: Math.min(root.peers.length * (Theme.fontSizeSmall + Theme.spacingXS), 200)
|
||||
// #54: lazy true-egress public IP (on-demand only)
|
||||
MouseArea {
|
||||
id: publicIpRow
|
||||
visible: root.isConnected
|
||||
y: Theme.spacingM + statusRow.implicitHeight + Theme.spacingXS
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
model: root.peers
|
||||
interactive: true
|
||||
boundsBehavior: Flickable.DragAndOvershootBounds
|
||||
width: publicIpText.implicitWidth
|
||||
height: publicIpText.implicitHeight
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
onClicked: {
|
||||
root.onPublicIpRowClicked();
|
||||
}
|
||||
|
||||
delegate: Item {
|
||||
width: peerList.width
|
||||
height: Theme.fontSizeSmall + Theme.spacingXS
|
||||
|
||||
Row {
|
||||
anchors.fill: parent
|
||||
spacing: Theme.spacingS
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
|
||||
MouseArea {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: peerHostnameText.implicitWidth
|
||||
height: peerHostnameText.implicitHeight
|
||||
onClicked: {
|
||||
root.copyToClipboard(modelData.hostname)
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: peerHostnameText
|
||||
text: modelData.hostname
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: modelData.online ? Theme.primary : Theme.surfaceVariantText
|
||||
}
|
||||
StyledText {
|
||||
id: publicIpText
|
||||
text: {
|
||||
var prefix = I18n.tr(TailscaleLib.getStrings().publicIPPrefix);
|
||||
if (root.publicIPLoading) {
|
||||
return prefix + I18n.tr(TailscaleLib.getStrings().publicIPLoading);
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: peerIpText.implicitWidth
|
||||
height: peerIpText.implicitHeight
|
||||
onClicked: {
|
||||
root.copyToClipboard(modelData.ip)
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: peerIpText
|
||||
text: modelData.ip
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
if (root.publicIP !== "") {
|
||||
return prefix + root.publicIP;
|
||||
}
|
||||
return prefix + I18n.tr(TailscaleLib.getStrings().publicIPTapHint);
|
||||
}
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
visible: modelData.exitNode
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
// Peer list when connected; empty-state hint when not (#55).
|
||||
Item {
|
||||
id: peerArea
|
||||
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)
|
||||
: (Theme.fontSizeSmall + Theme.spacingXS)
|
||||
anchors.left: parent.left
|
||||
anchors.leftMargin: Theme.spacingM
|
||||
|
||||
StyledText {
|
||||
visible: !root.isConnected
|
||||
anchors.fill: parent
|
||||
text: I18n.tr(TailscaleLib.getStrings().notConnectedHint)
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
|
||||
ListView {
|
||||
id: peerList
|
||||
visible: root.isConnected
|
||||
anchors.fill: parent
|
||||
model: root.peers
|
||||
interactive: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
clip: true
|
||||
|
||||
delegate: Item {
|
||||
width: peerList.width
|
||||
height: Theme.fontSizeSmall + Theme.spacingXS
|
||||
|
||||
Row {
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: exitNodeButton.implicitWidth
|
||||
height: exitNodeButton.implicitHeight
|
||||
onClicked: {
|
||||
root.setExitNode(modelData.hostname)
|
||||
spacing: Theme.spacingS
|
||||
|
||||
MouseArea {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: peerHostnameText.implicitWidth
|
||||
height: peerHostnameText.implicitHeight
|
||||
onClicked: {
|
||||
root.copyToClipboard(modelData.hostname);
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: peerHostnameText
|
||||
text: modelData.hostname
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: modelData.online ? Theme.primary : Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: exitNodeButton
|
||||
text: "↗"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: TailscaleLib.isActiveExitNode(root.currentExitNode, modelData.hostname) ? Theme.primary : Theme.surfaceVariantText
|
||||
MouseArea {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: peerIpText.implicitWidth
|
||||
height: peerIpText.implicitHeight
|
||||
onClicked: {
|
||||
root.copyToClipboard(modelData.ip);
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: peerIpText
|
||||
text: modelData.ip
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
visible: modelData.exitNode
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
hoverEnabled: true
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: exitNodeButton.implicitWidth
|
||||
height: exitNodeButton.implicitHeight
|
||||
onClicked: {
|
||||
root.setExitNode(modelData.hostname);
|
||||
}
|
||||
|
||||
StyledText {
|
||||
id: exitNodeButton
|
||||
text: "↗"
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
color: (root.currentExitNode === modelData.hostname) ? Theme.primary : Theme.surfaceVariantText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -271,7 +434,7 @@ PluginComponent {
|
|||
anchors.fill: parent
|
||||
acceptedButtons: Qt.RightButton
|
||||
onClicked: {
|
||||
root.toggleTailscale()
|
||||
root.toggleTailscale();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,25 +11,7 @@ This plugin is fully instrumented with `I18n.tr(...)` (from `qs.Common`) for all
|
|||
|
||||
See `getStrings()` and `errorMessage()` in `lib.js` for the canonical list.
|
||||
|
||||
Example `en.json` (for documentation / future tools):
|
||||
|
||||
```json
|
||||
{
|
||||
"Tailscale": "Tailscale",
|
||||
"Connected": "Connected",
|
||||
"Disconnected": "Disconnected",
|
||||
"Exit node: ": "Exit node: ",
|
||||
"None": "None",
|
||||
"Copied %1 to clipboard": "Copied %1 to clipboard",
|
||||
"Invalid exit node hostname": "Invalid exit node hostname",
|
||||
"Failed to connect to Tailscale": "Failed to connect to Tailscale",
|
||||
"Failed to disconnect from Tailscale": "Failed to disconnect from Tailscale",
|
||||
"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",
|
||||
"Tailscale command failed": "Tailscale command failed"
|
||||
}
|
||||
```
|
||||
Example `en.json` (for documentation / future tools) lives beside this README.
|
||||
|
||||
## Notes
|
||||
|
||||
|
|
@ -37,4 +19,4 @@ Example `en.json` (for documentation / future tools):
|
|||
- Plugin name/description in `plugin.json` and technical IDs ("tailscalectl") remain English.
|
||||
- This follows DMS `dms-plugin-dev` best practice for future-proofing even when only en is shipped.
|
||||
|
||||
Written by AI agent working for @jtmorris. Model: grok-build-0.1.
|
||||
Written by AI agent working for @jtmorris. Model: Grok 4.5.
|
||||
|
|
|
|||
|
|
@ -3,13 +3,18 @@
|
|||
"Connected": "Connected",
|
||||
"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",
|
||||
"Not connected": "Not connected",
|
||||
"Failed to connect to Tailscale": "Failed to connect to Tailscale",
|
||||
"Failed to disconnect from Tailscale": "Failed to disconnect from Tailscale",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
function parsePeers(peerMap) {
|
||||
if (!peerMap) { return []; }
|
||||
if (!peerMap) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(peerMap).map(function (key) {
|
||||
if (!Object.prototype.hasOwnProperty.call(peerMap, key)) { return null; }
|
||||
var p = peerMap[key];
|
||||
return {
|
||||
hostname: p.HostName || key,
|
||||
|
|
@ -9,7 +10,7 @@ function parsePeers(peerMap) {
|
|||
online: p.Online || false,
|
||||
exitNode: p.ExitNodeOption || false
|
||||
};
|
||||
}).filter(function (peer) { return peer !== null; });
|
||||
});
|
||||
}
|
||||
|
||||
function makeExitNodeCommand(hostname) {
|
||||
|
|
@ -23,7 +24,9 @@ function makeExitNodeCommand(hostname) {
|
|||
}
|
||||
|
||||
function findActiveExitNode(peerMap) {
|
||||
if (!peerMap) { return ""; }
|
||||
if (!peerMap) {
|
||||
return "";
|
||||
}
|
||||
for (const key of Object.keys(peerMap)) {
|
||||
const p = peerMap[key];
|
||||
if (p.ExitNode) {
|
||||
|
|
@ -33,6 +36,85 @@ function findActiveExitNode(peerMap) {
|
|||
return "";
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
// IPv4 only. 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;
|
||||
}
|
||||
if (a === 10) {
|
||||
return false;
|
||||
}
|
||||
if (a === 172 && b >= 16 && b <= 31) {
|
||||
return false;
|
||||
}
|
||||
if (a === 192 && b === 168) {
|
||||
return false;
|
||||
}
|
||||
if (a === 100 && b >= 64 && b <= 127) {
|
||||
return false;
|
||||
}
|
||||
if (a === 169 && b === 254) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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"]
|
||||
];
|
||||
}
|
||||
|
||||
// Parse curl stdout from an egress checker into a public IPv4, or "" if unusable.
|
||||
function parseEgressCheckResponse(stdout) {
|
||||
if (typeof stdout !== "string") {
|
||||
return "";
|
||||
}
|
||||
var line = stdout.trim().split(/\r?\n/)[0] || "";
|
||||
line = line.trim();
|
||||
if (isPublicIPv4(line)) {
|
||||
return line;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const clipboardTools = [
|
||||
{ argv: ["dms", "cl", "copy"] },
|
||||
{ argv: ["wl-copy"] }
|
||||
|
|
@ -50,40 +132,59 @@ function getStrings() {
|
|||
connected: "Connected",
|
||||
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"
|
||||
invalidExitNodeHostname: "Invalid exit node hostname",
|
||||
notConnectedHint: "Not connected"
|
||||
};
|
||||
}
|
||||
|
||||
// Light UI predicates — keep the view thin.
|
||||
function shouldShowClearExitNode(currentExitNode) {
|
||||
return currentExitNode !== "";
|
||||
}
|
||||
|
||||
function isActiveExitNode(currentExitNode, hostname) {
|
||||
return currentExitNode === hostname;
|
||||
}
|
||||
|
||||
// Security: validate hostnames coming from tailscale status JSON.
|
||||
// Fail closed on obviously malicious input.
|
||||
// Fail closed on obviously malicious input. Allow multi-label MagicDNS names
|
||||
// up to DNS FQDN length (253).
|
||||
function isValidExitNodeHostname(hostname) {
|
||||
if (typeof hostname !== "string") { return false; }
|
||||
if (hostname === "") { return true; }
|
||||
return /^[a-zA-Z0-9]([a-zA-Z0-9-_.]{0,62}[a-zA-Z0-9])?$/.test(hostname);
|
||||
if (typeof hostname !== "string") {
|
||||
return false;
|
||||
}
|
||||
if (hostname === "") {
|
||||
return true;
|
||||
}
|
||||
if (hostname.length > 253) {
|
||||
return false;
|
||||
}
|
||||
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: "",
|
||||
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 emptyStatusState();
|
||||
}
|
||||
const peerMap = data.Peer || {};
|
||||
const selfNode = data.Self || {};
|
||||
return {
|
||||
isConnected: data.BackendState === "Running",
|
||||
tailscaleIP: (data.Self && data.Self.TailscaleIPs && data.Self.TailscaleIPs[0]) || "",
|
||||
currentExitNode: findActiveExitNode(data.Peer || {}),
|
||||
peers: parsePeers(data.Peer || {})
|
||||
isConnected: true,
|
||||
tailscaleIP: (selfNode.TailscaleIPs && selfNode.TailscaleIPs[0]) || "",
|
||||
currentExitNode: findActiveExitNode(peerMap),
|
||||
peers: parsePeers(peerMap)
|
||||
};
|
||||
} catch (e) {
|
||||
return { isConnected: false, tailscaleIP: "", currentExitNode: "", peers: [] };
|
||||
return emptyStatusState();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -91,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"];
|
||||
}
|
||||
|
|
@ -104,13 +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. Used by both success and error paths.
|
||||
// detail is optional truncated stderr or extra context.
|
||||
function formatError(action, detail) {
|
||||
var base = errorMessage(action);
|
||||
if (detail && detail.length > 0) {
|
||||
|
|
@ -124,7 +223,10 @@ const PendingAction = Object.freeze({
|
|||
TOGGLE: "toggle"
|
||||
});
|
||||
|
||||
function commandForPendingAction(pending, freshIsConnected) {
|
||||
function commandForPendingAction(pending, freshIsConnected, statusOk) {
|
||||
if (!statusOk) {
|
||||
return null;
|
||||
}
|
||||
if (pending === PendingAction.TOGGLE) {
|
||||
return buildToggleCommand(freshIsConnected);
|
||||
}
|
||||
|
|
@ -132,5 +234,23 @@ function commandForPendingAction(pending, freshIsConnected) {
|
|||
}
|
||||
|
||||
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,
|
||||
PendingAction,
|
||||
commandForPendingAction,
|
||||
hostFromEndpoint,
|
||||
isPublicIPv4,
|
||||
getEgressCheckCommands,
|
||||
parseEgressCheckResponse
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,5 +9,5 @@
|
|||
"component": "./TailscaleWidget.qml",
|
||||
"permissions": ["process"],
|
||||
"requires": ["tailscale"],
|
||||
"version": "0.2.0"
|
||||
"version": "0.2.3"
|
||||
}
|
||||
|
|
|
|||
492
test/lib.test.js
492
test/lib.test.js
|
|
@ -1,18 +1,33 @@
|
|||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
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,
|
||||
PendingAction,
|
||||
commandForPendingAction
|
||||
} = lib;
|
||||
|
||||
/*
|
||||
* Unit tests for the pure functions exported from lib.js.
|
||||
* Unit tests for pure functions in lib.js.
|
||||
*
|
||||
* All functions in lib.js are exercised via Node's built-in test runner.
|
||||
*
|
||||
* TailscaleWidget.qml has no automated test coverage. The Proc.runCommand
|
||||
* calls, callback-based coordination (exact poll-act-poll preserved), and all
|
||||
* widget UI behavior must be verified manually in a running DMS instance.
|
||||
* TailscaleWidget.qml has no automated test coverage. Proc.runCommand
|
||||
* coordination, busy-mutex behavior, and widget UI must be verified
|
||||
* manually in a running DMS instance.
|
||||
*/
|
||||
|
||||
// --- parsePeers ---
|
||||
|
||||
test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
||||
const peerMap = {
|
||||
"peer-1": {
|
||||
|
|
@ -31,10 +46,44 @@ test("parsePeers extracts exitNode from ExitNodeOption", () => {
|
|||
|
||||
const peers = parsePeers(peerMap);
|
||||
|
||||
assert.strictEqual(peers.length, 2);
|
||||
assert.strictEqual(peers[0].exitNode, true);
|
||||
assert.strictEqual(peers[1].exitNode, false);
|
||||
assert.strictEqual(peers[0].hostname, "router");
|
||||
assert.strictEqual(peers[0].ip, "100.64.0.1");
|
||||
assert.strictEqual(peers[0].online, true);
|
||||
});
|
||||
|
||||
test("parsePeers returns empty array for null/undefined peerMap", () => {
|
||||
assert.deepStrictEqual(parsePeers(null), []);
|
||||
assert.deepStrictEqual(parsePeers(undefined), []);
|
||||
});
|
||||
|
||||
test("parsePeers returns empty array for empty peerMap", () => {
|
||||
assert.deepStrictEqual(parsePeers({}), []);
|
||||
});
|
||||
|
||||
test("parsePeers falls back to key when HostName missing", () => {
|
||||
const peers = parsePeers({
|
||||
"node-key-abc": { TailscaleIPs: ["100.64.0.9"], Online: false }
|
||||
});
|
||||
assert.strictEqual(peers[0].hostname, "node-key-abc");
|
||||
assert.strictEqual(peers[0].ip, "100.64.0.9");
|
||||
assert.strictEqual(peers[0].online, false);
|
||||
assert.strictEqual(peers[0].exitNode, false);
|
||||
});
|
||||
|
||||
test("parsePeers uses empty ip when TailscaleIPs missing or empty", () => {
|
||||
const peers = parsePeers({
|
||||
a: { HostName: "a" },
|
||||
b: { HostName: "b", TailscaleIPs: [] }
|
||||
});
|
||||
assert.strictEqual(peers[0].ip, "");
|
||||
assert.strictEqual(peers[1].ip, "");
|
||||
});
|
||||
|
||||
// --- makeExitNodeCommand / hostname validation ---
|
||||
|
||||
test("makeExitNodeCommand returns tailscale set command for hostname", () => {
|
||||
const cmd = makeExitNodeCommand("router");
|
||||
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node=router"]);
|
||||
|
|
@ -45,6 +94,49 @@ test("makeExitNodeCommand with empty string clears exit node", () => {
|
|||
assert.deepStrictEqual(cmd, ["tailscale", "set", "--exit-node="]);
|
||||
});
|
||||
|
||||
test("makeExitNodeCommand returns null for invalid hostname", () => {
|
||||
assert.strictEqual(makeExitNodeCommand("; rm"), null);
|
||||
assert.strictEqual(makeExitNodeCommand("$(whoami)"), null);
|
||||
assert.strictEqual(makeExitNodeCommand(null), null);
|
||||
assert.strictEqual(makeExitNodeCommand(42), null);
|
||||
});
|
||||
|
||||
test("makeExitNodeCommand still produces correct argv for valid input", () => {
|
||||
assert.deepStrictEqual(makeExitNodeCommand(""), ["tailscale", "set", "--exit-node="]);
|
||||
assert.deepStrictEqual(makeExitNodeCommand("gluetun-sjc"), ["tailscale", "set", "--exit-node=gluetun-sjc"]);
|
||||
});
|
||||
|
||||
test("isValidExitNodeHostname accepts empty string (clear)", () => {
|
||||
assert.strictEqual(isValidExitNodeHostname(""), true);
|
||||
});
|
||||
|
||||
test("isValidExitNodeHostname accepts realistic Tailscale hostnames", () => {
|
||||
["router", "gluetun-sjc", "my-exit-node-01", "peer_with_underscore", "a.b.c"].forEach((h) => {
|
||||
assert.strictEqual(isValidExitNodeHostname(h), true, h);
|
||||
});
|
||||
});
|
||||
|
||||
test("isValidExitNodeHostname accepts MagicDNS-style FQDNs under 253 chars", () => {
|
||||
assert.strictEqual(isValidExitNodeHostname("my-node.tail1234.ts.net"), true);
|
||||
const longButValid = "a".repeat(60) + "." + "b".repeat(60) + "." + "c".repeat(60);
|
||||
assert.ok(longButValid.length < 253);
|
||||
assert.strictEqual(isValidExitNodeHostname(longButValid), true);
|
||||
});
|
||||
|
||||
test("isValidExitNodeHostname rejects injection attempts and garbage", () => {
|
||||
["; rm -rf /", "$(whoami)", "`id`", "foo;bar", "a&b", "x\ny", "evil$(date)", " spacy ", "-leading", "trailing-"].forEach((h) => {
|
||||
assert.strictEqual(isValidExitNodeHostname(h), false, h);
|
||||
});
|
||||
});
|
||||
|
||||
test("isValidExitNodeHostname rejects non-strings and oversized names", () => {
|
||||
assert.strictEqual(isValidExitNodeHostname(undefined), false);
|
||||
assert.strictEqual(isValidExitNodeHostname({}), false);
|
||||
assert.strictEqual(isValidExitNodeHostname("x".repeat(254)), false);
|
||||
});
|
||||
|
||||
// --- findActiveExitNode ---
|
||||
|
||||
test("findActiveExitNode returns hostname of peer with ExitNode=true", () => {
|
||||
const peerMap = {
|
||||
"peer-1": { HostName: "gluetun-sjc", ExitNode: true, ExitNodeOption: true },
|
||||
|
|
@ -60,31 +152,53 @@ test("findActiveExitNode returns empty string when no exit node", () => {
|
|||
assert.strictEqual(findActiveExitNode(peerMap), "");
|
||||
});
|
||||
|
||||
test("errorMessage returns user-friendly message for tailscale up failure", () => {
|
||||
const msg = errorMessage("up", 1);
|
||||
assert.strictEqual(msg, "Failed to connect to Tailscale");
|
||||
})
|
||||
|
||||
test("errorMessage returns user-friendly message for tailscale down failure", () => {
|
||||
const msg = errorMessage("down", 1);
|
||||
assert.strictEqual(msg, "Failed to disconnect from Tailscale");
|
||||
test("findActiveExitNode returns empty string for null peerMap", () => {
|
||||
assert.strictEqual(findActiveExitNode(null), "");
|
||||
});
|
||||
|
||||
test("errorMessage returns user-friendly message for tailscale set failure", () => {
|
||||
const msg = errorMessage("set", 1);
|
||||
assert.strictEqual(msg, "Failed to set exit node");
|
||||
test("findActiveExitNode falls back to map key when HostName missing", () => {
|
||||
const peerMap = {
|
||||
"key-only": { ExitNode: true }
|
||||
};
|
||||
assert.strictEqual(findActiveExitNode(peerMap), "key-only");
|
||||
});
|
||||
|
||||
test("errorMessage returns user-friendly message for tailscale status failure", () => {
|
||||
const msg = errorMessage("status", 1);
|
||||
assert.strictEqual(msg, "Failed to read Tailscale status");
|
||||
// --- errorMessage / formatError ---
|
||||
|
||||
test("errorMessage returns user-friendly messages for known actions", () => {
|
||||
assert.strictEqual(errorMessage("up"), "Failed to connect to Tailscale");
|
||||
assert.strictEqual(errorMessage("connect"), "Failed to connect to Tailscale");
|
||||
assert.strictEqual(errorMessage("down"), "Failed to disconnect from Tailscale");
|
||||
assert.strictEqual(errorMessage("disconnect"), "Failed to disconnect from Tailscale");
|
||||
assert.strictEqual(errorMessage("set"), "Failed to set exit node");
|
||||
assert.strictEqual(errorMessage("status"), "Failed to read Tailscale status");
|
||||
assert.strictEqual(errorMessage("clipboard"), "Error copying to clipboard");
|
||||
});
|
||||
|
||||
test("errorMessage returns generic message for unknown command", () => {
|
||||
const msg = errorMessage("unknown", 1);
|
||||
assert.strictEqual(msg, "Tailscale command failed");
|
||||
assert.strictEqual(errorMessage("unknown"), "Tailscale command failed");
|
||||
});
|
||||
|
||||
test("formatError returns base message without detail", () => {
|
||||
assert.strictEqual(formatError("status"), "Failed to read Tailscale status");
|
||||
assert.strictEqual(formatError("set"), "Failed to set exit node");
|
||||
});
|
||||
|
||||
test("formatError appends and truncates detail to 120 chars", () => {
|
||||
const longDetail = "x".repeat(200);
|
||||
const msg = formatError("up", longDetail);
|
||||
assert.ok(msg.includes("Failed to connect to Tailscale"));
|
||||
assert.ok(msg.endsWith("x".repeat(120)));
|
||||
assert.ok(msg.length < 200);
|
||||
});
|
||||
|
||||
test("formatError handles empty or falsy detail gracefully", () => {
|
||||
assert.strictEqual(formatError("down", ""), "Failed to disconnect from Tailscale");
|
||||
assert.strictEqual(formatError("connect", null), "Failed to connect to Tailscale");
|
||||
});
|
||||
|
||||
// --- clipboard ---
|
||||
|
||||
test("getClipboardCommands returns ordered argv arrays with text appended", () => {
|
||||
const cmds = getClipboardCommands("1.2.3.4");
|
||||
assert.ok(Array.isArray(cmds));
|
||||
|
|
@ -100,158 +214,248 @@ test("getClipboardCommands handles text with special characters safely (direct a
|
|||
assert.ok(cmds[0].includes("it's a 'test' with \"quotes\" and\nnewlines"));
|
||||
});
|
||||
|
||||
test("getClipboardCommands is deterministic and open for future tools", () => {
|
||||
const cmds = getClipboardCommands("foo");
|
||||
assert.ok(Array.isArray(cmds[0]));
|
||||
assert.ok(Array.isArray(cmds[1]));
|
||||
});
|
||||
// --- strings ---
|
||||
|
||||
test("getStrings returns canonical UI strings for the widget", () => {
|
||||
const s = getStrings();
|
||||
assert.ok(s.header)
|
||||
assert.ok(s.connected)
|
||||
assert.ok(s.disconnected)
|
||||
assert.ok(s.exitNodePrefix)
|
||||
assert.ok(s.copied)
|
||||
})
|
||||
assert.ok(s.header);
|
||||
assert.ok(s.connected);
|
||||
assert.ok(s.disconnected);
|
||||
assert.ok(s.exitNodePrefix);
|
||||
assert.ok(s.none);
|
||||
assert.ok(s.copied);
|
||||
assert.ok(s.invalidExitNodeHostname);
|
||||
assert.ok(s.notConnectedHint);
|
||||
});
|
||||
|
||||
test("getStrings.copied is the I18n template key (interpolation happens at call site via .arg)", () => {
|
||||
test("getStrings.copied is the I18n template key (interpolation via .arg at call site)", () => {
|
||||
const s = getStrings();
|
||||
assert.strictEqual(s.copied, "Copied %1 to clipboard");
|
||||
})
|
||||
});
|
||||
|
||||
test("shouldShowClearExitNode returns true only when there is a current exit node", () => {
|
||||
assert.strictEqual(shouldShowClearExitNode("router"), true)
|
||||
assert.strictEqual(shouldShowClearExitNode(""), false)
|
||||
})
|
||||
// --- toggle helpers ---
|
||||
|
||||
test("isActiveExitNode correctly identifies the active exit node button", () => {
|
||||
assert.strictEqual(isActiveExitNode("gluetun-sjc", "gluetun-sjc"), true)
|
||||
assert.strictEqual(isActiveExitNode("gluetun-sjc", "gluetun-den"), false)
|
||||
assert.strictEqual(isActiveExitNode("", "router"), false)
|
||||
})
|
||||
test("buildToggleCommand returns down when connected", () => {
|
||||
assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"]);
|
||||
});
|
||||
|
||||
// --- buildToggleCommand ---
|
||||
|
||||
test("buildToggleCommand returns down command when connected", () => {
|
||||
assert.deepStrictEqual(buildToggleCommand(true), ["tailscale", "down"])
|
||||
})
|
||||
|
||||
test("buildToggleCommand returns up command when disconnected", () => {
|
||||
assert.deepStrictEqual(buildToggleCommand(false), ["tailscale", "up"])
|
||||
})
|
||||
test("buildToggleCommand returns up when disconnected", () => {
|
||||
assert.deepStrictEqual(buildToggleCommand(false), ["tailscale", "up"]);
|
||||
});
|
||||
|
||||
test("buildToggleCommand treats null and undefined as disconnected", () => {
|
||||
assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"])
|
||||
assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"])
|
||||
})
|
||||
assert.deepStrictEqual(buildToggleCommand(null), ["tailscale", "up"]);
|
||||
assert.deepStrictEqual(buildToggleCommand(undefined), ["tailscale", "up"]);
|
||||
});
|
||||
|
||||
test("commandForPendingAction returns toggle command when pending is TOGGLE and passes through buildToggleCommand logic", () => {
|
||||
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 toggle command when pending is TOGGLE and statusOk", () => {
|
||||
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, true, true), ["tailscale", "down"]);
|
||||
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, false, true), ["tailscale", "up"]);
|
||||
assert.deepStrictEqual(commandForPendingAction(PendingAction.TOGGLE, null, true), ["tailscale", "up"]);
|
||||
});
|
||||
|
||||
test("commandForPendingAction returns null when status poll failed (do not invent up/down)", () => {
|
||||
assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, false, false), null);
|
||||
assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, true, false), null);
|
||||
assert.strictEqual(commandForPendingAction(PendingAction.TOGGLE, false, undefined), null);
|
||||
});
|
||||
|
||||
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);
|
||||
assert.strictEqual(commandForPendingAction(null, true, true), null);
|
||||
assert.strictEqual(commandForPendingAction(undefined, false, true), null);
|
||||
assert.strictEqual(commandForPendingAction("something-else", true, true), null);
|
||||
assert.strictEqual(commandForPendingAction("", true, true), null);
|
||||
});
|
||||
|
||||
test("parseStatusResult produces correct state from valid JSON", () => {
|
||||
// --- parseStatusResult (#55 and ground-truth parsing) ---
|
||||
|
||||
test("parseStatusResult produces correct state from valid Running JSON", () => {
|
||||
const json = JSON.stringify({
|
||||
BackendState: "Running",
|
||||
Self: { TailscaleIPs: ["100.64.0.5"] },
|
||||
Peer: {
|
||||
"key-1": { HostName: "router", TailscaleIPs: ["100.64.0.1"], Online: true, ExitNode: true, ExitNodeOption: true }
|
||||
"key-1": {
|
||||
HostName: "router",
|
||||
TailscaleIPs: ["100.64.0.1"],
|
||||
Online: true,
|
||||
ExitNode: true,
|
||||
ExitNodeOption: true
|
||||
}
|
||||
}
|
||||
})
|
||||
const state = parseStatusResult(json)
|
||||
assert.strictEqual(state.isConnected, true)
|
||||
assert.strictEqual(state.tailscaleIP, "100.64.0.5")
|
||||
assert.strictEqual(state.currentExitNode, "router")
|
||||
assert.strictEqual(state.peers.length, 1)
|
||||
})
|
||||
});
|
||||
const state = parseStatusResult(json);
|
||||
assert.strictEqual(state.isConnected, true);
|
||||
assert.strictEqual(state.tailscaleIP, "100.64.0.5");
|
||||
assert.strictEqual(state.currentExitNode, "router");
|
||||
assert.strictEqual(state.peers.length, 1);
|
||||
});
|
||||
|
||||
test("parseStatusResult returns safe defaults for invalid JSON", () => {
|
||||
const state = parseStatusResult("not json at all")
|
||||
assert.strictEqual(state.isConnected, false)
|
||||
assert.strictEqual(state.tailscaleIP, "")
|
||||
assert.strictEqual(state.currentExitNode, "")
|
||||
assert.strictEqual(state.peers.length, 0)
|
||||
})
|
||||
const state = parseStatusResult("not json at all");
|
||||
assert.strictEqual(state.isConnected, false);
|
||||
assert.strictEqual(state.tailscaleIP, "");
|
||||
assert.strictEqual(state.currentExitNode, "");
|
||||
assert.deepStrictEqual(state.peers, []);
|
||||
});
|
||||
|
||||
test("parseStatusResult handles missing Self gracefully", () => {
|
||||
const json = JSON.stringify({ BackendState: "Running", Peer: {} })
|
||||
const state = parseStatusResult(json)
|
||||
assert.strictEqual(state.isConnected, true)
|
||||
assert.strictEqual(state.tailscaleIP, "")
|
||||
})
|
||||
test("parseStatusResult handles missing Self gracefully when Running", () => {
|
||||
const json = JSON.stringify({ BackendState: "Running", Peer: {} });
|
||||
const state = parseStatusResult(json);
|
||||
assert.strictEqual(state.isConnected, true);
|
||||
assert.strictEqual(state.tailscaleIP, "");
|
||||
});
|
||||
|
||||
test("parseStatusResult handles missing and empty Peer gracefully", () => {
|
||||
const json = JSON.stringify({ BackendState: "Running", Self: { TailscaleIPs: ["100.64.0.5"] } })
|
||||
const state = parseStatusResult(json)
|
||||
assert.strictEqual(state.peers.length, 0)
|
||||
assert.strictEqual(state.currentExitNode, "")
|
||||
})
|
||||
test("parseStatusResult handles missing and empty Peer gracefully when Running", () => {
|
||||
const json = JSON.stringify({
|
||||
BackendState: "Running",
|
||||
Self: { TailscaleIPs: ["100.64.0.5"] }
|
||||
});
|
||||
const state = parseStatusResult(json);
|
||||
assert.strictEqual(state.peers.length, 0);
|
||||
assert.strictEqual(state.currentExitNode, "");
|
||||
});
|
||||
|
||||
test("parseStatusResult sets isConnected false for non-Running BackendState", () => {
|
||||
const json = JSON.stringify({ BackendState: "NeedsLogin", Self: { TailscaleIPs: ["100.64.0.5"] }, Peer: {} })
|
||||
const state = parseStatusResult(json)
|
||||
assert.strictEqual(state.isConnected, false)
|
||||
})
|
||||
const json = JSON.stringify({
|
||||
BackendState: "NeedsLogin",
|
||||
Self: { TailscaleIPs: ["100.64.0.5"] },
|
||||
Peer: {}
|
||||
});
|
||||
const state = parseStatusResult(json);
|
||||
assert.strictEqual(state.isConnected, false);
|
||||
});
|
||||
|
||||
// --- formatError (central error + detail formatting) ---
|
||||
// #55: disconnected must not surface peer list / exit node from leftover JSON
|
||||
test("parseStatusResult clears peers and exit node when BackendState is not Running (#55)", () => {
|
||||
const json = JSON.stringify({
|
||||
BackendState: "Stopped",
|
||||
Self: { TailscaleIPs: ["100.64.0.5"] },
|
||||
Peer: {
|
||||
"k1": {
|
||||
HostName: "router",
|
||||
TailscaleIPs: ["100.64.0.1"],
|
||||
Online: false,
|
||||
ExitNode: true,
|
||||
ExitNodeOption: true
|
||||
}
|
||||
}
|
||||
});
|
||||
const state = parseStatusResult(json);
|
||||
assert.strictEqual(state.isConnected, false);
|
||||
assert.deepStrictEqual(state.peers, []);
|
||||
assert.strictEqual(state.currentExitNode, "");
|
||||
// Self IP may still appear in raw JSON; we clear display IP when disconnected
|
||||
// so the popout does not look "half connected".
|
||||
assert.strictEqual(state.tailscaleIP, "");
|
||||
});
|
||||
|
||||
test("formatError returns base message without detail", () => {
|
||||
assert.strictEqual(formatError("status"), "Failed to read Tailscale status")
|
||||
assert.strictEqual(formatError("set"), "Failed to set exit node")
|
||||
})
|
||||
|
||||
test("formatError appends and truncates detail", () => {
|
||||
const longDetail = "x".repeat(200)
|
||||
const msg = formatError("up", longDetail)
|
||||
assert.ok(msg.includes("Failed to connect to Tailscale"))
|
||||
assert.ok(msg.endsWith("x".repeat(120)))
|
||||
assert.ok(msg.length < 200)
|
||||
})
|
||||
|
||||
test("formatError handles empty or falsy detail gracefully", () => {
|
||||
assert.strictEqual(formatError("down", ""), "Failed to disconnect from Tailscale")
|
||||
assert.strictEqual(formatError("connect", null), "Failed to connect to Tailscale")
|
||||
})
|
||||
|
||||
// --- isValidExitNodeHostname + makeExitNodeCommand safety ---
|
||||
|
||||
test("isValidExitNodeHostname accepts empty string (clear)", () => {
|
||||
assert.strictEqual(isValidExitNodeHostname(""), true)
|
||||
})
|
||||
|
||||
test("isValidExitNodeHostname accepts realistic Tailscale hostnames", () => {
|
||||
["router", "gluetun-sjc", "my-exit-node-01", "peer_with_underscore", "a.b.c"].forEach(h =>
|
||||
assert.strictEqual(isValidExitNodeHostname(h), true, h)
|
||||
)
|
||||
})
|
||||
|
||||
test("isValidExitNodeHostname rejects obvious injection attempts", () => {
|
||||
["; rm -rf /", "$(whoami)", "`id`", "foo;bar", "a&b", "x\ny", "evil$(date)"].forEach(h =>
|
||||
assert.strictEqual(isValidExitNodeHostname(h), false, h)
|
||||
)
|
||||
})
|
||||
|
||||
test("makeExitNodeCommand returns null for invalid hostname", () => {
|
||||
assert.strictEqual(makeExitNodeCommand("; rm"), null)
|
||||
assert.strictEqual(makeExitNodeCommand("$(whoami)"), null)
|
||||
})
|
||||
|
||||
test("makeExitNodeCommand still produces correct argv for valid input", () => {
|
||||
assert.deepStrictEqual(makeExitNodeCommand(""), ["tailscale", "set", "--exit-node="])
|
||||
assert.deepStrictEqual(makeExitNodeCommand("gluetun-sjc"), ["tailscale", "set", "--exit-node=gluetun-sjc"])
|
||||
})
|
||||
test("parseStatusResult clears peers for NeedsLogin even if Peer map is populated (#55)", () => {
|
||||
const json = JSON.stringify({
|
||||
BackendState: "NeedsLogin",
|
||||
Self: { TailscaleIPs: ["100.64.0.5"] },
|
||||
Peer: {
|
||||
"k1": { HostName: "ghost", TailscaleIPs: ["100.64.0.2"], Online: false }
|
||||
}
|
||||
});
|
||||
const state = parseStatusResult(json);
|
||||
assert.strictEqual(state.isConnected, false);
|
||||
assert.deepStrictEqual(state.peers, []);
|
||||
assert.strictEqual(state.currentExitNode, "");
|
||||
assert.strictEqual(state.tailscaleIP, "");
|
||||
});
|
||||
|
||||
// --- getStatusCommand ---
|
||||
|
||||
test("getStatusCommand returns the canonical tailscale status --json argv", () => {
|
||||
const cmd = getStatusCommand()
|
||||
assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"])
|
||||
})
|
||||
const cmd = getStatusCommand();
|
||||
assert.deepStrictEqual(cmd, ["tailscale", "status", "--json"]);
|
||||
});
|
||||
|
||||
// --- PendingAction constant ---
|
||||
|
||||
test("PendingAction.TOGGLE is the stable string used by the widget", () => {
|
||||
assert.strictEqual(PendingAction.TOGGLE, "toggle");
|
||||
});
|
||||
|
||||
// --- export surface: no over-abstracted UI predicates ---
|
||||
|
||||
test("lib does not export trivial UI predicates shouldShowClearExitNode / isActiveExitNode", () => {
|
||||
assert.strictEqual(lib.shouldShowClearExitNode, undefined);
|
||||
assert.strictEqual(lib.isActiveExitNode, undefined);
|
||||
});
|
||||
|
||||
// --- 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");
|
||||
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("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("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("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: ["203.0.113.10:41641"]
|
||||
},
|
||||
Peer: {}
|
||||
});
|
||||
const state = lib.parseStatusResult(json);
|
||||
assert.strictEqual(state.isConnected, true);
|
||||
assert.strictEqual(state.tailscaleIP, "100.64.0.5");
|
||||
assert.strictEqual(state.publicIP, undefined);
|
||||
});
|
||||
|
||||
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("errorMessage includes egress failure", () => {
|
||||
assert.strictEqual(lib.errorMessage("egress"), "Failed to look up public IP");
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue