证书过期是证书验证需要处理的截止时间,不是必然立刻关闭所有既有连接的定时器。检查时应读取目标主机名实际收到的证书,再把它的有效期与其他信任条件分开判断。即使 notAfter 还在未来,也不能仅凭这一个日期宣称整个 HTTPS 服务健康。

比较时刻,不比较日历标签

RFC 5280 把有效期定义为从 notBefore 到 notAfter,包含两个端点。验证时间落在区间之外时,证书不能通过有效期检查;恰好处于 notAfter 的那个时刻仍在区间内。但这不意味着可以等到最后一秒才处理续期,时钟误差、重试和分批部署都要求提前留出余量。

这些字段描述绝对时刻。日志统一使用 UTC 便于排查,但两个已经正确解析的时间,即使使用不同的时区表示,也可以指向同一个瞬间。应使用时间库计算差值,不要比较本地日期字符串,也不要只看向下取整后的“剩余几天”,否则可能掩盖尚未生效的证书,或者推迟已经紧急的告警。

检查公共入口时,需要带上正确的 SNI 和预期主机名,不能只检查源站磁盘上的证书文件。不同监听器、区域或负载均衡节点可能仍在提供不同证书。读取公开证书不需要私钥,但能够取到有效期,并不等于信任链、主机名、用途及适用的客户端策略都已经验证成功。

坏掉的样子

续签成功而部署失败是常见的两步错位:新证书已经落盘,运行中的进程却还在提供旧证书。重载之后,应从真实入口比较已提供证书的指纹和有效期,并覆盖相关边缘节点。也要检查实际选择的证书链,因为叶证书仍然有效时,中间证书过期一样可能导致验证失败。

时钟漂移会让刚签出的证书看起来尚未生效。监控若遇到连接超时、取不到证书或解析失败,不能把空的到期时间当作健康结果。应区分“没有取得可信证据”和“验证成功且剩余时间充足”,并为每个受监控入口安排明确的责任人和升级路径,避免告警存在却无人处理。

续期和预警阈值需要结合证书实际寿命及故障恢复时间,不能把三十天当成所有场景通用的常量。Let’s Encrypt 建议可用时采用 ACME Renewal Information,并分散续期工作。签发失败与正在提供的证书接近过期应分别监控;续期也不自动等于每次都轮换私钥,更不等于执行了吊销检查。

快速验证

保存为 expiry_probe.go,使用 Go 1.23 或更新版本运行 go run expiry_probe.go。程序只在内存中生成隔离的根证书、中间证书和叶证书,并明确设置测试信任库、主机名、服务端认证用途与验证时刻。合成时间从 2026-09-09 00:00:00 UTC 开始,不会修改机器的系统时钟。

package main

import (
	"crypto/ed25519"
	"crypto/rand"
	"crypto/x509"
	"crypto/x509/pkix"
	"errors"
	"fmt"
	"math/big"
	"runtime"
	"time"
)

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func key() (ed25519.PublicKey, ed25519.PrivateKey) {
	public, private, err := ed25519.GenerateKey(rand.Reader)
	must(err)
	return public, private
}

func issue(serial int64, parent *x509.Certificate, public ed25519.PublicKey, signer ed25519.PrivateKey, start, end time.Time, ca bool) *x509.Certificate {
	template := &x509.Certificate{
		SerialNumber: big.NewInt(serial), Subject: pkix.Name{CommonName: fmt.Sprint("fixture-", serial)},
		NotBefore: start, NotAfter: end, IsCA: ca, BasicConstraintsValid: true,
	}
	if ca {
		template.KeyUsage = x509.KeyUsageCertSign
	} else {
		template.KeyUsage = x509.KeyUsageDigitalSignature
		template.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
		template.DNSNames = []string{"expiry.example.test"}
	}
	if parent == nil {
		parent = template
	}
	der, err := x509.CreateCertificate(rand.Reader, template, parent, public, signer)
	must(err)
	certificate, err := x509.ParseCertificate(der)
	must(err)
	return certificate
}

