implemented LE and ACME and fixed some bugs

This commit is contained in:
2025-11-27 04:20:09 +01:00
parent ec1e0da9d5
commit 145dfd3d7c
36 changed files with 10583 additions and 1107 deletions

1411
backend/acme_client.go Normal file

File diff suppressed because it is too large Load Diff

65
backend/cert_logger.go Normal file
View File

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

160
backend/cert_parser.go Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -10,8 +10,9 @@ import (
// ProviderConfig enthält die Konfiguration eines Providers
type ProviderConfig struct {
Enabled bool `json:"enabled"`
Settings map[string]interface{} `json:"settings"`
Enabled bool `json:"enabled"`
AcmeReady bool `json:"acme_ready"`
Settings map[string]interface{} `json:"settings"`
}
// SignCSRResult enthält das Ergebnis einer CSR-Signierung
@@ -77,8 +78,9 @@ func (pm *ProviderManager) RegisterProvider(provider Provider) {
// Lade Konfiguration falls vorhanden
if pm.configs[providerID] == nil {
pm.configs[providerID] = &ProviderConfig{
Enabled: false,
Settings: make(map[string]interface{}),
Enabled: false,
AcmeReady: false,
Settings: make(map[string]interface{}),
}
}
}
@@ -110,8 +112,9 @@ func (pm *ProviderManager) GetProviderConfig(id string) (*ProviderConfig, error)
config, exists := pm.configs[id]
if !exists {
return &ProviderConfig{
Enabled: false,
Settings: make(map[string]interface{}),
Enabled: false,
AcmeReady: false,
Settings: make(map[string]interface{}),
}, nil
}
return config, nil
@@ -145,8 +148,9 @@ func (pm *ProviderManager) SetProviderEnabled(id string, enabled bool) error {
if pm.configs[id] == nil {
pm.configs[id] = &ProviderConfig{
Enabled: enabled,
Settings: make(map[string]interface{}),
Enabled: enabled,
AcmeReady: false,
Settings: make(map[string]interface{}),
}
} else {
pm.configs[id].Enabled = enabled

View File

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

189
backend/renewal_info.go Normal file
View File

@@ -0,0 +1,189 @@
package main
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"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 Let's Encrypt ab
func FetchRenewalInfo(certID string) (*RenewalInfoResponse, error) {
// Let's Encrypt RenewalInfo API URL (Staging)
url := fmt.Sprintf("https://acme-staging-v02.api.letsencrypt.org/acme/renewal-info/%s", certID)
// 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
renewalInfo, err := FetchRenewalInfo(certIDBase64)
if err != nil {
// Prüfe ob es ein Staging-Zertifikat ist (RenewalInfo nicht verfügbar)
if strings.Contains(err.Error(), "Staging-Zertifikate") {
log.Printf("RenewalInfo wird übersprungen - Staging-Zertifikat (CertID: %s)", certIDBase64)
return nil // Kein Fehler, einfach überspringen
}
return fmt.Errorf("fehler beim Abrufen der RenewalInfo: %v", err)
}
log.Printf("RenewalInfo erhalten: Suggested Window Start: %s, End: %s", renewalInfo.SuggestedWindow.Start, renewalInfo.SuggestedWindow.End)
// Parse Suggested Window Start
scheduledAt, err := time.Parse(time.RFC3339, renewalInfo.SuggestedWindow.Start)
if err != nil {
return fmt.Errorf("fehler beim Parsen des Scheduled-At-Datums: %v", err)
}
// Speichere renewal_scheduled_at in DB
scheduledAtStr := scheduledAt.UTC().Format("2006-01-02 15:04:05")
ctx, cancel = context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
_, err = db.ExecContext(ctx, "UPDATE certificates SET renewal_scheduled_at = ? WHERE id = ?", scheduledAtStr, certID)
if err != nil {
log.Printf("Warnung: Fehler beim Speichern von renewal_scheduled_at in DB: %v", err)
}
// Erstelle Queue-Eintrag
queueID := uuid.New().String()
createdAt := time.Now().UTC().Format("2006-01-02 15:04:05")
ctx, cancel = context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
_, err = db.ExecContext(ctx, `
INSERT INTO renewal_queue (id, certificate_id, fqdn_id, space_id, scheduled_at, status, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', ?)
`, queueID, certID, fqdnID, spaceID, scheduledAtStr, createdAt)
if err != nil {
return fmt.Errorf("fehler beim Erstellen des Queue-Eintrags: %v", err)
}
log.Printf("RenewalInfo verarbeitet und Queue-Eintrag erstellt (ID: %s, Scheduled: %s)", queueID, scheduledAtStr)
return nil
}

View File

@@ -0,0 +1,267 @@
package main
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gorilla/mux"
)
// updateFqdnRenewalEnabledHandler aktualisiert das renewal_enabled Flag für einen FQDN
func updateFqdnRenewalEnabledHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
vars := mux.Vars(r)
spaceID := vars["spaceId"]
fqdnID := vars["fqdnId"]
// Prüfe Berechtigung
userID, _ := getUserFromRequest(r)
if userID == "" {
http.Error(w, "Nicht authentifiziert", http.StatusUnauthorized)
return
}
hasAccess, err := hasSpaceAccess(userID, spaceID)
if err != nil {
http.Error(w, "Fehler beim Prüfen der Berechtigung", http.StatusInternalServerError)
log.Printf("Fehler beim Prüfen der Berechtigung: %v", err)
return
}
if !hasAccess {
http.Error(w, "Keine Berechtigung für diesen Space", http.StatusForbidden)
return
}
// Prüfe ob FQDN existiert und hole aktuellen renewal_enabled Status
var currentRenewalEnabled sql.NullInt64
err = db.QueryRow("SELECT renewal_enabled FROM fqdns WHERE id = ? AND space_id = ?", fqdnID, spaceID).Scan(&currentRenewalEnabled)
if err == sql.ErrNoRows {
http.Error(w, "FQDN nicht gefunden", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Fehler beim Laden des FQDN", http.StatusInternalServerError)
log.Printf("Fehler beim Laden des FQDN: %v", err)
return
}
// Parse Request Body
var req struct {
RenewalEnabled bool `json:"renewalEnabled"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Ungültige Anfrage", http.StatusBadRequest)
return
}
// Prüfe ob renewal_enabled von false auf true geändert wird
wasDisabled := !currentRenewalEnabled.Valid || currentRenewalEnabled.Int64 == 0
willBeEnabled := req.RenewalEnabled
shouldProcessRenewalInfo := wasDisabled && willBeEnabled
// Beginne Transaktion
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "Fehler beim Starten der Transaktion", http.StatusInternalServerError)
log.Printf("Fehler beim Starten der Transaktion: %v", err)
return
}
defer tx.Rollback()
// Update renewal_enabled (explizit als 0 oder 1 speichern, nicht NULL)
var renewalEnabledInt int
if req.RenewalEnabled {
renewalEnabledInt = 1
} else {
renewalEnabledInt = 0
}
_, err = tx.ExecContext(ctx, "UPDATE fqdns SET renewal_enabled = ? WHERE id = ? AND space_id = ?", renewalEnabledInt, fqdnID, spaceID)
if err != nil {
http.Error(w, "Fehler beim Aktualisieren des renewal_enabled Flags", http.StatusInternalServerError)
log.Printf("Fehler beim Aktualisieren des renewal_enabled Flags: %v", err)
return
}
// Wenn renewal_enabled deaktiviert wird, lösche alle Queue-Einträge für diesen FQDN
if !req.RenewalEnabled {
_, err = tx.ExecContext(ctx, "DELETE FROM renewal_queue WHERE fqdn_id = ?", fqdnID)
if err != nil {
http.Error(w, "Fehler beim Löschen der Queue-Einträge", http.StatusInternalServerError)
log.Printf("Fehler beim Löschen der Queue-Einträge: %v", err)
return
}
log.Printf("Queue-Einträge für FQDN %s gelöscht (renewal_enabled deaktiviert)", fqdnID)
}
// 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,
})
}

View File

@@ -0,0 +1,332 @@
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)
// Beantrage neues Zertifikat
baseFqdn := strings.TrimPrefix(fqdn.FQDN, "*.")
result, err := RequestCertificate(baseFqdn, fqdn.AcmeEmail, fqdnID, fqdn.AcmeKeyID, traceID, updateTokenFunc, cleanupTokenFunc, statusCallback)
if err != nil {
return fmt.Errorf("fehler beim Beantragen des neuen Zertifikats: %v", err)
}
// Speichere neues Zertifikat
if result.Certificate != "" && result.PrivateKey != "" {
newCertID := uuid.New().String()
certificateID := result.OrderURL
if certificateID == "" {
certificateID = newCertID
}
createdAt := time.Now().UTC().Format("2006-01-02 15:04:05")
expiresAt, isIntermediate, parseErr := ParseCertificate(result.Certificate)
var expiresAtStr string
var isIntermediateInt int
if parseErr == nil {
expiresAtStr = expiresAt.UTC().Format("2006-01-02 15:04:05")
if isIntermediate {
isIntermediateInt = 1
}
}
// Verwende separaten Context für INSERT mit Retry-Logik
var insertErr error
for retry := 0; retry < 3; retry++ {
insertCtx, insertCancel := context.WithTimeout(context.Background(), 10*time.Second)
_, insertErr = db.ExecContext(insertCtx, `
INSERT INTO certificates (id, fqdn_id, space_id, csr_id, certificate_id, provider_id, certificate_pem, private_key_pem, status, expires_at, is_intermediate, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, newCertID, fqdnID, spaceID, nil, certificateID, "certigo-acmeproxy", result.Certificate, result.PrivateKey, "issued", expiresAtStr, isIntermediateInt, createdAt)
insertCancel()
if insertErr == nil {
break
}
if retry < 2 {
time.Sleep(time.Second * time.Duration(retry+1))
}
}
if insertErr != nil {
return fmt.Errorf("fehler beim Speichern des neuen Zertifikats: %v", insertErr)
}
// Markiere altes Zertifikat (optional, falls gewünscht)
// Hier könnten wir z.B. den Status auf 'replaced' setzen
// Verarbeite RenewalInfo für das neue Zertifikat (falls aktiviert)
renewalEnabledValue := true
if renewalEnabled.Valid {
renewalEnabledValue = renewalEnabled.Int64 == 1
}
if renewalEnabledValue {
go func() {
if err := ProcessRenewalInfoForCertificate(result.Certificate, newCertID, fqdnID, spaceID, true); err != nil {
log.Printf("Fehler beim Verarbeiten der RenewalInfo für erneuertes Zertifikat (wird ignoriert): %v", err)
}
}()
}
log.Printf("Automatische Zertifikatserneuerung erfolgreich abgeschlossen (Queue ID: %s, Neues Cert ID: %s)", queueID, newCertID)
}
// Update KeyID falls geändert (mit Retry-Logik)
if result.KeyID != "" && result.KeyID != fqdn.AcmeKeyID {
var keyIDErr error
for retry := 0; retry < 3; retry++ {
keyIDCtx, keyIDCancel := context.WithTimeout(context.Background(), 10*time.Second)
_, keyIDErr = db.ExecContext(keyIDCtx, "UPDATE fqdns SET acme_key_id = ? WHERE id = ?", result.KeyID, fqdnID)
keyIDCancel()
if keyIDErr == nil {
break
}
if retry < 2 {
time.Sleep(time.Second * time.Duration(retry+1))
}
}
if keyIDErr != nil {
log.Printf("Warnung: Fehler beim Speichern der neuen KeyID nach 3 Versuchen: %v", keyIDErr)
}
}
return nil
}

View File

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

View File

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

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

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

View File

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