Gemeldet als "Loeschen tut nichts". Das Loeschen hat funktioniert: der
Audit-Eintrag zeigt deleted=25 und die IDs waren danach weg. Nur war das
unsichtbar — die Kopf-Checkbox der Tabelle markiert ausschliesslich die
AKTUELLE Seite (25 Zeilen), und bei einem Backlog von ~950 optisch
identischen backend.down-Alarmen ruecken sofort die naechsten 25 nach.
Fuer den Operator sah es aus, als sei nichts passiert; um den Backlog
leerzubekommen waeren ~38 Klick-Runden noetig gewesen.
Neu: POST /alerts/events/delete-acknowledged loescht alle quittierten
Events auf einmal — Gegenstueck zum bereits vorhandenen "Alle
quittieren". Bewusst nur die quittierten: was noch niemand gesehen hat,
soll nicht per Sammelaktion verschwinden. Der Button zeigt die Anzahl
ueber den gesamten Datensatz, nicht nur die sichtbare Seite.
Ausserdem meldet das seitenweise Loeschen jetzt die Anzahl zurueck
("N Alarme geloescht") statt nur "Loeschen" und leert die Auswahl —
damit ist erkennbar, dass etwas passiert ist, auch wenn die Liste
optisch gleich aussieht.
Die automatische Aufraeumung (90 Tage) arbeitet uebrigens korrekt, sie
loescht taeglich die Eintraege die ueber die Grenze rutschen — der
Backlog stammt aus dem Zeitraum davor.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
389 lines
12 KiB
Go
389 lines
12 KiB
Go
// 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"`
|
|
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
|
|
}
|
|
|
|
// 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 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 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. Wenn openOnly
|
|
// gesetzt ist, werden nur noch offene (nicht quittierte) Events geliefert —
|
|
// das nutzt die Dashboard-Karte, damit Quittieren die Meldung verschwinden
|
|
// lässt.
|
|
func (s *Service) ListEvents(ctx context.Context, limit int, openOnly bool) ([]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, acknowledged_at
|
|
FROM alert_events
|
|
WHERE ($2::bool = false OR acknowledged_at IS NULL)
|
|
ORDER BY fired_at DESC, id DESC LIMIT $1`, limit, openOnly)
|
|
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, &e.AcknowledgedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, e)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
// Acknowledge quittiert die angegebenen Events (setzt acknowledged_at=NOW()
|
|
// bei noch offenen). Liefert die Anzahl geänderter Rows.
|
|
func (s *Service) Acknowledge(ctx context.Context, ids []int64) (int64, error) {
|
|
if len(ids) == 0 {
|
|
return 0, nil
|
|
}
|
|
tag, err := s.Pool.Exec(ctx,
|
|
`UPDATE alert_events SET acknowledged_at = NOW()
|
|
WHERE id = ANY($1) AND acknowledged_at IS NULL`, ids)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
// AcknowledgeAll quittiert alle offenen Events — Backing für den
|
|
// "Alle quittieren"-Button.
|
|
func (s *Service) AcknowledgeAll(ctx context.Context) (int64, error) {
|
|
tag, err := s.Pool.Exec(ctx,
|
|
`UPDATE alert_events SET acknowledged_at = NOW() WHERE acknowledged_at IS NULL`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
// DeleteEvents löscht die angegebenen Events endgültig. Liefert die Anzahl
|
|
// gelöschter Rows.
|
|
func (s *Service) DeleteEvents(ctx context.Context, ids []int64) (int64, error) {
|
|
if len(ids) == 0 {
|
|
return 0, nil
|
|
}
|
|
tag, err := s.Pool.Exec(ctx,
|
|
`DELETE FROM alert_events WHERE id = ANY($1)`, ids)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
// DeleteAcknowledged löscht alle bereits quittierten Events und liefert
|
|
// die Anzahl. Backing für "Alle quittierten löschen".
|
|
//
|
|
// Warum das gebraucht wird (Befund 2026-09-11): Bulk-Löschen ging nur über
|
|
// die Tabellen-Auswahl, und deren Kopf-Checkbox markiert nur die AKTUELLE
|
|
// Seite. Bei einem Backlog von ~950 Alt-Alarmen loescht ein Klick also 25
|
|
// Zeilen, woraufhin die naechsten 25 — optisch identischen — nachruecken.
|
|
// Es sah aus, als passiere nichts, obwohl korrekt geloescht wurde.
|
|
//
|
|
// Bewusst nur die quittierten: was noch niemand gesehen hat, soll nicht
|
|
// per Sammelaktion verschwinden.
|
|
func (s *Service) DeleteAcknowledged(ctx context.Context) (int64, error) {
|
|
tag, err := s.Pool.Exec(ctx,
|
|
`DELETE FROM alert_events WHERE acknowledged_at IS NOT NULL`)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
// Cleanup löscht alert_events älter als keepDays und liefert die Anzahl
|
|
// gelöschter Rows. make_interval(days => $1) nimmt $1 sauber als int —
|
|
// der frühere ($1 || ' days')::interval-Ansatz erzwang text und scheiterte
|
|
// unter pgx mit einem Encode-Fehler (vgl. waf PurgeAlerts, v1.3.3).
|
|
func (s *Service) Cleanup(ctx context.Context, keepDays int) (int64, error) {
|
|
if keepDays <= 0 {
|
|
return 0, nil
|
|
}
|
|
tag, err := s.Pool.Exec(ctx,
|
|
`DELETE FROM alert_events WHERE fired_at < NOW() - make_interval(days => $1)`,
|
|
keepDays,
|
|
)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return tag.RowsAffected(), nil
|
|
}
|
|
|
|
// 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 func() { _ = 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)
|
|
}
|