目录

Nginx-10 HTTPS 与 TLS 配置

前置阅读:Nginx-09 限流限连与安全防护网络-16 HTTPS 与 TLS网络-17 TLS 1.3

TLS 协议本身在网络系列讲过,这一篇专注于Nginx 侧的配置和性能优化

1. 最小可用配置

server {
    listen 443 ssl;
    http2 on;                # 1.25.1+ 的新写法,老版本是 listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/nginx/ssl/example.com/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/example.com/privkey.pem;

    location / {
        root /var/www/dist;
    }
}

# HTTP 跳转到 HTTPS
server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

1.1 证书链必须完整

ssl_certificate 要用完整链(fullchain),即「服务器证书 + 中间 CA 证书」按顺序拼接,根证书不需要(客户端本地已有)。

fullchain.pem 的内容:
-----BEGIN CERTIFICATE-----
(你的服务器证书)
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
(中间 CA 证书,可能有多个)
-----END CERTIFICATE-----

只放服务器证书的后果:桌面浏览器可能正常(它们会尝试自动补全中间证书,或者本地缓存过),但移动端和某些客户端会报证书错误。这是最典型的「我这里能访问,用户说不行」的场景。

验证证书链:

# 看链是否完整
openssl s_client -connect example.com:443 -servername example.com -showcerts < /dev/null

# 更直接的方式
echo | openssl s_client -connect example.com:443 -servername example.com 2>&1 \
     | grep -E "Verify return code|verify error"
# Verify return code: 0 (ok)     ← 正常
# verify error:num=20:unable to get local issuer certificate   ← 缺中间证书

# 检查本地证书文件里有几张证书
grep -c "BEGIN CERTIFICATE" /etc/nginx/ssl/fullchain.pem
# 应该 ≥ 2

# 确认私钥和证书匹配(两个 md5 必须相同)
openssl x509 -noout -modulus -in cert.pem   | openssl md5
openssl rsa  -noout -modulus -in privkey.pem | openssl md5

# 看证书信息和有效期
openssl x509 -in cert.pem -noout -text | head -20
openssl x509 -in cert.pem -noout -dates

线上验证推荐 SSL Labs,能给出 A+ 到 F 的评级和详细问题清单。

2. 协议与加密套件

2.1 推荐配置(兼顾安全与兼容)

http {
    # 只用 TLS 1.2 和 1.3。
    # TLS 1.0/1.1 已被所有主流浏览器弃用,且不符合 PCI DSS 要求
    ssl_protocols TLSv1.2 TLSv1.3;

    # TLS 1.2 的套件(TLS 1.3 的套件由 OpenSSL 内部决定,这里配了也不影响)
    # 只保留 ECDHE(前向保密)+ AEAD(GCM/CHACHA20)
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;

    # TLS 1.3 下让客户端选(客户端更了解自己的硬件加速能力)
    # TLS 1.2 下建议 on,让服务端按 ssl_ciphers 的顺序选
    ssl_prefer_server_ciphers off;

    # ECDH 曲线。X25519 最快且安全,放第一位
    ssl_ecdh_curve X25519:prime256v1:secp384r1;
}

2.2 为什么这样选

只留 ECDHE:ECDHE 提供前向保密(Forward Secrecy)——每次握手生成临时密钥,即使服务器私钥泄露,攻击者也无法解密之前抓到的流量。静态 RSA 密钥交换没有这个性质。

只留 AEAD 套件(GCM、CHACHA20-POLY1305):CBC 模式的套件有一系列已知攻击(BEAST、Lucky13、POODLE),RC4 已被完全破解。

ssl_prefer_server_ciphers off 在 TLS 1.3 下是官方推荐:客户端知道自己有没有 AES-NI 硬件加速。有 AES-NI 的设备(现代 x86/ARM)用 AES-GCM 更快;没有的(老手机、嵌入式)用 ChaCha20-Poly1305 更快。让客户端选比服务端猜要好。

X25519 曲线prime256v1(P-256)快约 20-30%,且实现上不易受时序攻击。

2.3 Mozilla 的三档配置

不想自己纠结的话,直接用 Mozilla 的 SSL Configuration Generator 生成:

档位 兼容性 协议
Modern Firefox 63+、Chrome 70+、iOS 12.2+ 只 TLS 1.3
Intermediate Firefox 27+、Chrome 31+、iOS 9+、Android 4.4+ TLS 1.2 + 1.3
Old 支持到 IE8、Android 2.3 TLS 1.0-1.3

绝大多数场景选 Intermediate。Modern 只支持 TLS 1.3,会挡掉一些还在用的老设备。

3. TLS 性能优化

