Group Mullvad exit nodes by country
This commit is contained in:
@@ -35,12 +35,19 @@ function displayHostName(hostName, dnsName) {
|
|||||||
return shortDnsName(dnsName) || host || "Unknown"
|
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) {
|
function osIcon(os) {
|
||||||
var value = String(os || "").toLowerCase()
|
var value = String(os || "").toLowerCase()
|
||||||
if (value === "linux") return ""
|
if (value === "linux") return ""
|
||||||
if (value === "macos" || value === "ios") return ""
|
if (value === "macos" || value === "ios") return ""
|
||||||
if (value === "windows") return ""
|
if (value === "windows") return ""
|
||||||
if (value === "android") return ""
|
if (value === "android") return ""
|
||||||
|
if (value === "mullvad") return ""
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,16 +64,113 @@ function peerFromStatus(id, peer) {
|
|||||||
id: id,
|
id: id,
|
||||||
HostName: displayHostName(peer.HostName, peer.DNSName),
|
HostName: displayHostName(peer.HostName, peer.DNSName),
|
||||||
DNSName: cleanDnsName(peer.DNSName),
|
DNSName: cleanDnsName(peer.DNSName),
|
||||||
|
DisplayName: displayHostName(peer.HostName, peer.DNSName),
|
||||||
TailscaleIPs: filterIPv4(peer.TailscaleIPs || []),
|
TailscaleIPs: filterIPv4(peer.TailscaleIPs || []),
|
||||||
TailscaleIPv6: filterIPv6(peer.TailscaleIPs || []),
|
TailscaleIPv6: filterIPv6(peer.TailscaleIPs || []),
|
||||||
Online: peer.Online === true,
|
Online: peer.Online === true,
|
||||||
OS: String(peer.OS || ""),
|
OS: String(peer.OS || ""),
|
||||||
Tags: peer.Tags || [],
|
Tags: peer.Tags || [],
|
||||||
ExitNodeOption: peer.ExitNodeOption === true,
|
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) {
|
function parseStatus(raw) {
|
||||||
var text = String(raw || "").trim()
|
var text = String(raw || "").trim()
|
||||||
if (text === "") return { ok: true, unavailable: true, message: "Disconnected" }
|
if (text === "") return { ok: true, unavailable: true, message: "Disconnected" }
|
||||||
@@ -83,6 +187,7 @@ function parseStatus(raw) {
|
|||||||
for (var id in rawPeers) {
|
for (var id in rawPeers) {
|
||||||
var peer = rawPeers[id] || {}
|
var peer = rawPeers[id] || {}
|
||||||
var normalized = peerFromStatus(id, peer)
|
var normalized = peerFromStatus(id, peer)
|
||||||
|
if (normalized.Mullvad) continue
|
||||||
if (normalized.Online) {
|
if (normalized.Online) {
|
||||||
peers.push(normalized)
|
peers.push(normalized)
|
||||||
if (normalized.ExitNodeOption) exitNodes.push(normalized)
|
if (normalized.ExitNodeOption) exitNodes.push(normalized)
|
||||||
@@ -155,7 +260,10 @@ if (typeof module !== "undefined") {
|
|||||||
displayHostName: displayHostName,
|
displayHostName: displayHostName,
|
||||||
osIcon: osIcon,
|
osIcon: osIcon,
|
||||||
accountLabel: accountLabel,
|
accountLabel: accountLabel,
|
||||||
|
isMullvadPeer: isMullvadPeer,
|
||||||
peerFromStatus: peerFromStatus,
|
peerFromStatus: peerFromStatus,
|
||||||
|
parseExitNodeList: parseExitNodeList,
|
||||||
|
mullvadCountryOptions: mullvadCountryOptions,
|
||||||
parseStatus: parseStatus,
|
parseStatus: parseStatus,
|
||||||
parseAccounts: parseAccounts
|
parseAccounts: parseAccounts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ Panel {
|
|||||||
property int exitNodeIndex: 0
|
property int exitNodeIndex: 0
|
||||||
property bool cursorActive: false
|
property bool cursorActive: false
|
||||||
property bool copyMenuOpen: false
|
property bool copyMenuOpen: false
|
||||||
|
property bool mullvadPickerOpen: false
|
||||||
|
property string mullvadQuery: ""
|
||||||
property int phraseIndex: 0
|
property int phraseIndex: 0
|
||||||
readonly property var activePhrases: [
|
readonly property var activePhrases: [
|
||||||
"Encrypting connections",
|
"Encrypting connections",
|
||||||
@@ -40,8 +42,11 @@ Panel {
|
|||||||
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
|
||||||
readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied
|
readonly property bool showConnections: tailscale.accounts.length > 1 || tailscale.accountsAccessDenied
|
||||||
readonly property bool showPeers: tailscale.running && tailscale.peers.length > 0
|
readonly property bool showPeers: tailscale.running && tailscale.peers.length > 0
|
||||||
readonly property var exitNodes: tailscale.exitNodes
|
readonly property var recentMullvadCountries: Array.isArray(settings.recentMullvadCountries) ? settings.recentMullvadCountries : []
|
||||||
readonly property bool showExitNodes: tailscale.running && exitNodes.length > 0
|
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 iconColor: tailscale.running ? foreground : dim
|
||||||
readonly property color barIconColor: tailscale.running ? barForeground : Qt.darker(barForeground, 1.55)
|
readonly property color barIconColor: tailscale.running ? barForeground : Qt.darker(barForeground, 1.55)
|
||||||
readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent"
|
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))]
|
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() {
|
function selectedAccount() {
|
||||||
if (tailscale.accounts.length === 0) return null
|
if (tailscale.accounts.length === 0) return null
|
||||||
return tailscale.accounts[Math.max(0, Math.min(accountIndex, tailscale.accounts.length - 1))]
|
return tailscale.accounts[Math.max(0, Math.min(accountIndex, tailscale.accounts.length - 1))]
|
||||||
@@ -133,7 +215,7 @@ Panel {
|
|||||||
} else if (focusSection === "peers") {
|
} else if (focusSection === "peers") {
|
||||||
openSelectedPeerCopyMenu()
|
openSelectedPeerCopyMenu()
|
||||||
} else if (focusSection === "exitNodes") {
|
} else if (focusSection === "exitNodes") {
|
||||||
tailscale.setExitNode(selectedExitNode())
|
chooseExitNode(selectedExitNode())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +541,43 @@ Panel {
|
|||||||
rowIndex: index
|
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
|
id: peerRow
|
||||||
property var peer: null
|
property var peer: null
|
||||||
property int rowIndex: 0
|
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 peerIp: peer && peer.TailscaleIPs && peer.TailscaleIPs.length > 0 ? String(peer.TailscaleIPs[0]) : ""
|
||||||
readonly property string peerIpv6: {
|
readonly property string peerIpv6: {
|
||||||
if (!peer || !peer.TailscaleIPv6 || peer.TailscaleIPv6.length === 0) return ""
|
if (!peer || !peer.TailscaleIPv6 || peer.TailscaleIPv6.length === 0) return ""
|
||||||
@@ -826,7 +945,7 @@ Panel {
|
|||||||
color: Color.background
|
color: Color.background
|
||||||
border.color: root.dim
|
border.color: root.dim
|
||||||
border.width: 1
|
border.width: 1
|
||||||
radius: Style.radius.md
|
radius: Style.cornerRadius
|
||||||
}
|
}
|
||||||
|
|
||||||
contentItem: Column {
|
contentItem: Column {
|
||||||
@@ -899,12 +1018,13 @@ Panel {
|
|||||||
id: exitNodeRow
|
id: exitNodeRow
|
||||||
property var peer: null
|
property var peer: null
|
||||||
property int rowIndex: 0
|
property int rowIndex: 0
|
||||||
|
readonly property bool addMullvad: peer && peer.AddMullvad === true
|
||||||
readonly property bool activeExitNode: peer && peer.ExitNode === true
|
readonly property bool activeExitNode: peer && peer.ExitNode === true
|
||||||
readonly property bool settingExitNode: peer && tailscale.settingExitNodeId === String(peer.id || "")
|
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
|
hasCursor: root.cursorActive && root.focusSection === "exitNodes" && root.exitNodeIndex === rowIndex
|
||||||
current: activeExitNode || settingExitNode
|
current: activeExitNode || settingExitNode || (addMullvad && root.mullvadPickerOpen)
|
||||||
foreground: root.foreground
|
foreground: root.foreground
|
||||||
fill: root.hoverFill
|
fill: root.hoverFill
|
||||||
currentFill: root.selectedFill
|
currentFill: root.selectedFill
|
||||||
@@ -922,8 +1042,8 @@ Panel {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
id: exitNodeGlyph
|
id: exitNodeGlyph
|
||||||
text: ""
|
text: exitNodeRow.addMullvad ? "+" : (peer && peer.Mullvad === true ? "" : "")
|
||||||
color: exitNodeRow.activeExitNode || exitNodeRow.settingExitNode ? root.foreground : root.dim
|
color: exitNodeRow.activeExitNode || exitNodeRow.settingExitNode || exitNodeRow.addMullvad ? root.foreground : root.dim
|
||||||
font.family: root.fontFamily
|
font.family: root.fontFamily
|
||||||
font.pixelSize: Style.font.body
|
font.pixelSize: Style.font.body
|
||||||
width: Style.space(22)
|
width: Style.space(22)
|
||||||
@@ -958,7 +1078,74 @@ Panel {
|
|||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
onEntered: root.setExitNodeCursor(exitNodeRow.rowIndex)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ Item {
|
|||||||
property string authUrl: ""
|
property string authUrl: ""
|
||||||
property var peers: []
|
property var peers: []
|
||||||
property var exitNodes: []
|
property var exitNodes: []
|
||||||
|
property var tailnetExitNodes: []
|
||||||
|
property var mullvadExitNodes: []
|
||||||
|
property var mullvadCountries: []
|
||||||
property var accounts: []
|
property var accounts: []
|
||||||
property string selectedAccountId: ""
|
property string selectedAccountId: ""
|
||||||
property string selectedAccountLabel: ""
|
property string selectedAccountLabel: ""
|
||||||
@@ -31,13 +34,15 @@ Item {
|
|||||||
property string lastError: ""
|
property string lastError: ""
|
||||||
|
|
||||||
readonly property int refreshIntervalSec: intSetting("refreshIntervalSec", 30, 5, 3600)
|
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")
|
readonly property string userName: Quickshell.env("USER") || Quickshell.env("LOGNAME")
|
||||||
|
|
||||||
property string _statusOutput: ""
|
property string _statusOutput: ""
|
||||||
property string _statusError: ""
|
property string _statusError: ""
|
||||||
property string _accountsOutput: ""
|
property string _accountsOutput: ""
|
||||||
property string _accountsError: ""
|
property string _accountsError: ""
|
||||||
|
property string _mullvadExitNodesOutput: ""
|
||||||
|
property string _mullvadExitNodesError: ""
|
||||||
property string _actionOutput: ""
|
property string _actionOutput: ""
|
||||||
property string _actionError: ""
|
property string _actionError: ""
|
||||||
property string _loginOutput: ""
|
property string _loginOutput: ""
|
||||||
@@ -133,6 +138,12 @@ Item {
|
|||||||
statusProcess.command = ["tailscale", "status", "--json"]
|
statusProcess.command = ["tailscale", "status", "--json"]
|
||||||
statusProcess.running = true
|
statusProcess.running = true
|
||||||
}
|
}
|
||||||
|
if (!mullvadExitNodesProcess.running) {
|
||||||
|
_mullvadExitNodesOutput = ""
|
||||||
|
_mullvadExitNodesError = ""
|
||||||
|
mullvadExitNodesProcess.command = ["tailscale", "exit-node", "list"]
|
||||||
|
mullvadExitNodesProcess.running = true
|
||||||
|
}
|
||||||
var now = Date.now()
|
var now = Date.now()
|
||||||
var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000
|
var shouldRefreshAccounts = forceAccounts === true || accounts.length === 0 || now - _lastAccountsRefreshMs > 60000
|
||||||
if (shouldRefreshAccounts && !accountsProcess.running) {
|
if (shouldRefreshAccounts && !accountsProcess.running) {
|
||||||
@@ -160,6 +171,9 @@ Item {
|
|||||||
authUrl = ""
|
authUrl = ""
|
||||||
peers = []
|
peers = []
|
||||||
exitNodes = []
|
exitNodes = []
|
||||||
|
tailnetExitNodes = []
|
||||||
|
mullvadExitNodes = []
|
||||||
|
mullvadCountries = []
|
||||||
accounts = []
|
accounts = []
|
||||||
selectedAccountId = ""
|
selectedAccountId = ""
|
||||||
selectedAccountLabel = ""
|
selectedAccountLabel = ""
|
||||||
@@ -190,7 +204,8 @@ Item {
|
|||||||
selfDnsName = parsed.selfDnsName
|
selfDnsName = parsed.selfDnsName
|
||||||
selfIp = parsed.selfIp
|
selfIp = parsed.selfIp
|
||||||
peers = parsed.running ? parsed.peers : []
|
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"
|
if (needsLogin) statusText = "Needs login"
|
||||||
else if (running) {
|
else if (running) {
|
||||||
@@ -215,6 +230,12 @@ Item {
|
|||||||
accountsAccessDenied = false
|
accountsAccessDenied = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseMullvadExitNodes(raw) {
|
||||||
|
mullvadExitNodes = Model.parseExitNodeList(raw)
|
||||||
|
mullvadCountries = Model.mullvadCountryOptions(mullvadExitNodes)
|
||||||
|
exitNodes = running ? tailnetExitNodes.concat(mullvadCountries) : []
|
||||||
|
}
|
||||||
|
|
||||||
function toggleTailscale() {
|
function toggleTailscale() {
|
||||||
if (!installed) return
|
if (!installed) return
|
||||||
if (running) runAction(["tailscale", "down"], "Turning Tailscale off…")
|
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 {
|
Process {
|
||||||
id: actionProcess
|
id: actionProcess
|
||||||
running: false
|
running: false
|
||||||
|
|||||||
@@ -62,16 +62,54 @@ const status = tailscale.parseStatus(JSON.stringify({
|
|||||||
TailscaleIPs: ['100.1.1.1', 'fd7a:115c:a1e0::1901:334b'],
|
TailscaleIPs: ['100.1.1.1', 'fd7a:115c:a1e0::1901:334b'],
|
||||||
Online: true,
|
Online: true,
|
||||||
OS: 'macos'
|
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')
|
assert(status.ok && status.running, 'tailscale parses running status')
|
||||||
assertEqual(status.selfIp, '100.74.97.73', 'tailscale parses self IP')
|
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')
|
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')
|
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({
|
const stopped = tailscale.parseStatus(JSON.stringify({
|
||||||
BackendState: 'Stopped',
|
BackendState: 'Stopped',
|
||||||
|
|||||||
Reference in New Issue
Block a user