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

246 lines
9.4 KiB
Go

package httpapi
import (
"context"
"log/slog"
"net/http"
"slices"
"strconv"
"time"
"github.com/google/uuid"
"github.com/syncova/syncova/packages/platform/logging"
)
// HTTP-Header, die Syncova für Nachvollziehbarkeit auswertet bzw. setzt.
const (
// headerCorrelationID trägt die vom Aufrufer vorgegebene Correlation ID (SYNCOVA_API.md).
headerCorrelationID = "X-Correlation-ID"
// headerRequestID trägt die serverseitig vergebene Request-ID.
headerRequestID = "X-Request-ID"
)
// requestIDContextKeyType ist ein privater Typ für den Context-Schlüssel der Request-ID.
type requestIDContextKeyType struct{}
// requestIDContextKey speichert die Request-ID im Request-Context.
var requestIDContextKey = requestIDContextKeyType{}
// Middleware ist ein Dekorator für einen HTTP-Handler.
type Middleware func(http.Handler) http.Handler
// Chain verkettet Middlewares so, dass die zuerst genannte außen liegt.
//
// Die Reihenfolge ist sicherheitsrelevant: Recovery und Correlation ID müssen
// außen liegen, damit auch Fehler innerer Schichten erfasst werden.
func Chain(finalHandler http.Handler, middlewares ...Middleware) http.Handler {
// Rückwärts anwenden, damit middlewares[0] die äußerste Schicht bildet.
for middlewareIndex := len(middlewares) - 1; middlewareIndex >= 0; middlewareIndex-- {
finalHandler = middlewares[middlewareIndex](finalHandler)
}
return finalHandler
}
// RequestIDFromContext liest die Request-ID aus dem Context.
// Ohne gesetzte ID liefert die Funktion eine leere Zeichenkette.
func RequestIDFromContext(currentContext context.Context) string {
requestID, isPresent := currentContext.Value(requestIDContextKey).(string)
if !isPresent {
return ""
}
return requestID
}
// CorrelationMiddleware vergibt Request-ID und Correlation ID für jeden Request.
//
// Die Request-ID wird immer serverseitig erzeugt; die Correlation ID darf der
// Aufrufer vorgeben, um eine Operation über Systemgrenzen hinweg zu verfolgen
// (PROMPT.md §50). Ein ungültiger Vorgabewert wird verworfen statt übernommen,
// damit keine fremden Zeichenketten in die Logs gelangen.
func CorrelationMiddleware() Middleware {
return func(nextHandler http.Handler) http.Handler {
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
requestID := uuid.NewString()
// Nur eine syntaktisch gültige UUID wird als Correlation ID übernommen.
correlationID := requestID
if providedCorrelationID := request.Header.Get(headerCorrelationID); providedCorrelationID != "" {
if _, parseError := uuid.Parse(providedCorrelationID); parseError == nil {
correlationID = providedCorrelationID
}
}
enrichedContext := context.WithValue(request.Context(), requestIDContextKey, requestID)
enrichedContext = logging.ContextWithCorrelationID(enrichedContext, correlationID)
// Beide IDs gehen an den Aufrufer zurück, damit er einen Vorfall
// gegenüber dem Betreiber eindeutig benennen kann.
responseWriter.Header().Set(headerRequestID, requestID)
responseWriter.Header().Set(headerCorrelationID, correlationID)
nextHandler.ServeHTTP(responseWriter, request.WithContext(enrichedContext))
})
}
}
// SecurityHeadersMiddleware setzt defensive Antwort-Header (PROMPT.md §45).
func SecurityHeadersMiddleware() Middleware {
return func(nextHandler http.Handler) http.Handler {
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
responseHeaders := responseWriter.Header()
// Verhindert, dass Browser den Inhaltstyp erraten.
responseHeaders.Set("X-Content-Type-Options", "nosniff")
// Die API liefert ausschließlich JSON und wird nie eingebettet.
responseHeaders.Set("X-Frame-Options", "DENY")
// Keine Referrer-Weitergabe an fremde Ziele.
responseHeaders.Set("Referrer-Policy", "no-referrer")
// Eine restriktive CSP, da API-Antworten kein aktives Material enthalten.
responseHeaders.Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
// Antworten der Control Plane dürfen nicht zwischengespeichert werden.
responseHeaders.Set("Cache-Control", "no-store")
nextHandler.ServeHTTP(responseWriter, request)
})
}
}
// CORSMiddleware erlaubt Cross-Origin-Zugriff ausschließlich für benannte Herkünfte.
//
// Eine leere Liste bedeutet: kein Cross-Origin-Zugriff. Es gibt bewusst keine
// Wildcard-Unterstützung (PROMPT.md §45).
func CORSMiddleware(allowedOrigins []string) Middleware {
return func(nextHandler http.Handler) http.Handler {
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
requestOrigin := request.Header.Get("Origin")
isAllowedOrigin := requestOrigin != "" && slices.Contains(allowedOrigins, requestOrigin)
if isAllowedOrigin {
responseHeaders := responseWriter.Header()
responseHeaders.Set("Access-Control-Allow-Origin", requestOrigin)
responseHeaders.Set("Access-Control-Allow-Credentials", "true")
responseHeaders.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, "+headerCorrelationID+", Idempotency-Key")
responseHeaders.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
responseHeaders.Set("Access-Control-Max-Age", "600")
// Antwort hängt von der Herkunft ab; ohne Vary wären Caches unsicher.
responseHeaders.Add("Vary", "Origin")
}
// Preflight-Anfragen werden hier abschließend beantwortet.
if request.Method == http.MethodOptions {
if isAllowedOrigin {
responseWriter.WriteHeader(http.StatusNoContent)
} else {
responseWriter.WriteHeader(http.StatusForbidden)
}
return
}
nextHandler.ServeHTTP(responseWriter, request)
})
}
}
// BodyLimitMiddleware begrenzt die Größe eines Request-Bodys (PROMPT.md §45).
func BodyLimitMiddleware(maxRequestBodyBytes int64) Middleware {
return func(nextHandler http.Handler) http.Handler {
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
// MaxBytesReader bricht das Lesen ab, sobald das Limit überschritten wird,
// statt den gesamten Body erst zu puffern.
request.Body = http.MaxBytesReader(responseWriter, request.Body, maxRequestBodyBytes)
nextHandler.ServeHTTP(responseWriter, request)
})
}
}
// RecoveryMiddleware fängt Panics und verwandelt sie in eine kontrollierte Antwort.
//
// PROMPT.md §51 verbietet unkontrollierte Ausnahmen: ein Programmierfehler darf
// den Dienst nicht beenden und keine internen Details ausliefern.
func RecoveryMiddleware(baseLogger *slog.Logger) Middleware {
return func(nextHandler http.Handler) http.Handler {
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
defer func() {
panicValue := recover()
if panicValue == nil {
return
}
// http.ErrAbortHandler ist der dokumentierte Weg, eine Antwort
// bewusst abzubrechen, und kein Fehlerfall.
if panicValue == http.ErrAbortHandler {
panic(panicValue)
}
requestLogger := logging.WithContext(request.Context(), baseLogger)
requestLogger.Error("panic im request-handler abgefangen",
slog.Any("panic", panicValue),
slog.String("path", request.URL.Path),
)
WriteError(responseWriter, request, requestLogger, NewInternalError(nil))
}()
nextHandler.ServeHTTP(responseWriter, request)
})
}
}
// statusCapturingResponseWriter merkt sich Statuscode und Antwortgröße für das Zugriffslog.
type statusCapturingResponseWriter struct {
http.ResponseWriter
// statusCode ist der tatsächlich gesendete HTTP-Status.
statusCode int
// writtenBytes zählt die Größe des Antwortkörpers.
writtenBytes int
}
// WriteHeader merkt sich den Statuscode und reicht ihn weiter.
func (capturingWriter *statusCapturingResponseWriter) WriteHeader(statusCode int) {
capturingWriter.statusCode = statusCode
capturingWriter.ResponseWriter.WriteHeader(statusCode)
}
// Write zählt die geschriebenen Bytes und reicht sie weiter.
func (capturingWriter *statusCapturingResponseWriter) Write(responseBytes []byte) (int, error) {
// Ohne vorherigen WriteHeader gilt implizit 200.
if capturingWriter.statusCode == 0 {
capturingWriter.statusCode = http.StatusOK
}
bytesWritten, writeError := capturingWriter.ResponseWriter.Write(responseBytes)
capturingWriter.writtenBytes += bytesWritten
return bytesWritten, writeError
}
// AccessLogMiddleware protokolliert jeden Request strukturiert (PROMPT.md §49).
func AccessLogMiddleware(baseLogger *slog.Logger) Middleware {
return func(nextHandler http.Handler) http.Handler {
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
requestStartTime := time.Now()
capturingWriter := &statusCapturingResponseWriter{ResponseWriter: responseWriter}
nextHandler.ServeHTTP(capturingWriter, request)
// Ein Handler, der nichts schreibt, hat faktisch 200 geliefert.
if capturingWriter.statusCode == 0 {
capturingWriter.statusCode = http.StatusOK
}
// Die Query-Zeichenkette wird bewusst nicht geloggt: sie könnte
// versehentlich sensible Werte enthalten (PROMPT.md §45).
logging.WithContext(request.Context(), baseLogger).Info("http request",
slog.String("method", request.Method),
slog.String("path", request.URL.Path),
slog.Int("status_code", capturingWriter.statusCode),
slog.Int("response_bytes", capturingWriter.writtenBytes),
slog.String("duration_ms", strconv.FormatFloat(time.Since(requestStartTime).Seconds()*1000, 'f', 2, 64)),
)
})
}
}