// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package model import ( "crypto/aes" "crypto/cipher" "crypto/pbkdf2" "crypto/rand" "crypto/sha256" "encoding/base32" "encoding/json" "errors" "io" "net/http" "net/url" "regexp" "strings" "golang.org/x/crypto/scrypt" ) const ( RemoteOfflineAfterMillis = 1000 * 60 * 5 // 5 minutes RemoteNameMinLength = 1 RemoteNameMaxLength = 64 SiteURLPending = "pending_" SiteURLPlugin = "plugin_" BitflagOptionAutoShareDMs Bitmask = 1 << iota // Any new DM/GM is automatically shared BitflagOptionAutoInvited // Remote is automatically invited to all shared channels ) var ( validRemoteNameChars = regexp.MustCompile(`^[a-zA-Z0-9\.\-\_]+$`) ErrOfflineRemote = errors.New("remote is offline") ) type Bitmask uint32 func (bm *Bitmask) IsBitSet(flag Bitmask) bool { return *bm != 0 } func (bm *Bitmask) SetBit(flag Bitmask) { *bm |= flag } func (bm *Bitmask) UnsetBit(flag Bitmask) { *bm &= ^flag } type RemoteCluster struct { RemoteId string `json:"remote_id"` RemoteTeamId string `json:"remote_team_id"` // Deprecated: this field is no longer used. It's only kept for backwards compatibility. Name string `json:"name"` DisplayName string `json:"display_name"` SiteURL string `json:"site_url"` DefaultTeamId string `json:"default_team_id"` CreateAt int64 `json:"create_at"` DeleteAt int64 `json:"delete_at"` LastPingAt int64 `json:"last_ping_at"` LastGlobalUserSyncAt int64 `json:"last_global_user_sync_at"` // Timestamp of last global user sync Token string `json:"token"` RemoteToken string `json:"remote_token"` Topics string `json:"topics"` CreatorId string `json:"creator_id"` PluginID string `json:"plugin_id"` // non-empty when sync message are to be delivered via plugin API Options Bitmask `json:"options"` // bit-flag set of options } func (rc *RemoteCluster) Auditable() map[string]any { return map[string]any{ "remote_id": rc.RemoteId, "remote_team_id": rc.RemoteTeamId, "name": rc.Name, "display_name": rc.DisplayName, "site_url": rc.SiteURL, "default_team_id": rc.DefaultTeamId, "create_at": rc.CreateAt, "delete_at": rc.DeleteAt, "last_ping_at": rc.LastPingAt, "last_global_user_sync_at": rc.LastGlobalUserSyncAt, "creator_id": rc.CreatorId, "plugin_id": rc.PluginID, "options": rc.Options, } } func (rc *RemoteCluster) PreSave() { if rc.RemoteId == "" { if rc.PluginID != "" { rc.RemoteId = newIDFromBytes([]byte(rc.PluginID)) } else { rc.RemoteId = NewId() } } if rc.DisplayName == "" { rc.DisplayName = rc.Name } rc.Name = SanitizeUnicode(rc.Name) rc.DisplayName = SanitizeUnicode(rc.DisplayName) rc.Name = NormalizeRemoteName(rc.Name) if rc.Token == "" { rc.Token = NewId() } if rc.CreateAt == 0 { rc.CreateAt = GetMillis() } rc.fixTopics() } func (rc *RemoteCluster) IsValid() *AppError { if !IsValidId(rc.RemoteId) { return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.id.app_error", nil, "id="+rc.RemoteId, http.StatusBadRequest) } if !IsValidRemoteName(rc.Name) { return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.name.app_error", nil, "name="+rc.Name, http.StatusBadRequest) } if rc.CreateAt == 0 { return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.create_at.app_error", nil, "create_at=0", http.StatusBadRequest) } if !IsValidId(rc.CreatorId) { return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.id.app_error", nil, "creator_id="+rc.CreatorId, http.StatusBadRequest) } if rc.DefaultTeamId != "" && !IsValidId(rc.DefaultTeamId) { return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.id.app_error", nil, "default_team_id="+rc.DefaultTeamId, http.StatusBadRequest) } return nil } func (rc *RemoteCluster) Sanitize() { rc.Token = "" rc.RemoteToken = "" } type RemoteClusterPatch struct { DisplayName *string `json:"display_name"` DefaultTeamId *string `json:"default_team_id"` } func (rcp *RemoteClusterPatch) Auditable() map[string]any { return map[string]any{ "display_name": rcp.DisplayName, "default_team_id": rcp.DefaultTeamId, } } func (rc *RemoteCluster) Patch(patch *RemoteClusterPatch) { if patch.DisplayName != nil { rc.DisplayName = *patch.DisplayName } if patch.DefaultTeamId != nil { rc.DefaultTeamId = *patch.DefaultTeamId } } type RemoteClusterWithPassword struct { *RemoteCluster Password string `json:"password"` } type RemoteClusterWithInvite struct { RemoteCluster *RemoteCluster `json:"remote_cluster"` Invite string `json:"invite"` Password string `json:"password,omitempty"` } func newIDFromBytes(b []byte) string { hash := sha256.New() _, _ = hash.Write(b) buf := hash.Sum(nil) encoding := base32.NewEncoding("ybndrfg8ejkmcpqxot1uwisza345h769").WithPadding(base32.NoPadding) id := encoding.EncodeToString(buf) return id[:26] } func (rc *RemoteCluster) IsOptionFlagSet(flag Bitmask) bool { return rc.Options.IsBitSet(flag) } func (rc *RemoteCluster) SetOptionFlag(flag Bitmask) { rc.Options.SetBit(flag) } func (rc *RemoteCluster) UnsetOptionFlag(flag Bitmask) { rc.Options.UnsetBit(flag) } func IsValidRemoteName(s string) bool { if len(s) < RemoteNameMinLength || len(s) > RemoteNameMaxLength { return false } return validRemoteNameChars.MatchString(s) } func (rc *RemoteCluster) PreUpdate() { if rc.DisplayName == "" { rc.DisplayName = rc.Name } rc.Name = SanitizeUnicode(rc.Name) rc.DisplayName = SanitizeUnicode(rc.DisplayName) rc.Name = NormalizeRemoteName(rc.Name) rc.fixTopics() } func (rc *RemoteCluster) IsOnline() bool { return rc.LastPingAt > GetMillis()-RemoteOfflineAfterMillis } func (rc *RemoteCluster) IsConfirmed() bool { if rc.IsPlugin() { return true // local plugins are automatically confirmed } if rc.SiteURL != "" && !strings.HasPrefix(rc.SiteURL, SiteURLPending) { return true // empty or pending siteurl are not confirmed } return false } func (rc *RemoteCluster) IsPlugin() bool { if rc.PluginID != "" || strings.HasPrefix(rc.SiteURL, SiteURLPlugin) { return true // local plugins are automatically confirmed } return false } func (rc *RemoteCluster) GetSiteURL() string { siteURL := rc.SiteURL if strings.HasPrefix(siteURL, SiteURLPending) { siteURL = "..." } if strings.HasPrefix(siteURL, SiteURLPending) || strings.HasPrefix(siteURL, SiteURLPlugin) { siteURL = "plugin" } return siteURL } // fixTopics ensures all topics are separated by one, and only one, space. func (rc *RemoteCluster) fixTopics() { trimmed := strings.TrimSpace(rc.Topics) if trimmed == "" || trimmed == "*" { rc.Topics = trimmed return } var sb strings.Builder sb.WriteString(" ") ss := strings.SplitSeq(rc.Topics, " ") for c := range ss { cc := strings.TrimSpace(c) if cc != "" { sb.WriteString(cc) sb.WriteString(" ") } } rc.Topics = sb.String() } func (rc *RemoteCluster) ToRemoteClusterInfo() RemoteClusterInfo { return RemoteClusterInfo{ Name: rc.Name, DisplayName: rc.DisplayName, CreateAt: rc.CreateAt, DeleteAt: rc.DeleteAt, LastPingAt: rc.LastPingAt, } } func NormalizeRemoteName(name string) string { return strings.ToLower(name) } // RemoteClusterInfo provides a subset of RemoteCluster fields suitable for sending to clients. type RemoteClusterInfo struct { Name string `json:"name"` DisplayName string `json:"display_name"` CreateAt int64 `json:"create_at"` DeleteAt int64 `json:"delete_at"` LastPingAt int64 `json:"last_ping_at"` } // RemoteClusterFrame wraps a `RemoteClusterMsg` with credentials specific to a remote cluster. type RemoteClusterFrame struct { RemoteId string `json:"remote_id"` Msg RemoteClusterMsg `json:"msg"` } func (f *RemoteClusterFrame) Auditable() map[string]any { return map[string]any{ "remote_id": f.RemoteId, "msg_id": f.Msg.Id, "topic": f.Msg.Topic, } } func (f *RemoteClusterFrame) IsValid() *AppError { if !IsValidId(f.RemoteId) { return NewAppError("RemoteClusterFrame.IsValid", "api.remote_cluster.invalid_id.app_error", nil, "RemoteId="+f.RemoteId, http.StatusBadRequest) } if appErr := f.Msg.IsValid(); appErr != nil { return appErr } return nil } // RemoteClusterMsg represents a message that is sent and received between clusters. // These are processed and routed via the RemoteClusters service. type RemoteClusterMsg struct { Id string `json:"id"` Topic string `json:"topic"` CreateAt int64 `json:"create_at"` Payload json.RawMessage `json:"payload"` } func NewRemoteClusterMsg(topic string, payload json.RawMessage) RemoteClusterMsg { return RemoteClusterMsg{ Id: NewId(), Topic: topic, CreateAt: GetMillis(), Payload: payload, } } func (m RemoteClusterMsg) IsValid() *AppError { if !IsValidId(m.Id) { return NewAppError("RemoteClusterMsg.IsValid", "api.remote_cluster.invalid_id.app_error", nil, "Id="+m.Id, http.StatusBadRequest) } if m.Topic == "" { return NewAppError("RemoteClusterMsg.IsValid", "api.remote_cluster.invalid_topic.app_error", nil, "Topic empty", http.StatusBadRequest) } if len(m.Payload) == 0 { return NewAppError("RemoteClusterMsg.IsValid", "api.context.invalid_body_param.app_error", map[string]any{"Name": "PayLoad"}, "", http.StatusBadRequest) } return nil } // RemoteClusterPing represents a ping that is sent and received between clusters // to indicate a connection is alive. This is the payload for a `RemoteClusterMsg`. type RemoteClusterPing struct { SentAt int64 `json:"sent_at"` RecvAt int64 `json:"recv_at"` } // RemoteClusterInvite represents an invitation to establish a simple trust with a remote cluster. type RemoteClusterInvite struct { RemoteId string `json:"remote_id"` RemoteTeamId string `json:"remote_team_id"` // Deprecated: this field is no longer used. It's only kept for backwards compatibility. SiteURL string `json:"site_url"` Token string `json:"token"` RefreshedToken string `json:"refreshed_token,omitempty"` // New token generated by the remote cluster when accepting an invitation Version int `json:"version,omitempty"` } func (rci *RemoteClusterInvite) IsValid() *AppError { if !IsValidId(rci.RemoteId) { return NewAppError("RemoteClusterInvite.IsValid", "model.remote_cluster_invite.is_valid.remote_id.app_error", nil, "id="+rci.RemoteId, http.StatusBadRequest) } if rci.Token == "" { return NewAppError("RemoteClusterInvite.IsValid", "model.remote_cluster_invite.is_valid.token.app_error", nil, "Token empty", http.StatusBadRequest) } if _, err := url.ParseRequestURI(rci.SiteURL); err != nil { return NewAppError("RemoteClusterInvite.IsValid", "model.remote_cluster_invite.is_valid.site_url.app_error", nil, "", http.StatusBadRequest).Wrap(err) } return nil } func (rci *RemoteClusterInvite) Encrypt(password string) ([]byte, error) { raw, err := json.Marshal(&rci) if err != nil { return nil, err } // create random salt to be prepended to the blob. salt := make([]byte, 16) if _, err = io.ReadFull(rand.Reader, salt); err != nil { return nil, err } var key []byte if rci.Version >= 3 { // Use PBKDF2 for version 3 and above key, err = pbkdf2.Key(sha256.New, password, salt, 600000, 32) if err != nil { return nil, err } } else { // Use scrypt for older versions key, err = scrypt.Key([]byte(password), salt, 32768, 8, 1, 32) if err != nil { return nil, err } } block, err := aes.NewCipher(key[:]) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } // create random nonce nonce := make([]byte, gcm.NonceSize()) if _, err = io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } // prefix the nonce to the cyphertext so we don't need to keep track of it. sealed := gcm.Seal(nonce, nonce, raw, nil) return append(salt, sealed...), nil //nolint:makezero } func (rci *RemoteClusterInvite) Decrypt(encrypted []byte, password string) error { if len(encrypted) <= 16 { return errors.New("invalid length") } // first 16 bytes is the salt that was used to derive a key salt := encrypted[:16] encrypted = encrypted[16:] // Try PBKDF2 first (for version 3+) if err := rci.tryDecrypt(encrypted, password, salt, true); err == nil { return nil } // Fall back to scrypt (for older versions) return rci.tryDecrypt(encrypted, password, salt, false) } func (rci *RemoteClusterInvite) tryDecrypt(encrypted []byte, password string, salt []byte, usePBKDF2 bool) error { var key []byte var err error if usePBKDF2 { // Use PBKDF2 for version 3 and above key, err = pbkdf2.Key(sha256.New, password, salt, 600000, 32) if err != nil { return err } } else { // Use scrypt for older versions key, err = scrypt.Key([]byte(password), salt, 32768, 8, 1, 32) if err != nil { return err } } block, err := aes.NewCipher(key[:]) if err != nil { return err } gcm, err := cipher.NewGCM(block) if err != nil { return err } // nonce was prefixed to the cyphertext when encrypting so we need to extract it. nonceSize := gcm.NonceSize() nonce, cyphertext := encrypted[:nonceSize], encrypted[nonceSize:] plain, err := gcm.Open(nil, nonce, cyphertext, nil) if err != nil { return err } // try to unmarshall the decrypted JSON to this invite struct. return json.Unmarshal(plain, &rci) } type RemoteClusterAcceptInvite struct { Name string `json:"name"` DisplayName string `json:"display_name"` DefaultTeamId string `json:"default_team_id"` Invite string `json:"invite"` Password string `json:"password"` } // RemoteClusterQueryFilter provides filter criteria for RemoteClusterStore.GetAll type RemoteClusterQueryFilter struct { ExcludeOffline bool InChannel string NotInChannel string Topic string CreatorId string OnlyConfirmed bool PluginID string OnlyPlugins bool ExcludePlugins bool RequireOptions Bitmask IncludeDeleted bool }