mirror of
https://github.com/pezkuwichain/pezkuwi-wallet-android.git
synced 2026-11-04 01:41:12 +00:00
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:
1 parent
5c66cd7f1d
commit
524cd81493
6 files changed
+201
-2
No files matched your search
@@ -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"
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ dependencies {
|
||||
api gsonConvertedDep
|
||||
implementation scalarsConverterDep
|
||||
implementation interceptorVersion
|
||||
implementation okhttpDnsOverHttpsDep
|
||||
|
||||
implementation zXingCoreDep
|
||||
implementation zXingEmbeddedDep
|
||||
|
||||
+79
@@ -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)
|
||||
|
||||
|
||||
+92
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user