This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/analysis"
|
||||
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/store"
|
||||
)
|
||||
|
||||
type Result struct {
|
||||
ChannelID string
|
||||
Detail string
|
||||
}
|
||||
|
||||
type Dispatcher struct {
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func NewDispatcher() *Dispatcher { return &Dispatcher{http: &http.Client{Timeout: 15 * time.Second}} }
|
||||
|
||||
func (d *Dispatcher) Send(ctx context.Context, rule store.Rule, finding analysis.Finding, channels []store.AlertChannel) ([]Result, error) {
|
||||
if len(channels) == 0 {
|
||||
log.Printf("alert for rule %q: %s", rule.Name, finding.Summary)
|
||||
return []Result{{Detail: "no channels configured; logged alert intent"}}, nil
|
||||
}
|
||||
var results []Result
|
||||
var firstErr error
|
||||
for _, channel := range channels {
|
||||
if !channel.Enabled {
|
||||
results = append(results, Result{ChannelID: channel.ID, Detail: "disabled"})
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
switch channel.Type {
|
||||
case "ntfy":
|
||||
err = d.sendNtfy(ctx, rule, finding, channel)
|
||||
case "gotify", "generic_webhook", "smtp":
|
||||
err = fmt.Errorf("%s delivery is scaffolded but not implemented in phase 1", channel.Type)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported channel type %q", channel.Type)
|
||||
}
|
||||
if err != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
results = append(results, Result{ChannelID: channel.ID, Detail: err.Error()})
|
||||
continue
|
||||
}
|
||||
results = append(results, Result{ChannelID: channel.ID, Detail: "sent"})
|
||||
}
|
||||
return results, firstErr
|
||||
}
|
||||
|
||||
func (d *Dispatcher) Test(ctx context.Context, channel store.AlertChannel) error {
|
||||
finding := analysis.Finding{Summary: "Log Guardian test notification", Confidence: "test"}
|
||||
rule := store.Rule{Name: "Test alert", Severity: "info"}
|
||||
switch channel.Type {
|
||||
case "ntfy":
|
||||
return d.sendNtfy(ctx, rule, finding, channel)
|
||||
case "gotify", "generic_webhook", "smtp":
|
||||
return fmt.Errorf("%s delivery is scaffolded but not implemented in phase 1", channel.Type)
|
||||
default:
|
||||
return fmt.Errorf("unsupported channel type %q", channel.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Dispatcher) sendNtfy(ctx context.Context, rule store.Rule, finding analysis.Finding, channel store.AlertChannel) error {
|
||||
server := strings.TrimRight(channel.Params["server_url"], "/")
|
||||
topic := strings.Trim(channel.Params["topic"], "/")
|
||||
if server == "" || topic == "" {
|
||||
return fmt.Errorf("ntfy requires server_url and topic")
|
||||
}
|
||||
target, err := url.JoinPath(server, topic)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := rule.Name + ": " + finding.Summary
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewBufferString(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Title", "Log Guardian: "+rule.Severity)
|
||||
if priority := channel.Params["priority"]; priority != "" {
|
||||
req.Header.Set("Priority", priority)
|
||||
}
|
||||
if tags := channel.Params["tags"]; tags != "" {
|
||||
req.Header.Set("Tags", tags)
|
||||
}
|
||||
if token := channel.Params["token"]; token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := d.http.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return fmt.Errorf("ntfy delivery failed: status %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/analysis"
|
||||
"gitea.wayfinderak.com/wayfinderak/log-guardian/internal/store"
|
||||
)
|
||||
|
||||
func TestNtfyDelivery(t *testing.T) {
|
||||
var gotAuth string
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/alerts" {
|
||||
t.Fatalf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
d := NewDispatcher()
|
||||
_, err := d.Send(t.Context(), store.Rule{Name: "API", Severity: "warning"}, analysis.Finding{Summary: "failed"}, []store.AlertChannel{{ID: "c1", Name: "ntfy", Type: "ntfy", Enabled: true, Params: map[string]string{"server_url": ts.URL, "topic": "alerts", "token": "secret"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotAuth != "Bearer secret" {
|
||||
t.Fatalf("missing auth header: %q", gotAuth)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user