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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4768 -0
  3. data/README.md +222 -13
  4. data/ext/hyperion_h2_codec/Cargo.lock +7 -0
  5. data/ext/hyperion_h2_codec/Cargo.toml +33 -0
  6. data/ext/hyperion_h2_codec/extconf.rb +73 -0
  7. data/ext/hyperion_h2_codec/src/frames.rs +140 -0
  8. data/ext/hyperion_h2_codec/src/hpack/huffman.rs +161 -0
  9. data/ext/hyperion_h2_codec/src/hpack.rs +457 -0
  10. data/ext/hyperion_h2_codec/src/lib.rs +296 -0
  11. data/ext/hyperion_http/extconf.rb +28 -0
  12. data/ext/hyperion_http/h2_codec_glue.c +408 -0
  13. data/ext/hyperion_http/page_cache.c +1125 -0
  14. data/ext/hyperion_http/parser.c +473 -38
  15. data/ext/hyperion_http/sendfile.c +982 -0
  16. data/ext/hyperion_http/websocket.c +493 -0
  17. data/ext/hyperion_io_uring/Cargo.lock +33 -0
  18. data/ext/hyperion_io_uring/Cargo.toml +34 -0
  19. data/ext/hyperion_io_uring/extconf.rb +74 -0
  20. data/ext/hyperion_io_uring/src/lib.rs +316 -0
  21. data/lib/hyperion/adapter/rack.rb +370 -42
  22. data/lib/hyperion/admin_listener.rb +207 -0
  23. data/lib/hyperion/admin_middleware.rb +36 -7
  24. data/lib/hyperion/cli.rb +310 -11
  25. data/lib/hyperion/config.rb +440 -14
  26. data/lib/hyperion/connection.rb +679 -22
  27. data/lib/hyperion/deprecations.rb +81 -0
  28. data/lib/hyperion/dispatch_mode.rb +165 -0
  29. data/lib/hyperion/fiber_local.rb +75 -13
  30. data/lib/hyperion/h2_admission.rb +77 -0
  31. data/lib/hyperion/h2_codec.rb +499 -0
  32. data/lib/hyperion/http/page_cache.rb +122 -0
  33. data/lib/hyperion/http/sendfile.rb +696 -0
  34. data/lib/hyperion/http2/native_hpack_adapter.rb +70 -0
  35. data/lib/hyperion/http2_handler.rb +618 -19
  36. data/lib/hyperion/io_uring.rb +317 -0
  37. data/lib/hyperion/lint_wrapper_pool.rb +126 -0
  38. data/lib/hyperion/master.rb +96 -9
  39. data/lib/hyperion/metrics/path_templater.rb +68 -0
  40. data/lib/hyperion/metrics.rb +256 -0
  41. data/lib/hyperion/prometheus_exporter.rb +150 -0
  42. data/lib/hyperion/request.rb +13 -0
  43. data/lib/hyperion/response_writer.rb +477 -16
  44. data/lib/hyperion/runtime.rb +195 -0
  45. data/lib/hyperion/server/route_table.rb +179 -0
  46. data/lib/hyperion/server.rb +519 -55
  47. data/lib/hyperion/static_preload.rb +133 -0
  48. data/lib/hyperion/thread_pool.rb +61 -7
  49. data/lib/hyperion/tls.rb +343 -1
  50. data/lib/hyperion/version.rb +1 -1
  51. data/lib/hyperion/websocket/close_codes.rb +71 -0
  52. data/lib/hyperion/websocket/connection.rb +876 -0
  53. data/lib/hyperion/websocket/frame.rb +356 -0
  54. data/lib/hyperion/websocket/handshake.rb +525 -0
  55. data/lib/hyperion/worker.rb +111 -9
  56. data/lib/hyperion.rb +137 -3
  57. metadata +50 -1
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'find'
4
+
5
+ require_relative 'http/page_cache'
6
+
7
+ module Hyperion
8
+ # 2.10-E — Boot-time static-asset preload.
9
+ #
10
+ # `StaticPreload.run` walks each operator-supplied directory tree and
11
+ # populates `Hyperion::Http::PageCache` from the regular files inside.
12
+ # When `immutable: true` (the default for the operator-facing surfaces
13
+ # — the whole point of preload is "I promise these don't change without
14
+ # a restart") every cached entry is marked immutable so the page cache
15
+ # never re-stats the file on subsequent serves.
16
+ #
17
+ # The Server boot path invokes this once per worker after `listen`
18
+ # configures the listener but BEFORE the accept loop spins up — so the
19
+ # very first request lands on a warm cache, not a cold cache miss.
20
+ #
21
+ # Rails-shaped apps get auto-detect for free: when the operator hasn't
22
+ # configured `preload_static` (and didn't pass `--no-preload-static`),
23
+ # `Config#resolved_preload_static_dirs` synthesises a list from
24
+ # `Rails.configuration.assets.paths.first(N)` (cap 8). Hyperion never
25
+ # `require`s rails — `detect_rails_paths` defensively probes
26
+ # `defined?(::Rails) && ::Rails.respond_to?(:configuration)` so the
27
+ # gem stays Rails-agnostic.
28
+ module StaticPreload
29
+ # Default cap on auto-detected Rails asset paths. 8 covers a typical
30
+ # Rails 7+ app (jsbundling/cssbundling/propshaft + a few engine
31
+ # paths) without iterating every gem-installed asset path the host
32
+ # ever depends on. Operators wanting a different cap can pass it
33
+ # explicitly to `detect_rails_paths(cap:)`.
34
+ RAILS_AUTO_DETECT_CAP = 8
35
+
36
+ class << self
37
+ # Walk each `entry` and populate the page cache. `entries` is an
38
+ # Array of `{path:, immutable:}` Hashes. Returns the total file
39
+ # count cached across all dirs.
40
+ #
41
+ # `logger` defaults to `Hyperion.logger` so callers in production
42
+ # don't have to thread one through; the spec suite passes an
43
+ # in-memory `Hyperion::Logger` so it can assert on the summary
44
+ # log line without disturbing the global Runtime logger.
45
+ def run(entries, logger: Hyperion.logger)
46
+ return 0 if entries.nil? || entries.empty?
47
+ return 0 unless Hyperion::Http::PageCache.available?
48
+
49
+ total = 0
50
+ entries.each do |entry|
51
+ path = entry[:path]
52
+ immutable = entry.fetch(:immutable, true)
53
+
54
+ unless File.directory?(path)
55
+ logger.warn { { message: 'static preload skipped', dir: path, reason: 'not a directory' } }
56
+ next
57
+ end
58
+
59
+ stats = preload_dir(path, immutable: immutable)
60
+ total += stats[:files]
61
+ logger.info do
62
+ {
63
+ message: 'static preload complete',
64
+ dir: path,
65
+ files: stats[:files],
66
+ bytes: stats[:bytes],
67
+ ms: stats[:ms]
68
+ }
69
+ end
70
+ end
71
+ total
72
+ end
73
+
74
+ # Detect the first `cap` Rails asset paths. Returns `[]` when
75
+ # Rails is not loaded, when the configuration surface is missing
76
+ # any expected method, or when `assets.paths` is not an Array.
77
+ # NEVER `require 'rails'` — auto-detect must work for the operator
78
+ # who has Rails in their bundle but for a generic Rack app
79
+ # Hyperion is supposed to stay neutral about.
80
+ def detect_rails_paths(cap: RAILS_AUTO_DETECT_CAP)
81
+ return [] unless rails_available?
82
+
83
+ config = ::Rails.configuration
84
+ return [] unless config.respond_to?(:assets)
85
+
86
+ assets = config.assets
87
+ return [] unless assets.respond_to?(:paths)
88
+
89
+ paths = assets.paths
90
+ return [] unless paths.is_a?(Array) && !paths.empty?
91
+
92
+ paths.first(cap).map(&:to_s)
93
+ rescue StandardError
94
+ # Auto-detect is a convenience; never let a Rails internals
95
+ # surface change crash boot. Worst case the operator gets the
96
+ # 1.x cold-cache behavior.
97
+ []
98
+ end
99
+
100
+ private
101
+
102
+ def rails_available?
103
+ defined?(::Rails) && ::Rails.respond_to?(:configuration)
104
+ end
105
+
106
+ # Walk a single directory, cache every regular file, optionally
107
+ # mark immutable. Returns `{files:, bytes:, ms:}` so the caller
108
+ # can build the summary log line.
109
+ def preload_dir(dir, immutable:)
110
+ files = 0
111
+ bytes = 0
112
+ t0 = monotonic_ms
113
+
114
+ Find.find(dir) do |path|
115
+ next unless File.file?(path)
116
+
117
+ result = Hyperion::Http::PageCache.cache_file(path)
118
+ next if result == :missing # symlink loop or unreadable file
119
+
120
+ files += 1
121
+ bytes += result if result.is_a?(Integer)
122
+ Hyperion::Http::PageCache.set_immutable(path, true) if immutable
123
+ end
124
+
125
+ { files: files, bytes: bytes, ms: (monotonic_ms - t0).round(1) }
126
+ end
127
+
128
+ def monotonic_ms
129
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) * 1000.0
130
+ end
131
+ end
132
+ end
133
+ end
@@ -28,10 +28,20 @@ module Hyperion
28
28
 
