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>
246 lines
9.4 KiB
Go
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)),
|
|
)
|
|
})
|
|
}
|
|
}
|