Nexus provides two sets of interfaces: one in namespace nexus::quic
for generic QUIC clients and servers, and another in namespace nexus::h3
for HTTP/3 clients and servers.
The library must be initialized before use, and this is accomplished by creating a nexus::global::context
with one of its factory functions:
{
auto global = nexus::global::init_client();
...
} // global cleanup on destruction
All QUIC connections are secure by default, so the use of TLS is not optional. TLS configuration is left to the application, which is expected to provide an initialized boost::asio::ssl::context
to the client and server interfaces.
QUIC requires TLS version 1.3, so the ssl context should be initialized with a TLS 1.3 method, and its min/max protocol version should be set:
auto ssl = boost::asio::ssl::context{boost::asio::ssl::context::tlsv13};
SSL_CTX_set_min_proto_version(ssl.native_handle(), TLS1_3_VERSION);
SSL_CTX_set_max_proto_version(ssl.native_handle(), TLS1_3_VERSION);
All QUIC clients "MUST authenticate the identity of the server", and the application is responsible for providing an SSL context that does this:
ssl.set_verify_mode(boost::asio::ssl::verify_peer);
QUIC endpoints use a TLS extension called Application-Layer Protocol Negotiation (ALPN) to negotiate the application protocol. The HTTP/3 client and server automatically negotiate the "h3" protocol, but the generic QUIC client and server are protocol-agnostic so must negotiate this manually.
The client provides its desired protocol names in a call to SSL_CTX_set_alpn_protos()
.
The server implements this negotiation using SSL_CTX_set_alpn_select_cb()
and SSL_select_next_proto()
, either selecting the first supported protocol in the client's list, or rejecting the client's handshake.
The negotiated protocol can be queried on either side with SSL_get0_alpn_selected()
.
As part of the connection handshake, the client and server exchange their QUIC Transport Parameters. Some of the transport parameters we send, such as flow control limits and timeouts, can be configured with class nexus::quic::settings
.
Nexus also receives the peer's transport parameters, and will automatically respect the limits they impose. For example, once we've opened the maximum number of outgoing streams on the connection, requests to initiate a new outgoing stream will block until another stream closes. And once we reach a stream or connection flow control limit, requests to write more data will block until the peer reads some and adjusts the window.
The client and server constructors take an optional nexus::quic::settings
argument and, once constructed, these settings cannot be changed.
The generic QUIC client (class nexus::quic::client
) takes control of a bound boost::asio::ip::udp::socket
and boost::asio::ssl::context
to initiate secure connections (class nexus::quic::connection
) to QUIC servers.
auto client = nexus::quic::client{ex, bind_endpoint, ssl};
Initiating a client connection is instantaneous, and does not wait for the connection handshake to complete. This allows the application to start opening streams and staging data in the meantime. If the handshake does fail, the relevant error code will be delivered to any pending stream operations.
A client initiates a connection either by calling nexus::quic::client::connect()
:
auto conn = nexus::quic::connection{client};
client.connect(conn, endpoint, hostname);
Or by providing the remote endpoint and hostname arguments to the nexus::quic::connection
constructor:
auto conn = nexus::quic::connection{client, endpoint, hostname};
The generic QUIC server (class nexus::quic::server
) listens on one or more UDP sockets (using class nexus::quic::acceptor
) to accept secure connections from QUIC clients.
auto server = nexus::quic::server{ex};
auto acceptor = nexus::quic::acceptor{server, bind_endpoint, ssl};
acceptor.listen(16);
auto conn = nexus::quic::connection{acceptor};
acceptor.accept(conn);
Unlike nexus::quic::client::connect()
which returns immediately without waiting for the connection handshake, nexus::quic::acceptor::accept()
only completes once the handshake is successful.
Once a generic QUIC connection (class nexus::quic::connection
) has been connected or accepted, it can be used both to initiate outgoing streams with nexus::quic::connection::connect()
:
auto stream = nexus::quic::stream{conn};
conn.connect(stream);
And to accept() incoming streams:
auto stream = nexus::quic::stream{conn};
conn.accept(stream);
When a connection closes, all related streams are closed and any pending operations are canceled with a connection error.
Once a generic QUIC stream (class nexus::quic::stream
) has been connected or accepted, it provides bidirectional, reliable ordered delivery of data.
For reads and writes, nexus::quic::stream
models the asio concepts AsyncReadStream
, AsyncWriteStream
, SyncReadStream
and SyncWriteStream
, so can be used with the same read and write algorithms as boost::asio::ip::tcp::socket
:
char data[16]; // read 16 bytes from the stream
auto bytes = boost::asio::read(stream, boost::asio::buffer(data));
The stream can be closed in one or both directions with nexus::quic::stream::shutdown()
and nexus::quic::stream::close()
.
Writes may be buffered by the stream due to a preference to send full packets, similar to Nagle's algorithm for TCP. Buffered stream data can be flushed, either by calling nexus::quic::stream::flush()
manually, shutting down the stream for write, or closing the stream.
Graceful shutdown of a QUIC stream differs from normal TCP sockets, which can be closed immediately even if there is unsent or unacked data, and the kernel will continue to (re)transmit data in the background until everything is acked. Because this transmission in QUIC depends on an open connection, the application must not close the associated nexus::quic::connection
until the stream shutdown completes. To support this graceful shutdown, the behavior of nexus::quic::stream::close()
matches that of the socket option SO_LINGER
, where the close does not complete until all stream data has been successfully acked. nexus::quic::stream::async_close()
is provided for asynchronous graceful shutdown.
A separate function nexus::quic::stream::reset()
is provided for immediate shutdown, and the nexus::quic::stream::~stream()
destructor calls nexus::quic::stream::reset()
instead of nexus::quic::stream::close()
.
For each potentially-blocking operation, both a synchronous or asynchronous version of the function are provided. The asynchronous function names all begin with async_
, and attempt to meet all "Requirements on asynchronous operations" specified by asio.
However, QUIC requires that we regularly poll its sockets for incoming packets, respond with acknowledgements and other control messages, and resend unacknowledged packets. The Nexus client and server classes issue asynchronous operations for this background work, and expect their associated execution context to process those operations in a timely manner - for example, by calling boost::asio::io_context::run()
.
This is unlike the asio I/O objects you may be familiar with, which can be used exclusively in the synchronous blocking mode without requiring another thread to process asynchronous work.
The library should be useable with or without exceptions, so both a throwing and non-throwing version of each function is provided. Their signatures are the same, except the non-throwing version takes a mutable reference to error_code as an additional parameter.
Global init/shutdown is not thread-safe with respect to other classes.
All engine operations can be considered thread-safe. The lsquic engine instance is not re-entrant, so an engine-wide mutex is used to serialize access to the engine and its related state. This mutex is held over all calls into the lsquic engine, including its calls to the callback functions we provide. No blocking system calls are made under this mutex, as lsquic does no i/o of its own.