Files
tokens-reef/backend/internal/service/webhook_service.go
User eb5d32553d
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled
feat: add webhook notification service and refactor data management
## 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
2026-04-15 23:03:48 +08:00

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
}