package hooks import ( "bytes" oContext "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "git.pcux.cn/xuans/Smail/config" "git.pcux.cn/xuans/Smail/dto/parsemail" "git.pcux.cn/xuans/Smail/hooks/framework" "git.pcux.cn/xuans/Smail/models" pluginservice "git.pcux.cn/xuans/Smail/services/plugin" "git.pcux.cn/xuans/Smail/utils/atomicfile" "git.pcux.cn/xuans/Smail/utils/context" log "github.com/sirupsen/logrus" "io" "net" "net/http" "os" "path/filepath" "runtime" "strings" "sync" "time" ) // HookList var HookList map[string]framework.EmailHook type HookSender struct { httpc http.Client name string executable string socket string authToken string } var pluginConfigService *pluginservice.ConfigService func SetPluginConfigService(service *pluginservice.ConfigService) { pluginConfigService = service } func PluginConfigService() *pluginservice.ConfigService { return pluginConfigService } const ( maxPluginRPCBytes int64 = 8 << 20 maxPluginSettingsPageResponseBytes = 1 << 20 maxPluginSettingsSaveResponseBytes = 4 << 10 ) var errPluginRPCResponseTooLarge = errors.New("plugin response exceeds the allowed size") func (h *HookSender) call(endpoint string, payload any) ([]byte, error) { return h.callWithResponseLimit(endpoint, payload, maxPluginRPCBytes) } func (h *HookSender) callWithResponseLimit(endpoint string, payload any, maxResponseBytes int64) ([]byte, error) { if maxResponseBytes <= 0 || maxResponseBytes > maxPluginRPCBytes { return nil, errors.New("plugin response limit is invalid") } body, err := json.Marshal(payload) if err != nil { return nil, err } if int64(len(body)) > maxPluginRPCBytes { return nil, errors.New("plugin request exceeds the allowed size") } req, err := http.NewRequest(http.MethodPost, "http://unix/"+endpoint, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") if h.authToken != "" { req.Header.Set("X-Smail-Plugin-Token", h.authToken) } ret, err := h.httpc.Do(req) if err != nil { return nil, err } defer ret.Body.Close() if ret.StatusCode < http.StatusOK || ret.StatusCode >= http.StatusMultipleChoices { return nil, fmt.Errorf("plugin returned status %d", ret.StatusCode) } if ret.ContentLength > maxResponseBytes { return nil, errPluginRPCResponseTooLarge } responseBody, err := io.ReadAll(io.LimitReader(ret.Body, maxResponseBytes+1)) if err != nil { return nil, err } if int64(len(responseBody)) > maxResponseBytes { return nil, errPluginRPCResponseTooLarge } return responseBody, nil } func (h *HookSender) callHook(ctx *context.Context, endpoint string, dto framework.HookDTO) (framework.HookDTO, bool) { body, err := h.call(endpoint, dto) if err != nil { log.WithContext(ctx).Errorf("[%s] Plugin call failed: %v", h.name, err) return dto, false } if len(body) == 0 { return dto, true } if err = json.Unmarshal(body, &dto); err != nil { log.WithContext(ctx).Errorf("[%s] Plugin returned invalid data", h.name) return dto, false } return dto, true } func (h *HookSender) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) { if h.name == "telegram_push" { if pluginConfigService == nil { return } allowed := make([]*models.UserEmail, 0, len(ue)) for _, recipient := range ue { if recipient == nil || recipient.UserID <= 0 { continue } status, err := pluginConfigService.GetOwnTelegramSettings(recipient.UserID) if err != nil || !pluginConfigService.TelegramCanNotify(status.Settings) { continue } if h.ConfigureUserSettings(recipient.UserID, pluginservice.TelegramDeliverySettings(status.Settings)) != nil { continue } allowed = append(allowed, recipient) } if len(allowed) == 0 { return } ue = allowed } if h.name == "wechat_push" { if pluginConfigService == nil { return } allowed := make([]*models.UserEmail, 0, len(ue)) for _, recipient := range ue { if recipient == nil || recipient.UserID <= 0 { continue } // 发信前重新读取,停用、解绑或禁用账户后不能继续使用插件缓存中的旧接收人。 status, err := pluginConfigService.GetOwnWeChatSettings(recipient.UserID) if err != nil || !status.Settings.Enabled || status.Settings.OpenID == "" || status.NeedsRebind { continue } payload, err := json.Marshal(status.Settings) if err != nil || h.ConfigureUserSettings(recipient.UserID, payload) != nil { continue } allowed = append(allowed, recipient) } if len(allowed) == 0 { return } ue = allowed } _, _ = h.callHook(ctx, "ReceiveSaveAfter", framework.HookDTO{Ctx: ctx, Email: email, UserEmail: ue}) } func (h *HookSender) SendBefore(ctx *context.Context, email *parsemail.Email) { dto, ok := h.callHook(ctx, "SendBefore", framework.HookDTO{Ctx: ctx, Email: email}) if ok && dto.Email != nil && email != nil { *email = *dto.Email } } func (h *HookSender) SendAfter(ctx *context.Context, email *parsemail.Email, errMap map[string]error) { _, _ = h.callHook(ctx, "SendAfter", framework.HookDTO{Ctx: ctx, Email: email, ErrMap: errMap}) } func (h *HookSender) ReceiveParseBefore(ctx *context.Context, email *[]byte) { dto, ok := h.callHook(ctx, "ReceiveParseBefore", framework.HookDTO{Ctx: ctx, EmailByte: email}) if ok && dto.EmailByte != nil && email != nil { *email = append((*email)[:0], (*dto.EmailByte)...) } } func (h *HookSender) ReceiveParseAfter(ctx *context.Context, email *parsemail.Email) { dto, ok := h.callHook(ctx, "ReceiveParseAfter", framework.HookDTO{Ctx: ctx, Email: email}) if ok && dto.Email != nil && email != nil { *email = *dto.Email } } func (h *HookSender) Capabilities() (framework.Capabilities, error) { body, err := h.call("Capabilities", struct{}{}) if err != nil { return framework.Capabilities{}, err } var capabilities framework.Capabilities if err := json.Unmarshal(body, &capabilities); err != nil { return framework.Capabilities{}, err } return capabilities, nil } func (h *HookSender) Health() error { body, err := h.call("Health", struct{}{}) if err != nil { return err } var response struct { Status string `json:"status"` } if err := json.Unmarshal(body, &response); err != nil || response.Status != "ok" { return errors.New("plugin health check returned an invalid status") } return nil } func (h *HookSender) ValidateConfig(payload framework.PluginRuntimeConfig) error { _, err := h.call("ValidateConfig", payload) return err } func (h *HookSender) Configure(payload framework.PluginRuntimeConfig) error { _, err := h.call("Configure", payload) return err } func (h *HookSender) ConfigureUserSettings(userID int, settings json.RawMessage) error { _, err := h.call("UserSettings", framework.UserSettingsRequest{ UserID: userID, Settings: append(json.RawMessage(nil), settings...), }) return err } func (h *HookSender) GetBotInfo() (framework.BotInfo, error) { body, err := h.call("BotInfo", struct{}{}) if err != nil { return framework.BotInfo{}, err } var info framework.BotInfo if err := json.Unmarshal(body, &info); err != nil { return framework.BotInfo{}, err } return info, nil } func (h *HookSender) TestMessage(settings json.RawMessage) error { _, err := h.call("TestMessage", append(json.RawMessage(nil), settings...)) return err } func (h *HookSender) GetName(ctx *context.Context) string { body, err := h.call("GetName", framework.HookDTO{Ctx: ctx}) if err != nil { log.WithContext(ctx).Errorf("[%s] Plugin call failed: %v", h.name, err) return "" } return string(body) } func (h *HookSender) SettingsHtml(ctx *context.Context, url string, requestData string) string { responseLimit := int64(maxPluginSettingsPageResponseBytes) if strings.HasSuffix(strings.TrimSuffix(url, "/"), "/save") { responseLimit = int64(maxPluginSettingsSaveResponseBytes) } body, err := h.callWithResponseLimit("SettingsHtml", framework.SettingsHtmlRequest{Ctx: ctx, URL: url, RequestData: requestData}, responseLimit) if err != nil { log.WithContext(ctx).Errorf("[%s] Plugin call failed: %v", h.name, err) return "" } return string(body) } func NewHookSender(socketPath string, name string, serverVersion string, authToken ...string) *HookSender { httpc := http.Client{ Timeout: time.Second * 35, Transport: &http.Transport{ DialContext: func(ctx oContext.Context, network, addr string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) }, }, } token := "" if len(authToken) > 0 { token = authToken[0] } return &HookSender{ httpc: httpc, socket: socketPath, name: name, authToken: token, } } var ( processList []*os.Process processListMu sync.Mutex ) // Init 注册hook对象 func Init(serverVersion string) { Stop() ResetHooks() cfg := config.CurrentConfig() if cfg == nil { log.Warn("Plugin configuration is unavailable") return } definitions, err := pluginDefinitionsForStartup(cfg) if err != nil { log.Warn("Plugin loading is disabled because the configured plugin target is invalid") return } if len(definitions) == 0 { log.Info("Plugin loading is disabled because the allow list is empty") return } root, err := pluginRoot() if err != nil { log.Error("Plugin root resolve failed") return } if !securePluginDirectory(root) || !securePluginDirectoryOwner(root) { log.Error("Plugin directory permissions are unsafe") return } pluginNo := 1 for _, definition := range definitions { logicalID := definition.LogicalID executableName, executableErr := pluginservice.ExecutableName(logicalID, runtime.GOOS) if executableErr != nil { log.Warnf("[%s] Plugin executable mapping is unavailable", logicalID) continue } pluginPath := filepath.Join(root, executableName) info, statErr := os.Lstat(pluginPath) if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || !isPluginExecutable(executableName, info.Mode()) || !securePluginFile(info.Mode()) || !securePluginFileOwner(info) { log.Warnf("[%s] Plugin file was rejected", logicalID) continue } socketName, socketErr := newPluginSocketName(pluginNo) if socketErr != nil { log.Warnf("[%s] Plugin socket name generation failed", logicalID) continue } authToken, tokenErr := newPluginAuthToken() if tokenErr != nil { log.Warnf("[%s] Plugin authentication material generation failed", logicalID) continue } env := pluginEnvironment(root, logicalID) env = appendPluginEnv(env, "SMAIL_PLUGIN_RPC_TOKEN", authToken) env = appendPluginEnv(env, "SMAIL_PLUGIN_ID", logicalID) launchSpec, launchErr := newPluginLaunchSpec(pluginPath, root, executableName, socketName, env) if launchErr != nil { if !config.DevelopmentMode { log.Warnf("[%s] Plugin rejected because secure process isolation is unavailable: %v", logicalID, launchErr) continue } log.Warnf("[%s] Secure process isolation is unavailable in development mode: %v", logicalID, launchErr) launchSpec = compatibilityPluginLaunchSpec(pluginPath, root, executableName, socketName, env) } socketPath := launchSpec.socketPath if !isAbstractPluginSocket(socketPath) { if removeErr := os.Remove(socketPath); removeErr != nil && !os.IsNotExist(removeErr) { log.Warnf("[%s] Plugin socket cleanup failed", logicalID) launchSpec.cleanup() continue } } log.Infof("[%s] Plugin load", logicalID) process, startErr := os.StartProcess(launchSpec.path, launchSpec.args, launchSpec.procAttr) if startErr != nil { log.Warnf("[%s] Plugin start failed", logicalID) launchSpec.cleanup() continue } launchSpec.closeParentFiles() processListMu.Lock() processList = append(processList, process) processListMu.Unlock() pluginNo++ go waitPlugin(process, logicalID, socketPath, launchSpec) if limitErr := applyPluginResourceLimits(process); limitErr != nil { log.WithError(limitErr).Warnf("[%s] 插件资源限制应用失败,插件未启用", logicalID) if killErr := process.Kill(); killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { log.WithError(killErr).Errorf("[%s] 插件终止失败,请检查服务权限", logicalID) } continue } if !waitPluginSocket(socketPath) { log.Warnf("[%s] Plugin socket was not created", logicalID) _ = process.Kill() continue } if runtime.GOOS != "windows" && !isAbstractPluginSocket(socketPath) { if chmodErr := os.Chmod(socketPath, 0600); chmodErr != nil { log.Warnf("[%s] Plugin socket permissions could not be tightened", logicalID) _ = process.Kill() continue } } hook := NewHookSender(socketPath, logicalID, serverVersion, authToken) hook.executable = executableName capabilities, capabilityErr := hook.Capabilities() if capabilityErr != nil || !pluginCapabilitiesMatch(definition, capabilities) { log.Warnf("[%s] Plugin capability handshake failed", logicalID) _ = process.Kill() continue } if instance, generation, ok := pluginInstanceForStartup(logicalID); ok { payload, payloadErr := pluginRuntimeConfig(logicalID, generation, instance) if payloadErr != nil { log.WithError(payloadErr).Warn("plugin runtime configuration build error") log.Warnf("[%s] 插件运行配置构建失败", logicalID) _ = process.Kill() continue } if validateErr := hook.ValidateConfig(payload); validateErr != nil { log.WithError(validateErr).Warn("plugin runtime configuration validation error") log.Warnf("[%s] 插件运行配置校验失败", logicalID) _ = process.Kill() continue } if configureErr := hook.Configure(payload); configureErr != nil { log.WithError(configureErr).Warn("plugin runtime configuration apply error") log.Warnf("[%s] 插件运行配置应用失败", logicalID) _ = process.Kill() continue } } if syncErr := syncTelegramUserSettings(logicalID, hook); syncErr != nil { log.WithError(syncErr).Warnf("[%s] 用户设置恢复失败,插件未启用", logicalID) _ = process.Kill() continue } if syncErr := syncWeChatUserSettings(logicalID, hook); syncErr != nil { log.Warnf("[%s] 微信个人设置恢复失败,插件未启用", logicalID) _ = process.Kill() continue } if healthErr := hook.Health(); healthErr != nil { log.Warnf("[%s] Plugin health check failed", logicalID) _ = process.Kill() continue } hookName := strings.TrimSpace(hook.GetName(&context.Context{})) if hookName == "" || !RegisterHook(hookName, hook) { log.Warnf("[%s] Plugin reported an invalid hook name", logicalID) _ = process.Kill() continue } log.Infof("[%s] Plugin load success", hookName) } } func SyncWeChatUserSettings(userID int, settings pluginservice.WeChatUserSettings) error { var target framework.EmailHook for _, name := range []string{"wechat_push", "WeChatPushHook", "WechatPush"} { if hook, found := GetHook(name); found { target = hook break } } configurer, ok := target.(framework.UserSettingsConfigurer) if !ok || userID <= 0 { return errors.New("微信插件不可用") } payload, err := json.Marshal(settings) if err != nil { return err } return configurer.ConfigureUserSettings(userID, payload) } func syncWeChatUserSettings(logicalID string, hook *HookSender) error { if logicalID != "wechat_push" { return nil } if hook == nil || pluginConfigService == nil { return pluginservice.ErrPluginStoreUnavailable } entries, err := pluginConfigService.ListWeChatUserSettings() if err != nil { return err } for _, entry := range entries { payload, err := json.Marshal(entry.Settings) if err != nil { return err } if err := hook.ConfigureUserSettings(entry.UserID, payload); err != nil { return err } } return nil } func SyncTelegramUserSettings(userID int, settings pluginservice.TelegramUserSettings) error { if userID <= 0 { return errors.New("invalid user ID") } hook, ok := GetHook("TelegramPush") if !ok { hook, ok = GetHook("telegram_push") } if !ok { return errors.New("telegram plugin is unavailable") } configurer, ok := hook.(framework.UserSettingsConfigurer) if !ok { return errors.New("telegram plugin does not support user settings") } if pluginConfigService == nil || !pluginConfigService.TelegramCanNotify(settings) { settings.Disabled = true } return configurer.ConfigureUserSettings(userID, pluginservice.TelegramDeliverySettings(settings)) } func syncTelegramUserSettings(logicalID string, hook *HookSender) error { if logicalID != "telegram_push" { return nil } if hook == nil || pluginConfigService == nil { return pluginservice.ErrPluginStoreUnavailable } entries, err := pluginConfigService.ListTelegramUserSettings() if err != nil { return fmt.Errorf("读取 Telegram 用户设置失败: %w", err) } // 完整恢复后才能注册插件,避免空设置或部分设置被误判为正常运行。 for _, entry := range entries { if syncErr := syncTelegramUserSetting(hook, entry.UserID, entry.Settings); syncErr != nil { return fmt.Errorf("恢复 Telegram 用户 %d 的设置失败: %w", entry.UserID, syncErr) } } return nil } func syncTelegramUserSetting(hook *HookSender, userID int, settings pluginservice.TelegramUserSettings) error { if hook == nil || userID <= 0 { return errors.New("invalid plugin user settings target") } if pluginConfigService == nil || !pluginConfigService.TelegramCanNotify(settings) { settings.Disabled = true } return hook.ConfigureUserSettings(userID, pluginservice.TelegramDeliverySettings(settings)) } func pluginInstanceForStartup(logicalID string) (config.PluginInstance, uint64, bool) { manager := config.CurrentManager() if manager == nil { return config.PluginInstance{}, 0, false } snapshot := manager.Snapshot() instance, ok := snapshot.Plugins.Plugins[logicalID] return instance, snapshot.Generation, ok } func pluginRuntimeConfig(logicalID string, generation uint64, instance config.PluginInstance) (framework.PluginRuntimeConfig, error) { if err := validatePluginRuntimeBinding(logicalID, instance); err != nil { return framework.PluginRuntimeConfig{}, err } if generation == 0 { return framework.PluginRuntimeConfig{}, errors.New("plugin configuration generation is invalid") } result := framework.PluginRuntimeConfig{ PluginID: logicalID, Generation: generation, Settings: append(json.RawMessage(nil), instance.Settings...), Capabilities: append([]string(nil), instance.Permissions.Capabilities...), Network: make([]framework.PluginNetworkTarget, 0, len(instance.Permissions.Network)), Secrets: make(map[string]string), } for _, target := range instance.Permissions.Network { result.Network = append(result.Network, framework.PluginNetworkTarget{Scheme: target.Scheme, Host: target.Host, Port: target.Port}) } if pluginConfigService == nil { if instance.Secret.Configured() { return framework.PluginRuntimeConfig{}, errors.New("plugin secret service is unavailable") } return result, nil } fieldName := "" switch logicalID { case "wechat_push": fieldName = "appSecret" case "telegram_push": fieldName = "botToken" } if fieldName != "" { secret, err := pluginConfigService.OpenSecretForPlugin(logicalID, fieldName) if err != nil { return framework.PluginRuntimeConfig{}, err } result.Secrets[fieldName] = secret } return result, nil } // validatePluginRuntimeBinding 在启动插件前再次校验配置与插件目录声明一致。 // 该校验不能替代 ConfigManager 的完整校验,只用于阻止错误或过期快照被下发。 func validatePluginRuntimeBinding(logicalID string, instance config.PluginInstance) error { if !instance.Enabled { return errors.New("plugin is disabled") } definition, ok := pluginservice.Lookup(logicalID) if !ok { return pluginservice.ErrUnknownPluginID } if !sameStringSet(instance.Permissions.Capabilities, definition.Capabilities) { return errors.New("plugin capabilities do not match catalog") } if instance.Secret.Ciphertext != "" || instance.Secret.Version != 0 || !instance.Secret.UpdatedAt.IsZero() { return errors.New("legacy plugin secret migration is required") } return nil } func pluginCapabilitiesMatch(definition pluginservice.Definition, capabilities framework.Capabilities) bool { return capabilities.PluginID == definition.LogicalID && capabilities.Protocol == 1 && sameStringSet(capabilities.Capabilities, definition.HandshakeCapabilities) && sameStringSet(capabilities.Settings, definition.SettingsFields) } func sameStringSet(left, right []string) bool { if len(left) != len(right) { return false } values := make(map[string]struct{}, len(left)) for _, value := range left { if _, exists := values[value]; exists { return false } values[value] = struct{}{} } for _, value := range right { if _, exists := values[value]; !exists { return false } } return true } func pluginDefinitionsForStartup(legacy *config.Config) ([]pluginservice.Definition, error) { if manager := config.CurrentManager(); manager != nil { return pluginDefinitionsFromRuntime(manager.Snapshot()) } if legacy == nil { return nil, errors.New("plugin configuration is unavailable") } return pluginservice.EnabledDefinitions(legacy.PluginAllowList, runtime.GOOS) } func pluginDefinitionsFromRuntime(runtimeConfig config.RuntimeConfig) ([]pluginservice.Definition, error) { values := make([]string, 0, len(runtimeConfig.Plugins.Plugins)) for logicalID, instance := range runtimeConfig.Plugins.Plugins { if instance.Enabled { values = append(values, logicalID) } } return pluginservice.EnabledDefinitions(values, runtime.GOOS) } func pluginAllowList(values []string) map[string]bool { allowed := make(map[string]bool, len(values)) for _, value := range values { name := strings.TrimSpace(value) if name == "" || filepath.Base(name) != name || strings.ContainsAny(name, `/\*?[]`) { continue } allowed[name] = true } return allowed } func securePluginDirectory(root string) bool { info, err := os.Lstat(root) if err != nil || !info.IsDir() { return false } return info.Mode()&os.ModeSymlink == 0 && info.Mode()&os.ModeSetuid == 0 && info.Mode()&os.ModeSetgid == 0 && (runtime.GOOS == "windows" || info.Mode().Perm()&0022 == 0) } func securePluginFile(mode os.FileMode) bool { return runtime.GOOS == "windows" || mode&os.ModeSetuid == 0 && mode&os.ModeSetgid == 0 && mode.Perm()&0022 == 0 } func isPluginExecutable(name string, mode os.FileMode) bool { if runtime.GOOS == "windows" { return strings.EqualFold(filepath.Ext(name), ".exe") } return filepath.Ext(name) == "" && mode.Perm()&0100 != 0 } func waitPluginSocket(socketPath string) bool { if isAbstractPluginSocket(socketPath) { for i := 0; i < 50; i++ { conn, err := net.DialTimeout("unix", socketPath, 100*time.Millisecond) if err == nil { _ = conn.Close() return true } time.Sleep(100 * time.Millisecond) } return false } for i := 0; i < 5; i++ { time.Sleep(time.Second) info, err := os.Lstat(socketPath) if err == nil && info.Mode()&os.ModeSocket != 0 && info.Mode()&os.ModeSymlink == 0 { return true } } return false } func waitPlugin(process *os.Process, name string, socketPath string, launchSpec *pluginLaunchSpec) { _, err := process.Wait() removeProcess(process) if err != nil { log.Warnf("[%s] Plugin stopped", name) } RemoveHooksByPlugin(name) if !isAbstractPluginSocket(socketPath) { _ = os.Remove(socketPath) } launchSpec.cleanup() } func newPluginAuthToken() (string, error) { token := make([]byte, 32) if _, err := rand.Read(token); err != nil { return "", err } return hex.EncodeToString(token), nil } func newPluginSocketName(pluginNo int) (string, error) { token := make([]byte, 16) if _, err := rand.Read(token); err != nil { return "", err } return fmt.Sprintf("smail-%d-%s", pluginNo, hex.EncodeToString(token)), nil } func minimalPluginEnv(root string) []string { allowed := map[string]bool{ "PATH": true, "SYSTEMROOT": true, "WINDIR": true, "TEMP": true, "TMP": true, "LANG": true, "LC_ALL": true, } ret := []string{"SMAIL_PLUGIN=1", "SMAIL_ROOT=" + root} for _, item := range os.Environ() { parts := strings.SplitN(item, "=", 2) if len(parts) == 2 && allowed[strings.ToUpper(parts[0])] { ret = append(ret, item) } } return ret } func pluginEnvironment(root, name string) []string { ret := minimalPluginEnv(root) if config.CurrentManager() != nil { return ret } cfg := config.CurrentConfig() if cfg == nil { return ret } pluginName := strings.ToLower(strings.TrimSpace(name)) if pluginName == "wechat_push" { ret = appendPluginEnv(ret, "SMAIL_WECHAT_APP_ID", cfg.WeChatPushAppId) ret = appendPluginEnv(ret, "SMAIL_WECHAT_TEMPLATE_ID", cfg.WeChatPushTemplateId) ret = appendPluginEnv(ret, "SMAIL_WECHAT_USER_ID", cfg.WeChatPushUserId) ret = appendPluginEnv(ret, "SMAIL_WEB_DOMAIN", cfg.WebDomain) ret = appendPluginEnv(ret, "SMAIL_HTTPS_ENABLED", fmt.Sprintf("%d", cfg.HttpsEnabled)) } return ret } func appendPluginEnv(env []string, name, value string) []string { if value == "" || strings.ContainsAny(value, "\x00\r\n") { return env } return append(env, name+"="+value) } func PersistPluginConfig(pluginName, payload string) error { if len(payload) == 0 || int64(len(payload)) > maxPluginConfigBytes || !json.Valid([]byte(payload)) { return errors.New("plugin configuration is invalid") } hook, ok := GetHook(pluginName) if !ok { return errors.New("plugin is unavailable") } sender, ok := hook.(*HookSender) if !ok { return errors.New("plugin configuration persistence is unavailable") } if bundledPlugins != nil { return errors.New("内置插件配置必须通过 plugins.json 配置服务保存") } root, err := pluginRoot() if err != nil || !securePluginDirectory(root) || !securePluginDirectoryOwner(root) { return errors.New("plugin directory is unsafe") } executableName := sender.executable if executableName == "" { executableName = sender.name } configPath := filepath.Join(root, pluginConfigFileName(executableName)) info, err := os.Lstat(configPath) if err == nil && info.Mode()&os.ModeSymlink != 0 { return errors.New("plugin configuration path is unsafe") } if err != nil && !os.IsNotExist(err) { return err } return atomicfile.Write(configPath, []byte(payload), 0600) } func removeProcess(target *os.Process) { processListMu.Lock() defer processListMu.Unlock() filtered := processList[:0] for _, process := range processList { if process != target { filtered = append(filtered, process) } } processList = filtered } func Stop() { log.Info("Plugin Stop") processListMu.Lock() processes := append([]*os.Process(nil), processList...) processList = nil processListMu.Unlock() for _, process := range processes { _ = process.Kill() } }