software steht so weit.
jetzt build + docker build und testen
This commit is contained in:
1 parent
42f0c0accb
commit
ca6cdcca63
90 files changed
+10298
-480
No files matched your search
@@ -0,0 +1,111 @@
|
||||
// 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...)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type received struct {
|
||||
mu sync.Mutex
|
||||
reqs []*http.Request
|
||||
body []string
|
||||
}
|
||||
|
||||
func (r *received) handler(status int) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) {
|
||||
b, _ := io.ReadAll(req.Body)
|
||||
r.mu.Lock()
|
||||
r.reqs = append(r.reqs, req)
|
||||
r.body = append(r.body, string(b))
|
||||
r.mu.Unlock()
|
||||
w.WriteHeader(status)
|
||||
}
|
||||
}
|
||||
|
||||
var msg = Message{
|
||||
Title: "Vertrag kunde läuft ab",
|
||||
Body: "Noch *14 Tage* bis 2026-12-15.",
|
||||
Severity: Critical,
|
||||
}
|
||||
|
||||
func TestSlack(t *testing.T) {
|
||||
var got received
|
||||
srv := httptest.NewServer(got.handler(200))
|
||||
defer srv.Close()
|
||||
if err := (Slack{URL: srv.URL}).Send(context.Background(), msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ct := got.reqs[0].Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("Content-Type %q", ct)
|
||||
}
|
||||
var payload map[string]string
|
||||
if err := json.Unmarshal([]byte(got.body[0]), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := payload["text"]
|
||||
if !strings.Contains(text, "*Vertrag kunde läuft ab*") || !strings.Contains(text, "Noch *14 Tage*") || !strings.Contains(text, ":rotating_light:") {
|
||||
t.Errorf("text = %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNtfy(t *testing.T) {
|
||||
var got received
|
||||
srv := httptest.NewServer(got.handler(200))
|
||||
defer srv.Close()
|
||||
if err := (Ntfy{URL: srv.URL + "/sultan", Token: "tk_geheim"}).Send(context.Background(), msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := got.reqs[0]
|
||||
if r.URL.Path != "/sultan" || got.body[0] != msg.Body {
|
||||
t.Errorf("Pfad %q, Body %q", r.URL.Path, got.body[0])
|
||||
}
|
||||
for h, want := range map[string]string{
|
||||
"Title": "Vertrag kunde läuft ab",
|
||||
"Priority": "urgent",
|
||||
"Tags": "rotating_light",
|
||||
"Markdown": "yes",
|
||||
"Authorization": "Bearer tk_geheim",
|
||||
} {
|
||||
if r.Header.Get(h) != want {
|
||||
t.Errorf("%s = %q, erwartet %q", h, r.Header.Get(h), want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeverityMapping(t *testing.T) {
|
||||
for sev, want := range map[Severity][2]string{
|
||||
Info: {"default", "information_source"},
|
||||
Warning: {"high", "warning"},
|
||||
Critical: {"urgent", "rotating_light"},
|
||||
} {
|
||||
var got received
|
||||
srv := httptest.NewServer(got.handler(200))
|
||||
(Ntfy{URL: srv.URL}).Send(context.Background(), Message{Title: "x", Body: "y", Severity: sev})
|
||||
srv.Close()
|
||||
if got.reqs[0].Header.Get("Priority") != want[0] || got.reqs[0].Header.Get("Tags") != want[1] {
|
||||
t.Errorf("%v: Priority %q Tags %q", sev, got.reqs[0].Header.Get("Priority"), got.reqs[0].Header.Get("Tags"))
|
||||
}
|
||||
if got.reqs[0].Header.Get("Authorization") != "" {
|
||||
t.Error("Authorization ohne Token gesetzt")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrors(t *testing.T) {
|
||||
var got received
|
||||
bad := httptest.NewServer(got.handler(500))
|
||||
defer bad.Close()
|
||||
for _, target := range []Target{Slack{URL: bad.URL}, Ntfy{URL: bad.URL}, Slack{URL: "http://127.0.0.1:1"}} {
|
||||
if err := target.Send(context.Background(), msg); err == nil {
|
||||
t.Errorf("%T: erwartet Fehler", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi sendet an alle Ziele; ein Fehler bei einem Ziel hält die anderen nicht auf.
|
||||
func TestMulti(t *testing.T) {
|
||||
var a, b received
|
||||
okA := httptest.NewServer(a.handler(200))
|
||||
okB := httptest.NewServer(b.handler(200))
|
||||
bad := httptest.NewServer((&received{}).handler(500))
|
||||
defer okA.Close()
|
||||
defer okB.Close()
|
||||
defer bad.Close()
|
||||
|
||||
m := Multi{Slack{URL: okA.URL}, Ntfy{URL: bad.URL}, Ntfy{URL: okB.URL}}
|
||||
err := m.Send(context.Background(), msg)
|
||||
if err == nil {
|
||||
t.Error("Fehler eines Ziels nicht gemeldet")
|
||||
}
|
||||
if len(a.reqs) != 1 || len(b.reqs) != 1 {
|
||||
t.Errorf("nicht alle Ziele erreicht: %d/%d", len(a.reqs), len(b.reqs))
|
||||
}
|
||||
if !errors.Is(Multi{}.Send(context.Background(), msg), ErrNoTargets) {
|
||||
t.Error("leeres Multi: erwartet ErrNoTargets")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user