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
@@ -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()
}