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.
This commit is contained in:
+32
-58
@@ -75,67 +75,41 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
|
|||||||
// Trim spaces from domain
|
// Trim spaces from domain
|
||||||
req.Domain = strings.TrimSpace(req.Domain)
|
req.Domain = strings.TrimSpace(req.Domain)
|
||||||
|
|
||||||
// Check if domain already exists
|
// Use SQLite upsert (INSERT ... ON CONFLICT) to atomically handle domain uniqueness
|
||||||
var existing config.Certificate
|
// This avoids the race condition between checking existence and inserting
|
||||||
if err := config.DB.Where("domain = ?", req.Domain).First(&existing).Error; err == nil {
|
result := config.DB.Exec(`
|
||||||
// Domain exists, check if it's a failed/expired certificate that can be retried
|
INSERT INTO certificates (domain, email, provider, challenge_type, dns_provider, dns_config, status, auto_renew, renew_days, created_at, updated_at)
|
||||||
if existing.Status == "error" || existing.Status == "expired" || existing.Status == "pending" {
|
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, datetime('now'), datetime('now'))
|
||||||
// Update existing record and retry issuance
|
ON CONFLICT(domain) DO UPDATE SET
|
||||||
existing.Email = req.Email
|
email = excluded.email,
|
||||||
existing.Provider = req.Provider
|
provider = excluded.provider,
|
||||||
existing.ChallengeType = req.ChallengeType
|
challenge_type = excluded.challenge_type,
|
||||||
existing.DNSProvider = req.DNSProvider
|
dns_provider = excluded.dns_provider,
|
||||||
existing.DNSConfig = req.DNSConfig
|
dns_config = excluded.dns_config,
|
||||||
existing.Status = "pending"
|
status = CASE
|
||||||
existing.ErrorMessage = ""
|
WHEN status IN ('error', 'expired', 'pending') THEN 'pending'
|
||||||
if req.AutoRenew != nil {
|
ELSE status
|
||||||
existing.AutoRenew = *req.AutoRenew
|
END,
|
||||||
}
|
error_message = CASE
|
||||||
if req.RenewDays != nil {
|
WHEN status IN ('error', 'expired', 'pending') THEN ''
|
||||||
existing.RenewDays = *req.RenewDays
|
ELSE error_message
|
||||||
}
|
END,
|
||||||
if err := config.DB.Save(&existing).Error; err != nil {
|
updated_at = datetime('now')
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
WHERE excluded.domain = certificates.domain
|
||||||
return
|
`, req.Domain, req.Email, req.Provider, req.ChallengeType, req.DNSProvider, req.DNSConfig, req.AutoRenew != nil && *req.AutoRenew, req.RenewDays)
|
||||||
}
|
|
||||||
// Start issuance in background
|
if result.Error != nil {
|
||||||
go func() {
|
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Error.Error()})
|
||||||
if err := services.GetACMECertificate(&existing, h.Cfg); err != nil {
|
|
||||||
existing.Status = "error"
|
|
||||||
existing.ErrorMessage = err.Error()
|
|
||||||
} else {
|
|
||||||
existing.Status = "active"
|
|
||||||
}
|
|
||||||
config.DB.Save(&existing)
|
|
||||||
}()
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
cert := config.Certificate{
|
// Reload to get the current state
|
||||||
Domain: req.Domain,
|
var cert config.Certificate
|
||||||
Email: req.Email,
|
config.DB.Where("domain = ?", req.Domain).First(&cert)
|
||||||
Provider: req.Provider,
|
|
||||||
ChallengeType: req.ChallengeType,
|
|
||||||
DNSProvider: req.DNSProvider,
|
|
||||||
DNSConfig: req.DNSConfig,
|
|
||||||
Status: "pending",
|
|
||||||
AutoRenew: true,
|
|
||||||
RenewDays: 30,
|
|
||||||
}
|
|
||||||
|
|
||||||
if req.AutoRenew != nil {
|
// If domain already existed and was active, return conflict
|
||||||
cert.AutoRenew = *req.AutoRenew
|
if cert.Status != "pending" {
|
||||||
}
|
c.JSON(http.StatusConflict, gin.H{"error": "domain already exists with status: " + cert.Status})
|
||||||
if req.RenewDays != nil {
|
|
||||||
cert.RenewDays = *req.RenewDays
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := config.DB.Create(&cert).Error; err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +124,7 @@ func (h *CertHandler) CreateCertificate(c *gin.Context) {
|
|||||||
config.DB.Save(&cert)
|
config.DB.Save(&cert)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
c.JSON(http.StatusAccepted, cert)
|
c.JSON(http.StatusAccepted, gin.H{"message": "certificate issuance started", "certificate": cert})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RenewCertificate manually renews a certificate
|
// RenewCertificate manually renews a certificate
|
||||||
|
|||||||
Reference in New Issue
Block a user