quicsilver 0.1.0
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 +7 -0
- data/.gitignore +12 -0
- data/.gitmodules +3 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +22 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +28 -0
- data/README.md +105 -0
- data/Rakefile +31 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/examples/README.md +105 -0
- data/examples/minimal_http3_client.rb +89 -0
- data/examples/minimal_http3_server.rb +32 -0
- data/examples/rack_http3_server.rb +60 -0
- data/examples/setup_certs.sh +57 -0
- data/ext/quicsilver/extconf.rb +25 -0
- data/ext/quicsilver/quicsilver.c +807 -0
- data/lib/quicsilver/client.rb +191 -0
- data/lib/quicsilver/http3/request_encoder.rb +112 -0
- data/lib/quicsilver/http3/request_parser.rb +158 -0
- data/lib/quicsilver/http3/response_encoder.rb +73 -0
- data/lib/quicsilver/http3.rb +68 -0
- data/lib/quicsilver/listener_data.rb +29 -0
- data/lib/quicsilver/server.rb +258 -0
- data/lib/quicsilver/server_configuration.rb +49 -0
- data/lib/quicsilver/version.rb +3 -0
- data/lib/quicsilver.rb +22 -0
- data/quicsilver.gemspec +44 -0
- metadata +143 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class Client
|
|
5
|
+
attr_reader :hostname, :port, :unsecure, :connection_timeout
|
|
6
|
+
|
|
7
|
+
def initialize(hostname, port = 4433, options = {})
|
|
8
|
+
@hostname = hostname
|
|
9
|
+
@port = port
|
|
10
|
+
@unsecure = options[:unsecure] || true
|
|
11
|
+
@connection_timeout = options[:connection_timeout] || 5000
|
|
12
|
+
|
|
13
|
+
@connection_data = nil
|
|
14
|
+
@connected = false
|
|
15
|
+
@connection_start_time = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def connect
|
|
19
|
+
raise Error, "Already connected" if @connected
|
|
20
|
+
|
|
21
|
+
# Initialize MSQUIC if not already done
|
|
22
|
+
result = Quicsilver.open_connection
|
|
23
|
+
|
|
24
|
+
# Create configuration
|
|
25
|
+
config = Quicsilver.create_configuration(@unsecure)
|
|
26
|
+
raise ConnectionError, "Failed to create configuration" if config.nil?
|
|
27
|
+
|
|
28
|
+
# Create connection (returns [handle, context])
|
|
29
|
+
@connection_data = Quicsilver.create_connection
|
|
30
|
+
raise ConnectionError, "Failed to create connection" if @connection_data.nil?
|
|
31
|
+
|
|
32
|
+
connection_handle = @connection_data[0]
|
|
33
|
+
context_handle = @connection_data[1]
|
|
34
|
+
|
|
35
|
+
# Start the connection
|
|
36
|
+
success = Quicsilver.start_connection(connection_handle, config, @hostname, @port)
|
|
37
|
+
unless success
|
|
38
|
+
Quicsilver.close_configuration(config)
|
|
39
|
+
cleanup_failed_connection
|
|
40
|
+
raise ConnectionError, "Failed to start connection"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Wait for connection to establish or fail
|
|
44
|
+
result = Quicsilver.wait_for_connection(context_handle, @connection_timeout)
|
|
45
|
+
|
|
46
|
+
if result.key?("error")
|
|
47
|
+
error_status = result["status"]
|
|
48
|
+
error_code = result["code"]
|
|
49
|
+
Quicsilver.close_configuration(config)
|
|
50
|
+
cleanup_failed_connection
|
|
51
|
+
error_msg = "Connection failed with status: 0x#{error_status.to_s(16)}, code: #{error_code}"
|
|
52
|
+
raise ConnectionError, error_msg
|
|
53
|
+
elsif result.key?("timeout")
|
|
54
|
+
Quicsilver.close_configuration(config)
|
|
55
|
+
cleanup_failed_connection
|
|
56
|
+
error_msg = "Connection timed out after #{@connection_timeout}ms"
|
|
57
|
+
raise TimeoutError, error_msg
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@connected = true
|
|
61
|
+
@connection_start_time = Time.now
|
|
62
|
+
|
|
63
|
+
send_control_stream
|
|
64
|
+
|
|
65
|
+
# Clean up config since connection is established
|
|
66
|
+
Quicsilver.close_configuration(config)
|
|
67
|
+
rescue => e
|
|
68
|
+
cleanup_failed_connection
|
|
69
|
+
|
|
70
|
+
if e.is_a?(ConnectionError) || e.is_a?(TimeoutError)
|
|
71
|
+
raise e
|
|
72
|
+
else
|
|
73
|
+
raise ConnectionError, "Connection failed: #{e.message}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def disconnect
|
|
78
|
+
return unless @connected || @connection_data
|
|
79
|
+
|
|
80
|
+
begin
|
|
81
|
+
if @connection_data
|
|
82
|
+
Quicsilver.close_connection_handle(@connection_data)
|
|
83
|
+
@connection_data = nil
|
|
84
|
+
end
|
|
85
|
+
rescue
|
|
86
|
+
# Ignore disconnect errors
|
|
87
|
+
ensure
|
|
88
|
+
@connected = false
|
|
89
|
+
@connection_start_time = nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def connected?
|
|
94
|
+
return false unless @connected && @connection_data
|
|
95
|
+
|
|
96
|
+
# Get connection status from the C extension
|
|
97
|
+
context_handle = @connection_data[1]
|
|
98
|
+
info = Quicsilver.connection_status(context_handle)
|
|
99
|
+
if info && info.key?("connected")
|
|
100
|
+
is_connected = info["connected"] && !info["failed"]
|
|
101
|
+
|
|
102
|
+
# If C extension says we're disconnected, update our state
|
|
103
|
+
if !is_connected && @connected
|
|
104
|
+
@connected = false
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
is_connected
|
|
108
|
+
else
|
|
109
|
+
# If we can't get status, assume disconnected
|
|
110
|
+
if @connected
|
|
111
|
+
@connected = false
|
|
112
|
+
end
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
rescue
|
|
116
|
+
# If there's an error checking status, assume disconnected
|
|
117
|
+
if @connected
|
|
118
|
+
@connected = false
|
|
119
|
+
end
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def connection_info
|
|
124
|
+
base_info = if @connection_data
|
|
125
|
+
begin
|
|
126
|
+
context_handle = @connection_data[1]
|
|
127
|
+
Quicsilver.connection_status(context_handle) || {}
|
|
128
|
+
rescue
|
|
129
|
+
{}
|
|
130
|
+
end
|
|
131
|
+
else
|
|
132
|
+
{}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
base_info.merge({
|
|
136
|
+
hostname: @hostname,
|
|
137
|
+
port: @port,
|
|
138
|
+
uptime: connection_uptime
|
|
139
|
+
})
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def connection_uptime
|
|
143
|
+
return 0 unless @connection_start_time
|
|
144
|
+
Time.now - @connection_start_time
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def send_data(data)
|
|
148
|
+
raise Error, "Not connected" unless @connected
|
|
149
|
+
|
|
150
|
+
stream = open_stream
|
|
151
|
+
unless stream
|
|
152
|
+
puts "❌ Failed to open stream"
|
|
153
|
+
return false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
result = Quicsilver.send_stream(stream, data, true)
|
|
157
|
+
puts "✅ Sent #{data.bytesize} bytes"
|
|
158
|
+
result
|
|
159
|
+
rescue => e
|
|
160
|
+
puts "❌ Send data error: #{e.class} - #{e.message}"
|
|
161
|
+
false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def cleanup_failed_connection
|
|
167
|
+
Quicsilver.close_connection_handle(@connection_data) if @connection_data
|
|
168
|
+
@connection_data = nil
|
|
169
|
+
@connected = false
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def open_stream
|
|
173
|
+
Quicsilver.open_stream(@connection_data[0], false)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def open_unidirectional_stream
|
|
177
|
+
Quicsilver.open_stream(@connection_data[0], true)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def send_control_stream
|
|
181
|
+
# Open unidirectional stream
|
|
182
|
+
stream = open_unidirectional_stream
|
|
183
|
+
|
|
184
|
+
# Build and send control stream data
|
|
185
|
+
control_data = Quicsilver::HTTP3.build_control_stream
|
|
186
|
+
Quicsilver.send_stream(stream, control_data, false)
|
|
187
|
+
|
|
188
|
+
@control_stream = stream
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module HTTP3
|
|
5
|
+
class RequestEncoder
|
|
6
|
+
def initialize(method:, path:, scheme: 'https', authority: 'localhost:4433', headers: {}, body: nil)
|
|
7
|
+
@method = method.upcase
|
|
8
|
+
@path = path
|
|
9
|
+
@scheme = scheme
|
|
10
|
+
@authority = authority
|
|
11
|
+
@headers = headers
|
|
12
|
+
@body = body
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def encode
|
|
16
|
+
frames = []
|
|
17
|
+
|
|
18
|
+
# Build HEADERS frame
|
|
19
|
+
headers_payload = encode_headers
|
|
20
|
+
frames << build_frame(0x01, headers_payload) # 0x01 = HEADERS frame
|
|
21
|
+
|
|
22
|
+
# Build DATA frame if body present
|
|
23
|
+
if @body && !@body.empty?
|
|
24
|
+
body_data = @body.is_a?(String) ? @body : @body.join
|
|
25
|
+
frames << build_frame(0x00, body_data) # 0x00 = DATA frame
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
frames.join.force_encoding(Encoding::BINARY)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def build_frame(type, payload)
|
|
34
|
+
frame_type = HTTP3.encode_varint(type)
|
|
35
|
+
frame_length = HTTP3.encode_varint(payload.bytesize)
|
|
36
|
+
frame_type + frame_length + payload
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def encode_headers
|
|
40
|
+
payload = "".b
|
|
41
|
+
|
|
42
|
+
# QPACK prefix: Required Insert Count = 0, Delta Base = 0
|
|
43
|
+
payload += "\x00\x00".b
|
|
44
|
+
|
|
45
|
+
# Encode pseudo-headers using Indexed Field Line with Post-Base Index
|
|
46
|
+
# Pattern: 0x50 (0101 0000) for :method, :scheme
|
|
47
|
+
# Pattern: 0x40 | index for :authority, :path (literal name, literal value)
|
|
48
|
+
|
|
49
|
+
# :method (use literal since GET/POST have specific indices but we want flexibility)
|
|
50
|
+
payload += encode_literal_pseudo_header(':method', @method)
|
|
51
|
+
|
|
52
|
+
# :scheme
|
|
53
|
+
payload += encode_literal_pseudo_header(':scheme', @scheme)
|
|
54
|
+
|
|
55
|
+
# :authority
|
|
56
|
+
payload += encode_literal_pseudo_header(':authority', @authority)
|
|
57
|
+
|
|
58
|
+
# :path
|
|
59
|
+
payload += encode_literal_pseudo_header(':path', @path)
|
|
60
|
+
|
|
61
|
+
# Encode regular headers
|
|
62
|
+
@headers.each do |name, value|
|
|
63
|
+
payload += encode_literal_header(name.to_s.downcase, value.to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
payload
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Literal field line with literal name for pseudo-headers
|
|
70
|
+
# Pattern: 0x50 (indexed name from static table) + value
|
|
71
|
+
def encode_literal_pseudo_header(name, value)
|
|
72
|
+
# For pseudo-headers, use indexed name reference from static table
|
|
73
|
+
# with literal value (pattern: 0101xxxx where xxxx = static table index)
|
|
74
|
+
static_index = case name
|
|
75
|
+
when ':authority' then 0
|
|
76
|
+
when ':path' then 1
|
|
77
|
+
when ':method' then (@method == 'GET' ? 17 : 20) # GET=17, POST=20
|
|
78
|
+
when ':scheme' then (@scheme == 'http' ? 22 : 23)
|
|
79
|
+
else nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
if static_index
|
|
83
|
+
# Use indexed field line (0x40 | index)
|
|
84
|
+
result = "".b
|
|
85
|
+
result += [0x40 | static_index].pack('C')
|
|
86
|
+
# For non-exact matches, append literal value
|
|
87
|
+
if name == ':authority' || name == ':path'
|
|
88
|
+
result += HTTP3.encode_varint(value.bytesize)
|
|
89
|
+
result += value.to_s.b
|
|
90
|
+
end
|
|
91
|
+
result
|
|
92
|
+
else
|
|
93
|
+
# Fallback to literal name
|
|
94
|
+
encode_literal_header(name, value)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Literal field line with literal name
|
|
99
|
+
# Pattern: 0x20 | name_length, name_bytes, value_length, value_bytes
|
|
100
|
+
def encode_literal_header(name, value)
|
|
101
|
+
result = "".b
|
|
102
|
+
# 0x20 = literal with literal name (no indexing)
|
|
103
|
+
name_len = name.bytesize
|
|
104
|
+
result += [0x20 | (name_len & 0x1F)].pack('C')
|
|
105
|
+
result += name.b
|
|
106
|
+
result += HTTP3.encode_varint(value.bytesize)
|
|
107
|
+
result += value.to_s.b
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'stringio'
|
|
4
|
+
|
|
5
|
+
module Quicsilver
|
|
6
|
+
module HTTP3
|
|
7
|
+
class RequestParser
|
|
8
|
+
attr_reader :frames, :headers, :body
|
|
9
|
+
|
|
10
|
+
def initialize(data)
|
|
11
|
+
@data = data
|
|
12
|
+
@frames = []
|
|
13
|
+
@headers = {}
|
|
14
|
+
@body = StringIO.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def parse
|
|
18
|
+
parse!
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_rack_env(stream_info = {})
|
|
22
|
+
return nil if @headers.empty?
|
|
23
|
+
|
|
24
|
+
# Extract path and query string
|
|
25
|
+
path_full = @headers[':path'] || '/'
|
|
26
|
+
path, query = path_full.split('?', 2)
|
|
27
|
+
|
|
28
|
+
# Extract host and port
|
|
29
|
+
authority = @headers[':authority'] || 'localhost:4433'
|
|
30
|
+
host, port = authority.split(':', 2)
|
|
31
|
+
port ||= '4433'
|
|
32
|
+
|
|
33
|
+
env = {
|
|
34
|
+
'REQUEST_METHOD' => @headers[':method'] || 'GET',
|
|
35
|
+
'PATH_INFO' => path,
|
|
36
|
+
'QUERY_STRING' => query || '',
|
|
37
|
+
'SERVER_NAME' => host,
|
|
38
|
+
'SERVER_PORT' => port,
|
|
39
|
+
'SERVER_PROTOCOL' => 'HTTP/3',
|
|
40
|
+
'rack.version' => [1, 3],
|
|
41
|
+
'rack.url_scheme' => @headers[':scheme'] || 'https',
|
|
42
|
+
'rack.input' => @body,
|
|
43
|
+
'rack.errors' => $stderr,
|
|
44
|
+
'rack.multithread' => true,
|
|
45
|
+
'rack.multiprocess' => false,
|
|
46
|
+
'rack.run_once' => false,
|
|
47
|
+
'rack.hijack?' => false,
|
|
48
|
+
'SCRIPT_NAME' => '',
|
|
49
|
+
'CONTENT_LENGTH' => @body.size.to_s,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Add regular headers as HTTP_*
|
|
53
|
+
@headers.each do |name, value|
|
|
54
|
+
next if name.start_with?(':')
|
|
55
|
+
env["HTTP_#{name.upcase.tr('-', '_')}"] = value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
env
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def parse!
|
|
64
|
+
buffer = @data.dup
|
|
65
|
+
offset = 0
|
|
66
|
+
|
|
67
|
+
while offset < buffer.bytesize
|
|
68
|
+
break if buffer.bytesize - offset < 2
|
|
69
|
+
|
|
70
|
+
type, type_len = HTTP3.decode_varint(buffer.bytes, offset)
|
|
71
|
+
length, length_len = HTTP3.decode_varint(buffer.bytes, offset + type_len)
|
|
72
|
+
header_len = type_len + length_len
|
|
73
|
+
|
|
74
|
+
break if buffer.bytesize < offset + header_len + length
|
|
75
|
+
|
|
76
|
+
payload = buffer[offset + header_len, length]
|
|
77
|
+
@frames << { type: type, length: length, payload: payload }
|
|
78
|
+
|
|
79
|
+
case type
|
|
80
|
+
when 0x01 # HEADERS
|
|
81
|
+
parse_headers(payload)
|
|
82
|
+
when 0x00 # DATA
|
|
83
|
+
@body.write(payload)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
offset += header_len + length
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
@body.rewind
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_headers(payload)
|
|
93
|
+
# Skip QPACK required insert count (1 byte) + delta base (1 byte)
|
|
94
|
+
offset = 2
|
|
95
|
+
return if payload.bytesize < offset
|
|
96
|
+
|
|
97
|
+
while offset < payload.bytesize
|
|
98
|
+
byte = payload.bytes[offset]
|
|
99
|
+
|
|
100
|
+
# Indexed field line (static table, starts with 0x4X or 0x5X)
|
|
101
|
+
if (byte & 0xC0) == 0x40 || (byte & 0xC0) == 0x80
|
|
102
|
+
index = byte & 0x3F
|
|
103
|
+
offset += 1
|
|
104
|
+
|
|
105
|
+
# Check if this is a complete field (name+value) or just name
|
|
106
|
+
field = decode_static_table_field(index)
|
|
107
|
+
|
|
108
|
+
if field.is_a?(Hash)
|
|
109
|
+
# Complete field with both name and value (e.g., :method GET)
|
|
110
|
+
@headers.merge!(field)
|
|
111
|
+
elsif field
|
|
112
|
+
# Name-only entry, value follows
|
|
113
|
+
value_len, len_bytes = HTTP3.decode_varint(payload.bytes, offset)
|
|
114
|
+
offset += len_bytes
|
|
115
|
+
value = payload[offset, value_len]
|
|
116
|
+
offset += value_len
|
|
117
|
+
@headers[field] = value
|
|
118
|
+
end
|
|
119
|
+
# Literal with literal name (starts with 0x2X)
|
|
120
|
+
elsif (byte & 0xE0) == 0x20
|
|
121
|
+
name_len = byte & 0x1F
|
|
122
|
+
offset += 1
|
|
123
|
+
name = payload[offset, name_len]
|
|
124
|
+
offset += name_len
|
|
125
|
+
|
|
126
|
+
value_len, len_bytes = HTTP3.decode_varint(payload.bytes, offset)
|
|
127
|
+
offset += len_bytes
|
|
128
|
+
value = payload[offset, value_len]
|
|
129
|
+
offset += value_len
|
|
130
|
+
|
|
131
|
+
@headers[name] = value
|
|
132
|
+
else
|
|
133
|
+
break # Unknown encoding
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# QPACK static table decoder (RFC 9204 Appendix A)
|
|
139
|
+
# Returns Hash for complete fields, String for name-only fields
|
|
140
|
+
def decode_static_table_field(index)
|
|
141
|
+
case index
|
|
142
|
+
when 0 then ':authority' # Name only
|
|
143
|
+
when 1 then ':path' # Name only
|
|
144
|
+
when 15 then {':method' => 'CONNECT'}
|
|
145
|
+
when 16 then {':method' => 'DELETE'}
|
|
146
|
+
when 17 then {':method' => 'GET'}
|
|
147
|
+
when 18 then {':method' => 'HEAD'}
|
|
148
|
+
when 19 then {':method' => 'OPTIONS'}
|
|
149
|
+
when 20 then {':method' => 'POST'}
|
|
150
|
+
when 21 then {':method' => 'PUT'}
|
|
151
|
+
when 22 then {':scheme' => 'http'}
|
|
152
|
+
when 23 then {':scheme' => 'https'}
|
|
153
|
+
else nil
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module HTTP3
|
|
5
|
+
class ResponseEncoder
|
|
6
|
+
def initialize(status, headers, body)
|
|
7
|
+
@status = status
|
|
8
|
+
@headers = headers
|
|
9
|
+
@body = body
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def encode
|
|
13
|
+
frames = ""
|
|
14
|
+
|
|
15
|
+
# HEADERS frame
|
|
16
|
+
frames += encode_headers_frame
|
|
17
|
+
|
|
18
|
+
# DATA frame(s)
|
|
19
|
+
@body.each do |chunk|
|
|
20
|
+
frames += encode_data_frame(chunk) unless chunk.empty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
@body.close if @body.respond_to?(:close)
|
|
24
|
+
|
|
25
|
+
frames
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def encode_headers_frame
|
|
31
|
+
payload = encode_qpack_response
|
|
32
|
+
|
|
33
|
+
frame_type = HTTP3.encode_varint(0x01) # HEADERS
|
|
34
|
+
frame_length = HTTP3.encode_varint(payload.bytesize)
|
|
35
|
+
|
|
36
|
+
frame_type + frame_length + payload
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def encode_data_frame(data)
|
|
40
|
+
frame_type = HTTP3.encode_varint(0x00) # DATA
|
|
41
|
+
frame_length = HTTP3.encode_varint(data.bytesize)
|
|
42
|
+
|
|
43
|
+
frame_type + frame_length + data
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def encode_qpack_response
|
|
47
|
+
# QPACK prefix: Required Insert Count = 0, Delta Base = 0
|
|
48
|
+
encoded = [0x00, 0x00].pack('C*')
|
|
49
|
+
|
|
50
|
+
# :status pseudo-header (literal indexed)
|
|
51
|
+
status_str = @status.to_s
|
|
52
|
+
encoded += [0x58, status_str.bytesize].pack('C*')
|
|
53
|
+
encoded += status_str
|
|
54
|
+
|
|
55
|
+
# Regular headers (literal with literal name)
|
|
56
|
+
@headers.each do |name, value|
|
|
57
|
+
next if name.start_with?('rack.') # Skip Rack internals
|
|
58
|
+
|
|
59
|
+
name = name.downcase
|
|
60
|
+
value = value.to_s
|
|
61
|
+
|
|
62
|
+
# Literal field line with literal name (0x2X prefix)
|
|
63
|
+
encoded += [0x20 | name.bytesize].pack('C')
|
|
64
|
+
encoded += name
|
|
65
|
+
encoded += HTTP3.encode_varint(value.bytesize)
|
|
66
|
+
encoded += value
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
encoded
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
module HTTP3
|
|
5
|
+
# Encode variable-length integer
|
|
6
|
+
def self.encode_varint(value)
|
|
7
|
+
case value
|
|
8
|
+
when 0..63
|
|
9
|
+
[value].pack('C')
|
|
10
|
+
when 64..16383
|
|
11
|
+
[0x40 | (value >> 8), value & 0xFF].pack('C*')
|
|
12
|
+
when 16384..1073741823
|
|
13
|
+
[0x80 | (value >> 24), (value >> 16) & 0xFF,
|
|
14
|
+
(value >> 8) & 0xFF, value & 0xFF].pack('C*')
|
|
15
|
+
else
|
|
16
|
+
[0xC0 | (value >> 56), (value >> 48) & 0xFF,
|
|
17
|
+
(value >> 40) & 0xFF, (value >> 32) & 0xFF,
|
|
18
|
+
(value >> 24) & 0xFF, (value >> 16) & 0xFF,
|
|
19
|
+
(value >> 8) & 0xFF, value & 0xFF].pack('C*')
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.build_settings_frame(settings = {})
|
|
24
|
+
payload = ""
|
|
25
|
+
settings.each do |id, value|
|
|
26
|
+
payload += encode_varint(id)
|
|
27
|
+
payload += encode_varint(value)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
frame_type = encode_varint(0x04) # SETTINGS
|
|
31
|
+
frame_length = encode_varint(payload.bytesize)
|
|
32
|
+
|
|
33
|
+
frame_type + frame_length + payload
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Build control stream data
|
|
37
|
+
def self.build_control_stream
|
|
38
|
+
stream_type = [0x00].pack('C') # Control stream type
|
|
39
|
+
settings = build_settings_frame({
|
|
40
|
+
# 0x01 => 4096, # QPACK_MAX_TABLE_CAPACITY (optional)
|
|
41
|
+
# 0x06 => 16384 # MAX_HEADER_LIST_SIZE (optional)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
stream_type + settings
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Decode variable-length integer (RFC 9000)
|
|
48
|
+
# Returns [value, bytes_consumed]
|
|
49
|
+
def self.decode_varint(bytes, offset = 0)
|
|
50
|
+
first = bytes[offset]
|
|
51
|
+
case (first & 0xC0) >> 6
|
|
52
|
+
when 0
|
|
53
|
+
[first & 0x3F, 1]
|
|
54
|
+
when 1
|
|
55
|
+
[(first & 0x3F) << 8 | bytes[offset + 1], 2]
|
|
56
|
+
when 2
|
|
57
|
+
[(first & 0x3F) << 24 | bytes[offset + 1] << 16 |
|
|
58
|
+
bytes[offset + 2] << 8 | bytes[offset + 3], 4]
|
|
59
|
+
else # when 3
|
|
60
|
+
[(first & 0x3F) << 56 | bytes[offset + 1] << 48 |
|
|
61
|
+
bytes[offset + 2] << 40 | bytes[offset + 3] << 32 |
|
|
62
|
+
bytes[offset + 4] << 24 | bytes[offset + 5] << 16 |
|
|
63
|
+
bytes[offset + 6] << 8 | bytes[offset + 7], 8]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Quicsilver
|
|
4
|
+
class ListenerData
|
|
5
|
+
attr_reader :listener_handle, :context_handle, :started, :stopped, :failed, :configuration
|
|
6
|
+
|
|
7
|
+
def initialize(listener_handle, context_handle)
|
|
8
|
+
@listener_handle = listener_handle # The MSQUIC listener handle
|
|
9
|
+
@context_handle = context_handle # The C context pointer
|
|
10
|
+
# NOTE: Fetch this from the context handle, or improve return values from the C extension
|
|
11
|
+
@started = false
|
|
12
|
+
@stopped = false
|
|
13
|
+
@failed = false
|
|
14
|
+
@configuration = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def started?
|
|
18
|
+
@started
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def stopped?
|
|
22
|
+
@stopped
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def failed?
|
|
26
|
+
@failed
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|