Skip to content

Commit d693a51

Browse files
committed
[receiver/tlscheckreceiver] allow scraping all certs
This allows scraping all certificates in a PEM bundle, or all certificates returned by an endpoint, rather than just the leaf certificate. It can be useful to monitor which certificates are used in a bundle, or monitor when intermediate certificates are due to expire. Signed-off-by: Hugh Cole-Baker <[email protected]>
1 parent ef13717 commit d693a51

File tree

4 files changed

+108
-61
lines changed

4 files changed

+108
-61
lines changed

receiver/tlscheckreceiver/README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ By default, the TLS Check Receiver will emit a single metric, `tlscheck.time_lef
2121

2222
## Example Configuration
2323

24-
Targets are configured as either remote enpoints accessed via TCP, or PEM-encoded certificate files stored locally on disk.
24+
Targets are configured as either remote enpoints accessed via TCP, or PEM-encoded certificate files stored locally on disk. By default only the first certificate in a file or presented by an endpoint (i.e. the leaf certificate) is monitored. The `scrape_all_certs` option can be used to monitor all certificates in a PEM bundle or all certificates presented by an endpoint.
2525

2626
```yaml
2727
receivers:
@@ -30,6 +30,10 @@ receivers:
3030
# Monitor a local PEM file
3131
- file_path: /etc/istio/certs/cert-chain.pem
3232

33+
# Monitor all certificates in a PEM bundle
34+
- file_path: /etc/ssl/certs/ca-bundle.crt
35+
scrape_all_certs: true
36+
3337
# Monitor a remote endpoint
3438
- endpoint: example.com:443
3539

