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 }