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