package proxmox import ( "context" "fmt" "io" "os" "path" "path/filepath" "strings" "sync" "golang.org/x/crypto/ssh" ) // ArchiveWriter legt ein Sicherungsarchiv auf einem Proxmox-Speicher ab. // // Die Gegenrichtung zu ArchiveTransport — und aus demselben Grund nötig: Die // Proxmox-API nimmt zur Wiederherstellung ausschließlich eine **Volumenkennung** // entgegen, also eine Datei, die auf einem Speicher des Knotens bereits liegt. // Einen Endpunkt, an den sich ein Archiv hochladen ließe, gibt es nicht. // // Ohne diese Naht endete die Wiederherstellung dort, wo sie anfängt: Das Archiv // läge unversehrt im Syncova-Repository und käme nie auf den Knoten. // // Bewusst eine **eigene** Schnittstelle statt einer Erweiterung von // ArchiveTransport: Ein Zugriffsweg kann lesend eingerichtet sein, ohne // schreiben zu dürfen — eine schreibgeschützt eingehängte Freigabe etwa. Wer // nur sichert, braucht das Schreibrecht nicht, und ein Recht, das niemand // braucht, sollte niemand haben. type ArchiveWriter interface { // CreateArchive legt eine Archivdatei an und liefert ihre Volumenkennung. // // Der Datenstrom wird erst mit dem Close() gültig: Ein halb geschriebenes // Archiv, das Proxmox für vollständig hält, ergäbe eine Wiederherstellung, // die scheitert — oder schlimmer, eine Maschine mit halben Daten. CreateArchive(createContext context.Context, nodeName string, storageIdentifier string, fileName string) (writeCloser io.WriteCloser, volumeIdentifier string, createError error) } // archiveDirectory ist das Unterverzeichnis für Sicherungsarchive. // // Proxmox legt Archive eines verzeichnisbasierten Speichers ausnahmslos unter // "dump/" ab und findet sie auch nur dort. Ein Archiv daneben existiert für die // Oberfläche und die API nicht. const archiveDirectory = "dump" // Sicherstellen, dass beide Transporte auch schreiben können. var ( _ ArchiveWriter = (*LocalArchiveTransport)(nil) _ ArchiveWriter = (*SSHArchiveTransport)(nil) ) // CreateArchive legt ein Archiv über das Dateisystem ab. func (transport *LocalArchiveTransport) CreateArchive(_ context.Context, nodeName string, storageIdentifier string, fileName string) (io.WriteCloser, string, error) { if validationError := validateArchiveFileName(fileName); validationError != nil { return nil, "", validationError } 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) } targetDirectory := filepath.Join(mountRoot, archiveDirectory) if makeError := os.MkdirAll(targetDirectory, 0o700); makeError != nil { return nil, "", fmt.Errorf("das verzeichnis %q liess sich nicht anlegen: %w", targetDirectory, makeError) } finalPath := filepath.Join(targetDirectory, fileName) // Geschrieben wird unter einem Zwischennamen und erst beim Close() // umbenannt — dasselbe Vorgehen wie beim Ablegen eines Blocks (Phase 2). // Bricht die Uebertragung ab, liegt kein Archiv da, das Proxmox fuer // vollstaendig haelt; es liegt eine Datei da, die niemand fuer eines haelt. temporaryFile, createError := os.CreateTemp(targetDirectory, "."+fileName+".teil-*") if createError != nil { return nil, "", fmt.Errorf("das archiv %q liess sich nicht anlegen: %w", finalPath, createError) } volumeIdentifier := storageIdentifier + ":" + archiveDirectory + "/" + fileName return &localArchiveUpload{ file: temporaryFile, temporaryPath: temporaryFile.Name(), finalPath: finalPath, }, volumeIdentifier, nil } // localArchiveUpload schreibt ein Archiv und macht es erst beim Schließen sichtbar. type localArchiveUpload struct { // file ist die Zwischendatei. file *os.File // temporaryPath ist ihr Name. temporaryPath string // finalPath ist der endgültige Name. finalPath string // closeOnce stellt sicher, dass nur einmal abgeschlossen wird. closeOnce sync.Once // closeError hält das Ergebnis des Abschlusses. closeError error } // Write schreibt in die Zwischendatei. func (upload *localArchiveUpload) Write(sourceBuffer []byte) (int, error) { return upload.file.Write(sourceBuffer) } // Close macht das Archiv sichtbar. func (upload *localArchiveUpload) Close() error { upload.closeOnce.Do(func() { // fsync vor dem Umbenennen: Ohne ihn überlebt die Datei einen // Stromausfall unvollständig, trägt aber bereits den endgültigen Namen // (derselbe Grund wie beim vierstufigen Schreiben in Phase 2). if syncError := upload.file.Sync(); syncError != nil { _ = upload.file.Close() _ = os.Remove(upload.temporaryPath) upload.closeError = fmt.Errorf("das archiv liess sich nicht sichern: %w", syncError) return } if fileCloseError := upload.file.Close(); fileCloseError != nil { _ = os.Remove(upload.temporaryPath) upload.closeError = fileCloseError return } if renameError := os.Rename(upload.temporaryPath, upload.finalPath); renameError != nil { _ = os.Remove(upload.temporaryPath) upload.closeError = fmt.Errorf("das archiv liess sich nicht an seinen platz bringen: %w", renameError) return } }) return upload.closeError } // Abort entfernt eine angefangene Übertragung. // // Getrennt vom Close(), weil ein abgebrochener Vorgang etwas anderes ist als // ein abgeschlossener: Wer beides zusammenlegt, räumt entweder zu viel auf oder // lässt bei jedem Fehler eine Datei liegen. func (upload *localArchiveUpload) Abort() error { _ = upload.file.Close() return os.Remove(upload.temporaryPath) } // CreateArchive legt ein Archiv über SSH ab. // // Geschrieben wird über die Standardeingabe eines `cat`, das in eine // Zwischendatei umleitet; erst beim Close() wird umbenannt. Der Rückgabewert // der Gegenseite wird ausgewertet — bricht das Schreiben ab, etwa weil der // Speicher voll ist, meldet Close() das, statt ein halbes Archiv als fertig // auszugeben. func (transport *SSHArchiveTransport) CreateArchive(createContext context.Context, nodeName string, storageIdentifier string, fileName string) (io.WriteCloser, string, error) { if validationError := validateArchiveFileName(fileName); validationError != nil { return nil, "", validationError } storageRoot, resolveError := transport.storagePathResolver.StoragePath(createContext, nodeName, storageIdentifier) if resolveError != nil { return nil, "", resolveError } targetDirectory := strings.TrimRight(storageRoot, "/") + "/" + archiveDirectory finalPath := targetDirectory + "/" + fileName temporaryPath := targetDirectory + "/." + fileName + ".teil" sshClient, connectError := transport.connect(createContext, nodeName) if connectError != nil { return nil, "", connectError } sshSession, sessionError := sshClient.NewSession() if sessionError != nil { _ = sshClient.Close() return nil, "", fmt.Errorf("die ssh-sitzung zu %q liess sich nicht oeffnen: %w", nodeName, sessionError) } standardInput, pipeError := sshSession.StdinPipe() if pipeError != nil { _ = sshSession.Close() _ = sshClient.Close() return nil, "", fmt.Errorf("der datenstrom liess sich nicht anbinden: %w", pipeError) } var errorOutput strings.Builder sshSession.Stderr = &errorOutput // mkdir, schreiben, umbenennen — in einem Aufruf, damit kein Zustand // zwischen zwei Sitzungen entsteht. Die Verkettung mit && bricht ab, sobald // ein Schritt scheitert; der Rueckgabewert traegt das nach aussen. remoteCommand := "mkdir -p -- " + quoteShellArgument(targetDirectory) + " && cat > " + quoteShellArgument(temporaryPath) + " && mv -- " + quoteShellArgument(temporaryPath) + " " + quoteShellArgument(finalPath) if startError := sshSession.Start(remoteCommand); startError != nil { _ = sshSession.Close() _ = sshClient.Close() return nil, "", fmt.Errorf("das archiv %q liess sich nicht anlegen: %w", finalPath, startError) } volumeIdentifier := storageIdentifier + ":" + archiveDirectory + "/" + fileName return &sshArchiveUpload{ writer: standardInput, session: sshSession, client: sshClient, errorOutput: &errorOutput, archivePath: finalPath, temporaryPath: temporaryPath, }, volumeIdentifier, nil } // sshArchiveUpload schreibt ein Archiv über eine SSH-Sitzung. type sshArchiveUpload struct { // writer ist die Standardeingabe der Gegenseite. writer io.WriteCloser // session ist die SSH-Sitzung. session *ssh.Session // client ist die SSH-Verbindung. client io.Closer // errorOutput sammelt den Fehlerkanal der Gegenseite. errorOutput *strings.Builder // archivePath benennt die Datei in Fehlermeldungen. archivePath string // temporaryPath ist der Zwischenname auf dem Knoten. temporaryPath string // closeOnce stellt sicher, dass nur einmal abgeschlossen wird. closeOnce sync.Once // closeError hält das Ergebnis des Abschlusses. closeError error } // Write schiebt Daten an die Gegenseite. func (upload *sshArchiveUpload) Write(sourceBuffer []byte) (int, error) { return upload.writer.Write(sourceBuffer) } // Close schließt die Übertragung ab und wertet das Ergebnis aus. func (upload *sshArchiveUpload) Close() error { upload.closeOnce.Do(func() { // Erst die Eingabe schliessen: `cat` endet dadurch und `mv` laeuft an. // Ohne diesen Schritt wartete Wait() endlos. if inputCloseError := upload.writer.Close(); inputCloseError != nil { upload.closeError = inputCloseError } // Der Rueckgabewert ist der eigentliche Nachweis. Bricht das Schreiben // ab — voller Speicher ist der haeufigste Fall —, waere ein // stillschweigendes Ende die schlimmste Auskunft: Proxmox faende ein // Archiv, das sich fuer vollstaendig ausgibt. if waitError := upload.session.Wait(); waitError != nil && upload.closeError == nil { upload.closeError = fmt.Errorf("das archiv %q wurde nicht vollstaendig geschrieben: %w (%s)", upload.archivePath, waitError, strings.TrimSpace(upload.errorOutput.String())) } _ = upload.session.Close() _ = upload.client.Close() }) return upload.closeError } // validateArchiveFileName weist Dateinamen zurück, die aus dem Verzeichnis führen. // // Der Name entsteht aus einer Backup-Kennung und damit mittelbar aus einer // Benutzereingabe. Ein Name wie "../../etc/cron.d/lauf" schriebe die Datei an // eine Stelle, an der sie ausgeführt wird — auf dem Proxmox-Knoten, mit den // Rechten des Anmeldekontos. func validateArchiveFileName(fileName string) error { trimmedName := strings.TrimSpace(fileName) if trimmedName == "" { return fmt.Errorf("es wurde kein dateiname fuer das archiv angegeben") } if trimmedName != path.Base(trimmedName) || trimmedName == "." || trimmedName == ".." { return fmt.Errorf("der archivname %q darf keinen pfadanteil enthalten", fileName) } if strings.ContainsAny(trimmedName, "/\\\x00") { return fmt.Errorf("der archivname %q enthaelt unzulaessige zeichen", fileName) } return nil }