|
@@ -7,10 +7,10 @@ import (
|
|
|
"net/http"
|
|
"net/http"
|
|
|
"path/filepath"
|
|
"path/filepath"
|
|
|
"strconv"
|
|
"strconv"
|
|
|
|
|
+ "strings"
|
|
|
"time"
|
|
"time"
|
|
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/gin-gonic/gin"
|
|
|
- "strings"
|
|
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
type CertHandler struct {
|
|
type CertHandler struct {
|
|
@@ -21,13 +21,20 @@ func NewCertHandler(cfg *config.Config) *CertHandler {
|
|
|
return &CertHandler{Cfg: cfg}
|
|
return &CertHandler{Cfg: cfg}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+type CreateCertRequest struct {
|
|
|
|
|
+ Domain string `json:"domain" binding:"required"`
|
|
|
|
|
+ Email string `json:"email" binding:"required"`
|
|
|
|
|
+ Provider string `json:"provider"`
|
|
|
|
|
+ ChallengeType string `json:"challenge_type"`
|
|
|
|
|
+ DNSProvider string `json:"dns_provider"`
|
|
|
|
|
+ DNSConfig string `json:"dns_config"`
|
|
|
|
|
+ AutoRenew *bool `json:"auto_renew"`
|
|
|
|
|
+ RenewDays *int `json:"renew_days"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// ListCertificates returns all certificates
|
|
// ListCertificates returns all certificates
|
|
|
func (h *CertHandler) ListCertificates(c *gin.Context) {
|
|
func (h *CertHandler) ListCertificates(c *gin.Context) {
|
|
|
- var certs []config.Certificate
|
|
|
|
|
- if err := config.DB.Order("created_at desc").Find(&certs).Error; err != nil {
|
|
|
|
|
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ certs := config.Store.GetAll()
|
|
|
c.JSON(http.StatusOK, certs)
|
|
c.JSON(http.StatusOK, certs)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -38,25 +45,14 @@ func (h *CertHandler) GetCertificate(c *gin.Context) {
|
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
- var cert config.Certificate
|
|
|
|
|
- if err := config.DB.First(&cert, id).Error; err != nil {
|
|
|
|
|
|
|
+ cert := config.Store.GetByID(uint(id))
|
|
|
|
|
+ if cert == nil {
|
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
c.JSON(http.StatusOK, cert)
|
|
c.JSON(http.StatusOK, cert)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-type CreateCertRequest struct {
|
|
|
|
|
- Domain string `json:"domain" binding:"required"`
|
|
|
|
|
- Email string `json:"email" binding:"required"`
|
|
|
|
|
- Provider string `json:"provider"` // letsencrypt, zerossl
|
|
|
|
|
- ChallengeType string `json:"challenge_type"` // http, dns
|
|
|
|
|
- DNSProvider string `json:"dns_provider"`
|
|
|
|
|
- DNSConfig string `json:"dns_config"` // JSON
|
|
|
|
|
- AutoRenew *bool `json:"auto_renew"`
|
|
|
|
|
- RenewDays *int `json:"renew_days"`
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
// CreateCertificate creates a new certificate entry and starts issuance
|
|
// CreateCertificate creates a new certificate entry and starts issuance
|
|
|
func (h *CertHandler) CreateCertificate(c *gin.Context) {
|
|
func (h *CertHandler) CreateCertificate(c *gin.Context) {
|
|
|
var req CreateCertRequest
|
|
var req CreateCertRequest
|
|
@@ -72,56 +68,81 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
|
|
|
req.ChallengeType = "http"
|
|
req.ChallengeType = "http"
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Trim spaces from domain
|
|
|
|
|
req.Domain = strings.TrimSpace(req.Domain)
|
|
req.Domain = strings.TrimSpace(req.Domain)
|
|
|
|
|
|
|
|
- // Use SQLite upsert (INSERT ... ON CONFLICT) to atomically handle domain uniqueness
|
|
|
|
|
- // This avoids the race condition between checking existence and inserting
|
|
|
|
|
- result := config.DB.Exec(`
|
|
|
|
|
- INSERT INTO certificates (domain, email, provider, challenge_type, dns_provider, dns_config, status, auto_renew, renew_days, created_at, updated_at)
|
|
|
|
|
- VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, datetime('now'), datetime('now'))
|
|
|
|
|
- ON CONFLICT(domain) DO UPDATE SET
|
|
|
|
|
- email = excluded.email,
|
|
|
|
|
- provider = excluded.provider,
|
|
|
|
|
- challenge_type = excluded.challenge_type,
|
|
|
|
|
- dns_provider = excluded.dns_provider,
|
|
|
|
|
- dns_config = excluded.dns_config,
|
|
|
|
|
- status = CASE
|
|
|
|
|
- WHEN status IN ('error', 'expired', 'pending') THEN 'pending'
|
|
|
|
|
- ELSE status
|
|
|
|
|
- END,
|
|
|
|
|
- error_message = CASE
|
|
|
|
|
- WHEN status IN ('error', 'expired', 'pending') THEN ''
|
|
|
|
|
- ELSE error_message
|
|
|
|
|
- END,
|
|
|
|
|
- updated_at = datetime('now')
|
|
|
|
|
- WHERE excluded.domain = certificates.domain
|
|
|
|
|
- `, req.Domain, req.Email, req.Provider, req.ChallengeType, req.DNSProvider, req.DNSConfig, req.AutoRenew != nil && *req.AutoRenew, req.RenewDays)
|
|
|
|
|
-
|
|
|
|
|
- if result.Error != nil {
|
|
|
|
|
- c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
|
|
|
|
|
|
|
+ autoRenew := true
|
|
|
|
|
+ renewDays := 30
|
|
|
|
|
+ if req.AutoRenew != nil {
|
|
|
|
|
+ autoRenew = *req.AutoRenew
|
|
|
|
|
+ }
|
|
|
|
|
+ if req.RenewDays != nil {
|
|
|
|
|
+ renewDays = *req.RenewDays
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ existing := config.Store.GetByDomain(req.Domain)
|
|
|
|
|
+ if existing != nil {
|
|
|
|
|
+ // Domain exists: only retry if it's in a retryable state
|
|
|
|
|
+ if existing.Status == "error" || existing.Status == "expired" || existing.Status == "pending" {
|
|
|
|
|
+ existing.Email = req.Email
|
|
|
|
|
+ existing.Provider = req.Provider
|
|
|
|
|
+ existing.ChallengeType = req.ChallengeType
|
|
|
|
|
+ existing.DNSProvider = req.DNSProvider
|
|
|
|
|
+ existing.DNSConfig = req.DNSConfig
|
|
|
|
|
+ existing.Status = "pending"
|
|
|
|
|
+ existing.ErrorMessage = ""
|
|
|
|
|
+ existing.AutoRenew = autoRenew
|
|
|
|
|
+ existing.RenewDays = renewDays
|
|
|
|
|
+ config.Store.Upsert(existing)
|
|
|
|
|
+
|
|
|
|
|
+ // Start issuance in background
|
|
|
|
|
+ go func() {
|
|
|
|
|
+ domain := existing.Domain
|
|
|
|
|
+ saved := config.Store.GetByDomain(domain)
|
|
|
|
|
+ if err := services.GetACMECertificate(saved, h.Cfg); err != nil {
|
|
|
|
|
+ saved.Status = "error"
|
|
|
|
|
+ saved.ErrorMessage = err.Error()
|
|
|
|
|
+ } else {
|
|
|
|
|
+ saved.Status = "active"
|
|
|
|
|
+ }
|
|
|
|
|
+ config.Store.Upsert(saved)
|
|
|
|
|
+ }()
|
|
|
|
|
+
|
|
|
|
|
+ c.JSON(http.StatusAccepted, gin.H{"message": "certificate re-issuance started", "certificate": existing})
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+ c.JSON(http.StatusConflict, gin.H{"error": "domain already exists with status: " + existing.Status})
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Reload to get the current state
|
|
|
|
|
- var cert config.Certificate
|
|
|
|
|
- config.DB.Where("domain = ?", req.Domain).First(&cert)
|
|
|
|
|
|
|
+ // Create new certificate
|
|
|
|
|
+ cert := &config.Certificate{
|
|
|
|
|
+ Domain: req.Domain,
|
|
|
|
|
+ Email: req.Email,
|
|
|
|
|
+ Provider: req.Provider,
|
|
|
|
|
+ ChallengeType: req.ChallengeType,
|
|
|
|
|
+ DNSProvider: req.DNSProvider,
|
|
|
|
|
+ DNSConfig: req.DNSConfig,
|
|
|
|
|
+ Status: "pending",
|
|
|
|
|
+ AutoRenew: autoRenew,
|
|
|
|
|
+ RenewDays: renewDays,
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
- // If domain already existed and was active, return conflict
|
|
|
|
|
- if cert.Status != "pending" {
|
|
|
|
|
- c.JSON(http.StatusConflict, gin.H{"error": "domain already exists with status: " + cert.Status})
|
|
|
|
|
|
|
+ if err := config.Store.Upsert(cert); err != nil {
|
|
|
|
|
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// Start issuance in background
|
|
// Start issuance in background
|
|
|
go func() {
|
|
go func() {
|
|
|
- if err := services.GetACMECertificate(&cert, h.Cfg); err != nil {
|
|
|
|
|
- cert.Status = "error"
|
|
|
|
|
- cert.ErrorMessage = err.Error()
|
|
|
|
|
|
|
+ domain := cert.Domain
|
|
|
|
|
+ saved := config.Store.GetByDomain(domain)
|
|
|
|
|
+ if err := services.GetACMECertificate(saved, h.Cfg); err != nil {
|
|
|
|
|
+ saved.Status = "error"
|
|
|
|
|
+ saved.ErrorMessage = err.Error()
|
|
|
} else {
|
|
} else {
|
|
|
- cert.Status = "active"
|
|
|
|
|
|
|
+ saved.Status = "active"
|
|
|
}
|
|
}
|
|
|
- config.DB.Save(&cert)
|
|
|
|
|
|
|
+ config.Store.Upsert(saved)
|
|
|
}()
|
|
}()
|
|
|
|
|
|
|
|
c.JSON(http.StatusAccepted, gin.H{"message": "certificate issuance started", "certificate": cert})
|
|
c.JSON(http.StatusAccepted, gin.H{"message": "certificate issuance started", "certificate": cert})
|
|
@@ -135,23 +156,25 @@ func (h *CertHandler) RenewCertificate(c *gin.Context) {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- var cert config.Certificate
|
|
|
|
|
- if err := config.DB.First(&cert, id).Error; err != nil {
|
|
|
|
|
|
|
+ cert := config.Store.GetByID(uint(id))
|
|
|
|
|
+ if cert == nil {
|
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
cert.Status = "renewing"
|
|
cert.Status = "renewing"
|
|
|
- config.DB.Save(&cert)
|
|
|
|
|
|
|
+ config.Store.Upsert(cert)
|
|
|
|
|
|
|
|
go func() {
|
|
go func() {
|
|
|
- if err := services.RenewCertificate(&cert, h.Cfg); err != nil {
|
|
|
|
|
- cert.Status = "error"
|
|
|
|
|
- cert.ErrorMessage = err.Error()
|
|
|
|
|
|
|
+ domain := cert.Domain
|
|
|
|
|
+ saved := config.Store.GetByDomain(domain)
|
|
|
|
|
+ if err := services.RenewCertificate(saved, h.Cfg); err != nil {
|
|
|
|
|
+ saved.Status = "error"
|
|
|
|
|
+ saved.ErrorMessage = err.Error()
|
|
|
} else {
|
|
} else {
|
|
|
- cert.Status = "active"
|
|
|
|
|
|
|
+ saved.Status = "active"
|
|
|
}
|
|
}
|
|
|
- config.DB.Save(&cert)
|
|
|
|
|
|
|
+ config.Store.Upsert(saved)
|
|
|
}()
|
|
}()
|
|
|
|
|
|
|
|
c.JSON(http.StatusAccepted, gin.H{"message": "renewal started", "certificate": cert})
|
|
c.JSON(http.StatusAccepted, gin.H{"message": "renewal started", "certificate": cert})
|
|
@@ -165,13 +188,7 @@ func (h *CertHandler) DeleteCertificate(c *gin.Context) {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- var cert config.Certificate
|
|
|
|
|
- if err := config.DB.First(&cert, id).Error; err != nil {
|
|
|
|
|
- c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
|
|
|
- return
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if err := config.DB.Delete(&cert).Error; err != nil {
|
|
|
|
|
|
|
+ if err := config.Store.Delete(uint(id)); err != nil {
|
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -187,8 +204,8 @@ func (h *CertHandler) UpdateCertificate(c *gin.Context) {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- var cert config.Certificate
|
|
|
|
|
- if err := config.DB.First(&cert, id).Error; err != nil {
|
|
|
|
|
|
|
+ cert := config.Store.GetByID(uint(id))
|
|
|
|
|
+ if cert == nil {
|
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -199,25 +216,19 @@ func (h *CertHandler) UpdateCertificate(c *gin.Context) {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // Only allow updating certain fields
|
|
|
|
|
- allowedFields := map[string]bool{
|
|
|
|
|
- "auto_renew": true,
|
|
|
|
|
- "renew_days": true,
|
|
|
|
|
- "dns_config": true,
|
|
|
|
|
|
|
+ if v, ok := updates["auto_renew"]; ok {
|
|
|
|
|
+ cert.AutoRenew, _ = v.(bool)
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- filtered := make(map[string]interface{})
|
|
|
|
|
- for k, v := range updates {
|
|
|
|
|
- if allowedFields[k] {
|
|
|
|
|
- filtered[k] = v
|
|
|
|
|
|
|
+ if v, ok := updates["renew_days"]; ok {
|
|
|
|
|
+ if f, ok := v.(float64); ok {
|
|
|
|
|
+ cert.RenewDays = int(f)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
-
|
|
|
|
|
- if err := config.DB.Model(&cert).Updates(filtered).Error; err != nil {
|
|
|
|
|
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
|
|
|
- return
|
|
|
|
|
|
|
+ if v, ok := updates["dns_config"]; ok {
|
|
|
|
|
+ cert.DNSConfig, _ = v.(string)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ config.Store.Upsert(cert)
|
|
|
c.JSON(http.StatusOK, cert)
|
|
c.JSON(http.StatusOK, cert)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -229,8 +240,8 @@ func (h *CertHandler) GetCertFiles(c *gin.Context) {
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- var cert config.Certificate
|
|
|
|
|
- if err := config.DB.First(&cert, id).Error; err != nil {
|
|
|
|
|
|
|
+ cert := config.Store.GetByID(uint(id))
|
|
|
|
|
+ if cert == nil {
|
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
@@ -248,15 +259,14 @@ func (h *CertHandler) GetCertFiles(c *gin.Context) {
|
|
|
|
|
|
|
|
// CheckRenewals checks all certificates and renews those about to expire
|
|
// CheckRenewals checks all certificates and renews those about to expire
|
|
|
func (h *CertHandler) CheckRenewals(c *gin.Context) {
|
|
func (h *CertHandler) CheckRenewals(c *gin.Context) {
|
|
|
- var certs []config.Certificate
|
|
|
|
|
- config.DB.Where("auto_renew = ? AND status = ?", true, "active").Find(&certs)
|
|
|
|
|
|
|
+ certs := config.Store.GetActiveWithAutoRenew()
|
|
|
|
|
|
|
|
renewed := []string{}
|
|
renewed := []string{}
|
|
|
failed := []string{}
|
|
failed := []string{}
|
|
|
|
|
|
|
|
for _, cert := range certs {
|
|
for _, cert := range certs {
|
|
|
if cert.ExpiresAt != nil && time.Until(*cert.ExpiresAt).Hours() < float64(cert.RenewDays*24) {
|
|
if cert.ExpiresAt != nil && time.Until(*cert.ExpiresAt).Hours() < float64(cert.RenewDays*24) {
|
|
|
- if err := services.RenewCertificate(&cert, h.Cfg); err != nil {
|
|
|
|
|
|
|
+ if err := services.RenewCertificate(cert, h.Cfg); err != nil {
|
|
|
cert.Status = "error"
|
|
cert.Status = "error"
|
|
|
cert.ErrorMessage = fmt.Sprintf("auto renew failed: %v", err)
|
|
cert.ErrorMessage = fmt.Sprintf("auto renew failed: %v", err)
|
|
|
failed = append(failed, cert.Domain)
|
|
failed = append(failed, cert.Domain)
|
|
@@ -264,7 +274,7 @@ func (h *CertHandler) CheckRenewals(c *gin.Context) {
|
|
|
cert.Status = "active"
|
|
cert.Status = "active"
|
|
|
renewed = append(renewed, cert.Domain)
|
|
renewed = append(renewed, cert.Domain)
|
|
|
}
|
|
}
|
|
|
- config.DB.Save(&cert)
|
|
|
|
|
|
|
+ config.Store.Upsert(cert)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -277,11 +287,20 @@ func (h *CertHandler) CheckRenewals(c *gin.Context) {
|
|
|
|
|
|
|
|
// Stats returns dashboard statistics
|
|
// Stats returns dashboard statistics
|
|
|
func (h *CertHandler) Stats(c *gin.Context) {
|
|
func (h *CertHandler) Stats(c *gin.Context) {
|
|
|
- var total, active, expired, errors int64
|
|
|
|
|
- config.DB.Model(&config.Certificate{}).Count(&total)
|
|
|
|
|
- config.DB.Model(&config.Certificate{}).Where("status = ?", "active").Count(&active)
|
|
|
|
|
- config.DB.Model(&config.Certificate{}).Where("status = ?", "expired").Count(&expired)
|
|
|
|
|
- config.DB.Model(&config.Certificate{}).Where("status = ?", "error").Count(&errors)
|
|
|
|
|
|
|
+ certs := config.Store.GetAll()
|
|
|
|
|
+
|
|
|
|
|
+ var total, active, expired, errors int
|
|
|
|
|
+ for _, cert := range certs {
|
|
|
|
|
+ total++
|
|
|
|
|
+ switch cert.Status {
|
|
|
|
|
+ case "active":
|
|
|
|
|
+ active++
|
|
|
|
|
+ case "expired":
|
|
|
|
|
+ expired++
|
|
|
|
|
+ case "error":
|
|
|
|
|
+ errors++
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
c.JSON(http.StatusOK, gin.H{
|
|
|
"total": total,
|
|
"total": total,
|