Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions .github/workflows/android-ech.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
name: android-ech

on:
push:
branches:
- main
pull_request:
schedule:
# Daily, an hour after the container suites, so a regression in the snapshot's ECH or
# DoH handling shows up without anyone pushing to this repo.
- cron: '47 7 * * *'
workflow_dispatch:
inputs:
okhttpVersion:
description: 'OkHttp version under test (e.g. 5.5.0-SNAPSHOT)'
required: false
type: string

permissions:
contents: read

env:
GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dkotlin.incremental=false"
# Empty means the version pinned as ech-okhttp in libs.versions.toml, which is the
# snapshot: ECH needs DnsOverHttps.includeServiceMetadata, and no release has it yet.
OKHTTP_VERSION: ${{ inputs.okhttpVersion || '' }}

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
android-ech:
name: android-ech (${{ inputs.okhttpVersion || 'pinned snapshot' }})
runs-on: ubuntu-latest
timeout-minutes: 45

steps:
- name: Checkout
uses: actions/checkout@v7

- name: Configure JDK
uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: 21

- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6

- name: Enable KVM group permissions
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

# API 37 is the emulator this suite needs: ECH is applied by OkHttp's Android platform
# through android.net.ssl.EchConfigList, which arrived there.
- name: Run the ECH suite against the fixture containers
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: '37.0'
target: google_apis_playstore_ps16k
arch: x86_64
disable-animations: true
emulator-options: >-
-no-window
-gpu swiftshader_indirect
-noaudio
-no-boot-anim
-camera-back none
-memory 2048
script: android-ech/run-ech-test.sh

# The suite's XML, for the status page, whatever colour the job ended up.
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v4
with:
name: android-ech-test-results-${{ inputs.okhttpVersion || 'pinned-snapshot' }}
path: |
android-ech/build/outputs/androidTest-results/connected/**/*.xml
android-ech/build/reports/androidTests/connected/
retention-days: 30
64 changes: 59 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@ schedule, and publishes the current status.
Suites
------

| Suite | What it needs | What it covers |
|--------------|--------------------|-------------------------------------------------------------------|
| `containers` | Docker | SOCKS5 and HTTP proxies, TLS via MockServer, virtual threads (Loom) |
| Suite | What it needs | What it covers |
|---------------|-----------------------------|---------------------------------------------------------------------|
| `containers` | Docker | SOCKS5 and HTTP proxies, TLS via MockServer, virtual threads (Loom) |
| `android-ech` | Docker, an API 37 emulator | Encrypted Client Hello over DoH: accepted, retried, and declined |

More suites are planned — notably network tests against external IETF and vendor test
servers, and the heavier Android device matrix.
servers, and the rest of the Android device matrix.

Public API only
---------------
Expand All @@ -34,7 +35,8 @@ Two things enforce that, rather than leaving it to good intentions:
can quietly lean on package-level access.
- `checkPublicApiOnly` fails the build on any import of `okhttp3.internal`,
`okhttp3.testing`, `mockwebserver3.internal` or `okio.internal`. It runs as part of
`check` and before every `test` task. Extend `forbiddenImports` in the root
`check`, before every `test` task, and before the Android suite's `connected…AndroidTest`
task, which `check` doesn't cover. Extend `forbiddenImports` in the root
`build.gradle.kts` as new dependencies arrive.

Where a test needs something the public API doesn't offer, prefer solving it with the
Expand Down Expand Up @@ -64,6 +66,51 @@ another release, a release candidate, or a snapshot:

Snapshots resolve from Sonatype; releases from Maven Central.

The ECH suite
-------------

`android-ech` tests Encrypted Client Hello end to end. It needs Docker *and* an emulator,
which is why it is its own suite with its own workflow rather than another entry under
`containers`.

Two containers stand behind it, built from one small Go program in `ech-fixture`:

- an origin that holds the ECH keys, generates the CA and leaf certificates, and answers
every request with the two facts the test is about — whether the handshake it accepted
used ECH, and which name it was for;
- a DoH resolver, configured from the origin's keys, answering HTTPS records that carry an
ECH config list, the origin's port, and an IPv4 hint.

Three hostnames give three outcomes. `green.secret.test` is published with the config the
origin holds, so the first handshake is accepted. `retry.secret.test` is published with a
stale config and the origin offers a retry config, so the client should retry and succeed
with ECH. `disabled.secret.test` is published with a stale config and the origin offers
nothing, so the client should fall back to a handshake without ECH rather than fail.

