为什么个人站长也需要一套自己的 CA
绝大多数 HTTPS 靠 Let's Encrypt 就够了:浏览器信任,成本为零。但有一类场景它解决不了——服务与服务之间的认证。比如你的 Nginx 要确认来访的确实是自己的后端应用,而不是被人伪造的请求;又比如内网的 Redis、MQ、API 网关之间要互相确认身份。这时候需要的是双向认证(mTLS),而 mTLS 的前提,是你自己签发一套证书,也就是自建 CA。
自建 CA 的核心价值有三个:第一,不依赖公网信任链,内网机器不需要能访问外网 CA 就能验证;第二,签发权在你手里,可以随时签发、吊销、轮换,不受第三方证书有效期与 OV/EV 规则的约束;第三,可以做客户端证书,让"证书即身份"成为一道强于密码和 API Key 的防线。本文从零搭一套根 CA + 中间 CA,签发服务端与客户端证书,并在 Nginx 上落地 mTLS,最后讲清楚吊销与轮换这两个最容易漏掉的运维环节。
第一层:用 openssl 搭根 CA
证书体系是一棵树:根 CA(Root CA)自签,用它签发中间 CA(Intermediate CA),中间 CA 再签发最终的服务端和客户端证书。为什么要加一层中间 CA?因为根的私钥最珍贵,只应该用来签中间证书,平时离线保存;日常签发都在中间 CA 层做。一旦中间 CA 出问题,吊销它重签一套即可,根不用动。
先准备目录结构与根 CA 的配置文件:
mkdir -p /etc/pki/CA/{private,newcerts,crl}
chmod 700 /etc/pki/CA/private
touch /etc/pki/CA/index.txt
echo 1000 > /etc/pki/CA/serial
echo 1000 > /etc/pki/CA/crlnumber生成根的私钥(4096 位,务必加密并离线保存)与自签根证书(有效期十年):
openssl genrsa -aes256 -out /etc/pki/CA/private/ca.key.pem 4096
chmod 400 /etc/pki/CA/private/ca.key.pem
openssl req -config /etc/pki/CA/openssl.cnf -key /etc/pki/CA/private/ca.key.pem \
-new -x509 -days 3650 -sha256 -extensions v3_ca \
-subj "/C=CN/O=MyOrg/CN=MyOrg Root CA" \
-out /etc/pki/CA/certs/ca.cert.pem关键在 -extensions v3_ca:根证书必须带上 basicConstraints=critical,CA:TRUE,否则它签出来的证书会被链条校验拒绝。这是自建 CA 最常见的第一个坑——用默认的 req -x509 不带扩展,签出来的根看似能签子证书,但浏览器和 openssl verify 都不认。
第二层:中间 CA
中间 CA 的私钥同样要保护,生成后用根 CA 签发请求:
openssl genrsa -aes256 -out /etc/pki/CA/private/intermediate.key.pem 4096
openssl req -config /etc/pki/CA/openssl.cnf -new -sha256 \
-key /etc/pki/CA/private/intermediate.key.pem \
-subj "/C=CN/O=MyOrg/CN=MyOrg Intermediate CA" \
-out /etc/pki/CA/intermediate.csr.pem
openssl ca -config /etc/pki/CA/openssl.cnf -extensions v3_intermediate_ca \
-days 1825 -notext -md sha256 \
-in /etc/pki/CA/intermediate.csr.pem \
-out /etc/pki/CA/certs/intermediate.cert.pem签完后立刻验证链条是否成立,这一步能提前发现 90% 的配置错误:
openssl verify -CAfile /etc/pki/CA/certs/ca.cert.pem \
/etc/pki/CA/certs/intermediate.cert.pem
# 期望输出:intermediate.cert.pem: OK如果这里报 unable to get issuer certificate 或 error 24 at 1 depth lookup: invalid CA certificate,几乎一定是根或中间证书缺了 CA:TRUE 扩展。先修好链条,再往下走,千万不要带着坏链条去签服务证书——越往后越难排查。
签发服务端证书
服务端证书需要 subjectAltName(SAN),现代浏览器和客户端已经不再信任 Common Name,只看 SAN。给一个内部 API 服务签发:
cat > /etc/pki/CA/san.cnf <<'EOF'
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
[req_distinguished_name]
[v3_req]
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = @alt_names
[alt_names]
DNS.1 = api.internal.example.com
DNS.2 = internal.example.com
IP.1 = 10.0.0.10
EOF
openssl genrsa -out /etc/pki/CA/private/api.key.pem 2048
openssl req -new -key /etc/pki/CA/private/api.key.pem \
-subj "/C=CN/O=MyOrg/CN=api.internal.example.com" \
-out /etc/pki/CA/api.csr.pem
openssl ca -config /etc/pki/CA/openssl.cnf -extensions server_cert \
-days 825 -notext -md sha256 \
-extfile /etc/pki/CA/san.cnf -extensions v3_req \
-in /etc/pki/CA/api.csr.pem \
-out /etc/pki/CA/certs/api.cert.pem注意 -extfile 这一步。很多人用 openssl ca 签完发现证书里没有 SAN,原因是扩展定义在单独的 san.cnf 里但没有通过 -extfile 传进去。没有 SAN 的证书,Chrome 会直接报 NET::ERR_CERT_COMMON_NAME_INVALID。签发完用一条命令验证:
openssl x509 -in /etc/pki/CA/certs/api.cert.pem -noout -text | grep -A1 "Subject Alternative Name"客户端证书与 mTLS 落地
客户端证书与上面流程一致,只是 extendedKeyUsage = clientAuth,而且可以只带一个 CN 作身份标识(比如 CN=backend-app-01)。生成后,Nginx 侧打开客户端验证:
server {
listen 443 ssl;
server_name api.internal.example.com;
ssl_certificate /etc/pki/certs/api.cert.pem;
ssl_certificate_key /etc/pki/private/api.key.pem;
ssl_client_certificate /etc/pki/certs/ca-chain.cert.pem;
ssl_verify_client on;
ssl_verify_depth 2;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_set_header X-Client-Verify $ssl_client_verify;
}
}四个配置项各有分工:ssl_client_certificate 指定用于验证客户端证书的 CA 链(注意这里放的是 CA 证书,不是服务端证书);ssl_verify_client on 强制要求客户端出示证书,缺证书直接握手失败;ssl_verify_depth 2 允许"根→中间→叶子"两级链条,如果你的链更深就要调大;$ssl_client_s_dn 把客户端证书的完整主题透传给后端,应用层可以据此做授权。
用 curl 测试时,客户端要同时带上自己的证书和私钥:
curl --cert /etc/pki/certs/client.cert.pem \
--key /etc/pki/private/client.key.pem \
--cacert /etc/pki/certs/ca-chain.cert.pem \
https://api.internal.example.com/health如果报 SSL certificate problem: unable to get local issuer certificate,说明 CA 链文件不完整——浏览器和 curl 都要求服务端把中间证书一并下发。正确做法是把中间证书和服务端证书拼成一个 fullchain 再配给 ssl_certificate:
cat certs/api.cert.pem certs/intermediate.cert.pem > certs/api-fullchain.cert.pem吊销:CRL 与 OCSP 的现实取舍
证书丢了、私钥泄露了怎么办?必须能吊销。自建 CA 通常用 CRL(证书吊销列表):把某个证书标记吊销,生成一份 CRL 文件,服务端在 ssl_crl 里引用它:
openssl ca -config /etc/pki/CA/openssl.cnf -revoke /etc/pki/CA/certs/client.cert.pem
openssl ca -config /etc/pki/CA/openssl.cnf -gencrl -out /etc/pki/CA/crl/ca.crl.pem配上 ssl_crl /etc/pki/CA/crl/ca.crl.pem; 后 reload 生效。但这里有个现实问题:CRL 是快照,会过期。如果你生成一次 CRL 就不管了,默认有效期过后校验会变成"无法判断吊销状态",在 ssl_verify_client on 下可能直接握手失败。解决方案是用 cron 定期重新生成 CRL,并保证 CRL 有效期长于刷新周期:
# 每天凌晨重新生成 CRL,keepalive 30 天有效期
0 3 * * * openssl ca -config /etc/pki/CA/openssl.cnf -gencrl \
-crldays 30 -out /etc/pki/CA/crl/ca.crl.pem && nginx -s reloadOCSP 更实时,但自建 OCSP responder 维护成本高,对个人站长来说 CRL 加定时刷新已经够用。真正大规模场景才值得上 OCSP,或者干脆走短有效期证书 + 频繁轮换的路线——把"吊销"问题转成"过期"问题。
自建 CA 的信任边界
在动手之前,先想清楚一个常被忽略的问题:这套 CA 的信任范围到底划到哪里。自建 CA 的根证书如果被导入到某台机器的系统信任库,那么这台机器就会信任由你签发的任何证书——包括你不希望它信任的。因此正确的做法是:只把根证书导入到确实需要验证内部服务的机器上,绝不导入到员工个人电脑的浏览器,更不要对外分发。
另一个边界是用途隔离。假设你有两套业务(比如一个对内 API、一个开发测试环境),用一个根 CA 就够,但应该用两个不同的中间 CA 分别签发。这样万一开发环境的中间 CA 私钥泄露,你只需吊销那个中间证书,生产链路完全不受影响。这就是中间层存在的真正意义——它不仅是"保护根私钥",更是"故障与泄露的隔离带"。
还有一点关于私钥的存储。自建 CA 的根私钥一旦泄露,攻击者就能伪造任意服务端和客户端证书,等于整套信任崩塌。所以根私钥必须加密存储(-aes256)并离线保管,理想状态是放在不联网的机器或硬件令牌里,只在签发中间证书时才拿上来用一次。日常签发的中间 CA 私钥可以用文件权限保护,但也要设 chmod 400 并限制属主。这三个边界——导入范围、用途隔离、私钥保管——决定了自建 CA 是"安全资产"还是"安全隐患"。
证书轮换:别等过期那天才发现
自建 CA 最大的运维风险不是技术难度,而是遗忘。公网证书有 Let's Encrypt 自动续期和浏览器到期警告兜底,自建证书什么都没有——它只会在某个凌晨静默过期,然后你的服务间通信全线 502。
建立三个习惯:其一,所有证书有效期写进监控,用 openssl 读出 notAfter 并接入 Uptime Kuma 或一个简单的告警脚本;其二,客户端证书有效期设短(90 天),强迫自己走轮换流程,反而比一个三年的证书更安全;其三,轮换脚本化,把"生成私钥→CSR→签发→部署→reload"写成一条命令,让轮换从"大工程"变成"日常动作"。
openssl x509 -in /etc/pki/certs/client.cert.pem -noout -enddate
# notAfter=Jan 15 08:00:00 2027 GMT真实复盘:一次 mTLS 上线后的"随机 502"
说一个我踩过的坑。给内网 API 加 mTLS 后,白天一切正常,可每到流量高峰就零星报 502,Nginx error.log 里是 client SSL certificate verify error: (20:unable to get local issuer certificate)。一开始怀疑是客户端证书配置错误,反复重签无效。
后来才定位到真正原因:我在 Nginx 里配的是 ssl_client_certificate 指向中间 CA 证书,但客户端出示的是"客户端证书 + 中间证书"的链条,Nginx 拿中间 CA 去验证时,链条深度判断出现了不一致,部分连接在握手高并发时校验失败。根因是 CA 链文件不完整——我把 ssl_client_certificate 改成了"根 + 中间"的完整链文件后,问题彻底消失。
这个案例的教训很具体:mTLS 的调试比单向 HTTPS 复杂,因为失败发生在握手层,应用日志里看不到;要养成先看 nginx -t、再用 openssl s_client -connect ... -cert ... -key ... -CAfile ... 手工模拟握手的习惯。命令行能握通的配置,Nginx 才可能稳定工作。
openssl s_client -connect api.internal.example.com:443 \
-cert client.cert.pem -key client.key.pem \
-CAfile ca-chain.cert.pem -state -debug 2>&1 | head -40总结
自建 CA 加 mTLS,本质是把"信任"从公网第三方收回自己手里。四层结构要记牢:根 CA(离线、十年)→ 中间 CA(日常签发)→ 服务端证书(带 SAN)→ 客户端证书(带 clientAuth 扩展)。落地时的三个高频坑分别是:根证书缺 CA:TRUE 导致整链失效、服务端证书缺 SAN 或不传 fullchain 导致客户端不认、CRL 过期导致校验异常。而真正的长期风险永远在运维纪律上——有效期监控、短周期轮换、脚本化签发,这三件事做到,自建 CA 才能既安全又省心。对于个人站长,这套东西的价值不在于炫技,而在于当你后端的服务越来越多时,有一把只属于自己内网的、随时可签发可吊销的"身份钥匙"。