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>
356 lines
13 KiB
Go
356 lines
13 KiB
Go
package proxmox
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/crypto/ssh"
|
|
)
|
|
|
|
// SSHArchiveTransport liest Sicherungsarchive über SSH vom Proxmox-Knoten.
|
|
//
|
|
// Der universelle Weg: Er funktioniert unabhängig davon, wie der Speicher
|
|
// eingerichtet ist, und braucht keinen gemeinsamen Einhängepunkt. Der Preis ist
|
|
// ein zweiter Zugang neben dem API-Token — SSH auf den Knoten.
|
|
//
|
|
// **Zum Lesen der Archivdatei genügt ein unprivilegiertes Konto**, sofern es die
|
|
// Datei lesen darf. Root-Zugang ist ausdrücklich nicht nötig, und wer ihn
|
|
// einrichtet, gibt mehr Recht, als die Aufgabe verlangt.
|
|
type SSHArchiveTransport struct {
|
|
// options beschreiben den Zugang.
|
|
options SSHTransportOptions
|
|
// storagePathResolver löst eine Speicherkennung in einen Pfad auf.
|
|
storagePathResolver StoragePathResolver
|
|
}
|
|
|
|
// StoragePathResolver liefert das Verzeichnis eines Proxmox-Speichers.
|
|
//
|
|
// Der Provider erfüllt diese Schnittstelle über die Proxmox-API. Sie steht hier
|
|
// als Naht, damit sich der Transport ohne laufenden Verbund prüfen lässt.
|
|
type StoragePathResolver interface {
|
|
// StoragePath liefert den Pfad eines Speichers auf einem Knoten.
|
|
StoragePath(resolveContext context.Context, nodeName string, storageIdentifier string) (string, error)
|
|
}
|
|
|
|
// SSHTransportOptions beschreiben den SSH-Zugang zum Knoten.
|
|
type SSHTransportOptions struct {
|
|
// Username ist das Anmeldekonto auf dem Knoten.
|
|
Username string
|
|
// Port ist der SSH-Port; 0 wählt 22.
|
|
Port int
|
|
// PrivateKeyPEM ist der private Schlüssel im PEM-Format.
|
|
//
|
|
// Ein Schlüssel und kein Passwort: Ein Passwort müsste im Klartext
|
|
// vorliegen, damit ein Dienst es verwenden kann.
|
|
PrivateKeyPEM []byte
|
|
// PrivateKeyPassphrase entschlüsselt einen geschützten Schlüssel.
|
|
PrivateKeyPassphrase []byte
|
|
// HostKeyFingerprints ordnet Knotennamen ihren erwarteten
|
|
// SSH-Wirtsschlüsseln zu (SHA-256, Base64 wie in `ssh-keygen -l`).
|
|
//
|
|
// **Ohne Eintrag wird die Verbindung abgelehnt.** Ein Transport, der jeden
|
|
// Wirtsschlüssel annimmt, macht aus einem Zwischenangriff eine Einladung:
|
|
// Der Angreifer liefert dann das Archiv, das Syncova für ein Backup hält.
|
|
// Dieselbe Entscheidung wie bei der Fingerabdruckbindung des API-Zugangs
|
|
// (Phase 7) — nur strenger, weil es hier keinen CA-Weg gibt.
|
|
HostKeyFingerprints map[string]string
|
|
// DialTimeout begrenzt den Verbindungsaufbau; 0 wählt 30 Sekunden.
|
|
DialTimeout time.Duration
|
|
}
|
|
|
|
// NewSSHArchiveTransport erzeugt den SSH-Zugriff.
|
|
func NewSSHArchiveTransport(transportOptions SSHTransportOptions,
|
|
storagePathResolver StoragePathResolver) (*SSHArchiveTransport, error) {
|
|
if strings.TrimSpace(transportOptions.Username) == "" {
|
|
return nil, fmt.Errorf("fuer den ssh-zugriff fehlt das anmeldekonto")
|
|
}
|
|
|
|
if len(transportOptions.PrivateKeyPEM) == 0 {
|
|
return nil, fmt.Errorf("fuer den ssh-zugriff fehlt der private schluessel")
|
|
}
|
|
|
|
if len(transportOptions.HostKeyFingerprints) == 0 {
|
|
return nil, fmt.Errorf("fuer den ssh-zugriff fehlen die fingerabdruecke der " +
|
|
"wirtsschluessel. ohne sie liesse sich ein zwischenangriff nicht erkennen")
|
|
}
|
|
|
|
if storagePathResolver == nil {
|
|
return nil, fmt.Errorf("fuer den ssh-zugriff fehlt die aufloesung der speicherpfade")
|
|
}
|
|
|
|
return &SSHArchiveTransport{
|
|
options: transportOptions,
|
|
storagePathResolver: storagePathResolver,
|
|
}, nil
|
|
}
|
|
|
|
// OpenArchive öffnet ein Sicherungsarchiv über SSH.
|
|
//
|
|
// Gelesen wird über `cat` auf der Gegenseite. Das ist bewusst einfach: SFTP
|
|
// brächte eine weitere Abhängigkeit und für einen einzelnen Datenstrom keinen
|
|
// Gewinn. Der Fehlerkanal wird mitgelesen — bricht `cat` ab, steht dort der
|
|
// Grund, und ohne ihn bekäme der Aufrufer einen abgeschnittenen Datenstrom ohne
|
|
// Erklärung.
|
|
func (transport *SSHArchiveTransport) OpenArchive(openContext context.Context, nodeName string,
|
|
volumeIdentifier string) (io.ReadCloser, error) {
|
|
storageIdentifier, relativePath, parseError := SplitVolumeIdentifier(volumeIdentifier)
|
|
if parseError != nil {
|
|
return nil, parseError
|
|
}
|
|
|
|
storageRoot, resolveError := transport.storagePathResolver.StoragePath(openContext,
|
|
nodeName, storageIdentifier)
|
|
if resolveError != nil {
|
|
return nil, resolveError
|
|
}
|
|
|
|
archivePath := strings.TrimRight(storageRoot, "/") + "/" + strings.TrimLeft(relativePath, "/")
|
|
|
|
sshClient, connectError := transport.connect(openContext, 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)
|
|
}
|
|
|
|
standardOutput, pipeError := sshSession.StdoutPipe()
|
|
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
|
|
|
|
// Der Pfad wird als einzelnes Argument uebergeben und in Anfuehrungszeichen
|
|
// gesetzt. Er stammt vom Knoten selbst, ist also nicht beliebig — aber ein
|
|
// Dateiname mit Leerzeichen zerriss den Befehl sonst.
|
|
remoteCommand := "cat -- " + quoteShellArgument(archivePath)
|
|
|
|
if startError := sshSession.Start(remoteCommand); startError != nil {
|
|
_ = sshSession.Close()
|
|
_ = sshClient.Close()
|
|
|
|
return nil, fmt.Errorf("das archiv %q liess sich nicht lesen: %w", archivePath, startError)
|
|
}
|
|
|
|
return &sshArchiveReader{
|
|
reader: standardOutput,
|
|
session: sshSession,
|
|
client: sshClient,
|
|
errorOutput: &errorOutput,
|
|
archivePath: archivePath,
|
|
}, nil
|
|
}
|
|
|
|
// connect baut die SSH-Verbindung zu einem Knoten auf.
|
|
func (transport *SSHArchiveTransport) connect(dialContext context.Context, nodeName string) (*ssh.Client, error) {
|
|
expectedFingerprint, hasFingerprint := transport.options.HostKeyFingerprints[nodeName]
|
|
if !hasFingerprint {
|
|
return nil, fmt.Errorf("fuer den knoten %q ist kein fingerabdruck des wirtsschluessels "+
|
|
"hinterlegt. ohne ihn liesse sich ein zwischenangriff nicht erkennen", nodeName)
|
|
}
|
|
|
|
signer, signerError := transport.buildSigner()
|
|
if signerError != nil {
|
|
return nil, signerError
|
|
}
|
|
|
|
dialTimeout := transport.options.DialTimeout
|
|
if dialTimeout <= 0 {
|
|
dialTimeout = 30 * time.Second
|
|
}
|
|
|
|
clientConfiguration := &ssh.ClientConfig{
|
|
User: transport.options.Username,
|
|
Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)},
|
|
HostKeyCallback: buildFingerprintCallback(expectedFingerprint),
|
|
Timeout: dialTimeout,
|
|
}
|
|
|
|
sshPort := transport.options.Port
|
|
if sshPort <= 0 {
|
|
sshPort = 22
|
|
}
|
|
|
|
networkAddress := net.JoinHostPort(nodeName, fmt.Sprintf("%d", sshPort))
|
|
|
|
// Der Verbindungsaufbau achtet auf den Context: Ein abgebrochener
|
|
// Sicherungslauf soll nicht am Zeitablauf haengen.
|
|
networkDialer := net.Dialer{Timeout: dialTimeout}
|
|
|
|
networkConnection, dialError := networkDialer.DialContext(dialContext, "tcp", networkAddress)
|
|
if dialError != nil {
|
|
return nil, fmt.Errorf("der knoten %q ist ueber ssh nicht erreichbar: %w", nodeName, dialError)
|
|
}
|
|
|
|
clientConnection, newChannels, incomingRequests, handshakeError := ssh.NewClientConn(
|
|
networkConnection, networkAddress, clientConfiguration)
|
|
if handshakeError != nil {
|
|
_ = networkConnection.Close()
|
|
|
|
return nil, fmt.Errorf("die ssh-anmeldung an %q schlug fehl: %w", nodeName, handshakeError)
|
|
}
|
|
|
|
return ssh.NewClient(clientConnection, newChannels, incomingRequests), nil
|
|
}
|
|
|
|
// buildSigner liest den privaten Schlüssel.
|
|
func (transport *SSHArchiveTransport) buildSigner() (ssh.Signer, error) {
|
|
if len(transport.options.PrivateKeyPassphrase) > 0 {
|
|
signer, parseError := ssh.ParsePrivateKeyWithPassphrase(
|
|
transport.options.PrivateKeyPEM, transport.options.PrivateKeyPassphrase)
|
|
if parseError != nil {
|
|
return nil, fmt.Errorf("der private schluessel liess sich nicht lesen: %w", parseError)
|
|
}
|
|
|
|
return signer, nil
|
|
}
|
|
|
|
signer, parseError := ssh.ParsePrivateKey(transport.options.PrivateKeyPEM)
|
|
if parseError != nil {
|
|
return nil, fmt.Errorf("der private schluessel liess sich nicht lesen: %w", parseError)
|
|
}
|
|
|
|
return signer, nil
|
|
}
|
|
|
|
// buildFingerprintCallback prüft den Wirtsschlüssel gegen einen Fingerabdruck.
|
|
//
|
|
// Verglichen wird der SHA-256-Fingerabdruck in der Schreibweise von
|
|
// `ssh-keygen -l` — mit oder ohne das Präfix "SHA256:". Ein Vergleich, der das
|
|
// Präfix verlangt, scheiterte an einem kopierten Wert ohne, und der Betreiber
|
|
// suchte den Fehler beim Schlüssel statt bei der Schreibweise.
|
|
func buildFingerprintCallback(expectedFingerprint string) ssh.HostKeyCallback {
|
|
normalizedExpectation := normalizeSSHFingerprint(expectedFingerprint)
|
|
|
|
return func(hostname string, remote net.Addr, publicKey ssh.PublicKey) error {
|
|
presentedFingerprint := normalizeSSHFingerprint(ssh.FingerprintSHA256(publicKey))
|
|
|
|
if presentedFingerprint != normalizedExpectation {
|
|
return fmt.Errorf("der wirtsschluessel von %q stimmt nicht mit dem hinterlegten "+
|
|
"fingerabdruck ueberein (erwartet %s, erhalten %s)",
|
|
hostname, normalizedExpectation, presentedFingerprint)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// normalizeSSHFingerprint bringt einen SSH-Fingerabdruck auf eine Vergleichsform.
|
|
//
|
|
// Ausdruecklich getrennt von normalizeFingerprint fuer TLS-Zertifikate: Jener
|
|
// entfernt Doppelpunkte und macht Kleinbuchstaben — bei einem Hex-Fingerabdruck
|
|
// richtig, bei einem Base64-Wert **falsch**. Base64 unterscheidet Gross- und
|
|
// Kleinschreibung; ein kleingeschriebener SSH-Fingerabdruck passte auf keinen
|
|
// Schluessel mehr, und die Verbindung schluege mit einer Meldung fehl, die nach
|
|
// einem Angriff aussieht.
|
|
func normalizeSSHFingerprint(rawFingerprint string) string {
|
|
trimmedFingerprint := strings.TrimSpace(rawFingerprint)
|
|
trimmedFingerprint = strings.TrimPrefix(trimmedFingerprint, "SHA256:")
|
|
|
|
// Die abschliessende Auffuellung aus Base64 laesst ssh-keygen weg.
|
|
return strings.TrimRight(trimmedFingerprint, "=")
|
|
}
|
|
|
|
// quoteShellArgument setzt ein Argument sicher in einfache Anführungszeichen.
|
|
func quoteShellArgument(rawArgument string) string {
|
|
return "'" + strings.ReplaceAll(rawArgument, "'", `'\''`) + "'"
|
|
}
|
|
|
|
// sshArchiveReader verbindet den Datenstrom mit seiner Sitzung.
|
|
//
|
|
// Ohne diese Hülle blieben Sitzung und Verbindung nach dem Lesen offen — bei
|
|
// einem Auftrag je Nacht fiele das nicht auf, bei hundert Gästen schon.
|
|
type sshArchiveReader struct {
|
|
// reader ist der Datenstrom der Gegenseite.
|
|
reader io.Reader
|
|
// session ist die SSH-Sitzung.
|
|
session *ssh.Session
|
|
// client ist die SSH-Verbindung.
|
|
client *ssh.Client
|
|
// errorOutput sammelt den Fehlerkanal der Gegenseite.
|
|
errorOutput *strings.Builder
|
|
// archivePath benennt die gelesene Datei in Fehlermeldungen.
|
|
archivePath string
|
|
// closeOnce stellt sicher, dass nur einmal aufgeräumt wird.
|
|
closeOnce sync.Once
|
|
}
|
|
|
|
// Read liest aus dem Datenstrom.
|
|
func (archiveReader *sshArchiveReader) Read(targetBuffer []byte) (int, error) {
|
|
return archiveReader.reader.Read(targetBuffer)
|
|
}
|
|
|
|
// Close beendet Sitzung und Verbindung.
|
|
//
|
|
// Der Rückgabewert des entfernten Befehls wird ausgewertet: Bricht `cat` ab —
|
|
// etwa weil die Datei mitten im Lesen verschwindet —, liefert der Datenstrom
|
|
// nur ein reguläres Ende. Ohne diese Prüfung hielte die Backup Engine ein
|
|
// abgeschnittenes Archiv für vollständig.
|
|
func (archiveReader *sshArchiveReader) Close() error {
|
|
var closeError error
|
|
|
|
archiveReader.closeOnce.Do(func() {
|
|
waitError := archiveReader.session.Wait()
|
|
|
|
_ = archiveReader.session.Close()
|
|
_ = archiveReader.client.Close()
|
|
|
|
if waitError != nil {
|
|
remoteMessage := strings.TrimSpace(archiveReader.errorOutput.String())
|
|
if remoteMessage == "" {
|
|
remoteMessage = waitError.Error()
|
|
}
|
|
|
|
closeError = fmt.Errorf("das lesen von %q brach auf dem knoten ab: %s",
|
|
archiveReader.archivePath, remoteMessage)
|
|
}
|
|
})
|
|
|
|
return closeError
|
|
}
|
|
|
|
// LoadPrivateKeyFile liest einen privaten Schlüssel aus einer Datei.
|
|
//
|
|
// Ein Helfer, damit der Schlüssel nicht durch eine Umgebungsvariable muss: Ein
|
|
// mehrzeiliger PEM-Block in einer Variablen ist fehleranfällig, und
|
|
// `systemctl show` zeigte ihn an.
|
|
func LoadPrivateKeyFile(keyPath string) ([]byte, error) {
|
|
keyMaterial, readError := os.ReadFile(keyPath)
|
|
if readError != nil {
|
|
return nil, fmt.Errorf("der private schluessel %q liess sich nicht lesen: %w",
|
|
keyPath, readError)
|
|
}
|
|
|
|
return keyMaterial, nil
|
|
}
|
|
|
|
// DecodeBase64PrivateKey liest einen Base64-kodierten Schlüssel.
|
|
//
|
|
// Für Umgebungen, in denen der Schlüssel durch eine Variable muss — dort ist
|
|
// eine einzelne Zeile handhabbar, ein PEM-Block nicht.
|
|
func DecodeBase64PrivateKey(encodedKey string) ([]byte, error) {
|
|
decodedKey, decodeError := base64.StdEncoding.DecodeString(strings.TrimSpace(encodedKey))
|
|
if decodeError != nil {
|
|
return nil, fmt.Errorf("der base64-kodierte schluessel ist unlesbar: %w", decodeError)
|
|
}
|
|
|
|
return decodedKey, nil
|
|
}
|