春江暮客

春江暮客的个人学习分享网站

用 Python 检查 TLS 证书到期时间:保留验证、提前预警与本地自检

2026-10-18 技术
用 Python 检查 TLS 证书到期时间:保留验证、提前预警与本地自检

网站的 443 端口可以连接,证书却只剩两天有效期;等到浏览器报错再续期,往往已经影响访问。本文用 Python 标准库写一个检查工具,验证 TLS 连接后读取证书到期时间,用 JSON 和退出码接入现有自动化流程。

面向初学者到中级读者,不需要 pip 包。我们先用本地证书复现正常、即将到期、不受信任和主机名不匹配,再检查真实站点。

1. 方法:先验证身份,再计算剩余时间

  1. 用 ssl.create_default_context() 创建客户端上下文,保留证书链和主机名验证。
  2. 建立 TCP 连接,再通过 server_hostname 指定要验证的名称;使用域名时也会发送 SNI。
  3. 握手成功后读取服务端叶子证书的 notAfter,转成 UTC 时间戳。
  4. 剩余秒数小于等于 --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 上测试,不需要全局安装依赖。

  1. 新建演示目录并下载两个完整脚本,后续命令都在这个目录运行:
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
  1. 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. 本地实验:两天证书触发三天预警

  1. 在终端 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;这些文件只用于本地实验。

  1. 保持终端 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,实际日期与运行时间有关。

  1. 分别验证信任错误和主机名错误:
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. 接入自动化与排错

  1. 对自己管理的实际域名运行检查,输入主机名,不能输入 https:// URL。例如检查本站:
python3 check_tls.py www.bobobk.com --warn-days 14 --timeout 3

证书剩余时间取决于续期状态,所以不要把某个固定天数写进测试断言。预警后按证书签发方的续期流程处理,并确认对外服务已经使用新证书。CDN 或负载均衡后的多个节点可能返回不同证书,一次连接只能观察当时选中的节点。

  1. 调用方应保留 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 内容。
  1. 证书检查通过后,继续验证 HTTPS 状态码和实际页面内容,方法见 curl 部署检查。证书有效不代表页面能正常加载;本工具也不是完整的吊销状态或整条证书链到期审计工具。

7. 小结

保留 TLS 身份验证,再对服务端叶子证书设置剩余时间阈值,可以提前发现续期窗口,也能把信任和主机名错误交给自动化处理。先用本地两天证书验证成功和失败分支,再接入现有监控;续期后重新检查真实端点,并继续核对 HTTP 响应。

标签

1024 12306 ablang adsense agents.md ai ai-agent ai-agents ai-seo algorithm amp antibodies apparmor argparse automation backup bash batch-processing binarycif bioinformatics biopython blockchain boltz bootstrapping boxes bubblewrap c-index cca cdn chatgpt checkpoint cli cloudflare codex cofoldarena copy cpu监控 cron csv cuda curl data-leakage data-processing data-quality data-validation datascience datavisualization datetime debugging deployment desktop-app devtools disk-space disown docker dovecot download electron encoding esm esm2 esm3 esmc esmfold2 faceswap fasta fastmcp faulthandler ffmpeg file-descriptors file-io flashppi flask folium frontend game generator git github-actions google google-research grep gzip harness hls html http hugo indexnow javascript jev json jsonl just k-means kaggle langfuse leecode linux list litellm llm llms.txt logging logs lollipop lsof m3u8 machine-learning macos manacher matplotlib mcp mirror mmcif model-evaluation monitoring mp3 mp4 mpnn multiomics mutation mysql nanobert nanobodies networkx nginx normalize numpy ollama omegatherm pandas password pdb pep-723 phaser pillow pip postfix preprocessing print protein-alignment protein-design protein-embeddings protein-interactions protein-language-models protein-stability protein-structure proxy pydantic pyecharts pyqt python python3 r raincloud reproducibility requests reservoir-sampling rfoptimization rg ripgrep rosettafold3 roundcube rrsi rsi rsync s-tui sampling scale scikit-learn scrapy screen seaborn security selenium seo sequence-identity sha256 shell signals sklearn socket solana somaticsignatures spl sqlite ssh standardize static-site subprocess symlink sysadmin system-one tcp tensorflow tkinter tls toml tron tronpy troubleshooting turtle typesafe-ai ubuntu usdt uv vhhbert vite webp wordcloud wordpress workflow yaml zipfile zoneinfo 后台 寓言 概率 经济 贸易 迅雷解析 钱包

友情链接

其它