1
0
Fork 0
telegraf/plugins/inputs/x509_cert/x509_cert.go
Daniel Baumann 4978089aab
Adding upstream version 1.34.4.
Signed-off-by: Daniel Baumann <daniel@debian.org>
2025-05-24 07:26:29 +02:00

546 lines
15 KiB
Go

// Package x509_cert reports metrics from an SSL certificate.
//
//go:generate ../../../tools/readme_config_includer/generator
package x509_cert
import (
"bytes"
"crypto/tls"
"crypto/x509"
_ "embed"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"net"
"net/smtp"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/pion/dtls/v2"
"golang.org/x/crypto/ocsp"
"github.com/influxdata/telegraf"
"github.com/influxdata/telegraf/config"
"github.com/influxdata/telegraf/internal/globpath"
"github.com/influxdata/telegraf/plugins/common/proxy"
common_tls "github.com/influxdata/telegraf/plugins/common/tls"
"github.com/influxdata/telegraf/plugins/inputs"
)
//go:embed sample.conf
var sampleConfig string
// Regexp for handling file URIs containing a drive letter and leading slash
var reDriveLetter = regexp.MustCompile(`^/([a-zA-Z]:/)`)
type X509Cert struct {
Sources []string `toml:"sources"`
Timeout config.Duration `toml:"timeout"`
ServerName string `toml:"server_name"`
Password config.Secret `toml:"password"`
ExcludeRootCerts bool `toml:"exclude_root_certs"`
PadSerial bool `toml:"pad_serial_with_zeroes"`
Log telegraf.Logger `toml:"-"`
common_tls.ClientConfig
proxy.TCPProxy
tlsCfg *tls.Config
locations []*url.URL
globpaths []*globpath.GlobPath
classification map[string]string
}
func (*X509Cert) SampleConfig() string {
return sampleConfig
}
func (c *X509Cert) Init() error {
// Check if we do have at least one source
if len(c.Sources) == 0 {
return errors.New("no source configured")
}
// Check the server name and transfer it if necessary
if c.ClientConfig.ServerName != "" && c.ServerName != "" {
return fmt.Errorf("both server_name (%q) and tls_server_name (%q) are set, but they are mutually exclusive", c.ServerName, c.ClientConfig.ServerName)
} else if c.ServerName != "" {
// Store the user-provided server-name in the TLS configuration
c.ClientConfig.ServerName = c.ServerName
}
// Normalize the sources, handle files and file-globbing
if err := c.sourcesToURLs(); err != nil {
return err
}
// Create the TLS configuration
tlsCfg, err := c.ClientConfig.TLSConfig()
if err != nil {
return err
}
if tlsCfg == nil {
tlsCfg = &tls.Config{}
}
c.tlsCfg = tlsCfg
return nil
}
func (c *X509Cert) Gather(acc telegraf.Accumulator) error {
now := time.Now()
collectedUrls := append(c.locations, c.collectCertURLs()...)
for _, location := range collectedUrls {
certs, ocspresp, err := c.getCert(location, time.Duration(c.Timeout))
if err != nil {
acc.AddError(fmt.Errorf("cannot get SSL cert %q: %w", location, err))
}
// Add all returned certs to the pool of intermediates except for
// the leaf node which has to come first
intermediates := x509.NewCertPool()
if len(certs) > 1 {
for _, c := range certs[1:] {
intermediates.AddCert(c)
}
}
dnsName := c.serverName(location)
results := make([]error, 0, len(certs))
c.classification = make(map[string]string)
for _, cert := range certs {
// The first certificate is the leaf/end-entity certificate which
// needs DNS name validation against the URL hostname.
opts := x509.VerifyOptions{
Intermediates: intermediates,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
Roots: c.tlsCfg.RootCAs,
DNSName: dnsName,
}
// Reset DNS name to only use it for the leaf node
dnsName = ""
// Do the processing
results = append(results, c.processCertificate(cert, opts))
}
for i, cert := range certs {
fields := getFields(cert, now)
tags := c.getTags(cert, location.String())
// Extract the verification result
err := results[i]
if err == nil {
tags["verification"] = "valid"
fields["verification_code"] = 0
} else {
tags["verification"] = "invalid"
fields["verification_code"] = 1
fields["verification_error"] = err.Error()
}
// OCSPResponse only for leaf cert
if i == 0 && ocspresp != nil && len(*ocspresp) > 0 {
var ocspissuer *x509.Certificate
for _, chaincert := range certs[1:] {
if cert.Issuer.CommonName == chaincert.Subject.CommonName &&
cert.Issuer.SerialNumber == chaincert.Subject.SerialNumber {
ocspissuer = chaincert
break
}
}
resp, err := ocsp.ParseResponse(*ocspresp, ocspissuer)
if err != nil {
if ocspissuer == nil {
tags["ocsp_stapled"] = "no"
fields["ocsp_error"] = err.Error()
} else {
ocspissuer = nil // retry parsing w/out issuer cert
resp, err = ocsp.ParseResponse(*ocspresp, ocspissuer)
}
}
if err != nil {
tags["ocsp_stapled"] = "no"
fields["ocsp_error"] = err.Error()
} else {
tags["ocsp_stapled"] = "yes"
if ocspissuer != nil {
tags["ocsp_verified"] = "yes"
} else {
tags["ocsp_verified"] = "no"
}
// resp.Status: 0=Good 1=Revoked 2=Unknown
fields["ocsp_status_code"] = resp.Status
switch resp.Status {
case 0:
tags["ocsp_status"] = "good"
case 1:
tags["ocsp_status"] = "revoked"
// Status=Good: revoked_at always = -62135596800
fields["ocsp_revoked_at"] = resp.RevokedAt.Unix()
default:
tags["ocsp_status"] = "unknown"
}
fields["ocsp_produced_at"] = resp.ProducedAt.Unix()
fields["ocsp_this_update"] = resp.ThisUpdate.Unix()
fields["ocsp_next_update"] = resp.NextUpdate.Unix()
}
} else {
tags["ocsp_stapled"] = "no"
}
// Determine the classification
sig := hex.EncodeToString(cert.Signature)
if class, found := c.classification[sig]; found {
tags["type"] = class
} else {
tags["type"] = "leaf"
}
acc.AddFields("x509_cert", fields, tags)
if c.ExcludeRootCerts {
break
}
}
}
return nil
}
func (c *X509Cert) processCertificate(certificate *x509.Certificate, opts x509.VerifyOptions) error {
chains, err := certificate.Verify(opts)
if err != nil {
c.Log.Debugf("Invalid certificate %v", c.getSerialNumberString(certificate))
c.Log.Debugf(" cert DNS names: %v", certificate.DNSNames)
c.Log.Debugf(" cert IP addresses: %v", certificate.IPAddresses)
c.Log.Debugf(" cert subject: %v", certificate.Subject)
c.Log.Debugf(" cert issuer: %v", certificate.Issuer)
c.Log.Debugf(" opts.DNSName: %v", opts.DNSName)
c.Log.Debugf(" verify options: %v", opts)
c.Log.Debugf(" verify error: %v", err)
c.Log.Debugf(" tlsCfg.ServerName: %v", c.tlsCfg.ServerName)
c.Log.Debugf(" ServerName: %v", c.ServerName)
}
// Check if the certificate is a root-certificate.
// The only reliable way to distinguish root certificates from
// intermediates is the fact that root certificates are self-signed,
// i.e. you can verify the certificate with its own public key.
rootErr := certificate.CheckSignature(certificate.SignatureAlgorithm, certificate.RawTBSCertificate, certificate.Signature)
if rootErr == nil {
sig := hex.EncodeToString(certificate.Signature)
c.classification[sig] = "root"
}
// Identify intermediate certificates
for _, chain := range chains {
// All nodes except the first one are of intermediate or CA type.
// Mark them as such. We never add leaf nodes to the classification
// so in the end if a cert is NOT in the classification it is a true
// leaf node.
for _, cert := range chain[1:] {
// Never change a classification if we already have one
sig := hex.EncodeToString(cert.Signature)
if _, found := c.classification[sig]; found {
continue
}
// We found an intermediate certificate which is not a CA. This
// should never happen actually.
if !cert.IsCA {
c.classification[sig] = "unknown"
continue
}
// The only reliable way to distinguish root certificates from
// intermediates is the fact that root certificates are self-signed,
// i.e. you can verify the certificate with its own public key.
rootErr := cert.CheckSignature(cert.SignatureAlgorithm, cert.RawTBSCertificate, cert.Signature)
if rootErr != nil {
c.classification[sig] = "intermediate"
} else {
c.classification[sig] = "root"
}
}
}
return err
}
func (c *X509Cert) sourcesToURLs() error {
for _, source := range c.Sources {
if strings.HasPrefix(source, "file://") || strings.HasPrefix(source, "/") {
source = filepath.ToSlash(strings.TrimPrefix(source, "file://"))
// Removing leading slash in Windows path containing a drive-letter
// like "file:///C:/Windows/..."
source = reDriveLetter.ReplaceAllString(source, "$1")
g, err := globpath.Compile(source)
if err != nil {
return fmt.Errorf("could not compile glob %q: %w", source, err)
}
c.globpaths = append(c.globpaths, g)
} else {
if strings.Index(source, ":\\") == 1 {
source = "file://" + filepath.ToSlash(source)
}
u, err := url.Parse(source)
if err != nil {
return fmt.Errorf("failed to parse cert location: %w", err)
}
c.locations = append(c.locations, u)
}
}
return nil
}
func (c *X509Cert) serverName(u *url.URL) string {
if c.tlsCfg.ServerName != "" {
return c.tlsCfg.ServerName
}
return u.Hostname()
}
func (c *X509Cert) getCert(u *url.URL, timeout time.Duration) ([]*x509.Certificate, *[]byte, error) {
protocol := u.Scheme
switch u.Scheme {
case "udp", "udp4", "udp6":
ipConn, err := net.DialTimeout(u.Scheme, u.Host, timeout)
if err != nil {
return nil, nil, err
}
defer ipConn.Close()
dtlsCfg := &dtls.Config{
InsecureSkipVerify: true,
Certificates: c.tlsCfg.Certificates,
RootCAs: c.tlsCfg.RootCAs,
ServerName: c.serverName(u),
}
conn, err := dtls.Client(ipConn, dtlsCfg)
if err != nil {
return nil, nil, err
}
defer conn.Close()
rawCerts := conn.ConnectionState().PeerCertificates
var certs []*x509.Certificate
for _, rawCert := range rawCerts {
parsed, err := x509.ParseCertificate(rawCert)
if err != nil {
return nil, nil, err
}
if parsed != nil {
certs = append(certs, parsed)
}
}
return certs, nil, nil
case "https":
protocol = "tcp"
if u.Port() == "" {
u.Host += ":443"
}
fallthrough
case "tcp", "tcp4", "tcp6":
dialer, err := c.Proxy()
if err != nil {
return nil, nil, err
}
ipConn, err := dialer.DialTimeout(protocol, u.Host, timeout)
if err != nil {
return nil, nil, err
}
defer ipConn.Close()
downloadTLSCfg := c.tlsCfg.Clone()
downloadTLSCfg.ServerName = c.serverName(u)
downloadTLSCfg.InsecureSkipVerify = true
conn := tls.Client(ipConn, downloadTLSCfg)
defer conn.Close()
hsErr := conn.Handshake()
if hsErr != nil {
return nil, nil, hsErr
}
certs := conn.ConnectionState().PeerCertificates
ocspresp := conn.ConnectionState().OCSPResponse
return certs, &ocspresp, nil
case "file":
content, err := os.ReadFile(u.Path)
if err != nil {
return nil, nil, err
}
var certs []*x509.Certificate
for {
block, rest := pem.Decode(bytes.TrimSpace(content))
if block == nil {
return nil, nil, errors.New("failed to parse certificate PEM")
}
if block.Type == "CERTIFICATE" {
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, err
}
certs = append(certs, cert)
}
if len(rest) == 0 {
break
}
content = rest
}
return certs, nil, nil
case "smtp":
ipConn, err := net.DialTimeout("tcp", u.Host, timeout)
if err != nil {
return nil, nil, err
}
defer ipConn.Close()
downloadTLSCfg := c.tlsCfg.Clone()
downloadTLSCfg.ServerName = c.serverName(u)
downloadTLSCfg.InsecureSkipVerify = true
smtpConn, err := smtp.NewClient(ipConn, u.Host)
if err != nil {
return nil, nil, err
}
err = smtpConn.Hello(downloadTLSCfg.ServerName)
if err != nil {
return nil, nil, err
}
id, err := smtpConn.Text.Cmd("STARTTLS")
if err != nil {
return nil, nil, err
}
smtpConn.Text.StartResponse(id)
defer smtpConn.Text.EndResponse(id)
_, _, err = smtpConn.Text.ReadResponse(220)
if err != nil {
return nil, nil, fmt.Errorf("did not get 220 after STARTTLS: %w", err)
}
tlsConn := tls.Client(ipConn, downloadTLSCfg)
defer tlsConn.Close()
hsErr := tlsConn.Handshake()
if hsErr != nil {
return nil, nil, hsErr
}
certs := tlsConn.ConnectionState().PeerCertificates
ocspresp := tlsConn.ConnectionState().OCSPResponse
return certs, &ocspresp, nil
case "jks":
certs, err := c.processJKS(u.Path)
return certs, nil, err
case "pkcs12":
certs, err := c.processPKCS12(u.Path)
return certs, nil, err
default:
return nil, nil, fmt.Errorf("unsupported scheme %q in location %s", u.Scheme, u.String())
}
}
func getFields(cert *x509.Certificate, now time.Time) map[string]interface{} {
age := int(now.Sub(cert.NotBefore).Seconds())
expiry := int(cert.NotAfter.Sub(now).Seconds())
startdate := cert.NotBefore.Unix()
enddate := cert.NotAfter.Unix()
fields := map[string]interface{}{
"age": age,
"expiry": expiry,
"startdate": startdate,
"enddate": enddate,
}
return fields
}
func (c *X509Cert) getTags(cert *x509.Certificate, location string) map[string]string {
tags := map[string]string{
"source": location,
"common_name": cert.Subject.CommonName,
"serial_number": c.getSerialNumberString(cert),
"signature_algorithm": cert.SignatureAlgorithm.String(),
"public_key_algorithm": cert.PublicKeyAlgorithm.String(),
}
if len(cert.Subject.Organization) > 0 {
tags["organization"] = cert.Subject.Organization[0]
}
if len(cert.Subject.OrganizationalUnit) > 0 {
tags["organizational_unit"] = cert.Subject.OrganizationalUnit[0]
}
if len(cert.Subject.Country) > 0 {
tags["country"] = cert.Subject.Country[0]
}
if len(cert.Subject.Province) > 0 {
tags["province"] = cert.Subject.Province[0]
}
if len(cert.Subject.Locality) > 0 {
tags["locality"] = cert.Subject.Locality[0]
}
tags["issuer_common_name"] = cert.Issuer.CommonName
tags["issuer_serial_number"] = cert.Issuer.SerialNumber
san := append(cert.DNSNames, cert.EmailAddresses...)
for _, ip := range cert.IPAddresses {
san = append(san, ip.String())
}
for _, uri := range cert.URIs {
san = append(san, uri.String())
}
tags["san"] = strings.Join(san, ",")
return tags
}
func (c *X509Cert) collectCertURLs() []*url.URL {
var urls []*url.URL
for _, path := range c.globpaths {
files := path.Match()
if len(files) == 0 {
c.Log.Errorf("could not find file: %v", path.GetRoots())
continue
}
for _, file := range files {
fn := filepath.ToSlash(file)
urls = append(urls, &url.URL{Scheme: "file", Path: fn})
}
}
return urls
}
func (c *X509Cert) getSerialNumberString(cert *x509.Certificate) string {
if c.PadSerial {
return fmt.Sprintf("%016x", cert.SerialNumber)
}
return cert.SerialNumber.Text(16)
}
func init() {
inputs.Add("x509_cert", func() telegraf.Input {
return &X509Cert{
Timeout: config.Duration(5 * time.Second),
}
})
}