Files
Auto-ssl/backend/handlers/cert.go
T
cnbugs d0e738e1ef fix: use SQLite upsert to eliminate race condition on domain uniqueness constraint
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.
2026-05-12 15:22:30 +08:00

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
}