29
29
  attr_reader :size, :max_pending
30
30
 
31
- def initialize(size:, max_pending: nil)
31
+ def initialize(size:, max_pending: nil, max_in_flight_per_conn: nil, route_table: nil)
32
32
  @size = size
33
33
  @max_pending = max_pending
34
- @inbox = Queue.new # multiplexes both kinds of jobs
34
+ # 2.3-B: per-conn fairness cap propagated to every Connection
35
+ # constructed by `:connection` jobs. nil (default) = no cap,
36
+ # matches 2.2.0. Positive integer = per-conn ceiling.
37
+ @max_in_flight_per_conn = max_in_flight_per_conn
38
+ # 2.10-D — direct-dispatch route table propagated to every
39
+ # Connection constructed by `:connection` jobs. nil falls
40
+ # through to `Hyperion::Server.route_table` (the process-wide
41
+ # singleton); a non-nil instance is honoured verbatim (test
42
+ # / multi-tenant seam).
43
+ @route_table = route_table
44
+ @inbox = Queue.new # multiplexes both kinds of jobs
35
45
  # Pre-allocate one reply queue per in-flight slot for the legacy `#call`
36
46
  # path. Bounded by `size`: if all workers are busy, all reply queues are
37
47
  # checked out, and the next caller blocks on `@reply_pool.pop` until a
