syncova-backup/packages/alerting/store.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

558 lines
19 KiB
Go

package alerting
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Status ist der Bearbeitungszustand einer Meldung.
type Status string
const (
// StatusOpen ist unbearbeitet.
StatusOpen Status = "open"
// StatusAcknowledged wurde zur Kenntnis genommen.
//
// **Nicht erledigt.** Eine bestaetigte Meldung bleibt bestehen, bis ihre
// Ursache verschwindet — sonst waere „bestaetigen" nur ein anderes Wort fuer
// „wegklicken", und der Zustand bliebe unbemerkt.
StatusAcknowledged Status = "acknowledged"
// StatusResolved ist erledigt.
StatusResolved Status = "resolved"
)
// Alert ist eine Meldung.
type Alert struct {
// ID ist der oeffentliche Bezeichner.
ID uuid.UUID `json:"id"`
// RuleName benennt die ausloesende Regel.
RuleName string `json:"rule_name"`
// Severity ist der Schweregrad.
Severity Severity `json:"severity"`
// Status ist der Bearbeitungszustand.
Status Status `json:"status"`
// Fingerprint kennzeichnet die Ursache eindeutig.
Fingerprint string `json:"fingerprint"`
// Title ist die Ueberschrift.
Title string `json:"title"`
// Message erklaert den Befund und die naechste Handlung.
Message string `json:"message"`
// EntityType benennt die Art des betroffenen Gegenstands.
EntityType string `json:"entity_type,omitempty"`
// EntityID ist der betroffene Gegenstand.
EntityID *uuid.UUID `json:"entity_id,omitempty"`
// EntityName ist sein sprechender Name.
EntityName string `json:"entity_name,omitempty"`
// Details traegt die Messwerte des Befunds.
Details map[string]any `json:"details,omitempty"`
// OccurrenceCount zaehlt, wie oft die Ursache seither auftrat.
OccurrenceCount int `json:"occurrence_count"`
// FirstSeenAt ist das erste Auftreten in UTC.
FirstSeenAt time.Time `json:"first_seen_at"`
// LastSeenAt ist das letzte Auftreten in UTC.
LastSeenAt time.Time `json:"last_seen_at"`
// AcknowledgedAt ist der Zeitpunkt der Kenntnisnahme in UTC.
AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"`
// AcknowledgedBy benennt den Bestaetigenden.
AcknowledgedBy *uuid.UUID `json:"acknowledged_by,omitempty"`
// AcknowledgementNote haelt eine Bemerkung fest.
AcknowledgementNote string `json:"acknowledgement_note,omitempty"`
// ResolvedAt ist der Zeitpunkt der Aufloesung in UTC.
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
// ResolvedBy benennt den Aufloesenden; nil bei automatischer Aufloesung.
ResolvedBy *uuid.UUID `json:"resolved_by,omitempty"`
// ResolutionNote begruendet die Aufloesung.
ResolutionNote string `json:"resolution_note,omitempty"`
// CreatedAt ist der Anlagezeitpunkt in UTC.
CreatedAt time.Time `json:"created_at"`
}
// WasResolvedAutomatically meldet eine von selbst verschwundene Ursache.
//
// Der Unterschied zur Aufloesung von Hand ist eine Auskunft ueber die Anlage:
// Was sich selbst erledigt, war ein Zwischenfall. Was jemand wegklicken musste,
// war eine Entscheidung.
func (alert *Alert) WasResolvedAutomatically() bool {
return alert.Status == StatusResolved && alert.ResolvedBy == nil
}
// Fehler der Datenzugriffsschicht.
var (
// ErrAlertNotFound meldet eine nicht vorhandene Meldung.
ErrAlertNotFound = errors.New("die meldung wurde nicht gefunden")
// ErrAlertNotOpen meldet eine bereits erledigte Meldung.
ErrAlertNotOpen = errors.New("die meldung ist bereits erledigt")
// ErrChannelNotFound meldet einen nicht vorhandenen Kanal.
ErrChannelNotFound = errors.New("der benachrichtigungskanal wurde nicht gefunden")
)
// Store legt Meldungen in PostgreSQL ab.
type Store struct {
// connectionPool ist der Datenbankpool der Control Plane.
connectionPool *pgxpool.Pool
}
// NewStore erzeugt die Datenzugriffsschicht.
func NewStore(connectionPool *pgxpool.Pool) *Store {
return &Store{connectionPool: connectionPool}
}
// alertColumnList sind die Spalten einer Meldung in fester Reihenfolge.
const alertColumnList = `
id, rule_name, severity, status, fingerprint, title, message,
entity_type, entity_id, entity_name, details, occurrence_count,
first_seen_at, last_seen_at, acknowledged_at, acknowledged_by,
acknowledgement_note, resolved_at, resolved_by, resolution_note, created_at`
// rowScanner deckt QueryRow und Rows gemeinsam ab.
type rowScanner interface {
// Scan liest die Spalten einer Zeile.
Scan(destinations ...any) error
}
// scanAlert liest eine Meldungszeile.
func scanAlert(scanner rowScanner) (*Alert, error) {
var (
loadedAlert Alert
severityText string
statusText string
entityType *string
entityName *string
detailsJSON []byte
acknowledgementNote *string
resolutionNote *string
)
scanError := scanner.Scan(
&loadedAlert.ID, &loadedAlert.RuleName, &severityText, &statusText,
&loadedAlert.Fingerprint, &loadedAlert.Title, &loadedAlert.Message,
&entityType, &loadedAlert.EntityID, &entityName, &detailsJSON,
&loadedAlert.OccurrenceCount, &loadedAlert.FirstSeenAt, &loadedAlert.LastSeenAt,
&loadedAlert.AcknowledgedAt, &loadedAlert.AcknowledgedBy, &acknowledgementNote,
&loadedAlert.ResolvedAt, &loadedAlert.ResolvedBy, &resolutionNote,
&loadedAlert.CreatedAt,
)
if scanError != nil {
return nil, scanError
}
loadedAlert.Severity = Severity(severityText)
loadedAlert.Status = Status(statusText)
for target, source := range map[*string]*string{
&loadedAlert.EntityType: entityType,
&loadedAlert.EntityName: entityName,
&loadedAlert.AcknowledgementNote: acknowledgementNote,
&loadedAlert.ResolutionNote: resolutionNote,
} {
if source != nil {
*target = *source
}
}
if len(detailsJSON) > 0 {
_ = json.Unmarshal(detailsJSON, &loadedAlert.Details)
}
return &loadedAlert, nil
}
// Finding ist ein von einer Regel erkannter Zustand.
type Finding struct {
// RuleName benennt die erkennende Regel.
RuleName string
// Severity ist der Schweregrad.
Severity Severity
// Fingerprint kennzeichnet die Ursache eindeutig.
Fingerprint string
// Title ist die Ueberschrift.
Title string
// Message erklaert den Befund und die naechste Handlung.
Message string
// EntityType benennt die Art des betroffenen Gegenstands.
EntityType string
// EntityID ist der betroffene Gegenstand.
EntityID *uuid.UUID
// EntityName ist sein sprechender Name.
EntityName string
// Details traegt die Messwerte des Befunds.
Details map[string]any
}
// RaiseFinding legt eine Meldung an oder aktualisiert die bestehende.
//
// Der zweite Rueckgabewert meldet, ob die Meldung neu ist. Nur eine neue wird
// zugestellt: Sonst bekaeme der Bereitschaftsdienst alle fuenf Minuten dieselbe
// Mail, solange der Zustand anhaelt.
func (store *Store) RaiseFinding(raiseContext context.Context, finding Finding) (*Alert, bool, error) {
detailsJSON, encodeError := json.Marshal(finding.Details)
if encodeError != nil {
return nil, false, fmt.Errorf("die messwerte konnten nicht abgelegt werden: %w", encodeError)
}
// Ein einziges Statement statt Nachsehen-und-Schreiben: Zwischen einer
// Abfrage und einem Insert passt ein zweiter Control-Server, und dann
// entstuenden zwei Meldungen zur selben Ursache. Der Teilindex verhindert
// das, und ON CONFLICT macht daraus die gewuenschte Aktualisierung.
const upsertStatement = `
INSERT INTO alerts (rule_name, severity, fingerprint, title, message,
entity_type, entity_id, entity_name, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT (fingerprint) WHERE status IN ('open', 'acknowledged')
DO UPDATE SET
last_seen_at = now(),
occurrence_count = alerts.occurrence_count + 1,
-- Der Schweregrad darf steigen: Aus einer Warnung kann ein
-- kritischer Zustand werden, waehrend die Meldung offen ist.
severity = CASE
WHEN EXCLUDED.severity = 'critical' THEN 'critical'
WHEN EXCLUDED.severity = 'high' AND alerts.severity IN ('information', 'warning') THEN 'high'
ELSE alerts.severity
END,
message = EXCLUDED.message,
details = EXCLUDED.details
RETURNING ` + alertColumnList + `, (xmax = 0) AS ist_neu`
var (
raisedAlert *Alert
isNew bool
)
resultRows, queryError := store.connectionPool.Query(raiseContext, upsertStatement,
finding.RuleName, string(finding.Severity), finding.Fingerprint,
finding.Title, finding.Message, nullableString(finding.EntityType),
finding.EntityID, nullableString(finding.EntityName), detailsJSON)
if queryError != nil {
return nil, false, fmt.Errorf("die meldung konnte nicht angelegt werden: %w", queryError)
}
defer resultRows.Close()
if resultRows.Next() {
var (
loadedAlert Alert
severityText string
statusText string
entityType *string
entityName *string
detailsBytes []byte
acknowledgementNote *string
resolutionNote *string
)
if scanError := resultRows.Scan(
&loadedAlert.ID, &loadedAlert.RuleName, &severityText, &statusText,
&loadedAlert.Fingerprint, &loadedAlert.Title, &loadedAlert.Message,
&entityType, &loadedAlert.EntityID, &entityName, &detailsBytes,
&loadedAlert.OccurrenceCount, &loadedAlert.FirstSeenAt, &loadedAlert.LastSeenAt,
&loadedAlert.AcknowledgedAt, &loadedAlert.AcknowledgedBy, &acknowledgementNote,
&loadedAlert.ResolvedAt, &loadedAlert.ResolvedBy, &resolutionNote,
&loadedAlert.CreatedAt, &isNew,
); scanError != nil {
return nil, false, fmt.Errorf("die meldung konnte nicht gelesen werden: %w", scanError)
}
loadedAlert.Severity = Severity(severityText)
loadedAlert.Status = Status(statusText)
if entityType != nil {
loadedAlert.EntityType = *entityType
}
if entityName != nil {
loadedAlert.EntityName = *entityName
}
if len(detailsBytes) > 0 {
_ = json.Unmarshal(detailsBytes, &loadedAlert.Details)
}
raisedAlert = &loadedAlert
}
if rowsError := resultRows.Err(); rowsError != nil {
return nil, false, rowsError
}
if raisedAlert == nil {
return nil, false, errors.New("die meldung wurde weder angelegt noch aktualisiert")
}
return raisedAlert, isNew, nil
}
// nullableString wandelt eine leere Zeichenkette in NULL.
func nullableString(value string) *string {
if value == "" {
return nil
}
return &value
}
// ResolveVanishedFindings loest Meldungen einer Regel auf, deren Ursache fehlt.
//
// **Der wichtigste Vorgang des Meldungswesens.** Ohne ihn bleibt jede Meldung
// stehen, bis jemand sie wegklickt; nach zwei Wochen steht dort eine Liste
// erledigter Probleme, und die eine aktuelle geht darin unter.
//
// Aufgeloest wird nur, was diese Regel selbst erzeugt hat und was im aktuellen
// Durchgang **nicht** mehr auftrat.
func (store *Store) ResolveVanishedFindings(resolveContext context.Context, ruleName string, activeFingerprints []string) (int, error) {
const resolveStatement = `
UPDATE alerts
SET status = 'resolved',
resolved_at = now(),
resolution_note = 'Die Ursache besteht nicht mehr.'
WHERE rule_name = $1
AND status IN ('open', 'acknowledged')
AND NOT (fingerprint = ANY($2))`
// Ein leeres Array statt NULL: `x = ANY(NULL)` ergibt NULL, und die
// Bedingung waere nie wahr — es wuerde nichts aufgeloest.
fingerprintList := activeFingerprints
if fingerprintList == nil {
fingerprintList = []string{}
}
commandTag, execError := store.connectionPool.Exec(resolveContext, resolveStatement,
ruleName, fingerprintList)
if execError != nil {
return 0, fmt.Errorf("erledigte meldungen konnten nicht aufgeloest werden: %w", execError)
}
return int(commandTag.RowsAffected()), nil
}
// AlertFilter schraenkt die Liste der Meldungen ein.
type AlertFilter struct {
// Status beschraenkt auf einen Bearbeitungszustand.
Status string
// Severity beschraenkt auf einen Schweregrad.
Severity string
// OnlyActive zeigt nur unerledigte Meldungen.
OnlyActive bool
// Page ist die angeforderte Seite.
Page int
// PageSize ist die Seitengroesse.
PageSize int
}
// ListAlerts liefert eine Seite von Meldungen.
func (store *Store) ListAlerts(listContext context.Context, filter AlertFilter) ([]Alert, int, error) {
if filter.Page < 1 {
filter.Page = 1
}
if filter.PageSize < 1 || filter.PageSize > 200 {
filter.PageSize = 50
}
const selectStatement = `
SELECT ` + alertColumnList + `, COUNT(*) OVER () AS total_count
FROM alerts
WHERE ($1::text IS NULL OR status = $1)
AND ($2::text IS NULL OR severity = $2)
AND (NOT $3::bool OR status IN ('open', 'acknowledged'))
ORDER BY
-- Unerledigte zuerst, danach nach Schweregrad und Aktualitaet. Eine
-- rein zeitliche Sortierung schoebe die kritische Meldung von gestern
-- unter die Auskunft von heute Morgen.
CASE status WHEN 'open' THEN 0 WHEN 'acknowledged' THEN 1 ELSE 2 END,
CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'warning' THEN 2 ELSE 3 END,
last_seen_at DESC
LIMIT $4 OFFSET $5`
alertRows, queryError := store.connectionPool.Query(listContext, selectStatement,
nullableString(filter.Status), nullableString(filter.Severity), filter.OnlyActive,
filter.PageSize, (filter.Page-1)*filter.PageSize)
if queryError != nil {
return nil, 0, fmt.Errorf("die meldungen konnten nicht gelesen werden: %w", queryError)
}
defer alertRows.Close()
loadedAlerts := make([]Alert, 0, filter.PageSize)
var totalCount int
for alertRows.Next() {
var (
loadedAlert Alert
severityText string
statusText string
entityType *string
entityName *string
detailsBytes []byte
acknowledgementNote *string
resolutionNote *string
)
if scanError := alertRows.Scan(
&loadedAlert.ID, &loadedAlert.RuleName, &severityText, &statusText,
&loadedAlert.Fingerprint, &loadedAlert.Title, &loadedAlert.Message,
&entityType, &loadedAlert.EntityID, &entityName, &detailsBytes,
&loadedAlert.OccurrenceCount, &loadedAlert.FirstSeenAt, &loadedAlert.LastSeenAt,
&loadedAlert.AcknowledgedAt, &loadedAlert.AcknowledgedBy, &acknowledgementNote,
&loadedAlert.ResolvedAt, &loadedAlert.ResolvedBy, &resolutionNote,
&loadedAlert.CreatedAt, &totalCount,
); scanError != nil {
return nil, 0, fmt.Errorf("eine meldung konnte nicht gelesen werden: %w", scanError)
}
loadedAlert.Severity = Severity(severityText)
loadedAlert.Status = Status(statusText)
if entityType != nil {
loadedAlert.EntityType = *entityType
}
if entityName != nil {
loadedAlert.EntityName = *entityName
}
if acknowledgementNote != nil {
loadedAlert.AcknowledgementNote = *acknowledgementNote
}
if resolutionNote != nil {
loadedAlert.ResolutionNote = *resolutionNote
}
if len(detailsBytes) > 0 {
_ = json.Unmarshal(detailsBytes, &loadedAlert.Details)
}
loadedAlerts = append(loadedAlerts, loadedAlert)
}
return loadedAlerts, totalCount, alertRows.Err()
}
// GetAlert liest eine Meldung.
func (store *Store) GetAlert(readContext context.Context, alertIdentifier uuid.UUID) (*Alert, error) {
const selectStatement = `SELECT ` + alertColumnList + ` FROM alerts WHERE id = $1`
loadedAlert, scanError := scanAlert(store.connectionPool.QueryRow(readContext, selectStatement, alertIdentifier))
if errors.Is(scanError, pgx.ErrNoRows) {
return nil, fmt.Errorf("%w: %s", ErrAlertNotFound, alertIdentifier)
}
if scanError != nil {
return nil, fmt.Errorf("die meldung konnte nicht gelesen werden: %w", scanError)
}
return loadedAlert, nil
}
// AcknowledgeAlert nimmt eine Meldung zur Kenntnis.
//
// Die Meldung bleibt **offen**. „Bestaetigen" heisst „ich weiss davon", nicht
// „weg damit": Waere es dasselbe, verschwaende der Zustand aus der Uebersicht,
// obwohl er weiterbesteht.
func (store *Store) AcknowledgeAlert(acknowledgeContext context.Context, alertIdentifier uuid.UUID, acknowledgedBy uuid.UUID, note string) (*Alert, error) {
const updateStatement = `
UPDATE alerts
SET status = 'acknowledged', acknowledged_at = now(),
acknowledged_by = $2, acknowledgement_note = $3
WHERE id = $1 AND status = 'open'
RETURNING ` + alertColumnList
updatedAlert, scanError := scanAlert(store.connectionPool.QueryRow(acknowledgeContext,
updateStatement, alertIdentifier, acknowledgedBy, nullableString(note)))
if errors.Is(scanError, pgx.ErrNoRows) {
return nil, fmt.Errorf("%w: %s", ErrAlertNotOpen, alertIdentifier)
}
if scanError != nil {
return nil, fmt.Errorf("die meldung konnte nicht bestaetigt werden: %w", scanError)
}
return updatedAlert, nil
}
// ResolveAlert erledigt eine Meldung von Hand.
//
// Der Weg ist fuer den Fall gedacht, dass die Anlage die Ursache nicht selbst
// als verschwunden erkennt. Er wird mit Verursacher festgehalten: Eine von
// Hand geschlossene Meldung ist etwas anderes als eine verschwundene Ursache.
func (store *Store) ResolveAlert(resolveContext context.Context, alertIdentifier uuid.UUID, resolvedBy uuid.UUID, note string) (*Alert, error) {
const updateStatement = `
UPDATE alerts
SET status = 'resolved', resolved_at = now(), resolved_by = $2, resolution_note = $3
WHERE id = $1 AND status IN ('open', 'acknowledged')
RETURNING ` + alertColumnList
updatedAlert, scanError := scanAlert(store.connectionPool.QueryRow(resolveContext,
updateStatement, alertIdentifier, resolvedBy, nullableString(note)))
if errors.Is(scanError, pgx.ErrNoRows) {
return nil, fmt.Errorf("%w: %s", ErrAlertNotOpen, alertIdentifier)
}
if scanError != nil {
return nil, fmt.Errorf("die meldung konnte nicht aufgeloest werden: %w", scanError)
}
return updatedAlert, nil
}
// AlertSummary fasst die Meldungslage zusammen.
type AlertSummary struct {
// OpenCount ist die Zahl unbearbeiteter Meldungen.
OpenCount int `json:"open_count"`
// AcknowledgedCount ist die Zahl zur Kenntnis genommener Meldungen.
AcknowledgedCount int `json:"acknowledged_count"`
// CriticalCount ist die Zahl unerledigter kritischer Meldungen.
CriticalCount int `json:"critical_count"`
// HighCount ist die Zahl unerledigter ernster Meldungen.
HighCount int `json:"high_count"`
// ResolvedLastDay ist die Zahl der in 24 Stunden erledigten Meldungen.
ResolvedLastDay int `json:"resolved_last_day"`
}
// Summary erhebt die Meldungslage.
func (store *Store) Summary(summaryContext context.Context) (*AlertSummary, error) {
const summaryStatement = `
SELECT
count(*) FILTER (WHERE status = 'open'),
count(*) FILTER (WHERE status = 'acknowledged'),
count(*) FILTER (WHERE status IN ('open', 'acknowledged') AND severity = 'critical'),
count(*) FILTER (WHERE status IN ('open', 'acknowledged') AND severity = 'high'),
count(*) FILTER (WHERE status = 'resolved' AND resolved_at > now() - interval '24 hours')
FROM alerts`
var summary AlertSummary
if scanError := store.connectionPool.QueryRow(summaryContext, summaryStatement).Scan(
&summary.OpenCount, &summary.AcknowledgedCount, &summary.CriticalCount,
&summary.HighCount, &summary.ResolvedLastDay); scanError != nil {
return nil, fmt.Errorf("die meldungslage konnte nicht erhoben werden: %w", scanError)
}
return &summary, nil
}
// PruneResolvedAlerts entfernt lange erledigte Meldungen.
func (store *Store) PruneResolvedAlerts(pruneContext context.Context, retentionPeriod time.Duration) (int64, error) {
const deleteStatement = `
DELETE FROM alerts
WHERE status = 'resolved' AND resolved_at < now() - $1::interval`
commandTag, execError := store.connectionPool.Exec(pruneContext, deleteStatement,
retentionPeriod.String())
if execError != nil {
return 0, fmt.Errorf("erledigte meldungen konnten nicht entfernt werden: %w", execError)
}
return commandTag.RowsAffected(), nil
}