112 lines
3.0 KiB
Go
112 lines
3.0 KiB
Go
// 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...)
|
|
}
|