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
}