-
-
package handlers
-
-import (
- "net/http"
- "strconv"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-)
-
-type AccessListHandler struct {
- service *services.AccessListService
-}
-
-func NewAccessListHandler(db *gorm.DB) *AccessListHandler {
- return &AccessListHandler{
- service: services.NewAccessListService(db),
- }
-}
-
-// Create handles POST /api/v1/access-lists
-func (h *AccessListHandler) Create(c *gin.Context) {
- var acl models.AccessList
- if err := c.ShouldBindJSON(&acl); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if err := h.service.Create(&acl); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusCreated, acl)
-}
-
-// List handles GET /api/v1/access-lists
-func (h *AccessListHandler) List(c *gin.Context) {
- acls, err := h.service.List()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, acls)
-}
-
-// Get handles GET /api/v1/access-lists/:id
-func (h *AccessListHandler) Get(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
- return
- }
-
- acl, err := h.service.GetByID(uint(id))
- if err != nil {
- if err == services.ErrAccessListNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, acl)
-}
-
-// Update handles PUT /api/v1/access-lists/:id
-func (h *AccessListHandler) Update(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
- return
- }
-
- var updates models.AccessList
- if err := c.ShouldBindJSON(&updates); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if err := h.service.Update(uint(id), &updates); err != nil {
- if err == services.ErrAccessListNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
- return
- }
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // Fetch updated record
- acl, _ := h.service.GetByID(uint(id))
- c.JSON(http.StatusOK, acl)
-}
-
-// Delete handles DELETE /api/v1/access-lists/:id
-func (h *AccessListHandler) Delete(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
- return
- }
-
- if err := h.service.Delete(uint(id)); err != nil {
- if err == services.ErrAccessListNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
- return
- }
- if err == services.ErrAccessListInUse {
- c.JSON(http.StatusConflict, gin.H{"error": "access list is in use by proxy hosts"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"message": "access list deleted"})
-}
-
-// TestIP handles POST /api/v1/access-lists/:id/test
-func (h *AccessListHandler) TestIP(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
- return
- }
-
- var req struct {
- IPAddress string `json:"ip_address" binding:"required"`
- }
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- allowed, reason, err := h.service.TestIP(uint(id), req.IPAddress)
- if err != nil {
- if err == services.ErrAccessListNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
- return
- }
- if err == services.ErrInvalidIPAddress {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP address"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "allowed": allowed,
- "reason": reason,
- })
-}
-
-// GetTemplates handles GET /api/v1/access-lists/templates
-func (h *AccessListHandler) GetTemplates(c *gin.Context) {
- templates := h.service.GetTemplates()
- c.JSON(http.StatusOK, templates)
-}
-
-
-
package handlers
-
-import (
- "net/http"
-
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
-)
-
-type AuthHandler struct {
- authService *services.AuthService
-}
-
-func NewAuthHandler(authService *services.AuthService) *AuthHandler {
- return &AuthHandler{authService: authService}
-}
-
-type LoginRequest struct {
- Email string `json:"email" binding:"required,email"`
- Password string `json:"password" binding:"required"`
-}
-
-func (h *AuthHandler) Login(c *gin.Context) {
- var req LoginRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- token, err := h.authService.Login(req.Email, req.Password)
- if err != nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
- return
- }
-
- // Set cookie
- c.SetCookie("auth_token", token, 3600*24, "/", "", false, true) // Secure should be true in prod
-
- c.JSON(http.StatusOK, gin.H{"token": token})
-}
-
-type RegisterRequest struct {
- Email string `json:"email" binding:"required,email"`
- Password string `json:"password" binding:"required,min=8"`
- Name string `json:"name" binding:"required"`
-}
-
-func (h *AuthHandler) Register(c *gin.Context) {
- var req RegisterRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- user, err := h.authService.Register(req.Email, req.Password, req.Name)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusCreated, user)
-}
-
-func (h *AuthHandler) Logout(c *gin.Context) {
- c.SetCookie("auth_token", "", -1, "/", "", false, true)
- c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
-}
-
-func (h *AuthHandler) Me(c *gin.Context) {
- userID, _ := c.Get("userID")
- role, _ := c.Get("role")
-
- u, err := h.authService.GetUserByID(userID.(uint))
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "user_id": userID,
- "role": role,
- "name": u.Name,
- "email": u.Email,
- })
-}
-
-type ChangePasswordRequest struct {
- OldPassword string `json:"old_password" binding:"required"`
- NewPassword string `json:"new_password" binding:"required,min=8"`
-}
-
-func (h *AuthHandler) ChangePassword(c *gin.Context) {
- var req ChangePasswordRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- userID, exists := c.Get("userID")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
- return
- }
-
- if err := h.authService.ChangePassword(userID.(uint), req.OldPassword, req.NewPassword); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
-}
-
-
-
package handlers
-
-import (
- "net/http"
- "os"
- "path/filepath"
-
- "github.com/Wikid82/charon/backend/internal/api/middleware"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/Wikid82/charon/backend/internal/util"
- "github.com/gin-gonic/gin"
-)
-
-type BackupHandler struct {
- service *services.BackupService
-}
-
-func NewBackupHandler(service *services.BackupService) *BackupHandler {
- return &BackupHandler{service: service}
-}
-
-func (h *BackupHandler) List(c *gin.Context) {
- backups, err := h.service.ListBackups()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list backups"})
- return
- }
- c.JSON(http.StatusOK, backups)
-}
-
-func (h *BackupHandler) Create(c *gin.Context) {
- filename, err := h.service.CreateBackup()
- if err != nil {
- middleware.GetRequestLogger(c).WithField("action", "create_backup").WithError(err).Error("Failed to create backup")
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup: " + err.Error()})
- return
- }
- middleware.GetRequestLogger(c).WithField("action", "create_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).Info("Backup created successfully")
- c.JSON(http.StatusCreated, gin.H{"filename": filename, "message": "Backup created successfully"})
-}
-
-func (h *BackupHandler) Delete(c *gin.Context) {
- filename := c.Param("filename")
- if err := h.service.DeleteBackup(filename); err != nil {
- if os.IsNotExist(err) {
- c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "Backup deleted"})
-}
-
-func (h *BackupHandler) Download(c *gin.Context) {
- filename := c.Param("filename")
- path, err := h.service.GetBackupPath(filename)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if _, err := os.Stat(path); os.IsNotExist(err) {
- c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
- return
- }
-
- c.Header("Content-Disposition", "attachment; filename="+filename)
- c.File(path)
-}
-
-func (h *BackupHandler) Restore(c *gin.Context) {
- filename := c.Param("filename")
- if err := h.service.RestoreBackup(filename); err != nil {
- middleware.GetRequestLogger(c).WithField("action", "restore_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).WithError(err).Error("Failed to restore backup")
- if os.IsNotExist(err) {
- c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to restore backup: " + err.Error()})
- return
- }
- middleware.GetRequestLogger(c).WithField("action", "restore_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).Info("Backup restored successfully")
- // In a real scenario, we might want to trigger a restart here
- c.JSON(http.StatusOK, gin.H{"message": "Backup restored successfully. Please restart the container."})
-}
-
-
-
package handlers
-
-import (
- "fmt"
- "net/http"
- "strconv"
-
- "github.com/gin-gonic/gin"
-
- "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
-}
-
-type CertificateHandler struct {
- service *services.CertificateService
- backupService BackupServiceInterface
- notificationService *services.NotificationService
-}
-
-func NewCertificateHandler(service *services.CertificateService, backupService BackupServiceInterface, ns *services.NotificationService) *CertificateHandler {
- return &CertificateHandler{
- service: service,
- backupService: backupService,
- notificationService: ns,
- }
-}
-
-func (h *CertificateHandler) List(c *gin.Context) {
- certs, err := h.service.ListCertificates()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- 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) 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
- 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"})
- return
- }
- defer func() { _ = certSrc.Close() }()
-
- keySrc, err := keyFile.Open()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
- return
- }
- defer func() { _ = keySrc.Close() }()
-
- // 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 := make([]byte, 1024*1024)
- n, _ = keySrc.Read(keyBytes)
- keyPEM := string(keyBytes[:n])
-
- cert, err := h.service.UploadCertificate(name, certPEM, keyPEM)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // Send Notification
- if h.notificationService != nil {
- h.notificationService.SendExternal(c.Request.Context(),
- "cert",
- "Certificate Uploaded",
- fmt.Sprintf("Certificate %s uploaded", util.SanitizeForLog(cert.Name)),
- map[string]interface{}{
- "Name": util.SanitizeForLog(cert.Name),
- "Domains": util.SanitizeForLog(cert.Domains),
- "Action": "uploaded",
- },
- )
- }
-
- c.JSON(http.StatusCreated, cert)
-}
-
-func (h *CertificateHandler) Delete(c *gin.Context) {
- idStr := c.Param("id")
- id, err := strconv.ParseUint(idStr, 10, 32)
- if err != nil {
- 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))
- if err != nil {
- 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
- }
-
- // Create backup before deletion
- if h.backupService != nil {
- if _, err := h.backupService.CreateBackup(); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup before deletion"})
- return
- }
- }
-
- // Proceed with deletion
- if err := h.service.DeleteCertificate(uint(id)); err != nil {
- if err == services.ErrCertInUse {
- c.JSON(http.StatusConflict, gin.H{"error": "certificate is in use by one or more proxy hosts"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- // Send Notification
- if h.notificationService != nil {
- h.notificationService.SendExternal(c.Request.Context(),
- "cert",
- "Certificate Deleted",
- fmt.Sprintf("Certificate ID %d deleted", id),
- map[string]interface{}{
- "ID": id,
- "Action": "deleted",
- },
- )
- }
-
- c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
-}
-
-
-
package handlers
-
-import (
- "context"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "syscall"
-)
-
-// DefaultCrowdsecExecutor implements CrowdsecExecutor using OS processes.
-type DefaultCrowdsecExecutor struct {
-}
-
-func NewDefaultCrowdsecExecutor() *DefaultCrowdsecExecutor { return &DefaultCrowdsecExecutor{} }
-
-func (e *DefaultCrowdsecExecutor) pidFile(configDir string) string {
- return filepath.Join(configDir, "crowdsec.pid")
-}
-
-func (e *DefaultCrowdsecExecutor) Start(ctx context.Context, binPath, configDir string) (int, error) {
- cmd := exec.CommandContext(ctx, binPath, "--config-dir", configDir)
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- if err := cmd.Start(); err != nil {
- return 0, err
- }
- pid := cmd.Process.Pid
- // write pid file
- if err := os.WriteFile(e.pidFile(configDir), []byte(strconv.Itoa(pid)), 0o644); err != nil {
- return pid, fmt.Errorf("failed to write pid file: %w", err)
- }
- // wait in background
- go func() {
- _ = cmd.Wait()
- _ = os.Remove(e.pidFile(configDir))
- }()
- return pid, nil
-}
-
-func (e *DefaultCrowdsecExecutor) Stop(ctx context.Context, configDir string) error {
- b, err := os.ReadFile(e.pidFile(configDir))
- if err != nil {
- return fmt.Errorf("pid file read: %w", err)
- }
- pid, err := strconv.Atoi(string(b))
- if err != nil {
- return fmt.Errorf("invalid pid: %w", err)
- }
- proc, err := os.FindProcess(pid)
- if err != nil {
- return err
- }
- if err := proc.Signal(syscall.SIGTERM); err != nil {
- return err
- }
- // best-effort remove pid file
- _ = os.Remove(e.pidFile(configDir))
- return nil
-}
-
-func (e *DefaultCrowdsecExecutor) Status(ctx context.Context, configDir string) (bool, int, error) {
- b, err := os.ReadFile(e.pidFile(configDir))
- if err != nil {
- return false, 0, nil
- }
- pid, err := strconv.Atoi(string(b))
- if err != nil {
- return false, 0, nil
- }
- // Check process exists
- proc, err := os.FindProcess(pid)
- if err != nil {
- return false, pid, nil
- }
- // Sending signal 0 is not portable on Windows, but OK for Linux containers
- if err := proc.Signal(syscall.Signal(0)); err != nil {
- return false, pid, nil
- }
- return true, pid, nil
-}
-
-
-
package handlers
-
-import (
- "archive/tar"
- "compress/gzip"
- "context"
- "fmt"
- "github.com/Wikid82/charon/backend/internal/logger"
- "io"
- "net/http"
- "os"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-)
-
-// Executor abstracts starting/stopping CrowdSec so tests can mock it.
-type CrowdsecExecutor interface {
- Start(ctx context.Context, binPath, configDir string) (int, error)
- Stop(ctx context.Context, configDir string) error
- Status(ctx context.Context, configDir string) (running bool, pid int, err error)
-}
-
-// CrowdsecHandler manages CrowdSec process and config imports.
-type CrowdsecHandler struct {
- DB *gorm.DB
- Executor CrowdsecExecutor
- BinPath string
- DataDir string
-}
-
-func NewCrowdsecHandler(db *gorm.DB, exec CrowdsecExecutor, binPath, dataDir string) *CrowdsecHandler {
- return &CrowdsecHandler{DB: db, Executor: exec, BinPath: binPath, DataDir: dataDir}
-}
-
-// Start starts the CrowdSec process.
-func (h *CrowdsecHandler) Start(c *gin.Context) {
- ctx := c.Request.Context()
- pid, err := h.Executor.Start(ctx, h.BinPath, h.DataDir)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"status": "started", "pid": pid})
-}
-
-// Stop stops the CrowdSec process.
-func (h *CrowdsecHandler) Stop(c *gin.Context) {
- ctx := c.Request.Context()
- if err := h.Executor.Stop(ctx, h.DataDir); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"status": "stopped"})
-}
-
-// Status returns simple running state.
-func (h *CrowdsecHandler) Status(c *gin.Context) {
- ctx := c.Request.Context()
- running, pid, err := h.Executor.Status(ctx, h.DataDir)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"running": running, "pid": pid})
-}
-
-// ImportConfig accepts a tar.gz or zip upload and extracts into DataDir (backing up existing config).
-func (h *CrowdsecHandler) ImportConfig(c *gin.Context) {
- file, err := c.FormFile("file")
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
- return
- }
-
- // Save to temp file
- tmpDir := os.TempDir()
- tmpPath := filepath.Join(tmpDir, fmt.Sprintf("crowdsec-import-%d", time.Now().UnixNano()))
- if err := os.MkdirAll(tmpPath, 0o755); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp dir"})
- return
- }
-
- dst := filepath.Join(tmpPath, file.Filename)
- if err := c.SaveUploadedFile(file, dst); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
- return
- }
-
- // For safety, do minimal validation: ensure file non-empty
- fi, err := os.Stat(dst)
- if err != nil || fi.Size() == 0 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "empty upload"})
- return
- }
-
- // Backup current config
- backupDir := h.DataDir + ".backup." + time.Now().Format("20060102-150405")
- if _, err := os.Stat(h.DataDir); err == nil {
- _ = os.Rename(h.DataDir, backupDir)
- }
- // Create target dir
- if err := os.MkdirAll(h.DataDir, 0o755); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config dir"})
- return
- }
-
- // For now, simply copy uploaded file into data dir for operator to handle extraction
- target := filepath.Join(h.DataDir, file.Filename)
- in, err := os.Open(dst)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open temp file"})
- return
- }
- defer in.Close()
- out, err := os.Create(target)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create target file"})
- return
- }
- defer out.Close()
- if _, err := io.Copy(out, in); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write config"})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"status": "imported", "backup": backupDir})
-}
-
-// ExportConfig creates a tar.gz archive of the CrowdSec data directory and streams it
-// back to the client as a downloadable file.
-func (h *CrowdsecHandler) ExportConfig(c *gin.Context) {
- // Ensure DataDir exists
- if _, err := os.Stat(h.DataDir); os.IsNotExist(err) {
- c.JSON(http.StatusNotFound, gin.H{"error": "crowdsec config not found"})
- return
- }
-
- // Create a gzip writer and tar writer that stream directly to the response
- c.Header("Content-Type", "application/gzip")
- filename := fmt.Sprintf("crowdsec-config-%s.tar.gz", time.Now().Format("20060102-150405"))
- c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
- gw := gzip.NewWriter(c.Writer)
- defer func() {
- if err := gw.Close(); err != nil {
- logger.Log().WithError(err).Warn("Failed to close gzip writer")
- }
- }()
- tw := tar.NewWriter(gw)
- defer func() {
- if err := tw.Close(); err != nil {
- logger.Log().WithError(err).Warn("Failed to close tar writer")
- }
- }()
-
- // Walk the DataDir and add files to the archive
- err := filepath.Walk(h.DataDir, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
- if info.IsDir() {
- return nil
- }
- rel, err := filepath.Rel(h.DataDir, path)
- if err != nil {
- return err
- }
- // Open file
- f, err := os.Open(path)
- if err != nil {
- return err
- }
- defer f.Close()
-
- hdr := &tar.Header{
- Name: rel,
- Size: info.Size(),
- Mode: int64(info.Mode()),
- ModTime: info.ModTime(),
- }
- if err := tw.WriteHeader(hdr); err != nil {
- return err
- }
- if _, err := io.Copy(tw, f); err != nil {
- return err
- }
- return nil
- })
- if err != nil {
- // If any error occurred while creating the archive, return 500
- c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-}
-
-// ListFiles returns a flat list of files under the CrowdSec DataDir.
-func (h *CrowdsecHandler) ListFiles(c *gin.Context) {
- var files []string
- if _, err := os.Stat(h.DataDir); os.IsNotExist(err) {
- c.JSON(http.StatusOK, gin.H{"files": files})
- return
- }
- err := filepath.Walk(h.DataDir, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
- if !info.IsDir() {
- rel, err := filepath.Rel(h.DataDir, path)
- if err != nil {
- return err
- }
- files = append(files, rel)
- }
- return nil
- })
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"files": files})
-}
-
-// ReadFile returns the contents of a specific file under DataDir. Query param 'path' required.
-func (h *CrowdsecHandler) ReadFile(c *gin.Context) {
- rel := c.Query("path")
- if rel == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
- return
- }
- clean := filepath.Clean(rel)
- // prevent directory traversal
- p := filepath.Join(h.DataDir, clean)
- if !strings.HasPrefix(p, filepath.Clean(h.DataDir)) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
- return
- }
- data, err := os.ReadFile(p)
- if err != nil {
- if os.IsNotExist(err) {
- c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"content": string(data)})
-}
-
-// WriteFile writes content to a file under the CrowdSec DataDir, creating a backup before doing so.
-// JSON body: { "path": "relative/path.conf", "content": "..." }
-func (h *CrowdsecHandler) WriteFile(c *gin.Context) {
- var payload struct {
- Path string `json:"path"`
- Content string `json:"content"`
- }
- if err := c.ShouldBindJSON(&payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
- return
- }
- if payload.Path == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
- return
- }
- clean := filepath.Clean(payload.Path)
- p := filepath.Join(h.DataDir, clean)
- if !strings.HasPrefix(p, filepath.Clean(h.DataDir)) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
- return
- }
- // Backup existing DataDir
- backupDir := h.DataDir + ".backup." + time.Now().Format("20060102-150405")
- if _, err := os.Stat(h.DataDir); err == nil {
- if err := os.Rename(h.DataDir, backupDir); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup"})
- return
- }
- }
- // Recreate DataDir and write file
- if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare dir"})
- return
- }
- if err := os.WriteFile(p, []byte(payload.Content), 0o644); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"status": "written", "backup": backupDir})
-}
-
-// RegisterRoutes registers crowdsec admin routes under protected group
-func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) {
- rg.POST("/admin/crowdsec/start", h.Start)
- rg.POST("/admin/crowdsec/stop", h.Stop)
- rg.GET("/admin/crowdsec/status", h.Status)
- rg.POST("/admin/crowdsec/import", h.ImportConfig)
- rg.GET("/admin/crowdsec/export", h.ExportConfig)
- rg.GET("/admin/crowdsec/files", h.ListFiles)
- rg.GET("/admin/crowdsec/file", h.ReadFile)
- rg.POST("/admin/crowdsec/file", h.WriteFile)
-}
-
-
-
package handlers
-
-import (
- "fmt"
- "net/http"
-
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
-)
-
-type DockerHandler struct {
- dockerService *services.DockerService
- remoteServerService *services.RemoteServerService
-}
-
-func NewDockerHandler(dockerService *services.DockerService, remoteServerService *services.RemoteServerService) *DockerHandler {
- return &DockerHandler{
- dockerService: dockerService,
- remoteServerService: remoteServerService,
- }
-}
-
-func (h *DockerHandler) RegisterRoutes(r *gin.RouterGroup) {
- r.GET("/docker/containers", h.ListContainers)
-}
-
-func (h *DockerHandler) ListContainers(c *gin.Context) {
- host := c.Query("host")
- serverID := c.Query("server_id")
-
- // If server_id is provided, look up the remote server
- if serverID != "" {
- server, err := h.remoteServerService.GetByUUID(serverID)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "Remote server not found"})
- return
- }
-
- // Construct Docker host string
- // Assuming TCP for now as that's what RemoteServer supports (Host/Port)
- // TODO: Support SSH if/when RemoteServer supports it
- host = fmt.Sprintf("tcp://%s:%d", server.Host, server.Port)
- }
-
- containers, err := h.dockerService.ListContainers(c.Request.Context(), host)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list containers: " + err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, containers)
-}
-
-
-
package handlers
-
-import (
- "fmt"
- "net/http"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/Wikid82/charon/backend/internal/util"
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-)
-
-type DomainHandler struct {
- DB *gorm.DB
- notificationService *services.NotificationService
-}
-
-func NewDomainHandler(db *gorm.DB, ns *services.NotificationService) *DomainHandler {
- return &DomainHandler{
- DB: db,
- notificationService: ns,
- }
-}
-
-func (h *DomainHandler) List(c *gin.Context) {
- var domains []models.Domain
- if err := h.DB.Order("name asc").Find(&domains).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch domains"})
- return
- }
- c.JSON(http.StatusOK, domains)
-}
-
-func (h *DomainHandler) Create(c *gin.Context) {
- var input struct {
- Name string `json:"name" binding:"required"`
- }
-
- if err := c.ShouldBindJSON(&input); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- domain := models.Domain{
- Name: input.Name,
- }
-
- if err := h.DB.Create(&domain).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create domain"})
- return
- }
-
- // Send Notification
- if h.notificationService != nil {
- h.notificationService.SendExternal(c.Request.Context(),
- "domain",
- "Domain Added",
- fmt.Sprintf("Domain %s added", util.SanitizeForLog(domain.Name)),
- map[string]interface{}{
- "Name": util.SanitizeForLog(domain.Name),
- "Action": "created",
- },
- )
- }
-
- c.JSON(http.StatusCreated, domain)
-}
-
-func (h *DomainHandler) Delete(c *gin.Context) {
- id := c.Param("id")
- var domain models.Domain
- if err := h.DB.Where("uuid = ?", id).First(&domain).Error; err == nil {
- // Send Notification before delete (or after if we keep the name)
- if h.notificationService != nil {
- h.notificationService.SendExternal(c.Request.Context(),
- "domain",
- "Domain Deleted",
- fmt.Sprintf("Domain %s deleted", util.SanitizeForLog(domain.Name)),
- map[string]interface{}{
- "Name": util.SanitizeForLog(domain.Name),
- "Action": "deleted",
- },
- )
- }
- }
-
- if err := h.DB.Where("uuid = ?", id).Delete(&models.Domain{}).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete domain"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "Domain deleted"})
-}
-
-
-
package handlers
-
-import (
- "net/http"
- "os"
- "strconv"
- "strings"
-
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/models"
-)
-
-// FeatureFlagsHandler exposes simple DB-backed feature flags with env fallback.
-type FeatureFlagsHandler struct {
- DB *gorm.DB
-}
-
-func NewFeatureFlagsHandler(db *gorm.DB) *FeatureFlagsHandler {
- return &FeatureFlagsHandler{DB: db}
-}
-
-// defaultFlags lists the canonical feature flags we expose.
-var defaultFlags = []string{
- "feature.global.enabled",
- "feature.cerberus.enabled",
- "feature.uptime.enabled",
- "feature.notifications.enabled",
- "feature.docker.enabled",
-}
-
-// GetFlags returns a map of feature flag -> bool. DB setting takes precedence
-// and falls back to environment variables if present.
-func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) {
- result := make(map[string]bool)
-
- for _, key := range defaultFlags {
- // Try DB
- var s models.Setting
- if err := h.DB.Where("key = ?", key).First(&s).Error; err == nil {
- v := strings.ToLower(strings.TrimSpace(s.Value))
- b := v == "1" || v == "true" || v == "yes"
- result[key] = b
- continue
- }
-
- // Fallback to env vars. Try FEATURE_... and also stripped service name e.g. CERBERUS_ENABLED
- envKey := strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
- if ev, ok := os.LookupEnv(envKey); ok {
- if bv, err := strconv.ParseBool(ev); err == nil {
- result[key] = bv
- continue
- }
- // accept 1/0
- result[key] = ev == "1"
- continue
- }
-
- // Try shorter variant after removing leading "feature."
- if strings.HasPrefix(key, "feature.") {
- short := strings.ToUpper(strings.ReplaceAll(strings.TrimPrefix(key, "feature."), ".", "_"))
- if ev, ok := os.LookupEnv(short); ok {
- if bv, err := strconv.ParseBool(ev); err == nil {
- result[key] = bv
- continue
- }
- result[key] = ev == "1"
- continue
- }
- }
-
- // Default false
- result[key] = false
- }
-
- c.JSON(http.StatusOK, result)
-}
-
-// UpdateFlags accepts a JSON object map[string]bool and upserts settings.
-func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) {
- var payload map[string]bool
- if err := c.ShouldBindJSON(&payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- for k, v := range payload {
- // Only allow keys in the default list to avoid arbitrary settings
- allowed := false
- for _, ak := range defaultFlags {
- if ak == k {
- allowed = true
- break
- }
- }
- if !allowed {
- continue
- }
-
- s := models.Setting{Key: k, Value: strconv.FormatBool(v), Type: "bool", Category: "feature"}
- if err := h.DB.Where(models.Setting{Key: k}).Assign(s).FirstOrCreate(&s).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save setting"})
- return
- }
- }
-
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
-}
-
-
-
package handlers
-
-import (
- "net"
- "net/http"
-
- "github.com/Wikid82/charon/backend/internal/version"
- "github.com/gin-gonic/gin"
-)
-
-// getLocalIP returns the non-loopback local IP of the host
-func getLocalIP() string {
- addrs, err := net.InterfaceAddrs()
- if err != nil {
- return ""
- }
- for _, address := range addrs {
- // check the address type and if it is not a loopback then return it
- if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
- if ipnet.IP.To4() != nil {
- return ipnet.IP.String()
- }
- }
- }
- return ""
-}
-
-// HealthHandler responds with basic service metadata for uptime checks.
-func HealthHandler(c *gin.Context) {
- c.JSON(http.StatusOK, gin.H{
- "status": "ok",
- "service": version.Name,
- "version": version.Version,
- "git_commit": version.GitCommit,
- "build_time": version.BuildTime,
- "internal_ip": getLocalIP(),
- })
-}
-
-
-
package handlers
-
-import (
- "encoding/json"
- "fmt"
- "net/http"
- "os"
- "path"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/api/middleware"
- "github.com/Wikid82/charon/backend/internal/caddy"
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/Wikid82/charon/backend/internal/util"
-)
-
-// ImportHandler handles Caddyfile import operations.
-type ImportHandler struct {
- db *gorm.DB
- proxyHostSvc *services.ProxyHostService
- importerservice *caddy.Importer
- importDir string
- mountPath string
-}
-
-// NewImportHandler creates a new import handler.
-func NewImportHandler(db *gorm.DB, caddyBinary, importDir, mountPath string) *ImportHandler {
- return &ImportHandler{
- db: db,
- proxyHostSvc: services.NewProxyHostService(db),
- importerservice: caddy.NewImporter(caddyBinary),
- importDir: importDir,
- mountPath: mountPath,
- }
-}
-
-// RegisterRoutes registers import-related routes.
-func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) {
- router.GET("/import/status", h.GetStatus)
- router.GET("/import/preview", h.GetPreview)
- router.POST("/import/upload", h.Upload)
- router.POST("/import/upload-multi", h.UploadMulti)
- router.POST("/import/detect-imports", h.DetectImports)
- router.POST("/import/commit", h.Commit)
- router.DELETE("/import/cancel", h.Cancel)
-}
-
-// GetStatus returns current import session status.
-func (h *ImportHandler) GetStatus(c *gin.Context) {
- var session models.ImportSession
- err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
- Order("created_at DESC").
- First(&session).Error
-
- if err == gorm.ErrRecordNotFound {
- // No pending/reviewing session, check if there's a mounted Caddyfile available for transient preview
- if h.mountPath != "" {
- if fileInfo, err := os.Stat(h.mountPath); err == nil {
- // Check if this mount has already been committed recently
- var committedSession models.ImportSession
- err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed").
- Order("committed_at DESC").
- First(&committedSession).Error
-
- // Allow re-import if:
- // 1. Never committed before (err == gorm.ErrRecordNotFound), OR
- // 2. File was modified after last commit
- allowImport := err == gorm.ErrRecordNotFound
- if !allowImport && committedSession.CommittedAt != nil {
- fileMod := fileInfo.ModTime()
- commitTime := *committedSession.CommittedAt
- allowImport = fileMod.After(commitTime)
- }
-
- if allowImport {
- // Mount file is available for import
- c.JSON(http.StatusOK, gin.H{
- "has_pending": true,
- "session": gin.H{
- "id": "transient",
- "state": "transient",
- "source_file": h.mountPath,
- },
- })
- return
- }
- // Mount file was already committed and hasn't been modified, don't offer it again
- }
- }
- c.JSON(http.StatusOK, gin.H{"has_pending": false})
- return
- }
-
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "has_pending": true,
- "session": gin.H{
- "id": session.UUID,
- "state": session.Status,
- "created_at": session.CreatedAt,
- "updated_at": session.UpdatedAt,
- },
- })
-}
-
-// GetPreview returns parsed hosts and conflicts for review.
-func (h *ImportHandler) GetPreview(c *gin.Context) {
- var session models.ImportSession
- err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
- Order("created_at DESC").
- First(&session).Error
-
- if err == nil {
- // DB session found
- var result caddy.ImportResult
- if err := json.Unmarshal([]byte(session.ParsedData), &result); err == nil {
- // Update status to reviewing
- session.Status = "reviewing"
- h.db.Save(&session)
-
- // Read original Caddyfile content if available
- var caddyfileContent string
- if session.SourceFile != "" {
- if content, err := os.ReadFile(session.SourceFile); err == nil {
- caddyfileContent = string(content)
- } else {
- backupPath := filepath.Join(h.importDir, "backups", filepath.Base(session.SourceFile))
- if content, err := os.ReadFile(backupPath); err == nil {
- caddyfileContent = string(content)
- }
- }
- }
-
- c.JSON(http.StatusOK, gin.H{
- "session": gin.H{
- "id": session.UUID,
- "state": session.Status,
- "created_at": session.CreatedAt,
- "updated_at": session.UpdatedAt,
- "source_file": session.SourceFile,
- },
- "preview": result,
- "caddyfile_content": caddyfileContent,
- })
- return
- }
- }
-
- // No DB session found or failed to parse session. Try transient preview from mountPath.
- if h.mountPath != "" {
- if fileInfo, err := os.Stat(h.mountPath); err == nil {
- // Check if this mount has already been committed recently
- var committedSession models.ImportSession
- err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed").
- Order("committed_at DESC").
- First(&committedSession).Error
-
- // Allow preview if:
- // 1. Never committed before (err == gorm.ErrRecordNotFound), OR
- // 2. File was modified after last commit
- allowPreview := err == gorm.ErrRecordNotFound
- if !allowPreview && committedSession.CommittedAt != nil {
- allowPreview = fileInfo.ModTime().After(*committedSession.CommittedAt)
- }
-
- if !allowPreview {
- // Mount file was already committed and hasn't been modified, don't offer preview again
- c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
- return
- }
-
- // Parse mounted Caddyfile transiently
- transient, err := h.importerservice.ImportFile(h.mountPath)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
- return
- }
-
- // Build a transient session id (not persisted)
- sid := uuid.NewString()
- var caddyfileContent string
- if content, err := os.ReadFile(h.mountPath); err == nil {
- caddyfileContent = string(content)
- }
-
- // Check for conflicts with existing hosts and build conflict details
- existingHosts, _ := h.proxyHostSvc.List()
- existingDomainsMap := make(map[string]models.ProxyHost)
- for _, eh := range existingHosts {
- existingDomainsMap[eh.DomainNames] = eh
- }
-
- conflictDetails := make(map[string]gin.H)
- for _, ph := range transient.Hosts {
- if existing, found := existingDomainsMap[ph.DomainNames]; found {
- transient.Conflicts = append(transient.Conflicts, ph.DomainNames)
- conflictDetails[ph.DomainNames] = gin.H{
- "existing": gin.H{
- "forward_scheme": existing.ForwardScheme,
- "forward_host": existing.ForwardHost,
- "forward_port": existing.ForwardPort,
- "ssl_forced": existing.SSLForced,
- "websocket": existing.WebsocketSupport,
- "enabled": existing.Enabled,
- },
- "imported": gin.H{
- "forward_scheme": ph.ForwardScheme,
- "forward_host": ph.ForwardHost,
- "forward_port": ph.ForwardPort,
- "ssl_forced": ph.SSLForced,
- "websocket": ph.WebsocketSupport,
- },
- }
- }
- }
-
- c.JSON(http.StatusOK, gin.H{
- "session": gin.H{"id": sid, "state": "transient", "source_file": h.mountPath},
- "preview": transient,
- "caddyfile_content": caddyfileContent,
- "conflict_details": conflictDetails,
- })
- return
- }
- }
-
- c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
-}
-
-// Upload handles manual Caddyfile upload or paste.
-func (h *ImportHandler) Upload(c *gin.Context) {
- var req struct {
- Content string `json:"content" binding:"required"`
- Filename string `json:"filename"`
- }
-
- // Capture raw request for better diagnostics in tests
- if err := c.ShouldBindJSON(&req); err != nil {
- // Try to include raw body preview when binding fails
- entry := middleware.GetRequestLogger(c)
- if raw, _ := c.GetRawData(); len(raw) > 0 {
- entry.WithError(err).WithField("raw_body_preview", util.SanitizeForLog(string(raw))).Error("Import Upload: failed to bind JSON")
- } else {
- entry.WithError(err).Error("Import Upload: failed to bind JSON")
- }
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- middleware.GetRequestLogger(c).WithField("filename", util.SanitizeForLog(filepath.Base(req.Filename))).WithField("content_len", len(req.Content)).Info("Import Upload: received upload")
-
- // Save upload to import/uploads/<uuid>.caddyfile and return transient preview (do not persist yet)
- sid := uuid.NewString()
- uploadsDir, err := safeJoin(h.importDir, "uploads")
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid import directory"})
- return
- }
- if err := os.MkdirAll(uploadsDir, 0755); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create uploads directory"})
- return
- }
- tempPath, err := safeJoin(uploadsDir, fmt.Sprintf("%s.caddyfile", sid))
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid temp path"})
- return
- }
- if err := os.WriteFile(tempPath, []byte(req.Content), 0644); err != nil {
- middleware.GetRequestLogger(c).WithField("tempPath", util.SanitizeForLog(filepath.Base(tempPath))).WithError(err).Error("Import Upload: failed to write temp file")
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write upload"})
- return
- }
-
- // Parse uploaded file transiently
- result, err := h.importerservice.ImportFile(tempPath)
- if err != nil {
- // Read a small preview of the uploaded file for diagnostics
- preview := ""
- if b, rerr := os.ReadFile(tempPath); rerr == nil {
- if len(b) > 200 {
- preview = string(b[:200])
- } else {
- preview = string(b)
- }
- }
- middleware.GetRequestLogger(c).WithError(err).WithField("tempPath", util.SanitizeForLog(filepath.Base(tempPath))).WithField("content_preview", util.SanitizeForLog(preview)).Error("Import Upload: import failed")
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
- return
- }
-
- // If no hosts were parsed, provide a clearer error when import directives exist
- if len(result.Hosts) == 0 {
- imports := detectImportDirectives(req.Content)
- if len(imports) > 0 {
- sanitizedImports := make([]string, 0, len(imports))
- for _, imp := range imports {
- sanitizedImports = append(sanitizedImports, util.SanitizeForLog(filepath.Base(imp)))
- }
- middleware.GetRequestLogger(c).WithField("imports", sanitizedImports).Warn("Import Upload: no hosts parsed but imports detected")
- } else {
- middleware.GetRequestLogger(c).WithField("content_len", len(req.Content)).Warn("Import Upload: no hosts parsed and no imports detected")
- }
- if len(imports) > 0 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "no sites found in uploaded Caddyfile; imports detected; please upload the referenced site files using the multi-file import flow", "imports": imports})
- return
- }
- c.JSON(http.StatusBadRequest, gin.H{"error": "no sites found in uploaded Caddyfile"})
- return
- }
-
- // Check for conflicts with existing hosts and build conflict details
- existingHosts, _ := h.proxyHostSvc.List()
- existingDomainsMap := make(map[string]models.ProxyHost)
- for _, eh := range existingHosts {
- existingDomainsMap[eh.DomainNames] = eh
- }
-
- conflictDetails := make(map[string]gin.H)
- for _, ph := range result.Hosts {
- if existing, found := existingDomainsMap[ph.DomainNames]; found {
- result.Conflicts = append(result.Conflicts, ph.DomainNames)
- conflictDetails[ph.DomainNames] = gin.H{
- "existing": gin.H{
- "forward_scheme": existing.ForwardScheme,
- "forward_host": existing.ForwardHost,
- "forward_port": existing.ForwardPort,
- "ssl_forced": existing.SSLForced,
- "websocket": existing.WebsocketSupport,
- "enabled": existing.Enabled,
- },
- "imported": gin.H{
- "forward_scheme": ph.ForwardScheme,
- "forward_host": ph.ForwardHost,
- "forward_port": ph.ForwardPort,
- "ssl_forced": ph.SSLForced,
- "websocket": ph.WebsocketSupport,
- },
- }
- }
- }
-
- c.JSON(http.StatusOK, gin.H{
- "session": gin.H{"id": sid, "state": "transient", "source_file": tempPath},
- "conflict_details": conflictDetails,
- "preview": result,
- })
-}
-
-// DetectImports analyzes Caddyfile content and returns detected import directives.
-func (h *ImportHandler) DetectImports(c *gin.Context) {
- var req struct {
- Content string `json:"content" binding:"required"`
- }
-
- if err := c.ShouldBindJSON(&req); err != nil {
- entry := middleware.GetRequestLogger(c)
- if raw, _ := c.GetRawData(); len(raw) > 0 {
- entry.WithError(err).WithField("raw_body_preview", util.SanitizeForLog(string(raw))).Error("Import UploadMulti: failed to bind JSON")
- } else {
- entry.WithError(err).Error("Import UploadMulti: failed to bind JSON")
- }
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- imports := detectImportDirectives(req.Content)
- c.JSON(http.StatusOK, gin.H{
- "has_imports": len(imports) > 0,
- "imports": imports,
- })
-}
-
-// UploadMulti handles upload of main Caddyfile + multiple site files.
-func (h *ImportHandler) UploadMulti(c *gin.Context) {
- var req struct {
- Files []struct {
- Filename string `json:"filename" binding:"required"`
- Content string `json:"content" binding:"required"`
- } `json:"files" binding:"required,min=1"`
- }
-
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // Validate: at least one file must be named "Caddyfile" or have no path separator
- hasCaddyfile := false
- for _, f := range req.Files {
- if f.Filename == "Caddyfile" || !strings.Contains(f.Filename, "/") {
- hasCaddyfile = true
- break
- }
- }
- if !hasCaddyfile {
- c.JSON(http.StatusBadRequest, gin.H{"error": "must include a main Caddyfile"})
- return
- }
-
- // Create session directory
- sid := uuid.NewString()
- sessionDir, err := safeJoin(h.importDir, filepath.Join("uploads", sid))
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "invalid session directory"})
- return
- }
- if err := os.MkdirAll(sessionDir, 0755); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create session directory"})
- return
- }
-
- // Write all files
- mainCaddyfile := ""
- for _, f := range req.Files {
- if strings.TrimSpace(f.Content) == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file '%s' is empty", f.Filename)})
- return
- }
-
- // Clean filename and create subdirectories if needed
- cleanName := filepath.Clean(f.Filename)
- targetPath, err := safeJoin(sessionDir, cleanName)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid filename: %s", f.Filename)})
- return
- }
-
- // Create parent directory if file is in a subdirectory
- if dir := filepath.Dir(targetPath); dir != sessionDir {
- if err := os.MkdirAll(dir, 0755); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create directory for %s", f.Filename)})
- return
- }
- }
-
- if err := os.WriteFile(targetPath, []byte(f.Content), 0644); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to write file %s", f.Filename)})
- return
- }
-
- // Track main Caddyfile
- if cleanName == "Caddyfile" || !strings.Contains(cleanName, "/") {
- mainCaddyfile = targetPath
- }
- }
-
- // Parse the main Caddyfile (which will automatically resolve imports)
- result, err := h.importerservice.ImportFile(mainCaddyfile)
- if err != nil {
- // Provide diagnostics
- preview := ""
- if b, rerr := os.ReadFile(mainCaddyfile); rerr == nil {
- if len(b) > 200 {
- preview = string(b[:200])
- } else {
- preview = string(b)
- }
- }
- middleware.GetRequestLogger(c).WithError(err).WithField("mainCaddyfile", util.SanitizeForLog(filepath.Base(mainCaddyfile))).WithField("preview", util.SanitizeForLog(preview)).Error("Import UploadMulti: import failed")
- c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("import failed: %v", err)})
- return
- }
-
- // If parsing succeeded but no hosts were found, and imports were present in the main file,
- // inform the caller to upload the site files.
- if len(result.Hosts) == 0 {
- mainContentBytes, _ := os.ReadFile(mainCaddyfile)
- imports := detectImportDirectives(string(mainContentBytes))
- if len(imports) > 0 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "no sites parsed from main Caddyfile; import directives detected; please include site files in upload", "imports": imports})
- return
- }
- c.JSON(http.StatusBadRequest, gin.H{"error": "no sites parsed from main Caddyfile"})
- return
- }
-
- // Check for conflicts
- existingHosts, _ := h.proxyHostSvc.List()
- existingDomains := make(map[string]bool)
- for _, eh := range existingHosts {
- existingDomains[eh.DomainNames] = true
- }
- for _, ph := range result.Hosts {
- if existingDomains[ph.DomainNames] {
- result.Conflicts = append(result.Conflicts, ph.DomainNames)
- }
- }
-
- c.JSON(http.StatusOK, gin.H{
- "session": gin.H{"id": sid, "state": "transient", "source_file": mainCaddyfile},
- "preview": result,
- })
-}
-
-// detectImportDirectives scans Caddyfile content for import directives.
-func detectImportDirectives(content string) []string {
- imports := []string{}
- lines := strings.Split(content, "\n")
- for _, line := range lines {
- trimmed := strings.TrimSpace(line)
- if strings.HasPrefix(trimmed, "import ") {
- path := strings.TrimSpace(strings.TrimPrefix(trimmed, "import"))
- // Remove any trailing comments
- if idx := strings.Index(path, "#"); idx != -1 {
- path = strings.TrimSpace(path[:idx])
- }
- imports = append(imports, path)
- }
- }
- return imports
-}
-
-// safeJoin joins a user-supplied path to a base directory and ensures
-// the resulting path is contained within the base directory.
-func safeJoin(baseDir, userPath string) (string, error) {
- clean := filepath.Clean(userPath)
- if clean == "" || clean == "." {
- return "", fmt.Errorf("empty path not allowed")
- }
- if filepath.IsAbs(clean) {
- return "", fmt.Errorf("absolute paths not allowed")
- }
-
- // Prevent attempts like ".." at start
- if strings.HasPrefix(clean, ".."+string(os.PathSeparator)) || clean == ".." {
- return "", fmt.Errorf("path traversal detected")
- }
-
- target := filepath.Join(baseDir, clean)
- rel, err := filepath.Rel(baseDir, target)
- if err != nil {
- return "", fmt.Errorf("invalid path")
- }
- if strings.HasPrefix(rel, "..") {
- return "", fmt.Errorf("path traversal detected")
- }
-
- // Normalize to use base's separators
- target = path.Clean(target)
- return target, nil
-}
-
-// isSafePathUnderBase reports whether userPath, when cleaned and joined
-// to baseDir, stays within baseDir. Used by tests.
-func isSafePathUnderBase(baseDir, userPath string) bool {
- _, err := safeJoin(baseDir, userPath)
- return err == nil
-}
-
-// Commit finalizes the import with user's conflict resolutions.
-func (h *ImportHandler) Commit(c *gin.Context) {
- var req struct {
- SessionUUID string `json:"session_uuid" binding:"required"`
- Resolutions map[string]string `json:"resolutions"` // domain -> action (keep/skip, overwrite, rename)
- Names map[string]string `json:"names"` // domain -> custom name
- }
-
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // Try to find a DB-backed session first
- var session models.ImportSession
- // Basic sanitize of session id to prevent path separators
- sid := filepath.Base(req.SessionUUID)
- if sid == "" || sid == "." || strings.Contains(sid, string(os.PathSeparator)) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_uuid"})
- return
- }
- var result *caddy.ImportResult
- if err := h.db.Where("uuid = ? AND status = ?", sid, "reviewing").First(&session).Error; err == nil {
- // DB session found
- if err := json.Unmarshal([]byte(session.ParsedData), &result); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse import data"})
- return
- }
- } else {
- // No DB session: check for uploaded temp file
- var parseErr error
- uploadsPath, err := safeJoin(h.importDir, filepath.Join("uploads", fmt.Sprintf("%s.caddyfile", sid)))
- if err == nil {
- if _, err := os.Stat(uploadsPath); err == nil {
- r, err := h.importerservice.ImportFile(uploadsPath)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse uploaded file"})
- return
- }
- result = r
- // We'll create a committed DB session after applying
- session = models.ImportSession{UUID: sid, SourceFile: uploadsPath}
- }
- }
- // If not found yet, check mounted Caddyfile
- if result == nil && h.mountPath != "" {
- if _, err := os.Stat(h.mountPath); err == nil {
- r, err := h.importerservice.ImportFile(h.mountPath)
- if err != nil {
- parseErr = err
- } else {
- result = r
- session = models.ImportSession{UUID: sid, SourceFile: h.mountPath}
- }
- }
- }
- // If still not parsed, return not found or error
- if result == nil {
- if parseErr != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
- return
- }
- c.JSON(http.StatusNotFound, gin.H{"error": "session not found or file missing"})
- return
- }
- }
-
- // Convert parsed hosts to ProxyHost models
- proxyHosts := caddy.ConvertToProxyHosts(result.Hosts)
- middleware.GetRequestLogger(c).WithField("parsed_hosts", len(result.Hosts)).WithField("proxy_hosts", len(proxyHosts)).Info("Import Commit: Parsed and converted hosts")
-
- created := 0
- updated := 0
- skipped := 0
- errors := []string{}
-
- // Get existing hosts to check for overwrites
- existingHosts, _ := h.proxyHostSvc.List()
- existingMap := make(map[string]*models.ProxyHost)
- for i := range existingHosts {
- existingMap[existingHosts[i].DomainNames] = &existingHosts[i]
- }
-
- for _, host := range proxyHosts {
- action := req.Resolutions[host.DomainNames]
-
- // Apply custom name from user input
- if customName, ok := req.Names[host.DomainNames]; ok && customName != "" {
- host.Name = customName
- }
-
- // "keep" means keep existing (don't import), same as "skip"
- if action == "skip" || action == "keep" {
- skipped++
- continue
- }
-
- if action == "rename" {
- host.DomainNames += "-imported"
- }
-
- // Handle overwrite: preserve existing ID, UUID, and certificate
- if action == "overwrite" {
- if existing, found := existingMap[host.DomainNames]; found {
- host.ID = existing.ID
- host.UUID = existing.UUID
- host.CertificateID = existing.CertificateID // Preserve certificate association
- host.CreatedAt = existing.CreatedAt
-
- if err := h.proxyHostSvc.Update(&host); err != nil {
- errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
- errors = append(errors, errMsg)
- middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).WithField("error", sanitizeForLog(errMsg)).Error("Import Commit Error (update)")
- } else {
- updated++
- middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).Info("Import Commit Success: Updated host")
- }
- continue
- }
- // If "overwrite" but doesn't exist, fall through to create
- }
-
- // Create new host
- host.UUID = uuid.NewString()
- if err := h.proxyHostSvc.Create(&host); err != nil {
- errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
- errors = append(errors, errMsg)
- middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).WithField("error", util.SanitizeForLog(errMsg)).Error("Import Commit Error")
- } else {
- created++
- middleware.GetRequestLogger(c).WithField("host", util.SanitizeForLog(host.DomainNames)).Info("Import Commit Success: Created host")
- }
- }
-
- // Persist an import session record now that user confirmed
- now := time.Now()
- session.Status = "committed"
- session.CommittedAt = &now
- session.UserResolutions = string(mustMarshal(req.Resolutions))
- // If ParsedData/ConflictReport not set, fill from result
- if session.ParsedData == "" {
- session.ParsedData = string(mustMarshal(result))
- }
- if session.ConflictReport == "" {
- session.ConflictReport = string(mustMarshal(result.Conflicts))
- }
- if err := h.db.Save(&session).Error; err != nil {
- middleware.GetRequestLogger(c).WithError(err).Warn("Warning: failed to save import session")
- }
-
- c.JSON(http.StatusOK, gin.H{
- "created": created,
- "updated": updated,
- "skipped": skipped,
- "errors": errors,
- })
-}
-
-// Cancel discards a pending import session.
-func (h *ImportHandler) Cancel(c *gin.Context) {
- sessionUUID := c.Query("session_uuid")
- if sessionUUID == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "session_uuid required"})
- return
- }
-
- sid := filepath.Base(sessionUUID)
- if sid == "" || sid == "." || strings.Contains(sid, string(os.PathSeparator)) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session_uuid"})
- return
- }
-
- var session models.ImportSession
- if err := h.db.Where("uuid = ?", sid).First(&session).Error; err == nil {
- session.Status = "rejected"
- h.db.Save(&session)
- c.JSON(http.StatusOK, gin.H{"message": "import cancelled"})
- return
- }
-
- // If no DB session, check for uploaded temp file and delete it
- uploadsPath, err := safeJoin(h.importDir, filepath.Join("uploads", fmt.Sprintf("%s.caddyfile", sid)))
- if err == nil {
- if _, err := os.Stat(uploadsPath); err == nil {
- os.Remove(uploadsPath)
- c.JSON(http.StatusOK, gin.H{"message": "transient upload cancelled"})
- return
- }
- }
-
- // If neither exists, return not found
- c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
-}
-
-// CheckMountedImport checks for mounted Caddyfile on startup.
-func CheckMountedImport(db *gorm.DB, mountPath, caddyBinary, importDir string) error {
- if _, err := os.Stat(mountPath); os.IsNotExist(err) {
- // If mount is gone, remove any pending/reviewing sessions created previously for this mount
- db.Where("source_file = ? AND status IN ?", mountPath, []string{"pending", "reviewing"}).Delete(&models.ImportSession{})
- return nil // No mounted file, nothing to import
- }
-
- // Check if already processed (includes committed to avoid re-imports)
- var count int64
- db.Model(&models.ImportSession{}).Where("source_file = ? AND status IN ?",
- mountPath, []string{"pending", "reviewing", "committed"}).Count(&count)
-
- if count > 0 {
- return nil // Already processed
- }
-
- // Do not create a DB session automatically for mounted imports; preview will be transient.
- return nil
-}
-
-func mustMarshal(v interface{}) []byte {
- b, _ := json.Marshal(v)
- return b
-}
-
-
-
package handlers
-
-import (
- "io"
- "net/http"
- "os"
- "strconv"
- "strings"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
-)
-
-type LogsHandler struct {
- service *services.LogService
-}
-
-func NewLogsHandler(service *services.LogService) *LogsHandler {
- return &LogsHandler{service: service}
-}
-
-func (h *LogsHandler) List(c *gin.Context) {
- logs, err := h.service.ListLogs()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list logs"})
- return
- }
- c.JSON(http.StatusOK, logs)
-}
-
-func (h *LogsHandler) Read(c *gin.Context) {
- filename := c.Param("filename")
-
- // Parse query parameters
- limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
- offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
-
- filter := models.LogFilter{
- Search: c.Query("search"),
- Host: c.Query("host"),
- Status: c.Query("status"),
- Level: c.Query("level"),
- Limit: limit,
- Offset: offset,
- Sort: c.DefaultQuery("sort", "desc"),
- }
-
- logs, total, err := h.service.QueryLogs(filename, filter)
- if err != nil {
- if os.IsNotExist(err) {
- c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read log"})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "filename": filename,
- "logs": logs,
- "total": total,
- "limit": limit,
- "offset": offset,
- })
-}
-
-func (h *LogsHandler) Download(c *gin.Context) {
- filename := c.Param("filename")
- path, err := h.service.GetLogPath(filename)
- if err != nil {
- if strings.Contains(err.Error(), "invalid filename") {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusNotFound, gin.H{"error": "Log file not found"})
- return
- }
-
- // Create a temporary file to serve a consistent snapshot
- // This prevents Content-Length mismatches if the live log file grows during download
- tmpFile, err := os.CreateTemp("", "charon-log-*.log")
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create temp file"})
- return
- }
- defer os.Remove(tmpFile.Name())
-
- srcFile, err := os.Open(path)
- if err != nil {
- _ = tmpFile.Close()
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to open log file"})
- return
- }
- defer func() { _ = srcFile.Close() }()
-
- if _, err := io.Copy(tmpFile, srcFile); err != nil {
- _ = tmpFile.Close()
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to copy log file"})
- return
- }
- _ = tmpFile.Close()
-
- c.Header("Content-Disposition", "attachment; filename="+filename)
- c.File(tmpFile.Name())
-}
-
-
-
package handlers
-
-import (
- "net/http"
-
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
-)
-
-type NotificationHandler struct {
- service *services.NotificationService
-}
-
-func NewNotificationHandler(service *services.NotificationService) *NotificationHandler {
- return &NotificationHandler{service: service}
-}
-
-func (h *NotificationHandler) List(c *gin.Context) {
- unreadOnly := c.Query("unread") == "true"
- notifications, err := h.service.List(unreadOnly)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list notifications"})
- return
- }
- c.JSON(http.StatusOK, notifications)
-}
-
-func (h *NotificationHandler) MarkAsRead(c *gin.Context) {
- id := c.Param("id")
- if err := h.service.MarkAsRead(id); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification as read"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "Notification marked as read"})
-}
-
-func (h *NotificationHandler) MarkAllAsRead(c *gin.Context) {
- if err := h.service.MarkAllAsRead(); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark all notifications as read"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "All notifications marked as read"})
-}
-
-
-
package handlers
-
-import (
- "encoding/json"
- "fmt"
- "net/http"
- "strings"
- "time"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
-)
-
-type NotificationProviderHandler struct {
- service *services.NotificationService
-}
-
-func NewNotificationProviderHandler(service *services.NotificationService) *NotificationProviderHandler {
- return &NotificationProviderHandler{service: service}
-}
-
-func (h *NotificationProviderHandler) List(c *gin.Context) {
- providers, err := h.service.ListProviders()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list providers"})
- return
- }
- c.JSON(http.StatusOK, providers)
-}
-
-func (h *NotificationProviderHandler) Create(c *gin.Context) {
- var provider models.NotificationProvider
- if err := c.ShouldBindJSON(&provider); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if err := h.service.CreateProvider(&provider); err != nil {
- // If it's a validation error from template parsing, return 400
- if strings.Contains(err.Error(), "invalid custom template") || strings.Contains(err.Error(), "rendered template") || strings.Contains(err.Error(), "failed to parse template") || strings.Contains(err.Error(), "failed to render template") {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create provider"})
- return
- }
- c.JSON(http.StatusCreated, provider)
-}
-
-func (h *NotificationProviderHandler) Update(c *gin.Context) {
- id := c.Param("id")
- var provider models.NotificationProvider
- if err := c.ShouldBindJSON(&provider); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- provider.ID = id
-
- if err := h.service.UpdateProvider(&provider); err != nil {
- if strings.Contains(err.Error(), "invalid custom template") || strings.Contains(err.Error(), "rendered template") || strings.Contains(err.Error(), "failed to parse template") || strings.Contains(err.Error(), "failed to render template") {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update provider"})
- return
- }
- c.JSON(http.StatusOK, provider)
-}
-
-func (h *NotificationProviderHandler) Delete(c *gin.Context) {
- id := c.Param("id")
- if err := h.service.DeleteProvider(id); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "Provider deleted"})
-}
-
-func (h *NotificationProviderHandler) Test(c *gin.Context) {
- var provider models.NotificationProvider
- if err := c.ShouldBindJSON(&provider); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if err := h.service.TestProvider(provider); err != nil {
- // Create internal notification for the failure
- _, _ = h.service.Create(models.NotificationTypeError, "Test Failed", fmt.Sprintf("Provider %s test failed: %v", provider.Name, err))
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "Test notification sent"})
-}
-
-// Templates returns a list of built-in templates a provider can use.
-func (h *NotificationProviderHandler) Templates(c *gin.Context) {
- c.JSON(http.StatusOK, []gin.H{
- {"id": "minimal", "name": "Minimal", "description": "Small JSON payload with title, message and time."},
- {"id": "detailed", "name": "Detailed", "description": "Full JSON payload with host, services and all data."},
- {"id": "custom", "name": "Custom", "description": "Use your own JSON template in the Config field."},
- })
-}
-
-// Preview renders the template for a provider and returns the resulting JSON object or an error.
-func (h *NotificationProviderHandler) Preview(c *gin.Context) {
- var raw map[string]interface{}
- if err := c.ShouldBindJSON(&raw); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- var provider models.NotificationProvider
- // Marshal raw into provider to get proper types
- if b, err := json.Marshal(raw); err == nil {
- _ = json.Unmarshal(b, &provider)
- }
- var payload map[string]interface{}
- if d, ok := raw["data"].(map[string]interface{}); ok {
- payload = d
- }
-
- if payload == nil {
- payload = map[string]interface{}{}
- }
-
- // Add some defaults for preview
- if _, ok := payload["Title"]; !ok {
- payload["Title"] = "Preview Title"
- }
- if _, ok := payload["Message"]; !ok {
- payload["Message"] = "Preview Message"
- }
- payload["Time"] = time.Now().Format(time.RFC3339)
- payload["EventType"] = "preview"
-
- rendered, parsed, err := h.service.RenderTemplate(provider, payload)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
- return
- }
- c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})
-}
-
-
-
package handlers
-
-import (
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
- "net/http"
-)
-
-type NotificationTemplateHandler struct {
- service *services.NotificationService
-}
-
-func NewNotificationTemplateHandler(s *services.NotificationService) *NotificationTemplateHandler {
- return &NotificationTemplateHandler{service: s}
-}
-
-func (h *NotificationTemplateHandler) List(c *gin.Context) {
- list, err := h.service.ListTemplates()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list templates"})
- return
- }
- c.JSON(http.StatusOK, list)
-}
-
-func (h *NotificationTemplateHandler) Create(c *gin.Context) {
- var t models.NotificationTemplate
- if err := c.ShouldBindJSON(&t); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- if err := h.service.CreateTemplate(&t); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create template"})
- return
- }
- c.JSON(http.StatusCreated, t)
-}
-
-func (h *NotificationTemplateHandler) Update(c *gin.Context) {
- id := c.Param("id")
- var t models.NotificationTemplate
- if err := c.ShouldBindJSON(&t); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- t.ID = id
- if err := h.service.UpdateTemplate(&t); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update template"})
- return
- }
- c.JSON(http.StatusOK, t)
-}
-
-func (h *NotificationTemplateHandler) Delete(c *gin.Context) {
- id := c.Param("id")
- if err := h.service.DeleteTemplate(id); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete template"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "deleted"})
-}
-
-// Preview allows rendering an arbitrary template (provided in request) or a stored template by id.
-func (h *NotificationTemplateHandler) Preview(c *gin.Context) {
- var raw map[string]interface{}
- if err := c.ShouldBindJSON(&raw); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- var tmplStr string
- if id, ok := raw["template_id"].(string); ok && id != "" {
- t, err := h.service.GetTemplate(id)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "template not found"})
- return
- }
- tmplStr = t.Config
- } else if s, ok := raw["template"].(string); ok {
- tmplStr = s
- }
-
- data := map[string]interface{}{}
- if d, ok := raw["data"].(map[string]interface{}); ok {
- data = d
- }
-
- // Build a fake provider to leverage existing RenderTemplate logic
- provider := models.NotificationProvider{Template: "custom", Config: tmplStr}
- rendered, parsed, err := h.service.RenderTemplate(provider, data)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "rendered": rendered})
- return
- }
- c.JSON(http.StatusOK, gin.H{"rendered": rendered, "parsed": parsed})
-}
-
-
-
package handlers
-
-import (
- "encoding/json"
- "fmt"
- "net/http"
- "strconv"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/api/middleware"
- "github.com/Wikid82/charon/backend/internal/caddy"
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/Wikid82/charon/backend/internal/util"
-)
-
-// ProxyHostHandler handles CRUD operations for proxy hosts.
-type ProxyHostHandler struct {
- service *services.ProxyHostService
- caddyManager *caddy.Manager
- notificationService *services.NotificationService
- uptimeService *services.UptimeService
-}
-
-// NewProxyHostHandler creates a new proxy host handler.
-func NewProxyHostHandler(db *gorm.DB, caddyManager *caddy.Manager, ns *services.NotificationService, uptimeService *services.UptimeService) *ProxyHostHandler {
- return &ProxyHostHandler{
- service: services.NewProxyHostService(db),
- caddyManager: caddyManager,
- notificationService: ns,
- uptimeService: uptimeService,
- }
-}
-
-// RegisterRoutes registers proxy host routes.
-func (h *ProxyHostHandler) RegisterRoutes(router *gin.RouterGroup) {
- router.GET("/proxy-hosts", h.List)
- router.POST("/proxy-hosts", h.Create)
- router.GET("/proxy-hosts/:uuid", h.Get)
- router.PUT("/proxy-hosts/:uuid", h.Update)
- router.DELETE("/proxy-hosts/:uuid", h.Delete)
- router.POST("/proxy-hosts/test", h.TestConnection)
- router.PUT("/proxy-hosts/bulk-update-acl", h.BulkUpdateACL)
-}
-
-// List retrieves all proxy hosts.
-func (h *ProxyHostHandler) List(c *gin.Context) {
- hosts, err := h.service.List()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, hosts)
-}
-
-// Create creates a new proxy host.
-func (h *ProxyHostHandler) Create(c *gin.Context) {
- var host models.ProxyHost
- if err := c.ShouldBindJSON(&host); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // Validate and normalize advanced config if present
- if host.AdvancedConfig != "" {
- var parsed interface{}
- if err := json.Unmarshal([]byte(host.AdvancedConfig), &parsed); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config JSON: " + err.Error()})
- return
- }
- parsed = caddy.NormalizeAdvancedConfig(parsed)
- if norm, err := json.Marshal(parsed); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config after normalization: " + err.Error()})
- return
- } else {
- host.AdvancedConfig = string(norm)
- }
- }
-
- host.UUID = uuid.NewString()
-
- // Assign UUIDs to locations
- for i := range host.Locations {
- host.Locations[i].UUID = uuid.NewString()
- }
-
- if err := h.service.Create(&host); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if h.caddyManager != nil {
- if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
- // Rollback: delete the created host if config application fails
- middleware.GetRequestLogger(c).WithError(err).Error("Error applying config")
- if deleteErr := h.service.Delete(host.ID); deleteErr != nil {
- idStr := strconv.FormatUint(uint64(host.ID), 10)
- middleware.GetRequestLogger(c).WithField("host_id", idStr).WithError(deleteErr).Error("Critical: Failed to rollback host")
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
- return
- }
- }
-
- // Send Notification
- if h.notificationService != nil {
- h.notificationService.SendExternal(c.Request.Context(),
- "proxy_host",
- "Proxy Host Created",
- fmt.Sprintf("Proxy Host %s (%s) created", util.SanitizeForLog(host.Name), util.SanitizeForLog(host.DomainNames)),
- map[string]interface{}{
- "Name": util.SanitizeForLog(host.Name),
- "Domains": util.SanitizeForLog(host.DomainNames),
- "Action": "created",
- },
- )
- }
-
- c.JSON(http.StatusCreated, host)
-}
-
-// Get retrieves a proxy host by UUID.
-func (h *ProxyHostHandler) Get(c *gin.Context) {
- uuid := c.Param("uuid")
-
- host, err := h.service.GetByUUID(uuid)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
- return
- }
-
- c.JSON(http.StatusOK, host)
-}
-
-// Update updates an existing proxy host.
-func (h *ProxyHostHandler) Update(c *gin.Context) {
- uuidStr := c.Param("uuid")
-
- host, err := h.service.GetByUUID(uuidStr)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
- return
- }
-
- // Perform a partial update: only mutate fields present in payload
- var payload map[string]interface{}
- if err := c.ShouldBindJSON(&payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // Handle simple scalar fields by json tag names (snake_case)
- if v, ok := payload["name"].(string); ok {
- host.Name = v
- }
- if v, ok := payload["domain_names"].(string); ok {
- host.DomainNames = v
- }
- if v, ok := payload["forward_scheme"].(string); ok {
- host.ForwardScheme = v
- }
- if v, ok := payload["forward_host"].(string); ok {
- host.ForwardHost = v
- }
- if v, ok := payload["forward_port"]; ok {
- switch t := v.(type) {
- case float64:
- host.ForwardPort = int(t)
- case int:
- host.ForwardPort = t
- case string:
- if p, err := strconv.Atoi(t); err == nil {
- host.ForwardPort = p
- }
- }
- }
- if v, ok := payload["ssl_forced"].(bool); ok {
- host.SSLForced = v
- }
- if v, ok := payload["http2_support"].(bool); ok {
- host.HTTP2Support = v
- }
- if v, ok := payload["hsts_enabled"].(bool); ok {
- host.HSTSEnabled = v
- }
- if v, ok := payload["hsts_subdomains"].(bool); ok {
- host.HSTSSubdomains = v
- }
- if v, ok := payload["block_exploits"].(bool); ok {
- host.BlockExploits = v
- }
- if v, ok := payload["websocket_support"].(bool); ok {
- host.WebsocketSupport = v
- }
- if v, ok := payload["application"].(string); ok {
- host.Application = v
- }
- if v, ok := payload["enabled"].(bool); ok {
- host.Enabled = v
- }
-
- // Nullable foreign keys
- if v, ok := payload["certificate_id"]; ok {
- if v == nil {
- host.CertificateID = nil
- } else {
- switch t := v.(type) {
- case float64:
- id := uint(t)
- host.CertificateID = &id
- case int:
- id := uint(t)
- host.CertificateID = &id
- case string:
- if n, err := strconv.ParseUint(t, 10, 32); err == nil {
- id := uint(n)
- host.CertificateID = &id
- }
- }
- }
- }
- if v, ok := payload["access_list_id"]; ok {
- if v == nil {
- host.AccessListID = nil
- } else {
- switch t := v.(type) {
- case float64:
- id := uint(t)
- host.AccessListID = &id
- case int:
- id := uint(t)
- host.AccessListID = &id
- case string:
- if n, err := strconv.ParseUint(t, 10, 32); err == nil {
- id := uint(n)
- host.AccessListID = &id
- }
- }
- }
- }
-
- // Locations: replace only if provided
- if v, ok := payload["locations"].([]interface{}); ok {
- // Rebind to []models.Location
- b, _ := json.Marshal(v)
- var locs []models.Location
- if err := json.Unmarshal(b, &locs); err == nil {
- // Ensure UUIDs exist for any new location entries
- for i := range locs {
- if locs[i].UUID == "" {
- locs[i].UUID = uuid.New().String()
- }
- }
- host.Locations = locs
- } else {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid locations payload"})
- return
- }
- }
-
- // Advanced config: normalize if provided and changed
- if v, ok := payload["advanced_config"].(string); ok {
- if v != "" && v != host.AdvancedConfig {
- var parsed interface{}
- if err := json.Unmarshal([]byte(v), &parsed); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config JSON: " + err.Error()})
- return
- }
- parsed = caddy.NormalizeAdvancedConfig(parsed)
- if norm, err := json.Marshal(parsed); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid advanced_config after normalization: " + err.Error()})
- return
- } else {
- // Backup previous
- host.AdvancedConfigBackup = host.AdvancedConfig
- host.AdvancedConfig = string(norm)
- }
- } else if v == "" { // allow clearing advanced config
- host.AdvancedConfigBackup = host.AdvancedConfig
- host.AdvancedConfig = ""
- }
- }
-
- if err := h.service.Update(host); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if h.caddyManager != nil {
- if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
- return
- }
- }
-
- c.JSON(http.StatusOK, host)
-}
-
-// Delete removes a proxy host.
-func (h *ProxyHostHandler) Delete(c *gin.Context) {
- uuid := c.Param("uuid")
-
- host, err := h.service.GetByUUID(uuid)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "proxy host not found"})
- return
- }
-
- // check if we should also delete associated uptime monitors (query param: delete_uptime=true)
- deleteUptime := c.DefaultQuery("delete_uptime", "false") == "true"
-
- if deleteUptime && h.uptimeService != nil {
- // Find all monitors referencing this proxy host and delete each
- var monitors []models.UptimeMonitor
- if err := h.uptimeService.DB.Where("proxy_host_id = ?", host.ID).Find(&monitors).Error; err == nil {
- for _, m := range monitors {
- _ = h.uptimeService.DeleteMonitor(m.ID)
- }
- }
- }
-
- if err := h.service.Delete(host.ID); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- if h.caddyManager != nil {
- if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
- return
- }
- }
-
- // Send Notification
- if h.notificationService != nil {
- h.notificationService.SendExternal(c.Request.Context(),
- "proxy_host",
- "Proxy Host Deleted",
- fmt.Sprintf("Proxy Host %s deleted", host.Name),
- map[string]interface{}{
- "Name": host.Name,
- "Action": "deleted",
- },
- )
- }
-
- c.JSON(http.StatusOK, gin.H{"message": "proxy host deleted"})
-}
-
-// TestConnection checks if the proxy host is reachable.
-func (h *ProxyHostHandler) TestConnection(c *gin.Context) {
- var req struct {
- ForwardHost string `json:"forward_host" binding:"required"`
- ForwardPort int `json:"forward_port" binding:"required"`
- }
-
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if err := h.service.TestConnection(req.ForwardHost, req.ForwardPort); err != nil {
- c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"message": "Connection successful"})
-}
-
-// BulkUpdateACL applies or removes an access list to multiple proxy hosts.
-func (h *ProxyHostHandler) BulkUpdateACL(c *gin.Context) {
- var req struct {
- HostUUIDs []string `json:"host_uuids" binding:"required"`
- AccessListID *uint `json:"access_list_id"` // nil means remove ACL
- }
-
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if len(req.HostUUIDs) == 0 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "host_uuids cannot be empty"})
- return
- }
-
- updated := 0
- errors := []map[string]string{}
-
- for _, uuid := range req.HostUUIDs {
- host, err := h.service.GetByUUID(uuid)
- if err != nil {
- errors = append(errors, map[string]string{
- "uuid": uuid,
- "error": "proxy host not found",
- })
- continue
- }
-
- host.AccessListID = req.AccessListID
- if err := h.service.Update(host); err != nil {
- errors = append(errors, map[string]string{
- "uuid": uuid,
- "error": err.Error(),
- })
- continue
- }
-
- updated++
- }
-
- // Apply Caddy config once for all updates
- if updated > 0 && h.caddyManager != nil {
- if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{
- "error": "Failed to apply configuration: " + err.Error(),
- "updated": updated,
- "errors": errors,
- })
- return
- }
- }
-
- c.JSON(http.StatusOK, gin.H{
- "updated": updated,
- "errors": errors,
- })
-}
-
-
-
package handlers
-
-import (
- "fmt"
- "net"
- "net/http"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/Wikid82/charon/backend/internal/util"
-)
-
-// RemoteServerHandler handles HTTP requests for remote server management.
-type RemoteServerHandler struct {
- service *services.RemoteServerService
- notificationService *services.NotificationService
-}
-
-// NewRemoteServerHandler creates a new remote server handler.
-func NewRemoteServerHandler(service *services.RemoteServerService, ns *services.NotificationService) *RemoteServerHandler {
- return &RemoteServerHandler{
- service: service,
- notificationService: ns,
- }
-}
-
-// RegisterRoutes registers remote server routes.
-func (h *RemoteServerHandler) RegisterRoutes(router *gin.RouterGroup) {
- router.GET("/remote-servers", h.List)
- router.POST("/remote-servers", h.Create)
- router.GET("/remote-servers/:uuid", h.Get)
- router.PUT("/remote-servers/:uuid", h.Update)
- router.DELETE("/remote-servers/:uuid", h.Delete)
- router.POST("/remote-servers/test", h.TestConnectionCustom)
- router.POST("/remote-servers/:uuid/test", h.TestConnection)
-}
-
-// List retrieves all remote servers.
-func (h *RemoteServerHandler) List(c *gin.Context) {
- enabledOnly := c.Query("enabled") == "true"
-
- servers, err := h.service.List(enabledOnly)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, servers)
-}
-
-// Create creates a new remote server.
-func (h *RemoteServerHandler) Create(c *gin.Context) {
- var server models.RemoteServer
- if err := c.ShouldBindJSON(&server); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- server.UUID = uuid.NewString()
-
- if err := h.service.Create(&server); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // Send Notification
- if h.notificationService != nil {
- h.notificationService.SendExternal(c.Request.Context(),
- "remote_server",
- "Remote Server Added",
- fmt.Sprintf("Remote Server %s (%s:%d) added", util.SanitizeForLog(server.Name), util.SanitizeForLog(server.Host), server.Port),
- map[string]interface{}{
- "Name": util.SanitizeForLog(server.Name),
- "Host": util.SanitizeForLog(server.Host),
- "Port": server.Port,
- "Action": "created",
- },
- )
- }
-
- c.JSON(http.StatusCreated, server)
-}
-
-// Get retrieves a remote server by UUID.
-func (h *RemoteServerHandler) Get(c *gin.Context) {
- uuid := c.Param("uuid")
-
- server, err := h.service.GetByUUID(uuid)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
- return
- }
-
- c.JSON(http.StatusOK, server)
-}
-
-// Update updates an existing remote server.
-func (h *RemoteServerHandler) Update(c *gin.Context) {
- uuid := c.Param("uuid")
-
- server, err := h.service.GetByUUID(uuid)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
- return
- }
-
- if err := c.ShouldBindJSON(server); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if err := h.service.Update(server); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, server)
-}
-
-// Delete removes a remote server.
-func (h *RemoteServerHandler) Delete(c *gin.Context) {
- uuid := c.Param("uuid")
-
- server, err := h.service.GetByUUID(uuid)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
- return
- }
-
- if err := h.service.Delete(server.ID); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- // Send Notification
- if h.notificationService != nil {
- h.notificationService.SendExternal(c.Request.Context(),
- "remote_server",
- "Remote Server Deleted",
- fmt.Sprintf("Remote Server %s deleted", util.SanitizeForLog(server.Name)),
- map[string]interface{}{
- "Name": util.SanitizeForLog(server.Name),
- "Action": "deleted",
- },
- )
- }
-
- c.JSON(http.StatusNoContent, nil)
-}
-
-// TestConnection tests the TCP connection to a remote server.
-func (h *RemoteServerHandler) TestConnection(c *gin.Context) {
- uuid := c.Param("uuid")
-
- server, err := h.service.GetByUUID(uuid)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
- return
- }
-
- // Test TCP connection with 5 second timeout
- address := net.JoinHostPort(server.Host, fmt.Sprintf("%d", server.Port))
- conn, err := net.DialTimeout("tcp", address, 5*time.Second)
-
- result := gin.H{
- "server_uuid": server.UUID,
- "address": address,
- "timestamp": time.Now().UTC(),
- }
-
- if err != nil {
- result["reachable"] = false
- result["error"] = err.Error()
-
- // Update server reachability status
- server.Reachable = false
- now := time.Now().UTC()
- server.LastChecked = &now
- _ = h.service.Update(server)
-
- c.JSON(http.StatusOK, result)
- return
- }
- defer func() { _ = conn.Close() }()
-
- // Connection successful
- result["reachable"] = true
- result["latency_ms"] = time.Since(time.Now()).Milliseconds()
-
- // Update server reachability status
- server.Reachable = true
- now := time.Now().UTC()
- server.LastChecked = &now
- _ = h.service.Update(server)
-
- c.JSON(http.StatusOK, result)
-}
-
-// TestConnectionCustom tests connectivity to a host/port provided in the body
-func (h *RemoteServerHandler) TestConnectionCustom(c *gin.Context) {
- var req struct {
- Host string `json:"host" binding:"required"`
- Port int `json:"port" binding:"required"`
- }
-
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // Test TCP connection with 5 second timeout
- address := net.JoinHostPort(req.Host, fmt.Sprintf("%d", req.Port))
- start := time.Now()
- conn, err := net.DialTimeout("tcp", address, 5*time.Second)
-
- result := gin.H{
- "address": address,
- "timestamp": time.Now().UTC(),
- }
-
- if err != nil {
- result["reachable"] = false
- result["error"] = err.Error()
- c.JSON(http.StatusOK, result)
- return
- }
- defer func() { _ = conn.Close() }()
-
- // Connection successful
- result["reachable"] = true
- result["latency_ms"] = time.Since(start).Milliseconds()
-
- c.JSON(http.StatusOK, result)
-}
-
-
-
package handlers
-
-import (
- "regexp"
- "strings"
-)
-
-// sanitizeForLog removes control characters and newlines from user content before logging.
-func sanitizeForLog(s string) string {
- if s == "" {
- return s
- }
- // Replace CRLF and LF with spaces and remove other control chars
- s = strings.ReplaceAll(s, "\r\n", " ")
- s = strings.ReplaceAll(s, "\n", " ")
- // remove any other non-printable control characters
- re := regexp.MustCompile(`[\x00-\x1F\x7F]+`)
- s = re.ReplaceAllString(s, " ")
- return s
-}
-
-
-
package handlers
-
-import (
- "errors"
- "net"
- "net/http"
- "strconv"
- "strings"
-
- "github.com/gin-gonic/gin"
- log "github.com/sirupsen/logrus"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/caddy"
- "github.com/Wikid82/charon/backend/internal/config"
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
-)
-
-// SecurityHandler handles security-related API requests.
-type SecurityHandler struct {
- cfg config.SecurityConfig
- db *gorm.DB
- svc *services.SecurityService
- caddyManager *caddy.Manager
-}
-
-// NewSecurityHandler creates a new SecurityHandler.
-func NewSecurityHandler(cfg config.SecurityConfig, db *gorm.DB, caddyManager *caddy.Manager) *SecurityHandler {
- svc := services.NewSecurityService(db)
- return &SecurityHandler{cfg: cfg, db: db, svc: svc, caddyManager: caddyManager}
-}
-
-// GetStatus returns the current status of all security services.
-func (h *SecurityHandler) GetStatus(c *gin.Context) {
- enabled := h.cfg.CerberusEnabled
- // Check runtime setting override
- var settingKey = "security.cerberus.enabled"
- if h.db != nil {
- var setting struct{ Value string }
- if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", settingKey).Scan(&setting).Error; err == nil && setting.Value != "" {
- if strings.EqualFold(setting.Value, "true") {
- enabled = true
- } else {
- enabled = false
- }
- }
- }
-
- // Allow runtime overrides for CrowdSec mode + API URL via settings table
- mode := h.cfg.CrowdSecMode
- apiURL := h.cfg.CrowdSecAPIURL
- if h.db != nil {
- var m struct{ Value string }
- if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.mode").Scan(&m).Error; err == nil && m.Value != "" {
- mode = m.Value
- }
- var a struct{ Value string }
- if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.crowdsec.api_url").Scan(&a).Error; err == nil && a.Value != "" {
- apiURL = a.Value
- }
- }
-
- // Only allow 'local' as an enabled mode. Any other value should be treated as disabled.
- if mode != "local" {
- mode = "disabled"
- apiURL = ""
- }
-
- // Allow runtime override for ACL enabled flag via settings table
- aclEnabled := h.cfg.ACLMode == "enabled"
- aclEffective := aclEnabled && enabled
- if h.db != nil {
- var a struct{ Value string }
- if err := h.db.Raw("SELECT value FROM settings WHERE key = ? LIMIT 1", "security.acl.enabled").Scan(&a).Error; err == nil && a.Value != "" {
- if strings.EqualFold(a.Value, "true") {
- aclEnabled = true
- } else if strings.EqualFold(a.Value, "false") {
- aclEnabled = false
- }
-
- // If Cerberus is disabled, ACL should not be considered enabled even
- // if the ACL setting is true. This keeps ACL tied to the Cerberus
- // suite state in the UI and APIs.
- aclEffective = aclEnabled && enabled
- }
- }
-
- c.JSON(http.StatusOK, gin.H{
- "cerberus": gin.H{"enabled": enabled},
- "crowdsec": gin.H{
- "mode": mode,
- "api_url": apiURL,
- "enabled": mode == "local",
- },
- "waf": gin.H{
- "mode": h.cfg.WAFMode,
- "enabled": h.cfg.WAFMode != "" && h.cfg.WAFMode != "disabled",
- },
- "rate_limit": gin.H{
- "mode": h.cfg.RateLimitMode,
- "enabled": h.cfg.RateLimitMode == "enabled",
- },
- "acl": gin.H{
- "mode": h.cfg.ACLMode,
- "enabled": aclEffective,
- },
- })
-}
-
-// GetConfig returns the site security configuration from DB or default
-func (h *SecurityHandler) GetConfig(c *gin.Context) {
- cfg, err := h.svc.Get()
- if err != nil {
- if err == services.ErrSecurityConfigNotFound {
- c.JSON(http.StatusOK, gin.H{"config": nil})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read security config"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"config": cfg})
-}
-
-// UpdateConfig creates or updates the SecurityConfig in DB
-func (h *SecurityHandler) UpdateConfig(c *gin.Context) {
- var payload models.SecurityConfig
- if err := c.ShouldBindJSON(&payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
- return
- }
- if payload.Name == "" {
- payload.Name = "default"
- }
- if err := h.svc.Upsert(&payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
- // Apply updated config to Caddy so WAF mode changes take effect
- if h.caddyManager != nil {
- if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
- log.WithError(err).Warn("failed to apply security config changes to Caddy")
- }
- }
- c.JSON(http.StatusOK, gin.H{"config": payload})
-}
-
-// GenerateBreakGlass generates a break-glass token and returns the plaintext token once
-func (h *SecurityHandler) GenerateBreakGlass(c *gin.Context) {
- token, err := h.svc.GenerateBreakGlassToken("default")
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate break-glass token"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"token": token})
-}
-
-// ListDecisions returns recent security decisions
-func (h *SecurityHandler) ListDecisions(c *gin.Context) {
- limit := 50
- if q := c.Query("limit"); q != "" {
- if v, err := strconv.Atoi(q); err == nil {
- limit = v
- }
- }
- list, err := h.svc.ListDecisions(limit)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list decisions"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"decisions": list})
-}
-
-// CreateDecision creates a manual decision (override) - for now no checks besides payload
-func (h *SecurityHandler) CreateDecision(c *gin.Context) {
- var payload models.SecurityDecision
- if err := c.ShouldBindJSON(&payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
- return
- }
- if payload.IP == "" || payload.Action == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "ip and action are required"})
- return
- }
- // Populate source
- payload.Source = "manual"
- if err := h.svc.LogDecision(&payload); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to log decision"})
- return
- }
- // Record an audit entry
- actor := c.GetString("user_id")
- if actor == "" {
- actor = c.ClientIP()
- }
- _ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "create_decision", Details: payload.Details})
- c.JSON(http.StatusOK, gin.H{"decision": payload})
-}
-
-// ListRuleSets returns the list of known rulesets
-func (h *SecurityHandler) ListRuleSets(c *gin.Context) {
- list, err := h.svc.ListRuleSets()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list rule sets"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"rulesets": list})
-}
-
-// UpsertRuleSet uploads or updates a ruleset
-func (h *SecurityHandler) UpsertRuleSet(c *gin.Context) {
- var payload models.SecurityRuleSet
- if err := c.ShouldBindJSON(&payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
- return
- }
- if payload.Name == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "name required"})
- return
- }
- if err := h.svc.UpsertRuleSet(&payload); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to upsert ruleset"})
- return
- }
- if h.caddyManager != nil {
- if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
- return
- }
- }
- // Create an audit event
- actor := c.GetString("user_id")
- if actor == "" {
- actor = c.ClientIP()
- }
- _ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "upsert_ruleset", Details: payload.Name})
- c.JSON(http.StatusOK, gin.H{"ruleset": payload})
-}
-
-// DeleteRuleSet removes a ruleset by id
-func (h *SecurityHandler) DeleteRuleSet(c *gin.Context) {
- idParam := c.Param("id")
- if idParam == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "id is required"})
- return
- }
- id, err := strconv.ParseUint(idParam, 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
- return
- }
- if err := h.svc.DeleteRuleSet(uint(id)); err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- c.JSON(http.StatusNotFound, gin.H{"error": "ruleset not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete ruleset"})
- return
- }
- if h.caddyManager != nil {
- if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
- return
- }
- }
- actor := c.GetString("user_id")
- if actor == "" {
- actor = c.ClientIP()
- }
- _ = h.svc.LogAudit(&models.SecurityAudit{Actor: actor, Action: "delete_ruleset", Details: idParam})
- c.JSON(http.StatusOK, gin.H{"deleted": true})
-}
-
-// Enable toggles Cerberus on, validating admin whitelist or break-glass token
-func (h *SecurityHandler) Enable(c *gin.Context) {
- // Look for requester's IP and optional breakglass token
- adminIP := c.ClientIP()
- var body struct {
- Token string `json:"break_glass_token"`
- }
- _ = c.ShouldBindJSON(&body)
-
- // If config exists, require that adminIP is in whitelist or token matches
- cfg, err := h.svc.Get()
- if err != nil && err != services.ErrSecurityConfigNotFound {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve security config"})
- return
- }
- if cfg != nil {
- // Check admin whitelist
- if cfg.AdminWhitelist == "" && body.Token == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "admin whitelist missing; provide break_glass_token or add admin_whitelist CIDR before enabling"})
- return
- }
- if body.Token != "" {
- ok, err := h.svc.VerifyBreakGlassToken(cfg.Name, body.Token)
- if err == nil && ok {
- // proceed
- } else {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token invalid"})
- return
- }
- } else {
- // verify client IP in admin whitelist
- found := false
- for _, entry := range strings.Split(cfg.AdminWhitelist, ",") {
- entry = strings.TrimSpace(entry)
- if entry == "" {
- continue
- }
- if entry == adminIP {
- found = true
- break
- }
- // If CIDR, check contains
- if _, cidr, err := net.ParseCIDR(entry); err == nil {
- if cidr.Contains(net.ParseIP(adminIP)) {
- found = true
- break
- }
- }
- }
- if !found {
- c.JSON(http.StatusForbidden, gin.H{"error": "admin IP not present in admin_whitelist"})
- return
- }
- }
- }
- // Set enabled true
- newCfg := &models.SecurityConfig{Name: "default", Enabled: true}
- if cfg != nil {
- newCfg = cfg
- newCfg.Enabled = true
- }
- if err := h.svc.Upsert(newCfg); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enable Cerberus"})
- return
- }
- if h.caddyManager != nil {
- if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
- return
- }
- }
- c.JSON(http.StatusOK, gin.H{"enabled": true})
-}
-
-// Disable toggles Cerberus off; requires break-glass token or localhost request
-func (h *SecurityHandler) Disable(c *gin.Context) {
- var body struct {
- Token string `json:"break_glass_token"`
- }
- _ = c.ShouldBindJSON(&body)
- // Allow requests from localhost to disable without token
- clientIP := c.ClientIP()
- if clientIP == "127.0.0.1" || clientIP == "::1" {
- cfg, _ := h.svc.Get()
- if cfg == nil {
- cfg = &models.SecurityConfig{Name: "default", Enabled: false}
- } else {
- cfg.Enabled = false
- }
- _ = h.svc.Upsert(cfg)
- if h.caddyManager != nil {
- _ = h.caddyManager.ApplyConfig(c.Request.Context())
- }
- c.JSON(http.StatusOK, gin.H{"enabled": false})
- return
- }
- cfg, err := h.svc.Get()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read config"})
- return
- }
- if body.Token == "" {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token required to disable Cerberus from non-localhost"})
- return
- }
- ok, err := h.svc.VerifyBreakGlassToken(cfg.Name, body.Token)
- if err != nil || !ok {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "break glass token invalid"})
- return
- }
- cfg.Enabled = false
- _ = h.svc.Upsert(cfg)
- if h.caddyManager != nil {
- _ = h.caddyManager.ApplyConfig(c.Request.Context())
- }
- c.JSON(http.StatusOK, gin.H{"enabled": false})
-}
-
-
-
package handlers
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
-
- "github.com/Wikid82/charon/backend/internal/config"
-)
-
-func TestSecurityHandler_GetStatus_Fixed(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- tests := []struct {
- name string
- cfg config.SecurityConfig
- expectedStatus int
- expectedBody map[string]interface{}
- }{
- {
- name: "All Disabled",
- cfg: config.SecurityConfig{
- CrowdSecMode: "disabled",
- WAFMode: "disabled",
- RateLimitMode: "disabled",
- ACLMode: "disabled",
- },
- expectedStatus: http.StatusOK,
- expectedBody: map[string]interface{}{
- "cerberus": map[string]interface{}{"enabled": false},
- "crowdsec": map[string]interface{}{
- "mode": "disabled",
- "api_url": "",
- "enabled": false,
- },
- "waf": map[string]interface{}{
- "mode": "disabled",
- "enabled": false,
- },
- "rate_limit": map[string]interface{}{
- "mode": "disabled",
- "enabled": false,
- },
- "acl": map[string]interface{}{
- "mode": "disabled",
- "enabled": false,
- },
- },
- },
- {
- name: "All Enabled",
- cfg: config.SecurityConfig{
- CrowdSecMode: "local",
- WAFMode: "enabled",
- RateLimitMode: "enabled",
- ACLMode: "enabled",
- },
- expectedStatus: http.StatusOK,
- expectedBody: map[string]interface{}{
- "cerberus": map[string]interface{}{"enabled": true},
- "crowdsec": map[string]interface{}{
- "mode": "local",
- "api_url": "",
- "enabled": true,
- },
- "waf": map[string]interface{}{
- "mode": "enabled",
- "enabled": true,
- },
- "rate_limit": map[string]interface{}{
- "mode": "enabled",
- "enabled": true,
- },
- "acl": map[string]interface{}{
- "mode": "enabled",
- "enabled": true,
- },
- },
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- handler := NewSecurityHandler(tt.cfg, nil, nil)
- router := gin.New()
- router.GET("/security/status", handler.GetStatus)
-
- w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/security/status", nil)
- router.ServeHTTP(w, req)
-
- assert.Equal(t, tt.expectedStatus, w.Code)
-
- var response map[string]interface{}
- err := json.Unmarshal(w.Body.Bytes(), &response)
- assert.NoError(t, err)
-
- expectedJSON, _ := json.Marshal(tt.expectedBody)
- var expectedNormalized map[string]interface{}
- if err := json.Unmarshal(expectedJSON, &expectedNormalized); err != nil {
- t.Fatalf("failed to unmarshal expected JSON: %v", err)
- }
-
- assert.Equal(t, expectedNormalized, response)
- })
- }
-}
-
-
-
package handlers
-
-import (
- "net/http"
-
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/models"
-)
-
-type SettingsHandler struct {
- DB *gorm.DB
-}
-
-func NewSettingsHandler(db *gorm.DB) *SettingsHandler {
- return &SettingsHandler{DB: db}
-}
-
-// GetSettings returns all settings.
-func (h *SettingsHandler) GetSettings(c *gin.Context) {
- var settings []models.Setting
- if err := h.DB.Find(&settings).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch settings"})
- return
- }
-
- // Convert to map for easier frontend consumption
- settingsMap := make(map[string]string)
- for _, s := range settings {
- settingsMap[s.Key] = s.Value
- }
-
- c.JSON(http.StatusOK, settingsMap)
-}
-
-type UpdateSettingRequest struct {
- Key string `json:"key" binding:"required"`
- Value string `json:"value" binding:"required"`
- Category string `json:"category"`
- Type string `json:"type"`
-}
-
-// UpdateSetting updates or creates a setting.
-func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
- var req UpdateSettingRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- setting := models.Setting{
- Key: req.Key,
- Value: req.Value,
- }
-
- if req.Category != "" {
- setting.Category = req.Category
- }
- if req.Type != "" {
- setting.Type = req.Type
- }
-
- // Upsert
- if err := h.DB.Where(models.Setting{Key: req.Key}).Assign(setting).FirstOrCreate(&setting).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save setting"})
- return
- }
-
- c.JSON(http.StatusOK, setting)
-}
-
-
-
package handlers
-
-import (
- "net/http"
- "strings"
-
- "github.com/gin-gonic/gin"
-)
-
-type SystemHandler struct{}
-
-func NewSystemHandler() *SystemHandler {
- return &SystemHandler{}
-}
-
-type MyIPResponse struct {
- IP string `json:"ip"`
- Source string `json:"source"`
-}
-
-// GetMyIP returns the client's public IP address
-func (h *SystemHandler) GetMyIP(c *gin.Context) {
- // Try to get the real IP from various headers (in order of preference)
- // This handles proxies, load balancers, and CDNs
- ip := getClientIP(c.Request)
-
- source := "direct"
- if c.GetHeader("X-Forwarded-For") != "" {
- source = "X-Forwarded-For"
- } else if c.GetHeader("X-Real-IP") != "" {
- source = "X-Real-IP"
- } else if c.GetHeader("CF-Connecting-IP") != "" {
- source = "Cloudflare"
- }
-
- c.JSON(http.StatusOK, MyIPResponse{
- IP: ip,
- Source: source,
- })
-}
-
-// getClientIP extracts the real client IP from the request
-// Checks headers in order of trust/reliability
-func getClientIP(r *http.Request) string {
- // Cloudflare
- if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
- return ip
- }
-
- // Other CDNs/proxies
- if ip := r.Header.Get("X-Real-IP"); ip != "" {
- return ip
- }
-
- // Standard proxy header (can be a comma-separated list)
- if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
- // Take the first IP in the list (client IP)
- ips := strings.Split(forwarded, ",")
- if len(ips) > 0 {
- return strings.TrimSpace(ips[0])
- }
- }
-
- // Fallback to RemoteAddr (format: "IP:port")
- if ip := r.RemoteAddr; ip != "" {
- // Remove port if present
- if idx := strings.LastIndex(ip, ":"); idx != -1 {
- return ip[:idx]
- }
- return ip
- }
-
- return "unknown"
-}
-
-
-
package handlers
-
-import (
- "fmt"
- "math/rand"
- "strings"
- "testing"
- "time"
-
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
-)
-
-// openTestDB creates a SQLite in-memory DB unique per test and applies
-// a busy timeout and WAL journal mode to reduce SQLITE locking during parallel tests.
-func OpenTestDB(t *testing.T) *gorm.DB {
- t.Helper()
- // Append a timestamp/random suffix to ensure uniqueness even across parallel runs
- dsnName := strings.ReplaceAll(t.Name(), "/", "_")
- rand.Seed(time.Now().UnixNano())
- uniqueSuffix := fmt.Sprintf("%d%d", time.Now().UnixNano(), rand.Intn(10000))
- dsn := fmt.Sprintf("file:%s_%s?mode=memory&cache=shared&_journal_mode=WAL&_busy_timeout=5000", dsnName, uniqueSuffix)
- db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open test db: %v", err)
- }
- return db
-}
-
-
-
package handlers
-
-import (
- "net/http"
-
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
-)
-
-type UpdateHandler struct {
- service *services.UpdateService
-}
-
-func NewUpdateHandler(service *services.UpdateService) *UpdateHandler {
- return &UpdateHandler{service: service}
-}
-
-func (h *UpdateHandler) Check(c *gin.Context) {
- info, err := h.service.CheckForUpdates()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check for updates"})
- return
- }
- c.JSON(http.StatusOK, info)
-}
-
-
-
package handlers
-
-import (
- "net/http"
- "strconv"
-
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
-)
-
-type UptimeHandler struct {
- service *services.UptimeService
-}
-
-func NewUptimeHandler(service *services.UptimeService) *UptimeHandler {
- return &UptimeHandler{service: service}
-}
-
-func (h *UptimeHandler) List(c *gin.Context) {
- monitors, err := h.service.ListMonitors()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list monitors"})
- return
- }
- c.JSON(http.StatusOK, monitors)
-}
-
-func (h *UptimeHandler) GetHistory(c *gin.Context) {
- id := c.Param("id")
- limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
-
- history, err := h.service.GetMonitorHistory(id, limit)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get history"})
- return
- }
- c.JSON(http.StatusOK, history)
-}
-
-func (h *UptimeHandler) Update(c *gin.Context) {
- id := c.Param("id")
- var updates map[string]interface{}
- if err := c.ShouldBindJSON(&updates); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- monitor, err := h.service.UpdateMonitor(id, updates)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, monitor)
-}
-
-func (h *UptimeHandler) Sync(c *gin.Context) {
- if err := h.service.SyncMonitors(); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to sync monitors"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "Sync started"})
-}
-
-// Delete removes a monitor and its associated data
-func (h *UptimeHandler) Delete(c *gin.Context) {
- id := c.Param("id")
- if err := h.service.DeleteMonitor(id); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete monitor"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "Monitor deleted"})
-}
-
-// CheckMonitor triggers an immediate check for a specific monitor
-func (h *UptimeHandler) CheckMonitor(c *gin.Context) {
- id := c.Param("id")
- monitor, err := h.service.GetMonitorByID(id)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "Monitor not found"})
- return
- }
-
- // Trigger immediate check in background
- go h.service.CheckMonitor(*monitor)
-
- c.JSON(http.StatusOK, gin.H{"message": "Check triggered"})
-}
-
-
-
package handlers
-
-import (
- "net/http"
- "strings"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/models"
-)
-
-type UserHandler struct {
- DB *gorm.DB
-}
-
-func NewUserHandler(db *gorm.DB) *UserHandler {
- return &UserHandler{DB: db}
-}
-
-func (h *UserHandler) RegisterRoutes(r *gin.RouterGroup) {
- r.GET("/setup", h.GetSetupStatus)
- r.POST("/setup", h.Setup)
- r.GET("/profile", h.GetProfile)
- r.POST("/regenerate-api-key", h.RegenerateAPIKey)
- r.PUT("/profile", h.UpdateProfile)
-}
-
-// GetSetupStatus checks if the application needs initial setup (i.e., no users exist).
-func (h *UserHandler) GetSetupStatus(c *gin.Context) {
- var count int64
- if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "setupRequired": count == 0,
- })
-}
-
-type SetupRequest struct {
- Name string `json:"name" binding:"required"`
- Email string `json:"email" binding:"required,email"`
- Password string `json:"password" binding:"required,min=8"`
-}
-
-// Setup creates the initial admin user and configures the ACME email.
-func (h *UserHandler) Setup(c *gin.Context) {
- // 1. Check if setup is allowed
- var count int64
- if err := h.DB.Model(&models.User{}).Count(&count).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check setup status"})
- return
- }
-
- if count > 0 {
- c.JSON(http.StatusForbidden, gin.H{"error": "Setup already completed"})
- return
- }
-
- // 2. Parse request
- var req SetupRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // 3. Create User
- user := models.User{
- UUID: uuid.New().String(),
- Name: req.Name,
- Email: strings.ToLower(req.Email),
- Role: "admin",
- Enabled: true,
- APIKey: uuid.New().String(),
- }
-
- if err := user.SetPassword(req.Password); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"})
- return
- }
-
- // 4. Create Setting for ACME Email
- acmeEmailSetting := models.Setting{
- Key: "caddy.acme_email",
- Value: req.Email,
- Type: "string",
- Category: "caddy",
- }
-
- // Transaction to ensure both succeed
- err := h.DB.Transaction(func(tx *gorm.DB) error {
- if err := tx.Create(&user).Error; err != nil {
- return err
- }
- // Use Save to update if exists (though it shouldn't in fresh setup) or create
- if err := tx.Where(models.Setting{Key: "caddy.acme_email"}).Assign(models.Setting{Value: req.Email}).FirstOrCreate(&acmeEmailSetting).Error; err != nil {
- return err
- }
- return nil
- })
-
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to complete setup: " + err.Error()})
- return
- }
-
- c.JSON(http.StatusCreated, gin.H{
- "message": "Setup completed successfully",
- "user": gin.H{
- "id": user.ID,
- "email": user.Email,
- "name": user.Name,
- },
- })
-}
-
-// RegenerateAPIKey generates a new API key for the authenticated user.
-func (h *UserHandler) RegenerateAPIKey(c *gin.Context) {
- userID, exists := c.Get("userID")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
- return
- }
-
- apiKey := uuid.New().String()
-
- if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Update("api_key", apiKey).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update API key"})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"api_key": apiKey})
-}
-
-// GetProfile returns the current user's profile including API key.
-func (h *UserHandler) GetProfile(c *gin.Context) {
- userID, exists := c.Get("userID")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
- return
- }
-
- var user models.User
- if err := h.DB.First(&user, userID).Error; err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "id": user.ID,
- "email": user.Email,
- "name": user.Name,
- "role": user.Role,
- "api_key": user.APIKey,
- })
-}
-
-type UpdateProfileRequest struct {
- Name string `json:"name" binding:"required"`
- Email string `json:"email" binding:"required,email"`
- CurrentPassword string `json:"current_password"`
-}
-
-// UpdateProfile updates the authenticated user's profile.
-func (h *UserHandler) UpdateProfile(c *gin.Context) {
- userID, exists := c.Get("userID")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
- return
- }
-
- var req UpdateProfileRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // Get current user
- var user models.User
- if err := h.DB.First(&user, userID).Error; err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
- return
- }
-
- // Check if email is already taken by another user
- req.Email = strings.ToLower(req.Email)
- var count int64
- if err := h.DB.Model(&models.User{}).Where("email = ? AND id != ?", req.Email, userID).Count(&count).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check email availability"})
- return
- }
-
- if count > 0 {
- c.JSON(http.StatusConflict, gin.H{"error": "Email already in use"})
- return
- }
-
- // If email is changing, verify password
- if req.Email != user.Email {
- if req.CurrentPassword == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Current password is required to change email"})
- return
- }
- if !user.CheckPassword(req.CurrentPassword) {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid password"})
- return
- }
- }
-
- if err := h.DB.Model(&models.User{}).Where("id = ?", userID).Updates(map[string]interface{}{
- "name": req.Name,
- "email": req.Email,
- }).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update profile"})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"message": "Profile updated successfully"})
-}
-
-
-
-
-
-
diff --git a/backend/importer.html b/backend/importer.html
deleted file mode 100644
index 01152c8d..00000000
--- a/backend/importer.html
+++ /dev/null
@@ -1,1648 +0,0 @@
-
-
-
-
-
-
-
package caddy
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "fmt"
- "io"
- "net/http"
- "time"
-)
-
-// Client wraps the Caddy admin API.
-type Client struct {
- baseURL string
- httpClient *http.Client
-}
-
-// NewClient creates a Caddy API client.
-func NewClient(adminAPIURL string) *Client {
- return &Client{
- baseURL: adminAPIURL,
- httpClient: &http.Client{
- Timeout: 30 * time.Second,
- },
- }
-}
-
-// Load atomically replaces Caddy's entire configuration.
-// This is the primary method for applying configuration changes.
-func (c *Client) Load(ctx context.Context, config *Config) error {
- body, err := json.Marshal(config)
- if err != nil {
- return fmt.Errorf("marshal config: %w", err)
- }
-
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/load", bytes.NewReader(body))
- if err != nil {
- return fmt.Errorf("create request: %w", err)
- }
- req.Header.Set("Content-Type", "application/json")
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return fmt.Errorf("execute request: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- bodyBytes, _ := io.ReadAll(resp.Body)
- return fmt.Errorf("caddy returned status %d: %s", resp.StatusCode, string(bodyBytes))
- }
-
- return nil
-}
-
-// GetConfig retrieves the current running configuration from Caddy.
-func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", nil)
- if err != nil {
- return nil, fmt.Errorf("create request: %w", err)
- }
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return nil, fmt.Errorf("execute request: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- bodyBytes, _ := io.ReadAll(resp.Body)
- return nil, fmt.Errorf("caddy returned status %d: %s", resp.StatusCode, string(bodyBytes))
- }
-
- var config Config
- if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
- return nil, fmt.Errorf("decode response: %w", err)
- }
-
- return &config, nil
-}
-
-// Ping checks if Caddy admin API is reachable.
-func (c *Client) Ping(ctx context.Context) error {
- req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", nil)
- if err != nil {
- return fmt.Errorf("create request: %w", err)
- }
-
- resp, err := c.httpClient.Do(req)
- if err != nil {
- return fmt.Errorf("caddy unreachable: %w", err)
- }
- defer resp.Body.Close()
-
- if resp.StatusCode != http.StatusOK {
- return fmt.Errorf("caddy returned status %d", resp.StatusCode)
- }
-
- return nil
-}
-
-
-
package caddy
-
-import (
- "encoding/json"
- "fmt"
- "path/filepath"
- "strings"
-
- "github.com/Wikid82/charon/backend/internal/models"
-)
-
-// 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 string, acmeEmail string, frontendDir string, sslProvider string, acmeStaging bool) (*Config, error) {
- // Define log file paths
- // We assume storageDir is like ".../data/caddy/data", so we go up to ".../data/logs"
- // storageDir is .../data/caddy/data
- // Dir -> .../data/caddy
- // Dir -> .../data
- logDir := filepath.Join(filepath.Dir(filepath.Dir(storageDir)), "logs")
- logFile := filepath.Join(logDir, "access.log")
-
- config := &Config{
- Logging: &LoggingConfig{
- Logs: map[string]*LogConfig{
- "access": {
- Level: "INFO",
- Writer: &WriterConfig{
- Output: "file",
- Filename: logFile,
- Roll: true,
- RollSize: 10, // 10 MB
- RollKeep: 5, // Keep 5 files
- RollKeepDays: 7, // Keep for 7 days
- },
- Encoder: &EncoderConfig{
- Format: "json",
- },
- Include: []string{"http.log.access.access_log"},
- },
- },
- },
- Apps: Apps{
- HTTP: &HTTPApp{
- Servers: map[string]*Server{},
- },
- },
- Storage: Storage{
- System: "file_system",
- Root: storageDir,
- },
- }
-
- if acmeEmail != "" {
- var issuers []interface{}
-
- // Configure issuers based on provider preference
- switch sslProvider {
- case "letsencrypt":
- acmeIssuer := map[string]interface{}{
- "module": "acme",
- "email": acmeEmail,
- }
- if acmeStaging {
- acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
- }
- issuers = append(issuers, acmeIssuer)
- case "zerossl":
- issuers = append(issuers, map[string]interface{}{
- "module": "zerossl",
- })
- default: // "both" or empty
- acmeIssuer := map[string]interface{}{
- "module": "acme",
- "email": acmeEmail,
- }
- if acmeStaging {
- acmeIssuer["ca"] = "https://acme-staging-v02.api.letsencrypt.org/directory"
- }
- issuers = append(issuers, acmeIssuer)
- issuers = append(issuers, map[string]interface{}{
- "module": "zerossl",
- })
- }
-
- config.Apps.TLS = &TLSApp{
- Automation: &AutomationConfig{
- Policies: []*AutomationPolicy{
- {
- IssuersRaw: issuers,
- },
- },
- },
- }
- }
-
- // Collect CUSTOM certificates only (not Let's Encrypt - those are managed by ACME)
- // Only custom/uploaded certificates should be loaded via LoadPEM
- customCerts := make(map[uint]models.SSLCertificate)
- for _, host := range hosts {
- if host.CertificateID != nil && host.Certificate != nil {
- // Only include custom certificates, not ACME-managed ones
- if host.Certificate.Provider == "custom" {
- customCerts[*host.CertificateID] = *host.Certificate
- }
- }
- }
-
- if len(customCerts) > 0 {
- var loadPEM []LoadPEMConfig
- for _, cert := range customCerts {
- // Validate that custom cert has both certificate and key
- if cert.Certificate == "" || cert.PrivateKey == "" {
- fmt.Printf("Warning: Custom certificate %s missing certificate or key, skipping\n", cert.Name)
- continue
- }
- loadPEM = append(loadPEM, LoadPEMConfig{
- Certificate: cert.Certificate,
- Key: cert.PrivateKey,
- Tags: []string{cert.UUID},
- })
- }
-
- if len(loadPEM) > 0 {
- if config.Apps.TLS == nil {
- config.Apps.TLS = &TLSApp{}
- }
- config.Apps.TLS.Certificates = &CertificatesConfig{
- LoadPEM: loadPEM,
- }
- }
- }
-
- if len(hosts) == 0 && frontendDir == "" {
- return config, nil
- }
-
- // Initialize routes slice
- routes := make([]*Route, 0)
-
- // Track processed domains to prevent duplicates (Ghost Host fix)
- processedDomains := make(map[string]bool)
-
- // Sort hosts by UpdatedAt desc to prefer newer configs in case of duplicates
- // Note: This assumes the input slice is already sorted or we don't care about order beyond duplicates
- // The caller (ApplyConfig) fetches all hosts. We should probably sort them here or there.
- // For now, we'll just process them. If we encounter a duplicate domain, we skip it.
- // To ensure we keep the *latest* one, we should iterate in reverse or sort.
- // But ApplyConfig uses db.Find(&hosts), which usually returns by ID asc.
- // So later IDs (newer) come last.
- // We want to keep the NEWER one.
- // So we should iterate backwards? Or just overwrite?
- // Caddy config structure is a list of servers/routes.
- // If we have multiple routes matching the same host, Caddy uses the first one?
- // Actually, Caddy matches routes in order.
- // If we emit two routes for "example.com", the first one will catch it.
- // So we want the NEWEST one to be FIRST in the list?
- // Or we want to only emit ONE route for "example.com".
- // If we emit only one, it should be the newest one.
- // So we should process hosts from newest to oldest, and skip duplicates.
-
- // Let's iterate in reverse order (assuming input is ID ASC)
- for i := len(hosts) - 1; i >= 0; i-- {
- host := hosts[i]
-
- if !host.Enabled {
- continue
- }
-
- if host.DomainNames == "" {
- // Log warning?
- continue
- }
-
- // Parse comma-separated domains
- rawDomains := strings.Split(host.DomainNames, ",")
- var uniqueDomains []string
-
- for _, d := range rawDomains {
- d = strings.TrimSpace(d)
- d = strings.ToLower(d) // Normalize to lowercase
- if d == "" {
- continue
- }
- if processedDomains[d] {
- fmt.Printf("Warning: Skipping duplicate domain %s for host %s (Ghost Host detection)\n", d, host.UUID)
- continue
- }
- processedDomains[d] = true
- uniqueDomains = append(uniqueDomains, d)
- }
-
- if len(uniqueDomains) == 0 {
- continue
- }
-
- // Build handlers for this host
- handlers := make([]Handler, 0)
-
- // Add Access Control List (ACL) handler if configured
- if host.AccessListID != nil && host.AccessList != nil && host.AccessList.Enabled {
- aclHandler, err := buildACLHandler(host.AccessList)
- if err != nil {
- fmt.Printf("Warning: Failed to build ACL handler for host %s: %v\n", host.UUID, err)
- } else if aclHandler != nil {
- handlers = append(handlers, aclHandler)
- }
- }
-
- // Add HSTS header if enabled
- if host.HSTSEnabled {
- hstsValue := "max-age=31536000"
- if host.HSTSSubdomains {
- hstsValue += "; includeSubDomains"
- }
- handlers = append(handlers, HeaderHandler(map[string][]string{
- "Strict-Transport-Security": {hstsValue},
- }))
- }
-
- // Add exploit blocking if enabled
- if host.BlockExploits {
- handlers = append(handlers, BlockExploitsHandler())
- }
-
- // Handle custom locations first (more specific routes)
- for _, loc := range host.Locations {
- dial := fmt.Sprintf("%s:%d", loc.ForwardHost, loc.ForwardPort)
- locRoute := &Route{
- Match: []Match{
- {
- Host: uniqueDomains,
- Path: []string{loc.Path, loc.Path + "/*"},
- },
- },
- Handle: []Handler{
- ReverseProxyHandler(dial, host.WebsocketSupport, host.Application),
- },
- Terminal: true,
- }
- routes = append(routes, locRoute)
- }
-
- // Main proxy handler
- dial := fmt.Sprintf("%s:%d", host.ForwardHost, host.ForwardPort)
- // Insert user advanced config (if present) as headers or handlers before the reverse proxy
- // so user-specified headers/handlers are applied prior to proxying.
- if host.AdvancedConfig != "" {
- var parsed interface{}
- if err := json.Unmarshal([]byte(host.AdvancedConfig), &parsed); err != nil {
- fmt.Printf("Warning: Failed to parse advanced_config for host %s: %v\n", host.UUID, err)
- } else {
- switch v := parsed.(type) {
- case map[string]interface{}:
- // Append as a handler
- // Ensure it has a "handler" key
- if _, ok := v["handler"]; ok {
- handlers = append(handlers, Handler(v))
- } else {
- fmt.Printf("Warning: advanced_config for host %s is not a handler object\n", host.UUID)
- }
- case []interface{}:
- for _, it := range v {
- if m, ok := it.(map[string]interface{}); ok {
- if _, ok2 := m["handler"]; ok2 {
- handlers = append(handlers, Handler(m))
- }
- }
- }
- default:
- fmt.Printf("Warning: advanced_config for host %s has unexpected JSON structure\n", host.UUID)
- }
- }
- }
- mainHandlers := append(handlers, ReverseProxyHandler(dial, host.WebsocketSupport, host.Application))
-
- route := &Route{
- Match: []Match{
- {Host: uniqueDomains},
- },
- Handle: mainHandlers,
- Terminal: true,
- }
-
- routes = append(routes, route)
- }
-
- // Add catch-all 404 handler
- // This matches any request that wasn't handled by previous routes
- if frontendDir != "" {
- catchAllRoute := &Route{
- Handle: []Handler{
- RewriteHandler("/unknown.html"),
- FileServerHandler(frontendDir),
- },
- Terminal: true,
- }
- routes = append(routes, catchAllRoute)
- }
-
- config.Apps.HTTP.Servers["charon_server"] = &Server{
- Listen: []string{":80", ":443"},
- Routes: routes,
- AutoHTTPS: &AutoHTTPSConfig{
- Disable: false,
- DisableRedir: false,
- },
- Logs: &ServerLogs{
- DefaultLoggerName: "access_log",
- },
- }
-
- return config, nil
-}
-
-// buildACLHandler creates access control handlers based on the AccessList configuration
-func buildACLHandler(acl *models.AccessList) (Handler, error) {
- // For geo-blocking, we use CEL (Common Expression Language) matcher with caddy-geoip2 placeholders
- // For IP-based ACLs, we use Caddy's native remote_ip matcher
-
- if strings.HasPrefix(acl.Type, "geo_") {
- // Geo-blocking using caddy-geoip2
- countryCodes := strings.Split(acl.CountryCodes, ",")
- var trimmedCodes []string
- for _, code := range countryCodes {
- trimmedCodes = append(trimmedCodes, `"`+strings.TrimSpace(code)+`"`)
- }
-
- var expression string
- if acl.Type == "geo_whitelist" {
- // Allow only these countries
- expression = fmt.Sprintf("{geoip2.country_code} in [%s]", strings.Join(trimmedCodes, ", "))
- } else {
- // geo_blacklist: Block these countries
- expression = fmt.Sprintf("{geoip2.country_code} not_in [%s]", strings.Join(trimmedCodes, ", "))
- }
-
- return Handler{
- "handler": "subroute",
- "routes": []map[string]interface{}{
- {
- "match": []map[string]interface{}{
- {
- "not": []map[string]interface{}{
- {
- "expression": expression,
- },
- },
- },
- },
- "handle": []map[string]interface{}{
- {
- "handler": "static_response",
- "status_code": 403,
- "body": "Access denied: Geographic restriction",
- },
- },
- "terminal": true,
- },
- },
- }, nil
- }
-
- // IP/CIDR-based ACLs using Caddy's native remote_ip matcher
- if acl.LocalNetworkOnly {
- // Allow only RFC1918 private networks
- return Handler{
- "handler": "subroute",
- "routes": []map[string]interface{}{
- {
- "match": []map[string]interface{}{
- {
- "not": []map[string]interface{}{
- {
- "remote_ip": map[string]interface{}{
- "ranges": []string{
- "10.0.0.0/8",
- "172.16.0.0/12",
- "192.168.0.0/16",
- "127.0.0.0/8",
- "169.254.0.0/16",
- "fc00::/7",
- "fe80::/10",
- "::1/128",
- },
- },
- },
- },
- },
- },
- "handle": []map[string]interface{}{
- {
- "handler": "static_response",
- "status_code": 403,
- "body": "Access denied: Not a local network IP",
- },
- },
- "terminal": true,
- },
- },
- }, nil
- }
-
- // Parse IP rules
- if acl.IPRules == "" {
- return nil, nil
- }
-
- var rules []models.AccessListRule
- if err := json.Unmarshal([]byte(acl.IPRules), &rules); err != nil {
- return nil, fmt.Errorf("invalid IP rules JSON: %w", err)
- }
-
- if len(rules) == 0 {
- return nil, nil
- }
-
- // Extract CIDR ranges
- var cidrs []string
- for _, rule := range rules {
- cidrs = append(cidrs, rule.CIDR)
- }
-
- if acl.Type == "whitelist" {
- // Allow only these IPs (block everything else)
- return Handler{
- "handler": "subroute",
- "routes": []map[string]interface{}{
- {
- "match": []map[string]interface{}{
- {
- "not": []map[string]interface{}{
- {
- "remote_ip": map[string]interface{}{
- "ranges": cidrs,
- },
- },
- },
- },
- },
- "handle": []map[string]interface{}{
- {
- "handler": "static_response",
- "status_code": 403,
- "body": "Access denied: IP not in whitelist",
- },
- },
- "terminal": true,
- },
- },
- }, nil
- }
-
- if acl.Type == "blacklist" {
- // Block these IPs (allow everything else)
- return Handler{
- "handler": "subroute",
- "routes": []map[string]interface{}{
- {
- "match": []map[string]interface{}{
- {
- "remote_ip": map[string]interface{}{
- "ranges": cidrs,
- },
- },
- },
- "handle": []map[string]interface{}{
- {
- "handler": "static_response",
- "status_code": 403,
- "body": "Access denied: IP blacklisted",
- },
- },
- "terminal": true,
- },
- },
- }, nil
- }
-
- return nil, nil
-}
-
-
-
package caddy
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "net"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
-
- "github.com/Wikid82/charon/backend/internal/models"
-)
-
-// Executor defines an interface for executing shell commands.
-type Executor interface {
- Execute(name string, args ...string) ([]byte, error)
-}
-
-// DefaultExecutor implements Executor using os/exec.
-type DefaultExecutor struct{}
-
-func (e *DefaultExecutor) Execute(name string, args ...string) ([]byte, error) {
- return exec.Command(name, args...).Output()
-}
-
-// CaddyConfig represents the root structure of Caddy's JSON config.
-type CaddyConfig struct {
- Apps *CaddyApps `json:"apps,omitempty"`
-}
-
-// CaddyApps contains application-specific configurations.
-type CaddyApps struct {
- HTTP *CaddyHTTP `json:"http,omitempty"`
-}
-
-// CaddyHTTP represents the HTTP app configuration.
-type CaddyHTTP struct {
- Servers map[string]*CaddyServer `json:"servers,omitempty"`
-}
-
-// CaddyServer represents a single server configuration.
-type CaddyServer struct {
- Listen []string `json:"listen,omitempty"`
- Routes []*CaddyRoute `json:"routes,omitempty"`
- TLSConnectionPolicies interface{} `json:"tls_connection_policies,omitempty"`
-}
-
-// CaddyRoute represents a single route with matchers and handlers.
-type CaddyRoute struct {
- Match []*CaddyMatcher `json:"match,omitempty"`
- Handle []*CaddyHandler `json:"handle,omitempty"`
-}
-
-// CaddyMatcher represents route matching criteria.
-type CaddyMatcher struct {
- Host []string `json:"host,omitempty"`
-}
-
-// CaddyHandler represents a handler in the route.
-type CaddyHandler struct {
- Handler string `json:"handler"`
- Upstreams interface{} `json:"upstreams,omitempty"`
- Headers interface{} `json:"headers,omitempty"`
- Routes interface{} `json:"routes,omitempty"` // For subroute handlers
-}
-
-// ParsedHost represents a single host detected during Caddyfile import.
-type ParsedHost struct {
- DomainNames string `json:"domain_names"`
- ForwardScheme string `json:"forward_scheme"`
- ForwardHost string `json:"forward_host"`
- ForwardPort int `json:"forward_port"`
- SSLForced bool `json:"ssl_forced"`
- WebsocketSupport bool `json:"websocket_support"`
- RawJSON string `json:"raw_json"` // Original Caddy JSON for this route
- Warnings []string `json:"warnings"` // Unsupported features
-}
-
-// ImportResult contains parsed hosts and detected conflicts.
-type ImportResult struct {
- Hosts []ParsedHost `json:"hosts"`
- Conflicts []string `json:"conflicts"`
- Errors []string `json:"errors"`
-}
-
-// Importer handles Caddyfile parsing and conversion to CPM+ models.
-type Importer struct {
- caddyBinaryPath string
- executor Executor
-}
-
-// NewImporter creates a new Caddyfile importer.
-func NewImporter(binaryPath string) *Importer {
- if binaryPath == "" {
- binaryPath = "caddy" // Default to PATH
- }
- return &Importer{
- caddyBinaryPath: binaryPath,
- executor: &DefaultExecutor{},
- }
-}
-
-// ParseCaddyfile reads a Caddyfile and converts it to Caddy JSON.
-func (i *Importer) ParseCaddyfile(caddyfilePath string) ([]byte, error) {
- if _, err := os.Stat(caddyfilePath); os.IsNotExist(err) {
- return nil, fmt.Errorf("caddyfile not found: %s", caddyfilePath)
- }
-
- output, err := i.executor.Execute(i.caddyBinaryPath, "adapt", "--config", caddyfilePath, "--adapter", "caddyfile")
- if err != nil {
- return nil, fmt.Errorf("caddy adapt failed: %w (output: %s)", err, string(output))
- }
-
- return output, nil
-}
-
-// extractHandlers recursively extracts handlers from a list, flattening subroutes.
-func (i *Importer) extractHandlers(handles []*CaddyHandler) []*CaddyHandler {
- var result []*CaddyHandler
-
- for _, handler := range handles {
- // If this is a subroute, extract handlers from its first route
- if handler.Handler == "subroute" {
- if routes, ok := handler.Routes.([]interface{}); ok && len(routes) > 0 {
- if subroute, ok := routes[0].(map[string]interface{}); ok {
- if subhandles, ok := subroute["handle"].([]interface{}); ok {
- // Convert the subhandles to CaddyHandler objects
- for _, sh := range subhandles {
- if shMap, ok := sh.(map[string]interface{}); ok {
- subHandler := &CaddyHandler{}
- if handlerType, ok := shMap["handler"].(string); ok {
- subHandler.Handler = handlerType
- }
- if upstreams, ok := shMap["upstreams"]; ok {
- subHandler.Upstreams = upstreams
- }
- if headers, ok := shMap["headers"]; ok {
- subHandler.Headers = headers
- }
- result = append(result, subHandler)
- }
- }
- }
- }
- }
- } else {
- // Regular handler, add it directly
- result = append(result, handler)
- }
- }
-
- return result
-}
-
-// ExtractHosts parses Caddy JSON and extracts proxy host information.
-func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) {
- var config CaddyConfig
- if err := json.Unmarshal(caddyJSON, &config); err != nil {
- return nil, fmt.Errorf("parsing caddy json: %w", err)
- }
-
- result := &ImportResult{
- Hosts: []ParsedHost{},
- Conflicts: []string{},
- Errors: []string{},
- }
-
- if config.Apps == nil || config.Apps.HTTP == nil || config.Apps.HTTP.Servers == nil {
- return result, nil // Empty config
- }
-
- seenDomains := make(map[string]bool)
-
- for serverName, server := range config.Apps.HTTP.Servers {
- // Detect if this server uses SSL based on listen address or TLS policies
- serverUsesSSL := server.TLSConnectionPolicies != nil
- for _, listenAddr := range server.Listen {
- // Check if listening on :443 or any HTTPS port indicator
- if strings.Contains(listenAddr, ":443") || strings.HasSuffix(listenAddr, "443") {
- serverUsesSSL = true
- break
- }
- }
-
- for routeIdx, route := range server.Routes {
- for _, match := range route.Match {
- for _, hostMatcher := range match.Host {
- domain := hostMatcher
-
- // Check for duplicate domains (report domain names only)
- if seenDomains[domain] {
- result.Conflicts = append(result.Conflicts, domain)
- continue
- }
- seenDomains[domain] = true
-
- // Extract reverse proxy handler
- host := ParsedHost{
- DomainNames: domain,
- SSLForced: strings.HasPrefix(domain, "https") || serverUsesSSL,
- }
-
- // Find reverse_proxy handler (may be nested in subroute)
- handlers := i.extractHandlers(route.Handle)
-
- for _, handler := range handlers {
- if handler.Handler == "reverse_proxy" {
- upstreams, _ := handler.Upstreams.([]interface{})
- if len(upstreams) > 0 {
- if upstream, ok := upstreams[0].(map[string]interface{}); ok {
- dial, _ := upstream["dial"].(string)
- if dial != "" {
- hostStr, portStr, err := net.SplitHostPort(dial)
- if err == nil {
- host.ForwardHost = hostStr
- if _, err := fmt.Sscanf(portStr, "%d", &host.ForwardPort); err != nil {
- host.ForwardPort = 80
- }
- } else {
- // Fallback: assume dial is just the host or has some other format
- // Try to handle simple "host:port" manually if net.SplitHostPort failed for some reason
- // or assume it's just a host
- parts := strings.Split(dial, ":")
- if len(parts) == 2 {
- host.ForwardHost = parts[0]
- if _, err := fmt.Sscanf(parts[1], "%d", &host.ForwardPort); err != nil {
- host.ForwardPort = 80
- }
- } else {
- host.ForwardHost = dial
- host.ForwardPort = 80
- }
- }
- }
- }
- }
-
- // Check for websocket support
- if headers, ok := handler.Headers.(map[string]interface{}); ok {
- if upgrade, ok := headers["Upgrade"].([]interface{}); ok {
- for _, v := range upgrade {
- if v == "websocket" {
- host.WebsocketSupport = true
- break
- }
- }
- }
- }
-
- // Default scheme
- host.ForwardScheme = "http"
- if host.SSLForced {
- host.ForwardScheme = "https"
- }
- }
-
- // Detect unsupported features
- if handler.Handler == "rewrite" {
- host.Warnings = append(host.Warnings, "Rewrite rules not supported - manual configuration required")
- }
- if handler.Handler == "file_server" {
- host.Warnings = append(host.Warnings, "File server directives not supported")
- }
- }
-
- // Store raw JSON for this route
- routeJSON, _ := json.Marshal(map[string]interface{}{
- "server": serverName,
- "route": routeIdx,
- "data": route,
- })
- host.RawJSON = string(routeJSON)
-
- result.Hosts = append(result.Hosts, host)
- }
- }
- }
- }
-
- return result, nil
-}
-
-// ImportFile performs complete import: parse Caddyfile and extract hosts.
-func (i *Importer) ImportFile(caddyfilePath string) (*ImportResult, error) {
- caddyJSON, err := i.ParseCaddyfile(caddyfilePath)
- if err != nil {
- return nil, err
- }
-
- return i.ExtractHosts(caddyJSON)
-}
-
-// ConvertToProxyHosts converts parsed hosts to ProxyHost models.
-func ConvertToProxyHosts(parsedHosts []ParsedHost) []models.ProxyHost {
- hosts := make([]models.ProxyHost, 0, len(parsedHosts))
-
- for _, parsed := range parsedHosts {
- if parsed.ForwardHost == "" || parsed.ForwardPort == 0 {
- continue // Skip invalid entries
- }
-
- hosts = append(hosts, models.ProxyHost{
- Name: parsed.DomainNames, // Can be customized by user during review
- DomainNames: parsed.DomainNames,
- ForwardScheme: parsed.ForwardScheme,
- ForwardHost: parsed.ForwardHost,
- ForwardPort: parsed.ForwardPort,
- SSLForced: parsed.SSLForced,
- WebsocketSupport: parsed.WebsocketSupport,
- })
- }
-
- return hosts
-}
-
-// ValidateCaddyBinary checks if the Caddy binary is available.
-func (i *Importer) ValidateCaddyBinary() error {
- _, err := i.executor.Execute(i.caddyBinaryPath, "version")
- if err != nil {
- return errors.New("caddy binary not found or not executable")
- }
- return nil
-}
-
-// BackupCaddyfile creates a timestamped backup of the original Caddyfile.
-func BackupCaddyfile(originalPath, backupDir string) (string, error) {
- if err := os.MkdirAll(backupDir, 0755); err != nil {
- return "", fmt.Errorf("creating backup directory: %w", err)
- }
-
- timestamp := fmt.Sprintf("%d", os.Getpid()) // Simple timestamp placeholder
- backupPath := filepath.Join(backupDir, fmt.Sprintf("Caddyfile.%s.backup", timestamp))
-
- input, err := os.ReadFile(originalPath)
- if err != nil {
- return "", fmt.Errorf("reading original file: %w", err)
- }
-
- if err := os.WriteFile(backupPath, input, 0644); err != nil {
- return "", fmt.Errorf("writing backup: %w", err)
- }
-
- return backupPath, nil
-}
-
-
-
package caddy
-
-import (
- "context"
- "crypto/sha256"
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "sort"
- "time"
-
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/models"
-)
-
-// Manager orchestrates Caddy configuration lifecycle: generate, validate, apply, rollback.
-type Manager struct {
- client *Client
- db *gorm.DB
- configDir string
- frontendDir string
- acmeStaging bool
-}
-
-// NewManager creates a configuration manager.
-func NewManager(client *Client, db *gorm.DB, configDir string, frontendDir string, acmeStaging bool) *Manager {
- return &Manager{
- client: client,
- db: db,
- configDir: configDir,
- frontendDir: frontendDir,
- acmeStaging: acmeStaging,
- }
-}
-
-// 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
- var hosts []models.ProxyHost
- if err := m.db.Preload("Locations").Preload("Certificate").Preload("AccessList").Find(&hosts).Error; err != nil {
- return fmt.Errorf("fetch proxy hosts: %w", err)
- }
-
- // Fetch ACME email setting
- var acmeEmailSetting models.Setting
- var acmeEmail string
- if err := m.db.Where("key = ?", "caddy.acme_email").First(&acmeEmailSetting).Error; err == nil {
- acmeEmail = acmeEmailSetting.Value
- }
-
- // Fetch SSL Provider setting
- var sslProviderSetting models.Setting
- var sslProvider string
- if err := m.db.Where("key = ?", "caddy.ssl_provider").First(&sslProviderSetting).Error; err == nil {
- sslProvider = sslProviderSetting.Value
- }
-
- // Generate Caddy config
- config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider, m.acmeStaging)
- if err != nil {
- return fmt.Errorf("generate config: %w", err)
- }
-
- // Validate before applying
- if err := Validate(config); err != nil {
- return fmt.Errorf("validation failed: %w", err)
- }
-
- // Save snapshot for rollback
- snapshotPath, err := m.saveSnapshot(config)
- if err != nil {
- return fmt.Errorf("save snapshot: %w", err)
- }
-
- // Calculate config hash for audit trail
- configJSON, _ := json.Marshal(config)
- configHash := fmt.Sprintf("%x", sha256.Sum256(configJSON))
-
- // Apply to Caddy
- if err := m.client.Load(ctx, config); err != nil {
- // Remove the failed snapshot so rollback uses the previous one
- _ = os.Remove(snapshotPath)
-
- // Rollback on failure
- if rollbackErr := m.rollback(ctx); rollbackErr != nil {
- // If rollback fails, we still want to record the failure
- m.recordConfigChange(configHash, false, err.Error())
- return fmt.Errorf("apply failed: %w, rollback also failed: %v", err, rollbackErr)
- }
-
- // Record failed attempt
- m.recordConfigChange(configHash, false, err.Error())
- return fmt.Errorf("apply failed (rolled back): %w", err)
- }
-
- // Record successful application
- m.recordConfigChange(configHash, true, "")
-
- // Cleanup old snapshots (keep last 10)
- if err := m.rotateSnapshots(10); err != nil {
- // Non-fatal - log but don't fail
- fmt.Printf("warning: snapshot rotation failed: %v\n", err)
- }
-
- return nil
-}
-
-// saveSnapshot stores the config to disk with timestamp.
-func (m *Manager) saveSnapshot(config *Config) (string, error) {
- timestamp := time.Now().Unix()
- filename := fmt.Sprintf("config-%d.json", timestamp)
- path := filepath.Join(m.configDir, filename)
-
- configJSON, err := json.MarshalIndent(config, "", " ")
- if err != nil {
- return "", fmt.Errorf("marshal config: %w", err)
- }
-
- if err := os.WriteFile(path, configJSON, 0644); err != nil {
- return "", fmt.Errorf("write snapshot: %w", err)
- }
-
- return path, nil
-}
-
-// rollback loads the most recent snapshot from disk.
-func (m *Manager) rollback(ctx context.Context) error {
- snapshots, err := m.listSnapshots()
- if err != nil || len(snapshots) == 0 {
- return fmt.Errorf("no snapshots available for rollback")
- }
-
- // Load most recent snapshot
- latestSnapshot := snapshots[len(snapshots)-1]
- configJSON, err := os.ReadFile(latestSnapshot)
- if err != nil {
- return fmt.Errorf("read snapshot: %w", err)
- }
-
- var config Config
- if err := json.Unmarshal(configJSON, &config); err != nil {
- return fmt.Errorf("unmarshal snapshot: %w", err)
- }
-
- // Apply the snapshot
- if err := m.client.Load(ctx, &config); err != nil {
- return fmt.Errorf("load snapshot: %w", err)
- }
-
- return nil
-}
-
-// listSnapshots returns all snapshot file paths sorted by modification time.
-func (m *Manager) listSnapshots() ([]string, error) {
- entries, err := os.ReadDir(m.configDir)
- if err != nil {
- return nil, fmt.Errorf("read config dir: %w", err)
- }
-
- var snapshots []string
- for _, entry := range entries {
- if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
- continue
- }
- snapshots = append(snapshots, filepath.Join(m.configDir, entry.Name()))
- }
-
- // Sort by modification time
- sort.Slice(snapshots, func(i, j int) bool {
- infoI, _ := os.Stat(snapshots[i])
- infoJ, _ := os.Stat(snapshots[j])
- return infoI.ModTime().Before(infoJ.ModTime())
- })
-
- return snapshots, nil
-}
-
-// rotateSnapshots keeps only the N most recent snapshots.
-func (m *Manager) rotateSnapshots(keep int) error {
- snapshots, err := m.listSnapshots()
- if err != nil {
- return err
- }
-
- if len(snapshots) <= keep {
- return nil
- }
-
- // Delete oldest snapshots
- toDelete := snapshots[:len(snapshots)-keep]
- for _, path := range toDelete {
- if err := os.Remove(path); err != nil {
- return fmt.Errorf("delete snapshot %s: %w", path, err)
- }
- }
-
- return nil
-}
-
-// recordConfigChange stores an audit record in the database.
-func (m *Manager) recordConfigChange(configHash string, success bool, errorMsg string) {
- record := models.CaddyConfig{
- ConfigHash: configHash,
- AppliedAt: time.Now(),
- Success: success,
- ErrorMsg: errorMsg,
- }
-
- // Best effort - don't fail if audit logging fails
- m.db.Create(&record)
-}
-
-// Ping checks if Caddy is reachable.
-func (m *Manager) Ping(ctx context.Context) error {
- return m.client.Ping(ctx)
-}
-
-// GetCurrentConfig retrieves the running config from Caddy.
-func (m *Manager) GetCurrentConfig(ctx context.Context) (*Config, error) {
- return m.client.GetConfig(ctx)
-}
-
-
-
package caddy
-
-// Config represents Caddy's top-level JSON configuration structure.
-// Reference: https://caddyserver.com/docs/json/
-type Config struct {
- Apps Apps `json:"apps"`
- Logging *LoggingConfig `json:"logging,omitempty"`
- Storage Storage `json:"storage,omitempty"`
-}
-
-// LoggingConfig configures Caddy's logging facility.
-type LoggingConfig struct {
- Logs map[string]*LogConfig `json:"logs,omitempty"`
- Sinks *SinkConfig `json:"sinks,omitempty"`
-}
-
-// LogConfig configures a specific logger.
-type LogConfig struct {
- Writer *WriterConfig `json:"writer,omitempty"`
- Encoder *EncoderConfig `json:"encoder,omitempty"`
- Level string `json:"level,omitempty"`
- Include []string `json:"include,omitempty"`
- Exclude []string `json:"exclude,omitempty"`
-}
-
-// WriterConfig configures the log writer (output).
-type WriterConfig struct {
- Output string `json:"output"`
- Filename string `json:"filename,omitempty"`
- Roll bool `json:"roll,omitempty"`
- RollSize int `json:"roll_size_mb,omitempty"`
- RollKeep int `json:"roll_keep,omitempty"`
- RollKeepDays int `json:"roll_keep_days,omitempty"`
-}
-
-// EncoderConfig configures the log format.
-type EncoderConfig struct {
- Format string `json:"format"` // "json", "console", etc.
-}
-
-// SinkConfig configures log sinks (e.g. stderr).
-type SinkConfig struct {
- Writer *WriterConfig `json:"writer,omitempty"`
-}
-
-// Storage configures the storage module.
-type Storage struct {
- System string `json:"module"`
- Root string `json:"root,omitempty"`
-}
-
-// Apps contains all Caddy app modules.
-type Apps struct {
- HTTP *HTTPApp `json:"http,omitempty"`
- TLS *TLSApp `json:"tls,omitempty"`
-}
-
-// HTTPApp configures the HTTP app.
-type HTTPApp struct {
- Servers map[string]*Server `json:"servers"`
-}
-
-// Server represents an HTTP server instance.
-type Server struct {
- Listen []string `json:"listen"`
- Routes []*Route `json:"routes"`
- AutoHTTPS *AutoHTTPSConfig `json:"automatic_https,omitempty"`
- Logs *ServerLogs `json:"logs,omitempty"`
-}
-
-// AutoHTTPSConfig controls automatic HTTPS behavior.
-type AutoHTTPSConfig struct {
- Disable bool `json:"disable,omitempty"`
- DisableRedir bool `json:"disable_redirects,omitempty"`
- Skip []string `json:"skip,omitempty"`
-}
-
-// ServerLogs configures access logging.
-type ServerLogs struct {
- DefaultLoggerName string `json:"default_logger_name,omitempty"`
-}
-
-// Route represents an HTTP route (matcher + handlers).
-type Route struct {
- Match []Match `json:"match,omitempty"`
- Handle []Handler `json:"handle"`
- Terminal bool `json:"terminal,omitempty"`
-}
-
-// Match represents a request matcher.
-type Match struct {
- Host []string `json:"host,omitempty"`
- Path []string `json:"path,omitempty"`
-}
-
-// Handler is the interface for all handler types.
-// Actual types will implement handler-specific fields.
-type Handler map[string]interface{}
-
-// ReverseProxyHandler creates a reverse_proxy handler.
-// application: "none", "plex", "jellyfin", "emby", "homeassistant", "nextcloud", "vaultwarden"
-func ReverseProxyHandler(dial string, enableWS bool, application string) Handler {
- h := Handler{
- "handler": "reverse_proxy",
- "flush_interval": -1, // Disable buffering for better streaming performance (Plex, etc.)
- "upstreams": []map[string]interface{}{
- {"dial": dial},
- },
- }
-
- // Build headers configuration
- headers := make(map[string]interface{})
- requestHeaders := make(map[string]interface{})
- setHeaders := make(map[string][]string)
-
- // WebSocket support
- if enableWS {
- setHeaders["Upgrade"] = []string{"{http.request.header.Upgrade}"}
- setHeaders["Connection"] = []string{"{http.request.header.Connection}"}
- }
-
- // Application-specific headers for proper client IP forwarding
- // These are critical for media servers behind tunnels/CGNAT
- switch application {
- case "plex":
- // Pass-through common Plex headers for improved compatibility when proxying
- setHeaders["X-Plex-Client-Identifier"] = []string{"{http.request.header.X-Plex-Client-Identifier}"}
- setHeaders["X-Plex-Device"] = []string{"{http.request.header.X-Plex-Device}"}
- setHeaders["X-Plex-Device-Name"] = []string{"{http.request.header.X-Plex-Device-Name}"}
- setHeaders["X-Plex-Platform"] = []string{"{http.request.header.X-Plex-Platform}"}
- setHeaders["X-Plex-Platform-Version"] = []string{"{http.request.header.X-Plex-Platform-Version}"}
- setHeaders["X-Plex-Product"] = []string{"{http.request.header.X-Plex-Product}"}
- setHeaders["X-Plex-Token"] = []string{"{http.request.header.X-Plex-Token}"}
- setHeaders["X-Plex-Version"] = []string{"{http.request.header.X-Plex-Version}"}
- // Also set X-Real-IP for accurate client IP reporting
- setHeaders["X-Real-IP"] = []string{"{http.request.remote.host}"}
- setHeaders["X-Forwarded-Host"] = []string{"{http.request.host}"}
- case "jellyfin", "emby", "homeassistant", "nextcloud", "vaultwarden":
- // X-Real-IP is required by most apps to identify the real client
- // Caddy already sets X-Forwarded-For and X-Forwarded-Proto by default
- setHeaders["X-Real-IP"] = []string{"{http.request.remote.host}"}
- // Some apps also check these headers
- setHeaders["X-Forwarded-Host"] = []string{"{http.request.host}"}
- }
-
- // Only add headers config if we have headers to set
- if len(setHeaders) > 0 {
- requestHeaders["set"] = setHeaders
- headers["request"] = requestHeaders
- h["headers"] = headers
- }
-
- return h
-}
-
-// HeaderHandler creates a handler that sets HTTP response headers.
-func HeaderHandler(headers map[string][]string) Handler {
- return Handler{
- "handler": "headers",
- "response": map[string]interface{}{
- "set": headers,
- },
- }
-}
-
-// BlockExploitsHandler creates a handler that blocks common exploits.
-// This uses Caddy's request matchers to block malicious patterns.
-func BlockExploitsHandler() Handler {
- return Handler{
- "handler": "vars",
- // Placeholder for future exploit blocking logic
- // Can be extended with specific matchers for SQL injection, XSS, etc.
- }
-}
-
-// RewriteHandler creates a rewrite handler.
-func RewriteHandler(uri string) Handler {
- return Handler{
- "handler": "rewrite",
- "uri": uri,
- }
-}
-
-// FileServerHandler creates a file_server handler.
-func FileServerHandler(root string) Handler {
- return Handler{
- "handler": "file_server",
- "root": root,
- }
-}
-
-// TLSApp configures the TLS app for certificate management.
-type TLSApp struct {
- Automation *AutomationConfig `json:"automation,omitempty"`
- Certificates *CertificatesConfig `json:"certificates,omitempty"`
-}
-
-// CertificatesConfig configures manual certificate loading.
-type CertificatesConfig struct {
- LoadPEM []LoadPEMConfig `json:"load_pem,omitempty"`
-}
-
-// LoadPEMConfig defines a PEM-loaded certificate.
-type LoadPEMConfig struct {
- Certificate string `json:"certificate"`
- Key string `json:"key"`
- Tags []string `json:"tags,omitempty"`
-}
-
-// AutomationConfig controls certificate automation.
-type AutomationConfig struct {
- Policies []*AutomationPolicy `json:"policies,omitempty"`
-}
-
-// AutomationPolicy defines certificate management for specific domains.
-type AutomationPolicy struct {
- Subjects []string `json:"subjects,omitempty"`
- IssuersRaw []interface{} `json:"issuers,omitempty"`
-}
-
-
-
package caddy
-
-import (
- "encoding/json"
- "fmt"
- "net"
- "strconv"
- "strings"
-)
-
-// Validate performs pre-flight validation on a Caddy config before applying it.
-func Validate(cfg *Config) error {
- if cfg == nil {
- return fmt.Errorf("config cannot be nil")
- }
-
- if cfg.Apps.HTTP == nil {
- return nil // Empty config is valid
- }
-
- // Track seen hosts to detect duplicates
- seenHosts := make(map[string]bool)
-
- for serverName, server := range cfg.Apps.HTTP.Servers {
- if len(server.Listen) == 0 {
- return fmt.Errorf("server %s has no listen addresses", serverName)
- }
-
- // Validate listen addresses
- for _, addr := range server.Listen {
- if err := validateListenAddr(addr); err != nil {
- return fmt.Errorf("invalid listen address %s in server %s: %w", addr, serverName, err)
- }
- }
-
- // Validate routes
- for i, route := range server.Routes {
- if err := validateRoute(route, seenHosts); err != nil {
- return fmt.Errorf("invalid route %d in server %s: %w", i, serverName, err)
- }
- }
- }
-
- // Validate JSON marshalling works
- if _, err := json.Marshal(cfg); err != nil {
- return fmt.Errorf("config cannot be marshalled to JSON: %w", err)
- }
-
- return nil
-}
-
-func validateListenAddr(addr string) error {
- // Strip network type prefix if present (tcp/, udp/)
- if idx := strings.Index(addr, "/"); idx != -1 {
- addr = addr[idx+1:]
- }
-
- // Parse host:port
- host, portStr, err := net.SplitHostPort(addr)
- if err != nil {
- return fmt.Errorf("invalid address format: %w", err)
- }
-
- // Validate port
- port, err := strconv.Atoi(portStr)
- if err != nil {
- return fmt.Errorf("invalid port: %w", err)
- }
- if port < 1 || port > 65535 {
- return fmt.Errorf("port %d out of range (1-65535)", port)
- }
-
- // Validate host (allow empty for wildcard binding)
- if host != "" && net.ParseIP(host) == nil {
- return fmt.Errorf("invalid IP address: %s", host)
- }
-
- return nil
-}
-
-func validateRoute(route *Route, seenHosts map[string]bool) error {
- if len(route.Handle) == 0 {
- return fmt.Errorf("route has no handlers")
- }
-
- // Check for duplicate host matchers
- for _, match := range route.Match {
- for _, host := range match.Host {
- if seenHosts[host] {
- return fmt.Errorf("duplicate host matcher: %s", host)
- }
- seenHosts[host] = true
- }
- }
-
- // Validate handlers
- for i, handler := range route.Handle {
- if err := validateHandler(handler); err != nil {
- return fmt.Errorf("invalid handler %d: %w", i, err)
- }
- }
-
- return nil
-}
-
-func validateHandler(handler Handler) error {
- handlerType, ok := handler["handler"].(string)
- if !ok {
- return fmt.Errorf("handler missing 'handler' field")
- }
-
- switch handlerType {
- case "reverse_proxy":
- return validateReverseProxy(handler)
- case "file_server", "static_response":
- return nil // Accept other common handlers
- default:
- // Unknown handlers are allowed (Caddy is extensible)
- return nil
- }
-}
-
-func validateReverseProxy(handler Handler) error {
- upstreams, ok := handler["upstreams"].([]map[string]interface{})
- if !ok {
- return fmt.Errorf("reverse_proxy missing upstreams")
- }
-
- if len(upstreams) == 0 {
- return fmt.Errorf("reverse_proxy has no upstreams")
- }
-
- for i, upstream := range upstreams {
- dial, ok := upstream["dial"].(string)
- if !ok || dial == "" {
- return fmt.Errorf("upstream %d missing dial address", i)
- }
-
- // Validate dial address format (host:port)
- if _, _, err := net.SplitHostPort(dial); err != nil {
- return fmt.Errorf("upstream %d has invalid dial address %s: %w", i, dial, err)
- }
- }
-
- return nil
-}
-
-
-
-
-
-
diff --git a/backend/integration/coraza_integration_test.go b/backend/integration/coraza_integration_test.go
deleted file mode 100644
index cb22df8a..00000000
--- a/backend/integration/coraza_integration_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-//go:build integration
-// +build integration
-
-package integration
-
-import (
- "context"
- "os/exec"
- "strings"
- "testing"
- "time"
-)
-
-// TestCorazaIntegration runs the scripts/coraza_integration.sh and ensures it completes successfully.
-// This test requires Docker and docker compose access locally; it is gated behind build tag `integration`.
-func TestCorazaIntegration(t *testing.T) {
- t.Parallel()
-
- // Ensure the script exists
- cmd := exec.CommandContext(context.Background(), "bash", "./scripts/coraza_integration.sh")
- // set a timeout in case something hangs
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
- defer cancel()
- cmd = exec.CommandContext(ctx, "bash", "./scripts/coraza_integration.sh")
-
- out, err := cmd.CombinedOutput()
- t.Logf("coraza_integration script output:\n%s", string(out))
- if err != nil {
- t.Fatalf("coraza integration failed: %v", err)
- }
- if !strings.Contains(string(out), "Coraza WAF blocked payload as expected") {
- t.Fatalf("unexpected script output, expected blocking assertion not found")
- }
-}
diff --git a/backend/integration/crowdsec_integration_test.go b/backend/integration/crowdsec_integration_test.go
deleted file mode 100644
index d6ddd29a..00000000
--- a/backend/integration/crowdsec_integration_test.go
+++ /dev/null
@@ -1,34 +0,0 @@
-//go:build integration
-// +build integration
-
-package integration
-
-import (
- "context"
- "os/exec"
- "strings"
- "testing"
- "time"
-)
-
-// TestCrowdsecIntegration runs scripts/crowdsec_integration.sh and ensures it completes successfully.
-func TestCrowdsecIntegration(t *testing.T) {
- t.Parallel()
-
- cmd := exec.CommandContext(context.Background(), "bash", "./scripts/crowdsec_integration.sh")
- // Ensure script runs from repo root so relative paths in scripts work reliably
- cmd.Dir = "../../"
- ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
- defer cancel()
- cmd = exec.CommandContext(ctx, "bash", "./scripts/crowdsec_integration.sh")
- cmd.Dir = "../../"
-
- out, err := cmd.CombinedOutput()
- t.Logf("crowdsec_integration script output:\n%s", string(out))
- if err != nil {
- t.Fatalf("crowdsec integration failed: %v", err)
- }
- if !strings.Contains(string(out), "Apply response: ") {
- t.Fatalf("unexpected script output, expected Apply response in output")
- }
-}
diff --git a/backend/internal/api/handlers/access_list_handler.go b/backend/internal/api/handlers/access_list_handler.go
deleted file mode 100644
index c97d5612..00000000
--- a/backend/internal/api/handlers/access_list_handler.go
+++ /dev/null
@@ -1,162 +0,0 @@
-package handlers
-
-import (
- "net/http"
- "strconv"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-)
-
-type AccessListHandler struct {
- service *services.AccessListService
-}
-
-func NewAccessListHandler(db *gorm.DB) *AccessListHandler {
- return &AccessListHandler{
- service: services.NewAccessListService(db),
- }
-}
-
-// Create handles POST /api/v1/access-lists
-func (h *AccessListHandler) Create(c *gin.Context) {
- var acl models.AccessList
- if err := c.ShouldBindJSON(&acl); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if err := h.service.Create(&acl); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusCreated, acl)
-}
-
-// List handles GET /api/v1/access-lists
-func (h *AccessListHandler) List(c *gin.Context) {
- acls, err := h.service.List()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, acls)
-}
-
-// Get handles GET /api/v1/access-lists/:id
-func (h *AccessListHandler) Get(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
- return
- }
-
- acl, err := h.service.GetByID(uint(id))
- if err != nil {
- if err == services.ErrAccessListNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, acl)
-}
-
-// Update handles PUT /api/v1/access-lists/:id
-func (h *AccessListHandler) Update(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
- return
- }
-
- var updates models.AccessList
- if err := c.ShouldBindJSON(&updates); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if err := h.service.Update(uint(id), &updates); err != nil {
- if err == services.ErrAccessListNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
- return
- }
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- // Fetch updated record
- acl, _ := h.service.GetByID(uint(id))
- c.JSON(http.StatusOK, acl)
-}
-
-// Delete handles DELETE /api/v1/access-lists/:id
-func (h *AccessListHandler) Delete(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
- return
- }
-
- if err := h.service.Delete(uint(id)); err != nil {
- if err == services.ErrAccessListNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
- return
- }
- if err == services.ErrAccessListInUse {
- c.JSON(http.StatusConflict, gin.H{"error": "access list is in use by proxy hosts"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"message": "access list deleted"})
-}
-
-// TestIP handles POST /api/v1/access-lists/:id/test
-func (h *AccessListHandler) TestIP(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
- return
- }
-
- var req struct {
- IPAddress string `json:"ip_address" binding:"required"`
- }
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- allowed, reason, err := h.service.TestIP(uint(id), req.IPAddress)
- if err != nil {
- if err == services.ErrAccessListNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "access list not found"})
- return
- }
- if err == services.ErrInvalidIPAddress {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid IP address"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "allowed": allowed,
- "reason": reason,
- })
-}
-
-// GetTemplates handles GET /api/v1/access-lists/templates
-func (h *AccessListHandler) GetTemplates(c *gin.Context) {
- templates := h.service.GetTemplates()
- c.JSON(http.StatusOK, templates)
-}
diff --git a/backend/internal/api/handlers/access_list_handler_coverage_test.go b/backend/internal/api/handlers/access_list_handler_coverage_test.go
deleted file mode 100644
index ad50fd9f..00000000
--- a/backend/internal/api/handlers/access_list_handler_coverage_test.go
+++ /dev/null
@@ -1,252 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
-)
-
-func TestAccessListHandler_Get_InvalidID(t *testing.T) {
- router, _ := setupAccessListTestRouter(t)
-
- req := httptest.NewRequest(http.MethodGet, "/access-lists/invalid", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestAccessListHandler_Update_InvalidID(t *testing.T) {
- router, _ := setupAccessListTestRouter(t)
-
- body := []byte(`{"name":"Test","type":"whitelist"}`)
- req := httptest.NewRequest(http.MethodPut, "/access-lists/invalid", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestAccessListHandler_Update_InvalidJSON(t *testing.T) {
- router, db := setupAccessListTestRouter(t)
-
- // Create test ACL
- acl := models.AccessList{UUID: "test-uuid", Name: "Test", Type: "whitelist"}
- db.Create(&acl)
-
- req := httptest.NewRequest(http.MethodPut, "/access-lists/1", bytes.NewReader([]byte("invalid json")))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestAccessListHandler_Delete_InvalidID(t *testing.T) {
- router, _ := setupAccessListTestRouter(t)
-
- req := httptest.NewRequest(http.MethodDelete, "/access-lists/invalid", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestAccessListHandler_TestIP_InvalidID(t *testing.T) {
- router, _ := setupAccessListTestRouter(t)
-
- body := []byte(`{"ip_address":"192.168.1.1"}`)
- req := httptest.NewRequest(http.MethodPost, "/access-lists/invalid/test", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestAccessListHandler_TestIP_MissingIPAddress(t *testing.T) {
- router, db := setupAccessListTestRouter(t)
-
- // Create test ACL
- acl := models.AccessList{UUID: "test-uuid", Name: "Test", Type: "whitelist"}
- db.Create(&acl)
-
- body := []byte(`{}`)
- req := httptest.NewRequest(http.MethodPost, "/access-lists/1/test", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestAccessListHandler_List_DBError(t *testing.T) {
- db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- // Don't migrate the table to cause error
-
- gin.SetMode(gin.TestMode)
- router := gin.New()
-
- handler := NewAccessListHandler(db)
- router.GET("/access-lists", handler.List)
-
- req := httptest.NewRequest(http.MethodGet, "/access-lists", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusInternalServerError, w.Code)
-}
-
-func TestAccessListHandler_Get_DBError(t *testing.T) {
- db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- // Don't migrate the table to cause error
-
- gin.SetMode(gin.TestMode)
- router := gin.New()
-
- handler := NewAccessListHandler(db)
- router.GET("/access-lists/:id", handler.Get)
-
- req := httptest.NewRequest(http.MethodGet, "/access-lists/1", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- // Should be 500 since table doesn't exist
- assert.Equal(t, http.StatusInternalServerError, w.Code)
-}
-
-func TestAccessListHandler_Delete_InternalError(t *testing.T) {
- db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- // Migrate AccessList but not ProxyHost to cause internal error on delete
- db.AutoMigrate(&models.AccessList{})
-
- gin.SetMode(gin.TestMode)
- router := gin.New()
-
- handler := NewAccessListHandler(db)
- router.DELETE("/access-lists/:id", handler.Delete)
-
- // Create ACL to delete
- acl := models.AccessList{UUID: "test-uuid", Name: "Test", Type: "whitelist"}
- db.Create(&acl)
-
- req := httptest.NewRequest(http.MethodDelete, "/access-lists/1", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- // Should return 500 since ProxyHost table doesn't exist for checking usage
- assert.Equal(t, http.StatusInternalServerError, w.Code)
-}
-
-func TestAccessListHandler_Update_InvalidType(t *testing.T) {
- router, db := setupAccessListTestRouter(t)
-
- // Create test ACL
- acl := models.AccessList{UUID: "test-uuid", Name: "Test", Type: "whitelist"}
- db.Create(&acl)
-
- body := []byte(`{"name":"Updated","type":"invalid_type"}`)
- req := httptest.NewRequest(http.MethodPut, "/access-lists/1", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestAccessListHandler_Create_InvalidJSON(t *testing.T) {
- router, _ := setupAccessListTestRouter(t)
-
- req := httptest.NewRequest(http.MethodPost, "/access-lists", bytes.NewReader([]byte("invalid")))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestAccessListHandler_TestIP_Blacklist(t *testing.T) {
- router, db := setupAccessListTestRouter(t)
-
- // Create blacklist ACL
- acl := models.AccessList{
- UUID: "blacklist-uuid",
- Name: "Test Blacklist",
- Type: "blacklist",
- IPRules: `[{"cidr":"10.0.0.0/8","description":"Block 10.x"}]`,
- Enabled: true,
- }
- db.Create(&acl)
-
- // Test IP in blacklist
- body := []byte(`{"ip_address":"10.0.0.1"}`)
- req := httptest.NewRequest(http.MethodPost, "/access-lists/1/test", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-}
-
-func TestAccessListHandler_TestIP_GeoWhitelist(t *testing.T) {
- router, db := setupAccessListTestRouter(t)
-
- // Create geo whitelist ACL
- acl := models.AccessList{
- UUID: "geo-uuid",
- Name: "US Only",
- Type: "geo_whitelist",
- CountryCodes: "US,CA",
- Enabled: true,
- }
- db.Create(&acl)
-
- // Test IP (geo lookup will likely fail in test but coverage is what matters)
- body := []byte(`{"ip_address":"8.8.8.8"}`)
- req := httptest.NewRequest(http.MethodPost, "/access-lists/1/test", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-}
-
-func TestAccessListHandler_TestIP_LocalNetworkOnly(t *testing.T) {
- router, db := setupAccessListTestRouter(t)
-
- // Create local network only ACL
- acl := models.AccessList{
- UUID: "local-uuid",
- Name: "Local Only",
- Type: "whitelist",
- LocalNetworkOnly: true,
- Enabled: true,
- }
- db.Create(&acl)
-
- // Test with local IP
- body := []byte(`{"ip_address":"192.168.1.1"}`)
- req := httptest.NewRequest(http.MethodPost, "/access-lists/1/test", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- // Test with public IP
- body = []byte(`{"ip_address":"8.8.8.8"}`)
- req = httptest.NewRequest(http.MethodPost, "/access-lists/1/test", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w = httptest.NewRecorder()
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-}
diff --git a/backend/internal/api/handlers/access_list_handler_test.go b/backend/internal/api/handlers/access_list_handler_test.go
deleted file mode 100644
index 51a84ea1..00000000
--- a/backend/internal/api/handlers/access_list_handler_test.go
+++ /dev/null
@@ -1,415 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
-)
-
-func setupAccessListTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- assert.NoError(t, err)
-
- err = db.AutoMigrate(&models.AccessList{}, &models.ProxyHost{})
- assert.NoError(t, err)
-
- gin.SetMode(gin.TestMode)
- router := gin.New()
-
- handler := NewAccessListHandler(db)
- router.POST("/access-lists", handler.Create)
- router.GET("/access-lists", handler.List)
- router.GET("/access-lists/:id", handler.Get)
- router.PUT("/access-lists/:id", handler.Update)
- router.DELETE("/access-lists/:id", handler.Delete)
- router.POST("/access-lists/:id/test", handler.TestIP)
- router.GET("/access-lists/templates", handler.GetTemplates)
-
- return router, db
-}
-
-func TestAccessListHandler_Create(t *testing.T) {
- router, _ := setupAccessListTestRouter(t)
-
- tests := []struct {
- name string
- payload map[string]interface{}
- wantStatus int
- }{
- {
- name: "create whitelist successfully",
- payload: map[string]interface{}{
- "name": "Office Whitelist",
- "description": "Allow office IPs only",
- "type": "whitelist",
- "ip_rules": `[{"cidr":"192.168.1.0/24","description":"Office network"}]`,
- "enabled": true,
- },
- wantStatus: http.StatusCreated,
- },
- {
- name: "create geo whitelist successfully",
- payload: map[string]interface{}{
- "name": "US Only",
- "type": "geo_whitelist",
- "country_codes": "US,CA",
- "enabled": true,
- },
- wantStatus: http.StatusCreated,
- },
- {
- name: "create local network only",
- payload: map[string]interface{}{
- "name": "Local Network",
- "type": "whitelist",
- "local_network_only": true,
- "enabled": true,
- },
- wantStatus: http.StatusCreated,
- },
- {
- name: "fail with invalid type",
- payload: map[string]interface{}{
- "name": "Invalid",
- "type": "invalid_type",
- "enabled": true,
- },
- wantStatus: http.StatusBadRequest,
- },
- {
- name: "fail with missing name",
- payload: map[string]interface{}{
- "type": "whitelist",
- "enabled": true,
- },
- wantStatus: http.StatusBadRequest,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- body, _ := json.Marshal(tt.payload)
- req := httptest.NewRequest(http.MethodPost, "/access-lists", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- router.ServeHTTP(w, req)
-
- assert.Equal(t, tt.wantStatus, w.Code)
-
- if w.Code == http.StatusCreated {
- var response models.AccessList
- err := json.Unmarshal(w.Body.Bytes(), &response)
- assert.NoError(t, err)
- assert.NotEmpty(t, response.UUID)
- assert.Equal(t, tt.payload["name"], response.Name)
- }
- })
- }
-}
-
-func TestAccessListHandler_List(t *testing.T) {
- router, db := setupAccessListTestRouter(t)
-
- // Create test data
- acls := []models.AccessList{
- {Name: "Test 1", Type: "whitelist", Enabled: true},
- {Name: "Test 2", Type: "blacklist", Enabled: false},
- }
- for i := range acls {
- acls[i].UUID = "test-uuid-" + string(rune(i))
- db.Create(&acls[i])
- }
-
- req := httptest.NewRequest(http.MethodGet, "/access-lists", http.NoBody)
- w := httptest.NewRecorder()
-
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var response []models.AccessList
- err := json.Unmarshal(w.Body.Bytes(), &response)
- assert.NoError(t, err)
- assert.Len(t, response, 2)
-}
-
-func TestAccessListHandler_Get(t *testing.T) {
- router, db := setupAccessListTestRouter(t)
-
- // Create test ACL
- acl := models.AccessList{
- UUID: "test-uuid",
- Name: "Test ACL",
- Type: "whitelist",
- Enabled: true,
- }
- db.Create(&acl)
-
- tests := []struct {
- name string
- id string
- wantStatus int
- }{
- {
- name: "get existing ACL",
- id: "1",
- wantStatus: http.StatusOK,
- },
- {
- name: "get non-existent ACL",
- id: "9999",
- wantStatus: http.StatusNotFound,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req := httptest.NewRequest(http.MethodGet, "/access-lists/"+tt.id, http.NoBody)
- w := httptest.NewRecorder()
-
- router.ServeHTTP(w, req)
-
- assert.Equal(t, tt.wantStatus, w.Code)
-
- if w.Code == http.StatusOK {
- var response models.AccessList
- err := json.Unmarshal(w.Body.Bytes(), &response)
- assert.NoError(t, err)
- assert.Equal(t, acl.Name, response.Name)
- }
- })
- }
-}
-
-func TestAccessListHandler_Update(t *testing.T) {
- router, db := setupAccessListTestRouter(t)
-
- // Create test ACL
- acl := models.AccessList{
- UUID: "test-uuid",
- Name: "Original Name",
- Type: "whitelist",
- Enabled: true,
- }
- db.Create(&acl)
-
- tests := []struct {
- name string
- id string
- payload map[string]interface{}
- wantStatus int
- }{
- {
- name: "update successfully",
- id: "1",
- payload: map[string]interface{}{
- "name": "Updated Name",
- "description": "New description",
- "enabled": false,
- "type": "whitelist",
- "ip_rules": `[{"cidr":"10.0.0.0/8","description":"Updated network"}]`,
- },
- wantStatus: http.StatusOK,
- },
- {
- name: "update non-existent ACL",
- id: "9999",
- payload: map[string]interface{}{
- "name": "Test",
- "type": "whitelist",
- "ip_rules": `[]`,
- },
- wantStatus: http.StatusNotFound,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- body, _ := json.Marshal(tt.payload)
- req := httptest.NewRequest(http.MethodPut, "/access-lists/"+tt.id, bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- router.ServeHTTP(w, req)
-
- if w.Code != tt.wantStatus {
- t.Logf("Response body: %s", w.Body.String())
- }
- assert.Equal(t, tt.wantStatus, w.Code)
-
- if w.Code == http.StatusOK {
- var response models.AccessList
- err := json.Unmarshal(w.Body.Bytes(), &response)
- assert.NoError(t, err)
- if name, ok := tt.payload["name"].(string); ok {
- assert.Equal(t, name, response.Name)
- }
- }
- })
- }
-}
-
-func TestAccessListHandler_Delete(t *testing.T) {
- router, db := setupAccessListTestRouter(t)
-
- // Create test ACL
- acl := models.AccessList{
- UUID: "test-uuid",
- Name: "Test ACL",
- Type: "whitelist",
- Enabled: true,
- }
- db.Create(&acl)
-
- // Create ACL in use
- aclInUse := models.AccessList{
- UUID: "in-use-uuid",
- Name: "In Use ACL",
- Type: "whitelist",
- Enabled: true,
- }
- db.Create(&aclInUse)
-
- host := models.ProxyHost{
- UUID: "host-uuid",
- Name: "Test Host",
- DomainNames: "test.com",
- ForwardHost: "localhost",
- ForwardPort: 8080,
- AccessListID: &aclInUse.ID,
- }
- db.Create(&host)
-
- tests := []struct {
- name string
- id string
- wantStatus int
- }{
- {
- name: "delete successfully",
- id: "1",
- wantStatus: http.StatusOK,
- },
- {
- name: "fail to delete ACL in use",
- id: "2",
- wantStatus: http.StatusConflict,
- },
- {
- name: "delete non-existent ACL",
- id: "9999",
- wantStatus: http.StatusNotFound,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- req := httptest.NewRequest(http.MethodDelete, "/access-lists/"+tt.id, http.NoBody)
- w := httptest.NewRecorder()
-
- router.ServeHTTP(w, req)
-
- assert.Equal(t, tt.wantStatus, w.Code)
- })
- }
-}
-
-func TestAccessListHandler_TestIP(t *testing.T) {
- router, db := setupAccessListTestRouter(t)
-
- // Create test ACL
- acl := models.AccessList{
- UUID: "test-uuid",
- Name: "Test Whitelist",
- Type: "whitelist",
- IPRules: `[{"cidr":"192.168.1.0/24","description":"Test network"}]`,
- Enabled: true,
- }
- db.Create(&acl)
-
- tests := []struct {
- name string
- id string
- payload map[string]string
- wantStatus int
- }{
- {
- name: "test IP in whitelist",
- id: "1", // Use numeric ID
- payload: map[string]string{"ip_address": "192.168.1.100"},
- wantStatus: http.StatusOK,
- },
- {
- name: "test IP not in whitelist",
- id: "1",
- payload: map[string]string{"ip_address": "10.0.0.1"},
- wantStatus: http.StatusOK,
- },
- {
- name: "test invalid IP",
- id: "1",
- payload: map[string]string{"ip_address": "invalid"},
- wantStatus: http.StatusBadRequest,
- },
- {
- name: "test non-existent ACL",
- id: "9999",
- payload: map[string]string{"ip_address": "192.168.1.100"},
- wantStatus: http.StatusNotFound,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- body, _ := json.Marshal(tt.payload)
- req := httptest.NewRequest(http.MethodPost, "/access-lists/"+tt.id+"/test", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
-
- router.ServeHTTP(w, req)
-
- assert.Equal(t, tt.wantStatus, w.Code)
-
- if w.Code == http.StatusOK {
- var response map[string]interface{}
- err := json.Unmarshal(w.Body.Bytes(), &response)
- assert.NoError(t, err)
- assert.Contains(t, response, "allowed")
- assert.Contains(t, response, "reason")
- }
- })
- }
-}
-
-func TestAccessListHandler_GetTemplates(t *testing.T) {
- router, _ := setupAccessListTestRouter(t)
-
- req := httptest.NewRequest(http.MethodGet, "/access-lists/templates", http.NoBody)
- w := httptest.NewRecorder()
-
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var response []map[string]interface{}
- err := json.Unmarshal(w.Body.Bytes(), &response)
- assert.NoError(t, err)
- assert.NotEmpty(t, response)
- assert.Greater(t, len(response), 0)
-
- // Verify template structure
- for _, template := range response {
- assert.Contains(t, template, "name")
- assert.Contains(t, template, "description")
- assert.Contains(t, template, "type")
- }
-}
diff --git a/backend/internal/api/handlers/additional_coverage_test.go b/backend/internal/api/handlers/additional_coverage_test.go
deleted file mode 100644
index 15aa1a5b..00000000
--- a/backend/internal/api/handlers/additional_coverage_test.go
+++ /dev/null
@@ -1,910 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "encoding/json"
- "mime/multipart"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/config"
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
-)
-
-func setupImportCoverageDB(t *testing.T) *gorm.DB {
- t.Helper()
- db := OpenTestDB(t)
- db.AutoMigrate(&models.ImportSession{}, &models.ProxyHost{}, &models.Domain{})
- return db
-}
-
-func TestImportHandler_Commit_InvalidJSON(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupImportCoverageDB(t)
-
- h := NewImportHandler(db, "", t.TempDir(), "")
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/import/commit", bytes.NewBufferString("invalid"))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.Commit(c)
-
- assert.Equal(t, 400, w.Code)
-}
-
-func TestImportHandler_Commit_InvalidSessionUUID(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupImportCoverageDB(t)
-
- h := NewImportHandler(db, "", t.TempDir(), "")
-
- body, _ := json.Marshal(map[string]interface{}{
- "session_uuid": "../../../etc/passwd",
- })
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.Commit(c)
-
- // After sanitization, "../../../etc/passwd" becomes "passwd" which doesn't exist
- assert.Equal(t, 404, w.Code)
- assert.Contains(t, w.Body.String(), "session not found")
-}
-
-func TestImportHandler_Commit_SessionNotFound(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupImportCoverageDB(t)
-
- h := NewImportHandler(db, "", t.TempDir(), "")
-
- body, _ := json.Marshal(map[string]interface{}{
- "session_uuid": "nonexistent-session",
- })
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/import/commit", bytes.NewBuffer(body))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.Commit(c)
-
- assert.Equal(t, 404, w.Code)
- assert.Contains(t, w.Body.String(), "session not found")
-}
-
-// Remote Server Handler additional test
-
-func setupRemoteServerCoverageDB2(t *testing.T) *gorm.DB {
- t.Helper()
- db := OpenTestDB(t)
- db.AutoMigrate(&models.RemoteServer{})
- return db
-}
-
-func TestRemoteServerHandler_TestConnection_Unreachable(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupRemoteServerCoverageDB2(t)
- svc := services.NewRemoteServerService(db)
- h := NewRemoteServerHandler(svc, nil)
-
- // Create a server with unreachable host
- server := &models.RemoteServer{
- Name: "Unreachable",
- Host: "192.0.2.1", // TEST-NET - not routable
- Port: 65535,
- }
- svc.Create(server)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Params = gin.Params{{Key: "uuid", Value: server.UUID}}
-
- h.TestConnection(c)
-
- // Should return 200 with reachable: false
- assert.Equal(t, 200, w.Code)
- assert.Contains(t, w.Body.String(), `"reachable":false`)
-}
-
-// Security Handler additional coverage tests
-
-func setupSecurityCoverageDB3(t *testing.T) *gorm.DB {
- t.Helper()
- db := OpenTestDB(t)
- db.AutoMigrate(
- &models.SecurityConfig{},
- &models.SecurityDecision{},
- &models.SecurityRuleSet{},
- &models.SecurityAudit{},
- )
- return db
-}
-
-func TestSecurityHandler_GetConfig_InternalError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupSecurityCoverageDB3(t)
-
- h := NewSecurityHandler(config.SecurityConfig{}, db, nil)
-
- // Drop table to cause internal error (not ErrSecurityConfigNotFound)
- db.Migrator().DropTable(&models.SecurityConfig{})
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("GET", "/security/config", http.NoBody)
-
- h.GetConfig(c)
-
- // Should return internal error
- assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "failed to read security config")
-}
-
-func TestSecurityHandler_UpdateConfig_ApplyCaddyError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupSecurityCoverageDB3(t)
-
- // Create handler with nil caddy manager (ApplyConfig will be called but is nil)
- h := NewSecurityHandler(config.SecurityConfig{}, db, nil)
-
- body, _ := json.Marshal(map[string]interface{}{
- "name": "test",
- "waf_mode": "block",
- })
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("PUT", "/security/config", bytes.NewBuffer(body))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.UpdateConfig(c)
-
- // Should succeed (caddy manager is nil so no apply error)
- assert.Equal(t, 200, w.Code)
-}
-
-func TestSecurityHandler_GenerateBreakGlass_Error(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupSecurityCoverageDB3(t)
-
- h := NewSecurityHandler(config.SecurityConfig{}, db, nil)
-
- // Drop the config table so generate fails
- db.Migrator().DropTable(&models.SecurityConfig{})
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/security/breakglass", http.NoBody)
-
- h.GenerateBreakGlass(c)
-
- assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "failed to generate break-glass token")
-}
-
-func TestSecurityHandler_ListDecisions_Error(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupSecurityCoverageDB3(t)
-
- h := NewSecurityHandler(config.SecurityConfig{}, db, nil)
-
- // Drop decisions table
- db.Migrator().DropTable(&models.SecurityDecision{})
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("GET", "/security/decisions", http.NoBody)
-
- h.ListDecisions(c)
-
- assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "failed to list decisions")
-}
-
-func TestSecurityHandler_ListRuleSets_Error(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupSecurityCoverageDB3(t)
-
- h := NewSecurityHandler(config.SecurityConfig{}, db, nil)
-
- // Drop rulesets table
- db.Migrator().DropTable(&models.SecurityRuleSet{})
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("GET", "/security/rulesets", http.NoBody)
-
- h.ListRuleSets(c)
-
- assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "failed to list rule sets")
-}
-
-func TestSecurityHandler_UpsertRuleSet_Error(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupSecurityCoverageDB3(t)
-
- h := NewSecurityHandler(config.SecurityConfig{}, db, nil)
-
- // Drop table to cause upsert to fail
- db.Migrator().DropTable(&models.SecurityRuleSet{})
-
- body, _ := json.Marshal(map[string]interface{}{
- "name": "test-ruleset",
- "enabled": true,
- })
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/security/rulesets", bytes.NewBuffer(body))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.UpsertRuleSet(c)
-
- assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "failed to upsert ruleset")
-}
-
-func TestSecurityHandler_CreateDecision_LogError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupSecurityCoverageDB3(t)
-
- h := NewSecurityHandler(config.SecurityConfig{}, db, nil)
-
- // Drop decisions table to cause log to fail
- db.Migrator().DropTable(&models.SecurityDecision{})
-
- body, _ := json.Marshal(map[string]interface{}{
- "ip": "192.168.1.1",
- "action": "ban",
- })
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/security/decisions", bytes.NewBuffer(body))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.CreateDecision(c)
-
- assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "failed to log decision")
-}
-
-func TestSecurityHandler_DeleteRuleSet_Error(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupSecurityCoverageDB3(t)
-
- h := NewSecurityHandler(config.SecurityConfig{}, db, nil)
-
- // Drop table to cause delete to fail (not NotFound but table error)
- db.Migrator().DropTable(&models.SecurityRuleSet{})
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Params = gin.Params{{Key: "id", Value: "999"}}
-
- h.DeleteRuleSet(c)
-
- assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "failed to delete ruleset")
-}
-
-// CrowdSec ImportConfig additional coverage tests
-
-func TestCrowdsec_ImportConfig_EmptyUpload(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- // Create empty file upload
- buf := &bytes.Buffer{}
- mw := multipart.NewWriter(buf)
- fw, _ := mw.CreateFormFile("file", "empty.tar.gz")
- // Write nothing to make file empty
- _ = fw
- mw.Close()
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest("POST", "/api/v1/admin/crowdsec/import", buf)
- req.Header.Set("Content-Type", mw.FormDataContentType())
- r.ServeHTTP(w, req)
-
- assert.Equal(t, 400, w.Code)
- assert.Contains(t, w.Body.String(), "empty upload")
-}
-
-// Backup Handler additional coverage tests
-
-func TestBackupHandler_List_DBError(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- // Use a non-writable temp dir to simulate errors
- tmpDir := t.TempDir()
-
- cfg := &config.Config{
- DatabasePath: filepath.Join(tmpDir, "nonexistent", "charon.db"),
- }
-
- svc := services.NewBackupService(cfg)
- h := NewBackupHandler(svc)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
-
- h.List(c)
-
- // Should succeed with empty list (service handles missing dir gracefully)
- assert.Equal(t, 200, w.Code)
-}
-
-// ImportHandler UploadMulti coverage tests
-
-func TestImportHandler_UploadMulti_InvalidJSON(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupImportCoverageDB(t)
-
- h := NewImportHandler(db, "", t.TempDir(), "")
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/import/upload-multi", bytes.NewBufferString("invalid"))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.UploadMulti(c)
-
- assert.Equal(t, 400, w.Code)
-}
-
-func TestImportHandler_UploadMulti_MissingCaddyfile(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupImportCoverageDB(t)
-
- h := NewImportHandler(db, "", t.TempDir(), "")
-
- body, _ := json.Marshal(map[string]interface{}{
- "files": []map[string]string{
- {"filename": "sites/example.com", "content": "example.com {}"},
- },
- })
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.UploadMulti(c)
-
- assert.Equal(t, 400, w.Code)
- assert.Contains(t, w.Body.String(), "must include a main Caddyfile")
-}
-
-func TestImportHandler_UploadMulti_EmptyContent(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupImportCoverageDB(t)
-
- h := NewImportHandler(db, "", t.TempDir(), "")
-
- body, _ := json.Marshal(map[string]interface{}{
- "files": []map[string]string{
- {"filename": "Caddyfile", "content": ""},
- },
- })
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.UploadMulti(c)
-
- assert.Equal(t, 400, w.Code)
- assert.Contains(t, w.Body.String(), "is empty")
-}
-
-func TestImportHandler_UploadMulti_PathTraversal(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupImportCoverageDB(t)
-
- h := NewImportHandler(db, "", t.TempDir(), "")
-
- body, _ := json.Marshal(map[string]interface{}{
- "files": []map[string]string{
- {"filename": "Caddyfile", "content": "example.com {}"},
- {"filename": "../../../etc/passwd", "content": "bad content"},
- },
- })
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.UploadMulti(c)
-
- assert.Equal(t, 400, w.Code)
- assert.Contains(t, w.Body.String(), "invalid filename")
-}
-
-// Logs Handler Download error coverage
-
-func setupLogsDownloadTest(t *testing.T) (h *LogsHandler, logsDir string) {
- t.Helper()
- tmpDir := t.TempDir()
- dataDir := filepath.Join(tmpDir, "data")
- os.MkdirAll(dataDir, 0o755)
-
- logsDir = filepath.Join(dataDir, "logs")
- os.MkdirAll(logsDir, 0o755)
-
- dbPath := filepath.Join(dataDir, "charon.db")
- cfg := &config.Config{DatabasePath: dbPath}
- svc := services.NewLogService(cfg)
- h = NewLogsHandler(svc)
-
- return h, logsDir
-}
-
-func TestLogsHandler_Download_PathTraversal(t *testing.T) {
- gin.SetMode(gin.TestMode)
- h, _ := setupLogsDownloadTest(t)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Params = gin.Params{{Key: "filename", Value: "../../../etc/passwd"}}
- c.Request = httptest.NewRequest("GET", "/logs/../../../etc/passwd/download", http.NoBody)
-
- h.Download(c)
-
- assert.Equal(t, 400, w.Code)
- assert.Contains(t, w.Body.String(), "invalid filename")
-}
-
-func TestLogsHandler_Download_NotFound(t *testing.T) {
- gin.SetMode(gin.TestMode)
- h, _ := setupLogsDownloadTest(t)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Params = gin.Params{{Key: "filename", Value: "nonexistent.log"}}
- c.Request = httptest.NewRequest("GET", "/logs/nonexistent.log/download", http.NoBody)
-
- h.Download(c)
-
- assert.Equal(t, 404, w.Code)
- assert.Contains(t, w.Body.String(), "not found")
-}
-
-func TestLogsHandler_Download_Success(t *testing.T) {
- gin.SetMode(gin.TestMode)
- h, logsDir := setupLogsDownloadTest(t)
-
- // Create a log file to download
- os.WriteFile(filepath.Join(logsDir, "test.log"), []byte("log content"), 0o644)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Params = gin.Params{{Key: "filename", Value: "test.log"}}
- c.Request = httptest.NewRequest("GET", "/logs/test.log/download", http.NoBody)
-
- h.Download(c)
-
- assert.Equal(t, 200, w.Code)
-}
-
-// Import Handler Upload error tests
-
-func TestImportHandler_Upload_InvalidJSON(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupImportCoverageDB(t)
-
- h := NewImportHandler(db, "", t.TempDir(), "")
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/import/upload", bytes.NewBufferString("not json"))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.Upload(c)
-
- assert.Equal(t, 400, w.Code)
-}
-
-func TestImportHandler_Upload_EmptyContent(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupImportCoverageDB(t)
-
- h := NewImportHandler(db, "", t.TempDir(), "")
-
- body, _ := json.Marshal(map[string]string{
- "content": "",
- })
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/import/upload", bytes.NewBuffer(body))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.Upload(c)
-
- assert.Equal(t, 400, w.Code)
-}
-
-// Additional Backup Handler tests
-
-func TestBackupHandler_List_ServiceError(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- // Create a temp dir with invalid permission for backup dir
- tmpDir := t.TempDir()
- dataDir := filepath.Join(tmpDir, "data")
- os.MkdirAll(dataDir, 0o755)
-
- // Create database file so config is valid
- dbPath := filepath.Join(dataDir, "charon.db")
- os.WriteFile(dbPath, []byte("test"), 0o644)
-
- cfg := &config.Config{
- DatabasePath: dbPath,
- }
-
- svc := services.NewBackupService(cfg)
- h := NewBackupHandler(svc)
-
- // Make backup dir a file to cause ReadDir error
- os.RemoveAll(svc.BackupDir)
- os.WriteFile(svc.BackupDir, []byte("not a dir"), 0o644)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("GET", "/backups", http.NoBody)
-
- h.List(c)
-
- assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "Failed to list backups")
-}
-
-func TestBackupHandler_Delete_PathTraversal(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- tmpDir := t.TempDir()
- dataDir := filepath.Join(tmpDir, "data")
- os.MkdirAll(dataDir, 0o755)
-
- dbPath := filepath.Join(dataDir, "charon.db")
- os.WriteFile(dbPath, []byte("test"), 0o644)
-
- cfg := &config.Config{
- DatabasePath: dbPath,
- }
-
- svc := services.NewBackupService(cfg)
- h := NewBackupHandler(svc)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Params = gin.Params{{Key: "filename", Value: "../../../etc/passwd"}}
- c.Request = httptest.NewRequest("DELETE", "/backups/../../../etc/passwd", http.NoBody)
-
- h.Delete(c)
-
- // Path traversal detection returns 500 with generic error
- assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "Failed to delete backup")
-}
-
-func TestBackupHandler_Delete_InternalError2(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- tmpDir := t.TempDir()
- dataDir := filepath.Join(tmpDir, "data")
- os.MkdirAll(dataDir, 0o755)
-
- dbPath := filepath.Join(dataDir, "charon.db")
- os.WriteFile(dbPath, []byte("test"), 0o644)
-
- cfg := &config.Config{
- DatabasePath: dbPath,
- }
-
- svc := services.NewBackupService(cfg)
- h := NewBackupHandler(svc)
-
- // Create a backup
- backupsDir := filepath.Join(dataDir, "backups")
- os.MkdirAll(backupsDir, 0o755)
- backupFile := filepath.Join(backupsDir, "test.zip")
- os.WriteFile(backupFile, []byte("backup"), 0o644)
-
- // Remove write permissions to cause delete error
- os.Chmod(backupsDir, 0o555)
- defer os.Chmod(backupsDir, 0o755)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Params = gin.Params{{Key: "filename", Value: "test.zip"}}
- c.Request = httptest.NewRequest("DELETE", "/backups/test.zip", http.NoBody)
-
- h.Delete(c)
-
- // Permission error
- assert.Contains(t, []int{200, 500}, w.Code)
-}
-
-// Remote Server TestConnection error paths
-
-func TestRemoteServerHandler_TestConnection_NotFound2(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupRemoteServerCoverageDB2(t)
- svc := services.NewRemoteServerService(db)
- h := NewRemoteServerHandler(svc, nil)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Params = gin.Params{{Key: "uuid", Value: "nonexistent-uuid"}}
-
- h.TestConnection(c)
-
- assert.Equal(t, 404, w.Code)
-}
-
-func TestRemoteServerHandler_TestConnectionCustom_Unreachable2(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupRemoteServerCoverageDB2(t)
- svc := services.NewRemoteServerService(db)
- h := NewRemoteServerHandler(svc, nil)
-
- body, _ := json.Marshal(map[string]interface{}{
- "host": "192.0.2.1", // TEST-NET - not routable
- "port": 65535,
- })
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/remote-servers/test", bytes.NewBuffer(body))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.TestConnectionCustom(c)
-
- assert.Equal(t, 200, w.Code)
- assert.Contains(t, w.Body.String(), `"reachable":false`)
-}
-
-// Auth Handler Register error paths
-
-func setupAuthCoverageDB(t *testing.T) *gorm.DB {
- t.Helper()
- db := OpenTestDB(t)
- db.AutoMigrate(&models.User{}, &models.Setting{})
- return db
-}
-
-func TestAuthHandler_Register_InvalidJSON(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupAuthCoverageDB(t)
-
- cfg := config.Config{JWTSecret: "test-secret"}
- authService := services.NewAuthService(db, cfg)
- h := NewAuthHandler(authService)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/register", bytes.NewBufferString("invalid"))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.Register(c)
-
- assert.Equal(t, 400, w.Code)
-}
-
-// Health handler coverage
-
-func TestHealthHandler_Basic(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("GET", "/health", http.NoBody)
-
- HealthHandler(c)
-
- assert.Equal(t, 200, w.Code)
- assert.Contains(t, w.Body.String(), "status")
- assert.Contains(t, w.Body.String(), "ok")
-}
-
-// Backup Create error coverage
-
-func TestBackupHandler_Create_Error(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- // Use a path where database file doesn't exist
- tmpDir := t.TempDir()
- dataDir := filepath.Join(tmpDir, "data")
- os.MkdirAll(dataDir, 0o755)
-
- // Don't create the database file - this will cause CreateBackup to fail
- dbPath := filepath.Join(dataDir, "charon.db")
-
- cfg := &config.Config{
- DatabasePath: dbPath,
- }
-
- svc := services.NewBackupService(cfg)
- h := NewBackupHandler(svc)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/backups", http.NoBody)
-
- h.Create(c)
-
- // Should fail because database file doesn't exist
- assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "Failed to create backup")
-}
-
-// Settings Handler coverage
-
-func setupSettingsCoverageDB(t *testing.T) *gorm.DB {
- t.Helper()
- db := OpenTestDB(t)
- db.AutoMigrate(&models.Setting{})
- return db
-}
-
-func TestSettingsHandler_GetSettings_Error(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupSettingsCoverageDB(t)
-
- h := NewSettingsHandler(db)
-
- // Drop table to cause error
- db.Migrator().DropTable(&models.Setting{})
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("GET", "/settings", http.NoBody)
-
- h.GetSettings(c)
-
- assert.Equal(t, 500, w.Code)
- assert.Contains(t, w.Body.String(), "Failed to fetch settings")
-}
-
-func TestSettingsHandler_UpdateSetting_InvalidJSON(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupSettingsCoverageDB(t)
-
- h := NewSettingsHandler(db)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("PUT", "/settings/test", bytes.NewBufferString("invalid"))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.UpdateSetting(c)
-
- assert.Equal(t, 400, w.Code)
-}
-
-// Additional remote server TestConnection tests
-
-func TestRemoteServerHandler_TestConnection_Reachable(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupRemoteServerCoverageDB2(t)
- svc := services.NewRemoteServerService(db)
- h := NewRemoteServerHandler(svc, nil)
-
- // Use localhost which should be reachable
- server := &models.RemoteServer{
- Name: "LocalTest",
- Host: "127.0.0.1",
- Port: 22, // SSH port typically listening on localhost
- }
- svc.Create(server)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Params = gin.Params{{Key: "uuid", Value: server.UUID}}
-
- h.TestConnection(c)
-
- // Should return 200 regardless of whether port is open
- assert.Equal(t, 200, w.Code)
-}
-
-func TestRemoteServerHandler_TestConnection_EmptyHost(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupRemoteServerCoverageDB2(t)
- svc := services.NewRemoteServerService(db)
- h := NewRemoteServerHandler(svc, nil)
-
- // Create server with empty host
- server := &models.RemoteServer{
- Name: "Empty",
- Host: "",
- Port: 22,
- }
- db.Create(server)
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Params = gin.Params{{Key: "uuid", Value: server.UUID}}
-
- h.TestConnection(c)
-
- // Should return 200 - empty host resolves to localhost on some systems
- assert.Equal(t, 200, w.Code)
- assert.Contains(t, w.Body.String(), `"reachable":`)
-}
-
-// Additional UploadMulti test with valid Caddyfile content
-
-func TestImportHandler_UploadMulti_ValidCaddyfile(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupImportCoverageDB(t)
-
- h := NewImportHandler(db, "", t.TempDir(), "")
-
- body, _ := json.Marshal(map[string]interface{}{
- "files": []map[string]string{
- {"filename": "Caddyfile", "content": "example.com { reverse_proxy localhost:8080 }"},
- },
- })
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.UploadMulti(c)
-
- // Without caddy binary, will fail with 400 at adapt step - that's fine, we hit the code path
- // We just verify we got a response (not a panic)
- assert.True(t, w.Code == 200 || w.Code == 400, "Should return valid HTTP response")
-}
-
-func TestImportHandler_UploadMulti_SubdirFile(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupImportCoverageDB(t)
-
- h := NewImportHandler(db, "", t.TempDir(), "")
-
- body, _ := json.Marshal(map[string]interface{}{
- "files": []map[string]string{
- {"filename": "Caddyfile", "content": "import sites/*"},
- {"filename": "sites/example.com", "content": "example.com {}"},
- },
- })
-
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- c.Request = httptest.NewRequest("POST", "/import/upload-multi", bytes.NewBuffer(body))
- c.Request.Header.Set("Content-Type", "application/json")
-
- h.UploadMulti(c)
-
- // Should process the subdirectory file
- // Just verify it doesn't crash
- assert.True(t, w.Code == 200 || w.Code == 400)
-}
diff --git a/backend/internal/api/handlers/auth_handler.go b/backend/internal/api/handlers/auth_handler.go
deleted file mode 100644
index 19727cda..00000000
--- a/backend/internal/api/handlers/auth_handler.go
+++ /dev/null
@@ -1,378 +0,0 @@
-package handlers
-
-import (
- "net/http"
- "os"
- "strconv"
- "strings"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-)
-
-type AuthHandler struct {
- authService *services.AuthService
- db *gorm.DB
-}
-
-func NewAuthHandler(authService *services.AuthService) *AuthHandler {
- return &AuthHandler{authService: authService}
-}
-
-// NewAuthHandlerWithDB creates an AuthHandler with database access for forward auth.
-func NewAuthHandlerWithDB(authService *services.AuthService, db *gorm.DB) *AuthHandler {
- return &AuthHandler{authService: authService, db: db}
-}
-
-// isProduction checks if we're running in production mode
-func isProduction() bool {
- env := os.Getenv("CHARON_ENV")
- return env == "production" || env == "prod"
-}
-
-// setSecureCookie sets an auth cookie with security best practices
-// - HttpOnly: prevents JavaScript access (XSS protection)
-// - Secure: only sent over HTTPS (in production)
-// - SameSite=Strict: prevents CSRF attacks
-func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
- secure := isProduction()
- sameSite := http.SameSiteStrictMode
-
- // Use the host without port for domain
- domain := ""
-
- c.SetSameSite(sameSite)
- c.SetCookie(
- name, // name
- value, // value
- maxAge, // maxAge in seconds
- "/", // path
- domain, // domain (empty = current host)
- secure, // secure (HTTPS only in production)
- true, // httpOnly (no JS access)
- )
-}
-
-// clearSecureCookie removes a cookie with the same security settings
-func clearSecureCookie(c *gin.Context, name string) {
- setSecureCookie(c, name, "", -1)
-}
-
-type LoginRequest struct {
- Email string `json:"email" binding:"required,email"`
- Password string `json:"password" binding:"required"`
-}
-
-func (h *AuthHandler) Login(c *gin.Context) {
- var req LoginRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- token, err := h.authService.Login(req.Email, req.Password)
- if err != nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
- return
- }
-
- // Set secure cookie (HttpOnly, Secure in prod, SameSite=Strict)
- setSecureCookie(c, "auth_token", token, 3600*24)
-
- c.JSON(http.StatusOK, gin.H{"token": token})
-}
-
-type RegisterRequest struct {
- Email string `json:"email" binding:"required,email"`
- Password string `json:"password" binding:"required,min=8"`
- Name string `json:"name" binding:"required"`
-}
-
-func (h *AuthHandler) Register(c *gin.Context) {
- var req RegisterRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- user, err := h.authService.Register(req.Email, req.Password, req.Name)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusCreated, user)
-}
-
-func (h *AuthHandler) Logout(c *gin.Context) {
- clearSecureCookie(c, "auth_token")
- c.JSON(http.StatusOK, gin.H{"message": "Logged out"})
-}
-
-func (h *AuthHandler) Me(c *gin.Context) {
- userID, _ := c.Get("userID")
- role, _ := c.Get("role")
-
- u, err := h.authService.GetUserByID(userID.(uint))
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "user_id": userID,
- "role": role,
- "name": u.Name,
- "email": u.Email,
- })
-}
-
-type ChangePasswordRequest struct {
- OldPassword string `json:"old_password" binding:"required"`
- NewPassword string `json:"new_password" binding:"required,min=8"`
-}
-
-func (h *AuthHandler) ChangePassword(c *gin.Context) {
- var req ChangePasswordRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- userID, exists := c.Get("userID")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
- return
- }
-
- if err := h.authService.ChangePassword(userID.(uint), req.OldPassword, req.NewPassword); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"})
-}
-
-// Verify is the forward auth endpoint for Caddy.
-// It validates the user's session and checks access permissions for the requested host.
-// Used by Caddy's forward_auth directive.
-//
-// Expected headers from Caddy:
-// - X-Forwarded-Host: The original host being accessed
-// - X-Forwarded-Uri: The original URI being accessed
-//
-// Response headers on success (200):
-// - X-Forwarded-User: The user's email
-// - X-Forwarded-Groups: The user's role (for future RBAC)
-//
-// Response on failure:
-// - 401: Not authenticated (redirect to login)
-// - 403: Authenticated but not authorized for this host
-func (h *AuthHandler) Verify(c *gin.Context) {
- // Extract token from cookie or Authorization header
- var tokenString string
-
- // Try cookie first (most common for browser requests)
- if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" {
- tokenString = cookie
- }
-
- // Fall back to Authorization header
- if tokenString == "" {
- authHeader := c.GetHeader("Authorization")
- if strings.HasPrefix(authHeader, "Bearer ") {
- tokenString = strings.TrimPrefix(authHeader, "Bearer ")
- }
- }
-
- // No token found - not authenticated
- if tokenString == "" {
- c.Header("X-Auth-Redirect", "/login")
- c.AbortWithStatus(http.StatusUnauthorized)
- return
- }
-
- // Validate token
- claims, err := h.authService.ValidateToken(tokenString)
- if err != nil {
- c.Header("X-Auth-Redirect", "/login")
- c.AbortWithStatus(http.StatusUnauthorized)
- return
- }
-
- // Get user details
- user, err := h.authService.GetUserByID(claims.UserID)
- if err != nil || !user.Enabled {
- c.Header("X-Auth-Redirect", "/login")
- c.AbortWithStatus(http.StatusUnauthorized)
- return
- }
-
- // Get the forwarded host from Caddy
- forwardedHost := c.GetHeader("X-Forwarded-Host")
- if forwardedHost == "" {
- forwardedHost = c.GetHeader("X-Original-Host")
- }
-
- // If we have a database reference and a forwarded host, check permissions
- if h.db != nil && forwardedHost != "" {
- // Find the proxy host for this domain
- var proxyHost models.ProxyHost
- err := h.db.Where("domain_names LIKE ?", "%"+forwardedHost+"%").First(&proxyHost).Error
-
- if err == nil && proxyHost.ForwardAuthEnabled {
- // Load user's permitted hosts for permission check
- var userWithHosts models.User
- if err := h.db.Preload("PermittedHosts").First(&userWithHosts, user.ID).Error; err == nil {
- // Check if user can access this host
- if !userWithHosts.CanAccessHost(proxyHost.ID) {
- c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
- "error": "Access denied to this application",
- })
- return
- }
- }
- }
- }
-
- // Set headers for downstream services
- c.Header("X-Forwarded-User", user.Email)
- c.Header("X-Forwarded-Groups", user.Role)
- c.Header("X-Forwarded-Name", user.Name)
-
- // Return 200 OK - access granted
- c.Status(http.StatusOK)
-}
-
-// VerifyStatus returns the current auth status without triggering a redirect.
-// Useful for frontend to check if user is logged in.
-func (h *AuthHandler) VerifyStatus(c *gin.Context) {
- // Extract token
- var tokenString string
-
- if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" {
- tokenString = cookie
- }
-
- if tokenString == "" {
- authHeader := c.GetHeader("Authorization")
- if strings.HasPrefix(authHeader, "Bearer ") {
- tokenString = strings.TrimPrefix(authHeader, "Bearer ")
- }
- }
-
- if tokenString == "" {
- c.JSON(http.StatusOK, gin.H{
- "authenticated": false,
- })
- return
- }
-
- claims, err := h.authService.ValidateToken(tokenString)
- if err != nil {
- c.JSON(http.StatusOK, gin.H{
- "authenticated": false,
- })
- return
- }
-
- user, err := h.authService.GetUserByID(claims.UserID)
- if err != nil || !user.Enabled {
- c.JSON(http.StatusOK, gin.H{
- "authenticated": false,
- })
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "authenticated": true,
- "user": gin.H{
- "id": user.ID,
- "email": user.Email,
- "name": user.Name,
- "role": user.Role,
- },
- })
-}
-
-// GetAccessibleHosts returns the list of proxy hosts the authenticated user can access.
-func (h *AuthHandler) GetAccessibleHosts(c *gin.Context) {
- userID, exists := c.Get("userID")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
- return
- }
-
- if h.db == nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Database not available"})
- return
- }
-
- // Load user with permitted hosts
- var user models.User
- if err := h.db.Preload("PermittedHosts").First(&user, userID).Error; err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
- return
- }
-
- // Get all enabled proxy hosts
- var allHosts []models.ProxyHost
- if err := h.db.Where("enabled = ?", true).Find(&allHosts).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch hosts"})
- return
- }
-
- // Filter to accessible hosts
- accessibleHosts := make([]gin.H, 0)
- for _, host := range allHosts {
- if user.CanAccessHost(host.ID) {
- accessibleHosts = append(accessibleHosts, gin.H{
- "id": host.ID,
- "name": host.Name,
- "domain_names": host.DomainNames,
- })
- }
- }
-
- c.JSON(http.StatusOK, gin.H{
- "hosts": accessibleHosts,
- "permission_mode": user.PermissionMode,
- })
-}
-
-// CheckHostAccess checks if the current user can access a specific host.
-func (h *AuthHandler) CheckHostAccess(c *gin.Context) {
- userID, exists := c.Get("userID")
- if !exists {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
- return
- }
-
- hostIDStr := c.Param("hostId")
- hostID, err := strconv.ParseUint(hostIDStr, 10, 32)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid host ID"})
- return
- }
-
- if h.db == nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Database not available"})
- return
- }
-
- // Load user with permitted hosts
- var user models.User
- if err := h.db.Preload("PermittedHosts").First(&user, userID).Error; err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "User not found"})
- return
- }
-
- canAccess := user.CanAccessHost(uint(hostID))
-
- c.JSON(http.StatusOK, gin.H{
- "host_id": hostID,
- "can_access": canAccess,
- })
-}
diff --git a/backend/internal/api/handlers/auth_handler_test.go b/backend/internal/api/handlers/auth_handler_test.go
deleted file mode 100644
index 77340c13..00000000
--- a/backend/internal/api/handlers/auth_handler_test.go
+++ /dev/null
@@ -1,807 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/Wikid82/charon/backend/internal/config"
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
-)
-
-func setupAuthHandler(t *testing.T) (*AuthHandler, *gorm.DB) {
- dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
- db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
- require.NoError(t, err)
- db.AutoMigrate(&models.User{}, &models.Setting{})
-
- cfg := config.Config{JWTSecret: "test-secret"}
- authService := services.NewAuthService(db, cfg)
- return NewAuthHandler(authService), db
-}
-
-func TestAuthHandler_Login(t *testing.T) {
- handler, db := setupAuthHandler(t)
-
- // Create user
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "test@example.com",
- Name: "Test User",
- }
- user.SetPassword("password123")
- db.Create(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.POST("/login", handler.Login)
-
- // Success
- body := map[string]string{
- "email": "test@example.com",
- "password": "password123",
- }
- jsonBody, _ := json.Marshal(body)
- req := httptest.NewRequest("POST", "/login", bytes.NewBuffer(jsonBody))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- assert.Contains(t, w.Body.String(), "token")
-}
-
-func TestAuthHandler_Login_Errors(t *testing.T) {
- handler, _ := setupAuthHandler(t)
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.POST("/login", handler.Login)
-
- // 1. Invalid JSON
- req := httptest.NewRequest("POST", "/login", bytes.NewBufferString("invalid"))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
- assert.Equal(t, http.StatusBadRequest, w.Code)
-
- // 2. Invalid Credentials
- body := map[string]string{
- "email": "nonexistent@example.com",
- "password": "wrong",
- }
- jsonBody, _ := json.Marshal(body)
- req = httptest.NewRequest("POST", "/login", bytes.NewBuffer(jsonBody))
- req.Header.Set("Content-Type", "application/json")
- w = httptest.NewRecorder()
- r.ServeHTTP(w, req)
- assert.Equal(t, http.StatusUnauthorized, w.Code)
-}
-
-func TestAuthHandler_Register(t *testing.T) {
- handler, _ := setupAuthHandler(t)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.POST("/register", handler.Register)
-
- body := map[string]string{
- "email": "new@example.com",
- "password": "password123",
- "name": "New User",
- }
- jsonBody, _ := json.Marshal(body)
- req := httptest.NewRequest("POST", "/register", bytes.NewBuffer(jsonBody))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusCreated, w.Code)
- assert.Contains(t, w.Body.String(), "new@example.com")
-}
-
-func TestAuthHandler_Register_Duplicate(t *testing.T) {
- handler, db := setupAuthHandler(t)
- db.Create(&models.User{UUID: uuid.NewString(), Email: "dup@example.com", Name: "Dup"})
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.POST("/register", handler.Register)
-
- body := map[string]string{
- "email": "dup@example.com",
- "password": "password123",
- "name": "Dup User",
- }
- jsonBody, _ := json.Marshal(body)
- req := httptest.NewRequest("POST", "/register", bytes.NewBuffer(jsonBody))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusInternalServerError, w.Code)
-}
-
-func TestAuthHandler_Logout(t *testing.T) {
- handler, _ := setupAuthHandler(t)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.POST("/logout", handler.Logout)
-
- req := httptest.NewRequest("POST", "/logout", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- assert.Contains(t, w.Body.String(), "Logged out")
- // Check cookie
- cookie := w.Result().Cookies()[0]
- assert.Equal(t, "auth_token", cookie.Name)
- assert.Equal(t, -1, cookie.MaxAge)
-}
-
-func TestAuthHandler_Me(t *testing.T) {
- handler, db := setupAuthHandler(t)
-
- // Create user that matches the middleware ID
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "me@example.com",
- Name: "Me User",
- Role: "admin",
- }
- db.Create(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- // Simulate middleware
- r.Use(func(c *gin.Context) {
- c.Set("userID", user.ID)
- c.Set("role", user.Role)
- c.Next()
- })
- r.GET("/me", handler.Me)
-
- req := httptest.NewRequest("GET", "/me", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- assert.Equal(t, float64(user.ID), resp["user_id"])
- assert.Equal(t, "admin", resp["role"])
- assert.Equal(t, "Me User", resp["name"])
- assert.Equal(t, "me@example.com", resp["email"])
-}
-
-func TestAuthHandler_Me_NotFound(t *testing.T) {
- handler, _ := setupAuthHandler(t)
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(func(c *gin.Context) {
- c.Set("userID", uint(999)) // Non-existent ID
- c.Next()
- })
- r.GET("/me", handler.Me)
-
- req := httptest.NewRequest("GET", "/me", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusNotFound, w.Code)
-}
-
-func TestAuthHandler_ChangePassword(t *testing.T) {
- handler, db := setupAuthHandler(t)
-
- // Create user
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "change@example.com",
- Name: "Change User",
- }
- user.SetPassword("oldpassword")
- db.Create(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- // Simulate middleware
- r.Use(func(c *gin.Context) {
- c.Set("userID", user.ID)
- c.Next()
- })
- r.POST("/change-password", handler.ChangePassword)
-
- body := map[string]string{
- "old_password": "oldpassword",
- "new_password": "newpassword123",
- }
- jsonBody, _ := json.Marshal(body)
- req := httptest.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- assert.Contains(t, w.Body.String(), "Password updated successfully")
-
- // Verify password changed
- var updatedUser models.User
- db.First(&updatedUser, user.ID)
- assert.True(t, updatedUser.CheckPassword("newpassword123"))
-}
-
-func TestAuthHandler_ChangePassword_WrongOld(t *testing.T) {
- handler, db := setupAuthHandler(t)
- user := &models.User{UUID: uuid.NewString(), Email: "wrong@example.com"}
- user.SetPassword("correct")
- db.Create(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(func(c *gin.Context) {
- c.Set("userID", user.ID)
- c.Next()
- })
- r.POST("/change-password", handler.ChangePassword)
-
- body := map[string]string{
- "old_password": "wrong",
- "new_password": "newpassword",
- }
- jsonBody, _ := json.Marshal(body)
- req := httptest.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestAuthHandler_ChangePassword_Errors(t *testing.T) {
- handler, _ := setupAuthHandler(t)
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.POST("/change-password", handler.ChangePassword)
-
- // 1. BindJSON error (checked before auth)
- req, _ := http.NewRequest("POST", "/change-password", bytes.NewBufferString("invalid json"))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
- assert.Equal(t, http.StatusBadRequest, w.Code)
-
- // 2. Unauthorized (valid JSON but no user in context)
- body := map[string]string{
- "old_password": "oldpassword",
- "new_password": "newpassword123",
- }
- jsonBody, _ := json.Marshal(body)
- req, _ = http.NewRequest("POST", "/change-password", bytes.NewBuffer(jsonBody))
- req.Header.Set("Content-Type", "application/json")
- w = httptest.NewRecorder()
- r.ServeHTTP(w, req)
- assert.Equal(t, http.StatusUnauthorized, w.Code)
-}
-
-// setupAuthHandlerWithDB creates an AuthHandler with DB access for forward auth tests
-func setupAuthHandlerWithDB(t *testing.T) (*AuthHandler, *gorm.DB) {
- dbName := "file:" + t.Name() + "?mode=memory&cache=shared"
- db, err := gorm.Open(sqlite.Open(dbName), &gorm.Config{})
- require.NoError(t, err)
- db.AutoMigrate(&models.User{}, &models.Setting{}, &models.ProxyHost{})
-
- cfg := config.Config{JWTSecret: "test-secret"}
- authService := services.NewAuthService(db, cfg)
- return NewAuthHandlerWithDB(authService, db), db
-}
-
-func TestNewAuthHandlerWithDB(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
- assert.NotNil(t, handler)
- assert.NotNil(t, handler.db)
- assert.NotNil(t, db)
-}
-
-func TestAuthHandler_Verify_NoCookie(t *testing.T) {
- handler, _ := setupAuthHandlerWithDB(t)
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/verify", handler.Verify)
-
- req := httptest.NewRequest("GET", "/verify", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusUnauthorized, w.Code)
- assert.Equal(t, "/login", w.Header().Get("X-Auth-Redirect"))
-}
-
-func TestAuthHandler_Verify_InvalidToken(t *testing.T) {
- handler, _ := setupAuthHandlerWithDB(t)
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/verify", handler.Verify)
-
- req := httptest.NewRequest("GET", "/verify", http.NoBody)
- req.AddCookie(&http.Cookie{Name: "auth_token", Value: "invalid-token"})
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusUnauthorized, w.Code)
-}
-
-func TestAuthHandler_Verify_ValidToken(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
-
- // Create user
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "test@example.com",
- Name: "Test User",
- Role: "user",
- Enabled: true,
- }
- user.SetPassword("password123")
- db.Create(user)
-
- // Generate token
- token, _ := handler.authService.GenerateToken(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/verify", handler.Verify)
-
- req := httptest.NewRequest("GET", "/verify", http.NoBody)
- req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- assert.Equal(t, "test@example.com", w.Header().Get("X-Forwarded-User"))
- assert.Equal(t, "user", w.Header().Get("X-Forwarded-Groups"))
-}
-
-func TestAuthHandler_Verify_BearerToken(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
-
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "bearer@example.com",
- Name: "Bearer User",
- Role: "admin",
- Enabled: true,
- }
- user.SetPassword("password123")
- db.Create(user)
-
- token, _ := handler.authService.GenerateToken(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/verify", handler.Verify)
-
- req := httptest.NewRequest("GET", "/verify", http.NoBody)
- req.Header.Set("Authorization", "Bearer "+token)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- assert.Equal(t, "bearer@example.com", w.Header().Get("X-Forwarded-User"))
-}
-
-func TestAuthHandler_Verify_DisabledUser(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
-
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "disabled@example.com",
- Name: "Disabled User",
- Role: "user",
- }
- user.SetPassword("password123")
- db.Create(user)
- // Explicitly disable after creation to bypass GORM's default:true behavior
- db.Model(user).Update("enabled", false)
-
- token, _ := handler.authService.GenerateToken(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/verify", handler.Verify)
-
- req := httptest.NewRequest("GET", "/verify", http.NoBody)
- req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusUnauthorized, w.Code)
-}
-
-func TestAuthHandler_Verify_ForwardAuthDenied(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
-
- // Create proxy host with forward auth enabled
- proxyHost := &models.ProxyHost{
- UUID: uuid.NewString(),
- Name: "Protected App",
- DomainNames: "app.example.com",
- ForwardAuthEnabled: true,
- Enabled: true,
- }
- db.Create(proxyHost)
-
- // Create user with deny_all permission
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "denied@example.com",
- Name: "Denied User",
- Role: "user",
- Enabled: true,
- PermissionMode: models.PermissionModeDenyAll,
- }
- user.SetPassword("password123")
- db.Create(user)
-
- token, _ := handler.authService.GenerateToken(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/verify", handler.Verify)
-
- req := httptest.NewRequest("GET", "/verify", http.NoBody)
- req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
- req.Header.Set("X-Forwarded-Host", "app.example.com")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusForbidden, w.Code)
-}
-
-func TestAuthHandler_VerifyStatus_NotAuthenticated(t *testing.T) {
- handler, _ := setupAuthHandlerWithDB(t)
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/status", handler.VerifyStatus)
-
- req := httptest.NewRequest("GET", "/status", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- assert.Equal(t, false, resp["authenticated"])
-}
-
-func TestAuthHandler_VerifyStatus_InvalidToken(t *testing.T) {
- handler, _ := setupAuthHandlerWithDB(t)
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/status", handler.VerifyStatus)
-
- req := httptest.NewRequest("GET", "/status", http.NoBody)
- req.AddCookie(&http.Cookie{Name: "auth_token", Value: "invalid"})
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- assert.Equal(t, false, resp["authenticated"])
-}
-
-func TestAuthHandler_VerifyStatus_Authenticated(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
-
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "status@example.com",
- Name: "Status User",
- Role: "user",
- Enabled: true,
- }
- user.SetPassword("password123")
- db.Create(user)
-
- token, _ := handler.authService.GenerateToken(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/status", handler.VerifyStatus)
-
- req := httptest.NewRequest("GET", "/status", http.NoBody)
- req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- assert.Equal(t, true, resp["authenticated"])
- userObj := resp["user"].(map[string]interface{})
- assert.Equal(t, "status@example.com", userObj["email"])
-}
-
-func TestAuthHandler_VerifyStatus_DisabledUser(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
-
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "disabled2@example.com",
- Name: "Disabled User 2",
- Role: "user",
- }
- user.SetPassword("password123")
- db.Create(user)
- // Explicitly disable after creation to bypass GORM's default:true behavior
- db.Model(user).Update("enabled", false)
-
- token, _ := handler.authService.GenerateToken(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/status", handler.VerifyStatus)
-
- req := httptest.NewRequest("GET", "/status", http.NoBody)
- req.AddCookie(&http.Cookie{Name: "auth_token", Value: token})
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- assert.Equal(t, false, resp["authenticated"])
-}
-
-func TestAuthHandler_GetAccessibleHosts_Unauthorized(t *testing.T) {
- handler, _ := setupAuthHandlerWithDB(t)
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/hosts", handler.GetAccessibleHosts)
-
- req := httptest.NewRequest("GET", "/hosts", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusUnauthorized, w.Code)
-}
-
-func TestAuthHandler_GetAccessibleHosts_AllowAll(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
-
- // Create proxy hosts
- host1 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 1", DomainNames: "host1.example.com", Enabled: true}
- host2 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 2", DomainNames: "host2.example.com", Enabled: true}
- db.Create(host1)
- db.Create(host2)
-
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "allowall@example.com",
- Name: "Allow All User",
- Role: "user",
- Enabled: true,
- PermissionMode: models.PermissionModeAllowAll,
- }
- db.Create(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(func(c *gin.Context) {
- c.Set("userID", user.ID)
- c.Next()
- })
- r.GET("/hosts", handler.GetAccessibleHosts)
-
- req := httptest.NewRequest("GET", "/hosts", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- hosts := resp["hosts"].([]interface{})
- assert.Len(t, hosts, 2)
-}
-
-func TestAuthHandler_GetAccessibleHosts_DenyAll(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
-
- // Create proxy hosts
- host1 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 1", DomainNames: "host1.example.com", Enabled: true}
- db.Create(host1)
-
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "denyall@example.com",
- Name: "Deny All User",
- Role: "user",
- Enabled: true,
- PermissionMode: models.PermissionModeDenyAll,
- }
- db.Create(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(func(c *gin.Context) {
- c.Set("userID", user.ID)
- c.Next()
- })
- r.GET("/hosts", handler.GetAccessibleHosts)
-
- req := httptest.NewRequest("GET", "/hosts", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- hosts := resp["hosts"].([]interface{})
- assert.Len(t, hosts, 0)
-}
-
-func TestAuthHandler_GetAccessibleHosts_PermittedHosts(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
-
- // Create proxy hosts
- host1 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 1", DomainNames: "host1.example.com", Enabled: true}
- host2 := &models.ProxyHost{UUID: uuid.NewString(), Name: "Host 2", DomainNames: "host2.example.com", Enabled: true}
- db.Create(host1)
- db.Create(host2)
-
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "permitted@example.com",
- Name: "Permitted User",
- Role: "user",
- Enabled: true,
- PermissionMode: models.PermissionModeDenyAll,
- PermittedHosts: []models.ProxyHost{*host1}, // Only host1
- }
- db.Create(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(func(c *gin.Context) {
- c.Set("userID", user.ID)
- c.Next()
- })
- r.GET("/hosts", handler.GetAccessibleHosts)
-
- req := httptest.NewRequest("GET", "/hosts", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- hosts := resp["hosts"].([]interface{})
- assert.Len(t, hosts, 1)
-}
-
-func TestAuthHandler_GetAccessibleHosts_UserNotFound(t *testing.T) {
- handler, _ := setupAuthHandlerWithDB(t)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(func(c *gin.Context) {
- c.Set("userID", uint(99999))
- c.Next()
- })
- r.GET("/hosts", handler.GetAccessibleHosts)
-
- req := httptest.NewRequest("GET", "/hosts", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusNotFound, w.Code)
-}
-
-func TestAuthHandler_CheckHostAccess_Unauthorized(t *testing.T) {
- handler, _ := setupAuthHandlerWithDB(t)
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/hosts/:hostId/access", handler.CheckHostAccess)
-
- req := httptest.NewRequest("GET", "/hosts/1/access", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusUnauthorized, w.Code)
-}
-
-func TestAuthHandler_CheckHostAccess_InvalidHostID(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
-
- user := &models.User{UUID: uuid.NewString(), Email: "check@example.com", Enabled: true}
- db.Create(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(func(c *gin.Context) {
- c.Set("userID", user.ID)
- c.Next()
- })
- r.GET("/hosts/:hostId/access", handler.CheckHostAccess)
-
- req := httptest.NewRequest("GET", "/hosts/invalid/access", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestAuthHandler_CheckHostAccess_Allowed(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
-
- host := &models.ProxyHost{UUID: uuid.NewString(), Name: "Test Host", DomainNames: "test.example.com", Enabled: true}
- db.Create(host)
-
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "checkallowed@example.com",
- Enabled: true,
- PermissionMode: models.PermissionModeAllowAll,
- }
- db.Create(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(func(c *gin.Context) {
- c.Set("userID", user.ID)
- c.Next()
- })
- r.GET("/hosts/:hostId/access", handler.CheckHostAccess)
-
- req := httptest.NewRequest("GET", "/hosts/1/access", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- assert.Equal(t, true, resp["can_access"])
-}
-
-func TestAuthHandler_CheckHostAccess_Denied(t *testing.T) {
- handler, db := setupAuthHandlerWithDB(t)
-
- host := &models.ProxyHost{UUID: uuid.NewString(), Name: "Protected Host", DomainNames: "protected.example.com", Enabled: true}
- db.Create(host)
-
- user := &models.User{
- UUID: uuid.NewString(),
- Email: "checkdenied@example.com",
- Enabled: true,
- PermissionMode: models.PermissionModeDenyAll,
- }
- db.Create(user)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(func(c *gin.Context) {
- c.Set("userID", user.ID)
- c.Next()
- })
- r.GET("/hosts/:hostId/access", handler.CheckHostAccess)
-
- req := httptest.NewRequest("GET", "/hosts/1/access", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- assert.Equal(t, false, resp["can_access"])
-}
diff --git a/backend/internal/api/handlers/backup_handler.go b/backend/internal/api/handlers/backup_handler.go
deleted file mode 100644
index 52af03d0..00000000
--- a/backend/internal/api/handlers/backup_handler.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package handlers
-
-import (
- "net/http"
- "os"
- "path/filepath"
-
- "github.com/Wikid82/charon/backend/internal/api/middleware"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/Wikid82/charon/backend/internal/util"
- "github.com/gin-gonic/gin"
-)
-
-type BackupHandler struct {
- service *services.BackupService
-}
-
-func NewBackupHandler(service *services.BackupService) *BackupHandler {
- return &BackupHandler{service: service}
-}
-
-func (h *BackupHandler) List(c *gin.Context) {
- backups, err := h.service.ListBackups()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list backups"})
- return
- }
- c.JSON(http.StatusOK, backups)
-}
-
-func (h *BackupHandler) Create(c *gin.Context) {
- filename, err := h.service.CreateBackup()
- if err != nil {
- middleware.GetRequestLogger(c).WithField("action", "create_backup").WithError(err).Error("Failed to create backup")
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create backup: " + err.Error()})
- return
- }
- middleware.GetRequestLogger(c).WithField("action", "create_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).Info("Backup created successfully")
- c.JSON(http.StatusCreated, gin.H{"filename": filename, "message": "Backup created successfully"})
-}
-
-func (h *BackupHandler) Delete(c *gin.Context) {
- filename := c.Param("filename")
- if err := h.service.DeleteBackup(filename); err != nil {
- if os.IsNotExist(err) {
- c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete backup"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "Backup deleted"})
-}
-
-func (h *BackupHandler) Download(c *gin.Context) {
- filename := c.Param("filename")
- path, err := h.service.GetBackupPath(filename)
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- if _, err := os.Stat(path); os.IsNotExist(err) {
- c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
- return
- }
-
- c.Header("Content-Disposition", "attachment; filename="+filename)
- c.File(path)
-}
-
-func (h *BackupHandler) Restore(c *gin.Context) {
- filename := c.Param("filename")
- if err := h.service.RestoreBackup(filename); err != nil {
- middleware.GetRequestLogger(c).WithField("action", "restore_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).WithError(err).Error("Failed to restore backup")
- if os.IsNotExist(err) {
- c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to restore backup: " + err.Error()})
- return
- }
- middleware.GetRequestLogger(c).WithField("action", "restore_backup").WithField("filename", util.SanitizeForLog(filepath.Base(filename))).Info("Backup restored successfully")
- // In a real scenario, we might want to trigger a restart here
- c.JSON(http.StatusOK, gin.H{"message": "Backup restored successfully. Please restart the container."})
-}
diff --git a/backend/internal/api/handlers/backup_handler_sanitize_test.go b/backend/internal/api/handlers/backup_handler_sanitize_test.go
deleted file mode 100644
index ecfb1fec..00000000
--- a/backend/internal/api/handlers/backup_handler_sanitize_test.go
+++ /dev/null
@@ -1,65 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "regexp"
- "strings"
- "testing"
-
- "github.com/Wikid82/charon/backend/internal/logger"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
-)
-
-func TestBackupHandlerSanitizesFilename(t *testing.T) {
- gin.SetMode(gin.TestMode)
- tmpDir := t.TempDir()
- // prepare a fake "database"
- dbPath := filepath.Join(tmpDir, "db.sqlite")
- if err := os.WriteFile(dbPath, []byte("db"), 0o644); err != nil {
- t.Fatalf("failed to create tmp db: %v", err)
- }
-
- svc := &services.BackupService{DataDir: tmpDir, BackupDir: tmpDir, DatabaseName: "db.sqlite", Cron: nil}
- h := NewBackupHandler(svc)
-
- // Create a gin test context and use it to call handler directly
- w := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(w)
- // Ensure request-scoped logger is present and writes to our buffer
- c.Set("logger", logger.WithFields(map[string]interface{}{"test": "1"}))
-
- // initialize logger to buffer
- buf := &bytes.Buffer{}
- logger.Init(true, buf)
-
- // Create a malicious filename with newline and path components
- malicious := "../evil\nname"
- c.Request = httptest.NewRequest(http.MethodGet, "/backups/"+strings.ReplaceAll(malicious, "\n", "%0A")+"/restore", http.NoBody)
- // Call handler directly with the test context
- h.Restore(c)
-
- out := buf.String()
- // Optionally we could assert on the response status code here if needed
- textRegex := regexp.MustCompile(`filename=?"?([^"\s]*)"?`)
- jsonRegex := regexp.MustCompile(`"filename":"([^"]*)"`)
- var loggedFilename string
- if m := textRegex.FindStringSubmatch(out); len(m) == 2 {
- loggedFilename = m[1]
- } else if m := jsonRegex.FindStringSubmatch(out); len(m) == 2 {
- loggedFilename = m[1]
- } else {
- t.Fatalf("could not extract filename from logs: %s", out)
- }
-
- if strings.Contains(loggedFilename, "\n") || strings.Contains(loggedFilename, "\r") {
- t.Fatalf("log filename contained raw newline: %q", loggedFilename)
- }
- if strings.Contains(loggedFilename, "..") {
- t.Fatalf("log filename contained path traversals in filename: %q", loggedFilename)
- }
-}
diff --git a/backend/internal/api/handlers/backup_handler_test.go b/backend/internal/api/handlers/backup_handler_test.go
deleted file mode 100644
index 5daa4f37..00000000
--- a/backend/internal/api/handlers/backup_handler_test.go
+++ /dev/null
@@ -1,330 +0,0 @@
-package handlers
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
-
- "github.com/Wikid82/charon/backend/internal/config"
- "github.com/Wikid82/charon/backend/internal/services"
-)
-
-func setupBackupTest(t *testing.T) (*gin.Engine, *services.BackupService, string) {
- t.Helper()
-
- // Create temp directories
- tmpDir, err := os.MkdirTemp("", "cpm-backup-test")
- require.NoError(t, err)
-
- // Structure: tmpDir/data/charon.db
- // BackupService expects DatabasePath to be .../data/charon.db
- // It sets DataDir to filepath.Dir(DatabasePath) -> .../data
- // It sets BackupDir to .../data/backups (Wait, let me check the code again)
-
- // Code: backupDir := filepath.Join(filepath.Dir(cfg.DatabasePath), "backups")
- // So if DatabasePath is /tmp/data/charon.db, DataDir is /tmp/data, BackupDir is /tmp/data/backups.
-
- dataDir := filepath.Join(tmpDir, "data")
- err = os.MkdirAll(dataDir, 0o755)
- require.NoError(t, err)
-
- dbPath := filepath.Join(dataDir, "charon.db")
- // Create a dummy DB file to back up
- err = os.WriteFile(dbPath, []byte("dummy db content"), 0o644)
- require.NoError(t, err)
-
- cfg := &config.Config{
- DatabasePath: dbPath,
- }
-
- svc := services.NewBackupService(cfg)
- h := NewBackupHandler(svc)
-
- r := gin.New()
- api := r.Group("/api/v1")
- // Manually register routes since we don't have a RegisterRoutes method on the handler yet?
- // Wait, I didn't check if I added RegisterRoutes to BackupHandler.
- // In routes.go I did:
- // backupHandler := handlers.NewBackupHandler(backupService)
- // backups := api.Group("/backups")
- // backups.GET("", backupHandler.List)
- // ...
- // So the handler doesn't have RegisterRoutes. I'll register manually here.
-
- backups := api.Group("/backups")
- backups.GET("", h.List)
- backups.POST("", h.Create)
- backups.POST("/:filename/restore", h.Restore)
- backups.DELETE("/:filename", h.Delete)
- backups.GET("/:filename/download", h.Download)
-
- return r, svc, tmpDir
-}
-
-func TestBackupLifecycle(t *testing.T) {
- router, _, tmpDir := setupBackupTest(t)
- defer os.RemoveAll(tmpDir)
-
- // 1. List backups (should be empty)
- req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
- // Check empty list
- // ...
-
- // 2. Create backup
- req = httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusCreated, resp.Code)
-
- var result map[string]string
- err := json.Unmarshal(resp.Body.Bytes(), &result)
- require.NoError(t, err)
- filename := result["filename"]
- require.NotEmpty(t, filename)
-
- // 3. List backups (should have 1)
- req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
- // Verify list contains filename
-
- // 4. Restore backup
- req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/"+filename+"/restore", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
-
- // 5. Download backup
- req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/"+filename+"/download", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
- // Content-Type might vary depending on implementation (application/octet-stream or zip)
- // require.Equal(t, "application/zip", resp.Header().Get("Content-Type"))
-
- // 6. Delete backup
- req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/"+filename, http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
-
- // 7. List backups (should be empty again)
- req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
- var list []interface{}
- json.Unmarshal(resp.Body.Bytes(), &list)
- require.Empty(t, list)
-
- // 8. Delete non-existent backup
- req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusNotFound, resp.Code)
-
- // 9. Restore non-existent backup
- req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/missing.zip/restore", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusNotFound, resp.Code)
-
- // 10. Download non-existent backup
- req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/missing.zip/download", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusNotFound, resp.Code)
-}
-
-func TestBackupHandler_Errors(t *testing.T) {
- router, svc, tmpDir := setupBackupTest(t)
- defer os.RemoveAll(tmpDir)
-
- // 1. List Error (remove backup dir to cause ReadDir error)
- // Note: Service now handles missing dir gracefully by returning empty list
- os.RemoveAll(svc.BackupDir)
-
- req := httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
- var list []interface{}
- json.Unmarshal(resp.Body.Bytes(), &list)
- require.Empty(t, list)
-
- // 4. Delete Error (Not Found)
- req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/missing.zip", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusNotFound, resp.Code)
-}
-
-func TestBackupHandler_List_Success(t *testing.T) {
- router, _, tmpDir := setupBackupTest(t)
- defer os.RemoveAll(tmpDir)
-
- // Create a backup first
- req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusCreated, resp.Code)
-
- // Now list should return it
- req = httptest.NewRequest(http.MethodGet, "/api/v1/backups", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
-
- var backups []services.BackupFile
- err := json.Unmarshal(resp.Body.Bytes(), &backups)
- require.NoError(t, err)
- require.Len(t, backups, 1)
- require.Contains(t, backups[0].Filename, "backup_")
-}
-
-func TestBackupHandler_Create_Success(t *testing.T) {
- router, _, tmpDir := setupBackupTest(t)
- defer os.RemoveAll(tmpDir)
-
- req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusCreated, resp.Code)
-
- var result map[string]string
- json.Unmarshal(resp.Body.Bytes(), &result)
- require.NotEmpty(t, result["filename"])
- require.Contains(t, result["filename"], "backup_")
-}
-
-func TestBackupHandler_Download_Success(t *testing.T) {
- router, _, tmpDir := setupBackupTest(t)
- defer os.RemoveAll(tmpDir)
-
- // Create backup
- req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusCreated, resp.Code)
-
- var result map[string]string
- json.Unmarshal(resp.Body.Bytes(), &result)
- filename := result["filename"]
-
- // Download it
- req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/"+filename+"/download", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
- require.Contains(t, resp.Header().Get("Content-Type"), "application")
-}
-
-func TestBackupHandler_PathTraversal(t *testing.T) {
- router, _, tmpDir := setupBackupTest(t)
- defer os.RemoveAll(tmpDir)
-
- // Try path traversal in Delete
- req := httptest.NewRequest(http.MethodDelete, "/api/v1/backups/../../../etc/passwd", http.NoBody)
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusNotFound, resp.Code)
-
- // Try path traversal in Download
- req = httptest.NewRequest(http.MethodGet, "/api/v1/backups/../../../etc/passwd/download", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Contains(t, []int{http.StatusBadRequest, http.StatusNotFound}, resp.Code)
-
- // Try path traversal in Restore
- req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/../../../etc/passwd/restore", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusNotFound, resp.Code)
-}
-
-func TestBackupHandler_Download_InvalidPath(t *testing.T) {
- router, _, tmpDir := setupBackupTest(t)
- defer os.RemoveAll(tmpDir)
-
- // Request with path traversal attempt
- req := httptest.NewRequest(http.MethodGet, "/api/v1/backups/../invalid/download", http.NoBody)
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- // Should be BadRequest due to path validation failure
- require.Contains(t, []int{http.StatusBadRequest, http.StatusNotFound}, resp.Code)
-}
-
-func TestBackupHandler_Create_ServiceError(t *testing.T) {
- router, svc, tmpDir := setupBackupTest(t)
- defer os.RemoveAll(tmpDir)
-
- // Remove write permissions on backup dir to force create error
- os.Chmod(svc.BackupDir, 0o444)
- defer os.Chmod(svc.BackupDir, 0o755)
-
- req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- // Should fail with 500 due to permission error
- require.Contains(t, []int{http.StatusInternalServerError, http.StatusCreated}, resp.Code)
-}
-
-func TestBackupHandler_Delete_InternalError(t *testing.T) {
- router, svc, tmpDir := setupBackupTest(t)
- defer os.RemoveAll(tmpDir)
-
- // Create a backup first
- req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusCreated, resp.Code)
-
- var result map[string]string
- json.Unmarshal(resp.Body.Bytes(), &result)
- filename := result["filename"]
-
- // Make backup dir read-only to cause delete error (not NotExist)
- os.Chmod(svc.BackupDir, 0o444)
- defer os.Chmod(svc.BackupDir, 0o755)
-
- req = httptest.NewRequest(http.MethodDelete, "/api/v1/backups/"+filename, http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- // Should fail with 500 due to permission error (not 404)
- require.Contains(t, []int{http.StatusInternalServerError, http.StatusOK}, resp.Code)
-}
-
-func TestBackupHandler_Restore_InternalError(t *testing.T) {
- router, svc, tmpDir := setupBackupTest(t)
- defer os.RemoveAll(tmpDir)
-
- // Create a backup first
- req := httptest.NewRequest(http.MethodPost, "/api/v1/backups", http.NoBody)
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusCreated, resp.Code)
-
- var result map[string]string
- json.Unmarshal(resp.Body.Bytes(), &result)
- filename := result["filename"]
-
- // Make data dir read-only to cause restore error
- os.Chmod(svc.DataDir, 0o444)
- defer os.Chmod(svc.DataDir, 0o755)
-
- req = httptest.NewRequest(http.MethodPost, "/api/v1/backups/"+filename+"/restore", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- // Should fail with 500 due to permission error
- require.Contains(t, []int{http.StatusInternalServerError, http.StatusOK}, resp.Code)
-}
diff --git a/backend/internal/api/handlers/benchmark_test.go b/backend/internal/api/handlers/benchmark_test.go
deleted file mode 100644
index 1bee57d8..00000000
--- a/backend/internal/api/handlers/benchmark_test.go
+++ /dev/null
@@ -1,463 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
- "gorm.io/gorm/logger"
-
- "github.com/Wikid82/charon/backend/internal/config"
- "github.com/Wikid82/charon/backend/internal/models"
-)
-
-// setupBenchmarkDB creates an in-memory SQLite database for benchmarks
-func setupBenchmarkDB(b *testing.B) *gorm.DB {
- b.Helper()
- db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
- Logger: logger.Default.LogMode(logger.Silent),
- })
- if err != nil {
- b.Fatal(err)
- }
- if err := db.AutoMigrate(
- &models.SecurityConfig{},
- &models.SecurityRuleSet{},
- &models.SecurityDecision{},
- &models.SecurityAudit{},
- &models.Setting{},
- &models.ProxyHost{},
- &models.AccessList{},
- &models.User{},
- ); err != nil {
- b.Fatal(err)
- }
- return db
-}
-
-// =============================================================================
-// SECURITY HANDLER BENCHMARKS
-// =============================================================================
-
-func BenchmarkSecurityHandler_GetStatus(b *testing.B) {
- gin.SetMode(gin.ReleaseMode)
- db := setupBenchmarkDB(b)
-
- // Seed settings
- settings := []models.Setting{
- {Key: "security.cerberus.enabled", Value: "true", Category: "security"},
- {Key: "security.waf.enabled", Value: "true", Category: "security"},
- {Key: "security.rate_limit.enabled", Value: "true", Category: "security"},
- {Key: "security.crowdsec.enabled", Value: "true", Category: "security"},
- {Key: "security.acl.enabled", Value: "true", Category: "security"},
- }
- for _, s := range settings {
- db.Create(&s)
- }
-
- cfg := config.SecurityConfig{CerberusEnabled: true}
- h := NewSecurityHandler(cfg, db, nil)
-
- router := gin.New()
- router.GET("/api/v1/security/status", h.GetStatus)
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; i < b.N; i++ {
- req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- b.Fatalf("unexpected status: %d", w.Code)
- }
- }
-}
-
-func BenchmarkSecurityHandler_GetStatus_NoSettings(b *testing.B) {
- gin.SetMode(gin.ReleaseMode)
- db := setupBenchmarkDB(b)
-
- cfg := config.SecurityConfig{CerberusEnabled: true}
- h := NewSecurityHandler(cfg, db, nil)
-
- router := gin.New()
- router.GET("/api/v1/security/status", h.GetStatus)
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; i < b.N; i++ {
- req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- b.Fatalf("unexpected status: %d", w.Code)
- }
- }
-}
-
-func BenchmarkSecurityHandler_ListDecisions(b *testing.B) {
- gin.SetMode(gin.ReleaseMode)
- db := setupBenchmarkDB(b)
-
- // Seed some decisions
- for i := 0; i < 100; i++ {
- db.Create(&models.SecurityDecision{
- UUID: "test-uuid-" + string(rune(i)),
- Source: "test",
- Action: "block",
- IP: "192.168.1.1",
- })
- }
-
- cfg := config.SecurityConfig{}
- h := NewSecurityHandler(cfg, db, nil)
-
- router := gin.New()
- router.GET("/api/v1/security/decisions", h.ListDecisions)
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; i < b.N; i++ {
- req := httptest.NewRequest("GET", "/api/v1/security/decisions?limit=50", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- b.Fatalf("unexpected status: %d", w.Code)
- }
- }
-}
-
-func BenchmarkSecurityHandler_ListRuleSets(b *testing.B) {
- gin.SetMode(gin.ReleaseMode)
- db := setupBenchmarkDB(b)
-
- // Seed some rulesets
- for i := 0; i < 10; i++ {
- db.Create(&models.SecurityRuleSet{
- UUID: "ruleset-uuid-" + string(rune(i)),
- Name: "Ruleset " + string(rune('A'+i)),
- Content: "SecRule REQUEST_URI \"@contains /admin\" \"id:1000,phase:1,deny\"",
- Mode: "blocking",
- })
- }
-
- cfg := config.SecurityConfig{}
- h := NewSecurityHandler(cfg, db, nil)
-
- router := gin.New()
- router.GET("/api/v1/security/rulesets", h.ListRuleSets)
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; i < b.N; i++ {
- req := httptest.NewRequest("GET", "/api/v1/security/rulesets", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- b.Fatalf("unexpected status: %d", w.Code)
- }
- }
-}
-
-func BenchmarkSecurityHandler_UpsertRuleSet(b *testing.B) {
- gin.SetMode(gin.ReleaseMode)
- db := setupBenchmarkDB(b)
-
- cfg := config.SecurityConfig{}
- h := NewSecurityHandler(cfg, db, nil)
-
- router := gin.New()
- router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
-
- payload := map[string]interface{}{
- "name": "bench-ruleset",
- "content": "SecRule REQUEST_URI \"@contains /admin\" \"id:1000,phase:1,deny\"",
- "mode": "blocking",
- }
- body, _ := json.Marshal(payload)
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; i < b.N; i++ {
- req := httptest.NewRequest("POST", "/api/v1/security/rulesets", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- b.Fatalf("unexpected status: %d", w.Code)
- }
- }
-}
-
-func BenchmarkSecurityHandler_CreateDecision(b *testing.B) {
- gin.SetMode(gin.ReleaseMode)
- db := setupBenchmarkDB(b)
-
- cfg := config.SecurityConfig{}
- h := NewSecurityHandler(cfg, db, nil)
-
- router := gin.New()
- router.POST("/api/v1/security/decisions", h.CreateDecision)
-
- payload := map[string]interface{}{
- "ip": "192.168.1.100",
- "action": "block",
- "details": "benchmark test",
- }
- body, _ := json.Marshal(payload)
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; i < b.N; i++ {
- req := httptest.NewRequest("POST", "/api/v1/security/decisions", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- b.Fatalf("unexpected status: %d", w.Code)
- }
- }
-}
-
-func BenchmarkSecurityHandler_GetConfig(b *testing.B) {
- gin.SetMode(gin.ReleaseMode)
- db := setupBenchmarkDB(b)
-
- // Seed a config
- db.Create(&models.SecurityConfig{
- Name: "default",
- Enabled: true,
- AdminWhitelist: "192.168.1.0/24",
- WAFMode: "block",
- RateLimitEnable: true,
- RateLimitBurst: 10,
- })
-
- cfg := config.SecurityConfig{}
- h := NewSecurityHandler(cfg, db, nil)
-
- router := gin.New()
- router.GET("/api/v1/security/config", h.GetConfig)
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; i < b.N; i++ {
- req := httptest.NewRequest("GET", "/api/v1/security/config", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- b.Fatalf("unexpected status: %d", w.Code)
- }
- }
-}
-
-func BenchmarkSecurityHandler_UpdateConfig(b *testing.B) {
- gin.SetMode(gin.ReleaseMode)
- db := setupBenchmarkDB(b)
-
- cfg := config.SecurityConfig{}
- h := NewSecurityHandler(cfg, db, nil)
-
- router := gin.New()
- router.PUT("/api/v1/security/config", h.UpdateConfig)
-
- payload := map[string]interface{}{
- "name": "default",
- "enabled": true,
- "rate_limit_enable": true,
- "rate_limit_burst": 10,
- "rate_limit_requests": 100,
- }
- body, _ := json.Marshal(payload)
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; i < b.N; i++ {
- req := httptest.NewRequest("PUT", "/api/v1/security/config", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- b.Fatalf("unexpected status: %d", w.Code)
- }
- }
-}
-
-// =============================================================================
-// PARALLEL BENCHMARKS (Concurrency Testing)
-// =============================================================================
-
-func BenchmarkSecurityHandler_GetStatus_Parallel(b *testing.B) {
- gin.SetMode(gin.ReleaseMode)
- db := setupBenchmarkDB(b)
-
- settings := []models.Setting{
- {Key: "security.cerberus.enabled", Value: "true", Category: "security"},
- {Key: "security.waf.enabled", Value: "true", Category: "security"},
- }
- for _, s := range settings {
- db.Create(&s)
- }
-
- cfg := config.SecurityConfig{CerberusEnabled: true}
- h := NewSecurityHandler(cfg, db, nil)
-
- router := gin.New()
- router.GET("/api/v1/security/status", h.GetStatus)
-
- b.ResetTimer()
- b.ReportAllocs()
-
- b.RunParallel(func(pb *testing.PB) {
- for pb.Next() {
- req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- b.Fatalf("unexpected status: %d", w.Code)
- }
- }
- })
-}
-
-func BenchmarkSecurityHandler_ListDecisions_Parallel(b *testing.B) {
- gin.SetMode(gin.ReleaseMode)
- // Use file-based SQLite with WAL mode for parallel testing
- db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared&_journal_mode=WAL"), &gorm.Config{
- Logger: logger.Default.LogMode(logger.Silent),
- })
- if err != nil {
- b.Fatal(err)
- }
- if err := db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityAudit{}); err != nil {
- b.Fatal(err)
- }
-
- for i := 0; i < 100; i++ {
- db.Create(&models.SecurityDecision{
- UUID: "test-uuid-" + string(rune(i)),
- Source: "test",
- Action: "block",
- IP: "192.168.1.1",
- })
- }
-
- cfg := config.SecurityConfig{}
- h := NewSecurityHandler(cfg, db, nil)
-
- router := gin.New()
- router.GET("/api/v1/security/decisions", h.ListDecisions)
-
- b.ResetTimer()
- b.ReportAllocs()
-
- b.RunParallel(func(pb *testing.PB) {
- for pb.Next() {
- req := httptest.NewRequest("GET", "/api/v1/security/decisions?limit=50", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- b.Fatalf("unexpected status: %d", w.Code)
- }
- }
- })
-}
-
-// =============================================================================
-// MEMORY PRESSURE BENCHMARKS
-// =============================================================================
-
-func BenchmarkSecurityHandler_LargeRuleSetContent(b *testing.B) {
- gin.SetMode(gin.ReleaseMode)
- db := setupBenchmarkDB(b)
-
- cfg := config.SecurityConfig{}
- h := NewSecurityHandler(cfg, db, nil)
-
- router := gin.New()
- router.POST("/api/v1/security/rulesets", h.UpsertRuleSet)
-
- // 100KB ruleset content (under 2MB limit)
- largeContent := ""
- for i := 0; i < 1000; i++ {
- largeContent += "SecRule REQUEST_URI \"@contains /path" + string(rune(i)) + "\" \"id:" + string(rune(1000+i)) + ",phase:1,deny\"\n"
- }
-
- payload := map[string]interface{}{
- "name": "large-ruleset",
- "content": largeContent,
- "mode": "blocking",
- }
- body, _ := json.Marshal(payload)
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; i < b.N; i++ {
- req := httptest.NewRequest("POST", "/api/v1/security/rulesets", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- b.Fatalf("unexpected status: %d", w.Code)
- }
- }
-}
-
-func BenchmarkSecurityHandler_ManySettingsLookups(b *testing.B) {
- gin.SetMode(gin.ReleaseMode)
- db := setupBenchmarkDB(b)
-
- // Seed many settings
- for i := 0; i < 100; i++ {
- db.Create(&models.Setting{
- Key: "setting.key." + string(rune(i)),
- Value: "value",
- Category: "misc",
- })
- }
- // Security settings
- settings := []models.Setting{
- {Key: "security.cerberus.enabled", Value: "true", Category: "security"},
- {Key: "security.waf.enabled", Value: "true", Category: "security"},
- {Key: "security.rate_limit.enabled", Value: "true", Category: "security"},
- {Key: "security.crowdsec.enabled", Value: "true", Category: "security"},
- {Key: "security.crowdsec.mode", Value: "local", Category: "security"},
- {Key: "security.crowdsec.api_url", Value: "http://localhost:8080", Category: "security"},
- {Key: "security.acl.enabled", Value: "true", Category: "security"},
- }
- for _, s := range settings {
- db.Create(&s)
- }
-
- cfg := config.SecurityConfig{}
- h := NewSecurityHandler(cfg, db, nil)
-
- router := gin.New()
- router.GET("/api/v1/security/status", h.GetStatus)
-
- b.ResetTimer()
- b.ReportAllocs()
-
- for i := 0; i < b.N; i++ {
- req := httptest.NewRequest("GET", "/api/v1/security/status", http.NoBody)
- w := httptest.NewRecorder()
- router.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- b.Fatalf("unexpected status: %d", w.Code)
- }
- }
-}
diff --git a/backend/internal/api/handlers/certificate_handler.go b/backend/internal/api/handlers/certificate_handler.go
deleted file mode 100644
index 08cb6bf7..00000000
--- a/backend/internal/api/handlers/certificate_handler.go
+++ /dev/null
@@ -1,218 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "net/http"
- "strconv"
- "sync"
- "time"
-
- "github.com/gin-gonic/gin"
-
- "github.com/Wikid82/charon/backend/internal/logger"
- "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
- // Rate limiting for notifications
- notificationMu sync.Mutex
- lastNotificationTime map[uint]time.Time
-}
-
-func NewCertificateHandler(service *services.CertificateService, backupService BackupServiceInterface, ns *services.NotificationService) *CertificateHandler {
- return &CertificateHandler{
- service: service,
- backupService: backupService,
- notificationService: ns,
- lastNotificationTime: make(map[uint]time.Time),
- }
-}
-
-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)
-}
-
-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) 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
- 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"})
- return
- }
- defer func() {
- if err := certSrc.Close(); err != nil {
- logger.Log().WithError(err).Warn("failed to close certificate file")
- }
- }()
-
- keySrc, err := keyFile.Open()
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open key file"})
- return
- }
- defer func() {
- if err := keySrc.Close(); err != nil {
- logger.Log().WithError(err).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 := make([]byte, 1024*1024)
- n, _ = keySrc.Read(keyBytes)
- keyPEM := string(keyBytes[:n])
-
- cert, err := h.service.UploadCertificate(name, certPEM, keyPEM)
- if err != nil {
- logger.Log().WithError(err).Error("failed to upload certificate")
- c.JSON(http.StatusBadRequest, gin.H{"error": "failed to upload certificate"})
- return
- }
-
- // Send Notification
- if h.notificationService != nil {
- h.notificationService.SendExternal(c.Request.Context(),
- "cert",
- "Certificate Uploaded",
- fmt.Sprintf("Certificate %s uploaded", util.SanitizeForLog(cert.Name)),
- map[string]interface{}{
- "Name": util.SanitizeForLog(cert.Name),
- "Domains": util.SanitizeForLog(cert.Domains),
- "Action": "uploaded",
- },
- )
- }
-
- c.JSON(http.StatusCreated, cert)
-}
-
-func (h *CertificateHandler) Delete(c *gin.Context) {
- idStr := c.Param("id")
- id, err := strconv.ParseUint(idStr, 10, 32)
- if err != nil {
- 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))
- if err != nil {
- logger.Log().WithError(err).WithField("certificate_id", id).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
- }
-
- // 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
- }
-
- 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
- }
- }
-
- // Proceed with deletion
- if err := h.service.DeleteCertificate(uint(id)); 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")
- 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]interface{}{
- "ID": id,
- "Action": "deleted",
- },
- )
- } else {
- h.notificationMu.Unlock()
- logger.Log().WithField("certificate_id", id).Debug("notification rate limited")
- }
- }
-
- c.JSON(http.StatusOK, gin.H{"message": "certificate deleted"})
-}
diff --git a/backend/internal/api/handlers/certificate_handler_coverage_test.go b/backend/internal/api/handlers/certificate_handler_coverage_test.go
deleted file mode 100644
index 8151c588..00000000
--- a/backend/internal/api/handlers/certificate_handler_coverage_test.go
+++ /dev/null
@@ -1,157 +0,0 @@
-package handlers
-
-import (
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
-)
-
-func TestCertificateHandler_List_DBError(t *testing.T) {
- db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- // Don't migrate to cause error
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.GET("/api/certificates", h.List)
-
- req := httptest.NewRequest(http.MethodGet, "/api/certificates", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusInternalServerError, w.Code)
-}
-
-func TestCertificateHandler_Delete_InvalidID(t *testing.T) {
- db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
-
- req := httptest.NewRequest(http.MethodDelete, "/api/certificates/invalid", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestCertificateHandler_Delete_NotFound(t *testing.T) {
- // Use unique in-memory DB per test to avoid SQLite locking issues in parallel test runs
- db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
-
- req := httptest.NewRequest(http.MethodDelete, "/api/certificates/9999", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusInternalServerError, w.Code)
-}
-
-func TestCertificateHandler_Delete_NoBackupService(t *testing.T) {
- // Use unique in-memory DB per test to avoid SQLite locking issues in parallel test runs
- db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})
-
- // Create certificate
- cert := models.SSLCertificate{UUID: "test-cert-no-backup", Name: "no-backup-cert", Provider: "custom", Domains: "nobackup.example.com"}
- db.Create(&cert)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
- // Wait for background sync goroutine to complete to avoid race with -race flag
- // NewCertificateService spawns a goroutine that immediately queries the DB
- // which can race with our test HTTP request. Give it time to complete.
- // In real usage, this isn't an issue because the server starts before receiving requests.
- // Alternative would be to add a WaitGroup to CertificateService, but that's overkill for tests.
- // A simple sleep is acceptable here as it's test-only code.
- // 100ms is more than enough for the goroutine to finish its initial sync.
- // This is the minimum reliable wait time based on empirical testing with -race flag.
- // The goroutine needs to: acquire mutex, stat directory, query DB, release mutex.
- // On CI runners, this can take longer than on local dev machines.
- time.Sleep(200 * time.Millisecond)
-
- // No backup service
- h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
-
- req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- // Should still succeed without backup service
- assert.Equal(t, http.StatusOK, w.Code)
-}
-
-func TestCertificateHandler_Delete_CheckUsageDBError(t *testing.T) {
- // Use unique in-memory DB per test to avoid SQLite locking issues in parallel test runs
- db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- // Only migrate SSLCertificate, not ProxyHost to cause error when checking usage
- db.AutoMigrate(&models.SSLCertificate{})
-
- // Create certificate
- cert := models.SSLCertificate{UUID: "test-cert-db-err", Name: "db-error-cert", Provider: "custom", Domains: "dberr.example.com"}
- db.Create(&cert)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
-
- req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusInternalServerError, w.Code)
-}
-
-func TestCertificateHandler_List_WithCertificates(t *testing.T) {
- // Use unique in-memory DB per test to avoid SQLite locking issues in parallel test runs
- db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
- db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{})
-
- // Create certificates
- db.Create(&models.SSLCertificate{UUID: "cert-1", Name: "Cert 1", Provider: "custom", Domains: "one.example.com"})
- db.Create(&models.SSLCertificate{UUID: "cert-2", Name: "Cert 2", Provider: "custom", Domains: "two.example.com"})
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.GET("/api/certificates", h.List)
-
- req := httptest.NewRequest(http.MethodGet, "/api/certificates", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- assert.Contains(t, w.Body.String(), "Cert 1")
- assert.Contains(t, w.Body.String(), "Cert 2")
-}
diff --git a/backend/internal/api/handlers/certificate_handler_security_test.go b/backend/internal/api/handlers/certificate_handler_security_test.go
deleted file mode 100644
index 275a5cfa..00000000
--- a/backend/internal/api/handlers/certificate_handler_security_test.go
+++ /dev/null
@@ -1,208 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
-)
-
-// TestCertificateHandler_Delete_RequiresAuth tests that delete requires authentication
-func TestCertificateHandler_Delete_RequiresAuth(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
-
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- // Add a middleware that rejects all unauthenticated requests
- r.Use(func(c *gin.Context) {
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
- })
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
-
- req := httptest.NewRequest(http.MethodDelete, "/api/certificates/1", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusUnauthorized {
- t.Fatalf("expected 401 Unauthorized without auth, got %d", w.Code)
- }
-}
-
-// TestCertificateHandler_List_RequiresAuth tests that list requires authentication
-func TestCertificateHandler_List_RequiresAuth(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
-
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- // Add a middleware that rejects all unauthenticated requests
- r.Use(func(c *gin.Context) {
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
- })
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.GET("/api/certificates", h.List)
-
- req := httptest.NewRequest(http.MethodGet, "/api/certificates", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusUnauthorized {
- t.Fatalf("expected 401 Unauthorized without auth, got %d", w.Code)
- }
-}
-
-// TestCertificateHandler_Upload_RequiresAuth tests that upload requires authentication
-func TestCertificateHandler_Upload_RequiresAuth(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
-
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- // Add a middleware that rejects all unauthenticated requests
- r.Use(func(c *gin.Context) {
- c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
- })
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.POST("/api/certificates", h.Upload)
-
- req := httptest.NewRequest(http.MethodPost, "/api/certificates", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusUnauthorized {
- t.Fatalf("expected 401 Unauthorized without auth, got %d", w.Code)
- }
-}
-
-// TestCertificateHandler_Delete_DiskSpaceCheck tests the disk space check before backup
-func TestCertificateHandler_Delete_DiskSpaceCheck(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
-
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- // Create a certificate
- cert := models.SSLCertificate{
- UUID: "test-cert",
- Name: "test",
- Provider: "custom",
- Domains: "test.com",
- }
- if err := db.Create(&cert).Error; err != nil {
- t.Fatalf("failed to create cert: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
-
- // Mock backup service that reports low disk space
- mockBackup := &mockBackupService{
- availableSpaceFunc: func() (int64, error) {
- return 50 * 1024 * 1024, nil // 50MB (less than 100MB required)
- },
- }
-
- h := NewCertificateHandler(svc, mockBackup, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
-
- req := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert.ID), http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusInsufficientStorage {
- t.Fatalf("expected 507 Insufficient Storage with low disk space, got %d", w.Code)
- }
-}
-
-// TestCertificateHandler_Delete_NotificationRateLimiting tests rate limiting
-func TestCertificateHandler_Delete_NotificationRateLimiting(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
-
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- // Create certificates
- cert1 := models.SSLCertificate{UUID: "test-1", Name: "test1", Provider: "custom", Domains: "test1.com"}
- cert2 := models.SSLCertificate{UUID: "test-2", Name: "test2", Provider: "custom", Domains: "test2.com"}
- if err := db.Create(&cert1).Error; err != nil {
- t.Fatalf("failed to create cert1: %v", err)
- }
- if err := db.Create(&cert2).Error; err != nil {
- t.Fatalf("failed to create cert2: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
-
- mockBackup := &mockBackupService{
- createFunc: func() (string, error) {
- return "backup.zip", nil
- },
- }
-
- h := NewCertificateHandler(svc, mockBackup, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
-
- // Delete first cert
- req1 := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert1.ID), http.NoBody)
- w1 := httptest.NewRecorder()
- r.ServeHTTP(w1, req1)
-
- if w1.Code != http.StatusOK {
- t.Fatalf("first delete failed: got %d", w1.Code)
- }
-
- // Delete second cert (different ID, should not be rate limited)
- req2 := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/api/certificates/%d", cert2.ID), http.NoBody)
- w2 := httptest.NewRecorder()
- r.ServeHTTP(w2, req2)
-
- if w2.Code != http.StatusOK {
- t.Fatalf("second delete failed: got %d", w2.Code)
- }
-
- // The test passes if both deletions succeed
- // Rate limiting is per-certificate ID, so different certs should not interfere
-}
diff --git a/backend/internal/api/handlers/certificate_handler_test.go b/backend/internal/api/handlers/certificate_handler_test.go
deleted file mode 100644
index 2559f5a9..00000000
--- a/backend/internal/api/handlers/certificate_handler_test.go
+++ /dev/null
@@ -1,470 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "crypto/rand"
- "crypto/rsa"
- "crypto/x509"
- "crypto/x509/pkix"
- "encoding/pem"
- "fmt"
- "math/big"
- "mime/multipart"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
-
- "github.com/gin-gonic/gin"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
-)
-
-// mockAuthMiddleware adds a mock user to the context for testing
-func mockAuthMiddleware() gin.HandlerFunc {
- return func(c *gin.Context) {
- c.Set("user", map[string]interface{}{"id": 1, "username": "testuser"})
- c.Next()
- }
-}
-
-func setupCertTestRouter(t *testing.T, db *gorm.DB) *gin.Engine {
- t.Helper()
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- r.Use(mockAuthMiddleware())
-
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
- return r
-}
-
-func TestDeleteCertificate_InUse(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
-
- // Migrate minimal models
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- // Create certificate
- cert := models.SSLCertificate{UUID: "test-cert", Name: "example-cert", Provider: "custom", Domains: "example.com"}
- if err := db.Create(&cert).Error; err != nil {
- t.Fatalf("failed to create cert: %v", err)
- }
-
- // Create proxy host referencing the certificate
- ph := models.ProxyHost{UUID: "ph-1", Name: "ph", DomainNames: "example.com", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID}
- if err := db.Create(&ph).Error; err != nil {
- t.Fatalf("failed to create proxy host: %v", err)
- }
-
- r := setupCertTestRouter(t, db)
-
- req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusConflict {
- t.Fatalf("expected 409 Conflict, got %d, body=%s", w.Code, w.Body.String())
- }
-}
-
-func toStr(id uint) string {
- return fmt.Sprintf("%d", id)
-}
-
-// Test that deleting a certificate NOT in use creates a backup and deletes successfully
-func TestDeleteCertificate_CreatesBackup(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
-
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- // Create certificate
- cert := models.SSLCertificate{UUID: "test-cert-backup-success", Name: "deletable-cert", Provider: "custom", Domains: "delete.example.com"}
- if err := db.Create(&cert).Error; err != nil {
- t.Fatalf("failed to create cert: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
-
- // Mock BackupService
- backupCalled := false
- mockBackupService := &mockBackupService{
- createFunc: func() (string, error) {
- backupCalled = true
- return "backup-test.tar.gz", nil
- },
- }
-
- h := NewCertificateHandler(svc, mockBackupService, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
-
- req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200 OK, got %d, body=%s", w.Code, w.Body.String())
- }
-
- if !backupCalled {
- t.Fatal("expected backup to be created before deletion")
- }
-
- // Verify certificate was deleted
- var found models.SSLCertificate
- err = db.First(&found, cert.ID).Error
- if err == nil {
- t.Fatal("expected certificate to be deleted")
- }
-}
-
-// Test that backup failure prevents deletion
-func TestDeleteCertificate_BackupFailure(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
-
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- // Create certificate
- cert := models.SSLCertificate{UUID: "test-cert-backup-fails", Name: "deletable-cert", Provider: "custom", Domains: "delete-fail.example.com"}
- if err := db.Create(&cert).Error; err != nil {
- t.Fatalf("failed to create cert: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
-
- // Mock BackupService that fails
- mockBackupService := &mockBackupService{
- createFunc: func() (string, error) {
- return "", fmt.Errorf("backup creation failed")
- },
- }
-
- h := NewCertificateHandler(svc, mockBackupService, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
-
- req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusInternalServerError {
- t.Fatalf("expected 500 Internal Server Error, got %d", w.Code)
- }
-
- // Verify certificate was NOT deleted
- var found models.SSLCertificate
- err = db.First(&found, cert.ID).Error
- if err != nil {
- t.Fatal("expected certificate to still exist after backup failure")
- }
-}
-
-// Test that in-use check does not create a backup
-func TestDeleteCertificate_InUse_NoBackup(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
-
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- // Create certificate
- cert := models.SSLCertificate{UUID: "test-cert-in-use-no-backup", Name: "in-use-cert", Provider: "custom", Domains: "inuse.example.com"}
- if err := db.Create(&cert).Error; err != nil {
- t.Fatalf("failed to create cert: %v", err)
- }
-
- // Create proxy host referencing the certificate
- ph := models.ProxyHost{UUID: "ph-no-backup-test", Name: "ph", DomainNames: "inuse.example.com", ForwardHost: "localhost", ForwardPort: 8080, CertificateID: &cert.ID}
- if err := db.Create(&ph).Error; err != nil {
- t.Fatalf("failed to create proxy host: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
-
- // Mock BackupService
- backupCalled := false
- mockBackupService := &mockBackupService{
- createFunc: func() (string, error) {
- backupCalled = true
- return "backup-test.tar.gz", nil
- },
- }
-
- h := NewCertificateHandler(svc, mockBackupService, nil)
- r.DELETE("/api/certificates/:id", h.Delete)
-
- req := httptest.NewRequest(http.MethodDelete, "/api/certificates/"+toStr(cert.ID), http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusConflict {
- t.Fatalf("expected 409 Conflict, got %d, body=%s", w.Code, w.Body.String())
- }
-
- if backupCalled {
- t.Fatal("expected backup NOT to be created when certificate is in use")
- }
-}
-
-// Mock BackupService for testing
-type mockBackupService struct {
- createFunc func() (string, error)
- availableSpaceFunc func() (int64, error)
-}
-
-func (m *mockBackupService) CreateBackup() (string, error) {
- if m.createFunc != nil {
- return m.createFunc()
- }
- return "", fmt.Errorf("not implemented")
-}
-
-func (m *mockBackupService) ListBackups() ([]services.BackupFile, error) {
- return nil, fmt.Errorf("not implemented")
-}
-
-func (m *mockBackupService) DeleteBackup(filename string) error {
- return fmt.Errorf("not implemented")
-}
-
-func (m *mockBackupService) GetBackupPath(filename string) (string, error) {
- return "", fmt.Errorf("not implemented")
-}
-
-func (m *mockBackupService) RestoreBackup(filename string) error {
- return fmt.Errorf("not implemented")
-}
-
-func (m *mockBackupService) GetAvailableSpace() (int64, error) {
- if m.availableSpaceFunc != nil {
- return m.availableSpaceFunc()
- }
- // Default: return 1GB available
- return 1024 * 1024 * 1024, nil
-}
-
-// Test List handler
-func TestCertificateHandler_List(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
-
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.GET("/api/certificates", h.List)
-
- req := httptest.NewRequest(http.MethodGet, "/api/certificates", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200 OK, got %d, body=%s", w.Code, w.Body.String())
- }
-}
-
-// Test Upload handler with missing name
-func TestCertificateHandler_Upload_MissingName(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
-
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.POST("/api/certificates", h.Upload)
-
- // Empty body - no form fields
- req := httptest.NewRequest(http.MethodPost, "/api/certificates", strings.NewReader(""))
- req.Header.Set("Content-Type", "multipart/form-data")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400 Bad Request, got %d", w.Code)
- }
-}
-
-// Test Upload handler missing certificate_file
-func TestCertificateHandler_Upload_MissingCertFile(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.POST("/api/certificates", h.Upload)
-
- body := strings.NewReader("name=testcert")
- req := httptest.NewRequest(http.MethodPost, "/api/certificates", body)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400 Bad Request, got %d", w.Code)
- }
- if !strings.Contains(w.Body.String(), "certificate_file") {
- t.Fatalf("expected error message about certificate_file, got: %s", w.Body.String())
- }
-}
-
-// Test Upload handler missing key_file
-func TestCertificateHandler_Upload_MissingKeyFile(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
- svc := services.NewCertificateService("/tmp", db)
- h := NewCertificateHandler(svc, nil, nil)
- r.POST("/api/certificates", h.Upload)
-
- body := strings.NewReader("name=testcert")
- req := httptest.NewRequest(http.MethodPost, "/api/certificates", body)
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400 Bad Request, got %d", w.Code)
- }
-}
-
-// Test Upload handler success path using a mock CertificateService
-func TestCertificateHandler_Upload_Success(t *testing.T) {
- db, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
- if err != nil {
- t.Fatalf("failed to open db: %v", err)
- }
- if err := db.AutoMigrate(&models.SSLCertificate{}, &models.ProxyHost{}); err != nil {
- t.Fatalf("failed to migrate: %v", err)
- }
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.Use(mockAuthMiddleware())
-
- // 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)
- h := NewCertificateHandler(svc, nil, nil)
- r.POST("/api/certificates", h.Upload)
-
- // Prepare multipart form data
- var body bytes.Buffer
- writer := multipart.NewWriter(&body)
- _ = writer.WriteField("name", "uploaded-cert")
- certPEM, keyPEM, err := generateSelfSignedCertPEM()
- if err != nil {
- t.Fatalf("failed to generate cert: %v", err)
- }
- part, _ := writer.CreateFormFile("certificate_file", "cert.pem")
- part.Write([]byte(certPEM))
- part2, _ := writer.CreateFormFile("key_file", "key.pem")
- part2.Write([]byte(keyPEM))
- writer.Close()
-
- req := httptest.NewRequest(http.MethodPost, "/api/certificates", &body)
- req.Header.Set("Content-Type", writer.FormDataContentType())
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusCreated {
- t.Fatalf("expected 201 Created, got %d, body=%s", w.Code, w.Body.String())
- }
-}
-
-func generateSelfSignedCertPEM() (certPEM, keyPEM string, err error) {
- // generate RSA key
- priv, err := rsa.GenerateKey(rand.Reader, 2048)
- if err != nil {
- return "", "", err
- }
- // create a simple self-signed cert
- template := x509.Certificate{
- SerialNumber: big.NewInt(1),
- Subject: pkix.Name{
- Organization: []string{"Test Org"},
- },
- NotBefore: time.Now().Add(-time.Hour),
- NotAfter: time.Now().Add(24 * time.Hour),
- KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
- ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
- BasicConstraintsValid: true,
- }
- derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
- if err != nil {
- return "", "", err
- }
- certBuf := new(bytes.Buffer)
- pem.Encode(certBuf, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
- keyBuf := new(bytes.Buffer)
- pem.Encode(keyBuf, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
- certPEM = certBuf.String()
- keyPEM = keyBuf.String()
- return certPEM, keyPEM, nil
-}
-
-// Note: mockCertificateService removed โ helper tests now use real service instances or testify mocks inlined where required.
diff --git a/backend/internal/api/handlers/coverage_quick_test.go b/backend/internal/api/handlers/coverage_quick_test.go
deleted file mode 100644
index 9e067aa2..00000000
--- a/backend/internal/api/handlers/coverage_quick_test.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package handlers
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
-)
-
-// Use a real BackupService, but point it at tmpDir for isolation
-
-func TestBackupHandlerQuick(t *testing.T) {
- gin.SetMode(gin.TestMode)
- tmpDir := t.TempDir()
- // prepare a fake "database" so CreateBackup can find it
- dbPath := filepath.Join(tmpDir, "db.sqlite")
- if err := os.WriteFile(dbPath, []byte("db"), 0o644); err != nil {
- t.Fatalf("failed to create tmp db: %v", err)
- }
-
- svc := &services.BackupService{DataDir: tmpDir, BackupDir: tmpDir, DatabaseName: "db.sqlite", Cron: nil}
- h := NewBackupHandler(svc)
-
- r := gin.New()
- // register routes used
- r.GET("/backups", h.List)
- r.POST("/backups", h.Create)
- r.DELETE("/backups/:filename", h.Delete)
- r.GET("/backups/:filename", h.Download)
- r.POST("/backups/:filename/restore", h.Restore)
-
- // List
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/backups", http.NoBody)
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
- }
-
- // Create (backup)
- w2 := httptest.NewRecorder()
- req2 := httptest.NewRequest(http.MethodPost, "/backups", http.NoBody)
- r.ServeHTTP(w2, req2)
- if w2.Code != http.StatusCreated {
- t.Fatalf("create expected 201 got %d", w2.Code)
- }
-
- var createResp struct {
- Filename string `json:"filename"`
- }
- if err := json.Unmarshal(w2.Body.Bytes(), &createResp); err != nil {
- t.Fatalf("invalid create json: %v", err)
- }
-
- // Delete missing
- w3 := httptest.NewRecorder()
- req3 := httptest.NewRequest(http.MethodDelete, "/backups/missing", http.NoBody)
- r.ServeHTTP(w3, req3)
- if w3.Code != http.StatusNotFound {
- t.Fatalf("delete missing expected 404 got %d", w3.Code)
- }
-
- // Download missing
- w4 := httptest.NewRecorder()
- req4 := httptest.NewRequest(http.MethodGet, "/backups/missing", http.NoBody)
- r.ServeHTTP(w4, req4)
- if w4.Code != http.StatusNotFound {
- t.Fatalf("download missing expected 404 got %d", w4.Code)
- }
-
- // Download present (use filename returned from create)
- w5 := httptest.NewRecorder()
- req5 := httptest.NewRequest(http.MethodGet, "/backups/"+createResp.Filename, http.NoBody)
- r.ServeHTTP(w5, req5)
- if w5.Code != http.StatusOK {
- t.Fatalf("download expected 200 got %d", w5.Code)
- }
-
- // Restore missing
- w6 := httptest.NewRecorder()
- req6 := httptest.NewRequest(http.MethodPost, "/backups/missing/restore", http.NoBody)
- r.ServeHTTP(w6, req6)
- if w6.Code != http.StatusNotFound {
- t.Fatalf("restore missing expected 404 got %d", w6.Code)
- }
-
- // Restore ok
- w7 := httptest.NewRecorder()
- req7 := httptest.NewRequest(http.MethodPost, "/backups/"+createResp.Filename+"/restore", http.NoBody)
- r.ServeHTTP(w7, req7)
- if w7.Code != http.StatusOK {
- t.Fatalf("restore expected 200 got %d", w7.Code)
- }
-}
diff --git a/backend/internal/api/handlers/crowdsec_cache_verification_test.go b/backend/internal/api/handlers/crowdsec_cache_verification_test.go
deleted file mode 100644
index 2a4dcde7..00000000
--- a/backend/internal/api/handlers/crowdsec_cache_verification_test.go
+++ /dev/null
@@ -1,92 +0,0 @@
-package handlers
-
-import (
- "context"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
-
- "github.com/Wikid82/charon/backend/internal/crowdsec"
-)
-
-// TestListPresetsShowsCachedStatus verifies the /presets endpoint marks cached presets.
-func TestListPresetsShowsCachedStatus(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- cacheDir := t.TempDir()
- dataDir := t.TempDir()
-
- cache, err := crowdsec.NewHubCache(cacheDir, time.Hour)
- require.NoError(t, err)
-
- // Cache a preset
- ctx := context.Background()
- archive := []byte("archive")
- _, err = cache.Store(ctx, "test/cached", "etag", "hub", "preview", archive)
- require.NoError(t, err)
-
- // Setup handler
- hub := crowdsec.NewHubService(nil, cache, dataDir)
- db := OpenTestDB(t)
- handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
- handler.Hub = hub
-
- r := gin.New()
- g := r.Group("/api/v1")
- handler.RegisterRoutes(g)
-
- // List presets
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
- resp := httptest.NewRecorder()
- r.ServeHTTP(resp, req)
-
- require.Equal(t, http.StatusOK, resp.Code)
-
- var result map[string]interface{}
- err = json.Unmarshal(resp.Body.Bytes(), &result)
- require.NoError(t, err)
-
- presets := result["presets"].([]interface{})
- require.NotEmpty(t, presets, "Should have at least one preset")
-
- // Find our cached preset
- found := false
- for _, p := range presets {
- preset := p.(map[string]interface{})
- if preset["slug"] == "test/cached" {
- found = true
- require.True(t, preset["cached"].(bool), "Preset should be marked as cached")
- require.NotEmpty(t, preset["cache_key"], "Should have cache_key")
- }
- }
- require.True(t, found, "Cached preset should appear in list")
-}
-
-// TestCacheKeyPersistence verifies cache keys are consistent and retrievable.
-func TestCacheKeyPersistence(t *testing.T) {
- cacheDir := t.TempDir()
-
- cache, err := crowdsec.NewHubCache(cacheDir, time.Hour)
- require.NoError(t, err)
-
- // Store a preset
- ctx := context.Background()
- archive := []byte("test archive")
- meta, err := cache.Store(ctx, "test/preset", "etag123", "hub", "preview text", archive)
- require.NoError(t, err)
-
- originalCacheKey := meta.CacheKey
- require.NotEmpty(t, originalCacheKey, "Cache key should be generated")
-
- // Load it back
- loaded, err := cache.Load(ctx, "test/preset")
- require.NoError(t, err)
- require.Equal(t, originalCacheKey, loaded.CacheKey, "Cache key should persist")
- require.Equal(t, "test/preset", loaded.Slug)
- require.Equal(t, "etag123", loaded.Etag)
-}
diff --git a/backend/internal/api/handlers/crowdsec_decisions_test.go b/backend/internal/api/handlers/crowdsec_decisions_test.go
deleted file mode 100644
index 26ba34bf..00000000
--- a/backend/internal/api/handlers/crowdsec_decisions_test.go
+++ /dev/null
@@ -1,450 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-// mockCommandExecutor is a mock implementation of CommandExecutor for testing
-type mockCommandExecutor struct {
- output []byte
- err error
- calls [][]string // Track all calls made
-}
-
-func (m *mockCommandExecutor) Execute(ctx context.Context, name string, args ...string) ([]byte, error) {
- call := append([]string{name}, args...)
- m.calls = append(m.calls, call)
- return m.output, m.err
-}
-
-func TestListDecisions_Success(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- mockExec := &mockCommandExecutor{
- output: []byte(`[{"id":1,"origin":"cscli","type":"ban","scope":"ip","value":"192.168.1.100","duration":"4h","scenario":"manual 'ban' from 'localhost'","created_at":"2025-12-05T10:00:00Z","until":"2025-12-05T14:00:00Z"}]`),
- }
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
- h.CmdExec = mockExec
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var resp map[string]interface{}
- err := json.Unmarshal(w.Body.Bytes(), &resp)
- require.NoError(t, err)
-
- decisions := resp["decisions"].([]interface{})
- assert.Len(t, decisions, 1)
-
- decision := decisions[0].(map[string]interface{})
- assert.Equal(t, "192.168.1.100", decision["value"])
- assert.Equal(t, "ban", decision["type"])
- assert.Equal(t, "ip", decision["scope"])
-
- // Verify cscli was called with correct args
- require.Len(t, mockExec.calls, 1)
- assert.Equal(t, []string{"cscli", "decisions", "list", "-o", "json"}, mockExec.calls[0])
-}
-
-func TestListDecisions_EmptyList(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- mockExec := &mockCommandExecutor{
- output: []byte("null"),
- }
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
- h.CmdExec = mockExec
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var resp map[string]interface{}
- err := json.Unmarshal(w.Body.Bytes(), &resp)
- require.NoError(t, err)
-
- decisions := resp["decisions"].([]interface{})
- assert.Len(t, decisions, 0)
- assert.Equal(t, float64(0), resp["total"])
-}
-
-func TestListDecisions_CscliError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- mockExec := &mockCommandExecutor{
- err: errors.New("cscli not found"),
- }
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
- h.CmdExec = mockExec
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
- r.ServeHTTP(w, req)
-
- // Should return 200 with empty list and error message
- assert.Equal(t, http.StatusOK, w.Code)
-
- var resp map[string]interface{}
- err := json.Unmarshal(w.Body.Bytes(), &resp)
- require.NoError(t, err)
-
- decisions := resp["decisions"].([]interface{})
- assert.Len(t, decisions, 0)
- assert.Contains(t, resp["error"], "cscli not available")
-}
-
-func TestListDecisions_InvalidJSON(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- mockExec := &mockCommandExecutor{
- output: []byte("invalid json"),
- }
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
- h.CmdExec = mockExec
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusInternalServerError, w.Code)
- assert.Contains(t, w.Body.String(), "failed to parse decisions")
-}
-
-func TestBanIP_Success(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- mockExec := &mockCommandExecutor{
- output: []byte(""),
- }
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
- h.CmdExec = mockExec
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- payload := BanIPRequest{
- IP: "192.168.1.100",
- Duration: "24h",
- Reason: "suspicious activity",
- }
- b, _ := json.Marshal(payload)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var resp map[string]interface{}
- err := json.Unmarshal(w.Body.Bytes(), &resp)
- require.NoError(t, err)
-
- assert.Equal(t, "banned", resp["status"])
- assert.Equal(t, "192.168.1.100", resp["ip"])
- assert.Equal(t, "24h", resp["duration"])
-
- // Verify cscli was called with correct args
- require.Len(t, mockExec.calls, 1)
- assert.Equal(t, "cscli", mockExec.calls[0][0])
- assert.Equal(t, "decisions", mockExec.calls[0][1])
- assert.Equal(t, "add", mockExec.calls[0][2])
- assert.Equal(t, "-i", mockExec.calls[0][3])
- assert.Equal(t, "192.168.1.100", mockExec.calls[0][4])
- assert.Equal(t, "-d", mockExec.calls[0][5])
- assert.Equal(t, "24h", mockExec.calls[0][6])
- assert.Equal(t, "-R", mockExec.calls[0][7])
- assert.Equal(t, "manual ban: suspicious activity", mockExec.calls[0][8])
-}
-
-func TestBanIP_DefaultDuration(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- mockExec := &mockCommandExecutor{
- output: []byte(""),
- }
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
- h.CmdExec = mockExec
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- payload := BanIPRequest{
- IP: "10.0.0.1",
- }
- b, _ := json.Marshal(payload)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var resp map[string]interface{}
- err := json.Unmarshal(w.Body.Bytes(), &resp)
- require.NoError(t, err)
-
- // Duration should default to 24h
- assert.Equal(t, "24h", resp["duration"])
-
- // Verify cscli was called with default duration
- require.Len(t, mockExec.calls, 1)
- assert.Equal(t, "24h", mockExec.calls[0][6])
-}
-
-func TestBanIP_MissingIP(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- payload := map[string]string{}
- b, _ := json.Marshal(payload)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
- assert.Contains(t, w.Body.String(), "ip is required")
-}
-
-func TestBanIP_EmptyIP(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- payload := BanIPRequest{
- IP: " ",
- }
- b, _ := json.Marshal(payload)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
- assert.Contains(t, w.Body.String(), "ip cannot be empty")
-}
-
-func TestBanIP_CscliError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- mockExec := &mockCommandExecutor{
- err: errors.New("cscli failed"),
- }
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
- h.CmdExec = mockExec
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- payload := BanIPRequest{
- IP: "192.168.1.100",
- }
- b, _ := json.Marshal(payload)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusInternalServerError, w.Code)
- assert.Contains(t, w.Body.String(), "failed to ban IP")
-}
-
-func TestUnbanIP_Success(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- mockExec := &mockCommandExecutor{
- output: []byte(""),
- }
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
- h.CmdExec = mockExec
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/ban/192.168.1.100", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var resp map[string]interface{}
- err := json.Unmarshal(w.Body.Bytes(), &resp)
- require.NoError(t, err)
-
- assert.Equal(t, "unbanned", resp["status"])
- assert.Equal(t, "192.168.1.100", resp["ip"])
-
- // Verify cscli was called with correct args
- require.Len(t, mockExec.calls, 1)
- assert.Equal(t, []string{"cscli", "decisions", "delete", "-i", "192.168.1.100"}, mockExec.calls[0])
-}
-
-func TestUnbanIP_CscliError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- mockExec := &mockCommandExecutor{
- err: errors.New("cscli failed"),
- }
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
- h.CmdExec = mockExec
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodDelete, "/api/v1/admin/crowdsec/ban/192.168.1.100", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusInternalServerError, w.Code)
- assert.Contains(t, w.Body.String(), "failed to unban IP")
-}
-
-func TestListDecisions_MultipleDecisions(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- mockExec := &mockCommandExecutor{
- output: []byte(`[
- {"id":1,"origin":"cscli","type":"ban","scope":"ip","value":"192.168.1.100","duration":"4h","scenario":"manual ban","created_at":"2025-12-05T10:00:00Z"},
- {"id":2,"origin":"crowdsec","type":"ban","scope":"ip","value":"10.0.0.50","duration":"1h","scenario":"ssh-bf","created_at":"2025-12-05T11:00:00Z"},
- {"id":3,"origin":"cscli","type":"ban","scope":"range","value":"172.16.0.0/24","duration":"24h","scenario":"manual ban","created_at":"2025-12-05T12:00:00Z"}
- ]`),
- }
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
- h.CmdExec = mockExec
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var resp map[string]interface{}
- err := json.Unmarshal(w.Body.Bytes(), &resp)
- require.NoError(t, err)
-
- decisions := resp["decisions"].([]interface{})
- assert.Len(t, decisions, 3)
- assert.Equal(t, float64(3), resp["total"])
-
- // Verify each decision
- d1 := decisions[0].(map[string]interface{})
- assert.Equal(t, "192.168.1.100", d1["value"])
- assert.Equal(t, "cscli", d1["origin"])
-
- d2 := decisions[1].(map[string]interface{})
- assert.Equal(t, "10.0.0.50", d2["value"])
- assert.Equal(t, "crowdsec", d2["origin"])
- assert.Equal(t, "ssh-bf", d2["scenario"])
-
- d3 := decisions[2].(map[string]interface{})
- assert.Equal(t, "172.16.0.0/24", d3["value"])
- assert.Equal(t, "range", d3["scope"])
-}
-
-func TestBanIP_InvalidJSON(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/ban", bytes.NewReader([]byte("invalid json")))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
- assert.Contains(t, w.Body.String(), "ip is required")
-}
diff --git a/backend/internal/api/handlers/crowdsec_exec.go b/backend/internal/api/handlers/crowdsec_exec.go
deleted file mode 100644
index 7214f418..00000000
--- a/backend/internal/api/handlers/crowdsec_exec.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package handlers
-
-import (
- "context"
- "errors"
- "fmt"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "syscall"
-)
-
-// DefaultCrowdsecExecutor implements CrowdsecExecutor using OS processes.
-type DefaultCrowdsecExecutor struct {
-}
-
-func NewDefaultCrowdsecExecutor() *DefaultCrowdsecExecutor { return &DefaultCrowdsecExecutor{} }
-
-func (e *DefaultCrowdsecExecutor) pidFile(configDir string) string {
- return filepath.Join(configDir, "crowdsec.pid")
-}
-
-func (e *DefaultCrowdsecExecutor) Start(ctx context.Context, binPath, configDir string) (int, error) {
- cmd := exec.CommandContext(ctx, binPath, "--config-dir", configDir)
- cmd.Stdout = os.Stdout
- cmd.Stderr = os.Stderr
- if err := cmd.Start(); err != nil {
- return 0, err
- }
- pid := cmd.Process.Pid
- // write pid file
- if err := os.WriteFile(e.pidFile(configDir), []byte(strconv.Itoa(pid)), 0o644); err != nil {
- return pid, fmt.Errorf("failed to write pid file: %w", err)
- }
- // wait in background
- go func() {
- _ = cmd.Wait()
- _ = os.Remove(e.pidFile(configDir))
- }()
- return pid, nil
-}
-
-func (e *DefaultCrowdsecExecutor) Stop(ctx context.Context, configDir string) error {
- b, err := os.ReadFile(e.pidFile(configDir))
- if err != nil {
- return fmt.Errorf("pid file read: %w", err)
- }
- pid, err := strconv.Atoi(string(b))
- if err != nil {
- return fmt.Errorf("invalid pid: %w", err)
- }
- proc, err := os.FindProcess(pid)
- if err != nil {
- return err
- }
- if err := proc.Signal(syscall.SIGTERM); err != nil {
- return err
- }
- // best-effort remove pid file
- _ = os.Remove(e.pidFile(configDir))
- return nil
-}
-
-func (e *DefaultCrowdsecExecutor) Status(ctx context.Context, configDir string) (running bool, pid int, err error) {
- b, err := os.ReadFile(e.pidFile(configDir))
- if err != nil {
- // Missing pid file is treated as not running
- return false, 0, nil
- }
-
- pid, err = strconv.Atoi(string(b))
- if err != nil {
- // Malformed pid file is treated as not running
- return false, 0, nil
- }
-
- proc, err := os.FindProcess(pid)
- if err != nil {
- // Process lookup failures are treated as not running
- return false, pid, nil
- }
-
- // Sending signal 0 is not portable on Windows, but OK for Linux containers
- if err = proc.Signal(syscall.Signal(0)); err != nil {
- if errors.Is(err, os.ErrProcessDone) {
- return false, pid, nil
- }
- // ESRCH or other errors mean process isn't running
- return false, pid, nil
- }
-
- return true, pid, nil
-}
diff --git a/backend/internal/api/handlers/crowdsec_exec_test.go b/backend/internal/api/handlers/crowdsec_exec_test.go
deleted file mode 100644
index 571131eb..00000000
--- a/backend/internal/api/handlers/crowdsec_exec_test.go
+++ /dev/null
@@ -1,167 +0,0 @@
-package handlers
-
-import (
- "context"
- "os"
- "path/filepath"
- "strconv"
- "testing"
- "time"
-
- "github.com/stretchr/testify/assert"
-)
-
-func TestDefaultCrowdsecExecutorPidFile(t *testing.T) {
- e := NewDefaultCrowdsecExecutor()
- tmp := t.TempDir()
- expected := filepath.Join(tmp, "crowdsec.pid")
- if p := e.pidFile(tmp); p != expected {
- t.Fatalf("pidFile mismatch got %s expected %s", p, expected)
- }
-}
-
-func TestDefaultCrowdsecExecutorStartStatusStop(t *testing.T) {
- e := NewDefaultCrowdsecExecutor()
- tmp := t.TempDir()
-
- // create a tiny script that sleeps and traps TERM
- script := filepath.Join(tmp, "runscript.sh")
- content := `#!/bin/sh
-trap 'exit 0' TERM INT
-while true; do sleep 1; done
-`
- if err := os.WriteFile(script, []byte(content), 0o755); err != nil {
- t.Fatalf("write script: %v", err)
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
- defer cancel()
-
- pid, err := e.Start(ctx, script, tmp)
- if err != nil {
- t.Fatalf("start err: %v", err)
- }
- if pid <= 0 {
- t.Fatalf("invalid pid %d", pid)
- }
-
- // ensure pid file exists and content matches
- pidB, err := os.ReadFile(e.pidFile(tmp))
- if err != nil {
- t.Fatalf("read pid file: %v", err)
- }
- gotPid, _ := strconv.Atoi(string(pidB))
- if gotPid != pid {
- t.Fatalf("pid file mismatch got %d expected %d", gotPid, pid)
- }
-
- // Status should return running
- running, got, err := e.Status(ctx, tmp)
- if err != nil {
- t.Fatalf("status err: %v", err)
- }
- if !running || got != pid {
- t.Fatalf("status expected running for %d got %d running=%v", pid, got, running)
- }
-
- // Stop should terminate and remove pid file
- if err := e.Stop(ctx, tmp); err != nil {
- t.Fatalf("stop err: %v", err)
- }
-
- // give a little time for process to exit
- time.Sleep(200 * time.Millisecond)
-
- running2, _, _ := e.Status(ctx, tmp)
- if running2 {
- t.Fatalf("process still running after stop")
- }
-}
-
-// Additional coverage tests for error paths
-
-func TestDefaultCrowdsecExecutor_Status_NoPidFile(t *testing.T) {
- exec := NewDefaultCrowdsecExecutor()
- tmpDir := t.TempDir()
-
- running, pid, err := exec.Status(context.Background(), tmpDir)
-
- assert.NoError(t, err)
- assert.False(t, running)
- assert.Equal(t, 0, pid)
-}
-
-func TestDefaultCrowdsecExecutor_Status_InvalidPid(t *testing.T) {
- exec := NewDefaultCrowdsecExecutor()
- tmpDir := t.TempDir()
-
- // Write invalid pid
- os.WriteFile(filepath.Join(tmpDir, "crowdsec.pid"), []byte("invalid"), 0o644)
-
- running, pid, err := exec.Status(context.Background(), tmpDir)
-
- assert.NoError(t, err)
- assert.False(t, running)
- assert.Equal(t, 0, pid)
-}
-
-func TestDefaultCrowdsecExecutor_Status_NonExistentProcess(t *testing.T) {
- exec := NewDefaultCrowdsecExecutor()
- tmpDir := t.TempDir()
-
- // Write a pid that doesn't exist
- // Use a very high PID that's unlikely to exist
- os.WriteFile(filepath.Join(tmpDir, "crowdsec.pid"), []byte("999999999"), 0o644)
-
- running, pid, err := exec.Status(context.Background(), tmpDir)
-
- assert.NoError(t, err)
- assert.False(t, running)
- assert.Equal(t, 999999999, pid)
-}
-
-func TestDefaultCrowdsecExecutor_Stop_NoPidFile(t *testing.T) {
- exec := NewDefaultCrowdsecExecutor()
- tmpDir := t.TempDir()
-
- err := exec.Stop(context.Background(), tmpDir)
-
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "pid file read")
-}
-
-func TestDefaultCrowdsecExecutor_Stop_InvalidPid(t *testing.T) {
- exec := NewDefaultCrowdsecExecutor()
- tmpDir := t.TempDir()
-
- // Write invalid pid
- os.WriteFile(filepath.Join(tmpDir, "crowdsec.pid"), []byte("invalid"), 0o644)
-
- err := exec.Stop(context.Background(), tmpDir)
-
- assert.Error(t, err)
- assert.Contains(t, err.Error(), "invalid pid")
-}
-
-func TestDefaultCrowdsecExecutor_Stop_NonExistentProcess(t *testing.T) {
- exec := NewDefaultCrowdsecExecutor()
- tmpDir := t.TempDir()
-
- // Write a pid that doesn't exist
- os.WriteFile(filepath.Join(tmpDir, "crowdsec.pid"), []byte("999999999"), 0o644)
-
- err := exec.Stop(context.Background(), tmpDir)
-
- // Should fail with signal error
- assert.Error(t, err)
-}
-
-func TestDefaultCrowdsecExecutor_Start_InvalidBinary(t *testing.T) {
- exec := NewDefaultCrowdsecExecutor()
- tmpDir := t.TempDir()
-
- pid, err := exec.Start(context.Background(), "/nonexistent/binary", tmpDir)
-
- assert.Error(t, err)
- assert.Equal(t, 0, pid)
-}
diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go
deleted file mode 100644
index 05378d54..00000000
--- a/backend/internal/api/handlers/crowdsec_handler.go
+++ /dev/null
@@ -1,1017 +0,0 @@
-package handlers
-
-import (
- "archive/tar"
- "compress/gzip"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "os"
- "os/exec"
- "path/filepath"
- "strconv"
- "strings"
- "time"
-
- "github.com/Wikid82/charon/backend/internal/crowdsec"
- "github.com/Wikid82/charon/backend/internal/logger"
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
-
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-)
-
-// CrowdsecExecutor abstracts starting/stopping CrowdSec so tests can mock it.
-type CrowdsecExecutor interface {
- Start(ctx context.Context, binPath, configDir string) (int, error)
- Stop(ctx context.Context, configDir string) error
- Status(ctx context.Context, configDir string) (running bool, pid int, err error)
-}
-
-// CommandExecutor abstracts command execution for testing.
-type CommandExecutor interface {
- Execute(ctx context.Context, name string, args ...string) ([]byte, error)
-}
-
-// RealCommandExecutor executes commands using os/exec.
-type RealCommandExecutor struct{}
-
-// Execute runs a command and returns its combined output (stdout/stderr)
-func (r *RealCommandExecutor) Execute(ctx context.Context, name string, args ...string) ([]byte, error) {
- cmd := exec.CommandContext(ctx, name, args...)
- return cmd.CombinedOutput()
-}
-
-// CrowdsecHandler manages CrowdSec process and config imports.
-type CrowdsecHandler struct {
- DB *gorm.DB
- Executor CrowdsecExecutor
- CmdExec CommandExecutor
- BinPath string
- DataDir string
- Hub *crowdsec.HubService
- Console *crowdsec.ConsoleEnrollmentService
- Security *services.SecurityService
-}
-
-func ttlRemainingSeconds(now time.Time, retrievedAt time.Time, ttl time.Duration) *int64 {
- if retrievedAt.IsZero() || ttl <= 0 {
- return nil
- }
- remaining := retrievedAt.Add(ttl).Sub(now)
- if remaining < 0 {
- var zero int64
- return &zero
- }
- secs := int64(remaining.Seconds())
- return &secs
-}
-
-func mapCrowdsecStatus(err error, defaultCode int) int {
- if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
- return http.StatusGatewayTimeout
- }
- return defaultCode
-}
-
-func NewCrowdsecHandler(db *gorm.DB, executor CrowdsecExecutor, binPath, dataDir string) *CrowdsecHandler {
- cacheDir := filepath.Join(dataDir, "hub_cache")
- cache, err := crowdsec.NewHubCache(cacheDir, 24*time.Hour)
- if err != nil {
- logger.Log().WithError(err).Warn("failed to init crowdsec hub cache")
- }
- hubSvc := crowdsec.NewHubService(&RealCommandExecutor{}, cache, dataDir)
- consoleSecret := os.Getenv("CHARON_CONSOLE_ENCRYPTION_KEY")
- if consoleSecret == "" {
- consoleSecret = os.Getenv("CHARON_JWT_SECRET")
- }
- var securitySvc *services.SecurityService
- var consoleSvc *crowdsec.ConsoleEnrollmentService
- if db != nil {
- securitySvc = services.NewSecurityService(db)
- consoleSvc = crowdsec.NewConsoleEnrollmentService(db, &crowdsec.SecureCommandExecutor{}, dataDir, consoleSecret)
- }
- return &CrowdsecHandler{
- DB: db,
- Executor: executor,
- CmdExec: &RealCommandExecutor{},
- BinPath: binPath,
- DataDir: dataDir,
- Hub: hubSvc,
- Console: consoleSvc,
- Security: securitySvc,
- }
-}
-
-// isCerberusEnabled returns true when Cerberus is enabled via DB or env flag.
-func (h *CrowdsecHandler) isCerberusEnabled() bool {
- if h.DB != nil && h.DB.Migrator().HasTable(&models.Setting{}) {
- var s models.Setting
- if err := h.DB.Where("key = ?", "feature.cerberus.enabled").First(&s).Error; err == nil {
- v := strings.ToLower(strings.TrimSpace(s.Value))
- return v == "true" || v == "1" || v == "yes"
- }
- }
-
- if envVal, ok := os.LookupEnv("FEATURE_CERBERUS_ENABLED"); ok {
- if b, err := strconv.ParseBool(envVal); err == nil {
- return b
- }
- return envVal == "1"
- }
-
- if envVal, ok := os.LookupEnv("CERBERUS_ENABLED"); ok {
- if b, err := strconv.ParseBool(envVal); err == nil {
- return b
- }
- return envVal == "1"
- }
-
- return true
-}
-
-// isConsoleEnrollmentEnabled toggles console enrollment via DB or env flag.
-func (h *CrowdsecHandler) isConsoleEnrollmentEnabled() bool {
- const key = "feature.crowdsec.console_enrollment"
- if h.DB != nil && h.DB.Migrator().HasTable(&models.Setting{}) {
- var s models.Setting
- if err := h.DB.Where("key = ?", key).First(&s).Error; err == nil {
- v := strings.ToLower(strings.TrimSpace(s.Value))
- return v == "true" || v == "1" || v == "yes"
- }
- }
-
- if envVal, ok := os.LookupEnv("FEATURE_CROWDSEC_CONSOLE_ENROLLMENT"); ok {
- if b, err := strconv.ParseBool(envVal); err == nil {
- return b
- }
- return envVal == "1"
- }
-
- return false
-}
-
-func actorFromContext(c *gin.Context) string {
- if id, ok := c.Get("userID"); ok {
- return fmt.Sprintf("user:%v", id)
- }
- return "unknown"
-}
-
-func (h *CrowdsecHandler) hubEndpoints() []string {
- if h.Hub == nil {
- return nil
- }
- set := make(map[string]struct{})
- for _, e := range []string{h.Hub.HubBaseURL, h.Hub.MirrorBaseURL} {
- if e == "" {
- continue
- }
- set[e] = struct{}{}
- }
- out := make([]string, 0, len(set))
- for k := range set {
- out = append(out, k)
- }
- return out
-}
-
-// Start starts the CrowdSec process.
-func (h *CrowdsecHandler) Start(c *gin.Context) {
- ctx := c.Request.Context()
- pid, err := h.Executor.Start(ctx, h.BinPath, h.DataDir)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"status": "started", "pid": pid})
-}
-
-// Stop stops the CrowdSec process.
-func (h *CrowdsecHandler) Stop(c *gin.Context) {
- ctx := c.Request.Context()
- if err := h.Executor.Stop(ctx, h.DataDir); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"status": "stopped"})
-}
-
-// Status returns simple running state.
-func (h *CrowdsecHandler) Status(c *gin.Context) {
- ctx := c.Request.Context()
- running, pid, err := h.Executor.Status(ctx, h.DataDir)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"running": running, "pid": pid})
-}
-
-// ImportConfig accepts a tar.gz or zip upload and extracts into DataDir (backing up existing config).
-func (h *CrowdsecHandler) ImportConfig(c *gin.Context) {
- file, err := c.FormFile("file")
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "file required"})
- return
- }
-
- // Save to temp file
- tmpDir := os.TempDir()
- tmpPath := filepath.Join(tmpDir, fmt.Sprintf("crowdsec-import-%d", time.Now().UnixNano()))
- if err := os.MkdirAll(tmpPath, 0o755); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create temp dir"})
- return
- }
-
- dst := filepath.Join(tmpPath, file.Filename)
- if err := c.SaveUploadedFile(file, dst); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save upload"})
- return
- }
-
- // For safety, do minimal validation: ensure file non-empty
- fi, err := os.Stat(dst)
- if err != nil || fi.Size() == 0 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "empty upload"})
- return
- }
-
- // Backup current config
- backupDir := h.DataDir + ".backup." + time.Now().Format("20060102-150405")
- if _, err := os.Stat(h.DataDir); err == nil {
- _ = os.Rename(h.DataDir, backupDir)
- }
- // Create target dir
- if err := os.MkdirAll(h.DataDir, 0o755); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config dir"})
- return
- }
-
- // For now, simply copy uploaded file into data dir for operator to handle extraction
- target := filepath.Join(h.DataDir, file.Filename)
- in, err := os.Open(dst)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to open temp file"})
- return
- }
- defer func() {
- if err := in.Close(); err != nil {
- logger.Log().WithError(err).Warn("failed to close temp file")
- }
- }()
- out, err := os.Create(target)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create target file"})
- return
- }
- defer func() {
- if err := out.Close(); err != nil {
- logger.Log().WithError(err).Warn("failed to close target file")
- }
- }()
- if _, err := io.Copy(out, in); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write config"})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"status": "imported", "backup": backupDir})
-}
-
-// ExportConfig creates a tar.gz archive of the CrowdSec data directory and streams it
-// back to the client as a downloadable file.
-func (h *CrowdsecHandler) ExportConfig(c *gin.Context) {
- // Ensure DataDir exists
- if _, err := os.Stat(h.DataDir); os.IsNotExist(err) {
- c.JSON(http.StatusNotFound, gin.H{"error": "crowdsec config not found"})
- return
- }
-
- // Create a gzip writer and tar writer that stream directly to the response
- c.Header("Content-Type", "application/gzip")
- filename := fmt.Sprintf("crowdsec-config-%s.tar.gz", time.Now().Format("20060102-150405"))
- c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
- gw := gzip.NewWriter(c.Writer)
- defer func() {
- if err := gw.Close(); err != nil {
- logger.Log().WithError(err).Warn("Failed to close gzip writer")
- }
- }()
- tw := tar.NewWriter(gw)
- defer func() {
- if err := tw.Close(); err != nil {
- logger.Log().WithError(err).Warn("Failed to close tar writer")
- }
- }()
-
- // Walk the DataDir and add files to the archive
- err := filepath.Walk(h.DataDir, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
- if info.IsDir() {
- return nil
- }
- rel, err := filepath.Rel(h.DataDir, path)
- if err != nil {
- return err
- }
- // Open file
- f, err := os.Open(path)
- if err != nil {
- return err
- }
- defer func() {
- if err := f.Close(); err != nil {
- logger.Log().WithError(err).Warn("failed to close file while archiving", "path", path)
- }
- }()
-
- hdr := &tar.Header{
- Name: rel,
- Size: info.Size(),
- Mode: int64(info.Mode()),
- ModTime: info.ModTime(),
- }
- if err := tw.WriteHeader(hdr); err != nil {
- return err
- }
- if _, err := io.Copy(tw, f); err != nil {
- return err
- }
- return nil
- })
- if err != nil {
- // If any error occurred while creating the archive, return 500
- c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-}
-
-// ListFiles returns a flat list of files under the CrowdSec DataDir.
-func (h *CrowdsecHandler) ListFiles(c *gin.Context) {
- var files []string
- if _, err := os.Stat(h.DataDir); os.IsNotExist(err) {
- c.JSON(http.StatusOK, gin.H{"files": files})
- return
- }
- err := filepath.Walk(h.DataDir, func(path string, info os.FileInfo, err error) error {
- if err != nil {
- return err
- }
- if !info.IsDir() {
- rel, err := filepath.Rel(h.DataDir, path)
- if err != nil {
- return err
- }
- files = append(files, rel)
- }
- return nil
- })
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"files": files})
-}
-
-// ReadFile returns the contents of a specific file under DataDir. Query param 'path' required.
-func (h *CrowdsecHandler) ReadFile(c *gin.Context) {
- rel := c.Query("path")
- if rel == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
- return
- }
- clean := filepath.Clean(rel)
- // prevent directory traversal
- p := filepath.Join(h.DataDir, clean)
- if !strings.HasPrefix(p, filepath.Clean(h.DataDir)) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
- return
- }
- data, err := os.ReadFile(p)
- if err != nil {
- if os.IsNotExist(err) {
- c.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- c.JSON(http.StatusOK, gin.H{"content": string(data)})
-}
-
-// WriteFile writes content to a file under the CrowdSec DataDir, creating a backup before doing so.
-// JSON body: { "path": "relative/path.conf", "content": "..." }
-func (h *CrowdsecHandler) WriteFile(c *gin.Context) {
- var payload struct {
- Path string `json:"path"`
- Content string `json:"content"`
- }
- if err := c.ShouldBindJSON(&payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
- return
- }
- if payload.Path == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "path required"})
- return
- }
- clean := filepath.Clean(payload.Path)
- p := filepath.Join(h.DataDir, clean)
- if !strings.HasPrefix(p, filepath.Clean(h.DataDir)) {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"})
- return
- }
- // Backup existing DataDir
- backupDir := h.DataDir + ".backup." + time.Now().Format("20060102-150405")
- if _, err := os.Stat(h.DataDir); err == nil {
- if err := os.Rename(h.DataDir, backupDir); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create backup"})
- return
- }
- }
- // Recreate DataDir and write file
- if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare dir"})
- return
- }
- if err := os.WriteFile(p, []byte(payload.Content), 0o644); err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to write file"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"status": "written", "backup": backupDir})
-}
-
-// ListPresets returns the curated preset catalog when Cerberus is enabled.
-func (h *CrowdsecHandler) ListPresets(c *gin.Context) {
- if !h.isCerberusEnabled() {
- c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
- return
- }
-
- type presetInfo struct {
- crowdsec.Preset
- Available bool `json:"available"`
- Cached bool `json:"cached"`
- CacheKey string `json:"cache_key,omitempty"`
- Etag string `json:"etag,omitempty"`
- RetrievedAt *time.Time `json:"retrieved_at,omitempty"`
- TTLRemainingSeconds *int64 `json:"ttl_remaining_seconds,omitempty"`
- }
-
- result := map[string]*presetInfo{}
- for _, p := range crowdsec.ListCuratedPresets() {
- cp := p
- result[p.Slug] = &presetInfo{Preset: cp, Available: true}
- }
-
- // Merge hub index when available
- if h.Hub != nil {
- ctx := c.Request.Context()
- if idx, err := h.Hub.FetchIndex(ctx); err == nil {
- for _, item := range idx.Items {
- slug := strings.TrimSpace(item.Name)
- if slug == "" {
- continue
- }
- if _, ok := result[slug]; !ok {
- result[slug] = &presetInfo{Preset: crowdsec.Preset{
- Slug: slug,
- Title: item.Title,
- Summary: item.Description,
- Source: "hub",
- Tags: []string{item.Type},
- RequiresHub: true,
- }, Available: true}
- } else {
- result[slug].Available = true
- }
- }
- } else {
- logger.Log().WithError(err).Warn("crowdsec hub index unavailable")
- }
- }
-
- // Merge cache metadata
- if h.Hub != nil && h.Hub.Cache != nil {
- ctx := c.Request.Context()
- if cached, err := h.Hub.Cache.List(ctx); err == nil {
- cacheTTL := h.Hub.Cache.TTL()
- now := time.Now().UTC()
- for _, entry := range cached {
- if _, ok := result[entry.Slug]; !ok {
- result[entry.Slug] = &presetInfo{Preset: crowdsec.Preset{Slug: entry.Slug, Title: entry.Slug, Summary: "cached preset", Source: "hub", RequiresHub: true}}
- }
- result[entry.Slug].Cached = true
- result[entry.Slug].CacheKey = entry.CacheKey
- result[entry.Slug].Etag = entry.Etag
- if !entry.RetrievedAt.IsZero() {
- val := entry.RetrievedAt
- result[entry.Slug].RetrievedAt = &val
- }
- result[entry.Slug].TTLRemainingSeconds = ttlRemainingSeconds(now, entry.RetrievedAt, cacheTTL)
- }
- } else {
- logger.Log().WithError(err).Warn("crowdsec hub cache list failed")
- }
- }
-
- list := make([]presetInfo, 0, len(result))
- for _, v := range result {
- list = append(list, *v)
- }
-
- c.JSON(http.StatusOK, gin.H{"presets": list})
-}
-
-// PullPreset downloads and caches a hub preset while returning a preview.
-func (h *CrowdsecHandler) PullPreset(c *gin.Context) {
- if !h.isCerberusEnabled() {
- c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
- return
- }
-
- var payload struct {
- Slug string `json:"slug"`
- }
- if err := c.ShouldBindJSON(&payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
- return
- }
- slug := strings.TrimSpace(payload.Slug)
- if slug == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "slug required"})
- return
- }
- if h.Hub == nil {
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub service unavailable"})
- return
- }
-
- // Check for curated preset that doesn't require hub
- if preset, ok := crowdsec.FindPreset(slug); ok && !preset.RequiresHub {
- c.JSON(http.StatusOK, gin.H{
- "status": "pulled",
- "slug": preset.Slug,
- "preview": "# Curated preset: " + preset.Title + "\n# " + preset.Summary,
- "cache_key": "curated-" + preset.Slug,
- "etag": "curated",
- "retrieved_at": time.Now(),
- "source": "charon-curated",
- })
- return
- }
-
- ctx := c.Request.Context()
- // Log cache directory before pull
- if h.Hub != nil && h.Hub.Cache != nil {
- cacheDir := filepath.Join(h.DataDir, "hub_cache")
- logger.Log().WithField("cache_dir", cacheDir).WithField("slug", slug).Info("attempting to pull preset")
- if stat, err := os.Stat(cacheDir); err == nil {
- logger.Log().WithField("cache_dir_mode", stat.Mode()).WithField("cache_dir_writable", stat.Mode().Perm()&0o200 != 0).Debug("cache directory exists")
- } else {
- logger.Log().WithError(err).Warn("cache directory stat failed")
- }
- }
-
- res, err := h.Hub.Pull(ctx, slug)
- if err != nil {
- status := mapCrowdsecStatus(err, http.StatusBadGateway)
- logger.Log().WithError(err).WithField("slug", slug).WithField("hub_base_url", h.Hub.HubBaseURL).Warn("crowdsec preset pull failed")
- c.JSON(status, gin.H{"error": err.Error(), "hub_endpoints": h.hubEndpoints()})
- return
- }
-
- // Verify cache was actually stored
- logger.Log().WithField("slug", res.Meta.Slug).WithField("cache_key", res.Meta.CacheKey).WithField("archive_path", res.Meta.ArchivePath).WithField("preview_path", res.Meta.PreviewPath).Info("preset pulled and cached successfully")
-
- // Verify files exist on disk
- if _, err := os.Stat(res.Meta.ArchivePath); err != nil {
- logger.Log().WithError(err).WithField("archive_path", res.Meta.ArchivePath).Error("cached archive file not found after pull")
- }
- if _, err := os.Stat(res.Meta.PreviewPath); err != nil {
- logger.Log().WithError(err).WithField("preview_path", res.Meta.PreviewPath).Error("cached preview file not found after pull")
- }
-
- c.JSON(http.StatusOK, gin.H{
- "status": "pulled",
- "slug": res.Meta.Slug,
- "preview": res.Preview,
- "cache_key": res.Meta.CacheKey,
- "etag": res.Meta.Etag,
- "retrieved_at": res.Meta.RetrievedAt,
- "source": res.Meta.Source,
- })
-}
-
-// ApplyPreset installs a pulled preset from cache or via cscli.
-func (h *CrowdsecHandler) ApplyPreset(c *gin.Context) {
- if !h.isCerberusEnabled() {
- c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
- return
- }
-
- var payload struct {
- Slug string `json:"slug"`
- }
- if err := c.ShouldBindJSON(&payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
- return
- }
-
- slug := strings.TrimSpace(payload.Slug)
- if slug == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "slug required"})
- return
- }
- if h.Hub == nil {
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub service unavailable"})
- return
- }
-
- // Check for curated preset that doesn't require hub
- if preset, ok := crowdsec.FindPreset(slug); ok && !preset.RequiresHub {
- if h.DB != nil {
- _ = h.DB.Create(&models.CrowdsecPresetEvent{
- Slug: slug,
- Action: "apply",
- Status: "applied",
- CacheKey: "curated-" + slug,
- BackupPath: "",
- }).Error
- }
-
- c.JSON(http.StatusOK, gin.H{
- "status": "applied",
- "backup": "",
- "reload_hint": true,
- "used_cscli": false,
- "cache_key": "curated-" + slug,
- "slug": slug,
- })
- return
- }
-
- ctx := c.Request.Context()
-
- // Log cache status before apply
- if h.Hub != nil && h.Hub.Cache != nil {
- cacheDir := filepath.Join(h.DataDir, "hub_cache")
- logger.Log().WithField("cache_dir", cacheDir).WithField("slug", slug).Info("attempting to apply preset")
-
- // Check if cached
- if cached, err := h.Hub.Cache.Load(ctx, slug); err == nil {
- logger.Log().WithField("slug", slug).WithField("cache_key", cached.CacheKey).WithField("archive_path", cached.ArchivePath).WithField("preview_path", cached.PreviewPath).Info("preset found in cache")
- // Verify files still exist
- if _, err := os.Stat(cached.ArchivePath); err != nil {
- logger.Log().WithError(err).WithField("archive_path", cached.ArchivePath).Error("cached archive file missing")
- }
- if _, err := os.Stat(cached.PreviewPath); err != nil {
- logger.Log().WithError(err).WithField("preview_path", cached.PreviewPath).Error("cached preview file missing")
- }
- } else {
- logger.Log().WithError(err).WithField("slug", slug).Warn("preset not found in cache before apply")
- // List what's actually in the cache
- if entries, listErr := h.Hub.Cache.List(ctx); listErr == nil {
- slugs := make([]string, len(entries))
- for i, e := range entries {
- slugs[i] = e.Slug
- }
- logger.Log().WithField("cached_slugs", slugs).Info("current cache contents")
- }
- }
- }
-
- res, err := h.Hub.Apply(ctx, slug)
- if err != nil {
- status := mapCrowdsecStatus(err, http.StatusInternalServerError)
- logger.Log().WithError(err).WithField("slug", slug).WithField("hub_base_url", h.Hub.HubBaseURL).WithField("backup_path", res.BackupPath).WithField("cache_key", res.CacheKey).Warn("crowdsec preset apply failed")
- if h.DB != nil {
- _ = h.DB.Create(&models.CrowdsecPresetEvent{Slug: slug, Action: "apply", Status: "failed", CacheKey: res.CacheKey, BackupPath: res.BackupPath, Error: err.Error()}).Error
- }
- // Build detailed error response
- errorMsg := err.Error()
- // Add actionable guidance based on error type
- if errors.Is(err, crowdsec.ErrCacheMiss) || strings.Contains(errorMsg, "cache miss") {
- errorMsg = "Preset cache missing or expired. Pull the preset again, then retry apply."
- } else if strings.Contains(errorMsg, "cscli unavailable") && strings.Contains(errorMsg, "no cached preset") {
- errorMsg = "CrowdSec preset not cached. Pull the preset first by clicking 'Pull Preview', then try applying again."
- }
- errorResponse := gin.H{"error": errorMsg, "hub_endpoints": h.hubEndpoints()}
- if res.BackupPath != "" {
- errorResponse["backup"] = res.BackupPath
- }
- if res.CacheKey != "" {
- errorResponse["cache_key"] = res.CacheKey
- }
- c.JSON(status, errorResponse)
- return
- }
-
- if h.DB != nil {
- status := res.Status
- if status == "" {
- status = "applied"
- }
- slugVal := res.AppliedPreset
- if slugVal == "" {
- slugVal = slug
- }
- _ = h.DB.Create(&models.CrowdsecPresetEvent{Slug: slugVal, Action: "apply", Status: status, CacheKey: res.CacheKey, BackupPath: res.BackupPath}).Error
- }
-
- c.JSON(http.StatusOK, gin.H{
- "status": res.Status,
- "backup": res.BackupPath,
- "reload_hint": res.ReloadHint,
- "used_cscli": res.UsedCSCLI,
- "cache_key": res.CacheKey,
- "slug": res.AppliedPreset,
- })
-}
-
-// ConsoleEnroll enrolls the local engine with CrowdSec console.
-func (h *CrowdsecHandler) ConsoleEnroll(c *gin.Context) {
- if !h.isConsoleEnrollmentEnabled() {
- c.JSON(http.StatusNotFound, gin.H{"error": "console enrollment disabled"})
- return
- }
- if h.Console == nil {
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": "console enrollment unavailable"})
- return
- }
-
- var payload struct {
- EnrollmentKey string `json:"enrollment_key"`
- Tenant string `json:"tenant"`
- AgentName string `json:"agent_name"`
- Force bool `json:"force"`
- }
- if err := c.ShouldBindJSON(&payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
- return
- }
-
- ctx := c.Request.Context()
- status, err := h.Console.Enroll(ctx, crowdsec.ConsoleEnrollRequest{
- EnrollmentKey: payload.EnrollmentKey,
- Tenant: payload.Tenant,
- AgentName: payload.AgentName,
- Force: payload.Force,
- })
-
- if err != nil {
- httpStatus := mapCrowdsecStatus(err, http.StatusBadGateway)
- if strings.Contains(strings.ToLower(err.Error()), "progress") {
- httpStatus = http.StatusConflict
- } else if strings.Contains(strings.ToLower(err.Error()), "required") {
- httpStatus = http.StatusBadRequest
- }
- logger.Log().WithError(err).WithField("tenant", payload.Tenant).WithField("agent", payload.AgentName).WithField("correlation_id", status.CorrelationID).Warn("crowdsec console enrollment failed")
- if h.Security != nil {
- _ = h.Security.LogAudit(&models.SecurityAudit{Actor: actorFromContext(c), Action: "crowdsec_console_enroll_failed", Details: fmt.Sprintf("status=%s tenant=%s agent=%s correlation_id=%s", status.Status, payload.Tenant, payload.AgentName, status.CorrelationID)})
- }
- resp := gin.H{"error": err.Error(), "status": status.Status}
- if status.CorrelationID != "" {
- resp["correlation_id"] = status.CorrelationID
- }
- c.JSON(httpStatus, resp)
- return
- }
-
- if h.Security != nil {
- _ = h.Security.LogAudit(&models.SecurityAudit{Actor: actorFromContext(c), Action: "crowdsec_console_enroll_succeeded", Details: fmt.Sprintf("status=%s tenant=%s agent=%s correlation_id=%s", status.Status, status.Tenant, status.AgentName, status.CorrelationID)})
- }
-
- c.JSON(http.StatusOK, status)
-}
-
-// ConsoleStatus returns the current console enrollment status without secrets.
-func (h *CrowdsecHandler) ConsoleStatus(c *gin.Context) {
- if !h.isConsoleEnrollmentEnabled() {
- c.JSON(http.StatusNotFound, gin.H{"error": "console enrollment disabled"})
- return
- }
- if h.Console == nil {
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": "console enrollment unavailable"})
- return
- }
-
- status, err := h.Console.Status(c.Request.Context())
- if err != nil {
- logger.Log().WithError(err).Warn("failed to read console enrollment status")
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read enrollment status"})
- return
- }
- c.JSON(http.StatusOK, status)
-}
-
-// GetCachedPreset returns cached preview for a slug when available.
-func (h *CrowdsecHandler) GetCachedPreset(c *gin.Context) {
- if !h.isCerberusEnabled() {
- c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
- return
- }
- if h.Hub == nil || h.Hub.Cache == nil {
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub cache unavailable"})
- return
- }
- ctx := c.Request.Context()
- slug := strings.TrimSpace(c.Param("slug"))
- if slug == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "slug required"})
- return
- }
- preview, err := h.Hub.Cache.LoadPreview(ctx, slug)
- if err != nil {
- if errors.Is(err, crowdsec.ErrCacheMiss) || errors.Is(err, crowdsec.ErrCacheExpired) {
- c.JSON(http.StatusNotFound, gin.H{"error": "cache miss"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
- meta, metaErr := h.Hub.Cache.Load(ctx, slug)
- if metaErr != nil && !errors.Is(metaErr, crowdsec.ErrCacheMiss) && !errors.Is(metaErr, crowdsec.ErrCacheExpired) {
- c.JSON(http.StatusInternalServerError, gin.H{"error": metaErr.Error()})
- return
- }
- cacheTTL := h.Hub.Cache.TTL()
- now := time.Now().UTC()
- c.JSON(http.StatusOK, gin.H{
- "preview": preview,
- "cache_key": meta.CacheKey,
- "etag": meta.Etag,
- "retrieved_at": meta.RetrievedAt,
- "ttl_remaining_seconds": ttlRemainingSeconds(now, meta.RetrievedAt, cacheTTL),
- })
-}
-
-// CrowdSecDecision represents a ban decision from CrowdSec
-type CrowdSecDecision struct {
- ID int64 `json:"id"`
- Origin string `json:"origin"`
- Type string `json:"type"`
- Scope string `json:"scope"`
- Value string `json:"value"`
- Duration string `json:"duration"`
- Scenario string `json:"scenario"`
- CreatedAt time.Time `json:"created_at"`
- Until string `json:"until,omitempty"`
-}
-
-// cscliDecision represents the JSON output from cscli decisions list
-type cscliDecision struct {
- ID int64 `json:"id"`
- Origin string `json:"origin"`
- Type string `json:"type"`
- Scope string `json:"scope"`
- Value string `json:"value"`
- Duration string `json:"duration"`
- Scenario string `json:"scenario"`
- CreatedAt string `json:"created_at"`
- Until string `json:"until"`
-}
-
-// ListDecisions calls cscli to get current decisions (banned IPs)
-func (h *CrowdsecHandler) ListDecisions(c *gin.Context) {
- ctx := c.Request.Context()
- output, err := h.CmdExec.Execute(ctx, "cscli", "decisions", "list", "-o", "json")
- if err != nil {
- // If cscli is not available or returns error, return empty list with warning
- logger.Log().WithError(err).Warn("Failed to execute cscli decisions list")
- c.JSON(http.StatusOK, gin.H{"decisions": []CrowdSecDecision{}, "error": "cscli not available or failed"})
- return
- }
-
- // Handle empty output (no decisions)
- if len(output) == 0 || string(output) == "null" || string(output) == "null\n" {
- c.JSON(http.StatusOK, gin.H{"decisions": []CrowdSecDecision{}, "total": 0})
- return
- }
-
- // Parse JSON output
- var rawDecisions []cscliDecision
- if err := json.Unmarshal(output, &rawDecisions); err != nil {
- logger.Log().WithError(err).WithField("output", string(output)).Warn("Failed to parse cscli decisions output")
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse decisions"})
- return
- }
-
- // Convert to our format
- decisions := make([]CrowdSecDecision, 0, len(rawDecisions))
- for _, d := range rawDecisions {
- var createdAt time.Time
- if d.CreatedAt != "" {
- createdAt, _ = time.Parse(time.RFC3339, d.CreatedAt)
- }
- decisions = append(decisions, CrowdSecDecision{
- ID: d.ID,
- Origin: d.Origin,
- Type: d.Type,
- Scope: d.Scope,
- Value: d.Value,
- Duration: d.Duration,
- Scenario: d.Scenario,
- CreatedAt: createdAt,
- Until: d.Until,
- })
- }
-
- c.JSON(http.StatusOK, gin.H{"decisions": decisions, "total": len(decisions)})
-}
-
-// BanIPRequest represents the request body for banning an IP
-type BanIPRequest struct {
- IP string `json:"ip" binding:"required"`
- Duration string `json:"duration"`
- Reason string `json:"reason"`
-}
-
-// BanIP adds a manual ban for an IP address
-func (h *CrowdsecHandler) BanIP(c *gin.Context) {
- var req BanIPRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"})
- return
- }
-
- // Validate IP format (basic check)
- ip := strings.TrimSpace(req.IP)
- if ip == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "ip cannot be empty"})
- return
- }
-
- // Default duration to 24h if not specified
- duration := req.Duration
- if duration == "" {
- duration = "24h"
- }
-
- // Build reason string
- reason := "manual ban"
- if req.Reason != "" {
- reason = fmt.Sprintf("manual ban: %s", req.Reason)
- }
-
- ctx := c.Request.Context()
- args := []string{"decisions", "add", "-i", ip, "-d", duration, "-R", reason, "-t", "ban"}
- _, err := h.CmdExec.Execute(ctx, "cscli", args...)
- if err != nil {
- logger.Log().WithError(err).WithField("ip", ip).Warn("Failed to execute cscli decisions add")
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to ban IP"})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"status": "banned", "ip": ip, "duration": duration})
-}
-
-// UnbanIP removes a ban for an IP address
-func (h *CrowdsecHandler) UnbanIP(c *gin.Context) {
- ip := c.Param("ip")
- if ip == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": "ip parameter required"})
- return
- }
-
- // Sanitize IP
- ip = strings.TrimSpace(ip)
-
- ctx := c.Request.Context()
- args := []string{"decisions", "delete", "-i", ip}
- _, err := h.CmdExec.Execute(ctx, "cscli", args...)
- if err != nil {
- logger.Log().WithError(err).WithField("ip", ip).Warn("Failed to execute cscli decisions delete")
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to unban IP"})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"status": "unbanned", "ip": ip})
-}
-
-// RegisterRoutes registers crowdsec admin routes under protected group
-func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) {
- rg.POST("/admin/crowdsec/start", h.Start)
- rg.POST("/admin/crowdsec/stop", h.Stop)
- rg.GET("/admin/crowdsec/status", h.Status)
- rg.POST("/admin/crowdsec/import", h.ImportConfig)
- rg.GET("/admin/crowdsec/export", h.ExportConfig)
- rg.GET("/admin/crowdsec/files", h.ListFiles)
- rg.GET("/admin/crowdsec/file", h.ReadFile)
- rg.POST("/admin/crowdsec/file", h.WriteFile)
- rg.GET("/admin/crowdsec/presets", h.ListPresets)
- rg.POST("/admin/crowdsec/presets/pull", h.PullPreset)
- rg.POST("/admin/crowdsec/presets/apply", h.ApplyPreset)
- rg.GET("/admin/crowdsec/presets/cache/:slug", h.GetCachedPreset)
- rg.POST("/admin/crowdsec/console/enroll", h.ConsoleEnroll)
- rg.GET("/admin/crowdsec/console/status", h.ConsoleStatus)
- // Decision management endpoints (Banned IP Dashboard)
- rg.GET("/admin/crowdsec/decisions", h.ListDecisions)
- rg.POST("/admin/crowdsec/ban", h.BanIP)
- rg.DELETE("/admin/crowdsec/ban/:ip", h.UnbanIP)
-}
diff --git a/backend/internal/api/handlers/crowdsec_handler_coverage_test.go b/backend/internal/api/handlers/crowdsec_handler_coverage_test.go
deleted file mode 100644
index e6e4216a..00000000
--- a/backend/internal/api/handlers/crowdsec_handler_coverage_test.go
+++ /dev/null
@@ -1,456 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
-)
-
-// errorExec is a mock that returns errors for all operations
-type errorExec struct{}
-
-func (f *errorExec) Start(ctx context.Context, binPath, configDir string) (int, error) {
- return 0, errors.New("failed to start crowdsec")
-}
-func (f *errorExec) Stop(ctx context.Context, configDir string) error {
- return errors.New("failed to stop crowdsec")
-}
-func (f *errorExec) Status(ctx context.Context, configDir string) (running bool, pid int, err error) {
- return false, 0, errors.New("failed to get status")
-}
-
-func TestCrowdsec_Start_Error(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &errorExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusInternalServerError, w.Code)
- assert.Contains(t, w.Body.String(), "failed to start crowdsec")
-}
-
-func TestCrowdsec_Stop_Error(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &errorExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusInternalServerError, w.Code)
- assert.Contains(t, w.Body.String(), "failed to stop crowdsec")
-}
-
-func TestCrowdsec_Status_Error(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &errorExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusInternalServerError, w.Code)
- assert.Contains(t, w.Body.String(), "failed to get status")
-}
-
-// ReadFile tests
-func TestCrowdsec_ReadFile_MissingPath(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
- assert.Contains(t, w.Body.String(), "path required")
-}
-
-func TestCrowdsec_ReadFile_PathTraversal(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- // Attempt path traversal
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=../../../etc/passwd", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
- assert.Contains(t, w.Body.String(), "invalid path")
-}
-
-func TestCrowdsec_ReadFile_NotFound(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=nonexistent.conf", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusNotFound, w.Code)
- assert.Contains(t, w.Body.String(), "file not found")
-}
-
-// WriteFile tests
-func TestCrowdsec_WriteFile_InvalidPayload(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader([]byte("invalid json")))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
- assert.Contains(t, w.Body.String(), "invalid payload")
-}
-
-func TestCrowdsec_WriteFile_MissingPath(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- payload := map[string]string{"content": "test"}
- b, _ := json.Marshal(payload)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
- assert.Contains(t, w.Body.String(), "path required")
-}
-
-func TestCrowdsec_WriteFile_PathTraversal(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- // Attempt path traversal
- payload := map[string]string{"path": "../../../etc/malicious.conf", "content": "bad"}
- b, _ := json.Marshal(payload)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
- assert.Contains(t, w.Body.String(), "invalid path")
-}
-
-// ExportConfig tests
-func TestCrowdsec_ExportConfig_NotFound(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- // Use a non-existent directory
- nonExistentDir := "/tmp/crowdsec-nonexistent-dir-12345"
- os.RemoveAll(nonExistentDir) // Make sure it doesn't exist
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", nonExistentDir)
- // remove any cache dir created during handler init so Export sees missing dir
- _ = os.RemoveAll(nonExistentDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusNotFound, w.Code)
- assert.Contains(t, w.Body.String(), "crowdsec config not found")
-}
-
-// ListFiles tests
-func TestCrowdsec_ListFiles_EmptyDir(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- // Files may be nil or empty array when dir is empty
- files := resp["files"]
- if files != nil {
- assert.Len(t, files.([]interface{}), 0)
- }
-}
-
-func TestCrowdsec_ListFiles_NonExistent(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- nonExistentDir := "/tmp/crowdsec-nonexistent-dir-67890"
- os.RemoveAll(nonExistentDir)
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", nonExistentDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- // Should return empty array (nil) for non-existent dir
- // The files key should exist
- _, ok := resp["files"]
- assert.True(t, ok)
-}
-
-// ImportConfig error cases
-func TestCrowdsec_ImportConfig_NoFile(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", http.NoBody)
- req.Header.Set("Content-Type", "multipart/form-data")
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
- assert.Contains(t, w.Body.String(), "file required")
-}
-
-// Additional ReadFile test with nested path that exists
-func TestCrowdsec_ReadFile_NestedPath(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- // Create a nested file in the data dir
- _ = os.MkdirAll(filepath.Join(tmpDir, "subdir"), 0o755)
- _ = os.WriteFile(filepath.Join(tmpDir, "subdir", "test.conf"), []byte("nested content"), 0o644)
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=subdir/test.conf", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- json.Unmarshal(w.Body.Bytes(), &resp)
- assert.Equal(t, "nested content", resp["content"])
-}
-
-// Test WriteFile when backup fails (simulate by making dir unwritable)
-func TestCrowdsec_WriteFile_Success(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- payload := map[string]string{"path": "new.conf", "content": "new content"}
- b, _ := json.Marshal(payload)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- assert.Contains(t, w.Body.String(), "written")
-
- // Verify file was created
- content, err := os.ReadFile(filepath.Join(tmpDir, "new.conf"))
- assert.NoError(t, err)
- assert.Equal(t, "new content", string(content))
-}
-
-func TestCrowdsec_ListPresets_Disabled(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusNotFound, w.Code)
-}
-
-func TestCrowdsec_ListPresets_Success(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
- var resp map[string]interface{}
- assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
- presets, ok := resp["presets"].([]interface{})
- assert.True(t, ok)
- assert.Greater(t, len(presets), 0)
-}
-
-func TestCrowdsec_PullPreset_Validation(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
- h.Hub = nil // simulate hub unavailable
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader([]byte("{}")))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
- assert.Equal(t, http.StatusBadRequest, w.Code)
-
- w = httptest.NewRecorder()
- req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader([]byte(`{"slug":"demo"}`)))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
- assert.Equal(t, http.StatusServiceUnavailable, w.Code)
-}
-
-func TestCrowdsec_ApplyPreset_Validation(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
- h.Hub = nil
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader([]byte("{}")))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
- assert.Equal(t, http.StatusBadRequest, w.Code)
-
- w = httptest.NewRecorder()
- req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader([]byte(`{"slug":"demo"}`)))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
- assert.Equal(t, http.StatusServiceUnavailable, w.Code)
-}
diff --git a/backend/internal/api/handlers/crowdsec_handler_test.go b/backend/internal/api/handlers/crowdsec_handler_test.go
deleted file mode 100644
index fdbc617b..00000000
--- a/backend/internal/api/handlers/crowdsec_handler_test.go
+++ /dev/null
@@ -1,521 +0,0 @@
-package handlers
-
-import (
- "archive/tar"
- "bytes"
- "compress/gzip"
- "context"
- "encoding/json"
- "errors"
- "io"
- "mime/multipart"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "gorm.io/gorm"
-)
-
-type fakeExec struct {
- started bool
-}
-
-func (f *fakeExec) Start(ctx context.Context, binPath, configDir string) (int, error) {
- f.started = true
- return 12345, nil
-}
-func (f *fakeExec) Stop(ctx context.Context, configDir string) error {
- f.started = false
- return nil
-}
-func (f *fakeExec) Status(ctx context.Context, configDir string) (running bool, pid int, err error) {
- if f.started {
- return true, 12345, nil
- }
- return false, 0, nil
-}
-
-func setupCrowdDB(t *testing.T) *gorm.DB {
- db := OpenTestDB(t)
- return db
-}
-
-func TestCrowdsecEndpoints(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- fe := &fakeExec{}
- h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- // Status (initially stopped)
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", http.NoBody)
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("status expected 200 got %d", w.Code)
- }
-
- // Start
- w2 := httptest.NewRecorder()
- req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", http.NoBody)
- r.ServeHTTP(w2, req2)
- if w2.Code != http.StatusOK {
- t.Fatalf("start expected 200 got %d", w2.Code)
- }
-
- // Stop
- w3 := httptest.NewRecorder()
- req3 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", http.NoBody)
- r.ServeHTTP(w3, req3)
- if w3.Code != http.StatusOK {
- t.Fatalf("stop expected 200 got %d", w3.Code)
- }
-}
-
-func TestImportConfig(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
- fe := &fakeExec{}
- h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- // create a small file to upload
- buf := &bytes.Buffer{}
- mw := multipart.NewWriter(buf)
- fw, _ := mw.CreateFormFile("file", "cfg.tar.gz")
- fw.Write([]byte("dummy"))
- mw.Close()
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
- req.Header.Set("Content-Type", mw.FormDataContentType())
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("import expected 200 got %d body=%s", w.Code, w.Body.String())
- }
-
- // ensure file exists in data dir
- if _, err := os.Stat(filepath.Join(tmpDir, "cfg.tar.gz")); err != nil {
- t.Fatalf("expected file in data dir: %v", err)
- }
-}
-
-func TestImportCreatesBackup(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
- // create existing config dir with a marker file
- _ = os.MkdirAll(tmpDir, 0o755)
- _ = os.WriteFile(filepath.Join(tmpDir, "existing.conf"), []byte("v1"), 0o644)
-
- fe := &fakeExec{}
- h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- // upload
- buf := &bytes.Buffer{}
- mw := multipart.NewWriter(buf)
- fw, _ := mw.CreateFormFile("file", "cfg.tar.gz")
- fw.Write([]byte("dummy2"))
- mw.Close()
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
- req.Header.Set("Content-Type", mw.FormDataContentType())
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("import expected 200 got %d body=%s", w.Code, w.Body.String())
- }
-
- // ensure backup dir exists (ends with .backup.TIMESTAMP)
- found := false
- entries, _ := os.ReadDir(filepath.Dir(tmpDir))
- for _, e := range entries {
- if e.IsDir() && filepath.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") {
- found = true
- break
- }
- }
- if !found {
- // fallback: check for any .backup.* in same parent dir
- entries, _ := os.ReadDir(filepath.Dir(tmpDir))
- for _, e := range entries {
- if e.IsDir() && filepath.Ext(e.Name()) == "" && e.Name() != "" && (filepath.Base(e.Name()) != filepath.Base(tmpDir)) {
- // best-effort assume backup present
- found = true
- break
- }
- }
- }
- if !found {
- t.Fatalf("expected backup directory next to data dir")
- }
-}
-
-func TestExportConfig(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
-
- // create some files to export
- _ = os.MkdirAll(filepath.Join(tmpDir, "conf.d"), 0o755)
- _ = os.WriteFile(filepath.Join(tmpDir, "conf.d", "a.conf"), []byte("rule1"), 0o644)
- _ = os.WriteFile(filepath.Join(tmpDir, "b.conf"), []byte("rule2"), 0o644)
-
- fe := &fakeExec{}
- h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("export expected 200 got %d body=%s", w.Code, w.Body.String())
- }
- if ct := w.Header().Get("Content-Type"); ct != "application/gzip" {
- t.Fatalf("unexpected content type: %s", ct)
- }
- if w.Body.Len() == 0 {
- t.Fatalf("expected response body to contain archive data")
- }
-}
-
-func TestListAndReadFile(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
- // create a nested file
- _ = os.MkdirAll(filepath.Join(tmpDir, "conf.d"), 0o755)
- _ = os.WriteFile(filepath.Join(tmpDir, "conf.d", "a.conf"), []byte("rule1"), 0o644)
- _ = os.WriteFile(filepath.Join(tmpDir, "b.conf"), []byte("rule2"), 0o644)
-
- fe := &fakeExec{}
- h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("files expected 200 got %d body=%s", w.Code, w.Body.String())
- }
- // read a single file
- w2 := httptest.NewRecorder()
- req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=conf.d/a.conf", http.NoBody)
- r.ServeHTTP(w2, req2)
- if w2.Code != http.StatusOK {
- t.Fatalf("file read expected 200 got %d body=%s", w2.Code, w2.Body.String())
- }
-}
-
-func TestExportConfigStreamsArchive(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- dataDir := t.TempDir()
- require.NoError(t, os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte("hello"), 0o644))
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
- require.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
- require.Contains(t, w.Header().Get("Content-Disposition"), "crowdsec-config-")
-
- gr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
- require.NoError(t, err)
- tr := tar.NewReader(gr)
- found := false
- for {
- hdr, err := tr.Next()
- if errors.Is(err, io.EOF) {
- break
- }
- require.NoError(t, err)
- if hdr.Name == "config.yaml" {
- data, readErr := io.ReadAll(tr)
- require.NoError(t, readErr)
- require.Equal(t, "hello", string(data))
- found = true
- }
- }
- require.True(t, found, "expected exported archive to contain config file")
-}
-
-func TestWriteFileCreatesBackup(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupCrowdDB(t)
- tmpDir := t.TempDir()
- // create existing config dir with a marker file
- _ = os.MkdirAll(tmpDir, 0o755)
- _ = os.WriteFile(filepath.Join(tmpDir, "existing.conf"), []byte("v1"), 0o644)
-
- fe := &fakeExec{}
- h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- // write content to new file
- payload := map[string]string{"path": "conf.d/new.conf", "content": "hello world"}
- b, _ := json.Marshal(payload)
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("write expected 200 got %d body=%s", w.Code, w.Body.String())
- }
-
- // ensure backup directory was created
- entries, err := os.ReadDir(filepath.Dir(tmpDir))
- require.NoError(t, err)
- foundBackup := false
- for _, e := range entries {
- if e.IsDir() && strings.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") {
- foundBackup = true
- break
- }
- }
- require.True(t, foundBackup, "expected backup directory to be created")
-}
-
-func TestListPresetsCerberusDisabled(t *testing.T) {
- gin.SetMode(gin.TestMode)
- t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
-
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Fatalf("expected 404 when cerberus disabled got %d", w.Code)
- }
-}
-
-func TestReadFileInvalidPath(t *testing.T) {
- gin.SetMode(gin.TestMode)
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=../secret", http.NoBody)
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400 for invalid path got %d", w.Code)
- }
-}
-
-func TestWriteFileInvalidPath(t *testing.T) {
- gin.SetMode(gin.TestMode)
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- body, _ := json.Marshal(map[string]string{"path": "../../escape", "content": "bad"})
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400 for invalid path got %d", w.Code)
- }
-}
-
-func TestWriteFileMissingPath(t *testing.T) {
- gin.SetMode(gin.TestMode)
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- body, _ := json.Marshal(map[string]string{"content": "data only"})
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestWriteFileInvalidPayload(t *testing.T) {
- gin.SetMode(gin.TestMode)
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewBufferString("not-json"))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestImportConfigRequiresFile(t *testing.T) {
- gin.SetMode(gin.TestMode)
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", http.NoBody)
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400 when file missing got %d", w.Code)
- }
-}
-
-func TestImportConfigRejectsEmptyUpload(t *testing.T) {
- gin.SetMode(gin.TestMode)
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- buf := &bytes.Buffer{}
- mw := multipart.NewWriter(buf)
- _, _ = mw.CreateFormFile("file", "empty.tgz")
- _ = mw.Close()
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
- req.Header.Set("Content-Type", mw.FormDataContentType())
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400 for empty upload got %d", w.Code)
- }
-}
-
-func TestListFilesMissingDir(t *testing.T) {
- gin.SetMode(gin.TestMode)
- missingDir := filepath.Join(t.TempDir(), "does-not-exist")
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", missingDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200 for missing dir got %d", w.Code)
- }
-}
-
-func TestListFilesReturnsEntries(t *testing.T) {
- gin.SetMode(gin.TestMode)
- dataDir := t.TempDir()
- require.NoError(t, os.WriteFile(filepath.Join(dataDir, "root.txt"), []byte("root"), 0o644))
- nestedDir := filepath.Join(dataDir, "nested")
- require.NoError(t, os.MkdirAll(nestedDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(nestedDir, "child.txt"), []byte("child"), 0o644))
-
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", dataDir)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200 got %d", w.Code)
- }
-
- var resp struct {
- Files []string `json:"files"`
- }
- require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
- require.ElementsMatch(t, []string{"root.txt", filepath.Join("nested", "child.txt")}, resp.Files)
-}
-
-func TestIsCerberusEnabledFromDB(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := OpenTestDB(t)
- require.NoError(t, db.AutoMigrate(&models.Setting{}))
- require.NoError(t, db.Create(&models.Setting{Key: "feature.cerberus.enabled", Value: "0"}).Error)
-
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
- r.ServeHTTP(w, req)
-
- if w.Code != http.StatusNotFound {
- t.Fatalf("expected 404 when cerberus disabled via DB got %d", w.Code)
- }
-}
-
-func TestIsCerberusEnabledInvalidEnv(t *testing.T) {
- gin.SetMode(gin.TestMode)
- t.Setenv("FEATURE_CERBERUS_ENABLED", "not-a-bool")
- h := NewCrowdsecHandler(nil, &fakeExec{}, "/bin/false", t.TempDir())
-
- if h.isCerberusEnabled() {
- t.Fatalf("expected cerberus to be disabled for invalid env flag")
- }
-}
-
-func TestIsCerberusEnabledLegacyEnv(t *testing.T) {
- gin.SetMode(gin.TestMode)
- h := NewCrowdsecHandler(nil, &fakeExec{}, "/bin/false", t.TempDir())
-
- t.Setenv("CERBERUS_ENABLED", "0")
-
- if h.isCerberusEnabled() {
- t.Fatalf("expected cerberus to be disabled for legacy env flag")
- }
-}
diff --git a/backend/internal/api/handlers/crowdsec_presets_handler_test.go b/backend/internal/api/handlers/crowdsec_presets_handler_test.go
deleted file mode 100644
index 29375516..00000000
--- a/backend/internal/api/handlers/crowdsec_presets_handler_test.go
+++ /dev/null
@@ -1,535 +0,0 @@
-package handlers
-
-import (
- "archive/tar"
- "bytes"
- "compress/gzip"
- "context"
- "encoding/json"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
-
- "github.com/Wikid82/charon/backend/internal/crowdsec"
- "github.com/Wikid82/charon/backend/internal/models"
-)
-
-type presetRoundTripper func(*http.Request) (*http.Response, error)
-
-func (p presetRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
- return p(req)
-}
-
-func makePresetTar(t *testing.T, files map[string]string) []byte {
- t.Helper()
- buf := &bytes.Buffer{}
- gw := gzip.NewWriter(buf)
- tw := tar.NewWriter(gw)
- for name, content := range files {
- hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))}
- require.NoError(t, tw.WriteHeader(hdr))
- _, err := tw.Write([]byte(content))
- require.NoError(t, err)
- }
- require.NoError(t, tw.Close())
- require.NoError(t, gw.Close())
- return buf.Bytes()
-}
-
-func TestListPresetsIncludesCacheAndIndex(t *testing.T) {
- gin.SetMode(gin.TestMode)
- cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
- require.NoError(t, err)
- _, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", []byte("archive"))
- require.NoError(t, err)
-
- hub := crowdsec.NewHubService(nil, cache, t.TempDir())
- hub.HubBaseURL = "http://example.com"
- hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
- if req.URL.String() == "http://example.com/api/index.json" {
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","type":"collection"}]}`)), Header: make(http.Header)}, nil
- }
- return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
- })}
-
- db := OpenTestDB(t)
- handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
- handler.Hub = hub
-
- r := gin.New()
- g := r.Group("/api/v1")
- handler.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
- r.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
-
- var payload struct {
- Presets []struct {
- Slug string `json:"slug"`
- Cached bool `json:"cached"`
- } `json:"presets"`
- }
- require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
- found := false
- for _, p := range payload.Presets {
- if p.Slug == "crowdsecurity/demo" {
- found = true
- require.True(t, p.Cached)
- }
- }
- require.True(t, found)
-}
-
-func TestPullPresetHandlerSuccess(t *testing.T) {
- gin.SetMode(gin.TestMode)
- cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
- require.NoError(t, err)
- dataDir := filepath.Join(t.TempDir(), "crowdsec")
- archive := makePresetTar(t, map[string]string{"config.yaml": "key: value"})
-
- hub := crowdsec.NewHubService(nil, cache, dataDir)
- hub.HubBaseURL = "http://example.com"
- hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
- switch req.URL.String() {
- case "http://example.com/api/index.json":
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","etag":"e1","download_url":"http://example.com/demo.tgz","preview_url":"http://example.com/demo.yaml"}]}`)), Header: make(http.Header)}, nil
- case "http://example.com/demo.yaml":
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("preview")), Header: make(http.Header)}, nil
- case "http://example.com/demo.tgz":
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil
- default:
- return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
- }
- })}
-
- handler := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", dataDir)
- handler.Hub = hub
-
- r := gin.New()
- g := r.Group("/api/v1")
- handler.RegisterRoutes(g)
-
- body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"})
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
- require.Contains(t, w.Body.String(), "cache_key")
- require.Contains(t, w.Body.String(), "preview")
-}
-
-func TestApplyPresetHandlerAudits(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := OpenTestDB(t)
- require.NoError(t, db.AutoMigrate(&models.CrowdsecPresetEvent{}))
-
- cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
- require.NoError(t, err)
- dataDir := filepath.Join(t.TempDir(), "crowdsec")
- archive := makePresetTar(t, map[string]string{"conf.yaml": "v: 1"})
- _, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", archive)
- require.NoError(t, err)
-
- hub := crowdsec.NewHubService(nil, cache, dataDir)
-
- handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
- handler.Hub = hub
-
- r := gin.New()
- g := r.Group("/api/v1")
- handler.RegisterRoutes(g)
-
- body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"})
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
- require.Equal(t, http.StatusOK, w.Code)
-
- var events []models.CrowdsecPresetEvent
- require.NoError(t, db.Find(&events).Error)
- require.Len(t, events, 1)
- require.Equal(t, "applied", events[0].Status)
-
- // Failure path
- badCache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
- require.NoError(t, err)
- badArchive := makePresetTar(t, map[string]string{"../bad.txt": "x"})
- _, err = badCache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", badArchive)
- require.NoError(t, err)
-
- badHub := crowdsec.NewHubService(nil, badCache, filepath.Join(t.TempDir(), "crowdsec2"))
- handler.Hub = badHub
-
- w2 := httptest.NewRecorder()
- req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body))
- req2.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w2, req2)
- require.Equal(t, http.StatusInternalServerError, w2.Code)
-
- require.NoError(t, db.Find(&events).Error)
- require.Len(t, events, 2)
- require.Equal(t, "failed", events[1].Status)
-}
-
-func TestPullPresetHandlerHubError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
- require.NoError(t, err)
-
- hub := crowdsec.NewHubService(nil, cache, t.TempDir())
- hub.HubBaseURL = "http://example.com"
- hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
- return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
- })}
-
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- h.Hub = hub
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/missing"})
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusBadGateway, w.Code)
-}
-
-func TestPullPresetHandlerTimeout(t *testing.T) {
- gin.SetMode(gin.TestMode)
- cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
- require.NoError(t, err)
-
- hub := crowdsec.NewHubService(nil, cache, t.TempDir())
- hub.HubBaseURL = "http://example.com"
- hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
- return nil, context.DeadlineExceeded
- })}
-
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- h.Hub = hub
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"})
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusGatewayTimeout, w.Code)
- require.Contains(t, w.Body.String(), "deadline")
-}
-
-func TestGetCachedPresetNotFound(t *testing.T) {
- gin.SetMode(gin.TestMode)
- cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
- require.NoError(t, err)
-
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir())
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/unknown", http.NoBody)
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusNotFound, w.Code)
-}
-
-func TestGetCachedPresetServiceUnavailable(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- h.Hub = &crowdsec.HubService{}
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/demo", http.NoBody)
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusServiceUnavailable, w.Code)
-}
-
-func TestApplyPresetHandlerBackupFailure(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := OpenTestDB(t)
- require.NoError(t, db.AutoMigrate(&models.CrowdsecPresetEvent{}))
-
- baseDir := t.TempDir()
- dataDir := filepath.Join(baseDir, "crowdsec")
- require.NoError(t, os.MkdirAll(dataDir, 0o755))
- require.NoError(t, os.WriteFile(filepath.Join(dataDir, "keep.txt"), []byte("before"), 0o644))
-
- hub := crowdsec.NewHubService(nil, nil, dataDir)
- h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
- h.Hub = hub
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"})
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusInternalServerError, w.Code)
-
- // Verify response includes backup path for traceability
- var response map[string]interface{}
- require.NoError(t, json.Unmarshal(w.Body.Bytes(), &response))
- _, hasBackup := response["backup"]
- require.True(t, hasBackup, "Response should include 'backup' field for diagnostics")
-
- // Verify error message is present
- errorMsg, ok := response["error"].(string)
- require.True(t, ok, "error field should be a string")
- require.Contains(t, errorMsg, "cache", "error should indicate cache is unavailable")
-
- var events []models.CrowdsecPresetEvent
- require.NoError(t, db.Find(&events).Error)
- require.Len(t, events, 1)
- require.Equal(t, "failed", events[0].Status)
- require.NotEmpty(t, events[0].BackupPath)
-
- content, readErr := os.ReadFile(filepath.Join(dataDir, "keep.txt"))
- require.NoError(t, readErr)
- require.Equal(t, "before", string(content))
-}
-
-func TestListPresetsMergesCuratedAndHub(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- hub := crowdsec.NewHubService(nil, nil, t.TempDir())
- hub.HubBaseURL = "http://hub.example"
- hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
- if req.URL.String() == "http://hub.example/api/index.json" {
- return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/custom","title":"Custom","description":"d","type":"collection"}]}`)), Header: make(http.Header)}, nil
- }
- return nil, errors.New("unexpected request")
- })}
-
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- h.Hub = hub
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- var payload struct {
- Presets []struct {
- Slug string `json:"slug"`
- Source string `json:"source"`
- Tags []string `json:"tags"`
- } `json:"presets"`
- }
- require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
-
- foundCurated := false
- foundHub := false
- for _, p := range payload.Presets {
- if p.Slug == "honeypot-friendly-defaults" {
- foundCurated = true
- }
- if p.Slug == "crowdsecurity/custom" {
- foundHub = true
- require.Equal(t, []string{"collection"}, p.Tags)
- }
- }
-
- require.True(t, foundCurated)
- require.True(t, foundHub)
-}
-
-func TestGetCachedPresetSuccess(t *testing.T) {
- gin.SetMode(gin.TestMode)
- t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
- cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
- require.NoError(t, err)
- const slug = "demo"
- _, err = cache.Store(context.Background(), slug, "etag123", "hub", "preview-body", []byte("tgz"))
- require.NoError(t, err)
-
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir())
- require.True(t, h.isCerberusEnabled())
- preview, err := h.Hub.Cache.LoadPreview(context.Background(), slug)
- require.NoError(t, err)
- require.Equal(t, "preview-body", preview)
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/"+slug, http.NoBody)
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
- require.Contains(t, w.Body.String(), "preview-body")
- require.Contains(t, w.Body.String(), "etag123")
-}
-
-func TestGetCachedPresetSlugRequired(t *testing.T) {
- gin.SetMode(gin.TestMode)
- t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
- cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
- require.NoError(t, err)
-
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir())
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/%20", http.NoBody)
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusBadRequest, w.Code)
- require.Contains(t, w.Body.String(), "slug required")
-}
-
-func TestGetCachedPresetPreviewError(t *testing.T) {
- gin.SetMode(gin.TestMode)
- t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
- cacheDir := t.TempDir()
- cache, err := crowdsec.NewHubCache(cacheDir, time.Hour)
- require.NoError(t, err)
- const slug = "broken"
- meta, err := cache.Store(context.Background(), slug, "etag999", "hub", "will-remove", []byte("tgz"))
- require.NoError(t, err)
- // Remove preview to force LoadPreview read error.
- require.NoError(t, os.Remove(meta.PreviewPath))
-
- h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
- h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir())
-
- r := gin.New()
- g := r.Group("/api/v1")
- h.RegisterRoutes(g)
-
- w := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/"+slug, http.NoBody)
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusInternalServerError, w.Code)
- require.Contains(t, w.Body.String(), "no such file")
-}
-
-func TestPullCuratedPresetSkipsHub(t *testing.T) {
-gin.SetMode(gin.TestMode)
-t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
-
-// Setup handler with a hub service that would fail if called
-cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
-require.NoError(t, err)
-
-// We don't set HTTPClient, so any network call would panic or fail if not handled
-hub := crowdsec.NewHubService(nil, cache, t.TempDir())
-
-h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
-h.Hub = hub
-
-r := gin.New()
-g := r.Group("/api/v1")
-h.RegisterRoutes(g)
-
-// Use a known curated preset that doesn't require hub
-slug := "honeypot-friendly-defaults"
-
-body, _ := json.Marshal(map[string]string{"slug": slug})
-w := httptest.NewRecorder()
-req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body))
-req.Header.Set("Content-Type", "application/json")
-r.ServeHTTP(w, req)
-
-require.Equal(t, http.StatusOK, w.Code)
-
-var resp map[string]interface{}
-require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
-
-require.Equal(t, "pulled", resp["status"])
-require.Equal(t, slug, resp["slug"])
-require.Equal(t, "charon-curated", resp["source"])
-require.Contains(t, resp["preview"], "Curated preset")
-}
-
-func TestApplyCuratedPresetSkipsHub(t *testing.T) {
-gin.SetMode(gin.TestMode)
-t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
-
-db := OpenTestDB(t)
-require.NoError(t, db.AutoMigrate(&models.CrowdsecPresetEvent{}))
-
-// Setup handler with a hub service that would fail if called
-// We intentionally don't put anything in cache to prove we don't check it
-cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
-require.NoError(t, err)
-
-hub := crowdsec.NewHubService(nil, cache, t.TempDir())
-
-h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
-h.Hub = hub
-
-r := gin.New()
-g := r.Group("/api/v1")
-h.RegisterRoutes(g)
-
-// Use a known curated preset that doesn't require hub
-slug := "honeypot-friendly-defaults"
-
-body, _ := json.Marshal(map[string]string{"slug": slug})
-w := httptest.NewRecorder()
-req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body))
-req.Header.Set("Content-Type", "application/json")
-r.ServeHTTP(w, req)
-
-require.Equal(t, http.StatusOK, w.Code)
-
-var resp map[string]interface{}
-require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
-
-require.Equal(t, "applied", resp["status"])
-require.Equal(t, slug, resp["slug"])
-
-// Verify event was logged
-var events []models.CrowdsecPresetEvent
-require.NoError(t, db.Find(&events).Error)
-require.Len(t, events, 1)
-require.Equal(t, slug, events[0].Slug)
-require.Equal(t, "applied", events[0].Status)
-}
diff --git a/backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go b/backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go
deleted file mode 100644
index c059a9de..00000000
--- a/backend/internal/api/handlers/crowdsec_pull_apply_integration_test.go
+++ /dev/null
@@ -1,226 +0,0 @@
-package handlers
-
-import (
- "archive/tar"
- "bytes"
- "compress/gzip"
- "context"
- "encoding/json"
- "io"
- "net/http"
- "net/http/httptest"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
-
- "github.com/Wikid82/charon/backend/internal/crowdsec"
-)
-
-// TestPullThenApplyIntegration tests the complete pullโapply workflow from the user's perspective.
-// This reproduces the scenario where a user pulls a preset and then tries to apply it.
-func TestPullThenApplyIntegration(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- // Setup
- cacheDir := t.TempDir()
- dataDir := t.TempDir()
-
- cache, err := crowdsec.NewHubCache(cacheDir, time.Hour)
- require.NoError(t, err)
-
- archive := makePresetTarGz(t, map[string]string{
- "config.yaml": "test: config\nversion: 1",
- })
-
- hub := crowdsec.NewHubService(nil, cache, dataDir)
- hub.HubBaseURL = "http://test.hub"
- hub.HTTPClient = &http.Client{
- Transport: testRoundTripper(func(req *http.Request) (*http.Response, error) {
- switch req.URL.String() {
- case "http://test.hub/api/index.json":
- body := `{"items":[{"name":"test/preset","title":"Test","description":"Test preset","etag":"abc123","download_url":"http://test.hub/test.tgz","preview_url":"http://test.hub/test.yaml"}]}`
- return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
- case "http://test.hub/test.yaml":
- return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader("preview content")), Header: make(http.Header)}, nil
- case "http://test.hub/test.tgz":
- return &http.Response{StatusCode: 200, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil
- default:
- return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
- }
- }),
- }
-
- db := OpenTestDB(t)
- handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
- handler.Hub = hub
-
- r := gin.New()
- g := r.Group("/api/v1")
- handler.RegisterRoutes(g)
-
- // Step 1: Pull the preset
- t.Log("User pulls preset")
- pullPayload, _ := json.Marshal(map[string]string{"slug": "test/preset"})
- pullReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(pullPayload))
- pullReq.Header.Set("Content-Type", "application/json")
- pullResp := httptest.NewRecorder()
- r.ServeHTTP(pullResp, pullReq)
-
- require.Equal(t, http.StatusOK, pullResp.Code, "Pull should succeed")
-
- var pullResult map[string]interface{}
- err = json.Unmarshal(pullResp.Body.Bytes(), &pullResult)
- require.NoError(t, err)
- require.Equal(t, "pulled", pullResult["status"])
- require.NotEmpty(t, pullResult["cache_key"], "Pull should return cache_key")
- require.NotEmpty(t, pullResult["preview"], "Pull should return preview")
-
- t.Log("Pull succeeded, cache_key:", pullResult["cache_key"])
-
- // Verify cache was populated
- ctx := context.Background()
- cached, err := cache.Load(ctx, "test/preset")
- require.NoError(t, err, "Preset should be cached after pull")
- require.Equal(t, "test/preset", cached.Slug)
- t.Log("Cache verified, slug:", cached.Slug)
-
- // Step 2: Apply the preset (this should use the cached data)
- t.Log("User applies preset")
- applyPayload, _ := json.Marshal(map[string]string{"slug": "test/preset"})
- applyReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(applyPayload))
- applyReq.Header.Set("Content-Type", "application/json")
- applyResp := httptest.NewRecorder()
- r.ServeHTTP(applyResp, applyReq)
-
- // This should NOT return "preset not cached" error
- require.Equal(t, http.StatusOK, applyResp.Code, "Apply should succeed after pull. Response: %s", applyResp.Body.String())
-
- var applyResult map[string]interface{}
- err = json.Unmarshal(applyResp.Body.Bytes(), &applyResult)
- require.NoError(t, err)
- require.Equal(t, "applied", applyResult["status"], "Apply status should be 'applied'")
- require.NotEmpty(t, applyResult["backup"], "Apply should return backup path")
-
- t.Log("Apply succeeded, backup:", applyResult["backup"])
-}
-
-// TestApplyWithoutPullReturnsProperError verifies the error message when applying without pulling first.
-func TestApplyWithoutPullReturnsProperError(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- cacheDir := t.TempDir()
- dataDir := t.TempDir()
-
- cache, err := crowdsec.NewHubCache(cacheDir, time.Hour)
- require.NoError(t, err)
-
- // Empty cache, no cscli
- hub := crowdsec.NewHubService(nil, cache, dataDir)
- hub.HubBaseURL = "http://test.hub"
- hub.HTTPClient = &http.Client{Transport: testRoundTripper(func(req *http.Request) (*http.Response, error) {
- return &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
- })}
-
- db := OpenTestDB(t)
- handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
- handler.Hub = hub
-
- r := gin.New()
- g := r.Group("/api/v1")
- handler.RegisterRoutes(g)
-
- // Try to apply without pulling first
- t.Log("User tries to apply preset without pulling first")
- applyPayload, _ := json.Marshal(map[string]string{"slug": "test/preset"})
- applyReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(applyPayload))
- applyReq.Header.Set("Content-Type", "application/json")
- applyResp := httptest.NewRecorder()
- r.ServeHTTP(applyResp, applyReq)
-
- require.Equal(t, http.StatusInternalServerError, applyResp.Code, "Apply should fail without cache")
-
- var errorResult map[string]interface{}
- err = json.Unmarshal(applyResp.Body.Bytes(), &errorResult)
- require.NoError(t, err)
-
- errorMsg := errorResult["error"].(string)
- require.Contains(t, errorMsg, "Preset cache missing", "Error should mention preset not cached")
- require.Contains(t, errorMsg, "Pull the preset", "Error should guide user to pull first")
- t.Log("Proper error message returned:", errorMsg)
-}
-
-func TestApplyRollbackWhenCacheMissingAndRepullFails(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- cacheDir := t.TempDir()
- dataRoot := t.TempDir()
- dataDir := filepath.Join(dataRoot, "crowdsec")
- require.NoError(t, os.MkdirAll(dataDir, 0o755))
- originalFile := filepath.Join(dataDir, "config.yaml")
- require.NoError(t, os.WriteFile(originalFile, []byte("original"), 0o644))
-
- cache, err := crowdsec.NewHubCache(cacheDir, time.Hour)
- require.NoError(t, err)
-
- hub := crowdsec.NewHubService(nil, cache, dataDir)
- hub.HubBaseURL = "http://test.hub"
- hub.HTTPClient = &http.Client{Transport: testRoundTripper(func(req *http.Request) (*http.Response, error) {
- // Force repull failure
- return &http.Response{StatusCode: 500, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
- })}
-
- db := OpenTestDB(t)
- handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
- handler.Hub = hub
-
- r := gin.New()
- g := r.Group("/api/v1")
- handler.RegisterRoutes(g)
-
- applyPayload, _ := json.Marshal(map[string]string{"slug": "missing/preset"})
- applyReq := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(applyPayload))
- applyReq.Header.Set("Content-Type", "application/json")
- applyResp := httptest.NewRecorder()
- r.ServeHTTP(applyResp, applyReq)
-
- require.Equal(t, http.StatusInternalServerError, applyResp.Code)
-
- var body map[string]any
- require.NoError(t, json.Unmarshal(applyResp.Body.Bytes(), &body))
- require.NotEmpty(t, body["backup"], "backup path should be returned for rollback traceability")
- require.Contains(t, body["error"], "Preset cache missing", "error should guide user to repull")
-
- // Original file should remain after rollback
- data, readErr := os.ReadFile(originalFile)
- require.NoError(t, readErr)
- require.Equal(t, "original", string(data))
-}
-
-func makePresetTarGz(t *testing.T, files map[string]string) []byte {
- t.Helper()
- buf := &bytes.Buffer{}
- gw := gzip.NewWriter(buf)
- tw := tar.NewWriter(gw)
-
- for name, content := range files {
- hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))}
- require.NoError(t, tw.WriteHeader(hdr))
- _, err := tw.Write([]byte(content))
- require.NoError(t, err)
- }
-
- require.NoError(t, tw.Close())
- require.NoError(t, gw.Close())
- return buf.Bytes()
-}
-
-type testRoundTripper func(*http.Request) (*http.Response, error)
-
-func (t testRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
- return t(req)
-}
diff --git a/backend/internal/api/handlers/doc.go b/backend/internal/api/handlers/doc.go
deleted file mode 100644
index 29205a6d..00000000
--- a/backend/internal/api/handlers/doc.go
+++ /dev/null
@@ -1,8 +0,0 @@
-// Package handlers provides HTTP handlers used by the Charon backend API.
-//
-// It exposes Gin-based handler implementations for resources such as
-// certificates, proxy hosts, users, notifications, backups, and system
-// configuration. This package wires services to HTTP endpoints and
-// performs request validation, response formatting, and basic error
-// handling.
-package handlers
diff --git a/backend/internal/api/handlers/docker_handler.go b/backend/internal/api/handlers/docker_handler.go
deleted file mode 100644
index 1f4540c6..00000000
--- a/backend/internal/api/handlers/docker_handler.go
+++ /dev/null
@@ -1,52 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "net/http"
-
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
-)
-
-type DockerHandler struct {
- dockerService *services.DockerService
- remoteServerService *services.RemoteServerService
-}
-
-func NewDockerHandler(dockerService *services.DockerService, remoteServerService *services.RemoteServerService) *DockerHandler {
- return &DockerHandler{
- dockerService: dockerService,
- remoteServerService: remoteServerService,
- }
-}
-
-func (h *DockerHandler) RegisterRoutes(r *gin.RouterGroup) {
- r.GET("/docker/containers", h.ListContainers)
-}
-
-func (h *DockerHandler) ListContainers(c *gin.Context) {
- host := c.Query("host")
- serverID := c.Query("server_id")
-
- // If server_id is provided, look up the remote server
- if serverID != "" {
- server, err := h.remoteServerService.GetByUUID(serverID)
- if err != nil {
- c.JSON(http.StatusNotFound, gin.H{"error": "Remote server not found"})
- return
- }
-
- // Construct Docker host string
- // Assuming TCP for now as that's what RemoteServer supports (Host/Port)
- // TODO: Support SSH if/when RemoteServer supports it
- host = fmt.Sprintf("tcp://%s:%d", server.Host, server.Port)
- }
-
- containers, err := h.dockerService.ListContainers(c.Request.Context(), host)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list containers: " + err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, containers)
-}
diff --git a/backend/internal/api/handlers/docker_handler_test.go b/backend/internal/api/handlers/docker_handler_test.go
deleted file mode 100644
index 0ac6c1cd..00000000
--- a/backend/internal/api/handlers/docker_handler_test.go
+++ /dev/null
@@ -1,171 +0,0 @@
-package handlers
-
-import (
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
-)
-
-func setupDockerTestRouter(t *testing.T) (*gin.Engine, *gorm.DB, *services.RemoteServerService) {
- dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
- db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
- require.NoError(t, err)
- require.NoError(t, db.AutoMigrate(&models.RemoteServer{}))
-
- rsService := services.NewRemoteServerService(db)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
-
- return r, db, rsService
-}
-
-func TestDockerHandler_ListContainers(t *testing.T) {
- // We can't easily mock the DockerService without an interface,
- // and the DockerService depends on the real Docker client.
- // So we'll just test that the handler is wired up correctly,
- // even if it returns an error because Docker isn't running in the test env.
-
- svc, _ := services.NewDockerService()
- // svc might be nil if docker is not available, but NewDockerHandler handles nil?
- // Actually NewDockerHandler just stores it.
- // If svc is nil, ListContainers will panic.
- // So we only run this if svc is not nil.
-
- if svc == nil {
- t.Skip("Docker not available")
- }
-
- r, _, rsService := setupDockerTestRouter(t)
-
- h := NewDockerHandler(svc, rsService)
- h.RegisterRoutes(r.Group("/"))
-
- req, _ := http.NewRequest("GET", "/docker/containers", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- // It might return 200 or 500 depending on if ListContainers succeeds
- assert.Contains(t, []int{http.StatusOK, http.StatusInternalServerError}, w.Code)
-}
-
-func TestDockerHandler_ListContainers_NonExistentServerID(t *testing.T) {
- svc, _ := services.NewDockerService()
- if svc == nil {
- t.Skip("Docker not available")
- }
-
- r, _, rsService := setupDockerTestRouter(t)
-
- h := NewDockerHandler(svc, rsService)
- h.RegisterRoutes(r.Group("/"))
-
- // Request with non-existent server_id
- req, _ := http.NewRequest("GET", "/docker/containers?server_id=non-existent-uuid", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusNotFound, w.Code)
- assert.Contains(t, w.Body.String(), "Remote server not found")
-}
-
-func TestDockerHandler_ListContainers_WithServerID(t *testing.T) {
- svc, _ := services.NewDockerService()
- if svc == nil {
- t.Skip("Docker not available")
- }
-
- r, db, rsService := setupDockerTestRouter(t)
-
- // Create a remote server
- server := models.RemoteServer{
- UUID: uuid.New().String(),
- Name: "Test Docker Server",
- Host: "docker.example.com",
- Port: 2375,
- Scheme: "",
- Enabled: true,
- }
- require.NoError(t, db.Create(&server).Error)
-
- h := NewDockerHandler(svc, rsService)
- h.RegisterRoutes(r.Group("/"))
-
- // Request with valid server_id (will fail to connect, but shouldn't error on lookup)
- req, _ := http.NewRequest("GET", "/docker/containers?server_id="+server.UUID, http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- // Should attempt to connect and likely fail with 500 (not 404)
- assert.Contains(t, []int{http.StatusOK, http.StatusInternalServerError}, w.Code)
- if w.Code == http.StatusInternalServerError {
- assert.Contains(t, w.Body.String(), "Failed to list containers")
- }
-}
-
-func TestDockerHandler_ListContainers_WithHostQuery(t *testing.T) {
- svc, _ := services.NewDockerService()
- if svc == nil {
- t.Skip("Docker not available")
- }
-
- r, _, rsService := setupDockerTestRouter(t)
-
- h := NewDockerHandler(svc, rsService)
- h.RegisterRoutes(r.Group("/"))
-
- // Request with custom host parameter
- req, _ := http.NewRequest("GET", "/docker/containers?host=tcp://invalid-host:2375", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- // Should attempt to connect and fail with 500
- assert.Equal(t, http.StatusInternalServerError, w.Code)
- assert.Contains(t, w.Body.String(), "Failed to list containers")
-}
-
-func TestDockerHandler_RegisterRoutes(t *testing.T) {
- svc, _ := services.NewDockerService()
- if svc == nil {
- t.Skip("Docker not available")
- }
-
- r, _, rsService := setupDockerTestRouter(t)
-
- h := NewDockerHandler(svc, rsService)
- h.RegisterRoutes(r.Group("/"))
-
- // Verify route is registered
- routes := r.Routes()
- found := false
- for _, route := range routes {
- if route.Path == "/docker/containers" && route.Method == "GET" {
- found = true
- break
- }
- }
- assert.True(t, found, "Expected /docker/containers GET route to be registered")
-}
-
-func TestDockerHandler_NewDockerHandler(t *testing.T) {
- svc, _ := services.NewDockerService()
- if svc == nil {
- t.Skip("Docker not available")
- }
-
- _, _, rsService := setupDockerTestRouter(t)
-
- h := NewDockerHandler(svc, rsService)
- assert.NotNil(t, h)
- assert.NotNil(t, h.dockerService)
- assert.NotNil(t, h.remoteServerService)
-}
diff --git a/backend/internal/api/handlers/domain_handler.go b/backend/internal/api/handlers/domain_handler.go
deleted file mode 100644
index ac4d7cae..00000000
--- a/backend/internal/api/handlers/domain_handler.go
+++ /dev/null
@@ -1,93 +0,0 @@
-package handlers
-
-import (
- "fmt"
- "net/http"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/Wikid82/charon/backend/internal/util"
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-)
-
-type DomainHandler struct {
- DB *gorm.DB
- notificationService *services.NotificationService
-}
-
-func NewDomainHandler(db *gorm.DB, ns *services.NotificationService) *DomainHandler {
- return &DomainHandler{
- DB: db,
- notificationService: ns,
- }
-}
-
-func (h *DomainHandler) List(c *gin.Context) {
- var domains []models.Domain
- if err := h.DB.Order("name asc").Find(&domains).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch domains"})
- return
- }
- c.JSON(http.StatusOK, domains)
-}
-
-func (h *DomainHandler) Create(c *gin.Context) {
- var input struct {
- Name string `json:"name" binding:"required"`
- }
-
- if err := c.ShouldBindJSON(&input); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- domain := models.Domain{
- Name: input.Name,
- }
-
- if err := h.DB.Create(&domain).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create domain"})
- return
- }
-
- // Send Notification
- if h.notificationService != nil {
- h.notificationService.SendExternal(c.Request.Context(),
- "domain",
- "Domain Added",
- fmt.Sprintf("Domain %s added", util.SanitizeForLog(domain.Name)),
- map[string]interface{}{
- "Name": util.SanitizeForLog(domain.Name),
- "Action": "created",
- },
- )
- }
-
- c.JSON(http.StatusCreated, domain)
-}
-
-func (h *DomainHandler) Delete(c *gin.Context) {
- id := c.Param("id")
- var domain models.Domain
- if err := h.DB.Where("uuid = ?", id).First(&domain).Error; err == nil {
- // Send Notification before delete (or after if we keep the name)
- if h.notificationService != nil {
- h.notificationService.SendExternal(c.Request.Context(),
- "domain",
- "Domain Deleted",
- fmt.Sprintf("Domain %s deleted", util.SanitizeForLog(domain.Name)),
- map[string]interface{}{
- "Name": util.SanitizeForLog(domain.Name),
- "Action": "deleted",
- },
- )
- }
- }
-
- if err := h.DB.Where("uuid = ?", id).Delete(&models.Domain{}).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete domain"})
- return
- }
- c.JSON(http.StatusOK, gin.H{"message": "Domain deleted"})
-}
diff --git a/backend/internal/api/handlers/domain_handler_test.go b/backend/internal/api/handlers/domain_handler_test.go
deleted file mode 100644
index e4f94f11..00000000
--- a/backend/internal/api/handlers/domain_handler_test.go
+++ /dev/null
@@ -1,160 +0,0 @@
-package handlers
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/require"
- "gorm.io/driver/sqlite"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
-)
-
-func setupDomainTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
- t.Helper()
-
- dsn := "file:" + t.Name() + "?mode=memory&cache=shared"
- db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
- require.NoError(t, err)
- require.NoError(t, db.AutoMigrate(&models.Domain{}, &models.Notification{}, &models.NotificationProvider{}))
-
- ns := services.NewNotificationService(db)
- h := NewDomainHandler(db, ns)
- r := gin.New()
-
- // Manually register routes since DomainHandler doesn't have a RegisterRoutes method yet
- // or we can just register them here for testing
- r.GET("/api/v1/domains", h.List)
- r.POST("/api/v1/domains", h.Create)
- r.DELETE("/api/v1/domains/:id", h.Delete)
-
- return r, db
-}
-
-func TestDomainLifecycle(t *testing.T) {
- router, _ := setupDomainTestRouter(t)
-
- // 1. Create Domain
- body := `{"name":"example.com"}`
- req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusCreated, resp.Code)
-
- var created models.Domain
- require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &created))
- require.Equal(t, "example.com", created.Name)
- require.NotEmpty(t, created.UUID)
-
- // 2. List Domains
- req = httptest.NewRequest(http.MethodGet, "/api/v1/domains", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
-
- var list []models.Domain
- require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list))
- require.Len(t, list, 1)
- require.Equal(t, "example.com", list[0].Name)
-
- // 3. Delete Domain
- req = httptest.NewRequest(http.MethodDelete, "/api/v1/domains/"+created.UUID, http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
-
- // 4. Verify Deletion
- req = httptest.NewRequest(http.MethodGet, "/api/v1/domains", http.NoBody)
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
-
- require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list))
- require.Len(t, list, 0)
-}
-
-func TestDomainErrors(t *testing.T) {
- router, _ := setupDomainTestRouter(t)
-
- // 1. Create Invalid JSON
- req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{invalid}`))
- req.Header.Set("Content-Type", "application/json")
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusBadRequest, resp.Code)
-
- // 2. Create Missing Name
- req = httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(`{}`))
- req.Header.Set("Content-Type", "application/json")
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusBadRequest, resp.Code)
-}
-
-func TestDomainDelete_NotFound(t *testing.T) {
- router, _ := setupDomainTestRouter(t)
-
- req := httptest.NewRequest(http.MethodDelete, "/api/v1/domains/nonexistent-uuid", http.NoBody)
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- // Handler may return 200 with deleted=true even if not found (soft delete behavior)
- require.True(t, resp.Code == http.StatusOK || resp.Code == http.StatusNotFound)
-}
-
-func TestDomainCreate_Duplicate(t *testing.T) {
- router, db := setupDomainTestRouter(t)
-
- // Create first domain
- body := `{"name":"duplicate.com"}`
- req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusCreated, resp.Code)
-
- // Try creating duplicate
- req = httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- resp = httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- // Should error - could be 409 Conflict or 500 depending on implementation
- require.True(t, resp.Code >= 400, "Expected error status for duplicate domain")
-
- // Verify only one exists
- var count int64
- db.Model(&models.Domain{}).Where("name = ?", "duplicate.com").Count(&count)
- require.Equal(t, int64(1), count)
-}
-
-func TestDomainList_Empty(t *testing.T) {
- router, _ := setupDomainTestRouter(t)
-
- req := httptest.NewRequest(http.MethodGet, "/api/v1/domains", http.NoBody)
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- require.Equal(t, http.StatusOK, resp.Code)
-
- var list []models.Domain
- require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &list))
- require.Empty(t, list)
-}
-
-func TestDomainCreate_LongName(t *testing.T) {
- router, _ := setupDomainTestRouter(t)
-
- longName := strings.Repeat("a", 300) + ".com"
- body := `{"name":"` + longName + `"}`
- req := httptest.NewRequest(http.MethodPost, "/api/v1/domains", strings.NewReader(body))
- req.Header.Set("Content-Type", "application/json")
- resp := httptest.NewRecorder()
- router.ServeHTTP(resp, req)
- // Should succeed (database will truncate or accept)
- require.True(t, resp.Code == http.StatusCreated || resp.Code >= 400)
-}
diff --git a/backend/internal/api/handlers/feature_flags_handler.go b/backend/internal/api/handlers/feature_flags_handler.go
deleted file mode 100644
index 45af2260..00000000
--- a/backend/internal/api/handlers/feature_flags_handler.go
+++ /dev/null
@@ -1,115 +0,0 @@
-package handlers
-
-import (
- "net/http"
- "os"
- "strconv"
- "strings"
-
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/models"
-)
-
-// FeatureFlagsHandler exposes simple DB-backed feature flags with env fallback.
-type FeatureFlagsHandler struct {
- DB *gorm.DB
-}
-
-func NewFeatureFlagsHandler(db *gorm.DB) *FeatureFlagsHandler {
- return &FeatureFlagsHandler{DB: db}
-}
-
-// defaultFlags lists the canonical feature flags we expose.
-var defaultFlags = []string{
- "feature.cerberus.enabled",
- "feature.uptime.enabled",
- "feature.crowdsec.console_enrollment",
-}
-
-var defaultFlagValues = map[string]bool{
- "feature.crowdsec.console_enrollment": false,
-}
-
-// GetFlags returns a map of feature flag -> bool. DB setting takes precedence
-// and falls back to environment variables if present.
-func (h *FeatureFlagsHandler) GetFlags(c *gin.Context) {
- result := make(map[string]bool)
-
- for _, key := range defaultFlags {
- defaultVal := true
- if v, ok := defaultFlagValues[key]; ok {
- defaultVal = v
- }
- // Try DB
- var s models.Setting
- if err := h.DB.Where("key = ?", key).First(&s).Error; err == nil {
- v := strings.ToLower(strings.TrimSpace(s.Value))
- b := v == "1" || v == "true" || v == "yes"
- result[key] = b
- continue
- }
-
- // Fallback to env vars. Try FEATURE_... and also stripped service name e.g. CERBERUS_ENABLED
- envKey := strings.ToUpper(strings.ReplaceAll(key, ".", "_"))
- if ev, ok := os.LookupEnv(envKey); ok {
- if bv, err := strconv.ParseBool(ev); err == nil {
- result[key] = bv
- continue
- }
- // accept 1/0
- result[key] = ev == "1"
- continue
- }
-
- // Try shorter variant after removing leading "feature."
- if strings.HasPrefix(key, "feature.") {
- short := strings.ToUpper(strings.ReplaceAll(strings.TrimPrefix(key, "feature."), ".", "_"))
- if ev, ok := os.LookupEnv(short); ok {
- if bv, err := strconv.ParseBool(ev); err == nil {
- result[key] = bv
- continue
- }
- result[key] = ev == "1"
- continue
- }
- }
-
- // Default based on declared flag value
- result[key] = defaultVal
- }
-
- c.JSON(http.StatusOK, result)
-}
-
-// UpdateFlags accepts a JSON object map[string]bool and upserts settings.
-func (h *FeatureFlagsHandler) UpdateFlags(c *gin.Context) {
- var payload map[string]bool
- if err := c.ShouldBindJSON(&payload); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- for k, v := range payload {
- // Only allow keys in the default list to avoid arbitrary settings
- allowed := false
- for _, ak := range defaultFlags {
- if ak == k {
- allowed = true
- break
- }
- }
- if !allowed {
- continue
- }
-
- s := models.Setting{Key: k, Value: strconv.FormatBool(v), Type: "bool", Category: "feature"}
- if err := h.DB.Where(models.Setting{Key: k}).Assign(s).FirstOrCreate(&s).Error; err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save setting"})
- return
- }
- }
-
- c.JSON(http.StatusOK, gin.H{"status": "ok"})
-}
diff --git a/backend/internal/api/handlers/feature_flags_handler_coverage_test.go b/backend/internal/api/handlers/feature_flags_handler_coverage_test.go
deleted file mode 100644
index 5e84f978..00000000
--- a/backend/internal/api/handlers/feature_flags_handler_coverage_test.go
+++ /dev/null
@@ -1,461 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-
- "github.com/Wikid82/charon/backend/internal/models"
-)
-
-func TestFeatureFlagsHandler_GetFlags_DBPrecedence(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- // Set a flag in DB
- db.Create(&models.Setting{
- Key: "feature.cerberus.enabled",
- Value: "false",
- Type: "bool",
- Category: "feature",
- })
-
- // Set env var that should be ignored (DB takes precedence)
- t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.GET("/api/v1/feature-flags", h.GetFlags)
-
- req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- var flags map[string]bool
- err := json.Unmarshal(w.Body.Bytes(), &flags)
- require.NoError(t, err)
-
- // DB value (false) should take precedence over env (true)
- assert.False(t, flags["feature.cerberus.enabled"])
-}
-
-func TestFeatureFlagsHandler_GetFlags_EnvFallback(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- // Set env var (no DB value exists)
- t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.GET("/api/v1/feature-flags", h.GetFlags)
-
- req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- var flags map[string]bool
- err := json.Unmarshal(w.Body.Bytes(), &flags)
- require.NoError(t, err)
-
- // Env value should be used
- assert.False(t, flags["feature.cerberus.enabled"])
-}
-
-func TestFeatureFlagsHandler_GetFlags_EnvShortForm(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- // Set short form env var (CERBERUS_ENABLED instead of FEATURE_CERBERUS_ENABLED)
- t.Setenv("CERBERUS_ENABLED", "false")
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.GET("/api/v1/feature-flags", h.GetFlags)
-
- req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- var flags map[string]bool
- err := json.Unmarshal(w.Body.Bytes(), &flags)
- require.NoError(t, err)
-
- // Short form env value should be used
- assert.False(t, flags["feature.cerberus.enabled"])
-}
-
-func TestFeatureFlagsHandler_GetFlags_EnvNumeric(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- // Set numeric env var (1/0 instead of true/false)
- t.Setenv("FEATURE_UPTIME_ENABLED", "0")
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.GET("/api/v1/feature-flags", h.GetFlags)
-
- req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- var flags map[string]bool
- err := json.Unmarshal(w.Body.Bytes(), &flags)
- require.NoError(t, err)
-
- // "0" should be parsed as false
- assert.False(t, flags["feature.uptime.enabled"])
-}
-
-func TestFeatureFlagsHandler_GetFlags_DefaultTrue(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- // No DB value, no env var - should default to true
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.GET("/api/v1/feature-flags", h.GetFlags)
-
- req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- var flags map[string]bool
- err := json.Unmarshal(w.Body.Bytes(), &flags)
- require.NoError(t, err)
-
- // All flags should default to true
- assert.True(t, flags["feature.cerberus.enabled"])
- assert.True(t, flags["feature.uptime.enabled"])
-}
-
-func TestFeatureFlagsHandler_GetFlags_AllDefaultFlagsPresent(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.GET("/api/v1/feature-flags", h.GetFlags)
-
- req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- var flags map[string]bool
- err := json.Unmarshal(w.Body.Bytes(), &flags)
- require.NoError(t, err)
-
- // Ensure all default flags are present
- for _, key := range defaultFlags {
- _, ok := flags[key]
- assert.True(t, ok, "expected flag %s to be present", key)
- }
-}
-
-func TestFeatureFlagsHandler_UpdateFlags_Success(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.PUT("/api/v1/feature-flags", h.UpdateFlags)
-
- payload := map[string]bool{
- "feature.cerberus.enabled": false,
- "feature.uptime.enabled": true,
- }
- b, _ := json.Marshal(payload)
-
- req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- // Verify DB persistence
- var s1 models.Setting
- err := db.Where("key = ?", "feature.cerberus.enabled").First(&s1).Error
- require.NoError(t, err)
- assert.Equal(t, "false", s1.Value)
- assert.Equal(t, "bool", s1.Type)
- assert.Equal(t, "feature", s1.Category)
-
- var s2 models.Setting
- err = db.Where("key = ?", "feature.uptime.enabled").First(&s2).Error
- require.NoError(t, err)
- assert.Equal(t, "true", s2.Value)
-}
-
-func TestFeatureFlagsHandler_UpdateFlags_Upsert(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- // Create existing setting
- db.Create(&models.Setting{
- Key: "feature.cerberus.enabled",
- Value: "true",
- Type: "bool",
- Category: "feature",
- })
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.PUT("/api/v1/feature-flags", h.UpdateFlags)
-
- // Update existing setting
- payload := map[string]bool{
- "feature.cerberus.enabled": false,
- }
- b, _ := json.Marshal(payload)
-
- req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- // Verify update
- var s models.Setting
- err := db.Where("key = ?", "feature.cerberus.enabled").First(&s).Error
- require.NoError(t, err)
- assert.Equal(t, "false", s.Value)
-
- // Verify only one record exists
- var count int64
- db.Model(&models.Setting{}).Where("key = ?", "feature.cerberus.enabled").Count(&count)
- assert.Equal(t, int64(1), count)
-}
-
-func TestFeatureFlagsHandler_UpdateFlags_InvalidJSON(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.PUT("/api/v1/feature-flags", h.UpdateFlags)
-
- req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader([]byte("invalid json")))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusBadRequest, w.Code)
-}
-
-func TestFeatureFlagsHandler_UpdateFlags_OnlyAllowedKeys(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.PUT("/api/v1/feature-flags", h.UpdateFlags)
-
- // Try to set a key not in defaultFlags
- payload := map[string]bool{
- "feature.cerberus.enabled": false,
- "feature.invalid.key": true, // Should be ignored
- }
- b, _ := json.Marshal(payload)
-
- req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- // Verify allowed key was saved
- var s1 models.Setting
- err := db.Where("key = ?", "feature.cerberus.enabled").First(&s1).Error
- require.NoError(t, err)
-
- // Verify disallowed key was NOT saved
- var s2 models.Setting
- err = db.Where("key = ?", "feature.invalid.key").First(&s2).Error
- assert.Error(t, err)
-}
-
-func TestFeatureFlagsHandler_UpdateFlags_EmptyPayload(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.PUT("/api/v1/feature-flags", h.UpdateFlags)
-
- payload := map[string]bool{}
- b, _ := json.Marshal(payload)
-
- req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-}
-
-func TestFeatureFlagsHandler_GetFlags_DBValueVariants(t *testing.T) {
- tests := []struct {
- name string
- dbValue string
- expected bool
- }{
- {"lowercase true", "true", true},
- {"uppercase TRUE", "TRUE", true},
- {"mixed case True", "True", true},
- {"numeric 1", "1", true},
- {"yes", "yes", true},
- {"YES uppercase", "YES", true},
- {"lowercase false", "false", false},
- {"numeric 0", "0", false},
- {"no", "no", false},
- {"empty string", "", false},
- {"random string", "random", false},
- {"whitespace padded true", " true ", true},
- {"whitespace padded false", " false ", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- // Set flag with test value
- db.Create(&models.Setting{
- Key: "feature.cerberus.enabled",
- Value: tt.dbValue,
- Type: "bool",
- Category: "feature",
- })
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.GET("/api/v1/feature-flags", h.GetFlags)
-
- req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- var flags map[string]bool
- err := json.Unmarshal(w.Body.Bytes(), &flags)
- require.NoError(t, err)
-
- assert.Equal(t, tt.expected, flags["feature.cerberus.enabled"],
- "dbValue=%q should result in %v", tt.dbValue, tt.expected)
- })
- }
-}
-
-func TestFeatureFlagsHandler_GetFlags_EnvValueVariants(t *testing.T) {
- tests := []struct {
- name string
- envValue string
- expected bool
- }{
- {"true string", "true", true},
- {"TRUE uppercase", "TRUE", true},
- {"1 numeric", "1", true},
- {"false string", "false", false},
- {"FALSE uppercase", "FALSE", false},
- {"0 numeric", "0", false},
- {"invalid value defaults to numeric check", "invalid", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- // Set env var (no DB value)
- t.Setenv("FEATURE_CERBERUS_ENABLED", tt.envValue)
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.GET("/api/v1/feature-flags", h.GetFlags)
-
- req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- var flags map[string]bool
- err := json.Unmarshal(w.Body.Bytes(), &flags)
- require.NoError(t, err)
-
- assert.Equal(t, tt.expected, flags["feature.cerberus.enabled"],
- "envValue=%q should result in %v", tt.envValue, tt.expected)
- })
- }
-}
-
-func TestFeatureFlagsHandler_UpdateFlags_BoolValues(t *testing.T) {
- tests := []struct {
- name string
- value bool
- dbExpect string
- }{
- {"true", true, "true"},
- {"false", false, "false"},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupFlagsDB(t)
-
- h := NewFeatureFlagsHandler(db)
- r := gin.New()
- r.PUT("/api/v1/feature-flags", h.UpdateFlags)
-
- payload := map[string]bool{
- "feature.cerberus.enabled": tt.value,
- }
- b, _ := json.Marshal(payload)
-
- req := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
- req.Header.Set("Content-Type", "application/json")
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- require.Equal(t, http.StatusOK, w.Code)
-
- var s models.Setting
- err := db.Where("key = ?", "feature.cerberus.enabled").First(&s).Error
- require.NoError(t, err)
- assert.Equal(t, tt.dbExpect, s.Value)
- })
- }
-}
-
-func TestFeatureFlagsHandler_NewFeatureFlagsHandler(t *testing.T) {
- db := setupFlagsDB(t)
- h := NewFeatureFlagsHandler(db)
-
- assert.NotNil(t, h)
- assert.NotNil(t, h.DB)
- assert.Equal(t, db, h.DB)
-}
diff --git a/backend/internal/api/handlers/feature_flags_handler_test.go b/backend/internal/api/handlers/feature_flags_handler_test.go
deleted file mode 100644
index d994a8de..00000000
--- a/backend/internal/api/handlers/feature_flags_handler_test.go
+++ /dev/null
@@ -1,99 +0,0 @@
-package handlers
-
-import (
- "bytes"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/models"
-)
-
-func setupFlagsDB(t *testing.T) *gorm.DB {
- db := OpenTestDB(t)
- if err := db.AutoMigrate(&models.Setting{}); err != nil {
- t.Fatalf("auto migrate failed: %v", err)
- }
- return db
-}
-
-func TestFeatureFlags_GetAndUpdate(t *testing.T) {
- db := setupFlagsDB(t)
-
- h := NewFeatureFlagsHandler(db)
-
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/api/v1/feature-flags", h.GetFlags)
- r.PUT("/api/v1/feature-flags", h.UpdateFlags)
-
- // 1) GET should return all default flags (as keys)
- req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String())
- }
- var flags map[string]bool
- if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil {
- t.Fatalf("invalid json: %v", err)
- }
- // ensure keys present
- for _, k := range defaultFlags {
- if _, ok := flags[k]; !ok {
- t.Fatalf("missing default flag key: %s", k)
- }
- }
-
- // 2) PUT update a single flag
- payload := map[string]bool{
- defaultFlags[0]: true,
- }
- b, _ := json.Marshal(payload)
- req2 := httptest.NewRequest(http.MethodPut, "/api/v1/feature-flags", bytes.NewReader(b))
- req2.Header.Set("Content-Type", "application/json")
- w2 := httptest.NewRecorder()
- r.ServeHTTP(w2, req2)
- if w2.Code != http.StatusOK {
- t.Fatalf("expected 200 on update got %d body=%s", w2.Code, w2.Body.String())
- }
-
- // confirm DB persisted
- var s models.Setting
- if err := db.Where("key = ?", defaultFlags[0]).First(&s).Error; err != nil {
- t.Fatalf("expected setting persisted, db error: %v", err)
- }
- if s.Value != "true" {
- t.Fatalf("expected stored value 'true' got '%s'", s.Value)
- }
-}
-
-func TestFeatureFlags_EnvFallback(t *testing.T) {
- // Ensure env fallback is used when DB not present
- t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
-
- db := OpenTestDB(t)
- // Do not write any settings so DB lookup fails and env is used
- h := NewFeatureFlagsHandler(db)
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/api/v1/feature-flags", h.GetFlags)
-
- req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
- if w.Code != http.StatusOK {
- t.Fatalf("expected 200 got %d body=%s", w.Code, w.Body.String())
- }
- var flags map[string]bool
- if err := json.Unmarshal(w.Body.Bytes(), &flags); err != nil {
- t.Fatalf("invalid json: %v", err)
- }
- if !flags["feature.cerberus.enabled"] {
- t.Fatalf("expected feature.cerberus.enabled to be true via env fallback")
- }
-}
diff --git a/backend/internal/api/handlers/handlers_test.go b/backend/internal/api/handlers/handlers_test.go
deleted file mode 100644
index a27132ac..00000000
--- a/backend/internal/api/handlers/handlers_test.go
+++ /dev/null
@@ -1,423 +0,0 @@
-package handlers_test
-
-import (
- "bytes"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "github.com/stretchr/testify/assert"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/api/handlers"
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
-)
-
-func setupTestDB(t *testing.T) *gorm.DB {
- db := handlers.OpenTestDB(t)
-
- // Auto migrate all models that handlers depend on
- db.AutoMigrate(
- &models.ProxyHost{},
- &models.Location{},
- &models.RemoteServer{},
- &models.ImportSession{},
- &models.Notification{},
- &models.NotificationProvider{},
- )
-
- return db
-}
-
-func TestRemoteServerHandler_List(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupTestDB(t)
-
- // Create test server
- server := &models.RemoteServer{
- UUID: uuid.NewString(),
- Name: "Test Server",
- Provider: "docker",
- Host: "localhost",
- Port: 8080,
- Enabled: true,
- }
- db.Create(server)
-
- ns := services.NewNotificationService(db)
- handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
- router := gin.New()
- handler.RegisterRoutes(router.Group("/api/v1"))
-
- // Test List
- w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/api/v1/remote-servers", http.NoBody)
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var servers []models.RemoteServer
- err := json.Unmarshal(w.Body.Bytes(), &servers)
- assert.NoError(t, err)
- assert.Len(t, servers, 1)
- assert.Equal(t, "Test Server", servers[0].Name)
-}
-
-func TestRemoteServerHandler_Create(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupTestDB(t)
-
- ns := services.NewNotificationService(db)
- handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
- router := gin.New()
- handler.RegisterRoutes(router.Group("/api/v1"))
-
- // Test Create
- serverData := map[string]interface{}{
- "name": "New Server",
- "provider": "generic",
- "host": "192.168.1.100",
- "port": 3000,
- "enabled": true,
- }
- body, _ := json.Marshal(serverData)
-
- w := httptest.NewRecorder()
- req, _ := http.NewRequest("POST", "/api/v1/remote-servers", bytes.NewBuffer(body))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusCreated, w.Code)
-
- var server models.RemoteServer
- err := json.Unmarshal(w.Body.Bytes(), &server)
- assert.NoError(t, err)
- assert.Equal(t, "New Server", server.Name)
- assert.NotEmpty(t, server.UUID)
-}
-
-func TestRemoteServerHandler_TestConnection(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupTestDB(t)
-
- // Create test server
- server := &models.RemoteServer{
- UUID: uuid.NewString(),
- Name: "Test Server",
- Provider: "docker",
- Host: "localhost",
- Port: 99999, // Invalid port to test failure
- Enabled: true,
- }
- db.Create(server)
-
- ns := services.NewNotificationService(db)
- handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
- router := gin.New()
- handler.RegisterRoutes(router.Group("/api/v1"))
-
- // Test connection
- w := httptest.NewRecorder()
- req, _ := http.NewRequest("POST", "/api/v1/remote-servers/"+server.UUID+"/test", http.NoBody)
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var result map[string]interface{}
- err := json.Unmarshal(w.Body.Bytes(), &result)
- assert.NoError(t, err)
- assert.False(t, result["reachable"].(bool))
- assert.NotEmpty(t, result["error"])
-}
-
-func TestRemoteServerHandler_Get(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupTestDB(t)
-
- // Create test server
- server := &models.RemoteServer{
- UUID: uuid.NewString(),
- Name: "Test Server",
- Provider: "docker",
- Host: "localhost",
- Port: 8080,
- Enabled: true,
- }
- db.Create(server)
-
- ns := services.NewNotificationService(db)
- handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
- router := gin.New()
- handler.RegisterRoutes(router.Group("/api/v1"))
-
- // Test Get
- w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, http.NoBody)
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var fetched models.RemoteServer
- err := json.Unmarshal(w.Body.Bytes(), &fetched)
- assert.NoError(t, err)
- assert.Equal(t, server.UUID, fetched.UUID)
-}
-
-func TestRemoteServerHandler_Update(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupTestDB(t)
-
- // Create test server
- server := &models.RemoteServer{
- UUID: uuid.NewString(),
- Name: "Test Server",
- Provider: "docker",
- Host: "localhost",
- Port: 8080,
- Enabled: true,
- }
- db.Create(server)
-
- ns := services.NewNotificationService(db)
- handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
- router := gin.New()
- handler.RegisterRoutes(router.Group("/api/v1"))
-
- // Test Update
- updateData := map[string]interface{}{
- "name": "Updated Server",
- "provider": "generic",
- "host": "10.0.0.1",
- "port": 9000,
- "enabled": false,
- }
- body, _ := json.Marshal(updateData)
-
- w := httptest.NewRecorder()
- req, _ := http.NewRequest("PUT", "/api/v1/remote-servers/"+server.UUID, bytes.NewBuffer(body))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var updated models.RemoteServer
- err := json.Unmarshal(w.Body.Bytes(), &updated)
- assert.NoError(t, err)
- assert.Equal(t, "Updated Server", updated.Name)
- assert.Equal(t, "generic", updated.Provider)
- assert.False(t, updated.Enabled)
-}
-
-func TestRemoteServerHandler_Delete(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupTestDB(t)
-
- // Create test server
- server := &models.RemoteServer{
- UUID: uuid.NewString(),
- Name: "Test Server",
- Provider: "docker",
- Host: "localhost",
- Port: 8080,
- Enabled: true,
- }
- db.Create(server)
-
- ns := services.NewNotificationService(db)
- handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
- router := gin.New()
- handler.RegisterRoutes(router.Group("/api/v1"))
-
- // Test Delete
- w := httptest.NewRecorder()
- req, _ := http.NewRequest("DELETE", "/api/v1/remote-servers/"+server.UUID, http.NoBody)
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusNoContent, w.Code)
-
- // Verify Delete
- w2 := httptest.NewRecorder()
- req2, _ := http.NewRequest("GET", "/api/v1/remote-servers/"+server.UUID, http.NoBody)
- router.ServeHTTP(w2, req2)
-
- assert.Equal(t, http.StatusNotFound, w2.Code)
-}
-
-func TestProxyHostHandler_List(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupTestDB(t)
-
- // Create test proxy host
- host := &models.ProxyHost{
- UUID: uuid.NewString(),
- Name: "Test Host",
- DomainNames: "test.local",
- ForwardScheme: "http",
- ForwardHost: "localhost",
- ForwardPort: 3000,
- Enabled: true,
- }
- db.Create(host)
-
- ns := services.NewNotificationService(db)
- handler := handlers.NewProxyHostHandler(db, nil, ns, nil)
- router := gin.New()
- handler.RegisterRoutes(router.Group("/api/v1"))
-
- // Test List
- w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/api/v1/proxy-hosts", http.NoBody)
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var hosts []models.ProxyHost
- err := json.Unmarshal(w.Body.Bytes(), &hosts)
- assert.NoError(t, err)
- assert.Len(t, hosts, 1)
- assert.Equal(t, "Test Host", hosts[0].Name)
-}
-
-func TestProxyHostHandler_Create(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupTestDB(t)
-
- ns := services.NewNotificationService(db)
- handler := handlers.NewProxyHostHandler(db, nil, ns, nil)
- router := gin.New()
- handler.RegisterRoutes(router.Group("/api/v1"))
-
- // Test Create
- hostData := map[string]interface{}{
- "name": "New Host",
- "domain_names": "new.local",
- "forward_scheme": "http",
- "forward_host": "192.168.1.200",
- "forward_port": 8080,
- "enabled": true,
- }
- body, _ := json.Marshal(hostData)
-
- w := httptest.NewRecorder()
- req, _ := http.NewRequest("POST", "/api/v1/proxy-hosts", bytes.NewBuffer(body))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusCreated, w.Code)
-
- var host models.ProxyHost
- err := json.Unmarshal(w.Body.Bytes(), &host)
- assert.NoError(t, err)
- assert.Equal(t, "New Host", host.Name)
- assert.Equal(t, "new.local", host.DomainNames)
- assert.NotEmpty(t, host.UUID)
-}
-
-func TestProxyHostHandler_PartialUpdate_DoesNotWipeFields(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupTestDB(t)
-
- // Seed a proxy host
- original := &models.ProxyHost{
- UUID: uuid.NewString(),
- Name: "Bazarr",
- DomainNames: "bazarr.example.com",
- ForwardScheme: "http",
- ForwardHost: "10.0.0.20",
- ForwardPort: 6767,
- Enabled: true,
- }
- db.Create(original)
-
- ns := services.NewNotificationService(db)
- handler := handlers.NewProxyHostHandler(db, nil, ns, nil)
- router := gin.New()
- handler.RegisterRoutes(router.Group("/api/v1"))
-
- // Perform partial update: only toggle enabled=false
- body := bytes.NewBufferString(`{"enabled": false}`)
- w := httptest.NewRecorder()
- req, _ := http.NewRequest("PUT", "/api/v1/proxy-hosts/"+original.UUID, body)
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var updated models.ProxyHost
- err := json.Unmarshal(w.Body.Bytes(), &updated)
- assert.NoError(t, err)
-
- // Validate that only 'enabled' changed; other fields remain intact
- assert.Equal(t, false, updated.Enabled)
- assert.Equal(t, "Bazarr", updated.Name)
- assert.Equal(t, "bazarr.example.com", updated.DomainNames)
- assert.Equal(t, "http", updated.ForwardScheme)
- assert.Equal(t, "10.0.0.20", updated.ForwardHost)
- assert.Equal(t, 6767, updated.ForwardPort)
-
- // Fetch via GET to ensure DB persisted state correctly
- w2 := httptest.NewRecorder()
- req2, _ := http.NewRequest("GET", "/api/v1/proxy-hosts/"+original.UUID, http.NoBody)
- router.ServeHTTP(w2, req2)
- assert.Equal(t, http.StatusOK, w2.Code)
-
- var fetched models.ProxyHost
- err = json.Unmarshal(w2.Body.Bytes(), &fetched)
- assert.NoError(t, err)
- assert.Equal(t, false, fetched.Enabled)
- assert.Equal(t, "Bazarr", fetched.Name)
- assert.Equal(t, "bazarr.example.com", fetched.DomainNames)
- assert.Equal(t, 6767, fetched.ForwardPort)
-}
-
-func TestHealthHandler(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- router := gin.New()
- router.GET("/health", handlers.HealthHandler)
-
- w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/health", http.NoBody)
- router.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var result map[string]string
- err := json.Unmarshal(w.Body.Bytes(), &result)
- assert.NoError(t, err)
- assert.Equal(t, "ok", result["status"])
-}
-
-func TestRemoteServerHandler_Errors(t *testing.T) {
- gin.SetMode(gin.TestMode)
- db := setupTestDB(t)
-
- ns := services.NewNotificationService(db)
- handler := handlers.NewRemoteServerHandler(services.NewRemoteServerService(db), ns)
- router := gin.New()
- handler.RegisterRoutes(router.Group("/api/v1"))
-
- // Get non-existent
- w := httptest.NewRecorder()
- req, _ := http.NewRequest("GET", "/api/v1/remote-servers/non-existent", http.NoBody)
- router.ServeHTTP(w, req)
- assert.Equal(t, http.StatusNotFound, w.Code)
-
- // Update non-existent
- w = httptest.NewRecorder()
- req, _ = http.NewRequest("PUT", "/api/v1/remote-servers/non-existent", strings.NewReader(`{}`))
- req.Header.Set("Content-Type", "application/json")
- router.ServeHTTP(w, req)
- assert.Equal(t, http.StatusNotFound, w.Code)
-
- // Delete non-existent
- w = httptest.NewRecorder()
- req, _ = http.NewRequest("DELETE", "/api/v1/remote-servers/non-existent", http.NoBody)
- router.ServeHTTP(w, req)
- assert.Equal(t, http.StatusNotFound, w.Code)
-}
diff --git a/backend/internal/api/handlers/health_handler.go b/backend/internal/api/handlers/health_handler.go
deleted file mode 100644
index 71d531ca..00000000
--- a/backend/internal/api/handlers/health_handler.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package handlers
-
-import (
- "net"
- "net/http"
-
- "github.com/Wikid82/charon/backend/internal/version"
- "github.com/gin-gonic/gin"
-)
-
-// getLocalIP returns the non-loopback local IP of the host
-func getLocalIP() string {
- addrs, err := net.InterfaceAddrs()
- if err != nil {
- return ""
- }
- for _, address := range addrs {
- // check the address type and if it is not a loopback then return it
- if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
- if ipnet.IP.To4() != nil {
- return ipnet.IP.String()
- }
- }
- }
- return ""
-}
-
-// HealthHandler responds with basic service metadata for uptime checks.
-func HealthHandler(c *gin.Context) {
- c.JSON(http.StatusOK, gin.H{
- "status": "ok",
- "service": version.Name,
- "version": version.Version,
- "git_commit": version.GitCommit,
- "build_time": version.BuildTime,
- "internal_ip": getLocalIP(),
- })
-}
diff --git a/backend/internal/api/handlers/health_handler_test.go b/backend/internal/api/handlers/health_handler_test.go
deleted file mode 100644
index 2ed9e5f0..00000000
--- a/backend/internal/api/handlers/health_handler_test.go
+++ /dev/null
@@ -1,38 +0,0 @@
-package handlers
-
-import (
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "testing"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
-)
-
-func TestHealthHandler(t *testing.T) {
- gin.SetMode(gin.TestMode)
- r := gin.New()
- r.GET("/health", HealthHandler)
-
- req, _ := http.NewRequest("GET", "/health", http.NoBody)
- w := httptest.NewRecorder()
- r.ServeHTTP(w, req)
-
- assert.Equal(t, http.StatusOK, w.Code)
-
- var resp map[string]string
- err := json.Unmarshal(w.Body.Bytes(), &resp)
- assert.NoError(t, err)
- assert.Equal(t, "ok", resp["status"])
- assert.NotEmpty(t, resp["version"])
-}
-
-func TestGetLocalIP(t *testing.T) {
- // This test just ensures getLocalIP doesn't panic
- // It may return empty string in test environments
- ip := getLocalIP()
- // IP can be empty or a valid IPv4 address
- t.Logf("getLocalIP returned: %q", ip)
- // No assertion needed - just exercising the code path
-}
diff --git a/backend/internal/api/handlers/import_handler.go b/backend/internal/api/handlers/import_handler.go
deleted file mode 100644
index f8495f12..00000000
--- a/backend/internal/api/handlers/import_handler.go
+++ /dev/null
@@ -1,779 +0,0 @@
-package handlers
-
-import (
- "encoding/json"
- "fmt"
- "net/http"
- "os"
- "path"
- "path/filepath"
- "strings"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "gorm.io/gorm"
-
- "github.com/Wikid82/charon/backend/internal/api/middleware"
- "github.com/Wikid82/charon/backend/internal/caddy"
- "github.com/Wikid82/charon/backend/internal/models"
- "github.com/Wikid82/charon/backend/internal/services"
- "github.com/Wikid82/charon/backend/internal/util"
-)
-
-// ImportHandler handles Caddyfile import operations.
-type ImportHandler struct {
- db *gorm.DB
- proxyHostSvc *services.ProxyHostService
- importerservice *caddy.Importer
- importDir string
- mountPath string
-}
-
-// NewImportHandler creates a new import handler.
-func NewImportHandler(db *gorm.DB, caddyBinary, importDir, mountPath string) *ImportHandler {
- return &ImportHandler{
- db: db,
- proxyHostSvc: services.NewProxyHostService(db),
- importerservice: caddy.NewImporter(caddyBinary),
- importDir: importDir,
- mountPath: mountPath,
- }
-}
-
-// RegisterRoutes registers import-related routes.
-func (h *ImportHandler) RegisterRoutes(router *gin.RouterGroup) {
- router.GET("/import/status", h.GetStatus)
- router.GET("/import/preview", h.GetPreview)
- router.POST("/import/upload", h.Upload)
- router.POST("/import/upload-multi", h.UploadMulti)
- router.POST("/import/detect-imports", h.DetectImports)
- router.POST("/import/commit", h.Commit)
- router.DELETE("/import/cancel", h.Cancel)
-}
-
-// GetStatus returns current import session status.
-func (h *ImportHandler) GetStatus(c *gin.Context) {
- var session models.ImportSession
- err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
- Order("created_at DESC").
- First(&session).Error
-
- if err == gorm.ErrRecordNotFound {
- // No pending/reviewing session, check if there's a mounted Caddyfile available for transient preview
- if h.mountPath != "" {
- if fileInfo, err := os.Stat(h.mountPath); err == nil {
- // Check if this mount has already been committed recently
- var committedSession models.ImportSession
- err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed").
- Order("committed_at DESC").
- First(&committedSession).Error
-
- // Allow re-import if:
- // 1. Never committed before (err == gorm.ErrRecordNotFound), OR
- // 2. File was modified after last commit
- allowImport := err == gorm.ErrRecordNotFound
- if !allowImport && committedSession.CommittedAt != nil {
- fileMod := fileInfo.ModTime()
- commitTime := *committedSession.CommittedAt
- allowImport = fileMod.After(commitTime)
- }
-
- if allowImport {
- // Mount file is available for import
- c.JSON(http.StatusOK, gin.H{
- "has_pending": true,
- "session": gin.H{
- "id": "transient",
- "state": "transient",
- "source_file": h.mountPath,
- },
- })
- return
- }
- // Mount file was already committed and hasn't been modified, don't offer it again
- }
- }
- c.JSON(http.StatusOK, gin.H{"has_pending": false})
- return
- }
-
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
- return
- }
-
- c.JSON(http.StatusOK, gin.H{
- "has_pending": true,
- "session": gin.H{
- "id": session.UUID,
- "state": session.Status,
- "created_at": session.CreatedAt,
- "updated_at": session.UpdatedAt,
- },
- })
-}
-
-// GetPreview returns parsed hosts and conflicts for review.
-func (h *ImportHandler) GetPreview(c *gin.Context) {
- var session models.ImportSession
- err := h.db.Where("status IN ?", []string{"pending", "reviewing"}).
- Order("created_at DESC").
- First(&session).Error
-
- if err == nil {
- // DB session found
- var result caddy.ImportResult
- if err := json.Unmarshal([]byte(session.ParsedData), &result); err == nil {
- // Update status to reviewing
- session.Status = "reviewing"
- h.db.Save(&session)
-
- // Read original Caddyfile content if available
- var caddyfileContent string
- if session.SourceFile != "" {
- if content, err := os.ReadFile(session.SourceFile); err == nil {
- caddyfileContent = string(content)
- } else {
- backupPath := filepath.Join(h.importDir, "backups", filepath.Base(session.SourceFile))
- if content, err := os.ReadFile(backupPath); err == nil {
- caddyfileContent = string(content)
- }
- }
- }
-
- c.JSON(http.StatusOK, gin.H{
- "session": gin.H{
- "id": session.UUID,
- "state": session.Status,
- "created_at": session.CreatedAt,
- "updated_at": session.UpdatedAt,
- "source_file": session.SourceFile,
- },
- "preview": result,
- "caddyfile_content": caddyfileContent,
- })
- return
- }
- }
-
- // No DB session found or failed to parse session. Try transient preview from mountPath.
- if h.mountPath != "" {
- if fileInfo, err := os.Stat(h.mountPath); err == nil {
- // Check if this mount has already been committed recently
- var committedSession models.ImportSession
- err := h.db.Where("source_file = ? AND status = ?", h.mountPath, "committed").
- Order("committed_at DESC").
- First(&committedSession).Error
-
- // Allow preview if:
- // 1. Never committed before (err == gorm.ErrRecordNotFound), OR
- // 2. File was modified after last commit
- allowPreview := err == gorm.ErrRecordNotFound
- if !allowPreview && committedSession.CommittedAt != nil {
- allowPreview = fileInfo.ModTime().After(*committedSession.CommittedAt)
- }
-
- if !allowPreview {
- // Mount file was already committed and hasn't been modified, don't offer preview again
- c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
- return
- }
-
- // Parse mounted Caddyfile transiently
- transient, err := h.importerservice.ImportFile(h.mountPath)
- if err != nil {
- c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse mounted Caddyfile"})
- return
- }
-
- // Build a transient session id (not persisted)
- sid := uuid.NewString()
- var caddyfileContent string
- if content, err := os.ReadFile(h.mountPath); err == nil {
- caddyfileContent = string(content)
- }
-
- // Check for conflicts with existing hosts and build conflict details
- existingHosts, _ := h.proxyHostSvc.List()
- existingDomainsMap := make(map[string]models.ProxyHost)
- for _, eh := range existingHosts {
- existingDomainsMap[eh.DomainNames] = eh
- }
-
- conflictDetails := make(map[string]gin.H)
- for _, ph := range transient.Hosts {
- if existing, found := existingDomainsMap[ph.DomainNames]; found {
- transient.Conflicts = append(transient.Conflicts, ph.DomainNames)
- conflictDetails[ph.DomainNames] = gin.H{
- "existing": gin.H{
- "forward_scheme": existing.ForwardScheme,
- "forward_host": existing.ForwardHost,
- "forward_port": existing.ForwardPort,
- "ssl_forced": existing.SSLForced,
- "websocket": existing.WebsocketSupport,
- "enabled": existing.Enabled,
- },
- "imported": gin.H{
- "forward_scheme": ph.ForwardScheme,
- "forward_host": ph.ForwardHost,
- "forward_port": ph.ForwardPort,
- "ssl_forced": ph.SSLForced,
- "websocket": ph.WebsocketSupport,
- },
- }
- }
- }
-
- c.JSON(http.StatusOK, gin.H{
- "session": gin.H{"id": sid, "state": "transient", "source_file": h.mountPath},
- "preview": transient,
- "caddyfile_content": caddyfileContent,
- "conflict_details": conflictDetails,
- })
- return
- }
- }
-
- c.JSON(http.StatusNotFound, gin.H{"error": "no pending import"})
-}
-
-// Upload handles manual Caddyfile upload or paste.
-func (h *ImportHandler) Upload(c *gin.Context) {
- var req struct {
- Content string `json:"content" binding:"required"`
- Filename string `json:"filename"`
- }
-
- // Capture raw request for better diagnostics in tests
- if err := c.ShouldBindJSON(&req); err != nil {
- // Try to include raw body preview when binding fails
- entry := middleware.GetRequestLogger(c)
- if raw, _ := c.GetRawData(); len(raw) > 0 {
- entry.WithError(err).WithField("raw_body_preview", util.SanitizeForLog(string(raw))).Error("Import Upload: failed to bind JSON")
- } else {
- entry.WithError(err).Error("Import Upload: failed to bind JSON")
- }
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
- return
- }
-
- middleware.GetRequestLogger(c).WithField("filename", util.SanitizeForLog(filepath.Base(req.Filename))).WithField("content_len", len(req.Content)).Info("Import Upload: received upload")
-
- // Save upload to import/uploads/