Add Unraid and Gotify notifications
This commit is contained in:
+20
-1
@@ -155,7 +155,7 @@ func DefaultConfig() Config {
|
||||
SchemaVersion: SchemaVersion,
|
||||
Jobs: []Job{},
|
||||
Repositories: []Repository{},
|
||||
Notifications: []NotificationTarget{},
|
||||
Notifications: []NotificationTarget{{SchemaVersion: SchemaVersion, ID: "unraid", Name: "Unraid", Type: "unraid", Enabled: true, Events: []string{"success", "warning", "failed"}}},
|
||||
Settings: Settings{
|
||||
ResticPath: "/usr/local/libexec/backupper/restic",
|
||||
RestoreRoot: "/mnt/user/backupper-restores",
|
||||
@@ -165,3 +165,22 @@ func DefaultConfig() Config {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeConfig(c Config) Config {
|
||||
notifications := make([]NotificationTarget, 0, len(c.Notifications)+1)
|
||||
hasUnraid := false
|
||||
for _, target := range c.Notifications {
|
||||
if target.Type == "ntfy" {
|
||||
continue
|
||||
}
|
||||
if target.Type == "unraid" {
|
||||
hasUnraid = true
|
||||
}
|
||||
notifications = append(notifications, target)
|
||||
}
|
||||
if !hasUnraid {
|
||||
notifications = append(notifications, NotificationTarget{SchemaVersion: SchemaVersion, ID: "unraid", Name: "Unraid", Type: "unraid", Enabled: true, Events: []string{"success", "warning", "failed"}})
|
||||
}
|
||||
c.Notifications = notifications
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package model
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -42,12 +43,54 @@ func ValidateConfig(c Config) error {
|
||||
}
|
||||
repositoryOwners[job.RepositoryID] = job.ID
|
||||
}
|
||||
notifications := make(map[string]struct{}, len(c.Notifications))
|
||||
for _, target := range c.Notifications {
|
||||
if err := ValidateNotification(target); err != nil {
|
||||
return fmt.Errorf("notification %q: %w", target.ID, err)
|
||||
}
|
||||
if _, exists := notifications[target.ID]; exists {
|
||||
return fmt.Errorf("duplicate notification id %q", target.ID)
|
||||
}
|
||||
notifications[target.ID] = struct{}{}
|
||||
}
|
||||
if !filepath.IsAbs(c.Settings.RestoreRoot) {
|
||||
return errors.New("restoreRoot must be absolute")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateNotification(target NotificationTarget) error {
|
||||
if target.SchemaVersion != SchemaVersion || !idPattern.MatchString(target.ID) {
|
||||
return errors.New("invalid schemaVersion or id")
|
||||
}
|
||||
if strings.TrimSpace(target.Name) == "" {
|
||||
return errors.New("name is required")
|
||||
}
|
||||
switch target.Type {
|
||||
case "unraid":
|
||||
case "gotify":
|
||||
parsed, err := url.Parse(target.URL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
||||
return errors.New("Gotify URL must be an absolute HTTP or HTTPS URL")
|
||||
}
|
||||
if !idPattern.MatchString(target.TokenRef) {
|
||||
return errors.New("Gotify tokenRef must be a valid secret ID")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported notification type %q", target.Type)
|
||||
}
|
||||
allowed := map[string]bool{"success": true, "warning": true, "failed": true}
|
||||
if target.Enabled && len(target.Events) == 0 {
|
||||
return errors.New("enabled notification requires at least one event")
|
||||
}
|
||||
for _, event := range target.Events {
|
||||
if !allowed[event] {
|
||||
return fmt.Errorf("unsupported event %q", event)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateJob(j Job) error {
|
||||
if j.SchemaVersion != SchemaVersion || !idPattern.MatchString(j.ID) {
|
||||
return errors.New("invalid schemaVersion or id")
|
||||
|
||||
@@ -15,6 +15,26 @@ func TestValidateConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeConfigReplacesNtfyAndAddsUnraid(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
c.Notifications = []NotificationTarget{{SchemaVersion: 1, ID: "old-ntfy", Name: "ntfy", Type: "ntfy", Enabled: true}}
|
||||
c = NormalizeConfig(c)
|
||||
if len(c.Notifications) != 1 || c.Notifications[0].Type != "unraid" || !c.Notifications[0].Enabled {
|
||||
t.Fatalf("notifications = %#v", c.Notifications)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGotifyNotification(t *testing.T) {
|
||||
target := NotificationTarget{SchemaVersion: 1, ID: "gotify", Name: "Gotify", Type: "gotify", Enabled: true, URL: "https://gotify.example.test", TokenRef: "gotify-token", Events: []string{"failed"}}
|
||||
if err := ValidateNotification(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target.URL = "javascript:alert(1)"
|
||||
if err := ValidateNotification(target); err == nil {
|
||||
t.Fatal("unsafe Gotify URL accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepositoryIsolation(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
c.Jobs = []Job{{SchemaVersion: 1, ID: "job", Name: "Broken", Type: JobFlash, RepositoryID: "missing", Consistency: Consistency{Mode: "live"}, Compression: "auto"}}
|
||||
|
||||
+41
-16
@@ -3,6 +3,7 @@ package notify
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -16,8 +17,10 @@ import (
|
||||
type SecretGetter interface{ Get(string) (string, error) }
|
||||
|
||||
type Sender struct {
|
||||
Secrets SecretGetter
|
||||
Client *http.Client
|
||||
Secrets SecretGetter
|
||||
Client *http.Client
|
||||
NotifyPath string
|
||||
RunCommand func(context.Context, string, ...string) error
|
||||
}
|
||||
|
||||
func (s *Sender) Send(ctx context.Context, target model.NotificationTarget, title, message, severity string) error {
|
||||
@@ -27,25 +30,47 @@ func (s *Sender) Send(ctx context.Context, target model.NotificationTarget, titl
|
||||
switch target.Type {
|
||||
case "unraid":
|
||||
importance := "normal"
|
||||
if severity == "error" {
|
||||
if severity == "failed" || severity == "error" {
|
||||
importance = "alert"
|
||||
} else if severity == "warning" {
|
||||
importance = "warning"
|
||||
}
|
||||
return exec.CommandContext(ctx, "/usr/local/emhttp/webGui/scripts/notify", "-e", "Backupper", "-s", title, "-d", message, "-i", importance).Run()
|
||||
case "ntfy":
|
||||
endpoint := strings.TrimRight(target.URL, "/") + "/" + url.PathEscape(target.Topic)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(message))
|
||||
path := s.NotifyPath
|
||||
if path == "" {
|
||||
path = "/usr/local/emhttp/webGui/scripts/notify"
|
||||
}
|
||||
args := []string{"-e", "Backupper", "-s", title, "-d", message, "-i", importance, "-l", "/Tools/Backupper"}
|
||||
if s.RunCommand != nil {
|
||||
return s.RunCommand(ctx, path, args...)
|
||||
}
|
||||
return exec.CommandContext(ctx, path, args...).Run()
|
||||
case "gotify":
|
||||
token, err := s.Secrets.Get(target.TokenRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Title", title)
|
||||
req.Header.Set("Tags", "floppy_disk")
|
||||
if target.TokenRef != "" {
|
||||
token, err := s.Secrets.Get(target.TokenRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
endpoint, err := url.Parse(strings.TrimRight(target.URL, "/") + "/message")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := endpoint.Query()
|
||||
query.Set("token", token)
|
||||
endpoint.RawQuery = query.Encode()
|
||||
priority := 2
|
||||
if severity == "warning" {
|
||||
priority = 5
|
||||
} else if severity == "failed" || severity == "error" {
|
||||
priority = 8
|
||||
}
|
||||
payload, err := json.Marshal(map[string]any{"title": title, "message": message, "priority": priority})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := s.Client
|
||||
if client == nil {
|
||||
client = &http.Client{Timeout: 15 * time.Second}
|
||||
@@ -56,7 +81,7 @@ func (s *Sender) Send(ctx context.Context, target model.NotificationTarget, titl
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("ntfy returned %s", response.Status)
|
||||
return fmt.Errorf("Gotify returned %s", response.Status)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/backupper-unraid/backupper/internal/model"
|
||||
)
|
||||
|
||||
type testSecrets map[string]string
|
||||
|
||||
func (s testSecrets) Get(id string) (string, error) { return s[id], nil }
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { return f(request) }
|
||||
|
||||
func TestSendGotify(t *testing.T) {
|
||||
var body map[string]any
|
||||
client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/message" || r.URL.Query().Get("token") != "secret-token" {
|
||||
t.Errorf("unexpected request URL %s", r.URL.String())
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Status: "200 OK", Body: io.NopCloser(strings.NewReader("{}")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
|
||||
sender := &Sender{Secrets: testSecrets{"gotify-token": "secret-token"}, Client: client}
|
||||
target := model.NotificationTarget{Enabled: true, Type: "gotify", URL: "https://gotify.example.test", TokenRef: "gotify-token"}
|
||||
if err := sender.Send(context.Background(), target, "Backup failed", "repository unavailable", "failed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["title"] != "Backup failed" || body["message"] != "repository unavailable" || body["priority"] != float64(8) {
|
||||
t.Fatalf("unexpected Gotify payload %#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendUnraid(t *testing.T) {
|
||||
var command string
|
||||
var args []string
|
||||
sender := &Sender{RunCommand: func(_ context.Context, name string, values ...string) error {
|
||||
command, args = name, values
|
||||
return nil
|
||||
}}
|
||||
target := model.NotificationTarget{Enabled: true, Type: "unraid"}
|
||||
if err := sender.Send(context.Background(), target, "Backup warning", "completed with warnings", "warning"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if command != "/usr/local/emhttp/webGui/scripts/notify" {
|
||||
t.Fatalf("unexpected command %q", command)
|
||||
}
|
||||
want := []string{"-e", "Backupper", "-s", "Backup warning", "-d", "completed with warnings", "-i", "warning", "-l", "/Tools/Backupper"}
|
||||
if !reflect.DeepEqual(args, want) {
|
||||
t.Fatalf("arguments = %#v, want %#v", args, want)
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ func (s *Service) LastRun(jobID string) time.Time {
|
||||
}
|
||||
|
||||
func (s *Service) SaveConfig(config model.Config) error {
|
||||
config = model.NormalizeConfig(config)
|
||||
for _, job := range config.Jobs {
|
||||
if job.Schedule.Cron != "" {
|
||||
if _, err := scheduler.Parse(job.Schedule.Cron); err != nil {
|
||||
@@ -316,12 +317,8 @@ func (s *Service) executeRestore(ctx context.Context, run model.Run) model.Run {
|
||||
}
|
||||
|
||||
func (s *Service) finishJob(run model.Run, job model.Job, result model.Run) model.Run {
|
||||
event := result.Status
|
||||
for _, wanted := range job.NotifyOn {
|
||||
if wanted == event {
|
||||
go s.notify(job.Name, result)
|
||||
break
|
||||
}
|
||||
if result.Status == "success" || result.Status == "warning" || result.Status == "failed" {
|
||||
go s.notify(job.Name, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -330,7 +327,9 @@ func (s *Service) notify(name string, run model.Run) {
|
||||
for _, target := range s.Config().Notifications {
|
||||
for _, event := range target.Events {
|
||||
if event == run.Status {
|
||||
_ = s.notifier.Send(context.Background(), target, "Backupper: "+name, run.Message, run.Status)
|
||||
if err := s.notifier.Send(context.Background(), target, "Backupper: "+name, run.Message, run.Status); err != nil {
|
||||
s.log.Warn("send notification failed", "target", target.ID, "type", target.Type, "error", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,12 @@ func (s *Store) LoadConfig() (model.Config, error) {
|
||||
if err := readJSON(s.configPath(), &c); err != nil {
|
||||
return c, err
|
||||
}
|
||||
c = model.NormalizeConfig(c)
|
||||
return c, model.ValidateConfig(c)
|
||||
}
|
||||
|
||||
func (s *Store) SaveConfig(c model.Config) error {
|
||||
c = model.NormalizeConfig(c)
|
||||
if err := model.ValidateConfig(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user