From 63b3f1cf2a783c106c289dae4166bf2039cf5727 Mon Sep 17 00:00:00 2001 From: Assis Ngolo Date: Mon, 10 Jul 2023 02:21:35 +0100 Subject: [PATCH] Add smtp and imap service types --- .gitignore | 1 + Makefile | 3 + frontend/src/forms/Service.vue | 19 ++- go.mod | 2 + go.sum | 9 ++ types/services/methods.go | 43 +++++-- types/services/routine.go | 227 ++++++++++++++++++++++++++++++++- 7 files changed, 288 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index ccdb9205..8eee2fe3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea +.vscode snap prime stage diff --git a/Makefile b/Makefile index ef78a2c5..e40742dc 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,9 @@ up: docker compose -f docker-compose.yml -f dev/docker-compose.full.yml up -d --remove-orphans make print_details +local: clean frontend-build build + export STATPING_DIR=./docker/statping/sqlite && ./statping --port 8080 + down: docker compose -f docker-compose.yml -f dev/docker-compose.full.yml down --volumes --remove-orphans diff --git a/frontend/src/forms/Service.vue b/frontend/src/forms/Service.vue index fbe847d7..a260e106 100644 --- a/frontend/src/forms/Service.vue +++ b/frontend/src/forms/Service.vue @@ -19,6 +19,8 @@ + + Use HTTP if you are checking a website or use TCP if you are checking a server @@ -88,6 +90,12 @@ +
+ +
+ +
+
@@ -132,6 +140,13 @@ Comma delimited list of HTTP Headers (KEY=VALUE,KEY=VALUE)
+
+ +
+ + Comma delimited list of IMAP/SMTP credentials (Username=user@domain.com,Password=secretpassword) +
+
@@ -156,7 +171,7 @@
-
+
@@ -194,7 +209,7 @@
-
+
diff --git a/go.mod b/go.mod index 1ce0e590..e27e0cb3 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,8 @@ require ( github.com/GeertJohan/go.rice v1.0.3 github.com/aws/aws-sdk-go v1.30.20 github.com/dgrijalva/jwt-go v3.2.0+incompatible + github.com/emersion/go-imap v1.0.6 + github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 // indirect github.com/fatih/structs v1.1.0 github.com/foomo/simplecert v1.7.5 github.com/foomo/tlsconfig v0.0.0-20180418120404-b67861b076c9 diff --git a/go.sum b/go.sum index ec477814..ea3dd9bc 100755 --- a/go.sum +++ b/go.sum @@ -159,6 +159,14 @@ github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5m github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= +github.com/emersion/go-imap v1.0.6 h1:N9+o5laOGuntStBo+BOgfEB5evPsPD+K5+M0T2dctIc= +github.com/emersion/go-imap v1.0.6/go.mod h1:yKASt+C3ZiDAiCSssxg9caIckWF/JG7ZQTO7GAmvicU= +github.com/emersion/go-message v0.11.1/go.mod h1:C4jnca5HOTo4bGN9YdqNQM9sITuT3Y0K6bSUw9RklvY= +github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b h1:uhWtEWBHgop1rqEk2klKaxPAkVDCXexai6hSuRQ7Nvs= +github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= +github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= +github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -392,6 +400,7 @@ github.com/liquidweb/liquidweb-go v1.6.1/go.mod h1:UDcVnAMDkZxpw4Y7NOHkqoeiGacVL github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8= github.com/matryer/try v0.0.0-20161228173917-9ac251b645a2/go.mod h1:0KeJpeMD6o+O4hW7qJOT7vyQPKrWmj26uf5wMc/IiIs= github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= diff --git a/types/services/methods.go b/types/services/methods.go index e0372659..bf68988e 100644 --- a/types/services/methods.go +++ b/types/services/methods.go @@ -6,27 +6,29 @@ import ( "crypto/x509" "encoding/hex" "fmt" + "io/ioutil" + "sort" + "strconv" + "time" + "github.com/statping-ng/statping-ng/types" "github.com/statping-ng/statping-ng/types/errors" "github.com/statping-ng/statping-ng/types/failures" "github.com/statping-ng/statping-ng/types/hits" "github.com/statping-ng/statping-ng/utils" - "io/ioutil" - "sort" - "strconv" - "time" ) const limitedFailures = 25 -func (s *Service) LoadTLSCert() (*tls.Config, error) { +func (s *Service) LoadTLSCert() (config *tls.Config, err error) { + config, err = s.configureTLS() if s.TLSCert.String == "" || s.TLSCertKey.String == "" { - return nil, nil + return } // load TLS cert and key from file path or PEM format var cert tls.Certificate - var err error + tlsCertExtension := utils.FileExtension(s.TLSCert.String) tlsCertKeyExtension := utils.FileExtension(s.TLSCertKey.String) if tlsCertExtension == "" && tlsCertKeyExtension == "" { @@ -38,13 +40,14 @@ func (s *Service) LoadTLSCert() (*tls.Config, error) { return nil, errors.Wrap(err, "issue loading X509KeyPair") } - config := &tls.Config{ - Certificates: []tls.Certificate{cert}, - InsecureSkipVerify: s.TLSCertRoot.String == "", + if config == nil { + config = &tls.Config{} } + config.Certificates = []tls.Certificate{cert} + config.InsecureSkipVerify = config.InsecureSkipVerify || s.TLSCertRoot.String == "" if s.TLSCertRoot.String == "" { - return config, nil + return } // create Root CA pool or use Root CA provided @@ -58,7 +61,23 @@ func (s *Service) LoadTLSCert() (*tls.Config, error) { config.RootCAs = caCertPool - return config, nil + return +} + +func (s Service) configureTLS() (config *tls.Config, err error) { + if !s.requiresTLS() { + return nil, nil + } + config = &tls.Config{ + ServerName: s.Domain, + InsecureSkipVerify: false, + } + + return +} + +func (s Service) requiresTLS() bool { + return s.VerifySSL.Bool || ((s.Type == "smtp" || s.Type == "imap") && (s.Port == 465 || s.Port == 587 || s.Port == 993)) } func (s Service) Duration() time.Duration { diff --git a/types/services/routine.go b/types/services/routine.go index fe28c416..966c5999 100644 --- a/types/services/routine.go +++ b/types/services/routine.go @@ -4,9 +4,11 @@ import ( "bytes" "context" "crypto/tls" + "errors" "fmt" "net" "net/http" + "net/smtp" "net/url" "regexp" "strings" @@ -17,6 +19,7 @@ import ( "google.golang.org/grpc" "google.golang.org/grpc/credentials" + "github.com/emersion/go-imap/client" "github.com/statping-ng/statping-ng/types/failures" "github.com/statping-ng/statping-ng/types/hits" "github.com/statping-ng/statping-ng/utils" @@ -58,7 +61,7 @@ CheckLoop: } func parseHost(s *Service) string { - if s.Type == "tcp" || s.Type == "udp" || s.Type == "grpc" { + if s.Type == "tcp" || s.Type == "udp" || s.Type == "grpc" || s.Type == "smtp" || s.Type == "imap" { return s.Domain } else { u, err := url.Parse(s.Domain) @@ -74,7 +77,7 @@ func dnsCheck(s *Service) (int64, error) { var err error t1 := utils.Now() host := parseHost(s) - if s.Type == "tcp" || s.Type == "udp" || s.Type == "grpc" { + if s.Type == "tcp" || s.Type == "udp" || s.Type == "grpc" || s.Type == "smtp" { _, err = net.LookupHost(host) } else { _, err = net.LookupIP(host) @@ -293,6 +296,222 @@ func CheckTcp(s *Service, record bool) (*Service, error) { return s, nil } +// checkSmtp will check an SMTP service +func CheckSmtp(s *Service, record bool) (*Service, error) { + defer s.updateLastCheck() + timer := prometheus.NewTimer(metrics.ServiceTimer(s.Name)) + defer timer.ObserveDuration() + + dnsLookup, err := dnsCheck(s) + if err != nil { + if record { + RecordFailure(s, fmt.Sprintf("Could not get IP address for %s service %v, %v", strings.ToUpper(s.Type), s.Domain, err), "lookup") + } + return s, err + } + s.PingTime = dnsLookup + t1 := utils.Now() + domain := fmt.Sprintf("%v", s.Domain) + if s.Port != 0 { + domain = fmt.Sprintf("%v:%v", s.Domain, s.Port) + if isIPv6(s.Domain) { + domain = fmt.Sprintf("[%v]:%v", s.Domain, s.Port) + } + } + + tlsConfig, err := s.LoadTLSCert() + if err != nil { + log.Errorln(err) + } + + var c *smtp.Client + var headers []string + var username, password string + if s.Headers.Valid { + headers = strings.Split(s.Headers.String, ",") + } else { + headers = nil + } + + // check if 'Content-Type' header was defined + for _, header := range headers { + switch strings.ToLower(strings.Split(header, "=")[0]) { + case "username": + username = strings.Split(header, "=")[1] + case "password": + password = strings.Split(header, "=")[1] + } + } + + if s.requiresTLS() || s.TLSCert.String != "" { + // test TCP connection if TLS Certificate was set + dialer := &net.Dialer{ + KeepAlive: time.Duration(s.Timeout) * time.Second, + Timeout: time.Duration(s.Timeout) * time.Second, + } + conn, err := tls.DialWithDialer(dialer, "tcp", domain, tlsConfig) + if err != nil { + if record { + RecordFailure(s, fmt.Sprintf("Dial Error: %v", err), "tls") + } + return s, err + } + defer conn.Close() + c, err = smtp.NewClient(conn, s.Domain) + if err != nil { + if record { + RecordFailure(s, fmt.Sprintf("%s Connection Error: %v", strings.ToUpper(s.Type), err), s.Type) + } + return s, err + } + } else { + // test TCP connection if there is no TLS Certificate set + conn, err := net.DialTimeout("tcp", domain, time.Duration(s.Timeout)*time.Second) + if err != nil { + if record { + RecordFailure(s, fmt.Sprintf("Dial Error: %v", err), "tls") + } + return s, err + } + defer conn.Close() + c, err = smtp.NewClient(conn, s.Domain) + if err != nil { + if record { + RecordFailure(s, fmt.Sprintf("%s Connection Error: %v", strings.ToUpper(s.Type), err), s.Type) + } + return s, err + } + } + + // Auth + if s.Port != 25 { + if username == "" || password == "" { + err = errors.New("no credentials configured") + if record { + RecordFailure(s, fmt.Sprintf("%s Authentication Error: %v", strings.ToUpper(s.Type), err), s.Type) + } + return s, err + } + + if err = c.Auth(smtp.PlainAuth("", username, password, s.Domain)); err != nil { + if record { + RecordFailure(s, fmt.Sprintf("%s Authentication Error: %v", strings.ToUpper(s.Type), err), s.Type) + } + return s, err + } + } + + s.Latency = utils.Now().Sub(t1).Microseconds() + s.LastResponse = "" + s.Online = true + if record { + RecordSuccess(s) + } + return s, nil +} + +func CheckImap(s *Service, record bool) (*Service, error) { + defer s.updateLastCheck() + timer := prometheus.NewTimer(metrics.ServiceTimer(s.Name)) + defer timer.ObserveDuration() + + dnsLookup, err := dnsCheck(s) + if err != nil { + if record { + RecordFailure(s, fmt.Sprintf("Could not get IP address for %s service %v, %v", strings.ToUpper(s.Type), s.Domain, err), "lookup") + } + return s, err + } + s.PingTime = dnsLookup + t1 := utils.Now() + domain := fmt.Sprintf("%v", s.Domain) + if s.Port != 0 { + domain = fmt.Sprintf("%v:%v", s.Domain, s.Port) + if isIPv6(s.Domain) { + domain = fmt.Sprintf("[%v]:%v", s.Domain, s.Port) + } + } + + tlsConfig, err := s.LoadTLSCert() + if err != nil { + log.Errorln(err) + } + + var headers []string + var username, password string + if s.Headers.Valid { + headers = strings.Split(s.Headers.String, ",") + } else { + headers = nil + } + + // check if 'Content-Type' header was defined + for _, header := range headers { + switch strings.ToLower(strings.Split(header, "=")[0]) { + case "username": + username = strings.Split(header, "=")[1] + case "password": + password = strings.Split(header, "=")[1] + } + } + + var conn *client.Client + if s.requiresTLS() || s.TLSCert.String != "" { + // test TCP connection if TLS Certificate was set + dialer := &net.Dialer{ + KeepAlive: time.Duration(s.Timeout) * time.Second, + Timeout: time.Duration(s.Timeout) * time.Second, + } + conn, err = client.DialWithDialerTLS(dialer, domain, tlsConfig) + if err != nil { + if record { + RecordFailure(s, fmt.Sprintf("Dial Error: %v", err), "tls") + } + return s, err + } + } else { + // test TCP connection if there is no TLS Certificate set + dialer := &net.Dialer{ + KeepAlive: time.Duration(s.Timeout) * time.Second, + Timeout: time.Duration(s.Timeout) * time.Second, + } + conn, err = client.DialWithDialer(dialer, domain) + if err != nil { + if record { + RecordFailure(s, fmt.Sprintf("Dial Error: %v", err), "tls") + } + return s, err + } + } + defer conn.Logout() + + // Auth + if s.Port != 143 { + if username == "" || password == "" { + err = errors.New("no credentials configured") + if record { + RecordFailure(s, fmt.Sprintf("%s Authentication Error: %v", strings.ToUpper(s.Type), err), s.Type) + } + return s, err + } + + if err = conn.Login(username, password); err != nil { + if record { + RecordFailure(s, fmt.Sprintf("%s Authentication Error: %v", strings.ToUpper(s.Type), err), s.Type) + } + return s, err + } + } + + s.Latency = utils.Now().Sub(t1).Microseconds() + s.LastResponse = "" + s.Online = true + if record { + RecordSuccess(s) + } + return s, nil +} + func (s *Service) updateLastCheck() { s.LastCheck = time.Now() } @@ -460,5 +679,9 @@ func (s *Service) CheckService(record bool) { CheckGrpc(s, record) case "icmp": CheckIcmp(s, record) + case "smtp": + CheckSmtp(s, record) + case "imap": + CheckImap(s, record) } }