syncova-backup/apps/api/internal/httpapi/router.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

706 lines
37 KiB
Go

package httpapi
import (
"log/slog"
"net/http"
"time"
"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/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/health"
"github.com/syncova/syncova/packages/platform/logging"
"github.com/syncova/syncova/packages/platform/netguard"
"github.com/syncova/syncova/packages/ransomware"
"github.com/syncova/syncova/packages/recovery"
"github.com/syncova/syncova/packages/reports"
"github.com/syncova/syncova/packages/retention"
"github.com/syncova/syncova/packages/security"
"github.com/syncova/syncova/packages/verification"
)
// apiBasePath ist das Präfix aller fachlichen Endpunkte (SYNCOVA_API.md).
// Die Version ist Teil des Pfads, damit spätere Verträge parallel bestehen können.
const apiBasePath = "/api/v1"
// Grenzen des Anmeldeschutzes.
//
// Sie greifen je Absenderadresse und ergänzen die kontobezogene Sperre: ohne sie
// könnte ein Angreifer viele Konten mit je wenigen Versuchen durchprobieren.
const (
// loginRateLimitAttempts ist die Anzahl erlaubter Anmeldeversuche je Zeitfenster.
loginRateLimitAttempts = 10
// loginRateLimitWindow ist das betrachtete Zeitfenster.
loginRateLimitWindow = 5 * time.Minute
// rateLimiterCleanupInterval ist der Abstand der Speicherbereinigung.
rateLimiterCleanupInterval = 10 * time.Minute
)
// RouterDependencies bündelt, was der Router zur Bedienung der Endpunkte braucht.
type RouterDependencies struct {
// Config ist die geladene Dienstkonfiguration.
Config config.Config
// Logger ist der Basis-Logger des Dienstes.
Logger *slog.Logger
// HealthRegistry liefert den Zustand aller überwachten Komponenten.
HealthRegistry *health.Registry
// BuildVersion ist die ausgelieferte Version, sichtbar unter /health.
BuildVersion string
// AuthService ist die Domänenlogik der Identitätsverwaltung.
AuthService *auth.Service
// AuthRepository ist die Datenzugriffsschicht für Rollen und Berechtigungen.
AuthRepository *auth.Repository
// AuditRecorder protokolliert sicherheitsrelevante Handlungen.
AuditRecorder audit.Recorder
// AgentService ist die Domänenlogik der Agent-Verwaltung.
AgentService *agentregistry.Service
// RestoreStore ist die Datenzugriffsschicht der Wiederherstellungen.
//
// Sie darf nil sein; dann werden die Wiederherstellungsrouten nicht
// eingebunden.
RestoreStore *recovery.Store
// JobStore ist die Datenzugriffsschicht der Sicherungsaufträge.
//
// Sie darf nil sein; dann werden die Auftragsrouten nicht eingebunden. Das
// erlaubt einen Dienst ohne Auftragsverwaltung, ohne dass ein Aufruf in
// einen Nil-Zeiger läuft.
JobStore *jobs.PostgresStore
// VerificationStore ist die Datenzugriffsschicht der Prüfungen.
//
// Sie darf nil sein; dann werden die Prüfrouten nicht eingebunden.
VerificationStore *verification.Store
// RetentionStore ist die Datenzugriffsschicht der Aufbewahrung.
//
// Sie darf nil sein; dann werden die Aufbewahrungsrouten nicht eingebunden.
RetentionStore *retention.Store
// MetricsStore bildet die Zeitreihen der Diagramme.
//
// Sie darf nil sein; dann werden die Kennzahlenrouten nicht eingebunden.
MetricsStore *metrics.Store
// AlertStore ist die Datenzugriffsschicht der Meldungen.
//
// Sie darf nil sein; dann werden die Meldungsrouten nicht eingebunden.
AlertStore *alerting.Store
// SecretStore verschlüsselt die Zugangsgeheimnisse der Kanäle.
SecretStore crypto.SecretStore
// SecurityInspector beurteilt die Sicherheitslage.
//
// Er darf nil sein; dann wird das Security Center nicht eingebunden.
SecurityInspector *security.Inspector
// RansomwareDetector bewertet Sicherungslaeufe statistisch.
//
// Er darf nil sein; dann wird die Auffaelligkeitsbewertung nicht eingebunden.
RansomwareDetector *ransomware.Detector
// ReportGenerator erzeugt die Berichte.
//
// Er darf nil sein; dann werden die Berichtsrouten nicht eingebunden.
ReportGenerator *reports.Generator
// AgentTaskStore vermittelt Sicherungsaufträge an Agenten.
//
// Er darf nil sein; dann werden die Auftragsrouten nicht eingebunden.
AgentTaskStore *agenttasks.Store
// HypervisorStore verwaltet die Virtualisierungsumgebungen (Phase 7).
//
// Er darf nil sein; dann werden die Verbundrouten nicht eingebunden. Das
// ist der Normalfall ohne Schlüsselmaterial: Ohne SecretStore lassen sich
// keine Zugangsdaten ablegen, und ein Verbund ohne Zugangsdaten wäre eine
// Maske, die nichts bewirkt.
HypervisorStore *hypervisor.Store
}
// NewRouter baut den vollständigen HTTP-Handler des API-Dienstes.
//
// Die Middleware-Reihenfolge ist bewusst gewählt: Recovery und Correlation ID
// liegen außen, damit auch Fehler innerer Schichten protokolliert und einer
// Operation zugeordnet werden können.
func NewRouter(routerDependencies RouterDependencies) http.Handler {
requestMultiplexer := http.NewServeMux()
registerHealthRoutes(requestMultiplexer, routerDependencies)
registerAuthRoutes(requestMultiplexer, routerDependencies)
registerUserRoutes(requestMultiplexer, routerDependencies)
registerRoleRoutes(requestMultiplexer, routerDependencies)
registerAgentRoutes(requestMultiplexer, routerDependencies)
registerJobRoutes(requestMultiplexer, routerDependencies)
registerRestoreRoutes(requestMultiplexer, routerDependencies)
registerVerificationRoutes(requestMultiplexer, routerDependencies)
registerRetentionRoutes(requestMultiplexer, routerDependencies)
registerMetricsRoutes(requestMultiplexer, routerDependencies)
registerAlertRoutes(requestMultiplexer, routerDependencies)
registerSecurityRoutes(requestMultiplexer, routerDependencies)
registerRansomwareRoutes(requestMultiplexer, routerDependencies)
registerReportRoutes(requestMultiplexer, routerDependencies)
registerHypervisorRoutes(requestMultiplexer, routerDependencies)
// Alles Unbekannte wird in der Standard-Fehlerhülle beantwortet, damit Clients
// nie eine HTML-Fehlerseite von net/http erhalten.
requestMultiplexer.HandleFunc("/", func(responseWriter http.ResponseWriter, request *http.Request) {
requestLogger := logging.WithContext(request.Context(), routerDependencies.Logger)
WriteError(responseWriter, request, requestLogger, NewNotFoundError("Der angefragte Endpunkt existiert nicht."))
})
middlewareChain := []Middleware{
RecoveryMiddleware(routerDependencies.Logger),
CorrelationMiddleware(),
SecurityHeadersMiddleware(),
CORSMiddleware(routerDependencies.Config.HTTP.AllowedOrigins),
BodyLimitMiddleware(routerDependencies.Config.HTTP.MaxRequestBodyBytes),
}
// Der allgemeine Begrenzer schuetzt die gesamte API, nicht nur die
// Anmeldung (Phase 19).
//
// Bis dahin war ausschliesslich der Login begrenzt: Ein angemeldeter
// Benutzer — oder ein entwendetes Token — konnte die Anlage mit Anfragen
// ueberfluten. Besonders teuer sind die Endpunkte, die im Hintergrund
// arbeiten: Ein Bericht erzeugt ein PDF, eine Vorabpruefung liest jeden
// Block eines Backups, das Security Center stellt zehn Abfragen.
if routerDependencies.Config.HTTP.RequestsPerMinute > 0 {
generalRateLimiter := NewRateLimiter(
routerDependencies.Config.HTTP.RequestsPerMinute, time.Minute)
startRateLimiterCleanup(generalRateLimiter)
middlewareChain = append(middlewareChain,
RateLimitMiddleware(generalRateLimiter, routerDependencies.Logger))
}
middlewareChain = append(middlewareChain, AccessLogMiddleware(routerDependencies.Logger))
return Chain(requestMultiplexer, middlewareChain...)
}
// registerHealthRoutes bindet die Betriebs- und Zustandsendpunkte ein.
//
// Sie sind bewusst ohne Anmeldung erreichbar: ein Load Balancer kann sich nicht
// anmelden, und der Zustandsbericht enthält keine vertraulichen Angaben.
func registerHealthRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
healthHandlerInstance := &healthHandler{
healthRegistry: routerDependencies.HealthRegistry,
logger: logging.WithComponent(routerDependencies.Logger, "health"),
buildVersion: routerDependencies.BuildVersion,
environment: routerDependencies.Config.Environment,
}
requestMultiplexer.HandleFunc("GET /health/live", healthHandlerInstance.handleLiveness)
requestMultiplexer.HandleFunc("GET /health/ready", healthHandlerInstance.handleReadiness)
requestMultiplexer.HandleFunc("GET "+apiBasePath+"/health", healthHandlerInstance.handleSystemHealth)
}
// registerAuthRoutes bindet Anmeldung und Selbstverwaltung ein.
func registerAuthRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
authHandlerInstance := &authHandler{
authService: routerDependencies.AuthService,
logger: logging.WithComponent(routerDependencies.Logger, "auth"),
}
// Der Begrenzer schützt die unauthentifizierten Endpunkte vor dem
// Durchprobieren von Zugangsdaten.
loginRateLimiter := NewRateLimiter(loginRateLimitAttempts, loginRateLimitWindow)
startRateLimiterCleanup(loginRateLimiter)
rateLimitedChain := func(handlerFunction http.HandlerFunc) http.HandlerFunc {
limitedHandler := RateLimitMiddleware(loginRateLimiter, routerDependencies.Logger)(handlerFunction)
return limitedHandler.ServeHTTP
}
// Anmeldung: ohne Nachweis erreichbar, aber mengenmäßig begrenzt.
requestMultiplexer.HandleFunc("POST "+apiBasePath+"/auth/login", rateLimitedChain(authHandlerInstance.handleLogin))
requestMultiplexer.HandleFunc("POST "+apiBasePath+"/auth/mfa/verify", rateLimitedChain(authHandlerInstance.handleVerifyMFA))
requestMultiplexer.HandleFunc("POST "+apiBasePath+"/auth/refresh", rateLimitedChain(authHandlerInstance.handleRefresh))
// Alles Weitere setzt eine gültige Sitzung voraus.
authenticated := authenticatedHandler(routerDependencies)
requestMultiplexer.Handle("POST "+apiBasePath+"/auth/logout", authenticated(authHandlerInstance.handleLogout))
requestMultiplexer.Handle("GET "+apiBasePath+"/me", authenticated(authHandlerInstance.handleGetCurrentUser))
// Den eigenen zweiten Faktor darf jeder angemeldete Benutzer einrichten;
// dafür ist keine gesonderte Berechtigung nötig.
requestMultiplexer.Handle("POST "+apiBasePath+"/me/mfa/enroll", authenticated(authHandlerInstance.handleBeginMFAEnrollment))
requestMultiplexer.Handle("POST "+apiBasePath+"/me/mfa/confirm", authenticated(authHandlerInstance.handleConfirmMFAEnrollment))
}
// registerUserRoutes bindet die Benutzerverwaltung ein.
func registerUserRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
userHandlerInstance := &userHandler{
authService: routerDependencies.AuthService,
logger: logging.WithComponent(routerDependencies.Logger, "users"),
}
protected := protectedHandler(routerDependencies)
requestMultiplexer.Handle("GET "+apiBasePath+"/users", protected("users.read", userHandlerInstance.handleListUsers))
requestMultiplexer.Handle("POST "+apiBasePath+"/users", protected("users.write", userHandlerInstance.handleCreateUser))
requestMultiplexer.Handle("GET "+apiBasePath+"/users/{id}", protected("users.read", userHandlerInstance.handleGetUser))
requestMultiplexer.Handle("PATCH "+apiBasePath+"/users/{id}", protected("users.write", userHandlerInstance.handleUpdateUser))
requestMultiplexer.Handle("DELETE "+apiBasePath+"/users/{id}", protected("users.write", userHandlerInstance.handleDeleteUser))
requestMultiplexer.Handle("POST "+apiBasePath+"/users/{id}/mfa/disable", protected("users.write", userHandlerInstance.handleDisableUserMFA))
}
// registerRoleRoutes bindet Rollen, Berechtigungen und das Auditprotokoll ein.
func registerRoleRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
roleHandlerInstance := &roleHandler{
repository: routerDependencies.AuthRepository,
auditRecorder: routerDependencies.AuditRecorder,
logger: logging.WithComponent(routerDependencies.Logger, "roles"),
}
auditHandlerInstance := &auditHandler{
auditRecorder: routerDependencies.AuditRecorder,
logger: logging.WithComponent(routerDependencies.Logger, "audit"),
}
protected := protectedHandler(routerDependencies)
requestMultiplexer.Handle("GET "+apiBasePath+"/roles", protected("roles.read", roleHandlerInstance.handleListRoles))
requestMultiplexer.Handle("POST "+apiBasePath+"/roles", protected("roles.write", roleHandlerInstance.handleCreateRole))
requestMultiplexer.Handle("GET "+apiBasePath+"/roles/{id}", protected("roles.read", roleHandlerInstance.handleGetRole))
requestMultiplexer.Handle("PATCH "+apiBasePath+"/roles/{id}", protected("roles.write", roleHandlerInstance.handleUpdateRole))
requestMultiplexer.Handle("DELETE "+apiBasePath+"/roles/{id}", protected("roles.write", roleHandlerInstance.handleDeleteRole))
requestMultiplexer.Handle("GET "+apiBasePath+"/permissions", protected("roles.read", roleHandlerInstance.handleListPermissions))
// Der Zugriff auf das Auditprotokoll ist gesondert beschränkt (SYNCOVA_API.md §17).
requestMultiplexer.Handle("GET "+apiBasePath+"/audit-events", protected("audit.read", auditHandlerInstance.handleListAuditEvents))
}
// registerJobRoutes bindet die Sicherungsaufträge ein (SYNCOVA_API.md §9).
func registerJobRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
if routerDependencies.JobStore == nil {
return
}
jobHandlerInstance := &jobHandler{
store: routerDependencies.JobStore,
auditRecorder: routerDependencies.AuditRecorder,
logger: logging.WithComponent(routerDependencies.Logger, "jobs"),
}
protected := protectedHandler(routerDependencies)
// Die Repositoryliste gehört fachlich zu den Zielen, wird aber vom
// Auftrags-Handler bedient: Sie hat kein eigenes Verhalten, und ein zweiter
// Handler für eine einzelne lesende Route wäre Aufwand ohne Nutzen.
requestMultiplexer.Handle("GET "+apiBasePath+"/repositories", protected("repositories.read", jobHandlerInstance.handleListRepositories))
// Eintragen, ansehen, umschalten und prüfen (SYNCOVA_API.md §8).
//
// Bis Phase 22 fehlten diese Endpunkte vollständig: Ein Repository ließ
// sich nur über SQL in die Control Plane bringen. Aufgefallen ist das erst
// beim vollständigen Durchlauf — die Anlage war über ihre eigene API nicht
// in Betrieb zu nehmen.
repositoryHandlerInstance := &repositoryHandler{
store: routerDependencies.JobStore,
auditRecorder: routerDependencies.AuditRecorder,
logger: logging.WithComponent(routerDependencies.Logger, "repositories"),
}
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories",
protected("repositories.write", repositoryHandlerInstance.handleRegisterRepository))
requestMultiplexer.Handle("GET "+apiBasePath+"/repositories/{id}",
protected("repositories.read", repositoryHandlerInstance.handleGetRepository))
requestMultiplexer.Handle("PATCH "+apiBasePath+"/repositories/{id}",
protected("repositories.write", repositoryHandlerInstance.handleUpdateRepository))
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/test",
protected("repositories.read", repositoryHandlerInstance.handleTestRepository))
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/health-check",
protected("repositories.read", repositoryHandlerInstance.handleRepositoryHealth))
// Integritätslauf und Katalogaufbau hängen am Schreibrecht: Beide lesen das
// gesamte Repository und binden dessen Datenträger für die Dauer des Laufs.
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/integrity-scan",
protected("repositories.write", repositoryHandlerInstance.handleRepositoryIntegrityScan))
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/rebuild-catalog",
protected("repositories.write", repositoryHandlerInstance.handleRebuildCatalog))
// Die Wiederherstellungspunkte sind die zentrale Auskunft der Anlage. Sie
// hängen am Leserecht für Backups, nicht an dem für Aufträge: Wer beurteilen
// soll, ob etwas wiederherstellbar ist, braucht keinen Zugriff auf die
// Zeitpläne.
requestMultiplexer.Handle("GET "+apiBasePath+"/backups", protected("backups.read", jobHandlerInstance.handleListBackups))
// Die Übersicht fasst zusammen, was ohnehin lesbar ist, und braucht deshalb
// kein eigenes Recht über das Lesen von Backups hinaus.
requestMultiplexer.Handle("GET "+apiBasePath+"/dashboard", protected("backups.read", jobHandlerInstance.handleDashboard))
requestMultiplexer.Handle("GET "+apiBasePath+"/jobs", protected("jobs.read", jobHandlerInstance.handleListJobs))
requestMultiplexer.Handle("POST "+apiBasePath+"/jobs", protected("jobs.write", jobHandlerInstance.handleCreateJob))
requestMultiplexer.Handle("GET "+apiBasePath+"/jobs/{id}", protected("jobs.read", jobHandlerInstance.handleGetJob))
// Das Löschen verlangt dasselbe Recht wie das Ändern (siehe 000002: die
// Beschreibung von jobs.write nennt das Löschen ausdrücklich mit). Es wird
// immer auditiert.
requestMultiplexer.Handle("DELETE "+apiBasePath+"/jobs/{id}", protected("jobs.write", jobHandlerInstance.handleDeleteJob))
// Ausführen, Aussetzen und Fortsetzen greifen in den Betrieb ein, ändern
// aber keine Konfiguration — dafür gibt es jobs.run.
requestMultiplexer.Handle("POST "+apiBasePath+"/jobs/{id}/run", protected("jobs.run", jobHandlerInstance.handleRunJob))
requestMultiplexer.Handle("POST "+apiBasePath+"/jobs/{id}/pause", protected("jobs.run", jobHandlerInstance.handlePauseJob))
requestMultiplexer.Handle("POST "+apiBasePath+"/jobs/{id}/resume", protected("jobs.run", jobHandlerInstance.handleResumeJob))
requestMultiplexer.Handle("GET "+apiBasePath+"/jobs/{id}/runs", protected("jobs.read", jobHandlerInstance.handleListJobRuns))
// Der Abbruch eines Laufs steht unter /backup-runs (SYNCOVA_API.md §10):
// Er betrifft den Lauf, nicht den Auftrag, und der Aufrufer kennt an dieser
// Stelle oft nur die Laufkennung.
requestMultiplexer.Handle("POST "+apiBasePath+"/backup-runs/{id}/cancel", protected("jobs.run", jobHandlerInstance.handleCancelRun))
}
// registerRestoreRoutes bindet die Wiederherstellung ein (SYNCOVA_API.md §13).
func registerRestoreRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
if routerDependencies.RestoreStore == nil || routerDependencies.JobStore == nil {
return
}
restoreHandlerInstance := &restoreHandler{
restoreStore: routerDependencies.RestoreStore,
jobStore: routerDependencies.JobStore,
auditRecorder: routerDependencies.AuditRecorder,
targetGuard: recovery.NewTargetGuard(routerDependencies.Config.Hardening.RestoreAllowedRoots),
logger: logging.WithComponent(routerDependencies.Logger, "restores"),
}
protected := protectedHandler(routerDependencies)
// Die Vorabprüfung schreibt nichts und braucht deshalb nur das Leserecht.
// Sie soll niedrigschwellig sein: Wer den Zustand der Backups beurteilen
// soll, muss sie ausführen können.
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/validate", protected("restores.read", restoreHandlerInstance.handleValidateRestore))
requestMultiplexer.Handle("GET "+apiBasePath+"/restores", protected("restores.read", restoreHandlerInstance.handleListRestores))
requestMultiplexer.Handle("POST "+apiBasePath+"/restores", protected("restores.execute", restoreHandlerInstance.handleCreateRestore))
requestMultiplexer.Handle("GET "+apiBasePath+"/restores/{id}", protected("restores.read", restoreHandlerInstance.handleGetRestore))
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/cancel", protected("restores.execute", restoreHandlerInstance.handleCancelRestore))
requestMultiplexer.Handle("POST "+apiBasePath+"/restores/{id}/resume", protected("restores.execute", restoreHandlerInstance.handleResumeRestore))
}
// registerVerificationRoutes bindet die Prüfung ein (SYNCOVA_API.md §14).
func registerVerificationRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
if routerDependencies.VerificationStore == nil || routerDependencies.JobStore == nil {
return
}
verificationHandlerInstance := &verificationHandler{
verificationStore: routerDependencies.VerificationStore,
jobStore: routerDependencies.JobStore,
auditRecorder: routerDependencies.AuditRecorder,
logger: logging.WithComponent(routerDependencies.Logger, "verification"),
}
protected := protectedHandler(routerDependencies)
requestMultiplexer.Handle("GET "+apiBasePath+"/verification", protected("verification.read", verificationHandlerInstance.handleListVerifications))
requestMultiplexer.Handle("POST "+apiBasePath+"/verification", protected("verification.write", verificationHandlerInstance.handleCreateVerification))
requestMultiplexer.Handle("GET "+apiBasePath+"/verification/{id}", protected("verification.read", verificationHandlerInstance.handleGetVerification))
requestMultiplexer.Handle("GET "+apiBasePath+"/verification/{id}/results", protected("verification.read", verificationHandlerInstance.handleGetVerificationResults))
requestMultiplexer.Handle("POST "+apiBasePath+"/verification/{id}/cancel", protected("verification.write", verificationHandlerInstance.handleCancelVerification))
// Die Bewertung steht am Backup, nicht bei den Prüfungen: Sie beantwortet
// eine Frage über das Backup („kann ich mich darauf verlassen?"), nicht über
// einen einzelnen Prüflauf. Sie schreibt nichts und braucht deshalb nur das
// Leserecht auf Backups.
requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/assurance", protected("backups.read", verificationHandlerInstance.handleGetAssurance))
}
// registerRetentionRoutes bindet Aufbewahrung und Unveränderlichkeit ein (Phase 11).
func registerRetentionRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
if routerDependencies.RetentionStore == nil || routerDependencies.JobStore == nil {
return
}
retentionHandlerInstance := &retentionHandler{
retentionStore: routerDependencies.RetentionStore,
jobStore: routerDependencies.JobStore,
auditRecorder: routerDependencies.AuditRecorder,
logger: logging.WithComponent(routerDependencies.Logger, "retention"),
}
protected := protectedHandler(routerDependencies)
// Aufbewahrungsregeln.
requestMultiplexer.Handle("GET "+apiBasePath+"/retention-policies", protected("repositories.read", retentionHandlerInstance.handleListPolicies))
requestMultiplexer.Handle("POST "+apiBasePath+"/retention-policies", protected("retention.write", retentionHandlerInstance.handleCreatePolicy))
requestMultiplexer.Handle("PATCH "+apiBasePath+"/retention-policies/{id}", protected("retention.write", retentionHandlerInstance.handleUpdatePolicy))
requestMultiplexer.Handle("DELETE "+apiBasePath+"/retention-policies/{id}", protected("retention.write", retentionHandlerInstance.handleDeletePolicy))
// Die Vorschau schreibt nichts und braucht deshalb nur das Leserecht. Sie
// soll niedrigschwellig sein: Wer wissen will, was eine Regel anrichtet, muss
// sie ausprobieren können, ohne sie ausführen zu dürfen.
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/retention/preview", protected("repositories.read", retentionHandlerInstance.handlePreviewRetention))
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/retention/apply", protected("retention.write", retentionHandlerInstance.handleApplyRetention))
// Die Messung fasst nur eine Probedatei an und beantwortet die wichtigste
// Frage des gehärteten Betriebs: Was verhindert dieser Speicher wirklich?
requestMultiplexer.Handle("POST "+apiBasePath+"/repositories/{id}/enforcement/measure", protected("repositories.write", retentionHandlerInstance.handleMeasureEnforcement))
// Schutzlage eines einzelnen Backups.
requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/protection", protected("backups.read", retentionHandlerInstance.handleGetProtection))
// Verlängern und Legal Hold hängen an immutability.manage — nicht an
// backups.delete. Wer aufräumen darf, darf deshalb noch lange keinen
// Aufbewahrungsschutz aufheben.
requestMultiplexer.Handle("POST "+apiBasePath+"/backups/{id}/retention/extend", protected("immutability.manage", retentionHandlerInstance.handleExtendRetention))
requestMultiplexer.Handle("POST "+apiBasePath+"/backups/{id}/legal-hold", protected("immutability.manage", retentionHandlerInstance.handlePlaceLegalHold))
requestMultiplexer.Handle("DELETE "+apiBasePath+"/backups/{id}/legal-hold", protected("immutability.manage", retentionHandlerInstance.handleReleaseLegalHold))
// Das Löschen eines einzelnen Backups.
requestMultiplexer.Handle("DELETE "+apiBasePath+"/backups/{id}", protected("backups.delete", retentionHandlerInstance.handleDeleteBackup))
}
// registerMetricsRoutes bindet die Kennzahlen und Diagramme ein (Phase 13).
func registerMetricsRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
if routerDependencies.MetricsStore == nil {
return
}
metricsHandlerInstance := &metricsHandler{
metricsStore: routerDependencies.MetricsStore,
logger: logging.WithComponent(routerDependencies.Logger, "metrics"),
}
protected := protectedHandler(routerDependencies)
// Die Kennzahlen fassen zusammen, was ohnehin lesbar ist, und hängen deshalb
// am Überwachungsrecht statt an einem eigenen.
requestMultiplexer.Handle("GET "+apiBasePath+"/metrics", protected("monitoring.read", metricsHandlerInstance.handleListCharts))
requestMultiplexer.Handle("GET "+apiBasePath+"/metrics/{metric}", protected("monitoring.read", metricsHandlerInstance.handleGetChart))
}
// registerAlertRoutes bindet Meldungen und Benachrichtigungen ein (Phase 14).
func registerAlertRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
if routerDependencies.AlertStore == nil {
return
}
alertHandlerInstance := &alertHandler{
alertStore: routerDependencies.AlertStore,
secretStore: routerDependencies.SecretStore,
auditRecorder: routerDependencies.AuditRecorder,
addressGuard: netguard.NewGuard(routerDependencies.Config.Hardening.AllowInternalNotificationTargets),
logger: logging.WithComponent(routerDependencies.Logger, "alerts"),
}
protected := protectedHandler(routerDependencies)
requestMultiplexer.Handle("GET "+apiBasePath+"/alerts", protected("alerts.read", alertHandlerInstance.handleListAlerts))
requestMultiplexer.Handle("GET "+apiBasePath+"/alerts/summary", protected("alerts.read", alertHandlerInstance.handleAlertSummary))
requestMultiplexer.Handle("GET "+apiBasePath+"/alerts/{id}", protected("alerts.read", alertHandlerInstance.handleGetAlert))
// Bestätigen und Auflösen greifen in den Betrieb ein, ändern aber keine
// Konfiguration — beides hängt an alerts.write.
requestMultiplexer.Handle("POST "+apiBasePath+"/alerts/{id}/acknowledge", protected("alerts.write", alertHandlerInstance.handleAcknowledgeAlert))
requestMultiplexer.Handle("POST "+apiBasePath+"/alerts/{id}/resolve", protected("alerts.write", alertHandlerInstance.handleResolveAlert))
// Die Kanäle sind Konfiguration und hängen deshalb am Einstellungsrecht: Wer
// Benachrichtigungen umleitet, kann erreichen, dass niemand mehr von einem
// Ausfall erfährt.
requestMultiplexer.Handle("GET "+apiBasePath+"/notification-channels", protected("settings.read", alertHandlerInstance.handleListChannels))
requestMultiplexer.Handle("POST "+apiBasePath+"/notification-channels", protected("settings.write", alertHandlerInstance.handleCreateChannel))
requestMultiplexer.Handle("DELETE "+apiBasePath+"/notification-channels/{id}", protected("settings.write", alertHandlerInstance.handleDeleteChannel))
}
// registerSecurityRoutes bindet das Security Center ein (Phase 15).
func registerSecurityRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
if routerDependencies.SecurityInspector == nil {
return
}
securityHandlerInstance := &securityHandler{
inspector: routerDependencies.SecurityInspector,
logger: logging.WithComponent(routerDependencies.Logger, "security"),
}
protected := protectedHandler(routerDependencies)
requestMultiplexer.Handle("GET "+apiBasePath+"/security", protected("security.read", securityHandlerInstance.handleGetAssessment))
requestMultiplexer.Handle("GET "+apiBasePath+"/security/findings", protected("security.read", securityHandlerInstance.handleListFindings))
}
// registerAgentRoutes bindet die Agent-Endpunkte ein (SYNCOVA_API.md §6).
func registerAgentRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
agentHandlerInstance := &agentHandler{
agentService: routerDependencies.AgentService,
logger: logging.WithComponent(routerDependencies.Logger, "agents"),
}
protected := protectedHandler(routerDependencies)
// Verwaltung durch Benutzer.
requestMultiplexer.Handle("GET "+apiBasePath+"/agents", protected("agents.read", agentHandlerInstance.handleListAgents))
requestMultiplexer.Handle("GET "+apiBasePath+"/agents/{id}", protected("agents.read", agentHandlerInstance.handleGetAgent))
requestMultiplexer.Handle("GET "+apiBasePath+"/agents/{id}/health", protected("agents.read", agentHandlerInstance.handleAgentHealth))
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/{id}/revoke", protected("agents.write", agentHandlerInstance.handleRevokeAgent))
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/{id}/rotate-credentials", protected("agents.write", agentHandlerInstance.handleRotateCredentials))
// Die Ausstellung eines Aufnahme-Tokens nimmt ein neues System in die
// Infrastruktur auf und ist deshalb an ein eigenes Recht gebunden.
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/enrollment-tokens",
protected("agents.enroll", agentHandlerInstance.handleIssueEnrollmentToken))
// Die Registrierung ist ohne Benutzeranmeldung erreichbar: ein sich
// aufnehmender Agent besitzt noch kein Betriebstoken. Sein Nachweis ist
// das Aufnahme-Token, das die Domänenlogik prüft.
requestMultiplexer.HandleFunc("POST "+apiBasePath+"/agents/register", agentHandlerInstance.handleRegisterAgent)
// Die Lebendmeldung verlangt das Betriebstoken des Agents, nicht die
// Sitzung eines Benutzers.
agentAuthenticated := AgentAuthenticationMiddleware(routerDependencies.AgentService, routerDependencies.Logger)
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/heartbeat",
agentAuthenticated(http.HandlerFunc(agentHandlerInstance.handleHeartbeat)))
// Die Auftragsübermittlung hängt ebenfalls am Betriebstoken des Agenten.
//
// Der Agent **holt** ab; der Server drückt nicht. Ein Agent steht hinter
// einer Firewall, oft hinter NAT — die Verbindung geht immer von ihm aus.
if routerDependencies.AgentTaskStore != nil {
agentTaskHandlerInstance := &agentTaskHandler{
taskStore: routerDependencies.AgentTaskStore,
logger: logging.WithComponent(routerDependencies.Logger, "agent-tasks"),
}
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/tasks/claim",
agentAuthenticated(http.HandlerFunc(agentTaskHandlerInstance.handleClaimTask)))
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/tasks/{id}/progress",
agentAuthenticated(http.HandlerFunc(agentTaskHandlerInstance.handleReportProgress)))
requestMultiplexer.Handle("POST "+apiBasePath+"/agents/tasks/{id}/result",
agentAuthenticated(http.HandlerFunc(agentTaskHandlerInstance.handleReportResult)))
}
}
// authenticatedHandler baut einen Handler, der eine gültige Sitzung verlangt.
func authenticatedHandler(routerDependencies RouterDependencies) func(http.HandlerFunc) http.Handler {
return func(handlerFunction http.HandlerFunc) http.Handler {
return AuthenticationMiddleware(routerDependencies.AuthService, routerDependencies.Logger)(handlerFunction)
}
}
// protectedHandler baut einen Handler, der Sitzung und Berechtigung verlangt.
//
// Beide Prüfungen sind hier untrennbar verbunden: ein Endpunkt lässt sich damit
// nicht versehentlich ohne Berechtigungsprüfung einbinden (PROMPT.md §42).
func protectedHandler(routerDependencies RouterDependencies) func(string, http.HandlerFunc) http.Handler {
return func(requiredPermission string, handlerFunction http.HandlerFunc) http.Handler {
permissionCheckedHandler := RequirePermission(
requiredPermission,
routerDependencies.AuthService,
routerDependencies.AuditRecorder,
routerDependencies.Logger,
handlerFunction,
)
return AuthenticationMiddleware(routerDependencies.AuthService, routerDependencies.Logger)(permissionCheckedHandler)
}
}
// startRateLimiterCleanup entfernt regelmäßig abgelaufene Einträge.
//
// Ohne Bereinigung wüchse die Zählerkarte mit jeder neuen Absenderadresse.
func startRateLimiterCleanup(limiter *RateLimiter) {
cleanupTicker := time.NewTicker(rateLimiterCleanupInterval)
go func() {
// Die Aufräumroutine läuft für die Lebensdauer des Dienstes.
for range cleanupTicker.C {
limiter.Cleanup()
}
}()
}
// registerRansomwareRoutes bindet die Auffaelligkeitsbewertung ein.
//
// Sie haengt an backups.read, nicht an einem eigenen Sicherheitsrecht: Wer die
// Backups sehen darf, muss auch erfahren duerfen, ob einer davon aus dem
// Rahmen faellt — die Bewertung ist eine Eigenschaft des Laufs, kein Geheimnis.
func registerRansomwareRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
if routerDependencies.RansomwareDetector == nil {
return
}
ransomwareHandlerInstance := &ransomwareHandler{
detector: routerDependencies.RansomwareDetector,
logger: logging.WithComponent(routerDependencies.Logger, "ransomware"),
}
protected := protectedHandler(routerDependencies)
requestMultiplexer.Handle("GET "+apiBasePath+"/backups/{id}/ransomware-assessment",
protected("backups.read", ransomwareHandlerInstance.handleGetAssessment))
}
// registerReportRoutes bindet die Berichte ein.
//
// Beide Routen haengen an reports.read. Die Berechtigung gibt es seit Phase 1;
// Viewer und Auditor tragen sie bereits — der Auditor ist genau die Rolle, fuer
// die der Bericht fuer Pruefungen geschrieben wurde.
func registerReportRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
if routerDependencies.ReportGenerator == nil {
return
}
reportHandlerInstance := &reportHandler{
reportGenerator: routerDependencies.ReportGenerator,
auditRecorder: routerDependencies.AuditRecorder,
logger: logging.WithComponent(routerDependencies.Logger, "reports"),
}
protected := protectedHandler(routerDependencies)
requestMultiplexer.Handle("GET "+apiBasePath+"/reports",
protected("reports.read", reportHandlerInstance.handleListReports))
requestMultiplexer.Handle("POST "+apiBasePath+"/reports/generate",
protected("reports.read", reportHandlerInstance.handleGenerateReport))
}
// registerHypervisorRoutes bindet die Endpunkte der Virtualisierungsumgebungen ein.
//
// Die Rechte sind die bereits vorhandenen providers.read und providers.write —
// ein neues Recht hätte jede bestehende Rollenzuweisung stillschweigend
// verändert.
func registerHypervisorRoutes(requestMultiplexer *http.ServeMux, routerDependencies RouterDependencies) {
if routerDependencies.HypervisorStore == nil {
return
}
hypervisorHandlerInstance := &hypervisorHandler{
clusterStore: routerDependencies.HypervisorStore,
auditRecorder: routerDependencies.AuditRecorder,
logger: logging.WithComponent(routerDependencies.Logger, "hypervisor"),
}
protected := protectedHandler(routerDependencies)
requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/clusters",
protected("providers.read", hypervisorHandlerInstance.handleListClusters))
requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/clusters/{id}",
protected("providers.read", hypervisorHandlerInstance.handleGetCluster))
requestMultiplexer.Handle("POST "+apiBasePath+"/proxmox/clusters",
protected("providers.write", hypervisorHandlerInstance.handleCreateCluster))
requestMultiplexer.Handle("DELETE "+apiBasePath+"/proxmox/clusters/{id}",
protected("providers.write", hypervisorHandlerInstance.handleDeleteCluster))
// Prüfung und Bestandsaufnahme lesen nur — aber sie bauen eine Verbindung
// nach außen auf und erzeugen Last auf dem Verbund. Deshalb hängen sie am
// Schreibrecht, nicht am Leserecht.
requestMultiplexer.Handle("POST "+apiBasePath+"/proxmox/clusters/{id}/test",
protected("providers.write", hypervisorHandlerInstance.handleTestConnection))
requestMultiplexer.Handle("POST "+apiBasePath+"/proxmox/clusters/{id}/discover",
protected("providers.write", hypervisorHandlerInstance.handleDiscover))
requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/clusters/{id}/hosts",
protected("providers.read", hypervisorHandlerInstance.handleListHosts))
requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/clusters/{id}/vms",
protected("providers.read", hypervisorHandlerInstance.handleListClusterMachines))
requestMultiplexer.Handle("GET "+apiBasePath+"/proxmox/vms/{id}",
protected("providers.read", hypervisorHandlerInstance.handleGetVirtualMachine))
// Zusätzlich zur Vertragsliste: der Bestand über alle Verbünde hinweg. Eine
// Übersicht, die zuerst nach dem Verbund fragt, ist keine Übersicht.
requestMultiplexer.Handle("GET "+apiBasePath+"/virtual-machines",
protected("providers.read", hypervisorHandlerInstance.handleListVirtualMachines))
}