Compare commits

...

5 Commits

43 changed files with 11565 additions and 1163 deletions

19
.gitignore vendored
View File

@@ -87,6 +87,14 @@ vite.config.ts.timestamp-*
*.sqlite3
backend/spaces.db
backend/*.db
backend/**/*.db
backend/**/*.db-shm
backend/**/*.db-wal
# Test databases
backend/testing/**/*.db
backend/testing/**/*.db-shm
backend/testing/**/*.db-wal
# Database backups
*.sql.backup
@@ -114,6 +122,9 @@ yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
backend/*.log
backend/logs/
backend/logs/**
*.log.*
# ============================================
# IDE & Editors
@@ -337,6 +348,14 @@ backend/test-outputs/
# Script outputs
backend/scripts/output/
# Testing directory (keep structure, ignore test databases)
backend/testing/scripts/*.db
backend/testing/scripts/*.db-shm
backend/testing/scripts/*.db-wal
# Keep directory structure but ignore contents
!backend/uploads/.gitkeep
!backend/config/providers/.gitkeep
!backend/testing/.gitkeep
!backend/testing/README.md
!backend/testing/scripts/*.go

1448
backend/acme_client.go Normal file

File diff suppressed because it is too large Load Diff

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
}

65
backend/cert_logger.go Normal file
View File

@@ -0,0 +1,65 @@
package main
import (
"crypto/rand"
"encoding/hex"
"fmt"
"log"
"os"
"path/filepath"
"time"
)
var certLogger *log.Logger
var certLogFile *os.File
// initCertLogger initialisiert das Logging-System für Zertifikatsanfragen
func initCertLogger() error {
logDir := "logs"
if err := os.MkdirAll(logDir, 0755); err != nil {
return fmt.Errorf("fehler beim Erstellen des Log-Verzeichnisses: %v", err)
}
logFile := filepath.Join(logDir, fmt.Sprintf("cert-requests-%s.log", time.Now().Format("2006-01-02")))
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("fehler beim Öffnen der Log-Datei: %v", err)
}
certLogFile = file
certLogger = log.New(file, "", log.LstdFlags)
return nil
}
// generateTraceID generiert eine eindeutige TraceID für einen Vorgang
func generateTraceID() string {
bytes := make([]byte, 8)
rand.Read(bytes)
return hex.EncodeToString(bytes)
}
// logCertStatus schreibt einen Status-Eintrag in die Log-Datei
// traceID: Eindeutige ID für den Vorgang
// step: Name des Schritts (z.B. "DNS_PRÜFUNG", "REGISTER_AUFRUF", "ACCOUNT_ERSTELLUNG", "ORDER_ERSTELLUNG", "CHALLENGE_VALIDIERUNG", "ZERTIFIKAT_ERSTELLT")
// status: "OK" oder "FAILED"
// message: Fehlermeldung bei FAILED, leer bei OK
func logCertStatus(traceID, fqdnID, step, status, message string) {
if certLogger == nil {
return
}
timestamp := time.Now().Format("2006-01-02 15:04:05")
if status == "OK" {
certLogger.Printf("[%s] TRACE_ID=%s FQDN_ID=%s VORGANG=%s STATUS=OK", timestamp, traceID, fqdnID, step)
} else {
certLogger.Printf("[%s] TRACE_ID=%s FQDN_ID=%s VORGANG=%s STATUS=FAILED ERROR=%s", timestamp, traceID, fqdnID, step, message)
}
}
// closeCertLogger schließt die Log-Datei
func closeCertLogger() {
if certLogFile != nil {
certLogFile.Close()
}
}

160
backend/cert_parser.go Normal file
View File

@@ -0,0 +1,160 @@
package main
import (
"crypto/x509"
"database/sql"
"encoding/pem"
"fmt"
"strings"
"time"
)
// ParseCertificateExtrakt Ablaufdatum und CA-Status aus einem PEM-Zertifikat
// Gibt zurück: expiresAt, isIntermediate, error
func ParseCertificate(certPEM string) (time.Time, bool, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return time.Time{}, false, fmt.Errorf("fehler beim Dekodieren des PEM-Blocks")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return time.Time{}, false, fmt.Errorf("fehler beim Parsen des Zertifikats: %v", err)
}
expiresAt := cert.NotAfter
// Ein Zertifikat ist Intermediate wenn IsCA=true ist
isIntermediate := cert.IsCA
return expiresAt, isIntermediate, nil
}
// SplitCertificateChain trennt eine PEM-Zertifikatskette in einzelne Zertifikate
// Gibt zurück: leafCert (PEM), intermediateCert (PEM), error
func SplitCertificateChain(certChainPEM string) (string, string, error) {
var leafCert string
var intermediateCert string
// Dekodiere alle PEM-Blöcke aus der Kette
var blocks []*pem.Block
rest := []byte(certChainPEM)
for {
block, remaining := pem.Decode(rest)
if block == nil {
break
}
if block.Type == "CERTIFICATE" {
blocks = append(blocks, block)
}
rest = remaining
}
if len(blocks) == 0 {
return "", "", fmt.Errorf("keine Zertifikate in der Kette gefunden")
}
// Parse jedes Zertifikat und trenne nach IsCA
for _, block := range blocks {
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
continue // Überspringe ungültige Zertifikate
}
// Encode zurück zu PEM
certPEM := string(pem.EncodeToMemory(block))
if cert.IsCA {
// Intermediate CA
if intermediateCert != "" {
// Wenn bereits ein Intermediate vorhanden ist, hänge es an (kann mehrere geben)
intermediateCert += "\n" + certPEM
} else {
intermediateCert = certPEM
}
} else {
// Leaf Certificate
if leafCert != "" {
// Wenn bereits ein Leaf vorhanden ist, verwende das erste (sollte nur eines geben)
continue
}
leafCert = certPEM
}
}
return leafCert, intermediateCert, nil
}
// GetCertificateIssuer extrahiert den Issuer-Namen aus einem PEM-Zertifikat
// Gibt zurück: issuerName (string), error
func GetCertificateIssuer(certPEM string) (string, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return "", fmt.Errorf("fehler beim Dekodieren des PEM-Blocks")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("fehler beim Parsen des Zertifikats: %v", err)
}
return cert.Issuer.String(), nil
}
// GetProviderNameFromIssuer bestimmt den Provider-Namen basierend auf dem Issuer
func GetProviderNameFromIssuer(issuer string) string {
issuerLower := fmt.Sprintf("%v", issuer)
if strings.Contains(issuerLower, "Let's Encrypt") || strings.Contains(issuerLower, "letsencrypt") {
return "Let's Encrypt"
}
if strings.Contains(issuerLower, "DigiCert") {
return "DigiCert"
}
if strings.Contains(issuerLower, "GlobalSign") {
return "GlobalSign"
}
if strings.Contains(issuerLower, "Sectigo") {
return "Sectigo"
}
if strings.Contains(issuerLower, "GoDaddy") {
return "GoDaddy"
}
// Fallback: Gib den Issuer-Namen zurück
return issuer
}
// CheckExistingValidCertificate prüft ob bereits ein gültiges Zertifikat für einen FQDN existiert
// Gibt zurück: exists (bool), expiresAt (time.Time), error
func CheckExistingValidCertificate(fqdnID, spaceID string) (bool, time.Time, error) {
var certPEM string
var expiresAtStr sql.NullString
err := db.QueryRow(`
SELECT certificate_pem, expires_at
FROM certificates
WHERE fqdn_id = ? AND space_id = ? AND status = 'issued'
ORDER BY created_at DESC
LIMIT 1
`, fqdnID, spaceID).Scan(&certPEM, &expiresAtStr)
if err != nil {
if err == sql.ErrNoRows {
return false, time.Time{}, nil
}
return false, time.Time{}, err
}
// Wenn expires_at bereits in DB vorhanden ist, verwende es
if expiresAtStr.Valid && expiresAtStr.String != "" {
expiresAt, err := time.Parse("2006-01-02 15:04:05", expiresAtStr.String)
if err == nil {
return true, expiresAt, nil
}
}
// Sonst parse das Zertifikat
expiresAt, _, err := ParseCertificate(certPEM)
if err != nil {
return true, time.Time{}, err
}
return true, expiresAt, nil
}

View File

@@ -1,5 +1,6 @@
{
"enabled": false,
"acme_ready": false,
"settings": {
"password": "test",
"username": "test"

View File

@@ -0,0 +1,7 @@
{
"enabled": true,
"acme_ready": true,
"settings": {
"baseURL": "http://openmailserver.de:8080"
}
}

View File

@@ -1,4 +1,5 @@
{
"enabled": true,
"acme_ready": false,
"settings": {}
}

View File

@@ -1,4 +1,5 @@
{
"enabled": false,
"acme_ready": false,
"settings": {}
}

View File

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

View File

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

View File

@@ -5,9 +5,9 @@ go 1.24.0
toolchain go1.24.10
require (
github.com/google/uuid v1.5.0
github.com/go-jose/go-jose/v4 v4.1.3
github.com/google/uuid v1.6.0
github.com/gorilla/mux v1.8.1
github.com/mattn/go-sqlite3 v1.14.18
golang.org/x/crypto v0.45.0
)
require golang.org/x/crypto v0.45.0 // indirect

View File

@@ -1,5 +1,7 @@
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,234 @@
package providers
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// CertigoACMEProxyProvider ist der Provider für certigo-acmeproxy
type CertigoACMEProxyProvider struct {
baseURL string
}
func NewCertigoACMEProxyProvider() *CertigoACMEProxyProvider {
return &CertigoACMEProxyProvider{}
}
func (p *CertigoACMEProxyProvider) GetName() string {
return "certigo-acmeproxy"
}
func (p *CertigoACMEProxyProvider) GetDisplayName() string {
return "Certigo ACME Proxy"
}
func (p *CertigoACMEProxyProvider) GetDescription() string {
return "ACME DNS-01 Challenge Responder für Let's Encrypt und andere ACME CAs"
}
func (p *CertigoACMEProxyProvider) ValidateConfig(settings map[string]interface{}) error {
baseURL, ok := settings["baseURL"].(string)
if !ok || strings.TrimSpace(baseURL) == "" {
return fmt.Errorf("baseURL ist erforderlich")
}
// Entferne trailing slash falls vorhanden
baseURL = strings.TrimSuffix(baseURL, "/")
// Validiere URL-Format
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return fmt.Errorf("baseURL muss mit http:// oder https:// beginnen")
}
return nil
}
func (p *CertigoACMEProxyProvider) TestConnection(settings map[string]interface{}) error {
// Validiere zuerst die Konfiguration
if err := p.ValidateConfig(settings); err != nil {
return err
}
baseURL, _ := settings["baseURL"].(string)
baseURL = strings.TrimSuffix(baseURL, "/")
// Teste Verbindung über Health Check
url := fmt.Sprintf("%s/health", baseURL)
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get(url)
if err != nil {
return fmt.Errorf("acme-proxy nicht erreichbar: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("acme-proxy antwortet mit Status %d", resp.StatusCode)
}
// Prüfe Response Body
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("fehler beim Lesen der Health-Check-Response: %v", err)
}
var healthResponse struct {
Status string `json:"status"`
}
if err := json.Unmarshal(body, &healthResponse); err != nil {
return fmt.Errorf("ungültige Health-Check-Response: %v", err)
}
if healthResponse.Status != "ok" {
return fmt.Errorf("acme-proxy meldet Status: %s", healthResponse.Status)
}
return nil
}
// GetRequiredSettings gibt die erforderlichen Einstellungen zurück
func (p *CertigoACMEProxyProvider) GetRequiredSettings() []SettingField {
return []SettingField{
{
Name: "baseURL",
Label: "Base URL",
Type: "text",
Required: true,
Description: "Base URL des certigo-acmeproxy Services (z.B. http://localhost:8080)",
},
}
}
// RegisterChallengeDomain registriert eine neue Challenge-Domain beim ACME Proxy
func (p *CertigoACMEProxyProvider) RegisterChallengeDomain(settings map[string]interface{}) (*ChallengeDomainResponse, error) {
if err := p.ValidateConfig(settings); err != nil {
return nil, err
}
baseURL, _ := settings["baseURL"].(string)
baseURL = strings.TrimSuffix(baseURL, "/")
url := fmt.Sprintf("%s/register", baseURL)
client := &http.Client{
Timeout: 10 * time.Second,
}
req, err := http.NewRequest("POST", url, nil)
if err != nil {
return nil, fmt.Errorf("fehler beim Erstellen des Requests: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fehler beim Senden des Requests: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("fehler beim Lesen der Response: %v", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("acme-proxy Fehler (Status %d): %s", resp.StatusCode, string(body))
}
var registerResponse ChallengeDomainResponse
if err := json.Unmarshal(body, &registerResponse); err != nil {
return nil, fmt.Errorf("fehler beim Parsen der Response: %v", err)
}
return &registerResponse, nil
}
// UpdateChallengeToken setzt oder aktualisiert den ACME Challenge Token
func (p *CertigoACMEProxyProvider) UpdateChallengeToken(username, password, token string, settings map[string]interface{}) error {
if err := p.ValidateConfig(settings); err != nil {
return err
}
baseURL, _ := settings["baseURL"].(string)
baseURL = strings.TrimSuffix(baseURL, "/")
url := fmt.Sprintf("%s/update", baseURL)
requestBody := map[string]string{
"txt": token,
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return fmt.Errorf("fehler beim Erstellen des Request-Body: %v", err)
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("fehler beim Erstellen des Requests: %v", err)
}
req.Header.Set("Content-Type", "application/json")
// Basic Authentication
auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
req.Header.Set("Authorization", "Basic "+auth)
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("fehler beim Senden des Requests: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("fehler beim Lesen der Response: %v", err)
}
if resp.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("ungültige Authentifizierung")
}
if resp.StatusCode == http.StatusBadRequest {
return fmt.Errorf("ungültige Anfrage: %s", string(body))
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("acme-proxy Fehler (Status %d): %s", resp.StatusCode, string(body))
}
return nil
}
// ChallengeDomainResponse enthält die Response von /register
type ChallengeDomainResponse struct {
Username string `json:"username"`
Password string `json:"password"`
Fulldomain string `json:"fulldomain"`
Subdomain string `json:"subdomain"`
}
// SignCSR signiert einen CSR (für ACME nicht direkt verwendet, aber Interface erfordert es)
func (p *CertigoACMEProxyProvider) SignCSR(csrPEM string, settings map[string]interface{}) (*SignCSRResult, error) {
return nil, fmt.Errorf("certigo-acmeproxy unterstützt keine direkte CSR-Signierung. Verwenden Sie ACME für Zertifikatsanfragen.")
}
// GetCertificate ruft ein Zertifikat ab (für ACME nicht direkt verwendet, aber Interface erfordert es)
func (p *CertigoACMEProxyProvider) GetCertificate(certificateID string, settings map[string]interface{}) (string, error) {
return "", fmt.Errorf("certigo-acmeproxy unterstützt keinen direkten Zertifikat-Abruf. Verwenden Sie ACME für Zertifikatsanfragen.")
}

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

@@ -11,6 +11,7 @@ import (
// ProviderConfig enthält die Konfiguration eines Providers
type ProviderConfig struct {
Enabled bool `json:"enabled"`
AcmeReady bool `json:"acme_ready"`
Settings map[string]interface{} `json:"settings"`
}
@@ -78,6 +79,7 @@ func (pm *ProviderManager) RegisterProvider(provider Provider) {
if pm.configs[providerID] == nil {
pm.configs[providerID] = &ProviderConfig{
Enabled: false,
AcmeReady: false,
Settings: make(map[string]interface{}),
}
}
@@ -111,6 +113,7 @@ func (pm *ProviderManager) GetProviderConfig(id string) (*ProviderConfig, error)
if !exists {
return &ProviderConfig{
Enabled: false,
AcmeReady: false,
Settings: make(map[string]interface{}),
}, nil
}
@@ -146,6 +149,7 @@ func (pm *ProviderManager) SetProviderEnabled(id string, enabled bool) error {
if pm.configs[id] == nil {
pm.configs[id] = &ProviderConfig{
Enabled: enabled,
AcmeReady: false,
Settings: make(map[string]interface{}),
}
} else {

View File

@@ -17,6 +17,7 @@ type ProviderInfo struct {
DisplayName string `json:"displayName"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
AcmeReady bool `json:"acme_ready"`
Settings []SettingField `json:"settings"`
}

207
backend/renewal_info.go Normal file
View File

@@ -0,0 +1,207 @@
package main
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"certigo-addon-backend/providers"
"github.com/google/uuid"
)
// RenewalInfoResponse enthält die Antwort von Let's Encrypt RenewalInfo API
type RenewalInfoResponse struct {
SuggestedWindow struct {
Start string `json:"start"`
End string `json:"end"`
} `json:"suggestedWindow"`
}
// CalculateCertID berechnet die CertID für Let's Encrypt RenewalInfo API
// Die CertID setzt sich aus Authority Key Identifier (AKI) und Serial Number zusammen
// Format: base64url(AKI).base64url(SerialNumber)
func CalculateCertID(certPEM string) (string, error) {
// Parse Zertifikat
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return "", fmt.Errorf("fehler beim Dekodieren des PEM-Blocks")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("fehler beim Parsen des Zertifikats: %v", err)
}
// Verwende AuthorityKeyId direkt aus dem Zertifikat
if len(cert.AuthorityKeyId) == 0 {
return "", fmt.Errorf("Authority Key Identifier nicht im Zertifikat gefunden")
}
// Encoding Referenz: base64.RawURLEncoding entfernt das Padding (=) automatisch
encoder := base64.RawURLEncoding
// Teil 1: AKI
akiPart := encoder.EncodeToString(cert.AuthorityKeyId)
// Teil 2: Serial Number
// WICHTIG: .Bytes() nutzen, NICHT .String() (was dezimal wäre) oder Hex!
serialPart := encoder.EncodeToString(cert.SerialNumber.Bytes())
// Resultat: AKI.SerialNumber mit Punkt getrennt
certID := fmt.Sprintf("%s.%s", akiPart, serialPart)
return certID, nil
}
// FetchRenewalInfo ruft die RenewalInfo von einem ACME-Server ab
func FetchRenewalInfo(providerID string, certID string) (*RenewalInfoResponse, error) {
// Hole ACME-Provider
acmeManager := providers.GetACMEManager()
provider, exists := acmeManager.GetACMEProvider(providerID)
if !exists {
return nil, fmt.Errorf("ACME-Provider '%s' nicht gefunden", providerID)
}
// Hole RenewalInfo URL vom Provider
renewalInfoURL := provider.GetRenewalInfoURL()
if renewalInfoURL == "" {
return nil, fmt.Errorf("ACME-Provider '%s' unterstützt keine RenewalInfo API", providerID)
}
// Erstelle URL mit CertID
url := fmt.Sprintf("%s/%s", renewalInfoURL, certID)
// HTTP Request
client := &http.Client{
Timeout: 10 * time.Second,
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, fmt.Errorf("fehler beim Erstellen des Requests: %v", err)
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fehler beim Senden des Requests: %v", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("fehler beim Lesen der Response: %v", err)
}
if resp.StatusCode != http.StatusOK {
// Prüfe ob es ein Staging-Zertifikat ist (404 mit spezifischer Fehlermeldung)
if resp.StatusCode == http.StatusNotFound {
bodyStr := string(body)
if strings.Contains(bodyStr, "Authority Key Identifier that did not match a known issuer") {
return nil, fmt.Errorf("RenewalInfo nicht verfügbar für Staging-Zertifikate (Status %d)", resp.StatusCode)
}
}
return nil, fmt.Errorf("RenewalInfo API Fehler (Status %d): %s", resp.StatusCode, string(body))
}
var renewalInfo RenewalInfoResponse
if err := json.Unmarshal(body, &renewalInfo); err != nil {
return nil, fmt.Errorf("fehler beim Parsen der Response: %v", err)
}
return &renewalInfo, nil
}
// ProcessRenewalInfoForCertificate verarbeitet RenewalInfo für ein Zertifikat
// Trennt die Zertifikatskette, berechnet CertID für Leaf, ruft RenewalInfo ab und erstellt Queue-Eintrag
func ProcessRenewalInfoForCertificate(certPEM string, certID string, fqdnID string, spaceID string, renewalEnabled bool) error {
if !renewalEnabled {
log.Printf("RenewalInfo wird übersprungen - renewal_enabled ist für FQDN %s deaktiviert", fqdnID)
return nil
}
// Trenne Zertifikatskette in Leaf und Intermediate
leafPEM, _, err := SplitCertificateChain(certPEM)
if err != nil {
return fmt.Errorf("fehler beim Trennen der Zertifikatskette: %v", err)
}
if leafPEM == "" {
return fmt.Errorf("kein Leaf-Zertifikat in der Kette gefunden")
}
// Berechne CertID für Leaf-Zertifikat
certIDBase64, err := CalculateCertID(leafPEM)
if err != nil {
return fmt.Errorf("fehler beim Berechnen der CertID: %v", err)
}
log.Printf("CertID berechnet: %s für Zertifikat %s", certIDBase64, certID)
// Speichere CertID in DB
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
_, err = db.ExecContext(ctx, "UPDATE certificates SET cert_id_base64 = ? WHERE id = ?", certIDBase64, certID)
if err != nil {
log.Printf("Warnung: Fehler beim Speichern der CertID in DB: %v", err)
// Weiter mit RenewalInfo-Abfrage, auch wenn DB-Update fehlschlägt
}
// Rufe RenewalInfo ab
// TODO: ACME-Provider-ID aus Zertifikat/FQDN-Konfiguration holen
// Standardmäßig verwenden wir Let's Encrypt Staging
acmeProviderID := "letsencrypt-staging"
renewalInfo, err := FetchRenewalInfo(acmeProviderID, certIDBase64)
if err != nil {
// Prüfe ob es ein Staging-Zertifikat ist (RenewalInfo nicht verfügbar)
if strings.Contains(err.Error(), "Staging-Zertifikate") {
log.Printf("RenewalInfo wird übersprungen - Staging-Zertifikat (CertID: %s)", certIDBase64)
return nil // Kein Fehler, einfach überspringen
}
return fmt.Errorf("fehler beim Abrufen der RenewalInfo: %v", err)
}
log.Printf("RenewalInfo erhalten: Suggested Window Start: %s, End: %s", renewalInfo.SuggestedWindow.Start, renewalInfo.SuggestedWindow.End)
// Parse Suggested Window Start
scheduledAt, err := time.Parse(time.RFC3339, renewalInfo.SuggestedWindow.Start)
if err != nil {
return fmt.Errorf("fehler beim Parsen des Scheduled-At-Datums: %v", err)
}
// Speichere renewal_scheduled_at in DB
scheduledAtStr := scheduledAt.UTC().Format("2006-01-02 15:04:05")
ctx, cancel = context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
_, err = db.ExecContext(ctx, "UPDATE certificates SET renewal_scheduled_at = ? WHERE id = ?", scheduledAtStr, certID)
if err != nil {
log.Printf("Warnung: Fehler beim Speichern von renewal_scheduled_at in DB: %v", err)
}
// Erstelle Queue-Eintrag
queueID := uuid.New().String()
createdAt := time.Now().UTC().Format("2006-01-02 15:04:05")
ctx, cancel = context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
_, err = db.ExecContext(ctx, `
INSERT INTO renewal_queue (id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', ?)
`, queueID, certID, fqdnID, spaceID, scheduledAtStr, createdAt)
if err != nil {
return fmt.Errorf("fehler beim Erstellen des Queue-Eintrags: %v", err)
}
log.Printf("RenewalInfo verarbeitet und Queue-Eintrag erstellt (ID: %s, Scheduled: %s)", queueID, scheduledAtStr)
return nil
}

View File

@@ -0,0 +1,333 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gorilla/mux"
)
// updateFqdnRenewalEnabledHandler aktualisiert das renewal_enabled Flag für einen FQDN
func updateFqdnRenewalEnabledHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
vars := mux.Vars(r)
spaceID := vars["spaceId"]
fqdnID := vars["fqdnId"]
// Prüfe Berechtigung
userID, _ := getUserFromRequest(r)
if userID == "" {
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
return
}
hasAccess, err := hasSpaceAccess(userID, spaceID)
if err != nil {
http.Error(w, "Fehler beim Prüfen der Berechtigung", http.StatusInternalServerError)
log.Printf("Fehler beim Prüfen der Berechtigung: %v", err)
return
}
if !hasAccess {
http.Error(w, "Keine Berechtigung für diesen Space", http.StatusForbidden)
return
}
// Prüfe ob FQDN existiert und hole aktuellen renewal_enabled Status
var currentRenewalEnabled sql.NullInt64
err = db.QueryRow("SELECT renewal_enabled FROM fqdns WHERE id = ? AND space_id = ?", fqdnID, spaceID).Scan(&currentRenewalEnabled)
if err == sql.ErrNoRows {
http.Error(w, "FQDN nicht gefunden", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Fehler beim Laden des FQDN", http.StatusInternalServerError)
log.Printf("Fehler beim Laden des FQDN: %v", err)
return
}
// Parse Request Body
var req struct {
RenewalEnabled bool `json:"renewalEnabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Ungültige Anfrage", http.StatusBadRequest)
return
}
// Prüfe ob renewal_enabled von false auf true geändert wird
wasDisabled := !currentRenewalEnabled.Valid || currentRenewalEnabled.Int64 == 0
willBeEnabled := req.RenewalEnabled
shouldProcessRenewalInfo := wasDisabled && willBeEnabled
// Beginne Transaktion
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "Fehler beim Starten der Transaktion", http.StatusInternalServerError)
log.Printf("Fehler beim Starten der Transaktion: %v", err)
return
}
defer tx.Rollback()
// Update renewal_enabled (explizit als 0 oder 1 speichern, nicht NULL)
var renewalEnabledInt int
if req.RenewalEnabled {
renewalEnabledInt = 1
} else {
renewalEnabledInt = 0
}
_, err = tx.ExecContext(ctx, "UPDATE fqdns SET renewal_enabled = ? WHERE id = ? AND space_id = ?", renewalEnabledInt, fqdnID, spaceID)
if err != nil {
http.Error(w, "Fehler beim Aktualisieren des renewal_enabled Flags", http.StatusInternalServerError)
log.Printf("Fehler beim Aktualisieren des renewal_enabled Flags: %v", err)
return
}
// Wenn renewal_enabled deaktiviert wird, lösche nur pending/processing Queue-Einträge für diesen FQDN
// Completed und failed Einträge bleiben als Historie erhalten
if !req.RenewalEnabled {
_, err = tx.ExecContext(ctx, "DELETE FROM renewal_queue WHERE fqdn_id = ? AND status IN ('pending', 'processing')", fqdnID)
if err != nil {
http.Error(w, "Fehler beim Löschen der Queue-Einträge", http.StatusInternalServerError)
log.Printf("Fehler beim Löschen der Queue-Einträge: %v", err)
return
}
log.Printf("Pending/Processing Queue-Einträge für FQDN %s gelöscht (renewal_enabled deaktiviert)", fqdnID)
}
// Committe die Transaktion
if err = tx.Commit(); err != nil {
http.Error(w, "Fehler beim Speichern der Änderungen", http.StatusInternalServerError)
log.Printf("Fehler beim Committen der Transaktion: %v", err)
return
}
// Wenn renewal_enabled von false auf true geändert wurde, verarbeite RenewalInfo für das aktuelle Zertifikat
if shouldProcessRenewalInfo {
go func() {
// Hole das aktuellste (nicht-intermediate) Zertifikat für diesen FQDN
var certID, certPEM string
err := db.QueryRow(`
SELECT id, certificate_pem
FROM certificates
WHERE fqdn_id = ? AND (is_intermediate = 0 OR is_intermediate IS NULL)
ORDER BY expires_at DESC, created_at DESC
LIMIT 1
`, fqdnID).Scan(&certID, &certPEM)
if err == sql.ErrNoRows {
log.Printf("Kein Zertifikat für FQDN %s gefunden - RenewalInfo wird übersprungen", fqdnID)
return
}
if err != nil {
log.Printf("Fehler beim Laden des Zertifikats für FQDN %s: %v", fqdnID, err)
return
}
if certPEM == "" {
log.Printf("Zertifikat %s hat kein PEM - RenewalInfo wird übersprungen", certID)
return
}
// Verarbeite RenewalInfo im Hintergrund
log.Printf("Verarbeite RenewalInfo für Zertifikat %s (FQDN %s wurde aktiviert)", certID, fqdnID)
if err := ProcessRenewalInfoForCertificate(certPEM, certID, fqdnID, spaceID, true); err != nil {
log.Printf("Fehler beim Verarbeiten der RenewalInfo für FQDN %s (wird ignoriert): %v", fqdnID, err)
} else {
log.Printf("RenewalInfo erfolgreich verarbeitet für FQDN %s", fqdnID)
}
}()
}
// Hole Username für Audit-Log
userID, username := getUserFromRequest(r)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"renewalEnabled": req.RenewalEnabled,
})
// Audit-Log
if auditService != nil {
ipAddress, userAgent := getRequestInfo(r)
auditService.Track(r.Context(), "UPDATE", "fqdn", fqdnID, userID, username, map[string]interface{}{
"spaceId": spaceID,
"renewalEnabled": req.RenewalEnabled,
"message": fmt.Sprintf("Renewal-Status für FQDN aktualisiert: %v", req.RenewalEnabled),
}, ipAddress, userAgent)
}
}
// getRenewalQueueHandler gibt alle Einträge aus der renewal_queue zurück
func getRenewalQueueHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Prüfe Berechtigung - jeder authentifizierte User kann die Queue sehen
userID, _ := getUserFromRequest(r)
if userID == "" {
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
return
}
// Hole alle Queue-Einträge mit FQDN und Space-Informationen
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, `
SELECT
rq.id,
rq.certificate_id,
rq.fqdn_id,
rq.space_id,
rq.scheduled_at,
rq.status,
rq.created_at,
rq.processed_at,
rq.error_message,
f.fqdn,
s.name as space_name
FROM renewal_queue rq
LEFT JOIN fqdns f ON rq.fqdn_id = f.id
LEFT JOIN spaces s ON rq.space_id = s.id
ORDER BY rq.scheduled_at ASC
`)
if err != nil {
http.Error(w, "Fehler beim Laden der Renewal Queue", http.StatusInternalServerError)
log.Printf("Fehler beim Laden der Renewal Queue: %v", err)
return
}
defer rows.Close()
var queueItems []map[string]interface{}
for rows.Next() {
var id, certID, fqdnID, spaceID, scheduledAt, status, createdAt, fqdn, spaceName string
var processedAt, errorMessage sql.NullString
err := rows.Scan(&id, &certID, &fqdnID, &spaceID, &scheduledAt, &status, &createdAt, &processedAt, &errorMessage, &fqdn, &spaceName)
if err != nil {
log.Printf("Fehler beim Scannen der Queue-Zeile: %v", err)
continue
}
item := map[string]interface{}{
"id": id,
"certificateId": certID,
"fqdnId": fqdnID,
"spaceId": spaceID,
"scheduledAt": scheduledAt,
"status": status,
"createdAt": createdAt,
"fqdn": fqdn,
"spaceName": spaceName,
}
if processedAt.Valid {
item["processedAt"] = processedAt.String
}
if errorMessage.Valid {
item["errorMessage"] = errorMessage.String
}
queueItems = append(queueItems, item)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"queue": queueItems,
})
}
// deleteAllRenewalQueueEntriesHandler löscht alle Einträge aus der Renewal Queue
func deleteAllRenewalQueueEntriesHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Prüfe Berechtigung - nur authentifizierte User
userID, username := getUserFromRequest(r)
if userID == "" {
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
return
}
// Prüfe Bestätigung (optional, aber empfohlen)
confirm := r.URL.Query().Get("confirm")
if confirm != "true" {
http.Error(w, "Bestätigung erforderlich. Verwende ?confirm=true", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// Lösche alle Queue-Einträge
result, err := db.ExecContext(ctx, "DELETE FROM renewal_queue")
if err != nil {
http.Error(w, "Fehler beim Löschen der Queue-Einträge", http.StatusInternalServerError)
log.Printf("Fehler beim Löschen der Queue-Einträge: %v", err)
return
}
rowsAffected, err := result.RowsAffected()
if err != nil {
http.Error(w, "Fehler beim Prüfen der gelöschten Zeilen", http.StatusInternalServerError)
log.Printf("Fehler beim Prüfen der gelöschten Zeilen: %v", err)
return
}
log.Printf("Alle Renewal Queue-Einträge gelöscht: %d Einträge", rowsAffected)
response := map[string]interface{}{
"success": true,
"message": "Alle Renewal Queue-Einträge erfolgreich gelöscht",
"deletedCount": rowsAffected,
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
// Audit-Log
if auditService != nil {
ipAddress, userAgent := getRequestInfo(r)
auditService.Track(r.Context(), "DELETE", "renewal_queue", "", userID, username, map[string]interface{}{
"deletedCount": rowsAffected,
"message": fmt.Sprintf("Alle Renewal Queue-Einträge gelöscht (%d Einträge)", rowsAffected),
}, ipAddress, userAgent)
}
}

View File

@@ -0,0 +1,350 @@
package main
import (
"context"
"database/sql"
"fmt"
"log"
"strings"
"time"
"github.com/google/uuid"
"certigo-addon-backend/providers"
)
// StartRenewalScheduler startet den Scheduler für automatische Zertifikatserneuerungen
// Der Scheduler prüft regelmäßig die renewal_queue auf fällige Erneuerungen
func StartRenewalScheduler() {
go func() {
ticker := time.NewTicker(5 * time.Minute) // Prüfe alle 5 Minuten
defer ticker.Stop()
log.Println("Renewal Scheduler gestartet - prüfe alle 5 Minuten auf fällige Erneuerungen")
// Führe sofort eine Prüfung durch beim Start
processRenewalQueue()
// Dann in regelmäßigen Intervallen
for range ticker.C {
processRenewalQueue()
}
}()
}
// processRenewalQueue prüft die renewal_queue auf fällige Erneuerungen und führt sie aus
func processRenewalQueue() {
// Verwende kürzeren Context für die Queue-Abfrage, um Datenbankblockierungen zu vermeiden
queryCtx, queryCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer queryCancel()
now := time.Now().UTC().Format("2006-01-02 15:04:05")
// Hole alle fälligen Queue-Einträge (scheduled_at <= now und status = 'pending')
rows, err := db.QueryContext(queryCtx, `
SELECT id, certificate_id, fqdn_id, space_id, scheduled_at
FROM renewal_queue
WHERE status = 'pending' AND scheduled_at <= ?
ORDER BY scheduled_at ASC
LIMIT 10
`, now)
if err != nil {
log.Printf("Fehler beim Abfragen der renewal_queue: %v", err)
return
}
defer rows.Close()
var processedCount int
for rows.Next() {
var queueID, certID, fqdnID, spaceID, scheduledAt string
if err := rows.Scan(&queueID, &certID, &fqdnID, &spaceID, &scheduledAt); err != nil {
log.Printf("Fehler beim Scannen der Queue-Zeile: %v", err)
continue
}
// Markiere als in Bearbeitung (mit angemessenem Timeout)
// Verwende Retry-Logik für Datenbankoperationen, um Blockierungen zu handhaben
var updateErr error
for retry := 0; retry < 3; retry++ {
updateCtx, updateCancel := context.WithTimeout(context.Background(), 10*time.Second)
_, updateErr = db.ExecContext(updateCtx, `
UPDATE renewal_queue
SET status = 'processing', processed_at = ?
WHERE id = ?
`, time.Now().UTC().Format("2006-01-02 15:04:05"), queueID)
updateCancel()
if updateErr == nil {
break
}
if retry < 2 {
log.Printf("Warnung: Fehler beim Aktualisieren des Queue-Status (Versuch %d/3), versuche erneut: %v", retry+1, updateErr)
time.Sleep(time.Second * time.Duration(retry+1)) // Exponential backoff
}
}
if updateErr != nil {
log.Printf("Fehler beim Aktualisieren des Queue-Status nach 3 Versuchen: %v", updateErr)
continue
}
// Führe Erneuerung durch (in separater Goroutine, um Datenbankblockierungen zu vermeiden)
go func(qID, cID, fID, sID string) {
if err := processCertificateRenewal(cID, fID, sID, qID); err != nil {
log.Printf("Fehler bei der Zertifikatserneuerung (Queue ID: %s): %v", qID, err)
// Markiere als fehlgeschlagen (mit Retry-Logik)
var updateErr error
for retry := 0; retry < 3; retry++ {
errorCtx, errorCancel := context.WithTimeout(context.Background(), 10*time.Second)
_, updateErr = db.ExecContext(errorCtx, `
UPDATE renewal_queue
SET status = 'failed', error_message = ?
WHERE id = ?
`, err.Error(), qID)
errorCancel()
if updateErr == nil {
break
}
if retry < 2 {
time.Sleep(time.Second * time.Duration(retry+1))
}
}
if updateErr != nil {
log.Printf("Fehler beim Aktualisieren des Fehlerstatus nach 3 Versuchen: %v", updateErr)
}
return
}
// Markiere als erfolgreich (mit Retry-Logik)
var successErr error
for retry := 0; retry < 3; retry++ {
successCtx, successCancel := context.WithTimeout(context.Background(), 10*time.Second)
_, successErr = db.ExecContext(successCtx, `
UPDATE renewal_queue
SET status = 'completed'
WHERE id = ?
`, qID)
successCancel()
if successErr == nil {
break
}
if retry < 2 {
time.Sleep(time.Second * time.Duration(retry+1))
}
}
if successErr != nil {
log.Printf("Fehler beim Markieren als erfolgreich nach 3 Versuchen: %v", successErr)
} else {
log.Printf("Zertifikatserneuerung erfolgreich verarbeitet (Queue ID: %s, Cert ID: %s, FQDN: %s)", qID, cID, fID)
}
}(queueID, certID, fqdnID, spaceID)
processedCount++
}
if processedCount > 0 {
log.Printf("Renewal Queue: %d Erneuerungen gestartet", processedCount)
}
}
// processCertificateRenewal führt die tatsächliche Zertifikatserneuerung durch
func processCertificateRenewal(certID, fqdnID, spaceID, queueID string) error {
// Lade FQDN-Daten (mit angemessenem Timeout)
var fqdn FQDN
var acmeProviderID, acmeUsername, acmePassword, acmeEmail, acmeKeyID sql.NullString
var renewalEnabled sql.NullInt64
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
err := db.QueryRowContext(ctx, `
SELECT id, space_id, fqdn, acme_provider_id, acme_username, acme_password, acme_email, acme_key_id, COALESCE(renewal_enabled, 1)
FROM fqdns
WHERE id = ? AND space_id = ?
`, fqdnID, spaceID).Scan(
&fqdn.ID, &fqdn.SpaceID, &fqdn.FQDN,
&acmeProviderID, &acmeUsername, &acmePassword, &acmeEmail, &acmeKeyID, &renewalEnabled,
)
if err != nil {
return err
}
if acmeProviderID.String != "certigo-acmeproxy" || !acmeUsername.Valid || !acmePassword.Valid || !acmeEmail.Valid {
return fmt.Errorf("fqdn hat keine gültigen ACME-Daten")
}
fqdn.AcmeProviderID = acmeProviderID.String
fqdn.AcmeUsername = acmeUsername.String
fqdn.AcmePassword = acmePassword.String
fqdn.AcmeEmail = acmeEmail.String
if acmeKeyID.Valid {
fqdn.AcmeKeyID = acmeKeyID.String
}
// Lade Provider-Konfiguration
pm := providers.GetManager()
provider, exists := pm.GetProvider("certigo-acmeproxy")
if !exists || provider == nil {
return fmt.Errorf("acme provider nicht gefunden")
}
config, err := pm.GetProviderConfig("certigo-acmeproxy")
if err != nil {
return fmt.Errorf("Fehler beim Laden der Provider-Konfiguration: %v", err)
}
acmeProxyProvider, ok := provider.(*providers.CertigoACMEProxyProvider)
if !ok {
return fmt.Errorf("Ungültiger Provider-Typ")
}
// Erstelle Update- und Cleanup-Funktionen (mit Retry-Logik)
updateTokenFunc := func(token string) error {
var updateErr error
for retry := 0; retry < 3; retry++ {
updateCtx, updateCancel := context.WithTimeout(context.Background(), 10*time.Second)
_, updateErr = db.ExecContext(updateCtx, "UPDATE fqdns SET acme_challenge_token = ? WHERE id = ?", token, fqdnID)
updateCancel()
if updateErr == nil {
break
}
if retry < 2 {
time.Sleep(time.Second * time.Duration(retry+1))
}
}
if updateErr != nil {
return fmt.Errorf("fehler beim Speichern des Challenge-Tokens: %v", updateErr)
}
return acmeProxyProvider.UpdateChallengeToken(fqdn.AcmeUsername, fqdn.AcmePassword, token, config.Settings)
}
cleanupTokenFunc := func() error {
var cleanupErr error
for retry := 0; retry < 3; retry++ {
cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 10*time.Second)
_, cleanupErr = db.ExecContext(cleanupCtx, "UPDATE fqdns SET acme_challenge_token = NULL WHERE id = ?", fqdnID)
cleanupCancel()
if cleanupErr == nil {
break
}
if retry < 2 {
time.Sleep(time.Second * time.Duration(retry+1))
}
}
return cleanupErr
}
// Status-Callback (optional, für Logging)
statusCallback := func(status string) {
log.Printf("[Renewal Queue %s] Status: %s", queueID, status)
}
// Generiere TraceID
traceID := generateTraceID()
log.Printf("Starte automatische Zertifikatserneuerung (Queue ID: %s, TraceID: %s, FQDN: %s)", queueID, traceID, fqdn.FQDN)
// Erstelle ACME-Client-Kontext
// Standardmäßig verwenden wir Let's Encrypt Staging, aber in Zukunft könnte dies aus der FQDN-Konfiguration kommen
acmeProviderIDStr := "letsencrypt-staging" // TODO: Aus FQDN-Konfiguration lesen
acmeCtx, err := NewACMEClientContext(acmeProviderIDStr)
if err != nil {
return fmt.Errorf("fehler beim Initialisieren des ACME-Providers: %v", err)
}
// Prüfe ob der KeyID zum aktuellen Provider passt
// Wenn der FQDN noch den alten Provider (certigo-acmeproxy) hat, aber wir jetzt einen direkten ACME-Provider verwenden,
// muss der KeyID ignoriert werden, da er zu einem anderen Provider gehört
keyIDToUse := fqdn.AcmeKeyID
if fqdn.AcmeProviderID == "certigo-acmeproxy" && acmeProviderIDStr != "certigo-acmeproxy" {
// Provider hat sich geändert - KeyID ist nicht mehr gültig
log.Printf("Provider hat sich geändert (%s -> %s), erstelle neuen Account", fqdn.AcmeProviderID, acmeProviderIDStr)
keyIDToUse = "" // Erzwinge neue Account-Erstellung
}
// Beantrage neues Zertifikat
baseFqdn := strings.TrimPrefix(fqdn.FQDN, "*.")
result, err := RequestCertificate(acmeCtx, baseFqdn, fqdn.AcmeEmail, fqdnID, keyIDToUse, traceID, updateTokenFunc, cleanupTokenFunc, statusCallback)
if err != nil {
return fmt.Errorf("fehler beim Beantragen des neuen Zertifikats: %v", err)
}
// Speichere neues Zertifikat
if result.Certificate != "" && result.PrivateKey != "" {
newCertID := uuid.New().String()
certificateID := result.OrderURL
if certificateID == "" {
certificateID = newCertID
}
createdAt := time.Now().UTC().Format("2006-01-02 15:04:05")
expiresAt, isIntermediate, parseErr := ParseCertificate(result.Certificate)
var expiresAtStr string
var isIntermediateInt int
if parseErr == nil {
expiresAtStr = expiresAt.UTC().Format("2006-01-02 15:04:05")
if isIntermediate {
isIntermediateInt = 1
}
}
// Verwende separaten Context für INSERT mit Retry-Logik
var insertErr error
for retry := 0; retry < 3; retry++ {
insertCtx, insertCancel := context.WithTimeout(context.Background(), 10*time.Second)
_, insertErr = db.ExecContext(insertCtx, `
INSERT INTO certificates (id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, private_key_pem, status, expires_at, is_intermediate, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, newCertID, fqdnID, spaceID, nil, certificateID, "certigo-acmeproxy", result.Certificate, result.PrivateKey, "issued", expiresAtStr, isIntermediateInt, createdAt)
insertCancel()
if insertErr == nil {
break
}
if retry < 2 {
time.Sleep(time.Second * time.Duration(retry+1))
}
}
if insertErr != nil {
return fmt.Errorf("fehler beim Speichern des neuen Zertifikats: %v", insertErr)
}
// Markiere altes Zertifikat (optional, falls gewünscht)
// Hier könnten wir z.B. den Status auf 'replaced' setzen
// Verarbeite RenewalInfo für das neue Zertifikat (falls aktiviert)
renewalEnabledValue := true
if renewalEnabled.Valid {
renewalEnabledValue = renewalEnabled.Int64 == 1
}
if renewalEnabledValue {
go func() {
if err := ProcessRenewalInfoForCertificate(result.Certificate, newCertID, fqdnID, spaceID, true); err != nil {
log.Printf("Fehler beim Verarbeiten der RenewalInfo für erneuertes Zertifikat (wird ignoriert): %v", err)
}
}()
}
log.Printf("Automatische Zertifikatserneuerung erfolgreich abgeschlossen (Queue ID: %s, Neues Cert ID: %s)", queueID, newCertID)
}
// Update KeyID falls geändert (mit Retry-Logik)
if result.KeyID != "" && result.KeyID != fqdn.AcmeKeyID {
var keyIDErr error
for retry := 0; retry < 3; retry++ {
keyIDCtx, keyIDCancel := context.WithTimeout(context.Background(), 10*time.Second)
_, keyIDErr = db.ExecContext(keyIDCtx, "UPDATE fqdns SET acme_key_id = ? WHERE id = ?", result.KeyID, fqdnID)
keyIDCancel()
if keyIDErr == nil {
break
}
if retry < 2 {
time.Sleep(time.Second * time.Duration(retry+1))
}
}
if keyIDErr != nil {
log.Printf("Warnung: Fehler beim Speichern der neuen KeyID nach 3 Versuchen: %v", keyIDErr)
}
}
return nil
}

View File

@@ -0,0 +1,182 @@
package main
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/google/uuid"
)
// TestCreateRenewalQueueEntryRequest ist die Request-Struktur für das Erstellen eines Test-Queue-Eintrags
type TestCreateRenewalQueueEntryRequest struct {
CertificateID string `json:"certificateId"`
FQDNID string `json:"fqdnId"`
SpaceID string `json:"spaceId"`
MinutesFromNow int `json:"minutesFromNow"` // Negative Werte = in der Vergangenheit (sofort fällig)
}
// createTestRenewalQueueEntryHandler erstellt einen Test-Queue-Eintrag für die Renewal-Funktion
// Nur für Administratoren zugänglich
func createTestRenewalQueueEntryHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Prüfe ob User Admin ist
userID, _ := getUserFromRequest(r)
if userID == "" {
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
return
}
isAdmin, err := isUserAdmin(userID)
if err != nil || !isAdmin {
http.Error(w, "Nur Administratoren können Test-Queue-Einträge erstellen", http.StatusForbidden)
return
}
// Parse Request Body
var req TestCreateRenewalQueueEntryRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Ungültige Request-Daten", http.StatusBadRequest)
return
}
// Validiere Eingaben
if req.CertificateID == "" || req.FQDNID == "" || req.SpaceID == "" {
http.Error(w, "certificateId, fqdnId und spaceId sind erforderlich", http.StatusBadRequest)
return
}
// Prüfe ob Zertifikat existiert
var certExists bool
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM certificates WHERE id = ?)", req.CertificateID).Scan(&certExists)
if err != nil {
http.Error(w, "Fehler beim Prüfen des Zertifikats", http.StatusInternalServerError)
log.Printf("Fehler beim Prüfen des Zertifikats: %v", err)
return
}
if !certExists {
http.Error(w, "Zertifikat nicht gefunden", http.StatusNotFound)
return
}
// Prüfe ob FQDN existiert
var fqdnExists bool
err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM fqdns WHERE id = ?)", req.FQDNID).Scan(&fqdnExists)
if err != nil {
http.Error(w, "Fehler beim Prüfen des FQDN", http.StatusInternalServerError)
log.Printf("Fehler beim Prüfen des FQDN: %v", err)
return
}
if !fqdnExists {
http.Error(w, "FQDN nicht gefunden", http.StatusNotFound)
return
}
// Berechne scheduled_at Zeitpunkt
now := time.Now().UTC()
scheduledAt := now.Add(time.Duration(req.MinutesFromNow) * time.Minute)
scheduledAtStr := scheduledAt.Format("2006-01-02 15:04:05")
// Generiere eindeutige Queue-ID
queueID := fmt.Sprintf("test-%s", uuid.New().String())
createdAt := now.Format("2006-01-02 15:04:05")
// Erstelle Queue-Eintrag
ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = db.ExecContext(ctx, `
INSERT INTO renewal_queue (id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', ?)
`, queueID, req.CertificateID, req.FQDNID, req.SpaceID, scheduledAtStr, createdAt)
if err != nil {
http.Error(w, "Fehler beim Erstellen des Queue-Eintrags", http.StatusInternalServerError)
log.Printf("Fehler beim Erstellen des Queue-Eintrags: %v", err)
return
}
// Lade erstellten Eintrag
var entry struct {
ID string `json:"id"`
CertificateID string `json:"certificateId"`
FQDNID string `json:"fqdnId"`
SpaceID string `json:"spaceId"`
ScheduledAt string `json:"scheduledAt"`
Status string `json:"status"`
CreatedAt string `json:"createdAt"`
}
err = db.QueryRowContext(ctx, `
SELECT id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at
FROM renewal_queue
WHERE id = ?
`, queueID).Scan(&entry.ID, &entry.CertificateID, &entry.FQDNID, &entry.SpaceID, &entry.ScheduledAt, &entry.Status, &entry.CreatedAt)
if err != nil {
http.Error(w, "Fehler beim Abrufen des erstellten Eintrags", http.StatusInternalServerError)
log.Printf("Fehler beim Abrufen des erstellten Eintrags: %v", err)
return
}
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"entry": entry,
"message": fmt.Sprintf("Test-Queue-Eintrag erstellt (geplant: %s)", scheduledAtStr),
})
}
// triggerRenewalQueueHandler führt die Queue-Verarbeitung manuell aus
// Nur für Administratoren zugänglich
func triggerRenewalQueueHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
// Prüfe ob User Admin ist
userID, _ := getUserFromRequest(r)
if userID == "" {
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
return
}
isAdmin, err := isUserAdmin(userID)
if err != nil || !isAdmin {
http.Error(w, "Nur Administratoren können die Queue-Verarbeitung manuell auslösen", http.StatusForbidden)
return
}
// Führe Queue-Verarbeitung in einer Goroutine aus, um den Request nicht zu blockieren
go func() {
log.Println("Manuelle Queue-Verarbeitung gestartet (via Test-Endpoint)")
processRenewalQueue()
log.Println("Manuelle Queue-Verarbeitung abgeschlossen")
}()
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "Queue-Verarbeitung wurde gestartet (läuft im Hintergrund)",
})
}

View File

@@ -1,4 +1,4 @@
# Test-Skripte für Audit-Logs
# Test-Skripte
## Test-Logs generieren
@@ -40,3 +40,72 @@ curl -X DELETE "http://localhost:8080/api/audit-logs?confirm=true" \
**Wichtig**: Der `confirm=true` Query-Parameter ist erforderlich, um versehentliches Löschen zu verhindern.
## Renewal-Funktion testen
Das Skript `test_renewal.go` erstellt Test-Queue-Einträge für die Renewal-Funktion.
### Verwendung:
```bash
cd backend/testing/scripts
go run test_renewal.go
```
### Was wird erstellt:
- Test-Queue-Einträge mit verschiedenen Zeitstempeln:
- Einer sofort fällig (vor 1 Minute)
- Einer in 5 Minuten
- Einer in 10 Minuten
- Verwendet existierende FQDNs mit Zertifikaten
- Zeigt Queue-Status an
### Manuelle Tests über API:
#### 1. Test-Queue-Eintrag erstellen:
```bash
curl -X POST "http://localhost:8080/api/renewal-queue/test/create" \
-u admin:admin \
-H "Content-Type: application/json" \
-d '{
"certificateId": "CERT_ID",
"fqdnId": "FQDN_ID",
"spaceId": "SPACE_ID",
"minutesFromNow": -5
}'
```
**Hinweis**: `minutesFromNow: -5` bedeutet, dass der Eintrag vor 5 Minuten geplant war (also sofort fällig).
#### 2. Queue-Verarbeitung manuell auslösen:
```bash
curl -X POST "http://localhost:8080/api/renewal-queue/test/trigger" \
-u admin:admin \
-H "Content-Type: application/json"
```
Dies führt `processRenewalQueue()` direkt aus, ohne auf den Scheduler zu warten.
#### 3. Queue-Status abrufen:
```bash
curl -X GET "http://localhost:8080/api/renewal-queue" \
-u admin:admin
```
### Aufräumen:
Test-Queue-Einträge können über SQL gelöscht werden:
```sql
DELETE FROM renewal_queue WHERE id LIKE 'test-%';
```
Oder über die Datenbank:
```bash
sqlite3 spaces.db "DELETE FROM renewal_queue WHERE id LIKE 'test-%';"
```

79
backend/testing/README.md Normal file
View File

@@ -0,0 +1,79 @@
# Testing Tools
Dieser Ordner enthält Test-Skripte für die Renewal-Funktion.
## Struktur
- `scripts/test_renewal.go` - Skript zum Erstellen von Test-Queue-Einträgen
**Hinweis**: Die Test-Handler (`renewal_test_handlers.go`) befinden sich im Hauptverzeichnis (`backend/`), da sie Teil des `package main` sein müssen, um von `main.go` aufgerufen werden zu können.
## Test-Handler
Die Test-Handler werden automatisch in `main.go` registriert und sind nur für Administratoren zugänglich:
- `POST /api/renewal-queue/test/create` - Erstellt einen Test-Queue-Eintrag
- `POST /api/renewal-queue/test/trigger` - Führt die Queue-Verarbeitung manuell aus
## Test-Skript
Das Test-Skript erstellt Test-Queue-Einträge mit verschiedenen Zeitstempeln:
```bash
cd backend/testing/scripts
go run test_renewal.go
```
Das Skript:
- Findet existierende FQDNs mit Zertifikaten
- Erstellt Test-Queue-Einträge mit verschiedenen Zeitstempeln
- Zeigt den aktuellen Queue-Status an
## Manuelle Tests über API
### 1. Test-Queue-Eintrag erstellen:
```bash
curl -X POST "http://localhost:8080/api/renewal-queue/test/create" \
-u admin:admin \
-H "Content-Type: application/json" \
-d '{
"certificateId": "CERT_ID",
"fqdnId": "FQDN_ID",
"spaceId": "SPACE_ID",
"minutesFromNow": -5
}'
```
**Hinweis**: `minutesFromNow: -5` bedeutet, dass der Eintrag vor 5 Minuten geplant war (also sofort fällig).
### 2. Queue-Verarbeitung manuell auslösen:
```bash
curl -X POST "http://localhost:8080/api/renewal-queue/test/trigger" \
-u admin:admin \
-H "Content-Type: application/json"
```
Dies führt `processRenewalQueue()` direkt aus, ohne auf den Scheduler zu warten.
### 3. Queue-Status abrufen:
```bash
curl -X GET "http://localhost:8080/api/renewal-queue" \
-u admin:admin
```
## Aufräumen
Test-Queue-Einträge können über SQL gelöscht werden:
```sql
DELETE FROM renewal_queue WHERE id LIKE 'test-%';
```
Oder über die Datenbank:
```bash
sqlite3 spaces.db "DELETE FROM renewal_queue WHERE id LIKE 'test-%';"
```

View File

@@ -0,0 +1,223 @@
package main
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
_ "github.com/mattn/go-sqlite3"
)
// Test-Skript für Renewal-Funktion
// Erstellt Test-Queue-Einträge mit vergangenen Zeitstempeln, um sofortige Tests zu ermöglichen
func main() {
// Konfiguration
// Datenbankpfad relativ zum backend-Verzeichnis (2 Ebenen höher)
dbPath := "../../spaces.db"
apiURL := "http://localhost:8080"
username := "admin"
password := "admin"
// Öffne Datenbank
db, err := sql.Open("sqlite3", dbPath+"?_foreign_keys=1&_journal_mode=WAL")
if err != nil {
log.Fatalf("Fehler beim Öffnen der Datenbank: %v", err)
}
defer db.Close()
// Teste Verbindung
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
log.Fatalf("Fehler beim Verbinden mit der Datenbank: %v", err)
}
fmt.Println("=== Renewal Test-Skript ===")
fmt.Println()
// 1. Hole existierende FQDNs mit Zertifikaten
fmt.Println("1. Suche nach FQDNs mit Zertifikaten...")
rows, err := db.QueryContext(ctx, `
SELECT DISTINCT
f.id as fqdn_id,
f.space_id,
f.fqdn,
c.id as cert_id
FROM fqdns f
INNER JOIN certificates c ON c.fqdn_id = f.id
WHERE f.acme_provider_id = 'certigo-acmeproxy'
LIMIT 5
`)
if err != nil {
log.Fatalf("Fehler beim Abfragen der FQDNs: %v", err)
}
defer rows.Close()
var fqdns []map[string]string
for rows.Next() {
var fqdnID, spaceID, fqdn, certID string
if err := rows.Scan(&fqdnID, &spaceID, &fqdn, &certID); err != nil {
log.Printf("Fehler beim Scannen: %v", err)
continue
}
fqdns = append(fqdns, map[string]string{
"fqdnId": fqdnID,
"spaceId": spaceID,
"fqdn": fqdn,
"certId": certID,
})
}
if len(fqdns) == 0 {
log.Fatal("Keine FQDNs mit Zertifikaten gefunden. Bitte erstelle zuerst ein Zertifikat.")
}
fmt.Printf(" Gefunden: %d FQDNs\n", len(fqdns))
for _, f := range fqdns {
fmt.Printf(" - %s (FQDN ID: %s, Cert ID: %s)\n", f["fqdn"], f["fqdnId"], f["certId"])
}
fmt.Println()
// 2. Erstelle Test-Queue-Einträge mit vergangenen Zeitstempeln
fmt.Println("2. Erstelle Test-Queue-Einträge...")
now := time.Now().UTC()
// Erstelle Einträge mit verschiedenen Zeitstempeln:
// - Einer sofort fällig (vor 1 Minute)
// - Einer in 5 Minuten
// - Einer in 10 Minuten
testTimes := []time.Time{
now.Add(-1 * time.Minute), // Sofort fällig
now.Add(5 * time.Minute), // In 5 Minuten
now.Add(10 * time.Minute), // In 10 Minuten
}
createdCount := 0
for i, fqdn := range fqdns {
if i >= len(testTimes) {
break
}
scheduledAt := testTimes[i]
scheduledAtStr := scheduledAt.Format("2006-01-02 15:04:05")
queueID := fmt.Sprintf("test-%d-%d", time.Now().Unix(), i)
createdAt := now.Format("2006-01-02 15:04:05")
// Prüfe ob Eintrag bereits existiert
var exists bool
err = db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM renewal_queue WHERE id = ?)", queueID).Scan(&exists)
if err != nil {
log.Printf("Fehler beim Prüfen: %v", err)
continue
}
if exists {
// Lösche existierenden Eintrag
_, err = db.ExecContext(ctx, "DELETE FROM renewal_queue WHERE id = ?", queueID)
if err != nil {
log.Printf("Fehler beim Löschen: %v", err)
continue
}
}
// Erstelle neuen Eintrag
_, err = db.ExecContext(ctx, `
INSERT INTO renewal_queue (id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', ?)
`, queueID, fqdn["certId"], fqdn["fqdnId"], fqdn["spaceId"], scheduledAtStr, createdAt)
if err != nil {
log.Printf("Fehler beim Erstellen des Queue-Eintrags: %v", err)
continue
}
fmt.Printf(" ✓ Queue-Eintrag erstellt: %s für %s (geplant: %s)\n", queueID, fqdn["fqdn"], scheduledAtStr)
createdCount++
}
fmt.Printf("\n %d Queue-Einträge erstellt\n", createdCount)
fmt.Println()
// 3. Zeige Queue-Status
fmt.Println("3. Aktuelle Queue-Status:")
queueRows, err := db.QueryContext(ctx, `
SELECT
rq.id,
rq.scheduled_at,
rq.status,
f.fqdn
FROM renewal_queue rq
LEFT JOIN fqdns f ON rq.fqdn_id = f.id
WHERE rq.id LIKE 'test-%'
ORDER BY rq.scheduled_at ASC
`)
if err != nil {
log.Printf("Fehler beim Abfragen der Queue: %v", err)
} else {
defer queueRows.Close()
for queueRows.Next() {
var id, scheduledAt, status, fqdn string
if err := queueRows.Scan(&id, &scheduledAt, &status, &fqdn); err != nil {
continue
}
fmt.Printf(" - %s: %s (Status: %s, FQDN: %s)\n", id, scheduledAt, status, fqdn)
}
}
fmt.Println()
// 4. Teste API-Endpunkt (manueller Trigger)
fmt.Println("4. Teste manuellen Queue-Trigger über API...")
fmt.Println(" (Dies würde normalerweise automatisch vom Scheduler ausgeführt)")
fmt.Println()
// Erstelle Basic Auth Header
auth := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password)))
// Teste Queue-Status über API
client := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest("GET", apiURL+"/api/renewal-queue", nil)
if err != nil {
log.Printf("Fehler beim Erstellen des Requests: %v", err)
} else {
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", auth))
resp, err := client.Do(req)
if err != nil {
log.Printf("Fehler beim Abrufen der Queue: %v", err)
} else {
defer resp.Body.Close()
if resp.StatusCode == http.StatusOK {
var result struct {
Success bool `json:"success"`
Queue []map[string]interface{} `json:"queue"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err == nil {
fmt.Printf(" ✓ API-Antwort erhalten: %d Einträge in der Queue\n", len(result.Queue))
for _, item := range result.Queue {
if id, ok := item["id"].(string); ok && len(id) > 5 && id[:5] == "test-" {
fmt.Printf(" - Test-Eintrag: %s (Status: %v, Scheduled: %v)\n",
id, item["status"], item["scheduledAt"])
}
}
}
}
}
}
fmt.Println()
fmt.Println("=== Test abgeschlossen ===")
fmt.Println()
fmt.Println("Nächste Schritte:")
fmt.Println("1. Der Scheduler sollte automatisch die fälligen Einträge verarbeiten (alle 5 Minuten)")
fmt.Println("2. Oder warte auf die nächste Scheduler-Ausführung")
fmt.Println("3. Prüfe die Logs für Verarbeitungsdetails")
fmt.Println()
fmt.Println("Zum Aufräumen der Test-Einträge:")
fmt.Println(" DELETE FROM renewal_queue WHERE id LIKE 'test-%';")
}

View File

@@ -0,0 +1,865 @@
# Automatische Zertifikats-Erneuerung Konzept für Let's Encrypt
## 1. Übersicht
### 1.1 Ziel
Implementierung einer automatischen Erneuerungsfunktion für Let's Encrypt (LE) Zertifikate, die ablaufende Zertifikate rechtzeitig erneuert, bevor sie ablaufen.
### 1.2 Anforderungen
- **Proaktive Erneuerung**: Zertifikate werden erneuert, bevor sie ablaufen (z.B. 30 Tage vor Ablauf)
- **Automatische Ausführung**: Läuft im Hintergrund ohne Benutzerinteraktion
- **Fehlerbehandlung**: Robustes Error-Handling und Retry-Mechanismus
- **Logging & Monitoring**: Umfassendes Logging für Nachverfolgbarkeit
- **Konfigurierbarkeit**: Erneuerungs-Schwellenwerte und Intervalle konfigurierbar
- **Berechtigungen**: Respektiert bestehende Permission-Systeme
- **DNS-Validierung**: Automatische DNS-Challenge-Validierung vor Erneuerung
---
## 2. Architektur
### 2.1 Komponenten-Übersicht
```
┌─────────────────────────────────────────────────────────────┐
│ Auto-Renewal System │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ Scheduler │───>│ Certificate │───>│ Renewal │ │
│ │ (Cron) │ │ Scanner │ │ Worker │ │
│ └──────────────┘ └──────────────┘ └─────────────┘ │
│ │ │ │ │
│ │ │ │ │
│ v v v │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ Config │ │ Database │ │ ACME │ │
│ │ Manager │ │ Queries │ │ Client │ │
│ └──────────────┘ └──────────────┘ └─────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌─────────────┐ │
│ │ Logger │ │ Notifier │ │ Retry │ │
│ │ Service │ │ Service │ │ Manager │ │
│ └──────────────┘ └──────────────┘ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 Workflow
```
1. Scheduler startet (z.B. täglich um 02:00 Uhr)
├─> 2. Scanner identifiziert ablaufende Zertifikate
│ (expires_at < now + renewal_threshold)
├─> 3. Für jedes Zertifikat:
│ │
│ ├─> 3.1 Prüfe ob Auto-Renewal aktiviert
│ │
│ ├─> 3.2 Prüfe ob bereits Erneuerung läuft
│ │
│ ├─> 3.3 Prüfe Berechtigungen (Space-Zugriff)
│ │
│ ├─> 3.4 Validiere DNS (CNAME Check)
│ │
│ ├─> 3.5 Erstelle Renewal-Job
│ │
│ └─> 3.6 Queue für Worker
├─> 4. Worker verarbeitet Jobs sequenziell
│ │
│ ├─> 4.1 Hole FQDN-Informationen
│ │
│ ├─> 4.2 Hole ACME-Provider-Konfiguration
│ │
│ ├─> 4.3 Rufe RequestCertificate() auf
│ │
│ ├─> 4.4 Speichere neues Zertifikat
│ │
│ ├─> 4.5 Markiere altes Zertifikat als "replaced"
│ │
│ └─> 4.6 Logge Erfolg/Fehler
└─> 5. Cleanup & Reporting
```
---
## 3. Datenbank-Schema
### 3.1 Erweiterte Certificates-Tabelle
```sql
-- Migration: Erweitere certificates-Tabelle um Auto-Renewal-Felder
ALTER TABLE certificates ADD COLUMN auto_renewal_enabled BOOLEAN DEFAULT 1;
ALTER TABLE certificates ADD COLUMN renewal_attempts INTEGER DEFAULT 0;
ALTER TABLE certificates ADD COLUMN last_renewal_attempt DATETIME;
ALTER TABLE certificates ADD COLUMN next_renewal_check DATETIME;
ALTER TABLE certificates ADD COLUMN renewal_status TEXT; -- 'pending', 'in_progress', 'success', 'failed', 'disabled'
ALTER TABLE certificates ADD COLUMN replaced_by_cert_id TEXT; -- ID des neuen Zertifikats
ALTER TABLE certificates ADD COLUMN replaces_cert_id TEXT; -- ID des ersetzten Zertifikats
```
### 3.2 Neue Tabelle: certificate_renewal_logs
```sql
CREATE TABLE IF NOT EXISTS certificate_renewal_logs (
id TEXT PRIMARY KEY,
certificate_id TEXT NOT NULL,
fqdn_id TEXT NOT NULL,
space_id TEXT NOT NULL,
renewal_status TEXT NOT NULL, -- 'started', 'success', 'failed', 'skipped'
renewal_reason TEXT, -- 'expiring_soon', 'manual', 'retry'
error_message TEXT,
old_expires_at DATETIME,
new_expires_at DATETIME,
new_certificate_id TEXT,
renewal_duration_seconds INTEGER,
trace_id TEXT,
created_at DATETIME NOT NULL,
FOREIGN KEY (certificate_id) REFERENCES certificates(id) ON DELETE CASCADE,
FOREIGN KEY (fqdn_id) REFERENCES fqdns(id) ON DELETE CASCADE,
FOREIGN KEY (space_id) REFERENCES spaces(id) ON DELETE CASCADE
);
CREATE INDEX idx_renewal_logs_certificate_id ON certificate_renewal_logs(certificate_id);
CREATE INDEX idx_renewal_logs_created_at ON certificate_renewal_logs(created_at);
CREATE INDEX idx_renewal_logs_status ON certificate_renewal_logs(renewal_status);
```
### 3.3 Neue Tabelle: renewal_config
```sql
CREATE TABLE IF NOT EXISTS renewal_config (
id TEXT PRIMARY KEY DEFAULT 'global',
enabled BOOLEAN DEFAULT 1,
renewal_threshold_days INTEGER DEFAULT 30, -- Erneuere X Tage vor Ablauf
check_interval_hours INTEGER DEFAULT 24, -- Wie oft prüfen (in Stunden)
max_renewal_attempts INTEGER DEFAULT 3, -- Max. Versuche pro Zertifikat
retry_delay_hours INTEGER DEFAULT 24, -- Wartezeit zwischen Retries
notification_enabled BOOLEAN DEFAULT 0,
notification_email TEXT,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
-- Initiale Konfiguration einfügen
INSERT INTO renewal_config (id, enabled, renewal_threshold_days, check_interval_hours, max_renewal_attempts, retry_delay_hours, created_at, updated_at)
VALUES ('global', 1, 30, 24, 3, 24, datetime('now'), datetime('now'));
```
### 3.4 FQDN-Tabelle Erweiterung
```sql
-- Optional: Pro-FQDN Auto-Renewal-Einstellungen
ALTER TABLE fqdns ADD COLUMN auto_renewal_enabled BOOLEAN DEFAULT 1;
```
---
## 4. Konfiguration
### 4.1 Global Configuration (Environment Variables)
```bash
# Auto-Renewal Einstellungen
AUTO_RENEWAL_ENABLED=true
AUTO_RENEWAL_THRESHOLD_DAYS=30
AUTO_RENEWAL_CHECK_INTERVAL_HOURS=24
AUTO_RENEWAL_SCHEDULE="0 2 * * *" # Cron-Format: Täglich um 02:00 Uhr
AUTO_RENEWAL_MAX_ATTEMPTS=3
AUTO_RENEWAL_RETRY_DELAY_HOURS=24
# Notifications
AUTO_RENEWAL_NOTIFICATIONS_ENABLED=false
AUTO_RENEWAL_NOTIFICATION_EMAIL=admin@example.com
# Concurrency
AUTO_RENEWAL_MAX_CONCURRENT=1 # Anzahl paralleler Erneuerungen
```
### 4.2 Per-FQDN Configuration
- **Default**: Auto-Renewal aktiviert für alle FQDNs
- **Opt-out**: Pro FQDN deaktivierbar über `fqdns.auto_renewal_enabled`
- **Opt-out**: Pro Zertifikat deaktivierbar über `certificates.auto_renewal_enabled`
---
## 5. Scheduler-Implementierung
### 5.1 Optionen
#### Option A: Go Cron Library (Empfohlen)
```go
import "github.com/robfig/cron/v3"
c := cron.New()
c.AddFunc("0 2 * * *", func() {
runCertificateRenewalScan()
})
c.Start()
```
**Vorteile:**
- Einfach zu implementieren
- Gut getestet
- Cron-Format unterstützt
**Nachteile:**
- Läuft nur im Backend-Prozess
- Bei Neustart muss Scheduler neu gestartet werden
#### Option B: Separate Background Service
```go
// Separate Go-Routine die kontinuierlich läuft
go func() {
ticker := time.NewTicker(24 * time.Hour)
for {
select {
case <-ticker.C:
runCertificateRenewalScan()
case <-ctx.Done():
return
}
}
}()
```
**Vorteile:**
- Einfacher zu debuggen
- Keine externe Dependency
**Nachteile:**
- Weniger flexibel als Cron
- Muss selbst implementiert werden
#### Option C: System Cron Job
```bash
# /etc/cron.d/certigo-renewal
0 2 * * * curl -X POST http://localhost:8080/api/internal/renewal/scan
```
**Vorteile:**
- Unabhängig vom Backend-Prozess
- Läuft auch wenn Backend neu gestartet wird
**Nachteile:**
- Externe Dependency (curl)
- Schwieriger zu debuggen
- Benötigt separaten API-Endpunkt
**Empfehlung: Option A (Go Cron Library)**
---
## 6. Certificate Scanner
### 6.1 Query für ablaufende Zertifikate
```sql
SELECT
c.id,
c.fqdn_id,
c.space_id,
c.certificate_id,
c.provider_id,
c.expires_at,
c.auto_renewal_enabled,
c.renewal_status,
c.renewal_attempts,
c.last_renewal_attempt,
f.fqdn,
f.acme_email,
f.acme_key_id,
f.provider_id as fqdn_provider_id
FROM certificates c
INNER JOIN fqdns f ON c.fqdn_id = f.id
WHERE
-- Nur Leaf-Zertifikate (nicht Intermediate)
c.is_intermediate = 0
-- Nur Let's Encrypt Zertifikate (via certigo-acmeproxy)
AND c.provider_id = 'certigo-acmeproxy'
-- Nur gültige/ausgestellte Zertifikate
AND c.status = 'issued'
-- Auto-Renewal muss aktiviert sein
AND (c.auto_renewal_enabled IS NULL OR c.auto_renewal_enabled = 1)
AND (f.auto_renewal_enabled IS NULL OR f.auto_renewal_enabled = 1)
-- Zertifikat läuft bald ab
AND c.expires_at IS NOT NULL
AND datetime(c.expires_at) <= datetime('now', '+' || ? || ' days')
-- Keine laufende Erneuerung
AND (c.renewal_status IS NULL OR c.renewal_status != 'in_progress')
-- Nicht zu viele Versuche
AND (c.renewal_attempts IS NULL OR c.renewal_attempts < ?)
-- Retry-Delay eingehalten
AND (
c.last_renewal_attempt IS NULL
OR datetime(c.last_renewal_attempt) <= datetime('now', '-' || ? || ' hours')
)
ORDER BY c.expires_at ASC;
```
### 6.2 Filter-Logik
**Ausschluss-Kriterien:**
1. ✅ Intermediate-Zertifikate (nur Leaf)
2. ✅ Nur `certigo-acmeproxy` Provider
3. ✅ Status = 'issued'
4. ✅ Auto-Renewal aktiviert (Certificate + FQDN)
5.`expires_at` innerhalb Threshold
6. ✅ Keine laufende Erneuerung (`renewal_status != 'in_progress'`)
7. ✅ Max. Versuche nicht überschritten
8. ✅ Retry-Delay eingehalten
---
## 7. Renewal Worker
### 7.1 Renewal-Prozess
```go
func renewCertificate(certID string, fqdnID string, spaceID string) error {
traceID := generateTraceID()
// 1. Markiere als "in_progress"
updateRenewalStatus(certID, "in_progress", traceID)
// 2. Hole FQDN-Informationen
fqdn, err := getFQDN(fqdnID)
if err != nil {
logRenewalError(certID, traceID, "FQDN nicht gefunden", err)
return err
}
// 3. Prüfe DNS (CNAME)
if !validateDNSCNAME(fqdn.FQDN) {
logRenewalError(certID, traceID, "DNS-CNAME nicht gültig", nil)
return fmt.Errorf("DNS validation failed")
}
// 4. Hole ACME-Provider-Konfiguration
providerConfig, err := getACMEProviderConfig(fqdn.ProviderID)
if err != nil {
logRenewalError(certID, traceID, "Provider-Konfiguration nicht gefunden", err)
return err
}
// 5. Rufe RequestCertificate() auf
result, err := RequestCertificate(
fqdn.FQDN,
fqdn.AcmeEmail,
fqdnID,
fqdn.AcmeKeyID,
traceID,
updateTokenFunc,
cleanupTokenFunc,
statusCallback,
)
if err != nil {
// 6a. Fehler: Erhöhe Versuche, setze Retry-Zeitpunkt
incrementRenewalAttempts(certID)
setNextRenewalCheck(certID, time.Now().Add(retryDelay))
updateRenewalStatus(certID, "failed", traceID)
logRenewalError(certID, traceID, "Erneuerung fehlgeschlagen", err)
return err
}
// 6b. Erfolg: Speichere neues Zertifikat
newCertID, err := saveNewCertificate(result, fqdnID, spaceID)
if err != nil {
logRenewalError(certID, traceID, "Fehler beim Speichern", err)
return err
}
// 7. Verknüpfe alte und neue Zertifikate
linkCertificates(certID, newCertID)
// 8. Markiere als erfolgreich
updateRenewalStatus(certID, "success", traceID)
logRenewalSuccess(certID, newCertID, traceID)
// 9. Optional: Benachrichtigung senden
sendRenewalNotification(fqdn.FQDN, newCertID, traceID)
return nil
}
```
### 7.2 Concurrency Control
**Sequenzielle Verarbeitung:**
- Pro FQDN nur eine Erneuerung gleichzeitig
- Pro Space max. N Erneuerungen parallel (konfigurierbar)
- Global max. M Erneuerungen parallel (konfigurierbar)
**Implementierung:**
```go
// Semaphore für Concurrency Control
var renewalSemaphore = make(chan struct{}, maxConcurrentRenewals)
func renewCertificateWithLock(certID string, fqdnID string, spaceID string) error {
renewalSemaphore <- struct{}{} // Acquire
defer func() { <-renewalSemaphore }() // Release
return renewCertificate(certID, fqdnID, spaceID)
}
```
---
## 8. Fehlerbehandlung & Retry
### 8.1 Fehler-Kategorien
| Fehler-Typ | Retry? | Max. Versuche | Beispiel |
|-----------|--------|--------------|----------|
| DNS-Validierung fehlgeschlagen | ✅ Ja | 3 | CNAME nicht gesetzt |
| ACME-Provider-Fehler | ✅ Ja | 3 | Rate Limit erreicht |
| Netzwerk-Fehler | ✅ Ja | 5 | Timeout, Connection Error |
| Konfigurations-Fehler | ❌ Nein | 0 | Provider nicht konfiguriert |
| Berechtigungs-Fehler | ❌ Nein | 0 | Kein Space-Zugriff |
### 8.2 Retry-Strategie
**Exponential Backoff:**
```
Versuch 1: Sofort
Versuch 2: Nach 24 Stunden
Versuch 3: Nach 48 Stunden
Versuch 4+: Nach 72 Stunden
```
**Oder: Fixed Delay**
```
Alle Retries: Nach X Stunden (konfigurierbar, Default: 24h)
```
### 8.3 Fehler-Logging
```go
type RenewalError struct {
CertificateID string
FQDN string
ErrorType string // 'dns', 'acme', 'network', 'config'
ErrorMessage string
TraceID string
Timestamp time.Time
Attempt int
}
```
---
## 9. Logging & Monitoring
### 9.1 Structured Logging
**Erfolgreiche Erneuerung:**
```json
{
"event": "certificate_renewal_success",
"trace_id": "abc123",
"certificate_id": "cert-uuid",
"fqdn": "example.com",
"old_expires_at": "2025-02-15T10:00:00Z",
"new_expires_at": "2025-05-15T10:00:00Z",
"new_certificate_id": "new-cert-uuid",
"duration_seconds": 45,
"timestamp": "2025-01-15T02:05:00Z"
}
```
**Fehlgeschlagene Erneuerung:**
```json
{
"event": "certificate_renewal_failed",
"trace_id": "abc123",
"certificate_id": "cert-uuid",
"fqdn": "example.com",
"error_type": "dns_validation",
"error_message": "CNAME record not found",
"attempt": 1,
"max_attempts": 3,
"next_retry": "2025-01-16T02:00:00Z",
"timestamp": "2025-01-15T02:05:00Z"
}
```
### 9.2 Audit Logs
**Integration in bestehendes Audit-System:**
```go
auditService.Track(ctx, "RENEW", "certificate", certID, "system", "auto-renewal", map[string]interface{}{
"fqdn": fqdn,
"old_expires_at": oldExpiresAt,
"new_expires_at": newExpiresAt,
"trace_id": traceID,
}, ipAddress, userAgent)
```
### 9.3 Metrics
**Zu tracken:**
- Anzahl Erneuerungen pro Tag/Woche/Monat
- Erfolgsrate (Erfolgreich / Gesamt)
- Durchschnittliche Erneuerungsdauer
- Anzahl fehlgeschlagener Erneuerungen
- Anzahl Retries
- Zertifikate die bald ablaufen (Warnung)
---
## 10. API-Endpunkte
### 10.1 Manuelle Erneuerung
**POST** `/api/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/renew`
Manuell eine Erneuerung auslösen.
**Response:**
```json
{
"success": true,
"message": "Erneuerung gestartet",
"trace_id": "abc123",
"estimated_completion": "2025-01-15T02:05:00Z"
}
```
### 10.2 Erneuerungs-Status abrufen
**GET** `/api/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/renewal-status`
**Response:**
```json
{
"auto_renewal_enabled": true,
"renewal_status": "success",
"renewal_attempts": 1,
"last_renewal_attempt": "2025-01-15T02:00:00Z",
"next_renewal_check": "2025-01-16T02:00:00Z",
"replaced_by_cert_id": "new-cert-uuid"
}
```
### 10.3 Erneuerungs-Logs abrufen
**GET** `/api/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/renewal-logs`
**Query Parameters:**
- `limit` (optional): Anzahl Einträge (Default: 50)
- `offset` (optional): Pagination Offset
**Response:**
```json
{
"logs": [
{
"id": "log-uuid",
"renewal_status": "success",
"renewal_reason": "expiring_soon",
"old_expires_at": "2025-02-15T10:00:00Z",
"new_expires_at": "2025-05-15T10:00:00Z",
"new_certificate_id": "new-cert-uuid",
"renewal_duration_seconds": 45,
"trace_id": "abc123",
"created_at": "2025-01-15T02:00:00Z"
}
],
"total": 10,
"limit": 50,
"offset": 0
}
```
### 10.4 Auto-Renewal konfigurieren
**PUT** `/api/spaces/{spaceId}/fqdns/{fqdnId}/certificates/{certId}/auto-renewal`
**Body:**
```json
{
"enabled": true
}
```
### 10.5 Global Configuration
**GET** `/api/internal/renewal/config`
**Response:**
```json
{
"enabled": true,
"renewal_threshold_days": 30,
"check_interval_hours": 24,
"max_renewal_attempts": 3,
"retry_delay_hours": 24
}
```
**PUT** `/api/internal/renewal/config`
**Body:**
```json
{
"enabled": true,
"renewal_threshold_days": 30,
"check_interval_hours": 24,
"max_renewal_attempts": 3,
"retry_delay_hours": 24
}
```
### 10.6 Manueller Scan (für Testing)
**POST** `/api/internal/renewal/scan`
Löst einen manuellen Scan aus (nur für Admins).
**Response:**
```json
{
"success": true,
"certificates_found": 5,
"certificates_queued": 3,
"certificates_skipped": 2
}
```
---
## 11. Frontend-Integration
### 11.1 UI-Komponenten
#### Auto-Renewal Toggle
- **Ort**: Certificate Detail View
- **Funktion**: Ein/Aus-Schalter für Auto-Renewal pro Zertifikat
#### Renewal Status Badge
- **Ort**: Certificate List & Detail View
- **Anzeige**:
- 🟢 "Auto-Renewal aktiv" (wenn enabled)
- 🟡 "Erneuerung läuft" (wenn in_progress)
- 🔴 "Erneuerung fehlgeschlagen" (wenn failed)
- ⚪ "Auto-Renewal deaktiviert" (wenn disabled)
#### Renewal History
- **Ort**: Certificate Detail View
- **Anzeige**: Tabelle mit Erneuerungs-Logs
- **Spalten**: Datum, Status, Grund, Neue Ablaufzeit, Trace ID
#### Manuelle Erneuerung Button
- **Ort**: Certificate Detail View
- **Funktion**: "Jetzt erneuern" Button (falls Auto-Renewal deaktiviert)
#### Upcoming Renewals Dashboard
- **Ort**: Dashboard/Overview
- **Anzeige**: Liste von Zertifikaten die bald erneuert werden
- **Filter**: Nach Space, FQDN, Ablaufdatum
### 11.2 Notifications (Optional)
**Email-Benachrichtigungen:**
- Erfolgreiche Erneuerung
- Fehlgeschlagene Erneuerung (nach max. Versuchen)
- Warnung: Zertifikat läuft in X Tagen ab (falls Erneuerung fehlschlägt)
**In-App Notifications:**
- Toast-Notification bei erfolgreicher Erneuerung
- Alert bei fehlgeschlagener Erneuerung
---
## 12. Sicherheit & Berechtigungen
### 12.1 Berechtigungen
**Auto-Renewal ausführen:**
- System-User (für automatische Erneuerungen)
- Admin-User (für manuelle Erneuerungen)
- User mit `FULL_ACCESS` auf Space (für manuelle Erneuerungen)
**Auto-Renewal konfigurieren:**
- Admin-User
- User mit `FULL_ACCESS` auf Space
**Erneuerungs-Logs anzeigen:**
- Alle User mit Space-Zugriff (READ-Berechtigung)
### 12.2 Rate Limiting
**Let's Encrypt Rate Limits:**
- 50 Certificates per Registered Domain per week
- 300 New Orders per Account per 3 hours
**Schutz:**
- Tracke Anzahl Erneuerungen pro FQDN
- Verzögere Erneuerung wenn Rate Limit erreicht
- Logge Warnung bei Rate Limit
---
## 13. Testing & Rollout
### 13.1 Test-Plan
**Phase 1: Unit Tests**
- [ ] Certificate Scanner Query
- [ ] Renewal Worker Logic
- [ ] Retry-Mechanismus
- [ ] Error-Handling
**Phase 2: Integration Tests**
- [ ] End-to-End Erneuerung (mit Staging ACME)
- [ ] Fehler-Szenarien (DNS-Fehler, Rate Limit)
- [ ] Concurrency Tests
**Phase 3: Staging Tests**
- [ ] Test mit echten Staging-Zertifikaten
- [ ] Monitoring & Logging prüfen
- [ ] Performance-Tests
**Phase 4: Production Rollout**
- [ ] Feature Flag aktivieren
- [ ] Monitoring aktivieren
- [ ] Schrittweise Aktivierung (zuerst einzelne FQDNs)
### 13.2 Rollback-Plan
**Falls Probleme auftreten:**
1. Auto-Renewal global deaktivieren (Config)
2. Laufende Erneuerungen abbrechen (Status zurücksetzen)
3. Manuelle Erneuerung weiterhin möglich
---
## 14. Monitoring & Alerting
### 14.1 Health Checks
**Endpoint:** `GET /api/health/renewal`
**Response:**
```json
{
"status": "healthy",
"last_scan": "2025-01-15T02:00:00Z",
"next_scan": "2025-01-16T02:00:00Z",
"certificates_pending": 2,
"certificates_in_progress": 1,
"certificates_failed": 0
}
```
### 14.2 Alerts
**Zu überwachen:**
- ❌ Auto-Renewal Service läuft nicht
- ⚠️ Viele fehlgeschlagene Erneuerungen (> 10% in 24h)
- ⚠️ Zertifikate laufen in < 7 Tagen ab (ohne Erneuerung)
- Rate Limit erreicht
- Scheduler läuft nicht (letzter Scan > 48h her)
---
## 15. Zukünftige Erweiterungen
### 15.1 Multi-Provider Support
- Erneuerung für andere Provider (nicht nur Let's Encrypt)
### 15.2 Smart Scheduling
- Erneuerung basierend auf Traffic-Patterns
- Erneuerung außerhalb der Geschäftszeiten
### 15.3 Batch Renewals
- Erneuerung mehrerer Zertifikate gleichzeitig (wenn möglich)
### 15.4 Webhook-Integration
- Webhooks für erfolgreiche/fehlgeschlagene Erneuerungen
- Integration mit externen Monitoring-Tools
### 15.5 Certificate Rotation
- Automatische Rotation von Private Keys
- Unterstützung für Key-Rollover
---
## 16. Abhängigkeiten
### 16.1 Backend (Go)
```go
// Cron Scheduler
github.com/robfig/cron/v3
// (Bereits vorhanden)
// - ACME Client (acme_client.go)
// - Certificate Parser (cert_parser.go)
// - Logger (cert_logger.go)
```
### 16.2 Frontend
Keine zusätzlichen Dependencies nötig.
---
## 17. Risiken & Mitigation
### 17.1 Risiken
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|--------|-------------------|--------|------------|
| Rate Limit erreicht | Mittel | Hoch | Rate Limit Tracking, Verzögerung |
| DNS-Validierung fehlschlägt | Mittel | Hoch | DNS-Check vor Erneuerung, Retry |
| ACME-Provider Downtime | Niedrig | Hoch | Retry-Mechanismus, Fallback |
| Doppelte Erneuerung | Niedrig | Mittel | Status-Check, Locking |
| Datenbank-Lock | Niedrig | Mittel | Transaktionen, Timeouts |
### 17.2 Best Practices
- ✅ Idempotenz: Erneuerung kann mehrfach ausgeführt werden ohne Probleme
- ✅ Atomic Operations: Datenbank-Transaktionen für Konsistenz
- ✅ Graceful Degradation: Bei Fehlern weiterhin manuelle Erneuerung möglich
- ✅ Comprehensive Logging: Alle Schritte loggen für Debugging
- ✅ Rate Limit Awareness: Respektiere Let's Encrypt Limits
---
## 18. Zusammenfassung
### 18.1 Vorteile
- **Automatisierung**: Keine manuelle Intervention nötig
- **Zuverlässigkeit**: Zertifikate laufen nicht mehr ab
- **Zeitersparnis**: Weniger manuelle Arbeit
- **Sicherheit**: Immer gültige Zertifikate
### 18.2 Herausforderungen
- **Komplexität**: Zusätzliche Infrastruktur und Code
- **Fehlerbehandlung**: Robustes Error-Handling erforderlich
- **Rate Limits**: Let's Encrypt Limits beachten
- **Testing**: Umfangreiche Tests erforderlich
### 18.3 Empfohlene Implementierungs-Reihenfolge
1. **Phase 1**: Datenbank-Schema & Grundfunktionalität
2. **Phase 2**: Scanner & Worker
3. **Phase 3**: Scheduler & Automation
4. **Phase 4**: Frontend-Integration
5. **Phase 5**: Monitoring & Alerting
6. **Phase 6**: Notifications (Optional)
---
**Erstellt am**: 2025-01-XX
**Version**: 1.0
**Status**: Konzept - Noch nicht implementiert

754
docs/OAUTH_KONZEPT.md Normal file
View File

@@ -0,0 +1,754 @@
# OAuth 2.0 Integration Konzept für Certigo
## 1. Übersicht
### 1.1 Ziel
Integration von OAuth 2.0 als zusätzliche Authentifizierungsmethode neben dem bestehenden Basic Authentication System. Benutzer sollen sich mit externen OAuth-Providern (z.B. Google, Microsoft, GitHub) anmelden können.
### 1.2 Anforderungen
- **Hybrides System**: OAuth und Basic Auth parallel unterstützen
- **User Linking**: OAuth-Benutzer mit bestehenden lokalen Accounts verknüpfen können
- **Automatische User-Erstellung**: Neue OAuth-Benutzer automatisch anlegen
- **Berechtigungssystem**: OAuth-Benutzer in bestehendes Permission-System integrieren
- **Session Management**: Sichere Session-Verwaltung für OAuth-Logins
- **Multi-Provider**: Unterstützung mehrerer OAuth-Provider gleichzeitig
---
## 2. Architektur
### 2.1 OAuth Flow (Authorization Code Flow)
```
┌─────────┐ ┌──────────┐ ┌─────────────┐ ┌──────────┐
│ Browser │ │ Frontend │ │ Backend │ │ OAuth │
│ │ │ │ │ │ │ Provider │
└────┬────┘ └────┬─────┘ └──────┬──────┘ └────┬─────┘
│ │ │ │
│ 1. Login Button │ │ │
│──────────────────>│ │ │
│ │ │ │
│ │ 2. GET /api/oauth/{provider}/auth │
│ │─────────────────────>│ │
│ │ │ │
│ │ │ 3. Redirect to OAuth │
│ │ │ Authorization URL │
│ │<─────────────────────│ │
│ │ │ │
│ 4. Redirect to │ │ │
│ OAuth Provider │ │ │
│<──────────────────│ │ │
│ │ │ │
│ 5. User Auth │ │ │
│────────────────────────────────────────────────────────────────>│
│ │ │ │
│ 6. Callback with │ │ │
│ Authorization │ │ │
│ Code │ │ │
│<────────────────────────────────────────────────────────────────│
│ │ │ │
│ 7. Callback to │ │ │
│ /api/oauth/ │ │ │
│ {provider}/ │ │ │
│ callback │ │ │
│──────────────────>│ │ │
│ │ 8. POST /api/oauth/{provider}/callback │
│ │ (code=xxx) │ │
│ │─────────────────────>│ │
│ │ │ │
│ │ │ 9. Exchange Code for │
│ │ │ Access Token │
│ │ │──────────────────────>│
│ │ │ │
│ │ │ 10. Get User Info │
│ │ │──────────────────────>│
│ │ │ │
│ │ │ 11. User Info │
│ │ │<──────────────────────│
│ │ │ │
│ │ │ 12. Create/Update │
│ │ │ User in DB │
│ │ │ │
│ │ │ 13. Create Session │
│ │ │ │
│ │ 14. Return Session │ │
│ │ Token │ │
│ │<─────────────────────│ │
│ │ │ │
│ 15. Store Session │ │ │
│ & Redirect │ │ │
│<──────────────────│ │ │
│ │ │ │
```
### 2.2 Komponenten
#### Backend
- **OAuth Handler**: `/api/oauth/{provider}/auth` - Initiierung des OAuth Flows
- **OAuth Callback Handler**: `/api/oauth/{provider}/callback` - Verarbeitung des Authorization Codes
- **OAuth Provider Manager**: Verwaltung mehrerer OAuth-Provider
- **Session Manager**: Verwaltung von OAuth-Sessions (JWT oder Session-Tokens)
- **User Linking Service**: Verknüpfung von OAuth-Accounts mit lokalen Accounts
#### Frontend
- **OAuth Login Component**: Buttons für verschiedene OAuth-Provider
- **OAuth Callback Handler**: Verarbeitung des Redirects nach OAuth-Authentifizierung
- **Session Storage**: Speicherung von Session-Tokens (HttpOnly Cookies bevorzugt)
---
## 3. Datenbank-Schema
### 3.1 Erweiterte Users-Tabelle
```sql
-- Migration: Erweitere users-Tabelle um OAuth-Felder
ALTER TABLE users ADD COLUMN auth_method TEXT DEFAULT 'basic'; -- 'basic' | 'oauth' | 'hybrid'
ALTER TABLE users ADD COLUMN oauth_provider TEXT; -- 'google' | 'microsoft' | 'github' | NULL
ALTER TABLE users ADD COLUMN oauth_provider_id TEXT; -- Externe User-ID vom OAuth-Provider
ALTER TABLE users ADD COLUMN oauth_email TEXT; -- Email vom OAuth-Provider (kann von lokaler Email abweichen)
ALTER TABLE users ADD COLUMN password_hash TEXT; -- NULL für reine OAuth-User
```
### 3.2 Neue Tabelle: oauth_sessions
```sql
CREATE TABLE IF NOT EXISTS oauth_sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
access_token TEXT, -- Verschlüsselt gespeichert
refresh_token TEXT, -- Verschlüsselt gespeichert
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL,
last_used_at DATETIME,
ip_address TEXT,
user_agent TEXT,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX idx_oauth_sessions_user_id ON oauth_sessions(user_id);
CREATE INDEX idx_oauth_sessions_expires_at ON oauth_sessions(expires_at);
```
### 3.3 Neue Tabelle: oauth_providers
```sql
CREATE TABLE IF NOT EXISTS oauth_providers (
id TEXT PRIMARY KEY, -- 'google', 'microsoft', 'github'
name TEXT NOT NULL,
enabled BOOLEAN DEFAULT 1,
client_id TEXT NOT NULL,
client_secret TEXT NOT NULL, -- Verschlüsselt gespeichert
authorization_url TEXT NOT NULL,
token_url TEXT NOT NULL,
user_info_url TEXT NOT NULL,
scopes TEXT, -- JSON Array: ["openid", "email", "profile"]
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
```
### 3.4 Neue Tabelle: user_oauth_links
```sql
-- Für Benutzer, die mehrere OAuth-Provider verknüpfen wollen
CREATE TABLE IF NOT EXISTS user_oauth_links (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
provider_user_id TEXT NOT NULL,
provider_email TEXT,
linked_at DATETIME NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE(provider, provider_user_id)
);
CREATE INDEX idx_user_oauth_links_user_id ON user_oauth_links(user_id);
CREATE INDEX idx_user_oauth_links_provider ON user_oauth_links(provider, provider_user_id);
```
---
## 4. OAuth Provider Konfiguration
### 4.1 Unterstützte Provider
#### Google OAuth 2.0
- **Authorization URL**: `https://accounts.google.com/o/oauth2/v2/auth`
- **Token URL**: `https://oauth2.googleapis.com/token`
- **User Info URL**: `https://www.googleapis.com/oauth2/v2/userinfo`
- **Scopes**: `["openid", "email", "profile"]`
#### Microsoft Azure AD
- **Authorization URL**: `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize`
- **Token URL**: `https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token`
- **User Info URL**: `https://graph.microsoft.com/v1.0/me`
- **Scopes**: `["openid", "email", "profile"]`
#### GitHub
- **Authorization URL**: `https://github.com/login/oauth/authorize`
- **Token URL**: `https://github.com/login/oauth/access_token`
- **User Info URL**: `https://api.github.com/user`
- **Scopes**: `["user:email"]`
### 4.2 Provider-Konfiguration (Environment Variables / Config File)
```yaml
oauth:
providers:
google:
enabled: true
client_id: "${GOOGLE_CLIENT_ID}"
client_secret: "${GOOGLE_CLIENT_SECRET}"
redirect_uri: "http://localhost:5173/api/oauth/google/callback"
scopes: ["openid", "email", "profile"]
microsoft:
enabled: true
tenant: "${MICROSOFT_TENANT_ID}"
client_id: "${MICROSOFT_CLIENT_ID}"
client_secret: "${MICROSOFT_CLIENT_SECRET}"
redirect_uri: "http://localhost:5173/api/oauth/microsoft/callback"
scopes: ["openid", "email", "profile"]
github:
enabled: false
client_id: "${GITHUB_CLIENT_ID}"
client_secret: "${GITHUB_CLIENT_SECRET}"
redirect_uri: "http://localhost:5173/api/oauth/github/callback"
scopes: ["user:email"]
```
---
## 5. API-Endpunkte
### 5.1 OAuth Initiation
**GET** `/api/oauth/{provider}/auth`
Initiert den OAuth Flow für einen bestimmten Provider.
**Query Parameters:**
- `redirect_uri` (optional): Custom Redirect URI nach erfolgreichem Login
**Response:**
- `302 Redirect` zur OAuth Provider Authorization URL
**Beispiel:**
```
GET /api/oauth/google/auth
→ Redirect zu: https://accounts.google.com/o/oauth2/v2/auth?client_id=...&redirect_uri=...&scope=...&response_type=code&state=...
```
### 5.2 OAuth Callback
**GET** `/api/oauth/{provider}/callback`
Verarbeitet den Authorization Code vom OAuth-Provider.
**Query Parameters:**
- `code`: Authorization Code vom Provider
- `state`: CSRF Protection Token (optional, aber empfohlen)
**Response:**
```json
{
"success": true,
"user": {
"id": "uuid",
"username": "user@example.com",
"email": "user@example.com",
"isAdmin": false,
"enabled": true,
"authMethod": "oauth",
"oauthProvider": "google"
},
"sessionToken": "jwt-token-here",
"redirectTo": "/"
}
```
**Fehler-Response:**
```json
{
"success": false,
"error": "Invalid authorization code",
"errorCode": "OAUTH_INVALID_CODE"
}
```
### 5.3 OAuth Logout
**POST** `/api/oauth/logout`
Beendet die OAuth-Session.
**Headers:**
- `Authorization: Bearer {sessionToken}`
**Response:**
```json
{
"success": true,
"message": "Logged out successfully"
}
```
### 5.4 OAuth Provider Status
**GET** `/api/oauth/providers`
Gibt eine Liste aller konfigurierten OAuth-Provider zurück.
**Response:**
```json
{
"providers": [
{
"id": "google",
"name": "Google",
"enabled": true,
"authUrl": "/api/oauth/google/auth"
},
{
"id": "microsoft",
"name": "Microsoft",
"enabled": true,
"authUrl": "/api/oauth/microsoft/auth"
}
]
}
```
### 5.5 Link OAuth Account
**POST** `/api/oauth/link`
Verknüpft einen OAuth-Account mit einem bestehenden lokalen Account (für Hybrid-Auth).
**Headers:**
- `Authorization: Basic {credentials}` (lokaler Account)
**Body:**
```json
{
"provider": "google",
"code": "authorization-code-from-oauth"
}
```
**Response:**
```json
{
"success": true,
"message": "OAuth account linked successfully"
}
```
---
## 6. Session Management
### 6.1 Session-Token (JWT)
**Token-Struktur:**
```json
{
"sub": "user-uuid",
"auth_method": "oauth",
"provider": "google",
"exp": 1234567890,
"iat": 1234567890,
"session_id": "session-uuid"
}
```
**Token-Speicherung:**
- **Backend**: In `oauth_sessions` Tabelle
- **Frontend**: HttpOnly Cookie (bevorzugt) oder localStorage (Fallback)
- **Lifetime**: 24 Stunden (konfigurierbar)
- **Refresh**: Automatisches Refresh bei Ablauf (falls Refresh Token vorhanden)
### 6.2 Session-Validierung
**Middleware**: `oauthSessionMiddleware`
Prüft OAuth-Session-Token in folgenden Headers:
1. `Authorization: Bearer {token}`
2. Cookie: `oauth_session`
**Flow:**
```
Request → oauthSessionMiddleware → Check Token → Validate Session → Continue
↓ Invalid
401 Unauthorized
```
---
## 7. User Management
### 7.1 Automatische User-Erstellung
**Flow:**
1. OAuth-Callback empfängt User-Info vom Provider
2. Prüfe ob User mit `oauth_provider_id` existiert
3. Falls nicht:
- Erstelle neuen User
- Setze `auth_method = 'oauth'`
- Setze `oauth_provider` und `oauth_provider_id`
- Setze `password_hash = NULL`
- Setze `enabled = true` (oder konfigurierbar)
- Setze `isAdmin = false`
- Weise Standard-Berechtigungsgruppe zu (optional)
4. Falls ja:
- Update `last_login_at` (falls Feld existiert)
- Update Session
### 7.2 User Linking (Hybrid Auth)
**Szenario**: Benutzer hat bereits lokalen Account, möchte OAuth hinzufügen
**Flow:**
1. User loggt sich mit Basic Auth ein
2. User klickt "Link Google Account"
3. OAuth Flow wird initiiert
4. Nach erfolgreicher OAuth-Auth:
- Verknüpfe OAuth-Account mit lokalem Account
- Setze `auth_method = 'hybrid'`
- Erstelle Eintrag in `user_oauth_links`
5. User kann sich nun mit beiden Methoden anmelden
### 7.3 User Mapping
**Email-basierte Verknüpfung:**
- Falls OAuth-Email mit lokalem Account übereinstimmt → Auto-Link (optional, konfigurierbar)
- Falls nicht → Neue User-Erstellung oder manuelle Verknüpfung erforderlich
---
## 8. Sicherheit
### 8.1 CSRF Protection
**State Parameter:**
- Generiere zufälligen `state` Token bei OAuth-Initiation
- Speichere in Session/Cookie
- Validiere bei Callback
**Implementierung:**
```go
state := generateRandomToken(32)
storeStateInSession(state)
redirectURL := fmt.Sprintf("%s?state=%s&...", oauthURL, state)
```
### 8.2 Token-Verschlüsselung
**Access/Refresh Tokens:**
- Verschlüsselt in Datenbank speichern (AES-256)
- Nie im Klartext loggen
- Automatische Löschung bei Ablauf
### 8.3 Rate Limiting
**OAuth-Endpunkte:**
- `/api/oauth/{provider}/auth`: 10 Requests/Minute pro IP
- `/api/oauth/{provider}/callback`: 5 Requests/Minute pro IP
### 8.4 Secure Cookies
**Session Cookies:**
- `HttpOnly`: true
- `Secure`: true (HTTPS only)
- `SameSite`: Lax oder Strict
- `Path`: `/api`
---
## 9. Frontend-Integration
### 9.1 Login-Seite Erweiterung
**Aktuelle Login-Seite** (`frontend/src/pages/Login.jsx`) erweitern:
```jsx
// OAuth Login Buttons hinzufügen
<div className="oauth-providers">
<button onClick={() => initiateOAuth('google')}>
<img src="/icons/google.svg" /> Mit Google anmelden
</button>
<button onClick={() => initiateOAuth('microsoft')}>
<img src="/icons/microsoft.svg" /> Mit Microsoft anmelden
</button>
</div>
// Oder: Separater OAuth-Login-Bereich
<div className="divider">oder</div>
```
### 9.2 OAuth Callback Handler
**Neue Route**: `frontend/src/pages/OAuthCallback.jsx`
```jsx
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const state = params.get('state')
const provider = extractProviderFromURL() // z.B. '/oauth/google/callback'
if (code) {
handleOAuthCallback(provider, code, state)
}
}, [])
```
### 9.3 AuthContext Erweiterung
**Erweitere** `frontend/src/contexts/AuthContext.jsx`:
```jsx
const loginWithOAuth = async (provider) => {
// Redirect zu Backend OAuth Initiation
window.location.href = `/api/oauth/${provider}/auth`
}
const handleOAuthCallback = async (provider, code, state) => {
const response = await fetch(`/api/oauth/${provider}/callback?code=${code}&state=${state}`)
const data = await response.json()
if (data.success) {
// Store session token
localStorage.setItem('oauth_session', data.sessionToken)
setUser(data.user)
setIsAuthenticated(true)
navigate('/')
}
}
```
---
## 10. Migration & Backward Compatibility
### 10.1 Bestehende User
**Migration:**
- Alle bestehenden User haben `auth_method = 'basic'`
- `oauth_provider` und `oauth_provider_id` sind `NULL`
- `password_hash` bleibt bestehen
### 10.2 API-Kompatibilität
**Bestehende Endpunkte:**
- Funktionieren weiterhin mit Basic Auth
- Neue Middleware prüft zuerst OAuth-Session, dann Basic Auth
**Middleware-Order:**
```
Request → oauthSessionMiddleware → basicAuthMiddleware → Handler
↓ Invalid ↓ Invalid
Try Basic Auth 401 Unauthorized
```
### 10.3 User-Erstellung
**Admin-Erstellung:**
- Admins können weiterhin lokale User erstellen
- OAuth-User können manuell zu Admins gemacht werden
---
## 11. Konfiguration & Deployment
### 11.1 Environment Variables
```bash
# OAuth Provider Credentials
GOOGLE_CLIENT_ID=xxx
GOOGLE_CLIENT_SECRET=xxx
MICROSOFT_CLIENT_ID=xxx
MICROSOFT_CLIENT_SECRET=xxx
MICROSOFT_TENANT_ID=xxx
GITHUB_CLIENT_ID=xxx
GITHUB_CLIENT_SECRET=xxx
# OAuth Settings
OAUTH_SESSION_SECRET=xxx # Für Session-Token Signing
OAUTH_SESSION_LIFETIME=24h
OAUTH_REDIRECT_BASE_URL=http://localhost:5173
OAUTH_AUTO_CREATE_USERS=true
OAUTH_AUTO_LINK_BY_EMAIL=false
```
### 11.2 Provider-Registrierung
**Google:**
1. Google Cloud Console → APIs & Services → Credentials
2. OAuth 2.0 Client ID erstellen
3. Authorized redirect URIs: `http://localhost:5173/api/oauth/google/callback`
**Microsoft:**
1. Azure Portal → App Registrations
2. Neue App registrieren
3. Redirect URIs: `http://localhost:5173/api/oauth/microsoft/callback`
**GitHub:**
1. GitHub Settings → Developer settings → OAuth Apps
2. Neue OAuth App erstellen
3. Authorization callback URL: `http://localhost:5173/api/oauth/github/callback`
---
## 12. Testing & Rollout
### 12.1 Test-Plan
**Phase 1: Backend-Integration**
- [ ] OAuth Provider Manager implementieren
- [ ] OAuth Handler Endpunkte
- [ ] Session Management
- [ ] Datenbank-Migrationen
**Phase 2: Frontend-Integration**
- [ ] OAuth Login Buttons
- [ ] Callback Handler
- [ ] Session Storage
- [ ] AuthContext Erweiterung
**Phase 3: Testing**
- [ ] Unit Tests für OAuth Flow
- [ ] Integration Tests
- [ ] Security Tests (CSRF, Token Validation)
- [ ] User Acceptance Testing
**Phase 4: Rollout**
- [ ] Staging Deployment
- [ ] Production Deployment
- [ ] Monitoring & Logging
### 12.2 Rollback-Plan
**Falls Probleme auftreten:**
1. OAuth-Endpunkte deaktivieren (Feature Flag)
2. Bestehende Basic Auth bleibt funktionsfähig
3. Datenbank-Migrationen sind rückwärtskompatibel
---
## 13. Monitoring & Logging
### 13.1 Logging
**OAuth-Events:**
- OAuth Flow Initiation
- OAuth Callback (Erfolg/Fehler)
- User-Erstellung via OAuth
- Session-Erstellung/Löschung
- Token-Refresh
**Structured Logging:**
```go
logOAuthEvent("oauth_login_initiated", map[string]interface{}{
"provider": "google",
"user_id": userID,
"ip_address": ip,
"trace_id": traceID,
})
```
### 13.2 Metrics
**Zu tracken:**
- Anzahl OAuth-Logins pro Provider
- Erfolgsrate OAuth Flows
- Session-Dauer
- Fehlerrate (Invalid Code, Token Expiry, etc.)
---
## 14. Zukünftige Erweiterungen
### 14.1 Multi-Factor Authentication
- OAuth als Second Factor für Basic Auth User
### 14.2 SSO (Single Sign-On)
- SAML 2.0 Support
- OpenID Connect
### 14.3 Social Login
- Weitere Provider: Facebook, Twitter, LinkedIn
### 14.4 Account Management
- UI für Verknüpfung mehrerer OAuth-Accounts
- Entkopplung von OAuth-Accounts
---
## 15. Abhängigkeiten
### 15.1 Backend (Go)
```go
// OAuth Libraries
github.com/golang/oauth2
github.com/coreos/go-oidc/v3/oidc // Für OpenID Connect
// JWT
github.com/golang-jwt/jwt/v5
// Encryption
golang.org/x/crypto
```
### 15.2 Frontend
Keine zusätzlichen Dependencies nötig (native Fetch API für OAuth Flow)
---
## 16. Risiken & Mitigation
### 16.1 Risiken
| Risiko | Wahrscheinlichkeit | Impact | Mitigation |
|--------|-------------------|--------|------------|
| OAuth Provider Downtime | Mittel | Hoch | Fallback auf Basic Auth |
| Token-Leak | Niedrig | Sehr Hoch | HttpOnly Cookies, Token Rotation |
| CSRF-Angriffe | Mittel | Hoch | State Parameter Validation |
| Account Takeover | Niedrig | Sehr Hoch | Email-Verification, Rate Limiting |
### 16.2 Security Best Practices
- ✅ HTTPS nur (in Production)
- ✅ State Parameter für CSRF Protection
- ✅ Token-Verschlüsselung in DB
- ✅ Session Timeout
- ✅ Rate Limiting
- ✅ Audit Logging
---
## 17. Zusammenfassung
### 17.1 Vorteile
- **Benutzerfreundlichkeit**: Ein-Klick-Login mit bekannten Accounts
- **Sicherheit**: Keine Passwort-Verwaltung für OAuth-User
- **Skalierbarkeit**: Externe Provider übernehmen Authentifizierung
- **Flexibilität**: Hybrid-System unterstützt beide Methoden
### 17.2 Herausforderungen
- **Komplexität**: Zusätzliche Infrastruktur und Code
- **Abhängigkeit**: Abhängig von externen Providern
- **Migration**: Bestehende User müssen unterstützt werden
- **Testing**: Mehr Test-Szenarien durch Multi-Provider
---
**Erstellt am**: 2025-01-XX
**Version**: 1.0
**Status**: Konzept - Noch nicht implementiert

View File

@@ -1,7 +1,8 @@
import { useState } from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { PermissionsProvider } from './contexts/PermissionsContext'
import { ToastProvider, useToast } from './contexts/ToastContext'
import { usePermissions } from './hooks/usePermissions'
import Sidebar from './components/Sidebar'
import Footer from './components/Footer'
@@ -15,6 +16,7 @@ import Permissions from './pages/Permissions'
import Providers from './pages/Providers'
import Login from './pages/Login'
import AuditLogs from './pages/AuditLogs'
import RenewalQueue from './pages/RenewalQueue'
// Protected Route Component
const ProtectedRoute = ({ children }) => {
@@ -75,10 +77,12 @@ const AdminRoute = ({ children }) => {
}
// Group Required Route Component - User muss einer Berechtigungsgruppe zugewiesen sein
const GroupRequiredRoute = ({ children }) => {
const { isAuthenticated, loading } = useAuth()
const GroupRequiredRoute = ({ children, allowHomePage = false }) => {
const { isAuthenticated, loading, user } = useAuth()
const { isAdmin, hasFullAccess, accessibleSpaces, loading: permissionsLoading } = usePermissions()
const location = useLocation()
// Warte, bis sowohl Auth als auch Permissions geladen sind
if (loading || permissionsLoading) {
return (
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 flex items-center justify-center">
@@ -97,19 +101,50 @@ const GroupRequiredRoute = ({ children }) => {
return <Navigate to="/login" replace />
}
// Admin oder User mit Gruppen haben Zugriff
const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
// Verwende isAdmin aus Permissions, oder Fallback auf user.isAdmin aus AuthContext
// WICHTIG: Nur prüfen, wenn Permissions vollständig geladen sind (permissionsLoading === false)
const effectiveIsAdmin = isAdmin || (user?.isAdmin === true)
if (!hasGroups) {
// Admin oder User mit Gruppen haben Zugriff
const hasGroups = effectiveIsAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
// Debug-Logging (kann später entfernt werden)
if (process.env.NODE_ENV === 'development') {
console.log('GroupRequiredRoute Debug:', {
isAdmin,
userIsAdmin: user?.isAdmin,
effectiveIsAdmin,
hasFullAccess,
accessibleSpacesLength: accessibleSpaces?.length || 0,
hasGroups,
pathname: location.pathname,
allowHomePage,
permissionsLoading,
authLoading: loading
})
}
// Zeige Warnung, wenn keine Berechtigung vorhanden ist, aber leite nicht weiter
// Dies verhindert, dass Benutzer beim Refresh zur Home-Seite weitergeleitet werden
if (!hasGroups && !permissionsLoading) {
// Erlaube Zugriff auf alle Seiten, aber zeige Warnung
return (
<Navigate
to="/"
replace
state={{
message: "Sie sind keiner Berechtigungsgruppe zugewiesen. Bitte kontaktieren Sie einen Administrator.",
type: "warning"
}}
/>
<div>
<div className="mb-6 p-4 rounded-lg border bg-yellow-500/20 border-yellow-500/50">
<div className="flex items-start gap-3">
<div className="text-2xl flex-shrink-0 text-yellow-400"></div>
<div className="flex-1">
<p className="font-semibold mb-1 text-yellow-300">
Keine Berechtigungsgruppe
</p>
<p className="text-sm text-yellow-200">
Sie sind keiner Berechtigungsgruppe zugewiesen. Bitte kontaktieren Sie einen Administrator.
</p>
</div>
</div>
</div>
{children}
</div>
)
}
@@ -139,6 +174,153 @@ const PublicRoute = ({ children }) => {
const AppContent = () => {
const [sidebarOpen, setSidebarOpen] = useState(true)
const { showToast } = useToast()
// Globale Validierungsmeldungen für alle required-Felder
useEffect(() => {
const setupCustomValidation = () => {
// Funktion zum Setzen benutzerdefinierter Validierungsmeldungen
const setCustomValidityMessages = () => {
const requiredFields = document.querySelectorAll('input[required], select[required], textarea[required]')
requiredFields.forEach((field) => {
// Überspringe Felder, die bereits behandelt wurden
if (field.dataset.customValidation === 'true') {
return
}
// Markiere Feld als behandelt
field.dataset.customValidation = 'true'
// Setze benutzerdefinierte Meldung basierend auf Feldtyp und Label
const label = document.querySelector(`label[for="${field.id}"]`) ||
field.closest('label') ||
field.previousElementSibling
let customMessage = 'Dieses Feld ist ein Pflichtfeld und muss ausgefüllt werden.'
// Spezifische Meldungen basierend auf Feldtyp oder Label
if (field.type === 'email') {
customMessage = 'Bitte geben Sie eine gültige E-Mail-Adresse ein.'
} else if (field.type === 'password') {
customMessage = 'Bitte geben Sie ein Passwort ein.'
} else if (field.tagName === 'SELECT') {
customMessage = 'Bitte wählen Sie eine Option aus.'
} else if (label) {
const labelText = label.textContent || ''
if (labelText.toLowerCase().includes('name') || labelText.toLowerCase().includes('benutzername')) {
customMessage = 'Bitte geben Sie einen Namen ein.'
} else if (labelText.toLowerCase().includes('fqdn') || labelText.toLowerCase().includes('domain')) {
customMessage = 'Bitte geben Sie einen FQDN ein.'
} else if (labelText.toLowerCase().includes('provider')) {
customMessage = 'Bitte wählen Sie einen Provider aus.'
} else if (labelText.toLowerCase().includes('beschreibung')) {
customMessage = 'Bitte geben Sie eine Beschreibung ein.'
}
}
// Speichere die benutzerdefinierte Meldung am Feld
field.dataset.customMessage = customMessage
// Setze benutzerdefinierte Validierungsmeldung
const handleInvalid = (e) => {
e.preventDefault()
field.setCustomValidity('') // Entferne Standard-Meldung
// Fallback: Zeige Toast auch bei einzelnen Feld-Validierungen
// (wird normalerweise über handleFormSubmit gesteuert)
// Nur wenn das Feld direkt validiert wird (z.B. bei onBlur)
if (!field.form || !field.form.dataset.submitAttempted) {
showToast(customMessage, 'error', 5000)
}
}
// Entferne benutzerdefinierte Meldung bei Eingabe/Änderung
const handleInput = () => {
field.setCustomValidity('')
}
field.addEventListener('invalid', handleInvalid)
field.addEventListener('input', handleInput)
field.addEventListener('change', handleInput)
})
}
// Initiale Einrichtung
setCustomValidityMessages()
// Beobachte DOM-Änderungen für dynamisch hinzugefügte Formulare
const observer = new MutationObserver(() => {
// Kleine Verzögerung, damit React die DOM-Änderungen abgeschlossen hat
setTimeout(() => {
setCustomValidityMessages()
}, 100)
})
observer.observe(document.body, {
childList: true,
subtree: true
})
// Einrichtung bei Form-Submit
const handleFormSubmit = (e) => {
const form = e.target
if (form.tagName === 'FORM') {
// Markiere Formular als Submit-Versuch
form.dataset.submitAttempted = 'true'
// Prüfe zuerst, ob das Formular gültig ist
if (!form.checkValidity()) {
e.preventDefault()
e.stopPropagation()
// Sammle alle ungültigen Felder
const invalidFields = Array.from(form.querySelectorAll('input[required], select[required], textarea[required]')).filter((field) => {
// Prüfe explizit, ob das Feld ungültig ist
if (field.type === 'checkbox' || field.type === 'radio') {
return !field.checked && field.required
}
if (field.tagName === 'SELECT') {
return !field.value && field.required
}
return !field.value.trim() && field.required
})
if (invalidFields.length > 0) {
// Fokussiere das erste ungültige Feld
const firstInvalid = invalidFields[0]
firstInvalid.focus()
// Sammle alle Validierungsmeldungen
const messages = invalidFields.map((field) => {
const customMessage = field.dataset.customMessage ||
'Dieses Feld ist ein Pflichtfeld und muss ausgefüllt werden.'
return customMessage
})
// Zeige alle Toasts - die Queue im ToastContext sorgt für sequenzielle Anzeige
messages.forEach((message) => {
showToast(message, 'error', 5000)
})
}
} else {
// Formular ist gültig, entferne Markierung
delete form.dataset.submitAttempted
}
}
}
document.addEventListener('submit', handleFormSubmit, true)
return () => {
observer.disconnect()
document.removeEventListener('submit', handleFormSubmit, true)
}
}
const cleanup = setupCustomValidation()
return cleanup
}, [showToast])
return (
<div className="flex flex-col h-screen bg-gradient-to-r from-slate-700 to-slate-900">
@@ -148,7 +330,8 @@ const AppContent = () => {
<div className="flex-1">
<Routes>
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
<Route path="/" element={<GroupRequiredRoute allowHomePage={true}><Home /></GroupRequiredRoute>} />
<Route path="/queue" element={<GroupRequiredRoute><RenewalQueue /></GroupRequiredRoute>} />
<Route path="/spaces" element={<GroupRequiredRoute><Spaces /></GroupRequiredRoute>} />
<Route path="/spaces/:id" element={<GroupRequiredRoute><SpaceDetail /></GroupRequiredRoute>} />
<Route path="/impressum" element={<GroupRequiredRoute><Impressum /></GroupRequiredRoute>} />
@@ -171,7 +354,9 @@ function App() {
<Router>
<AuthProvider>
<PermissionsProvider>
<ToastProvider>
<AppContent />
</ToastProvider>
</PermissionsProvider>
</AuthProvider>
</Router>

View File

@@ -13,14 +13,24 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
// Prüfe ob User Berechtigungsgruppen hat
const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
// Menüpunkte - Home ist immer sichtbar, andere nur mit Gruppen
// Menüpunkte - andere nur mit Gruppen
const menuItems = [
{ path: '/', label: 'Home', icon: '🏠', alwaysVisible: true },
{ path: '/spaces', label: 'Spaces', icon: '📁', requiresGroups: true },
{ path: '/audit-logs', label: 'Audit Log', icon: '📋', requiresGroups: true },
{ path: '/impressum', label: 'Impressum', icon: '', requiresGroups: true },
].filter(item => item.alwaysVisible || !item.requiresGroups || hasGroups)
// Home mit Unterpunkten
const homeMenu = {
label: 'Home',
icon: '🏠',
path: '/',
alwaysVisible: true,
subItems: [
{ path: '/queue', label: 'Queue', icon: '⏰', requiresGroups: true },
].filter(item => !item.requiresGroups || hasGroups)
}
// Settings mit Unterpunkten
const settingsMenu = {
label: 'Settings',
@@ -53,7 +63,7 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
return expandedMenus[menuPath] || false
}
// Automatisch Settings-Menü expandieren, wenn auf einer Settings-Seite
// Automatisch Menüs expandieren, wenn auf einer entsprechenden Seite
useEffect(() => {
if (location.pathname.startsWith('/settings')) {
setExpandedMenus(prev => ({
@@ -61,6 +71,12 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
'/settings': true
}))
}
if (location.pathname === '/queue' || location.pathname === '/') {
setExpandedMenus(prev => ({
...prev,
'/': true
}))
}
}, [location.pathname])
return (
@@ -112,6 +128,82 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
</div>
<nav className="px-2 py-4 overflow-hidden flex flex-col h-[calc(100%-4rem)]">
<ul className="space-y-2 flex-1">
{/* Home Menu mit Unterpunkten */}
<li>
<div className={`flex items-center rounded-lg transition-all duration-200 ${
(isActive(homeMenu.path) || location.pathname === '/queue')
? 'bg-slate-700 shadow-md'
: ''
}`}>
<Link
to={homeMenu.path}
className={`flex-1 flex items-center px-3 py-3 rounded-lg transition-all duration-200 ${
(isActive(homeMenu.path) || location.pathname === '/queue')
? 'text-white font-semibold'
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
}`}
title={!isOpen ? homeMenu.label : ''}
>
<span className={`text-xl flex-shrink-0 ${isOpen ? 'mr-3' : 'mx-auto'}`}>
{homeMenu.icon}
</span>
{isOpen && (
<span className="whitespace-nowrap overflow-hidden">
{homeMenu.label}
</span>
)}
</Link>
{isOpen && homeMenu.subItems && homeMenu.subItems.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation()
toggleMenu(homeMenu.path)
}}
className={`p-2 rounded-lg transition-colors ${
(isActive(homeMenu.path) || location.pathname === '/queue')
? 'text-white hover:bg-slate-600'
: 'text-slate-400 hover:text-slate-300 hover:bg-slate-700/50'
}`}
title="Menü erweitern"
>
<svg
className={`w-4 h-4 transition-transform duration-200 ${
isMenuExpanded(homeMenu.path) ? 'rotate-90' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
)}
</div>
{isOpen && isMenuExpanded(homeMenu.path) && homeMenu.subItems && homeMenu.subItems.length > 0 && (
<ul className="ml-4 mt-1 space-y-1">
{homeMenu.subItems.map((subItem) => (
<li key={subItem.path}>
<Link
to={subItem.path}
className={`flex items-center px-3 py-2 rounded-lg transition-all duration-200 ${
isActive(subItem.path)
? 'bg-slate-600 text-white font-semibold'
: 'text-slate-400 hover:bg-slate-700/50 hover:text-slate-200'
}`}
>
<span className="text-lg flex-shrink-0 mr-2">
{subItem.icon}
</span>
<span className="whitespace-nowrap overflow-hidden">
{subItem.label}
</span>
</Link>
</li>
))}
</ul>
)}
</li>
{menuItems.map((item) => (
<li key={item.path}>
<Link

View File

@@ -0,0 +1,75 @@
import { useEffect } from 'react'
const Toast = ({ message, type = 'error', onClose, duration = 4000 }) => {
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(() => {
onClose()
}, duration)
return () => clearTimeout(timer)
}
}, [duration, onClose])
const typeStyles = {
error: 'bg-red-500/90 border-red-400/50 text-white',
warning: 'bg-amber-500/90 border-amber-400/50 text-white',
info: 'bg-blue-500/90 border-blue-400/50 text-white',
success: 'bg-emerald-500/90 border-emerald-400/50 text-white'
}
const iconStyles = {
error: 'text-red-200',
warning: 'text-amber-200',
info: 'text-blue-200',
success: 'text-emerald-200'
}
const icons = {
error: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
warning: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
),
info: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
success: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
}
return (
<div
className={`flex items-start gap-3 px-4 py-3 rounded-lg border-2 shadow-2xl backdrop-blur-sm min-w-[300px] max-w-[500px] animate-slide-in-right transition-all duration-300 ${typeStyles[type]}`}
role="alert"
>
<div className={`flex-shrink-0 ${iconStyles[type]}`}>
{icons[type]}
</div>
<div className="flex-1 text-sm font-medium">
{message}
</div>
<button
onClick={onClose}
className="flex-shrink-0 text-white/80 hover:text-white transition-colors"
aria-label="Schließen"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
)
}
export default Toast

View File

@@ -19,6 +19,7 @@ export const PermissionsProvider = ({ children }) => {
canUploadCSR: {},
canSignCSR: {},
})
// Initial loading state: true wenn authentifiziert (weil Permissions noch nicht geladen sind)
const [loading, setLoading] = useState(true)
const intervalRef = useRef(null)
const isMountedRef = useRef(true)
@@ -37,6 +38,14 @@ export const PermissionsProvider = ({ children }) => {
if (response.ok && isMountedRef.current) {
try {
const data = await response.json()
// Debug-Logging (kann später entfernt werden)
if (process.env.NODE_ENV === 'development') {
console.log('Permissions loaded:', {
isAdmin: data.isAdmin,
hasFullAccess: data.hasFullAccess,
accessibleSpacesLength: Array.isArray(data.accessibleSpaces) ? data.accessibleSpaces.length : 0
})
}
// Nur Permissions aktualisieren, wenn Daten erfolgreich geparst wurden
setPermissions({
isAdmin: data.isAdmin || false,
@@ -49,18 +58,36 @@ export const PermissionsProvider = ({ children }) => {
canUploadCSR: data.permissions?.canUploadCSR || {},
canSignCSR: data.permissions?.canSignCSR || {},
})
// Setze loading nur auf false, wenn Permissions erfolgreich geladen wurden
if (isInitial && isMountedRef.current) {
setLoading(false)
}
} catch (parseErr) {
console.error('Error parsing permissions response:', parseErr)
// Bei Parse-Fehler Permissions nicht zurücksetzen, nur loggen
// Setze loading auf false, damit die App nicht hängt
if (isInitial && isMountedRef.current) {
setLoading(false)
}
}
} else if (response.status === 401 && isMountedRef.current) {
// Bei 401 Unauthorized werden Permissions zurückgesetzt (wird von AuthContext gehandelt)
console.log('Unauthorized - permissions will be cleared by auth context')
if (isInitial && isMountedRef.current) {
setLoading(false)
}
} else {
// Bei anderen Fehlern (z.B. 500) loggen, aber loading auf false setzen
const errorText = await response.text().catch(() => 'Unable to read error response')
console.error('Error fetching permissions: HTTP', response.status, errorText)
if (isInitial && isMountedRef.current) {
setLoading(false)
}
}
} catch (err) {
console.error('Error fetching permissions:', err)
// Bei Netzwerkfehlern etc. Permissions nicht zurücksetzen
} finally {
// Setze loading auf false, damit die App nicht hängt
if (isInitial && isMountedRef.current) {
setLoading(false)
}
@@ -70,6 +97,8 @@ export const PermissionsProvider = ({ children }) => {
// Initiales Laden der Permissions
useEffect(() => {
if (isAuthenticated) {
// Setze loading auf true, bevor Permissions geladen werden
setLoading(true)
fetchPermissions(true)
} else {
setPermissions({

View File

@@ -0,0 +1,98 @@
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
import Toast from '../components/Toast'
const ToastContext = createContext()
export const useToast = () => {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
export const ToastProvider = ({ children }) => {
const [toasts, setToasts] = useState([])
const queueRef = useRef([])
const isProcessingRef = useRef(false)
const timeoutRef = useRef(null)
// Sequenzielle Verarbeitung der Toast-Queue
const processQueue = useCallback(() => {
// Wenn bereits verarbeitet wird oder Queue leer, nichts tun
if (isProcessingRef.current || queueRef.current.length === 0) {
return
}
isProcessingRef.current = true
const nextToast = queueRef.current.shift()
if (nextToast) {
// Füge Toast zum State hinzu
setToasts((prev) => [...prev, nextToast])
// Warte 200ms bevor der nächste Toast verarbeitet wird
timeoutRef.current = setTimeout(() => {
isProcessingRef.current = false
// Verarbeite nächsten Toast in der Queue
processQueue()
}, 200)
} else {
isProcessingRef.current = false
}
}, [])
// Starte Verarbeitung wenn Queue nicht leer ist
useEffect(() => {
if (queueRef.current.length > 0 && !isProcessingRef.current) {
processQueue()
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [processQueue])
const showToast = useCallback((message, type = 'error', duration = 4000) => {
const id = Date.now() + Math.random()
const newToast = { id, message, type, duration }
// Füge zur Queue hinzu
queueRef.current.push(newToast)
// Starte Verarbeitung wenn nicht bereits aktiv
if (!isProcessingRef.current) {
processQueue()
}
return id
}, [processQueue])
const removeToast = useCallback((id) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id))
}, [])
return (
<ToastContext.Provider value={{ showToast, removeToast }}>
{children}
<div className="fixed top-4 right-4 z-50 pointer-events-none flex flex-col gap-2">
{toasts.map((toast) => (
<div
key={toast.id}
className="pointer-events-auto"
>
<Toast
message={toast.message}
type={toast.type}
duration={toast.duration}
onClose={() => removeToast(toast.id)}
/>
</div>
))}
</div>
</ToastContext.Provider>
)
}

View File

@@ -55,3 +55,26 @@ body {
}
}
/* Toast Animation */
@keyframes slide-in-right {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.animate-slide-in-right {
animation: slide-in-right 0.3s ease-out;
}
/* Toast Container - Stapelung mehrerer Toasts */
.toast-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
}

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
<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"
>
<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={(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"
>
<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
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={(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">
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

@@ -0,0 +1,379 @@
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 (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()
// 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':
return 'bg-red-500/20 text-red-400 border-red-500/50'
default:
return 'bg-slate-500/20 text-slate-400 border-slate-500/50'
}
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
try {
// Prüfe ob es bereits im DB-Format ist (YYYY-MM-DD HH:MM:SS)
const match = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2}):(\d{2})/)
if (match) {
// Parse als UTC und formatiere
const [, year, month, day, hour, minute] = match
const date = new Date(Date.UTC(
parseInt(year, 10),
parseInt(month, 10) - 1,
parseInt(day, 10),
parseInt(hour, 10),
parseInt(minute, 10)
))
return date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZone: 'UTC'
})
}
// Fallback: Versuche als ISO-String zu parsen
const date = new Date(dateStr)
if (isNaN(date.getTime())) {
return dateStr // Falls Parsing fehlschlägt, gib Original zurück
}
return date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hour12: false
})
} catch (err) {
console.error('Fehler beim Formatieren des Datums:', err, dateStr)
return dateStr
}
}
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">
<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">
<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>
)
}
export default RenewalQueue

File diff suppressed because it is too large Load Diff

View File

@@ -208,52 +208,123 @@ const Spaces = () => {
{/* Create Space Form */}
{showForm && (
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
<h2 className="text-2xl font-semibold text-white mb-4">
<div className="bg-gradient-to-br from-slate-800/90 to-slate-900/90 backdrop-blur-sm rounded-xl shadow-2xl border border-emerald-500/30 p-8 mb-6 relative overflow-hidden">
{/* Decorative background elements */}
<div className="absolute top-0 right-0 w-64 h-64 bg-emerald-500/10 rounded-full blur-3xl -mr-32 -mt-32"></div>
<div className="absolute bottom-0 left-0 w-48 h-48 bg-teal-500/10 rounded-full blur-3xl -ml-24 -mb-24"></div>
<div className="relative z-10">
{/* Header with Icon */}
<div className="flex items-center mb-6">
<div className="flex-shrink-0 w-12 h-12 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-xl flex items-center justify-center mr-4 shadow-lg shadow-emerald-500/25">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<div>
<h2 className="text-2xl font-bold text-white mb-1">
Neuen Space erstellen
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-slate-200 mb-2">
<p className="text-sm text-slate-400">
Erstellen Sie einen neuen Arbeitsbereich für Ihre Zertifikate
</p>
</div>
</div>
{/* Info Banner */}
<div className="mb-6 p-4 bg-emerald-500/10 border border-emerald-500/30 rounded-lg flex items-start">
<svg className="w-5 h-5 text-emerald-400 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-emerald-300">
Spaces sind Arbeitsbereiche, in denen Sie FQDNs und Zertifikate organisieren können. Jeder Space kann mehrere FQDNs enthalten.
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Name Input */}
<div className="space-y-2">
<label htmlFor="name" className="flex items-center text-sm font-semibold text-slate-200">
<svg className="w-4 h-4 mr-2 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
Name *
</label>
<div className="relative">
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
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 focus:border-transparent"
placeholder="Geben Sie einen Namen ein"
className="w-full px-4 py-3 pl-11 bg-slate-700/70 border-2 border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all hover:border-slate-500"
placeholder="z.B. Produktion, Entwicklung, Test"
required
/>
<svg className="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-emerald-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-slate-200 mb-2">
</div>
{/* Description Textarea */}
<div className="space-y-2">
<label htmlFor="description" className="flex items-center text-sm font-semibold text-slate-200">
<svg className="w-4 h-4 mr-2 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" />
</svg>
Beschreibung
<span className="ml-2 text-xs font-normal text-slate-400">(optional)</span>
</label>
<div className="relative">
<textarea
id="description"
name="description"
value={formData.description}
onChange={handleChange}
rows="4"
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 focus:border-transparent resize-none"
placeholder="Geben Sie eine Beschreibung ein (optional)"
className="w-full px-4 py-3 pl-11 pt-3 bg-slate-700/70 border-2 border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 transition-all hover:border-slate-500 resize-none"
placeholder="Geben Sie eine Beschreibung ein (z.B. Verwendungszweck, Standort, Team, etc.)"
/>
<svg className="absolute left-3 top-3 w-5 h-5 text-emerald-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" />
</svg>
</div>
</div>
{/* Error Message */}
{error && (
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm">
{error}
<div className="p-4 bg-red-500/20 border-2 border-red-500/50 rounded-lg flex items-start">
<svg className="w-5 h-5 text-red-400 mr-3 mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p className="text-sm text-red-300">{error}</p>
</div>
)}
<div className="flex gap-3">
{/* Action Buttons */}
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={loading}
className="px-6 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-800 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-all duration-200"
className="flex-1 px-6 py-3 bg-gradient-to-r from-emerald-600 to-emerald-700 hover:from-emerald-700 hover:to-emerald-800 disabled:from-slate-600 disabled:to-slate-700 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-all duration-200 shadow-lg shadow-emerald-500/25 hover:shadow-emerald-500/40 disabled:shadow-none flex items-center justify-center"
>
{loading ? 'Wird erstellt...' : 'Space erstellen'}
{loading ? (
<>
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" 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>
Wird erstellt...
</>
) : (
<>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
</svg>
Space erstellen
</>
)}
</button>
<button
type="button"
@@ -262,13 +333,14 @@ const Spaces = () => {
setFormData({ name: '', description: '' })
setError('')
}}
className="px-6 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors duration-200"
className="px-6 py-3 bg-slate-700/70 hover:bg-slate-700 border-2 border-slate-600 hover:border-slate-500 text-white font-semibold rounded-lg transition-all duration-200"
>
Abbrechen
</button>
</div>
</form>
</div>
</div>
)}
{/* Spaces List */}

View File

@@ -28,6 +28,7 @@ const Users = () => {
groupIds: []
})
const [showAdminWarning, setShowAdminWarning] = useState(false)
const [pendingAdminChange, setPendingAdminChange] = useState(null) // Speichert den vorherigen isAdmin Wert
useEffect(() => {
fetchUsers()
@@ -124,6 +125,7 @@ const Users = () => {
setShowForm(false)
setEditingUser(null)
setShowAdminWarning(false)
setPendingAdminChange(null)
// Aktualisiere Berechtigungen nach Änderung an Benutzern (Gruppen-Zuweisungen könnten sich geändert haben)
refreshPermissions()
} else {
@@ -281,16 +283,27 @@ const Users = () => {
const handleAdminToggle = (e) => {
const isAdmin = e.target.checked
const previousIsAdmin = formData.isAdmin
// Wenn Admin aktiviert wird, zeige Warnung und setze Wert NICHT sofort
if (isAdmin && !showAdminWarning) {
// Speichere den vorherigen Wert
setPendingAdminChange(previousIsAdmin)
setShowAdminWarning(true)
// Wert NICHT setzen - wird erst gesetzt wenn Benutzer bestätigt
// Checkbox bleibt visuell unverändert durch controlled component
return
}
// Wenn Admin deaktiviert wird (Checkbox wird abgewählt), setze sofort
setFormData(prev => ({
...prev,
isAdmin,
// Wenn Admin aktiviert wird, entferne alle Gruppen und stelle sicher dass enabled=true
// Wenn Admin deaktiviert wird, Gruppen können wieder gesetzt werden
groupIds: isAdmin ? [] : prev.groupIds,
enabled: isAdmin ? true : (prev.enabled !== undefined ? prev.enabled : true) // Admin muss immer enabled sein
enabled: isAdmin ? true : (prev.enabled !== undefined ? prev.enabled : true)
}))
setPendingAdminChange(null)
}
const getPermissionLabel = (permission) => {
@@ -322,6 +335,7 @@ const Users = () => {
setEditingUser(null)
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', isAdmin: false, enabled: true, groupIds: [] })
setShowAdminWarning(false)
setPendingAdminChange(null)
}}
className="px-6 py-3 bg-blue-600 hover:bg-blue-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200"
>
@@ -470,7 +484,7 @@ const Users = () => {
type="checkbox"
checked={formData.isAdmin || false}
onChange={handleAdminToggle}
disabled={editingUser && editingUser.username === 'admin'} // Admin user kann seinen Status nicht ändern
disabled={editingUser && editingUser.id === 'admin'} // Admin user kann seinen Status nicht ändern
className="mt-1 w-5 h-5 text-blue-600 bg-slate-700 border-slate-600 rounded focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-slate-800 cursor-pointer"
/>
<div className="ml-3 flex-1">
@@ -550,6 +564,7 @@ const Users = () => {
setEditingUser(null)
setFormData({ username: '', email: '', oldPassword: '', password: '', confirmPassword: '', isAdmin: false, enabled: true, groupIds: [] })
setShowAdminWarning(false)
setPendingAdminChange(null)
setError('')
}}
className="px-6 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors duration-200"
@@ -709,13 +724,31 @@ const Users = () => {
<div className="flex gap-3">
<button
onClick={() => setShowAdminWarning(false)}
onClick={() => {
// Bei Abbrechen: Zurück zum vorherigen Wert
setFormData(prev => ({
...prev,
isAdmin: pendingAdminChange !== null ? pendingAdminChange : prev.isAdmin
}))
setPendingAdminChange(null)
setShowAdminWarning(false)
}}
className="flex-1 px-4 py-2 bg-slate-600 hover:bg-slate-700 text-white font-semibold rounded-lg transition-colors duration-200"
>
Abbrechen
</button>
<button
onClick={() => setShowAdminWarning(false)}
onClick={() => {
// Bei Bestätigung: isAdmin auf true setzen und Modal schließen
setFormData(prev => ({
...prev,
isAdmin: true,
groupIds: [], // Gruppen entfernen wenn Admin aktiviert wird
enabled: true // Admin muss immer enabled sein
}))
setPendingAdminChange(null)
setShowAdminWarning(false)
}}
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white font-semibold rounded-lg transition-colors duration-200"
>
Verstanden, fortfahren