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>
173 lines
5.2 KiB
Go
173 lines
5.2 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestExtractBearerToken(testInstance *testing.T) {
|
|
validHeaders := map[string]string{
|
|
"Bearer abc123": "abc123",
|
|
"bearer abc123": "abc123",
|
|
"BEARER abc123": "abc123",
|
|
"Bearer abc123": "abc123",
|
|
}
|
|
|
|
for headerValue, expectedToken := range validHeaders {
|
|
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
|
|
testRequest.Header.Set("Authorization", headerValue)
|
|
|
|
extractedToken, extractError := extractBearerToken(testRequest)
|
|
if extractError != nil {
|
|
testInstance.Errorf("der Header %q wurde abgelehnt: %v", headerValue, extractError)
|
|
continue
|
|
}
|
|
|
|
if extractedToken != expectedToken {
|
|
testInstance.Errorf("der Header %q lieferte %q statt %q", headerValue, extractedToken, expectedToken)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestExtractBearerTokenRejectsInvalidHeaders(testInstance *testing.T) {
|
|
invalidHeaders := map[string]string{
|
|
"leer": "",
|
|
"nur Schema": "Bearer",
|
|
"leeres Token": "Bearer ",
|
|
"falsches Schema": "Basic abc123",
|
|
"ohne Schema": "abc123",
|
|
}
|
|
|
|
for headerDescription, headerValue := range invalidHeaders {
|
|
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
|
|
if headerValue != "" {
|
|
testRequest.Header.Set("Authorization", headerValue)
|
|
}
|
|
|
|
// Ein unbrauchbarer Header darf niemals als gültiger Nachweis durchgehen.
|
|
if _, extractError := extractBearerToken(testRequest); extractError == nil {
|
|
testInstance.Errorf("%s: der Header %q wurde akzeptiert", headerDescription, headerValue)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestClientIPAddressIgnoresForwardedHeaders(testInstance *testing.T) {
|
|
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
|
|
testRequest.RemoteAddr = "192.0.2.10:54321"
|
|
|
|
// Diese Header sind frei fälschbar. Würden sie ausgewertet, könnte ein
|
|
// Angreifer die Herkunft im Auditprotokoll beliebig verfälschen.
|
|
testRequest.Header.Set("X-Forwarded-For", "10.0.0.1")
|
|
testRequest.Header.Set("X-Real-IP", "10.0.0.2")
|
|
|
|
determinedAddress := clientIPAddress(testRequest)
|
|
if determinedAddress != "192.0.2.10" {
|
|
testInstance.Fatalf("es soll die echte Absenderadresse verwendet werden, war %q", determinedAddress)
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterAllowsUpToLimit(testInstance *testing.T) {
|
|
testLimiter := NewRateLimiter(3, time.Minute)
|
|
|
|
for attemptIndex := 1; attemptIndex <= 3; attemptIndex++ {
|
|
if !testLimiter.Allow("192.0.2.1") {
|
|
testInstance.Fatalf("Versuch %d soll erlaubt sein", attemptIndex)
|
|
}
|
|
}
|
|
|
|
// Der vierte Versuch überschreitet das Limit.
|
|
if testLimiter.Allow("192.0.2.1") {
|
|
testInstance.Fatal("der vierte Versuch soll abgelehnt werden")
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterSeparatesSenders(testInstance *testing.T) {
|
|
testLimiter := NewRateLimiter(2, time.Minute)
|
|
|
|
testLimiter.Allow("192.0.2.1")
|
|
testLimiter.Allow("192.0.2.1")
|
|
|
|
// Ein anderer Absender darf durch fremde Versuche nicht ausgesperrt werden.
|
|
if !testLimiter.Allow("192.0.2.2") {
|
|
testInstance.Fatal("ein anderer Absender soll unabhängig gezählt werden")
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterForgetsOldAttempts(testInstance *testing.T) {
|
|
// Ein sehr kurzes Fenster macht das Ablaufen im Test beobachtbar.
|
|
testLimiter := NewRateLimiter(1, 50*time.Millisecond)
|
|
|
|
if !testLimiter.Allow("192.0.2.1") {
|
|
testInstance.Fatal("der erste Versuch soll erlaubt sein")
|
|
}
|
|
|
|
if testLimiter.Allow("192.0.2.1") {
|
|
testInstance.Fatal("der zweite Versuch soll im selben Fenster abgelehnt werden")
|
|
}
|
|
|
|
time.Sleep(60 * time.Millisecond)
|
|
|
|
if !testLimiter.Allow("192.0.2.1") {
|
|
testInstance.Fatal("nach Ablauf des Fensters soll wieder ein Versuch erlaubt sein")
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterCleanupRemovesStaleEntries(testInstance *testing.T) {
|
|
testLimiter := NewRateLimiter(5, 50*time.Millisecond)
|
|
|
|
testLimiter.Allow("192.0.2.1")
|
|
testLimiter.Allow("192.0.2.2")
|
|
|
|
time.Sleep(60 * time.Millisecond)
|
|
testLimiter.Cleanup()
|
|
|
|
testLimiter.mutex.Lock()
|
|
remainingEntries := len(testLimiter.attemptsByKey)
|
|
testLimiter.mutex.Unlock()
|
|
|
|
// Ohne Bereinigung wüchse die Karte mit jeder neuen Absenderadresse.
|
|
if remainingEntries != 0 {
|
|
testInstance.Fatalf("die Bereinigung soll abgelaufene Einträge entfernen, es blieben %d", remainingEntries)
|
|
}
|
|
}
|
|
|
|
func TestRateLimiterIsSafeForConcurrentUse(testInstance *testing.T) {
|
|
testLimiter := NewRateLimiter(1000, time.Minute)
|
|
|
|
// Der Begrenzer wird aus vielen Requests gleichzeitig aufgerufen.
|
|
var waitGroup sync.WaitGroup
|
|
for goroutineIndex := 0; goroutineIndex < 50; goroutineIndex++ {
|
|
waitGroup.Add(1)
|
|
|
|
go func() {
|
|
defer waitGroup.Done()
|
|
|
|
for callIndex := 0; callIndex < 20; callIndex++ {
|
|
testLimiter.Allow("192.0.2.1")
|
|
}
|
|
}()
|
|
}
|
|
|
|
waitGroup.Wait()
|
|
|
|
testLimiter.mutex.Lock()
|
|
recordedAttempts := len(testLimiter.attemptsByKey["192.0.2.1"])
|
|
testLimiter.mutex.Unlock()
|
|
|
|
if recordedAttempts != 1000 {
|
|
testInstance.Fatalf("es sollen 1000 Versuche gezählt sein, waren %d", recordedAttempts)
|
|
}
|
|
}
|
|
|
|
func TestAuthenticatedUserFromContextWithoutUser(testInstance *testing.T) {
|
|
testRequest := httptest.NewRequest(http.MethodGet, "/api/v1/users", nil)
|
|
|
|
// Ohne Authentifizierung darf kein Benutzer im Context liegen.
|
|
if _, isPresent := AuthenticatedUserFromContext(testRequest.Context()); isPresent {
|
|
testInstance.Fatal("ohne Anmeldung darf kein Benutzer gefunden werden")
|
|
}
|
|
}
|