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>
384 lines
12 KiB
Go
384 lines
12 KiB
Go
package alerting
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// buildTestAlert baut eine Meldung fuer die Zustellung.
|
|
func buildTestAlert() Alert {
|
|
return Alert{
|
|
ID: uuid.New(),
|
|
RuleName: RuleBackupFailure,
|
|
Severity: SeverityCritical,
|
|
Status: StatusOpen,
|
|
Title: "Sicherung gescheitert: Dateiserver",
|
|
Message: "Der letzte Lauf ist gescheitert. Pruefen Sie die Ursache.",
|
|
EntityType: "backup_job",
|
|
EntityName: "Dateiserver",
|
|
OccurrenceCount: 1,
|
|
FirstSeenAt: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
// fakeSMTPServer nimmt eine Nachricht entgegen und gibt sie zurueck.
|
|
//
|
|
// Ein echter Server statt eines Attrappen-Interfaces: Nur so ist geprueft, dass
|
|
// die Nachricht tatsaechlich das SMTP-Gespraech uebersteht — Zeilenenden,
|
|
// Punktbehandlung und Kopfzeilen eingeschlossen. Ein Zusteller, der nur gegen
|
|
// eine Attrappe geprueft ist, scheitert beim ersten echten Server.
|
|
type fakeSMTPServer struct {
|
|
// listener nimmt Verbindungen an.
|
|
listener net.Listener
|
|
// mutex schuetzt die empfangenen Nachrichten.
|
|
mutex sync.Mutex
|
|
// receivedMessages haelt die empfangenen Nachrichten.
|
|
receivedMessages []string
|
|
// receivedRecipients haelt die genannten Empfaenger.
|
|
receivedRecipients []string
|
|
}
|
|
|
|
// startFakeSMTPServer startet einen SMTP-Empfaenger auf einem freien Port.
|
|
func startFakeSMTPServer(testInstance *testing.T) *fakeSMTPServer {
|
|
testInstance.Helper()
|
|
|
|
networkListener, listenError := net.Listen("tcp", "127.0.0.1:0")
|
|
if listenError != nil {
|
|
testInstance.Fatalf("der Testserver liess sich nicht starten: %v", listenError)
|
|
}
|
|
|
|
server := &fakeSMTPServer{listener: networkListener}
|
|
|
|
go server.acceptConnections()
|
|
|
|
testInstance.Cleanup(func() { _ = networkListener.Close() })
|
|
|
|
return server
|
|
}
|
|
|
|
// address liefert Host und Port des Servers.
|
|
func (server *fakeSMTPServer) address() (string, int) {
|
|
tcpAddress := server.listener.Addr().(*net.TCPAddr)
|
|
|
|
return "127.0.0.1", tcpAddress.Port
|
|
}
|
|
|
|
// messages liefert die empfangenen Nachrichten.
|
|
func (server *fakeSMTPServer) messages() []string {
|
|
server.mutex.Lock()
|
|
defer server.mutex.Unlock()
|
|
|
|
return append([]string(nil), server.receivedMessages...)
|
|
}
|
|
|
|
// recipients liefert die genannten Empfaenger.
|
|
func (server *fakeSMTPServer) recipients() []string {
|
|
server.mutex.Lock()
|
|
defer server.mutex.Unlock()
|
|
|
|
return append([]string(nil), server.receivedRecipients...)
|
|
}
|
|
|
|
// acceptConnections bedient eingehende Verbindungen.
|
|
func (server *fakeSMTPServer) acceptConnections() {
|
|
for {
|
|
clientConnection, acceptError := server.listener.Accept()
|
|
if acceptError != nil {
|
|
return
|
|
}
|
|
|
|
go server.handleConnection(clientConnection)
|
|
}
|
|
}
|
|
|
|
// handleConnection fuehrt ein einzelnes SMTP-Gespraech.
|
|
func (server *fakeSMTPServer) handleConnection(clientConnection net.Conn) {
|
|
defer func() { _ = clientConnection.Close() }()
|
|
|
|
connectionReader := bufio.NewReader(clientConnection)
|
|
|
|
writeLine := func(responseLine string) {
|
|
_, _ = fmt.Fprintf(clientConnection, "%s\r\n", responseLine)
|
|
}
|
|
|
|
writeLine("220 syncova-test ESMTP")
|
|
|
|
var messageBuilder strings.Builder
|
|
isReadingData := false
|
|
|
|
for {
|
|
receivedLine, readError := connectionReader.ReadString('\n')
|
|
if readError != nil {
|
|
return
|
|
}
|
|
|
|
trimmedLine := strings.TrimRight(receivedLine, "\r\n")
|
|
|
|
if isReadingData {
|
|
if trimmedLine == "." {
|
|
isReadingData = false
|
|
|
|
server.mutex.Lock()
|
|
server.receivedMessages = append(server.receivedMessages, messageBuilder.String())
|
|
server.mutex.Unlock()
|
|
|
|
messageBuilder.Reset()
|
|
writeLine("250 OK")
|
|
|
|
continue
|
|
}
|
|
|
|
messageBuilder.WriteString(trimmedLine)
|
|
messageBuilder.WriteString("\n")
|
|
|
|
continue
|
|
}
|
|
|
|
upperLine := strings.ToUpper(trimmedLine)
|
|
|
|
switch {
|
|
case strings.HasPrefix(upperLine, "EHLO"), strings.HasPrefix(upperLine, "HELO"):
|
|
// Ohne AUTH und ohne STARTTLS: Der Test prueft die Zustellung, nicht
|
|
// die Verschluesselung. net/smtp verzichtet dann von selbst auf beides.
|
|
writeLine("250-syncova-test")
|
|
writeLine("250 SIZE 10240000")
|
|
case strings.HasPrefix(upperLine, "MAIL FROM"):
|
|
writeLine("250 OK")
|
|
case strings.HasPrefix(upperLine, "RCPT TO"):
|
|
server.mutex.Lock()
|
|
server.receivedRecipients = append(server.receivedRecipients, trimmedLine)
|
|
server.mutex.Unlock()
|
|
|
|
writeLine("250 OK")
|
|
case upperLine == "DATA":
|
|
isReadingData = true
|
|
writeLine("354 Ende mit <CRLF>.<CRLF>")
|
|
case upperLine == "QUIT":
|
|
writeLine("221 Tschuess")
|
|
|
|
return
|
|
default:
|
|
writeLine("250 OK")
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEmailDeliveryReachesServer prueft die Zustellung per SMTP.
|
|
func TestEmailDeliveryReachesServer(testInstance *testing.T) {
|
|
smtpServer := startFakeSMTPServer(testInstance)
|
|
serverHost, serverPort := smtpServer.address()
|
|
|
|
emailChannel := Channel{
|
|
Name: "Betrieb",
|
|
ChannelType: ChannelEmail,
|
|
Enabled: true,
|
|
MinimumSeverity: SeverityHigh,
|
|
Configuration: map[string]any{
|
|
"host": serverHost,
|
|
"port": float64(serverPort),
|
|
"from": "syncova@example.test",
|
|
"to": []any{"betrieb@example.test", "bereitschaft@example.test"},
|
|
},
|
|
}
|
|
|
|
testAlert := buildTestAlert()
|
|
deliverer := &EmailDeliverer{}
|
|
|
|
if deliveryError := deliverer.Deliver(context.Background(), emailChannel, "", testAlert); deliveryError != nil {
|
|
testInstance.Fatalf("die Zustellung schlug fehl: %v", deliveryError)
|
|
}
|
|
|
|
receivedMessages := smtpServer.messages()
|
|
if len(receivedMessages) != 1 {
|
|
testInstance.Fatalf("der Server empfing %d Nachrichten, erwartet wurde genau eine",
|
|
len(receivedMessages))
|
|
}
|
|
|
|
receivedMessage := receivedMessages[0]
|
|
|
|
// Der Schweregrad gehoert in die Betreffzeile: Wer nachts auf sein Telefon
|
|
// sieht, liest den Betreff und sonst nichts.
|
|
if !strings.Contains(receivedMessage, "Subject: [Syncova CRITICAL]") {
|
|
testInstance.Errorf("der Betreff nennt den Schweregrad nicht:\n%s", receivedMessage)
|
|
}
|
|
|
|
if !strings.Contains(receivedMessage, testAlert.Title) {
|
|
testInstance.Error("die Nachricht enthaelt die Ueberschrift nicht")
|
|
}
|
|
|
|
// Die Meldungskennung erlaubt es, den Vorgang in der Oberflaeche
|
|
// wiederzufinden, ohne zu suchen.
|
|
if !strings.Contains(receivedMessage, testAlert.ID.String()) {
|
|
testInstance.Error("die Nachricht enthaelt die Meldungskennung nicht")
|
|
}
|
|
|
|
if len(smtpServer.recipients()) != 2 {
|
|
testInstance.Errorf("es wurden %d Empfaenger genannt, erwartet wurden 2",
|
|
len(smtpServer.recipients()))
|
|
}
|
|
}
|
|
|
|
// TestEmailDeliveryRejectsIncompleteConfiguration prueft die Ablehnung unvollstaendiger Kanaele.
|
|
func TestEmailDeliveryRejectsIncompleteConfiguration(testInstance *testing.T) {
|
|
deliverer := &EmailDeliverer{}
|
|
testAlert := buildTestAlert()
|
|
|
|
testCases := []struct {
|
|
name string
|
|
configuration map[string]any
|
|
}{
|
|
{"ohne Server", map[string]any{"from": "a@b.test", "to": "c@d.test"}},
|
|
{"ohne Absender", map[string]any{"host": "127.0.0.1", "to": "c@d.test"}},
|
|
{"ohne Empfaenger", map[string]any{"host": "127.0.0.1", "from": "a@b.test"}},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
channel := Channel{ChannelType: ChannelEmail, Configuration: testCase.configuration}
|
|
|
|
if deliveryError := deliverer.Deliver(context.Background(), channel, "",
|
|
testAlert); deliveryError == nil {
|
|
testInstance.Errorf("%s wurde angenommen", testCase.name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWebhookDeliveryReachesReceiver prueft die Zustellung per HTTP.
|
|
func TestWebhookDeliveryReachesReceiver(testInstance *testing.T) {
|
|
var (
|
|
receivedPayload webhookPayload
|
|
receivedHeader string
|
|
requestCount int
|
|
)
|
|
|
|
testServer := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestCount++
|
|
receivedHeader = request.Header.Get("Authorization")
|
|
|
|
_ = json.NewDecoder(request.Body).Decode(&receivedPayload)
|
|
|
|
responseWriter.WriteHeader(http.StatusAccepted)
|
|
}))
|
|
|
|
defer testServer.Close()
|
|
|
|
webhookChannel := Channel{
|
|
Name: "Leitstand",
|
|
ChannelType: ChannelWebhook,
|
|
Enabled: true,
|
|
Configuration: map[string]any{
|
|
"url": testServer.URL,
|
|
// Der Testserver spricht HTTP; im Betrieb verlangt der Zusteller
|
|
// HTTPS, sofern das nicht ausdruecklich abgewaehlt wird.
|
|
"allow_insecure": true,
|
|
},
|
|
}
|
|
|
|
testAlert := buildTestAlert()
|
|
deliverer := &WebhookDeliverer{httpClient: testServer.Client()}
|
|
|
|
if deliveryError := deliverer.Deliver(context.Background(), webhookChannel,
|
|
"geheim-123", testAlert); deliveryError != nil {
|
|
testInstance.Fatalf("die Zustellung schlug fehl: %v", deliveryError)
|
|
}
|
|
|
|
if requestCount != 1 {
|
|
testInstance.Fatalf("der Empfaenger bekam %d Anfragen, erwartet wurde eine", requestCount)
|
|
}
|
|
|
|
if receivedPayload.AlertID != testAlert.ID {
|
|
testInstance.Error("die Meldungskennung kam nicht an")
|
|
}
|
|
|
|
if receivedPayload.Severity != SeverityCritical {
|
|
testInstance.Errorf("der Schweregrad war %q, erwartet wurde critical", receivedPayload.Severity)
|
|
}
|
|
|
|
// Der Empfaenger muss pruefen koennen, dass die Meldung von dieser Anlage
|
|
// stammt.
|
|
if receivedHeader != "Bearer geheim-123" {
|
|
testInstance.Errorf("das Zugangsgeheimnis kam nicht an: %q", receivedHeader)
|
|
}
|
|
}
|
|
|
|
// TestWebhookRefusesPlainHTTPByDefault prueft den Schutz vor unverschluesselter Zustellung.
|
|
//
|
|
// Eine Meldung ueber unverschluesseltes HTTP verraet einem Mitleser, welche
|
|
// Anlage gerade nicht gesichert wird — eine Auskunft, die man nicht verschenkt.
|
|
func TestWebhookRefusesPlainHTTPByDefault(testInstance *testing.T) {
|
|
webhookChannel := Channel{
|
|
ChannelType: ChannelWebhook,
|
|
Configuration: map[string]any{"url": "http://leitstand.example.test/hook"},
|
|
}
|
|
|
|
deliverer := &WebhookDeliverer{httpClient: http.DefaultClient}
|
|
|
|
deliveryError := deliverer.Deliver(context.Background(), webhookChannel, "", buildTestAlert())
|
|
if deliveryError == nil {
|
|
testInstance.Fatal("die unverschluesselte Zustellung wurde angenommen")
|
|
}
|
|
|
|
if !strings.Contains(deliveryError.Error(), "allow_insecure") {
|
|
testInstance.Errorf("der Fehler nennt den Ausweg nicht: %v", deliveryError)
|
|
}
|
|
}
|
|
|
|
// TestWebhookReportsRejectionByReceiver prueft die Behandlung einer Ablehnung.
|
|
func TestWebhookReportsRejectionByReceiver(testInstance *testing.T) {
|
|
testServer := httptest.NewServer(http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
|
responseWriter.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
|
|
defer testServer.Close()
|
|
|
|
webhookChannel := Channel{
|
|
ChannelType: ChannelWebhook,
|
|
Configuration: map[string]any{"url": testServer.URL, "allow_insecure": true},
|
|
}
|
|
|
|
deliverer := &WebhookDeliverer{httpClient: testServer.Client()}
|
|
|
|
deliveryError := deliverer.Deliver(context.Background(), webhookChannel, "", buildTestAlert())
|
|
if deliveryError == nil {
|
|
testInstance.Fatal("eine abgelehnte Zustellung wurde als Erfolg gemeldet")
|
|
}
|
|
|
|
// Der Fehler muss den Statuscode nennen: „Zustellung fehlgeschlagen" allein
|
|
// laesst offen, ob der Empfaenger nicht da war oder die Nachricht ablehnte.
|
|
if !strings.Contains(deliveryError.Error(), "500") {
|
|
testInstance.Errorf("der Fehler nennt den Statuscode nicht: %v", deliveryError)
|
|
}
|
|
}
|
|
|
|
// TestSeverityThresholdFiltersChannels prueft die Schwelle je Kanal.
|
|
func TestSeverityThresholdFiltersChannels(testInstance *testing.T) {
|
|
// Ohne diese Grenze bekaeme der Bereitschaftsdienst nachts jede Information.
|
|
// Nach einer Woche schaltet er die Benachrichtigungen ab, und dann kommt
|
|
// auch die kritische nicht mehr an.
|
|
if SeverityWarning.AtLeast(SeverityHigh) {
|
|
testInstance.Error("eine Warnung erreichte einen Kanal, der erst ab hoch zustellt")
|
|
}
|
|
|
|
if !SeverityCritical.AtLeast(SeverityHigh) {
|
|
testInstance.Error("ein kritischer Zustand erreichte den Kanal nicht")
|
|
}
|
|
|
|
if !SeverityHigh.AtLeast(SeverityHigh) {
|
|
testInstance.Error("die Schwelle selbst wurde nicht erreicht")
|
|
}
|
|
|
|
if !SeverityInformation.AtLeast(SeverityInformation) {
|
|
testInstance.Error("eine Auskunft erreichte den Auskunftskanal nicht")
|
|
}
|
|
}
|