TLS 握手是 Nginx 上最昂贵的单项操作。一次完整握手涉及非对称加密运算(RSA 签名或 ECDSA 签名),比整个 HTTP 请求处理都贵。

各种优化的收益量级:

优化项 收益
会话复用(session cache/ticket) 省掉整个非对称运算,收益最大
ECDSA 证书替代 RSA 握手 CPU 降 3-5 倍
TLS 1.3 握手往返从 2-RTT 降到 1-RTT
OCSP Stapling 省掉客户端到 OCSP 服务器的一次往返
HTTP/2 复用连接,减少握手次数
TLS 1.3 Early Data 0-RTT,但有重放风险

3.1 会话复用

这是最重要的优化。原理是让客户端复用之前协商好的主密钥,跳过非对称运算。

两种机制:

Session Cache(服务端存储)

http {
    # shared:名字:大小 —— 所有 worker 共享
    # 1MB 约存 4000 个会话
    ssl_session_cache shared:SSL:50m;

    # 会话有效期。默认只有 5 分钟,太短了,建议 1 天
    ssl_session_timeout 1d;
}

ssl_session_cache 必须用 shared 而不是 builtinbuiltin 是每个 worker 独立的 OpenSSL 内部缓存,客户端第二次请求落到另一个 worker 上就复用不了。多 worker 下 builtin 的命中率极低。

# ❌ 常见错误:命中率很低
ssl_session_cache builtin:1000;

# ❌ 更糟:默认值 none,完全没有复用
# (不写 ssl_session_cache 就是 none)

# ✅
ssl_session_cache shared:SSL:50m;

Session Ticket(客户端存储)

ssl_session_tickets on;                              # 默认 on
ssl_session_ticket_key /etc/nginx/ssl/ticket1.key;    # 当前 key(第一个用于加密)
ssl_session_ticket_key /etc/nginx/ssl/ticket2.key;    # 旧 key(只用于解密)

服务端用一个对称密钥加密会话状态,交给客户端保存在 ticket 里。服务端不用存任何东西,天然支持多机共享(只要 key 一样)。

多台 Nginx 必须共享 ticket key,否则客户端的 ticket 在另一台机器上解不开:

# 生成 ticket key
openssl rand 80 > /etc/nginx/ssl/ticket1.key
chmod 600 /etc/nginx/ssl/ticket1.key

# 分发到所有 Nginx 节点
for host in nginx-1 nginx-2 nginx-3; do
    scp /etc/nginx/ssl/ticket1.key $host:/etc/nginx/ssl/
done

Ticket key 必须定期轮换(建议每天)。因为 ticket 里的会话密钥是用它加密的,key 长期不换等于削弱了前向保密——攻击者拿到 key 就能解密所有用它加密过的会话。

轮换脚本:

#!/bin/bash
# rotate-ticket-key.sh,每天凌晨跑
cd /etc/nginx/ssl
mv ticket2.key ticket3.key 2>/dev/null
mv ticket1.key ticket2.key
openssl rand 80 > ticket1.key
chmod 600 ticket1.key
rm -f ticket3.key
nginx -s reload

配置里保留两个 key:新 key 用于加密新会话,旧 key 仍能解密上一周期的 ticket,实现平滑过渡。

验证会话复用是否生效

# 用 -reconnect 测试复用
openssl s_client -connect example.com:443 -servername example.com -reconnect < /dev/null 2>&1 \
    | grep -E "Reused|New"
# 第一次: New, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256
# 后续:   Reused, TLSv1.3, Cipher is TLS_AES_128_GCM_SHA256   ← 复用成功

# 分别测两种机制
openssl s_client -connect example.com:443 -no_ticket -reconnect < /dev/null 2>&1 | grep -c Reused
# 禁用 ticket,测 session cache

# 看 ticket 是否返回
openssl s_client -connect example.com:443 < /dev/null 2>&1 | grep -A2 "TLS session ticket"

3.2 ECDSA 证书

RSA 2048 的签名运算比 ECDSA P-256 慢 3-5 倍。换成 ECDSA 能显著降低握手 CPU。

参考数据(单核):

证书类型 握手/秒
RSA 2048 1000-2000
RSA 4096 200-400
ECDSA P-256 5000-8000

RSA 4096 是常见的过度配置:安全性提升有限(2048 位到 2030 年前都够用),但性能损失 5 倍。除了合规要求,不要用 4096。

双证书配置(ECDSA + RSA 兼容老客户端)

Nginx 1.11.0+ 支持同时配置多张证书,会根据客户端的能力自动选择:

