## Backend Changes - Add WebhookService for sending alert notifications via HTTP webhooks - Implement HMAC-SHA256 signature for webhook payload authentication - Add webhook configuration API endpoints and settings - Integrate webhook calls into OpsAlertEvaluatorService - Fix routes/common.go string conversion (use strconv.Itoa) - Add comprehensive webhook service tests ## Frontend Changes - Add webhook notification configuration UI in OpsSettingsDialog - Add WebhookNotificationConfig types and API functions - Add i18n translations for webhook features (zh/en) - Refactor DataManagementView.vue into modular components: - PostgresProfilesCard.vue (356 lines) - RedisProfilesCard.vue (331 lines) - S3ProfilesCard.vue (363 lines) - BackupJobsCard.vue (216 lines) - DataManagementView.vue (94 lines) - Add OpsSettingsDialog component tests ## Testing - All backend tests pass - All frontend tests pass - Webhook service tests cover signature, HTTP, timeout, error handling
191 lines
4.7 KiB
Go
191 lines
4.7 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// WebhookService handles sending alert notifications via webhooks.
|
|
type WebhookService struct {
|
|
opsService *OpsService
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewWebhookService creates a new webhook service.
|
|
func NewWebhookService(opsService *OpsService) *WebhookService {
|
|
return &WebhookService{
|
|
opsService: opsService,
|
|
httpClient: &http.Client{
|
|
Timeout: 30 * time.Second,
|
|
},
|
|
}
|
|
}
|
|
|
|
// SendAlertWebhook sends an alert notification to configured webhook URLs.
|
|
func (s *WebhookService) SendAlertWebhook(ctx context.Context, rule *OpsAlertRule, event *OpsAlertEvent, resolved bool) error {
|
|
if s == nil || s.opsService == nil {
|
|
return nil
|
|
}
|
|
|
|
cfg, err := s.opsService.GetWebhookNotificationConfig(ctx)
|
|
if err != nil || cfg == nil || !cfg.Alert.Enabled {
|
|
return nil
|
|
}
|
|
|
|
if len(cfg.Alert.URLs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Check severity threshold
|
|
if !shouldSendWebhookByMinSeverity(cfg.Alert.MinSeverity, rule.Severity) {
|
|
return nil
|
|
}
|
|
|
|
// Check if resolved alerts should be sent
|
|
if resolved && !cfg.Alert.IncludeResolved {
|
|
return nil
|
|
}
|
|
|
|
payload := s.buildAlertPayload(rule, event, resolved)
|
|
if err := s.signPayload(payload, cfg.Alert.Secret); err != nil {
|
|
logger.L().Warn("failed to sign webhook payload", zap.Error(err))
|
|
}
|
|
|
|
payloadBytes, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to marshal webhook payload: %w", err)
|
|
}
|
|
|
|
var lastErr error
|
|
for _, url := range cfg.Alert.URLs {
|
|
url = strings.TrimSpace(url)
|
|
if url == "" {
|
|
continue
|
|
}
|
|
|
|
if err := s.sendWebhook(ctx, url, payloadBytes, cfg.Alert.TimeoutSeconds, cfg.Alert.Secret); err != nil {
|
|
logger.L().Warn("failed to send webhook", zap.String("url", url), zap.Error(err))
|
|
lastErr = err
|
|
continue
|
|
}
|
|
}
|
|
|
|
return lastErr
|
|
}
|
|
|
|
// buildAlertPayload constructs the webhook payload for an alert.
|
|
func (s *WebhookService) buildAlertPayload(rule *OpsAlertRule, event *OpsAlertEvent, resolved bool) *OpsWebhookPayload {
|
|
payload := &OpsWebhookPayload{
|
|
Type: "alert",
|
|
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
|
Data: OpsWebhookData{
|
|
Rule: rule,
|
|
Event: event,
|
|
},
|
|
}
|
|
|
|
if resolved {
|
|
payload.Type = "alert_resolved"
|
|
payload.Data.ResolvedAt = time.Now().UTC().Format(time.RFC3339)
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
// signPayload adds HMAC signature to the payload if a secret is configured.
|
|
func (s *WebhookService) signPayload(payload *OpsWebhookPayload, secret string) error {
|
|
if secret == "" || payload == nil {
|
|
return nil
|
|
}
|
|
|
|
// Create a copy of payload without signature for signing
|
|
signPayload := &OpsWebhookPayload{
|
|
Type: payload.Type,
|
|
Timestamp: payload.Timestamp,
|
|
Data: payload.Data,
|
|
}
|
|
|
|
data, err := json.Marshal(signPayload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
mac := hmac.New(sha256.New, []byte(secret))
|
|
mac.Write(data)
|
|
payload.Signature = hex.EncodeToString(mac.Sum(nil))
|
|
|
|
return nil
|
|
}
|
|
|
|
// sendWebhook sends the webhook payload to a single URL.
|
|
func (s *WebhookService) sendWebhook(ctx context.Context, url string, payload []byte, timeoutSeconds int, secret string) error {
|
|
if s.httpClient == nil {
|
|
return errors.New("http client not initialized")
|
|
}
|
|
|
|
timeout := time.Duration(timeoutSeconds) * time.Second
|
|
if timeout <= 0 {
|
|
timeout = 10 * time.Second
|
|
}
|
|
|
|
client := &http.Client{Timeout: timeout}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "Sub2API-Webhook/1.0")
|
|
if secret != "" {
|
|
req.Header.Set("X-Webhook-Signature", "sha256="+hex.EncodeToString(hmac.New(sha256.New, []byte(secret)).Sum(payload)))
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("webhook returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// shouldSendWebhookByMinSeverity checks if the alert severity meets the minimum threshold.
|
|
func shouldSendWebhookByMinSeverity(minSeverity, ruleSeverity string) bool {
|
|
minSeverity = strings.TrimSpace(strings.ToLower(minSeverity))
|
|
ruleSeverity = strings.TrimSpace(strings.ToLower(ruleSeverity))
|
|
|
|
severityLevels := map[string]int{
|
|
"critical": 3,
|
|
"warning": 2,
|
|
"info": 1,
|
|
}
|
|
|
|
minLevel, okMin := severityLevels[minSeverity]
|
|
ruleLevel, okRule := severityLevels[ruleSeverity]
|
|
|
|
if !okMin || !okRule {
|
|
return true // If unknown severity, send by default
|
|
}
|
|
|
|
return ruleLevel >= minLevel
|
|
}
|