quic 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12a202268fc7d9b862cdc0b6577284f5ae98fc275229b129be39f03656ce6967
4
- data.tar.gz: eef365c93fcce127f4621ac8aa244ea0b020f239c29bd56bdd5706998852449d
3
+ metadata.gz: 9032a4ca3ee79288be00be635d283adcfe5435a9aea166e9c15868f051b675ea
4
+ data.tar.gz: 52377290a15ad9644312b2db8350bfe3979834eae926be6cd9e1e460e0091b01
5
5
  SHA512:
6
- metadata.gz: a6fdb666ea56dc95b50087da5f43a14366416434029e59b4e041f4a7a5eb41a79abbf530403bb7b4505ada4da5387cf6fcba5fc29f8350bc0466bb5ad8f58dc4
7
- data.tar.gz: 33f053db5b35c2c7dd428d5e6ad3e29b58b4624f208ebae9593b3bb160fdfd7622942c13d75311ebd854411371f09690627a1f42366d1b3fe24d06362cbe7f36
6
+ metadata.gz: 9df57929c35a332b353df2c2955a1799e42a94d0a9afb4b1b697df085daba67aca60c2a906fd3f76c1eb07ac2cbb48d657ee12a44c87d41a4d881275ae596d8a
7
+ data.tar.gz: 340e5424b7fe6dc6aad37dd5be581d5d4e077b062f480e1f60d5a6c7c13d296425a66244b1278a35ed1b1cd125d052df1c8dd57e9989ebf3741c650a22f5fe51
data/CHANGELOG.md CHANGED
@@ -1,4 +1,49 @@
1
- ## [Unreleased]
1
+ ## [0.0.3] - 2026-10-13
2
+
3
+ ### Added
4
+ - `QUIC::Connection::Server` serves a single connection per UDP socket. `Server.accept(sock:, certificate_path:, private_key_path:, transport_params: nil, settings: nil)` waits for the first datagram, connects the socket to its sender and returns a Server whose handshake `#run` completes; streams, `#accept_stream` and `#close` then work as on a Client. The certificate file is PEM, leaf first, and `settings` must name at least one ALPN protocol; `verify_mode`, `ca_file` and `ca_path` are ignored, since no client certificate is requested. A certificate or key that cannot be loaded or used, or an empty ALPN list, raises `ArgumentError`; a first datagram that cannot start a connection raises `QUIC::Error::Proto`. There is no Retry, address validation or Version Negotiation, and `Server.new` is private. ([#9](https://github.com/unasuke/quic-ruby/pull/9))
5
+ - `QUIC::Stream#closed?` returns true once both directions of the stream have finished and everything sent has been acknowledged. It stays false if the connection closes first. ([#9](https://github.com/unasuke/quic-ruby/pull/9))
6
+ - A C function table for other native extensions, declared in `quic/ext.h` (ABI 1.0). An extension looks up `quic_ext_get_api` with `rb_ext_resolve_symbol` (Ruby 3.3 or later) and registers a C stream handler on a connection; the handler then owns every stream of that connection, receiving stream data and handing out what to send as `#write_pkt` builds each packet. `QUIC::EXT_INCLUDE_DIR` tells an extconf.rb where the header is. quic.so now exports `quic_ext_get_api` besides `Init_quic`; ngtcp2 and picotls stay hidden. ([#12](https://github.com/unasuke/quic-ruby/pull/12))
7
+ - `QUIC::Error::InvalidState`, raised by `#open_bidi_stream`, `#open_uni_stream`, `#accept_stream` and `#accept_stream_nonblock` of a connection whose streams belong to a C stream handler. ([#12](https://github.com/unasuke/quic-ruby/pull/12))
8
+ - `QUIC::TLSContext.server(certificate_path:, private_key_path:, alpn:)` reads a server's certificate chain and private key once and keeps them with its ALPN protocols, for every connection made from it to share. A connection holds on to the context it started with. The checks and `ArgumentError` messages are those of `Server.accept`, which now builds one inside; `TLSContext.new` raises `TypeError`. ([#13](https://github.com/unasuke/quic-ruby/pull/13))
9
+ - `QUIC::Endpoint` serves many connections on one unconnected UDP socket, leaving the socket and the I/O loop to the caller. `Endpoint.new(local_address:, tls_context:, transport_params: nil, settings: nil, max_connections: 1024)`; `#read_pkt(packet, remote:, local: nil)` routes a datagram by its connection ID and returns the `Connection::Server` it went to, making one for a new client; `#write_pkt` returns `[packet, sockaddr]` pairs until nil; `#expiry` and `#handle_expiry` run every connection's timers; `#take_closed` hands out the connections that have finished; `#connections`, `#stop_accepting` and `#tls_context=` complete it. It answers an unsupported version with Version Negotiation, a packet for an unknown connection with a Stateless Reset (at most 100 in a burst, then one a second), and a new connection beyond `max_connections` or after `#stop_accepting` with CONNECTION_REFUSED. At most 1024 of these answers wait for `#write_pkt`; any beyond are dropped. A connection's own failure closes that connection alone and never raises from the Endpoint. Its connections have no socket: `#close` queues the CONNECTION_CLOSE for `#write_pkt`, and only non-blocking stream methods work. There is no Retry or address validation. ([#14](https://github.com/unasuke/quic-ruby/pull/14))
10
+ - `QUIC::Connection::Server#error` (and `Client#error`) tells why an Endpoint closed the connection: nil when the peer closed it, it timed out idle or `#close` was called, the exception itself when Ruby code in a callback raised, and otherwise a `QUIC::Error` made from ngtcp2's error code. It is always nil for a connection no Endpoint made. ([#14](https://github.com/unasuke/quic-ruby/pull/14))
11
+ - `QUIC::Settings#qlog_dir` (a String or Pathname, nil by default) makes every connection write its qlog to `<qlog_dir>/<its first source connection ID in hex>.sqlog`. `Client.new`, `Connection::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. ([#15](https://github.com/unasuke/quic-ruby/pull/15))
12
+
13
+ ### Changed
14
+ - The extension no longer uses the ngtcp2 functions and callbacks deprecated in 1.22.0 and 1.23.0, and calls their `*2` replacements instead. Behavior is unchanged. ([#8](https://github.com/unasuke/quic-ruby/pull/8))
15
+ - `#accept_stream_nonblock` now raises `QUIC::Error::WaitReadable` with "no peer-initiated stream available" (was "no server-initiated stream available"), as it serves `QUIC::Connection::Server` too. ([#9](https://github.com/unasuke/quic-ruby/pull/9))
16
+ - picotls's `lib/pembase64.c`, a PEM loader nothing uses, is no longer built at install time, so every picotls source compiled into the gem is MIT licensed. ([#10](https://github.com/unasuke/quic-ruby/pull/10))
17
+
18
+ ### Fixed
19
+ - `QUIC::Stream#read` and `#read_nonblock` give the peer flow control credit back for what they hand to the application. Before, a connection stopped receiving once `initial_max_data` (1 MiB by default) had been used up. ([#11](https://github.com/unasuke/quic-ruby/pull/11))
20
+ - When a stream the peer opened closes, the peer may open another one. Before, it could open only `initial_max_streams_bidi` bidirectional streams over the whole connection. A stream that closes while it waits for `#accept_stream` is given back only once it has been accepted, so that the streams waiting there stay within that limit. ([#11](https://github.com/unasuke/quic-ruby/pull/11))
21
+ - An exception raised by Ruby code inside an ngtcp2 callback (for example a `FrozenError`) comes out of `#read_pkt`, `#write_pkt` or `#handle_expiry` instead of unwinding through ngtcp2. ([#11](https://github.com/unasuke/quic-ruby/pull/11))
22
+ - `QUIC::Stream#write` and `#write_nonblock` count the bytes already queued but not yet sent against the peer's window, and `#write_pkt` no longer raises `QUIC::Error::FlowControl` when that window is full; the data waits until the peer extends it. ([#11](https://github.com/unasuke/quic-ruby/pull/11))
23
+ - `#read_pkt`, `Client._open` and `Server._accept` raise `ArgumentError` for a sockaddr String other than an AF_INET or AF_INET6 sockaddr of that family's exact size, as `Addrinfo#to_sockaddr` makes. ngtcp2 copied an address of any length into room for a `sockaddr_in6`, so a longer String overran the heap. ([#14](https://github.com/unasuke/quic-ruby/pull/14))
24
+
25
+ ## [0.0.2] - 2026-09-27
26
+
27
+ ### Added
28
+ - Server certificates are now verified, by default against the system's trusted CAs. `QUIC::Settings` gains `verify_mode` (`:peer`, the default, or `:none`), `ca_file` and `ca_path`; setting either of the latter trusts only the certificates found there. The system store is loaded once per process, so `SSL_CERT_FILE` / `SSL_CERT_DIR` are honoured but later changes to them are not picked up. A rejected certificate raises `QUIC::Error::CertificateVerifyFailed`, which carries the X509 error as `#verify_result`. ([#3](https://github.com/unasuke/quic-ruby/pull/3))
29
+ - `QUIC::Error::CryptoError#tls_alert` holds the TLS alert this client sent when a handshake failed in `#read_pkt`. Alerts received from the peer are not recorded. ([#3](https://github.com/unasuke/quic-ruby/pull/3))
30
+ - `QUIC::Connection::Client#close(error_code: 0, reason: "")` sends an application CONNECTION_CLOSE (frame type 0x1d) over the bound socket and transitions ngtcp2 to the closing period. Pre-handshake calls are a best-effort no-op; a second `#close` after the closing/draining period is also a no-op.
31
+ - `QUIC::Connection::Client.new(address_family:)` accepts `:inet` / `:inet6` / `nil` to pin the resolved peer address family, and `QUIC::Connection::Client#remote_address` exposes the resolved `Addrinfo` so callers can line their connected socket's path up with ngtcp2's.
32
+ - `QUIC::Connection::Client#accept_stream(timeout: nil)` / `#accept_stream_nonblock` return peer-initiated (server) streams. `#accept_stream` blocks (driving the I/O loop) until a stream arrives, with `timeout: 0` returning immediately and a `Numeric` timeout returning `nil` after the deadline; `#accept_stream_nonblock` raises `QUIC::Error::WaitReadable` when none is queued.
33
+ - `QUIC::Stream#reset(error_code = 0)` aborts the send side with RESET_STREAM. Subsequent `#write` / `#write_nonblock` raise `QUIC::Error::StreamClosed`.
34
+ - `QUIC.library_versions` now includes a `:picotls` key holding the commit hash picotls was built from, since picotls has neither a version macro nor releases.
35
+
36
+ ### Changed
37
+ - The top-level namespace is now `QUIC` instead of `Quic`, and every constant moves with it: `QUIC::Connection::Client`, `QUIC::Stream`, `QUIC::Settings`, `QUIC::TransportParams`, and `QUIC::Error` together with its subclasses. No `Quic` alias is left behind. The gem name and `require "quic"` are unchanged.
38
+ - The TLS 1.3 handshake is now performed by [picotls](https://github.com/h2o/picotls) instead of LibreSSL's libssl.
39
+ - LibreSSL is no longer vendored. ngtcp2 and picotls are downloaded and built at install time and linked statically, but the cryptographic primitives and X.509 handling now come from the host's OpenSSL (or LibreSSL), linked dynamically and located through `pkg-config` (`--with-openssl-dir=` overrides it). OpenSSL 1.1.1 or later is required, and installing now needs the OpenSSL development package. This shares one libcrypto with Ruby's `openssl` extension: two copies in one process interposed on each other's global symbols, which reported the wrong library version and could segfault depending on `require` order. `QUIC.library_versions[:openssl]` therefore reports the host library.
40
+ - ngtcp2 is now built from 1.25.0 (was 1.22.1), and picotls from `f07f1c8`, the revision ngtcp2 1.25.0 is tested against. ngtcp2 now requires a C11 compiler to build. ([#4](https://github.com/unasuke/quic-ruby/pull/4))
41
+ - quic.so exports only `Init_quic`. The symbols of the statically linked ngtcp2 and picotls are no longer visible to the rest of the process. ([#2](https://github.com/unasuke/quic-ruby/pull/2), by [@hanazuki](https://github.com/hanazuki))
42
+ - `QUIC::Connection::Client` is now GC.compact safe: the C-side struct's back-reference to the owning Ruby object is updated via a `dcompact` slot.
43
+ - The entries in this section were rewritten to state the difference from 0.0.1, rather than the succession of steps that got there. ([#5](https://github.com/unasuke/quic-ruby/pull/5))
44
+
45
+ ### Fixed
46
+ - `QUIC::Stream#read` hung until the idle timeout instead of returning. Both branches of the blocking read waited on a predicate that could never become true once the peer had sent anything, so the read spun in the I/O loop until ngtcp2 gave up. A read that can be satisfied from the receive buffer now performs no I/O at all.
2
47
 
3
48
  ## [0.0.1] - 2026-05-28
4
49
 
@@ -0,0 +1,84 @@
1
+ Licenses of bundled dependencies
2
+ ================================
3
+
4
+ The quic gem downloads ngtcp2 and picotls at install time, builds them on the
5
+ installing machine and links them into quic.so statically. It therefore
6
+ redistributes no third-party binaries; their terms are reproduced below for
7
+ reference. libcrypto is the host's OpenSSL (or LibreSSL) and is neither
8
+ bundled nor redistributed. The quic gem itself is MIT licensed; see
9
+ LICENSE.txt.
10
+
11
+
12
+ ngtcp2 1.25.0 -- https://github.com/ngtcp2/ngtcp2
13
+ -------------------------------------------------
14
+
15
+ The MIT License
16
+
17
+ Copyright (c) 2016 ngtcp2 contributors
18
+
19
+ Permission is hereby granted, free of charge, to any person obtaining
20
+ a copy of this software and associated documentation files (the
21
+ "Software"), to deal in the Software without restriction, including
22
+ without limitation the rights to use, copy, modify, merge, publish,
23
+ distribute, sublicense, and/or sell copies of the Software, and to
24
+ permit persons to whom the Software is furnished to do so, subject to
25
+ the following conditions:
26
+
27
+ The above copyright notice and this permission notice shall be
28
+ included in all copies or substantial portions of the Software.
29
+
30
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
31
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
32
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
33
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
34
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
35
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
36
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
37
+
38
+
39
+ Portions of ngtcp2
40
+ ------------------
41
+
42
+ ngtcp2 embeds code derived from other projects. Those files are compiled into
43
+ the statically linked libngtcp2 and are covered by their own terms:
44
+
45
+ * lib/ngtcp2_pcg.c embeds the PCG random number generator, taken from
46
+ imneme/pcg-c, and carries "Copyright 2014-2019 Melissa O'Neill
47
+ <oneill@pcg-random.org>, and the PCG Project contributors". It states
48
+ "SPDX-License-Identifier: (Apache-2.0 OR MIT)"; under the MIT option its
49
+ terms are the MIT terms reproduced above.
50
+
51
+ * Several files under lib/ carry "Copyright (c) 2012-2023 nghttp2
52
+ contributors". They are distributed under the same MIT terms as the ngtcp2
53
+ COPYING text above.
54
+
55
+
56
+ picotls f07f1c8 -- https://github.com/h2o/picotls
57
+ -------------------------------------------------
58
+
59
+ picotls has no top-level license file; the terms are stated in the header of
60
+ each source file. The files compiled into libpicotls.a, lib/picotls.c,
61
+ lib/hpke.c and lib/openssl.c, are MIT licensed; the text is reproduced below.
62
+
63
+ The MIT License
64
+
65
+ Copyright (c) 2016 DeNA Co., Ltd., Kazuho Oku
66
+ Copyright (c) 2022 Fastly, Kazuho Oku
67
+
68
+ Permission is hereby granted, free of charge, to any person obtaining a copy
69
+ of this software and associated documentation files (the "Software"), to
70
+ deal in the Software without restriction, including without limitation the
71
+ rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
72
+ sell copies of the Software, and to permit persons to whom the Software is
73
+ furnished to do so, subject to the following conditions:
74
+
75
+ The above copyright notice and this permission notice shall be included in
76
+ all copies or substantial portions of the Software.
77
+
78
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
79
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
80
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
81
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
82
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
83
+ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
84
+ IN THE SOFTWARE.
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # Quic
1
+ # QUIC
2
2
 
3
- `quic` is a thin Ruby binding around [ngtcp2](https://github.com/ngtcp2/ngtcp2) for the QUIC transport protocol, with [LibreSSL](https://github.com/libressl/portable) as the TLS backend. Both dependencies are vendored at install time via [`mini_portile2`](https://github.com/flavorjones/mini_portile) (no system libraries required).
3
+ `quic` is a thin Ruby binding around [ngtcp2](https://github.com/ngtcp2/ngtcp2) for the QUIC transport protocol. TLS 1.3 is handled by [picotls](https://github.com/h2o/picotls). ngtcp2 and picotls are vendored at install time via [`mini_portile2`](https://github.com/flavorjones/mini_portile) and linked statically; the cryptographic primitives and X.509 handling come from the host's OpenSSL (or LibreSSL), linked dynamically.
4
4
 
5
5
  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`](https://github.com/socketry/protocol-quic) instead.
6
6
 
@@ -8,13 +8,99 @@ This project is in early development; the public API is not yet stable.
8
8
 
9
9
  ## Installation
10
10
 
11
- Not yet released to RubyGems.org. To use from a Gemfile while it is in development:
11
+ ```ruby
12
+ gem "quic"
13
+ ```
14
+
15
+ The gem is built from source at install time, so the host needs:
16
+
17
+ - OpenSSL 1.1.1 or later, **with its development package** (`libssl-dev`, `openssl-devel`, …). LibreSSL works too — see below.
18
+ - `autoconf`, `automake`, `libtool`, `pkg-config` and a C11 compiler, to build ngtcp2.
19
+ - either `patch` or `git`, to apply the picotls patch in `ext/quic/patches/` (`mini_portile2` uses `git apply` when `git` is available and falls back to `patch -p1`).
20
+
21
+ OpenSSL is located through `pkg-config`. If it lives somewhere `pkg-config` does not look, point at the prefix:
22
+
23
+ ```console
24
+ $ gem install quic -- --with-openssl-dir=$(brew --prefix openssl@3)
25
+ ```
26
+
27
+ 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.
28
+
29
+ **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.
30
+
31
+ ## Usage
32
+
33
+ ```ruby
34
+ require "quic"
35
+ require "socket"
36
+
37
+ client = QUIC::Connection::Client.new(
38
+ host: "cloudflare-quic.com", port: 443, address_family: :inet,
39
+ settings: QUIC::Settings.default.with(alpn: ["h3"])
40
+ )
41
+ sock = UDPSocket.new(Socket::AF_INET)
42
+ sock.connect(client.remote_address.ip_address, client.remote_address.ip_port)
43
+ client.bind(sock).run # completes the handshake, verifying the certificate
44
+ client.handshake_completed? # => true
45
+ ```
46
+
47
+ Pinning 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.
48
+
49
+ More complete programs live in [`examples/`](examples/):
50
+
51
+ - [`handshake_demo.rb`](examples/handshake_demo.rb): prints the library versions and times a handshake.
52
+ - [`echo_demo.rb`](examples/echo_demo.rb): exchanges data on a stream with a local echo server.
53
+ - [`echo_server_demo.rb`](examples/echo_server_demo.rb): the echo server for `echo_demo.rb`, generating its own certificate.
54
+ - [`doq_demo.rb`](examples/doq_demo.rb): sends a DNS over QUIC query.
55
+ - [`io_loop_demo.rb`](examples/io_loop_demo.rb): sends the same query from an I/O loop the script owns, calling `#write_pkt` / `#read_pkt` / `#handle_expiry` itself.
56
+ - [`verify_demo.rb`](examples/verify_demo.rb): tries the certificate verification settings described below against a public server.
57
+
58
+ ### Server
12
59
 
13
60
  ```ruby
14
- gem "quic", github: "unasuke/quic-ruby"
61
+ require "quic"
62
+ require "socket"
63
+
64
+ sock = UDPSocket.new(Socket::AF_INET)
65
+ sock.bind("127.0.0.1", 4433)
66
+ server = QUIC::Connection::Server.accept(
67
+ sock: sock, certificate_path: "server.pem", private_key_path: "server.key",
68
+ settings: QUIC::Settings.default.with(alpn: ["perf"])
69
+ )
70
+ server.run # completes the handshake
71
+ stream = server.accept_stream
72
+ stream.write(stream.read, fin: true) # echo everything up to the client's FIN
73
+ begin
74
+ server.pump_until { stream.closed? }
75
+ rescue QUIC::Error::Closed
76
+ # the client closed the connection before the stream finished
77
+ end
15
78
  ```
16
79
 
17
- The native extension downloads and builds LibreSSL and ngtcp2 release tarballs during `bundle install`, so the host needs `autoconf`, `automake`, `libtool`, `pkg-config`, and a C toolchain available.
80
+ `accept` connects the socket to the first client that sends to it and serves that one connection only. [`echo_server_demo.rb`](examples/echo_server_demo.rb) generates its own certificate, so it runs as is against `echo_demo.rb`.
81
+
82
+ ### Certificate verification
83
+
84
+ The server certificate is verified by default, against the system's trusted CAs.
85
+
86
+ Setting `ca_file` (a PEM bundle) or `ca_path` (a hashed certificate directory) trusts only the certificates found there:
87
+
88
+ ```ruby
89
+ settings = QUIC::Settings.default.with(alpn: ["h3"], ca_file: "/path/to/ca.pem")
90
+ ```
91
+
92
+ `verify_mode: :none` turns verification off, for example against a local test server with a self-signed certificate:
93
+
94
+ ```ruby
95
+ settings = QUIC::Settings.default.with(alpn: ["perf"], verify_mode: :none)
96
+ ```
97
+
98
+ ## Limitations
99
+
100
+ - **No certificate revocation checking.** CRLs and OCSP are not consulted.
101
+ - **No session resumption or 0-RTT.**
102
+ - **The server handles one connection per socket.** `QUIC::Connection::Server.accept` serves the first client that sends to the socket; there is no Retry, address validation, Version Negotiation, or client certificate authentication.
103
+ - **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.
18
104
 
19
105
  ## Development
20
106
 
@@ -30,6 +116,8 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/unasuk
30
116
 
31
117
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
32
118
 
119
+ The gem ships no third-party binaries: [ngtcp2](https://github.com/ngtcp2/ngtcp2) (MIT, with an embedded PCG random number generator under Apache-2.0 OR MIT) and [picotls](https://github.com/h2o/picotls) (MIT) are downloaded and built on the installing machine, and libcrypto is the host's. Their license texts are in [LICENSE-DEPENDENCIES.txt](LICENSE-DEPENDENCIES.txt) for reference.
120
+
33
121
  ## Code of Conduct
34
122
 
35
- Everyone interacting in the Quic project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/unasuke/quic-ruby/blob/main/CODE_OF_CONDUCT.md).
123
+ Everyone interacting in the quic-ruby project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/unasuke/quic-ruby/blob/main/CODE_OF_CONDUCT.md).
data/Rakefile CHANGED
@@ -17,4 +17,11 @@ Rake::ExtensionTask.new("quic", GEMSPEC) do |ext|
17
17
  ext.lib_dir = "lib/quic"
18
18
  end
19
19
 
20
+ # A test-only extension that drives quic/ext.h like the http3 gem does
21
+ # (test/test_quic_ext_api.rb). It lands in test/ext/ and is never packaged.
22
+ Rake::ExtensionTask.new("quic_ext_echo") do |ext|
23
+ ext.ext_dir = "test/ext/quic_ext_echo"
24
+ ext.lib_dir = "test/ext"
25
+ end
26
+
20
27
  task default: %i[clobber compile test standard]
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ # DNS over QUIC (RFC 9250) demo: send one DNS query to a public DoQ resolver
4
+ # and print the answers. This exercises the pieces a real protocol needs --
5
+ # ALPN negotiation, a client-initiated bidirectional stream, a half-close via
6
+ # FIN, and reading until the peer closes its side.
7
+ #
8
+ # Three rules from RFC 9250 shape the code below:
9
+ #
10
+ # * ALPN is "doq" and the default port is 853 (RFC 9250 section 4.1.1, 4.1.2).
11
+ # * Each query/response pair gets its own bidirectional stream, and the
12
+ # client signals FIN after the query (section 4.2).
13
+ # * The DNS message is framed with a 2-octet length prefix, as in DNS over
14
+ # TCP, and the DNS Message ID must be 0 (section 4.2.1). QUIC stream IDs
15
+ # already correlate a response with its query, so the DNS-level ID is
16
+ # redundant and carrying one would leak an identifier.
17
+ #
18
+ # The query is built and parsed with Resolv::DNS::Message from the standard
19
+ # library, so this needs no gems beyond quic itself. It also does not add
20
+ # EDNS(0) padding, which RFC 9250 section 5.5.2 recommends for privacy.
21
+ #
22
+ # Run with: bundle exec ruby examples/doq_demo.rb [name] [type]
23
+ # bundle exec ruby examples/doq_demo.rb ruby-lang.org AAAA
24
+ #
25
+ # Override the resolver via env vars:
26
+ # DOQ_HOST (default dns.quad9.net)
27
+ # DOQ_PORT (default 853)
28
+ #
29
+ # Other resolvers this was checked against: dns.adguard-dns.com and
30
+ # unfiltered.adguard-dns.com.
31
+
32
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
33
+ require "quic"
34
+ require "resolv"
35
+ require "socket"
36
+
37
+ TARGET_HOST = ENV.fetch("DOQ_HOST", "dns.quad9.net")
38
+ TARGET_PORT = Integer(ENV.fetch("DOQ_PORT", "853"))
39
+ QUERY_NAME = ARGV[0] || "example.com"
40
+ QUERY_TYPE = ARGV[1] || "A"
41
+
42
+ # Resolv only gives well-known names to a handful of resource classes; the
43
+ # rest arrive as Resolv::DNS::Resource::TypeNN_ClassNN. Map the numeric type
44
+ # back to a name for display, falling back to the RFC 3597 TYPENN form.
45
+ TYPE_NAMES = %w[A NS CNAME SOA PTR MX TXT AAAA SRV].to_h { |name|
46
+ [Resolv::DNS::Resource::IN.const_get(name)::TypeValue, name]
47
+ }.freeze
48
+
49
+ resource_class = begin
50
+ Resolv::DNS::Resource::IN.const_get(QUERY_TYPE)
51
+ rescue NameError
52
+ abort "unknown query type: #{QUERY_TYPE}"
53
+ end
54
+
55
+ # Pin the lookup to one IPv4 address and connect the socket to the address
56
+ # the client resolved, so both talk to the same peer. See
57
+ # examples/handshake_demo.rb for why that matters.
58
+ client = QUIC::Connection::Client.new(
59
+ host: TARGET_HOST, port: TARGET_PORT, address_family: :inet,
60
+ settings: QUIC::Settings.default.with(alpn: ["doq"])
61
+ )
62
+ addr = client.remote_address
63
+ sock = UDPSocket.new(Socket::AF_INET)
64
+ sock.connect(addr.ip_address, addr.ip_port)
65
+
66
+ puts "resolver: #{TARGET_HOST} (#{addr.ip_address}:#{addr.ip_port}), alpn doq"
67
+ puts "query: #{QUERY_NAME} #{QUERY_TYPE}"
68
+ puts
69
+
70
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
71
+ client.bind(sock).run
72
+ handshake_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
73
+ puts "handshake completed in #{handshake_ms}ms"
74
+
75
+ # Message ID 0, recursion desired.
76
+ query = Resolv::DNS::Message.new(0)
77
+ query.rd = 1
78
+ query.add_question(QUERY_NAME, resource_class)
79
+ wire = query.encode
80
+
81
+ stream = client.open_bidi_stream
82
+ # The 2-octet length prefix and the message go out together, with FIN: the
83
+ # client has nothing more to send on this stream.
84
+ stream.write([wire.bytesize].pack("n") + wire, fin: true)
85
+ puts "sent #{wire.bytesize} bytes on stream #{stream.id}, half-closed"
86
+
87
+ # The server answers and closes its side, so read until EOF. With no length,
88
+ # Stream#read returns everything received, or an empty String if nothing was.
89
+ response = stream.read
90
+ abort "resolver closed the stream without a response" if response.bytesize < 2
91
+
92
+ length = response.unpack1("n")
93
+ body = response.byteslice(2, length)
94
+ if body.nil? || body.bytesize < length
95
+ abort "truncated response: length prefix says #{length}, got #{response.bytesize - 2}"
96
+ end
97
+
98
+ answer = Resolv::DNS::Message.decode(body)
99
+ rcode = answer.rcode
100
+ total_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
101
+ puts "received #{body.bytesize} bytes, rcode #{rcode} (#{total_ms}ms total)"
102
+ puts
103
+
104
+ if answer.answer.empty?
105
+ puts "(no answer records)"
106
+ else
107
+ answer.each_answer do |name, ttl, data|
108
+ value = case data
109
+ when Resolv::DNS::Resource::IN::A, Resolv::DNS::Resource::IN::AAAA then data.address.to_s
110
+ when Resolv::DNS::Resource::IN::CNAME, Resolv::DNS::Resource::IN::NS,
111
+ Resolv::DNS::Resource::IN::PTR then data.name.to_s
112
+ when Resolv::DNS::Resource::IN::MX then "#{data.preference} #{data.exchange}"
113
+ when Resolv::DNS::Resource::IN::TXT then data.strings.join(" ")
114
+ else data.inspect
115
+ end
116
+ type_value = data.class::TypeValue
117
+ puts format("%-34s %6d %-6s %s", name, ttl, TYPE_NAMES.fetch(type_value, "TYPE#{type_value}"), value)
118
+ end
119
+ end
120
+
121
+ client.close
122
+ sock.close
@@ -3,13 +3,18 @@
3
3
  # Stream API demo: open a bidirectional stream, write a small payload with
4
4
  # FIN, then block on Stream#read until the peer echoes the bytes back and
5
5
  # closes its side. Requires a local QUIC echo server (default: 127.0.0.1:4433
6
- # with ALPN "perf"); ngtcp2 ships an examples/server binary that does this
7
- # when built with --enable-examples.
6
+ # with ALPN "perf"), such as examples/echo_server_demo.rb.
8
7
  #
9
8
  # Override the target via env vars:
10
9
  # QUIC_ECHO_HOST (default 127.0.0.1)
11
10
  # QUIC_ECHO_PORT (default 4433)
12
11
  # QUIC_ECHO_ALPN (default perf)
12
+ # QUIC_ECHO_CA_FILE (default unset: the server certificate is not verified)
13
+ #
14
+ # examples/echo_server_demo.rb prints the path of the CA it generated; pass
15
+ # it as QUIC_ECHO_CA_FILE to verify the certificate. Verification is off
16
+ # without it. When it is set, the certificate must match QUIC_ECHO_HOST,
17
+ # which means an IP SAN of 127.0.0.1 with the default host.
13
18
  #
14
19
  # Run with: bundle exec ruby examples/echo_demo.rb
15
20
 
@@ -20,19 +25,23 @@ require "socket"
20
25
  TARGET_HOST = ENV.fetch("QUIC_ECHO_HOST", "127.0.0.1")
21
26
  TARGET_PORT = Integer(ENV.fetch("QUIC_ECHO_PORT", "4433"))
22
27
  TARGET_ALPN = ENV.fetch("QUIC_ECHO_ALPN", "perf")
23
-
24
- addr = Addrinfo.getaddrinfo(TARGET_HOST, TARGET_PORT, Socket::AF_INET, Socket::SOCK_DGRAM).first
25
- sock = UDPSocket.new
26
- sock.connect(addr.ip_address, addr.ip_port)
27
-
28
- settings = Quic::Settings.default.with(alpn: [TARGET_ALPN])
29
- client = Quic::Connection::Client._open(
30
- local_sockaddr: Addrinfo.udp("0.0.0.0", 0).to_sockaddr,
31
- remote_sockaddr: addr.to_sockaddr,
32
- server_name: TARGET_HOST,
33
- transport_params: Quic::TransportParams.default,
34
- settings: settings
28
+ TARGET_CA_FILE = ENV["QUIC_ECHO_CA_FILE"]
29
+
30
+ settings = QUIC::Settings.default.with(alpn: [TARGET_ALPN])
31
+ settings = if TARGET_CA_FILE.nil? || TARGET_CA_FILE.empty?
32
+ settings.with(verify_mode: :none)
33
+ else
34
+ settings.with(ca_file: TARGET_CA_FILE)
35
+ end
36
+
37
+ # Connect the socket to the address the client resolved, so both talk to the
38
+ # same peer (see examples/handshake_demo.rb).
39
+ client = QUIC::Connection::Client.new(
40
+ host: TARGET_HOST, port: TARGET_PORT, address_family: :inet, settings: settings
35
41
  )
42
+ addr = client.remote_address
43
+ sock = UDPSocket.new(Socket::AF_INET)
44
+ sock.connect(addr.ip_address, addr.ip_port)
36
45
  client.bind(sock).run
37
46
 
38
47
  stream = client.open_bidi_stream
@@ -46,4 +55,5 @@ response = stream.read
46
55
  puts "received #{response.bytesize} bytes:"
47
56
  puts response
48
57
 
58
+ client.close
49
59
  sock.close
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Echo server for examples/echo_demo.rb: accept one QUIC connection, echo
4
+ # every stream back to the client (read up to its FIN, then write it all
5
+ # back with FIN), and exit once the client closes the connection, goes away,
6
+ # or opens no new stream for 10 seconds.
7
+ #
8
+ # No certificate files are needed. A throwaway CA and a server certificate it
9
+ # signed are generated at startup, and the CA's path is printed; the files
10
+ # are removed when the server exits.
11
+ #
12
+ # echo_demo.rb reads the same env vars, so it connects without any setup:
13
+ # QUIC_ECHO_HOST address to bind (default 127.0.0.1); also added to the
14
+ # certificate's SAN
15
+ # QUIC_ECHO_PORT (default 4433)
16
+ # QUIC_ECHO_ALPN (default perf)
17
+ #
18
+ # Run with: bundle exec ruby examples/echo_server_demo.rb
19
+ # then, from another terminal: bundle exec ruby examples/echo_demo.rb
20
+
21
+ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
22
+ require "quic"
23
+ require "ipaddr"
24
+ require "openssl"
25
+ require "socket"
26
+ require "tmpdir"
27
+
28
+ BIND_HOST = ENV.fetch("QUIC_ECHO_HOST", "127.0.0.1")
29
+ BIND_PORT = Integer(ENV.fetch("QUIC_ECHO_PORT", "4433"))
30
+ ALPN = ENV.fetch("QUIC_ECHO_ALPN", "perf")
31
+ ACCEPT_TIMEOUT = 10
32
+
33
+ # localhost and 127.0.0.1, plus the bind address so that echo_demo.rb can
34
+ # verify the certificate against QUIC_ECHO_HOST.
35
+ def subject_alt_names(host)
36
+ names = ["DNS:localhost", "IP:127.0.0.1"]
37
+ entry = begin
38
+ IPAddr.new(host)
39
+ "IP:#{host}"
40
+ rescue IPAddr::InvalidAddressError
41
+ "DNS:#{host}"
42
+ end
43
+ names << entry unless names.include?(entry)
44
+ names.join(",")
45
+ end
46
+
47
+ # Write a CA and a server certificate it signed (EC P-256, valid for one
48
+ # day), with the server's private key, into dir. Returns the three paths.
49
+ def generate_certificates(dir, host)
50
+ now = Time.now
51
+
52
+ ca_key = OpenSSL::PKey::EC.generate("prime256v1")
53
+ ca_name = OpenSSL::X509::Name.parse("/CN=quic-ruby echo server demo CA")
54
+ ca_cert = OpenSSL::X509::Certificate.new
55
+ ca_cert.version = 2
56
+ ca_cert.serial = 1
57
+ ca_cert.subject = ca_name
58
+ ca_cert.issuer = ca_name
59
+ ca_cert.public_key = ca_key
60
+ ca_cert.not_before = now - 60
61
+ ca_cert.not_after = now + 86_400
62
+ factory = OpenSSL::X509::ExtensionFactory.new(ca_cert, ca_cert)
63
+ ca_cert.add_extension(factory.create_extension("basicConstraints", "CA:TRUE", true))
64
+ ca_cert.add_extension(factory.create_extension("keyUsage", "keyCertSign,cRLSign", true))
65
+ ca_cert.sign(ca_key, OpenSSL::Digest.new("SHA256"))
66
+
67
+ server_key = OpenSSL::PKey::EC.generate("prime256v1")
68
+ server_cert = OpenSSL::X509::Certificate.new
69
+ server_cert.version = 2
70
+ server_cert.serial = 2
71
+ server_cert.subject = OpenSSL::X509::Name.parse("/CN=localhost")
72
+ server_cert.issuer = ca_name
73
+ server_cert.public_key = server_key
74
+ server_cert.not_before = now - 60
75
+ server_cert.not_after = now + 86_400
76
+ factory = OpenSSL::X509::ExtensionFactory.new(ca_cert, server_cert)
77
+ server_cert.add_extension(factory.create_extension("subjectAltName", subject_alt_names(host)))
78
+ server_cert.add_extension(factory.create_extension("extendedKeyUsage", "serverAuth"))
79
+ server_cert.sign(ca_key, OpenSSL::Digest.new("SHA256"))
80
+
81
+ paths = %w[ca.pem server.pem server.key].map { |name| File.join(dir, name) }
82
+ File.write(paths[0], ca_cert.to_pem)
83
+ File.write(paths[1], server_cert.to_pem)
84
+ File.write(paths[2], server_key.private_to_pem)
85
+ paths
86
+ end
87
+
88
+ Dir.mktmpdir("quic-echo-server-") do |dir|
89
+ ca_file, certificate_path, private_key_path = generate_certificates(dir, BIND_HOST)
90
+
91
+ sock = UDPSocket.new(Socket::AF_INET)
92
+ sock.bind(BIND_HOST, BIND_PORT)
93
+ puts "listening on #{sock.local_address.inspect_sockaddr} (UDP), ALPN #{ALPN}"
94
+ puts "CA certificate: #{ca_file}"
95
+ puts "try a verified connection with:"
96
+ puts " QUIC_ECHO_CA_FILE=#{ca_file} bundle exec ruby examples/echo_demo.rb"
97
+ puts "(without QUIC_ECHO_CA_FILE, echo_demo.rb does not verify the certificate)"
98
+ puts
99
+
100
+ server = QUIC::Connection::Server.accept(
101
+ sock: sock, certificate_path: certificate_path, private_key_path: private_key_path,
102
+ settings: QUIC::Settings.default.with(alpn: [ALPN])
103
+ )
104
+ puts "accepted #{server.remote_address.inspect_sockaddr}"
105
+ server.run
106
+ puts "handshake completed"
107
+
108
+ begin
109
+ while (stream = server.accept_stream(timeout: ACCEPT_TIMEOUT))
110
+ data = stream.read
111
+ stream.write(data, fin: true)
112
+ puts "stream #{stream.id}: echoed #{data.bytesize} bytes"
113
+ server.pump_until { stream.closed? }
114
+ end
115
+ puts "no new stream for #{ACCEPT_TIMEOUT} seconds"
116
+ rescue QUIC::Error::Closed
117
+ puts "client closed the connection"
118
+ rescue Errno::ECONNREFUSED
119
+ # The socket is connected to the client, so once the client's socket is
120
+ # gone without a CONNECTION_CLOSE, the ICMP port unreachable surfaces
121
+ # here, from sending to it.
122
+ puts "client went away without closing the connection"
123
+ end
124
+
125
+ server.close
126
+ sock.close
127
+ end