server {
    listen 443 ssl;
    http2 on;

    # ECDSA(现代客户端优先用,更快)
    ssl_certificate     /etc/nginx/ssl/ecdsa/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/ecdsa/privkey.pem;

    # RSA(老客户端降级用)
    ssl_certificate     /etc/nginx/ssl/rsa/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/rsa/privkey.pem;

    # 套件顺序里 ECDSA 在前,让支持的客户端优先选它
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:...;
}

选择逻辑在 TLS 握手时完成:客户端在 ClientHello 里声明支持的签名算法,Nginx 挑一张匹配的证书。支持 ECDSA 的客户端走 ECDSA(快),只支持 RSA 的走 RSA(兼容),两头都占到。

生成 ECDSA 证书:

# 自签测试用
openssl ecparam -genkey -name prime256v1 -out ecdsa.key
openssl req -new -x509 -key ecdsa.key -out ecdsa.crt -days 365 \
    -subj "/CN=example.com"

# Let's Encrypt 签发 ECDSA
certbot certonly --key-type ecdsa --elliptic-curve secp256r1 -d example.com

3.3 OCSP Stapling

浏览器需要验证证书没被吊销。默认做法是浏览器自己去访问 CA 的 OCSP 服务器——这多了一次 DNS 解析 + TCP 连接 + HTTP 请求,可能增加几百毫秒延迟,而且泄露了用户访问了哪个网站。

OCSP Stapling 让 Nginx 预先向 CA 查询好吊销状态,把签名的响应「订」(staple)在 TLS 握手里发给客户端。

http {
    ssl_stapling on;
    ssl_stapling_verify on;

    # 用于验证 OCSP 响应签名的证书链(通常是中间 CA + 根 CA)
    # 如果 ssl_certificate 里已包含完整链,某些情况下可以省略
    ssl_trusted_certificate /etc/nginx/ssl/chain.pem;

    # Nginx 需要解析 OCSP 服务器的域名,必须配 resolver
    resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
    resolver_timeout 5s;
}

resolver 必须配,否则 Nginx 无法解析 OCSP 服务器地址,Stapling 静默失效。这是最常见的配置遗漏。

验证:

openssl s_client -connect example.com:443 -servername example.com -status < /dev/null 2>&1 \
    | grep -A 20 "OCSP response"

# 成功的输出:
# OCSP Response Status: successful (0x0)
# Cert Status: good

# 失败的输出:
# OCSP response: no response sent

注意 Stapling 的首次请求:Nginx 启动后第一个请求时还没有 OCSP 响应(要等它异步去查),所以第一次验证可能显示 “no response sent”。等几秒再试。

Nginx 1.27.4+ 引入了 ssl_certificate_cache 和更好的 OCSP 预取机制,可以避免这个冷启动问题。

3.4 TLS 1.3 与 0-RTT

ssl_protocols TLSv1.2 TLSv1.3;

# 0-RTT(Early Data):客户端在握手完成前就发送应用数据
ssl_early_data on;

# 开启后必须处理重放风险
proxy_set_header Early-Data $ssl_early_data;

TLS 1.3 的改进:

  • 握手从 2-RTT 降到 1-RTT:省一个往返,跨国访问能省 100-300ms
  • 会话恢复 0-RTT:客户端直接带数据,零往返

0-RTT 的重放攻击风险:Early Data 里的请求可以被攻击者截获并重复发送,服务端无法区分。所以:

  • 只能用于幂等请求(GET/HEAD)
  • 非幂等请求必须在应用层拒绝或做幂等处理

Nginx 会设置 $ssl_early_data 变量(值为 1 表示这个请求来自 Early Data),把它传给后端让业务层判断:

// Go 后端
if r.Header.Get("Early-Data") == "1" && r.Method != http.MethodGet {
    // RFC 8470 定义的状态码:要求客户端重发(不用 Early Data)
    w.WriteHeader(http.StatusTooEarly)  // 425
    return
}

保守建议:除非确实需要极致的首字节延迟,不要开 ssl_early_data 收益(省 1-RTT)和引入的复杂度、风险相比不划算。

3.5 减少握手次数

# 客户端长连接开长一些,减少重新握手
keepalive_timeout  75s;
keepalive_requests 1000;

# TLS 记录大小。
# 默认 16KB 意味着要凑够 16KB 才能解密第一个字节,影响首字节延迟。
# 4KB 让浏览器更快开始渲染,代价是稍多的协议开销。
ssl_buffer_size 4k;

ssl_buffer_size 的权衡:

  • 大(16k):吞吐更高,适合大文件下载
  • 小(4k):首字节延迟更低,适合网页

网页服务用 4k,文件下载服务用 16k

4. HSTS 与安全增强

