diff --git a/build.gradle b/build.gradle index 94a580fc..6d7d59cf 100644 --- a/build.gradle +++ b/build.gradle @@ -153,6 +153,7 @@ buildscript { retrofitDep = "com.squareup.retrofit2:retrofit:$retrofitVersion" interceptorVersion = "com.squareup.okhttp3:logging-interceptor:$okhttpVersion" + okhttpDnsOverHttpsDep = "com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion" gsonConvertedDep = "com.squareup.retrofit2:converter-gson:$retrofitVersion" scalarsConverterDep = "com.squareup.retrofit2:converter-scalars:$retrofitVersion" diff --git a/common/build.gradle b/common/build.gradle index 903043b4..fdae3b00 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -115,6 +115,7 @@ dependencies { api gsonConvertedDep implementation scalarsConverterDep implementation interceptorVersion + implementation okhttpDnsOverHttpsDep implementation zXingCoreDep implementation zXingEmbeddedDep diff --git a/common/src/main/java/io/novafoundation/nova/common/data/network/dns/EncryptedFirstDns.kt b/common/src/main/java/io/novafoundation/nova/common/data/network/dns/EncryptedFirstDns.kt new file mode 100644 index 00000000..df4b1722 --- /dev/null +++ b/common/src/main/java/io/novafoundation/nova/common/data/network/dns/EncryptedFirstDns.kt @@ -0,0 +1,79 @@ +package io.novafoundation.nova.common.data.network.dns + +import okhttp3.Dns +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.dnsoverhttps.DnsOverHttps +import java.net.Inet6Address +import java.net.InetAddress +import java.net.UnknownHostException +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit + +/** + * Resolves names over DNS-over-HTTPS first, so a network that answers plain DNS with a wrong address does not + * decide where the app connects; if every encrypted resolver fails, the system resolver answers, so no network + * is worse off than before. A resolver that failed is skipped for [resolverDownMillis], so a network that blocks + * DoH pays its timeout once, not on every lookup. + */ +class EncryptedFirstDns( + private val resolvers: List, + private val fallback: Dns = Dns.SYSTEM, + private val resolverDownMillis: Long = TimeUnit.MINUTES.toMillis(10), + private val clock: () -> Long = System::currentTimeMillis, +) : Dns { + + companion object { + + /** Google, Cloudflare and Quad9, each reached by IP so resolving the resolver needs no plain DNS. */ + fun publicResolvers(bootstrapClient: OkHttpClient): List = listOf( + doh(bootstrapClient, "https://dns.google/dns-query", "8.8.8.8", "8.8.4.4", "2001:4860:4860::8888"), + doh(bootstrapClient, "https://cloudflare-dns.com/dns-query", "1.1.1.1", "1.0.0.1", "2606:4700:4700::1111"), + doh(bootstrapClient, "https://dns.quad9.net/dns-query", "9.9.9.9", "149.112.112.112", "2620:fe::fe"), + ) + + private fun doh(client: OkHttpClient, url: String, vararg bootstrap: String): Dns = DnsOverHttps.Builder() + .client(client) + .url(url.toHttpUrl()) + .bootstrapDnsHosts(bootstrap.map { InetAddress.getByName(it) }) + .includeIPv6(true) + .build() + + private val IP_LITERAL = Regex("^[0-9.]+$|:") + } + + private val downUntil = ConcurrentHashMap() + + override fun lookup(hostname: String): List { + // Addresses and local names never leave the device. + if (IP_LITERAL.containsMatchIn(hostname) || hostname == "localhost") return fallback.lookup(hostname) + + resolvers.forEachIndexed { index, resolver -> + if (isDown(index)) return@forEachIndexed + + try { + val addresses = resolver.lookup(hostname) + // DoH answers IPv6 whether or not this network has it; the system resolver would not. OkHttp tries + // addresses in order, so IPv4 goes first and a broken IPv6 route cannot stall every connection. + if (addresses.isNotEmpty()) return addresses.sortedBy { it is Inet6Address } + } catch (e: UnknownHostException) { + // DnsOverHttps reports an unreachable resolver with the I/O failure as the cause; NXDOMAIN and + // SERVFAIL come without one and say nothing about the resolver. + if (e.cause != null) markDown(index) + } + } + + return fallback.lookup(hostname) + } + + private fun isDown(index: Int): Boolean { + val until = downUntil[index] ?: return false + if (clock() < until) return true + downUntil.remove(index, until) + return false + } + + private fun markDown(index: Int) { + downUntil[index] = clock() + resolverDownMillis + } +} diff --git a/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt index ba192d63..077aed93 100644 --- a/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt +++ b/common/src/main/java/io/novafoundation/nova/common/di/modules/CommonModule.kt @@ -25,6 +25,7 @@ import io.novafoundation.nova.common.data.memory.ComputationalCache import io.novafoundation.nova.common.data.memory.RealComputationalCache import io.novafoundation.nova.common.data.network.NetworkApiCreator import io.novafoundation.nova.common.data.network.coingecko.CoinGeckoLinkParser +import io.novafoundation.nova.common.data.network.dns.EncryptedFirstDns import io.novafoundation.nova.common.data.network.mirror.ConfigMirrorInterceptor import io.novafoundation.nova.common.data.providers.deviceid.AndroidDeviceIdProvider import io.novafoundation.nova.common.data.providers.deviceid.DeviceIdProvider @@ -146,12 +147,17 @@ class CommonModule { @Provides @ApplicationScope - fun imageLoader(context: Context, configMirrorInterceptor: ConfigMirrorInterceptor) = ImageLoader.Builder(context) + fun imageLoader( + context: Context, + configMirrorInterceptor: ConfigMirrorInterceptor, + encryptedFirstDns: EncryptedFirstDns + ) = ImageLoader.Builder(context) // Token icons live on raw.githubusercontent.com too; Coil keeps its own disk cache. .okHttpClient { OkHttpClient.Builder() .cache(CoilUtils.createDefaultCache(context)) .addInterceptor(configMirrorInterceptor) + .dns(encryptedFirstDns) .build() } .componentRegistry { diff --git a/common/src/main/java/io/novafoundation/nova/common/di/modules/NetworkModule.kt b/common/src/main/java/io/novafoundation/nova/common/di/modules/NetworkModule.kt index 9f0e6f8f..312f303f 100644 --- a/common/src/main/java/io/novafoundation/nova/common/di/modules/NetworkModule.kt +++ b/common/src/main/java/io/novafoundation/nova/common/di/modules/NetworkModule.kt @@ -11,6 +11,7 @@ import io.novafoundation.nova.common.data.network.AppLinksProvider import io.novafoundation.nova.common.data.network.HttpExceptionHandler import io.novafoundation.nova.common.data.network.NetworkApiCreator import io.novafoundation.nova.common.data.network.TimeHeaderInterceptor +import io.novafoundation.nova.common.data.network.dns.EncryptedFirstDns import io.novafoundation.nova.common.data.network.mirror.ConfigMirrorInterceptor import io.novafoundation.nova.common.data.network.rpc.SocketSingleRequestExecutor import io.novafoundation.nova.common.di.scope.ApplicationScope @@ -36,6 +37,9 @@ import java.util.concurrent.TimeUnit private const val HTTP_CACHE = "http_cache" private const val CACHE_SIZE = 50L * 1024L * 1024L // 50 MiB private const val TIMEOUT_SECONDS = 20L +private const val DOH_TIMEOUT_SECONDS = 4L +private const val DOH_CACHE = "doh_cache" +private const val DOH_CACHE_SIZE = 1L * 1024L * 1024L // 1 MiB private const val SOCKET_CONNECTION_TIMEOUT = 5_000 @@ -79,11 +83,26 @@ class NetworkModule { @ApplicationScope fun provideConfigMirrorInterceptor() = ConfigMirrorInterceptor() + @Provides + @ApplicationScope + fun provideEncryptedFirstDns(context: Context): EncryptedFirstDns { + // Only carries DNS queries to the resolvers, which it reaches by IP; short timeouts so a network that + // blocks DoH falls back to the system resolver quickly. + val resolverClient = OkHttpClient.Builder() + .connectTimeout(DOH_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(DOH_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .cache(Cache(File(context.cacheDir, DOH_CACHE), DOH_CACHE_SIZE)) + .build() + + return EncryptedFirstDns(EncryptedFirstDns.publicResolvers(resolverClient)) + } + @Provides @ApplicationScope fun provideOkHttpClient( context: Context, - configMirrorInterceptor: ConfigMirrorInterceptor + configMirrorInterceptor: ConfigMirrorInterceptor, + encryptedFirstDns: EncryptedFirstDns ): OkHttpClient { val builder = OkHttpClient.Builder() .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) @@ -91,6 +110,7 @@ class NetworkModule { .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) .cache(Cache(File(context.cacheDir, HTTP_CACHE), CACHE_SIZE)) .retryOnConnectionFailure(true) + .dns(encryptedFirstDns) .addInterceptor(TimeHeaderInterceptor()) .addInterceptor(configMirrorInterceptor) diff --git a/common/src/test/java/io/novafoundation/nova/common/data/network/dns/EncryptedFirstDnsTest.kt b/common/src/test/java/io/novafoundation/nova/common/data/network/dns/EncryptedFirstDnsTest.kt new file mode 100644 index 00000000..4c2ab7a7 --- /dev/null +++ b/common/src/test/java/io/novafoundation/nova/common/data/network/dns/EncryptedFirstDnsTest.kt @@ -0,0 +1,92 @@ +package io.novafoundation.nova.common.data.network.dns + +import okhttp3.Dns +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.IOException +import java.net.InetAddress +import java.net.UnknownHostException + +class EncryptedFirstDnsTest { + + private val honest = InetAddress.getByName("203.0.113.7") + private val poisoned = InetAddress.getByName("192.0.2.66") + + private var now = 0L + private val calls = mutableListOf() + + private fun resolver(name: String, answer: () -> List) = object : Dns { + override fun lookup(hostname: String): List { + calls += "$name:$hostname" + return answer() + } + } + + private val system = resolver("system") { listOf(poisoned) } + + private fun unreachable(): Nothing = throw UnknownHostException("h").apply { initCause(IOException("connect timed out")) } + + @Test + fun `the encrypted answer wins over the system resolver`() { + val dns = EncryptedFirstDns(listOf(resolver("doh1") { listOf(honest) }), system) + + assertEquals(listOf(honest), dns.lookup("raw.githubusercontent.com")) + assertEquals(listOf("doh1:raw.githubusercontent.com"), calls) + } + + @Test + fun `IPv4 addresses come first`() { + val v6 = InetAddress.getByName("2001:db8::1") + val dns = EncryptedFirstDns(listOf(resolver("doh1") { listOf(v6, honest) }), system) + + assertEquals(listOf(honest, v6), dns.lookup("a.example")) + } + + @Test + fun `an unreachable resolver hands over to the next`() { + val dns = EncryptedFirstDns(listOf(resolver("doh1") { unreachable() }, resolver("doh2") { listOf(honest) }), system) + + assertEquals(listOf(honest), dns.lookup("a.example")) + } + + @Test + fun `when every encrypted resolver fails the system resolver answers`() { + val dns = EncryptedFirstDns(listOf(resolver("doh1") { unreachable() }), system) + + assertEquals(listOf(poisoned), dns.lookup("a.example")) + } + + @Test + fun `an unreachable resolver is not waited on again inside its window`() { + val dns = EncryptedFirstDns(listOf(resolver("doh1") { unreachable() }), system, resolverDownMillis = 1_000, clock = { now }) + dns.lookup("a.example") + calls.clear() + + dns.lookup("b.example") + assertEquals(listOf("system:b.example"), calls) + + now += 1_001 + calls.clear() + dns.lookup("c.example") + assertEquals(listOf("doh1:c.example", "system:c.example"), calls) + } + + @Test + fun `a name that does not exist does not take the resolver out`() { + val dns = EncryptedFirstDns(listOf(resolver("doh1") { throw UnknownHostException("x: NXDOMAIN") }), system, clock = { now }) + dns.lookup("missing.example") + calls.clear() + + dns.lookup("b.example") + assertEquals(listOf("doh1:b.example", "system:b.example"), calls) + } + + @Test + fun `addresses and localhost never leave the device`() { + val dns = EncryptedFirstDns(listOf(resolver("doh1") { listOf(honest) }), resolver("system") { emptyList() }) + + listOf("10.0.2.2", "::1", "localhost").forEach { dns.lookup(it) } + + assertEquals(listOf("system:10.0.2.2", "system:::1", "system:localhost"), calls) + } +}