feat(quic): live interop harness — picoquic Docker + InteropRunner main

Setup for driving the pure-Kotlin QUIC client against real reference
servers, kicking off the live-interop validation that closes the loop on
the audit cycles.

PermissiveCertificateValidator (commonMain test-only):
  Accepts any cert chain and any signature. For local dev servers (picoquic,
  quic-go, nests-rs in Docker) where the system trust store would reject the
  self-signed cert. Documented as test-only — must never be wired into
  production code.

InteropRunner.main (jvmTest):
  Standalone runnable that opens a UDP socket, builds a QuicConnection with
  PermissiveCertificateValidator, drives the handshake under a configurable
  timeout, and reports CONNECTED / HandshakeFailed / Timeout / UdpFailed
  with peer transport parameters on success. Exit code 0 on connect, 1 on
  any failure mode — wirable into CI.

`quic/scripts/run-picoquic.sh`:
  One-line Docker harness that runs Christian Huitema's picoquic reference
  server on UDP 4433. Picoquic is the IETF QUIC WG's reference impl and
  the most permissive target for a fresh client (clear qlog traces, accepts
  a wide range of transport params).

`./gradlew :quic:interop` Gradle task:
  Wraps the runner so live-interop is one command:
    ./gradlew :quic:interop -PinteropHost=127.0.0.1 -PinteropPort=4433
  Validated against a non-running server: returns Timeout cleanly with
  exit 1 (the failure-path proves the harness wires correctly).

Workflow documented in `quic/scripts/README.md` covering picoquic,
quic-go, aioquic, quiche, and the eventual graduation path to the
quic-interop-runner Docker matrix at https://interop.seemann.io.

