Node.js 用户自定义域名 HTTPS 访问绑定证书的方法
通过 SNICallback 为用户自定义域名动态选择 HTTPS 证书

问题背景

最近在做线上产品的时候遇到了一个比较麻烦的问题,用户需要绑定自己的域名。但是由于线上服务器已经处于全站 HTTPS 的状态,需要用户提供自己域名对应的证书和私钥,证书信息可能会考虑存在后端的数据库以及缓存中。为了更加灵活地提供自定义域名的绑定服务,就需要后端服务来实现支持 SNI(Server Name Indication)的加密处理。

其实在平时使用 CDN 服务的时候,服务商大多都支持用户绑定自定义域名并上传证书信息。以前一直挺好奇这方面的实现原理,但是这方面现成的资料并不多,绝大多数的项目应该都遇不到这个需求,感觉可以自己来折腾一下。

显然如果用 nginx 来做这种场景下的加密层的话,会有每次更新证书需要 reload 服务的缺陷,如果用户自定义域名的情况比较多,会非常不易维护。除非自己去 hack 相关的模块,显然这样的维护代价还是有点高的。所以还是需要考虑其他方式来实现。

Node.js 实现 SNI 加密访问的原理

因为项目上的后端是 node.js 的,所以很自然地优先研究起了 Node.js 的实现方法。看下 Node.js 自带的 https 模块,可以发现 https.Server 本身就是 tls.Server 的一个子类。TLS 层本身只是实现了加密的处理,https 模块则对连接增加了访问协议的支持。

https 模块本身没有太多加密相关的接口说明,tls 模块则提供了比较多的说明。注意到 tls.createServer 中存在一个 SNICallback 参数,文档要求通过 cb(null, ctx) 返回一个 SecureContext 实例,可以使用 tls.createSecureContext(…​) 创建。相关接口见 Node.js 4.4.4 TLS 文档。

大致就是客户端在请求建立连接的时候,会向这个方法传入域名信息,以供建立加密的连接 context。如果这个方法不返回 context,则自动使用默认的连接设置。所以,通过这个接口来生成用户所需域名的加密连接应该是可行的。

另外需要说明一下,早期的 SSLv2 默认一台服务器只提供一项服务,没有对 SNI 提供支持。到了后来的 TLS 规范为了迎合实际情况,增加了握手请求时向服务端提供主机名的方案来实现 SNI。不过由于历史原因,早期的浏览器如 IE 6 就不支持 SNI。不过因为当时的线上服务一般都可以不考虑如此老旧的浏览器了,所以 SNI 的功能直接用就行。

Node.js 实现 SNI 加密访问

接下来可以写一个简单的 demo 来测试一下实际的访问效果。需要预先准备好至少两个 HTTPS 证书,比如我这里准备了 hamhire.com 和 huhamhire.com 的证书和私钥。这里测试代码使用的是 Node.js 4.4.4 版本,额外还用到了 Express 框架。测试代码如下。

'use strict';
const https = require('https');
const tls = require('tls');
const fs = require('fs');

const app = require('express')();

app.use('/', (req, res) => {
    res.send('Hello, World!').end();
});

https.createServer({
    key: fs.readFileSync('certs/hamhire.com.key'),
    cert: fs.readFileSync('certs/hamhire.com.crt'),
    secureProtocol: 'SSLv23_method',
    honorCipherOrder: true,
    SNICallback: (servername, callback) => {
        console.log(`visit: ${ servername }`);
        if (servername === 'test.huhamhire.com') {
            let ctx = tls.createSecureContext({
                key: fs.readFileSync('certs/huhamhire.com.key'),
                cert: fs.readFileSync('certs/huhamhire.com.crt')
            });
            return callback(null, ctx);
        }
        return callback();
    }
}, app).listen(443, () => {
    console.log('SSL Proxy listening on port 443');
});

服务启动之后,本地可以修改一下 hosts 文件,把指定的域名指向到本机上。然后分别测试一下 test.hamhire.com 和 test.huhamhire.com 两个域名的访问。

可以看到访问 test.hamhire.com 时,服务器使用了 *.hamhire.com 的证书。

浏览器访问 test.hamhire.com,展示 hamhire.com 的通配符证书
图 1. 访问 test.hamhire.com 时使用的证书

而在访问 test.huhamhire.com 时,服务器则使用了 *.huhamhire.com 的证书。

浏览器访问 test.huhamhire.com,展示 huhamhire.com 的通配符证书
图 2. 访问 test.huhamhire.com 时使用的证书

另外,在控制台也可以得到对应域名访问的信息。

控制台输出 test.hamhire.com 和 test.huhamhire.com 的访问记录
图 3. 控制台记录两个域名的访问

我找到的关于 Node.js 提供 SNI 访问的大致实现思路就是这样,实际部署的时候,Node.js 服务器外层应该还需要有转发 TCP 数据的负载均衡器。


最后修改于 2016-07-09