/quiche4j

QUIC transport protocol and HTTP/3 for Java

Primary LanguageJavaBSD 2-Clause "Simplified" LicenseBSD-2-Clause

Quiche4j

Java implementation of the QUIC transport protocol and HTTP/3.

The library provides thin Java API layer on top of JNI calls to quiche. Quiche4j provides a low level API for processing QUIC packets and handling connection state. The application is responsible for providing I/O (e.g. sockets handling) as well as timers. The library itself does not make any assumptions on how I/O layer is organized, making it's pluggle into different architectures.

The main goal of the JNI bindings is to ensure high-performance and flexibility for the application developers while maintaining full access to quiche library features. Specifically, the bindings layer tries to ensure zero-copy data trasfer between runtimes where possible and perform minimum allocations on Java side.

Usage

Maven:

<dependencies>
    <dependency>
        <groupId>io.quiche4j</groupId>
        <artifactId>quiche4j-core</artifactId>
        <version>0.2.5</version>
    </dependency>
    <dependency>
        <groupId>io.quiche4j</groupId>
        <artifactId>quiche4j-jni</artifactId>
        <classifier>linux_x64_86</classifier>
        <version>0.2.5</version>
    </dependency>
</dependencies>

Note that quiche4j-jni contains native library and should be installed with proper classifier. os-maven-plugin could be used to simplify classifier detection

<build>
    <extensions>
        <extension>
            <groupId>kr.motd.maven</groupId>
            <artifactId>os-maven-plugin</artifactId>
            <version>1.6.1</version>
        </extension>
    </extensions>
</build>
<dependencies>
    <dependency>
        <groupId>io.quiche4j</groupId>
        <artifactId>quiche4j-jni</artifactId>
        <classifier>${os.detected.classifier}</classifier>
        <version>0.2.5</version>
    </dependency>
</dependencies>

Building

Quiche4j requires cargo and Rust 1.39+ to build. The latest stable Rust release can be installed using rustup. Once the Rust build environment is setup,

$ git clone https://github.com/kachayev/quiche4j
$ mvn clean install

Run Examples

Run HTTP3 client example:

$ ./http3-client.sh https://quic.tech:8443
> sending request to https://quic.tech:8443
> handshake size: 1200
> socket.recieve 167 bytes
> conn.recv 167 bytes
...
! conn is closed recv=10 sent=12 lost=0 rtt=95 cwnd=14520 delivery_rate=1436

Run HTTP3 server example:

$ ./http3-server.sh :4433
! listening on localhost:4433

Compile Manually

Maven project is setup to automatically compile JNI library and include the result of the compilation into the quiche4j-jni JAR. Even thought this method is convenient for distribution, it might lack flexibility. To compile JNI manually follow the next steps,

$ git clone https://github.com/kachayev/quiche4j
$ cargo build --release --manifest-path quiche4j-jni/Cargo.toml
$ mvn clean install
$ java \
    -Djava.library.path=quiche4j-jni/target/release/ \
    -cp quiche4j-examples/target/quiche4j-examples-*.jar \
    io.quiche4j.examples.Http3Server

The code would try to load native libraries from java.library.path first, using built-in artifact as a fallback only.

For cross-compilation options, see cargo build documentation.

API

Connection

Before establishing a QUIC connection, you need to create a configuration object:

import io.quiche4j.Config;
import io.quiche4j.ConfigBuilder;

final Config config = new ConfigBuilder(Quiche.PROTOCOL_VERSION).build();

On the client-side the Quiche.connect utility function can be used to create a new connection, while Quiche.accept is for servers:

// client
final byte[] connId = Quiche.newConnectionId();
// note, that "quic.tech" here is not used for establishing network
// connection. it's used only for peer verification (thus, optional)
final Connection conn = Quiche.connect("quic.tech", connId, config);

// server
final Connection conn = Quiche.accept(sourceConnId, originalDestinationId, config);

Incoming packets

Using the connection's recv method the application can process incoming packets that belong to that connection from the network:

final byte[] buf = new byte[1350];
while(true) {
    DatagramPacket packet = new DatagramPacket(buf, buf.length);
    try {
        // read from the socket
        socket.receive(packet);
        final byte[] buffer = Arrays.copyOfRange(packet.getData(), packet.getOffset(), packet.getLength());
        // update the connection state
        final int read = conn.recv(buffer);
        if(read <= 0) break;
    } catch (SocketTimeoutException e) {
        conn.onTimeout();
        break;
    }
}

Outgoing packets

Outgoing packet are generated using the connection's send method instead:

final byte[] buf = new byte[1350];
while(true) {
    // get data that's need to be sent based on the connection state
    final int len = conn.send(buf);
    if (len <= 0) break;
    final DatagramPacket packet = new DatagramPacket(buf, len, address, port);
    // send it to the network
    socket.send(packet);
}

