Golang 监控 HTTPS 证书过期时间

一、业务需求

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

二、HTTPS 证书链

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

三、Go 的 HTTP 请求

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

// 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 https://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:

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("https://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 [https://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 [https://mritd.com] certificate will expire, remaining time: %fh", cert.NotAfter.Sub(time.Now()).Hours()))
		}
	}
	return nil
}

五、整合告警

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

ixjtRl

六、其他改进

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


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