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

372 lines
13 KiB
Go

package alerting
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/smtp"
"strings"
"time"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/platform/netguard"
)
// ChannelType ist die Art einer Zustellung.
type ChannelType string
const (
// ChannelEmail stellt per SMTP zu.
ChannelEmail ChannelType = "email"
// ChannelWebhook stellt per HTTP-POST zu.
ChannelWebhook ChannelType = "webhook"
)
// Channel ist ein Benachrichtigungskanal.
type Channel struct {
// ID ist der oeffentliche Bezeichner.
ID uuid.UUID `json:"id"`
// Name ist die sprechende Bezeichnung.
Name string `json:"name"`
// ChannelType ist die Art der Zustellung.
ChannelType ChannelType `json:"channel_type"`
// Enabled meldet einen aktiven Kanal.
Enabled bool `json:"enabled"`
// MinimumSeverity ist der niedrigste zugestellte Schweregrad.
MinimumSeverity Severity `json:"minimum_severity"`
// Configuration traegt die Zustelldaten ohne Geheimnisse.
Configuration map[string]any `json:"configuration"`
// LastDeliveryAt ist der Zeitpunkt der letzten Zustellung in UTC.
LastDeliveryAt *time.Time `json:"last_delivery_at,omitempty"`
// LastDeliveryError ist der Grund des letzten Fehlschlags.
//
// Ein Kanal, der seit Wochen nichts zustellt, ist genauso schlimm wie eine
// fehlende Meldung — und ohne dieses Feld faellt es niemandem auf.
LastDeliveryError string `json:"last_delivery_error,omitempty"`
// ConsecutiveFailures zaehlt die Fehlschlaege in Folge.
ConsecutiveFailures int `json:"consecutive_failures"`
// CreatedAt ist der Anlagezeitpunkt in UTC.
CreatedAt time.Time `json:"created_at"`
}
// IsHealthy meldet einen zustellfaehigen Kanal.
func (channel *Channel) IsHealthy() bool {
return channel.Enabled && channel.ConsecutiveFailures == 0
}
// deliveryTimeout begrenzt eine einzelne Zustellung.
//
// Ohne Grenze bliebe die Auswertungsschleife an einem nicht antwortenden
// Empfaenger haengen — und die uebrigen Meldungen erreichten niemanden.
const deliveryTimeout = 15 * time.Second
// Deliverer stellt eine Meldung ueber einen Kanal zu.
type Deliverer interface {
// Deliver stellt die Meldung zu.
Deliver(deliveryContext context.Context, channel Channel, secret string, alert Alert) error
}
// DeliveryDispatcher waehlt den passenden Zusteller.
type DeliveryDispatcher struct {
// emailDeliverer stellt per SMTP zu.
emailDeliverer Deliverer
// webhookDeliverer stellt per HTTP zu.
webhookDeliverer Deliverer
}
// NewDeliveryDispatcher erzeugt die Zustellung.
//
// allowInternalTargets hebt den Schutz gegen interne Zieladressen auf. Er
// gehoert in die Konfiguration eines Betreibers, der einen Meldedienst im
// eigenen Netz betreibt — und nirgendwo sonst.
func NewDeliveryDispatcher(allowInternalTargets bool) *DeliveryDispatcher {
addressGuard := netguard.NewGuard(allowInternalTargets)
return &DeliveryDispatcher{
emailDeliverer: &EmailDeliverer{addressGuard: addressGuard},
webhookDeliverer: &WebhookDeliverer{
httpClient: &http.Client{Timeout: deliveryTimeout},
addressGuard: addressGuard,
},
}
}
// Deliver stellt eine Meldung ueber den angegebenen Kanal zu.
func (dispatcher *DeliveryDispatcher) Deliver(deliveryContext context.Context, channel Channel, secret string, alert Alert) error {
switch channel.ChannelType {
case ChannelEmail:
return dispatcher.emailDeliverer.Deliver(deliveryContext, channel, secret, alert)
case ChannelWebhook:
return dispatcher.webhookDeliverer.Deliver(deliveryContext, channel, secret, alert)
default:
return fmt.Errorf("die kanalart %q ist unbekannt", channel.ChannelType)
}
}
// EmailDeliverer stellt Meldungen per SMTP zu.
type EmailDeliverer struct {
// addressGuard wehrt interne Zieladressen ab (SSRF, Phase 19).
//
// Auch ein SMTP-Server ist ein Ziel: Wer ihn auf 127.0.0.1:11211 richtet,
// spricht mit einem Zwischenspeicher statt mit einem Mailserver — und
// bekommt dessen Antwort ueber die Fehlermeldung zurueck.
addressGuard *netguard.Guard
}
// Deliver verschickt eine Meldung als E-Mail.
func (deliverer *EmailDeliverer) Deliver(deliveryContext context.Context, channel Channel, secret string, alert Alert) error {
smtpHost, hasHost := channel.Configuration["host"].(string)
if !hasHost || smtpHost == "" {
return errors.New("dem kanal fehlt die angabe host")
}
if deliverer.addressGuard != nil {
if guardError := deliverer.addressGuard.CheckHost(deliveryContext, smtpHost); guardError != nil {
return guardError
}
}
smtpPort := 25
if portValue, hasPort := channel.Configuration["port"].(float64); hasPort {
smtpPort = int(portValue)
}
senderAddress, hasSender := channel.Configuration["from"].(string)
if !hasSender || senderAddress == "" {
return errors.New("dem kanal fehlt die absenderadresse")
}
recipientAddresses := readStringList(channel.Configuration["to"])
if len(recipientAddresses) == 0 {
return errors.New("dem kanal fehlt mindestens ein empfaenger")
}
messageBody := buildEmailMessage(senderAddress, recipientAddresses, alert)
serverAddress := net.JoinHostPort(smtpHost, fmt.Sprintf("%d", smtpPort))
var smtpAuthentication smtp.Auth
if userName, hasUser := channel.Configuration["username"].(string); hasUser && userName != "" {
// PlainAuth verweigert sich auf unverschluesselten Verbindungen von
// selbst — das ist gewollt und wird nicht umgangen: Ein Passwort im
// Klartext ueber das Netz zu schicken waere schlimmer als keine
// Benachrichtigung.
smtpAuthentication = smtp.PlainAuth("", userName, secret, smtpHost)
}
// Ein eigener Kanal fuer das Ergebnis: net/smtp kennt keinen Kontext, und
// ohne diese Umhuellung liefe ein haengender Server bis zum
// Betriebssystem-Timeout.
deliveryResult := make(chan error, 1)
go func() {
deliveryResult <- smtp.SendMail(serverAddress, smtpAuthentication,
senderAddress, recipientAddresses, messageBody)
}()
select {
case sendError := <-deliveryResult:
if sendError != nil {
return fmt.Errorf("die e-mail wurde nicht angenommen: %w", sendError)
}
return nil
case <-time.After(deliveryTimeout):
return fmt.Errorf("der smtp-server %s antwortete nicht innerhalb von %s",
serverAddress, deliveryTimeout)
case <-deliveryContext.Done():
return deliveryContext.Err()
}
}
// buildEmailMessage baut die Nachricht einer Meldung.
func buildEmailMessage(senderAddress string, recipientAddresses []string, alert Alert) []byte {
var messageBuilder bytes.Buffer
// Der Schweregrad steht im Betreff, nicht nur im Rumpf: Wer nachts auf sein
// Telefon sieht, liest die Betreffzeile und sonst nichts.
messageBuilder.WriteString(fmt.Sprintf("From: %s\r\n", senderAddress))
messageBuilder.WriteString(fmt.Sprintf("To: %s\r\n", strings.Join(recipientAddresses, ", ")))
messageBuilder.WriteString(fmt.Sprintf("Subject: [Syncova %s] %s\r\n",
strings.ToUpper(string(alert.Severity)), alert.Title))
messageBuilder.WriteString("MIME-Version: 1.0\r\n")
messageBuilder.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
messageBuilder.WriteString("\r\n")
messageBuilder.WriteString(alert.Message)
messageBuilder.WriteString("\r\n\r\n")
messageBuilder.WriteString(fmt.Sprintf("Regel: %s\r\n", alert.RuleName))
messageBuilder.WriteString(fmt.Sprintf("Schweregrad: %s\r\n", alert.Severity))
if alert.EntityName != "" {
messageBuilder.WriteString(fmt.Sprintf("Betroffen: %s (%s)\r\n",
alert.EntityName, alert.EntityType))
}
messageBuilder.WriteString(fmt.Sprintf("Erstmals: %s\r\n",
alert.FirstSeenAt.Format(time.RFC3339)))
// Die Meldungskennung gehoert in die Mail: Mit ihr laesst sich der Vorgang
// in der Oberflaeche wiederfinden, ohne zu suchen.
messageBuilder.WriteString(fmt.Sprintf("Meldung: %s\r\n", alert.ID))
return messageBuilder.Bytes()
}
// readStringList liest eine Liste von Zeichenketten aus der Konfiguration.
func readStringList(configurationValue any) []string {
rawList, isList := configurationValue.([]any)
if !isList {
if singleValue, isString := configurationValue.(string); isString && singleValue != "" {
return []string{singleValue}
}
return nil
}
stringList := make([]string, 0, len(rawList))
for _, rawValue := range rawList {
if stringValue, isString := rawValue.(string); isString && stringValue != "" {
stringList = append(stringList, stringValue)
}
}
return stringList
}
// WebhookDeliverer stellt Meldungen per HTTP-POST zu.
type WebhookDeliverer struct {
// httpClient fuehrt die Anfrage aus.
httpClient *http.Client
// addressGuard wehrt interne Zieladressen ab (SSRF, Phase 19).
//
// Er darf nil sein — dann findet keine Pruefung statt. Das ist
// ausschliesslich fuer Tests gedacht, die gegen einen lokalen Testserver
// zustellen; im Betrieb setzt NewDeliveryDispatcher ihn immer.
addressGuard *netguard.Guard
}
// webhookPayload ist der Rumpf einer Webhook-Zustellung.
//
// Bewusst eine eigene Struktur und nicht die Meldung selbst: Der Vertrag nach
// aussen darf sich nicht mitaendern, wenn ein internes Feld umbenannt wird.
type webhookPayload struct {
// AlertID ist die Meldungskennung.
AlertID uuid.UUID `json:"alert_id"`
// Rule benennt die ausloesende Regel.
Rule string `json:"rule"`
// Severity ist der Schweregrad.
Severity Severity `json:"severity"`
// Status ist der Bearbeitungszustand.
Status Status `json:"status"`
// Title ist die Ueberschrift.
Title string `json:"title"`
// Message erklaert den Befund.
Message string `json:"message"`
// EntityType benennt die Art des betroffenen Gegenstands.
EntityType string `json:"entity_type,omitempty"`
// EntityName ist sein sprechender Name.
EntityName string `json:"entity_name,omitempty"`
// OccurrenceCount zaehlt das Auftreten.
OccurrenceCount int `json:"occurrence_count"`
// FirstSeenAt ist das erste Auftreten in UTC.
FirstSeenAt time.Time `json:"first_seen_at"`
// Details traegt die Messwerte.
Details map[string]any `json:"details,omitempty"`
}
// Deliver schickt eine Meldung an einen Webhook.
func (deliverer *WebhookDeliverer) Deliver(deliveryContext context.Context, channel Channel, secret string, alert Alert) error {
targetURL, hasURL := channel.Configuration["url"].(string)
if !hasURL || targetURL == "" {
return errors.New("dem kanal fehlt die zieladresse")
}
// Nur HTTPS, sofern nicht ausdruecklich anders gewuenscht: Eine Meldung
// ueber unverschluesseltes HTTP verraet einem Mitleser, welche Anlage
// gerade nicht gesichert wird.
allowsPlainHTTP, _ := channel.Configuration["allow_insecure"].(bool)
if !strings.HasPrefix(targetURL, "https://") && !allowsPlainHTTP {
return errors.New("die zieladresse ist nicht https; setzen Sie allow_insecure, " +
"wenn das beabsichtigt ist")
}
// Die Adresspruefung sitzt hier ein **zweites** Mal — beim Anlegen des
// Kanals fand sie bereits statt. Der Grund ist kein Uebereifer: Ein Name
// kann zwischen beiden Zeitpunkten auf eine andere Adresse zeigen. Ein
// DNS-Eintrag, der beim Pruefen oeffentlich und beim Zustellen intern
// auffloest, ist der uebliche Weg um eine einmalige Pruefung herum.
if deliverer.addressGuard != nil {
if guardError := deliverer.addressGuard.CheckURL(deliveryContext, targetURL); guardError != nil {
return guardError
}
}
payloadBytes, encodeError := json.Marshal(webhookPayload{
AlertID: alert.ID,
Rule: alert.RuleName,
Severity: alert.Severity,
Status: alert.Status,
Title: alert.Title,
Message: alert.Message,
EntityType: alert.EntityType,
EntityName: alert.EntityName,
OccurrenceCount: alert.OccurrenceCount,
FirstSeenAt: alert.FirstSeenAt,
Details: alert.Details,
})
if encodeError != nil {
return fmt.Errorf("die nachricht konnte nicht gebildet werden: %w", encodeError)
}
requestContext, cancelRequest := context.WithTimeout(deliveryContext, deliveryTimeout)
defer cancelRequest()
httpRequest, requestError := http.NewRequestWithContext(requestContext, http.MethodPost,
targetURL, bytes.NewReader(payloadBytes))
if requestError != nil {
return fmt.Errorf("die anfrage konnte nicht gebildet werden: %w", requestError)
}
httpRequest.Header.Set("Content-Type", "application/json")
httpRequest.Header.Set("User-Agent", "Syncova")
if secret != "" {
// Der Empfaenger soll pruefen koennen, dass die Meldung von dieser
// Anlage stammt. Ein Bearer-Token ist der einfachste Weg, der bei jedem
// Empfaenger funktioniert.
httpRequest.Header.Set("Authorization", "Bearer "+secret)
}
httpResponse, sendError := deliverer.httpClient.Do(httpRequest)
if sendError != nil {
return fmt.Errorf("der empfaenger war nicht erreichbar: %w", sendError)
}
defer func() { _ = httpResponse.Body.Close() }()
if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 {
return fmt.Errorf("der empfaenger antwortete mit %d", httpResponse.StatusCode)
}
return nil
}
// insecureTLSConfiguration ist absichtlich nicht vorhanden.
//
// Wer eine Meldung an einen Empfaenger mit selbstsigniertem Zertifikat schicken
// will, hinterlegt dessen Wurzelzertifikat im System. Ein Schalter
// „Zertifikat egal" waere die bequeme Loesung und der Weg, ueber den ein
// Angreifer erfaehrt, welche Anlage gerade unbewacht ist.
var _ = tls.Config{}