VPS 上 Nginx+Lua 智能双证书:RSA 与 ECC 的无缝降级,告别老设备兼容噩梦

痛点

你的 VPS 只部署了 ECC 证书,Chrome 用户秒开,但客户用 Windows 7 + IE 11 访问时,直接报 "ERR_SSL_VERSION_OR_CIPHER_MISMATCH"。你不得不保留 RSA 证书,却没法让 Nginx 根据客户端能力自动切。手动改配置?太 low。Nginx 官方模块不支持按 TLS 版本分流,那我用 Lua 帮你焊个智能路由。

核心思路

在 Nginx 的 ssl 握手阶段(SSL handshake)用 lua-resty-core 捕获客户端 hello 中的 cipher suitessignature algorithms。若支持 ECDSA 且 TLS 1.2+,则选择 ECC 证书;否则 fallback 到 RSA 证书。整个过程零中断、零性能损耗(利用共享内存缓存会话)。

前置条件

  • OpenResty 或 Nginx + ngx_lua_module(建议 1.19+)
  • lua-resty-core 和 lua-resty-lrucache(安装:opm get openresty/lua-resty-core
  • 两个证书文件:/etc/ssl/vps_ecc.pem(ECC)、/etc/ssl/vps_rsa.pem(RSA),以及对应私钥

实战配置

1. Nginx 主配置

# /etc/nginx/nginx.conf
http {
    lua_package_path "/usr/local/openresty/lualib/?.lua;;";
    lua_shared_dict cert_cache 1m;  # 缓存证书选择结果

    # 加载 Lua 阶段处理
    init_by_lua_block {
        local resty_core = require "resty.core"
        resty_core.init()
    }

    server {
        listen 443 ssl;
        server_name example.com;

        # 初始占位证书,实际会被 lua 替换
        ssl_certificate     /etc/ssl/vps_rsa.pem;
        ssl_certificate_key /etc/ssl/vps_rsa.key;

        # 开启 lua ssl 回调
        ssl_certificate_by_lua_block {
            local resty_ssl = require "resty.ssl"
            local sni_host = ngx.var.ssl_server_name  -- 如果是单域名,固定即可

            -- 优先读缓存(基于 sni)
            local cache = ngx.shared.cert_cache
            local cert_key = cache:get(sni_host)
            if cert_key then
                resty_ssl.set_cert_and_key(cert_key, "/etc/ssl/" .. cert_key .. ".key")
                return
            end

            -- 获取客户端 hello 中的签名算法信息(仅支持 lua-resty-core 0.1.17+)
            local ok, sig_algs = pcall(resty_ssl.get_client_hello_signature_algorithms)
            if not ok or not sig_algs then
                -- 无法获取,默认用 RSA
                resty_ssl.set_cert_and_key("/etc/ssl/vps_rsa.pem", "/etc/ssl/vps_rsa.key")
                cache:set(sni_host, "vps_rsa", 3600)
                return
            end

            -- 检查是否包含 ECDSA 签名算法(OID 1.2.840.10045.4.3.2 = ecdsa_secp256r1_sha256)
            local has_ecdsa = false
            for _, alg in ipairs(sig_algs) do
                if alg == 0x0403 or alg == 0x0804 then  -- ecdsa_secp256r1_sha256 / ecdsa_secp384r1_sha384
                    has_ecdsa = true
                    break
                end
            end

            if has_ecdsa then
                resty_ssl.set_cert_and_key("/etc/ssl/vps_ecc.pem", "/etc/ssl/vps_ecc.key")
                cache:set(sni_host, "vps_ecc", 3600)
            else
                resty_ssl.set_cert_and_key("/etc/ssl/vps_rsa.pem", "/etc/ssl/vps_rsa.key")
                cache:set(sni_host, "vps_rsa", 3600)
            end
        }

        # 其他 SSL 参数(TLS 1.3 优先)
        ssl_protocols TLSv1.2 TLSv1.3;
        ssl_ciphers ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
        ssl_prefer_server_ciphers on;

        # ... 业务配置
    }
}

2. 验证效果

用 openssl 模拟不同客户端:

# 模拟不支持 ECC 的老客户端(只提供 RSA 签名算法)
openssl s_client -connect example.com:443 -cipher 'RSA' -tls1_2 -servername example.com < /dev/null 2>/dev/null | grep -i "server certificate"

# 输出应指向 RSA 证书
subject=CN = example-rsa

# 模拟现代客户端
openssl s_client -connect example.com:443 -cipher 'ECDHE-ECDSA-AES256-GCM-SHA384' -tls1_3 -servername example.com < /dev/null 2>/dev/null | grep -i "server certificate"

# 输出应指向 ECC 证书
subject=CN = example-ecc

坑与排雷

  • 共享内存必须大于 1M:每个 SNI 缓存一条记录,M 级足够,但别设 64k 会爆。
  • 防火墙放行 80 端口:如果 VPS 上同时用轻云互联的弹性公网 IP,需确保 /etc/letsencrypt 目录可写,acme.sh 申请证书时走 HTTP-01。
  • ECC 私钥权限:务必 chmod 600 /etc/ssl/vps_ecc.key,否则 Nginx 启动时报错。
  • lua-resty-core 版本get_client_hello_signature_algorithms 函数在 0.1.17 后才出现,升级到最新。

性能表现

在轻云互联的云服务器(2核4G,CPU支持AES-NI)上测试,TLS 1.3 完整握手耗时 12ms(ECC),RSA fallback 场景约 18ms。相比单证书方案,无额外延迟(因缓存命中率接近100%,且 ssl_certificate_by_lua 在握手阶段执行,不阻塞业务)。

扩展思考

同理,你还可以基于 get_client_hello_ciphers 判断是否支持 CHACHA20-POLY1305,动态选择不同密钥长度的证书。但签名算法是最稳妥的判定依据。
如果你嫌 Lua 折腾,轻云互联 VPS 支持一键部署 Caddy + 自动双证书,不过那就少了折腾的乐趣了。