receiver/tlscheckreceiver/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ var errInvalidEndpoint = errors.New(`"endpoint" must be in the form of <hostname
2121
type CertificateTarget struct {
2222
confignet.TCPAddrConfig `mapstructure:",squash"`
2323
FilePath string `mapstructure:"file_path"`
24+
ScrapeAllCerts bool `mapstructure:"scrape_all_certs"`
2425

2526
// prevent unkeyed literal initialization
2627
_ struct{}

receiver/tlscheckreceiver/scraper.go

Lines changed: 44 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -109,30 +109,7 @@ func getConnectionState(endpoint string) (tls.ConnectionState, error) {
109109
return conn.ConnectionState(), nil
110110
}
111111

112-
func (s *scraper) scrapeEndpoint(endpoint string, metrics *pmetric.Metrics, wg *sync.WaitGroup, mux *sync.Mutex, errs chan error) {
113-
defer wg.Done()
114-
if err := validateEndpoint(endpoint); err != nil {
115-
s.settings.Logger.Error("Failed to validate endpoint", zap.String("endpoint", endpoint), zap.Error(err))
116-
errs <- err
117-
return
118-
}
119-
120-
state, err := s.getConnectionState(endpoint)
121-
if err != nil {
122-
s.settings.Logger.Error("TCP connection error encountered", zap.String("endpoint", endpoint), zap.Error(err))
123-
errs <- err
124-
return
125-
}
126-
127-
s.settings.Logger.Debug("Peer Certificates", zap.Int("certificates_count", len(state.PeerCertificates)))
128-
if len(state.PeerCertificates) == 0 {
129-
err := fmt.Errorf("no TLS certificates found for endpoint: %s. Verify the endpoint serves TLS certificates", endpoint)
130-
s.settings.Logger.Error(err.Error(), zap.String("endpoint", endpoint))
131-
errs <- err
132-
return
133-
}
134-
135-
cert := state.PeerCertificates[0]
112+
func recordOneCert(mb *metadata.MetricsBuilder, now pcommon.Timestamp, cert *x509.Certificate) {
136113
issuer := cert.Issuer.String()
137114
commonName := cert.Subject.CommonName
138115
sans := make([]any, len(cert.DNSNames)+len(cert.IPAddresses)+len(cert.URIs)+len(cert.EmailAddresses))
@@ -160,20 +137,51 @@ func (s *scraper) scrapeEndpoint(endpoint string, metrics *pmetric.Metrics, wg *
160137
currentTime := time.Now()
161138
timeLeft := cert.NotAfter.Sub(currentTime).Seconds()
162139
timeLeftInt := int64(timeLeft)
163-
now := pcommon.NewTimestampFromTime(time.Now())
140+
141+
mb.RecordTlscheckTimeLeftDataPoint(now, timeLeftInt, issuer, commonName, sans)
142+
}
143+
144+
func (s *scraper) scrapeEndpoint(endpoint string, scrapeAll bool, metrics *pmetric.Metrics, wg *sync.WaitGroup, mux *sync.Mutex, errs chan error) {
145+
defer wg.Done()
146+
if err := validateEndpoint(endpoint); err != nil {
147+
s.settings.Logger.Error("Failed to validate endpoint", zap.String("endpoint", endpoint), zap.Error(err))
148+
errs <- err
149+
return
150+
}
151+
152+
state, err := s.getConnectionState(endpoint)
153+
if err != nil {
154+
s.settings.Logger.Error("TCP connection error encountered", zap.String("endpoint", endpoint), zap.Error(err))
155+
errs <- err
156+
return
157+
}
158+
159+
s.settings.Logger.Debug("Peer Certificates", zap.Int("certificates_count", len(state.PeerCertificates)))
160+
if len(state.PeerCertificates) == 0 {
161+
err := fmt.Errorf("no TLS certificates found for endpoint: %s. Verify the endpoint serves TLS certificates", endpoint)
162+
s.settings.Logger.Error(err.Error(), zap.String("endpoint", endpoint))
163+
errs <- err
164+
return
165+
}
164166

165167
mux.Lock()
166168
defer mux.Unlock()
167169

168170
mb := metadata.NewMetricsBuilder(s.cfg.MetricsBuilderConfig, s.settings, metadata.WithStartTime(pcommon.NewTimestampFromTime(time.Now())))
169171
rb := mb.NewResourceBuilder()
170172
rb.SetTlscheckTarget(endpoint)
171-
mb.RecordTlscheckTimeLeftDataPoint(now, timeLeftInt, issuer, commonName, sans)
173+
now := pcommon.NewTimestampFromTime(time.Now())
174+
for _, cert := range state.PeerCertificates {
175+
recordOneCert(mb, now, cert)
176+
if !scrapeAll {
177+
break
178+
}
179+
}
172180
resourceMetrics := mb.Emit(metadata.WithResource(rb.Emit()))
173181
resourceMetrics.ResourceMetrics().At(0).MoveTo(metrics.ResourceMetrics().AppendEmpty())
174182
}
175183

176-
func (s *scraper) scrapeFile(filePath string, metrics *pmetric.Metrics, wg *sync.WaitGroup, mux *sync.Mutex, errs chan error) {
184+
func (s *scraper) scrapeFile(filePath string, scrapeAll bool, metrics *pmetric.Metrics, wg *sync.WaitGroup, mux *sync.Mutex, errs chan error) {
177185
defer wg.Done()
178186
if err := validateFilepath(filePath); err != nil {
179187
s.settings.Logger.Error("Failed to validate certificate file", zap.String("file_path", filePath), zap.Error(err))
@@ -226,43 +234,19 @@ func (s *scraper) scrapeFile(filePath string, metrics *pmetric.Metrics, wg *sync
226234

227235
s.settings.Logger.Debug("Found certificates in chain", zap.String("file_path", filePath), zap.Int("count", len(certs)))
228236

229-
cert := certs[0] // Use the leaf certificate
230-
issuer := cert.Issuer.String()
231-
commonName := cert.Subject.CommonName
232-
sans := make([]any, len(cert.DNSNames)+len(cert.IPAddresses)+len(cert.URIs)+len(cert.EmailAddresses))
233-
i := 0
234-
for _, ip := range cert.IPAddresses {
235-
sans[i] = ip.String()
236-
i++
237-
}
238-
239-
for _, uri := range cert.URIs {
240-
sans[i] = uri.String()
241-
i++
242-
}
243-
244-
for _, dnsName := range cert.DNSNames {
245-
sans[i] = dnsName
246-
i++
247-
}
248-
249-
for _, emailAddress := range cert.EmailAddresses {
250-
sans[i] = emailAddress
251-
i++
252-
}
253-
254-
currentTime := time.Now()
255-
timeLeft := cert.NotAfter.Sub(currentTime).Seconds()
256-
timeLeftInt := int64(timeLeft)
257-
now := pcommon.NewTimestampFromTime(time.Now())
258-
259237
mux.Lock()
260238
defer mux.Unlock()
261239

262240
mb := metadata.NewMetricsBuilder(s.cfg.MetricsBuilderConfig, s.settings, metadata.WithStartTime(pcommon.NewTimestampFromTime(time.Now())))
263241
rb := mb.NewResourceBuilder()
264242
rb.SetTlscheckTarget(filePath)
265-
mb.RecordTlscheckTimeLeftDataPoint(now, timeLeftInt, issuer, commonName, sans)
243+
now := pcommon.NewTimestampFromTime(time.Now())
244+
for _, cert := range certs {
245+
recordOneCert(mb, now, cert)
246+
if !scrapeAll {
247+
break
248+
}
249+
}
266250
resourceMetrics := mb.Emit(metadata.WithResource(rb.Emit()))
267251
resourceMetrics.ResourceMetrics().At(0).MoveTo(metrics.ResourceMetrics().AppendEmpty())
268252
}
@@ -288,9 +272,9 @@ func (s *scraper) scrape(ctx context.Context) (pmetric.Metrics, error) {
288272

289273
for _, target := range s.cfg.Targets {
290274
if target.FilePath != "" {
291-
go s.scrapeFile(target.FilePath, &metrics, &wg, &mux, errChan)
275+
go s.scrapeFile(target.FilePath, target.ScrapeAllCerts, &metrics, &wg, &mux, errChan)
292276
} else {
293-
go s.scrapeEndpoint(target.Endpoint, &metrics, &wg, &mux, errChan)
277+
go s.scrapeEndpoint(target.Endpoint, target.ScrapeAllCerts, &metrics, &wg, &mux, errChan)
294278
}
295279
}
296280

receiver/tlscheckreceiver/scraper_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ func TestScrape_ValidFilepathCertificate(t *testing.T) {
319319
rm := metrics.ResourceMetrics().At(0)
320320
ilms := rm.ScopeMetrics().At(0)
321321
metric := ilms.Metrics().At(0)
322+
assert.Equal(t, 1, metric.Gauge().DataPoints().Len())
322323
dp := metric.Gauge().DataPoints().At(0)
323324
target, exists := rm.Resource().Attributes().Get("tlscheck.target")
324325
require.True(t, exists)
@@ -336,6 +337,63 @@ func TestScrape_ValidFilepathCertificate(t *testing.T) {
336337
assert.Positive(t, timeLeft, "Time left should be positive for a valid cert")
337338
}
338339

340+
func TestScrape_AllFilepathCertificates(t *testing.T) {
341+
caCertFile := createMockCertFile(t, time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
342+
cfg := &Config{
343+
Targets: []*CertificateTarget{
344+
{
345+
FilePath: caCertFile,
346+
ScrapeAllCerts: true,
347+
},
348+
},
349+
MetricsBuilderConfig: metadata.DefaultMetricsBuilderConfig(),
350+
}
351+
factory := receivertest.NewNopFactory()
352+
settings := receivertest.NewNopSettings(factory.Type())
353+
s := newScraper(cfg, settings, mockGetConnectionStateValid)
354+
355+
metrics, err := s.scrape(t.Context())
356+
require.NoError(t, err)
357+
assert.Equal(t, 1, metrics.ResourceMetrics().Len())
358+
359+
rm := metrics.ResourceMetrics().At(0)
360+
ilms := rm.ScopeMetrics().At(0)
361+
metric := ilms.Metrics().At(0)
362+
363+
target, exists := rm.Resource().Attributes().Get("tlscheck.target")
364+
require.True(t, exists)
365+
assert.Equal(t, caCertFile, target.AsString())
366+
assert.Equal(t, 2, metric.Gauge().DataPoints().Len())
367+
368+
expectedAttrs := []struct {
369+
issuer string
370+
commonName string
371+
}{
372+
{
373+
issuer: "CN=FooIssuer",
374+
commonName: "test.example.com",
375+
},
376+
{
377+
issuer: "CN=FooIssuer",
378+
commonName: "FooIssuer",
379+
},
380+
}
381+
for index, attrs := range expectedAttrs {
382+
dp := metric.Gauge().DataPoints().At(index)
383+
384+
// Verify the metric attributes
385+
attributes := dp.Attributes()
386+
issuer, _ := attributes.Get("tlscheck.x509.issuer")
387+
commonName, _ := attributes.Get("tlscheck.x509.cn")
388+
assert.Equal(t, attrs.issuer, issuer.AsString(), "Incorrect issuer for target %s", caCertFile)
389+
assert.Equal(t, attrs.commonName, commonName.AsString(), "Incorrect common name for target %s", caCertFile)
390+
391+
// Verify positive time left on cert
392+
timeLeft := dp.IntValue()
393+
assert.Positive(t, timeLeft, "Time left should be positive for a valid cert")
394+
}
395+
}
396+
339397
func TestValidateEndpoint(t *testing.T) {
340398
testCases := []struct {
341399
desc string

0 commit comments

Comments
 (0)