SSD Nodes Learn
指南 Matt Connor作者: Matt Connor · 更新于 2026-07-19

Ubuntu 自签名证书的正确做法

在 Ubuntu 24.04 上用一条带 SAN 的 openssl 命令签发 Chrome 真正接受的自签名 TLS 证书,配置 nginx 或 Apache,并让客户端正确信任它,无需 curl -k。

您将构建什么

一张现代浏览器和客户端真正接受的自签名 TLS 证书:正确的 subjectAltName、合理的密钥权限、接入 nginx 或 Apache,再加上几乎所有教程都跳过的那一环:让您的客户端正确地信任它,而不是一路点击警告、把 curl -k 永久写死在脚本里。最后是一个只需五条命令的私有证书颁发机构(CA),供某个内部服务从一个变成六个时使用。

先做决定,因为自签名证书作为正确工具的场合,远比它被实际使用的场合要少。如果服务能通过真实的 DNS 名称从公共互联网访问,请停下来,改用 在 nginx 上用 certbot 申请免费的 Let's Encrypt 证书对应的 Apache 版本。它不花钱、会自动续期,而且全世界每一个浏览器都已经信任它。在公开站点上使用自签名证书,会训练您的用户习惯性点击安全警告,这比纯 HTTP 还要糟糕。

当画面里根本没有公共互联网时,自签名才是正确的工具:绑定在 您 VPS 上某个 WireGuard 隧道地址 上的管理面板、私有网络中的预发布机器、后端之间的服务间流量、家庭实验室的设备,或者替换 Webmin 在 10000 端口上为自己生成 的占位证书。反正 Let's Encrypt 也无法为 10.8.0.1git.internal.lan 签发证书:没有任何公共 CA 会把私有 IP 或自造的顶级域名写进证书。对于这些名称,您自己就是 CA。

下面的一切都在一台全新的 Ubuntu 24.04 机器上运行,它自带 OpenSSL 3.0.x(用 openssl version 确认)。这里没有任何步骤需要联网;全部都能在离线(air-gapped)环境下工作。

为什么旧的一行命令生成的证书会被 Chrome 拒绝

每一篇 2017 年之前的教程给您的命令都长这样:

# Do not run this — shown so you recognise it in old guides
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout selfsigned.key -out selfsigned.crt

它会问一连串交互式问题,把您的主机名放进 Common Name(通用名称)字段,并生成一张没有 subjectAltName 扩展的证书。这样的证书一出生就已经死了。Chrome 从 2017 年 4 月的第 58 版起就不再读取 Common Name(RFC 2818 早在 2000 年就已弃用 CN 匹配),Firefox、Safari、curl 和 Python 的行为也都一样。证书要么通过 SAN 扩展标识它的服务器,要么就什么都标识不了,而浏览器会用这样一句话明确告诉您:

NET::ERR_CERT_COMMON_NAME_INVALID

This server could not prove that it is git.internal.lan; its security
certificate does not specify Subject Alternative Names.

再怎么摆弄信任库也修不好这个错误,因为这张证书确实什么名称都没有标识。如果您此刻正盯着 NET::ERR_CERT_COMMON_NAME_INVALID,那说明您的证书没有 SAN(或者 SAN 不对),您需要重新签发一张。好在解决办法只需要一条命令。

用一条命令签发浏览器接受的证书

OpenSSL 在 1.1.1 版中新增了 -addext 标志,这意味着您不再需要旧教程里为注入 SAN 而做的那些配置文件折腾。在 Ubuntu 24.04 上:

sudo openssl req -x509 -newkey rsa:4096 -sha256 -days 730 -noenc \
  -keyout /etc/ssl/private/git.internal.key \
  -out /etc/ssl/certs/git.internal.crt \
  -subj "/CN=git.internal.lan" \
  -addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"

