syncova-backup/packages/disasterrecovery/snapshot.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

331 lines
14 KiB
Go

// Package disasterrecovery sichert die Control-Plane-Konfiguration im
// Repository und stellt sie von dort wieder her
// (SYNCOVA_IMPLEMENTATION_PLAN.md §20).
//
// Die Entscheidung, die dieses Paket traegt, steht in einem Satz:
//
// Eine Konfigurationssicherung, die nur auf dem Control-Server liegt, ist
// beim Verlust des Control-Servers wertlos.
//
// Genau das ist Szenario A. Wer die Konfiguration in ein Verzeichnis neben der
// Datenbank schreibt, hat sie im Ernstfall nicht mehr — Server und Datenbank
// gehen typischerweise gemeinsam verloren, weil sie auf derselben Maschine
// stehen. Der einzige Ort, der den Verlust ueberlebt, ist das Repository:
// Es liegt anderswo, es ist selbstbeschreibend, und ohne es waere ohnehin alles
// verloren.
package disasterrecovery
import (
"fmt"
"time"
)
// SnapshotFormatVersion ist die Version des Sicherungssatzes.
//
// Sie wird beim Einlesen geprueft. Ein Satz aus einer kuenftigen Version wird
// abgelehnt, statt teilweise gelesen zu werden: Eine halb wiederhergestellte
// Konfiguration ist schlimmer als gar keine, weil sie arbeitsfaehig aussieht.
const SnapshotFormatVersion = 1
// SnapshotFileName ist der Dateiname des Sicherungssatzes im Repository.
const SnapshotFileName = "control-plane.json"
// SnapshotDirectory ist das Verzeichnis des Sicherungssatzes im Repository.
//
// Unter metadata/, nicht unter manifests/: Der Satz beschreibt die Anlage, nicht
// ein Backup. Im Katalog hat er nichts zu suchen, und ein Rebuild darf ueber ihn
// hinweggehen, ohne ihn anzufassen.
const SnapshotDirectory = "metadata/control-plane"
// Snapshot ist die gesicherte Control-Plane-Konfiguration.
//
// Enthalten ist ausschliesslich **Konfiguration**, keine Betriebsdaten. Laeufe,
// Meldungen, Pruefungen und Kennzahlen fehlen bewusst: Sie beschreiben eine
// Vergangenheit, die nach einem Totalverlust nicht wiederkehrt, und sie waeren
// um Groessenordnungen umfangreicher als das, was zum Weiterarbeiten noetig ist.
//
// Die Wiederherstellungspunkte fehlen ebenfalls — sie stehen in den Manifesten
// des Repositorys und werden von dort rekonstruiert. Sie hier zu doppeln
// erzeugte zwei Quellen fuer dieselbe Aussage, und beim naechsten Backup liefen
// sie auseinander.
type Snapshot struct {
// FormatVersion ist die Version des Sicherungssatzes.
FormatVersion int `json:"format_version"`
// RepositoryID benennt das Repository, in dem der Satz liegt.
//
// Sie verhindert, dass ein versehentlich kopierter Satz zu einem fremden
// Repository gehoert und dort eine falsche Anlage beschreibt — dieselbe
// Ueberlegung wie beim Katalog (Phase 2).
RepositoryID string `json:"repository_id"`
// CreatedAt ist der Zeitpunkt der Sicherung in UTC.
CreatedAt time.Time `json:"created_at"`
// CreatedBy ist der Anmeldename oder das Programm, das sie erzeugt hat.
CreatedBy string `json:"created_by,omitempty"`
// ProductVersion ist die Programmversion zur Zeit der Sicherung.
//
// Sie steht dabei, damit nach einem Totalverlust erkennbar ist, welche
// Fassung die Anlage betrieben hat.
ProductVersion string `json:"product_version,omitempty"`
// SchemaVersion ist der Migrationsstand der Datenbank.
//
// Der wichtigste Wert des ganzen Kopfes: Ein Sicherungssatz aus Schemastand
// 12 laesst sich nicht in eine Datenbank des Standes 9 einspielen, und der
// Fehler faellt sonst erst dann auf, wenn Spalten fehlen.
SchemaVersion int `json:"schema_version"`
// Repositories sind die eingerichteten Ablagen.
Repositories []RepositoryRecord `json:"repositories"`
// RetentionPolicies sind die Aufbewahrungsregeln.
RetentionPolicies []RetentionPolicyRecord `json:"retention_policies"`
// Jobs sind die Sicherungsauftraege samt Quellen.
Jobs []JobRecord `json:"jobs"`
// MaintenanceWindows sind die Wartungsfenster.
MaintenanceWindows []MaintenanceWindowRecord `json:"maintenance_windows"`
// NotificationChannels sind die Benachrichtigungswege **ohne Geheimnisse**.
NotificationChannels []NotificationChannelRecord `json:"notification_channels"`
// Users sind die Konten **ohne Passwoerter**.
Users []UserRecord `json:"users"`
// Settings sind die Systemeinstellungen.
Settings []SettingRecord `json:"settings"`
// OmittedForSecurity benennt, was bewusst nicht gesichert wurde.
//
// Sie steht **im Satz selbst**, nicht nur in der Betriebsanleitung: Wer nach
// einem Totalverlust eine Anlage wiederherstellt, hat die Anleitung nicht
// dabei und muss aus der Datei selbst erfahren, was er noch zu tun hat.
OmittedForSecurity []string `json:"omitted_for_security"`
}
// RepositoryRecord ist eine gesicherte Repository-Einrichtung.
type RepositoryRecord struct {
// ID ist der oeffentliche Bezeichner.
ID string `json:"id"`
// Name ist die Bezeichnung.
Name string `json:"name"`
// RepositoryType ist die Art der Ablage.
RepositoryType string `json:"repository_type"`
// Location ist der Pfad oder die Adresse.
Location string `json:"location"`
// RepositoryUUID ist die Kennung im Repository selbst.
RepositoryUUID string `json:"repository_uuid,omitempty"`
// Status ist der Zustand.
Status string `json:"status"`
// Hardened meldet ein gehaertetes Repository.
Hardened bool `json:"hardened"`
// CapacityBytes ist die hinterlegte Kapazitaet.
CapacityBytes *int64 `json:"capacity_bytes,omitempty"`
// RetentionSeconds ist die Aufbewahrungsfrist.
RetentionSeconds *int64 `json:"retention_seconds,omitempty"`
// MinimumRetentionSeconds ist die Mindestaufbewahrung.
MinimumRetentionSeconds *int64 `json:"minimum_retention_seconds,omitempty"`
}
// RetentionPolicyRecord ist eine gesicherte Aufbewahrungsregel.
type RetentionPolicyRecord struct {
// ID ist der oeffentliche Bezeichner.
ID string `json:"id"`
// Name ist die Bezeichnung.
Name string `json:"name"`
// Rules ist die Regel als JSON.
Rules []byte `json:"rules,omitempty"`
// KeepWithinSeconds ist die Mindesthaltezeit.
KeepWithinSeconds *int64 `json:"keep_within_seconds,omitempty"`
// KeepLast ist die Zahl stets gehaltener Wiederherstellungspunkte.
KeepLast *int `json:"keep_last,omitempty"`
// KeepDaily haelt taegliche Punkte.
KeepDaily *int `json:"keep_daily,omitempty"`
// KeepWeekly haelt woechentliche Punkte.
KeepWeekly *int `json:"keep_weekly,omitempty"`
// KeepMonthly haelt monatliche Punkte.
KeepMonthly *int `json:"keep_monthly,omitempty"`
// KeepYearly haelt jaehrliche Punkte.
KeepYearly *int `json:"keep_yearly,omitempty"`
// TimeZone ist die Zeitzone der Regel.
TimeZone string `json:"time_zone,omitempty"`
}
// JobRecord ist ein gesicherter Sicherungsauftrag.
type JobRecord struct {
// ID ist der oeffentliche Bezeichner.
ID string `json:"id"`
// Name ist die Bezeichnung.
Name string `json:"name"`
// Description ist die Beschreibung.
Description string `json:"description,omitempty"`
// Status ist der Zustand des Auftrags.
Status string `json:"status"`
// Priority ist die Einstufung.
//
// Eine Zeichenkette, keine Zahl: Das Schema fuehrt sprechende Stufen
// ("critical", "high", …), damit eine Einstufung ohne Nachschlagen lesbar ist.
Priority string `json:"priority"`
// ScheduleType ist die Art des Zeitplans.
ScheduleType string `json:"schedule_type"`
// ScheduleConfig ist der Zeitplan als JSON.
ScheduleConfig []byte `json:"schedule_config,omitempty"`
// RepositoryID benennt das Zielrepository.
RepositoryID string `json:"repository_id"`
// RetentionPolicyID benennt die Aufbewahrungsregel.
RetentionPolicyID string `json:"retention_policy_id,omitempty"`
// RPOSeconds ist die zugesagte Wiederherstellungslage.
RPOSeconds *int64 `json:"rpo_seconds,omitempty"`
// RTOSeconds ist die zugesagte Wiederherstellungszeit.
RTOSeconds *int64 `json:"rto_seconds,omitempty"`
// BandwidthLimitBPS begrenzt die Leserate.
BandwidthLimitBPS *int64 `json:"bandwidth_limit_bps,omitempty"`
// MaxConcurrency ist die Zahl gleichzeitiger Quellen.
MaxConcurrency int `json:"max_concurrency"`
// RetryPolicy ist die Wiederholungsregel als JSON.
RetryPolicy []byte `json:"retry_policy,omitempty"`
// Sources sind die Quellen des Auftrags.
Sources []JobSourceRecord `json:"sources"`
}
// JobSourceRecord ist eine gesicherte Quelle.
type JobSourceRecord struct {
// ID ist der oeffentliche Bezeichner.
ID string `json:"id"`
// SourceType ist die Art der Quelle.
SourceType string `json:"source_type"`
// SourceID ist die Kennung der Quelle.
SourceID string `json:"source_id"`
// SourceName ist der sprechende Name.
SourceName string `json:"source_name"`
// IncludePatterns sind die einzuschliessenden Muster.
IncludePatterns []string `json:"include_patterns,omitempty"`
// ExcludePatterns sind die auszuschliessenden Muster.
ExcludePatterns []string `json:"exclude_patterns,omitempty"`
}
// MaintenanceWindowRecord ist ein gesichertes Wartungsfenster.
type MaintenanceWindowRecord struct {
// ID ist der oeffentliche Bezeichner.
ID string `json:"id"`
// Name ist die Bezeichnung.
Name string `json:"name"`
// WindowKind unterscheidet Erlaubnis- und Sperrfenster.
WindowKind string `json:"window_kind"`
// StartsAt ist der Beginn in UTC.
StartsAt time.Time `json:"starts_at"`
// EndsAt ist das Ende in UTC.
EndsAt time.Time `json:"ends_at"`
// Recurrence beschreibt die Wiederholung als JSON.
Recurrence []byte `json:"recurrence,omitempty"`
// Enabled meldet ein wirksames Fenster.
Enabled bool `json:"enabled"`
// JobIDs sind die betroffenen Auftraege.
//
// Ein Fenster ohne Auftragszuordnung gilt fuer alle — dieselbe Bedeutung wie
// im Scheduler (Phase 8).
JobIDs []string `json:"job_ids,omitempty"`
}
// NotificationChannelRecord ist ein gesicherter Benachrichtigungsweg.
//
// **Ohne Zugangsdaten und ohne Konfiguration.** Ein SMTP-Passwort im Repository
// waere ein Zugang zu einem fremden System, abgelegt an einem Ort, der
// womoeglich bei einem Dienstleister liegt. Aber auch die Konfiguration bleibt
// draussen: In ihr steht die Webhook-Adresse, und die traegt bei vielen
// Diensten das Zugangstoken im Pfad. Ein Feld einzeln zu schwaerzen hiesse, bei
// jedem neuen Kanaltyp erneut daran zu denken — und einmal denkt niemand daran.
//
// Was bleibt, ist ein **Merkzettel**: Es gab einen Kanal dieses Namens, dieser
// Art, mit dieser Schwelle. Er wird abgeschaltet angelegt und ist neu
// einzurichten. Das ist weniger, als man sich wuenscht, und mehr als nichts.
type NotificationChannelRecord struct {
// ID ist der oeffentliche Bezeichner.
ID string `json:"id"`
// Name ist die Bezeichnung.
Name string `json:"name"`
// ChannelType ist die Art des Wegs.
ChannelType string `json:"channel_type"`
// MinimumSeverity ist die Schwelle der Zustellung.
MinimumSeverity string `json:"minimum_severity"`
// WasEnabled meldet einen Kanal, der zur Zeit der Sicherung aktiv war.
//
// Beim Einspielen wird der Kanal dennoch **abgeschaltet** angelegt: Ohne
// Konfiguration kann er nichts zustellen, und ein Kanal, der eingeschaltet
// aussieht und schweigt, ist gefaehrlicher als ein erkennbar abgeschalteter.
// Das Feld sagt dem Betreiber, welche Kanaele er wieder scharf schalten muss.
WasEnabled bool `json:"was_enabled"`
}
// UserRecord ist ein gesichertes Konto.
//
// **Ohne Passwort und ohne zweiten Faktor.** Ein Argon2id-Hash ist zwar kein
// Klartext, aber er laesst sich offline angreifen — und ein Repository liegt
// naturgemaess dort, wo es einen Serverausfall ueberlebt: ausserhalb der Anlage.
// Nach einer Wiederherstellung legt man den ersten Administrator neu an; die
// uebrigen Konten kommen zustandslos zurueck und muessen ein Passwort erhalten.
type UserRecord struct {
// ID ist der oeffentliche Bezeichner.
ID string `json:"id"`
// Username ist der Anmeldename.
Username string `json:"username"`
// Email ist die Mailadresse.
Email string `json:"email,omitempty"`
// Status ist der Zustand des Kontos.
Status string `json:"status"`
// Roles sind die zugewiesenen Rollennamen.
//
// Namen und nicht Kennungen: Die mitgelieferten Rollen sind unveraenderlich
// und tragen in einer frisch aufgesetzten Anlage neue Kennungen.
Roles []string `json:"roles"`
}
// SettingRecord ist eine gesicherte Systemeinstellung.
type SettingRecord struct {
// Key ist der Schluessel.
Key string `json:"key"`
// Value ist der Wert als JSON.
Value []byte `json:"value,omitempty"`
}
// Validate prueft einen Sicherungssatz auf Verwendbarkeit.
func (snapshot *Snapshot) Validate() error {
if snapshot.FormatVersion != SnapshotFormatVersion {
return fmt.Errorf("der sicherungssatz hat die formatversion %d, unterstuetzt wird %d",
snapshot.FormatVersion, SnapshotFormatVersion)
}
if snapshot.RepositoryID == "" {
return fmt.Errorf("dem sicherungssatz fehlt die repository-kennung")
}
if snapshot.CreatedAt.IsZero() {
return fmt.Errorf("dem sicherungssatz fehlt der erzeugungszeitpunkt")
}
return nil
}
// Summary fasst den Inhalt eines Sicherungssatzes zusammen.
func (snapshot *Snapshot) Summary() string {
return fmt.Sprintf("%d Repositories, %d Aufträge, %d Aufbewahrungsregeln, "+
"%d Wartungsfenster, %d Benachrichtigungswege, %d Konten",
len(snapshot.Repositories), len(snapshot.Jobs), len(snapshot.RetentionPolicies),
len(snapshot.MaintenanceWindows), len(snapshot.NotificationChannels), len(snapshot.Users))
}
// securityOmissions benennt, was ein Sicherungssatz niemals enthaelt.
//
// Die Liste wandert in jeden Satz. Sie ist kein Kommentar, sondern die
// Handlungsanweisung fuer den Tag, an dem jemand eine Anlage aus dem Nichts
// wiederherstellt.
func securityOmissions() []string {
return []string{
"Passwörter und Passwort-Hashes. Legen Sie nach der Wiederherstellung mit " +
"'syncova-admin create-admin' einen Administrator an; die übrigen Konten " +
"kommen ohne Passwort zurück und müssen eines erhalten.",
"Zweite Faktoren (TOTP-Geheimnisse). Sie müssen neu eingerichtet werden.",
"Zugangsdaten UND Konfiguration der Benachrichtigungswege (SMTP-Server und " +
"-Passwörter, Webhook-Adressen — letztere tragen oft ein Token im Pfad). " +
"Von jedem Kanal bleiben Name, Art und Schwelle als Merkzettel; er wird " +
"abgeschaltet angelegt und muss neu eingerichtet werden.",
"Sitzungen und Betriebstokens der Agenten. Agenten müssen neu aufgenommen werden.",
"Der Verschlüsselungsschlüssel der Anlage (SYNCOVA_ENCRYPTION_KEY). Er liegt " +
"in der Umgebung des Dienstes und gehört nicht in ein Repository — ohne ihn " +
"lassen sich die abgelegten Geheimnisse nicht entschlüsseln.",
}
}