上周帮朋友把一个内网服务从HTTP切到HTTPS,Nginx配置半小时就搞定了,结果卡在证书信任那步。浏览器一直提示“您的连接不是私密连接”,就算手动点了“继续前往”,页面里的Service Worker、WebRTC、部分API照样被浏览器拦掉。排查了一圈,问题根源不在Nginx,而在证书的可信链——自签名证书签名者自己就是自己,浏览器根本不认这套。
今天写一篇完整实操记录,覆盖从生成证书、配置Nginx到让浏览器彻底信任证书的完整链路。适合两类人:一类是内网环境、开发测试环境、个人NAS、家庭实验室想上HTTPS但不想花钱买证书的朋友;另一类是已经在用自签名证书、但一直被浏览器警告困扰,想彻底解决的朋友。我会把选型思路、每一步的命令、参数为什么这么写、以及踩过的坑全部说清楚。
1. 证书方案怎么选:不是只有Let's Encrypt一条路
1.1 三类证书的适用场景
一提到SSL证书,很多人第一反应是Let's Encrypt或者阿里云免费证书。确实,如果你有公网域名、80端口和443端口都能被外网访问,用免费证书是最省事的——安装一个certbot客户端就能自动申请和续期,浏览器天然信任,根本不用操心下面的信任问题。
但实际工作里,大量场景是拿不到公网证书的:
- 内网IP地址(比如192.168.x.x、10.x.x.x),正规CA不会给纯IP签公网证书,只能签IP证书,而且条件苛刻。
- 公司内网域名,比如
gitlab.internal.corp这种,公网DNS根本解析不了,CA验证域名所有权时无从下手。 - 离线环境、隔离网段、测试环境,没有外网连通性,申请公网证书的流程走不通。
- 设备自带的管理界面(路由器、NAS、摄像头),厂商也没法为每个用户的私有IP签证书。
这些场景下,选择只有两个:自签名证书,或者自建本地CA。
1.2 为什么我推荐自建本地CA
先说结论:能用自建CA就别用纯自签名证书。
这两个的区别,类比一下就是“私刻公章”和“自建发证机构”的关系。自签名证书是一张只有一个公章的文件,谁都能刻,所以浏览器默认不信;自建CA是你自己开了一家“公证处”,浏览器虽然不认这家公证处,但你可以把这家公证处的“营业执照”(根证书)装进系统信任区,装完之后,由这家公证处签发的所有服务器证书,浏览器都会信任。
用自建CA还有一个隐藏优势:你只导一次根证书,之后给任意多台服务器签证书,浏览器全都认。而纯自签名证书每次换一台机器都要重新导入,机器一多就是灾难。
所以我的经验是:一次性搭建本地CA,以后签证书只是敲三条命令的事。下面第二节就讲怎么搭。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零生成一套证书:根CA、服务器证书与SAN扩展
2.1 环境准备与OpenSSL版本确认
生成证书用OpenSSL。Linux和macOS自带,Windows可以装Git Bash或WSL后用。先确认版本:
bash复制openssl version
如果是OpenSSL 3.x(比如3.0.x、3.3.x),命令和参数上和老版本有些差别,但本文用的命令在1.1.1和3.x下都能跑。需要注意:所有私钥生成命令中我的写法都用genpkey而不是老式的genrsa,因为OpenSSL 3.0里genrsa会打印一条关于“不安全”的底层API提示,虽然不影响结果,但看着别扭。
2.2 生成根CA
第一步,生成根CA的私钥,并设置文件权限:
bash复制mkdir -p ~/myca
cd ~/myca
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ca.key
chmod 600 ca.key
密钥长度选2048位,这是当前性价比最高的选择。4096位更安全但性能开销大,而很多CA/B Forum规则已经慢慢转向强制密钥长度,2048在很长一段时间内仍是合规最低标准,内网环境完全够用。
第二步,生成根CA证书:
bash复制openssl req -new -x509 -days 3650 -key ca.key -out ca.crt \
-subj "/C=CN/ST=Beijing/L=Beijing/O=MyLocalCA/CN=MyLocalCA Root CA" \
-sha256
解释几个关键点:
-x509表示直接生成自签名证书,不是生成CSR。-days 3650根证书有效期10年。根证书是自己导入信任区的,有效期长一点省心。CN=MyLocalCA Root CA是根CA的名字,会被显示在系统信任列表里,建议起一个一眼能认出来的名字。-sha256重要,这直接关系到热搜词里提到的弱哈希算法问题,后面第五节专门说。
2.3 生成服务器证书并写入SAN
这是全流程里最容易出错的环节。生成服务器证书需要三个步骤:生成私钥、生成CSR、用CA签发。
先生成私钥并生成CSR:
bash复制openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out server.key
chmod 600 server.key
openssl req -new -key server.key -out server.csr \
-subj "/C=CN/ST=Beijing/L=Beijing/O=MyLocal/CN=192.168.1.100"
这里的CN填什么?如果服务器有域名填域名(如test.local),如果没有域名只有IP,直接填IP。但注意光填CN没用,现代浏览器早就不看CN了,看的是证书里的subjectAltName(SAN,主体备用名称)字段。Chrome 58之后、Firefox 48之后,如果一个证书没有SAN扩展,或者SAN里没有用户访问的域名/IP,那浏览器一律报ERR_CERT_COMMON_NAME_INVALID或NET::ERR_CERT_AUTHORITY_INVALID。
所以关键步骤来了:创建一个扩展配置文件,把SAN写进去。
bash复制cat > server_ext.cnf <<'EOF'
basicConstraints=CA:FALSE
keyUsage=digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=IP:192.168.1.100,DNS:test.local,DNS:localhost
EOF
SAN里要包含所有你将来会用到的访问方式。比如既要内网IP访问,又要域名访问,那就都写上;还可能访问https://localhost调试,那就把localhost也加进去。字段用逗号分隔,IP用IP:前缀,域名用DNS:前缀。
然后用根CA签发服务器证书:
bash复制openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out server.crt -days 825 \
-extfile server_ext.cnf -sha256
-CAcreateserial会自动生成一个序列号文件,用来防止证书重号。- 有效期为什么是825天?因为Apple对证书有效期有严格限制,新签发证书超过825天会被强制拒绝信任。虽然这是给公共CA定的规则,但自建CA签的证书如果还想在iPhone、Mac上被信任,最好也遵守。保守一点直接用398天,因为CA/B Forum已经要求所有公共CA新签证书不超过398天,自建CA按这个标准执行就永远不会踩雷。
签发完成后,检查一下证书内容:
bash复制openssl x509 -in server.crt -noout -text | grep -A1 "Subject Alternative Name"
openssl x509 -in server.crt -noout -text | grep -A1 "Signature Algorithm"
确认SAN和签名算法都正确。我吃过一次亏:签发的时候忘了加-extfile参数,导致证书里完全没有SAN,结果浏览器把所有主流浏览器全报了一遍错。
2.4 顺手处理CVE-2005-4900弱哈希告警
热搜词里有一个很典型的问题:“SSL证书使用了弱hash算法(CVE-2005-4900)怎么修复”。
这个漏洞的名字听着吓人,其实就是证书的签名哈希算法用了SHA-1。SHA-1早在2017年就被谷歌等公司宣告实质破解,所以现代浏览器和系统都不再信任SHA-1签名的证书。当你用老版本的OpenSSL或不加-sha256参数时,生成的证书默认可能是SHA-1,导入Nginx后浏览器就会提示证书使用了弱哈希算法。
修复思路分两步。
第一步,生成证书时显式指定-sha256。我在上面的命令里都已经加了,包括根证书和服务器证书。如果你的CA证书已经是SHA-1签名的,需要重新生成根证书并重新导入系统信任区。
第二步,检查现有证书的签名算法。
bash复制openssl x509 -in server.crt -noout -text | grep "Signature Algorithm"
输出应该是sha256WithRSAEncryption,如果看到sha1WithRSAEncryption,就说明证书不合格,需要重新签发。
还有一个隐蔽细节:Nginx配置里如果ssl_ciphers包含MD5之类的弱套件,也可能被安全扫描工具标记为弱加密。建议配置里直接用HIGH:!aNULL:!MD5,把不安全的散列算法全部排除。
3. Nginx接入SSL:配置项逐行拆解
3.1 最小可用配置
证书准备好后,把它放到Nginx能读到的地方。我习惯放到/etc/nginx/ssl/下,方便集中管理。
最小配置是这样的:
nginx复制server {
listen 443 ssl;
server_name 192.168.1.100 test.local;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
ssl_trusted_certificate /etc/nginx/ssl/ca.crt;
root /var/www/html;
index index.html;
}
这里三个ssl开头的指令要弄清楚含义:
ssl_certificate:服务器证书文件。如果证书链有多级(服务器证书+中间证书),必须把链上的证书按顺序拼接在这个文件里。ssl_certificate_key:服务器私钥。ssl_trusted_certificate:根CA证书,用于设置OCSP stapling时把链发给客户端验证。
如果用根CA直接签发服务器证书(本文的做法),证书链只有一层,server.crt里只放服务器证书即可。如果你用了中间CA,需要把server.crt和中间证书拼在一起,方法是把两个PEM文件按顺序合并:
bash复制cat server.crt intermediate.crt > fullchain.crt
然后ssl_certificate指向合并后的fullchain.crt。
配置完先测试再加载:
bash复制nginx -t
systemctl reload nginx
3.2 HTTP自动跳转HTTPS
配置完成后,访问HTTP端口时用户会得到一个404或者Nginx默认页面,体验很割裂。应该在80端口上做301跳转,把HTTP流量全部指向HTTPS:
nginx复制server {
listen 80;
server_name 192.168.1.100 test.local;
return 301 https://$host$request_uri;
}
$host$request_uri保留了原始域名和路径,这样用户访问http://192.168.1.100/api时会自动跳到https://192.168.1.100/api,不会因为跳转丢失URL参数。
有一个细节:如果网站用了WebSocket或某些API会发送跨域请求,跳转时301和302的效果有差异。301是永久重定向,浏览器会缓存;302是临时重定向。一般情况下用301没问题,但如果你的服务还在频繁调整路径,改成return 302更不容易被浏览器缓存干扰。
3.3 协议版本、套件与会话缓存
生产环境光有基础配置还不够,通常还要把协议版本和套件收紧。下面的配置是我的常用模板:
nginx复制server {
listen 443 ssl;
server_name 192.168.1.100 test.local;
ssl_certificate /etc/nginx/ssl/server.crt;
ssl_certificate_key /etc/nginx/ssl/server.key;
ssl_trusted_certificate /etc/nginx/ssl/ca.crt;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
root /var/www/html;
index index.html;
}
简单说下每一项的取舍:
ssl_protocols TLSv1.2 TLSv1.3:TLSv1和TLSv1.1已经是老掉牙的协议,主流浏览器都默认禁用了,留着反而会拉低安全性评分。ssl_ciphers HIGH:!aNULL:!MD5:HIGH表示只启用高强度的加密套件,!aNULL禁用匿名加密(防止中间人),!MD5禁用MD5散列算法。这个表达简单有效,适合大多数场景。ssl_session_cache shared:SSL:10m:启用SSL会话缓存。10m的内存大概能存几万个会话,对于内网规模完全够用。ssl_session_timeout 10m:会话超时时间。在超时时间内,同一个浏览器再次连接时可以复用之前的握手结果,大幅减少TLS握手延迟。
配置修改后:
bash复制nginx -t && systemctl reload nginx
然后直接用curl验证:
bash复制curl -v https://192.168.1.100 --cacert ~/myca/ca.crt
这里--cacert指定根CA证书,curl就能验证证书链是否有效并正常访问。输出里出现SSL certificate verify ok,说明Nginx端的证书链配置正确。
4. 让浏览器真正信任证书:本地CA的导入与验证
4.1 浏览器报“不安全”的真正原因
浏览器为什么死活不信任你的证书?这要从信任链说起。
访问HTTPS网站时,浏览器会做三件事:第一,检查证书是否过期;第二,检查证书里的域名/IP和访问的地址是否一致;第三,检查证书的签发者是否在“受信任的根证书颁发机构”列表里。
你的证书是自建CA签发的,这个CA不在浏览器的信任列表里,所以第三项直接失败,浏览器立刻报警。解决思路就是把这个自建CA的根证书装进系统的信任区。装完之后,浏览器看到“这个证书是由某个受信任根颁发的”,整条链就顺了。
4.2 根CA导入系统信任区(Windows/macOS/Linux)
导入的对象是根CA证书ca.crt,不是服务器证书server.crt。这是个很容易搞混的点,很多人在服务器上把server.crt导入了还是报错,就是因为没搞清楚这个。
Windows系统:
双击ca.crt,选择“安装证书”,存储位置选“本地计算机”,然后选择“将所有证书都放入下列存储”,浏览找到“受信任的根证书颁发机构”,确认即可。
导入后可以打开certmgr.msc,展开“受信任的根证书颁发机构”->“证书”,搜索“MyLocalCA”,能看到就说明导入成功。
macOS系统:
双击ca.crt打开“钥匙串访问”,选择“登录”钥匙串,然后在证书上右键点“显示简介”,展开“信任”,把“使用此证书时”改为“始终信任”。
macOS有个坑:如果只导入到“登录”钥匙串,只对当前用户生效,Chrome和Safari不一定认;导入到“系统”钥匙串才是全局信任。所以更稳妥的做法是双击后选择“系统”钥匙串,然后再修改信任设置。
Linux系统(Debian/Ubuntu):
bash复制sudo cp ca.crt /usr/local/share/ca-certificates/myca.crt
sudo update-ca-certificates
Red Hat系(CentOS/RHEL/Fedora)命令略有不同:
bash复制sudo cp ca.crt /etc/pki/ca-trust/source/anchors/
sudo update-ca-trust
导入之后,连浏览器自身也要处理:Chrome系浏览器(Chrome/Edge/Brave)使用系统信任区,导入系统根后就生效;Firefox使用自己的信任库,需要在设置里单独导入。 Firefox“设置”里搜“证书”,点“查看证书”->“证书颁发机构”->“导入”,选ca.crt,然后勾选“信任由此证书颁发机构标识的网站”。
这个“Firefox单独导入”的细节特别容易漏。因为我习惯用Chrome,导入系统后Chrome正常了,结果测试Firefox时又报了一遍不安全,排查了好久才发现Firefox根本不看系统信任区。
4.3 导入后如何验证真的生效
导入完成后,重启一下浏览器(不是刷新页面,是完全退出再启动,因为信任区是启动时加载的),再访问HTTPS地址,地址栏应该出现小锁图标。
如果还报错,用下面这个命令检查浏览器实际收到证书链:
bash复制openssl s_client -connect 192.168.1.100:443 -CAfile ~/myca/ca.crt -servername 192.168.1.100
看到输出末尾有Verify return code: 0 (ok),说明证书链没问题,那问题一定出在浏览器侧的信任区导入环节。
还有一个检测思路:把刚才openssl s_client的输出复制到https://whatsmychaincert.com/这类在线工具里,会告诉你证书链是否完整。内网环境如果访问不了外网,就靠本地openssl命令验证。
5. 证书相关报错的排查手册
5.1 常见浏览器报错含义对照
证书类报错五花八门,大多数时候不是证书本身坏了,而是几个固定原因。遇到报错先看提示对应的是哪一类,再针对性排查。
| 报错信息 | 含义 | 常见原因 |
|---|---|---|
| NET::ERR_CERT_AUTHORITY_INVALID | 证书签发者不被信任 | 根CA没导入系统信任区,或导入的是服务器证书而非根CA |
| NET::ERR_CERT_COMMON_NAME_INVALID | 证书域名不匹配 | SAN里没写访问用的IP/域名 |
| NET::ERR_CERT_DATE_INVALID | 证书过期或未生效 | 系统时间不对,或证书有效期已过 |
| SSL certificate problem: self-signed certificate | 自签名证书不被信任 | 用了纯自签名而不是自建CA,或根CA未导入 |
| SSL_ERROR_NO_CYPHER_OVERLAP | 加密套件不匹配 | 客户端太老,或ssl_ciphers配置太严格 |
| ssl_certificate contains a weak hash algorithm | SHA-1签名告警 | 证书签名算法为SHA-1,需要用-sha256重新生成 |
我的排查顺序是:先看证书有没有过期,再看SAN匹配不匹配,最后才看信任链。因为SAN的坑是最隐蔽的——证书状态完全正常,签名算法也正确,但就是报域名不匹配,往往就是SAN里少写了。
5.2 用OpenSSL命令行定位证书链问题
遇到奇怪的HTTPS报错,用浏览器控制台的“安全”面板看有限,不如直接命令行验证。
完整的诊断流程:
bash复制# 查看Nginx发送的证书链
echo | openssl s_client -connect 192.168.1.100:443 -servername 192.168.1.100 2>/dev/null | openssl x509 -noout -subject -issuer -dates
# 查看Nginx发送的完整证书链(包含中间证书)
echo | openssl s_client -connect 192.168.1.100:443 -servername 192.168.1.100 -showcerts 2>/dev/null
# 指定根CA来验证信任链
echo | openssl s_client -connect 192.168.1.100:443 -CAfile ~/myca/ca.crt -verify_return_error 2>/dev/null | grep "Verify return code"
-verify_return_error会强制让验证失败时返回非0退出码,方便写脚本自动检测。最后一个命令输出Verify return code: 0 (ok),才说明链路是干净通畅的。
如果这里验证通过,但浏览器还报错,基本可以断定是浏览器侧的信任问题,去检查系统信任区。
5.3 证书续期的三个注意点
自签名证书和自建CA证书到期后,都要手动续期。续期不是直接重新生成证书完事,有几点要留意:
-
续期不能改根CA。 根CA的有效期很长(我这里设了10年),服务器证书到期后只需要重新走一遍生成CSR和签发的流程,用同一个根CA签发新证书。只要你之前把根CA导入过系统信任区,新证书签发后直接替换
server.crt和server.key即可,不用再动系统信任区。 -
替换证书后必须reload,不是restart。 执行
nginx -s reload,让Nginx平滑加载新证书。粗暴restart会断开所有正在处理的连接,生产环境影响业务。 -
到期前一周做一次演练。 我吃过一次亏:服务器证书悄无声息地过期,用户访问才报警,排查了半天才发现是证书过期。后来我写了一个简单的定时任务,每周检查一次证书有效期:
bash复制#!/bin/bash
CERT=/etc/nginx/ssl/server.crt
DAYS=$(openssl x509 -in "$CERT" -noout -enddate | cut -d= -f2)
END_EPOCH=$(date -d "$DAYS" +%s)
NOW_EPOCH=$(date +%s)
LEFT=$(( (END_EPOCH - NOW_EPOCH) / 86400 ))
if [ "$LEFT" -lt 14 ]; then
echo "证书还剩 $LEFT 天到期,请及时续期" | mail -s "SSL证书到期提醒" admin@example.com
fi
这个脚本放到crontab里每周跑一次,基本不会错过续期窗口。
我在实际使用中还有一个小习惯,每次签发完证书都会执行一下openssl x509 -in server.crt -noout -text,不看别的,只看Not Before和Not After两个时间字段。证书有效期格式是UTC时间,经常容易换算错,尤其是服务器时区设置和本地不一致时,很容易签出一张“未来生效”的证书。那次折腾了半个小时的经历让我明白了:证书这块,简单就是王道,每一步验证都是给自己省时间。
