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>
168 lines
6.3 KiB
Go
168 lines
6.3 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
)
|
|
|
|
// WriteTransformedChunk legt einen Block ab, dessen gespeicherte Form von
|
|
// seinem Klartext abweicht.
|
|
//
|
|
// Die Kennung bleibt der Hash des **Klartextes**. Nur so wirkt die
|
|
// Deduplizierung: verschlüsselte Daten sind bei jedem Durchgang verschieden,
|
|
// zwei gleiche Ursprungsblöcke ergäben also nie denselben Geheimtext. Die
|
|
// Zuordnung Klartext-Kennung → abgelegte Form ist der Kern jedes
|
|
// deduplizierenden und zugleich verschlüsselnden Backup-Systems.
|
|
//
|
|
// Die Unversehrtheit der abgelegten Form sichert nicht die Kennung, sondern
|
|
// die zurückgegebene Prüfsumme: sie gehört ins Manifest, damit ein
|
|
// Integritätslauf ohne Entschlüsselungsschlüssel prüfen kann.
|
|
func (localRepository *LocalRepository) WriteTransformedChunk(writeContext context.Context, plaintextIdentifier string, storedData []byte) (storedDigest string, wasNew bool, writeError error) {
|
|
if validationError := validateChunkIdentifier(plaintextIdentifier); validationError != nil {
|
|
return "", false, validationError
|
|
}
|
|
|
|
targetPath, pathError := localRepository.chunkPath(plaintextIdentifier)
|
|
if pathError != nil {
|
|
return "", false, pathError
|
|
}
|
|
|
|
digestOfStoredForm := sha256.Sum256(storedData)
|
|
storedDigest = hex.EncodeToString(digestOfStoredForm[:])
|
|
|
|
// Liegt der Block bereits vor, wird nichts erneut geschrieben. Die
|
|
// vorhandene Fassung bleibt maßgeblich: ein zweiter Durchgang erzeugte
|
|
// wegen der Verschlüsselung andere Bytes bei identischem Klartext.
|
|
if _, statError := os.Stat(targetPath); statError == nil {
|
|
existingDigest, digestError := localRepository.digestOfStoredChunk(plaintextIdentifier)
|
|
if digestError != nil {
|
|
return "", false, digestError
|
|
}
|
|
|
|
return existingDigest, false, nil
|
|
}
|
|
|
|
if directoryError := ensureDirectory(filepath.Dir(targetPath)); directoryError != nil {
|
|
return "", false, directoryError
|
|
}
|
|
|
|
if atomicWriteError := writeFileAtomically(targetPath, storedData, dataFilePermissions); atomicWriteError != nil {
|
|
return "", false, fmt.Errorf("der chunk konnte nicht geschrieben werden: %w", atomicWriteError)
|
|
}
|
|
|
|
localRepository.protectStoredChunk(targetPath)
|
|
|
|
return storedDigest, true, nil
|
|
}
|
|
|
|
// StoredDigestOfChunk liefert die Prüfsumme der abgelegten Form eines Blocks.
|
|
//
|
|
// Der Weg wird gebraucht, wenn ein Block bereits vorliegt: sein Eintrag im
|
|
// Manifest braucht die Prüfsumme dennoch, damit ein Integritätslauf ihn prüfen kann.
|
|
func (localRepository *LocalRepository) StoredDigestOfChunk(queryContext context.Context, chunkIdentifier string) (string, error) {
|
|
return localRepository.digestOfStoredChunk(chunkIdentifier)
|
|
}
|
|
|
|
// digestOfStoredChunk berechnet die Prüfsumme der abgelegten Form eines Blocks.
|
|
func (localRepository *LocalRepository) digestOfStoredChunk(chunkIdentifier string) (string, error) {
|
|
targetPath, pathError := localRepository.chunkPath(chunkIdentifier)
|
|
if pathError != nil {
|
|
return "", pathError
|
|
}
|
|
|
|
storedData, readError := os.ReadFile(targetPath)
|
|
if readError != nil {
|
|
if errors.Is(readError, os.ErrNotExist) {
|
|
return "", fmt.Errorf("%w (kennung %s)", ErrChunkNotFound, chunkIdentifier)
|
|
}
|
|
|
|
return "", fmt.Errorf("der chunk konnte nicht gelesen werden: %w", readError)
|
|
}
|
|
|
|
digestOfStoredForm := sha256.Sum256(storedData)
|
|
|
|
return hex.EncodeToString(digestOfStoredForm[:]), nil
|
|
}
|
|
|
|
// ReadStoredChunk liest die abgelegte Form eines Blocks.
|
|
//
|
|
// Geprüft wird gegen die im Manifest vermerkte Prüfsumme der gespeicherten
|
|
// Form, nicht gegen die Klartext-Kennung: der Klartext liegt hier noch nicht
|
|
// vor. Die abschließende Prüfung gegen die Kennung erfolgt in der Backup
|
|
// Engine, nachdem der Block entschlüsselt und entpackt wurde.
|
|
//
|
|
// Ist expectedStoredDigest leer, unterbleibt die Prüfung an dieser Stelle —
|
|
// dann muss der Aufrufer sie zwingend selbst vornehmen.
|
|
func (localRepository *LocalRepository) ReadStoredChunk(readContext context.Context, chunkIdentifier string, expectedStoredDigest string) ([]byte, error) {
|
|
targetPath, pathError := localRepository.chunkPath(chunkIdentifier)
|
|
if pathError != nil {
|
|
return nil, pathError
|
|
}
|
|
|
|
storedData, readError := os.ReadFile(targetPath)
|
|
if readError != nil {
|
|
if errors.Is(readError, os.ErrNotExist) {
|
|
return nil, fmt.Errorf("%w (kennung %s)", ErrChunkNotFound, chunkIdentifier)
|
|
}
|
|
|
|
return nil, fmt.Errorf("der chunk konnte nicht gelesen werden: %w", readError)
|
|
}
|
|
|
|
if expectedStoredDigest == "" {
|
|
return storedData, nil
|
|
}
|
|
|
|
digestOfStoredForm := sha256.Sum256(storedData)
|
|
actualDigest := hex.EncodeToString(digestOfStoredForm[:])
|
|
|
|
if actualDigest != expectedStoredDigest {
|
|
localRepository.logger.Error("beschädigter chunk erkannt",
|
|
slog.String("chunk_id", chunkIdentifier),
|
|
slog.String("expected_stored_digest", expectedStoredDigest),
|
|
slog.String("actual_stored_digest", actualDigest))
|
|
|
|
return nil, fmt.Errorf("%w (kennung %s)", ErrChunkCorrupted, chunkIdentifier)
|
|
}
|
|
|
|
return storedData, nil
|
|
}
|
|
|
|
// VerifyStoredChunk prüft einen abgelegten Block gegen eine erwartete Prüfsumme.
|
|
//
|
|
// Der Weg erlaubt es dem Integritätslauf, auch verschlüsselte Blöcke zu prüfen,
|
|
// ohne einen Schlüssel zu benötigen (PROMPT.md §14).
|
|
func (localRepository *LocalRepository) VerifyStoredChunk(verifyContext context.Context, chunkIdentifier string, expectedStoredDigest string) error {
|
|
_, readError := localRepository.ReadStoredChunk(verifyContext, chunkIdentifier, expectedStoredDigest)
|
|
return readError
|
|
}
|
|
|
|
// WriteTransformedChunkForTest überschreibt einen abgelegten Block.
|
|
//
|
|
// Der Weg dient ausschließlich Tests, die eine Beschädigung auf dem Datenträger
|
|
// nachstellen. Im Betrieb darf ein abgelegter Block niemals überschrieben
|
|
// werden: seine Kennung beschreibt genau einen Inhalt.
|
|
func (localRepository *LocalRepository) WriteTransformedChunkForTest(chunkIdentifier string, storedData []byte) (string, bool, error) {
|
|
targetPath, pathError := localRepository.chunkPath(chunkIdentifier)
|
|
if pathError != nil {
|
|
return "", false, pathError
|
|
}
|
|
|
|
if chmodError := os.Chmod(targetPath, dataFilePermissions); chmodError != nil && !errors.Is(chmodError, os.ErrNotExist) {
|
|
return "", false, chmodError
|
|
}
|
|
|
|
if writeError := writeFileAtomically(targetPath, storedData, dataFilePermissions); writeError != nil {
|
|
return "", false, writeError
|
|
}
|
|
|
|
digestOfStoredForm := sha256.Sum256(storedData)
|
|
|
|
return hex.EncodeToString(digestOfStoredForm[:]), true, nil
|
|
}
|