feat(network): resolve names over DNS-over-HTTPS first, system DNS as fallback

A network that answers plain DNS with a wrong address decides where the app
connects. The shared OkHttp client and Coil's client now resolve through
EncryptedFirstDns: Google, Cloudflare and Quad9 over HTTPS, each reached by
IP, then the system resolver if all of them fail, so no network is worse off.

- A resolver whose request failed is skipped for ten minutes, so a network
  that blocks DoH pays the four-second timeout once. NXDOMAIN and SERVFAIL do
  not count as failures.
- IPv4 addresses are returned first: DoH answers IPv6 whether or not the
  network has it, and OkHttp tries addresses in order.
- IP literals and localhost never leave the device.

WebSocket RPC (nv-websocket) still resolves through the system.
This commit is contained in:
Satoshi Qazi Muhammed committed 2026-10-31 00:47:33 +04:00
1 parent 5c66cd7f1d
commit 524cd81493
6 files changed
+201 -2

No files matched your search

+1
View File
@@ -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"
+1
View File
@@ -115,6 +115,7 @@ dependencies {
api gsonConvertedDep
implementation scalarsConverterDep
implementation interceptorVersion
implementation okhttpDnsOverHttpsDep
implementation zXingCoreDep
implementation zXingEmbeddedDep
@@ -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<Dns>,
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<Dns> = 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<Int, Long>()
override fun lookup(hostname: String): List<InetAddress> {
// 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
}
}
@@ -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 {
@@ -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)
@@ -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<String>()
private fun resolver(name: String, answer: () -> List<InetAddress>) = object : Dns {
override fun lookup(hostname: String): List<InetAddress> {
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)
}
}