@@ -39,6 +49,25 @@ module Hyperion
39
49
  @reply_pool = Queue.new
40
50
  size.times { @reply_pool << Queue.new }
41
51
  @workers = Array.new(size) { spawn_worker }
52
+ # 2.4-C: snapshot-time gauge — operator scrape sees the live
53
+ # inbox depth as of /-/metrics scrape, not a stale-since-init
54
+ # number. The block reads `Queue#size` (cheap, lock-free) so the
55
+ # scrape path doesn't perturb the running pool.
56
+ register_queue_depth_gauge!
57
+ end
58
+
59
+ THREADPOOL_QUEUE_DEPTH_GAUGE = :hyperion_threadpool_queue_depth
60
+
61
+ def queue_size
62
+ @inbox.size
63
+ end
64
+
65
+ def register_queue_depth_gauge!
66
+ Hyperion.metrics.set_gauge(THREADPOOL_QUEUE_DEPTH_GAUGE,
67
+ nil,
68
+ [Process.pid.to_s]) { @inbox.size }
69
+ rescue StandardError
70
+ nil
42
71
  end
43
72
 
44
73
  # HTTP/1.1 path: hand the whole socket to a worker thread. The worker
@@ -73,7 +102,23 @@ module Hyperion
73
102
  # `spawn_worker`.
74
103
  def call(app, request)
75
104
  reply = @reply_pool.pop
76
- @inbox << [:call, app, request, reply]
105
+ @inbox << [:call, app, request, reply, nil]
106
+ result = reply.pop
107
+ @reply_pool << reply
108
+ result
109
+ end
110
+
111
+ # 2.1.0 (WS-1) — same as #call, but threads a `Hyperion::Connection`
112
+ # through to `Adapter::Rack.call(app, request, connection:)` so the
113
+ # Rack env hash advertises full-hijack support. The worker pushes the
114
+ # standard [status, headers, body] tuple back; if the app called
115
+ # `env['rack.hijack'].call`, the connection's `@hijacked` ivar was
116
+ # flipped from inside the worker thread and the calling fiber will
117
+ # observe it on return (Ruby ivars are visible across the GVL boundary
118
+ # for plain assignments — no Mutex/atomic needed).
119
+ def call_with_connection(app, request, connection)
120
+ reply = @reply_pool.pop
121
+ @inbox << [:call, app, request, reply, connection]
77
122
  result = reply.pop
