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>
372 lines
13 KiB
Go
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{}
|