func main() {
	base := time.Date(2026, 9, 9, 0, 0, 0, 0, time.UTC)
	rootPublic, rootPrivate := key()
	issuerPublic, issuerPrivate := key()
	leafPublic, _ := key()
	root := issue(1, nil, rootPublic, rootPrivate, base.Add(-24*time.Hour), base.Add(24*time.Hour), true)
	issuer := issue(2, root, issuerPublic, rootPrivate, base.Add(-time.Hour), base.Add(3*time.Hour), true)
	leaf := issue(3, issuer, leafPublic, issuerPrivate, base, base.Add(2*time.Hour), false)
	roots, intermediates := x509.NewCertPool(), x509.NewCertPool()
	roots.AddCert(root)
	intermediates.AddCert(issuer)
	verify := func(certificate *x509.Certificate, instant time.Time, name string) error {
		_, err := certificate.Verify(x509.VerifyOptions{
			Roots: roots, Intermediates: intermediates, CurrentTime: instant, DNSName: name,
			KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
		})
		return err
	}
	expired := func(err error, certificate *x509.Certificate) {
		var invalid x509.CertificateInvalidError
		check(errors.As(err, &invalid) && invalid.Reason == x509.Expired &&
			invalid.Cert.SerialNumber.Cmp(certificate.SerialNumber) == 0, "wrong validity failure")
	}
	for _, test := range []struct {
		label   string
		instant time.Time
		valid   bool
	}{
		{"before", base.Add(-time.Second), false},
		{"notBefore", base, true},
		{"middle", base.Add(time.Hour), true},
		{"notAfter", base.Add(2 * time.Hour), true},
		{"after", base.Add(2*time.Hour + time.Second), false},
	} {
		err := verify(leaf, test.instant, "expiry.example.test")
		if test.valid {
			must(err)
		} else {
			expired(err, leaf)
		}
		fmt.Println(test.label, "valid:", err == nil)
	}
	longLeaf := issue(4, issuer, leafPublic, issuerPrivate, base, base.Add(6*time.Hour), false)
	expired(verify(longLeaf, base.Add(4*time.Hour), "expiry.example.test"), issuer)
	var hostname x509.HostnameError
	check(errors.As(verify(leaf, base.Add(time.Hour), "wrong.example.test"), &hostname), "wrong name must fail")
	sameInstant := base.In(time.FixedZone("fixture+08", 8*60*60))
	must(verify(leaf, sameInstant, "expiry.example.test"))
	check(leaf.NotAfter.Sub(sameInstant) == 2*time.Hour, "timezone must not change the instant")
	remaining := leaf.NotAfter.Sub(base.Add(90 * time.Minute))
	check(remaining == 30*time.Minute && remaining <= 45*time.Minute, "fixture alert must fire")
	fmt.Println("issuer expiry and wrong name rejected; timezone preserves instant; remaining=30m triggers 45m warning")
	fmt.Println(runtime.Version())
}

五个时间点应依次输出 false、true、true、true、false。普通叶证书有效两小时,中间证书有效到基准时刻之后三小时,根证书则到之后二十四小时。这样检查叶证书两个端点时,不会被其他签发者提前过期遮住真正要观察的行为。反向断言同时检查错误原因和出错证书的序列号。

第二张叶证书延长到六小时。把验证时刻移到第四小时,这张叶证书本身还在有效期内,但中间证书已经过期,验证应明确失败在签发者。另一个独立检查使用仍然有效的普通叶证书,却填写错误的 DNS 名称,此时得到 HostnameError,说明时间有效不代表名称也匹配。

程序还把基准时刻改用固定 UTC+08 时区表示;表示方法变了,验证结果和剩余时长都不应改变。在第九十分钟,普通叶证书只剩三十分钟,因而触发本例四十五分钟的预警阈值。这些短时段专门用于实验,不是生产续期建议;运行结果只输出判断标签和 Go 版本,不打印任何私钥。

已核验的 macOS、Go 1.27.0 运行通过所有断言。实验执行的是 X.509 验证,并没有建立网络 TLS 握手,也没有实际部署自动续签。Go 的 Verify 不执行吊销检查;本例没有检查公共入口、浏览器专有策略、OCSP、CRL 或会话恢复行为。既有连接如何处理凭据变化,仍需单独制定并验证生命周期策略。

延伸阅读

来源