每个标志的作用:

  • -x509 直接输出一张自签名证书,而不是签名请求。
  • -newkey rsa:4096 在同一步里生成一把新密钥。RSA 4096 不会得罪任何老旧客户端;如果连接过来的都是现代客户端,-newkey ec -pkeyopt ec_paramgen_curve:P-256 更小也更快。
  • -noenc 是旧 -nodes 在 OpenSSL 3.x 中的新写法:密钥不加口令。两种写法都有效。带口令的密钥会让 nginx 在每次启动时卡住等待输入,所以对于服务器密钥您需要这个。
  • -days 730,即两年;关于这个数字,过期一节还有更多说明。
  • -subj 在命令行里直接回答那些交互式问题。CN 如今只是装饰,但仍然把它设成主名称;有些工具会显示它。
  • -addext "subjectAltName=..." 是承重的那个标志。把客户端会输入的每一个名称和每一个 IP 都列出来:主机名用 DNS: 条目(像 DNS:*.internal.lan 这样的通配符也可以),地址用 IP: 条目。如果有人会访问 https://10.8.0.1,那么 IP:10.8.0.1 条目就必须在里面:只有 DNS 的 SAN 会让他们再次撞上 NET::ERR_CERT_COMMON_NAME_INVALID

在接入任何东西之前,先确认 SAN 确实写进去了:

openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -ext subjectAltName

正确的输出:

X509v3 Subject Alternative Name:
    DNS:git.internal.lan, IP Address:10.8.0.1

如果它输出的是 No extensions in certificate,说明证书没有 SAN,浏览器会拒绝它,请重新生成,而不要硬着头皮继续。

锁定密钥

一把机器上每个用户都能读的私钥算不上私钥。在 Ubuntu 上,/etc/ssl/private 已经是 710 root:ssl-cert,能挡住随意的窥视,但还是要给文件本身显式设置权限:

sudo chown root:root /etc/ssl/private/git.internal.key
sudo chmod 600 /etc/ssl/private/git.internal.key

nginx 和 Apache 都会在放弃权限之前以 root 身份读取证书,所以 root:root 加 600 权限对它们都适用。如果这把密钥属于某个以自己的用户身份运行、并自行加载密钥的服务,比如 Node 应用、Gitea、Python 守护进程,那就改为把它 chown 给那个服务用户,权限仍然是 600。您永远不该做的:644 权限、在 git 仓库里放一份副本,或者在 /tmp 里放一份副本。

接入 nginx

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name git.internal.lan;

    ssl_certificate     /etc/ssl/certs/git.internal.crt;
    ssl_certificate_key /etc/ssl/private/git.internal.key;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}
sudo nginx -t && sudo systemctl reload nginx

在 reload 生效之前,nginx -t 必须打印出 syntax is oktest is successful。如果它打印的是 SSL_CTX_use_PrivateKey_file() failed ... key values mismatch,说明证书和密钥来自两次不同的生成运行,请参见故障排查一节。

接入 Apache

sudo a2enmod ssl proxy proxy_http

这里光有 ssl 还不够:下面的虚拟主机用到了 ProxyPass,如果没有 mod_proxymod_proxy_http,配置测试会以 Invalid command 'ProxyPass', perhaps misspelled or defined by a module not included in the server configuration 报错终止。把虚拟主机保存为 /etc/apache2/sites-available/git-internal.conf

<VirtualHost *:443>
    ServerName git.internal.lan
    SSLEngine on
    SSLCertificateFile      /etc/ssl/certs/git.internal.crt
    SSLCertificateKeyFile   /etc/ssl/private/git.internal.key

    ProxyPass        / http://127.0.0.1:3000/
    ProxyPassReverse / http://127.0.0.1:3000/
</VirtualHost>
sudo a2ensite git-internal
sudo apache2ctl configtest && sudo systemctl reload apache2

configtest 应当回答 Syntax OK。现在从一台客户端机器测试:

curl -v https://git.internal.lan/

您会得到一个错误:

curl: (60) SSL certificate problem: self-signed certificate

这不是 bug。这正是 TLS 在起作用:curl 从没听说过您的证书,于是拒绝与一台它无法认证的服务器通信。下一节才是真正的解决办法,而它并不是此刻半个互联网都在做的那件事。

