feature/letsEncryptProvider #10
@@ -21,12 +21,50 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// Let's Encrypt Staging für Tests
|
||||
acmeStagingDirectory = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
acmeStagingNewAccount = "https://acme-staging-v02.api.letsencrypt.org/acme/new-acct"
|
||||
acmeStagingNewOrder = "https://acme-staging-v02.api.letsencrypt.org/acme/new-order"
|
||||
)
|
||||
// ACMEDirectory enthält die Endpunkte eines ACME-Servers
|
||||
type ACMEDirectory struct {
|
||||
NewNonce string `json:"newNonce"`
|
||||
NewAccount string `json:"newAccount"`
|
||||
NewOrder string `json:"newOrder"`
|
||||
RevokeCert string `json:"revokeCert"`
|
||||
KeyChange string `json:"keyChange"`
|
||||
Meta struct {
|
||||
TermsOfService string `json:"termsOfService"`
|
||||
Website string `json:"website"`
|
||||
CaaIdentities []string `json:"caaIdentities"`
|
||||
ExternalAccountRequired bool `json:"externalAccountRequired"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
|
||||
// getACMEDirectory ruft die Directory-Endpunkte von einem ACME-Server ab
|
||||
func getACMEDirectory(directoryURL string) (*ACMEDirectory, error) {
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get(directoryURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Abrufen der ACME Directory: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("fehler beim Abrufen der ACME Directory (Status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Lesen der Directory-Response: %v", err)
|
||||
}
|
||||
|
||||
var directory ACMEDirectory
|
||||
if err := json.Unmarshal(body, &directory); err != nil {
|
||||
return nil, fmt.Errorf("fehler beim Parsen der Directory-Response: %v", err)
|
||||
}
|
||||
|
||||
return &directory, nil
|
||||
}
|
||||
|
||||
// ACMEKeyPair enthält Private und Public Key
|
||||
type ACMEKeyPair struct {
|
||||
@@ -114,10 +152,9 @@ func loadOrCreateKeyPair(fqdnID string, keyDir string) (*ACMEKeyPair, error) {
|
||||
}
|
||||
|
||||
// getNonce ruft einen neuen Nonce vom ACME-Server ab
|
||||
func getNonce() (string, error) {
|
||||
|
||||
func getNonce(directoryURL string) (string, error) {
|
||||
// Rufe Directory-Endpoint auf, um einen Nonce zu bekommen
|
||||
req, err := http.NewRequest("HEAD", acmeStagingDirectory, nil)
|
||||
req, err := http.NewRequest("HEAD", directoryURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fehler beim Erstellen des HEAD-Requests: %v", err)
|
||||
}
|
||||
@@ -338,12 +375,12 @@ func calculateKeyAuthHash(token string, pubKey *rsa.PublicKey) (string, error) {
|
||||
return txtValue, nil
|
||||
}
|
||||
|
||||
// createAccount erstellt einen neuen Account bei Let's Encrypt
|
||||
func createAccount(keyPair *ACMEKeyPair, email string, traceID, fqdnID string, statusCallback func(status string)) (string, error) {
|
||||
statusCallback("Erstelle Account bei Let's Encrypt...")
|
||||
// createAccount erstellt einen neuen Account bei einem ACME-Server
|
||||
func createAccount(keyPair *ACMEKeyPair, directoryURL, newAccountURL string, email string, traceID, fqdnID string, statusCallback func(status string)) (string, error) {
|
||||
statusCallback("Erstelle Account bei ACME-Server...")
|
||||
|
||||
// Hole Nonce vom Server
|
||||
nonce, err := getNonce()
|
||||
nonce, err := getNonce(directoryURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
||||
}
|
||||
@@ -355,13 +392,13 @@ func createAccount(keyPair *ACMEKeyPair, email string, traceID, fqdnID string, s
|
||||
}
|
||||
|
||||
// Erstelle JWS (ohne KeyID, da es ein neuer Account ist)
|
||||
jws, err := createJWS(keyPair, payload, acmeStagingNewAccount, "", nonce)
|
||||
jws, err := createJWS(keyPair, payload, newAccountURL, "", nonce)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fehler beim Erstellen des JWS: %v", err)
|
||||
}
|
||||
|
||||
// Sende Request
|
||||
req, err := http.NewRequest("POST", acmeStagingNewAccount, bytes.NewBufferString(jws))
|
||||
req, err := http.NewRequest("POST", newAccountURL, bytes.NewBufferString(jws))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fehler beim Erstellen des HTTP-Requests: %v", err)
|
||||
}
|
||||
@@ -398,12 +435,12 @@ func createAccount(keyPair *ACMEKeyPair, email string, traceID, fqdnID string, s
|
||||
return keyID, nil
|
||||
}
|
||||
|
||||
// createOrder erstellt eine neue Order bei Let's Encrypt
|
||||
func createOrder(keyPair *ACMEKeyPair, keyID string, domains []string, traceID, fqdnID string, statusCallback func(status string)) (string, map[string]interface{}, error) {
|
||||
statusCallback("Erstelle Order bei Let's Encrypt...")
|
||||
// createOrder erstellt eine neue Order bei einem ACME-Server
|
||||
func createOrder(keyPair *ACMEKeyPair, directoryURL, newOrderURL string, keyID string, domains []string, traceID, fqdnID string, statusCallback func(status string)) (string, map[string]interface{}, error) {
|
||||
statusCallback("Erstelle Order bei ACME-Server...")
|
||||
|
||||
// Hole Nonce vom Server
|
||||
nonce, err := getNonce()
|
||||
nonce, err := getNonce(directoryURL)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
||||
}
|
||||
@@ -420,13 +457,13 @@ func createOrder(keyPair *ACMEKeyPair, keyID string, domains []string, traceID,
|
||||
}
|
||||
|
||||
// Erstelle JWS (mit KeyID)
|
||||
jws, err := createJWS(keyPair, payload, acmeStagingNewOrder, keyID, nonce)
|
||||
jws, err := createJWS(keyPair, payload, newOrderURL, keyID, nonce)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("fehler beim Erstellen des JWS: %v", err)
|
||||
}
|
||||
|
||||
// Sende Request
|
||||
req, err := http.NewRequest("POST", acmeStagingNewOrder, bytes.NewBufferString(jws))
|
||||
req, err := http.NewRequest("POST", newOrderURL, bytes.NewBufferString(jws))
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("fehler beim Erstellen des HTTP-Requests: %v", err)
|
||||
}
|
||||
@@ -468,10 +505,10 @@ func createOrder(keyPair *ACMEKeyPair, keyID string, domains []string, traceID,
|
||||
return orderURL, orderResponse, nil
|
||||
}
|
||||
|
||||
// RequestCertificate beantragt ein Zertifikat von Let's Encrypt (manuell ohne LEGO)
|
||||
// RequestCertificate beantragt ein Zertifikat von einem ACME-Server
|
||||
// cleanupTokenFunc wird aufgerufen, wenn die Challenge invalid ist, um den Token zu bereinigen
|
||||
// traceID wird vom Aufrufer übergeben, um die gleiche TRACE_ID über den gesamten Prozess zu verwenden
|
||||
func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID string, traceID string, updateTokenFunc func(token string) error, cleanupTokenFunc func() error, statusCallback func(status string)) (*CertificateRequestResult, error) {
|
||||
func RequestCertificate(ctx *ACMEClientContext, fqdn string, email string, fqdnID string, existingKeyID string, traceID string, updateTokenFunc func(token string) error, cleanupTokenFunc func() error, statusCallback func(status string)) (*CertificateRequestResult, error) {
|
||||
|
||||
log.Printf("[ACME] ===== REQUEST CERTIFICATE START =====")
|
||||
log.Printf("[ACME] FQDN: %s", fqdn)
|
||||
@@ -503,14 +540,14 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID
|
||||
result.Status = append(result.Status, "Key-Paar erfolgreich geladen/erstellt")
|
||||
statusCallback("Key-Paar erfolgreich geladen/erstellt")
|
||||
|
||||
// Schritt 2: Erstelle Account bei Let's Encrypt (falls noch nicht vorhanden)
|
||||
// Schritt 2: Erstelle Account beim ACME-Server (falls noch nicht vorhanden)
|
||||
log.Printf("[ACME] Schritt 2: Erstelle/Verwende Account...")
|
||||
var keyID string
|
||||
if existingKeyID == "" {
|
||||
log.Printf("[ACME] Keine KeyID vorhanden, erstelle neuen Account...")
|
||||
statusCallback("Erstelle Account bei Let's Encrypt...")
|
||||
statusCallback(fmt.Sprintf("Erstelle Account bei %s...", ctx.Provider.GetDisplayName()))
|
||||
result.StepStatus["ACCOUNT_ERSTELLUNG"] = "loading"
|
||||
keyID, err = createAccount(keyPair, email, traceID, fqdnID, statusCallback)
|
||||
keyID, err = createAccount(keyPair, ctx.DirectoryURL, ctx.NewAccountURL, email, traceID, fqdnID, statusCallback)
|
||||
if err != nil {
|
||||
log.Printf("[ACME] FEHLER bei Schritt 2 (Account-Erstellung): %v", err)
|
||||
logCertStatus(traceID, fqdnID, "ACCOUNT_ERSTELLUNG", "FAILED", err.Error())
|
||||
@@ -531,16 +568,16 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID
|
||||
statusCallback(fmt.Sprintf("Verwende existierenden Account (KeyID: %s)", keyID))
|
||||
}
|
||||
|
||||
// Schritt 3: Erstelle Order bei Let's Encrypt
|
||||
// Schritt 3: Erstelle Order beim ACME-Server
|
||||
baseFqdn := fqdn
|
||||
if strings.HasPrefix(baseFqdn, "*.") {
|
||||
baseFqdn = baseFqdn[2:]
|
||||
}
|
||||
|
||||
log.Printf("[ACME] Schritt 3: Erstelle Order für Domain: %s", baseFqdn)
|
||||
statusCallback("Erstelle Order bei Let's Encrypt...")
|
||||
statusCallback(fmt.Sprintf("Erstelle Order bei %s...", ctx.Provider.GetDisplayName()))
|
||||
result.StepStatus["ORDER_ERSTELLUNG"] = "loading"
|
||||
orderURL, orderResponse, err := createOrder(keyPair, keyID, []string{baseFqdn}, traceID, fqdnID, statusCallback)
|
||||
orderURL, orderResponse, err := createOrder(keyPair, ctx.DirectoryURL, ctx.NewOrderURL, keyID, []string{baseFqdn}, traceID, fqdnID, statusCallback)
|
||||
if err != nil {
|
||||
log.Printf("[ACME] FEHLER bei Schritt 3 (Order-Erstellung): %v", err)
|
||||
logCertStatus(traceID, fqdnID, "ORDER_ERSTELLUNG", "FAILED", err.Error())
|
||||
@@ -557,7 +594,7 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID
|
||||
if orderResponse != nil {
|
||||
statusCallback("Extrahiere Challenge-Token...")
|
||||
|
||||
token, err := extractTokenFromOrder(keyPair, keyID, orderResponse, baseFqdn)
|
||||
token, err := extractTokenFromOrder(ctx, keyPair, keyID, orderResponse, baseFqdn)
|
||||
if err != nil {
|
||||
logCertStatus(traceID, fqdnID, "TOKEN_EXTRAKTION", "FAILED", err.Error())
|
||||
return nil, fmt.Errorf("fehler beim Extrahieren des Tokens: %v", err)
|
||||
@@ -588,14 +625,14 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID
|
||||
// Schritt 5: Aktiviere Challenge bei Let's Encrypt
|
||||
statusCallback("Aktiviere Challenge bei Let's Encrypt...")
|
||||
|
||||
challengeURL, err := extractChallengeURLFromOrder(keyPair, keyID, orderResponse, baseFqdn)
|
||||
challengeURL, err := extractChallengeURLFromOrder(ctx, keyPair, keyID, orderResponse, baseFqdn)
|
||||
if err != nil {
|
||||
logCertStatus(traceID, fqdnID, "CHALLENGE_URL_EXTRAKTION", "FAILED", err.Error())
|
||||
return nil, fmt.Errorf("fehler beim Extrahieren der Challenge-URL: %v", err)
|
||||
}
|
||||
|
||||
result.StepStatus["CHALLENGE_AKTIVIERUNG"] = "loading"
|
||||
if err := activateChallenge(keyPair, keyID, challengeURL, traceID, fqdnID); err != nil {
|
||||
if err := activateChallenge(ctx, keyPair, keyID, challengeURL, traceID, fqdnID); err != nil {
|
||||
logCertStatus(traceID, fqdnID, "CHALLENGE_AKTIVIERUNG", "FAILED", err.Error())
|
||||
result.StepStatus["CHALLENGE_AKTIVIERUNG"] = "error"
|
||||
return nil, fmt.Errorf("fehler beim Aktivieren der Challenge: %v", err)
|
||||
@@ -608,7 +645,7 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID
|
||||
// Schritt 6: Warte auf Challenge-Validierung (Polling)
|
||||
statusCallback("Warte auf Challenge-Validierung...")
|
||||
|
||||
if err := waitForChallengeValidation(keyPair, keyID, challengeURL, traceID, fqdnID, statusCallback, cleanupTokenFunc); err != nil {
|
||||
if err := waitForChallengeValidation(ctx, keyPair, keyID, challengeURL, traceID, fqdnID, statusCallback, cleanupTokenFunc); err != nil {
|
||||
// Cleanup wurde bereits in waitForChallengeValidation durchgeführt
|
||||
// Bereinige auch den Token aus der Datenbank
|
||||
if cleanupTokenFunc != nil {
|
||||
@@ -627,7 +664,7 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID
|
||||
statusCallback("Finalisiere Order und hole Zertifikat...")
|
||||
|
||||
result.StepStatus["ZERTIFIKAT_ERSTELLUNG"] = "loading"
|
||||
certPEM, keyPEM, err := finalizeOrderAndGetCertificate(keyPair, keyID, orderURL, orderResponse, traceID, fqdnID, statusCallback)
|
||||
certPEM, keyPEM, err := finalizeOrderAndGetCertificate(ctx, keyPair, keyID, orderURL, orderResponse, traceID, fqdnID, statusCallback)
|
||||
if err != nil {
|
||||
logCertStatus(traceID, fqdnID, "ZERTIFIKAT_ERSTELLUNG", "FAILED", err.Error())
|
||||
result.StepStatus["ZERTIFIKAT_ERSTELLUNG"] = "error"
|
||||
@@ -648,7 +685,7 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID
|
||||
}
|
||||
|
||||
// extractTokenFromOrder extrahiert den Challenge-Token aus der Order-Response
|
||||
func extractTokenFromOrder(keyPair *ACMEKeyPair, keyID string, orderResponse map[string]interface{}, domain string) (string, error) {
|
||||
func extractTokenFromOrder(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, orderResponse map[string]interface{}, domain string) (string, error) {
|
||||
|
||||
// Extrahiere authorizations Array
|
||||
authorizations, ok := orderResponse["authorizations"].([]interface{})
|
||||
@@ -661,7 +698,7 @@ func extractTokenFromOrder(keyPair *ACMEKeyPair, keyID string, orderResponse map
|
||||
}
|
||||
|
||||
// Hole Nonce für Authorization-Request
|
||||
nonce, err := getNonce()
|
||||
nonce, err := getNonce(ctx.DirectoryURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
||||
}
|
||||
@@ -753,7 +790,7 @@ func extractTokenFromOrder(keyPair *ACMEKeyPair, keyID string, orderResponse map
|
||||
}
|
||||
|
||||
// extractChallengeURLFromOrder extrahiert die Challenge-URL aus der Order-Response
|
||||
func extractChallengeURLFromOrder(keyPair *ACMEKeyPair, keyID string, orderResponse map[string]interface{}, domain string) (string, error) {
|
||||
func extractChallengeURLFromOrder(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, orderResponse map[string]interface{}, domain string) (string, error) {
|
||||
|
||||
// Extrahiere authorizations Array
|
||||
authorizations, ok := orderResponse["authorizations"].([]interface{})
|
||||
@@ -766,7 +803,7 @@ func extractChallengeURLFromOrder(keyPair *ACMEKeyPair, keyID string, orderRespo
|
||||
}
|
||||
|
||||
// Hole Nonce für Authorization-Request
|
||||
nonce, err := getNonce()
|
||||
nonce, err := getNonce(ctx.DirectoryURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
||||
}
|
||||
@@ -855,10 +892,10 @@ func extractChallengeURLFromOrder(keyPair *ACMEKeyPair, keyID string, orderRespo
|
||||
return "", fmt.Errorf("keine DNS-01 Challenge URL in Authorizations gefunden")
|
||||
}
|
||||
|
||||
// activateChallenge aktiviert eine Challenge bei Let's Encrypt
|
||||
func activateChallenge(keyPair *ACMEKeyPair, keyID string, challengeURL string, traceID, fqdnID string) error {
|
||||
// activateChallenge aktiviert eine Challenge beim ACME-Server
|
||||
func activateChallenge(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, challengeURL string, traceID, fqdnID string) error {
|
||||
// Hole Nonce
|
||||
nonce, err := getNonce()
|
||||
nonce, err := getNonce(ctx.DirectoryURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
||||
}
|
||||
@@ -901,10 +938,10 @@ func activateChallenge(keyPair *ACMEKeyPair, keyID string, challengeURL string,
|
||||
}
|
||||
|
||||
// cleanupChallenge führt einen Cleanup-Prozess für eine Challenge durch
|
||||
func cleanupChallenge(keyPair *ACMEKeyPair, keyID string, challengeURL string) error {
|
||||
func cleanupChallenge(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, challengeURL string) error {
|
||||
|
||||
// Hole Nonce
|
||||
nonce, err := getNonce()
|
||||
nonce, err := getNonce(ctx.DirectoryURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
||||
}
|
||||
@@ -944,7 +981,7 @@ func cleanupChallenge(keyPair *ACMEKeyPair, keyID string, challengeURL string) e
|
||||
}
|
||||
|
||||
// waitForChallengeValidation wartet auf die Validierung der Challenge (Polling)
|
||||
func waitForChallengeValidation(keyPair *ACMEKeyPair, keyID string, challengeURL string, traceID, fqdnID string, statusCallback func(status string), cleanupTokenFunc func() error) error {
|
||||
func waitForChallengeValidation(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, challengeURL string, traceID, fqdnID string, statusCallback func(status string), cleanupTokenFunc func() error) error {
|
||||
maxAttempts := 30 // Maximal 30 Versuche
|
||||
pollInterval := 2 * time.Second // Alle 2 Sekunden prüfen
|
||||
|
||||
@@ -952,7 +989,7 @@ func waitForChallengeValidation(keyPair *ACMEKeyPair, keyID string, challengeURL
|
||||
statusCallback(fmt.Sprintf("Prüfe Challenge-Status (%d/%d)...", attempt, maxAttempts))
|
||||
|
||||
// Hole Nonce
|
||||
nonce, err := getNonce()
|
||||
nonce, err := getNonce(ctx.DirectoryURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
||||
}
|
||||
@@ -1047,7 +1084,7 @@ func waitForChallengeValidation(keyPair *ACMEKeyPair, keyID string, challengeURL
|
||||
}
|
||||
|
||||
// waitForOrderReady wartet, bis die Order den Status "ready" hat (nach Challenge-Validierung)
|
||||
func waitForOrderReady(keyPair *ACMEKeyPair, keyID string, orderURL string, traceID, fqdnID string, statusCallback func(status string)) error {
|
||||
func waitForOrderReady(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, orderURL string, traceID, fqdnID string, statusCallback func(status string)) error {
|
||||
maxAttempts := 30 // Maximal 30 Versuche
|
||||
pollInterval := 2 * time.Second // Alle 2 Sekunden prüfen
|
||||
maxConsecutiveErrors := 3 // Maximal 3 aufeinanderfolgende Fehler
|
||||
@@ -1057,7 +1094,7 @@ func waitForOrderReady(keyPair *ACMEKeyPair, keyID string, orderURL string, trac
|
||||
statusCallback(fmt.Sprintf("Prüfe Order-Status (%d/%d)...", attempt, maxAttempts))
|
||||
|
||||
// Hole Nonce
|
||||
nonce, err := getNonce()
|
||||
nonce, err := getNonce(ctx.DirectoryURL)
|
||||
if err != nil {
|
||||
log.Printf("[ACME] Fehler beim Abrufen des Nonce: %v", err)
|
||||
consecutiveErrors++
|
||||
@@ -1184,11 +1221,11 @@ func waitForOrderReady(keyPair *ACMEKeyPair, keyID string, orderURL string, trac
|
||||
}
|
||||
|
||||
// finalizeOrderAndGetCertificate finalisiert die Order und holt das Zertifikat
|
||||
func finalizeOrderAndGetCertificate(keyPair *ACMEKeyPair, keyID string, orderURL string, orderResponse map[string]interface{}, traceID, fqdnID string, statusCallback func(status string)) (string, string, error) {
|
||||
func finalizeOrderAndGetCertificate(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, orderURL string, orderResponse map[string]interface{}, traceID, fqdnID string, statusCallback func(status string)) (string, string, error) {
|
||||
|
||||
// Warte, bis die Order bereit ist (Status "ready" oder "valid")
|
||||
statusCallback("Warte auf Order-Bereitschaft...")
|
||||
if err := waitForOrderReady(keyPair, keyID, orderURL, traceID, fqdnID, statusCallback); err != nil {
|
||||
if err := waitForOrderReady(ctx, keyPair, keyID, orderURL, traceID, fqdnID, statusCallback); err != nil {
|
||||
return "", "", fmt.Errorf("fehler beim Warten auf Order-Bereitschaft: %v", err)
|
||||
}
|
||||
|
||||
@@ -1235,7 +1272,7 @@ func finalizeOrderAndGetCertificate(keyPair *ACMEKeyPair, keyID string, orderURL
|
||||
}
|
||||
|
||||
// Hole Nonce
|
||||
nonce, err := getNonce()
|
||||
nonce, err := getNonce(ctx.DirectoryURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
||||
}
|
||||
@@ -1291,7 +1328,7 @@ func finalizeOrderAndGetCertificate(keyPair *ACMEKeyPair, keyID string, orderURL
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
statusCallback(fmt.Sprintf("Prüfe Order-Status (%d/%d)...", attempt, maxAttempts))
|
||||
|
||||
nonce, err := getNonce()
|
||||
nonce, err := getNonce(ctx.DirectoryURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
||||
}
|
||||
@@ -1349,7 +1386,7 @@ func finalizeOrderAndGetCertificate(keyPair *ACMEKeyPair, keyID string, orderURL
|
||||
statusCallback("Hole Zertifikat...")
|
||||
|
||||
// Hole Zertifikat
|
||||
nonce, err = getNonce()
|
||||
nonce, err = getNonce(ctx.DirectoryURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
||||
}
|
||||
|
||||
57
backend/acme_client_context.go
Normal file
57
backend/acme_client_context.go
Normal file
@@ -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
|
||||
}
|
||||
|
||||
5
backend/config/providers/letsencrypt-production.json
Normal file
5
backend/config/providers/letsencrypt-production.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"settings": {}
|
||||
}
|
||||
|
||||
5
backend/config/providers/letsencrypt-staging.json
Normal file
5
backend/config/providers/letsencrypt-staging.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabled": true,
|
||||
"settings": {}
|
||||
}
|
||||
|
||||
@@ -2241,8 +2241,18 @@ func requestCertificateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
log.Printf("[STATUS] %s", status)
|
||||
}
|
||||
|
||||
// Erstelle ACME-Client-Kontext
|
||||
// Standardmäßig verwenden wir Let's Encrypt Staging, aber in Zukunft könnte dies aus der FQDN-Konfiguration kommen
|
||||
acmeProviderIDStr := "letsencrypt-staging" // TODO: Aus FQDN-Konfiguration lesen
|
||||
acmeCtx, err := NewACMEClientContext(acmeProviderIDStr)
|
||||
if err != nil {
|
||||
log.Printf("FEHLER beim Erstellen des ACME-Client-Kontexts: %v", err)
|
||||
http.Error(w, fmt.Sprintf("Fehler beim Initialisieren des ACME-Providers: %v", err), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Rufe RequestCertificate auf...")
|
||||
result, err := RequestCertificate(baseFqdn, fqdn.AcmeEmail, fqdnID, fqdn.AcmeKeyID, traceID, updateTokenFunc, cleanupTokenFunc, statusCallback)
|
||||
result, err := RequestCertificate(acmeCtx, baseFqdn, fqdn.AcmeEmail, fqdnID, fqdn.AcmeKeyID, traceID, updateTokenFunc, cleanupTokenFunc, statusCallback)
|
||||
if err != nil {
|
||||
logCertStatus(traceID, fqdnID, "ZERTIFIKATSANFRAGE_GESAMT", "FAILED", err.Error())
|
||||
stepStatus["ZERTIFIKATSANFRAGE_GESAMT"] = "error"
|
||||
@@ -5592,6 +5602,11 @@ func main() {
|
||||
pm.RegisterProvider(providers.NewAutoDNSProvider())
|
||||
pm.RegisterProvider(providers.NewHetznerProvider())
|
||||
pm.RegisterProvider(providers.NewCertigoACMEProxyProvider())
|
||||
|
||||
// Initialisiere ACME-Provider
|
||||
acmeManager := providers.GetACMEManager()
|
||||
acmeManager.RegisterACMEProvider(providers.NewLetsEncryptProvider("production"))
|
||||
acmeManager.RegisterACMEProvider(providers.NewLetsEncryptProvider("staging"))
|
||||
|
||||
// Starte Renewal Scheduler
|
||||
StartRenewalScheduler()
|
||||
@@ -5655,6 +5670,7 @@ func main() {
|
||||
|
||||
// Renewal Queue Routes
|
||||
api.HandleFunc("/renewal-queue", basicAuthMiddleware(getRenewalQueueHandler)).Methods("GET", "OPTIONS")
|
||||
api.HandleFunc("/renewal-queue", basicAuthMiddleware(deleteAllRenewalQueueEntriesHandler)).Methods("DELETE", "OPTIONS")
|
||||
|
||||
// Renewal Queue Test Routes (nur für Administratoren)
|
||||
api.HandleFunc("/renewal-queue/test/create", basicAuthMiddleware(createTestRenewalQueueEntryHandler)).Methods("POST", "OPTIONS")
|
||||
|
||||
@@ -10,6 +10,9 @@ servers:
|
||||
- url: http://localhost:8080/api
|
||||
description: Local development server
|
||||
|
||||
security:
|
||||
- basicAuth: []
|
||||
|
||||
paths:
|
||||
/health:
|
||||
get:
|
||||
@@ -561,9 +564,52 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RenewalQueueEntry'
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
queue:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RenewalQueueEntry'
|
||||
'401':
|
||||
description: Nicht authentifiziert
|
||||
|
||||
delete:
|
||||
summary: Alle Renewal Queue Einträge löschen
|
||||
description: Löscht alle Einträge aus der Renewal Queue. Erfordert confirm=true Query-Parameter.
|
||||
tags:
|
||||
- Renewal Queue
|
||||
parameters:
|
||||
- name: confirm
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Muss "true" sein, um die Operation auszuführen
|
||||
example: "true"
|
||||
responses:
|
||||
'200':
|
||||
description: Alle Renewal Queue Einträge erfolgreich gelöscht
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success:
|
||||
type: boolean
|
||||
example: true
|
||||
message:
|
||||
type: string
|
||||
example: "Alle Renewal Queue-Einträge erfolgreich gelöscht"
|
||||
deletedCount:
|
||||
type: integer
|
||||
example: 42
|
||||
'400':
|
||||
description: Bestätigung erforderlich
|
||||
'401':
|
||||
description: Nicht authentifiziert
|
||||
|
||||
/renewal-queue/test/create:
|
||||
post:
|
||||
@@ -1766,6 +1812,7 @@ components:
|
||||
example: 5
|
||||
|
||||
securitySchemes:
|
||||
{}:
|
||||
basicAuth:
|
||||
type: http
|
||||
scheme: none
|
||||
scheme: basic
|
||||
description: Basic HTTP Authentication
|
||||
|
||||
182
backend/providers/acme_provider.go
Normal file
182
backend/providers/acme_provider.go
Normal file
@@ -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)
|
||||
}
|
||||
|
||||
106
backend/providers/letsencrypt.go
Normal file
106
backend/providers/letsencrypt.go
Normal file
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"certigo-addon-backend/providers"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -60,10 +62,23 @@ func CalculateCertID(certPEM string) (string, error) {
|
||||
return certID, nil
|
||||
}
|
||||
|
||||
// FetchRenewalInfo ruft die RenewalInfo von Let's Encrypt ab
|
||||
func FetchRenewalInfo(certID string) (*RenewalInfoResponse, error) {
|
||||
// Let's Encrypt RenewalInfo API URL (Staging)
|
||||
url := fmt.Sprintf("https://acme-staging-v02.api.letsencrypt.org/acme/renewal-info/%s", certID)
|
||||
// FetchRenewalInfo ruft die RenewalInfo von einem ACME-Server ab
|
||||
func FetchRenewalInfo(providerID string, certID string) (*RenewalInfoResponse, error) {
|
||||
// Hole ACME-Provider
|
||||
acmeManager := providers.GetACMEManager()
|
||||
provider, exists := acmeManager.GetACMEProvider(providerID)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("ACME-Provider '%s' nicht gefunden", providerID)
|
||||
}
|
||||
|
||||
// Hole RenewalInfo URL vom Provider
|
||||
renewalInfoURL := provider.GetRenewalInfoURL()
|
||||
if renewalInfoURL == "" {
|
||||
return nil, fmt.Errorf("ACME-Provider '%s' unterstützt keine RenewalInfo API", providerID)
|
||||
}
|
||||
|
||||
// Erstelle URL mit CertID
|
||||
url := fmt.Sprintf("%s/%s", renewalInfoURL, certID)
|
||||
|
||||
// HTTP Request
|
||||
client := &http.Client{
|
||||
@@ -143,7 +158,10 @@ func ProcessRenewalInfoForCertificate(certPEM string, certID string, fqdnID stri
|
||||
}
|
||||
|
||||
// Rufe RenewalInfo ab
|
||||
renewalInfo, err := FetchRenewalInfo(certIDBase64)
|
||||
// TODO: ACME-Provider-ID aus Zertifikat/FQDN-Konfiguration holen
|
||||
// Standardmäßig verwenden wir Let's Encrypt Staging
|
||||
acmeProviderID := "letsencrypt-staging"
|
||||
renewalInfo, err := FetchRenewalInfo(acmeProviderID, certIDBase64)
|
||||
if err != nil {
|
||||
// Prüfe ob es ein Staging-Zertifikat ist (RenewalInfo nicht verfügbar)
|
||||
if strings.Contains(err.Error(), "Staging-Zertifikate") {
|
||||
|
||||
@@ -101,15 +101,16 @@ func updateFqdnRenewalEnabledHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Wenn renewal_enabled deaktiviert wird, lösche alle Queue-Einträge für diesen FQDN
|
||||
// Wenn renewal_enabled deaktiviert wird, lösche nur pending/processing Queue-Einträge für diesen FQDN
|
||||
// Completed und failed Einträge bleiben als Historie erhalten
|
||||
if !req.RenewalEnabled {
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM renewal_queue WHERE fqdn_id = ?", fqdnID)
|
||||
_, err = tx.ExecContext(ctx, "DELETE FROM renewal_queue WHERE fqdn_id = ? AND status IN ('pending', 'processing')", fqdnID)
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Löschen der Queue-Einträge", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Löschen der Queue-Einträge: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("Queue-Einträge für FQDN %s gelöscht (renewal_enabled deaktiviert)", fqdnID)
|
||||
log.Printf("Pending/Processing Queue-Einträge für FQDN %s gelöscht (renewal_enabled deaktiviert)", fqdnID)
|
||||
}
|
||||
|
||||
// Committe die Transaktion
|
||||
@@ -265,3 +266,68 @@ func getRenewalQueueHandler(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// deleteAllRenewalQueueEntriesHandler löscht alle Einträge aus der Renewal Queue
|
||||
func deleteAllRenewalQueueEntriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Prüfe Berechtigung - nur authentifizierte User
|
||||
userID, username := getUserFromRequest(r)
|
||||
if userID == "" {
|
||||
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Prüfe Bestätigung (optional, aber empfohlen)
|
||||
confirm := r.URL.Query().Get("confirm")
|
||||
if confirm != "true" {
|
||||
http.Error(w, "Bestätigung erforderlich. Verwende ?confirm=true", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Lösche alle Queue-Einträge
|
||||
result, err := db.ExecContext(ctx, "DELETE FROM renewal_queue")
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Löschen der Queue-Einträge", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Löschen der Queue-Einträge: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
http.Error(w, "Fehler beim Prüfen der gelöschten Zeilen", http.StatusInternalServerError)
|
||||
log.Printf("Fehler beim Prüfen der gelöschten Zeilen: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Alle Renewal Queue-Einträge gelöscht: %d Einträge", rowsAffected)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "Alle Renewal Queue-Einträge erfolgreich gelöscht",
|
||||
"deletedCount": rowsAffected,
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
|
||||
// Audit-Log
|
||||
if auditService != nil {
|
||||
ipAddress, userAgent := getRequestInfo(r)
|
||||
auditService.Track(r.Context(), "DELETE", "renewal_queue", "", userID, username, map[string]interface{}{
|
||||
"deletedCount": rowsAffected,
|
||||
"message": fmt.Sprintf("Alle Renewal Queue-Einträge gelöscht (%d Einträge)", rowsAffected),
|
||||
}, ipAddress, userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -242,9 +242,27 @@ func processCertificateRenewal(certID, fqdnID, spaceID, queueID string) error {
|
||||
traceID := generateTraceID()
|
||||
log.Printf("Starte automatische Zertifikatserneuerung (Queue ID: %s, TraceID: %s, FQDN: %s)", queueID, traceID, fqdn.FQDN)
|
||||
|
||||
// Erstelle ACME-Client-Kontext
|
||||
// Standardmäßig verwenden wir Let's Encrypt Staging, aber in Zukunft könnte dies aus der FQDN-Konfiguration kommen
|
||||
acmeProviderIDStr := "letsencrypt-staging" // TODO: Aus FQDN-Konfiguration lesen
|
||||
acmeCtx, err := NewACMEClientContext(acmeProviderIDStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Initialisieren des ACME-Providers: %v", err)
|
||||
}
|
||||
|
||||
// Prüfe ob der KeyID zum aktuellen Provider passt
|
||||
// Wenn der FQDN noch den alten Provider (certigo-acmeproxy) hat, aber wir jetzt einen direkten ACME-Provider verwenden,
|
||||
// muss der KeyID ignoriert werden, da er zu einem anderen Provider gehört
|
||||
keyIDToUse := fqdn.AcmeKeyID
|
||||
if fqdn.AcmeProviderID == "certigo-acmeproxy" && acmeProviderIDStr != "certigo-acmeproxy" {
|
||||
// Provider hat sich geändert - KeyID ist nicht mehr gültig
|
||||
log.Printf("Provider hat sich geändert (%s -> %s), erstelle neuen Account", fqdn.AcmeProviderID, acmeProviderIDStr)
|
||||
keyIDToUse = "" // Erzwinge neue Account-Erstellung
|
||||
}
|
||||
|
||||
// Beantrage neues Zertifikat
|
||||
baseFqdn := strings.TrimPrefix(fqdn.FQDN, "*.")
|
||||
result, err := RequestCertificate(baseFqdn, fqdn.AcmeEmail, fqdnID, fqdn.AcmeKeyID, traceID, updateTokenFunc, cleanupTokenFunc, statusCallback)
|
||||
result, err := RequestCertificate(acmeCtx, baseFqdn, fqdn.AcmeEmail, fqdnID, keyIDToUse, traceID, updateTokenFunc, cleanupTokenFunc, statusCallback)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fehler beim Beantragen des neuen Zertifikats: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,78 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
// Custom Dropdown Component
|
||||
const CustomDropdown = ({ label, value, onChange, options, placeholder = "Auswählen" }) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find(opt => opt.value === value) || { label: placeholder }
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<label className="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-3">
|
||||
{label}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-3 bg-slate-700/50 hover:bg-slate-700/70 active:bg-slate-700 text-white rounded-lg border border-slate-600/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all duration-200 text-sm flex items-center justify-between"
|
||||
>
|
||||
<span className={!value ? 'text-slate-400' : 'text-white'}>{selectedOption.label}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-2 bg-slate-800/95 backdrop-blur-sm border border-slate-600/50 rounded-lg shadow-2xl overflow-hidden">
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`w-full px-4 py-2.5 text-left text-sm transition-all duration-150 ${
|
||||
value === option.value
|
||||
? 'bg-blue-600/20 text-blue-300 border-l-2 border-blue-500 font-medium'
|
||||
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AuditLogs = () => {
|
||||
const { authFetch } = useAuth()
|
||||
const [logs, setLogs] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastUpdate, setLastUpdate] = useState(null)
|
||||
const [filters, setFilters] = useState({
|
||||
action: '',
|
||||
resourceType: '',
|
||||
@@ -23,6 +90,7 @@ const AuditLogs = () => {
|
||||
try {
|
||||
if (!silent) {
|
||||
setLoading(true)
|
||||
setIsRefreshing(true)
|
||||
}
|
||||
setError('')
|
||||
|
||||
@@ -41,9 +109,8 @@ const AuditLogs = () => {
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('Audit-Logs Response:', data)
|
||||
console.log('Anzahl Logs:', data.logs?.length || 0)
|
||||
setLogs(data.logs || [])
|
||||
setLastUpdate(new Date())
|
||||
setPagination({
|
||||
...pagination,
|
||||
total: data.total || 0,
|
||||
@@ -57,6 +124,7 @@ const AuditLogs = () => {
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setLoading(false)
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,7 +140,7 @@ const AuditLogs = () => {
|
||||
}, 5000) // Aktualisiere alle 5 Sekunden
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [filters.action, filters.resourceType, filters.userId, pagination.offset])
|
||||
}, [filters.action, filters.resourceType, filters.userId, pagination.offset, authFetch])
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setFilters({ ...filters, [key]: value })
|
||||
@@ -188,6 +256,18 @@ const AuditLogs = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const formatLastUpdate = () => {
|
||||
if (!lastUpdate) return ''
|
||||
const now = new Date()
|
||||
const diff = Math.floor((now - lastUpdate) / 1000)
|
||||
if (diff < 5) return 'Gerade eben'
|
||||
if (diff < 60) return `Vor ${diff} Sekunden`
|
||||
const minutes = Math.floor(diff / 60)
|
||||
return `Vor ${minutes} Minute${minutes > 1 ? 'n' : ''}`
|
||||
}
|
||||
|
||||
const hasActiveFilters = filters.action || filters.resourceType || filters.userId
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
@@ -196,55 +276,80 @@ const AuditLogs = () => {
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Audit Log</h1>
|
||||
<p className="text-slate-300">Übersicht aller Systemaktivitäten und Änderungen</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
||||
<span>Live-Aktualisierung aktiv</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{isRefreshing && (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span className="text-sm">Aktualisiere...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-500/20 border border-green-500/50 rounded-full">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-green-300 font-medium">Live</span>
|
||||
</div>
|
||||
{lastUpdate && (
|
||||
<div className="text-sm text-slate-400">
|
||||
{formatLastUpdate()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="bg-slate-800/50 border border-slate-600/50 rounded-lg p-4 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Aktion
|
||||
</label>
|
||||
<select
|
||||
value={filters.action}
|
||||
onChange={(e) => handleFilterChange('action', e.target.value)}
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-white">Filter</h2>
|
||||
{hasActiveFilters && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setFilters({ action: '', resourceType: '', userId: '' })
|
||||
setPagination({ ...pagination, offset: 0 })
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-700/50 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors duration-200 text-sm"
|
||||
>
|
||||
<option value="">Alle Aktionen</option>
|
||||
<option value="CREATE">Erstellt</option>
|
||||
<option value="UPDATE">Aktualisiert</option>
|
||||
<option value="DELETE">Gelöscht</option>
|
||||
<option value="UPLOAD">Hochgeladen</option>
|
||||
<option value="SIGN">Signiert</option>
|
||||
<option value="ENABLE">Aktiviert</option>
|
||||
<option value="DISABLE">Deaktiviert</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||
Ressourcentyp
|
||||
</label>
|
||||
<select
|
||||
value={filters.resourceType}
|
||||
onChange={(e) => handleFilterChange('resourceType', e.target.value)}
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Alle Typen</option>
|
||||
<option value="user">Benutzer</option>
|
||||
<option value="space">Space</option>
|
||||
<option value="fqdn">FQDN</option>
|
||||
<option value="csr">CSR</option>
|
||||
<option value="provider">Provider</option>
|
||||
<option value="certificate">Zertifikat</option>
|
||||
<option value="permission_group">Berechtigungsgruppen</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<CustomDropdown
|
||||
label="Aktion"
|
||||
value={filters.action}
|
||||
onChange={(value) => handleFilterChange('action', value)}
|
||||
options={[
|
||||
{ value: '', label: 'Alle Aktionen' },
|
||||
{ value: 'CREATE', label: 'Erstellt' },
|
||||
{ value: 'UPDATE', label: 'Aktualisiert' },
|
||||
{ value: 'DELETE', label: 'Gelöscht' },
|
||||
{ value: 'UPLOAD', label: 'Hochgeladen' },
|
||||
{ value: 'SIGN', label: 'Signiert' },
|
||||
{ value: 'ENABLE', label: 'Aktiviert' },
|
||||
{ value: 'DISABLE', label: 'Deaktiviert' }
|
||||
]}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Ressourcentyp"
|
||||
value={filters.resourceType}
|
||||
onChange={(value) => handleFilterChange('resourceType', value)}
|
||||
options={[
|
||||
{ value: '', label: 'Alle Typen' },
|
||||
{ value: 'user', label: 'Benutzer' },
|
||||
{ value: 'space', label: 'Space' },
|
||||
{ value: 'fqdn', label: 'FQDN' },
|
||||
{ value: 'csr', label: 'CSR' },
|
||||
{ value: 'provider', label: 'Provider' },
|
||||
{ value: 'certificate', label: 'Zertifikat' },
|
||||
{ value: 'permission_group', label: 'Berechtigungsgruppen' }
|
||||
]}
|
||||
/>
|
||||
<div className="relative">
|
||||
<label className="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-3">
|
||||
Benutzer-ID
|
||||
</label>
|
||||
<input
|
||||
@@ -252,7 +357,7 @@ const AuditLogs = () => {
|
||||
value={filters.userId}
|
||||
onChange={(e) => handleFilterChange('userId', e.target.value)}
|
||||
placeholder="Benutzer-ID filtern"
|
||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="w-full px-4 py-3 bg-slate-700/50 hover:bg-slate-700/70 text-white rounded-lg border border-slate-600/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all duration-200 text-sm placeholder-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,166 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
// Custom Dropdown Component
|
||||
const CustomDropdown = ({ label, value, onChange, options, placeholder = "Auswählen" }) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const selectedOption = options.find(opt => opt.value === value) || { label: placeholder }
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<label className="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-3">
|
||||
{label}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="w-full px-4 py-3 bg-slate-700/50 hover:bg-slate-700/70 active:bg-slate-700 text-white rounded-lg border border-slate-600/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all duration-200 text-sm flex items-center justify-between"
|
||||
>
|
||||
<span className={value === 'all' ? 'text-slate-400' : 'text-white'}>{selectedOption.label}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="absolute z-50 w-full mt-2 bg-slate-800/95 backdrop-blur-sm border border-slate-600/50 rounded-lg shadow-2xl overflow-hidden">
|
||||
<div className="max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(option.value)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`w-full px-4 py-2.5 text-left text-sm transition-all duration-150 ${
|
||||
value === option.value
|
||||
? 'bg-blue-600/20 text-blue-300 border-l-2 border-blue-500 font-medium'
|
||||
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RenewalQueue = () => {
|
||||
const { authFetch } = useAuth()
|
||||
const [queue, setQueue] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [statusFilter, setStatusFilter] = useState('all')
|
||||
const [spaceFilter, setSpaceFilter] = useState('all')
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastUpdate, setLastUpdate] = useState(null)
|
||||
|
||||
const fetchQueue = async () => {
|
||||
const fetchQueue = async (silent = false) => {
|
||||
try {
|
||||
if (!silent) {
|
||||
setIsRefreshing(true)
|
||||
}
|
||||
const response = await authFetch('/api/renewal-queue')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
setQueue(data.queue || [])
|
||||
setLastUpdate(new Date())
|
||||
setLoading(false)
|
||||
} catch (err) {
|
||||
console.error('Error fetching renewal queue:', err)
|
||||
setLoading(false)
|
||||
} finally {
|
||||
if (!silent) {
|
||||
setIsRefreshing(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchQueue()
|
||||
const interval = setInterval(fetchQueue, 30000) // Refresh every 30 seconds
|
||||
// Live-Refresh alle 5 Sekunden für Echtzeit-Ansicht
|
||||
const interval = setInterval(() => fetchQueue(true), 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [authFetch])
|
||||
|
||||
// Extrahiere eindeutige Spaces für Filter
|
||||
const uniqueSpaces = useMemo(() => {
|
||||
const spaces = new Set()
|
||||
queue.forEach(item => {
|
||||
if (item.spaceName) {
|
||||
spaces.add(item.spaceName)
|
||||
}
|
||||
})
|
||||
return Array.from(spaces).sort()
|
||||
}, [queue])
|
||||
|
||||
// Filtere und sortiere Queue-Einträge
|
||||
const filteredAndSortedQueue = useMemo(() => {
|
||||
let filtered = queue
|
||||
|
||||
// Filter nach Status
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(item => item.status === statusFilter)
|
||||
}
|
||||
|
||||
// Filter nach Space
|
||||
if (spaceFilter !== 'all') {
|
||||
filtered = filtered.filter(item => item.spaceName === spaceFilter)
|
||||
}
|
||||
|
||||
// Sortiere nach scheduled_at (nächste oben)
|
||||
filtered.sort((a, b) => {
|
||||
const dateA = new Date(a.scheduledAt)
|
||||
const dateB = new Date(b.scheduledAt)
|
||||
return dateA - dateB
|
||||
})
|
||||
|
||||
return filtered
|
||||
}, [queue, statusFilter, spaceFilter])
|
||||
|
||||
// Teile in "Ausstehend" und "Erledigt"
|
||||
const pendingQueue = useMemo(() => {
|
||||
return filteredAndSortedQueue.filter(item =>
|
||||
item.status === 'pending' || item.status === 'processing'
|
||||
)
|
||||
}, [filteredAndSortedQueue])
|
||||
|
||||
const completedQueue = useMemo(() => {
|
||||
return filteredAndSortedQueue.filter(item =>
|
||||
item.status === 'completed' || item.status === 'failed'
|
||||
)
|
||||
}, [filteredAndSortedQueue])
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50'
|
||||
case 'processing':
|
||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/50'
|
||||
case 'success':
|
||||
return 'bg-green-500/20 text-green-400 border-green-500/50'
|
||||
case 'completed':
|
||||
return 'bg-green-500/20 text-green-400 border-green-500/50'
|
||||
case 'failed':
|
||||
@@ -86,13 +214,92 @@ const RenewalQueue = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const renderQueueTable = (items, title) => {
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">{title}</h2>
|
||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-700/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">FQDN</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Space</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Geplant für</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Verarbeitet</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Fehler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/50">
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-slate-700/30 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white font-medium">{item.fqdn || '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-300">{item.spaceName || '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-300">{formatDate(item.scheduledAt)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(item.status)}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-300">{formatDate(item.processedAt)}</td>
|
||||
<td className="px-6 py-4 text-sm text-red-400">{item.errorMessage || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
const formatLastUpdate = () => {
|
||||
if (!lastUpdate) return ''
|
||||
const now = new Date()
|
||||
const diff = Math.floor((now - lastUpdate) / 1000)
|
||||
if (diff < 5) return 'Gerade eben'
|
||||
if (diff < 60) return `Vor ${diff} Sekunden`
|
||||
const minutes = Math.floor(diff / 60)
|
||||
return `Vor ${minutes} Minute${minutes > 1 ? 'n' : ''}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
|
||||
<div className="max-w-10xl mx-auto">
|
||||
<h1 className="text-4xl font-bold text-white mb-4">Renewal Queue</h1>
|
||||
<p className="text-lg text-slate-200 mb-8">
|
||||
Übersicht über geplante und laufende Zertifikatserneuerungen
|
||||
</p>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-white mb-2">Renewal Queue</h1>
|
||||
<p className="text-lg text-slate-200">
|
||||
Übersicht über geplante und laufende Zertifikatserneuerungen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{isRefreshing && (
|
||||
<div className="flex items-center gap-2 text-blue-400">
|
||||
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span className="text-sm">Aktualisiere...</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-500/20 border border-green-500/50 rounded-full">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||
<span className="text-sm text-green-300 font-medium">Live</span>
|
||||
</div>
|
||||
{lastUpdate && (
|
||||
<div className="text-sm text-slate-400">
|
||||
{formatLastUpdate()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
@@ -100,44 +307,68 @@ const RenewalQueue = () => {
|
||||
<p className="text-slate-300 mt-4">Lade Queue...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 overflow-hidden">
|
||||
{queue.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<p className="text-slate-400 text-lg">Keine Einträge in der Renewal Queue</p>
|
||||
<>
|
||||
{/* Filter */}
|
||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-white">Filter</h2>
|
||||
{(statusFilter !== 'all' || spaceFilter !== 'all') && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setStatusFilter('all')
|
||||
setSpaceFilter('all')
|
||||
}}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-slate-700/50 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors duration-200 text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-700/50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">FQDN</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Space</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Geplant für</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Verarbeitet</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-300 uppercase tracking-wider">Fehler</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/50">
|
||||
{queue.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-slate-700/30 transition-colors">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-white font-medium">{item.fqdn || '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-300">{item.spaceName || '-'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-300">{formatDate(item.scheduledAt)}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-semibold border ${getStatusColor(item.status)}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-300">{formatDate(item.processedAt)}</td>
|
||||
<td className="px-6 py-4 text-sm text-red-400">{item.errorMessage || '-'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<CustomDropdown
|
||||
label="Status"
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
options={[
|
||||
{ value: 'all', label: 'Alle Status' },
|
||||
{ value: 'pending', label: 'Pending' },
|
||||
{ value: 'processing', label: 'Processing' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
{ value: 'failed', label: 'Failed' }
|
||||
]}
|
||||
/>
|
||||
<CustomDropdown
|
||||
label="Space"
|
||||
value={spaceFilter}
|
||||
onChange={setSpaceFilter}
|
||||
options={[
|
||||
{ value: 'all', label: 'Alle Spaces' },
|
||||
...uniqueSpaces.map(space => ({ value: space, label: space }))
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ausstehende Tasks */}
|
||||
{renderQueueTable(pendingQueue, `Ausstehend (${pendingQueue.length})`)}
|
||||
|
||||
{/* Erledigte Tasks */}
|
||||
{renderQueueTable(completedQueue, `Erledigt (${completedQueue.length})`)}
|
||||
|
||||
{/* Keine Einträge */}
|
||||
{filteredAndSortedQueue.length === 0 && (
|
||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-12 text-center">
|
||||
<p className="text-slate-400 text-lg">
|
||||
{queue.length === 0
|
||||
? 'Keine Einträge in der Renewal Queue'
|
||||
: 'Keine Einträge entsprechen den gewählten Filtern'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user