From 76f2129a5b7806299194ae7e480c1807394e73bb Mon Sep 17 00:00:00 2001 From: Jamie Banks Date: Thu, 4 Jun 2026 17:43:08 +0000 Subject: [PATCH] feat: implement backup notification system via email and webhooks --- README.md | 42 ++++++++++ backup/manager.go | 18 +++++ config/config.go | 54 ++++++++++++- notify/notify.go | 191 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 notify/notify.go diff --git a/README.md b/README.md index bbb21d6..a1ab995 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ GoDump is a lightweight, standalone MariaDB backup application written in Go. It - **Isolated Backups**: Each database is backed up via `mysqldump` in a dedicated subprocess, compressed instantly with `gzip`, and stored independently. Failure of one database does not disrupt others. - **Retention Policies**: Configurable retention period (in days) per instance. Old backups are automatically groomed after every run. - **Embedded Web UI**: Single-page modern interface served directly from the Go binary. No external CDN dependencies, fully functional offline. View statuses, trigger manual backups, browse backup files, and read real-time logs. +- **Optional Authentication**: Secure your dashboard and API with a simple, cookie-based session login. +- **Notifications**: Receive instant alerts when backup jobs complete via HTML Emails (SMTP) or JSON Webhooks (perfect for Ntfy, Gotify, Discord, Slack, Zapier, etc.). - **Cron Scheduling**: Uses standard cron expressions to schedule automated jobs. ## Requirements @@ -38,6 +40,41 @@ GoDump uses a YAML configuration file. By default, it looks for `/etc/godump/con server: port: 8080 +auth: + enabled: true + username: admin + password: password + +notifications: + events: + on_success: false + on_failure: true + email: + enabled: true + # You can override events at the channel level + # events: + # on_success: false + # on_failure: true + host: smtp.example.com + port: 587 + username: myuser + password: mypassword + from: godump@example.com + to: you@example.com + webhooks: + - enabled: true + url: https://hook.example.com/success + events: + on_success: true + on_failure: false + - enabled: true + url: https://hook.example.com/failure + events: + on_success: false + on_failure: true + headers: + Authorization: "Bearer your_token_here" + logging: file: "" @@ -62,6 +99,11 @@ instances: ``` - `server.port`: The HTTP port for the web UI. +- `auth`: Optional authentication for the Web UI. `enabled` to turn it on, along with `username` and `password`. +- `notifications`: Optional post-run notifications. + - `events`: Control what triggers notifications globally (`on_success`, `on_failure`). + - `email`: SMTP details for sending HTML-formatted email alerts. Can have its own `events` block. + - `webhooks`: An array of webhook endpoints. Each can have its own `events` block to fire only on specific outcomes. - `logging.file`: The path where log files should be written. - `instances`: An array of MariaDB instances. Each requires its own name, connection details, backup directory, retention configuration (in days), and cron `schedule`. diff --git a/backup/manager.go b/backup/manager.go index 7b4c78b..5406ce6 100644 --- a/backup/manager.go +++ b/backup/manager.go @@ -8,6 +8,7 @@ import ( "godump/config" "godump/logger" + "godump/notify" "github.com/robfig/cron/v3" ) @@ -77,6 +78,7 @@ func (s *InstanceStatus) Snapshot() InstanceSnapshot { } type Manager struct { + cfg *config.Config instances map[string]*InstanceStatus cron *cron.Cron mu sync.RWMutex @@ -87,6 +89,7 @@ func NewManager(cfg *config.Config) *Manager { c.Start() m := &Manager{ + cfg: cfg, instances: make(map[string]*InstanceStatus), cron: c, } @@ -305,7 +308,22 @@ func (m *Manager) RunInstance(name string) { } else { inst.OverallResult = "partial" } + + payload := notify.Payload{ + InstanceName: name, + OverallResult: inst.OverallResult, + Time: time.Now(), + } + for _, db := range dbs { + dbStat := inst.Databases[db] + payload.Databases = append(payload.Databases, notify.DBResult{ + Name: dbStat.Name, + Size: dbStat.LastBackupSize, + Result: dbStat.LastBackupResult, + }) + } inst.mu.Unlock() logger.Info(name, "Backup job completed. Result: %s", inst.OverallResult) + notify.Send(m.cfg.Notifications, payload) } diff --git a/config/config.go b/config/config.go index 22336cb..a9ad581 100644 --- a/config/config.go +++ b/config/config.go @@ -12,11 +12,42 @@ type AuthConfig struct { Password string `yaml:"password"` } +type EmailConfig struct { + Enabled bool `yaml:"enabled"` + Events *NotificationEventsConfig `yaml:"events,omitempty"` + Host string `yaml:"host"` + Port int `yaml:"port"` + Username string `yaml:"username"` + Password string `yaml:"password"` + From string `yaml:"from"` + To string `yaml:"to"` +} + +type WebhookConfig struct { + Enabled bool `yaml:"enabled"` + Events *NotificationEventsConfig `yaml:"events,omitempty"` + URL string `yaml:"url"` + Headers map[string]string `yaml:"headers"` +} + +type NotificationEventsConfig struct { + OnSuccess bool `yaml:"on_success"` + OnFailure bool `yaml:"on_failure"` +} + +type NotificationsConfig struct { + Events NotificationEventsConfig `yaml:"events"` + Email EmailConfig `yaml:"email"` + Webhook WebhookConfig `yaml:"webhook"` + Webhooks []WebhookConfig `yaml:"webhooks"` +} + type Config struct { - Server ServerConfig `yaml:"server"` - Auth AuthConfig `yaml:"auth"` - Logging LoggingConfig `yaml:"logging"` - Instances []InstanceConfig `yaml:"instances"` + Server ServerConfig `yaml:"server"` + Auth AuthConfig `yaml:"auth"` + Notifications NotificationsConfig `yaml:"notifications"` + Logging LoggingConfig `yaml:"logging"` + Instances []InstanceConfig `yaml:"instances"` } type ServerConfig struct { @@ -62,6 +93,21 @@ func GenerateDefaultConfig(path string) error { Username: "admin", Password: "password", }, + Notifications: NotificationsConfig{ + Events: NotificationEventsConfig{ + OnSuccess: false, + OnFailure: true, + }, + Email: EmailConfig{ + Enabled: false, + Host: "smtp.example.com", + Port: 587, + }, + Webhook: WebhookConfig{ + Enabled: false, + URL: "https://hook.example.com", + }, + }, Logging: LoggingConfig{ File: "", }, diff --git a/notify/notify.go b/notify/notify.go new file mode 100644 index 0000000..54e6edd --- /dev/null +++ b/notify/notify.go @@ -0,0 +1,191 @@ +package notify + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/smtp" + "text/template" + "time" + + "godump/config" + "godump/logger" +) + +type DBResult struct { + Name string `json:"name"` + Size int64 `json:"size"` + Result string `json:"result"` +} + +type Payload struct { + InstanceName string `json:"instance_name"` + OverallResult string `json:"overall_result"` + Time time.Time `json:"time"` + Databases []DBResult `json:"databases"` +} + +func shouldSend(global config.NotificationEventsConfig, local *config.NotificationEventsConfig, result string) bool { + events := global + if local != nil { + events = *local + } + if result == "success" && !events.OnSuccess { + return false + } + if (result == "failed" || result == "partial") && !events.OnFailure { + return false + } + return true +} + +func Send(cfg config.NotificationsConfig, payload Payload) { + if cfg.Webhook.Enabled && shouldSend(cfg.Events, cfg.Webhook.Events, payload.OverallResult) { + go sendWebhook(cfg.Webhook, payload) + } + for _, w := range cfg.Webhooks { + if w.Enabled && shouldSend(cfg.Events, w.Events, payload.OverallResult) { + go sendWebhook(w, payload) + } + } + if cfg.Email.Enabled && shouldSend(cfg.Events, cfg.Email.Events, payload.OverallResult) { + go sendEmail(cfg.Email, payload) + } +} + +func sendWebhook(cfg config.WebhookConfig, payload Payload) { + message := fmt.Sprintf("**GoDump Backup Result**\nInstance: %s\nResult: %s\nTime: %s\n\nDatabases:\n", + payload.InstanceName, payload.OverallResult, payload.Time.Format("2006-01-02 15:04:05")) + for _, db := range payload.Databases { + message += fmt.Sprintf("- %s: %s (%s)\n", db.Name, db.Result, formatBytes(db.Size)) + } + + webhookPayload := map[string]interface{}{ + "content": message, + "text": message, + "title": fmt.Sprintf("GoDump Backup Result: %s", payload.InstanceName), + "message": message, + } + + data, err := json.Marshal(webhookPayload) + if err != nil { + logger.Error(payload.InstanceName, "Failed to marshal webhook payload: %v", err) + return + } + + req, err := http.NewRequest("POST", cfg.URL, bytes.NewBuffer(data)) + if err != nil { + logger.Error(payload.InstanceName, "Failed to create webhook request: %v", err) + return + } + + req.Header.Set("Content-Type", "application/json") + for k, v := range cfg.Headers { + req.Header.Set(k, v) + } + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + logger.Error(payload.InstanceName, "Failed to send webhook: %v", err) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + logger.Error(payload.InstanceName, "Webhook returned error status: %d", resp.StatusCode) + } else { + logger.Info(payload.InstanceName, "Webhook notification sent successfully") + } +} + +func formatBytes(b int64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%d B", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) +} + +const emailTmplStr = ` + + + + + + +
+

