feat(alerts): Health-Alarme via Webhook + Email-SMTP

Sidebar → System → Alarme.

Migration 0021: alert_channels (kind=webhook|email, target, settings,
active) + alert_events (kind, severity=info/warning/error/critical,
subject, message, sent_to JSONB).

internal/services/alerts/:
  - Fire(kind, severity, subject, message) — broadcastet an alle
    aktiven Channels + persistiert Event mit per-Channel-Result
    (ok/error) in sent_to.
  - Webhook-Sender: POST JSON {kind, severity, subject, message,
    content, text, fired_at}. Slack/Discord/Teams akzeptieren das
    out-of-the-box ohne Adapter (content + text-Felder gleichzeitig).
  - Email-Sender: net/smtp + STARTTLS optional. Settings (smtp_host,
    smtp_port, username/password, from, use_tls) liegen in
    channel.settings JSONB.

internal/handlers/alerts.go: CRUD + POST /alerts/test + GET
/alerts/events (history).

Scheduler-Trigger:
  - cert.expiring  — TLS-Cert <14 Tage Restzeit (12h-dedupe pro cert)
                     severity warning, <3 Tage → error
  - cert.renew_failed       — Renewer-Cycle hat fails
  - cert.renewer.run_failed — Renewer-Cycle abgebrochen
  - backup.failed  — Scheduled Backup error
  - license.invalid — License-Server liefert valid=false

In-process Dedupe (12h TTL, map[key]time.Time) verhindert dass
identische Alerts in Schleifen feuern.

UI (pages/Alerts): Tabs Channels (CRUD-Tabelle, Add-Modal mit
conditional-Email-Fields) + History (200 letzte Events mit
severity-Tag + per-Channel-Delivery-Status). Header-Button
„Test-Alert" feuert einen Test-Event in alle aktiven Channels.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-13 15:57:05 +02:00
parent 4a34629023
commit 81a8217493
13 changed files with 1012 additions and 14 deletions

View File

@@ -0,0 +1,304 @@
// 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)
}