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:
GitHub Actions
2026-04-11 07:17:45 +00:00
parent 9e82efd23a
commit 4b925418f2
24 changed files with 3520 additions and 649 deletions
+1
View File
@@ -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
)
+2
View File
@@ -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{
+37 -2
View File
@@ -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)
+1 -1
View File
@@ -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
+38 -6
View File
@@ -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},
})
}
+7 -1
View File
@@ -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
+15
View File
@@ -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")
+20 -11
View File
@@ -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)
+671 -73
View File
@@ -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: &notAfter,
NotBefore: &notBefore,
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
View File
File diff suppressed because it is too large Load Diff