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 ( +
Übersicht aller Systemaktivitäten und Änderungen