Next step: actually run picoquic in Docker and chase whatever real-world
incompatibilities surface. Each will be a focused fix commit.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 23:08:18 +00:00
parent bd192fc649
commit d2a10c9e4a
5 changed files with 340 additions and 0 deletions
+25
View File
@@ -99,3 +99,28 @@ kotlin {
}
}
}
/**
* Run the live-interop runner against a real QUIC server. Configure target
* via -PinteropHost=… -PinteropPort=… -PinteropTimeoutSec=…
*
* Usage:
* ./gradlew :quic:interop -PinteropHost=127.0.0.1 -PinteropPort=4433
*/
tasks.register<JavaExec>("interop") {
group = "verification"
description = "Drive QuicConnection against a real QUIC server (default 127.0.0.1:4433)."
dependsOn("jvmTestClasses", "jvmJar")
classpath =
files(
tasks.named("jvmJar"),
configurations.named("jvmTestRuntimeClasspath"),
layout.buildDirectory.dir("classes/kotlin/jvm/test"),
)
mainClass.set("com.vitorpamplona.quic.interop.InteropRunnerKt")
val host = (project.findProperty("interopHost") as? String) ?: "127.0.0.1"
val port = (project.findProperty("interopPort") as? String) ?: "4433"
val timeoutSec = (project.findProperty("interopTimeoutSec") as? String) ?: "10"
args(host, port)
systemProperty("interopTimeoutSec", timeoutSec)
}
+70
View File
@@ -0,0 +1,70 @@
# `:quic` interop harness
Scripts and test entry points for driving the pure-Kotlin QUIC client against
real reference servers.
## Quickstart — picoquic
```bash
# Terminal 1: run the picoquic reference server in Docker.
quic/scripts/run-picoquic.sh -d
# Terminal 2: drive our client at it.
./gradlew :quic:jvmTestClasses
java -cp "$(./gradlew -q :quic:printTestRuntimeClasspath)" \
com.vitorpamplona.quic.interop.InteropRunnerKt 127.0.0.1 4433
```
Expected output:
```
== :quic interop runner ==
target: 127.0.0.1:4433
timeout: 10s
✓ HANDSHAKE COMPLETE
status: CONNECTED
negotiated ALPN: h3
peer transport params: max_data=…, max_streams_bidi=…, …
```
## What this proves
- TCP-equivalent UDP connection setup
- QUIC v1 Initial / Handshake / 1-RTT packet flow with RFC-correct PADDING
- TLS 1.3 over QUIC with the SHA-256 cipher suites
- ALPN `h3` negotiation
- QUIC transport parameters round-trip
- Header protection (AES-ECB)
- AEAD payload protection (AES-128-GCM)
It does NOT yet prove WebTransport / MoQ — picoquic doesn't speak WT.
## Live nests interop
For a full WebTransport + MoQ test against the actual Nostr nests server, the
target is `nostrnests.com:443`:
```bash
java -cp "..." com.vitorpamplona.quic.interop.InteropRunnerKt nostrnests.com 443
```
This goes against the real CA-signed cert, so revert to the default
`JdkCertificateValidator` (the InteropRunner currently uses
`PermissiveCertificateValidator` for self-signed dev servers — change the
constant before pointing at production).
## Other reference servers worth trying
| Server | Image | Notes |
|---|---|---|
| picoquic | `privateoctopus/picoquic` | Most permissive; clear qlog traces |
| quic-go | `martenseemann/quic-go-interop` | Stable, widest scenario coverage |
| aioquic | `aiortc/aioquic` | Easy to debug, Python reference |
| quiche | `cloudflare/quiche` | Production-grade, strict |
| nests-rs | (local cargo build) | The actual MoQ relay; needs WebTransport |
The IETF's [`quic-interop-runner`](https://github.com/quic-interop/quic-interop-runner)
exposes all of these via a single Docker matrix. Wrapping our client in its
container contract (`TESTCASE` env, `REQUESTS=` URL list, `/certs` mount) would
let us join the public matrix at https://interop.seemann.io.
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Spin up Christian Huitema's picoquic reference server on UDP 4433.
# Picoquic is the IETF QUIC WG's reference impl and the most permissive
# server for a brand-new client to interop against — it logs detailed
# qlog traces and accepts a wide range of transport parameters.
#
# Usage:
# quic/scripts/run-picoquic.sh # foreground, ^C to stop
# quic/scripts/run-picoquic.sh -d # detached
#
# Then in another terminal, drive our client:
# ./gradlew :quic:jvmTest --tests '*InteropRunner*' \
# -DinteropHost=127.0.0.1 -DinteropPort=4433
#
# Or compile + run the runner main directly:
# ./gradlew :quic:jvmTestClasses
# java -cp 'quic/build/classes/kotlin/jvm/{main,test}:…' \
# com.vitorpamplona.quic.interop.InteropRunnerKt 127.0.0.1 4433
set -euo pipefail
DETACH=${1:-}
IMAGE="privateoctopus/picoquic:latest"
CONTAINER="amethyst-picoquic-interop"
echo "Pulling $IMAGE..."
docker pull "$IMAGE" >/dev/null
# Stop any prior instance so re-runs are idempotent.
docker rm -f "$CONTAINER" 2>/dev/null || true
if [ "$DETACH" = "-d" ]; then
docker run -d --name "$CONTAINER" -p 4433:4433/udp "$IMAGE" \
picoquicdemo -p 4433
echo "picoquic started (container=$CONTAINER) on UDP 4433"
echo "Stop with: docker rm -f $CONTAINER"
else
exec docker run --rm --name "$CONTAINER" -p 4433:4433/udp "$IMAGE" \
picoquicdemo -p 4433
fi
@@ -0,0 +1,48 @@
/*
* 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.tls
/**
* Test-only certificate validator that accepts every chain and every
* signature algorithm. Useful for connecting to local development servers
* with self-signed certificates (picoquic, nests-rs, quic-go's `interop`
* server, etc.) where the system trust store would reject the cert.
*
* **NEVER** wire this into production code. The whole point of the
* required-validator design is that the type system catches misuse — pass
* this only from explicit test entry points.
*/
class PermissiveCertificateValidator : CertificateValidator {
override fun validateChain(
chain: List<ByteArray>,
expectedHost: String,
) {
// Accept anything. No-op.
}
override fun verifySignature(
signatureAlgorithm: Int,
signature: ByteArray,
transcriptHash: ByteArray,
) {
// Accept anything. No-op.
}
}
@@ -0,0 +1,156 @@
/*
* 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
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.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
/**
* Standalone interop runner. Drives [QuicConnection] against a real QUIC
* server (picoquic, quic-go, quiche, nests-rs, etc.) and reports whether
* the handshake completes.
*
* Run via:
*
* ./gradlew :quic:jvmTest --tests '*InteropRunner*' \
* -DinteropHost=localhost -DinteropPort=4433
*
* Or invoke main() directly from an IDE.
*
* The runner uses [PermissiveCertificateValidator] — pointing this at a
* production server is a security misconfiguration; it's intentionally
* test-only.
*/
fun main(args: Array<String>) {
val host = System.getProperty("interopHost") ?: args.getOrNull(0) ?: "127.0.0.1"
val port = (System.getProperty("interopPort") ?: args.getOrNull(1) ?: "4433").toInt()
val timeoutSec = (System.getProperty("interopTimeoutSec") ?: "10").toLong()
println("== :quic interop runner ==")
println("target: $host:$port")
println("timeout: ${timeoutSec}s")
println()
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val outcome =
runBlocking {
val socket =
try {
UdpSocket.connect(host, port)
} catch (t: Throwable) {
return@runBlocking InteropOutcome.UdpFailed(t.message ?: t::class.simpleName ?: "?")
}
val conn =
QuicConnection(
serverName = host,
config = QuicConnectionConfig(),
tlsCertificateValidator = PermissiveCertificateValidator(),
)
val driver = QuicConnectionDriver(conn, socket, scope)
driver.start()
val handshakeResult =
withTimeoutOrNull(timeoutSec * 1_000L) {
runCatching { conn.awaitHandshake() }
}
val outcome =
when {
handshakeResult == null -> {
InteropOutcome.Timeout
}
handshakeResult.isSuccess -> {
InteropOutcome.Connected(conn)
}
else -> {
InteropOutcome.HandshakeFailed(
handshakeResult.exceptionOrNull()?.message ?: "?",
)
}
}
try {
driver.close()
} catch (_: Throwable) {
}
outcome
}
when (outcome) {
is InteropOutcome.Connected -> {
println("✓ HANDSHAKE COMPLETE")
println(" status: ${outcome.conn.status}")
println(" negotiated ALPN: ${outcome.conn.tls.negotiatedAlpn?.decodeToString()}")
println(" peer transport params: ${outcome.conn.peerTransportParameters?.let { tpSummary(it) } ?: "(none)"}")
}
is InteropOutcome.HandshakeFailed -> {
println("✗ HANDSHAKE FAILED")
println(" reason: ${outcome.reason}")
kotlin.system.exitProcess(1)
}
InteropOutcome.Timeout -> {
println("✗ TIMED OUT after ${timeoutSec}s")
kotlin.system.exitProcess(1)
}
is InteropOutcome.UdpFailed -> {
println("✗ UDP CONNECT FAILED")
println(" reason: ${outcome.reason}")
kotlin.system.exitProcess(1)
}
}
}
private fun tpSummary(tp: com.vitorpamplona.quic.connection.TransportParameters): String =
listOfNotNull(
tp.initialMaxData?.let { "max_data=$it" },
tp.initialMaxStreamsBidi?.let { "max_streams_bidi=$it" },
tp.initialMaxStreamsUni?.let { "max_streams_uni=$it" },
tp.maxIdleTimeoutMillis?.let { "idle_timeout=${it}ms" },
tp.maxDatagramFrameSize?.let { "max_datagram=$it" },
).joinToString(", ")
private sealed class InteropOutcome {
data class Connected(
val conn: QuicConnection,
) : InteropOutcome()
data class HandshakeFailed(
val reason: String,
) : InteropOutcome()
data class UdpFailed(
val reason: String,
) : InteropOutcome()
object Timeout : InteropOutcome()
}