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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
Executable
+41
@@ -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
|
||||
+48
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user