From d3666c5e989c778927182edcda51742a248f4c8c Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 27 May 2026 16:34:12 +0200 Subject: [PATCH] Group Mullvad exit nodes by country --- shell/plugins/panels/tailscale/Model.js | 110 ++++++++++- shell/plugins/panels/tailscale/Panel.qml | 207 ++++++++++++++++++++- shell/plugins/panels/tailscale/Service.qml | 38 +++- test/shell.d/tailscale-test.sh | 42 ++++- 4 files changed, 382 insertions(+), 15 deletions(-) diff --git a/shell/plugins/panels/tailscale/Model.js b/shell/plugins/panels/tailscale/Model.js index e7039516..5dbc890d 100644 --- a/shell/plugins/panels/tailscale/Model.js +++ b/shell/plugins/panels/tailscale/Model.js @@ -35,12 +35,19 @@ function displayHostName(hostName, dnsName) { return shortDnsName(dnsName) || host || "Unknown" } +function isMullvadPeer(peer) { + var hostName = String((peer && peer.HostName) || "").toLowerCase() + var dnsName = cleanDnsName((peer && peer.DNSName) || "").toLowerCase() + return dnsName.indexOf(".mullvad.ts.net") !== -1 || hostName.indexOf(".mullvad.ts.net") !== -1 +} + function osIcon(os) { var value = String(os || "").toLowerCase() if (value === "linux") return "󰌽" if (value === "macos" || value === "ios") return "󰀵" if (value === "windows") return "󰍲" if (value === "android") return "󰀲" + if (value === "mullvad") return "󰖂" return "󰟀" } @@ -57,16 +64,113 @@ function peerFromStatus(id, peer) { id: id, HostName: displayHostName(peer.HostName, peer.DNSName), DNSName: cleanDnsName(peer.DNSName), + DisplayName: displayHostName(peer.HostName, peer.DNSName), TailscaleIPs: filterIPv4(peer.TailscaleIPs || []), TailscaleIPv6: filterIPv6(peer.TailscaleIPs || []), Online: peer.Online === true, OS: String(peer.OS || ""), Tags: peer.Tags || [], ExitNodeOption: peer.ExitNodeOption === true, - ExitNode: peer.ExitNode === true + ExitNode: peer.ExitNode === true, + Mullvad: isMullvadPeer(peer) } } +function sliceTableColumn(line, start, end) { + var text = String(line || "") + if (start < 0 || start >= text.length) return "" + if (end < 0) return text.substring(start).trim() + return text.substring(start, Math.min(end, text.length)).trim() +} + +function parseExitNodeList(raw) { + var lines = String(raw || "").split(/\r?\n/) + var header = "" + var headerIndex = -1 + for (var i = 0; i < lines.length; i++) { + if (/^\s*IP\s+HOSTNAME\s+COUNTRY\s+CITY\s+STATUS\s*$/.test(lines[i])) { + header = lines[i] + headerIndex = i + break + } + } + if (headerIndex === -1) return [] + + var ipStart = header.indexOf("IP") + var hostStart = header.indexOf("HOSTNAME") + var countryStart = header.indexOf("COUNTRY") + var cityStart = header.indexOf("CITY") + var statusStart = header.indexOf("STATUS") + var byHost = {} + + for (var j = headerIndex + 1; j < lines.length; j++) { + var line = lines[j] + if (/^\s*$/.test(line) || /^\s*#/.test(line)) continue + + var ip = sliceTableColumn(line, ipStart, hostStart) + var host = sliceTableColumn(line, hostStart, countryStart) + var country = sliceTableColumn(line, countryStart, cityStart) + var city = sliceTableColumn(line, cityStart, statusStart) + var status = sliceTableColumn(line, statusStart, -1) + if (host.indexOf(".mullvad.ts.net") === -1) continue + + byHost[host] = { + id: "mullvad:" + host, + HostName: host, + DNSName: host, + DisplayName: (city && city !== "Any" ? city + ", " : "") + country, + TailscaleIPs: ip ? [ip] : [], + TailscaleIPv6: [], + Online: true, + OS: "mullvad", + Tags: [], + ExitNodeOption: true, + ExitNode: status !== "" && status !== "-", + Mullvad: true, + Country: country, + City: city, + Status: status + } + } + + var result = [] + for (var hostName in byHost) result.push(byHost[hostName]) + result.sort(function(a, b) { + var countryCompare = String(a.Country).localeCompare(String(b.Country)) + if (countryCompare !== 0) return countryCompare + return String(a.DisplayName).localeCompare(String(b.DisplayName)) + }) + return result +} + +function mullvadCountryOptions(nodes) { + var byCountry = {} + var values = Array.isArray(nodes) ? nodes : [] + for (var i = 0; i < values.length; i++) { + var node = values[i] || {} + if (node.Mullvad !== true) continue + var country = String(node.Country || "").trim() + if (country === "") continue + var current = byCountry[country] + if (!current || node.City === "Any") { + var option = {} + for (var key in node) option[key] = node[key] + option.id = "mullvad-country:" + country + option.DisplayName = country + option.Country = country + option.MullvadCountry = true + byCountry[country] = option + } + } + + var result = [] + for (var name in byCountry) result.push(byCountry[name]) + result.sort(function(a, b) { + return String(a.Country).localeCompare(String(b.Country)) + }) + return result +} + function parseStatus(raw) { var text = String(raw || "").trim() if (text === "") return { ok: true, unavailable: true, message: "Disconnected" } @@ -83,6 +187,7 @@ function parseStatus(raw) { for (var id in rawPeers) { var peer = rawPeers[id] || {} var normalized = peerFromStatus(id, peer) + if (normalized.Mullvad) continue if (normalized.Online) { peers.push(normalized) if (normalized.ExitNodeOption) exitNodes.push(normalized) @@ -155,7 +260,10 @@ if (typeof module !== "undefined") { displayHostName: displayHostName, osIcon: osIcon, accountLabel: accountLabel, + isMullvadPeer: isMullvadPeer, peerFromStatus: peerFromStatus, + parseExitNodeList: parseExitNodeList, + mullvadCountryOptions: mullvadCountryOptions, parseStatus: parseStatus, parseAccounts: parseAccounts } diff --git a/shell/plugins/panels/tailscale/Panel.qml b/shell/plugins/panels/tailscale/Panel.qml index c61c2000..e7847450 100644 --- a/shell/plugins/panels/tailscale/Panel.qml +++ b/shell/plugins/panels/tailscale/Panel.qml @@ -19,6 +19,8 @@ Panel { property int exitNodeIndex: 0 property bool cursorActive: false property bool copyMenuOpen: false + property bool mullvadPickerOpen: false + property string mullvadQuery: "" property int phraseIndex: 0 readonly property var activePhrases: [ "Encrypting connections", @@ -40,8 +42,11 @@ Panel { 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.running && tailscale.peers.length > 0 - readonly property var exitNodes: tailscale.exitNodes - readonly property bool showExitNodes: tailscale.running && exitNodes.length > 0 + readonly property var recentMullvadCountries: Array.isArray(settings.recentMullvadCountries) ? settings.recentMullvadCountries : [] + readonly property var recentMullvadExitNodes: recentMullvadNodes() + readonly property var exitNodes: displayExitNodes() + readonly property bool showExitNodes: tailscale.running && (exitNodes.length > 0 || tailscale.mullvadCountries.length > 0) + readonly property var filteredMullvadCountries: filteredMullvadCountryNodes() readonly property color iconColor: tailscale.running ? foreground : dim readonly property color barIconColor: tailscale.running ? barForeground : Qt.darker(barForeground, 1.55) readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" @@ -57,6 +62,83 @@ Panel { return exitNodes[Math.max(0, Math.min(exitNodeIndex, exitNodes.length - 1))] } + function displayExitNodes() { + var nodes = [] + for (var i = 0; i < tailscale.tailnetExitNodes.length; i++) nodes.push(tailscale.tailnetExitNodes[i]) + for (var j = 0; j < recentMullvadExitNodes.length; j++) nodes.push(recentMullvadExitNodes[j]) + if (tailscale.mullvadCountries.length > 0) nodes.push({ id: "mullvad:add", AddMullvad: true, DisplayName: "Choose Mullvad region" }) + return nodes + } + + function recentMullvadNodes() { + var nodes = [] + var seen = {} + for (var a = 0; a < tailscale.mullvadCountries.length && nodes.length < 5; a++) { + var active = tailscale.mullvadCountries[a] + var activeCountry = String(active.Country || "") + if (active.ExitNode === true && activeCountry !== "" && !seen[activeCountry]) { + nodes.push(active) + seen[activeCountry] = true + } + } + for (var i = 0; i < recentMullvadCountries.length && nodes.length < 5; i++) { + var country = String(recentMullvadCountries[i] || "") + if (country === "" || seen[country]) continue + var node = mullvadCountryNode(country) + if (node) { + nodes.push(node) + seen[country] = true + } + } + return nodes + } + + function mullvadCountryNode(country) { + for (var i = 0; i < tailscale.mullvadCountries.length; i++) { + var node = tailscale.mullvadCountries[i] + if (String(node.Country || "") === String(country || "")) return node + } + return null + } + + function filteredMullvadCountryNodes() { + var query = String(mullvadQuery || "").trim().toLowerCase() + var result = [] + for (var i = 0; i < tailscale.mullvadCountries.length; i++) { + var node = tailscale.mullvadCountries[i] + var label = String(node.DisplayName || node.Country || "").toLowerCase() + if (query === "" || label.indexOf(query) !== -1) result.push(node) + } + return result + } + + function persistRecentMullvad(country) { + var name = String(country || "") + if (name === "") return + var next = [name] + for (var i = 0; i < recentMullvadCountries.length && next.length < 5; i++) { + var existing = String(recentMullvadCountries[i] || "") + if (existing !== "" && existing !== name && next.indexOf(existing) === -1) next.push(existing) + } + if (!root.bar || !root.bar.shell || typeof root.bar.shell.updateEntryInline !== "function") return + var entry = { id: root.moduleName } + for (var key in settings) if (key !== "id") entry[key] = settings[key] + entry.recentMullvadCountries = next + root.bar.shell.updateEntryInline(root.moduleName, entry) + } + + function chooseExitNode(peer) { + if (!peer) return + if (peer.AddMullvad === true) { + mullvadPickerOpen = !mullvadPickerOpen + if (mullvadPickerOpen) Qt.callLater(function() { if (mullvadSearch) mullvadSearch.forceActiveFocus() }) + return + } + if (peer.Mullvad === true) persistRecentMullvad(peer.Country) + tailscale.setExitNode(peer) + mullvadPickerOpen = false + } + function selectedAccount() { if (tailscale.accounts.length === 0) return null return tailscale.accounts[Math.max(0, Math.min(accountIndex, tailscale.accounts.length - 1))] @@ -133,7 +215,7 @@ Panel { } else if (focusSection === "peers") { openSelectedPeerCopyMenu() } else if (focusSection === "exitNodes") { - tailscale.setExitNode(selectedExitNode()) + chooseExitNode(selectedExitNode()) } } @@ -459,6 +541,43 @@ Panel { rowIndex: index } } + + Column { + visible: root.mullvadPickerOpen + width: parent.width + spacing: Style.space(6) + + TextField { + id: mullvadSearch + width: parent.width + foreground: root.foreground + placeholderText: "Search countries" + text: root.mullvadQuery + onTextChanged: root.mullvadQuery = text + onAccepted: { + if (root.filteredMullvadCountries.length > 0) root.chooseExitNode(root.filteredMullvadCountries[0]) + } + } + + Text { + visible: root.filteredMullvadCountries.length === 0 + width: parent.width + text: "No Mullvad regions found." + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + horizontalAlignment: Text.AlignHCenter + } + + Repeater { + model: root.filteredMullvadCountries + MullvadCountryRow { + required property var modelData + width: parent.width + peer: modelData + } + } + } } } @@ -671,7 +790,7 @@ Panel { id: peerRow property var peer: null property int rowIndex: 0 - readonly property string peerName: peer ? String(peer.HostName || "Unknown") : "Unknown" + readonly property string peerName: peer ? String(peer.DisplayName || peer.HostName || "Unknown") : "Unknown" readonly property string peerIp: peer && peer.TailscaleIPs && peer.TailscaleIPs.length > 0 ? String(peer.TailscaleIPs[0]) : "" readonly property string peerIpv6: { if (!peer || !peer.TailscaleIPv6 || peer.TailscaleIPv6.length === 0) return "" @@ -826,7 +945,7 @@ Panel { color: Color.background border.color: root.dim border.width: 1 - radius: Style.radius.md + radius: Style.cornerRadius } contentItem: Column { @@ -899,12 +1018,13 @@ Panel { id: exitNodeRow property var peer: null property int rowIndex: 0 + 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.HostName || "Unknown") : "Unknown" + readonly property string peerName: peer ? String(peer.DisplayName || peer.HostName || "Unknown") : "Unknown" hasCursor: root.cursorActive && root.focusSection === "exitNodes" && root.exitNodeIndex === rowIndex - current: activeExitNode || settingExitNode + current: activeExitNode || settingExitNode || (addMullvad && root.mullvadPickerOpen) foreground: root.foreground fill: root.hoverFill currentFill: root.selectedFill @@ -922,8 +1042,8 @@ Panel { Text { id: exitNodeGlyph - text: "󱇢" - color: exitNodeRow.activeExitNode || exitNodeRow.settingExitNode ? root.foreground : root.dim + text: exitNodeRow.addMullvad ? "+" : (peer && peer.Mullvad === true ? "󰖂" : "󱇢") + color: exitNodeRow.activeExitNode || exitNodeRow.settingExitNode || exitNodeRow.addMullvad ? root.foreground : root.dim font.family: root.fontFamily font.pixelSize: Style.font.body width: Style.space(22) @@ -958,7 +1078,74 @@ Panel { hoverEnabled: true cursorShape: Qt.PointingHandCursor onEntered: root.setExitNodeCursor(exitNodeRow.rowIndex) - onClicked: if (exitNodeRow.peer) tailscale.setExitNode(exitNodeRow.peer) + onClicked: root.chooseExitNode(exitNodeRow.peer) + } + } + + component MullvadCountryRow: CursorSurface { + id: countryRow + + property var peer: null + readonly property string countryName: peer ? String(peer.DisplayName || peer.Country || "Unknown") : "Unknown" + readonly property bool activeExitNode: peer && peer.ExitNode === true + readonly property bool settingExitNode: peer && tailscale.settingExitNodeId === String(peer.id || "") + + foreground: root.foreground + fill: root.hoverFill + currentFill: root.selectedFill + current: activeExitNode || settingExitNode + implicitHeight: row.implicitHeight + Style.spacing.lg + + Row { + id: row + anchors.left: parent.left + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + spacing: Style.space(8) + + Text { + text: "󰖂" + color: countryRow.current ? root.foreground : root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.body + width: Style.space(22) + horizontalAlignment: Text.AlignHCenter + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Style.space(30) + anchors.verticalCenter: parent.verticalCenter + spacing: Style.space(1) + + Text { + width: parent.width + text: countryRow.countryName + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.body + font.bold: countryRow.activeExitNode + elide: Text.ElideRight + } + + Text { + width: parent.width + text: peer && peer.City === "Any" ? "Best available city" : String(peer && peer.City ? peer.City : "") + color: root.dim + font.family: root.fontFamily + font.pixelSize: Style.font.caption + elide: Text.ElideRight + } + } + } + + MouseArea { + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.chooseExitNode(countryRow.peer) } } } diff --git a/shell/plugins/panels/tailscale/Service.qml b/shell/plugins/panels/tailscale/Service.qml index 846bc42b..c5259f99 100644 --- a/shell/plugins/panels/tailscale/Service.qml +++ b/shell/plugins/panels/tailscale/Service.qml @@ -21,6 +21,9 @@ Item { property string authUrl: "" property var peers: [] property var exitNodes: [] + property var tailnetExitNodes: [] + property var mullvadExitNodes: [] + property var mullvadCountries: [] property var accounts: [] property string selectedAccountId: "" property string selectedAccountLabel: "" @@ -31,13 +34,15 @@ Item { property string lastError: "" readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 5, 3600) - readonly property bool busy: whichProcess.running || statusProcess.running || accountsProcess.running || actionProcess.running || loginProcess.running || switchProcess.running || operatorProcess.running || exitNodeProcess.running + readonly property bool busy: whichProcess.running || statusProcess.running || mullvadExitNodesProcess.running || accountsProcess.running || actionProcess.running || loginProcess.running || switchProcess.running || operatorProcess.running || exitNodeProcess.running readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME") property string _statusOutput: "" property string _statusError: "" property string _accountsOutput: "" property string _accountsError: "" + property string _mullvadExitNodesOutput: "" + property string _mullvadExitNodesError: "" property string _actionOutput: "" property string _actionError: "" property string _loginOutput: "" @@ -133,6 +138,12 @@ Item { statusProcess.command = ["tailscale", "status", "--json"] statusProcess.running = true } + if (!mullvadExitNodesProcess.running) { + _mullvadExitNodesOutput = "" + _mullvadExitNodesError = "" + mullvadExitNodesProcess.command = ["tailscale", "exit-node", "list"] + mullvadExitNodesProcess.running = true + } var now = Date.now() var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000 if (shouldRefreshAccounts && !accountsProcess.running) { @@ -160,6 +171,9 @@ Item { authUrl = "" peers = [] exitNodes = [] + tailnetExitNodes = [] + mullvadExitNodes = [] + mullvadCountries = [] accounts = [] selectedAccountId = "" selectedAccountLabel = "" @@ -190,7 +204,8 @@ Item { selfDnsName = parsed.selfDnsName selfIp = parsed.selfIp peers = parsed.running ? parsed.peers : [] - exitNodes = parsed.running ? parsed.exitNodes : [] + tailnetExitNodes = parsed.running ? parsed.exitNodes : [] + exitNodes = parsed.running ? tailnetExitNodes.concat(mullvadCountries) : [] if (needsLogin) statusText = "Needs login" else if (running) { @@ -215,6 +230,12 @@ Item { accountsAccessDenied = false } + function parseMullvadExitNodes(raw) { + mullvadExitNodes = Model.parseExitNodeList(raw) + mullvadCountries = Model.mullvadCountryOptions(mullvadExitNodes) + exitNodes = running ? tailnetExitNodes.concat(mullvadCountries) : [] + } + function toggleTailscale() { if (!installed) return if (running) runAction(["tailscale", "down"], "Turning Tailscale off…") @@ -397,6 +418,19 @@ Item { } } + Process { + id: mullvadExitNodesProcess + running: false + command: [] + stdout: StdioCollector { id: mullvadExitNodesStdout; waitForEnd: true; onStreamFinished: root._mullvadExitNodesOutput = text } + stderr: StdioCollector { id: mullvadExitNodesStderr; waitForEnd: true; onStreamFinished: root._mullvadExitNodesError = text } + onExited: function(exitCode) { + var stdout = String(mullvadExitNodesStdout.text || root._mullvadExitNodesOutput || "") + if (exitCode === 0) root.parseMullvadExitNodes(stdout) + else root.parseMullvadExitNodes("") + } + } + Process { id: actionProcess running: false diff --git a/test/shell.d/tailscale-test.sh b/test/shell.d/tailscale-test.sh index 47d4f15b..c4e38a67 100644 --- a/test/shell.d/tailscale-test.sh +++ b/test/shell.d/tailscale-test.sh @@ -62,16 +62,54 @@ const status = tailscale.parseStatus(JSON.stringify({ TailscaleIPs: ['100.1.1.1', 'fd7a:115c:a1e0::1901:334b'], Online: true, OS: 'macos' + }, + mullvadExit: { + HostName: 'al-tia-wg-003', + DNSName: 'al-tia-wg-003.mullvad.ts.net.', + TailscaleIPs: ['100.95.87.11'], + Online: true, + OS: 'linux', + ExitNodeOption: true, + ExitNode: false } } })) assert(status.ok && status.running, 'tailscale parses running status') assertEqual(status.selfIp, '100.74.97.73', 'tailscale parses self IP') -assertDeepEqual(status.peers.map(peer => peer.HostName), ['alpha', 'zed'], 'tailscale filters offline peers and sorts online peers') +assertDeepEqual(status.peers.map(peer => peer.HostName), ['alpha', 'zed'], 'tailscale filters offline and Mullvad peers and sorts online peers') assertDeepEqual(status.peers[0].TailscaleIPv6, ['fd7a:115c:a1e0::1901:334b'], 'tailscale preserves peer IPv6 addresses for copy menu') assert(status.peers[1].ExitNodeOption && status.peers[1].ExitNode, 'tailscale preserves exit node flags') -assertDeepEqual(status.exitNodes.map(peer => peer.HostName), ['zed'], 'tailscale lists only online exit nodes') +assertDeepEqual(status.exitNodes.map(peer => peer.HostName), ['zed'], 'tailscale lists only online tailnet exit nodes') +assert(tailscale.isMullvadPeer({ HostName: 'al-tia-wg-003', DNSName: 'al-tia-wg-003.mullvad.ts.net.' }), 'tailscale detects Mullvad status peers') + +const mullvadNodes = tailscale.parseExitNodeList(` + IP HOSTNAME COUNTRY CITY STATUS + 100.65.216.13 au-adl-wg-301.mullvad.ts.net Australia Any - + 100.65.216.13 au-adl-wg-301.mullvad.ts.net Australia Adelaide - + 100.66.11.119 dk-cph-wg-001.mullvad.ts.net Denmark Copenhagen - + 100.1.2.3 office.tailnet.ts.net Denmark Office - + +# To use an exit node, use tailscale set --exit-node= +`) + +assertDeepEqual( + mullvadNodes.map(node => node.DisplayName), + ['Adelaide, Australia', 'Copenhagen, Denmark'], + 'tailscale parses Mullvad exit nodes and skips duplicate country rows' +) +assertEqual(mullvadNodes[1].DNSName, 'dk-cph-wg-001.mullvad.ts.net', 'tailscale preserves Mullvad hostname as exit node target') +assertDeepEqual(mullvadNodes[1].TailscaleIPs, ['100.66.11.119'], 'tailscale preserves Mullvad exit node IP') +assert(mullvadNodes.every(node => node.Mullvad === true && node.ExitNodeOption === true), 'tailscale marks Mullvad rows as exit nodes') + +const mullvadCountries = tailscale.mullvadCountryOptions(mullvadNodes) +assertDeepEqual( + mullvadCountries.map(node => node.DisplayName), + ['Australia', 'Denmark'], + 'tailscale groups Mullvad exit nodes by country' +) +assertEqual(mullvadCountries[0].DNSName, 'au-adl-wg-301.mullvad.ts.net', 'tailscale uses a country endpoint for grouped countries') +assertEqual(mullvadCountries[1].DNSName, 'dk-cph-wg-001.mullvad.ts.net', 'tailscale falls back to first city endpoint without Any') const stopped = tailscale.parseStatus(JSON.stringify({ BackendState: 'Stopped',