Skip to content

Commit

Permalink
Add certificate perfdata metrics
Browse files Browse the repository at this point in the history
Add performance data metrics to track upcoming expirations
and total certificate types (scopes) in the chain:

- `expires_leaf`
- `expires_intermediate`
- `certs_present_leaf`
- `certs_present_intermediate`
- `certs_present_root`
- `certs_present_unknown`

As part of providing these performance data metrics various helper
functions were added:

- `certs.NumLeafCerts`
- `certs.NumIntermediateCerts`
- `certs.NumRootCerts`
- `certs.NumUnknownCerts`
- `certs.LeafCerts`
- `certs.IntermediateCerts`
- `certs.RootCerts`
- `certs.OldestLeafCert`
- `certs.OldestIntermediateCert`
- `certs.OldestRootCert`
- `certs.ExpiresInDays`

The README file has been updated to list the purpose of each
performance data metric.

As of this commit, failure to collect performance data is emitted as
an error message and recorded in the plugin's error collection (for
display in `LongServiceOutput`). Future work is scheduled to revisit
this choice and audit exit states as a whole to determine if more
appropriate exit states should be used.

refs GH-445
refs GH-464
  • Loading branch information
atc0005 committed Jan 31, 2023
1 parent 728e750 commit 1a81170
Show file tree
Hide file tree
Showing 4 changed files with 338 additions and 0 deletions.
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Go-based tooling to check/verify certs (e.g., as part of a Nagios service check)
- [Project home](#project-home)
- [Overview](#overview)
- [`check_certs`](#check_certs)
- [Performance Data](#performance-data)
- [`lscert`](#lscert)
- [`certsum`](#certsum)
- [Features](#features)
Expand Down Expand Up @@ -136,6 +137,29 @@ breaking changes.

---

#### Performance Data

Initial support has been added for emitting Performance Data / Metrics, but
refinement suggestions are welcome.

Consult the tables below for the metrics implemented thus far.

Please add to an existing
[Discussion](https://github.com/atc0005/check-cert/discussions) thread
(if applicable) or [open a new
one](https://github.com/atc0005/check-cert/discussions/new) with any
feedback that you may have. Thanks in advance!

| Emitted Performance Data / Metric | Meaning |
| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `time` | Runtime for plugin |
| `expires_leaf` | Days remaining before leaf (aka, "server") certificate expires. If multiple leaf certificates are present (invalid configuration), the one expiring soonest is reported. |
| `expires_intermediate` | Days remaining before the next to expire intermediate certificate expires. |
| `certs_present_leaf` | Number of leaf (aka, "server") certificates present in the chain. |
| `certs_present_intermediate` | Number of intermediate certificates present in the chain. |
| `certs_present_root` | Number of root certificates present in the chain. |
| `certs_present_unknown` | Number of certificates present in the chain with an unknown scope (i.e., the plugin cannot determine whether a leaf, intermediate or root). Please [report this scenario](https://github.com/atc0005/check-cert/issues/new/choose). |

### `lscert`

The `lscert` CLI app is used to generate a summary of certificate chain
Expand Down
23 changes: 23 additions & 0 deletions cmd/check_cert/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,29 @@ func main() {
)
}

pd, perfDataErr := getPerfData(certChain, cfg.AgeCritical, cfg.AgeWarning)
if perfDataErr != nil {
log.Error().
Err(perfDataErr).
Msg("failed to generate performance data")

// Surface the error in plugin output.
plugin.AddError(perfDataErr)

// TODO: Abort plugin execution with UNKNOWN status?
}

if err := plugin.AddPerfData(false, pd...); err != nil {
log.Error().
Err(err).
Msg("failed to add performance data")

// Surface the error in plugin output.
plugin.AddError(err)

// TODO: Abort plugin execution with UNKNOWN status?
}

switch {
case validationResults.HasFailed():

Expand Down
106 changes: 106 additions & 0 deletions cmd/check_cert/perfdata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright 2023 Adam Chalkley
//
// https://github.com/atc0005/check-cert
//
// Licensed under the MIT License. See LICENSE file in the project root for
// full license information.

package main

import (
"crypto/x509"
"fmt"
"strconv"

"github.com/atc0005/check-cert/internal/certs"
"github.com/atc0005/go-nagios"
)

// getPerfData generates performance data metrics from the given certificate
// chain and certificate age thresholds. An error is returned if any are
// encountered while gathering metrics or if an empty certificate chain is
// provided.
func getPerfData(certChain []*x509.Certificate, ageCritical int, ageWarning int) ([]nagios.PerformanceData, error) {
if len(certChain) == 0 {
return nil, fmt.Errorf(
"func getPerfData: unable to generate metrics: %w",
certs.ErrMissingValue,
)
}

var expiresLeaf int
oldestLeaf := certs.OldestLeafCert(certChain)
if daysToExpiration, err := certs.ExpiresInDays(oldestLeaf); err == nil {
expiresLeaf = daysToExpiration
}

var expiresIntermediate int
oldestIntermediate := certs.OldestIntermediateCert(certChain)
if daysToExpiration, err := certs.ExpiresInDays(oldestIntermediate); err == nil {
expiresIntermediate = daysToExpiration
}

var expiresRoot int
oldestRoot := certs.OldestRootCert(certChain)
if daysToExpiration, err := certs.ExpiresInDays(oldestRoot); err == nil {
expiresRoot = daysToExpiration
}

// TODO: Should we emit this metric?
_ = expiresRoot

certsPresentLeaf := strconv.Itoa(certs.NumLeafCerts(certChain))
certsPresentIntermediate := strconv.Itoa(certs.NumIntermediateCerts(certChain))
certsPresentRoot := strconv.Itoa(certs.NumRootCerts(certChain))
certsPresentUnknown := strconv.Itoa(certs.NumUnknownCerts(certChain))

pd := []nagios.PerformanceData{
{
Label: "expires_leaf",
Value: fmt.Sprintf("%d", expiresLeaf),
UnitOfMeasurement: "d",
Warn: fmt.Sprintf("%d", ageWarning),
Crit: fmt.Sprintf("%d", ageCritical),
},
{
Label: "expires_intermediate",
Value: fmt.Sprintf("%d", expiresIntermediate),
UnitOfMeasurement: "d",
Warn: fmt.Sprintf("%d", ageWarning),
Crit: fmt.Sprintf("%d", ageCritical),
},

// TODO: Should we even track this? If we report 0 as a default value
// when the cert is not found, how will that differ from when the cert
// is actually present and expired?
//
//
// NOTE: Current thinking is that I should not include root cert
// expiration perfdata; root cert should ideally not be in the chain
// per current best practice(s).
// {
// Label: "expires_root",
// Value: fmt.Sprintf("%d", expiresRoot),
// UnitOfMeasurement: "d",
// },
{
Label: "certs_present_leaf",
Value: certsPresentLeaf,
},
{
Label: "certs_present_intermediate",
Value: certsPresentIntermediate,
},
{
Label: "certs_present_root",
Value: certsPresentRoot,
},
{
Label: "certs_present_unknown",
Value: certsPresentUnknown,
},
}

return pd, nil

}
185 changes: 185 additions & 0 deletions internal/certs/certs.go
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,191 @@ func NumExpiringCerts(certChain []*x509.Certificate, ageCritical time.Time, ageW

}

// NumLeafCerts receives a slice of x509 certificates and returns a count of
// leaf certificates present in the chain.
func NumLeafCerts(certChain []*x509.Certificate) int {
var num int
for _, cert := range certChain {
chainPos := ChainPosition(cert, certChain)
switch chainPos {
case certChainPositionLeaf:
num++
case certChainPositionLeafSelfSigned:
num++
}
}

return num
}

// NumIntermediateCerts receives a slice of x509 certificates and returns a
// count of intermediate certificates present in the chain.
func NumIntermediateCerts(certChain []*x509.Certificate) int {
var num int
for _, cert := range certChain {
chainPos := ChainPosition(cert, certChain)
if chainPos == certChainPositionIntermediate {
num++
}
}

return num
}

// NumRootCerts receives a slice of x509 certificates and returns a
// count of root certificates present in the chain.
func NumRootCerts(certChain []*x509.Certificate) int {
var num int
for _, cert := range certChain {
chainPos := ChainPosition(cert, certChain)
if chainPos == certChainPositionRoot {
num++
}
}

return num
}

// NumUnknownCerts receives a slice of x509 certificates and returns a count
// of unidentified certificates present in the chain.
func NumUnknownCerts(certChain []*x509.Certificate) int {
var num int
for _, cert := range certChain {
chainPos := ChainPosition(cert, certChain)
if chainPos == certChainPositionUnknown {
num++
}
}

return num
}

// LeafCerts receives a slice of x509 certificates and returns a (potentially
// empty) collection of leaf certificates present in the chain.
func LeafCerts(certChain []*x509.Certificate) []*x509.Certificate {
numPresent := NumLeafCerts(certChain)
leafCerts := make([]*x509.Certificate, 0, numPresent)

for _, cert := range certChain {
chainPos := ChainPosition(cert, certChain)
switch chainPos {
case certChainPositionLeaf:
leafCerts = append(leafCerts, cert)
case certChainPositionLeafSelfSigned:
leafCerts = append(leafCerts, cert)
}

}

return leafCerts
}

// IntermediateCerts receives a slice of x509 certificates and returns a
// (potentially empty) collection of intermediate certificates present in the
// chain.
func IntermediateCerts(certChain []*x509.Certificate) []*x509.Certificate {
numPresent := NumLeafCerts(certChain)
intermediateCerts := make([]*x509.Certificate, 0, numPresent)

for _, cert := range certChain {
chainPos := ChainPosition(cert, certChain)
if chainPos == certChainPositionIntermediate {
intermediateCerts = append(intermediateCerts, cert)
}
}

return intermediateCerts
}

// RootCerts receives a slice of x509 certificates and returns a (potentially
// empty) collection of root certificates present in the chain.
func RootCerts(certChain []*x509.Certificate) []*x509.Certificate {
numPresent := NumLeafCerts(certChain)
rootCerts := make([]*x509.Certificate, 0, numPresent)

for _, cert := range certChain {
chainPos := ChainPosition(cert, certChain)
if chainPos == certChainPositionRoot {
rootCerts = append(rootCerts, cert)
}
}

return rootCerts
}

// OldestLeafCert returns the oldest leaf certificate in a given certificate
// chain. If a leaf certificate is not not present nil is returned.
func OldestLeafCert(certChain []*x509.Certificate) *x509.Certificate {
leafs := LeafCerts(certChain)

return NextToExpire(leafs, false)
}

// OldestIntermediateCert returns the oldest intermediate certificate in a
// given certificate chain. If a leaf certificate is not not present nil is
// returned.
func OldestIntermediateCert(certChain []*x509.Certificate) *x509.Certificate {
intermediates := IntermediateCerts(certChain)

return NextToExpire(intermediates, false)
}

// OldestRootCert returns the oldest root certificate in a given certificate
// chain. If a root certificate is not not present nil is returned.
func OldestRootCert(certChain []*x509.Certificate) *x509.Certificate {
roots := RootCerts(certChain)

return NextToExpire(roots, false)
}

// ExpiresInDays evaluates the given certificate and returns the number of
// days until the certificate expires. If already expired, a negative number
// is returned indicating how many days the certificate is past expiration.
//
// An error is returned if the pointer to the given certificate is nil.
// func ExpiresInDays(cert *x509.Certificate) (int, error) {
// if cert == nil {
// return 0, fmt.Errorf(
// "func ExpiresInDays: unable to determine expiration: %w",
// ErrMissingValue,
// )
// }
//
// timeRemaining := time.Until(cert.NotAfter).Hours()
//
// // Toss remainder so that we only get the whole number of days
// daysRemaining := int(math.Trunc(timeRemaining / 24))
//
// return daysRemaining, nil
// }

// ExpiresInDays evaluates the given certificate and returns the number of
// days until the certificate expires. Zero is returned if the certificate is
// expired or if the remaining certificate lifetime is shorter than one full
// day.
//
// An error is returned if the pointer to the given certificate is nil.
func ExpiresInDays(cert *x509.Certificate) (int, error) {
if cert == nil {
return 0, fmt.Errorf(
"func ExpiresInDays: unable to determine expiration: %w",
ErrMissingValue,
)
}

timeRemaining := time.Until(cert.NotAfter).Hours()

// Toss remainder so that we only get the whole number of days
daysRemaining := int(math.Trunc(timeRemaining / 24))

// Zero is our baseline for now.
if daysRemaining < 0 {
daysRemaining = 0
}

return daysRemaining, nil
}

// FormattedExpiration receives a Time value and converts it to a string
// representing the largest useful whole units of time in days and hours. For
// example, if a certificate has 1 year, 2 days and 3 hours remaining until
Expand Down

0 comments on commit 1a81170

Please sign in to comment.