Timers

The application is responsible for maintaining a timer to react to time-based connection events. When a timer expires, the connection's onTimeout method should be called, after which additional packets might need to be sent on the network:

// handle timer
conn.onTimeout();

// sending corresponding packets
final byte[] buf = new byte[1350];
while(true) {
    final int len = conn.send(buf);
    if (len <= 0) break;
    final DatagramPacket packet = new DatagramPacket(buf, len, address, port);
    socket.send(packet);
}

Streams Data

After some back and forth, the connection will complete its handshake and will be ready for sending or receiving application data.

Data can be sent on a stream by using the streamSend method:

if(conn.isEstablished()) {
    // handshake completed, send some data on stream 0
    conn.streamSend(0, "hello".getBytes(), true);
}

The application can check whether there are any readable streams by using the connection's readable method, which returns an iterator over all the streams that have outstanding data to read.

The streamRecv method can then be used to retrieve the application data from the readable stream:

if(conn.isEstablished()) {
    final byte[] buf = new byte[1350]; 
    for(long streamId: conn.readable()) {
        // stream <streamId> is readable, read until there's no more data
        while(true) {
            final int len = conn.streamRecv(streamId, buf);
            if(len <= 0) break;
        }
    }
}

HTTP/3

The library provides a high level API for sending and receiving HTTP/3 requests and responses on top of the QUIC transport protocol.

Connection

HTTP/3 connections require a QUIC transport-layer connection, see "Connection" for a full description of the setup process. To use HTTP/3, the QUIC connection must be configured with a suitable ALPN Protocol ID:

import io.quiche4j.Config;
import io.quiche4j.ConfigBuilder;
import io.quiche4j.http3.Http3Connection;

final Config config = new ConfigBuilder(Quiche.PROTOCOL_VERSION)
    .withApplicationProtos(Http3.APPLICATION_PROTOCOL)
    .build();

The QUIC handshake is driven by sending and receiving QUIC packets. Once the handshake has completed, the first step in establishing an HTTP/3 connection is creating its configuration object:

import io.quiche4j.http3.Http3Config;
import io.quiche4j.http3.Http3ConfigBuilder;

final Http3Config h3Config = new Http3ConfigBuilder().build();

HTTP/3 client and server connections are both created using the Http3Connection.withTransport function:

import io.quiche4j.http3.Http3Connection;

final Http3Connection h3Conn = Http3Connection.withTransport(conn, h3Config);

Sending Request

An HTTP/3 client can send a request by using the connection's sendRequest method to queue request headers; sending QUIC packets causes the requests to get sent to the peer:

import io.quiche4j.http3.Http3Header;

List<Http3Header> req = new ArrayList<>();
req.add(new Http3Header(":method", "GET"));
req.add(new Http3Header(":scheme", "https"));
req.add(new Http3Header(":authority", "quic.tech"));
req.add(new Http3Header(":path", "/"));
req.add(new Http3Header("user-agent", "Quiche4j"));
h3Conn.sendRequest(req, true);

An HTTP/3 client can send a request with additional body data by using the connection's sendBody method:

final long streamId = h3Conn.sendRequest(req, false);
h3Conn.sendBody(streamId, "Hello there!".getBytes(), true);

Handling Responses

After receiving QUIC packets, HTTP/3 data is processed using the connection's poll method.

An HTTP/3 server uses poll to read requests and responds to them, an HTTP/3 client uses poll to read responses. poll method accepts object that implements Http3EventListener interface defining callbacks for different type of events

import io.quiche4j.http3.Http3EventListener;
import io.quiche4j.http3.Http3Header;

final long streamId = h3Conn.poll(new Http3EventListener() {
    public void onHeaders(long streamId, List<Http3Header> headers) {
        // got headers
    }

    public void onData(long streamId) {
        // got body
        final byte[] body = new byte[MAX_DATAGRAM_SIZE];
        final int len = h3Conn.recvBody(streamId, body);
    }

    public void onFinished(long streamId) {
        // done with this stream
        conn.close(true, 0x00, "Bye! :)".getBytes()));
    }
});

if(Quiche.ErrorCode.DONE == streamId) {
    // this means no event was emitted
    // it would take more packets to proceed with new events
}

Note that poll would either execute callbacks and returns immediately. If there's not enough data to fire any of the events, poll immediately returns Quiche.ErrorCode.DONE. The application is responsible for handling incoming packets from the network and feeding packets data into connection before executing next poll.

Examples

Have a look at the quiche4j-examples folder for more complete examples on how to use the Quiche4j API to work with HTTP/3 protocol.

Examples package has Http3NettyClient with a toy implementation of HTTP/3 client to show case the idea of how quiche4j connection state management could be integrated with Netty I/O primitives.

