feature/letsEncryptProvider #10

Merged
nick.adam merged 2 commits from feature/letsEncryptProvider into development 2025-11-27 22:54:39 +00:00
42 changed files with 11527 additions and 1158 deletions
Showing only changes of commit 688b277b5d - Show all commits

View File

@@ -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)
}

View 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
}

View File

@@ -0,0 +1,5 @@
{
"enabled": true,
"settings": {}
}

View File

@@ -0,0 +1,5 @@
{
"enabled": true,
"settings": {}
}

View File

@@ -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")

View File

@@ -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

View 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)
}

View 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{}
}

View File

@@ -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") {

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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>

View File

@@ -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>