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>
196 lines
7.6 KiB
Go
196 lines
7.6 KiB
Go
package repository
|
|
|
|
import (
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// Verzeichnisse eines Repositorys (SYNCOVA_ARCHITECTURE.md §9).
|
|
//
|
|
// Die Aufteilung erlaubt es, beim Wiederaufbau gezielt nur die Manifeste zu
|
|
// lesen, ohne die weitaus größere Chunk-Ablage anzufassen.
|
|
const (
|
|
// directoryFormat enthält den Descriptor und die Formatangaben.
|
|
directoryFormat = "format"
|
|
// directoryManifests enthält die abgeschlossenen Backup-Manifeste.
|
|
directoryManifests = "manifests"
|
|
// directoryChunks enthält die eigentlichen Datenblöcke.
|
|
directoryChunks = "chunks"
|
|
// directoryIndexes enthält den Katalog und weitere Beschleuniger.
|
|
directoryIndexes = "indexes"
|
|
// directoryJournals enthält die Journale laufender Schreibsessions.
|
|
directoryJournals = "journals"
|
|
// directoryVerification enthält die Ergebnisse von Integritätsläufen.
|
|
directoryVerification = "verification"
|
|
// directoryMetadata enthält ergänzende Repository-Angaben.
|
|
directoryMetadata = "metadata"
|
|
// directoryStaging nimmt unfertige Daten auf, bis ein Commit sie sichtbar macht.
|
|
directoryStaging = "staging"
|
|
)
|
|
|
|
// Dateinamen innerhalb eines Repositorys.
|
|
const (
|
|
// fileDescriptor beschreibt das Repository und liegt in format/.
|
|
fileDescriptor = "repository.json"
|
|
// fileCatalog ist der Katalog aller Backups und liegt in indexes/.
|
|
fileCatalog = "catalog.json"
|
|
// fileLock verhindert gleichzeitige Schreibzugriffe und liegt im Wurzelverzeichnis.
|
|
fileLock = "repository.lock"
|
|
// manifestExtension ist die Endung einer Manifestdatei.
|
|
manifestExtension = ".manifest.json"
|
|
// journalExtension ist die Endung einer Journaldatei.
|
|
journalExtension = ".journal.json"
|
|
// retentionHoldExtension ist die Endung eines Schutzvermerks.
|
|
//
|
|
// Der Vermerk liegt neben dem Manifest, damit ein Wiederaufbau ohne
|
|
// Datenbank beides in einem Durchgang findet.
|
|
retentionHoldExtension = ".hold.json"
|
|
)
|
|
|
|
// allRepositoryDirectories listet die beim Anlegen zu erstellenden Verzeichnisse.
|
|
var allRepositoryDirectories = []string{
|
|
directoryFormat,
|
|
directoryManifests,
|
|
directoryChunks,
|
|
directoryIndexes,
|
|
directoryJournals,
|
|
directoryVerification,
|
|
directoryMetadata,
|
|
directoryStaging,
|
|
}
|
|
|
|
// chunkFanoutDepth ist die Zahl der Unterverzeichnisebenen der Chunk-Ablage.
|
|
//
|
|
// Zwei Ebenen à zwei Hex-Zeichen ergeben 65 536 Verzeichnisse. Ohne diese
|
|
// Aufteilung lägen Millionen Dateien in einem einzigen Verzeichnis, was auf
|
|
// gängigen Dateisystemen jede Suche unbrauchbar langsam macht.
|
|
const chunkFanoutDepth = 2
|
|
|
|
// chunkFanoutCharacters ist die Zahl der Hex-Zeichen je Verzeichnisebene.
|
|
const chunkFanoutCharacters = 2
|
|
|
|
// descriptorPath liefert den Pfad des Descriptors.
|
|
func (localRepository *LocalRepository) descriptorPath() string {
|
|
return filepath.Join(localRepository.rootPath, directoryFormat, fileDescriptor)
|
|
}
|
|
|
|
// catalogPath liefert den Pfad des Katalogs.
|
|
func (localRepository *LocalRepository) catalogPath() string {
|
|
return filepath.Join(localRepository.rootPath, directoryIndexes, fileCatalog)
|
|
}
|
|
|
|
// lockPath liefert den Pfad der Sperrdatei.
|
|
func (localRepository *LocalRepository) lockPath() string {
|
|
return filepath.Join(localRepository.rootPath, fileLock)
|
|
}
|
|
|
|
// manifestPath liefert den Pfad des Manifests eines Backups.
|
|
func (localRepository *LocalRepository) manifestPath(backupID string) string {
|
|
return filepath.Join(localRepository.rootPath, directoryManifests, backupID+manifestExtension)
|
|
}
|
|
|
|
// journalPath liefert den Pfad des Journals einer Schreibsession.
|
|
func (localRepository *LocalRepository) journalPath(sessionID string) string {
|
|
return filepath.Join(localRepository.rootPath, directoryJournals, sessionID+journalExtension)
|
|
}
|
|
|
|
// stagingPath liefert das Arbeitsverzeichnis einer Schreibsession.
|
|
func (localRepository *LocalRepository) stagingPath(sessionID string) string {
|
|
return filepath.Join(localRepository.rootPath, directoryStaging, sessionID)
|
|
}
|
|
|
|
// chunkPath liefert den Ablagepfad eines Chunks anhand seiner Kennung.
|
|
//
|
|
// Der Pfad ergibt sich allein aus dem Inhaltshash. Damit ist die Ablage
|
|
// inhaltsadressiert: derselbe Inhalt landet immer am selben Ort, was die
|
|
// Deduplizierung ohne zusätzlichen Index ermöglicht (PROMPT.md §10).
|
|
func (localRepository *LocalRepository) chunkPath(chunkIdentifier string) (string, error) {
|
|
// Die Kennung wird geprüft, bevor sie zu einem Pfad wird: ein manipulierter
|
|
// Wert wie "../../etc/passwd" darf niemals aus dem Repository herausführen.
|
|
if validationError := validateChunkIdentifier(chunkIdentifier); validationError != nil {
|
|
return "", validationError
|
|
}
|
|
|
|
pathElements := make([]string, 0, chunkFanoutDepth+2)
|
|
pathElements = append(pathElements, localRepository.rootPath, directoryChunks)
|
|
|
|
for fanoutLevel := 0; fanoutLevel < chunkFanoutDepth; fanoutLevel++ {
|
|
startIndex := fanoutLevel * chunkFanoutCharacters
|
|
pathElements = append(pathElements, chunkIdentifier[startIndex:startIndex+chunkFanoutCharacters])
|
|
}
|
|
|
|
pathElements = append(pathElements, chunkIdentifier)
|
|
|
|
return filepath.Join(pathElements...), nil
|
|
}
|
|
|
|
// chunkIdentifierLength ist die Länge einer Chunk-Kennung in Hex-Zeichen.
|
|
//
|
|
// SHA-256 liefert 32 Byte, also 64 Hex-Zeichen.
|
|
const chunkIdentifierLength = 64
|
|
|
|
// validateChunkIdentifier prüft eine Chunk-Kennung auf Wohlgeformtheit.
|
|
//
|
|
// Die Prüfung ist eine Sicherheitsmaßnahme: Kennungen stammen aus Manifesten,
|
|
// die auch aus einem fremden Repository stammen können. Ohne Prüfung liesse
|
|
// sich über einen manipulierten Wert auf beliebige Pfade zugreifen
|
|
// (Path Traversal, PROMPT.md §97).
|
|
func validateChunkIdentifier(chunkIdentifier string) error {
|
|
if len(chunkIdentifier) != chunkIdentifierLength {
|
|
return fmt.Errorf("%w: die kennung hat %d zeichen, erwartet werden %d",
|
|
ErrInvalidChunkIdentifier, len(chunkIdentifier), chunkIdentifierLength)
|
|
}
|
|
|
|
// Nur kleingeschriebene Hex-Zeichen sind zulässig. Damit sind Pfadtrenner,
|
|
// Punkte und alle anderen Sonderzeichen ausgeschlossen.
|
|
for _, identifierRune := range chunkIdentifier {
|
|
isHexDigit := (identifierRune >= '0' && identifierRune <= '9') ||
|
|
(identifierRune >= 'a' && identifierRune <= 'f')
|
|
|
|
if !isHexDigit {
|
|
return fmt.Errorf("%w: die kennung enthält ein unzulässiges zeichen", ErrInvalidChunkIdentifier)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateBackupIdentifier prüft eine Backup-Kennung auf Wohlgeformtheit.
|
|
//
|
|
// Auch sie wird zu einem Dateipfad und muss deshalb dieselbe Sorgfalt erfahren
|
|
// wie eine Chunk-Kennung.
|
|
func validateBackupIdentifier(backupIdentifier string) error {
|
|
if backupIdentifier == "" {
|
|
return fmt.Errorf("%w: die kennung ist leer", ErrInvalidBackupIdentifier)
|
|
}
|
|
|
|
// Ein Pfadtrenner oder eine Punktfolge würde aus dem Repository herausführen.
|
|
if strings.ContainsAny(backupIdentifier, `/\`) || strings.Contains(backupIdentifier, "..") {
|
|
return fmt.Errorf("%w: die kennung enthält unzulässige zeichen", ErrInvalidBackupIdentifier)
|
|
}
|
|
|
|
// UUIDs sind 36 Zeichen lang; etwas Spielraum lässt spätere Formate zu.
|
|
if len(backupIdentifier) > 64 {
|
|
return fmt.Errorf("%w: die kennung ist zu lang", ErrInvalidBackupIdentifier)
|
|
}
|
|
|
|
for _, identifierRune := range backupIdentifier {
|
|
isAllowed := (identifierRune >= '0' && identifierRune <= '9') ||
|
|
(identifierRune >= 'a' && identifierRune <= 'z') ||
|
|
(identifierRune >= 'A' && identifierRune <= 'Z') ||
|
|
identifierRune == '-' || identifierRune == '_'
|
|
|
|
if !isAllowed {
|
|
return fmt.Errorf("%w: die kennung enthält unzulässige zeichen", ErrInvalidBackupIdentifier)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// retentionHoldPath liefert den Pfad des Schutzvermerks eines Backups.
|
|
func (localRepository *LocalRepository) retentionHoldPath(backupID string) string {
|
|
return filepath.Join(localRepository.rootPath, directoryManifests, backupID+retentionHoldExtension)
|
|
}
|