server {
    listen 443 ssl;

    # HSTS:强制浏览器在指定时间内只用 HTTPS 访问本站
    # max-age=63072000 是 2 年(提交到 preload list 的要求)
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
}

HSTS 的作用:浏览器记住这个站点只能用 HTTPS,即使用户输入 http:// 也会在本地直接改成 https,不发出明文请求。这挡住了 SSL 剥离攻击(攻击者在中间把 HTTPS 降级成 HTTP)。

上线 HSTS 的注意事项

  1. includeSubDomains 会影响所有子域名。如果有子域名还在用 HTTP(比如内部系统),加了这个参数后浏览器会拒绝访问它们。
  2. max-age 从小开始。先设 300(5 分钟)观察一周,确认所有子域名和路径的 HTTPS 都正常,再逐步加大到 2 年。
  3. preload 是不可逆的。提交到 hstspreload.org 后,HSTS 规则会内置到浏览器里,移除要等好几个月的浏览器版本迭代。确认长期用 HTTPS 再提交。

HSTS 不要加在 HTTP 的 server 块上——HTTP 响应里的 HSTS 头会被浏览器忽略(因为明文可被篡改),只在 HTTPS 响应里有效。

5. Let’s Encrypt 自动化

5.1 首次申请

# 装 certbot
apt install certbot python3-certbot-nginx

# 方式一:certbot 自动改 Nginx 配置(快速上手)
certbot --nginx -d example.com -d www.example.com

# 方式二:只签发证书,自己管配置(推荐,配置可控)
certbot certonly --webroot -w /var/www/html \
    -d example.com -d www.example.com \
    --key-type ecdsa

# 方式三:DNS 验证(泛域名证书必须用这个)
certbot certonly --manual --preferred-challenges dns \
    -d "*.example.com" -d example.com

5.2 Nginx 配合 webroot 验证

# snippets/letsencrypt.conf
location ^~ /.well-known/acme-challenge/ {
    root /var/www/html;
    default_type "text/plain";
    allow all;
    # 注意:不能被上面的 "location ~ /\." 规则拦住
}
server {
    listen 80;
    server_name example.com www.example.com;

    include snippets/letsencrypt.conf;    # 必须在跳转规则之前

    location / {
        return 301 https://$host$request_uri;
    }
}

顺序很重要:ACME 验证路径的 location 必须在 return 301 之前,否则验证请求会被跳转到 HTTPS,而此时证书还没签发,验证失败。用 ^~ 也能保证优先级。

另外注意常见的「屏蔽隐藏文件」规则会拦掉 ACME 验证:

# ⚠️ 这条规则会拦掉 /.well-known/acme-challenge/
location ~ /\. {
    deny all;
}

# ✅ 排除 .well-known
location ~ /\.(?!well-known) {
    deny all;
}

5.3 自动续期

# certbot 装好后会自动创建 systemd timer
systemctl status certbot.timer
systemctl list-timers | grep certbot

# 手动测试续期流程(不真的续)
certbot renew --dry-run

# 续期成功后 reload nginx
# 写在 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'EOF'
#!/bin/bash
nginx -t && nginx -s reload
EOF
chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

证书过期是最常见的线上事故之一。一定要加监控:

#!/bin/bash
# check-cert-expiry.sh —— 加到 crontab 每天跑
DOMAINS="example.com api.example.com www.example.com"
THRESHOLD=15    # 15 天内过期就告警

for d in $DOMAINS; do
    end=$(echo | openssl s_client -connect "$d:443" -servername "$d" 2>/dev/null \
          | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2)
    if [ -z "$end" ]; then
        echo "ERROR: cannot fetch cert for $d"
        continue
    fi
    end_ts=$(date -d "$end" +%s)
    now_ts=$(date +%s)
    days=$(( (end_ts - now_ts) / 86400 ))

    if [ "$days" -lt "$THRESHOLD" ]; then
        echo "WARNING: $d expires in $days days ($end)"
        # 这里接告警:企业微信/钉钉/PagerDuty
    else
        echo "OK: $d expires in $days days"
    fi
done

5.4 acme.sh(更轻量的替代)

curl https://get.acme.sh | sh
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt

# 签发(支持各种 DNS API 自动验证)
export Ali_Key="xxx"
export Ali_Secret="yyy"
~/.acme.sh/acme.sh --issue --dns dns_ali -d example.com -d "*.example.com" \
    --keylength ec-256

# 安装到指定位置并自动 reload
~/.acme.sh/acme.sh --install-cert -d example.com --ecc \
    --key-file       /etc/nginx/ssl/example.com/privkey.pem \
    --fullchain-file /etc/nginx/ssl/example.com/fullchain.pem \
    --reloadcmd      "nginx -t && nginx -s reload"

