diff --git a/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt index 245f9d99e156..4588877e2165 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/AndroidNetworkPinning.kt @@ -17,12 +17,9 @@ package okhttp.android.test import android.annotation.SuppressLint import android.net.Network -import android.os.Build -import java.net.InetAddress -import okhttp3.Dns import okhttp3.Interceptor import okhttp3.Response -import okhttp3.android.EchAwareDns +import okhttp3.android.AndroidDns /** * Interceptor that supports Network Pinning on Android via Request tags. @@ -43,34 +40,11 @@ class AndroidNetworkPinning : Interceptor { if (pinnedNetwork != null) { chain .withSocketFactory(pinnedNetwork.socketFactory) - .withDns(dnsForNetwork(pinnedNetwork)) + .withDns(AndroidDns(network = pinnedNetwork)) } else { chain } return effectiveChain.proceed(request) } - - /** - * ECH needs the `HTTPS` record, which is only reachable through `DnsResolver.rawQuery()` and only - * consulted by the platform from API 37. Below that there's nothing to gain from the extra query, - * so [AndroidNetworkDns] does the plain address lookup. - */ - private fun dnsForNetwork(network: Network): Dns = - when { - Build.VERSION.SDK_INT >= 37 -> EchAwareDns.forNetwork(network) - else -> AndroidNetworkDns(network) - } -} - -/** - * A [Dns] scoped to [network], used below API 37 where there's no ECH to resolve for. - * - * [Network.getAllByName] is the whole implementation: it resolves on that network and nothing else, - * with no service metadata. - */ -class AndroidNetworkDns( - private val network: Network, -) : Dns { - override fun lookup(hostname: String): List = network.getAllByName(hostname).toList() } diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt index 4b0a727f6de0..c3216f19d518 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -25,10 +25,11 @@ import assertk.assertions.doesNotContain import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isTrue +import okhttp3.Dns import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.android.EchAwareDns +import okhttp3.android.AndroidDns import okhttp3.dnsoverhttps.DnsOverHttps import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.BeforeEach @@ -37,9 +38,9 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.fail /** - * Confirms Encrypted Client Hello (ECH) end to end, with [EchAwareDns]. + * Confirms Encrypted Client Hello (ECH) end to end. * - * Test with both [okhttp3.android.AndroidDns] and [DnsOverHttps]. + * Test with both [AndroidDns] and [DnsOverHttps]. * * See `res/xml/network_security_config.xml` for overrides. */ @@ -47,19 +48,20 @@ import org.junit.jupiter.api.fail @Tag("Remote") @Burst class EchTest( - private val useDoh: Boolean = false, + private val dnsApi: DnsApi = DnsApi.Doh, ) { private lateinit var client: OkHttpClient @BeforeEach fun setUp() { - // EchAwareDns reads API 37 NetworkSecurityPolicy.getDomainEncryptionMode(). + // ECH requires API 37. assumeTrue(Build.VERSION.SDK_INT >= 37) + val bootstrapClient = OkHttpClient() + val dns = dnsApi.create(bootstrapClient) client = - OkHttpClient - .Builder() - .dns(dns()) + bootstrapClient.newBuilder() + .dns(dns) .build() } @@ -131,36 +133,25 @@ class EchTest( assertThat(client.get("https://crypto.cloudflare.com/cdn-cgi/trace")).contains("sni=plaintext") } - /** - * [EchAwareDns] over the platform resolver, or over DoH when [useDoh]. Both arms use the same - * source: the ECH one carries service metadata, the other doesn't. - */ - private fun dns(): EchAwareDns = - when { - useDoh -> { - val bootstrapClient = OkHttpClient() - EchAwareDns( - echDns = dnsOverHttps(bootstrapClient, includeServiceMetadata = true), - addressOnlyDns = dnsOverHttps(bootstrapClient, includeServiceMetadata = false), - ) - } - else -> EchAwareDns() - } - - /** Addressed by IP, so resolving the resolver doesn't need a resolver. */ - private fun dnsOverHttps( - bootstrapClient: OkHttpClient, - includeServiceMetadata: Boolean, - ): DnsOverHttps = - DnsOverHttps - .Builder() - .client(bootstrapClient) - .url("https://1.1.1.1/dns-query".toHttpUrl()) - .includeServiceMetadata(includeServiceMetadata) - .build() - - private fun OkHttpClient.get(url: String): String = - newCall(Request.Builder().url(url).build()).execute().use { response -> + fun OkHttpClient.get(url: String): String = + newCall(Request(url.toHttpUrl())).execute().use { response -> response.body.string() } + + enum class DnsApi { + Android { + override fun create(client: OkHttpClient) = AndroidDns() + }, + + Doh { + /** DNS server is addressed by IP, so resolving the resolver doesn't need a resolver. */ + override fun create(client: OkHttpClient) = DnsOverHttps.Builder() + .client(client) + .url("https://1.1.1.1/dns-query".toHttpUrl()) + .includeServiceMetadata(true) + .build() + }; + + abstract fun create(client: OkHttpClient): Dns + } } diff --git a/android-test/src/main/res/xml/network_security_config.xml b/android-test/src/main/res/xml/network_security_config.xml index 319ff322509a..5c7ff1bcf2ce 100644 --- a/android-test/src/main/res/xml/network_security_config.xml +++ b/android-test/src/main/res/xml/network_security_config.xml @@ -5,7 +5,7 @@ localhost - + cloudflare-ech.com tls-ech.dev diff --git a/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DnsOverHttpsTest.kt b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DnsOverHttpsTest.kt index 3855a1802720..312f2d45cd5e 100644 --- a/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DnsOverHttpsTest.kt +++ b/okhttp-dnsoverhttps/src/test/java/okhttp3/dnsoverhttps/DnsOverHttpsTest.kt @@ -47,7 +47,7 @@ import okhttp3.Dns import okhttp3.DnsCache import okhttp3.EventRecorder import okhttp3.FakeDns -import okhttp3.FakeDns.Request.DnsOverHttpsRequest +import okhttp3.FakeDns.Request.DnsRequest import okhttp3.Headers.Companion.headersOf import okhttp3.Interceptor import okhttp3.OkHttpClient @@ -151,8 +151,8 @@ class DnsOverHttpsTest( server["lysine.dev"] = listOf(InetAddress.getByName("10.20.30.40")) val result = dns.invoke(entryPoint, "lysine.dev") assertThat(result).isEqualTo(listOf(address("10.20.30.40"))) - val (httpsRequest, dnsRequest) = server.takeRequest() as DnsOverHttpsRequest - assertThat(httpsRequest.method).isEqualTo("GET") + val (dnsRequest, httpsRequest) = server.takeRequest() as DnsRequest + assertThat(httpsRequest!!.method).isEqualTo("GET") assertThat(dnsRequest) .isEqualTo(queryRequest("lysine.dev", TYPE_A)) } @@ -166,8 +166,8 @@ class DnsOverHttpsTest( server["lysine.dev"] = listOf(InetAddress.getByName("10.20.30.40")) val result0 = dns.invoke(entryPoint, "lysine.dev") assertThat(result0).isEqualTo(listOf(address("10.20.30.40"))) - val (httpsRequest, dnsRequest) = server.takeRequest() as DnsOverHttpsRequest - assertThat(httpsRequest.method).isEqualTo("GET") + val (dnsRequest, httpsRequest) = server.takeRequest() as DnsRequest + assertThat(httpsRequest!!.method).isEqualTo("GET") assertThat(dnsRequest) .isEqualTo(queryRequest("lysine.dev", TYPE_A)) @@ -193,12 +193,12 @@ class DnsOverHttpsTest( address("10.20.30.40"), ) - val (httpsRequest1, dnsRequest1) = server.takeRequest() as DnsOverHttpsRequest - assertThat(httpsRequest1.method).isEqualTo("GET") + val (dnsRequest1, httpsRequest1) = server.takeRequest() as DnsRequest + assertThat(httpsRequest1!!.method).isEqualTo("GET") assertThat(dnsRequest1).isEqualTo(queryRequest("lysine.dev", TYPE_AAAA)) - val (httpsRequest2, dnsRequest2) = server.takeRequest() as DnsOverHttpsRequest - assertThat(httpsRequest2.method).isEqualTo("GET") + val (dnsRequest2, httpsRequest2) = server.takeRequest() as DnsRequest + assertThat(httpsRequest2!!.method).isEqualTo("GET") assertThat(dnsRequest2).isEqualTo(queryRequest("lysine.dev", TYPE_A)) } @@ -218,10 +218,10 @@ class DnsOverHttpsTest( address("10.20.30.40"), ) - val (_, dnsRequest1) = server.takeRequest() as DnsOverHttpsRequest + val (dnsRequest1, _) = server.takeRequest() as DnsRequest assertThat(dnsRequest1).isEqualTo(queryRequest("lysine.dev", TYPE_AAAA)) - val (_, dnsRequest2) = server.takeRequest() as DnsOverHttpsRequest + val (dnsRequest2, _) = server.takeRequest() as DnsRequest assertThat(dnsRequest2).isEqualTo(queryRequest("lysine.dev", TYPE_A)) assertThat(server.pollRequest()).isNull() @@ -232,8 +232,8 @@ class DnsOverHttpsTest( assertFailsWith { dns(entryPoint, "lysine.dev") } - val (httpsRequest, dnsRequest) = server.takeRequest() as DnsOverHttpsRequest - assertThat(httpsRequest.method).isEqualTo("GET") + val (dnsRequest, httpsRequest) = server.takeRequest() as DnsRequest + assertThat(httpsRequest!!.method).isEqualTo("GET") assertThat(dnsRequest) .isEqualTo(queryRequest("lysine.dev", TYPE_A)) } @@ -335,8 +335,8 @@ class DnsOverHttpsTest( val result1 = cachedDns(entryPoint, "lysine.dev") assertThat(result1).containsExactly(address("10.20.30.40")) - val (httpsRequest1, dnsRequest1) = server.takeRequest() as DnsOverHttpsRequest - assertThat(httpsRequest1.method).isEqualTo("GET") + val (dnsRequest1, httpsRequest1) = server.takeRequest() as DnsRequest + assertThat(httpsRequest1!!.method).isEqualTo("GET") assertThat(dnsRequest1) .isEqualTo(queryRequest("lysine.dev", TYPE_A)) @@ -350,8 +350,8 @@ class DnsOverHttpsTest( val result3 = cachedDns(entryPoint, "alternate.lysine.dev") assertThat(result3).containsExactly(address("55.66.77.88")) - val (httpsRequest2, dnsRequest2) = server.takeRequest() as DnsOverHttpsRequest - assertThat(httpsRequest2.method).isEqualTo("GET") + val (dnsRequest2, httpsRequest2) = server.takeRequest() as DnsRequest + assertThat(httpsRequest2!!.method).isEqualTo("GET") assertThat(dnsRequest2) .isEqualTo(queryRequest("alternate.lysine.dev", TYPE_A)) @@ -378,8 +378,8 @@ class DnsOverHttpsTest( val result1 = cachedDns(entryPoint, "lysine.dev") assertThat(result1).containsExactly(address("10.20.30.40")) - val (httpsRequest1, _) = server.takeRequest() as DnsOverHttpsRequest - assertThat(httpsRequest1.method).isEqualTo("POST") + val (_, httpsRequest1) = server.takeRequest() as DnsRequest + assertThat(httpsRequest1!!.method).isEqualTo("POST") assertThat(httpsRequest1.url.encodedQuery) .isEqualTo("ct") @@ -393,8 +393,8 @@ class DnsOverHttpsTest( val result3 = cachedDns(entryPoint, "alternate.lysine.dev") assertThat(result3).containsExactly(address("55.66.77.88")) - val (httpsRequest2, _) = server.takeRequest() as DnsOverHttpsRequest - assertThat(httpsRequest2.method).isEqualTo("POST") + val (_, httpsRequest2) = server.takeRequest() as DnsRequest + assertThat(httpsRequest2!!.method).isEqualTo("POST") assertThat(httpsRequest2.url.encodedQuery) .isEqualTo("ct") @@ -418,8 +418,8 @@ class DnsOverHttpsTest( server["lysine.dev"] = listOf(InetAddress.getByName("10.20.30.40")) val result1 = cachedDns(entryPoint, "lysine.dev") assertThat(result1).containsExactly(address("10.20.30.40")) - val (httpsRequest1, dnsRequest1) = server.takeRequest() as DnsOverHttpsRequest - assertThat(httpsRequest1.method).isEqualTo("GET") + val (dnsRequest1, httpsRequest1) = server.takeRequest() as DnsRequest + assertThat(httpsRequest1!!.method).isEqualTo("GET") assertThat(dnsRequest1) .isEqualTo(queryRequest("lysine.dev", TYPE_A)) @@ -427,8 +427,8 @@ class DnsOverHttpsTest( val result2 = cachedDns(entryPoint, "lysine.dev") assertThat(result2).isEqualTo(listOf(address("10.20.30.40"))) - val (httpsRequest2, dnsRequest2) = server.takeRequest() as DnsOverHttpsRequest - assertThat(httpsRequest2.method).isEqualTo("GET") + val (dnsRequest2, httpsRequest2) = server.takeRequest() as DnsRequest + assertThat(httpsRequest2!!.method).isEqualTo("GET") assertThat(dnsRequest2) .isEqualTo(queryRequest("lysine.dev", TYPE_A)) diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt index 78fff4e85481..6bcb1e137246 100644 --- a/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt @@ -73,7 +73,7 @@ class FakeDns( } val dnsRequest = DnsMessageReader(encodedDnsQuery).read() - requests.put(Request.DnsOverHttpsRequest(request, dnsRequest)) + requests.put(Request.DnsRequest(dnsRequest, request)) val dnsResponse = invoke(dnsRequest) @@ -183,7 +183,12 @@ class FakeDns( } } - fun invoke(request: DnsMessage): DnsMessage { + fun query(request: DnsMessage): DnsMessage { + requests.put(Request.DnsRequest(request)) + return invoke(request) + } + + private fun invoke(request: DnsMessage): DnsMessage { val answers = buildList { for (question in request.questions) { @@ -314,9 +319,9 @@ class FakeDns( sealed interface Request { val hostname: String - data class DnsOverHttpsRequest( - val httpRequest: RecordedRequest, + data class DnsRequest( val dnsRequest: DnsMessage, + val httpRequest: RecordedRequest? = null, ) : Request { override val hostname: String get() = dnsRequest.questions.single().name diff --git a/okhttp/api/android/okhttp.api b/okhttp/api/android/okhttp.api index c7f1534c713f..26f1219816b8 100644 --- a/okhttp/api/android/okhttp.api +++ b/okhttp/api/android/okhttp.api @@ -1382,23 +1382,9 @@ public abstract class okhttp3/WebSocketListener { } public final class okhttp3/android/AndroidDns : okhttp3/Dns { - public fun ()V - public fun (Landroid/net/DnsResolver;Landroid/net/Network;Lokhttp3/DnsCache;ZLjava/util/concurrent/Executor;)V - public synthetic fun (Landroid/net/DnsResolver;Landroid/net/Network;Lokhttp3/DnsCache;ZLjava/util/concurrent/Executor;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public fun lookup (Ljava/lang/String;)Ljava/util/List; - public fun newCall (Lokhttp3/Dns$Request;)Lokhttp3/Dns$Call; -} - -public final class okhttp3/android/EchAwareDns : okhttp3/Dns { - public static final field Companion Lokhttp3/android/EchAwareDns$Companion; - public fun ()V - public fun (Lokhttp3/Dns;Lokhttp3/Dns;Landroid/security/NetworkSecurityPolicy;)V - public synthetic fun (Lokhttp3/Dns;Lokhttp3/Dns;Landroid/security/NetworkSecurityPolicy;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Landroid/net/DnsResolver;Landroid/net/Network;Lokhttp3/DnsCache;Z)V + public synthetic fun (Landroid/net/DnsResolver;Landroid/net/Network;Lokhttp3/DnsCache;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun lookup (Ljava/lang/String;)Ljava/util/List; public fun newCall (Lokhttp3/Dns$Request;)Lokhttp3/Dns$Call; } -public final class okhttp3/android/EchAwareDns$Companion { - public final fun forNetwork (Landroid/net/Network;)Lokhttp3/android/EchAwareDns; -} - diff --git a/okhttp/src/androidHostTest/kotlin/okhttp3/android/AndroidDnsRobolectricTest.kt b/okhttp/src/androidHostTest/kotlin/okhttp3/android/AndroidDnsRobolectricTest.kt new file mode 100644 index 000000000000..231c341b95e8 --- /dev/null +++ b/okhttp/src/androidHostTest/kotlin/okhttp3/android/AndroidDnsRobolectricTest.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(OkHttpInternalApi::class) + +package okhttp3.android + +import android.annotation.SuppressLint +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_DISABLED +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_UNKNOWN +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.hasSize +import assertk.assertions.isEmpty +import java.net.InetAddress +import okhttp3.Dns +import okhttp3.DnsCache +import okhttp3.FakeDns +import okhttp3.internal.OkHttpInternalApi +import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.concurrent.TaskRunner +import okhttp3.internal.dns.ResourceRecord +import okhttp3.internal.dns.execute +import okio.ByteString +import okio.ByteString.Companion.encodeUtf8 +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@SuppressLint("NewApi") +@SuppressSignatureCheck +@RunWith(RobolectricTestRunner::class) +@Config( + sdk = [37], + shadows = [ + ShadowDnsResolver::class, + ShadowNetwork::class, + ShadowNetworkSecurityPolicy::class, + ], +) +class AndroidDnsRobolectricTest { + private val echConfigList = "ech config list".encodeUtf8() + + private val dnsServer = + FakeDns().apply { + setRecords( + hostname = "publicobject.com", + address = InetAddress.getByName("10.20.30.40"), + echConfigList = echConfigList, + ) + } + + private val domainEncryptionModes = + mutableMapOf( + "publicobject.com" to DOMAIN_ENCRYPTION_MODE_ENABLED, + ) + + private val dnsCache = DnsCache() + + private val androidDns = + AndroidDns( + dnsResolver = ShadowDnsResolver.create(dnsServer), + network = ShadowNetwork.create(dnsServer), + dnsCache = dnsCache, + includeServiceMetadata = true, + executor = { it.run() }, + taskRunner = TaskRunner.INSTANCE, + lazyNetworkSecurityPolicy = + lazy { + ShadowNetworkSecurityPolicy.create(domainEncryptionModes) + }, + ) + + @Test + fun happyPath() { + val records = androidDns.recordsFor("publicobject.com") + assertThat(records.addresses()).containsExactly(InetAddress.getByName("10.20.30.40")) + assertThat(records.echConfigLists()).containsExactly(echConfigList) + assertThat(dnsServer.takeAllRequests()).hasSize(2) + } + + @Test + fun disabledPolicySkipsHttpsMetadata() { + domainEncryptionModes["publicobject.com"] = DOMAIN_ENCRYPTION_MODE_DISABLED + val records = androidDns.recordsFor("publicobject.com") + assertThat(records.addresses()).containsExactly(InetAddress.getByName("10.20.30.40")) + assertThat(records.echConfigLists()).isEmpty() + } + + @Test + fun unknownPolicySkipsHttpsMetadata() { + domainEncryptionModes["publicobject.com"] = DOMAIN_ENCRYPTION_MODE_UNKNOWN + val records = androidDns.recordsFor("publicobject.com") + assertThat(records.addresses()).containsExactly(InetAddress.getByName("10.20.30.40")) + assertThat(records.echConfigLists()).isEmpty() + } + + @Test + fun opportunisticPolicyIncludesHttpsMetadata() { + domainEncryptionModes["publicobject.com"] = DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC + val records = androidDns.recordsFor("publicobject.com") + assertThat(records.addresses()).containsExactly(InetAddress.getByName("10.20.30.40")) + assertThat(records.echConfigLists()).containsExactly(echConfigList) + } + + @Test + fun policyIsPerHost() { + val deniedEchConfigList = "denied ech config list".encodeUtf8() + dnsServer.setRecords( + hostname = "denied.example.com", + address = InetAddress.getByName("1:2::3:4"), + echConfigList = deniedEchConfigList, + ) + domainEncryptionModes["denied.example.com"] = DOMAIN_ENCRYPTION_MODE_DISABLED + + val recordsA = androidDns.recordsFor("publicobject.com") + assertThat(recordsA.addresses()).containsExactly(InetAddress.getByName("10.20.30.40")) + assertThat(recordsA.echConfigLists()).containsExactly(echConfigList) + + val recordsB = androidDns.recordsFor("denied.example.com") + assertThat(recordsB.addresses()).containsExactly(InetAddress.getByName("1:2::3:4")) + assertThat(recordsB.echConfigLists()).isEmpty() + } + + @Test + fun lookupDoesNotRequestServiceMetadata() { + val addresses = androidDns.lookup("publicobject.com") + assertThat(addresses).containsExactly(InetAddress.getByName("10.20.30.40")) + assertThat(dnsServer.takeAllRequests()).hasSize(1) + } + + private fun Dns.recordsFor(hostname: String): List = newCall(Dns.Request(hostname)).execute() + + private fun List.addresses() = filterIsInstance().map { it.address } + + private fun List.echConfigLists() = filterIsInstance().mapNotNull { it.echConfigList } +} + +/** Serves [address] for [hostname], plus an `HTTPS` record when [echConfigList] is non-null. */ +private fun FakeDns.setRecords( + hostname: String, + address: InetAddress, + echConfigList: ByteString? = null, +) { + this[hostname] = + listOfNotNull( + echConfigList?.let { + ResourceRecord.Https( + name = hostname, + timeToLive = 5, + echConfigList = it, + ) + }, + ResourceRecord.IpAddress( + name = hostname, + timeToLive = 5, + address = address, + ), + ) +} diff --git a/okhttp/src/androidHostTest/kotlin/okhttp3/android/EchAwareDnsTest.kt b/okhttp/src/androidHostTest/kotlin/okhttp3/android/EchAwareDnsTest.kt deleted file mode 100644 index 36b338b438a6..000000000000 --- a/okhttp/src/androidHostTest/kotlin/okhttp3/android/EchAwareDnsTest.kt +++ /dev/null @@ -1,199 +0,0 @@ -/* - * Copyright (c) 2026 OkHttp Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -@file:OptIn(OkHttpInternalApi::class) - -package okhttp3.android - -import android.annotation.SuppressLint -import android.net.Network -import android.security.NetworkSecurityPolicy -import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_DISABLED -import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED -import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC -import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_UNKNOWN -import assertk.assertThat -import assertk.assertions.containsExactly -import assertk.assertions.isEmpty -import assertk.assertions.isEqualTo -import assertk.assertions.isFalse -import assertk.assertions.isTrue -import java.net.InetAddress -import okhttp3.Dns -import okhttp3.FakeDns -import okhttp3.android.ShadowNetworkSecurityPolicy.Companion.newNetworkSecurityPolicy -import okhttp3.internal.OkHttpInternalApi -import okhttp3.internal.SuppressSignatureCheck -import okhttp3.internal.dns.ResourceRecord -import okhttp3.internal.dns.execute -import okio.ByteString -import okio.ByteString.Companion.encodeUtf8 -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.shadows.ShadowNetwork - -@SuppressLint("NewApi") -@SuppressSignatureCheck -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [37], shadows = [ShadowNetworkSecurityPolicy::class]) -class EchAwareDnsTest { - private val echAddress = InetAddress.getByAddress("publicobject.com", byteArrayOf(1, 1, 1, 1)) - private val addressOnlyAddress = InetAddress.getByAddress("publicobject.com", byteArrayOf(2, 2, 2, 2)) - private val echConfigList = "ech config list".encodeUtf8() - - /** Carries service metadata, as [AndroidDns] does when it queries the `HTTPS` record. */ - private val echDns = - FakeDns().apply { - setRecords("publicobject.com", echAddress, echConfigList) - } - - private val addressOnlyDns = - FakeDns().apply { - setRecords("publicobject.com", addressOnlyAddress) - } - - @Test - fun encryptionEnabledUsesEchDns() { - val records = recordsFor(DOMAIN_ENCRYPTION_MODE_ENABLED) - - assertThat(records.addresses()).containsExactly(echAddress) - assertThat(records.echConfigLists()).containsExactly(echConfigList) - } - - @Test - fun encryptionOpportunisticUsesEchDns() { - val records = recordsFor(DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC) - - assertThat(records.addresses()).containsExactly(echAddress) - assertThat(records.echConfigLists()).containsExactly(echConfigList) - } - - @Test - fun encryptionDisabledUsesAddressOnlyDns() { - val records = recordsFor(DOMAIN_ENCRYPTION_MODE_DISABLED) - - assertThat(records.addresses()).containsExactly(addressOnlyAddress) - assertThat(records.echConfigLists()).isEmpty() - } - - @Test - fun encryptionUnknownUsesAddressOnlyDns() { - val records = recordsFor(DOMAIN_ENCRYPTION_MODE_UNKNOWN) - - assertThat(records.addresses()).containsExactly(addressOnlyAddress) - assertThat(records.echConfigLists()).isEmpty() - } - - /** Hosts the policy says nothing about don't pay for an `HTTPS` query. */ - @Test - fun unconfiguredHostUsesAddressOnlyDns() { - val records = echAwareDns(newNetworkSecurityPolicy()).recordsFor("publicobject.com") - - assertThat(records.addresses()).containsExactly(addressOnlyAddress) - assertThat(records.echConfigLists()).isEmpty() - } - - /** Each host is judged on its own policy, even though both are available from [echDns]. */ - @Test - fun policyIsPerHost() { - val deniedEchAddress = InetAddress.getByAddress("denied.example.com", byteArrayOf(1, 1, 1, 2)) - val deniedEchConfigList = "denied ech config list".encodeUtf8() - val deniedAddress = InetAddress.getByAddress("denied.example.com", byteArrayOf(2, 2, 2, 2)) - echDns.setRecords("denied.example.com", deniedEchAddress, deniedEchConfigList) - addressOnlyDns.setRecords("denied.example.com", deniedAddress) - - val dns = - echAwareDns( - newNetworkSecurityPolicy( - "publicobject.com" to DOMAIN_ENCRYPTION_MODE_ENABLED, - "denied.example.com" to DOMAIN_ENCRYPTION_MODE_DISABLED, - ), - ) - - val allowedRecords = dns.recordsFor("publicobject.com") - assertThat(allowedRecords.addresses()).containsExactly(echAddress) - assertThat(allowedRecords.echConfigLists()).containsExactly(echConfigList) - - val deniedRecords = dns.recordsFor("denied.example.com") - assertThat(deniedRecords.addresses()).containsExactly(deniedAddress) - assertThat(deniedRecords.echConfigLists()).isEmpty() - } - - /** [Dns.lookup] can't carry HTTPS records, so it never pays for the `HTTPS` query. */ - @Test - fun lookupAlwaysUsesAddressOnlyDns() { - val dns = echAwareDns(newNetworkSecurityPolicy("publicobject.com" to DOMAIN_ENCRYPTION_MODE_ENABLED)) - - assertThat(dns.lookup("publicobject.com")).containsExactly(addressOnlyAddress) - } - - /** Both arms of [EchAwareDns.forNetwork] resolve on the network, not just the ECH one. */ - @Test - fun forNetworkScopesBothArms() { - val network = ShadowNetwork.newInstance(1234) - - val dns = EchAwareDns.forNetwork(network) - - val echDns = dns.echDns as AndroidDns - assertThat(echDns.network).isEqualTo(network) - assertThat(echDns.includeServiceMetadata).isTrue() - - val addressOnlyDns = dns.addressOnlyDns as AndroidDns - assertThat(addressOnlyDns.network).isEqualTo(network) - assertThat(addressOnlyDns.includeServiceMetadata).isFalse() - } - - private fun recordsFor(domainEncryptionMode: Int): List = - echAwareDns(newNetworkSecurityPolicy("publicobject.com" to domainEncryptionMode)) - .recordsFor("publicobject.com") - - private fun Dns.recordsFor(hostname: String): List = newCall(Dns.Request(hostname)).execute() - - private fun List.addresses() = filterIsInstance().map { it.address } - - private fun List.echConfigLists() = filterIsInstance().mapNotNull { it.echConfigList } - - private fun echAwareDns(policy: NetworkSecurityPolicy) = - EchAwareDns( - echDns = echDns, - addressOnlyDns = addressOnlyDns, - policy = policy, - ) -} - -/** Serves [address] for [hostname], plus an `HTTPS` record when [echConfigList] is non-null. */ -private fun FakeDns.setRecords( - hostname: String, - address: InetAddress, - echConfigList: ByteString? = null, -) { - this[hostname] = - listOfNotNull( - echConfigList?.let { - ResourceRecord.Https( - name = hostname, - timeToLive = 5, - echConfigList = it, - ) - }, - ResourceRecord.IpAddress( - name = hostname, - timeToLive = 5, - address = address, - ), - ) -} diff --git a/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowDnsResolver.kt b/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowDnsResolver.kt new file mode 100644 index 000000000000..cd5227e4cc80 --- /dev/null +++ b/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowDnsResolver.kt @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.android + +import android.net.DnsResolver +import android.net.Network +import android.os.CancellationSignal +import java.util.concurrent.Executor +import okhttp3.FakeDns +import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.dns.DnsMessage +import okhttp3.internal.dns.DnsMessageWriter +import okhttp3.internal.dns.Question +import okio.Buffer +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadow.api.Shadow + +@SuppressSignatureCheck +@Implements(DnsResolver::class) +class ShadowDnsResolver { + lateinit var dns: FakeDns + + @Implementation + fun rawQuery( + network: Network?, + domain: String, + nsClass: Int, + nsType: Int, + flags: Int, + executor: Executor, + cancellationSignal: CancellationSignal?, + callback: DnsResolver.Callback, + ) { + val response = + dns.query( + DnsMessage( + id = 0, + flags = flags, + questions = + listOf( + Question( + name = domain, + type = nsType, + `class` = nsClass, + ), + ), + ), + ) + + val responseBytes = + Buffer().run { + DnsMessageWriter(this).write(response) + readByteArray() + } + + executor.execute { + callback.onAnswer(responseBytes, response.responseCode) + } + } + + companion object { + fun create(dns: FakeDns): DnsResolver { + val result = Shadow.newInstanceOf(DnsResolver::class.java) + val shadow = Shadow.extract(result) + shadow.dns = dns + return result + } + } +} diff --git a/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowNetwork.kt b/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowNetwork.kt new file mode 100644 index 000000000000..5503bff57f32 --- /dev/null +++ b/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowNetwork.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp3.android + +import android.net.Network +import java.net.InetAddress +import java.net.UnknownHostException +import okhttp3.FakeDns +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadow.api.Shadow + +@Implements(Network::class) +class ShadowNetwork { + lateinit var dns: FakeDns + + @Implementation + @Throws(UnknownHostException::class) + fun getAllByName(host: String): Array = dns.lookup(host).toTypedArray() + + companion object { + fun create(dns: FakeDns): Network { + val result = Shadow.newInstanceOf(Network::class.java) + Shadow.extract(result).dns = dns + return result + } + } +} diff --git a/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowNetworkSecurityPolicy.kt b/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowNetworkSecurityPolicy.kt index 2d80157b0062..53a6588341e9 100644 --- a/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowNetworkSecurityPolicy.kt +++ b/okhttp/src/androidHostTest/kotlin/okhttp3/android/ShadowNetworkSecurityPolicy.kt @@ -17,6 +17,7 @@ package okhttp3.android import android.security.NetworkSecurityPolicy import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_UNKNOWN +import okhttp3.android.ShadowNetworkSecurityPolicy.Companion.create import okhttp3.internal.SuppressSignatureCheck import org.robolectric.annotation.Implementation import org.robolectric.annotation.Implements @@ -24,23 +25,27 @@ import org.robolectric.shadow.api.Shadow /** * Gives tests a [NetworkSecurityPolicy] with domain encryption modes of their choosing. The - * platform constructor is private, so instances come from [newNetworkSecurityPolicy] rather than + * platform constructor is private, so instances come from [create] rather than * from a subclass. */ @SuppressSignatureCheck @Implements(NetworkSecurityPolicy::class) class ShadowNetworkSecurityPolicy { - var domainEncryptionModes: Map = mapOf() + lateinit var domainEncryptionModes: Map @Implementation fun getDomainEncryptionMode(hostname: String): Int = domainEncryptionModes[hostname] ?: DOMAIN_ENCRYPTION_MODE_UNKNOWN companion object { - /** Returns a policy that reports [domainEncryptionModes], and [DOMAIN_ENCRYPTION_MODE_UNKNOWN] for other hosts. */ - fun newNetworkSecurityPolicy(vararg domainEncryptionModes: Pair): NetworkSecurityPolicy { - val policy = Shadow.newInstanceOf(NetworkSecurityPolicy::class.java) - Shadow.extract(policy).domainEncryptionModes = domainEncryptionModes.toMap() - return policy + /** + * Returns a policy that reports [domainEncryptionModes], and [DOMAIN_ENCRYPTION_MODE_UNKNOWN] + * for other hosts. + */ + fun create(domainEncryptionModes: Map): NetworkSecurityPolicy { + val result = Shadow.newInstanceOf(NetworkSecurityPolicy::class.java) + val shadow = Shadow.extract(result) + shadow.domainEncryptionModes = domainEncryptionModes + return result } } } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt index 2de449e62bc0..fb570ea5059c 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidDns.kt @@ -20,7 +20,11 @@ package okhttp3.android import android.annotation.SuppressLint import android.net.DnsResolver import android.net.Network +import android.os.Build import android.os.CancellationSignal +import android.security.NetworkSecurityPolicy +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED +import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC import androidx.annotation.RequiresApi import java.io.IOException import java.net.InetAddress @@ -51,20 +55,37 @@ import okio.Buffer */ @RequiresApi(29) @SuppressSignatureCheck -class AndroidDns( - internal val dnsResolver: DnsResolver = DnsResolver.getInstance(), - internal val network: Network? = null, - dnsCache: DnsCache = DnsCache(), - /** - * True to also query the `HTTPS` record for service metadata. Keep this on: it enables privacy - * features such as Encrypted Client Hello (ECH) for the HTTPS call. Set it to false only when - * you want to disable ECH. - */ - internal val includeServiceMetadata: Boolean = true, - // Runs inline; the executor only hands off DnsResolver's callbacks. - internal val executor: Executor = Executor { it.run() }, +class AndroidDns internal constructor( + internal val dnsResolver: DnsResolver, + internal val network: Network?, + dnsCache: DnsCache, + internal val includeServiceMetadata: Boolean, + /** Runs inline; the executor only hands off DnsResolver's callbacks. */ + internal val executor: Executor, + private val taskRunner: TaskRunner, + lazyNetworkSecurityPolicy: Lazy, ) : Dns { - private val taskRunner: TaskRunner = TaskRunner.INSTANCE + constructor( + dnsResolver: DnsResolver = DnsResolver.getInstance(), + network: Network? = null, + dnsCache: DnsCache = DnsCache(), + /** + * True to also query the `HTTPS` record for service metadata. Keep this on: it enables privacy + * features such as Encrypted Client Hello (ECH) for the HTTPS call. Set it to false only when + * you want to disable ECH. + */ + includeServiceMetadata: Boolean = true, + ) : this( + dnsResolver = dnsResolver, + network = network, + dnsCache = dnsCache, + includeServiceMetadata = includeServiceMetadata, + executor = Executor { it.run() }, + taskRunner = TaskRunner.INSTANCE, + lazyNetworkSecurityPolicy = lazy { NetworkSecurityPolicy.getInstance() }, + ) + + private val networkSecurityPolicy: NetworkSecurityPolicy by lazyNetworkSecurityPolicy /** Drives [StateMachineDnsCall] using the system resolver and [DnsResolver]. */ private val queryFactory = @@ -87,16 +108,32 @@ class AndroidDns( private fun call( request: Dns.Request, includeServiceMetadata: Boolean, - ): Dns.Call = - StateMachineDnsCall( - taskRunner = taskRunner, - request = request, - queryFactory = queryFactory, - // A single `A` query stands in for both families: the system resolver returns IPv4 and - // IPv6 addresses together, so there's no separate `AAAA` query. - includeIPv6 = false, - includeServiceMetadata = includeServiceMetadata, - ) + ) = StateMachineDnsCall( + taskRunner = taskRunner, + request = request, + queryFactory = queryFactory, + // A single `A` query stands in for both families: the system resolver returns IPv4 and + // IPv6 addresses together, so there's no separate `AAAA` query. + includeIPv6 = false, + includeServiceMetadata = includeServiceMetadata && includeServiceMetadataForRequest(request), + ) + + /** + * Returns true to fetch HTTPS DNS records. Fetching these records requires a network round trip, + * so we only fetch them if they'll be useful. + * + * The main user of these records is Encrypted Client Hello (ECH). Android's HTTPS stack only + * supports ECH on API 37+, and only if configured via [NetworkSecurityPolicy]. If both are true, + * we fetch the ECH records. + */ + private fun includeServiceMetadataForRequest(request: Dns.Request): Boolean { + if (Build.VERSION.SDK_INT < 37) return false + + return when (networkSecurityPolicy.getDomainEncryptionMode(request.hostname)) { + DOMAIN_ENCRYPTION_MODE_ENABLED, DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC -> true + else -> false + } + } /** One outstanding transport-layer query. */ private inner class AndroidQuery( diff --git a/okhttp/src/androidMain/kotlin/okhttp3/android/EchAwareDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/android/EchAwareDns.kt deleted file mode 100644 index 591f2d3d4e5d..000000000000 --- a/okhttp/src/androidMain/kotlin/okhttp3/android/EchAwareDns.kt +++ /dev/null @@ -1,91 +0,0 @@ -/* - * Copyright (c) 2026 OkHttp Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package okhttp3.android - -import android.annotation.SuppressLint -import android.net.Network -import android.os.Build -import android.security.NetworkSecurityPolicy -import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED -import android.security.NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC -import androidx.annotation.ChecksSdkIntAtLeast -import androidx.annotation.RequiresApi -import java.net.InetAddress -import okhttp3.Dns -import okhttp3.internal.SuppressSignatureCheck -import okhttp3.internal.platform.Platform.Companion.isAndroid - -/** - * ECH Aware DNS Wrapper for Android 37. - * - * Uses the [NetworkSecurityPolicy] to read the domain encryption settings for - * a given host. - * - * Usage: - * ``` - * val dns = EchAwareDns(AndroidDns()) - * val client = OkHttpClient.Builder().dns(dns).build() - * ``` - * - * @param echDns resolves hosts that the policy permits ECH for. It should carry service metadata, - * such as [AndroidDns] with `includeServiceMetadata` enabled. - * @param addressOnlyDns resolves hosts that the policy denies ECH for. - * The default, [Dns.SYSTEM], resolves on the default network. - * @param policy the source of each host's domain encryption mode. - */ -@SuppressLint("NewApi") -@SuppressSignatureCheck -class EchAwareDns - @RequiresApi(37) - constructor( - internal val echDns: Dns = AndroidDns(), - internal val addressOnlyDns: Dns = Dns.SYSTEM, - internal val policy: NetworkSecurityPolicy = NetworkSecurityPolicy.getInstance(), - ) : Dns { - // Safe to call lookup as it doesn't carry HTTPS records - override fun lookup(hostname: String): List = addressOnlyDns.lookup(hostname) - - override fun newCall(request: Dns.Request): Dns.Call { - val dns = if (echAllowed(request.hostname)) echDns else addressOnlyDns - - return dns.newCall(request) - } - - /** - * Allows avoiding waiting for HTTPS records, when we know that Android Conscrypt won't use them. - */ - private fun echAllowed(hostname: String): Boolean = - when (policy.getDomainEncryptionMode(hostname)) { - DOMAIN_ENCRYPTION_MODE_ENABLED, DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC -> true - else -> false - } - - @SuppressSignatureCheck - companion object { - /** Returns a [Dns] that resolves on [network]. */ - @RequiresApi(37) - fun forNetwork(network: Network): EchAwareDns = - EchAwareDns( - echDns = AndroidDns(network = network), - addressOnlyDns = AndroidDns(network = network, includeServiceMetadata = false), - ) - - @ChecksSdkIntAtLeast(api = 37) - internal val isSupported: Boolean = isAndroid && Build.VERSION.SDK_INT >= 37 - - internal fun buildIfSupported(): Dns? = if (isSupported) EchAwareDns() else null - } - } diff --git a/samples/android/src/androidTest/kotlin/okhttp3/sample/ech/EchClientTest.kt b/samples/android/src/androidTest/kotlin/okhttp3/sample/ech/EchClientTest.kt index b88925fdadaa..b9872f41ee8c 100644 --- a/samples/android/src/androidTest/kotlin/okhttp3/sample/ech/EchClientTest.kt +++ b/samples/android/src/androidTest/kotlin/okhttp3/sample/ech/EchClientTest.kt @@ -15,7 +15,6 @@ */ package okhttp3.sample.ech -import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SdkSuppress import assertk.assertThat @@ -25,7 +24,7 @@ import okhttp3.Dns import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.android.EchAwareDns +import okhttp3.android.AndroidDns import okhttp3.dnsoverhttps.DnsOverHttps import org.junit.Test import org.junit.runner.RunWith @@ -35,85 +34,56 @@ import org.junit.runner.RunWith class EchClientTest { @Test fun androidDns() { - testEch(Resolver.AndroidDns) + testEch(AndroidDns()) } @Test fun dnsOverHttps() { - testEch(Resolver.DnsOverHttps) + testEch( + DnsOverHttps.Builder() + .client(OkHttpClient()) + .url("https://1.1.1.1/dns-query".toHttpUrl()) + .build() + ) } - private fun testEch(resolver: Resolver) { - val client = echClient(resolver) + /** + * ``` + * DNS record: cloudflare-ech.com/104.18.10.118 + * DNS record: cloudflare-ech.com/104.18.11.118 + * DNS record: cloudflare-ech.com/2606:4700::6812:a76 + * DNS record: cloudflare-ech.com/2606:4700::6812:b76 + * DNS record: ServiceMetadata{ + * cloudflare-ech.com, + * alpnIds=[h3, h2, http/1.1], + * ipAddressHints=[104.18.10.118, 104.18.11.118, 2606:4700::6812:a76, 2606:4700::6812:b76], + * echConfigList=0045fe0d0041da002000201e8ee5aa34c64a7439d45dfd1157ab774e2f70abccceef4cd24ae0998286cc760004000100010012636c6f7564666c6172652d6563682e636f6d0000 + * } + * Parsed ECHConfigList: + * config[0]: + * version: 0xfe0d + * contents length: 65 + * config ID: 218 + * KEM ID: 0x0020 + * public key: 1e8ee5aa34c64a7439d45dfd1157ab774e2f70abccceef4cd24ae0998286cc76 + * maximum name length: 0 + * public name: cloudflare-ech.com + * cipher suites: + * KDF 0x0001, AEAD 0x0001 + * extensions: + * ``` + */ + private fun testEch(dns: Dns) { + val client = OkHttpClient.Builder() + .dns(dns) + .build() + + val echCheckRequest = + Request("https://cloudflare-ech.com/cdn-cgi/trace".toHttpUrl()) - client.newCall(ECH_CHECK_REQUEST).execute().use { response -> + client.newCall(echCheckRequest).execute().use { response -> assertThat(response.isSuccessful).isTrue() assertThat(response.body.string()).contains("sni=encrypted") } } } - -private enum class Resolver { - AndroidDns, - DnsOverHttps, -} - -private fun echClient(resolver: Resolver): OkHttpClient { - return OkHttpClient - .Builder() - .apply { - if (Build.VERSION.SDK_INT >= 37) { - dns( - when (resolver) { - Resolver.AndroidDns -> EchAwareDns() - Resolver.DnsOverHttps -> echAwareDnsOverHttps() - }, - ) - } - }.build() -} - -private fun echAwareDnsOverHttps(): Dns { - val bootstrapClient = OkHttpClient() - val dnsUrl = "https://1.1.1.1/dns-query".toHttpUrl() - - fun dns(includeServiceMetadata: Boolean): DnsOverHttps = - DnsOverHttps - .Builder() - .client(bootstrapClient) - .url(dnsUrl) - .includeServiceMetadata(includeServiceMetadata) - .build() - - return EchAwareDns( - echDns = dns(includeServiceMetadata = true), - addressOnlyDns = dns(includeServiceMetadata = false), - ) -} - -/* -DNS record: cloudflare-ech.com/104.18.10.118 -DNS record: cloudflare-ech.com/104.18.11.118 -DNS record: cloudflare-ech.com/2606:4700::6812:a76 -DNS record: cloudflare-ech.com/2606:4700::6812:b76 -DNS record: ServiceMetadata{ - cloudflare-ech.com, - alpnIds=[h3, h2, http/1.1], - ipAddressHints=[104.18.10.118, 104.18.11.118, 2606:4700::6812:a76, 2606:4700::6812:b76], - echConfigList=0045fe0d0041da002000201e8ee5aa34c64a7439d45dfd1157ab774e2f70abccceef4cd24ae0998286cc760004000100010012636c6f7564666c6172652d6563682e636f6d0000 -} -Parsed ECHConfigList: - config[0]: - version: 0xfe0d - contents length: 65 - config ID: 218 - KEM ID: 0x0020 - public key: 1e8ee5aa34c64a7439d45dfd1157ab774e2f70abccceef4cd24ae0998286cc76 - maximum name length: 0 - public name: cloudflare-ech.com - cipher suites: - KDF 0x0001, AEAD 0x0001 - extensions: -*/ -private val ECH_CHECK_REQUEST = - Request("https://cloudflare-ech.com/cdn-cgi/trace".toHttpUrl())