From 688b277b5d848302a207f7bbfaa0b9dd5f1d121a Mon Sep 17 00:00:00 2001 From: Nick Adam Date: Thu, 27 Nov 2025 23:50:59 +0100 Subject: [PATCH] added last fixes for dev branch prepartion --- backend/acme_client.go | 141 +++++--- backend/acme_client_context.go | 57 ++++ .../providers/letsencrypt-production.json | 5 + .../config/providers/letsencrypt-staging.json | 5 + backend/main.go | 18 +- backend/openapi.yaml | 57 +++- backend/providers/acme_provider.go | 182 ++++++++++ backend/providers/letsencrypt.go | 106 ++++++ backend/renewal_info.go | 28 +- backend/renewal_queue_handlers.go | 72 +++- backend/renewal_scheduler.go | 20 +- frontend/src/pages/AuditLogs.jsx | 203 ++++++++--- frontend/src/pages/RenewalQueue.jsx | 315 +++++++++++++++--- 13 files changed, 1051 insertions(+), 158 deletions(-) create mode 100644 backend/acme_client_context.go create mode 100644 backend/config/providers/letsencrypt-production.json create mode 100644 backend/config/providers/letsencrypt-staging.json create mode 100644 backend/providers/acme_provider.go create mode 100644 backend/providers/letsencrypt.go diff --git a/backend/acme_client.go b/backend/acme_client.go index c873d1f..7883cf1 100644 --- a/backend/acme_client.go +++ b/backend/acme_client.go @@ -21,12 +21,50 @@ import ( "time" ) -const ( - // Let's Encrypt Staging für Tests - acmeStagingDirectory = "https://acme-staging-v02.api.letsencrypt.org/directory" - acmeStagingNewAccount = "https://acme-staging-v02.api.letsencrypt.org/acme/new-acct" - acmeStagingNewOrder = "https://acme-staging-v02.api.letsencrypt.org/acme/new-order" -) +// ACMEDirectory enthält die Endpunkte eines ACME-Servers +type ACMEDirectory struct { + NewNonce string `json:"newNonce"` + NewAccount string `json:"newAccount"` + NewOrder string `json:"newOrder"` + RevokeCert string `json:"revokeCert"` + KeyChange string `json:"keyChange"` + Meta struct { + TermsOfService string `json:"termsOfService"` + Website string `json:"website"` + CaaIdentities []string `json:"caaIdentities"` + ExternalAccountRequired bool `json:"externalAccountRequired"` + } `json:"meta"` +} + +// getACMEDirectory ruft die Directory-Endpunkte von einem ACME-Server ab +func getACMEDirectory(directoryURL string) (*ACMEDirectory, error) { + client := &http.Client{ + Timeout: 30 * time.Second, + } + + resp, err := client.Get(directoryURL) + if err != nil { + return nil, fmt.Errorf("fehler beim Abrufen der ACME Directory: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("fehler beim Abrufen der ACME Directory (Status %d): %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("fehler beim Lesen der Directory-Response: %v", err) + } + + var directory ACMEDirectory + if err := json.Unmarshal(body, &directory); err != nil { + return nil, fmt.Errorf("fehler beim Parsen der Directory-Response: %v", err) + } + + return &directory, nil +} // ACMEKeyPair enthält Private und Public Key type ACMEKeyPair struct { @@ -114,10 +152,9 @@ func loadOrCreateKeyPair(fqdnID string, keyDir string) (*ACMEKeyPair, error) { } // getNonce ruft einen neuen Nonce vom ACME-Server ab -func getNonce() (string, error) { - +func getNonce(directoryURL string) (string, error) { // Rufe Directory-Endpoint auf, um einen Nonce zu bekommen - req, err := http.NewRequest("HEAD", acmeStagingDirectory, nil) + req, err := http.NewRequest("HEAD", directoryURL, nil) if err != nil { return "", fmt.Errorf("fehler beim Erstellen des HEAD-Requests: %v", err) } @@ -338,12 +375,12 @@ func calculateKeyAuthHash(token string, pubKey *rsa.PublicKey) (string, error) { return txtValue, nil } -// createAccount erstellt einen neuen Account bei Let's Encrypt -func createAccount(keyPair *ACMEKeyPair, email string, traceID, fqdnID string, statusCallback func(status string)) (string, error) { - statusCallback("Erstelle Account bei Let's Encrypt...") +// createAccount erstellt einen neuen Account bei einem ACME-Server +func createAccount(keyPair *ACMEKeyPair, directoryURL, newAccountURL string, email string, traceID, fqdnID string, statusCallback func(status string)) (string, error) { + statusCallback("Erstelle Account bei ACME-Server...") // Hole Nonce vom Server - nonce, err := getNonce() + nonce, err := getNonce(directoryURL) if err != nil { return "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) } @@ -355,13 +392,13 @@ func createAccount(keyPair *ACMEKeyPair, email string, traceID, fqdnID string, s } // Erstelle JWS (ohne KeyID, da es ein neuer Account ist) - jws, err := createJWS(keyPair, payload, acmeStagingNewAccount, "", nonce) + jws, err := createJWS(keyPair, payload, newAccountURL, "", nonce) if err != nil { return "", fmt.Errorf("fehler beim Erstellen des JWS: %v", err) } // Sende Request - req, err := http.NewRequest("POST", acmeStagingNewAccount, bytes.NewBufferString(jws)) + req, err := http.NewRequest("POST", newAccountURL, bytes.NewBufferString(jws)) if err != nil { return "", fmt.Errorf("fehler beim Erstellen des HTTP-Requests: %v", err) } @@ -398,12 +435,12 @@ func createAccount(keyPair *ACMEKeyPair, email string, traceID, fqdnID string, s return keyID, nil } -// createOrder erstellt eine neue Order bei Let's Encrypt -func createOrder(keyPair *ACMEKeyPair, keyID string, domains []string, traceID, fqdnID string, statusCallback func(status string)) (string, map[string]interface{}, error) { - statusCallback("Erstelle Order bei Let's Encrypt...") +// createOrder erstellt eine neue Order bei einem ACME-Server +func createOrder(keyPair *ACMEKeyPair, directoryURL, newOrderURL string, keyID string, domains []string, traceID, fqdnID string, statusCallback func(status string)) (string, map[string]interface{}, error) { + statusCallback("Erstelle Order bei ACME-Server...") // Hole Nonce vom Server - nonce, err := getNonce() + nonce, err := getNonce(directoryURL) if err != nil { return "", nil, fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) } @@ -420,13 +457,13 @@ func createOrder(keyPair *ACMEKeyPair, keyID string, domains []string, traceID, } // Erstelle JWS (mit KeyID) - jws, err := createJWS(keyPair, payload, acmeStagingNewOrder, keyID, nonce) + jws, err := createJWS(keyPair, payload, newOrderURL, keyID, nonce) if err != nil { return "", nil, fmt.Errorf("fehler beim Erstellen des JWS: %v", err) } // Sende Request - req, err := http.NewRequest("POST", acmeStagingNewOrder, bytes.NewBufferString(jws)) + req, err := http.NewRequest("POST", newOrderURL, bytes.NewBufferString(jws)) if err != nil { return "", nil, fmt.Errorf("fehler beim Erstellen des HTTP-Requests: %v", err) } @@ -468,10 +505,10 @@ func createOrder(keyPair *ACMEKeyPair, keyID string, domains []string, traceID, return orderURL, orderResponse, nil } -// RequestCertificate beantragt ein Zertifikat von Let's Encrypt (manuell ohne LEGO) +// RequestCertificate beantragt ein Zertifikat von einem ACME-Server // cleanupTokenFunc wird aufgerufen, wenn die Challenge invalid ist, um den Token zu bereinigen // traceID wird vom Aufrufer übergeben, um die gleiche TRACE_ID über den gesamten Prozess zu verwenden -func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID string, traceID string, updateTokenFunc func(token string) error, cleanupTokenFunc func() error, statusCallback func(status string)) (*CertificateRequestResult, error) { +func RequestCertificate(ctx *ACMEClientContext, fqdn string, email string, fqdnID string, existingKeyID string, traceID string, updateTokenFunc func(token string) error, cleanupTokenFunc func() error, statusCallback func(status string)) (*CertificateRequestResult, error) { log.Printf("[ACME] ===== REQUEST CERTIFICATE START =====") log.Printf("[ACME] FQDN: %s", fqdn) @@ -503,14 +540,14 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID result.Status = append(result.Status, "Key-Paar erfolgreich geladen/erstellt") statusCallback("Key-Paar erfolgreich geladen/erstellt") - // Schritt 2: Erstelle Account bei Let's Encrypt (falls noch nicht vorhanden) + // Schritt 2: Erstelle Account beim ACME-Server (falls noch nicht vorhanden) log.Printf("[ACME] Schritt 2: Erstelle/Verwende Account...") var keyID string if existingKeyID == "" { log.Printf("[ACME] Keine KeyID vorhanden, erstelle neuen Account...") - statusCallback("Erstelle Account bei Let's Encrypt...") + statusCallback(fmt.Sprintf("Erstelle Account bei %s...", ctx.Provider.GetDisplayName())) result.StepStatus["ACCOUNT_ERSTELLUNG"] = "loading" - keyID, err = createAccount(keyPair, email, traceID, fqdnID, statusCallback) + keyID, err = createAccount(keyPair, ctx.DirectoryURL, ctx.NewAccountURL, email, traceID, fqdnID, statusCallback) if err != nil { log.Printf("[ACME] FEHLER bei Schritt 2 (Account-Erstellung): %v", err) logCertStatus(traceID, fqdnID, "ACCOUNT_ERSTELLUNG", "FAILED", err.Error()) @@ -531,16 +568,16 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID statusCallback(fmt.Sprintf("Verwende existierenden Account (KeyID: %s)", keyID)) } - // Schritt 3: Erstelle Order bei Let's Encrypt + // Schritt 3: Erstelle Order beim ACME-Server baseFqdn := fqdn if strings.HasPrefix(baseFqdn, "*.") { baseFqdn = baseFqdn[2:] } log.Printf("[ACME] Schritt 3: Erstelle Order für Domain: %s", baseFqdn) - statusCallback("Erstelle Order bei Let's Encrypt...") + statusCallback(fmt.Sprintf("Erstelle Order bei %s...", ctx.Provider.GetDisplayName())) result.StepStatus["ORDER_ERSTELLUNG"] = "loading" - orderURL, orderResponse, err := createOrder(keyPair, keyID, []string{baseFqdn}, traceID, fqdnID, statusCallback) + orderURL, orderResponse, err := createOrder(keyPair, ctx.DirectoryURL, ctx.NewOrderURL, keyID, []string{baseFqdn}, traceID, fqdnID, statusCallback) if err != nil { log.Printf("[ACME] FEHLER bei Schritt 3 (Order-Erstellung): %v", err) logCertStatus(traceID, fqdnID, "ORDER_ERSTELLUNG", "FAILED", err.Error()) @@ -557,7 +594,7 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID if orderResponse != nil { statusCallback("Extrahiere Challenge-Token...") - token, err := extractTokenFromOrder(keyPair, keyID, orderResponse, baseFqdn) + token, err := extractTokenFromOrder(ctx, keyPair, keyID, orderResponse, baseFqdn) if err != nil { logCertStatus(traceID, fqdnID, "TOKEN_EXTRAKTION", "FAILED", err.Error()) return nil, fmt.Errorf("fehler beim Extrahieren des Tokens: %v", err) @@ -588,14 +625,14 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID // Schritt 5: Aktiviere Challenge bei Let's Encrypt statusCallback("Aktiviere Challenge bei Let's Encrypt...") - challengeURL, err := extractChallengeURLFromOrder(keyPair, keyID, orderResponse, baseFqdn) + challengeURL, err := extractChallengeURLFromOrder(ctx, keyPair, keyID, orderResponse, baseFqdn) if err != nil { logCertStatus(traceID, fqdnID, "CHALLENGE_URL_EXTRAKTION", "FAILED", err.Error()) return nil, fmt.Errorf("fehler beim Extrahieren der Challenge-URL: %v", err) } result.StepStatus["CHALLENGE_AKTIVIERUNG"] = "loading" - if err := activateChallenge(keyPair, keyID, challengeURL, traceID, fqdnID); err != nil { + if err := activateChallenge(ctx, keyPair, keyID, challengeURL, traceID, fqdnID); err != nil { logCertStatus(traceID, fqdnID, "CHALLENGE_AKTIVIERUNG", "FAILED", err.Error()) result.StepStatus["CHALLENGE_AKTIVIERUNG"] = "error" return nil, fmt.Errorf("fehler beim Aktivieren der Challenge: %v", err) @@ -608,7 +645,7 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID // Schritt 6: Warte auf Challenge-Validierung (Polling) statusCallback("Warte auf Challenge-Validierung...") - if err := waitForChallengeValidation(keyPair, keyID, challengeURL, traceID, fqdnID, statusCallback, cleanupTokenFunc); err != nil { + if err := waitForChallengeValidation(ctx, keyPair, keyID, challengeURL, traceID, fqdnID, statusCallback, cleanupTokenFunc); err != nil { // Cleanup wurde bereits in waitForChallengeValidation durchgeführt // Bereinige auch den Token aus der Datenbank if cleanupTokenFunc != nil { @@ -627,7 +664,7 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID statusCallback("Finalisiere Order und hole Zertifikat...") result.StepStatus["ZERTIFIKAT_ERSTELLUNG"] = "loading" - certPEM, keyPEM, err := finalizeOrderAndGetCertificate(keyPair, keyID, orderURL, orderResponse, traceID, fqdnID, statusCallback) + certPEM, keyPEM, err := finalizeOrderAndGetCertificate(ctx, keyPair, keyID, orderURL, orderResponse, traceID, fqdnID, statusCallback) if err != nil { logCertStatus(traceID, fqdnID, "ZERTIFIKAT_ERSTELLUNG", "FAILED", err.Error()) result.StepStatus["ZERTIFIKAT_ERSTELLUNG"] = "error" @@ -648,7 +685,7 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID } // extractTokenFromOrder extrahiert den Challenge-Token aus der Order-Response -func extractTokenFromOrder(keyPair *ACMEKeyPair, keyID string, orderResponse map[string]interface{}, domain string) (string, error) { +func extractTokenFromOrder(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, orderResponse map[string]interface{}, domain string) (string, error) { // Extrahiere authorizations Array authorizations, ok := orderResponse["authorizations"].([]interface{}) @@ -661,7 +698,7 @@ func extractTokenFromOrder(keyPair *ACMEKeyPair, keyID string, orderResponse map } // Hole Nonce für Authorization-Request - nonce, err := getNonce() + nonce, err := getNonce(ctx.DirectoryURL) if err != nil { return "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) } @@ -753,7 +790,7 @@ func extractTokenFromOrder(keyPair *ACMEKeyPair, keyID string, orderResponse map } // extractChallengeURLFromOrder extrahiert die Challenge-URL aus der Order-Response -func extractChallengeURLFromOrder(keyPair *ACMEKeyPair, keyID string, orderResponse map[string]interface{}, domain string) (string, error) { +func extractChallengeURLFromOrder(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, orderResponse map[string]interface{}, domain string) (string, error) { // Extrahiere authorizations Array authorizations, ok := orderResponse["authorizations"].([]interface{}) @@ -766,7 +803,7 @@ func extractChallengeURLFromOrder(keyPair *ACMEKeyPair, keyID string, orderRespo } // Hole Nonce für Authorization-Request - nonce, err := getNonce() + nonce, err := getNonce(ctx.DirectoryURL) if err != nil { return "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) } @@ -855,10 +892,10 @@ func extractChallengeURLFromOrder(keyPair *ACMEKeyPair, keyID string, orderRespo return "", fmt.Errorf("keine DNS-01 Challenge URL in Authorizations gefunden") } -// activateChallenge aktiviert eine Challenge bei Let's Encrypt -func activateChallenge(keyPair *ACMEKeyPair, keyID string, challengeURL string, traceID, fqdnID string) error { +// activateChallenge aktiviert eine Challenge beim ACME-Server +func activateChallenge(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, challengeURL string, traceID, fqdnID string) error { // Hole Nonce - nonce, err := getNonce() + nonce, err := getNonce(ctx.DirectoryURL) if err != nil { return fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) } @@ -901,10 +938,10 @@ func activateChallenge(keyPair *ACMEKeyPair, keyID string, challengeURL string, } // cleanupChallenge führt einen Cleanup-Prozess für eine Challenge durch -func cleanupChallenge(keyPair *ACMEKeyPair, keyID string, challengeURL string) error { +func cleanupChallenge(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, challengeURL string) error { // Hole Nonce - nonce, err := getNonce() + nonce, err := getNonce(ctx.DirectoryURL) if err != nil { return fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) } @@ -944,7 +981,7 @@ func cleanupChallenge(keyPair *ACMEKeyPair, keyID string, challengeURL string) e } // waitForChallengeValidation wartet auf die Validierung der Challenge (Polling) -func waitForChallengeValidation(keyPair *ACMEKeyPair, keyID string, challengeURL string, traceID, fqdnID string, statusCallback func(status string), cleanupTokenFunc func() error) error { +func waitForChallengeValidation(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, challengeURL string, traceID, fqdnID string, statusCallback func(status string), cleanupTokenFunc func() error) error { maxAttempts := 30 // Maximal 30 Versuche pollInterval := 2 * time.Second // Alle 2 Sekunden prüfen @@ -952,7 +989,7 @@ func waitForChallengeValidation(keyPair *ACMEKeyPair, keyID string, challengeURL statusCallback(fmt.Sprintf("Prüfe Challenge-Status (%d/%d)...", attempt, maxAttempts)) // Hole Nonce - nonce, err := getNonce() + nonce, err := getNonce(ctx.DirectoryURL) if err != nil { return fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) } @@ -1047,7 +1084,7 @@ func waitForChallengeValidation(keyPair *ACMEKeyPair, keyID string, challengeURL } // waitForOrderReady wartet, bis die Order den Status "ready" hat (nach Challenge-Validierung) -func waitForOrderReady(keyPair *ACMEKeyPair, keyID string, orderURL string, traceID, fqdnID string, statusCallback func(status string)) error { +func waitForOrderReady(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, orderURL string, traceID, fqdnID string, statusCallback func(status string)) error { maxAttempts := 30 // Maximal 30 Versuche pollInterval := 2 * time.Second // Alle 2 Sekunden prüfen maxConsecutiveErrors := 3 // Maximal 3 aufeinanderfolgende Fehler @@ -1057,7 +1094,7 @@ func waitForOrderReady(keyPair *ACMEKeyPair, keyID string, orderURL string, trac statusCallback(fmt.Sprintf("Prüfe Order-Status (%d/%d)...", attempt, maxAttempts)) // Hole Nonce - nonce, err := getNonce() + nonce, err := getNonce(ctx.DirectoryURL) if err != nil { log.Printf("[ACME] Fehler beim Abrufen des Nonce: %v", err) consecutiveErrors++ @@ -1184,11 +1221,11 @@ func waitForOrderReady(keyPair *ACMEKeyPair, keyID string, orderURL string, trac } // finalizeOrderAndGetCertificate finalisiert die Order und holt das Zertifikat -func finalizeOrderAndGetCertificate(keyPair *ACMEKeyPair, keyID string, orderURL string, orderResponse map[string]interface{}, traceID, fqdnID string, statusCallback func(status string)) (string, string, error) { +func finalizeOrderAndGetCertificate(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, orderURL string, orderResponse map[string]interface{}, traceID, fqdnID string, statusCallback func(status string)) (string, string, error) { // Warte, bis die Order bereit ist (Status "ready" oder "valid") statusCallback("Warte auf Order-Bereitschaft...") - if err := waitForOrderReady(keyPair, keyID, orderURL, traceID, fqdnID, statusCallback); err != nil { + if err := waitForOrderReady(ctx, keyPair, keyID, orderURL, traceID, fqdnID, statusCallback); err != nil { return "", "", fmt.Errorf("fehler beim Warten auf Order-Bereitschaft: %v", err) } @@ -1235,7 +1272,7 @@ func finalizeOrderAndGetCertificate(keyPair *ACMEKeyPair, keyID string, orderURL } // Hole Nonce - nonce, err := getNonce() + nonce, err := getNonce(ctx.DirectoryURL) if err != nil { return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) } @@ -1291,7 +1328,7 @@ func finalizeOrderAndGetCertificate(keyPair *ACMEKeyPair, keyID string, orderURL for attempt := 1; attempt <= maxAttempts; attempt++ { statusCallback(fmt.Sprintf("Prüfe Order-Status (%d/%d)...", attempt, maxAttempts)) - nonce, err := getNonce() + nonce, err := getNonce(ctx.DirectoryURL) if err != nil { return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) } @@ -1349,7 +1386,7 @@ func finalizeOrderAndGetCertificate(keyPair *ACMEKeyPair, keyID string, orderURL statusCallback("Hole Zertifikat...") // Hole Zertifikat - nonce, err = getNonce() + nonce, err = getNonce(ctx.DirectoryURL) if err != nil { return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) } diff --git a/backend/acme_client_context.go b/backend/acme_client_context.go new file mode 100644 index 0000000..d22b44f --- /dev/null +++ b/backend/acme_client_context.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "certigo-addon-backend/providers" +) + +// ACMEClientContext enthält den Kontext für ACME-Operationen +type ACMEClientContext struct { + Provider providers.ACMEProvider + Directory *ACMEDirectory + DirectoryURL string + NewAccountURL string + NewOrderURL string + NewNonceURL string +} + +// NewACMEClientContext erstellt einen neuen ACME-Client-Kontext +func NewACMEClientContext(providerID string) (*ACMEClientContext, error) { + acmeManager := providers.GetACMEManager() + provider, exists := acmeManager.GetACMEProvider(providerID) + if !exists { + return nil, fmt.Errorf("ACME-Provider '%s' nicht gefunden", providerID) + } + + config, err := acmeManager.GetACMEProviderConfig(providerID) + if err != nil { + return nil, fmt.Errorf("fehler beim Laden der Provider-Konfiguration: %v", err) + } + + if !config.Enabled { + return nil, fmt.Errorf("ACME-Provider '%s' ist nicht aktiviert", providerID) + } + + // Validiere Konfiguration + if err := provider.ValidateConfig(config.Settings); err != nil { + return nil, fmt.Errorf("ungültige Provider-Konfiguration: %v", err) + } + + directoryURL := provider.GetDirectoryURL() + + // Hole Directory-Endpunkte + directory, err := getACMEDirectory(directoryURL) + if err != nil { + return nil, fmt.Errorf("fehler beim Abrufen der ACME Directory: %v", err) + } + + return &ACMEClientContext{ + Provider: provider, + Directory: directory, + DirectoryURL: directoryURL, + NewAccountURL: directory.NewAccount, + NewOrderURL: directory.NewOrder, + NewNonceURL: directory.NewNonce, + }, nil +} + diff --git a/backend/config/providers/letsencrypt-production.json b/backend/config/providers/letsencrypt-production.json new file mode 100644 index 0000000..c3db584 --- /dev/null +++ b/backend/config/providers/letsencrypt-production.json @@ -0,0 +1,5 @@ +{ + "enabled": true, + "settings": {} +} + diff --git a/backend/config/providers/letsencrypt-staging.json b/backend/config/providers/letsencrypt-staging.json new file mode 100644 index 0000000..c3db584 --- /dev/null +++ b/backend/config/providers/letsencrypt-staging.json @@ -0,0 +1,5 @@ +{ + "enabled": true, + "settings": {} +} + diff --git a/backend/main.go b/backend/main.go index a094dd0..fb7dbee 100644 --- a/backend/main.go +++ b/backend/main.go @@ -2241,8 +2241,18 @@ func requestCertificateHandler(w http.ResponseWriter, r *http.Request) { log.Printf("[STATUS] %s", status) } + // Erstelle ACME-Client-Kontext + // Standardmäßig verwenden wir Let's Encrypt Staging, aber in Zukunft könnte dies aus der FQDN-Konfiguration kommen + acmeProviderIDStr := "letsencrypt-staging" // TODO: Aus FQDN-Konfiguration lesen + acmeCtx, err := NewACMEClientContext(acmeProviderIDStr) + if err != nil { + log.Printf("FEHLER beim Erstellen des ACME-Client-Kontexts: %v", err) + http.Error(w, fmt.Sprintf("Fehler beim Initialisieren des ACME-Providers: %v", err), http.StatusInternalServerError) + return + } + log.Printf("Rufe RequestCertificate auf...") - result, err := RequestCertificate(baseFqdn, fqdn.AcmeEmail, fqdnID, fqdn.AcmeKeyID, traceID, updateTokenFunc, cleanupTokenFunc, statusCallback) + result, err := RequestCertificate(acmeCtx, baseFqdn, fqdn.AcmeEmail, fqdnID, fqdn.AcmeKeyID, traceID, updateTokenFunc, cleanupTokenFunc, statusCallback) if err != nil { logCertStatus(traceID, fqdnID, "ZERTIFIKATSANFRAGE_GESAMT", "FAILED", err.Error()) stepStatus["ZERTIFIKATSANFRAGE_GESAMT"] = "error" @@ -5592,6 +5602,11 @@ func main() { pm.RegisterProvider(providers.NewAutoDNSProvider()) pm.RegisterProvider(providers.NewHetznerProvider()) pm.RegisterProvider(providers.NewCertigoACMEProxyProvider()) + + // Initialisiere ACME-Provider + acmeManager := providers.GetACMEManager() + acmeManager.RegisterACMEProvider(providers.NewLetsEncryptProvider("production")) + acmeManager.RegisterACMEProvider(providers.NewLetsEncryptProvider("staging")) // Starte Renewal Scheduler StartRenewalScheduler() @@ -5655,6 +5670,7 @@ func main() { // Renewal Queue Routes api.HandleFunc("/renewal-queue", basicAuthMiddleware(getRenewalQueueHandler)).Methods("GET", "OPTIONS") + api.HandleFunc("/renewal-queue", basicAuthMiddleware(deleteAllRenewalQueueEntriesHandler)).Methods("DELETE", "OPTIONS") // Renewal Queue Test Routes (nur für Administratoren) api.HandleFunc("/renewal-queue/test/create", basicAuthMiddleware(createTestRenewalQueueEntryHandler)).Methods("POST", "OPTIONS") diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 64f17c2..4d57b73 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -10,6 +10,9 @@ servers: - url: http://localhost:8080/api description: Local development server +security: + - basicAuth: [] + paths: /health: get: @@ -561,9 +564,52 @@ paths: content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/RenewalQueueEntry' + type: object + properties: + success: + type: boolean + example: true + queue: + type: array + items: + $ref: '#/components/schemas/RenewalQueueEntry' + '401': + description: Nicht authentifiziert + + delete: + summary: Alle Renewal Queue Einträge löschen + description: Löscht alle Einträge aus der Renewal Queue. Erfordert confirm=true Query-Parameter. + tags: + - Renewal Queue + parameters: + - name: confirm + in: query + required: true + schema: + type: string + description: Muss "true" sein, um die Operation auszuführen + example: "true" + responses: + '200': + description: Alle Renewal Queue Einträge erfolgreich gelöscht + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: "Alle Renewal Queue-Einträge erfolgreich gelöscht" + deletedCount: + type: integer + example: 42 + '400': + description: Bestätigung erforderlich + '401': + description: Nicht authentifiziert /renewal-queue/test/create: post: @@ -1766,6 +1812,7 @@ components: example: 5 securitySchemes: - {}: + basicAuth: type: http - scheme: none + scheme: basic + description: Basic HTTP Authentication diff --git a/backend/providers/acme_provider.go b/backend/providers/acme_provider.go new file mode 100644 index 0000000..73ee398 --- /dev/null +++ b/backend/providers/acme_provider.go @@ -0,0 +1,182 @@ +package providers + +import ( + "encoding/json" + "os" + "path/filepath" + "sync" +) + +// ACMEProvider Interface für ACME-basierte Certificate Authorities +type ACMEProvider interface { + // GetName gibt den Namen des ACME-Providers zurück + GetName() string + // GetDisplayName gibt den Anzeigenamen zurück + GetDisplayName() string + // GetDescription gibt eine Beschreibung zurück + GetDescription() string + // GetDirectoryURL gibt die ACME Directory URL zurück + GetDirectoryURL() string + // GetRenewalInfoURL gibt die RenewalInfo API URL zurück (optional) + GetRenewalInfoURL() string + // ValidateConfig validiert die Konfiguration + ValidateConfig(settings map[string]interface{}) error + // TestConnection testet die Verbindung zum ACME-Server + TestConnection(settings map[string]interface{}) error + // GetRequiredSettings gibt die erforderlichen Einstellungen zurück + GetRequiredSettings() []SettingField +} + +// ACMEProviderConfig enthält die Konfiguration eines ACME-Providers +type ACMEProviderConfig struct { + Enabled bool `json:"enabled"` + Settings map[string]interface{} `json:"settings"` +} + +// ACMEProviderManager verwaltet alle ACME-Provider +type ACMEProviderManager struct { + providers map[string]ACMEProvider + configs map[string]*ACMEProviderConfig + configDir string + mu sync.RWMutex +} + +var acmeManager *ACMEProviderManager +var acmeOnce sync.Once + +// GetACMEManager gibt die Singleton-Instanz des ACMEProviderManagers zurück +func GetACMEManager() *ACMEProviderManager { + acmeOnce.Do(func() { + acmeManager = &ACMEProviderManager{ + providers: make(map[string]ACMEProvider), + configs: make(map[string]*ACMEProviderConfig), + configDir: "./config/providers", + } + acmeManager.loadAllConfigs() + }) + return acmeManager +} + +// RegisterACMEProvider registriert einen neuen ACME-Provider +func (pm *ACMEProviderManager) RegisterACMEProvider(provider ACMEProvider) { + pm.mu.Lock() + defer pm.mu.Unlock() + + providerID := provider.GetName() + pm.providers[providerID] = provider + + // Lade Konfiguration falls vorhanden + if pm.configs[providerID] == nil { + pm.configs[providerID] = &ACMEProviderConfig{ + Enabled: false, + Settings: make(map[string]interface{}), + } + } +} + +// GetACMEProvider gibt einen ACME-Provider zurück +func (pm *ACMEProviderManager) GetACMEProvider(id string) (ACMEProvider, bool) { + pm.mu.RLock() + defer pm.mu.RUnlock() + provider, exists := pm.providers[id] + return provider, exists +} + +// GetAllACMEProviders gibt alle registrierten ACME-Provider zurück +func (pm *ACMEProviderManager) GetAllACMEProviders() map[string]ACMEProvider { + pm.mu.RLock() + defer pm.mu.RUnlock() + result := make(map[string]ACMEProvider) + for id, provider := range pm.providers { + result[id] = provider + } + return result +} + +// GetACMEProviderConfig gibt die Konfiguration eines ACME-Providers zurück +func (pm *ACMEProviderManager) GetACMEProviderConfig(id string) (*ACMEProviderConfig, error) { + pm.mu.RLock() + defer pm.mu.RUnlock() + + config, exists := pm.configs[id] + if !exists { + return &ACMEProviderConfig{ + Enabled: false, + Settings: make(map[string]interface{}), + }, nil + } + return config, nil +} + +// SetACMEProviderEnabled aktiviert/deaktiviert einen ACME-Provider +func (pm *ACMEProviderManager) SetACMEProviderEnabled(id string, enabled bool) error { + pm.mu.Lock() + defer pm.mu.Unlock() + + if pm.configs[id] == nil { + pm.configs[id] = &ACMEProviderConfig{ + Enabled: enabled, + Settings: make(map[string]interface{}), + } + } else { + pm.configs[id].Enabled = enabled + } + + return pm.saveConfig(id, pm.configs[id]) +} + +// loadAllConfigs lädt alle Konfigurationsdateien +func (pm *ACMEProviderManager) loadAllConfigs() { + // Stelle sicher, dass das Verzeichnis existiert + os.MkdirAll(pm.configDir, 0755) + + // Lade alle JSON-Dateien im Konfigurationsverzeichnis + files, err := filepath.Glob(filepath.Join(pm.configDir, "*.json")) + if err != nil { + return + } + + for _, file := range files { + id := filepath.Base(file[:len(file)-5]) // Entferne .json + // Nur ACME-Provider-Konfigurationen laden (beginnen mit "letsencrypt") + if id == "letsencrypt-production" || id == "letsencrypt-staging" { + config, err := pm.loadConfig(id) + if err == nil { + pm.configs[id] = config + } + } + } +} + +// loadConfig lädt eine Konfigurationsdatei +func (pm *ACMEProviderManager) loadConfig(id string) (*ACMEProviderConfig, error) { + filePath := filepath.Join(pm.configDir, id+".json") + + data, err := os.ReadFile(filePath) + if err != nil { + return nil, err + } + + var config ACMEProviderConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, err + } + + return &config, nil +} + +// saveConfig speichert eine Konfiguration in eine Datei +func (pm *ACMEProviderManager) saveConfig(id string, config *ACMEProviderConfig) error { + // Stelle sicher, dass das Verzeichnis existiert + os.MkdirAll(pm.configDir, 0755) + + filePath := filepath.Join(pm.configDir, id+".json") + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + + return os.WriteFile(filePath, data, 0644) +} + diff --git a/backend/providers/letsencrypt.go b/backend/providers/letsencrypt.go new file mode 100644 index 0000000..2104579 --- /dev/null +++ b/backend/providers/letsencrypt.go @@ -0,0 +1,106 @@ +package providers + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// LetsEncryptProvider ist der Provider für Let's Encrypt +type LetsEncryptProvider struct { + environment string // "production" oder "staging" +} + +// NewLetsEncryptProvider erstellt einen neuen Let's Encrypt Provider +func NewLetsEncryptProvider(environment string) *LetsEncryptProvider { + if environment != "staging" && environment != "production" { + environment = "production" + } + return &LetsEncryptProvider{ + environment: environment, + } +} + +func (p *LetsEncryptProvider) GetName() string { + if p.environment == "staging" { + return "letsencrypt-staging" + } + return "letsencrypt-production" +} + +func (p *LetsEncryptProvider) GetDisplayName() string { + if p.environment == "staging" { + return "Let's Encrypt (Staging)" + } + return "Let's Encrypt (Production)" +} + +func (p *LetsEncryptProvider) GetDescription() string { + if p.environment == "staging" { + return "Let's Encrypt Staging Environment für Tests" + } + return "Let's Encrypt Production Certificate Authority" +} + +func (p *LetsEncryptProvider) GetDirectoryURL() string { + if p.environment == "staging" { + return "https://acme-staging-v02.api.letsencrypt.org/directory" + } + return "https://acme-v02.api.letsencrypt.org/directory" +} + +func (p *LetsEncryptProvider) GetRenewalInfoURL() string { + if p.environment == "staging" { + return "https://acme-staging-v02.api.letsencrypt.org/acme/renewal-info" + } + return "https://acme-v02.api.letsencrypt.org/acme/renewal-info" +} + +func (p *LetsEncryptProvider) ValidateConfig(settings map[string]interface{}) error { + // Let's Encrypt benötigt keine zusätzliche Konfiguration + // Die Directory URL wird automatisch basierend auf der Environment gesetzt + return nil +} + +func (p *LetsEncryptProvider) TestConnection(settings map[string]interface{}) error { + // Teste Verbindung zum ACME Directory + directoryURL := p.GetDirectoryURL() + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Get(directoryURL) + if err != nil { + return fmt.Errorf("ACME Directory nicht erreichbar: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("ACME Directory antwortet mit Status %d: %s", resp.StatusCode, string(body)) + } + + // Prüfe ob es ein gültiges ACME Directory ist + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("fehler beim Lesen der Directory-Response: %v", err) + } + + // Einfache Validierung: Prüfe ob "newAccount" oder "newNonce" im Body enthalten ist + bodyStr := string(body) + if !strings.Contains(bodyStr, "newAccount") && !strings.Contains(bodyStr, "newNonce") { + return fmt.Errorf("ungültige ACME Directory Response") + } + + return nil +} + +func (p *LetsEncryptProvider) GetRequiredSettings() []SettingField { + // Let's Encrypt benötigt keine zusätzlichen Einstellungen + // Die Directory URL wird automatisch basierend auf der Environment gesetzt + return []SettingField{} +} + diff --git a/backend/renewal_info.go b/backend/renewal_info.go index 8dfc7f2..a61b3ca 100644 --- a/backend/renewal_info.go +++ b/backend/renewal_info.go @@ -13,6 +13,8 @@ import ( "strings" "time" + "certigo-addon-backend/providers" + "github.com/google/uuid" ) @@ -60,10 +62,23 @@ func CalculateCertID(certPEM string) (string, error) { return certID, nil } -// FetchRenewalInfo ruft die RenewalInfo von Let's Encrypt ab -func FetchRenewalInfo(certID string) (*RenewalInfoResponse, error) { - // Let's Encrypt RenewalInfo API URL (Staging) - url := fmt.Sprintf("https://acme-staging-v02.api.letsencrypt.org/acme/renewal-info/%s", certID) +// FetchRenewalInfo ruft die RenewalInfo von einem ACME-Server ab +func FetchRenewalInfo(providerID string, certID string) (*RenewalInfoResponse, error) { + // Hole ACME-Provider + acmeManager := providers.GetACMEManager() + provider, exists := acmeManager.GetACMEProvider(providerID) + if !exists { + return nil, fmt.Errorf("ACME-Provider '%s' nicht gefunden", providerID) + } + + // Hole RenewalInfo URL vom Provider + renewalInfoURL := provider.GetRenewalInfoURL() + if renewalInfoURL == "" { + return nil, fmt.Errorf("ACME-Provider '%s' unterstützt keine RenewalInfo API", providerID) + } + + // Erstelle URL mit CertID + url := fmt.Sprintf("%s/%s", renewalInfoURL, certID) // HTTP Request client := &http.Client{ @@ -143,7 +158,10 @@ func ProcessRenewalInfoForCertificate(certPEM string, certID string, fqdnID stri } // Rufe RenewalInfo ab - renewalInfo, err := FetchRenewalInfo(certIDBase64) + // TODO: ACME-Provider-ID aus Zertifikat/FQDN-Konfiguration holen + // Standardmäßig verwenden wir Let's Encrypt Staging + acmeProviderID := "letsencrypt-staging" + renewalInfo, err := FetchRenewalInfo(acmeProviderID, certIDBase64) if err != nil { // Prüfe ob es ein Staging-Zertifikat ist (RenewalInfo nicht verfügbar) if strings.Contains(err.Error(), "Staging-Zertifikate") { diff --git a/backend/renewal_queue_handlers.go b/backend/renewal_queue_handlers.go index 137ab84..cbbb6ce 100644 --- a/backend/renewal_queue_handlers.go +++ b/backend/renewal_queue_handlers.go @@ -101,15 +101,16 @@ func updateFqdnRenewalEnabledHandler(w http.ResponseWriter, r *http.Request) { return } - // Wenn renewal_enabled deaktiviert wird, lösche alle Queue-Einträge für diesen FQDN + // Wenn renewal_enabled deaktiviert wird, lösche nur pending/processing Queue-Einträge für diesen FQDN + // Completed und failed Einträge bleiben als Historie erhalten if !req.RenewalEnabled { - _, err = tx.ExecContext(ctx, "DELETE FROM renewal_queue WHERE fqdn_id = ?", fqdnID) + _, err = tx.ExecContext(ctx, "DELETE FROM renewal_queue WHERE fqdn_id = ? AND status IN ('pending', 'processing')", fqdnID) if err != nil { http.Error(w, "Fehler beim Löschen der Queue-Einträge", http.StatusInternalServerError) log.Printf("Fehler beim Löschen der Queue-Einträge: %v", err) return } - log.Printf("Queue-Einträge für FQDN %s gelöscht (renewal_enabled deaktiviert)", fqdnID) + log.Printf("Pending/Processing Queue-Einträge für FQDN %s gelöscht (renewal_enabled deaktiviert)", fqdnID) } // Committe die Transaktion @@ -265,3 +266,68 @@ func getRenewalQueueHandler(w http.ResponseWriter, r *http.Request) { }) } +// deleteAllRenewalQueueEntriesHandler löscht alle Einträge aus der Renewal Queue +func deleteAllRenewalQueueEntriesHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Prüfe Berechtigung - nur authentifizierte User + userID, username := getUserFromRequest(r) + if userID == "" { + http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized) + return + } + + // Prüfe Bestätigung (optional, aber empfohlen) + confirm := r.URL.Query().Get("confirm") + if confirm != "true" { + http.Error(w, "Bestätigung erforderlich. Verwende ?confirm=true", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Lösche alle Queue-Einträge + result, err := db.ExecContext(ctx, "DELETE FROM renewal_queue") + if err != nil { + http.Error(w, "Fehler beim Löschen der Queue-Einträge", http.StatusInternalServerError) + log.Printf("Fehler beim Löschen der Queue-Einträge: %v", err) + return + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + http.Error(w, "Fehler beim Prüfen der gelöschten Zeilen", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen der gelöschten Zeilen: %v", err) + return + } + + log.Printf("Alle Renewal Queue-Einträge gelöscht: %d Einträge", rowsAffected) + + response := map[string]interface{}{ + "success": true, + "message": "Alle Renewal Queue-Einträge erfolgreich gelöscht", + "deletedCount": rowsAffected, + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + + // Audit-Log + if auditService != nil { + ipAddress, userAgent := getRequestInfo(r) + auditService.Track(r.Context(), "DELETE", "renewal_queue", "", userID, username, map[string]interface{}{ + "deletedCount": rowsAffected, + "message": fmt.Sprintf("Alle Renewal Queue-Einträge gelöscht (%d Einträge)", rowsAffected), + }, ipAddress, userAgent) + } +} + diff --git a/backend/renewal_scheduler.go b/backend/renewal_scheduler.go index fbfe709..5cd0b40 100644 --- a/backend/renewal_scheduler.go +++ b/backend/renewal_scheduler.go @@ -242,9 +242,27 @@ func processCertificateRenewal(certID, fqdnID, spaceID, queueID string) error { traceID := generateTraceID() log.Printf("Starte automatische Zertifikatserneuerung (Queue ID: %s, TraceID: %s, FQDN: %s)", queueID, traceID, fqdn.FQDN) + // Erstelle ACME-Client-Kontext + // Standardmäßig verwenden wir Let's Encrypt Staging, aber in Zukunft könnte dies aus der FQDN-Konfiguration kommen + acmeProviderIDStr := "letsencrypt-staging" // TODO: Aus FQDN-Konfiguration lesen + acmeCtx, err := NewACMEClientContext(acmeProviderIDStr) + if err != nil { + return fmt.Errorf("fehler beim Initialisieren des ACME-Providers: %v", err) + } + + // Prüfe ob der KeyID zum aktuellen Provider passt + // Wenn der FQDN noch den alten Provider (certigo-acmeproxy) hat, aber wir jetzt einen direkten ACME-Provider verwenden, + // muss der KeyID ignoriert werden, da er zu einem anderen Provider gehört + keyIDToUse := fqdn.AcmeKeyID + if fqdn.AcmeProviderID == "certigo-acmeproxy" && acmeProviderIDStr != "certigo-acmeproxy" { + // Provider hat sich geändert - KeyID ist nicht mehr gültig + log.Printf("Provider hat sich geändert (%s -> %s), erstelle neuen Account", fqdn.AcmeProviderID, acmeProviderIDStr) + keyIDToUse = "" // Erzwinge neue Account-Erstellung + } + // Beantrage neues Zertifikat baseFqdn := strings.TrimPrefix(fqdn.FQDN, "*.") - result, err := RequestCertificate(baseFqdn, fqdn.AcmeEmail, fqdnID, fqdn.AcmeKeyID, traceID, updateTokenFunc, cleanupTokenFunc, statusCallback) + result, err := RequestCertificate(acmeCtx, baseFqdn, fqdn.AcmeEmail, fqdnID, keyIDToUse, traceID, updateTokenFunc, cleanupTokenFunc, statusCallback) if err != nil { return fmt.Errorf("fehler beim Beantragen des neuen Zertifikats: %v", err) } diff --git a/frontend/src/pages/AuditLogs.jsx b/frontend/src/pages/AuditLogs.jsx index 4ae55c7..f342b5d 100644 --- a/frontend/src/pages/AuditLogs.jsx +++ b/frontend/src/pages/AuditLogs.jsx @@ -1,11 +1,78 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { useAuth } from '../contexts/AuthContext' +// Custom Dropdown Component +const CustomDropdown = ({ label, value, onChange, options, placeholder = "Auswählen" }) => { + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const selectedOption = options.find(opt => opt.value === value) || { label: placeholder } + + return ( +
+ + + {isOpen && ( +
+
+ {options.map((option) => ( + + ))} +
+
+ )} +
+ ) +} + const AuditLogs = () => { const { authFetch } = useAuth() const [logs, setLogs] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState('') + const [isRefreshing, setIsRefreshing] = useState(false) + const [lastUpdate, setLastUpdate] = useState(null) const [filters, setFilters] = useState({ action: '', resourceType: '', @@ -23,6 +90,7 @@ const AuditLogs = () => { try { if (!silent) { setLoading(true) + setIsRefreshing(true) } setError('') @@ -41,9 +109,8 @@ const AuditLogs = () => { } const data = await response.json() - console.log('Audit-Logs Response:', data) - console.log('Anzahl Logs:', data.logs?.length || 0) setLogs(data.logs || []) + setLastUpdate(new Date()) setPagination({ ...pagination, total: data.total || 0, @@ -57,6 +124,7 @@ const AuditLogs = () => { } finally { if (!silent) { setLoading(false) + setIsRefreshing(false) } } } @@ -72,7 +140,7 @@ const AuditLogs = () => { }, 5000) // Aktualisiere alle 5 Sekunden return () => clearInterval(interval) - }, [filters.action, filters.resourceType, filters.userId, pagination.offset]) + }, [filters.action, filters.resourceType, filters.userId, pagination.offset, authFetch]) const handleFilterChange = (key, value) => { setFilters({ ...filters, [key]: value }) @@ -188,6 +256,18 @@ const AuditLogs = () => { } } + const formatLastUpdate = () => { + if (!lastUpdate) return '' + const now = new Date() + const diff = Math.floor((now - lastUpdate) / 1000) + if (diff < 5) return 'Gerade eben' + if (diff < 60) return `Vor ${diff} Sekunden` + const minutes = Math.floor(diff / 60) + return `Vor ${minutes} Minute${minutes > 1 ? 'n' : ''}` + } + + const hasActiveFilters = filters.action || filters.resourceType || filters.userId + return (
@@ -196,55 +276,80 @@ const AuditLogs = () => {

Audit Log

Übersicht aller Systemaktivitäten und Änderungen

-
-
- Live-Aktualisierung aktiv +
+ {isRefreshing && ( +
+ + + + + Aktualisiere... +
+ )} +
+
+ Live +
+ {lastUpdate && ( +
+ {formatLastUpdate()} +
+ )}
{/* Filter */} -
-
-
- - -
-
- - -
-
-
+
+ handleFilterChange('action', value)} + options={[ + { value: '', label: 'Alle Aktionen' }, + { value: 'CREATE', label: 'Erstellt' }, + { value: 'UPDATE', label: 'Aktualisiert' }, + { value: 'DELETE', label: 'Gelöscht' }, + { value: 'UPLOAD', label: 'Hochgeladen' }, + { value: 'SIGN', label: 'Signiert' }, + { value: 'ENABLE', label: 'Aktiviert' }, + { value: 'DISABLE', label: 'Deaktiviert' } + ]} + /> + handleFilterChange('resourceType', value)} + options={[ + { value: '', label: 'Alle Typen' }, + { value: 'user', label: 'Benutzer' }, + { value: 'space', label: 'Space' }, + { value: 'fqdn', label: 'FQDN' }, + { value: 'csr', label: 'CSR' }, + { value: 'provider', label: 'Provider' }, + { value: 'certificate', label: 'Zertifikat' }, + { value: 'permission_group', label: 'Berechtigungsgruppen' } + ]} + /> +
+ { value={filters.userId} onChange={(e) => handleFilterChange('userId', e.target.value)} placeholder="Benutzer-ID filtern" - className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500" + className="w-full px-4 py-3 bg-slate-700/50 hover:bg-slate-700/70 text-white rounded-lg border border-slate-600/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all duration-200 text-sm placeholder-slate-400" />
diff --git a/frontend/src/pages/RenewalQueue.jsx b/frontend/src/pages/RenewalQueue.jsx index dda59e5..e307589 100644 --- a/frontend/src/pages/RenewalQueue.jsx +++ b/frontend/src/pages/RenewalQueue.jsx @@ -1,38 +1,166 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { useAuth } from '../contexts/AuthContext' +// Custom Dropdown Component +const CustomDropdown = ({ label, value, onChange, options, placeholder = "Auswählen" }) => { + const [isOpen, setIsOpen] = useState(false) + const dropdownRef = useRef(null) + + useEffect(() => { + const handleClickOutside = (event) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { + setIsOpen(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + const selectedOption = options.find(opt => opt.value === value) || { label: placeholder } + + return ( +
+ + + {isOpen && ( +
+
+ {options.map((option) => ( + + ))} +
+
+ )} +
+ ) +} + const RenewalQueue = () => { const { authFetch } = useAuth() const [queue, setQueue] = useState([]) const [loading, setLoading] = useState(true) + const [statusFilter, setStatusFilter] = useState('all') + const [spaceFilter, setSpaceFilter] = useState('all') + const [isRefreshing, setIsRefreshing] = useState(false) + const [lastUpdate, setLastUpdate] = useState(null) - const fetchQueue = async () => { + const fetchQueue = async (silent = false) => { try { + if (!silent) { + setIsRefreshing(true) + } const response = await authFetch('/api/renewal-queue') if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`) } const data = await response.json() setQueue(data.queue || []) + setLastUpdate(new Date()) setLoading(false) } catch (err) { console.error('Error fetching renewal queue:', err) setLoading(false) + } finally { + if (!silent) { + setIsRefreshing(false) + } } } useEffect(() => { fetchQueue() - const interval = setInterval(fetchQueue, 30000) // Refresh every 30 seconds + // Live-Refresh alle 5 Sekunden für Echtzeit-Ansicht + const interval = setInterval(() => fetchQueue(true), 5000) return () => clearInterval(interval) }, [authFetch]) + // Extrahiere eindeutige Spaces für Filter + const uniqueSpaces = useMemo(() => { + const spaces = new Set() + queue.forEach(item => { + if (item.spaceName) { + spaces.add(item.spaceName) + } + }) + return Array.from(spaces).sort() + }, [queue]) + + // Filtere und sortiere Queue-Einträge + const filteredAndSortedQueue = useMemo(() => { + let filtered = queue + + // Filter nach Status + if (statusFilter !== 'all') { + filtered = filtered.filter(item => item.status === statusFilter) + } + + // Filter nach Space + if (spaceFilter !== 'all') { + filtered = filtered.filter(item => item.spaceName === spaceFilter) + } + + // Sortiere nach scheduled_at (nächste oben) + filtered.sort((a, b) => { + const dateA = new Date(a.scheduledAt) + const dateB = new Date(b.scheduledAt) + return dateA - dateB + }) + + return filtered + }, [queue, statusFilter, spaceFilter]) + + // Teile in "Ausstehend" und "Erledigt" + const pendingQueue = useMemo(() => { + return filteredAndSortedQueue.filter(item => + item.status === 'pending' || item.status === 'processing' + ) + }, [filteredAndSortedQueue]) + + const completedQueue = useMemo(() => { + return filteredAndSortedQueue.filter(item => + item.status === 'completed' || item.status === 'failed' + ) + }, [filteredAndSortedQueue]) + const getStatusColor = (status) => { switch (status) { case 'pending': return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50' case 'processing': return 'bg-blue-500/20 text-blue-400 border-blue-500/50' + case 'success': + return 'bg-green-500/20 text-green-400 border-green-500/50' case 'completed': return 'bg-green-500/20 text-green-400 border-green-500/50' case 'failed': @@ -86,13 +214,92 @@ const RenewalQueue = () => { } } + const renderQueueTable = (items, title) => { + if (items.length === 0) { + return null + } + + return ( +
+

{title}

+
+
+ + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + ))} + +
FQDNSpaceGeplant fürStatusVerarbeitetFehler
{item.fqdn || '-'}{item.spaceName || '-'}{formatDate(item.scheduledAt)} + + {item.status} + + {formatDate(item.processedAt)}{item.errorMessage || '-'}
+
+
+
+ ) + } + + + const formatLastUpdate = () => { + if (!lastUpdate) return '' + const now = new Date() + const diff = Math.floor((now - lastUpdate) / 1000) + if (diff < 5) return 'Gerade eben' + if (diff < 60) return `Vor ${diff} Sekunden` + const minutes = Math.floor(diff / 60) + return `Vor ${minutes} Minute${minutes > 1 ? 'n' : ''}` + } + return (
-

Renewal Queue

-

- Übersicht über geplante und laufende Zertifikatserneuerungen -

+
+
+

Renewal Queue

+

+ Übersicht über geplante und laufende Zertifikatserneuerungen +

+
+
+ {isRefreshing && ( +
+ + + + + Aktualisiere... +
+ )} +
+
+ Live +
+ {lastUpdate && ( +
+ {formatLastUpdate()} +
+ )} +
+
{loading ? (
@@ -100,44 +307,68 @@ const RenewalQueue = () => {

Lade Queue...

) : ( -
- {queue.length === 0 ? ( -
-

Keine Einträge in der Renewal Queue

+ <> + {/* Filter */} +
+
+

Filter

+ {(statusFilter !== 'all' || spaceFilter !== 'all') && ( + + )}
- ) : ( -
- - - - - - - - - - - - - {queue.map((item) => ( - - - - - - - - - ))} - -
FQDNSpaceGeplant fürStatusVerarbeitetFehler
{item.fqdn || '-'}{item.spaceName || '-'}{formatDate(item.scheduledAt)} - - {item.status} - - {formatDate(item.processedAt)}{item.errorMessage || '-'}
+
+ + ({ value: space, label: space })) + ]} + /> +
+
+ + {/* Ausstehende Tasks */} + {renderQueueTable(pendingQueue, `Ausstehend (${pendingQueue.length})`)} + + {/* Erledigte Tasks */} + {renderQueueTable(completedQueue, `Erledigt (${completedQueue.length})`)} + + {/* Keine Einträge */} + {filteredAndSortedQueue.length === 0 && ( +
+

+ {queue.length === 0 + ? 'Keine Einträge in der Renewal Queue' + : 'Keine Einträge entsprechen den gewählten Filtern'} +

)} -
+ )}