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>
331 lines
14 KiB
Go
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.",
|
|
}
|
|
}
|