The tests run on the device, and the device has no Docker — so the containers run on the
host and the device reaches them over `adb reverse`. `ech-fixture` is what starts them:
not a test, but a process that publishes its host ports and the fixture CA to a file and
stays up until that file is deleted. `run-ech-test.sh` ties the two together:

```
android-ech/run-ech-test.sh # fixture, adb reverse, instrumentation tests
android-ech/run-ech-test.sh --smoke-only # fixture only, for a machine with no emulator
```

It needs a running emulator or a connected device on API 37 — `android.net.ssl.EchConfigList`,
which is how OkHttp's Android platform applies a config list, arrived there. The tests skip
themselves on anything older, and on a run that didn't come through the script.

This suite tests **5.5.0-SNAPSHOT** by default, not the release the other suites pin, and
`libs.versions.toml` carries that as a separate `ech-okhttp` version. It has to: the suite
needs `DnsOverHttps.Builder.includeServiceMetadata`, and no release has it — 5.4.0 resolves
A and AAAA records only, so there is no HTTPS record to carry an ECH config list. Point it
at whatever you like with `-PokhttpVersion`, and drop `ech-okhttp` once a release ships the
API.

ECH itself is Android-only in OkHttp today: JVM platforms accept the config list and ignore
it, so there is nothing here for the `containers` suite to assert.

CI
--

Expand Down Expand Up @@ -97,6 +144,13 @@ will be built from. The job runs with `--continue` so one failing suite doesn't
others of a result, and the matrix runs with `fail-fast: false` so one failing version
doesn't rob the other.

The `android-ech` workflow runs on the same events, on its own daily schedule, and uploads
its results as `android-ech-test-results-<version>`. It runs one version rather than a
matrix — the snapshot — because that is the only version with the API the suite needs. It
boots an API 37 emulator, so it is slower and more failure-prone than the container jobs;
that is the price of testing ECH at all, and it is why it is a separate workflow whose
colour doesn't mask the container suites'.

Suites that report rather than gate
-----------------------------------

Expand Down
71 changes: 71 additions & 0 deletions android-ech/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
plugins {
// AGP 9 brings Kotlin support with it — applying the Kotlin Android plugin here fails.
alias(libs.plugins.android.library)
alias(libs.plugins.android.junit5)
}

// The version of OkHttp under test. This suite defaults to the snapshot rather than to the
// release the other suites pin, because ECH needs `DnsOverHttps.includeServiceMetadata` and
// no release has it yet. Override the same way as everywhere else:
// ./gradlew android-ech:connectedDebugAndroidTest -PokhttpVersion=5.5.0-SNAPSHOT
val okhttpVersion =
providers
.gradleProperty("okhttpVersion")
.getOrElse(libs.versions.ech.okhttp.get())

// Snapshots are republished under the same name, and Gradle caches a changing module for 24
// hours, so without this a daily run can test yesterday's build. Releases are immutable.
if (okhttpVersion.endsWith("-SNAPSHOT")) {
configurations.configureEach {
resolutionStrategy.cacheChangingModulesFor(0, "seconds")
}
}

android {
namespace = "okhttp.testbed.android.ech"

compileSdk {
// ECH arrived in Android 16 QPR2 / API 37: `android.net.ssl.EchConfigList` is what
// OkHttp's Android platform hands the config list to.
version = release(37)
}

defaultConfig {
minSdk = 21

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments +=
mapOf(
// The suite is JUnit 5, as the JVM suites are.
"runnerBuilder" to "de.mannodermaus.junit5.AndroidJUnit5Builder",
)
}

compileOptions {
sourceCompatibility(JavaVersion.VERSION_11)
targetCompatibility(JavaVersion.VERSION_11)
}

testOptions {
targetSdk = 37
}
}

dependencies {
androidTestImplementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
androidTestImplementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion")

androidTestImplementation(libs.assertk)
androidTestImplementation(libs.junit.jupiter.api)
androidTestImplementation(libs.junit5android.core)
androidTestImplementation(libs.androidx.test.runner)
androidTestRuntimeOnly(libs.junit5android.runner)
}

