Files
Pulse/internal/notifications/webhook_enhanced.go
rcourtman 17dec929a0 feat: Add mention support for webhook alerts. Related to #1118
Adds a Mention field to webhook configurations that allows users to tag
individuals or groups when alerts are sent. This works with:

- Discord: @everyone, <@USER_ID>, <@&ROLE_ID>
- Microsoft Teams: @General, user email
- Mattermost: @channel, @all, @username

The mention is included in the webhook payload via the {{.Mention}} template
variable. Built-in templates for Discord, Slack, and Teams now conditionally
include mentions when configured.

Backend changes:
- Add Mention field to WebhookConfig struct
- Add Mention field to WebhookPayloadData for template access
- Pass mention through sendGroupedWebhook

Frontend changes:
- Add mention field to Webhook interface
- Add Mention input to webhook configuration form
- Show service-specific help text for mention formats
2026-01-18 15:16:37 +00:00

631 lines
18 KiB
Go

package notifications
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rs/zerolog/log"
)
// EnhancedWebhookConfig extends WebhookConfig with template support
type EnhancedWebhookConfig struct {
WebhookConfig
Service string `json:"service"` // discord, slack, teams, pagerduty, generic
PayloadTemplate string `json:"payloadTemplate"` // Go template for payload
RetryEnabled bool `json:"retryEnabled"`
RetryCount int `json:"retryCount"`
FilterRules WebhookFilterRules `json:"filterRules"`
CustomFields map[string]interface{} `json:"customFields"` // For template variables
ResponseLogging bool `json:"responseLogging"` // Log response for debugging
}
// WebhookFilterRules defines filtering for this webhook
type WebhookFilterRules struct {
Levels []string `json:"levels"` // Only send these levels
Types []string `json:"types"` // Only send these alert types
Nodes []string `json:"nodes"` // Only send from these nodes
ResourceTypes []string `json:"resourceTypes"` // vm, container, storage, etc
}
// WebhookPayloadData contains all data available for templates
type WebhookPayloadData struct {
// Alert fields
ID string
Level string
Type string
ResourceName string
ResourceID string
Node string
Instance string
Message string
Value float64
Threshold float64
ValueFormatted string
ThresholdFormatted string
StartTime string
Duration string
Timestamp string
ResourceType string
Acknowledged bool
AckTime string
AckUser string
// Additional context
Metadata map[string]interface{}
CustomFields map[string]interface{}
AlertCount int
Alerts []*alerts.Alert // For grouped alerts
ChatID string // For Telegram webhooks
Mention string // Platform-specific mention text
}
// SendEnhancedWebhook sends a webhook with template support
func (n *NotificationManager) SendEnhancedWebhook(webhook EnhancedWebhookConfig, alert *alerts.Alert) error {
// Check filters
if !n.shouldSendWebhook(webhook, alert) {
log.Debug().
Str("webhook", webhook.Name).
Str("alertID", alert.ID).
Msg("Alert filtered out by webhook rules")
return nil
}
// Prepare template data
data := n.prepareWebhookData(alert, webhook.CustomFields)
// Render URL template when placeholders are present
renderedURL, renderErr := renderWebhookURL(webhook.URL, data)
if renderErr != nil {
return fmt.Errorf("failed to render webhook URL template: %w", renderErr)
}
webhook.URL = renderedURL
// Service-specific enrichment
switch webhook.Service {
case "telegram":
chatID, chatErr := extractTelegramChatID(webhook.URL)
if chatErr != nil {
return fmt.Errorf("failed to extract Telegram chat_id: %w", chatErr)
}
if chatID != "" {
data.ChatID = chatID
log.Debug().
Str("webhook", webhook.Name).
Str("chatID", chatID).
Msg("Extracted Telegram chat_id from rendered URL for enhanced webhook")
}
case "pagerduty":
if data.CustomFields == nil {
data.CustomFields = make(map[string]interface{})
}
if routingKey, ok := webhook.Headers["routing_key"]; ok {
data.CustomFields["routing_key"] = routingKey
}
}
// Generate payload from template with service-specific handling
payload, err := n.generatePayloadFromTemplateWithService(webhook.PayloadTemplate, data, webhook.Service)
if err != nil {
return fmt.Errorf("failed to generate payload: %w", err)
}
// Send with retry logic
if webhook.RetryEnabled {
return n.sendWebhookWithRetry(webhook, payload)
}
return n.sendWebhookOnce(webhook, payload)
}
// NOTE: prepareWebhookData is now defined in notifications.go to avoid duplication
// NOTE: generatePayloadFromTemplate is now defined in notifications.go to avoid duplication
// shouldSendWebhook checks if alert matches webhook filter rules
func (n *NotificationManager) shouldSendWebhook(webhook EnhancedWebhookConfig, alert *alerts.Alert) bool {
rules := webhook.FilterRules
// Check level filter
if len(rules.Levels) > 0 {
found := false
for _, level := range rules.Levels {
if string(alert.Level) == level {
found = true
break
}
}
if !found {
return false
}
}
// Check type filter
if len(rules.Types) > 0 {
found := false
for _, alertType := range rules.Types {
if alert.Type == alertType {
found = true
break
}
}
if !found {
return false
}
}
// Check node filter
if len(rules.Nodes) > 0 {
found := false
for _, node := range rules.Nodes {
if alert.Node == node {
found = true
break
}
}
if !found {
return false
}
}
// Check resource type filter
if len(rules.ResourceTypes) > 0 {
resourceType, ok := alert.Metadata["resourceType"].(string)
if !ok {
return false
}
found := false
for _, rt := range rules.ResourceTypes {
if resourceType == rt {
found = true
break
}
}
if !found {
return false
}
}
return true
}
// sendWebhookWithRetry implements exponential backoff retry with enhanced error tracking
// Note: When used with the persistent queue, retry behavior is layered:
// - Transport retries (this function): up to RetryCount attempts with exponential backoff
// - Queue retries: up to MaxAttempts (default 3) with exponential backoff
// Total attempts = RetryCount * MaxAttempts (e.g., 3 * 3 = 9 HTTP calls for a single notification)
// This ensures delivery even during transient failures at either layer.
func (n *NotificationManager) sendWebhookWithRetry(webhook EnhancedWebhookConfig, payload []byte) error {
maxRetries := webhook.RetryCount
if maxRetries <= 0 {
maxRetries = WebhookDefaultRetries
}
var lastErr error
var lastResp *http.Response
backoff := WebhookInitialBackoff
retryableErrors := 0
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
// Check for Retry-After header from previous response (overrides backoff)
if lastResp != nil && lastResp.StatusCode == 429 {
if retryAfter := lastResp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, err := strconv.Atoi(retryAfter); err == nil {
customBackoff := time.Duration(seconds) * time.Second
log.Debug().
Str("webhook", webhook.Name).
Dur("retryAfter", customBackoff).
Msg("Using Retry-After header for backoff")
time.Sleep(customBackoff)
backoff = customBackoff // Use this for next iteration
}
} else {
// No Retry-After, use exponential backoff
log.Debug().
Str("webhook", webhook.Name).
Int("attempt", attempt).
Int("maxRetries", maxRetries).
Dur("backoff", backoff).
Msg("Retrying webhook after backoff")
time.Sleep(backoff)
}
} else {
// Not a 429, use exponential backoff
log.Debug().
Str("webhook", webhook.Name).
Int("attempt", attempt).
Int("maxRetries", maxRetries).
Dur("backoff", backoff).
Msg("Retrying webhook after backoff")
time.Sleep(backoff)
}
// Exponential backoff for next iteration
backoff *= 2
if backoff > WebhookMaxBackoff {
backoff = WebhookMaxBackoff
}
}
resp, err := n.sendWebhookOnceWithResponse(webhook, payload)
lastResp = resp
if err == nil {
if attempt > 0 {
log.Info().
Str("webhook", webhook.Name).
Int("attempt", attempt).
Int("totalAttempts", attempt+1).
Msg("Webhook succeeded after retry")
}
// Log successful delivery
log.Debug().
Str("webhook", webhook.Name).
Str("service", webhook.Service).
Int("payloadSize", len(payload)).
Msg("Webhook delivered successfully")
// Track successful delivery
delivery := WebhookDelivery{
WebhookName: webhook.Name,
WebhookURL: webhook.URL,
Service: webhook.Service,
AlertID: "enhanced", // This is for enhanced webhooks, alertID might not be available
Timestamp: time.Now(),
StatusCode: 200, // Assume success
Success: true,
RetryAttempts: attempt,
PayloadSize: len(payload),
}
n.addWebhookDelivery(delivery)
return nil
}
lastErr = err
// Determine if error is retryable
isRetryable := isRetryableWebhookError(err)
if isRetryable {
retryableErrors++
}
log.Warn().
Err(err).
Str("webhook", webhook.Name).
Str("service", webhook.Service).
Int("attempt", attempt+1).
Int("maxRetries", maxRetries+1).
Bool("retryable", isRetryable).
Msg("Webhook attempt failed")
// If error is not retryable, break early
if !isRetryable && attempt == 0 {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Msg("Non-retryable webhook error - not attempting retry")
break
}
}
// Final error logging with summary
log.Error().
Err(lastErr).
Str("webhook", webhook.Name).
Str("service", webhook.Service).
Int("totalAttempts", maxRetries+1).
Int("retryableErrors", retryableErrors).
Msg("Webhook delivery failed after all retry attempts")
// Track failed delivery
delivery := WebhookDelivery{
WebhookName: webhook.Name,
WebhookURL: webhook.URL,
Service: webhook.Service,
AlertID: "enhanced", // This is for enhanced webhooks, alertID might not be available
Timestamp: time.Now(),
StatusCode: 0, // Unknown status
Success: false,
ErrorMessage: lastErr.Error(),
RetryAttempts: maxRetries,
PayloadSize: len(payload),
}
n.addWebhookDelivery(delivery)
return fmt.Errorf("webhook failed after %d attempts: %w", maxRetries+1, lastErr)
}
// isRetryableWebhookError determines if a webhook error should trigger a retry
func isRetryableWebhookError(err error) bool {
errStr := strings.ToLower(err.Error())
// Network-related errors that should be retried
if strings.Contains(errStr, "timeout") ||
strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "connection reset") ||
strings.Contains(errStr, "no such host") ||
strings.Contains(errStr, "network unreachable") {
return true
}
// HTTP status codes that should be retried
if strings.Contains(errStr, "status 429") || // Rate limited
strings.Contains(errStr, "status 502") || // Bad Gateway
strings.Contains(errStr, "status 503") || // Service Unavailable
strings.Contains(errStr, "status 504") { // Gateway Timeout
return true
}
// 5xx server errors are generally retryable
for i := 500; i <= 599; i++ {
if strings.Contains(errStr, fmt.Sprintf("status %d", i)) {
return true
}
}
// 4xx client errors are generally not retryable
for i := 400; i <= 499; i++ {
if strings.Contains(errStr, fmt.Sprintf("status %d", i)) {
return false
}
}
// Default to retryable for unknown errors
return true
}
// sendWebhookOnceWithResponse sends a single webhook request and returns the response
func (n *NotificationManager) sendWebhookOnceWithResponse(webhook EnhancedWebhookConfig, payload []byte) (*http.Response, error) {
method := webhook.Method
if method == "" {
method = "POST"
}
req, err := http.NewRequest(method, webhook.URL, bytes.NewBuffer(payload))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set headers
for key, value := range webhook.Headers {
req.Header.Set(key, value)
}
if req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("User-Agent", "Pulse-Monitoring/2.0")
// Send request with secure client
client := n.createSecureWebhookClient(WebhookTimeout)
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// Read response body with size limit
limitedReader := io.LimitReader(resp.Body, WebhookMaxResponseSize)
var respBody bytes.Buffer
bytesRead, err := respBody.ReadFrom(limitedReader)
if err != nil {
return resp, fmt.Errorf("failed to read webhook response body: %w", err)
}
if bytesRead >= WebhookMaxResponseSize {
log.Warn().
Str("webhook", webhook.Name).
Int64("bytesRead", bytesRead).
Msg("Webhook response exceeded size limit")
}
responseBody := respBody.String()
// Log response if enabled or if there's an error
if webhook.ResponseLogging || resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Debug().
Str("webhook", webhook.Name).
Int("status", resp.StatusCode).
Str("response", responseBody).
Msg("Webhook response")
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp, fmt.Errorf("webhook returned status %d: %s", resp.StatusCode, responseBody)
}
return resp, nil
}
// sendWebhookOnce sends a single webhook request (compatibility wrapper)
func (n *NotificationManager) sendWebhookOnce(webhook EnhancedWebhookConfig, payload []byte) error {
_, err := n.sendWebhookOnceWithResponse(webhook, payload)
return err
}
// NOTE: formatWebhookDuration is now defined in notifications.go to avoid duplication
// TestEnhancedWebhook tests a webhook with a specific payload
func (n *NotificationManager) TestEnhancedWebhook(webhook EnhancedWebhookConfig) (int, string, error) {
// Use the configured publicURL if available, otherwise use a placeholder
instanceURL := n.publicURL
if instanceURL == "" {
instanceURL = "https://192.168.1.100:8006"
}
// Create test alert
testAlert := &alerts.Alert{
ID: "test-" + time.Now().Format("20060102-150405"),
Type: "cpu",
Level: "warning",
ResourceID: "100",
ResourceName: "Test VM",
Node: "pve-node-01",
Instance: instanceURL,
Message: "Test webhook notification from Pulse Monitoring",
Value: 85.5,
Threshold: 80.0,
StartTime: time.Now().Add(-2 * time.Minute),
LastSeen: time.Now(),
Metadata: map[string]interface{}{
"resourceType": "vm",
},
}
// Prepare data
data := n.prepareWebhookData(testAlert, webhook.CustomFields)
// Render webhook URL using template data
renderedURL, renderErr := renderWebhookURL(webhook.URL, data)
if renderErr != nil {
return 0, "", fmt.Errorf("failed to render webhook URL template: %w", renderErr)
}
webhook.URL = renderedURL
// Validate webhook URL to prevent SSRF/DNS rebinding attacks (same validation as live sends)
if err := n.ValidateWebhookURL(webhook.URL); err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Str("url", webhook.URL).
Msg("Webhook URL validation failed for test request")
return 0, "", fmt.Errorf("webhook URL validation failed: %w", err)
}
// For Telegram, extract chat_id from URL if present
if webhook.Service == "telegram" {
if chatID, err := extractTelegramChatID(webhook.URL); err == nil && chatID != "" {
data.ChatID = chatID
} else if err != nil {
log.Warn().
Err(err).
Str("webhook", webhook.Name).
Msg("Failed to extract Telegram chat_id during enhanced webhook test")
}
// Note: For test webhooks, we don't fail if chat_id is missing
// as this may be intentional during testing
} else if webhook.Service == "pagerduty" {
if data.CustomFields == nil {
data.CustomFields = make(map[string]interface{})
}
if routingKey, ok := webhook.Headers["routing_key"]; ok {
data.CustomFields["routing_key"] = routingKey
}
}
// Generate payload with service-specific handling
payload, err := n.generatePayloadFromTemplateWithService(webhook.PayloadTemplate, data, webhook.Service)
if err != nil {
return 0, "", fmt.Errorf("failed to generate payload: %w", err)
}
// Send request
method := webhook.Method
if method == "" {
method = "POST"
}
// For Telegram webhooks, strip chat_id from URL if present
webhookURL := webhook.URL
if webhook.Service == "telegram" && strings.Contains(webhookURL, "chat_id=") {
if u, err := url.Parse(webhookURL); err == nil {
q := u.Query()
q.Del("chat_id") // Remove chat_id from query params
u.RawQuery = q.Encode()
webhookURL = u.String()
}
}
req, err := http.NewRequest(method, webhookURL, bytes.NewBuffer(payload))
if err != nil {
return 0, "", fmt.Errorf("failed to create request: %w", err)
}
// Set headers
// Special handling for ntfy service - add dynamic headers based on test alert level
if webhook.Service == "ntfy" {
// Set Content-Type for ntfy (plain text)
req.Header.Set("Content-Type", "text/plain")
// Set dynamic headers based on alert level
title := fmt.Sprintf("%s: %s",
func() string {
switch testAlert.Level {
case alerts.AlertLevelCritical:
return "CRITICAL"
case alerts.AlertLevelWarning:
return "WARNING"
default:
return "INFO"
}
}(),
testAlert.ResourceName,
)
req.Header.Set("Title", title)
priority := func() string {
switch testAlert.Level {
case alerts.AlertLevelCritical:
return "urgent"
case alerts.AlertLevelWarning:
return "high"
default:
return "default"
}
}()
req.Header.Set("Priority", priority)
tags := fmt.Sprintf("%s,pulse,%s",
func() string {
switch testAlert.Level {
case alerts.AlertLevelCritical:
return "rotating_light"
case alerts.AlertLevelWarning:
return "warning"
default:
return "white_check_mark"
}
}(),
testAlert.Type,
)
req.Header.Set("Tags", tags)
}
// Apply any custom headers from webhook config (these override defaults)
for key, value := range webhook.Headers {
// Skip template-like headers (those with {{)
if !strings.Contains(value, "{{") {
req.Header.Set(key, value)
}
}
if webhook.Service != "ntfy" && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("User-Agent", "Pulse-Monitoring/2.0 (Test)")
// Send with shorter timeout for testing
client := n.createSecureWebhookClient(WebhookTestTimeout)
resp, err := client.Do(req)
if err != nil {
return 0, "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
// Read response with size limit
limitedReader := io.LimitReader(resp.Body, WebhookMaxResponseSize)
var respBody bytes.Buffer
if _, err := respBody.ReadFrom(limitedReader); err != nil {
return 0, "", fmt.Errorf("failed to read webhook response body: %w", err)
}
return resp.StatusCode, respBody.String(), nil
}