quic 0.0.1 → 0.0.2
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 +4 -4
- data/CHANGELOG.md +22 -1
- data/LICENSE-DEPENDENCIES.txt +101 -0
- data/README.md +69 -6
- data/examples/doq_demo.rb +122 -0
- data/examples/echo_demo.rb +23 -12
- data/examples/handshake_demo.rb +18 -20
- data/examples/io_loop_demo.rb +144 -0
- data/examples/verify_demo.rb +105 -0
- data/ext/quic/connection_client.c +403 -68
- data/ext/quic/extconf.rb +138 -28
- data/ext/quic/patches/picotls/0001-openssl-use-raw-public-key-api-for-X25519.patch +74 -0
- data/ext/quic/quic.c +63 -51
- data/ext/quic/quic.h +35 -25
- data/ext/quic/stream.c +59 -23
- data/ext/quic/stream.h +7 -3
- data/lib/quic/connection.rb +69 -9
- data/lib/quic/settings.rb +23 -3
- data/lib/quic/stream.rb +17 -14
- data/lib/quic/transport_params.rb +1 -1
- data/lib/quic/version.rb +2 -2
- data/lib/quic.rb +1 -1
- data/sig/quic.rbs +42 -5
- metadata +31 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2598a7b8f6d545c4f092bc5885644fdc6a6cc19406489ebba6dcd40e90d63394
|
|
4
|
+
data.tar.gz: 71076bbc240ea79b361bac74443748dcbc725e6cb5fcc22ff6587935a1afb9e1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6b882c40642e5efdf6a45717c6dba5fe890c26dbe7bbae66ef9b3871e17958773dd7c41877a5b9decf40e0fd6ba03359f632b8f9ca6dc393cabf36600d03acf1
|
|
7
|
+
data.tar.gz: 29a203887f07455ae6d8d6c76d553bb82fe9c1d72aa2233f797f60093e353dbf028b8b8d3b8f6e40a645b9535cb6b089f4971cf763e96a45af7cc7a494e03a2b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
|
-
## [
|
|
1
|
+
## [0.0.2] - 2026-09-27
|
|
2
|
+
|
|
3
|
+
### Added
|
|
4
|
+
- 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))
|
|
5
|
+
- `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))
|
|
6
|
+
- `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.
|
|
7
|
+
- `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.
|
|
8
|
+
- `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.
|
|
9
|
+
- `QUIC::Stream#reset(error_code = 0)` aborts the send side with RESET_STREAM. Subsequent `#write` / `#write_nonblock` raise `QUIC::Error::StreamClosed`.
|
|
10
|
+
- `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.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
- 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.
|
|
14
|
+
- The TLS 1.3 handshake is now performed by [picotls](https://github.com/h2o/picotls) instead of LibreSSL's libssl.
|
|
15
|
+
- 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.
|
|
16
|
+
- 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))
|
|
17
|
+
- 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))
|
|
18
|
+
- `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.
|
|
19
|
+
- 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))
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- `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
23
|
|
|
3
24
|
## [0.0.1] - 2026-05-28
|
|
4
25
|
|
|
@@ -0,0 +1,101 @@
|
|
|
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. Of the files compiled into libpicotls.a, lib/picotls.c,
|
|
61
|
+
lib/hpke.c and lib/openssl.c are MIT licensed, while lib/pembase64.c is under
|
|
62
|
+
an ISC-style license. Both texts are reproduced below.
|
|
63
|
+
|
|
64
|
+
The MIT License
|
|
65
|
+
|
|
66
|
+
Copyright (c) 2016 DeNA Co., Ltd., Kazuho Oku
|
|
67
|
+
Copyright (c) 2022 Fastly, Kazuho Oku
|
|
68
|
+
|
|
69
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
70
|
+
of this software and associated documentation files (the "Software"), to
|
|
71
|
+
deal in the Software without restriction, including without limitation the
|
|
72
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
|
|
73
|
+
sell copies of the Software, and to permit persons to whom the Software is
|
|
74
|
+
furnished to do so, subject to the following conditions:
|
|
75
|
+
|
|
76
|
+
The above copyright notice and this permission notice shall be included in
|
|
77
|
+
all copies or substantial portions of the Software.
|
|
78
|
+
|
|
79
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
80
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
81
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
82
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
83
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
84
|
+
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
|
|
85
|
+
IN THE SOFTWARE.
|
|
86
|
+
|
|
87
|
+
The ISC-style license
|
|
88
|
+
|
|
89
|
+
Copyright (c) 2016 Christian Huitema <huitema@huitema.net>
|
|
90
|
+
|
|
91
|
+
Permission to use, copy, modify, and distribute this software for any
|
|
92
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
93
|
+
copyright notice and this permission notice appear in all copies.
|
|
94
|
+
|
|
95
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
96
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
97
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
98
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
99
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
100
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
101
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
#
|
|
1
|
+
# QUIC
|
|
2
2
|
|
|
3
|
-
`quic` is a thin Ruby binding around [ngtcp2](https://github.com/ngtcp2/ngtcp2) for the QUIC transport protocol
|
|
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,74 @@ This project is in early development; the public API is not yet stable.
|
|
|
8
8
|
|
|
9
9
|
## Installation
|
|
10
10
|
|
|
11
|
-
|
|
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
|
|
12
32
|
|
|
13
33
|
```ruby
|
|
14
|
-
|
|
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
|
|
15
45
|
```
|
|
16
46
|
|
|
17
|
-
|
|
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
|
+
- [`doq_demo.rb`](examples/doq_demo.rb): sends a DNS over QUIC query.
|
|
54
|
+
- [`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.
|
|
55
|
+
- [`verify_demo.rb`](examples/verify_demo.rb): tries the certificate verification settings described below against a public server.
|
|
56
|
+
|
|
57
|
+
### Certificate verification
|
|
58
|
+
|
|
59
|
+
The server certificate is verified by default, against the system's trusted CAs.
|
|
60
|
+
|
|
61
|
+
Setting `ca_file` (a PEM bundle) or `ca_path` (a hashed certificate directory) trusts only the certificates found there:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
settings = QUIC::Settings.default.with(alpn: ["h3"], ca_file: "/path/to/ca.pem")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`verify_mode: :none` turns verification off, for example against a local test server with a self-signed certificate:
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
settings = QUIC::Settings.default.with(alpn: ["perf"], verify_mode: :none)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Limitations
|
|
74
|
+
|
|
75
|
+
- **No certificate revocation checking.** CRLs and OCSP are not consulted.
|
|
76
|
+
- **No session resumption or 0-RTT.**
|
|
77
|
+
- **No server side.** Only the client (`QUIC::Connection::Client`) exists; there is no listen/accept.
|
|
78
|
+
- **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
79
|
|
|
19
80
|
## Development
|
|
20
81
|
|
|
@@ -30,6 +91,8 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/unasuk
|
|
|
30
91
|
|
|
31
92
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
32
93
|
|
|
94
|
+
The gem ships no third-party binaries: [ngtcp2](https://github.com/ngtcp2/ngtcp2) (MIT, with portions under the Chromium BSD-3-Clause license) and [picotls](https://github.com/h2o/picotls) (MIT, with one file under an ISC-style license) 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.
|
|
95
|
+
|
|
33
96
|
## Code of Conduct
|
|
34
97
|
|
|
35
|
-
Everyone interacting in the
|
|
98
|
+
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).
|
|
@@ -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
|
data/examples/echo_demo.rb
CHANGED
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
# QUIC_ECHO_HOST (default 127.0.0.1)
|
|
11
11
|
# QUIC_ECHO_PORT (default 4433)
|
|
12
12
|
# QUIC_ECHO_ALPN (default perf)
|
|
13
|
+
# QUIC_ECHO_CA_FILE (default unset: the server certificate is not verified)
|
|
14
|
+
#
|
|
15
|
+
# The echo server usually runs with a self-signed certificate, so
|
|
16
|
+
# verification is off unless QUIC_ECHO_CA_FILE names a CA to trust. When it
|
|
17
|
+
# is set, the certificate must match QUIC_ECHO_HOST, which means an IP SAN
|
|
18
|
+
# of 127.0.0.1 with the default host.
|
|
13
19
|
#
|
|
14
20
|
# Run with: bundle exec ruby examples/echo_demo.rb
|
|
15
21
|
|
|
@@ -20,19 +26,23 @@ require "socket"
|
|
|
20
26
|
TARGET_HOST = ENV.fetch("QUIC_ECHO_HOST", "127.0.0.1")
|
|
21
27
|
TARGET_PORT = Integer(ENV.fetch("QUIC_ECHO_PORT", "4433"))
|
|
22
28
|
TARGET_ALPN = ENV.fetch("QUIC_ECHO_ALPN", "perf")
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
TARGET_CA_FILE = ENV["QUIC_ECHO_CA_FILE"]
|
|
30
|
+
|
|
31
|
+
settings = QUIC::Settings.default.with(alpn: [TARGET_ALPN])
|
|
32
|
+
settings = if TARGET_CA_FILE.nil? || TARGET_CA_FILE.empty?
|
|
33
|
+
settings.with(verify_mode: :none)
|
|
34
|
+
else
|
|
35
|
+
settings.with(ca_file: TARGET_CA_FILE)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Connect the socket to the address the client resolved, so both talk to the
|
|
39
|
+
# same peer (see examples/handshake_demo.rb).
|
|
40
|
+
client = QUIC::Connection::Client.new(
|
|
41
|
+
host: TARGET_HOST, port: TARGET_PORT, address_family: :inet, settings: settings
|
|
35
42
|
)
|
|
43
|
+
addr = client.remote_address
|
|
44
|
+
sock = UDPSocket.new(Socket::AF_INET)
|
|
45
|
+
sock.connect(addr.ip_address, addr.ip_port)
|
|
36
46
|
client.bind(sock).run
|
|
37
47
|
|
|
38
48
|
stream = client.open_bidi_stream
|
|
@@ -46,4 +56,5 @@ response = stream.read
|
|
|
46
56
|
puts "received #{response.bytesize} bytes:"
|
|
47
57
|
puts response
|
|
48
58
|
|
|
59
|
+
client.close
|
|
49
60
|
sock.close
|
data/examples/handshake_demo.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
# Sample script: drive a real QUIC + TLS 1.3 handshake against a public
|
|
4
|
-
# HTTP/3 server (cloudflare-quic.com:443) using Client#bind + Client#run
|
|
4
|
+
# HTTP/3 server (cloudflare-quic.com:443) using Client#bind + Client#run, and
|
|
5
|
+
# print the library versions the extension was built with.
|
|
5
6
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# _open and the socket avoids this.
|
|
7
|
+
# address_family: :inet pins the lookup to a single IPv4 address, and the
|
|
8
|
+
# socket is connected to client.remote_address so that it talks to the same
|
|
9
|
+
# peer as the connection path ngtcp2 holds. Cloudflare returns several A/AAAA
|
|
10
|
+
# records; a second, independent lookup could pick another one, and ngtcp2
|
|
11
|
+
# would then drop every reply as coming from an unknown path.
|
|
12
12
|
#
|
|
13
13
|
# Run with: bundle exec ruby examples/handshake_demo.rb
|
|
14
14
|
|
|
@@ -19,21 +19,17 @@ require "socket"
|
|
|
19
19
|
TARGET_HOST = "cloudflare-quic.com"
|
|
20
20
|
TARGET_PORT = 443
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
settings = Quic::Settings.default.with(alpn: ["h3"])
|
|
27
|
-
client = Quic::Connection::Client._open(
|
|
28
|
-
local_sockaddr: Addrinfo.udp("0.0.0.0", 0).to_sockaddr,
|
|
29
|
-
remote_sockaddr: addr.to_sockaddr,
|
|
30
|
-
server_name: TARGET_HOST,
|
|
31
|
-
transport_params: Quic::TransportParams.default,
|
|
32
|
-
settings: settings
|
|
22
|
+
client = QUIC::Connection::Client.new(
|
|
23
|
+
host: TARGET_HOST, port: TARGET_PORT, address_family: :inet,
|
|
24
|
+
settings: QUIC::Settings.default.with(alpn: ["h3"])
|
|
33
25
|
)
|
|
26
|
+
addr = client.remote_address
|
|
27
|
+
sock = UDPSocket.new(Socket::AF_INET)
|
|
28
|
+
sock.connect(addr.ip_address, addr.ip_port)
|
|
34
29
|
|
|
35
|
-
puts "ngtcp2: #{
|
|
36
|
-
puts "
|
|
30
|
+
puts "ngtcp2: #{QUIC.library_versions[:ngtcp2]}"
|
|
31
|
+
puts "picotls: #{QUIC.library_versions[:picotls]}"
|
|
32
|
+
puts "libcrypto: #{QUIC.library_versions[:openssl]}"
|
|
37
33
|
puts "Target: #{TARGET_HOST} (#{addr.ip_address}:#{addr.ip_port})"
|
|
38
34
|
puts
|
|
39
35
|
|
|
@@ -42,4 +38,6 @@ client.bind(sock).run
|
|
|
42
38
|
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
|
|
43
39
|
|
|
44
40
|
puts "handshake_completed? #{client.handshake_completed?} (#{elapsed_ms}ms)"
|
|
41
|
+
|
|
42
|
+
client.close
|
|
45
43
|
sock.close
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Caller-owned I/O loop demo: the same DNS over QUIC query as
|
|
4
|
+
# examples/doq_demo.rb, but without Client#bind / #run or the blocking
|
|
5
|
+
# Stream#read / #write. The script owns the socket and the loop, and calls the
|
|
6
|
+
# packet-level primitives itself:
|
|
7
|
+
#
|
|
8
|
+
# Client#write_pkt the next datagram to send, or nil when there is
|
|
9
|
+
# nothing more to send right now
|
|
10
|
+
# Client#read_pkt feed one received datagram to the connection
|
|
11
|
+
# Client#expiry the next timer deadline (CLOCK_MONOTONIC in
|
|
12
|
+
# nanoseconds), or nil
|
|
13
|
+
# Client#handle_expiry run the connection's timers (retransmission,
|
|
14
|
+
# idle timeout, ...)
|
|
15
|
+
# Stream#write_nonblock queue stream data; raises QUIC::Error::WaitWritable
|
|
16
|
+
# when the peer's flow control window is full
|
|
17
|
+
# Stream#read_nonblock take received stream data; raises
|
|
18
|
+
# QUIC::Error::WaitReadable when none has arrived,
|
|
19
|
+
# EOFError once the peer has finished
|
|
20
|
+
#
|
|
21
|
+
# None of these touch the network. Every step that has to wait goes through
|
|
22
|
+
# #pump below, which flushes outgoing packets and then waits for a datagram or
|
|
23
|
+
# the next timer, whichever comes first. That is the part to adapt when the
|
|
24
|
+
# loop has to live inside something else, such as an event loop that also
|
|
25
|
+
# serves other sockets.
|
|
26
|
+
#
|
|
27
|
+
# Run with: bundle exec ruby examples/io_loop_demo.rb [name]
|
|
28
|
+
#
|
|
29
|
+
# Override the resolver via env vars:
|
|
30
|
+
# DOQ_HOST (default dns.quad9.net)
|
|
31
|
+
# DOQ_PORT (default 853)
|
|
32
|
+
|
|
33
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
34
|
+
require "quic"
|
|
35
|
+
require "resolv"
|
|
36
|
+
require "socket"
|
|
37
|
+
|
|
38
|
+
TARGET_HOST = ENV.fetch("DOQ_HOST", "dns.quad9.net")
|
|
39
|
+
TARGET_PORT = Integer(ENV.fetch("DOQ_PORT", "853"))
|
|
40
|
+
QUERY_NAME = ARGV[0] || "example.com"
|
|
41
|
+
TIMEOUT_SEC = 10
|
|
42
|
+
|
|
43
|
+
client = QUIC::Connection::Client.new(
|
|
44
|
+
host: TARGET_HOST, port: TARGET_PORT, address_family: :inet,
|
|
45
|
+
settings: QUIC::Settings.default.with(alpn: ["doq"])
|
|
46
|
+
)
|
|
47
|
+
addr = client.remote_address
|
|
48
|
+
sock = UDPSocket.new(Socket::AF_INET)
|
|
49
|
+
sock.connect(addr.ip_address, addr.ip_port)
|
|
50
|
+
|
|
51
|
+
# read_pkt needs the network path a datagram arrived on, and it has to match
|
|
52
|
+
# the path the connection was created with. Client.new creates it with an
|
|
53
|
+
# unspecified local address (0.0.0.0:0) and the resolved remote address.
|
|
54
|
+
LOCAL_SOCKADDR = Addrinfo.udp("0.0.0.0", 0).to_sockaddr
|
|
55
|
+
REMOTE_SOCKADDR = addr.to_sockaddr
|
|
56
|
+
|
|
57
|
+
# One turn of the loop: send everything the connection has queued, then wait
|
|
58
|
+
# until a datagram arrives or the next timer is due, and hand that over.
|
|
59
|
+
def pump(client, sock, deadline, stats)
|
|
60
|
+
while (pkt = client.write_pkt)
|
|
61
|
+
sock.send(pkt, 0)
|
|
62
|
+
stats[:sent] += 1
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
+
abort "timed out after #{TIMEOUT_SEC}s" if now > deadline
|
|
67
|
+
wait = deadline - now
|
|
68
|
+
if (expiry = client.expiry)
|
|
69
|
+
until_expiry = (expiry - Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)) / 1e9
|
|
70
|
+
wait = until_expiry.clamp(0.0, wait)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
if IO.select([sock], nil, nil, wait)
|
|
74
|
+
data, = sock.recvfrom(2048)
|
|
75
|
+
client.read_pkt(data, local_sockaddr: LOCAL_SOCKADDR, remote_sockaddr: REMOTE_SOCKADDR)
|
|
76
|
+
stats[:received] += 1
|
|
77
|
+
else
|
|
78
|
+
client.handle_expiry
|
|
79
|
+
stats[:timers] += 1
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
stats = Hash.new(0)
|
|
84
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + TIMEOUT_SEC
|
|
85
|
+
|
|
86
|
+
puts "resolver: #{TARGET_HOST} (#{addr.ip_address}:#{addr.ip_port}), alpn doq"
|
|
87
|
+
puts "query: #{QUERY_NAME} A"
|
|
88
|
+
puts
|
|
89
|
+
|
|
90
|
+
pump(client, sock, deadline, stats) until client.handshake_completed?
|
|
91
|
+
puts "handshake completed: #{stats[:sent]} datagrams sent, #{stats[:received]} received"
|
|
92
|
+
|
|
93
|
+
# RFC 9250: DNS Message ID 0, framed with a 2-octet length prefix, one
|
|
94
|
+
# bidirectional stream per query, FIN after the query.
|
|
95
|
+
query = Resolv::DNS::Message.new(0)
|
|
96
|
+
query.rd = 1
|
|
97
|
+
query.add_question(QUERY_NAME, Resolv::DNS::Resource::IN::A)
|
|
98
|
+
wire = query.encode
|
|
99
|
+
pending = [wire.bytesize].pack("n") + wire
|
|
100
|
+
|
|
101
|
+
stream = client.open_bidi_stream
|
|
102
|
+
# write_nonblock queues as much as the flow control window allows and returns
|
|
103
|
+
# that count. FIN only goes out with the last byte, so keep passing fin: true
|
|
104
|
+
# for whatever is left.
|
|
105
|
+
until pending.empty?
|
|
106
|
+
begin
|
|
107
|
+
queued = stream.write_nonblock(pending, fin: true)
|
|
108
|
+
pending = pending.byteslice(queued..)
|
|
109
|
+
rescue QUIC::Error::WaitWritable
|
|
110
|
+
pump(client, sock, deadline, stats)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
puts "queued #{wire.bytesize + 2} bytes + FIN on stream #{stream.id}"
|
|
114
|
+
|
|
115
|
+
# Nothing has been sent yet: the query leaves on the next pump, which happens
|
|
116
|
+
# here as soon as read_nonblock finds nothing to read.
|
|
117
|
+
response = String.new(encoding: Encoding::BINARY)
|
|
118
|
+
loop do
|
|
119
|
+
response << stream.read_nonblock(4096)
|
|
120
|
+
rescue QUIC::Error::WaitReadable
|
|
121
|
+
pump(client, sock, deadline, stats)
|
|
122
|
+
rescue EOFError
|
|
123
|
+
break
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
abort "resolver closed the stream without a response" if response.bytesize < 2
|
|
127
|
+
length = response.unpack1("n")
|
|
128
|
+
body = response.byteslice(2, length)
|
|
129
|
+
abort "truncated response" if body.nil? || body.bytesize < length
|
|
130
|
+
|
|
131
|
+
answer = Resolv::DNS::Message.decode(body)
|
|
132
|
+
puts "received #{body.bytesize} bytes, rcode #{answer.rcode}"
|
|
133
|
+
puts "loop totals: #{stats[:sent]} datagrams sent, #{stats[:received]} received, #{stats[:timers]} timer runs"
|
|
134
|
+
puts
|
|
135
|
+
|
|
136
|
+
answer.each_answer do |name, ttl, data|
|
|
137
|
+
next unless data.is_a?(Resolv::DNS::Resource::IN::A)
|
|
138
|
+
puts format("%-34s %6d A %s", name, ttl, data.address)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Client#close writes the CONNECTION_CLOSE datagram to a bound socket itself,
|
|
142
|
+
# so bind the socket now, only for that.
|
|
143
|
+
client.bind(sock).close
|
|
144
|
+
sock.close
|