// `check` doesn't run instrumentation tests, so the public-API check has to be wired to the
// task that does — otherwise this suite could reach into okhttp3.internal unnoticed.
tasks
.matching { it.name.startsWith("connected") && it.name.endsWith("AndroidTest") }
.configureEach {
dependsOn("checkPublicApiOnly")
}
100 changes: 100 additions & 0 deletions android-ech/run-ech-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
#!/usr/bin/env bash

# Runs the Android ECH suite against the host-side containers.
#
# The containers can't run on the device, and the device can't reach the host by name, so
# this script is the glue: it starts the fixture on the host, waits for it to publish its
# ports and CA, forwards those ports onto the device with `adb reverse`, and then runs the
# instrumentation tests. `--smoke-only` stops after the fixture is up, which is what to run
# where no emulator is available — it still exercises Docker, Gradle and the fixture itself.

set -euo pipefail

mode="${1:-instrumentation}"
if [[ "$mode" != "instrumentation" && "$mode" != "--smoke-only" ]]; then
echo "usage: $0 [--smoke-only]" >&2
exit 2
fi

repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
temporary_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
endpoint_file="$temporary_dir/okhttp-testbed-ech.endpoint"
service_log="$temporary_dir/okhttp-testbed-ech.log"
startup_timeout_seconds="${ECH_FIXTURE_TIMEOUT_SECONDS:-1200}"
rm -f "$endpoint_file" "$service_log"

# Passed through so a run can pick a version the same way the Gradle suites do.
gradle_arguments=()
if [[ -n "${OKHTTP_VERSION:-}" ]]; then
gradle_arguments+=("-PokhttpVersion=$OKHTTP_VERSION")
fi

ECH_FIXTURE_ENDPOINT_FILE="$endpoint_file" \
"$repository_root/gradlew" -p "$repository_root" :ech-fixture:runEchFixture "${gradle_arguments[@]}" \
>"$service_log" 2>&1 &
service_pid=$!

cleanup() {
adb reverse --remove tcp:8053 >/dev/null 2>&1 || true
adb reverse --remove tcp:443 >/dev/null 2>&1 || true
adb reverse --remove tcp:8443 >/dev/null 2>&1 || true
# Deleting the endpoint file is how the fixture is asked to stop.
rm -f "$endpoint_file"
for _ in {1..200}; do
if ! kill -0 "$service_pid" 2>/dev/null; then
break
fi
sleep 0.1
done
kill "$service_pid" >/dev/null 2>&1 || true
wait "$service_pid" >/dev/null 2>&1 || true
}
trap cleanup EXIT

startup_deadline=$((SECONDS + startup_timeout_seconds))
while ((SECONDS < startup_deadline)); do
if [[ -s "$endpoint_file" ]]; then
break
fi
if ! kill -0 "$service_pid" 2>/dev/null; then
cat "$service_log" >&2
exit 1
fi
sleep 1
done

if [[ ! -s "$endpoint_file" ]]; then
cat "$service_log" >&2
echo "Timed out waiting for the ECH fixture" >&2
exit 1
fi

property() {
sed -n "s/^$1=//p" "$endpoint_file"
}

doh_host_port="$(property DOH_HOST_PORT)"
target_host_port="$(property TARGET_HOST_PORT)"
ca_certificate="$(property CA_CERT)"
if [[ ! "$doh_host_port" =~ ^[0-9]+$ || ! "$target_host_port" =~ ^[0-9]+$ || -z "$ca_certificate" ]]; then
cat "$service_log" >&2
echo "Invalid ECH fixture metadata" >&2
exit 1
fi

if [[ "$mode" == "--smoke-only" ]]; then
exit 0
fi

# 8053 is the resolver. The origin is reached on 8443, the port the HTTPS record publishes,
# and on 443 for the default the URL would otherwise use.
adb reverse tcp:8053 "tcp:$doh_host_port"
adb reverse tcp:443 "tcp:$target_host_port"
adb reverse tcp:8443 "tcp:$target_host_port"

"$repository_root/gradlew" -p "$repository_root" :android-ech:connectedDebugAndroidTest \
"${gradle_arguments[@]}" \
-Pandroid.testInstrumentationRunnerArguments.class=okhttp.testbed.android.ech.EncryptedClientHelloTest \
-Pandroid.testInstrumentationRunnerArguments.ech=true \
-Pandroid.testInstrumentationRunnerArguments.dohPort=8053 \
-Pandroid.testInstrumentationRunnerArguments.caCertificate="$ca_certificate"
Loading
Loading