- Added unit tests for CrowdSec handler, including listing, banning, and unbanning IPs. - Implemented mock command executor for testing command execution. - Created tests for various scenarios including successful operations, error handling, and invalid inputs. - Developed CrowdSec configuration tests to ensure proper handler setup and JSON output. - Documented security features and identified gaps in CrowdSec, WAF, and Rate Limiting implementations. - Established acceptance criteria for feature completeness and outlined implementation phases for future work.
475 lines
14 KiB
Go
475 lines
14 KiB
Go
package handlers
|
|
|
|
import (
|
|
"archive/tar"
|
|
"compress/gzip"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/logger"
|
|
|
|
"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)
|
|
}
|
|
|
|
// 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 output
|
|
func (r *RealCommandExecutor) Execute(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
return cmd.Output()
|
|
}
|
|
|
|
// CrowdsecHandler manages CrowdSec process and config imports.
|
|
type CrowdsecHandler struct {
|
|
DB *gorm.DB
|
|
Executor CrowdsecExecutor
|
|
CmdExec CommandExecutor
|
|
BinPath string
|
|
DataDir string
|
|
}
|
|
|
|
func NewCrowdsecHandler(db *gorm.DB, exec CrowdsecExecutor, binPath, dataDir string) *CrowdsecHandler {
|
|
return &CrowdsecHandler{
|
|
DB: db,
|
|
Executor: exec,
|
|
CmdExec: &RealCommandExecutor{},
|
|
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})
|
|
}
|
|
|
|
// 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)
|
|
// 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)
|
|
}
|