acme.sh 是纯 shell 脚本,无 Python 依赖,DNS API 支持比 certbot 全(几十家 DNS 服务商),泛域名证书自动续期更方便。

6. HTTP/2

server {
    listen 443 ssl;
    http2 on;                             # 1.25.1+ 的写法

    # 1.25.1 之前:listen 443 ssl http2;

    # 单连接最大并发 stream 数
    http2_max_concurrent_streams 128;

    # HTTP/2 连接的空闲超时
    keepalive_timeout 75s;

    # 1.19.7 之前有 http2_push,现在已移除
    # (Server Push 实践效果差,浏览器已全部弃用,改用 103 Early Hints)
}

6.1 HTTP/2 的收益与注意点

收益

  • 多路复用:一个 TCP 连接跑所有请求,消灭了 HTTP/1.1 的队头阻塞和 6 连接限制
  • 头部压缩(HPACK):重复的 header 只传一次索引
  • 二进制分帧:解析更高效
  • 服务端可以设置 stream 优先级

注意点

  1. 域名分片(domain sharding)反成负优化。HTTP/1.1 时代把资源拆到多个域名以突破浏览器的 6 连接限制;HTTP/2 下这会造成多次 TLS 握手,应该合并回一个域名。

  2. CSS Sprite 和文件合并的收益变小。HTTP/2 下多个小请求的开销很低,过度合并反而影响缓存粒度(改一个图标要重新下载整张雪碧图)。

  3. TCP 层的队头阻塞仍然存在。HTTP/2 解决了应用层的队头阻塞,但一个 TCP 包丢了,整个连接上所有 stream 都要等重传。这正是 HTTP/3 用 QUIC(基于 UDP)要解决的问题。

  4. limit_conn 效果变弱。一个 HTTP/2 连接能并发几百个请求,靠限连接数挡不住,必须用 limit_req + http2_max_concurrent_streams

  5. Server Push 已死。所有主流浏览器都移除了支持,Nginx 1.25.1 也删掉了 http2_push。替代方案是 103 Early Hints(Nginx 目前需要 Lua 或等官方支持)。

6.2 验证 HTTP/2

curl -I --http2 https://example.com
# HTTP/2 200        ← 生效

# 看 ALPN 协商
openssl s_client -connect example.com:443 -alpn h2 < /dev/null 2>&1 | grep ALPN
# ALPN protocol: h2

# nghttp 详细查看帧
nghttp -nv https://example.com

7. HTTP/3 与 QUIC

Nginx 1.25.0+ 正式支持 HTTP/3(需要用支持 QUIC 的 SSL 库编译,如 BoringSSL 或 OpenSSL 3.2+ / quictls)。

./configure --with-http_v3_module --with-openssl=../quictls ...
server {
    # HTTP/3 走 UDP 443
    listen 443 quic reuseport;
    # 同时保留 HTTP/2 走 TCP 443(降级用)
    listen 443 ssl;
    http2 on;

    server_name example.com;

    ssl_certificate     /etc/nginx/ssl/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/privkey.pem;

    # QUIC 要求 TLS 1.3
    ssl_protocols TLSv1.3;

    # 告诉客户端「我支持 HTTP/3,下次用 UDP 443 来」
    add_header Alt-Svc 'h3=":443"; ma=86400' always;

    # QUIC 地址验证(防放大攻击),建议开
    quic_retry on;

    # QUIC 的 GSO 优化(需要内核支持)
    quic_gso on;
}

别忘了防火墙放开 UDP 443

ufw allow 443/udp
# 或
iptables -A INPUT -p udp --dport 443 -j ACCEPT

7.1 HTTP/3 的价值

QUIC 基于 UDP,解决了 TCP 的固有问题:

问题 TCP + HTTP/2 QUIC + HTTP/3
队头阻塞 一个包丢了阻塞所有 stream 每个 stream 独立,互不影响
握手 TCP 3 次握手 + TLS 1.3 1-RTT = 2-RTT 合并成 1-RTT,恢复时 0-RTT
连接迁移 IP 变了连接就断(WiFi 切 4G) Connection ID 不变,连接保持
拥塞控制 内核实现,升级慢 用户态实现,可快速迭代

收益最明显的场景是弱网和移动端:丢包率高时 HTTP/3 的优势非常大;地铁里 WiFi 切蜂窝网络时连接不断。

代价

  • CPU 开销比 TCP 高(加解密在用户态、没有网卡硬件卸载)
  • 部分企业防火墙和运营商会阻断 UDP 443
  • 生态还在成熟

