The imquic library is structured to try and keep its different features mostly separated in different files, for better understanding of the internals and a cleaner separation of responsibilities. At the moment, the code is mainly structured like this:
STREAM
abstraction, that keeps a chunk-based buffer that can be added to in any order, and/or popped from in an ordered way (stream.c and stream.h);All this is tied by an event loop (loop.c and loop.h), and relies on a few utilities, namely:
CRYPTO
and STREAM
(buffer.c and buffer.h);On top of the raw QUIC stack, other parts of the code deal with a more application level overlay, specifically:
CONNECT
request), by leveraging a custom QPACK stack (qpack.c, qpack.h and huffman.h);The library needs a one-time initialization, to initialize different parts of its stack. In order to do that it relies on an atomic initialized
property that refers to values defined in imquic_init_state to figure out what the initialization state is.
The initialization method simply calls, in sequence, imquic_quic_init and imquic_tls_init for the core, and imquic_moq_init and imquic_roq_init to initialize the native support for MoQ and RoQ. To conclude, it uses imquic_loop_init to initialize the event loop.
The library currently only logs to stdout, and can log messages with different levels of debugging. Specifically, when a debugging level is configured in the application, only messages that have an associated level lower than the configured level will be displayed on the logs. By default, this value is IMQUIC_LOG_VERB
but a different level can be configured at any given time via imquic_set_log_level.
The versioning information is in a dynamically generated version.c file, that is filled in at compile time out of the configuration and building process. It is then exposed to the library via some extern
variables defined in version.h.
Most resources that this documentation covers involve mutexes for thread-safeness, and reference counting to keep track of memory usage in order to avoid race conditions or use-after-free uses.
The event loop is basically a GLib implementation, so built on top of GMainContext
and GMainLoop
. At the moment, this consists in a single loop running on a dedicated thread, that's launched at initialization time. Plans for the future include refactoring this part, so that we can, e.g., have either different loops per endpoint, or possibly a configurable number of loops/threads each responsible for a higher number of endpoints.
There's mainly two sources that can be attached to this event loop:
There's also a separate source for timed firing of callbacks, using imquic_loop_add_timer, but at the moment that's only used to trigger the automated and regular delivery of keep-alives via PING
frames.
Each imquic_network_source is associated to a specific endpoint in the library (a client or a server), and so to a imquic_network_endpoint (more on that later). When incoming traffic is detected on such a source, the loop passes that to the imquic_process_message function in the QUIC stack, along to a reference to the endpoint. As we'll see later, this may get the stack to detect and create a new connection, that will be handled within the context of the library.
When a connection is created by the QUIC stack (more on that later too), it's added to the loop as a source via the above mentioned imquic_connection_source instance, whose purpose is just to reference the connection in the loop. More precisely, any time the QUIC stack wants something done on a connection, it updates some internal properties, and then sets the wakeup
atomic to signal the loop it wants it handled. In order to ensure the loop sees in in a timely fashion, imquic_loop_wakeup is called too.
As we've seen in the intro to this documentation, the QUIC stack is actually made of different moving parts that work together. The next subsections will cover each of those in more detail, starting from the concept of endpoint in the library, and then moving to connections, processing of QUIC messages and how messages are crafted and sent back.
As a QUIC library, imquic obviously starts from the concept of endpoint, and as such on whether a user wants to create a QUIC server or a QUIC client, which will have different requirements in terms of how they're configured and then managed.
The public API documentation explains how these endpoints are created from the perspective of someone using the library. Internally, both clients and servers are represented by the same abstraction, called imquic_network_endpoint, which represents an endpoint in the library capable of sending and receiving messages, independently of whether it's a server or client. This abstraction obviously mantains different pieces of information associated to the role of the endpoint and how it's configured.
Specifically, creating an endpoint starts from a imquic_configuration object, that the public API code fills in according to what was passed by the user. This configuration object is passed to imquic_network_endpoint_create and returns a imquic_network_endpoint instance. Before this resource becomes "operative" it must first be added to the loop in order to be monitored, which as we've seen is done with a call to imquic_loop_poll_endpoint. For servers, that's enough, because a server will wait for incoming connection attempts and react to them. For new clients, imquic_start_quic_client must be called too, in order to initiate the steps to send QUIC messages to establish a new connection.
The library uses the imquic_connection structure as an abstraction for a connection: this identifies a specific connection an imquic endpoint is part of. For clients, an imquic_connection instance is automatically created when imquic_start_quic_client is called, since that's when an attempt to establish a connection is performed. For servers, an instance is instead created when an endpoint receives a packet, and the call to imquic_parse_packet identifies the message as coming from a new connection.
When a connection is created via a call to imquic_connection_create, it is initialized and mapped to the imquic_network_endpoint that originated it (which is used for sending and receiving messages on that connection). In case a WebTransport must be established on the connection, a imquic_http3_connection instance is created as well (more on that later). Finally, the connection is added to the loop via the already mentioned imquic_loop_poll_connection.
At this point, all actions on this connection refer to that instance: this includes creating streams, sending and receiving data, handling callbacks and so on. This imquic_connection instance is also what is passed to end users of the library as an opaque pointer: considering this structure is reference counted, the public API provides an interface to end users to add themselves as users of the connection as well.
An existing connection envisages sending and receiving QUIC messages, which includes a need to be able to build and parse them accordingly. The QUIC stack in quic.c and quic.h provides that functionality, where the imquic_packet structure provides an abstraction to QUIC packets, and a way to parse, build and serialize them. Cryptographic operations are performed there as well, via dedicated cryptographic utilities (more on that later).
Receiving a message works pretty much like this:
CRYPTO
and STREAM
), while others may update the state of the connection and/or trigger actions to perform;ACK
or CRYPTO
frames), and then it moves on.Sending a message can instead be originated either by the end user (e.g., in an attempt to send data to the peer) or by the stack itself (e.g., as part of regular checks, or triggers from the event loop). There are different helpers to generate a new message to send, which will all populate their own imquic_packet instance: in order to turn that in something that can be sent on the wire, the imquic_serialize_packet method is used, which takes care of serializing the structure to a QUIC packet, whose header will then be protected and whose payload encrypted: after that, a call to imquic_send_packet will invoke imquic_network_send on the endpoint associated to the connection, which will actually send the packet via UDP.
Loss detection is handled via a timed callback that's updated at regular times, depending on incoming and outgoing packets. More specifically, any time an ACK is received or an ACK eliciting packet is sent, the imquic_connection_update_loss_timer is called to re-initialize the loss detection timer: when the timer fires, the imquic_connection_loss_detection_timeout callback is invoked, which is where checks are performed to, e.g., figure out if any packet has been lost, or if the PTO timer expired. As a result, packets may be retransmitted or a new PING
sent, after which the loss detection timer is reset again.
We mentioned how the QUIC stack relies on a set of cryptographic utilities to take care of header protection and packet encryption.
This is done, first of all, by leveraging a TLS context, structured in imquic_tls, which contains certificate and key to use, besides the actual OpenSSL (actually quictls) SSL_CTX
context. Multiple endpoints can share the same context, or create their own. Any time a new connection is spawned out of an endpoint, a dedicated SSL
structure that will be used for the TLS exchanges.
Each connection also has a set of imquic_protection instances, one per each encryption level in QUIC. Each of those is made of two separate imquic_encryption instances, one for the local side (protecting, encrypting) and one for the remote side (removing the protection, decrypting). This structure contains all the info needed to take care of cryptographic operations, including the hashing algorithm, secrets, IVs, etc., all taking into account key phasing that may occur during a QUIC session.
Deriving initial keys for a connection is done with the imquic_derive_initial_secret function, which updates one imquic_protection instance (local or remote). Other encryption levels have their instances automatically updated as part of the quictls set_read_secret
and set_write_secret
(search SSL_QUIC_METHOD
for more information). Deriving secrets and being notified about them both involve internal HKDF utilities based on quictls primitives.
Reacting to a key phase bit change is done in imquic_update_keys.
Protecting and unprotecting headers is performed in the imquic_protect_header and imquic_unprotect_header helper methods specifically. Both are unaware of the context, and just work on buffers that the QUIC and TLS helper stacks provide. Again, HKDF helper functions are leveraged within the context of these operations.
Encrypting and dencrypting payloads is performed in the imquic_encrypt_payload and imquic_decrypt_payload respectively. As the header protection equivalents, they're unaware of context, and work on generic buffers leveraging the existing HKDF utilities.
When not using self containing messages for delivering data (e.g., using DATAGRAM
), QUIC can send and receive data in chunks, e.g., as part of CRYPTO
or STREAM
. By chunking we mean that, although the overall stream of data in each context is assumed to be in order (as in TCP), different portions of the buffer to send can actually be delivered in any order you want, by providing offset and length values to let the user know which portion of the overall data this "chunk" should fit in.
In order to provide a streamlined API to end users, while at the same time simplifying the library interals, a structure called imquic_buffer provides a gap-aware buffer of chunks. The insertion API allows both for specific placement in a buffer, via imquic_buffer_put, and a simple appending of data at the end of the existing buffer, via imquic_buffer_append. Retrieving data from a buffer, instead, is always done in an orderly fashion: you can either check if there's data to read, via imquic_buffer_peek, or retrieve it and update the internal buffer index, via imquic_buffer_get. This means that, in case the buffer does contain data, but the current index is waiting on a gap because there's a chunk that hasn't been delivered yet, the buffer will wait there until the gap is filled, thus guaranteeing that reading data on a buffer is always performed in order.
As mentioned, these buffers are currently used by the QUIC stack for two specific frames, CRYPTO
and STREAM
, since both provide a stream-based delivery of data that can envisage data sent in unordered chunks at different offsets. For CRYPTO
these buffers are part of the imquic_connection itself: for STREAM
, considering the multistream nature of QUIC, imquic exposes a dedicated structure called imquic_stream, that provides an abstraction to an actual QUIC stream.
This imquic_stream structure contains all the required info needed to manage a specific stream, including its ID, who originated it, whether it's bidirectional or unidirectional, and two separate buffers, one for sending and one for receiving (although only one may be needed, depending on the nature of the stream). State is also mantained, in order to figure out, e.g., when a stream is complete.
A list/map of such imquic_stream instances is kept in the imquic_connection that is managing them. New imquic_stream instances can be created either because the stack sees an incoming STREAM
frame from the peer for a new ID, or because the end user or the QUIC stack locally create one. In both cases, imquic_stream_create is used to create a new stream the connection should be aware of, since any attempt to interact with such a stream (e.g., for the purpose of delivering data) will fail if the stream ID is unknown.
In order to ensure a monotonically increasing allocation of locally created stream IDs to end users (and native protocols, as we'll see later), the internal imquic_connection API provides a helper function called imquic_connection_new_stream_id for the purpose.
Once a stream exists, incoming STREAM
data will be notified via internal callbacks on the connection (and from there to the end user or, if mediated, to the native protocol handling them), while data can be sent on a STREAM
using imquic_connection_send_on_stream. It's important to point out that this function only adds the data to send as a new chunk in the above mentioned imquic_buffer instance: in order to trigger the actual delivery of data for that STREAM
, the imquic_connection_flush_stream method must be called, which updates an internal property in the connection and wakes the loop, so that the callback to send all pending STREAM
data is called.
To conclude, as anticipated data can be exchanged in self-contained messages in QUIC too, specifically using DATAGRAM
if support for that frame was negotiated. In that case, internal buffering is only performed to mediate between the end user and the actual delivery of the data, which as we explained is always triggered in a scheduled way by the event loop, and not directly when the user calls the function to send it. If we exclude the missing imquic_buffer, that isn't involved for DATAGRAM
frames, the process is similar: incoming DATAGRAM
frames will be notified via internal callbacks on the connection (and from there to the end user or, if mediated, to the native protocol handling them), while data can be sent on a DATAGRAM
using imquic_connection_send_on_datagram, which will also involve the loop as in the STREAM
case.
While imquic itself provides a raw QUIC stack that should be usable for different use cases and applications, it also comes, out of the box, with native support for a few applition level protocols, in order to simplify the life of developers interested in some specific use cases.
WebTransport is a "first class citizen" in imquic, meaning that it's exposed as an option as part of the public APIs, independently of the protocols that will be build on top of that. The native support of MoQ, for instance, builds on top of this WebTransport support.
As explained in the intro, this is achieved in the core by implementing the basics of HTTP/3 CONNECT
for the sole purpose of establishing a WebTransport connection, when needed. When a user is interested in WebTransport, a imquic_http3_connection instance is created and associated to the imquic_connection instance. This new HTTP/3 specific resource is then used any time data is sent or received over the associated QUIC connection: any time there's incoming STREAM
data, for instance, rather than pass it to the application as the stack would normally do, it's passed to the WebTransport stack first instead, via a call to imquic_http3_process_stream_data. This function checks if it's a Control stream, if it's a stream related to QPACK, or if's a stream meant for exchanging data. For WebTransport, this means checking the codes that identify the usage of those streams, and handle them accordingly. After that, the WebTransport layer becomes a transparent "proxy" between the connection and the application, with STREAM
offsets shifted in order to mask this intermediate layer from the application perspective.
In order to set up a WebTransport on a QUIC connection, some HTTP/3 messages must be exchanged first. Specifically, both endpoints need to exchange a SETTINGS
frame to negotiate some parameters. Parsing a remote SETTINGS
is done in imquic_http3_parse_settings, while preparing the local one is done in imquic_http3_prepare_settings. After that, a client is supposed send a CONNECT
request, while the server will (hopefully) send a 200
back. If the imquic endpoint is acting as a client, it will use imquic_http3_check_send_connect to prepare a CONNECT
message to send, and then wait for a response. For both clients and servers, parsing HTTP/3 requests/responses is done by imquic_http3_parse_request, which will in turn parse the HEADERS
frame using imquic_http3_parse_request_headers. This would conclude the setup for clients, while servers will need to send a response back, which is done in imquic_http3_prepare_headers_response.
The QPACK portion of the exchange is performed via a custom QPACK stack that uses static tables for taking care of Huffman encoding and decoding. Specifically, the HTTP/3 stack creates a imquic_qpack_context that controls two dynamic tables (imquic_qpack_dynamic_table). Incoming QPACK messages are processed either in imquic_qpack_decode (for messages coming from the peer's encoder stream) or in imquic_qpack_process (for actual HTTP/3 requests/responses compressed with QPACK). The first method decodes Huffman codes where needed, and updates the remote dynamic table accordingly; the second one references static and dynamic tables to reconstruct headers to return back to the HTTP/3 stack. Outgoing messages, instead, are passed to the imquic_qpack_encode method, which checks if new additions must be made to the local dynamic table (and in case prepares QPACK encoder stream with Huffman codes to send to the peer), and then references the static and dynamic tables to encode requests/responses via QPACK.
TBD.
TBD.