让客户端信任它,以及要拒绝的反面做法

先说错误的做法,并直接点出它们的本质。写进脚本里的 curl -k(或 --insecure)、Python requests 里的 verify=False、Node 里的 NODE_TLS_REJECT_UNAUTHORIZED=0,这些都不会让您的证书变得受信任。它们把证书验证关掉了,意味着客户端会乐呵呵地与任何出示任何证书的服务器通信,包括攻击者塞在链路中间的那一台。您保留了 TLS 的开销,却丢掉了它本来的意义,也就是认证。更糟的是,这些标志会四处扩散:先粘进一个 cron 任务,再进一个部署脚本,然后进生产代码,直到没人记得哪些连接本来只是临时的。如果一个 verify=False 活过了催生它的那次调试,那说明设计就是错的。

正确的做法是告诉每一台客户端操作系统:这张证书是一个受信任的根。在 Ubuntu 和 Debian 客户端上:

sudo cp git.internal.crt /usr/local/share/ca-certificates/git.internal.crt
sudo update-ca-certificates

输出里要紧的那一行(后面跟着一段 Running hooks in /etc/ca-certificates/update.d... 的内容):

Updating certificates in /etc/ssl/certs...
1 added, 0 removed; done.

这几行里藏着两个坑。文件名必须.crt 结尾:.pem 扩展名会被悄悄忽略,您会得到 0 added 而没有任何错误提示。而且内容必须是 PEM 格式,文件以 -----BEGIN CERTIFICATE----- 开头;如果是 DER 二进制,请先用 openssl x509 -inform der -in file.der -out file.crt 转换。把自签名证书本身当作根来添加是可行的,因为自签名证书本身就是它自己的根。

之后,curlwgetgitapt,以及其他任何针对系统证书包使用 OpenSSL 的程序,都会无需任何标志就信任这台服务器。有少数几个客户端自带信任库,需要单独处理:

  • Linux 上的 Chrome/Chromium 读取的是 NSS 数据库,而不是系统存储:sudo apt install libnss3-tools,然后针对每个用户执行 certutil -d sql:$HOME/.pki/nssdb -A -t "C,," -n "git.internal" -i git.internal.crt
  • Firefox 有自己的存储:设置 → 隐私与安全 → 证书 → 导入,或者在 about:config 里把 security.enterprise_roots.enabled 设为 true,让它读取系统存储。
  • Python requests 自带一份 CA 证书包(certifi),会忽略系统存储:传入 verify="/usr/local/share/ca-certificates/git.internal.crt",或导出 REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt
  • Node.js:导出 NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/git.internal.crt

在 Windows 客户端上,双击 .crt 并把它安装到受信任的根证书颁发机构;在 macOS 上,用 Keychain Access 把它加入系统钥匙串,并标记为始终信任

一个根服务多个服务:一个小型私有 CA

逐张证书地建立信任马上就会遇到扩展瓶颈:六个服务乘以四台客户端机器就是二十四次信任安装,而每新增一个服务还会更多。解决办法是一个私有 CA:客户端只信任一个根,您用它给每个服务的证书签名。

友好的选择是 mkcert,它在 Ubuntu 24.04 的软件仓库里,并且能处理 update-ca-certificates 漏掉的那些 NSS 存储(Chrome、Firefox):

sudo apt install -y mkcert libnss3-tools
mkcert -install
mkcert git.internal.lan "*.internal.lan" 10.8.0.1

mkcert -install 会创建一个根,并把它注册进那台机器上的每一个信任库;第三条命令生成 git.internal.lan+2.pemgit.internal.lan+2-key.pem,可以直接放进上面的 nginx 或 Apache 配置片段。它的设计前提是一台开发机器,根密钥就存放在运行了 -install 的那台机器上,所以它非常适合开发笔记本,却不适合一整队服务器。

对于服务器,纯 OpenSSL 用五条命令就能搞定整个 CA:

