// 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.", } }