TT Lab
开始
学习 学习路径 课程

证书已经续期,浏览器却还显示旧证书

续期工具改文件,服务器发内存

在 TT Lab 中继续学习

一句话总结

续期工具修改的是文件,而服务器发出的是内存中的证书。如果两者之间没有重启(reload)来衔接,磁盘上明明有新证书,浏览器看到的却是旧的。所以续期监控要看的不是文件,而是服务器实际发出的证书。

为什么需要它

收到了到期前三天的告警。日志里写着续期任务昨天已经成功了。然而浏览器仍然显示三天后到期的证书。负责人打开文件——确实是新证书。如果不知道服务器在什么时候读取证书,这个矛盾就解不开。nginx、Envoy、JVM 应用,乃至 Python 的 ssl 模块,大多数服务器都是在启动时一次性把证书加载进内存。文件即使变了,也不会重新读取。续期工具覆盖了文件,与服务器使用这个文件,是两件互不相干的事。

这个仓库里也出现过同类的问题。修改了配置,让 cert-manager 自动续期证书,但按那个配置,一次续期都还没有经历过。要回答“应该会定期续期吧”,就得真的让它续期一次,然后看浏览器收到的证书的日期是否变化。判定依据既不是 Pod 状态,也不是日志,而是 openssl s_client 取回的证书的日期。

工作原理

用 Python 的 ssl 模块看,结构一目了然。SSLContext.load_cert_chain(certfile, keyfile) 在调用时读取文件并放入上下文,此后所有握手发出的都是上下文中所持有的内容。即使覆盖了文件,上下文也不知道。要让它重新读取,就必须重启进程,或者使用服务器提供的重新加载信号(nginx 的 reload,Envoy 的 SDS 这类监视文件变化的结构)。在 Kubernetes 中,cert-manager 更新 Secret 后,以卷挂载的文件过一会儿就会改变,但读取这个文件的仍然是应用自己的事——没有重新加载逻辑的应用必须重启 Pod。

观察工具是 openssl s_client。用 -connect host:port 进行握手,用 -servername 发送 SNI,再把收到的证书交给 x509,读取 serial、到期日和指纹。

$ echo | openssl s_client -connect 127.0.0.1:8443 -servername www.lab.internal 2>/dev/null \
    | openssl x509 -noout -serial -enddate
serial=2037629D1C3A75C63253D1BE593E399BA192FF71      ← 서버가 내보내는 것
$ openssl x509 -in /root/renew/live.crt -noout -serial
serial=2037629D1C3A75C63253D1BE593E399BA192FF72      ← 디스크에 놓인 것

两个 serial 不同,就是事故。监控脚本只要比较这两个值即可,而到期监控要对用 s_client 收到的证书,而不是对磁盘文件,使用 -checkend。如果对文件使用,就会亮起“已经续期了,放心”这样虚假的绿灯。

续期本身就是用同一把密钥对新的 CSR 签名。只有 serial 和 validity 会变,所以不更换密钥,证书也会成为新的一版(连密钥一起更换更安全,但这不是本模块的主题)。有效期设得短,续期就会频繁发生,这类事故也就会尽早暴露——设得长,则一年一次,在负责人换人之后才爆发。

在现场相遇的样子

症状总是从“据说续期成功了”开始。确认顺序有三步。第一,用 s_client 取回服务器发出的证书,查看 serial。第二,与磁盘上的文件对比。第三,如果不同,就去找重新加载的方法——视进程而定,答案是 reload 信号、重启 Pod,或 sidecar 的 SDS 刷新。如果负载均衡器后面有多台服务器,也可能只有一台发出旧证书,所以要把各台服务器的地址直接放进 -connect,逐台查看。第一次启用续期自动化时,不要等到到期,而是强制触发一次续期,并一直确认到服务器发出新证书为止。自动化必须有实际的签发历史,才值得信赖。

下一项实验要做什么

用有效期 3 天的 v1 证书启动 Python HTTPS 服务器,并用 s_client 读取 serial。用同一把密钥签发有效期 30 天的 v2,覆盖文件之后,观察没有重启的服务器仍然发出 v1。编写比较磁盘与服务器的 certdiff.sh,以及查看服务器所发证书到期情况的 servedcheck.sh,并用评分器的服务器接受双向检查。重启之后确认发出的是 v2,并把两个 serial 写进报告。