目录
阅读姊妹篇: 深入理解nginx的https alpn机制
1. 概述
SNI(Server Name Indication)是一种TLS(Transport Layer Security)协议的扩展,用于在建立加密连接时指定服务器的主机名。在使用单个IP地址和端口提供多个域名的服务时,SNI是非常有用的。
当客户端发起TLS握手时,它会发送一个包含所请求主机名的扩展,这样服务器就可以根据这个主机名选择合适的证书来完成握手。这使得服务器能够在同一IP地址和端口上为多个域名提供加密连接,而不需要为每个域名分配一个独立的IP地址。
对于HTTPS网站来说,SNI是至关重要的,因为它允许服务器在同一IP地址上为多个域名提供加密连接,不需要为每个域名单独部署一台服务器,从而降低了运维成本并提高了灵活性。
在使用SNI时,服务器端必须能够根据客户端发送的SNI信息来选择正确的证书进行握手。通常,服务器端配置会包含多个虚拟主机的证书信息,以便根据收到的SNI信息选择正确的证书来完成握手。
总的来说,SNI允许客户端在TLS握手期间指定所请求的主机名,从而使服务器能够根据主机名选择正确的证书,实现一个IP地址上多个域名的加密连接。
本文基于nginx,对sni的实现原理进行深入的分析。
2. 初识sni
有图有真相,先上一张抓包图,如下图:
在ssl握手的第一个报文ClientHello中我们可以看到server_name的扩展信息,里面包含了当前请求的网站域名www.test.com。
当服务器收到这个报文后,将会解析出server_name扩展信息,这样子就可以在还没有收到客户端发送的HTTP报文前,即SSL握手阶段就提前知道了客户端需要访问的域名,服务器从而可以从容地在握手阶段选择绑定了该域名的SSL证书,来完成整个握手的过程。
需要强调一下的是,每个从CA申请下来的证书是会绑定域名的,SSL证书可以绑定一个或者多个域名,甚至是泛域名,这样子当浏览器在用https访问网站的时候,服务器会将配置的证书发送给浏览器,浏览器会根据拿到的证书进行检查,包括检查当前访问的域名是不是在证书中列出的域名列表中,如果不是的话,浏览器就会显示不安全网站的警告,甚至拒绝用户访问该网站。
3. nginx的ssl证书配置指令
nginx和ssl相关的配置指令很多,但是和证书配置相关的指令主要包括ssl_certificate、ssl_certificate_key、 ssl_password_file这三个。下面分别来说明一下:
3.1 ssl_certificate
语 法: ssl_certificate file;
默认值: —
上下文: http, server
这个指令用于给一个虚拟主机配置一个PEM格式的证书文件,如果除了主证书外还需要指定证书链中的中间证书,它们应该按照以下顺序在同一个文件中指定:首先是主证书,然后是中间证书。一个以 PEM 格式的私钥也可以放在同一个文件中。
从nginx 1.10.0版本开始,可以配置多个ssl_certificate以便加载不同类型的证书,如RSA and ECDSA等。
从nginx 1.15.9版本开始,如果openssl版本大于等于1.0.2, 那么nginx可以支持证书文件名嵌入动态变量,这样子可以很将配置书写成下面的格式,如:
ssl_certificate $ssl_server_name.crt;
ssl_certificate_key $ssl_server_name.key;
从而能够自动在握手的时候加载用户请求所对应域名的证书文件,方便了运维配置工作,当然这样子配置可能有一定的性能上的损失。
nginx也支持直接将证书文件的内容用data:$variable的形式来设置,而这个variable的值可以用nginx插件来设置,这样子就完全不需要文件了,便于程序根据实际需要更加灵活第动态加载证书。
3.2 ssl_certificate_key
语 法: ssl_certificate_key file;
默认值: —
上下文: http, server
这个指令用于给一个虚拟主机配置证书文件对应的证书私钥,与ssl_certificate需要一一对应。同样支持文件名嵌入动态变量,和data:$variable方式加载证书,另外还支持engine:name:id格式的配置,用来让nginx从openssl的某个engine中获取指定id的证书私钥。
3.3 ssl_password_file
语 法: ssl_password_file file;
默认值: —
上下文: http, server
配置一个用于解密证书私钥的密码本文件,密码本文件每个密码一行,nginx依次尝试解密。当然,如果证书私钥没有加密,那么ssl_password_file是可以不进行配置的。
4. nginx源码分析
4.1 给ssl上下文的初始化
ssl上下文的初始化是在ngx_http_ssl_merge_srv_conf函数中进行的,这个时候配置文件中的证书、密钥、加密文件的配置已经读取到了,本函数在这里执行main和server级别的配置的合并,然后就是创建ssl上下文,最后加载证书到ssl上下文了,源码如下:
if (ngx_ssl_ciphers(cf, &conf->ssl, &conf->ciphers,
conf->prefer_server_ciphers)
!= NGX_OK)
{
return NGX_CONF_ERROR;
}
/* 如果ssl_certificate和ssl_certificate_key配置的有动态变量,
会进行变量的解析工作 */
if (ngx_http_ssl_compile_certificates(cf, conf) != NGX_OK) {
return NGX_CONF_ERROR;
}
/* conf->certificate_values不为NULL
表示ssl_certificate和ssl_certificate_key配置含有动态变量 */
if (conf->certificate_values) {
#ifdef SSL_R_CERT_CB_ERROR
/* install callback to lookup certificates */
/* 向ssl底层注册一个证书选择的回调函数 */
SSL_CTX_set_cert_cb(conf->ssl.ctx, ngx_http_ssl_certificate, conf);
#else
ngx_log_error(NGX_LOG_EMERG, cf->log, 0,
"variables in "
"\"ssl_certificate\" and \"ssl_certificate_key\" "
"directives are not supported on this platform");
return NGX_CONF_ERROR;
#endif
} else if (conf->certificates) {
/* configure certificates */
/* 配置的证书是静态文件且不包含动态变量,那么直接将证书加载到ssl上下文 */
if (ngx_ssl_certificates(cf, &conf->ssl, conf->certificates,
conf->certificate_keys, conf->passwords)
!= NGX_OK)
{
return NGX_CONF_ERROR;
}
}
......
上面的代码会判断配置的证书是否静态文件,如果是静态文件则在这个阶段就直接将证书加载到ssl上下文中,因为这个阶段信息已经很清楚了,后续就不需要加载了;如果不是静态文件,那么这个阶段是没办法知道要加载的证书到底是什么内容的,要等到最终进行ssl握手的时候才能知晓,所以nginx通过SSL_CTX_set_cert_cb注册了一个回调函数ngx_http_ssl_certificate,最终在需要加载证书的时候就会回调这个函数来获取真正的证书内容。
当然,还有一个需要关注的是下面的代码,这个代码也是在ngx_http_ssl_merge_srv_conf函数中执行的,源码如下:
#ifdef SSL_CTRL_SET_TLSEXT_HOSTNAME
if (SSL_CTX_set_tlsext_servername_callback(conf->ssl.ctx,
ngx_http_ssl_servername)
== 0)
{
ngx_log_error(NGX_LOG_WARN, cf->log, 0,
"nginx was built with SNI support, however, now it is linked "
"dynamically to an OpenSSL library which has no tlsext support, "
"therefore SNI is not available");
}
#endif
这段代码注册了sni的回调函数ngx_http_ssl_servername到ssl上下文中。
4.2 连接初始化
在4.1节中所述的ssl上下文准备好以后,ssl连接当然是还没有建立的,只能说仍然只是停留在配置阶段,那么接下去可以想到客户端发起了tcp连接,nginx接受了这个连接,就需要开始对这个连接进行初始化,连接的初始化过程是由ngx_http_init_connection函数来完成的。那么如果开启了https,就会执行如下代码:
#if (NGX_HTTP_SSL)
{
ngx_http_ssl_srv_conf_t *sscf;
sscf = ngx_http_get_module_srv_conf(hc->conf_ctx, ngx_http_ssl_module);
if (sscf->enable || hc->addr_conf->ssl) {
hc->ssl = 1;
c->log->action = "SSL handshaking";
rev->handler = ngx_http_ssl_handshake;
}
}
#endif
这段代码给当前连接的读事件设置了一个回调函数,即ngx_http_ssl_handshake函数,它用来进行ssl的握手操作。那么当nginx从这个连接上收到请求数据的时候就会开始执行ssl握手操作。在ngx_http_ssl_handshake函数中,有以下这段代码:
if (ngx_ssl_create_connection(&sscf->ssl, c, NGX_SSL_BUFFER)
!= NGX_OK)
{
ngx_http_close_connection(c);
return;
}
这段代码用之前启动阶段准备好的ssl上下文和当前的socket连接来创建一个新的ssl连接,这样子就将当前的socket连接和ssl上下文关联起来了。后面就是真正的ssl握手操作了,在ngx_http_ssl_handshake代码里有:
rc = ngx_ssl_handshake(c);
在ngx_ssl_handshake函数里面会发起异步的ssl握手操作,这里略过。
4.3 处理sni回调
在握手期间,ssl底层逻辑会解析ClientHello数据报文,发现有sni数据后,就回调前面设置好的ngx_http_ssl_servername函数了。下面来分析一下ngx_http_ssl_servername函数的实现:
int
ngx_http_ssl_servername(ngx_ssl_conn_t *ssl_conn, int *ad, void *arg)
{
#if defined(T_INGRESS_SHARED_MEMORY_PB) && OPENSSL_VERSION_NUMBER >= 0x10101000L
return SSL_TLSEXT_ERR_OK;
#endif
ngx_int_t rc;
ngx_str_t host;
const char *servername;
ngx_connection_t *c;
ngx_http_connection_t *hc;
ngx_http_ssl_srv_conf_t *sscf;
ngx_http_core_loc_conf_t *clcf;