feat: implement backup notification system via email and webhooks
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -12,9 +12,40 @@ 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"`
|
||||
Notifications NotificationsConfig `yaml:"notifications"`
|
||||
Logging LoggingConfig `yaml:"logging"`
|
||||
Instances []InstanceConfig `yaml:"instances"`
|
||||
}
|
||||
@@ -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: "",
|
||||
},
|
||||
|
||||
@@ -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 = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; background-color: #0a0a0a; color: #f0f0f0; margin: 0; padding: 20px; }
|
||||
.card { background-color: #141414; border: 1px solid #2a2a2a; border-radius: 8px; padding: 20px; max-width: 600px; margin: 0 auto; }
|
||||
h2 { margin-top: 0; }
|
||||
.accent { color: #21D198; }
|
||||
.error { color: #ef4444; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
|
||||
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #2a2a2a; }
|
||||
th { color: #888888; text-transform: uppercase; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h2>GoDump - Instance: <span class="accent">{{.InstanceName}}</span></h2>
|
||||
<p>Overall Result: <strong class="{{if eq .OverallResult "success"}}accent{{else}}error{{end}}">{{.OverallResult}}</strong></p>
|
||||
<p>Time: {{.Time.Format "2006-01-02 15:04:05"}}</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Database</th>
|
||||
<th>Size</th>
|
||||
<th>Result</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Databases}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{formatBytes .Size}}</td>
|
||||
<td class="{{if eq .Result "success"}}accent{{else}}error{{end}}">{{.Result}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user