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>
306 lines
11 KiB
Go
306 lines
11 KiB
Go
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
|
|
}
|