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>
335 lines
12 KiB
Go
335 lines
12 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgconn"
|
|
"github.com/syncova/syncova/packages/audit"
|
|
"github.com/syncova/syncova/packages/auth"
|
|
"github.com/syncova/syncova/packages/platform/logging"
|
|
)
|
|
|
|
// authenticatedUserContextKeyType ist der private Typ des Context-Schlüssels
|
|
// für den angemeldeten Benutzer.
|
|
type authenticatedUserContextKeyType struct{}
|
|
|
|
// sessionContextKeyType ist der private Typ des Context-Schlüssels für die Sitzung.
|
|
type sessionContextKeyType struct{}
|
|
|
|
var (
|
|
// authenticatedUserContextKey speichert den angemeldeten Benutzer.
|
|
authenticatedUserContextKey = authenticatedUserContextKeyType{}
|
|
// sessionContextKey speichert die Kennung der aktiven Sitzung.
|
|
sessionContextKey = sessionContextKeyType{}
|
|
)
|
|
|
|
// AuthenticatedUserFromContext liest den angemeldeten Benutzer aus dem Context.
|
|
//
|
|
// Der zweite Rückgabewert ist false, wenn der Request nicht authentifiziert ist.
|
|
func AuthenticatedUserFromContext(currentContext context.Context) (auth.User, bool) {
|
|
authenticatedUser, isPresent := currentContext.Value(authenticatedUserContextKey).(auth.User)
|
|
return authenticatedUser, isPresent
|
|
}
|
|
|
|
// SessionIDFromContext liest die Kennung der aktiven Sitzung aus dem Context.
|
|
func SessionIDFromContext(currentContext context.Context) (uuid.UUID, bool) {
|
|
sessionID, isPresent := currentContext.Value(sessionContextKey).(uuid.UUID)
|
|
return sessionID, isPresent
|
|
}
|
|
|
|
// RequestContextFrom baut den Herkunftskontext für das Auditprotokoll.
|
|
func RequestContextFrom(request *http.Request) auth.RequestContext {
|
|
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
|
|
|
|
return auth.RequestContext{
|
|
IPAddress: clientIPAddress(request),
|
|
UserAgent: request.UserAgent(),
|
|
CorrelationID: correlationID,
|
|
}
|
|
}
|
|
|
|
// clientIPAddress ermittelt die Absenderadresse eines Requests.
|
|
//
|
|
// Weitergeleitete Adressen aus Headern werden bewusst NICHT ausgewertet: sie
|
|
// sind frei fälschbar und würden ein Auditprotokoll wertlos machen, solange
|
|
// nicht bekannt ist, welchem Proxy zu trauen ist.
|
|
func clientIPAddress(request *http.Request) string {
|
|
remoteHost, _, splitError := net.SplitHostPort(request.RemoteAddr)
|
|
if splitError != nil {
|
|
return request.RemoteAddr
|
|
}
|
|
|
|
return remoteHost
|
|
}
|
|
|
|
// AuthenticationMiddleware prüft das Zugriffstoken jedes Requests.
|
|
//
|
|
// Ohne gültiges Token endet der Request mit 401; der nachgelagerte Handler wird
|
|
// dann gar nicht erst erreicht (PROMPT.md §45).
|
|
func AuthenticationMiddleware(authService *auth.Service, baseLogger *slog.Logger) Middleware {
|
|
return func(nextHandler http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), baseLogger)
|
|
|
|
accessToken, extractError := extractBearerToken(request)
|
|
if extractError != nil {
|
|
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
|
|
"Für diesen Zugriff ist eine Anmeldung erforderlich."))
|
|
return
|
|
}
|
|
|
|
authenticatedUser, sessionID, authenticateError := authService.Authenticate(request.Context(), accessToken)
|
|
if authenticateError != nil {
|
|
// Die Ursache wird nicht offengelegt: ob ein Token unbekannt,
|
|
// abgelaufen oder widerrufen ist, geht den Aufrufer nichts an.
|
|
if errors.Is(authenticateError, auth.ErrAccountDisabled) {
|
|
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
|
|
"Das Konto ist nicht mehr aktiv. Bitte an die Administration wenden."))
|
|
return
|
|
}
|
|
|
|
// Eine nicht erreichbare Datenbank ist kein Anmeldeproblem.
|
|
//
|
|
// Die Tokenpruefung braucht die Datenbank; faellt sie aus,
|
|
// scheitert jede Pruefung. Als „Sitzung abgelaufen" gemeldet
|
|
// schickt das den Betreiber auf die falsche Spur: Er meldet
|
|
// sich neu an, was ebenfalls scheitert, und sucht den Fehler
|
|
// bei der Anmeldung statt bei der Datenbank. In Phase 21 real
|
|
// beobachtet.
|
|
if errors.Is(authenticateError, context.DeadlineExceeded) ||
|
|
isDatabaseUnavailable(authenticateError) {
|
|
requestLogger.Error("die anmeldung liess sich nicht pruefen",
|
|
slog.String("grund", authenticateError.Error()))
|
|
|
|
WriteError(responseWriter, request, requestLogger, NewServiceUnavailableError(
|
|
"Die Anmeldung lässt sich derzeit nicht prüfen: Die Datenbank ist "+
|
|
"nicht erreichbar. Das ist kein Problem Ihrer Sitzung."))
|
|
|
|
return
|
|
}
|
|
|
|
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
|
|
"Die Sitzung ist abgelaufen. Bitte erneut anmelden."))
|
|
return
|
|
}
|
|
|
|
enrichedContext := context.WithValue(request.Context(), authenticatedUserContextKey, authenticatedUser)
|
|
enrichedContext = context.WithValue(enrichedContext, sessionContextKey, sessionID)
|
|
|
|
nextHandler.ServeHTTP(responseWriter, request.WithContext(enrichedContext))
|
|
})
|
|
}
|
|
}
|
|
|
|
// RequirePermission erzwingt eine Berechtigung für einen Handler.
|
|
//
|
|
// Die Prüfung erfolgt ausschließlich serverseitig; Angaben des Frontends werden
|
|
// niemals als Berechtigungsnachweis akzeptiert (PROMPT.md §42).
|
|
func RequirePermission(requiredPermission string, authService *auth.Service, auditRecorder audit.Recorder, baseLogger *slog.Logger, protectedHandler http.HandlerFunc) http.HandlerFunc {
|
|
return func(responseWriter http.ResponseWriter, request *http.Request) {
|
|
requestLogger := logging.WithContext(request.Context(), baseLogger)
|
|
|
|
authenticatedUser, isAuthenticated := AuthenticatedUserFromContext(request.Context())
|
|
if !isAuthenticated {
|
|
// Dieser Fall bedeutet einen Fehler im Routenaufbau: der Handler
|
|
// wurde ohne vorgeschaltete Authentifizierung eingebunden.
|
|
requestLogger.Error("berechtigungsprüfung ohne authentifizierung aufgerufen",
|
|
slog.String("permission", requiredPermission),
|
|
slog.String("path", request.URL.Path))
|
|
|
|
WriteError(responseWriter, request, requestLogger, newUnauthenticatedError(
|
|
"Für diesen Zugriff ist eine Anmeldung erforderlich."))
|
|
return
|
|
}
|
|
|
|
if !authenticatedUser.HasPermission(requiredPermission) {
|
|
// Ein abgewiesener Zugriff ist ein Sicherheitsereignis (PROMPT.md §125).
|
|
correlationID, _ := logging.CorrelationIDFromContext(request.Context())
|
|
if recordError := auditRecorder.Record(request.Context(), audit.Event{
|
|
UserID: &authenticatedUser.ID,
|
|
ActorUsername: authenticatedUser.Username,
|
|
Action: audit.ActionPermissionDenied,
|
|
Result: audit.ResultDenied,
|
|
IPAddress: clientIPAddress(request),
|
|
UserAgent: request.UserAgent(),
|
|
CorrelationID: correlationID,
|
|
Details: map[string]any{
|
|
"benötigte_berechtigung": requiredPermission,
|
|
"pfad": request.URL.Path,
|
|
},
|
|
}); recordError != nil {
|
|
requestLogger.Error("abgewiesener zugriff konnte nicht protokolliert werden",
|
|
slog.String("error", recordError.Error()))
|
|
}
|
|
|
|
WriteError(responseWriter, request, requestLogger, &APIError{
|
|
StatusCode: http.StatusForbidden,
|
|
Code: ErrorCodePermissionDenied,
|
|
Message: "Für diese Aktion fehlt die erforderliche Berechtigung.",
|
|
Details: map[string]any{"required_permission": requiredPermission},
|
|
})
|
|
return
|
|
}
|
|
|
|
protectedHandler(responseWriter, request)
|
|
}
|
|
}
|
|
|
|
// extractBearerToken liest das Zugriffstoken aus dem Authorization-Header.
|
|
func extractBearerToken(request *http.Request) (string, error) {
|
|
authorizationHeader := request.Header.Get("Authorization")
|
|
if authorizationHeader == "" {
|
|
return "", errors.New("kein authorization-header vorhanden")
|
|
}
|
|
|
|
// Das Schema wird ohne Beachtung der Groß-/Kleinschreibung verglichen.
|
|
const bearerPrefix = "bearer "
|
|
if len(authorizationHeader) <= len(bearerPrefix) || !strings.EqualFold(authorizationHeader[:len(bearerPrefix)], bearerPrefix) {
|
|
return "", errors.New("das authorization-schema ist nicht Bearer")
|
|
}
|
|
|
|
accessToken := strings.TrimSpace(authorizationHeader[len(bearerPrefix):])
|
|
if accessToken == "" {
|
|
return "", errors.New("das token ist leer")
|
|
}
|
|
|
|
return accessToken, nil
|
|
}
|
|
|
|
// newUnauthenticatedError baut eine Antwort für einen fehlenden Nachweis.
|
|
func newUnauthenticatedError(errorMessage string) *APIError {
|
|
return &APIError{
|
|
StatusCode: http.StatusUnauthorized,
|
|
Code: ErrorCodeUnauthenticated,
|
|
Message: errorMessage,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rate Limiting
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// RateLimiter begrenzt die Anzahl Versuche je Absender.
|
|
//
|
|
// Der Zähler liegt im Arbeitsspeicher: für die Control Plane mit einem Knoten
|
|
// genügt das. Bei mehreren Knoten müsste er in eine gemeinsame Ablage wandern.
|
|
type RateLimiter struct {
|
|
// mutex schützt die Zählerkarte gegen gleichzeitige Zugriffe.
|
|
mutex sync.Mutex
|
|
// attemptsByKey hält die Zeitpunkte der Versuche je Absender.
|
|
attemptsByKey map[string][]time.Time
|
|
// maxAttempts ist die erlaubte Anzahl Versuche im Zeitfenster.
|
|
maxAttempts int
|
|
// window ist die Länge des betrachteten Zeitfensters.
|
|
window time.Duration
|
|
}
|
|
|
|
// NewRateLimiter erzeugt einen Begrenzer.
|
|
func NewRateLimiter(maxAttempts int, window time.Duration) *RateLimiter {
|
|
return &RateLimiter{
|
|
attemptsByKey: make(map[string][]time.Time),
|
|
maxAttempts: maxAttempts,
|
|
window: window,
|
|
}
|
|
}
|
|
|
|
// Allow meldet, ob ein weiterer Versuch zulässig ist, und vermerkt ihn.
|
|
func (limiter *RateLimiter) Allow(limiterKey string) bool {
|
|
limiter.mutex.Lock()
|
|
defer limiter.mutex.Unlock()
|
|
|
|
currentTime := time.Now()
|
|
windowStart := currentTime.Add(-limiter.window)
|
|
|
|
// Versuche ausserhalb des Zeitfensters werden verworfen.
|
|
recentAttempts := make([]time.Time, 0, len(limiter.attemptsByKey[limiterKey]))
|
|
for _, attemptTime := range limiter.attemptsByKey[limiterKey] {
|
|
if attemptTime.After(windowStart) {
|
|
recentAttempts = append(recentAttempts, attemptTime)
|
|
}
|
|
}
|
|
|
|
if len(recentAttempts) >= limiter.maxAttempts {
|
|
limiter.attemptsByKey[limiterKey] = recentAttempts
|
|
return false
|
|
}
|
|
|
|
limiter.attemptsByKey[limiterKey] = append(recentAttempts, currentTime)
|
|
|
|
return true
|
|
}
|
|
|
|
// Cleanup entfernt Einträge, deren Versuche vollständig abgelaufen sind.
|
|
//
|
|
// Ohne diese Bereinigung wüchse die Karte mit jeder neuen Absenderadresse.
|
|
func (limiter *RateLimiter) Cleanup() {
|
|
limiter.mutex.Lock()
|
|
defer limiter.mutex.Unlock()
|
|
|
|
windowStart := time.Now().Add(-limiter.window)
|
|
|
|
for limiterKey, attemptTimes := range limiter.attemptsByKey {
|
|
hasRecentAttempt := false
|
|
for _, attemptTime := range attemptTimes {
|
|
if attemptTime.After(windowStart) {
|
|
hasRecentAttempt = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !hasRecentAttempt {
|
|
delete(limiter.attemptsByKey, limiterKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
// RateLimitMiddleware begrenzt Anfragen je Absenderadresse.
|
|
//
|
|
// Sie schützt die Anmeldung vor dem Durchprobieren von Passwörtern (PROMPT.md §45).
|
|
func RateLimitMiddleware(limiter *RateLimiter, baseLogger *slog.Logger) Middleware {
|
|
return func(nextHandler http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(responseWriter http.ResponseWriter, request *http.Request) {
|
|
if !limiter.Allow(clientIPAddress(request)) {
|
|
requestLogger := logging.WithContext(request.Context(), baseLogger)
|
|
requestLogger.Warn("anfrage wegen zu vieler versuche abgewiesen",
|
|
slog.String("path", request.URL.Path))
|
|
|
|
WriteError(responseWriter, request, requestLogger, &APIError{
|
|
StatusCode: http.StatusTooManyRequests,
|
|
Code: ErrorCodeRateLimited,
|
|
Message: "Zu viele Versuche. Bitte einige Minuten warten und erneut versuchen.",
|
|
})
|
|
return
|
|
}
|
|
|
|
nextHandler.ServeHTTP(responseWriter, request)
|
|
})
|
|
}
|
|
}
|
|
|
|
// isDatabaseUnavailable erkennt einen Ausfall der Datenbank.
|
|
//
|
|
// Geprueft wird der Verbindungsfehler, nicht der Meldungstext: Ein
|
|
// Textvergleich braeche bei der ersten geaenderten Fehlermeldung des Treibers,
|
|
// und zwar unbemerkt — der Ausfall erschiene dann wieder als Anmeldeproblem.
|
|
func isDatabaseUnavailable(occurredError error) bool {
|
|
// Ein Netzfehler auf dem Weg zur Datenbank: kein Rechner, keine Route,
|
|
// keine Verbindung.
|
|
var networkError net.Error
|
|
if errors.As(occurredError, &networkError) {
|
|
return true
|
|
}
|
|
|
|
var connectError *pgconn.ConnectError
|
|
|
|
return errors.As(occurredError, &connectError)
|
|
}
|