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

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.options |= 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.options &= ~OpenSSL::SSL::OP_NO_TICKET if ctx.options
  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.

Returns:

  • (Boolean)


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).

Returns:

  • (Boolean)


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