1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
package com.vauth.foxyvpn.data
import android.content.Context
import com.vauth.foxyvpn.data.model.ProxyCandidate
import com.vauth.foxyvpn.data.model.VpnCity
import com.vauth.foxyvpn.data.model.VpnCountry
import com.vauth.foxyvpn.data.model.VpnProtocol
import com.vauth.foxyvpn.data.model.VpnServerNode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.io.IOException
private const val TAG = "ServerListClient"
private const val REMOTE_SETTINGS_URL =
"https://firefox.settings.services.mozilla.com/v1/buckets/main/collections/vpn-serverlist/records"
const val RECOMMENDED_COUNTRY_CODE = "REC"
private val EXCLUDED_COUNTRY_NAMES = setOf("CatchAll Anycast")
class ServerListClient(
private val context: Context? = null,
private val client: OkHttpClient = ControlPlaneHttp.client,
) {
private val cacheFile: File?
get() = context?.let { File(it.filesDir, "vpn_serverlist_cache.json") }
suspend fun fetchCountries(): List<VpnCountry> = withContext(Dispatchers.IO) {
val request = Request.Builder().url(REMOTE_SETTINGS_URL).applyMozillaVpnHeaders().build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("Remote Settings fetch failed: HTTP ${response.code}")
}
val raw = response.body?.string().orEmpty()
val countries = parseRecords(raw)
if (countries.isNotEmpty()) {
saveCache(raw)
}
AppLogger.i(TAG, "fetched ${countries.size} countries from Remote Settings")
countries
}
}
fun loadCachedCountries(): List<VpnCountry> {
val file = cacheFile ?: return emptyList()
if (!file.exists() || !file.isFile) return emptyList()
return runCatching {
val raw = file.readText()
val parsed = parseRecords(raw)
if (parsed.isNotEmpty()) {
AppLogger.d(TAG, "loaded ${parsed.size} countries from local cache")
}
parsed
}.getOrElse {
AppLogger.w(TAG, "failed to read server list cache", it)
emptyList()
}
}
private fun saveCache(rawJson: String) {
val file = cacheFile ?: return
runCatching {
val tempFile = File.createTempFile("serverlist", ".tmp", file.parentFile)
tempFile.writeText(rawJson)
if (!tempFile.renameTo(file)) {
tempFile.copyTo(file, overwrite = true)
tempFile.delete()
}
AppLogger.d(TAG, "updated local server list cache")
}.onFailure {
AppLogger.w(TAG, "failed to save server list cache", it)
}
}
private fun parseRecords(rawJson: String): List<VpnCountry> {
if (rawJson.isBlank()) return emptyList()
val body = JSONObject(rawJson)
val records = body.optJSONArray("data") ?: JSONArray()
val countries = mutableListOf<VpnCountry>()
for (i in 0 until records.length()) {
val record = records.getJSONObject(i)
val countryJson = record.optJSONObject("country") ?: record
val country = parseCountry(countryJson)
val isExcluded = EXCLUDED_COUNTRY_NAMES.any { it.equals(country.name, ignoreCase = true) }
if (country.code.isNotBlank() && country.cities.isNotEmpty() && !isExcluded) {
countries.add(country)
}
}
return countries
}
private fun parseCountry(json: JSONObject): VpnCountry = VpnCountry(
name = json.optString("name"),
code = json.optString("code"),
cities = buildList {
val cities = json.optJSONArray("cities") ?: JSONArray()
for (i in 0 until cities.length()) add(parseCity(cities.getJSONObject(i)))
},
)
private fun parseCity(json: JSONObject): VpnCity = VpnCity(
name = json.optString("name"),
code = json.optString("code"),
servers = buildList {
val servers = json.optJSONArray("servers") ?: JSONArray()
for (i in 0 until servers.length()) add(parseServer(servers.getJSONObject(i)))
},
)
private fun parseServer(json: JSONObject): VpnServerNode = VpnServerNode(
hostname = json.optString("hostname"),
port = json.optInt("port", 0),
quarantined = json.optBoolean("quarantined", false),
protocols = buildList {
val protocols = json.optJSONArray("protocols") ?: JSONArray()
for (i in 0 until protocols.length()) add(parseProtocol(protocols.getJSONObject(i)))
},
)
private fun parseProtocol(json: JSONObject): VpnProtocol = VpnProtocol(
name = json.optString("name"),
host = json.optString("host"),
port = json.optInt("port", 0),
scheme = json.optString("scheme"),
templateString = json.optString("templateString"),
)
companion object {
fun defaultConnectTarget(server: VpnServerNode): Pair<String, Int>? {
for (proto in server.protocols) {
if (proto.name == "connect") {
val host = proto.host.ifBlank { server.hostname }
val port = if (proto.port != 0) proto.port else server.port
return host to port
}
}
if (server.protocols.isEmpty()) return server.hostname to server.port
return null
}
fun candidatesForCountry(countries: List<VpnCountry>, countryCode: String): List<ProxyCandidate> {
val out = mutableListOf<ProxyCandidate>()
for (country in countries) {
if (!country.code.equals(countryCode, ignoreCase = true)) continue
for (city in country.cities) {
for (server in city.servers) {
if (server.quarantined) continue
val target = defaultConnectTarget(server) ?: continue
out.add(
ProxyCandidate(
host = target.first,
port = target.second,
countryCode = country.code,
countryName = country.name,
cityCode = city.code,
),
)
}
}
}
return out
}
fun candidatesForCity(countries: List<VpnCountry>, countryCode: String, cityCode: String): List<ProxyCandidate> =
candidatesForCountry(countries, countryCode).filter { it.cityCode.equals(cityCode, ignoreCase = true) }
}
}