78
123
  @reply_pool << reply
79
124
  result
@@ -97,9 +142,14 @@ module Hyperion
97
142
  _, socket, app, max_request_read_seconds = job
98
143
  # Worker thread owns the connection for its full lifetime. Pass
99
144
  # thread_pool: nil so Connection#call_app inlines Adapter::Rack.call
100
- # — the worker IS the pool, no further hop required.
145
+ # — the worker IS the pool, no further hop required. 2.3-B
146
+ # threads `max_in_flight_per_conn` so the per-conn fairness
147
+ # cap (if configured) takes effect on this worker's serve loop.
101
148
  begin
102
- Hyperion::Connection.new.serve(socket, app, max_request_read_seconds: max_request_read_seconds)
149
+ Hyperion::Connection
150
+ .new(max_in_flight_per_conn: @max_in_flight_per_conn,
151
+ route_table: @route_table)
152
+ .serve(socket, app, max_request_read_seconds: max_request_read_seconds)
103
153
  rescue StandardError => e
104
154
  Hyperion.logger.error do
105
155
  {
@@ -110,10 +160,14 @@ module Hyperion
110
160
  end
111
161
  end
112
162
  when :call
113
- _, app, request, reply = job
163
+ _, app, request, reply, connection = job
114
164
  reply <<
115
165
  begin
116
- Hyperion::Adapter::Rack.call(app, request)
166
+ if connection
167
+ Hyperion::Adapter::Rack.call(app, request, connection: connection)
168
+ else
169
+ Hyperion::Adapter::Rack.call(app, request)
170
+ end
117
171
  rescue StandardError => e
118
172
  Hyperion.logger.error do
119
173
  { message: 'thread pool worker raised', error: e.message, error_class: e.class.name }
data/lib/hyperion/tls.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'openssl'
4
+ require 'etc'
4
5
 
5
6
  module Hyperion
6
7
  # TLS context builder with ALPN configured for HTTP/2 + HTTP/1.1.
@@ -8,14 +9,79 @@ module Hyperion
8
9
  # Phase 7: TLS is opt-in via Server's `tls:` kwarg. ALPN lets the client
9
10
  # negotiate `h2` (HTTP/2) or `http/1.1` during the handshake; the server
10
11
  # then dispatches to either Http2Handler or Connection accordingly.
12
+ #
13
+ # 1.8.0 (Phase 4): server-side session resumption is on by default. The
14
+ # context enables `SESSION_CACHE_SERVER` mode and clears `OP_NO_TICKET`
15
+ # so OpenSSL's auto-rolled session-ticket key handles short-circuited
16
+ # handshakes for returning clients. `session_id_context` is set to a
17
+ # stable per-process value so cache lookups cross worker boundaries when
18
+ # the master inherits a single listener fd (`:share` mode); on Linux
19
+ # `:reuseport` workers, the kernel pins client → worker by tuple hash so
20
+ # each worker's local cache covers its own returning clients.
21
+ #
22
+ # Cross-worker ticket-key sharing requires `SSL_CTX_set_tlsext_ticket_keys`
23
+ # which Ruby's stdlib OpenSSL does not bind today (3.3.x). When that
24
+ # binding lands we'll thread the master-generated key through to each
25
+ # worker; until then resumption works inside a single worker's session
26
+ # cache. RFC §4 documents this trade-off.
27
+ #
28
+ # 2.2.0 (Phase 9): kernel TLS transmit (KTLS_TX) on Linux ≥ 4.13 +
29
+ # OpenSSL ≥ 3.0. After the userspace handshake completes, the symmetric
30
+ # session key is handed to the kernel and subsequent SSL_write calls go
31
+ # through kernel sendfile/write paths, bypassing the userspace cipher
32
+ # loop. Pairs with — does not replace — Phase 4 session resumption.
33
+ # macOS / BSD have no kTLS support; the probe returns false and the
34
+ # context falls back to plain userspace SSL_write transparently.
11
35
  module TLS
12
36
  SUPPORTED_PROTOCOLS = %w[h2 http/1.1].freeze
13
37
 
14
38
  PEM_CERT_RE = /-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m
15
39
 
40
+ # OpenSSL 3.0+ added SSL_OP_ENABLE_KTLS (= 0x00000008 in openssl/ssl.h).
41
+ # Most Ruby openssl bindings expose it as `OpenSSL::SSL::OP_ENABLE_KTLS`;
42
+ # fall back to the literal constant value on builds that don't.
43
+ OP_ENABLE_KTLS_VALUE = if OpenSSL::SSL.const_defined?(:OP_ENABLE_KTLS)
44
+ OpenSSL::SSL::OP_ENABLE_KTLS
45
+ else
46
+ 0x00000008
47
+ end
48
+
49
+ # OpenSSL 3.0 cuts the line for kTLS support — earlier 1.1.x builds
50
+ # accept the option flag but silently no-op. Compare against the
51
+ # numeric `OPENSSL_VERSION_NUMBER` (Mmnnffpps form) so the check works
52
+ # with stdlib bindings that don't expose every symbolic constant.
53
+ MIN_OPENSSL_VERSION_FOR_KTLS = 0x30000000 # 3.0.0
54
+
55
+ # Linux added kTLS_TX in 4.13 (commit d3b18ad31f91e). Earlier kernels
56
+ # don't expose the AF_ALG TLS ULP. Probe via Etc.uname[:release].
57
+ MIN_LINUX_KERNEL_FOR_KTLS = [4, 13].freeze
58
+
59
+ require 'securerandom'
60
+
61
+ # Stable per-process session_id_context. Sized at the OpenSSL hard
62
+ # cap (32 bytes); randomized once at process boot so two unrelated
63
+ # Hyperion processes on the same host don't share session caches by
64
+ # accident, but consistent across forks via copy-on-write so workers
65
+ # all advertise the same context id.
66
+ SESSION_ID_CONTEXT = SecureRandom.bytes(32).freeze
67
+
68
+ DEFAULT_SESSION_CACHE_SIZE = 20_480
69
+
16
70
  module_function
17
71
 
18
- def context(cert:, key:, chain: nil)
72
+ # Builds the OpenSSL::SSL::SSLContext used to wrap TLS listening
73
+ # sockets. `session_cache_size` (default 20_480) is the in-process
74
+ # server-side cache budget. Setting `0` disables the cache entirely
75
+ # (every connection pays the full handshake cost — useful for tests
76
+ # of the cache-eviction path).
77
+ #
78
+ # 2.2.0 (Phase 9): `ktls` selects the kernel-TLS transmit policy.
79
+ # * `:auto` (default) — enable on Linux when the kernel + OpenSSL
80
+ # combo supports it; off elsewhere.
81
+ # * `:on` — force-enable; raise at boot if not supported.
82
+ # * `:off` — never enable, even if supported.
83
+ def context(cert:, key:, chain: nil, session_cache_size: DEFAULT_SESSION_CACHE_SIZE,
84
+ ktls: :auto)
19
85
  ctx = OpenSSL::SSL::SSLContext.new
20
86
  ctx.cert = cert
21
87
  ctx.key = key
@@ -29,6 +95,186 @@ module Hyperion
29
95
  # Prefer h2 if the client offered it; else fall back to http/1.1.
30
96
  SUPPORTED_PROTOCOLS.find { |p| client_protocols.include?(p) }
31
97
  end
98
+
99
+ configure_session_resumption!(ctx, session_cache_size)
100
+ configure_ktls!(ctx, ktls)
101
+ ctx
102
+ end
103
+
104
+ # Whether this Ruby + kernel combination can host kTLS_TX. Cached
105
+ # per-process: the answer can't change without a process restart.
106
+ # Three gates: Linux kernel ≥ 4.13, OpenSSL ≥ 3.0, and the openssl
107
+ # gem actually exposing the flag (sanity-check, since we fall back
108
+ # to the literal value when the constant is missing).
109
+ def ktls_supported?
110
+ return @ktls_supported if defined?(@ktls_supported)
111
+
112
+ @ktls_supported = linux_ktls_kernel? && openssl_ktls_capable?
113
+ end
114
+
115
+ # Test seam: clear the cached probe result so a stubbed Etc.uname /
116
+ # OpenSSL version can drive the spec matrix. Production code never
117
+ # calls this — `ktls_supported?` is idempotent across the process
118
+ # lifetime once memoized.
119
+ def reset_ktls_probe!
120
+ remove_instance_variable(:@ktls_supported) if defined?(@ktls_supported)
121
+ end
122
+
123
+ # Apply the operator-supplied kTLS policy onto an already-built
124
+ # SSLContext. Split out so it can be re-applied after a SIGUSR2
125
+ # rotation (parallels `configure_session_resumption!`).
126
+ #
127
+ # OpenSSL drives the actual promotion: when OP_ENABLE_KTLS is set on
128
+ # the context and the negotiated cipher is one the kernel supports
129
+ # (currently AES-128-GCM, AES-256-GCM, and CHACHA20-POLY1305 on newer
130
+ # kernels), `SSL_write` on the post-handshake socket goes through
131
+ # the kernel TLS path. We just opt in here.
132
+ def configure_ktls!(ctx, mode)
133
+ case mode
134
+ when :off, false
135
+ return ctx
136
+ when :on, true
137
+ unless ktls_supported?
138
+ raise Hyperion::UnsupportedError,
139
+ 'kTLS not supported on this platform (need Linux >= 4.13 + OpenSSL >= 3.0); ' \
140
+ 'set tls.ktls = :off or :auto to fall back to userspace SSL_write'
141
+ end
142
+ when :auto, nil
143
+ return ctx unless ktls_supported?
144
+ else
145
+ raise ArgumentError, "tls.ktls must be :auto, :on, or :off (got #{mode.inspect})"
146
+ end
147
+
148
+ ctx.options |= OP_ENABLE_KTLS_VALUE
149
+ ctx
150
+ rescue NoMethodError
151
+ # Some openssl bindings expose `#options` as read-only. Treat as a
152
+ # silent no-op rather than crashing the boot — kTLS is an
153
+ # optimization, the request path keeps working without it.
154
+ ctx
155
+ end
156
+
157
+ # Whether kernel TLS_TX is currently active on the supplied
158
+ # SSLSocket. Used in tests + the once-per-worker boot log to confirm
159
+ # the kernel actually accepted the cipher. Returns `nil` when the
160
+ # answer can't be determined (no FFI access to libssl, or this build
161
+ # of OpenSSL doesn't expose `SSL_get_KTLS_send`) — callers must
162
+ # distinguish nil ("don't know") from false ("definitely not active").
163
+ #
164
+ # Implementation note: Ruby's stdlib openssl does not expose the
165
+ # underlying `SSL*` pointer, so a direct `SSL_get_KTLS_send` call is
166
+ # not reliably reachable from Ruby. We approximate with a kernel-
167
+ # module probe: on Linux, the `tls` module is loaded into the kernel
168
+ # the first time any process opens a socket with `setsockopt(...,
169
+ # TCP_ULP, "tls")`. After OpenSSL promotes the connection to KTLS,
170
+ # `/proc/modules` contains `tls` with a positive refcount. This is a
171
+ # process-global signal — adequate for the boot-log assertion (one-
172
+ # shot at first connection) and for the spec's "did kTLS engage on
173
+ # this host" check, but not a per-socket guarantee.
174
+ def ktls_active?(_ssl_socket = nil)
175
+ return nil unless ktls_supported?
176
+
177
+ File.foreach('/proc/modules') do |line|
178
+ next unless line.start_with?('tls ')
179
+
180
+ # Format: `tls 155648 3 - Live ...` — third column is refcount.
181
+ refcount = line.split(' ', 4)[2].to_i
182
+ return refcount.positive?
183
+ end
184
+ false
185
+ rescue Errno::ENOENT, Errno::EACCES
186
+ nil
187
+ end
188
+
189
+ # 2.4-C — gauge name for the per-worker count of active connections
190
+ # whose TLS_TX is currently driven by the kernel module. Exposed as
191
+ # a module constant so the Server (handshake-complete path) and
192
+ # Connection (close path) can share the same identifier without
193
+ # plumbing.
194
+ KTLS_ACTIVE_CONNECTIONS_GAUGE = :hyperion_tls_ktls_active_connections
195
+
196
+ # Mark an SSLSocket as kTLS-tracked. Bumps the worker's gauge by
197
+ # one. Idempotent on the same socket — calling twice is a no-op so
198
+ # signal-driven re-handshakes don't double-count. Returns true when
199
+ # the gauge was actually incremented (kTLS engaged for this peer),
200
+ # false otherwise.
201
+ def track_ktls_handshake!(ssl_socket)
202
+ return false unless ssl_socket
203
+ return false if ssl_socket.instance_variable_get(:@hyperion_ktls_tracked)
204
+
205
+ active = ktls_active?(ssl_socket)
206
+ return false unless active
207
+
208
+ ssl_socket.instance_variable_set(:@hyperion_ktls_tracked, true)
209
+ Hyperion.metrics.increment_gauge(KTLS_ACTIVE_CONNECTIONS_GAUGE,
210
+ [Process.pid.to_s])
211
+ true
212
+ rescue StandardError
213
+ false
214
+ end
215
+
216
+ # Counterpart to `track_ktls_handshake!`. Called by Connection's
217
+ # ensure block when a socket flagged as kTLS-tracked is closed.
218
+ # Idempotent.
219
+ def untrack_ktls_handshake!(ssl_socket)
220
+ return false unless ssl_socket
221
+ return false unless ssl_socket.instance_variable_get(:@hyperion_ktls_tracked)
222
+
223
+ ssl_socket.instance_variable_set(:@hyperion_ktls_tracked, false)
224
+ Hyperion.metrics.decrement_gauge(KTLS_ACTIVE_CONNECTIONS_GAUGE,
225
+ [Process.pid.to_s])
226
+ true
227
+ rescue StandardError
228
+ false
229
+ end
230
+
231
+ private_class_method def linux_ktls_kernel?
232
+ sysname = Etc.uname[:sysname]
233
+ return false unless sysname == 'Linux'
234
+
235
+ release = Etc.uname[:release].to_s
236
+ major, minor = release.split('.', 3).first(2).map(&:to_i)
237
+ return false unless major
238
+
239
+ min_major, min_minor = MIN_LINUX_KERNEL_FOR_KTLS
240
+ major > min_major || (major == min_major && minor >= min_minor)
241
+ end
242
+
243
+ private_class_method def openssl_ktls_capable?
244
+ OpenSSL::OPENSSL_VERSION_NUMBER >= MIN_OPENSSL_VERSION_FOR_KTLS
245
+ end
246
+
247
+ # Wire up the in-process server-side session cache + tickets. Split
248
+ # out of `context` so it can be re-applied after a SIGUSR2 rotation
249
+ # call to `flush_sessions` without rebuilding the whole context.
250
+ #
251
+ # `session_cache_size` follows the OpenSSL convention: zero or
252
+ # negative disables the cache; positive sets the LRU cap. We do NOT
253
+ # set `OP_NO_TICKET` — its absence is what enables RFC 5077 session
254
+ # tickets (resumption with no server-side state).
255
+ def configure_session_resumption!(ctx, session_cache_size)
256
+ ctx.session_id_context = SESSION_ID_CONTEXT[0, 32]
257
+ if session_cache_size.to_i.positive?
258
+ ctx.session_cache_mode = OpenSSL::SSL::SSLContext::SESSION_CACHE_SERVER
259
+ ctx.session_cache_size = session_cache_size.to_i
260
+ else
261
+ ctx.session_cache_mode = OpenSSL::SSL::SSLContext::SESSION_CACHE_OFF
262
+ end
263
+ # Explicitly clear OP_NO_TICKET if a default-params layer set it.
264
+ # OpenSSL's default is tickets ON, but a host app that mutates
265
+ # SSLContext::DEFAULT_PARAMS could add it; defend by clearing.
266
+ ctx.options &= ~OpenSSL::SSL::OP_NO_TICKET if ctx.options
267
+ ctx
268
+ end
269
+
270
+ # SIGUSR2 hook: flush the in-process session cache so subsequent
271
+ # connections cannot resume against entries the master has decided
272
+ # are stale. OpenSSL auto-generates a fresh session-ticket key when
273
+ # the previous one's lifetime elapses; calling `flush_sessions` here
274
+ # narrows the resumption window so an exfiltrated cache entry is
275
+ # invalidated within one rotation cycle.
276
+ def rotate!(ctx)
277
+ ctx.flush_sessions
32
278
  ctx
33
279
  end
34
280
 
@@ -40,5 +286,101 @@ module Hyperion
40
286
  def parse_pem_chain(pem)
41
287
  pem.scan(PEM_CERT_RE).map { |block| OpenSSL::X509::Certificate.new(block) }
42
288
  end
289
+
290
+ # 2.3-B: TLS handshake CPU throttle. Per-worker token bucket sized
291
+ # at the operator's `tls.handshake_rate_limit` (handshakes/sec).
292
+ # Capacity == rate so a steady-state handshake stream of `rate`
293
+ # handshakes/sec passes cleanly while a burst above the rate is
294
+ # rate-limited; tokens refill at `rate` per second uniformly.
295
+ #
296
+ # **When this fires.** A flood of new TLS handshakes (e.g., during
297
+ # a deployment when nginx restarts and reconnects everything) can
298
+ # starve regular requests of CPU — RSA/ECDHE handshakes are the
299
+ # most expensive op the server does. The bucket caps that
300
+ # starvation by closing the TCP connection at the listener edge
301
+ # before SSL_accept runs; clients see a clean TCP RST/FIN and
302
+ # retry. Default `:unlimited` keeps 2.2.0 behaviour.
303
+ #
304
+ # **For nginx-fronted topologies** this is mostly defensive: nginx
305
+ # keeps long-lived upstream connections, so handshake rate is
306
+ # normally near-zero. Real value is for direct-exposure operators
307
+ # or staging environments where misconfiguration causes a
308
+ # handshake storm.
309
+ #
310
+ # **Concurrency.** A Mutex-guarded refill+take. Hold time is one
311
+ # `Process.clock_gettime` + a couple of arithmetic ops — tens of
312
+ # nanoseconds. Contention is bounded by handshake rate (orders
313
+ # of magnitude lower than request rate), so the mutex is never on
314
+ # the hot per-request path.
315
+ class HandshakeRateLimiter
316
+ attr_reader :rate, :capacity
317
+
318
+ # Build a limiter for `rate` handshakes/sec/worker, or `:unlimited`
319
+ # to short-circuit every `acquire_token!` to true (no throttle).
320
+ # Anything else raises ArgumentError so config typos surface at
321
+ # boot.
322
+ def initialize(rate)
323
+ if rate == :unlimited || rate.nil?
324
+ @rate = :unlimited
325
+ @capacity = nil
326
+ @tokens = nil
327
+ @last_refill_at = nil
328
+ @mutex = nil
329
+ elsif rate.is_a?(Integer) && rate.positive?
330
+ @rate = rate
331
+ @capacity = rate.to_f
332
+ @tokens = @capacity
333
+ @last_refill_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
334
+ @mutex = Mutex.new
335
+ else
336
+ raise ArgumentError,
337
+ "tls.handshake_rate_limit must be a positive integer or :unlimited (got #{rate.inspect})"
338
+ end
339
+ @rejected = 0
340
+ end
341
+
342
+ # True when the bucket had a token to spend (handshake proceeds).
343
+ # False when the bucket is empty (caller should close the TCP
344
+ # connection without running SSL_accept — saves the CPU cost of
345
+ # the asymmetric crypto under handshake-storm conditions).
346
+ def acquire_token!
347
+ return true if @rate == :unlimited
348
+
349
+ @mutex.synchronize do
350
+ refill_locked!
351
+ if @tokens >= 1.0
352
+ @tokens -= 1.0
353
+ true
354
+ else
355
+ @rejected += 1
356
+ false
357
+ end
358
+ end
359
+ end
360
+
361
+ # Snapshot for stats / logging. `tokens` is the current bucket
362
+ # level (float), `rejected` is the cumulative count of denied
363
+ # handshake attempts since limiter construction.
364
+ def stats
365
+ return { rate: :unlimited, rejected: 0 } if @rate == :unlimited
366
+
367
+ @mutex.synchronize do
368
+ refill_locked!
369
+ { rate: @rate, capacity: @capacity, tokens: @tokens, rejected: @rejected }
370
+ end
371
+ end
372
+
373
+ private
374
+
375
+ # Refill must be called with @mutex held.
376
+ def refill_locked!
377
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
378
+ elapsed = now - @last_refill_at
379
+ return if elapsed <= 0
380
+
381
+ @tokens = [@tokens + (elapsed * @rate), @capacity].min
382
+ @last_refill_at = now
383
+ end
384
+ end
43
385
  end
44
386
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hyperion
4
- VERSION = '1.6.2'
4
+ VERSION = '2.11.0'
5
5
  end