diff --git a/shell/plugins/panels/tailscale/Model.js b/shell/plugins/panels/tailscale/Model.js index bddf7153bb0..e78a46593b0 100644 --- a/shell/plugins/panels/tailscale/Model.js +++ b/shell/plugins/panels/tailscale/Model.js @@ -35,6 +35,18 @@ function displayHostName(hostName, dnsName) { return shortDnsName(dnsName) || host || "Unknown" } +// Exit node rows name an identifier, not a friendly label: the value shown +// must match `tailscale exit-node list`, tsui and the admin console so it can +// be cross-referenced and typed into `tailscale set --exit-node=`. MACHINES +// rows keep displayHostName, where the OS hostname reads better. +function exitNodeLabel(peer) { + if (!peer) return "Unknown" + if (peer.AddMullvad === true || peer.MullvadRegion === true || peer.Mullvad === true) { + return String(peer.DisplayName || "Unknown") + } + return shortDnsName(peer.DNSName) || String(peer.DisplayName || peer.HostName || "Unknown") +} + function isMullvadHost(name) { var value = String(name || "").toLowerCase() var suffix = ".mullvad.ts.net" @@ -115,6 +127,55 @@ function peerFromStatus(id, peer) { } } +// Tagged wins over ownership: a tagged device is owned by the tag, not by +// whoever authenticated it. Matches tsui, where atl-exit-node sits under +// Tagged Devices even though it was enrolled by the current user. +function peerGroup(peer, selfUserId) { + if (!peer) return "other" + var tags = peer.Tags + if (tags && tags.length > 0) return "tagged" + var owner = String(peer.UserID || "") + var self = String(selfUserId || "") + if (owner !== "" && self !== "" && owner === self) return "mine" + return "other" +} + +// Input arrives already sorted by HostName (see parseStatus), so preserving +// order keeps each group alphabetical without re-sorting. +function groupPeers(peers, selfUserId) { + var groups = { mine: [], tagged: [], other: [] } + var values = Array.isArray(peers) ? peers : [] + for (var i = 0; i < values.length; i++) { + groups[peerGroup(values[i], selfUserId)].push(values[i]) + } + return groups +} + +// Searches the MagicDNS name as well as the hostname: the two diverge (a +// machine named "Firezone" answers to ny-exit-node), so hostname-only search +// would miss the name the user actually knows the machine by. +function peerMatchesQuery(peer, query) { + var needle = String(query || "").trim().toLowerCase() + if (needle === "") return true + if (!peer) return false + var haystack = [peer.DisplayName, peer.HostName, peer.DNSName, peer.OS] + var ips = peer.TailscaleIPs || [] + for (var i = 0; i < ips.length; i++) haystack.push(ips[i]) + for (var j = 0; j < haystack.length; j++) { + if (String(haystack[j] || "").toLowerCase().indexOf(needle) !== -1) return true + } + return false +} + +function filterPeers(peers, query) { + var values = Array.isArray(peers) ? peers : [] + var result = [] + for (var i = 0; i < values.length; i++) { + if (peerMatchesQuery(values[i], query)) result.push(values[i]) + } + return result +} + function sliceTableColumn(line, start, end) { var text = String(line || "") if (start < 0 || start >= text.length) return "" @@ -309,6 +370,7 @@ if (typeof module !== "undefined") { cleanDnsName: cleanDnsName, shortDnsName: shortDnsName, displayHostName: displayHostName, + exitNodeLabel: exitNodeLabel, osIcon: osIcon, accountLabel: accountLabel, loginPlan: loginPlan, @@ -316,6 +378,10 @@ if (typeof module !== "undefined") { isTaildropTarget: isTaildropTarget, isMullvadPeer: isMullvadPeer, peerFromStatus: peerFromStatus, + peerGroup: peerGroup, + groupPeers: groupPeers, + peerMatchesQuery: peerMatchesQuery, + filterPeers: filterPeers, parseExitNodeList: parseExitNodeList, mullvadRegionOptions: mullvadRegionOptions, mullvadCountryOptions: mullvadCountryOptions, diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index 6a9763072b2..a691025fbd0 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -42,7 +42,18 @@ Panel { readonly property color dim: Qt.darker(foreground, 1.55) readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied - readonly property bool showPeers: tailscale.active && tailscale.peers.length > 0 + property bool peerSearchOpen: false + property string peerQuery: "" + + // Filter before grouping so each group shows only matches and empty groups + // hide themselves, rather than leaving three headings over an empty list. + readonly property var peerGroups: tailscale.groupPeers( + tailscale.filterPeers(tailscale.peers, peerSearchOpen ? peerQuery : ""), + tailscale.selfUserId) + // Flat, render-ordered view of peerGroups. Cursor bounds and rowIndex must + // follow what is drawn, not tailscale.peers' sort order. + readonly property var orderedPeers: peerGroups.mine.concat(peerGroups.tagged).concat(peerGroups.other) + readonly property bool showPeers: tailscale.active && orderedPeers.length > 0 readonly property var recentMullvadRegions: settings.recentMullvadRegions instanceof Array ? settings.recentMullvadRegions : (settings.recentMullvadCountries instanceof Array ? settings.recentMullvadCountries : []) readonly property var recentMullvadExitNodes: recentMullvadNodes() readonly property var exitNodes: displayExitNodes() @@ -58,8 +69,8 @@ Panel { readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" function selectedPeer() { - if (tailscale.peers.length === 0) return null - return tailscale.peers[Math.max(0, Math.min(peerIndex, tailscale.peers.length - 1))] + if (orderedPeers.length === 0) return null + return orderedPeers[Math.max(0, Math.min(peerIndex, orderedPeers.length - 1))] } function selectedExitNode() { @@ -181,7 +192,7 @@ Panel { if (headerIndex < 0) headerIndex = 0 if (headerIndex > 0) headerIndex = 0 if (accountIndex >= tailscale.accounts.length) accountIndex = Math.max(0, tailscale.accounts.length - 1) - if (peerIndex >= tailscale.peers.length) peerIndex = Math.max(0, tailscale.peers.length - 1) + if (peerIndex >= orderedPeers.length) peerIndex = Math.max(0, orderedPeers.length - 1) if (exitNodeIndex >= exitNodes.length) exitNodeIndex = Math.max(0, exitNodes.length - 1) if (mullvadRegionIndex >= filteredMullvadRegions.length) mullvadRegionIndex = Math.max(0, filteredMullvadRegions.length - 1) if (focusSection === "auth" && !tailscale.accountsAccessDenied) focusSection = tailscale.accounts.length > 1 ? "accounts" : (showExitNodes ? "exitNodes" : (showPeers ? "peers" : "header")) @@ -219,7 +230,7 @@ Panel { if (dy < 0) { if (peerIndex <= 0) focusSection = showExitNodes ? "exitNodes" : (tailscale.accounts.length > 1 ? "accounts" : (tailscale.accountsAccessDenied ? "auth" : "header")) else peerIndex-- - } else if (peerIndex < tailscale.peers.length - 1) { + } else if (peerIndex < orderedPeers.length - 1) { peerIndex++ } } else if (focusSection === "exitNodes") { @@ -260,6 +271,30 @@ Panel { scrollMullvadRegionCursorIntoView() } + function openPeerSearch() { + peerSearchOpen = true + peerQuery = "" + peerIndex = 0 + focusSection = "peers" + Qt.callLater(function() { if (peerSearch) peerSearch.forceActiveFocus() }) + } + + function closePeerSearch() { + peerSearchOpen = false + peerQuery = "" + keyCatcher.forceActiveFocus() + } + + // Deliberately not routed through moveCursor: its section dispatch would + // throw focus out of "peers" at the list boundaries while the search field + // still holds focus. + function movePeerCursor(delta) { + if (orderedPeers.length === 0) return + cursorActive = true + peerIndex = Math.max(0, Math.min(orderedPeers.length - 1, peerIndex + delta)) + scrollCursorIntoView() + } + function activateMullvadRegionCursor() { var region = selectedMullvadRegion() if (region) chooseExitNode(region) @@ -413,7 +448,7 @@ Panel { PanelKeyCatcher { id: keyCatcher anchors.fill: parent - blocked: root.copyMenuOpen + blocked: root.copyMenuOpen || peerSearch.activeFocus || mullvadSearch.activeFocus onMoveRequested: function(dx, dy) { if (!root.cursorActive) { root.cursorActive = true; return } root.moveCursor(dx, dy) @@ -422,7 +457,8 @@ Panel { onCloseRequested: root.close() onTabRequested: function(direction) { root.switchPanel(direction) } onTextKey: function(t) { - if (t === "t" || t === "T") tailscale.toggleTailscale() + if (t === "/") root.openPeerSearch() + else if (t === "t" || t === "T") tailscale.toggleTailscale() else if (t === "c" || t === "C") tailscale.copyPeerIp(root.selectedPeer()) else if (t === "n" || t === "N") tailscale.copyPeerName(root.selectedPeer()) else if (t === "d" || t === "D") tailscale.copyPeerDnsName(root.selectedPeer()) @@ -681,6 +717,39 @@ Panel { fontFamily: root.fontFamily } + TextField { + id: peerSearch + visible: root.peerSearchOpen + width: parent.width + foreground: root.foreground + placeholderText: "Search machines" + text: root.peerQuery + onTextChanged: { + root.peerQuery = text + root.peerIndex = 0 + } + // Arrow keys only -- unlike the Mullvad picker this field must + // accept j/k/h/l as literal text. + Keys.onPressed: function(event) { + if (event.key === Qt.Key_Down) { root.movePeerCursor(1); event.accepted = true; return } + if (event.key === Qt.Key_Up) { root.movePeerCursor(-1); event.accepted = true; return } + if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) { root.activateCursor(); event.accepted = true; return } + if (event.key === Qt.Key_Escape) { root.closePeerSearch(); event.accepted = true } + } + } + + Text { + visible: root.peerSearchOpen && root.orderedPeers.length === 0 + width: parent.width + text: "No machines match \"" + root.peerQuery + "\"." + // The query is user input; never let AutoText interpret markup. + textFormat: Text.PlainText + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + horizontalAlignment: Text.AlignHCenter + } + Text { visible: tailscale.installed && tailscale.active && tailscale.peers.length === 0 width: parent.width @@ -695,16 +764,48 @@ Panel { id: peerColumn visible: root.showPeers width: parent.width - spacing: Style.space(6) + spacing: Style.space(12) Repeater { - model: tailscale.peers - PeerRow { + model: [ + { key: "mine", label: "MY DEVICES" }, + { key: "tagged", label: "TAGGED DEVICES" }, + { key: "other", label: "OTHER DEVICES" } + ] + + Column { + id: peerGroupColumn required property var modelData - required property int index + + readonly property var rows: root.peerGroups[modelData.key] || [] + // rowIndex must stay unique across groups: the cursor indexes + // root.orderedPeers, which concatenates mine + tagged + other. + readonly property int groupOffset: modelData.key === "mine" + ? 0 + : (modelData.key === "tagged" + ? root.peerGroups.mine.length + : root.peerGroups.mine.length + root.peerGroups.tagged.length) + width: peerColumn.width - peer: modelData - rowIndex: index + spacing: Style.space(6) + visible: rows.length > 0 + + PanelSectionHeader { + text: peerGroupColumn.modelData.label + foreground: root.dim + fontFamily: root.fontFamily + } + + Repeater { + model: peerGroupColumn.rows + PeerRow { + required property var modelData + required property int index + width: peerColumn.width + peer: modelData + rowIndex: peerGroupColumn.groupOffset + index + } + } } } } @@ -1118,7 +1219,7 @@ Panel { readonly property bool addMullvad: peer && peer.AddMullvad === true readonly property bool activeExitNode: peer && peer.ExitNode === true readonly property bool settingExitNode: peer && tailscale.settingExitNodeId === String(peer.id || "") - readonly property string peerName: peer ? String(peer.DisplayName || peer.HostName || "Unknown") : "Unknown" + readonly property string peerName: tailscale.exitNodeLabel(peer) readonly property string actionTooltip: addMullvad ? "" : (activeExitNode ? "Disconnect" : "Connect") hasCursor: root.cursorActive && root.focusSection === "exitNodes" && root.exitNodeIndex === rowIndex diff --git a/shell/plugins/panels/tailscale/Service.qml b/shell/plugins/panels/tailscale/Service.qml index 13fbfbc5a54..24be7bfb7a5 100644 --- a/shell/plugins/panels/tailscale/Service.qml +++ b/shell/plugins/panels/tailscale/Service.qml @@ -95,6 +95,18 @@ Item { return Model.displayHostName(hostName, dnsName) } + function exitNodeLabel(peer) { + return Model.exitNodeLabel(peer) + } + + function groupPeers(peers, selfUserId) { + return Model.groupPeers(peers, selfUserId) + } + + function filterPeers(peers, query) { + return Model.filterPeers(peers, query) + } + function osIcon(os) { return Model.osIcon(os) } diff --git a/test/shell.d/tailscale-test.sh b/test/shell.d/tailscale-test.sh index 9c04cca91a9..6edc34e0805 100644 --- a/test/shell.d/tailscale-test.sh +++ b/test/shell.d/tailscale-test.sh @@ -207,4 +207,90 @@ assertDeepEqual( assertDeepEqual(tailscale.parseStatus('{'), { ok: false, unavailable: true, message: 'Status error', error: 'Failed to parse tailscale status' }, 'tailscale reports invalid status JSON') assertDeepEqual(tailscale.parseAccounts('{'), { accounts: [], selectedAccountId: '', selectedAccountLabel: '' }, 'tailscale handles invalid account JSON') +assertEqual(tailscale.exitNodeLabel({ + HostName: 'Firezone', + DNSName: 'ny-exit-node.tailcb223.ts.net', + DisplayName: 'Firezone' +}), 'ny-exit-node', 'tailscale prefers the MagicDNS name on exit node rows') + +assertEqual(tailscale.exitNodeLabel({ + HostName: 'atl-exit-node', + DNSName: 'atl-exit-node.tailcb223.ts.net', + DisplayName: 'atl-exit-node' +}), 'atl-exit-node', 'tailscale leaves matching exit node names alone') + +assertEqual(tailscale.exitNodeLabel({ + MullvadRegion: true, + DisplayName: 'Stockholm, Sweden', + DNSName: 'se-sto-wg-001.mullvad.ts.net' +}), 'Stockholm, Sweden', 'tailscale keeps the region label on Mullvad region rows') + +assertEqual(tailscale.exitNodeLabel({ + Mullvad: true, + DisplayName: 'Stockholm, Sweden', + DNSName: 'se-sto-wg-001.mullvad.ts.net' +}), 'Stockholm, Sweden', 'tailscale keeps the region label on Mullvad peer rows') + +assertEqual(tailscale.exitNodeLabel({ + AddMullvad: true, + DisplayName: 'Choose Mullvad region' +}), 'Choose Mullvad region', 'tailscale keeps the synthetic add-Mullvad row label') + +assertEqual(tailscale.exitNodeLabel({ HostName: 'Firezone', DisplayName: 'Firezone' }), 'Firezone', 'tailscale falls back to the hostname when DNS is missing') +assertEqual(tailscale.exitNodeLabel(null), 'Unknown', 'tailscale labels a missing exit node peer as Unknown') + +assert(/readonly property string peerName: tailscale\.exitNodeLabel\(peer\)/.test(panelSource), 'tailscale labels exit node rows with the MagicDNS helper') +assert(/readonly property string peerName: peer \? String\(peer\.DisplayName \|\| peer\.HostName \|\| "Unknown"\) : "Unknown"/.test(panelSource), 'tailscale keeps the friendly hostname on machine rows') +const SELF = '7119035026267488' + +assertEqual(tailscale.peerGroup({ UserID: SELF, Tags: ['tag:exit-node'] }, SELF), 'tagged', 'tailscale groups my tagged device as tagged') +assertEqual(tailscale.peerGroup({ UserID: '999', Tags: ['tag:server'] }, SELF), 'tagged', 'tailscale groups another user tagged device as tagged') +assertEqual(tailscale.peerGroup({ UserID: SELF, Tags: [] }, SELF), 'mine', 'tailscale groups my untagged device as mine') +assertEqual(tailscale.peerGroup({ UserID: '999', Tags: [] }, SELF), 'other', 'tailscale groups another user device as other') +assertEqual(tailscale.peerGroup(null, SELF), 'other', 'tailscale groups a missing peer as other') +assertEqual(tailscale.peerGroup({ UserID: '', Tags: [] }, SELF), 'other', 'tailscale groups an ownerless peer as other') +assertEqual(tailscale.peerGroup({ UserID: SELF, Tags: [] }, ''), 'other', 'tailscale groups every peer as other without a self id') + +const grouped = tailscale.groupPeers([ + { HostName: 'a', UserID: SELF, Tags: [] }, + { HostName: 'b', UserID: '999', Tags: ['tag:x'] }, + { HostName: 'c', UserID: '999', Tags: [] }, + { HostName: 'd', UserID: SELF, Tags: [] } +], SELF) + +assertDeepEqual(grouped.mine.map(function (p) { return p.HostName }), ['a', 'd'], 'tailscale collects my devices in order') +assertDeepEqual(grouped.tagged.map(function (p) { return p.HostName }), ['b'], 'tailscale collects tagged devices in order') +assertDeepEqual(grouped.other.map(function (p) { return p.HostName }), ['c'], 'tailscale collects other devices in order') + +assertDeepEqual(tailscale.groupPeers([], SELF), { mine: [], tagged: [], other: [] }, 'tailscale groups an empty peer list into empty groups') +assertDeepEqual(tailscale.groupPeers(null, SELF), { mine: [], tagged: [], other: [] }, 'tailscale groups a missing peer list into empty groups') +const firezone = { + HostName: 'Firezone', + DisplayName: 'Firezone', + DNSName: 'ny-exit-node.tailcb223.ts.net', + OS: 'linux', + TailscaleIPs: ['100.95.213.121'] +} + +assert(tailscale.peerMatchesQuery(firezone, 'fire'), 'tailscale matches a peer by display name') +assert(tailscale.peerMatchesQuery(firezone, 'FIRE'), 'tailscale matches a peer case-insensitively') +assert(tailscale.peerMatchesQuery(firezone, 'ny-exit'), 'tailscale matches a peer by MagicDNS name') +assert(tailscale.peerMatchesQuery(firezone, '100.95'), 'tailscale matches a peer by IP address') +assert(!tailscale.peerMatchesQuery(firezone, 'zzz'), 'tailscale rejects a non-matching query') +assert(tailscale.peerMatchesQuery(firezone, ''), 'tailscale treats an empty query as matching') +assert(tailscale.peerMatchesQuery(firezone, ' '), 'tailscale treats a blank query as matching') +assert(tailscale.peerMatchesQuery(null, ''), 'tailscale treats an empty query as matching even without a peer') +assert(!tailscale.peerMatchesQuery(null, 'fire'), 'tailscale rejects a real query against a missing peer') + +const searchable = [firezone, { + HostName: 'atl-exit-node', + DisplayName: 'atl-exit-node', + DNSName: 'atl-exit-node.tailcb223.ts.net', + TailscaleIPs: [] +}] + +assertEqual(tailscale.filterPeers(searchable, 'fire').length, 1, 'tailscale filters peers down to a single match') +assertEqual(tailscale.filterPeers(searchable, 'exit-node').length, 2, 'tailscale filters peers on a shared DNS fragment') +assertEqual(tailscale.filterPeers(searchable, '').length, 2, 'tailscale returns every peer for an empty query') +assertEqual(tailscale.filterPeers(null, 'x').length, 0, 'tailscale filters a missing peer list to nothing') JS