syncova-backup/packages/repository/retention.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

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)
}