diff --git a/agent/app/api/v2/alert.go b/agent/app/api/v2/alert.go index 90c4480be..485f76b3f 100644 --- a/agent/app/api/v2/alert.go +++ b/agent/app/api/v2/alert.go @@ -2,11 +2,14 @@ package v2 import ( "errors" + "net/http" "net/url" "strings" "github.com/1Panel-dev/1Panel/agent/app/api/v2/helper" "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/repo" + "github.com/1Panel-dev/1Panel/agent/constant" "github.com/gin-gonic/gin" ) @@ -294,6 +297,34 @@ func (b *BaseApi) UpdateAlertConfig(c *gin.Context) { return } if err := alertService.UpdateAlertConfig(req, loadAuditUser(c)); err != nil { + switch { + case errors.Is(err, repo.ErrAlertConfigRevisionConflict): + helper.ErrorWithBusinessCode(c, http.StatusConflict, "ALERT_CONFIG_REVISION_CONFLICT", "ErrInvalidParams", err) + case errors.Is(err, repo.ErrAlertConfigRevisionRequired): + helper.ErrorWithBusinessCode(c, http.StatusConflict, "ALERT_CONFIG_REVISION_REQUIRED", "ErrInvalidParams", err) + default: + helper.InternalServer(c, err) + } + return + } + helper.Success(c) +} + +// @Tags Alert +// @Summary Update alert config status +// @Accept json +// @Param request body dto.AlertConfigStatusUpdate true "request" +// @Success 200 +// @Security ApiKeyAuth +// @Security Timestamp +// @Router /alert/config/status [post] +// @x-panel-log {"bodyKeys":["id","status"],"paramKeys":[],"BeforeFunctions":[],"formatZH":"更新告警配置状态 [id][status]","formatEN":"update alert config status [id][status]"} +func (b *BaseApi) UpdateAlertConfigStatus(c *gin.Context) { + var req dto.AlertConfigStatusUpdate + if err := helper.CheckBindAndValidate(&req, c); err != nil { + return + } + if err := alertService.UpdateAlertConfigStatus(req, loadAuditUser(c)); err != nil { helper.InternalServer(c, err) return } @@ -346,6 +377,15 @@ func (b *BaseApi) TestAlertConfig(c *gin.Context) { if err := helper.CheckBindAndValidate(&req, c); err != nil { return } + if req.Type == constant.Custom { + result, err := alertService.TestCustomAlertConfig(req) + if err != nil { + helper.InternalServer(c, err) + return + } + helper.SuccessWithData(c, result) + return + } flag, err := alertService.TestAlertConfig(req) if err != nil { helper.InternalServer(c, err) diff --git a/agent/app/dto/alert.go b/agent/app/dto/alert.go index 30ea2ad12..84203c674 100644 --- a/agent/app/dto/alert.go +++ b/agent/app/dto/alert.go @@ -151,16 +151,25 @@ type AlertLog struct { } type AlertDetail struct { - LicenseId string `json:"licenseId"` - Type string `json:"type"` - SubType string `json:"subType"` - Title string `json:"title"` - Method string `json:"method"` - LicenseCode string `json:"licenseCode"` - DeviceId string `json:"deviceId"` - Project string `json:"project"` - Params []Param `json:"params"` - Phone string `json:"phone"` + LicenseId string `json:"licenseId"` + Type string `json:"type"` + SubType string `json:"subType"` + Title string `json:"title"` + Method string `json:"method"` + LicenseCode string `json:"licenseCode"` + DeviceId string `json:"deviceId"` + Project string `json:"project"` + Params []Param `json:"params"` + Phone string `json:"phone"` + Task *AlertTaskMetadata `json:"task,omitempty"` +} + +type AlertTaskMetadata struct { + AlertID uint `json:"alertId"` + Type string `json:"type"` + Quota string `json:"quota"` + QuotaType string `json:"quotaType"` + Method string `json:"method"` } type AlertRule struct { @@ -295,15 +304,19 @@ type OfflineQueryRequest struct { } type AlertConfigUpdate struct { - ID uint `json:"id"` - Type string `json:"type"` - Title string `json:"title"` - Status string `json:"status"` - Config string `json:"config"` - DisplayName string `json:"displayName"` + ID uint `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Status string `json:"status"` + Config string `json:"config"` + DisplayName string `json:"displayName"` + Revision *time.Time `json:"revision"` } type AlertConfigTest struct { + ID uint `json:"id"` + Type string `json:"type"` + Config string `json:"config"` Host string `json:"host"` Port int `json:"port"` Sender string `json:"sender"` diff --git a/agent/app/dto/alert_webhook.go b/agent/app/dto/alert_webhook.go new file mode 100644 index 000000000..39fddceca --- /dev/null +++ b/agent/app/dto/alert_webhook.go @@ -0,0 +1,80 @@ +package dto + +const AlertCustomWebhookSchemaVersion = 1 + +type AlertConfigStatusUpdate struct { + ID uint `json:"id" validate:"required"` + Status string `json:"status" validate:"required,oneof=Enable Disable"` +} + +type AlertCustomWebhookSecretMutation struct { + Action string `json:"action,omitempty"` + Value string `json:"value,omitempty"` +} + +type AlertCustomWebhookURL struct { + AlertCustomWebhookSecretMutation + Configured bool `json:"configured"` + Masked string `json:"masked,omitempty"` +} + +type AlertCustomWebhookBody struct { + Type string `json:"type"` + Template string `json:"template,omitempty"` + Fields []AlertCustomWebhookFormField `json:"fields,omitempty"` +} + +type AlertCustomWebhookFormField struct { + Key string `json:"key"` + Value string `json:"value"` +} + +type AlertCustomWebhookHeader struct { + UID string `json:"uid"` + Key string `json:"key"` + Secret bool `json:"secret"` + Action string `json:"action,omitempty"` + Value string `json:"value,omitempty"` + Configured bool `json:"configured,omitempty"` + Masked string `json:"masked,omitempty"` +} + +type AlertCustomWebhookConfig struct { + SchemaVersion int `json:"schemaVersion"` + State string `json:"state,omitempty"` + DisplayName string `json:"displayName"` + Preset string `json:"preset"` + Method string `json:"method"` + URL AlertCustomWebhookURL `json:"url"` + Body AlertCustomWebhookBody `json:"body"` + Headers []AlertCustomWebhookHeader `json:"headers"` +} + +type AlertCustomWebhookSecretConfig struct { + SchemaVersion int `json:"schemaVersion"` + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` +} + +type AlertCustomWebhookResolvedConfig struct { + SchemaVersion int + DisplayName string + Preset string + Method string + URL string + Body AlertCustomWebhookBody + Headers []AlertCustomWebhookResolvedHeader +} + +type AlertCustomWebhookResolvedHeader struct { + Key string + Value string +} + +type AlertConfigTestResult struct { + Success bool `json:"success"` + StatusCode int `json:"statusCode,omitempty"` + Duration int64 `json:"duration,omitempty"` // milliseconds + Message string `json:"message,omitempty"` + Response string `json:"response,omitempty"` +} diff --git a/agent/app/model/alert.go b/agent/app/model/alert.go index 560965a76..34c3fc721 100644 --- a/agent/app/model/alert.go +++ b/agent/app/model/alert.go @@ -1,5 +1,12 @@ package model +import ( + "strings" + + "github.com/google/uuid" + "gorm.io/gorm" +) + type Alert struct { BaseModel @@ -18,10 +25,11 @@ type Alert struct { type AlertTask struct { BaseModel - Type string `gorm:"type:varchar(64);not null" json:"type"` - Quota string `gorm:"type:varchar(64)" json:"quota"` - QuotaType string `gorm:"type:varchar(64)" json:"quotaType"` - Method string `gorm:"type:varchar(128);not null;default:'sms'" json:"method"` + Type string `gorm:"type:varchar(64);not null" json:"type"` + Quota string `gorm:"type:varchar(64)" json:"quota"` + QuotaType string `gorm:"type:varchar(64)" json:"quotaType"` + Method string `gorm:"type:varchar(128);not null;default:'sms'" json:"method"` + DeliveryLogID *uint `gorm:"uniqueIndex" json:"-"` } type AlertLog struct { @@ -41,12 +49,21 @@ type AlertLog struct { type AlertConfig struct { BaseModel - Type string `gorm:"type:varchar(64);not null" json:"type"` - Title string `gorm:"type:varchar(64);not null" json:"title"` - Status string `gorm:"type:varchar(64);not null" json:"status"` - Config string `gorm:"type:varchar(256);not null" json:"config"` - CreateUser string `gorm:"type:varchar(256)" json:"createUser"` - UpdateUser string `gorm:"type:varchar(256)" json:"updateUser"` + UID string `gorm:"type:varchar(64);not null;uniqueIndex" json:"uid"` + Type string `gorm:"type:varchar(64);not null" json:"type"` + Title string `gorm:"type:varchar(64);not null" json:"title"` + Status string `gorm:"type:varchar(64);not null" json:"status"` + Config string `gorm:"type:text;not null" json:"config"` + SecretConfig string `gorm:"type:text;not null;default:''" json:"-"` + CreateUser string `gorm:"type:varchar(256)" json:"createUser"` + UpdateUser string `gorm:"type:varchar(256)" json:"updateUser"` +} + +func (a *AlertConfig) BeforeCreate(_ *gorm.DB) error { + if strings.TrimSpace(a.UID) == "" { + a.UID = uuid.NewString() + } + return nil } type LoginLog struct { diff --git a/agent/app/repo/alert.go b/agent/app/repo/alert.go index 015a9b0eb..a7d24c236 100644 --- a/agent/app/repo/alert.go +++ b/agent/app/repo/alert.go @@ -1,20 +1,30 @@ package repo import ( + "encoding/base64" "encoding/json" + "errors" + "fmt" + "strconv" "strings" + "time" "github.com/1Panel-dev/1Panel/agent/app/model" "github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/global" + "github.com/google/uuid" "google.golang.org/genproto/googleapis/type/date" "gorm.io/gorm" - "strconv" - "time" + "gorm.io/gorm/clause" ) type AlertRepo struct{} +var ( + ErrAlertConfigRevisionConflict = errors.New("alert config revision conflict") + ErrAlertConfigRevisionRequired = errors.New("alert config revision is required") +) + type IAlertRepo interface { WithByType(alertType string) DBOption WithByStatusIn(status []string) DBOption @@ -24,6 +34,7 @@ type IAlertRepo interface { WithByCreateAt(date *date.Date) DBOption WithByLicenseId(licenseId string) DBOption WithByRecordId(recordId uint) DBOption + WithByDeliveryLogID(logID uint) DBOption WithByAlertMethodContainsConfigID(id uint) DBOption WithByMethodConfigIDs(ids []uint) DBOption @@ -45,6 +56,8 @@ type IAlertRepo interface { CleanAlertLogs() error CreateAlertTask(alertTaskBase *model.AlertTask) error + CreatePendingAlertTask(logID, alertID uint, alertTask *model.AlertTask) (bool, error) + FinalizePendingAlertTask(logID uint, succeeded bool, message string, fallback *model.AlertTask) (bool, error) DeleteAlertTask(opts ...DBOption) error GetAlertTask(opts ...DBOption) (model.AlertTask, error) LoadTaskCount(alertType string, project string, method string) (uint, uint, error) @@ -55,6 +68,7 @@ type IAlertRepo interface { GetConfigById(id uint) (model.AlertConfig, error) AlertConfigList(opts ...DBOption) ([]model.AlertConfig, error) UpdateAlertConfig(maps map[string]interface{}, opts ...DBOption) error + UpdateAlertConfigWithRevision(maps map[string]interface{}, revision *time.Time, opts ...DBOption) error CreateAlertConfig(config *model.AlertConfig) error DeleteAlertConfig(opts ...DBOption) error @@ -223,13 +237,78 @@ func (a *AlertRepo) DeleteLog(opts ...DBOption) error { } func (a *AlertRepo) CleanAlertLogs() error { - return global.AlertDB.Where("1 = 1").Delete(&model.AlertLog{}).Error + return global.AlertDB.Where("status <> ?", constant.AlertPushing).Delete(&model.AlertLog{}).Error } func (a *AlertRepo) CreateAlertTask(alertTaskBase *model.AlertTask) error { return global.AlertDB.Model(&model.AlertTask{}).Create(&alertTaskBase).Error } +func (a *AlertRepo) CreatePendingAlertTask(logID, alertID uint, alertTask *model.AlertTask) (bool, error) { + if alertTask == nil { + return false, fmt.Errorf("pending alert task is required") + } + created := false + err := global.AlertDB.Transaction(func(tx *gorm.DB) error { + var log model.AlertLog + if err := tx.Where("id = ? AND status = ?", logID, constant.AlertPushing).First(&log).Error; err != nil { + return err + } + if log.AlertId != alertID || log.Type != alertTask.Type || log.Method != alertTask.Method { + return fmt.Errorf("pending alert task does not match delivery log %d", logID) + } + alertTask.DeliveryLogID = &logID + result := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "delivery_log_id"}}, + DoNothing: true, + }).Create(alertTask) + if result.Error != nil { + return result.Error + } + created = result.RowsAffected > 0 + return nil + }) + return created, err +} + +func (a *AlertRepo) FinalizePendingAlertTask(logID uint, succeeded bool, message string, fallback *model.AlertTask) (bool, error) { + finalized := false + err := global.AlertDB.Transaction(func(tx *gorm.DB) error { + status := constant.AlertError + if succeeded { + status = constant.AlertSuccess + message = "" + } + result := tx.Model(&model.AlertLog{}). + Where("id = ? AND status = ?", logID, constant.AlertPushing). + Updates(map[string]interface{}{"status": status, "message": message}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return nil + } + finalized = true + if !succeeded { + return tx.Where("delivery_log_id = ?", logID).Delete(&model.AlertTask{}).Error + } + + var count int64 + if err := tx.Model(&model.AlertTask{}).Where("delivery_log_id = ?", logID).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return nil + } + if fallback == nil { + return fmt.Errorf("pending alert task metadata is unavailable for delivery log %d", logID) + } + fallback.DeliveryLogID = &logID + return tx.Create(fallback).Error + }) + return finalized, err +} + func (a *AlertRepo) DeleteAlertTask(opts ...DBOption) error { db, _ := getAlertDB(opts...) return db.Delete(&model.AlertTask{}).Error @@ -310,7 +389,23 @@ func (a *AlertRepo) UpdateAlertConfig(maps map[string]interface{}, opts ...DBOpt return db.Model(&model.AlertConfig{}).Updates(maps).Error } +func (a *AlertRepo) UpdateAlertConfigWithRevision(maps map[string]interface{}, revision *time.Time, opts ...DBOption) error { + if revision == nil { + return a.UpdateAlertConfig(maps, opts...) + } + db, _ := getAlertDB(opts...) + result := db.Model(&model.AlertConfig{}).Where("updated_at = ?", *revision).Updates(maps) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return ErrAlertConfigRevisionConflict + } + return nil +} + func (a *AlertRepo) CreateAlertConfig(config *model.AlertConfig) error { + ensureAlertConfigUID(config) return global.AlertDB.Model(&model.AlertConfig{}).Create(config).Error } @@ -338,6 +433,12 @@ func (a *AlertRepo) WithByTypeNotIn(types []string) DBOption { } } +func (a *AlertRepo) WithByDeliveryLogID(logID uint) DBOption { + return func(g *gorm.DB) *gorm.DB { + return g.Where("delivery_log_id = ?", logID) + } +} + func (a *AlertRepo) PageAlertConfig(page, size int, opts ...DBOption) (int64, []model.AlertConfig, error) { var configs []model.AlertConfig db := global.AlertDB.Model(&model.AlertConfig{}) @@ -378,26 +479,44 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error { return err } - oldConfigMap := make(map[string]uint) + oldConfigMap := make(map[string]model.AlertConfig) + oldConfigByUID := make(map[string]model.AlertConfig) oldConfigByType := make(map[string][]model.AlertConfig) oldConfigByKey := make(map[string][]model.AlertConfig) consumedConfigIDs := make(map[uint]struct{}) for _, item := range oldConfigs { + if strings.TrimSpace(item.UID) != "" { + oldConfigByUID[item.UID] = item + } if singletonTypes[item.Type] { - oldConfigMap[item.Type] = item.ID + oldConfigMap[item.Type] = item continue } oldConfigByType[item.Type] = append(oldConfigByType[item.Type], item) oldConfigByKey[alertConfigSyncKey(item)] = append(oldConfigByKey[alertConfigSyncKey(item)], item) } for _, item := range data { + if uid := strings.TrimSpace(item.UID); uid != "" { + if matched, ok := oldConfigByUID[uid]; ok && matched.Type != item.Type { + tx.Rollback() + return fmt.Errorf("alert config UID %q belongs to type %q, not %q", uid, matched.Type, item.Type) + } + } if singletonTypes[item.Type] { - if val, ok := oldConfigMap[item.Type]; ok { - item.ID = val + if matched, ok := oldConfigMap[item.Type]; ok { + if err := inheritAlertConfigSyncState(&item, matched); err != nil { + tx.Rollback() + return err + } delete(oldConfigMap, item.Type) consumedConfigIDs[item.ID] = struct{}{} } else { item.ID = 0 + ensureAlertConfigUID(&item) + if err := validateAlertConfigSyncSecret(&item); err != nil { + tx.Rollback() + return err + } } if item.ID == 0 { if err := tx.Create(&item).Error; err != nil { @@ -411,9 +530,31 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error { continue } + if strings.TrimSpace(item.UID) != "" { + if matched, ok := oldConfigByUID[item.UID]; ok { + delete(oldConfigByUID, item.UID) + if err := inheritAlertConfigSyncState(&item, matched); err != nil { + tx.Rollback() + return err + } + consumedConfigIDs[item.ID] = struct{}{} + if err := tx.Save(&item).Error; err != nil { + tx.Rollback() + return err + } + deleteAlertConfigByID(oldConfigByType, matched.ID) + deleteAlertConfigByID(oldConfigByKey, matched.ID) + continue + } + } + key := alertConfigSyncKey(item) if matched, ok := popAlertConfigByKey(oldConfigByKey, key); ok { - item.ID = matched.ID + delete(oldConfigByUID, matched.UID) + if err := inheritAlertConfigSyncState(&item, matched); err != nil { + tx.Rollback() + return err + } consumedConfigIDs[item.ID] = struct{}{} if err := tx.Save(&item).Error; err != nil { tx.Rollback() @@ -424,7 +565,12 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error { } if matched, ok := popUnusedAlertConfigByType(oldConfigByType, usedConfigIDs, item.Type); ok { - item.ID = matched.ID + delete(oldConfigByUID, matched.UID) + deleteAlertConfigByID(oldConfigByKey, matched.ID) + if err := inheritAlertConfigSyncState(&item, matched); err != nil { + tx.Rollback() + return err + } consumedConfigIDs[item.ID] = struct{}{} if err := tx.Save(&item).Error; err != nil { tx.Rollback() @@ -434,6 +580,11 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error { } item.ID = 0 + ensureAlertConfigUID(&item) + if err := validateAlertConfigSyncSecret(&item); err != nil { + tx.Rollback() + return err + } if err := tx.Create(&item).Error; err != nil { tx.Rollback() return err @@ -458,6 +609,63 @@ func (a *AlertRepo) SyncAll(data []model.AlertConfig) error { return nil } +func ensureAlertConfigUID(config *model.AlertConfig) { + if config != nil && strings.TrimSpace(config.UID) == "" { + config.UID = uuid.NewString() + } +} + +func inheritAlertConfigSyncState(incoming *model.AlertConfig, existing model.AlertConfig) error { + if incoming.Type != existing.Type { + return fmt.Errorf("alert config UID %q belongs to type %q, not %q", incoming.UID, existing.Type, incoming.Type) + } + preserveExistingCustom := incoming.Type == constant.Custom && + existing.Status == constant.AlertDisable && + incoming.Title == existing.Title && + incoming.Status == existing.Status && + incoming.Config == existing.Config && + (incoming.SecretConfig == "" || incoming.SecretConfig == existing.SecretConfig) + incoming.ID = existing.ID + if strings.TrimSpace(incoming.UID) == "" { + incoming.UID = existing.UID + } + if incoming.Type == constant.Custom && incoming.SecretConfig == "" { + incoming.SecretConfig = existing.SecretConfig + } + if preserveExistingCustom { + return nil + } + return validateAlertConfigSyncSecret(incoming) +} + +func validateAlertConfigSyncSecret(incoming *model.AlertConfig) error { + if incoming.Type != constant.Custom { + incoming.SecretConfig = "" + return nil + } + if strings.TrimSpace(incoming.SecretConfig) == "" { + return fmt.Errorf("custom webhook sync secret is missing") + } + var version struct { + SchemaVersion int `json:"schemaVersion"` + } + if err := json.Unmarshal([]byte(incoming.Config), &version); err != nil || version.SchemaVersion != 1 { + return fmt.Errorf("custom webhook sync config must use schemaVersion 1") + } + secret := incoming.SecretConfig + for _, prefix := range []string{"core:v1:", "agent:v1:"} { + if !strings.HasPrefix(secret, prefix) { + continue + } + ciphertext, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, prefix)) + if err != nil || len(ciphertext) < 32 || len(ciphertext)%16 != 0 { + return fmt.Errorf("custom webhook sync secret envelope is invalid") + } + return nil + } + return fmt.Errorf("custom webhook sync secret must use a versioned envelope") +} + func loadUsedAlertConfigIDs(tx *gorm.DB) (map[uint]struct{}, error) { var alerts []model.Alert if err := tx.Select("method").Find(&alerts).Error; err != nil { diff --git a/agent/app/service/alert.go b/agent/app/service/alert.go index 5b7f5e7d1..c981c88f9 100644 --- a/agent/app/service/alert.go +++ b/agent/app/service/alert.go @@ -17,10 +17,13 @@ import ( "github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/global" "github.com/1Panel-dev/1Panel/agent/i18n" + alertconfig "github.com/1Panel-dev/1Panel/agent/utils/alert_config" + alertwebhook "github.com/1Panel-dev/1Panel/agent/utils/alert_webhook" "github.com/1Panel-dev/1Panel/agent/utils/cmd" "github.com/1Panel-dev/1Panel/agent/utils/copier" "github.com/1Panel-dev/1Panel/agent/utils/email" "github.com/1Panel-dev/1Panel/agent/utils/xpack" + "github.com/1Panel-dev/1Panel/agent/utils/xpack/providers" "github.com/shirou/gopsutil/v4/disk" ) @@ -34,6 +37,28 @@ var communityAlertMethodTypeNames = map[string]string{ constant.SMS: "SMS", } +var legacyAlertMethodTypeMap = map[string]string{ + "mail": constant.Email, + constant.Email: constant.Email, + constant.SMS: constant.SMS, + constant.Bark: constant.Bark, + constant.WeChat: constant.WeCom, + constant.WeCom: constant.WeCom, + constant.DingTalk: constant.DingTalk, + constant.FeiShu: constant.FeiShu, + constant.Custom: constant.Custom, +} + +var supportedAlertMethodTypes = map[string]struct{}{ + constant.Email: {}, + constant.SMS: {}, + constant.Bark: {}, + constant.WeCom: {}, + constant.DingTalk: {}, + constant.FeiShu: {}, + constant.Custom: {}, +} + type IAlertService interface { PageAlert(req dto.AlertSearch) (int64, []dto.AlertDTO, error) GetAlerts() ([]dto.AlertDTO, error) @@ -53,8 +78,10 @@ type IAlertService interface { GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertConfig, error) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error) UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error + UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) error DeleteAlertConfig(id uint) error TestAlertConfig(req dto.AlertConfigTest) (bool, error) + TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error) } func NewIAlertService() IAlertService { @@ -180,9 +207,15 @@ func (a AlertService) CreateAlert(create dto.AlertCreate, operator string) error } func (a AlertService) UpdateAlert(req dto.AlertUpdate, operator string) error { - if err := a.validateCommunityAlertMethod(req.Method); err != nil { + methodTypes, err := a.validateAlertMethodReferences(req.Method) + if err != nil { return err } + if req.Status != constant.AlertDisable { + if err := a.validateAlertMethodEntitlement(methodTypes); err != nil { + return err + } + } upMap := make(map[string]interface{}) upMap["id"] = req.ID @@ -240,7 +273,16 @@ func (a AlertService) UpdateStatus(id uint, status string) error { if alertInfo.ID == 0 { return buserr.New("ErrRecordNotFound") } - err := alertRepo.Update(map[string]interface{}{"status": status}, repo.WithByID(alertInfo.ID)) + methodTypes, err := a.validateAlertMethodReferences(alertInfo.Method) + if err != nil { + return err + } + if status == constant.AlertEnable { + if err := a.validateAlertMethodEntitlement(methodTypes); err != nil { + return err + } + } + err = alertRepo.Update(map[string]interface{}{"status": status}, repo.WithByID(alertInfo.ID)) if err != nil { return err } @@ -412,6 +454,7 @@ func (a AlertService) parseAlertLog(item model.AlertLog) (dto.AlertLogDTO, error if err := unmarshalAlertInfo(item.AlertDetail, &alertDetail); err != nil { return dto.AlertLogDTO{}, err } + alertDetail.Task = nil if err := unmarshalAlertInfo(item.AlertRule, &alertRule); err != nil { return dto.AlertLogDTO{}, err } @@ -494,7 +537,13 @@ func (a AlertService) GetAlertConfig(req dto.AlertConfigQuery) ([]model.AlertCon } opts = append(opts, repo.WithByStatus(constant.AlertEnable)) configs, err := alertRepo.AlertConfigList(opts...) - return configs, err + if err != nil { + return nil, err + } + if err := exposeCustomAlertConfigSecrets(configs); err != nil { + return nil, err + } + return configs, nil } func (a AlertService) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []model.AlertConfig, error) { @@ -505,13 +554,49 @@ func (a AlertService) PageAlertConfig(req dto.AlertConfigPageReq) (int64, []mode if len(req.ExcludeTypes) > 0 { opts = append(opts, alertRepo.WithByTypeNotIn(req.ExcludeTypes)) } - return alertRepo.PageAlertConfig(req.Page, req.PageSize, opts...) + total, configs, err := alertRepo.PageAlertConfig(req.Page, req.PageSize, opts...) + if err != nil { + return 0, nil, err + } + if err := exposeCustomAlertConfigSecrets(configs); err != nil { + return 0, nil, err + } + return total, configs, nil } func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator string) error { + if req.Type == constant.Custom { + if req.ID != 0 && req.Revision == nil { + return repo.ErrAlertConfigRevisionRequired + } + return a.updateCustomAlertConfig(req, operator) + } + usesMutation, err := alertconfig.UsesMutation(req.Type, req.Config) + if err != nil { + return err + } + if req.ID != 0 && usesMutation && req.Revision == nil { + return repo.ErrAlertConfigRevisionRequired + } + var existing *model.AlertConfig + if req.ID != 0 { + stored, err := alertRepo.GetConfigById(req.ID) + if err != nil { + return err + } + if stored.Type != req.Type { + return fmt.Errorf("alert config %d has type %s, not %s", req.ID, stored.Type, req.Type) + } + existing = &stored + } if err := a.validateCommunityAlertConfigType(req.Type); err != nil { return err } + prepared, err := alertconfig.Prepare(req.Type, req.Config, req.Status, existing) + if err != nil { + return err + } + req.Config = prepared if err := a.checkAlertConfigDisplayNameUnique(req); err != nil { return err } @@ -526,7 +611,7 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri upMap["status"] = req.Status upMap["config"] = req.Config upMap["update_user"] = operator - if err := alertRepo.UpdateAlertConfig(upMap, repo.WithByID(req.ID)); err != nil { + if err := alertRepo.UpdateAlertConfigWithRevision(upMap, req.Revision, repo.WithByID(req.ID)); err != nil { return err } } else { @@ -544,6 +629,99 @@ func (a AlertService) UpdateAlertConfig(req dto.AlertConfigUpdate, operator stri return nil } +func (a AlertService) updateCustomAlertConfig(req dto.AlertConfigUpdate, operator string) error { + if err := validateAlertConfigStatus(req.Status); err != nil { + return err + } + + var existing *model.AlertConfig + if req.ID != 0 { + config, err := alertRepo.GetConfigById(req.ID) + if err != nil { + return err + } + if config.Type != constant.Custom { + return fmt.Errorf("alert config %d is not a custom webhook", req.ID) + } + existing = &config + } + prepared, err := alertwebhook.Prepare(req.Config, req.Status, existing) + if err != nil { + return err + } + validatedReq := req + validatedReq.Config = prepared.Config + if err := a.checkAlertConfigDisplayNameUnique(validatedReq); err != nil { + return err + } + + if existing != nil { + return alertRepo.UpdateAlertConfigWithRevision(map[string]interface{}{ + "type": constant.Custom, + "title": req.Title, + "status": req.Status, + "config": prepared.Config, + "secret_config": prepared.SecretConfig, + "update_user": operator, + }, req.Revision, repo.WithByID(req.ID)) + } + + return alertRepo.CreateAlertConfig(&model.AlertConfig{ + Type: constant.Custom, + Title: req.Title, + Status: req.Status, + Config: prepared.Config, + SecretConfig: prepared.SecretConfig, + CreateUser: operator, + UpdateUser: operator, + }) +} + +func (a AlertService) UpdateAlertConfigStatus(req dto.AlertConfigStatusUpdate, operator string) error { + if err := validateAlertConfigStatus(req.Status); err != nil { + return err + } + config, err := alertRepo.GetConfigById(req.ID) + if err != nil { + return err + } + if req.Status == constant.AlertEnable { + if err := a.validateCommunityAlertConfigType(config.Type); err != nil { + return err + } + if config.Type == constant.Custom { + if _, err := alertwebhook.Resolve(config); err != nil { + return err + } + } + } + return alertRepo.UpdateAlertConfig(map[string]interface{}{ + "status": req.Status, + "update_user": operator, + }, repo.WithByID(req.ID)) +} + +func validateAlertConfigStatus(status string) error { + if status != constant.AlertEnable && status != constant.AlertDisable { + return fmt.Errorf("alert config status must be Enable or Disable") + } + return nil +} + +func exposeCustomAlertConfigSecrets(configs []model.AlertConfig) error { + for index := range configs { + if configs[index].Type != constant.Custom { + continue + } + view, err := alertwebhook.PlainView(configs[index]) + if err != nil { + return fmt.Errorf("build editable custom alert config %d: %w", configs[index].ID, err) + } + configs[index].Config = view + } + return nil +} + func (a AlertService) checkAlertConfigSMSPhoneUnique(req dto.AlertConfigUpdate) error { if req.Type != constant.SMSConfig { return nil @@ -568,6 +746,9 @@ func (a AlertService) checkAlertConfigSMSPhoneUnique(req dto.AlertConfigUpdate) } func (a AlertService) checkAlertConfigDisplayNameUnique(req dto.AlertConfigUpdate) error { + if req.Type != constant.Custom && (global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn") { + return nil + } displayName := alertConfigDisplayName(req.Type, req.Config) if displayName == "" { return nil @@ -591,37 +772,67 @@ func (a AlertService) checkAlertConfigDisplayNameUnique(req dto.AlertConfigUpdat } func (a AlertService) validateCommunityAlertMethod(method string) error { - if global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn" { - return nil - } - if strings.TrimSpace(method) == "" { - return nil + methodTypes, err := a.validateAlertMethodReferences(method) + if err != nil { + return err } + return a.validateAlertMethodEntitlement(methodTypes) +} +func (a AlertService) validateAlertMethodReferences(method string) ([]string, error) { + if strings.TrimSpace(method) == "" { + return nil, buserr.WithErr("ErrAlertMethodNotSupported", nil) + } + methodTypes := make([]string, 0) for _, item := range strings.Split(method, ",") { item = strings.TrimSpace(item) if item == "" { continue } + configType := "" if configID, err := strconv.ParseUint(item, 10, 64); err == nil { config, err := alertRepo.GetConfigById(uint(configID)) if err != nil { - return err + return nil, err } - if _, ok := communityAlertMethodTypeNames[config.Type]; ok { - return buserr.WithErr("ErrAlertMethodNotSupported", nil) + configType = config.Type + } else { + var ok bool + configType, ok = legacyAlertMethodTypeMap[item] + if !ok { + return nil, buserr.WithErr("ErrAlertMethodNotSupported", nil) } + } + if _, ok := supportedAlertMethodTypes[configType]; !ok { + return nil, buserr.WithErr("ErrAlertMethodNotSupported", nil) + } + methodTypes = append(methodTypes, configType) + } + if len(methodTypes) == 0 { + return nil, buserr.WithErr("ErrAlertMethodNotSupported", nil) + } + return methodTypes, nil +} + +func (a AlertService) validateAlertMethodEntitlement(methodTypes []string) error { + for _, configType := range methodTypes { + if configType == constant.Custom { continue } - if _, ok := communityAlertMethodTypeNames[item]; ok { + if global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn" { + continue + } + if _, ok := communityAlertMethodTypeNames[configType]; ok { return buserr.WithErr("ErrAlertMethodNotSupported", nil) } } - return nil } func (a AlertService) validateCommunityAlertConfigType(configType string) error { + if configType == constant.Custom { + return nil + } if global.CONF.Base.IsEnterprise || global.CONF.Base.Edition == "cn" { return nil } @@ -633,7 +844,7 @@ func (a AlertService) validateCommunityAlertConfigType(configType string) error func alertConfigDisplayName(configType, configData string) string { switch configType { - case constant.Email, constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark, constant.SMS: + case constant.Email, constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark, constant.SMS, constant.Custom: var cfg struct { DisplayName string `json:"displayName"` } @@ -672,20 +883,24 @@ func (a AlertService) DeleteAlertConfig(id uint) error { } func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) { - username := req.UserName - if username == "" { - username = req.Sender + emailConfig, err := resolveEmailTestConfig(req) + if err != nil { + return false, err } - encodedDisplayName := mime.BEncoding.Encode("UTF-8", req.DisplayName) + username := emailConfig.UserName + if username == "" { + username = emailConfig.Sender + } + encodedDisplayName := mime.BEncoding.Encode("UTF-8", emailConfig.DisplayName) cfg := email.SMTPConfig{ - Host: req.Host, - Port: req.Port, - Sender: req.Sender, + Host: emailConfig.Host, + Port: emailConfig.Port, + Sender: emailConfig.Sender, Username: username, - Password: req.Password, - From: fmt.Sprintf(`"%s" <%s>`, encodedDisplayName, req.Sender), - Encryption: req.Encryption, - Recipient: req.Recipient, + Password: emailConfig.Password, + From: fmt.Sprintf(`"%s" <%s>`, encodedDisplayName, emailConfig.Sender), + Encryption: emailConfig.Encryption, + Recipient: emailConfig.Recipient, } msg := email.EmailMessage{ @@ -700,9 +915,94 @@ func (a AlertService) TestAlertConfig(req dto.AlertConfigTest) (bool, error) { return true, nil } +func resolveEmailTestConfig(req dto.AlertConfigTest) (dto.AlertEmailConfig, error) { + emailConfig := dto.AlertEmailConfig{ + Host: req.Host, + Port: req.Port, + Sender: req.Sender, + UserName: req.UserName, + Password: req.Password, + DisplayName: req.DisplayName, + Encryption: req.Encryption, + Recipient: req.Recipient, + } + if strings.TrimSpace(req.Config) != "" { + configType := req.Type + if configType == "" { + configType = constant.EmailConfig + } + if configType != constant.EmailConfig { + return dto.AlertEmailConfig{}, fmt.Errorf("alert config test type must be email") + } + var existing *model.AlertConfig + if req.ID != 0 { + stored, err := alertRepo.GetConfigById(req.ID) + if err != nil { + return dto.AlertEmailConfig{}, err + } + existing = &stored + } + prepared, err := alertconfig.Prepare(configType, req.Config, constant.AlertEnable, existing) + if err != nil { + return dto.AlertEmailConfig{}, err + } + if err := json.Unmarshal([]byte(prepared), &emailConfig); err != nil { + return dto.AlertEmailConfig{}, fmt.Errorf("decode email alert config: %w", err) + } + } + return emailConfig, nil +} + +func (a AlertService) TestCustomAlertConfig(req dto.AlertConfigTest) (dto.AlertConfigTestResult, error) { + if req.Type != constant.Custom { + return dto.AlertConfigTestResult{}, fmt.Errorf("alert config test type must be custom") + } + var existing *model.AlertConfig + if req.ID != 0 { + config, err := alertRepo.GetConfigById(req.ID) + if err != nil { + return dto.AlertConfigTestResult{}, err + } + if config.Type != constant.Custom { + return dto.AlertConfigTestResult{}, fmt.Errorf("alert config %d is not a custom webhook", req.ID) + } + existing = &config + } + prepared, err := alertwebhook.Prepare(req.Config, constant.AlertEnable, existing) + if err != nil { + return dto.AlertConfigTestResult{}, err + } + resolved, err := alertwebhook.Resolve(model.AlertConfig{ + Type: constant.Custom, + Config: prepared.Config, + SecretConfig: prepared.SecretConfig, + }) + if err != nil { + return dto.AlertConfigTestResult{}, err + } + tester, ok := xpack.AlertProvider.(providers.CustomWebhookTester) + if !ok { + return dto.AlertConfigTestResult{ + Success: false, + Message: providers.ErrCustomWebhookUnsupported.Error(), + }, nil + } + return tester.TestCustomWebhook(resolved) +} + func (a AlertService) ExternalUpdateAlert(updateAlert dto.AlertCreate, operator string) error { - if err := a.validateCommunityAlertMethod(updateAlert.Method); err != nil { - return err + var methodTypes []string + if updateAlert.SendCount != 0 || strings.TrimSpace(updateAlert.Method) != "" { + var err error + methodTypes, err = a.validateAlertMethodReferences(updateAlert.Method) + if err != nil { + return err + } + } + if updateAlert.SendCount != 0 { + if err := a.validateAlertMethodEntitlement(methodTypes); err != nil { + return err + } } upMap := make(map[string]interface{}) var newStatus string diff --git a/agent/app/service/alert_helper.go b/agent/app/service/alert_helper.go index f5afb42e7..e9687167a 100644 --- a/agent/app/service/alert_helper.go +++ b/agent/app/service/alert_helper.go @@ -695,9 +695,10 @@ func sendAlertsByConfigId(alert dto.AlertDTO, alertType, quota, quotaType string func sendAlertsByLegacyMethod(alert dto.AlertDTO, alertType, quota, quotaType string, params []dto.Param, method string) { typeMap := map[string]string{ - "mail": constant.Email, - constant.Bark: constant.Bark, - constant.SMS: constant.SMS, + "mail": constant.Email, + constant.Bark: constant.Bark, + constant.SMS: constant.SMS, + constant.Custom: constant.Custom, } configType, ok := typeMap[method] if !ok { @@ -785,7 +786,7 @@ func doSendAlert(alert dto.AlertDTO, alertType, quota, quotaType string, params } alertUtil.CreateNewAlertTask(quota, alertType, quotaType, methodStr) - case constant.WeCom, constant.DingTalk, constant.FeiShu: + case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Custom: todayCount, isValid := canSendAlertToday(alertType, quotaType, alert.SendCount, methodStr) if !isValid { return @@ -798,12 +799,31 @@ func doSendAlert(alert dto.AlertDTO, alertType, quota, quotaType string, params } transport := xpack.MultiNodeProvider.LoadRequestTransport() agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo() - alertErr := xpack.AlertProvider.CreateWebhookAlertLog(alertType, alert, create, quotaType, params, config, transport, agentInfo) + queued := false + var alertErr error + if config.Type == constant.Custom { + task := dto.AlertTaskMetadata{ + AlertID: alert.ID, + Type: alertType, + Quota: quota, + QuotaType: quotaType, + Method: methodStr, + } + result, deliveryErr := xpack.DeliverCustomWebhookAlertLog(alertType, alert, create, quotaType, params, config, transport, agentInfo, task) + queued, alertErr = result.Queued, deliveryErr + if alertErr == nil && result.Queued { + _, alertErr = alertUtil.RecordQueuedAlertTask(result.LogID, task) + } + } else { + alertErr = xpack.AlertProvider.CreateWebhookAlertLog(alertType, alert, create, quotaType, params, config, transport, agentInfo) + } if alertErr != nil { global.LOG.Infof("%s alert webhook %s push faild, err: %v", alertType, methodStr, alertErr) return } - alertUtil.CreateNewAlertTask(quota, alertType, quotaType, methodStr) + if !queued { + alertUtil.CreateNewAlertTask(quota, alertType, quotaType, methodStr) + } } } diff --git a/agent/app/service/alert_sender.go b/agent/app/service/alert_sender.go index 5a1d0fffd..f84eb33fc 100644 --- a/agent/app/service/alert_sender.go +++ b/agent/app/service/alert_sender.go @@ -75,7 +75,7 @@ func (s *AlertSender) sendByConfig(config model.AlertConfig, quota string, param } else { s.sendBarkWithConfig(config, quota, params) } - case constant.WeCom, constant.DingTalk, constant.FeiShu: + case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Custom: if isResource { s.sendResourceWebhookWithConfig(config, quota, params) } else { @@ -86,7 +86,7 @@ func (s *AlertSender) sendByConfig(config model.AlertConfig, quota string, param func (s *AlertSender) sendByLegacyMethod(method string, quota string, params []dto.Param, isResource bool) { alertRepo := repo.NewIAlertRepo() - typeMap := map[string]string{"mail": constant.Email, constant.Bark: constant.Bark, constant.SMS: constant.SMS} + typeMap := map[string]string{"mail": constant.Email, constant.Bark: constant.Bark, constant.SMS: constant.SMS, constant.Custom: constant.Custom} configType := method if mapped, ok := typeMap[method]; ok { configType = mapped @@ -308,12 +308,31 @@ func (s *AlertSender) sendWebhookWithConfig(config model.AlertConfig, quota stri } transport := xpack.MultiNodeProvider.LoadRequestTransport() agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo() - err := xpack.AlertProvider.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo) + queued := false + var err error + if config.Type == constant.Custom { + task := dto.AlertTaskMetadata{ + AlertID: s.alert.ID, + Type: s.alert.Type, + Quota: quota, + QuotaType: s.quotaType, + Method: strconv.Itoa(int(config.ID)), + } + result, deliveryErr := xpack.DeliverCustomWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo, task) + queued, err = result.Queued, deliveryErr + if err == nil && result.Queued { + _, err = alertUtil.RecordQueuedAlertTask(result.LogID, task) + } + } else { + err = xpack.AlertProvider.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo) + } if err != nil { global.LOG.Errorf("%s alert %s webhook push failed: %v", s.alert.Type, config.Type, err) return } - alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, strconv.Itoa(int(config.ID))) + if !queued { + alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, strconv.Itoa(int(config.ID))) + } } func (s *AlertSender) sendResourceWebhookWithConfig(config model.AlertConfig, quota string, params []dto.Param) { @@ -334,11 +353,31 @@ func (s *AlertSender) sendResourceWebhookWithConfig(config model.AlertConfig, qu } transport := xpack.MultiNodeProvider.LoadRequestTransport() agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo() - if err := xpack.AlertProvider.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo); err != nil { + queued := false + var err error + if config.Type == constant.Custom { + task := dto.AlertTaskMetadata{ + AlertID: s.alert.ID, + Type: s.alert.Type, + Quota: quota, + QuotaType: s.quotaType, + Method: strconv.Itoa(int(config.ID)), + } + result, deliveryErr := xpack.DeliverCustomWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo, task) + queued, err = result.Queued, deliveryErr + if err == nil && result.Queued { + _, err = alertUtil.RecordQueuedAlertTask(result.LogID, task) + } + } else { + err = xpack.AlertProvider.CreateWebhookAlertLog(s.alert.Type, s.alert, create, quota, params, config, transport, agentInfo) + } + if err != nil { global.LOG.Errorf("%s alert %s webhook push failed: %v", s.alert.Type, config.Type, err) return } - alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, strconv.Itoa(int(config.ID))) + if !queued { + alertUtil.CreateNewAlertTask(quota, s.alert.Type, s.quotaType, strconv.Itoa(int(config.ID))) + } } func (s *AlertSender) sendWebhook(quota string, params []dto.Param, method string) { diff --git a/agent/cmd/server/docs/x-log.json b/agent/cmd/server/docs/x-log.json index 2a27c52c6..b3ac1bc15 100644 --- a/agent/cmd/server/docs/x-log.json +++ b/agent/cmd/server/docs/x-log.json @@ -5787,4 +5787,4 @@ "formatZH": "从主节点同步设置", "formatEN": "sync settings from master" } -} \ No newline at end of file +} diff --git a/agent/init/migration/migrate.go b/agent/init/migration/migrate.go index a81230c51..ff31fc8b9 100644 --- a/agent/init/migration/migrate.go +++ b/agent/init/migration/migrate.go @@ -5,6 +5,7 @@ import ( "github.com/1Panel-dev/1Panel/agent/init/migration/migrations" "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" ) func Init() { @@ -123,13 +124,21 @@ func InitTaskDB() { } func InitAlertDB() { - m := gormigrate.New(global.AlertDB, gormigrate.DefaultOptions, []*gormigrate.Migration{ - migrations.MigrateAlertMethodConfigIDs, - migrations.MigrateAlertLogTaskMethodConfigIDs, - migrations.AddAlertAuditUser, - }) - if err := m.Migrate(); err != nil { + if err := migrateAlertDB(global.AlertDB); err != nil { global.LOG.Error(err) panic(err) } } + +func migrateAlertDB(db *gorm.DB) error { + options := *gormigrate.DefaultOptions + options.UseTransaction = true + m := gormigrate.New(db, &options, []*gormigrate.Migration{ + migrations.AddAlertConfigUIDAndSecret, + migrations.MigrateAlertMethodConfigIDs, + migrations.MigrateAlertLogTaskMethodConfigIDs, + migrations.AddAlertAuditUser, + migrations.AddAlertTaskDeliveryLogID, + }) + return m.Migrate() +} diff --git a/agent/init/migration/migrations/init.go b/agent/init/migration/migrations/init.go index bf89cb624..2a6d7d327 100644 --- a/agent/init/migration/migrations/init.go +++ b/agent/init/migration/migrations/init.go @@ -19,6 +19,7 @@ import ( "github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/global" migrationutils "github.com/1Panel-dev/1Panel/agent/init/migration/migrations/utils" + alertwebhook "github.com/1Panel-dev/1Panel/agent/utils/alert_webhook" "github.com/1Panel-dev/1Panel/agent/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/copier" "github.com/1Panel-dev/1Panel/agent/utils/encrypt" @@ -27,6 +28,7 @@ import ( "github.com/1Panel-dev/1Panel/agent/utils/xpack" "github.com/go-gormigrate/gormigrate/v2" + "github.com/google/uuid" "gorm.io/gorm" ) @@ -489,7 +491,7 @@ var AddColumnToAlert = &gormigrate.Migration{ var MigrateAlertMethodConfigIDs = &gormigrate.Migration{ ID: "20251001-migrate-alert-method-config-ids", Migrate: func(tx *gorm.DB) error { - if err := global.AlertDB.AutoMigrate(&model.Alert{}, &model.AlertLog{}, &model.AlertTask{}, &model.AlertConfig{}); err != nil { + if err := tx.AutoMigrate(&model.Alert{}, &model.AlertLog{}, &model.AlertTask{}, &model.AlertConfig{}); err != nil { return err } if err := migrateAlertMethodConfigIDs(tx); err != nil { @@ -502,7 +504,7 @@ var MigrateAlertMethodConfigIDs = &gormigrate.Migration{ var MigrateAlertLogTaskMethodConfigIDs = &gormigrate.Migration{ ID: "20260608-migrate-alert-log-task-method-config-ids", Migrate: func(tx *gorm.DB) error { - if err := global.AlertDB.AutoMigrate(&model.AlertLog{}, &model.AlertTask{}, &model.AlertConfig{}); err != nil { + if err := tx.AutoMigrate(&model.AlertLog{}, &model.AlertTask{}, &model.AlertConfig{}); err != nil { return err } if err := migrateAlertMethodRecords(tx, &model.AlertLog{}); err != nil { @@ -518,10 +520,104 @@ var MigrateAlertLogTaskMethodConfigIDs = &gormigrate.Migration{ var AddAlertAuditUser = &gormigrate.Migration{ ID: "20260602-add-alert-audit-user", Migrate: func(tx *gorm.DB) error { - return global.AlertDB.AutoMigrate(&model.Alert{}, &model.AlertConfig{}) + return tx.AutoMigrate(&model.Alert{}, &model.AlertConfig{}) }, } +var AddAlertConfigUIDAndSecret = &gormigrate.Migration{ + ID: "20260826-add-alert-config-uid-secret", + Migrate: func(tx *gorm.DB) error { + return migrateAlertConfigUIDAndSecret(tx) + }, +} + +var AddAlertTaskDeliveryLogID = &gormigrate.Migration{ + ID: "20260826-add-alert-task-delivery-log-id", + Migrate: func(tx *gorm.DB) error { + return tx.AutoMigrate(&model.AlertTask{}) + }, +} + +func migrateAlertConfigUIDAndSecret(tx *gorm.DB) error { + if !tx.Migrator().HasTable(&model.AlertConfig{}) { + return tx.AutoMigrate(&model.AlertConfig{}) + } + + if !tx.Migrator().HasColumn(&model.AlertConfig{}, "UID") { + if err := tx.Exec("ALTER TABLE alert_configs ADD COLUMN uid varchar(64)").Error; err != nil { + return err + } + } + if !tx.Migrator().HasColumn(&model.AlertConfig{}, "SecretConfig") { + if err := tx.Exec("ALTER TABLE alert_configs ADD COLUMN secret_config text NOT NULL DEFAULT ''").Error; err != nil { + return err + } + } + + var configs []struct { + ID uint + UID string + } + if err := tx.Table("alert_configs").Select("id", "uid").Find(&configs).Error; err != nil { + return err + } + for _, config := range configs { + if strings.TrimSpace(config.UID) != "" { + continue + } + if err := tx.Table("alert_configs").Where("id = ?", config.ID).Update("uid", uuid.NewString()).Error; err != nil { + return err + } + } + + if err := tx.AutoMigrate(&model.AlertConfig{}); err != nil { + return err + } + + var customConfigs []model.AlertConfig + if err := tx.Where("type = ?", constant.Custom).Find(&customConfigs).Error; err != nil { + return err + } + for _, config := range customConfigs { + prepared, status, legacy, err := alertwebhook.NormalizeLegacy(config.Config, config.Status, config.Title) + if err != nil { + return fmt.Errorf("normalize legacy custom webhook %d: %w", config.ID, err) + } + if !legacy { + if err := alertwebhook.ValidateStored(config); err != nil && config.Status != constant.AlertDisable { + if updateErr := tx.Model(&model.AlertConfig{}).Where("id = ?", config.ID).Update("status", constant.AlertDisable).Error; updateErr != nil { + return updateErr + } + } + continue + } + if err := tx.Model(&model.AlertConfig{}).Where("id = ?", config.ID).Updates(map[string]interface{}{ + "config": prepared.Config, + "secret_config": prepared.SecretConfig, + "status": status, + }).Error; err != nil { + return err + } + } + + if tx.Migrator().HasTable(&model.Alert{}) { + if err := migrateAlertMethodConfigIDs(tx); err != nil { + return err + } + } + if tx.Migrator().HasTable(&model.AlertLog{}) { + if err := migrateAlertMethodRecords(tx, &model.AlertLog{}); err != nil { + return err + } + } + if tx.Migrator().HasTable(&model.AlertTask{}) { + if err := migrateAlertMethodRecords(tx, &model.AlertTask{}); err != nil { + return err + } + } + return nil +} + func migrateAlertMethodConfigIDs(tx *gorm.DB) error { if err := tx.Model(&model.AlertConfig{}).Where("type = ?", "mail").Update("type", constant.EmailConfig).Error; err != nil { return err @@ -615,6 +711,7 @@ func alertLegacyMethodTypeMap() map[string]string { constant.WeCom: constant.WeCom, constant.DingTalk: constant.DingTalk, constant.FeiShu: constant.FeiShu, + constant.Custom: constant.Custom, } } diff --git a/agent/router/ro_alert.go b/agent/router/ro_alert.go index 1400336c4..fa56ab6bf 100644 --- a/agent/router/ro_alert.go +++ b/agent/router/ro_alert.go @@ -24,6 +24,7 @@ func (a *AlertRouter) InitRouter(Router *gin.RouterGroup) { alertRouter.POST("/cronjob/list", baseApi.GetCronJobs) alertRouter.POST("/config/update", baseApi.UpdateAlertConfig) + alertRouter.POST("/config/status", baseApi.UpdateAlertConfigStatus) alertRouter.POST("/config/info", baseApi.GetAlertConfig) alertRouter.POST("/config/search", baseApi.PageAlertConfig) alertRouter.POST("/config/del", baseApi.DeleteAlertConfig) diff --git a/agent/utils/alert/alert.go b/agent/utils/alert/alert.go index 39c9e27c6..5ea18b7ce 100644 --- a/agent/utils/alert/alert.go +++ b/agent/utils/alert/alert.go @@ -157,6 +157,117 @@ func CreateNewAlertTask(quota, alertType, quotaType, method string) { global.LOG.Infof("%s alert %s push completed", alertType, method) } +func AttachQueuedAlertTaskMetadata(rawDetail string, task dto.AlertTaskMetadata) (string, error) { + if err := validateQueuedAlertTaskMetadata(task); err != nil { + return "", err + } + var detail dto.AlertDetail + if err := json.Unmarshal([]byte(rawDetail), &detail); err != nil { + return "", fmt.Errorf("decode queued alert detail: %w", err) + } + detail.Task = &task + data, err := json.Marshal(detail) + if err != nil { + return "", fmt.Errorf("encode queued alert detail: %w", err) + } + return string(data), nil +} + +func RecordQueuedAlertTask(logID uint, task dto.AlertTaskMetadata) (bool, error) { + if logID == 0 { + return false, fmt.Errorf("queued alert log ID is required") + } + if global.AlertDB == nil { + return false, fmt.Errorf("alert database is unavailable") + } + if err := validateQueuedAlertTaskMetadata(task); err != nil { + return false, err + } + alertRepo := repo.NewIAlertRepo() + log, err := alertRepo.GetLog(repo.WithByID(logID)) + if err != nil { + return false, err + } + if log.Status != constant.AlertPushing { + if log.Status == constant.AlertSuccess { + if existing, taskErr := alertRepo.GetAlertTask(alertRepo.WithByDeliveryLogID(logID)); taskErr == nil && existing.DeliveryLogID != nil { + return false, nil + } + } + return false, fmt.Errorf("alert delivery log %d is not pending", logID) + } + storedTask, alertTask, err := queuedAlertTaskFromLog(log) + if err != nil { + return false, err + } + if storedTask != task { + return false, fmt.Errorf("queued alert task metadata changed before reservation") + } + return alertRepo.CreatePendingAlertTask(logID, task.AlertID, &alertTask) +} + +func FinalizeQueuedAlertDelivery(logID uint, succeeded bool, message string) (bool, error) { + if logID == 0 { + return false, fmt.Errorf("queued alert log ID is required") + } + if global.AlertDB == nil { + return false, fmt.Errorf("alert database is unavailable") + } + var fallback *model.AlertTask + if succeeded { + alertRepo := repo.NewIAlertRepo() + log, err := alertRepo.GetLog(repo.WithByID(logID)) + if err != nil { + return false, err + } + if log.Status != constant.AlertPushing { + return false, nil + } + _, task, err := queuedAlertTaskFromLog(log) + if err != nil { + return false, err + } + fallback = &task + } + return repo.NewIAlertRepo().FinalizePendingAlertTask(logID, succeeded, message, fallback) +} + +func queuedAlertTaskFromLog(log model.AlertLog) (dto.AlertTaskMetadata, model.AlertTask, error) { + var detail dto.AlertDetail + if err := json.Unmarshal([]byte(log.AlertDetail), &detail); err != nil { + return dto.AlertTaskMetadata{}, model.AlertTask{}, fmt.Errorf("decode queued alert task metadata: %w", err) + } + if detail.Task == nil { + return dto.AlertTaskMetadata{}, model.AlertTask{}, fmt.Errorf("queued alert task metadata is missing") + } + if err := validateQueuedAlertTaskMetadata(*detail.Task); err != nil { + return dto.AlertTaskMetadata{}, model.AlertTask{}, err + } + if detail.Task.AlertID != log.AlertId || detail.Task.Type != log.Type || detail.Task.Method != log.Method { + return dto.AlertTaskMetadata{}, model.AlertTask{}, fmt.Errorf("queued alert task metadata does not match its log") + } + task := model.AlertTask{ + Type: detail.Task.Type, + Quota: detail.Task.Quota, + QuotaType: detail.Task.QuotaType, + Method: detail.Task.Method, + } + return *detail.Task, task, nil +} + +func validateQueuedAlertTaskMetadata(task dto.AlertTaskMetadata) error { + if task.AlertID == 0 { + return fmt.Errorf("queued alert task alert ID is required") + } + if strings.TrimSpace(task.Type) == "" { + return fmt.Errorf("queued alert task type is required") + } + if strings.TrimSpace(task.Method) == "" { + return fmt.Errorf("queued alert task method is required") + } + return nil +} + func ProcessAlertDetail(alert dto.AlertDTO, project string, params []dto.Param, method string) string { alertDetail := dto.AlertDetail{ Type: GetCronJobType(alert.Type), diff --git a/agent/utils/alert/custom_webhook.go b/agent/utils/alert/custom_webhook.go new file mode 100644 index 000000000..a28393698 --- /dev/null +++ b/agent/utils/alert/custom_webhook.go @@ -0,0 +1,206 @@ +package alert + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/constant" + "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/i18n" + "github.com/1Panel-dev/1Panel/agent/utils/alert_webhook" + "github.com/1Panel-dev/1Panel/agent/utils/webhook_sender" +) + +var customWebhookNow = time.Now + +func CreateCustomWebhookAlertLog( + alertType string, + info dto.AlertDTO, + create dto.AlertLogCreate, + project string, + params []dto.Param, + config model.AlertConfig, + transport *http.Transport, + agentInfo *dto.AgentInfo, +) error { + alertInfo := info + alertInfo.Type = alertType + create.Type = GetCronJobType(alertType) + create.AlertRule = ProcessAlertRule(info) + create.AlertDetail = ProcessAlertDetail(alertInfo, project, params, constant.Custom) + return deliverCustomWebhook(create, config, transport, agentInfo, customWebhookNow()) +} + +func CreateTaskScanCustomWebhookAlertLog( + info dto.AlertDTO, + alertType string, + create dto.AlertLogCreate, + pushAlert dto.PushAlert, + config model.AlertConfig, + transport *http.Transport, + agentInfo *dto.AgentInfo, +) error { + params := CreateAlertParams(GetCronJobTypeName(pushAlert.Param)) + alertInfo := info + alertInfo.Type = alertType + create.Type = GetCronJobType(alertType) + create.AlertRule = ProcessAlertRule(info) + create.AlertDetail = ProcessAlertDetail(alertInfo, pushAlert.TaskName, params, constant.Custom) + return deliverCustomWebhook(create, config, transport, agentInfo, customWebhookNow()) +} + +func deliverCustomWebhook( + create dto.AlertLogCreate, + config model.AlertConfig, + transport *http.Transport, + agentInfo *dto.AgentInfo, + occurredAt time.Time, +) error { + var alertLog model.AlertLog + templateData, err := customWebhookTemplateData(create.AlertDetail, agentInfo, occurredAt) + if err != nil { + return saveCustomWebhookDeliveryError(create, &alertLog, err) + } + request, err := buildCustomWebhookRequest(config, templateData, transport) + if err != nil { + return saveCustomWebhookDeliveryError(create, &alertLog, err) + } + if _, err := webhook_sender.Execute(context.Background(), request); err != nil { + return saveCustomWebhookDeliveryError(create, &alertLog, err) + } + create.Status = constant.AlertSuccess + return SaveAlertLog(create, &alertLog) +} + +func saveCustomWebhookDeliveryError(create dto.AlertLogCreate, alertLog *model.AlertLog, deliveryErr error) error { + create.Status = constant.AlertError + create.Message = deliveryErr.Error() + if err := SaveAlertLog(create, alertLog); err != nil { + global.LOG.Errorf("save custom webhook delivery error log failed: %v", err) + } + return deliveryErr +} + +func customWebhookTemplateData(rawDetail string, agentInfo *dto.AgentInfo, occurredAt time.Time) (webhook_sender.TemplateData, error) { + var detail dto.AlertDetail + if err := json.Unmarshal([]byte(rawDetail), &detail); err != nil { + return webhook_sender.TemplateData{}, errors.New("resolve custom webhook alert detail failed") + } + businessType := detail.SubType + if businessType == "" { + businessType = detail.Type + } + if businessType == "" { + return webhook_sender.TemplateData{}, errors.New("resolve custom webhook alert detail failed") + } + content := GetSendContent(businessType, detail.Params, agentInfo) + if content == "" { + content = i18n.GetMsgWithMap("CommonAlert", map[string]interface{}{"msg": detail.Title}) + } + message := strings.TrimSpace(webhook_sender.NormalizeToText(content)) + if message == "" { + message = detail.Title + } + return webhook_sender.TemplateData{ + Title: detail.Title, + Message: message, + Type: businessType, + NodeName: customWebhookNodeName(agentInfo), + Timestamp: occurredAt, + }, nil +} + +func customWebhookNodeName(agentInfo *dto.AgentInfo) string { + if agentInfo != nil && strings.TrimSpace(agentInfo.NodeName) != "" { + return strings.TrimSpace(agentInfo.NodeName) + } + return strings.TrimSpace(getFallbackHostname()) +} + +func buildCustomWebhookRequest( + config model.AlertConfig, + data webhook_sender.TemplateData, + transport *http.Transport, +) (webhook_sender.Request, error) { + resolved, err := alert_webhook.Resolve(config) + if err != nil { + return webhook_sender.Request{}, errors.New("resolve custom webhook config failed") + } + return buildResolvedCustomWebhookRequest(resolved, data, transport) +} + +func TestCustomWebhook( + resolved dto.AlertCustomWebhookResolvedConfig, + transport *http.Transport, + agentInfo *dto.AgentInfo, +) (dto.AlertConfigTestResult, error) { + request, err := buildResolvedCustomWebhookRequest(resolved, webhook_sender.TemplateData{ + Title: "1Panel Webhook Test", + Message: "This is a test notification from 1Panel.", + Type: "test", + NodeName: customWebhookNodeName(agentInfo), + Timestamp: customWebhookNow(), + }, transport) + if err != nil { + return dto.AlertConfigTestResult{}, err + } + request.CaptureResponse = true + result, executeErr := webhook_sender.Execute(context.Background(), request) + durationMillis := result.Duration.Milliseconds() + if result.Duration > 0 && durationMillis == 0 { + durationMillis = 1 + } + testResult := dto.AlertConfigTestResult{ + Success: executeErr == nil, + StatusCode: result.StatusCode, + Duration: durationMillis, + Response: result.Response, + } + if executeErr != nil { + testResult.Message = executeErr.Error() + } + return testResult, nil +} + +func buildResolvedCustomWebhookRequest( + resolved dto.AlertCustomWebhookResolvedConfig, + data webhook_sender.TemplateData, + transport *http.Transport, +) (webhook_sender.Request, error) { + preset, err := webhook_sender.ResolvePreset(resolved.Preset) + if err != nil { + return webhook_sender.Request{}, errors.New("render custom webhook request failed") + } + format := webhook_sender.BodyFormat(resolved.Body.Type) + fields := make([]webhook_sender.FormField, 0, len(resolved.Body.Fields)) + for _, field := range resolved.Body.Fields { + fields = append(fields, webhook_sender.FormField{Key: field.Key, Value: field.Value}) + } + body, err := webhook_sender.RenderBody(webhook_sender.RenderRequest{ + Format: format, + Template: resolved.Body.Template, + Fields: fields, + Data: data, + }) + if err != nil { + return webhook_sender.Request{}, errors.New("render custom webhook request failed") + } + headers := make(map[string]string, len(resolved.Headers)) + for _, header := range resolved.Headers { + headers[header.Key] = header.Value + } + return webhook_sender.Request{ + URL: resolved.URL, + Preset: preset, + Format: format, + Body: body, + Headers: headers, + Transport: transport, + }, nil +} diff --git a/agent/utils/alert_config/secret.go b/agent/utils/alert_config/secret.go new file mode 100644 index 000000000..10f44883f --- /dev/null +++ b/agent/utils/alert_config/secret.go @@ -0,0 +1,312 @@ +package alert_config + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "strings" + "unicode/utf8" + + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/constant" +) + +const MaskedSecret = "******" + +type secretMutation struct { + Action string `json:"action"` + Value string `json:"value,omitempty"` +} + +func IsLegacySecretType(configType string) bool { + return secretField(configType) != "" +} + +func UsesMutation(configType, rawMutation string) (bool, error) { + field := secretField(configType) + if field == "" { + return false, nil + } + root, err := decodeObject(rawMutation) + if err != nil { + return false, fmt.Errorf("decode alert config mutation: %w", err) + } + if raw, ok := root[field]; ok && rawIsObject(raw) { + return true, nil + } + if !isWebhookType(configType) { + return false, nil + } + raw, ok := root["webhooks"] + if !ok { + return false, nil + } + var items []map[string]json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return false, fmt.Errorf("webhooks mutation must be an array") + } + for _, item := range items { + if rawURL, ok := item["url"]; ok && rawIsObject(rawURL) { + return true, nil + } + } + return false, nil +} + +func Prepare(configType, rawMutation, status string, existing *model.AlertConfig) (string, error) { + field := secretField(configType) + if field == "" { + return rawMutation, nil + } + root, err := decodeObject(rawMutation) + if err != nil { + return "", fmt.Errorf("decode alert config mutation: %w", err) + } + var existingRoot map[string]json.RawMessage + if existing != nil { + if existing.Type != configType { + return "", fmt.Errorf("alert config %d has type %s, not %s", existing.ID, existing.Type, configType) + } + existingRoot, err = decodeObject(existing.Config) + if err != nil { + return "", fmt.Errorf("decode stored alert config: %w", err) + } + } + + existingSecret, err := storedSecret(existingRoot, field) + if err != nil { + return "", err + } + if raw, ok := root[field]; ok { + value, err := mergeSecret(field, raw, existingSecret, existing != nil) + if err != nil { + return "", fmt.Errorf("merge alert config %s: %w", field, err) + } + root[field] = mustJSON(value) + } else if existing != nil { + root[field] = mustJSON(existingSecret) + } + + if isWebhookType(configType) { + if err := mergeWebhookArray(root, existingRoot, existing != nil); err != nil { + return "", err + } + } + if status == constant.AlertEnable { + if configType == constant.SMSConfig { + phone, err := storedSecret(root, "phone") + if err != nil || phone == "" { + return "", fmt.Errorf("SMS phone is required while the config is enabled") + } + } + if isWebhookType(configType) && !hasWebhookURL(root) { + return "", fmt.Errorf("webhook URL is required while the config is enabled") + } + } + return encodeObject(root) +} + +func secretField(configType string) string { + switch configType { + case constant.EmailConfig: + return "password" + case constant.SMSConfig: + return "phone" + case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark: + return "url" + default: + return "" + } +} + +func isWebhookType(configType string) bool { + switch configType { + case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Bark: + return true + default: + return false + } +} + +func decodeObject(raw string) (map[string]json.RawMessage, error) { + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.DisallowUnknownFields() + var result map[string]json.RawMessage + if err := decoder.Decode(&result); err != nil { + return nil, err + } + if result == nil { + return nil, fmt.Errorf("alert config must be a JSON object") + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + return nil, fmt.Errorf("multiple JSON values are not allowed") + } + return nil, err + } + return result, nil +} + +func encodeObject(value map[string]json.RawMessage) (string, error) { + encoded, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("encode alert config: %w", err) + } + return string(encoded), nil +} + +func mustJSON(value any) json.RawMessage { + encoded, err := json.Marshal(value) + if err != nil { + panic(err) + } + return encoded +} + +func decodeStoredSecret(raw json.RawMessage) (string, error) { + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("stored secret is not a string") + } + return value, nil +} + +func storedSecret(root map[string]json.RawMessage, field string) (string, error) { + if root == nil { + return "", nil + } + raw, ok := root[field] + if !ok { + return "", nil + } + value, err := decodeStoredSecret(raw) + if err != nil { + return "", fmt.Errorf("stored alert config %s is invalid", field) + } + return value, nil +} + +func mergeSecret(field string, raw json.RawMessage, existing string, hasExisting bool) (string, error) { + if value, err := decodeStoredSecret(raw); err == nil { + if hasExisting && isLegacyMaskValue(field, value) { + return existing, nil + } + return value, nil + } + var mutation secretMutation + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&mutation); err != nil { + return "", fmt.Errorf("secret mutation must be a string or keep/replace/clear object") + } + switch mutation.Action { + case "keep": + if !hasExisting { + return "", fmt.Errorf("secret cannot be kept because the config does not exist") + } + return existing, nil + case "replace": + return mutation.Value, nil + case "clear": + return "", nil + default: + return "", fmt.Errorf("secret action must be keep, replace, or clear") + } +} + +func isLegacyMaskValue(field, value string) bool { + if field == "phone" { + return isLegacyMaskedPhone(value) + } + return value == MaskedSecret +} + +func isLegacyMaskedPhone(value string) bool { + value = strings.TrimSpace(value) + if !strings.Contains(value, "*") { + return false + } + return maskPhone(strings.ReplaceAll(value, "*", "0")) == value +} + +func rawIsObject(raw json.RawMessage) bool { + trimmed := bytes.TrimSpace(raw) + return len(trimmed) > 0 && trimmed[0] == '{' +} + +func mergeWebhookArray(root, existingRoot map[string]json.RawMessage, hasExisting bool) error { + raw, ok := root["webhooks"] + if !ok { + return nil + } + var items []map[string]json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return fmt.Errorf("webhooks mutation must be an array") + } + var existingItems []map[string]json.RawMessage + if existingRoot != nil { + if existingRaw, ok := existingRoot["webhooks"]; ok { + if err := json.Unmarshal(existingRaw, &existingItems); err != nil { + return fmt.Errorf("stored webhooks must be an array") + } + } + } + for index := range items { + rawURL, ok := items[index]["url"] + if !ok { + continue + } + var existingURL string + itemExists := hasExisting && index < len(existingItems) + if itemExists { + var err error + existingURL, err = storedSecret(existingItems[index], "url") + if err != nil { + return fmt.Errorf("stored webhook URL %d is invalid", index) + } + } + value, err := mergeSecret("url", rawURL, existingURL, itemExists) + if err != nil { + return fmt.Errorf("merge webhook URL %d: %w", index, err) + } + items[index]["url"] = mustJSON(value) + } + root["webhooks"] = mustJSON(items) + return nil +} + +func hasWebhookURL(root map[string]json.RawMessage) bool { + if value, err := storedSecret(root, "url"); err == nil && value != "" { + return true + } + raw, ok := root["webhooks"] + if !ok { + return false + } + var items []map[string]json.RawMessage + if err := json.Unmarshal(raw, &items); err != nil { + return false + } + for _, item := range items { + if value, err := storedSecret(item, "url"); err == nil && value != "" { + return true + } + } + return false +} + +func maskPhone(value string) string { + runes := []rune(strings.TrimSpace(value)) + switch { + case len(runes) >= 11: + return string(runes[:3]) + strings.Repeat("*", len(runes)-7) + string(runes[len(runes)-4:]) + case len(runes) >= 8: + return string(runes[:2]) + strings.Repeat("*", len(runes)-4) + string(runes[len(runes)-2:]) + case utf8.RuneCountInString(value) == 0: + return "" + default: + return MaskedSecret + } +} diff --git a/agent/utils/alert_push/alert_push.go b/agent/utils/alert_push/alert_push.go index abf648b9a..5f9591c17 100644 --- a/agent/utils/alert_push/alert_push.go +++ b/agent/utils/alert_push/alert_push.go @@ -50,9 +50,10 @@ func pushByConfigId(alertRepo repo.IAlertRepo, alert dto.AlertDTO, pushAlert dto func pushByLegacyMethod(alertRepo repo.IAlertRepo, alert dto.AlertDTO, pushAlert dto.PushAlert, method string) { typeMap := map[string]string{ - "mail": constant.Email, - constant.Bark: constant.Bark, - constant.SMS: constant.SMS, + "mail": constant.Email, + constant.Bark: constant.Bark, + constant.SMS: constant.SMS, + constant.Custom: constant.Custom, } configType := method if mapped, ok := typeMap[method]; ok { @@ -137,7 +138,7 @@ func sendAlert(alertRepo repo.IAlertRepo, alert dto.AlertDTO, pushAlert dto.Push } alertUtil.CreateNewAlertTask(strconv.Itoa(int(pushAlert.EntryID)), alertUtil.GetCronJobType(alert.Type), strconv.Itoa(int(pushAlert.EntryID)), methodStr) - case constant.WeCom, constant.DingTalk, constant.FeiShu: + case constant.WeCom, constant.DingTalk, constant.FeiShu, constant.Custom: todayCount, _, err := alertRepo.LoadTaskCount(alertUtil.GetCronJobType(alert.Type), strconv.Itoa(int(pushAlert.EntryID)), methodStr) if err != nil || alert.SendCount <= todayCount { return @@ -150,11 +151,29 @@ func sendAlert(alertRepo repo.IAlertRepo, alert dto.AlertDTO, pushAlert dto.Push } transport := xpack.MultiNodeProvider.LoadRequestTransport() agentInfo, _ := xpack.MultiNodeProvider.GetAgentInfo() - err = xpack.AlertProvider.CreateTaskScanWebhookAlertLog(alert, alert.Type, create, pushAlert, config, transport, agentInfo) + queued := false + if config.Type == constant.Custom { + task := dto.AlertTaskMetadata{ + AlertID: alert.ID, + Type: alertUtil.GetCronJobType(alert.Type), + Quota: strconv.Itoa(int(pushAlert.EntryID)), + QuotaType: strconv.Itoa(int(pushAlert.EntryID)), + Method: methodStr, + } + result, deliveryErr := xpack.DeliverTaskScanCustomWebhookAlertLog(alert, alert.Type, create, pushAlert, config, transport, agentInfo, task) + queued, err = result.Queued, deliveryErr + if err == nil && result.Queued { + _, err = alertUtil.RecordQueuedAlertTask(result.LogID, task) + } + } else { + err = xpack.AlertProvider.CreateTaskScanWebhookAlertLog(alert, alert.Type, create, pushAlert, config, transport, agentInfo) + } if err != nil { global.LOG.Errorf("%s alert %s webhook push failed: %v", alert.Type, methodStr, err) return } - alertUtil.CreateNewAlertTask(strconv.Itoa(int(pushAlert.EntryID)), alertUtil.GetCronJobType(alert.Type), strconv.Itoa(int(pushAlert.EntryID)), methodStr) + if !queued { + alertUtil.CreateNewAlertTask(strconv.Itoa(int(pushAlert.EntryID)), alertUtil.GetCronJobType(alert.Type), strconv.Itoa(int(pushAlert.EntryID)), methodStr) + } } } diff --git a/agent/utils/alert_webhook/config.go b/agent/utils/alert_webhook/config.go new file mode 100644 index 000000000..f1bfddc5e --- /dev/null +++ b/agent/utils/alert_webhook/config.go @@ -0,0 +1,1011 @@ +package alert_webhook + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "unicode/utf8" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/constant" + "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/utils/encrypt" + "github.com/google/uuid" +) + +const ( + MaskedSecret = "******" + DefaultGenericJSONTemplate = `{"schema_version":"1","title":"{{title}}","message":"{{message}}","type":"{{type}}","node_name":"{{nodeName}}","timestamp":"{{timestamp}}"}` + + coreKeyPrefix = "core:v1:" + agentKeyPrefix = "agent:v1:" + + maxDisplayNameRunes = 64 + maxURLLength = 8192 + maxHeaders = 64 + maxHeaderNameLength = 256 + maxHeaderValueLen = 16 * 1024 + maxBodyLength = 256 * 1024 + maxFormFields = 128 + maxConfigLength = 512 * 1024 + maxSecretLength = 256 * 1024 +) + +var supportedPresets = map[string]struct{}{ + "genericJson": {}, + "slack": {}, + "discord": {}, + "teamsWorkflows": {}, + "custom": {}, +} + +var forbiddenHeaders = map[string]struct{}{ + "connection": {}, + "content-length": {}, + "content-type": {}, + "host": {}, + "proxy-connection": {}, + "proxy-authorization": {}, + "te": {}, + "trailer": {}, + "transfer-encoding": {}, + "upgrade": {}, +} + +var supportedTemplateVariables = map[string]struct{}{ + "{{title}}": {}, + "{{message}}": {}, + "{{type}}": {}, + "{{nodeName}}": {}, + "{{timestamp}}": {}, +} + +type PreparedConfig struct { + Config string + SecretConfig string + DisplayName string +} + +func Prepare(rawConfig, status string, existing *model.AlertConfig) (PreparedConfig, error) { + if len(rawConfig) > maxConfigLength { + return PreparedConfig{}, fmt.Errorf("custom webhook config exceeds %d bytes", maxConfigLength) + } + var mutation dto.AlertCustomWebhookConfig + if err := decodeStrict(rawConfig, &mutation); err != nil { + return PreparedConfig{}, fmt.Errorf("decode custom webhook config: %w", err) + } + if mutation.SchemaVersion != dto.AlertCustomWebhookSchemaVersion { + return PreparedConfig{}, fmt.Errorf("unsupported custom webhook schemaVersion: %d", mutation.SchemaVersion) + } + if mutation.State != "" { + return PreparedConfig{}, fmt.Errorf("custom webhook state is read-only") + } + + existingSecret := dto.AlertCustomWebhookSecretConfig{ + SchemaVersion: dto.AlertCustomWebhookSchemaVersion, + Headers: map[string]string{}, + } + if existing != nil { + if existing.Type != constant.Custom { + return PreparedConfig{}, fmt.Errorf("alert config %d is not a custom webhook", existing.ID) + } + if mutationNeedsExistingSecret(mutation) { + var err error + existingSecret, err = decryptSecret(existing.SecretConfig) + if err != nil { + return PreparedConfig{}, err + } + } + } + + stored, secret, err := mergeAndValidate(mutation, status, existingSecret, existing != nil) + if err != nil { + return PreparedConfig{}, err + } + configData, err := json.Marshal(stored) + if err != nil { + return PreparedConfig{}, fmt.Errorf("encode custom webhook config: %w", err) + } + secretData, err := json.Marshal(secret) + if err != nil { + return PreparedConfig{}, fmt.Errorf("encode custom webhook secret: %w", err) + } + if len(configData) > maxConfigLength { + return PreparedConfig{}, fmt.Errorf("custom webhook config exceeds %d bytes", maxConfigLength) + } + if len(secretData) > maxSecretLength { + return PreparedConfig{}, fmt.Errorf("custom webhook secret exceeds %d bytes", maxSecretLength) + } + secretCipher, err := encryptSecretPlain(string(secretData)) + if err != nil { + return PreparedConfig{}, fmt.Errorf("encrypt custom webhook secret: %w", err) + } + + return PreparedConfig{ + Config: string(configData), + SecretConfig: secretCipher, + DisplayName: stored.DisplayName, + }, nil +} + +func mutationNeedsExistingSecret(mutation dto.AlertCustomWebhookConfig) bool { + if mutation.URL.Action == "keep" { + return true + } + for _, header := range mutation.Headers { + if header.Secret && header.Action == "keep" { + return true + } + } + return false +} + +type plainConfigView struct { + SchemaVersion int `json:"schemaVersion"` + State string `json:"state,omitempty"` + DisplayName string `json:"displayName"` + Preset string `json:"preset"` + Method string `json:"method"` + URL string `json:"url"` + Body dto.AlertCustomWebhookBody `json:"body"` + Headers []plainConfigViewHeader `json:"headers"` +} + +type plainConfigViewHeader struct { + UID string `json:"uid"` + Key string `json:"key"` + Secret bool `json:"secret"` + Value string `json:"value"` +} + +// PlainView decrypts a custom webhook only for the authenticated alert-config +// read contract. The encrypted SecretConfig remains the sole persisted copy. +func PlainView(config model.AlertConfig) (string, error) { + if config.Type != constant.Custom { + return config.Config, nil + } + stored, err := decodeStored(config.Config) + if err != nil { + if _, _, legacy := legacyValues(config.Config); legacy { + return config.Config, nil + } + return safeFallbackView(config, "", "invalid") + } + secret, err := validatedStoredSecret(config, stored) + if err != nil { + return safeFallbackView(config, stored.DisplayName, "invalid") + } + view := plainConfigView{ + SchemaVersion: stored.SchemaVersion, + DisplayName: stored.DisplayName, + Preset: stored.Preset, + Method: stored.Method, + URL: secret.URL, + Body: stored.Body, + Headers: make([]plainConfigViewHeader, 0, len(stored.Headers)), + } + for _, header := range stored.Headers { + value := header.Value + if header.Secret { + value = secret.Headers[header.UID] + } + view.Headers = append(view.Headers, plainConfigViewHeader{ + UID: header.UID, + Key: header.Key, + Secret: header.Secret, + Value: value, + }) + } + result, err := json.Marshal(view) + if err != nil { + return "", fmt.Errorf("encode custom webhook plain view: %w", err) + } + return string(result), nil +} + +func NormalizeLegacy(rawConfig, status, fallbackName string) (PreparedConfig, string, bool, error) { + displayName, rawURL, legacy := legacyValues(rawConfig) + if !legacy { + return PreparedConfig{}, status, false, nil + } + if displayName == "" { + displayName = strings.TrimSpace(fallbackName) + } + if displayName == "" || utf8.RuneCountInString(displayName) > maxDisplayNameRunes { + displayName = "Custom Webhook" + } + if status != constant.AlertEnable && status != constant.AlertDisable { + status = constant.AlertDisable + } + urlMutation := dto.AlertCustomWebhookSecretMutation{Action: "clear"} + if rawURL != "" { + if err := validateURL(rawURL); err == nil { + urlMutation = dto.AlertCustomWebhookSecretMutation{Action: "replace", Value: rawURL} + } else { + status = constant.AlertDisable + } + } else { + status = constant.AlertDisable + } + mutation := dto.AlertCustomWebhookConfig{ + SchemaVersion: dto.AlertCustomWebhookSchemaVersion, + DisplayName: displayName, + Preset: "genericJson", + Method: http.MethodPost, + URL: dto.AlertCustomWebhookURL{AlertCustomWebhookSecretMutation: urlMutation}, + Body: dto.AlertCustomWebhookBody{ + Type: "json", + Template: DefaultGenericJSONTemplate, + }, + Headers: make([]dto.AlertCustomWebhookHeader, 0), + } + data, err := json.Marshal(mutation) + if err != nil { + return PreparedConfig{}, status, true, fmt.Errorf("encode legacy custom webhook config: %w", err) + } + prepared, err := Prepare(string(data), status, nil) + return prepared, status, true, err +} + +func safeFallbackView(config model.AlertConfig, displayName, state string) (string, error) { + if displayName == "" { + displayName = strings.TrimSpace(config.Title) + } + if displayName == "" || utf8.RuneCountInString(displayName) > maxDisplayNameRunes { + displayName = "Custom Webhook" + } + view := dto.AlertCustomWebhookConfig{ + SchemaVersion: dto.AlertCustomWebhookSchemaVersion, + State: state, + DisplayName: displayName, + Preset: "genericJson", + Method: http.MethodPost, + URL: dto.AlertCustomWebhookURL{Configured: false}, + Body: dto.AlertCustomWebhookBody{ + Type: "json", + Template: DefaultGenericJSONTemplate, + }, + Headers: make([]dto.AlertCustomWebhookHeader, 0), + } + result, err := json.Marshal(view) + if err != nil { + return "", fmt.Errorf("encode custom webhook fallback view: %w", err) + } + return string(result), nil +} + +func legacyValues(rawConfig string) (string, string, bool) { + var raw map[string]json.RawMessage + if err := json.Unmarshal([]byte(rawConfig), &raw); err != nil || raw == nil || len(raw) != 2 { + return "", "", false + } + displayRaw, hasDisplayName := raw["displayName"] + urlRaw, hasURL := raw["url"] + if !hasDisplayName || !hasURL { + return "", "", false + } + var displayName, rawURL string + if err := json.Unmarshal(displayRaw, &displayName); err != nil { + return "", "", false + } + if err := json.Unmarshal(urlRaw, &rawURL); err != nil { + return "", "", false + } + return strings.TrimSpace(displayName), strings.TrimSpace(rawURL), true +} + +func Resolve(config model.AlertConfig) (dto.AlertCustomWebhookResolvedConfig, error) { + if config.Type != constant.Custom { + return dto.AlertCustomWebhookResolvedConfig{}, fmt.Errorf("alert config %d is not a custom webhook", config.ID) + } + stored, err := decodeStored(config.Config) + if err != nil { + return dto.AlertCustomWebhookResolvedConfig{}, err + } + secret, err := decryptSecret(config.SecretConfig) + if err != nil { + return dto.AlertCustomWebhookResolvedConfig{}, err + } + if err := validateURL(secret.URL); err != nil { + return dto.AlertCustomWebhookResolvedConfig{}, err + } + + resolved := dto.AlertCustomWebhookResolvedConfig{ + SchemaVersion: stored.SchemaVersion, + DisplayName: stored.DisplayName, + Preset: stored.Preset, + Method: stored.Method, + URL: secret.URL, + Body: stored.Body, + Headers: make([]dto.AlertCustomWebhookResolvedHeader, 0, len(stored.Headers)), + } + for _, header := range stored.Headers { + value := header.Value + if header.Secret { + value = secret.Headers[header.UID] + if value == "" && !header.Configured { + continue + } + if value == "" { + return dto.AlertCustomWebhookResolvedConfig{}, fmt.Errorf("secret header %q is not configured", header.Key) + } + } + resolved.Headers = append(resolved.Headers, dto.AlertCustomWebhookResolvedHeader{Key: header.Key, Value: value}) + } + return resolved, nil +} + +func ValidateStored(config model.AlertConfig) error { + if config.Type != constant.Custom { + return fmt.Errorf("alert config %d is not a custom webhook", config.ID) + } + stored, err := decodeStored(config.Config) + if err != nil { + return err + } + return validateStoredSecret(config, stored) +} + +func ExportSecretPlain(config model.AlertConfig) (string, error) { + if config.Type != constant.Custom { + return "", fmt.Errorf("alert config %d is not a custom webhook", config.ID) + } + plainText, err := decryptSecretPlain(config.SecretConfig) + if err != nil { + return "", err + } + secret, err := decodeAndValidateSecret(plainText) + if err != nil { + return "", err + } + canonical, err := json.Marshal(secret) + if err != nil { + return "", fmt.Errorf("encode custom webhook secret: %w", err) + } + return string(canonical), nil +} + +func ImportSecretPlain(plainText string) (string, error) { + secret, err := decodeAndValidateSecret(plainText) + if err != nil { + return "", err + } + canonical, err := json.Marshal(secret) + if err != nil { + return "", fmt.Errorf("encode custom webhook secret: %w", err) + } + return encryptSecretPlain(string(canonical)) +} + +func ReencryptSecret(cipherText string) (string, error) { + plainText, err := decryptSecretPlain(cipherText) + if err != nil { + return "", err + } + return ImportSecretPlain(plainText) +} + +func mergeAndValidate( + mutation dto.AlertCustomWebhookConfig, + status string, + existingSecret dto.AlertCustomWebhookSecretConfig, + hasExisting bool, +) (dto.AlertCustomWebhookConfig, dto.AlertCustomWebhookSecretConfig, error) { + mutation.DisplayName = strings.TrimSpace(mutation.DisplayName) + mutation.Preset = strings.TrimSpace(mutation.Preset) + if mutation.DisplayName == "" { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook displayName is required") + } + if utf8.RuneCountInString(mutation.DisplayName) > maxDisplayNameRunes { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook displayName exceeds %d characters", maxDisplayNameRunes) + } + if _, ok := supportedPresets[mutation.Preset]; !ok { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("unsupported custom webhook preset: %s", mutation.Preset) + } + if mutation.Method != http.MethodPost { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook method must be POST") + } + if err := validateBody(&mutation); err != nil { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, err + } + + secret := dto.AlertCustomWebhookSecretConfig{ + SchemaVersion: dto.AlertCustomWebhookSchemaVersion, + Headers: make(map[string]string), + } + switch mutation.URL.Action { + case "keep": + if !hasExisting || existingSecret.URL == "" { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook URL cannot be kept because it is not configured") + } + secret.URL = existingSecret.URL + case "replace": + secret.URL = strings.TrimSpace(mutation.URL.Value) + if err := validateURL(secret.URL); err != nil { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, err + } + case "clear": + secret.URL = "" + default: + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook URL action must be keep, replace, or clear") + } + if secret.URL == "" && status != constant.AlertDisable { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook URL is required while the config is enabled") + } + mutation.URL = dto.AlertCustomWebhookURL{Configured: secret.URL != ""} + + if len(mutation.Headers) > maxHeaders { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook headers exceed the limit of %d", maxHeaders) + } + seenUIDs := make(map[string]struct{}, len(mutation.Headers)) + seenKeys := make(map[string]struct{}, len(mutation.Headers)) + storedHeaders := make([]dto.AlertCustomWebhookHeader, 0, len(mutation.Headers)) + for _, header := range mutation.Headers { + header.UID = strings.TrimSpace(header.UID) + if header.UID == "" { + header.UID = uuid.NewString() + } else if _, err := uuid.Parse(header.UID); err != nil { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook header uid must be a UUID") + } + if _, exists := seenUIDs[header.UID]; exists { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("duplicate custom webhook header uid: %s", header.UID) + } + seenUIDs[header.UID] = struct{}{} + + header.Key = http.CanonicalHeaderKey(strings.TrimSpace(header.Key)) + if err := validateHeaderName(header.Key); err != nil { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, err + } + if isSensitiveHeaderName(header.Key) && !header.Secret { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook header %q must be marked secret", header.Key) + } + keyIdentity := strings.ToLower(header.Key) + if _, exists := seenKeys[keyIdentity]; exists { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("duplicate custom webhook header: %s", header.Key) + } + seenKeys[keyIdentity] = struct{}{} + + storedHeader := dto.AlertCustomWebhookHeader{UID: header.UID, Key: header.Key, Secret: header.Secret} + if header.Secret { + switch header.Action { + case "keep": + value, ok := existingSecret.Headers[header.UID] + if !hasExisting || !ok || value == "" { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("secret header %q cannot be kept because it is not configured", header.Key) + } + secret.Headers[header.UID] = value + case "replace": + if header.Value == "" { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("secret header %q value is required", header.Key) + } + if err := validateHeaderValue(header.Value); err != nil { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("header %q value is invalid", header.Key) + } + secret.Headers[header.UID] = header.Value + case "clear": + default: + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("secret header %q action must be keep, replace, or clear", header.Key) + } + storedHeader.Configured = secret.Headers[header.UID] != "" + } else { + if header.Action != "replace" { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("non-secret header %q action must be replace", header.Key) + } + if err := validateHeaderValue(header.Value); err != nil { + return dto.AlertCustomWebhookConfig{}, dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("header %q value is invalid", header.Key) + } + storedHeader.Value = header.Value + } + storedHeaders = append(storedHeaders, storedHeader) + } + mutation.Headers = storedHeaders + mutation.SchemaVersion = dto.AlertCustomWebhookSchemaVersion + return mutation, secret, nil +} + +func validateStoredSecret(config model.AlertConfig, stored dto.AlertCustomWebhookConfig) error { + _, err := validatedStoredSecret(config, stored) + return err +} + +func validatedStoredSecret(config model.AlertConfig, stored dto.AlertCustomWebhookConfig) (dto.AlertCustomWebhookSecretConfig, error) { + if config.Status != constant.AlertEnable && config.Status != constant.AlertDisable { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook status is invalid") + } + if strings.TrimSpace(config.SecretConfig) == "" { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook secret is missing") + } + secret, err := decryptSecret(config.SecretConfig) + if err != nil { + return dto.AlertCustomWebhookSecretConfig{}, err + } + if secret.URL != "" { + if err := validateURL(secret.URL); err != nil { + return dto.AlertCustomWebhookSecretConfig{}, err + } + } + if stored.URL.Configured != (secret.URL != "") { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook URL state does not match its secret") + } + if config.Status == constant.AlertEnable && secret.URL == "" { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook URL is required while the config is enabled") + } + usedSecrets := make(map[string]struct{}, len(stored.Headers)) + for _, header := range stored.Headers { + if !header.Secret { + continue + } + value, configured := secret.Headers[header.UID] + if header.Configured != (configured && value != "") { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook secret header state is inconsistent") + } + if configured { + usedSecrets[header.UID] = struct{}{} + } + } + if len(usedSecrets) != len(secret.Headers) { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("stored custom webhook contains orphaned header secrets") + } + return secret, nil +} + +func validateBody(config *dto.AlertCustomWebhookConfig) error { + config.Body.Type = strings.TrimSpace(config.Body.Type) + if config.Preset != "custom" && config.Body.Type != "json" { + return fmt.Errorf("custom webhook preset %s requires a JSON body", config.Preset) + } + switch config.Body.Type { + case "json": + if config.Body.Template == "" { + return fmt.Errorf("json custom webhook body template is required") + } + if len(config.Body.Template) > maxBodyLength { + return fmt.Errorf("custom webhook body exceeds %d bytes", maxBodyLength) + } + if !json.Valid([]byte(config.Body.Template)) { + return fmt.Errorf("custom webhook JSON body template must be valid JSON") + } + var document any + if err := json.Unmarshal([]byte(config.Body.Template), &document); err != nil { + return fmt.Errorf("custom webhook JSON body template must be valid JSON") + } + if containsTemplateJSONKey(document) { + return fmt.Errorf("custom webhook JSON body keys cannot contain template variables") + } + if err := validateTemplateVariables(config.Body.Template); err != nil { + return err + } + if len(config.Body.Fields) != 0 { + return fmt.Errorf("json custom webhook body does not accept form fields") + } + case "form": + if config.Body.Template != "" { + return fmt.Errorf("form custom webhook body does not accept a template") + } + if len(config.Body.Fields) == 0 { + return fmt.Errorf("form custom webhook body requires at least one field") + } + if len(config.Body.Fields) > maxFormFields { + return fmt.Errorf("custom webhook form fields exceed the limit of %d", maxFormFields) + } + seen := make(map[string]struct{}, len(config.Body.Fields)) + for index := range config.Body.Fields { + field := &config.Body.Fields[index] + field.Key = strings.TrimSpace(field.Key) + if field.Key == "" { + return fmt.Errorf("custom webhook form field key is required") + } + if strings.Contains(field.Key, "{{") || strings.Contains(field.Key, "}}") { + return fmt.Errorf("custom webhook form field keys cannot contain template variables") + } + if len(field.Key) > maxHeaderNameLength || len(field.Value) > maxHeaderValueLen { + return fmt.Errorf("custom webhook form field %q is too long", field.Key) + } + identity := strings.ToLower(field.Key) + if _, exists := seen[identity]; exists { + return fmt.Errorf("duplicate custom webhook form field: %s", field.Key) + } + seen[identity] = struct{}{} + if err := validateTemplateVariables(field.Value); err != nil { + return err + } + } + case "text": + if config.Body.Template == "" { + return fmt.Errorf("text custom webhook body template is required") + } + if len(config.Body.Template) > maxBodyLength { + return fmt.Errorf("custom webhook body exceeds %d bytes", maxBodyLength) + } + if err := validateTemplateVariables(config.Body.Template); err != nil { + return err + } + if len(config.Body.Fields) != 0 { + return fmt.Errorf("text custom webhook body does not accept form fields") + } + default: + return fmt.Errorf("custom webhook body type must be json, form, or text") + } + return nil +} + +func containsTemplateJSONKey(value any) bool { + switch typed := value.(type) { + case map[string]any: + for key, child := range typed { + if strings.Contains(key, "{{") || strings.Contains(key, "}}") || containsTemplateJSONKey(child) { + return true + } + } + case []any: + for _, child := range typed { + if containsTemplateJSONKey(child) { + return true + } + } + } + return false +} + +func validateTemplateVariables(template string) error { + remainder := template + for { + start := strings.Index(remainder, "{{") + if start < 0 { + if strings.Contains(remainder, "}}") { + return fmt.Errorf("custom webhook body contains a malformed template variable") + } + return nil + } + if strings.Contains(remainder[:start], "}}") { + return fmt.Errorf("custom webhook body contains a malformed template variable") + } + endOffset := strings.Index(remainder[start+2:], "}}") + if endOffset < 0 { + return fmt.Errorf("custom webhook body contains a malformed template variable") + } + end := start + 2 + endOffset + 2 + variable := remainder[start:end] + if _, supported := supportedTemplateVariables[variable]; !supported { + return fmt.Errorf("custom webhook body contains an unsupported template variable") + } + remainder = remainder[end:] + } +} + +func ContentTypeForBodyType(bodyType string) (string, error) { + switch bodyType { + case "json": + return "application/json", nil + case "form": + return "application/x-www-form-urlencoded", nil + case "text": + return "text/plain", nil + default: + return "", fmt.Errorf("custom webhook body type must be json, form, or text") + } +} + +func validateURL(rawURL string) error { + if rawURL == "" { + return fmt.Errorf("custom webhook URL is required") + } + if len(rawURL) > maxURLLength { + return fmt.Errorf("custom webhook URL exceeds %d bytes", maxURLLength) + } + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return fmt.Errorf("invalid custom webhook URL") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return fmt.Errorf("custom webhook URL scheme must be http or https") + } + hostname := strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), ".") + if parsed.Host == "" || hostname == "" || strings.Contains(hostname, "%") { + return fmt.Errorf("custom webhook URL host is required") + } + if strings.HasSuffix(parsed.Host, ":") { + return fmt.Errorf("custom webhook URL port is invalid") + } + if port := parsed.Port(); port != "" { + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > 65535 { + return fmt.Errorf("custom webhook URL port is invalid") + } + } + if parsed.User != nil { + return fmt.Errorf("custom webhook URL must not contain user info") + } + if parsed.Fragment != "" { + return fmt.Errorf("custom webhook URL must not contain a fragment") + } + return nil +} + +func validateHeaderName(name string) error { + if name == "" { + return fmt.Errorf("custom webhook header name is required") + } + if len(name) > maxHeaderNameLength { + return fmt.Errorf("custom webhook header name is too long") + } + for index := 0; index < len(name); index++ { + if !isHeaderTokenByte(name[index]) { + return fmt.Errorf("invalid custom webhook header name: %s", name) + } + } + if _, forbidden := forbiddenHeaders[strings.ToLower(name)]; forbidden { + return fmt.Errorf("custom webhook header %q is managed by the sender", name) + } + return nil +} + +func validateHeaderValue(value string) error { + if len(value) > maxHeaderValueLen || strings.ContainsAny(value, "\r\n") { + return fmt.Errorf("invalid custom webhook header value") + } + return nil +} + +func isSensitiveHeaderName(name string) bool { + normalized := strings.ToLower(strings.TrimSpace(name)) + compact := strings.NewReplacer("-", "", "_", "").Replace(normalized) + return normalized == "authorization" || + normalized == "cookie" || + strings.Contains(compact, "token") || + strings.Contains(compact, "secret") || + strings.Contains(compact, "signature") || + strings.Contains(compact, "apikey") || + strings.HasSuffix(normalized, "-key") || + strings.HasSuffix(normalized, "_key") +} + +func isHeaderTokenByte(char byte) bool { + if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' { + return true + } + return strings.ContainsRune("!#$%&'*+-.^_`|~", rune(char)) +} + +func decodeStored(rawConfig string) (dto.AlertCustomWebhookConfig, error) { + if len(rawConfig) > maxConfigLength { + return dto.AlertCustomWebhookConfig{}, fmt.Errorf("stored custom webhook config exceeds %d bytes", maxConfigLength) + } + var stored dto.AlertCustomWebhookConfig + if err := decodeStrict(rawConfig, &stored); err != nil { + return dto.AlertCustomWebhookConfig{}, fmt.Errorf("decode stored custom webhook config: %w", err) + } + if stored.SchemaVersion != dto.AlertCustomWebhookSchemaVersion { + return dto.AlertCustomWebhookConfig{}, fmt.Errorf("unsupported stored custom webhook schemaVersion: %d", stored.SchemaVersion) + } + if stored.State != "" { + return dto.AlertCustomWebhookConfig{}, fmt.Errorf("stored custom webhook contains a view state") + } + if stored.URL.Action != "" || stored.URL.Value != "" { + return dto.AlertCustomWebhookConfig{}, fmt.Errorf("stored custom webhook URL contains a mutation") + } + for _, header := range stored.Headers { + if header.Action != "" || header.Secret && header.Value != "" { + return dto.AlertCustomWebhookConfig{}, fmt.Errorf("stored custom webhook header %q contains a mutation", header.Key) + } + } + if err := validateStoredConfig(&stored); err != nil { + return dto.AlertCustomWebhookConfig{}, err + } + return stored, nil +} + +func validateStoredConfig(config *dto.AlertCustomWebhookConfig) error { + config.DisplayName = strings.TrimSpace(config.DisplayName) + if config.DisplayName == "" || utf8.RuneCountInString(config.DisplayName) > maxDisplayNameRunes { + return fmt.Errorf("stored custom webhook displayName is invalid") + } + if _, ok := supportedPresets[config.Preset]; !ok { + return fmt.Errorf("unsupported stored custom webhook preset: %s", config.Preset) + } + if config.Method != http.MethodPost { + return fmt.Errorf("stored custom webhook method must be POST") + } + if err := validateBody(config); err != nil { + return err + } + seenUIDs := make(map[string]struct{}, len(config.Headers)) + seenKeys := make(map[string]struct{}, len(config.Headers)) + for index := range config.Headers { + header := &config.Headers[index] + if header.UID == "" { + return fmt.Errorf("stored custom webhook header uid is required") + } + if _, err := uuid.Parse(header.UID); err != nil { + return fmt.Errorf("stored custom webhook header uid must be a UUID") + } + if _, exists := seenUIDs[header.UID]; exists { + return fmt.Errorf("duplicate stored custom webhook header uid: %s", header.UID) + } + seenUIDs[header.UID] = struct{}{} + header.Key = http.CanonicalHeaderKey(strings.TrimSpace(header.Key)) + if err := validateHeaderName(header.Key); err != nil { + return err + } + if isSensitiveHeaderName(header.Key) && !header.Secret { + return fmt.Errorf("stored custom webhook header %q must be marked secret", header.Key) + } + identity := strings.ToLower(header.Key) + if _, exists := seenKeys[identity]; exists { + return fmt.Errorf("duplicate stored custom webhook header: %s", header.Key) + } + seenKeys[identity] = struct{}{} + if !header.Secret { + if err := validateHeaderValue(header.Value); err != nil { + return fmt.Errorf("stored header %q value is invalid", header.Key) + } + } + } + return nil +} + +func decryptSecret(cipherText string) (dto.AlertCustomWebhookSecretConfig, error) { + if strings.TrimSpace(cipherText) == "" { + return dto.AlertCustomWebhookSecretConfig{ + SchemaVersion: dto.AlertCustomWebhookSchemaVersion, + Headers: map[string]string{}, + }, nil + } + plainText, err := decryptSecretPlain(cipherText) + if err != nil { + return dto.AlertCustomWebhookSecretConfig{}, err + } + return decodeAndValidateSecret(plainText) +} + +func encryptSecretPlain(plainText string) (string, error) { + coreKey, coreAvailable, err := loadCoreEncryptKey() + if err != nil { + return "", err + } + if coreAvailable { + cipherText, err := encrypt.StringEncryptWithKey(plainText, coreKey) + if err != nil { + return "", fmt.Errorf("encrypt custom webhook secret with core key: %w", err) + } + return coreKeyPrefix + cipherText, nil + } + key, err := loadAgentEncryptKey() + if err != nil { + return "", err + } + cipherText, err := encrypt.StringEncryptWithKey(plainText, key) + if err != nil { + return "", fmt.Errorf("encrypt custom webhook secret with agent key: %w", err) + } + return agentKeyPrefix + cipherText, nil +} + +func decryptSecretPlain(cipherText string) (string, error) { + if strings.TrimSpace(cipherText) == "" { + return "", fmt.Errorf("custom webhook secret is empty") + } + var ( + payload string + key string + err error + ) + switch { + case strings.HasPrefix(cipherText, coreKeyPrefix): + payload = strings.TrimPrefix(cipherText, coreKeyPrefix) + var available bool + key, available, err = loadCoreEncryptKey() + if err != nil { + return "", err + } + if !available { + return "", fmt.Errorf("decrypt custom webhook secret: core encrypt key is unavailable") + } + case strings.HasPrefix(cipherText, agentKeyPrefix): + payload = strings.TrimPrefix(cipherText, agentKeyPrefix) + key, err = loadAgentEncryptKey() + if err != nil { + return "", err + } + default: + payload = cipherText + key, err = loadAgentEncryptKey() + if err != nil { + return "", err + } + } + if payload == "" { + return "", fmt.Errorf("decrypt custom webhook secret: ciphertext is empty") + } + plainText, err := encrypt.StringDecryptWithKey(payload, key) + if err != nil { + return "", fmt.Errorf("decrypt custom webhook secret: %w", err) + } + return plainText, nil +} + +func loadCoreEncryptKey() (string, bool, error) { + if global.CoreDB == nil { + return "", false, nil + } + var setting model.Setting + if err := global.CoreDB.Where("key = ?", "EncryptKey").First(&setting).Error; err != nil { + return "", true, fmt.Errorf("custom webhook core encrypt key is unavailable") + } + key := strings.TrimSpace(setting.Value) + if key == "" { + return "", true, fmt.Errorf("custom webhook core encrypt key is empty") + } + return key, true, nil +} + +func loadAgentEncryptKey() (string, error) { + if key := strings.TrimSpace(global.CONF.Base.EncryptKey); key != "" { + return key, nil + } + if global.DB != nil { + var setting model.Setting + if err := global.DB.Where("key = ?", "EncryptKey").First(&setting).Error; err == nil { + if key := strings.TrimSpace(setting.Value); key != "" { + return key, nil + } + } + } + return "", fmt.Errorf("custom webhook agent encrypt key is empty") +} + +func decodeAndValidateSecret(plainText string) (dto.AlertCustomWebhookSecretConfig, error) { + if len(plainText) > maxSecretLength { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret exceeds %d bytes", maxSecretLength) + } + var secret dto.AlertCustomWebhookSecretConfig + if err := decodeStrict(plainText, &secret); err != nil { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("decode custom webhook secret: %w", err) + } + if secret.SchemaVersion != dto.AlertCustomWebhookSchemaVersion { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("unsupported custom webhook secret schemaVersion: %d", secret.SchemaVersion) + } + if secret.URL != "" { + if err := validateURL(secret.URL); err != nil { + return dto.AlertCustomWebhookSecretConfig{}, err + } + } + if secret.Headers == nil { + secret.Headers = map[string]string{} + } + if len(secret.Headers) > maxHeaders { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret headers exceed the limit of %d", maxHeaders) + } + for uid, value := range secret.Headers { + if _, err := uuid.Parse(strings.TrimSpace(uid)); err != nil { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret header uid must be a UUID") + } + if value == "" { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret header %q is empty", uid) + } + if err := validateHeaderValue(value); err != nil { + return dto.AlertCustomWebhookSecretConfig{}, fmt.Errorf("custom webhook secret header %q is invalid", uid) + } + } + return secret, nil +} + +func decodeStrict(raw string, target any) error { + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return fmt.Errorf("multiple JSON values are not allowed") + } + return err + } + return nil +} diff --git a/agent/utils/webhook_sender/render.go b/agent/utils/webhook_sender/render.go new file mode 100644 index 000000000..ad91eda58 --- /dev/null +++ b/agent/utils/webhook_sender/render.go @@ -0,0 +1,185 @@ +package webhook_sender + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "net/url" + "strings" + "time" +) + +const MaxRenderedBodyBytes = 256 * 1024 + +type TemplateData struct { + Title string + Message string + Type string + Timestamp time.Time + NodeName string +} + +type FormField struct { + Key string + Value string +} + +type RenderRequest struct { + Format BodyFormat + Template string + Fields []FormField + Data TemplateData +} + +func RenderBody(input RenderRequest) ([]byte, error) { + values := templateValues(input.Data) + var rendered []byte + var err error + switch input.Format { + case BodyJSON: + rendered, err = renderJSONTemplate(input.Template, values) + if err != nil { + return nil, errors.New("rendered webhook JSON body is invalid") + } + case BodyText: + var text string + text, err = renderRestrictedTemplate(input.Template, values) + if err != nil { + return nil, err + } + rendered = []byte(text) + case BodyForm: + form := make(url.Values, len(input.Fields)) + for _, field := range input.Fields { + if strings.Contains(field.Key, "{{") || strings.Contains(field.Key, "}}") { + return nil, errors.New("webhook form field key cannot contain template variables") + } + value, err := renderRestrictedTemplate(field.Value, values) + if err != nil { + return nil, err + } + form.Add(field.Key, value) + } + rendered = []byte(form.Encode()) + default: + return nil, errors.New("unsupported webhook body format") + } + if len(rendered) > MaxRenderedBodyBytes { + return nil, errors.New("rendered webhook body exceeded size limit") + } + return rendered, nil +} + +func ResolvePreset(value string) (Preset, error) { + switch strings.TrimSpace(value) { + case "", "generic", "genericJson", "custom": + return PresetGeneric, nil + case "slack": + return PresetSlack, nil + case "discord": + return PresetDiscord, nil + case "teams", "teamsWorkflows": + return PresetTeams, nil + default: + return "", errors.New("unsupported webhook preset") + } +} + +func templateValues(data TemplateData) map[string]string { + timestamp := data.Timestamp + if timestamp.IsZero() { + timestamp = time.Now() + } + return map[string]string{ + "title": data.Title, + "message": data.Message, + "type": data.Type, + "timestamp": timestamp.Format(time.RFC3339), + "nodeName": data.NodeName, + } +} + +func renderJSONTemplate(source string, values map[string]string) ([]byte, error) { + decoder := json.NewDecoder(strings.NewReader(source)) + decoder.UseNumber() + var document any + if err := decoder.Decode(&document); err != nil { + return nil, errors.New("invalid webhook JSON template") + } + if err := ensureJSONEOF(decoder); err != nil { + return nil, err + } + rendered, err := renderJSONValue(document, values) + if err != nil { + return nil, err + } + result, err := json.Marshal(rendered) + if err != nil { + return nil, errors.New("marshal rendered webhook JSON body failed") + } + return result, nil +} + +func ensureJSONEOF(decoder *json.Decoder) error { + var extra any + if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) { + return errors.New("invalid webhook JSON template") + } + return nil +} + +func renderJSONValue(value any, values map[string]string) (any, error) { + switch typed := value.(type) { + case string: + return renderRestrictedTemplate(typed, values) + case []any: + for index := range typed { + rendered, err := renderJSONValue(typed[index], values) + if err != nil { + return nil, err + } + typed[index] = rendered + } + return typed, nil + case map[string]any: + for key, item := range typed { + rendered, err := renderJSONValue(item, values) + if err != nil { + return nil, err + } + typed[key] = rendered + } + return typed, nil + default: + return value, nil + } +} + +func renderRestrictedTemplate(source string, values map[string]string) (string, error) { + var output bytes.Buffer + for len(source) != 0 { + start := strings.Index(source, "{{") + if start == -1 { + if strings.Contains(source, "}}") { + return "", errors.New("invalid webhook template placeholder") + } + output.WriteString(source) + break + } + output.WriteString(source[:start]) + source = source[start+2:] + end := strings.Index(source, "}}") + if end == -1 { + return "", errors.New("invalid webhook template placeholder") + } + name := strings.TrimSpace(source[:end]) + value, ok := values[name] + if !ok || name == "" || strings.Contains(name, "{{") { + return "", errors.New("unsupported webhook template variable") + } + output.WriteString(value) + source = source[end+2:] + } + return output.String(), nil +} diff --git a/agent/utils/webhook_sender/request.go b/agent/utils/webhook_sender/request.go new file mode 100644 index 000000000..51426d1da --- /dev/null +++ b/agent/utils/webhook_sender/request.go @@ -0,0 +1,767 @@ +package webhook_sender + +import ( + "bufio" + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/netip" + "net/url" + "sort" + "strings" + "time" + "unicode/utf8" +) + +const ( + RequestTimeout = 10 * time.Second + MaxResponseBodyBytes = 64 * 1024 + MaxCapturedResponseBytes = 2 * 1024 +) + +var loadWebhookSystemCertPool = x509.SystemCertPool + +type Preset string + +const ( + PresetGeneric Preset = "generic" + PresetSlack Preset = "slack" + PresetDiscord Preset = "discord" + PresetTeams Preset = "teams" +) + +type BodyFormat string + +const ( + BodyJSON BodyFormat = "json" + BodyForm BodyFormat = "form" + BodyText BodyFormat = "text" +) + +type Request struct { + URL string + Preset Preset + Format BodyFormat + Body []byte + Headers map[string]string + Transport *http.Transport + Resolver IPResolver + CaptureResponse bool +} + +type IPResolver interface { + LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error) +} + +type Result struct { + StatusCode int + ResponseSize int + Duration time.Duration + Response string +} + +func Execute(ctx context.Context, input Request) (Result, error) { + if ctx == nil { + ctx = context.Background() + } + + contentType, err := validateRenderedBody(input.Format, input.Body) + if err != nil { + return Result{}, err + } + if err := validateHeaders(input.Headers); err != nil { + return Result{}, err + } + requestContext, cancel := context.WithTimeout(ctx, RequestTimeout) + defer cancel() + + target, err := prepareTarget(requestContext, input.URL, input.Preset, input.Resolver) + if err != nil { + return Result{}, errors.New("invalid webhook request URL") + } + + req, err := http.NewRequestWithContext(requestContext, http.MethodPost, target.URL, bytes.NewReader(input.Body)) + if err != nil { + return Result{}, errors.New("create webhook request failed") + } + req.Host = target.HostHeader + for name, value := range input.Headers { + req.Header.Set(name, value) + } + + req.Header.Set("Content-Type", contentType) + + transport := systemTLSTransport(input.Transport, target.ServerName, target.OriginalURL, target.Addresses) + var roundTripper http.RoundTripper = transport + if target.OriginalURL != nil && target.OriginalURL.Scheme == "http" { + roundTripper = &pinnedHTTPProxyRoundTripper{transport: transport, originalHost: target.HostHeader} + } + client := &http.Client{ + Timeout: RequestTimeout, + Transport: roundTripper, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + startedAt := time.Now() + resp, err := client.Do(req) + duration := time.Since(startedAt) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + return Result{Duration: duration}, errors.New("webhook request timed out") + } + if errors.Is(err, context.Canceled) { + return Result{Duration: duration}, errors.New("webhook request canceled") + } + return Result{Duration: duration}, errors.New("webhook request failed") + } + defer resp.Body.Close() + + responseBody, err := io.ReadAll(io.LimitReader(resp.Body, MaxResponseBodyBytes+1)) + duration = time.Since(startedAt) + result := Result{ + StatusCode: resp.StatusCode, + ResponseSize: len(responseBody), + Duration: duration, + } + if input.CaptureResponse { + result.Response = captureResponse(responseBody, input, target) + } + if err != nil { + return result, errors.New("read webhook response failed") + } + if len(responseBody) > MaxResponseBodyBytes { + return result, errors.New("webhook response exceeded size limit") + } + + if err := validateResponse(input.Preset, resp.StatusCode, responseBody); err != nil { + return result, err + } + return result, nil +} + +type pinnedHTTPProxyRoundTripper struct { + transport *http.Transport + originalHost string +} + +func (p *pinnedHTTPProxyRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + if p.transport.Proxy == nil { + return p.transport.RoundTrip(request) + } + proxyURL, err := p.transport.Proxy(request) + if err != nil { + return nil, errors.New("select webhook proxy failed") + } + if proxyURL == nil { + direct := p.transport.Clone() + direct.Proxy = nil + return direct.RoundTrip(request) + } + return p.roundTripProxy(request, proxyURL) +} + +func (p *pinnedHTTPProxyRoundTripper) roundTripProxy(request *http.Request, proxyURL *url.URL) (*http.Response, error) { + proxyAddress, err := proxyDialAddress(proxyURL) + if err != nil { + return nil, err + } + dialContext := p.transport.DialContext + if dialContext == nil { + dialContext = (&net.Dialer{}).DialContext + } + connection, err := dialContext(request.Context(), "tcp", proxyAddress) + if err != nil { + return nil, errors.New("connect webhook proxy failed") + } + closeConnection := true + defer func() { + if closeConnection { + _ = connection.Close() + } + }() + if deadline, ok := request.Context().Deadline(); ok { + _ = connection.SetDeadline(deadline) + } + if proxyURL.Scheme == "https" { + proxyTLSConfig := &tls.Config{ServerName: proxyURL.Hostname()} + if roots, rootsErr := loadWebhookSystemCertPool(); rootsErr == nil { + proxyTLSConfig.RootCAs = roots + } + tlsConnection := tls.Client(connection, proxyTLSConfig) + if err := tlsConnection.HandshakeContext(request.Context()); err != nil { + return nil, errors.New("connect webhook proxy failed") + } + connection = tlsConnection + } + + connectAuthority, err := pinnedHTTPConnectAuthority(request.URL) + if err != nil { + return nil, err + } + connectRequest := &http.Request{ + Method: http.MethodConnect, + URL: &url.URL{Opaque: connectAuthority}, + Host: connectAuthority, + Header: make(http.Header), + } + if err := p.addProxyHeaders(connectRequest, proxyURL); err != nil { + return nil, err + } + if err := connectRequest.Write(connection); err != nil { + return nil, errors.New("connect webhook proxy failed") + } + reader := bufio.NewReader(connection) + connectResponse, err := http.ReadResponse(reader, connectRequest) + if err != nil { + return nil, errors.New("connect webhook proxy failed") + } + if p.transport.OnProxyConnectResponse != nil { + if err := p.transport.OnProxyConnectResponse(request.Context(), proxyURL, connectRequest, connectResponse); err != nil { + return nil, errors.New("connect webhook proxy failed") + } + } + if connectResponse.StatusCode != http.StatusOK { + return nil, errors.New("connect webhook proxy failed") + } + _ = connectResponse.Body.Close() + + wireRequest := request.Clone(request.Context()) + wireRequest.URL = cloneURL(request.URL) + wireRequest.Host = p.originalHost + wireRequest.Close = true + wireRequest.Header = request.Header.Clone() + if request.GetBody != nil { + body, bodyErr := request.GetBody() + if bodyErr != nil { + return nil, errors.New("prepare webhook proxy request failed") + } + wireRequest.Body = body + defer body.Close() + } + if err := wireRequest.Write(connection); err != nil { + return nil, errors.New("send webhook proxy request failed") + } + response, err := http.ReadResponse(reader, request) + if err != nil { + return nil, errors.New("read webhook proxy response failed") + } + response.Body = &proxyConnectionBody{ReadCloser: response.Body, connection: connection} + closeConnection = false + return response, nil +} + +func (p *pinnedHTTPProxyRoundTripper) addProxyHeaders(request *http.Request, proxyURL *url.URL) error { + for name, values := range p.transport.ProxyConnectHeader { + request.Header[name] = append([]string(nil), values...) + } + if p.transport.GetProxyConnectHeader != nil { + target := request.URL.Host + if target == "" { + target = request.Host + } + headers, err := p.transport.GetProxyConnectHeader(request.Context(), proxyURL, target) + if err != nil { + return errors.New("prepare webhook proxy request failed") + } + for name, values := range headers { + request.Header[name] = append([]string(nil), values...) + } + } + if proxyURL.User != nil && request.Header.Get("Proxy-Authorization") == "" { + password, _ := proxyURL.User.Password() + credential := base64.StdEncoding.EncodeToString([]byte(proxyURL.User.Username() + ":" + password)) + request.Header.Set("Proxy-Authorization", "Basic "+credential) + } + return nil +} + +func pinnedHTTPConnectAuthority(target *url.URL) (string, error) { + if target == nil || target.Hostname() == "" { + return "", errors.New("prepare webhook proxy request failed") + } + port := target.Port() + if port == "" { + port = "80" + } + return net.JoinHostPort(target.Hostname(), port), nil +} + +func proxyDialAddress(proxyURL *url.URL) (string, error) { + if proxyURL == nil || (proxyURL.Scheme != "http" && proxyURL.Scheme != "https") || proxyURL.Hostname() == "" { + return "", errors.New("unsupported webhook proxy") + } + port := proxyURL.Port() + if port == "" { + if proxyURL.Scheme == "https" { + port = "443" + } else { + port = "80" + } + } + return net.JoinHostPort(proxyURL.Hostname(), port), nil +} + +func cloneURL(source *url.URL) *url.URL { + if source == nil { + return &url.URL{} + } + cloned := *source + return &cloned +} + +type proxyConnectionBody struct { + io.ReadCloser + connection net.Conn +} + +func (b *proxyConnectionBody) Close() error { + bodyErr := b.ReadCloser.Close() + connectionErr := b.connection.Close() + if bodyErr != nil { + return bodyErr + } + return connectionErr +} + +func captureResponse(body []byte, input Request, target preparedTarget) string { + response := strings.ToValidUTF8(string(body), "\uFFFD") + redactions := make([]string, 0, len(input.Headers)+8) + redactions = append(redactions, urlRedactionValues(input.URL)...) + redactions = append(redactions, urlRedactionValues(target.URL)...) + if target.OriginalURL != nil { + redactions = append(redactions, urlRedactionValues(target.OriginalURL.String())...) + } + for name, value := range input.Headers { + if value != "" { + redactions = append(redactions, value) + } + redactions = append(redactions, headerRedactionValues(name, value)...) + } + sort.SliceStable(redactions, func(i, j int) bool { + return len(redactions[i]) > len(redactions[j]) + }) + for _, value := range redactions { + if value != "" { + response = strings.ReplaceAll(response, value, "[REDACTED]") + } + } + return truncateUTF8(response, MaxCapturedResponseBytes) +} + +func urlRedactionValues(rawURL string) []string { + trimmed := strings.TrimSpace(rawURL) + values := []string{trimmed} + parsed, err := url.Parse(trimmed) + if err != nil { + return values + } + values = append(values, parsed.String()) + for _, segment := range strings.Split(strings.Trim(parsed.EscapedPath(), "/"), "/") { + if segment == "" { + continue + } + values = append(values, segment) + if decoded, err := url.PathUnescape(segment); err == nil && decoded != segment { + values = append(values, decoded) + } + } + for _, pair := range strings.Split(parsed.RawQuery, "&") { + if pair == "" { + continue + } + encodedName, encodedValue, found := strings.Cut(pair, "=") + if !found { + values = append(values, encodedName) + if decoded, err := url.QueryUnescape(encodedName); err == nil && decoded != encodedName { + values = append(values, decoded) + } + continue + } + if encodedValue == "" { + continue + } + values = append(values, encodedValue) + if decoded, err := url.QueryUnescape(encodedValue); err == nil && decoded != encodedValue { + values = append(values, decoded) + } + } + return values +} + +func headerRedactionValues(name, value string) []string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil + } + var values []string + switch http.CanonicalHeaderKey(name) { + case "Authorization", "Proxy-Authorization": + if _, credential, found := strings.Cut(trimmed, " "); found { + credential = strings.TrimSpace(credential) + if credential != "" { + values = append(values, credential) + } + } + case "Cookie": + for _, cookie := range strings.Split(trimmed, ";") { + _, cookieValue, found := strings.Cut(cookie, "=") + if !found { + continue + } + cookieValue = strings.Trim(strings.TrimSpace(cookieValue), `"`) + if cookieValue != "" { + values = append(values, cookieValue) + } + } + } + return values +} + +func truncateUTF8(value string, maxBytes int) string { + if len(value) <= maxBytes { + return value + } + value = value[:maxBytes] + for !utf8.ValidString(value) { + value = value[:len(value)-1] + } + return value +} + +type preparedTarget struct { + URL string + HostHeader string + ServerName string + OriginalURL *url.URL + Addresses []net.IP +} + +func prepareTarget(ctx context.Context, rawURL string, preset Preset, resolver IPResolver) (preparedTarget, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.Fragment != "" { + return preparedTarget{}, errors.New("invalid URL") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return preparedTarget{}, errors.New("unsupported URL scheme") + } + if preset == PresetDiscord { + query := parsed.Query() + query.Set("wait", "true") + parsed.RawQuery = query.Encode() + } + + hostname := strings.TrimSuffix(parsed.Hostname(), ".") + if hostname == "" || strings.Contains(hostname, "%") { + return preparedTarget{}, errors.New("invalid URL host") + } + addresses, err := resolveAddresses(ctx, hostname, resolver) + if err != nil || len(addresses) == 0 { + return preparedTarget{}, errors.New("resolve webhook URL failed") + } + for _, address := range addresses { + if !publicWebhookIP(address) { + return preparedTarget{}, errors.New("webhook URL resolved to a blocked address") + } + } + + originalHost := parsed.Host + originalURL := *parsed + parsed.Host = pinnedHost(addresses[0], parsed.Port()) + return preparedTarget{ + URL: parsed.String(), + HostHeader: originalHost, + ServerName: hostname, + OriginalURL: &originalURL, + Addresses: addresses, + }, nil +} + +func resolveAddresses(ctx context.Context, hostname string, resolver IPResolver) ([]net.IP, error) { + if literal := net.ParseIP(hostname); literal != nil { + return []net.IP{literal}, nil + } + if resolver == nil { + resolver = net.DefaultResolver + } + resolved, err := resolver.LookupIPAddr(ctx, hostname) + if err != nil { + return nil, err + } + addresses := make([]net.IP, 0, len(resolved)) + for _, item := range resolved { + if item.IP != nil { + addresses = append(addresses, item.IP) + } + } + return addresses, nil +} + +func publicWebhookIP(ip net.IP) bool { + if ip == nil { + return false + } + if ipv4 := ip.To4(); ipv4 != nil { + ip = ipv4 + } + if !ip.IsGlobalUnicast() || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsMulticast() { + return false + } + address, ok := netip.AddrFromSlice(ip) + if !ok { + return false + } + address = address.Unmap() + if wellKnownNAT64Prefix.Contains(address) { + value := address.As16() + return publicWebhookIP(net.IPv4(value[12], value[13], value[14], value[15])) + } + for _, prefix := range globallyReachableSpecialPrefixes { + if prefix.Contains(address) { + return true + } + } + for _, prefix := range blockedWebhookPrefixes { + if prefix.Contains(address) { + return false + } + } + return true +} + +var wellKnownNAT64Prefix = netip.MustParsePrefix("64:ff9b::/96") + +var globallyReachableSpecialPrefixes = []netip.Prefix{ + netip.MustParsePrefix("192.0.0.9/32"), + netip.MustParsePrefix("192.0.0.10/32"), + netip.MustParsePrefix("2001:1::1/128"), + netip.MustParsePrefix("2001:1::2/128"), + netip.MustParsePrefix("2001:1::3/128"), + netip.MustParsePrefix("2001:3::/32"), + netip.MustParsePrefix("2001:4:112::/48"), + netip.MustParsePrefix("2001:20::/28"), + netip.MustParsePrefix("2001:30::/28"), +} + +var blockedWebhookPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.88.99.0/24"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("64:ff9b:1::/48"), + netip.MustParsePrefix("100::/64"), + netip.MustParsePrefix("100:0:0:1::/64"), + netip.MustParsePrefix("2001::/23"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("2002::/16"), + netip.MustParsePrefix("3fff::/20"), + netip.MustParsePrefix("5f00::/16"), + netip.MustParsePrefix("fec0::/10"), +} + +func pinnedHost(ip net.IP, port string) string { + host := ip.String() + if port != "" { + return net.JoinHostPort(host, port) + } + if ip.To4() == nil { + return "[" + host + "]" + } + return host +} + +func validateRenderedBody(format BodyFormat, body []byte) (string, error) { + if format == "" { + format = BodyJSON + } + switch format { + case BodyJSON: + if !json.Valid(body) { + return "", errors.New("rendered webhook JSON body is invalid") + } + return "application/json; charset=utf-8", nil + case BodyForm: + return "application/x-www-form-urlencoded", nil + case BodyText: + return "text/plain; charset=utf-8", nil + default: + return "", errors.New("unsupported webhook body format") + } +} + +func validateHeaders(headers map[string]string) error { + for name, value := range headers { + if !validHeaderName(name) || strings.ContainsAny(value, "\r\n") || reservedHeader(name) { + return errors.New("invalid webhook request header") + } + } + return nil +} + +func validHeaderName(name string) bool { + if name == "" { + return false + } + for i := 0; i < len(name); i++ { + if !headerTokenByte(name[i]) { + return false + } + } + return true +} + +func headerTokenByte(b byte) bool { + if b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' || b >= '0' && b <= '9' { + return true + } + return strings.ContainsRune("!#$%&'*+-.^_`|~", rune(b)) +} + +func reservedHeader(name string) bool { + switch http.CanonicalHeaderKey(name) { + case "Connection", "Content-Length", "Content-Type", "Host", "Proxy-Authorization", "Proxy-Connection", "Te", "Trailer", "Transfer-Encoding", "Upgrade": + return true + default: + return false + } +} + +func systemTLSTransport(input *http.Transport, serverName string, originalURL *url.URL, addresses []net.IP) *http.Transport { + var transport *http.Transport + if input != nil { + transport = input.Clone() + } else if defaultTransport, ok := http.DefaultTransport.(*http.Transport); ok { + transport = defaultTransport.Clone() + } else { + transport = &http.Transport{Proxy: http.ProxyFromEnvironment} + } + + tlsConfig := &tls.Config{} + if transport.TLSClientConfig != nil { + tlsConfig = transport.TLSClientConfig.Clone() + } + tlsConfig.InsecureSkipVerify = false + if roots, err := loadWebhookSystemCertPool(); err == nil { + tlsConfig.RootCAs = roots + } else { + tlsConfig.RootCAs = nil + } + tlsConfig.Certificates = nil + tlsConfig.GetClientCertificate = nil + tlsConfig.ServerName = serverName + transport.TLSClientConfig = tlsConfig + transport.DialTLS = nil + transport.DialTLSContext = nil + if transport.Proxy != nil && originalURL != nil { + proxySelector := transport.Proxy + selectionURL := *originalURL + transport.Proxy = func(request *http.Request) (*url.URL, error) { + selectionRequest := request.Clone(request.Context()) + selectionRequest.URL = &selectionURL + selectionRequest.Host = selectionURL.Host + return proxySelector(selectionRequest) + } + } + if len(addresses) > 1 { + firstAddress := addresses[0] + baseDial := transport.DialContext + if baseDial == nil { + baseDial = (&net.Dialer{}).DialContext + } + transport.DialContext = func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + dialIP := net.ParseIP(host) + if err != nil || dialIP == nil || !dialIP.Equal(firstAddress) { + return baseDial(ctx, network, address) + } + var lastErr error + for _, candidate := range addresses { + connection, dialErr := baseDial(ctx, network, net.JoinHostPort(candidate.String(), port)) + if dialErr == nil { + return connection, nil + } + lastErr = dialErr + } + return nil, lastErr + } + } + secureDial := transport.DialContext + if secureDial == nil { + secureDial = (&net.Dialer{}).DialContext + } + targetPort := "443" + if originalURL != nil && originalURL.Port() != "" { + targetPort = originalURL.Port() + } + transport.DialTLSContext = func(ctx context.Context, network, address string) (net.Conn, error) { + connection, err := secureDial(ctx, network, address) + if err != nil { + return nil, err + } + host, port, splitErr := net.SplitHostPort(address) + if splitErr != nil { + _ = connection.Close() + return nil, splitErr + } + firstHopConfig := tlsConfig.Clone() + if port == targetPort && isPinnedWebhookAddress(host, addresses) { + firstHopConfig.ServerName = serverName + } else { + firstHopConfig.ServerName = strings.TrimSuffix(host, ".") + firstHopConfig.NextProtos = []string{"http/1.1"} + } + tlsConnection := tls.Client(connection, firstHopConfig) + if err := tlsConnection.HandshakeContext(ctx); err != nil { + _ = connection.Close() + return nil, err + } + return tlsConnection, nil + } + return transport +} + +func isPinnedWebhookAddress(host string, addresses []net.IP) bool { + dialIP := net.ParseIP(host) + if dialIP == nil { + return false + } + for _, address := range addresses { + if dialIP.Equal(address) { + return true + } + } + return false +} + +func validateResponse(preset Preset, statusCode int, body []byte) error { + if preset == "" { + preset = PresetGeneric + } + switch preset { + case PresetSlack: + if statusCode != http.StatusOK || strings.TrimSpace(string(body)) != "ok" { + return errors.New("webhook provider rejected response") + } + return nil + case PresetGeneric, PresetDiscord, PresetTeams: + if statusCode < http.StatusOK || statusCode >= http.StatusMultipleChoices { + return errors.New("webhook provider rejected response") + } + return nil + default: + return errors.New("unsupported webhook preset") + } +} diff --git a/agent/utils/webhook_sender/webhook_sender.go b/agent/utils/webhook_sender/webhook_sender.go new file mode 100644 index 000000000..b9d7cd1ba --- /dev/null +++ b/agent/utils/webhook_sender/webhook_sender.go @@ -0,0 +1,130 @@ +package webhook_sender + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/1Panel-dev/1Panel/agent/constant" +) + +func NormalizeToText(s string) string { + r := strings.NewReplacer( + "\r\n", "\n", + "\r", "\n", + "
", "\n", + "
", "\n", + "
", "\n", + "

