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>
439 lines
16 KiB
Go
439 lines
16 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
// RetentionHold ist der Schutzvermerk eines Backups.
|
|
//
|
|
// Warum eine eigene Datei und nicht ein Feld im Manifest? Das Manifest ist mit
|
|
// seinem ContentHash **versiegelt** — es ist der Abschlussvermerk des Backups.
|
|
// Wer nachträglich ein Feld darin ändert, macht entweder den Hash ungültig oder
|
|
// muss ihn neu bilden; im zweiten Fall wäre das Manifest nicht mehr das, was
|
|
// beim Commit geprüft wurde. Beides untergräbt genau die Eigenschaft, auf der
|
|
// die gesamte Integritätsprüfung beruht.
|
|
//
|
|
// Der Vermerk liegt deshalb daneben, wird selbst gegen Löschung geschützt und
|
|
// führt seine eigene Historie mit: Ein Repository muss ohne Control Server
|
|
// deutbar bleiben, also gehört die Frage „warum wird das noch gehalten?" ins
|
|
// Repository und nicht nur in die Datenbank.
|
|
type RetentionHold struct {
|
|
// BackupID ist das geschützte Backup.
|
|
BackupID string `json:"backup_id"`
|
|
// ImmutableUntil ist das Ende der verlängerten Aufbewahrungsfrist in UTC.
|
|
//
|
|
// Nil bedeutet: keine Verlängerung über die Frist des Manifests hinaus.
|
|
ImmutableUntil *time.Time `json:"immutable_until,omitempty"`
|
|
// LegalHold meldet einen unbefristeten Schutz für Beweiszwecke.
|
|
LegalHold bool `json:"legal_hold"`
|
|
// LegalHoldReason begründet den unbefristeten Schutz.
|
|
//
|
|
// Ein Legal Hold ohne Begründung wäre nach einem Jahr nicht mehr auflösbar:
|
|
// Niemand traute sich, ihn aufzuheben, weil niemand mehr wüsste, warum er
|
|
// besteht.
|
|
LegalHoldReason string `json:"legal_hold_reason,omitempty"`
|
|
// History hält jede Änderung fest.
|
|
History []RetentionChange `json:"history"`
|
|
// UpdatedAt ist der Zeitpunkt der letzten Änderung in UTC.
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// RetentionChange ist eine einzelne Änderung am Schutzvermerk.
|
|
type RetentionChange struct {
|
|
// Action benennt die Änderung.
|
|
Action string `json:"action"`
|
|
// Actor ist der Verursacher.
|
|
Actor string `json:"actor"`
|
|
// Reason begründet die Änderung.
|
|
Reason string `json:"reason,omitempty"`
|
|
// PreviousUntil ist die vorherige Frist in UTC.
|
|
PreviousUntil *time.Time `json:"previous_until,omitempty"`
|
|
// NewUntil ist die neue Frist in UTC.
|
|
NewUntil *time.Time `json:"new_until,omitempty"`
|
|
// OccurredAt ist der Zeitpunkt in UTC.
|
|
OccurredAt time.Time `json:"occurred_at"`
|
|
}
|
|
|
|
// Aktionen am Schutzvermerk.
|
|
const (
|
|
// retentionActionExtended meldet eine verlängerte Frist.
|
|
retentionActionExtended = "retention_extended"
|
|
// retentionActionLegalHoldPlaced meldet einen gesetzten Legal Hold.
|
|
retentionActionLegalHoldPlaced = "legal_hold_placed"
|
|
// retentionActionLegalHoldReleased meldet einen aufgehobenen Legal Hold.
|
|
retentionActionLegalHoldReleased = "legal_hold_released"
|
|
)
|
|
|
|
// ProtectionStatus ist die geltende Schutzlage eines Backups.
|
|
//
|
|
// Sie fasst Manifest und Schutzvermerk zusammen. Kein Aufrufer soll beide
|
|
// Quellen selbst zusammenrechnen müssen — die Regel „es gilt die längere Frist"
|
|
// gehört an genau eine Stelle.
|
|
type ProtectionStatus struct {
|
|
// BackupID ist das betrachtete Backup.
|
|
BackupID string `json:"backup_id"`
|
|
// ImmutableUntil ist das Ende der geltenden Frist in UTC.
|
|
ImmutableUntil *time.Time `json:"immutable_until,omitempty"`
|
|
// LegalHold meldet einen unbefristeten Schutz.
|
|
LegalHold bool `json:"legal_hold"`
|
|
// LegalHoldReason begründet den unbefristeten Schutz.
|
|
LegalHoldReason string `json:"legal_hold_reason,omitempty"`
|
|
// ExtendedBeyondManifest meldet eine über das Manifest hinaus verlängerte Frist.
|
|
ExtendedBeyondManifest bool `json:"extended_beyond_manifest"`
|
|
}
|
|
|
|
// IsProtected meldet, ob eine Löschung derzeit unzulässig ist.
|
|
func (status ProtectionStatus) IsProtected(referenceTime time.Time) bool {
|
|
if status.LegalHold {
|
|
return true
|
|
}
|
|
|
|
return status.ImmutableUntil != nil && referenceTime.Before(*status.ImmutableUntil)
|
|
}
|
|
|
|
// Describe erklärt die Schutzlage in einem Satz.
|
|
func (status ProtectionStatus) Describe(referenceTime time.Time) string {
|
|
if status.LegalHold {
|
|
if status.LegalHoldReason != "" {
|
|
return "Fuer Beweiszwecke gehalten: " + status.LegalHoldReason
|
|
}
|
|
|
|
return "Fuer Beweiszwecke gehalten."
|
|
}
|
|
|
|
if status.ImmutableUntil == nil {
|
|
return "Kein Aufbewahrungsschutz."
|
|
}
|
|
|
|
if referenceTime.Before(*status.ImmutableUntil) {
|
|
return fmt.Sprintf("Geschuetzt bis %s.", status.ImmutableUntil.Format(time.RFC3339))
|
|
}
|
|
|
|
return fmt.Sprintf("Die Aufbewahrungsfrist endete am %s.", status.ImmutableUntil.Format(time.RFC3339))
|
|
}
|
|
|
|
// ProtectionStatusOf liefert die geltende Schutzlage eines Backups.
|
|
func (localRepository *LocalRepository) ProtectionStatusOf(statusContext context.Context, backupID string) (ProtectionStatus, error) {
|
|
manifest, manifestError := localRepository.ReadManifest(statusContext, backupID)
|
|
if manifestError != nil {
|
|
return ProtectionStatus{}, manifestError
|
|
}
|
|
|
|
return localRepository.protectionStatusFromManifest(manifest)
|
|
}
|
|
|
|
// protectionStatusFromManifest bildet die Schutzlage aus Manifest und Vermerk.
|
|
func (localRepository *LocalRepository) protectionStatusFromManifest(manifest *Manifest) (ProtectionStatus, error) {
|
|
status := ProtectionStatus{
|
|
BackupID: manifest.BackupID,
|
|
ImmutableUntil: manifest.ImmutableUntil,
|
|
}
|
|
|
|
retentionHold, holdError := localRepository.ReadRetentionHold(manifest.BackupID)
|
|
if holdError != nil {
|
|
return ProtectionStatus{}, holdError
|
|
}
|
|
|
|
if retentionHold == nil {
|
|
return status, nil
|
|
}
|
|
|
|
status.LegalHold = retentionHold.LegalHold
|
|
status.LegalHoldReason = retentionHold.LegalHoldReason
|
|
|
|
// Es gilt immer die **längere** Frist. Der Vermerk kann verlängern, niemals
|
|
// verkürzen — sonst wäre er der bequemste Weg, den Schutz auszuhebeln.
|
|
if retentionHold.ImmutableUntil != nil {
|
|
if status.ImmutableUntil == nil || retentionHold.ImmutableUntil.After(*status.ImmutableUntil) {
|
|
status.ImmutableUntil = retentionHold.ImmutableUntil
|
|
status.ExtendedBeyondManifest = true
|
|
}
|
|
}
|
|
|
|
return status, nil
|
|
}
|
|
|
|
// ReadRetentionHold liest den Schutzvermerk eines Backups.
|
|
//
|
|
// Ein fehlender Vermerk ist kein Fehler: Die meisten Backups haben keinen.
|
|
func (localRepository *LocalRepository) ReadRetentionHold(backupID string) (*RetentionHold, error) {
|
|
if validationError := validateBackupIdentifier(backupID); validationError != nil {
|
|
return nil, validationError
|
|
}
|
|
|
|
holdFilePath := localRepository.retentionHoldPath(backupID)
|
|
|
|
holdContent, readError := os.ReadFile(holdFilePath)
|
|
if errors.Is(readError, os.ErrNotExist) {
|
|
return nil, nil
|
|
}
|
|
|
|
if readError != nil {
|
|
return nil, fmt.Errorf("der schutzvermerk konnte nicht gelesen werden: %w", readError)
|
|
}
|
|
|
|
var retentionHold RetentionHold
|
|
if decodeError := json.Unmarshal(holdContent, &retentionHold); decodeError != nil {
|
|
// Ein unlesbarer Vermerk wird **nicht** als „kein Schutz" gedeutet. Das
|
|
// wäre der gefährlichste Fehlerfall der ganzen Phase: Eine beschädigte
|
|
// Datei gäbe ein geschütztes Backup zum Löschen frei.
|
|
return nil, fmt.Errorf("der schutzvermerk von %s ist unlesbar; das backup gilt weiterhin als geschützt: %w",
|
|
backupID, decodeError)
|
|
}
|
|
|
|
return &retentionHold, nil
|
|
}
|
|
|
|
// ExtendRetention verlängert die Aufbewahrungsfrist eines Backups.
|
|
//
|
|
// Verkürzen ist ausgeschlossen — auch für einen Administrator. Wäre es möglich,
|
|
// bestünde der Aufbewahrungsschutz nur aus einer Zahl, die der Angreifer
|
|
// zuallererst ändern würde.
|
|
func (localRepository *LocalRepository) ExtendRetention(extendContext context.Context, backupID string, newImmutableUntil time.Time, actor string, reason string) (ProtectionStatus, error) {
|
|
if localRepository.lockHandle == nil {
|
|
return ProtectionStatus{}, errors.New("das repository wurde nur lesend geöffnet; der schutz lässt sich nicht ändern")
|
|
}
|
|
|
|
currentStatus, statusError := localRepository.ProtectionStatusOf(extendContext, backupID)
|
|
if statusError != nil {
|
|
return ProtectionStatus{}, statusError
|
|
}
|
|
|
|
if currentStatus.ImmutableUntil != nil && !newImmutableUntil.After(*currentStatus.ImmutableUntil) {
|
|
return ProtectionStatus{}, fmt.Errorf("%w (geltend bis %s, verlangt bis %s)",
|
|
ErrRetentionCannotBeShortened,
|
|
currentStatus.ImmutableUntil.Format(time.RFC3339),
|
|
newImmutableUntil.Format(time.RFC3339))
|
|
}
|
|
|
|
retentionHold, loadError := localRepository.loadOrCreateHold(backupID)
|
|
if loadError != nil {
|
|
return ProtectionStatus{}, loadError
|
|
}
|
|
|
|
previousUntil := currentStatus.ImmutableUntil
|
|
normalizedUntil := newImmutableUntil.UTC()
|
|
|
|
retentionHold.ImmutableUntil = &normalizedUntil
|
|
retentionHold.History = append(retentionHold.History, RetentionChange{
|
|
Action: retentionActionExtended,
|
|
Actor: actor,
|
|
Reason: reason,
|
|
PreviousUntil: previousUntil,
|
|
NewUntil: &normalizedUntil,
|
|
OccurredAt: localRepository.timeSource().UTC(),
|
|
})
|
|
|
|
if writeError := localRepository.writeRetentionHold(retentionHold); writeError != nil {
|
|
return ProtectionStatus{}, writeError
|
|
}
|
|
|
|
localRepository.logger.Info("die aufbewahrungsfrist wurde verlängert",
|
|
slog.String("backup_id", backupID),
|
|
slog.String("bis", normalizedUntil.Format(time.RFC3339)),
|
|
slog.String("veranlasst_von", actor))
|
|
|
|
return localRepository.ProtectionStatusOf(extendContext, backupID)
|
|
}
|
|
|
|
// PlaceLegalHold hält ein Backup unbefristet für Beweiszwecke.
|
|
func (localRepository *LocalRepository) PlaceLegalHold(holdContext context.Context, backupID string, actor string, reason string) (ProtectionStatus, error) {
|
|
if localRepository.lockHandle == nil {
|
|
return ProtectionStatus{}, errors.New("das repository wurde nur lesend geöffnet; der schutz lässt sich nicht ändern")
|
|
}
|
|
|
|
if reason == "" {
|
|
// Ohne Begründung liesse sich der Hold später nicht mehr auflösen:
|
|
// Niemand traut sich, einen Schutz aufzuheben, dessen Anlass unbekannt ist.
|
|
return ProtectionStatus{}, errors.New("ein legal hold verlangt eine begründung")
|
|
}
|
|
|
|
if _, manifestError := localRepository.ReadManifest(holdContext, backupID); manifestError != nil {
|
|
return ProtectionStatus{}, manifestError
|
|
}
|
|
|
|
retentionHold, loadError := localRepository.loadOrCreateHold(backupID)
|
|
if loadError != nil {
|
|
return ProtectionStatus{}, loadError
|
|
}
|
|
|
|
retentionHold.LegalHold = true
|
|
retentionHold.LegalHoldReason = reason
|
|
retentionHold.History = append(retentionHold.History, RetentionChange{
|
|
Action: retentionActionLegalHoldPlaced,
|
|
Actor: actor,
|
|
Reason: reason,
|
|
OccurredAt: localRepository.timeSource().UTC(),
|
|
})
|
|
|
|
if writeError := localRepository.writeRetentionHold(retentionHold); writeError != nil {
|
|
return ProtectionStatus{}, writeError
|
|
}
|
|
|
|
localRepository.logger.Warn("ein backup wird für beweiszwecke gehalten",
|
|
slog.String("backup_id", backupID),
|
|
slog.String("grund", reason),
|
|
slog.String("veranlasst_von", actor))
|
|
|
|
return localRepository.ProtectionStatusOf(holdContext, backupID)
|
|
}
|
|
|
|
// ReleaseLegalHold hebt einen unbefristeten Schutz auf.
|
|
//
|
|
// Die Aufbewahrungsfrist bleibt davon unberührt: Ein aufgehobener Legal Hold
|
|
// gibt ein Backup nicht zum Löschen frei, das noch unter Frist steht.
|
|
func (localRepository *LocalRepository) ReleaseLegalHold(releaseContext context.Context, backupID string, actor string, reason string) (ProtectionStatus, error) {
|
|
if localRepository.lockHandle == nil {
|
|
return ProtectionStatus{}, errors.New("das repository wurde nur lesend geöffnet; der schutz lässt sich nicht ändern")
|
|
}
|
|
|
|
if reason == "" {
|
|
return ProtectionStatus{}, errors.New("die aufhebung eines legal holds verlangt eine begründung")
|
|
}
|
|
|
|
retentionHold, loadError := localRepository.loadOrCreateHold(backupID)
|
|
if loadError != nil {
|
|
return ProtectionStatus{}, loadError
|
|
}
|
|
|
|
if !retentionHold.LegalHold {
|
|
return ProtectionStatus{}, errors.New("für dieses backup besteht kein legal hold")
|
|
}
|
|
|
|
retentionHold.LegalHold = false
|
|
retentionHold.LegalHoldReason = ""
|
|
retentionHold.History = append(retentionHold.History, RetentionChange{
|
|
Action: retentionActionLegalHoldReleased,
|
|
Actor: actor,
|
|
Reason: reason,
|
|
OccurredAt: localRepository.timeSource().UTC(),
|
|
})
|
|
|
|
if writeError := localRepository.writeRetentionHold(retentionHold); writeError != nil {
|
|
return ProtectionStatus{}, writeError
|
|
}
|
|
|
|
localRepository.logger.Warn("ein legal hold wurde aufgehoben",
|
|
slog.String("backup_id", backupID),
|
|
slog.String("grund", reason),
|
|
slog.String("veranlasst_von", actor))
|
|
|
|
return localRepository.ProtectionStatusOf(releaseContext, backupID)
|
|
}
|
|
|
|
// loadOrCreateHold liest den Schutzvermerk oder legt einen neuen an.
|
|
func (localRepository *LocalRepository) loadOrCreateHold(backupID string) (*RetentionHold, error) {
|
|
existingHold, readError := localRepository.ReadRetentionHold(backupID)
|
|
if readError != nil {
|
|
return nil, readError
|
|
}
|
|
|
|
if existingHold != nil {
|
|
return existingHold, nil
|
|
}
|
|
|
|
return &RetentionHold{BackupID: backupID, History: make([]RetentionChange, 0, 1)}, nil
|
|
}
|
|
|
|
// writeRetentionHold legt den Schutzvermerk ab.
|
|
//
|
|
// Der Vermerk wird selbst gegen Löschung geschützt: Wäre er ungeschützt, liesse
|
|
// sich ein Legal Hold durch das Entfernen einer einzigen kleinen Datei
|
|
// aufheben — und der Weg wäre bequemer als der über die Anwendung.
|
|
func (localRepository *LocalRepository) writeRetentionHold(retentionHold *RetentionHold) error {
|
|
retentionHold.UpdatedAt = localRepository.timeSource().UTC()
|
|
|
|
encodedHold, encodeError := json.MarshalIndent(retentionHold, "", " ")
|
|
if encodeError != nil {
|
|
return fmt.Errorf("der schutzvermerk konnte nicht kodiert werden: %w", encodeError)
|
|
}
|
|
|
|
holdFilePath := localRepository.retentionHoldPath(retentionHold.BackupID)
|
|
|
|
// Ein bestehender geschützter Vermerk lässt sich nicht per rename ersetzen.
|
|
// Der Schutz wird deshalb kurz aufgehoben und danach neu gesetzt.
|
|
if _, statError := os.Stat(holdFilePath); statError == nil {
|
|
if releaseError := releaseManifestFile(holdFilePath); releaseError != nil {
|
|
return fmt.Errorf("der bestehende schutzvermerk liess sich nicht zum schreiben freigeben: %w", releaseError)
|
|
}
|
|
}
|
|
|
|
if writeError := writeFileAtomically(holdFilePath, encodedHold, dataFilePermissions); writeError != nil {
|
|
return fmt.Errorf("der schutzvermerk konnte nicht abgelegt werden: %w", writeError)
|
|
}
|
|
|
|
if localRepository.descriptor.Immutable {
|
|
if protectError := protectManifestFile(holdFilePath); protectError != nil {
|
|
localRepository.logger.Warn("der schutzvermerk konnte nicht gegen löschung geschützt werden",
|
|
slog.String("backup_id", retentionHold.BackupID),
|
|
slog.String("grund", protectError.Error()))
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// removeRetentionHold entfernt den Schutzvermerk eines gelöschten Backups.
|
|
func (localRepository *LocalRepository) removeRetentionHold(backupID string) error {
|
|
holdFilePath := localRepository.retentionHoldPath(backupID)
|
|
|
|
if _, statError := os.Stat(holdFilePath); errors.Is(statError, os.ErrNotExist) {
|
|
return nil
|
|
}
|
|
|
|
if releaseError := releaseManifestFile(holdFilePath); releaseError != nil {
|
|
return releaseError
|
|
}
|
|
|
|
if removeError := os.Remove(holdFilePath); removeError != nil && !errors.Is(removeError, os.ErrNotExist) {
|
|
return fmt.Errorf("der schutzvermerk konnte nicht entfernt werden: %w", removeError)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// protectStoredChunk schützt einen frisch abgelegten Datenblock vor Löschung.
|
|
//
|
|
// Der Schritt stammt aus einem Angriffsversuch, der zunächst gelang: Ein
|
|
// geschütztes Manifest überlebte `rm -rf`, **seine Chunks nicht**. Zurück blieb
|
|
// ein Manifest, das auf nichts mehr zeigte — ein Backup, das sich für vollständig
|
|
// ausgibt und leer ist. Genau das darf es nicht geben (PROMPT.md §137).
|
|
//
|
|
// In einem gehärteten Repository gibt es deshalb keine ungeschützten Daten:
|
|
// Jeder Block trägt das Kennzeichen von dem Moment an, in dem er abgelegt wird.
|
|
// Ob ein späteres Backup ihn wiederverwendet, spielt keine Rolle — der Schutz
|
|
// gilt dem Inhalt, nicht dem Verweis.
|
|
//
|
|
// Fehler werden protokolliert, aber nicht geworfen: Ein Backup daran scheitern
|
|
// zu lassen wäre der falsche Tausch. Die tatsächlich erreichte Stufe steht in
|
|
// der Messung, nicht in einer Behauptung.
|
|
func (localRepository *LocalRepository) protectStoredChunk(chunkFilePath string) {
|
|
if !localRepository.descriptor.Immutable {
|
|
return
|
|
}
|
|
|
|
if protectError := protectManifestFile(chunkFilePath); protectError != nil {
|
|
localRepository.logger.Warn("ein datenblock konnte nicht gegen löschung geschützt werden",
|
|
slog.String("pfad", chunkFilePath),
|
|
slog.String("grund", protectError.Error()))
|
|
}
|
|
}
|
|
|
|
// releaseStoredChunk gibt einen Datenblock zum Entfernen frei.
|
|
//
|
|
// Aufgerufen wird das ausschließlich von der Bereinigung, und dort nur für
|
|
// Blöcke, die **kein** Manifest mehr referenziert.
|
|
func (localRepository *LocalRepository) releaseStoredChunk(chunkFilePath string) error {
|
|
if !localRepository.descriptor.Immutable {
|
|
return nil
|
|
}
|
|
|
|
return releaseManifestFile(chunkFilePath)
|
|
}
|