TlsAcceptor is the server-side counterpart to TlsStream —
it wraps a TcpListener and produces TlsStream connections
after completing the TLS handshake against a server certificate
chain. TlsServerConfig carries the acceptor's policy: cert /
key paths, ALPN protocols to advertise, optional CA bundle for
mTLS client-cert verification.
This commit ships the type infrastructure: TlsServerConfig,
TlsAcceptor shell, TlsInfo value type, TlsServerError
hierarchy, plus the public re-exports through flare.tls and
the root flare package. The reactor-side handshake state
machine — non-blocking SSL_accept driven by edge-triggered
readable / writable events — is a focused follow-up that lands
once the OpenSSL SSL_CTX_* server-side surface is wired into
the existing flare/tls/ffi/openssl_wrapper.cpp.
Why split: the C-side handshake state machine is ~150 lines of
SSL_accept + SSL_get_error + BIO plumbing, plus
matching reactor surgery. The API surface here lets per-request
TlsInfo plumbing, cert reload, and mTLS all plug into the
public surface; the actual cipher-on-the-wire bits land
together in a single follow-up.
Public API:
from flare.tls import (
TlsServerConfig, TlsAcceptor, TlsInfo,
TlsServerError, TlsServerNotImplemented,
)
var cfg = TlsServerConfig(
cert_file="/etc/letsencrypt/live/example.com/fullchain.pem",
key_file="/etc/letsencrypt/live/example.com/privkey.pem",
alpn=["h2", "http/1.1"], # served preference order
require_client_cert=False, # mTLS off by default
client_ca_bundle="",
)
var acceptor = TlsAcceptor.bind(addr, cfg)
# acceptor.serve(handler) — flips on with the reactor
# follow-up that lands the SSL_accept state machine.
Structs
TlsServerError
struct TlsServerError
Generic server-side TLS failure (handshake, cert load, etc.).
message describes the failure in human-readable form;
code carries the underlying OpenSSL error code if
available (0 if not).
Implements Copyable, Movable, Writable
- Fields
message: Stringcode: Int
__init__
def __init__(out self, message: String, code: Int = Int(0))
write_to
def write_to[W: Writer](self, mut writer: W)
TlsServerNotImplemented
struct TlsServerNotImplemented
Marker raised to signal that the reactor-side handshake state machine is not available. Distinct type so callers can match on it for graceful degradation and fall back to the blocking handshake_fd path.
Implements Copyable, Movable, Writable
- Fields
message: String
write_to
def write_to[W: Writer](self, mut writer: W)
TlsSniCert
struct TlsSniCert
A certificate served to clients whose SNI matches host (an exact name, or *.example.com for one label).
Implements Copyable, Movable
- Fields
host: Stringcert_file: Stringkey_file: String
TlsServerConfig
struct TlsServerConfig
Fields:
cert_file: Path to the server certificate chain
in PEM format (full chain — leaf
first, then intermediates). Required.
key_file: Path to the server private key in
PEM format. Required.
alpn: ALPN protocol identifiers to
advertise during the handshake. Order
is preference order (the OpenSSL
callback selects the first
intersection with the client's
advertised list). Empty = no ALPN.
require_client_cert: Whether to require a client
certificate (mTLS). Defaults False.
When True, client_ca_bundle must
also be set.
client_ca_bundle: Path to a PEM bundle of trust anchors
for verifying client certificates
(mTLS). Empty = use OpenSSL's default
trust store.
min_protocol: Minimum TLS protocol version to
negotiate. Default
TLS_PROTOCOL_TLS12. TLS 1.0 / 1.1
are explicitly rejected.
Reload semantics: cert_file / key_file are re-read at
every call to TlsAcceptor.reload(); the config struct
itself is value-copied at acceptor construction time.
Implements Copyable, Movable
- Fields
cert_file: Stringkey_file: Stringalpn: List[String]require_client_cert: Boolclient_ca_bundle: Stringmin_protocol: Intenable_session_tickets: Bool: Opt-in; default False. When True the acceptor's SSL_CTX issues RFC 5077 session tickets (TLS 1.2) / RFC 8446 §4.6.1 NewSessionTicket frames (TLS 1.3) so peers can resume on subsequent connects. When False the context sends no tickets and keeps no server session cache, so no session is issued and none can be resumed (every connection is a full handshake). The ticket key lives as long as the SSL_CTX; :meth:TlsAcceptor.reload builds a new context, so it rotates the key and invalidates every ticket and cached session issued before it.ticket_lifetime_s: Int: Session ticket lifetime in seconds. Default 7200 (two hours). Maps to SSL_CTX_set_timeout and the embedded TLS 1.3 ticket_lifetime field. Production deployments should keep this short and rotate ticket keys via an out-of-band secret-management path.sni_certs: List[TlsSniCert]: Certificates chosen by SNI; a client whose SNI matches none, or that sends none, gets cert_file.handshake_timeout_ms: Int: Wall-clock cap (milliseconds) on a single blocking SSL_accept drive in TlsAcceptor.handshake_fd. Default 10_000 (10 s). A slow or stalled client that never completes the handshake is aborted at the deadline so it cannot hold the worker indefinitely (slow-loris-on-handshake DoS bound). Measured against a monotonic clock, not an iteration count, so it stays honest under the libc-sleep multiplier. 0 disables the cap.
__init__
def __init__(out self, cert_file: String, key_file: String, var alpn: List[String] = List(), require_client_cert: Bool = False, client_ca_bundle: String = "", min_protocol: Int = Int(771), enable_session_tickets: Bool = False, ticket_lifetime_s: Int = Int(7200), handshake_timeout_ms: Int = Int(10000), var sni_certs: List[TlsSniCert] = List())
Construct the config; validates inter-field invariants.
Raises Error: When require_client_cert=True is set
without a client_ca_bundle (mTLS without trust
anchors is meaningless — the verify callback
would have nothing to verify against). Prevents
the misconfiguration foot-gun by failing at
construction time rather than at handshake time.
TlsInfo
struct TlsInfo
Per-connection TLS metadata returned by :meth:TlsAcceptor.handshake_fd.
Available when the connection terminated TLS at flare's
TlsAcceptor. Plain-HTTP connections never see this struct
(the reactor only constructs it on the TLS handshake path).
Fields:
protocol: Negotiated protocol version
(e.g. "TLSv1.3").
cipher: Cipher suite name
(e.g. "TLS_AES_128_GCM_SHA256").
sni_host: Client-Hello SNI hostname, or empty
string if the client didn't send one.
alpn_protocol: Negotiated ALPN protocol
(e.g. "h2", "http/1.1"), or
empty string if ALPN didn't fire.
client_cert_subject: Subject DN of the client certificate
when mTLS is on; empty string
otherwise.
Implements Copyable, Movable
- Fields
protocol: Stringcipher: Stringsni_host: Stringalpn_protocol: Stringclient_cert_subject: String
__init__
def __init__(out self, protocol: String = "", cipher: String = "", sni_host: String = "", alpn_protocol: String = "", client_cert_subject: String = "")
TlsAcceptor
struct TlsAcceptor
Server-side TLS acceptor.
Wraps a TlsServerConfig and a real ServerCtx (the
OpenSSL SSL_CTX* produced by C6's FFI). Constructor
validates the cert + key + ALPN + mTLS config and raises if
any are bad, so configuration errors fail at server-bind
time rather than at the first inbound handshake.
Per-connection use:
- After
listener.accept() returns a TcpStream, call
acceptor.handshake(stream) raises -> Tuple[TlsStream, TlsInfo]. The method drives SSL_accept to completion
in blocking mode (suitable for the existing serve
entry shape; the reactor-side STATE_TLS_HANDSHAKE
state machine that uses the same FFI but in a non-blocking
reactor loop is a focused follow-up).
Cert reload:
acceptor.reload() re-reads the cert + key from the paths
in config and atomically swaps them on the underlying
SSL_CTX. In-flight sessions hold the old cert via
SSL_use_certificate; new handshakes pick up the new one.
Implements Movable
- Fields
config: TlsServerConfig: The acceptor's policy. Mutable via reload() for cert rotation without restart.
__init__
def __init__(out self, var config: TlsServerConfig)
Loads the cert + key, configures min-protocol + cipher
list, sets up ALPN if non-empty, sets up mTLS verification
if require_client_cert. Raises on any FFI failure —
configuration errors are caught at construction time.
into_ctx
def into_ctx(deinit self) -> ServerCtx
Hand over the configured SSL_CTX (ALPN, mTLS, minimum version, session-ticket policy applied), e.g. to the HTTP reactor, which drives its own non-blocking handshakes.
reload
def reload(mut self)
Re-read the cert + key files from config and swap in a freshly built SSL_CTX.
The replacement context is constructed from config first
and only then installed, so a failed reload (unreadable file,
cert / key mismatch) raises and leaves the running context
untouched. In-flight sessions keep their reference to the
old context and finish with the old cert; new handshakes
pick up the new one.
A new SSL_CTX has its own random session-ticket keys and
an empty session cache, so a reload also rotates the ticket
key: a ticket or cached session issued before the reload does
not resume after it (the peer falls back to a full
handshake). This is what docs/threat-model.md promises.
handshake_fd
def handshake_fd(mut self, fd: Int) -> Tuple[Int, TlsInfo]
Drive the SSL_accept state machine on fd to completion (or fatal error) in blocking-poll mode. Sleeps in 1ms slices between WANT_READ / WANT_WRITE returns.
Returns (ssl_addr, tls_info) — the caller owns the
ssl_addr and is responsible for calling
server_ssl_free(ctx, ssl_addr) when done. The
TlsInfo carries the negotiated ALPN protocol + SNI
hostname (live-populated from the OpenSSL handle).
- Args
fd: The accepted TCP fd (as returned by
TcpListener.accept().raw_fd() or similar).
Raises Error: On fatal handshake failure (cert mismatch,
client refused, etc.).
info_placeholder
def info_placeholder(self) -> TlsInfo
Return a default TlsInfo value with empty strings in every field. Kept for API-surface compatibility; production callers go through handshake_fd which returns a live-populated TlsInfo.