From 6a87a3a14df414a27d662ef6aeea38e38edaa2d9 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Tue, 11 Aug 2026 07:48:06 +0100 Subject: [PATCH] Add Android ECH Testcontainers workflow --- .github/workflows/android-testcontainers.yml | 90 +++++ android-test/run-ech-test.sh | 80 ++++ android-test/run-testcontainers-test.sh | 84 ++++ android-test/src/androidTest/README.md | 91 +++++ .../android/test/EncryptedClientHelloTest.kt | 130 +++++++ .../android/test/TestcontainersHostTest.kt | 40 ++ container-tests/build.gradle.kts | 16 + .../containers/AndroidEchTestService.kt | 117 ++++++ .../okhttp3/containers/AndroidTestService.kt | 77 ++++ .../resources/android-ech-fixture/Dockerfile | 9 + .../resources/android-ech-fixture/main.go | 361 ++++++++++++++++++ 11 files changed, 1095 insertions(+) create mode 100644 .github/workflows/android-testcontainers.yml create mode 100755 android-test/run-ech-test.sh create mode 100755 android-test/run-testcontainers-test.sh create mode 100644 android-test/src/androidTest/java/okhttp/android/test/EncryptedClientHelloTest.kt create mode 100644 android-test/src/androidTest/java/okhttp/android/test/TestcontainersHostTest.kt create mode 100644 container-tests/src/test/java/okhttp3/containers/AndroidEchTestService.kt create mode 100644 container-tests/src/test/java/okhttp3/containers/AndroidTestService.kt create mode 100644 container-tests/src/test/resources/android-ech-fixture/Dockerfile create mode 100644 container-tests/src/test/resources/android-ech-fixture/main.go diff --git a/.github/workflows/android-testcontainers.yml b/.github/workflows/android-testcontainers.yml new file mode 100644 index 000000000000..9f648393fde8 --- /dev/null +++ b/.github/workflows/android-testcontainers.yml @@ -0,0 +1,90 @@ +name: Android ECH Testcontainers + +on: + workflow_dispatch: + pull_request: + types: [opened, labeled, unlabeled, synchronize] + push: + branches: + - main + +permissions: + contents: read + +env: + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dkotlin.incremental=false" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + android-testcontainers: + runs-on: ubuntu-latest + timeout-minutes: 30 + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' || contains(github.event.pull_request.labels.*.name, 'containers') + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Configure JDK + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: 21 + + # Some act runner images lose their bundled Node path after setup-java updates PATH. + - name: Restore act Node path + if: env.ACT == 'true' + run: | + node_path="$(find /opt/acttoolcache/node -path '*/bin/node' -type f | sort -V | tail -1)" + test -n "$node_path" + dirname "$node_path" >> "$GITHUB_PATH" + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + with: + cache-read-only: ${{ env.ACT != 'true' && github.ref != 'refs/heads/main' }} + + # act runs this job in a container, where nested KVM is generally unavailable. + # This still exercises the workflow, Gradle launcher, Docker, and Testcontainers locally. + - name: Test ECH services with act + if: env.ACT == 'true' + env: + # A cold Gradle build plus the Go fixture image may be slow on Apple Silicon. + ANDROID_ECH_TEST_SERVICE_TIMEOUT_SECONDS: 1200 + run: android-test/run-ech-test.sh --smoke-only + + - name: Enable KVM group permissions + if: env.ACT != 'true' + 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 + + - name: Run Android ECH test against Testcontainers + if: env.ACT != 'true' + 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-test/run-ech-test.sh + + - name: Upload Android test results + if: always() && env.ACT != 'true' + uses: actions/upload-artifact@v7 + with: + name: android-ech-testcontainers-results + path: | + android-test/build/outputs/androidTest-results/connected/ + android-test/build/reports/androidTests/connected/ diff --git a/android-test/run-ech-test.sh b/android-test/run-ech-test.sh new file mode 100755 index 000000000000..c1061f08bac8 --- /dev/null +++ b/android-test/run-ech-test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +set -euo pipefail + +mode="${1:-instrumentation}" +if [[ "$mode" != "instrumentation" && "$mode" != "--smoke-only" ]]; then + echo "usage: $0 [--smoke-only]" >&2 + exit 2 +fi + +temporary_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +endpoint_file="$temporary_dir/okhttp-android-ech-test.endpoint" +service_log="$temporary_dir/okhttp-android-ech-test.log" +startup_timeout_seconds="${ANDROID_ECH_TEST_SERVICE_TIMEOUT_SECONDS:-1200}" +rm -f "$endpoint_file" "$service_log" + +ANDROID_ECH_TEST_ENDPOINT_FILE="$endpoint_file" \ + ./gradlew :container-tests:runAndroidEchTestService >"$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 + 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 test services" >&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 test service metadata" >&2 + exit 1 +fi + +if [[ "$mode" == "--smoke-only" ]]; then + exit 0 +fi + +adb reverse tcp:8053 "tcp:$doh_host_port" +adb reverse tcp:443 "tcp:$target_host_port" +adb reverse tcp:8443 "tcp:$target_host_port" +./gradlew :android-test:connectedDebugAndroidTest \ + -PandroidBuild=true \ + -Pandroid.testInstrumentationRunnerArguments.class=okhttp.android.test.EncryptedClientHelloTest \ + -Pandroid.testInstrumentationRunnerArguments.ech=true \ + -Pandroid.testInstrumentationRunnerArguments.dohPort=8053 \ + -Pandroid.testInstrumentationRunnerArguments.caCertificate="$ca_certificate" diff --git a/android-test/run-testcontainers-test.sh b/android-test/run-testcontainers-test.sh new file mode 100755 index 000000000000..69c2505d4e44 --- /dev/null +++ b/android-test/run-testcontainers-test.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash + +set -euo pipefail + +mode="${1:-instrumentation}" +if [[ "$mode" != "instrumentation" && "$mode" != "--smoke-only" ]]; then + echo "usage: $0 [--smoke-only]" >&2 + exit 2 +fi + +temporary_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +endpoint_file="$temporary_dir/okhttp-android-test-service.endpoint" +service_log="$temporary_dir/okhttp-android-test-service.log" +startup_timeout_seconds="${ANDROID_TEST_SERVICE_TIMEOUT_SECONDS:-600}" +rm -f "$endpoint_file" "$service_log" + +ANDROID_TEST_SERVICE_ENDPOINT_FILE="$endpoint_file" \ + ./gradlew :container-tests:runAndroidTestService >"$service_log" 2>&1 & +service_pid=$! + +cleanup() { + adb reverse --remove tcp:8080 >/dev/null 2>&1 || true + # Removing the endpoint file asks the launcher to stop its container and exit cleanly. + rm -f "$endpoint_file" + for _ in {1..100}; 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 Testcontainers service" >&2 + exit 1 +fi + +endpoint="$(<"$endpoint_file")" +service_reachable=false +for _ in {1..120}; do + if curl --fail --silent "$endpoint/android-test"; then + service_reachable=true + break + fi + sleep 0.25 +done +if [[ "$service_reachable" != "true" ]]; then + cat "$service_log" >&2 + echo "Testcontainers service is not reachable at $endpoint" >&2 + exit 1 +fi +echo + +if [[ "$mode" == "--smoke-only" ]]; then + exit 0 +fi + +service_port="${endpoint##*:}" +if [[ ! "$service_port" =~ ^[0-9]+$ ]]; then + echo "Could not read the mapped port from $endpoint" >&2 + exit 1 +fi + +adb reverse tcp:8080 "tcp:$service_port" +./gradlew :android-test:connectedDebugAndroidTest \ + -PandroidBuild=true \ + -Pandroid.testInstrumentationRunnerArguments.class=okhttp.android.test.TestcontainersHostTest \ + -Pandroid.testInstrumentationRunnerArguments.testcontainers=true diff --git a/android-test/src/androidTest/README.md b/android-test/src/androidTest/README.md index 8d485e22269a..57d868b199e1 100644 --- a/android-test/src/androidTest/README.md +++ b/android-test/src/androidTest/README.md @@ -53,3 +53,94 @@ BUILD SUCCESSFUL in 1m 30s ``` n.b. use ANDROID_SERIAL=emulator-5554 or similar if you need to select between devices. + +Testcontainers service on the host +---------------------------------- + +`TestcontainersHostTest` runs on Android while its MockServer service runs in a +Testcontainers-managed Docker container on the host. The CI job uses the API 37.0 +`google_apis_playstore_ps16k` system image. With an API 37 emulator running and +Docker available, run: + +``` +$ android-test/run-testcontainers-test.sh +``` + +The script starts the host service, discovers its random mapped port, and uses +`adb reverse` to make it available to the test at `127.0.0.1:8080`. It then runs +only `TestcontainersHostTest` and stops the container. + +With a Docker engine running, the GitHub workflow can be smoke-tested with `act` +without trying to run a nested emulator: + +``` +$ act workflow_dispatch -W .github/workflows/android-testcontainers.yml +``` + +When using Colima, the Docker socket path visible to the Linux daemon differs +from its macOS forwarding path. Start Colima and run `act` with: + +``` +$ colima start +$ act workflow_dispatch \ + -W .github/workflows/android-testcontainers.yml \ + --container-architecture linux/arm64 \ + --container-daemon-socket unix:///var/run/docker.sock +``` + +The explicit ARM64 runner avoids emulating an amd64 `act` image on Apple Silicon. +Omit that option on Intel hosts. + +For a direct local run with Colima, export the daemon-side socket used by Ryuk: + +``` +$ export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock +$ android-test/run-testcontainers-test.sh +``` + +Encrypted Client Hello fixture +------------------------------ + +`EncryptedClientHelloTest` is the API 37 ECH interoperability test used by the +workflow. Testcontainers starts two instances of a hermetic Go fixture: + +* a TLS DoH server that returns an HTTPS (type 65) record containing an + `ech` SvcParam; and +* an HTTPS target configured with the corresponding ECH private key. + +The emulator reaches both random host ports through `adb reverse`. The tests +query the DoH server with OkHttp and exercise three API 37 `SSLSocket` paths: + +* a current ECH config is accepted and the server observes the private SNI; +* a stale config is rejected, then the server-provided config is used on a + successful ECH retry; and +* a stale config with no server-provided replacement is retried successfully + without ECH. + +The test configures `DnsOverHttps` as the client's `Dns` implementation and +makes ordinary OkHttp requests. OkHttp requests the HTTPS DNS record, consumes +its service metadata, configures the API 37 socket, and handles ECH rejection +state. There is no manual DNS message parsing or direct `SSLSocket` use in the +instrumentation test. The retry tests assert the intended OkHttp behavior and +are expected to fail until ECH retry support lands. + +Run it with an API 37 emulator and Docker already running: + +``` +$ export TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock # Colima only +$ android-test/run-ech-test.sh +``` + +To validate the Gradle/Testcontainers side without an emulator, including when +running the workflow with `act`: + +``` +$ android-test/run-ech-test.sh --smoke-only +$ act workflow_dispatch \ + -W .github/workflows/android-testcontainers.yml \ + --container-architecture linux/arm64 \ + --container-daemon-socket unix:///var/run/docker.sock +``` + +The test exercises OkHttp's Android ECH connection planning end to end against +the hermetic DoH and HTTPS services. diff --git a/android-test/src/androidTest/java/okhttp/android/test/EncryptedClientHelloTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EncryptedClientHelloTest.kt new file mode 100644 index 000000000000..c81c9ac03116 --- /dev/null +++ b/android-test/src/androidTest/java/okhttp/android/test/EncryptedClientHelloTest.kt @@ -0,0 +1,130 @@ +/* + * Copyright (C) 2026 Block, Inc. + * + * 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 okhttp.android.test + +import android.os.Build +import androidx.test.platform.app.InstrumentationRegistry +import java.io.ByteArrayInputStream +import java.net.InetAddress +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.util.Base64 +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +import kotlin.test.assertContains +import kotlin.test.assertEquals +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.dnsoverhttps.DnsOverHttps +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test + +class EncryptedClientHelloTest { + @Test + fun greenPathAcceptsEncryptedClientHello() { + val response = fixture().get(GREEN_NAME) + + assertContains(response, "\"echAccepted\":true") + assertContains(response, "\"serverName\":\"$GREEN_NAME\"") + } + + @Test + fun rejectedConfigIsRetriedWithServerConfig() { + val response = fixture().get(RETRY_NAME) + + assertContains(response, "\"echAccepted\":true") + assertContains(response, "\"serverName\":\"$RETRY_NAME\"") + } + + @Test + fun rejectedConfigWithoutServerConfigIsRetriedWithoutEch() { + val response = fixture().get(DISABLED_NAME) + + assertContains(response, "\"echAccepted\":false") + assertContains(response, "\"serverName\":\"$DISABLED_NAME\"") + } + + private fun fixture(): Fixture { + val arguments = InstrumentationRegistry.getArguments() + assumeTrue(arguments.getString("ech") == "true", "requires the host-side ECH fixtures") + assumeTrue(Build.VERSION.SDK_INT >= 37, "ECH requires Android API 37") + val dohPort = requireNotNull(arguments.getString("dohPort")).toInt() + val caCertificate = Base64.getDecoder().decode(requireNotNull(arguments.getString("caCertificate"))) + val (sslContext, trustManager) = sslContext(caCertificate) + return Fixture(dohPort, sslContext, trustManager) + } + + private class Fixture( + dohPort: Int, + sslContext: SSLContext, + trustManager: X509TrustManager, + ) { + private val client: OkHttpClient + + init { + val bootstrapClient = + OkHttpClient + .Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .build() + val dns = + DnsOverHttps + .Builder() + .client(bootstrapClient) + .url("https://$DOH_NAME:$dohPort/dns-query".toHttpUrl()) + .bootstrapDnsHosts(InetAddress.getByName("127.0.0.1")) + .includeServiceMetadata(true) + .resolvePrivateAddresses(true) + .post(true) + .build() + client = bootstrapClient.newBuilder().dns(dns).build() + } + + fun get(hostname: String): String = + client + .newCall(Request("https://$hostname/".toHttpUrl())) + .execute() + .use { response -> + assertEquals(200, response.code) + response.body.string() + } + } + + private companion object { + private fun sslContext(caCertificatePem: ByteArray): Pair { + val certificate = + CertificateFactory + .getInstance("X.509") + .generateCertificate(ByteArrayInputStream(caCertificatePem)) + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null) + keyStore.setCertificateEntry("fixture", certificate) + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(keyStore) + val trustManager = trustManagerFactory.trustManagers.single() as X509TrustManager + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, arrayOf(trustManager), null) + return sslContext to trustManager + } + + private const val DOH_NAME = "doh.test" + private const val GREEN_NAME = "green.secret.test" + private const val RETRY_NAME = "retry.secret.test" + private const val DISABLED_NAME = "disabled.secret.test" + } +} diff --git a/android-test/src/androidTest/java/okhttp/android/test/TestcontainersHostTest.kt b/android-test/src/androidTest/java/okhttp/android/test/TestcontainersHostTest.kt new file mode 100644 index 000000000000..f6c21b28208f --- /dev/null +++ b/android-test/src/androidTest/java/okhttp/android/test/TestcontainersHostTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (C) 2026 Block, Inc. + * + * 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 okhttp.android.test + +import androidx.test.platform.app.InstrumentationRegistry +import kotlin.test.assertEquals +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test + +class TestcontainersHostTest { + @Test + fun reachesTestcontainersServiceOnHost() { + assumeTrue( + InstrumentationRegistry.getArguments().getString("testcontainers") == "true", + "requires the host-side Testcontainers launcher", + ) + + val request = Request.Builder().url("http://127.0.0.1:8080/android-test").build() + + OkHttpClient().newCall(request).execute().use { response -> + assertEquals(200, response.code) + assertEquals("hello from Testcontainers", response.body.string()) + } + } +} diff --git a/container-tests/build.gradle.kts b/container-tests/build.gradle.kts index 88ff8f415190..e4129c874f75 100644 --- a/container-tests/build.gradle.kts +++ b/container-tests/build.gradle.kts @@ -1,3 +1,5 @@ +import org.gradle.language.base.plugins.LifecycleBasePlugin + plugins { kotlin("jvm") id("okhttp.base-conventions") @@ -47,3 +49,17 @@ dependencies { testImplementation(libs.mockserver.client) testImplementation(libs.testcontainers.junit5) } + +tasks.register("runAndroidTestService") { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Starts the Testcontainers service used by Android instrumentation tests." + classpath = sourceSets["test"].runtimeClasspath + mainClass = "okhttp3.containers.AndroidTestService" +} + +tasks.register("runAndroidEchTestService") { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Starts the DoH and HTTPS containers used by the Android ECH test." + classpath = sourceSets["test"].runtimeClasspath + mainClass = "okhttp3.containers.AndroidEchTestService" +} diff --git a/container-tests/src/test/java/okhttp3/containers/AndroidEchTestService.kt b/container-tests/src/test/java/okhttp3/containers/AndroidEchTestService.kt new file mode 100644 index 000000000000..0131edbd2ae1 --- /dev/null +++ b/container-tests/src/test/java/okhttp3/containers/AndroidEchTestService.kt @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 Block, Inc. + * + * 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.containers + +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.util.concurrent.atomic.AtomicBoolean +import org.testcontainers.containers.GenericContainer +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.images.builder.ImageFromDockerfile + +/** Two host-side containers for an Android ECH interoperability test: DoH and HTTPS. */ +object AndroidEchTestService { + private const val CONTROL_PORT = 8080 + private const val DOH_PORT = 8053 + private const val TARGET_PORT = 8443 + + @JvmStatic + fun main(args: Array) { + require(args.isEmpty()) { "This service does not accept command-line arguments" } + + val endpointFile = + Path.of( + requireNotNull(System.getenv("ANDROID_ECH_TEST_ENDPOINT_FILE")) { + "ANDROID_ECH_TEST_ENDPOINT_FILE is not set" + }, + ) + val image = + ImageFromDockerfile("okhttp/android-ech-fixture:local", false) + .withFileFromClasspath("Dockerfile", "android-ech-fixture/Dockerfile") + .withFileFromClasspath("main.go", "android-ech-fixture/main.go") + + val target = GenericContainer(image) + target.withCommand("target") + target.withExposedPorts(CONTROL_PORT, TARGET_PORT) + target.waitingFor(Wait.forHttp("/health").forPort(CONTROL_PORT)) + target.withStartupTimeout(Duration.ofMinutes(10)) + var doh: GenericContainer? = null + val stopped = AtomicBoolean() + val stop = { + if (stopped.compareAndSet(false, true)) { + doh?.stop() + target.stop() + } + } + + target.start() + val targetHost = target.host.normalizedLoopback() + val metadata = + URI("http://$targetHost:${target.getMappedPort(CONTROL_PORT)}/metadata") + .toURL() + .readText() + .lineSequence() + .filter { it.isNotEmpty() } + .associate { line -> + val (name, value) = line.split('=', limit = 2) + name to value + } + + val dohContainer = GenericContainer(image) + dohContainer.withCommand("doh") + dohContainer.withEnv("ECH_GREEN_CONFIG_LIST", metadata.required("ECH_GREEN_CONFIG_LIST")) + dohContainer.withEnv("ECH_RETRY_STALE_CONFIG_LIST", metadata.required("ECH_RETRY_STALE_CONFIG_LIST")) + dohContainer.withEnv( + "ECH_DISABLED_STALE_CONFIG_LIST", + metadata.required("ECH_DISABLED_STALE_CONFIG_LIST"), + ) + dohContainer.withEnv("DOH_CERT", metadata.required("DOH_CERT")) + dohContainer.withEnv("DOH_KEY", metadata.required("DOH_KEY")) + dohContainer.withEnv("TARGET_PORT", TARGET_PORT.toString()) + dohContainer.withExposedPorts(DOH_PORT) + dohContainer.waitingFor(Wait.forHttps("/health").forPort(DOH_PORT).allowInsecure()) + dohContainer.withStartupTimeout(Duration.ofMinutes(5)) + doh = dohContainer + dohContainer.start() + + Runtime.getRuntime().addShutdownHook(Thread({ stop() }, "android-ech-test-service-shutdown")) + try { + val endpoints = + """ + DOH_HOST_PORT=${dohContainer.getMappedPort(DOH_PORT)} + TARGET_HOST_PORT=${target.getMappedPort(TARGET_PORT)} + CA_CERT=${metadata.required("CA_CERT")} + """.trimIndent() + "\n" + endpointFile.parent?.let { Files.createDirectories(it) } + Files.writeString(endpointFile, endpoints) + println("Android ECH test services ready on $targetHost") + + while (Files.exists(endpointFile)) { + Thread.sleep(250L) + } + } finally { + Files.deleteIfExists(endpointFile) + stop() + } + } + + private fun String.normalizedLoopback() = if (this == "localhost") "127.0.0.1" else this + + private fun Map.required(name: String) = + requireNotNull(this[name]) { "ECH fixture metadata does not contain $name" } +} diff --git a/container-tests/src/test/java/okhttp3/containers/AndroidTestService.kt b/container-tests/src/test/java/okhttp3/containers/AndroidTestService.kt new file mode 100644 index 000000000000..270e446e0bfc --- /dev/null +++ b/container-tests/src/test/java/okhttp3/containers/AndroidTestService.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2026 Block, Inc. + * + * 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.containers + +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.util.concurrent.atomic.AtomicBoolean +import okhttp3.containers.BasicMockServerTest.Companion.MOCKSERVER_IMAGE +import org.mockserver.client.MockServerClient +import org.mockserver.configuration.ConfigurationProperties +import org.mockserver.model.HttpRequest.request +import org.mockserver.model.HttpResponse.response +import org.testcontainers.containers.MockServerContainer + +/** A host-side Testcontainers service whose lifetime is controlled by the calling shell. */ +object AndroidTestService { + @JvmStatic + fun main(args: Array) { + require(args.isEmpty()) { "This service does not accept command-line arguments" } + + val endpointFile = + Path.of( + requireNotNull(System.getenv("ANDROID_TEST_SERVICE_ENDPOINT_FILE")) { + "ANDROID_TEST_SERVICE_ENDPOINT_FILE is not set" + }, + ) + val mockServer = + MockServerContainer(MOCKSERVER_IMAGE) + // The amd64 MockServer image starts under emulation on Apple Silicon when using Colima. + .withStartupTimeout(Duration.ofMinutes(5)) + val stopped = AtomicBoolean() + val stop = { + if (stopped.compareAndSet(false, true)) { + mockServer.stop() + } + } + + mockServer.start() + ConfigurationProperties.maxSocketTimeout(Duration.ofMinutes(2).toMillis()) + val mockServerClient = MockServerClient(mockServer.host, mockServer.serverPort) + Runtime.getRuntime().addShutdownHook(Thread({ stop() }, "android-test-service-shutdown")) + + try { + mockServerClient + .`when`(request().withMethod("GET").withPath("/android-test")) + .respond(response().withStatusCode(200).withBody("hello from Testcontainers")) + + val host = mockServer.host.let { if (it == "localhost") "127.0.0.1" else it } + val endpoint = "http://$host:${mockServer.serverPort}" + endpointFile.parent?.let { Files.createDirectories(it) } + Files.writeString(endpointFile, endpoint) + println("Android test service ready at $endpoint") + + while (Files.exists(endpointFile)) { + Thread.sleep(250L) + } + } finally { + Files.deleteIfExists(endpointFile) + mockServerClient.close() + stop() + } + } +} diff --git a/container-tests/src/test/resources/android-ech-fixture/Dockerfile b/container-tests/src/test/resources/android-ech-fixture/Dockerfile new file mode 100644 index 000000000000..312ef1334b22 --- /dev/null +++ b/container-tests/src/test/resources/android-ech-fixture/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.26-alpine AS build + +WORKDIR /src +COPY main.go . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /fixture main.go + +FROM alpine:3.23 +COPY --from=build /fixture /fixture +ENTRYPOINT ["/fixture"] diff --git a/container-tests/src/test/resources/android-ech-fixture/main.go b/container-tests/src/test/resources/android-ech-fixture/main.go new file mode 100644 index 000000000000..00bc88f15104 --- /dev/null +++ b/container-tests/src/test/resources/android-ech-fixture/main.go @@ -0,0 +1,361 @@ +package main + +import ( + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/binary" + "encoding/pem" + "fmt" + "io" + "log" + "math/big" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +const ( + greenName = "green.secret.test" + greenPublicName = "green.public.test" + retryName = "retry.secret.test" + retryPublicName = "retry.public.test" + disabledName = "disabled.secret.test" + disabledPublicName = "disabled.public.test" + dohName = "doh.test" +) + +type echKey struct { + config []byte + private []byte + retryable bool +} + +func main() { + if len(os.Args) != 2 { + log.Fatal("usage: fixture target|doh") + } + + switch os.Args[1] { + case "target": + runTarget() + case "doh": + runDoH() + default: + log.Fatalf("unknown mode %q", os.Args[1]) + } +} + +func runTarget() { + caCert, caKey, caPEM := newCA() + targetCertPEM, targetKeyPEM := newLeaf( + caCert, + caKey, + greenName, + greenPublicName, + retryName, + retryPublicName, + disabledName, + disabledPublicName, + ) + dohCertPEM, dohKeyPEM := newLeaf(caCert, caKey, dohName) + targetCertificate, err := tls.X509KeyPair(targetCertPEM, targetKeyPEM) + must(err) + + greenKey := newECHKey(1, greenPublicName, true) + retryStaleKey := newECHKey(2, retryPublicName, false) + retryKey := newECHKey(3, retryPublicName, true) + disabledStaleKey := newECHKey(4, disabledPublicName, false) + disabledKey := newECHKey(5, disabledPublicName, false) + + metadata := strings.Join([]string{ + "ECH_GREEN_CONFIG_LIST=" + base64.StdEncoding.EncodeToString(configList(greenKey)), + "ECH_RETRY_STALE_CONFIG_LIST=" + base64.StdEncoding.EncodeToString(configList(retryStaleKey)), + "ECH_DISABLED_STALE_CONFIG_LIST=" + base64.StdEncoding.EncodeToString(configList(disabledStaleKey)), + "DOH_CERT=" + base64.StdEncoding.EncodeToString(dohCertPEM), + "DOH_KEY=" + base64.StdEncoding.EncodeToString(dohKeyPEM), + "CA_CERT=" + base64.StdEncoding.EncodeToString(caPEM), + }, "\n") + "\n" + + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "ok") }) + mux.HandleFunc("/metadata", func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, metadata) }) + log.Fatal(http.ListenAndServe(":8080", mux)) + }() + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{targetCertificate}, + MinVersion: tls.VersionTLS13, + GetEncryptedClientHelloKeys: func(hello *tls.ClientHelloInfo) ([]tls.EncryptedClientHelloKey, error) { + var key echKey + switch hello.ServerName { + case greenPublicName: + key = greenKey + case retryPublicName: + key = retryKey + case disabledPublicName: + key = disabledKey + default: + return []tls.EncryptedClientHelloKey{}, nil + } + return []tls.EncryptedClientHelloKey{{ + Config: key.config, + PrivateKey: key.private, + SendAsRetry: key.retryable, + }}, nil + }, + } + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"echAccepted":%t,"serverName":%q}`, r.TLS.ECHAccepted, r.TLS.ServerName) + }) + server := &http.Server{Addr: ":8443", Handler: mux, TLSConfig: tlsConfig} + log.Fatal(server.ListenAndServeTLS("", "")) +} + +func runDoH() { + certPEM := decodeEnvironment("DOH_CERT") + keyPEM := decodeEnvironment("DOH_KEY") + echConfigs := map[string][]byte{ + greenName: decodeEnvironment("ECH_GREEN_CONFIG_LIST"), + retryName: decodeEnvironment("ECH_RETRY_STALE_CONFIG_LIST"), + disabledName: decodeEnvironment("ECH_DISABLED_STALE_CONFIG_LIST"), + } + targetPort, err := strconv.Atoi(requiredEnvironment("TARGET_PORT")) + must(err) + certificate, err := tls.X509KeyPair(certPEM, keyPEM) + must(err) + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "ok") }) + mux.HandleFunc("/dns-query", func(w http.ResponseWriter, r *http.Request) { + query, err := readDnsQuery(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + response, err := dnsResponse(query, echConfigs, targetPort) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/dns-message") + _, _ = w.Write(response) + }) + server := &http.Server{ + Addr: ":8053", + Handler: mux, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS13, + }, + } + log.Fatal(server.ListenAndServeTLS("", "")) +} + +func readDnsQuery(r *http.Request) ([]byte, error) { + if r.Method == http.MethodPost { + return io.ReadAll(io.LimitReader(r.Body, 65536)) + } + if r.Method == http.MethodGet { + encoded := r.URL.Query().Get("dns") + return base64.RawURLEncoding.DecodeString(encoded) + } + return nil, fmt.Errorf("unsupported method %s", r.Method) +} + +func dnsResponse(query []byte, echConfigs map[string][]byte, targetPort int) ([]byte, error) { + if len(query) < 17 { + return nil, fmt.Errorf("short DNS query") + } + questionEnd, questionName, err := dnsQuestion(query) + if err != nil { + return nil, err + } + qtype := binary.BigEndian.Uint16(query[questionEnd-4 : questionEnd-2]) + var rdata []byte + switch qtype { + case 1: // A + rdata = []byte{127, 0, 0, 1} + case 65: // HTTPS + echConfigList, ok := echConfigs[questionName] + if !ok { + return nil, fmt.Errorf("no ECH fixture for %q", questionName) + } + rdata = httpsRecord(echConfigList, targetPort) + case 28: // AAAA: a successful response with no answers. + default: + } + + answerCount := uint16(0) + if rdata != nil { + answerCount = 1 + } + response := make([]byte, 12) + copy(response[0:2], query[0:2]) + binary.BigEndian.PutUint16(response[2:4], 0x8180) + binary.BigEndian.PutUint16(response[4:6], 1) + binary.BigEndian.PutUint16(response[6:8], answerCount) + response = append(response, query[12:questionEnd]...) + if rdata == nil { + return response, nil + } + response = append(response, 0xc0, 0x0c) + response = appendUint16(response, qtype) + response = appendUint16(response, 1) + response = appendUint32(response, 60) + response = appendUint16Length(response, rdata) + return response, nil +} + +func dnsQuestion(query []byte) (int, string, error) { + position := 12 + var labels []string + for { + if position >= len(query) { + return 0, "", fmt.Errorf("invalid DNS name") + } + length := int(query[position]) + position++ + if length == 0 { + break + } + if length > 63 || position+length > len(query) { + return 0, "", fmt.Errorf("invalid DNS label") + } + labels = append(labels, string(query[position:position+length])) + position += length + } + if position+4 > len(query) { + return 0, "", fmt.Errorf("short DNS question") + } + return position + 4, strings.ToLower(strings.Join(labels, ".")), nil +} + +func httpsRecord(echConfigList []byte, targetPort int) []byte { + result := appendUint16(nil, 1) // SvcPriority. + result = append(result, 0) // TargetName is the owner name. + result = appendSvcParam(result, 1, []byte{2, 'h', '2', 8, 'h', 't', 't', 'p', '/', '1', '.', '1'}) + port := make([]byte, 2) + binary.BigEndian.PutUint16(port, uint16(targetPort)) + result = appendSvcParam(result, 3, port) + result = appendSvcParam(result, 4, []byte{127, 0, 0, 1}) + result = appendSvcParam(result, 5, echConfigList) + return result +} + +func appendSvcParam(dst []byte, key uint16, value []byte) []byte { + dst = appendUint16(dst, key) + return appendUint16Length(dst, value) +} + +func marshalECHConfig(id byte, publicKey []byte, publicName string, maxNameLength byte) []byte { + contents := []byte{id} + contents = appendUint16(contents, 0x0020) // DHKEM(X25519, HKDF-SHA256). + contents = appendUint16Length(contents, publicKey) + cipherSuites := appendUint16(nil, 0x0001) // HKDF-SHA256. + cipherSuites = appendUint16(cipherSuites, 0x0001) // AES-128-GCM. + contents = appendUint16Length(contents, cipherSuites) + contents = append(contents, maxNameLength, byte(len(publicName))) + contents = append(contents, publicName...) + contents = appendUint16(contents, 0) // Extensions. + + config := appendUint16(nil, 0xfe0d) + return appendUint16Length(config, contents) +} + +func newECHKey(id byte, publicName string, retryable bool) echKey { + privateKey, err := ecdh.X25519().GenerateKey(rand.Reader) + must(err) + return echKey{ + config: marshalECHConfig(id, privateKey.PublicKey().Bytes(), publicName, 64), + private: privateKey.Bytes(), + retryable: retryable, + } +} + +func configList(key echKey) []byte { + return appendUint16Length(nil, key.config) +} + +func appendUint16(dst []byte, value uint16) []byte { + return binary.BigEndian.AppendUint16(dst, value) +} + +func appendUint32(dst []byte, value uint32) []byte { + return binary.BigEndian.AppendUint32(dst, value) +} + +func appendUint16Length(dst, value []byte) []byte { + dst = appendUint16(dst, uint16(len(value))) + return append(dst, value...) +} + +func newCA() (*x509.Certificate, *ecdsa.PrivateKey, []byte) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + must(err) + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "OkHttp ECH Test CA"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + must(err) + certificate, err := x509.ParseCertificate(der) + must(err) + return certificate, key, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func newLeaf(ca *x509.Certificate, caKey *ecdsa.PrivateKey, names ...string) ([]byte, []byte) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + must(err) + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(now.UnixNano()), + Subject: pkix.Name{CommonName: names[0]}, + DNSNames: names, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, ca, &key.PublicKey, caKey) + must(err) + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + must(err) + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) +} + +func requiredEnvironment(name string) string { + value := os.Getenv(name) + if value == "" { + log.Fatalf("%s is not set", name) + } + return value +} + +func decodeEnvironment(name string) []byte { + value, err := base64.StdEncoding.DecodeString(requiredEnvironment(name)) + must(err) + return value +} + +func must(err error) { + if err != nil { + log.Fatal(err) + } +}