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", + "