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

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