用 Python 检查 TLS 证书到期时间:保留验证、提前预警与本地自检
网站的 443 端口可以连接,证书却只剩两天有效期;等到浏览器报错再续期,往往已经影响访问。本文用 Python 标准库写一个检查工具,验证 TLS 连接后读取证书到期时间,用 JSON 和退出码接入现有自动化流程。
面向初学者到中级读者,不需要 pip 包。我们先用本地证书复现正常、即将到期、不受信任和主机名不匹配,再检查真实站点。
1. 方法:先验证身份,再计算剩余时间
- 用
ssl.create_default_context()创建客户端上下文,保留证书链和主机名验证。 - 建立 TCP 连接,再通过
server_hostname指定要验证的名称;使用域名时也会发送 SNI。 - 握手成功后读取服务端叶子证书的
notAfter,转成 UTC 时间戳。 - 剩余秒数小于等于
--warn-days × 86400时返回expiring,否则返回ok。
做法对应 Python 官方 TLS 客户端示例与 getpeercert() 文档。到期时间转换使用 ssl.cert_time_to_seconds(),避免把证书中的 GMT 当作本地时间。
如果连接还没走到 TLS 阶段,先参考 Python TCP 端口探测。TCP 连通只说明连接建立成功,不能说明证书正常。
2. 准备:取得脚本和本地测试工具
需要 Python 3.9 或更新版本及可用的 ssl 模块;本地实验额外使用支持 -addext 的 OpenSSL 命令和下载用的 curl。示例已在 Linux、Python 3.11.4 与系统 Python 3.13 上测试,不需要全局安装依赖。
- 新建演示目录并下载两个完整脚本,后续命令都在这个目录运行:
python3 --version
openssl version
mkdir tls-expiry-demo
cd tls-expiry-demo
curl -fS https://www.bobobk.com/downloads/tls-expiry/check_tls.py -o check_tls.py
curl -fS https://www.bobobk.com/downloads/tls-expiry/test_tls.py -o test_tls.py
python3 check_tls.py --help
check_tls.py是下一节的完整代码;test_tls.py是配套本地自检脚本。没有 curl 时,可用浏览器保存两个下载文件。检查普通公网 HTTPS 证书只需要check_tls.py;OpenSSL 命令仅用于本地测试。
3. 完整实现:一行 JSON 和明确退出码
"""Check one TLS endpoint's verified leaf certificate. Python 3.9+."""
import argparse
from datetime import datetime, timezone
import json
import math
import socket
import ssl
import time
def check(host, port, timeout, warn_days, cafile=None):
report = {"host": host, "port": port, "status": "error"}
try:
context = ssl.create_default_context(cafile=cafile)
with socket.create_connection((host, port), timeout=timeout) as raw:
with context.wrap_socket(raw, server_hostname=host) as tls:
certificate = tls.getpeercert()
expires = ssl.cert_time_to_seconds(certificate["notAfter"])
remaining = expires - time.time()
report.update(
status="expiring" if remaining <= warn_days * 86400 else "ok",
expires_utc=datetime.fromtimestamp(expires, timezone.utc).isoformat(),
days_remaining=round(remaining / 86400, 3),
)
except ssl.SSLCertVerificationError as exc:
report.update(status="verify_error", error=exc.verify_message)
except (OSError, ValueError, KeyError, OverflowError) as exc:
report.update(error=str(exc))
return report
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("host", help="DNS hostname or IP address, without a URL")
parser.add_argument("--port", type=int, default=443)
parser.add_argument("--timeout", type=float, default=3)
parser.add_argument("--warn-days", type=int, default=14)
parser.add_argument("--cafile", help="explicit PEM trust file, e.g. for a lab")
args = parser.parse_args()
if not args.host or any(c.isspace() for c in args.host) or "/" in args.host:
parser.error("host must be a hostname or IP address without a URL")
if not 1 <= args.port <= 65535:
parser.error("--port must be between 1 and 65535")
if not math.isfinite(args.timeout) or not 0 < args.timeout <= 60:
parser.error("--timeout must be finite and greater than 0, at most 60")
if not 0 <= args.warn_days <= 36500:
parser.error("--warn-days must be between 0 and 36500")
report = check(args.host, args.port, args.timeout, args.warn_days, args.cafile)
print(json.dumps(report, ensure_ascii=False))
return 0 if report["status"] == "ok" else 1
if __name__ == "__main__":
raise SystemExit(main())
days_remaining 仅为显示而四舍五入,预警判断使用原始秒数。过期证书通常在握手时就被拒绝,返回 verify_error,不会通过关闭验证来读取一个看似正常的结果。
| 退出码 | JSON 状态或行为 |
|---|---|
| 0 | ok:本次证书验证成功,剩余时间超过阈值。 |
| 1 | expiring:证书有效,但剩余时间不足;verify_error:验证失败;error:其他 TLS、网络或文件错误。 |
| 2 | 参数错误,说明写入标准错误,不输出 JSON。 |
socket.create_connection()支持按主机名连接并尝试解析出的地址。--timeout 约束连接和握手操作,不是整个进程的总时限;DNS 解析和多个地址尝试可能让总时间更长。本工具检查直接 TLS 服务,不处理 SMTP STARTTLS,也不读取 HTTP 页面。
4. 本地实验:两天证书触发三天预警
- 在终端 A 的演示目录创建专用配置和测试证书,再启动只监听回环地址的 TLS 服务:
umask 077
printf '[req]\ndistinguished_name = dn\n[dn]\n' > demo-openssl.cnf
openssl req -x509 -newkey rsa:2048 -nodes \
-config demo-openssl.cnf -days 2 -subj '/CN=localhost' \
-addext 'subjectAltName=DNS:localhost' \
-addext 'basicConstraints=critical,CA:TRUE' \
-addext 'keyUsage=critical,digitalSignature,keyEncipherment,keyCertSign' \
-addext 'subjectKeyIdentifier=hash' \
-addext 'authorityKeyIdentifier=keyid:always' \
-keyout demo-key.pem -out demo-cert.pem
openssl s_server -accept 127.0.0.1:18443 \
-cert demo-cert.pem -key demo-key.pem -www
配置只声明生成请求所需的最小信息,扩展都在命令中指定,避免系统默认配置与 -addext 重复添加同一扩展。命令参数可查 OpenSSL req 官方手册和 s_server 官方手册。-nodes 生成未加密的演示私钥,因此使用 umask 077;这些文件只用于本地实验。
- 保持终端 A 运行,在终端 B 进入同一个目录:
python3 check_tls.py localhost --port 18443 --cafile demo-cert.pem --warn-days 0
if python3 check_tls.py localhost --port 18443 --cafile demo-cert.pem --warn-days 3; then
printf 'Certificate has enough time remaining\n'
else
tls_exit=$?
printf 'Check needs attention: exit=%s\n' "$tls_exit"
fi
第一条返回 status: "ok"。第二条因证书只剩约两天,而阈值为三天,返回 status: "expiring" 和退出码 1。JSON 包含 expires_utc 和 days_remaining,实际日期与运行时间有关。
- 分别验证信任错误和主机名错误:
if python3 check_tls.py localhost --port 18443; then
printf 'Unexpected trust result\n'
else
printf 'Expected trust failure: exit=%s\n' "$?"
fi
if python3 check_tls.py 127.0.0.1 --port 18443 --cafile demo-cert.pem; then
printf 'Unexpected hostname result\n'
else
printf 'Expected hostname failure: exit=%s\n' "$?"
fi
两次都应返回 verify_error。第一次没有信任演示证书;第二次虽然显式信任它,但证书只列出 DNS:localhost,不包含 127.0.0.1 这个 IP 身份。--cafile 为本次调用选择信任文件,不会安装到系统,也不会关闭主机名验证;只使用事先确认过来源的信任文件。
实验结束后,在终端 A 按 Ctrl+C。若端口占用,改用 18444,并一起修改终端 B 的 --port;不要停止其他程序来腾出端口。证书两天后过期时,重新生成演示文件再实验。
5. 验证:自动测试失败分支
运行下载的 test_tls.py:
python3 test_tls.py
预期输出:
OK: valid, expiring, untrusted, hostname mismatch, refused, arguments, timeout
自检会在临时目录生成证书,在 127.0.0.1 的动态端口启动本地 TLS 服务,并通过子进程核对 JSON 和退出码。它还用“绑定但不监听”的端口验证连接失败,检查无效超时、端口和预警天数;超时异常使用 mock 注入,避免依赖公网丢包。线程结束后关闭 socket,并自动删除自己生成的证书和私钥,不影响第 4 节文件。
下载完成后,自检不访问公网。它确实测试了本地握手、信任拒绝和主机名拒绝;注入超时只验证错误处理,不测量真实网络延迟,也没有模拟真实过期证书。
6. 接入自动化与排错
- 对自己管理的实际域名运行检查,输入主机名,不能输入
https://URL。例如检查本站:
python3 check_tls.py www.bobobk.com --warn-days 14 --timeout 3
证书剩余时间取决于续期状态,所以不要把某个固定天数写进测试断言。预警后按证书签发方的续期流程处理,并确认对外服务已经使用新证书。CDN 或负载均衡后的多个节点可能返回不同证书,一次连接只能观察当时选中的节点。
- 调用方应保留 JSON 和非零退出码,把退出码 1 交给已有告警流程;不要在命令后加
|| true隐藏失败。定时环境里应固定解释器、脚本和信任文件的绝对路径,具体见定时任务路径与环境排查。
| 现象 | 具体处理 |
|---|---|
expiring |
续期后重新检查对外端点;只更新磁盘文件而服务未加载新证书,预警仍会存在。 |
verify_error,提示证书过期 |
核对检查主机时间和服务端证书,完成续期部署,再运行相同检查。 |
verify_error,提示 issuer/trust |
检查服务端是否提供正确的中间证书链,以及当前 Python 使用的可信 CA 文件。私有 CA 场景用已确认的 --cafile。 |
verify_error,提示 hostname/IP |
使用证书 SAN 覆盖的域名;如必须使用 IP,应签发包含该 IP SAN 的证书。 |
error,提示 timeout/refused |
核对域名解析、目标端口和监听服务,再检查访问路径。 |
error,提示 PEM/文件路径 |
检查 --cafile 路径、读取权限及 PEM 内容。 |
- 证书检查通过后,继续验证 HTTPS 状态码和实际页面内容,方法见 curl 部署检查。证书有效不代表页面能正常加载;本工具也不是完整的吊销状态或整条证书链到期审计工具。
7. 小结
保留 TLS 身份验证,再对服务端叶子证书设置剩余时间阈值,可以提前发现续期窗口,也能把信任和主机名错误交给自动化处理。先用本地两天证书验证成功和失败分支,再接入现有监控;续期后重新检查真实端点,并继续核对 HTTP 响应。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-tls-certificate-expiry.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。