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

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
}