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

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))
}