Files
dbbackup/internal/notify/manager.go
Alexander Renz 9d8a6e763e security: P0 fixes - SQL injection prevention + data race fix
- Add identifier validation for database names in PostgreSQL and MySQL
  - validateIdentifier() rejects names with invalid characters
  - quoteIdentifier() safely quotes identifiers with proper escaping
  - Max length: 63 chars (PostgreSQL), 64 chars (MySQL)
  - Only allows alphanumeric + underscores, must start with letter/underscore

- Fix data race in notification manager
  - Multiple goroutines were appending to shared error slice
  - Added errMu sync.Mutex to protect concurrent error collection

- Security improvements prevent:
  - SQL injection via malicious database names
  - CREATE DATABASE `foo`; DROP DATABASE production; --`
  - Race conditions causing lost or corrupted error data
2026-01-07 09:45:13 +01:00

260 lines
6.9 KiB
Go

// Package notify - Notification manager for fan-out to multiple backends
package notify
import (
"context"
"fmt"
"os"
"sync"
)
// Manager manages multiple notification backends
type Manager struct {
config Config
notifiers []Notifier
mu sync.RWMutex
hostname string
}
// NewManager creates a new notification manager with configured backends
func NewManager(config Config) *Manager {
hostname, _ := os.Hostname()
m := &Manager{
config: config,
notifiers: make([]Notifier, 0),
hostname: hostname,
}
// Initialize enabled backends
if config.SMTPEnabled {
m.notifiers = append(m.notifiers, NewSMTPNotifier(config))
}
if config.WebhookEnabled {
m.notifiers = append(m.notifiers, NewWebhookNotifier(config))
}
return m
}
// AddNotifier adds a custom notifier to the manager
func (m *Manager) AddNotifier(n Notifier) {
m.mu.Lock()
defer m.mu.Unlock()
m.notifiers = append(m.notifiers, n)
}
// Notify sends an event to all enabled notification backends
// This is a non-blocking operation that runs in a goroutine
func (m *Manager) Notify(event *Event) {
go m.NotifySync(context.Background(), event)
}
// NotifySync sends an event synchronously to all enabled backends
func (m *Manager) NotifySync(ctx context.Context, event *Event) error {
// Add hostname if not set
if event.Hostname == "" && m.hostname != "" {
event.Hostname = m.hostname
}
// Check if we should send based on event type/severity
if !m.shouldSend(event) {
return nil
}
m.mu.RLock()
notifiers := make([]Notifier, len(m.notifiers))
copy(notifiers, m.notifiers)
m.mu.RUnlock()
var errors []error
var errMu sync.Mutex
var wg sync.WaitGroup
for _, n := range notifiers {
if !n.IsEnabled() {
continue
}
wg.Add(1)
go func(notifier Notifier) {
defer wg.Done()
if err := notifier.Send(ctx, event); err != nil {
errMu.Lock()
errors = append(errors, fmt.Errorf("%s: %w", notifier.Name(), err))
errMu.Unlock()
}
}(n)
}
wg.Wait()
if len(errors) > 0 {
return fmt.Errorf("notification errors: %v", errors)
}
return nil
}
// shouldSend determines if an event should be sent based on configuration
func (m *Manager) shouldSend(event *Event) bool {
// Check minimum severity
if !m.meetsSeverity(event.Severity) {
return false
}
// Check event type filters
switch event.Type {
case EventBackupCompleted, EventRestoreCompleted, EventCleanupCompleted, EventVerifyCompleted:
return m.config.OnSuccess
case EventBackupFailed, EventRestoreFailed, EventVerifyFailed:
return m.config.OnFailure
case EventBackupStarted, EventRestoreStarted:
return m.config.OnSuccess
default:
return true
}
}
// meetsSeverity checks if event severity meets minimum threshold
func (m *Manager) meetsSeverity(severity Severity) bool {
severityOrder := map[Severity]int{
SeverityInfo: 0,
SeverityWarning: 1,
SeverityError: 2,
SeverityCritical: 3,
}
eventLevel, ok := severityOrder[severity]
if !ok {
return true
}
minLevel, ok := severityOrder[m.config.MinSeverity]
if !ok {
return true
}
return eventLevel >= minLevel
}
// HasEnabledNotifiers returns true if at least one notifier is enabled
func (m *Manager) HasEnabledNotifiers() bool {
m.mu.RLock()
defer m.mu.RUnlock()
for _, n := range m.notifiers {
if n.IsEnabled() {
return true
}
}
return false
}
// EnabledNotifiers returns the names of all enabled notifiers
func (m *Manager) EnabledNotifiers() []string {
m.mu.RLock()
defer m.mu.RUnlock()
names := make([]string, 0)
for _, n := range m.notifiers {
if n.IsEnabled() {
names = append(names, n.Name())
}
}
return names
}
// BackupStarted sends a backup started notification
func (m *Manager) BackupStarted(database string) {
event := NewEvent(EventBackupStarted, SeverityInfo, fmt.Sprintf("Starting backup of database '%s'", database)).
WithDatabase(database)
m.Notify(event)
}
// BackupCompleted sends a backup completed notification
func (m *Manager) BackupCompleted(database, backupFile string, size int64, duration interface{}) {
event := NewEvent(EventBackupCompleted, SeverityInfo, fmt.Sprintf("Backup of database '%s' completed successfully", database)).
WithDatabase(database).
WithBackupInfo(backupFile, size)
if d, ok := duration.(interface{ Seconds() float64 }); ok {
event.WithDetail("duration_seconds", fmt.Sprintf("%.2f", d.Seconds()))
}
m.Notify(event)
}
// BackupFailed sends a backup failed notification
func (m *Manager) BackupFailed(database string, err error) {
event := NewEvent(EventBackupFailed, SeverityError, fmt.Sprintf("Backup of database '%s' failed", database)).
WithDatabase(database).
WithError(err)
m.Notify(event)
}
// RestoreStarted sends a restore started notification
func (m *Manager) RestoreStarted(database, backupFile string) {
event := NewEvent(EventRestoreStarted, SeverityInfo, fmt.Sprintf("Starting restore of database '%s' from '%s'", database, backupFile)).
WithDatabase(database).
WithBackupInfo(backupFile, 0)
m.Notify(event)
}
// RestoreCompleted sends a restore completed notification
func (m *Manager) RestoreCompleted(database, backupFile string, duration interface{}) {
event := NewEvent(EventRestoreCompleted, SeverityInfo, fmt.Sprintf("Restore of database '%s' completed successfully", database)).
WithDatabase(database).
WithBackupInfo(backupFile, 0)
if d, ok := duration.(interface{ Seconds() float64 }); ok {
event.WithDetail("duration_seconds", fmt.Sprintf("%.2f", d.Seconds()))
}
m.Notify(event)
}
// RestoreFailed sends a restore failed notification
func (m *Manager) RestoreFailed(database string, err error) {
event := NewEvent(EventRestoreFailed, SeverityError, fmt.Sprintf("Restore of database '%s' failed", database)).
WithDatabase(database).
WithError(err)
m.Notify(event)
}
// CleanupCompleted sends a cleanup completed notification
func (m *Manager) CleanupCompleted(directory string, deleted int, spaceFreed int64) {
event := NewEvent(EventCleanupCompleted, SeverityInfo, fmt.Sprintf("Cleanup completed: %d backups deleted", deleted)).
WithDetail("directory", directory).
WithDetail("space_freed", formatBytes(spaceFreed))
m.Notify(event)
}
// VerifyCompleted sends a verification completed notification
func (m *Manager) VerifyCompleted(backupFile string, isValid bool) {
if isValid {
event := NewEvent(EventVerifyCompleted, SeverityInfo, "Backup verification passed").
WithBackupInfo(backupFile, 0)
m.Notify(event)
} else {
event := NewEvent(EventVerifyFailed, SeverityError, "Backup verification failed").
WithBackupInfo(backupFile, 0)
m.Notify(event)
}
}
// PITRRecovery sends a PITR recovery notification
func (m *Manager) PITRRecovery(database, targetTime string) {
event := NewEvent(EventPITRRecovery, SeverityInfo, fmt.Sprintf("Point-in-time recovery initiated for '%s' to %s", database, targetTime)).
WithDatabase(database).
WithDetail("target_time", targetTime)
m.Notify(event)
}
// NullManager returns a no-op notification manager
func NullManager() *Manager {
return &Manager{
notifiers: make([]Notifier, 0),
}
}