8

Golang 监控 HTTPS 证书过期时间

 2 years ago
source link: https://mritd.com/2021/05/31/golang-check-certificate-expiration-time/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

一、业务需求

由于近几年 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,最终效果如下:

六、其他改进

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK