diff --git a/api4/system.go b/api4/system.go index ac754a9cd3..f58e7481b0 100644 --- a/api4/system.go +++ b/api4/system.go @@ -13,11 +13,14 @@ import ( "strconv" "time" + "github.com/pkg/errors" + "github.com/mattermost/mattermost-server/v5/audit" "github.com/mattermost/mattermost-server/v5/mlog" "github.com/mattermost/mattermost-server/v5/model" "github.com/mattermost/mattermost-server/v5/services/cache" "github.com/mattermost/mattermost-server/v5/services/filesstore" + "github.com/mattermost/mattermost-server/v5/services/upgrader" ) const ( @@ -54,7 +57,9 @@ func (api *API) InitSystem() { api.BaseRoutes.ApiRoot.Handle("/server_busy", api.ApiSessionRequired(setServerBusy)).Methods("POST") api.BaseRoutes.ApiRoot.Handle("/server_busy", api.ApiSessionRequired(getServerBusyExpires)).Methods("GET") api.BaseRoutes.ApiRoot.Handle("/server_busy", api.ApiSessionRequired(clearServerBusy)).Methods("DELETE") - + api.BaseRoutes.ApiRoot.Handle("/upgrade_to_enterprise", api.ApiSessionRequired(upgradeToEnterprise)).Methods("POST") + api.BaseRoutes.ApiRoot.Handle("/upgrade_to_enterprise/status", api.ApiSessionRequired(upgradeToEnterpriseStatus)).Methods("GET") + api.BaseRoutes.ApiRoot.Handle("/restart", api.ApiSessionRequired(restart)).Methods("POST") api.BaseRoutes.ApiRoot.Handle("/warn_metrics/status", api.ApiSessionRequired(getWarnMetricsStatus)).Methods("GET") api.BaseRoutes.ApiRoot.Handle("/warn_metrics/ack/{warn_metric_id:[A-Za-z0-9-_]+}", api.ApiHandler(sendWarnMetricAckEmail)).Methods("POST") } @@ -548,6 +553,108 @@ func getServerBusyExpires(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(c.App.Srv().Busy.ToJson())) } +func upgradeToEnterprise(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord("upgradeToEnterprise", audit.Fail) + defer c.LogAuditRec(auditRec) + + if !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + if model.BuildEnterpriseReady == "true" { + c.Err = model.NewAppError("upgradeToEnterprise", "api.upgrade_to_enterprise.already-enterprise.app_error", nil, "", http.StatusTooManyRequests) + return + } + + percentage, _ := c.App.Srv().UpgradeToE0Status() + + if percentage > 0 { + c.Err = model.NewAppError("upgradeToEnterprise", "api.upgrade_to_enterprise.app_error", nil, "", http.StatusTooManyRequests) + return + } + if percentage == 100 { + c.Err = model.NewAppError("upgradeToEnterprise", "api.upgrade_to_enterprise.already-done.app_error", nil, "", http.StatusTooManyRequests) + return + } + + if err := c.App.Srv().CanIUpgradeToE0(); err != nil { + var ipErr *upgrader.InvalidPermissions + var iaErr *upgrader.InvalidArch + switch { + case errors.As(err, &ipErr): + params := map[string]interface{}{ + "MattermostUsername": ipErr.MattermostUsername, + "FileUsername": ipErr.FileUsername, + "Path": ipErr.Path, + } + if ipErr.ErrType == "invalid-user-and-permission" { + c.Err = model.NewAppError("upgradeToEnterprise", "api.upgrade_to_enterprise.invalid-user-and-permission.app_error", params, err.Error(), http.StatusForbidden) + } else if ipErr.ErrType == "invalid-user" { + c.Err = model.NewAppError("upgradeToEnterprise", "api.upgrade_to_enterprise.invalid-user.app_error", params, err.Error(), http.StatusForbidden) + } else if ipErr.ErrType == "invalid-permission" { + c.Err = model.NewAppError("upgradeToEnterprise", "api.upgrade_to_enterprise.invalid-permission.app_error", params, err.Error(), http.StatusForbidden) + } + case errors.As(err, &iaErr): + c.Err = model.NewAppError("upgradeToEnterprise", "api.upgrade_to_enterprise.system_not_supported.app_error", nil, err.Error(), http.StatusForbidden) + default: + c.Err = model.NewAppError("upgradeToEnterprise", "api.upgrade_to_enterprise.generic_error.app_error", nil, err.Error(), http.StatusForbidden) + } + return + } + + c.App.Srv().Go(func() { + c.App.Srv().UpgradeToE0() + }) + + auditRec.Success() + w.WriteHeader(http.StatusAccepted) + ReturnStatusOK(w) +} + +func upgradeToEnterpriseStatus(c *Context, w http.ResponseWriter, r *http.Request) { + if !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + percentage, err := c.App.Srv().UpgradeToE0Status() + var s map[string]interface{} + if err != nil { + var isErr *upgrader.InvalidSignature + switch { + case errors.As(err, &isErr): + appErr := model.NewAppError("upgradeToEnterpriseStatus", "api.upgrade_to_enterprise_status.app_error", nil, err.Error(), http.StatusBadRequest) + s = map[string]interface{}{"percentage": 0, "error": appErr.Message} + default: + appErr := model.NewAppError("upgradeToEnterpriseStatus", "api.upgrade_to_enterprise_status.signature.app_error", nil, err.Error(), http.StatusBadRequest) + s = map[string]interface{}{"percentage": 0, "error": appErr.Message} + } + } else { + s = map[string]interface{}{"percentage": percentage, "error": nil} + } + + w.Write([]byte(model.StringInterfaceToJson(s))) +} + +func restart(c *Context, w http.ResponseWriter, r *http.Request) { + auditRec := c.MakeAuditRecord("restartServer", audit.Fail) + defer c.LogAuditRec(auditRec) + + if !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { + c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) + return + } + + auditRec.Success() + ReturnStatusOK(w) + time.Sleep(1 * time.Second) + + go func() { + c.App.Srv().Restart() + }() +} + func getWarnMetricsStatus(c *Context, w http.ResponseWriter, r *http.Request) { if !c.App.SessionHasPermissionTo(*c.App.Session(), model.PERMISSION_MANAGE_SYSTEM) { c.SetPermissionError(model.PERMISSION_MANAGE_SYSTEM) diff --git a/app/app_test.go b/app/app_test.go index 07b78a3b64..79f983e832 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -42,6 +42,7 @@ func TestUnitUpdateConfig(t *testing.T) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) mockSystemStore.On("Get").Return(make(model.StringMap), nil) diff --git a/app/config.go b/app/config.go index 588a2e706b..b4a0c5a78e 100644 --- a/app/config.go +++ b/app/config.go @@ -346,6 +346,7 @@ func (s *Server) ClientConfigWithComputed() map[string]string { // by the client. respCfg["NoAccounts"] = strconv.FormatBool(s.IsFirstUserAccount()) respCfg["MaxPostSize"] = strconv.Itoa(s.MaxPostSize()) + respCfg["UpgradedFromTE"] = strconv.FormatBool(s.isUpgradedFromTE()) respCfg["InstallationDate"] = "" if installationDate, err := s.getSystemInstallDate(); err == nil { respCfg["InstallationDate"] = strconv.FormatInt(installationDate, 10) diff --git a/app/config_test.go b/app/config_test.go index 43adf6e14b..b31f2a4c8c 100644 --- a/app/config_test.go +++ b/app/config_test.go @@ -74,6 +74,7 @@ func TestClientConfigWithComputed(t *testing.T) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockStore.On("User").Return(&mockUserStore) mockStore.On("Post").Return(&mockPostStore) diff --git a/app/enterprise_test.go b/app/enterprise_test.go index c9ffc65cd7..8db72003da 100644 --- a/app/enterprise_test.go +++ b/app/enterprise_test.go @@ -104,6 +104,7 @@ func TestSAMLSettings(t *testing.T) { mockPostStore := storemocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := storemocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) diff --git a/app/notification_push_test.go b/app/notification_push_test.go index ae1e3ea279..d18129b014 100644 --- a/app/notification_push_test.go +++ b/app/notification_push_test.go @@ -554,6 +554,7 @@ func TestGetPushNotificationMessage(t *testing.T) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) @@ -1126,6 +1127,7 @@ func TestClearPushNotificationSync(t *testing.T) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) @@ -1180,6 +1182,7 @@ func TestUpdateMobileAppBadgeSync(t *testing.T) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) @@ -1222,6 +1225,7 @@ func TestSendAckToPushProxy(t *testing.T) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) @@ -1379,6 +1383,7 @@ func BenchmarkPushNotificationThroughput(b *testing.B) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) diff --git a/app/plugin_signature_test.go b/app/plugin_signature_test.go index 862caecdbe..9340f63702 100644 --- a/app/plugin_signature_test.go +++ b/app/plugin_signature_test.go @@ -26,6 +26,7 @@ func TestPluginPublicKeys(t *testing.T) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) diff --git a/app/post_test.go b/app/post_test.go index 7e4ebecab6..26fe87d83b 100644 --- a/app/post_test.go +++ b/app/post_test.go @@ -459,6 +459,7 @@ func TestImageProxy(t *testing.T) { mockPostStore := storemocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := storemocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) diff --git a/app/server.go b/app/server.go index 1a8b4a1514..46c7f36344 100644 --- a/app/server.go +++ b/app/server.go @@ -13,10 +13,12 @@ import ( "net/http" "net/url" "os" + "os/exec" "path" "strings" "sync" "sync/atomic" + "syscall" "time" "github.com/getsentry/sentry-go" @@ -44,6 +46,7 @@ import ( "github.com/mattermost/mattermost-server/v5/services/searchengine/bleveengine" "github.com/mattermost/mattermost-server/v5/services/timezones" "github.com/mattermost/mattermost-server/v5/services/tracing" + "github.com/mattermost/mattermost-server/v5/services/upgrader" "github.com/mattermost/mattermost-server/v5/store" "github.com/mattermost/mattermost-server/v5/store/localcachelayer" "github.com/mattermost/mattermost-server/v5/store/retrylayer" @@ -723,6 +726,51 @@ func (s *Server) Shutdown() error { return nil } +func (s *Server) Restart() error { + percentage, err := s.UpgradeToE0Status() + if err != nil || percentage != 100 { + return errors.Wrap(err, "unable to restart because the system has not been upgraded") + } + s.Shutdown() + + argv0, err := exec.LookPath(os.Args[0]) + if err != nil { + return err + } + + if _, err = os.Stat(argv0); err != nil { + return err + } + + mlog.Info("Restarting server") + return syscall.Exec(argv0, os.Args, os.Environ()) +} + +func (s *Server) isUpgradedFromTE() bool { + val, err := s.Store.System().GetByName(model.SYSTEM_UPGRADED_FROM_TE_ID) + if err != nil { + return false + } + return val.Value == "true" +} + +func (s *Server) CanIUpgradeToE0() error { + return upgrader.CanIUpgradeToE0() +} + +func (s *Server) UpgradeToE0() error { + if err := upgrader.UpgradeToE0(); err != nil { + return err + } + upgradedFromTE := &model.System{Name: model.SYSTEM_UPGRADED_FROM_TE_ID, Value: "true"} + s.Store.System().Save(upgradedFromTE) + return nil +} + +func (s *Server) UpgradeToE0Status() (int64, error) { + return upgrader.UpgradeToE0Status() +} + // Go creates a goroutine, but maintains a record of it to ensure that execution completes before // the server is shutdown. func (s *Server) Go(f func()) { diff --git a/app/web_hub_test.go b/app/web_hub_test.go index 431d9599d4..c28e441e72 100644 --- a/app/web_hub_test.go +++ b/app/web_hub_test.go @@ -131,6 +131,7 @@ func TestHubSessionRevokeRace(t *testing.T) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) diff --git a/i18n/en.json b/i18n/en.json index 9b413cb69c..c6c9bc6f0c 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2622,6 +2622,46 @@ "id": "api.templates.welcome_subject", "translation": "[{{ .SiteName }}] You joined {{ .ServerURL }}" }, + { + "id": "api.upgrade_to_enterprise.already-done.app_error", + "translation": "You have already upgraded to Mattermost Enterprise Edition. Please restart the server to finish the upgrade." + }, + { + "id": "api.upgrade_to_enterprise.already-enterprise.app_error", + "translation": "You cannot upgrade because you are already running Mattermost Enterprise Edition." + }, + { + "id": "api.upgrade_to_enterprise.app_error", + "translation": "An upgrade to Mattermost Enterprise Edition is already running." + }, + { + "id": "api.upgrade_to_enterprise.generic_error.app_error", + "translation": "Mattermost was unable to upgrade to Enterprise Edition." + }, + { + "id": "api.upgrade_to_enterprise.invalid-permission.app_error", + "translation": "Mattermost was unable to upgrade to Enterprise Edition. The Mattermost system user {{.MattermostUsername}} does not have write access to the necessary binary file. A system administrator can update the file permissions by executing the following command on the server where Mattermost is installed:\n\n```\nchmod +w \"{{.Path}}\"\n```\n\nAfter changing the file permissions, try to upgrade Mattermost again. When you have upgraded and restarted, remember to restore the original binary file permissions:\n\n```\nchmod -w \"{{.Path}}\"\n```" + }, + { + "id": "api.upgrade_to_enterprise.invalid-user-and-permission.app_error", + "translation": "Mattermost was unable to upgrade to Enterprise Edition. The Mattermost system user {{.MattermostUsername}} does not have write access to the necessary binary file. A system administrator can update the file permissions by executing the following command on the server where Mattermost is installed:\n\n```\nchown {{.MattermostUsername}} \"{{.Path}}\"\nchmod +w \"{{.Path}}\"\n```\n\nAfter changing the file permissions, try to upgrade Mattermost again. When you have upgraded and restarted, remember to restore the original binary file permissions:\n\n```\nchown {{.FileUsername}} \"{{.Path}}\"\nchmod -w \"{{.Path}}\"\n```" + }, + { + "id": "api.upgrade_to_enterprise.invalid-user.app_error", + "translation": "Mattermost was unable to upgrade to Enterprise Edition. The Mattermost system user {{.MattermostUsername}} does not have write access to the necessary binary file. A system administrator can update the file permissions by executing the following command on the server where Mattermost is installed:\n\n```\nchown {{.MattermostUsername}} \"{{.Path}}\"\n```\n\nAfter changing the file permissions, try to upgrade Mattermost again. When you have upgraded and restarted, remember to restore the original binary file permissions:\n\n```\nchown {{.FileUsername}} \"{{.Path}}\"\n```" + }, + { + "id": "api.upgrade_to_enterprise.system_not_supported.app_error", + "translation": "Mattermost was unable to upgrade to Enterprise Edition. This feature will only work on Linux systems with x86-64 architecture." + }, + { + "id": "api.upgrade_to_enterprise_status.app_error", + "translation": "Mattermost was unable to upgrade to Enterprise Edition." + }, + { + "id": "api.upgrade_to_enterprise_status.signature.app_error", + "translation": "Mattermost was unable to upgrade to Enterprise Edition. The digital signature of the downloaded binary file could not be verified." + }, { "id": "api.user.activate_mfa.email_and_ldap_only.app_error", "translation": "MFA is not available for this account type." diff --git a/model/system.go b/model/system.go index 1ad0775c93..2be454ab24 100644 --- a/model/system.go +++ b/model/system.go @@ -20,6 +20,7 @@ const ( SYSTEM_INSTALLATION_DATE_KEY = "InstallationDate" SYSTEM_FIRST_SERVER_RUN_TIMESTAMP_KEY = "FirstServerRunTimestamp" SYSTEM_CLUSTER_ENCRYPTION_KEY = "ClusterEncryptionKey" + SYSTEM_UPGRADED_FROM_TE_ID = "UpgradedFromTE" SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_200 = "warn_metric_number_of_active_users_200" SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_400 = "warn_metric_number_of_active_users_400" SYSTEM_WARN_METRIC_NUMBER_OF_ACTIVE_USERS_500 = "warn_metric_number_of_active_users_500" diff --git a/services/upgrader/errors.go b/services/upgrader/errors.go new file mode 100644 index 0000000000..52ef3892cf --- /dev/null +++ b/services/upgrader/errors.go @@ -0,0 +1,49 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package upgrader + +import "fmt" + +// InvalidArch indicates that the current operating system or cpu architecture doesn't support upgrades +type InvalidArch struct{} + +func NewInvalidArch() *InvalidArch { + return &InvalidArch{} +} + +func (e *InvalidArch) Error() string { + return "invalid operating system or processor architecture" +} + +// InvalidSignature indicates that the downloaded file doesn't have a valid signature. +type InvalidSignature struct{} + +func NewInvalidSignature() *InvalidSignature { + return &InvalidSignature{} +} + +func (e *InvalidSignature) Error() string { + return "invalid file signature" +} + +// InvalidPermissions indicates that the file permissions doesn't allow to upgrade +type InvalidPermissions struct { + ErrType string + Path string + FileUsername string + MattermostUsername string +} + +func NewInvalidPermissions(errType string, path string, mattermostUsername string, fileUsername string) *InvalidPermissions { + return &InvalidPermissions{ + ErrType: errType, + Path: path, + FileUsername: fileUsername, + MattermostUsername: mattermostUsername, + } +} + +func (e *InvalidPermissions) Error() string { + return fmt.Sprintf("the user %s is unable to update the %s file", e.MattermostUsername, e.Path) +} diff --git a/services/upgrader/upgrader.go b/services/upgrader/upgrader.go new file mode 100644 index 0000000000..8218998c60 --- /dev/null +++ b/services/upgrader/upgrader.go @@ -0,0 +1,17 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +// +build !linux + +package upgrader + +func CanIUpgradeToE0() error { + return &InvalidArch{} +} + +func UpgradeToE0() error { + return &InvalidArch{} +} + +func UpgradeToE0Status() (int64, error) { + return 0, &InvalidArch{} +} diff --git a/services/upgrader/upgrader_linux.go b/services/upgrader/upgrader_linux.go new file mode 100644 index 0000000000..83b1e814b5 --- /dev/null +++ b/services/upgrader/upgrader_linux.go @@ -0,0 +1,332 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package upgrader + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "os/user" + "path" + "runtime" + "strconv" + "sync/atomic" + "syscall" + + "github.com/pkg/errors" + "golang.org/x/crypto/openpgp" + + "github.com/mattermost/mattermost-server/v5/mlog" + "github.com/mattermost/mattermost-server/v5/model" +) + +const mattermostBuildPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBFjZQxwBCAC6kNn3zDlq/aY83M9V7MHVPoK2jnZ3BfH7sA+ibQXsijCkPSR4 +5bCUJ9qVA4XKGK+cpO9vkolSNs10igCaaemaUZNB6ksu3gT737/SZcCAfRO+cLX7 +Q2la+jwTvu1YeT/M5xDZ1KHTFxsGskeIenz2rZHeuZwBl9qep34QszWtRX40eRts +fl6WltLrepiExTp6NMZ50k+Em4JGM6CWBMo22ucy0jYjZXO5hEGb3o6NGiG+Dx2z +b2J78LksCKGsSrn0F1rLJeA933bFL4g9ozv9asBlzmpgG77ESg6YE1N/Rh7WDzVA +prIR0MuB5JjElASw5LDVxDV6RZsxEVQr7ETLABEBAAG0KU1hdHRlcm1vc3QgQnVp +bGQgPGRldi1vcHNAbWF0dGVybW9zdC5jb20+iQFUBBMBCAA+AhsDBQsJCAcCBhUI +CQoLAgQWAgMBAh4BAheAFiEEobMdRvDzoQsCzy1E+PLDF0R3SygFAl6HYr0FCQlw +hqEACgkQ+PLDF0R3SyheNQgAnkiT2vFMCtU5FmC16HVYXzDpYMtdCQPh/gmeEkiI +80rFRg/cn6f0BNnaTfDu6r6cepmhLNpDAowjQ7uBnv8fL2dzCydIGFv2r7FfmcOJ +zhEQ3zXPwP6mYlxPCCgxAozsLv9Yv41KGCHIlzYwkAazc0BhpAW/h8L3VGkE+b+g +x6lKVoufm4rKnT49Dgly6fVOxuR/BqZo87B5jksV3izLTHt5hiY8Pc5GW8WwO/tr +pNAw+6HRXq1Dr/JRz5PIOr5KP5tVLBed4IteZ1xaTRd4++07ZbiZjhXY8WKpVp3y +iN7Om24jQpxbJI9+KKJ3+yhcwhr8/PJ8ZVuhJo3BNv1PcQ== +=9Qk8 +-----END PGP PUBLIC KEY BLOCK-----` + +var upgradePercentage int64 +var upgradeError error +var upgrading int32 + +type writeCounter struct { + total int64 + readed int64 +} + +func (wc *writeCounter) Write(p []byte) (int, error) { + n := len(p) + wc.readed += int64(n) + percentage := (wc.readed * 100) / wc.total + if percentage == 0 { + upgradePercentage = 1 + } else if percentage == 100 { + upgradePercentage = 99 + } else { + upgradePercentage = percentage + } + return n, nil +} + +func getCurrentVersionTgzUrl() string { + return "https://releases.mattermost.com/" + model.CurrentVersion + "/mattermost-" + model.CurrentVersion + "-linux-amd64.tar.gz" +} + +func verifySignature(filename string, sigfilename string, publicKey string) error { + keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(publicKey))) + if err != nil { + mlog.Error("Unable to load the public key to verify the file signature", mlog.Err(err)) + return NewInvalidSignature() + } + + mattermost_tar, err := os.Open(filename) + if err != nil { + mlog.Error("Unable to open the Mattermost .tar file to verify the file signature", mlog.Err(err)) + return NewInvalidSignature() + } + + signature, err := os.Open(sigfilename) + if err != nil { + mlog.Error("Unable to open the Mattermost .sig file verify the file signature", mlog.Err(err)) + return NewInvalidSignature() + } + + _, err = openpgp.CheckDetachedSignature(keyring, mattermost_tar, signature) + if err != nil { + mlog.Error("Unable to verify the Mattermost file signature", mlog.Err(err)) + return NewInvalidSignature() + } + return nil +} + +func canIWriteTheExecutable() error { + executablePath, err := os.Executable() + if err != nil { + return errors.New("error getting the path of the executable") + } + executableInfo, err := os.Stat(path.Dir(executablePath)) + if err != nil { + return errors.New("error getting the executable info") + } + stat, ok := executableInfo.Sys().(*syscall.Stat_t) + if !ok { + return errors.New("error getting the executable info") + } + fileUID := int(stat.Uid) + fileUser, err := user.LookupId(strconv.Itoa(fileUID)) + if err != nil { + return errors.New("error getting the executable info") + } + + mattermostUID := os.Getuid() + mattermostUser, err := user.LookupId(strconv.Itoa(mattermostUID)) + if err != nil { + return errors.New("error getting the executable info") + } + + mode := executableInfo.Mode() + if fileUID != mattermostUID && mode&(1<<1) == 0 && mode&(1<<7) == 0 { + return NewInvalidPermissions("invalid-user-and-permission", path.Dir(executablePath), mattermostUser.Username, fileUser.Username) + } + + if fileUID != mattermostUID && mode&(1<<1) == 0 && mode&(1<<7) != 0 { + return NewInvalidPermissions("invalid-user", path.Dir(executablePath), mattermostUser.Username, fileUser.Username) + } + + if fileUID == mattermostUID && mode&(1<<7) == 0 { + return NewInvalidPermissions("invalid-permission", path.Dir(executablePath), mattermostUser.Username, fileUser.Username) + } + return nil +} + +func canIUpgrade() error { + if runtime.GOARCH != "amd64" { + return NewInvalidArch() + } + if runtime.GOOS != "linux" { + return NewInvalidArch() + } + return canIWriteTheExecutable() +} + +func CanIUpgradeToE0() error { + if err := canIUpgrade(); err != nil { + mlog.Error("Unable to upgrade from TE to E0", mlog.Err(err)) + return err + } + if model.BuildEnterpriseReady == "true" { + mlog.Warn("Unable to upgrade from TE to E0. The server is already running E0.") + return errors.New("you cannot upgrade your server from TE to E0 because you are already running Mattermost Enterprise Edition") + } + return nil +} + +func UpgradeToE0() error { + if !atomic.CompareAndSwapInt32(&upgrading, 0, 1) { + mlog.Warn("Trying to upgrade while another upgrade is running") + return errors.New("another upgrade is already running") + } + defer atomic.CompareAndSwapInt32(&upgrading, 1, 0) + + upgradePercentage = 1 + upgradeError = nil + + executablePath, err := os.Executable() + if err != nil { + upgradePercentage = 0 + upgradeError = errors.New("error getting the executable path") + mlog.Error("Unable to get the path of the Mattermost executable", mlog.Err(err)) + return err + } + + filename, err := download(getCurrentVersionTgzUrl(), 1024*1024*300) + if err != nil { + if filename != "" { + os.Remove(filename) + } + upgradeError = fmt.Errorf("error downloading the new Mattermost server binary file (percentage: %d)", upgradePercentage) + mlog.Error("Unable to download the Mattermost server binary file", mlog.Int64("percentage", upgradePercentage), mlog.String("url", getCurrentVersionTgzUrl()), mlog.Err(err)) + upgradePercentage = 0 + return err + } + defer os.Remove(filename) + sigfilename, err := download(getCurrentVersionTgzUrl()+".sig", 1024) + if err != nil { + if sigfilename != "" { + os.Remove(sigfilename) + } + upgradeError = errors.New("error downloading the signature file of the new server") + mlog.Error("Unable to download the signature file of the new Mattermost server", mlog.String("url", getCurrentVersionTgzUrl()+".sig"), mlog.Err(err)) + upgradePercentage = 0 + return err + } + defer os.Remove(sigfilename) + + err = verifySignature(filename, sigfilename, mattermostBuildPublicKey) + if err != nil { + upgradePercentage = 0 + upgradeError = errors.New("unable to verify the signature of the downloaded file") + mlog.Error("Unable to verify the signature of the downloaded file", mlog.Err(err)) + return err + } + + err = extractBinary(executablePath, filename) + if err != nil { + upgradePercentage = 0 + upgradeError = err + mlog.Error("Unable to extract the binary from the downloaded file", mlog.Err(err)) + return err + } + upgradePercentage = 100 + return nil +} + +func UpgradeToE0Status() (int64, error) { + return upgradePercentage, upgradeError +} + +func download(url string, limit int64) (string, error) { + resp, err := http.Get(url) + if err != nil { + return "", err + } + defer resp.Body.Close() + + out, err := ioutil.TempFile("", "*_mattermost.tar.gz") + if err != nil { + return "", err + } + defer out.Close() + + counter := &writeCounter{total: resp.ContentLength} + _, err = io.Copy(out, io.TeeReader(&io.LimitedReader{R: resp.Body, N: limit}, counter)) + return out.Name(), err +} + +func getFilePermissionsOrDefault(filename string, def os.FileMode) os.FileMode { + file, err := os.Open(filename) + if err != nil { + mlog.Error("Unable to get the file permissions", mlog.String("filename", filename), mlog.Err(err)) + return def + } + fileStats, err := file.Stat() + if err != nil { + mlog.Error("Unable to get the file permissions", mlog.String("filename", filename), mlog.Err(err)) + return def + } + return fileStats.Mode() +} + +func extractBinary(executablePath string, filename string) error { + gzipStream, err := os.Open(filename) + if err != nil { + return err + } + + uncompressedStream, err := gzip.NewReader(gzipStream) + if err != nil { + return err + } + + tarReader := tar.NewReader(uncompressedStream) + + for { + header, err := tarReader.Next() + + if err == io.EOF { + return errors.New("unable to find the Mattermost binary in the downloaded version") + } + + if err != nil { + return err + } + + if header.Typeflag == tar.TypeReg && header.Name == "mattermost/bin/mattermost" { + permissions := getFilePermissionsOrDefault(executablePath, 0755) + tmpFile, err := ioutil.TempFile(path.Dir(executablePath), "*") + if err != nil { + return err + } + tmpFileName := tmpFile.Name() + os.Remove(tmpFileName) + err = os.Rename(executablePath, tmpFileName) + if err != nil { + return err + } + outFile, err := os.Create(executablePath) + if err != nil { + err2 := os.Rename(tmpFileName, executablePath) + if err2 != nil { + mlog.Critical("Unable to restore the backup of the executable file. Restore the executable file manually.") + return errors.Wrap(err2, "critical error: unable to upgrade the binary or restore the old binary version. Please restore it manually") + } + return err + } + defer outFile.Close() + if _, err = io.Copy(outFile, tarReader); err != nil { + err2 := os.Remove(executablePath) + if err2 != nil { + mlog.Critical("Unable to restore the backup of the executable file. Restore the executable file manually.") + return errors.Wrap(err2, "critical error: unable to upgrade the binary or restore the old binary version. Please restore it manually") + } + + err2 = os.Rename(tmpFileName, executablePath) + if err2 != nil { + mlog.Critical("Unable to restore the backup of the executable file. Restore the executable file manually.") + return errors.Wrap(err2, "critical error: unable to upgrade the binary or restore the old binary version. Please restore it manually") + } + return err + } + err = os.Remove(tmpFileName) + if err != nil { + mlog.Warn("Unable to clean up the binary backup file.", mlog.Err(err)) + } + err = os.Chmod(executablePath, permissions) + if err != nil { + mlog.Warn("Unable to set the correct permissions for the file.", mlog.Err(err)) + } + break + } + } + return nil +} diff --git a/services/upgrader/upgrader_test.go b/services/upgrader/upgrader_test.go new file mode 100644 index 0000000000..6f428b205f --- /dev/null +++ b/services/upgrader/upgrader_test.go @@ -0,0 +1,134 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +package upgrader + +import ( + "archive/tar" + "compress/gzip" + "io/ioutil" + "os" + "testing" + + "github.com/mattermost/mattermost-server/v5/model" + "github.com/stretchr/testify/require" +) + +func TestCanIUpgradeToE0(t *testing.T) { + t.Run("when you are already in an enterprise build", func(t *testing.T) { + buildEnterprise := model.BuildEnterpriseReady + model.BuildEnterpriseReady = "true" + defer func() { + model.BuildEnterpriseReady = buildEnterprise + }() + require.Error(t, CanIUpgradeToE0()) + }) + + t.Run("when you are not in an enterprise build", func(t *testing.T) { + buildEnterprise := model.BuildEnterpriseReady + model.BuildEnterpriseReady = "false" + defer func() { + model.BuildEnterpriseReady = buildEnterprise + }() + require.NoError(t, CanIUpgradeToE0()) + }) +} + +func TestGetCurrentVersionTgzUrl(t *testing.T) { + currentVersion := model.CurrentVersion + model.CurrentVersion = "5.22.0" + defer func() { + model.CurrentVersion = currentVersion + }() + require.Equal(t, "https://releases.mattermost.com/5.22.0/mattermost-5.22.0-linux-amd64.tar.gz", getCurrentVersionTgzUrl()) +} + +func TestExtractBinary(t *testing.T) { + t.Run("extract from empty file", func(t *testing.T) { + tmpMockTarGz, err := ioutil.TempFile("", "mock_tgz") + require.Nil(t, err) + defer os.Remove(tmpMockTarGz.Name()) + tmpMockTarGz.Close() + + tmpMockExecutable, err := ioutil.TempFile("", "mock_exe") + require.Nil(t, err) + defer os.Remove(tmpMockExecutable.Name()) + tmpMockExecutable.Close() + + extractBinary(tmpMockExecutable.Name(), tmpMockTarGz.Name()) + }) + + t.Run("extract from empty tar.gz file", func(t *testing.T) { + tmpMockTarGz, err := ioutil.TempFile("", "mock_tgz") + require.Nil(t, err) + defer os.Remove(tmpMockTarGz.Name()) + gz := gzip.NewWriter(tmpMockTarGz) + tw := tar.NewWriter(gz) + tw.Close() + gz.Close() + tmpMockTarGz.Close() + + tmpMockExecutable, err := ioutil.TempFile("", "mock_exe") + require.Nil(t, err) + defer os.Remove(tmpMockExecutable.Name()) + tmpMockExecutable.Close() + + require.Error(t, extractBinary(tmpMockExecutable.Name(), tmpMockTarGz.Name())) + }) + + t.Run("extract from tar.gz without mattermost/bin/mattermost file", func(t *testing.T) { + tmpMockTarGz, err := ioutil.TempFile("", "mock_tgz") + require.Nil(t, err) + defer os.Remove(tmpMockTarGz.Name()) + gz := gzip.NewWriter(tmpMockTarGz) + tw := tar.NewWriter(gz) + + tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: "test-filename", + Size: 4, + }) + tw.Write([]byte("test")) + + gz.Close() + tmpMockTarGz.Close() + + tmpMockExecutable, err := ioutil.TempFile("", "mock_exe") + require.Nil(t, err) + defer os.Remove(tmpMockExecutable.Name()) + tmpMockExecutable.Close() + + require.Error(t, extractBinary(tmpMockExecutable.Name(), tmpMockTarGz.Name())) + }) + + t.Run("extract from tar.gz with mattermost/bin/mattermost file", func(t *testing.T) { + tmpMockTarGz, err := ioutil.TempFile("", "mock_tgz") + require.Nil(t, err) + defer os.Remove(tmpMockTarGz.Name()) + gz := gzip.NewWriter(tmpMockTarGz) + tw := tar.NewWriter(gz) + + tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, + Name: "mattermost/bin/mattermost", + Size: 4, + }) + tw.Write([]byte("test")) + + gz.Close() + tmpMockTarGz.Close() + + tmpMockExecutable, err := ioutil.TempFile("", "mock_exe") + require.Nil(t, err) + defer os.Remove(tmpMockExecutable.Name()) + tmpMockExecutable.Close() + + require.NoError(t, extractBinary(tmpMockExecutable.Name(), tmpMockTarGz.Name())) + tmpMockExecutableAfter, err := os.Open(tmpMockExecutable.Name()) + require.NoError(t, err) + defer tmpMockExecutableAfter.Close() + bytes, err := ioutil.ReadAll(tmpMockExecutableAfter) + require.NoError(t, err) + require.Equal(t, []byte("test"), bytes) + }) +} diff --git a/testlib/store.go b/testlib/store.go index 3702502703..468fbf9345 100644 --- a/testlib/store.go +++ b/testlib/store.go @@ -24,6 +24,7 @@ func (s *TestStore) Close() { func GetMockStoreForSetupFunctions() *mocks.Store { mockStore := mocks.Store{} systemStore := mocks.SystemStore{} + systemStore.On("GetByName", "UpgradedFromTE").Return(nil, model.NewAppError("FakeError", "app.system.get_by_name.app_error", nil, "", http.StatusInternalServerError)) systemStore.On("GetByName", "AsymmetricSigningKey").Return(nil, model.NewAppError("FakeError", "app.system.get_by_name.app_error", nil, "", http.StatusInternalServerError)) systemStore.On("GetByName", "PostActionCookieSecret").Return(nil, model.NewAppError("FakeError", "app.system.get_by_name.app_error", nil, "", http.StatusInternalServerError)) systemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: strconv.FormatInt(model.GetMillis(), 10)}, nil) diff --git a/web/context_test.go b/web/context_test.go index c196f34ae2..39f9dba242 100644 --- a/web/context_test.go +++ b/web/context_test.go @@ -43,6 +43,7 @@ func TestMfaRequired(t *testing.T) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockStore.On("User").Return(&mockUserStore) diff --git a/web/handlers_test.go b/web/handlers_test.go index bbf8a0f3e1..04548f2368 100644 --- a/web/handlers_test.go +++ b/web/handlers_test.go @@ -70,6 +70,7 @@ func TestHandlerServeHTTPSecureTransport(t *testing.T) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) @@ -311,6 +312,7 @@ func TestHandlerServeCSPHeader(t *testing.T) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil) @@ -477,6 +479,7 @@ func TestCheckCSRFToken(t *testing.T) { mockPostStore := mocks.PostStore{} mockPostStore.On("GetMaxPostSize").Return(65535, nil) mockSystemStore := mocks.SystemStore{} + mockSystemStore.On("GetByName", "UpgradedFromTE").Return(&model.System{Name: "UpgradedFromTE", Value: "false"}, nil) mockSystemStore.On("GetByName", "InstallationDate").Return(&model.System{Name: "InstallationDate", Value: "10"}, nil) mockSystemStore.On("GetByName", "FirstServerRunTimestamp").Return(&model.System{Name: "FirstServerRunTimestamp", Value: "10"}, nil)