diff --git a/.gitignore b/.gitignore index 91b8328..7b97fb6 100644 --- a/.gitignore +++ b/.gitignore @@ -87,6 +87,14 @@ vite.config.ts.timestamp-* *.sqlite3 backend/spaces.db backend/*.db +backend/**/*.db +backend/**/*.db-shm +backend/**/*.db-wal + +# Test databases +backend/testing/**/*.db +backend/testing/**/*.db-shm +backend/testing/**/*.db-wal # Database backups *.sql.backup @@ -114,6 +122,9 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* backend/*.log +backend/logs/ +backend/logs/** +*.log.* # ============================================ # IDE & Editors @@ -337,6 +348,14 @@ backend/test-outputs/ # Script outputs backend/scripts/output/ +# Testing directory (keep structure, ignore test databases) +backend/testing/scripts/*.db +backend/testing/scripts/*.db-shm +backend/testing/scripts/*.db-wal + # Keep directory structure but ignore contents !backend/uploads/.gitkeep !backend/config/providers/.gitkeep +!backend/testing/.gitkeep +!backend/testing/README.md +!backend/testing/scripts/*.go diff --git a/backend/acme_client.go b/backend/acme_client.go new file mode 100644 index 0000000..7883cf1 --- /dev/null +++ b/backend/acme_client.go @@ -0,0 +1,1448 @@ +package main + +import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +// 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 { + PrivateKey *rsa.PrivateKey + PublicKey *rsa.PublicKey + KeyDir string +} + +// CertificateRequestResult enthält das Ergebnis einer Zertifikatsanfrage +type CertificateRequestResult struct { + Certificate string `json:"certificate"` + PrivateKey string `json:"privateKey"` + KeyID string `json:"keyId"` + Status []string `json:"status"` + OrderURL string `json:"orderUrl,omitempty"` + StepStatus map[string]string `json:"stepStatus,omitempty"` // Schritt-Status für Frontend +} + +// loadOrCreateKeyPair lädt ein Key-Paar oder erstellt ein neues +func loadOrCreateKeyPair(fqdnID string, keyDir string) (*ACMEKeyPair, error) { + + // Erstelle Key-Verzeichnis falls nicht vorhanden + if err := os.MkdirAll(keyDir, 0755); err != nil { + return nil, fmt.Errorf("fehler beim Erstellen des Key-Verzeichnisses: %v", err) + } + + privateKeyPath := filepath.Join(keyDir, fmt.Sprintf("%s_private.pem", fqdnID)) + publicKeyPath := filepath.Join(keyDir, fmt.Sprintf("%s_public.pem", fqdnID)) + + var privateKey *rsa.PrivateKey + var err error + + // Prüfe ob Private Key bereits existiert + if _, statErr := os.Stat(privateKeyPath); statErr == nil { + // Lade existierenden Key + privateKeyData, err := os.ReadFile(privateKeyPath) + if err != nil { + return nil, fmt.Errorf("fehler beim Lesen des Private Keys: %v", err) + } + + block, _ := pem.Decode(privateKeyData) + if block == nil { + return nil, fmt.Errorf("fehler beim Dekodieren des Private Keys") + } + + privateKey, err = x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("fehler beim Parsen des Private Keys: %v", err) + } + } else { + // Erstelle neuen Key + privateKey, err = rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, fmt.Errorf("fehler beim Generieren des Private Keys: %v", err) + } + + // Speichere Private Key + privateKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }) + if err := os.WriteFile(privateKeyPath, privateKeyPEM, 0600); err != nil { + return nil, fmt.Errorf("fehler beim Speichern des Private Keys: %v", err) + } + + // Speichere Public Key + publicKeyDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("fehler beim Marshalling des Public Keys: %v", err) + } + publicKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: publicKeyDER, + }) + if err := os.WriteFile(publicKeyPath, publicKeyPEM, 0644); err != nil { + return nil, fmt.Errorf("fehler beim Speichern des Public Keys: %v", err) + } + } + + return &ACMEKeyPair{ + PrivateKey: privateKey, + PublicKey: &privateKey.PublicKey, + KeyDir: keyDir, + }, nil +} + +// getNonce ruft einen neuen Nonce vom ACME-Server ab +func getNonce(directoryURL string) (string, error) { + // Rufe Directory-Endpoint auf, um einen Nonce zu bekommen + req, err := http.NewRequest("HEAD", directoryURL, nil) + if err != nil { + return "", fmt.Errorf("fehler beim Erstellen des HEAD-Requests: %v", err) + } + + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fehler beim Senden des HEAD-Requests: %v", err) + } + defer resp.Body.Close() + + nonce := resp.Header.Get("Replay-Nonce") + if nonce == "" { + return "", fmt.Errorf("kein Nonce in Response gefunden") + } + + return nonce, nil +} + +// createJWSForGet erstellt einen JWS für POST-as-GET Requests (leerer Payload) +func createJWSForGet(keyPair *ACMEKeyPair, url string, keyID string, nonce string) (string, error) { + + // Für POST-as-GET ist der Payload leer (nicht {}, sondern wirklich leer) + payloadB64 := "" // Leerer String für POST-as-GET + + // Erstelle Protected Header + protected := map[string]interface{}{ + "alg": "RS256", + "nonce": nonce, + "url": url, + } + if keyID != "" { + protected["kid"] = keyID + } else { + // Für newAccount: verwende JWK (JSON Web Key) + jwk, err := publicKeyToJWK(keyPair.PublicKey) + if err != nil { + return "", fmt.Errorf("fehler beim Erstellen des JWK: %v", err) + } + protected["jwk"] = jwk + } + + protectedBytes, err := json.Marshal(protected) + if err != nil { + return "", fmt.Errorf("fehler beim Serialisieren des Protected Headers: %v", err) + } + protectedB64 := base64.RawURLEncoding.EncodeToString(protectedBytes) + + // Erstelle Signing Input + signingInput := fmt.Sprintf("%s.%s", protectedB64, payloadB64) + + // Signiere mit Private Key (RS256 = RSA mit SHA-256) + hasher := sha256.New() + hasher.Write([]byte(signingInput)) + hashed := hasher.Sum(nil) + + signature, err := rsa.SignPKCS1v15(rand.Reader, keyPair.PrivateKey, crypto.SHA256, hashed) + if err != nil { + return "", fmt.Errorf("fehler beim Signieren: %v", err) + } + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) + + // Erstelle JWS + jws := map[string]string{ + "protected": protectedB64, + "payload": payloadB64, + "signature": signatureB64, + } + + jwsBytes, err := json.Marshal(jws) + if err != nil { + return "", fmt.Errorf("fehler beim Serialisieren des JWS: %v", err) + } + + return string(jwsBytes), nil +} + +// createJWS erstellt einen JWS (JSON Web Signature) für ACME-Requests +func createJWS(keyPair *ACMEKeyPair, payload interface{}, url string, keyID string, nonce string) (string, error) { + + // Serialisiere Payload + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("fehler beim Serialisieren des Payloads: %v", err) + } + payloadB64 := base64.RawURLEncoding.EncodeToString(payloadBytes) + + // Erstelle Protected Header + protected := map[string]interface{}{ + "alg": "RS256", + "nonce": nonce, + "url": url, + } + if keyID != "" { + protected["kid"] = keyID + } else { + // Für newAccount: verwende JWK (JSON Web Key) + jwk, err := publicKeyToJWK(keyPair.PublicKey) + if err != nil { + return "", fmt.Errorf("fehler beim Erstellen des JWK: %v", err) + } + protected["jwk"] = jwk + } + + protectedBytes, err := json.Marshal(protected) + if err != nil { + return "", fmt.Errorf("fehler beim Serialisieren des Protected Headers: %v", err) + } + protectedB64 := base64.RawURLEncoding.EncodeToString(protectedBytes) + + // Erstelle Signing Input + signingInput := fmt.Sprintf("%s.%s", protectedB64, payloadB64) + + // Signiere mit Private Key (RS256 = RSA mit SHA-256) + hasher := sha256.New() + hasher.Write([]byte(signingInput)) + hashed := hasher.Sum(nil) + + signature, err := rsa.SignPKCS1v15(rand.Reader, keyPair.PrivateKey, crypto.SHA256, hashed) + if err != nil { + return "", fmt.Errorf("fehler beim Signieren: %v", err) + } + signatureB64 := base64.RawURLEncoding.EncodeToString(signature) + + // Erstelle JWS + jws := map[string]string{ + "protected": protectedB64, + "payload": payloadB64, + "signature": signatureB64, + } + + jwsBytes, err := json.Marshal(jws) + if err != nil { + return "", fmt.Errorf("fehler beim Serialisieren des JWS: %v", err) + } + + return string(jwsBytes), nil +} + +// publicKeyToJWK konvertiert einen RSA Public Key zu JWK Format +func publicKeyToJWK(pubKey *rsa.PublicKey) (map[string]interface{}, error) { + // Berechne n (Modulus) und e (Exponent) für JWK + // n muss in Big-Endian Format sein + nBytes := pubKey.N.Bytes() + n := base64.RawURLEncoding.EncodeToString(nBytes) + + // e (Exponent) ist normalerweise 65537 (0x10001) oder kleiner + // Konvertiere zu Big-Endian Bytes + var eBytes []byte + if pubKey.E < 256 { + eBytes = []byte{byte(pubKey.E)} + } else if pubKey.E < 65536 { + eBytes = []byte{byte(pubKey.E >> 8), byte(pubKey.E)} + } else { + // Für größere Exponenten (selten) + e := pubKey.E + for e > 0 { + eBytes = append([]byte{byte(e & 0xFF)}, eBytes...) + e >>= 8 + } + } + e := base64.RawURLEncoding.EncodeToString(eBytes) + + return map[string]interface{}{ + "kty": "RSA", + "n": n, + "e": e, + }, nil +} + +// calculateJWKThumbprint berechnet den JWK Thumbprint gemäß RFC 7638 +func calculateJWKThumbprint(pubKey *rsa.PublicKey) (string, error) { + // Erstelle JWK + jwk, err := publicKeyToJWK(pubKey) + if err != nil { + return "", fmt.Errorf("fehler beim Erstellen des JWK: %v", err) + } + + // Sortiere Keys lexikografisch und erstelle kanonisches JSON (ohne Whitespace) + // Für RSA: kty, n, e (in dieser Reihenfolge) + canonicalJSON := fmt.Sprintf(`{"e":"%s","kty":"%s","n":"%s"}`, jwk["e"], jwk["kty"], jwk["n"]) + + // Berechne SHA-256 Hash + hasher := sha256.New() + hasher.Write([]byte(canonicalJSON)) + hash := hasher.Sum(nil) + + // Base64URL kodieren (ohne Padding) + thumbprint := base64.RawURLEncoding.EncodeToString(hash) + + return thumbprint, nil +} + +// calculateKeyAuthHash berechnet den KeyAuth Hash für DNS-01 Challenge +// Formel: keyAuth = token + "." + base64url(sha256(jwk_thumbprint)) +// TXT-Record-Wert = base64url(sha256(keyAuth)) +func calculateKeyAuthHash(token string, pubKey *rsa.PublicKey) (string, error) { + + // Phase 1: Berechne JWK Thumbprint + thumbprint, err := calculateJWKThumbprint(pubKey) + if err != nil { + return "", fmt.Errorf("fehler beim Berechnen des JWK Thumbprints: %v", err) + } + + // Phase 2: Erstelle KeyAuth = Token + "." + Thumbprint + keyAuth := token + "." + thumbprint + + // Phase 3: Berechne SHA-256 Hash des KeyAuth + hasher := sha256.New() + hasher.Write([]byte(keyAuth)) + hash := hasher.Sum(nil) + + // Base64URL kodieren (ohne Padding) + txtValue := base64.RawURLEncoding.EncodeToString(hash) + + return txtValue, nil +} + +// 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(directoryURL) + if err != nil { + return "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) + } + + // Erstelle Payload für newAccount + payload := map[string]interface{}{ + "termsOfServiceAgreed": true, + "contact": []string{fmt.Sprintf("mailto:%s", email)}, + } + + // Erstelle JWS (ohne KeyID, da es ein neuer Account ist) + 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", newAccountURL, bytes.NewBufferString(jws)) + if err != nil { + return "", fmt.Errorf("fehler beim Erstellen des HTTP-Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/jose+json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fehler beim Senden des Requests: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("fehler beim Lesen der Response: %v", err) + } + + // Prüfe Status Code + if resp.StatusCode != 201 && resp.StatusCode != 200 { + return "", fmt.Errorf("fehler bei der Account-Erstellung (Status %d): %s", resp.StatusCode, string(body)) + } + + // Extrahiere KeyID aus Location Header + keyID := resp.Header.Get("Location") + if keyID == "" { + return "", fmt.Errorf("keine KeyID in Location Header gefunden") + } + + statusCallback(fmt.Sprintf("Account erfolgreich erstellt (KeyID: %s)", keyID)) + return keyID, nil +} + +// 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(directoryURL) + if err != nil { + return "", nil, fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) + } + + // Erstelle Payload für newOrder + payload := map[string]interface{}{ + "identifiers": []map[string]string{}, + } + for _, domain := range domains { + payload["identifiers"] = append(payload["identifiers"].([]map[string]string), map[string]string{ + "type": "dns", + "value": domain, + }) + } + + // Erstelle JWS (mit KeyID) + 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", newOrderURL, bytes.NewBufferString(jws)) + if err != nil { + return "", nil, fmt.Errorf("fehler beim Erstellen des HTTP-Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/jose+json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return "", nil, fmt.Errorf("fehler beim Senden des Requests: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", nil, fmt.Errorf("fehler beim Lesen der Response: %v", err) + } + + // Prüfe Status Code + if resp.StatusCode != 201 { + return "", nil, fmt.Errorf("fehler bei der Order-Erstellung (Status %d): %s", resp.StatusCode, string(body)) + } + + // Parse Response + var orderResponse map[string]interface{} + if err := json.Unmarshal(body, &orderResponse); err != nil { + return "", nil, fmt.Errorf("fehler beim Parsen der Order-Response: %v", err) + } + + orderURL := resp.Header.Get("Location") + if orderURL == "" { + return "", nil, fmt.Errorf("keine Order URL in Location Header gefunden") + } + + statusCallback(fmt.Sprintf("Order erfolgreich erstellt (URL: %s)", orderURL)) + return orderURL, orderResponse, nil +} + +// 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(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) + log.Printf("[ACME] Email: %s", email) + log.Printf("[ACME] FQDN ID: %s", fqdnID) + log.Printf("[ACME] Existing KeyID: %s", existingKeyID) + log.Printf("[ACME] TraceID: %s", traceID) + + result := &CertificateRequestResult{ + Status: []string{}, + StepStatus: make(map[string]string), + } + + // Schritt 1: Erstelle/Lade Key-Paar + log.Printf("[ACME] Schritt 1: Erstelle/Lade Key-Paar...") + statusCallback("Erstelle/Lade Key-Paar...") + keyDir := filepath.Join(os.TempDir(), "certigo-keys") + result.StepStatus["KEY_PAAR_ERSTELLUNG"] = "loading" + keyPair, err := loadOrCreateKeyPair(fqdnID, keyDir) + if err != nil { + log.Printf("[ACME] FEHLER bei Schritt 1: %v", err) + logCertStatus(traceID, fqdnID, "KEY_PAAR_ERSTELLUNG", "FAILED", err.Error()) + result.StepStatus["KEY_PAAR_ERSTELLUNG"] = "error" + return nil, fmt.Errorf("fehler beim Laden/Erstellen des Key-Paars: %v", err) + } + log.Printf("[ACME] Schritt 1 erfolgreich: Key-Paar geladen/erstellt") + logCertStatus(traceID, fqdnID, "KEY_PAAR_ERSTELLUNG", "OK", "") + result.StepStatus["KEY_PAAR_ERSTELLUNG"] = "success" + result.Status = append(result.Status, "Key-Paar erfolgreich geladen/erstellt") + statusCallback("Key-Paar erfolgreich geladen/erstellt") + + // 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(fmt.Sprintf("Erstelle Account bei %s...", ctx.Provider.GetDisplayName())) + result.StepStatus["ACCOUNT_ERSTELLUNG"] = "loading" + 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()) + result.StepStatus["ACCOUNT_ERSTELLUNG"] = "error" + return nil, fmt.Errorf("fehler bei der Account-Erstellung: %v", err) + } + log.Printf("[ACME] Schritt 2 erfolgreich: Account erstellt (KeyID: %s)", keyID) + logCertStatus(traceID, fqdnID, "ACCOUNT_ERSTELLUNG", "OK", "") + result.StepStatus["ACCOUNT_ERSTELLUNG"] = "success" + result.KeyID = keyID + result.Status = append(result.Status, fmt.Sprintf("Account erfolgreich erstellt (KeyID: %s)", keyID)) + } else { + log.Printf("[ACME] Verwende existierenden Account (KeyID: %s)", existingKeyID) + result.StepStatus["ACCOUNT_ERSTELLUNG"] = "success" + keyID = existingKeyID + result.KeyID = keyID + result.Status = append(result.Status, fmt.Sprintf("Verwende existierenden Account (KeyID: %s)", keyID)) + statusCallback(fmt.Sprintf("Verwende existierenden Account (KeyID: %s)", keyID)) + } + + // 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(fmt.Sprintf("Erstelle Order bei %s...", ctx.Provider.GetDisplayName())) + result.StepStatus["ORDER_ERSTELLUNG"] = "loading" + 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()) + result.StepStatus["ORDER_ERSTELLUNG"] = "error" + return nil, fmt.Errorf("fehler bei der Order-Erstellung: %v", err) + } + log.Printf("[ACME] Schritt 3 erfolgreich: Order erstellt (URL: %s)", orderURL) + logCertStatus(traceID, fqdnID, "ORDER_ERSTELLUNG", "OK", "") + result.StepStatus["ORDER_ERSTELLUNG"] = "success" + result.OrderURL = orderURL + result.Status = append(result.Status, fmt.Sprintf("Order erfolgreich erstellt (URL: %s)", orderURL)) + + // Schritt 4: Extrahiere Token aus Order-Response und sende an certigo-acmeproxy + if orderResponse != nil { + statusCallback("Extrahiere Challenge-Token...") + + 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) + } + + if token != "" { + // Berechne keyAuth Hash für DNS-01 Challenge + keyAuthHash, err := calculateKeyAuthHash(token, keyPair.PublicKey) + if err != nil { + logCertStatus(traceID, fqdnID, "KEYAUTH_BERECHNUNG", "FAILED", err.Error()) + return nil, fmt.Errorf("fehler beim Berechnen des keyAuth Hash: %v", err) + } + + statusCallback("Sende Token an certigo-acmeproxy...") + + // Sende Token via updateTokenFunc (certigo-acmeproxy erwartet den keyAuth Hash, nicht nur den Token) + result.StepStatus["REGISTER_AUFRUF"] = "loading" + if err := updateTokenFunc(keyAuthHash); err != nil { + logCertStatus(traceID, fqdnID, "REGISTER_AUFRUF", "FAILED", err.Error()) + result.StepStatus["REGISTER_AUFRUF"] = "error" + return nil, fmt.Errorf("fehler beim Senden des Tokens: %v", err) + } + logCertStatus(traceID, fqdnID, "REGISTER_AUFRUF", "OK", "") + result.StepStatus["REGISTER_AUFRUF"] = "success" + result.Status = append(result.Status, "Token erfolgreich an certigo-acmeproxy gesendet") + statusCallback("Token erfolgreich an certigo-acmeproxy gesendet") + + // Schritt 5: Aktiviere Challenge bei Let's Encrypt + statusCallback("Aktiviere Challenge bei Let's Encrypt...") + + 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(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) + } + logCertStatus(traceID, fqdnID, "CHALLENGE_AKTIVIERUNG", "OK", "") + result.StepStatus["CHALLENGE_AKTIVIERUNG"] = "success" + result.Status = append(result.Status, "Challenge bei Let's Encrypt aktiviert") + statusCallback("Challenge bei Let's Encrypt aktiviert") + + // Schritt 6: Warte auf Challenge-Validierung (Polling) + statusCallback("Warte auf Challenge-Validierung...") + + 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 { + if cleanupErr := cleanupTokenFunc(); cleanupErr != nil { + } else { + } + } + // Gebe die Fehlermeldung weiter, damit der Benutzer informiert wird + return nil, fmt.Errorf("fehler bei der Challenge-Validierung: %v", err) + } + + result.Status = append(result.Status, "Challenge erfolgreich validiert") + statusCallback("Challenge erfolgreich validiert") + + // Schritt 7: Finalisiere Order und hole Zertifikat + statusCallback("Finalisiere Order und hole Zertifikat...") + + result.StepStatus["ZERTIFIKAT_ERSTELLUNG"] = "loading" + 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" + return nil, fmt.Errorf("fehler beim Finalisieren der Order: %v", err) + } + logCertStatus(traceID, fqdnID, "ZERTIFIKAT_ERSTELLUNG", "OK", "") + result.StepStatus["ZERTIFIKAT_ERSTELLUNG"] = "success" + + result.Certificate = certPEM + result.PrivateKey = keyPEM + result.Status = append(result.Status, "Zertifikat erfolgreich erstellt") + statusCallback("Zertifikat erfolgreich erstellt") + } else { + } + } + + return result, nil +} + +// extractTokenFromOrder extrahiert den Challenge-Token aus der Order-Response +func extractTokenFromOrder(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, orderResponse map[string]interface{}, domain string) (string, error) { + + // Extrahiere authorizations Array + authorizations, ok := orderResponse["authorizations"].([]interface{}) + if !ok { + return "", fmt.Errorf("keine authorizations in Order-Response gefunden") + } + + if len(authorizations) == 0 { + return "", fmt.Errorf("authorizations Array ist leer") + } + + // Hole Nonce für Authorization-Request + nonce, err := getNonce(ctx.DirectoryURL) + if err != nil { + return "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) + } + + // Durchlaufe alle Authorizations + for _, authURL := range authorizations { + authURLStr, ok := authURL.(string) + if !ok { + continue + } + + // Debug: Curl-Befehl für manuelle Abfrage + + // Erstelle JWS für Authorization-Request (POST-as-GET: leerer Payload als String "") + // Für POST-as-GET muss der Payload leer sein, nicht {} + jws, err := createJWSForGet(keyPair, authURLStr, keyID, nonce) + if err != nil { + return "", fmt.Errorf("fehler beim Erstellen des JWS für Authorization: %v", err) + } + + // Sende POST-as-GET Request für Authorization + req, err := http.NewRequest("POST", authURLStr, bytes.NewBufferString(jws)) + if err != nil { + return "", fmt.Errorf("fehler beim Erstellen des Authorization-Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/jose+json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fehler beim Senden des Authorization-Requests: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("fehler beim Lesen der Authorization-Response: %v", err) + } + + // Update Nonce für nächsten Request + newNonce := resp.Header.Get("Replay-Nonce") + if newNonce != "" { + nonce = newNonce + } + + if resp.StatusCode != 200 { + continue + } + + // Parse Authorization-Response + var authResponse map[string]interface{} + if err := json.Unmarshal(body, &authResponse); err != nil { + continue + } + + // Extrahiere Challenges + challenges, ok := authResponse["challenges"].([]interface{}) + if !ok { + continue + } + + // Suche DNS-01 Challenge + for _, challenge := range challenges { + challengeMap, ok := challenge.(map[string]interface{}) + if !ok { + continue + } + + challengeType, ok := challengeMap["type"].(string) + if !ok || challengeType != "dns-01" { + continue + } + + // Extrahiere Token + token, ok := challengeMap["token"].(string) + if !ok || token == "" { + continue + } + + return token, nil + } + } + + return "", fmt.Errorf("kein DNS-01 Challenge Token in Authorizations gefunden") +} + +// extractChallengeURLFromOrder extrahiert die Challenge-URL aus der Order-Response +func extractChallengeURLFromOrder(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, orderResponse map[string]interface{}, domain string) (string, error) { + + // Extrahiere authorizations Array + authorizations, ok := orderResponse["authorizations"].([]interface{}) + if !ok { + return "", fmt.Errorf("keine authorizations in Order-Response gefunden") + } + + if len(authorizations) == 0 { + return "", fmt.Errorf("authorizations Array ist leer") + } + + // Hole Nonce für Authorization-Request + nonce, err := getNonce(ctx.DirectoryURL) + if err != nil { + return "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) + } + + // Durchlaufe alle Authorizations + for _, authURL := range authorizations { + authURLStr, ok := authURL.(string) + if !ok { + continue + } + + // Erstelle POST-as-GET Request für Authorization + jws, err := createJWSForGet(keyPair, authURLStr, keyID, nonce) + if err != nil { + return "", fmt.Errorf("fehler beim Erstellen des JWS für Authorization: %v", err) + } + + req, err := http.NewRequest("POST", authURLStr, bytes.NewBufferString(jws)) + if err != nil { + return "", fmt.Errorf("fehler beim Erstellen des Authorization-Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/jose+json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fehler beim Senden des Authorization-Requests: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + continue + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + continue + } + + // Update Nonce + newNonce := resp.Header.Get("Replay-Nonce") + if newNonce != "" { + nonce = newNonce + } + + // Parse Authorization-Response + var authResponse map[string]interface{} + if err := json.Unmarshal(body, &authResponse); err != nil { + continue + } + + // Extrahiere Challenges + challenges, ok := authResponse["challenges"].([]interface{}) + if !ok { + continue + } + + // Suche DNS-01 Challenge + for _, challenge := range challenges { + challengeMap, ok := challenge.(map[string]interface{}) + if !ok { + continue + } + + challengeType, ok := challengeMap["type"].(string) + if !ok || challengeType != "dns-01" { + continue + } + + // Extrahiere Challenge URL + challengeURL, ok := challengeMap["url"].(string) + if !ok || challengeURL == "" { + continue + } + + // Debug: Curl-Befehl für manuelle Abfrage + + return challengeURL, nil + } + } + + return "", fmt.Errorf("keine DNS-01 Challenge URL in Authorizations gefunden") +} + +// 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(ctx.DirectoryURL) + if err != nil { + return fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) + } + + // Erstelle Payload für Challenge-Aktivierung (leerer Payload) + emptyPayload := map[string]interface{}{} + jws, err := createJWS(keyPair, emptyPayload, challengeURL, keyID, nonce) + if err != nil { + return fmt.Errorf("fehler beim Erstellen des JWS: %v", err) + } + + // Sende POST Request zur Challenge-Aktivierung + req, err := http.NewRequest("POST", challengeURL, bytes.NewBufferString(jws)) + if err != nil { + return fmt.Errorf("fehler beim Erstellen des Challenge-Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/jose+json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("fehler beim Senden des Challenge-Requests: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("fehler beim Lesen der Challenge-Response: %v", err) + } + + if resp.StatusCode != 200 { + return fmt.Errorf("fehler bei der Challenge-Aktivierung (Status %d): %s", resp.StatusCode, string(body)) + } + + return nil +} + +// cleanupChallenge führt einen Cleanup-Prozess für eine Challenge durch +func cleanupChallenge(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, challengeURL string) error { + + // Hole Nonce + nonce, err := getNonce(ctx.DirectoryURL) + if err != nil { + return fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) + } + + // Erstelle Payload für Challenge-Cleanup (leerer Payload) + // Hinweis: ACME v2 unterstützt kein explizites Cleanup, aber wir können versuchen, + // die Challenge-Informationen zu löschen oder zu resetten + emptyPayload := map[string]interface{}{} + jws, err := createJWS(keyPair, emptyPayload, challengeURL, keyID, nonce) + if err != nil { + return fmt.Errorf("fehler beim Erstellen des JWS: %v", err) + } + + // Sende POST Request zur Challenge (kann helfen, den Status zu aktualisieren) + req, err := http.NewRequest("POST", challengeURL, bytes.NewBufferString(jws)) + if err != nil { + return fmt.Errorf("fehler beim Erstellen des Cleanup-Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/jose+json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("fehler beim Senden des Cleanup-Requests: %v", err) + } + defer resp.Body.Close() + + // Cleanup ist optional - auch wenn es fehlschlägt, ist das nicht kritisch + // Hauptsache ist, dass der Benutzer informiert wird und von vorne beginnen kann + // Lesen der Response ist nicht notwendig für Cleanup + io.ReadAll(resp.Body) + return nil +} + +// waitForChallengeValidation wartet auf die Validierung der Challenge (Polling) +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 + + for attempt := 1; attempt <= maxAttempts; attempt++ { + statusCallback(fmt.Sprintf("Prüfe Challenge-Status (%d/%d)...", attempt, maxAttempts)) + + // Hole Nonce + nonce, err := getNonce(ctx.DirectoryURL) + if err != nil { + return fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) + } + + // Erstelle POST-as-GET Request für Challenge-Status + jws, err := createJWSForGet(keyPair, challengeURL, keyID, nonce) + if err != nil { + return fmt.Errorf("fehler beim Erstellen des JWS: %v", err) + } + + // Sende Request + req, err := http.NewRequest("POST", challengeURL, bytes.NewBufferString(jws)) + if err != nil { + return fmt.Errorf("fehler beim Erstellen des Status-Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/jose+json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("fehler beim Senden des Status-Requests: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("fehler beim Lesen der Status-Response: %v", err) + } + + if resp.StatusCode != 200 { + time.Sleep(pollInterval) + continue + } + + // Parse Response + var challengeResponse map[string]interface{} + if err := json.Unmarshal(body, &challengeResponse); err != nil { + time.Sleep(pollInterval) + continue + } + + status, ok := challengeResponse["status"].(string) + if !ok { + time.Sleep(pollInterval) + continue + } + + if status == "valid" { + return nil + } + + if status == "invalid" { + errorDetail := "" + errorType := "" + if errors, ok := challengeResponse["error"].(map[string]interface{}); ok { + if detail, ok := errors["detail"].(string); ok { + errorDetail = detail + } + if errType, ok := errors["type"].(string); ok { + errorType = errType + } + } + + // Cleanup: Entferne Token + if cleanupTokenFunc != nil { + cleanupTokenFunc() + } + + errorMsg := fmt.Sprintf("Challenge ist ungültig") + if errorType != "" { + errorMsg += fmt.Sprintf(" (Typ: %s)", errorType) + } + if errorDetail != "" { + errorMsg += fmt.Sprintf(" - %s", errorDetail) + } + errorMsg += ". Bitte prüfen Sie die DNS-Einstellungen und versuchen Sie es erneut." + + return fmt.Errorf(errorMsg) + } + + // Status ist noch "pending", warte und versuche es erneut + if attempt < maxAttempts { + time.Sleep(pollInterval) + } + } + + return fmt.Errorf("challenge-Validierung hat das Zeitlimit überschritten (Status noch nicht 'valid')") +} + +// waitForOrderReady wartet, bis die Order den Status "ready" hat (nach Challenge-Validierung) +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 + consecutiveErrors := 0 // Zähler für aufeinanderfolgende Fehler + + for attempt := 1; attempt <= maxAttempts; attempt++ { + statusCallback(fmt.Sprintf("Prüfe Order-Status (%d/%d)...", attempt, maxAttempts)) + + // Hole Nonce + nonce, err := getNonce(ctx.DirectoryURL) + if err != nil { + log.Printf("[ACME] Fehler beim Abrufen des Nonce: %v", err) + consecutiveErrors++ + if consecutiveErrors >= maxConsecutiveErrors { + return fmt.Errorf("zu viele aufeinanderfolgende Fehler beim Abrufen des Nonce: %v", err) + } + time.Sleep(pollInterval) + continue + } + + // Erstelle JWS für Order-Abfrage (POST-as-GET: leerer Payload) + jws, err := createJWSForGet(keyPair, orderURL, keyID, nonce) + if err != nil { + log.Printf("[ACME] Fehler beim Erstellen des JWS: %v", err) + consecutiveErrors++ + if consecutiveErrors >= maxConsecutiveErrors { + return fmt.Errorf("zu viele aufeinanderfolgende Fehler beim Erstellen des JWS: %v", err) + } + time.Sleep(pollInterval) + continue + } + + // Sende POST-as-GET Request für Order-Status + req, err := http.NewRequest("POST", orderURL, bytes.NewBufferString(jws)) + if err != nil { + log.Printf("[ACME] Fehler beim Erstellen des Requests: %v", err) + consecutiveErrors++ + if consecutiveErrors >= maxConsecutiveErrors { + return fmt.Errorf("zu viele aufeinanderfolgende Fehler beim Erstellen des Requests: %v", err) + } + time.Sleep(pollInterval) + continue + } + + req.Header.Set("Content-Type", "application/jose+json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{ + Timeout: 10 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + log.Printf("[ACME] Fehler beim Senden des Requests: %v", err) + consecutiveErrors++ + if consecutiveErrors >= maxConsecutiveErrors { + return fmt.Errorf("zu viele aufeinanderfolgende Fehler beim Senden des Requests: %v", err) + } + time.Sleep(pollInterval) + continue + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("[ACME] Fehler beim Lesen der Response: %v", err) + consecutiveErrors++ + if consecutiveErrors >= maxConsecutiveErrors { + return fmt.Errorf("zu viele aufeinanderfolgende Fehler beim Lesen der Response: %v", err) + } + time.Sleep(pollInterval) + continue + } + + if resp.StatusCode != 200 { + log.Printf("[ACME] Fehler beim Abrufen des Order-Status (Status %d): %s", resp.StatusCode, string(body)) + consecutiveErrors++ + if consecutiveErrors >= maxConsecutiveErrors { + return fmt.Errorf("zu viele aufeinanderfolgende Fehler beim Abrufen des Order-Status (Status %d): %s", resp.StatusCode, string(body)) + } + time.Sleep(pollInterval) + continue + } + + // Parse Order Response + var orderStatusResponse map[string]interface{} + if err := json.Unmarshal(body, &orderStatusResponse); err != nil { + log.Printf("[ACME] Fehler beim Parsen der Order-Response: %v", err) + consecutiveErrors++ + if consecutiveErrors >= maxConsecutiveErrors { + return fmt.Errorf("zu viele aufeinanderfolgende Fehler beim Parsen der Order-Response: %v", err) + } + time.Sleep(pollInterval) + continue + } + + // Reset error counter bei erfolgreichem Request + consecutiveErrors = 0 + + // Prüfe Order-Status + status, ok := orderStatusResponse["status"].(string) + if !ok { + time.Sleep(pollInterval) + continue + } + + if status == "ready" { + statusCallback("Order ist bereit für Finalisierung") + return nil + } + + if status == "invalid" { + errorDetail := "" + if errors, ok := orderStatusResponse["error"].(map[string]interface{}); ok { + if detail, ok := errors["detail"].(string); ok { + errorDetail = detail + } + } + return fmt.Errorf("order ist ungültig: %s", errorDetail) + } + + if status == "valid" { + // Order ist bereits valid (Zertifikat wurde bereits ausgestellt) + statusCallback("Order ist bereits valid") + return nil + } + + // Status ist noch "pending" oder "processing" - weiter warten + if attempt < maxAttempts { + time.Sleep(pollInterval) + } + } + + return fmt.Errorf("order hat das Zeitlimit überschritten (Status noch nicht 'ready' oder 'valid')") +} + +// finalizeOrderAndGetCertificate finalisiert die Order und holt das Zertifikat +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(ctx, keyPair, keyID, orderURL, traceID, fqdnID, statusCallback); err != nil { + return "", "", fmt.Errorf("fehler beim Warten auf Order-Bereitschaft: %v", err) + } + + // Extrahiere finalize URL + finalizeURL, ok := orderResponse["finalize"].(string) + if !ok || finalizeURL == "" { + return "", "", fmt.Errorf("keine finalize URL in Order-Response gefunden") + } + + // Debug: Curl-Befehl für manuelle Abfrage + + // Extrahiere Domain aus identifiers + var domain string + if identifiers, ok := orderResponse["identifiers"].([]interface{}); ok && len(identifiers) > 0 { + if identifier, ok := identifiers[0].(map[string]interface{}); ok { + if value, ok := identifier["value"].(string); ok { + domain = value + } + } + } + + if domain == "" { + return "", "", fmt.Errorf("keine Domain in Order-Response gefunden") + } + + // Erstelle separates Key-Pair für das Zertifikat (muss unterschiedlich vom Account-Key sein) + certPrivateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", "", fmt.Errorf("fehler beim Generieren des Zertifikat-Keys: %v", err) + } + + // Erstelle CSR für die Domain mit dem separaten Key + csrDER, err := createCSR(certPrivateKey, domain) + if err != nil { + return "", "", fmt.Errorf("fehler beim Erstellen des CSR: %v", err) + } + + // Encode CSR als Base64URL + csrB64 := base64.RawURLEncoding.EncodeToString(csrDER) + + // Erstelle Payload für Finalisierung + payload := map[string]interface{}{ + "csr": csrB64, + } + + // Hole Nonce + nonce, err := getNonce(ctx.DirectoryURL) + if err != nil { + return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) + } + + // Erstelle JWS + jws, err := createJWS(keyPair, payload, finalizeURL, keyID, nonce) + if err != nil { + return "", "", fmt.Errorf("fehler beim Erstellen des JWS: %v", err) + } + + // Sende Finalize Request + req, err := http.NewRequest("POST", finalizeURL, bytes.NewBufferString(jws)) + if err != nil { + return "", "", fmt.Errorf("fehler beim Erstellen des Finalize-Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/jose+json") + req.Header.Set("Accept", "application/json") + + client := &http.Client{ + Timeout: 30 * time.Second, + } + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("fehler beim Senden des Finalize-Requests: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("fehler beim Lesen der Finalize-Response: %v", err) + } + + if resp.StatusCode != 200 { + return "", "", fmt.Errorf("fehler bei der Finalisierung (Status %d): %s", resp.StatusCode, string(body)) + } + + // Parse Finalize Response + var finalizeResponse map[string]interface{} + if err := json.Unmarshal(body, &finalizeResponse); err != nil { + return "", "", fmt.Errorf("fehler beim Parsen der Finalize-Response: %v", err) + } + + // Warte auf Zertifikat (Polling) + statusCallback("Warte auf Zertifikat...") + + certURL, ok := finalizeResponse["certificate"].(string) + if !ok || certURL == "" { + // Polling: Prüfe Order-Status bis certificate URL verfügbar ist + maxAttempts := 30 + pollInterval := 2 * time.Second + + for attempt := 1; attempt <= maxAttempts; attempt++ { + statusCallback(fmt.Sprintf("Prüfe Order-Status (%d/%d)...", attempt, maxAttempts)) + + nonce, err := getNonce(ctx.DirectoryURL) + if err != nil { + return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) + } + + jws, err := createJWSForGet(keyPair, orderURL, keyID, nonce) + if err != nil { + return "", "", fmt.Errorf("fehler beim Erstellen des JWS: %v", err) + } + + req, err := http.NewRequest("POST", orderURL, bytes.NewBufferString(jws)) + if err != nil { + return "", "", fmt.Errorf("fehler beim Erstellen des Order-Status-Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/jose+json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", "", fmt.Errorf("fehler beim Senden des Order-Status-Requests: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("fehler beim Lesen der Order-Status-Response: %v", err) + } + + if resp.StatusCode != 200 { + time.Sleep(pollInterval) + continue + } + + var orderStatusResponse map[string]interface{} + if err := json.Unmarshal(body, &orderStatusResponse); err != nil { + time.Sleep(pollInterval) + continue + } + + if cert, ok := orderStatusResponse["certificate"].(string); ok && cert != "" { + certURL = cert + break + } + + if attempt < maxAttempts { + time.Sleep(pollInterval) + } + } + } + + if certURL == "" { + return "", "", fmt.Errorf("keine Zertifikat-URL in Order-Response gefunden") + } + + statusCallback("Hole Zertifikat...") + + // Hole Zertifikat + nonce, err = getNonce(ctx.DirectoryURL) + if err != nil { + return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err) + } + + jws, err = createJWSForGet(keyPair, certURL, keyID, nonce) + if err != nil { + return "", "", fmt.Errorf("fehler beim Erstellen des JWS: %v", err) + } + + req, err = http.NewRequest("POST", certURL, bytes.NewBufferString(jws)) + if err != nil { + return "", "", fmt.Errorf("fehler beim Erstellen des Certificate-Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/jose+json") + req.Header.Set("Accept", "application/pem-certificate-chain") + + resp, err = client.Do(req) + if err != nil { + return "", "", fmt.Errorf("fehler beim Senden des Certificate-Requests: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return "", "", fmt.Errorf("fehler beim Abrufen des Zertifikats (Status %d): %s", resp.StatusCode, string(body)) + } + + certPEM, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", fmt.Errorf("fehler beim Lesen des Zertifikats: %v", err) + } + + // Konvertiere Private Key zu PEM (verwende den separaten Zertifikat-Key, nicht den Account-Key) + keyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(certPrivateKey), + }) + + return string(certPEM), string(keyPEM), nil +} + +// createCSR erstellt einen Certificate Signing Request +func createCSR(privateKey *rsa.PrivateKey, domain string) ([]byte, error) { + + template := &x509.CertificateRequest{ + Subject: pkix.Name{ + CommonName: domain, + }, + DNSNames: []string{domain}, + } + + csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, privateKey) + if err != nil { + return nil, fmt.Errorf("fehler beim Erstellen des CSR: %v", err) + } + + return csrDER, nil +} 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/cert_logger.go b/backend/cert_logger.go new file mode 100644 index 0000000..b410e32 --- /dev/null +++ b/backend/cert_logger.go @@ -0,0 +1,65 @@ +package main + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "log" + "os" + "path/filepath" + "time" +) + +var certLogger *log.Logger +var certLogFile *os.File + +// initCertLogger initialisiert das Logging-System für Zertifikatsanfragen +func initCertLogger() error { + logDir := "logs" + if err := os.MkdirAll(logDir, 0755); err != nil { + return fmt.Errorf("fehler beim Erstellen des Log-Verzeichnisses: %v", err) + } + + logFile := filepath.Join(logDir, fmt.Sprintf("cert-requests-%s.log", time.Now().Format("2006-01-02"))) + file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) + if err != nil { + return fmt.Errorf("fehler beim Öffnen der Log-Datei: %v", err) + } + + certLogFile = file + certLogger = log.New(file, "", log.LstdFlags) + return nil +} + +// generateTraceID generiert eine eindeutige TraceID für einen Vorgang +func generateTraceID() string { + bytes := make([]byte, 8) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} + +// logCertStatus schreibt einen Status-Eintrag in die Log-Datei +// traceID: Eindeutige ID für den Vorgang +// step: Name des Schritts (z.B. "DNS_PRÜFUNG", "REGISTER_AUFRUF", "ACCOUNT_ERSTELLUNG", "ORDER_ERSTELLUNG", "CHALLENGE_VALIDIERUNG", "ZERTIFIKAT_ERSTELLT") +// status: "OK" oder "FAILED" +// message: Fehlermeldung bei FAILED, leer bei OK +func logCertStatus(traceID, fqdnID, step, status, message string) { + if certLogger == nil { + return + } + + timestamp := time.Now().Format("2006-01-02 15:04:05") + if status == "OK" { + certLogger.Printf("[%s] TRACE_ID=%s FQDN_ID=%s VORGANG=%s STATUS=OK", timestamp, traceID, fqdnID, step) + } else { + certLogger.Printf("[%s] TRACE_ID=%s FQDN_ID=%s VORGANG=%s STATUS=FAILED ERROR=%s", timestamp, traceID, fqdnID, step, message) + } +} + +// closeCertLogger schließt die Log-Datei +func closeCertLogger() { + if certLogFile != nil { + certLogFile.Close() + } +} + diff --git a/backend/cert_parser.go b/backend/cert_parser.go new file mode 100644 index 0000000..e9b4272 --- /dev/null +++ b/backend/cert_parser.go @@ -0,0 +1,160 @@ +package main + +import ( + "crypto/x509" + "database/sql" + "encoding/pem" + "fmt" + "strings" + "time" +) + +// ParseCertificateExtrakt Ablaufdatum und CA-Status aus einem PEM-Zertifikat +// Gibt zurück: expiresAt, isIntermediate, error +func ParseCertificate(certPEM string) (time.Time, bool, error) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return time.Time{}, false, fmt.Errorf("fehler beim Dekodieren des PEM-Blocks") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return time.Time{}, false, fmt.Errorf("fehler beim Parsen des Zertifikats: %v", err) + } + + expiresAt := cert.NotAfter + // Ein Zertifikat ist Intermediate wenn IsCA=true ist + isIntermediate := cert.IsCA + + return expiresAt, isIntermediate, nil +} + +// SplitCertificateChain trennt eine PEM-Zertifikatskette in einzelne Zertifikate +// Gibt zurück: leafCert (PEM), intermediateCert (PEM), error +func SplitCertificateChain(certChainPEM string) (string, string, error) { + var leafCert string + var intermediateCert string + + // Dekodiere alle PEM-Blöcke aus der Kette + var blocks []*pem.Block + rest := []byte(certChainPEM) + for { + block, remaining := pem.Decode(rest) + if block == nil { + break + } + if block.Type == "CERTIFICATE" { + blocks = append(blocks, block) + } + rest = remaining + } + + if len(blocks) == 0 { + return "", "", fmt.Errorf("keine Zertifikate in der Kette gefunden") + } + + // Parse jedes Zertifikat und trenne nach IsCA + for _, block := range blocks { + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + continue // Überspringe ungültige Zertifikate + } + + // Encode zurück zu PEM + certPEM := string(pem.EncodeToMemory(block)) + + if cert.IsCA { + // Intermediate CA + if intermediateCert != "" { + // Wenn bereits ein Intermediate vorhanden ist, hänge es an (kann mehrere geben) + intermediateCert += "\n" + certPEM + } else { + intermediateCert = certPEM + } + } else { + // Leaf Certificate + if leafCert != "" { + // Wenn bereits ein Leaf vorhanden ist, verwende das erste (sollte nur eines geben) + continue + } + leafCert = certPEM + } + } + + return leafCert, intermediateCert, nil +} + +// GetCertificateIssuer extrahiert den Issuer-Namen aus einem PEM-Zertifikat +// Gibt zurück: issuerName (string), error +func GetCertificateIssuer(certPEM string) (string, error) { + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return "", fmt.Errorf("fehler beim Dekodieren des PEM-Blocks") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", fmt.Errorf("fehler beim Parsen des Zertifikats: %v", err) + } + + return cert.Issuer.String(), nil +} + +// GetProviderNameFromIssuer bestimmt den Provider-Namen basierend auf dem Issuer +func GetProviderNameFromIssuer(issuer string) string { + issuerLower := fmt.Sprintf("%v", issuer) + if strings.Contains(issuerLower, "Let's Encrypt") || strings.Contains(issuerLower, "letsencrypt") { + return "Let's Encrypt" + } + if strings.Contains(issuerLower, "DigiCert") { + return "DigiCert" + } + if strings.Contains(issuerLower, "GlobalSign") { + return "GlobalSign" + } + if strings.Contains(issuerLower, "Sectigo") { + return "Sectigo" + } + if strings.Contains(issuerLower, "GoDaddy") { + return "GoDaddy" + } + // Fallback: Gib den Issuer-Namen zurück + return issuer +} + +// CheckExistingValidCertificate prüft ob bereits ein gültiges Zertifikat für einen FQDN existiert +// Gibt zurück: exists (bool), expiresAt (time.Time), error +func CheckExistingValidCertificate(fqdnID, spaceID string) (bool, time.Time, error) { + var certPEM string + var expiresAtStr sql.NullString + err := db.QueryRow(` + SELECT certificate_pem, expires_at + FROM certificates + WHERE fqdn_id = ? AND space_id = ? AND status = 'issued' + ORDER BY created_at DESC + LIMIT 1 + `, fqdnID, spaceID).Scan(&certPEM, &expiresAtStr) + + if err != nil { + if err == sql.ErrNoRows { + return false, time.Time{}, nil + } + return false, time.Time{}, err + } + + // Wenn expires_at bereits in DB vorhanden ist, verwende es + if expiresAtStr.Valid && expiresAtStr.String != "" { + expiresAt, err := time.Parse("2006-01-02 15:04:05", expiresAtStr.String) + if err == nil { + return true, expiresAt, nil + } + } + + // Sonst parse das Zertifikat + expiresAt, _, err := ParseCertificate(certPEM) + if err != nil { + return true, time.Time{}, err + } + + return true, expiresAt, nil +} diff --git a/backend/config/providers/autodns.json b/backend/config/providers/autodns.json index a44eac1..db37672 100644 --- a/backend/config/providers/autodns.json +++ b/backend/config/providers/autodns.json @@ -1,5 +1,6 @@ { "enabled": false, + "acme_ready": false, "settings": { "password": "test", "username": "test" diff --git a/backend/config/providers/certigo-acmeproxy.json b/backend/config/providers/certigo-acmeproxy.json new file mode 100644 index 0000000..c32ab18 --- /dev/null +++ b/backend/config/providers/certigo-acmeproxy.json @@ -0,0 +1,7 @@ +{ + "enabled": true, + "acme_ready": true, + "settings": { + "baseURL": "http://openmailserver.de:8080" + } +} \ No newline at end of file diff --git a/backend/config/providers/dummy-ca.json b/backend/config/providers/dummy-ca.json index f96a9a4..26568c2 100644 --- a/backend/config/providers/dummy-ca.json +++ b/backend/config/providers/dummy-ca.json @@ -1,4 +1,5 @@ { "enabled": true, + "acme_ready": false, "settings": {} } \ No newline at end of file diff --git a/backend/config/providers/hetzner.json b/backend/config/providers/hetzner.json index 977815c..e856ba5 100644 --- a/backend/config/providers/hetzner.json +++ b/backend/config/providers/hetzner.json @@ -1,4 +1,5 @@ { "enabled": false, + "acme_ready": false, "settings": {} } \ No newline at end of file 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/go.mod b/backend/go.mod index 879097f..0ce1b23 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,9 +5,9 @@ go 1.24.0 toolchain go1.24.10 require ( - github.com/google/uuid v1.5.0 + github.com/go-jose/go-jose/v4 v4.1.3 + github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 github.com/mattn/go-sqlite3 v1.14.18 + golang.org/x/crypto v0.45.0 ) - -require golang.org/x/crypto v0.45.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 15da475..35ea170 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,5 +1,7 @@ -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI= diff --git a/backend/main.go b/backend/main.go index ff784c8..fb7dbee 100644 --- a/backend/main.go +++ b/backend/main.go @@ -216,16 +216,28 @@ type CreateSpaceRequest struct { } type FQDN struct { - ID string `json:"id"` - SpaceID string `json:"spaceId"` - FQDN string `json:"fqdn"` - Description string `json:"description"` - CreatedAt string `json:"createdAt"` + ID string `json:"id"` + SpaceID string `json:"spaceId"` + FQDN string `json:"fqdn"` + Description string `json:"description"` + CreatedAt string `json:"createdAt"` + AcmeProviderID string `json:"acmeProviderId,omitempty"` + AcmeUsername string `json:"acmeUsername,omitempty"` + AcmePassword string `json:"acmePassword,omitempty"` + AcmeFulldomain string `json:"acmeFulldomain,omitempty"` + AcmeSubdomain string `json:"acmeSubdomain,omitempty"` + AcmeChallengeToken string `json:"acmeChallengeToken,omitempty"` + AcmeEmail string `json:"acmeEmail,omitempty"` + AcmeKeyID string `json:"acmeKeyId,omitempty"` + RenewalEnabled bool `json:"renewalEnabled"` } type CreateFQDNRequest struct { FQDN string `json:"fqdn"` Description string `json:"description"` + ProviderID string `json:"providerId,omitempty"` + Acme bool `json:"acme,omitempty"` + AcmeEmail string `json:"acmeEmail,omitempty"` } type Extension struct { @@ -386,12 +398,20 @@ func initDB() { } // Prüfe und bereinige WAL-Dateien falls nötig + // Verwende längeres Timeout und ignoriere Fehler, da dies optional ist log.Println("Führe WAL-Checkpoint aus...") - ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) _, err = db.ExecContext(ctx, "PRAGMA wal_checkpoint(TRUNCATE)") cancel() if err != nil { - log.Printf("Warnung: WAL-Checkpoint fehlgeschlagen: %v", err) + log.Printf("Warnung: WAL-Checkpoint fehlgeschlagen (kann ignoriert werden): %v", err) + // Prüfe ob die DB von einem anderen Prozess gesperrt ist + if strings.Contains(err.Error(), "locked") || strings.Contains(err.Error(), "database is locked") { + log.Printf("Hinweis: Die Datenbank wird möglicherweise von einem anderen Prozess verwendet.") + log.Printf("Bitte schließen Sie alle anderen Programme, die die Datenbank öffnen (z.B. SQLite-Browser).") + } + } else { + log.Println("WAL-Checkpoint erfolgreich") } // Erstelle Tabelle falls sie nicht existiert @@ -430,6 +450,124 @@ func initDB() { log.Fatal("Fehler beim Erstellen der FQDN-Tabelle:", err) } + // Erweitere FQDN-Tabelle um ACME Challenge-Daten (Migration) + log.Println("Erweitere fqdns-Tabelle um ACME Felder...") + // SQLite unterstützt kein "IF NOT EXISTS" bei ALTER TABLE ADD COLUMN + // Prüfe stattdessen, ob die Spalte bereits existiert + var acmeProviderIDExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('fqdns') WHERE name='acme_provider_id'").Scan(&acmeProviderIDExists) + if err == nil && acmeProviderIDExists == 0 { + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "ALTER TABLE fqdns ADD COLUMN acme_provider_id TEXT") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von acme_provider_id: %v", err) + } + } + + var acmeUsernameExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('fqdns') WHERE name='acme_username'").Scan(&acmeUsernameExists) + if err == nil && acmeUsernameExists == 0 { + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "ALTER TABLE fqdns ADD COLUMN acme_username TEXT") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von acme_username: %v", err) + } + } + + var acmePasswordExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('fqdns') WHERE name='acme_password'").Scan(&acmePasswordExists) + if err == nil && acmePasswordExists == 0 { + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "ALTER TABLE fqdns ADD COLUMN acme_password TEXT") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von acme_password: %v", err) + } + } + + var acmeFulldomainExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('fqdns') WHERE name='acme_fulldomain'").Scan(&acmeFulldomainExists) + if err == nil && acmeFulldomainExists == 0 { + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "ALTER TABLE fqdns ADD COLUMN acme_fulldomain TEXT") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von acme_fulldomain: %v", err) + } + } + + var acmeSubdomainExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('fqdns') WHERE name='acme_subdomain'").Scan(&acmeSubdomainExists) + if err == nil && acmeSubdomainExists == 0 { + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "ALTER TABLE fqdns ADD COLUMN acme_subdomain TEXT") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von acme_subdomain: %v", err) + } + } + + var acmeChallengeTokenExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('fqdns') WHERE name='acme_challenge_token'").Scan(&acmeChallengeTokenExists) + if err == nil && acmeChallengeTokenExists == 0 { + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "ALTER TABLE fqdns ADD COLUMN acme_challenge_token TEXT") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von acme_challenge_token: %v", err) + } + } + + var acmeEmailExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('fqdns') WHERE name='acme_email'").Scan(&acmeEmailExists) + if err == nil && acmeEmailExists == 0 { + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "ALTER TABLE fqdns ADD COLUMN acme_email TEXT") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von acme_email: %v", err) + } + } + + var acmeKeyIDExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('fqdns') WHERE name='acme_key_id'").Scan(&acmeKeyIDExists) + if err == nil && acmeKeyIDExists == 0 { + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "ALTER TABLE fqdns ADD COLUMN acme_key_id TEXT") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von acme_key_id: %v", err) + } + } + + // Füge renewal_enabled Spalte hinzu (standardmäßig aktiviert) + var renewalEnabledExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('fqdns') WHERE name='renewal_enabled'").Scan(&renewalEnabledExists) + if err == nil && renewalEnabledExists == 0 { + log.Println("Füge renewal_enabled Spalte zu fqdns-Tabelle hinzu...") + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "ALTER TABLE fqdns ADD COLUMN renewal_enabled INTEGER DEFAULT 1") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von renewal_enabled: %v", err) + } else { + log.Println("renewal_enabled Spalte erfolgreich hinzugefügt") + } + } else if err == nil && renewalEnabledExists > 0 { + // Spalte existiert bereits - setze alle NULL-Werte auf 1 (Default) + log.Println("Setze NULL-Werte in renewal_enabled auf 1 (Default)...") + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "UPDATE fqdns SET renewal_enabled = 1 WHERE renewal_enabled IS NULL") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Setzen von NULL-Werten in renewal_enabled: %v", err) + } else { + log.Println("NULL-Werte in renewal_enabled erfolgreich auf 1 gesetzt") + } + } + // Erstelle CSR-Tabelle log.Println("Erstelle csrs-Tabelle...") createCSRTableSQL := ` @@ -506,30 +644,245 @@ func initDB() { // Erstelle Zertifikat-Tabelle log.Println("Erstelle certificates-Tabelle...") - createCertificateTableSQL := ` - CREATE TABLE IF NOT EXISTS certificates ( - id TEXT PRIMARY KEY, - fqdn_id TEXT NOT NULL, - space_id TEXT NOT NULL, - csr_id TEXT NOT NULL, - certificate_id TEXT NOT NULL, - provider_id TEXT NOT NULL, - certificate_pem TEXT, - status TEXT NOT NULL, - created_at DATETIME NOT NULL, - FOREIGN KEY (fqdn_id) REFERENCES fqdns(id) ON DELETE CASCADE, - FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, - FOREIGN KEY (csr_id) REFERENCES csrs(id) ON DELETE CASCADE - );` - - ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) - _, err = db.ExecContext(ctx, createCertificateTableSQL) - cancel() + // Prüfe ob Tabelle bereits existiert + var tableExists int + err = db.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='certificates'").Scan(&tableExists) if err != nil { - if strings.Contains(err.Error(), "database is locked") { - log.Fatal("Datenbank ist gesperrt. Bitte beenden Sie alle anderen Prozesse, die die Datenbank verwenden (z.B. andere go run main.go Instanzen).") + log.Printf("Warnung: Fehler beim Prüfen der certificates-Tabelle: %v", err) + tableExists = 0 + } + + if tableExists > 0 { + // Tabelle existiert bereits - prüfe ob Migration nötig ist + log.Println("certificates-Tabelle existiert bereits, prüfe Migration...") + + // Prüfe ob csr_id NOT NULL ist oder ob Foreign Key Constraint existiert + var csrIDNotNull int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('certificates') WHERE name='csr_id' AND \"notnull\"=1").Scan(&csrIDNotNull) + needsMigration := false + if err == nil && csrIDNotNull > 0 { + needsMigration = true + log.Println("Migriere certificates-Tabelle: csr_id ist NOT NULL, entferne Constraint...") + } + + // Prüfe ob Foreign Key Constraint für csr_id existiert (durch Prüfung der CREATE TABLE Statement) + var fkExists int + err = db.QueryRow(` + SELECT COUNT(*) FROM sqlite_master + WHERE type='table' AND name='certificates' + AND sql LIKE '%FOREIGN KEY%csr_id%' + `).Scan(&fkExists) + if err == nil && fkExists > 0 { + needsMigration = true + log.Println("Migriere certificates-Tabelle: Foreign Key Constraint für csr_id gefunden, entferne...") + } + + if needsMigration { + // SQLite unterstützt kein ALTER COLUMN um NOT NULL zu entfernen oder Foreign Keys zu löschen + // Wir müssen die Tabelle neu erstellen + log.Println("Starte Migration der certificates-Tabelle...") + + // Erstelle temporäre Tabelle mit neuer Struktur (ohne Foreign Key für csr_id) + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) + _, err = db.ExecContext(ctx, ` + CREATE TABLE certificates_new ( + id TEXT PRIMARY KEY, + fqdn_id TEXT NOT NULL, + space_id TEXT NOT NULL, + csr_id TEXT, + certificate_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + certificate_pem TEXT, + private_key_pem TEXT, + status TEXT NOT NULL, + expires_at DATETIME, + is_intermediate INTEGER DEFAULT 0, + parent_certificate_id TEXT, + created_at DATETIME NOT NULL, + FOREIGN KEY (fqdn_id) REFERENCES fqdns(id) ON DELETE CASCADE, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, + FOREIGN KEY (parent_certificate_id) REFERENCES certificates(id) ON DELETE CASCADE + ) + `) + cancel() + if err == nil { + // Prüfe welche Spalten in alter Tabelle existieren + var hasPrivateKey int + db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('certificates') WHERE name='private_key_pem'").Scan(&hasPrivateKey) + + // Prüfe welche Spalten in alter Tabelle existieren + var hasExpiresAt int + var hasIsIntermediate int + db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('certificates') WHERE name='expires_at'").Scan(&hasExpiresAt) + db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('certificates') WHERE name='is_intermediate'").Scan(&hasIsIntermediate) + + // Kopiere Daten + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) + if hasPrivateKey > 0 && hasExpiresAt > 0 && hasIsIntermediate > 0 { + // Kopiere mit allen neuen Spalten + _, err = db.ExecContext(ctx, ` + INSERT INTO certificates_new (id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, private_key_pem, status, expires_at, is_intermediate, created_at) + SELECT id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, private_key_pem, status, expires_at, is_intermediate, created_at + FROM certificates + `) + } else if hasPrivateKey > 0 { + // Kopiere mit private_key_pem, aber ohne expires_at/is_intermediate + _, err = db.ExecContext(ctx, ` + INSERT INTO certificates_new (id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, private_key_pem, status, created_at) + SELECT id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, private_key_pem, status, created_at + FROM certificates + `) + } else { + // Kopiere ohne private_key_pem, expires_at, is_intermediate + _, err = db.ExecContext(ctx, ` + INSERT INTO certificates_new (id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, status, created_at) + SELECT id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, status, created_at + FROM certificates + `) + } + cancel() + if err == nil { + // Lösche alte Tabelle + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) + _, err = db.ExecContext(ctx, "DROP TABLE certificates") + cancel() + if err == nil { + // Benenne neue Tabelle um + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) + _, err = db.ExecContext(ctx, "ALTER TABLE certificates_new RENAME TO certificates") + cancel() + if err == nil { + log.Println("Migration erfolgreich: certificates-Tabelle migriert (csr_id optional, Foreign Key entfernt)") + } else { + log.Printf("Warnung: Fehler beim Umbenennen der Tabelle: %v", err) + } + } else { + log.Printf("Warnung: Fehler beim Löschen der alten Tabelle: %v", err) + } + } else { + log.Printf("Warnung: Fehler beim Kopieren der Daten: %v", err) + // Lösche neue Tabelle + db.ExecContext(context.Background(), "DROP TABLE certificates_new") + } + } else { + log.Printf("Warnung: Fehler beim Erstellen der neuen Tabelle: %v", err) + } + } + + // Prüfe ob private_key_pem existiert + var privateKeyExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('certificates') WHERE name='private_key_pem'").Scan(&privateKeyExists) + if err == nil && privateKeyExists == 0 { + log.Println("Füge private_key_pem Spalte zu certificates-Tabelle hinzu...") + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) + _, err = db.ExecContext(ctx, "ALTER TABLE certificates ADD COLUMN private_key_pem TEXT") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von private_key_pem: %v", err) + } + } + + // Prüfe ob expires_at existiert + var expiresAtExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('certificates') WHERE name='expires_at'").Scan(&expiresAtExists) + if err == nil && expiresAtExists == 0 { + log.Println("Füge expires_at Spalte zu certificates-Tabelle hinzu...") + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) + _, err = db.ExecContext(ctx, "ALTER TABLE certificates ADD COLUMN expires_at DATETIME") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von expires_at: %v", err) + } + } + + // Prüfe ob is_intermediate existiert + var isIntermediateExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('certificates') WHERE name='is_intermediate'").Scan(&isIntermediateExists) + if err == nil && isIntermediateExists == 0 { + log.Println("Füge is_intermediate Spalte zu certificates-Tabelle hinzu...") + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) + _, err = db.ExecContext(ctx, "ALTER TABLE certificates ADD COLUMN is_intermediate INTEGER DEFAULT 0") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von is_intermediate: %v", err) + } + } + + // Prüfe ob parent_certificate_id existiert + var parentCertIDExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('certificates') WHERE name='parent_certificate_id'").Scan(&parentCertIDExists) + if err == nil && parentCertIDExists == 0 { + log.Println("Füge parent_certificate_id Spalte zu certificates-Tabelle hinzu...") + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) + _, err = db.ExecContext(ctx, "ALTER TABLE certificates ADD COLUMN parent_certificate_id TEXT") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von parent_certificate_id: %v", err) + } else { + log.Println("parent_certificate_id-Spalte erfolgreich hinzugefügt") + } + } + + // Prüfe ob cert_id_base64 existiert + var certIDBase64Exists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('certificates') WHERE name='cert_id_base64'").Scan(&certIDBase64Exists) + if err == nil && certIDBase64Exists == 0 { + log.Println("Füge cert_id_base64 Spalte zu certificates-Tabelle hinzu...") + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) + _, err = db.ExecContext(ctx, "ALTER TABLE certificates ADD COLUMN cert_id_base64 TEXT") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von cert_id_base64: %v", err) + } else { + log.Println("cert_id_base64-Spalte erfolgreich hinzugefügt") + } + } + + // Prüfe ob renewal_scheduled_at existiert + var renewalScheduledAtExists int + err = db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('certificates') WHERE name='renewal_scheduled_at'").Scan(&renewalScheduledAtExists) + if err == nil && renewalScheduledAtExists == 0 { + log.Println("Füge renewal_scheduled_at Spalte zu certificates-Tabelle hinzu...") + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) + _, err = db.ExecContext(ctx, "ALTER TABLE certificates ADD COLUMN renewal_scheduled_at DATETIME") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Hinzufügen von renewal_scheduled_at: %v", err) + } else { + log.Println("renewal_scheduled_at-Spalte erfolgreich hinzugefügt") + } + } + } else { + // Tabelle existiert nicht - erstelle sie neu + createCertificateTableSQL := ` + CREATE TABLE certificates ( + id TEXT PRIMARY KEY, + fqdn_id TEXT NOT NULL, + space_id TEXT NOT NULL, + csr_id TEXT, + certificate_id TEXT NOT NULL, + provider_id TEXT NOT NULL, + certificate_pem TEXT, + private_key_pem TEXT, + status TEXT NOT NULL, + expires_at DATETIME, + is_intermediate INTEGER DEFAULT 0, + parent_certificate_id TEXT, + created_at DATETIME NOT NULL, + FOREIGN KEY (fqdn_id) REFERENCES fqdns(id) ON DELETE CASCADE, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE, + FOREIGN KEY (parent_certificate_id) REFERENCES certificates(id) ON DELETE CASCADE + );` + + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, createCertificateTableSQL) + cancel() + if err != nil { + if strings.Contains(err.Error(), "database is locked") { + log.Fatal("Datenbank ist gesperrt. Bitte beenden Sie alle anderen Prozesse, die die Datenbank verwenden (z.B. andere go run main.go Instanzen).") + } + log.Fatal("Fehler beim Erstellen der Zertifikat-Tabelle:", err) } - log.Fatal("Fehler beim Erstellen der Zertifikat-Tabelle:", err) } // Erstelle Users-Tabelle @@ -556,6 +909,17 @@ func initDB() { } log.Println("Datenbank erfolgreich initialisiert") + + // Erstelle Index auf username für schnellere Lookups (falls nicht bereits vorhanden) + log.Println("Erstelle Index auf username...") + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, "CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)") + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Erstellen des username-Index: %v (kann ignoriert werden)", err) + } else { + log.Println("username-Index erfolgreich erstellt") + } // Füge is_admin Spalte hinzu falls nicht vorhanden ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) @@ -607,11 +971,16 @@ func initDB() { CREATE INDEX IF NOT EXISTS idx_audit_logs_action ON audit_logs(action); CREATE INDEX IF NOT EXISTS idx_audit_logs_resource_type ON audit_logs(resource_type);` - ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) _, err = db.ExecContext(ctx, createIndexSQL) cancel() if err != nil { - log.Printf("Warnung: Fehler beim Erstellen der Indizes: %v", err) + if strings.Contains(err.Error(), "locked") || strings.Contains(err.Error(), "database is locked") { + log.Printf("Warnung: Fehler beim Erstellen der Indizes (Datenbank gesperrt): %v", err) + log.Printf("Hinweis: Die Indizes werden beim nächsten Start erstellt.") + } else { + log.Printf("Warnung: Fehler beim Erstellen der Indizes: %v", err) + } } // Erstelle Default Admin-User falls nicht vorhanden @@ -643,14 +1012,17 @@ func initDB() { created_at DATETIME NOT NULL );` - ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + ctx, cancel = context.WithTimeout(context.Background(), time.Second*30) _, err = db.ExecContext(ctx, createPermissionGroupsTableSQL) cancel() if err != nil { - if strings.Contains(err.Error(), "database is locked") { - log.Fatal("datenbank ist gesperrt. Bitte beenden Sie alle anderen Prozesse, die die Datenbank verwenden.") + if strings.Contains(err.Error(), "locked") || strings.Contains(err.Error(), "database is locked") { + log.Printf("Warnung: Datenbank gesperrt beim Erstellen der permission_groups-Tabelle: %v", err) + log.Printf("Hinweis: Die Tabelle wird beim nächsten Start erstellt.") + log.Printf("Bitte schließen Sie alle anderen Programme, die die Datenbank öffnen (z.B. SQLite-Browser).") + } else { + log.Fatal("Fehler beim Erstellen der permission_groups-Tabelle:", err) } - log.Fatal("Fehler beim Erstellen der permission_groups-Tabelle:", err) } // Erstelle group_spaces-Tabelle für Space-Zuweisungen @@ -696,6 +1068,45 @@ func initDB() { } log.Println("Berechtigungssystem-Tabellen erfolgreich erstellt") + + // Erstelle renewal_queue-Tabelle für geplante Zertifikatserneuerungen + log.Println("Erstelle renewal_queue-Tabelle...") + createRenewalQueueTableSQL := ` + CREATE TABLE IF NOT EXISTS renewal_queue ( + id TEXT PRIMARY KEY, + certificate_id TEXT NOT NULL, + fqdn_id TEXT NOT NULL, + space_id TEXT NOT NULL, + scheduled_at DATETIME NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL, + processed_at DATETIME, + error_message TEXT, + FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE CASCADE, + FOREIGN KEY (fqdn_id) REFERENCES fqdns(id) ON DELETE CASCADE, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE + );` + + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, createRenewalQueueTableSQL) + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Erstellen der renewal_queue-Tabelle: %v", err) + } else { + log.Println("renewal_queue-Tabelle erfolgreich erstellt") + } + + // Erstelle Index für schnelle Abfragen + createRenewalQueueIndexSQL := ` + CREATE INDEX IF NOT EXISTS idx_renewal_queue_scheduled_at ON renewal_queue(scheduled_at); + CREATE INDEX IF NOT EXISTS idx_renewal_queue_status ON renewal_queue(status); + ` + ctx, cancel = context.WithTimeout(context.Background(), time.Second*5) + _, err = db.ExecContext(ctx, createRenewalQueueIndexSQL) + cancel() + if err != nil { + log.Printf("Warnung: Fehler beim Erstellen der Indizes für renewal_queue: %v", err) + } } func createDefaultAdmin() { @@ -713,11 +1124,29 @@ func createDefaultAdmin() { if count > 0 { log.Println("Admin-User mit UID 'admin' existiert bereits") // Stelle sicher, dass der Admin-User als Admin markiert ist - _, err = db.ExecContext(ctx, "UPDATE users SET is_admin = 1 WHERE id = 'admin'") - if err != nil { - log.Printf("Warnung: Konnte Admin-Status nicht setzen: %v", err) - } else { - log.Println("Admin-User ist als Administrator markiert") + // Versuche Admin-Status mit Retry-Logik zu setzen + maxRetries := 3 + for i := 0; i < maxRetries; i++ { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + _, err = db.ExecContext(ctx, "UPDATE users SET is_admin = 1 WHERE id = 'admin'") + cancel() + if err != nil { + if strings.Contains(err.Error(), "locked") || strings.Contains(err.Error(), "database is locked") { + if i < maxRetries-1 { + log.Printf("Warnung: Datenbank gesperrt, versuche erneut (%d/%d)...", i+1, maxRetries) + time.Sleep(time.Second * 2) + continue + } + log.Printf("Warnung: Konnte Admin-Status nicht setzen (Datenbank gesperrt): %v", err) + log.Printf("Hinweis: Die Datenbank wird möglicherweise von einem anderen Prozess verwendet.") + log.Printf("Bitte schließen Sie alle anderen Programme, die die Datenbank öffnen (z.B. SQLite-Browser).") + } else { + log.Printf("Warnung: Konnte Admin-Status nicht setzen: %v", err) + } + } else { + log.Println("Admin-User ist als Administrator markiert") + break + } } // Prüfe ob das Passwort noch "admin" ist (für Debugging) var storedHash string @@ -1298,7 +1727,7 @@ func getFqdnsHandler(w http.ResponseWriter, r *http.Request) { return } - rows, err := db.Query("SELECT id, space_id, fqdn, description, created_at FROM fqdns WHERE space_id = ? ORDER BY created_at DESC", spaceID) + rows, err := db.Query("SELECT id, space_id, fqdn, description, created_at, acme_provider_id, acme_username, acme_password, acme_fulldomain, acme_subdomain, acme_challenge_token, acme_email, acme_key_id, renewal_enabled FROM fqdns WHERE space_id = ? ORDER BY created_at DESC", spaceID) if err != nil { http.Error(w, "Fehler beim Abrufen der FQDNs", http.StatusInternalServerError) log.Printf("Fehler beim Abrufen der FQDNs: %v", err) @@ -1311,7 +1740,16 @@ func getFqdnsHandler(w http.ResponseWriter, r *http.Request) { var fqdn FQDN var createdAt time.Time var description sql.NullString - err := rows.Scan(&fqdn.ID, &fqdn.SpaceID, &fqdn.FQDN, &description, &createdAt) + var acmeProviderID sql.NullString + var acmeUsername sql.NullString + var acmePassword sql.NullString + var acmeFulldomain sql.NullString + var acmeSubdomain sql.NullString + var acmeChallengeToken sql.NullString + var acmeEmail sql.NullString + var acmeKeyID sql.NullString + var renewalEnabled sql.NullInt64 + err := rows.Scan(&fqdn.ID, &fqdn.SpaceID, &fqdn.FQDN, &description, &createdAt, &acmeProviderID, &acmeUsername, &acmePassword, &acmeFulldomain, &acmeSubdomain, &acmeChallengeToken, &acmeEmail, &acmeKeyID, &renewalEnabled) if err != nil { http.Error(w, "Fehler beim Lesen der Daten", http.StatusInternalServerError) log.Printf("Fehler beim Lesen der Daten: %v", err) @@ -1322,6 +1760,37 @@ func getFqdnsHandler(w http.ResponseWriter, r *http.Request) { } else { fqdn.Description = "" } + if acmeProviderID.Valid { + fqdn.AcmeProviderID = acmeProviderID.String + } + if acmeUsername.Valid { + fqdn.AcmeUsername = acmeUsername.String + } + if acmePassword.Valid { + fqdn.AcmePassword = acmePassword.String + } + if acmeFulldomain.Valid { + fqdn.AcmeFulldomain = acmeFulldomain.String + } + if acmeSubdomain.Valid { + fqdn.AcmeSubdomain = acmeSubdomain.String + } + if acmeChallengeToken.Valid { + fqdn.AcmeChallengeToken = acmeChallengeToken.String + } + if acmeEmail.Valid { + fqdn.AcmeEmail = acmeEmail.String + } + if acmeKeyID.Valid { + fqdn.AcmeKeyID = acmeKeyID.String + } + // Setze renewalEnabled (Standard: true wenn nicht gesetzt oder NULL) + if renewalEnabled.Valid { + fqdn.RenewalEnabled = renewalEnabled.Int64 == 1 + } else { + // Wenn NULL, dann ist es ein alter Eintrag ohne renewal_enabled Spalte -> Default true + fqdn.RenewalEnabled = true + } fqdn.CreatedAt = createdAt.Format(time.RFC3339) fqdns = append(fqdns, fqdn) } @@ -1420,10 +1889,73 @@ func createFqdnHandler(w http.ResponseWriter, r *http.Request) { id := uuid.New().String() createdAt := time.Now() + // ACME Challenge-Domain registrieren, falls ACME aktiviert ist + var acmeProviderID, acmeUsername, acmePassword, acmeFulldomain, acmeSubdomain, acmeEmail string + log.Printf("FQDN Creation Request - Acme: %v, ProviderID: %s, FQDN: %s, Email: %s", req.Acme, req.ProviderID, req.FQDN, req.AcmeEmail) + if req.Acme && req.ProviderID == "certigo-acmeproxy" { + // Prüfe ob Email angegeben wurde + if req.AcmeEmail == "" { + http.Error(w, "Email-Adresse ist für ACME FQDN erforderlich", http.StatusBadRequest) + return + } + acmeEmail = req.AcmeEmail + log.Printf("ACME FQDN erkannt, starte Registrierung...") + pm := providers.GetManager() + provider, exists := pm.GetProvider(req.ProviderID) + if !exists { + log.Printf("ACME Provider nicht gefunden: %s", req.ProviderID) + http.Error(w, "ACME Provider nicht gefunden", http.StatusBadRequest) + return + } + + // Prüfe ob Provider ACME-fähig ist + config, err := pm.GetProviderConfig(req.ProviderID) + if err != nil { + http.Error(w, "Fehler beim Abrufen der Provider-Konfiguration", http.StatusInternalServerError) + log.Printf("Fehler beim Abrufen der Provider-Konfiguration: %v", err) + return + } + + if !config.AcmeReady { + http.Error(w, "Provider ist nicht ACME-fähig", http.StatusBadRequest) + return + } + + // Prüfe ob Provider aktiviert ist + if !config.Enabled { + http.Error(w, "ACME Provider ist nicht aktiviert", http.StatusBadRequest) + return + } + + // Rufe RegisterChallengeDomain auf + acmeProxyProvider, ok := provider.(*providers.CertigoACMEProxyProvider) + if !ok { + http.Error(w, "Ungültiger ACME Provider-Typ", http.StatusInternalServerError) + return + } + + challengeResponse, err := acmeProxyProvider.RegisterChallengeDomain(config.Settings) + if err != nil { + http.Error(w, fmt.Sprintf("Fehler bei der ACME Challenge-Domain Registrierung: %v", err), http.StatusInternalServerError) + log.Printf("Fehler bei der ACME Challenge-Domain Registrierung: %v", err) + return + } + + acmeProviderID = req.ProviderID + acmeUsername = challengeResponse.Username + acmePassword = challengeResponse.Password + acmeFulldomain = challengeResponse.Fulldomain + acmeSubdomain = challengeResponse.Subdomain + + log.Printf("ACME Challenge-Domain registriert für FQDN %s: %s (Subdomain: %s)", req.FQDN, acmeFulldomain, acmeSubdomain) + } else { + log.Printf("Kein ACME FQDN - Acme: %v, ProviderID: %s", req.Acme, req.ProviderID) + } + // Speichere in Datenbank _, err = db.Exec( - "INSERT INTO fqdns (id, space_id, fqdn, description, created_at) VALUES (?, ?, ?, ?, ?)", - id, spaceID, req.FQDN, req.Description, createdAt, + "INSERT INTO fqdns (id, space_id, fqdn, description, created_at, acme_provider_id, acme_username, acme_password, acme_fulldomain, acme_subdomain, acme_email) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + id, spaceID, req.FQDN, req.Description, createdAt, acmeProviderID, acmeUsername, acmePassword, acmeFulldomain, acmeSubdomain, acmeEmail, ) if err != nil { http.Error(w, "Fehler beim Speichern des FQDN", http.StatusInternalServerError) @@ -1432,13 +1964,21 @@ func createFqdnHandler(w http.ResponseWriter, r *http.Request) { } newFqdn := FQDN{ - ID: id, - SpaceID: spaceID, - FQDN: req.FQDN, - Description: req.Description, - CreatedAt: createdAt.Format(time.RFC3339), + ID: id, + SpaceID: spaceID, + FQDN: req.FQDN, + Description: req.Description, + CreatedAt: createdAt.Format(time.RFC3339), + AcmeProviderID: acmeProviderID, + AcmeUsername: acmeUsername, + AcmePassword: acmePassword, + AcmeFulldomain: acmeFulldomain, + AcmeSubdomain: acmeSubdomain, + AcmeEmail: acmeEmail, } + log.Printf("Returning FQDN with ACME data - ProviderID: %s, Fulldomain: %s, Subdomain: %s", newFqdn.AcmeProviderID, newFqdn.AcmeFulldomain, newFqdn.AcmeSubdomain) + w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(newFqdn) @@ -1452,6 +1992,399 @@ func createFqdnHandler(w http.ResponseWriter, r *http.Request) { }, ipAddress, userAgent) } +func requestCertificateHandler(w http.ResponseWriter, r *http.Request) { + log.Printf("===== REQUEST CERTIFICATE HANDLER AUFGERUFEN =====") + log.Printf("Method: %s", r.Method) + log.Printf("URL: %s", r.URL.String()) + + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + log.Printf("OPTIONS Request - beende Handler") + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + spaceID := vars["spaceId"] + fqdnID := vars["fqdnId"] + + log.Printf("SpaceID: %s, FQDNID: %s", spaceID, fqdnID) + + if spaceID == "" || fqdnID == "" { + log.Printf("FEHLER: Space ID oder FQDN ID fehlt") + http.Error(w, "Space ID und FQDN ID sind erforderlich", http.StatusBadRequest) + return + } + + // Prüfe Berechtigung + userID, username := getUserFromRequest(r) + if userID == "" { + http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized) + return + } + + hasAccess, err := hasSpaceAccess(userID, spaceID) + if err != nil { + http.Error(w, "Fehler beim Prüfen der Berechtigung", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen der Berechtigung: %v", err) + return + } + + if !hasAccess { + http.Error(w, "Keine Berechtigung für diesen Space", http.StatusForbidden) + return + } + + // Lade FQDN aus Datenbank + var fqdn FQDN + var createdAt time.Time + var description sql.NullString + var acmeProviderID sql.NullString + var acmeUsername sql.NullString + var acmePassword sql.NullString + var acmeFulldomain sql.NullString + var acmeSubdomain sql.NullString + var acmeChallengeToken sql.NullString + + var acmeEmail sql.NullString + var acmeKeyID sql.NullString + var renewalEnabled sql.NullInt64 + + err = db.QueryRow( + "SELECT id, space_id, fqdn, description, created_at, acme_provider_id, acme_username, acme_password, acme_fulldomain, acme_subdomain, acme_challenge_token, acme_email, acme_key_id, renewal_enabled FROM fqdns WHERE id = ? AND space_id = ?", + fqdnID, spaceID, + ).Scan(&fqdn.ID, &fqdn.SpaceID, &fqdn.FQDN, &description, &createdAt, &acmeProviderID, &acmeUsername, &acmePassword, &acmeFulldomain, &acmeSubdomain, &acmeChallengeToken, &acmeEmail, &acmeKeyID, &renewalEnabled) + + if err == sql.ErrNoRows { + http.Error(w, "FQDN nicht gefunden", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "Fehler beim Laden des FQDN", http.StatusInternalServerError) + log.Printf("Fehler beim Laden des FQDN: %v", err) + return + } + + // Setze nullable Felder + if description.Valid { + fqdn.Description = description.String + } + if acmeProviderID.Valid { + fqdn.AcmeProviderID = acmeProviderID.String + } + if acmeUsername.Valid { + fqdn.AcmeUsername = acmeUsername.String + } + if acmePassword.Valid { + fqdn.AcmePassword = acmePassword.String + } + if acmeFulldomain.Valid { + fqdn.AcmeFulldomain = acmeFulldomain.String + } + if acmeSubdomain.Valid { + fqdn.AcmeSubdomain = acmeSubdomain.String + } + if acmeChallengeToken.Valid { + fqdn.AcmeChallengeToken = acmeChallengeToken.String + } + if acmeEmail.Valid { + fqdn.AcmeEmail = acmeEmail.String + } + if acmeKeyID.Valid { + fqdn.AcmeKeyID = acmeKeyID.String + } + // Setze renewalEnabled (Standard: true wenn nicht gesetzt oder NULL) + if renewalEnabled.Valid { + fqdn.RenewalEnabled = renewalEnabled.Int64 == 1 + } else { + // Wenn NULL, dann ist es ein alter Eintrag ohne renewal_enabled Spalte -> Default true + fqdn.RenewalEnabled = true + } + fqdn.CreatedAt = createdAt.Format(time.RFC3339) + + // Prüfe ob ACME-Daten vorhanden sind + log.Printf("Prüfe ACME-Daten: ProviderID=%s, Username=%s, Password=%s, Email=%s", fqdn.AcmeProviderID, fqdn.AcmeUsername, fqdn.AcmePassword, fqdn.AcmeEmail) + if fqdn.AcmeProviderID != "certigo-acmeproxy" || fqdn.AcmeUsername == "" || fqdn.AcmePassword == "" || fqdn.AcmeEmail == "" { + log.Printf("FEHLER: FQDN hat keine gültigen ACME-Daten (ProviderID: %s, Username: %s, Password: %s, Email: %s)", fqdn.AcmeProviderID, fqdn.AcmeUsername, fqdn.AcmePassword, fqdn.AcmeEmail) + http.Error(w, "FQDN hat keine gültigen ACME-Daten", http.StatusBadRequest) + return + } + log.Printf("ACME-Daten OK") + + // Prüfe ob bereits ein gültiges Zertifikat existiert (nur wenn nicht bestätigt) + log.Printf("Lese Request Body...") + var reqBody struct { + Confirmed bool `json:"confirmed"` + } + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + log.Printf("FEHLER beim Lesen des Request Body: %v", err) + // Setze default auf false wenn Body leer ist + reqBody.Confirmed = false + } + log.Printf("Request Body gelesen: Confirmed=%v", reqBody.Confirmed) + + if !reqBody.Confirmed { + log.Printf("Prüfe auf existierende gültige Zertifikate...") + hasValidCert, expiresAt, err := CheckExistingValidCertificate(fqdnID, spaceID) + if err != nil { + log.Printf("Fehler beim Prüfen bestehender Zertifikate: %v", err) + // Weiter mit Request, da Prüfung fehlgeschlagen ist + } else if hasValidCert { + log.Printf("Gültiges Zertifikat gefunden, läuft ab: %v", expiresAt) + // Prüfe ob Zertifikat noch gültig ist + now := time.Now() + if expiresAt.After(now) { + log.Printf("Zertifikat ist noch gültig - sende Bestätigungsanfrage an Frontend") + // Zertifikat ist noch gültig - sende Bestätigungsanfrage an Frontend + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "requiresConfirmation": true, + "message": "Es existiert bereits ein gültiges Zertifikat für diesen FQDN", + "expiresAt": expiresAt.Format(time.RFC3339), + "expiresAtFormatted": expiresAt.Format("02.01.2006 15:04:05"), + }) + return + } + } else { + log.Printf("Kein gültiges Zertifikat gefunden - fahre fort mit Request") + // Kein Zertifikat vorhanden - fahre direkt mit Request fort + // (Request wird unten ausgeführt) + } + } else { + log.Printf("Request wurde bestätigt - fahre fort") + } + + // Lade Provider-Konfiguration + log.Printf("Lade Provider-Konfiguration...") + pm := providers.GetManager() + provider, exists := pm.GetProvider("certigo-acmeproxy") + if !exists || provider == nil { + log.Printf("FEHLER: ACME Provider nicht gefunden") + http.Error(w, "ACME Provider nicht gefunden", http.StatusInternalServerError) + return + } + log.Printf("Provider gefunden") + + config, err := pm.GetProviderConfig("certigo-acmeproxy") + if err != nil { + log.Printf("FEHLER beim Laden der Provider-Konfiguration: %v", err) + http.Error(w, "Fehler beim Laden der Provider-Konfiguration", http.StatusInternalServerError) + return + } + log.Printf("Provider-Konfiguration geladen") + + // Type-Assertion für CertigoACMEProxyProvider + log.Printf("Führe Type-Assertion für Provider durch...") + acmeProxyProvider, ok := provider.(*providers.CertigoACMEProxyProvider) + if !ok { + log.Printf("FEHLER: Ungültiger Provider-Typ") + http.Error(w, "Ungültiger Provider-Typ", http.StatusInternalServerError) + return + } + log.Printf("Type-Assertion erfolgreich") + + // Erstelle Update-Funktion für den Token + log.Printf("Erstelle Update- und Cleanup-Funktionen...") + updateTokenFunc := func(token string) error { + log.Printf("[updateTokenFunc] Speichere Token in Datenbank...") + // Speichere Token in Datenbank + _, err := db.Exec("UPDATE fqdns SET acme_challenge_token = ? WHERE id = ?", token, fqdnID) + if err != nil { + return fmt.Errorf("fehler beim Speichern des Challenge-Tokens: %v", err) + } + + // Sende Token an certigo-acmeproxy + err = acmeProxyProvider.UpdateChallengeToken(fqdn.AcmeUsername, fqdn.AcmePassword, token, config.Settings) + if err != nil { + return fmt.Errorf("fehler beim Senden des Tokens an certigo-acmeproxy: %v", err) + } + + return nil + } + + // Erstelle Cleanup-Funktion für den Token (wird aufgerufen, wenn Challenge invalid ist) + cleanupTokenFunc := func() error { + // Entferne Token aus Datenbank + _, err := db.Exec("UPDATE fqdns SET acme_challenge_token = NULL WHERE id = ?", fqdnID) + if err != nil { + return fmt.Errorf("fehler beim Entfernen des Challenge-Tokens: %v", err) + } + return nil + } + + // Beantrage Zertifikat + baseFqdn := strings.TrimPrefix(fqdn.FQDN, "*.") + + // Generiere TraceID für diesen Request + traceID := generateTraceID() + logCertStatus(traceID, fqdnID, "ZERTIFIKATSANFRAGE_START", "OK", "") + + // Initialisiere Schritt-Status für Frontend + stepStatus := make(map[string]string) + stepStatus["ZERTIFIKATSANFRAGE_START"] = "success" + + log.Printf("===== STARTE ZERTIFIKATSANFRAGE =====") + log.Printf("FQDN: %s", baseFqdn) + log.Printf("FQDN ID: %s", fqdnID) + log.Printf("TraceID: %s", traceID) + log.Printf("Email: %s", fqdn.AcmeEmail) + log.Printf("Existing KeyID: %s", fqdn.AcmeKeyID) + + // Status-Callback für Live-Updates (mit Console-Log für Debugging) + var statusMessages []string + statusCallback := func(status string) { + statusMessages = append(statusMessages, status) + 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(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" + log.Printf("===== FEHLER BEIM ZERTIFIKATSANFRAGE =====") + log.Printf("Fehler: %v", err) + http.Error(w, fmt.Sprintf("Fehler beim Beantragen des Zertifikats: %v", err), http.StatusInternalServerError) + return + } + logCertStatus(traceID, fqdnID, "ZERTIFIKATSANFRAGE_GESAMT", "OK", "") + stepStatus["ZERTIFIKATSANFRAGE_GESAMT"] = "success" + // Merge stepStatus from result with our initial stepStatus + if result.StepStatus != nil { + for k, v := range result.StepStatus { + stepStatus[k] = v + } + } + log.Printf("===== ZERTIFIKATSANFRAGE ERFOLGREICH =====") + log.Printf("KeyID: %s", result.KeyID) + log.Printf("OrderURL: %s", result.OrderURL) + log.Printf("Certificate vorhanden: %v", result.Certificate != "") + log.Printf("PrivateKey vorhanden: %v", result.PrivateKey != "") + + // Speichere KeyID in Datenbank (falls neu erstellt) + if result.KeyID != "" && result.KeyID != fqdn.AcmeKeyID { + _, err = db.Exec("UPDATE fqdns SET acme_key_id = ? WHERE id = ?", result.KeyID, fqdnID) + if err != nil { + log.Printf("Warnung: Fehler beim Speichern der KeyID: %v", err) + } + } + + // Speichere Zertifikat in Datenbank (falls erfolgreich erstellt) + if result.Certificate != "" && result.PrivateKey != "" { + certID := uuid.New().String() + certificateID := result.OrderURL + if certificateID == "" { + certificateID = certID // Fallback falls keine Order-URL vorhanden + } + // Speichere createdAt in UTC + createdAt := time.Now().UTC().Format("2006-01-02 15:04:05") + + // Parse Zertifikat um Ablaufdatum und CA-Status zu extrahieren + expiresAt, isIntermediate, parseErr := ParseCertificate(result.Certificate) + var expiresAtStr string + var isIntermediateInt int + if parseErr == nil { + // Speichere expiresAt in UTC + expiresAtStr = expiresAt.UTC().Format("2006-01-02 15:04:05") + if isIntermediate { + isIntermediateInt = 1 + } + } + + // Speichere Zertifikat (komplette Kette) + _, err = db.Exec(` + INSERT INTO certificates (id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, private_key_pem, status, expires_at, is_intermediate, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, certID, fqdnID, spaceID, nil, certificateID, "certigo-acmeproxy", result.Certificate, result.PrivateKey, "issued", expiresAtStr, isIntermediateInt, createdAt) + + if err != nil { + log.Printf("Warnung: Fehler beim Speichern des Zertifikats in der Datenbank: %v", err) + + // Prüfe ob es ein NOT NULL oder Foreign Key Problem ist + if strings.Contains(err.Error(), "NOT NULL") || strings.Contains(err.Error(), "FOREIGN KEY") { + // Versuche ohne csr_id Spalte (falls sie nicht existiert oder optional ist) + _, err = db.Exec(` + INSERT INTO certificates (id, fqdn_id, space_id, certificate_id, provider_id, certificate_pem, private_key_pem, status, expires_at, is_intermediate, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, certID, fqdnID, spaceID, certificateID, "certigo-acmeproxy", result.Certificate, result.PrivateKey, "issued", expiresAtStr, isIntermediateInt, createdAt) + + if err != nil { + log.Printf("Warnung: Fehler beim Speichern ohne csr_id: %v", err) + // Letzter Versuch: Verwende leeren String (falls NULL nicht erlaubt, aber leerer String OK ist) + _, err = db.Exec(` + INSERT INTO certificates (id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, private_key_pem, status, expires_at, is_intermediate, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, certID, fqdnID, spaceID, "", certificateID, "certigo-acmeproxy", result.Certificate, result.PrivateKey, "issued", expiresAtStr, isIntermediateInt, createdAt) + if err != nil { + log.Printf("Fehler: Konnte Zertifikat nicht in Datenbank speichern: %v", err) + } else { + log.Printf("Zertifikat erfolgreich in Datenbank gespeichert (ID: %s, mit leerem csr_id)", certID) + } + } else { + log.Printf("Zertifikat erfolgreich in Datenbank gespeichert (ID: %s, ohne csr_id)", certID) + } + } else { + log.Printf("Fehler: Unerwarteter Fehler beim Speichern des Zertifikats: %v", err) + } + } else { + log.Printf("Zertifikat erfolgreich in Datenbank gespeichert (ID: %s)", certID) + } + + // Prüfe ob RenewalInfo aktiviert ist und verarbeite RenewalInfo im Hintergrund + renewalEnabledValue := true // Default + if renewalEnabled.Valid { + renewalEnabledValue = renewalEnabled.Int64 == 1 + } + + if renewalEnabledValue { + // Verarbeite RenewalInfo im Hintergrund (asynchron) + go func() { + if err := ProcessRenewalInfoForCertificate(result.Certificate, certID, fqdnID, spaceID, true); err != nil { + log.Printf("Fehler beim Verarbeiten der RenewalInfo (wird ignoriert): %v", err) + } + }() + } else { + log.Printf("RenewalInfo wird übersprungen - renewal_enabled ist für FQDN %s deaktiviert", fqdnID) + } + } + + // Erfolgreiche Antwort + response := map[string]interface{}{ + "success": true, + "message": "Zertifikat erfolgreich beantragt", + "certificate": result.Certificate, + "privateKey": result.PrivateKey, + "keyId": result.KeyID, + "status": result.Status, + "stepStatus": stepStatus, + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + + // Audit-Log + ipAddress, userAgent := getRequestInfo(r) + auditService.Track(r.Context(), "CREATE", "certificate", fqdnID, userID, username, map[string]interface{}{ + "fqdn": fqdn.FQDN, + "spaceId": spaceID, + "message": fmt.Sprintf("Zertifikat beantragt für FQDN: %s", fqdn.FQDN), + }, ipAddress, userAgent) +} + func deleteFqdnHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Access-Control-Allow-Origin", "*") @@ -2309,550 +3242,16 @@ func swaggerUIHandler(w http.ResponseWriter, r *http.Request) { } func openAPIHandler(w http.ResponseWriter, r *http.Request) { - // Lese die OpenAPI YAML Datei - openAPIContent := `openapi: 3.0.3 -info: - title: Certigo Addon API - description: API für die Verwaltung von Spaces, FQDNs und Certificate Signing Requests (CSRs) - version: 1.0.0 - contact: - name: Certigo Addon -servers: - - url: http://localhost:8080/api - description: Local development server -paths: - /health: - get: - summary: System Health Check - description: Prüft den Systemstatus des Backends - tags: [System] - responses: - '200': - description: System ist erreichbar - content: - application/json: - schema: - $ref: '#/components/schemas/HealthResponse' - /stats: - get: - summary: Statistiken abrufen - description: Ruft Statistiken über die Anzahl der Spaces, FQDNs und CSRs ab - tags: [System] - responses: - '200': - description: Statistiken erfolgreich abgerufen - content: - application/json: - schema: - $ref: '#/components/schemas/StatsResponse' - /spaces: - get: - summary: Alle Spaces abrufen - description: Ruft eine Liste aller Spaces ab - tags: [Spaces] - responses: - '200': - description: Liste der Spaces - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/Space' - post: - summary: Space erstellen - description: Erstellt einen neuen Space - tags: [Spaces] - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateSpaceRequest' - responses: - '201': - description: Space erfolgreich erstellt - content: - application/json: - schema: - $ref: '#/components/schemas/Space' - '400': - description: Ungültige Anfrage - /spaces/{id}: - delete: - summary: Space löschen - description: Löscht einen Space. Wenn der Space FQDNs enthält, muss der Parameter deleteFqdns=true gesetzt werden. - tags: [Spaces] - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - name: deleteFqdns - in: query - required: false - schema: - type: boolean - default: false - description: Wenn true, werden alle FQDNs des Spaces mitgelöscht - responses: - '200': - description: Space erfolgreich gelöscht - content: - application/json: - schema: - $ref: '#/components/schemas/MessageResponse' - '404': - description: Space nicht gefunden - '409': - description: Space enthält noch FQDNs - /spaces/{id}/fqdns/count: - get: - summary: FQDN-Anzahl abrufen - description: Ruft die Anzahl der FQDNs für einen Space ab - tags: [FQDNs] - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - responses: - '200': - description: Anzahl der FQDNs - content: - application/json: - schema: - $ref: '#/components/schemas/CountResponse' - /spaces/{id}/fqdns: - get: - summary: Alle FQDNs eines Spaces abrufen - description: Ruft alle FQDNs für einen Space ab - tags: [FQDNs] - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - responses: - '200': - description: Liste der FQDNs - content: - application/json: - schema: - type: array - items: - $ref: '#/components/schemas/FQDN' - '404': - description: Space nicht gefunden - post: - summary: FQDN erstellen - description: Erstellt einen neuen FQDN innerhalb eines Spaces - tags: [FQDNs] - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateFQDNRequest' - responses: - '201': - description: FQDN erfolgreich erstellt - content: - application/json: - schema: - $ref: '#/components/schemas/FQDN' - '400': - description: Ungültige Anfrage - '404': - description: Space nicht gefunden - '409': - description: FQDN existiert bereits in diesem Space - delete: - summary: Alle FQDNs eines Spaces löschen - description: Löscht alle FQDNs eines Spaces - tags: [FQDNs] - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - responses: - '200': - description: Alle FQDNs erfolgreich gelöscht - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - /spaces/{id}/fqdns/{fqdnId}: - delete: - summary: FQDN löschen - description: Löscht einen einzelnen FQDN - tags: [FQDNs] - parameters: - - name: id - in: path - required: true - schema: - type: string - format: uuid - - name: fqdnId - in: path - required: true - schema: - type: string - format: uuid - responses: - '200': - description: FQDN erfolgreich gelöscht - content: - application/json: - schema: - $ref: '#/components/schemas/MessageResponse' - '404': - description: FQDN nicht gefunden - /fqdns: - delete: - summary: Alle FQDNs global löschen - description: Löscht alle FQDNs aus allen Spaces. Erfordert confirm=true Query-Parameter. - tags: [FQDNs] - parameters: - - name: confirm - in: query - required: true - schema: - type: boolean - description: Muss true sein, um die Operation auszuführen - responses: - '200': - description: Alle FQDNs erfolgreich gelöscht - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '400': - description: Bestätigung erforderlich - /csrs: - delete: - summary: Alle CSRs global löschen - description: Löscht alle CSRs aus allen Spaces. Erfordert confirm=true Query-Parameter. - tags: [CSRs] - 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 CSRs erfolgreich gelöscht - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '400': - description: Bestätigung erforderlich - /spaces/{spaceId}/fqdns/{fqdnId}/csr: - post: - summary: CSR hochladen - description: Lädt einen CSR (Certificate Signing Request) im PEM-Format hoch - tags: [CSRs] - parameters: - - name: spaceId - in: path - required: true - schema: - type: string - format: uuid - - name: fqdnId - in: path - required: true - schema: - type: string - format: uuid - requestBody: - required: true - content: - multipart/form-data: - schema: - type: object - required: [csr, spaceId, fqdn] - properties: - csr: - type: string - format: binary - description: CSR-Datei im PEM-Format - spaceId: - type: string - description: ID des Spaces - fqdn: - type: string - description: Name des FQDNs - responses: - '201': - description: CSR erfolgreich hochgeladen - content: - application/json: - schema: - $ref: '#/components/schemas/CSR' - '400': - description: Ungültige Anfrage oder ungültiges CSR-Format - '404': - description: Space oder FQDN nicht gefunden - get: - summary: CSR(s) abrufen - description: Ruft CSR(s) für einen FQDN ab. Mit latest=true wird nur der neueste CSR zurückgegeben. - tags: [CSRs] - parameters: - - name: spaceId - in: path - required: true - schema: - type: string - format: uuid - - name: fqdnId - in: path - required: true - schema: - type: string - format: uuid - - name: latest - in: query - required: false - schema: - type: boolean - default: false - description: Wenn true, wird nur der neueste CSR zurückgegeben - responses: - '200': - description: CSR(s) erfolgreich abgerufen - content: - application/json: - schema: - oneOf: - - $ref: '#/components/schemas/CSR' - - type: array - items: - $ref: '#/components/schemas/CSR' - '404': - description: FQDN nicht gefunden -components: - schemas: - HealthResponse: - type: object - properties: - status: - type: string - example: "ok" - message: - type: string - example: "Backend ist erreichbar" - time: - type: string - format: date-time - example: "2024-01-15T10:30:00Z" - StatsResponse: - type: object - properties: - spaces: - type: integer - example: 5 - fqdns: - type: integer - example: 12 - csrs: - type: integer - example: 7 - certificates: - type: integer - example: 8 - users: - type: integer - example: 3 - Space: - type: object - properties: - id: - type: string - format: uuid - example: "550e8400-e29b-41d4-a716-446655440000" - name: - type: string - example: "Mein Space" - description: - type: string - example: "Beschreibung des Spaces" - createdAt: - type: string - format: date-time - example: "2024-01-15T10:30:00Z" - CreateSpaceRequest: - type: object - required: [name] - properties: - name: - type: string - example: "Mein Space" - description: - type: string - example: "Beschreibung des Spaces" - FQDN: - type: object - properties: - id: - type: string - format: uuid - example: "660e8400-e29b-41d4-a716-446655440000" - spaceId: - type: string - format: uuid - example: "550e8400-e29b-41d4-a716-446655440000" - fqdn: - type: string - example: "example.com" - description: - type: string - example: "Beschreibung des FQDN" - createdAt: - type: string - format: date-time - example: "2024-01-15T10:30:00Z" - CreateFQDNRequest: - type: object - required: [fqdn] - properties: - fqdn: - type: string - example: "example.com" - description: - type: string - example: "Beschreibung des FQDN" - Extension: - type: object - properties: - id: - type: string - example: "2.5.29.37" - oid: - type: string - example: "2.5.29.37" - name: - type: string - example: "X509v3 Extended Key Usage" - critical: - type: boolean - example: false - value: - type: string - example: "301406082b0601050507030106082b06010505070302" - description: - type: string - example: "TLS Web Server Authentication" - purposes: - type: array - items: - type: string - example: ["TLS Web Server Authentication", "TLS Web Client Authentication"] - CSR: - type: object - properties: - id: - type: string - format: uuid - example: "770e8400-e29b-41d4-a716-446655440000" - fqdnId: - type: string - format: uuid - example: "660e8400-e29b-41d4-a716-446655440000" - spaceId: - type: string - format: uuid - example: "550e8400-e29b-41d4-a716-446655440000" - fqdn: - type: string - example: "example.com" - csrPem: - type: string - example: "-----BEGIN CERTIFICATE REQUEST-----" - subject: - type: string - example: "CN=example.com" - publicKeyAlgorithm: - type: string - example: "RSA" - signatureAlgorithm: - type: string - example: "SHA256-RSA" - keySize: - type: integer - example: 2048 - dnsNames: - type: array - items: - type: string - example: ["example.com", "www.example.com"] - emailAddresses: - type: array - items: - type: string - example: ["admin@example.com"] - ipAddresses: - type: array - items: - type: string - example: ["192.168.1.1"] - uris: - type: array - items: - type: string - example: ["https://example.com"] - extensions: - type: array - items: - $ref: '#/components/schemas/Extension' - createdAt: - type: string - format: date-time - example: "2024-01-15T10:30:00Z" - MessageResponse: - type: object - properties: - message: - type: string - example: "Operation erfolgreich" - CountResponse: - type: object - properties: - count: - type: integer - example: 5 - DeleteResponse: - type: object - properties: - message: - type: string - example: "Alle FQDNs erfolgreich gelöscht" - deletedCount: - type: integer - example: 5` + // Lese die OpenAPI YAML Datei vom Dateisystem + openAPIContent, err := os.ReadFile("openapi.yaml") + if err != nil { + log.Printf("Fehler beim Lesen der openapi.yaml Datei: %v", err) + http.Error(w, "OpenAPI Spezifikation nicht gefunden", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/x-yaml") - w.Write([]byte(openAPIContent)) + w.Write(openAPIContent) } // User Handler Functions @@ -5129,8 +5528,9 @@ func loginHandler(w http.ResponseWriter, r *http.Request) { var user User var storedHash string var enabled int - err = db.QueryRowContext(ctx, "SELECT id, username, email, password_hash, enabled, created_at FROM users WHERE username = ?", username). - Scan(&user.ID, &user.Username, &user.Email, &storedHash, &enabled, &user.CreatedAt) + var isAdmin int + err = db.QueryRowContext(ctx, "SELECT id, username, email, password_hash, enabled, is_admin, created_at FROM users WHERE username = ?", username). + Scan(&user.ID, &user.Username, &user.Email, &storedHash, &enabled, &isAdmin, &user.CreatedAt) if err != nil { if err == sql.ErrNoRows { log.Printf("Benutzer nicht gefunden: %s", username) @@ -5144,6 +5544,10 @@ func loginHandler(w http.ResponseWriter, r *http.Request) { return } + // Setze isAdmin und enabled Felder + user.IsAdmin = isAdmin == 1 + user.Enabled = enabled == 1 + // Prüfe ob User aktiviert ist if enabled == 0 { log.Printf("Login-Versuch für deaktivierten Benutzer: %s", username) @@ -5177,6 +5581,12 @@ func loginHandler(w http.ResponseWriter, r *http.Request) { func main() { log.Println("Starte certigo-addon Backend...") + // Initialisiere Logging-System + if err := initCertLogger(); err != nil { + log.Printf("Warnung: Fehler beim Initialisieren des Logging-Systems: %v", err) + } + defer closeCertLogger() + // Initialisiere Datenbank log.Println("Initialisiere Datenbank...") initDB() @@ -5191,6 +5601,15 @@ func main() { pm.RegisterProvider(providers.NewDummyCAProvider()) 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() r := mux.NewRouter() @@ -5246,6 +5665,16 @@ func main() { api.HandleFunc("/spaces/{spaceId}/fqdns/{fqdnId}/csr/sign", basicAuthMiddleware(signCSRHandler)).Methods("POST", "OPTIONS") api.HandleFunc("/spaces/{spaceId}/fqdns/{fqdnId}/certificates", basicAuthMiddleware(getCertificatesHandler)).Methods("GET", "OPTIONS") api.HandleFunc("/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/refresh", basicAuthMiddleware(refreshCertificateHandler)).Methods("POST", "OPTIONS") + api.HandleFunc("/spaces/{spaceId}/fqdns/{fqdnId}/request-certificate", basicAuthMiddleware(requestCertificateHandler)).Methods("POST", "OPTIONS") + api.HandleFunc("/spaces/{spaceId}/fqdns/{fqdnId}/renewal-enabled", basicAuthMiddleware(updateFqdnRenewalEnabledHandler)).Methods("PUT", "OPTIONS") + + // 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") + api.HandleFunc("/renewal-queue/test/trigger", basicAuthMiddleware(triggerRenewalQueueHandler)).Methods("POST", "OPTIONS") // Audit Log Routes api.HandleFunc("/audit-logs", basicAuthMiddleware(getAuditLogsHandler)).Methods("GET", "OPTIONS") @@ -5287,6 +5716,7 @@ func getProvidersHandler(w http.ResponseWriter, r *http.Request) { DisplayName: provider.GetDisplayName(), Description: provider.GetDescription(), Enabled: config.Enabled, + AcmeReady: config.AcmeReady, Settings: provider.GetRequiredSettings(), } providerMap[id] = providerInfo @@ -5692,12 +6122,14 @@ func getCertificatesHandler(w http.ResponseWriter, r *http.Request) { return } - // Hole alle Zertifikate für diesen FQDN - rows, err := db.Query(` - SELECT id, csr_id, certificate_id, provider_id, certificate_pem, status, created_at + // Hole alle Zertifikate für diesen FQDN, sortiert nach Ablaufdatum (neueste zuerst) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + rows, err := db.QueryContext(ctx, ` + SELECT id, csr_id, certificate_id, provider_id, certificate_pem, private_key_pem, status, expires_at, is_intermediate, created_at FROM certificates WHERE fqdn_id = ? AND space_id = ? - ORDER BY created_at DESC + ORDER BY expires_at DESC, created_at DESC `, fqdnID, spaceID) if err != nil { @@ -5709,22 +6141,101 @@ func getCertificatesHandler(w http.ResponseWriter, r *http.Request) { var certificates []map[string]interface{} for rows.Next() { - var id, csrID, certID, providerID, certPEM, status, createdAt string - err := rows.Scan(&id, &csrID, &certID, &providerID, &certPEM, &status, &createdAt) + var id, certID, providerID, certPEM, status, createdAt string + var csrID sql.NullString + var privateKeyPEM sql.NullString + var expiresAtStr sql.NullString + var isIntermediateInt int + err := rows.Scan(&id, &csrID, &certID, &providerID, &certPEM, &privateKeyPEM, &status, &expiresAtStr, &isIntermediateInt, &createdAt) if err != nil { log.Printf("Fehler beim Scannen der Zertifikat-Zeile: %v", err) continue } - certificates = append(certificates, map[string]interface{}{ + // Parse und formatiere createdAt als ISO 8601 mit Europe/Berlin Zeitzone + // Annahme: Zeiten in DB sind in UTC gespeichert (Format: "2006-01-02 15:04:05") + var createdAtISO string + if createdAt != "" { + if t, err := time.Parse("2006-01-02 15:04:05", createdAt); err == nil { + // Parse erstellt eine Zeit ohne Zeitzone, interpretiere sie explizit als UTC + tUTC := time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), time.UTC) + berlinLocation, err := time.LoadLocation("Europe/Berlin") + if err != nil { + log.Printf("Warnung: Konnte Zeitzone Europe/Berlin nicht laden: %v, verwende UTC", err) + createdAtISO = tUTC.Format(time.RFC3339) + } else { + // Konvertiere von UTC nach Europe/Berlin + berlinTime := tUTC.In(berlinLocation) + createdAtISO = berlinTime.Format(time.RFC3339) + } + } else { + // Fallback: Verwende Original-String + createdAtISO = createdAt + } + } + + // Extrahiere Leaf-Zertifikat für Metadaten (expiresAt, issuer), aber sende komplettes PEM + leafPEM, _, splitErr := SplitCertificateChain(certPEM) + + // Verwende Leaf-PEM für Metadaten-Extraktion, falls verfügbar + certPEMForMetadata := certPEM + if splitErr == nil && leafPEM != "" { + certPEMForMetadata = leafPEM + } + + // Extrahiere Issuer aus dem Leaf-Zertifikat (für "Issued by" Anzeige) + var issuerName string + if certPEMForMetadata != "" { + issuer, err := GetCertificateIssuer(certPEMForMetadata) + if err == nil { + issuerName = GetProviderNameFromIssuer(issuer) + } + } + + // Erstelle Zertifikat-Eintrag (komplettes PEM, aber nur Leaf-Metadaten) + certData := map[string]interface{}{ "id": id, - "csrId": csrID, "certificateId": certID, - "providerId": providerID, - "certificatePEM": certPEM, + "providerId": providerID, // Ursprünglicher Provider aus Certigo + "issuer": issuerName, // Issuer aus dem Leaf-Zertifikat + "certificatePEM": certPEM, // Komplettes PEM (mit Intermediate) "status": status, - "createdAt": createdAt, - }) + "createdAt": createdAtISO, + } + + // Füge csrId nur hinzu, wenn vorhanden (kann NULL sein für ACME-Zertifikate) + if csrID.Valid && csrID.String != "" { + certData["csrId"] = csrID.String + } + + // Füge privateKeyPEM hinzu, wenn vorhanden + if privateKeyPEM.Valid && privateKeyPEM.String != "" { + certData["privateKeyPEM"] = privateKeyPEM.String + } + + // Füge expiresAt hinzu (nur für Leaf-Zertifikate, aus DB oder aus Leaf-Zertifikat extrahiert) + // Ignoriere Intermediate-Zertifikate (isIntermediateInt == 1) + if isIntermediateInt == 0 { + // Zuerst versuche es aus der DB - sende direkt als String ohne Zeitzonen-Konvertierung + if expiresAtStr.Valid && expiresAtStr.String != "" { + // Sende die Zeit direkt aus der DB ohne Konvertierung + certData["expiresAt"] = expiresAtStr.String + } else { + // Falls nicht in DB, extrahiere aus Leaf-Zertifikat + if certPEMForMetadata != "" { + certExpiresAt, _, parseErr := ParseCertificate(certPEMForMetadata) + if parseErr == nil { + // Format als "YYYY-MM-DD HH:MM:SS" ohne Zeitzone + certData["expiresAt"] = certExpiresAt.UTC().Format("2006-01-02 15:04:05") + } + } + } + } + + // Nur Leaf-Zertifikate anzeigen (ignoriere Intermediate) + if isIntermediateInt == 0 { + certificates = append(certificates, certData) + } } w.WriteHeader(http.StatusOK) diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 082bebf..4d57b73 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -1,8 +1,8 @@ openapi: 3.0.3 info: title: Certigo Addon API - description: API für die Verwaltung von Spaces, FQDNs und Certificate Signing Requests (CSRs) - version: 1.0.0 + description: API für die Verwaltung von Spaces, FQDNs, Certificate Signing Requests (CSRs), Zertifikaten, Benutzern, Berechtigungen und automatischer Zertifikatserneuerung + version: 2.0.0 contact: name: Certigo Addon @@ -10,6 +10,9 @@ servers: - url: http://localhost:8080/api description: Local development server +security: + - basicAuth: [] + paths: /health: get: @@ -241,29 +244,6 @@ paths: description: Löscht alle FQDNs aus allen Spaces. Erfordert confirm=true Query-Parameter. tags: - FQDNs - parameters: - - name: confirm - in: query - required: true - schema: - type: boolean - description: Muss true sein, um die Operation auszuführen - responses: - '200': - description: Alle FQDNs erfolgreich gelöscht - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteResponse' - '400': - description: Bestätigung erforderlich - - /csrs: - delete: - summary: Alle CSRs global löschen - description: Löscht alle CSRs aus allen Spaces. Erfordert confirm=true Query-Parameter. - tags: - - CSRs parameters: - name: confirm in: query @@ -274,7 +254,7 @@ paths: example: "true" responses: '200': - description: Alle CSRs erfolgreich gelöscht + description: Alle FQDNs erfolgreich gelöscht content: application/json: schema: @@ -373,6 +353,811 @@ paths: '404': description: FQDN nicht gefunden + /spaces/{spaceId}/fqdns/{fqdnId}/csr/sign: + post: + summary: CSR signieren lassen + description: Sendet einen CSR an einen Provider zur Signierung + tags: + - CSRs + parameters: + - name: spaceId + in: path + required: true + schema: + type: string + format: uuid + - name: fqdnId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SignCSRRequest' + responses: + '200': + description: CSR erfolgreich signiert + content: + application/json: + schema: + $ref: '#/components/schemas/SignCSRResponse' + '400': + description: Ungültige Anfrage + '404': + description: FQDN, CSR oder Provider nicht gefunden + + /spaces/{spaceId}/fqdns/{fqdnId}/certificates: + get: + summary: Zertifikate abrufen + description: Ruft alle Zertifikate für einen FQDN ab + tags: + - Certificates + parameters: + - name: spaceId + in: path + required: true + schema: + type: string + format: uuid + - name: fqdnId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Liste der Zertifikate + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Certificate' + '404': + description: FQDN nicht gefunden + + /spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/refresh: + post: + summary: Zertifikat aktualisieren + description: Aktualisiert die Informationen eines Zertifikats vom Provider + tags: + - Certificates + parameters: + - name: spaceId + in: path + required: true + schema: + type: string + format: uuid + - name: fqdnId + in: path + required: true + schema: + type: string + format: uuid + - name: certId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Zertifikat erfolgreich aktualisiert + content: + application/json: + schema: + $ref: '#/components/schemas/Certificate' + '404': + description: Zertifikat nicht gefunden + + /spaces/{spaceId}/fqdns/{fqdnId}/request-certificate: + post: + summary: Zertifikat beantragen (ACME) + description: Beantragt ein neues Zertifikat über das ACME-Protokoll für einen FQDN + tags: + - Certificates + parameters: + - name: spaceId + in: path + required: true + schema: + type: string + format: uuid + - name: fqdnId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + $ref: '#/components/schemas/RequestCertificateRequest' + responses: + '200': + description: Zertifikat erfolgreich beantragt + content: + application/json: + schema: + $ref: '#/components/schemas/RequestCertificateResponse' + '400': + description: Ungültige Anfrage + '404': + description: FQDN nicht gefunden + + /spaces/{spaceId}/fqdns/{fqdnId}/renewal-enabled: + put: + summary: Auto-Renewal für FQDN aktivieren/deaktivieren + description: Aktiviert oder deaktiviert die automatische Erneuerung für einen FQDN + tags: + - FQDNs + parameters: + - name: spaceId + in: path + required: true + schema: + type: string + format: uuid + - name: fqdnId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateRenewalEnabledRequest' + responses: + '200': + description: Auto-Renewal Status erfolgreich aktualisiert + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '404': + description: FQDN nicht gefunden + + /csrs: + delete: + summary: Alle CSRs global löschen + description: Löscht alle CSRs aus allen Spaces. Erfordert confirm=true Query-Parameter. + tags: + - CSRs + 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 CSRs erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '400': + description: Bestätigung erforderlich + + /renewal-queue: + get: + summary: Renewal Queue abrufen + description: Ruft alle Einträge in der Renewal Queue ab + tags: + - Renewal Queue + responses: + '200': + description: Liste der Renewal Queue Einträge + content: + application/json: + schema: + 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: + summary: Test-Queue-Eintrag erstellen + description: Erstellt einen Test-Eintrag in der Renewal Queue (nur für Administratoren) + tags: + - Renewal Queue + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTestRenewalQueueEntryRequest' + responses: + '201': + description: Test-Queue-Eintrag erfolgreich erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/CreateTestRenewalQueueEntryResponse' + '400': + description: Ungültige Anfrage + '403': + description: Nur Administratoren dürfen Test-Einträge erstellen + + /renewal-queue/test/trigger: + post: + summary: Renewal Queue manuell auslösen + description: Löst die manuelle Verarbeitung der Renewal Queue aus (nur für Administratoren) + tags: + - Renewal Queue + responses: + '200': + description: Queue-Verarbeitung erfolgreich ausgelöst + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '403': + description: Nur Administratoren dürfen die Queue manuell auslösen + + /users: + get: + summary: Alle Benutzer abrufen + description: Ruft eine Liste aller Benutzer ab (nur für Administratoren) + tags: + - Users + responses: + '200': + description: Liste der Benutzer + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + '403': + description: Nur Administratoren dürfen Benutzer abrufen + + post: + summary: Benutzer erstellen + description: Erstellt einen neuen Benutzer (nur für Administratoren) + tags: + - Users + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserRequest' + responses: + '201': + description: Benutzer erfolgreich erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Ungültige Anfrage + '403': + description: Nur Administratoren dürfen Benutzer erstellen + + /users/{id}: + get: + summary: Benutzer abrufen + description: Ruft einen einzelnen Benutzer ab (nur für Administratoren) + tags: + - Users + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Benutzer erfolgreich abgerufen + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '404': + description: Benutzer nicht gefunden + '403': + description: Nur Administratoren dürfen Benutzer abrufen + + put: + summary: Benutzer aktualisieren + description: Aktualisiert einen Benutzer (nur für Administratoren) + tags: + - Users + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateUserRequest' + responses: + '200': + description: Benutzer erfolgreich aktualisiert + content: + application/json: + schema: + $ref: '#/components/schemas/User' + '400': + description: Ungültige Anfrage + '404': + description: Benutzer nicht gefunden + '403': + description: Nur Administratoren dürfen Benutzer aktualisieren + + delete: + summary: Benutzer löschen + description: Löscht einen Benutzer (nur für Administratoren) + tags: + - Users + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Benutzer erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '404': + description: Benutzer nicht gefunden + '403': + description: Nur Administratoren dürfen Benutzer löschen + + /users/{id}/avatar: + get: + summary: Avatar abrufen + description: Ruft das Profilbild eines Benutzers ab + tags: + - Users + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Avatar erfolgreich abgerufen + content: + image/png: + schema: + type: string + format: binary + '404': + description: Avatar nicht gefunden + + post: + summary: Avatar hochladen + description: Lädt ein Profilbild für einen Benutzer hoch + tags: + - Users + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - avatar + properties: + avatar: + type: string + format: binary + description: Bilddatei (PNG, JPG, etc.) + responses: + '200': + description: Avatar erfolgreich hochgeladen + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + description: Ungültige Datei + + /user/permissions: + get: + summary: Benutzer-Berechtigungen abrufen + description: Ruft die Berechtigungen des aktuell authentifizierten Benutzers ab + tags: + - Users + responses: + '200': + description: Berechtigungen erfolgreich abgerufen + content: + application/json: + schema: + $ref: '#/components/schemas/UserPermissions' + + /permission-groups: + get: + summary: Alle Berechtigungsgruppen abrufen + description: Ruft eine Liste aller Berechtigungsgruppen ab (nur für Administratoren) + tags: + - Permission Groups + responses: + '200': + description: Liste der Berechtigungsgruppen + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/PermissionGroup' + '403': + description: Nur Administratoren dürfen Berechtigungsgruppen abrufen + + post: + summary: Berechtigungsgruppe erstellen + description: Erstellt eine neue Berechtigungsgruppe (nur für Administratoren) + tags: + - Permission Groups + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePermissionGroupRequest' + responses: + '201': + description: Berechtigungsgruppe erfolgreich erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/PermissionGroup' + '400': + description: Ungültige Anfrage + '403': + description: Nur Administratoren dürfen Berechtigungsgruppen erstellen + + /permission-groups/{id}: + get: + summary: Berechtigungsgruppe abrufen + description: Ruft eine einzelne Berechtigungsgruppe ab (nur für Administratoren) + tags: + - Permission Groups + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Berechtigungsgruppe erfolgreich abgerufen + content: + application/json: + schema: + $ref: '#/components/schemas/PermissionGroup' + '404': + description: Berechtigungsgruppe nicht gefunden + '403': + description: Nur Administratoren dürfen Berechtigungsgruppen abrufen + + put: + summary: Berechtigungsgruppe aktualisieren + description: Aktualisiert eine Berechtigungsgruppe (nur für Administratoren) + tags: + - Permission Groups + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePermissionGroupRequest' + responses: + '200': + description: Berechtigungsgruppe erfolgreich aktualisiert + content: + application/json: + schema: + $ref: '#/components/schemas/PermissionGroup' + '400': + description: Ungültige Anfrage + '404': + description: Berechtigungsgruppe nicht gefunden + '403': + description: Nur Administratoren dürfen Berechtigungsgruppen aktualisieren + + delete: + summary: Berechtigungsgruppe löschen + description: Löscht eine Berechtigungsgruppe (nur für Administratoren) + tags: + - Permission Groups + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Berechtigungsgruppe erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '404': + description: Berechtigungsgruppe nicht gefunden + '403': + description: Nur Administratoren dürfen Berechtigungsgruppen löschen + + /providers: + get: + summary: Alle Provider abrufen + description: Ruft eine Liste aller Provider ab + tags: + - Providers + responses: + '200': + description: Liste der Provider + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Provider' + + /providers/{id}: + get: + summary: Provider abrufen + description: Ruft einen einzelnen Provider ab + tags: + - Providers + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Provider erfolgreich abgerufen + content: + application/json: + schema: + $ref: '#/components/schemas/Provider' + '404': + description: Provider nicht gefunden + + /providers/{id}/enabled: + put: + summary: Provider aktivieren/deaktivieren + description: Aktiviert oder deaktiviert einen Provider + tags: + - Providers + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SetProviderEnabledRequest' + responses: + '200': + description: Provider Status erfolgreich aktualisiert + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '404': + description: Provider nicht gefunden + + /providers/{id}/config: + put: + summary: Provider-Konfiguration aktualisieren + description: Aktualisiert die Konfiguration eines Providers + tags: + - Providers + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateProviderConfigRequest' + responses: + '200': + description: Provider-Konfiguration erfolgreich aktualisiert + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + '400': + description: Ungültige Konfiguration + '404': + description: Provider nicht gefunden + + /providers/{id}/test: + post: + summary: Provider-Verbindung testen + description: Testet die Verbindung zu einem Provider + tags: + - Providers + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + '200': + description: Verbindungstest erfolgreich + content: + application/json: + schema: + $ref: '#/components/schemas/ProviderTestResponse' + '404': + description: Provider nicht gefunden + + /audit-logs: + get: + summary: Audit-Logs abrufen + description: Ruft Audit-Logs ab mit optionalen Filtern + tags: + - Audit Logs + parameters: + - name: limit + in: query + required: false + schema: + type: integer + default: 100 + description: Maximale Anzahl der Einträge + - name: offset + in: query + required: false + schema: + type: integer + default: 0 + description: Offset für Paginierung + responses: + '200': + description: Audit-Logs erfolgreich abgerufen + content: + application/json: + schema: + $ref: '#/components/schemas/AuditLogsResponse' + + delete: + summary: Alle Audit-Logs löschen + description: Löscht alle Audit-Logs + tags: + - Audit Logs + responses: + '200': + description: Alle Audit-Logs erfolgreich gelöscht + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + + /audit-logs/test: + post: + summary: Test-Audit-Log erstellen + description: Erstellt einen Test-Audit-Log-Eintrag + tags: + - Audit Logs + requestBody: + required: false + content: + application/json: + schema: + type: object + responses: + '200': + description: Test-Audit-Log erfolgreich erstellt + content: + application/json: + schema: + $ref: '#/components/schemas/MessageResponse' + components: schemas: HealthResponse: @@ -430,7 +1215,7 @@ components: example: "Mein Space" description: type: string - example: "Beschreibung des Spaces" + example: "Beschreibung des Space" FQDN: type: object @@ -453,6 +1238,10 @@ components: type: string format: date-time example: "2024-01-15T10:30:00Z" + renewalEnabled: + type: boolean + example: true + description: Auto-Renewal aktiviert CreateFQDNRequest: type: object @@ -465,6 +1254,20 @@ components: description: type: string example: "Beschreibung des FQDN" + providerId: + type: string + format: uuid + example: "770e8400-e29b-41d4-a716-446655440000" + + UpdateRenewalEnabledRequest: + type: object + required: + - renewalEnabled + properties: + renewalEnabled: + type: boolean + example: true + description: Auto-Renewal aktivieren/deaktivieren Extension: type: object @@ -555,6 +1358,435 @@ components: format: date-time example: "2024-01-15T10:30:00Z" + SignCSRRequest: + type: object + required: + - providerId + properties: + providerId: + type: string + format: uuid + example: "880e8400-e29b-41d4-a716-446655440000" + + SignCSRResponse: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: "CSR erfolgreich signiert" + certificate: + type: string + example: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" + + Certificate: + type: object + properties: + id: + type: string + format: uuid + example: "990e8400-e29b-41d4-a716-446655440000" + fqdnId: + type: string + format: uuid + example: "660e8400-e29b-41d4-a716-446655440000" + spaceId: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + pem: + type: string + example: "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----" + issuer: + type: string + example: "Let's Encrypt" + expiresAt: + type: string + format: date-time + example: "2025-01-15T10:30:00Z" + createdAt: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + + RequestCertificateRequest: + type: object + properties: + confirmed: + type: boolean + example: false + description: Bestätigung für bereits gültiges Zertifikat + + RequestCertificateResponse: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: "Zertifikat erfolgreich beantragt" + certificate: + type: object + properties: + id: + type: string + format: uuid + pem: + type: string + stepStatus: + type: object + description: Status der einzelnen Schritte des Zertifikatsanfrage-Prozesses + + RenewalQueueEntry: + type: object + properties: + id: + type: string + format: uuid + example: "aa0e8400-e29b-41d4-a716-446655440000" + certificateId: + type: string + format: uuid + example: "990e8400-e29b-41d4-a716-446655440000" + fqdnId: + type: string + format: uuid + example: "660e8400-e29b-41d4-a716-446655440000" + spaceId: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + scheduledAt: + type: string + format: date-time + example: "2025-01-24T03:25:13Z" + status: + type: string + enum: [pending, processing, completed, failed] + example: "pending" + createdAt: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + + CreateTestRenewalQueueEntryRequest: + type: object + required: + - certificateId + - fqdnId + - spaceId + - minutesFromNow + properties: + certificateId: + type: string + format: uuid + example: "990e8400-e29b-41d4-a716-446655440000" + fqdnId: + type: string + format: uuid + example: "660e8400-e29b-41d4-a716-446655440000" + spaceId: + type: string + format: uuid + example: "550e8400-e29b-41d4-a716-446655440000" + minutesFromNow: + type: integer + example: 5 + description: Anzahl der Minuten ab jetzt, wann der Renewal ausgeführt werden soll + + CreateTestRenewalQueueEntryResponse: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: "Test-Queue-Eintrag erstellt" + queueId: + type: string + format: uuid + example: "test-aa0e8400-e29b-41d4-a716-446655440000" + scheduledAt: + type: string + format: date-time + example: "2024-01-15T10:35:00Z" + + User: + type: object + properties: + id: + type: string + format: uuid + example: "bb0e8400-e29b-41d4-a716-446655440000" + username: + type: string + example: "admin" + email: + type: string + example: "admin@example.com" + isAdmin: + type: boolean + example: true + enabled: + type: boolean + example: true + createdAt: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + + CreateUserRequest: + type: object + required: + - username + - email + - password + properties: + username: + type: string + example: "newuser" + email: + type: string + example: "user@example.com" + password: + type: string + example: "securePassword123" + isAdmin: + type: boolean + example: false + + UpdateUserRequest: + type: object + properties: + username: + type: string + example: "updateduser" + email: + type: string + example: "updated@example.com" + password: + type: string + example: "newPassword123" + isAdmin: + type: boolean + example: false + enabled: + type: boolean + example: true + + UserPermissions: + type: object + properties: + isAdmin: + type: boolean + example: true + hasFullAccess: + type: boolean + example: false + accessibleSpaces: + type: array + items: + type: string + format: uuid + example: ["550e8400-e29b-41d4-a716-446655440000"] + permissions: + type: object + properties: + canCreateSpace: + type: boolean + canDeleteSpace: + type: boolean + canCreateFqdn: + type: object + canDeleteFqdn: + type: object + canUploadCSR: + type: object + canSignCSR: + type: object + groups: + type: array + items: + $ref: '#/components/schemas/PermissionGroup' + + PermissionGroup: + type: object + properties: + id: + type: string + format: uuid + example: "cc0e8400-e29b-41d4-a716-446655440000" + name: + type: string + example: "Developers" + description: + type: string + example: "Entwickler-Gruppe" + permission: + type: string + enum: [READ, READ_WRITE, FULL_ACCESS] + example: "READ_WRITE" + spaceIds: + type: array + items: + type: string + format: uuid + example: ["550e8400-e29b-41d4-a716-446655440000"] + createdAt: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + + CreatePermissionGroupRequest: + type: object + required: + - name + - permission + properties: + name: + type: string + example: "Developers" + description: + type: string + example: "Entwickler-Gruppe" + permission: + type: string + enum: [READ, READ_WRITE, FULL_ACCESS] + example: "READ_WRITE" + spaceIds: + type: array + items: + type: string + format: uuid + example: ["550e8400-e29b-41d4-a716-446655440000"] + + UpdatePermissionGroupRequest: + type: object + properties: + name: + type: string + example: "Developers" + description: + type: string + example: "Entwickler-Gruppe" + permission: + type: string + enum: [READ, READ_WRITE, FULL_ACCESS] + example: "READ_WRITE" + spaceIds: + type: array + items: + type: string + format: uuid + example: ["550e8400-e29b-41d4-a716-446655440000"] + + Provider: + type: object + properties: + id: + type: string + format: uuid + example: "dd0e8400-e29b-41d4-a716-446655440000" + name: + type: string + example: "Let's Encrypt" + displayName: + type: string + example: "Let's Encrypt (Production)" + description: + type: string + example: "Let's Encrypt Production CA" + enabled: + type: boolean + example: true + config: + type: object + description: Provider-spezifische Konfiguration + + SetProviderEnabledRequest: + type: object + required: + - enabled + properties: + enabled: + type: boolean + example: true + + UpdateProviderConfigRequest: + type: object + properties: + config: + type: object + description: Provider-spezifische Konfiguration + + ProviderTestResponse: + type: object + properties: + success: + type: boolean + example: true + message: + type: string + example: "Verbindung erfolgreich" + + AuditLogsResponse: + type: object + properties: + logs: + type: array + items: + $ref: '#/components/schemas/AuditLog' + total: + type: integer + example: 100 + limit: + type: integer + example: 100 + offset: + type: integer + example: 0 + + AuditLog: + type: object + properties: + id: + type: string + format: uuid + example: "ee0e8400-e29b-41d4-a716-446655440000" + action: + type: string + example: "CREATE" + resourceType: + type: string + example: "fqdn" + resourceId: + type: string + format: uuid + example: "660e8400-e29b-41d4-a716-446655440000" + userId: + type: string + format: uuid + example: "bb0e8400-e29b-41d4-a716-446655440000" + username: + type: string + example: "admin" + details: + type: object + ipAddress: + type: string + example: "192.168.1.1" + userAgent: + type: string + example: "Mozilla/5.0..." + timestamp: + type: string + format: date-time + example: "2024-01-15T10:30:00Z" + MessageResponse: type: object properties: @@ -580,7 +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/certigo-acmeproxy.go b/backend/providers/certigo-acmeproxy.go new file mode 100644 index 0000000..08bfa27 --- /dev/null +++ b/backend/providers/certigo-acmeproxy.go @@ -0,0 +1,234 @@ +package providers + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// CertigoACMEProxyProvider ist der Provider für certigo-acmeproxy +type CertigoACMEProxyProvider struct { + baseURL string +} + +func NewCertigoACMEProxyProvider() *CertigoACMEProxyProvider { + return &CertigoACMEProxyProvider{} +} + +func (p *CertigoACMEProxyProvider) GetName() string { + return "certigo-acmeproxy" +} + +func (p *CertigoACMEProxyProvider) GetDisplayName() string { + return "Certigo ACME Proxy" +} + +func (p *CertigoACMEProxyProvider) GetDescription() string { + return "ACME DNS-01 Challenge Responder für Let's Encrypt und andere ACME CAs" +} + +func (p *CertigoACMEProxyProvider) ValidateConfig(settings map[string]interface{}) error { + baseURL, ok := settings["baseURL"].(string) + if !ok || strings.TrimSpace(baseURL) == "" { + return fmt.Errorf("baseURL ist erforderlich") + } + + // Entferne trailing slash falls vorhanden + baseURL = strings.TrimSuffix(baseURL, "/") + + // Validiere URL-Format + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + return fmt.Errorf("baseURL muss mit http:// oder https:// beginnen") + } + + return nil +} + +func (p *CertigoACMEProxyProvider) TestConnection(settings map[string]interface{}) error { + // Validiere zuerst die Konfiguration + if err := p.ValidateConfig(settings); err != nil { + return err + } + + baseURL, _ := settings["baseURL"].(string) + baseURL = strings.TrimSuffix(baseURL, "/") + + // Teste Verbindung über Health Check + url := fmt.Sprintf("%s/health", baseURL) + + client := &http.Client{ + Timeout: 5 * time.Second, + } + + resp, err := client.Get(url) + if err != nil { + return fmt.Errorf("acme-proxy nicht erreichbar: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("acme-proxy antwortet mit Status %d", resp.StatusCode) + } + + // Prüfe Response Body + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("fehler beim Lesen der Health-Check-Response: %v", err) + } + + var healthResponse struct { + Status string `json:"status"` + } + if err := json.Unmarshal(body, &healthResponse); err != nil { + return fmt.Errorf("ungültige Health-Check-Response: %v", err) + } + + if healthResponse.Status != "ok" { + return fmt.Errorf("acme-proxy meldet Status: %s", healthResponse.Status) + } + + return nil +} + +// GetRequiredSettings gibt die erforderlichen Einstellungen zurück +func (p *CertigoACMEProxyProvider) GetRequiredSettings() []SettingField { + return []SettingField{ + { + Name: "baseURL", + Label: "Base URL", + Type: "text", + Required: true, + Description: "Base URL des certigo-acmeproxy Services (z.B. http://localhost:8080)", + }, + } +} + +// RegisterChallengeDomain registriert eine neue Challenge-Domain beim ACME Proxy +func (p *CertigoACMEProxyProvider) RegisterChallengeDomain(settings map[string]interface{}) (*ChallengeDomainResponse, error) { + if err := p.ValidateConfig(settings); err != nil { + return nil, err + } + + baseURL, _ := settings["baseURL"].(string) + baseURL = strings.TrimSuffix(baseURL, "/") + + url := fmt.Sprintf("%s/register", baseURL) + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return nil, fmt.Errorf("fehler beim Erstellen des Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fehler beim Senden des Requests: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("fehler beim Lesen der Response: %v", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("acme-proxy Fehler (Status %d): %s", resp.StatusCode, string(body)) + } + + var registerResponse ChallengeDomainResponse + if err := json.Unmarshal(body, ®isterResponse); err != nil { + return nil, fmt.Errorf("fehler beim Parsen der Response: %v", err) + } + + return ®isterResponse, nil +} + +// UpdateChallengeToken setzt oder aktualisiert den ACME Challenge Token +func (p *CertigoACMEProxyProvider) UpdateChallengeToken(username, password, token string, settings map[string]interface{}) error { + if err := p.ValidateConfig(settings); err != nil { + return err + } + + baseURL, _ := settings["baseURL"].(string) + baseURL = strings.TrimSuffix(baseURL, "/") + + url := fmt.Sprintf("%s/update", baseURL) + + requestBody := map[string]string{ + "txt": token, + } + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return fmt.Errorf("fehler beim Erstellen des Request-Body: %v", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("fehler beim Erstellen des Requests: %v", err) + } + + req.Header.Set("Content-Type", "application/json") + + // Basic Authentication + auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) + req.Header.Set("Authorization", "Basic "+auth) + + client := &http.Client{ + Timeout: 10 * time.Second, + } + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("fehler beim Senden des Requests: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("fehler beim Lesen der Response: %v", err) + } + + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("ungültige Authentifizierung") + } + + if resp.StatusCode == http.StatusBadRequest { + return fmt.Errorf("ungültige Anfrage: %s", string(body)) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("acme-proxy Fehler (Status %d): %s", resp.StatusCode, string(body)) + } + + return nil +} + +// ChallengeDomainResponse enthält die Response von /register +type ChallengeDomainResponse struct { + Username string `json:"username"` + Password string `json:"password"` + Fulldomain string `json:"fulldomain"` + Subdomain string `json:"subdomain"` +} + +// SignCSR signiert einen CSR (für ACME nicht direkt verwendet, aber Interface erfordert es) +func (p *CertigoACMEProxyProvider) SignCSR(csrPEM string, settings map[string]interface{}) (*SignCSRResult, error) { + return nil, fmt.Errorf("certigo-acmeproxy unterstützt keine direkte CSR-Signierung. Verwenden Sie ACME für Zertifikatsanfragen.") +} + +// GetCertificate ruft ein Zertifikat ab (für ACME nicht direkt verwendet, aber Interface erfordert es) +func (p *CertigoACMEProxyProvider) GetCertificate(certificateID string, settings map[string]interface{}) (string, error) { + return "", fmt.Errorf("certigo-acmeproxy unterstützt keinen direkten Zertifikat-Abruf. Verwenden Sie ACME für Zertifikatsanfragen.") +} + 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/providers/provider.go b/backend/providers/provider.go index e9ae57e..ca175fe 100644 --- a/backend/providers/provider.go +++ b/backend/providers/provider.go @@ -10,8 +10,9 @@ import ( // ProviderConfig enthält die Konfiguration eines Providers type ProviderConfig struct { - Enabled bool `json:"enabled"` - Settings map[string]interface{} `json:"settings"` + Enabled bool `json:"enabled"` + AcmeReady bool `json:"acme_ready"` + Settings map[string]interface{} `json:"settings"` } // SignCSRResult enthält das Ergebnis einer CSR-Signierung @@ -77,8 +78,9 @@ func (pm *ProviderManager) RegisterProvider(provider Provider) { // Lade Konfiguration falls vorhanden if pm.configs[providerID] == nil { pm.configs[providerID] = &ProviderConfig{ - Enabled: false, - Settings: make(map[string]interface{}), + Enabled: false, + AcmeReady: false, + Settings: make(map[string]interface{}), } } } @@ -110,8 +112,9 @@ func (pm *ProviderManager) GetProviderConfig(id string) (*ProviderConfig, error) config, exists := pm.configs[id] if !exists { return &ProviderConfig{ - Enabled: false, - Settings: make(map[string]interface{}), + Enabled: false, + AcmeReady: false, + Settings: make(map[string]interface{}), }, nil } return config, nil @@ -145,8 +148,9 @@ func (pm *ProviderManager) SetProviderEnabled(id string, enabled bool) error { if pm.configs[id] == nil { pm.configs[id] = &ProviderConfig{ - Enabled: enabled, - Settings: make(map[string]interface{}), + Enabled: enabled, + AcmeReady: false, + Settings: make(map[string]interface{}), } } else { pm.configs[id].Enabled = enabled diff --git a/backend/providers/types.go b/backend/providers/types.go index 7cd7875..fd6cc2b 100644 --- a/backend/providers/types.go +++ b/backend/providers/types.go @@ -17,6 +17,7 @@ type ProviderInfo struct { DisplayName string `json:"displayName"` Description string `json:"description"` Enabled bool `json:"enabled"` + AcmeReady bool `json:"acme_ready"` Settings []SettingField `json:"settings"` } diff --git a/backend/renewal_info.go b/backend/renewal_info.go new file mode 100644 index 0000000..a61b3ca --- /dev/null +++ b/backend/renewal_info.go @@ -0,0 +1,207 @@ +package main + +import ( + "context" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "certigo-addon-backend/providers" + + "github.com/google/uuid" +) + +// RenewalInfoResponse enthält die Antwort von Let's Encrypt RenewalInfo API +type RenewalInfoResponse struct { + SuggestedWindow struct { + Start string `json:"start"` + End string `json:"end"` + } `json:"suggestedWindow"` +} + +// CalculateCertID berechnet die CertID für Let's Encrypt RenewalInfo API +// Die CertID setzt sich aus Authority Key Identifier (AKI) und Serial Number zusammen +// Format: base64url(AKI).base64url(SerialNumber) +func CalculateCertID(certPEM string) (string, error) { + // Parse Zertifikat + block, _ := pem.Decode([]byte(certPEM)) + if block == nil { + return "", fmt.Errorf("fehler beim Dekodieren des PEM-Blocks") + } + + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return "", fmt.Errorf("fehler beim Parsen des Zertifikats: %v", err) + } + + // Verwende AuthorityKeyId direkt aus dem Zertifikat + if len(cert.AuthorityKeyId) == 0 { + return "", fmt.Errorf("Authority Key Identifier nicht im Zertifikat gefunden") + } + + // Encoding Referenz: base64.RawURLEncoding entfernt das Padding (=) automatisch + encoder := base64.RawURLEncoding + + // Teil 1: AKI + akiPart := encoder.EncodeToString(cert.AuthorityKeyId) + + // Teil 2: Serial Number + // WICHTIG: .Bytes() nutzen, NICHT .String() (was dezimal wäre) oder Hex! + serialPart := encoder.EncodeToString(cert.SerialNumber.Bytes()) + + // Resultat: AKI.SerialNumber mit Punkt getrennt + certID := fmt.Sprintf("%s.%s", akiPart, serialPart) + + return certID, nil +} + +// 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{ + Timeout: 10 * time.Second, + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("fehler beim Erstellen des Requests: %v", err) + } + + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fehler beim Senden des Requests: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("fehler beim Lesen der Response: %v", err) + } + + if resp.StatusCode != http.StatusOK { + // Prüfe ob es ein Staging-Zertifikat ist (404 mit spezifischer Fehlermeldung) + if resp.StatusCode == http.StatusNotFound { + bodyStr := string(body) + if strings.Contains(bodyStr, "Authority Key Identifier that did not match a known issuer") { + return nil, fmt.Errorf("RenewalInfo nicht verfügbar für Staging-Zertifikate (Status %d)", resp.StatusCode) + } + } + return nil, fmt.Errorf("RenewalInfo API Fehler (Status %d): %s", resp.StatusCode, string(body)) + } + + var renewalInfo RenewalInfoResponse + if err := json.Unmarshal(body, &renewalInfo); err != nil { + return nil, fmt.Errorf("fehler beim Parsen der Response: %v", err) + } + + return &renewalInfo, nil +} + +// ProcessRenewalInfoForCertificate verarbeitet RenewalInfo für ein Zertifikat +// Trennt die Zertifikatskette, berechnet CertID für Leaf, ruft RenewalInfo ab und erstellt Queue-Eintrag +func ProcessRenewalInfoForCertificate(certPEM string, certID string, fqdnID string, spaceID string, renewalEnabled bool) error { + if !renewalEnabled { + log.Printf("RenewalInfo wird übersprungen - renewal_enabled ist für FQDN %s deaktiviert", fqdnID) + return nil + } + + // Trenne Zertifikatskette in Leaf und Intermediate + leafPEM, _, err := SplitCertificateChain(certPEM) + if err != nil { + return fmt.Errorf("fehler beim Trennen der Zertifikatskette: %v", err) + } + + if leafPEM == "" { + return fmt.Errorf("kein Leaf-Zertifikat in der Kette gefunden") + } + + // Berechne CertID für Leaf-Zertifikat + certIDBase64, err := CalculateCertID(leafPEM) + if err != nil { + return fmt.Errorf("fehler beim Berechnen der CertID: %v", err) + } + + log.Printf("CertID berechnet: %s für Zertifikat %s", certIDBase64, certID) + + // Speichere CertID in DB + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + _, err = db.ExecContext(ctx, "UPDATE certificates SET cert_id_base64 = ? WHERE id = ?", certIDBase64, certID) + if err != nil { + log.Printf("Warnung: Fehler beim Speichern der CertID in DB: %v", err) + // Weiter mit RenewalInfo-Abfrage, auch wenn DB-Update fehlschlägt + } + + // Rufe RenewalInfo ab + // 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") { + log.Printf("RenewalInfo wird übersprungen - Staging-Zertifikat (CertID: %s)", certIDBase64) + return nil // Kein Fehler, einfach überspringen + } + return fmt.Errorf("fehler beim Abrufen der RenewalInfo: %v", err) + } + + log.Printf("RenewalInfo erhalten: Suggested Window Start: %s, End: %s", renewalInfo.SuggestedWindow.Start, renewalInfo.SuggestedWindow.End) + + // Parse Suggested Window Start + scheduledAt, err := time.Parse(time.RFC3339, renewalInfo.SuggestedWindow.Start) + if err != nil { + return fmt.Errorf("fehler beim Parsen des Scheduled-At-Datums: %v", err) + } + + // Speichere renewal_scheduled_at in DB + scheduledAtStr := scheduledAt.UTC().Format("2006-01-02 15:04:05") + ctx, cancel = context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + _, err = db.ExecContext(ctx, "UPDATE certificates SET renewal_scheduled_at = ? WHERE id = ?", scheduledAtStr, certID) + if err != nil { + log.Printf("Warnung: Fehler beim Speichern von renewal_scheduled_at in DB: %v", err) + } + + // Erstelle Queue-Eintrag + queueID := uuid.New().String() + createdAt := time.Now().UTC().Format("2006-01-02 15:04:05") + + ctx, cancel = context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + _, err = db.ExecContext(ctx, ` + INSERT INTO renewal_queue (id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?) + `, queueID, certID, fqdnID, spaceID, scheduledAtStr, createdAt) + if err != nil { + return fmt.Errorf("fehler beim Erstellen des Queue-Eintrags: %v", err) + } + + log.Printf("RenewalInfo verarbeitet und Queue-Eintrag erstellt (ID: %s, Scheduled: %s)", queueID, scheduledAtStr) + return nil +} diff --git a/backend/renewal_queue_handlers.go b/backend/renewal_queue_handlers.go new file mode 100644 index 0000000..cbbb6ce --- /dev/null +++ b/backend/renewal_queue_handlers.go @@ -0,0 +1,333 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/gorilla/mux" +) + +// updateFqdnRenewalEnabledHandler aktualisiert das renewal_enabled Flag für einen FQDN +func updateFqdnRenewalEnabledHandler(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", "PUT, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + vars := mux.Vars(r) + spaceID := vars["spaceId"] + fqdnID := vars["fqdnId"] + + // Prüfe Berechtigung + userID, _ := getUserFromRequest(r) + if userID == "" { + http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized) + return + } + + hasAccess, err := hasSpaceAccess(userID, spaceID) + if err != nil { + http.Error(w, "Fehler beim Prüfen der Berechtigung", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen der Berechtigung: %v", err) + return + } + + if !hasAccess { + http.Error(w, "Keine Berechtigung für diesen Space", http.StatusForbidden) + return + } + + // Prüfe ob FQDN existiert und hole aktuellen renewal_enabled Status + var currentRenewalEnabled sql.NullInt64 + err = db.QueryRow("SELECT renewal_enabled FROM fqdns WHERE id = ? AND space_id = ?", fqdnID, spaceID).Scan(¤tRenewalEnabled) + if err == sql.ErrNoRows { + http.Error(w, "FQDN nicht gefunden", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "Fehler beim Laden des FQDN", http.StatusInternalServerError) + log.Printf("Fehler beim Laden des FQDN: %v", err) + return + } + + // Parse Request Body + var req struct { + RenewalEnabled bool `json:"renewalEnabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Ungültige Anfrage", http.StatusBadRequest) + return + } + + // Prüfe ob renewal_enabled von false auf true geändert wird + wasDisabled := !currentRenewalEnabled.Valid || currentRenewalEnabled.Int64 == 0 + willBeEnabled := req.RenewalEnabled + shouldProcessRenewalInfo := wasDisabled && willBeEnabled + + // Beginne Transaktion + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tx, err := db.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "Fehler beim Starten der Transaktion", http.StatusInternalServerError) + log.Printf("Fehler beim Starten der Transaktion: %v", err) + return + } + defer tx.Rollback() + + // Update renewal_enabled (explizit als 0 oder 1 speichern, nicht NULL) + var renewalEnabledInt int + if req.RenewalEnabled { + renewalEnabledInt = 1 + } else { + renewalEnabledInt = 0 + } + + _, err = tx.ExecContext(ctx, "UPDATE fqdns SET renewal_enabled = ? WHERE id = ? AND space_id = ?", renewalEnabledInt, fqdnID, spaceID) + if err != nil { + http.Error(w, "Fehler beim Aktualisieren des renewal_enabled Flags", http.StatusInternalServerError) + log.Printf("Fehler beim Aktualisieren des renewal_enabled Flags: %v", err) + return + } + + // 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 = ? 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("Pending/Processing Queue-Einträge für FQDN %s gelöscht (renewal_enabled deaktiviert)", fqdnID) + } + + // Committe die Transaktion + if err = tx.Commit(); err != nil { + http.Error(w, "Fehler beim Speichern der Änderungen", http.StatusInternalServerError) + log.Printf("Fehler beim Committen der Transaktion: %v", err) + return + } + + // Wenn renewal_enabled von false auf true geändert wurde, verarbeite RenewalInfo für das aktuelle Zertifikat + if shouldProcessRenewalInfo { + go func() { + // Hole das aktuellste (nicht-intermediate) Zertifikat für diesen FQDN + var certID, certPEM string + err := db.QueryRow(` + SELECT id, certificate_pem + FROM certificates + WHERE fqdn_id = ? AND (is_intermediate = 0 OR is_intermediate IS NULL) + ORDER BY expires_at DESC, created_at DESC + LIMIT 1 + `, fqdnID).Scan(&certID, &certPEM) + + if err == sql.ErrNoRows { + log.Printf("Kein Zertifikat für FQDN %s gefunden - RenewalInfo wird übersprungen", fqdnID) + return + } + if err != nil { + log.Printf("Fehler beim Laden des Zertifikats für FQDN %s: %v", fqdnID, err) + return + } + + if certPEM == "" { + log.Printf("Zertifikat %s hat kein PEM - RenewalInfo wird übersprungen", certID) + return + } + + // Verarbeite RenewalInfo im Hintergrund + log.Printf("Verarbeite RenewalInfo für Zertifikat %s (FQDN %s wurde aktiviert)", certID, fqdnID) + if err := ProcessRenewalInfoForCertificate(certPEM, certID, fqdnID, spaceID, true); err != nil { + log.Printf("Fehler beim Verarbeiten der RenewalInfo für FQDN %s (wird ignoriert): %v", fqdnID, err) + } else { + log.Printf("RenewalInfo erfolgreich verarbeitet für FQDN %s", fqdnID) + } + }() + } + + // Hole Username für Audit-Log + userID, username := getUserFromRequest(r) + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "renewalEnabled": req.RenewalEnabled, + }) + + // Audit-Log + if auditService != nil { + ipAddress, userAgent := getRequestInfo(r) + auditService.Track(r.Context(), "UPDATE", "fqdn", fqdnID, userID, username, map[string]interface{}{ + "spaceId": spaceID, + "renewalEnabled": req.RenewalEnabled, + "message": fmt.Sprintf("Renewal-Status für FQDN aktualisiert: %v", req.RenewalEnabled), + }, ipAddress, userAgent) + } +} + +// getRenewalQueueHandler gibt alle Einträge aus der renewal_queue zurück +func getRenewalQueueHandler(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", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Prüfe Berechtigung - jeder authentifizierte User kann die Queue sehen + userID, _ := getUserFromRequest(r) + if userID == "" { + http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized) + return + } + + // Hole alle Queue-Einträge mit FQDN und Space-Informationen + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rows, err := db.QueryContext(ctx, ` + SELECT + rq.id, + rq.certificate_id, + rq.fqdn_id, + rq.space_id, + rq.scheduled_at, + rq.status, + rq.created_at, + rq.processed_at, + rq.error_message, + f.fqdn, + s.name as space_name + FROM renewal_queue rq + LEFT JOIN fqdns f ON rq.fqdn_id = f.id + LEFT JOIN spaces s ON rq.space_id = s.id + ORDER BY rq.scheduled_at ASC + `) + + if err != nil { + http.Error(w, "Fehler beim Laden der Renewal Queue", http.StatusInternalServerError) + log.Printf("Fehler beim Laden der Renewal Queue: %v", err) + return + } + defer rows.Close() + + var queueItems []map[string]interface{} + for rows.Next() { + var id, certID, fqdnID, spaceID, scheduledAt, status, createdAt, fqdn, spaceName string + var processedAt, errorMessage sql.NullString + + err := rows.Scan(&id, &certID, &fqdnID, &spaceID, &scheduledAt, &status, &createdAt, &processedAt, &errorMessage, &fqdn, &spaceName) + if err != nil { + log.Printf("Fehler beim Scannen der Queue-Zeile: %v", err) + continue + } + + item := map[string]interface{}{ + "id": id, + "certificateId": certID, + "fqdnId": fqdnID, + "spaceId": spaceID, + "scheduledAt": scheduledAt, + "status": status, + "createdAt": createdAt, + "fqdn": fqdn, + "spaceName": spaceName, + } + + if processedAt.Valid { + item["processedAt"] = processedAt.String + } + if errorMessage.Valid { + item["errorMessage"] = errorMessage.String + } + + queueItems = append(queueItems, item) + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "queue": queueItems, + }) +} + +// 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 new file mode 100644 index 0000000..5cd0b40 --- /dev/null +++ b/backend/renewal_scheduler.go @@ -0,0 +1,350 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "strings" + "time" + + "github.com/google/uuid" + "certigo-addon-backend/providers" +) + +// StartRenewalScheduler startet den Scheduler für automatische Zertifikatserneuerungen +// Der Scheduler prüft regelmäßig die renewal_queue auf fällige Erneuerungen +func StartRenewalScheduler() { + go func() { + ticker := time.NewTicker(5 * time.Minute) // Prüfe alle 5 Minuten + defer ticker.Stop() + + log.Println("Renewal Scheduler gestartet - prüfe alle 5 Minuten auf fällige Erneuerungen") + + // Führe sofort eine Prüfung durch beim Start + processRenewalQueue() + + // Dann in regelmäßigen Intervallen + for range ticker.C { + processRenewalQueue() + } + }() +} + +// processRenewalQueue prüft die renewal_queue auf fällige Erneuerungen und führt sie aus +func processRenewalQueue() { + // Verwende kürzeren Context für die Queue-Abfrage, um Datenbankblockierungen zu vermeiden + queryCtx, queryCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer queryCancel() + + now := time.Now().UTC().Format("2006-01-02 15:04:05") + + // Hole alle fälligen Queue-Einträge (scheduled_at <= now und status = 'pending') + rows, err := db.QueryContext(queryCtx, ` + SELECT id, certificate_id, fqdn_id, space_id, scheduled_at + FROM renewal_queue + WHERE status = 'pending' AND scheduled_at <= ? + ORDER BY scheduled_at ASC + LIMIT 10 + `, now) + + if err != nil { + log.Printf("Fehler beim Abfragen der renewal_queue: %v", err) + return + } + defer rows.Close() + + var processedCount int + for rows.Next() { + var queueID, certID, fqdnID, spaceID, scheduledAt string + if err := rows.Scan(&queueID, &certID, &fqdnID, &spaceID, &scheduledAt); err != nil { + log.Printf("Fehler beim Scannen der Queue-Zeile: %v", err) + continue + } + + // Markiere als in Bearbeitung (mit angemessenem Timeout) + // Verwende Retry-Logik für Datenbankoperationen, um Blockierungen zu handhaben + var updateErr error + for retry := 0; retry < 3; retry++ { + updateCtx, updateCancel := context.WithTimeout(context.Background(), 10*time.Second) + _, updateErr = db.ExecContext(updateCtx, ` + UPDATE renewal_queue + SET status = 'processing', processed_at = ? + WHERE id = ? + `, time.Now().UTC().Format("2006-01-02 15:04:05"), queueID) + updateCancel() + if updateErr == nil { + break + } + if retry < 2 { + log.Printf("Warnung: Fehler beim Aktualisieren des Queue-Status (Versuch %d/3), versuche erneut: %v", retry+1, updateErr) + time.Sleep(time.Second * time.Duration(retry+1)) // Exponential backoff + } + } + if updateErr != nil { + log.Printf("Fehler beim Aktualisieren des Queue-Status nach 3 Versuchen: %v", updateErr) + continue + } + + // Führe Erneuerung durch (in separater Goroutine, um Datenbankblockierungen zu vermeiden) + go func(qID, cID, fID, sID string) { + if err := processCertificateRenewal(cID, fID, sID, qID); err != nil { + log.Printf("Fehler bei der Zertifikatserneuerung (Queue ID: %s): %v", qID, err) + // Markiere als fehlgeschlagen (mit Retry-Logik) + var updateErr error + for retry := 0; retry < 3; retry++ { + errorCtx, errorCancel := context.WithTimeout(context.Background(), 10*time.Second) + _, updateErr = db.ExecContext(errorCtx, ` + UPDATE renewal_queue + SET status = 'failed', error_message = ? + WHERE id = ? + `, err.Error(), qID) + errorCancel() + if updateErr == nil { + break + } + if retry < 2 { + time.Sleep(time.Second * time.Duration(retry+1)) + } + } + if updateErr != nil { + log.Printf("Fehler beim Aktualisieren des Fehlerstatus nach 3 Versuchen: %v", updateErr) + } + return + } + + // Markiere als erfolgreich (mit Retry-Logik) + var successErr error + for retry := 0; retry < 3; retry++ { + successCtx, successCancel := context.WithTimeout(context.Background(), 10*time.Second) + _, successErr = db.ExecContext(successCtx, ` + UPDATE renewal_queue + SET status = 'completed' + WHERE id = ? + `, qID) + successCancel() + if successErr == nil { + break + } + if retry < 2 { + time.Sleep(time.Second * time.Duration(retry+1)) + } + } + if successErr != nil { + log.Printf("Fehler beim Markieren als erfolgreich nach 3 Versuchen: %v", successErr) + } else { + log.Printf("Zertifikatserneuerung erfolgreich verarbeitet (Queue ID: %s, Cert ID: %s, FQDN: %s)", qID, cID, fID) + } + }(queueID, certID, fqdnID, spaceID) + + processedCount++ + } + + if processedCount > 0 { + log.Printf("Renewal Queue: %d Erneuerungen gestartet", processedCount) + } +} + +// processCertificateRenewal führt die tatsächliche Zertifikatserneuerung durch +func processCertificateRenewal(certID, fqdnID, spaceID, queueID string) error { + // Lade FQDN-Daten (mit angemessenem Timeout) + var fqdn FQDN + var acmeProviderID, acmeUsername, acmePassword, acmeEmail, acmeKeyID sql.NullString + var renewalEnabled sql.NullInt64 + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err := db.QueryRowContext(ctx, ` + SELECT id, space_id, fqdn, acme_provider_id, acme_username, acme_password, acme_email, acme_key_id, COALESCE(renewal_enabled, 1) + FROM fqdns + WHERE id = ? AND space_id = ? + `, fqdnID, spaceID).Scan( + &fqdn.ID, &fqdn.SpaceID, &fqdn.FQDN, + &acmeProviderID, &acmeUsername, &acmePassword, &acmeEmail, &acmeKeyID, &renewalEnabled, + ) + + if err != nil { + return err + } + + if acmeProviderID.String != "certigo-acmeproxy" || !acmeUsername.Valid || !acmePassword.Valid || !acmeEmail.Valid { + return fmt.Errorf("fqdn hat keine gültigen ACME-Daten") + } + + fqdn.AcmeProviderID = acmeProviderID.String + fqdn.AcmeUsername = acmeUsername.String + fqdn.AcmePassword = acmePassword.String + fqdn.AcmeEmail = acmeEmail.String + if acmeKeyID.Valid { + fqdn.AcmeKeyID = acmeKeyID.String + } + + // Lade Provider-Konfiguration + pm := providers.GetManager() + provider, exists := pm.GetProvider("certigo-acmeproxy") + if !exists || provider == nil { + return fmt.Errorf("acme provider nicht gefunden") + } + + config, err := pm.GetProviderConfig("certigo-acmeproxy") + if err != nil { + return fmt.Errorf("Fehler beim Laden der Provider-Konfiguration: %v", err) + } + + acmeProxyProvider, ok := provider.(*providers.CertigoACMEProxyProvider) + if !ok { + return fmt.Errorf("Ungültiger Provider-Typ") + } + + // Erstelle Update- und Cleanup-Funktionen (mit Retry-Logik) + updateTokenFunc := func(token string) error { + var updateErr error + for retry := 0; retry < 3; retry++ { + updateCtx, updateCancel := context.WithTimeout(context.Background(), 10*time.Second) + _, updateErr = db.ExecContext(updateCtx, "UPDATE fqdns SET acme_challenge_token = ? WHERE id = ?", token, fqdnID) + updateCancel() + if updateErr == nil { + break + } + if retry < 2 { + time.Sleep(time.Second * time.Duration(retry+1)) + } + } + if updateErr != nil { + return fmt.Errorf("fehler beim Speichern des Challenge-Tokens: %v", updateErr) + } + return acmeProxyProvider.UpdateChallengeToken(fqdn.AcmeUsername, fqdn.AcmePassword, token, config.Settings) + } + + cleanupTokenFunc := func() error { + var cleanupErr error + for retry := 0; retry < 3; retry++ { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second) + _, cleanupErr = db.ExecContext(cleanupCtx, "UPDATE fqdns SET acme_challenge_token = NULL WHERE id = ?", fqdnID) + cleanupCancel() + if cleanupErr == nil { + break + } + if retry < 2 { + time.Sleep(time.Second * time.Duration(retry+1)) + } + } + return cleanupErr + } + + // Status-Callback (optional, für Logging) + statusCallback := func(status string) { + log.Printf("[Renewal Queue %s] Status: %s", queueID, status) + } + + // Generiere TraceID + 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(acmeCtx, baseFqdn, fqdn.AcmeEmail, fqdnID, keyIDToUse, traceID, updateTokenFunc, cleanupTokenFunc, statusCallback) + if err != nil { + return fmt.Errorf("fehler beim Beantragen des neuen Zertifikats: %v", err) + } + + // Speichere neues Zertifikat + if result.Certificate != "" && result.PrivateKey != "" { + newCertID := uuid.New().String() + certificateID := result.OrderURL + if certificateID == "" { + certificateID = newCertID + } + + createdAt := time.Now().UTC().Format("2006-01-02 15:04:05") + expiresAt, isIntermediate, parseErr := ParseCertificate(result.Certificate) + var expiresAtStr string + var isIntermediateInt int + if parseErr == nil { + expiresAtStr = expiresAt.UTC().Format("2006-01-02 15:04:05") + if isIntermediate { + isIntermediateInt = 1 + } + } + + // Verwende separaten Context für INSERT mit Retry-Logik + var insertErr error + for retry := 0; retry < 3; retry++ { + insertCtx, insertCancel := context.WithTimeout(context.Background(), 10*time.Second) + _, insertErr = db.ExecContext(insertCtx, ` + INSERT INTO certificates (id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, private_key_pem, status, expires_at, is_intermediate, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, newCertID, fqdnID, spaceID, nil, certificateID, "certigo-acmeproxy", result.Certificate, result.PrivateKey, "issued", expiresAtStr, isIntermediateInt, createdAt) + insertCancel() + if insertErr == nil { + break + } + if retry < 2 { + time.Sleep(time.Second * time.Duration(retry+1)) + } + } + if insertErr != nil { + return fmt.Errorf("fehler beim Speichern des neuen Zertifikats: %v", insertErr) + } + + // Markiere altes Zertifikat (optional, falls gewünscht) + // Hier könnten wir z.B. den Status auf 'replaced' setzen + + // Verarbeite RenewalInfo für das neue Zertifikat (falls aktiviert) + renewalEnabledValue := true + if renewalEnabled.Valid { + renewalEnabledValue = renewalEnabled.Int64 == 1 + } + + if renewalEnabledValue { + go func() { + if err := ProcessRenewalInfoForCertificate(result.Certificate, newCertID, fqdnID, spaceID, true); err != nil { + log.Printf("Fehler beim Verarbeiten der RenewalInfo für erneuertes Zertifikat (wird ignoriert): %v", err) + } + }() + } + + log.Printf("Automatische Zertifikatserneuerung erfolgreich abgeschlossen (Queue ID: %s, Neues Cert ID: %s)", queueID, newCertID) + } + + // Update KeyID falls geändert (mit Retry-Logik) + if result.KeyID != "" && result.KeyID != fqdn.AcmeKeyID { + var keyIDErr error + for retry := 0; retry < 3; retry++ { + keyIDCtx, keyIDCancel := context.WithTimeout(context.Background(), 10*time.Second) + _, keyIDErr = db.ExecContext(keyIDCtx, "UPDATE fqdns SET acme_key_id = ? WHERE id = ?", result.KeyID, fqdnID) + keyIDCancel() + if keyIDErr == nil { + break + } + if retry < 2 { + time.Sleep(time.Second * time.Duration(retry+1)) + } + } + if keyIDErr != nil { + log.Printf("Warnung: Fehler beim Speichern der neuen KeyID nach 3 Versuchen: %v", keyIDErr) + } + } + + return nil +} + diff --git a/backend/renewal_test_handlers.go b/backend/renewal_test_handlers.go new file mode 100644 index 0000000..b82eb13 --- /dev/null +++ b/backend/renewal_test_handlers.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "github.com/google/uuid" +) + +// TestCreateRenewalQueueEntryRequest ist die Request-Struktur für das Erstellen eines Test-Queue-Eintrags +type TestCreateRenewalQueueEntryRequest struct { + CertificateID string `json:"certificateId"` + FQDNID string `json:"fqdnId"` + SpaceID string `json:"spaceId"` + MinutesFromNow int `json:"minutesFromNow"` // Negative Werte = in der Vergangenheit (sofort fällig) +} + +// createTestRenewalQueueEntryHandler erstellt einen Test-Queue-Eintrag für die Renewal-Funktion +// Nur für Administratoren zugänglich +func createTestRenewalQueueEntryHandler(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", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Prüfe ob User Admin ist + userID, _ := getUserFromRequest(r) + if userID == "" { + http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized) + return + } + + isAdmin, err := isUserAdmin(userID) + if err != nil || !isAdmin { + http.Error(w, "Nur Administratoren können Test-Queue-Einträge erstellen", http.StatusForbidden) + return + } + + // Parse Request Body + var req TestCreateRenewalQueueEntryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Ungültige Request-Daten", http.StatusBadRequest) + return + } + + // Validiere Eingaben + if req.CertificateID == "" || req.FQDNID == "" || req.SpaceID == "" { + http.Error(w, "certificateId, fqdnId und spaceId sind erforderlich", http.StatusBadRequest) + return + } + + // Prüfe ob Zertifikat existiert + var certExists bool + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM certificates WHERE id = ?)", req.CertificateID).Scan(&certExists) + if err != nil { + http.Error(w, "Fehler beim Prüfen des Zertifikats", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen des Zertifikats: %v", err) + return + } + if !certExists { + http.Error(w, "Zertifikat nicht gefunden", http.StatusNotFound) + return + } + + // Prüfe ob FQDN existiert + var fqdnExists bool + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM fqdns WHERE id = ?)", req.FQDNID).Scan(&fqdnExists) + if err != nil { + http.Error(w, "Fehler beim Prüfen des FQDN", http.StatusInternalServerError) + log.Printf("Fehler beim Prüfen des FQDN: %v", err) + return + } + if !fqdnExists { + http.Error(w, "FQDN nicht gefunden", http.StatusNotFound) + return + } + + // Berechne scheduled_at Zeitpunkt + now := time.Now().UTC() + scheduledAt := now.Add(time.Duration(req.MinutesFromNow) * time.Minute) + scheduledAtStr := scheduledAt.Format("2006-01-02 15:04:05") + + // Generiere eindeutige Queue-ID + queueID := fmt.Sprintf("test-%s", uuid.New().String()) + createdAt := now.Format("2006-01-02 15:04:05") + + // Erstelle Queue-Eintrag + ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _, err = db.ExecContext(ctx, ` + INSERT INTO renewal_queue (id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?) + `, queueID, req.CertificateID, req.FQDNID, req.SpaceID, scheduledAtStr, createdAt) + + if err != nil { + http.Error(w, "Fehler beim Erstellen des Queue-Eintrags", http.StatusInternalServerError) + log.Printf("Fehler beim Erstellen des Queue-Eintrags: %v", err) + return + } + + // Lade erstellten Eintrag + var entry struct { + ID string `json:"id"` + CertificateID string `json:"certificateId"` + FQDNID string `json:"fqdnId"` + SpaceID string `json:"spaceId"` + ScheduledAt string `json:"scheduledAt"` + Status string `json:"status"` + CreatedAt string `json:"createdAt"` + } + + err = db.QueryRowContext(ctx, ` + SELECT id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at + FROM renewal_queue + WHERE id = ? + `, queueID).Scan(&entry.ID, &entry.CertificateID, &entry.FQDNID, &entry.SpaceID, &entry.ScheduledAt, &entry.Status, &entry.CreatedAt) + + if err != nil { + http.Error(w, "Fehler beim Abrufen des erstellten Eintrags", http.StatusInternalServerError) + log.Printf("Fehler beim Abrufen des erstellten Eintrags: %v", err) + return + } + + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "entry": entry, + "message": fmt.Sprintf("Test-Queue-Eintrag erstellt (geplant: %s)", scheduledAtStr), + }) +} + +// triggerRenewalQueueHandler führt die Queue-Verarbeitung manuell aus +// Nur für Administratoren zugänglich +func triggerRenewalQueueHandler(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", "POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + + // Prüfe ob User Admin ist + userID, _ := getUserFromRequest(r) + if userID == "" { + http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized) + return + } + + isAdmin, err := isUserAdmin(userID) + if err != nil || !isAdmin { + http.Error(w, "Nur Administratoren können die Queue-Verarbeitung manuell auslösen", http.StatusForbidden) + return + } + + // Führe Queue-Verarbeitung in einer Goroutine aus, um den Request nicht zu blockieren + go func() { + log.Println("Manuelle Queue-Verarbeitung gestartet (via Test-Endpoint)") + processRenewalQueue() + log.Println("Manuelle Queue-Verarbeitung abgeschlossen") + }() + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Queue-Verarbeitung wurde gestartet (läuft im Hintergrund)", + }) +} + diff --git a/backend/scripts/README.md b/backend/scripts/README.md index 0bd70d9..e44539c 100644 --- a/backend/scripts/README.md +++ b/backend/scripts/README.md @@ -1,4 +1,4 @@ -# Test-Skripte für Audit-Logs +# Test-Skripte ## Test-Logs generieren @@ -40,3 +40,72 @@ curl -X DELETE "http://localhost:8080/api/audit-logs?confirm=true" \ **Wichtig**: Der `confirm=true` Query-Parameter ist erforderlich, um versehentliches Löschen zu verhindern. +## Renewal-Funktion testen + +Das Skript `test_renewal.go` erstellt Test-Queue-Einträge für die Renewal-Funktion. + +### Verwendung: + +```bash +cd backend/testing/scripts +go run test_renewal.go +``` + +### Was wird erstellt: + +- Test-Queue-Einträge mit verschiedenen Zeitstempeln: + - Einer sofort fällig (vor 1 Minute) + - Einer in 5 Minuten + - Einer in 10 Minuten +- Verwendet existierende FQDNs mit Zertifikaten +- Zeigt Queue-Status an + +### Manuelle Tests über API: + +#### 1. Test-Queue-Eintrag erstellen: + +```bash +curl -X POST "http://localhost:8080/api/renewal-queue/test/create" \ + -u admin:admin \ + -H "Content-Type: application/json" \ + -d '{ + "certificateId": "CERT_ID", + "fqdnId": "FQDN_ID", + "spaceId": "SPACE_ID", + "minutesFromNow": -5 + }' +``` + +**Hinweis**: `minutesFromNow: -5` bedeutet, dass der Eintrag vor 5 Minuten geplant war (also sofort fällig). + +#### 2. Queue-Verarbeitung manuell auslösen: + +```bash +curl -X POST "http://localhost:8080/api/renewal-queue/test/trigger" \ + -u admin:admin \ + -H "Content-Type: application/json" +``` + +Dies führt `processRenewalQueue()` direkt aus, ohne auf den Scheduler zu warten. + +#### 3. Queue-Status abrufen: + +```bash +curl -X GET "http://localhost:8080/api/renewal-queue" \ + -u admin:admin +``` + +### Aufräumen: + +Test-Queue-Einträge können über SQL gelöscht werden: + +```sql +DELETE FROM renewal_queue WHERE id LIKE 'test-%'; +``` + +Oder über die Datenbank: + +```bash +sqlite3 spaces.db "DELETE FROM renewal_queue WHERE id LIKE 'test-%';" +``` + diff --git a/backend/testing/README.md b/backend/testing/README.md new file mode 100644 index 0000000..f664b9e --- /dev/null +++ b/backend/testing/README.md @@ -0,0 +1,79 @@ +# Testing Tools + +Dieser Ordner enthält Test-Skripte für die Renewal-Funktion. + +## Struktur + +- `scripts/test_renewal.go` - Skript zum Erstellen von Test-Queue-Einträgen + +**Hinweis**: Die Test-Handler (`renewal_test_handlers.go`) befinden sich im Hauptverzeichnis (`backend/`), da sie Teil des `package main` sein müssen, um von `main.go` aufgerufen werden zu können. + +## Test-Handler + +Die Test-Handler werden automatisch in `main.go` registriert und sind nur für Administratoren zugänglich: + +- `POST /api/renewal-queue/test/create` - Erstellt einen Test-Queue-Eintrag +- `POST /api/renewal-queue/test/trigger` - Führt die Queue-Verarbeitung manuell aus + +## Test-Skript + +Das Test-Skript erstellt Test-Queue-Einträge mit verschiedenen Zeitstempeln: + +```bash +cd backend/testing/scripts +go run test_renewal.go +``` + +Das Skript: +- Findet existierende FQDNs mit Zertifikaten +- Erstellt Test-Queue-Einträge mit verschiedenen Zeitstempeln +- Zeigt den aktuellen Queue-Status an + +## Manuelle Tests über API + +### 1. Test-Queue-Eintrag erstellen: + +```bash +curl -X POST "http://localhost:8080/api/renewal-queue/test/create" \ + -u admin:admin \ + -H "Content-Type: application/json" \ + -d '{ + "certificateId": "CERT_ID", + "fqdnId": "FQDN_ID", + "spaceId": "SPACE_ID", + "minutesFromNow": -5 + }' +``` + +**Hinweis**: `minutesFromNow: -5` bedeutet, dass der Eintrag vor 5 Minuten geplant war (also sofort fällig). + +### 2. Queue-Verarbeitung manuell auslösen: + +```bash +curl -X POST "http://localhost:8080/api/renewal-queue/test/trigger" \ + -u admin:admin \ + -H "Content-Type: application/json" +``` + +Dies führt `processRenewalQueue()` direkt aus, ohne auf den Scheduler zu warten. + +### 3. Queue-Status abrufen: + +```bash +curl -X GET "http://localhost:8080/api/renewal-queue" \ + -u admin:admin +``` + +## Aufräumen + +Test-Queue-Einträge können über SQL gelöscht werden: + +```sql +DELETE FROM renewal_queue WHERE id LIKE 'test-%'; +``` + +Oder über die Datenbank: + +```bash +sqlite3 spaces.db "DELETE FROM renewal_queue WHERE id LIKE 'test-%';" +``` diff --git a/backend/testing/scripts/test_renewal.go b/backend/testing/scripts/test_renewal.go new file mode 100644 index 0000000..1498d16 --- /dev/null +++ b/backend/testing/scripts/test_renewal.go @@ -0,0 +1,223 @@ +package main + +import ( + "context" + "database/sql" + "encoding/base64" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// Test-Skript für Renewal-Funktion +// Erstellt Test-Queue-Einträge mit vergangenen Zeitstempeln, um sofortige Tests zu ermöglichen + +func main() { + // Konfiguration + // Datenbankpfad relativ zum backend-Verzeichnis (2 Ebenen höher) + dbPath := "../../spaces.db" + apiURL := "http://localhost:8080" + username := "admin" + password := "admin" + + // Öffne Datenbank + db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1&_journal_mode=WAL") + if err != nil { + log.Fatalf("Fehler beim Öffnen der Datenbank: %v", err) + } + defer db.Close() + + // Teste Verbindung + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := db.PingContext(ctx); err != nil { + log.Fatalf("Fehler beim Verbinden mit der Datenbank: %v", err) + } + + fmt.Println("=== Renewal Test-Skript ===") + fmt.Println() + + // 1. Hole existierende FQDNs mit Zertifikaten + fmt.Println("1. Suche nach FQDNs mit Zertifikaten...") + rows, err := db.QueryContext(ctx, ` + SELECT DISTINCT + f.id as fqdn_id, + f.space_id, + f.fqdn, + c.id as cert_id + FROM fqdns f + INNER JOIN certificates c ON c.fqdn_id = f.id + WHERE f.acme_provider_id = 'certigo-acmeproxy' + LIMIT 5 + `) + if err != nil { + log.Fatalf("Fehler beim Abfragen der FQDNs: %v", err) + } + defer rows.Close() + + var fqdns []map[string]string + for rows.Next() { + var fqdnID, spaceID, fqdn, certID string + if err := rows.Scan(&fqdnID, &spaceID, &fqdn, &certID); err != nil { + log.Printf("Fehler beim Scannen: %v", err) + continue + } + fqdns = append(fqdns, map[string]string{ + "fqdnId": fqdnID, + "spaceId": spaceID, + "fqdn": fqdn, + "certId": certID, + }) + } + + if len(fqdns) == 0 { + log.Fatal("Keine FQDNs mit Zertifikaten gefunden. Bitte erstelle zuerst ein Zertifikat.") + } + + fmt.Printf(" Gefunden: %d FQDNs\n", len(fqdns)) + for _, f := range fqdns { + fmt.Printf(" - %s (FQDN ID: %s, Cert ID: %s)\n", f["fqdn"], f["fqdnId"], f["certId"]) + } + fmt.Println() + + // 2. Erstelle Test-Queue-Einträge mit vergangenen Zeitstempeln + fmt.Println("2. Erstelle Test-Queue-Einträge...") + now := time.Now().UTC() + + // Erstelle Einträge mit verschiedenen Zeitstempeln: + // - Einer sofort fällig (vor 1 Minute) + // - Einer in 5 Minuten + // - Einer in 10 Minuten + testTimes := []time.Time{ + now.Add(-1 * time.Minute), // Sofort fällig + now.Add(5 * time.Minute), // In 5 Minuten + now.Add(10 * time.Minute), // In 10 Minuten + } + + createdCount := 0 + for i, fqdn := range fqdns { + if i >= len(testTimes) { + break + } + + scheduledAt := testTimes[i] + scheduledAtStr := scheduledAt.Format("2006-01-02 15:04:05") + queueID := fmt.Sprintf("test-%d-%d", time.Now().Unix(), i) + createdAt := now.Format("2006-01-02 15:04:05") + + // Prüfe ob Eintrag bereits existiert + var exists bool + err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM renewal_queue WHERE id = ?)", queueID).Scan(&exists) + if err != nil { + log.Printf("Fehler beim Prüfen: %v", err) + continue + } + + if exists { + // Lösche existierenden Eintrag + _, err = db.ExecContext(ctx, "DELETE FROM renewal_queue WHERE id = ?", queueID) + if err != nil { + log.Printf("Fehler beim Löschen: %v", err) + continue + } + } + + // Erstelle neuen Eintrag + _, err = db.ExecContext(ctx, ` + INSERT INTO renewal_queue (id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at) + VALUES (?, ?, ?, ?, ?, 'pending', ?) + `, queueID, fqdn["certId"], fqdn["fqdnId"], fqdn["spaceId"], scheduledAtStr, createdAt) + + if err != nil { + log.Printf("Fehler beim Erstellen des Queue-Eintrags: %v", err) + continue + } + + fmt.Printf(" ✓ Queue-Eintrag erstellt: %s für %s (geplant: %s)\n", queueID, fqdn["fqdn"], scheduledAtStr) + createdCount++ + } + + fmt.Printf("\n %d Queue-Einträge erstellt\n", createdCount) + fmt.Println() + + // 3. Zeige Queue-Status + fmt.Println("3. Aktuelle Queue-Status:") + queueRows, err := db.QueryContext(ctx, ` + SELECT + rq.id, + rq.scheduled_at, + rq.status, + f.fqdn + FROM renewal_queue rq + LEFT JOIN fqdns f ON rq.fqdn_id = f.id + WHERE rq.id LIKE 'test-%' + ORDER BY rq.scheduled_at ASC + `) + if err != nil { + log.Printf("Fehler beim Abfragen der Queue: %v", err) + } else { + defer queueRows.Close() + for queueRows.Next() { + var id, scheduledAt, status, fqdn string + if err := queueRows.Scan(&id, &scheduledAt, &status, &fqdn); err != nil { + continue + } + fmt.Printf(" - %s: %s (Status: %s, FQDN: %s)\n", id, scheduledAt, status, fqdn) + } + } + fmt.Println() + + // 4. Teste API-Endpunkt (manueller Trigger) + fmt.Println("4. Teste manuellen Queue-Trigger über API...") + fmt.Println(" (Dies würde normalerweise automatisch vom Scheduler ausgeführt)") + fmt.Println() + + // Erstelle Basic Auth Header + auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))) + + // Teste Queue-Status über API + client := &http.Client{Timeout: 10 * time.Second} + req, err := http.NewRequest("GET", apiURL+"/api/renewal-queue", nil) + if err != nil { + log.Printf("Fehler beim Erstellen des Requests: %v", err) + } else { + req.Header.Set("Authorization", fmt.Sprintf("Basic %s", auth)) + resp, err := client.Do(req) + if err != nil { + log.Printf("Fehler beim Abrufen der Queue: %v", err) + } else { + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + var result struct { + Success bool `json:"success"` + Queue []map[string]interface{} `json:"queue"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err == nil { + fmt.Printf(" ✓ API-Antwort erhalten: %d Einträge in der Queue\n", len(result.Queue)) + for _, item := range result.Queue { + if id, ok := item["id"].(string); ok && len(id) > 5 && id[:5] == "test-" { + fmt.Printf(" - Test-Eintrag: %s (Status: %v, Scheduled: %v)\n", + id, item["status"], item["scheduledAt"]) + } + } + } + } + } + } + + fmt.Println() + fmt.Println("=== Test abgeschlossen ===") + fmt.Println() + fmt.Println("Nächste Schritte:") + fmt.Println("1. Der Scheduler sollte automatisch die fälligen Einträge verarbeiten (alle 5 Minuten)") + fmt.Println("2. Oder warte auf die nächste Scheduler-Ausführung") + fmt.Println("3. Prüfe die Logs für Verarbeitungsdetails") + fmt.Println() + fmt.Println("Zum Aufräumen der Test-Einträge:") + fmt.Println(" DELETE FROM renewal_queue WHERE id LIKE 'test-%';") +} + diff --git a/API_CHEATSHEET.md b/docs/API_CHEATSHEET.md similarity index 100% rename from API_CHEATSHEET.md rename to docs/API_CHEATSHEET.md diff --git a/docs/AUTO_RENEWAL_KONZEPT.md b/docs/AUTO_RENEWAL_KONZEPT.md new file mode 100644 index 0000000..16cab4e --- /dev/null +++ b/docs/AUTO_RENEWAL_KONZEPT.md @@ -0,0 +1,865 @@ +# Automatische Zertifikats-Erneuerung Konzept für Let's Encrypt + +## 1. Übersicht + +### 1.1 Ziel +Implementierung einer automatischen Erneuerungsfunktion für Let's Encrypt (LE) Zertifikate, die ablaufende Zertifikate rechtzeitig erneuert, bevor sie ablaufen. + +### 1.2 Anforderungen +- **Proaktive Erneuerung**: Zertifikate werden erneuert, bevor sie ablaufen (z.B. 30 Tage vor Ablauf) +- **Automatische Ausführung**: Läuft im Hintergrund ohne Benutzerinteraktion +- **Fehlerbehandlung**: Robustes Error-Handling und Retry-Mechanismus +- **Logging & Monitoring**: Umfassendes Logging für Nachverfolgbarkeit +- **Konfigurierbarkeit**: Erneuerungs-Schwellenwerte und Intervalle konfigurierbar +- **Berechtigungen**: Respektiert bestehende Permission-Systeme +- **DNS-Validierung**: Automatische DNS-Challenge-Validierung vor Erneuerung + +--- + +## 2. Architektur + +### 2.1 Komponenten-Übersicht + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Auto-Renewal System │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │ +│ │ Scheduler │───>│ Certificate │───>│ Renewal │ │ +│ │ (Cron) │ │ Scanner │ │ Worker │ │ +│ └──────────────┘ └──────────────┘ └─────────────┘ │ +│ │ │ │ │ +│ │ │ │ │ +│ v v v │ +│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │ +│ │ Config │ │ Database │ │ ACME │ │ +│ │ Manager │ │ Queries │ │ Client │ │ +│ └──────────────┘ └──────────────┘ └─────────────┘ │ +│ │ +│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │ +│ │ Logger │ │ Notifier │ │ Retry │ │ +│ │ Service │ │ Service │ │ Manager │ │ +│ └──────────────┘ └──────────────┘ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Workflow + +``` +1. Scheduler startet (z.B. täglich um 02:00 Uhr) + │ + ├─> 2. Scanner identifiziert ablaufende Zertifikate + │ (expires_at < now + renewal_threshold) + │ + ├─> 3. Für jedes Zertifikat: + │ │ + │ ├─> 3.1 Prüfe ob Auto-Renewal aktiviert + │ │ + │ ├─> 3.2 Prüfe ob bereits Erneuerung läuft + │ │ + │ ├─> 3.3 Prüfe Berechtigungen (Space-Zugriff) + │ │ + │ ├─> 3.4 Validiere DNS (CNAME Check) + │ │ + │ ├─> 3.5 Erstelle Renewal-Job + │ │ + │ └─> 3.6 Queue für Worker + │ + ├─> 4. Worker verarbeitet Jobs sequenziell + │ │ + │ ├─> 4.1 Hole FQDN-Informationen + │ │ + │ ├─> 4.2 Hole ACME-Provider-Konfiguration + │ │ + │ ├─> 4.3 Rufe RequestCertificate() auf + │ │ + │ ├─> 4.4 Speichere neues Zertifikat + │ │ + │ ├─> 4.5 Markiere altes Zertifikat als "replaced" + │ │ + │ └─> 4.6 Logge Erfolg/Fehler + │ + └─> 5. Cleanup & Reporting +``` + +--- + +## 3. Datenbank-Schema + +### 3.1 Erweiterte Certificates-Tabelle + +```sql +-- Migration: Erweitere certificates-Tabelle um Auto-Renewal-Felder +ALTER TABLE certificates ADD COLUMN auto_renewal_enabled BOOLEAN DEFAULT 1; +ALTER TABLE certificates ADD COLUMN renewal_attempts INTEGER DEFAULT 0; +ALTER TABLE certificates ADD COLUMN last_renewal_attempt DATETIME; +ALTER TABLE certificates ADD COLUMN next_renewal_check DATETIME; +ALTER TABLE certificates ADD COLUMN renewal_status TEXT; -- 'pending', 'in_progress', 'success', 'failed', 'disabled' +ALTER TABLE certificates ADD COLUMN replaced_by_cert_id TEXT; -- ID des neuen Zertifikats +ALTER TABLE certificates ADD COLUMN replaces_cert_id TEXT; -- ID des ersetzten Zertifikats +``` + +### 3.2 Neue Tabelle: certificate_renewal_logs + +```sql +CREATE TABLE IF NOT EXISTS certificate_renewal_logs ( + id TEXT PRIMARY KEY, + certificate_id TEXT NOT NULL, + fqdn_id TEXT NOT NULL, + space_id TEXT NOT NULL, + renewal_status TEXT NOT NULL, -- 'started', 'success', 'failed', 'skipped' + renewal_reason TEXT, -- 'expiring_soon', 'manual', 'retry' + error_message TEXT, + old_expires_at DATETIME, + new_expires_at DATETIME, + new_certificate_id TEXT, + renewal_duration_seconds INTEGER, + trace_id TEXT, + created_at DATETIME NOT NULL, + FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE CASCADE, + FOREIGN KEY (fqdn_id) REFERENCES fqdns(id) ON DELETE CASCADE, + FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE +); + +CREATE INDEX idx_renewal_logs_certificate_id ON certificate_renewal_logs(certificate_id); +CREATE INDEX idx_renewal_logs_created_at ON certificate_renewal_logs(created_at); +CREATE INDEX idx_renewal_logs_status ON certificate_renewal_logs(renewal_status); +``` + +### 3.3 Neue Tabelle: renewal_config + +```sql +CREATE TABLE IF NOT EXISTS renewal_config ( + id TEXT PRIMARY KEY DEFAULT 'global', + enabled BOOLEAN DEFAULT 1, + renewal_threshold_days INTEGER DEFAULT 30, -- Erneuere X Tage vor Ablauf + check_interval_hours INTEGER DEFAULT 24, -- Wie oft prüfen (in Stunden) + max_renewal_attempts INTEGER DEFAULT 3, -- Max. Versuche pro Zertifikat + retry_delay_hours INTEGER DEFAULT 24, -- Wartezeit zwischen Retries + notification_enabled BOOLEAN DEFAULT 0, + notification_email TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); + +-- Initiale Konfiguration einfügen +INSERT INTO renewal_config (id, enabled, renewal_threshold_days, check_interval_hours, max_renewal_attempts, retry_delay_hours, created_at, updated_at) +VALUES ('global', 1, 30, 24, 3, 24, datetime('now'), datetime('now')); +``` + +### 3.4 FQDN-Tabelle Erweiterung + +```sql +-- Optional: Pro-FQDN Auto-Renewal-Einstellungen +ALTER TABLE fqdns ADD COLUMN auto_renewal_enabled BOOLEAN DEFAULT 1; +``` + +--- + +## 4. Konfiguration + +### 4.1 Global Configuration (Environment Variables) + +```bash +# Auto-Renewal Einstellungen +AUTO_RENEWAL_ENABLED=true +AUTO_RENEWAL_THRESHOLD_DAYS=30 +AUTO_RENEWAL_CHECK_INTERVAL_HOURS=24 +AUTO_RENEWAL_SCHEDULE="0 2 * * *" # Cron-Format: Täglich um 02:00 Uhr +AUTO_RENEWAL_MAX_ATTEMPTS=3 +AUTO_RENEWAL_RETRY_DELAY_HOURS=24 + +# Notifications +AUTO_RENEWAL_NOTIFICATIONS_ENABLED=false +AUTO_RENEWAL_NOTIFICATION_EMAIL=admin@example.com + +# Concurrency +AUTO_RENEWAL_MAX_CONCURRENT=1 # Anzahl paralleler Erneuerungen +``` + +### 4.2 Per-FQDN Configuration + +- **Default**: Auto-Renewal aktiviert für alle FQDNs +- **Opt-out**: Pro FQDN deaktivierbar über `fqdns.auto_renewal_enabled` +- **Opt-out**: Pro Zertifikat deaktivierbar über `certificates.auto_renewal_enabled` + +--- + +## 5. Scheduler-Implementierung + +### 5.1 Optionen + +#### Option A: Go Cron Library (Empfohlen) +```go +import "github.com/robfig/cron/v3" + +c := cron.New() +c.AddFunc("0 2 * * *", func() { + runCertificateRenewalScan() +}) +c.Start() +``` + +**Vorteile:** +- Einfach zu implementieren +- Gut getestet +- Cron-Format unterstützt + +**Nachteile:** +- Läuft nur im Backend-Prozess +- Bei Neustart muss Scheduler neu gestartet werden + +#### Option B: Separate Background Service +```go +// Separate Go-Routine die kontinuierlich läuft +go func() { + ticker := time.NewTicker(24 * time.Hour) + for { + select { + case <-ticker.C: + runCertificateRenewalScan() + case <-ctx.Done(): + return + } + } +}() +``` + +**Vorteile:** +- Einfacher zu debuggen +- Keine externe Dependency + +**Nachteile:** +- Weniger flexibel als Cron +- Muss selbst implementiert werden + +#### Option C: System Cron Job +```bash +# /etc/cron.d/certigo-renewal +0 2 * * * curl -X POST http://localhost:8080/api/internal/renewal/scan +``` + +**Vorteile:** +- Unabhängig vom Backend-Prozess +- Läuft auch wenn Backend neu gestartet wird + +**Nachteile:** +- Externe Dependency (curl) +- Schwieriger zu debuggen +- Benötigt separaten API-Endpunkt + +**Empfehlung: Option A (Go Cron Library)** + +--- + +## 6. Certificate Scanner + +### 6.1 Query für ablaufende Zertifikate + +```sql +SELECT + c.id, + c.fqdn_id, + c.space_id, + c.certificate_id, + c.provider_id, + c.expires_at, + c.auto_renewal_enabled, + c.renewal_status, + c.renewal_attempts, + c.last_renewal_attempt, + f.fqdn, + f.acme_email, + f.acme_key_id, + f.provider_id as fqdn_provider_id +FROM certificates c +INNER JOIN fqdns f ON c.fqdn_id = f.id +WHERE + -- Nur Leaf-Zertifikate (nicht Intermediate) + c.is_intermediate = 0 + -- Nur Let's Encrypt Zertifikate (via certigo-acmeproxy) + AND c.provider_id = 'certigo-acmeproxy' + -- Nur gültige/ausgestellte Zertifikate + AND c.status = 'issued' + -- Auto-Renewal muss aktiviert sein + AND (c.auto_renewal_enabled IS NULL OR c.auto_renewal_enabled = 1) + AND (f.auto_renewal_enabled IS NULL OR f.auto_renewal_enabled = 1) + -- Zertifikat läuft bald ab + AND c.expires_at IS NOT NULL + AND datetime(c.expires_at) <= datetime('now', '+' || ? || ' days') + -- Keine laufende Erneuerung + AND (c.renewal_status IS NULL OR c.renewal_status != 'in_progress') + -- Nicht zu viele Versuche + AND (c.renewal_attempts IS NULL OR c.renewal_attempts < ?) + -- Retry-Delay eingehalten + AND ( + c.last_renewal_attempt IS NULL + OR datetime(c.last_renewal_attempt) <= datetime('now', '-' || ? || ' hours') + ) +ORDER BY c.expires_at ASC; +``` + +### 6.2 Filter-Logik + +**Ausschluss-Kriterien:** +1. ✅ Intermediate-Zertifikate (nur Leaf) +2. ✅ Nur `certigo-acmeproxy` Provider +3. ✅ Status = 'issued' +4. ✅ Auto-Renewal aktiviert (Certificate + FQDN) +5. ✅ `expires_at` innerhalb Threshold +6. ✅ Keine laufende Erneuerung (`renewal_status != 'in_progress'`) +7. ✅ Max. Versuche nicht überschritten +8. ✅ Retry-Delay eingehalten + +--- + +## 7. Renewal Worker + +### 7.1 Renewal-Prozess + +```go +func renewCertificate(certID string, fqdnID string, spaceID string) error { + traceID := generateTraceID() + + // 1. Markiere als "in_progress" + updateRenewalStatus(certID, "in_progress", traceID) + + // 2. Hole FQDN-Informationen + fqdn, err := getFQDN(fqdnID) + if err != nil { + logRenewalError(certID, traceID, "FQDN nicht gefunden", err) + return err + } + + // 3. Prüfe DNS (CNAME) + if !validateDNSCNAME(fqdn.FQDN) { + logRenewalError(certID, traceID, "DNS-CNAME nicht gültig", nil) + return fmt.Errorf("DNS validation failed") + } + + // 4. Hole ACME-Provider-Konfiguration + providerConfig, err := getACMEProviderConfig(fqdn.ProviderID) + if err != nil { + logRenewalError(certID, traceID, "Provider-Konfiguration nicht gefunden", err) + return err + } + + // 5. Rufe RequestCertificate() auf + result, err := RequestCertificate( + fqdn.FQDN, + fqdn.AcmeEmail, + fqdnID, + fqdn.AcmeKeyID, + traceID, + updateTokenFunc, + cleanupTokenFunc, + statusCallback, + ) + + if err != nil { + // 6a. Fehler: Erhöhe Versuche, setze Retry-Zeitpunkt + incrementRenewalAttempts(certID) + setNextRenewalCheck(certID, time.Now().Add(retryDelay)) + updateRenewalStatus(certID, "failed", traceID) + logRenewalError(certID, traceID, "Erneuerung fehlgeschlagen", err) + return err + } + + // 6b. Erfolg: Speichere neues Zertifikat + newCertID, err := saveNewCertificate(result, fqdnID, spaceID) + if err != nil { + logRenewalError(certID, traceID, "Fehler beim Speichern", err) + return err + } + + // 7. Verknüpfe alte und neue Zertifikate + linkCertificates(certID, newCertID) + + // 8. Markiere als erfolgreich + updateRenewalStatus(certID, "success", traceID) + logRenewalSuccess(certID, newCertID, traceID) + + // 9. Optional: Benachrichtigung senden + sendRenewalNotification(fqdn.FQDN, newCertID, traceID) + + return nil +} +``` + +### 7.2 Concurrency Control + +**Sequenzielle Verarbeitung:** +- Pro FQDN nur eine Erneuerung gleichzeitig +- Pro Space max. N Erneuerungen parallel (konfigurierbar) +- Global max. M Erneuerungen parallel (konfigurierbar) + +**Implementierung:** +```go +// Semaphore für Concurrency Control +var renewalSemaphore = make(chan struct{}, maxConcurrentRenewals) + +func renewCertificateWithLock(certID string, fqdnID string, spaceID string) error { + renewalSemaphore <- struct{}{} // Acquire + defer func() { <-renewalSemaphore }() // Release + + return renewCertificate(certID, fqdnID, spaceID) +} +``` + +--- + +## 8. Fehlerbehandlung & Retry + +### 8.1 Fehler-Kategorien + +| Fehler-Typ | Retry? | Max. Versuche | Beispiel | +|-----------|--------|--------------|----------| +| DNS-Validierung fehlgeschlagen | ✅ Ja | 3 | CNAME nicht gesetzt | +| ACME-Provider-Fehler | ✅ Ja | 3 | Rate Limit erreicht | +| Netzwerk-Fehler | ✅ Ja | 5 | Timeout, Connection Error | +| Konfigurations-Fehler | ❌ Nein | 0 | Provider nicht konfiguriert | +| Berechtigungs-Fehler | ❌ Nein | 0 | Kein Space-Zugriff | + +### 8.2 Retry-Strategie + +**Exponential Backoff:** +``` +Versuch 1: Sofort +Versuch 2: Nach 24 Stunden +Versuch 3: Nach 48 Stunden +Versuch 4+: Nach 72 Stunden +``` + +**Oder: Fixed Delay** +``` +Alle Retries: Nach X Stunden (konfigurierbar, Default: 24h) +``` + +### 8.3 Fehler-Logging + +```go +type RenewalError struct { + CertificateID string + FQDN string + ErrorType string // 'dns', 'acme', 'network', 'config' + ErrorMessage string + TraceID string + Timestamp time.Time + Attempt int +} +``` + +--- + +## 9. Logging & Monitoring + +### 9.1 Structured Logging + +**Erfolgreiche Erneuerung:** +```json +{ + "event": "certificate_renewal_success", + "trace_id": "abc123", + "certificate_id": "cert-uuid", + "fqdn": "example.com", + "old_expires_at": "2025-02-15T10:00:00Z", + "new_expires_at": "2025-05-15T10:00:00Z", + "new_certificate_id": "new-cert-uuid", + "duration_seconds": 45, + "timestamp": "2025-01-15T02:05:00Z" +} +``` + +**Fehlgeschlagene Erneuerung:** +```json +{ + "event": "certificate_renewal_failed", + "trace_id": "abc123", + "certificate_id": "cert-uuid", + "fqdn": "example.com", + "error_type": "dns_validation", + "error_message": "CNAME record not found", + "attempt": 1, + "max_attempts": 3, + "next_retry": "2025-01-16T02:00:00Z", + "timestamp": "2025-01-15T02:05:00Z" +} +``` + +### 9.2 Audit Logs + +**Integration in bestehendes Audit-System:** +```go +auditService.Track(ctx, "RENEW", "certificate", certID, "system", "auto-renewal", map[string]interface{}{ + "fqdn": fqdn, + "old_expires_at": oldExpiresAt, + "new_expires_at": newExpiresAt, + "trace_id": traceID, +}, ipAddress, userAgent) +``` + +### 9.3 Metrics + +**Zu tracken:** +- Anzahl Erneuerungen pro Tag/Woche/Monat +- Erfolgsrate (Erfolgreich / Gesamt) +- Durchschnittliche Erneuerungsdauer +- Anzahl fehlgeschlagener Erneuerungen +- Anzahl Retries +- Zertifikate die bald ablaufen (Warnung) + +--- + +## 10. API-Endpunkte + +### 10.1 Manuelle Erneuerung + +**POST** `/api/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/renew` + +Manuell eine Erneuerung auslösen. + +**Response:** +```json +{ + "success": true, + "message": "Erneuerung gestartet", + "trace_id": "abc123", + "estimated_completion": "2025-01-15T02:05:00Z" +} +``` + +### 10.2 Erneuerungs-Status abrufen + +**GET** `/api/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/renewal-status` + +**Response:** +```json +{ + "auto_renewal_enabled": true, + "renewal_status": "success", + "renewal_attempts": 1, + "last_renewal_attempt": "2025-01-15T02:00:00Z", + "next_renewal_check": "2025-01-16T02:00:00Z", + "replaced_by_cert_id": "new-cert-uuid" +} +``` + +### 10.3 Erneuerungs-Logs abrufen + +**GET** `/api/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/renewal-logs` + +**Query Parameters:** +- `limit` (optional): Anzahl Einträge (Default: 50) +- `offset` (optional): Pagination Offset + +**Response:** +```json +{ + "logs": [ + { + "id": "log-uuid", + "renewal_status": "success", + "renewal_reason": "expiring_soon", + "old_expires_at": "2025-02-15T10:00:00Z", + "new_expires_at": "2025-05-15T10:00:00Z", + "new_certificate_id": "new-cert-uuid", + "renewal_duration_seconds": 45, + "trace_id": "abc123", + "created_at": "2025-01-15T02:00:00Z" + } + ], + "total": 10, + "limit": 50, + "offset": 0 +} +``` + +### 10.4 Auto-Renewal konfigurieren + +**PUT** `/api/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/auto-renewal` + +**Body:** +```json +{ + "enabled": true +} +``` + +### 10.5 Global Configuration + +**GET** `/api/internal/renewal/config` + +**Response:** +```json +{ + "enabled": true, + "renewal_threshold_days": 30, + "check_interval_hours": 24, + "max_renewal_attempts": 3, + "retry_delay_hours": 24 +} +``` + +**PUT** `/api/internal/renewal/config` + +**Body:** +```json +{ + "enabled": true, + "renewal_threshold_days": 30, + "check_interval_hours": 24, + "max_renewal_attempts": 3, + "retry_delay_hours": 24 +} +``` + +### 10.6 Manueller Scan (für Testing) + +**POST** `/api/internal/renewal/scan` + +Löst einen manuellen Scan aus (nur für Admins). + +**Response:** +```json +{ + "success": true, + "certificates_found": 5, + "certificates_queued": 3, + "certificates_skipped": 2 +} +``` + +--- + +## 11. Frontend-Integration + +### 11.1 UI-Komponenten + +#### Auto-Renewal Toggle +- **Ort**: Certificate Detail View +- **Funktion**: Ein/Aus-Schalter für Auto-Renewal pro Zertifikat + +#### Renewal Status Badge +- **Ort**: Certificate List & Detail View +- **Anzeige**: + - 🟢 "Auto-Renewal aktiv" (wenn enabled) + - 🟡 "Erneuerung läuft" (wenn in_progress) + - 🔴 "Erneuerung fehlgeschlagen" (wenn failed) + - ⚪ "Auto-Renewal deaktiviert" (wenn disabled) + +#### Renewal History +- **Ort**: Certificate Detail View +- **Anzeige**: Tabelle mit Erneuerungs-Logs +- **Spalten**: Datum, Status, Grund, Neue Ablaufzeit, Trace ID + +#### Manuelle Erneuerung Button +- **Ort**: Certificate Detail View +- **Funktion**: "Jetzt erneuern" Button (falls Auto-Renewal deaktiviert) + +#### Upcoming Renewals Dashboard +- **Ort**: Dashboard/Overview +- **Anzeige**: Liste von Zertifikaten die bald erneuert werden +- **Filter**: Nach Space, FQDN, Ablaufdatum + +### 11.2 Notifications (Optional) + +**Email-Benachrichtigungen:** +- Erfolgreiche Erneuerung +- Fehlgeschlagene Erneuerung (nach max. Versuchen) +- Warnung: Zertifikat läuft in X Tagen ab (falls Erneuerung fehlschlägt) + +**In-App Notifications:** +- Toast-Notification bei erfolgreicher Erneuerung +- Alert bei fehlgeschlagener Erneuerung + +--- + +## 12. Sicherheit & Berechtigungen + +### 12.1 Berechtigungen + +**Auto-Renewal ausführen:** +- System-User (für automatische Erneuerungen) +- Admin-User (für manuelle Erneuerungen) +- User mit `FULL_ACCESS` auf Space (für manuelle Erneuerungen) + +**Auto-Renewal konfigurieren:** +- Admin-User +- User mit `FULL_ACCESS` auf Space + +**Erneuerungs-Logs anzeigen:** +- Alle User mit Space-Zugriff (READ-Berechtigung) + +### 12.2 Rate Limiting + +**Let's Encrypt Rate Limits:** +- 50 Certificates per Registered Domain per week +- 300 New Orders per Account per 3 hours + +**Schutz:** +- Tracke Anzahl Erneuerungen pro FQDN +- Verzögere Erneuerung wenn Rate Limit erreicht +- Logge Warnung bei Rate Limit + +--- + +## 13. Testing & Rollout + +### 13.1 Test-Plan + +**Phase 1: Unit Tests** +- [ ] Certificate Scanner Query +- [ ] Renewal Worker Logic +- [ ] Retry-Mechanismus +- [ ] Error-Handling + +**Phase 2: Integration Tests** +- [ ] End-to-End Erneuerung (mit Staging ACME) +- [ ] Fehler-Szenarien (DNS-Fehler, Rate Limit) +- [ ] Concurrency Tests + +**Phase 3: Staging Tests** +- [ ] Test mit echten Staging-Zertifikaten +- [ ] Monitoring & Logging prüfen +- [ ] Performance-Tests + +**Phase 4: Production Rollout** +- [ ] Feature Flag aktivieren +- [ ] Monitoring aktivieren +- [ ] Schrittweise Aktivierung (zuerst einzelne FQDNs) + +### 13.2 Rollback-Plan + +**Falls Probleme auftreten:** +1. Auto-Renewal global deaktivieren (Config) +2. Laufende Erneuerungen abbrechen (Status zurücksetzen) +3. Manuelle Erneuerung weiterhin möglich + +--- + +## 14. Monitoring & Alerting + +### 14.1 Health Checks + +**Endpoint:** `GET /api/health/renewal` + +**Response:** +```json +{ + "status": "healthy", + "last_scan": "2025-01-15T02:00:00Z", + "next_scan": "2025-01-16T02:00:00Z", + "certificates_pending": 2, + "certificates_in_progress": 1, + "certificates_failed": 0 +} +``` + +### 14.2 Alerts + +**Zu überwachen:** +- ❌ Auto-Renewal Service läuft nicht +- ⚠️ Viele fehlgeschlagene Erneuerungen (> 10% in 24h) +- ⚠️ Zertifikate laufen in < 7 Tagen ab (ohne Erneuerung) +- ⚠️ Rate Limit erreicht +- ⚠️ Scheduler läuft nicht (letzter Scan > 48h her) + +--- + +## 15. Zukünftige Erweiterungen + +### 15.1 Multi-Provider Support +- Erneuerung für andere Provider (nicht nur Let's Encrypt) + +### 15.2 Smart Scheduling +- Erneuerung basierend auf Traffic-Patterns +- Erneuerung außerhalb der Geschäftszeiten + +### 15.3 Batch Renewals +- Erneuerung mehrerer Zertifikate gleichzeitig (wenn möglich) + +### 15.4 Webhook-Integration +- Webhooks für erfolgreiche/fehlgeschlagene Erneuerungen +- Integration mit externen Monitoring-Tools + +### 15.5 Certificate Rotation +- Automatische Rotation von Private Keys +- Unterstützung für Key-Rollover + +--- + +## 16. Abhängigkeiten + +### 16.1 Backend (Go) + +```go +// Cron Scheduler +github.com/robfig/cron/v3 + +// (Bereits vorhanden) +// - ACME Client (acme_client.go) +// - Certificate Parser (cert_parser.go) +// - Logger (cert_logger.go) +``` + +### 16.2 Frontend + +Keine zusätzlichen Dependencies nötig. + +--- + +## 17. Risiken & Mitigation + +### 17.1 Risiken + +| Risiko | Wahrscheinlichkeit | Impact | Mitigation | +|--------|-------------------|--------|------------| +| Rate Limit erreicht | Mittel | Hoch | Rate Limit Tracking, Verzögerung | +| DNS-Validierung fehlschlägt | Mittel | Hoch | DNS-Check vor Erneuerung, Retry | +| ACME-Provider Downtime | Niedrig | Hoch | Retry-Mechanismus, Fallback | +| Doppelte Erneuerung | Niedrig | Mittel | Status-Check, Locking | +| Datenbank-Lock | Niedrig | Mittel | Transaktionen, Timeouts | + +### 17.2 Best Practices + +- ✅ Idempotenz: Erneuerung kann mehrfach ausgeführt werden ohne Probleme +- ✅ Atomic Operations: Datenbank-Transaktionen für Konsistenz +- ✅ Graceful Degradation: Bei Fehlern weiterhin manuelle Erneuerung möglich +- ✅ Comprehensive Logging: Alle Schritte loggen für Debugging +- ✅ Rate Limit Awareness: Respektiere Let's Encrypt Limits + +--- + +## 18. Zusammenfassung + +### 18.1 Vorteile + +- **Automatisierung**: Keine manuelle Intervention nötig +- **Zuverlässigkeit**: Zertifikate laufen nicht mehr ab +- **Zeitersparnis**: Weniger manuelle Arbeit +- **Sicherheit**: Immer gültige Zertifikate + +### 18.2 Herausforderungen + +- **Komplexität**: Zusätzliche Infrastruktur und Code +- **Fehlerbehandlung**: Robustes Error-Handling erforderlich +- **Rate Limits**: Let's Encrypt Limits beachten +- **Testing**: Umfangreiche Tests erforderlich + +### 18.3 Empfohlene Implementierungs-Reihenfolge + +1. **Phase 1**: Datenbank-Schema & Grundfunktionalität +2. **Phase 2**: Scanner & Worker +3. **Phase 3**: Scheduler & Automation +4. **Phase 4**: Frontend-Integration +5. **Phase 5**: Monitoring & Alerting +6. **Phase 6**: Notifications (Optional) + +--- + +**Erstellt am**: 2025-01-XX +**Version**: 1.0 +**Status**: Konzept - Noch nicht implementiert + diff --git a/DB_COMMANDS.md b/docs/DB_COMMANDS.md similarity index 100% rename from DB_COMMANDS.md rename to docs/DB_COMMANDS.md diff --git a/docs/OAUTH_KONZEPT.md b/docs/OAUTH_KONZEPT.md new file mode 100644 index 0000000..ee7da96 --- /dev/null +++ b/docs/OAUTH_KONZEPT.md @@ -0,0 +1,754 @@ +# OAuth 2.0 Integration Konzept für Certigo + +## 1. Übersicht + +### 1.1 Ziel +Integration von OAuth 2.0 als zusätzliche Authentifizierungsmethode neben dem bestehenden Basic Authentication System. Benutzer sollen sich mit externen OAuth-Providern (z.B. Google, Microsoft, GitHub) anmelden können. + +### 1.2 Anforderungen +- **Hybrides System**: OAuth und Basic Auth parallel unterstützen +- **User Linking**: OAuth-Benutzer mit bestehenden lokalen Accounts verknüpfen können +- **Automatische User-Erstellung**: Neue OAuth-Benutzer automatisch anlegen +- **Berechtigungssystem**: OAuth-Benutzer in bestehendes Permission-System integrieren +- **Session Management**: Sichere Session-Verwaltung für OAuth-Logins +- **Multi-Provider**: Unterstützung mehrerer OAuth-Provider gleichzeitig + +--- + +## 2. Architektur + +### 2.1 OAuth Flow (Authorization Code Flow) + +``` +┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐ +│ Browser │ │ Frontend │ │ Backend │ │ OAuth │ +│ │ │ │ │ │ │ Provider │ +└────┬────┘ └────┬─────┘ └──────┬──────┘ └────┬─────┘ + │ │ │ │ + │ 1. Login Button │ │ │ + │──────────────────>│ │ │ + │ │ │ │ + │ │ 2. GET /api/oauth/{provider}/auth │ + │ │─────────────────────>│ │ + │ │ │ │ + │ │ │ 3. Redirect to OAuth │ + │ │ │ Authorization URL │ + │ │<─────────────────────│ │ + │ │ │ │ + │ 4. Redirect to │ │ │ + │ OAuth Provider │ │ │ + │<──────────────────│ │ │ + │ │ │ │ + │ 5. User Auth │ │ │ + │────────────────────────────────────────────────────────────────>│ + │ │ │ │ + │ 6. Callback with │ │ │ + │ Authorization │ │ │ + │ Code │ │ │ + │<────────────────────────────────────────────────────────────────│ + │ │ │ │ + │ 7. Callback to │ │ │ + │ /api/oauth/ │ │ │ + │ {provider}/ │ │ │ + │ callback │ │ │ + │──────────────────>│ │ │ + │ │ 8. POST /api/oauth/{provider}/callback │ + │ │ (code=xxx) │ │ + │ │─────────────────────>│ │ + │ │ │ │ + │ │ │ 9. Exchange Code for │ + │ │ │ Access Token │ + │ │ │──────────────────────>│ + │ │ │ │ + │ │ │ 10. Get User Info │ + │ │ │──────────────────────>│ + │ │ │ │ + │ │ │ 11. User Info │ + │ │ │<──────────────────────│ + │ │ │ │ + │ │ │ 12. Create/Update │ + │ │ │ User in DB │ + │ │ │ │ + │ │ │ 13. Create Session │ + │ │ │ │ + │ │ 14. Return Session │ │ + │ │ Token │ │ + │ │<─────────────────────│ │ + │ │ │ │ + │ 15. Store Session │ │ │ + │ & Redirect │ │ │ + │<──────────────────│ │ │ + │ │ │ │ +``` + +### 2.2 Komponenten + +#### Backend +- **OAuth Handler**: `/api/oauth/{provider}/auth` - Initiierung des OAuth Flows +- **OAuth Callback Handler**: `/api/oauth/{provider}/callback` - Verarbeitung des Authorization Codes +- **OAuth Provider Manager**: Verwaltung mehrerer OAuth-Provider +- **Session Manager**: Verwaltung von OAuth-Sessions (JWT oder Session-Tokens) +- **User Linking Service**: Verknüpfung von OAuth-Accounts mit lokalen Accounts + +#### Frontend +- **OAuth Login Component**: Buttons für verschiedene OAuth-Provider +- **OAuth Callback Handler**: Verarbeitung des Redirects nach OAuth-Authentifizierung +- **Session Storage**: Speicherung von Session-Tokens (HttpOnly Cookies bevorzugt) + +--- + +## 3. Datenbank-Schema + +### 3.1 Erweiterte Users-Tabelle + +```sql +-- Migration: Erweitere users-Tabelle um OAuth-Felder +ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'basic'; -- 'basic' | 'oauth' | 'hybrid' +ALTER TABLE users ADD COLUMN oauth_provider TEXT; -- 'google' | 'microsoft' | 'github' | NULL +ALTER TABLE users ADD COLUMN oauth_provider_id TEXT; -- Externe User-ID vom OAuth-Provider +ALTER TABLE users ADD COLUMN oauth_email TEXT; -- Email vom OAuth-Provider (kann von lokaler Email abweichen) +ALTER TABLE users ADD COLUMN password_hash TEXT; -- NULL für reine OAuth-User +``` + +### 3.2 Neue Tabelle: oauth_sessions + +```sql +CREATE TABLE IF NOT EXISTS oauth_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + access_token TEXT, -- Verschlüsselt gespeichert + refresh_token TEXT, -- Verschlüsselt gespeichert + expires_at DATETIME NOT NULL, + created_at DATETIME NOT NULL, + last_used_at DATETIME, + ip_address TEXT, + user_agent TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE INDEX idx_oauth_sessions_user_id ON oauth_sessions(user_id); +CREATE INDEX idx_oauth_sessions_expires_at ON oauth_sessions(expires_at); +``` + +### 3.3 Neue Tabelle: oauth_providers + +```sql +CREATE TABLE IF NOT EXISTS oauth_providers ( + id TEXT PRIMARY KEY, -- 'google', 'microsoft', 'github' + name TEXT NOT NULL, + enabled BOOLEAN DEFAULT 1, + client_id TEXT NOT NULL, + client_secret TEXT NOT NULL, -- Verschlüsselt gespeichert + authorization_url TEXT NOT NULL, + token_url TEXT NOT NULL, + user_info_url TEXT NOT NULL, + scopes TEXT, -- JSON Array: ["openid", "email", "profile"] + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL +); +``` + +### 3.4 Neue Tabelle: user_oauth_links + +```sql +-- Für Benutzer, die mehrere OAuth-Provider verknüpfen wollen +CREATE TABLE IF NOT EXISTS user_oauth_links ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + provider_email TEXT, + linked_at DATETIME NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(provider, provider_user_id) +); + +CREATE INDEX idx_user_oauth_links_user_id ON user_oauth_links(user_id); +CREATE INDEX idx_user_oauth_links_provider ON user_oauth_links(provider, provider_user_id); +``` + +--- + +## 4. OAuth Provider Konfiguration + +### 4.1 Unterstützte Provider + +#### Google OAuth 2.0 +- **Authorization URL**: `https://accounts.google.com/o/oauth2/v2/auth` +- **Token URL**: `https://oauth2.googleapis.com/token` +- **User Info URL**: `https://www.googleapis.com/oauth2/v2/userinfo` +- **Scopes**: `["openid", "email", "profile"]` + +#### Microsoft Azure AD +- **Authorization URL**: `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize` +- **Token URL**: `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token` +- **User Info URL**: `https://graph.microsoft.com/v1.0/me` +- **Scopes**: `["openid", "email", "profile"]` + +#### GitHub +- **Authorization URL**: `https://github.com/login/oauth/authorize` +- **Token URL**: `https://github.com/login/oauth/access_token` +- **User Info URL**: `https://api.github.com/user` +- **Scopes**: `["user:email"]` + +### 4.2 Provider-Konfiguration (Environment Variables / Config File) + +```yaml +oauth: + providers: + google: + enabled: true + client_id: "${GOOGLE_CLIENT_ID}" + client_secret: "${GOOGLE_CLIENT_SECRET}" + redirect_uri: "http://localhost:5173/api/oauth/google/callback" + scopes: ["openid", "email", "profile"] + microsoft: + enabled: true + tenant: "${MICROSOFT_TENANT_ID}" + client_id: "${MICROSOFT_CLIENT_ID}" + client_secret: "${MICROSOFT_CLIENT_SECRET}" + redirect_uri: "http://localhost:5173/api/oauth/microsoft/callback" + scopes: ["openid", "email", "profile"] + github: + enabled: false + client_id: "${GITHUB_CLIENT_ID}" + client_secret: "${GITHUB_CLIENT_SECRET}" + redirect_uri: "http://localhost:5173/api/oauth/github/callback" + scopes: ["user:email"] +``` + +--- + +## 5. API-Endpunkte + +### 5.1 OAuth Initiation + +**GET** `/api/oauth/{provider}/auth` + +Initiert den OAuth Flow für einen bestimmten Provider. + +**Query Parameters:** +- `redirect_uri` (optional): Custom Redirect URI nach erfolgreichem Login + +**Response:** +- `302 Redirect` zur OAuth Provider Authorization URL + +**Beispiel:** +``` +GET /api/oauth/google/auth +→ Redirect zu: https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=...&scope=...&response_type=code&state=... +``` + +### 5.2 OAuth Callback + +**GET** `/api/oauth/{provider}/callback` + +Verarbeitet den Authorization Code vom OAuth-Provider. + +**Query Parameters:** +- `code`: Authorization Code vom Provider +- `state`: CSRF Protection Token (optional, aber empfohlen) + +**Response:** +```json +{ + "success": true, + "user": { + "id": "uuid", + "username": "user@example.com", + "email": "user@example.com", + "isAdmin": false, + "enabled": true, + "authMethod": "oauth", + "oauthProvider": "google" + }, + "sessionToken": "jwt-token-here", + "redirectTo": "/" +} +``` + +**Fehler-Response:** +```json +{ + "success": false, + "error": "Invalid authorization code", + "errorCode": "OAUTH_INVALID_CODE" +} +``` + +### 5.3 OAuth Logout + +**POST** `/api/oauth/logout` + +Beendet die OAuth-Session. + +**Headers:** +- `Authorization: Bearer {sessionToken}` + +**Response:** +```json +{ + "success": true, + "message": "Logged out successfully" +} +``` + +### 5.4 OAuth Provider Status + +**GET** `/api/oauth/providers` + +Gibt eine Liste aller konfigurierten OAuth-Provider zurück. + +**Response:** +```json +{ + "providers": [ + { + "id": "google", + "name": "Google", + "enabled": true, + "authUrl": "/api/oauth/google/auth" + }, + { + "id": "microsoft", + "name": "Microsoft", + "enabled": true, + "authUrl": "/api/oauth/microsoft/auth" + } + ] +} +``` + +### 5.5 Link OAuth Account + +**POST** `/api/oauth/link` + +Verknüpft einen OAuth-Account mit einem bestehenden lokalen Account (für Hybrid-Auth). + +**Headers:** +- `Authorization: Basic {credentials}` (lokaler Account) + +**Body:** +```json +{ + "provider": "google", + "code": "authorization-code-from-oauth" +} +``` + +**Response:** +```json +{ + "success": true, + "message": "OAuth account linked successfully" +} +``` + +--- + +## 6. Session Management + +### 6.1 Session-Token (JWT) + +**Token-Struktur:** +```json +{ + "sub": "user-uuid", + "auth_method": "oauth", + "provider": "google", + "exp": 1234567890, + "iat": 1234567890, + "session_id": "session-uuid" +} +``` + +**Token-Speicherung:** +- **Backend**: In `oauth_sessions` Tabelle +- **Frontend**: HttpOnly Cookie (bevorzugt) oder localStorage (Fallback) +- **Lifetime**: 24 Stunden (konfigurierbar) +- **Refresh**: Automatisches Refresh bei Ablauf (falls Refresh Token vorhanden) + +### 6.2 Session-Validierung + +**Middleware**: `oauthSessionMiddleware` + +Prüft OAuth-Session-Token in folgenden Headers: +1. `Authorization: Bearer {token}` +2. Cookie: `oauth_session` + +**Flow:** +``` +Request → oauthSessionMiddleware → Check Token → Validate Session → Continue + ↓ Invalid + 401 Unauthorized +``` + +--- + +## 7. User Management + +### 7.1 Automatische User-Erstellung + +**Flow:** +1. OAuth-Callback empfängt User-Info vom Provider +2. Prüfe ob User mit `oauth_provider_id` existiert +3. Falls nicht: + - Erstelle neuen User + - Setze `auth_method = 'oauth'` + - Setze `oauth_provider` und `oauth_provider_id` + - Setze `password_hash = NULL` + - Setze `enabled = true` (oder konfigurierbar) + - Setze `isAdmin = false` + - Weise Standard-Berechtigungsgruppe zu (optional) +4. Falls ja: + - Update `last_login_at` (falls Feld existiert) + - Update Session + +### 7.2 User Linking (Hybrid Auth) + +**Szenario**: Benutzer hat bereits lokalen Account, möchte OAuth hinzufügen + +**Flow:** +1. User loggt sich mit Basic Auth ein +2. User klickt "Link Google Account" +3. OAuth Flow wird initiiert +4. Nach erfolgreicher OAuth-Auth: + - Verknüpfe OAuth-Account mit lokalem Account + - Setze `auth_method = 'hybrid'` + - Erstelle Eintrag in `user_oauth_links` +5. User kann sich nun mit beiden Methoden anmelden + +### 7.3 User Mapping + +**Email-basierte Verknüpfung:** +- Falls OAuth-Email mit lokalem Account übereinstimmt → Auto-Link (optional, konfigurierbar) +- Falls nicht → Neue User-Erstellung oder manuelle Verknüpfung erforderlich + +--- + +## 8. Sicherheit + +### 8.1 CSRF Protection + +**State Parameter:** +- Generiere zufälligen `state` Token bei OAuth-Initiation +- Speichere in Session/Cookie +- Validiere bei Callback + +**Implementierung:** +```go +state := generateRandomToken(32) +storeStateInSession(state) +redirectURL := fmt.Sprintf("%s?state=%s&...", oauthURL, state) +``` + +### 8.2 Token-Verschlüsselung + +**Access/Refresh Tokens:** +- Verschlüsselt in Datenbank speichern (AES-256) +- Nie im Klartext loggen +- Automatische Löschung bei Ablauf + +### 8.3 Rate Limiting + +**OAuth-Endpunkte:** +- `/api/oauth/{provider}/auth`: 10 Requests/Minute pro IP +- `/api/oauth/{provider}/callback`: 5 Requests/Minute pro IP + +### 8.4 Secure Cookies + +**Session Cookies:** +- `HttpOnly`: true +- `Secure`: true (HTTPS only) +- `SameSite`: Lax oder Strict +- `Path`: `/api` + +--- + +## 9. Frontend-Integration + +### 9.1 Login-Seite Erweiterung + +**Aktuelle Login-Seite** (`frontend/src/pages/Login.jsx`) erweitern: + +```jsx +// OAuth Login Buttons hinzufügen +
+ Keine Berechtigungsgruppe +
++ Sie sind keiner Berechtigungsgruppe zugewiesen. Bitte kontaktieren Sie einen Administrator. +
+