// Package notify verschickt Benachrichtigungen des Servers (Fristen, // Mahnstufen, Offline-Alarme, Policy-Verstöße) an Webhooks: Slack-kompatibel // (auch Mattermost, Rocket.Chat, Discord-Slack-Endpunkt) und ntfy. package notify import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" ) // Severity ist die Dringlichkeit einer Nachricht. type Severity int const ( Info Severity = iota Warning Critical ) // Message ist eine Benachrichtigung; Body darf Slack-Markdown enthalten. type Message struct { Title string Body string Severity Severity } // Target ist ein Benachrichtigungsziel. type Target interface { Send(ctx context.Context, m Message) error } // ErrNoTargets: Multi ohne Ziele. var ErrNoTargets = errors.New("notify: keine Ziele konfiguriert") var client = &http.Client{Timeout: 10 * time.Second} func post(ctx context.Context, url, contentType string, body []byte, headers map[string]string) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", contentType) for k, v := range headers { req.Header.Set(k, v) } resp, err := client.Do(req) if err != nil { return fmt.Errorf("notify: %w", err) } defer resp.Body.Close() if resp.StatusCode/100 != 2 { msg, _ := io.ReadAll(io.LimitReader(resp.Body, 256)) return fmt.Errorf("notify: %s antwortet %d: %s", url, resp.StatusCode, strings.TrimSpace(string(msg))) } return nil } var emoji = map[Severity]string{Info: ":information_source:", Warning: ":warning:", Critical: ":rotating_light:"} // Slack sendet an einen Slack-kompatiblen Incoming Webhook ({"text": …}). type Slack struct{ URL string } func (s Slack) Send(ctx context.Context, m Message) error { body, _ := json.Marshal(map[string]string{"text": fmt.Sprintf("%s *%s*\n%s", emoji[m.Severity], m.Title, m.Body)}) return post(ctx, s.URL, "application/json", body, nil) } var ntfyPriority = map[Severity][2]string{ Info: {"default", "information_source"}, Warning: {"high", "warning"}, Critical: {"urgent", "rotating_light"}, } // Ntfy sendet an ein ntfy-Topic (URL inkl. Topic), optional mit Access-Token. type Ntfy struct { URL string Token string } func (n Ntfy) Send(ctx context.Context, m Message) error { p := ntfyPriority[m.Severity] headers := map[string]string{"Title": m.Title, "Priority": p[0], "Tags": p[1], "Markdown": "yes"} if n.Token != "" { headers["Authorization"] = "Bearer " + n.Token } return post(ctx, n.URL, "text/markdown; charset=utf-8", []byte(m.Body), headers) } // Multi sendet an alle Ziele; Fehler einzelner Ziele werden gesammelt // gemeldet, halten die übrigen aber nicht auf. type Multi []Target func (m Multi) Send(ctx context.Context, msg Message) error { if len(m) == 0 { return ErrNoTargets } var errs []error for _, t := range m { if err := t.Send(ctx, msg); err != nil { errs = append(errs, err) } } return errors.Join(errs...) }