syncova-backup/apps/api/cmd/syncova-dr/main.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

480 lines
16 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Command syncova-dr stellt eine Anlage nach einem Totalverlust wieder her
// (SYNCOVA_IMPLEMENTATION_PLAN.md §20).
//
// Das Werkzeug bedient man an dem Tag, an dem nichts mehr da ist. Daraus folgen
// drei Eigenschaften, die es von den uebrigen Kommandos unterscheiden:
//
// „inspect" braucht **keine Datenbank**. Nach einem Totalverlust will man zuerst
// sehen, was ueberhaupt noch da ist, bevor man einen Server aufsetzt.
//
// Jeder Schritt sagt, was er getan hat **und was noch zu tun bleibt**. Wer eine
// Anlage aus dem Nichts wiederherstellt, hat keine Betriebsanleitung neben sich
// liegen; die Ausgabe muss die Anleitung sein.
//
// Nichts laeuft von selbst wieder an. Auftraege kommen angehalten zurueck,
// Repositories als nicht erreichbar, Benachrichtigungswege abgeschaltet. Eine
// Anlage, die nach dem Wiederaufbau um zwei Uhr nachts von selbst auf ein halb
// hergestelltes System schreibt, waere schlimmer als eine, die stillsteht.
//
// Aufruf:
//
// syncova-dr export --repo <pfad> Sichert die Konfiguration ins Repository
// syncova-dr inspect --repo <pfad> Zeigt den Sicherungssatz (ohne Datenbank)
// syncova-dr restore --repo <pfad> [--catalog] Spielt Konfiguration und Katalog ein
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"strings"
"time"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/disasterrecovery"
"github.com/syncova/syncova/packages/platform/config"
"github.com/syncova/syncova/packages/platform/database"
"github.com/syncova/syncova/packages/platform/logging"
"github.com/syncova/syncova/packages/repository"
)
// serviceName benennt das Kommando in den Logs.
const serviceName = "syncova-dr"
// commandTimeout begrenzt die Laufzeit einer Datenbankoperation.
//
// Grosszuegig bemessen: Der Katalogaufbau liest jedes Manifest des Repositorys,
// und bei einer grossen Anlage sind das viele tausend Dateien.
const commandTimeout = 30 * time.Minute
// buildVersion wird beim Bauen über -ldflags gesetzt.
var buildVersion = "0.1.0-dev"
func main() {
if runError := run(); runError != nil {
fmt.Fprintf(os.Stderr, "%s: %v\n", serviceName, runError)
os.Exit(1)
}
}
// run wertet das Unterkommando aus.
func run() error {
// Die Versionsabfrage steht vor allem anderen: Wer wissen will, welche
// Fassung auf einem Server liegt, hat in dem Moment womöglich keine
// Konfiguration — etwa auf einem frisch ausgepackten Paket.
if len(os.Args) > 1 && isVersionArgument(os.Args[1]) {
fmt.Printf("%s %s\n", serviceName, buildVersion)
return nil
}
if len(os.Args) < 2 {
printUsage()
return fmt.Errorf("es wurde kein kommando angegeben")
}
commandName := os.Args[1]
commandArguments := os.Args[2:]
switch commandName {
case "export":
return runExport(commandArguments)
case "inspect":
return runInspect(commandArguments)
case "restore":
return runRestore(commandArguments)
case "help", "-h", "--help":
printUsage()
return nil
default:
printUsage()
return fmt.Errorf("unbekanntes kommando %q", commandName)
}
}
// printUsage schreibt die Kurzhilfe.
func printUsage() {
fmt.Fprintf(os.Stderr, `%s: Verwendung:
syncova-dr export --repo <pfad> Sichert die Konfiguration ins Repository
syncova-dr inspect --repo <pfad> Zeigt den Sicherungssatz (ohne Datenbank)
syncova-dr restore --repo <pfad> [--catalog] Spielt Konfiguration und Katalog ein
Der Weg nach einem Totalverlust:
1. syncova-migrate up Schema anlegen
2. syncova-dr inspect --repo <pfad> Ansehen, was da ist
3. syncova-dr restore --repo <pfad> --catalog Konfiguration und Wiederherstellungspunkte
4. syncova-admin create-admin --username <n> Ersten Zugang anlegen
`, serviceName)
}
// runExport sichert die Konfiguration ins Repository.
func runExport(commandArguments []string) error {
exportFlags := flag.NewFlagSet("export", flag.ContinueOnError)
repositoryPath := exportFlags.String("repo", "", "Pfad des Repositorys")
if parseError := exportFlags.Parse(commandArguments); parseError != nil {
return parseError
}
if *repositoryPath == "" {
return fmt.Errorf("--repo ist erforderlich")
}
commandContext, cancelCommand := context.WithTimeout(context.Background(), commandTimeout)
defer cancelCommand()
environment, environmentError := buildEnvironment(commandContext)
if environmentError != nil {
return environmentError
}
defer environment.close()
openRepository, openError := repository.Open(commandContext, *repositoryPath,
repository.OpenOptions{}, environment.logger)
if openError != nil {
return fmt.Errorf("das repository liess sich nicht oeffnen: %w", openError)
}
defer func() { _ = openRepository.Close() }()
repositoryDescriptor := openRepository.Descriptor()
snapshotExporter := disasterrecovery.NewExporter(environment.databasePool.Connections())
configurationSnapshot, buildError := snapshotExporter.BuildSnapshot(commandContext,
repositoryDescriptor.RepositoryID, serviceName, buildVersion)
if buildError != nil {
return buildError
}
snapshotPath, writeError := disasterrecovery.WriteSnapshot(openRepository.RootPath(),
configurationSnapshot)
if writeError != nil {
return writeError
}
fmt.Printf("Konfiguration gesichert: %s\n", snapshotPath)
fmt.Printf(" %s\n", configurationSnapshot.Summary())
fmt.Printf(" Schemastand %d, erzeugt %s\n",
configurationSnapshot.SchemaVersion,
configurationSnapshot.CreatedAt.Format(time.RFC3339))
fmt.Println()
fmt.Println("Nicht enthalten (und nach einer Wiederherstellung von Hand zu erledigen):")
for _, omission := range configurationSnapshot.OmittedForSecurity {
fmt.Printf(" – %s\n", wrapForTerminal(omission, " "))
}
return nil
}
// runInspect zeigt den Sicherungssatz eines Repositorys.
//
// **Ohne Datenbank.** Das ist der Zweck: Wer nach einem Ausfall vor einem
// Repository steht, muss sehen koennen, was darin ist, bevor er entscheidet, wie
// er weitermacht.
func runInspect(commandArguments []string) error {
inspectFlags := flag.NewFlagSet("inspect", flag.ContinueOnError)
repositoryPath := inspectFlags.String("repo", "", "Pfad des Repositorys")
if parseError := inspectFlags.Parse(commandArguments); parseError != nil {
return parseError
}
if *repositoryPath == "" {
return fmt.Errorf("--repo ist erforderlich")
}
configurationSnapshot, readError := disasterrecovery.ReadSnapshot(*repositoryPath)
if readError != nil {
return readError
}
fmt.Println("Sicherungssatz der Konfiguration")
fmt.Printf(" Repository: %s\n", configurationSnapshot.RepositoryID)
fmt.Printf(" Erzeugt: %s von %s\n",
configurationSnapshot.CreatedAt.Format(time.RFC3339), configurationSnapshot.CreatedBy)
fmt.Printf(" Programm: %s\n", configurationSnapshot.ProductVersion)
fmt.Printf(" Schemastand: %d\n", configurationSnapshot.SchemaVersion)
fmt.Printf(" Inhalt: %s\n", configurationSnapshot.Summary())
if len(configurationSnapshot.Repositories) > 0 {
fmt.Println("\nRepositories:")
for _, repositoryRecord := range configurationSnapshot.Repositories {
fmt.Printf(" %-24s %s\n", repositoryRecord.Name, repositoryRecord.Location)
}
}
if len(configurationSnapshot.Jobs) > 0 {
fmt.Println("\nAufträge:")
for _, jobRecord := range configurationSnapshot.Jobs {
fmt.Printf(" %-24s %d Quelle(n), Zeitplan %s\n",
jobRecord.Name, len(jobRecord.Sources), jobRecord.ScheduleType)
}
}
fmt.Println("\nNicht enthalten (nach der Wiederherstellung von Hand zu erledigen):")
for _, omission := range configurationSnapshot.OmittedForSecurity {
fmt.Printf(" – %s\n", wrapForTerminal(omission, " "))
}
return nil
}
// runRestore spielt Konfiguration und Katalog ein.
func runRestore(commandArguments []string) error {
restoreFlags := flag.NewFlagSet("restore", flag.ContinueOnError)
repositoryPath := restoreFlags.String("repo", "", "Pfad des Repositorys")
includeCatalog := restoreFlags.Bool("catalog", false,
"Wiederherstellungspunkte aus den Manifesten übernehmen")
if parseError := restoreFlags.Parse(commandArguments); parseError != nil {
return parseError
}
if *repositoryPath == "" {
return fmt.Errorf("--repo ist erforderlich")
}
commandContext, cancelCommand := context.WithTimeout(context.Background(), commandTimeout)
defer cancelCommand()
environment, environmentError := buildEnvironment(commandContext)
if environmentError != nil {
return environmentError
}
defer environment.close()
configurationSnapshot, readError := disasterrecovery.ReadSnapshot(*repositoryPath)
if readError != nil {
return readError
}
snapshotImporter := disasterrecovery.NewImporter(environment.databasePool.Connections())
importResult, importError := snapshotImporter.ImportSnapshot(commandContext, configurationSnapshot)
if importError != nil {
return importError
}
printImportResult(importResult)
if *includeCatalog {
if catalogError := restoreCatalog(commandContext, environment, *repositoryPath,
configurationSnapshot); catalogError != nil {
return catalogError
}
} else {
fmt.Println("\nDie Wiederherstellungspunkte wurden NICHT übernommen (--catalog fehlt).")
fmt.Println("Ohne sie kennt die Anlage die vorhandenen Sicherungen nicht.")
}
printNextSteps(importResult, *includeCatalog)
return nil
}
// restoreCatalog uebernimmt die Wiederherstellungspunkte aus dem Repository.
func restoreCatalog(commandContext context.Context, environment *commandEnvironment,
repositoryPath string, configurationSnapshot *disasterrecovery.Snapshot) error {
openRepository, openError := repository.Open(commandContext, repositoryPath,
repository.OpenOptions{}, environment.logger)
if openError != nil {
return fmt.Errorf("das repository liess sich nicht oeffnen: %w", openError)
}
defer func() { _ = openRepository.Close() }()
repositoryDescriptor := openRepository.Descriptor()
// Das Repository muss dasselbe sein, das der Sicherungssatz beschreibt.
// Andernfalls landeten die Wiederherstellungspunkte unter einer fremden
// Repository-Kennung — und ein spaeterer Restore suchte sie am falschen Ort.
if repositoryDescriptor.RepositoryID != configurationSnapshot.RepositoryID {
return fmt.Errorf("das repository traegt die kennung %s, der sicherungssatz "+
"beschreibt %s", repositoryDescriptor.RepositoryID, configurationSnapshot.RepositoryID)
}
databaseRepositoryIdentifier, resolveError := resolveRepositoryIdentifier(
configurationSnapshot, repositoryDescriptor.RepositoryID)
if resolveError != nil {
return resolveError
}
fmt.Println("\nWiederherstellungspunkte werden aus den Manifesten aufgebaut …")
catalogImporter := disasterrecovery.NewCatalogImporter(environment.databasePool.Connections())
catalogResult, catalogError := catalogImporter.ImportCatalog(commandContext,
openRepository, databaseRepositoryIdentifier)
if catalogError != nil {
return catalogError
}
fmt.Printf(" %d Wiederherstellungspunkte übernommen, %d bereits bekannt, %d Ketten angelegt\n",
catalogResult.BackupsImported, catalogResult.BackupsAlreadyKnown,
catalogResult.ChainsCreated)
if len(catalogResult.UnresolvedParents) > 0 {
fmt.Printf(" %d Zusatzsicherung(en) ohne auffindbares Elternbackup. Sie bleiben "+
"nutzbar: Syncova-Manifeste sind vollständig.\n", len(catalogResult.UnresolvedParents))
}
return nil
}
// resolveRepositoryIdentifier findet die Datenbankkennung des Repositorys.
func resolveRepositoryIdentifier(configurationSnapshot *disasterrecovery.Snapshot,
repositoryUUID string) (uuid.UUID, error) {
for _, repositoryRecord := range configurationSnapshot.Repositories {
if repositoryRecord.RepositoryUUID != repositoryUUID {
continue
}
parsedIdentifier, parseError := uuid.Parse(repositoryRecord.ID)
if parseError != nil {
return uuid.Nil, fmt.Errorf("die kennung des repositorys %q ist unlesbar: %w",
repositoryRecord.Name, parseError)
}
return parsedIdentifier, nil
}
return uuid.Nil, fmt.Errorf("der sicherungssatz kennt kein repository mit der kennung %s. "+
"ohne diese zuordnung liessen sich die wiederherstellungspunkte keinem eingerichteten "+
"repository zuordnen", repositoryUUID)
}
// printImportResult schreibt das Ergebnis der Konfigurationswiederherstellung.
func printImportResult(importResult *disasterrecovery.ImportResult) {
fmt.Println("Konfiguration wiederhergestellt:")
fmt.Printf(" %d Repositories (als 'nicht erreichbar' — prüfen Sie die Pfade)\n",
importResult.RepositoriesRestored)
fmt.Printf(" %d Aufbewahrungsregeln\n", importResult.RetentionPoliciesRestored)
fmt.Printf(" %d Aufträge mit %d Quellen (ANGEHALTEN)\n",
importResult.JobsRestored, importResult.JobSourcesRestored)
fmt.Printf(" %d Wartungsfenster\n", importResult.MaintenanceWindowsRestored)
fmt.Printf(" %d Benachrichtigungswege (ABGESCHALTET — ohne Zugangsdaten)\n",
importResult.NotificationChannelsRestored)
fmt.Printf(" %d Konten (DEAKTIVIERT — ohne Passwort)\n", importResult.UsersRestored)
fmt.Printf(" %d Einstellungen\n", importResult.SettingsRestored)
if len(importResult.SkippedExisting) > 0 {
fmt.Printf("\n %d Objekt(e) waren bereits vorhanden und wurden NICHT überschrieben:\n",
len(importResult.SkippedExisting))
for _, skippedObject := range importResult.SkippedExisting {
fmt.Printf(" – %s\n", skippedObject)
}
}
}
// printNextSteps schreibt die verbleibenden Handgriffe.
//
// Sie stehen am Ende der Ausgabe, weil sie das sind, was der Betreiber als
// naechstes tut. Eine Wiederherstellung, die mit „fertig" endet und einen
// halben Tag spaeter an einem angehaltenen Auftrag scheitert, ist keine.
func printNextSteps(importResult *disasterrecovery.ImportResult, catalogImported bool) {
fmt.Println("\nWas jetzt noch zu tun ist:")
fmt.Println(" 1. Ersten Zugang anlegen: syncova-admin create-admin --username <name>")
fmt.Println(" 2. Pfade der Repositories prüfen und sie auf 'aktiv' setzen")
if !catalogImported {
fmt.Println(" 3. Wiederherstellungspunkte übernehmen: syncova-dr restore --repo <pfad> --catalog")
}
fmt.Println(" 4. Aufträge einzeln prüfen und wieder freigeben")
fmt.Println(" 5. Benachrichtigungswege neu einrichten und einschalten")
fmt.Println(" 6. Vor der ersten Sicherung eine Prüfung anstoßen — die Wiederherstellbarkeit")
fmt.Println(" der übernommenen Punkte ist NICHT belegt: sie wurde nicht neu nachgewiesen.")
if len(importResult.ManualStepsRequired) > 0 {
fmt.Println("\nAus dem Sicherungssatz:")
for _, manualStep := range importResult.ManualStepsRequired {
fmt.Printf(" – %s\n", wrapForTerminal(manualStep, " "))
}
}
}
// commandEnvironment haelt die aufgebauten Dienste.
type commandEnvironment struct {
// logger schreibt die Protokollzeilen.
logger *slog.Logger
// databasePool ist der Verbindungspool; er muss geschlossen werden.
databasePool *database.Pool
}
// close gibt die Betriebsmittel frei.
func (environment *commandEnvironment) close() {
environment.databasePool.Close()
}
// buildEnvironment laedt Konfiguration und verbindet die Datenbank.
func buildEnvironment(setupContext context.Context) (*commandEnvironment, error) {
serviceConfig, configError := config.Load(serviceName)
if configError != nil {
return nil, configError
}
commandLogger := logging.New(os.Stderr, logging.Options{
ServiceName: serviceConfig.ServiceName,
Level: serviceConfig.Logging.Level,
Format: serviceConfig.Logging.Format,
})
databasePool, databaseError := database.Connect(setupContext, serviceConfig.Database, commandLogger)
if databaseError != nil {
return nil, databaseError
}
return &commandEnvironment{logger: commandLogger, databasePool: databasePool}, nil
}
// wrapForTerminal bricht einen langen Hinweis auf Terminalbreite um.
//
// Ohne Umbruch laeuft ein dreizeiliger Hinweis als eine Zeile durch das Fenster
// und ist genau dann unlesbar, wenn er gebraucht wird.
func wrapForTerminal(text string, continuationPrefix string) string {
const lineWidth = 76
words := strings.Fields(text)
if len(words) == 0 {
return ""
}
var wrappedText strings.Builder
currentLineLength := 0
for wordIndex, word := range words {
if currentLineLength > 0 && currentLineLength+len(word)+1 > lineWidth {
wrappedText.WriteString("\n" + continuationPrefix)
currentLineLength = 0
} else if wordIndex > 0 {
wrappedText.WriteString(" ")
currentLineLength++
}
wrappedText.WriteString(word)
currentLineLength += len(word)
}
return wrappedText.String()
}
// isVersionArgument erkennt eine Versionsabfrage.
func isVersionArgument(argument string) bool {
return argument == "version" || argument == "--version" || argument == "-version"
}