Golang 监控 http 证书过期时间

一、业务需求

由于近几年 Let’s Encrypt 的兴起以及 http 的普及,个人用户终于可以免费 “绿” 一把了;但是 Let’s Encrypt ACME 申请的证书目前只有 3 个月,过期就要更换,最尴尬的是某些比较重要的东西(比如扶墙服务)证书一旦过期会耽误大事;而不同环境下自动更换证书工具也不一定靠谱,极端时候还是需要自己手动更换,所以催生了我想写个证书过期时间检测的小玩具的想法。

二、http 证书链

了解证书加密体系的应该知道,TLS 证书是链式信任的,所以中间任何一个证书过期、失效都会导致整个信任链断裂,不过单纯的 Let’s Encrypt ACME 证书检测可能只关注末端证书即可,除非哪天 Let’s Encrypt 倒下…

三、Go 的 HTTP 请求

Go 在发送 HTTP 请求后,在响应体中会包含一个 TLS *tls.ConnectionState 结构体,该结构体中目前存放了服务端返回的整个证书链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// ConnectionState records basic TLS details about the connection.
type ConnectionState struct {
// Version is the TLS version used by the connection (e.g. VersionTLS12).
Version uint16

// HandshakeComplete is true if the handshake has concluded.
HandshakeComplete bool

// DidResume is true if this connection was successfully resumed from a
// previous session with a session ticket or similar mechanism.
DidResume bool

// CipherSuite is the cipher suite negotiated for the connection (e.g.
// TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, TLS_AES_128_GCM_SHA256).
CipherSuite uint16

// NegotiatedProtocol is the application protocol negotiated with ALPN.
NegotiatedProtocol string

// NegotiatedProtocolIsMutual used to indicate a mutual NPN negotiation.
//
// Deprecated: this value is always true.
NegotiatedProtocolIsMutual bool

// ServerName is the value of the Server Name Indication extension sent by
// the client. It's available both on the server and on the client side.
ServerName string

// PeerCertificates are the parsed certificates sent by the peer, in the
// order in which they were sent. The first element is the leaf certificate
// that the connection is verified against.
//
// On the client side, it can't be empty. On the server side, it can be
// empty if Config.ClientAuth is not RequireAnyClientCert or
// RequireAndVerifyClientCert.
PeerCertificates []*x509.Certificate

// VerifiedChains is a list of one or more chains where the first element is
// PeerCertificates[0] and the last element is from Config.RootCAs (on the
// client side) or Config.ClientCAs (on the server side).
//
// On the client side, it's set if Config.InsecureSkipVerify is false. On
// the server side, it's set if Config.ClientAuth is VerifyClientCertIfGiven
// (and the peer provided a certificate) or RequireAndVerifyClientCert.
VerifiedChains [][]*x509.Certificate

// SignedCertificateTimestamps is a list of SCTs provided by the peer
// through the TLS handshake for the leaf certificate, if any.
SignedCertificateTimestamps [][]byte

// OCSPResponse is a stapled Online Certificate Status Protocol (OCSP)
// response provided by the peer for the leaf certificate, if any.
OCSPResponse []byte

// TLSUnique contains the "tls-unique" channel binding value (see RFC 5929,
// Section 3). This value will be nil for TLS 1.3 connections and for all
// resumed connections.
//
// Deprecated: there are conditions in which this value might not be unique
// to a connection. See the Security Considerations sections of RFC 5705 and
// RFC 7627, and http://mitls.org/pages/attacks/3SHAKE#channelbindings.
TLSUnique []byte

// ekm is a closure exposed via ExportKeyingMaterial.
ekm func(label string, context []byte, length int) ([]byte, error)
}

根据源码注释可以看到,PeerCertificates 包含了服务端所有证书,那么如果需要检测证书过期时间只需要遍历这个证书切片即可。

四、代码实现

基本需求确定,且确立代码可行性后直接开始 coding:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func checkSSL(beforeTime time.Duration) error {
client := &http.Client{
Transport: &http.Transport{
// 注意如果证书已过期,那么只有在关闭证书校验的情况下链接才能建立成功
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
// 10s 超时后认为服务挂了
Timeout: 10 * time.Second,
}
resp, err := client.Get("http://mritd.com")
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()

// 遍历所有证书
for _, cert := range resp.TLS.PeerCertificates {
// 检测证书是否已经过期
if !cert.NotAfter.After(time.Now()) {
return NewWebSiteError(fmt.Sprintf("Website [http://mritd.com] certificate has expired: %s", cert.NotAfter.Local().Format("2006-01-02 15:04:05")))
}

// 检测证书距离当前时间 是否小于 beforeTime
// 例如 beforeTime = 7d,那么在证书过期前 6d 开始就发出警告
if cert.NotAfter.Sub(time.Now()) < beforeTime {
return NewWebSiteError(fmt.Sprintf("Website [http://mritd.com] certificate will expire, remaining time: %fh", cert.NotAfter.Sub(time.Now()).Hours()))
}
}
return nil
}

五、整合告警

基本检测逻辑完成后,可以尝试集成告警服务,例如 Email、Telegram、微信通知等;告警的实现暂时不在本文讨论范围内,具体完整实现可以参考 http://github.com/mritd/certmonitor,certmonitor 集成了 Telegram,最终效果如下:

ixjtRl

六、其他改进

有些情况下某些服务不一定是完全基于 http 的,所以协议上可以后续去尝试使用 tls 客户端直接链接,还可能需要考虑未来基于 QUIC 的 HTTP3 等,复杂点也要支持文件证书检测… 给我时间我能给自己提一万个需求(今天就先码到这)…


本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 国际许可协议进行许可,转载请注明出处。