-
Notifications
You must be signed in to change notification settings - Fork 9.3k
Add Android ECH support #9573
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Add Android ECH support #9573
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| sdk=36 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| /* | ||
| * 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 androidx.annotation.RequiresApi | ||
| import java.io.IOException | ||
| import java.net.InetAddress | ||
| import java.net.UnknownHostException | ||
| import java.util.concurrent.Executor | ||
| import java.util.concurrent.atomic.AtomicBoolean | ||
| import okhttp3.Dns | ||
| import okhttp3.internal.SuppressSignatureCheck | ||
| import okhttp3.internal.concurrent.Task | ||
| import okhttp3.internal.concurrent.TaskRunner | ||
| import okhttp3.internal.dns.DnsCallStateMachine | ||
| import okhttp3.internal.dns.DnsMessage | ||
| import okhttp3.internal.dns.DnsMessageReader | ||
| import okhttp3.internal.dns.TYPE_A | ||
| import okhttp3.internal.dns.TYPE_HTTPS | ||
| import okhttp3.internal.dns.execute | ||
| import okio.Buffer | ||
|
|
||
| /** | ||
| * A [Dns] backed by Android's system resolver, with ECH support from Android's [DnsResolver]. | ||
| * | ||
| * IP addresses come from the system resolver — [InetAddress.getAllByName], or | ||
| * [Network.getAllByName] when a [network] is set. | ||
| */ | ||
| @RequiresApi(29) | ||
| @SuppressSignatureCheck | ||
| class AndroidDns | ||
| @JvmOverloads | ||
| constructor( | ||
| private val dnsResolver: DnsResolver = DnsResolver.getInstance(), | ||
| private val network: Network? = null, | ||
| /** | ||
| * True to also query the `HTTPS` record for service metadata such as ECH. Set this to false to | ||
| * save a query when only IP addresses are needed. | ||
| */ | ||
| private val includeServiceMetadata: Boolean = true, | ||
| // Runs inline; the executor only hands off DnsResolver's callbacks. | ||
| private val executor: Executor = Executor { it.run() }, | ||
| ) : Dns { | ||
| /** | ||
| * Resolves addresses only, for callers using the legacy blocking API. [Dns] cannot carry | ||
| * HTTPS/ECH metadata, so this skips that query rather than paying for it and discarding it. | ||
| */ | ||
| override fun lookup(hostname: String): List<InetAddress> = | ||
| AndroidDnsCall(Dns.Request(hostname), includeServiceMetadata = false) | ||
| .execute() | ||
| .filterIsInstance<Dns.Record.IpAddress>() | ||
| .map { it.address } | ||
|
|
||
| override fun newCall(request: Dns.Request): Dns.Call = AndroidDnsCall(request, includeServiceMetadata = includeServiceMetadata) | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @swankjesse should includeServiceMetadata move to Dns.Request? so we can honour NetworkSecurityPolicy?
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think so. I consider that to be a property of the service and not the data. |
||
|
|
||
| private inner class AndroidDnsCall( | ||
| override val request: Dns.Request, | ||
| includeServiceMetadata: Boolean, | ||
| ) : Dns.Call, | ||
| DnsCallStateMachine.Transport<AndroidDnsCall.Query> { | ||
|
yschimke marked this conversation as resolved.
|
||
| private val stateMachine = | ||
| DnsCallStateMachine( | ||
| transport = this, | ||
| call = this, | ||
| canceledException = null, | ||
| // A single `A` query to represent the blocking InetAddress call for both. | ||
|
yschimke marked this conversation as resolved.
|
||
| includeIPv6 = false, | ||
| includeServiceMetadata = includeServiceMetadata, | ||
| ) | ||
|
|
||
| override fun enqueue(callback: Dns.Callback) { | ||
| stateMachine.start(callback) | ||
| } | ||
|
|
||
| override fun cancel() { | ||
| stateMachine.cancel() | ||
| } | ||
|
|
||
| override fun isCanceled(): Boolean = stateMachine.canceled | ||
|
|
||
| override fun newQuery(dnsMessage: DnsMessage) = Query(dnsMessage) | ||
|
|
||
| override fun enqueue(query: Query) { | ||
| when (query.type) { | ||
| TYPE_A -> resolveAddresses(query) | ||
| else -> queryServiceMetadata(query) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Cancels a query. The system resolver doesn't call back once canceled, so this completes the | ||
| * query itself to keep the state machine from stalling. | ||
|
yschimke marked this conversation as resolved.
|
||
| */ | ||
| override fun cancel(query: Query) { | ||
| query.cancellationSignal?.cancel() | ||
| query.complete { stateMachine.onQueryFailure(query, IOException("canceled")) } | ||
| } | ||
|
|
||
| /** | ||
| * Resolves IP addresses through the system resolver. This is a blocking call, so it runs on a | ||
| * [TaskRunner] thread. When canceled, it still runs to completion but its result is discarded. | ||
| */ | ||
| private fun resolveAddresses(query: Query) { | ||
| TaskRunner.INSTANCE.newQueue().schedule( | ||
| object : Task("${request.hostname} address lookup", cancelable = false) { | ||
| override fun runOnce(): Long { | ||
| try { | ||
| val addresses = | ||
| when (network) { | ||
| null -> InetAddress.getAllByName(request.hostname) | ||
| else -> network.getAllByName(request.hostname) | ||
| } | ||
| val records = addresses.map { Dns.Record.IpAddress(request.hostname, it) } | ||
| query.complete { stateMachine.onQueryRecords(query, records) } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. neat |
||
| } catch (e: UnknownHostException) { | ||
| query.complete { stateMachine.onQueryFailure(query, e) } | ||
| } | ||
| return -1L | ||
| } | ||
| }, | ||
| ) | ||
| } | ||
|
|
||
| /** | ||
| * Asks [DnsResolver] for the `HTTPS` record. The platform has no typed API for this below | ||
| * API 36, so we request the raw message and decode it with OkHttp's own [DnsMessageReader] — | ||
| * the same decoder `DnsOverHttps` uses. | ||
| * | ||
| * Failures are reported to the [Dns.Callback] rather than swallowed, so callers can tell an | ||
| * absent `HTTPS` record from a query that errored. Note that any addresses already resolved | ||
| * are still delivered first. | ||
| */ | ||
| @Suppress("ktlint:standard:comment-wrapping") | ||
| private fun queryServiceMetadata(query: Query) { | ||
| val queryCallback = | ||
| object : DnsResolver.Callback<ByteArray> { | ||
| override fun onAnswer( | ||
| answer: ByteArray, | ||
| rcode: Int, | ||
| ) { | ||
| val message = | ||
| try { | ||
| DnsMessageReader(Buffer().write(answer)).read() | ||
| } catch (e: IOException) { | ||
| return query.complete { stateMachine.onQueryFailure(query, e) } | ||
| } | ||
|
|
||
| // The state machine turns a non-success rcode into a failure of its own. | ||
| query.complete { stateMachine.onQueryResponse(query, message) } | ||
| } | ||
|
|
||
| override fun onError(e: DnsResolver.DnsException) { | ||
| query.complete { | ||
| stateMachine.onQueryFailure( | ||
| query, | ||
| IOException("HTTPS query failed with code ${e.code}", e), | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| dnsResolver.rawQuery( | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice |
||
| /* network = */ network, | ||
| /* domain = */ request.hostname, | ||
| /* nsClass = */ DnsResolver.CLASS_IN, | ||
| /* nsType = */ TYPE_HTTPS, | ||
| /* flags = */ DnsResolver.FLAG_EMPTY, | ||
| /* executor = */ executor, | ||
| /* cancellationSignal = */ query.cancellationSignal, | ||
| /* callback = */ queryCallback, | ||
| ) | ||
| } catch (e: SecurityException) { | ||
| // The app lacks INTERNET permission, or network policy forbids the query. The callback | ||
| // won't run, so complete the query here or the state machine never terminates. | ||
| query.complete { stateMachine.onQueryFailure(query, IOException(e)) } | ||
| } | ||
| } | ||
|
|
||
| /** One outstanding query. Each completes the state machine's query at most once. */ | ||
| inner class Query( | ||
| dnsMessage: DnsMessage, | ||
| ) { | ||
| val type: Int = dnsMessage.questions.single().type | ||
|
yschimke marked this conversation as resolved.
|
||
|
|
||
| /** Only the `HTTPS` query reaches [DnsResolver], which is the API that takes a signal. */ | ||
| val cancellationSignal = if (type == TYPE_A) null else CancellationSignal() | ||
| private val completed = AtomicBoolean() | ||
|
|
||
| fun complete(block: () -> Unit) { | ||
| if (completed.compareAndSet(false, true)) block() | ||
|
yschimke marked this conversation as resolved.
|
||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I wonder if there’s a way to phrase this documentation to encourage people to keep it on.
‘Set this to false to omit the service metadata query. This will disable privacy features in the HTTPS call.’
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think maybe we end up defaulting to the networkSecurityConfig, but let developers opt-out, or opt-in.
For now, likely opt-in while we confirm it works.
GREASE means it should never cause failures just by being enabled.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll ask tomorrow if that mode still exists.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yep, I'll update the wording. But also think if we want this we should make AndroidDns the default on API 37+ and when network security policy isn't globally disabled?
I think effectively the modes are (potentially per host)
UNSPECIFIED
DISABLED
ENABLED
So maybe that default once we are confident is the right option?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BTW if we ignore or override the NSP, Conscrypt still checks it.
https://cs.android.com/android/platform/superproject/+/android-latest-release:external/conscrypt/common/src/main/java/org/conscrypt/NativeSsl.java;l=569?q=enableEchBasedOnPolicy