diff --git a/quic/interop/Dockerfile b/quic/interop/Dockerfile new file mode 100644 index 000000000..21b225c86 --- /dev/null +++ b/quic/interop/Dockerfile @@ -0,0 +1,16 @@ +# quic-interop-runner endpoint image for :quic. +# +# Build (from repo root): +# ./gradlew :quic-interop:installDist +# docker build -t amethyst-quic-interop -f quic/interop/Dockerfile . +# +# Or run `make build` inside quic/interop/. +FROM martenseemann/quic-network-simulator-endpoint:latest + +RUN apt-get update \ + && apt-get install -y --no-install-recommends openjdk-21-jre-headless \ + && rm -rf /var/lib/apt/lists/* + +COPY quic/interop/build/install/quic-interop /opt/quic-interop +COPY quic/interop/run_endpoint.sh /run_endpoint.sh +RUN chmod +x /run_endpoint.sh /opt/quic-interop/bin/quic-interop diff --git a/quic/interop/Makefile b/quic/interop/Makefile new file mode 100644 index 000000000..b905c0f84 --- /dev/null +++ b/quic/interop/Makefile @@ -0,0 +1,39 @@ +# Local iteration loop for the quic-interop-runner endpoint image. +# +# make build # compile JVM dist + build Docker image +# make smoke # quick handshake test against a local picoquic +# make image-name # print the image tag (for use in implementations.json) +# +# These targets are deliberately thin wrappers — the real harness is the +# `quic-interop-runner` repo (clone separately and register `amethyst` with +# image=amethyst-quic-interop:latest, role=client). +IMAGE ?= amethyst-quic-interop:latest +REPO_ROOT := $(shell git rev-parse --show-toplevel) +DIST_DIR := build/install/quic-interop +PICOQUIC_PORT ?= 4433 + +.PHONY: build dist docker smoke image-name clean + +build: docker + +dist: + cd $(REPO_ROOT) && ./gradlew :quic-interop:installDist + +docker: dist + cd $(REPO_ROOT) && docker build -t $(IMAGE) -f quic/interop/Dockerfile . + +smoke: docker + @docker rm -f amethyst-quic-interop-smoke >/dev/null 2>&1 || true + docker run --rm --name amethyst-quic-interop-smoke \ + --network host \ + -e ROLE=client \ + -e TESTCASE=handshake \ + -e REQUESTS="https://127.0.0.1:$(PICOQUIC_PORT)/" \ + $(IMAGE) + +image-name: + @echo $(IMAGE) + +clean: + cd $(REPO_ROOT) && ./gradlew :quic-interop:clean + docker image rm -f $(IMAGE) 2>/dev/null || true diff --git a/quic/interop/build.gradle.kts b/quic/interop/build.gradle.kts new file mode 100644 index 000000000..839f2f5d6 --- /dev/null +++ b/quic/interop/build.gradle.kts @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.jetbrainsKotlinJvm) + application +} + +kotlin { + jvmToolchain(21) + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } +} + +sourceSets { + main { + kotlin.srcDir("src/main/kotlin") + } +} + +dependencies { + implementation(project(":quic")) + implementation(libs.kotlinx.coroutines.core) +} + +application { + mainClass.set("com.vitorpamplona.quic.interop.runner.InteropClientKt") + applicationName = "quic-interop" +} diff --git a/quic/interop/plans/2026-05-06-interop-runner.md b/quic/interop/plans/2026-05-06-interop-runner.md new file mode 100644 index 000000000..0dd0ac2f9 --- /dev/null +++ b/quic/interop/plans/2026-05-06-interop-runner.md @@ -0,0 +1,63 @@ +# quic-interop-runner endpoint — Phase 0 scaffolding + +Date: 2026-05-06 + +## Why + +We want the [`quic-interop-runner`](https://github.com/quic-interop/quic-interop-runner) +matrix as a **bug-finding harness**, not a vanity scoreboard. Each peer impl +(quiche, aioquic, picoquic, ngtcp2, msquic, mvfst, lsquic, neqo, kwik) enforces +different parts of RFC 9000/9001/9002/9114 strictly, so failures triangulate +to specific bugs in `:quic`. The runner's ns-3 sim also exposes loss / +reorder / migration scenarios that are awkward to reproduce in unit tests. + +## What's in Phase 0 + +- `:quic-interop` Gradle module (JVM-only application, registered at + `quic/interop/` via `settings.gradle`). +- `InteropClient.kt` reads the runner's env-var contract (`ROLE`, `TESTCASE`, + `REQUESTS`, …) and dispatches by testcase. Phase 0 implements only + `handshake`; everything else returns `127` (runner-skip). +- `Dockerfile` based on `martenseemann/quic-network-simulator-endpoint` + + OpenJDK 21 runtime, copies the `installDist` output. +- `run_endpoint.sh` sources the base image's `/setup.sh` then execs our JVM + binary. +- `Makefile` wrappers: `make build`, `make smoke`, `make clean`. + +## Local iteration loop + +``` +# In our repo: +make -C quic/interop build + +# In a sibling clone of quic-interop-runner, add to implementations.json: +"amethyst": { + "image": "amethyst-quic-interop:latest", + "url": "https://github.com/vitorpamplona/amethyst", + "role": "client" +} + +python run.py -d -i amethyst -s aioquic -t handshake --log-dir ./logs +``` + +Inspect `./logs//client_qlog/*.qlog` in qvis when something breaks. + +## Phase ladder (excerpt — full plan in conversation) + +| Phase | Goal | Tests | Exit criterion | +|---|---|---|---| +| 0 | Minimum harness | `handshake` | one test reproducible end-to-end ✅ | +| 1 | Triangulate handshake bugs | + `versionnegotiation`, `chacha20` | green vs aioquic + quiche + picoquic | +| 2 | Streams + loss + multiplexing | + `transfer`, `multiplexing`, `*loss`, `http3` | green vs aioquic + quiche; soak 500/500 | +| 3 | Edge cases | `retry`, `resumption`, `zerortt`, `keyupdate`, `rebinding-*`, `blackhole`, `amplificationlimit` | every test green or unsupported-127 with a written reason | +| 4 | CI gate | nightly Phases 1–2; PR-blocking subset on every push | qlogs uploaded as artifacts on red | + +## Known follow-ups (NOT in Phase 0) + +- `SSLKEYLOGFILE` and `QLOGDIR` are not yet wired through to `:quic` — the + runner will set them but our endpoint ignores them. Phase 1 must surface + TLS keys (so Wireshark can decrypt the sim's pcap captures) and qlog + output (so qvis is useful). +- Server role: we are client-first. Reassess after Phase 3. +- WebTransport is **not** part of the standard interop matrix; it needs a + separate harness against `moq-rs` / chrome-headless. diff --git a/quic/interop/run_endpoint.sh b/quic/interop/run_endpoint.sh new file mode 100755 index 000000000..fdbcf2146 --- /dev/null +++ b/quic/interop/run_endpoint.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# quic-interop-runner endpoint entry point. +# +# The base image (martenseemann/quic-network-simulator-endpoint) sets up +# routing in /setup.sh; we source it then dispatch to our JVM client based +# on the ROLE env var pushed in by the runner. +set -euo pipefail + +# shellcheck disable=SC1091 +source /setup.sh + +case "${ROLE:-client}" in + client) + exec /opt/quic-interop/bin/quic-interop + ;; + server) + # Phase 0: server role unimplemented. Exit 127 so the runner skips. + echo "server role not implemented" >&2 + exit 127 + ;; + *) + echo "unknown ROLE=${ROLE}" >&2 + exit 127 + ;; +esac diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt new file mode 100644 index 000000000..b9b1a0504 --- /dev/null +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quic.interop.runner + +import com.vitorpamplona.quic.connection.QuicConnection +import com.vitorpamplona.quic.connection.QuicConnectionConfig +import com.vitorpamplona.quic.connection.QuicConnectionDriver +import com.vitorpamplona.quic.tls.PermissiveCertificateValidator +import com.vitorpamplona.quic.transport.UdpSocket +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.net.URI +import kotlin.system.exitProcess + +/** + * Endpoint that implements the [quic-interop-runner](https://github.com/quic-interop/quic-interop-runner) + * Docker contract. Reads the env-var protocol the runner pushes into each + * client container and dispatches by [TESTCASE]. + * + * Exit codes: + * - `0` — testcase passed + * - `1` — testcase failed (bug in our impl, OR partner) + * - `127` — testcase not implemented (runner skips, doesn't fail) + */ +private const val EXIT_OK = 0 +private const val EXIT_FAIL = 1 +private const val EXIT_UNSUPPORTED = 127 + +private const val HANDSHAKE_TIMEOUT_SEC = 10L + +fun main() { + val role = System.getenv("ROLE") ?: "client" + if (role != "client") { + System.err.println("server role not implemented") + exitProcess(EXIT_UNSUPPORTED) + } + + val testcase = System.getenv("TESTCASE")?.trim().orEmpty() + val requests = System.getenv("REQUESTS")?.trim().orEmpty() + + System.err.println("== quic-interop client ==") + System.err.println("testcase: $testcase") + System.err.println("requests: $requests") + + val code = + when (testcase) { + "handshake" -> runHandshakeTest(requests) + else -> EXIT_UNSUPPORTED + } + exitProcess(code) +} + +private fun runHandshakeTest(requests: String): Int { + val target = + parseFirstTarget(requests) ?: run { + System.err.println("no parseable target in REQUESTS") + return EXIT_FAIL + } + val (host, port) = target + System.err.println("handshake target: $host:$port") + + val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + val outcome = + runBlocking { + val socket = + try { + UdpSocket.connect(host, port) + } catch (t: Throwable) { + return@runBlocking "udp_failed: ${t.message ?: t::class.simpleName}" + } + val conn = + QuicConnection( + serverName = host, + config = QuicConnectionConfig(), + tlsCertificateValidator = PermissiveCertificateValidator(), + ) + val driver = QuicConnectionDriver(conn, socket, scope) + driver.start() + + val handshake = + withTimeoutOrNull(HANDSHAKE_TIMEOUT_SEC * 1_000L) { + runCatching { conn.awaitHandshake() } + } + val result = + when { + handshake == null -> "timeout" + handshake.isSuccess -> "ok" + else -> "failed: ${handshake.exceptionOrNull()?.message ?: "?"}" + } + runCatching { driver.close() } + delay(50) + result + } + scope.cancel() + + return if (outcome == "ok") { + System.err.println("handshake ok") + EXIT_OK + } else { + System.err.println("handshake $outcome") + EXIT_FAIL + } +} + +private fun parseFirstTarget(requests: String): Pair? { + val first = requests.split(Regex("\\s+")).firstOrNull { it.isNotBlank() } ?: return null + val uri = + try { + URI(first) + } catch (_: Throwable) { + return null + } + val host = uri.host ?: return null + val port = uri.port.takeIf { it > 0 } ?: 443 + return host to port +} diff --git a/settings.gradle b/settings.gradle index 38a632ba1..df2b7aaf1 100644 --- a/settings.gradle +++ b/settings.gradle @@ -40,3 +40,5 @@ include ':quic' include ':nestsClient' include ':desktopApp' include ':cli' +include ':quic-interop' +project(':quic-interop').projectDir = file('quic/interop')