syncova-backup/packages/providers/proxmox/transport_test.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

482 lines
18 KiB
Go

package proxmox
import (
"context"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/syncova/syncova/packages/providers"
)
// TestLocalTransportReadsArchive prueft den Dateizugriff.
func TestLocalTransportReadsArchive(testInstance *testing.T) {
storageRoot := testInstance.TempDir()
dumpDirectory := filepath.Join(storageRoot, "dump")
if makeError := os.MkdirAll(dumpDirectory, 0o700); makeError != nil {
testInstance.Fatalf("das Verzeichnis ließ sich nicht anlegen: %v", makeError)
}
archiveContent := []byte("VZDUMP-ARCHIVINHALT")
archivePath := filepath.Join(dumpDirectory, "vzdump-qemu-100-2026_08_13-02_00_00.vma")
if writeError := os.WriteFile(archivePath, archiveContent, 0o600); writeError != nil {
testInstance.Fatalf("das Archiv ließ sich nicht anlegen: %v", writeError)
}
transport := NewLocalArchiveTransport(map[string]string{"local": storageRoot})
archiveReader, openError := transport.OpenArchive(context.Background(), "pve-01",
"local:dump/vzdump-qemu-100-2026_08_13-02_00_00.vma")
if openError != nil {
testInstance.Fatalf("das Archiv ließ sich nicht öffnen: %v", openError)
}
defer func() { _ = archiveReader.Close() }()
readContent, readError := io.ReadAll(archiveReader)
if readError != nil {
testInstance.Fatalf("das Archiv ließ sich nicht lesen: %v", readError)
}
if string(readContent) != string(archiveContent) {
testInstance.Errorf("der Inhalt weicht ab: %q", string(readContent))
}
}
// TestLocalTransportRejectsPathEscape ist der Sicherheitstest des Transports.
//
// Die Volumenkennung kommt vom Proxmox-Knoten. Vertrauenswuerdig ist sie nur so
// weit, wie man dem Knoten vertraut — und ein Pfad wie
// "dump/../../../etc/shadow" laese eine beliebige Datei des Syncova-Servers ins
// Backup. Dieselbe Ueberlegung wie bei validateManifestPath (Phase 5).
func TestLocalTransportRejectsPathEscape(testInstance *testing.T) {
storageRoot := testInstance.TempDir()
transport := NewLocalArchiveTransport(map[string]string{"local": storageRoot})
escapeAttempts := []string{
"local:../../../etc/passwd",
"local:dump/../../../../etc/shadow",
"local:..",
}
for _, volumeIdentifier := range escapeAttempts {
archiveReader, openError := transport.OpenArchive(context.Background(), "pve-01", volumeIdentifier)
if openError == nil {
_ = archiveReader.Close()
testInstance.Errorf("der Ausbruchsversuch %q wurde zugelassen", volumeIdentifier)
}
}
}
// TestLocalTransportNamesMissingMapping prueft die Fehlermeldung.
//
// Ein Speicher ohne hinterlegten Pfad ist der haeufigste Einrichtungsfehler.
// Die Meldung muss sagen, was zu tun ist — sonst sucht der Betreiber bei
// Proxmox statt in seiner eigenen Konfiguration.
func TestLocalTransportNamesMissingMapping(testInstance *testing.T) {
transport := NewLocalArchiveTransport(map[string]string{"local": testInstance.TempDir()})
_, openError := transport.OpenArchive(context.Background(), "pve-01", "nfs-backup:dump/x.vma")
if openError == nil {
testInstance.Fatal("ein unbekannter Speicher wurde angenommen")
}
if !strings.Contains(openError.Error(), "nfs-backup") {
testInstance.Errorf("die Meldung nennt den Speicher nicht: %v", openError)
}
if !strings.Contains(openError.Error(), "ssh") {
testInstance.Errorf("die Meldung nennt den Ausweg nicht: %v", openError)
}
}
// TestSplitVolumeIdentifier prueft die Zerlegung der Volumenkennung.
func TestSplitVolumeIdentifier(testInstance *testing.T) {
storageIdentifier, relativePath, parseError := SplitVolumeIdentifier(
"local:dump/vzdump-qemu-100-2026_08_13-02_00_00.vma.zst")
if parseError != nil {
testInstance.Fatalf("die Kennung ließ sich nicht zerlegen: %v", parseError)
}
if storageIdentifier != "local" {
testInstance.Errorf("der Speicher ist %q", storageIdentifier)
}
if relativePath != "dump/vzdump-qemu-100-2026_08_13-02_00_00.vma.zst" {
testInstance.Errorf("der Pfad ist %q", relativePath)
}
// Fehlerhafte Formen werden abgelehnt, nicht geraten.
for _, malformedIdentifier := range []string{"", "local", "local:", ":dump/x", " "} {
if _, _, splitError := SplitVolumeIdentifier(malformedIdentifier); splitError == nil {
testInstance.Errorf("die fehlerhafte Kennung %q wurde angenommen", malformedIdentifier)
}
}
}
// TestSSHTransportRequiresHostKeyFingerprints ist der Sicherheitstest des SSH-Wegs.
//
// Ein Transport, der jeden Wirtsschluessel annimmt, macht aus einem
// Zwischenangriff eine Einladung: Der Angreifer liefert dann das Archiv, das
// Syncova fuer ein Backup haelt.
func TestSSHTransportRequiresHostKeyFingerprints(testInstance *testing.T) {
_, buildError := NewSSHArchiveTransport(SSHTransportOptions{
Username: "syncova",
PrivateKeyPEM: []byte("-----BEGIN OPENSSH PRIVATE KEY-----\n"), // secretscan:erlaubt: Platzhalter ohne Schluesselmaterial
}, stubStoragePathResolver{})
if buildError == nil {
testInstance.Fatal("ein SSH-Transport ohne Fingerabdrücke wurde angenommen")
}
if !strings.Contains(buildError.Error(), "zwischenangriff") {
testInstance.Errorf("die Meldung nennt den Grund nicht: %v", buildError)
}
}
// TestSSHTransportRequiresCredentials prueft die uebrigen Pflichtangaben.
func TestSSHTransportRequiresCredentials(testInstance *testing.T) {
fingerprints := map[string]string{"pve-01": "SHA256:abc"}
if _, buildError := NewSSHArchiveTransport(SSHTransportOptions{
PrivateKeyPEM: []byte("key"),
HostKeyFingerprints: fingerprints,
}, stubStoragePathResolver{}); buildError == nil {
testInstance.Error("ein SSH-Transport ohne Anmeldekonto wurde angenommen")
}
if _, buildError := NewSSHArchiveTransport(SSHTransportOptions{
Username: "syncova",
HostKeyFingerprints: fingerprints,
}, stubStoragePathResolver{}); buildError == nil {
testInstance.Error("ein SSH-Transport ohne Schlüssel wurde angenommen")
}
if _, buildError := NewSSHArchiveTransport(SSHTransportOptions{
Username: "syncova",
PrivateKeyPEM: []byte("key"),
HostKeyFingerprints: fingerprints,
}, nil); buildError == nil {
testInstance.Error("ein SSH-Transport ohne Pfadauflösung wurde angenommen")
}
}
// TestSSHFingerprintNormalizationKeepsCase ist der Regressionstest zum Fund.
//
// Der bestehende normalizeFingerprint fuer TLS-Zertifikate macht
// Kleinbuchstaben — bei einem Hex-Fingerabdruck richtig, bei einem Base64-Wert
// **falsch**. Base64 unterscheidet Gross- und Kleinschreibung; ein
// kleingeschriebener SSH-Fingerabdruck passte auf keinen Schluessel mehr, und
// die Verbindung schluege mit einer Meldung fehl, die nach einem Angriff
// aussieht.
func TestSSHFingerprintNormalizationKeepsCase(testInstance *testing.T) {
const mixedCaseFingerprint = "SHA256:aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789+/xyz"
normalizedValue := normalizeSSHFingerprint(mixedCaseFingerprint)
if normalizedValue != "aBcDeFgHiJkLmNoPqRsTuVwXyZ0123456789+/xyz" {
testInstance.Errorf("die Groß-/Kleinschreibung ging verloren: %q", normalizedValue)
}
// Mit und ohne Praefix muessen gleich behandelt werden: Wer den Wert aus
// `ssh-keygen -l` kopiert, hat das Praefix dabei — aus einer Konfiguration
// womoeglich nicht.
if normalizeSSHFingerprint("SHA256:abc") != normalizeSSHFingerprint("abc") {
testInstance.Error("das Präfix SHA256: wird nicht abgeschnitten")
}
// Die Base64-Auffuellung laesst ssh-keygen weg.
if normalizeSSHFingerprint("abc==") != normalizeSSHFingerprint("abc") {
testInstance.Error("die Base64-Auffüllung wird nicht abgeschnitten")
}
}
// TestQuoteShellArgumentIsSafe prueft die Maskierung des Dateipfads.
//
// Der Pfad geht als Teil einer Befehlszeile an die Gegenseite. Enthaelt ein
// Dateiname ein einfaches Anfuehrungszeichen — bei einem vzdump-Archiv
// unwahrscheinlich, bei einem selbst gewaehlten Speicherpfad nicht —, dann
// entscheidet allein diese Funktion darueber, ob daraus ein zweiter Befehl
// wird. Geprueft wird deshalb gegen eine **echte Shell**, nicht gegen ein
// erwartetes Zeichenmuster: Eine handgeschriebene Erwartung prueft nur, ob die
// Funktion tut, was ich mir gedacht habe.
func TestQuoteShellArgumentIsSafe(testInstance *testing.T) {
dangerousArguments := []string{
"/var/lib/vz/dump/x'; rm -rf /; echo '",
"/var/lib/vz/dump/$(whoami).vma",
"/var/lib/vz/dump/`id`.vma",
"/var/lib/vz/dump/a b\tc.vma",
"/var/lib/vz/dump/x\"y.vma",
"/var/lib/vz/dump/*",
"",
}
for _, dangerousArgument := range dangerousArguments {
// printf %s gibt genau ein Argument aus. Kommt der Pfad unveraendert
// zurueck, ist er weder zerlegt noch ausgewertet worden.
shellCommand := "printf %s " + quoteShellArgument(dangerousArgument)
shellOutput, runError := exec.Command("/bin/sh", "-c", shellCommand).Output()
if runError != nil {
testInstance.Fatalf("die shell scheiterte an %q: %v", dangerousArgument, runError)
}
if string(shellOutput) != dangerousArgument {
testInstance.Errorf("das argument %q kam als %q zurueck", dangerousArgument, string(shellOutput))
}
}
}
// TestQuoteShellArgumentPassesSingleArgument stellt sicher, dass kein zweites entsteht.
func TestQuoteShellArgumentPassesSingleArgument(testInstance *testing.T) {
// Zwei Zaehlungen: Ohne Maskierung ergaebe dieser Pfad drei Argumente.
shellCommand := "set -- " + quoteShellArgument("/var/lib/vz/dump/a b c.vma") + "; printf %s $#"
shellOutput, runError := exec.Command("/bin/sh", "-c", shellCommand).Output()
if runError != nil {
testInstance.Fatalf("die shell scheiterte: %v", runError)
}
if string(shellOutput) != "1" {
testInstance.Errorf("es entstanden %s argumente statt einem", string(shellOutput))
}
}
// stubStoragePathResolver ersetzt die Pfadauflösung im Test.
type stubStoragePathResolver struct{}
// StoragePath liefert einen festen Pfad.
func (stubStoragePathResolver) StoragePath(context.Context, string, string) (string, error) {
return "/var/lib/vz", nil
}
// TestOpenDiskReadsArchiveAndCleansUp prueft den gesamten Weg.
//
// Das ist der Test, der die Luecke der Phase 7 schliesst: vzdump anstossen,
// das entstandene Archiv finden, es **als Datenstrom lesen** und es danach vom
// Knoten entfernen. Bis hierher konnte OpenDisk kein einziges Byte liefern —
// das ArchiveTransport-Interface hatte keine Umsetzung.
func TestOpenDiskReadsArchiveAndCleansUp(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
// Das Archiv, das der Nachbau nach dem vzdump ausweist, wird als echte
// Datei angelegt: local:backup/vzdump-qemu-100-2026_08_11-02_00_00.vma
storageRoot := testInstance.TempDir()
if makeError := os.MkdirAll(filepath.Join(storageRoot, "backup"), 0o700); makeError != nil {
testInstance.Fatalf("das Verzeichnis ließ sich nicht anlegen: %v", makeError)
}
archiveContent := []byte("VMA\x00 echter archivinhalt aus dem dateisystem")
archivePath := filepath.Join(storageRoot, "backup", "vzdump-qemu-100-2026_08_11-02_00_00.vma")
if writeError := os.WriteFile(archivePath, archiveContent, 0o600); writeError != nil {
testInstance.Fatalf("das Archiv ließ sich nicht anlegen: %v", writeError)
}
testProvider.options.ArchiveTransport = NewLocalArchiveTransport(map[string]string{"local": storageRoot})
diskStream, openError := testProvider.OpenDisk(context.Background(), providers.DiskReadRequest{
GuestID: "qemu/100",
})
if openError != nil {
testInstance.Fatalf("die Sicherung ließ sich nicht öffnen: %v", openError)
}
readContent, readError := io.ReadAll(diskStream)
if readError != nil {
testInstance.Fatalf("das Archiv ließ sich nicht lesen: %v", readError)
}
if string(readContent) != string(archiveContent) {
testInstance.Errorf("der gelesene Inhalt weicht ab: %q", string(readContent))
}
// Vor dem Schliessen darf nichts geloescht sein — sonst zoege das Aufraeumen
// dem Leser die Datei unter den Fuessen weg.
if len(fakeServer.deletedArchiveList()) != 0 {
testInstance.Error("das Archiv wurde entfernt, bevor es gelesen war")
}
if closeError := diskStream.Close(); closeError != nil {
testInstance.Fatalf("der Datenstrom ließ sich nicht schließen: %v", closeError)
}
deletedArchives := fakeServer.deletedArchiveList()
if len(deletedArchives) != 1 {
testInstance.Fatalf("es wurden %d Archive entfernt, erwartet war eines", len(deletedArchives))
}
if !strings.Contains(deletedArchives[0], "vzdump-qemu-100") {
testInstance.Errorf("es wurde das falsche Archiv entfernt: %s", deletedArchives[0])
}
}
// TestOpenDiskKeepsArchiveWhenRequested prueft den ausdruecklichen Ausweg.
//
// Fuehrt Proxmox auf diesem Speicher selbst eine Aufbewahrung, waere das
// Loeschen ein Eingriff in fremde Verwaltung.
func TestOpenDiskKeepsArchiveWhenRequested(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
testProvider.options.ArchiveTransport = &stubArchiveTransport{}
testProvider.options.KeepArchiveOnNode = true
diskStream, openError := testProvider.OpenDisk(context.Background(), providers.DiskReadRequest{
GuestID: "qemu/100",
})
if openError != nil {
testInstance.Fatalf("die Sicherung ließ sich nicht öffnen: %v", openError)
}
if closeError := diskStream.Close(); closeError != nil {
testInstance.Fatalf("der Datenstrom ließ sich nicht schließen: %v", closeError)
}
if len(fakeServer.deletedArchiveList()) != 0 {
testInstance.Error("das Archiv wurde trotz KeepArchiveOnNode entfernt")
}
}
// TestOpenDiskCleansUpWhenTransportFails prueft den Fehlerweg.
//
// Scheitert der Zugriff auf das Archiv, bleibt es sonst auf dem Knoten liegen —
// bei jedem naechtlichen Versuch eines. Ein voller Proxmox-Speicher legt danach
// auch die Sicherungen still, die bisher liefen.
func TestOpenDiskCleansUpWhenTransportFails(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
// Ein Transport ohne hinterlegte Zuordnung scheitert beim Oeffnen.
testProvider.options.ArchiveTransport = NewLocalArchiveTransport(map[string]string{})
if _, openError := testProvider.OpenDisk(context.Background(), providers.DiskReadRequest{
GuestID: "qemu/100",
}); openError == nil {
testInstance.Fatal("ein nicht erreichbares Archiv muss fehlschlagen")
}
if len(fakeServer.deletedArchiveList()) != 1 {
testInstance.Errorf("das Archiv blieb nach dem Fehlschlag auf dem Knoten liegen")
}
}
// TestStoragePathNamesUnsupportedStorage prueft die Pfadauflösung.
//
// ZFS, LVM und Ceph legen Archive nicht als Datei ab. Einen Pfad zu raten
// ergaebe eine Fehlermeldung ueber eine fehlende Datei — und der Betreiber
// suchte an der falschen Stelle.
func TestStoragePathNamesUnsupportedStorage(testInstance *testing.T) {
fakeServer := newFakeProxmoxServer(testInstance)
testProvider := fakeServer.newConnectedProvider(testInstance)
resolvedPath, resolveError := testProvider.StoragePath(context.Background(), "pve-01", "local")
if resolveError != nil {
testInstance.Fatalf("der Speicherpfad ließ sich nicht auflösen: %v", resolveError)
}
if resolvedPath != "/var/lib/vz" {
testInstance.Errorf("der Pfad ist %q", resolvedPath)
}
_, poolError := testProvider.StoragePath(context.Background(), "pve-01", "zfs-pool")
if poolError == nil {
testInstance.Fatal("ein Speicher ohne Pfad wurde angenommen")
}
if !strings.Contains(poolError.Error(), "zfs-pool") {
testInstance.Errorf("die Meldung nennt den Speicher nicht: %v", poolError)
}
}
// TestLocalTransportWritesArchiveAtomically prueft die Gegenrichtung.
//
// Das Archiv wird erst mit dem Close() unter seinem endgueltigen Namen
// sichtbar. Ohne diesen Schritt faende Proxmox waehrend der Uebertragung eine
// Datei, die es fuer ein vollstaendiges Archiv haelt.
func TestLocalTransportWritesArchiveAtomically(testInstance *testing.T) {
storageRoot := testInstance.TempDir()
transport := NewLocalArchiveTransport(map[string]string{"local": storageRoot})
archiveWriter, volumeIdentifier, createError := transport.CreateArchive(context.Background(),
"pve-01", "local", "vzdump-qemu-100-syncova.vma")
if createError != nil {
testInstance.Fatalf("das Archiv ließ sich nicht anlegen: %v", createError)
}
if volumeIdentifier != "local:dump/vzdump-qemu-100-syncova.vma" {
testInstance.Errorf("die Volumenkennung ist %q", volumeIdentifier)
}
archiveContent := []byte("zurueckgeschriebenes archiv")
if _, writeError := archiveWriter.Write(archiveContent); writeError != nil {
testInstance.Fatalf("das Archiv ließ sich nicht schreiben: %v", writeError)
}
finalPath := filepath.Join(storageRoot, "dump", "vzdump-qemu-100-syncova.vma")
// Vor dem Close() darf die Datei unter ihrem endgültigen Namen nicht da sein.
if _, statError := os.Stat(finalPath); statError == nil {
testInstance.Error("das unvollständige Archiv trug bereits seinen endgültigen Namen")
}
if closeError := archiveWriter.Close(); closeError != nil {
testInstance.Fatalf("das Archiv ließ sich nicht abschließen: %v", closeError)
}
writtenContent, readError := os.ReadFile(finalPath)
if readError != nil {
testInstance.Fatalf("das Archiv liegt nicht an seinem Platz: %v", readError)
}
if string(writtenContent) != string(archiveContent) {
testInstance.Errorf("der Inhalt weicht ab: %q", string(writtenContent))
}
// Es darf keine Zwischendatei liegen bleiben.
directoryEntries, listError := os.ReadDir(filepath.Join(storageRoot, "dump"))
if listError != nil {
testInstance.Fatalf("das Verzeichnis ließ sich nicht lesen: %v", listError)
}
if len(directoryEntries) != 1 {
testInstance.Errorf("im Verzeichnis liegen %d Dateien statt einer", len(directoryEntries))
}
}
// TestArchiveFileNameIsValidated ist der Sicherheitstest der Gegenrichtung.
//
// Der Dateiname entsteht aus einer Backup-Kennung und damit mittelbar aus einer
// Benutzereingabe. Ein Name mit Pfadanteil schriebe die Datei irgendwohin — auf
// dem Proxmox-Knoten, mit den Rechten des Anmeldekontos.
func TestArchiveFileNameIsValidated(testInstance *testing.T) {
storageRoot := testInstance.TempDir()
transport := NewLocalArchiveTransport(map[string]string{"local": storageRoot})
forbiddenNames := []string{
"../../etc/cron.d/lauf",
"unterordner/archiv.vma",
"..",
".",
"",
" ",
}
for _, forbiddenName := range forbiddenNames {
archiveWriter, _, createError := transport.CreateArchive(context.Background(),
"pve-01", "local", forbiddenName)
if createError == nil {
_ = archiveWriter.Close()
testInstance.Errorf("der Archivname %q wurde angenommen", forbiddenName)
}
}
}