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>
119 lines
4.3 KiB
Go
119 lines
4.3 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// LocalArchiveTransport liest Sicherungsarchive über das Dateisystem.
|
|
//
|
|
// Der einfachste und schnellste Weg — er gilt in zwei Fällen:
|
|
//
|
|
// 1. Syncova läuft auf dem Proxmox-Knoten selbst.
|
|
// 2. Der Proxmox-Sicherungsspeicher ist auf dem Syncova-Server eingehängt
|
|
// (NFS oder CIFS, wie es bei einem gemeinsamen Speicher ohnehin üblich ist).
|
|
//
|
|
// Er überträgt nichts über das Netz und braucht keine Anmeldedaten — die Datei
|
|
// wird gelesen, wo sie liegt. In einem Verbund mit gemeinsamem Speicher ist das
|
|
// die Bauart, die man ohnehin hat.
|
|
type LocalArchiveTransport struct {
|
|
// mountRoots ordnet Proxmox-Speicherkennungen lokalen Pfaden zu.
|
|
//
|
|
// Beispiel: {"local": "/var/lib/vz", "nfs-backup": "/mnt/proxmox-backup"}.
|
|
//
|
|
// Die Zuordnung ist ausdrücklich, nicht geraten: Ein Speicher heißt auf dem
|
|
// Knoten anders als auf dem Server, der ihn einhängt, und ein geratener
|
|
// Pfad führte entweder ins Leere oder — schlimmer — auf fremde Daten.
|
|
mountRoots map[string]string
|
|
}
|
|
|
|
// NewLocalArchiveTransport erzeugt den Dateizugriff.
|
|
func NewLocalArchiveTransport(mountRoots map[string]string) *LocalArchiveTransport {
|
|
copiedRoots := make(map[string]string, len(mountRoots))
|
|
|
|
for storageIdentifier, mountPath := range mountRoots {
|
|
copiedRoots[storageIdentifier] = filepath.Clean(mountPath)
|
|
}
|
|
|
|
return &LocalArchiveTransport{mountRoots: copiedRoots}
|
|
}
|
|
|
|
// OpenArchive öffnet ein Sicherungsarchiv über das Dateisystem.
|
|
func (transport *LocalArchiveTransport) OpenArchive(_ context.Context, nodeName string,
|
|
volumeIdentifier string) (io.ReadCloser, error) {
|
|
storageIdentifier, relativePath, parseError := SplitVolumeIdentifier(volumeIdentifier)
|
|
if parseError != nil {
|
|
return nil, parseError
|
|
}
|
|
|
|
mountRoot, isMapped := transport.mountRoots[storageIdentifier]
|
|
if !isMapped {
|
|
return nil, fmt.Errorf("fuer den proxmox-speicher %q ist kein lokaler pfad hinterlegt "+
|
|
"(knoten %s). tragen sie ihn ein oder verwenden sie den ssh-zugriff",
|
|
storageIdentifier, nodeName)
|
|
}
|
|
|
|
archivePath := filepath.Join(mountRoot, filepath.FromSlash(relativePath))
|
|
|
|
// Der zusammengesetzte Pfad muss unterhalb des Einhängepunkts bleiben.
|
|
//
|
|
// Die Volumenkennung kommt vom Proxmox-Knoten. Vertrauenswuerdig ist sie
|
|
// nur so weit, wie man dem Knoten vertraut — und ein Pfad wie
|
|
// "backup/../../../etc/shadow" laese dann eine beliebige Datei des
|
|
// Syncova-Servers ins Backup (dieselbe Ueberlegung wie bei
|
|
// validateManifestPath, Phase 5).
|
|
if !isWithinDirectory(archivePath, mountRoot) {
|
|
return nil, fmt.Errorf("die volumenkennung %q fuehrt aus dem speicherverzeichnis heraus",
|
|
volumeIdentifier)
|
|
}
|
|
|
|
archiveFile, openError := os.Open(archivePath)
|
|
if openError != nil {
|
|
return nil, fmt.Errorf("das sicherungsarchiv %q liess sich nicht oeffnen: %w",
|
|
archivePath, openError)
|
|
}
|
|
|
|
return archiveFile, nil
|
|
}
|
|
|
|
// SplitVolumeIdentifier zerlegt eine Proxmox-Volumenkennung.
|
|
//
|
|
// Sie hat die Form "<speicher>:<relativer-pfad>", etwa
|
|
// "local:backup/vzdump-qemu-100-2026_08_11-02_00_00.vma". Der Doppelpunkt
|
|
// trennt genau einmal — ein Pfad darf selbst keinen enthalten.
|
|
func SplitVolumeIdentifier(volumeIdentifier string) (storageIdentifier string, relativePath string, parseError error) {
|
|
trimmedIdentifier := strings.TrimSpace(volumeIdentifier)
|
|
|
|
storageIdentifier, relativePath, hasSeparator := strings.Cut(trimmedIdentifier, ":")
|
|
if !hasSeparator || storageIdentifier == "" || relativePath == "" {
|
|
return "", "", fmt.Errorf("die volumenkennung %q hat nicht die form <speicher>:<pfad>",
|
|
volumeIdentifier)
|
|
}
|
|
|
|
return storageIdentifier, relativePath, nil
|
|
}
|
|
|
|
// isWithinDirectory meldet, ob ein Pfad in einem Verzeichnis liegt.
|
|
//
|
|
// Ueber die Pfadtrennung, nicht ueber strings.HasPrefix: Sonst gaelte
|
|
// "/mnt/backups-alt" als Teil von "/mnt/backup" (derselbe Fund wie in Phase 19).
|
|
func isWithinDirectory(candidatePath string, directoryPath string) bool {
|
|
cleanedCandidate := filepath.Clean(candidatePath)
|
|
cleanedDirectory := filepath.Clean(directoryPath)
|
|
|
|
if cleanedCandidate == cleanedDirectory {
|
|
return true
|
|
}
|
|
|
|
relativePath, relativeError := filepath.Rel(cleanedDirectory, cleanedCandidate)
|
|
if relativeError != nil {
|
|
return false
|
|
}
|
|
|
|
return relativePath != ".." && !strings.HasPrefix(relativePath, ".."+string(filepath.Separator))
|
|
}
|