feat(network): Provide additional DoH providers in case primary ones are blocked.

VPNAND-2332
This commit is contained in:
Mateusz Markowicz
2025-09-11 16:50:18 +02:00
parent 80f410799f
commit b60bf4e699
8 changed files with 236 additions and 27 deletions
@@ -96,6 +96,7 @@ class ApiManagerFactory(
private val clientVersionValidator: ClientVersionValidator,
private val dohAlternativesListener: DohAlternativesListener?,
private val dohProviderUrls: Array<String> = Constants.DOH_PROVIDERS_URLS,
private val alternativeDohProviderUrls: List<String> = Constants.ALTERNATIVE_DOH_PROVIDERS_URLS,
private val okHttpClient: OkHttpClient,
private val interceptors: Set<Pair<InterceptorInfo, Interceptor>>,
) {
@@ -218,6 +219,8 @@ class ApiManagerFactory(
baseUrl.toString(),
apiClient,
dohServices,
alternativeDohProviderUrls,
{ serviceUrl -> DnsOverHttpsProviderRFC8484(baseOkHttpClient, serviceUrl, apiClient, networkManager) },
protonDohService,
mainScope,
prefs,
@@ -34,6 +34,7 @@ class NetworkPrefsImpl(context: Context) : NetworkPrefs, PreferencesProvider {
override var activeAltBaseUrl: String? by string()
override var lastPrimaryApiFail: Long by long(default = Long.MIN_VALUE)
override var alternativeBaseUrls: List<String>? by list()
override var successfulSecondaryDohServiceUrl: String? by string()
companion object {
private const val PREFS_NAME = "me.proton.core.network"
@@ -49,4 +49,58 @@ object Constants {
*/
val DOH_PROVIDERS_URLS =
arrayOf("https://dns11.quad9.net/dns-query/", "https://dns.google/dns-query/")
/**
* Alternative DNS over HTTPS services urls. DohProvider will randomly pick from this list for additional resolvers.
*/
val ALTERNATIVE_DOH_PROVIDERS_URLS = listOf(
"https://anycast.dns.nextdns.io/dns-query/",
"https://130.59.31.248/dns-query/",
"https://dns-doh.dnsforfamily.com/dns-query/",
"https://94.140.14.14/dns-query/",
"https://1.1.1.2/dns-query/",
"https://pluton.plan9-dns.com/dns-query/",
"https://syd.adfilter.net/dns-query/",
"https://dnspub.restena.lu/dns-query/",
"https://blank.dnsforge.de/dns-query/",
"https://dns.mullvad.net/dns-query/",
"https://dns.digitale-gesellschaft.ch/dns-query/",
"https://dns.brahma.world/dns-query/",
"https://94.140.14.140/dns-query/",
"https://base.dns.mullvad.net/dns-query/",
"https://per.adfilter.net/dns-query/",
"https://adl.adfilter.net/dns-query/",
"https://family.dns.mullvad.net/dns-query/",
"https://dns.digitalsize.net/dns-query/",
"https://unicast.uncensoreddns.org/dns-query/",
"https://dns.circl.lu/dns-query/",
"https://dns12.quad9.net/dns-query/",
"https://all.dns.mullvad.net/dns-query/",
"https://dns.aa.net.uk/dns-query/",
"https://doh.libredns.gr/dns-query/",
"https://extended.dns.mullvad.net/dns-query/",
"https://dns.njal.la/dns-query/",
"https://dns9.quad9.net/dns-query/",
"https://149.112.112.9/dns-query/",
"https://freedns.controld.com/dns-query/",
"https://76.76.2.11/dns-query/",
"https://149.112.112.12/dns-query/",
"https://dnsforge.de/dns-query/",
"https://94.140.15.16/dns-query/",
"https://doq.dns4all.eu/dns-query/",
"https://ns1.fdn.fr/dns-query/",
"https://dns10.quad9.net/dns-query/",
"https://9.9.9.10/dns-query/",
"https://149.112.112.11/dns-query/",
"https://doh.ffmuc.net/dns-query/",
"https://odvr.nic.cz/dns-query/",
"https://ibksturm.synology.me/dns-query/",
"https://1.0.0.3/dns-query/",
"https://adblock.dns.mullvad.net/dns-query/",
"https://dns-doh-no-safe-search.dnsforfamily.com/dns-query/",
"https://dns1.dnscrypt.ca/dns-query/",
"https://cloudflare-dns.com/dns-query/",
"https://anycast.uncensoreddns.org/dns-query/",
"https://dns.quad9.net/dns-query/",
)
}
@@ -143,7 +143,7 @@ internal class ApiManagerTests {
if (firstArg<UserId?>() != null) session.sessionId else null
}
coEvery { sessionProvider.getSession(any()) } returns session
DohProvider.lastAlternativesRefresh = Long.MIN_VALUE
networkManager = MockNetworkManager()
networkManager.networkStatus = NetworkStatus.Unmetered
@@ -177,6 +177,8 @@ internal class ApiManagerTests {
baseUrl,
apiClient,
listOf(dohService),
emptyList(),
{ DohService { _, _ -> emptyList() } },
protonDohService,
testScope,
prefs,
@@ -414,6 +416,8 @@ internal class ApiManagerTests {
baseUrl,
apiClient,
listOf(dohService),
emptyList(),
{ DohService { _, _ -> emptyList() } },
protonDohService,
testScope,
prefs,
@@ -457,6 +461,8 @@ internal class ApiManagerTests {
baseUrl,
apiClient,
listOf(dohService),
emptyList(),
{ DohService { _, _ -> emptyList() } },
protonDohService,
testScope,
prefs,
@@ -25,6 +25,7 @@ import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.mockk
import kotlinx.coroutines.delay
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runTest
import me.proton.core.network.data.doh.DnsOverHttpsProviderRFC8484
@@ -49,6 +50,13 @@ import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
private val primaryService = DohService { _, _ -> delay(100); listOf("1.1.1.1")}
private val primaryServiceFail = DohService { _, _ -> delay(1000); null }
private val secondaryService = DohService { _, _ -> delay(10); listOf("2.2.2.2") }
private val secondaryServiceFail = DohService { _, _ -> delay(1000); null }
private const val SECONDARY_URL = "https://secondary.com"
private const val SECONDARY_URL_FAIL = "https://secondaryFail.com"
@Config(sdk = [Build.VERSION_CODES.M])
@RunWith(RobolectricTestRunner::class)
internal class DohProviderTests {
@@ -65,10 +73,11 @@ internal class DohProviderTests {
@BeforeTest
fun before() {
MockKAnnotations.init(this)
every { networkManager.isConnectedToNetwork() } returns isNetworkAvailable
every { networkManager.isConnectedToNetwork() } answers { isNetworkAvailable }
isNetworkAvailable = true
webServer = MockWebServer()
DohProvider.lastAlternativesRefresh = Long.MIN_VALUE
val okHttpClient = OkHttpClient.Builder().build()
val client = MockApiClient()
dohProvider = DnsOverHttpsProviderRFC8484(
@@ -120,18 +129,13 @@ internal class DohProviderTests {
listOf("proxy.com")
}
val protonService = mockk<DohService>()
val prefs = MockNetworkPrefs()
val provider = DohProvider(
"https://test.com",
MockApiClient(),
val provider = createDohProvider(
listOf(service1, service2),
protonService,
this,
emptyList(),
{ url -> mockk<DohService>() },
prefs,
{ currentTime },
null,
null)
)
val start = currentTime
provider.refreshAlternatives()
@@ -144,4 +148,112 @@ internal class DohProviderTests {
// Make sure it finishes as soon as one service succeeds.
assertEquals(1000, duration)
}
@Test
fun `primary providers results are preferred to secondary ones`() = runTest {
val prefs = MockNetworkPrefs()
val provider = createDohProvider(
listOf(primaryService),
listOf(SECONDARY_URL, SECONDARY_URL_FAIL),
{ url ->
when (url) {
SECONDARY_URL -> secondaryService
SECONDARY_URL_FAIL -> secondaryServiceFail
else -> throw IllegalStateException("Unexpected url")
}
},
prefs,
)
val start = currentTime
provider.refreshAlternatives()
val duration = currentTime - start
assertEquals(listOf("1.1.1.1"), prefs.alternativeBaseUrls)
// We'll wait for the primary service to finish, even if secondary one finished already.
assertEquals(100, duration)
assertEquals(SECONDARY_URL, prefs.successfulSecondaryDohServiceUrl)
}
@Test
fun `when primary providers fail, secondary results are used` () = runTest {
val prefs = MockNetworkPrefs()
val provider = createDohProvider(
listOf(primaryServiceFail),
listOf(SECONDARY_URL),
{ secondaryService },
prefs,
)
val start = currentTime
provider.refreshAlternatives()
val duration = currentTime - start
assertEquals(listOf("2.2.2.2"), prefs.alternativeBaseUrls)
assertEquals(SECONDARY_URL, prefs.successfulSecondaryDohServiceUrl)
// Wait for primary to fail (1000ms) and only then take secondary result.
assertEquals(1000, duration)
}
@Test
fun `when successful secondary fails, pref is cleared`() = runTest {
val prefs = MockNetworkPrefs()
prefs.successfulSecondaryDohServiceUrl = SECONDARY_URL_FAIL
val primaryServiceLong = DohService { _, _ -> delay(2_000); listOf("1.1.1.1") }
val provider = createDohProvider(
listOf(primaryServiceLong),
listOf(SECONDARY_URL_FAIL),
{ secondaryServiceFail },
prefs,
)
val start = currentTime
provider.refreshAlternatives()
val duration = currentTime - start
assertEquals(null, prefs.successfulSecondaryDohServiceUrl)
assertEquals(listOf("1.1.1.1"), prefs.alternativeBaseUrls)
assertEquals(2_000, duration)
}
@Test
fun `when primary works it doesn't wait for slow secondary`() = runTest {
val prefs = MockNetworkPrefs()
val secondaryServiceSlow = DohService { _, _ -> delay(5_000); listOf("2.2.2.2") }
val provider = createDohProvider(
listOf(primaryService),
listOf(SECONDARY_URL),
{ secondaryServiceSlow },
prefs,
)
val start = currentTime
provider.refreshAlternatives()
val duration = currentTime - start
assertEquals(listOf("1.1.1.1"), prefs.alternativeBaseUrls)
assertEquals(100, duration)
}
private fun TestScope.createDohProvider(
primaryServices: List<DohService> = listOf(primaryService),
secondaryServicesUrls: List<String> = listOf(SECONDARY_URL),
createSecondaryService: (String) -> DohService = { secondaryService },
prefs: MockNetworkPrefs = MockNetworkPrefs(),
) = DohProvider(
"https://test.com",
MockApiClient(),
primaryServices,
secondaryServicesUrls,
createSecondaryService,
mockk<DohService>(),
this,
prefs,
::currentTime,
null,
null
)
}
@@ -98,6 +98,7 @@ class MockNetworkPrefs : NetworkPrefs {
override var activeAltBaseUrl: String? = null
override var lastPrimaryApiFail: Long = Long.MIN_VALUE
override var alternativeBaseUrls: List<String>? = null
override var successfulSecondaryDohServiceUrl: String? = null
}
class MockLogger : Logger {
@@ -32,19 +32,21 @@ import java.util.concurrent.TimeUnit
/**
* Gets the list of alternative baseUrls for Proton API.
*/
interface DohService {
fun interface DohService {
suspend fun getAlternativeBaseUrls(sessionId: SessionId?, primaryBaseUrl: String): List<String>?
}
/**
* Refreshes alternative urls for [baseUrl] using given list of DoH services ([dohServices]). Makes
* sure that only one refresh operation takes place at one time for given baseUrl. Single instance
* should exist per baseUrl.
* Refreshes alternative urls for [baseUrl] using given list of DoH services ([primaryDohServices] + randomly selected
* services from [secondaryDohServicesUrls]). Makes sure that only one refresh operation takes place at one time for
* given baseUrl. Single instance should exist per baseUrl.
*/
class DohProvider(
private val baseUrl: String,
private val apiClient: ApiClient,
private val dohServices: List<DohService>,
private val primaryDohServices: List<DohService>,
private val secondaryDohServicesUrls: List<String>,
private val createSecondaryDohService: (String) -> DohService,
private val protonDohService: DohService,
private val networkMainScope: CoroutineScope,
private val prefs: NetworkPrefs,
@@ -77,24 +79,51 @@ class DohProvider(
}
private suspend fun tryDohServices(): Boolean = coroutineScope {
var allServicesFailed = true
// Select 2 random secondary services, but include successful one if available.
val secondaryServiceUrls =
prefs.successfulSecondaryDohServiceUrl?.let {
listOfNotNull(it, secondaryDohServicesUrls.randomOrNull())
} ?: secondaryDohServicesUrls.asSequence().shuffled().take(2).toList()
val secondaryDohServices = secondaryServiceUrls.map { createSecondaryDohService(it) }
val dohServices = primaryDohServices + secondaryDohServices
val successfulServices = mutableListOf<Pair<DohService, List<String>>>()
val jobs = mutableListOf<Job>()
dohServices.mapTo(jobs) { service ->
launch {
val success = withTimeoutOrNull(apiClient.dohServiceTimeoutMs) {
val result = service.getAlternativeBaseUrls(sessionId, baseUrl)
if (result != null)
prefs.alternativeBaseUrls = result
result != null
} ?: false
if (success) {
allServicesFailed = false
jobs.forEach { it.cancel() }
val isSecondaryService = service in secondaryDohServices
val result = withTimeoutOrNull(apiClient.dohServiceTimeoutMs) {
service.getAlternativeBaseUrls(sessionId, baseUrl)
}
if (result != null) {
successfulServices += service to result
if (!isSecondaryService) {
jobs.forEach { it.cancel() }
}
} else if (isSecondaryService) {
val index = secondaryDohServices.indexOf(service)
if (index != -1 && secondaryServiceUrls[index] == prefs.successfulSecondaryDohServiceUrl) {
prefs.successfulSecondaryDohServiceUrl = null
}
}
}
}
jobs.joinAll()
allServicesFailed
val firstSuccessfulPrimaryService = successfulServices.firstOrNull { (service, _) -> service in primaryDohServices }
val firstSuccessfulSecondaryService = successfulServices.firstOrNull { (service, _) -> service in secondaryDohServices }
if (firstSuccessfulPrimaryService != null) {
prefs.alternativeBaseUrls = firstSuccessfulPrimaryService.second
} else if (firstSuccessfulSecondaryService != null) {
prefs.alternativeBaseUrls = firstSuccessfulSecondaryService.second
}
if (firstSuccessfulSecondaryService != null) {
prefs.successfulSecondaryDohServiceUrl =
secondaryServiceUrls[secondaryDohServices.indexOf(firstSuccessfulSecondaryService.first)]
}
val allFailed = successfulServices.isEmpty()
allFailed
}
companion object {
@@ -31,4 +31,7 @@ interface NetworkPrefs {
/** List of base urls for Proton API proxies returned in the last DoH query. */
var alternativeBaseUrls: List<String>?
/** Url/IP of secondary DoH service that successfully responded last time. */
var successfulSecondaryDohServiceUrl: String?
}