From a897b84049567c6eda0dfee214e670fb700f6dd0 Mon Sep 17 00:00:00 2001 From: Jiri Tyr Date: Mon, 30 Jul 2018 20:12:45 +0100 Subject: [PATCH] Adding x509_cert input plugin (#3768) --- plugins/inputs/all/all.go | 1 + plugins/inputs/x509_cert/README.md | 45 +++++ plugins/inputs/x509_cert/x509_cert.go | 163 ++++++++++++++++ plugins/inputs/x509_cert/x509_cert_test.go | 205 +++++++++++++++++++++ 4 files changed, 414 insertions(+) create mode 100644 plugins/inputs/x509_cert/README.md create mode 100644 plugins/inputs/x509_cert/x509_cert.go create mode 100644 plugins/inputs/x509_cert/x509_cert_test.go diff --git a/plugins/inputs/all/all.go b/plugins/inputs/all/all.go index 8594db0a9..8989684e4 100644 --- a/plugins/inputs/all/all.go +++ b/plugins/inputs/all/all.go @@ -124,6 +124,7 @@ import ( _ "github.com/influxdata/telegraf/plugins/inputs/webhooks" _ "github.com/influxdata/telegraf/plugins/inputs/win_perf_counters" _ "github.com/influxdata/telegraf/plugins/inputs/win_services" + _ "github.com/influxdata/telegraf/plugins/inputs/x509_cert" _ "github.com/influxdata/telegraf/plugins/inputs/zfs" _ "github.com/influxdata/telegraf/plugins/inputs/zipkin" _ "github.com/influxdata/telegraf/plugins/inputs/zookeeper" diff --git a/plugins/inputs/x509_cert/README.md b/plugins/inputs/x509_cert/README.md new file mode 100644 index 000000000..6b922f0e1 --- /dev/null +++ b/plugins/inputs/x509_cert/README.md @@ -0,0 +1,45 @@ +# X509 Cert Input Plugin + +This plugin provides information about X509 certificate accessible via local +file or network connection. + + +### Configuration + +```toml +# Reads metrics from a SSL certificate +[[inputs.x509_cert]] + ## List certificate sources + sources = ["/etc/ssl/certs/ssl-cert-snakeoil.pem", "https://example.org"] + + ## Timeout for SSL connection + # timeout = 5s + + ## Optional TLS Config + # tls_ca = "/etc/telegraf/ca.pem" + # tls_cert = "/etc/telegraf/cert.pem" + # tls_key = "/etc/telegraf/key.pem" + + ## Use TLS but skip chain & host verification + # insecure_skip_verify = false +``` + + +### Metrics + +- `x509_cert` + - tags: + - `source` - source of the certificate + - fields: + - `expiry` (int, seconds) + - `age` (int, seconds) + - `startdate` (int, seconds) + - `enddate` (int, seconds) + + +### Example output + +``` +x509_cert,host=myhost,source=https://example.org age=1753627i,expiry=5503972i,startdate=1516092060i,enddate=1523349660i 1517845687000000000 +x509_cert,host=myhost,source=/etc/ssl/certs/ssl-cert-snakeoil.pem age=7522207i,expiry=308002732i,startdate=1510323480i,enddate=1825848420i 1517845687000000000 +``` diff --git a/plugins/inputs/x509_cert/x509_cert.go b/plugins/inputs/x509_cert/x509_cert.go new file mode 100644 index 000000000..2e5d26996 --- /dev/null +++ b/plugins/inputs/x509_cert/x509_cert.go @@ -0,0 +1,163 @@ +// Package x509_cert reports metrics from an SSL certificate. +package x509_cert + +import ( + "crypto/tls" + "crypto/x509" + "encoding/pem" + "fmt" + "io/ioutil" + "net" + "net/url" + "strings" + "time" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/internal" + _tls "github.com/influxdata/telegraf/internal/tls" + "github.com/influxdata/telegraf/plugins/inputs" +) + +const sampleConfig = ` + ## List certificate sources + sources = ["/etc/ssl/certs/ssl-cert-snakeoil.pem", "tcp://example.org:443"] + + ## Timeout for SSL connection + # timeout = 5s + + ## Optional TLS Config + # tls_ca = "/etc/telegraf/ca.pem" + # tls_cert = "/etc/telegraf/cert.pem" + # tls_key = "/etc/telegraf/key.pem" + + ## Use TLS but skip chain & host verification + # insecure_skip_verify = false +` +const description = "Reads metrics from a SSL certificate" + +// X509Cert holds the configuration of the plugin. +type X509Cert struct { + Sources []string `toml:"sources"` + Timeout internal.Duration `toml:"timeout"` + _tls.ClientConfig +} + +// Description returns description of the plugin. +func (c *X509Cert) Description() string { + return description +} + +// SampleConfig returns configuration sample for the plugin. +func (c *X509Cert) SampleConfig() string { + return sampleConfig +} + +func (c *X509Cert) getCert(location string, timeout time.Duration) ([]*x509.Certificate, error) { + if strings.HasPrefix(location, "/") { + location = "file://" + location + } + + u, err := url.Parse(location) + if err != nil { + return nil, fmt.Errorf("failed to parse cert location - %s\n", err.Error()) + } + + switch u.Scheme { + case "https": + u.Scheme = "tcp" + fallthrough + case "udp", "udp4", "udp6": + fallthrough + case "tcp", "tcp4", "tcp6": + tlsCfg, err := c.ClientConfig.TLSConfig() + if err != nil { + return nil, err + } + + ipConn, err := net.DialTimeout(u.Scheme, u.Host, timeout) + if err != nil { + return nil, err + } + defer ipConn.Close() + + conn := tls.Client(ipConn, tlsCfg) + defer conn.Close() + + hsErr := conn.Handshake() + if hsErr != nil { + return nil, hsErr + } + + certs := conn.ConnectionState().PeerCertificates + + return certs, nil + case "file": + content, err := ioutil.ReadFile(u.Path) + if err != nil { + return nil, err + } + + block, _ := pem.Decode(content) + if block == nil { + return nil, fmt.Errorf("failed to parse certificate PEM") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, err + } + + return []*x509.Certificate{cert}, nil + default: + return nil, fmt.Errorf("unsuported scheme '%s' in location %s\n", u.Scheme, location) + } +} + +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 +} + +// Gather adds metrics into the accumulator. +func (c *X509Cert) Gather(acc telegraf.Accumulator) error { + now := time.Now() + + for _, location := range c.Sources { + certs, err := c.getCert(location, c.Timeout.Duration*time.Second) + if err != nil { + return fmt.Errorf("cannot get SSL cert '%s': %s", location, err.Error()) + } + + tags := map[string]string{ + "source": location, + } + + for _, cert := range certs { + fields := getFields(cert, now) + + acc.AddFields("x509_cert", fields, tags) + } + } + + return nil +} + +func init() { + inputs.Add("x509_cert", func() telegraf.Input { + return &X509Cert{ + Sources: []string{}, + Timeout: internal.Duration{Duration: 5}, + } + }) +} diff --git a/plugins/inputs/x509_cert/x509_cert_test.go b/plugins/inputs/x509_cert/x509_cert_test.go new file mode 100644 index 000000000..f4c6c8738 --- /dev/null +++ b/plugins/inputs/x509_cert/x509_cert_test.go @@ -0,0 +1,205 @@ +package x509_cert + +import ( + "crypto/tls" + "encoding/base64" + "fmt" + "io/ioutil" + "os" + "testing" + "time" + + "github.com/influxdata/telegraf" + "github.com/influxdata/telegraf/internal" + "github.com/influxdata/telegraf/testutil" +) + +var pki = testutil.NewPKI("../../../testutil/pki") + +// Make sure X509Cert implements telegraf.Input +var _ telegraf.Input = &X509Cert{} + +func TestGatherRemote(t *testing.T) { + if testing.Short() { + t.Skip("Skipping network-dependent test in short mode.") + } + + tmpfile, err := ioutil.TempFile("", "example") + if err != nil { + t.Fatal(err) + } + + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.Write([]byte(pki.ReadServerCert())); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + server string + timeout time.Duration + close bool + unset bool + noshake bool + error bool + }{ + {name: "wrong port", server: ":99999", error: true}, + {name: "no server", timeout: 5}, + {name: "successful https", server: "https://example.org:443", timeout: 5}, + {name: "successful file", server: "file://" + tmpfile.Name(), timeout: 5}, + {name: "unsupported scheme", server: "foo://", timeout: 5, error: true}, + {name: "no certificate", timeout: 5, unset: true, error: true}, + {name: "closed connection", close: true, error: true}, + {name: "no handshake", timeout: 5, noshake: true, error: true}, + } + + pair, err := tls.X509KeyPair([]byte(pki.ReadServerCert()), []byte(pki.ReadServerKey())) + if err != nil { + t.Fatal(err) + } + + config := &tls.Config{ + InsecureSkipVerify: true, + Certificates: []tls.Certificate{pair}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.unset { + config.Certificates = nil + config.GetCertificate = func(i *tls.ClientHelloInfo) (*tls.Certificate, error) { + return nil, nil + } + } + + ln, err := tls.Listen("tcp", ":0", config) + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + go func() { + sconn, err := ln.Accept() + if err != nil { + return + } + if test.close { + sconn.Close() + } + + serverConfig := config.Clone() + + srv := tls.Server(sconn, serverConfig) + if test.noshake { + srv.Close() + } + if err := srv.Handshake(); err != nil { + return + } + }() + + if test.server == "" { + test.server = "tcp://" + ln.Addr().String() + } + + sc := X509Cert{ + Sources: []string{test.server}, + Timeout: internal.Duration{Duration: test.timeout}, + } + + sc.InsecureSkipVerify = true + testErr := false + + acc := testutil.Accumulator{} + err = sc.Gather(&acc) + if err != nil { + testErr = true + } + + if testErr != test.error { + t.Errorf("%s", err) + } + }) + } +} + +func TestGatherLocal(t *testing.T) { + wrongCert := fmt.Sprintf("-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n", base64.StdEncoding.EncodeToString([]byte("test"))) + + tests := []struct { + name string + mode os.FileMode + content string + error bool + }{ + {name: "permission denied", mode: 0001, error: true}, + {name: "not a certificate", mode: 0640, content: "test", error: true}, + {name: "wrong certificate", mode: 0640, content: wrongCert, error: true}, + {name: "correct certificate", mode: 0640, content: pki.ReadServerCert()}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + f, err := ioutil.TempFile("", "x509_cert") + if err != nil { + t.Fatal(err) + } + + _, err = f.Write([]byte(test.content)) + if err != nil { + t.Fatal(err) + } + + err = f.Chmod(test.mode) + if err != nil { + t.Fatal(err) + } + + err = f.Close() + if err != nil { + t.Fatal(err) + } + + defer os.Remove(f.Name()) + + sc := X509Cert{ + Sources: []string{f.Name()}, + } + + error := false + + acc := testutil.Accumulator{} + err = sc.Gather(&acc) + if err != nil { + error = true + } + + if error != test.error { + t.Errorf("%s", err) + } + }) + } +} + +func TestStrings(t *testing.T) { + sc := X509Cert{} + + tests := []struct { + name string + method string + returned string + expected string + }{ + {name: "description", method: "Description", returned: sc.Description(), expected: description}, + {name: "sample config", method: "SampleConfig", returned: sc.SampleConfig(), expected: sampleConfig}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.returned != test.expected { + t.Errorf("Expected method %s to return '%s', found '%s'.", test.method, test.expected, test.returned) + } + }) + } +}