syncova-backup/packages/alerting/loop.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

271 lines
8.4 KiB
Go

package alerting
import (
"context"
"errors"
"log/slog"
"time"
"github.com/syncova/syncova/packages/platform/crypto"
"github.com/syncova/syncova/packages/platform/logging"
)
// LoopOptions steuern die Auswertungsschleife.
type LoopOptions struct {
// Interval ist der Abstand zweier Auswertungen.
//
// Eine Minute: Kurz genug, damit ein Ausfall zeitnah auffaellt, lang genug,
// dass die Datenbank es nicht merkt. Die Auswertung liest nur.
Interval time.Duration
// ResolvedRetention ist die Aufbewahrungsfrist erledigter Meldungen.
ResolvedRetention time.Duration
// NotificationsEnabled schaltet die Zustellung ein.
//
// Getrennt von der Auswertung: Eine Anlage soll Meldungen erzeugen koennen,
// ohne dass jemand nachts eine Mail bekommt — etwa waehrend der Einrichtung.
NotificationsEnabled bool
}
// Standardwerte der Schleife.
const (
// defaultEvaluationInterval ist der Standardabstand der Auswertung.
defaultEvaluationInterval = time.Minute
// defaultResolvedRetention ist die Standardaufbewahrung erledigter Meldungen.
//
// Dreissig Tage: lang genug fuer die Frage „was war letzten Monat los?",
// kurz genug, dass die Tabelle nicht waechst.
defaultResolvedRetention = 30 * 24 * time.Hour
)
// applyDefaults fuellt fehlende Werte.
func (options *LoopOptions) applyDefaults() {
if options.Interval <= 0 {
options.Interval = defaultEvaluationInterval
}
if options.ResolvedRetention <= 0 {
options.ResolvedRetention = defaultResolvedRetention
}
}
// Loop wertet die Regeln regelmaessig aus und stellt neue Meldungen zu.
type Loop struct {
// store legt die Meldungen ab.
store *Store
// evaluator prueft die Regeln.
evaluator *Evaluator
// dispatcher stellt Meldungen zu.
dispatcher Deliverer
// secretStore entschluesselt die Zugangsgeheimnisse der Kanaele.
secretStore crypto.SecretStore
// options sind die Einstellungen.
options LoopOptions
// logger protokolliert den Verlauf.
logger *slog.Logger
}
// NewLoop erzeugt die Auswertungsschleife.
func NewLoop(store *Store, evaluator *Evaluator, dispatcher Deliverer, secretStore crypto.SecretStore, options LoopOptions, baseLogger *slog.Logger) (*Loop, error) {
if store == nil {
return nil, errors.New("die auswertung braucht eine datenzugriffsschicht")
}
if evaluator == nil {
return nil, errors.New("die auswertung braucht eine regelpruefung")
}
options.applyDefaults()
return &Loop{
store: store,
evaluator: evaluator,
dispatcher: dispatcher,
secretStore: secretStore,
options: options,
logger: logging.WithComponent(baseLogger, "alerting"),
}, nil
}
// Run laeuft bis zum Abbruch des Kontexts.
func (loop *Loop) Run(runContext context.Context) error {
loop.logger.Info("die meldungsauswertung beginnt",
slog.Duration("abstand", loop.options.Interval),
slog.Int("regeln", AvailableRuleCount()),
slog.Bool("zustellung", loop.options.NotificationsEnabled))
// Der erste Durchgang laeuft sofort. Sonst blieben nach einem Neustart alle
// bestehenden Zustaende eine Minute lang unbemerkt — und ein Neustart
// geschieht oft genau dann, wenn etwas nicht stimmt.
loop.evaluateOnce(runContext)
evaluationTicker := time.NewTicker(loop.options.Interval)
defer evaluationTicker.Stop()
pruneTicker := time.NewTicker(24 * time.Hour)
defer pruneTicker.Stop()
for {
select {
case <-runContext.Done():
loop.logger.Info("die meldungsauswertung wird beendet")
return nil
case <-evaluationTicker.C:
loop.evaluateOnce(runContext)
case <-pruneTicker.C:
loop.pruneResolved(runContext)
}
}
}
// evaluateOnce wertet alle Regeln einmal aus.
func (loop *Loop) evaluateOnce(evaluateContext context.Context) {
ruleResults, evaluateError := loop.evaluator.EvaluateAll(evaluateContext)
if evaluateError != nil {
loop.logger.Error("die regeln liessen sich nicht auswerten",
slog.String("grund", evaluateError.Error()))
return
}
for _, ruleResult := range ruleResults {
loop.applyRuleResult(evaluateContext, ruleResult)
}
}
// applyRuleResult schreibt die Befunde einer Regel fort.
func (loop *Loop) applyRuleResult(applyContext context.Context, ruleResult RuleResult) {
activeFingerprints := make([]string, 0, len(ruleResult.Findings))
for _, finding := range ruleResult.Findings {
activeFingerprints = append(activeFingerprints, finding.Fingerprint)
raisedAlert, isNew, raiseError := loop.store.RaiseFinding(applyContext, finding)
if raiseError != nil {
loop.logger.Error("eine meldung konnte nicht angelegt werden",
slog.String("regel", finding.RuleName),
slog.String("grund", raiseError.Error()))
continue
}
if !isNew {
// Eine bestehende Meldung wurde nur aktualisiert. Sie erneut
// zuzustellen hiesse, alle fuenf Minuten dieselbe Mail zu schicken,
// solange der Zustand anhaelt.
continue
}
loop.logger.Warn("neue meldung",
slog.String("regel", raisedAlert.RuleName),
slog.String("schweregrad", string(raisedAlert.Severity)),
slog.String("titel", raisedAlert.Title),
slog.String("meldung_id", raisedAlert.ID.String()))
if loop.options.NotificationsEnabled {
loop.deliverAlert(applyContext, *raisedAlert)
}
}
// Was nicht mehr zutrifft, wird aufgeloest. **Der wichtigste Schritt:** Ohne
// ihn bleibt jede Meldung stehen, bis jemand sie wegklickt — und nach zwei
// Wochen geht die aktuelle in einer Liste erledigter Probleme unter.
resolvedCount, resolveError := loop.store.ResolveVanishedFindings(applyContext,
ruleResult.RuleName, activeFingerprints)
if resolveError != nil {
loop.logger.Error("erledigte meldungen liessen sich nicht aufloesen",
slog.String("regel", ruleResult.RuleName),
slog.String("grund", resolveError.Error()))
return
}
if resolvedCount > 0 {
loop.logger.Info("meldungen haben sich von selbst erledigt",
slog.String("regel", ruleResult.RuleName),
slog.Int("anzahl", resolvedCount))
}
}
// deliverAlert stellt eine Meldung ueber alle passenden Kanaele zu.
func (loop *Loop) deliverAlert(deliverContext context.Context, alert Alert) {
if loop.dispatcher == nil {
return
}
matchingChannels, listError := loop.store.EnabledChannelsFor(deliverContext, alert.Severity)
if listError != nil {
loop.logger.Error("die benachrichtigungskanaele liessen sich nicht lesen",
slog.String("grund", listError.Error()))
return
}
for _, channel := range matchingChannels {
alreadyDelivered, checkError := loop.store.WasDelivered(deliverContext, alert.ID, channel.ID)
if checkError != nil {
loop.logger.Error("die zustellung liess sich nicht pruefen",
slog.String("kanal", channel.Name),
slog.String("grund", checkError.Error()))
continue
}
if alreadyDelivered {
continue
}
channelSecret, secretError := loop.store.LoadChannelSecret(deliverContext, channel.ID, loop.secretStore)
if secretError != nil {
loop.logger.Error("das zugangsgeheimnis des kanals liess sich nicht lesen",
slog.String("kanal", channel.Name),
slog.String("grund", secretError.Error()))
continue
}
deliveryError := loop.dispatcher.Deliver(deliverContext, channel, channelSecret, alert)
if recordError := loop.store.RecordDelivery(deliverContext, alert.ID, channel.ID,
deliveryError); recordError != nil {
loop.logger.Error("die zustellung konnte nicht vermerkt werden",
slog.String("kanal", channel.Name),
slog.String("grund", recordError.Error()))
}
if deliveryError != nil {
// Eine fehlgeschlagene Zustellung wird protokolliert und am Kanal
// vermerkt, aber nicht wiederholt: Ein nicht erreichbarer Empfaenger
// bleibt es meist auch beim zweiten Versuch, und die Schleife soll
// nicht daran haengen. Der Kanalzustand macht das Problem sichtbar.
loop.logger.Error("eine meldung konnte nicht zugestellt werden",
slog.String("kanal", channel.Name),
slog.String("meldung", alert.Title),
slog.String("grund", deliveryError.Error()))
continue
}
loop.logger.Info("meldung zugestellt",
slog.String("kanal", channel.Name),
slog.String("meldung_id", alert.ID.String()))
}
}
// pruneResolved entfernt lange erledigte Meldungen.
func (loop *Loop) pruneResolved(pruneContext context.Context) {
removedCount, pruneError := loop.store.PruneResolvedAlerts(pruneContext, loop.options.ResolvedRetention)
if pruneError != nil {
loop.logger.Error("erledigte meldungen liessen sich nicht entfernen",
slog.String("grund", pruneError.Error()))
return
}
if removedCount > 0 {
loop.logger.Info("erledigte meldungen entfernt", slog.Int64("anzahl", removedCount))
}
}