feat: Add certificate validation service with parsing and metadata extraction
- Implemented certificate parsing for PEM, DER, and PFX formats. - Added functions to validate key matches and certificate chains. - Introduced metadata extraction for certificates including common name, domains, and issuer organization. - Created unit tests for all new functionalities to ensure reliability and correctness.
This commit is contained in:
@@ -99,4 +99,5 @@ require (
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.48.2 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.1 // indirect
|
||||
)
|
||||
|
||||
@@ -269,3 +269,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8=
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
@@ -2,14 +2,18 @@ package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/logger"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/Wikid82/charon/backend/internal/util"
|
||||
)
|
||||
@@ -28,9 +32,10 @@ type CertificateHandler struct {
|
||||
service *services.CertificateService
|
||||
backupService BackupServiceInterface
|
||||
notificationService *services.NotificationService
|
||||
db *gorm.DB
|
||||
// Rate limiting for notifications
|
||||
notificationMu sync.Mutex
|
||||
lastNotificationTime map[uint]time.Time
|
||||
lastNotificationTime map[string]time.Time
|
||||
}
|
||||
|
||||
func NewCertificateHandler(service *services.CertificateService, backupService BackupServiceInterface, ns *services.NotificationService) *CertificateHandler {
|
||||
@@ -38,10 +43,18 @@ func NewCertificateHandler(service *services.CertificateService, backupService B
|
||||
service: service,
|
||||
backupService: backupService,
|
||||
notificationService: ns,
|
||||
lastNotificationTime: make(map[uint]time.Time),
|
||||
lastNotificationTime: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
// SetDB sets the database connection for user lookups (export re-auth).
|
||||
func (h *CertificateHandler) SetDB(db *gorm.DB) {
|
||||
h.db = db
|
||||
}
|
||||
|
||||
// maxFileSize is 1MB for certificate file uploads.
|
||||
const maxFileSize = 1 << 20
|
||||
|
||||
func (h *CertificateHandler) List(c *gin.Context) {
|
||||
certs, err := h.service.ListCertificates()
|
||||
if err != nil {
|
||||
@@ -53,34 +66,41 @@ func (h *CertificateHandler) List(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, certs)
|
||||
}
|
||||
|
||||
type UploadCertificateRequest struct {
|
||||
Name string `form:"name" binding:"required"`
|
||||
Certificate string `form:"certificate"` // PEM content
|
||||
PrivateKey string `form:"private_key"` // PEM content
|
||||
func (h *CertificateHandler) Get(c *gin.Context) {
|
||||
certUUID := c.Param("uuid")
|
||||
if certUUID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"})
|
||||
return
|
||||
}
|
||||
|
||||
detail, err := h.service.GetCertificate(certUUID)
|
||||
if err != nil {
|
||||
if err == services.ErrCertNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
||||
return
|
||||
}
|
||||
logger.Log().WithError(err).Error("failed to get certificate")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get certificate"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, detail)
|
||||
}
|
||||
|
||||
func (h *CertificateHandler) Upload(c *gin.Context) {
|
||||
// Handle multipart form
|
||||
name := c.PostForm("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read files
|
||||
// Read certificate file
|
||||
certFile, err := c.FormFile("certificate_file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"})
|
||||
return
|
||||
}
|
||||
|
||||
keyFile, err := c.FormFile("key_file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Open and read content
|
||||
certSrc, err := certFile.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"})
|
||||
@@ -92,35 +112,70 @@ func (h *CertificateHandler) Upload(c *gin.Context) {
|
||||
}
|
||||
}()
|
||||
|
||||
keySrc, err := keyFile.Open()
|
||||
certBytes, err := io.ReadAll(io.LimitReader(certSrc, maxFileSize))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read certificate file"})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if errClose := keySrc.Close(); errClose != nil {
|
||||
logger.Log().WithError(errClose).Warn("failed to close key file")
|
||||
certPEM := string(certBytes)
|
||||
|
||||
// Read private key file (optional for PFX)
|
||||
var keyPEM string
|
||||
keyFile, err := c.FormFile("key_file")
|
||||
if err == nil {
|
||||
keySrc, errOpen := keyFile.Open()
|
||||
if errOpen != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
|
||||
return
|
||||
}
|
||||
}()
|
||||
defer func() {
|
||||
if errClose := keySrc.Close(); errClose != nil {
|
||||
logger.Log().WithError(errClose).Warn("failed to close key file")
|
||||
}
|
||||
}()
|
||||
|
||||
// Read to string
|
||||
// Limit size to avoid DoS (e.g. 1MB)
|
||||
certBytes := make([]byte, 1024*1024)
|
||||
n, _ := certSrc.Read(certBytes)
|
||||
certPEM := string(certBytes[:n])
|
||||
keyBytes, errRead := io.ReadAll(io.LimitReader(keySrc, maxFileSize))
|
||||
if errRead != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read key file"})
|
||||
return
|
||||
}
|
||||
keyPEM = string(keyBytes)
|
||||
} else if !strings.HasSuffix(strings.ToLower(certFile.Filename), ".pfx") &&
|
||||
!strings.HasSuffix(strings.ToLower(certFile.Filename), ".p12") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "key_file is required for PEM certificates"})
|
||||
return
|
||||
}
|
||||
|
||||
keyBytes := make([]byte, 1024*1024)
|
||||
n, _ = keySrc.Read(keyBytes)
|
||||
keyPEM := string(keyBytes[:n])
|
||||
// Read chain file (optional)
|
||||
var chainPEM string
|
||||
chainFile, err := c.FormFile("chain_file")
|
||||
if err == nil {
|
||||
chainSrc, errOpen := chainFile.Open()
|
||||
if errOpen != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open chain file"})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if errClose := chainSrc.Close(); errClose != nil {
|
||||
logger.Log().WithError(errClose).Warn("failed to close chain file")
|
||||
}
|
||||
}()
|
||||
|
||||
cert, err := h.service.UploadCertificate(name, certPEM, keyPEM)
|
||||
chainBytes, errRead := io.ReadAll(io.LimitReader(chainSrc, maxFileSize))
|
||||
if errRead != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read chain file"})
|
||||
return
|
||||
}
|
||||
chainPEM = string(chainBytes)
|
||||
}
|
||||
|
||||
cert, err := h.service.UploadCertificate(name, certPEM, keyPEM, chainPEM)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).Error("failed to upload certificate")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload certificate"})
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Send Notification
|
||||
if h.notificationService != nil {
|
||||
h.notificationService.SendExternal(c.Request.Context(),
|
||||
"cert",
|
||||
@@ -137,24 +192,250 @@ func (h *CertificateHandler) Upload(c *gin.Context) {
|
||||
c.JSON(http.StatusCreated, cert)
|
||||
}
|
||||
|
||||
type updateCertificateRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *CertificateHandler) Update(c *gin.Context) {
|
||||
certUUID := c.Param("uuid")
|
||||
if certUUID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req updateCertificateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
|
||||
info, err := h.service.UpdateCertificate(certUUID, req.Name)
|
||||
if err != nil {
|
||||
if err == services.ErrCertNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
||||
return
|
||||
}
|
||||
logger.Log().WithError(err).Error("failed to update certificate")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update certificate"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, info)
|
||||
}
|
||||
|
||||
func (h *CertificateHandler) Validate(c *gin.Context) {
|
||||
// Read certificate file
|
||||
certFile, err := c.FormFile("certificate_file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "certificate_file is required"})
|
||||
return
|
||||
}
|
||||
|
||||
certSrc, err := certFile.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open cert file"})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if errClose := certSrc.Close(); errClose != nil {
|
||||
logger.Log().WithError(errClose).Warn("failed to close certificate file")
|
||||
}
|
||||
}()
|
||||
|
||||
certBytes, err := io.ReadAll(io.LimitReader(certSrc, maxFileSize))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read certificate file"})
|
||||
return
|
||||
}
|
||||
|
||||
// Read optional key file
|
||||
var keyPEM string
|
||||
keyFile, err := c.FormFile("key_file")
|
||||
if err == nil {
|
||||
keySrc, errOpen := keyFile.Open()
|
||||
if errOpen != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if errClose := keySrc.Close(); errClose != nil {
|
||||
logger.Log().WithError(errClose).Warn("failed to close key file")
|
||||
}
|
||||
}()
|
||||
|
||||
keyBytes, errRead := io.ReadAll(io.LimitReader(keySrc, maxFileSize))
|
||||
if errRead != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read key file"})
|
||||
return
|
||||
}
|
||||
keyPEM = string(keyBytes)
|
||||
}
|
||||
|
||||
// Read optional chain file
|
||||
var chainPEM string
|
||||
chainFile, err := c.FormFile("chain_file")
|
||||
if err == nil {
|
||||
chainSrc, errOpen := chainFile.Open()
|
||||
if errOpen != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open chain file"})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if errClose := chainSrc.Close(); errClose != nil {
|
||||
logger.Log().WithError(errClose).Warn("failed to close chain file")
|
||||
}
|
||||
}()
|
||||
|
||||
chainBytes, errRead := io.ReadAll(io.LimitReader(chainSrc, maxFileSize))
|
||||
if errRead != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read chain file"})
|
||||
return
|
||||
}
|
||||
chainPEM = string(chainBytes)
|
||||
}
|
||||
|
||||
result, err := h.service.ValidateCertificate(string(certBytes), keyPEM, chainPEM)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).Error("failed to validate certificate")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "validation failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
type exportCertificateRequest struct {
|
||||
Format string `json:"format" binding:"required"`
|
||||
IncludeKey bool `json:"include_key"`
|
||||
PFXPassword string `json:"pfx_password"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (h *CertificateHandler) Export(c *gin.Context) {
|
||||
certUUID := c.Param("uuid")
|
||||
if certUUID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "uuid is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req exportCertificateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "format is required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Re-authenticate when requesting private key
|
||||
if req.IncludeKey {
|
||||
if req.Password == "" {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "password required to export private key"})
|
||||
return
|
||||
}
|
||||
|
||||
userVal, exists := c.Get("user")
|
||||
if !exists || h.db == nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
userMap, ok := userVal.(map[string]any)
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "invalid session"})
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := userMap["id"]
|
||||
if !ok {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "invalid session"})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := h.db.First(&user, userID).Error; err != nil {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if !user.CheckPassword(req.Password) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "incorrect password"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
data, filename, err := h.service.ExportCertificate(certUUID, req.Format, req.IncludeKey)
|
||||
if err != nil {
|
||||
if err == services.ErrCertNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
||||
return
|
||||
}
|
||||
logger.Log().WithError(err).Error("failed to export certificate")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to export certificate"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", filename))
|
||||
c.Data(http.StatusOK, "application/octet-stream", data)
|
||||
}
|
||||
|
||||
func (h *CertificateHandler) Delete(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
idStr := c.Param("uuid")
|
||||
|
||||
// Support both numeric ID (legacy) and UUID
|
||||
if numID, err := strconv.ParseUint(idStr, 10, 32); err == nil && numID > 0 {
|
||||
inUse, err := h.service.IsCertificateInUse(uint(numID))
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).WithField("certificate_id", numID).Error("failed to check certificate usage")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"})
|
||||
return
|
||||
}
|
||||
if inUse {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
|
||||
return
|
||||
}
|
||||
|
||||
if h.backupService != nil {
|
||||
if availableSpace, err := h.backupService.GetAvailableSpace(); err != nil {
|
||||
logger.Log().WithError(err).Warn("unable to check disk space, proceeding with backup")
|
||||
} else if availableSpace < 100*1024*1024 {
|
||||
logger.Log().WithField("available_bytes", availableSpace).Warn("low disk space, skipping backup")
|
||||
c.JSON(http.StatusInsufficientStorage, gin.H{"error": "insufficient disk space for backup"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.backupService.CreateBackup(); err != nil {
|
||||
logger.Log().WithError(err).Error("failed to create backup before deletion")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup before deletion"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := h.service.DeleteCertificateByID(uint(numID)); err != nil {
|
||||
if err == services.ErrCertInUse {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
|
||||
return
|
||||
}
|
||||
logger.Log().WithError(err).WithField("certificate_id", numID).Error("failed to delete certificate")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete certificate"})
|
||||
return
|
||||
}
|
||||
|
||||
h.sendDeleteNotification(c, fmt.Sprintf("%d", numID))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
|
||||
return
|
||||
}
|
||||
|
||||
// UUID path - value isn't numeric, validate it looks like a UUID
|
||||
if idStr == "" || idStr == "0" || len(idStr) < 32 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate ID range
|
||||
if id == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if certificate is in use before proceeding
|
||||
inUse, err := h.service.IsCertificateInUse(uint(id))
|
||||
inUse, err := h.service.IsCertificateInUseByUUID(idStr)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).WithField("certificate_id", id).Error("failed to check certificate usage")
|
||||
if err == services.ErrCertNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
||||
return
|
||||
}
|
||||
logger.Log().WithError(err).WithField("certificate_uuid", idStr).Error("failed to check certificate usage")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to check certificate usage"})
|
||||
return
|
||||
}
|
||||
@@ -163,13 +444,10 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Create backup before deletion
|
||||
if h.backupService != nil {
|
||||
// Check disk space before backup (require at least 100MB free)
|
||||
if availableSpace, err := h.backupService.GetAvailableSpace(); err != nil {
|
||||
logger.Log().WithError(err).Warn("unable to check disk space, proceeding with backup")
|
||||
} else if availableSpace < 100*1024*1024 {
|
||||
logger.Log().WithField("available_bytes", availableSpace).Warn("low disk space, skipping backup")
|
||||
c.JSON(http.StatusInsufficientStorage, gin.H{"error": "insufficient disk space for backup"})
|
||||
return
|
||||
}
|
||||
@@ -181,38 +459,46 @@ func (h *CertificateHandler) Delete(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with deletion
|
||||
if err := h.service.DeleteCertificate(uint(id)); err != nil {
|
||||
if err := h.service.DeleteCertificate(idStr); err != nil {
|
||||
if err == services.ErrCertInUse {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
|
||||
return
|
||||
}
|
||||
logger.Log().WithError(err).WithField("certificate_id", id).Error("failed to delete certificate")
|
||||
if err == services.ErrCertNotFound {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
||||
return
|
||||
}
|
||||
logger.Log().WithError(err).WithField("certificate_uuid", idStr).Error("failed to delete certificate")
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete certificate"})
|
||||
return
|
||||
}
|
||||
|
||||
// Send Notification with rate limiting (1 per cert per 10 seconds)
|
||||
if h.notificationService != nil {
|
||||
h.notificationMu.Lock()
|
||||
lastTime, exists := h.lastNotificationTime[uint(id)]
|
||||
if !exists || time.Since(lastTime) > 10*time.Second {
|
||||
h.lastNotificationTime[uint(id)] = time.Now()
|
||||
h.notificationMu.Unlock()
|
||||
h.notificationService.SendExternal(c.Request.Context(),
|
||||
"cert",
|
||||
"Certificate Deleted",
|
||||
fmt.Sprintf("Certificate ID %d deleted", id),
|
||||
map[string]any{
|
||||
"ID": id,
|
||||
"Action": "deleted",
|
||||
},
|
||||
)
|
||||
} else {
|
||||
h.notificationMu.Unlock()
|
||||
logger.Log().WithField("certificate_id", id).Debug("notification rate limited")
|
||||
}
|
||||
}
|
||||
|
||||
h.sendDeleteNotification(c, idStr)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
|
||||
}
|
||||
|
||||
func (h *CertificateHandler) sendDeleteNotification(c *gin.Context, certRef string) {
|
||||
if h.notificationService == nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.notificationMu.Lock()
|
||||
lastTime, exists := h.lastNotificationTime[certRef]
|
||||
if exists && time.Since(lastTime) < 10*time.Second {
|
||||
h.notificationMu.Unlock()
|
||||
logger.Log().WithField("certificate_ref", certRef).Debug("notification rate limited")
|
||||
return
|
||||
}
|
||||
h.lastNotificationTime[certRef] = time.Now()
|
||||
h.notificationMu.Unlock()
|
||||
|
||||
h.notificationService.SendExternal(c.Request.Context(),
|
||||
"cert",
|
||||
"Certificate Deleted",
|
||||
fmt.Sprintf("Certificate %s deleted", certRef),
|
||||
map[string]any{
|
||||
"Ref": certRef,
|
||||
"Action": "deleted",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestCertificateHandler_List_DBError(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.GET("/api/certificates", h.List)
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestCertificateHandler_Delete_InvalidID(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.DELETE("/api/certificates/:id", h.Delete)
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestCertificateHandler_Delete_NotFound(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.DELETE("/api/certificates/:id", h.Delete)
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestCertificateHandler_Delete_NoBackupService(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
|
||||
// No backup service
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
@@ -95,7 +95,7 @@ func TestCertificateHandler_Delete_CheckUsageDBError(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.DELETE("/api/certificates/:id", h.Delete)
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestCertificateHandler_List_WithCertificates(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.GET("/api/certificates", h.List)
|
||||
|
||||
@@ -135,7 +135,7 @@ func TestCertificateHandler_Delete_ZeroID(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.DELETE("/api/certificates/:id", h.Delete)
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestCertificateHandler_DBSetupOrdering(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.GET("/api/certificates", h.List)
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestCertificateHandler_Delete_RequiresAuth(t *testing.T) {
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
})
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.DELETE("/api/certificates/:id", h.Delete)
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestCertificateHandler_List_RequiresAuth(t *testing.T) {
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
})
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.GET("/api/certificates", h.List)
|
||||
|
||||
@@ -88,7 +88,7 @@ func TestCertificateHandler_Upload_RequiresAuth(t *testing.T) {
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
||||
})
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.POST("/api/certificates", h.Upload)
|
||||
|
||||
@@ -125,7 +125,7 @@ func TestCertificateHandler_Delete_DiskSpaceCheck(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
|
||||
// Mock backup service that reports low disk space
|
||||
mockBackup := &mockBackupService{
|
||||
@@ -177,7 +177,7 @@ func TestCertificateHandler_Delete_NotificationRateLimiting(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
|
||||
mockBackup := &mockBackupService{
|
||||
createFunc: func() (string, error) {
|
||||
|
||||
@@ -39,7 +39,7 @@ func setupCertTestRouter(t *testing.T, db *gorm.DB) *gin.Engine {
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.DELETE("/api/certificates/:id", h.Delete)
|
||||
return r
|
||||
@@ -111,7 +111,7 @@ func TestDeleteCertificate_CreatesBackup(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
|
||||
// Mock BackupService
|
||||
backupCalled := false
|
||||
@@ -164,7 +164,7 @@ func TestDeleteCertificate_BackupFailure(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
|
||||
// Mock BackupService that fails
|
||||
mockBackupService := &mockBackupService{
|
||||
@@ -217,7 +217,7 @@ func TestDeleteCertificate_InUse_NoBackup(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
|
||||
// Mock BackupService
|
||||
backupCalled := false
|
||||
@@ -295,7 +295,7 @@ func TestCertificateHandler_List(t *testing.T) {
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.GET("/api/certificates", h.List)
|
||||
|
||||
@@ -321,7 +321,7 @@ func TestCertificateHandler_Upload_MissingName(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.POST("/api/certificates", h.Upload)
|
||||
|
||||
@@ -348,7 +348,7 @@ func TestCertificateHandler_Upload_MissingCertFile(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.POST("/api/certificates", h.Upload)
|
||||
|
||||
@@ -378,7 +378,7 @@ func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.POST("/api/certificates", h.Upload)
|
||||
|
||||
@@ -404,7 +404,7 @@ func TestCertificateHandler_Upload_MissingKeyFile_MultipartWithCert(t *testing.T
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.POST("/api/certificates", h.Upload)
|
||||
|
||||
@@ -447,7 +447,7 @@ func TestCertificateHandler_Upload_Success(t *testing.T) {
|
||||
// Create a mock CertificateService that returns a created certificate
|
||||
// Create a temporary services.CertificateService with a temp dir and DB
|
||||
tmpDir := t.TempDir()
|
||||
svc := services.NewCertificateService(tmpDir, db)
|
||||
svc := services.NewCertificateService(tmpDir, db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.POST("/api/certificates", h.Upload)
|
||||
|
||||
@@ -519,7 +519,7 @@ func TestCertificateHandler_Upload_WithNotificationService(t *testing.T) {
|
||||
r.Use(mockAuthMiddleware())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
svc := services.NewCertificateService(tmpDir, db)
|
||||
svc := services.NewCertificateService(tmpDir, db, nil)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
h := NewCertificateHandler(svc, nil, ns)
|
||||
r.POST("/api/certificates", h.Upload)
|
||||
@@ -555,7 +555,7 @@ func TestDeleteCertificate_InvalidID(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.DELETE("/api/certificates/:id", h.Delete)
|
||||
|
||||
@@ -580,7 +580,7 @@ func TestDeleteCertificate_ZeroID(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.DELETE("/api/certificates/:id", h.Delete)
|
||||
|
||||
@@ -611,7 +611,7 @@ func TestDeleteCertificate_LowDiskSpace(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
|
||||
// Mock BackupService with low disk space
|
||||
mockBackupService := &mockBackupService{
|
||||
@@ -659,7 +659,7 @@ func TestDeleteCertificate_DiskSpaceCheckError(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
|
||||
// Mock BackupService with space check error but backup succeeds
|
||||
mockBackupService := &mockBackupService{
|
||||
@@ -717,7 +717,7 @@ func TestDeleteCertificate_ExpiredLetsEncrypt_NotInUse(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
|
||||
mockBS := &mockBackupService{
|
||||
createFunc: func() (string, error) {
|
||||
@@ -775,7 +775,7 @@ func TestDeleteCertificate_ValidLetsEncrypt_NotInUse(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
|
||||
mockBS := &mockBackupService{
|
||||
createFunc: func() (string, error) {
|
||||
@@ -820,7 +820,7 @@ func TestDeleteCertificate_UsageCheckError(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
h := NewCertificateHandler(svc, nil, nil)
|
||||
r.DELETE("/api/certificates/:id", h.Delete)
|
||||
|
||||
@@ -857,7 +857,7 @@ func TestDeleteCertificate_NotificationRateLimit(t *testing.T) {
|
||||
|
||||
r := gin.New()
|
||||
r.Use(mockAuthMiddleware())
|
||||
svc := services.NewCertificateService("/tmp", db)
|
||||
svc := services.NewCertificateService("/tmp", db, nil)
|
||||
ns := services.NewNotificationService(db, nil)
|
||||
|
||||
mockBackupService := &mockBackupService{
|
||||
|
||||
@@ -152,6 +152,14 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
caddyClient := caddy.NewClient(cfg.CaddyAdminAPI)
|
||||
caddyManager = caddy.NewManager(caddyClient, db, cfg.CaddyConfigDir, cfg.FrontendDir, cfg.ACMEStaging, cfg.Security)
|
||||
}
|
||||
|
||||
// Wire encryption service to Caddy manager for decrypting certificate private keys
|
||||
if cfg.EncryptionKey != "" {
|
||||
if svc, err := crypto.NewEncryptionService(cfg.EncryptionKey); err == nil {
|
||||
caddyManager.SetEncryptionService(svc)
|
||||
}
|
||||
}
|
||||
|
||||
if cerb == nil {
|
||||
cerb = cerberus.New(cfg.Security, db)
|
||||
}
|
||||
@@ -666,11 +674,38 @@ func RegisterWithDeps(router *gin.Engine, db *gorm.DB, cfg config.Config, caddyM
|
||||
// where ACME and certificates are stored (e.g. <CaddyConfigDir>/data).
|
||||
caddyDataDir := cfg.CaddyConfigDir + "/data"
|
||||
logger.Log().WithField("caddy_data_dir", caddyDataDir).Info("Using Caddy data directory for certificates scan")
|
||||
certService := services.NewCertificateService(caddyDataDir, db)
|
||||
var certEncSvc *crypto.EncryptionService
|
||||
if cfg.EncryptionKey != "" {
|
||||
svc, err := crypto.NewEncryptionService(cfg.EncryptionKey)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).Warn("Failed to initialize encryption service for certificate key storage")
|
||||
} else {
|
||||
certEncSvc = svc
|
||||
}
|
||||
}
|
||||
certService := services.NewCertificateService(caddyDataDir, db, certEncSvc)
|
||||
certHandler := handlers.NewCertificateHandler(certService, backupService, notificationService)
|
||||
certHandler.SetDB(db)
|
||||
|
||||
// Migrate unencrypted private keys
|
||||
if err := certService.MigratePrivateKeys(); err != nil {
|
||||
logger.Log().WithError(err).Warn("Failed to migrate certificate private keys")
|
||||
}
|
||||
|
||||
management.GET("/certificates", certHandler.List)
|
||||
management.POST("/certificates", certHandler.Upload)
|
||||
management.DELETE("/certificates/:id", certHandler.Delete)
|
||||
management.POST("/certificates/validate", certHandler.Validate)
|
||||
management.GET("/certificates/:uuid", certHandler.Get)
|
||||
management.PUT("/certificates/:uuid", certHandler.Update)
|
||||
management.POST("/certificates/:uuid/export", certHandler.Export)
|
||||
management.DELETE("/certificates/:uuid", certHandler.Delete)
|
||||
|
||||
// Start certificate expiry checker
|
||||
warningDays := 30
|
||||
if cfg.CertExpiryWarningDays > 0 {
|
||||
warningDays = cfg.CertExpiryWarningDays
|
||||
}
|
||||
go certService.StartExpiryChecker(context.Background(), notificationService, warningDays)
|
||||
|
||||
// Proxy Hosts & Remote Servers
|
||||
proxyHostHandler := handlers.NewProxyHostHandler(db, caddyManager, notificationService, uptimeService)
|
||||
|
||||
@@ -773,7 +773,7 @@ func TestRegister_CertificateRoutes(t *testing.T) {
|
||||
|
||||
// Certificate routes
|
||||
assert.True(t, routeMap["/api/v1/certificates"])
|
||||
assert.True(t, routeMap["/api/v1/certificates/:id"])
|
||||
assert.True(t, routeMap["/api/v1/certificates/:uuid"])
|
||||
}
|
||||
|
||||
// TestRegister_NilHandlers verifies registration behavior with minimal/nil components
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/crypto"
|
||||
"github.com/Wikid82/charon/backend/internal/logger"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/pkg/dnsprovider"
|
||||
@@ -15,7 +16,7 @@ import (
|
||||
|
||||
// GenerateConfig creates a Caddy JSON configuration from proxy hosts.
|
||||
// This is the core transformation layer from our database model to Caddy config.
|
||||
func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
|
||||
func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
|
||||
// Define log file paths for Caddy access logs.
|
||||
// When CrowdSec is enabled, we use /var/log/caddy/access.log which is the standard
|
||||
// location that CrowdSec's acquis.yaml is configured to monitor.
|
||||
@@ -427,16 +428,47 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
|
||||
}
|
||||
|
||||
if len(customCerts) > 0 {
|
||||
// Resolve encryption service from variadic parameter
|
||||
var certEncSvc *crypto.EncryptionService
|
||||
if len(encSvc) > 0 && encSvc[0] != nil {
|
||||
certEncSvc = encSvc[0]
|
||||
}
|
||||
|
||||
var loadPEM []LoadPEMConfig
|
||||
for _, cert := range customCerts {
|
||||
// Validate that custom cert has both certificate and key
|
||||
if cert.Certificate == "" || cert.PrivateKey == "" {
|
||||
logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing certificate or key, skipping")
|
||||
// Determine private key: prefer encrypted, fall back to plaintext for migration
|
||||
var keyPEM string
|
||||
if cert.PrivateKeyEncrypted != "" && certEncSvc != nil {
|
||||
decrypted, err := certEncSvc.Decrypt(cert.PrivateKeyEncrypted)
|
||||
if err != nil {
|
||||
logger.Log().WithField("cert", cert.Name).WithError(err).Warn("Failed to decrypt private key, skipping certificate")
|
||||
continue
|
||||
}
|
||||
keyPEM = string(decrypted)
|
||||
} else if cert.PrivateKeyEncrypted != "" {
|
||||
logger.Log().WithField("cert", cert.Name).Warn("Certificate has encrypted key but no encryption service available, skipping")
|
||||
continue
|
||||
} else if cert.PrivateKey != "" {
|
||||
keyPEM = cert.PrivateKey
|
||||
} else {
|
||||
logger.Log().WithField("cert", cert.Name).Warn("Custom certificate has no encrypted key, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
if cert.Certificate == "" {
|
||||
logger.Log().WithField("cert", cert.Name).Warn("Custom certificate missing certificate PEM, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
// Concatenate chain with leaf certificate
|
||||
fullCert := cert.Certificate
|
||||
if cert.CertificateChain != "" {
|
||||
fullCert = fullCert + "\n" + cert.CertificateChain
|
||||
}
|
||||
|
||||
loadPEM = append(loadPEM, LoadPEMConfig{
|
||||
Certificate: cert.Certificate,
|
||||
Key: cert.PrivateKey,
|
||||
Certificate: fullCert,
|
||||
Key: keyPEM,
|
||||
Tags: []string{cert.UUID},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,6 +73,7 @@ type Manager struct {
|
||||
frontendDir string
|
||||
acmeStaging bool
|
||||
securityCfg config.SecurityConfig
|
||||
encSvc *crypto.EncryptionService
|
||||
}
|
||||
|
||||
// NewManager creates a configuration manager.
|
||||
@@ -87,6 +88,11 @@ func NewManager(client CaddyClient, db *gorm.DB, configDir, frontendDir string,
|
||||
}
|
||||
}
|
||||
|
||||
// SetEncryptionService configures the encryption service for decrypting private keys in Caddy config generation.
|
||||
func (m *Manager) SetEncryptionService(svc *crypto.EncryptionService) {
|
||||
m.encSvc = svc
|
||||
}
|
||||
|
||||
// ApplyConfig generates configuration from database, validates it, applies to Caddy with rollback on failure.
|
||||
func (m *Manager) ApplyConfig(ctx context.Context) error {
|
||||
// Fetch all proxy hosts from database
|
||||
@@ -418,7 +424,7 @@ func (m *Manager) ApplyConfig(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
|
||||
generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs)
|
||||
generatedConfig, err := generateConfigFunc(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, effectiveProvider, effectiveStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, &secCfg, dnsProviderConfigs, m.encSvc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate config: %w", err)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/crypto"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
@@ -422,7 +423,7 @@ func TestManager_ApplyConfig_GenerateConfigFails(t *testing.T) {
|
||||
|
||||
// stub generateConfigFunc to always return error
|
||||
orig := generateConfigFunc
|
||||
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
|
||||
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
|
||||
return nil, fmt.Errorf("generate fail")
|
||||
}
|
||||
defer func() { generateConfigFunc = orig }()
|
||||
@@ -600,7 +601,7 @@ func TestManager_ApplyConfig_PassesAdminWhitelistToGenerateConfig(t *testing.T)
|
||||
// Stub generateConfigFunc to capture adminWhitelist
|
||||
var capturedAdmin string
|
||||
orig := generateConfigFunc
|
||||
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
|
||||
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
|
||||
capturedAdmin = adminWhitelist
|
||||
// return minimal config
|
||||
return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
|
||||
@@ -651,7 +652,7 @@ func TestManager_ApplyConfig_PassesRuleSetsToGenerateConfig(t *testing.T) {
|
||||
|
||||
var capturedRules []models.SecurityRuleSet
|
||||
orig := generateConfigFunc
|
||||
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
|
||||
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
|
||||
capturedRules = rulesets
|
||||
return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
|
||||
}
|
||||
@@ -706,7 +707,7 @@ func TestManager_ApplyConfig_IncludesWAFHandlerWithRuleset(t *testing.T) {
|
||||
var capturedWafEnabled bool
|
||||
var capturedRulesets []models.SecurityRuleSet
|
||||
origGen := generateConfigFunc
|
||||
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
|
||||
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
|
||||
capturedWafEnabled = wafEnabled
|
||||
capturedRulesets = rulesets
|
||||
return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg, dnsProviderConfigs)
|
||||
@@ -811,7 +812,7 @@ func TestManager_ApplyConfig_RulesetWriteFileFailure(t *testing.T) {
|
||||
// Capture rulesetPaths from GenerateConfig
|
||||
var capturedPaths map[string]string
|
||||
origGen := generateConfigFunc
|
||||
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
|
||||
generateConfigFunc = func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
|
||||
capturedPaths = rulesetPaths
|
||||
return origGen(hosts, storageDir, acmeEmail, frontendDir, sslProvider, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled, adminWhitelist, rulesets, rulesetPaths, decisions, secCfg, dnsProviderConfigs)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func TestManagerApplyConfig_DNSProviders_NoKey_SkipsDecryption(t *testing.T) {
|
||||
generateConfigFunc = origGen
|
||||
validateConfigFunc = origVal
|
||||
}()
|
||||
generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
|
||||
generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, _ ...*crypto.EncryptionService) (*Config, error) {
|
||||
capturedLen = len(dnsProviderConfigs)
|
||||
return &Config{}, nil
|
||||
}
|
||||
@@ -111,7 +111,7 @@ func TestManagerApplyConfig_DNSProviders_UsesFallbackEnvKeys(t *testing.T) {
|
||||
generateConfigFunc = origGen
|
||||
validateConfigFunc = origVal
|
||||
}()
|
||||
generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
|
||||
generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, _ ...*crypto.EncryptionService) (*Config, error) {
|
||||
captured = append([]DNSProviderConfig(nil), dnsProviderConfigs...)
|
||||
return &Config{}, nil
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func TestManagerApplyConfig_DNSProviders_SkipsDecryptOrJSONFailures(t *testing.T
|
||||
generateConfigFunc = origGen
|
||||
validateConfigFunc = origVal
|
||||
}()
|
||||
generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
|
||||
generateConfigFunc = func(_ []models.ProxyHost, _ string, _ string, _ string, _ string, _ bool, _ bool, _ bool, _ bool, _ bool, _ string, _ []models.SecurityRuleSet, _ map[string]string, _ []models.SecurityDecision, _ *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, _ ...*crypto.EncryptionService) (*Config, error) {
|
||||
captured = append([]DNSProviderConfig(nil), dnsProviderConfigs...)
|
||||
return &Config{}, nil
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/config"
|
||||
"github.com/Wikid82/charon/backend/internal/crypto"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -17,8 +18,8 @@ import (
|
||||
)
|
||||
|
||||
// mockGenerateConfigFunc creates a mock config generator that captures parameters
|
||||
func mockGenerateConfigFunc(capturedProvider *string, capturedStaging *bool) func([]models.ProxyHost, string, string, string, string, bool, bool, bool, bool, bool, string, []models.SecurityRuleSet, map[string]string, []models.SecurityDecision, *models.SecurityConfig, []DNSProviderConfig) (*Config, error) {
|
||||
return func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig) (*Config, error) {
|
||||
func mockGenerateConfigFunc(capturedProvider *string, capturedStaging *bool) func([]models.ProxyHost, string, string, string, string, bool, bool, bool, bool, bool, string, []models.SecurityRuleSet, map[string]string, []models.SecurityDecision, *models.SecurityConfig, []DNSProviderConfig, ...*crypto.EncryptionService) (*Config, error) {
|
||||
return func(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool, crowdsecEnabled bool, wafEnabled bool, rateLimitEnabled bool, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, encSvc ...*crypto.EncryptionService) (*Config, error) {
|
||||
*capturedProvider = sslProvider
|
||||
*capturedStaging = acmeStaging
|
||||
return &Config{Apps: Apps{HTTP: &HTTPApp{Servers: map[string]*Server{}}}}, nil
|
||||
|
||||
@@ -33,6 +33,7 @@ type Config struct {
|
||||
CaddyLogDir string
|
||||
CrowdSecLogDir string
|
||||
Debug bool
|
||||
CertExpiryWarningDays int
|
||||
Security SecurityConfig
|
||||
Emergency EmergencyConfig
|
||||
}
|
||||
@@ -109,6 +110,20 @@ func Load() (Config, error) {
|
||||
Debug: getEnvAny("false", "CHARON_DEBUG", "CPM_DEBUG") == "true",
|
||||
}
|
||||
|
||||
// Certificate expiry warning days
|
||||
if days := getEnvAny("30", "CHARON_CERT_EXPIRY_WARNING_DAYS"); days != "" {
|
||||
if n, err := strconv.Atoi(days); err == nil && n > 0 {
|
||||
cfg.CertExpiryWarningDays = n
|
||||
}
|
||||
}
|
||||
|
||||
// Certificate expiry warning days
|
||||
if days := getEnvAny("30", "CHARON_CERT_EXPIRY_WARNING_DAYS"); days != "" {
|
||||
if n, err := strconv.Atoi(days); err == nil && n > 0 {
|
||||
cfg.CertExpiryWarningDays = n
|
||||
}
|
||||
}
|
||||
|
||||
// Set JWTSecret using os.Getenv directly so no string literal flows into the
|
||||
// field — prevents CodeQL go/parse-jwt-with-hardcoded-key taint from any fallback.
|
||||
cfg.JWTSecret = os.Getenv("CHARON_JWT_SECRET")
|
||||
|
||||
@@ -7,15 +7,24 @@ import (
|
||||
// SSLCertificate represents TLS certificates managed by Charon.
|
||||
// Can be Let's Encrypt auto-generated or custom uploaded certs.
|
||||
type SSLCertificate struct {
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Provider string `json:"provider" gorm:"index"` // "letsencrypt", "letsencrypt-staging", "custom"
|
||||
Domains string `json:"domains" gorm:"index"` // comma-separated list of domains
|
||||
Certificate string `json:"certificate" gorm:"type:text"` // PEM-encoded certificate
|
||||
PrivateKey string `json:"private_key" gorm:"type:text"` // PEM-encoded private key
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"`
|
||||
AutoRenew bool `json:"auto_renew" gorm:"default:false"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ID uint `json:"-" gorm:"primaryKey"`
|
||||
UUID string `json:"uuid" gorm:"uniqueIndex"`
|
||||
Name string `json:"name" gorm:"index"`
|
||||
Provider string `json:"provider" gorm:"index"`
|
||||
Domains string `json:"domains" gorm:"index"`
|
||||
CommonName string `json:"common_name"`
|
||||
Certificate string `json:"-" gorm:"type:text"`
|
||||
CertificateChain string `json:"-" gorm:"type:text"`
|
||||
PrivateKeyEncrypted string `json:"-" gorm:"column:private_key_enc;type:text"`
|
||||
PrivateKey string `json:"-" gorm:"-"`
|
||||
KeyVersion int `json:"-" gorm:"default:1"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
SerialNumber string `json:"serial_number"`
|
||||
IssuerOrg string `json:"issuer_org"`
|
||||
KeyType string `json:"key_type"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" gorm:"index"`
|
||||
NotBefore *time.Time `json:"not_before,omitempty"`
|
||||
AutoRenew bool `json:"auto_renew" gorm:"default:false"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ func TestNewInternalServiceHTTPClient(t *testing.T) {
|
||||
client := NewInternalServiceHTTPClient(tt.timeout)
|
||||
if client == nil {
|
||||
t.Fatal("NewInternalServiceHTTPClient() returned nil")
|
||||
return
|
||||
}
|
||||
if client.Timeout != tt.timeout {
|
||||
t.Errorf("expected timeout %v, got %v", tt.timeout, client.Timeout)
|
||||
|
||||
@@ -179,6 +179,7 @@ func TestNewSafeHTTPClient_DefaultOptions(t *testing.T) {
|
||||
client := NewSafeHTTPClient()
|
||||
if client == nil {
|
||||
t.Fatal("NewSafeHTTPClient() returned nil")
|
||||
return
|
||||
}
|
||||
if client.Timeout != 10*time.Second {
|
||||
t.Errorf("expected default timeout of 10s, got %v", client.Timeout)
|
||||
@@ -190,6 +191,7 @@ func TestNewSafeHTTPClient_WithTimeout(t *testing.T) {
|
||||
client := NewSafeHTTPClient(WithTimeout(10 * time.Second))
|
||||
if client == nil {
|
||||
t.Fatal("NewSafeHTTPClient() returned nil")
|
||||
return
|
||||
}
|
||||
if client.Timeout != 10*time.Second {
|
||||
t.Errorf("expected timeout of 10s, got %v", client.Timeout)
|
||||
@@ -848,6 +850,7 @@ func TestClientOptions_AllFunctionalOptions(t *testing.T) {
|
||||
|
||||
if client == nil {
|
||||
t.Fatal("NewSafeHTTPClient() returned nil with all options")
|
||||
return
|
||||
}
|
||||
if client.Timeout != 15*time.Second {
|
||||
t.Errorf("expected timeout of 15s, got %v", client.Timeout)
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/crypto"
|
||||
"github.com/Wikid82/charon/backend/internal/logger"
|
||||
"github.com/Wikid82/charon/backend/internal/util"
|
||||
|
||||
@@ -22,22 +26,73 @@ import (
|
||||
// ErrCertInUse is returned when a certificate is linked to one or more proxy hosts.
|
||||
var ErrCertInUse = fmt.Errorf("certificate is in use by one or more proxy hosts")
|
||||
|
||||
// CertificateInfo represents parsed certificate details.
|
||||
// ErrCertNotFound is returned when a certificate cannot be found by UUID.
|
||||
var ErrCertNotFound = fmt.Errorf("certificate not found")
|
||||
|
||||
// CertificateInfo represents parsed certificate details for list responses.
|
||||
type CertificateInfo struct {
|
||||
ID uint `json:"id,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Domain string `json:"domain"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CommonName string `json:"common_name,omitempty"`
|
||||
Domains string `json:"domains"`
|
||||
Issuer string `json:"issuer"`
|
||||
IssuerOrg string `json:"issuer_org,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
NotBefore time.Time `json:"not_before,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Provider string `json:"provider"`
|
||||
ChainDepth int `json:"chain_depth,omitempty"`
|
||||
HasKey bool `json:"has_key"`
|
||||
InUse bool `json:"in_use"`
|
||||
}
|
||||
|
||||
// AssignedHostInfo represents a proxy host assigned to a certificate.
|
||||
type AssignedHostInfo struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
DomainNames string `json:"domain_names"`
|
||||
}
|
||||
|
||||
// ChainEntry represents a single certificate in the chain.
|
||||
type ChainEntry struct {
|
||||
Subject string `json:"subject"`
|
||||
Issuer string `json:"issuer"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Status string `json:"status"` // "valid", "expiring", "expired", "untrusted"
|
||||
Provider string `json:"provider"` // "letsencrypt", "letsencrypt-staging", "custom"
|
||||
}
|
||||
|
||||
// CertificateDetail contains full certificate metadata for detail responses.
|
||||
type CertificateDetail struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name,omitempty"`
|
||||
CommonName string `json:"common_name,omitempty"`
|
||||
Domains string `json:"domains"`
|
||||
Issuer string `json:"issuer"`
|
||||
IssuerOrg string `json:"issuer_org,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
SerialNumber string `json:"serial_number,omitempty"`
|
||||
KeyType string `json:"key_type,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
NotBefore time.Time `json:"not_before,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Provider string `json:"provider"`
|
||||
ChainDepth int `json:"chain_depth,omitempty"`
|
||||
HasKey bool `json:"has_key"`
|
||||
InUse bool `json:"in_use"`
|
||||
AssignedHosts []AssignedHostInfo `json:"assigned_hosts"`
|
||||
Chain []ChainEntry `json:"chain"`
|
||||
AutoRenew bool `json:"auto_renew"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CertificateService manages certificate retrieval and parsing.
|
||||
type CertificateService struct {
|
||||
dataDir string
|
||||
db *gorm.DB
|
||||
encSvc *crypto.EncryptionService
|
||||
cache []CertificateInfo
|
||||
cacheMu sync.RWMutex
|
||||
lastScan time.Time
|
||||
@@ -46,11 +101,12 @@ type CertificateService struct {
|
||||
}
|
||||
|
||||
// NewCertificateService creates a new certificate service.
|
||||
func NewCertificateService(dataDir string, db *gorm.DB) *CertificateService {
|
||||
func NewCertificateService(dataDir string, db *gorm.DB, encSvc *crypto.EncryptionService) *CertificateService {
|
||||
svc := &CertificateService{
|
||||
dataDir: dataDir,
|
||||
db: db,
|
||||
scanTTL: 5 * time.Minute, // Only rescan disk every 5 minutes
|
||||
encSvc: encSvc,
|
||||
scanTTL: 5 * time.Minute,
|
||||
}
|
||||
return svc
|
||||
}
|
||||
@@ -224,15 +280,18 @@ func (s *CertificateService) refreshCacheFromDB() error {
|
||||
return fmt.Errorf("failed to fetch certs from DB: %w", err)
|
||||
}
|
||||
|
||||
// Build a map of domain -> proxy host name for quick lookup
|
||||
// Build a set of certificate IDs that are in use
|
||||
certInUse := make(map[uint]bool)
|
||||
var proxyHosts []models.ProxyHost
|
||||
s.db.Find(&proxyHosts)
|
||||
domainToName := make(map[string]string)
|
||||
for _, ph := range proxyHosts {
|
||||
if ph.CertificateID != nil {
|
||||
certInUse[*ph.CertificateID] = true
|
||||
}
|
||||
if ph.Name == "" {
|
||||
continue
|
||||
}
|
||||
// Handle comma-separated domains
|
||||
domains := strings.Split(ph.DomainNames, ",")
|
||||
for _, d := range domains {
|
||||
d = strings.TrimSpace(strings.ToLower(d))
|
||||
@@ -244,27 +303,20 @@ func (s *CertificateService) refreshCacheFromDB() error {
|
||||
|
||||
certs := make([]CertificateInfo, 0, len(dbCerts))
|
||||
for _, c := range dbCerts {
|
||||
status := "valid"
|
||||
|
||||
// Staging certificates are untrusted by browsers
|
||||
if strings.Contains(c.Provider, "staging") {
|
||||
status = "untrusted"
|
||||
} else if c.ExpiresAt != nil {
|
||||
if time.Now().After(*c.ExpiresAt) {
|
||||
status = "expired"
|
||||
} else if time.Now().AddDate(0, 0, 30).After(*c.ExpiresAt) {
|
||||
status = "expiring"
|
||||
}
|
||||
}
|
||||
status := certStatus(c)
|
||||
|
||||
expires := time.Time{}
|
||||
if c.ExpiresAt != nil {
|
||||
expires = *c.ExpiresAt
|
||||
}
|
||||
|
||||
notBefore := time.Time{}
|
||||
if c.NotBefore != nil {
|
||||
notBefore = *c.NotBefore
|
||||
}
|
||||
|
||||
// Try to get name from proxy host, fall back to cert name or domain
|
||||
name := c.Name
|
||||
// Check all domains in the cert against proxy hosts
|
||||
certDomains := strings.Split(c.Domains, ",")
|
||||
for _, d := range certDomains {
|
||||
d = strings.TrimSpace(strings.ToLower(d))
|
||||
@@ -274,15 +326,36 @@ func (s *CertificateService) refreshCacheFromDB() error {
|
||||
}
|
||||
}
|
||||
|
||||
chainDepth := 0
|
||||
if c.CertificateChain != "" {
|
||||
rest := []byte(c.CertificateChain)
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
chainDepth++
|
||||
}
|
||||
}
|
||||
|
||||
certs = append(certs, CertificateInfo{
|
||||
ID: c.ID,
|
||||
UUID: c.UUID,
|
||||
Name: name,
|
||||
Domain: c.Domains,
|
||||
Issuer: c.Provider,
|
||||
ExpiresAt: expires,
|
||||
Status: status,
|
||||
Provider: c.Provider,
|
||||
UUID: c.UUID,
|
||||
Name: name,
|
||||
CommonName: c.CommonName,
|
||||
Domains: c.Domains,
|
||||
Issuer: c.Provider,
|
||||
IssuerOrg: c.IssuerOrg,
|
||||
Fingerprint: c.Fingerprint,
|
||||
SerialNumber: c.SerialNumber,
|
||||
KeyType: c.KeyType,
|
||||
ExpiresAt: expires,
|
||||
NotBefore: notBefore,
|
||||
Status: status,
|
||||
Provider: c.Provider,
|
||||
ChainDepth: chainDepth,
|
||||
HasKey: c.PrivateKeyEncrypted != "",
|
||||
InUse: certInUse[c.ID],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -290,6 +363,21 @@ func (s *CertificateService) refreshCacheFromDB() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func certStatus(c models.SSLCertificate) string {
|
||||
if strings.Contains(c.Provider, "staging") {
|
||||
return "untrusted"
|
||||
}
|
||||
if c.ExpiresAt != nil {
|
||||
if time.Now().After(*c.ExpiresAt) {
|
||||
return "expired"
|
||||
}
|
||||
if time.Now().AddDate(0, 0, 30).After(*c.ExpiresAt) {
|
||||
return "expiring"
|
||||
}
|
||||
}
|
||||
return "valid"
|
||||
}
|
||||
|
||||
// ListCertificates returns cached certificate info.
|
||||
// Fast path: returns from cache if available.
|
||||
// Triggers background rescan if cache is stale.
|
||||
@@ -342,45 +430,205 @@ func (s *CertificateService) InvalidateCache() {
|
||||
s.cacheMu.Unlock()
|
||||
}
|
||||
|
||||
// UploadCertificate saves a new custom certificate.
|
||||
func (s *CertificateService) UploadCertificate(name, certPEM, keyPEM string) (*models.SSLCertificate, error) {
|
||||
// Validate PEM
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("invalid certificate PEM")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
// UploadCertificate saves a new custom certificate with full validation and encryption.
|
||||
func (s *CertificateService) UploadCertificate(name, certPEM, keyPEM, chainPEM string) (*CertificateInfo, error) {
|
||||
parsed, err := ParseCertificateInput([]byte(certPEM), []byte(keyPEM), []byte(chainPEM), "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
return nil, fmt.Errorf("failed to parse certificate input: %w", err)
|
||||
}
|
||||
|
||||
// Create DB entry
|
||||
// Validate key matches certificate if key is provided
|
||||
if parsed.PrivateKey != nil {
|
||||
if err := ValidateKeyMatch(parsed.Leaf, parsed.PrivateKey); err != nil {
|
||||
return nil, fmt.Errorf("key validation failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract metadata
|
||||
meta := ExtractCertificateMetadata(parsed.Leaf)
|
||||
|
||||
domains := meta.CommonName
|
||||
if len(parsed.Leaf.DNSNames) > 0 {
|
||||
domains = strings.Join(parsed.Leaf.DNSNames, ",")
|
||||
}
|
||||
|
||||
notAfter := parsed.Leaf.NotAfter
|
||||
notBefore := parsed.Leaf.NotBefore
|
||||
|
||||
sslCert := &models.SSLCertificate{
|
||||
UUID: uuid.New().String(),
|
||||
Name: name,
|
||||
Provider: "custom",
|
||||
Domains: cert.Subject.CommonName, // Or SANs
|
||||
Certificate: certPEM,
|
||||
PrivateKey: keyPEM,
|
||||
ExpiresAt: &cert.NotAfter,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
UUID: uuid.New().String(),
|
||||
Name: name,
|
||||
Provider: "custom",
|
||||
Domains: domains,
|
||||
CommonName: meta.CommonName,
|
||||
Certificate: parsed.CertPEM,
|
||||
CertificateChain: parsed.ChainPEM,
|
||||
Fingerprint: meta.Fingerprint,
|
||||
SerialNumber: meta.SerialNumber,
|
||||
IssuerOrg: meta.IssuerOrg,
|
||||
KeyType: meta.KeyType,
|
||||
ExpiresAt: ¬After,
|
||||
NotBefore: ¬Before,
|
||||
KeyVersion: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Handle SANs if present
|
||||
if len(cert.DNSNames) > 0 {
|
||||
sslCert.Domains = strings.Join(cert.DNSNames, ",")
|
||||
// Encrypt private key at rest
|
||||
if parsed.KeyPEM != "" && s.encSvc != nil {
|
||||
encrypted, err := s.encSvc.Encrypt([]byte(parsed.KeyPEM))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
|
||||
}
|
||||
sslCert.PrivateKeyEncrypted = encrypted
|
||||
}
|
||||
|
||||
if err := s.db.Create(sslCert).Error; err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to save certificate: %w", err)
|
||||
}
|
||||
|
||||
// Invalidate cache so the new cert appears immediately
|
||||
s.InvalidateCache()
|
||||
|
||||
return sslCert, nil
|
||||
chainDepth := len(parsed.Intermediates)
|
||||
|
||||
info := &CertificateInfo{
|
||||
UUID: sslCert.UUID,
|
||||
Name: sslCert.Name,
|
||||
CommonName: sslCert.CommonName,
|
||||
Domains: sslCert.Domains,
|
||||
Issuer: sslCert.Provider,
|
||||
IssuerOrg: sslCert.IssuerOrg,
|
||||
Fingerprint: sslCert.Fingerprint,
|
||||
SerialNumber: sslCert.SerialNumber,
|
||||
KeyType: sslCert.KeyType,
|
||||
ExpiresAt: notAfter,
|
||||
NotBefore: notBefore,
|
||||
Status: certStatus(*sslCert),
|
||||
Provider: sslCert.Provider,
|
||||
ChainDepth: chainDepth,
|
||||
HasKey: sslCert.PrivateKeyEncrypted != "",
|
||||
InUse: false,
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// GetCertificate returns full certificate detail by UUID.
|
||||
func (s *CertificateService) GetCertificate(certUUID string) (*CertificateDetail, error) {
|
||||
var cert models.SSLCertificate
|
||||
if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrCertNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch certificate: %w", err)
|
||||
}
|
||||
|
||||
// Get assigned hosts
|
||||
var hosts []models.ProxyHost
|
||||
s.db.Where("certificate_id = ?", cert.ID).Find(&hosts)
|
||||
assignedHosts := make([]AssignedHostInfo, 0, len(hosts))
|
||||
for _, h := range hosts {
|
||||
assignedHosts = append(assignedHosts, AssignedHostInfo{
|
||||
UUID: h.UUID,
|
||||
Name: h.Name,
|
||||
DomainNames: h.DomainNames,
|
||||
})
|
||||
}
|
||||
|
||||
// Parse chain entries
|
||||
chain := buildChainEntries(cert.Certificate, cert.CertificateChain)
|
||||
|
||||
expires := time.Time{}
|
||||
if cert.ExpiresAt != nil {
|
||||
expires = *cert.ExpiresAt
|
||||
}
|
||||
notBefore := time.Time{}
|
||||
if cert.NotBefore != nil {
|
||||
notBefore = *cert.NotBefore
|
||||
}
|
||||
|
||||
detail := &CertificateDetail{
|
||||
UUID: cert.UUID,
|
||||
Name: cert.Name,
|
||||
CommonName: cert.CommonName,
|
||||
Domains: cert.Domains,
|
||||
Issuer: cert.Provider,
|
||||
IssuerOrg: cert.IssuerOrg,
|
||||
Fingerprint: cert.Fingerprint,
|
||||
SerialNumber: cert.SerialNumber,
|
||||
KeyType: cert.KeyType,
|
||||
ExpiresAt: expires,
|
||||
NotBefore: notBefore,
|
||||
Status: certStatus(cert),
|
||||
Provider: cert.Provider,
|
||||
ChainDepth: len(chain),
|
||||
HasKey: cert.PrivateKeyEncrypted != "",
|
||||
InUse: len(hosts) > 0,
|
||||
AssignedHosts: assignedHosts,
|
||||
Chain: chain,
|
||||
AutoRenew: cert.AutoRenew,
|
||||
CreatedAt: cert.CreatedAt,
|
||||
UpdatedAt: cert.UpdatedAt,
|
||||
}
|
||||
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// ValidateCertificate validates certificate data without storing.
|
||||
func (s *CertificateService) ValidateCertificate(certPEM, keyPEM, chainPEM string) (*ValidationResult, error) {
|
||||
result := &ValidationResult{
|
||||
Warnings: []string{},
|
||||
Errors: []string{},
|
||||
}
|
||||
|
||||
parsed, err := ParseCertificateInput([]byte(certPEM), []byte(keyPEM), []byte(chainPEM), "")
|
||||
if err != nil {
|
||||
result.Errors = append(result.Errors, err.Error())
|
||||
return result, nil
|
||||
}
|
||||
|
||||
meta := ExtractCertificateMetadata(parsed.Leaf)
|
||||
result.CommonName = meta.CommonName
|
||||
result.Domains = meta.Domains
|
||||
result.IssuerOrg = meta.IssuerOrg
|
||||
result.ExpiresAt = meta.NotAfter
|
||||
result.ChainDepth = len(parsed.Intermediates)
|
||||
|
||||
// Key match check
|
||||
if parsed.PrivateKey != nil {
|
||||
if err := ValidateKeyMatch(parsed.Leaf, parsed.PrivateKey); err != nil {
|
||||
result.Errors = append(result.Errors, fmt.Sprintf("key mismatch: %s", err.Error()))
|
||||
} else {
|
||||
result.KeyMatch = true
|
||||
}
|
||||
}
|
||||
|
||||
// Chain validation (best-effort, warn on failure)
|
||||
if len(parsed.Intermediates) > 0 {
|
||||
if err := ValidateChain(parsed.Leaf, parsed.Intermediates); err != nil {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("chain validation: %s", err.Error()))
|
||||
} else {
|
||||
result.ChainValid = true
|
||||
}
|
||||
} else {
|
||||
// Try verifying with system roots
|
||||
if err := ValidateChain(parsed.Leaf, nil); err != nil {
|
||||
result.Warnings = append(result.Warnings, "certificate could not be verified against system roots")
|
||||
} else {
|
||||
result.ChainValid = true
|
||||
}
|
||||
}
|
||||
|
||||
// Expiry warnings
|
||||
daysUntilExpiry := time.Until(parsed.Leaf.NotAfter).Hours() / 24
|
||||
if daysUntilExpiry < 0 {
|
||||
result.Warnings = append(result.Warnings, "Certificate has expired")
|
||||
} else if daysUntilExpiry < 30 {
|
||||
result.Warnings = append(result.Warnings, fmt.Sprintf("Certificate expires in %.0f days", daysUntilExpiry))
|
||||
}
|
||||
|
||||
result.Valid = len(result.Errors) == 0
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// IsCertificateInUse checks if a certificate is referenced by any proxy host.
|
||||
@@ -392,10 +640,30 @@ func (s *CertificateService) IsCertificateInUse(id uint) (bool, error) {
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// DeleteCertificate removes a certificate.
|
||||
func (s *CertificateService) DeleteCertificate(id uint) error {
|
||||
// IsCertificateInUseByUUID checks if a certificate is referenced by any proxy host, looked up by UUID.
|
||||
func (s *CertificateService) IsCertificateInUseByUUID(certUUID string) (bool, error) {
|
||||
var cert models.SSLCertificate
|
||||
if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return false, ErrCertNotFound
|
||||
}
|
||||
return false, fmt.Errorf("failed to look up certificate: %w", err)
|
||||
}
|
||||
return s.IsCertificateInUse(cert.ID)
|
||||
}
|
||||
|
||||
// DeleteCertificate removes a certificate by UUID.
|
||||
func (s *CertificateService) DeleteCertificate(certUUID string) error {
|
||||
var cert models.SSLCertificate
|
||||
if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return ErrCertNotFound
|
||||
}
|
||||
return fmt.Errorf("failed to look up certificate: %w", err)
|
||||
}
|
||||
|
||||
// Prevent deletion if the certificate is referenced by any proxy host
|
||||
inUse, err := s.IsCertificateInUse(id)
|
||||
inUse, err := s.IsCertificateInUse(cert.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -403,30 +671,22 @@ func (s *CertificateService) DeleteCertificate(id uint) error {
|
||||
return ErrCertInUse
|
||||
}
|
||||
|
||||
var cert models.SSLCertificate
|
||||
if err := s.db.Where("id = ?", id).First(&cert).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cert.Provider == "letsencrypt" {
|
||||
if cert.Provider == "letsencrypt" || cert.Provider == "letsencrypt-staging" {
|
||||
// Best-effort file deletion
|
||||
certRoot := filepath.Join(s.dataDir, "certificates")
|
||||
_ = filepath.Walk(certRoot, func(path string, info os.FileInfo, err error) error {
|
||||
if err == nil && !info.IsDir() && strings.HasSuffix(info.Name(), ".crt") {
|
||||
if info.Name() == cert.Domains+".crt" {
|
||||
// Found it
|
||||
logger.Log().WithField("path", path).Info("CertificateService: deleting ACME cert file")
|
||||
if err := os.Remove(path); err != nil {
|
||||
logger.Log().WithError(err).Error("CertificateService: failed to delete cert file")
|
||||
}
|
||||
// Try to delete key as well
|
||||
keyPath := strings.TrimSuffix(path, ".crt") + ".key"
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
if err := os.Remove(keyPath); err != nil {
|
||||
logger.Log().WithError(err).Warn("Failed to remove key file")
|
||||
}
|
||||
}
|
||||
// Also try to delete the json meta file
|
||||
jsonPath := strings.TrimSuffix(path, ".crt") + ".json"
|
||||
if _, err := os.Stat(jsonPath); err == nil {
|
||||
if err := os.Remove(jsonPath); err != nil {
|
||||
@@ -439,10 +699,348 @@ func (s *CertificateService) DeleteCertificate(id uint) error {
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
if err := s.db.Delete(&models.SSLCertificate{}, "id = ?", cert.ID).Error; err != nil {
|
||||
return fmt.Errorf("failed to delete certificate: %w", err)
|
||||
}
|
||||
// Invalidate cache so the deleted cert disappears immediately
|
||||
s.InvalidateCache()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ExportCertificate exports a certificate in the requested format.
|
||||
// Returns the file data, suggested filename, and any error.
|
||||
func (s *CertificateService) ExportCertificate(certUUID string, format string, includeKey bool) ([]byte, string, error) {
|
||||
var cert models.SSLCertificate
|
||||
if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, "", ErrCertNotFound
|
||||
}
|
||||
return nil, "", fmt.Errorf("failed to fetch certificate: %w", err)
|
||||
}
|
||||
|
||||
baseName := cert.Name
|
||||
if baseName == "" {
|
||||
baseName = "certificate"
|
||||
}
|
||||
|
||||
switch strings.ToLower(format) {
|
||||
case "pem":
|
||||
var buf strings.Builder
|
||||
buf.WriteString(cert.Certificate)
|
||||
if cert.CertificateChain != "" {
|
||||
buf.WriteString("\n")
|
||||
buf.WriteString(cert.CertificateChain)
|
||||
}
|
||||
if includeKey {
|
||||
keyPEM, err := s.GetDecryptedPrivateKey(&cert)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decrypt private key: %w", err)
|
||||
}
|
||||
buf.WriteString("\n")
|
||||
buf.WriteString(keyPEM)
|
||||
}
|
||||
return []byte(buf.String()), baseName + ".pem", nil
|
||||
|
||||
case "der":
|
||||
derData, err := ConvertPEMToDER(cert.Certificate)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to convert to DER: %w", err)
|
||||
}
|
||||
return derData, baseName + ".der", nil
|
||||
|
||||
case "pfx", "p12":
|
||||
keyPEM, err := s.GetDecryptedPrivateKey(&cert)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to decrypt private key for PFX: %w", err)
|
||||
}
|
||||
pfxData, err := ConvertPEMToPFX(cert.Certificate, keyPEM, cert.CertificateChain, "")
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create PFX: %w", err)
|
||||
}
|
||||
return pfxData, baseName + ".pfx", nil
|
||||
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unsupported export format: %s", format)
|
||||
}
|
||||
}
|
||||
|
||||
// GetDecryptedPrivateKey decrypts and returns the private key PEM for internal use.
|
||||
func (s *CertificateService) GetDecryptedPrivateKey(cert *models.SSLCertificate) (string, error) {
|
||||
if cert.PrivateKeyEncrypted == "" {
|
||||
return "", fmt.Errorf("no encrypted private key stored")
|
||||
}
|
||||
if s.encSvc == nil {
|
||||
return "", fmt.Errorf("encryption service not configured")
|
||||
}
|
||||
|
||||
decrypted, err := s.encSvc.Decrypt(cert.PrivateKeyEncrypted)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decrypt private key: %w", err)
|
||||
}
|
||||
|
||||
return string(decrypted), nil
|
||||
}
|
||||
|
||||
// MigratePrivateKeys encrypts existing plaintext private keys.
|
||||
// Idempotent — skips already-migrated rows.
|
||||
func (s *CertificateService) MigratePrivateKeys() error {
|
||||
if s.encSvc == nil {
|
||||
logger.Log().Warn("CertificateService: encryption service not configured, skipping key migration")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use raw SQL because PrivateKey has gorm:"-" tag
|
||||
type rawCert struct {
|
||||
ID uint
|
||||
PrivateKey string
|
||||
PrivateKeyEnc string `gorm:"column:private_key_enc"`
|
||||
}
|
||||
|
||||
var certs []rawCert
|
||||
if err := s.db.Raw("SELECT id, private_key, private_key_enc FROM ssl_certificates WHERE private_key != '' AND (private_key_enc = '' OR private_key_enc IS NULL)").Scan(&certs).Error; err != nil {
|
||||
return fmt.Errorf("failed to query certificates for migration: %w", err)
|
||||
}
|
||||
|
||||
if len(certs) == 0 {
|
||||
logger.Log().Info("CertificateService: no private keys to migrate")
|
||||
return nil
|
||||
}
|
||||
|
||||
logger.Log().WithField("count", len(certs)).Info("CertificateService: migrating plaintext private keys")
|
||||
|
||||
for _, c := range certs {
|
||||
encrypted, err := s.encSvc.Encrypt([]byte(c.PrivateKey))
|
||||
if err != nil {
|
||||
logger.Log().WithField("cert_id", c.ID).WithError(err).Error("CertificateService: failed to encrypt key during migration")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.db.Exec("UPDATE ssl_certificates SET private_key_enc = ?, key_version = 1, private_key = '' WHERE id = ?", encrypted, c.ID).Error; err != nil {
|
||||
logger.Log().WithField("cert_id", c.ID).WithError(err).Error("CertificateService: failed to update migrated key")
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Log().WithField("cert_id", c.ID).Info("CertificateService: migrated private key")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteCertificateByID removes a certificate by numeric ID (legacy compatibility).
|
||||
func (s *CertificateService) DeleteCertificateByID(id uint) error {
|
||||
var cert models.SSLCertificate
|
||||
if err := s.db.Where("id = ?", id).First(&cert).Error; err != nil {
|
||||
return fmt.Errorf("failed to look up certificate: %w", err)
|
||||
}
|
||||
return s.DeleteCertificate(cert.UUID)
|
||||
}
|
||||
|
||||
// UpdateCertificate updates certificate metadata (name only) by UUID.
|
||||
func (s *CertificateService) UpdateCertificate(certUUID string, name string) (*CertificateInfo, error) {
|
||||
var cert models.SSLCertificate
|
||||
if err := s.db.Where("uuid = ?", certUUID).First(&cert).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, ErrCertNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("failed to fetch certificate: %w", err)
|
||||
}
|
||||
|
||||
cert.Name = name
|
||||
if err := s.db.Save(&cert).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to update certificate: %w", err)
|
||||
}
|
||||
|
||||
s.InvalidateCache()
|
||||
|
||||
expires := time.Time{}
|
||||
if cert.ExpiresAt != nil {
|
||||
expires = *cert.ExpiresAt
|
||||
}
|
||||
notBefore := time.Time{}
|
||||
if cert.NotBefore != nil {
|
||||
notBefore = *cert.NotBefore
|
||||
}
|
||||
|
||||
var chainDepth int
|
||||
if cert.CertificateChain != "" {
|
||||
certs, _ := parsePEMCertificates([]byte(cert.CertificateChain))
|
||||
chainDepth = len(certs)
|
||||
}
|
||||
|
||||
inUse, _ := s.IsCertificateInUse(cert.ID)
|
||||
|
||||
return &CertificateInfo{
|
||||
UUID: cert.UUID,
|
||||
Name: cert.Name,
|
||||
CommonName: cert.CommonName,
|
||||
Domains: cert.Domains,
|
||||
Issuer: cert.Provider,
|
||||
IssuerOrg: cert.IssuerOrg,
|
||||
Fingerprint: cert.Fingerprint,
|
||||
SerialNumber: cert.SerialNumber,
|
||||
KeyType: cert.KeyType,
|
||||
ExpiresAt: expires,
|
||||
NotBefore: notBefore,
|
||||
Status: certStatus(cert),
|
||||
Provider: cert.Provider,
|
||||
ChainDepth: chainDepth,
|
||||
HasKey: cert.PrivateKeyEncrypted != "",
|
||||
InUse: inUse,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CheckExpiringCertificates returns certificates that are expiring within the given number of days.
|
||||
func (s *CertificateService) CheckExpiringCertificates(warningDays int) ([]CertificateInfo, error) {
|
||||
var certs []models.SSLCertificate
|
||||
threshold := time.Now().Add(time.Duration(warningDays) * 24 * time.Hour)
|
||||
|
||||
if err := s.db.Where("provider = ? AND expires_at IS NOT NULL AND expires_at <= ?", "custom", threshold).Find(&certs).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to query expiring certificates: %w", err)
|
||||
}
|
||||
|
||||
result := make([]CertificateInfo, 0, len(certs))
|
||||
for _, cert := range certs {
|
||||
expires := time.Time{}
|
||||
if cert.ExpiresAt != nil {
|
||||
expires = *cert.ExpiresAt
|
||||
}
|
||||
notBefore := time.Time{}
|
||||
if cert.NotBefore != nil {
|
||||
notBefore = *cert.NotBefore
|
||||
}
|
||||
|
||||
result = append(result, CertificateInfo{
|
||||
UUID: cert.UUID,
|
||||
Name: cert.Name,
|
||||
CommonName: cert.CommonName,
|
||||
Domains: cert.Domains,
|
||||
Issuer: cert.Provider,
|
||||
IssuerOrg: cert.IssuerOrg,
|
||||
Fingerprint: cert.Fingerprint,
|
||||
SerialNumber: cert.SerialNumber,
|
||||
KeyType: cert.KeyType,
|
||||
ExpiresAt: expires,
|
||||
NotBefore: notBefore,
|
||||
Status: certStatus(cert),
|
||||
Provider: cert.Provider,
|
||||
HasKey: cert.PrivateKeyEncrypted != "",
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// StartExpiryChecker runs a background goroutine that periodically checks for expiring certificates.
|
||||
func (s *CertificateService) StartExpiryChecker(ctx context.Context, notificationSvc *NotificationService, warningDays int) {
|
||||
// Startup delay: avoid notification bursts during frequent restarts
|
||||
startupDelay := 5 * time.Minute
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(startupDelay):
|
||||
}
|
||||
|
||||
// Add random jitter (0-60 minutes) using crypto/rand
|
||||
maxJitter := int64(60 * time.Minute)
|
||||
n, errRand := crand.Int(crand.Reader, big.NewInt(maxJitter))
|
||||
if errRand != nil {
|
||||
n = big.NewInt(maxJitter / 2)
|
||||
}
|
||||
jitter := time.Duration(n.Int64())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(jitter):
|
||||
}
|
||||
|
||||
s.checkExpiry(ctx, notificationSvc, warningDays)
|
||||
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.checkExpiry(ctx, notificationSvc, warningDays)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *CertificateService) checkExpiry(ctx context.Context, notificationSvc *NotificationService, warningDays int) {
|
||||
if notificationSvc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
certs, err := s.CheckExpiringCertificates(warningDays)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).Error("CertificateService: failed to check expiring certificates")
|
||||
return
|
||||
}
|
||||
|
||||
for _, cert := range certs {
|
||||
daysLeft := time.Until(cert.ExpiresAt).Hours() / 24
|
||||
|
||||
if daysLeft < 0 {
|
||||
// Expired
|
||||
if _, err := notificationSvc.Create(
|
||||
models.NotificationTypeError,
|
||||
"Certificate Expired",
|
||||
fmt.Sprintf("Certificate %q (%s) has expired.", cert.Name, cert.Domains),
|
||||
); err != nil {
|
||||
logger.Log().WithError(err).Error("CertificateService: failed to create expiry notification")
|
||||
}
|
||||
notificationSvc.SendExternal(ctx,
|
||||
"cert_expiry",
|
||||
"Certificate Expired",
|
||||
fmt.Sprintf("Certificate %q (%s) has expired.", cert.Name, cert.Domains),
|
||||
map[string]any{"uuid": cert.UUID, "domains": cert.Domains, "status": "expired"},
|
||||
)
|
||||
} else {
|
||||
// Expiring soon
|
||||
if _, err := notificationSvc.Create(
|
||||
models.NotificationTypeWarning,
|
||||
"Certificate Expiring Soon",
|
||||
fmt.Sprintf("Certificate %q (%s) expires in %.0f days.", cert.Name, cert.Domains, daysLeft),
|
||||
); err != nil {
|
||||
logger.Log().WithError(err).Error("CertificateService: failed to create expiry warning notification")
|
||||
}
|
||||
notificationSvc.SendExternal(ctx,
|
||||
"cert_expiry",
|
||||
"Certificate Expiring Soon",
|
||||
fmt.Sprintf("Certificate %q (%s) expires in %.0f days.", cert.Name, cert.Domains, daysLeft),
|
||||
map[string]any{"uuid": cert.UUID, "domains": cert.Domains, "days_left": int(daysLeft)},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildChainEntries(certPEM, chainPEM string) []ChainEntry {
|
||||
var entries []ChainEntry
|
||||
|
||||
// Parse leaf
|
||||
if certPEM != "" {
|
||||
certs, _ := parsePEMCertificates([]byte(certPEM))
|
||||
for _, c := range certs {
|
||||
entries = append(entries, ChainEntry{
|
||||
Subject: c.Subject.CommonName,
|
||||
Issuer: c.Issuer.CommonName,
|
||||
ExpiresAt: c.NotAfter,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Parse chain
|
||||
if chainPEM != "" {
|
||||
certs, _ := parsePEMCertificates([]byte(chainPEM))
|
||||
for _, c := range certs {
|
||||
entries = append(entries, ChainEntry{
|
||||
Subject: c.Subject.CommonName,
|
||||
Issuer: c.Issuer.CommonName,
|
||||
ExpiresAt: c.NotAfter,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
@@ -31,6 +31,14 @@ func newTestCertificateService(dataDir string, db *gorm.DB) *CertificateService
|
||||
}
|
||||
}
|
||||
|
||||
// certDBID looks up the numeric DB primary key for a certificate by UUID.
|
||||
func certDBID(t *testing.T, db *gorm.DB, uuid string) uint {
|
||||
t.Helper()
|
||||
var cert models.SSLCertificate
|
||||
require.NoError(t, db.Where("uuid = ?", uuid).First(&cert).Error)
|
||||
return cert.ID
|
||||
}
|
||||
|
||||
func TestNewCertificateService(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
@@ -43,7 +51,7 @@ func TestNewCertificateService(t *testing.T) {
|
||||
require.NoError(t, os.MkdirAll(certDir, 0o750)) // #nosec G301 -- test directory
|
||||
|
||||
// Test service creation
|
||||
svc := NewCertificateService(tmpDir, db)
|
||||
svc := NewCertificateService(tmpDir, db, nil)
|
||||
assert.NotNil(t, svc)
|
||||
assert.Equal(t, tmpDir, svc.dataDir)
|
||||
assert.Equal(t, db, svc.db)
|
||||
@@ -54,6 +62,11 @@ func TestNewCertificateService(t *testing.T) {
|
||||
}
|
||||
|
||||
func generateTestCert(t *testing.T, domain string, expiry time.Time) []byte {
|
||||
certPEM, _ := generateTestCertAndKey(t, domain, expiry)
|
||||
return certPEM
|
||||
}
|
||||
|
||||
func generateTestCertAndKey(t *testing.T, domain string, expiry time.Time) ([]byte, []byte) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to generate private key: %v", err)
|
||||
@@ -77,7 +90,9 @@ func generateTestCert(t *testing.T, domain string, expiry time.Time) []byte {
|
||||
t.Fatalf("Failed to create certificate: %v", err)
|
||||
}
|
||||
|
||||
return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
|
||||
return certPEM, keyPEM
|
||||
}
|
||||
|
||||
func TestCertificateService_GetCertificateInfo(t *testing.T) {
|
||||
@@ -123,7 +138,7 @@ func TestCertificateService_GetCertificateInfo(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, certs, 1)
|
||||
if len(certs) > 0 {
|
||||
assert.Equal(t, domain, certs[0].Domain)
|
||||
assert.Equal(t, domain, certs[0].Domains)
|
||||
assert.Equal(t, "valid", certs[0].Status)
|
||||
// Check expiry within a margin
|
||||
assert.WithinDuration(t, expiry, certs[0].ExpiresAt, time.Second)
|
||||
@@ -153,7 +168,7 @@ func TestCertificateService_GetCertificateInfo(t *testing.T) {
|
||||
// Find the expired one
|
||||
var foundExpired bool
|
||||
for _, c := range certs {
|
||||
if c.Domain == expiredDomain {
|
||||
if c.Domains == expiredDomain {
|
||||
assert.Equal(t, "expired", c.Status)
|
||||
foundExpired = true
|
||||
}
|
||||
@@ -174,11 +189,10 @@ func TestCertificateService_UploadAndDelete(t *testing.T) {
|
||||
// Generate Cert
|
||||
domain := "custom.example.com"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
keyPEM := []byte("FAKE PRIVATE KEY")
|
||||
certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry)
|
||||
|
||||
// Test Upload
|
||||
cert, err := cs.UploadCertificate("My Custom Cert", string(certPEM), string(keyPEM))
|
||||
cert, err := cs.UploadCertificate("My Custom Cert", string(certPEM), string(keyPEM), "")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, cert)
|
||||
assert.Equal(t, "My Custom Cert", cert.Name)
|
||||
@@ -190,7 +204,7 @@ func TestCertificateService_UploadAndDelete(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
var found bool
|
||||
for _, c := range certs {
|
||||
if c.ID == cert.ID {
|
||||
if c.UUID == cert.UUID {
|
||||
found = true
|
||||
assert.Equal(t, "custom", c.Provider)
|
||||
break
|
||||
@@ -199,7 +213,7 @@ func TestCertificateService_UploadAndDelete(t *testing.T) {
|
||||
assert.True(t, found)
|
||||
|
||||
// Test Delete
|
||||
err = cs.DeleteCertificate(cert.ID)
|
||||
err = cs.DeleteCertificate(cert.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify it's gone
|
||||
@@ -207,7 +221,7 @@ func TestCertificateService_UploadAndDelete(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
found = false
|
||||
for _, c := range certs {
|
||||
if c.ID == cert.ID {
|
||||
if c.UUID == cert.UUID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
@@ -248,7 +262,7 @@ func TestCertificateService_Persistence(t *testing.T) {
|
||||
// Verify it's in the returned list
|
||||
var foundInList bool
|
||||
for _, c := range certs {
|
||||
if c.Domain == domain {
|
||||
if c.Domains == domain {
|
||||
foundInList = true
|
||||
assert.Equal(t, "letsencrypt", c.Provider)
|
||||
break
|
||||
@@ -264,7 +278,7 @@ func TestCertificateService_Persistence(t *testing.T) {
|
||||
assert.Equal(t, string(certPEM), dbCert.Certificate)
|
||||
|
||||
// 4. Delete the certificate via Service (which should delete the file)
|
||||
err = cs.DeleteCertificate(dbCert.ID)
|
||||
err = cs.DeleteCertificate(dbCert.UUID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify file is gone
|
||||
@@ -278,7 +292,7 @@ func TestCertificateService_Persistence(t *testing.T) {
|
||||
// Verify it's NOT in the returned list
|
||||
foundInList = false
|
||||
for _, c := range certs {
|
||||
if c.Domain == domain {
|
||||
if c.Domains == domain {
|
||||
foundInList = true
|
||||
break
|
||||
}
|
||||
@@ -301,14 +315,14 @@ func TestCertificateService_UploadCertificate_Errors(t *testing.T) {
|
||||
cs := newTestCertificateService(tmpDir, db)
|
||||
|
||||
t.Run("invalid PEM format", func(t *testing.T) {
|
||||
cert, err := cs.UploadCertificate("Invalid", "not-a-valid-pem", "also-not-valid")
|
||||
cert, err := cs.UploadCertificate("Invalid", "not-a-valid-pem", "also-not-valid", "")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, cert)
|
||||
assert.Contains(t, err.Error(), "invalid certificate PEM")
|
||||
assert.Contains(t, err.Error(), "unrecognized certificate format")
|
||||
})
|
||||
|
||||
t.Run("empty certificate", func(t *testing.T) {
|
||||
cert, err := cs.UploadCertificate("Empty", "", "some-key")
|
||||
cert, err := cs.UploadCertificate("Empty", "", "some-key", "")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, cert)
|
||||
})
|
||||
@@ -318,19 +332,18 @@ func TestCertificateService_UploadCertificate_Errors(t *testing.T) {
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
|
||||
cert, err := cs.UploadCertificate("No Key", string(certPEM), "")
|
||||
cert, err := cs.UploadCertificate("No Key", string(certPEM), "", "")
|
||||
assert.NoError(t, err) // Uploading without key is allowed
|
||||
assert.NotNil(t, cert)
|
||||
assert.Equal(t, "", cert.PrivateKey)
|
||||
assert.False(t, cert.HasKey)
|
||||
})
|
||||
|
||||
t.Run("valid certificate with name", func(t *testing.T) {
|
||||
domain := "valid.com"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
keyPEM := []byte("FAKE PRIVATE KEY")
|
||||
certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry)
|
||||
|
||||
cert, err := cs.UploadCertificate("Valid Cert", string(certPEM), string(keyPEM))
|
||||
cert, err := cs.UploadCertificate("Valid Cert", string(certPEM), string(keyPEM), "")
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cert)
|
||||
assert.Equal(t, "Valid Cert", cert.Name)
|
||||
@@ -341,10 +354,9 @@ func TestCertificateService_UploadCertificate_Errors(t *testing.T) {
|
||||
t.Run("expired certificate can be uploaded", func(t *testing.T) {
|
||||
domain := "expired-upload.com"
|
||||
expiry := time.Now().Add(-24 * time.Hour) // Already expired
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
keyPEM := []byte("FAKE PRIVATE KEY")
|
||||
certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry)
|
||||
|
||||
cert, err := cs.UploadCertificate("Expired Upload", string(certPEM), string(keyPEM))
|
||||
cert, err := cs.UploadCertificate("Expired Upload", string(certPEM), string(keyPEM), "")
|
||||
// Should still upload successfully, but status will be expired
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, cert)
|
||||
@@ -430,7 +442,7 @@ func TestCertificateService_ListCertificates_EdgeCases(t *testing.T) {
|
||||
domain2 := "custom.example.com"
|
||||
expiry2 := time.Now().Add(48 * time.Hour)
|
||||
certPEM2 := generateTestCert(t, domain2, expiry2)
|
||||
_, err = cs.UploadCertificate("Custom", string(certPEM2), "FAKE KEY")
|
||||
_, err = cs.UploadCertificate("Custom", string(certPEM2), "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
certs, err := cs.ListCertificates()
|
||||
@@ -457,20 +469,22 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) {
|
||||
cs := newTestCertificateService(tmpDir, db)
|
||||
|
||||
t.Run("delete non-existent certificate", func(t *testing.T) {
|
||||
// IsCertificateInUse will succeed (not in use), then First will fail
|
||||
err := cs.DeleteCertificate(99999)
|
||||
// DeleteCertificate takes UUID string; non-existent UUID returns error
|
||||
err := cs.DeleteCertificate("non-existent-uuid")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, gorm.ErrRecordNotFound, err)
|
||||
})
|
||||
|
||||
t.Run("delete certificate in use returns ErrCertInUse", func(t *testing.T) {
|
||||
// Create certificate
|
||||
domain := "in-use.com"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
cert, err := cs.UploadCertificate("In Use", string(certPEM), "FAKE KEY")
|
||||
certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry)
|
||||
cert, err := cs.UploadCertificate("In Use", string(certPEM), string(keyPEM), "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Look up numeric ID for FK
|
||||
dbID := certDBID(t, db, cert.UUID)
|
||||
|
||||
// Create proxy host using this certificate
|
||||
ph := models.ProxyHost{
|
||||
UUID: "test-ph",
|
||||
@@ -478,18 +492,18 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) {
|
||||
DomainNames: "in-use.com",
|
||||
ForwardHost: "localhost",
|
||||
ForwardPort: 8080,
|
||||
CertificateID: &cert.ID,
|
||||
CertificateID: &dbID,
|
||||
}
|
||||
require.NoError(t, db.Create(&ph).Error)
|
||||
|
||||
// Attempt to delete certificate - should fail with ErrCertInUse
|
||||
err = cs.DeleteCertificate(cert.ID)
|
||||
err = cs.DeleteCertificate(cert.UUID)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, ErrCertInUse, err)
|
||||
|
||||
// Verify certificate still exists
|
||||
var dbCert models.SSLCertificate
|
||||
err = db.First(&dbCert, "id = ?", cert.ID).Error
|
||||
err = db.First(&dbCert, "id = ?", dbID).Error
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
@@ -497,21 +511,24 @@ func TestCertificateService_DeleteCertificate_Errors(t *testing.T) {
|
||||
// Create and upload cert
|
||||
domain := "to-delete.com"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
cert, err := cs.UploadCertificate("To Delete", string(certPEM), "FAKE KEY")
|
||||
certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry)
|
||||
cert, err := cs.UploadCertificate("To Delete", string(certPEM), string(keyPEM), "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Look up numeric ID for verification
|
||||
dbID := certDBID(t, db, cert.UUID)
|
||||
|
||||
// Manually remove the file (custom certs stored by numeric ID)
|
||||
certPath := filepath.Join(tmpDir, "certificates", "custom", "cert.crt")
|
||||
_ = os.Remove(certPath)
|
||||
|
||||
// Delete should still work (DB cleanup)
|
||||
err = cs.DeleteCertificate(cert.ID)
|
||||
err = cs.DeleteCertificate(cert.UUID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify DB record is gone
|
||||
var dbCert models.SSLCertificate
|
||||
err = db.First(&dbCert, "id = ?", cert.ID).Error
|
||||
err = db.First(&dbCert, "id = ?", dbID).Error
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
@@ -781,9 +798,8 @@ func TestCertificateService_CertificateWithSANs(t *testing.T) {
|
||||
domain := "san.example.com"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCertWithSANs(t, domain, []string{"san.example.com", "www.san.example.com", "api.san.example.com"}, expiry)
|
||||
keyPEM := []byte("FAKE PRIVATE KEY")
|
||||
|
||||
cert, err := cs.UploadCertificate("SAN Cert", string(certPEM), string(keyPEM))
|
||||
cert, err := cs.UploadCertificate("SAN Cert", string(certPEM), "", "")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, cert)
|
||||
// Should have joined SANs
|
||||
@@ -807,10 +823,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
|
||||
domain := "unused.com"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
cert, err := cs.UploadCertificate("Unused", string(certPEM), "FAKE KEY")
|
||||
cert, err := cs.UploadCertificate("Unused", string(certPEM), "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
inUse, err := cs.IsCertificateInUse(cert.ID)
|
||||
dbID := certDBID(t, db, cert.UUID)
|
||||
inUse, err := cs.IsCertificateInUse(dbID)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, inUse)
|
||||
})
|
||||
@@ -820,9 +837,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
|
||||
domain := "used.com"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
cert, err := cs.UploadCertificate("Used", string(certPEM), "FAKE KEY")
|
||||
cert, err := cs.UploadCertificate("Used", string(certPEM), "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
dbID := certDBID(t, db, cert.UUID)
|
||||
|
||||
// Create proxy host using this certificate
|
||||
ph := models.ProxyHost{
|
||||
UUID: "ph-1",
|
||||
@@ -830,11 +849,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
|
||||
DomainNames: "used.com",
|
||||
ForwardHost: "localhost",
|
||||
ForwardPort: 8080,
|
||||
CertificateID: &cert.ID,
|
||||
CertificateID: &dbID,
|
||||
}
|
||||
require.NoError(t, db.Create(&ph).Error)
|
||||
|
||||
inUse, err := cs.IsCertificateInUse(cert.ID)
|
||||
inUse, err := cs.IsCertificateInUse(dbID)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, inUse)
|
||||
})
|
||||
@@ -844,9 +863,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
|
||||
domain := "shared.com"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
cert, err := cs.UploadCertificate("Shared", string(certPEM), "FAKE KEY")
|
||||
cert, err := cs.UploadCertificate("Shared", string(certPEM), "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
dbID := certDBID(t, db, cert.UUID)
|
||||
|
||||
// Create multiple proxy hosts using this certificate
|
||||
for i := 1; i <= 3; i++ {
|
||||
ph := models.ProxyHost{
|
||||
@@ -855,12 +876,12 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
|
||||
DomainNames: fmt.Sprintf("host%d.shared.com", i),
|
||||
ForwardHost: "localhost",
|
||||
ForwardPort: 8080 + i,
|
||||
CertificateID: &cert.ID,
|
||||
CertificateID: &dbID,
|
||||
}
|
||||
require.NoError(t, db.Create(&ph).Error)
|
||||
}
|
||||
|
||||
inUse, err := cs.IsCertificateInUse(cert.ID)
|
||||
inUse, err := cs.IsCertificateInUse(dbID)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, inUse)
|
||||
})
|
||||
@@ -876,9 +897,11 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
|
||||
domain := "freed.com"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
cert, err := cs.UploadCertificate("Freed", string(certPEM), "FAKE KEY")
|
||||
cert, err := cs.UploadCertificate("Freed", string(certPEM), "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
dbID := certDBID(t, db, cert.UUID)
|
||||
|
||||
// Create proxy host using this certificate
|
||||
ph := models.ProxyHost{
|
||||
UUID: "ph-freed",
|
||||
@@ -886,12 +909,12 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
|
||||
DomainNames: "freed.com",
|
||||
ForwardHost: "localhost",
|
||||
ForwardPort: 8080,
|
||||
CertificateID: &cert.ID,
|
||||
CertificateID: &dbID,
|
||||
}
|
||||
require.NoError(t, db.Create(&ph).Error)
|
||||
|
||||
// Verify in use
|
||||
inUse, err := cs.IsCertificateInUse(cert.ID)
|
||||
inUse, err := cs.IsCertificateInUse(dbID)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, inUse)
|
||||
|
||||
@@ -899,12 +922,12 @@ func TestCertificateService_IsCertificateInUse(t *testing.T) {
|
||||
require.NoError(t, db.Delete(&ph).Error)
|
||||
|
||||
// Verify no longer in use
|
||||
inUse, err = cs.IsCertificateInUse(cert.ID)
|
||||
inUse, err = cs.IsCertificateInUse(dbID)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, inUse)
|
||||
|
||||
// Now deletion should succeed
|
||||
err = cs.DeleteCertificate(cert.ID)
|
||||
err = cs.DeleteCertificate(cert.UUID)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -922,10 +945,9 @@ func TestCertificateService_CacheBehavior(t *testing.T) {
|
||||
// Create a cert
|
||||
domain := "cache.example.com"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
certPEM := generateTestCert(t, domain, expiry)
|
||||
keyPEM := []byte("FAKE PRIVATE KEY")
|
||||
certPEM, keyPEM := generateTestCertAndKey(t, domain, expiry)
|
||||
|
||||
cert, err := cs.UploadCertificate("Cache Test", string(certPEM), string(keyPEM))
|
||||
cert, err := cs.UploadCertificate("Cache Test", string(certPEM), string(keyPEM), "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, cert)
|
||||
|
||||
@@ -940,7 +962,7 @@ func TestCertificateService_CacheBehavior(t *testing.T) {
|
||||
require.Len(t, certs2, 1)
|
||||
|
||||
// Both should return the same cert
|
||||
assert.Equal(t, certs1[0].ID, certs2[0].ID)
|
||||
assert.Equal(t, certs1[0].UUID, certs2[0].UUID)
|
||||
})
|
||||
|
||||
t.Run("invalidate cache forces resync", func(t *testing.T) {
|
||||
@@ -954,7 +976,7 @@ func TestCertificateService_CacheBehavior(t *testing.T) {
|
||||
|
||||
// Create a cert via upload (auto-invalidates)
|
||||
certPEM := generateTestCert(t, "invalidate.example.com", time.Now().Add(24*time.Hour))
|
||||
_, err = cs.UploadCertificate("Invalidate Test", string(certPEM), "")
|
||||
_, err = cs.UploadCertificate("Invalidate Test", string(certPEM), "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get list (should have 1)
|
||||
@@ -1012,7 +1034,7 @@ func TestCertificateService_CacheBehavior(t *testing.T) {
|
||||
certs, err := cs.ListCertificates()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, certs, 1)
|
||||
assert.Equal(t, "db.example.com", certs[0].Domain)
|
||||
assert.Equal(t, "db.example.com", certs[0].Domains)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1032,7 +1054,7 @@ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
cert, err := cs.UploadCertificate("Corrupted", corruptedPEM, "")
|
||||
cert, err := cs.UploadCertificate("Corrupted", corruptedPEM, "", "")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, cert)
|
||||
assert.Contains(t, err.Error(), "failed to parse certificate")
|
||||
@@ -1047,7 +1069,7 @@ A7qVvdqxevEuUkW4K+2KdMXmnQbG9Aa7k7eBjK1S+0LYmVjPKlJGNXHDGuy5Fw/d
|
||||
hI6GH4twrbDJCR2Bwy/XWXgqgGRzAgMBAAECgYBYWVtLze8R+KrZdHj0hLjZEPnl
|
||||
-----END PRIVATE KEY-----`
|
||||
|
||||
cert, err := cs.UploadCertificate("Wrong Type", wrongTypePEM, "")
|
||||
cert, err := cs.UploadCertificate("Wrong Type", wrongTypePEM, "", "")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, cert)
|
||||
assert.Contains(t, err.Error(), "failed to parse certificate")
|
||||
@@ -1070,7 +1092,7 @@ hI6GH4twrbDJCR2Bwy/XWXgqgGRzAgMBAAECgYBYWVtLze8R+KrZdHj0hLjZEPnl
|
||||
require.NoError(t, err)
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
|
||||
|
||||
cert, err := cs.UploadCertificate("Empty Subject", string(certPEM), "")
|
||||
cert, err := cs.UploadCertificate("Empty Subject", string(certPEM), "", "")
|
||||
assert.NoError(t, err) // Upload succeeds
|
||||
assert.NotNil(t, cert)
|
||||
assert.Equal(t, "", cert.Domains) // Empty domains field
|
||||
@@ -1165,7 +1187,7 @@ func TestCertificateService_SyncFromDisk_ErrorHandling(t *testing.T) {
|
||||
certs, err := cs.ListCertificates()
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, certs, 1)
|
||||
assert.Equal(t, validDomain, certs[0].Domain)
|
||||
assert.Equal(t, validDomain, certs[0].Domains)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1233,7 +1255,7 @@ func TestCertificateService_RefreshCacheFromDB_EdgeCases(t *testing.T) {
|
||||
require.Len(t, certs, 1)
|
||||
// Should use proxy host name
|
||||
assert.Equal(t, "Matched Proxy", certs[0].Name)
|
||||
assert.Contains(t, certs[0].Domain, "www.example.com")
|
||||
assert.Contains(t, certs[0].Domains, "www.example.com")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"software.sslmate.com/src/go-pkcs12"
|
||||
)
|
||||
|
||||
// CertFormat represents a certificate file format.
|
||||
type CertFormat string
|
||||
|
||||
const (
|
||||
FormatPEM CertFormat = "pem"
|
||||
FormatDER CertFormat = "der"
|
||||
FormatPFX CertFormat = "pfx"
|
||||
FormatUnknown CertFormat = "unknown"
|
||||
)
|
||||
|
||||
// ParsedCertificate contains the parsed result of certificate input.
|
||||
type ParsedCertificate struct {
|
||||
Leaf *x509.Certificate
|
||||
Intermediates []*x509.Certificate
|
||||
PrivateKey crypto.PrivateKey
|
||||
CertPEM string
|
||||
KeyPEM string
|
||||
ChainPEM string
|
||||
Format CertFormat
|
||||
}
|
||||
|
||||
// CertificateMetadata contains extracted metadata from an x509 certificate.
|
||||
type CertificateMetadata struct {
|
||||
CommonName string
|
||||
Domains []string
|
||||
Fingerprint string
|
||||
SerialNumber string
|
||||
IssuerOrg string
|
||||
KeyType string
|
||||
NotBefore time.Time
|
||||
NotAfter time.Time
|
||||
}
|
||||
|
||||
// ValidationResult contains the result of a certificate validation.
|
||||
type ValidationResult struct {
|
||||
Valid bool `json:"valid"`
|
||||
CommonName string `json:"common_name"`
|
||||
Domains []string `json:"domains"`
|
||||
IssuerOrg string `json:"issuer_org"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
KeyMatch bool `json:"key_match"`
|
||||
ChainValid bool `json:"chain_valid"`
|
||||
ChainDepth int `json:"chain_depth"`
|
||||
Warnings []string `json:"warnings"`
|
||||
Errors []string `json:"errors"`
|
||||
}
|
||||
|
||||
// DetectFormat determines the certificate format from raw file content.
|
||||
// Uses trial-parse strategy: PEM → PFX → DER.
|
||||
func DetectFormat(data []byte) CertFormat {
|
||||
block, _ := pem.Decode(data)
|
||||
if block != nil {
|
||||
return FormatPEM
|
||||
}
|
||||
|
||||
if _, _, _, err := pkcs12.DecodeChain(data, ""); err == nil {
|
||||
return FormatPFX
|
||||
}
|
||||
// PFX with empty password failed, but it could be password-protected
|
||||
// If data starts with PKCS12 magic bytes (ASN.1 SEQUENCE), treat as PFX candidate
|
||||
if len(data) > 2 && data[0] == 0x30 {
|
||||
// Could be DER or PFX; try DER parse
|
||||
if _, err := x509.ParseCertificate(data); err == nil {
|
||||
return FormatDER
|
||||
}
|
||||
// If DER parse fails, it's likely PFX
|
||||
return FormatPFX
|
||||
}
|
||||
|
||||
if _, err := x509.ParseCertificate(data); err == nil {
|
||||
return FormatDER
|
||||
}
|
||||
|
||||
return FormatUnknown
|
||||
}
|
||||
|
||||
// ParseCertificateInput handles PEM, PFX, and DER input parsing.
|
||||
func ParseCertificateInput(certData []byte, keyData []byte, chainData []byte, pfxPassword string) (*ParsedCertificate, error) {
|
||||
if len(certData) == 0 {
|
||||
return nil, fmt.Errorf("certificate data is empty")
|
||||
}
|
||||
|
||||
format := DetectFormat(certData)
|
||||
|
||||
switch format {
|
||||
case FormatPEM:
|
||||
return parsePEMInput(certData, keyData, chainData)
|
||||
case FormatPFX:
|
||||
return parsePFXInput(certData, pfxPassword)
|
||||
case FormatDER:
|
||||
return parseDERInput(certData, keyData)
|
||||
default:
|
||||
return nil, fmt.Errorf("unrecognized certificate format")
|
||||
}
|
||||
}
|
||||
|
||||
func parsePEMInput(certData []byte, keyData []byte, chainData []byte) (*ParsedCertificate, error) {
|
||||
result := &ParsedCertificate{Format: FormatPEM}
|
||||
|
||||
// Parse leaf certificate
|
||||
certs, err := parsePEMCertificates(certData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate PEM: %w", err)
|
||||
}
|
||||
if len(certs) == 0 {
|
||||
return nil, fmt.Errorf("no certificates found in PEM data")
|
||||
}
|
||||
|
||||
result.Leaf = certs[0]
|
||||
result.CertPEM = string(certData)
|
||||
|
||||
// If certData contains multiple certs, treat extras as intermediates
|
||||
if len(certs) > 1 {
|
||||
result.Intermediates = certs[1:]
|
||||
}
|
||||
|
||||
// Parse chain file if provided
|
||||
if len(chainData) > 0 {
|
||||
chainCerts, err := parsePEMCertificates(chainData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse chain PEM: %w", err)
|
||||
}
|
||||
result.Intermediates = append(result.Intermediates, chainCerts...)
|
||||
result.ChainPEM = string(chainData)
|
||||
}
|
||||
|
||||
// Build chain PEM from intermediates if not set from chain file
|
||||
if result.ChainPEM == "" && len(result.Intermediates) > 0 {
|
||||
var chainBuilder strings.Builder
|
||||
for _, ic := range result.Intermediates {
|
||||
if err := pem.Encode(&chainBuilder, &pem.Block{Type: "CERTIFICATE", Bytes: ic.Raw}); err != nil {
|
||||
return nil, fmt.Errorf("failed to encode intermediate certificate: %w", err)
|
||||
}
|
||||
}
|
||||
result.ChainPEM = chainBuilder.String()
|
||||
}
|
||||
|
||||
// Parse private key
|
||||
if len(keyData) > 0 {
|
||||
key, err := parsePEMPrivateKey(keyData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key PEM: %w", err)
|
||||
}
|
||||
result.PrivateKey = key
|
||||
result.KeyPEM = string(keyData)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parsePFXInput(pfxData []byte, password string) (*ParsedCertificate, error) {
|
||||
privateKey, leaf, caCerts, err := pkcs12.DecodeChain(pfxData, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode PFX/PKCS12: %w", err)
|
||||
}
|
||||
|
||||
result := &ParsedCertificate{
|
||||
Format: FormatPFX,
|
||||
Leaf: leaf,
|
||||
Intermediates: caCerts,
|
||||
PrivateKey: privateKey,
|
||||
}
|
||||
|
||||
// Convert to PEM for storage
|
||||
result.CertPEM = encodeCertToPEM(leaf)
|
||||
|
||||
if len(caCerts) > 0 {
|
||||
var chainBuilder strings.Builder
|
||||
for _, ca := range caCerts {
|
||||
chainBuilder.WriteString(encodeCertToPEM(ca))
|
||||
}
|
||||
result.ChainPEM = chainBuilder.String()
|
||||
}
|
||||
|
||||
keyPEM, err := encodeKeyToPEM(privateKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode private key to PEM: %w", err)
|
||||
}
|
||||
result.KeyPEM = keyPEM
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func parseDERInput(certData []byte, keyData []byte) (*ParsedCertificate, error) {
|
||||
cert, err := x509.ParseCertificate(certData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse DER certificate: %w", err)
|
||||
}
|
||||
|
||||
result := &ParsedCertificate{
|
||||
Format: FormatDER,
|
||||
Leaf: cert,
|
||||
CertPEM: encodeCertToPEM(cert),
|
||||
}
|
||||
|
||||
if len(keyData) > 0 {
|
||||
key, err := parsePEMPrivateKey(keyData)
|
||||
if err != nil {
|
||||
// Try DER key
|
||||
key, err = x509.ParsePKCS8PrivateKey(keyData)
|
||||
if err != nil {
|
||||
key2, err2 := x509.ParseECPrivateKey(keyData)
|
||||
if err2 != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key: %w", err)
|
||||
}
|
||||
key = key2
|
||||
}
|
||||
}
|
||||
result.PrivateKey = key
|
||||
keyPEM, err := encodeKeyToPEM(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode private key to PEM: %w", err)
|
||||
}
|
||||
result.KeyPEM = keyPEM
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ValidateKeyMatch checks that the private key matches the certificate public key.
|
||||
func ValidateKeyMatch(cert *x509.Certificate, key crypto.PrivateKey) error {
|
||||
if cert == nil {
|
||||
return fmt.Errorf("certificate is nil")
|
||||
}
|
||||
if key == nil {
|
||||
return fmt.Errorf("private key is nil")
|
||||
}
|
||||
|
||||
switch pub := cert.PublicKey.(type) {
|
||||
case *rsa.PublicKey:
|
||||
privKey, ok := key.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("key type mismatch: certificate has RSA public key but private key is not RSA")
|
||||
}
|
||||
if pub.N.Cmp(privKey.N) != 0 {
|
||||
return fmt.Errorf("RSA key mismatch: certificate and private key modulus differ")
|
||||
}
|
||||
case *ecdsa.PublicKey:
|
||||
privKey, ok := key.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("key type mismatch: certificate has ECDSA public key but private key is not ECDSA")
|
||||
}
|
||||
if pub.X.Cmp(privKey.X) != 0 || pub.Y.Cmp(privKey.Y) != 0 {
|
||||
return fmt.Errorf("ECDSA key mismatch: certificate and private key points differ")
|
||||
}
|
||||
case ed25519.PublicKey:
|
||||
privKey, ok := key.(ed25519.PrivateKey)
|
||||
if !ok {
|
||||
return fmt.Errorf("key type mismatch: certificate has Ed25519 public key but private key is not Ed25519")
|
||||
}
|
||||
pubFromPriv := privKey.Public().(ed25519.PublicKey)
|
||||
if !pub.Equal(pubFromPriv) {
|
||||
return fmt.Errorf("Ed25519 key mismatch: certificate and private key differ")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported public key type: %T", cert.PublicKey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateChain verifies the certificate chain from leaf to root.
|
||||
func ValidateChain(leaf *x509.Certificate, intermediates []*x509.Certificate) error {
|
||||
if leaf == nil {
|
||||
return fmt.Errorf("leaf certificate is nil")
|
||||
}
|
||||
|
||||
pool := x509.NewCertPool()
|
||||
for _, ic := range intermediates {
|
||||
pool.AddCert(ic)
|
||||
}
|
||||
|
||||
opts := x509.VerifyOptions{
|
||||
Intermediates: pool,
|
||||
CurrentTime: time.Now(),
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}
|
||||
|
||||
if _, err := leaf.Verify(opts); err != nil {
|
||||
return fmt.Errorf("chain verification failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertDERToPEM converts DER-encoded certificate to PEM.
|
||||
func ConvertDERToPEM(derData []byte) (string, error) {
|
||||
cert, err := x509.ParseCertificate(derData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid DER data: %w", err)
|
||||
}
|
||||
return encodeCertToPEM(cert), nil
|
||||
}
|
||||
|
||||
// ConvertPFXToPEM extracts cert, key, and chain from PFX/PKCS12.
|
||||
func ConvertPFXToPEM(pfxData []byte, password string) (certPEM string, keyPEM string, chainPEM string, err error) {
|
||||
privateKey, leaf, caCerts, err := pkcs12.DecodeChain(pfxData, password)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to decode PFX: %w", err)
|
||||
}
|
||||
|
||||
certPEM = encodeCertToPEM(leaf)
|
||||
|
||||
keyPEM, err = encodeKeyToPEM(privateKey)
|
||||
if err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to encode key: %w", err)
|
||||
}
|
||||
|
||||
if len(caCerts) > 0 {
|
||||
var builder strings.Builder
|
||||
for _, ca := range caCerts {
|
||||
builder.WriteString(encodeCertToPEM(ca))
|
||||
}
|
||||
chainPEM = builder.String()
|
||||
}
|
||||
|
||||
return certPEM, keyPEM, chainPEM, nil
|
||||
}
|
||||
|
||||
// ConvertPEMToPFX bundles cert, key, chain into PFX.
|
||||
func ConvertPEMToPFX(certPEM string, keyPEM string, chainPEM string, password string) ([]byte, error) {
|
||||
certs, err := parsePEMCertificates([]byte(certPEM))
|
||||
if err != nil || len(certs) == 0 {
|
||||
return nil, fmt.Errorf("failed to parse cert PEM: %w", err)
|
||||
}
|
||||
|
||||
key, err := parsePEMPrivateKey([]byte(keyPEM))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse key PEM: %w", err)
|
||||
}
|
||||
|
||||
var caCerts []*x509.Certificate
|
||||
if chainPEM != "" {
|
||||
caCerts, err = parsePEMCertificates([]byte(chainPEM))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse chain PEM: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
pfxData, err := pkcs12.Modern.Encode(key, certs[0], caCerts, password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode PFX: %w", err)
|
||||
}
|
||||
|
||||
return pfxData, nil
|
||||
}
|
||||
|
||||
// ConvertPEMToDER converts PEM certificate to DER.
|
||||
func ConvertPEMToDER(certPEM string) ([]byte, error) {
|
||||
block, _ := pem.Decode([]byte(certPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode PEM")
|
||||
}
|
||||
// Verify it's a valid certificate
|
||||
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
|
||||
return nil, fmt.Errorf("invalid certificate PEM: %w", err)
|
||||
}
|
||||
return block.Bytes, nil
|
||||
}
|
||||
|
||||
// ExtractCertificateMetadata extracts fingerprint, serial, issuer, key type, etc.
|
||||
func ExtractCertificateMetadata(cert *x509.Certificate) *CertificateMetadata {
|
||||
if cert == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
fingerprint := sha256.Sum256(cert.Raw)
|
||||
fpHex := formatFingerprint(hex.EncodeToString(fingerprint[:]))
|
||||
|
||||
serial := formatSerial(cert.SerialNumber)
|
||||
|
||||
issuerOrg := ""
|
||||
if len(cert.Issuer.Organization) > 0 {
|
||||
issuerOrg = cert.Issuer.Organization[0]
|
||||
}
|
||||
|
||||
domains := make([]string, 0, len(cert.DNSNames)+1)
|
||||
if cert.Subject.CommonName != "" {
|
||||
domains = append(domains, cert.Subject.CommonName)
|
||||
}
|
||||
for _, san := range cert.DNSNames {
|
||||
if san != cert.Subject.CommonName {
|
||||
domains = append(domains, san)
|
||||
}
|
||||
}
|
||||
|
||||
return &CertificateMetadata{
|
||||
CommonName: cert.Subject.CommonName,
|
||||
Domains: domains,
|
||||
Fingerprint: fpHex,
|
||||
SerialNumber: serial,
|
||||
IssuerOrg: issuerOrg,
|
||||
KeyType: detectKeyType(cert),
|
||||
NotBefore: cert.NotBefore,
|
||||
NotAfter: cert.NotAfter,
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func parsePEMCertificates(data []byte) ([]*x509.Certificate, error) {
|
||||
var certs []*x509.Certificate
|
||||
rest := data
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
certs = append(certs, cert)
|
||||
}
|
||||
return certs, nil
|
||||
}
|
||||
|
||||
func parsePEMPrivateKey(data []byte) (crypto.PrivateKey, error) {
|
||||
block, _ := pem.Decode(data)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("no PEM data found")
|
||||
}
|
||||
|
||||
// Try PKCS8 first (handles RSA, ECDSA, Ed25519)
|
||||
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Try PKCS1 RSA
|
||||
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Try EC
|
||||
if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("unsupported private key format")
|
||||
}
|
||||
|
||||
func encodeCertToPEM(cert *x509.Certificate) string {
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw}))
|
||||
}
|
||||
|
||||
func encodeKeyToPEM(key crypto.PrivateKey) (string, error) {
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal private key: %w", err)
|
||||
}
|
||||
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})), nil
|
||||
}
|
||||
|
||||
func formatFingerprint(hex string) string {
|
||||
var parts []string
|
||||
for i := 0; i < len(hex); i += 2 {
|
||||
end := i + 2
|
||||
if end > len(hex) {
|
||||
end = len(hex)
|
||||
}
|
||||
parts = append(parts, strings.ToUpper(hex[i:end]))
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
func formatSerial(n *big.Int) string {
|
||||
if n == nil {
|
||||
return ""
|
||||
}
|
||||
b := n.Bytes()
|
||||
parts := make([]string, len(b))
|
||||
for i, v := range b {
|
||||
parts[i] = fmt.Sprintf("%02X", v)
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
|
||||
func detectKeyType(cert *x509.Certificate) string {
|
||||
switch pub := cert.PublicKey.(type) {
|
||||
case *rsa.PublicKey:
|
||||
bits := pub.N.BitLen()
|
||||
return fmt.Sprintf("RSA-%d", bits)
|
||||
case *ecdsa.PublicKey:
|
||||
switch pub.Curve {
|
||||
case elliptic.P256():
|
||||
return "ECDSA-P256"
|
||||
case elliptic.P384():
|
||||
return "ECDSA-P384"
|
||||
default:
|
||||
return "ECDSA"
|
||||
}
|
||||
case ed25519.PublicKey:
|
||||
return "Ed25519"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/ed25519"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func makeRSACertAndKey(t *testing.T, cn string, expiry time.Time) (*x509.Certificate, *rsa.PrivateKey, []byte, []byte) {
|
||||
t.Helper()
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: expiry,
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
require.NoError(t, err)
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
|
||||
return cert, priv, certPEM, keyPEM
|
||||
}
|
||||
|
||||
func makeECDSACertAndKey(t *testing.T, cn string) (*x509.Certificate, *ecdsa.PrivateKey, []byte, []byte) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(2),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
require.NoError(t, err)
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
require.NoError(t, err)
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
return cert, priv, certPEM, keyPEM
|
||||
}
|
||||
|
||||
func makeEd25519CertAndKey(t *testing.T, cn string) (*x509.Certificate, ed25519.PrivateKey, []byte, []byte) {
|
||||
t.Helper()
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(3),
|
||||
Subject: pkix.Name{CommonName: cn},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, pub, priv)
|
||||
require.NoError(t, err)
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(priv)
|
||||
require.NoError(t, err)
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
return cert, priv, certPEM, keyPEM
|
||||
}
|
||||
|
||||
// --- DetectFormat ---
|
||||
|
||||
func TestDetectFormat(t *testing.T) {
|
||||
cert, _, certPEM, _ := makeRSACertAndKey(t, "test.com", time.Now().Add(time.Hour))
|
||||
|
||||
t.Run("PEM format", func(t *testing.T) {
|
||||
assert.Equal(t, FormatPEM, DetectFormat(certPEM))
|
||||
})
|
||||
|
||||
t.Run("DER format", func(t *testing.T) {
|
||||
assert.Equal(t, FormatDER, DetectFormat(cert.Raw))
|
||||
})
|
||||
|
||||
t.Run("unknown format", func(t *testing.T) {
|
||||
assert.Equal(t, FormatUnknown, DetectFormat([]byte("not a cert")))
|
||||
})
|
||||
|
||||
t.Run("empty data", func(t *testing.T) {
|
||||
assert.Equal(t, FormatUnknown, DetectFormat([]byte{}))
|
||||
})
|
||||
}
|
||||
|
||||
// --- ParseCertificateInput ---
|
||||
|
||||
func TestParseCertificateInput(t *testing.T) {
|
||||
t.Run("PEM cert only", func(t *testing.T) {
|
||||
_, _, certPEM, _ := makeRSACertAndKey(t, "pem.test", time.Now().Add(time.Hour))
|
||||
parsed, err := ParseCertificateInput(certPEM, nil, nil, "")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, parsed.Leaf)
|
||||
assert.Equal(t, FormatPEM, parsed.Format)
|
||||
assert.Nil(t, parsed.PrivateKey)
|
||||
})
|
||||
|
||||
t.Run("PEM cert with key", func(t *testing.T) {
|
||||
_, _, certPEM, keyPEM := makeRSACertAndKey(t, "pem-key.test", time.Now().Add(time.Hour))
|
||||
parsed, err := ParseCertificateInput(certPEM, keyPEM, nil, "")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, parsed.Leaf)
|
||||
assert.NotNil(t, parsed.PrivateKey)
|
||||
assert.Equal(t, FormatPEM, parsed.Format)
|
||||
})
|
||||
|
||||
t.Run("DER cert", func(t *testing.T) {
|
||||
cert, _, _, _ := makeRSACertAndKey(t, "der.test", time.Now().Add(time.Hour))
|
||||
parsed, err := ParseCertificateInput(cert.Raw, nil, nil, "")
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, parsed.Leaf)
|
||||
assert.Equal(t, FormatDER, parsed.Format)
|
||||
})
|
||||
|
||||
t.Run("empty data returns error", func(t *testing.T) {
|
||||
_, err := ParseCertificateInput(nil, nil, nil, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "empty")
|
||||
})
|
||||
|
||||
t.Run("unrecognized format returns error", func(t *testing.T) {
|
||||
_, err := ParseCertificateInput([]byte("garbage"), nil, nil, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unrecognized")
|
||||
})
|
||||
|
||||
t.Run("invalid key PEM returns error", func(t *testing.T) {
|
||||
_, _, certPEM, _ := makeRSACertAndKey(t, "badkey.test", time.Now().Add(time.Hour))
|
||||
_, err := ParseCertificateInput(certPEM, []byte("not-key"), nil, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "private key")
|
||||
})
|
||||
}
|
||||
|
||||
// --- ValidateKeyMatch ---
|
||||
|
||||
func TestValidateKeyMatch(t *testing.T) {
|
||||
t.Run("RSA matching", func(t *testing.T) {
|
||||
cert, priv, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour))
|
||||
assert.NoError(t, ValidateKeyMatch(cert, priv))
|
||||
})
|
||||
|
||||
t.Run("RSA mismatched", func(t *testing.T) {
|
||||
cert, _, _, _ := makeRSACertAndKey(t, "rsa1.test", time.Now().Add(time.Hour))
|
||||
_, otherPriv, _, _ := makeRSACertAndKey(t, "rsa2.test", time.Now().Add(time.Hour))
|
||||
err := ValidateKeyMatch(cert, otherPriv)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "mismatch")
|
||||
})
|
||||
|
||||
t.Run("ECDSA matching", func(t *testing.T) {
|
||||
cert, priv, _, _ := makeECDSACertAndKey(t, "ecdsa.test")
|
||||
assert.NoError(t, ValidateKeyMatch(cert, priv))
|
||||
})
|
||||
|
||||
t.Run("ECDSA mismatched", func(t *testing.T) {
|
||||
cert, _, _, _ := makeECDSACertAndKey(t, "ec1.test")
|
||||
_, other, _, _ := makeECDSACertAndKey(t, "ec2.test")
|
||||
assert.Error(t, ValidateKeyMatch(cert, other))
|
||||
})
|
||||
|
||||
t.Run("Ed25519 matching", func(t *testing.T) {
|
||||
cert, priv, _, _ := makeEd25519CertAndKey(t, "ed.test")
|
||||
assert.NoError(t, ValidateKeyMatch(cert, priv))
|
||||
})
|
||||
|
||||
t.Run("Ed25519 mismatched", func(t *testing.T) {
|
||||
cert, _, _, _ := makeEd25519CertAndKey(t, "ed1.test")
|
||||
_, other, _, _ := makeEd25519CertAndKey(t, "ed2.test")
|
||||
assert.Error(t, ValidateKeyMatch(cert, other))
|
||||
})
|
||||
|
||||
t.Run("type mismatch RSA cert with ECDSA key", func(t *testing.T) {
|
||||
cert, _, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour))
|
||||
_, ecKey, _, _ := makeECDSACertAndKey(t, "ec.test")
|
||||
err := ValidateKeyMatch(cert, ecKey)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "type mismatch")
|
||||
})
|
||||
|
||||
t.Run("nil certificate", func(t *testing.T) {
|
||||
_, priv, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour))
|
||||
assert.Error(t, ValidateKeyMatch(nil, priv))
|
||||
})
|
||||
|
||||
t.Run("nil key", func(t *testing.T) {
|
||||
cert, _, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour))
|
||||
assert.Error(t, ValidateKeyMatch(cert, nil))
|
||||
})
|
||||
}
|
||||
|
||||
// --- ValidateChain ---
|
||||
|
||||
func TestValidateChain(t *testing.T) {
|
||||
t.Run("nil leaf returns error", func(t *testing.T) {
|
||||
err := ValidateChain(nil, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "nil")
|
||||
})
|
||||
|
||||
t.Run("self-signed cert validates", func(t *testing.T) {
|
||||
cert, _, _, _ := makeRSACertAndKey(t, "self.test", time.Now().Add(time.Hour))
|
||||
// Self-signed won't pass chain validation without being a CA
|
||||
err := ValidateChain(cert, nil)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// --- ConvertDERToPEM ---
|
||||
|
||||
func TestConvertDERToPEM(t *testing.T) {
|
||||
t.Run("valid DER", func(t *testing.T) {
|
||||
cert, _, _, _ := makeRSACertAndKey(t, "der.test", time.Now().Add(time.Hour))
|
||||
pemStr, err := ConvertDERToPEM(cert.Raw)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, pemStr, "BEGIN CERTIFICATE")
|
||||
})
|
||||
|
||||
t.Run("invalid DER", func(t *testing.T) {
|
||||
_, err := ConvertDERToPEM([]byte("not-der"))
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// --- ConvertPEMToDER ---
|
||||
|
||||
func TestConvertPEMToDER(t *testing.T) {
|
||||
t.Run("valid PEM", func(t *testing.T) {
|
||||
_, _, certPEM, _ := makeRSACertAndKey(t, "p2d.test", time.Now().Add(time.Hour))
|
||||
der, err := ConvertPEMToDER(string(certPEM))
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, der)
|
||||
// Round-trip
|
||||
cert, err := x509.ParseCertificate(der)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "p2d.test", cert.Subject.CommonName)
|
||||
})
|
||||
|
||||
t.Run("invalid PEM", func(t *testing.T) {
|
||||
_, err := ConvertPEMToDER("not-pem")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// --- ExtractCertificateMetadata ---
|
||||
|
||||
func TestExtractCertificateMetadata(t *testing.T) {
|
||||
t.Run("nil cert returns nil", func(t *testing.T) {
|
||||
assert.Nil(t, ExtractCertificateMetadata(nil))
|
||||
})
|
||||
|
||||
t.Run("RSA cert metadata", func(t *testing.T) {
|
||||
cert, _, _, _ := makeRSACertAndKey(t, "meta.test", time.Now().Add(time.Hour))
|
||||
m := ExtractCertificateMetadata(cert)
|
||||
require.NotNil(t, m)
|
||||
assert.Equal(t, "meta.test", m.CommonName)
|
||||
assert.Contains(t, m.KeyType, "RSA")
|
||||
assert.NotEmpty(t, m.Fingerprint)
|
||||
assert.NotEmpty(t, m.SerialNumber)
|
||||
assert.Contains(t, m.Domains, "meta.test")
|
||||
})
|
||||
|
||||
t.Run("ECDSA cert metadata", func(t *testing.T) {
|
||||
cert, _, _, _ := makeECDSACertAndKey(t, "ec-meta.test")
|
||||
m := ExtractCertificateMetadata(cert)
|
||||
require.NotNil(t, m)
|
||||
assert.Contains(t, m.KeyType, "ECDSA")
|
||||
})
|
||||
|
||||
t.Run("Ed25519 cert metadata", func(t *testing.T) {
|
||||
cert, _, _, _ := makeEd25519CertAndKey(t, "ed-meta.test")
|
||||
m := ExtractCertificateMetadata(cert)
|
||||
require.NotNil(t, m)
|
||||
assert.Equal(t, "Ed25519", m.KeyType)
|
||||
})
|
||||
|
||||
t.Run("cert with SANs", func(t *testing.T) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(10),
|
||||
Subject: pkix.Name{CommonName: "main.test"},
|
||||
DNSNames: []string{"main.test", "alt1.test", "alt2.test"},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
require.NoError(t, err)
|
||||
cert, _ := x509.ParseCertificate(der)
|
||||
|
||||
m := ExtractCertificateMetadata(cert)
|
||||
require.NotNil(t, m)
|
||||
assert.Contains(t, m.Domains, "main.test")
|
||||
assert.Contains(t, m.Domains, "alt1.test")
|
||||
assert.Contains(t, m.Domains, "alt2.test")
|
||||
// CN should not be duplicated when it matches a SAN
|
||||
count := 0
|
||||
for _, d := range m.Domains {
|
||||
if d == "main.test" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, count, "CN should not be duplicated in domains list")
|
||||
})
|
||||
|
||||
t.Run("cert with issuer org", func(t *testing.T) {
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(11),
|
||||
Subject: pkix.Name{CommonName: "org.test"},
|
||||
Issuer: pkix.Name{Organization: []string{"Test Org Inc"}},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
require.NoError(t, err)
|
||||
cert, _ := x509.ParseCertificate(der)
|
||||
|
||||
m := ExtractCertificateMetadata(cert)
|
||||
require.NotNil(t, m)
|
||||
// Self-signed cert's issuer org may differ from template
|
||||
assert.NotEmpty(t, m.Fingerprint)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func TestFormatFingerprint(t *testing.T) {
|
||||
assert.Equal(t, "AB:CD:EF", formatFingerprint("abcdef"))
|
||||
assert.Equal(t, "01:23", formatFingerprint("0123"))
|
||||
assert.Equal(t, "", formatFingerprint(""))
|
||||
}
|
||||
|
||||
func TestFormatSerial(t *testing.T) {
|
||||
assert.Equal(t, "01", formatSerial(big.NewInt(1)))
|
||||
assert.Equal(t, "FF", formatSerial(big.NewInt(255)))
|
||||
assert.Equal(t, "", formatSerial(nil))
|
||||
}
|
||||
|
||||
func TestDetectKeyType(t *testing.T) {
|
||||
t.Run("RSA key type", func(t *testing.T) {
|
||||
cert, _, _, _ := makeRSACertAndKey(t, "rsa.test", time.Now().Add(time.Hour))
|
||||
kt := detectKeyType(cert)
|
||||
assert.Contains(t, kt, "RSA-2048")
|
||||
})
|
||||
|
||||
t.Run("ECDSA-P256 key type", func(t *testing.T) {
|
||||
cert, _, _, _ := makeECDSACertAndKey(t, "ec.test")
|
||||
kt := detectKeyType(cert)
|
||||
assert.Equal(t, "ECDSA-P256", kt)
|
||||
})
|
||||
|
||||
t.Run("Ed25519 key type", func(t *testing.T) {
|
||||
cert, _, _, _ := makeEd25519CertAndKey(t, "ed.test")
|
||||
kt := detectKeyType(cert)
|
||||
assert.Equal(t, "Ed25519", kt)
|
||||
})
|
||||
}
|
||||
@@ -12,6 +12,7 @@ func TestNewRFC2136Provider(t *testing.T) {
|
||||
|
||||
if provider == nil {
|
||||
t.Fatal("NewRFC2136Provider() returned nil")
|
||||
return
|
||||
}
|
||||
|
||||
if provider.propagationTimeout != RFC2136DefaultPropagationTimeout {
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
# Nightly Build Vulnerability Remediation Plan
|
||||
|
||||
**Date**: 2026-04-09
|
||||
**Status**: Draft — Awaiting Approval
|
||||
**Scope**: Dependency security patches for 5 HIGH + 3 MEDIUM vulnerability groups
|
||||
**Target**: Single PR — all changes ship together
|
||||
**Archived**: Previous plan (CrowdSec Hub Bootstrapping) → `docs/plans/archive/crowdsec-hub-bootstrap-spec.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The Charon nightly build is failing container image vulnerability scans with **5 HIGH-severity** and **multiple MEDIUM-severity** findings. These vulnerabilities exist across three compiled binaries embedded in the container image:
|
||||
|
||||
1. **Charon backend** (`/app/charon`) — Go binary built from `backend/go.mod`
|
||||
2. **Caddy** (`/usr/bin/caddy`) — Built via xcaddy in the Dockerfile Caddy builder stage
|
||||
3. **CrowdSec** (`/usr/local/bin/crowdsec`, `/usr/local/bin/cscli`) — Built from source in the Dockerfile CrowdSec builder stage
|
||||
|
||||
Additionally, the **nightly branch** was synced from development before the Go 1.26.2 bump landed, so the nightly image was compiled with Go 1.26.1 (confirmed in `ci_failure.log` line 55: `GO_VERSION: 1.26.1`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Research Findings
|
||||
|
||||
### 2.1 Go Version Audit
|
||||
|
||||
All files on `development` / `main` already reference **Go 1.26.2**:
|
||||
|
||||
| File | Current Value | Status |
|
||||
|------|---------------|--------|
|
||||
| `backend/go.mod` | `go 1.26.2` | ✅ Current |
|
||||
| `go.work` | `go 1.26.2` | ✅ Current |
|
||||
| `Dockerfile` (`ARG GO_VERSION`) | `1.26.2` | ✅ Current |
|
||||
| `.github/workflows/nightly-build.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/codecov-upload.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/quality-checks.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/codeql.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/benchmark.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/release-goreleaser.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/workflows/e2e-tests-split.yml` | `'1.26.2'` | ✅ Current |
|
||||
| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | `'1.26.1'` | ❌ **Stale** |
|
||||
| `scripts/install-go-1.26.0.sh` | `1.26.0` | ⚠️ Old install script (not used in CI/Docker builds) |
|
||||
|
||||
**Root Cause of Go stdlib CVEs**: The nightly branch's last sync predated the 1.26.2 bump. The next nightly sync from development will propagate 1.26.2 automatically. The only file requiring a fix is the example workflow.
|
||||
|
||||
### 2.2 Vulnerability Inventory
|
||||
|
||||
#### HIGH Severity (must fix — merge-blocking)
|
||||
|
||||
| # | CVE / GHSA | Package | Current | Fix | Binary | Dep Type |
|
||||
|---|-----------|---------|---------|-----|--------|----------|
|
||||
| 1 | CVE-2026-39883 | `go.opentelemetry.io/otel/sdk` | v1.40.0 | v1.43.0 | Caddy | Transitive (Caddy plugins → otelhttp → otel/sdk) |
|
||||
| 2 | CVE-2026-34986 | `github.com/go-jose/go-jose/v3` | v3.0.4 | **v3.0.5** | Caddy | Transitive (caddy-security → JWT/JOSE stack) |
|
||||
| 3 | CVE-2026-34986 | `github.com/go-jose/go-jose/v4` | v4.1.3 | **v4.1.4** | Caddy | Transitive (grpc v1.79.3 → go-jose/v4) |
|
||||
| 4 | CVE-2026-32286 | `github.com/jackc/pgproto3/v2` | v2.3.3 | pgx/v4 v4.18.3 ¹ | CrowdSec | Transitive (CrowdSec → pgx/v4 v4.18.2 → pgproto3/v2) |
|
||||
|
||||
¹ pgproto3/v2 has **no patched release**. Fix requires upstream migration to pgx/v5 (uses pgproto3/v3). See §5 Risk Assessment.
|
||||
|
||||
#### MEDIUM Severity (fix in same pass)
|
||||
|
||||
| # | CVE / GHSA | Package(s) | Current | Fix | Binary | Dep Type |
|
||||
|---|-----------|------------|---------|-----|--------|----------|
|
||||
| 5 | GHSA-xmrv-pmrh-hhx2 | AWS SDK v2: `eventstream` v1.7.1, `cloudwatchlogs` v1.57.2, `kinesis` v1.40.1, `s3` v1.87.3 | See left | Bump all | CrowdSec | Direct deps of CrowdSec v1.7.7 |
|
||||
| 6 | CVE-2026-32281, -32288, -32289 | Go stdlib | 1.26.1 | **1.26.2** | All (nightly image) | Toolchain |
|
||||
| 7 | CVE-2026-39882 | OTel HTTP exporters: `otlploghttp` v0.16.0, `otlpmetrichttp` v1.40.0, `otlptracehttp` v1.40.0 | See left | Bump all | Caddy | Transitive (Caddy plugins → OTel exporters) |
|
||||
|
||||
### 2.3 Dependency Chain Analysis
|
||||
|
||||
#### Backend (`backend/go.mod`)
|
||||
|
||||
```
|
||||
charon/backend (direct)
|
||||
└─ docker/docker v28.5.2+incompatible (direct)
|
||||
└─ otelhttp v0.68.0 (indirect)
|
||||
└─ otel/sdk v1.43.0 (indirect) — already at latest
|
||||
└─ grpc v1.79.3 (indirect)
|
||||
└─ otlptracehttp v1.42.0 (indirect) ── CVE-2026-39882
|
||||
```
|
||||
|
||||
Backend resolved versions (verified via `go list -m -json`):
|
||||
|
||||
| Package | Version | Type |
|
||||
|---------|---------|------|
|
||||
| `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` | v1.42.0 | indirect |
|
||||
| `google.golang.org/grpc` | v1.79.3 | indirect |
|
||||
| `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` | v0.68.0 | indirect |
|
||||
|
||||
**Not present in backend**: go-jose/v3, go-jose/v4, otel/sdk, pgproto3/v2, AWS SDK, otlploghttp, otlpmetrichttp.
|
||||
|
||||
#### CrowdSec Binary (Dockerfile `crowdsec-builder` stage)
|
||||
|
||||
Source: CrowdSec v1.7.7 `go.mod` (verified via `git clone --depth 1 --branch v1.7.7`):
|
||||
|
||||
```
|
||||
crowdsec v1.7.7
|
||||
└─ pgx/v4 v4.18.2 (direct) → pgproto3/v2 v2.3.3 (indirect) ── CVE-2026-32286
|
||||
└─ aws-sdk-go-v2/service/s3 v1.87.3 (direct) ── GHSA-xmrv-pmrh-hhx2
|
||||
└─ aws-sdk-go-v2/service/cloudwatchlogs v1.57.2 (direct) ── GHSA-xmrv-pmrh-hhx2
|
||||
└─ aws-sdk-go-v2/service/kinesis v1.40.1 (direct) ── GHSA-xmrv-pmrh-hhx2
|
||||
└─ aws-sdk-go-v2/aws/protocol/eventstream v1.7.1 (indirect) ── GHSA-xmrv-pmrh-hhx2
|
||||
└─ otel v1.39.0, otel/metric v1.39.0, otel/trace v1.39.0 (indirect)
|
||||
```
|
||||
|
||||
Confirmed by Trivy image scan (`trivy-image-report.json`): pgproto3/v2 v2.3.3 flagged in `usr/local/bin/crowdsec` and `usr/local/bin/cscli`.
|
||||
|
||||
#### Caddy Binary (Dockerfile `caddy-builder` stage)
|
||||
|
||||
Built via xcaddy with plugins. go.mod is generated at build time. Vulnerable packages enter via:
|
||||
|
||||
```
|
||||
xcaddy build (Caddy v2.11.2 + plugins)
|
||||
└─ caddy-security v1.1.61 → go-jose/v3 (JWT auth stack) ── CVE-2026-34986
|
||||
└─ grpc (patched to v1.79.3 in Dockerfile) → go-jose/v4 v4.1.3 ── CVE-2026-34986
|
||||
└─ Caddy/plugins → otel/sdk v1.40.0 ── CVE-2026-39883
|
||||
└─ Caddy/plugins → otlploghttp v0.16.0, otlpmetrichttp v1.40.0, otlptracehttp v1.40.0 ── CVE-2026-39882
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Technical Specifications
|
||||
|
||||
### 3.1 Backend go.mod Changes
|
||||
|
||||
**File**: `backend/go.mod` (+ `backend/go.sum` auto-generated)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# Upgrade grpc to v1.80.0 (security patches for transitive deps)
|
||||
go get google.golang.org/grpc@v1.80.0
|
||||
|
||||
# CVE-2026-39882: OTel HTTP exporter (backend only has otlptracehttp)
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0
|
||||
|
||||
go mod tidy
|
||||
```
|
||||
|
||||
Expected `go.mod` diff:
|
||||
- `google.golang.org/grpc` v1.79.3 → v1.80.0
|
||||
- `go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp` v1.42.0 → v1.43.0
|
||||
|
||||
### 3.2 Dockerfile — Caddy Builder Stage Patches
|
||||
|
||||
**File**: `Dockerfile`, within the caddy-builder `RUN bash -c '...'` block, in the **Stage 2: Apply security patches** section.
|
||||
|
||||
Add after the existing `go get golang.org/x/net@v${XNET_VERSION};` line:
|
||||
|
||||
```bash
|
||||
# CVE-2026-34986: go-jose JOSE/JWT validation bypass
|
||||
# Fix in v3.0.5 and v4.1.4. Pin here until caddy-security ships fix.
|
||||
# renovate: datasource=go depName=github.com/go-jose/go-jose/v3
|
||||
go get github.com/go-jose/go-jose/v3@v3.0.5; \
|
||||
# renovate: datasource=go depName=github.com/go-jose/go-jose/v4
|
||||
go get github.com/go-jose/go-jose/v4@v4.1.4; \
|
||||
# CVE-2026-39883: OTel SDK resource leak
|
||||
# Fix in v1.43.0. Pin here until Caddy ships with updated OTel.
|
||||
# renovate: datasource=go depName=go.opentelemetry.io/otel/sdk
|
||||
go get go.opentelemetry.io/otel/sdk@v1.43.0; \
|
||||
# CVE-2026-39882: OTel HTTP exporter request smuggling
|
||||
# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp@v0.19.0; \
|
||||
# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp@v1.43.0; \
|
||||
# renovate: datasource=go depName=go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0; \
|
||||
```
|
||||
|
||||
Update existing grpc patch line from `v1.79.3` → `v1.80.0`:
|
||||
|
||||
```bash
|
||||
# Before:
|
||||
go get google.golang.org/grpc@v1.79.3; \
|
||||
# After:
|
||||
# CVE-2026-33186: gRPC-Go auth bypass (fixed in v1.79.3)
|
||||
# CVE-2026-34986: go-jose/v4 transitive fix (requires grpc >= v1.80.0)
|
||||
# renovate: datasource=go depName=google.golang.org/grpc
|
||||
go get google.golang.org/grpc@v1.80.0; \
|
||||
```
|
||||
|
||||
### 3.3 Dockerfile — CrowdSec Builder Stage Patches
|
||||
|
||||
**File**: `Dockerfile`, within the crowdsec-builder `RUN` block that patches dependencies.
|
||||
|
||||
Add after the existing `go get golang.org/x/net@v${XNET_VERSION}` line:
|
||||
|
||||
```bash
|
||||
# CVE-2026-32286: pgproto3/v2 buffer overflow (no v2 fix exists; bump pgx/v4 to latest patch)
|
||||
# renovate: datasource=go depName=github.com/jackc/pgx/v4
|
||||
go get github.com/jackc/pgx/v4@v4.18.3 && \
|
||||
# GHSA-xmrv-pmrh-hhx2: AWS SDK v2 event stream injection
|
||||
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
|
||||
go get github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream@v1.7.8 && \
|
||||
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs
|
||||
go get github.com/aws/aws-sdk-go-v2/service/cloudwatchlogs@v1.68.0 && \
|
||||
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/kinesis
|
||||
go get github.com/aws/aws-sdk-go-v2/service/kinesis@v1.43.5 && \
|
||||
# renovate: datasource=go depName=github.com/aws/aws-sdk-go-v2/service/s3
|
||||
go get github.com/aws/aws-sdk-go-v2/service/s3@v1.99.0 && \
|
||||
```
|
||||
|
||||
CrowdSec grpc already at v1.80.0 — no change needed.
|
||||
|
||||
### 3.4 Example Workflow Fix
|
||||
|
||||
**File**: `.github/skills/examples/gorm-scanner-ci-workflow.yml` (line 28)
|
||||
|
||||
```yaml
|
||||
# Before:
|
||||
go-version: "1.26.1"
|
||||
# After:
|
||||
go-version: "1.26.2"
|
||||
```
|
||||
|
||||
### 3.5 Go Stdlib CVEs (nightly branch — no code change needed)
|
||||
|
||||
The nightly workflow syncs `development → nightly` via `git merge --ff-only`. Since `development` already has Go 1.26.2 everywhere:
|
||||
- Dockerfile `ARG GO_VERSION=1.26.2` ✓
|
||||
- All CI workflows `GO_VERSION: '1.26.2'` ✓
|
||||
- `backend/go.mod` `go 1.26.2` ✓
|
||||
|
||||
The next nightly run at 09:00 UTC will automatically propagate Go 1.26.2 to the nightly branch and rebuild the image.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementation Plan
|
||||
|
||||
### Phase 1: Playwright Tests (N/A)
|
||||
|
||||
No UI/UX changes — this is a dependency-only update. Existing E2E tests validate runtime behavior.
|
||||
|
||||
### Phase 2: Backend Implementation
|
||||
|
||||
| Task | File(s) | Action |
|
||||
|------|---------|--------|
|
||||
| 2.1 | `backend/go.mod`, `backend/go.sum` | Run `go get` commands from §3.1 |
|
||||
| 2.2 | Verify build | `cd backend && go build ./cmd/api` |
|
||||
| 2.3 | Verify vet | `cd backend && go vet ./...` |
|
||||
| 2.4 | Verify tests | `cd backend && go test ./...` |
|
||||
| 2.5 | Verify vulns | `cd backend && govulncheck ./...` |
|
||||
|
||||
### Phase 3: Dockerfile Implementation
|
||||
|
||||
| Task | File(s) | Action |
|
||||
|------|---------|--------|
|
||||
| 3.1 | `Dockerfile` (caddy-builder, ~L258-280) | Add go-jose v3/v4, OTel SDK, OTel exporter patches per §3.2 |
|
||||
| 3.2 | `Dockerfile` (caddy-builder, ~L270) | Update grpc patch v1.79.3 → v1.80.0 |
|
||||
| 3.3 | `Dockerfile` (crowdsec-builder, ~L360-370) | Add pgx, AWS SDK patches per §3.3 |
|
||||
| 3.3a | CrowdSec binaries | After patching deps, run `go build` on CrowdSec binaries before full Docker build for faster compilation feedback |
|
||||
| 3.4 | `Dockerfile` | Verify `docker build .` completes successfully (amd64) |
|
||||
|
||||
### Phase 4: CI / Misc Fixes
|
||||
|
||||
| Task | File(s) | Action |
|
||||
|------|---------|--------|
|
||||
| 4.1 | `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Bump Go version 1.26.2 → 1.26.2 |
|
||||
|
||||
### Phase 5: Validation
|
||||
|
||||
| Task | Validation |
|
||||
|------|------------|
|
||||
| 5.1 | `cd backend && go build ./cmd/api` — compiles cleanly |
|
||||
| 5.2 | `cd backend && go test ./...` — all tests pass |
|
||||
| 5.3 | `cd backend && go vet ./...` — no issues |
|
||||
| 5.4 | `cd backend && govulncheck ./...` — 0 findings |
|
||||
| 5.5 | `docker build -t charon:vuln-fix .` — image builds for amd64 |
|
||||
| 5.6 | Trivy scan on built image: `docker run --rm -v /var/run/docker.sock:/var/run/docker.sock aquasec/trivy:latest image --severity CRITICAL,HIGH charon:vuln-fix` — 0 HIGH (pgproto3/v2 excepted) |
|
||||
| 5.7 | Container health: `docker run -d -p 8080:8080 charon:vuln-fix && curl -f http://localhost:8080/health` |
|
||||
| 5.8 | E2E Playwright tests pass against rebuilt container |
|
||||
|
||||
---
|
||||
|
||||
## 5. Risk Assessment
|
||||
|
||||
### Low Risk
|
||||
|
||||
| Change | Risk | Rationale |
|
||||
|--------|------|-----------|
|
||||
| `go-jose/v3` v3.0.4 → v3.0.5 | Low | Security patch release only |
|
||||
| `go-jose/v4` v4.1.3 → v4.1.4 | Low | Security patch release only |
|
||||
| `otel/sdk` v1.40.0 → v1.43.0 (Caddy) | Low | Minor bumps, backwards compatible |
|
||||
| `otlptracehttp` v1.42.0 → v1.43.0 (backend) | Low | Minor bump |
|
||||
| OTel exporters (Caddy) | Low | Minor/patch bumps |
|
||||
| Go version example fix | None | Non-runtime file |
|
||||
|
||||
### Medium Risk
|
||||
|
||||
| Change | Risk | Mitigation |
|
||||
|--------|------|------------|
|
||||
| `grpc` v1.79.3 → v1.80.0 | Medium | Minor version bump. gRPC is indirect — Charon doesn't use gRPC directly. Run full test suite. Verify Caddy and CrowdSec still compile. |
|
||||
| AWS SDK major bumps (s3 v1.87→v1.99, cloudwatchlogs v1.57→v1.68, kinesis v1.40→v1.43) | Medium | CrowdSec build may fail if internal APIs changed between versions. Mitigate: run `go mod tidy` after patches and verify CrowdSec binaries compile. **Note:** AWS SDK Go v2 packages use independent semver within the `v1.x.x` line — these are minor version bumps, not major API breaks. |
|
||||
| `pgx/v4` v4.18.2 → v4.18.3 | Medium | Patch release should be safe. May not fully resolve pgproto3/v2 since no patched v2 exists. |
|
||||
|
||||
### Known Limitation: pgproto3/v2 (CVE-2026-32286)
|
||||
|
||||
The `pgproto3/v2` module has **no patched release** — the fix exists only in `pgproto3/v3` (used by `pgx/v5`). CrowdSec v1.7.7 uses `pgx/v4` which depends on `pgproto3/v2`. Remediation:
|
||||
|
||||
1. Bump `pgx/v4` to v4.18.3 (latest v4 patch) — may transitively resolve the issue
|
||||
2. If scanner still flags pgproto3/v2 after the bump: document as **accepted risk with upstream tracking**
|
||||
3. Monitor CrowdSec releases for `pgx/v5` migration
|
||||
4. Consider upgrading `CROWDSEC_VERSION` ARG if a newer CrowdSec release ships with pgx/v5
|
||||
|
||||
---
|
||||
|
||||
## 6. Acceptance Criteria
|
||||
|
||||
- [ ] `cd backend && go build ./cmd/api` succeeds with zero warnings
|
||||
- [ ] `cd backend && go test ./...` passes with zero failures
|
||||
- [ ] `cd backend && go vet ./...` reports zero issues
|
||||
- [ ] `cd backend && govulncheck ./...` reports zero findings
|
||||
- [ ] Docker image builds successfully for amd64
|
||||
- [ ] Trivy/Grype scan of built image shows 0 new HIGH findings (pgproto3/v2 excepted if upstream unpatched)
|
||||
- [ ] Container starts, health check passes on port 8080
|
||||
- [ ] Existing E2E Playwright tests pass against rebuilt container
|
||||
- [ ] No new compile errors in Caddy or CrowdSec builder stages
|
||||
- [ ] `backend/go.mod` shows updated versions for grpc, otlptracehttp
|
||||
|
||||
---
|
||||
|
||||
## 7. Commit Slicing Strategy
|
||||
|
||||
### Decision: Single PR
|
||||
|
||||
**Rationale**: All changes are dependency version bumps with no feature or behavioral changes. They address a single concern (security vulnerability remediation) and should be reviewed and merged atomically to avoid partial-fix states.
|
||||
|
||||
**Trigger reasons for single PR**:
|
||||
- All changes are security patches — cannot ship partial fixes
|
||||
- Changes span backend + Dockerfile + CI config — logically coupled
|
||||
- No risk of one slice breaking another
|
||||
- Total diff is small (go.mod/go.sum + Dockerfile patch lines + 1 YAML fix)
|
||||
|
||||
### PR-1: Nightly Build Vulnerability Remediation
|
||||
|
||||
**Scope**: All changes in §3.1–§3.4
|
||||
|
||||
**Files modified**:
|
||||
|
||||
| File | Change Type |
|
||||
|------|-------------|
|
||||
| `backend/go.mod` | Dependency version bumps (grpc, otlptracehttp) |
|
||||
| `backend/go.sum` | Auto-generated checksum updates |
|
||||
| `Dockerfile` | Add `go get` patches in caddy-builder and crowdsec-builder stages |
|
||||
| `.github/skills/examples/gorm-scanner-ci-workflow.yml` | Go version 1.26.2 → 1.26.2 |
|
||||
|
||||
**Dependencies**: None (standalone)
|
||||
|
||||
**Validation gates**:
|
||||
1. `go build` / `go test` / `go vet` / `govulncheck` pass
|
||||
2. Docker image builds for amd64
|
||||
3. Trivy/Grype scan passes (0 new HIGH)
|
||||
4. E2E tests pass
|
||||
|
||||
**Rollback**: Revert PR. All changes are version pins — reverting restores previous state with no data migration needed.
|
||||
|
||||
### Post-merge Actions
|
||||
|
||||
1. Nightly build will automatically sync development → nightly and rebuild the image with all patches
|
||||
2. Monitor next nightly scan for zero HIGH findings
|
||||
3. If pgproto3/v2 still flagged: open tracking issue for CrowdSec pgx/v5 upstream migration
|
||||
4. If any AWS SDK bump breaks CrowdSec compilation: pin to intermediate version and document
|
||||
|
||||
---
|
||||
|
||||
## 8. CI Failure Amendment: pgx/v4 Module Path Mismatch
|
||||
|
||||
**Date**: 2026-04-09
|
||||
**Failure**: PR #921 `build-and-push` job, step `crowdsec-builder 7/11`
|
||||
**Error**: `go: github.com/jackc/pgx/v4@v5.9.1: invalid version: go.mod has non-.../v4 module path "github.com/jackc/pgx/v5" (and .../v4/go.mod does not exist) at revision v5.9.1`
|
||||
|
||||
### Root Cause
|
||||
|
||||
Dockerfile line 386 specifies `go get github.com/jackc/pgx/v4@v5.9.1`. This mixes the v4 module path with a v5 version tag. Go's semantic import versioning rejects this because tag `v5.9.1` declares module path `github.com/jackc/pgx/v5` in its go.mod.
|
||||
|
||||
### Fix
|
||||
|
||||
**Dockerfile line 386** — change:
|
||||
```dockerfile
|
||||
go get github.com/jackc/pgx/v4@v5.9.1 && \
|
||||
```
|
||||
to:
|
||||
```dockerfile
|
||||
go get github.com/jackc/pgx/v4@v4.18.3 && \
|
||||
```
|
||||
|
||||
No changes needed to the Renovate annotation (line 385) or the CVE comment (line 384) — both are already correct.
|
||||
|
||||
### Why v4.18.3
|
||||
|
||||
- CrowdSec v1.7.7 uses `github.com/jackc/pgx/v4 v4.18.2` (direct dependency)
|
||||
- v4.18.3 is the latest and likely final v4 release
|
||||
- pgproto3/v2 is archived at v2.3.3 (July 2025) — no fix will be released in the v2 line
|
||||
- The CVE (pgproto3/v2 buffer overflow) can only be fully resolved by CrowdSec migrating to pgx/v5 upstream
|
||||
- Bumping pgx/v4 to v4.18.3 gets the latest v4 maintenance patch; the CVE remains an accepted risk per §5
|
||||
|
||||
### Validation
|
||||
|
||||
The same `docker build` that previously failed at step 7/11 should now pass through the CrowdSec dependency patching stage and proceed to compilation (steps 8-11).
|
||||
|
||||
---
|
||||
|
||||
## 9. Commands Reference
|
||||
|
||||
```bash
|
||||
# === Backend dependency upgrades ===
|
||||
cd /projects/Charon/backend
|
||||
|
||||
go get google.golang.org/grpc@v1.80.0
|
||||
go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp@v1.43.0
|
||||
go mod tidy
|
||||
|
||||
# === Validate backend ===
|
||||
go build ./cmd/api
|
||||
go test ./...
|
||||
go vet ./...
|
||||
govulncheck ./...
|
||||
|
||||
# === Docker build (after Dockerfile edits) ===
|
||||
cd /projects/Charon
|
||||
docker build -t charon:vuln-fix .
|
||||
|
||||
# === Scan built image ===
|
||||
docker run --rm \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
aquasec/trivy:latest image \
|
||||
--severity CRITICAL,HIGH \
|
||||
charon:vuln-fix
|
||||
|
||||
# === Quick container health check ===
|
||||
docker run -d --name charon-vuln-test -p 8080:8080 charon:vuln-fix
|
||||
sleep 10
|
||||
curl -f http://localhost:8080/health
|
||||
docker stop charon-vuln-test && docker rm charon-vuln-test
|
||||
```
|
||||
+895
-378
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user