证书过期是证书验证需要处理的截止时间,不是必然立刻关闭所有既有连接的定时器。检查时应读取目标主机名实际收到的证书,再把它的有效期与其他信任条件分开判断。即使 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 或会话恢复行为。既有连接如何处理凭据变化,仍需单独制定并验证生命周期策略。
延伸阅读
来源
- RFC 5280:X.509 证书,包含端点的有效期与证书路径,已核验 2026-09-09。
- Go crypto/x509,VerifyOptions、错误原因与吊销检查边界,已核验 2026-09-09。
- Let’s Encrypt:集成指南,续期信息、调度与失败报告,已核验 2026-09-09。