524 lines
16 KiB
Go
524 lines
16 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"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"
|
|
)
|
|
|
|
// BackupServiceInterface defines the contract for backup service operations
|
|
type BackupServiceInterface interface {
|
|
CreateBackup() (string, error)
|
|
ListBackups() ([]services.BackupFile, error)
|
|
DeleteBackup(filename string) error
|
|
GetBackupPath(filename string) (string, error)
|
|
RestoreBackup(filename string) error
|
|
GetAvailableSpace() (int64, error)
|
|
}
|
|
|
|
type CertificateHandler struct {
|
|
service *services.CertificateService
|
|
backupService BackupServiceInterface
|
|
notificationService *services.NotificationService
|
|
db *gorm.DB
|
|
// Rate limiting for notifications
|
|
notificationMu sync.Mutex
|
|
lastNotificationTime map[string]time.Time
|
|
}
|
|
|
|
func NewCertificateHandler(service *services.CertificateService, backupService BackupServiceInterface, ns *services.NotificationService) *CertificateHandler {
|
|
return &CertificateHandler{
|
|
service: service,
|
|
backupService: backupService,
|
|
notificationService: ns,
|
|
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 {
|
|
logger.Log().WithError(err).Error("failed to list certificates")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list certificates"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, certs)
|
|
}
|
|
|
|
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) {
|
|
name := c.PostForm("name")
|
|
if name == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
|
return
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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")
|
|
}
|
|
}()
|
|
|
|
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
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
}()
|
|
|
|
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": err.Error()})
|
|
return
|
|
}
|
|
|
|
if h.notificationService != nil {
|
|
h.notificationService.SendExternal(c.Request.Context(),
|
|
"cert",
|
|
"Certificate Uploaded",
|
|
"A new custom certificate was successfully uploaded.",
|
|
map[string]any{
|
|
"Name": util.SanitizeForLog(cert.Name),
|
|
"Domains": util.SanitizeForLog(cert.Domains),
|
|
"Action": "uploaded",
|
|
},
|
|
)
|
|
}
|
|
|
|
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(fmt.Errorf("%s", util.SanitizeForLog(err.Error()))).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("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 - parse to validate format and produce a canonical, safe string
|
|
parsedUUID, parseErr := uuid.Parse(idStr)
|
|
if parseErr != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
|
return
|
|
}
|
|
certUUID := parsedUUID.String()
|
|
|
|
inUse, err := h.service.IsCertificateInUseByUUID(certUUID)
|
|
if err != nil {
|
|
if err == services.ErrCertNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
return
|
|
}
|
|
logger.Log().WithError(err).WithField("certificate_uuid", util.SanitizeForLog(certUUID)).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 {
|
|
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.DeleteCertificate(certUUID); err != nil {
|
|
if err == services.ErrCertInUse {
|
|
c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
|
|
return
|
|
}
|
|
if err == services.ErrCertNotFound {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "certificate not found"})
|
|
return
|
|
}
|
|
logger.Log().WithError(err).WithField("certificate_uuid", util.SanitizeForLog(certUUID)).Error("failed to delete certificate")
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete certificate"})
|
|
return
|
|
}
|
|
|
|
h.sendDeleteNotification(c, certUUID)
|
|
c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
|
|
}
|
|
|
|
func (h *CertificateHandler) sendDeleteNotification(c *gin.Context, certRef string) {
|
|
if h.notificationService == nil {
|
|
return
|
|
}
|
|
|
|
// Re-validate to produce a CodeQL-safe value (breaks taint from user input).
|
|
// Callers already pass validated data; this is defense-in-depth.
|
|
safeRef := sanitizeCertRef(certRef)
|
|
|
|
h.notificationMu.Lock()
|
|
lastTime, exists := h.lastNotificationTime[certRef]
|
|
if exists && time.Since(lastTime) < 10*time.Second {
|
|
h.notificationMu.Unlock()
|
|
logger.Log().WithField("certificate_ref", safeRef).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", safeRef),
|
|
map[string]any{
|
|
"Ref": safeRef,
|
|
"Action": "deleted",
|
|
},
|
|
)
|
|
}
|
|
|
|
// sanitizeCertRef re-validates a certificate reference (UUID or numeric ID)
|
|
// and returns a safe string representation. Returns a placeholder if invalid.
|
|
func sanitizeCertRef(ref string) string {
|
|
if parsed, err := uuid.Parse(ref); err == nil {
|
|
return parsed.String()
|
|
}
|
|
if n, err := strconv.ParseUint(ref, 10, 64); err == nil {
|
|
return strconv.FormatUint(n, 10)
|
|
}
|
|
return "[invalid-ref]"
|
|
}
|