이 글에서 다루는 것
"Apache httpd 소스 파헤치기" 연재의 열다섯 번째 글입니다. 14편에서는 httpd가 다른 서버에 요청을 넘기는 mod_proxy를 봤습니다. 이번에는 httpd.conf에 SSLEngine on 한 줄을 넣으면 HTTPS가 되는 뒤편, mod_ssl을 봅니다.
답하려는 질문은 네 가지입니다.
- httpd의 코어(
server/폴더)에는 TLS 코드가 없습니다. 그러면 암호화는 어디에서, 어떤 순서로 끼어드는가 - OpenSSL은 "소켓에서 읽고 쓰는" 라이브러리인데, httpd는 소켓을 필터 사슬 뒤에 숨겨 둡니다. 둘은 어떻게 이어지는가
- 포트 하나(443)에 가상 호스트가 여럿이면, 요청을 읽기도 전에 어느 인증서를 보낼지는 어떻게 정하는가
SSL_PROTOCOL같은 값은 누가 만들고, 다른 모듈은 mod_ssl을 모르면서 어떻게 그 값을 읽는가
| 항목 | 내용 |
|---|---|
| 대상 | Apache httpd |
| 버전 | 2.5.1-dev (trunk) |
| 커밋 | e4af30fe6d (2026-10-24) |
| 주로 읽는 파일 | 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 |
글에 나오는 줄 번호와 세부 동작은 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.
mod_ssl은 modules/ssl/ 아래 C 파일만 2만 3천 줄이 넘는 큰 모듈입니다(mod_ssl_ct.c 같은 부속까지 센 값입니다). 이 글은 httpd 소스를 처음 보는 분이 따라올 수 있게 TLS가 서버에 꽂히는 자리만 큰 줄기로 따라갑니다. 클라이언트 인증서, 세션 캐시, OCSP 같은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적었습니다.
먼저 알아 둘 말
TLS(Transport Layer Security)는 TCP 연결 위에 암호화를 얹는 규약입니다. 예전 이름이 SSL이라서 httpd의 디렉티브와 소스에는 아직 SSL이 붙어 있습니다(SSLEngine, mod_ssl). 이 글에서 두 말은 같은 것을 가리킵니다. HTTPS는 "TLS로 감싼 연결 위에서 주고받는 HTTP"입니다.
핸드셰이크(handshake)는 TLS 연결의 첫머리에서 클라이언트와 서버가 주고받는 인사입니다. 클라이언트가 먼저 ClientHello를 보내고(지원하는 TLS 버전, 암호 방식 목록 등), 서버가 답하면서 인증서를 보냅니다. 인증서는 "이 서버가 정말 a.example이다"를 증명하는 문서로, 서버는 짝이 되는 개인 키를 가지고 있어야 합니다. 핸드셰이크가 끝나야 HTTP 요청이 오갑니다.
SNI(Server Name Indication)는 ClientHello 안에 "나는 b.example에 접속하려 한다"고 적어 보내는 칸입니다. HTTP의 Host 헤더는 암호화된 다음에야 읽을 수 있으므로, 인증서를 고르는 시점에는 SNI가 유일한 단서입니다.
OpenSSL은 TLS를 실제로 계산하는 C 라이브러리입니다. mod_ssl은 암호를 직접 구현하지 않고 OpenSSL을 부릅니다. 이 글에 나오는 OpenSSL 쪽 이름은 셋입니다.
SSL_CTX— 인증서·키·허용할 프로토콜 같은 설정 묶음. 가상 호스트마다 하나씩, 서버가 시작할 때 만듭니다.SSL— 연결 하나의 TLS 상태. 연결마다SSL_CTX를 본으로 하나씩 만듭니다.BIO— OpenSSL이 바이트를 읽고 쓰는 입출력 창구. 기본은 소켓이나 메모리지만, 읽기·쓰기 함수를 직접 채운 BIO를 만들 수도 있습니다. 이 글의 핵심이 이것입니다.
연결 필터는 11편에서 본 필터 가운데 연결 하나에 붙어 바이트를 다루는 층입니다. 필터는 종류(AP_FTYPE_*)의 숫자 순서로 사슬에 서고, 맨 아래에는 소켓에 실제로 읽고 쓰는 코어 필터 CORE_IN·CORE가 있습니다. 데이터는 버킷에 담겨 브리게이드(버킷의 목록)로 넘겨집니다.
가상 호스트는 9편에서 본 대로 두 번 골라집니다. 연결이 들어올 때 IP와 포트로 한 번(c->base_server), 요청을 읽은 뒤 Host 헤더로 한 번(r->server)입니다. TLS는 그 사이에 한 번이 더 필요합니다.
1. 코어는 TLS를 모릅니다 — mod_ssl이 거는 훅
server/ 폴더의 코어 코드를 읽어 보면 SSL_read()나 SSL_accept() 같은 OpenSSL 호출이 없습니다. 코어는 TLS를 모르고, 모듈이 끼어들 자리(훅과 필터)만 열어 둡니다. mod_ssl이 그 자리에 무엇을 거는지는 모듈의 등록 함수 하나에 모여 있습니다.
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 761–809행
ap_hook_xxx(함수, 앞에 와야 할 모듈, 뒤에 와야 할 모듈, 순서)는 6편에서 본 훅 등록 방식 그대로입니다. 함수 이름을 인자로 넘기는 것은 C의 함수 포인터로, "나중에 이 함수를 불러 달라"는 뜻입니다(2편). 이 목록을 시간 순서로 다시 놓으면 이 글의 지도가 됩니다.
| 언제 | 훅 / 등록 | mod_ssl이 하는 일 | 이 글 |
|---|---|---|---|
| 기동, 설정 읽기 전 | pre_config → ssl_hook_pre_config() |
OpenSSL 초기화 | 2절 |
| 기동, 설정 읽은 뒤 | post_config → ssl_init_Module() |
가상 호스트마다 SSL_CTX 만들기 |
2절 |
| 등록할 때 | ssl_io_filter_register() |
연결 필터 세 개 등록 | 3절 |
| 연결마다, 맨 처음 | pre_connection → ssl_hook_pre_connection() |
SSL 객체 만들고 필터 끼우기 |
3·4절 |
| 연결마다, 요청 읽기 전 | process_connection → ssl_hook_process_connection() |
핸드셰이크 일으키기 | 5절 |
| 요청 읽은 직후 | post_read_request → ssl_hook_ReadReq() |
SNI와 Host가 맞는지 보기 |
6절 |
| 핸들러 직전 | fixups → ssl_hook_Fixup() |
HTTPS, SSL_* 환경 변수 |
7절 |
| 언제든 | ssl_conn_is_ssl 훅, ssl_var_lookup 선택 함수 |
"이 연결은 TLS인가", "SSL_PROTOCOL은?"에 답하기 | 7절 |
http_scheme·default_port 훅도 눈여겨볼 만합니다. 코어가 리다이렉트 주소 같은 것을 만들 때 "이 요청의 스킴은 http인가 https인가"를 스스로 판단하지 않고 훅에 묻습니다(include/httpd.h의 #define ap_http_scheme(r) ap_run_http_scheme(r), 302행). mod_ssl은 TLS 요청이면 "https"를, 아니면 NULL을 돌려줍니다(mod_ssl.c 676–679행). 이 훅은 첫 번째로 NULL이 아닌 답을 낸 함수에서 멈추는 RUN_FIRST 방식이라(server/protocol.c 2558행), mod_ssl이 답하지 않으면 맨 뒤(APR_HOOK_REALLY_LAST)에 걸린 http 모듈의 http_scheme()가 "http"라고 답합니다(modules/http/http_core.c 117–128행, 362행).
코어는 https 라는 글자를 직접 쓰지 않고 훅에 묻기만 합니다. mod_ssl 을 싣지 않으면 늘 맨 뒤의 "http" 가 답이 됩니다.
설정 파일에서 보던 SSLEngine, SSLCertificateFile 같은 디렉티브는 모듈 구조체에서 STANDARD20_MODULE_STUFF 다음 다섯 번째 칸인 ssl_config_cmds 표에 들어 있습니다(mod_ssl.c 811–822행). 디렉티브가 구조체 필드가 되는 과정은 7편과 같습니다.
2. 기동 — post_config에서 가상 호스트마다 SSL_CTX를 만듭니다
TLS 설정은 비쌉니다. 인증서와 개인 키 파일을 읽어 검사하고, 허용할 프로토콜과 암호 방식을 정해야 합니다. 이것을 연결마다 하면 느리므로 mod_ssl은 기동할 때 가상 호스트마다 한 번 SSL_CTX를 만들어 둡니다. 그 일을 하는 곳이 post_config 훅에 건 ssl_init_Module()입니다(ssl_engine_init.c 451–714행).
파란색은 훅, 노란색은 만들어 두는 것입니다. 연결이 오기 전에 여기까지 끝납니다.
pre_config— OpenSSL 깨우기. OpenSSL 1.1 이상에서는OPENSSL_init_ssl()한 줄입니다(mod_ssl.c 428–434행). 그 아래#else쪽은 옛 OpenSSL용 코드입니다.#ifndef/#else는 컴파일할 때 둘 중 하나만 남기는 전처리기 분기입니다.- 서버마다 기본값 정리.
server_rec목록을 돌며SSLEngine이 정해지지 않은 서버는 꺼진 것으로 확정하고(Listen 443 https처럼 프로토콜이 https이고 인증서가 지정된 서버만 예외로 켭니다), 로그에 쓸 이름vhost_id를 만듭니다(ssl_engine_init.c 497–543행). - 켜진 서버마다
SSL_CTX. 두 번째 루프가ssl_init_ConfigureServer()→ssl_init_server_ctx()를 부릅니다(625–640행, 3268–3308행). 여기서SSL_CTX_new()로 설정 묶음을 만들고(972행) 6절에서 볼 SNI 콜백을 등록한 뒤, 인증서와 키를 싣습니다(ssl_init_server_certs(), 2200행부터). - BIO 함수 표. 끝 무렵에
init_bio_methods()가 4절의 BIO 함수 표를 만듭니다(686–689행).
인증서를 싣기 직전에는 다른 모듈에게도 기회를 줍니다.
/* 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 3145–3149행
ap_ssl_add_cert_files()는 코어(server/ssl.c)에 있는 훅입니다. Let's Encrypt 같은 곳에서 인증서를 자동으로 받아 오는 mod_md가 여기에 함수를 걸어 두고(modules/md/mod_md.c 1592행), 자기가 받아 둔 인증서 파일 이름을 목록에 보탭니다. mod_md는 mod_ssl을, mod_ssl은 mod_md를 직접 부르지 않고 둘 다 코어의 훅만 압니다. 이 코어 훅들은 7절에서 다시 봅니다.
헷갈리기 쉬운 곳
SSLEngine on은<VirtualHost>안에 둡니다.SSL_CTX는 서버 설정마다 하나입니다. 전역에 두면 가상 호스트들이 같은 설정 구조체를 물려받아 같은SSL_CTX를 두 번 만들려 하게 되고, mod_ssl은 "SSLEngine On should go in the VirtualHost, not in global scope."라는 메시지를 남기고 멈춥니다(ssl_engine_init.c 3138–3143행).- 로그의
:443은 실제 포트가 아닐 수 있습니다. 아래 예제는 8443번 포트에서 돌지만 로그에는 "Configuring server a.example:443"이 찍힙니다.vhost_id를 만드는ssl_util_vhostid()가ServerName에 포트가 없으면(s->port == 0) TLS 서버는 443이라고 적기 때문입니다(ssl_util.c 42–55행). 이름일 뿐 실제로 듣는 포트와는 상관없습니다. - 기동 로그가 두 번씩 나옵니다. 아래 예제의 로그에서 "Init: Initializing (virtual) servers for SSL"(AH01887)은 두 번 찍힙니다. httpd가 기동할 때 설정을 두 번 읽고
post_config도 두 번 부르기 때문입니다. 이유는 5편에 있습니다.
3. 연결마다 — pre_connection에서 SSL 객체를 만들고 필터를 끼웁니다
연결이 들어오면 MPM은 9편에서 본 순서를 밟습니다. IP와 포트로 기본 가상 호스트를 정하고(ap_update_vhost_given_ip()), pre_connection 훅을 돌린 다음, process_connection 훅을 돌립니다(server/connection.c 244–253행; event MPM은 같은 순서를 process_socket() 안에서 직접 밟습니다, server/mpm/event/event.c 1099–1153행).
pre_connection은 아직 한 바이트도 읽지 않은 시점입니다. mod_ssl은 여기서 TLS를 쓸 연결인지 보고, 쓸 연결이면 준비만 해 둡니다.
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 686–715행
ssl_engine_status()는 이 연결의 c->base_server(IP·포트로 고른 가상 호스트)가 SSLEngine on인지 봅니다(mod_ssl.c 542–571행). 아니면 DECLINED를 돌려주고 손을 뗍니다. 같은 서버에서 80번 포트 연결에는 mod_ssl이 아무것도 하지 않는 이유입니다. myConnConfig(c)는 연결의 모듈별 설정 칸에서 mod_ssl 몫을 꺼내는 매크로입니다(ssl_private.h 383–384행). 여기에 연결마다 하나씩 SSLConnRec라는 구조체를 두고 이 연결의 SSL 객체, 지금 고른 가상 호스트(server) 같은 것을 담습니다(ssl_private.h 643–677행).
ssl_init_ssl_connection()의 뼈대는 세 줄입니다.
if (!(sslconn->ssl = ssl = SSL_new(mctx->ssl_ctx))) {
/* ... 오류 기록 ... */
}
/* ... */
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 641–674행 (가운데를 줄였습니다)
SSL_new()— 기동 때 만든SSL_CTX를 본으로 이 연결의SSL을 만듭니다. 이때 쓰는SSL_CTX는c->base_server의 것, 곧 IP와 포트로 고른 기본 가상 호스트의 것입니다. 아직 SNI를 읽지 않았으니 다른 방법이 없습니다. 6절에서 이것을 바꿉니다.SSL_set_app_data(ssl, c)— OpenSSL의SSL객체에 httpd의conn_rec를 매달아 둡니다. 나중에 OpenSSL이 mod_ssl의 콜백을 부를 때 넘겨주는 것은SSL *뿐인데, 콜백은 이것으로conn_rec를 되찾습니다(SSL_get_app_data()).ssl_io_filter_init()— 필터를 끼웁니다.
연결에 붙는 SSL 필터 세 개
mod_ssl이 등록하는 필터는 이렇습니다.
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 2305–2314행
필터 이름 ssl_io_filter는 문자열 "SSL/TLS Filter", ssl_io_coalesce는 "SSL/TLS Coalescing Filter"입니다(1019–1021행). 종류 숫자는 AP_FTYPE_CONNECTION이 50, 코어가 소켓을 맡는 AP_FTYPE_NETWORK가 60입니다(include/util_filter.h 180–183행). 그래서 SSL 필터(55)는 코어 필터(60) 바로 위에 섭니다. 마지막 ssl_io_buffer는 클라이언트 인증서 때문에 요청 본문을 잠시 맡아 두는 특수한 필터라서 이 글에서는 뺍니다.
파란 상자가 mod_ssl의 필터입니다. 위쪽 필터들은 평문만 보고, 암호문은 파란 상자와 회색 상자 사이에만 흐릅니다.
ssl_io_filter_init()이 이 필터들을 연결에 실제로 붙입니다.
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 2265–2303행
필터를 붙이는 것 말고도 눈에 띄는 줄이 셋 있습니다.
BIO_new(...)두 번 — 쓰기용 BIO(pbioWrite)는 여기서, 읽기용 BIO(pbioRead)는ssl_io_input_add_filter()안에서 만듭니다(2240–2247행).BIO_set_data()로 BIO마다 mod_ssl의 문맥 구조체를 매달아 둡니다.SSL_set_bio(ssl, 읽기 BIO, 쓰기 BIO)— 이 연결의SSL에게 "소켓 대신 이 두 BIO로 읽고 써라"라고 알려 줍니다. 다음 절의 주인공입니다.apr_pool_cleanup_register(c->pool, ...)— 연결 풀이 지워질 때 TLS 상태도 정리되게 합니다. 풀에 정리 함수를 거는 방식은 4편에서 봤습니다. mod_ssl은 연결을 닫는 코드를 따로 갖지 않고 연결의 수명에 기댑니다.
헷갈리기 쉬운 곳
pre_connection 시점의 가상 호스트는 SNI와 상관없이 IP·포트로 고른 것입니다. 아래 예제에서 b.example로 접속한 연결도 로그에는 "Connection to child … established (server a.example:443)"로 찍힙니다. 같은 *:8443의 첫 번째 가상 호스트가 a.example이기 때문입니다. b.example로 바뀌는 것은 핸드셰이크 도중입니다(6절).
4. BIO — OpenSSL과 필터 사슬을 잇는 어댑터
OpenSSL은 원래 소켓 번호를 받아 직접 read()·write()를 부르게 만들 수 있습니다. 그런데 httpd에서 소켓은 코어 필터 CORE_IN·CORE의 것입니다. 그 사이에 다른 필터가 끼어 있을 수도 있고(예를 들어 받은 바이트 수를 세는 mod_logio의 입력 필터는 AP_FTYPE_NETWORK - 1, 곧 SSL 필터와 CORE_IN 사이에 섭니다, modules/loggers/mod_logio.c 342–343행), event MPM은 소켓을 넌블로킹으로 다룹니다. OpenSSL이 소켓을 직접 만지면 이 질서가 깨집니다.
그래서 mod_ssl은 OpenSSL에게 소켓 대신 직접 만든 BIO를 줍니다. BIO는 "읽기 함수, 쓰기 함수, 제어 함수"를 담은 함수 표(BIO_METHOD)로 동작을 정합니다. mod_ssl은 이 표의 칸을 자기 함수로 채웁니다.
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 607–620행
2편에서 본 "함수 포인터 표로 동작을 바꿔 끼우기"를 이번에는 httpd가 아니라 OpenSSL이 요구하는 모양입니다. 같은 파일 576–600행에는 옛 OpenSSL용으로 구조체를 직접 채우는 판도 있는데(#if MODSSL_USE_OPENSSL_PRE_1_1_API), 하는 일은 같습니다.
읽기: 필터 → OpenSSL → BIO → 다시 필터
파일 머리의 주석이 흐름을 한 줄로 요약해 둡니다.
* 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 55–58행
실선이 부르는 방향, 점선이 데이터가 돌아오는 방향입니다. OpenSSL(회색)이 mod_ssl의 두 함수(파란색) 사이에 샌드위치처럼 끼어 있습니다.
- 위쪽 필터(HTTP 요청을 읽는 쪽)가
ap_get_brigade()로 "평문을 달라"고 하면 SSL 입력 필터ssl_io_filter_input()이 불립니다(1559행). - 이 필터는 OpenSSL의
SSL_read()를 부릅니다(ssl_io_input_read()안, 683행). - OpenSSL은 복호화할 암호문이 필요하니 자기 BIO에서 읽으려 합니다. 그 BIO의 읽기 함수가 mod_ssl의
bio_filter_in_read()입니다. bio_filter_in_read()는 필터 사슬의 다음 필터에게 암호문을 달라고 합니다.
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 473–556행 (가운데를 줄였습니다)
BIO_get_data(bio)로 3절에서 매달아 둔 문맥을 되찾고, 그 안의 inctx->f(SSL 입력 필터 자신)의 next, 곧 CORE_IN 쪽에 ap_get_brigade()를 부릅니다. 받은 브리게이드의 버킷들을 brigade_consume()이 OpenSSL이 준 char 배열 in에 펴서 복사하고, 복사한 바이트 수를 돌려줍니다. 버킷 브리게이드 세계(11편)와 "버퍼와 길이"를 주고받는 OpenSSL 세계 사이의 번역이 이 함수에서 일어납니다.
- 암호문을 받은 OpenSSL이 복호화하고,
ssl_io_filter_input()은 평문을 버킷에 담아 위로 올려 보냅니다.
쓰기: 거꾸로, 같은 모양
쓰기는 반대 방향입니다. SSL 출력 필터 ssl_io_filter_output()(1923행)이 받은 평문 버킷을 하나씩 읽어 ssl_filter_write() → SSL_write()로 넘기면(2015행, 872행), OpenSSL은 암호화한 결과를 쓰기 BIO에 씁니다. 그 BIO의 쓰기 함수 bio_filter_out_write()는 받은 바이트를 버킷으로 감싸 아래 필터로 넘깁니다.
e = apr_bucket_transient_create(in, inl, outctx->bb->bucket_alloc);
APR_BRIGADE_INSERT_TAIL(outctx->bb, e);
modules/ssl/ssl_engine_io.c 215–216행
transient(일시) 버킷은 "남의 메모리를 잠깐 가리키기만 하는" 버킷입니다. OpenSSL의 버퍼를 복사하지 않고 그대로 가리키고, 아래 필터가 나중을 위해 보관해야 하면 그때 복사합니다(코드 바로 위 주석, 212–214행). 이어 bio_filter_out_pass()가 이 브리게이드를 ap_pass_brigade()로 CORE 필터에 넘깁니다(238행, 134–146행).
출력 쪽에만 있는 Coalescing 필터(54)는 SSL 필터 바로 위에서 작은 버킷들을 모읍니다. 소스 주석에 따르면 SSL 출력 필터는 버킷 하나마다 TLS 레코드(암호화의 단위) 하나를 만들기 때문에, 작은 버킷이 많으면 레코드와 패킷이 불필요하게 늘고, 헤더를 따로 된 레코드로 보내면 헤더 크기가 밖에서 보인다고 합니다(1687–1700행).
예제: OpenSSL이 정말 httpd의 필터를 부르는지
이 샌드위치를 눈으로 보려고 bio_filter_in_read()에 중단점을 걸고 HTTPS 요청을 한 번 보냈습니다. httpd를 -X(자식 프로세스 없이 한 프로세스로 실행하는 디버그 옵션)로 gdb 아래에서 띄우고 curl로 접속한 뒤, 멈춘 자리의 호출 스택을 찍었습니다. 이 예제는 기준 커밋(e4af30fe6d)을 그대로 빌드한 httpd 2.5.1-dev로 도커 컨테이너(Ubuntu 24.04, OpenSSL 3.0.13)에서 실행했습니다. 예제 전체는 ex/u15-mod-ssl/run.sh 하나입니다(설정은 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
(주소는 실행마다 다릅니다. ??는 디버그 정보가 없는 OpenSSL 내부 함수입니다.) 아래에서 위로 읽으면 이렇습니다. event MPM의 작업 스레드(#15)가 process_connection 훅을 돌리고(#13), mod_ssl의 훅(#12)이 ap_get_brigade()로 입력 필터를 부르고(#11), SSL 입력 필터(#10)가 핸드셰이크 함수(#9)를 거쳐 OpenSSL(#8~#4, libssl)로 들어가고, OpenSSL이 BIO_read()(#3)로 mod_ssl의 bio_filter_in_read()(#0)를 다시 부릅니다. httpd → OpenSSL → httpd로 돌아오는 모양이 그대로 찍혔습니다.
눈여겨볼 점이 하나 더 있습니다. 첫 읽기가 일어난 곳은 HTTP 요청을 읽는 코드가 아니라 ssl_hook_process_connection()이고, 그 안의 함수는 SSL_read()가 아니라 핸드셰이크(#9)입니다. 다음 절이 이 이야기입니다.
5. 핸드셰이크는 언제 — 요청을 읽기 직전, 따로 한 번
필터만 끼워 두면 핸드셰이크는 첫 읽기 때 저절로 일어납니다. SSL 입력 필터는 읽을 때마다 먼저 ssl_io_filter_handshake()를 부르고(ssl_engine_io.c 1617–1619행), 이 함수는 핸드셰이크가 이미 끝났으면 바로 돌아가고(1197–1199행), 아니면 SSL_accept()를 부릅니다(1424행). 출력 필터도 똑같이 앞에서 핸드셰이크를 확인합니다(1956–1958행).
그런데 mod_ssl은 그것을 기다리지 않고 process_connection 훅에서 핸드셰이크만 먼저 일으킵니다.
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);
/* ... 실패하면 연결을 닫을 준비 ... */
}
return DECLINED;
}
modules/ssl/mod_ssl.c 717–755행
AP_MODE_INIT은 "데이터는 필요 없고 초기화만 하라"는 읽기 모드입니다. SSL 입력 필터는 이 모드면 핸드셰이크까지만 하고 돌아갑니다(1621–1626행).- 주석이 이유를 말합니다. 핸드셰이크가 SNI와 ALPN(이 연결에서 HTTP/1.1과 HTTP/2 가운데 무엇을 쓸지 정하는 TLS 확장) 같은 유용한 일들을 일으키기 때문입니다. 연결에서 어떤 프로토콜을 쓸지는 요청을 읽는 모듈이 정해지기 전에 알아야 합니다.
- 마지막에
DECLINED를 돌려줍니다.process_connection은 처음으로DECLINED가 아닌 값을 낸 함수에서 멈추는 RUN_FIRST 훅입니다(server/connection.c 43행). mod_ssl은 핸드셰이크만 하고 연결을 놓아 주고, 뒤에 걸린 http 모듈의ap_process_http_connection()(APR_HOOK_REALLY_LAST, http_core.c 358–359행)이 이어서 요청을 읽습니다. 그때부터 http 모듈은 TLS가 있는지도 모른 채 평문만 받습니다.
연결 하나가 지나는 길. 가운데 파란 상자 셋(ClientHello 콜백부터)이 6절의 SNI 이야기입니다.
예제: 자체 서명 인증서로 curl -vk
컨테이너 안에서 openssl req -x509 ...로 a.example과 b.example의 자체 서명 인증서(스스로 서명해서 브라우저는 믿지 않는 연습용 인증서)를 만들고, 8443번 포트에 TLS 가상 호스트 둘을 띄웠습니다. 설정에서 이 글과 관련된 부분만 옮깁니다.
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)
# mod_ssl 이 고른 값을 응답 헤더로 보여 준다
# %{...}s : mod_headers 가 ap_ssl_var_lookup() 으로 바로 물어본다
# %{...}e : fixups 단계가 채운 환경 변수(subprocess_env)를 읽는다
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>
curl -vk의 -v는 핸드셰이크 과정을 보여 주고, -k는 자체 서명 인증서라도 검증 실패로 끊지 말라는 옵션입니다. --resolve a.example:8443:127.0.0.1로 이름을 컨테이너 자신에게 돌렸습니다. 출력에서 핸드셰이크 줄과 응답 헤더만 골랐습니다.
=== 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
Client hello부터 Finished까지가 4절의 스택에서 본 SSL_accept() 안에서 일어난 일이고, 그 뒤에야 HTTP/1.1 200 OK가 옵니다. ALPN: server accepted http/1.1은 이 컨테이너 설정에 HTTP/2 모듈(mod_http2)을 싣지 않았기 때문입니다. 응답 헤더 넷은 7절에서 봅니다.
헷갈리기 쉬운 곳: TLS 포트에 평문 HTTP를 보내면
https:// 대신 http://로 8443번에 접속하면 연결이 그냥 끊길 것 같지만, httpd는 HTML 오류 페이지를 돌려줍니다.
=== 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>
SSL_accept()가 실패하면서 OpenSSL이 "이건 HTTP 요청 같다"(SSL_R_HTTP_REQUEST)고 알려 주면, 핸드셰이크 함수는 mod_ssl만의 오류 코드 MODSSL_ERROR_HTTP_ON_HTTPS를 돌려줍니다(ssl_engine_io.c 1449–1458행). 그러면 ssl_io_filter_error()가 이 연결의 SSL 필터를 꺼서 이후로는 바이트를 그대로 통과시키게 하고, "가짜 요청 줄을 보내야 한다"는 표시를 남깁니다(ssl_engine_io.c 981–999행). process_connection 훅은 이 표시가 있으면 연결을 닫지 않고 http 모듈에 넘기고(위 코드의 줄인 자리, mod_ssl.c 734–751행), http 모듈이 요청을 읽으려 하면 SSL 입력 필터가 클라이언트가 보낸 바이트 대신 가짜 요청 줄 "GET / HTTP/1.0"과 빈 줄을 올려 보냅니다(945–951행, 1582–1599행). http 모듈은 평범한 요청으로 알고 처리를 시작하고, post_read_request 훅의 ssl_hook_ReadReq()가 이 표시를 보고 오류 메모를 붙여 400을 돌려줍니다(ssl_engine_kernel.c 166–177행). 원래 요청이 무엇이었든 응답은 /에 대한 400입니다. 오류를 보여 주려고 요청을 꾸며 내는, 필터 구조라서 가능한 우회입니다.
6. SNI — 핸드셰이크 도중에 가상 호스트와 인증서를 바꿉니다
포트 하나에 가상 호스트가 여럿이면 문제가 있습니다. 서버는 핸드셰이크에서 인증서를 보내야 하는데, 어느 가상 호스트의 인증서인지 알려 주는 Host 헤더는 핸드셰이크가 끝나야 읽을 수 있습니다. 3절에서 SSL_new()가 일단 기본 가상 호스트(a.example)의 SSL_CTX 로 시작한 이유입니다. 바로잡을 단서는 ClientHello 안의 SNI뿐입니다.
콜백을 등록해 두는 곳
기동 때 SSL_CTX를 만들면서 OpenSSL에게 "ClientHello를 받으면 이 함수를 불러 달라"고 등록해 둡니다.
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 780–798행 (오류 처리를 줄였습니다)
콜백이 둘입니다. 예전부터 있던 SNI 콜백과, OpenSSL 1.1.1부터 있는 ClientHello 콜백입니다. 주석대로 ClientHello 콜백이 더 이른 시점에 불려서, 가상 호스트에 따라 허용할 TLS 버전까지 바꿀 수 있습니다. 두 콜백 모두 결국 같은 함수 init_vhost()를 부르고, init_vhost()는 한 연결에서 한 번만 일합니다(vhost_found, ssl_engine_kernel.c 2256–2260행). 예제 컨테이너의 OpenSSL은 3.0.13이라 1.1.1 이상의 조건(#if)을 만족하므로 두 콜백이 모두 등록되고, 아래 예제 로그에서 보듯 연결마다 가상 호스트 찾기는 한 번만 일어납니다.
SNI로 가상 호스트 찾기
ClientHello 콜백은 SNI 확장의 바이트를 직접 뜯어 호스트 이름을 꺼낸 뒤 init_vhost()에 넘깁니다(ssl_engine_kernel.c 2410–2467행). init_vhost()는 코어 함수 ap_vhost_iterate_given_conn()으로 이 연결의 IP·포트에 걸린 이름 기반 가상 호스트들을 하나씩 ssl_find_vhost()에 넘깁니다(2266–2267행). 가상 호스트 목록을 다루는 코드는 코어(server/vhost.c, 9편)에 있고 mod_ssl은 "이 이름이 맞는가"만 판단합니다.
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);
/* ... 옵션, 프로토콜 버전, 검증 설정, 세션 ID 문맥을 새 ctx 에 맞춘다 ... */
sslcon->server = s;
/* ... */
return 1;
}
return 0;
}
modules/ssl/ssl_engine_kernel.c 2475–2559행 (가운데를 줄였습니다)
ssl_util_vhost_matches()는 SNI 이름을ServerName, 그다음ServerAlias와 비교합니다(ssl_util.c 61행부터).SSL_set_SSL_CTX()가 핵심입니다. 핸드셰이크 도중인SSL객체의 설정 묶음을 b.example의 것으로 바꿔 끼웁니다. 그래서 서버가 보내는 인증서가 b.example의 것이 됩니다. 이 함수가 인증서만 바꾸기 때문에 나머지 설정을 손으로 옮기는 코드가 줄인 자리에 있습니다(소스 주석, 2490–2494행).sslcon->server = s— mod_ssl이 기억하는 "이 연결의 가상 호스트"를 바꿉니다. 코어의c->base_server는 건드리지 않습니다.
맞는 이름이 없으면 경고도 보내지 않고 기본(첫 번째) 가상 호스트로 핸드셰이크를 계속합니다(2275–2293행의 주석에 RFC 6066을 근거로 든 설명이 있습니다).
예제: 같은 포트, 다른 인증서
앞의 설정 그대로 SNI만 바꿔 접속했습니다.
=== 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는 같은 8443번 포트인데 인증서가 CN=b.example입니다. C는 IP 주소로 접속해서 curl이 SNI를 보내지 않은 경우입니다(SNI에는 IP 주소를 넣지 않습니다). 그러면 기본 가상 호스트 a.example의 인증서가 나갑니다. Host: a.example을 붙였으므로 내용도 site A입니다. 이때 mod_ssl의 디버그 로그에는 이렇게 남습니다(LogLevel ssl:debug, 같은 메시지를 세어 묶었습니다).
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)
(연결 번호는 실행마다 다릅니다.) pre_connection의 AH01964는 다섯 연결 모두 a.example로 찍혔고, b.example로 바뀐 것은 그 뒤 핸드셰이크 도중의 AH02043입니다. 3절의 "헷갈리기 쉬운 곳"을 로그로 확인한 셈입니다. 다섯 번째 연결(평문 HTTP)은 핸드셰이크가 실패해 SNI 줄이 없습니다.
헷갈리기 쉬운 곳: SNI와 Host가 다르면 421
SNI는 핸드셰이크에서, Host는 요청에서 옵니다. 클라이언트가 둘을 다르게 보내면 어떻게 될까요. SNI는 a.example, Host는 b.example로 보내 봤습니다.
=== D. SNI = a.example but Host: b.example ===
421
Host로 고른 r->server는 b.example인데 TLS 연결은 a.example의 인증서로 맺어졌습니다. post_read_request 훅의 ssl_hook_ReadReq()가 두 가상 호스트의 TLS 설정을 비교해서 맞지 않으면 421 Misdirected Request("이 연결로는 그 호스트에 답할 수 없다")를 돌려줍니다.
/* 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 231–243행
로그에는 "Hostname a.example provided via SNI and hostname b.example provided via HTTP have no compatible SSL setup for policy 'secure'"(AH02032)가 남았습니다. "맞는다"의 기준은 SSLVHostSNIPolicy 디렉티브로 정하고 기본값은 secure입니다(ssl_engine_config.c 64행). secure는 기동 때 가상 호스트마다 인증서 파일·키 파일·프로토콜·암호 방식·클라이언트 검증 설정 등을 MD5로 묶은 값을 만들어 두고, 그 값이 같은지 봅니다(ssl_engine_init.c 330–405행). 예제의 두 가상 호스트는 인증서 파일이 다르니 맞지 않습니다. 반대로 C처럼 SNI가 없고 Host가 기본 가상 호스트면 문제가 없습니다.
위 예제 출력과 디버그 로그의 메시지 번호를 경우마다 모았습니다. 가상 호스트가 바뀌는 것은 핸드셰이크 도중(AH02043)이고, Host 와 맞는지는 요청을 읽은 뒤에 봅니다.
7. SSL_* 변수 — 코어의 ap_ssl_* 함수를 거쳐 꺼냅니다
다른 모듈도 TLS 정보가 필요합니다. mod_rewrite는 %{HTTPS}로 리다이렉트를 정하고, mod_log_config는 프로토콜과 암호 방식을 로그에 적고, mod_http2는 TLS 연결인지에 따라 다르게 동작합니다. 그런데 이 모듈들이 mod_ssl의 함수를 직접 부르면 mod_ssl 없이 빌드할 수 없게 됩니다.
trunk의 답은 코어에 TLS 질문 창구를 두는 것입니다. include/http_ssl.h가 선언하고 server/ssl.c가 구현합니다. 몇 개만 옮깁니다.
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;
}
두 함수 모두 모양이 같습니다. 먼저 훅을 돌리고(ap_run_ssl_*, 둘 다 RUN_FIRST, 256–260행), 아무도 답하지 않으면 예전 방식의 선택 함수(optional function) 로 물러납니다. 선택 함수는 "이름으로 등록해 두면 다른 모듈이 이름으로 찾아 부르는" APR의 장치로, 등록한 모듈이 없으면 NULL이 됩니다(6편에서 맛보기로 봤습니다).
다른 모듈은 회색 상자 왼쪽 위처럼 코어만 압니다. mod_ssl이 없어도 컴파일되고, TLS 모듈이 없으면 "TLS 아님"이라는 답이 돌아옵니다.
이 커밋의 mod_ssl은 두 길을 섞어 씁니다.
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 245–252행
"TLS 연결인가"는 새 훅 ssl_conn_is_ssl에 답하고(이 연결에 SSL 객체가 있으면 OK, 69–73행), 변수 값은 예전처럼 선택 함수 ssl_var_lookup으로 내놓습니다. 그런데 코어도 같은 이름 ssl_var_lookup으로 선택 함수를 등록합니다. 코어의 post_config 훅(APR_HOOK_REALLY_FIRST)이 부르는 ap_setup_ssl_optional_fns()가, 먼저 등록돼 있던 mod_ssl의 함수를 module_ssl_var_lookup에 챙겨 두고 그 자리에 자기 함수를 등록합니다(server/ssl.c 192–216행, server/core.c 5404행, 6161행). 그래서 새 API를 쓰는 모듈이든, 예전처럼 선택 함수를 찾는 모듈이든 결국 같은 길로 mod_ssl에 닿습니다.
나머지 훅(ssl_add_cert_files, ssl_add_fallback_cert_files, ssl_answer_challenge, ssl_ocsp_*)은 반대 방향, 곧 mod_ssl이 묻고 다른 모듈(mod_md)이 답하는 창구입니다. 이 커밋에서 이 훅들에 함수를 거는 모듈은 mod_md뿐입니다(mod_md.c 1591–1597행).
2.4.x에도 있습니다. server/ssl.c와 include/http_ssl.h는 trunk에만 있는 것이 아니라 2.4.x 브랜치에도 있고, 들어 있는 함수 목록(ap_ssl_conn_is_ssl, ap_ssl_var_lookup, ap_ssl_add_cert_files, ap_ssl_bind_outgoing 등)도 같습니다. 2.4.x 브랜치의 기록을 보면 이 파일들은 2.4.48 릴리스에 들어갔습니다(커밋 메시지는 "다른 TLS 모듈이 mod_ssl인 척하지 않고도 동작하게"를 이유로 듭니다). 그러니 2.4.48보다 오래된 httpd용 모듈 소스에서는 APR_RETRIEVE_OPTIONAL_FN(ssl_var_lookup) 같은 예전 방식만 보게 됩니다.
mod_headers의 %{…}s는 이 창구를 씁니다
예제 설정의 %{SSL_PROTOCOL}s에서 s는 mod_headers가 등록한 형식 글자이고(mod_headers.c 1068행), 그 함수는 코어의 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 207–215행
mod_ssl 쪽 ssl_var_lookup()은 이름의 앞부분을 보고 갈래를 타다가, SSL_PROTOCOL이면 OpenSSL의 SSL_get_version()을 부릅니다(ssl_engine_vars.c 491–493행). 예제의 X-SSL-Protocol: TLSv1.3이 그 값입니다. 값은 물어볼 때 그 자리에서 계산됩니다.
헷갈리기 쉬운 곳: %{SSL_PROTOCOL}e가 (null)인 이유
예제 A(a.example)에서 X-Env-HTTPS는 on인데 X-Env-Protocol은 (null)이었고, B(b.example)에서는 둘 다 값이 있었습니다. %{…}e는 mod_ssl에 묻지 않고 요청의 환경 변수 표(r->subprocess_env, CGI 같은 것에 넘겨줄 변수)를 읽습니다. 그 표를 채우는 것이 fixups 훅의 ssl_hook_Fixup()입니다.
/* 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 1425–1452행
HTTPS=on은 언제나, SSL_TLS_SNI는 클라이언트가 SNI를 보냈다면 SSLOptions와 상관없이 넣지만, SSL_PROTOCOL을 비롯한 수십 개의 표준 변수(1345행부터의 목록)는 SSLOptions +StdEnvVars가 켜져 있을 때만 넣습니다. 요청마다 수십 개를 계산하는 것이 비싸서 기본으로는 꺼 두는 것입니다. 예제에서는 b.example에만 +StdEnvVars를 줬습니다. 정리하면 이렇습니다.
| 형식 | 어디서 읽나 | StdEnvVars 없이 |
|---|---|---|
%{SSL_PROTOCOL}s (mod_headers) |
ap_ssl_var_lookup() → mod_ssl이 그 자리에서 계산 |
값이 나옴 |
%{SSL_PROTOCOL}e (mod_headers) |
r->subprocess_env — fixups가 채운 표 |
(null) |
%{HTTPS}e |
같은 표, 언제나 넣는 값 | on |
CGI 스크립트에 넘어가는 것이 이 표이므로, CGI에서 SSL_PROTOCOL이 비어 있다면 +StdEnvVars를 확인할 곳입니다. 반대로 로그나 헤더에 값이 필요할 뿐이라면 +StdEnvVars 없이 직접 묻는 형식(mod_headers의 s, mod_log_config의 %{…}x — mod_log_config.c 911–918행, 1902행)을 쓰면 됩니다.
이 글에서 뺀 것
mod_ssl에서 이 글이 다루지 않은 큰 덩어리의 이름과 위치만 적어 둡니다.
- 클라이언트 인증서 검증 —
SSLVerifyClient. 검증 콜백ssl_callback_SSLVerify()(ssl_engine_init.c 1376행에서 등록), 디렉터리마다 다른 검증을 위해 다시 협상하는ssl_hook_Access()(ssl_engine_kernel.c), 그때 요청 본문을 맡아 두는ssl_io_buffer필터. - 세션 캐시와 세션 티켓 — 예제에 넣은
SSLSessionCache.ssl_scache.c와 socache 프로바이더(mod_socache_shmcb 등), 콜백 등록은 ssl_engine_init.c 1171–1173행. - OCSP 스테이플링 —
ssl_util_stapling.c, 코어 훅ap_ssl_ocsp_prime()·ap_ssl_ocsp_get_resp(). - ALPN — HTTP/2를 고르는 콜백
ssl_callback_alpn_select()(ssl_engine_kernel.c 2663행). - 백엔드 쪽 TLS — mod_proxy가
https://백엔드에 붙을 때 mod_ssl이 클라이언트가 되는 길(SSLProxyEngine,ap_ssl_bind_outgoing()→ssl_hook_ssl_bind_outgoing(), mod_ssl.c 573–606행). mod_proxy 자체는 14편에 있습니다. - 개인 키 암호 묻기 —
SSLPassPhraseDialog,ssl_engine_pphrase.c. - trunk에만 있는 것들 — 정책 묶음
SSLPolicy, Encrypted Client Hello(SSLECHKeyDir,HAVE_OPENSSL_ECH로 빌드할 때만), Certificate Transparency 모듈mod_ssl_ct.c. 이 글에서는 2.4.x와 비교해 확인하지 않았습니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| 코어와 TLS | 코어에는 TLS 코드가 없다. mod_ssl이 훅과 연결 필터로 끼어든다 |
ssl_init_Module() |
post_config에서 SSLEngine on인 가상 호스트마다 SSL_CTX(인증서·키·설정 묶음)를 만든다 |
ssl_hook_pre_connection() |
연결마다 기본 가상 호스트의 SSL_CTX로 SSL_new(), SSL 필터를 끼운다 |
| SSL 필터 | CONNECTION+5(55)라서 코어 필터(60) 바로 위. 위쪽은 평문만 본다 |
| 직접 만든 BIO | OpenSSL의 읽기·쓰기가 bio_filter_in_read()·bio_filter_out_write()로 들어와 다시 필터 사슬로 이어진다 |
| 핸드셰이크 시점 | process_connection에서 AP_MODE_INIT으로 먼저 일으키고 DECLINED로 http 모듈에 넘긴다 |
| SNI | ClientHello 콜백 → init_vhost() → SSL_set_SSL_CTX()로 인증서를 바꾼다. c->base_server는 그대로 |
| 421 | SNI로 고른 가상 호스트와 Host로 고른 가상 호스트의 TLS 설정이 다르면 ssl_hook_ReadReq()가 거절한다 |
ap_ssl_* |
코어(server/ssl.c)의 TLS 질문 창구. 훅 먼저, 없으면 예전 선택 함수. 2.4.48부터 2.4.x에도 있다 |
SSL_* 환경 변수 |
HTTPS는 늘, SSL_TLS_SNI는 SNI가 오면 늘, 나머지는 SSLOptions +StdEnvVars일 때만 fixups가 채운다 |
다음에 볼 것
TLS는 바이트 흐름을 감싸는 연결 필터 한 겹과, 기동·연결·요청의 몇몇 훅으로 끼어든다는 것을 봤습니다. 위쪽의 HTTP 코드는 그 존재를 모르고, 알고 싶은 모듈은 코어의 ap_ssl_* 창구로 묻습니다.
연재의 마지막 글은 이 모든 일이 기록되는 곳, 로그입니다. 오류 로그의 ap_log_error()와 이 글의 예제에서 쓴 LogLevel ssl:debug 같은 모듈별 로그 수준, 그리고 접근 로그의 LogFormat 한 줄이 함수 표로 바뀌는 과정을 봅니다. 7절 끝에서 본 %{…}x도 거기서 다시 나옵니다. 16편: 로그 — 접근 로그와 오류 로그, 그리고 연재를 마치며에서 이어집니다.
Spring 연재에는 TLS를 다룬 글이 없습니다. 다만 "요청과 응답을 앞뒤에서 감싸고, 안쪽 코드는 그 존재를 모른다"는 모양은 Spring 20편의 서블릿 필터와 닮았습니다. 서블릿 필터가 요청 객체를 감싼다면, mod_ssl의 필터는 그보다 아래에서 연결의 바이트를 감쌉니다.