// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package plugin import ( "bytes" "fmt" "hash/fnv" "os" "path/filepath" "strings" "sync" "time" "github.com/pkg/errors" "github.com/mattermost/mattermost-server/server/v8/channels/einterfaces" "github.com/mattermost/mattermost-server/server/v8/channels/utils" "github.com/mattermost/mattermost-server/server/v8/model" "github.com/mattermost/mattermost-server/server/v8/platform/shared/mlog" ) var ErrNotFound = errors.New("Item not found") type apiImplCreatorFunc func(*model.Manifest) API // registeredPlugin stores the state for a given plugin that has been activated // or attempted to be activated this server run. // // If an installed plugin is missing from the env.registeredPlugins map, then the // plugin is configured as disabled and has not been activated during this server run. type registeredPlugin struct { BundleInfo *model.BundleInfo State int Error string supervisor *supervisor } // PrepackagedPlugin is a plugin prepackaged with the server and found on startup. type PrepackagedPlugin struct { Path string IconData string Manifest *model.Manifest Signature []byte } // Environment represents the execution environment of active plugins. // // It is meant for use by the Mattermost server to manipulate, interact with and report on the set // of active plugins. type Environment struct { registeredPlugins sync.Map pluginHealthCheckJob *PluginHealthCheckJob logger *mlog.Logger metrics einterfaces.MetricsInterface newAPIImpl apiImplCreatorFunc dbDriver Driver pluginDir string webappPluginDir string patchReactDOM bool prepackagedPlugins []*PrepackagedPlugin prepackagedPluginsLock sync.RWMutex } func NewEnvironment( newAPIImpl apiImplCreatorFunc, dbDriver Driver, pluginDir string, webappPluginDir string, patchReactDOM bool, logger *mlog.Logger, metrics einterfaces.MetricsInterface, ) (*Environment, error) { return &Environment{ logger: logger, metrics: metrics, newAPIImpl: newAPIImpl, dbDriver: dbDriver, pluginDir: pluginDir, webappPluginDir: webappPluginDir, patchReactDOM: patchReactDOM, }, nil } // Performs a full scan of the given path. // // This function will return info for all subdirectories that appear to be plugins (i.e. all // subdirectories containing plugin manifest files, regardless of whether they could actually be // parsed). // // Plugins are found non-recursively and paths beginning with a dot are always ignored. func scanSearchPath(path string) ([]*model.BundleInfo, error) { files, err := os.ReadDir(path) if err != nil { return nil, err } var ret []*model.BundleInfo for _, file := range files { if !file.IsDir() || file.Name()[0] == '.' { continue } info := model.BundleInfoForPath(filepath.Join(path, file.Name())) if info.Manifest != nil { ret = append(ret, info) } } return ret, nil } var pluginIDBlocklist = map[string]bool{ "playbooks": true, "com.mattermost.plugin-incident-response": true, "com.mattermost.plugin-incident-management": true, "focalboard": true, } func PluginIDIsBlocked(id string) bool { _, ok := pluginIDBlocklist[id] return ok } // Returns a list of all plugins within the environment. func (env *Environment) Available() ([]*model.BundleInfo, error) { rawList, err := scanSearchPath(env.pluginDir) if err != nil { return nil, err } // Filter any plugins that match the blocklist filteredList := make([]*model.BundleInfo, 0, len(rawList)) for _, bundleInfo := range rawList { if PluginIDIsBlocked(bundleInfo.Manifest.Id) { env.logger.Debug("Plugin ignored by blocklist", mlog.String("plugin_id", bundleInfo.Manifest.Id)) } else { filteredList = append(filteredList, bundleInfo) } } return filteredList, nil } // Returns a list of prepackaged plugins available in the local prepackaged_plugins folder. // The list content is immutable and should not be modified. func (env *Environment) PrepackagedPlugins() []*PrepackagedPlugin { env.prepackagedPluginsLock.RLock() defer env.prepackagedPluginsLock.RUnlock() return env.prepackagedPlugins } // Returns a list of all currently active plugins within the environment. // The returned list should not be modified. func (env *Environment) Active() []*model.BundleInfo { activePlugins := []*model.BundleInfo{} env.registeredPlugins.Range(func(key, value any) bool { plugin := value.(registeredPlugin) if env.IsActive(plugin.BundleInfo.Manifest.Id) { activePlugins = append(activePlugins, plugin.BundleInfo) } return true }) return activePlugins } // IsActive returns true if the plugin with the given id is active. func (env *Environment) IsActive(id string) bool { return env.GetPluginState(id) == model.PluginStateRunning } func (env *Environment) SetPluginError(id string, err string) { if rp, ok := env.registeredPlugins.Load(id); ok { p := rp.(registeredPlugin) p.Error = err env.registeredPlugins.Store(id, p) } } func (env *Environment) getPluginError(id string) string { if rp, ok := env.registeredPlugins.Load(id); ok { return rp.(registeredPlugin).Error } return "" } // GetPluginState returns the current state of a plugin (disabled, running, or error) func (env *Environment) GetPluginState(id string) int { rp, ok := env.registeredPlugins.Load(id) if !ok { return model.PluginStateNotRunning } return rp.(registeredPlugin).State } // setPluginState sets the current state of a plugin (disabled, running, or error) func (env *Environment) setPluginState(id string, state int) { if rp, ok := env.registeredPlugins.Load(id); ok { p := rp.(registeredPlugin) p.State = state env.registeredPlugins.Store(id, p) } } // PublicFilesPath returns a path and true if the plugin with the given id is active. // It returns an empty string and false if the path is not set or invalid func (env *Environment) PublicFilesPath(id string) (string, error) { if !env.IsActive(id) { return "", fmt.Errorf("plugin not found: %v", id) } return filepath.Join(env.pluginDir, id, "public"), nil } // Statuses returns a list of plugin statuses representing the state of every plugin func (env *Environment) Statuses() (model.PluginStatuses, error) { plugins, err := env.Available() if err != nil { return nil, errors.Wrap(err, "unable to get plugin statuses") } pluginStatuses := make(model.PluginStatuses, 0, len(plugins)) for _, plugin := range plugins { // For now we don't handle bad manifests, we should if plugin.Manifest == nil { continue } pluginState := env.GetPluginState(plugin.Manifest.Id) status := &model.PluginStatus{ PluginId: plugin.Manifest.Id, PluginPath: filepath.Dir(plugin.ManifestPath), State: pluginState, Error: env.getPluginError(plugin.Manifest.Id), Name: plugin.Manifest.Name, Description: plugin.Manifest.Description, Version: plugin.Manifest.Version, } pluginStatuses = append(pluginStatuses, status) } return pluginStatuses, nil } // GetManifest returns a manifest for a given pluginId. // Returns ErrNotFound if plugin is not found. func (env *Environment) GetManifest(pluginId string) (*model.Manifest, error) { plugins, err := env.Available() if err != nil { return nil, errors.Wrap(err, "unable to get plugin statuses") } for _, plugin := range plugins { if plugin.Manifest != nil && plugin.Manifest.Id == pluginId { return plugin.Manifest, nil } } return nil, ErrNotFound } func (env *Environment) Activate(id string) (manifest *model.Manifest, activated bool, reterr error) { defer func() { if reterr != nil { env.SetPluginError(id, reterr.Error()) } else { env.SetPluginError(id, "") } }() // Check if we are already active if env.IsActive(id) { return nil, false, nil } plugins, err := env.Available() if err != nil { return nil, false, err } var pluginInfo *model.BundleInfo for _, p := range plugins { if p.Manifest != nil && p.Manifest.Id == id { if pluginInfo != nil { return nil, false, fmt.Errorf("multiple plugins found: %v", id) } pluginInfo = p } } if pluginInfo == nil { return nil, false, fmt.Errorf("plugin not found: %v", id) } rp := newRegisteredPlugin(pluginInfo) env.registeredPlugins.Store(id, rp) defer func() { if reterr == nil { env.setPluginState(id, model.PluginStateRunning) } else { env.setPluginState(id, model.PluginStateFailedToStart) } }() if pluginInfo.Manifest.MinServerVersion != "" { fulfilled, err := pluginInfo.Manifest.MeetMinServerVersion(model.CurrentVersion) if err != nil { return nil, false, fmt.Errorf("%v: %v", err.Error(), id) } if !fulfilled { return nil, false, fmt.Errorf("plugin requires Mattermost %v: %v", pluginInfo.Manifest.MinServerVersion, id) } } componentActivated := false if pluginInfo.Manifest.HasWebapp() { updatedManifest, err := env.UnpackWebappBundle(id) if err != nil { return nil, false, errors.Wrapf(err, "unable to generate webapp bundle: %v", id) } pluginInfo.Manifest.Webapp.BundleHash = updatedManifest.Webapp.BundleHash componentActivated = true } if pluginInfo.Manifest.HasServer() { sup, err := newSupervisor(pluginInfo, env.newAPIImpl(pluginInfo.Manifest), env.dbDriver, env.logger, env.metrics) if err != nil { return nil, false, errors.Wrapf(err, "unable to start plugin: %v", id) } // We pre-emptively set the state to running to prevent re-entrancy issues. // The plugin's OnActivate hook can in-turn call UpdateConfiguration // which again calls this method. This method is guarded against multiple calls, // but fails if it is called recursively. // // Therefore, setting the state to running prevents this from happening, // and in case there is an error, the defer clause will set the proper state anyways. env.setPluginState(id, model.PluginStateRunning) if err := sup.Hooks().OnActivate(); err != nil { sup.Shutdown() return nil, false, err } rp.supervisor = sup env.registeredPlugins.Store(id, rp) componentActivated = true } if !componentActivated { return nil, false, fmt.Errorf("unable to start plugin: must at least have a web app or server component") } mlog.Debug("Plugin activated", mlog.String("plugin_id", pluginInfo.Manifest.Id), mlog.String("version", pluginInfo.Manifest.Version)) return pluginInfo.Manifest, true, nil } func (env *Environment) RemovePlugin(id string) { if _, ok := env.registeredPlugins.Load(id); ok { env.registeredPlugins.Delete(id) } } // Deactivates the plugin with the given id. func (env *Environment) Deactivate(id string) bool { p, ok := env.registeredPlugins.Load(id) if !ok { return false } isActive := env.IsActive(id) env.setPluginState(id, model.PluginStateNotRunning) if !isActive { return false } rp := p.(registeredPlugin) if rp.supervisor != nil { if err := rp.supervisor.Hooks().OnDeactivate(); err != nil { env.logger.Error("Plugin OnDeactivate() error", mlog.String("plugin_id", rp.BundleInfo.Manifest.Id), mlog.Err(err)) } rp.supervisor.Shutdown() } return true } // RestartPlugin deactivates, then activates the plugin with the given id. func (env *Environment) RestartPlugin(id string) error { env.Deactivate(id) _, _, err := env.Activate(id) return err } // Shutdown deactivates all plugins and gracefully shuts down the environment. func (env *Environment) Shutdown() { env.TogglePluginHealthCheckJob(false) var wg sync.WaitGroup env.registeredPlugins.Range(func(key, value any) bool { rp := value.(registeredPlugin) if rp.supervisor == nil || !env.IsActive(rp.BundleInfo.Manifest.Id) { return true } wg.Add(1) done := make(chan bool) go func() { defer close(done) if err := rp.supervisor.Hooks().OnDeactivate(); err != nil { env.logger.Error("Plugin OnDeactivate() error", mlog.String("plugin_id", rp.BundleInfo.Manifest.Id), mlog.Err(err)) } }() go func() { defer wg.Done() select { case <-time.After(10 * time.Second): env.logger.Warn("Plugin OnDeactivate() failed to complete in 10 seconds", mlog.String("plugin_id", rp.BundleInfo.Manifest.Id)) case <-done: } rp.supervisor.Shutdown() }() return true }) wg.Wait() env.registeredPlugins.Range(func(key, value any) bool { env.registeredPlugins.Delete(key) return true }) } // UnpackWebappBundle unpacks webapp bundle for a given plugin id on disk. func (env *Environment) UnpackWebappBundle(id string) (*model.Manifest, error) { plugins, err := env.Available() if err != nil { return nil, errors.New("Unable to get available plugins") } var manifest *model.Manifest for _, p := range plugins { if p.Manifest != nil && p.Manifest.Id == id { if manifest != nil { return nil, fmt.Errorf("multiple plugins found: %v", id) } manifest = p.Manifest } } if manifest == nil { return nil, fmt.Errorf("plugin not found: %v", id) } bundlePath := filepath.Clean(manifest.Webapp.BundlePath) if bundlePath == "" || bundlePath[0] == '.' { return nil, fmt.Errorf("invalid webapp bundle path") } bundlePath = filepath.Join(env.pluginDir, id, bundlePath) destinationPath := filepath.Join(env.webappPluginDir, id) if err = os.RemoveAll(destinationPath); err != nil { return nil, errors.Wrapf(err, "unable to remove old webapp bundle directory: %v", destinationPath) } if err = utils.CopyDir(filepath.Dir(bundlePath), destinationPath); err != nil { return nil, errors.Wrapf(err, "unable to copy webapp bundle directory: %v", id) } sourceBundleFilepath := filepath.Join(destinationPath, filepath.Base(bundlePath)) sourceBundleFileContents, err := os.ReadFile(sourceBundleFilepath) if err != nil { return nil, errors.Wrapf(err, "unable to read webapp bundle: %v", id) } if env.patchReactDOM { newContents, changed := patchReactDOM(sourceBundleFileContents) if changed { sourceBundleFileContents = newContents err = os.WriteFile(sourceBundleFilepath, sourceBundleFileContents, 0644) if err != nil { return nil, errors.Wrapf(err, "unable to overwrite webapp bundle: %v", id) } } } hash := fnv.New64a() if _, err = hash.Write(sourceBundleFileContents); err != nil { return nil, errors.Wrapf(err, "unable to generate hash for webapp bundle: %v", id) } manifest.Webapp.BundleHash = hash.Sum([]byte{}) if err = os.Rename( sourceBundleFilepath, filepath.Join(destinationPath, fmt.Sprintf("%s_%x_bundle.js", id, manifest.Webapp.BundleHash)), ); err != nil { return nil, errors.Wrapf(err, "unable to rename webapp bundle: %v", id) } return manifest, nil } func patchReactDOM(initialBytes []byte) ([]byte, bool) { if !bytes.Contains(initialBytes, []byte("react-dom.production.min.js")) { return initialBytes, false } initial := string(initialBytes) nameIndex := strings.Index(initial, "react-dom.production.min.js") beginning := strings.LastIndex(initial[:nameIndex], "{") var end int argDefBeginning := strings.LastIndex(initial[:beginning], "function") + 9 argDefEnd := strings.LastIndex(initial[:beginning], ")") - 1 argsNames := strings.Split(initial[argDefBeginning:argDefEnd], ",") if len(argsNames) != 3 { return initialBytes, false } exportsArgName := strings.TrimSpace(argsNames[1]) numOpenBraces := 0 for i, c := range initial[beginning:] { if end != 0 { break } switch c { case '}': numOpenBraces-- if numOpenBraces == 0 { end = beginning + i } case '{': numOpenBraces++ } } beforePatch := initial[:end] afterPatch := initial[end:] patch := fmt.Sprintf("; Object.assign(%s, window.ReactDOM)", exportsArgName) result := fmt.Sprintf("%s%s%s", beforePatch, patch, afterPatch) return []byte(result), true } // HooksForPlugin returns the hooks API for the plugin with the given id. // // Consider using RunMultiPluginHook instead. func (env *Environment) HooksForPlugin(id string) (Hooks, error) { if p, ok := env.registeredPlugins.Load(id); ok { rp := p.(registeredPlugin) if rp.supervisor != nil && env.IsActive(id) { return rp.supervisor.Hooks(), nil } } return nil, fmt.Errorf("plugin not found: %v", id) } // RunMultiPluginHook invokes hookRunnerFunc for each active plugin that implements the given hookId. // // If hookRunnerFunc returns false, iteration will not continue. The iteration order among active // plugins is not specified. func (env *Environment) RunMultiPluginHook(hookRunnerFunc func(hooks Hooks) bool, hookId int) { startTime := time.Now() env.registeredPlugins.Range(func(key, value any) bool { rp := value.(registeredPlugin) if rp.supervisor == nil || !rp.supervisor.Implements(hookId) || !env.IsActive(rp.BundleInfo.Manifest.Id) { return true } hookStartTime := time.Now() result := hookRunnerFunc(rp.supervisor.Hooks()) if env.metrics != nil { elapsedTime := float64(time.Since(hookStartTime)) / float64(time.Second) env.metrics.ObservePluginMultiHookIterationDuration(rp.BundleInfo.Manifest.Id, elapsedTime) } return result }) if env.metrics != nil { elapsedTime := float64(time.Since(startTime)) / float64(time.Second) env.metrics.ObservePluginMultiHookDuration(elapsedTime) } } // PerformHealthCheck uses the active plugin's supervisor to verify if the plugin has crashed. func (env *Environment) PerformHealthCheck(id string) error { p, ok := env.registeredPlugins.Load(id) if !ok { return nil } rp := p.(registeredPlugin) sup := rp.supervisor if sup == nil { return nil } return sup.PerformHealthCheck() } // SetPrepackagedPlugins saves prepackaged plugins in the environment. func (env *Environment) SetPrepackagedPlugins(plugins []*PrepackagedPlugin) { env.prepackagedPluginsLock.Lock() env.prepackagedPlugins = plugins env.prepackagedPluginsLock.Unlock() } func newRegisteredPlugin(bundle *model.BundleInfo) registeredPlugin { state := model.PluginStateNotRunning return registeredPlugin{State: state, BundleInfo: bundle} } // TogglePluginHealthCheckJob starts a new job if one is not running and is set to enabled, or kills an existing one if set to disabled. func (env *Environment) TogglePluginHealthCheckJob(enable bool) { // Config is set to enable. No job exists, start a new job. if enable && env.pluginHealthCheckJob == nil { mlog.Debug("Enabling plugin health check job", mlog.Duration("interval_s", HealthCheckInterval)) job := newPluginHealthCheckJob(env) env.pluginHealthCheckJob = job go job.run() } // Config is set to disable. Job exists, kill existing job. if !enable && env.pluginHealthCheckJob != nil { mlog.Debug("Disabling plugin health check job") env.pluginHealthCheckJob.Cancel() env.pluginHealthCheckJob = nil } } // GetPluginHealthCheckJob returns the configured PluginHealthCheckJob, if any. func (env *Environment) GetPluginHealthCheckJob() *PluginHealthCheckJob { return env.pluginHealthCheckJob }