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 只有两种成因,排查时先分清是哪一种:
- 证书真的过期了(绝大多数)。Let's Encrypt 证书有效期只有 90 天,靠手动续期迟早出事。常见于:
acme.sh/certbot的定时任务因为换服务器、改 crontab、脚本路径变更而静默失效——续期失败不会有人通知你,直到用户先发现。 - 客户端系统时间错了。虚拟机休眠恢复后时间漂移、树莓派没有 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 同步
预防措施
- 让脚本替你盯着。把
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 证书" 你的收件邮箱
- 验证自动续期任务真的还活着:
~/.acme.sh/acme.sh --list看Renew列的下次续期时间,crontab -l | grep acme确认定时任务还在。换服务器、迁移目录后必须重新验证一次。 - 续期后一定要 reload,别让新证书躺在磁盘上不生效。
- 监控要从站外做。装在同一台服务器上的监控,服务器一挂就一起哑了。
小结:NET::ERR_CERT_DATE_INVALID 本身不难修,难的是让它不再复发。真正的解法是"自动续期 + 独立到期预警"两条腿,缺一条迟早还会被用户先发现。
系列阅读
- 证书续期别再靠人肉:acme.sh + 腾讯云 DNSPod 通配符证书自动续期与排障实录——红屏出现之前的预防:怎么确认续期任务真的存在、三个极易误判的信号、一次续期失败的完整定位。
本文环境:Django 4.2 / Python 3.11 / Ubuntu 22.04
文中脚本实跑环境:Windows 11 / Python 3.13.14(仅用标准库,Linux 通用)