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>
271 lines
8.4 KiB
Go
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))
|
|
}
|
|
}
|