notifier: ntfy + SMTP event dispatch, wired through SystemEventLogger
This commit is contained in:
parent
4c1da83d8d
commit
5e710f597b
6 changed files with 756 additions and 3 deletions
244
server/internal/services/notifier/notifier.go
Normal file
244
server/internal/services/notifier/notifier.go
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
// Package notifier dispatches system events to external notification sinks
|
||||
// (ntfy, SMTP email). Best-effort, fail-open — the dashboard is the source
|
||||
// of truth; notifications are a courtesy tap on the shoulder.
|
||||
//
|
||||
// Design of record: docs/tasks/NOTIFY-001-notification-system.md
|
||||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SettingsReader is the subset of SecuritySettingsService that the
|
||||
// notifier needs. Implemented by the services package; avoids a
|
||||
// circular import.
|
||||
type SettingsReader interface {
|
||||
GetSetting(category, key string) (interface{}, error)
|
||||
}
|
||||
|
||||
// Setup builds and registers sinks on a Dispatcher from security_settings.
|
||||
// Pass the dispatcher and a settings reader (typically SecuritySettingsService).
|
||||
// Returns the count of sinks registered.
|
||||
func Setup(d *Dispatcher, settings SettingsReader) int {
|
||||
count := 0
|
||||
|
||||
// --- ntfy ---
|
||||
if url := getString(settings, "notify", "ntfy_url"); url != "" {
|
||||
classes := ParseEventClasses(getString(settings, "notify", "ntfy_events"))
|
||||
severity := getString(settings, "notify", "ntfy_severity")
|
||||
if severity == "" {
|
||||
severity = "error,critical"
|
||||
}
|
||||
sink := NewNtfySink(url, severity, classes)
|
||||
d.Register(sink, classes...)
|
||||
log.Printf("[INFO] [notifier] ntfy_configured url=%s severity=%s classes=%v",
|
||||
url, severity, classes)
|
||||
count++
|
||||
}
|
||||
|
||||
// --- SMTP ---
|
||||
if host := getString(settings, "notify", "smtp_host"); host != "" {
|
||||
from := getString(settings, "notify", "smtp_from")
|
||||
to := getString(settings, "notify", "smtp_to")
|
||||
if from != "" && to != "" {
|
||||
classes := ParseEventClasses(getString(settings, "notify", "smtp_events"))
|
||||
severity := getString(settings, "notify", "smtp_severity")
|
||||
if severity == "" {
|
||||
severity = "error,critical"
|
||||
}
|
||||
sink := NewSmtpSink(host, from, to, severity, classes)
|
||||
d.Register(sink, classes...)
|
||||
log.Printf("[INFO] [notifier] smtp_configured host=%s to=%s severity=%s classes=%v",
|
||||
host, to, severity, classes)
|
||||
count++
|
||||
} else {
|
||||
log.Printf("[WARN] [notifier] smtp_host_configured_but_missing_from_or_to host=%s", host)
|
||||
}
|
||||
}
|
||||
|
||||
return count
|
||||
}
|
||||
|
||||
// getString reads a string-valued setting with a default fallback.
|
||||
func getString(settings SettingsReader, category, key string) string {
|
||||
v, err := settings.GetSetting(category, key)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// EventClass categorises a notification by what happened.
|
||||
type EventClass string
|
||||
|
||||
const (
|
||||
ClassSecurity EventClass = "security"
|
||||
ClassAgentOffline EventClass = "agent_offline"
|
||||
ClassUpdateFailed EventClass = "update_failed"
|
||||
ClassThreat EventClass = "threat"
|
||||
ClassEOL EventClass = "eol"
|
||||
)
|
||||
|
||||
// NotifyEvent is the payload passed to every configured sink.
|
||||
type NotifyEvent struct {
|
||||
Class EventClass
|
||||
Severity string // info, warning, error, critical
|
||||
Title string
|
||||
Message string
|
||||
AgentID string // optional — empty for server-scoped events
|
||||
Fingerprint string // dedup key — same fingerprint within class suppresses repeats
|
||||
}
|
||||
|
||||
// Sink is one external notification target.
|
||||
type Sink interface {
|
||||
// ID returns a stable short name for logging (e.g. "ntfy", "smtp").
|
||||
ID() string
|
||||
// Send delivers the event. Must not panic. Returns error for logging.
|
||||
Send(ctx context.Context, event NotifyEvent) error
|
||||
}
|
||||
|
||||
// Dispatcher routes events to configured sinks based on event class.
|
||||
// Zero-value is usable (no sinks).
|
||||
type Dispatcher struct {
|
||||
mu sync.RWMutex
|
||||
sinks map[EventClass][]Sink
|
||||
|
||||
// Dedup window — suppress repeated events with the same class+fingerprint
|
||||
// inside this window.
|
||||
dedupWindow time.Duration
|
||||
dedup map[string]time.Time
|
||||
dedupMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewDispatcher creates a Dispatcher with the given dedup window.
|
||||
// A 5-minute window is reasonable for most deployments.
|
||||
func NewDispatcher(dedupWindow time.Duration) *Dispatcher {
|
||||
return &Dispatcher{
|
||||
sinks: make(map[EventClass][]Sink),
|
||||
dedupWindow: dedupWindow,
|
||||
dedup: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// Register adds a sink for one or more event classes. Pass zero classes to
|
||||
// subscribe to everything.
|
||||
func (d *Dispatcher) Register(sink Sink, classes ...EventClass) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if len(classes) == 0 {
|
||||
classes = []EventClass{ClassSecurity, ClassAgentOffline, ClassUpdateFailed, ClassThreat, ClassEOL}
|
||||
}
|
||||
for _, c := range classes {
|
||||
d.sinks[c] = append(d.sinks[c], sink)
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch sends the event to every sink subscribed to its class.
|
||||
// Best-effort: individual sink failures are logged and do not block
|
||||
// other sinks or the caller. Dedup suppresses events whose class+fingerprint
|
||||
// have been seen within the dedup window.
|
||||
func (d *Dispatcher) Dispatch(ctx context.Context, event NotifyEvent) {
|
||||
// Dedup check — default fingerprint is the message if none set.
|
||||
if event.Fingerprint == "" {
|
||||
event.Fingerprint = event.Message
|
||||
}
|
||||
d.dedupMu.Lock()
|
||||
key := fmt.Sprintf("%s|%s", event.Class, event.Fingerprint)
|
||||
if last, ok := d.dedup[key]; ok && time.Since(last) < d.dedupWindow {
|
||||
d.dedupMu.Unlock()
|
||||
return
|
||||
}
|
||||
d.dedup[key] = time.Now()
|
||||
// Prune expired entries so the map doesn't grow unbounded.
|
||||
for k, v := range d.dedup {
|
||||
if time.Since(v) > d.dedupWindow*2 {
|
||||
delete(d.dedup, k)
|
||||
}
|
||||
}
|
||||
d.dedupMu.Unlock()
|
||||
|
||||
d.mu.RLock()
|
||||
sinks := d.sinks[event.Class]
|
||||
d.mu.RUnlock()
|
||||
|
||||
for _, sink := range sinks {
|
||||
go func(s Sink) {
|
||||
if err := s.Send(ctx, event); err != nil {
|
||||
log.Printf("[WARN] [notifier] [%s] send_failed class=%s title=%q error=%v",
|
||||
s.ID(), event.Class, event.Title, err)
|
||||
}
|
||||
}(sink)
|
||||
}
|
||||
}
|
||||
|
||||
// SinkIDs returns the IDs of all registered sinks for diagnostics.
|
||||
func (d *Dispatcher) SinkIDs() []string {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
seen := map[string]bool{}
|
||||
for _, sinks := range d.sinks {
|
||||
for _, s := range sinks {
|
||||
seen[s.ID()] = true
|
||||
}
|
||||
}
|
||||
ids := make([]string, 0, len(seen))
|
||||
for id := range seen {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// ParseEventClasses splits a comma-separated config string into event classes.
|
||||
// Unknown tokens are dropped with a log warning.
|
||||
func ParseEventClasses(raw string) []EventClass {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
classes := make([]EventClass, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(strings.ToLower(p))
|
||||
switch p {
|
||||
case "security":
|
||||
classes = append(classes, ClassSecurity)
|
||||
case "agent_offline", "agent-offline":
|
||||
classes = append(classes, ClassAgentOffline)
|
||||
case "update_failed", "update-failed":
|
||||
classes = append(classes, ClassUpdateFailed)
|
||||
case "threat":
|
||||
classes = append(classes, ClassThreat)
|
||||
case "eol":
|
||||
classes = append(classes, ClassEOL)
|
||||
default:
|
||||
log.Printf("[WARN] [notifier] unknown_event_class class=%q", p)
|
||||
}
|
||||
}
|
||||
return classes
|
||||
}
|
||||
|
||||
// SeverityAll is the default — all severities pass through.
|
||||
const SeverityAll = "info,warning,error,critical"
|
||||
|
||||
// SeverityPasses returns true when a given severity string meets a
|
||||
// comma-separated minimum-severity filter. e.g. "error,critical" passes
|
||||
// error and critical but not warning/info.
|
||||
func SeverityPasses(filter, severity string) bool {
|
||||
if filter == "" || filter == SeverityAll {
|
||||
return true
|
||||
}
|
||||
for _, s := range strings.Split(filter, ",") {
|
||||
if strings.TrimSpace(s) == severity {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
122
server/internal/services/notifier/notifier_test.go
Normal file
122
server/internal/services/notifier/notifier_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseEventClasses(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want int
|
||||
}{
|
||||
{"empty", "", 0},
|
||||
{"single known", "security", 1},
|
||||
{"two known", "security,agent_offline", 2},
|
||||
{"with dashes", "agent-offline,update-failed", 2},
|
||||
{"mixed case and spaces", " Security , THREAT ", 2},
|
||||
{"unknown dropped", "security,bogus,threat", 2},
|
||||
{"all unknown", "foo,bar", 0},
|
||||
{"all five", "security,agent_offline,update_failed,threat,eol", 5},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := ParseEventClasses(tt.raw)
|
||||
if len(got) != tt.want {
|
||||
t.Errorf("ParseEventClasses(%q) = %d classes, want %d: %v", tt.raw, len(got), tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeverityPasses(t *testing.T) {
|
||||
tests := []struct {
|
||||
filter string
|
||||
severity string
|
||||
want bool
|
||||
}{
|
||||
// Empty/any filter passes everything
|
||||
{"", "info", true},
|
||||
{"info,warning,error,critical", "info", true},
|
||||
// Exact match
|
||||
{"error,critical", "error", true},
|
||||
{"error,critical", "critical", true},
|
||||
// Below threshold
|
||||
{"error,critical", "warning", false},
|
||||
{"error,critical", "info", false},
|
||||
// Single filter
|
||||
{"warning", "warning", true},
|
||||
{"warning", "error", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.filter+"_"+tt.severity, func(t *testing.T) {
|
||||
got := SeverityPasses(tt.filter, tt.severity)
|
||||
if got != tt.want {
|
||||
t.Errorf("SeverityPasses(%q, %q) = %v, want %v", tt.filter, tt.severity, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherDedup(t *testing.T) {
|
||||
d := NewDispatcher(5 * time.Second) // long window so dups are caught
|
||||
|
||||
cs := &countingSink{}
|
||||
d.Register(cs, ClassSecurity)
|
||||
|
||||
event := NotifyEvent{
|
||||
Class: ClassSecurity,
|
||||
Severity: "error",
|
||||
Title: "Test",
|
||||
Message: "test message",
|
||||
Fingerprint: "test-dedup-fp",
|
||||
}
|
||||
|
||||
// First dispatch — goes through, goroutine fires Send.
|
||||
d.Dispatch(context.Background(), event)
|
||||
// Second dispatch — dedup map hit, goroutine NOT fired.
|
||||
d.Dispatch(context.Background(), event)
|
||||
// Third — same.
|
||||
d.Dispatch(context.Background(), event)
|
||||
|
||||
// Let goroutines settle.
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
if cs.count != 1 {
|
||||
t.Errorf("expected exactly 1 delivery (dedup suppressed the rest), got %d", cs.count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatcherNoDedupDifferentFP(t *testing.T) {
|
||||
d := NewDispatcher(5 * time.Second)
|
||||
|
||||
cs := &countingSink{}
|
||||
d.Register(cs, ClassSecurity)
|
||||
|
||||
d.Dispatch(context.Background(), NotifyEvent{
|
||||
Class: ClassSecurity, Severity: "error", Title: "A", Message: "msg A", Fingerprint: "fp-a",
|
||||
})
|
||||
d.Dispatch(context.Background(), NotifyEvent{
|
||||
Class: ClassSecurity, Severity: "error", Title: "B", Message: "msg B", Fingerprint: "fp-b",
|
||||
})
|
||||
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
if cs.count != 2 {
|
||||
t.Errorf("expected 2 deliveries for different fingerprints, got %d", cs.count)
|
||||
}
|
||||
}
|
||||
|
||||
type countingSink struct {
|
||||
count int
|
||||
}
|
||||
|
||||
func (s *countingSink) ID() string { return "test" }
|
||||
func (s *countingSink) Send(_ context.Context, _ NotifyEvent) error {
|
||||
s.count++
|
||||
return nil
|
||||
}
|
||||
116
server/internal/services/notifier/ntfy.go
Normal file
116
server/internal/services/notifier/ntfy.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package notifier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NtfySink delivers events to an ntfy server via HTTP POST.
|
||||
// ntfy is a self-hostable pub-sub notification service — https://ntfy.sh.
|
||||
type NtfySink struct {
|
||||
url string
|
||||
severity string // comma-separated minimum severity
|
||||
eventClass []EventClass
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewNtfySink creates an ntfy sink. url is the full topic URL
|
||||
// (e.g. "https://ntfy.example.com/redflag"). severity is the minimum
|
||||
// severity filter ("error,critical" = only error+critical).
|
||||
// eventClass is the parsed list of event classes to subscribe to.
|
||||
func NewNtfySink(url, severity string, eventClass []EventClass) *NtfySink {
|
||||
return &NtfySink{
|
||||
url: strings.TrimRight(url, "/"),
|
||||
severity: severity,
|
||||
eventClass: eventClass,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *NtfySink) ID() string { return "ntfy" }
|
||||
|
||||
// ntfyPayload is the JSON body for an ntfy publish request.
|
||||
type ntfyPayload struct {
|
||||
Topic string `json:"topic"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Priority int `json:"priority"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
func (s *NtfySink) Send(ctx context.Context, event NotifyEvent) error {
|
||||
if !SeverityPasses(s.severity, event.Severity) {
|
||||
return nil
|
||||
}
|
||||
|
||||
prio := ntfyPriority(event)
|
||||
payload := ntfyPayload{
|
||||
Title: event.Title,
|
||||
Message: event.Message,
|
||||
Priority: prio,
|
||||
Tags: ntfyTags(event),
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal ntfy payload: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build ntfy request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ntfy post: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("ntfy returned %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ntfyPriority(event NotifyEvent) int {
|
||||
switch event.Class {
|
||||
case ClassSecurity:
|
||||
return 5 // max — immediate
|
||||
case ClassAgentOffline:
|
||||
return 4 // high
|
||||
case ClassUpdateFailed:
|
||||
return 4
|
||||
case ClassThreat:
|
||||
return 4
|
||||
case ClassEOL:
|
||||
return 3 // default
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
}
|
||||
|
||||
func ntfyTags(event NotifyEvent) []string {
|
||||
switch event.Class {
|
||||
case ClassSecurity:
|
||||
return []string{"lock", "redflag"}
|
||||
case ClassAgentOffline:
|
||||
return []string{"no_entry", "redflag"}
|
||||
case ClassUpdateFailed:
|
||||
return []string{"x", "redflag"}
|
||||
case ClassThreat:
|
||||
return []string{"warning", "redflag"}
|
||||
case ClassEOL:
|
||||
return []string{"hourglass", "redflag"}
|
||||
default:
|
||||
return []string{"redflag"}
|
||||
}
|
||||
}
|
||||
164
server/internal/services/notifier/smtp.go
Normal file
164
server/internal/services/notifier/smtp.go
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
package notifier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SmtpSink delivers events via plain-text SMTP email.
|
||||
// Uses net/smtp only — no HTML templates, no third-party mail frameworks.
|
||||
// Auth preference: STARTTLS → plain auth → CRAM-MD5.
|
||||
type SmtpSink struct {
|
||||
host string // host:port
|
||||
from string
|
||||
to string
|
||||
severity string
|
||||
eventClass []EventClass
|
||||
}
|
||||
|
||||
// NewSmtpSink creates an SMTP sink. host is the SMTP server address
|
||||
// (e.g. "smtp.example.com:587"). from is the sender address. to is
|
||||
// the recipient. severity filters by minimum severity.
|
||||
func NewSmtpSink(host, from, to, severity string, eventClass []EventClass) *SmtpSink {
|
||||
return &SmtpSink{
|
||||
host: host,
|
||||
from: from,
|
||||
to: to,
|
||||
severity: severity,
|
||||
eventClass: eventClass,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SmtpSink) ID() string { return "smtp" }
|
||||
|
||||
func (s *SmtpSink) Send(ctx context.Context, event NotifyEvent) error {
|
||||
if !SeverityPasses(s.severity, event.Severity) {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := buildEmail(s.from, s.to, event)
|
||||
|
||||
// Split host:port — net/smtp needs them separate.
|
||||
host, port := s.splitHostPort()
|
||||
|
||||
// Try STARTTLS first (port 587 path), then fall back to plain auth.
|
||||
// net/smtp.SendMail with TLS config triggers STARTTLS when available.
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: host,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
done <- fmt.Errorf("smtp panic: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
addr := net.JoinHostPort(host, port)
|
||||
if port == "465" {
|
||||
// SMTPS — TLS from the start, not STARTTLS.
|
||||
conn, err := tls.DialWithDialer(&net.Dialer{Timeout: 15 * time.Second}, "tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
done <- fmt.Errorf("smtps dial: %w", err)
|
||||
return
|
||||
}
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
done <- fmt.Errorf("smtps client: %w", err)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
if err := sendMailNoAuth(client, s.from, []string{s.to}, msg); err != nil {
|
||||
done <- fmt.Errorf("smtps send: %w", err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Standard SMTP — attempt STARTTLS, then send without auth.
|
||||
// No SMTP AUTH credentials configured: RedFlag SMTP is
|
||||
// designed for internal relays that accept unauthenticated
|
||||
// mail from trusted networks (postfix null client, msmtp,
|
||||
// or a local MTA on localhost:25).
|
||||
err := smtp.SendMail(addr, nil, s.from, []string{s.to}, msg)
|
||||
if err != nil {
|
||||
done <- fmt.Errorf("smtp send: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
done <- nil
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
log.Printf("[WARN] [notifier] [smtp] send_failed host=%s error=%v", s.host, err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SmtpSink) splitHostPort() (string, string) {
|
||||
parts := strings.SplitN(s.host, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[0], parts[1]
|
||||
}
|
||||
return s.host, "25"
|
||||
}
|
||||
|
||||
// sendMailNoAuth sends mail through an already-established SMTP client
|
||||
// without attempting authentication.
|
||||
func sendMailNoAuth(client *smtp.Client, from string, to []string, msg []byte) error {
|
||||
if err := client.Mail(from); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, addr := range to {
|
||||
if err := client.Rcpt(addr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// buildEmail composes a plain-text RFC 5322 message.
|
||||
func buildEmail(from, to string, event NotifyEvent) []byte {
|
||||
now := time.Now().UTC().Format(time.RFC1123Z)
|
||||
subject := fmt.Sprintf("[RedFlag] %s: %s", strings.ToUpper(string(event.Class)), event.Title)
|
||||
|
||||
var body strings.Builder
|
||||
body.WriteString(fmt.Sprintf("From: RedFlag <%s>\r\n", from))
|
||||
body.WriteString(fmt.Sprintf("To: <%s>\r\n", to))
|
||||
body.WriteString(fmt.Sprintf("Subject: %s\r\n", subject))
|
||||
body.WriteString(fmt.Sprintf("Date: %s\r\n", now))
|
||||
body.WriteString("MIME-Version: 1.0\r\n")
|
||||
body.WriteString("Content-Type: text/plain; charset=\"utf-8\"\r\n")
|
||||
body.WriteString("X-Mailer: RedFlag Notifier\r\n")
|
||||
body.WriteString("\r\n")
|
||||
body.WriteString(event.Message)
|
||||
body.WriteString("\r\n")
|
||||
body.WriteString(fmt.Sprintf("\r\n---\r\nSeverity: %s | Class: %s", event.Severity, event.Class))
|
||||
if event.AgentID != "" {
|
||||
body.WriteString(fmt.Sprintf(" | Agent: %s", event.AgentID))
|
||||
}
|
||||
body.WriteString(fmt.Sprintf("\r\nSent: %s\r\n", now))
|
||||
|
||||
return []byte(body.String())
|
||||
}
|
||||
|
|
@ -439,6 +439,16 @@ func (s *SecuritySettingsService) getDefaultSettings() map[string]map[string]int
|
|||
"metrics_enabled": false,
|
||||
"metrics_token_hash": "",
|
||||
},
|
||||
"notify": {
|
||||
"ntfy_url": "",
|
||||
"ntfy_events": "",
|
||||
"ntfy_severity": "error,critical",
|
||||
"smtp_host": "",
|
||||
"smtp_from": "",
|
||||
"smtp_to": "",
|
||||
"smtp_events": "",
|
||||
"smtp_severity": "error,critical",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,18 +1,22 @@
|
|||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
"github.com/Fimeg/RedFlag/server/internal/services/notifier"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// SystemEventLogger persists events into the system_events table.
|
||||
// SystemEventLogger persists events into the system_events table and
|
||||
// optionally dispatches them to external notification sinks.
|
||||
// Used by the orchestrator (timeouts/workflow) to emit system_events with zero
|
||||
// import coupling on the services package.
|
||||
type SystemEventLogger struct {
|
||||
db *sqlx.DB
|
||||
db *sqlx.DB
|
||||
dispatcher *notifier.Dispatcher
|
||||
}
|
||||
|
||||
// NewSystemEventLogger creates a SystemEventLogger.
|
||||
|
|
@ -20,7 +24,14 @@ func NewSystemEventLogger(db *sqlx.DB) *SystemEventLogger {
|
|||
return &SystemEventLogger{db: db}
|
||||
}
|
||||
|
||||
// LogEvent inserts a row into system_events. Best-effort: failures are logged
|
||||
// SetDispatcher attaches a notification dispatcher. May be nil (notifications
|
||||
// disabled). Safe to call before or after first LogEvent.
|
||||
func (l *SystemEventLogger) SetDispatcher(d *notifier.Dispatcher) {
|
||||
l.dispatcher = d
|
||||
}
|
||||
|
||||
// LogEvent inserts a row into system_events and dispatches to external
|
||||
// notification sinks if configured. Best-effort: failures are logged
|
||||
// but never returned, so the caller never stalls on event emission.
|
||||
func (l *SystemEventLogger) LogEvent(agentID *uuid.UUID, eventType, eventSubtype, severity, component, message string, metadata map[string]interface{}) {
|
||||
meta := models.JSONB(metadata)
|
||||
|
|
@ -41,4 +52,90 @@ func (l *SystemEventLogger) LogEvent(agentID *uuid.UUID, eventType, eventSubtype
|
|||
if _, err := l.db.Exec(query, id, agentID, eventType, eventSubtype, severity, component, message, meta); err != nil {
|
||||
log.Printf("[ERROR] [server] [event_logger] insert_failed event_type=%s component=%s error=%v", eventType, component, err)
|
||||
}
|
||||
|
||||
// Dispatch to external notification sinks (best-effort, fail-open).
|
||||
if l.dispatcher != nil {
|
||||
ne := eventToNotify(eventType, eventSubtype, severity, message, agentID)
|
||||
if ne.Class != "" {
|
||||
l.dispatcher.Dispatch(context.Background(), ne)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// eventToNotify maps a system event (type+subtype+severity) to a NotifyEvent
|
||||
// with the appropriate event class. Returns a zero-value NotifyEvent
|
||||
// (Class == "") if the event is not operator-notifiable.
|
||||
func eventToNotify(eventType, eventSubtype, severity, message string, agentID *uuid.UUID) notifier.NotifyEvent {
|
||||
// agent_update events
|
||||
if eventType == "agent_update" {
|
||||
switch eventSubtype {
|
||||
case "timed_out", "failed":
|
||||
return notifier.NotifyEvent{
|
||||
Class: notifier.ClassUpdateFailed,
|
||||
Severity: severity,
|
||||
Title: "Agent update " + eventSubtype,
|
||||
Message: message,
|
||||
AgentID: agentIDString(agentID),
|
||||
Fingerprint: "agent_update_" + eventSubtype + "_" + agentIDString(agentID),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Command failures
|
||||
if eventType == "command_failed" {
|
||||
return notifier.NotifyEvent{
|
||||
Class: notifier.ClassSecurity,
|
||||
Severity: severity,
|
||||
Title: "Command rejected by agent",
|
||||
Message: message,
|
||||
AgentID: agentIDString(agentID),
|
||||
Fingerprint: "command_failed_" + agentIDString(agentID),
|
||||
}
|
||||
}
|
||||
|
||||
// Agent registration failures
|
||||
if eventType == "agent_registration" && eventSubtype == "failed" {
|
||||
return notifier.NotifyEvent{
|
||||
Class: notifier.ClassSecurity,
|
||||
Severity: severity,
|
||||
Title: "Agent registration failed",
|
||||
Message: message,
|
||||
AgentID: agentIDString(agentID),
|
||||
Fingerprint: "registration_failed_" + agentIDString(agentID),
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh token security events — machine mismatch, token reuse.
|
||||
if eventType == "token_security" {
|
||||
return notifier.NotifyEvent{
|
||||
Class: notifier.ClassSecurity,
|
||||
Severity: severity,
|
||||
Title: "Token security event",
|
||||
Message: message,
|
||||
AgentID: agentIDString(agentID),
|
||||
Fingerprint: "token_security_" + eventSubtype + "_" + agentIDString(agentID),
|
||||
}
|
||||
}
|
||||
|
||||
// Heartbeat / agent offline detection.
|
||||
if eventType == "agent_heartbeat" && eventSubtype == "missed" {
|
||||
return notifier.NotifyEvent{
|
||||
Class: notifier.ClassAgentOffline,
|
||||
Severity: severity,
|
||||
Title: "Agent missed heartbeat",
|
||||
Message: message,
|
||||
AgentID: agentIDString(agentID),
|
||||
Fingerprint: "missed_heartbeat_" + agentIDString(agentID),
|
||||
}
|
||||
}
|
||||
|
||||
// Not a notifiable event class.
|
||||
return notifier.NotifyEvent{}
|
||||
}
|
||||
|
||||
func agentIDString(agentID *uuid.UUID) string {
|
||||
if agentID == nil {
|
||||
return ""
|
||||
}
|
||||
return agentID.String()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue