What this post covers
This is the fifteenth post in the "Digging into Apache httpd's Source" series. In part 14 we looked at mod_proxy, which passes requests on to other servers. This time we look behind the curtain of the one line SSLEngine on in httpd.conf that turns on HTTPS: mod_ssl.
There are four questions to answer.
- The httpd core (the
server/directory) contains no TLS code. So where, and in what order, does encryption get in? - OpenSSL is a library that "reads from and writes to a socket", but httpd hides its sockets behind a chain of filters. How do the two connect?
- With several virtual hosts on one port (443), how does httpd decide which certificate to send before it has even read the request?
- Who produces values like
SSL_PROTOCOL, and how do other modules read them without knowing mod_ssl exists?
| Item | Details |
|---|---|
| Subject | Apache httpd |
| Version | 2.5.1-dev (trunk) |
| Commit | e4af30fe6d (2026-10-24) |
| Main files | modules/ssl/mod_ssl.c, ssl_engine_io.c, ssl_engine_init.c, ssl_engine_kernel.c, ssl_engine_vars.c, server/ssl.c, include/http_ssl.h |
All line numbers and details in this post refer to this commit. How to get the same source and build the Docker image used for the examples is in part 1.
mod_ssl is a big module: the C files under modules/ssl/ alone add up to more than 23,000 lines (counting add-ons such as mod_ssl_ct.c). So that someone opening httpd's source for the first time can keep up, this post follows only the main path: the places where TLS plugs into the server. Client certificates, the session cache, OCSP and the like are listed by name and location in "What this post leaves out" at the end.
Terms to know first
TLS (Transport Layer Security) is the protocol that puts encryption on top of a TCP connection. Its old name was SSL, which is why httpd's directives and source still say SSL (SSLEngine, mod_ssl). In this post the two words mean the same thing. HTTPS is "HTTP spoken over a connection wrapped in TLS".
The handshake is the greeting the client and server exchange at the very start of a TLS connection. The client speaks first with a ClientHello (the TLS versions and ciphers it supports, and so on), and the server answers by sending its certificate. A certificate is a document proving "this server really is a.example", and the server must hold the matching private key. HTTP requests only flow once the handshake is over.
SNI (Server Name Indication) is a field inside the ClientHello that says "I want to reach b.example". HTTP's Host header can only be read after decryption, so at the moment the certificate is chosen SNI is the only clue available.
OpenSSL is the C library that does the actual TLS work. mod_ssl doesn't implement any cryptography itself; it calls OpenSSL. Three OpenSSL names show up in this post.
SSL_CTX— a bundle of settings: certificate, key, allowed protocols. One per virtual host, built when the server starts.SSL— the TLS state of one connection. One per connection, made from anSSL_CTXtemplate.BIO— OpenSSL's I/O endpoint for reading and writing bytes. Normally it's a socket or memory, but you can build a BIO whose read and write functions you fill in yourself. That is the heart of this post.
Connection filters are the layer of filters from part 11 that attach to a single connection and deal in bytes. Filters line up in the chain by the number of their type (AP_FTYPE_*), and at the very bottom sit the core filters CORE_IN and CORE, which actually read from and write to the socket. Data travels in buckets, passed along as brigades (lists of buckets).
Virtual hosts, as we saw in part 9, are chosen twice: once by IP and port when the connection arrives (c->base_server), and once by the Host header after the request is read (r->server). TLS needs one more choice in between.
1. The core doesn't know TLS — the hooks mod_ssl registers
If you read the core code in server/, you won't find OpenSSL calls like SSL_read() or SSL_accept(). The core doesn't know about TLS; it just leaves places (hooks and filters) open for modules to step in. What mod_ssl puts in those places is gathered in a single registration function.
static void ssl_register_hooks(apr_pool_t *p)
{
/* ... */
ssl_io_filter_register(p);
ap_hook_pre_connection(ssl_hook_pre_connection,NULL,NULL, APR_HOOK_MIDDLE);
ap_hook_process_connection(ssl_hook_process_connection,
NULL, NULL, APR_HOOK_MIDDLE);
ap_hook_test_config (ssl_hook_ConfigTest, NULL,NULL, APR_HOOK_MIDDLE);
ap_hook_post_config (ssl_init_Module, NULL,b_pc, APR_HOOK_MIDDLE);
ap_hook_http_scheme (ssl_hook_http_scheme, NULL,NULL, APR_HOOK_MIDDLE);
ap_hook_default_port (ssl_hook_default_port, NULL,NULL, APR_HOOK_MIDDLE);
ap_hook_pre_config (ssl_hook_pre_config, NULL,NULL, APR_HOOK_MIDDLE);
ap_hook_child_init (ssl_init_Child, NULL,NULL, APR_HOOK_MIDDLE);
ap_hook_post_read_request(ssl_hook_ReadReq, pre_prr,NULL, APR_HOOK_MIDDLE);
/* ... check_access, check_authn, check_authz ... */
ap_hook_fixups (ssl_hook_Fixup, NULL,NULL, APR_HOOK_MIDDLE);
/* ... */
ssl_var_register(p);
ap_hook_ssl_bind_outgoing (ssl_hook_ssl_bind_outgoing, NULL, NULL, APR_HOOK_MIDDLE);
/* ... */
}
modules/ssl/mod_ssl.c lines 761–809
ap_hook_xxx(function, modules that must come before, modules that must come after, position) is exactly the hook registration we saw in part 6. Passing a function's name as an argument is a C function pointer: it means "call this function later" (part 2). Put the list in time order and you have the map for this post.
| When | Hook / registration | What mod_ssl does | Section |
|---|---|---|---|
| Startup, before reading config | pre_config → ssl_hook_pre_config() |
Initialise OpenSSL | 2 |
| Startup, after reading config | post_config → ssl_init_Module() |
Build an SSL_CTX per virtual host |
2 |
| At registration | ssl_io_filter_register() |
Register three connection filters | 3 |
| Each connection, first thing | pre_connection → ssl_hook_pre_connection() |
Create the SSL object, insert filters |
3, 4 |
| Each connection, before reading a request | process_connection → ssl_hook_process_connection() |
Trigger the handshake | 5 |
| Right after a request is read | post_read_request → ssl_hook_ReadReq() |
Check SNI against Host |
6 |
| Just before the handler | fixups → ssl_hook_Fixup() |
HTTPS, SSL_* environment variables |
7 |
| Any time | ssl_conn_is_ssl hook, ssl_var_lookup optional function |
Answer "is this connection TLS?" and "what is SSL_PROTOCOL?" | 7 |
The http_scheme and default_port hooks are worth a look too. When the core builds something like a redirect URL, it doesn't decide by itself whether the request's scheme is http or https; it asks a hook (#define ap_http_scheme(r) ap_run_http_scheme(r) in include/httpd.h, line 302). mod_ssl returns "https" for a TLS request and NULL otherwise (mod_ssl.c lines 676–679). This hook is RUN_FIRST, stopping at the first function that gives a non-NULL answer (server/protocol.c line 2558), so if mod_ssl stays quiet, the http module's http_scheme(), registered at the very end (APR_HOOK_REALLY_LAST), answers "http" (modules/http/http_core.c lines 117–128, line 362).
The core never writes the word https itself; it only asks the hook. Without mod_ssl loaded, the "http" at the end is always the answer.
The directives you know from config files, such as SSLEngine and SSLCertificateFile, live in the ssl_config_cmds table, the fifth slot of the module struct after STANDARD20_MODULE_STUFF (mod_ssl.c lines 811–822). How a directive becomes a struct field is the same as in part 7.
2. Startup — post_config builds an SSL_CTX for each virtual host
TLS setup is expensive. You have to read and check the certificate and private key files, and decide which protocols and ciphers to allow. Doing that per connection would be slow, so mod_ssl builds an SSL_CTX once per virtual host at startup. The function that does it is ssl_init_Module(), registered on the post_config hook (ssl_engine_init.c lines 451–714).
Blue is a hook, yellow is something built and kept. All of this is done before the first connection arrives.
pre_config— wake OpenSSL up. With OpenSSL 1.1 or later this is a single line,OPENSSL_init_ssl()(mod_ssl.c lines 428–434). The#elsepart below it is for old OpenSSL versions.#ifndef/#elseis a preprocessor branch that keeps only one of the two at compile time.- Settle defaults for each server. It walks the
server_reclist, marks servers without an explicitSSLEngineas off (the only exception: a server whose protocol is https, as inListen 443 https, and that has certificates configured is switched on), and buildsvhost_id, the name used in logs (ssl_engine_init.c lines 497–543). - An
SSL_CTXfor each enabled server. A second loop callsssl_init_ConfigureServer()→ssl_init_server_ctx()(lines 625–640, 3268–3308). This is whereSSL_CTX_new()creates the settings bundle (line 972) and the SNI callbacks we'll meet in section 6 are registered, and then the certificate and key are loaded (ssl_init_server_certs(), from line 2200). - BIO function tables. Near the end,
init_bio_methods()builds the BIO function tables of section 4 (lines 686–689).
Right before loading certificates, other modules get a chance too.
/* Allow others to provide certificate files */
pks = sc->server->pks;
n = pks->cert_files->nelts;
ap_ssl_add_cert_files(s, p, pks->cert_files, pks->key_files);
ssl_run_add_cert_files(s, p, pks->cert_files, pks->key_files);
modules/ssl/ssl_engine_init.c lines 3145–3149
ap_ssl_add_cert_files() is a hook in the core (server/ssl.c). mod_md, which fetches certificates automatically from places like Let's Encrypt, hangs a function on it (modules/md/mod_md.c line 1592) and adds the file names of the certificates it has obtained to the list. mod_md never calls mod_ssl, and mod_ssl never calls mod_md; both only know the core's hook. We come back to these core hooks in section 7.
Easy to get wrong
- Put
SSLEngine oninside a<VirtualHost>. There is oneSSL_CTXper server config. If you put it at global scope, the virtual hosts inherit the same config struct and mod_ssl ends up trying to build the sameSSL_CTXtwice; it stops with the message "SSLEngine On should go in the VirtualHost, not in global scope." (ssl_engine_init.c lines 3138–3143). - The
:443in the log may not be the real port. The example below runs on port 8443, yet the log says "Configuring server a.example:443". WhenServerNamehas no port (s->port == 0),ssl_util_vhostid(), which buildsvhost_id, writes 443 for a TLS server (ssl_util.c lines 42–55). It's only a name; it has nothing to do with the port actually being listened on. - The startup log lines appear twice. In the example's log, "Init: Initializing (virtual) servers for SSL" (AH01887) is printed twice. httpd reads its config twice at startup and runs
post_configtwice. The reason is in part 5.
3. Per connection — pre_connection creates the SSL object and inserts filters
When a connection arrives, the MPM follows the sequence from part 9: pick the default virtual host by IP and port (ap_update_vhost_given_ip()), run the pre_connection hook, then run the process_connection hook (server/connection.c lines 244–253; the event MPM follows the same order itself inside process_socket(), server/mpm/event/event.c lines 1099–1153).
At pre_connection not a single byte has been read yet. mod_ssl checks whether this connection should use TLS and, if so, only prepares.
static int ssl_hook_pre_connection(conn_rec *c, void *csd)
{
SSLSrvConfigRec *sc;
SSLConnRec *sslconn = myConnConfig(c);
/*
* Immediately stop processing if SSL is disabled for this connection
*/
if (ssl_engine_status(c, sslconn) != OK) {
return DECLINED;
}
/* ... */
ap_log_cerror(APLOG_MARK, APLOG_INFO, 0, c, APLOGNO(01964)
"Connection to child %ld established "
"(server %s)", c->id, sc->vhost_id);
return ssl_init_ssl_connection(c, NULL);
}
modules/ssl/mod_ssl.c lines 686–715
ssl_engine_status() checks whether this connection's c->base_server (the virtual host chosen by IP and port) has SSLEngine on (mod_ssl.c lines 542–571). If not, it returns DECLINED and steps aside. That is why, on the same server, mod_ssl does nothing at all for connections on port 80. myConnConfig(c) is a macro that fetches mod_ssl's share of the connection's per-module config slots (ssl_private.h lines 383–384). mod_ssl keeps one SSLConnRec struct there per connection, holding things like the connection's SSL object and the currently chosen virtual host (server) (ssl_private.h lines 643–677).
The skeleton of ssl_init_ssl_connection() is three lines.
if (!(sslconn->ssl = ssl = SSL_new(mctx->ssl_ctx))) {
/* ... log the error ... */
}
/* ... */
SSL_set_app_data(ssl, c);
modssl_set_app_data2(ssl, NULL); /* will be request_rec */
SSL_set_verify_result(ssl, X509_V_OK);
return ssl_io_filter_init(c, r, ssl);
}
modules/ssl/mod_ssl.c lines 641–674 (middle shortened)
SSL_new()— creates this connection'sSSLfrom theSSL_CTXbuilt at startup. TheSSL_CTXused here belongs toc->base_server, that is, the default virtual host chosen by IP and port. SNI hasn't been read yet, so there's no other option. Section 6 changes this.SSL_set_app_data(ssl, c)— hangs httpd'sconn_recon OpenSSL'sSSLobject. Later, when OpenSSL calls one of mod_ssl's callbacks, all it passes is theSSL *; the callback uses this to get theconn_recback (SSL_get_app_data()).ssl_io_filter_init()— inserts the filters.
Three SSL filters on the connection
These are the filters mod_ssl registers.
void ssl_io_filter_register(apr_pool_t *p)
{
ap_register_input_filter (ssl_io_filter, ssl_io_filter_input, NULL, AP_FTYPE_CONNECTION + 5);
ap_register_output_filter (ssl_io_coalesce, ssl_io_filter_coalesce, NULL, AP_FTYPE_CONNECTION + 4);
ap_register_output_filter (ssl_io_filter, ssl_io_filter_output, NULL, AP_FTYPE_CONNECTION + 5);
ap_register_input_filter (ssl_io_buffer, ssl_io_filter_buffer, NULL, AP_FTYPE_PROTOCOL);
return;
}
modules/ssl/ssl_engine_io.c lines 2305–2314
The filter name ssl_io_filter is the string "SSL/TLS Filter", and ssl_io_coalesce is "SSL/TLS Coalescing Filter" (lines 1019–1021). As for the type numbers, AP_FTYPE_CONNECTION is 50 and AP_FTYPE_NETWORK, where the core handles the socket, is 60 (include/util_filter.h lines 180–183). So the SSL filter (55) sits just above the core filters (60). The last one, ssl_io_buffer, is a special filter that holds a request body for a while because of client certificates, so we leave it out here.
The blue boxes are mod_ssl's filters. Everything above them sees only plaintext; ciphertext only flows between the blue and grey boxes.
ssl_io_filter_init() actually attaches these filters to the connection.
apr_status_t ssl_io_filter_init(conn_rec *c, request_rec *r, SSL *ssl)
{
ssl_filter_ctx_t *filter_ctx;
apr_status_t rv = APR_SUCCESS;
filter_ctx = apr_palloc(c->pool, sizeof(ssl_filter_ctx_t));
filter_ctx->config = myConnConfig(c);
ap_add_output_filter(ssl_io_coalesce, NULL, r, c);
filter_ctx->pOutputFilter = ap_add_output_filter(ssl_io_filter,
filter_ctx, r, c);
#if MODSSL_USE_OPENSSL_PRE_1_1_API
filter_ctx->pbioWrite = BIO_new(&bio_filter_out_method);
#else
filter_ctx->pbioWrite = BIO_new(bio_filter_out_method);
#endif
if(filter_ctx->pbioWrite == NULL) {
return APR_ENOMEM;
}
BIO_set_data(filter_ctx->pbioWrite, (void *)bio_filter_out_ctx_new(filter_ctx, c));
rv = ssl_io_input_add_filter(filter_ctx, c, r, ssl);
if(rv != APR_SUCCESS) {
return rv;
}
SSL_set_bio(ssl, filter_ctx->pbioRead, filter_ctx->pbioWrite);
filter_ctx->pssl = ssl;
apr_pool_cleanup_register(c->pool, (void*)filter_ctx,
ssl_io_filter_cleanup, apr_pool_cleanup_null);
/* ... */
return APR_SUCCESS;
}
modules/ssl/ssl_engine_io.c lines 2265–2303
Besides attaching filters, three lines stand out.
BIO_new(...)twice — the write BIO (pbioWrite) is created here, the read BIO (pbioRead) insidessl_io_input_add_filter()(lines 2240–2247).BIO_set_data()hangs one of mod_ssl's context structs on each BIO.SSL_set_bio(ssl, read BIO, write BIO)— tells this connection'sSSL"read and write through these two BIOs instead of a socket". This is the star of the next section.apr_pool_cleanup_register(c->pool, ...)— makes the TLS state get cleaned up when the connection pool is destroyed. Hanging cleanup functions on a pool is what we saw in part 4. mod_ssl has no separate connection-closing code; it relies on the connection's lifetime.
Easy to get wrong
The virtual host at pre_connection time is the one chosen by IP and port, regardless of SNI. In the example below, even a connection made to b.example is logged as "Connection to child … established (server a.example:443)", because a.example is the first virtual host on the same *:8443. The switch to b.example happens during the handshake (section 6).
4. BIOs — the adapter between OpenSSL and the filter chain
OpenSSL can be set up to take a socket number and call read() and write() on it directly. In httpd, though, the socket belongs to the core filters CORE_IN and CORE. Other filters may sit in between (for example, mod_logio's input filter, which counts received bytes, is registered at AP_FTYPE_NETWORK - 1, between the SSL filter and CORE_IN, modules/loggers/mod_logio.c lines 342–343), and the event MPM handles sockets in non-blocking mode. If OpenSSL touched the socket directly, it would break that order.
So mod_ssl gives OpenSSL a BIO of its own making instead of a socket. A BIO's behaviour is defined by a function table (BIO_METHOD) holding "read function, write function, control function". mod_ssl fills the slots of that table with its own functions.
void init_bio_methods(void)
{
bio_filter_out_method = BIO_meth_new(BIO_TYPE_MEM, "APR output filter");
BIO_meth_set_write(bio_filter_out_method, &bio_filter_out_write);
BIO_meth_set_ctrl(bio_filter_out_method, &bio_filter_out_ctrl);
BIO_meth_set_create(bio_filter_out_method, &bio_filter_create);
BIO_meth_set_destroy(bio_filter_out_method, &bio_filter_destroy);
bio_filter_in_method = BIO_meth_new(BIO_TYPE_MEM, "APR input filter");
BIO_meth_set_read(bio_filter_in_method, &bio_filter_in_read);
BIO_meth_set_ctrl(bio_filter_in_method, &bio_filter_in_ctrl); /* ctrl is never called */
BIO_meth_set_create(bio_filter_in_method, &bio_filter_create);
BIO_meth_set_destroy(bio_filter_in_method, &bio_filter_destroy);
}
modules/ssl/ssl_engine_io.c lines 607–620
This is the "swap behaviour via a table of function pointers" from part 2, only this time it's OpenSSL, not httpd, asking for it. Lines 576–600 of the same file have a version for old OpenSSL that fills in the struct directly (#if MODSSL_USE_OPENSSL_PRE_1_1_API); it does the same job.
Reading: filter → OpenSSL → BIO → back to the filters
A comment at the top of the file sums up the flow in one line.
* The input chain is roughly:
*
* ssl_io_filter_input->ssl_io_input_read->SSL_read->...
* ...->bio_filter_in_read->ap_get_brigade/next-httpd-filter
modules/ssl/ssl_engine_io.c lines 55–58
Solid arrows are calls, dotted arrows are data coming back. OpenSSL (grey) is sandwiched between two mod_ssl functions (blue).
- When the filter above (the one reading the HTTP request) asks for plaintext with
ap_get_brigade(), the SSL input filterssl_io_filter_input()is called (line 1559). - This filter calls OpenSSL's
SSL_read()(insidessl_io_input_read(), line 683). - OpenSSL needs ciphertext to decrypt, so it reads from its BIO. That BIO's read function is mod_ssl's
bio_filter_in_read(). bio_filter_in_read()asks the next filter in the chain for ciphertext.
static int bio_filter_in_read(BIO *bio, char *in, int inlen)
{
apr_size_t inl = inlen;
bio_filter_in_ctx_t *inctx = (bio_filter_in_ctx_t *)BIO_get_data(bio);
apr_read_type_e block = inctx->block;
/* ... */
if (APR_BRIGADE_EMPTY(inctx->bb)) {
inctx->rc = ap_get_brigade(inctx->f->next, inctx->bb,
AP_MODE_READBYTES, block,
inl);
/* ... */
}
inctx->rc = brigade_consume(inctx->bb, block, in, &inl);
if (inctx->rc == APR_SUCCESS) {
return (int)inl;
}
/* ... */
}
modules/ssl/ssl_engine_io.c lines 473–556 (middle shortened)
BIO_get_data(bio) retrieves the context hung on the BIO in section 3, and the function calls ap_get_brigade() on inctx->f (the SSL input filter itself)'s next, i.e. towards CORE_IN. brigade_consume() then flattens the buckets of the returned brigade into the char array in that OpenSSL handed over, and the function returns the number of bytes copied. This function is where the translation happens between the bucket-brigade world (part 11) and the OpenSSL world of "a buffer and a length".
- OpenSSL decrypts the ciphertext it received, and
ssl_io_filter_input()wraps the plaintext in a bucket and sends it upward.
Writing: the same shape, in reverse
Writing goes the other way. The SSL output filter ssl_io_filter_output() (line 1923) reads the plaintext buckets it receives one by one and passes them through ssl_filter_write() → SSL_write() (line 2015, line 872). OpenSSL writes the encrypted result to its write BIO, and that BIO's write function, bio_filter_out_write(), wraps the bytes in a bucket and passes them to the filter below.
e = apr_bucket_transient_create(in, inl, outctx->bb->bucket_alloc);
APR_BRIGADE_INSERT_TAIL(outctx->bb, e);
modules/ssl/ssl_engine_io.c lines 215–216
A transient bucket "just points at someone else's memory for a moment". It points at OpenSSL's buffer without copying it, and if a filter further down needs to keep the data for later, it copies it then (the comment right above, lines 212–214). Next, bio_filter_out_pass() hands this brigade to the CORE filter with ap_pass_brigade() (line 238, lines 134–146).
The Coalescing filter (54), which exists only on the output side, sits right above the SSL filter and gathers small buckets. According to the source comment, the SSL output filter produces one TLS record (the unit of encryption) per bucket, so many small buckets mean needlessly many records and packets, and sending headers as a separate record reveals their size to an observer (lines 1687–1700).
Example: does OpenSSL really call httpd's filters?
To see this sandwich with our own eyes, I set a breakpoint on bio_filter_in_read() and sent one HTTPS request. I started httpd under gdb with -X (a debug option that runs it as a single process with no children), connected with curl, and printed the call stack where it stopped. The example was run with httpd 2.5.1-dev built unchanged from the reference commit (e4af30fe6d), in a Docker container (Ubuntu 24.04, OpenSSL 3.0.13). The whole example is the single file ex/u15-mod-ssl/run.sh (the config is in section 5).
#0 0x00007efe2609a81f in bio_filter_in_read () from /opt/httpd/modules/mod_ssl.so
#1 0x00007efe25ba4273 in ?? () from /lib/x86_64-linux-gnu/libcrypto.so.3
#2 0x00007efe25ba2727 in ?? () from /lib/x86_64-linux-gnu/libcrypto.so.3
#3 0x00007efe25ba2ebb in BIO_read () from /lib/x86_64-linux-gnu/libcrypto.so.3
#4 0x00007efe2601da27 in ?? () from /lib/x86_64-linux-gnu/libssl.so.3
#5 0x00007efe260212d9 in ?? () from /lib/x86_64-linux-gnu/libssl.so.3
#6 0x00007efe2601fe88 in ?? () from /lib/x86_64-linux-gnu/libssl.so.3
#7 0x00007efe2603e8e8 in ?? () from /lib/x86_64-linux-gnu/libssl.so.3
#8 0x00007efe260323a5 in ?? () from /lib/x86_64-linux-gnu/libssl.so.3
#9 0x00007efe2609e861 in ssl_io_filter_handshake () from /opt/httpd/modules/mod_ssl.so
#10 0x00007efe2609f9da in ssl_io_filter_input () from /opt/httpd/modules/mod_ssl.so
#11 0x0000555684490d8c in ap_get_brigade ()
#12 0x00007efe26088461 in ssl_hook_process_connection () from /opt/httpd/modules/mod_ssl.so
#13 0x0000555684489c11 in ap_run_process_connection ()
#14 0x00007efe261028b2 in process_socket () from /opt/httpd/modules/mod_mpm_event.so
#15 0x00007efe26106bc7 in worker_thread () from /opt/httpd/modules/mod_mpm_event.so
#16 0x0000555684486ff9 in thread_start ()
#17 0x00007efe26260b84 in ?? () from /lib/x86_64-linux-gnu/libc.so.6
#18 0x00007efe262edcc4 in clone () from /lib/x86_64-linux-gnu/libc.so.6
(Addresses differ on every run. ?? marks internal OpenSSL functions with no debug info.) Read it from the bottom up. An event MPM worker thread (#15) runs the process_connection hook (#13); mod_ssl's hook (#12) calls the input filters with ap_get_brigade() (#11); the SSL input filter (#10) goes through the handshake function (#9) into OpenSSL (#8–#4, libssl); and OpenSSL, via BIO_read() (#3), calls back into mod_ssl's bio_filter_in_read() (#0). The httpd → OpenSSL → httpd round trip shows up exactly as described.
There's one more thing to notice. The first read didn't happen in the code that reads HTTP requests but in ssl_hook_process_connection(), and the function inside it is not SSL_read() but the handshake (#9). That's the subject of the next section.
5. When does the handshake happen? — separately, just before the request is read
With the filters in place, the handshake would happen by itself on the first read. Every time the SSL input filter reads, it first calls ssl_io_filter_handshake() (ssl_engine_io.c lines 1617–1619), which returns immediately if the handshake is already done (lines 1197–1199) and otherwise calls SSL_accept() (line 1424). The output filter checks the handshake up front in the same way (lines 1956–1958).
mod_ssl doesn't wait for that, though. In the process_connection hook it triggers just the handshake first.
static int ssl_hook_process_connection(conn_rec* c)
{
SSLConnRec *sslconn = myConnConfig(c);
if (sslconn && !sslconn->disabled) {
/* On an active SSL connection, let the input filters initialize
* themselves which triggers the handshake, which again triggers
* all kinds of useful things such as SNI and ALPN.
*/
apr_bucket_brigade* temp;
apr_status_t rv;
temp = apr_brigade_create(c->pool, c->bucket_alloc);
rv = ap_get_brigade(c->input_filters, temp,
AP_MODE_INIT, APR_BLOCK_READ, 0);
apr_brigade_destroy(temp);
/* ... on failure, get ready to close the connection ... */
}
return DECLINED;
}
modules/ssl/mod_ssl.c lines 717–755
AP_MODE_INITis a read mode meaning "I don't want data, just initialise". In this mode the SSL input filter does the handshake and returns (lines 1621–1626).- The comment gives the reason: the handshake triggers useful things like SNI and ALPN (the TLS extension that decides whether this connection will speak HTTP/1.1 or HTTP/2). Which protocol a connection will use has to be known before the module that reads requests is chosen.
- At the end it returns
DECLINED.process_connectionis a RUN_FIRST hook that stops at the first function returning something other thanDECLINED(server/connection.c line 43). mod_ssl only does the handshake and lets go of the connection; the http module'sap_process_http_connection(), registered further back (APR_HOOK_REALLY_LAST, http_core.c lines 358–359), then goes on to read the request. From then on the http module receives only plaintext, unaware that TLS is there at all.
The path of one connection. The three blue boxes in the middle (from the ClientHello callback on) are the SNI story of section 6.
Example: curl -vk with self-signed certificates
Inside the container I made self-signed certificates for a.example and b.example with openssl req -x509 ... (practice certificates signed by themselves, which browsers don't trust) and started two TLS virtual hosts on port 8443. Here is the relevant part of the config.
Listen 8443
LoadModule headers_module modules/mod_headers.so
LoadModule socache_shmcb_module modules/mod_socache_shmcb.so
LoadModule ssl_module modules/mod_ssl.so
LogLevel warn ssl:debug
SSLSessionCache shmcb:/tmp/ssl_scache(512000)
# Show values chosen by mod_ssl as response headers
# %{...}s : mod_headers asks ap_ssl_var_lookup() directly
# %{...}e : reads the environment variables (subprocess_env) filled in by the fixups phase
Header always set X-SSL-Protocol "%{SSL_PROTOCOL}s"
Header always set X-SSL-SNI "%{SSL_TLS_SNI}s"
Header always set X-Env-HTTPS "%{HTTPS}e"
Header always set X-Env-Protocol "%{SSL_PROTOCOL}e"
<VirtualHost *:8443>
ServerName a.example
DocumentRoot /tmp/a
SSLEngine on
SSLCertificateFile /tmp/certs/a.example.crt
SSLCertificateKeyFile /tmp/certs/a.example.key
</VirtualHost>
<VirtualHost *:8443>
ServerName b.example
DocumentRoot /tmp/b
SSLEngine on
SSLCertificateFile /tmp/certs/b.example.crt
SSLCertificateKeyFile /tmp/certs/b.example.key
SSLOptions +StdEnvVars
</VirtualHost>
In curl -vk, -v shows the handshake and -k tells curl not to give up on a failed verification, even for a self-signed certificate. --resolve a.example:8443:127.0.0.1 points the name at the container itself. From the output I kept only the handshake lines and response headers.
=== A. curl -vk https://a.example:8443/index.txt (handshake lines) ===
* Connected to a.example (127.0.0.1) port 8443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / RSASSA-PSS
* ALPN: server accepted http/1.1
* Server certificate:
* subject: CN=a.example
* issuer: CN=a.example
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
< HTTP/1.1 200 OK
< X-SSL-Protocol: TLSv1.3
< X-SSL-SNI: a.example
< X-Env-HTTPS: on
< X-Env-Protocol: (null)
site A
Everything from Client hello to Finished happened inside the SSL_accept() we saw in the stack in section 4, and only after that does HTTP/1.1 200 OK arrive. ALPN: server accepted http/1.1 is because this container's config doesn't load the HTTP/2 module (mod_http2). We look at the four response headers in section 7.
Easy to get wrong: plain HTTP on the TLS port
If you connect to 8443 with http:// instead of https://, you might expect the connection to simply drop, but httpd sends back an HTML error page.
=== E. plain HTTP to the TLS port ===
HTTP/1.1 400 Bad Request
<p>Your browser sent a request that this server could not understand.<br />
Reason: You're speaking plain HTTP to an SSL-enabled server port.<br />
Instead use the HTTPS scheme to access this URL, please.<br />
</p>
When SSL_accept() fails and OpenSSL reports "this looks like an HTTP request" (SSL_R_HTTP_REQUEST), the handshake function returns mod_ssl's own error code MODSSL_ERROR_HTTP_ON_HTTPS (ssl_engine_io.c lines 1449–1458). ssl_io_filter_error() then switches off the SSL filter for this connection so that it passes bytes straight through from now on, and leaves a note saying "a fake request line must be sent" (ssl_engine_io.c lines 981–999). When that note is present, the process_connection hook doesn't close the connection but hands it on to the http module (the shortened part of the code above, mod_ssl.c lines 734–751), and when the http module tries to read a request, the SSL input filter sends up a fake request line "GET / HTTP/1.0" and an empty line instead of the bytes the client sent (lines 945–951, 1582–1599). The http module treats it as an ordinary request and starts processing, and ssl_hook_ReadReq() on the post_read_request hook sees the note, attaches an error message and returns 400 (ssl_engine_kernel.c lines 166–177). Whatever the original request was, the response is a 400 for /. It's a workaround that invents a request just to show an error page, something only the filter design makes possible.
6. SNI — switching virtual host and certificate mid-handshake
With several virtual hosts on one port there's a problem. The server must send a certificate during the handshake, but the Host header that says which virtual host is meant can only be read after the handshake. That's why SSL_new() in section 3 starts with the default virtual host's (a.example's) SSL_CTX. The only clue to correct it is the SNI inside the ClientHello.
Where the callbacks are registered
While building the SSL_CTX at startup, mod_ssl tells OpenSSL "when you receive a ClientHello, call this function".
if (!SSL_CTX_set_tlsext_servername_callback(mctx->ssl_ctx,
ssl_callback_ServerNameIndication) ||
!SSL_CTX_set_tlsext_servername_arg(mctx->ssl_ctx, mctx)) {
/* ... */
}
#if OPENSSL_VERSION_NUMBER >= 0x10101000L && !defined(LIBRESSL_VERSION_NUMBER)
/*
* The ClientHello callback also allows to retrieve the SNI, but since it
* runs at the earliest possible connection stage we can even set the TLS
* protocol version(s) according to the selected (name-based-)vhost, which
* is not possible at the SNI callback stage (due to OpenSSL internals).
*/
SSL_CTX_set_client_hello_cb(mctx->ssl_ctx, ssl_callback_ClientHello, NULL);
#endif
modules/ssl/ssl_engine_init.c lines 780–798 (error handling shortened)
There are two callbacks: the long-standing SNI callback, and the ClientHello callback available since OpenSSL 1.1.1. As the comment says, the ClientHello callback is called earlier, early enough to change even the allowed TLS versions depending on the virtual host. Both callbacks end up in the same function, init_vhost(), which only does its work once per connection (vhost_found, ssl_engine_kernel.c lines 2256–2260). The example container's OpenSSL is 3.0.13, which satisfies the 1.1.1-or-later condition (#if), so both callbacks are registered, and as the example log below shows, the virtual host lookup happens only once per connection.
Finding the virtual host from SNI
The ClientHello callback pulls the host name out of the raw bytes of the SNI extension itself and passes it to init_vhost() (ssl_engine_kernel.c lines 2410–2467). init_vhost() uses the core function ap_vhost_iterate_given_conn() to hand the name-based virtual hosts on this connection's IP and port to ssl_find_vhost() one at a time (lines 2266–2267). The code that manages the virtual host list lives in the core (server/vhost.c, part 9); mod_ssl only judges "does this name match?".
static int ssl_find_vhost(void *servername, conn_rec *c, server_rec *s)
{
SSLSrvConfigRec *sc;
SSL *ssl;
BOOL found;
SSLConnRec *sslcon;
found = ssl_util_vhost_matches(servername, s);
/* set SSL_CTX (if matched) */
sslcon = myConnConfig(c);
if (found && (ssl = sslcon->ssl) &&
(sc = mySrvConfig(s))) {
SSL_CTX *ctx = SSL_set_SSL_CTX(ssl, sc->server->ssl_ctx);
/* ... bring options, protocol versions, verification and session id context in line with the new ctx ... */
sslcon->server = s;
/* ... */
return 1;
}
return 0;
}
modules/ssl/ssl_engine_kernel.c lines 2475–2559 (middle shortened)
ssl_util_vhost_matches()compares the SNI name withServerName, then withServerAlias(from ssl_util.c line 61).SSL_set_SSL_CTX()is the key line. It swaps the settings bundle of the mid-handshakeSSLobject for b.example's, so the certificate the server sends becomes b.example's. Because this function only swaps the certificate, the shortened part contains code that copies the remaining settings over by hand (source comment, lines 2490–2494).sslcon->server = s— changes the virtual host mod_ssl remembers for this connection. The core'sc->base_serveris left untouched.
If no name matches, it carries on with the handshake on the default (first) virtual host without even sending a warning (the comment at lines 2275–2293 explains why, citing RFC 6066).
Example: same port, different certificate
Same config as before, only the SNI changes.
=== B. same port, SNI = b.example ===
* subject: CN=b.example
< HTTP/1.1 200 OK
< X-SSL-Protocol: TLSv1.3
< X-SSL-SNI: b.example
< X-Env-HTTPS: on
< X-Env-Protocol: TLSv1.3
site B
=== C. no SNI (connect by IP) ===
* subject: CN=a.example
< HTTP/1.1 200 OK
< X-SSL-SNI: (null)
site A
B is on the same port 8443, but the certificate is CN=b.example. C connects by IP address, so curl sends no SNI (an IP address doesn't go in SNI). The default virtual host a.example's certificate goes out. I added Host: a.example, so the content is site A as well. mod_ssl's debug log records this (LogLevel ssl:debug, identical messages counted and grouped):
1 AH01964: Connection to child 128 established (server a.example:443)
1 AH01964: Connection to child 130 established (server a.example:443)
1 AH01964: Connection to child 132 established (server a.example:443)
1 AH01964: Connection to child 134 established (server a.example:443)
1 AH01964: Connection to child 136 established (server a.example:443)
2 AH02043: SSL virtual host for servername a.example found
1 AH02043: SSL virtual host for servername b.example found
1 AH02645: Server name not provided via TLS extension (using default/first virtual host)
(Connection numbers differ on every run.) The AH01964 from pre_connection says a.example for all five connections; the switch to b.example is the later AH02043, during the handshake. That's the "easy to get wrong" note from section 3, confirmed in the log. The fifth connection (plain HTTP) failed its handshake and so has no SNI line.
Easy to get wrong: SNI and Host differ → 421
SNI comes from the handshake, Host from the request. What if a client sends different values? I sent SNI a.example with Host: b.example.
=== D. SNI = a.example but Host: b.example ===
421
The r->server chosen from Host is b.example, but the TLS connection was made with a.example's certificate. ssl_hook_ReadReq() on the post_read_request hook compares the TLS setups of the two virtual hosts, and if they don't match it returns 421 Misdirected Request ("I can't answer for that host on this connection").
/* Enforce SSL SNI vhost compatibility policy: the virtual
* host selected for the connection (based on the SNI
* extension) must have a "compatible" SSL configuration with
* the one selected based on the Host: header. */
if (!ssl_check_vhost_sni_policy(sc, hssc)) {
ap_log_rerror(APLOG_MARK, APLOG_ERR, 0, r, APLOGNO(02032)
"Hostname %s %s and hostname %s provided"
" via HTTP have no compatible SSL setup for policy '%s'",
servername ? servername : handshakeserver->server_hostname,
servername ? "provided via SNI" : "(default host as no SNI was provided)",
r->hostname, MODSSL_SNIVH_NAME(sc->mc->snivh_policy));
return HTTP_MISDIRECTED_REQUEST;
}
modules/ssl/ssl_engine_kernel.c lines 231–243
The log recorded "Hostname a.example provided via SNI and hostname b.example provided via HTTP have no compatible SSL setup for policy 'secure'" (AH02032). What counts as "matching" is set by the SSLVHostSNIPolicy directive, and the default is secure (ssl_engine_config.c line 64). Under secure, at startup each virtual host gets an MD5 digest over its certificate files, key files, protocols, ciphers, client verification settings and so on, and the check compares those digests (ssl_engine_init.c lines 330–405). The example's two virtual hosts have different certificate files, so they don't match. Conversely, as in C, no SNI plus a Host naming the default virtual host is fine.
The example outputs and debug-log message numbers above, gathered per case. The virtual host changes during the handshake (AH02043); whether it agrees with Host is checked after the request is read.
7. SSL_* variables — fetched through the core's ap_ssl_* functions
Other modules need TLS information too. mod_rewrite decides redirects with %{HTTPS}, mod_log_config writes the protocol and cipher to the log, and mod_http2 behaves differently depending on whether the connection is TLS. But if these modules called mod_ssl's functions directly, they couldn't be built without mod_ssl.
Trunk's answer is a TLS question desk in the core. include/http_ssl.h declares it and server/ssl.c implements it. Here are a couple of the functions.
AP_DECLARE(int) ap_ssl_conn_is_ssl(conn_rec *c)
{
int r = (ap_run_ssl_conn_is_ssl(c) == OK);
if (r == 0 && module_ssl_is_https) {
r = module_ssl_is_https(c);
}
return r;
}
AP_DECLARE(const char *) ap_ssl_var_lookup(apr_pool_t *p, server_rec *s,
conn_rec *c, request_rec *r,
const char *name)
{
const char *val = ap_run_ssl_var_lookup(p, s, c, r, name);
if (val == NULL && module_ssl_var_lookup) {
val = module_ssl_var_lookup(p, s, c, r, name);
}
return val;
}
Both have the same shape. First run the hook (ap_run_ssl_*, both RUN_FIRST, lines 256–260), and if nobody answers, fall back to the old-style optional function. An optional function is an APR mechanism where "a module registers a function by name and other modules look it up by name"; if no module has registered it, it's NULL (we had a taste of it in part 6).
Other modules know only the core, as in the grey box at the top left. They compile without mod_ssl, and if no TLS module is present they get the answer "not TLS".
mod_ssl at this commit uses a mix of both paths.
void ssl_var_register(apr_pool_t *p)
{
char *cp, *cp2;
ap_hook_ssl_conn_is_ssl(ssl_conn_is_ssl, NULL, NULL, APR_HOOK_MIDDLE);
APR_REGISTER_OPTIONAL_FN(ssl_get_tls_cb);
APR_REGISTER_OPTIONAL_FN(ssl_var_lookup);
APR_REGISTER_OPTIONAL_FN(ssl_ext_list);
modules/ssl/ssl_engine_vars.c lines 245–252
It answers "is this connection TLS?" on the new ssl_conn_is_ssl hook (OK if the connection has an SSL object, lines 69–73), but still offers variable values the old way, as the optional function ssl_var_lookup. The core, however, registers an optional function under the same name ssl_var_lookup. ap_setup_ssl_optional_fns(), called from the core's post_config hook (APR_HOOK_REALLY_FIRST), saves mod_ssl's previously registered function in module_ssl_var_lookup and registers its own function in its place (server/ssl.c lines 192–216, server/core.c line 5404, line 6161). So whether a module uses the new API or still looks up the optional function the old way, it ends up reaching mod_ssl by the same road.
The remaining hooks (ssl_add_cert_files, ssl_add_fallback_cert_files, ssl_answer_challenge, ssl_ocsp_*) point the other way: they are desks where mod_ssl asks and another module (mod_md) answers. At this commit, mod_md is the only module that hangs functions on them (mod_md.c lines 1591–1597).
2.4.x has it too. server/ssl.c and include/http_ssl.h aren't trunk-only; the 2.4.x branch has them, with the same list of functions (ap_ssl_conn_is_ssl, ap_ssl_var_lookup, ap_ssl_add_cert_files, ap_ssl_bind_outgoing and so on). According to the 2.4.x branch history, these files arrived in the 2.4.48 release (the commit message gives the reason as letting other TLS modules work "without impersonating mod_ssl"). So in module sources written for httpd older than 2.4.48, you'll only see the old style, such as APR_RETRIEVE_OPTIONAL_FN(ssl_var_lookup).
mod_headers' %{…}s uses this desk
In the example config's %{SSL_PROTOCOL}s, s is a format letter registered by mod_headers (mod_headers.c line 1068), and its function simply calls the core's ap_ssl_var_lookup().
static const char *header_request_ssl_var(request_rec *r, char *name)
{
const char *val = ap_ssl_var_lookup(r->pool, r->server,
r->connection, r, name);
if (val && val[0])
return unwrap_header(r->pool, val);
else
return "(null)";
}
modules/metadata/mod_headers.c lines 207–215
On mod_ssl's side, ssl_var_lookup() branches on the start of the name, and for SSL_PROTOCOL it calls OpenSSL's SSL_get_version() (ssl_engine_vars.c lines 491–493). The example's X-SSL-Protocol: TLSv1.3 is that value. The value is computed on the spot when asked.
Easy to get wrong: why %{SSL_PROTOCOL}e is (null)
In example A (a.example), X-Env-HTTPS was on but X-Env-Protocol was (null); in B (b.example) both had values. %{…}e doesn't ask mod_ssl; it reads the request's environment variable table (r->subprocess_env, the variables handed to things like CGI). That table is filled in by ssl_hook_Fixup() on the fixups hook.
/* the always present HTTPS (=HTTP over SSL) flag! */
apr_table_setn(env, "HTTPS", "on");
#ifdef HAVE_TLSEXT
/* add content of SNI TLS extension (if supplied with ClientHello) */
if ((servername = SSL_get_servername(ssl, TLSEXT_NAMETYPE_host_name))) {
apr_table_set(env, "SSL_TLS_SNI", servername);
}
#endif
/* ... */
/* standard SSL environment variables */
if (dc->nOptions & SSL_OPT_STDENVVARS) {
modssl_var_extract_dns(env, ssl, r->pool);
modssl_var_extract_san_entries(env, ssl, r->pool);
for (i = 0; ssl_hook_Fixup_vars[i]; i++) {
var = ssl_hook_Fixup_vars[i];
val = ssl_var_lookup(r->pool, r->server, r->connection, r, var);
if (!strIsEmpty(val)) {
apr_table_setn(env, var, val);
}
}
}
modules/ssl/ssl_engine_kernel.c lines 1425–1452
HTTPS=on always goes in, and SSL_TLS_SNI goes in whenever the client sent SNI, regardless of SSLOptions; but the dozens of standard variables including SSL_PROTOCOL (the list starting at line 1345) go in only when SSLOptions +StdEnvVars is on. Computing dozens of them on every request is expensive, so it's off by default. In the example, only b.example got +StdEnvVars. In summary:
| Format | Where it reads from | Without StdEnvVars |
|---|---|---|
%{SSL_PROTOCOL}s (mod_headers) |
ap_ssl_var_lookup() → mod_ssl computes it on the spot |
has a value |
%{SSL_PROTOCOL}e (mod_headers) |
r->subprocess_env — the table filled by fixups |
(null) |
%{HTTPS}e |
same table, always-set value | on |
This table is what gets passed to CGI scripts, so if SSL_PROTOCOL is empty in a CGI, +StdEnvVars is the place to check. On the other hand, if you only need the value in a log or a header, use a format that asks directly without +StdEnvVars (mod_headers' s, mod_log_config's %{…}x — mod_log_config.c lines 911–918, line 1902).
What this post leaves out
Here are just the names and locations of the big parts of mod_ssl this post didn't cover.
- Client certificate verification —
SSLVerifyClient. The verify callbackssl_callback_SSLVerify()(registered at ssl_engine_init.c line 1376),ssl_hook_Access()which renegotiates for per-directory verification (ssl_engine_kernel.c), and thessl_io_bufferfilter that holds the request body while that happens. - Session cache and session tickets — the
SSLSessionCachein the example.ssl_scache.cand the socache providers (mod_socache_shmcb and others); the callbacks are registered at ssl_engine_init.c lines 1171–1173. - OCSP stapling —
ssl_util_stapling.c, and the core hooksap_ssl_ocsp_prime()andap_ssl_ocsp_get_resp(). - ALPN — the callback that picks HTTP/2,
ssl_callback_alpn_select()(ssl_engine_kernel.c line 2663). - TLS towards backends — the path where mod_ssl becomes a client when mod_proxy connects to an
https://backend (SSLProxyEngine,ap_ssl_bind_outgoing()→ssl_hook_ssl_bind_outgoing(), mod_ssl.c lines 573–606). mod_proxy itself is in part 14. - Asking for private key passphrases —
SSLPassPhraseDialog,ssl_engine_pphrase.c. - Trunk-only features — the policy bundles of
SSLPolicy, Encrypted Client Hello (SSLECHKeyDir, only when built withHAVE_OPENSSL_ECH), and the Certificate Transparency modulemod_ssl_ct.c. I didn't compare these against 2.4.x for this post.
Summary
| Concept | One-line summary |
|---|---|
| The core and TLS | The core has no TLS code. mod_ssl steps in through hooks and connection filters |
ssl_init_Module() |
In post_config, builds an SSL_CTX (certificate, key, settings bundle) for each virtual host with SSLEngine on |
ssl_hook_pre_connection() |
Per connection, SSL_new() from the default virtual host's SSL_CTX, then inserts the SSL filters |
| SSL filters | CONNECTION+5 (55), so right above the core filters (60). Everything above sees only plaintext |
| Home-made BIOs | OpenSSL's reads and writes come in through bio_filter_in_read() and bio_filter_out_write() and continue down the filter chain |
| Handshake timing | Triggered first in process_connection with AP_MODE_INIT, then DECLINED hands over to the http module |
| SNI | ClientHello callback → init_vhost() → SSL_set_SSL_CTX() swaps the certificate. c->base_server stays as it was |
| 421 | If the virtual host chosen by SNI and the one chosen by Host have different TLS setups, ssl_hook_ReadReq() refuses |
ap_ssl_* |
The core's (server/ssl.c) TLS question desk. Hooks first, then the old optional functions. In 2.4.x since 2.4.48 |
SSL_* environment variables |
fixups always sets HTTPS, sets SSL_TLS_SNI whenever SNI was sent, and sets the rest only with SSLOptions +StdEnvVars |
What's next
We've seen that TLS gets in as one layer of connection filter wrapped around the byte stream, plus a handful of hooks at startup, per connection and per request. The HTTP code above doesn't know it's there, and modules that want to know ask through the core's ap_ssl_* desk.
The last post in the series is about the place where all of this gets recorded: logs. We'll look at ap_log_error() for the error log, per-module log levels like the LogLevel ssl:debug used in this post's example, and how one LogFormat line in the access log turns into a table of functions. The %{…}x we met at the end of section 7 shows up there again. It continues in part 16: Logging — Access Logs, Error Logs, and Wrapping Up the Series.
The Spring series has no post on TLS. Still, the shape of "wrap the request and response from both sides, while the code inside doesn't know it's there" resembles the servlet filters in Spring part 20. Where a servlet filter wraps request objects, mod_ssl's filter works further down, wrapping the connection's bytes.