QUIC
quic is a thin Ruby binding around ngtcp2 for the QUIC transport protocol. TLS 1.3 is handled by picotls. ngtcp2 and picotls are vendored at install time via mini_portile2 and linked statically; the cryptographic primitives and X.509 handling come from the host's OpenSSL (or LibreSSL), linked dynamically.
The gem is intentionally optimized for synchronous I/O and String-based buffers. It exposes ngtcp2 primitives (#read_pkt / #write_pkt / #expiry / #handle_expiry) and lets the caller own the I/O loop. If you need Fiber Scheduler / IO::Buffer / async ecosystem integration, see socketry/protocol-quic instead.
This project is in early development; the public API is not yet stable.
Installation
gem "quic"The gem is built from source at install time, so the host needs:
- OpenSSL 1.1.1 or later, with its development package (
libssl-dev,openssl-devel, …). LibreSSL works too — see below. -
autoconf,automake,libtool,pkg-configand a C11 compiler, to build ngtcp2. - either
patchorgit, to apply the picotls patch inext/quic/patches/(mini_portile2usesgit applywhengitis available and falls back topatch -p1).
OpenSSL is located through pkg-config. If it lives somewhere pkg-config does not look, point at the prefix:
$ gem install quic -- --with-openssl-dir=$(brew --prefix openssl@3)Linking the host's libcrypto rather than bundling one is deliberate: the process then shares a single libcrypto with Ruby's own openssl extension. Two copies in one process interpose on each other's global symbols, which silently misroutes calls and can crash the VM.
LibreSSL is supported and exercised: ext/quic/patches/picotls/ carries a patch that restores picotls's X25519 key exchange there, which upstream disables because LibreSSL lacks EVP_PKEY_{get1,set1}_tls_encodedpoint(). The patch is a no-op on OpenSSL, where X25519 is available anyway.
Usage
require "quic"
require "socket"
client = QUIC::Connection::Client.new(
host: "cloudflare-quic.com", port: 443, address_family: :inet,
settings: QUIC::Settings.default.with(alpn: ["h3"])
)
sock = UDPSocket.new(Socket::AF_INET)
sock.connect(client.remote_address.ip_address, client.remote_address.ip_port)
client.bind(sock).run # completes the handshake, verifying the certificate
client.handshake_completed? # => truePinning the address family and connecting the socket to client.remote_address keeps the socket and ngtcp2's connection path on the same peer address; resolving the host name twice could otherwise pick different DNS records.
More complete programs live in examples/:
-
handshake_demo.rb: prints the library versions and times a handshake. -
echo_demo.rb: exchanges data on a stream with a local echo server. -
echo_server_demo.rb: the echo server forecho_demo.rb, generating its own certificate. -
echo_endpoint_demo.rb: the same echo server on aQUIC::Endpoint, serving many clients at once from an I/O loop of its own, and writing qlog whenQUIC_ECHO_QLOG_DIRis set. -
doq_demo.rb: sends a DNS over QUIC query. -
io_loop_demo.rb: sends the same query from an I/O loop the script owns, calling#write_pkt/#read_pkt/#handle_expiryitself. -
verify_demo.rb: tries the certificate verification settings described below against a public server.
Server
require "quic"
require "socket"
sock = UDPSocket.new(Socket::AF_INET)
sock.bind("127.0.0.1", 4433)
server = QUIC::Connection::Server.accept(
sock: sock, certificate_path: "server.pem", private_key_path: "server.key",
settings: QUIC::Settings.default.with(alpn: ["perf"])
)
server.run # completes the handshake
stream = server.accept_stream
stream.write(stream.read, fin: true) # echo everything up to the client's FIN
begin
server.pump_until { stream.closed? }
rescue QUIC::Error::Closed
# the client closed the connection before the stream finished
endaccept connects the socket to the first client that sends to it and serves that one connection only; to serve many clients on one socket, use a QUIC::Endpoint (below). echo_server_demo.rb generates its own certificate, so it runs as is against echo_demo.rb.
Many connections on one socket
QUIC::Endpoint serves many connections on one UDP socket. It routes each datagram to its connection by connection ID, makes a connection for each new client, and answers what no connection can: Version Negotiation for a version it does not support, a Stateless Reset for a packet of a connection it does not know, and CONNECTION_REFUSED beyond max_connections (1024 by default) or after #stop_accepting. The socket and the I/O loop stay with you:
require "quic"
require "socket"
tls = QUIC::TLSContext.server(
certificate_path: "server.pem", private_key_path: "server.key", alpn: ["perf"]
)
sock = UDPSocket.new(Socket::AF_INET)
sock.bind("127.0.0.1", 4433) # the address it serves, not 0.0.0.0
endpoint = QUIC::Endpoint.new(local_address: sock.local_address, tls_context: tls)
loop do
expiry = endpoint.expiry # CLOCK_MONOTONIC in nanoseconds, or nil
now = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
if IO.select([sock], nil, nil, expiry && [expiry - now, 0].max / 1e9)
data, sender = sock.recvmsg(65_535)
endpoint.read_pkt(data, remote: sender.to_sockaddr) # the connection it went to
end
endpoint.handle_expiry
endpoint.connections.each do |conn|
# conn.accept_stream_nonblock, Stream#read_nonblock, Stream#write_nonblock
end
while (packet, to = endpoint.write_pkt)
sock.send(packet, 0, to)
end
endpoint.take_closed.each { |conn| warn conn.error.inspect if conn.error }
endThe connections are QUIC::Connection::Servers without a socket of their own, so only their non-blocking methods work, and #close queues its CONNECTION_CLOSE for Endpoint#write_pkt. A connection's failure never raises from the Endpoint: it closes that connection alone, and the connection's #error tells why. The Endpoint's own answers wait for #write_pkt in a queue of at most 1024, and any beyond are dropped, so call it often under load. echo_endpoint_demo.rb fills in the streams and runs against echo_demo.rb.
QUIC::TLSContext.server reads the certificate chain (PEM, leaf first) and the private key once, for every connection made from it to share. To put a renewed certificate in place, assign a new context: endpoint.tls_context = QUIC::TLSContext.server(...). New connections use it, and existing ones keep the context they started with.
Certificate verification
The server certificate is verified by default, against the system's trusted CAs.
Setting ca_file (a PEM bundle) or ca_path (a hashed certificate directory) trusts only the certificates found there:
settings = QUIC::Settings.default.with(alpn: ["h3"], ca_file: "/path/to/ca.pem")verify_mode: :none turns verification off, for example against a local test server with a self-signed certificate:
settings = QUIC::Settings.default.with(alpn: ["perf"], verify_mode: :none)qlog
qlog_dir makes every connection write its qlog to <qlog_dir>/<its first source connection ID in hex>.sqlog:
settings = QUIC::Settings.default.with(alpn: ["h3"], qlog_dir: "/tmp/qlog")It applies to clients, Connection::Server.accept and every connection of an Endpoint. Client.new, Server.accept and Endpoint.new raise ArgumentError unless it is a writable directory. A file that cannot be opened later, when a connection is made, only costs that connection its qlog. A connection's file is closed as soon as the connection ends, not when the connection is garbage collected.
From other native extensions
Another native extension can drive a connection's streams from C instead of through QUIC::Stream, as the http3 gem does. quic/ext.h declares the function table (ABI 1.0), and QUIC::EXT_INCLUDE_DIR tells an extconf.rb where it is:
require "mkmf"
require "quic/ext_include"
append_cppflags("-I#{QUIC::EXT_INCLUDE_DIR}")At run time, look the table up once quic is loaded. rb_ext_resolve_symbol needs Ruby 3.3 or later:
#include <quic/ext.h>
rb_require("quic");
quic_ext_get_api_fn get = (quic_ext_get_api_fn)rb_ext_resolve_symbol("quic/quic", "quic_ext_get_api");
const quic_ext_api *api = get ? get(QUIC_EXT_ABI_MAJOR) : NULL; /* NULL: a different ABI major */A connection given a stream handler with set_stream_handler makes no QUIC::Stream: the handler receives every stream's data and hands out what to send as #write_pkt builds each packet, and #open_bidi_stream, #accept_stream and the like raise QUIC::Error::InvalidState. The comment at the top of ext.h states the rules on versions, exceptions and lifetimes.
Limitations
- No certificate revocation checking. CRLs and OCSP are not consulted.
- No session resumption or 0-RTT.
-
No Retry or address validation. An Endpoint makes a connection for any Initial packet without checking that its sender owns the source address, so Initial packets with forged addresses can fill
max_connectionsforhandshake_timeout(10 seconds by default) and keep real clients out meanwhile. - No client certificate authentication. A server does not ask for a client certificate.
- Key exchanges and cipher suites are fixed. X25519, secp256r1 and secp384r1 with AES-128-GCM, AES-256-GCM and ChaCha20-Poly1305. They cannot be selected from Ruby.
Development
After checking out the repo, run bin/setup to install dependencies. Then run bundle exec rake to compile the C extension and run the tests + linter. You can also run bin/console for an interactive prompt.
To install this gem onto your local machine, run bundle exec rake install.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/unasuke/quic-ruby. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.
License
The gem is available as open source under the terms of the MIT License.
The gem ships no third-party binaries: ngtcp2 (MIT, with an embedded PCG random number generator under Apache-2.0 OR MIT) and picotls (MIT) are downloaded and built on the installing machine, and libcrypto is the host's. Their license texts are in LICENSE-DEPENDENCIES.txt for reference.
Code of Conduct
Everyone interacting in the quic-ruby project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.