openssl genrsa -out lab-ca.key 4096
openssl req -x509 -new -key lab-ca.key -sha256 -days 3650 \
  -out lab-ca.crt -subj "/CN=Lab Internal CA" \
  -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"
openssl genrsa -out git.key 2048
openssl req -new -key git.key -out git.csr -subj "/CN=git.internal.lan" \
  -addext "subjectAltName=DNS:git.internal.lan,IP:10.8.0.1"
openssl x509 -req -in git.csr -CA lab-ca.crt -CAkey lab-ca.key \
  -CAcreateserial -days 730 -sha256 -copy_extensions copy -out git.crt

陷阱在最后一条命令里:openssl x509 -req 默认会丢弃 CSR 里的所有扩展,包括您精心添加的 SAN。-copy_extensions copy(一个 OpenSSL 3.x 选项,所以在 24.04 上可用)会把它们带过去;省掉它,签出的证书就没有 SAN,Chrome 又会拿 NET::ERR_CERT_COMMON_NAME_INVALID 来迎接您。用之前那条同样的 openssl x509 -noout -ext subjectAltName 检查来验证。

通过上面的信任库步骤把 lab-ca.crt 分发给各客户端,每台机器只需一次,永久有效。像守护王冠珠宝一样守护 lab-ca.key,因为它现在就是王冠珠宝:权限 600,最好放在一台并非它所签发服务的服务器上,因为谁掌握了它,谁就能为您客户端会相信的任何名称签发证书。

过期与轮换

公共 CA 的证书有效期正在快速缩短:CA/Browser Forum 在 2026 年 3 月把新签发的公开受信任证书上限定为 200 天(从原先的 398 天下调),到 2027 年降到 100 天,到 2029 年 3 月降到 47 天,但这些规则约束的是公开受信任的 CA。您的私有 CA 不受它们管辖,浏览器也不会对手动安装的根强制执行这些规则。有一条现实中的限制确实适用:无论由谁签发,Apple 平台都会拒绝有效期超过 825 天的任何 TLS 服务器证书,所以如果会有 iPhone 或 Mac 连接进来,就把叶子证书保持在两年或更短。-days 730 在任何地方都能过这道坎;十年的根搭配两年的叶子证书,是一种舒适的内部形态。

长期有效的证书只会以一种方式失败:悄无声息地、一次性地、在一个没人记得自己选过的日期上。检查一下您手里的证书:

openssl x509 -in /etc/ssl/certs/git.internal.crt -noout -enddate

把续期记进一个真正的日历,或者让 cron 在到期前 30 天唠叨您:openssl x509 -checkend 2592000 -in cert.crt 一旦到期时间落在这么多秒之内,就会以非零状态退出。如果您已经在运行 Uptime Kuma 做状态监控,它的 HTTPS 监控会免费为您标出即将到来的证书过期。

有了私有 CA,轮换就变得令人愉快地乏味:重新运行签发 CSR 与签名的命令,替换文件,重载 Web 服务器。根没有变,所以没有任何客户端会察觉到什么。

故障排查,附上您会看到的字符串

NET::ERR_CERT_AUTHORITY_INVALID:这是您安装信任之前的预期状态,不是证书的缺陷。如果在您安装了根之后它仍然存在:在 Linux 上,Chrome 读取的是 NSS 而不是系统存储(参见 certutil 步骤);或者复制过去的文件没有以 .crt 结尾,导致 update-ca-certificates 报了 0 added;又或者服务器出示的证书与您所信任的那张不是同一张,可以用 openssl s_client -connect git.internal.lan:443 </dev/null 2>/dev/null | openssl x509 -noout -fingerprint -sha256 比对指纹。

NET::ERR_CERT_COMMON_NAME_INVALID:证书没有 SAN,或者 SAN 没有覆盖地址栏里的名称。典型情况是:SAN 里列的是 DNS:git.internal.lan,但用户访问的是 https://10.8.0.1。改动信任库解决不了这个问题;请带上缺失的条目重新签发。

