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>
280 lines
10 KiB
Go
280 lines
10 KiB
Go
package disasterrecovery
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// buildSampleSnapshot erzeugt einen Sicherungssatz mit allen Bestandteilen.
|
|
func buildSampleSnapshot() *Snapshot {
|
|
return &Snapshot{
|
|
FormatVersion: SnapshotFormatVersion,
|
|
RepositoryID: "11111111-2222-3333-4444-555555555555",
|
|
CreatedAt: time.Date(2026, 8, 13, 6, 0, 0, 0, time.UTC),
|
|
CreatedBy: "syncova-dr",
|
|
ProductVersion: "0.1.0-dev",
|
|
SchemaVersion: 10,
|
|
Repositories: []RepositoryRecord{
|
|
{ID: "aaaaaaaa-0000-0000-0000-000000000001", Name: "haupt",
|
|
RepositoryType: "local", Location: "/srv/backups", Status: "active"},
|
|
},
|
|
Jobs: []JobRecord{
|
|
{ID: "bbbbbbbb-0000-0000-0000-000000000001", Name: "Dateiserver",
|
|
Status: "active", Priority: "high", ScheduleType: "daily",
|
|
RepositoryID: "aaaaaaaa-0000-0000-0000-000000000001"},
|
|
},
|
|
NotificationChannels: []NotificationChannelRecord{
|
|
{ID: "cccccccc-0000-0000-0000-000000000001", Name: "Bereitschaft",
|
|
ChannelType: "email", MinimumSeverity: "high", WasEnabled: true},
|
|
},
|
|
Users: []UserRecord{
|
|
{ID: "dddddddd-0000-0000-0000-000000000001", Username: "admin",
|
|
Email: "admin@example.org", Status: "active",
|
|
Roles: []string{"super_administrator"}},
|
|
},
|
|
OmittedForSecurity: securityOmissions(),
|
|
}
|
|
}
|
|
|
|
// TestSnapshotCarriesNoSecrets ist der wichtigste Test des Pakets.
|
|
//
|
|
// Ein Sicherungssatz liegt im Repository, und ein Repository liegt naturgemaess
|
|
// dort, wo es einen Serverausfall ueberlebt: ausserhalb der Anlage, womoeglich
|
|
// bei einem Dienstleister. Ein Passwort-Hash oder ein SMTP-Zugang, der dort
|
|
// einmal gelandet ist, laesst sich nicht zurueckholen — die Datei ist bereits
|
|
// geschrieben.
|
|
//
|
|
// Geprueft wird die **serialisierte** Form, nicht die Struktur: Ein Feld, das
|
|
// jemand spaeter ergaenzt, faellt nur so auf.
|
|
func TestSnapshotCarriesNoSecrets(testInstance *testing.T) {
|
|
// Die Liste der Auslassungen wird vorher entfernt: In ihr **stehen** die
|
|
// Begriffe mit Absicht („Zweite Faktoren (TOTP-Geheimnisse)"). Sie
|
|
// mitzuprüfen ergäbe einen Alarm auf genau den Text, der vor dem Problem
|
|
// warnt — und ein Prüfwerkzeug, das grundlos anschlägt, wird bald nicht mehr
|
|
// ernst genommen.
|
|
snapshotUnderTest := buildSampleSnapshot()
|
|
snapshotUnderTest.OmittedForSecurity = nil
|
|
|
|
encodedSnapshot, encodeError := json.Marshal(snapshotUnderTest)
|
|
if encodeError != nil {
|
|
testInstance.Fatalf("der Sicherungssatz ließ sich nicht kodieren: %v", encodeError)
|
|
}
|
|
|
|
serializedText := strings.ToLower(string(encodedSnapshot))
|
|
|
|
// Feldnamen, die es in einem Sicherungssatz niemals geben darf.
|
|
forbiddenFieldNames := []string{
|
|
"password_hash", "passwordhash", "secret_ciphertext", "credentials_ciphertext",
|
|
"secret_key", "private_key", "access_token", "refresh_token",
|
|
"\"configuration\"", "totp",
|
|
}
|
|
|
|
for _, forbiddenField := range forbiddenFieldNames {
|
|
if strings.Contains(serializedText, forbiddenField) {
|
|
testInstance.Errorf("der Sicherungssatz enthält das Feld %q", forbiddenField)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestUserRecordHasNoPasswordField prueft die Struktur des Kontos.
|
|
//
|
|
// Ergaenzend zum Test oben: Dieser faellt auch dann auf, wenn jemand ein Feld
|
|
// hinzufuegt, das im Beispiel leer bleibt und deshalb aus dem JSON verschwindet.
|
|
func TestUserRecordHasNoPasswordField(testInstance *testing.T) {
|
|
encodedUser, encodeError := json.Marshal(UserRecord{Username: "test"})
|
|
if encodeError != nil {
|
|
testInstance.Fatalf("das Konto ließ sich nicht kodieren: %v", encodeError)
|
|
}
|
|
|
|
var decodedFields map[string]any
|
|
|
|
if decodeError := json.Unmarshal(encodedUser, &decodedFields); decodeError != nil {
|
|
testInstance.Fatalf("das Konto ließ sich nicht lesen: %v", decodeError)
|
|
}
|
|
|
|
allowedFields := map[string]bool{
|
|
"id": true, "username": true, "email": true, "status": true, "roles": true,
|
|
}
|
|
|
|
for fieldName := range decodedFields {
|
|
if !allowedFields[fieldName] {
|
|
testInstance.Errorf("das Konto trägt das unerwartete Feld %q — jedes neue Feld "+
|
|
"hier ist ein Kandidat für ein Geheimnis im Repository", fieldName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestOmissionsAreCarriedInTheSnapshot prueft die Handlungsanweisung.
|
|
//
|
|
// Wer nach einem Totalverlust eine Anlage wiederherstellt, hat die
|
|
// Betriebsanleitung nicht dabei. Was fehlt, muss aus der Datei selbst
|
|
// hervorgehen.
|
|
func TestOmissionsAreCarriedInTheSnapshot(testInstance *testing.T) {
|
|
sampleSnapshot := buildSampleSnapshot()
|
|
|
|
if len(sampleSnapshot.OmittedForSecurity) == 0 {
|
|
testInstance.Fatal("der Sicherungssatz benennt nicht, was ihm fehlt")
|
|
}
|
|
|
|
joinedOmissions := strings.ToLower(strings.Join(sampleSnapshot.OmittedForSecurity, " "))
|
|
|
|
// Jede weggelassene Art von Geheimnis muss genannt sein.
|
|
for _, requiredMention := range []string{"passwör", "faktor", "zugangsdaten", "token",
|
|
"schlüssel"} {
|
|
if !strings.Contains(joinedOmissions, requiredMention) {
|
|
testInstance.Errorf("die Liste der Auslassungen erwähnt %q nicht", requiredMention)
|
|
}
|
|
}
|
|
|
|
// Und sie muss sagen, was zu tun ist — nicht nur, was fehlt.
|
|
if !strings.Contains(joinedOmissions, "create-admin") {
|
|
testInstance.Error("die Liste nennt den Weg zum ersten Zugang nicht")
|
|
}
|
|
}
|
|
|
|
// TestSnapshotRejectsForeignFormatVersion prueft die Versionspruefung.
|
|
//
|
|
// Ein Satz aus einer kuenftigen Version wird abgelehnt, statt teilweise gelesen
|
|
// zu werden: Eine halb wiederhergestellte Konfiguration ist schlimmer als gar
|
|
// keine, weil sie arbeitsfaehig aussieht.
|
|
func TestSnapshotRejectsForeignFormatVersion(testInstance *testing.T) {
|
|
futureSnapshot := buildSampleSnapshot()
|
|
futureSnapshot.FormatVersion = SnapshotFormatVersion + 1
|
|
|
|
if validationError := futureSnapshot.Validate(); validationError == nil {
|
|
testInstance.Error("ein Satz aus einer künftigen Formatversion wurde angenommen")
|
|
}
|
|
|
|
emptyRepositorySnapshot := buildSampleSnapshot()
|
|
emptyRepositorySnapshot.RepositoryID = ""
|
|
|
|
if validationError := emptyRepositorySnapshot.Validate(); validationError == nil {
|
|
testInstance.Error("ein Satz ohne Repository-Kennung wurde angenommen")
|
|
}
|
|
}
|
|
|
|
// TestWriteSnapshotIsAtomicAndKeepsHistory prueft das Schreiben.
|
|
func TestWriteSnapshotIsAtomicAndKeepsHistory(testInstance *testing.T) {
|
|
repositoryRoot := testInstance.TempDir()
|
|
|
|
snapshotPath, writeError := WriteSnapshot(repositoryRoot, buildSampleSnapshot())
|
|
if writeError != nil {
|
|
testInstance.Fatalf("der Sicherungssatz ließ sich nicht schreiben: %v", writeError)
|
|
}
|
|
|
|
if _, statError := os.Stat(snapshotPath); statError != nil {
|
|
testInstance.Fatalf("die feste Datei fehlt: %v", statError)
|
|
}
|
|
|
|
// Neben der festen Datei muss eine datierte Fassung liegen: Wer eine
|
|
// irrtuemliche Konfigurationsaenderung zurueckdrehen will, braucht den Stand
|
|
// davor.
|
|
directoryEntries, readError := os.ReadDir(filepath.Join(repositoryRoot,
|
|
filepath.FromSlash(SnapshotDirectory)))
|
|
if readError != nil {
|
|
testInstance.Fatalf("das Verzeichnis ließ sich nicht lesen: %v", readError)
|
|
}
|
|
|
|
datedFileCount := 0
|
|
|
|
for _, directoryEntry := range directoryEntries {
|
|
if strings.HasPrefix(directoryEntry.Name(), "control-plane-") {
|
|
datedFileCount++
|
|
}
|
|
|
|
// Eine liegengebliebene Temp-Datei bedeutet, dass das atomare Schreiben
|
|
// nicht aufgeraeumt hat.
|
|
if strings.HasPrefix(directoryEntry.Name(), ".tmp-") {
|
|
testInstance.Errorf("eine temporäre Datei blieb zurück: %s", directoryEntry.Name())
|
|
}
|
|
}
|
|
|
|
if datedFileCount != 1 {
|
|
testInstance.Errorf("es liegen %d datierte Fassungen vor, erwartet wurde eine",
|
|
datedFileCount)
|
|
}
|
|
}
|
|
|
|
// TestReadSnapshotExplainsAMissingFile prueft die Fehlermeldung.
|
|
//
|
|
// Ein Repository ohne Sicherungssatz ist kein Totalschaden: Die
|
|
// Wiederherstellungspunkte sind weiterhin nutzbar. Die Meldung muss das sagen,
|
|
// sonst haelt der Betreiber im Ernstfall alles fuer verloren.
|
|
func TestReadSnapshotExplainsAMissingFile(testInstance *testing.T) {
|
|
_, readError := ReadSnapshot(testInstance.TempDir())
|
|
|
|
if readError == nil {
|
|
testInstance.Fatal("ein fehlender Sicherungssatz wurde nicht gemeldet")
|
|
}
|
|
|
|
if !strings.Contains(readError.Error(), "wiederherstellungspunkte") {
|
|
testInstance.Errorf("die Meldung sagt nicht, dass die Backups nutzbar bleiben: %v",
|
|
readError)
|
|
}
|
|
}
|
|
|
|
// TestReadSnapshotRejectsCorruptedFile prueft die Behandlung einer kaputten Datei.
|
|
func TestReadSnapshotRejectsCorruptedFile(testInstance *testing.T) {
|
|
repositoryRoot := testInstance.TempDir()
|
|
snapshotDirectory := filepath.Join(repositoryRoot, filepath.FromSlash(SnapshotDirectory))
|
|
|
|
if directoryError := os.MkdirAll(snapshotDirectory, 0o700); directoryError != nil {
|
|
testInstance.Fatalf("das Verzeichnis ließ sich nicht anlegen: %v", directoryError)
|
|
}
|
|
|
|
if writeError := os.WriteFile(filepath.Join(snapshotDirectory, SnapshotFileName),
|
|
[]byte("{ das ist kein json"), 0o600); writeError != nil {
|
|
testInstance.Fatalf("die Datei ließ sich nicht schreiben: %v", writeError)
|
|
}
|
|
|
|
if _, readError := ReadSnapshot(repositoryRoot); readError == nil {
|
|
testInstance.Error("ein beschädigter Sicherungssatz wurde angenommen")
|
|
}
|
|
}
|
|
|
|
// TestDeriveIdentifierIsStable prueft die Kennungsableitung.
|
|
//
|
|
// Eine zweite Uebernahme desselben Repositorys muss dieselben Kennungen ergeben.
|
|
// Mit Zufallswerten entstuenden bei jedem Lauf Dubletten, und niemand koennte
|
|
// sagen, welcher Eintrag der richtige ist.
|
|
func TestDeriveIdentifierIsStable(testInstance *testing.T) {
|
|
repositoryIdentifier := uuidFromString(testInstance, "11111111-2222-3333-4444-555555555555")
|
|
|
|
firstRun := deriveIdentifier(repositoryIdentifier, "backup", "run-abc")
|
|
secondRun := deriveIdentifier(repositoryIdentifier, "backup", "run-abc")
|
|
|
|
if firstRun != secondRun {
|
|
testInstance.Errorf("dieselbe Eingabe ergab zwei Kennungen: %s und %s", firstRun, secondRun)
|
|
}
|
|
|
|
// Verschiedene Arten desselben Schluessels duerfen nicht kollidieren.
|
|
if deriveIdentifier(repositoryIdentifier, "chain", "run-abc") == firstRun {
|
|
testInstance.Error("Kette und Backup mit gleichem Schlüssel ergeben dieselbe Kennung")
|
|
}
|
|
|
|
// Und zwei Repositories mit gleichlautender Backupkennung ebenso wenig.
|
|
otherRepository := uuidFromString(testInstance, "99999999-8888-7777-6666-555555555555")
|
|
|
|
if deriveIdentifier(otherRepository, "backup", "run-abc") == firstRun {
|
|
testInstance.Error("zwei Repositories mit gleicher Backupkennung kollidieren")
|
|
}
|
|
}
|
|
|
|
// uuidFromString liest eine UUID oder bricht den Test ab.
|
|
func uuidFromString(testInstance *testing.T, rawIdentifier string) uuid.UUID {
|
|
testInstance.Helper()
|
|
|
|
parsedIdentifier, parseError := uuid.Parse(rawIdentifier)
|
|
if parseError != nil {
|
|
testInstance.Fatalf("die Kennung %q ist unlesbar: %v", rawIdentifier, parseError)
|
|
}
|
|
|
|
return parsedIdentifier
|
|
}
|