Compare commits
3 Commits
ec1e0da9d5
...
9a1168abf0
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a1168abf0 | |||
| 688b277b5d | |||
| 145dfd3d7c |
19
.gitignore
vendored
19
.gitignore
vendored
@@ -87,6 +87,14 @@ vite.config.ts.timestamp-*
|
|||||||
*.sqlite3
|
*.sqlite3
|
||||||
backend/spaces.db
|
backend/spaces.db
|
||||||
backend/*.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
|
# Database backups
|
||||||
*.sql.backup
|
*.sql.backup
|
||||||
@@ -114,6 +122,9 @@ yarn-error.log*
|
|||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
backend/*.log
|
backend/*.log
|
||||||
|
backend/logs/
|
||||||
|
backend/logs/**
|
||||||
|
*.log.*
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# IDE & Editors
|
# IDE & Editors
|
||||||
@@ -337,6 +348,14 @@ backend/test-outputs/
|
|||||||
# Script outputs
|
# Script outputs
|
||||||
backend/scripts/output/
|
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
|
# Keep directory structure but ignore contents
|
||||||
!backend/uploads/.gitkeep
|
!backend/uploads/.gitkeep
|
||||||
!backend/config/providers/.gitkeep
|
!backend/config/providers/.gitkeep
|
||||||
|
!backend/testing/.gitkeep
|
||||||
|
!backend/testing/README.md
|
||||||
|
!backend/testing/scripts/*.go
|
||||||
|
|||||||
1448
backend/acme_client.go
Normal file
1448
backend/acme_client.go
Normal file
File diff suppressed because it is too large
Load Diff
57
backend/acme_client_context.go
Normal file
57
backend/acme_client_context.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"certigo-addon-backend/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ACMEClientContext enthält den Kontext für ACME-Operationen
|
||||||
|
type ACMEClientContext struct {
|
||||||
|
Provider providers.ACMEProvider
|
||||||
|
Directory *ACMEDirectory
|
||||||
|
DirectoryURL string
|
||||||
|
NewAccountURL string
|
||||||
|
NewOrderURL string
|
||||||
|
NewNonceURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewACMEClientContext erstellt einen neuen ACME-Client-Kontext
|
||||||
|
func NewACMEClientContext(providerID string) (*ACMEClientContext, error) {
|
||||||
|
acmeManager := providers.GetACMEManager()
|
||||||
|
provider, exists := acmeManager.GetACMEProvider(providerID)
|
||||||
|
if !exists {
|
||||||
|
return nil, fmt.Errorf("ACME-Provider '%s' nicht gefunden", providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := acmeManager.GetACMEProviderConfig(providerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fehler beim Laden der Provider-Konfiguration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !config.Enabled {
|
||||||
|
return nil, fmt.Errorf("ACME-Provider '%s' ist nicht aktiviert", providerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validiere Konfiguration
|
||||||
|
if err := provider.ValidateConfig(config.Settings); err != nil {
|
||||||
|
return nil, fmt.Errorf("ungültige Provider-Konfiguration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
directoryURL := provider.GetDirectoryURL()
|
||||||
|
|
||||||
|
// Hole Directory-Endpunkte
|
||||||
|
directory, err := getACMEDirectory(directoryURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fehler beim Abrufen der ACME Directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ACMEClientContext{
|
||||||
|
Provider: provider,
|
||||||
|
Directory: directory,
|
||||||
|
DirectoryURL: directoryURL,
|
||||||
|
NewAccountURL: directory.NewAccount,
|
||||||
|
NewOrderURL: directory.NewOrder,
|
||||||
|
NewNonceURL: directory.NewNonce,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
65
backend/cert_logger.go
Normal file
65
backend/cert_logger.go
Normal 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
160
backend/cert_parser.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"acme_ready": false,
|
||||||
"settings": {
|
"settings": {
|
||||||
"password": "test",
|
"password": "test",
|
||||||
"username": "test"
|
"username": "test"
|
||||||
|
|||||||
7
backend/config/providers/certigo-acmeproxy.json
Normal file
7
backend/config/providers/certigo-acmeproxy.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"acme_ready": true,
|
||||||
|
"settings": {
|
||||||
|
"baseURL": "http://openmailserver.de:8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
|
"acme_ready": false,
|
||||||
"settings": {}
|
"settings": {}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
"acme_ready": false,
|
||||||
"settings": {}
|
"settings": {}
|
||||||
}
|
}
|
||||||
5
backend/config/providers/letsencrypt-production.json
Normal file
5
backend/config/providers/letsencrypt-production.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
|
|
||||||
5
backend/config/providers/letsencrypt-staging.json
Normal file
5
backend/config/providers/letsencrypt-staging.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"enabled": true,
|
||||||
|
"settings": {}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -5,9 +5,9 @@ go 1.24.0
|
|||||||
toolchain go1.24.10
|
toolchain go1.24.10
|
||||||
|
|
||||||
require (
|
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/gorilla/mux v1.8.1
|
||||||
github.com/mattn/go-sqlite3 v1.14.18
|
github.com/mattn/go-sqlite3 v1.14.18
|
||||||
|
golang.org/x/crypto v0.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/crypto v0.45.0 // indirect
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||||
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
|
||||||
|
|||||||
1723
backend/main.go
1723
backend/main.go
File diff suppressed because it is too large
Load Diff
1292
backend/openapi.yaml
1292
backend/openapi.yaml
File diff suppressed because it is too large
Load Diff
182
backend/providers/acme_provider.go
Normal file
182
backend/providers/acme_provider.go
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ACMEProvider Interface für ACME-basierte Certificate Authorities
|
||||||
|
type ACMEProvider interface {
|
||||||
|
// GetName gibt den Namen des ACME-Providers zurück
|
||||||
|
GetName() string
|
||||||
|
// GetDisplayName gibt den Anzeigenamen zurück
|
||||||
|
GetDisplayName() string
|
||||||
|
// GetDescription gibt eine Beschreibung zurück
|
||||||
|
GetDescription() string
|
||||||
|
// GetDirectoryURL gibt die ACME Directory URL zurück
|
||||||
|
GetDirectoryURL() string
|
||||||
|
// GetRenewalInfoURL gibt die RenewalInfo API URL zurück (optional)
|
||||||
|
GetRenewalInfoURL() string
|
||||||
|
// ValidateConfig validiert die Konfiguration
|
||||||
|
ValidateConfig(settings map[string]interface{}) error
|
||||||
|
// TestConnection testet die Verbindung zum ACME-Server
|
||||||
|
TestConnection(settings map[string]interface{}) error
|
||||||
|
// GetRequiredSettings gibt die erforderlichen Einstellungen zurück
|
||||||
|
GetRequiredSettings() []SettingField
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACMEProviderConfig enthält die Konfiguration eines ACME-Providers
|
||||||
|
type ACMEProviderConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Settings map[string]interface{} `json:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACMEProviderManager verwaltet alle ACME-Provider
|
||||||
|
type ACMEProviderManager struct {
|
||||||
|
providers map[string]ACMEProvider
|
||||||
|
configs map[string]*ACMEProviderConfig
|
||||||
|
configDir string
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var acmeManager *ACMEProviderManager
|
||||||
|
var acmeOnce sync.Once
|
||||||
|
|
||||||
|
// GetACMEManager gibt die Singleton-Instanz des ACMEProviderManagers zurück
|
||||||
|
func GetACMEManager() *ACMEProviderManager {
|
||||||
|
acmeOnce.Do(func() {
|
||||||
|
acmeManager = &ACMEProviderManager{
|
||||||
|
providers: make(map[string]ACMEProvider),
|
||||||
|
configs: make(map[string]*ACMEProviderConfig),
|
||||||
|
configDir: "./config/providers",
|
||||||
|
}
|
||||||
|
acmeManager.loadAllConfigs()
|
||||||
|
})
|
||||||
|
return acmeManager
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterACMEProvider registriert einen neuen ACME-Provider
|
||||||
|
func (pm *ACMEProviderManager) RegisterACMEProvider(provider ACMEProvider) {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
providerID := provider.GetName()
|
||||||
|
pm.providers[providerID] = provider
|
||||||
|
|
||||||
|
// Lade Konfiguration falls vorhanden
|
||||||
|
if pm.configs[providerID] == nil {
|
||||||
|
pm.configs[providerID] = &ACMEProviderConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Settings: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetACMEProvider gibt einen ACME-Provider zurück
|
||||||
|
func (pm *ACMEProviderManager) GetACMEProvider(id string) (ACMEProvider, bool) {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
provider, exists := pm.providers[id]
|
||||||
|
return provider, exists
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAllACMEProviders gibt alle registrierten ACME-Provider zurück
|
||||||
|
func (pm *ACMEProviderManager) GetAllACMEProviders() map[string]ACMEProvider {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
result := make(map[string]ACMEProvider)
|
||||||
|
for id, provider := range pm.providers {
|
||||||
|
result[id] = provider
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetACMEProviderConfig gibt die Konfiguration eines ACME-Providers zurück
|
||||||
|
func (pm *ACMEProviderManager) GetACMEProviderConfig(id string) (*ACMEProviderConfig, error) {
|
||||||
|
pm.mu.RLock()
|
||||||
|
defer pm.mu.RUnlock()
|
||||||
|
|
||||||
|
config, exists := pm.configs[id]
|
||||||
|
if !exists {
|
||||||
|
return &ACMEProviderConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Settings: make(map[string]interface{}),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetACMEProviderEnabled aktiviert/deaktiviert einen ACME-Provider
|
||||||
|
func (pm *ACMEProviderManager) SetACMEProviderEnabled(id string, enabled bool) error {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
if pm.configs[id] == nil {
|
||||||
|
pm.configs[id] = &ACMEProviderConfig{
|
||||||
|
Enabled: enabled,
|
||||||
|
Settings: make(map[string]interface{}),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
pm.configs[id].Enabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
return pm.saveConfig(id, pm.configs[id])
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadAllConfigs lädt alle Konfigurationsdateien
|
||||||
|
func (pm *ACMEProviderManager) loadAllConfigs() {
|
||||||
|
// Stelle sicher, dass das Verzeichnis existiert
|
||||||
|
os.MkdirAll(pm.configDir, 0755)
|
||||||
|
|
||||||
|
// Lade alle JSON-Dateien im Konfigurationsverzeichnis
|
||||||
|
files, err := filepath.Glob(filepath.Join(pm.configDir, "*.json"))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
id := filepath.Base(file[:len(file)-5]) // Entferne .json
|
||||||
|
// Nur ACME-Provider-Konfigurationen laden (beginnen mit "letsencrypt")
|
||||||
|
if id == "letsencrypt-production" || id == "letsencrypt-staging" {
|
||||||
|
config, err := pm.loadConfig(id)
|
||||||
|
if err == nil {
|
||||||
|
pm.configs[id] = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadConfig lädt eine Konfigurationsdatei
|
||||||
|
func (pm *ACMEProviderManager) loadConfig(id string) (*ACMEProviderConfig, error) {
|
||||||
|
filePath := filepath.Join(pm.configDir, id+".json")
|
||||||
|
|
||||||
|
data, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var config ACMEProviderConfig
|
||||||
|
if err := json.Unmarshal(data, &config); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveConfig speichert eine Konfiguration in eine Datei
|
||||||
|
func (pm *ACMEProviderManager) saveConfig(id string, config *ACMEProviderConfig) error {
|
||||||
|
// Stelle sicher, dass das Verzeichnis existiert
|
||||||
|
os.MkdirAll(pm.configDir, 0755)
|
||||||
|
|
||||||
|
filePath := filepath.Join(pm.configDir, id+".json")
|
||||||
|
|
||||||
|
data, err := json.MarshalIndent(config, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(filePath, data, 0644)
|
||||||
|
}
|
||||||
|
|
||||||
234
backend/providers/certigo-acmeproxy.go
Normal file
234
backend/providers/certigo-acmeproxy.go
Normal 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, ®isterResponse); err != nil {
|
||||||
|
return nil, fmt.Errorf("fehler beim Parsen der Response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ®isterResponse, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateChallengeToken setzt oder aktualisiert den ACME Challenge Token
|
||||||
|
func (p *CertigoACMEProxyProvider) UpdateChallengeToken(username, password, token string, settings map[string]interface{}) error {
|
||||||
|
if err := p.ValidateConfig(settings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, _ := settings["baseURL"].(string)
|
||||||
|
baseURL = strings.TrimSuffix(baseURL, "/")
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/update", baseURL)
|
||||||
|
|
||||||
|
requestBody := map[string]string{
|
||||||
|
"txt": token,
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(requestBody)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fehler beim Erstellen des Request-Body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fehler beim Erstellen des Requests: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Basic Authentication
|
||||||
|
auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
|
||||||
|
req.Header.Set("Authorization", "Basic "+auth)
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fehler beim Senden des Requests: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fehler beim Lesen der Response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
return fmt.Errorf("ungültige Authentifizierung")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusBadRequest {
|
||||||
|
return fmt.Errorf("ungültige Anfrage: %s", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("acme-proxy Fehler (Status %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChallengeDomainResponse enthält die Response von /register
|
||||||
|
type ChallengeDomainResponse struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Fulldomain string `json:"fulldomain"`
|
||||||
|
Subdomain string `json:"subdomain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignCSR signiert einen CSR (für ACME nicht direkt verwendet, aber Interface erfordert es)
|
||||||
|
func (p *CertigoACMEProxyProvider) SignCSR(csrPEM string, settings map[string]interface{}) (*SignCSRResult, error) {
|
||||||
|
return nil, fmt.Errorf("certigo-acmeproxy unterstützt keine direkte CSR-Signierung. Verwenden Sie ACME für Zertifikatsanfragen.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCertificate ruft ein Zertifikat ab (für ACME nicht direkt verwendet, aber Interface erfordert es)
|
||||||
|
func (p *CertigoACMEProxyProvider) GetCertificate(certificateID string, settings map[string]interface{}) (string, error) {
|
||||||
|
return "", fmt.Errorf("certigo-acmeproxy unterstützt keinen direkten Zertifikat-Abruf. Verwenden Sie ACME für Zertifikatsanfragen.")
|
||||||
|
}
|
||||||
|
|
||||||
106
backend/providers/letsencrypt.go
Normal file
106
backend/providers/letsencrypt.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package providers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LetsEncryptProvider ist der Provider für Let's Encrypt
|
||||||
|
type LetsEncryptProvider struct {
|
||||||
|
environment string // "production" oder "staging"
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLetsEncryptProvider erstellt einen neuen Let's Encrypt Provider
|
||||||
|
func NewLetsEncryptProvider(environment string) *LetsEncryptProvider {
|
||||||
|
if environment != "staging" && environment != "production" {
|
||||||
|
environment = "production"
|
||||||
|
}
|
||||||
|
return &LetsEncryptProvider{
|
||||||
|
environment: environment,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LetsEncryptProvider) GetName() string {
|
||||||
|
if p.environment == "staging" {
|
||||||
|
return "letsencrypt-staging"
|
||||||
|
}
|
||||||
|
return "letsencrypt-production"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LetsEncryptProvider) GetDisplayName() string {
|
||||||
|
if p.environment == "staging" {
|
||||||
|
return "Let's Encrypt (Staging)"
|
||||||
|
}
|
||||||
|
return "Let's Encrypt (Production)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LetsEncryptProvider) GetDescription() string {
|
||||||
|
if p.environment == "staging" {
|
||||||
|
return "Let's Encrypt Staging Environment für Tests"
|
||||||
|
}
|
||||||
|
return "Let's Encrypt Production Certificate Authority"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LetsEncryptProvider) GetDirectoryURL() string {
|
||||||
|
if p.environment == "staging" {
|
||||||
|
return "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||||
|
}
|
||||||
|
return "https://acme-v02.api.letsencrypt.org/directory"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LetsEncryptProvider) GetRenewalInfoURL() string {
|
||||||
|
if p.environment == "staging" {
|
||||||
|
return "https://acme-staging-v02.api.letsencrypt.org/acme/renewal-info"
|
||||||
|
}
|
||||||
|
return "https://acme-v02.api.letsencrypt.org/acme/renewal-info"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LetsEncryptProvider) ValidateConfig(settings map[string]interface{}) error {
|
||||||
|
// Let's Encrypt benötigt keine zusätzliche Konfiguration
|
||||||
|
// Die Directory URL wird automatisch basierend auf der Environment gesetzt
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LetsEncryptProvider) TestConnection(settings map[string]interface{}) error {
|
||||||
|
// Teste Verbindung zum ACME Directory
|
||||||
|
directoryURL := p.GetDirectoryURL()
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get(directoryURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ACME Directory nicht erreichbar: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("ACME Directory antwortet mit Status %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob es ein gültiges ACME Directory ist
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fehler beim Lesen der Directory-Response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Einfache Validierung: Prüfe ob "newAccount" oder "newNonce" im Body enthalten ist
|
||||||
|
bodyStr := string(body)
|
||||||
|
if !strings.Contains(bodyStr, "newAccount") && !strings.Contains(bodyStr, "newNonce") {
|
||||||
|
return fmt.Errorf("ungültige ACME Directory Response")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LetsEncryptProvider) GetRequiredSettings() []SettingField {
|
||||||
|
// Let's Encrypt benötigt keine zusätzlichen Einstellungen
|
||||||
|
// Die Directory URL wird automatisch basierend auf der Environment gesetzt
|
||||||
|
return []SettingField{}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -10,8 +10,9 @@ import (
|
|||||||
|
|
||||||
// ProviderConfig enthält die Konfiguration eines Providers
|
// ProviderConfig enthält die Konfiguration eines Providers
|
||||||
type ProviderConfig struct {
|
type ProviderConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Settings map[string]interface{} `json:"settings"`
|
AcmeReady bool `json:"acme_ready"`
|
||||||
|
Settings map[string]interface{} `json:"settings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SignCSRResult enthält das Ergebnis einer CSR-Signierung
|
// SignCSRResult enthält das Ergebnis einer CSR-Signierung
|
||||||
@@ -77,8 +78,9 @@ func (pm *ProviderManager) RegisterProvider(provider Provider) {
|
|||||||
// Lade Konfiguration falls vorhanden
|
// Lade Konfiguration falls vorhanden
|
||||||
if pm.configs[providerID] == nil {
|
if pm.configs[providerID] == nil {
|
||||||
pm.configs[providerID] = &ProviderConfig{
|
pm.configs[providerID] = &ProviderConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Settings: make(map[string]interface{}),
|
AcmeReady: false,
|
||||||
|
Settings: make(map[string]interface{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,8 +112,9 @@ func (pm *ProviderManager) GetProviderConfig(id string) (*ProviderConfig, error)
|
|||||||
config, exists := pm.configs[id]
|
config, exists := pm.configs[id]
|
||||||
if !exists {
|
if !exists {
|
||||||
return &ProviderConfig{
|
return &ProviderConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Settings: make(map[string]interface{}),
|
AcmeReady: false,
|
||||||
|
Settings: make(map[string]interface{}),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
return config, nil
|
return config, nil
|
||||||
@@ -145,8 +148,9 @@ func (pm *ProviderManager) SetProviderEnabled(id string, enabled bool) error {
|
|||||||
|
|
||||||
if pm.configs[id] == nil {
|
if pm.configs[id] == nil {
|
||||||
pm.configs[id] = &ProviderConfig{
|
pm.configs[id] = &ProviderConfig{
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
Settings: make(map[string]interface{}),
|
AcmeReady: false,
|
||||||
|
Settings: make(map[string]interface{}),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
pm.configs[id].Enabled = enabled
|
pm.configs[id].Enabled = enabled
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ type ProviderInfo struct {
|
|||||||
DisplayName string `json:"displayName"`
|
DisplayName string `json:"displayName"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
AcmeReady bool `json:"acme_ready"`
|
||||||
Settings []SettingField `json:"settings"`
|
Settings []SettingField `json:"settings"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
207
backend/renewal_info.go
Normal file
207
backend/renewal_info.go
Normal 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
|
||||||
|
}
|
||||||
333
backend/renewal_queue_handlers.go
Normal file
333
backend/renewal_queue_handlers.go
Normal 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(¤tRenewalEnabled)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
http.Error(w, "FQDN nicht gefunden", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Fehler beim Laden des FQDN", http.StatusInternalServerError)
|
||||||
|
log.Printf("Fehler beim Laden des FQDN: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Request Body
|
||||||
|
var req struct {
|
||||||
|
RenewalEnabled bool `json:"renewalEnabled"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "Ungültige Anfrage", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe ob renewal_enabled von false auf true geändert wird
|
||||||
|
wasDisabled := !currentRenewalEnabled.Valid || currentRenewalEnabled.Int64 == 0
|
||||||
|
willBeEnabled := req.RenewalEnabled
|
||||||
|
shouldProcessRenewalInfo := wasDisabled && willBeEnabled
|
||||||
|
|
||||||
|
// Beginne Transaktion
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tx, err := db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Fehler beim Starten der Transaktion", http.StatusInternalServerError)
|
||||||
|
log.Printf("Fehler beim Starten der Transaktion: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
// Update renewal_enabled (explizit als 0 oder 1 speichern, nicht NULL)
|
||||||
|
var renewalEnabledInt int
|
||||||
|
if req.RenewalEnabled {
|
||||||
|
renewalEnabledInt = 1
|
||||||
|
} else {
|
||||||
|
renewalEnabledInt = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.ExecContext(ctx, "UPDATE fqdns SET renewal_enabled = ? WHERE id = ? AND space_id = ?", renewalEnabledInt, fqdnID, spaceID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Fehler beim Aktualisieren des renewal_enabled Flags", http.StatusInternalServerError)
|
||||||
|
log.Printf("Fehler beim Aktualisieren des renewal_enabled Flags: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn renewal_enabled deaktiviert wird, lösche nur pending/processing Queue-Einträge für diesen FQDN
|
||||||
|
// Completed und failed Einträge bleiben als Historie erhalten
|
||||||
|
if !req.RenewalEnabled {
|
||||||
|
_, err = tx.ExecContext(ctx, "DELETE FROM renewal_queue WHERE fqdn_id = ? AND status IN ('pending', 'processing')", fqdnID)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Fehler beim Löschen der Queue-Einträge", http.StatusInternalServerError)
|
||||||
|
log.Printf("Fehler beim Löschen der Queue-Einträge: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("Pending/Processing Queue-Einträge für FQDN %s gelöscht (renewal_enabled deaktiviert)", fqdnID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Committe die Transaktion
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
http.Error(w, "Fehler beim Speichern der Änderungen", http.StatusInternalServerError)
|
||||||
|
log.Printf("Fehler beim Committen der Transaktion: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn renewal_enabled von false auf true geändert wurde, verarbeite RenewalInfo für das aktuelle Zertifikat
|
||||||
|
if shouldProcessRenewalInfo {
|
||||||
|
go func() {
|
||||||
|
// Hole das aktuellste (nicht-intermediate) Zertifikat für diesen FQDN
|
||||||
|
var certID, certPEM string
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT id, certificate_pem
|
||||||
|
FROM certificates
|
||||||
|
WHERE fqdn_id = ? AND (is_intermediate = 0 OR is_intermediate IS NULL)
|
||||||
|
ORDER BY expires_at DESC, created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, fqdnID).Scan(&certID, &certPEM)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
log.Printf("Kein Zertifikat für FQDN %s gefunden - RenewalInfo wird übersprungen", fqdnID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Fehler beim Laden des Zertifikats für FQDN %s: %v", fqdnID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if certPEM == "" {
|
||||||
|
log.Printf("Zertifikat %s hat kein PEM - RenewalInfo wird übersprungen", certID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verarbeite RenewalInfo im Hintergrund
|
||||||
|
log.Printf("Verarbeite RenewalInfo für Zertifikat %s (FQDN %s wurde aktiviert)", certID, fqdnID)
|
||||||
|
if err := ProcessRenewalInfoForCertificate(certPEM, certID, fqdnID, spaceID, true); err != nil {
|
||||||
|
log.Printf("Fehler beim Verarbeiten der RenewalInfo für FQDN %s (wird ignoriert): %v", fqdnID, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("RenewalInfo erfolgreich verarbeitet für FQDN %s", fqdnID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hole Username für Audit-Log
|
||||||
|
userID, username := getUserFromRequest(r)
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"renewalEnabled": req.RenewalEnabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Audit-Log
|
||||||
|
if auditService != nil {
|
||||||
|
ipAddress, userAgent := getRequestInfo(r)
|
||||||
|
auditService.Track(r.Context(), "UPDATE", "fqdn", fqdnID, userID, username, map[string]interface{}{
|
||||||
|
"spaceId": spaceID,
|
||||||
|
"renewalEnabled": req.RenewalEnabled,
|
||||||
|
"message": fmt.Sprintf("Renewal-Status für FQDN aktualisiert: %v", req.RenewalEnabled),
|
||||||
|
}, ipAddress, userAgent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRenewalQueueHandler gibt alle Einträge aus der renewal_queue zurück
|
||||||
|
func getRenewalQueueHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe Berechtigung - jeder authentifizierte User kann die Queue sehen
|
||||||
|
userID, _ := getUserFromRequest(r)
|
||||||
|
if userID == "" {
|
||||||
|
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hole alle Queue-Einträge mit FQDN und Space-Informationen
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT
|
||||||
|
rq.id,
|
||||||
|
rq.certificate_id,
|
||||||
|
rq.fqdn_id,
|
||||||
|
rq.space_id,
|
||||||
|
rq.scheduled_at,
|
||||||
|
rq.status,
|
||||||
|
rq.created_at,
|
||||||
|
rq.processed_at,
|
||||||
|
rq.error_message,
|
||||||
|
f.fqdn,
|
||||||
|
s.name as space_name
|
||||||
|
FROM renewal_queue rq
|
||||||
|
LEFT JOIN fqdns f ON rq.fqdn_id = f.id
|
||||||
|
LEFT JOIN spaces s ON rq.space_id = s.id
|
||||||
|
ORDER BY rq.scheduled_at ASC
|
||||||
|
`)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Fehler beim Laden der Renewal Queue", http.StatusInternalServerError)
|
||||||
|
log.Printf("Fehler beim Laden der Renewal Queue: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var queueItems []map[string]interface{}
|
||||||
|
for rows.Next() {
|
||||||
|
var id, certID, fqdnID, spaceID, scheduledAt, status, createdAt, fqdn, spaceName string
|
||||||
|
var processedAt, errorMessage sql.NullString
|
||||||
|
|
||||||
|
err := rows.Scan(&id, &certID, &fqdnID, &spaceID, &scheduledAt, &status, &createdAt, &processedAt, &errorMessage, &fqdn, &spaceName)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Fehler beim Scannen der Queue-Zeile: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
item := map[string]interface{}{
|
||||||
|
"id": id,
|
||||||
|
"certificateId": certID,
|
||||||
|
"fqdnId": fqdnID,
|
||||||
|
"spaceId": spaceID,
|
||||||
|
"scheduledAt": scheduledAt,
|
||||||
|
"status": status,
|
||||||
|
"createdAt": createdAt,
|
||||||
|
"fqdn": fqdn,
|
||||||
|
"spaceName": spaceName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if processedAt.Valid {
|
||||||
|
item["processedAt"] = processedAt.String
|
||||||
|
}
|
||||||
|
if errorMessage.Valid {
|
||||||
|
item["errorMessage"] = errorMessage.String
|
||||||
|
}
|
||||||
|
|
||||||
|
queueItems = append(queueItems, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"queue": queueItems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteAllRenewalQueueEntriesHandler löscht alle Einträge aus der Renewal Queue
|
||||||
|
func deleteAllRenewalQueueEntriesHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
if r.Method == "OPTIONS" {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe Berechtigung - nur authentifizierte User
|
||||||
|
userID, username := getUserFromRequest(r)
|
||||||
|
if userID == "" {
|
||||||
|
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prüfe Bestätigung (optional, aber empfohlen)
|
||||||
|
confirm := r.URL.Query().Get("confirm")
|
||||||
|
if confirm != "true" {
|
||||||
|
http.Error(w, "Bestätigung erforderlich. Verwende ?confirm=true", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Lösche alle Queue-Einträge
|
||||||
|
result, err := db.ExecContext(ctx, "DELETE FROM renewal_queue")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Fehler beim Löschen der Queue-Einträge", http.StatusInternalServerError)
|
||||||
|
log.Printf("Fehler beim Löschen der Queue-Einträge: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Fehler beim Prüfen der gelöschten Zeilen", http.StatusInternalServerError)
|
||||||
|
log.Printf("Fehler beim Prüfen der gelöschten Zeilen: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Alle Renewal Queue-Einträge gelöscht: %d Einträge", rowsAffected)
|
||||||
|
|
||||||
|
response := map[string]interface{}{
|
||||||
|
"success": true,
|
||||||
|
"message": "Alle Renewal Queue-Einträge erfolgreich gelöscht",
|
||||||
|
"deletedCount": rowsAffected,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
|
||||||
|
// Audit-Log
|
||||||
|
if auditService != nil {
|
||||||
|
ipAddress, userAgent := getRequestInfo(r)
|
||||||
|
auditService.Track(r.Context(), "DELETE", "renewal_queue", "", userID, username, map[string]interface{}{
|
||||||
|
"deletedCount": rowsAffected,
|
||||||
|
"message": fmt.Sprintf("Alle Renewal Queue-Einträge gelöscht (%d Einträge)", rowsAffected),
|
||||||
|
}, ipAddress, userAgent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
350
backend/renewal_scheduler.go
Normal file
350
backend/renewal_scheduler.go
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
182
backend/renewal_test_handlers.go
Normal file
182
backend/renewal_test_handlers.go
Normal 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)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Test-Skripte für Audit-Logs
|
# Test-Skripte
|
||||||
|
|
||||||
## Test-Logs generieren
|
## 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.
|
**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
79
backend/testing/README.md
Normal 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-%';"
|
||||||
|
```
|
||||||
223
backend/testing/scripts/test_renewal.go
Normal file
223
backend/testing/scripts/test_renewal.go
Normal 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-%';")
|
||||||
|
}
|
||||||
|
|
||||||
865
docs/AUTO_RENEWAL_KONZEPT.md
Normal file
865
docs/AUTO_RENEWAL_KONZEPT.md
Normal 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
754
docs/OAUTH_KONZEPT.md
Normal 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
|
||||||
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||||
import { PermissionsProvider } from './contexts/PermissionsContext'
|
import { PermissionsProvider } from './contexts/PermissionsContext'
|
||||||
|
import { ToastProvider, useToast } from './contexts/ToastContext'
|
||||||
import { usePermissions } from './hooks/usePermissions'
|
import { usePermissions } from './hooks/usePermissions'
|
||||||
import Sidebar from './components/Sidebar'
|
import Sidebar from './components/Sidebar'
|
||||||
import Footer from './components/Footer'
|
import Footer from './components/Footer'
|
||||||
@@ -15,6 +16,7 @@ import Permissions from './pages/Permissions'
|
|||||||
import Providers from './pages/Providers'
|
import Providers from './pages/Providers'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import AuditLogs from './pages/AuditLogs'
|
import AuditLogs from './pages/AuditLogs'
|
||||||
|
import RenewalQueue from './pages/RenewalQueue'
|
||||||
|
|
||||||
// Protected Route Component
|
// Protected Route Component
|
||||||
const ProtectedRoute = ({ children }) => {
|
const ProtectedRoute = ({ children }) => {
|
||||||
@@ -75,10 +77,12 @@ const AdminRoute = ({ children }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Group Required Route Component - User muss einer Berechtigungsgruppe zugewiesen sein
|
// Group Required Route Component - User muss einer Berechtigungsgruppe zugewiesen sein
|
||||||
const GroupRequiredRoute = ({ children }) => {
|
const GroupRequiredRoute = ({ children, allowHomePage = false }) => {
|
||||||
const { isAuthenticated, loading } = useAuth()
|
const { isAuthenticated, loading, user } = useAuth()
|
||||||
const { isAdmin, hasFullAccess, accessibleSpaces, loading: permissionsLoading } = usePermissions()
|
const { isAdmin, hasFullAccess, accessibleSpaces, loading: permissionsLoading } = usePermissions()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
// Warte, bis sowohl Auth als auch Permissions geladen sind
|
||||||
if (loading || permissionsLoading) {
|
if (loading || permissionsLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 flex items-center justify-center">
|
<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 />
|
return <Navigate to="/login" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admin oder User mit Gruppen haben Zugriff
|
// Verwende isAdmin aus Permissions, oder Fallback auf user.isAdmin aus AuthContext
|
||||||
const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
|
// 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 (
|
return (
|
||||||
<Navigate
|
<div>
|
||||||
to="/"
|
<div className="mb-6 p-4 rounded-lg border bg-yellow-500/20 border-yellow-500/50">
|
||||||
replace
|
<div className="flex items-start gap-3">
|
||||||
state={{
|
<div className="text-2xl flex-shrink-0 text-yellow-400">⚠️</div>
|
||||||
message: "Sie sind keiner Berechtigungsgruppe zugewiesen. Bitte kontaktieren Sie einen Administrator.",
|
<div className="flex-1">
|
||||||
type: "warning"
|
<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 AppContent = () => {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true)
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-gradient-to-r from-slate-700 to-slate-900">
|
<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">
|
<div className="flex-1">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<PublicRoute><Login /></PublicRoute>} />
|
<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" element={<GroupRequiredRoute><Spaces /></GroupRequiredRoute>} />
|
||||||
<Route path="/spaces/:id" element={<GroupRequiredRoute><SpaceDetail /></GroupRequiredRoute>} />
|
<Route path="/spaces/:id" element={<GroupRequiredRoute><SpaceDetail /></GroupRequiredRoute>} />
|
||||||
<Route path="/impressum" element={<GroupRequiredRoute><Impressum /></GroupRequiredRoute>} />
|
<Route path="/impressum" element={<GroupRequiredRoute><Impressum /></GroupRequiredRoute>} />
|
||||||
@@ -171,7 +354,9 @@ function App() {
|
|||||||
<Router>
|
<Router>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<PermissionsProvider>
|
<PermissionsProvider>
|
||||||
<AppContent />
|
<ToastProvider>
|
||||||
|
<AppContent />
|
||||||
|
</ToastProvider>
|
||||||
</PermissionsProvider>
|
</PermissionsProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</Router>
|
</Router>
|
||||||
|
|||||||
@@ -13,14 +13,24 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
|||||||
// Prüfe ob User Berechtigungsgruppen hat
|
// Prüfe ob User Berechtigungsgruppen hat
|
||||||
const hasGroups = isAdmin || hasFullAccess || (accessibleSpaces && accessibleSpaces.length > 0)
|
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 = [
|
const menuItems = [
|
||||||
{ path: '/', label: 'Home', icon: '🏠', alwaysVisible: true },
|
|
||||||
{ path: '/spaces', label: 'Spaces', icon: '📁', requiresGroups: true },
|
{ path: '/spaces', label: 'Spaces', icon: '📁', requiresGroups: true },
|
||||||
{ path: '/audit-logs', label: 'Audit Log', icon: '📋', requiresGroups: true },
|
{ path: '/audit-logs', label: 'Audit Log', icon: '📋', requiresGroups: true },
|
||||||
{ path: '/impressum', label: 'Impressum', icon: 'ℹ️', requiresGroups: true },
|
{ path: '/impressum', label: 'Impressum', icon: 'ℹ️', requiresGroups: true },
|
||||||
].filter(item => item.alwaysVisible || !item.requiresGroups || hasGroups)
|
].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
|
// Settings mit Unterpunkten
|
||||||
const settingsMenu = {
|
const settingsMenu = {
|
||||||
label: 'Settings',
|
label: 'Settings',
|
||||||
@@ -53,7 +63,7 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
|||||||
return expandedMenus[menuPath] || false
|
return expandedMenus[menuPath] || false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatisch Settings-Menü expandieren, wenn auf einer Settings-Seite
|
// Automatisch Menüs expandieren, wenn auf einer entsprechenden Seite
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (location.pathname.startsWith('/settings')) {
|
if (location.pathname.startsWith('/settings')) {
|
||||||
setExpandedMenus(prev => ({
|
setExpandedMenus(prev => ({
|
||||||
@@ -61,6 +71,12 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
|||||||
'/settings': true
|
'/settings': true
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
if (location.pathname === '/queue' || location.pathname === '/') {
|
||||||
|
setExpandedMenus(prev => ({
|
||||||
|
...prev,
|
||||||
|
'/': true
|
||||||
|
}))
|
||||||
|
}
|
||||||
}, [location.pathname])
|
}, [location.pathname])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -112,6 +128,82 @@ const Sidebar = ({ isOpen, setIsOpen }) => {
|
|||||||
</div>
|
</div>
|
||||||
<nav className="px-2 py-4 overflow-hidden flex flex-col h-[calc(100%-4rem)]">
|
<nav className="px-2 py-4 overflow-hidden flex flex-col h-[calc(100%-4rem)]">
|
||||||
<ul className="space-y-2 flex-1">
|
<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) => (
|
{menuItems.map((item) => (
|
||||||
<li key={item.path}>
|
<li key={item.path}>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
75
frontend/src/components/Toast.jsx
Normal file
75
frontend/src/components/Toast.jsx
Normal 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
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@ export const PermissionsProvider = ({ children }) => {
|
|||||||
canUploadCSR: {},
|
canUploadCSR: {},
|
||||||
canSignCSR: {},
|
canSignCSR: {},
|
||||||
})
|
})
|
||||||
|
// Initial loading state: true wenn authentifiziert (weil Permissions noch nicht geladen sind)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const intervalRef = useRef(null)
|
const intervalRef = useRef(null)
|
||||||
const isMountedRef = useRef(true)
|
const isMountedRef = useRef(true)
|
||||||
@@ -37,6 +38,14 @@ export const PermissionsProvider = ({ children }) => {
|
|||||||
if (response.ok && isMountedRef.current) {
|
if (response.ok && isMountedRef.current) {
|
||||||
try {
|
try {
|
||||||
const data = await response.json()
|
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
|
// Nur Permissions aktualisieren, wenn Daten erfolgreich geparst wurden
|
||||||
setPermissions({
|
setPermissions({
|
||||||
isAdmin: data.isAdmin || false,
|
isAdmin: data.isAdmin || false,
|
||||||
@@ -49,18 +58,36 @@ export const PermissionsProvider = ({ children }) => {
|
|||||||
canUploadCSR: data.permissions?.canUploadCSR || {},
|
canUploadCSR: data.permissions?.canUploadCSR || {},
|
||||||
canSignCSR: data.permissions?.canSignCSR || {},
|
canSignCSR: data.permissions?.canSignCSR || {},
|
||||||
})
|
})
|
||||||
|
// Setze loading nur auf false, wenn Permissions erfolgreich geladen wurden
|
||||||
|
if (isInitial && isMountedRef.current) {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
} catch (parseErr) {
|
} catch (parseErr) {
|
||||||
console.error('Error parsing permissions response:', parseErr)
|
console.error('Error parsing permissions response:', parseErr)
|
||||||
// Bei Parse-Fehler Permissions nicht zurücksetzen, nur loggen
|
// 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) {
|
} else if (response.status === 401 && isMountedRef.current) {
|
||||||
// Bei 401 Unauthorized werden Permissions zurückgesetzt (wird von AuthContext gehandelt)
|
// Bei 401 Unauthorized werden Permissions zurückgesetzt (wird von AuthContext gehandelt)
|
||||||
console.log('Unauthorized - permissions will be cleared by auth context')
|
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) {
|
} catch (err) {
|
||||||
console.error('Error fetching permissions:', err)
|
console.error('Error fetching permissions:', err)
|
||||||
// Bei Netzwerkfehlern etc. Permissions nicht zurücksetzen
|
// Bei Netzwerkfehlern etc. Permissions nicht zurücksetzen
|
||||||
} finally {
|
// Setze loading auf false, damit die App nicht hängt
|
||||||
if (isInitial && isMountedRef.current) {
|
if (isInitial && isMountedRef.current) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -70,6 +97,8 @@ export const PermissionsProvider = ({ children }) => {
|
|||||||
// Initiales Laden der Permissions
|
// Initiales Laden der Permissions
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
|
// Setze loading auf true, bevor Permissions geladen werden
|
||||||
|
setLoading(true)
|
||||||
fetchPermissions(true)
|
fetchPermissions(true)
|
||||||
} else {
|
} else {
|
||||||
setPermissions({
|
setPermissions({
|
||||||
|
|||||||
98
frontend/src/contexts/ToastContext.jsx
Normal file
98
frontend/src/contexts/ToastContext.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,78 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
// Custom Dropdown Component
|
||||||
|
const CustomDropdown = ({ label, value, onChange, options, placeholder = "Auswählen" }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const dropdownRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const selectedOption = options.find(opt => opt.value === value) || { label: placeholder }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 uppercase tracking-wider mb-3">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-full px-4 py-3 bg-slate-700/50 hover:bg-slate-700/70 active:bg-slate-700 text-white rounded-lg border border-slate-600/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all duration-200 text-sm flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<span className={!value ? 'text-slate-400' : 'text-white'}>{selectedOption.label}</span>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 text-slate-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute z-50 w-full mt-2 bg-slate-800/95 backdrop-blur-sm border border-slate-600/50 rounded-lg shadow-2xl overflow-hidden">
|
||||||
|
<div className="max-h-60 overflow-y-auto custom-scrollbar">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option.value)
|
||||||
|
setIsOpen(false)
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2.5 text-left text-sm transition-all duration-150 ${
|
||||||
|
value === option.value
|
||||||
|
? 'bg-blue-600/20 text-blue-300 border-l-2 border-blue-500 font-medium'
|
||||||
|
: 'text-slate-300 hover:bg-slate-700/50 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const AuditLogs = () => {
|
const AuditLogs = () => {
|
||||||
const { authFetch } = useAuth()
|
const { authFetch } = useAuth()
|
||||||
const [logs, setLogs] = useState([])
|
const [logs, setLogs] = useState([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||||
|
const [lastUpdate, setLastUpdate] = useState(null)
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState({
|
||||||
action: '',
|
action: '',
|
||||||
resourceType: '',
|
resourceType: '',
|
||||||
@@ -23,6 +90,7 @@ const AuditLogs = () => {
|
|||||||
try {
|
try {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setIsRefreshing(true)
|
||||||
}
|
}
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
@@ -41,9 +109,8 @@ const AuditLogs = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
console.log('Audit-Logs Response:', data)
|
|
||||||
console.log('Anzahl Logs:', data.logs?.length || 0)
|
|
||||||
setLogs(data.logs || [])
|
setLogs(data.logs || [])
|
||||||
|
setLastUpdate(new Date())
|
||||||
setPagination({
|
setPagination({
|
||||||
...pagination,
|
...pagination,
|
||||||
total: data.total || 0,
|
total: data.total || 0,
|
||||||
@@ -57,6 +124,7 @@ const AuditLogs = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
setIsRefreshing(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -72,7 +140,7 @@ const AuditLogs = () => {
|
|||||||
}, 5000) // Aktualisiere alle 5 Sekunden
|
}, 5000) // Aktualisiere alle 5 Sekunden
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [filters.action, filters.resourceType, filters.userId, pagination.offset])
|
}, [filters.action, filters.resourceType, filters.userId, pagination.offset, authFetch])
|
||||||
|
|
||||||
const handleFilterChange = (key, value) => {
|
const handleFilterChange = (key, value) => {
|
||||||
setFilters({ ...filters, [key]: value })
|
setFilters({ ...filters, [key]: value })
|
||||||
@@ -188,6 +256,18 @@ const AuditLogs = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatLastUpdate = () => {
|
||||||
|
if (!lastUpdate) return ''
|
||||||
|
const now = new Date()
|
||||||
|
const diff = Math.floor((now - lastUpdate) / 1000)
|
||||||
|
if (diff < 5) return 'Gerade eben'
|
||||||
|
if (diff < 60) return `Vor ${diff} Sekunden`
|
||||||
|
const minutes = Math.floor(diff / 60)
|
||||||
|
return `Vor ${minutes} Minute${minutes > 1 ? 'n' : ''}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveFilters = filters.action || filters.resourceType || filters.userId
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 p-6">
|
<div className="min-h-screen bg-gradient-to-r from-slate-700 to-slate-900 p-6">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto">
|
||||||
@@ -196,55 +276,80 @@ const AuditLogs = () => {
|
|||||||
<h1 className="text-3xl font-bold text-white mb-2">Audit Log</h1>
|
<h1 className="text-3xl font-bold text-white mb-2">Audit Log</h1>
|
||||||
<p className="text-slate-300">Übersicht aller Systemaktivitäten und Änderungen</p>
|
<p className="text-slate-300">Übersicht aller Systemaktivitäten und Änderungen</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
|
{isRefreshing && (
|
||||||
<span>Live-Aktualisierung aktiv</span>
|
<div className="flex items-center gap-2 text-blue-400">
|
||||||
|
<svg className="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">Aktualisiere...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-500/20 border border-green-500/50 rounded-full">
|
||||||
|
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse"></div>
|
||||||
|
<span className="text-sm text-green-300 font-medium">Live</span>
|
||||||
|
</div>
|
||||||
|
{lastUpdate && (
|
||||||
|
<div className="text-sm text-slate-400">
|
||||||
|
{formatLastUpdate()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter */}
|
{/* Filter */}
|
||||||
<div className="bg-slate-800/50 border border-slate-600/50 rounded-lg p-4 mb-6">
|
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<h2 className="text-lg font-semibold text-white">Filter</h2>
|
||||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
{hasActiveFilters && (
|
||||||
Aktion
|
<button
|
||||||
</label>
|
onClick={() => {
|
||||||
<select
|
setFilters({ action: '', resourceType: '', userId: '' })
|
||||||
value={filters.action}
|
setPagination({ ...pagination, offset: 0 })
|
||||||
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"
|
className="flex items-center gap-2 px-3 py-1.5 bg-slate-700/50 hover:bg-slate-700 text-slate-300 rounded-lg transition-colors duration-200 text-sm"
|
||||||
>
|
>
|
||||||
<option value="">Alle Aktionen</option>
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<option value="CREATE">Erstellt</option>
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
<option value="UPDATE">Aktualisiert</option>
|
</svg>
|
||||||
<option value="DELETE">Gelöscht</option>
|
Zurücksetzen
|
||||||
<option value="UPLOAD">Hochgeladen</option>
|
</button>
|
||||||
<option value="SIGN">Signiert</option>
|
)}
|
||||||
<option value="ENABLE">Aktiviert</option>
|
</div>
|
||||||
<option value="DISABLE">Deaktiviert</option>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
</select>
|
<CustomDropdown
|
||||||
</div>
|
label="Aktion"
|
||||||
<div>
|
value={filters.action}
|
||||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
onChange={(value) => handleFilterChange('action', value)}
|
||||||
Ressourcentyp
|
options={[
|
||||||
</label>
|
{ value: '', label: 'Alle Aktionen' },
|
||||||
<select
|
{ value: 'CREATE', label: 'Erstellt' },
|
||||||
value={filters.resourceType}
|
{ value: 'UPDATE', label: 'Aktualisiert' },
|
||||||
onChange={(e) => handleFilterChange('resourceType', e.target.value)}
|
{ value: 'DELETE', label: 'Gelöscht' },
|
||||||
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"
|
{ value: 'UPLOAD', label: 'Hochgeladen' },
|
||||||
>
|
{ value: 'SIGN', label: 'Signiert' },
|
||||||
<option value="">Alle Typen</option>
|
{ value: 'ENABLE', label: 'Aktiviert' },
|
||||||
<option value="user">Benutzer</option>
|
{ value: 'DISABLE', label: 'Deaktiviert' }
|
||||||
<option value="space">Space</option>
|
]}
|
||||||
<option value="fqdn">FQDN</option>
|
/>
|
||||||
<option value="csr">CSR</option>
|
<CustomDropdown
|
||||||
<option value="provider">Provider</option>
|
label="Ressourcentyp"
|
||||||
<option value="certificate">Zertifikat</option>
|
value={filters.resourceType}
|
||||||
<option value="permission_group">Berechtigungsgruppen</option>
|
onChange={(value) => handleFilterChange('resourceType', value)}
|
||||||
</select>
|
options={[
|
||||||
</div>
|
{ value: '', label: 'Alle Typen' },
|
||||||
<div>
|
{ value: 'user', label: 'Benutzer' },
|
||||||
<label className="block text-sm font-medium text-slate-200 mb-2">
|
{ 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
|
Benutzer-ID
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -252,7 +357,7 @@ const AuditLogs = () => {
|
|||||||
value={filters.userId}
|
value={filters.userId}
|
||||||
onChange={(e) => handleFilterChange('userId', e.target.value)}
|
onChange={(e) => handleFilterChange('userId', e.target.value)}
|
||||||
placeholder="Benutzer-ID filtern"
|
placeholder="Benutzer-ID filtern"
|
||||||
className="w-full px-4 py-2 bg-slate-700/50 border border-slate-600 rounded-lg text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full px-4 py-3 bg-slate-700/50 hover:bg-slate-700/70 text-white rounded-lg border border-slate-600/50 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500/50 transition-all duration-200 text-sm placeholder-slate-400"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
379
frontend/src/pages/RenewalQueue.jsx
Normal file
379
frontend/src/pages/RenewalQueue.jsx
Normal 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
@@ -208,66 +208,138 @@ const Spaces = () => {
|
|||||||
|
|
||||||
{/* Create Space Form */}
|
{/* Create Space Form */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<div className="bg-slate-800/80 backdrop-blur-sm rounded-lg shadow-xl border border-slate-600/50 p-6 mb-6">
|
<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">
|
||||||
<h2 className="text-2xl font-semibold text-white mb-4">
|
{/* Decorative background elements */}
|
||||||
Neuen Space erstellen
|
<div className="absolute top-0 right-0 w-64 h-64 bg-emerald-500/10 rounded-full blur-3xl -mr-32 -mt-32"></div>
|
||||||
</h2>
|
<div className="absolute bottom-0 left-0 w-48 h-48 bg-teal-500/10 rounded-full blur-3xl -ml-24 -mb-24"></div>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div>
|
<div className="relative z-10">
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-slate-200 mb-2">
|
{/* Header with Icon */}
|
||||||
Name *
|
<div className="flex items-center mb-6">
|
||||||
</label>
|
<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">
|
||||||
<input
|
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
type="text"
|
<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" />
|
||||||
id="name"
|
</svg>
|
||||||
name="name"
|
</div>
|
||||||
value={formData.name}
|
<div>
|
||||||
onChange={handleChange}
|
<h2 className="text-2xl font-bold text-white mb-1">
|
||||||
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"
|
Neuen Space erstellen
|
||||||
placeholder="Geben Sie einen Namen ein"
|
</h2>
|
||||||
required
|
<p className="text-sm text-slate-400">
|
||||||
/>
|
Erstellen Sie einen neuen Arbeitsbereich für Ihre Zertifikate
|
||||||
</div>
|
</p>
|
||||||
<div>
|
|
||||||
<label htmlFor="description" className="block text-sm font-medium text-slate-200 mb-2">
|
|
||||||
Beschreibung
|
|
||||||
</label>
|
|
||||||
<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)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-red-300 text-sm">
|
|
||||||
{error}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
{loading ? 'Wird erstellt...' : 'Space erstellen'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowForm(false)
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
|
{/* 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-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>
|
||||||
|
|
||||||
|
{/* 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-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-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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
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 ? (
|
||||||
|
<>
|
||||||
|
<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"
|
||||||
|
onClick={() => {
|
||||||
|
setShowForm(false)
|
||||||
|
setFormData({ name: '', description: '' })
|
||||||
|
setError('')
|
||||||
|
}}
|
||||||
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user