curl: (60) SSL certificate problem: self-signed certificate:curl 不信任这张证书。变体 self-signed certificate in certificate chain 对于由您私有 CA 签发的证书表示同样的意思。一次性解决:curl --cacert lab-ca.crt https://...;永久解决:信任库。而不是 -k

unable to load certificate ... Expecting: TRUSTED CERTIFICATE(或 Expecting: CERTIFICATE REQUEST,或 no start line):PEM 弄混了。您喂给 OpenSSL 的文件类型不对:在它期待证书的地方给了密钥或 CSR,或在它期待 PEM 的地方给了 DER 二进制。head -1 filename 会告诉您手里实际是什么,证书以 -----BEGIN CERTIFICATE----- 开头。对于 DER,用 openssl x509 -inform der -in file.der -out file.crt 转换。

nginx: [emerg] SSL_CTX_use_PrivateKey_file(...) failed (SSL: error ... key values mismatch):证书和密钥不是一对,通常是因为生成命令跑了两次,文件被弄混了。用 openssl x509 -in git.internal.crt -noout -pubkey | sha256sumopenssl pkey -in git.internal.key -pubout | sha256sum 对比确认;哈希相同就说明是匹配的一对。如果不同,就把两者重新一起生成。

FAQ

我创建了自签名证书后,为什么 Chrome 还是显示“不安全”?

如果错误是 NET::ERR_CERT_AUTHORITY_INVALID,那证书本身没问题,只是 Chrome 还没有理由信任它。把它(或您的私有 CA 根)安装进客户端的信任库,并记住在 Linux 上 Chrome 通过 certutil 使用 NSS 数据库,而不是系统存储。如果错误是 NET::ERR_CERT_COMMON_NAME_INVALID,那证书缺少与该 URL 匹配的 Subject Alternative Name,必须用 -addext "subjectAltName=..." 重新签发。

不用 -k,怎么让 curl 信任一张自签名证书?

把证书(PEM 格式,.crt 扩展名)复制到 /usr/local/share/ca-certificates/,然后运行 sudo update-ca-certificates,输出必须显示 1 added。从此 curl 会像验证任何公共证书一样验证它。如果只是想临时发一次请求而不改动系统,curl --cacert /path/to/cert.crt 只针对那一个文件做验证;-k 会完全关闭验证,不该出现在任何人的脚本里。

一张自签名证书能有效多久?

技术上想多久都行:CA/Browser Forum 的上限(现在是 200 天,到 2029 年为 47 天)约束的是公开受信任的 CA,而不是私有信任。实践中,把服务器证书上限定在 825 天,因为不论签发者是谁,Apple 设备都会拒绝更长的证书。十年的私有根搭配两年(-days 730)的叶子证书是个合理的默认值;只要把续期记进日历就行,因为一张过期的内部证书会在一个没人记得的日期上悄无声息地让一切瘫痪。

我该用自签名证书还是 Let's Encrypt?

如果服务有公共 DNS 名称并且能从互联网访问,永远选 Let's Encrypt:免费、自动化、每个客户端都已经信任。自签名(或私有 CA)用于 Let's Encrypt 无法签发的场景:私有 IP、像 .lan 这样仅限内部的主机名、离线网络,以及有意藏在 VPN 后面的服务。这个决定关乎可达性和命名,而不是安全强度:两者的密码学是完全一样的。

为什么我把证书加进 /usr/local/share/ca-certificates 后它还是被拒绝?

检查三件事。文件必须以 .crt 结尾:.pem 扩展名会被悄悄跳过,update-ca-certificates 会报 0 added。内容必须是以 -----BEGIN CERTIFICATE----- 开头的 PEM 文本,而不是 DER 二进制。而且应用程序必须真的使用系统存储:Linux 上的 Chrome、Firefox、Python requests、Node.js 和 Java 各自都保留着一个私有信任库,需要单独把证书加进去。

#openssl#tls#self-signed#ubuntu#安全