diff --git a/server/channels/app/platform/support_packet.go b/server/channels/app/platform/support_packet.go index 30f5b6659b6..43c8720f33c 100644 --- a/server/channels/app/platform/support_packet.go +++ b/server/channels/app/platform/support_packet.go @@ -5,7 +5,11 @@ package platform import ( "bytes" + "context" "encoding/json" + "fmt" + "net/http" + "net/url" "os" "runtime" rpprof "runtime/pprof" @@ -19,6 +23,8 @@ import ( "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/shared/mlog" "github.com/mattermost/mattermost/server/public/shared/request" + "github.com/mattermost/mattermost/server/v8/channels/utils" + "github.com/mattermost/mattermost/server/v8/platform/shared/mail" ) const ( @@ -221,6 +227,8 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model } d.LDAP.ServerName = severName d.LDAP.ServerVersion = serverVersion + } else { + d.LDAP.Status = model.StatusDisabled } /* SAML */ @@ -231,14 +239,64 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model /* Elastic Search */ if se := ps.SearchEngine.ElasticsearchEngine; se != nil { d.ElasticSearch.Backend = *ps.Config().ElasticsearchSettings.Backend + d.ElasticSearch.ServerVersion = se.GetFullVersion() + d.ElasticSearch.ServerPlugins = se.GetPlugins() if *ps.Config().ElasticsearchSettings.EnableIndexing { appErr := se.TestConfig(rctx, ps.Config()) if appErr != nil { + d.ElasticSearch.Status = model.StatusFail d.ElasticSearch.Error = appErr.Error() + } else { + d.ElasticSearch.Status = model.StatusOk } + } else { + d.ElasticSearch.Status = model.StatusDisabled } - d.ElasticSearch.ServerVersion = se.GetFullVersion() - d.ElasticSearch.ServerPlugins = se.GetPlugins() + } else { + d.ElasticSearch.Status = model.StatusDisabled + } + + /* Email Notifications */ + if model.SafeDereference(ps.Config().EmailSettings.SendEmailNotifications) { + emailSettings := ps.Config().EmailSettings + hostname := utils.GetHostnameFromSiteURL(model.SafeDereference(ps.Config().ServiceSettings.SiteURL)) + mailCfg := &mail.SMTPConfig{ + Hostname: hostname, + ConnectionSecurity: model.SafeDereference(emailSettings.ConnectionSecurity), + SkipServerCertificateVerification: model.SafeDereference(emailSettings.SkipServerCertificateVerification), + ServerName: model.SafeDereference(emailSettings.SMTPServer), + Server: model.SafeDereference(emailSettings.SMTPServer), + Port: model.SafeDereference(emailSettings.SMTPPort), + ServerTimeout: model.SafeDereference(emailSettings.SMTPServerTimeout), + Username: model.SafeDereference(emailSettings.SMTPUsername), + Password: model.SafeDereference(emailSettings.SMTPPassword), + EnableSMTPAuth: model.SafeDereference(emailSettings.EnableSMTPAuth), + SendEmailNotifications: true, + FeedbackName: model.SafeDereference(emailSettings.FeedbackName), + FeedbackEmail: model.SafeDereference(emailSettings.FeedbackEmail), + ReplyToAddress: model.SafeDereference(emailSettings.ReplyToAddress), + } + if smtpErr := mail.TestConnection(mailCfg); smtpErr != nil { + d.Notifications.Email.Status = model.StatusFail + d.Notifications.Email.Error = smtpErr.Error() + } else { + d.Notifications.Email.Status = model.StatusOk + } + } else { + d.Notifications.Email.Status = model.StatusDisabled + } + + /* Push Notifications */ + if model.SafeDereference(ps.Config().EmailSettings.SendPushNotifications) { + pushServerURL := model.SafeDereference(ps.Config().EmailSettings.PushNotificationServer) + if pushErr := testPushProxyConnection(rctx.Context(), pushServerURL); pushErr != nil { + d.Notifications.Push.Status = model.StatusFail + d.Notifications.Push.Error = pushErr.Error() + } else { + d.Notifications.Push.Status = model.StatusOk + } + } else { + d.Notifications.Push.Status = model.StatusDisabled } b, err := yaml.Marshal(&d) @@ -253,6 +311,29 @@ func (ps *PlatformService) getSupportPacketDiagnostics(rctx request.CTX) (*model return fileData, rErr.ErrorOrNil() } +// TODO: move this into its own push proxy package once one exists (see also pushNotificationClient in server.go) +func testPushProxyConnection(ctx context.Context, serverURL string) error { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + versionURL, err := url.JoinPath(serverURL, "version") + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL, nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("push proxy returned unexpected status %d", resp.StatusCode) + } + return nil +} + func (ps *PlatformService) getSanitizedConfigFile(rctx request.CTX) (*model.FileData, error) { config := ps.getSanitizedConfig(rctx, &model.SanitizeOptions{PartiallyRedactDataSources: true}) spConfig := model.SupportPacketConfig{ diff --git a/server/channels/app/platform/support_packet_test.go b/server/channels/app/platform/support_packet_test.go index b8181656ece..570a5cc7a58 100644 --- a/server/channels/app/platform/support_packet_test.go +++ b/server/channels/app/platform/support_packet_test.go @@ -4,12 +4,18 @@ package platform import ( + "bufio" "bytes" "encoding/json" "errors" + "net" + "net/http" + "net/http/httptest" "os" "path" "runtime" + "strconv" + "strings" "testing" "github.com/goccy/go-yaml" @@ -255,7 +261,7 @@ func TestGetSupportPacketDiagnostics(t *testing.T) { assert.Zero(t, d.Cluster.NumberOfNodes) /* LDAP */ - assert.Empty(t, d.LDAP.Status) + assert.Equal(t, model.StatusDisabled, d.LDAP.Status) assert.Empty(t, d.LDAP.Error) assert.Empty(t, d.LDAP.ServerName) assert.Empty(t, d.LDAP.ServerVersion) @@ -264,6 +270,7 @@ func TestGetSupportPacketDiagnostics(t *testing.T) { assert.Empty(t, d.SAML.ProviderType) /* Elastic Search */ + assert.Equal(t, model.StatusDisabled, d.ElasticSearch.Status) assert.Empty(t, d.ElasticSearch.ServerVersion) assert.Empty(t, d.ElasticSearch.ServerPlugins) }) @@ -314,6 +321,7 @@ func TestGetSupportPacketDiagnostics(t *testing.T) { packet := getDiagnostics(t) + assert.Equal(t, model.StatusDisabled, packet.LDAP.Status) assert.Equal(t, "", packet.LDAP.ServerName) assert.Equal(t, "", packet.LDAP.ServerVersion) }) @@ -475,6 +483,7 @@ func TestGetSupportPacketDiagnostics(t *testing.T) { packet := getDiagnostics(t) + assert.Equal(t, model.StatusDisabled, packet.ElasticSearch.Status) assert.Equal(t, model.ElasticsearchSettingsESBackend, packet.ElasticSearch.Backend) assert.Equal(t, "7.10.0", packet.ElasticSearch.ServerVersion) assert.Equal(t, []string{"plugin1", "plugin2"}, packet.ElasticSearch.ServerPlugins) @@ -499,6 +508,7 @@ func TestGetSupportPacketDiagnostics(t *testing.T) { packet := getDiagnostics(t) + assert.Equal(t, model.StatusOk, packet.ElasticSearch.Status) assert.Equal(t, model.ElasticsearchSettingsOSBackend, packet.ElasticSearch.Backend) assert.Equal(t, "2.5.0", packet.ElasticSearch.ServerVersion) assert.Equal(t, []string{"opensearch-plugin"}, packet.ElasticSearch.ServerPlugins) @@ -524,11 +534,170 @@ func TestGetSupportPacketDiagnostics(t *testing.T) { packet := getDiagnostics(t) + assert.Equal(t, model.StatusFail, packet.ElasticSearch.Status) assert.Equal(t, model.ElasticsearchSettingsESBackend, packet.ElasticSearch.Backend) assert.Equal(t, "7.10.0", packet.ElasticSearch.ServerVersion) assert.Equal(t, []string{"plugin1", "plugin2"}, packet.ElasticSearch.ServerPlugins) assert.Equal(t, "TestConfig: ent.elasticsearch.test_config.connection_failed, connection refused", packet.ElasticSearch.Error) }) + + t.Run("push notifications disabled", func(t *testing.T) { + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.EmailSettings.SendPushNotifications = model.NewPointer(false) + }) + t.Cleanup(func() { + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.EmailSettings.SendPushNotifications = model.NewPointer(true) + }) + }) + + packet := getDiagnostics(t) + + assert.Equal(t, model.StatusDisabled, packet.Notifications.Push.Status) + assert.Empty(t, packet.Notifications.Push.Error) + }) + + t.Run("push notifications reachable", func(t *testing.T) { + pushServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/version", r.URL.Path) + w.WriteHeader(http.StatusOK) + })) + defer pushServer.Close() + + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.EmailSettings.SendPushNotifications = model.NewPointer(true) + cfg.EmailSettings.PushNotificationServer = model.NewPointer(pushServer.URL) + }) + t.Cleanup(func() { + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.EmailSettings.SendPushNotifications = model.NewPointer(true) + cfg.EmailSettings.PushNotificationServer = model.NewPointer(model.GenericNotificationServer) + }) + }) + + packet := getDiagnostics(t) + + assert.Equal(t, model.StatusOk, packet.Notifications.Push.Status) + assert.Empty(t, packet.Notifications.Push.Error) + }) + + t.Run("push notifications unreachable", func(t *testing.T) { + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.EmailSettings.SendPushNotifications = model.NewPointer(true) + cfg.EmailSettings.PushNotificationServer = model.NewPointer("http://localhost:1") + }) + t.Cleanup(func() { + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.EmailSettings.SendPushNotifications = model.NewPointer(true) + cfg.EmailSettings.PushNotificationServer = model.NewPointer(model.GenericNotificationServer) + }) + }) + + packet := getDiagnostics(t) + + assert.Equal(t, model.StatusFail, packet.Notifications.Push.Status) + assert.NotEmpty(t, packet.Notifications.Push.Error) + }) + + t.Run("email notifications disabled", func(t *testing.T) { + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.EmailSettings.SendEmailNotifications = model.NewPointer(false) + }) + t.Cleanup(func() { + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.EmailSettings.SendEmailNotifications = model.NewPointer(true) + }) + }) + + packet := getDiagnostics(t) + + assert.Equal(t, model.StatusDisabled, packet.Notifications.Email.Status) + assert.Empty(t, packet.Notifications.Email.Error) + }) + + t.Run("email notifications reachable", func(t *testing.T) { + l, listenErr := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, listenErr) + defer l.Close() + + go func() { + for { + conn, err := l.Accept() + if err != nil { + return + } + go func(c net.Conn) { + defer c.Close() + rw := bufio.NewReadWriter(bufio.NewReader(c), bufio.NewWriter(c)) + _, _ = rw.WriteString("220 localhost ESMTP Test\r\n") + rw.Flush() + for { + line, err := rw.ReadString('\n') + if err != nil { + return + } + if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), "QUIT") { + _, _ = rw.WriteString("221 Bye\r\n") + rw.Flush() + return + } + _, _ = rw.WriteString("250 OK\r\n") + rw.Flush() + } + }(conn) + } + }() + + tcpAddr := l.Addr().(*net.TCPAddr) + smtpPort := strconv.Itoa(tcpAddr.Port) + + // MM_EMAILSETTINGS_SMTPSERVER may be set in CI and would override UpdateConfig. + // Use t.Setenv so the env var is updated before UpdateConfig calls Store.Set(), + // which re-reads GetEnvironment() (os.Environ()) and applies overrides. + t.Setenv("MM_EMAILSETTINGS_SMTPSERVER", "127.0.0.1") + + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.EmailSettings.SendEmailNotifications = model.NewPointer(true) + cfg.EmailSettings.SMTPServer = model.NewPointer("127.0.0.1") + cfg.EmailSettings.SMTPPort = model.NewPointer(smtpPort) + cfg.EmailSettings.EnableSMTPAuth = model.NewPointer(false) + cfg.EmailSettings.ConnectionSecurity = model.NewPointer("") + }) + t.Cleanup(func() { + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.EmailSettings.SendEmailNotifications = model.NewPointer(true) + cfg.EmailSettings.SMTPServer = model.NewPointer(model.EmailSMTPDefaultServer) + cfg.EmailSettings.SMTPPort = model.NewPointer(model.EmailSMTPDefaultPort) + }) + }) + + packet := getDiagnostics(t) + + assert.Equal(t, model.StatusOk, packet.Notifications.Email.Status) + assert.Empty(t, packet.Notifications.Email.Error) + }) + + t.Run("email notifications unreachable", func(t *testing.T) { + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.EmailSettings.SendEmailNotifications = model.NewPointer(true) + cfg.EmailSettings.SMTPServer = model.NewPointer("localhost") + cfg.EmailSettings.SMTPPort = model.NewPointer("1") + cfg.EmailSettings.SMTPServerTimeout = model.NewPointer(1) + }) + t.Cleanup(func() { + th.Service.UpdateConfig(func(cfg *model.Config) { + cfg.EmailSettings.SendEmailNotifications = model.NewPointer(true) + cfg.EmailSettings.SMTPServer = model.NewPointer(model.EmailSMTPDefaultServer) + cfg.EmailSettings.SMTPPort = model.NewPointer(model.EmailSMTPDefaultPort) + cfg.EmailSettings.SMTPServerTimeout = model.NewPointer(10) + }) + }) + + packet := getDiagnostics(t) + + assert.Equal(t, model.StatusFail, packet.Notifications.Email.Status) + assert.NotEmpty(t, packet.Notifications.Email.Error) + }) } func TestGetSanitizedConfigFile(t *testing.T) { diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 8a57eebeb51..37bee97aa28 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -45,6 +45,7 @@ const ( STATUS = "status" StatusOk = "OK" StatusFail = "FAIL" + StatusDisabled = "disabled" StatusUnhealthy = "UNHEALTHY" StatusRemove = "REMOVE" ConnectionId = "Connection-Id" diff --git a/server/public/model/support_packet.go b/server/public/model/support_packet.go index 2616202305f..7a2d85a893c 100644 --- a/server/public/model/support_packet.go +++ b/server/public/model/support_packet.go @@ -75,6 +75,17 @@ type SupportPacketDiagnostics struct { NumberOfNodes int `yaml:"number_of_nodes"` } `yaml:"cluster"` + Notifications struct { + Email struct { + Status string `yaml:"status"` + Error string `yaml:"error,omitempty"` + } `yaml:"email,omitempty"` + Push struct { + Status string `yaml:"status"` + Error string `yaml:"error,omitempty"` + } `yaml:"push,omitempty"` + } `yaml:"notifications,omitempty"` + LDAP struct { Status string `yaml:"status,omitempty"` Error string `yaml:"error,omitempty"` @@ -87,6 +98,7 @@ type SupportPacketDiagnostics struct { } `yaml:"saml"` ElasticSearch struct { + Status string `yaml:"status,omitempty"` Backend string `yaml:"backend,omitempty"` ServerVersion string `yaml:"server_version,omitempty"` ServerPlugins []string `yaml:"server_plugins,omitempty"`