d0e738e1ef
Previously the CreateCertificate handler checked domain existence and then inserted separately, creating a race window where concurrent requests could both pass the check and trigger 'UNIQUE constraint failed' on INSERT. Now uses SQLite's native INSERT ... ON CONFLICT (upsert) which atomically handles the uniqueness constraint at the database level, eliminating the race condition entirely.
301 lines
8.4 KiB
Go
301 lines
8.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"auto-ssl/config"
|
|
"auto-ssl/services"
|
|
"fmt"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"strings"
|
|
)
|
|
|
|
type CertHandler struct {
|
|
Cfg *config.Config
|
|
}
|
|
|
|
func NewCertHandler(cfg *config.Config) *CertHandler {
|
|
return &CertHandler{Cfg: cfg}
|
|
}
|
|
|
|
// ListCertificates returns all certificates
|
|
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
|
|
}
|
|
c.JSON(http.StatusOK, certs)
|
|
}
|
|
|
|
// GetCertificate returns a single certificate
|
|
func (h *CertHandler) GetCertificate(c *gin.Context) {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
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
|
|
}
|
|
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
|
|
func (h *CertHandler) CreateCertificate(c *gin.Context) {
|
|
var req CreateCertRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if req.Provider == "" {
|
|
req.Provider = "letsencrypt"
|
|
}
|
|
if req.ChallengeType == "" {
|
|
req.ChallengeType = "http"
|
|
}
|
|
|
|
// Trim spaces from 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()})
|
|
return
|
|
}
|
|
|
|
// Reload to get the current state
|
|
var cert config.Certificate
|
|
config.DB.Where("domain = ?", req.Domain).First(&cert)
|
|
|
|
// 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})
|
|
return
|
|
}
|
|
|
|
// Start issuance in background
|
|
go func() {
|
|
if err := services.GetACMECertificate(&cert, h.Cfg); err != nil {
|
|
cert.Status = "error"
|
|
cert.ErrorMessage = err.Error()
|
|
} else {
|
|
cert.Status = "active"
|
|
}
|
|
config.DB.Save(&cert)
|
|
}()
|
|
|
|
c.JSON(http.StatusAccepted, gin.H{"message": "certificate issuance started", "certificate": cert})
|
|
}
|
|
|
|
// RenewCertificate manually renews a certificate
|
|
func (h *CertHandler) RenewCertificate(c *gin.Context) {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
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
|
|
}
|
|
|
|
cert.Status = "renewing"
|
|
config.DB.Save(&cert)
|
|
|
|
go func() {
|
|
if err := services.RenewCertificate(&cert, h.Cfg); err != nil {
|
|
cert.Status = "error"
|
|
cert.ErrorMessage = err.Error()
|
|
} else {
|
|
cert.Status = "active"
|
|
}
|
|
config.DB.Save(&cert)
|
|
}()
|
|
|
|
c.JSON(http.StatusAccepted, gin.H{"message": "renewal started", "certificate": cert})
|
|
}
|
|
|
|
// DeleteCertificate deletes a certificate record and files
|
|
func (h *CertHandler) DeleteCertificate(c *gin.Context) {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
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 {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
|
|
}
|
|
|
|
// UpdateCertificate updates certificate settings
|
|
func (h *CertHandler) UpdateCertificate(c *gin.Context) {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
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
|
|
}
|
|
|
|
var updates map[string]interface{}
|
|
if err := c.ShouldBindJSON(&updates); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Only allow updating certain fields
|
|
allowedFields := map[string]bool{
|
|
"auto_renew": true,
|
|
"renew_days": true,
|
|
"dns_config": true,
|
|
}
|
|
|
|
filtered := make(map[string]interface{})
|
|
for k, v := range updates {
|
|
if allowedFields[k] {
|
|
filtered[k] = v
|
|
}
|
|
}
|
|
|
|
if err := config.DB.Model(&cert).Updates(filtered).Error; err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, cert)
|
|
}
|
|
|
|
// GetCertFiles returns the content of certificate files for download
|
|
func (h *CertHandler) GetCertFiles(c *gin.Context) {
|
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
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
|
|
}
|
|
|
|
fullchain, privkey, chain := services.GetCertFilesPaths(cert.Domain, h.Cfg)
|
|
|
|
result := gin.H{
|
|
"domain": cert.Domain,
|
|
"fullchain": readFileSafe(fullchain),
|
|
"privkey": readFileSafe(privkey),
|
|
"chain": readFileSafe(chain),
|
|
}
|
|
c.JSON(http.StatusOK, result)
|
|
}
|
|
|
|
// CheckRenewals checks all certificates and renews those about to expire
|
|
func (h *CertHandler) CheckRenewals(c *gin.Context) {
|
|
var certs []config.Certificate
|
|
config.DB.Where("auto_renew = ? AND status = ?", true, "active").Find(&certs)
|
|
|
|
renewed := []string{}
|
|
failed := []string{}
|
|
|
|
for _, cert := range certs {
|
|
if cert.ExpiresAt != nil && time.Until(*cert.ExpiresAt).Hours() < float64(cert.RenewDays*24) {
|
|
if err := services.RenewCertificate(&cert, h.Cfg); err != nil {
|
|
cert.Status = "error"
|
|
cert.ErrorMessage = fmt.Sprintf("auto renew failed: %v", err)
|
|
failed = append(failed, cert.Domain)
|
|
} else {
|
|
cert.Status = "active"
|
|
renewed = append(renewed, cert.Domain)
|
|
}
|
|
config.DB.Save(&cert)
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"message": "renewal check complete",
|
|
"renewed": renewed,
|
|
"failed": failed,
|
|
})
|
|
}
|
|
|
|
// Stats returns dashboard statistics
|
|
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)
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"total": total,
|
|
"active": active,
|
|
"expired": expired,
|
|
"errors": errors,
|
|
})
|
|
}
|
|
|
|
func readFileSafe(path string) string {
|
|
data, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return data
|
|
}
|