namalzi

← 글 목록

Apache httpd 소스 파헤치기 15: mod_ssl — TLS는 어디에 끼어드나

Apache httpd · 게시

httpd 코어는 TLS를 모릅니다. 그런데도 SSLEngine on 한 줄이면 HTTPS가 됩니다. mod_ssl이 훅으로 기동과 연결에 끼어들고, 연결 필터와 직접 만든 BIO로 OpenSSL을 필터 사슬 사이에 끼우고, SNI로 가상 호스트와 인증서를 바꾸고, SSL_* 변수를 다른 모듈에 내주는 과정을 소스와 예제로 따라갑니다.

이 글에서 다루는 것

"Apache httpd 소스 파헤치기" 연재의 열다섯 번째 글입니다. 14편에서는 httpd가 다른 서버에 요청을 넘기는 mod_proxy를 봤습니다. 이번에는 httpd.conf에 SSLEngine on 한 줄을 넣으면 HTTPS가 되는 뒤편, 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 쪽 이름은 셋입니다.

연결 필터는 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행).

코어가 스킴을 묻는 길. 코어가 리다이렉트 주소 같은 것을 만들 때 부르는 ap_http_scheme(r) 는 실은 ap_run_http_scheme(r) 매크로다. http_scheme 훅은 RUN_FIRST 라서 NULL 이 아닌 첫 답에서 멈춘다. MIDDLE 로 건 mod_ssl 의 ssl_hook_http_scheme 이 이 요청이 TLS 인지 보고, 그렇다면 "https" 를 돌려줘 거기서 멈춘다. 아니면 NULL 을 돌려주고, REALLY_LAST 로 건 http 모듈의 http_scheme() 이 "http" 라고 답한다 코어는 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행).

mod_ssl의 기동 순서. pre_config 훅에서 OpenSSL을 초기화하고, 설정을 읽어 SSL 디렉티브 값을 서버 설정 구조체에 담은 뒤, post_config 훅의 ssl_init_Module이 모든 server_rec를 돌며 vhost_id를 만들고, 세션 캐시와 뮤텍스를 준비하고, SSLEngine on인 서버마다 ssl_init_server_ctx에서 ssl_add_cert_files 훅을 부른 다음 SSL_CTX를 만들어 인증서와 키를 싣고 SNI 콜백을 등록한다. 마지막으로 init_bio_methods가 BIO 함수 표를 만든다. 파란색은 훅, 노란색은 만들어 두는 것입니다. 연결이 오기 전에 여기까지 끝납니다.

  1. pre_config — OpenSSL 깨우기. OpenSSL 1.1 이상에서는 OPENSSL_init_ssl() 한 줄입니다(mod_ssl.c 428–434행). 그 아래 #else 쪽은 옛 OpenSSL용 코드입니다. #ifndef/#else는 컴파일할 때 둘 중 하나만 남기는 전처리기 분기입니다.
  2. 서버마다 기본값 정리. server_rec 목록을 돌며 SSLEngine이 정해지지 않은 서버는 꺼진 것으로 확정하고(Listen 443 https처럼 프로토콜이 https이고 인증서가 지정된 서버만 예외로 켭니다), 로그에 쓸 이름 vhost_id를 만듭니다(ssl_engine_init.c 497–543행).
  3. 켜진 서버마다 SSL_CTX. 두 번째 루프가 ssl_init_ConfigureServer() → ssl_init_server_ctx()를 부릅니다(625–640행, 3268–3308행). 여기서 SSL_CTX_new()로 설정 묶음을 만들고(972행) 6절에서 볼 SNI 콜백을 등록한 뒤, 인증서와 키를 싣습니다(ssl_init_server_certs(), 2200행부터).
  4. 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절에서 다시 봅니다.

헷갈리기 쉬운 곳

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행 (가운데를 줄였습니다)

  1. SSL_new() — 기동 때 만든 SSL_CTX를 본으로 이 연결의 SSL을 만듭니다. 이때 쓰는 SSL_CTX는 c->base_server의 것, 곧 IP와 포트로 고른 기본 가상 호스트의 것입니다. 아직 SNI를 읽지 않았으니 다른 방법이 없습니다. 6절에서 이것을 바꿉니다.
  2. SSL_set_app_data(ssl, c) — OpenSSL의 SSL 객체에 httpd의 conn_rec를 매달아 둡니다. 나중에 OpenSSL이 mod_ssl의 콜백을 부를 때 넘겨주는 것은 SSL *뿐인데, 콜백은 이것으로 conn_rec를 되찾습니다(SSL_get_app_data()).
  3. 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는 클라이언트 인증서 때문에 요청 본문을 잠시 맡아 두는 특수한 필터라서 이 글에서는 뺍니다.

연결 필터 사슬. 들어오는 쪽은 HTTP 요청 읽기 아래에 SSL/TLS Filter(55), 그 아래 CORE_IN(60)이 소켓에서 읽는다. 나가는 쪽은 HTTP 필터들 아래에 SSL/TLS Coalescing Filter(54), SSL/TLS Filter(55), CORE(60)가 차례로 서서 소켓에 쓴다. 파란 상자가 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행

필터를 붙이는 것 말고도 눈에 띄는 줄이 셋 있습니다.

헷갈리기 쉬운 곳

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행