Errors Hanlding

Native JNI code propagates errors using return codes (typically the return code < 0 means either DONE or failed). For example, quiche::Error enum. Quiche4j follows the same convention instead of throwing Java exceptions to ensure good perfomance and compatibility with async runtimes (catching exception in async environemnt might be somewhat problematic). See Quiche.ErrorCode and Http3.ErrorCode for more details.

Unlike other methods, Quiche.connect and Quiche.accept throw ConnectionFailureException if JNI code failed before quiche::Connection struct had been allocated. In this case there's no pointer to carry around, thus Java code does not create Connection object.

Debug

Use QUICHEJ4_JNI_LOG environment variable to tweak JNI log level. Setting variable to trace gives good visibility into the processing. Example

$ QUICHE4J_JNI_LOG=trace ./http3-client.sh https://quic.tech:8443
...
[2020-09-27T20:49:39Z TRACE quiche] 3457285232348874d2bda1ed5add4a0c894dc9f2 rx pkt Handshake version=ff00001d dcid=3457285232348874d2bda1ed5add4a0c894dc9f2 scid=1b48925e8fcf6281be7f5ca472dd44b71a2f2fc1 len=731 pn=2
[2020-09-27T20:49:39Z TRACE quiche] 3457285232348874d2bda1ed5add4a0c894dc9f2 rx frm CRYPTO off=2252 len=709
[2020-09-27T20:49:39Z TRACE quiche::tls] 3457285232348874d2bda1ed5add4a0c894dc9f2 write message lvl=Handshake len=36
[2020-09-27T20:49:39Z TRACE quiche::tls] 3457285232348874d2bda1ed5add4a0c894dc9f2 set write secret lvl=OneRTT
[2020-09-27T20:49:39Z TRACE quiche::tls] 3457285232348874d2bda1ed5add4a0c894dc9f2 set read secret lvl=OneRTT
[2020-09-27T20:49:39Z TRACE quiche] 3457285232348874d2bda1ed5add4a0c894dc9f2 connection established: proto=Ok("h3-29") cipher=Some(AES128_GCM) curve=Some("X25519") sigalg=Some("rsa_pss_rsae_sha256") resumed=false TransportParams { original_destination_connection_id: Some([121, 203, 4, 8, 44, 253, 150, 111, 224, 200, 201, 105, 201, 162, 250, 160]), max_idle_timeout: 30000, stateless_reset_token: None, max_udp_payload_size: 1350, initial_max_data: 10000000, initial_max_stream_data_bidi_local: 1000000, initial_max_stream_data_bidi_remote: 1000000, initial_max_stream_data_uni: 1000000, initial_max_streams_bidi: 100, initial_max_streams_uni: 100, ack_delay_exponent: 3, max_ack_delay: 25, disable_active_migration: true, active_conn_id_limit: 2, initial_source_connection_id: Some([27, 72, 146, 94, 143, 207, 98, 129, 190, 127, 92, 164, 114, 221, 68, 183, 26, 47, 47, 193]), retry_source_connection_id: None }
...

Implementation Details

  • Modules Native.java and Http3Native.java contains definition of all native calls, structurally close to quiche's src/ffi.rs and src/h3/ffi.rs respectively.

  • JNI calls are implmeneted in Rust (see quiche4j-jni for more details) using rust-jni library. The goal was to stick to primitive types as much as possible and avoid Java objects manipulations in native code. There are still a few exceptions from this rule, e.g. operations with connection Stats, management of Http3Header lists, etc.

  • Proxy Java objects maintain a handle (pointer) to the corresponding Rust struct to maximise compatability with all quiche features. A single instance of a Cleaner is statically defined in io.quiche4j.Native class and is used to register all deallocation callback (conventionally called free for each class that maintains a native pointer).

Contribute

  • Check for open issues or open a fresh issue to start a discussion around a feature idea or a bug (also, check out "TODO" section of this document).
  • Fork the repository on Github & fork master to feature-* branch to start making your changes.
  • Write a test which shows that the bug was fixed or that the feature works as expected.

or simply...

  • Use it.
  • Enjoy it.
  • Spread the word.

TODO

There are still a few xxx comments in the code. Both for Java and for Rust. Plus, there are a few methods that are not exposed to Java layer. Notably, operations with stream priorities and HTTP/3 connection configuration (some of those would require to extend quiche library as well).

Other ideas to work on:

  • Propagate Rust panics into Java exceptions (when necessary)
  • Setup integration testing suite against different QUIC implementations out there
  • Qlog support
  • Experiment with in-memory serialization (Arrow?) to deal with (presumably) high overhead of manipulating objects in native code

Copyright

Copyright (C) 2020, Oleksii Kachaiev.

See COPYING for the license.

See cloudflare/quiche/copying for Quiche license.