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

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
}