From f89dab2d859a69f13faa02b783b98bf202059c4a Mon Sep 17 00:00:00 2001 From: Tobias Hintze Date: Mon, 18 Sep 2017 14:55:28 +0200 Subject: [PATCH] add tls-upgrade for tcp-prober (STARTTLS) (#220) Improves query_response by adding starttls which upgrades the connection to TLS. This allows probing STARTTLS on IMAP/SMTP/POP3. --- CONFIGURATION.md | 9 ++- config/config.go | 5 +- config/testdata/blackbox-good.yml | 14 ++++ example.yml | 25 +++++++ prober/tcp.go | 29 ++++++++ prober/tcp_test.go | 116 ++++++++++++++++++++++++++++++ prober/utils_test.go | 44 ++++++++++++ 7 files changed, 238 insertions(+), 4 deletions(-) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 86de0db..1ba3c55 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -101,10 +101,15 @@ The other placeholders are specified separately. [ preferred_ip_protocol: | default = "ip6" ] # The query sent in the TCP probe and the expected associated response. +# starttls upgrades TCP connection to TLS. query_response: - [ - [ [ expect: ], [ send: ] ], ... ] + [ - [ [ expect: ], + [ send: ], + [ starttls: ] + ], ... + ] -# Whether or not TLS is used. +# Whether or not TLS is used when the connection is initiated. [ tls: ] # Configuration for TLS protocol of TCP probe. diff --git a/config/config.go b/config/config.go index 5d4baf4..81b504f 100644 --- a/config/config.go +++ b/config/config.go @@ -76,8 +76,9 @@ type HTTPProbe struct { } type QueryResponse struct { - Expect string `yaml:"expect,omitempty"` - Send string `yaml:"send,omitempty"` + Expect string `yaml:"expect,omitempty"` + Send string `yaml:"send,omitempty"` + StartTLS bool `yaml:"starttls,omitempty"` // Catches all undefined fields and must be empty after parsing. XXX map[string]interface{} `yaml:",inline"` diff --git a/config/testdata/blackbox-good.yml b/config/testdata/blackbox-good.yml index 1f145d2..af15e67 100644 --- a/config/testdata/blackbox-good.yml +++ b/config/testdata/blackbox-good.yml @@ -28,6 +28,20 @@ modules: tcp: query_response: - expect: "^SSH-2.0-" + smtp_starttls: + prober: tcp + timeout: 5s + tcp: + query_response: + - expect: "^220 " + - send: "EHLO prober" + - expect: "^250-STARTTLS" + - send: "STARTTLS" + - expect: "^220" + - starttls: true + - send: "EHLO prober" + - expect: "^250-AUTH" + - send: "QUIT" irc_banner: prober: tcp timeout: 5s diff --git a/example.yml b/example.yml index 39816d1..d91c37a 100644 --- a/example.yml +++ b/example.yml @@ -40,6 +40,31 @@ modules: tcp_connect_example: prober: tcp timeout: 5s + imap_starttls: + prober: tcp + timeout: 5s + tcp: + query_response: + - expect: "OK.*STARTTLS" + - send: ". STARTTLS" + - expect: "OK" + - starttls: true + - send: ". capability" + - expect: "CAPABILITY IMAP4rev1" + smtp_starttls: + prober: tcp + timeout: 5s + tcp: + query_response: + - expect: "^220 ([^ ]+) ESMTP (.+)$" + - send: "EHLO prober" + - expect: "^250-STARTTLS" + - send: "STARTTLS" + - expect: "^220" + - starttls: true + - send: "EHLO prober" + - expect: "^250-AUTH" + - send: "QUIT" irc_banner_example: prober: tcp timeout: 5s diff --git a/prober/tcp.go b/prober/tcp.go index 3e64410..d6f0aa5 100644 --- a/prober/tcp.go +++ b/prober/tcp.go @@ -138,6 +138,35 @@ func ProbeTCP(ctx context.Context, target string, module config.Module, registry return false } } + if qr.StartTLS { + // Upgrade TCP connection to TLS. + tlsConfig, err := pconfig.NewTLSConfig(&module.TCP.TLSConfig) + if err != nil { + level.Error(logger).Log("msg", "Failed to create TLS configuration", "err", err) + return false + } + if tlsConfig.ServerName == "" { + // Use target-hostname as default for TLS-servername. + targetAddress, _, _ := net.SplitHostPort(target) // Had succeeded in dialTCP already. + tlsConfig.ServerName = targetAddress + } + tlsConn := tls.Client(conn, tlsConfig) + defer tlsConn.Close() + + // Initiate TLS handshake (required here to get TLS state). + if err := tlsConn.Handshake(); err != nil { + level.Error(logger).Log("msg", "TLS Handshake (client) failed", "err", err) + return false + } + level.Info(logger).Log("msg", "TLS Handshake (client) succeeded.") + conn = net.Conn(tlsConn) + scanner = bufio.NewScanner(conn) + + // Get certificate expiry. + state := tlsConn.ConnectionState() + registry.MustRegister(probeSSLEarliestCertExpiry) + probeSSLEarliestCertExpiry.Set(float64(getEarliestCertExpiry(&state).UnixNano()) / 1e9) + } } return true } diff --git a/prober/tcp_test.go b/prober/tcp_test.go index aa63d2e..74771dd 100644 --- a/prober/tcp_test.go +++ b/prober/tcp_test.go @@ -15,14 +15,18 @@ package prober import ( "context" + "crypto/tls" "fmt" + "io/ioutil" "net" + "os" "runtime" "testing" "time" "github.com/go-kit/kit/log" "github.com/prometheus/client_golang/prometheus" + pconfig "github.com/prometheus/common/config" "github.com/prometheus/blackbox_exporter/config" ) @@ -62,6 +66,118 @@ func TestTCPConnectionFails(t *testing.T) { } } +func TestTCPConnectionQueryResponseStartTLS(t *testing.T) { + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + t.Fatalf("Error listening on socket: %s", err) + } + defer ln.Close() + + testCTX, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Create test certificates valid for 1 day. + certExpiry := time.Now().AddDate(0, 0, 1) + testcert_pem, testkey_pem := generateTestCertificate(certExpiry) + + // CAFile must be passed via filesystem, use a tempfile. + tmpCaFile, err := ioutil.TempFile("", "cafile.pem") + if err != nil { + panic(fmt.Sprintf("Error creating CA tempfile: %s", err)) + } + if _, err := tmpCaFile.Write(testcert_pem); err != nil { + panic(fmt.Sprintf("Error writing CA tempfile: %s", err)) + } + if err := tmpCaFile.Close(); err != nil { + panic(fmt.Sprintf("Error closing CA tempfile: %s", err)) + } + defer os.Remove(tmpCaFile.Name()) + + // Define some (bogus) example SMTP dialog with STARTTLS. + module := config.Module{ + TCP: config.TCPProbe{ + QueryResponse: []config.QueryResponse{ + {Expect: "^220.*ESMTP.*$"}, + {Send: "EHLO tls.prober"}, + {Expect: "^250-STARTTLS"}, + {Send: "STARTTLS"}, + {Expect: "^220"}, + {StartTLS: true}, + {Send: "EHLO tls.prober"}, + {Expect: "^250-AUTH"}, + {Send: "QUIT"}, + }, + TLSConfig: pconfig.TLSConfig{ + CAFile: tmpCaFile.Name(), + InsecureSkipVerify: false, + }, + }, + } + + // Handle server side of this test. + ch := make(chan (struct{})) + go func() { + conn, err := ln.Accept() + if err != nil { + panic(fmt.Sprintf("Error accepting on socket: %s", err)) + } + defer conn.Close() + fmt.Fprintf(conn, "220 ESMTP StartTLS pseudo-server\n") + if _, e := fmt.Fscanf(conn, "EHLO tls.prober\n"); e != nil { + panic("Error in dialog. No EHLO received.") + } + fmt.Fprintf(conn, "250-pseudo-server.example.net\n") + fmt.Fprintf(conn, "250-STARTTLS\n") + fmt.Fprintf(conn, "250 DSN\n") + + if _, e := fmt.Fscanf(conn, "STARTTLS\n"); e != nil { + panic("Error in dialog. No (TLS) STARTTLS received.") + } + fmt.Fprintf(conn, "220 2.0.0 Ready to start TLS\n") + + testcert, err := tls.X509KeyPair(testcert_pem, testkey_pem) + if err != nil { + panic(fmt.Sprintf("Failed to decode TLS testing keypair: %s\n", err)) + } + + // Do the server-side upgrade to TLS. + tlsConfig := &tls.Config{ + ServerName: "localhost", + Certificates: []tls.Certificate{testcert}, + } + tlsConn := tls.Server(conn, tlsConfig) + if err := tlsConn.Handshake(); err != nil { + panic(fmt.Sprintf("TLS Handshake (server) failed: %s\n", err)) + } + defer tlsConn.Close() + + // Continue encrypted. + if _, e := fmt.Fscanf(tlsConn, "EHLO"); e != nil { + panic("Error in dialog. No (TLS) EHLO received.") + } + fmt.Fprintf(tlsConn, "250-AUTH\n") + fmt.Fprintf(tlsConn, "250 DSN\n") + ch <- struct{}{} + }() + + // Do the client side of this test. + registry := prometheus.NewRegistry() + if !ProbeTCP(testCTX, ln.Addr().String(), module, registry, log.NewNopLogger()) { + t.Fatalf("TCP module failed, expected success.") + } + <-ch + + // Check the probe_ssl_earliest_cert_expiry. + mfs, err := registry.Gather() + if err != nil { + t.Fatal(err) + } + expectedResults := map[string]float64{ + "probe_ssl_earliest_cert_expiry": float64(certExpiry.Unix()), + } + checkRegistryResults(expectedResults, mfs, t) +} + func TestTCPConnectionQueryResponseIRC(t *testing.T) { ln, err := net.Listen("tcp", "localhost:0") if err != nil { diff --git a/prober/utils_test.go b/prober/utils_test.go index a81608a..aa4a9a1 100644 --- a/prober/utils_test.go +++ b/prober/utils_test.go @@ -1,7 +1,16 @@ package prober import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "fmt" + "math/big" + "net" "testing" + "time" dto "github.com/prometheus/client_model/go" ) @@ -22,3 +31,38 @@ func checkRegistryResults(expRes map[string]float64, mfs []*dto.MetricFamily, t } } } + +// Create test certificate with specified expiry date +// Certificate will be self-signed and use localhost/127.0.0.1 +// Generated certificate and key are returned in PEM encoding +func generateTestCertificate(expiry time.Time) ([]byte, []byte) { + privatekey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + panic(fmt.Sprintf("Error creating rsa key: %s", err)) + } + publickey := &privatekey.PublicKey + + cert := x509.Certificate{ + IsCA: true, + BasicConstraintsValid: true, + SubjectKeyId: []byte{1}, + SerialNumber: big.NewInt(1), + Subject: pkix.Name{ + Organization: []string{"Example Org"}, + }, + NotBefore: time.Now(), + NotAfter: expiry, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + } + cert.DNSNames = append(cert.DNSNames, "localhost") + cert.IPAddresses = append(cert.IPAddresses, net.ParseIP("127.0.0.1")) + cert.IPAddresses = append(cert.IPAddresses, net.ParseIP("::1")) + derCert, err := x509.CreateCertificate(rand.Reader, &cert, &cert, publickey, privatekey) + if err != nil { + panic(fmt.Sprintf("Error signing test-certificate: %s", err)) + } + pemCert := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derCert}) + pemKey := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privatekey)}) + return pemCert, pemKey +} -- 2.25.1