mattermost/server/public/model/remote_cluster.go
Doug Lauder fdaea9dec3
MM-68339: slugify RemoteCluster.Name in plugin registration (#36309)
The new TestRegisterPluginForSharedChannels tests added in #36126 broke
  master CI because RegisterPluginForSharedChannels assigned opts.Displayname
  directly to RemoteCluster.Name, which IsValid validates against the slug
  regex ^[a-zA-Z0-9.\-_]+$. Display names with spaces (e.g. "legacy plugin")
  fail validation. The tests didn't run in the PR's final CI shard and the
  issue surfaced post-merge.

  Add CleanRemoteName to the public model, mirroring CleanTeamName and
  CleanUsername: lowercase, replace spaces and other disallowed characters
  with hyphens, trim, truncate to RemoteNameMaxLength, fall back to NewId
  when the result is empty. Use it in RegisterPluginForSharedChannels so
  Name is always slug-valid while DisplayName keeps the human-readable label.
  This also lets real plugins register with display names containing spaces.
2026-04-28 13:37:33 -04:00

546 lines
15 KiB
Go

// 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/json"
"errors"
"fmt"
"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_"
// Deprecated: SiteURLPlugin was used as a prefix for plugin-based remote SiteURLs.
// New registrations store the plugin-provided SiteURL directly. Use PluginID field
// to identify plugin-based remotes.
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 == "" {
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.SiteURL == "" {
return NewAppError("RemoteCluster.IsValid", "model.cluster.is_valid.site_url.app_error", nil, "site_url is empty", 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 (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)
}
// CleanRemoteName converts an arbitrary string into a slug compatible with
// IsValidRemoteName: lowercased, with spaces and other disallowed characters
// replaced by hyphens. The result is truncated to RemoteNameMaxLength. If
// the cleaned value is still invalid (e.g. empty input), a new ID is
// substituted so the caller always receives a valid name.
func CleanRemoteName(s string) string {
s = strings.ToLower(strings.ReplaceAll(s, " ", "-"))
s = strings.TrimSpace(s)
for _, c := range s {
char := fmt.Sprintf("%c", c)
if !validRemoteNameChars.MatchString(char) {
s = strings.ReplaceAll(s, char, "-")
}
}
s = strings.Trim(s, "-")
if len(s) > RemoteNameMaxLength {
s = strings.Trim(s[:RemoteNameMaxLength], "-")
}
if !IsValidRemoteName(s) {
s = NewId()
}
return 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 {
return rc.PluginID != ""
}
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{
RemoteId: rc.RemoteId,
Name: rc.Name,
DisplayName: rc.DisplayName,
CreateAt: rc.CreateAt,
DeleteAt: rc.DeleteAt,
LastPingAt: rc.LastPingAt,
SiteURL: rc.SiteURL,
}
}
func NormalizeRemoteName(name string) string {
return strings.ToLower(name)
}
// RemoteClusterInfo provides a subset of RemoteCluster fields suitable for sending to clients.
type RemoteClusterInfo struct {
RemoteId string `json:"remote_id"`
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"`
SiteURL string `json:"site_url,omitempty"`
}
// 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
}