읽기 경로. 위 필터가 ap_get_brigade로 평문을 달라고 하면 ssl_io_filter_input이 OpenSSL의 SSL_read 또는 SSL_accept를 부르고, OpenSSL은 BIO_read로 바이트를 요청한다. 그러면 mod_ssl이 만든 BIO의 read 함수 bio_filter_in_read가 불려 ap_get_brigade로 아래 필터에 암호문을 요청하고, CORE_IN이 소켓에서 읽은 암호문이 char 배열로 OpenSSL에 돌아가 복호화된 뒤 평문 버킷으로 위 필터에 전달된다. 실선이 부르는 방향, 점선이 데이터가 돌아오는 방향입니다. OpenSSL(회색)이 mod_ssl의 두 함수(파란색) 사이에 샌드위치처럼 끼어 있습니다.

  1. 위쪽 필터(HTTP 요청을 읽는 쪽)가 ap_get_brigade()로 "평문을 달라"고 하면 SSL 입력 필터 ssl_io_filter_input()이 불립니다(1559행).
  2. 이 필터는 OpenSSL의 SSL_read()를 부릅니다(ssl_io_input_read() 안, 683행).
  3. OpenSSL은 복호화할 암호문이 필요하니 자기 BIO에서 읽으려 합니다. 그 BIO의 읽기 함수가 mod_ssl의 bio_filter_in_read()입니다.
  4. 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 세계 사이의 번역이 이 함수에서 일어납니다.

  1. 암호문을 받은 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행

연결 하나의 시간 순서. 연결을 accept하면 ap_update_vhost_given_ip가 IP와 포트로 c->base_server를 a.example로 정하고, pre_connection 훅에서 SSL_new와 SSL 필터 끼우기, process_connection 훅에서 AP_MODE_INIT로 한 번 읽어 SSL_accept가 ClientHello를 읽는다. ssl_callback_ClientHello가 SNI b.example을 보고 init_vhost와 ssl_find_vhost가 SSL_set_SSL_CTX로 b.example의 인증서로 바꾼다. 핸드셰이크가 끝나면 mod_ssl은 DECLINED를 돌려주고 http 모듈이 요청을 읽어 Host로 r->server를 정한다. ssl_hook_ReadReq가 두 가상 호스트의 TLS 설정이 맞는지 보고, 맞으면 요청을 처리하고 아니면 421 Misdirected Request를 돌려준다. 연결 하나가 지나는 길. 가운데 파란 상자 셋(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행 (가운데를 줄였습니다)

맞는 이름이 없으면 경고도 보내지 않고 기본(첫 번째) 가상 호스트로 핸드셰이크를 계속합니다(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가 기본 가상 호스트면 문제가 없습니다.

예제 B, C, D 를 나란히 놓은 그림. B 는 SNI 와 Host 가 모두 b.example 이다. pre_connection 은 a.example 의 SSL_CTX 로 시작하고(AH01964 server a.example:443), ssl_find_vhost 가 SSL_set_SSL_CTX 로 바꿔 AH02043 b.example found 가 남고 인증서는 CN=b.example, 응답은 200 site B 와 X-SSL-SNI: b.example 이다. C 는 IP 로 접속해 SNI 가 없고 Host 는 a.example 이다. 맞출 이름이 없어 기본(첫) 가상 호스트로 가며 AH02645 와 인증서 CN=a.example, Host 도 기본 가상 호스트라 200 site A 와 X-SSL-SNI: (null) 이다. D 는 SNI 가 a.example, Host 가 b.example 이다. 핸드셰이크는 a.example 과 맞아 인증서 CN=a.example 이지만, ssl_hook_ReadReq 에서 r->server 는 b.example 이고 SSLVHostSNIPolicy secure 기준으로 인증서 파일이 달라 맞지 않으므로 421 Misdirected Request 와 AH02032 가 남는다 위 예제 출력과 디버그 로그의 메시지 번호를 경우마다 모았습니다. 가상 호스트가 바뀌는 것은 핸드셰이크 도중(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;
}

server/ssl.c 84–91행

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;
}

server/ssl.c 181–190행

두 함수 모두 모양이 같습니다. 먼저 훅을 돌리고(ap_run_ssl_*, 둘 다 RUN_FIRST, 256–260행), 아무도 답하지 않으면 예전 방식의 선택 함수(optional function) 로 물러납니다. 선택 함수는 "이름으로 등록해 두면 다른 모듈이 이름으로 찾아 부르는" APR의 장치로, 등록한 모듈이 없으면 NULL이 됩니다(6편에서 맛보기로 봤습니다).

코어의 TLS 질문 창구. mod_headers, mod_rewrite, mod_log_config, mod_http2 같은 모듈은 server/ssl.c의 ap_ssl_var_lookup과 ap_ssl_conn_is_ssl을 부른다. 코어는 먼저 ssl_* 훅에 등록한 모듈이 답했는지 보고, 답했으면 그 답을 쓴다. mod_ssl은 ssl_conn_is_ssl 훅에 등록해 있다. 답이 없으면 예전 방식의 선택 함수, 곧 mod_ssl의 ssl_var_lookup으로 물러난다. core_post_config의 ap_setup_ssl_optional_fns가 기동 때 예전 선택 함수를 챙겨 두고 자기 것으로 바꿔 등록한다. 다른 모듈은 회색 상자 왼쪽 위처럼 코어만 압니다. 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에서 이 글이 다루지 않은 큰 덩어리의 이름과 위치만 적어 둡니다.

정리

개념 한 줄 요약
코어와 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의 필터는 그보다 아래에서 연결의 바이트를 감쌉니다.

다른 글