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
джерело 0703dffb90
коміт d0e738e1ef
+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