nginx 前面的 TLS 和后面的转发头,是两个最容易被"配了就行"糊弄过去的地方。现象往往不是报错,而是:浏览器提示证书不属于这个域名、应用拿到的客户端 IP 全是 127.0.0.1、跳转链接变成 http://、上传大文件直接 413。这篇按握手、证书、转发、超时的顺序拆。
1. TLS 握手时,证书是怎么被选中的
客户端在 ClientHello 里带 SNI(要访问的主机名),nginx 拿它和所有 server_name 比对,选中最具体的那个 server 块,用该块的证书应答。比对不到任何 server_name 时,用的是默认 server——也就是该 listen 端口上第一个出现的 server 块,或者显式标了 default_server 的那个。
于是"没配 server_name"的结果是:访问 A 域名却收到 B 域名的证书。
# 兜底块:吞掉所有未知 SNI,避免串证书
server {
listen 443 ssl default_server;
server_name _;
ssl_certificate /etc/nginx/certs/default.crt;
ssl_certificate_key /etc/nginx/certs/default.key;
return 444;
}
| 客户端 SNI | 匹配到的块 | 返回的证书 |
|---|---|---|
与某个 server_name 精确相同 |
该块 | 该块证书,正确 |
命中通配 *.example.com |
通配块 | 该块证书 |
| 无匹配 | default_server 或首个 server |
默认块的证书,常见串证书 |
| 客户端不发 SNI(老客户端、IP 直连) | 同上 | 同上 |
return 444 是 nginx 的"直接断连",比返回 404 更干净,也避免默认块被扫到内容。
2. 证书链:只配叶证书会缺中间证书
一张服务器证书背后是一条链:叶证书 → 中间证书 → 根证书。浏览器信任根,但中间证书必须由服务器一起下发。只配叶证书时,桌面浏览器可能靠缓存或 AIA 抓取蒙对,手机 App、curl、Java 客户端往往直接失败。
# 把叶证书与中间证书按顺序拼接成 fullchain
$ cat server.crt intermediate.crt > fullchain.crt
$ nginx -T 2>/dev/null | grep -E 'ssl_certificate'
$ openssl s_client -connect example.com:443 -servername example.com </dev/null 2>/dev/null | grep -E 'Verify return code|depth'
Verify return code: 0 (ok) 才是完整;出现 unable to get local issuer certificate 就是缺中间证书。签发方通常直接给 fullchain.pem,配置里用它,不要用 cert.pem。
| 配置 | 下发内容 | 典型表现 |
|---|---|---|
cert.pem(叶) |
仅叶证书 | 桌面浏览器偶发正常,移动端/命令行链验证失败 |
fullchain.pem |
叶 + 中间 | 正常 |
3. listen 443 ssl 与 http2 的位置
ssl 写在 listen 上,表示这个端口按 TLS 处理;ssl_certificate / ssl_certificate_key 是 server 级指令,写在 http 或 server 里都合法,写在 server 里更清晰。
http2 的位置在 1.25 有明确变化:1.25.1 起 listen 443 ssl http2 这种写法被标记为废弃,推荐独立的 http2 on; 指令。按 nginx 1.25 核对版本行为,两个写法在过渡期都还能跑,但会打 warning。
server {
listen 443 ssl; # 1.25:不要在这里写 http2
http2 on; # 独立指令
server_name example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
}
http2 on 只在 server 级生效,写 http 级不生效;ssl_protocols 则可以放 http 级做全局默认。
4. 现代 TLS 配置的取舍
| 项 | 建议 | 取舍点 |
|---|---|---|
ssl_protocols |
TLSv1.2 TLSv1.3 |
留 1.1 是为极老客户端,代价是合规与套件范围变宽 |
ssl_ciphers |
1.3 套件由 OpenSSL 定,1.2 侧留 ECDHE+AESGCM | 过窄会挡掉旧 Android;过宽会引入 CBC |
ssl_prefer_server_ciphers |
on |
仅在 1.2 有效,1.3 由客户端定序 |
ssl_session_cache |
shared:SSL:10m |
复用会话省一次完整握手,10m 约对应数万会话 |
ssl_session_timeout |
1h |
太长会让吊销信息滞后生效 |
ssl_stapling |
on + ssl_stapling_verify on |
需 ssl_trusted_certificate 指向链,否则不生效 |
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1h;
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/certs/chain.pem;
stapling 配了不生效时先看错误日志:多数是 ssl_trusted_certificate 缺失或链不完整,拿不到响应就静默跳过。
5. 转发头:Host、X-Real-IP、X-Forwarded-For
应用在反代后面,REMOTE_ADDR 永远是 nginx 的地址。要让应用知道真实来源,得靠头。
| 头 | 语义 | 常见误用 |
|---|---|---|
Host |
原始请求的主机名 | 不传则应用按 localhost:8080 拼跳转链接 |
X-Real-IP |
单个客户端 IP,nginx 直接赋值 | 以为它是链路,其实是覆盖写 |
X-Forwarded-For |
IP 列表,追加语义 | 把入站值当可信导致伪造 |
X-Forwarded-Proto |
原始协议 | 不传则应用生成 http:// 绝对地址 |
location /api/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
$proxy_add_x_forwarded_for 等于"入站的 X-Forwarded-For + , + $remote_addr"。所以这个头可以被客户端自带:直接请求时带 X-Forwarded-For: 1.2.3.4,nginx 会把 1.2.3.4 原样留在最左边。若应用取最左值做鉴权或限流,等于让任何人伪造来源 IP。
正确做法是明确信任边界:只信任你自己那层代理加进来的值,取从右往左第一个非可信地址。前面还有 Cloudflare 之类的 CDN 时,链会更长,需要按实际跳数取值,而不是一律取最左。
# 有 CDN 时:先信任 CDN 段,再用 real_ip 模块还原
set_real_ip_from 173.245.48.0/20;
real_ip_header CF-Connecting-IP;
real_ip_recursive on;
Spring 侧要显式打开:
server:
forward-headers-strategy: framework # 或 native
native 让容器(Tomcat)自己处理 X-Forwarded-*;framework 由 Spring 的 ForwardedHeaderFilter 处理。不配这一项,request.getScheme() 仍是 http,RedirectView 生成的绝对地址会掉协议。
6. proxy_pass 带不带 URI,路径改写完全不同
| 写法 | 请求 /api/users/1 转发到 |
规则 |
|---|---|---|
proxy_pass http://127.0.0.1:8080; |
/api/users/1 |
不带 URI,原样透传 |
proxy_pass http://127.0.0.1:8080/; |
/users/1 |
带 /,替换掉 location 匹配的前缀 |
proxy_pass http://127.0.0.1:8080/v2/; |
/v2/users/1 |
带 URI,前缀被替换 |
关键在 location /api/ 里的斜杠:location /api/ 配 proxy_pass .../ 会剥掉 /api,配 proxy_pass 不带斜杠会保留。这一对斜杠是"接口 404"最常见的来源,改之前先用 curl -v 看实际请求行。
7. 上传体积与超时三件套
默认 client_max_body_size 1m,超过直接 413,请求体根本不进应用。
| 指令 | 管的阶段 | 典型值 |
|---|---|---|
client_max_body_size |
请求体大小 | 上传接口按业务定,如 50m |
proxy_connect_timeout |
与上游建连 | 5s,内网可更短 |
proxy_read_timeout |
两次读之间的间隔 | 60s;长任务需放大 |
proxy_send_timeout |
两次写之间的间隔 | 60s |
proxy_buffering |
是否缓冲上游响应 | SSE/流式必须 off |
注意 proxy_read_timeout 是两次读操作之间的间隔,不是整个请求的总时长。一个持续有数据输出的流式接口,60s 足够;一个 5 分钟才出第一个字节的同步任务,60s 一定断。
location /api/stream/ {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
client_max_body_size 50m;
}
proxy_http_version 1.1 与清空 Connection 是 SSE 的必要条件:默认的 HTTP/1.0 上游连接会在响应结束后关闭,事件流随之断掉。
8. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 浏览器提示证书属于别的域名 | 该 SNI 没匹配到 server_name,走了默认块 |
补 server_name,加 default_server 兜底 |
curl 报链验证失败、浏览器正常 |
只配了叶证书 | 改用 fullchain.pem |
应用里客户端 IP 全是 127.0.0.1 |
没传 X-Real-IP / X-Forwarded-For |
补 proxy_set_header,应用侧读对应头 |
登录后跳转变成 http:// |
缺 X-Forwarded-Proto 或 Spring 未开 forward-headers |
传 $scheme,配 forward-headers-strategy |
| 接口 404,上游收到少一段路径 | proxy_pass 末尾斜杠与 location 不匹配 |
按第 6 节表核对,curl -v 看请求行 |
| 上传大文件 413 | client_max_body_size 默认 1m |
在对应 location 或 server 放大 |
| SSE 几分钟后断开 | 缓冲未关、上游用 HTTP/1.0、读超时太短 | proxy_buffering off + 1.1 + 放大读超时 |
| 改完配置没生效 | 只 reload 没 -t,或改的文件不是 -T 里显示的那个 |
先 nginx -t,再 nginx -T 核对实际加载内容 |
最后一条值得单说:nginx -t 通过了再 reload 是硬规矩;而"改了没生效"多数不是 reload 的问题,是改的文件没被 include——nginx -T 会把最终合并后的配置全量打印,比对着看比猜快得多。
9. 边界
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 证书自动化续期 | ACME 客户端 + reload 钩子 |
手工续期必然忘记 |
| 证书过期监控 | 外部探针按天 openssl s_client |
nginx 自己不会告警 |
| 多域名大量站点 | 一张多 SAN 证书或统一通配 | 减少 SNI 命中错块的概率 |
| 需要 L7 路由与重试策略 | Ingress Controller / 服务网格 | nginx 原生重试语义有限 |
10. 自测
- 客户端不带 SNI 时,nginx 用哪个
server块的证书应答? - 为什么"桌面浏览器正常、手机 App 报证书错误"通常指向证书链不完整?
- nginx 1.25 里
http2应该写在哪一级?写在http级会怎样? $proxy_add_x_forwarded_for的追加语义为什么会让来源 IP 可被伪造?location /api/配proxy_pass http://up/;与不带斜杠,转发后的路径差在哪?- SSE 接口除了关
proxy_buffering,还需要补哪两项?