315 lines
8.3 KiB
Go
315 lines
8.3 KiB
Go
package probers
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/prometheus/client_golang/prometheus"
|
|
"golang.org/x/crypto/ocsp"
|
|
|
|
"github.com/letsencrypt/boulder/observer/obsdialer"
|
|
)
|
|
|
|
type reason int
|
|
|
|
const (
|
|
none reason = iota
|
|
internalError
|
|
revocationStatusError
|
|
rootDidNotMatch
|
|
statusDidNotMatch
|
|
)
|
|
|
|
var reasonToString = map[reason]string{
|
|
none: "nil",
|
|
internalError: "internalError",
|
|
revocationStatusError: "revocationStatusError",
|
|
rootDidNotMatch: "rootDidNotMatch",
|
|
statusDidNotMatch: "statusDidNotMatch",
|
|
}
|
|
|
|
func getReasons() []string {
|
|
var allReasons []string
|
|
for _, v := range reasonToString {
|
|
allReasons = append(allReasons, v)
|
|
}
|
|
return allReasons
|
|
}
|
|
|
|
// TLSProbe is the exported `Prober` object for monitors configured to perform
|
|
// TLS protocols.
|
|
type TLSProbe struct {
|
|
hostname string
|
|
rootOrg string
|
|
rootCN string
|
|
response string
|
|
notAfter *prometheus.GaugeVec
|
|
notBefore *prometheus.GaugeVec
|
|
reason *prometheus.CounterVec
|
|
}
|
|
|
|
// Name returns a string that uniquely identifies the monitor.
|
|
func (p TLSProbe) Name() string {
|
|
return p.hostname
|
|
}
|
|
|
|
// Kind returns a name that uniquely identifies the `Kind` of `Prober`.
|
|
func (p TLSProbe) Kind() string {
|
|
return "TLS"
|
|
}
|
|
|
|
// Get OCSP status (good, revoked or unknown) of certificate
|
|
func checkOCSP(ctx context.Context, cert, issuer *x509.Certificate, want int) (bool, error) {
|
|
req, err := ocsp.CreateRequest(cert, issuer, nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/%s", cert.OCSPServer[0], base64.StdEncoding.EncodeToString(req))
|
|
r, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
res, err := http.DefaultClient.Do(r)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
output, err := io.ReadAll(res.Body)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
ocspRes, err := ocsp.ParseResponseForCert(output, cert, issuer)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return ocspRes.Status == want, nil
|
|
}
|
|
|
|
func checkCRL(ctx context.Context, cert, issuer *x509.Certificate, want int) (bool, error) {
|
|
if len(cert.CRLDistributionPoints) != 1 {
|
|
return false, errors.New("cert does not contain CRLDP URI")
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", cert.CRLDistributionPoints[0], nil)
|
|
if err != nil {
|
|
return false, fmt.Errorf("creating HTTP request: %w", err)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return false, fmt.Errorf("downloading CRL: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
der, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return false, fmt.Errorf("reading CRL: %w", err)
|
|
}
|
|
|
|
crl, err := x509.ParseRevocationList(der)
|
|
if err != nil {
|
|
return false, fmt.Errorf("parsing CRL: %w", err)
|
|
}
|
|
|
|
err = crl.CheckSignatureFrom(issuer)
|
|
if err != nil {
|
|
return false, fmt.Errorf("validating CRL: %w", err)
|
|
}
|
|
|
|
for _, entry := range crl.RevokedCertificateEntries {
|
|
if entry.SerialNumber.Cmp(cert.SerialNumber) == 0 {
|
|
return want == ocsp.Revoked, nil
|
|
}
|
|
}
|
|
return want == ocsp.Good, nil
|
|
}
|
|
|
|
// Return an error if the root settings are nonempty and do not match the
|
|
// expected root.
|
|
func (p TLSProbe) checkRoot(rootOrg, rootCN string) error {
|
|
if (p.rootCN == "" && p.rootOrg == "") || (rootOrg == p.rootOrg && rootCN == p.rootCN) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("Expected root does not match.")
|
|
}
|
|
|
|
// Export expiration timestamp and reason to Prometheus.
|
|
func (p TLSProbe) exportMetrics(cert *x509.Certificate, reason reason) {
|
|
if cert != nil {
|
|
p.notAfter.WithLabelValues(p.hostname).Set(float64(cert.NotAfter.Unix()))
|
|
p.notBefore.WithLabelValues(p.hostname).Set(float64(cert.NotBefore.Unix()))
|
|
}
|
|
p.reason.WithLabelValues(p.hostname, reasonToString[reason]).Inc()
|
|
}
|
|
|
|
func (p TLSProbe) probeExpired(timeout time.Duration) bool {
|
|
addr := p.hostname
|
|
_, _, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
addr = net.JoinHostPort(addr, "443")
|
|
}
|
|
|
|
tlsDialer := tls.Dialer{
|
|
NetDialer: &obsdialer.Dialer,
|
|
Config: &tls.Config{
|
|
// Set InsecureSkipVerify to skip the default validation we are
|
|
// replacing. This will not disable VerifyConnection.
|
|
InsecureSkipVerify: true,
|
|
VerifyConnection: func(cs tls.ConnectionState) error {
|
|
issuers := x509.NewCertPool()
|
|
for _, cert := range cs.PeerCertificates[1:] {
|
|
issuers.AddCert(cert)
|
|
}
|
|
opts := x509.VerifyOptions{
|
|
// We set the current time to be the cert's expiration date so that
|
|
// the validation routine doesn't complain that the cert is expired.
|
|
CurrentTime: cs.PeerCertificates[0].NotAfter,
|
|
// By settings roots and intermediates to be whatever was presented
|
|
// in the handshake, we're saying that we don't care about the cert
|
|
// chaining up to the system trust store. This is safe because we
|
|
// check the root ourselves in checkRoot().
|
|
Intermediates: issuers,
|
|
Roots: issuers,
|
|
}
|
|
_, err := cs.PeerCertificates[0].Verify(opts)
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
conn, err := tlsDialer.DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
p.exportMetrics(nil, internalError)
|
|
return false
|
|
}
|
|
defer conn.Close()
|
|
|
|
// tls.Dialer.DialContext is documented to always return *tls.Conn
|
|
peers := conn.(*tls.Conn).ConnectionState().PeerCertificates
|
|
if time.Until(peers[0].NotAfter) > 0 {
|
|
p.exportMetrics(peers[0], statusDidNotMatch)
|
|
return false
|
|
}
|
|
|
|
root := peers[len(peers)-1].Issuer
|
|
err = p.checkRoot(root.Organization[0], root.CommonName)
|
|
if err != nil {
|
|
p.exportMetrics(peers[0], rootDidNotMatch)
|
|
return false
|
|
}
|
|
|
|
p.exportMetrics(peers[0], none)
|
|
return true
|
|
}
|
|
|
|
func (p TLSProbe) probeUnexpired(timeout time.Duration) bool {
|
|
addr := p.hostname
|
|
_, _, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
addr = net.JoinHostPort(addr, "443")
|
|
}
|
|
|
|
tlsDialer := tls.Dialer{
|
|
NetDialer: &obsdialer.Dialer,
|
|
Config: &tls.Config{
|
|
// Set InsecureSkipVerify to skip the default validation we are
|
|
// replacing. This will not disable VerifyConnection.
|
|
InsecureSkipVerify: true,
|
|
VerifyConnection: func(cs tls.ConnectionState) error {
|
|
issuers := x509.NewCertPool()
|
|
for _, cert := range cs.PeerCertificates[1:] {
|
|
issuers.AddCert(cert)
|
|
}
|
|
opts := x509.VerifyOptions{
|
|
// By settings roots and intermediates to be whatever was presented
|
|
// in the handshake, we're saying that we don't care about the cert
|
|
// chaining up to the system trust store. This is safe because we
|
|
// check the root ourselves in checkRoot().
|
|
Intermediates: issuers,
|
|
Roots: issuers,
|
|
}
|
|
_, err := cs.PeerCertificates[0].Verify(opts)
|
|
return err
|
|
},
|
|
},
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
|
|
conn, err := tlsDialer.DialContext(ctx, "tcp", addr)
|
|
if err != nil {
|
|
p.exportMetrics(nil, internalError)
|
|
return false
|
|
}
|
|
defer conn.Close()
|
|
|
|
// tls.Dialer.DialContext is documented to always return *tls.Conn
|
|
peers := conn.(*tls.Conn).ConnectionState().PeerCertificates
|
|
root := peers[len(peers)-1].Issuer
|
|
err = p.checkRoot(root.Organization[0], root.CommonName)
|
|
if err != nil {
|
|
p.exportMetrics(peers[0], rootDidNotMatch)
|
|
return false
|
|
}
|
|
|
|
var wantStatus int
|
|
switch p.response {
|
|
case "valid":
|
|
wantStatus = ocsp.Good
|
|
case "revoked":
|
|
wantStatus = ocsp.Revoked
|
|
}
|
|
|
|
var statusMatch bool
|
|
if len(peers[0].OCSPServer) != 0 {
|
|
statusMatch, err = checkOCSP(ctx, peers[0], peers[1], wantStatus)
|
|
} else {
|
|
statusMatch, err = checkCRL(ctx, peers[0], peers[1], wantStatus)
|
|
}
|
|
if err != nil {
|
|
p.exportMetrics(peers[0], revocationStatusError)
|
|
return false
|
|
}
|
|
|
|
if !statusMatch {
|
|
p.exportMetrics(peers[0], statusDidNotMatch)
|
|
return false
|
|
}
|
|
|
|
p.exportMetrics(peers[0], none)
|
|
return true
|
|
}
|
|
|
|
// Probe performs the configured TLS probe. Return true if the root has the
|
|
// expected Subject (or if no root is provided for comparison in settings), and
|
|
// the end entity certificate has the correct expiration status (either expired
|
|
// or unexpired, depending on what is configured). Exports metrics for the
|
|
// NotAfter timestamp of the end entity certificate and the reason for the Probe
|
|
// returning false ("none" if returns true).
|
|
func (p TLSProbe) Probe(timeout time.Duration) (bool, time.Duration) {
|
|
start := time.Now()
|
|
var success bool
|
|
if p.response == "expired" {
|
|
success = p.probeExpired(timeout)
|
|
} else {
|
|
success = p.probeUnexpired(timeout)
|
|
}
|
|
|
|
return success, time.Since(start)
|
|
}
|