GoDump - Instance: {{.InstanceName}}

+

Overall Result: {{.OverallResult}}

+

Time: {{.Time.Format "2006-01-02 15:04:05"}}

+ + + + + + + + + + + {{range .Databases}} + + + + + + {{end}} + +
DatabaseSizeResult
{{.Name}}{{formatBytes .Size}}{{.Result}}
+
+ + +` + +func sendEmail(cfg config.EmailConfig, payload Payload) { + tmpl, err := template.New("email").Funcs(template.FuncMap{"formatBytes": formatBytes}).Parse(emailTmplStr) + if err != nil { + logger.Error(payload.InstanceName, "Failed to parse email template: %v", err) + return + } + + var body bytes.Buffer + if err := tmpl.Execute(&body, payload); err != nil { + logger.Error(payload.InstanceName, "Failed to execute email template: %v", err) + return + } + + subject := fmt.Sprintf("Subject: GoDump Backup Result: %s [%s]\r\n", payload.InstanceName, payload.OverallResult) + mime := "MIME-version: 1.0;\nContent-Type: text/html; charset=\"UTF-8\";\n\n" + + msg := []byte(fmt.Sprintf("To: %s\r\nFrom: GoDump <%s>\r\n%s%s\r\n%s", cfg.To, cfg.From, subject, mime, body.String())) + + var auth smtp.Auth + if cfg.Username != "" { + auth = smtp.PlainAuth("", cfg.Username, cfg.Password, cfg.Host) + } + + addr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) + + err = smtp.SendMail(addr, auth, cfg.From, []string{cfg.To}, msg) + if err != nil { + logger.Error(payload.InstanceName, "Failed to send email: %v", err) + } else { + logger.Info(payload.InstanceName, "Email notification sent successfully") + } +}