Module: Hyperion::TLS
- Defined in:
- lib/hyperion/tls.rb
Overview
TLS context builder with ALPN configured for HTTP/2 + HTTP/1.1.
Phase 7: TLS is opt-in via Server’s ‘tls:` kwarg. ALPN lets the client negotiate `h2` (HTTP/2) or `http/1.1` during the handshake; the server then dispatches to either Http2Handler or Connection accordingly.
1.8.0 (Phase 4): server-side session resumption is on by default. The context enables ‘SESSION_CACHE_SERVER` mode and clears `OP_NO_TICKET` so OpenSSL’s auto-rolled session-ticket key handles short-circuited handshakes for returning clients. ‘session_id_context` is set to a stable per-process value so cache lookups cross worker boundaries when the master inherits a single listener fd (`:share` mode); on Linux `:reuseport` workers, the kernel pins client → worker by tuple hash so each worker’s local cache covers its own returning clients.
Cross-worker ticket-key sharing requires ‘SSL_CTX_set_tlsext_ticket_keys` which Ruby’s stdlib OpenSSL does not bind today (3.3.x). When that binding lands we’ll thread the master-generated key through to each worker; until then resumption works inside a single worker’s session cache. RFC §4 documents this trade-off.
2.2.0 (Phase 9): kernel TLS transmit (KTLS_TX) on Linux ≥ 4.13 + OpenSSL ≥ 3.0. After the userspace handshake completes, the symmetric session key is handed to the kernel and subsequent SSL_write calls go through kernel sendfile/write paths, bypassing the userspace cipher loop. Pairs with — does not replace — Phase 4 session resumption. macOS / BSD have no kTLS support; the probe returns false and the context falls back to plain userspace SSL_write transparently.
Defined Under Namespace
Classes: HandshakeRateLimiter
Constant Summary collapse
- SUPPORTED_PROTOCOLS =
%w[h2 http/1.1].freeze
- PEM_CERT_RE =
/-----BEGIN CERTIFICATE-----.+?-----END CERTIFICATE-----/m- OP_ENABLE_KTLS_VALUE =
OpenSSL 3.0+ added SSL_OP_ENABLE_KTLS (= 0x00000008 in openssl/ssl.h). Most Ruby openssl bindings expose it as ‘OpenSSL::SSL::OP_ENABLE_KTLS`; fall back to the literal constant value on builds that don’t.
if OpenSSL::SSL.const_defined?(:OP_ENABLE_KTLS) OpenSSL::SSL::OP_ENABLE_KTLS else 0x00000008 end
- MIN_OPENSSL_VERSION_FOR_KTLS =
OpenSSL 3.0 cuts the line for kTLS support — earlier 1.1.x builds accept the option flag but silently no-op. Compare against the numeric ‘OPENSSL_VERSION_NUMBER` (Mmnnffpps form) so the check works with stdlib bindings that don’t expose every symbolic constant.
0x30000000- MIN_LINUX_KERNEL_FOR_KTLS =
Linux added kTLS_TX in 4.13 (commit d3b18ad31f91e). Earlier kernels don’t expose the AF_ALG TLS ULP. Probe via Etc.uname.
[4, 13].freeze
- SESSION_ID_CONTEXT =
Stable per-process session_id_context. Sized at the OpenSSL hard cap (32 bytes); randomized once at process boot so two unrelated Hyperion processes on the same host don’t share session caches by accident, but consistent across forks via copy-on-write so workers all advertise the same context id.
SecureRandom.bytes(32).freeze
- DEFAULT_SESSION_CACHE_SIZE =
20_480- KTLS_ACTIVE_CONNECTIONS_GAUGE =
2.4-C — gauge name for the per-worker count of active connections whose TLS_TX is currently driven by the kernel module. Exposed as a module constant so the Server (handshake-complete path) and Connection (close path) can share the same identifier without plumbing.
:hyperion_tls_ktls_active_connections
Class Method Summary collapse
-
.configure_ktls!(ctx, mode) ⇒ Object
Apply the operator-supplied kTLS policy onto an already-built SSLContext.
-
.configure_session_resumption!(ctx, session_cache_size) ⇒ Object
Wire up the in-process server-side session cache + tickets.
-
.context(cert:, key:, chain: nil, session_cache_size: DEFAULT_SESSION_CACHE_SIZE, ktls: :auto) ⇒ Object
Builds the OpenSSL::SSL::SSLContext used to wrap TLS listening sockets.
-
.ktls_active?(_ssl_socket = nil) ⇒ Boolean
Whether kernel TLS_TX is currently active on the supplied SSLSocket.
-
.ktls_supported? ⇒ Boolean
Whether this Ruby + kernel combination can host kTLS_TX.
-
.parse_pem_chain(pem) ⇒ Object
Split a PEM blob into one OpenSSL::X509::Certificate per BEGIN/END block.
-
.reset_ktls_probe! ⇒ Object
Test seam: clear the cached probe result so a stubbed Etc.uname / OpenSSL version can drive the spec matrix.
-
.rotate!(ctx) ⇒ Object
SIGUSR2 hook: flush the in-process session cache so subsequent connections cannot resume against entries the master has decided are stale.
-
.track_ktls_handshake!(ssl_socket) ⇒ Object
Mark an SSLSocket as kTLS-tracked.
-
.untrack_ktls_handshake!(ssl_socket) ⇒ Object
Counterpart to ‘track_ktls_handshake!`.
Class Method Details
.configure_ktls!(ctx, mode) ⇒ Object
Apply the operator-supplied kTLS policy onto an already-built SSLContext. Split out so it can be re-applied after a SIGUSR2 rotation (parallels ‘configure_session_resumption!`).
OpenSSL drives the actual promotion: when OP_ENABLE_KTLS is set on the context and the negotiated cipher is one the kernel supports (currently AES-128-GCM, AES-256-GCM, and CHACHA20-POLY1305 on newer kernels), ‘SSL_write` on the post-handshake socket goes through the kernel TLS path. We just opt in here.
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 |
# File 'lib/hyperion/tls.rb', line 132 def configure_ktls!(ctx, mode) case mode when :off, false return ctx when :on, true unless ktls_supported? raise Hyperion::UnsupportedError, 'kTLS not supported on this platform (need Linux >= 4.13 + OpenSSL >= 3.0); ' \ 'set tls.ktls = :off or :auto to fall back to userspace SSL_write' end when :auto, nil return ctx unless ktls_supported? else raise ArgumentError, "tls.ktls must be :auto, :on, or :off (got #{mode.inspect})" end ctx. |= OP_ENABLE_KTLS_VALUE ctx rescue NoMethodError # Some openssl bindings expose `#options` as read-only. Treat as a # silent no-op rather than crashing the boot — kTLS is an # optimization, the request path keeps working without it. ctx end |
.configure_session_resumption!(ctx, session_cache_size) ⇒ Object
Wire up the in-process server-side session cache + tickets. Split out of ‘context` so it can be re-applied after a SIGUSR2 rotation call to `flush_sessions` without rebuilding the whole context.
‘session_cache_size` follows the OpenSSL convention: zero or negative disables the cache; positive sets the LRU cap. We do NOT set `OP_NO_TICKET` — its absence is what enables RFC 5077 session tickets (resumption with no server-side state).
255 256 257 258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/hyperion/tls.rb', line 255 def configure_session_resumption!(ctx, session_cache_size) ctx.session_id_context = SESSION_ID_CONTEXT[0, 32] if session_cache_size.to_i.positive? ctx.session_cache_mode = OpenSSL::SSL::SSLContext::SESSION_CACHE_SERVER ctx.session_cache_size = session_cache_size.to_i else ctx.session_cache_mode = OpenSSL::SSL::SSLContext::SESSION_CACHE_OFF end # Explicitly clear OP_NO_TICKET if a default-params layer set it. # OpenSSL's default is tickets ON, but a host app that mutates # SSLContext::DEFAULT_PARAMS could add it; defend by clearing. ctx. &= ~OpenSSL::SSL::OP_NO_TICKET if ctx. ctx end |
.context(cert:, key:, chain: nil, session_cache_size: DEFAULT_SESSION_CACHE_SIZE, ktls: :auto) ⇒ Object
Builds the OpenSSL::SSL::SSLContext used to wrap TLS listening sockets. ‘session_cache_size` (default 20_480) is the in-process server-side cache budget. Setting `0` disables the cache entirely (every connection pays the full handshake cost — useful for tests of the cache-eviction path).
2.2.0 (Phase 9): ‘ktls` selects the kernel-TLS transmit policy.
* `:auto` (default) — enable on Linux when the kernel + OpenSSL
combo supports it; off elsewhere.
* `:on` — force-enable; raise at boot if not supported.
* `:off` — never enable, even if supported.
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/hyperion/tls.rb', line 83 def context(cert:, key:, chain: nil, session_cache_size: DEFAULT_SESSION_CACHE_SIZE, ktls: :auto) ctx = OpenSSL::SSL::SSLContext.new ctx.cert = cert ctx.key = key # NB: do NOT switch to `chain.present?` — that's ActiveSupport, which # this gem does not depend on (would NameError at runtime). The # explicit guard below is the plain-Ruby equivalent. ctx.extra_chain_cert = chain unless chain.nil? || chain.empty? ctx.min_version = OpenSSL::SSL::TLS1_2_VERSION ctx.alpn_protocols = SUPPORTED_PROTOCOLS ctx.alpn_select_cb = lambda do |client_protocols| # Prefer h2 if the client offered it; else fall back to http/1.1. SUPPORTED_PROTOCOLS.find { |p| client_protocols.include?(p) } end configure_session_resumption!(ctx, session_cache_size) configure_ktls!(ctx, ktls) ctx end |
.ktls_active?(_ssl_socket = nil) ⇒ Boolean
Whether kernel TLS_TX is currently active on the supplied SSLSocket. Used in tests + the once-per-worker boot log to confirm the kernel actually accepted the cipher. Returns ‘nil` when the answer can’t be determined (no FFI access to libssl, or this build of OpenSSL doesn’t expose ‘SSL_get_KTLS_send`) — callers must distinguish nil (“don’t know”) from false (“definitely not active”).
Implementation note: Ruby’s stdlib openssl does not expose the underlying ‘SSL*` pointer, so a direct `SSL_get_KTLS_send` call is not reliably reachable from Ruby. We approximate with a kernel- module probe: on Linux, the `tls` module is loaded into the kernel the first time any process opens a socket with `setsockopt(…, TCP_ULP, “tls”)`. After OpenSSL promotes the connection to KTLS, `/proc/modules` contains `tls` with a positive refcount. This is a process-global signal — adequate for the boot-log assertion (one- shot at first connection) and for the spec’s “did kTLS engage on this host” check, but not a per-socket guarantee.
174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/hyperion/tls.rb', line 174 def ktls_active?(_ssl_socket = nil) return nil unless ktls_supported? File.foreach('/proc/modules') do |line| next unless line.start_with?('tls ') # Format: `tls 155648 3 - Live ...` — third column is refcount. refcount = line.split(' ', 4)[2].to_i return refcount.positive? end false rescue Errno::ENOENT, Errno::EACCES nil end |
.ktls_supported? ⇒ Boolean
Whether this Ruby + kernel combination can host kTLS_TX. Cached per-process: the answer can’t change without a process restart. Three gates: Linux kernel ≥ 4.13, OpenSSL ≥ 3.0, and the openssl gem actually exposing the flag (sanity-check, since we fall back to the literal value when the constant is missing).
109 110 111 112 113 |
# File 'lib/hyperion/tls.rb', line 109 def ktls_supported? return @ktls_supported if defined?(@ktls_supported) @ktls_supported = linux_ktls_kernel? && openssl_ktls_capable? end |
.parse_pem_chain(pem) ⇒ Object
Split a PEM blob into one OpenSSL::X509::Certificate per BEGIN/END block. Production cert files commonly bundle leaf + intermediate(s) in a single file, but ‘OpenSSL::X509::Certificate.new(pem)` only parses the FIRST block — so if we don’t split here the intermediates are silently dropped and clients see an incomplete chain.
286 287 288 |
# File 'lib/hyperion/tls.rb', line 286 def parse_pem_chain(pem) pem.scan(PEM_CERT_RE).map { |block| OpenSSL::X509::Certificate.new(block) } end |
.reset_ktls_probe! ⇒ Object
Test seam: clear the cached probe result so a stubbed Etc.uname / OpenSSL version can drive the spec matrix. Production code never calls this — ‘ktls_supported?` is idempotent across the process lifetime once memoized.
119 120 121 |
# File 'lib/hyperion/tls.rb', line 119 def reset_ktls_probe! remove_instance_variable(:@ktls_supported) if defined?(@ktls_supported) end |
.rotate!(ctx) ⇒ Object
SIGUSR2 hook: flush the in-process session cache so subsequent connections cannot resume against entries the master has decided are stale. OpenSSL auto-generates a fresh session-ticket key when the previous one’s lifetime elapses; calling ‘flush_sessions` here narrows the resumption window so an exfiltrated cache entry is invalidated within one rotation cycle.
276 277 278 279 |
# File 'lib/hyperion/tls.rb', line 276 def rotate!(ctx) ctx.flush_sessions ctx end |
.track_ktls_handshake!(ssl_socket) ⇒ Object
Mark an SSLSocket as kTLS-tracked. Bumps the worker’s gauge by one. Idempotent on the same socket — calling twice is a no-op so signal-driven re-handshakes don’t double-count. Returns true when the gauge was actually incremented (kTLS engaged for this peer), false otherwise.
201 202 203 204 205 206 207 208 209 210 211 212 213 214 |
# File 'lib/hyperion/tls.rb', line 201 def track_ktls_handshake!(ssl_socket) return false unless ssl_socket return false if ssl_socket.instance_variable_get(:@hyperion_ktls_tracked) active = ktls_active?(ssl_socket) return false unless active ssl_socket.instance_variable_set(:@hyperion_ktls_tracked, true) Hyperion.metrics.increment_gauge(KTLS_ACTIVE_CONNECTIONS_GAUGE, [Process.pid.to_s]) true rescue StandardError false end |
.untrack_ktls_handshake!(ssl_socket) ⇒ Object
Counterpart to ‘track_ktls_handshake!`. Called by Connection’s ensure block when a socket flagged as kTLS-tracked is closed. Idempotent.
219 220 221 222 223 224 225 226 227 228 229 |
# File 'lib/hyperion/tls.rb', line 219 def untrack_ktls_handshake!(ssl_socket) return false unless ssl_socket return false unless ssl_socket.instance_variable_get(:@hyperion_ktls_tracked) ssl_socket.instance_variable_set(:@hyperion_ktls_tracked, false) Hyperion.metrics.decrement_gauge(KTLS_ACTIVE_CONNECTIONS_GAUGE, [Process.pid.to_s]) true rescue StandardError false end |