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>
169 lines
6.4 KiB
Go
169 lines
6.4 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
)
|
|
|
|
// dataKeyFileName ist die Ablage des Repository-Datenschlüssels.
|
|
const dataKeyFileName = "data-key.json"
|
|
|
|
// dataKeyLength ist die Länge des Datenschlüssels (AES-256).
|
|
const dataKeyLength = 32
|
|
|
|
// DataKeyRecord beschreibt den verschlüsselten Datenschlüssel eines Repositorys.
|
|
//
|
|
// Der Schlüssel gehört zum **Repository**, nicht zu einem einzelnen Backup.
|
|
// Das ist zwingend: nur so kann ein späteres Backup Blöcke wiederverwenden, die
|
|
// ein früheres abgelegt hat. Ein Schlüssel je Backup machte jede Deduplizierung
|
|
// über Backupgrenzen hinweg unmöglich — die wiederverwendeten Blöcke wären mit
|
|
// einem anderen Schlüssel verschlüsselt und für das neue Backup unlesbar.
|
|
type DataKeyRecord struct {
|
|
// EncryptedKey ist der mit dem übergeordneten Schlüssel gesicherte Datenschlüssel.
|
|
EncryptedKey []byte `json:"encrypted_key"`
|
|
// KeyVersion benennt den zur Entschlüsselung nötigen übergeordneten Schlüssel.
|
|
KeyVersion string `json:"key_version"`
|
|
// CreatedAt ist der Anlagezeitpunkt in UTC.
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// SecretSealer verschlüsselt den Datenschlüssel eines Repositorys.
|
|
//
|
|
// Die Schnittstelle entspricht dem Ausschnitt des Secret Stores, den das
|
|
// Repository tatsächlich braucht.
|
|
type SecretSealer interface {
|
|
// Encrypt verschlüsselt einen Wert und meldet die verwendete Schlüsselversion.
|
|
Encrypt(plaintext []byte) (ciphertext []byte, keyVersion string, encryptError error)
|
|
// Decrypt entschlüsselt einen Wert der angegebenen Schlüsselversion.
|
|
Decrypt(ciphertext []byte, keyVersion string) ([]byte, error)
|
|
}
|
|
|
|
// ErrDataKeyUnavailable meldet einen nicht lesbaren Datenschlüssel.
|
|
//
|
|
// Ohne ihn sind alle verschlüsselten Blöcke des Repositorys unlesbar. Das ist
|
|
// der schwerste Betriebsfall überhaupt (PROMPT.md §142).
|
|
var ErrDataKeyUnavailable = errors.New("der datenschlüssel des repositorys ist nicht verfügbar")
|
|
|
|
// dataKeyPath liefert den Ablagepfad des Datenschlüssels.
|
|
func (localRepository *LocalRepository) dataKeyPath() string {
|
|
return filepath.Join(localRepository.rootPath, directoryMetadata, dataKeyFileName)
|
|
}
|
|
|
|
// LoadOrCreateDataKey liefert den Datenschlüssel des Repositorys.
|
|
//
|
|
// Beim ersten Aufruf entsteht er und wird verschlüsselt abgelegt; danach wird
|
|
// er stets derselbe geliefert. Er liegt im Repository, damit ein angehängtes
|
|
// Repository ohne Control Server lesbar bleibt — vorausgesetzt, der
|
|
// übergeordnete Schlüssel ist vorhanden.
|
|
func (localRepository *LocalRepository) LoadOrCreateDataKey(keyContext context.Context, secretSealer SecretSealer) ([]byte, error) {
|
|
existingKey, loadError := localRepository.loadDataKey(secretSealer)
|
|
if loadError == nil {
|
|
return existingKey, nil
|
|
}
|
|
|
|
if !errors.Is(loadError, os.ErrNotExist) {
|
|
return nil, loadError
|
|
}
|
|
|
|
// Ohne Schreibsperre könnten zwei Vorgänge unterschiedliche Schlüssel
|
|
// anlegen; der zweite machte die Blöcke des ersten unlesbar.
|
|
if localRepository.lockHandle == nil {
|
|
return nil, fmt.Errorf("%w: er müsste angelegt werden, das repository ist aber nur lesend geöffnet",
|
|
ErrDataKeyUnavailable)
|
|
}
|
|
|
|
generatedKey := make([]byte, dataKeyLength)
|
|
if _, randomError := rand.Read(generatedKey); randomError != nil {
|
|
return nil, fmt.Errorf("es konnte kein sicherer datenschlüssel erzeugt werden: %w", randomError)
|
|
}
|
|
|
|
encryptedKey, keyVersion, sealError := secretSealer.Encrypt(generatedKey)
|
|
if sealError != nil {
|
|
return nil, fmt.Errorf("der datenschlüssel konnte nicht gesichert werden: %w", sealError)
|
|
}
|
|
|
|
keyRecord := DataKeyRecord{
|
|
EncryptedKey: encryptedKey,
|
|
KeyVersion: keyVersion,
|
|
CreatedAt: localRepository.timeSource().UTC(),
|
|
}
|
|
|
|
encodedRecord, encodeError := jsonMarshalIndent(keyRecord)
|
|
if encodeError != nil {
|
|
return nil, encodeError
|
|
}
|
|
|
|
if writeError := writeFileAtomically(localRepository.dataKeyPath(), encodedRecord, dataFilePermissions); writeError != nil {
|
|
return nil, fmt.Errorf("der datenschlüssel konnte nicht abgelegt werden: %w", writeError)
|
|
}
|
|
|
|
// Der Datenschlüssel ist das lohnendste Ziel des gesamten Repositorys: Wer
|
|
// diese eine kleine Datei löscht, vernichtet jedes verschlüsselte Backup,
|
|
// ohne einen einzigen Datenblock anzufassen. In einem gehärteten Repository
|
|
// trägt er deshalb dasselbe Löschkennzeichen wie die Daten selbst.
|
|
localRepository.protectStoredChunk(localRepository.dataKeyPath())
|
|
|
|
localRepository.logger.Info("datenschlüssel für das repository angelegt",
|
|
slog.String("key_version", keyVersion))
|
|
|
|
return generatedKey, nil
|
|
}
|
|
|
|
// loadDataKey liest den abgelegten Datenschlüssel.
|
|
func (localRepository *LocalRepository) loadDataKey(secretSealer SecretSealer) ([]byte, error) {
|
|
rawRecord, readError := os.ReadFile(localRepository.dataKeyPath())
|
|
if readError != nil {
|
|
// Der Fehler wird unverändert weitergereicht, damit der Aufrufer
|
|
// "noch nicht vorhanden" von einem echten Problem unterscheiden kann.
|
|
return nil, readError
|
|
}
|
|
|
|
var keyRecord DataKeyRecord
|
|
if unmarshalError := json.Unmarshal(rawRecord, &keyRecord); unmarshalError != nil {
|
|
return nil, fmt.Errorf("%w: die ablage ist unlesbar", ErrDataKeyUnavailable)
|
|
}
|
|
|
|
dataKey, unsealError := secretSealer.Decrypt(keyRecord.EncryptedKey, keyRecord.KeyVersion)
|
|
if unsealError != nil {
|
|
return nil, fmt.Errorf("%w: der übergeordnete schlüssel der version %q fehlt oder passt nicht",
|
|
ErrDataKeyUnavailable, keyRecord.KeyVersion)
|
|
}
|
|
|
|
if len(dataKey) != dataKeyLength {
|
|
return nil, fmt.Errorf("%w: er hat eine unerwartete länge", ErrDataKeyUnavailable)
|
|
}
|
|
|
|
return dataKey, nil
|
|
}
|
|
|
|
// HasDataKey meldet, ob für dieses Repository bereits ein Datenschlüssel vorliegt.
|
|
func (localRepository *LocalRepository) HasDataKey() bool {
|
|
_, statError := os.Stat(localRepository.dataKeyPath())
|
|
return statError == nil
|
|
}
|
|
|
|
// DataKeyVersion liefert die Schlüsselversion des abgelegten Datenschlüssels.
|
|
//
|
|
// Sie wird gebraucht, um bei einer Rotation des übergeordneten Schlüssels zu
|
|
// erkennen, welche Fassung noch benötigt wird.
|
|
func (localRepository *LocalRepository) DataKeyVersion() (string, error) {
|
|
rawRecord, readError := os.ReadFile(localRepository.dataKeyPath())
|
|
if readError != nil {
|
|
return "", readError
|
|
}
|
|
|
|
var keyRecord DataKeyRecord
|
|
if unmarshalError := json.Unmarshal(rawRecord, &keyRecord); unmarshalError != nil {
|
|
return "", fmt.Errorf("%w: die ablage ist unlesbar", ErrDataKeyUnavailable)
|
|
}
|
|
|
|
return keyRecord.KeyVersion, nil
|
|
}
|