implemented LE and ACME and fixed some bugs
This commit is contained in:
1411
backend/acme_client.go
Normal file
1411
backend/acme_client.go
Normal file
File diff suppressed because it is too large
Load Diff
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,
|
||||
"acme_ready": false,
|
||||
"settings": {
|
||||
"password": "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,
|
||||
"acme_ready": false,
|
||||
"settings": {}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"enabled": false,
|
||||
"acme_ready": false,
|
||||
"settings": {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
1707
backend/main.go
1707
backend/main.go
File diff suppressed because it is too large
Load Diff
1241
backend/openapi.yaml
1241
backend/openapi.yaml
File diff suppressed because it is too large
Load Diff
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.")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
189
backend/renewal_info.go
Normal 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
|
||||
}
|
||||
267
backend/renewal_queue_handlers.go
Normal file
267
backend/renewal_queue_handlers.go
Normal 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(¤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 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,
|
||||
})
|
||||
}
|
||||
|
||||
332
backend/renewal_scheduler.go
Normal file
332
backend/renewal_scheduler.go
Normal 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
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -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
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-%';")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user