add tls-upgrade for tcp-prober (STARTTLS) (#220)
authorTobias Hintze <thz@users.noreply.github.com>
Mon, 18 Sep 2017 12:55:28 +0000 (14:55 +0200)
committerBrian Brazil <brian.brazil@robustperception.io>
Mon, 18 Sep 2017 12:55:28 +0000 (13:55 +0100)
Improves query_response by adding starttls
which upgrades the connection to TLS.
This allows probing STARTTLS on IMAP/SMTP/POP3.

CONFIGURATION.md
config/config.go
config/testdata/blackbox-good.yml
example.yml
prober/tcp.go
prober/tcp_test.go
prober/utils_test.go

index 86de0dbfe5ff11c926e3ff262d93fc10f71cd61e..1ba3c5511b42c5a45b2714647554cc85250eb8f6 100644 (file)
@@ -101,10 +101,15 @@ The other placeholders are specified separately.
 [ preferred_ip_protocol: <string> | default = "ip6" ]
 
 # The query sent in the TCP probe and the expected associated response.
+# starttls upgrades TCP connection to TLS.
 query_response:
-  [ - [ [ expect: <string> ], [ send: <string> ] ], ... ]
+  [ - [ [ expect: <string> ],
+        [ send: <string> ],
+        [ starttls: <boolean | default = false> ]
+      ], ...
+  ]
 
-# Whether or not TLS is used.
+# Whether or not TLS is used when the connection is initiated.
 [ tls: <boolean | default = false> ]
 
 # Configuration for TLS protocol of TCP probe.
index 5d4baf440653f791c9eac505cccf406142b376dc..81b504f7a929d7774ce20253829145a8d57e7276 100644 (file)
@@ -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"`
index 1f145d2f07ceff08b492c38ecdc2ba493d79dacd..af15e67a66fe16e7ca88912fb7c159c7ef1ec223 100644 (file)
@@ -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
index 39816d1b38410e1c0de43b453d69b7d992cf1486..d91c37a1551b0ff829fc8bdcc7a40d1be1535812 100644 (file)
@@ -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
index 3e64410caef32205d2232ce7439fcc77eb85fcc8..d6f0aa567f7f8dab2107db58a2d9a1a97e989f06 100644 (file)
@@ -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
 }
index aa63d2ecbcba80033c12aa185bf2f8242339202e..74771dd595aee70fdf0eecb79e6e4a8f015474f2 100644 (file)
@@ -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 {
index a81608a662a4b0441a70a970d7f1214aa3438acb..aa4a9a1c48823eb5c8e36395e62ade6a061b4ad9 100644 (file)
@@ -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
+}