hyperion-rb 1.6.2 → 2.11.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 +4 -4
- data/CHANGELOG.md +4768 -0
- data/README.md +222 -13
- data/ext/hyperion_h2_codec/Cargo.lock +7 -0
- data/ext/hyperion_h2_codec/Cargo.toml +33 -0
- data/ext/hyperion_h2_codec/extconf.rb +73 -0
- data/ext/hyperion_h2_codec/src/frames.rs +140 -0
- data/ext/hyperion_h2_codec/src/hpack/huffman.rs +161 -0
- data/ext/hyperion_h2_codec/src/hpack.rs +457 -0
- data/ext/hyperion_h2_codec/src/lib.rs +296 -0
- data/ext/hyperion_http/extconf.rb +28 -0
- data/ext/hyperion_http/h2_codec_glue.c +408 -0
- data/ext/hyperion_http/page_cache.c +1125 -0
- data/ext/hyperion_http/parser.c +473 -38
- data/ext/hyperion_http/sendfile.c +982 -0
- data/ext/hyperion_http/websocket.c +493 -0
- data/ext/hyperion_io_uring/Cargo.lock +33 -0
- data/ext/hyperion_io_uring/Cargo.toml +34 -0
- data/ext/hyperion_io_uring/extconf.rb +74 -0
- data/ext/hyperion_io_uring/src/lib.rs +316 -0
- data/lib/hyperion/adapter/rack.rb +370 -42
- data/lib/hyperion/admin_listener.rb +207 -0
- data/lib/hyperion/admin_middleware.rb +36 -7
- data/lib/hyperion/cli.rb +310 -11
- data/lib/hyperion/config.rb +440 -14
- data/lib/hyperion/connection.rb +679 -22
- data/lib/hyperion/deprecations.rb +81 -0
- data/lib/hyperion/dispatch_mode.rb +165 -0
- data/lib/hyperion/fiber_local.rb +75 -13
- data/lib/hyperion/h2_admission.rb +77 -0
- data/lib/hyperion/h2_codec.rb +499 -0
- data/lib/hyperion/http/page_cache.rb +122 -0
- data/lib/hyperion/http/sendfile.rb +696 -0
- data/lib/hyperion/http2/native_hpack_adapter.rb +70 -0
- data/lib/hyperion/http2_handler.rb +618 -19
- data/lib/hyperion/io_uring.rb +317 -0
- data/lib/hyperion/lint_wrapper_pool.rb +126 -0
- data/lib/hyperion/master.rb +96 -9
- data/lib/hyperion/metrics/path_templater.rb +68 -0
- data/lib/hyperion/metrics.rb +256 -0
- data/lib/hyperion/prometheus_exporter.rb +150 -0
- data/lib/hyperion/request.rb +13 -0
- data/lib/hyperion/response_writer.rb +477 -16
- data/lib/hyperion/runtime.rb +195 -0
- data/lib/hyperion/server/route_table.rb +179 -0
- data/lib/hyperion/server.rb +519 -55
- data/lib/hyperion/static_preload.rb +133 -0
- data/lib/hyperion/thread_pool.rb +61 -7
- data/lib/hyperion/tls.rb +343 -1
- data/lib/hyperion/version.rb +1 -1
- data/lib/hyperion/websocket/close_codes.rb +71 -0
- data/lib/hyperion/websocket/connection.rb +876 -0
- data/lib/hyperion/websocket/frame.rb +356 -0
- data/lib/hyperion/websocket/handshake.rb +525 -0
- data/lib/hyperion/worker.rb +111 -9
- data/lib/hyperion.rb +137 -3
- metadata +50 -1
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'digest/sha1'
|
|
5
|
+
|
|
6
|
+
module Hyperion
|
|
7
|
+
# WS-2 (2.1.0) — RFC 6455 §1.3 / §4.2 HTTP/1.1 → WebSocket handshake.
|
|
8
|
+
#
|
|
9
|
+
# This module is intentionally narrow: given a Rack env (Hyperion's adapter
|
|
10
|
+
# has just built it from the parsed Hyperion::Request), validate that the
|
|
11
|
+
# request is a well-formed WebSocket upgrade attempt and compute the
|
|
12
|
+
# `Sec-WebSocket-Accept` header value the client expects to see in the
|
|
13
|
+
# 101 response. Hyperion does NOT write the 101 itself — that's the
|
|
14
|
+
# application's responsibility (faye-websocket / ActionCable convention,
|
|
15
|
+
# Option B in the WS-2 plan). All Hyperion does is:
|
|
16
|
+
#
|
|
17
|
+
# 1. Detect upgrade requests (Connection: upgrade + Upgrade: websocket)
|
|
18
|
+
# 2. Validate them per RFC 6455 §4.2.1
|
|
19
|
+
# 3. Stash the result in env['hyperion.websocket.handshake'] so the app
|
|
20
|
+
# (or a middleware like rack-websocket) can echo the right
|
|
21
|
+
# Sec-WebSocket-Accept header without re-doing the SHA-1/base64 dance
|
|
22
|
+
# 4. Short-circuit a 400 / 426 on validation failure BEFORE the app
|
|
23
|
+
# sees the env (the app shouldn't have to know about malformed
|
|
24
|
+
# WS handshake attempts — that's protocol-level)
|
|
25
|
+
#
|
|
26
|
+
# WS-1 supplies the hijack primitive (env['rack.hijack'].call → live
|
|
27
|
+
# socket); WS-3 supplies frame ser/de (Hyperion::WebSocket::Parser /
|
|
28
|
+
# ::Builder); WS-4 will compose all three into a Hyperion::WebSocket::Connection
|
|
29
|
+
# wrapper. WS-2 owns ONLY the HTTP-side handshake.
|
|
30
|
+
module WebSocket
|
|
31
|
+
# GUID from RFC 6455 §1.3 — concatenated with the client's
|
|
32
|
+
# Sec-WebSocket-Key, SHA-1'd, base64'd to compute the accept value.
|
|
33
|
+
GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
34
|
+
|
|
35
|
+
# RFC 6455 §4.2.1: only protocol version 13 is supported. The handshake
|
|
36
|
+
# responds 426 Upgrade Required + `Sec-WebSocket-Version: 13` so the
|
|
37
|
+
# client knows the right version to retry with.
|
|
38
|
+
SUPPORTED_VERSION = '13'
|
|
39
|
+
|
|
40
|
+
# Public end-user API: applications / middleware can `rescue` this to
|
|
41
|
+
# know "this was a WS upgrade attempt that failed at validation, NOT
|
|
42
|
+
# an unrelated 4xx". WS-2 itself doesn't raise this — it's supplied
|
|
43
|
+
# for downstream consumers who want to build their own WS facade on
|
|
44
|
+
# top of `Handshake.validate`.
|
|
45
|
+
class HandshakeError < StandardError
|
|
46
|
+
attr_reader :status, :extra_headers
|
|
47
|
+
|
|
48
|
+
def initialize(status, message, extra_headers = {})
|
|
49
|
+
super(message)
|
|
50
|
+
@status = status
|
|
51
|
+
@extra_headers = extra_headers
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
module Handshake
|
|
56
|
+
# Common-case header keys looked up in env. All UPPER_SNAKE; values
|
|
57
|
+
# come from the Rack adapter's HTTP_KEY_CACHE (frozen) so straight
|
|
58
|
+
# `env[KEY]` is cheaper than building the key per call.
|
|
59
|
+
UPGRADE_KEY = 'HTTP_UPGRADE'
|
|
60
|
+
CONNECTION_KEY = 'HTTP_CONNECTION'
|
|
61
|
+
WS_KEY_KEY = 'HTTP_SEC_WEBSOCKET_KEY'
|
|
62
|
+
WS_VERSION_KEY = 'HTTP_SEC_WEBSOCKET_VERSION'
|
|
63
|
+
WS_PROTO_KEY = 'HTTP_SEC_WEBSOCKET_PROTOCOL'
|
|
64
|
+
WS_EXT_KEY = 'HTTP_SEC_WEBSOCKET_EXTENSIONS'
|
|
65
|
+
ORIGIN_KEY = 'HTTP_ORIGIN'
|
|
66
|
+
HOST_KEY = 'HTTP_HOST'
|
|
67
|
+
METHOD_KEY = 'REQUEST_METHOD'
|
|
68
|
+
PROTO_KEY = 'SERVER_PROTOCOL'
|
|
69
|
+
|
|
70
|
+
# Phase 11 — frozen sentinel returned by `validate` for plain HTTP
|
|
71
|
+
# requests (the overwhelmingly common branch). Pre-Phase-11 the
|
|
72
|
+
# function allocated a fresh `[:not_websocket, nil, nil]` Array on
|
|
73
|
+
# every non-WS request — one Array per HTTP request. The caller
|
|
74
|
+
# only reads `.first` and `case` on the tag, never mutates the
|
|
75
|
+
# tuple, so a frozen shared instance is safe.
|
|
76
|
+
#
|
|
77
|
+
# 2.3-C: the handshake result tuple now has a 4th slot (`extensions`)
|
|
78
|
+
# for permessage-deflate parameters. Existing destructuring of
|
|
79
|
+
# `[:ok, accept, sub]` is unchanged; the 4th slot is appended and
|
|
80
|
+
# ignored by 3-arg callers. The `:not_websocket` sentinel keeps the
|
|
81
|
+
# 4-slot shape with a frozen empty hash so `.frozen?` invariants on
|
|
82
|
+
# the slot stay stable.
|
|
83
|
+
NOT_WEBSOCKET_RESULT = [:not_websocket, nil, nil, {}].freeze
|
|
84
|
+
EMPTY_EXTENSIONS = {}.freeze
|
|
85
|
+
|
|
86
|
+
# RFC 7692 §7.1 — permessage-deflate extension token + parameter
|
|
87
|
+
# names. We accept these spellings only (case-sensitive per RFC).
|
|
88
|
+
PERMESSAGE_DEFLATE = 'permessage-deflate'
|
|
89
|
+
PARAM_SERVER_NO_TAKEOVER = 'server_no_context_takeover'
|
|
90
|
+
PARAM_CLIENT_NO_TAKEOVER = 'client_no_context_takeover'
|
|
91
|
+
PARAM_SERVER_MAX_WINDOW = 'server_max_window_bits'
|
|
92
|
+
PARAM_CLIENT_MAX_WINDOW = 'client_max_window_bits'
|
|
93
|
+
|
|
94
|
+
# RFC 7692 §7.1.2.2 — window_bits range. RFC says 8..15, but
|
|
95
|
+
# zlib's raw deflate rejects window_bits=8 in some versions; we
|
|
96
|
+
# clamp to 9..15 in practice, the lower bound matches what
|
|
97
|
+
# browsers actually use.
|
|
98
|
+
MIN_WINDOW_BITS = 9
|
|
99
|
+
MAX_WINDOW_BITS = 15
|
|
100
|
+
DEFAULT_WINDOW_BITS = 15
|
|
101
|
+
|
|
102
|
+
# Validate WS-upgrade preconditions on a Rack env.
|
|
103
|
+
#
|
|
104
|
+
# Returns a 4-tuple. The first slot is a Symbol tag the caller
|
|
105
|
+
# branches on:
|
|
106
|
+
#
|
|
107
|
+
# [:ok, accept_header_value, selected_subprotocol_or_nil,
|
|
108
|
+
# negotiated_extensions]
|
|
109
|
+
# — request is a valid RFC 6455 §4.2.1 handshake. Caller should
|
|
110
|
+
# stash the tuple in env and let the app handle the 101.
|
|
111
|
+
# `negotiated_extensions` is a Hash keyed by extension symbol;
|
|
112
|
+
# `{}` when no extension was negotiated. For permessage-deflate
|
|
113
|
+
# (RFC 7692) the value carries the resolved parameter set:
|
|
114
|
+
# {
|
|
115
|
+
# permessage_deflate: {
|
|
116
|
+
# server_no_context_takeover: false,
|
|
117
|
+
# client_no_context_takeover: false,
|
|
118
|
+
# server_max_window_bits: 15,
|
|
119
|
+
# client_max_window_bits: 15
|
|
120
|
+
# }
|
|
121
|
+
# }
|
|
122
|
+
#
|
|
123
|
+
# [:bad_request, body, extra_headers]
|
|
124
|
+
# — request is a WS upgrade attempt with a protocol error
|
|
125
|
+
# (missing/invalid Sec-WebSocket-Key, wrong method, etc.).
|
|
126
|
+
# Caller short-circuits a 400.
|
|
127
|
+
#
|
|
128
|
+
# [:upgrade_required, body, extra_headers]
|
|
129
|
+
# — Sec-WebSocket-Version is missing or not 13.
|
|
130
|
+
# `extra_headers` always includes `'sec-websocket-version' => '13'`
|
|
131
|
+
# so the client sees the version Hyperion supports.
|
|
132
|
+
# Caller short-circuits a 426 (RFC 6455 §4.4).
|
|
133
|
+
#
|
|
134
|
+
# [:not_websocket, nil, nil]
|
|
135
|
+
# — request is not a WS upgrade (no Upgrade header, or Upgrade:
|
|
136
|
+
# value other than `websocket`). Caller proceeds with the
|
|
137
|
+
# normal HTTP flow. We don't trip on h2c / other Upgrade
|
|
138
|
+
# variants — only `websocket` is intercepted.
|
|
139
|
+
#
|
|
140
|
+
# Optional kwargs:
|
|
141
|
+
#
|
|
142
|
+
# subprotocol_selector — a Proc that receives the array of
|
|
143
|
+
# client-offered subprotocols (parsed from
|
|
144
|
+
# Sec-WebSocket-Protocol). Returns:
|
|
145
|
+
# * a String matching one of the offers → echoed back in the
|
|
146
|
+
# Sec-WebSocket-Protocol response header
|
|
147
|
+
# * nil → no Sec-WebSocket-Protocol header (server silently
|
|
148
|
+
# declines, RFC 6455 §4.2.2)
|
|
149
|
+
# * a String NOT matching any offer → treated as nil (server
|
|
150
|
+
# MUST NOT pick a protocol the client didn't offer)
|
|
151
|
+
#
|
|
152
|
+
# origin_allow_list — an Array of allowed Origin header values.
|
|
153
|
+
# When nil (default), any Origin (including missing) is accepted
|
|
154
|
+
# — browsers enforce CORS-style restrictions on the WS upgrade
|
|
155
|
+
# independently. Pass [] to reject all browser-originated WS,
|
|
156
|
+
# pass ['https://example.com'] to allow only that origin.
|
|
157
|
+
def self.validate(env, subprotocol_selector: nil, origin_allow_list: default_origin_allow_list,
|
|
158
|
+
permessage_deflate: :auto)
|
|
159
|
+
return NOT_WEBSOCKET_RESULT unless websocket_upgrade?(env)
|
|
160
|
+
|
|
161
|
+
# Once we've decided this IS a WS attempt, every subsequent
|
|
162
|
+
# validation failure is a 4xx, NOT a passthrough. The order
|
|
163
|
+
# below mirrors RFC 6455 §4.2.1's MUST list.
|
|
164
|
+
|
|
165
|
+
return bad_request('WebSocket upgrade requires GET') unless env[METHOD_KEY] == 'GET'
|
|
166
|
+
|
|
167
|
+
proto = env[PROTO_KEY].to_s
|
|
168
|
+
unless proto.start_with?('HTTP/') &&
|
|
169
|
+
http_version_at_least_1_1?(proto)
|
|
170
|
+
return bad_request('WebSocket upgrade requires HTTP/1.1+')
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
host = env[HOST_KEY]
|
|
174
|
+
return bad_request('Host header required') if host.nil? || host.empty?
|
|
175
|
+
|
|
176
|
+
# Sec-WebSocket-Version check before Sec-WebSocket-Key so a
|
|
177
|
+
# client speaking the old hixie-76 / draft-08 dialect gets the
|
|
178
|
+
# 426 hint to upgrade rather than a generic 400 on the missing
|
|
179
|
+
# key (the old dialect uses different key headers).
|
|
180
|
+
version = env[WS_VERSION_KEY]
|
|
181
|
+
unless version == SUPPORTED_VERSION
|
|
182
|
+
return [
|
|
183
|
+
:upgrade_required,
|
|
184
|
+
"Unsupported Sec-WebSocket-Version (need #{SUPPORTED_VERSION})",
|
|
185
|
+
{ 'sec-websocket-version' => SUPPORTED_VERSION }
|
|
186
|
+
]
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
client_key = env[WS_KEY_KEY]
|
|
190
|
+
return bad_request('Sec-WebSocket-Key required') if client_key.nil? || client_key.empty?
|
|
191
|
+
|
|
192
|
+
return bad_request('Sec-WebSocket-Key must decode to 16 bytes') unless valid_client_key?(client_key)
|
|
193
|
+
|
|
194
|
+
if origin_allow_list && !origin_allowed?(env[ORIGIN_KEY], origin_allow_list)
|
|
195
|
+
return bad_request('Origin not in allow-list')
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
accept = accept_value(client_key)
|
|
199
|
+
subprotocol = pick_subprotocol(env[WS_PROTO_KEY], subprotocol_selector)
|
|
200
|
+
|
|
201
|
+
# RFC 7692 negotiation. Returns either a {permessage_deflate: {...}}
|
|
202
|
+
# hash or `EMPTY_EXTENSIONS`. With `permessage_deflate: :on` and
|
|
203
|
+
# no client offer, returns the bad_request tuple itself — the
|
|
204
|
+
# operator opted into "compression-required" semantics.
|
|
205
|
+
extensions = negotiate_extensions(env[WS_EXT_KEY], permessage_deflate)
|
|
206
|
+
return extensions if extensions.is_a?(Array) && extensions.first == :bad_request
|
|
207
|
+
|
|
208
|
+
[:ok, accept, subprotocol, extensions]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Compute the Sec-WebSocket-Accept value per RFC 6455 §4.2.2:
|
|
212
|
+
# base64( SHA1( client_key + GUID ) ).
|
|
213
|
+
#
|
|
214
|
+
# Test vector: key="dGhlIHNhbXBsZSBub25jZQ==" → "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
|
|
215
|
+
def self.accept_value(client_key)
|
|
216
|
+
Base64.strict_encode64(Digest::SHA1.digest("#{client_key}#{GUID}"))
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Build the wire bytes of the 101 Switching Protocols response.
|
|
220
|
+
# Apps that don't want to hand-roll headers can call this and write
|
|
221
|
+
# the result to `env['rack.hijack'].call` (the raw socket) before
|
|
222
|
+
# sending any frames. Header keys are lowercased (RFC 7230 says
|
|
223
|
+
# field names are case-insensitive; lowercasing matches what every
|
|
224
|
+
# other Hyperion writer does).
|
|
225
|
+
#
|
|
226
|
+
# accept_value — String, the value of Sec-WebSocket-Accept
|
|
227
|
+
# subprotocol — String or nil, echoed back in
|
|
228
|
+
# Sec-WebSocket-Protocol when non-nil
|
|
229
|
+
# extra_headers — Hash<String,String>, any additional 101
|
|
230
|
+
# headers (e.g. Sec-WebSocket-Extensions for
|
|
231
|
+
# permessage-deflate, when negotiated by the app)
|
|
232
|
+
def self.build_101_response(accept_value, subprotocol = nil, extra_headers = {})
|
|
233
|
+
lines = String.new(encoding: Encoding::ASCII_8BIT)
|
|
234
|
+
lines << "HTTP/1.1 101 Switching Protocols\r\n"
|
|
235
|
+
lines << "upgrade: websocket\r\n"
|
|
236
|
+
lines << "connection: Upgrade\r\n"
|
|
237
|
+
lines << "sec-websocket-accept: #{accept_value}\r\n"
|
|
238
|
+
lines << "sec-websocket-protocol: #{subprotocol}\r\n" if subprotocol
|
|
239
|
+
extra_headers.each do |k, v|
|
|
240
|
+
lines << "#{k.to_s.downcase}: #{v}\r\n"
|
|
241
|
+
end
|
|
242
|
+
lines << "\r\n"
|
|
243
|
+
lines
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Render the negotiated `extensions` hash from `validate` as the
|
|
247
|
+
# `sec-websocket-extensions` header value the server should echo
|
|
248
|
+
# back in the 101 response. Returns nil when nothing was
|
|
249
|
+
# negotiated (caller should omit the header). Operators can pass
|
|
250
|
+
# the result straight into the `extra_headers` slot of
|
|
251
|
+
# `build_101_response`:
|
|
252
|
+
#
|
|
253
|
+
# ext_value = Handshake.format_extensions_header(extensions)
|
|
254
|
+
# extras = ext_value ? { 'sec-websocket-extensions' => ext_value } : {}
|
|
255
|
+
# socket.write(Handshake.build_101_response(accept, sub, extras))
|
|
256
|
+
def self.format_extensions_header(extensions)
|
|
257
|
+
return nil if extensions.nil? || extensions.empty?
|
|
258
|
+
|
|
259
|
+
params = extensions[:permessage_deflate]
|
|
260
|
+
return nil if params.nil?
|
|
261
|
+
|
|
262
|
+
parts = [PERMESSAGE_DEFLATE]
|
|
263
|
+
parts << PARAM_SERVER_NO_TAKEOVER if params[:server_no_context_takeover]
|
|
264
|
+
parts << PARAM_CLIENT_NO_TAKEOVER if params[:client_no_context_takeover]
|
|
265
|
+
# Only echo window-bits parameters if the negotiated value
|
|
266
|
+
# differs from the RFC default of 15. RFC 7692 §7.1.2.1 says
|
|
267
|
+
# the absence of the parameter means 15 bits; including it
|
|
268
|
+
# redundantly is allowed but adds wire bytes for no win.
|
|
269
|
+
if (server_max = params[:server_max_window_bits]) && server_max != DEFAULT_WINDOW_BITS
|
|
270
|
+
parts << "#{PARAM_SERVER_MAX_WINDOW}=#{server_max}"
|
|
271
|
+
end
|
|
272
|
+
if (client_max = params[:client_max_window_bits]) && client_max != DEFAULT_WINDOW_BITS
|
|
273
|
+
parts << "#{PARAM_CLIENT_MAX_WINDOW}=#{client_max}"
|
|
274
|
+
end
|
|
275
|
+
parts.join('; ')
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Default origin allow-list. nil = accept any origin (the safe
|
|
279
|
+
# default for backend services where browsers enforce CORS-style
|
|
280
|
+
# restrictions independently). Operators can override via the env
|
|
281
|
+
# var fallback `HYPERION_WS_ORIGIN_ALLOW_LIST` (comma-separated)
|
|
282
|
+
# without needing to thread a Hyperion::Config DSL change in.
|
|
283
|
+
def self.default_origin_allow_list
|
|
284
|
+
raw = ENV.fetch('HYPERION_WS_ORIGIN_ALLOW_LIST', nil)
|
|
285
|
+
return nil if raw.nil? || raw.empty?
|
|
286
|
+
|
|
287
|
+
raw.split(',').map(&:strip).reject(&:empty?)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# --- internals below ---------------------------------------------
|
|
291
|
+
|
|
292
|
+
def self.websocket_upgrade?(env)
|
|
293
|
+
upgrade = env[UPGRADE_KEY]
|
|
294
|
+
return false if upgrade.nil? || upgrade.empty?
|
|
295
|
+
|
|
296
|
+
# RFC 6455 §4.1: Upgrade may carry a comma-separated token list
|
|
297
|
+
# in theory, but in practice browsers always send a single
|
|
298
|
+
# token. Accept either; we just need `websocket` to appear.
|
|
299
|
+
return false unless tokenize(upgrade).any? { |t| t.casecmp?('websocket') }
|
|
300
|
+
|
|
301
|
+
# Connection MUST contain `upgrade` (case-insensitive) but the
|
|
302
|
+
# full value can be a token list like `keep-alive, Upgrade` —
|
|
303
|
+
# particularly common from older Firefox + some proxy chains.
|
|
304
|
+
connection = env[CONNECTION_KEY]
|
|
305
|
+
return false if connection.nil? || connection.empty?
|
|
306
|
+
|
|
307
|
+
tokenize(connection).any? { |t| t.casecmp?('upgrade') }
|
|
308
|
+
end
|
|
309
|
+
private_class_method :websocket_upgrade?
|
|
310
|
+
|
|
311
|
+
def self.tokenize(header_value)
|
|
312
|
+
header_value.to_s.split(',').map(&:strip)
|
|
313
|
+
end
|
|
314
|
+
private_class_method :tokenize
|
|
315
|
+
|
|
316
|
+
def self.http_version_at_least_1_1?(proto)
|
|
317
|
+
# proto is like "HTTP/1.1" or "HTTP/2.0" — split off the version
|
|
318
|
+
# tail and compare numerically. RFC 6455 says "HTTP/1.1 or higher".
|
|
319
|
+
version = proto.sub('HTTP/', '')
|
|
320
|
+
major_s, minor_s = version.split('.', 2)
|
|
321
|
+
major = major_s.to_i
|
|
322
|
+
minor = minor_s.to_i
|
|
323
|
+
return true if major > 1
|
|
324
|
+
return false if major < 1
|
|
325
|
+
|
|
326
|
+
minor >= 1
|
|
327
|
+
end
|
|
328
|
+
private_class_method :http_version_at_least_1_1?
|
|
329
|
+
|
|
330
|
+
# RFC 6455 §4.1: client key MUST be a base64-encoded random nonce of
|
|
331
|
+
# 16 bytes. Validate by decoding strictly (no newlines, no padding
|
|
332
|
+
# tolerance) and asserting decoded length. A key like
|
|
333
|
+
# `not-base64!!` decodes to garbage of arbitrary length, so we
|
|
334
|
+
# rescue ArgumentError from strict_decode64 and treat it as invalid.
|
|
335
|
+
def self.valid_client_key?(client_key)
|
|
336
|
+
decoded = Base64.strict_decode64(client_key)
|
|
337
|
+
decoded.bytesize == 16
|
|
338
|
+
rescue ArgumentError
|
|
339
|
+
false
|
|
340
|
+
end
|
|
341
|
+
private_class_method :valid_client_key?
|
|
342
|
+
|
|
343
|
+
def self.origin_allowed?(origin, allow_list)
|
|
344
|
+
# An empty / missing Origin is allowed only if the allow-list
|
|
345
|
+
# explicitly includes nil or the empty string. This matches the
|
|
346
|
+
# principle of "browsers send Origin, non-browsers may not — if
|
|
347
|
+
# you've configured an allow-list, you've decided non-browsers
|
|
348
|
+
# don't get a free pass".
|
|
349
|
+
return true if allow_list.nil?
|
|
350
|
+
|
|
351
|
+
allow_list.any? { |allowed| allowed == origin }
|
|
352
|
+
end
|
|
353
|
+
private_class_method :origin_allowed?
|
|
354
|
+
|
|
355
|
+
def self.pick_subprotocol(header_value, selector)
|
|
356
|
+
return nil if selector.nil?
|
|
357
|
+
return nil if header_value.nil? || header_value.empty?
|
|
358
|
+
|
|
359
|
+
offered = tokenize(header_value)
|
|
360
|
+
return nil if offered.empty?
|
|
361
|
+
|
|
362
|
+
chosen = selector.call(offered)
|
|
363
|
+
return nil unless chosen.is_a?(String)
|
|
364
|
+
return nil unless offered.include?(chosen)
|
|
365
|
+
|
|
366
|
+
chosen
|
|
367
|
+
end
|
|
368
|
+
private_class_method :pick_subprotocol
|
|
369
|
+
|
|
370
|
+
def self.bad_request(message)
|
|
371
|
+
[:bad_request, message, {}]
|
|
372
|
+
end
|
|
373
|
+
private_class_method :bad_request
|
|
374
|
+
|
|
375
|
+
# RFC 7692 negotiation. `header_value` is the raw request-side
|
|
376
|
+
# `Sec-WebSocket-Extensions` header (may be nil / empty / multi-
|
|
377
|
+
# offer). `policy` is one of:
|
|
378
|
+
#
|
|
379
|
+
# :off — server never advertises permessage-deflate; returns
|
|
380
|
+
# EMPTY_EXTENSIONS regardless of client offers.
|
|
381
|
+
# :auto — accept if the client offered any usable variant of
|
|
382
|
+
# permessage-deflate; otherwise return EMPTY_EXTENSIONS.
|
|
383
|
+
# This is the safe default — backwards compatible with
|
|
384
|
+
# clients that don't offer the extension.
|
|
385
|
+
# :on — require permessage-deflate. If the client didn't offer
|
|
386
|
+
# a usable variant, return a bad_request tuple so the
|
|
387
|
+
# caller short-circuits a 400. Operators only flip this
|
|
388
|
+
# on when they've measured savings on their workload AND
|
|
389
|
+
# controlled the client population.
|
|
390
|
+
#
|
|
391
|
+
# Per RFC 7692 §5.1 the request header may carry multiple offers
|
|
392
|
+
# separated by commas (e.g. `permessage-deflate; server_no_context_takeover,
|
|
393
|
+
# permessage-deflate`). We pick the FIRST offer we can satisfy —
|
|
394
|
+
# this matches the RFC's "the first acceptable extension" guidance
|
|
395
|
+
# and gives clients a deterministic ordering.
|
|
396
|
+
def self.negotiate_extensions(header_value, policy)
|
|
397
|
+
return EMPTY_EXTENSIONS if policy == :off
|
|
398
|
+
|
|
399
|
+
offers = parse_extension_offers(header_value)
|
|
400
|
+
deflate_offers = offers.select { |o| o[:name] == PERMESSAGE_DEFLATE }
|
|
401
|
+
|
|
402
|
+
if deflate_offers.empty?
|
|
403
|
+
return bad_request('permessage-deflate required but not offered') if policy == :on
|
|
404
|
+
|
|
405
|
+
return EMPTY_EXTENSIONS
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Try offers in order; first one we can satisfy wins.
|
|
409
|
+
deflate_offers.each do |offer|
|
|
410
|
+
accepted = accept_deflate_offer(offer)
|
|
411
|
+
return { permessage_deflate: accepted } if accepted
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# All offers had params we can't satisfy.
|
|
415
|
+
return bad_request('no acceptable permessage-deflate parameter set') if policy == :on
|
|
416
|
+
|
|
417
|
+
EMPTY_EXTENSIONS
|
|
418
|
+
end
|
|
419
|
+
private_class_method :negotiate_extensions
|
|
420
|
+
|
|
421
|
+
# RFC 7692 §5.1 — parse `Sec-WebSocket-Extensions` header into
|
|
422
|
+
# an Array of `{ name: String, params: { String => String|true } }`
|
|
423
|
+
# hashes. The parser is forgiving: garbage parameter values are
|
|
424
|
+
# logged-and-skipped (we set the param to a sentinel marker the
|
|
425
|
+
# acceptor rejects), not raised. Multiple offers are
|
|
426
|
+
# comma-separated; each offer's params are semicolon-separated;
|
|
427
|
+
# each param is `name` (boolean) or `name=value` (string).
|
|
428
|
+
def self.parse_extension_offers(header_value)
|
|
429
|
+
return [] if header_value.nil? || header_value.empty?
|
|
430
|
+
|
|
431
|
+
offers = []
|
|
432
|
+
header_value.split(',').each do |raw_offer|
|
|
433
|
+
tokens = raw_offer.split(';').map(&:strip).reject(&:empty?)
|
|
434
|
+
next if tokens.empty?
|
|
435
|
+
|
|
436
|
+
name = tokens.shift
|
|
437
|
+
params = {}
|
|
438
|
+
tokens.each do |token|
|
|
439
|
+
k, v = token.split('=', 2).map(&:strip)
|
|
440
|
+
next if k.nil? || k.empty?
|
|
441
|
+
|
|
442
|
+
params[k] = if v.nil?
|
|
443
|
+
true
|
|
444
|
+
else
|
|
445
|
+
# Trim optional quoted-string per RFC 7692 §5.1.
|
|
446
|
+
v.start_with?('"') && v.end_with?('"') && v.length >= 2 ? v[1..-2] : v
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
offers << { name: name, params: params }
|
|
450
|
+
end
|
|
451
|
+
offers
|
|
452
|
+
end
|
|
453
|
+
private_class_method :parse_extension_offers
|
|
454
|
+
|
|
455
|
+
# Try to accept one parsed permessage-deflate offer. Returns the
|
|
456
|
+
# resolved param hash on success, or nil if any param is
|
|
457
|
+
# unrecognized / out of range (we silently skip — the next offer
|
|
458
|
+
# may be acceptable, or the policy may downgrade to no-extension).
|
|
459
|
+
def self.accept_deflate_offer(offer)
|
|
460
|
+
accepted = {
|
|
461
|
+
server_no_context_takeover: false,
|
|
462
|
+
client_no_context_takeover: false,
|
|
463
|
+
server_max_window_bits: DEFAULT_WINDOW_BITS,
|
|
464
|
+
client_max_window_bits: DEFAULT_WINDOW_BITS
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
offer[:params].each do |key, value|
|
|
468
|
+
case key
|
|
469
|
+
when PARAM_SERVER_NO_TAKEOVER
|
|
470
|
+
return nil unless value == true
|
|
471
|
+
|
|
472
|
+
accepted[:server_no_context_takeover] = true
|
|
473
|
+
when PARAM_CLIENT_NO_TAKEOVER
|
|
474
|
+
return nil unless value == true
|
|
475
|
+
|
|
476
|
+
accepted[:client_no_context_takeover] = true
|
|
477
|
+
when PARAM_SERVER_MAX_WINDOW
|
|
478
|
+
# Server-side window bits — the client requesting an upper
|
|
479
|
+
# bound on what we use. Accept if in range; we never set a
|
|
480
|
+
# value larger than the client asked for.
|
|
481
|
+
bits = window_bits_or_nil(value)
|
|
482
|
+
return nil if bits.nil?
|
|
483
|
+
|
|
484
|
+
accepted[:server_max_window_bits] = bits
|
|
485
|
+
when PARAM_CLIENT_MAX_WINDOW
|
|
486
|
+
# Client-side window bits — RFC 7692 §7.1.2.2: this
|
|
487
|
+
# parameter MAY appear without a value (just the token,
|
|
488
|
+
# meaning "client supports any bit size; pick one"). When
|
|
489
|
+
# it has a value, accept up to that value.
|
|
490
|
+
if value == true
|
|
491
|
+
# Client advertises support; we don't request a smaller
|
|
492
|
+
# window because there's no operational reason to (memory
|
|
493
|
+
# is on the client side).
|
|
494
|
+
accepted[:client_max_window_bits] = DEFAULT_WINDOW_BITS
|
|
495
|
+
else
|
|
496
|
+
bits = window_bits_or_nil(value)
|
|
497
|
+
return nil if bits.nil?
|
|
498
|
+
|
|
499
|
+
accepted[:client_max_window_bits] = bits
|
|
500
|
+
end
|
|
501
|
+
else
|
|
502
|
+
# Unknown parameter — RFC 7692 §5.1 says reject the offer
|
|
503
|
+
# entirely. Skip; the caller will try the next offer.
|
|
504
|
+
return nil
|
|
505
|
+
end
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
accepted
|
|
509
|
+
end
|
|
510
|
+
private_class_method :accept_deflate_offer
|
|
511
|
+
|
|
512
|
+
# Validate a window_bits value String. Returns the Integer when
|
|
513
|
+
# in [MIN_WINDOW_BITS..MAX_WINDOW_BITS], nil otherwise.
|
|
514
|
+
def self.window_bits_or_nil(value)
|
|
515
|
+
return nil unless value.is_a?(String) && value.match?(/\A\d+\z/)
|
|
516
|
+
|
|
517
|
+
bits = value.to_i
|
|
518
|
+
return nil if bits < MIN_WINDOW_BITS || bits > MAX_WINDOW_BITS
|
|
519
|
+
|
|
520
|
+
bits
|
|
521
|
+
end
|
|
522
|
+
private_class_method :window_bits_or_nil
|
|
523
|
+
end
|
|
524
|
+
end
|
|
525
|
+
end
|
data/lib/hyperion/worker.rb
CHANGED
|
@@ -20,7 +20,17 @@ module Hyperion
|
|
|
20
20
|
thread_count: Server::DEFAULT_THREAD_COUNT,
|
|
21
21
|
config: nil, worker_index: 0, listener: nil,
|
|
22
22
|
max_pending: nil, max_request_read_seconds: 60,
|
|
23
|
-
h2_settings: nil, async_io: nil
|
|
23
|
+
h2_settings: nil, async_io: nil, runtime: nil,
|
|
24
|
+
accept_fibers_per_worker: 1, h2_max_total_streams: nil,
|
|
25
|
+
admin_listener_port: nil, admin_listener_host: '127.0.0.1',
|
|
26
|
+
admin_token: nil,
|
|
27
|
+
tls_session_cache_size: TLS::DEFAULT_SESSION_CACHE_SIZE,
|
|
28
|
+
tls_ticket_key_rotation_signal: :USR2,
|
|
29
|
+
tls_ktls: :auto,
|
|
30
|
+
io_uring: :off,
|
|
31
|
+
max_in_flight_per_conn: nil,
|
|
32
|
+
tls_handshake_rate_limit: :unlimited,
|
|
33
|
+
preload_static_dirs: nil)
|
|
24
34
|
@host = host
|
|
25
35
|
@port = port
|
|
26
36
|
@app = app
|
|
@@ -34,6 +44,19 @@ module Hyperion
|
|
|
34
44
|
@max_request_read_seconds = max_request_read_seconds
|
|
35
45
|
@h2_settings = h2_settings
|
|
36
46
|
@async_io = async_io
|
|
47
|
+
@runtime = runtime
|
|
48
|
+
@accept_fibers_per_worker = accept_fibers_per_worker
|
|
49
|
+
@h2_max_total_streams = h2_max_total_streams
|
|
50
|
+
@admin_listener_port = admin_listener_port
|
|
51
|
+
@admin_listener_host = admin_listener_host
|
|
52
|
+
@admin_token = admin_token
|
|
53
|
+
@tls_session_cache_size = tls_session_cache_size
|
|
54
|
+
@tls_ticket_key_rotation_signal = tls_ticket_key_rotation_signal
|
|
55
|
+
@tls_ktls = tls_ktls
|
|
56
|
+
@io_uring = io_uring
|
|
57
|
+
@max_in_flight_per_conn = max_in_flight_per_conn
|
|
58
|
+
@tls_handshake_rate_limit = tls_handshake_rate_limit
|
|
59
|
+
@preload_static_dirs = preload_static_dirs
|
|
37
60
|
end
|
|
38
61
|
|
|
39
62
|
def run
|
|
@@ -53,22 +76,62 @@ module Hyperion
|
|
|
53
76
|
max_pending: @max_pending,
|
|
54
77
|
max_request_read_seconds: @max_request_read_seconds,
|
|
55
78
|
h2_settings: @h2_settings,
|
|
56
|
-
async_io: @async_io
|
|
79
|
+
async_io: @async_io,
|
|
80
|
+
runtime: @runtime,
|
|
81
|
+
accept_fibers_per_worker: @accept_fibers_per_worker,
|
|
82
|
+
h2_max_total_streams: @h2_max_total_streams,
|
|
83
|
+
admin_listener_port: @admin_listener_port,
|
|
84
|
+
admin_listener_host: @admin_listener_host,
|
|
85
|
+
admin_token: @admin_token,
|
|
86
|
+
tls_session_cache_size: @tls_session_cache_size,
|
|
87
|
+
tls_ktls: @tls_ktls,
|
|
88
|
+
io_uring: @io_uring,
|
|
89
|
+
max_in_flight_per_conn: @max_in_flight_per_conn,
|
|
90
|
+
tls_handshake_rate_limit: @tls_handshake_rate_limit,
|
|
91
|
+
preload_static_dirs: @preload_static_dirs)
|
|
92
|
+
|
|
93
|
+
# `on_worker_boot` runs in the child after fork, BEFORE the worker
|
|
94
|
+
# adopts/binds its listener and before any accept. App code reconnects
|
|
95
|
+
# DB/Redis pools here so each worker has its own. Index identifies the
|
|
96
|
+
# slot (0..workers-1) so apps can shard background work if they want.
|
|
97
|
+
#
|
|
98
|
+
# Pre-1.6.3 this hook fired AFTER the listener was adopted (`:share`)
|
|
99
|
+
# or freshly bound with SO_REUSEPORT (`:reuseport`). On `:reuseport`
|
|
100
|
+
# that meant the kernel could queue inbound connections to the
|
|
101
|
+
# worker's listen socket while the operator's hook was still warming
|
|
102
|
+
# up DB pools — observable as first-request latency spikes against
|
|
103
|
+
# an unready handler. Firing the hook before listener setup makes the
|
|
104
|
+
# two worker models behave identically: no socket exists for this
|
|
105
|
+
# worker until the boot hook has returned.
|
|
106
|
+
@config.on_worker_boot.each { |h| h.call(@worker_index) }
|
|
107
|
+
|
|
108
|
+
# 2.4-C: register the io_uring active gauge for this worker.
|
|
109
|
+
# `Hyperion::IOUring.resolve_policy!` was already called by the
|
|
110
|
+
# Server constructor; if it returned true, this worker is using
|
|
111
|
+
# the io_uring accept path and the gauge value is 1.
|
|
112
|
+
io_uring_active = @io_uring != :off && Hyperion::IOUring.resolve_policy!(@io_uring) ? 1 : 0
|
|
113
|
+
Hyperion.metrics.set_gauge(
|
|
114
|
+
:hyperion_io_uring_workers_active,
|
|
115
|
+
io_uring_active,
|
|
116
|
+
[Process.pid.to_s]
|
|
117
|
+
)
|
|
118
|
+
|
|
57
119
|
tcp_server = @listener || build_reuseport_listener
|
|
58
120
|
server.adopt_listener(tcp_server)
|
|
59
121
|
|
|
60
122
|
Signal.trap('TERM') { server.stop }
|
|
61
123
|
Signal.trap('INT') { server.stop }
|
|
62
|
-
|
|
63
|
-
# `on_worker_boot` runs in the child after fork, after the listener is
|
|
64
|
-
# ready, and before we start accepting. App code reconnects DB/Redis
|
|
65
|
-
# pools here so each worker has its own. Index identifies the slot
|
|
66
|
-
# (0..workers-1) so apps can shard background work if they want.
|
|
67
|
-
@config.on_worker_boot.each { |h| h.call(@worker_index) }
|
|
124
|
+
install_tls_rotation_signal_handler(server)
|
|
68
125
|
|
|
69
126
|
begin
|
|
70
127
|
server.start
|
|
71
128
|
ensure
|
|
129
|
+
# 2.4-C: clear the per-worker io_uring gauge slot at shutdown
|
|
130
|
+
# so post-shutdown scrapes don't claim the policy is still
|
|
131
|
+
# active for this worker.
|
|
132
|
+
Hyperion.metrics.set_gauge(:hyperion_io_uring_workers_active,
|
|
133
|
+
0,
|
|
134
|
+
[Process.pid.to_s])
|
|
72
135
|
# `on_worker_shutdown` fires when the accept loop exits — either
|
|
73
136
|
# due to graceful SIGTERM or a hard error. Use it to flush metrics,
|
|
74
137
|
# close DB connections cleanly, etc.
|
|
@@ -87,7 +150,9 @@ module Hyperion
|
|
|
87
150
|
sock.listen(::Socket::SOMAXCONN)
|
|
88
151
|
|
|
89
152
|
if @tls
|
|
90
|
-
ctx = Hyperion::TLS.context(cert: @tls[:cert], key: @tls[:key], chain: @tls[:chain]
|
|
153
|
+
ctx = Hyperion::TLS.context(cert: @tls[:cert], key: @tls[:key], chain: @tls[:chain],
|
|
154
|
+
session_cache_size: @tls_session_cache_size,
|
|
155
|
+
ktls: @tls_ktls)
|
|
91
156
|
ssl = ::OpenSSL::SSL::SSLServer.new(sock, ctx)
|
|
92
157
|
ssl.start_immediately = false
|
|
93
158
|
ssl
|
|
@@ -97,5 +162,42 @@ module Hyperion
|
|
|
97
162
|
sock
|
|
98
163
|
end
|
|
99
164
|
end
|
|
165
|
+
|
|
166
|
+
# Wire the TLS ticket-key rotation signal (default SIGUSR2) to call
|
|
167
|
+
# `Hyperion::TLS.rotate!` against the per-worker SSLContext. The
|
|
168
|
+
# signal is broadcast by the master on operator demand or on the
|
|
169
|
+
# worker's own initiative; either way the receiving worker flushes
|
|
170
|
+
# its session cache so subsequent connections can no longer resume
|
|
171
|
+
# against pre-rotation entries.
|
|
172
|
+
#
|
|
173
|
+
# When the operator picks `:NONE` the trap is skipped — the default
|
|
174
|
+
# SIG_DFL handler stays in place and the worker keeps the original
|
|
175
|
+
# session cache for its full lifetime.
|
|
176
|
+
def install_tls_rotation_signal_handler(server)
|
|
177
|
+
return unless @tls
|
|
178
|
+
return if @tls_ticket_key_rotation_signal.nil?
|
|
179
|
+
return if @tls_ticket_key_rotation_signal == :NONE
|
|
180
|
+
|
|
181
|
+
sig = @tls_ticket_key_rotation_signal.to_s
|
|
182
|
+
Signal.trap(sig) do
|
|
183
|
+
ctx = server.ssl_ctx
|
|
184
|
+
::Hyperion::TLS.rotate!(ctx) if ctx
|
|
185
|
+
rescue StandardError
|
|
186
|
+
# Signal handlers run in the main thread context; swallowing
|
|
187
|
+
# here avoids a corrupted-trap state if `flush_sessions` raises
|
|
188
|
+
# against an in-progress flush from a previous signal.
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
rescue ArgumentError
|
|
192
|
+
# Operator passed a bogus signal name (`:DOES_NOT_EXIST`). Log
|
|
193
|
+
# and continue — rotation off is acceptable, the worker should
|
|
194
|
+
# not refuse to boot over a knob typo.
|
|
195
|
+
Hyperion.logger.warn do
|
|
196
|
+
{
|
|
197
|
+
message: 'invalid tls_ticket_key_rotation_signal; rotation disabled',
|
|
198
|
+
signal: @tls_ticket_key_rotation_signal
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
end
|
|
100
202
|
end
|
|
101
203
|
end
|