建议:面向公网的 C 端服务值得开(浏览器会自动降级,风险低);纯内网服务没必要。

7.2 验证 HTTP/3

# curl 需要用支持 HTTP/3 的版本编译
curl -I --http3 https://example.com

# 或者用在线工具
# https://http3check.net/

8. 多域名与 SNI

8.1 SNI 的作用

一个 IP 上托管多个 HTTPS 站点,需要在 TLS 握手时就知道客户端要访问哪个域名(因为要选对应的证书)。SNI(Server Name Indication) 就是 ClientHello 里的一个扩展,明文携带目标域名。

# 三个域名,三张证书,共用一个 IP 和 443 端口
server {
    listen 443 ssl;
    server_name a.example.com;
    ssl_certificate     /etc/nginx/ssl/a/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/a/privkey.pem;
}

server {
    listen 443 ssl;
    server_name b.example.com;
    ssl_certificate     /etc/nginx/ssl/b/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/b/privkey.pem;
}

# 必须有兜底!否则不带 SNI 的客户端会拿到第一个 server 的证书
server {
    listen 443 ssl default_server;
    server_name _;
    ssl_certificate     /etc/nginx/ssl/default/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/default/privkey.pem;
    ssl_reject_handshake on;    # 1.19.4+:直接拒绝握手,不用配假证书
    return 444;
}

ssl_reject_handshake on 是 1.19.4 引入的好东西:不需要配一张假证书就能拒绝未知 SNI 的握手。之前必须放一张自签证书占位。

8.2 用变量动态加载证书

证书特别多(几千个域名,比如 SaaS 平台的自定义域名)时,写几千个 server 块不现实:

server {
    listen 443 ssl;
    server_name ~^(?<domain>.+)$;

    # 按域名动态查找证书文件
    ssl_certificate     /etc/nginx/ssl/$domain/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/$domain/privkey.pem;

    # 1.27.4+:缓存证书,避免每次握手都读磁盘
    ssl_certificate_cache max=1000 inactive=20s valid=1m;
}

注意性能:用变量后每次握手都要读磁盘文件(1.27.4 之前没有缓存)。域名多的话建议用 OpenResty 的 ssl_certificate_by_lua_block 从共享内存或 Redis 取:

ssl_certificate_by_lua_block {
    local ssl = require "ngx.ssl"
    local host = ssl.server_name()
    -- 从共享内存/Redis 取证书,支持热更新,无需 reload
    local cert, key = get_cert_from_cache(host)
    ssl.clear_certs()
    ssl.set_der_cert(cert)
    ssl.set_der_priv_key(key)
}

9. mTLS(双向认证)

客户端也要出示证书,服务端验证。用于内部服务间调用、API 开放平台、金融级安全要求。

server {
    listen 443 ssl;
    server_name api.internal.com;

    ssl_certificate     /etc/nginx/ssl/server/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/server/privkey.pem;

    # ---------- 客户端证书验证 ----------
    ssl_client_certificate /etc/nginx/ssl/ca.crt;   # 签发客户端证书的 CA
    ssl_verify_client on;                            # on / off / optional / optional_no_ca
    ssl_verify_depth 2;                              # 证书链验证深度

    # 吊销列表(客户端证书泄露后要能吊销)
    ssl_crl /etc/nginx/ssl/client.crl;

    location / {
        # 把客户端证书信息传给后端做细粒度授权
        proxy_set_header X-Client-DN     $ssl_client_s_dn;
        proxy_set_header X-Client-Serial $ssl_client_serial;
        proxy_set_header X-Client-Verify $ssl_client_verify;
        proxy_set_header X-Client-Fingerprint $ssl_client_fingerprint;
        proxy_pass http://backend;
    }
}

ssl_verify_client 的四个值:

行为
on 必须提供有效客户端证书,否则拒绝连接
off 不要求(默认)
optional 可选,但如果提供了就必须有效;结果在 $ssl_client_verify
optional_no_ca 可选,且不验证签发 CA(由后端应用自己验证)

optional 模式很实用:内网证书免密、外网走 Token

ssl_verify_client optional;

location / {
    # 有有效客户端证书的直接放行
    if ($ssl_client_verify = SUCCESS) {
        proxy_pass http://backend;
        break;
    }
    # 没有的走正常鉴权
    auth_request /auth;
    proxy_pass http://backend;
}

9.1 生成测试用的客户端证书

# 1. 生成 CA
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
    -keyout ca.key -out ca.crt -subj "/CN=Internal CA"

# 2. 生成客户端私钥和 CSR
openssl req -newkey rsa:2048 -nodes \
    -keyout client.key -out client.csr -subj "/CN=service-a/O=MyOrg"

