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 +4 -4
- data/CHANGELOG.md +46 -1
- data/LICENSE-DEPENDENCIES.txt +84 -0
- data/README.md +94 -6
- data/Rakefile +7 -0
- data/examples/doq_demo.rb +122 -0
- data/examples/echo_demo.rb +24 -14
- data/examples/echo_server_demo.rb +127 -0
- 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 +154 -676
- data/ext/quic/connection_common.c +1262 -0
- data/ext/quic/connection_common.h +217 -0
- data/ext/quic/connection_server.c +186 -0
- data/ext/quic/endpoint.c +823 -0
- data/ext/quic/endpoint.h +28 -0
- data/ext/quic/ext_api.c +112 -0
- data/ext/quic/extconf.rb +145 -28
- data/ext/quic/include/quic/ext.h +84 -0
- data/ext/quic/patches/picotls/0001-openssl-use-raw-public-key-api-for-X25519.patch +74 -0
- data/ext/quic/quic.c +91 -54
- data/ext/quic/quic.h +43 -33
- data/ext/quic/stream.c +137 -40
- data/ext/quic/stream.h +17 -7
- data/ext/quic/tls_context.c +260 -0
- data/ext/quic/tls_context.h +46 -0
- data/lib/quic/connection.rb +173 -28
- data/lib/quic/endpoint.rb +57 -0
- data/lib/quic/ext_include.rb +12 -0
- data/lib/quic/settings.rb +46 -3
- data/lib/quic/stream.rb +20 -16
- data/lib/quic/transport_params.rb +1 -1
- data/lib/quic/version.rb +2 -2
- data/lib/quic.rb +3 -1
- data/sig/quic.rbs +231 -13
- metadata +43 -9
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
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Server certificate verification demo: handshake with a public QUIC server
|
|
4
|
+
# under a few QUIC::Settings verification configurations and report how each
|
|
5
|
+
# one ends.
|
|
6
|
+
#
|
|
7
|
+
# 1. The defaults: verify_mode :peer against the system CA store.
|
|
8
|
+
# 2. ca_file holding only a throwaway CA generated on the spot. The server's
|
|
9
|
+
# chain does not lead to it, so the handshake fails with
|
|
10
|
+
# QUIC::Error::CertificateVerifyFailed.
|
|
11
|
+
# 3. verify_mode :none, which skips verification entirely.
|
|
12
|
+
# 4. ca_file from VERIFY_CA_FILE, when that variable is set.
|
|
13
|
+
#
|
|
14
|
+
# Override the target via env vars:
|
|
15
|
+
# VERIFY_HOST (default cloudflare-quic.com)
|
|
16
|
+
# VERIFY_PORT (default 443)
|
|
17
|
+
# VERIFY_ALPN (default h3)
|
|
18
|
+
# VERIFY_CA_FILE (default unset: step 4 is skipped)
|
|
19
|
+
#
|
|
20
|
+
# Each attempt connects its socket to the address its client resolved, so
|
|
21
|
+
# both talk to the same peer (see examples/handshake_demo.rb).
|
|
22
|
+
#
|
|
23
|
+
# Run with: bundle exec ruby examples/verify_demo.rb
|
|
24
|
+
|
|
25
|
+
$LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
26
|
+
require "quic"
|
|
27
|
+
require "socket"
|
|
28
|
+
require "openssl"
|
|
29
|
+
require "tmpdir"
|
|
30
|
+
|
|
31
|
+
TARGET_HOST = ENV.fetch("VERIFY_HOST", "cloudflare-quic.com")
|
|
32
|
+
TARGET_PORT = Integer(ENV.fetch("VERIFY_PORT", "443"))
|
|
33
|
+
TARGET_ALPN = ENV.fetch("VERIFY_ALPN", "h3")
|
|
34
|
+
TARGET_CA_FILE = ENV["VERIFY_CA_FILE"]
|
|
35
|
+
|
|
36
|
+
# A self-signed CA that no real server chains to, written as PEM into dir.
|
|
37
|
+
def write_untrusted_ca(dir)
|
|
38
|
+
key = OpenSSL::PKey::EC.generate("prime256v1")
|
|
39
|
+
name = OpenSSL::X509::Name.parse("/CN=quic-ruby verify_demo CA")
|
|
40
|
+
cert = OpenSSL::X509::Certificate.new
|
|
41
|
+
cert.version = 2
|
|
42
|
+
cert.serial = 1
|
|
43
|
+
cert.subject = name
|
|
44
|
+
cert.issuer = name
|
|
45
|
+
cert.public_key = key
|
|
46
|
+
cert.not_before = Time.now - 60
|
|
47
|
+
cert.not_after = Time.now + 3600
|
|
48
|
+
factory = OpenSSL::X509::ExtensionFactory.new(cert, cert)
|
|
49
|
+
cert.add_extension(factory.create_extension("basicConstraints", "CA:TRUE", true))
|
|
50
|
+
cert.add_extension(factory.create_extension("keyUsage", "keyCertSign,cRLSign", true))
|
|
51
|
+
cert.sign(key, OpenSSL::Digest.new("SHA256"))
|
|
52
|
+
File.join(dir, "untrusted-ca.pem").tap { |path| File.write(path, cert.to_pem) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# verify_result is an X509_V_ERR_* value; show the matching Ruby constant.
|
|
56
|
+
def verify_result_name(value)
|
|
57
|
+
name = OpenSSL::X509.constants.grep(/\AV_ERR_/).find { |c| OpenSSL::X509.const_get(c) == value }
|
|
58
|
+
name ? "OpenSSL::X509::#{name}" : "unknown"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def handshake(label, settings)
|
|
62
|
+
client = QUIC::Connection::Client.new(
|
|
63
|
+
host: TARGET_HOST, port: TARGET_PORT, address_family: :inet, settings: settings
|
|
64
|
+
)
|
|
65
|
+
addr = client.remote_address
|
|
66
|
+
sock = UDPSocket.new(Socket::AF_INET)
|
|
67
|
+
sock.connect(addr.ip_address, addr.ip_port)
|
|
68
|
+
|
|
69
|
+
puts "== #{label} (#{addr.ip_address}:#{addr.ip_port})"
|
|
70
|
+
puts " verify_mode=#{settings.verify_mode.inspect} ca_file=#{settings.ca_file.inspect} ca_path=#{settings.ca_path.inspect}"
|
|
71
|
+
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
72
|
+
client.bind(sock).run
|
|
73
|
+
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round(1)
|
|
74
|
+
puts " handshake completed (#{elapsed_ms}ms)"
|
|
75
|
+
client.close
|
|
76
|
+
rescue QUIC::Error::CertificateVerifyFailed => e
|
|
77
|
+
puts " #{e.class}: #{e.message}"
|
|
78
|
+
puts " verify_result=#{e.verify_result.inspect} (#{verify_result_name(e.verify_result)})"
|
|
79
|
+
puts " tls_alert=#{e.tls_alert.inspect}"
|
|
80
|
+
rescue QUIC::Error::CryptoError => e
|
|
81
|
+
puts " #{e.class}: #{e.message} (tls_alert=#{e.tls_alert.inspect})"
|
|
82
|
+
rescue QUIC::Error => e
|
|
83
|
+
puts " #{e.class}: #{e.message}"
|
|
84
|
+
ensure
|
|
85
|
+
sock&.close
|
|
86
|
+
puts
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
puts "libcrypto: #{QUIC.library_versions[:openssl]}"
|
|
90
|
+
puts "Target: #{TARGET_HOST}:#{TARGET_PORT}, alpn #{TARGET_ALPN}"
|
|
91
|
+
puts
|
|
92
|
+
|
|
93
|
+
base = QUIC::Settings.default.with(alpn: [TARGET_ALPN])
|
|
94
|
+
|
|
95
|
+
handshake("system CA store (default)", base)
|
|
96
|
+
|
|
97
|
+
Dir.mktmpdir do |dir|
|
|
98
|
+
handshake("ca_file with an unrelated CA", base.with(ca_file: write_untrusted_ca(dir)))
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
handshake("verify_mode :none (not verified)", base.with(verify_mode: :none))
|
|
102
|
+
|
|
103
|
+
if TARGET_CA_FILE && !TARGET_CA_FILE.empty?
|
|
104
|
+
handshake("ca_file from VERIFY_CA_FILE", base.with(ca_file: TARGET_CA_FILE))
|
|
105
|
+
end
|