feature/letsEncryptProvider #10
@@ -21,12 +21,50 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// ACMEDirectory enthält die Endpunkte eines ACME-Servers
|
||||||
// Let's Encrypt Staging für Tests
|
type ACMEDirectory struct {
|
||||||
acmeStagingDirectory = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
NewNonce string `json:"newNonce"`
|
||||||
acmeStagingNewAccount = "https://acme-staging-v02.api.letsencrypt.org/acme/new-acct"
|
NewAccount string `json:"newAccount"`
|
||||||
acmeStagingNewOrder = "https://acme-staging-v02.api.letsencrypt.org/acme/new-order"
|
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
|
// ACMEKeyPair enthält Private und Public Key
|
||||||
type ACMEKeyPair struct {
|
type ACMEKeyPair struct {
|
||||||
@@ -114,10 +152,9 @@ func loadOrCreateKeyPair(fqdnID string, keyDir string) (*ACMEKeyPair, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// getNonce ruft einen neuen Nonce vom ACME-Server ab
|
// 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
|
// 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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fehler beim Erstellen des HEAD-Requests: %v", err)
|
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
|
return txtValue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createAccount erstellt einen neuen Account bei Let's Encrypt
|
// createAccount erstellt einen neuen Account bei einem ACME-Server
|
||||||
func createAccount(keyPair *ACMEKeyPair, email string, traceID, fqdnID string, statusCallback func(status string)) (string, error) {
|
func createAccount(keyPair *ACMEKeyPair, directoryURL, newAccountURL string, email string, traceID, fqdnID string, statusCallback func(status string)) (string, error) {
|
||||||
statusCallback("Erstelle Account bei Let's Encrypt...")
|
statusCallback("Erstelle Account bei ACME-Server...")
|
||||||
|
|
||||||
// Hole Nonce vom Server
|
// Hole Nonce vom Server
|
||||||
nonce, err := getNonce()
|
nonce, err := getNonce(directoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
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)
|
// 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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fehler beim Erstellen des JWS: %v", err)
|
return "", fmt.Errorf("fehler beim Erstellen des JWS: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sende Request
|
// Sende Request
|
||||||
req, err := http.NewRequest("POST", acmeStagingNewAccount, bytes.NewBufferString(jws))
|
req, err := http.NewRequest("POST", newAccountURL, bytes.NewBufferString(jws))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fehler beim Erstellen des HTTP-Requests: %v", err)
|
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
|
return keyID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// createOrder erstellt eine neue Order bei Let's Encrypt
|
// createOrder erstellt eine neue Order bei einem ACME-Server
|
||||||
func createOrder(keyPair *ACMEKeyPair, keyID string, domains []string, traceID, fqdnID string, statusCallback func(status string)) (string, map[string]interface{}, error) {
|
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 Let's Encrypt...")
|
statusCallback("Erstelle Order bei ACME-Server...")
|
||||||
|
|
||||||
// Hole Nonce vom Server
|
// Hole Nonce vom Server
|
||||||
nonce, err := getNonce()
|
nonce, err := getNonce(directoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
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)
|
// Erstelle JWS (mit KeyID)
|
||||||
jws, err := createJWS(keyPair, payload, acmeStagingNewOrder, keyID, nonce)
|
jws, err := createJWS(keyPair, payload, newOrderURL, keyID, nonce)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("fehler beim Erstellen des JWS: %v", err)
|
return "", nil, fmt.Errorf("fehler beim Erstellen des JWS: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sende Request
|
// Sende Request
|
||||||
req, err := http.NewRequest("POST", acmeStagingNewOrder, bytes.NewBufferString(jws))
|
req, err := http.NewRequest("POST", newOrderURL, bytes.NewBufferString(jws))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", nil, fmt.Errorf("fehler beim Erstellen des HTTP-Requests: %v", err)
|
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
|
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
|
// 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
|
// 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] ===== REQUEST CERTIFICATE START =====")
|
||||||
log.Printf("[ACME] FQDN: %s", fqdn)
|
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")
|
result.Status = append(result.Status, "Key-Paar erfolgreich geladen/erstellt")
|
||||||
statusCallback("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...")
|
log.Printf("[ACME] Schritt 2: Erstelle/Verwende Account...")
|
||||||
var keyID string
|
var keyID string
|
||||||
if existingKeyID == "" {
|
if existingKeyID == "" {
|
||||||
log.Printf("[ACME] Keine KeyID vorhanden, erstelle neuen Account...")
|
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"
|
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 {
|
if err != nil {
|
||||||
log.Printf("[ACME] FEHLER bei Schritt 2 (Account-Erstellung): %v", err)
|
log.Printf("[ACME] FEHLER bei Schritt 2 (Account-Erstellung): %v", err)
|
||||||
logCertStatus(traceID, fqdnID, "ACCOUNT_ERSTELLUNG", "FAILED", err.Error())
|
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))
|
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
|
baseFqdn := fqdn
|
||||||
if strings.HasPrefix(baseFqdn, "*.") {
|
if strings.HasPrefix(baseFqdn, "*.") {
|
||||||
baseFqdn = baseFqdn[2:]
|
baseFqdn = baseFqdn[2:]
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[ACME] Schritt 3: Erstelle Order für Domain: %s", baseFqdn)
|
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"
|
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 {
|
if err != nil {
|
||||||
log.Printf("[ACME] FEHLER bei Schritt 3 (Order-Erstellung): %v", err)
|
log.Printf("[ACME] FEHLER bei Schritt 3 (Order-Erstellung): %v", err)
|
||||||
logCertStatus(traceID, fqdnID, "ORDER_ERSTELLUNG", "FAILED", err.Error())
|
logCertStatus(traceID, fqdnID, "ORDER_ERSTELLUNG", "FAILED", err.Error())
|
||||||
@@ -557,7 +594,7 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID
|
|||||||
if orderResponse != nil {
|
if orderResponse != nil {
|
||||||
statusCallback("Extrahiere Challenge-Token...")
|
statusCallback("Extrahiere Challenge-Token...")
|
||||||
|
|
||||||
token, err := extractTokenFromOrder(keyPair, keyID, orderResponse, baseFqdn)
|
token, err := extractTokenFromOrder(ctx, keyPair, keyID, orderResponse, baseFqdn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logCertStatus(traceID, fqdnID, "TOKEN_EXTRAKTION", "FAILED", err.Error())
|
logCertStatus(traceID, fqdnID, "TOKEN_EXTRAKTION", "FAILED", err.Error())
|
||||||
return nil, fmt.Errorf("fehler beim Extrahieren des Tokens: %v", err)
|
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
|
// Schritt 5: Aktiviere Challenge bei Let's Encrypt
|
||||||
statusCallback("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 {
|
if err != nil {
|
||||||
logCertStatus(traceID, fqdnID, "CHALLENGE_URL_EXTRAKTION", "FAILED", err.Error())
|
logCertStatus(traceID, fqdnID, "CHALLENGE_URL_EXTRAKTION", "FAILED", err.Error())
|
||||||
return nil, fmt.Errorf("fehler beim Extrahieren der Challenge-URL: %v", err)
|
return nil, fmt.Errorf("fehler beim Extrahieren der Challenge-URL: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.StepStatus["CHALLENGE_AKTIVIERUNG"] = "loading"
|
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())
|
logCertStatus(traceID, fqdnID, "CHALLENGE_AKTIVIERUNG", "FAILED", err.Error())
|
||||||
result.StepStatus["CHALLENGE_AKTIVIERUNG"] = "error"
|
result.StepStatus["CHALLENGE_AKTIVIERUNG"] = "error"
|
||||||
return nil, fmt.Errorf("fehler beim Aktivieren der Challenge: %v", err)
|
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)
|
// Schritt 6: Warte auf Challenge-Validierung (Polling)
|
||||||
statusCallback("Warte auf Challenge-Validierung...")
|
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
|
// Cleanup wurde bereits in waitForChallengeValidation durchgeführt
|
||||||
// Bereinige auch den Token aus der Datenbank
|
// Bereinige auch den Token aus der Datenbank
|
||||||
if cleanupTokenFunc != nil {
|
if cleanupTokenFunc != nil {
|
||||||
@@ -627,7 +664,7 @@ func RequestCertificate(fqdn string, email string, fqdnID string, existingKeyID
|
|||||||
statusCallback("Finalisiere Order und hole Zertifikat...")
|
statusCallback("Finalisiere Order und hole Zertifikat...")
|
||||||
|
|
||||||
result.StepStatus["ZERTIFIKAT_ERSTELLUNG"] = "loading"
|
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 {
|
if err != nil {
|
||||||
logCertStatus(traceID, fqdnID, "ZERTIFIKAT_ERSTELLUNG", "FAILED", err.Error())
|
logCertStatus(traceID, fqdnID, "ZERTIFIKAT_ERSTELLUNG", "FAILED", err.Error())
|
||||||
result.StepStatus["ZERTIFIKAT_ERSTELLUNG"] = "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
|
// 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
|
// Extrahiere authorizations Array
|
||||||
authorizations, ok := orderResponse["authorizations"].([]interface{})
|
authorizations, ok := orderResponse["authorizations"].([]interface{})
|
||||||
@@ -661,7 +698,7 @@ func extractTokenFromOrder(keyPair *ACMEKeyPair, keyID string, orderResponse map
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hole Nonce für Authorization-Request
|
// Hole Nonce für Authorization-Request
|
||||||
nonce, err := getNonce()
|
nonce, err := getNonce(ctx.DirectoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
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
|
// 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
|
// Extrahiere authorizations Array
|
||||||
authorizations, ok := orderResponse["authorizations"].([]interface{})
|
authorizations, ok := orderResponse["authorizations"].([]interface{})
|
||||||
@@ -766,7 +803,7 @@ func extractChallengeURLFromOrder(keyPair *ACMEKeyPair, keyID string, orderRespo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hole Nonce für Authorization-Request
|
// Hole Nonce für Authorization-Request
|
||||||
nonce, err := getNonce()
|
nonce, err := getNonce(ctx.DirectoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
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")
|
return "", fmt.Errorf("keine DNS-01 Challenge URL in Authorizations gefunden")
|
||||||
}
|
}
|
||||||
|
|
||||||
// activateChallenge aktiviert eine Challenge bei Let's Encrypt
|
// activateChallenge aktiviert eine Challenge beim ACME-Server
|
||||||
func activateChallenge(keyPair *ACMEKeyPair, keyID string, challengeURL string, traceID, fqdnID string) error {
|
func activateChallenge(ctx *ACMEClientContext, keyPair *ACMEKeyPair, keyID string, challengeURL string, traceID, fqdnID string) error {
|
||||||
// Hole Nonce
|
// Hole Nonce
|
||||||
nonce, err := getNonce()
|
nonce, err := getNonce(ctx.DirectoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
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
|
// 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
|
// Hole Nonce
|
||||||
nonce, err := getNonce()
|
nonce, err := getNonce(ctx.DirectoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
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)
|
// 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
|
maxAttempts := 30 // Maximal 30 Versuche
|
||||||
pollInterval := 2 * time.Second // Alle 2 Sekunden prüfen
|
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))
|
statusCallback(fmt.Sprintf("Prüfe Challenge-Status (%d/%d)...", attempt, maxAttempts))
|
||||||
|
|
||||||
// Hole Nonce
|
// Hole Nonce
|
||||||
nonce, err := getNonce()
|
nonce, err := getNonce(ctx.DirectoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
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)
|
// 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
|
maxAttempts := 30 // Maximal 30 Versuche
|
||||||
pollInterval := 2 * time.Second // Alle 2 Sekunden prüfen
|
pollInterval := 2 * time.Second // Alle 2 Sekunden prüfen
|
||||||
maxConsecutiveErrors := 3 // Maximal 3 aufeinanderfolgende Fehler
|
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))
|
statusCallback(fmt.Sprintf("Prüfe Order-Status (%d/%d)...", attempt, maxAttempts))
|
||||||
|
|
||||||
// Hole Nonce
|
// Hole Nonce
|
||||||
nonce, err := getNonce()
|
nonce, err := getNonce(ctx.DirectoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ACME] Fehler beim Abrufen des Nonce: %v", err)
|
log.Printf("[ACME] Fehler beim Abrufen des Nonce: %v", err)
|
||||||
consecutiveErrors++
|
consecutiveErrors++
|
||||||
@@ -1184,11 +1221,11 @@ func waitForOrderReady(keyPair *ACMEKeyPair, keyID string, orderURL string, trac
|
|||||||
}
|
}
|
||||||
|
|
||||||
// finalizeOrderAndGetCertificate finalisiert die Order und holt das Zertifikat
|
// 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")
|
// Warte, bis die Order bereit ist (Status "ready" oder "valid")
|
||||||
statusCallback("Warte auf Order-Bereitschaft...")
|
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)
|
return "", "", fmt.Errorf("fehler beim Warten auf Order-Bereitschaft: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1235,7 +1272,7 @@ func finalizeOrderAndGetCertificate(keyPair *ACMEKeyPair, keyID string, orderURL
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Hole Nonce
|
// Hole Nonce
|
||||||
nonce, err := getNonce()
|
nonce, err := getNonce(ctx.DirectoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
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++ {
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
statusCallback(fmt.Sprintf("Prüfe Order-Status (%d/%d)...", attempt, maxAttempts))
|
statusCallback(fmt.Sprintf("Prüfe Order-Status (%d/%d)...", attempt, maxAttempts))
|
||||||
|
|
||||||
nonce, err := getNonce()
|
nonce, err := getNonce(ctx.DirectoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1349,7 +1386,7 @@ func finalizeOrderAndGetCertificate(keyPair *ACMEKeyPair, keyID string, orderURL
|
|||||||
statusCallback("Hole Zertifikat...")
|
statusCallback("Hole Zertifikat...")
|
||||||
|
|
||||||
// Hole Zertifikat
|
// Hole Zertifikat
|
||||||
nonce, err = getNonce()
|
nonce, err = getNonce(ctx.DirectoryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("fehler beim Abrufen des Nonce: %v", err)
|
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)
|
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...")
|
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 {
|
if err != nil {
|
||||||
logCertStatus(traceID, fqdnID, "ZERTIFIKATSANFRAGE_GESAMT", "FAILED", err.Error())
|
logCertStatus(traceID, fqdnID, "ZERTIFIKATSANFRAGE_GESAMT", "FAILED", err.Error())
|
||||||
stepStatus["ZERTIFIKATSANFRAGE_GESAMT"] = "error"
|
stepStatus["ZERTIFIKATSANFRAGE_GESAMT"] = "error"
|
||||||
@@ -5593,6 +5603,11 @@ func main() {
|
|||||||
pm.RegisterProvider(providers.NewHetznerProvider())
|
pm.RegisterProvider(providers.NewHetznerProvider())
|
||||||
pm.RegisterProvider(providers.NewCertigoACMEProxyProvider())
|
pm.RegisterProvider(providers.NewCertigoACMEProxyProvider())
|
||||||
|
|
||||||
|
// Initialisiere ACME-Provider
|
||||||
|
acmeManager := providers.GetACMEManager()
|
||||||
|
acmeManager.RegisterACMEProvider(providers.NewLetsEncryptProvider("production"))
|
||||||
|
acmeManager.RegisterACMEProvider(providers.NewLetsEncryptProvider("staging"))
|
||||||
|
|
||||||
// Starte Renewal Scheduler
|
// Starte Renewal Scheduler
|
||||||
StartRenewalScheduler()
|
StartRenewalScheduler()
|
||||||
|
|
||||||
@@ -5655,6 +5670,7 @@ func main() {
|
|||||||
|
|
||||||
// Renewal Queue Routes
|
// Renewal Queue Routes
|
||||||
api.HandleFunc("/renewal-queue", basicAuthMiddleware(getRenewalQueueHandler)).Methods("GET", "OPTIONS")
|
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)
|
// Renewal Queue Test Routes (nur für Administratoren)
|
||||||
api.HandleFunc("/renewal-queue/test/create", basicAuthMiddleware(createTestRenewalQueueEntryHandler)).Methods("POST", "OPTIONS")
|
api.HandleFunc("/renewal-queue/test/create", basicAuthMiddleware(createTestRenewalQueueEntryHandler)).Methods("POST", "OPTIONS")
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ servers:
|
|||||||
- url: http://localhost:8080/api
|
- url: http://localhost:8080/api
|
||||||
description: Local development server
|
description: Local development server
|
||||||
|
|
||||||
|
security:
|
||||||
|
- basicAuth: []
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
/health:
|
/health:
|
||||||
get:
|
get:
|
||||||
@@ -561,9 +564,52 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
success:
|
||||||
|
type: boolean
|
||||||
|
example: true
|
||||||
|
queue:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/RenewalQueueEntry'
|
$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:
|
/renewal-queue/test/create:
|
||||||
post:
|
post:
|
||||||
@@ -1766,6 +1812,7 @@ components:
|
|||||||
example: 5
|
example: 5
|
||||||
|
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
{}:
|
basicAuth:
|
||||||
type: http
|
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"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"certigo-addon-backend/providers"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,10 +62,23 @@ func CalculateCertID(certPEM string) (string, error) {
|
|||||||
return certID, nil
|
return certID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchRenewalInfo ruft die RenewalInfo von Let's Encrypt ab
|
// FetchRenewalInfo ruft die RenewalInfo von einem ACME-Server ab
|
||||||
func FetchRenewalInfo(certID string) (*RenewalInfoResponse, error) {
|
func FetchRenewalInfo(providerID string, certID string) (*RenewalInfoResponse, error) {
|
||||||
// Let's Encrypt RenewalInfo API URL (Staging)
|
// Hole ACME-Provider
|
||||||
url := fmt.Sprintf("https://acme-staging-v02.api.letsencrypt.org/acme/renewal-info/%s", certID)
|
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
|
// HTTP Request
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
@@ -143,7 +158,10 @@ func ProcessRenewalInfoForCertificate(certPEM string, certID string, fqdnID stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rufe RenewalInfo ab
|
// 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 {
|
if err != nil {
|
||||||
// Prüfe ob es ein Staging-Zertifikat ist (RenewalInfo nicht verfügbar)
|
// Prüfe ob es ein Staging-Zertifikat ist (RenewalInfo nicht verfügbar)
|
||||||
if strings.Contains(err.Error(), "Staging-Zertifikate") {
|
if strings.Contains(err.Error(), "Staging-Zertifikate") {
|
||||||
|
|||||||
@@ -101,15 +101,16 @@ func updateFqdnRenewalEnabledHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
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 {
|
if err != nil {
|
||||||
http.Error(w, "Fehler beim Löschen der Queue-Einträge", http.StatusInternalServerError)
|
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)
|
log.Printf("Fehler beim Löschen der Queue-Einträge: %v", err)
|
||||||
return
|
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
|
// 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()
|
traceID := generateTraceID()
|
||||||
log.Printf("Starte automatische Zertifikatserneuerung (Queue ID: %s, TraceID: %s, FQDN: %s)", queueID, traceID, fqdn.FQDN)
|
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
|
// Beantrage neues Zertifikat
|
||||||
baseFqdn := strings.TrimPrefix(fqdn.FQDN, "*.")
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("fehler beim Beantragen des neuen Zertifikats: %v", err)
|
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'
|
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 AuditLogs = () => {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [logs, setLogs] = useState([])
|
const [logs, setLogs] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
|
const [lastUpdate, setLastUpdate] = useState(null)
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
action: '',
|
action: '',
|
||||||
resourceType: '',
|
resourceType: '',
|
||||||
@@ -23,6 +90,7 @@ const AuditLogs = () => {
|
|||||||
try {
|
try {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setIsRefreshing(true)
|
||||||
}
|
}
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
@@ -41,9 +109,8 @@ const AuditLogs = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
console.log('Audit-Logs Response:', data)
|
|
||||||
console.log('Anzahl Logs:', data.logs?.length || 0)
|
|
||||||
setLogs(data.logs || [])
|
setLogs(data.logs || [])
|
||||||
|
setLastUpdate(new Date())
|
||||||
setPagination({
|
setPagination({
|
||||||
...pagination,
|
...pagination,
|
||||||
total: data.total || 0,
|
total: data.total || 0,
|
||||||
@@ -57,6 +124,7 @@ const AuditLogs = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
setIsRefreshing(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +140,7 @@ const AuditLogs = () => {
|
|||||||
}, 5000) // Aktualisiere alle 5 Sekunden
|
}, 5000) // Aktualisiere alle 5 Sekunden
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [filters.action, filters.resourceType, filters.userId, pagination.offset])
|
}, [filters.action, filters.resourceType, filters.userId, pagination.offset, authFetch])
|
||||||
|
|
||||||
const handleFilterChange = (key, value) => {
|
const handleFilterChange = (key, value) => {
|
||||||
setFilters({ ...filters, [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 (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 p-6">
|
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<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>
|
<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>
|
<p className="text-slate-300">Übersicht aller Systemaktivitäten und Änderungen</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
{isRefreshing && (
|
||||||
<span>Live-Aktualisierung aktiv</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<div className="bg-slate-800/50 border border-slate-600/50 rounded-lg p-4 mb-6">
|
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<h2 className="text-lg font-semibold text-white">Filter</h2>
|
||||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
{hasActiveFilters && (
|
||||||
Aktion
|
<button
|
||||||
</label>
|
onClick={() => {
|
||||||
<select
|
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"
|
||||||
|
>
|
||||||
|
<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}
|
value={filters.action}
|
||||||
onChange={(e) => handleFilterChange('action', e.target.value)}
|
onChange={(value) => handleFilterChange('action', 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"
|
options={[
|
||||||
>
|
{ value: '', label: 'Alle Aktionen' },
|
||||||
<option value="">Alle Aktionen</option>
|
{ value: 'CREATE', label: 'Erstellt' },
|
||||||
<option value="CREATE">Erstellt</option>
|
{ value: 'UPDATE', label: 'Aktualisiert' },
|
||||||
<option value="UPDATE">Aktualisiert</option>
|
{ value: 'DELETE', label: 'Gelöscht' },
|
||||||
<option value="DELETE">Gelöscht</option>
|
{ value: 'UPLOAD', label: 'Hochgeladen' },
|
||||||
<option value="UPLOAD">Hochgeladen</option>
|
{ value: 'SIGN', label: 'Signiert' },
|
||||||
<option value="SIGN">Signiert</option>
|
{ value: 'ENABLE', label: 'Aktiviert' },
|
||||||
<option value="ENABLE">Aktiviert</option>
|
{ value: 'DISABLE', label: 'Deaktiviert' }
|
||||||
<option value="DISABLE">Deaktiviert</option>
|
]}
|
||||||
</select>
|
/>
|
||||||
</div>
|
<CustomDropdown
|
||||||
<div>
|
label="Ressourcentyp"
|
||||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
|
||||||
Ressourcentyp
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={filters.resourceType}
|
value={filters.resourceType}
|
||||||
onChange={(e) => handleFilterChange('resourceType', e.target.value)}
|
onChange={(value) => handleFilterChange('resourceType', 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"
|
options={[
|
||||||
>
|
{ value: '', label: 'Alle Typen' },
|
||||||
<option value="">Alle Typen</option>
|
{ value: 'user', label: 'Benutzer' },
|
||||||
<option value="user">Benutzer</option>
|
{ value: 'space', label: 'Space' },
|
||||||
<option value="space">Space</option>
|
{ value: 'fqdn', label: 'FQDN' },
|
||||||
<option value="fqdn">FQDN</option>
|
{ value: 'csr', label: 'CSR' },
|
||||||
<option value="csr">CSR</option>
|
{ value: 'provider', label: 'Provider' },
|
||||||
<option value="provider">Provider</option>
|
{ value: 'certificate', label: 'Zertifikat' },
|
||||||
<option value="certificate">Zertifikat</option>
|
{ value: 'permission_group', label: 'Berechtigungsgruppen' }
|
||||||
<option value="permission_group">Berechtigungsgruppen</option>
|
]}
|
||||||
</select>
|
/>
|
||||||
</div>
|
<div className="relative">
|
||||||
<div>
|
<label className="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-3">
|
||||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
|
||||||
Benutzer-ID
|
Benutzer-ID
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -252,7 +357,7 @@ const AuditLogs = () => {
|
|||||||
value={filters.userId}
|
value={filters.userId}
|
||||||
onChange={(e) => handleFilterChange('userId', e.target.value)}
|
onChange={(e) => handleFilterChange('userId', e.target.value)}
|
||||||
placeholder="Benutzer-ID filtern"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,38 +1,166 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
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 RenewalQueue = () => {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [queue, setQueue] = useState([])
|
const [queue, setQueue] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
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 {
|
try {
|
||||||
|
if (!silent) {
|
||||||
|
setIsRefreshing(true)
|
||||||
|
}
|
||||||
const response = await authFetch('/api/renewal-queue')
|
const response = await authFetch('/api/renewal-queue')
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`HTTP error! status: ${response.status}`)
|
throw new Error(`HTTP error! status: ${response.status}`)
|
||||||
}
|
}
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
setQueue(data.queue || [])
|
setQueue(data.queue || [])
|
||||||
|
setLastUpdate(new Date())
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching renewal queue:', err)
|
console.error('Error fetching renewal queue:', err)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
} finally {
|
||||||
|
if (!silent) {
|
||||||
|
setIsRefreshing(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchQueue()
|
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)
|
return () => clearInterval(interval)
|
||||||
}, [authFetch])
|
}, [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) => {
|
const getStatusColor = (status) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'pending':
|
case 'pending':
|
||||||
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50'
|
return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50'
|
||||||
case 'processing':
|
case 'processing':
|
||||||
return 'bg-blue-500/20 text-blue-400 border-blue-500/50'
|
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':
|
case 'completed':
|
||||||
return 'bg-green-500/20 text-green-400 border-green-500/50'
|
return 'bg-green-500/20 text-green-400 border-green-500/50'
|
||||||
case 'failed':
|
case 'failed':
|
||||||
@@ -86,26 +214,15 @@ const RenewalQueue = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const renderQueueTable = (items, title) => {
|
||||||
<div className="p-8 min-h-full bg-gradient-to-r from-slate-700 to-slate-900">
|
if (items.length === 0) {
|
||||||
<div className="max-w-10xl mx-auto">
|
return null
|
||||||
<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>
|
|
||||||
|
|
||||||
{loading ? (
|
return (
|
||||||
<div className="text-center py-12">
|
<div className="mb-8">
|
||||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
<h2 className="text-2xl font-bold text-white mb-4">{title}</h2>
|
||||||
<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">
|
<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>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-slate-700/50">
|
<thead className="bg-slate-700/50">
|
||||||
@@ -119,7 +236,7 @@ const RenewalQueue = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-700/50">
|
<tbody className="divide-y divide-slate-700/50">
|
||||||
{queue.map((item) => (
|
{items.map((item) => (
|
||||||
<tr key={item.id} className="hover:bg-slate-700/30 transition-colors">
|
<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-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">{item.spaceName || '-'}</td>
|
||||||
@@ -136,8 +253,122 @@ const RenewalQueue = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
|
||||||
|
<p className="text-slate-300 mt-4">Lade Queue...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 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="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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user