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.
@@ -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
- # Why _open instead of Client.new: Client.new resolves the hostname via
7
- # Addrinfo.udp, and UDPSocket#connect resolves it independently. Cloudflare
8
- # returns multiple A/AAAA records, so the two resolutions can disagree
9
- # and ngtcp2 then drops every reply with "ignore packet from unknown path".
10
- # Pre-resolving once to a single IPv4 sockaddr and feeding it to both
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
- addr = Addrinfo.getaddrinfo(TARGET_HOST, TARGET_PORT, Socket::AF_INET, Socket::SOCK_DGRAM).first
23
- sock = UDPSocket.new
24
- sock.connect(addr.ip_address, addr.ip_port)
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: #{Quic.library_versions[:ngtcp2]}"
36
- puts "TLS: #{Quic.library_versions[:openssl]}"
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