Group Mullvad exit nodes by country

This commit is contained in:
David Heinemeier Hansson
2026-05-28 21:21:33 +02:00
parent 007393387a
commit d3666c5e98
4 changed files with 382 additions and 15 deletions
+109 -1
View File
@@ -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
}
+197 -10
View File
@@ -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)
}
}
}
+36 -2
View File
@@ -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