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,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
|
data/lib/hyperion/thread_pool.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/hyperion/version.rb
CHANGED