W weiserv
← 返回博客

NET::ERR_CERT_DATE_INVALID 排查实录:证书过期的根因二分与独立到期预警

NET::ERR_CERT_DATE_INVALID 排查实录:证书过期的根因二分与独立到期预警

某天早上打开自己的站点,Chrome 直接拦了一整屏红色警告——您的连接不是私密连接

让证书自动续期、别让红屏出现,我在《证书续期别再靠人肉》一文里已经完整写过(见文末系列阅读)。本文回答的是红屏已经出现之后的问题:怎么快速定位是谁的锅,以及怎么保证它不会无声无息地再来第二次。这类问题最怕的不是修不好,而是你不知道它什么时候会再来。

报错 / 症状

Chrome / Edge 拦截页:

您的连接不是私密连接
攻击者可能会试图从 example.com 窃取您的信息
NET::ERR_CERT_DATE_INVALID

其他客户端的等价表现:

  • Firefox:SEC_ERROR_EXPIRED_CERTIFICATE
  • curl:curl: (60) SSL certificate problem: certificate has expired
  • Python requests:SSLError: certificate verify failed: certificate has expired

注意和 NET::ERR_CERT_COMMON_NAME_INVALID(域名不匹配)、NET::ERR_CERT_AUTHORITY_INVALID(签发机构不受信任,常见于自签证书)区分开——DATE 的这个,99% 就是时间问题

触发环境

  • 服务端:Ubuntu 22.04 + Nginx 1.18.0,Let's Encrypt 免费证书(有效期仅 90 天)
  • 客户端复现:Windows 11 + Python 3.13.14(脚本仅用标准库,Linux 同样可跑)

根因分析

NET::ERR_CERT_DATE_INVALID 只有两种成因,排查时先分清是哪一种:

  1. 证书真的过期了(绝大多数)。Let's Encrypt 证书有效期只有 90 天,靠手动续期迟早出事。常见于:acme.sh / certbot 的定时任务因为换服务器、改 crontab、脚本路径变更而静默失效——续期失败不会有人通知你,直到用户先发现。
  2. 客户端系统时间错了。虚拟机休眠恢复后时间漂移、树莓派没有 RTC 电池、CMOS 掉电,都会让本地时间跑到证书生效期之外。此时服务端一切正常,换台设备访问就好了。

还有一个容易被忽略的点:Nginx reload 了才生效acme.sh 续期成功但没执行 reloadcmd,磁盘上是新证书、内存里还是旧证书,浏览器照样报过期。

解决方法

方案一:先确认到底过没过期(不要靠猜)

最快的方式是命令行直接看:

echo | openssl s_client -servername weiserv.com -connect weiserv.com:443 2>/dev/null \
  | openssl x509 -noout -dates

但 openssl 的输出不好直接做告警判断。下面这个纯标准库 Python 脚本会算出剩余天数并用退出码分级,方便挂到监控里:

# check_cert.py —— 仅用标准库,Windows / Linux 通用
# 关键点:证书已过期时,默认校验会在握手阶段直接抛错,读不到有效期,
#        必须降级到"不校验"的 context 才能把 notAfter 读出来。
import socket
import ssl
import sys
from datetime import datetime, timezone


def fetch_cert(host: str, port: int) -> tuple[dict, bool]:
    """返回 (证书字典, 是否通过了校验)。"""
    ctx = ssl.create_default_context()
    try:
        with socket.create_connection((host, port), timeout=10) as sock:
            with ctx.wrap_socket(sock, server_hostname=host) as ssock:
                return ssock.getpeercert(), True
    except ssl.SSLCertVerificationError as exc:
        print(f"[!] 校验失败:{exc.verify_message},降级为不校验模式读取证书")

    raw_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
    raw_ctx.check_hostname = False
    raw_ctx.verify_mode = ssl.CERT_NONE
    with socket.create_connection((host, port), timeout=10) as sock:
        with raw_ctx.wrap_socket(sock, server_hostname=host) as ssock:
            der = ssock.getpeercert(binary_form=True)
    return _decode_via_tempfile(der), False


def _decode_via_tempfile(der: bytes) -> dict:
    """把 DER 落成临时 PEM 再解析。"""
    import os
    import tempfile

    pem = ssl.DER_cert_to_PEM_cert(der)
    fd, path = tempfile.mkstemp(suffix=".pem")
    try:
        with os.fdopen(fd, "w") as fh:
            fh.write(pem)
        return ssl._ssl._test_decode_cert(path)
    finally:
        os.unlink(path)


