mattermost/server/public/model/remote_cluster.go
Jesse Hallam 06b1bf3a51
MM-64878: FIPS Build (#33809)
* pin to ubuntu-24.04

* always use FIPS compatible Postgres settings

* use sha256 for remote cluster IDs

* use sha256 for client config hash

* rework S3 backend to be FIPS compatible

* skip setup-node during build, since already in container

* support FIPS builds

* Dockerfile for FIPS image, using glibc-openssl-fips

* workaround entrypoint inconsistencies

* authenticate to DockerHub

* fix FIPS_ENABLED, add test-mmctl-fips

* decouple check-mattermost-vet from test/build steps

* fixup! decouple check-mattermost-vet from test/build steps

* only build-linux-amd64 for fips

* rm entrypoint workaround

* tweak comment grammar

* rm unused Dockerfile.fips (for now)

* ignore gpg import errors, since would fail later anyway

* for fips, only make package-linux-amd64

* set FIPS_ENABLED for build step

* Add a FIPS-specific list of prepackaged plugins

Note that the names are still temporary, since they are not uploaded to
S3 yet. We may need to tweak them when that happens.

* s/golangci-lint/check-style/

This ensures we run all the `check-style` checks: previously,
`modernize` was missing.

* pin go-vet to @v2, remove annoying comment

* add -fips to linux-amd64.tz.gz package

* rm unused setup-chainctl

* use BUILD_TYPE_NAME instead

* mv fips build to enterprise-only

* fixup! use BUILD_TYPE_NAME instead

* temporarily pre-package no plugins for FIPS

* split package-cleanup

* undo package-cleanup, just skip ARM, also test

* skip arm for FIPS in second target too

* fmt Makefile

* Revert "rm unused Dockerfile.fips (for now)"

This reverts commit 601e37e0ff.

* reintroduce Dockerfile.fips and align with existing Dockerfile

* s/IMAGE/BUILD_IMAGE/

* bump the glibc-openssl-fips version

* rm redundant comment

* fix FIPS checks

* set PLUGIN_PACKAGES empty until prepackaged plugins ready

* upgrade glibc-openssl-fips, use non-dev version for final stage

* another BUILD_IMAGE case

* Prepackage the FIPS versions of plugins

* relocate FIPS_ENABLED initialization before use

* s/Config File MD5/Config File Hash/

* Update the FIPS plugin names and encode the + sign

* add /var/tmp for local socket manipulation

---------

Co-authored-by: Alejandro García Montoro <alejandro.garciamontoro@gmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
2025-09-15 10:53:28 -03:00

522 lines
14 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/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
}