", "\n", + "

", "", + "", "\n", + "

", "", + " ", " ", + ) + return r.Replace(s) +} + +func BuildWebhookPayload(method string, title string, text string) (any, error) { + switch method { + case constant.WeCom: + return map[string]any{ + "msgtype": "markdown", + "markdown": map[string]any{ + "content": fmt.Sprintf("**%s**\n%s", title, text), + }, + }, nil + case constant.DingTalk: + return map[string]any{ + "msgtype": "text", + "text": map[string]any{ + "content": fmt.Sprintf("%s\n%s", title, text), + }, + }, nil + case constant.FeiShu: + return map[string]any{ + "msg_type": "text", + "content": map[string]any{ + "text": fmt.Sprintf("%s\n%s", title, text), + }, + }, nil + case constant.Custom: + return map[string]any{ + "title": title, + "message": text, + "type": "1panel_alert", + "ts": time.Now().Unix(), + }, nil + default: + return nil, fmt.Errorf("unsupported webhook method: %s", method) + } +} + +func SendWebhookRequest(method string, url string, payload any, transport *http.Transport) error { + return sendLegacyWebhookRequest(method, url, payload, transport) +} + +func sendLegacyWebhookRequest(method string, url string, payload any, transport *http.Transport) error { + if url == "" { + return fmt.Errorf("send webhook request failed: url is empty") + } + if payload == nil { + return fmt.Errorf("send webhook request failed: payload is nil") + } + body, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal webhook payload failed") + } + client := &http.Client{Timeout: RequestTimeout} + if transport != nil { + client.Transport = transport + } + ctx, cancel := context.WithTimeout(context.Background(), RequestTimeout) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create webhook request failed") + } + req.Header.Set("Content-Type", "application/json; charset=utf-8") + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("webhook request failed") + } + defer resp.Body.Close() + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("webhook response status code: %d", resp.StatusCode) + } + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read webhook response failed") + } + switch method { + case constant.WeCom, constant.DingTalk: + var result struct { + Errcode int `json:"errcode"` + Errmsg string `json:"errmsg"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return nil + } + if result.Errcode != 0 { + return fmt.Errorf("webhook provider rejected response") + } + case constant.FeiShu: + var result struct { + StatusCode int `json:"StatusCode"` + Code int `json:"code"` + } + if err := json.Unmarshal(respBody, &result); err != nil { + return nil + } + if result.StatusCode != 0 || result.Code != 0 { + return fmt.Errorf("webhook provider rejected response") + } + } + return nil +} diff --git a/agent/utils/xpack/alert_delivery.go b/agent/utils/xpack/alert_delivery.go new file mode 100644 index 000000000..bf6c13f62 --- /dev/null +++ b/agent/utils/xpack/alert_delivery.go @@ -0,0 +1,57 @@ +package xpack + +import ( + "fmt" + "net/http" + + "github.com/1Panel-dev/1Panel/agent/app/dto" + "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/utils/xpack/providers" +) + +func DeliverCustomWebhookAlertLog( + alertType string, + info dto.AlertDTO, + create dto.AlertLogCreate, + project string, + params []dto.Param, + config model.AlertConfig, + transport *http.Transport, + agentInfo *dto.AgentInfo, + task dto.AlertTaskMetadata, +) (providers.DeliveryResult, error) { + if provider, ok := AlertProvider.(providers.CustomWebhookDeliveryProvider); ok { + result, err := provider.CreateCustomWebhookAlertLog(alertType, info, create, project, params, config, transport, agentInfo, task) + return validateCustomWebhookDeliveryResult(result, err) + } + err := AlertProvider.CreateWebhookAlertLog(alertType, info, create, project, params, config, transport, agentInfo) + return providers.DeliveryResult{}, err +} + +func DeliverTaskScanCustomWebhookAlertLog( + alert dto.AlertDTO, + alertType string, + create dto.AlertLogCreate, + pushAlert dto.PushAlert, + config model.AlertConfig, + transport *http.Transport, + agentInfo *dto.AgentInfo, + task dto.AlertTaskMetadata, +) (providers.DeliveryResult, error) { + if provider, ok := AlertProvider.(providers.CustomWebhookDeliveryProvider); ok { + result, err := provider.CreateTaskScanCustomWebhookAlertLog(alert, alertType, create, pushAlert, config, transport, agentInfo, task) + return validateCustomWebhookDeliveryResult(result, err) + } + err := AlertProvider.CreateTaskScanWebhookAlertLog(alert, alertType, create, pushAlert, config, transport, agentInfo) + return providers.DeliveryResult{}, err +} + +func validateCustomWebhookDeliveryResult(result providers.DeliveryResult, err error) (providers.DeliveryResult, error) { + if err != nil { + return result, err + } + if result.Queued && result.LogID == 0 { + return providers.DeliveryResult{}, fmt.Errorf("custom webhook provider queued a delivery without a log ID") + } + return result, nil +} diff --git a/agent/utils/xpack/helper/alert.go b/agent/utils/xpack/helper/alert.go index b35693c80..5e7d72e7b 100644 --- a/agent/utils/xpack/helper/alert.go +++ b/agent/utils/xpack/helper/alert.go @@ -5,11 +5,24 @@ import ( "github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/model" + "github.com/1Panel-dev/1Panel/agent/constant" + alertUtil "github.com/1Panel-dev/1Panel/agent/utils/alert" "github.com/1Panel-dev/1Panel/agent/utils/xpack/providers" ) type alertHelper struct{} +var ( + _ providers.CustomWebhookTester = (*alertHelper)(nil) + _ providers.CustomWebhookDeliveryProvider = (*alertHelper)(nil) +) + +var loadCommunityCustomWebhookContext = func() (*http.Transport, *dto.AgentInfo) { + multiNode := &multiNodeHelper{} + agentInfo, _ := multiNode.GetAgentInfo() + return multiNode.LoadRequestTransport(), agentInfo +} + func NewIAlertProvider() providers.AlertProvider { return &alertHelper{} } @@ -23,13 +36,34 @@ func (a *alertHelper) CreateSMSAlertLog(alertType string, info dto.AlertDTO, cre } func (a *alertHelper) CreateTaskScanWebhookAlertLog(alert dto.AlertDTO, alertType string, create dto.AlertLogCreate, pushAlert dto.PushAlert, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo) error { + if config.Type == constant.Custom { + return alertUtil.CreateTaskScanCustomWebhookAlertLog(alert, alertType, create, pushAlert, config, transport, agentInfo) + } return nil } func (a *alertHelper) CreateWebhookAlertLog(alertType string, info dto.AlertDTO, create dto.AlertLogCreate, project string, params []dto.Param, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo) error { + if config.Type == constant.Custom { + return alertUtil.CreateCustomWebhookAlertLog(alertType, info, create, project, params, config, transport, agentInfo) + } return nil } +func (a *alertHelper) CreateCustomWebhookAlertLog(alertType string, info dto.AlertDTO, create dto.AlertLogCreate, project string, params []dto.Param, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo, _ dto.AlertTaskMetadata) (providers.DeliveryResult, error) { + err := alertUtil.CreateCustomWebhookAlertLog(alertType, info, create, project, params, config, transport, agentInfo) + return providers.DeliveryResult{}, err +} + +func (a *alertHelper) CreateTaskScanCustomWebhookAlertLog(alert dto.AlertDTO, alertType string, create dto.AlertLogCreate, pushAlert dto.PushAlert, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo, _ dto.AlertTaskMetadata) (providers.DeliveryResult, error) { + err := alertUtil.CreateTaskScanCustomWebhookAlertLog(alert, alertType, create, pushAlert, config, transport, agentInfo) + return providers.DeliveryResult{}, err +} + +func (a *alertHelper) TestCustomWebhook(config dto.AlertCustomWebhookResolvedConfig) (dto.AlertConfigTestResult, error) { + transport, agentInfo := loadCommunityCustomWebhookContext() + return alertUtil.TestCustomWebhook(config, transport, agentInfo) +} + func (a *alertHelper) GetLicenseErrorAlert() (uint, error) { return 0, nil } diff --git a/agent/utils/xpack/providers/alert.go b/agent/utils/xpack/providers/alert.go index 8f885866d..ed9a19246 100644 --- a/agent/utils/xpack/providers/alert.go +++ b/agent/utils/xpack/providers/alert.go @@ -1,12 +1,15 @@ package providers import ( + "errors" "net/http" "github.com/1Panel-dev/1Panel/agent/app/dto" "github.com/1Panel-dev/1Panel/agent/app/model" ) +var ErrCustomWebhookUnsupported = errors.New("custom webhook sender is unavailable") + type AlertProvider interface { GetNodeErrorAlert() (uint, error) GetLicenseErrorAlert() (uint, error) @@ -16,3 +19,17 @@ type AlertProvider interface { CreateTaskScanWebhookAlertLog(alert dto.AlertDTO, alertType string, create dto.AlertLogCreate, pushAlert dto.PushAlert, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo) error CreateWebhookAlertLog(alertType string, info dto.AlertDTO, create dto.AlertLogCreate, project string, params []dto.Param, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo) error } + +type CustomWebhookTester interface { + TestCustomWebhook(config dto.AlertCustomWebhookResolvedConfig) (dto.AlertConfigTestResult, error) +} + +type DeliveryResult struct { + Queued bool + LogID uint +} + +type CustomWebhookDeliveryProvider interface { + CreateCustomWebhookAlertLog(alertType string, info dto.AlertDTO, create dto.AlertLogCreate, project string, params []dto.Param, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo, task dto.AlertTaskMetadata) (DeliveryResult, error) + CreateTaskScanCustomWebhookAlertLog(alert dto.AlertDTO, alertType string, create dto.AlertLogCreate, pushAlert dto.PushAlert, config model.AlertConfig, transport *http.Transport, agentInfo *dto.AgentInfo, task dto.AlertTaskMetadata) (DeliveryResult, error) +} diff --git a/core/app/model/alert.go b/core/app/model/alert.go index 2043cb521..cbdffc575 100644 --- a/core/app/model/alert.go +++ b/core/app/model/alert.go @@ -2,8 +2,10 @@ package model type AlertConfig struct { BaseModel - Type string `json:"type"` - Title string `json:"title"` - Status string `json:"status"` - Config string `json:"config"` + UID string `json:"uid"` + Type string `json:"type"` + Title string `json:"title"` + Status string `json:"status"` + Config string `json:"config"` + SecretConfig string `json:"-"` } diff --git a/core/cmd/server/docs/x-log.json b/core/cmd/server/docs/x-log.json index 2a27c52c6..b3ac1bc15 100644 --- a/core/cmd/server/docs/x-log.json +++ b/core/cmd/server/docs/x-log.json @@ -5787,4 +5787,4 @@ "formatZH": "从主节点同步设置", "formatEN": "sync settings from master" } -} \ No newline at end of file +} diff --git a/core/init/router/proxy.go b/core/init/router/proxy.go index 3be526474..1564f3e83 100644 --- a/core/init/router/proxy.go +++ b/core/init/router/proxy.go @@ -1,8 +1,10 @@ package router import ( + "errors" "net/http" "net/url" + "path" "strconv" "strings" @@ -18,6 +20,8 @@ import ( "github.com/gin-gonic/gin" ) +var errInternalOnlyAgentEndpoint = errors.New("internal agent endpoint cannot be proxied") + func Proxy() gin.HandlerFunc { return func(c *gin.Context) { reqPath := c.Request.URL.Path @@ -51,6 +55,11 @@ func Proxy() gin.HandlerFunc { c.Request.Header.Set("X-Panel-User", url.QueryEscape(userName)) } + if isInternalOnlyAgentEndpoint(reqPath) { + helper.ErrorWithDetail(c, http.StatusForbidden, "ErrProxy", errInternalOnlyAgentEndpoint) + return + } + if reqPath == "/api/v2/hosts/terminal/local" && (currentNode == "local" || len(currentNode) == 0) { proxyLocalAgent(c) return @@ -65,6 +74,12 @@ func Proxy() gin.HandlerFunc { } } +func isInternalOnlyAgentEndpoint(reqPath string) bool { + normalizedPath := path.Clean(reqPath) + return normalizedPath == "/api/v2/xpack/alert/offline/email" || + normalizedPath == "/api/v2/xpack/alert/offline/webhook" +} + func proxyLocalAgent(c *gin.Context) { defer func() { if err := recover(); err != nil && err != http.ErrAbortHandler { diff --git a/core/utils/alert_webhook/secret.go b/core/utils/alert_webhook/secret.go new file mode 100644 index 000000000..edb849d0d --- /dev/null +++ b/core/utils/alert_webhook/secret.go @@ -0,0 +1,135 @@ +package alert_webhook + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "strconv" + "strings" + + "github.com/1Panel-dev/1Panel/core/app/model" + "github.com/1Panel-dev/1Panel/core/constant" + "github.com/1Panel-dev/1Panel/core/global" + "github.com/1Panel-dev/1Panel/core/utils/encrypt" + "github.com/google/uuid" +) + +const ( + coreKeyPrefix = "core:v1:" + secretSchemaV1 = 1 + maxSecretLength = 256 * 1024 + maxSecretHeaders = 64 + maxSecretValueSize = 16 * 1024 +) + +type secretConfig struct { + SchemaVersion int `json:"schemaVersion"` + URL string `json:"url"` + Headers map[string]string `json:"headers,omitempty"` +} + +func ExportSecretPlain(config model.AlertConfig) (string, error) { + if config.Type != constant.Custom { + return "", fmt.Errorf("alert config %d is not a custom webhook", config.ID) + } + if !strings.HasPrefix(config.SecretConfig, coreKeyPrefix) { + return "", fmt.Errorf("custom webhook secret is not encrypted with the core key") + } + payload := strings.TrimPrefix(config.SecretConfig, coreKeyPrefix) + if payload == "" { + return "", fmt.Errorf("custom webhook secret ciphertext is empty") + } + key, err := loadEncryptKey() + if err != nil { + return "", err + } + plainText, err := encrypt.StringDecryptWithKey(payload, key) + if err != nil { + return "", fmt.Errorf("decrypt custom webhook secret: %w", err) + } + secret, err := decodeAndValidateSecret(plainText) + if err != nil { + return "", err + } + canonical, err := json.Marshal(secret) + if err != nil { + return "", fmt.Errorf("encode custom webhook secret: %w", err) + } + return string(canonical), nil +} + +func loadEncryptKey() (string, error) { + if key := strings.TrimSpace(global.CONF.Base.EncryptKey); key != "" { + return key, nil + } + if global.DB != nil { + var setting model.Setting + if err := global.DB.Where("key = ?", "EncryptKey").First(&setting).Error; err == nil { + if key := strings.TrimSpace(setting.Value); key != "" { + return key, nil + } + } + } + return "", fmt.Errorf("custom webhook core encrypt key is empty") +} + +func decodeAndValidateSecret(plainText string) (secretConfig, error) { + if len(plainText) == 0 || len(plainText) > maxSecretLength { + return secretConfig{}, fmt.Errorf("custom webhook secret has an invalid size") + } + var secret secretConfig + decoder := json.NewDecoder(strings.NewReader(plainText)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&secret); err != nil { + return secretConfig{}, fmt.Errorf("decode custom webhook secret: %w", err) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + return secretConfig{}, fmt.Errorf("decode custom webhook secret: multiple JSON values are not allowed") + } + if secret.SchemaVersion != secretSchemaV1 { + return secretConfig{}, fmt.Errorf("unsupported custom webhook secret schemaVersion: %d", secret.SchemaVersion) + } + if err := validateSecretURL(secret.URL); err != nil { + return secretConfig{}, err + } + if len(secret.Headers) > maxSecretHeaders { + return secretConfig{}, fmt.Errorf("custom webhook secret headers exceed the limit") + } + if secret.Headers == nil { + secret.Headers = map[string]string{} + } + for uid, value := range secret.Headers { + if _, err := uuid.Parse(strings.TrimSpace(uid)); err != nil { + return secretConfig{}, fmt.Errorf("custom webhook secret header uid must be a UUID") + } + if value == "" || len(value) > maxSecretValueSize || strings.ContainsAny(value, "\r\n") { + return secretConfig{}, fmt.Errorf("custom webhook secret header is invalid") + } + } + return secret, nil +} + +func validateSecretURL(rawURL string) error { + if rawURL == "" { + return nil + } + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("invalid custom webhook URL") + } + hostname := strings.TrimSuffix(strings.TrimSpace(parsed.Hostname()), ".") + if (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" || hostname == "" || strings.Contains(hostname, "%") || parsed.User != nil || parsed.Fragment != "" { + return fmt.Errorf("invalid custom webhook URL") + } + if strings.HasSuffix(parsed.Host, ":") { + return fmt.Errorf("invalid custom webhook URL") + } + if port := parsed.Port(); port != "" { + value, err := strconv.Atoi(port) + if err != nil || value < 1 || value > 65535 { + return fmt.Errorf("invalid custom webhook URL") + } + } + return nil +} diff --git a/core/utils/req_helper/proxy_local/req_to_local.go b/core/utils/req_helper/proxy_local/req_to_local.go index df202ce92..1e65f6e90 100644 --- a/core/utils/req_helper/proxy_local/req_to_local.go +++ b/core/utils/req_helper/proxy_local/req_to_local.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "strings" + "time" "github.com/gin-gonic/gin" @@ -24,6 +25,12 @@ func NewLocalClient(reqUrl, reqMethod string, body io.Reader, ctx *gin.Context) return client.Request(reqUrl, reqMethod, body, ctx) } +func NewLocalClientWithContext(requestContext context.Context, reqURL, reqMethod string, body io.Reader, ctx *gin.Context, timeout time.Duration) (interface{}, error) { + client := newReusableClientWithTimeout("/etc/1panel/agent.sock", timeout) + defer client.CloseIdleConnections() + return client.RequestWithContext(requestContext, reqURL, reqMethod, body, ctx) +} + type ReusableClient struct { client *http.Client sockPath string @@ -34,17 +41,19 @@ func NewReusableClient() *ReusableClient { } func newReusableClient(sockPath string) *ReusableClient { - dialUnix := func() (conn net.Conn, err error) { - return net.Dial("unix", sockPath) - } + return newReusableClientWithTimeout(sockPath, 0) +} + +func newReusableClientWithTimeout(sockPath string, timeout time.Duration) *ReusableClient { + dialer := &net.Dialer{Timeout: timeout} transport := &http.Transport{ MaxIdleConns: 12, MaxIdleConnsPerHost: 6, DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { - return dialUnix() + return dialer.DialContext(ctx, "unix", sockPath) }, } - return &ReusableClient{client: &http.Client{Transport: transport}, sockPath: sockPath} + return &ReusableClient{client: &http.Client{Transport: transport, Timeout: timeout}, sockPath: sockPath} } func (c *ReusableClient) CloseIdleConnections() { @@ -55,6 +64,10 @@ func (c *ReusableClient) CloseIdleConnections() { } func (c *ReusableClient) Request(reqUrl, reqMethod string, body io.Reader, ctx *gin.Context) (interface{}, error) { + return c.RequestWithContext(context.Background(), reqUrl, reqMethod, body, ctx) +} + +func (c *ReusableClient) RequestWithContext(requestContext context.Context, reqUrl, reqMethod string, body io.Reader, ctx *gin.Context) (interface{}, error) { if c == nil || c.client == nil { return nil, errors.New("local agent client is not initialized") } @@ -71,7 +84,7 @@ func (c *ReusableClient) Request(reqUrl, reqMethod string, body io.Reader, ctx * Host: parsedURL.Host, } - req, err := http.NewRequest(reqMethod, rURL.String(), body) + req, err := http.NewRequestWithContext(requestContext, reqMethod, rURL.String(), body) if err != nil { return nil, fmt.Errorf("creating request failed, err: %v", err) } diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 449f53dba..6329d1d36 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -206,7 +206,7 @@ class RequestHttp { return this.service.delete(url, { params, ..._object }); } download(url: string, params?: object, _object = {}): Promise { - return this.service.post(url, params, _object); + return this.service.post(url, params, _object) as unknown as Promise; } upload(url: string, params: object = {}, config?: RequestConfig): Promise> { return this.service.post(url, params, config); diff --git a/frontend/src/api/interface/alert.ts b/frontend/src/api/interface/alert.ts index 4d1535895..77d5bf5d9 100644 --- a/frontend/src/api/interface/alert.ts +++ b/frontend/src/api/interface/alert.ts @@ -156,6 +156,7 @@ export namespace Alert { export interface AlertConfigUpdateReq { id: number; + revision?: string; type: string; title: string; config: string; @@ -163,9 +164,17 @@ export namespace Alert { displayName: string; } + export interface AlertConfigStatusReq { + id: number; + status: string; + } + export interface AlertConfigTest { - port: number; + id?: number; + type: 'email'; + config: string; host: string; + port: number; sender: string; userName: string; password: string; @@ -174,6 +183,20 @@ export namespace Alert { recipient: string; } + export interface AlertConfigCustomTest { + id?: number; + type: 'custom'; + config: string; + } + + export interface AlertConfigCustomTestResult { + success: boolean; + statusCode?: number; + duration?: number; + message?: string; + response?: string; + } + export interface CommonAlertConfig { id?: number; type: string; diff --git a/frontend/src/api/modules/alert.ts b/frontend/src/api/modules/alert.ts index 78f60dfc5..4242e7e18 100644 --- a/frontend/src/api/modules/alert.ts +++ b/frontend/src/api/modules/alert.ts @@ -14,6 +14,7 @@ const resolveAlertConfigExcludeTypes = (excludeTypes: string[] = []) => { } return Array.from(types); }; + export const SearchAlerts = (req: Alert.AlertSearch, currentNode?: string) => { return http.post>( `/alert/search`, @@ -99,10 +100,18 @@ export const UpdateAlertConfig = (req: Alert.AlertConfigUpdateReq) => { return http.post(`/alert/config/update`, req); }; +export const UpdateAlertConfigStatus = (req: Alert.AlertConfigStatusReq) => { + return http.post(`/alert/config/status`, req); +}; + export const TestAlertConfig = (req: Alert.AlertConfigTest) => { return http.post(`/alert/config/test`, req); }; +export const TestCustomAlertConfig = (req: Alert.AlertConfigCustomTest) => { + return http.post(`/alert/config/test`, req); +}; + export const SyncAlertInfo = (req: Alert.AlertLogId) => { return http.post(`/xpack/alert/logs/sync`, req); }; diff --git a/frontend/src/components/app-status/index.vue b/frontend/src/components/app-status/index.vue index b5f29e4ad..8adb41ada 100644 --- a/frontend/src/components/app-status/index.vue +++ b/frontend/src/components/app-status/index.vue @@ -199,6 +199,6 @@ defineExpose({ border: 1px solid var(--el-color-warning); background-color: transparent; padding: 8px 8px; - width: 70px; + width: 71px; } diff --git a/frontend/src/lang/modules/en.ts b/frontend/src/lang/modules/en.ts index 2df1411ef..7ce2b80d7 100644 --- a/frontend/src/lang/modules/en.ts +++ b/frontend/src/lang/modules/en.ts @@ -946,7 +946,6 @@ const message = { from_remote: 'This model was not downloaded via 1Panel, no related pull logs.', no_logs: 'The pull logs for this model have been deleted and cannot be viewed.', vllmVersionHelper: 'For FusionXpark GB 10 servers, please select the -cu130 version.', - ascendVisibleDevices: 'Ascend Visible Devices', vllmCommandPortHelper: 'The startup command must use port {0}; otherwise, the service will be inaccessible.', ascendVisibleDevices: 'Ascend visible devices (ASCEND_RT_VISIBLE_DEVICES)', @@ -1140,7 +1139,6 @@ const message = { cachedToken: 'Cached Tokens', cacheHitRate: 'Cache Hit Rate', activeUsers: 'Active Users', - activeStreamingRequests: 'Active Streaming Requests', activeModels: 'Active Models', failedRequests: 'Failed Requests', averageTokenPerRequest: 'Avg Tokens/Request', @@ -6703,7 +6701,57 @@ const message = { feiShuConfigHelper: 'Feishu alert notification configuration', webhookName: 'Bot name', webhookUrl: 'Webhook URL', - alertConfigProHelper: 'Commercial Edition also supports WeCom, DingTalk, Feishu, and SMS alerts.', + custom: 'Webhook', + webhookPreset: 'Preset', + genericJsonPreset: 'Generic JSON', + customPreset: 'Custom', + webhookUrlSecretHelper: 'The Webhook URL is encrypted at rest and can be viewed and edited here', + webhookPublicAddressHelper: + 'Only publicly reachable HTTP/HTTPS addresses are supported; local, private, and reserved addresses are blocked', + customWebhookRecoveryRequired: + 'This configuration is invalid or from an older version. Re-enter the Webhook URL and any required secret headers, then save.', + clearSecret: 'Clear', + keepSecret: 'Keep unchanged', + secretCleared: 'This secret will be cleared when you save', + bodyType: 'Body Type', + bodyTemplate: 'Body Template', + formFieldName: 'Field name', + formFieldValue: 'Field value', + addFormField: 'Add Field', + webhookAdvanced: 'Advanced', + headers: 'Headers', + headerName: 'Header name', + headerValue: 'Header value', + secretValue: 'Secret value', + addHeader: 'Add Header', + templateVariables: 'Template Variables', + templateVariableTitle: 'Alert title', + templateVariableMessage: 'Alert content', + templateVariableType: 'Alert type', + templateVariableNodeName: 'Node name', + templateVariableTimestamp: 'Event time', + templateVariablesHelper: + 'title=alert title, message=alert content, type=alert type, nodeName=node name, timestamp=event time. Click a variable to insert it into the body.', + testResultStale: 'The configuration changed, so the previous test result is no longer valid', + alertConfigChanged: 'The configuration was updated. Refresh and try again.', + presetOverwriteHelper: 'Switching presets will replace the current body configuration. Continue?', + customWebhookValidation: { + displayNameRequired: 'Enter a display name', + urlRequired: 'Enter a Webhook URL', + urlInvalid: 'The Webhook URL must be a valid HTTP or HTTPS URL', + bodyRequired: 'Configure the request body', + jsonInvalid: 'The JSON body template is invalid', + formFieldRequired: 'Form field names are required', + formFieldDuplicate: 'Form field names must be unique', + headerRequired: 'Header names are required', + headerInvalid: 'The header name is invalid', + headerDuplicate: 'Header names must be unique', + headerReserved: 'This header is managed by the system and cannot be customized', + headerMustBeSecret: 'Authentication or credential headers must be marked as secret values', + templateVariableInvalid: 'The body contains an unsupported template variable', + secretRequired: 'Enter a secret value, or choose Keep unchanged or Clear', + }, + alertConfigProHelper: 'Commercial Edition adds more notification channels.', recipientPlaceholder: 'Please enter recipient email address', addRecipient: 'Add Recipient', webhookItem: 'Webhook', diff --git a/frontend/src/lang/modules/es-es.ts b/frontend/src/lang/modules/es-es.ts index 9ffce81a7..40135c4b6 100644 --- a/frontend/src/lang/modules/es-es.ts +++ b/frontend/src/lang/modules/es-es.ts @@ -950,7 +950,6 @@ const message = { from_remote: 'Este modelo no fue descargado vía 1Panel, no hay registros de descarga relacionados.', no_logs: 'Los registros de descarga de este modelo han sido eliminados y no se pueden consultar.', vllmVersionHelper: 'Para servidores FusionXpark GB 10, seleccione la versión -cu130.', - ascendVisibleDevices: 'Dispositivos Ascend visibles', vllmCommandPortHelper: 'El comando de inicio debe usar el puerto {0}; de lo contrario, no se podrá acceder al servicio.', ascendVisibleDevices: 'Dispositivos Ascend visibles (ASCEND_RT_VISIBLE_DEVICES)', @@ -1146,7 +1145,6 @@ const message = { cachedToken: 'Tokens en caché', cacheHitRate: 'Tasa de acierto de caché', activeUsers: 'Usuarios activos', - activeStreamingRequests: 'Solicitudes de streaming activas', activeModels: 'Modelos activos', failedRequests: 'Solicitudes fallidas', averageTokenPerRequest: 'Tokens promedio/solicitud', @@ -6809,7 +6807,58 @@ const message = { barkConfigHelper: 'Configuración de notificación de alerta Bark', webhookName: 'Nombre del bot', webhookUrl: 'URL de Webhook', - alertConfigProHelper: 'La edición comercial también admite alertas mediante WeCom, DingTalk, Feishu y SMS.', + custom: 'Webhook', + webhookPreset: 'Preajuste', + genericJsonPreset: 'JSON genérico', + customPreset: 'Personalizado', + webhookUrlSecretHelper: 'La URL del Webhook se almacena cifrada y se puede ver y editar aquí', + webhookPublicAddressHelper: + 'Solo se admiten direcciones HTTP/HTTPS accesibles públicamente; se bloquean las direcciones locales, privadas y reservadas', + customWebhookRecoveryRequired: + 'Esta configuración no es válida o procede de una versión anterior. Vuelve a introducir la URL del Webhook y las cabeceras secretas necesarias y guarda los cambios.', + clearSecret: 'Borrar', + keepSecret: 'Mantener sin cambios', + secretCleared: 'Este secreto se borrará al guardar', + bodyType: 'Tipo de Body', + bodyTemplate: 'Plantilla del Body', + formFieldName: 'Nombre del campo', + formFieldValue: 'Valor del campo', + addFormField: 'Añadir campo', + webhookAdvanced: 'Avanzado', + headers: 'Headers', + headerName: 'Nombre del Header', + headerValue: 'Valor del Header', + secretValue: 'Valor secreto', + addHeader: 'Añadir Header', + templateVariables: 'Variables de plantilla', + templateVariableTitle: 'Título de alerta', + templateVariableMessage: 'Contenido de alerta', + templateVariableType: 'Tipo de alerta', + templateVariableNodeName: 'Nombre del nodo', + templateVariableTimestamp: 'Hora del evento', + templateVariablesHelper: + 'title=título de la alerta, message=contenido, type=tipo, nodeName=nombre del nodo, timestamp=hora del evento. Haz clic para insertar una variable en el Body.', + testResultStale: 'La configuración cambió; el resultado de la prueba anterior ya no es válido', + alertConfigChanged: 'La configuración se actualizó. Actualice la página e inténtelo de nuevo.', + presetOverwriteHelper: 'Cambiar el preajuste reemplazará la configuración actual del Body. ¿Continuar?', + customWebhookValidation: { + displayNameRequired: 'Introduce un nombre para mostrar', + urlRequired: 'Introduce una URL de Webhook', + urlInvalid: 'La URL del Webhook debe ser una URL HTTP o HTTPS válida', + bodyRequired: 'Configura el Body de la solicitud', + jsonInvalid: 'La plantilla JSON del Body no es válida', + formFieldRequired: 'Los nombres de los campos Form son obligatorios', + formFieldDuplicate: 'Los nombres de los campos Form deben ser únicos', + headerRequired: 'Los nombres de los Headers son obligatorios', + headerInvalid: 'El nombre del Header no es válido', + headerDuplicate: 'Los nombres de los Headers deben ser únicos', + headerReserved: 'Este Header lo gestiona el sistema y no se puede personalizar', + headerMustBeSecret: + 'Las cabeceras de autenticación o credenciales deben marcarse como valores secretos', + templateVariableInvalid: 'El Body contiene una variable de plantilla no compatible', + secretRequired: 'Introduce un secreto o elige Mantener sin cambios o Borrar', + }, + alertConfigProHelper: 'La edición comercial añade más canales de notificación.', }, theme: { lingXiaGold: 'LXware Gold', diff --git a/frontend/src/lang/modules/fa.ts b/frontend/src/lang/modules/fa.ts index af706b1e0..cec435e21 100644 --- a/frontend/src/lang/modules/fa.ts +++ b/frontend/src/lang/modules/fa.ts @@ -933,7 +933,6 @@ const message = { from_remote: 'این مدل از طریق 1Panel دانلود نشده است، لاگ مربوط به دریافت وجود ندارد.', no_logs: 'لاگ دریافت این مدل حذف شده است و قابل مشاهده نیست.', vllmVersionHelper: 'برای سرورهای FusionXpark GB 10، لطفاً نسخه -cu130 را انتخاب کنید.', - ascendVisibleDevices: 'دستگاه‌های Ascend قابل مشاهده', vllmCommandPortHelper: 'فرمان راه‌اندازی باید از پورت {0} استفاده کند؛ در غیر این صورت سرویس قابل دسترسی نخواهد بود.', ascendVisibleDevices: 'دستگاه‌های قابل مشاهده Ascend (ASCEND_RT_VISIBLE_DEVICES)', @@ -1126,7 +1125,6 @@ const message = { cachedToken: 'توکن‌های کش شده', cacheHitRate: 'نرخ برخورد کش', activeUsers: 'کاربران فعال', - activeStreamingRequests: 'درخواست‌های جریانی فعال', activeModels: 'مدل‌های فعال', failedRequests: 'درخواست‌های ناموفق', averageTokenPerRequest: 'میانگین توکن در هر درخواست', @@ -6657,7 +6655,57 @@ const message = { feiShuConfigHelper: 'پیکربندی اعلان هشدار فیشو', webhookName: 'نام ربات', webhookUrl: 'URL وب‌هوک', - alertConfigProHelper: 'نسخه تجاری همچنین از هشدارهای WeCom، دینگ‌تاک، فیشو و پیامک پشتیبانی می‌کند.', + custom: 'Webhook', + webhookPreset: 'پیش‌تنظیم', + genericJsonPreset: 'JSON عمومی', + customPreset: 'سفارشی', + webhookUrlSecretHelper: 'URL وب‌هوک به‌صورت رمزگذاری‌شده ذخیره می‌شود و در اینجا قابل مشاهده و ویرایش است', + webhookPublicAddressHelper: + 'فقط نشانی‌های HTTP/HTTPS قابل دسترس از اینترنت عمومی پشتیبانی می‌شوند؛ نشانی‌های محلی، خصوصی و رزروشده مسدود می‌شوند', + customWebhookRecoveryRequired: + 'این پیکربندی نامعتبر یا مربوط به نسخه‌ای قدیمی است. URL وب‌هوک و Headerهای محرمانه موردنیاز را دوباره وارد و ذخیره کنید.', + clearSecret: 'پاک کردن', + keepSecret: 'بدون تغییر', + secretCleared: 'این مقدار محرمانه هنگام ذخیره پاک می‌شود', + bodyType: 'نوع Body', + bodyTemplate: 'قالب Body', + formFieldName: 'نام فیلد', + formFieldValue: 'مقدار فیلد', + addFormField: 'افزودن فیلد', + webhookAdvanced: 'پیشرفته', + headers: 'Headers', + headerName: 'نام Header', + headerValue: 'مقدار Header', + secretValue: 'مقدار محرمانه', + addHeader: 'افزودن Header', + templateVariables: 'متغیرهای قالب', + templateVariableTitle: 'عنوان هشدار', + templateVariableMessage: 'محتوای هشدار', + templateVariableType: 'نوع هشدار', + templateVariableNodeName: 'نام گره', + templateVariableTimestamp: 'زمان رخداد', + templateVariablesHelper: + 'title=عنوان هشدار، message=محتوا، type=نوع، nodeName=نام گره، timestamp=زمان رخداد. برای درج در Body روی متغیر کلیک کنید.', + testResultStale: 'پیکربندی تغییر کرده و نتیجه آزمایش قبلی دیگر معتبر نیست', + alertConfigChanged: 'پیکربندی به‌روزرسانی شده است. صفحه را تازه‌سازی کرده و دوباره تلاش کنید.', + presetOverwriteHelper: 'تغییر پیش‌تنظیم، پیکربندی فعلی Body را جایگزین می‌کند. ادامه می‌دهید؟', + customWebhookValidation: { + displayNameRequired: 'نام نمایشی را وارد کنید', + urlRequired: 'URL وب‌هوک را وارد کنید', + urlInvalid: 'URL وب‌هوک باید یک آدرس معتبر HTTP یا HTTPS باشد', + bodyRequired: 'Body درخواست را پیکربندی کنید', + jsonInvalid: 'قالب JSON Body معتبر نیست', + formFieldRequired: 'نام فیلدهای Form الزامی است', + formFieldDuplicate: 'نام فیلدهای Form باید یکتا باشد', + headerRequired: 'نام Headers الزامی است', + headerInvalid: 'نام Header معتبر نیست', + headerDuplicate: 'نام Headers باید یکتا باشد', + headerReserved: 'این Header توسط سیستم مدیریت می‌شود و قابل سفارشی‌سازی نیست', + headerMustBeSecret: 'Headerهای احراز هویت یا اطلاعات دسترسی باید محرمانه تنظیم شوند', + templateVariableInvalid: 'Body شامل متغیر قالب پشتیبانی‌نشده است', + secretRequired: 'مقدار محرمانه را وارد کنید یا بدون تغییر/پاک کردن را انتخاب کنید', + }, + alertConfigProHelper: 'نسخه تجاری کانال‌های اعلان بیشتری ارائه می‌کند.', recipientPlaceholder: 'لطفاً آدرس ایمیل گیرنده را وارد کنید', addRecipient: 'افزودن گیرنده', webhookItem: 'وب‌هوک', diff --git a/frontend/src/lang/modules/ja.ts b/frontend/src/lang/modules/ja.ts index 2d51a4287..6fe2c82cb 100644 --- a/frontend/src/lang/modules/ja.ts +++ b/frontend/src/lang/modules/ja.ts @@ -937,7 +937,6 @@ const message = { from_remote: 'このモデルは1Panelを介してダウンロードされておらず、関連するプルログはありません。', no_logs: 'このモデルのプルログは削除されており、関連するログを表示できません。', vllmVersionHelper: 'FusionXpark GB 10 サーバーでは -cu130 バージョンを選択してください。', - ascendVisibleDevices: 'Ascend 可視デバイス', vllmCommandPortHelper: '起動コマンドではポート {0} を使用する必要があります。使用しない場合、サービスにアクセスできません。', ascendVisibleDevices: 'Ascend 可視デバイス(ASCEND_RT_VISIBLE_DEVICES)', @@ -1129,7 +1128,6 @@ const message = { cachedToken: 'キャッシュ Token', cacheHitRate: 'キャッシュヒット率', activeUsers: 'アクティブユーザー', - activeStreamingRequests: 'アクティブなストリーミングリクエスト', activeModels: 'アクティブモデル', failedRequests: '失敗リクエスト', averageTokenPerRequest: '平均 Token/リクエスト', @@ -6699,7 +6697,57 @@ const message = { barkConfigHelper: 'Barkアラート通知設定', webhookName: 'ボット名', webhookUrl: 'Webhook URL', - alertConfigProHelper: '商用版では、WeCom、DingTalk、Feishu、SMS 通知も利用できます。', + custom: 'Webhook', + webhookPreset: 'プリセット', + genericJsonPreset: '汎用 JSON', + customPreset: 'カスタム', + webhookUrlSecretHelper: 'Webhook URL は暗号化して保存され、ここで表示および編集できます', + webhookPublicAddressHelper: + '公開ネットワークから到達可能な HTTP/HTTPS アドレスのみ対応し、ローカル、プライベート、予約済みアドレスは拒否されます', + customWebhookRecoveryRequired: + 'この設定は無効か旧バージョンのものです。Webhook URL と必要なシークレット Header を再入力して保存してください。', + clearSecret: 'クリア', + keepSecret: '変更しない', + secretCleared: '保存するとこのシークレットはクリアされます', + bodyType: 'Body タイプ', + bodyTemplate: 'Body テンプレート', + formFieldName: 'フィールド名', + formFieldValue: 'フィールド値', + addFormField: 'フィールドを追加', + webhookAdvanced: '詳細設定', + headers: 'Headers', + headerName: 'Header 名', + headerValue: 'Header 値', + secretValue: 'シークレット値', + addHeader: 'Header を追加', + templateVariables: 'テンプレート変数', + templateVariableTitle: 'アラートタイトル', + templateVariableMessage: 'アラート内容', + templateVariableType: 'アラートタイプ', + templateVariableNodeName: 'ノード名', + templateVariableTimestamp: '発生時刻', + templateVariablesHelper: + 'title=アラートタイトル、message=アラート内容、type=アラートタイプ、nodeName=ノード名、timestamp=発生時刻。変数をクリックすると Body に挿入できます。', + testResultStale: '設定が変更されたため、前回のテスト結果は無効です', + alertConfigChanged: '設定が更新されました。更新してからもう一度お試しください', + presetOverwriteHelper: 'プリセットを切り替えると現在の Body 設定が上書きされます。続行しますか?', + customWebhookValidation: { + displayNameRequired: '表示名を入力してください', + urlRequired: 'Webhook URL を入力してください', + urlInvalid: 'Webhook URL には有効な HTTP または HTTPS URL を指定してください', + bodyRequired: 'リクエスト Body を設定してください', + jsonInvalid: 'JSON Body テンプレートが無効です', + formFieldRequired: 'Form フィールド名は必須です', + formFieldDuplicate: 'Form フィールド名は重複できません', + headerRequired: 'Header 名は必須です', + headerInvalid: 'Header 名が無効です', + headerDuplicate: 'Header 名は重複できません', + headerReserved: 'この Header はシステム管理のためカスタマイズできません', + headerMustBeSecret: '認証情報を含む Header はシークレット値に設定してください', + templateVariableInvalid: 'Body に未対応のテンプレート変数が含まれています', + secretRequired: 'シークレット値を入力するか、変更しない/クリアを選択してください', + }, + alertConfigProHelper: '商用版では、追加の通知チャネルを利用できます。', }, theme: { lingXiaGold: 'LXware Gold', diff --git a/frontend/src/lang/modules/ko.ts b/frontend/src/lang/modules/ko.ts index 08303312d..cca8af5c5 100644 --- a/frontend/src/lang/modules/ko.ts +++ b/frontend/src/lang/modules/ko.ts @@ -927,7 +927,6 @@ const message = { from_remote: '이 모델은 1Panel을 통해 다운로드되지 않았으며 관련 풀 로그가 없습니다.', no_logs: '이 모델의 풀 로그가 삭제되어 관련 로그를 볼 수 없습니다.', vllmVersionHelper: 'FusionXpark GB 10 서버는 -cu130 버전을 선택하세요.', - ascendVisibleDevices: 'Ascend 표시 장치', vllmCommandPortHelper: '시작 명령은 {0} 포트를 사용해야 하며, 그렇지 않으면 서비스에 접근할 수 없습니다.', ascendVisibleDevices: 'Ascend 표시 장치 (ASCEND_RT_VISIBLE_DEVICES)', syncModelAccount: '모델 계정에 동기화', @@ -1118,7 +1117,6 @@ const message = { cachedToken: '캐시 Token', cacheHitRate: '캐시 적중률', activeUsers: '활성 사용자', - activeStreamingRequests: '활성 스트리밍 요청', activeModels: '활성 모델', failedRequests: '실패한 요청', averageTokenPerRequest: '평균 Token/요청', @@ -6563,7 +6561,57 @@ const message = { barkConfigHelper: 'Bark 알림 구성', webhookName: '봇 이름', webhookUrl: 'Webhook URL', - alertConfigProHelper: '상용 버전에서는 WeCom, DingTalk, Feishu, SMS 알림도 지원합니다.', + custom: 'Webhook', + webhookPreset: '프리셋', + genericJsonPreset: '일반 JSON', + customPreset: '사용자 지정', + webhookUrlSecretHelper: 'Webhook URL은 암호화되어 저장되며 여기에서 확인하고 편집할 수 있습니다', + webhookPublicAddressHelper: + '공개 네트워크에서 접근 가능한 HTTP/HTTPS 주소만 지원하며 로컬, 사설 및 예약 주소는 차단됩니다', + customWebhookRecoveryRequired: + '이 설정은 유효하지 않거나 이전 버전에서 생성되었습니다. Webhook URL과 필요한 비밀 Header를 다시 입력한 후 저장하세요.', + clearSecret: '지우기', + keepSecret: '변경하지 않음', + secretCleared: '저장하면 이 비밀 값이 지워집니다', + bodyType: 'Body 유형', + bodyTemplate: 'Body 템플릿', + formFieldName: '필드 이름', + formFieldValue: '필드 값', + addFormField: '필드 추가', + webhookAdvanced: '고급 설정', + headers: 'Headers', + headerName: 'Header 이름', + headerValue: 'Header 값', + secretValue: '비밀 값', + addHeader: 'Header 추가', + templateVariables: '템플릿 변수', + templateVariableTitle: '알림 제목', + templateVariableMessage: '알림 내용', + templateVariableType: '알림 유형', + templateVariableNodeName: '노드 이름', + templateVariableTimestamp: '발생 시간', + templateVariablesHelper: + 'title=알림 제목, message=알림 내용, type=알림 유형, nodeName=노드 이름, timestamp=발생 시간. 변수를 클릭하면 Body에 삽입됩니다.', + testResultStale: '설정이 변경되어 이전 테스트 결과가 더 이상 유효하지 않습니다', + alertConfigChanged: '구성이 업데이트되었습니다. 새로 고친 후 다시 시도하세요.', + presetOverwriteHelper: '프리셋을 전환하면 현재 Body 설정을 덮어씁니다. 계속하시겠습니까?', + customWebhookValidation: { + displayNameRequired: '표시 이름을 입력하세요', + urlRequired: 'Webhook URL을 입력하세요', + urlInvalid: 'Webhook URL은 유효한 HTTP 또는 HTTPS URL이어야 합니다', + bodyRequired: '요청 Body를 설정하세요', + jsonInvalid: 'JSON Body 템플릿이 올바르지 않습니다', + formFieldRequired: 'Form 필드 이름은 필수입니다', + formFieldDuplicate: 'Form 필드 이름은 중복될 수 없습니다', + headerRequired: 'Header 이름은 필수입니다', + headerInvalid: 'Header 이름이 올바르지 않습니다', + headerDuplicate: 'Header 이름은 중복될 수 없습니다', + headerReserved: '이 Header는 시스템에서 관리하므로 사용자 지정할 수 없습니다', + headerMustBeSecret: '인증 또는 자격 증명 Header는 비밀 값으로 설정해야 합니다', + templateVariableInvalid: 'Body에 지원되지 않는 템플릿 변수가 있습니다', + secretRequired: '비밀 값을 입력하거나 변경하지 않음/지우기를 선택하세요', + }, + alertConfigProHelper: '상용 버전에서는 더 많은 알림 채널을 지원합니다.', }, theme: { lingXiaGold: 'LXware Gold', diff --git a/frontend/src/lang/modules/lo.ts b/frontend/src/lang/modules/lo.ts index e3f4681c3..0591bbcce 100644 --- a/frontend/src/lang/modules/lo.ts +++ b/frontend/src/lang/modules/lo.ts @@ -927,7 +927,6 @@ const message = { from_remote: 'ໂມເດວນີ້ບໍ່ໄດ້ດາວໂຫຼດຜ່ານ 1Panel, ບໍ່ມີລັອກການດຶງຂໍ້ມູນທີ່ກ່ຽວຂ້ອງ.', no_logs: 'ລັອກການດຶງຂໍ້ມູນຂອງໂມເດວນີ້ຖືກລຶບແລ້ວ ແລະ ບໍ່ສາມາດເບິ່ງໄດ້.', vllmVersionHelper: 'ສຳລັບເຊີເວີ FusionXpark GB 10, ກະລຸນາເລືອກເວີຊັນ -cu130.', - ascendVisibleDevices: 'ອຸປະກອນ Ascend ທີ່ເຫັນໄດ້', vllmCommandPortHelper: 'ຄຳສັ່ງເລີ່ມຕົ້ນຕ້ອງໃຊ້ພອດ {0}; ບໍ່ດັ່ງນັ້ນຈະບໍ່ສາມາດເຂົ້າເຖິງບໍລິການໄດ້.', ascendVisibleDevices: 'ອຸປະກອນ Ascend ທີ່ເຫັນໄດ້ (ASCEND_RT_VISIBLE_DEVICES)', syncModelAccount: 'ຊິ້ງຄ໌ໄປຍັງບັນຊີໂມເດວ', @@ -1037,7 +1036,6 @@ const message = { gatewayConcurrency: 'ການເຮັດວຽກພ້ອມກັນຂອງເກດເວປັດຈຸບັນ', waitingQueue: 'ຄິວລໍຖ້າປັດຈຸບັນ', currentActiveUsers: 'ຜູ້ໃຊ້ທີ່ກຳລັງໃຊ້ງານ', - activeStreamingRequests: 'ຄຳຮ້ອງຂໍສະຕຣີມທີ່ກຳລັງໃຊ້ງານ', modelAccountConcurrency: 'ການເຮັດວຽກພ້ອມກັນຂອງບັນຊີໂມເດວ', accountAvailability: 'ຄວາມພ້ອມໃຊ້ງານຂອງບັນຊີໂມເດວ', capacityFull: 'ຄວາມຈຸເຕັມ', @@ -6476,7 +6474,57 @@ const message = { feiShuConfigHelper: 'ຕັ້ງຄ່າການແຈ້ງເຕືອນ Feishu', webhookName: 'ຊື່ບັອດ', webhookUrl: 'Webhook URL', - alertConfigProHelper: 'ເວີຊັນ Commercial ຮອງຮັບ WeCom, DingTalk, Feishu ແລະ SMS.', + custom: 'Webhook', + webhookPreset: 'ຄ່າສຳເລັດ', + genericJsonPreset: 'JSON ທົ່ວໄປ', + customPreset: 'ກຳນົດເອງ', + webhookUrlSecretHelper: 'Webhook URL ຈະຖືກເກັບແບບເຂົ້າລະຫັດ ແລະ ສາມາດເບິ່ງ ຫຼື ແກ້ໄຂໄດ້ຢູ່ບ່ອນນີ້', + webhookPublicAddressHelper: + 'ຮອງຮັບສະເພາະທີ່ຢູ່ HTTP/HTTPS ທີ່ເຂົ້າເຖິງໄດ້ຈາກເຄືອຂ່າຍສາທາລະນະ; ທີ່ຢູ່ພາຍໃນ, ສ່ວນຕົວ ແລະ ສຳຮອງຈະຖືກບລັອກ', + customWebhookRecoveryRequired: + 'ການຕັ້ງຄ່ານີ້ບໍ່ຖືກຕ້ອງ ຫຼື ມາຈາກເວີຊັນເກົ່າ. ກະລຸນາປ້ອນ Webhook URL ແລະ Header ລັບທີ່ຈຳເປັນໃໝ່ ແລ້ວບັນທຶກ.', + clearSecret: 'ລ້າງ', + keepSecret: 'ບໍ່ປ່ຽນແປງ', + secretCleared: 'ຄ່າລັບນີ້ຈະຖືກລ້າງເມື່ອບັນທຶກ', + bodyType: 'ປະເພດ Body', + bodyTemplate: 'ແມ່ແບບ Body', + formFieldName: 'ຊື່ຟິວ', + formFieldValue: 'ຄ່າຟິວ', + addFormField: 'ເພີ່ມຟິວ', + webhookAdvanced: 'ຂັ້ນສູງ', + headers: 'Headers', + headerName: 'ຊື່ Header', + headerValue: 'ຄ່າ Header', + secretValue: 'ຄ່າລັບ', + addHeader: 'ເພີ່ມ Header', + templateVariables: 'ຕົວແປແມ່ແບບ', + templateVariableTitle: 'ຫົວຂໍ້ແຈ້ງເຕືອນ', + templateVariableMessage: 'ເນື້ອຫາແຈ້ງເຕືອນ', + templateVariableType: 'ປະເພດແຈ້ງເຕືອນ', + templateVariableNodeName: 'ຊື່ໂນດ', + templateVariableTimestamp: 'ເວລາເກີດເຫດ', + templateVariablesHelper: + 'title=ຫົວຂໍ້ແຈ້ງເຕືອນ, message=ເນື້ອຫາ, type=ປະເພດ, nodeName=ຊື່ໂນດ, timestamp=ເວລາເກີດເຫດ. ຄລິກຕົວແປເພື່ອແຊກໃນ Body.', + testResultStale: 'ການຕັ້ງຄ່າປ່ຽນແລ້ວ; ຜົນທົດສອບກ່ອນໜ້າບໍ່ຖືກຕ້ອງອີກ', + alertConfigChanged: 'ການຕັ້ງຄ່າຖືກອັບເດດແລ້ວ. ໂຫຼດໃໝ່ແລ້ວລອງອີກຄັ້ງ.', + presetOverwriteHelper: 'ການປ່ຽນຄ່າສຳເລັດຈະແທນການຕັ້ງຄ່າ Body ປັດຈຸບັນ. ສືບຕໍ່ບໍ?', + customWebhookValidation: { + displayNameRequired: 'ປ້ອນຊື່ສະແດງ', + urlRequired: 'ປ້ອນ Webhook URL', + urlInvalid: 'Webhook URL ຕ້ອງເປັນ HTTP ຫຼື HTTPS ທີ່ຖືກຕ້ອງ', + bodyRequired: 'ຕັ້ງຄ່າ Body ຂອງຄຳຂໍ', + jsonInvalid: 'ແມ່ແບບ JSON Body ບໍ່ຖືກຕ້ອງ', + formFieldRequired: 'ຊື່ຟິວ Form ແມ່ນຈຳເປັນ', + formFieldDuplicate: 'ຊື່ຟິວ Form ຕ້ອງບໍ່ຊ້ຳກັນ', + headerRequired: 'ຊື່ Headers ແມ່ນຈຳເປັນ', + headerInvalid: 'ຊື່ Header ບໍ່ຖືກຕ້ອງ', + headerDuplicate: 'ຊື່ Headers ຕ້ອງບໍ່ຊ້ຳກັນ', + headerReserved: 'Header ນີ້ຖືກຈັດການໂດຍລະບົບ ແລະ ປັບແຕ່ງບໍ່ໄດ້', + headerMustBeSecret: 'Header ການຢືນຢັນ ຫຼື ຂໍ້ມູນຮັບຮອງຕ້ອງຕັ້ງເປັນຄ່າລັບ', + templateVariableInvalid: 'Body ມີຕົວແປແມ່ແບບທີ່ບໍ່ຮອງຮັບ', + secretRequired: 'ປ້ອນຄ່າລັບ ຫຼື ເລືອກບໍ່ປ່ຽນແປງ/ລ້າງ', + }, + alertConfigProHelper: 'ເວີຊັນ Commercial ເພີ່ມຊ່ອງທາງແຈ້ງເຕືອນອື່ນໆ.', recipientPlaceholder: 'ກະລຸນາປ້ອນທີ່ຢູ່ອີເມລຜູ້ຮັບ', addRecipient: 'ເພີ່ມຜູ້ຮັບ', webhookItem: 'Webhook', diff --git a/frontend/src/lang/modules/ms.ts b/frontend/src/lang/modules/ms.ts index 78ad94245..09ced39dc 100644 --- a/frontend/src/lang/modules/ms.ts +++ b/frontend/src/lang/modules/ms.ts @@ -958,7 +958,6 @@ const message = { from_remote: 'Model ini tidak dimuat turun melalui 1Panel; tiada log muat turun berkaitan.', no_logs: 'Log muat turun model ini telah dipadam dan tidak boleh dilihat.', vllmVersionHelper: 'Untuk pelayan FusionXpark GB 10, sila pilih versi -cu130.', - ascendVisibleDevices: 'Peranti Ascend yang kelihatan', vllmCommandPortHelper: 'Perintah permulaan mesti menggunakan port {0}; jika tidak, perkhidmatan tidak dapat diakses.', ascendVisibleDevices: 'Peranti Ascend boleh dilihat (ASCEND_RT_VISIBLE_DEVICES)', @@ -1151,7 +1150,6 @@ const message = { cachedToken: 'Token cache', cacheHitRate: 'Kadar hit cache', activeUsers: 'Pengguna aktif', - activeStreamingRequests: 'Permintaan penstriman aktif', activeModels: 'Model aktif', failedRequests: 'Permintaan gagal', averageTokenPerRequest: 'Purata Token/permintaan', @@ -6809,7 +6807,57 @@ const message = { barkConfigHelper: 'Konfigurasi pemberitahuan amaran Bark', webhookName: 'Nama bot', webhookUrl: 'URL Webhook', - alertConfigProHelper: 'Edisi Komersial turut menyokong amaran WeCom, DingTalk, Feishu dan SMS.', + custom: 'Webhook', + webhookPreset: 'Pratetap', + genericJsonPreset: 'JSON umum', + customPreset: 'Tersuai', + webhookUrlSecretHelper: 'URL Webhook disimpan secara disulitkan dan boleh dilihat serta diedit di sini', + webhookPublicAddressHelper: + 'Hanya alamat HTTP/HTTPS yang boleh dicapai secara awam disokong; alamat setempat, peribadi dan simpanan disekat', + customWebhookRecoveryRequired: + 'Konfigurasi ini tidak sah atau daripada versi lama. Masukkan semula URL Webhook dan Header rahsia yang diperlukan, kemudian simpan.', + clearSecret: 'Kosongkan', + keepSecret: 'Kekalkan', + secretCleared: 'Rahsia ini akan dikosongkan apabila disimpan', + bodyType: 'Jenis Body', + bodyTemplate: 'Templat Body', + formFieldName: 'Nama medan', + formFieldValue: 'Nilai medan', + addFormField: 'Tambah medan', + webhookAdvanced: 'Lanjutan', + headers: 'Headers', + headerName: 'Nama Header', + headerValue: 'Nilai Header', + secretValue: 'Nilai rahsia', + addHeader: 'Tambah Header', + templateVariables: 'Pemboleh ubah templat', + templateVariableTitle: 'Tajuk amaran', + templateVariableMessage: 'Kandungan amaran', + templateVariableType: 'Jenis amaran', + templateVariableNodeName: 'Nama nod', + templateVariableTimestamp: 'Masa kejadian', + templateVariablesHelper: + 'title=tajuk amaran, message=kandungan, type=jenis, nodeName=nama nod, timestamp=masa kejadian. Klik pemboleh ubah untuk memasukkannya ke dalam Body.', + testResultStale: 'Konfigurasi telah berubah; keputusan ujian sebelumnya tidak lagi sah', + alertConfigChanged: 'Konfigurasi telah dikemas kini. Muat semula dan cuba lagi.', + presetOverwriteHelper: 'Menukar pratetap akan menggantikan konfigurasi Body semasa. Teruskan?', + customWebhookValidation: { + displayNameRequired: 'Masukkan nama paparan', + urlRequired: 'Masukkan URL Webhook', + urlInvalid: 'URL Webhook mestilah URL HTTP atau HTTPS yang sah', + bodyRequired: 'Konfigurasikan Body permintaan', + jsonInvalid: 'Templat JSON Body tidak sah', + formFieldRequired: 'Nama medan Form diperlukan', + formFieldDuplicate: 'Nama medan Form mestilah unik', + headerRequired: 'Nama Headers diperlukan', + headerInvalid: 'Nama Header tidak sah', + headerDuplicate: 'Nama Headers mestilah unik', + headerReserved: 'Header ini diurus oleh sistem dan tidak boleh disesuaikan', + headerMustBeSecret: 'Header pengesahan atau kelayakan mesti ditandai sebagai nilai rahsia', + templateVariableInvalid: 'Body mengandungi pemboleh ubah templat yang tidak disokong', + secretRequired: 'Masukkan nilai rahsia atau pilih Kekalkan atau Kosongkan', + }, + alertConfigProHelper: 'Edisi Komersial menambah lebih banyak saluran pemberitahuan.', }, theme: { lingXiaGold: 'LXware Gold', diff --git a/frontend/src/lang/modules/pt-br.ts b/frontend/src/lang/modules/pt-br.ts index b5c4c2f1a..52eb90409 100644 --- a/frontend/src/lang/modules/pt-br.ts +++ b/frontend/src/lang/modules/pt-br.ts @@ -954,7 +954,6 @@ const message = { from_remote: 'Este modelo não foi baixado pelo 1Panel; não há logs de download relacionados.', no_logs: 'Os logs de download deste modelo foram excluídos e não podem ser visualizados.', vllmVersionHelper: 'Para servidores FusionXpark GB 10, selecione a versão -cu130.', - ascendVisibleDevices: 'Dispositivos Ascend visíveis', vllmCommandPortHelper: 'O comando de inicialização deve usar a porta {0}; caso contrário, o serviço ficará inacessível.', ascendVisibleDevices: 'Dispositivos Ascend visíveis (ASCEND_RT_VISIBLE_DEVICES)', @@ -1148,7 +1147,6 @@ const message = { cachedToken: 'Tokens em cache', cacheHitRate: 'Taxa de acerto do cache', activeUsers: 'Usuários ativos', - activeStreamingRequests: 'Solicitações de streaming ativas', activeModels: 'Modelos ativos', failedRequests: 'Requisições com falha', averageTokenPerRequest: 'Média de Token/requisição', @@ -6848,8 +6846,57 @@ const message = { barkConfigHelper: 'Configuração de notificação de alerta Bark', webhookName: 'Nome do bot', webhookUrl: 'URL do Webhook', - alertConfigProHelper: - 'A edição comercial também oferece suporte a alertas via WeCom, DingTalk, Feishu e SMS.', + custom: 'Webhook', + webhookPreset: 'Predefinição', + genericJsonPreset: 'JSON genérico', + customPreset: 'Personalizado', + webhookUrlSecretHelper: 'A URL do Webhook é armazenada criptografada e pode ser vista e editada aqui', + webhookPublicAddressHelper: + 'Somente endereços HTTP/HTTPS acessíveis publicamente são aceitos; endereços locais, privados e reservados são bloqueados', + customWebhookRecoveryRequired: + 'Esta configuração é inválida ou veio de uma versão anterior. Informe novamente a URL do Webhook e os headers secretos necessários e salve.', + clearSecret: 'Limpar', + keepSecret: 'Manter inalterado', + secretCleared: 'Este segredo será limpo ao salvar', + bodyType: 'Tipo do Body', + bodyTemplate: 'Modelo do Body', + formFieldName: 'Nome do campo', + formFieldValue: 'Valor do campo', + addFormField: 'Adicionar campo', + webhookAdvanced: 'Avançado', + headers: 'Headers', + headerName: 'Nome do Header', + headerValue: 'Valor do Header', + secretValue: 'Valor secreto', + addHeader: 'Adicionar Header', + templateVariables: 'Variáveis do modelo', + templateVariableTitle: 'Título do alerta', + templateVariableMessage: 'Conteúdo do alerta', + templateVariableType: 'Tipo de alerta', + templateVariableNodeName: 'Nome do nó', + templateVariableTimestamp: 'Hora do evento', + templateVariablesHelper: + 'title=título do alerta, message=conteúdo, type=tipo, nodeName=nome do nó, timestamp=hora do evento. Clique para inserir a variável no Body.', + testResultStale: 'A configuração foi alterada; o resultado do teste anterior não é mais válido', + alertConfigChanged: 'A configuração foi atualizada. Atualize a página e tente novamente.', + presetOverwriteHelper: 'Trocar a predefinição substituirá a configuração atual do Body. Continuar?', + customWebhookValidation: { + displayNameRequired: 'Informe um nome de exibição', + urlRequired: 'Informe uma URL de Webhook', + urlInvalid: 'A URL do Webhook deve ser uma URL HTTP ou HTTPS válida', + bodyRequired: 'Configure o Body da solicitação', + jsonInvalid: 'O modelo JSON do Body é inválido', + formFieldRequired: 'Os nomes dos campos Form são obrigatórios', + formFieldDuplicate: 'Os nomes dos campos Form devem ser únicos', + headerRequired: 'Os nomes dos Headers são obrigatórios', + headerInvalid: 'O nome do Header é inválido', + headerDuplicate: 'Os nomes dos Headers devem ser únicos', + headerReserved: 'Este Header é gerenciado pelo sistema e não pode ser personalizado', + headerMustBeSecret: 'Headers de autenticação ou credenciais devem ser marcados como valores secretos', + templateVariableInvalid: 'O Body contém uma variável de modelo não compatível', + secretRequired: 'Informe um segredo ou escolha Manter inalterado ou Limpar', + }, + alertConfigProHelper: 'A edição comercial adiciona mais canais de notificação.', }, theme: { lingXiaGold: 'LXware Gold', diff --git a/frontend/src/lang/modules/ru.ts b/frontend/src/lang/modules/ru.ts index 9c66e0b9d..f8c7a10f4 100644 --- a/frontend/src/lang/modules/ru.ts +++ b/frontend/src/lang/modules/ru.ts @@ -947,7 +947,6 @@ const message = { from_remote: 'Эта модель не была загружена через 1Panel, нет связанных журналов извлечения.', no_logs: 'Журналы извлечения для этой модели были удалены и не могут быть просмотрены.', vllmVersionHelper: 'Для серверов FusionXpark GB 10 выберите версию -cu130.', - ascendVisibleDevices: 'Видимые устройства Ascend', vllmCommandPortHelper: 'Команда запуска должна использовать порт {0}, иначе сервис будет недоступен.', ascendVisibleDevices: 'Видимые устройства Ascend (ASCEND_RT_VISIBLE_DEVICES)', syncModelAccount: 'Синхронизировать с аккаунтом модели', @@ -1139,7 +1138,6 @@ const message = { cachedToken: 'Кэшированные Token', cacheHitRate: 'Попадания в кэш', activeUsers: 'Активные пользователи', - activeStreamingRequests: 'Активные потоковые запросы', activeModels: 'Активные модели', failedRequests: 'Неуспешные запросы', averageTokenPerRequest: 'Среднее Token/запрос', @@ -6817,8 +6815,58 @@ const message = { barkConfigHelper: 'Конфигурация уведомлений Bark', webhookName: 'Имя бота', webhookUrl: 'URL Webhook', - alertConfigProHelper: - 'Коммерческая версия дополнительно поддерживает уведомления через WeCom, DingTalk, Feishu и SMS.', + custom: 'Webhook', + webhookPreset: 'Предустановка', + genericJsonPreset: 'Универсальный JSON', + customPreset: 'Пользовательский', + webhookUrlSecretHelper: + 'URL Webhook хранится в зашифрованном виде; здесь его можно просматривать и изменять', + webhookPublicAddressHelper: + 'Поддерживаются только общедоступные адреса HTTP/HTTPS; локальные, частные и зарезервированные адреса блокируются', + customWebhookRecoveryRequired: + 'Эта конфигурация недействительна или создана в старой версии. Повторно укажите URL Webhook и необходимые секретные заголовки, затем сохраните.', + clearSecret: 'Очистить', + keepSecret: 'Не изменять', + secretCleared: 'Этот секрет будет очищен при сохранении', + bodyType: 'Тип Body', + bodyTemplate: 'Шаблон Body', + formFieldName: 'Имя поля', + formFieldValue: 'Значение поля', + addFormField: 'Добавить поле', + webhookAdvanced: 'Дополнительно', + headers: 'Headers', + headerName: 'Имя Header', + headerValue: 'Значение Header', + secretValue: 'Секретное значение', + addHeader: 'Добавить Header', + templateVariables: 'Переменные шаблона', + templateVariableTitle: 'Заголовок оповещения', + templateVariableMessage: 'Содержимое оповещения', + templateVariableType: 'Тип оповещения', + templateVariableNodeName: 'Имя узла', + templateVariableTimestamp: 'Время события', + templateVariablesHelper: + 'title=заголовок, message=содержимое, type=тип, nodeName=имя узла, timestamp=время события. Нажмите переменную, чтобы вставить её в Body.', + testResultStale: 'Конфигурация изменена, поэтому предыдущий результат теста больше недействителен', + alertConfigChanged: 'Конфигурация обновлена. Обновите страницу и повторите попытку.', + presetOverwriteHelper: 'Смена предустановки заменит текущую конфигурацию Body. Продолжить?', + customWebhookValidation: { + displayNameRequired: 'Введите отображаемое имя', + urlRequired: 'Введите URL Webhook', + urlInvalid: 'URL Webhook должен быть корректным адресом HTTP или HTTPS', + bodyRequired: 'Настройте Body запроса', + jsonInvalid: 'Недопустимый шаблон JSON Body', + formFieldRequired: 'Имена полей Form обязательны', + formFieldDuplicate: 'Имена полей Form не должны повторяться', + headerRequired: 'Имена Headers обязательны', + headerInvalid: 'Недопустимое имя Header', + headerDuplicate: 'Имена Headers не должны повторяться', + headerReserved: 'Этот Header управляется системой и не может быть изменён', + headerMustBeSecret: 'Заголовки аутентификации или учетных данных должны быть отмечены как секретные', + templateVariableInvalid: 'Body содержит неподдерживаемую переменную шаблона', + secretRequired: 'Введите секрет или выберите Не изменять либо Очистить', + }, + alertConfigProHelper: 'Коммерческая версия добавляет дополнительные каналы уведомлений.', }, theme: { lingXiaGold: 'LXware Gold', diff --git a/frontend/src/lang/modules/tr.ts b/frontend/src/lang/modules/tr.ts index 7745bc810..751b2ab65 100644 --- a/frontend/src/lang/modules/tr.ts +++ b/frontend/src/lang/modules/tr.ts @@ -956,7 +956,6 @@ const message = { from_remote: 'Bu model 1Panel aracılığıyla indirilmedi, ilgili çekme logları yok.', no_logs: 'Bu modelin çekme logları silindi ve görüntülenemiyor.', vllmVersionHelper: 'FusionXpark GB 10 sunucuları için lütfen -cu130 sürümünü seçin.', - ascendVisibleDevices: 'Görünür Ascend cihazları', vllmCommandPortHelper: 'Başlatma komutu {0} numaralı bağlantı noktasını kullanmalıdır; aksi halde hizmete erişilemez.', ascendVisibleDevices: 'Görünür Ascend cihazları (ASCEND_RT_VISIBLE_DEVICES)', @@ -1148,7 +1147,6 @@ const message = { cachedToken: 'Önbellek Token', cacheHitRate: 'Önbellek İsabet Oranı', activeUsers: 'Aktif kullanıcılar', - activeStreamingRequests: 'Etkin akış istekleri', activeModels: 'Aktif modeller', failedRequests: 'Başarısız istekler', averageTokenPerRequest: 'Ortalama Token/istek', @@ -6817,7 +6815,58 @@ const message = { barkConfigHelper: 'Bark uyarı bildirim yapılandırması', webhookName: 'Bot adı', webhookUrl: 'Webhook URL', - alertConfigProHelper: 'Ticari sürüm ayrıca WeCom, DingTalk, Feishu ve SMS bildirimlerini destekler.', + custom: 'Webhook', + webhookPreset: 'Ön ayar', + genericJsonPreset: 'Genel JSON', + customPreset: 'Özel', + webhookUrlSecretHelper: 'Webhook URL şifreli olarak saklanır ve burada görüntülenip düzenlenebilir', + webhookPublicAddressHelper: + 'Yalnızca genel ağdan erişilebilen HTTP/HTTPS adresleri desteklenir; yerel, özel ve ayrılmış adresler engellenir', + customWebhookRecoveryRequired: + 'Bu yapılandırma geçersiz veya eski bir sürümden geliyor. Webhook URL ve gerekli gizli Header değerlerini yeniden girip kaydedin.', + clearSecret: 'Temizle', + keepSecret: 'Değiştirme', + secretCleared: 'Kaydettiğinizde bu gizli değer temizlenecek', + bodyType: 'Body türü', + bodyTemplate: 'Body şablonu', + formFieldName: 'Alan adı', + formFieldValue: 'Alan değeri', + addFormField: 'Alan ekle', + webhookAdvanced: 'Gelişmiş', + headers: 'Headers', + headerName: 'Header adı', + headerValue: 'Header değeri', + secretValue: 'Gizli değer', + addHeader: 'Header ekle', + templateVariables: 'Şablon değişkenleri', + templateVariableTitle: 'Uyarı başlığı', + templateVariableMessage: 'Uyarı içeriği', + templateVariableType: 'Uyarı türü', + templateVariableNodeName: 'Düğüm adı', + templateVariableTimestamp: 'Olay zamanı', + templateVariablesHelper: + 'title=uyarı başlığı, message=içerik, type=tür, nodeName=düğüm adı, timestamp=olay zamanı. Body içine eklemek için değişkene tıklayın.', + testResultStale: 'Yapılandırma değişti; önceki test sonucu artık geçerli değil', + alertConfigChanged: 'Yapılandırma güncellendi. Yenileyip tekrar deneyin.', + presetOverwriteHelper: 'Ön ayarı değiştirmek mevcut Body yapılandırmasını değiştirecek. Devam edilsin mi?', + customWebhookValidation: { + displayNameRequired: 'Bir görünen ad girin', + urlRequired: 'Bir Webhook URL girin', + urlInvalid: 'Webhook URL geçerli bir HTTP veya HTTPS URL olmalıdır', + bodyRequired: 'İstek Body içeriğini yapılandırın', + jsonInvalid: 'JSON Body şablonu geçersiz', + formFieldRequired: 'Form alan adları zorunludur', + formFieldDuplicate: 'Form alan adları benzersiz olmalıdır', + headerRequired: 'Header adları zorunludur', + headerInvalid: 'Header adı geçersiz', + headerDuplicate: 'Header adları benzersiz olmalıdır', + headerReserved: 'Bu Header sistem tarafından yönetilir ve özelleştirilemez', + headerMustBeSecret: + 'Kimlik doğrulama veya kimlik bilgisi Header alanları gizli değer olarak işaretlenmelidir', + templateVariableInvalid: 'Body desteklenmeyen bir şablon değişkeni içeriyor', + secretRequired: 'Bir gizli değer girin veya Değiştirme ya da Temizle seçeneğini kullanın', + }, + alertConfigProHelper: 'Ticari sürüm daha fazla bildirim kanalı ekler.', }, theme: { lingXiaGold: 'LXware Gold', diff --git a/frontend/src/lang/modules/zh-Hant.ts b/frontend/src/lang/modules/zh-Hant.ts index 32423c5c2..e72f95f21 100644 --- a/frontend/src/lang/modules/zh-Hant.ts +++ b/frontend/src/lang/modules/zh-Hant.ts @@ -898,7 +898,6 @@ const message = { from_remote: '該模型並非透過 1Panel 下載,無相關拉取日誌。', no_logs: '該模型的拉取日誌已被刪除,無法檢視相關日誌。', vllmVersionHelper: 'FusionXpark GB 10 伺服器請選擇 -cu130 版本', - ascendVisibleDevices: 'Ascend 可見裝置', vllmCommandPortHelper: '啟動命令必須使用 {0} 連接埠,否則服務將無法存取。', ascendVisibleDevices: 'Ascend 可見裝置(ASCEND_RT_VISIBLE_DEVICES)', syncModelAccount: '同步到模型帳號', @@ -1083,7 +1082,6 @@ const message = { cachedToken: '快取 Token', cacheHitRate: '快取命中率', activeUsers: '活躍使用者', - activeStreamingRequests: '活躍串流請求', activeModels: '活躍模型', failedRequests: '失敗請求', averageTokenPerRequest: '平均 Token/請求', @@ -6229,7 +6227,56 @@ const message = { barkConfigHelper: 'Bark 告警通知設定', webhookName: '機器人名稱', webhookUrl: 'Webhook 位址', - alertConfigProHelper: '商業版額外支援企業微信、釘釘、飛書及簡訊告警。', + custom: 'Webhook', + webhookPreset: '預設', + genericJsonPreset: '通用 JSON', + customPreset: '自訂', + webhookUrlSecretHelper: 'Webhook 位址會加密儲存,可在此檢視與編輯', + webhookPublicAddressHelper: '僅支援可由公網存取的 HTTP/HTTPS 位址;本機、內網和保留位址會被攔截', + customWebhookRecoveryRequired: + '目前設定已失效或來自舊版本,請重新填寫 Webhook 位址及所需的敏感 Header 後儲存', + clearSecret: '清除', + keepSecret: '保持不變', + secretCleared: '儲存後將清除此敏感值', + bodyType: 'Body 類型', + bodyTemplate: 'Body 範本', + formFieldName: '欄位名稱', + formFieldValue: '欄位值', + addFormField: '新增欄位', + webhookAdvanced: '進階設定', + headers: 'Headers', + headerName: 'Header 名稱', + headerValue: 'Header 值', + secretValue: '敏感值', + addHeader: '新增 Header', + templateVariables: '範本變數', + templateVariableTitle: '告警標題', + templateVariableMessage: '告警內容', + templateVariableType: '告警類型', + templateVariableNodeName: '節點名稱', + templateVariableTimestamp: '發生時間', + templateVariablesHelper: + 'title=告警標題,message=告警內容,type=告警類型,nodeName=節點名稱,timestamp=發生時間;點選變數可插入 Body。', + testResultStale: '設定已變更,先前的測試結果已失效', + alertConfigChanged: '設定已更新,請重新整理後再試', + presetOverwriteHelper: '切換預設將覆寫目前的 Body 設定,是否繼續?', + customWebhookValidation: { + displayNameRequired: '請輸入顯示名稱', + urlRequired: '請輸入 Webhook 位址', + urlInvalid: 'Webhook 位址必須是有效的 HTTP 或 HTTPS 位址', + bodyRequired: '請設定請求 Body', + jsonInvalid: 'JSON Body 範本格式無效', + formFieldRequired: 'Form 欄位名稱不能為空', + formFieldDuplicate: 'Form 欄位名稱不能重複', + headerRequired: 'Header 名稱不能為空', + headerInvalid: 'Header 名稱格式無效', + headerDuplicate: 'Header 名稱不能重複', + headerReserved: '此 Header 由系統管理,無法自訂', + headerMustBeSecret: '驗證或憑證類 Header 必須設為敏感值', + templateVariableInvalid: 'Body 包含不支援的範本變數', + secretRequired: '請輸入敏感值,或選擇保持不變/清除', + }, + alertConfigProHelper: '商業版額外支援更多通知通道。', }, theme: { lingXiaGold: 'LXware Gold', diff --git a/frontend/src/lang/modules/zh.ts b/frontend/src/lang/modules/zh.ts index 011386029..f06d92ee7 100644 --- a/frontend/src/lang/modules/zh.ts +++ b/frontend/src/lang/modules/zh.ts @@ -911,7 +911,6 @@ const message = { from_remote: '该模型并非通过 1Panel 下载,无相关拉取日志。', no_logs: '该模型的拉取日志已被删除,无法查看相关日志。', vllmVersionHelper: 'FusionXpark GB 10 服务器请选择 -cu130 版本', - ascendVisibleDevices: 'Ascend 可见设备', vllmCommandPortHelper: '启动命令必须使用 {0} 端口,否则服务将无法访问。', ascendVisibleDevices: 'Ascend 可见设备(ASCEND_RT_VISIBLE_DEVICES)', syncModelAccount: '同步到模型账号', @@ -1097,7 +1096,6 @@ const message = { cachedToken: '缓存 Token', cacheHitRate: '缓存命中率', activeUsers: '活跃用户', - activeStreamingRequests: '活跃流式请求', activeModels: '活跃模型', failedRequests: '失败请求', averageTokenPerRequest: '平均 Token/请求', @@ -6286,6 +6284,55 @@ const message = { barkConfigHelper: 'Bark 告警通知配置', webhookName: '机器人名称', webhookUrl: 'Webhook 地址', + custom: 'Webhook', + webhookPreset: '预设', + genericJsonPreset: '通用 JSON', + customPreset: '自定义', + webhookUrlSecretHelper: 'Webhook 地址加密存储,可在此查看和编辑', + webhookPublicAddressHelper: '仅支持公网可访问的 HTTP/HTTPS 地址;本机、内网和保留地址会被拦截', + customWebhookRecoveryRequired: + '当前配置已失效或来自旧版本,请重新填写 Webhook 地址及所需的敏感 Header 后保存', + clearSecret: '清除', + keepSecret: '保持不变', + secretCleared: '保存后将清除此敏感值', + bodyType: 'Body 类型', + bodyTemplate: 'Body 模板', + formFieldName: '字段名', + formFieldValue: '字段值', + addFormField: '添加字段', + webhookAdvanced: '高级配置', + headers: 'Headers', + headerName: 'Header 名称', + headerValue: 'Header 值', + secretValue: '敏感值', + addHeader: '添加 Header', + templateVariables: '模板变量', + templateVariableTitle: '告警标题', + templateVariableMessage: '告警内容', + templateVariableType: '告警类型', + templateVariableNodeName: '节点名称', + templateVariableTimestamp: '发生时间', + templateVariablesHelper: + 'title=告警标题,message=告警内容,type=告警类型,nodeName=节点名称,timestamp=发生时间;点击变量可插入 Body。', + testResultStale: '配置已更改,之前的测试结果已失效', + alertConfigChanged: '配置已更新,请刷新后重试', + presetOverwriteHelper: '切换预设将覆盖当前 Body 配置,是否继续?', + customWebhookValidation: { + displayNameRequired: '请输入显示名称', + urlRequired: '请输入 Webhook 地址', + urlInvalid: 'Webhook 地址必须是有效的 HTTP 或 HTTPS 地址', + bodyRequired: '请配置请求 Body', + jsonInvalid: 'JSON Body 模板格式无效', + formFieldRequired: 'Form 字段名不能为空', + formFieldDuplicate: 'Form 字段名不能重复', + headerRequired: 'Header 名称不能为空', + headerInvalid: 'Header 名称格式无效', + headerDuplicate: 'Header 名称不能重复', + headerReserved: '该 Header 由系统管理,不能自定义', + headerMustBeSecret: '认证或凭证类 Header 必须设为敏感值', + templateVariableInvalid: 'Body 中包含不支持的模板变量', + secretRequired: '请输入敏感值,或选择保持不变/清除', + }, alertConfigProHelper: '商业版额外支持企业微信、钉钉、飞书及短信告警。', recipientPlaceholder: '请输入收件人邮箱地址', addRecipient: '添加收件人', diff --git a/frontend/src/views/cronjob/cronjob/operate/index.vue b/frontend/src/views/cronjob/cronjob/operate/index.vue index 131f25f84..96de8f0ef 100644 --- a/frontend/src/views/cronjob/cronjob/operate/index.vue +++ b/frontend/src/views/cronjob/cronjob/operate/index.vue @@ -870,6 +870,7 @@ import { splitTimeFromSecond, transferTimeToSecond } from '@/utils/validate'; import { getGroupList } from '@/api/modules/group'; import { routerToName, routerToPath } from '@/utils/router'; import { loadBaseDir } from '@/api/modules/setting'; +import { getAlertConfigDisplayName } from '@/views/setting/alert/setting/drawer/secret-field'; const router = useRouter(); const { docsUrl, isFxplay, isProductPro } = useGlobalStore(); @@ -914,6 +915,8 @@ const legacyAlertMethodTypeMap: Record = { weCom: 'weCom', dingTalk: 'dingTalk', feiShu: 'feiShu', + webhook: 'custom', + custom: 'custom', }; const normalizeAlertMethodItems = (methods: string[]) => { @@ -935,7 +938,7 @@ const groupedAlertConfigOptions = computed(() => { type: string; options: { value: string; label: string; typeLabel: string }[]; }[] = []; - const typeOrder = ['email', 'sms', 'weCom', 'dingTalk', 'feiShu', 'bark']; + const typeOrder = ['email', 'sms', 'weCom', 'dingTalk', 'feiShu', 'bark', 'custom']; for (const t of typeOrder) { if (typeMap.has(t)) { const typeLabel = getConfigTypeLabel(t); @@ -948,6 +951,14 @@ const groupedAlertConfigOptions = computed(() => { }); } } + for (const [type, options] of typeMap) { + if (typeOrder.includes(type)) continue; + const typeLabel = getConfigTypeLabel(type); + groups.push({ + type, + options: options.map((item) => ({ ...item, typeLabel })), + }); + } return groups; }); @@ -957,8 +968,11 @@ const getConfigTypeLabel = (type: string): string => { const getAlertConfigOptionLabel = (c: Alert.AlertConfigInfo): string => { try { - const cfg = JSON.parse(c.config || '{}'); - return cfg.displayName || i18n.global.t(`xpack.alert.${c.type === 'email' ? 'mail' : c.type}`); + const cfg = JSON.parse(c.config || '{}') as Record; + return ( + getAlertConfigDisplayName(c.type, cfg) || + i18n.global.t(`xpack.alert.${c.type === 'email' ? 'mail' : c.type}`) + ); } catch { return i18n.global.t(`xpack.alert.${c.type === 'email' ? 'mail' : c.type}`); } diff --git a/frontend/src/views/setting/alert/dash/index.vue b/frontend/src/views/setting/alert/dash/index.vue index cbe1f0229..10ac7f699 100644 --- a/frontend/src/views/setting/alert/dash/index.vue +++ b/frontend/src/views/setting/alert/dash/index.vue @@ -160,6 +160,7 @@ + + diff --git a/frontend/src/views/setting/alert/setting/drawer/custom-webhook.ts b/frontend/src/views/setting/alert/setting/drawer/custom-webhook.ts new file mode 100644 index 000000000..a36a5ab57 --- /dev/null +++ b/frontend/src/views/setting/alert/setting/drawer/custom-webhook.ts @@ -0,0 +1,653 @@ +export const CUSTOM_WEBHOOK_TYPE = 'custom'; +export const CUSTOM_WEBHOOK_SCHEMA_VERSION = 1 as const; +export const CUSTOM_WEBHOOK_LIMITS = { + url: 8192, + headers: 64, + headerName: 256, + headerValue: 16 * 1024, + body: 256 * 1024, + formFields: 128, +} as const; + +export type CustomWebhookPreset = 'genericJson' | 'slack' | 'discord' | 'teamsWorkflows' | 'custom'; +export type CustomWebhookBodyType = 'json' | 'form' | 'text'; +export type CustomWebhookSecretAction = 'keep' | 'replace' | 'clear'; + +export interface CustomWebhookSecretView { + configured: boolean; + masked?: string; + value?: string; +} + +export interface CustomWebhookSecretDraft extends CustomWebhookSecretView { + masked: string; + action: CustomWebhookSecretAction; + value: string; + originalValue?: string; +} + +export interface CustomWebhookFormField { + uid: string; + key: string; + value: string; +} + +export interface CustomWebhookFormFieldView { + uid?: string; + key: string; + value: string; +} + +export interface CustomWebhookHeaderView { + uid: string; + key: string; + secret: boolean; + value?: string; + configured?: boolean; + masked?: string; +} + +export interface CustomWebhookHeaderDraft { + uid: string; + key: string; + secret: boolean; + configured: boolean; + masked: string; + action: CustomWebhookSecretAction; + value: string; + originalValue?: string; +} + +export interface CustomWebhookBody { + type: CustomWebhookBodyType; + template: string; + fields: CustomWebhookFormField[]; +} + +export interface CustomWebhookBodyView { + type: CustomWebhookBodyType; + template: string; + fields: CustomWebhookFormFieldView[]; +} + +export interface CustomWebhookConfigView { + schemaVersion: typeof CUSTOM_WEBHOOK_SCHEMA_VERSION; + state?: 'legacy' | 'invalid'; + displayName: string; + preset: CustomWebhookPreset; + method?: 'POST'; + url: CustomWebhookSecretView | string; + body: Partial; + headers: CustomWebhookHeaderView[]; +} + +export interface CustomWebhookDraft { + schemaVersion: typeof CUSTOM_WEBHOOK_SCHEMA_VERSION; + state?: 'legacy' | 'invalid'; + displayName: string; + preset: CustomWebhookPreset; + url: CustomWebhookSecretDraft; + body: CustomWebhookBody; + headers: CustomWebhookHeaderDraft[]; +} + +export interface CustomWebhookSecretMutation { + action: CustomWebhookSecretAction; + value?: string; +} + +export interface CustomWebhookConfigUpdate { + schemaVersion: typeof CUSTOM_WEBHOOK_SCHEMA_VERSION; + displayName: string; + preset: CustomWebhookPreset; + method: 'POST'; + url: CustomWebhookSecretMutation; + body: { + type: CustomWebhookBodyType; + template: string; + fields: Array<{ + key: string; + value: string; + }>; + }; + headers: Array<{ + uid: string; + key: string; + secret: boolean; + action: CustomWebhookSecretAction; + value?: string; + }>; +} + +export interface CustomWebhookValidationIssue { + field: string; + code: + | 'displayNameRequired' + | 'urlRequired' + | 'urlInvalid' + | 'bodyRequired' + | 'jsonInvalid' + | 'formFieldRequired' + | 'formFieldDuplicate' + | 'headerRequired' + | 'headerInvalid' + | 'headerDuplicate' + | 'headerReserved' + | 'headerMustBeSecret' + | 'templateVariableInvalid' + | 'secretRequired'; +} + +interface PresetDefinition { + bodyType: CustomWebhookBodyType; + template: string; +} + +const PRESET_DEFINITIONS: Record, PresetDefinition> = { + genericJson: { + bodyType: 'json', + template: `{ + "schema_version": "1", + "title": "{{title}}", + "message": "{{message}}", + "type": "{{type}}", + "node_name": "{{nodeName}}", + "timestamp": "{{timestamp}}" +}`, + }, + slack: { + bodyType: 'json', + template: `{ + "text": "*{{title}}*\\n{{message}}" +}`, + }, + discord: { + bodyType: 'json', + template: `{ + "content": "**{{title}}**\\n{{message}}", + "allowed_mentions": { "parse": [] } +}`, + }, + teamsWorkflows: { + bodyType: 'json', + template: `{ + "type": "message", + "attachments": [ + { + "contentType": "application/vnd.microsoft.card.adaptive", + "contentUrl": null, + "content": { + "$schema": "http://adaptivecards.io/schemas/adaptive-card.json", + "type": "AdaptiveCard", + "version": "1.4", + "body": [ + { "type": "TextBlock", "weight": "Bolder", "text": "{{title}}" }, + { "type": "TextBlock", "wrap": true, "text": "{{message}}" } + ] + } + } + ] +}`, + }, +}; + +export const CUSTOM_WEBHOOK_VARIABLES = ['{{title}}', '{{message}}', '{{type}}', '{{nodeName}}', '{{timestamp}}']; + +const RESERVED_HEADERS = new Set([ + 'connection', + 'content-length', + 'content-type', + 'host', + 'proxy-connection', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); + +const CUSTOM_WEBHOOK_VARIABLE_TOKENS = new Set(CUSTOM_WEBHOOK_VARIABLES); + +const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/; + +export const contentTypeForBodyType = (type: CustomWebhookBodyType): string => { + if (type === 'form') return 'application/x-www-form-urlencoded'; + if (type === 'text') return 'text/plain'; + return 'application/json'; +}; + +export const createCustomWebhookUid = (): string => { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + if (typeof globalThis.crypto?.getRandomValues === 'function') { + globalThis.crypto.getRandomValues(bytes); + } else { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +}; + +export const isCustomWebhookSecretHeader = (key: string): boolean => { + const normalized = key.trim().toLowerCase(); + const compact = normalized.replace(/[-_]/g, ''); + return ( + normalized === 'authorization' || + normalized === 'cookie' || + normalized.endsWith('-key') || + normalized.endsWith('_key') || + compact.includes('apikey') || + compact.includes('token') || + compact.includes('secret') || + compact.includes('signature') + ); +}; + +export const createCustomWebhookFormField = (): CustomWebhookFormField => ({ + uid: createCustomWebhookUid(), + key: '', + value: '', +}); + +export const createCustomWebhookHeader = (): CustomWebhookHeaderDraft => ({ + uid: createCustomWebhookUid(), + key: '', + secret: false, + configured: false, + masked: '', + action: 'replace', + value: '', +}); + +export const createDefaultCustomWebhookDraft = (): CustomWebhookDraft => { + const draft: CustomWebhookDraft = { + schemaVersion: CUSTOM_WEBHOOK_SCHEMA_VERSION, + displayName: '', + preset: 'genericJson', + url: { + configured: false, + masked: '', + action: 'replace', + value: '', + }, + body: { + type: 'json', + template: '', + fields: [], + }, + headers: [], + }; + return applyCustomWebhookPreset(draft, 'genericJson'); +}; + +const normalizePreset = (value: unknown): CustomWebhookPreset => { + if (value === 'genericJson' || value === 'slack' || value === 'discord' || value === 'teamsWorkflows') { + return value; + } + return 'custom'; +}; + +const normalizeBodyType = (value: unknown): CustomWebhookBodyType => { + if (value === 'form' || value === 'text') return value; + return 'json'; +}; + +export const maskCustomWebhookUrl = (value: string): string => { + return value ? '******' : ''; +}; + +export const hydrateCustomWebhookDraft = (raw: Partial = {}): CustomWebhookDraft => { + const fallback = createDefaultCustomWebhookDraft(); + const preset = normalizePreset(raw.preset); + const rawUrl = raw.url; + const hasSanitizedUrlView = typeof rawUrl === 'object' && rawUrl !== null; + const urlConfigured = typeof rawUrl === 'string' ? Boolean(rawUrl) : Boolean(rawUrl?.configured); + const urlValue = typeof rawUrl === 'string' ? rawUrl : ''; + const urlMasked = typeof rawUrl === 'string' ? '' : rawUrl?.masked || ''; + const bodyType = normalizeBodyType(raw.body?.type); + const headers = Array.isArray(raw.headers) + ? raw.headers.map((header) => { + const secret = Boolean(header.secret) || isCustomWebhookSecretHeader(header.key || ''); + const secretValue = secret && typeof header.value === 'string' ? header.value : ''; + const configured = secret ? Boolean(header.configured ?? secretValue) : false; + return { + uid: header.uid || createCustomWebhookUid(), + key: header.key || '', + secret, + configured, + masked: secret ? header.masked || (header.value ? '***' : '') : '', + action: secret + ? secretValue + ? ('keep' as const) + : configured + ? ('keep' as const) + : ('clear' as const) + : ('replace' as const), + value: secret ? secretValue : header.value || '', + ...(secretValue ? { originalValue: secretValue } : {}), + }; + }) + : []; + + return { + schemaVersion: CUSTOM_WEBHOOK_SCHEMA_VERSION, + ...(raw.state === 'legacy' || raw.state === 'invalid' ? { state: raw.state } : {}), + displayName: raw.displayName || '', + preset, + url: { + configured: urlConfigured, + masked: urlMasked, + action: + typeof rawUrl === 'string' && rawUrl + ? 'keep' + : urlConfigured + ? 'keep' + : hasSanitizedUrlView + ? 'clear' + : 'replace', + value: urlValue, + ...(urlValue ? { originalValue: urlValue } : {}), + }, + body: { + type: bodyType, + template: + bodyType === 'form' + ? '' + : raw.body?.template === undefined + ? bodyType === 'text' + ? '{{message}}' + : fallback.body.template + : raw.body.template, + fields: Array.isArray(raw.body?.fields) + ? raw.body.fields.map((field) => ({ + uid: field.uid || createCustomWebhookUid(), + key: field.key || '', + value: field.value || '', + })) + : [], + }, + headers, + }; +}; + +export const applyCustomWebhookPreset = ( + source: CustomWebhookDraft, + preset: CustomWebhookPreset, +): CustomWebhookDraft => { + if (preset === 'custom') { + return { ...source, preset: 'custom' }; + } + const definition = PRESET_DEFINITIONS[preset]; + return { + ...source, + preset, + body: { + type: definition.bodyType, + template: definition.template, + fields: [], + }, + }; +}; + +export const isCustomWebhookPresetPristine = (draft: CustomWebhookDraft): boolean => { + if (draft.preset === 'custom') return false; + const expected = applyCustomWebhookPreset(draft, draft.preset); + return ( + draft.body.type === expected.body.type && + draft.body.template === expected.body.template && + draft.body.fields.length === expected.body.fields.length + ); +}; + +export const updateCustomWebhookBodyTemplate = (draft: CustomWebhookDraft, template: string): CustomWebhookDraft => ({ + ...draft, + body: { ...draft.body, template }, +}); + +const serializeSecret = (secret: CustomWebhookSecretDraft): CustomWebhookSecretMutation => { + if (secret.action === 'replace') { + return { action: 'replace', value: secret.value.trim() }; + } + return { action: secret.action }; +}; + +export const serializeCustomWebhookDraft = (draft: CustomWebhookDraft): CustomWebhookConfigUpdate => ({ + schemaVersion: CUSTOM_WEBHOOK_SCHEMA_VERSION, + displayName: draft.displayName.trim(), + preset: draft.preset, + method: 'POST', + url: serializeSecret(draft.url), + body: { + type: draft.body.type, + template: draft.body.type === 'form' ? '' : draft.body.template, + fields: + draft.body.type === 'form' + ? draft.body.fields.map((field) => ({ + key: field.key.trim(), + value: field.value, + })) + : [], + }, + headers: draft.headers.map((header) => ({ + uid: header.uid, + key: header.key.trim(), + secret: header.secret, + action: header.secret ? header.action : 'replace', + ...(header.secret && header.action !== 'replace' ? {} : { value: header.value }), + })), +}); + +const isValidHttpUrl = (value: string): boolean => { + try { + const parsed = new URL(value); + return ( + (parsed.protocol === 'http:' || parsed.protocol === 'https:') && + !parsed.username && + !parsed.password && + !parsed.hash + ); + } catch { + return false; + } +}; + +const isJsonTemplateValid = (value: string): boolean => { + try { + JSON.parse(value); + return true; + } catch { + return false; + } +}; + +const hasTemplateJsonKey = (value: string): boolean => { + try { + const visit = (node: unknown): boolean => { + if (Array.isArray(node)) return node.some(visit); + if (node && typeof node === 'object') { + return Object.entries(node).some( + ([key, child]) => key.includes('{{') || key.includes('}}') || visit(child), + ); + } + return false; + }; + return visit(JSON.parse(value)); + } catch { + return false; + } +}; + +const hasInvalidTemplateVariable = (value: string): boolean => { + let remainder = value; + while (remainder) { + const start = remainder.indexOf('{{'); + if (start < 0) return remainder.includes('}}'); + if (remainder.slice(0, start).includes('}}')) return true; + const endOffset = remainder.slice(start + 2).indexOf('}}'); + if (endOffset < 0) return true; + const end = start + 2 + endOffset + 2; + if (!CUSTOM_WEBHOOK_VARIABLE_TOKENS.has(remainder.slice(start, end))) return true; + remainder = remainder.slice(end); + } + return false; +}; + +export interface CustomWebhookTemplateSelection { + start: number; + end: number; +} + +export interface CustomWebhookTemplateInsertion { + template: string; + cursor: number; +} + +export const insertCustomWebhookVariable = ( + template: string, + variable: string, + bodyType: Exclude, + selection?: CustomWebhookTemplateSelection, +): CustomWebhookTemplateInsertion => { + if (selection && selection.start >= 0 && selection.end >= selection.start && selection.end <= template.length) { + const next = template.slice(0, selection.start) + variable + template.slice(selection.end); + return { template: next, cursor: selection.start + variable.length }; + } + if (bodyType === 'text') { + return { template: template + variable, cursor: template.length + variable.length }; + } + + const stringValuePattern = /("(?:message|text|content)"\s*:\s*")((?:\\.|[^"\\])*)(")/; + const anyStringValuePattern = /(:\s*")((?:\\.|[^"\\])*)(")/; + const match = stringValuePattern.exec(template) || anyStringValuePattern.exec(template); + if (match) { + const insertAt = match.index + match[0].length - 1; + return { + template: template.slice(0, insertAt) + variable + template.slice(insertAt), + cursor: insertAt + variable.length, + }; + } + + const trimmed = template.trim(); + if (trimmed === '{}') { + const next = `{\n "message": "${variable}"\n}`; + return { template: next, cursor: next.indexOf(variable) + variable.length }; + } + if (trimmed === '[]') { + const next = `[\n "${variable}"\n]`; + return { template: next, cursor: next.indexOf(variable) + variable.length }; + } + return { template, cursor: template.length }; +}; + +export const validateCustomWebhookDraft = ( + draft: CustomWebhookDraft, + options: { allowClearedUrl?: boolean } = {}, +): CustomWebhookValidationIssue[] => { + const issues: CustomWebhookValidationIssue[] = []; + if (!draft.displayName.trim()) { + issues.push({ field: 'displayName', code: 'displayNameRequired' }); + } + if (draft.url.action === 'keep') { + if (!draft.url.configured) issues.push({ field: 'url', code: 'urlRequired' }); + } else if (draft.url.action === 'replace') { + if (!draft.url.value.trim()) { + issues.push({ field: 'url', code: 'urlRequired' }); + } else if (!isValidHttpUrl(draft.url.value.trim())) { + issues.push({ field: 'url', code: 'urlInvalid' }); + } + } else if (!options.allowClearedUrl) { + issues.push({ field: 'url', code: 'urlRequired' }); + } + + if (draft.body.type === 'form') { + if (draft.body.fields.length === 0) { + issues.push({ field: 'body', code: 'bodyRequired' }); + } + const keys = new Set(); + draft.body.fields.forEach((field, index) => { + const key = field.key.trim(); + if (!key) { + issues.push({ field: `body.fields.${index}`, code: 'formFieldRequired' }); + return; + } + if (key.includes('{{') || key.includes('}}')) { + issues.push({ field: `body.fields.${index}`, code: 'templateVariableInvalid' }); + } + const normalized = key.toLowerCase(); + if (keys.has(normalized)) { + issues.push({ field: `body.fields.${index}`, code: 'formFieldDuplicate' }); + } + keys.add(normalized); + if (hasInvalidTemplateVariable(field.value)) { + issues.push({ field: `body.fields.${index}`, code: 'templateVariableInvalid' }); + } + }); + } else if (!draft.body.template.trim()) { + issues.push({ field: 'body', code: 'bodyRequired' }); + } else if (draft.body.type === 'json' && !isJsonTemplateValid(draft.body.template)) { + issues.push({ field: 'body', code: 'jsonInvalid' }); + } else if (draft.body.type === 'json' && hasTemplateJsonKey(draft.body.template)) { + issues.push({ field: 'body', code: 'templateVariableInvalid' }); + } + if (draft.body.type !== 'form' && hasInvalidTemplateVariable(draft.body.template)) { + issues.push({ field: 'body', code: 'templateVariableInvalid' }); + } + + const headerKeys = new Set(); + draft.headers.forEach((header, index) => { + const key = header.key.trim(); + const normalized = key.toLowerCase(); + if (!key) { + issues.push({ field: `headers.${index}`, code: 'headerRequired' }); + } else if (!HEADER_NAME_PATTERN.test(key)) { + issues.push({ field: `headers.${index}`, code: 'headerInvalid' }); + } else if (RESERVED_HEADERS.has(normalized)) { + issues.push({ field: `headers.${index}`, code: 'headerReserved' }); + } else if (headerKeys.has(normalized)) { + issues.push({ field: `headers.${index}`, code: 'headerDuplicate' }); + } + headerKeys.add(normalized); + + if (isCustomWebhookSecretHeader(key) && !header.secret) { + issues.push({ field: `headers.${index}`, code: 'headerMustBeSecret' }); + } + + if (header.secret) { + if (header.action === 'keep' && !header.configured) { + issues.push({ field: `headers.${index}`, code: 'secretRequired' }); + } + if (header.action === 'replace' && !header.value) { + issues.push({ field: `headers.${index}`, code: 'secretRequired' }); + } + } + }); + return issues; +}; + +export const formatCustomWebhookSafeSummary = ( + config: Partial, + options: { includeUrl?: boolean } = {}, +): string => { + const bodyType = normalizeBodyType(config.body?.type).toUpperCase(); + const headerCount = Array.isArray(config.headers) ? config.headers.length : 0; + const summary = `POST · ${bodyType} · ${headerCount} Headers`; + if (options.includeUrl === false) return summary; + const url = typeof config.url === 'string' ? maskCustomWebhookUrl(config.url) : config.url?.masked || '***'; + return `${summary} · ${url}`; +}; + +export const formatCustomWebhookDetails = ( + config: Partial | string, + missingUrl = '-', +): string => { + const normalized = typeof config === 'string' ? { url: config } : config; + const summary = formatCustomWebhookSafeSummary(normalized, { includeUrl: false }); + const url = typeof normalized.url === 'string' ? normalized.url : normalized.url?.value || ''; + return `${summary} · ${url || missingUrl}`; +}; diff --git a/frontend/src/views/setting/alert/setting/drawer/index.vue b/frontend/src/views/setting/alert/setting/drawer/index.vue index be9ee76f6..5debe49af 100644 --- a/frontend/src/views/setting/alert/setting/drawer/index.vue +++ b/frontend/src/views/setting/alert/setting/drawer/index.vue @@ -1,5 +1,5 @@ + +