// Package alerts liefert Health-Notifications via Webhook + SMTP. // Triggers (cert-expiry, backup-fail, license-invalid) leben im // edgeguard-scheduler; Operator-Triggers (Test-Event) im API-Handler. package alerts import ( "bytes" "context" "crypto/tls" "encoding/json" "errors" "fmt" "io" "net/http" "net/smtp" "strconv" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ErrNotFound = errors.New("alert channel not found") // Severity ist die UI-Kategorie. Constraint in der DB. type Severity string const ( SeverityInfo Severity = "info" SeverityWarning Severity = "warning" SeverityError Severity = "error" SeverityCritical Severity = "critical" ) // Kind klassifiziert die Trigger-Source (cert.expiring, // backup.failed, license.invalid, test). Frei textbar — UI rendert // es als Tag. // Channel ist eine Notification-Senke. type Channel struct { ID int64 `json:"id"` Name string `json:"name"` Kind string `json:"kind"` // webhook | email Target string `json:"target"` Settings json.RawMessage `json:"settings,omitempty"` Active bool `json:"active"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } // EmailSettings sind die SMTP-Konfig-Felder die in settings.JSONB // für kind=email liegen. type EmailSettings struct { SMTPHost string `json:"smtp_host"` SMTPPort int `json:"smtp_port"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` From string `json:"from"` UseTLS bool `json:"use_tls"` } // Event ist eine Row in alert_events. type Event struct { ID int64 `json:"id"` Kind string `json:"kind"` Severity Severity `json:"severity"` Subject string `json:"subject"` Message string `json:"message"` SentTo json.RawMessage `json:"sent_to"` FiredAt time.Time `json:"fired_at"` } // SendResult pro Channel — landet als JSON-Array in sent_to. type SendResult struct { ChannelID int64 `json:"channel_id"` ChannelName string `json:"channel_name"` OK bool `json:"ok"` Error string `json:"error,omitempty"` } type Service struct { Pool *pgxpool.Pool HTTPClient *http.Client } func New(pool *pgxpool.Pool) *Service { return &Service{ Pool: pool, HTTPClient: &http.Client{ Timeout: 10 * time.Second, Transport: &http.Transport{ // Webhooks gehen oft an interne Receivers — wir // erlauben self-signed TLS auf der Webhook-Seite // (Slack/Discord/Teams sind ohnehin valid signed). TLSClientConfig: &tls.Config{InsecureSkipVerify: false}, }, }, } } // ListChannels gibt alle Channels zurück, newest-first. func (s *Service) ListChannels(ctx context.Context) ([]Channel, error) { rows, err := s.Pool.Query(ctx, ` SELECT id, name, kind, target, settings, active, created_at, updated_at FROM alert_channels ORDER BY id ASC`) if err != nil { return nil, err } defer rows.Close() out := []Channel{} for rows.Next() { var c Channel if err := rows.Scan(&c.ID, &c.Name, &c.Kind, &c.Target, &c.Settings, &c.Active, &c.CreatedAt, &c.UpdatedAt); err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } func (s *Service) CreateChannel(ctx context.Context, c Channel) (*Channel, error) { if c.Settings == nil || len(c.Settings) == 0 { c.Settings = json.RawMessage(`{}`) } row := s.Pool.QueryRow(ctx, ` INSERT INTO alert_channels (name, kind, target, settings, active) VALUES ($1, $2, $3, $4, $5) RETURNING id, name, kind, target, settings, active, created_at, updated_at`, c.Name, c.Kind, c.Target, c.Settings, c.Active) var out Channel if err := row.Scan(&out.ID, &out.Name, &out.Kind, &out.Target, &out.Settings, &out.Active, &out.CreatedAt, &out.UpdatedAt); err != nil { return nil, err } return &out, nil } func (s *Service) UpdateChannel(ctx context.Context, id int64, c Channel) (*Channel, error) { if c.Settings == nil || len(c.Settings) == 0 { c.Settings = json.RawMessage(`{}`) } row := s.Pool.QueryRow(ctx, ` UPDATE alert_channels SET name = $1, kind = $2, target = $3, settings = $4, active = $5, updated_at = NOW() WHERE id = $6 RETURNING id, name, kind, target, settings, active, created_at, updated_at`, c.Name, c.Kind, c.Target, c.Settings, c.Active, id) var out Channel if err := row.Scan(&out.ID, &out.Name, &out.Kind, &out.Target, &out.Settings, &out.Active, &out.CreatedAt, &out.UpdatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, ErrNotFound } return nil, err } return &out, nil } func (s *Service) DeleteChannel(ctx context.Context, id int64) error { tag, err := s.Pool.Exec(ctx, `DELETE FROM alert_channels WHERE id = $1`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil } // ListEvents liefert die letzten N Events newest-first. func (s *Service) ListEvents(ctx context.Context, limit int) ([]Event, error) { if limit <= 0 || limit > 500 { limit = 100 } rows, err := s.Pool.Query(ctx, ` SELECT id, kind, severity, subject, message, sent_to, fired_at FROM alert_events ORDER BY fired_at DESC, id DESC LIMIT $1`, limit) if err != nil { return nil, err } defer rows.Close() out := []Event{} for rows.Next() { var e Event if err := rows.Scan(&e.ID, &e.Kind, &e.Severity, &e.Subject, &e.Message, &e.SentTo, &e.FiredAt); err != nil { return nil, err } out = append(out, e) } return out, rows.Err() } // Fire dispatch'ed einen Event an alle aktiven Channels und persistiert // das Ergebnis. Non-fatal — Send-Failures werden im sent_to-JSON // dokumentiert, der Event selbst landet in jedem Fall in der History. func (s *Service) Fire(ctx context.Context, kind string, severity Severity, subject, message string) (*Event, error) { chans, err := s.ListChannels(ctx) if err != nil { return nil, err } results := []SendResult{} for _, c := range chans { if !c.Active { continue } r := SendResult{ChannelID: c.ID, ChannelName: c.Name} var sendErr error switch c.Kind { case "webhook": sendErr = s.sendWebhook(ctx, c, kind, severity, subject, message) case "email": sendErr = s.sendEmail(c, severity, subject, message) default: sendErr = fmt.Errorf("unknown kind %q", c.Kind) } if sendErr != nil { r.OK = false r.Error = sendErr.Error() } else { r.OK = true } results = append(results, r) } sentJSON, _ := json.Marshal(results) var e Event err = s.Pool.QueryRow(ctx, ` INSERT INTO alert_events (kind, severity, subject, message, sent_to) VALUES ($1, $2, $3, $4, $5) RETURNING id, kind, severity, subject, message, sent_to, fired_at`, kind, string(severity), subject, message, sentJSON). Scan(&e.ID, &e.Kind, &e.Severity, &e.Subject, &e.Message, &e.SentTo, &e.FiredAt) if err != nil { return nil, err } return &e, nil } // sendWebhook POSTet ein JSON-Payload mit kind+severity+subject+message // + ISO-timestamp. Slack/Discord/Teams akzeptieren das (Discord // braucht "content"-Feld; wir liefern beides damit der Operator // keinen Adapter braucht). func (s *Service) sendWebhook(ctx context.Context, c Channel, kind string, sev Severity, subject, message string) error { payload := map[string]any{ "kind": kind, "severity": string(sev), "subject": subject, "message": message, "content": fmt.Sprintf("[%s] %s: %s\n%s", strings.ToUpper(string(sev)), kind, subject, message), "text": fmt.Sprintf("*[%s]* %s — %s\n%s", strings.ToUpper(string(sev)), kind, subject, message), "fired_at": time.Now().UTC().Format(time.RFC3339), } body, _ := json.Marshal(payload) req, err := http.NewRequestWithContext(ctx, "POST", c.Target, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "edgeguard-alerts/1.0") resp, err := s.HTTPClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) return fmt.Errorf("webhook %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } return nil } // sendEmail nutzt net/smtp + STARTTLS optional. Settings-Felder // (host/port/user/pass/from/use_tls) liegen in c.Settings. func (s *Service) sendEmail(c Channel, sev Severity, subject, message string) error { var es EmailSettings if len(c.Settings) > 0 { if err := json.Unmarshal(c.Settings, &es); err != nil { return fmt.Errorf("parse settings: %w", err) } } if es.SMTPHost == "" || es.SMTPPort == 0 || es.From == "" { return errors.New("email settings incomplete (smtp_host/smtp_port/from required)") } addr := es.SMTPHost + ":" + strconv.Itoa(es.SMTPPort) msg := []byte("From: " + es.From + "\r\n" + "To: " + c.Target + "\r\n" + "Subject: [" + strings.ToUpper(string(sev)) + "] " + subject + "\r\n" + "Content-Type: text/plain; charset=utf-8\r\n" + "\r\n" + message + "\r\n") var auth smtp.Auth if es.Username != "" { auth = smtp.PlainAuth("", es.Username, es.Password, es.SMTPHost) } return smtp.SendMail(addr, auth, es.From, []string{c.Target}, msg) }