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 }