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>
380 lines
16 KiB
Go
380 lines
16 KiB
Go
// Command syncova-api startet den Control-Plane-API-Dienst von Syncova.
|
|
//
|
|
// Der Dienst stellt die REST-Schnittstelle laut SYNCOVA_API.md bereit. Er
|
|
// verändert das Datenbankschema niemals selbst — Migrationen laufen ausschließlich
|
|
// über das Kommando syncova-migrate (SYNCOVA_DATABASE.md §18).
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/signal"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/syncova/syncova/apps/api/internal/httpapi"
|
|
"github.com/syncova/syncova/migrations"
|
|
"github.com/syncova/syncova/packages/agentregistry"
|
|
"github.com/syncova/syncova/packages/agenttasks"
|
|
"github.com/syncova/syncova/packages/alerting"
|
|
"github.com/syncova/syncova/packages/audit"
|
|
"github.com/syncova/syncova/packages/auth"
|
|
"github.com/syncova/syncova/packages/backupexecutor"
|
|
"github.com/syncova/syncova/packages/hypervisor"
|
|
"github.com/syncova/syncova/packages/jobs"
|
|
"github.com/syncova/syncova/packages/metrics"
|
|
"github.com/syncova/syncova/packages/platform/config"
|
|
"github.com/syncova/syncova/packages/platform/crypto"
|
|
"github.com/syncova/syncova/packages/platform/database"
|
|
"github.com/syncova/syncova/packages/platform/health"
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
"github.com/syncova/syncova/packages/ransomware"
|
|
"github.com/syncova/syncova/packages/recovery"
|
|
"github.com/syncova/syncova/packages/reports"
|
|
"github.com/syncova/syncova/packages/repository"
|
|
"github.com/syncova/syncova/packages/retention"
|
|
"github.com/syncova/syncova/packages/security"
|
|
"github.com/syncova/syncova/packages/verification"
|
|
)
|
|
|
|
// serviceName benennt den Dienst in Logs und Metriken.
|
|
const serviceName = "syncova-api"
|
|
|
|
// buildVersion wird beim Bauen über -ldflags gesetzt.
|
|
// Der Standardwert kennzeichnet einen Build außerhalb der Release-Pipeline.
|
|
var buildVersion = "0.1.0-dev"
|
|
|
|
// databaseStartupTimeout ist die Frist, in der die Datenbank beim Start erreichbar sein muss.
|
|
const databaseStartupTimeout = 30 * time.Second
|
|
|
|
// healthCheckTimeout begrenzt jede einzelne Komponentenprüfung.
|
|
const healthCheckTimeout = 5 * time.Second
|
|
|
|
func main() {
|
|
// Die Versionsabfrage steht **vor** allem anderen.
|
|
//
|
|
// Ein Betreiber, der wissen will, welche Fassung auf einem Server liegt,
|
|
// hat in dem Moment womöglich keine Datenbank und keine Konfiguration —
|
|
// etwa auf einem frisch ausgepackten Paket oder mitten in einer Störung.
|
|
// Eine Antwort, die erst nach vollständiger Konfiguration käme, wäre genau
|
|
// dann nicht zu bekommen, wenn man sie braucht.
|
|
if len(os.Args) > 1 && isVersionArgument(os.Args[1]) {
|
|
fmt.Printf("syncova-api %s\n", buildVersion)
|
|
|
|
return
|
|
}
|
|
|
|
// run() kapselt die gesamte Logik, damit defer-Aufrufe vor dem Prozessende greifen.
|
|
if runError := run(); runError != nil {
|
|
// Zu diesem Zeitpunkt existiert womöglich noch kein Logger,
|
|
// deshalb geht die Meldung direkt nach stderr.
|
|
fmt.Fprintf(os.Stderr, "syncova-api konnte nicht gestartet werden: %v\n", runError)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// run startet alle Komponenten und wartet auf das Abschaltsignal.
|
|
func run() error {
|
|
serviceConfig, configError := config.Load(serviceName)
|
|
if configError != nil {
|
|
return configError
|
|
}
|
|
|
|
serviceLogger := logging.New(os.Stdout, logging.Options{
|
|
ServiceName: serviceConfig.ServiceName,
|
|
Level: serviceConfig.Logging.Level,
|
|
Format: serviceConfig.Logging.Format,
|
|
})
|
|
|
|
serviceLogger.Info("syncova-api startet",
|
|
slog.String("version", buildVersion),
|
|
slog.String("environment", string(serviceConfig.Environment)),
|
|
)
|
|
|
|
// SIGINT und SIGTERM lösen ein geordnetes Herunterfahren aus.
|
|
shutdownContext, stopSignalListener := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stopSignalListener()
|
|
|
|
// Das Schema wird vor dem ersten Request geprüft: ein Dienst, der gegen ein
|
|
// unpassendes Schema arbeitet, könnte Daten falsch interpretieren.
|
|
if schemaError := database.VerifySchemaIsUpToDate(migrations.FS, serviceConfig.Database.ConnectionString()); schemaError != nil {
|
|
return schemaError
|
|
}
|
|
|
|
databasePool, databaseError := database.AwaitAvailable(shutdownContext, serviceConfig.Database, serviceLogger, databaseStartupTimeout)
|
|
if databaseError != nil {
|
|
return databaseError
|
|
}
|
|
defer databasePool.Close()
|
|
|
|
// Die Datenbank ist als kritisch registriert: ohne sie ist der Dienst nicht betriebsbereit.
|
|
healthRegistry := health.NewRegistry(healthCheckTimeout)
|
|
healthRegistry.Register("database", true, databasePool.HealthCheck())
|
|
|
|
// Der Secret Store verschlüsselt MFA-Secrets und später Repository-Zugangsdaten.
|
|
secretStore, secretStoreError := crypto.NewLocalSecretStore(
|
|
serviceConfig.Encryption.Keys(), serviceConfig.Encryption.CurrentKeyVersion)
|
|
if secretStoreError != nil {
|
|
return fmt.Errorf("die verschlüsselung konnte nicht eingerichtet werden: %w", secretStoreError)
|
|
}
|
|
|
|
auditRecorder := audit.NewPostgresRecorder(databasePool.Connections(), serviceLogger)
|
|
authRepository := auth.NewRepository(databasePool.Connections())
|
|
authService := auth.NewService(authRepository, secretStore, auditRecorder, serviceConfig.Auth, serviceLogger)
|
|
|
|
agentStore := agentregistry.NewPostgresStore(databasePool.Connections())
|
|
agentService := agentregistry.NewService(agentStore, auditRecorder, serviceLogger)
|
|
|
|
jobStore := jobs.NewPostgresStore(databasePool.Connections())
|
|
restoreStore := recovery.NewStore(databasePool.Connections())
|
|
verificationStore := verification.NewStore(databasePool.Connections())
|
|
retentionStore := retention.NewStore(databasePool.Connections())
|
|
metricsStore := metrics.NewStore(databasePool.Connections())
|
|
alertStore := alerting.NewStore(databasePool.Connections())
|
|
securityInspector := security.NewInspector(databasePool.Connections(), buildVersion)
|
|
agentTaskStore := agenttasks.NewStore(databasePool.Connections())
|
|
|
|
// Die Virtualisierungsumgebungen brauchen denselben Schlüsselspeicher: Ihre
|
|
// API-Tokens und SSH-Schlüssel gehören nie im Klartext in die Datenbank.
|
|
hypervisorStore, hypervisorStoreError := hypervisor.NewStore(databasePool.Connections(), secretStore)
|
|
if hypervisorStoreError != nil {
|
|
return fmt.Errorf("die virtualisierungsverwaltung konnte nicht eingerichtet werden: %w", hypervisorStoreError)
|
|
}
|
|
|
|
apiRouter := httpapi.NewRouter(httpapi.RouterDependencies{
|
|
Config: serviceConfig,
|
|
Logger: serviceLogger,
|
|
HealthRegistry: healthRegistry,
|
|
BuildVersion: buildVersion,
|
|
AuthService: authService,
|
|
AuthRepository: authRepository,
|
|
AuditRecorder: auditRecorder,
|
|
AgentService: agentService,
|
|
JobStore: jobStore,
|
|
RestoreStore: restoreStore,
|
|
|
|
VerificationStore: verificationStore,
|
|
RetentionStore: retentionStore,
|
|
MetricsStore: metricsStore,
|
|
AlertStore: alertStore,
|
|
SecretStore: secretStore,
|
|
SecurityInspector: securityInspector,
|
|
// Die Auffaelligkeitsbewertung liest ausschliesslich aus der Datenbank.
|
|
RansomwareDetector: ransomware.NewDetector(databasePool.Connections()),
|
|
// Der Berichtsersteller nutzt dieselbe Sicherheitspruefung wie das
|
|
// Security Center — zwei Berechnungen derselben Zahl liefen auseinander.
|
|
ReportGenerator: reports.NewGenerator(databasePool.Connections(), securityInspector),
|
|
AgentTaskStore: agentTaskStore,
|
|
HypervisorStore: hypervisorStore,
|
|
})
|
|
|
|
apiServer, serverError := httpapi.NewServer(serviceConfig.HTTP, apiRouter, serviceLogger)
|
|
if serverError != nil {
|
|
return serverError
|
|
}
|
|
|
|
// Ein Dienst, der ohne Verschlüsselung lauscht, sagt das bei jedem Start.
|
|
//
|
|
// Der Betrieb hinter einem Reverse Proxy, der TLS übernimmt, ist der
|
|
// Normalfall und völlig in Ordnung — solange der Dienst dann nur lokal
|
|
// erreichbar ist. Gefährlich ist die Kombination aus fehlender
|
|
// Verschlüsselung und einer Bindung an alle Schnittstellen: Dann wandern
|
|
// Anmeldedaten im Klartext durch das Netz, ohne dass es jemandem auffällt.
|
|
if !apiServer.UsesTLS() {
|
|
if strings.HasPrefix(serviceConfig.HTTP.ListenAddress, "127.0.0.1") ||
|
|
strings.HasPrefix(serviceConfig.HTTP.ListenAddress, "localhost") ||
|
|
strings.HasPrefix(serviceConfig.HTTP.ListenAddress, "[::1]") {
|
|
serviceLogger.Info("der dienst laeuft ohne tls und ist nur lokal erreichbar",
|
|
slog.String("adresse", serviceConfig.HTTP.ListenAddress))
|
|
} else {
|
|
serviceLogger.Warn("DER DIENST LAEUFT OHNE VERSCHLUESSELUNG UND IST VON AUSSEN ERREICHBAR",
|
|
slog.String("adresse", serviceConfig.HTTP.ListenAddress),
|
|
slog.String("abhilfe", "setzen sie SYNCOVA_HTTP_TLS_CERT_FILE und "+
|
|
"SYNCOVA_HTTP_TLS_KEY_FILE, oder binden sie den dienst an 127.0.0.1 "+
|
|
"und stellen sie einen reverse proxy davor"))
|
|
}
|
|
}
|
|
|
|
// Die Ausführungsschleife läuft neben dem HTTP-Server. Sie bekommt denselben
|
|
// Abbruchkontext, damit ein SIGTERM beide beendet — zuerst die Schleife, die
|
|
// ihre laufenden Vorgänge abbricht und deren Ergebnis festschreibt.
|
|
//
|
|
// Derselbe Secret Store, der die MFA-Geheimnisse schützt, verschlüsselt auch
|
|
// die Datenschlüssel der Repositories. Ein zweiter Schlüsselsatz brächte
|
|
// keinen Sicherheitsgewinn, aber eine zweite Stelle, an der er verloren
|
|
// gehen kann.
|
|
backupExecutor, executorError := backupexecutor.New(jobStore, backupexecutor.Options{
|
|
SecretStore: secretStore,
|
|
AgentTaskStore: agentTaskStore,
|
|
HypervisorStore: hypervisorStore,
|
|
CreatedByVersion: buildVersion,
|
|
}, serviceLogger)
|
|
if executorError != nil {
|
|
return fmt.Errorf("der backup-executor konnte nicht eingerichtet werden: %w", executorError)
|
|
}
|
|
|
|
executionLoop, loopError := jobs.NewLoop(jobStore, backupExecutor, jobs.LoopOptions{
|
|
InstanceName: schedulerInstanceName(),
|
|
}, serviceLogger)
|
|
if loopError != nil {
|
|
return fmt.Errorf("die ausführungsschleife konnte nicht eingerichtet werden: %w", loopError)
|
|
}
|
|
|
|
// Die Schleife gibt auch verwaiste Agentenauftraege frei.
|
|
executionLoop.SetAgentTaskReclaimer(agentTaskStore)
|
|
|
|
// Die Wiederherstellung läuft in einer eigenen Schleife. Sie mit den
|
|
// Sicherungen zu vermengen wäre falsch: Eine Wiederherstellung wird nie
|
|
// automatisch wiederholt, und ihre Nebenläufigkeit ist bewusst eine andere
|
|
// — im Ernstfall zählt die Geschwindigkeit *einer* Wiederherstellung.
|
|
restoreExecutor, restoreExecutorError := recovery.NewRestoreExecutor(
|
|
recovery.NewStoreRepositoryResolver(jobStore),
|
|
recovery.ExecutorOptions{SecretStore: secretStore},
|
|
serviceLogger)
|
|
if restoreExecutorError != nil {
|
|
return fmt.Errorf("die wiederherstellung konnte nicht eingerichtet werden: %w", restoreExecutorError)
|
|
}
|
|
|
|
recoveryLoop, recoveryLoopError := recovery.NewLoop(restoreStore, restoreExecutor, recovery.LoopOptions{
|
|
InstanceName: schedulerInstanceName(),
|
|
}, serviceLogger)
|
|
if recoveryLoopError != nil {
|
|
return fmt.Errorf("die wiederherstellungsschleife konnte nicht eingerichtet werden: %w", recoveryLoopError)
|
|
}
|
|
|
|
// Die Prüfung läuft in einer dritten Schleife. Sie mit den Sicherungen zu
|
|
// vermengen wäre falsch: Eine Prüfung wird nie wiederholt, weil sie
|
|
// fehlschlug, und sie darf eine laufende Sicherung nicht verdrängen — beide
|
|
// lesen denselben Datenträger.
|
|
verificationRunner, verificationRunnerError := verification.NewRepositoryRunner(
|
|
recovery.NewStoreRepositoryResolver(jobStore),
|
|
verification.RunnerOptions{SecretStore: secretStore},
|
|
serviceLogger)
|
|
if verificationRunnerError != nil {
|
|
return fmt.Errorf("die prüfung konnte nicht eingerichtet werden: %w", verificationRunnerError)
|
|
}
|
|
|
|
verificationLoop, verificationLoopError := verification.NewLoop(verificationStore, verificationRunner,
|
|
verification.LoopOptions{InstanceName: schedulerInstanceName()}, serviceLogger)
|
|
if verificationLoopError != nil {
|
|
return fmt.Errorf("die prüfschleife konnte nicht eingerichtet werden: %w", verificationLoopError)
|
|
}
|
|
|
|
// Die Kennzahlenerfassung hält fest, was sonst verloren geht: Die Belegung
|
|
// eines Repositorys ist eine Momentaufnahme, die beim nächsten Schreiben
|
|
// überschrieben wird. Ohne diese Schleife gäbe es keine Verlaufsreihe — und
|
|
// damit weder Wachstumskurve noch Kapazitätsprognose.
|
|
metricsCollector, collectorError := metrics.NewCollector(metricsStore, jobStore,
|
|
repository.MeasureFilesystemUsage, metrics.CollectorOptions{}, serviceLogger)
|
|
if collectorError != nil {
|
|
return fmt.Errorf("die kennzahlenerfassung konnte nicht eingerichtet werden: %w", collectorError)
|
|
}
|
|
|
|
// Die Sicherheitsbewertung wird bei jedem Abruf neu berechnet; erfasst wird
|
|
// hier ihr **Verlauf**. Ohne ihn liesse sich nicht sagen, ob die Lage besser
|
|
// oder schlechter geworden ist.
|
|
metricsCollector = metricsCollector.WithSecurityScoreSource(securityInspector)
|
|
|
|
// Die Meldungsauswertung schliesst den Kreis: Sie erkennt die Zustände, die
|
|
// jemand ansehen sollte, und stellt sie zu. Ohne sie bliebe jeder Ausfall
|
|
// unbemerkt, bis jemand von sich aus in die Oberfläche sieht.
|
|
alertLoop, alertLoopError := alerting.NewLoop(alertStore,
|
|
alerting.NewEvaluator(databasePool.Connections()),
|
|
alerting.NewDeliveryDispatcher(serviceConfig.Hardening.AllowInternalNotificationTargets), secretStore,
|
|
alerting.LoopOptions{NotificationsEnabled: true}, serviceLogger)
|
|
if alertLoopError != nil {
|
|
return fmt.Errorf("die meldungsauswertung konnte nicht eingerichtet werden: %w", alertLoopError)
|
|
}
|
|
|
|
var loopWaitGroup sync.WaitGroup
|
|
loopWaitGroup.Add(5)
|
|
|
|
go func() {
|
|
defer loopWaitGroup.Done()
|
|
|
|
if runError := executionLoop.Run(shutdownContext); runError != nil {
|
|
serviceLogger.Error("die ausführungsschleife wurde nicht sauber beendet",
|
|
slog.String("grund", runError.Error()))
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
defer loopWaitGroup.Done()
|
|
|
|
if runError := recoveryLoop.Run(shutdownContext); runError != nil {
|
|
serviceLogger.Error("die wiederherstellungsschleife wurde nicht sauber beendet",
|
|
slog.String("grund", runError.Error()))
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
defer loopWaitGroup.Done()
|
|
|
|
if runError := verificationLoop.Run(shutdownContext); runError != nil {
|
|
serviceLogger.Error("die prüfschleife wurde nicht sauber beendet",
|
|
slog.String("grund", runError.Error()))
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
defer loopWaitGroup.Done()
|
|
|
|
if runError := metricsCollector.Run(shutdownContext); runError != nil {
|
|
serviceLogger.Error("die kennzahlenerfassung wurde nicht sauber beendet",
|
|
slog.String("grund", runError.Error()))
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
defer loopWaitGroup.Done()
|
|
|
|
if runError := alertLoop.Run(shutdownContext); runError != nil {
|
|
serviceLogger.Error("die meldungsauswertung wurde nicht sauber beendet",
|
|
slog.String("grund", runError.Error()))
|
|
}
|
|
}()
|
|
|
|
serveError := apiServer.Serve(shutdownContext)
|
|
|
|
// Auf die Schleife wird immer gewartet — auch wenn der HTTP-Server mit einem
|
|
// Fehler endete. Ein Prozess, der endet, während noch ein Lauf schreibt,
|
|
// hinterliesse einen Lauf auf „running" und blockierte den Auftrag bis zum
|
|
// Ablauf der Frist für verwaiste Läufe.
|
|
loopWaitGroup.Wait()
|
|
|
|
if serveError != nil {
|
|
return fmt.Errorf("der API-Server wurde unerwartet beendet: %w", serveError)
|
|
}
|
|
|
|
serviceLogger.Info("syncova-api beendet")
|
|
|
|
return nil
|
|
}
|
|
|
|
// schedulerInstanceName bildet den Namen dieses Control-Servers.
|
|
//
|
|
// Der Rechnername allein genügt nicht: Zwei Prozesse auf derselben Maschine
|
|
// wären ununterscheidbar, und nach einem Absturz liesse sich nicht sagen,
|
|
// welcher der beiden die verwaisten Läufe hielt.
|
|
func schedulerInstanceName() string {
|
|
hostName, hostError := os.Hostname()
|
|
if hostError != nil {
|
|
hostName = "unbekannt"
|
|
}
|
|
|
|
return fmt.Sprintf("%s-%d", hostName, os.Getpid())
|
|
}
|
|
|
|
// isVersionArgument erkennt eine Versionsabfrage.
|
|
//
|
|
// Drei Schreibweisen, weil sich niemand merkt, welche ein bestimmtes Programm
|
|
// erwartet — und weil eine Fehlermeldung auf "--version" der denkbar
|
|
// schlechteste erste Eindruck ist.
|
|
func isVersionArgument(argument string) bool {
|
|
return argument == "version" || argument == "--version" || argument == "-version"
|
|
}
|