Skip to content

Commit b0bf3de

Browse files
jshaCopilotmcpherrinmaarongable
authored
Add AIA certificate prober to boulder-observer (#8624)
Add an AIA certificate prober to Boulder-observer, so we can verify the served certificates have the right Common Name (preventing mixups), content type and encoding. We export the certificate notBefore and notAfter for expiry monitoring purposes. This PR was largely written by Copilot under Matthew's supervision, and is modelled after the CRL Prober. Re-land of #8594, which was accidentally merged to a feature branch (due to branch stacking). Fixes #8593 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Matthew McPherrin <git@mcpherrin.ca> Co-authored-by: Aaron Gable <aaron@letsencrypt.org>
1 parent 05d5387 commit b0bf3de

File tree

8 files changed

+530
-2
lines changed

8 files changed

+530
-2
lines changed

cmd/boulder-observer/README.md

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,12 @@ Prometheus.
2525
* [CRL](#crl)
2626
* [Schema](#schema-4)
2727
* [Example](#example-4)
28-
* [TLS](#tls)
28+
* [AIA](#aia)
2929
* [Schema](#schema-5)
3030
* [Example](#example-5)
31+
* [TLS](#tls)
32+
* [Schema](#schema-6)
33+
* [Example](#example-6)
3134
* [Metrics](#metrics)
3235
* [Global Metrics](#global-metrics)
3336
* [obs_monitors](#obs_monitors)
@@ -36,6 +39,9 @@ Prometheus.
3639
* [obs_crl_this_update](#obs_crl_this_update)
3740
* [obs_crl_next_update](#obs_crl_next_update)
3841
* [obs_crl_revoked_cert_count](#obs_crl_revoked_cert_count)
42+
* [AIA Metrics](#aia-metrics)
43+
* [obs_aia_not_before](#obs_aia_not_before)
44+
* [obs_aia_not_after](#obs_aia_not_after)
3945
* [TLS Metrics](#tls-metrics)
4046
* [obs_crl_this_update](#obs_tls_not_after)
4147
* [obs_crl_next_update](#obs_tls_reason)
@@ -203,6 +209,26 @@ monitors:
203209
url: http://x1.c.lencr.org/
204210
```
205211

212+
#### AIA
213+
214+
##### Schema
215+
216+
`url`: Scheme + Hostname to grab the AIA certificate from (e.g. `http://r3.i.lencr.org/`).
217+
218+
`expectCommonName`: Expected Common Name (CN) of the certificate. The prober verifies the certificate's CN matches this value.
219+
220+
##### Example
221+
222+
```yaml
223+
monitors:
224+
-
225+
period: 1h
226+
kind: AIA
227+
settings:
228+
url: http://r3.i.lencr.org/
229+
expectCommonName: "R3"
230+
```
231+
206232
#### TLS
207233

208234
##### Schema
@@ -321,6 +347,39 @@ Count of revoked certificates in a CRL.
321347

322348
`url`: Url of the CRL
323349

350+
### AIA Metrics
351+
352+
These metrics will be available whenever a valid AIA prober is configured.
353+
354+
#### obs_aia_not_before
355+
356+
Unix timestamp value (in seconds) of the notBefore field for an AIA certificate.
357+
358+
**Labels:**
359+
360+
`url`: URL of the AIA certificate
361+
362+
#### obs_aia_not_after
363+
364+
Unix timestamp value (in seconds) of the notAfter field for an AIA certificate.
365+
366+
**Labels:**
367+
368+
`url`: URL of the AIA certificate
369+
370+
**Example Usage:**
371+
372+
This is a sample rule that alerts when an AIA certificate has a notAfter timestamp indicating that the certificate will expire within the next 30 days:
373+
374+
```yaml
375+
- alert: AIACertExpiresSoon
376+
expr: obs_aia_not_after{url="http://r3.i.lencr.org/"} <= time() + 2592000
377+
labels:
378+
severity: warning
379+
annotations:
380+
description: 'AIA certificate expires within 30 days'
381+
```
382+
324383
### TLS Metrics
325384

326385
These metrics will be available whenever a valid TLS prober is configured.

observer/mon_conf.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import (
1414
// MonConf is exported to receive YAML configuration in `ObsConf`.
1515
type MonConf struct {
1616
Period config.Duration `yaml:"period"`
17-
Kind string `yaml:"kind" validate:"required,oneof=DNS HTTP CRL TLS TCP"`
17+
Kind string `yaml:"kind" validate:"required,oneof=DNS HTTP CRL TLS TCP AIA"`
1818
Settings probers.Settings `yaml:"settings" validate:"min=1,dive"`
1919
}
2020

observer/observer.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55

66
"github.com/letsencrypt/boulder/cmd"
77
blog "github.com/letsencrypt/boulder/log"
8+
_ "github.com/letsencrypt/boulder/observer/probers/aia"
89
_ "github.com/letsencrypt/boulder/observer/probers/crl"
910
_ "github.com/letsencrypt/boulder/observer/probers/dns"
1011
_ "github.com/letsencrypt/boulder/observer/probers/http"

observer/probers/aia/aia.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package probers
2+
3+
import (
4+
"context"
5+
"crypto/x509"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
10+
"github.com/prometheus/client_golang/prometheus"
11+
)
12+
13+
// AIAProbe is the exported 'Prober' object for monitors configured to
14+
// monitor AIA certificate availability & characteristics.
15+
type AIAProbe struct {
16+
url string
17+
expectCommonName string
18+
cNotBefore *prometheus.GaugeVec
19+
cNotAfter *prometheus.GaugeVec
20+
}
21+
22+
// Name returns a string that uniquely identifies the monitor.
23+
func (p AIAProbe) Name() string {
24+
return p.url
25+
}
26+
27+
// Kind returns a name that uniquely identifies the `Kind` of `Prober`.
28+
func (p AIAProbe) Kind() string {
29+
return "AIA"
30+
}
31+
32+
// Probe requests the configured AIA certificate and publishes metrics about it if found.
33+
func (p AIAProbe) Probe(ctx context.Context) error {
34+
req, err := http.NewRequestWithContext(ctx, "GET", p.url, nil)
35+
if err != nil {
36+
return err
37+
}
38+
39+
resp, err := http.DefaultClient.Do(req)
40+
if err != nil {
41+
return err
42+
}
43+
defer resp.Body.Close()
44+
45+
// Check Content-Type header
46+
contentType := resp.Header.Get("Content-Type")
47+
if contentType != "application/pkix-cert" {
48+
return fmt.Errorf("certificate Content-Type is %q but want application/pkix-cert", contentType)
49+
}
50+
51+
body, err := io.ReadAll(resp.Body)
52+
if err != nil {
53+
return err
54+
}
55+
56+
// Parse the DER-encoded certificate
57+
cert, err := x509.ParseCertificate(body)
58+
if err != nil {
59+
return err
60+
}
61+
62+
// Check if the certificate is a CA certificate
63+
if !cert.IsCA {
64+
return fmt.Errorf("certificate is not a CA certificate")
65+
}
66+
67+
// Check if the CommonName matches the expected value
68+
if cert.Subject.CommonName != p.expectCommonName {
69+
return fmt.Errorf("certificate has CN %q but want %q", cert.Subject.CommonName, p.expectCommonName)
70+
}
71+
72+
// Report metrics for this certificate
73+
p.cNotBefore.WithLabelValues(p.url).Set(float64(cert.NotBefore.Unix()))
74+
p.cNotAfter.WithLabelValues(p.url).Set(float64(cert.NotAfter.Unix()))
75+
76+
return nil
77+
}

observer/probers/aia/aia_conf.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package probers
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
7+
"github.com/prometheus/client_golang/prometheus"
8+
9+
"github.com/letsencrypt/boulder/observer/probers"
10+
"github.com/letsencrypt/boulder/strictyaml"
11+
)
12+
13+
const (
14+
notBeforeName = "obs_aia_not_before"
15+
notAfterName = "obs_aia_not_after"
16+
)
17+
18+
// AIAConf is exported to receive YAML configuration
19+
type AIAConf struct {
20+
URL string `yaml:"url"`
21+
ExpectCommonName string `yaml:"expectCommonName"`
22+
}
23+
24+
// Kind returns a name that uniquely identifies the `Kind` of `Configurer`.
25+
func (c AIAConf) Kind() string {
26+
return "AIA"
27+
}
28+
29+
// UnmarshalSettings constructs a AIAConf object from YAML as bytes.
30+
func (c AIAConf) UnmarshalSettings(settings []byte) (probers.Configurer, error) {
31+
var conf AIAConf
32+
err := strictyaml.Unmarshal(settings, &conf)
33+
34+
if err != nil {
35+
return nil, err
36+
}
37+
return conf, nil
38+
}
39+
40+
func (c AIAConf) validateURL() error {
41+
url, err := url.Parse(c.URL)
42+
if err != nil {
43+
return fmt.Errorf(
44+
"invalid 'url', got: %q, expected a valid url", c.URL)
45+
}
46+
if url.Scheme == "" {
47+
return fmt.Errorf(
48+
"invalid 'url', got: %q, missing scheme", c.URL)
49+
}
50+
return nil
51+
}
52+
53+
// MakeProber constructs a `AIAProbe` object from the contents of the
54+
// bound `AIAConf` object. If the `AIAConf` cannot be validated, an
55+
// error appropriate for end-user consumption is returned instead.
56+
func (c AIAConf) MakeProber(collectors map[string]prometheus.Collector) (probers.Prober, error) {
57+
// validate `url`
58+
err := c.validateURL()
59+
if err != nil {
60+
return nil, err
61+
}
62+
63+
// validate `expectCommonName` is provided
64+
if c.ExpectCommonName == "" {
65+
return nil, fmt.Errorf("'expectCommonName' is required")
66+
}
67+
68+
// validate the prometheus collectors that were passed in
69+
coll, ok := collectors[notBeforeName]
70+
if !ok {
71+
return nil, fmt.Errorf("aia prober did not receive collector %q", notBeforeName)
72+
}
73+
notBeforeColl, ok := coll.(*prometheus.GaugeVec)
74+
if !ok {
75+
return nil, fmt.Errorf("aia prober received collector %q of wrong type, got: %T, expected *prometheus.GaugeVec", notBeforeName, coll)
76+
}
77+
78+
coll, ok = collectors[notAfterName]
79+
if !ok {
80+
return nil, fmt.Errorf("aia prober did not receive collector %q", notAfterName)
81+
}
82+
notAfterColl, ok := coll.(*prometheus.GaugeVec)
83+
if !ok {
84+
return nil, fmt.Errorf("aia prober received collector %q of wrong type, got: %T, expected *prometheus.GaugeVec", notAfterName, coll)
85+
}
86+
87+
return AIAProbe{c.URL, c.ExpectCommonName, notBeforeColl, notAfterColl}, nil
88+
}
89+
90+
// Instrument constructs any `prometheus.Collector` objects the `AIAProbe` will
91+
// need to report its own metrics. A map is returned containing the constructed
92+
// objects, indexed by the name of the prometheus metric. If no objects were
93+
// constructed, nil is returned.
94+
func (c AIAConf) Instrument() map[string]prometheus.Collector {
95+
notBefore := prometheus.Collector(prometheus.NewGaugeVec(
96+
prometheus.GaugeOpts{
97+
Name: notBeforeName,
98+
Help: "AIA certificate notBefore Unix timestamp in seconds",
99+
}, []string{"url"},
100+
))
101+
notAfter := prometheus.Collector(prometheus.NewGaugeVec(
102+
prometheus.GaugeOpts{
103+
Name: notAfterName,
104+
Help: "AIA certificate notAfter Unix timestamp in seconds",
105+
}, []string{"url"},
106+
))
107+
return map[string]prometheus.Collector{
108+
notBeforeName: notBefore,
109+
notAfterName: notAfter,
110+
}
111+
}
112+
113+
// init is called at runtime and registers `AIAConf`, a `Prober`
114+
// `Configurer` type, as "AIA".
115+
func init() {
116+
probers.Register(AIAConf{})
117+
}

0 commit comments

Comments
 (0)