def check_cert(host: str, port: int = 443, warn_days: int = 15) -> int:
    cert, verified = fetch_cert(host, port)

    not_after = cert["notAfter"]  # 形如 'Sep 18 03:21:07 2026 GMT'
    expire_at = datetime.strptime(not_after, "%b %d %H:%M:%S %Y %Z").replace(
        tzinfo=timezone.utc
    )
    left = (expire_at - datetime.now(timezone.utc)).days
    issuer = dict(x[0] for x in cert["issuer"]).get("organizationName", "unknown")

    print(f"域名      : {host}")
    print(f"签发机构  : {issuer}")
    print(f"链路校验  : {'通过' if verified else '未通过'}")
    print(f"到期时间  : {expire_at:%Y-%m-%d %H:%M:%S} UTC")
    print(f"剩余天数  : {left} 天")

    if left < 0:
        print("状态      : [FAIL] 证书已过期,浏览器将报 NET::ERR_CERT_DATE_INVALID")
        return 2
    if left <= warn_days:
        print(f"状态      : [WARN] 不足 {warn_days} 天,请立即续期")
        return 1
    print("状态      : [OK] 有效期充足")
    return 0


if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "weiserv.com"
    sys.exit(check_cert(target))

这里有个写第一版时踩到的坑值得单独说:一开始我只用 ssl.create_default_context() 去读证书,结果对着已经过期的站点跑,脚本自己先炸了——

ssl.SSLCertVerificationError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: certificate has expired (_ssl.c:1032)

原因很直白:证书过期属于校验失败,TLS 握手阶段就被中断了getpeercert() 根本没机会执行。所以想读出"过期了多久",必须捕获 SSLCertVerificationError 后降级成 CERT_NONE 再连一次。这也是上面代码里 fetch_cert() 要写两段的原因。

终端输出(实证)

分别对正常站点和公开的过期测试站点 expired.badssl.com 实跑:

=== weiserv.com ===
域名      : weiserv.com
签发机构  : Let's Encrypt
链路校验  : 通过
到期时间  : 2026-10-04 14:49:46 UTC
剩余天数  : 55 天
状态      : [OK] 有效期充足
退出码=0

=== expired.badssl.com ===
[!] 校验失败:certificate has expired,降级为不校验模式读取证书
域名      : expired.badssl.com
签发机构  : COMODO CA Limited
链路校验  : 未通过
到期时间  : 2015-04-12 23:59:59 UTC
剩余天数  : -4138 天
状态      : [FAIL] 证书已过期,浏览器将报 NET::ERR_CERT_DATE_INVALID
退出码=2

过期站点被准确识别,并且把"过期了 4138 天"这种极端情况也算了出来。

方案二:服务端证书过期 → 续期并确保 reload 生效

确认是服务端证书过期后,手动续一次,并用 --install-cert 把 reloadcmd 一并注册——否则磁盘上是新证书、内存里还是旧证书,浏览器照样报过期:

# 强制续期(调试期用 --force,平时不要加,避免撞 CA 频率限制)
~/.acme.sh/acme.sh --renew -d weiserv.com --force

# reloadcmd 只需注册一次,之后每次自动续期都会带上
~/.acme.sh/acme.sh --install-cert -d weiserv.com \
  --key-file       /etc/nginx/ssl/weiserv.key \
  --fullchain-file /etc/nginx/ssl/weiserv.crt \
  --reloadcmd      "systemctl reload nginx"

至于续期任务怎么配成全自动、--install-cronjob 怎么用、一次真实的续期失败怎么定位——完整过程都在《证书续期别再靠人肉》里,本文不重复。

方案三:检查客户端时间(服务端没问题时)
timedatectl status          # 看 "System clock synchronized: yes"
sudo timedatectl set-ntp on # 打开 NTP 同步

预防措施

  1. 让脚本替你盯着。把 check_cert.py 挂进 crontab,剩余不足 15 天就告警:

bash # 每天 08:30 检查,退出码非 0 时邮件通知(收件邮箱自行替换) 30 8 * * * /usr/bin/python3 /opt/scripts/check_cert.py weiserv.com || \ echo "证书即将过期,请检查" | mail -s "[告警] weiserv 证书" 你的收件邮箱

  1. 验证自动续期任务真的还活着~/.acme.sh/acme.sh --listRenew 列的下次续期时间,crontab -l | grep acme 确认定时任务还在。换服务器、迁移目录后必须重新验证一次
  2. 续期后一定要 reload,别让新证书躺在磁盘上不生效。
  3. 监控要从站外做。装在同一台服务器上的监控,服务器一挂就一起哑了。

小结:NET::ERR_CERT_DATE_INVALID 本身不难修,难的是让它不再复发。真正的解法是"自动续期 + 独立到期预警"两条腿,缺一条迟早还会被用户先发现。

系列阅读

本文环境:Django 4.2 / Python 3.11 / Ubuntu 22.04
文中脚本实跑环境:Windows 11 / Python 3.13.14(仅用标准库,Linux 通用)

广告位占位 · post-inline