Files
certigo/backend/renewal_scheduler.go

351 lines
12 KiB
Go

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
}