Files
pulse/internal/notifications/notifications.go
T
rcourtman 9b531c547d Fix recovery notifications silently disabled by config PUT (#1332)
Two fixes for missing recovery/resolved notifications:

1. API config PUT handler now preserves notifyOnResolve when the client
   omits it from the request body. Go decodes a missing bool as false,
   which silently disabled recovery notifications on older clients.

2. CancelAlert now always cleans up the cooldown record even when the
   alert has already left the pending buffer, preventing stale cooldown
   entries from suppressing future alert cycles.
2026-03-09 11:28:28 +00:00

3557 lines
106 KiB
Go

package notifications
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"math"
"net"
"net/http"
"net/url"
"os/exec"
"strings"
"sync"
"text/template"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rs/zerolog/log"
)
// Webhook configuration constants
const (
// HTTP client settings
WebhookTimeout = 30 * time.Second
WebhookMaxResponseSize = 1 * 1024 * 1024 // 1 MB max response size
WebhookMaxRedirects = 3 // Maximum number of redirects to follow
WebhookTestTimeout = 10 * time.Second
// Retry settings
WebhookInitialBackoff = 1 * time.Second
WebhookMaxBackoff = 30 * time.Second
WebhookDefaultRetries = 3
// History settings
WebhookHistoryMaxSize = 100
// Rate limiting settings
WebhookRateLimitWindow = 1 * time.Minute // Time window for rate limiting
WebhookRateLimitMax = 10 // Max requests per window per webhook
)
const (
queueTypeSuffixResolved = "_resolved"
queueTypeSuffixEscalation = "_escalation"
metadataResolvedAt = "resolvedAt"
)
type notificationEvent string
const (
eventAlert notificationEvent = "alert"
eventResolved notificationEvent = "resolved"
eventEscalation notificationEvent = "escalation"
)
var ErrNotificationCancelled = errors.New("notification cancelled")
// createSecureWebhookClient creates an HTTP client with security controls
func (n *NotificationManager) createSecureWebhookClient(timeout time.Duration) *http.Client {
// dedicated transport that pins DNS resolution to prevent rebinding
transport := &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
// Extract hostname and port
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
// Validate IP if it's already an IP
if ip := net.ParseIP(host); ip != nil {
if isPrivateIP(ip) && !n.isIPInAllowlist(ip) {
return nil, fmt.Errorf("blocked private IP: %s", ip)
}
// It's an IP, dial directly
d := net.Dialer{Timeout: 10 * time.Second}
return d.DialContext(ctx, network, addr)
}
// Resolve hostname
ips, err := net.LookupIP(host)
if err != nil {
return nil, err
}
// Find first permitted IP
var permittedIP net.IP
for _, ip := range ips {
if !isPrivateIP(ip) || n.isIPInAllowlist(ip) {
permittedIP = ip
break
}
}
if permittedIP == nil {
return nil, fmt.Errorf("hostname %s resolves to blocked private IPs", host)
}
// Log if we filtered some IPs
if len(ips) > 1 {
log.Debug().
Str("host", host).
Str("selected_ip", permittedIP.String()).
Msg("DNS resolution pinned for webhook security")
}
// Dial the permitted IP
d := net.Dialer{Timeout: 10 * time.Second}
return d.DialContext(ctx, network, net.JoinHostPort(permittedIP.String(), port))
},
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &http.Client{
Timeout: timeout,
Transport: transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= WebhookMaxRedirects {
return fmt.Errorf("stopped after %d redirects", WebhookMaxRedirects)
}
// Re-validate strictly on redirect
return n.ValidateWebhookURL(req.URL.String())
},
}
}
// TestNodeInfo contains information about nodes for test notifications
type TestNodeInfo struct {
NodeName string
InstanceURL string
}
// WebhookDelivery tracks webhook delivery attempts for debugging
type WebhookDelivery struct {
WebhookName string `json:"webhookName"`
WebhookURL string `json:"webhookUrl"`
Service string `json:"service"`
AlertID string `json:"alertId"`
Timestamp time.Time `json:"timestamp"`
StatusCode int `json:"statusCode"`
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
RetryAttempts int `json:"retryAttempts"`
PayloadSize int `json:"payloadSize"`
}
// webhookRateLimit tracks rate limiting for webhook deliveries
type webhookRateLimit struct {
lastSent time.Time
sentCount int
}
// NotificationManager handles sending notifications
type NotificationManager struct {
mu sync.RWMutex
emailConfig EmailConfig
emailManager *EnhancedEmailManager // Shared email manager for rate limiting
webhooks []WebhookConfig
appriseConfig AppriseConfig
enabled bool
cooldown time.Duration
notifyOnResolve bool
lastNotified map[string]notificationRecord
groupWindow time.Duration
pendingAlerts []*alerts.Alert
groupTimer *time.Timer
groupByNode bool
publicURL string // Full URL to access Pulse
groupByGuest bool
webhookHistory []WebhookDelivery // Keep last 100 webhook deliveries for debugging
webhookRateLimits map[string]*webhookRateLimit // Track rate limits per webhook URL
webhookRateMu sync.Mutex // Separate mutex for webhook rate limiting
appriseExec appriseExecFunc
queue *NotificationQueue // Persistent notification queue
webhookClient *http.Client // Shared HTTP client for webhooks
stopCleanup chan struct{} // Signal to stop cleanup goroutine
allowedPrivateNets []*net.IPNet // Parsed CIDR ranges allowed for private webhook targets
allowedPrivateMu sync.RWMutex // Protects allowedPrivateNets
}
type appriseExecFunc func(ctx context.Context, path string, args []string) ([]byte, error)
// copyEmailConfig returns a defensive copy of EmailConfig including its slices to avoid data races.
func copyEmailConfig(cfg EmailConfig) EmailConfig {
copy := cfg
if len(cfg.To) > 0 {
copy.To = append([]string(nil), cfg.To...)
}
return copy
}
// copyWebhookConfigs deep-copies webhook configurations to isolate concurrent writers from background senders.
func copyWebhookConfigs(webhooks []WebhookConfig) []WebhookConfig {
if len(webhooks) == 0 {
return nil
}
copies := make([]WebhookConfig, 0, len(webhooks))
for _, webhook := range webhooks {
clone := webhook
if len(webhook.Headers) > 0 {
headers := make(map[string]string, len(webhook.Headers))
for k, v := range webhook.Headers {
headers[k] = v
}
clone.Headers = headers
}
if len(webhook.CustomFields) > 0 {
custom := make(map[string]string, len(webhook.CustomFields))
for k, v := range webhook.CustomFields {
custom[k] = v
}
clone.CustomFields = custom
}
copies = append(copies, clone)
}
return copies
}
func copyAppriseConfig(cfg AppriseConfig) AppriseConfig {
copy := cfg
if len(cfg.Targets) > 0 {
copy.Targets = append([]string(nil), cfg.Targets...)
}
return copy
}
// annotateResolvedMetadata stores the resolution timestamp on the alert metadata for queue persistence.
func annotateResolvedMetadata(alert *alerts.Alert, resolvedAt time.Time) {
if alert == nil {
return
}
if alert.Metadata == nil {
alert.Metadata = make(map[string]interface{})
}
alert.Metadata[metadataResolvedAt] = resolvedAt.Format(time.RFC3339)
}
// NormalizeAppriseConfig cleans and normalizes Apprise configuration values.
func NormalizeAppriseConfig(cfg AppriseConfig) AppriseConfig {
normalized := cfg
mode := strings.ToLower(strings.TrimSpace(string(normalized.Mode)))
switch mode {
case string(AppriseModeHTTP):
normalized.Mode = AppriseModeHTTP
default:
normalized.Mode = AppriseModeCLI
}
normalized.CLIPath = "apprise" // Force default binary for security
if normalized.TimeoutSeconds <= 0 {
normalized.TimeoutSeconds = 15
} else if normalized.TimeoutSeconds > 120 {
normalized.TimeoutSeconds = 120
} else if normalized.TimeoutSeconds < 5 {
normalized.TimeoutSeconds = 5
}
cleanTargets := make([]string, 0, len(normalized.Targets))
seen := make(map[string]struct{}, len(normalized.Targets))
for _, target := range normalized.Targets {
trimmed := strings.TrimSpace(target)
if trimmed == "" {
continue
}
lower := strings.ToLower(trimmed)
if _, exists := seen[lower]; exists {
continue
}
seen[lower] = struct{}{}
cleanTargets = append(cleanTargets, trimmed)
}
normalized.Targets = cleanTargets
normalized.ServerURL = strings.TrimSpace(normalized.ServerURL)
normalized.ServerURL = strings.TrimRight(normalized.ServerURL, "/")
normalized.ConfigKey = strings.TrimSpace(normalized.ConfigKey)
normalized.APIKey = strings.TrimSpace(normalized.APIKey)
normalized.APIKeyHeader = strings.TrimSpace(normalized.APIKeyHeader)
if normalized.APIKeyHeader == "" {
normalized.APIKeyHeader = "X-API-KEY"
}
switch normalized.Mode {
case AppriseModeCLI:
if len(normalized.Targets) == 0 {
normalized.Enabled = false
}
case AppriseModeHTTP:
if normalized.ServerURL == "" {
normalized.Enabled = false
}
}
return normalized
}
func defaultAppriseExec(ctx context.Context, path string, args []string) ([]byte, error) {
cmd := exec.CommandContext(ctx, path, args...)
return cmd.CombinedOutput()
}
type notificationRecord struct {
lastSent time.Time
alertStart time.Time
}
// Alert represents an alert (interface to avoid circular dependency)
type Alert interface {
GetID() string
GetResourceName() string
GetType() string
GetLevel() string
GetValue() float64
GetThreshold() float64
GetMessage() string
GetNode() string
GetInstance() string
GetStartTime() time.Time
}
// EmailConfig holds email notification settings
type EmailConfig struct {
Enabled bool `json:"enabled"`
Provider string `json:"provider"` // Email provider name (Gmail, SendGrid, etc.)
SMTPHost string `json:"server"` // Changed from smtpHost to server for frontend consistency
SMTPPort int `json:"port"` // Changed from smtpPort to port for frontend consistency
Username string `json:"username"`
Password string `json:"password"`
From string `json:"from"`
To []string `json:"to"`
TLS bool `json:"tls"`
StartTLS bool `json:"startTLS"` // STARTTLS support
RateLimit int `json:"rateLimit"` // Max emails per minute (0 = default 60)
}
// WebhookConfig holds webhook settings
type WebhookConfig struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
Enabled bool `json:"enabled"`
Service string `json:"service"` // discord, slack, teams, etc.
Template string `json:"template"` // Custom payload template
CustomFields map[string]string `json:"customFields,omitempty"`
Mention string `json:"mention,omitempty"` // Platform-specific mention (e.g., @everyone, @channel, <@USER_ID>)
}
// AppriseMode identifies how Pulse should deliver notifications through Apprise.
type AppriseMode string
const (
AppriseModeCLI AppriseMode = "cli"
AppriseModeHTTP AppriseMode = "http"
)
// AppriseConfig holds Apprise notification settings.
type AppriseConfig struct {
Enabled bool `json:"enabled"`
Mode AppriseMode `json:"mode,omitempty"`
Targets []string `json:"targets"`
CLIPath string `json:"cliPath,omitempty"`
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
ServerURL string `json:"serverUrl,omitempty"`
ConfigKey string `json:"configKey,omitempty"`
APIKey string `json:"apiKey,omitempty"`
APIKeyHeader string `json:"apiKeyHeader,omitempty"`
SkipTLSVerify bool `json:"skipTlsVerify,omitempty"`
}
// NewNotificationManager creates a new notification manager using the global data directory.
// For multi-tenant deployments, use NewNotificationManagerWithDataDir instead.
func NewNotificationManager(publicURL string) *NotificationManager {
return NewNotificationManagerWithDataDir(publicURL, "")
}
// NewNotificationManagerWithDataDir creates a new notification manager with a custom data directory.
// This enables tenant-scoped notification queue persistence in multi-tenant deployments.
// If dataDir is empty, it uses the global data directory.
func NewNotificationManagerWithDataDir(publicURL string, dataDir string) *NotificationManager {
cleanURL := strings.TrimRight(strings.TrimSpace(publicURL), "/")
if cleanURL != "" {
log.Info().Str("publicURL", cleanURL).Msg("NotificationManager initialized with public URL")
} else {
log.Info().Msg("NotificationManager initialized without public URL - webhook links may not work")
}
// Initialize persistent queue with tenant-specific data directory
queue, err := NewNotificationQueue(dataDir)
if err != nil {
log.Error().Err(err).Msg("Failed to initialize notification queue, notifications will be in-memory only")
queue = nil
}
nm := &NotificationManager{
enabled: true,
cooldown: 5 * time.Minute,
notifyOnResolve: true,
lastNotified: make(map[string]notificationRecord),
webhooks: []WebhookConfig{},
appriseConfig: AppriseConfig{
Enabled: false,
Mode: AppriseModeCLI,
Targets: []string{},
CLIPath: "apprise",
TimeoutSeconds: 15,
APIKeyHeader: "X-API-KEY",
},
groupWindow: 30 * time.Second,
pendingAlerts: make([]*alerts.Alert, 0),
groupByNode: true,
groupByGuest: false,
webhookHistory: make([]WebhookDelivery, 0, WebhookHistoryMaxSize),
webhookRateLimits: make(map[string]*webhookRateLimit),
publicURL: cleanURL,
appriseExec: defaultAppriseExec,
queue: queue,
stopCleanup: make(chan struct{}),
}
// Create webhook client after NotificationManager is initialized
nm.webhookClient = nm.createSecureWebhookClient(WebhookTimeout)
// Wire up queue processor if queue is available
if queue != nil {
queue.SetProcessor(nm.ProcessQueuedNotification)
}
// Start periodic cleanup of old lastNotified entries (every 1 hour)
go nm.cleanupOldNotificationRecords()
return nm
}
// SetPublicURL updates the public URL used for webhook payloads.
func (n *NotificationManager) SetPublicURL(publicURL string) {
trimmed := strings.TrimRight(strings.TrimSpace(publicURL), "/")
if trimmed == "" {
return
}
n.mu.Lock()
if n.publicURL == trimmed {
n.mu.Unlock()
return
}
n.publicURL = trimmed
n.mu.Unlock()
log.Info().Str("publicURL", trimmed).Msg("NotificationManager public URL updated")
}
// GetPublicURL returns the configured public URL for notifications.
func (n *NotificationManager) GetPublicURL() string {
n.mu.RLock()
defer n.mu.RUnlock()
return n.publicURL
}
// SetEmailConfig updates email configuration
func (n *NotificationManager) SetEmailConfig(config EmailConfig) {
n.mu.Lock()
n.emailConfig = config
// Recreate email manager with new config to preserve rate limiting state
rateLimit := config.RateLimit
if rateLimit <= 0 {
rateLimit = 60
}
providerConfig := EmailProviderConfig{
EmailConfig: config,
Provider: "",
MaxRetries: 3,
RetryDelay: 5,
RateLimit: rateLimit,
StartTLS: config.StartTLS,
SkipTLSVerify: false,
AuthRequired: config.Username != "" && config.Password != "",
}
n.emailManager = NewEnhancedEmailManager(providerConfig)
queue := n.queue
n.mu.Unlock()
if !config.Enabled && queue != nil {
if err := queue.CancelByTypes([]string{"email", "email_resolved", "email_escalation"}); err != nil {
log.Error().Err(err).Msg("Failed to cancel queued email notifications after disable")
}
}
}
// SetAppriseConfig updates Apprise configuration.
func (n *NotificationManager) SetAppriseConfig(config AppriseConfig) {
n.mu.Lock()
n.appriseConfig = NormalizeAppriseConfig(config)
queue := n.queue
enabled := n.appriseConfig.Enabled
n.mu.Unlock()
if !enabled && queue != nil {
if err := queue.CancelByTypes([]string{"apprise", "apprise_resolved", "apprise_escalation"}); err != nil {
log.Error().Err(err).Msg("Failed to cancel queued Apprise notifications after disable")
}
}
}
// GetAppriseConfig returns a copy of the Apprise configuration.
func (n *NotificationManager) GetAppriseConfig() AppriseConfig {
n.mu.RLock()
defer n.mu.RUnlock()
return copyAppriseConfig(n.appriseConfig)
}
// SetCooldown updates the cooldown duration
func (n *NotificationManager) SetCooldown(minutes int) {
n.mu.Lock()
defer n.mu.Unlock()
if minutes < 0 {
minutes = 0
}
n.cooldown = time.Duration(minutes) * time.Minute
log.Info().Int("minutes", minutes).Msg("Updated notification cooldown")
}
// SetNotifyOnResolve toggles whether resolved alerts send notifications.
func (n *NotificationManager) SetNotifyOnResolve(enabled bool) {
n.mu.Lock()
was := n.notifyOnResolve
n.notifyOnResolve = enabled
n.mu.Unlock()
if was != enabled {
log.Info().Bool("enabled", enabled).Msg("Updated resolved alert notifications")
}
}
// GetNotifyOnResolve returns whether resolved alerts trigger notifications.
func (n *NotificationManager) GetNotifyOnResolve() bool {
n.mu.RLock()
defer n.mu.RUnlock()
return n.notifyOnResolve
}
// SetGroupingWindow updates the grouping window duration
func (n *NotificationManager) SetGroupingWindow(seconds int) {
n.mu.Lock()
defer n.mu.Unlock()
if seconds < 0 {
seconds = 0
}
n.groupWindow = time.Duration(seconds) * time.Second
log.Info().Int("seconds", seconds).Msg("Updated notification grouping window")
}
// SetGroupingOptions updates grouping options
func (n *NotificationManager) SetGroupingOptions(byNode, byGuest bool) {
n.mu.Lock()
defer n.mu.Unlock()
n.groupByNode = byNode
n.groupByGuest = byGuest
log.Info().Bool("byNode", byNode).Bool("byGuest", byGuest).Msg("Updated notification grouping options")
}
// AddWebhook adds a webhook configuration
func (n *NotificationManager) AddWebhook(webhook WebhookConfig) {
n.mu.Lock()
defer n.mu.Unlock()
n.webhooks = append(n.webhooks, webhook)
}
// UpdateWebhook updates an existing webhook
func (n *NotificationManager) UpdateWebhook(id string, webhook WebhookConfig) error {
n.mu.Lock()
queue := n.queue
for i, w := range n.webhooks {
if w.ID == id {
n.webhooks[i] = webhook
n.mu.Unlock()
if !webhook.Enabled && queue != nil {
if err := queue.CancelWebhooksByIDs([]string{id}); err != nil {
log.Error().Err(err).Str("webhookID", id).Msg("Failed to cancel queued webhook notifications after disable")
}
}
return nil
}
}
n.mu.Unlock()
return fmt.Errorf("webhook not found: %s", id)
}
// DeleteWebhook removes a webhook
func (n *NotificationManager) DeleteWebhook(id string) error {
n.mu.Lock()
queue := n.queue
for i, w := range n.webhooks {
if w.ID == id {
n.webhooks = append(n.webhooks[:i], n.webhooks[i+1:]...)
n.mu.Unlock()
if queue != nil {
if err := queue.CancelWebhooksByIDs([]string{id}); err != nil {
log.Error().Err(err).Str("webhookID", id).Msg("Failed to cancel queued webhook notifications after delete")
}
}
return nil
}
}
n.mu.Unlock()
return fmt.Errorf("webhook not found: %s", id)
}
// GetWebhooks returns all webhook configurations
func (n *NotificationManager) GetWebhooks() []WebhookConfig {
n.mu.RLock()
defer n.mu.RUnlock()
if len(n.webhooks) == 0 {
return []WebhookConfig{}
}
webhooks := make([]WebhookConfig, len(n.webhooks))
copy(webhooks, n.webhooks)
return webhooks
}
// GetEmailConfig returns the email configuration
func (n *NotificationManager) GetEmailConfig() EmailConfig {
n.mu.RLock()
defer n.mu.RUnlock()
return n.emailConfig
}
// GetQueue returns the notification queue
func (n *NotificationManager) GetQueue() *NotificationQueue {
n.mu.RLock()
defer n.mu.RUnlock()
return n.queue
}
// SetEnabled toggles notification delivery globally for this runtime instance.
func (n *NotificationManager) SetEnabled(enabled bool) {
var (
queue *NotificationQueue
changed bool
)
n.mu.Lock()
changed = n.enabled != enabled
n.enabled = enabled
if !enabled {
for i := range n.pendingAlerts {
n.pendingAlerts[i] = nil
}
n.pendingAlerts = n.pendingAlerts[:0]
if n.groupTimer != nil {
n.groupTimer.Stop()
n.groupTimer = nil
}
queue = n.queue
}
n.mu.Unlock()
if changed {
log.Info().Bool("enabled", enabled).Msg("Updated notification manager enabled state")
}
if !enabled && queue != nil {
if err := queue.CancelByTypes([]string{
"email", "email_resolved", "email_escalation",
"webhook", "webhook_resolved", "webhook_escalation",
"apprise", "apprise_resolved", "apprise_escalation",
}); err != nil {
log.Error().Err(err).Msg("Failed to cancel queued notifications after global disable")
}
}
}
// IsEnabled reports whether notification delivery is currently enabled.
func (n *NotificationManager) IsEnabled() bool {
n.mu.RLock()
defer n.mu.RUnlock()
return n.enabled
}
// SendAlert sends notifications for an alert
func (n *NotificationManager) SendAlert(alert *alerts.Alert) {
n.mu.Lock()
defer n.mu.Unlock()
log.Info().
Str("alertID", alert.ID).
Bool("enabled", n.enabled).
Int("webhooks", len(n.webhooks)).
Bool("emailEnabled", n.emailConfig.Enabled).
Msg("SendAlert called")
if !n.enabled {
log.Debug().Msg("Notifications disabled, skipping")
return
}
// Check cooldown
record, exists := n.lastNotified[alert.ID]
if exists && record.alertStart.Equal(alert.StartTime) && time.Since(record.lastSent) < n.cooldown {
log.Info().
Str("alertID", alert.ID).
Str("resourceName", alert.ResourceName).
Str("type", alert.Type).
Dur("timeSince", time.Since(record.lastSent)).
Dur("cooldown", n.cooldown).
Dur("remainingCooldown", n.cooldown-time.Since(record.lastSent)).
Msg("Alert notification in cooldown for active alert - notification suppressed")
return
}
log.Info().
Str("alertID", alert.ID).
Str("resourceName", alert.ResourceName).
Str("type", alert.Type).
Float64("value", alert.Value).
Float64("threshold", alert.Threshold).
Bool("inCooldown", exists).
Msg("Alert passed cooldown check - adding to pending notifications")
// Add to pending alerts for grouping
n.pendingAlerts = append(n.pendingAlerts, alert)
// If this is the first alert in the group, start the timer
if n.groupTimer == nil {
n.groupTimer = time.AfterFunc(n.groupWindow, func() {
n.sendGroupedAlerts()
})
log.Debug().
Int("pendingCount", len(n.pendingAlerts)).
Dur("groupWindow", n.groupWindow).
Msg("Started alert grouping timer")
}
}
// SendAlertToChannels sends an alert to specific notification channels only,
// bypassing both alert grouping and the regular notification cooldown. This is
// used for escalation notifications where the escalation level specifies which
// channels to notify — escalation has its own timing/level progression so the
// regular cooldown should not suppress it.
// Valid channel values: "email", "webhook", "apprise", "all".
func (n *NotificationManager) SendAlertToChannels(alert *alerts.Alert, channels ...string) {
n.mu.Lock()
if !n.enabled {
n.mu.Unlock()
log.Debug().Msg("Notifications disabled, skipping channel-filtered send")
return
}
// Build channel filter (normalize to lowercase for robustness)
channelSet := make(map[string]bool, len(channels))
for _, ch := range channels {
channelSet[strings.ToLower(strings.TrimSpace(ch))] = true
}
sendAll := channelSet["all"] || len(channels) == 0
// Snapshot only the requested channel configs
var emailConfig EmailConfig
if sendAll || channelSet["email"] {
emailConfig = copyEmailConfig(n.emailConfig)
}
var webhooks []WebhookConfig
if sendAll || channelSet["webhook"] {
webhooks = copyWebhookConfigs(n.webhooks)
}
var appriseConfig AppriseConfig
if sendAll || channelSet["apprise"] {
appriseConfig = copyAppriseConfig(n.appriseConfig)
}
// Check that at least one requested channel is actually enabled
hasEnabled := (emailConfig.Enabled) ||
(appriseConfig.Enabled) ||
func() bool {
for _, w := range webhooks {
if w.Enabled {
return true
}
}
return false
}()
if !hasEnabled {
n.mu.Unlock()
log.Info().
Str("alertID", alert.ID).
Strs("channels", channels).
Msg("No enabled channels match escalation target - skipping")
return
}
n.mu.Unlock()
log.Info().
Str("alertID", alert.ID).
Strs("channels", channels).
Msg("Sending escalation notification to filtered channels")
alertsToSend := []*alerts.Alert{alert}
// Use persistent queue with escalation-typed entries so sends get retried
// on failure, but the dequeue handler skips cooldown writes (it only marks
// cooldown for eventAlert, not eventEscalation).
if n.queue != nil {
n.enqueueEscalationNotifications(emailConfig, webhooks, appriseConfig, alertsToSend)
} else {
n.sendNotificationsDirect(emailConfig, webhooks, appriseConfig, alertsToSend)
}
}
// SendResolvedAlert delivers notifications for a resolved alert immediately.
func (n *NotificationManager) SendResolvedAlert(resolved *alerts.ResolvedAlert) {
if resolved == nil || resolved.Alert == nil {
return
}
// Clone the alert so downstream goroutines cannot mutate shared state.
alertCopy := resolved.Alert.Clone()
if alertCopy == nil {
return
}
resolvedAt := resolved.ResolvedTime
if resolvedAt.IsZero() {
resolvedAt = time.Now()
}
annotateResolvedMetadata(alertCopy, resolvedAt)
n.mu.RLock()
enabled := n.enabled && n.notifyOnResolve
emailConfig := copyEmailConfig(n.emailConfig)
webhooks := copyWebhookConfigs(n.webhooks)
appriseConfig := copyAppriseConfig(n.appriseConfig)
queue := n.queue
n.mu.RUnlock()
if !enabled {
log.Debug().
Str("alertID", alertCopy.ID).
Msg("Resolved notifications disabled, skipping")
return
}
alertsToSend := []*alerts.Alert{alertCopy}
if queue != nil {
n.enqueueResolvedNotifications(queue, emailConfig, webhooks, appriseConfig, alertsToSend, resolvedAt)
} else {
n.sendResolvedNotificationsDirect(emailConfig, webhooks, appriseConfig, alertsToSend, resolvedAt)
}
}
// CancelAlert removes pending notifications for a resolved alert and cleans
// up cooldown and queue state. Always cleans up cooldown and queued firing
// notifications even when the alert has already left the pending buffer
// (was already grouped and flushed to the queue). Refs: #1332
func (n *NotificationManager) CancelAlert(alertID string) {
n.mu.Lock()
defer n.mu.Unlock()
// Always clean up cooldown record for resolved alert, even if it
// already left the pending buffer (i.e. was already sent/grouped).
// Without this, a stale cooldown entry can suppress the next firing.
delete(n.lastNotified, alertID)
// Always cancel queued firing notifications for this alert. If the
// alert was flushed from the pending buffer to the persistent queue
// but hasn't been sent yet, we must cancel it to avoid delivering a
// stale "firing" notification after the alert has already resolved.
if n.queue != nil {
if err := n.queue.CancelByAlertIDs([]string{alertID}); err != nil {
log.Error().Err(err).Str("alertID", alertID).Msg("Failed to cancel queued notifications")
}
}
// Remove from the in-memory pending buffer if still there
if len(n.pendingAlerts) == 0 {
return
}
filtered := n.pendingAlerts[:0]
removed := 0
for _, pending := range n.pendingAlerts {
if pending == nil {
continue
}
if pending.ID == alertID {
removed++
continue
}
filtered = append(filtered, pending)
}
if removed == 0 {
return
}
for i := len(filtered); i < len(n.pendingAlerts); i++ {
n.pendingAlerts[i] = nil
}
n.pendingAlerts = filtered
if len(n.pendingAlerts) == 0 && n.groupTimer != nil {
if n.groupTimer.Stop() {
log.Debug().Str("alertID", alertID).Msg("Stopped grouping timer after alert cancellation")
}
n.groupTimer = nil
}
log.Debug().
Str("alertID", alertID).
Int("remaining", len(n.pendingAlerts)).
Msg("Removed resolved alert from pending notifications and cooldown map")
}
// sendGroupedAlerts sends all pending alerts as a group
func (n *NotificationManager) sendGroupedAlerts() {
n.mu.Lock()
defer n.mu.Unlock()
if len(n.pendingAlerts) == 0 {
return
}
// Copy alerts to send
alertsToSend := make([]*alerts.Alert, len(n.pendingAlerts))
copy(alertsToSend, n.pendingAlerts)
// Clear pending alerts
n.pendingAlerts = n.pendingAlerts[:0]
n.groupTimer = nil
log.Info().
Int("alertCount", len(alertsToSend)).
Msg("Sending grouped alert notifications")
// Snapshot configuration while holding the lock to avoid races with concurrent updates
emailConfig := copyEmailConfig(n.emailConfig)
webhooks := copyWebhookConfigs(n.webhooks)
appriseConfig := copyAppriseConfig(n.appriseConfig)
// Use persistent queue if available, otherwise send directly
if n.queue != nil {
n.enqueueNotifications(emailConfig, webhooks, appriseConfig, alertsToSend)
// Note: Cooldown will be marked after successful dequeue and send
} else {
n.sendNotificationsDirect(emailConfig, webhooks, appriseConfig, alertsToSend)
// For direct sends, mark cooldown immediately (fire-and-forget)
now := time.Now()
for _, alert := range alertsToSend {
n.lastNotified[alert.ID] = notificationRecord{
lastSent: now,
alertStart: alert.StartTime,
}
}
}
}
// enqueueNotifications adds notifications to the persistent queue
// Falls back to direct sending if enqueue fails
func (n *NotificationManager) enqueueNotifications(emailConfig EmailConfig, webhooks []WebhookConfig, appriseConfig AppriseConfig, alertsToSend []*alerts.Alert) {
anyFailed := false
// Enqueue email notification
if emailConfig.Enabled {
configJSON, err := json.Marshal(emailConfig)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal email config for queue")
} else {
notif := &QueuedNotification{
Type: "email",
Alerts: alertsToSend,
Config: configJSON,
MaxAttempts: 3,
}
if err := n.queue.Enqueue(notif); err != nil {
log.Error().Err(err).Msg("Failed to enqueue email notification - falling back to direct send")
anyFailed = true
go n.sendGroupedEmail(emailConfig, alertsToSend)
} else {
log.Debug().Int("alertCount", len(alertsToSend)).Msg("Enqueued email notification")
}
}
}
// Enqueue webhook notifications
for _, webhook := range webhooks {
if webhook.Enabled {
configJSON, err := json.Marshal(webhook)
if err != nil {
log.Error().Err(err).Str("webhookName", webhook.Name).Msg("Failed to marshal webhook config for queue")
} else {
notif := &QueuedNotification{
Type: "webhook",
Alerts: alertsToSend,
Config: configJSON,
MaxAttempts: 3,
}
if err := n.queue.Enqueue(notif); err != nil {
log.Error().Err(err).Str("webhookName", webhook.Name).Msg("Failed to enqueue webhook notification - falling back to direct send")
anyFailed = true
go n.sendGroupedWebhook(webhook, alertsToSend)
} else {
log.Debug().Str("webhookName", webhook.Name).Int("alertCount", len(alertsToSend)).Msg("Enqueued webhook notification")
}
}
}
}
// Enqueue apprise notification
if appriseConfig.Enabled {
configJSON, err := json.Marshal(appriseConfig)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal apprise config for queue")
} else {
notif := &QueuedNotification{
Type: "apprise",
Alerts: alertsToSend,
Config: configJSON,
MaxAttempts: 3,
}
if err := n.queue.Enqueue(notif); err != nil {
log.Error().Err(err).Msg("Failed to enqueue apprise notification - falling back to direct send")
anyFailed = true
go n.sendGroupedApprise(appriseConfig, alertsToSend)
} else {
log.Debug().Int("alertCount", len(alertsToSend)).Msg("Enqueued apprise notification")
}
}
}
// If any enqueue failed, mark cooldown immediately for fire-and-forget sends
if anyFailed {
n.mu.Lock()
now := time.Now()
for _, alert := range alertsToSend {
n.lastNotified[alert.ID] = notificationRecord{
lastSent: now,
alertStart: alert.StartTime,
}
}
n.mu.Unlock()
}
}
// enqueueEscalationNotifications adds escalation notifications to the persistent
// queue using the "_escalation" type suffix. The dequeue handler only marks
// cooldown for eventAlert, so escalation sends won't interfere with the alert
// manager's own re-notify cadence.
func (n *NotificationManager) enqueueEscalationNotifications(emailConfig EmailConfig, webhooks []WebhookConfig, appriseConfig AppriseConfig, alertsToSend []*alerts.Alert) {
if emailConfig.Enabled {
configJSON, err := json.Marshal(emailConfig)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal email config for escalation queue")
} else {
notif := &QueuedNotification{
Type: "email" + queueTypeSuffixEscalation,
Alerts: alertsToSend,
Config: configJSON,
MaxAttempts: 3,
}
if err := n.queue.Enqueue(notif); err != nil {
log.Error().Err(err).Msg("Failed to enqueue escalation email - falling back to direct send")
go n.sendGroupedEmail(emailConfig, alertsToSend)
}
}
}
for _, webhook := range webhooks {
if webhook.Enabled {
configJSON, err := json.Marshal(webhook)
if err != nil {
log.Error().Err(err).Str("webhookName", webhook.Name).Msg("Failed to marshal webhook config for escalation queue")
} else {
notif := &QueuedNotification{
Type: "webhook" + queueTypeSuffixEscalation,
Alerts: alertsToSend,
Config: configJSON,
MaxAttempts: 3,
}
if err := n.queue.Enqueue(notif); err != nil {
log.Error().Err(err).Str("webhookName", webhook.Name).Msg("Failed to enqueue escalation webhook - falling back to direct send")
go n.sendGroupedWebhook(webhook, alertsToSend)
}
}
}
}
if appriseConfig.Enabled {
configJSON, err := json.Marshal(appriseConfig)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal apprise config for escalation queue")
} else {
notif := &QueuedNotification{
Type: "apprise" + queueTypeSuffixEscalation,
Alerts: alertsToSend,
Config: configJSON,
MaxAttempts: 3,
}
if err := n.queue.Enqueue(notif); err != nil {
log.Error().Err(err).Msg("Failed to enqueue escalation apprise - falling back to direct send")
go n.sendGroupedApprise(appriseConfig, alertsToSend)
}
}
}
}
// enqueueResolvedNotifications adds resolved notifications to the persistent queue.
func (n *NotificationManager) enqueueResolvedNotifications(queue *NotificationQueue, emailConfig EmailConfig, webhooks []WebhookConfig, appriseConfig AppriseConfig, alertsToSend []*alerts.Alert, resolvedAt time.Time) {
if queue == nil {
return
}
anyFailed := false
if emailConfig.Enabled {
configJSON, err := json.Marshal(emailConfig)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal email config for resolved queue")
} else {
notif := &QueuedNotification{
Type: "email" + queueTypeSuffixResolved,
Alerts: alertsToSend,
Config: configJSON,
MaxAttempts: 3,
}
if err := queue.Enqueue(notif); err != nil {
log.Error().Err(err).Msg("Failed to enqueue resolved email notification - falling back to direct send")
anyFailed = true
go n.sendResolvedEmail(emailConfig, alertsToSend, resolvedAt)
} else {
log.Debug().Int("alertCount", len(alertsToSend)).Msg("Enqueued resolved email notification")
}
}
}
for _, webhook := range webhooks {
if !webhook.Enabled {
continue
}
webhookCopy := webhook
configJSON, err := json.Marshal(webhookCopy)
if err != nil {
log.Error().Err(err).Str("webhookName", webhookCopy.Name).Msg("Failed to marshal webhook config for resolved queue")
continue
}
notif := &QueuedNotification{
Type: "webhook" + queueTypeSuffixResolved,
Alerts: alertsToSend,
Config: configJSON,
MaxAttempts: 3,
}
if err := queue.Enqueue(notif); err != nil {
log.Error().Err(err).Str("webhookName", webhookCopy.Name).Msg("Failed to enqueue resolved webhook notification - falling back to direct send")
anyFailed = true
go n.sendResolvedWebhook(webhookCopy, alertsToSend, resolvedAt)
} else {
log.Debug().Str("webhookName", webhookCopy.Name).Int("alertCount", len(alertsToSend)).Msg("Enqueued resolved webhook notification")
}
}
if appriseConfig.Enabled {
configJSON, err := json.Marshal(appriseConfig)
if err != nil {
log.Error().Err(err).Msg("Failed to marshal apprise config for resolved queue")
} else {
notif := &QueuedNotification{
Type: "apprise" + queueTypeSuffixResolved,
Alerts: alertsToSend,
Config: configJSON,
MaxAttempts: 3,
}
if err := queue.Enqueue(notif); err != nil {
log.Error().Err(err).Msg("Failed to enqueue resolved Apprise notification - falling back to direct send")
anyFailed = true
go n.sendResolvedApprise(appriseConfig, alertsToSend, resolvedAt)
} else {
log.Debug().Int("alertCount", len(alertsToSend)).Msg("Enqueued resolved Apprise notification")
}
}
}
if anyFailed {
log.Debug().Msg("At least one resolved notification enqueue failed; direct sends were triggered")
}
}
// sendNotificationsDirect sends notifications without using the queue (fallback)
func (n *NotificationManager) sendNotificationsDirect(emailConfig EmailConfig, webhooks []WebhookConfig, appriseConfig AppriseConfig, alertsToSend []*alerts.Alert) {
// Send notifications using the captured snapshots outside the lock to avoid blocking writers
if emailConfig.Enabled {
log.Info().
Int("alertCount", len(alertsToSend)).
Str("smtpHost", emailConfig.SMTPHost).
Int("smtpPort", emailConfig.SMTPPort).
Strs("recipients", emailConfig.To).
Bool("hasAuth", emailConfig.Username != "" && emailConfig.Password != "").
Msg("Email notifications enabled - sending grouped email")
go n.sendGroupedEmail(emailConfig, alertsToSend)
} else {
log.Debug().
Int("alertCount", len(alertsToSend)).
Msg("Email notifications disabled - skipping email delivery")
}
for _, webhook := range webhooks {
if webhook.Enabled {
go n.sendGroupedWebhook(webhook, alertsToSend)
}
}
if appriseConfig.Enabled {
go n.sendGroupedApprise(appriseConfig, alertsToSend)
}
}
// sendResolvedNotificationsDirect delivers resolved notifications without queue persistence.
func (n *NotificationManager) sendResolvedNotificationsDirect(emailConfig EmailConfig, webhooks []WebhookConfig, appriseConfig AppriseConfig, alertsToSend []*alerts.Alert, resolvedAt time.Time) {
if len(alertsToSend) == 0 {
return
}
if emailConfig.Enabled {
go func() {
if err := n.sendResolvedEmail(emailConfig, alertsToSend, resolvedAt); err != nil {
log.Error().Err(err).Msg("Failed to send resolved email notification")
}
}()
}
for _, webhook := range webhooks {
if !webhook.Enabled {
continue
}
webhookCopy := webhook
go func() {
if err := n.sendResolvedWebhook(webhookCopy, alertsToSend, resolvedAt); err != nil {
log.Error().
Err(err).
Str("webhookName", webhookCopy.Name).
Msg("Failed to send resolved webhook notification")
}
}()
}
if appriseConfig.Enabled {
go func() {
if err := n.sendResolvedApprise(appriseConfig, alertsToSend, resolvedAt); err != nil {
log.Error().Err(err).Msg("Failed to send resolved Apprise notification")
}
}()
}
}
// sendGroupedEmail sends a grouped email notification
func (n *NotificationManager) sendGroupedEmail(config EmailConfig, alertList []*alerts.Alert) error {
// Don't check for recipients here - sendHTMLEmail handles empty recipients
// by using the From address as the recipient
// Generate email using template
subject, htmlBody, textBody := EmailTemplate(alertList, false)
// Send using HTML-aware method
return n.sendHTMLEmailWithError(subject, htmlBody, textBody, config)
}
func (n *NotificationManager) sendResolvedEmail(config EmailConfig, alertList []*alerts.Alert, resolvedAt time.Time) error {
if len(alertList) == 0 {
return fmt.Errorf("no alerts to send")
}
subject, htmlBody, textBody := buildResolvedNotificationContent(alertList, resolvedAt, n.publicURL)
if subject == "" && textBody == "" {
return fmt.Errorf("failed to build resolved email content")
}
return n.sendHTMLEmailWithError(subject, htmlBody, textBody, config)
}
func (n *NotificationManager) sendGroupedApprise(config AppriseConfig, alertList []*alerts.Alert) error {
if len(alertList) == 0 {
return fmt.Errorf("no alerts to send")
}
cfg := NormalizeAppriseConfig(config)
if !cfg.Enabled {
return fmt.Errorf("apprise not enabled")
}
title, body, notifyType := buildApprisePayload(alertList, n.publicURL)
if title == "" && body == "" {
return fmt.Errorf("failed to build apprise payload")
}
switch cfg.Mode {
case AppriseModeHTTP:
if err := n.sendAppriseViaHTTP(cfg, title, body, notifyType); err != nil {
log.Warn().
Err(err).
Str("mode", string(cfg.Mode)).
Str("serverUrl", cfg.ServerURL).
Msg("Failed to send Apprise notification via API")
return fmt.Errorf("apprise HTTP send failed: %w", err)
}
default:
if err := n.sendAppriseViaCLI(cfg, title, body); err != nil {
log.Warn().
Err(err).
Str("mode", string(cfg.Mode)).
Str("cliPath", cfg.CLIPath).
Strs("targets", cfg.Targets).
Msg("Failed to send Apprise notification")
return fmt.Errorf("apprise CLI send failed: %w", err)
}
}
return nil
}
func buildApprisePayload(alertList []*alerts.Alert, publicURL string) (string, string, string) {
validAlerts := make([]*alerts.Alert, 0, len(alertList))
var primary *alerts.Alert
for _, alert := range alertList {
if alert == nil {
continue
}
if primary == nil {
primary = alert
}
validAlerts = append(validAlerts, alert)
}
if len(validAlerts) == 0 || primary == nil {
return "", "", "info"
}
title := fmt.Sprintf("Pulse alert: %s", primary.ResourceName)
if len(validAlerts) > 1 {
title = fmt.Sprintf("Pulse alerts (%d)", len(validAlerts))
}
var bodyBuilder strings.Builder
bodyBuilder.WriteString(primary.Message)
bodyBuilder.WriteString("\n\n")
for _, alert := range validAlerts {
bodyBuilder.WriteString(fmt.Sprintf("[%s] %s", strings.ToUpper(string(alert.Level)), alert.ResourceName))
bodyBuilder.WriteString(fmt.Sprintf(" — value %.2f (threshold %.2f)\n", alert.Value, alert.Threshold))
if alert.Node != "" {
bodyBuilder.WriteString(fmt.Sprintf("Node: %s\n", alertNodeDisplay(alert)))
}
if alert.Instance != "" && alert.Instance != alert.Node {
bodyBuilder.WriteString(fmt.Sprintf("Instance: %s\n", alert.Instance))
}
bodyBuilder.WriteString("\n")
}
if publicURL != "" {
bodyBuilder.WriteString("Dashboard: " + publicURL + "\n")
}
return title, bodyBuilder.String(), resolveAppriseNotificationType(validAlerts)
}
func buildResolvedNotificationContent(alertList []*alerts.Alert, resolvedAt time.Time, publicURL string) (string, string, string) {
validAlerts := make([]*alerts.Alert, 0, len(alertList))
var primary *alerts.Alert
for _, alert := range alertList {
if alert == nil {
continue
}
if primary == nil {
primary = alert
}
validAlerts = append(validAlerts, alert)
}
if len(validAlerts) == 0 || primary == nil {
return "", "", ""
}
if resolvedAt.IsZero() {
resolvedAt = time.Now()
}
resolvedLabel := resolvedAt.Format(time.RFC3339)
title := fmt.Sprintf("Pulse alert resolved: %s", primary.ResourceName)
if len(validAlerts) > 1 {
title = fmt.Sprintf("Pulse alerts resolved (%d)", len(validAlerts))
}
var bodyBuilder strings.Builder
bodyBuilder.WriteString("Resolved at ")
bodyBuilder.WriteString(resolvedLabel)
bodyBuilder.WriteString("\n\n")
for _, alert := range validAlerts {
bodyBuilder.WriteString(fmt.Sprintf("[%s] %s\n", strings.ToUpper(string(alert.Level)), alert.ResourceName))
if alert.Message != "" {
bodyBuilder.WriteString(alert.Message)
bodyBuilder.WriteString("\n")
}
if !alert.StartTime.IsZero() {
bodyBuilder.WriteString("Started: ")
bodyBuilder.WriteString(alert.StartTime.Format(time.RFC3339))
bodyBuilder.WriteString("\n")
}
bodyBuilder.WriteString("Cleared: ")
bodyBuilder.WriteString(resolvedLabel)
bodyBuilder.WriteString("\n")
if alert.Node != "" {
bodyBuilder.WriteString("Node: ")
bodyBuilder.WriteString(alertNodeDisplay(alert))
bodyBuilder.WriteString("\n")
}
if alert.Instance != "" && alert.Instance != alert.Node {
bodyBuilder.WriteString("Instance: ")
bodyBuilder.WriteString(alert.Instance)
bodyBuilder.WriteString("\n")
}
if alert.Threshold != 0 || alert.Value != 0 {
bodyBuilder.WriteString(fmt.Sprintf("Last value %.2f (threshold %.2f)\n", alert.Value, alert.Threshold))
}
bodyBuilder.WriteString("\n")
}
if publicURL != "" {
bodyBuilder.WriteString("Dashboard: ")
bodyBuilder.WriteString(publicURL)
bodyBuilder.WriteString("\n")
}
textBody := bodyBuilder.String()
htmlBody := "<pre style=\"font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, \\\"Liberation Mono\\\", \\\"Courier New\\\", monospace\">" +
html.EscapeString(textBody) + "</pre>"
return title, htmlBody, textBody
}
func resolveAppriseNotificationType(alertList []*alerts.Alert) string {
notifyType := "info"
for _, alert := range alertList {
if alert == nil {
continue
}
switch alert.Level {
case alerts.AlertLevelCritical:
return "failure"
case alerts.AlertLevelWarning:
notifyType = "warning"
}
}
return notifyType
}
func (n *NotificationManager) sendAppriseViaCLI(cfg AppriseConfig, title, body string) error {
if len(cfg.Targets) == 0 {
return fmt.Errorf("no Apprise targets configured for CLI delivery")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.TimeoutSeconds)*time.Second)
defer cancel()
args := []string{"-t", title, "-b", body}
args = append(args, cfg.Targets...)
execFn := n.appriseExec
if execFn == nil {
execFn = defaultAppriseExec
}
output, err := execFn(ctx, cfg.CLIPath, args)
if err != nil {
if len(output) > 0 {
log.Debug().
Str("cliPath", cfg.CLIPath).
Strs("targets", cfg.Targets).
Str("output", string(output)).
Msg("Apprise CLI output (error)")
}
return err
}
if len(output) > 0 {
log.Debug().
Str("cliPath", cfg.CLIPath).
Strs("targets", cfg.Targets).
Str("output", string(output)).
Msg("Apprise CLI output")
}
return nil
}
func (n *NotificationManager) sendAppriseViaHTTP(cfg AppriseConfig, title, body, notifyType string) error {
if cfg.ServerURL == "" {
return fmt.Errorf("apprise server URL is not configured")
}
serverURL := cfg.ServerURL
lowerURL := strings.ToLower(serverURL)
if !strings.HasPrefix(lowerURL, "http://") && !strings.HasPrefix(lowerURL, "https://") {
return fmt.Errorf("apprise server URL must start with http or https: %s", serverURL)
}
// Validate Apprise server URL to prevent SSRF
if err := n.ValidateWebhookURL(serverURL); err != nil {
log.Error().
Err(err).
Str("serverURL", serverURL).
Msg("Apprise server URL validation failed - possible SSRF attempt")
return fmt.Errorf("apprise server URL validation failed: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.TimeoutSeconds)*time.Second)
defer cancel()
notifyEndpoint := "/notify"
if cfg.ConfigKey != "" {
notifyEndpoint = "/notify/" + url.PathEscape(cfg.ConfigKey)
}
requestURL := strings.TrimRight(serverURL, "/") + notifyEndpoint
payload := map[string]any{
"body": body,
"title": title,
}
if len(cfg.Targets) > 0 {
payload["urls"] = cfg.Targets
}
if notifyType != "" {
payload["type"] = notifyType
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal Apprise payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewReader(payloadBytes))
if err != nil {
return fmt.Errorf("failed to create Apprise request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if cfg.APIKey != "" {
if cfg.APIKeyHeader == "" {
req.Header.Set("X-API-KEY", cfg.APIKey)
} else {
req.Header.Set(cfg.APIKeyHeader, cfg.APIKey)
}
}
client := &http.Client{
Timeout: time.Duration(cfg.TimeoutSeconds) * time.Second,
}
if strings.HasPrefix(lowerURL, "https://") && cfg.SkipTLSVerify {
client.Transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to reach Apprise server: %w", err)
}
defer resp.Body.Close()
limited := io.LimitReader(resp.Body, WebhookMaxResponseSize)
respBody, _ := io.ReadAll(limited)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if len(respBody) > 0 {
return fmt.Errorf("apprise server returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return fmt.Errorf("apprise server returned HTTP %d", resp.StatusCode)
}
if len(respBody) > 0 {
log.Debug().
Str("mode", string(cfg.Mode)).
Str("serverUrl", cfg.ServerURL).
Str("response", string(respBody)).
Msg("Apprise API response")
}
return nil
}
func (n *NotificationManager) sendResolvedApprise(config AppriseConfig, alertList []*alerts.Alert, resolvedAt time.Time) error {
if len(alertList) == 0 {
return fmt.Errorf("no alerts to send")
}
cfg := NormalizeAppriseConfig(config)
if !cfg.Enabled {
return fmt.Errorf("apprise not enabled")
}
title, _, body := buildResolvedNotificationContent(alertList, resolvedAt, n.publicURL)
if title == "" && body == "" {
return fmt.Errorf("failed to build resolved apprise payload")
}
switch cfg.Mode {
case AppriseModeHTTP:
if err := n.sendAppriseViaHTTP(cfg, title, body, "info"); err != nil {
log.Warn().
Err(err).
Str("mode", string(cfg.Mode)).
Str("serverUrl", cfg.ServerURL).
Msg("Failed to send resolved Apprise notification via API")
return fmt.Errorf("apprise HTTP send failed: %w", err)
}
default:
if err := n.sendAppriseViaCLI(cfg, title, body); err != nil {
log.Warn().
Err(err).
Str("mode", string(cfg.Mode)).
Str("cliPath", cfg.CLIPath).
Strs("targets", cfg.Targets).
Msg("Failed to send resolved Apprise notification")
return fmt.Errorf("apprise CLI send failed: %w", err)
}
}
return nil
}
// sendEmail sends an email notification
func (n *NotificationManager) sendEmail(alert *alerts.Alert) {
n.mu.RLock()
config := n.emailConfig
n.mu.RUnlock()
// Don't check for recipients here - sendHTMLEmail handles empty recipients
// by using the From address as the recipient
// Generate email using template
subject, htmlBody, textBody := EmailTemplate([]*alerts.Alert{alert}, true)
// Send using HTML-aware method
n.sendHTMLEmail(subject, htmlBody, textBody, config)
}
// sendHTMLEmailWithError sends an HTML email with multipart content and returns any error
func (n *NotificationManager) sendHTMLEmailWithError(subject, htmlBody, textBody string, config EmailConfig) error {
// Use From address as recipient if To is empty
recipients := config.To
if len(recipients) == 0 && config.From != "" {
recipients = []string{config.From}
log.Info().
Str("from", config.From).
Msg("Using From address as recipient since To is empty")
}
// Use shared email manager for rate limiting, or create a new one if not available
n.mu.RLock()
manager := n.emailManager
n.mu.RUnlock()
if manager == nil {
// Create email manager if not yet initialized
rl := config.RateLimit
if rl <= 0 {
rl = 60
}
enhancedConfig := EmailProviderConfig{
EmailConfig: EmailConfig{
From: config.From,
To: recipients,
SMTPHost: config.SMTPHost,
SMTPPort: config.SMTPPort,
Username: config.Username,
Password: config.Password,
},
Provider: config.Provider,
StartTLS: config.StartTLS,
MaxRetries: 2,
RetryDelay: 3,
RateLimit: rl,
SkipTLSVerify: false,
AuthRequired: config.Username != "" && config.Password != "",
}
manager = NewEnhancedEmailManager(enhancedConfig)
} else {
// Update manager's config but preserve rate limiter
manager.config.EmailConfig.From = config.From
manager.config.EmailConfig.To = recipients
}
log.Info().
Str("smtp", fmt.Sprintf("%s:%d", config.SMTPHost, config.SMTPPort)).
Str("from", config.From).
Strs("to", recipients).
Bool("hasAuth", config.Username != "" && config.Password != "").
Bool("startTLS", manager.config.StartTLS).
Msg("Attempting to send email via SMTP with enhanced support")
err := manager.SendEmailWithRetry(subject, htmlBody, textBody)
if err != nil {
log.Error().
Err(err).
Str("smtp", fmt.Sprintf("%s:%d", config.SMTPHost, config.SMTPPort)).
Strs("recipients", recipients).
Msg("Failed to send email notification")
return fmt.Errorf("failed to send email: %w", err)
}
log.Info().
Strs("recipients", recipients).
Int("recipientCount", len(recipients)).
Msg("Email notification sent successfully")
return nil
}
// sendHTMLEmail sends an HTML email with multipart content
func (n *NotificationManager) sendHTMLEmail(subject, htmlBody, textBody string, config EmailConfig) {
// Use From address as recipient if To is empty
recipients := config.To
if len(recipients) == 0 && config.From != "" {
recipients = []string{config.From}
log.Info().
Str("from", config.From).
Msg("Using From address as recipient since To is empty")
}
// Create enhanced email configuration with proper STARTTLS support
rl := config.RateLimit
if rl <= 0 {
rl = 60
}
enhancedConfig := EmailProviderConfig{
EmailConfig: EmailConfig{
From: config.From,
To: recipients,
SMTPHost: config.SMTPHost,
SMTPPort: config.SMTPPort,
Username: config.Username,
Password: config.Password,
},
Provider: config.Provider,
StartTLS: config.StartTLS, // Use the configured StartTLS setting
MaxRetries: 2,
RetryDelay: 3,
RateLimit: rl,
SkipTLSVerify: false,
AuthRequired: config.Username != "" && config.Password != "",
}
// Use enhanced email manager for better compatibility
enhancedManager := NewEnhancedEmailManager(enhancedConfig)
log.Info().
Str("smtp", fmt.Sprintf("%s:%d", config.SMTPHost, config.SMTPPort)).
Str("from", config.From).
Strs("to", recipients).
Bool("hasAuth", config.Username != "" && config.Password != "").
Bool("startTLS", enhancedConfig.StartTLS).
Msg("Attempting to send email via SMTP with enhanced support")
err := enhancedManager.SendEmailWithRetry(subject, htmlBody, textBody)
if err != nil {
log.Error().
Err(err).
Str("smtp", fmt.Sprintf("%s:%d", config.SMTPHost, config.SMTPPort)).
Strs("recipients", recipients).
Msg("Failed to send email notification")
} else {
log.Info().
Strs("recipients", recipients).
Int("recipientCount", len(recipients)).
Msg("Email notification sent successfully")
}
}
// sendGroupedWebhook sends a grouped webhook notification
func (n *NotificationManager) sendGroupedWebhook(webhook WebhookConfig, alertList []*alerts.Alert) error {
var jsonData []byte
var err error
if len(alertList) == 0 {
return fmt.Errorf("no alerts to send")
}
// Create a shallow copy of the primary alert to avoid mutating the original memory
// when we modify the message for grouped summaries.
originalPrimary := alertList[0]
alertCopy := *originalPrimary
primaryAlert := &alertCopy
customFields := convertWebhookCustomFields(webhook.CustomFields)
var templateData WebhookPayloadData
var dataPrepared bool
var urlRendered bool
var serviceDataApplied bool
prepareData := func() *WebhookPayloadData {
if !dataPrepared {
prepared := n.prepareWebhookData(primaryAlert, customFields)
prepared.AlertCount = len(alertList)
prepared.Alerts = alertList
prepared.Mention = webhook.Mention
templateData = prepared
dataPrepared = true
}
return &templateData
}
ensureURLAndServiceData := func() (*WebhookPayloadData, bool) {
dataPtr := prepareData()
if !urlRendered {
rendered, renderErr := renderWebhookURL(webhook.URL, *dataPtr)
if renderErr != nil {
log.Error().
Err(renderErr).
Str("webhook", webhook.Name).
Msg("Failed to render webhook URL template for grouped notification")
return nil, false
}
webhook.URL = rendered
urlRendered = true
}
if !serviceDataApplied {
switch webhook.Service {
case "telegram":
chatID, chatErr := extractTelegramChatID(webhook.URL)
if chatErr != nil {
log.Error().
Err(chatErr).
Str("webhook", webhook.Name).
Msg("Failed to extract Telegram chat_id for grouped notification")
return nil, false
}
if chatID != "" {
dataPtr.ChatID = chatID
log.Debug().
Str("webhook", webhook.Name).
Str("chatID", chatID).
Msg("Extracted Telegram chat_id from rendered URL for grouped notification")
}
case "pagerduty":
if dataPtr.CustomFields == nil {
dataPtr.CustomFields = make(map[string]interface{})
}
if routingKey, ok := webhook.Headers["routing_key"]; ok {
dataPtr.CustomFields["routing_key"] = routingKey
}
case "pushover":
dataPtr.CustomFields = ensurePushoverCustomFieldAliases(dataPtr.CustomFields)
}
serviceDataApplied = true
}
return dataPtr, true
}
// Check if webhook has a custom template first
// Only use custom template if it's not empty
if webhook.Template != "" && strings.TrimSpace(webhook.Template) != "" && len(alertList) > 0 {
// Use custom template with enhanced message for grouped alerts
alert := primaryAlert
if len(alertList) > 1 {
// Build a full list of all alerts
summary := alert.Message
otherAlerts := []string{}
for i := 1; i < len(alertList); i++ { // Show ALL alerts
otherAlerts = append(otherAlerts, fmt.Sprintf("• %s: %.1f%%", alertList[i].ResourceName, alertList[i].Value))
}
if len(otherAlerts) > 0 {
// For custom templates, we need to escape newlines since they're likely
// used in shell commands or other contexts that need escaping
alert.Message = fmt.Sprintf("%s\\n\\nAll %d alerts:\\n%s", summary, len(alertList), strings.Join(otherAlerts, "\\n"))
}
}
enhanced := EnhancedWebhookConfig{
WebhookConfig: webhook,
Service: webhook.Service,
PayloadTemplate: webhook.Template,
CustomFields: customFields,
}
if dataPtr, ok := ensureURLAndServiceData(); ok {
jsonData, err = n.generatePayloadFromTemplateWithService(enhanced.PayloadTemplate, *dataPtr, webhook.Service)
} else {
return fmt.Errorf("failed to prepare webhook URL and service data")
}
if err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Int("alertCount", len(alertList)).
Msg("Failed to generate grouped payload from custom template")
return fmt.Errorf("failed to generate payload from custom template: %w", err)
}
} else if webhook.Service != "" && webhook.Service != "generic" && len(alertList) > 0 {
// For service-specific webhooks, use the first alert with a note about others
// For simplicity, send the first alert with a note about others
// Most webhook services work better with single structured payloads
alert := primaryAlert
enhanced := EnhancedWebhookConfig{
WebhookConfig: webhook,
Service: webhook.Service,
CustomFields: customFields,
}
// Get service template
templates := GetWebhookTemplates()
templateFound := false
for _, tmpl := range templates {
if tmpl.Service == webhook.Service {
enhanced.PayloadTemplate = tmpl.PayloadTemplate
templateFound = true
break
}
}
if templateFound {
// Modify message if multiple alerts - but format differently for Discord
if len(alertList) > 1 {
summary := alert.Message
otherAlerts := []string{}
for i := 1; i < len(alertList); i++ {
otherAlerts = append(otherAlerts, fmt.Sprintf("• %s: %.1f%%", alertList[i].ResourceName, alertList[i].Value))
}
if len(otherAlerts) > 0 {
// For Discord, format as a single line list to avoid newline issues
// Discord embeds don't render \n in description anyway
if webhook.Service == "discord" {
// Use comma-separated list for Discord
alert.Message = fmt.Sprintf("%s | %d alerts: %s", summary, len(alertList), strings.Join(otherAlerts, ", "))
} else {
// For other services, escape newlines properly
alert.Message = fmt.Sprintf("%s\\n\\nAll %d alerts:\\n%s", summary, len(alertList), strings.Join(otherAlerts, "\\n"))
}
}
}
if dataPtr, ok := ensureURLAndServiceData(); ok {
jsonData, err = n.generatePayloadFromTemplateWithService(enhanced.PayloadTemplate, *dataPtr, webhook.Service)
} else {
return fmt.Errorf("failed to prepare webhook URL and service data")
}
if err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Int("alertCount", len(alertList)).
Msg("Failed to generate payload for grouped alerts")
return fmt.Errorf("failed to generate payload for grouped alerts: %w", err)
}
} else {
// No template found, use generic payload
webhook.Service = "generic"
}
}
// Use generic payload if no service or template not found
// But ONLY if jsonData hasn't been set yet (from custom template)
if jsonData == nil && (webhook.Service == "" || webhook.Service == "generic") {
if _, ok := ensureURLAndServiceData(); !ok {
return fmt.Errorf("failed to prepare webhook URL and service data")
}
// Use generic payload for other services
payload := map[string]interface{}{
"alerts": alertList,
"count": len(alertList),
"timestamp": time.Now().Unix(),
"source": "pulse-monitoring",
"grouped": true,
}
jsonData, err = json.Marshal(payload)
if err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Int("alertCount", len(alertList)).
Msg("Failed to marshal grouped webhook payload")
return fmt.Errorf("failed to marshal grouped webhook payload: %w", err)
}
}
if _, ok := ensureURLAndServiceData(); !ok {
return fmt.Errorf("failed to prepare webhook URL and service data")
}
// Send using same request logic
return n.sendWebhookRequest(webhook, jsonData, "grouped")
}
func (n *NotificationManager) sendResolvedWebhook(webhook WebhookConfig, alertList []*alerts.Alert, resolvedAt time.Time) error {
if len(alertList) == 0 {
return fmt.Errorf("no alerts to send")
}
if !webhook.Enabled {
return fmt.Errorf("webhook is disabled")
}
if resolvedAt.IsZero() {
resolvedAt = time.Now()
}
// ntfy needs plain-text body + headers, not JSON
if webhook.Service == "ntfy" {
return n.sendResolvedWebhookNtfy(webhook, alertList, resolvedAt)
}
// Use custom or service-specific templates for resolved webhooks (mirrors sendWebhook logic).
// Custom template takes precedence, then service template.
// For service webhooks, return an error on failure rather than sending a generic payload
// that the service endpoint would reject anyway.
hasCustomTemplate := webhook.Template != "" && strings.TrimSpace(webhook.Template) != ""
hasServiceTemplate := webhook.Service != "" && webhook.Service != "generic"
if hasCustomTemplate || hasServiceTemplate {
alert := alertList[0]
if alert == nil {
return fmt.Errorf("first alert in resolved list is nil for service webhook %s", webhook.Name)
}
data := n.prepareResolvedWebhookData(alert, webhook, resolvedAt)
data.AlertCount = len(alertList)
if len(alertList) > 1 {
data.Message = fmt.Sprintf("Resolved: %d alerts cleared", len(alertList))
}
data.Alerts = alertList
// Render URL template if placeholders are present
renderedURL, renderErr := renderWebhookURL(webhook.URL, data)
if renderErr != nil {
return fmt.Errorf("failed to render resolved webhook URL template for %s: %w", webhook.Name, renderErr)
}
webhook.URL = renderedURL
// Service-specific data enrichment (mirrors sendWebhook logic)
if webhook.Service == "telegram" {
chatID, chatErr := extractTelegramChatID(renderedURL)
if chatErr != nil {
return fmt.Errorf("failed to extract Telegram chat_id for resolved webhook %s: %w", webhook.Name, chatErr)
}
if chatID != "" {
data.ChatID = chatID
}
} 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
}
}
// Try custom template first, then service template
if hasCustomTemplate {
jsonData, err := n.generatePayloadFromTemplateWithService(webhook.Template, data, webhook.Service)
if err == nil {
return n.sendWebhookRequest(webhook, jsonData, "resolved")
}
log.Warn().Err(err).Str("webhook", webhook.Name).Msg("Failed to render resolved custom template, trying service template")
// Fall through to service template if available
}
if hasServiceTemplate {
templates := GetWebhookTemplates()
for _, tmpl := range templates {
if tmpl.Service == webhook.Service && tmpl.ResolvedPayloadTemplate != "" {
jsonData, err := n.generatePayloadFromTemplateWithService(tmpl.ResolvedPayloadTemplate, data, webhook.Service)
if err == nil {
return n.sendWebhookRequest(webhook, jsonData, "resolved")
}
return fmt.Errorf("failed to render resolved %s template for %s: %w", webhook.Service, webhook.Name, err)
}
}
// No resolved template for this service — fall through to generic payload
}
// Fall through to generic payload below
}
// Generic payload for webhooks with no service and no custom template
payload := map[string]interface{}{
"event": string(eventResolved),
"alerts": alertList,
"count": len(alertList),
"resolvedAt": resolvedAt.Unix(),
"resolvedAtIso": resolvedAt.Format(time.RFC3339),
"source": "pulse-monitoring",
}
if n.publicURL != "" {
payload["dashboard"] = n.publicURL
}
if len(alertList) == 1 && alertList[0] != nil {
payload["alertId"] = alertList[0].ID
}
jsonData, err := json.Marshal(payload)
if err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Int("alertCount", len(alertList)).
Msg("Failed to marshal resolved webhook payload")
return fmt.Errorf("failed to marshal resolved webhook payload: %w", err)
}
return n.sendWebhookRequest(webhook, jsonData, "resolved")
}
// sendResolvedWebhookNtfy sends a resolved webhook formatted for ntfy (plain text + headers)
func (n *NotificationManager) sendResolvedWebhookNtfy(webhook WebhookConfig, alertList []*alerts.Alert, resolvedAt time.Time) error {
// Re-validate webhook URL
if err := n.ValidateWebhookURL(webhook.URL); err != nil {
return fmt.Errorf("webhook URL validation failed: %w", err)
}
if !n.checkWebhookRateLimit(webhook.URL) {
return fmt.Errorf("rate limit exceeded for webhook %s", webhook.Name)
}
// Build plain-text body
var body strings.Builder
if len(alertList) == 1 && alertList[0] != nil {
a := alertList[0]
fmt.Fprintf(&body, "Resolved: %s on %s is now healthy", a.ResourceName, a.Node)
} else {
fmt.Fprintf(&body, "%d alerts resolved at %s:\n", len(alertList), resolvedAt.Format(time.RFC822))
for _, a := range alertList {
if a != nil {
fmt.Fprintf(&body, "- %s on %s\n", a.ResourceName, a.Node)
}
}
}
// Build title
title := "RESOLVED"
if len(alertList) == 1 && alertList[0] != nil {
title = fmt.Sprintf("RESOLVED: %s", alertList[0].ResourceName)
} else {
title = fmt.Sprintf("RESOLVED: %d alerts", len(alertList))
}
method := webhook.Method
if method == "" {
method = "POST"
}
req, err := http.NewRequest(method, webhook.URL, bytes.NewBufferString(body.String()))
if err != nil {
return fmt.Errorf("failed to create ntfy request: %w", err)
}
req.Header.Set("Content-Type", "text/plain")
req.Header.Set("Title", title)
req.Header.Set("Priority", "default")
req.Header.Set("Tags", "white_check_mark,pulse,resolved")
req.Header.Set("User-Agent", "Pulse-Monitoring/2.0")
// Apply any custom headers from webhook config
for key, value := range webhook.Headers {
if !strings.Contains(value, "{{") {
req.Header.Set(key, value)
}
}
resp, err := n.webhookClient.Do(req)
if err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Msg("Failed to send resolved ntfy webhook")
return fmt.Errorf("failed to send ntfy webhook: %w", err)
}
defer resp.Body.Close()
// Read response with size limit
limitedReader := io.LimitReader(resp.Body, WebhookMaxResponseSize)
var respBody bytes.Buffer
respBody.ReadFrom(limitedReader)
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
log.Info().
Str("webhook", webhook.Name).
Str("service", "ntfy").
Str("type", "resolved").
Int("status", resp.StatusCode).
Int("alertCount", len(alertList)).
Msg("Resolved ntfy webhook sent successfully")
return nil
}
log.Warn().
Str("webhook", webhook.Name).
Str("service", "ntfy").
Int("status", resp.StatusCode).
Str("response", respBody.String()).
Msg("Resolved ntfy webhook returned non-success status")
return fmt.Errorf("ntfy webhook returned HTTP %d: %s", resp.StatusCode, respBody.String())
}
// checkWebhookRateLimit checks if a webhook can be sent based on rate limits
func (n *NotificationManager) checkWebhookRateLimit(webhookURL string) bool {
n.webhookRateMu.Lock()
defer n.webhookRateMu.Unlock()
now := time.Now()
limit, exists := n.webhookRateLimits[webhookURL]
if !exists {
// First time sending to this webhook
n.webhookRateLimits[webhookURL] = &webhookRateLimit{
lastSent: now,
sentCount: 1,
}
return true
}
// Check if we're still in the rate limit window
if now.Sub(limit.lastSent) > WebhookRateLimitWindow {
// Window expired, reset counter
limit.lastSent = now
limit.sentCount = 1
return true
}
// Still in window, check if we've exceeded the limit
if limit.sentCount >= WebhookRateLimitMax {
log.Warn().
Str("webhookURL", webhookURL).
Int("sentCount", limit.sentCount).
Dur("window", WebhookRateLimitWindow).
Msg("Webhook rate limit exceeded, dropping request")
return false
}
// Increment counter and allow
limit.sentCount++
return true
}
// sendWebhookRequest sends the actual webhook request
func (n *NotificationManager) sendWebhookRequest(webhook WebhookConfig, jsonData []byte, alertType string) error {
// Re-validate webhook URL to prevent DNS rebinding attacks
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 at send time - possible DNS rebinding")
return fmt.Errorf("webhook URL validation failed: %w", err)
}
// Check rate limit before sending
if !n.checkWebhookRateLimit(webhook.URL) {
log.Warn().
Str("webhook", webhook.Name).
Str("url", webhook.URL).
Msg("Webhook request dropped due to rate limiting")
return fmt.Errorf("rate limit exceeded for webhook %s", webhook.Name)
}
// Create request
method := webhook.Method
if method == "" {
method = "POST"
}
// For Telegram webhooks, strip chat_id from URL if present
// The chat_id should only be in the JSON body, not the URL
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()
log.Debug().
Str("original", webhook.URL).
Str("cleaned", webhookURL).
Msg("Stripped chat_id from Telegram webhook URL")
}
}
req, err := http.NewRequest(method, webhookURL, bytes.NewBuffer(jsonData))
if err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Str("type", alertType).
Msg("Failed to create webhook request")
return fmt.Errorf("failed to create webhook request: %w", err)
}
// Set headers
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Pulse-Monitoring/2.0")
// Special handling for ntfy service
if webhook.Service == "ntfy" {
// Set Content-Type for ntfy (plain text)
req.Header.Set("Content-Type", "text/plain")
// Note: Dynamic headers for ntfy are set in sendWebhook for individual alerts
}
// Apply any custom headers from webhook config
for key, value := range webhook.Headers {
// Skip template-like headers (those with {{) to prevent errors
if !strings.Contains(value, "{{") {
req.Header.Set(key, value)
}
}
// Debug log for Telegram and Gotify webhooks (without secrets)
if webhook.Service == "telegram" || webhook.Service == "gotify" {
log.Debug().
Str("webhook", webhook.Name).
Str("service", webhook.Service).
Msg("Sending webhook")
}
// Send request with shared secure client
resp, err := n.webhookClient.Do(req)
if err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Str("type", alertType).
Msg("Failed to send webhook")
return fmt.Errorf("failed to send webhook: %w", err)
}
defer resp.Body.Close()
// Read response body with size limit to prevent memory exhaustion
limitedReader := io.LimitReader(resp.Body, WebhookMaxResponseSize)
var respBody bytes.Buffer
bytesRead, err := respBody.ReadFrom(limitedReader)
if err != nil {
log.Warn().
Err(err).
Str("webhook", webhook.Name).
Str("type", alertType).
Msg("Failed to read webhook response body")
return fmt.Errorf("failed to read webhook response: %w", err)
}
// Check if we hit the size limit
if bytesRead >= WebhookMaxResponseSize {
log.Warn().
Str("webhook", webhook.Name).
Int64("bytesRead", bytesRead).
Int("maxSize", WebhookMaxResponseSize).
Msg("Webhook response exceeded size limit, truncated")
}
responseBody := respBody.String()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
log.Info().
Str("webhook", webhook.Name).
Str("service", webhook.Service).
Str("type", alertType).
Int("status", resp.StatusCode).
Int("payloadSize", len(jsonData)).
Msg("Webhook notification sent successfully")
// Log response body only in debug mode for successful requests
if len(responseBody) > 0 {
log.Debug().
Str("webhook", webhook.Name).
Str("response", responseBody).
Msg("Webhook response body")
}
return nil
} else {
log.Warn().
Str("webhook", webhook.Name).
Str("service", webhook.Service).
Str("type", alertType).
Int("status", resp.StatusCode).
Str("response", responseBody).
Msg("Webhook returned non-success status")
return fmt.Errorf("webhook returned HTTP %d: %s", resp.StatusCode, responseBody)
}
}
// sendWebhook sends a webhook notification
func (n *NotificationManager) sendWebhook(webhook WebhookConfig, alert *alerts.Alert) {
var jsonData []byte
var err error
customFields := convertWebhookCustomFields(webhook.CustomFields)
data := n.prepareWebhookData(alert, customFields)
// Render URL template if placeholders are present
renderedURL, renderErr := renderWebhookURL(webhook.URL, data)
if renderErr != nil {
log.Error().
Err(renderErr).
Str("webhook", webhook.Name).
Msg("Failed to render webhook URL template")
return
}
webhook.URL = renderedURL
// Service-specific data enrichment
if webhook.Service == "telegram" {
chatID, chatErr := extractTelegramChatID(renderedURL)
if chatErr != nil {
log.Error().
Err(chatErr).
Str("webhook", webhook.Name).
Msg("Failed to extract Telegram chat_id - skipping webhook")
return
}
if chatID != "" {
data.ChatID = chatID
log.Debug().
Str("webhook", webhook.Name).
Str("chatID", chatID).
Msg("Extracted Telegram chat_id from rendered URL")
}
} 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
}
}
// Check if webhook has a custom template first
// Only use custom template if it's not empty
if webhook.Template != "" && strings.TrimSpace(webhook.Template) != "" {
// Use custom template provided by user
enhanced := EnhancedWebhookConfig{
WebhookConfig: webhook,
Service: webhook.Service,
PayloadTemplate: webhook.Template,
CustomFields: customFields,
}
jsonData, err = n.generatePayloadFromTemplateWithService(enhanced.PayloadTemplate, data, webhook.Service)
if err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Str("alertID", alert.ID).
Msg("Failed to generate webhook payload from custom template")
return
}
} else if webhook.Service != "" && webhook.Service != "generic" {
// Check if this webhook has a service type and use the proper template
// Convert to enhanced webhook to use template
enhanced := EnhancedWebhookConfig{
WebhookConfig: webhook,
Service: webhook.Service,
CustomFields: customFields,
}
// Get service template
templates := GetWebhookTemplates()
templateFound := false
for _, tmpl := range templates {
if tmpl.Service == webhook.Service {
enhanced.PayloadTemplate = tmpl.PayloadTemplate
templateFound = true
break
}
}
// Only use template if found, otherwise fall back to generic
if templateFound {
jsonData, err = n.generatePayloadFromTemplateWithService(enhanced.PayloadTemplate, data, webhook.Service)
if err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Str("service", webhook.Service).
Str("alertID", alert.ID).
Msg("Failed to generate webhook payload")
return
}
} else {
// No template found, use generic payload
webhook.Service = "generic"
}
}
// Use generic payload if no service or template not found
// But ONLY if jsonData hasn't been set yet (from custom template)
if jsonData == nil && (webhook.Service == "" || webhook.Service == "generic") {
// Use generic payload for other services
payload := map[string]interface{}{
"alert": alert,
"timestamp": time.Now().Unix(),
"source": "pulse-monitoring",
}
jsonData, err = json.Marshal(payload)
if err != nil {
log.Error().
Err(err).
Str("webhook", webhook.Name).
Str("alertID", alert.ID).
Msg("Failed to marshal webhook payload")
return
}
}
// Send using common request logic
n.sendWebhookRequest(webhook, jsonData, fmt.Sprintf("alert-%s", alert.ID))
}
func convertWebhookCustomFields(fields map[string]string) map[string]interface{} {
if len(fields) == 0 {
return nil
}
converted := make(map[string]interface{}, len(fields))
for key, value := range fields {
converted[key] = value
}
return converted
}
func ensurePushoverCustomFieldAliases(fields map[string]interface{}) map[string]interface{} {
if fields == nil {
return nil
}
if _, ok := fields["token"]; !ok || isEmptyInterface(fields["token"]) {
if legacy, ok := fields["app_token"]; ok && !isEmptyInterface(legacy) {
fields["token"] = legacy
}
}
if _, ok := fields["user"]; !ok || isEmptyInterface(fields["user"]) {
if legacy, ok := fields["user_token"]; ok && !isEmptyInterface(legacy) {
fields["user"] = legacy
}
}
return fields
}
func isEmptyInterface(value interface{}) bool {
switch v := value.(type) {
case string:
return strings.TrimSpace(v) == ""
case fmt.Stringer:
return strings.TrimSpace(v.String()) == ""
case nil:
return true
default:
return false
}
}
// prepareWebhookData prepares data for template rendering
func (n *NotificationManager) prepareWebhookData(alert *alerts.Alert, customFields map[string]interface{}) WebhookPayloadData {
duration := time.Since(alert.StartTime)
// Construct full Pulse URL if publicURL is configured
// The Instance field should contain the full URL to the Pulse dashboard
instance := ""
if n.publicURL != "" {
// Remove trailing slash from publicURL if present
instance = strings.TrimRight(n.publicURL, "/")
} else if alert.Instance != "" && (strings.HasPrefix(alert.Instance, "http://") || strings.HasPrefix(alert.Instance, "https://")) {
// If publicURL is not set but alert.Instance contains a full URL, use it
instance = alert.Instance
}
resourceType := ""
if alert.Metadata != nil {
if rt, ok := alert.Metadata["resourceType"].(string); ok {
resourceType = rt
}
}
var metadataCopy map[string]interface{}
if alert.Metadata != nil {
metadataCopy = make(map[string]interface{}, len(alert.Metadata))
for k, v := range alert.Metadata {
metadataCopy[k] = v
}
}
var ackTime string
if alert.AckTime != nil {
ackTime = alert.AckTime.Format(time.RFC3339)
}
// Round Value and Threshold to 1 decimal place for cleaner webhook payloads
roundedValue := math.Round(alert.Value*10) / 10
roundedThreshold := math.Round(alert.Threshold*10) / 10
return WebhookPayloadData{
ID: alert.ID,
Level: string(alert.Level),
Type: alert.Type,
ResourceName: alert.ResourceName,
ResourceID: alert.ResourceID,
Node: alert.Node,
NodeDisplayName: alertNodeDisplay(alert),
Instance: instance,
Message: alert.Message,
Value: roundedValue,
Threshold: roundedThreshold,
ValueFormatted: formatMetricValue(alert.Type, alert.Value),
ThresholdFormatted: formatMetricThreshold(alert.Type, alert.Threshold),
StartTime: alert.StartTime.Format(time.RFC3339),
Duration: formatWebhookDuration(duration),
Timestamp: time.Now().Format(time.RFC3339),
ResourceType: resourceType,
Acknowledged: alert.Acknowledged,
AckTime: ackTime,
AckUser: alert.AckUser,
Event: "alert",
Metadata: metadataCopy,
CustomFields: customFields,
AlertCount: 1,
}
}
// prepareResolvedWebhookData builds a WebhookPayloadData for a resolved alert,
// suitable for rendering through service-specific templates (Discord, Slack, Teams, etc.).
func (n *NotificationManager) prepareResolvedWebhookData(alert *alerts.Alert, webhook WebhookConfig, resolvedAt time.Time) WebhookPayloadData {
duration := resolvedAt.Sub(alert.StartTime)
instance := ""
if n.publicURL != "" {
instance = strings.TrimRight(n.publicURL, "/")
} else if alert.Instance != "" && (strings.HasPrefix(alert.Instance, "http://") || strings.HasPrefix(alert.Instance, "https://")) {
instance = alert.Instance
}
resourceType := ""
if alert.Metadata != nil {
if rt, ok := alert.Metadata["resourceType"].(string); ok {
resourceType = rt
}
}
var metadataCopy map[string]interface{}
if alert.Metadata != nil {
metadataCopy = make(map[string]interface{}, len(alert.Metadata))
for k, v := range alert.Metadata {
metadataCopy[k] = v
}
}
var ackTime string
if alert.AckTime != nil {
ackTime = alert.AckTime.Format(time.RFC3339)
}
roundedValue := math.Round(alert.Value*10) / 10
roundedThreshold := math.Round(alert.Threshold*10) / 10
// Build a human-readable resolved message
message := fmt.Sprintf("Resolved: %s on %s is now healthy", alert.ResourceName, alertNodeDisplay(alert))
return WebhookPayloadData{
ID: alert.ID,
Level: "resolved",
Type: alert.Type,
ResourceName: alert.ResourceName,
ResourceID: alert.ResourceID,
Node: alert.Node,
NodeDisplayName: alertNodeDisplay(alert),
Instance: instance,
Message: message,
Value: roundedValue,
Threshold: roundedThreshold,
ValueFormatted: formatMetricValue(alert.Type, alert.Value),
ThresholdFormatted: formatMetricThreshold(alert.Type, alert.Threshold),
StartTime: alert.StartTime.Format(time.RFC3339),
Duration: formatWebhookDuration(duration),
Timestamp: resolvedAt.Format(time.RFC3339),
ResourceType: resourceType,
Acknowledged: alert.Acknowledged,
AckTime: ackTime,
AckUser: alert.AckUser,
Event: "resolved",
ResolvedAt: resolvedAt.Format(time.RFC3339),
ResolvedAtISO: resolvedAt.Format(time.RFC3339),
Metadata: metadataCopy,
CustomFields: convertWebhookCustomFields(webhook.CustomFields),
AlertCount: 1,
Mention: webhook.Mention,
}
}
func templateFuncMap() template.FuncMap {
return template.FuncMap{
"title": func(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
},
"upper": strings.ToUpper,
"lower": strings.ToLower,
"printf": fmt.Sprintf,
"urlquery": template.URLQueryEscaper,
"urlencode": template.URLQueryEscaper,
"urlpath": url.PathEscape,
"pathescape": func(s string) string {
return url.PathEscape(s)
},
}
}
// generatePayloadFromTemplateWithService renders the payload using Go templates with service-specific handling
func (n *NotificationManager) generatePayloadFromTemplateWithService(templateStr string, data WebhookPayloadData, service string) ([]byte, error) {
tmpl, err := template.New("webhook").Funcs(templateFuncMap()).Parse(templateStr)
if err != nil {
return nil, fmt.Errorf("invalid template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return nil, fmt.Errorf("template execution failed: %w", err)
}
// Skip JSON validation for services that use plain text payloads
if service == "ntfy" {
// ntfy uses plain text, not JSON
return buf.Bytes(), nil
}
// Validate that the generated payload is valid JSON for other services
var jsonCheck interface{}
if err := json.Unmarshal(buf.Bytes(), &jsonCheck); err != nil {
log.Error().
Err(err).
Str("payload", buf.String()).
Msg("Generated webhook payload is invalid JSON")
return nil, fmt.Errorf("template produced invalid JSON: %w", err)
}
return buf.Bytes(), nil
}
// renderWebhookURL applies template rendering to webhook URLs and ensures the result is a valid URL
func renderWebhookURL(urlTemplate string, data WebhookPayloadData) (string, error) {
trimmed := strings.TrimSpace(urlTemplate)
if trimmed == "" {
return "", fmt.Errorf("webhook URL cannot be empty")
}
if !strings.Contains(trimmed, "{{") {
return trimmed, nil
}
tmpl, err := template.New("webhook_url").Funcs(templateFuncMap()).Parse(trimmed)
if err != nil {
return "", fmt.Errorf("invalid webhook URL template: %w", err)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", fmt.Errorf("webhook URL template execution failed: %w", err)
}
rendered := strings.TrimSpace(buf.String())
if rendered == "" {
return "", fmt.Errorf("webhook URL template produced empty URL")
}
parsed, err := url.Parse(rendered)
if err != nil {
return "", fmt.Errorf("webhook URL template produced invalid URL: %w", err)
}
if parsed.Scheme == "" || parsed.Host == "" {
return "", fmt.Errorf("webhook URL template produced invalid URL: missing scheme or host")
}
return parsed.String(), nil
}
// formatWebhookDuration formats a duration in a human-readable way
func formatWebhookDuration(d time.Duration) string {
if d < time.Minute {
return fmt.Sprintf("%ds", int(d.Seconds()))
} else if d < time.Hour {
return fmt.Sprintf("%dm", int(d.Minutes()))
} else if d < 24*time.Hour {
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
} else {
days := int(d.Hours()) / 24
hours := int(d.Hours()) % 24
return fmt.Sprintf("%dd %dh", days, hours)
}
}
// extractTelegramChatID extracts and validates the chat_id from a Telegram webhook URL
func extractTelegramChatID(webhookURL string) (string, error) {
if !strings.Contains(webhookURL, "chat_id=") {
return "", fmt.Errorf("telegram webhook URL missing chat_id parameter")
}
u, err := url.Parse(webhookURL)
if err != nil {
return "", fmt.Errorf("invalid URL format: %w", err)
}
chatID := u.Query().Get("chat_id")
if chatID == "" {
return "", fmt.Errorf("chat_id parameter is empty")
}
// Validate that chat_id is numeric (Telegram chat IDs are always numeric)
// Handle negative IDs (group chats) and positive IDs (private chats)
if strings.HasPrefix(chatID, "-") {
if !isNumeric(chatID[1:]) {
return "", fmt.Errorf("chat_id must be numeric, got: %s", chatID)
}
} else if !isNumeric(chatID) {
return "", fmt.Errorf("chat_id must be numeric, got: %s", chatID)
}
return chatID, nil
}
// isNumeric checks if a string contains only digits
func isNumeric(s string) bool {
for _, char := range s {
if char < '0' || char > '9' {
return false
}
}
return len(s) > 0
}
// ValidateWebhookURL validates that a webhook URL is safe and properly formed
func (n *NotificationManager) ValidateWebhookURL(webhookURL string) error {
if webhookURL == "" {
return fmt.Errorf("webhook URL cannot be empty")
}
u, err := url.Parse(webhookURL)
if err != nil {
return fmt.Errorf("invalid URL format: %w", err)
}
// Must be HTTP or HTTPS
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("webhook URL must use http or https protocol")
}
// Get hostname for validation
host := u.Hostname()
if host == "" {
return fmt.Errorf("webhook URL missing hostname")
}
// Block localhost and loopback addresses (SSRF protection) unless allowlisted
if host == "localhost" || host == "127.0.0.1" || host == "::1" || strings.HasPrefix(host, "127.") {
// Check if localhost is in the allowlist
localhostIP := net.ParseIP("127.0.0.1")
if !n.isIPInAllowlist(localhostIP) {
return fmt.Errorf("webhook URLs pointing to localhost are not allowed for security reasons")
}
log.Debug().
Str("host", host).
Str("url", webhookURL).
Msg("Localhost webhook URL allowed via allowlist")
}
// Block link-local addresses
if strings.HasPrefix(host, "169.254.") || strings.HasPrefix(host, "fe80:") {
return fmt.Errorf("webhook URLs pointing to link-local addresses are not allowed")
}
// Resolve hostname to IPs and check for private ranges (DNS rebinding protection)
ips, err := net.LookupIP(host)
if err != nil {
// DNS resolution failed - reject for security
return fmt.Errorf("failed to resolve webhook hostname %s: %w (DNS resolution required for security)", host, err)
}
// Check all resolved IPs for private ranges
for _, ip := range ips {
if isPrivateIP(ip) {
// Check if this private IP is in the allowlist
if n.isIPInAllowlist(ip) {
log.Debug().
Str("ip", ip.String()).
Str("url", webhookURL).
Msg("Webhook URL resolves to private IP in allowlist")
} else {
return fmt.Errorf("webhook URL resolves to private IP %s - private networks are not allowed for security (configure allowlist in System Settings)", ip.String())
}
}
}
// Block common metadata service endpoints (cloud providers)
metadataHosts := []string{
"169.254.169.254", // AWS, Azure, GCP metadata
"metadata.google.internal",
"metadata.goog",
}
for _, metadataHost := range metadataHosts {
if host == metadataHost {
return fmt.Errorf("webhook URLs pointing to cloud metadata services are not allowed")
}
}
// Ensure hostname is not just an IP address without proper DNS
// This helps prevent SSRF attacks using numeric IPs to bypass filters
if u.Scheme == "https" && isNumericIP(host) {
log.Warn().
Str("url", webhookURL).
Msg("Webhook URL uses numeric IP with HTTPS - certificate validation may fail")
}
return nil
}
// isPrivateIP checks if an IP address is in a private range
func isPrivateIP(ip net.IP) bool {
// Private IPv4 ranges
privateRanges := []string{
"10.0.0.0/8", // RFC1918
"172.16.0.0/12", // RFC1918
"192.168.0.0/16", // RFC1918
"127.0.0.0/8", // Loopback
"169.254.0.0/16", // Link-local
"::1/128", // IPv6 loopback
"fe80::/10", // IPv6 link-local
"fc00::/7", // IPv6 unique local
}
for _, cidr := range privateRanges {
_, ipnet, err := net.ParseCIDR(cidr)
if err != nil {
continue
}
if ipnet.Contains(ip) {
return true
}
}
return false
}
// isNumericIP checks if a string is a numeric IP address
func isNumericIP(host string) bool {
// Simple check: if it contains only digits, dots, and colons, it's likely an IP
for _, char := range host {
if !(char >= '0' && char <= '9') && char != '.' && char != ':' {
return false
}
}
return len(host) > 0 && (strings.Contains(host, ".") || strings.Contains(host, ":"))
}
// UpdateAllowedPrivateCIDRs parses and updates the list of allowed private CIDR ranges for webhooks
func (n *NotificationManager) UpdateAllowedPrivateCIDRs(cidrsString string) error {
n.allowedPrivateMu.Lock()
defer n.allowedPrivateMu.Unlock()
// Clear existing allowlist
n.allowedPrivateNets = nil
// Empty string means no allowlist (block all private IPs)
if cidrsString == "" {
log.Info().Msg("Webhook private IP allowlist cleared - all private IPs blocked")
return nil
}
// Parse comma-separated CIDRs
cidrs := strings.Split(cidrsString, ",")
var parsedNets []*net.IPNet
for _, cidr := range cidrs {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
// Support bare IPs by adding /32 or /128
if !strings.Contains(cidr, "/") {
ip := net.ParseIP(cidr)
if ip == nil {
return fmt.Errorf("invalid IP address: %s", cidr)
}
if ip.To4() != nil {
cidr = cidr + "/32"
} else {
cidr = cidr + "/128"
}
}
_, ipNet, err := net.ParseCIDR(cidr)
if err != nil {
return fmt.Errorf("invalid CIDR range %s: %w", cidr, err)
}
parsedNets = append(parsedNets, ipNet)
}
n.allowedPrivateNets = parsedNets
log.Info().
Str("cidrs", cidrsString).
Int("count", len(parsedNets)).
Msg("Webhook private IP allowlist updated")
return nil
}
// isIPInAllowlist checks if an IP is in the configured allowlist
func (n *NotificationManager) isIPInAllowlist(ip net.IP) bool {
n.allowedPrivateMu.RLock()
defer n.allowedPrivateMu.RUnlock()
// No allowlist means block all private IPs
if len(n.allowedPrivateNets) == 0 {
return false
}
// Check if IP is in any allowed range
for _, ipNet := range n.allowedPrivateNets {
if ipNet.Contains(ip) {
return true
}
}
return false
}
// addWebhookDelivery adds a webhook delivery record to the history
func (n *NotificationManager) addWebhookDelivery(delivery WebhookDelivery) {
n.mu.Lock()
defer n.mu.Unlock()
// Add to history
n.webhookHistory = append(n.webhookHistory, delivery)
// Keep only last 100 entries
if len(n.webhookHistory) > 100 {
// Remove oldest entry
n.webhookHistory = n.webhookHistory[1:]
}
}
// GetWebhookHistory returns recent webhook delivery history
func (n *NotificationManager) GetWebhookHistory() []WebhookDelivery {
n.mu.RLock()
defer n.mu.RUnlock()
// Return a copy to avoid concurrent access issues
history := make([]WebhookDelivery, len(n.webhookHistory))
copy(history, n.webhookHistory)
return history
}
func buildNotificationTestAlert() *alerts.Alert {
return &alerts.Alert{
ID: "test-alert",
Type: "cpu",
Level: "warning",
ResourceID: "test-resource",
ResourceName: "Test Resource",
Node: "pve-node-01",
Instance: "https://192.168.1.100:8006",
Message: "This is a test alert from Pulse Monitoring to verify your notification settings are working correctly",
Value: 95.5,
Threshold: 90,
StartTime: time.Now().Add(-5 * time.Minute), // Show it's been active for 5 minutes
LastSeen: time.Now(),
Metadata: map[string]interface{}{
"resourceType": "vm",
},
}
}
// GetQueueStats returns statistics about the notification queue
func (n *NotificationManager) GetQueueStats() (map[string]int, error) {
n.mu.RLock()
queue := n.queue
n.mu.RUnlock()
if queue == nil {
return nil, fmt.Errorf("notification queue not initialized")
}
return queue.GetQueueStats()
}
// SendTestNotification sends a test notification
func (n *NotificationManager) SendTestNotification(method string) error {
testAlert := buildNotificationTestAlert()
switch method {
case "email":
log.Info().
Bool("enabled", n.emailConfig.Enabled).
Str("smtp", n.emailConfig.SMTPHost).
Int("port", n.emailConfig.SMTPPort).
Str("from", n.emailConfig.From).
Int("toCount", len(n.emailConfig.To)).
Msg("Testing email notification")
if !n.emailConfig.Enabled {
return fmt.Errorf("email notifications are not enabled")
}
n.sendEmail(testAlert)
return nil
case "webhook":
n.mu.RLock()
if len(n.webhooks) == 0 {
n.mu.RUnlock()
return fmt.Errorf("no webhooks configured")
}
// Find first enabled webhook and copy it before releasing lock
var webhookToTest *WebhookConfig
for _, webhook := range n.webhooks {
if webhook.Enabled {
// Copy webhook to avoid race condition
webhookCopy := webhook
webhookToTest = &webhookCopy
break
}
}
n.mu.RUnlock()
if webhookToTest == nil {
return fmt.Errorf("no enabled webhooks found")
}
n.sendWebhook(*webhookToTest, testAlert)
return nil
case "apprise":
n.mu.RLock()
appriseConfig := n.appriseConfig
n.mu.RUnlock()
log.Info().
Bool("enabled", appriseConfig.Enabled).
Str("mode", string(appriseConfig.Mode)).
Int("targetCount", len(appriseConfig.Targets)).
Msg("Testing Apprise notification")
if !appriseConfig.Enabled {
return fmt.Errorf("apprise notifications are not enabled")
}
// Use sendGroupedApprise with a single test alert
return n.sendGroupedApprise(appriseConfig, []*alerts.Alert{testAlert})
default:
return fmt.Errorf("unknown notification method: %s", method)
}
}
// SendTestAppriseWithConfig sends a test Apprise notification using provided config
func (n *NotificationManager) SendTestAppriseWithConfig(config AppriseConfig) error {
cfg := NormalizeAppriseConfig(config)
log.Info().
Bool("enabled", cfg.Enabled).
Str("mode", string(cfg.Mode)).
Int("targetCount", len(cfg.Targets)).
Str("serverURL", cfg.ServerURL).
Msg("Testing Apprise notification with provided config")
if !cfg.Enabled {
switch cfg.Mode {
case AppriseModeCLI:
return fmt.Errorf("apprise notifications are not enabled in the provided configuration: at least one target is required for CLI mode")
case AppriseModeHTTP:
return fmt.Errorf("apprise notifications are not enabled in the provided configuration: server URL is required for API mode")
default:
return fmt.Errorf("apprise notifications are not enabled in the provided configuration")
}
}
return n.sendGroupedApprise(cfg, []*alerts.Alert{buildNotificationTestAlert()})
}
// SendTestWebhook sends a test notification to a specific webhook
func (n *NotificationManager) SendTestWebhook(webhook WebhookConfig) error {
// Create a test alert for webhook testing with realistic values
// Use the configured publicURL if available, otherwise use a placeholder
instanceURL := n.publicURL
if instanceURL == "" {
instanceURL = "http://your-pulse-instance:7655"
}
testAlert := &alerts.Alert{
ID: "test-webhook-" + webhook.ID,
Type: "cpu",
Level: "warning",
ResourceID: "webhook-test",
ResourceName: "Test Alert",
Node: "test-node",
Instance: instanceURL, // Use the actual Pulse URL
Message: fmt.Sprintf("This is a test alert from Pulse to verify your %s webhook is working correctly", webhook.Name),
Value: 85.5,
Threshold: 80.0,
StartTime: time.Now().Add(-5 * time.Minute), // Alert started 5 minutes ago
LastSeen: time.Now(),
Metadata: map[string]interface{}{
"webhookName": webhook.Name,
"webhookURL": webhook.URL,
"testTime": time.Now().Format(time.RFC3339),
},
}
// Send the test webhook
n.sendWebhook(webhook, testAlert)
return nil
}
// SendTestNotificationWithConfig sends a test notification using provided config
func (n *NotificationManager) SendTestNotificationWithConfig(method string, config *EmailConfig, nodeInfo *TestNodeInfo) error {
// Use actual node info if provided, otherwise use defaults
nodeName := "test-node"
instanceURL := n.publicURL
if instanceURL == "" {
instanceURL = "https://proxmox.local:8006"
}
if nodeInfo != nil {
if nodeInfo.NodeName != "" {
nodeName = nodeInfo.NodeName
}
if nodeInfo.InstanceURL != "" {
instanceURL = nodeInfo.InstanceURL
}
}
testAlert := &alerts.Alert{
ID: "test-alert",
Type: "cpu",
Level: "warning",
ResourceID: "test-email-config",
ResourceName: "Email Configuration Test",
Node: nodeName,
Instance: instanceURL,
Message: "This is a test alert to verify your email notification settings are working correctly",
Value: 85.5,
Threshold: 80,
StartTime: time.Now(),
LastSeen: time.Now(),
Metadata: map[string]interface{}{
"resourceType": "test",
},
}
switch method {
case "email":
if config == nil {
return fmt.Errorf("email configuration is required")
}
log.Info().
Bool("enabled", config.Enabled).
Str("smtp", config.SMTPHost).
Int("port", config.SMTPPort).
Str("from", config.From).
Int("toCount", len(config.To)).
Strs("to", config.To).
Bool("smtpEmpty", config.SMTPHost == "").
Bool("fromEmpty", config.From == "").
Msg("Testing email notification with provided config")
if !config.Enabled {
return fmt.Errorf("email notifications are not enabled in the provided configuration")
}
if config.SMTPHost == "" || config.From == "" {
return fmt.Errorf("email configuration is incomplete: SMTP host and from address are required")
}
// Generate email using template
subject, htmlBody, textBody := EmailTemplate([]*alerts.Alert{testAlert}, true)
// Send using provided config and return any error
return n.sendHTMLEmailWithError(subject, htmlBody, textBody, *config)
default:
return fmt.Errorf("unsupported method for config-based testing: %s", method)
}
}
func normalizeQueueType(notifType string) (string, notificationEvent) {
if strings.HasSuffix(notifType, queueTypeSuffixResolved) {
return strings.TrimSuffix(notifType, queueTypeSuffixResolved), eventResolved
}
if strings.HasSuffix(notifType, queueTypeSuffixEscalation) {
return strings.TrimSuffix(notifType, queueTypeSuffixEscalation), eventEscalation
}
return notifType, eventAlert
}
func resolvedTimeFromAlerts(alerts []*alerts.Alert) time.Time {
for _, alert := range alerts {
if alert == nil || alert.Metadata == nil {
continue
}
raw, ok := alert.Metadata[metadataResolvedAt]
if !ok {
continue
}
switch ts := raw.(type) {
case string:
if parsed, err := time.Parse(time.RFC3339, ts); err == nil {
return parsed
}
case float64:
if ts > 0 {
return time.Unix(int64(ts), 0)
}
}
}
return time.Now()
}
// ProcessQueuedNotification processes a notification from the persistent queue
func (n *NotificationManager) ProcessQueuedNotification(notif *QueuedNotification) error {
baseType, event := normalizeQueueType(notif.Type)
log.Debug().
Str("notificationID", notif.ID).
Str("type", baseType).
Str("event", string(event)).
Int("alertCount", len(notif.Alerts)).
Msg("Processing queued notification")
var err error
switch baseType {
case "email":
emailConfig, resolveErr := n.resolveQueuedEmailConfig()
if resolveErr != nil {
return resolveErr
}
if event == eventResolved {
err = n.sendResolvedEmail(emailConfig, notif.Alerts, resolvedTimeFromAlerts(notif.Alerts))
} else {
err = n.sendGroupedEmail(emailConfig, notif.Alerts)
}
case "webhook":
var queuedWebhookConfig WebhookConfig
if err = json.Unmarshal(notif.Config, &queuedWebhookConfig); err != nil {
return fmt.Errorf("failed to unmarshal webhook config: %w", err)
}
webhookConfig, resolveErr := n.resolveQueuedWebhookConfig(queuedWebhookConfig)
if resolveErr != nil {
return resolveErr
}
if event == eventResolved {
err = n.sendResolvedWebhook(webhookConfig, notif.Alerts, resolvedTimeFromAlerts(notif.Alerts))
} else {
err = n.sendGroupedWebhook(webhookConfig, notif.Alerts)
}
case "apprise":
appriseConfig, resolveErr := n.resolveQueuedAppriseConfig()
if resolveErr != nil {
return resolveErr
}
if event == eventResolved {
err = n.sendResolvedApprise(appriseConfig, notif.Alerts, resolvedTimeFromAlerts(notif.Alerts))
} else {
err = n.sendGroupedApprise(appriseConfig, notif.Alerts)
}
default:
return fmt.Errorf("unknown notification type: %s", baseType)
}
// Mark cooldown after successful send for active alerts only
if err == nil && event == eventAlert {
n.mu.Lock()
now := time.Now()
for _, alert := range notif.Alerts {
n.lastNotified[alert.ID] = notificationRecord{
lastSent: now,
alertStart: alert.StartTime,
}
}
n.mu.Unlock()
}
return err
}
func (n *NotificationManager) resolveQueuedEmailConfig() (EmailConfig, error) {
n.mu.RLock()
enabled := n.enabled
config := copyEmailConfig(n.emailConfig)
n.mu.RUnlock()
if !enabled {
return EmailConfig{}, fmt.Errorf("%w: notifications are disabled", ErrNotificationCancelled)
}
if !config.Enabled {
return EmailConfig{}, fmt.Errorf("%w: email notifications are disabled", ErrNotificationCancelled)
}
return config, nil
}
func (n *NotificationManager) resolveQueuedAppriseConfig() (AppriseConfig, error) {
n.mu.RLock()
enabled := n.enabled
config := copyAppriseConfig(n.appriseConfig)
n.mu.RUnlock()
if !enabled {
return AppriseConfig{}, fmt.Errorf("%w: notifications are disabled", ErrNotificationCancelled)
}
if !config.Enabled {
return AppriseConfig{}, fmt.Errorf("%w: Apprise notifications are disabled", ErrNotificationCancelled)
}
return config, nil
}
func (n *NotificationManager) resolveQueuedWebhookConfig(queued WebhookConfig) (WebhookConfig, error) {
n.mu.RLock()
enabled := n.enabled
webhooks := copyWebhookConfigs(n.webhooks)
n.mu.RUnlock()
if !enabled {
return WebhookConfig{}, fmt.Errorf("%w: notifications are disabled", ErrNotificationCancelled)
}
if queued.ID != "" {
for _, webhook := range webhooks {
if webhook.ID != queued.ID {
continue
}
if !webhook.Enabled {
return WebhookConfig{}, fmt.Errorf("%w: webhook %s is disabled", ErrNotificationCancelled, queued.ID)
}
return webhook, nil
}
return WebhookConfig{}, fmt.Errorf("%w: webhook %s no longer exists", ErrNotificationCancelled, queued.ID)
}
for _, webhook := range webhooks {
if webhook.Name == queued.Name && webhook.URL == queued.URL {
if !webhook.Enabled {
return WebhookConfig{}, fmt.Errorf("%w: webhook %q is disabled", ErrNotificationCancelled, queued.Name)
}
return webhook, nil
}
}
return WebhookConfig{}, fmt.Errorf("%w: webhook %q no longer exists", ErrNotificationCancelled, queued.Name)
}
// cleanupOldNotificationRecords periodically cleans up old entries from lastNotified map
func (n *NotificationManager) cleanupOldNotificationRecords() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
n.mu.Lock()
now := time.Now()
cutoff := now.Add(-24 * time.Hour)
cleaned := 0
for alertID, record := range n.lastNotified {
// Remove entries older than 24 hours
if record.lastSent.Before(cutoff) {
delete(n.lastNotified, alertID)
cleaned++
}
}
if cleaned > 0 {
log.Debug().
Int("cleaned", cleaned).
Int("remaining", len(n.lastNotified)).
Msg("Cleaned up old notification cooldown records")
}
n.mu.Unlock()
case <-n.stopCleanup:
// Stop cleanup when manager is stopped
return
}
}
}
// Stop gracefully stops the notification manager
func (n *NotificationManager) Stop() {
n.mu.Lock()
// Stop cleanup goroutine
close(n.stopCleanup)
// Get queue reference before unlocking
queue := n.queue
// Unlock before stopping queue to avoid deadlock with queue workers
// that may need to acquire n.mu during ProcessQueuedNotification
n.mu.Unlock()
// Stop the notification queue if it exists
if queue != nil {
queue.Stop()
}
// Relock for remaining cleanup
n.mu.Lock()
defer n.mu.Unlock()
// Cancel any pending group timer
if n.groupTimer != nil {
n.groupTimer.Stop()
n.groupTimer = nil
}
// Clear pending alerts
n.pendingAlerts = nil
log.Info().Msg("NotificationManager stopped")
}