github.com/letsencrypt/boulder@v0.20251208.0/observer/probers/tls/tls.go (about) 1 package probers 2 3 import ( 4 "context" 5 "crypto/tls" 6 "crypto/x509" 7 "encoding/base64" 8 "errors" 9 "fmt" 10 "io" 11 "net" 12 "net/http" 13 "time" 14 15 "github.com/prometheus/client_golang/prometheus" 16 "golang.org/x/crypto/ocsp" 17 18 "github.com/letsencrypt/boulder/observer/obsdialer" 19 ) 20 21 type reason int 22 23 const ( 24 none reason = iota 25 internalError 26 revocationStatusError 27 rootDidNotMatch 28 statusDidNotMatch 29 ) 30 31 var reasonToString = map[reason]string{ 32 none: "nil", 33 internalError: "internalError", 34 revocationStatusError: "revocationStatusError", 35 rootDidNotMatch: "rootDidNotMatch", 36 statusDidNotMatch: "statusDidNotMatch", 37 } 38 39 func getReasons() []string { 40 var allReasons []string 41 for _, v := range reasonToString { 42 allReasons = append(allReasons, v) 43 } 44 return allReasons 45 } 46 47 // TLSProbe is the exported `Prober` object for monitors configured to perform 48 // TLS protocols. 49 type TLSProbe struct { 50 hostname string 51 rootOrg string 52 rootCN string 53 response string 54 notAfter *prometheus.GaugeVec 55 notBefore *prometheus.GaugeVec 56 reason *prometheus.CounterVec 57 } 58 59 // Name returns a string that uniquely identifies the monitor. 60 func (p TLSProbe) Name() string { 61 return p.hostname 62 } 63 64 // Kind returns a name that uniquely identifies the `Kind` of `Prober`. 65 func (p TLSProbe) Kind() string { 66 return "TLS" 67 } 68 69 // Get OCSP status (good, revoked or unknown) of certificate 70 func checkOCSP(ctx context.Context, cert, issuer *x509.Certificate, want int) (bool, error) { 71 req, err := ocsp.CreateRequest(cert, issuer, nil) 72 if err != nil { 73 return false, err 74 } 75 76 url := fmt.Sprintf("%s/%s", cert.OCSPServer[0], base64.StdEncoding.EncodeToString(req)) 77 r, err := http.NewRequestWithContext(ctx, "GET", url, nil) 78 if err != nil { 79 return false, err 80 } 81 82 res, err := http.DefaultClient.Do(r) 83 if err != nil { 84 return false, err 85 } 86 87 output, err := io.ReadAll(res.Body) 88 if err != nil { 89 return false, err 90 } 91 92 ocspRes, err := ocsp.ParseResponseForCert(output, cert, issuer) 93 if err != nil { 94 return false, err 95 } 96 97 return ocspRes.Status == want, nil 98 } 99 100 func checkCRL(ctx context.Context, cert, issuer *x509.Certificate, want int) (bool, error) { 101 if len(cert.CRLDistributionPoints) != 1 { 102 return false, errors.New("cert does not contain CRLDP URI") 103 } 104 105 req, err := http.NewRequestWithContext(ctx, "GET", cert.CRLDistributionPoints[0], nil) 106 if err != nil { 107 return false, fmt.Errorf("creating HTTP request: %w", err) 108 } 109 110 resp, err := http.DefaultClient.Do(req) 111 if err != nil { 112 return false, fmt.Errorf("downloading CRL: %w", err) 113 } 114 defer resp.Body.Close() 115 116 der, err := io.ReadAll(resp.Body) 117 if err != nil { 118 return false, fmt.Errorf("reading CRL: %w", err) 119 } 120 121 crl, err := x509.ParseRevocationList(der) 122 if err != nil { 123 return false, fmt.Errorf("parsing CRL: %w", err) 124 } 125 126 err = crl.CheckSignatureFrom(issuer) 127 if err != nil { 128 return false, fmt.Errorf("validating CRL: %w", err) 129 } 130 131 for _, entry := range crl.RevokedCertificateEntries { 132 if entry.SerialNumber.Cmp(cert.SerialNumber) == 0 { 133 return want == ocsp.Revoked, nil 134 } 135 } 136 return want == ocsp.Good, nil 137 } 138 139 // Return an error if the root settings are nonempty and do not match the 140 // expected root. 141 func (p TLSProbe) checkRoot(rootOrg, rootCN string) error { 142 if (p.rootCN == "" && p.rootOrg == "") || (rootOrg == p.rootOrg && rootCN == p.rootCN) { 143 return nil 144 } 145 return fmt.Errorf("Expected root does not match.") 146 } 147 148 // Export expiration timestamp and reason to Prometheus. 149 func (p TLSProbe) exportMetrics(cert *x509.Certificate, reason reason) { 150 if cert != nil { 151 p.notAfter.WithLabelValues(p.hostname).Set(float64(cert.NotAfter.Unix())) 152 p.notBefore.WithLabelValues(p.hostname).Set(float64(cert.NotBefore.Unix())) 153 } 154 p.reason.WithLabelValues(p.hostname, reasonToString[reason]).Inc() 155 } 156 157 func (p TLSProbe) probeExpired(timeout time.Duration) bool { 158 addr := p.hostname 159 _, _, err := net.SplitHostPort(addr) 160 if err != nil { 161 addr = net.JoinHostPort(addr, "443") 162 } 163 164 tlsDialer := tls.Dialer{ 165 NetDialer: &obsdialer.Dialer, 166 Config: &tls.Config{ 167 // Set InsecureSkipVerify to skip the default validation we are 168 // replacing. This will not disable VerifyConnection. 169 InsecureSkipVerify: true, 170 VerifyConnection: func(cs tls.ConnectionState) error { 171 issuers := x509.NewCertPool() 172 for _, cert := range cs.PeerCertificates[1:] { 173 issuers.AddCert(cert) 174 } 175 opts := x509.VerifyOptions{ 176 // We set the current time to be the cert's expiration date so that 177 // the validation routine doesn't complain that the cert is expired. 178 CurrentTime: cs.PeerCertificates[0].NotAfter, 179 // By settings roots and intermediates to be whatever was presented 180 // in the handshake, we're saying that we don't care about the cert 181 // chaining up to the system trust store. This is safe because we 182 // check the root ourselves in checkRoot(). 183 Intermediates: issuers, 184 Roots: issuers, 185 } 186 _, err := cs.PeerCertificates[0].Verify(opts) 187 return err 188 }, 189 }, 190 } 191 192 ctx, cancel := context.WithTimeout(context.Background(), timeout) 193 defer cancel() 194 195 conn, err := tlsDialer.DialContext(ctx, "tcp", addr) 196 if err != nil { 197 p.exportMetrics(nil, internalError) 198 return false 199 } 200 defer conn.Close() 201 202 // tls.Dialer.DialContext is documented to always return *tls.Conn 203 peers := conn.(*tls.Conn).ConnectionState().PeerCertificates 204 if time.Until(peers[0].NotAfter) > 0 { 205 p.exportMetrics(peers[0], statusDidNotMatch) 206 return false 207 } 208 209 root := peers[len(peers)-1].Issuer 210 err = p.checkRoot(root.Organization[0], root.CommonName) 211 if err != nil { 212 p.exportMetrics(peers[0], rootDidNotMatch) 213 return false 214 } 215 216 p.exportMetrics(peers[0], none) 217 return true 218 } 219 220 func (p TLSProbe) probeUnexpired(timeout time.Duration) bool { 221 addr := p.hostname 222 _, _, err := net.SplitHostPort(addr) 223 if err != nil { 224 addr = net.JoinHostPort(addr, "443") 225 } 226 227 tlsDialer := tls.Dialer{ 228 NetDialer: &obsdialer.Dialer, 229 Config: &tls.Config{ 230 // Set InsecureSkipVerify to skip the default validation we are 231 // replacing. This will not disable VerifyConnection. 232 InsecureSkipVerify: true, 233 VerifyConnection: func(cs tls.ConnectionState) error { 234 issuers := x509.NewCertPool() 235 for _, cert := range cs.PeerCertificates[1:] { 236 issuers.AddCert(cert) 237 } 238 opts := x509.VerifyOptions{ 239 // By settings roots and intermediates to be whatever was presented 240 // in the handshake, we're saying that we don't care about the cert 241 // chaining up to the system trust store. This is safe because we 242 // check the root ourselves in checkRoot(). 243 Intermediates: issuers, 244 Roots: issuers, 245 } 246 _, err := cs.PeerCertificates[0].Verify(opts) 247 return err 248 }, 249 }, 250 } 251 252 ctx, cancel := context.WithTimeout(context.Background(), timeout) 253 defer cancel() 254 255 conn, err := tlsDialer.DialContext(ctx, "tcp", addr) 256 if err != nil { 257 p.exportMetrics(nil, internalError) 258 return false 259 } 260 defer conn.Close() 261 262 // tls.Dialer.DialContext is documented to always return *tls.Conn 263 peers := conn.(*tls.Conn).ConnectionState().PeerCertificates 264 root := peers[len(peers)-1].Issuer 265 err = p.checkRoot(root.Organization[0], root.CommonName) 266 if err != nil { 267 p.exportMetrics(peers[0], rootDidNotMatch) 268 return false 269 } 270 271 var wantStatus int 272 switch p.response { 273 case "valid": 274 wantStatus = ocsp.Good 275 case "revoked": 276 wantStatus = ocsp.Revoked 277 } 278 279 var statusMatch bool 280 if len(peers[0].OCSPServer) != 0 { 281 statusMatch, err = checkOCSP(ctx, peers[0], peers[1], wantStatus) 282 } else { 283 statusMatch, err = checkCRL(ctx, peers[0], peers[1], wantStatus) 284 } 285 if err != nil { 286 p.exportMetrics(peers[0], revocationStatusError) 287 return false 288 } 289 290 if !statusMatch { 291 p.exportMetrics(peers[0], statusDidNotMatch) 292 return false 293 } 294 295 p.exportMetrics(peers[0], none) 296 return true 297 } 298 299 // Probe performs the configured TLS probe. Return true if the root has the 300 // expected Subject (or if no root is provided for comparison in settings), and 301 // the end entity certificate has the correct expiration status (either expired 302 // or unexpired, depending on what is configured). Exports metrics for the 303 // NotAfter timestamp of the end entity certificate and the reason for the Probe 304 // returning false ("none" if returns true). 305 func (p TLSProbe) Probe(timeout time.Duration) (bool, time.Duration) { 306 start := time.Now() 307 var success bool 308 if p.response == "expired" { 309 success = p.probeExpired(timeout) 310 } else { 311 success = p.probeUnexpired(timeout) 312 } 313 314 return success, time.Since(start) 315 }