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>
381 lines
14 KiB
Go
381 lines
14 KiB
Go
package hypervisor
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/syncova/syncova/packages/platform/crypto"
|
|
)
|
|
|
|
// TestClusterInputValidation prueft die Eingangspruefung.
|
|
//
|
|
// Sie ist der einzige Punkt, an dem ein unbrauchbarer Verbund auffaellt, ohne
|
|
// dass jemand darunter leidet: Ein Verbund ohne Zugriffsweg auf die Archive
|
|
// laesst sich sonst einrichten, meldet „erreichbar" und liefert bei der ersten
|
|
// Sicherung um zwei Uhr nachts kein einziges Byte.
|
|
func TestClusterInputValidation(testInstance *testing.T) {
|
|
validInput := ClusterInput{
|
|
Name: "pve-verbund",
|
|
APIEndpoint: "https://pve.example:8006",
|
|
APITokenID: "syncova@pve!backup",
|
|
APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert
|
|
BackupStorageID: "local",
|
|
ArchiveTransport: TransportLocal,
|
|
ArchiveMountRoots: map[string]string{"local": "/var/lib/vz"},
|
|
}
|
|
|
|
if validationError := validInput.Validate(); validationError != nil {
|
|
testInstance.Fatalf("eine gültige Eingabe wurde abgelehnt: %v", validationError)
|
|
}
|
|
|
|
testCases := []struct {
|
|
name string
|
|
modify func(*ClusterInput)
|
|
expectedInMsg string
|
|
}{
|
|
{
|
|
name: "ohne Namen",
|
|
modify: func(input *ClusterInput) { input.Name = "" },
|
|
expectedInMsg: "namen",
|
|
},
|
|
{
|
|
// http:// ist ein Tippfehler mit Folgen: Das API-Token ginge im
|
|
// Klartext über das Netz.
|
|
name: "unverschlüsselte Adresse",
|
|
modify: func(input *ClusterInput) { input.APIEndpoint = "http://pve.example:8006" },
|
|
expectedInMsg: "klartext",
|
|
},
|
|
{
|
|
name: "ohne Token",
|
|
modify: func(input *ClusterInput) { input.APITokenSecret = "" },
|
|
expectedInMsg: "api-token",
|
|
},
|
|
{
|
|
name: "ohne Sicherungsspeicher",
|
|
modify: func(input *ClusterInput) { input.BackupStorageID = "" },
|
|
expectedInMsg: "speicher",
|
|
},
|
|
{
|
|
name: "lokaler Weg ohne Zuordnung",
|
|
modify: func(input *ClusterInput) { input.ArchiveMountRoots = nil },
|
|
expectedInMsg: "zuordnung",
|
|
},
|
|
{
|
|
name: "lokaler Weg mit relativem Pfad",
|
|
modify: func(input *ClusterInput) {
|
|
input.ArchiveMountRoots = map[string]string{"local": "var/lib/vz"}
|
|
},
|
|
expectedInMsg: "absolut",
|
|
},
|
|
{
|
|
name: "unbekannter Zugriffsweg",
|
|
modify: func(input *ClusterInput) { input.ArchiveTransport = "ftp" },
|
|
expectedInMsg: "unbekannt",
|
|
},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
testInstance.Run(testCase.name, func(subTest *testing.T) {
|
|
modifiedInput := validInput
|
|
testCase.modify(&modifiedInput)
|
|
|
|
validationError := modifiedInput.Validate()
|
|
if validationError == nil {
|
|
subTest.Fatal("die fehlerhafte Eingabe wurde angenommen")
|
|
}
|
|
|
|
if !strings.Contains(validationError.Error(), testCase.expectedInMsg) {
|
|
subTest.Errorf("die Meldung erklärt den Fehler nicht: %v", validationError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSSHClusterRequiresHostFingerprints ist der Sicherheitstest der Einrichtung.
|
|
//
|
|
// Ein SSH-Zugriffsweg ohne hinterlegte Wirtsschluessel nimmt jeden Server an,
|
|
// der auf die Adresse antwortet. Der Angreifer lieferte dann das Archiv, das
|
|
// Syncova fuer ein Backup haelt — und niemand saehe einen Fehler.
|
|
func TestSSHClusterRequiresHostFingerprints(testInstance *testing.T) {
|
|
sshInput := ClusterInput{
|
|
Name: "pve-ssh",
|
|
APIEndpoint: "https://pve.example:8006",
|
|
APITokenID: "syncova@pve!backup",
|
|
APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert
|
|
BackupStorageID: "local",
|
|
ArchiveTransport: TransportSSH,
|
|
SSHUsername: "syncova",
|
|
SSHPrivateKeyPEM: "-----BEGIN OPENSSH PRIVATE KEY-----\n", // secretscan:erlaubt: Platzhalter ohne Schluesselmaterial
|
|
}
|
|
|
|
validationError := sshInput.Validate()
|
|
if validationError == nil {
|
|
testInstance.Fatal("ein SSH-Verbund ohne Wirtsschlüssel wurde angenommen")
|
|
}
|
|
|
|
if !strings.Contains(validationError.Error(), "zwischenangriff") {
|
|
testInstance.Errorf("die Meldung nennt den Grund nicht: %v", validationError)
|
|
}
|
|
|
|
// Mit Fingerabdruck geht es durch.
|
|
sshInput.SSHHostFingerprints = map[string]string{"pve-01": "SHA256:abc"}
|
|
|
|
if acceptedError := sshInput.Validate(); acceptedError != nil {
|
|
testInstance.Errorf("ein vollständiger SSH-Verbund wurde abgelehnt: %v", acceptedError)
|
|
}
|
|
}
|
|
|
|
// TestClusterRoundTripKeepsSecretsEncrypted prueft Ablage und Rueckgabe.
|
|
//
|
|
// Der entscheidende Teil steht am Ende: Das API-Token darf in der Tabelle nicht
|
|
// im Klartext auffindbar sein. Wer die Datenbank sichert, sicherte es sonst
|
|
// gleich mit (PROMPT.md §140).
|
|
func TestClusterRoundTripKeepsSecretsEncrypted(testInstance *testing.T) {
|
|
connectionPool := connectTestDatabase(testInstance)
|
|
testStore := newTestStore(testInstance, connectionPool)
|
|
|
|
const tokenSecret = "streng-geheimer-tokenwert-1234" // secretscan:erlaubt: erfundener Testwert
|
|
|
|
createdCluster, createError := testStore.CreateCluster(context.Background(), ClusterInput{
|
|
Name: "verbund-" + uuid.NewString(),
|
|
APIEndpoint: "https://pve.example:8006",
|
|
APITokenID: "syncova@pve!backup",
|
|
APITokenSecret: tokenSecret,
|
|
TLSFingerprint: "aa:bb:cc",
|
|
BackupStorageID: "local",
|
|
ArchiveTransport: TransportLocal,
|
|
ArchiveMountRoots: map[string]string{"local": "/var/lib/vz"},
|
|
})
|
|
if createError != nil {
|
|
testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError)
|
|
}
|
|
|
|
testInstance.Cleanup(func() {
|
|
_ = testStore.DeleteCluster(context.Background(), createdCluster.ID)
|
|
})
|
|
|
|
if createdCluster.Status != StatusUnknown {
|
|
testInstance.Errorf("ein neuer Verbund gilt als %q statt als ungeprüft", createdCluster.Status)
|
|
}
|
|
|
|
readCluster, readError := testStore.GetCluster(context.Background(), createdCluster.ID)
|
|
if readError != nil {
|
|
testInstance.Fatalf("der Verbund ließ sich nicht lesen: %v", readError)
|
|
}
|
|
|
|
if readCluster.ArchiveMountRoots["local"] != "/var/lib/vz" {
|
|
testInstance.Errorf("die Speicherzuordnung ging verloren: %v", readCluster.ArchiveMountRoots)
|
|
}
|
|
|
|
credentials, credentialError := testStore.LoadCredentials(context.Background(), createdCluster.ID)
|
|
if credentialError != nil {
|
|
testInstance.Fatalf("die Zugangsdaten ließen sich nicht lesen: %v", credentialError)
|
|
}
|
|
|
|
if credentials.APITokenSecret != tokenSecret {
|
|
testInstance.Error("das entschlüsselte Token stimmt nicht mit dem abgelegten überein")
|
|
}
|
|
|
|
// Der Nachweis: Der Klartext steht nirgends in der Zeile.
|
|
var storedRow string
|
|
|
|
if scanError := connectionPool.QueryRow(context.Background(),
|
|
`SELECT proxmox_clusters::text FROM proxmox_clusters WHERE id = $1`,
|
|
createdCluster.ID).Scan(&storedRow); scanError != nil {
|
|
testInstance.Fatalf("die Zeile ließ sich nicht lesen: %v", scanError)
|
|
}
|
|
|
|
if strings.Contains(storedRow, tokenSecret) {
|
|
testInstance.Error("das API-Token steht im Klartext in der Datenbank")
|
|
}
|
|
}
|
|
|
|
// TestRecordConnectionResultKeepsLastSeen ist der Regressionstest zum Zeitpunkt.
|
|
//
|
|
// last_seen_at wird nur bei Erfolg fortgeschrieben. Bei jedem Versuch zu setzen
|
|
// machte aus „zuletzt erreicht" ein „zuletzt versucht" — und ein seit Wochen
|
|
// toter Verbund saehe frisch aus.
|
|
func TestRecordConnectionResultKeepsLastSeen(testInstance *testing.T) {
|
|
connectionPool := connectTestDatabase(testInstance)
|
|
testStore := newTestStore(testInstance, connectionPool)
|
|
|
|
createdCluster, createError := testStore.CreateCluster(context.Background(), ClusterInput{
|
|
Name: "verbund-" + uuid.NewString(),
|
|
APIEndpoint: "https://pve.example:8006",
|
|
APITokenID: "syncova@pve!backup",
|
|
APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert
|
|
BackupStorageID: "local",
|
|
ArchiveTransport: TransportLocal,
|
|
ArchiveMountRoots: map[string]string{"local": "/var/lib/vz"},
|
|
})
|
|
if createError != nil {
|
|
testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError)
|
|
}
|
|
|
|
testInstance.Cleanup(func() {
|
|
_ = testStore.DeleteCluster(context.Background(), createdCluster.ID)
|
|
})
|
|
|
|
if recordError := testStore.RecordConnectionResult(context.Background(),
|
|
createdCluster.ID, StatusReachable, nil); recordError != nil {
|
|
testInstance.Fatalf("das Ergebnis ließ sich nicht vermerken: %v", recordError)
|
|
}
|
|
|
|
reachableCluster, _ := testStore.GetCluster(context.Background(), createdCluster.ID)
|
|
if reachableCluster.LastSeenAt == nil {
|
|
testInstance.Fatal("nach einer erfolgreichen Verbindung fehlt der Zeitpunkt")
|
|
}
|
|
|
|
successTime := *reachableCluster.LastSeenAt
|
|
|
|
if recordError := testStore.RecordConnectionResult(context.Background(),
|
|
createdCluster.ID, StatusUnauthorized, errors.New("token abgelehnt")); recordError != nil {
|
|
testInstance.Fatalf("das Ergebnis ließ sich nicht vermerken: %v", recordError)
|
|
}
|
|
|
|
failedCluster, _ := testStore.GetCluster(context.Background(), createdCluster.ID)
|
|
|
|
if failedCluster.Status != StatusUnauthorized {
|
|
testInstance.Errorf("der Zustand ist %q", failedCluster.Status)
|
|
}
|
|
|
|
if failedCluster.LastError == "" {
|
|
testInstance.Error("der Grund des Fehlschlags wurde nicht vermerkt")
|
|
}
|
|
|
|
if failedCluster.LastSeenAt == nil || !failedCluster.LastSeenAt.Equal(successTime) {
|
|
testInstance.Error("ein fehlgeschlagener Versuch hat den Zeitpunkt der letzten Verbindung verschoben")
|
|
}
|
|
}
|
|
|
|
// TestMarkMissingGuestsHandlesEmptyList ist der Regressionstest zu `ANY(NULL)`.
|
|
//
|
|
// Ein Verbund ohne Gaeste — etwa weil alle geloescht wurden — muss **alle**
|
|
// bekannten Gaeste als fehlend vermerken. Mit einer NULL statt eines leeren
|
|
// Arrays waere die Bedingung niemals wahr, und es fiele niemandem auf.
|
|
func TestMarkMissingGuestsHandlesEmptyList(testInstance *testing.T) {
|
|
connectionPool := connectTestDatabase(testInstance)
|
|
testStore := newTestStore(testInstance, connectionPool)
|
|
|
|
createdCluster, createError := testStore.CreateCluster(context.Background(), ClusterInput{
|
|
Name: "verbund-" + uuid.NewString(),
|
|
APIEndpoint: "https://pve.example:8006",
|
|
APITokenID: "syncova@pve!backup",
|
|
APITokenSecret: "geheim", // secretscan:erlaubt: erfundener Testwert
|
|
BackupStorageID: "local",
|
|
ArchiveTransport: TransportLocal,
|
|
ArchiveMountRoots: map[string]string{"local": "/var/lib/vz"},
|
|
})
|
|
if createError != nil {
|
|
testInstance.Fatalf("der Verbund ließ sich nicht anlegen: %v", createError)
|
|
}
|
|
|
|
testInstance.Cleanup(func() {
|
|
_ = testStore.DeleteCluster(context.Background(), createdCluster.ID)
|
|
})
|
|
|
|
if _, insertError := connectionPool.Exec(context.Background(),
|
|
`INSERT INTO virtual_machines (cluster_id, provider_vm_id, name, guest_kind)
|
|
VALUES ($1, 'qemu/100', 'web-01', 'qemu'), ($1, 'qemu/101', 'db-01', 'qemu')`,
|
|
createdCluster.ID); insertError != nil {
|
|
testInstance.Fatalf("die Gäste ließen sich nicht anlegen: %v", insertError)
|
|
}
|
|
|
|
missingCount, markError := testStore.markMissingGuests(context.Background(), createdCluster.ID, nil)
|
|
if markError != nil {
|
|
testInstance.Fatalf("die fehlenden Gäste ließen sich nicht vermerken: %v", markError)
|
|
}
|
|
|
|
if missingCount != 2 {
|
|
testInstance.Errorf("es wurden %d Gäste als fehlend vermerkt statt zwei", missingCount)
|
|
}
|
|
|
|
// Gelöscht wird nichts: Die Zuordnung zu vorhandenen Backups muss bleiben.
|
|
machineList, listError := testStore.ListVirtualMachines(context.Background(), &createdCluster.ID)
|
|
if listError != nil {
|
|
testInstance.Fatalf("der Bestand ließ sich nicht lesen: %v", listError)
|
|
}
|
|
|
|
if len(machineList) != 2 {
|
|
testInstance.Fatalf("es sind %d Gäste im Bestand statt zwei", len(machineList))
|
|
}
|
|
|
|
for _, singleMachine := range machineList {
|
|
if singleMachine.MissingSince == nil {
|
|
testInstance.Errorf("der Gast %q gilt nicht als fehlend", singleMachine.Name)
|
|
}
|
|
}
|
|
|
|
// Ein zweiter Lauf darf den Zeitpunkt nicht verschieben: Sonst stünde bei
|
|
// einem seit Wochen fehlenden Gast immer „seit gerade eben".
|
|
secondCount, secondError := testStore.markMissingGuests(context.Background(), createdCluster.ID, nil)
|
|
if secondError != nil {
|
|
testInstance.Fatalf("der zweite Lauf schlug fehl: %v", secondError)
|
|
}
|
|
|
|
if secondCount != 0 {
|
|
testInstance.Errorf("der zweite Lauf vermerkte %d Gäste erneut", secondCount)
|
|
}
|
|
}
|
|
|
|
// newTestStore baut den Speicher mit einem Testschlüssel.
|
|
func newTestStore(testInstance *testing.T, connectionPool *pgxpool.Pool) *Store {
|
|
testInstance.Helper()
|
|
|
|
secretStore, storeError := crypto.NewLocalSecretStore(map[string][]byte{
|
|
"v1": []byte("0123456789abcdef0123456789abcdef"), // secretscan:erlaubt: erfundener Testwert
|
|
}, "v1")
|
|
if storeError != nil {
|
|
testInstance.Fatalf("der Schlüsselspeicher ließ sich nicht bauen: %v", storeError)
|
|
}
|
|
|
|
builtStore, buildError := NewStore(connectionPool, secretStore)
|
|
if buildError != nil {
|
|
testInstance.Fatalf("der Speicher ließ sich nicht bauen: %v", buildError)
|
|
}
|
|
|
|
return builtStore
|
|
}
|
|
|
|
// connectTestDatabase öffnet die Testdatenbank.
|
|
//
|
|
// Ohne erreichbare Datenbank werden die Tests übersprungen statt zu scheitern:
|
|
// Ein rot leuchtender Testlauf auf einem Rechner ohne Docker gewöhnt nur das
|
|
// Wegschauen an.
|
|
func connectTestDatabase(testInstance *testing.T) *pgxpool.Pool {
|
|
testInstance.Helper()
|
|
|
|
connectionString := os.Getenv("SYNCOVA_TEST_DATABASE_URL")
|
|
if connectionString == "" {
|
|
connectionString = os.Getenv("SYNCOVA_DATABASE_URL")
|
|
}
|
|
|
|
if connectionString == "" {
|
|
testInstance.Skip("SYNCOVA_TEST_DATABASE_URL ist nicht gesetzt; die Datenbanktests werden übersprungen")
|
|
}
|
|
|
|
connectContext, cancelConnect := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancelConnect()
|
|
|
|
connectionPool, poolError := pgxpool.New(connectContext, connectionString)
|
|
if poolError != nil {
|
|
testInstance.Skipf("die Testdatenbank war nicht erreichbar: %v", poolError)
|
|
}
|
|
|
|
if pingError := connectionPool.Ping(connectContext); pingError != nil {
|
|
connectionPool.Close()
|
|
testInstance.Skipf("die Testdatenbank antwortete nicht: %v", pingError)
|
|
}
|
|
|
|
testInstance.Cleanup(connectionPool.Close)
|
|
|
|
return connectionPool
|
|
}
|