# 3. 用 CA 签发客户端证书
openssl x509 -req -in client.csr -CA ca.crt -CAkey ca.key \
    -CAcreateserial -days 365 -out client.crt

# 4. 打包成 p12(浏览器导入用)
openssl pkcs12 -export -in client.crt -inkey client.key \
    -out client.p12 -name "My Client Cert"

# 5. 测试
curl --cert client.crt --key client.key https://api.internal.com/

10. 完整的 TLS 配置片段

# /etc/nginx/snippets/ssl-common.conf
# 在每个 HTTPS server 块里 include 这个文件

ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
ssl_ecdh_curve X25519:prime256v1:secp384r1;

# 会话复用
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
ssl_session_tickets on;
ssl_session_ticket_key /etc/nginx/ssl/ticket1.key;
ssl_session_ticket_key /etc/nginx/ssl/ticket2.key;

# OCSP Stapling
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/chain.pem;

# 缓冲区(网页服务用 4k 降低首字节延迟)
ssl_buffer_size 4k;

# HSTS
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# nginx.conf 的 http 块里配一次
http {
    resolver 1.1.1.1 8.8.8.8 valid=300s ipv6=off;
    resolver_timeout 5s;

    include /etc/nginx/conf.d/*.conf;
}

使用:

server {
    listen 443 ssl reuseport;
    listen [::]:443 ssl;
    http2 on;
    server_name example.com;

    ssl_certificate     /etc/nginx/ssl/example.com/ecdsa/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/example.com/ecdsa/privkey.pem;
    ssl_certificate     /etc/nginx/ssl/example.com/rsa/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/example.com/rsa/privkey.pem;

    include snippets/ssl-common.conf;
    include snippets/security-headers.conf;

    # ... location 配置
}

11. 排查清单

现象 原因 处理
移动端报证书错误,PC 正常 证书链不完整 用 fullchain.pem
SSL_ERROR_NO_CYPHER_OVERLAP 客户端和服务端没有共同的套件 放宽 ssl_ciphers/ssl_protocols
握手 CPU 100% 没开会话复用,或用了 RSA 4096 ssl_session_cache shared,换 ECDSA
SSL Labs 评分低 协议/套件过时,缺 HSTS 用 Mozilla generator 重新生成配置
OCSP Stapling 不生效 没配 resolver resolver
多台机器会话复用率低 ticket key 不一致 同步 ticket key 文件
首字节延迟高 ssl_buffer_size 太大 网页服务设 4k
证书过期导致全站不可用 没有续期监控 加过期监控脚本 + 告警
ACME 验证失败 验证路径被跳转或被 location ~ /\. 拦住 调整 location 顺序和规则
HTTP/3 不生效 防火墙没开 UDP 443,或缺 Alt-Svc 开 UDP,加 Alt-Svc

快速自检脚本:

#!/bin/bash
D=${1:-example.com}
echo "=== 证书链 ==="
echo | openssl s_client -connect "$D:443" -servername "$D" 2>&1 | grep -E "Verify return code"
echo "=== 协议与套件 ==="
echo | openssl s_client -connect "$D:443" -servername "$D" 2>&1 | grep -E "Protocol|Cipher"
echo "=== 会话复用 ==="
openssl s_client -connect "$D:443" -servername "$D" -reconnect < /dev/null 2>&1 | grep -cE "^Reused"
echo "=== OCSP Stapling ==="
echo | openssl s_client -connect "$D:443" -servername "$D" -status 2>&1 | grep -E "OCSP Response Status|no response"
echo "=== HTTP/2 ==="
curl -sI --http2 "https://$D" | head -1
echo "=== 有效期 ==="
echo | openssl s_client -connect "$D:443" -servername "$D" 2>/dev/null | openssl x509 -noout -dates
echo "=== HSTS ==="
curl -sI "https://$D" | grep -i strict-transport

12. 面试题

Q:Nginx 配 HTTPS 时 ssl_certificate 应该放什么?只放服务器证书会怎样?

要放完整链(fullchain):服务器证书 + 中间 CA 证书,按顺序拼接,不需要根证书。只放服务器证书时,桌面浏览器可能因为本地缓存或自动补全而正常显示,但移动端和很多客户端库会报「无法验证签发者」的错误——这是典型的「我这里能访问但用户不行」。

Q:TLS 握手为什么贵?怎么优化?

贵在非对称加密运算(RSA/ECDSA 签名),一次完整握手的 CPU 开销比整个 HTTP 请求处理都大。优化按收益排序:(1) 会话复用(ssl_session_cache shared + ssl_session_tickets),直接跳过非对称运算,收益最大;(2) 用 ECDSA 证书替代 RSA,快 3-5 倍;(3) TLS 1.3,握手从 2-RTT 降到 1-RTT;(4) OCSP Stapling 省掉客户端查询吊销状态的往返;(5) 加长 keepalive 减少握手次数。

Q:ssl_session_cache builtinshared 有什么区别?

builtin 是 OpenSSL 的内部缓存,每个 worker 独立,客户端第二次请求落到另一个 worker 上就复用不了,多 worker 下命中率极低。shared 是 Nginx 的共享内存,所有 worker 共享,命中率高。生产必须用 shared。另外不配这个指令时默认是 none(完全没有复用)。

Q:Session Cache 和 Session Ticket 有什么区别?多台 Nginx 怎么处理?

Session Cache 在服务端存会话状态、给客户端一个 ID;Session Ticket 把加密后的会话状态交给客户端保存,服务端不存任何东西。多台 Nginx 时:Cache 无法跨机共享(除非用第三方模块接 Redis),Ticket 只要各机器的 ssl_session_ticket_key 一致就能跨机复用。所以多机部署要重点保证 ticket key 同步,并定期轮换(保留新旧两个 key 实现平滑过渡)。

Q:为什么 ticket key 要轮换?

Ticket 里的会话密钥是用 ticket key 对称加密的。key 长期不变的话,攻击者一旦拿到它,就能解密所有用这个 key 加密过的历史会话——相当于削弱了 ECDHE 提供的前向保密。建议每天轮换,配置里保留两个 key(新的加密、旧的仍可解密)。

Q:OCSP Stapling 是什么?为什么必须配 resolver

浏览器验证证书是否被吊销时默认要自己访问 CA 的 OCSP 服务器,多一次 DNS + TCP + HTTP 往返(可能几百毫秒),还泄露用户访问了哪个站点。Stapling 让 Nginx 预先查好并把 CA 签名的响应「订」在 TLS 握手里发出去。Nginx 需要解析 OCSP 服务器的域名,所以必须配 resolver——不配的话 Stapling 会静默失效,这是最常见的配置遗漏。

Q:什么是前向保密?Nginx 怎么配置?

前向保密(Forward Secrecy)指即使服务器私钥泄露,攻击者也无法解密之前抓取的流量。实现方式是每次握手用 ECDHE/DHE 协商临时密钥,私钥只用于身份签名而不参与密钥交换。配置上就是 ssl_ciphers 里只保留 ECDHE-DHE- 开头的套件,剔除静态 RSA 密钥交换的套件。

Q:TLS 1.3 的 0-RTT 有什么风险?

Early Data 里的请求可以被攻击者截获后重复发送,服务端无法区分是重放还是正常请求。所以只能用于幂等请求。Nginx 会设置 $ssl_early_data 变量,应该传给后端(Early-Data 头),让业务层对非幂等方法返回 425 Too Early 要求客户端重发。保守做法是不开 ssl_early_data——省 1-RTT 的收益不值得引入这个复杂度。

Q:SNI 解决了什么问题?没有 SNI 会怎样?

TLS 握手时服务端要先选证书,但此时还没收到 HTTP 请求(不知道 Host)。SNI 是 ClientHello 里的扩展,明文携带目标域名,让服务端能选对证书。这使得一个 IP 能托管多个 HTTPS 站点。没有 SNI 的客户端(很老的设备)会拿到 default_server 的证书,导致域名不匹配错误。配 ssl_reject_handshake on(1.19.4+)可以直接拒绝这类握手而不用放一张假证书。

Q:HTTP/2 相比 HTTP/1.1 的改进?还有什么问题没解决?

改进:二进制分帧、多路复用(消灭应用层队头阻塞和浏览器 6 连接限制)、HPACK 头部压缩、stream 优先级。没解决的:TCP 层的队头阻塞——一个 TCP 包丢了,整个连接上所有 stream 都要等重传。这是 HTTP/3 用基于 UDP 的 QUIC 来解决的。另外 HTTP/2 下域名分片和文件过度合并成了负优化,limit_conn 的限制效果也大幅削弱。

Q:HSTS 的 preload 要注意什么?

提交到 hstspreload.org 后,规则会内置进浏览器源码,移除需要等好几个月的浏览器版本迭代,基本不可逆。上线前必须确认:所有子域名(如果加了 includeSubDomains)都已支持 HTTPS、长期不会回退到 HTTP。建议 max-age 从 300 秒开始逐步加大,观察无误再提交 preload。


上一篇:Nginx-09 限流限连与安全防护 | 下一篇:Nginx-11 日志与监控