feat: Add CrowdSec configuration management and export functionality
- Implemented CrowdSec configuration page with import/export capabilities. - Added API endpoints for exporting, importing, listing, reading, and writing CrowdSec configuration files. - Enhanced security handler to support runtime overrides for CrowdSec mode and API URL. - Updated frontend components to include CrowdSec settings in the UI. - Added tests for CrowdSec configuration management and security handler behavior. - Improved user experience with toast notifications for successful operations and error handling.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
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", nil)
|
||||
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", nil)
|
||||
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", nil)
|
||||
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", nil)
|
||||
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, nil)
|
||||
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", nil)
|
||||
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", nil)
|
||||
r.ServeHTTP(w7, req7)
|
||||
if w7.Code != http.StatusOK { t.Fatalf("restore expected 200 got %d", w7.Code) }
|
||||
}
|
||||
@@ -1,83 +1,83 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// DefaultCrowdsecExecutor implements CrowdsecExecutor using OS processes.
|
||||
type DefaultCrowdsecExecutor struct{
|
||||
type DefaultCrowdsecExecutor struct {
|
||||
}
|
||||
|
||||
func NewDefaultCrowdsecExecutor() *DefaultCrowdsecExecutor { return &DefaultCrowdsecExecutor{} }
|
||||
|
||||
func (e *DefaultCrowdsecExecutor) pidFile(configDir string) string {
|
||||
return filepath.Join(configDir, "crowdsec.pid")
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,135 +1,294 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"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)
|
||||
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
|
||||
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}
|
||||
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})
|
||||
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"})
|
||||
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})
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// 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})
|
||||
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 gw.Close()
|
||||
tw := tar.NewWriter(gw)
|
||||
defer tw.Close()
|
||||
|
||||
// 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.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)
|
||||
}
|
||||
|
||||
@@ -1,155 +1,274 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"context"
|
||||
"bytes"
|
||||
"context"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type fakeExec struct{
|
||||
started bool
|
||||
type fakeExec struct {
|
||||
started bool
|
||||
}
|
||||
|
||||
func (f *fakeExec) Start(ctx context.Context, binPath, configDir string) (int, error) {
|
||||
f.started = true
|
||||
return 12345, nil
|
||||
f.started = true
|
||||
return 12345, nil
|
||||
}
|
||||
func (f *fakeExec) Stop(ctx context.Context, configDir string) error {
|
||||
f.started = false
|
||||
return nil
|
||||
f.started = false
|
||||
return nil
|
||||
}
|
||||
func (f *fakeExec) Status(ctx context.Context, configDir string) (bool, int, error) {
|
||||
if f.started {
|
||||
return true, 12345, nil
|
||||
}
|
||||
return false, 0, nil
|
||||
if f.started {
|
||||
return true, 12345, nil
|
||||
}
|
||||
return false, 0, nil
|
||||
}
|
||||
|
||||
func setupCrowdDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil { t.Fatalf("db open: %v", err) }
|
||||
return db
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("db open: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestCrowdsecEndpoints(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
fe := &fakeExec{}
|
||||
h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir)
|
||||
fe := &fakeExec{}
|
||||
h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
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", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK { t.Fatalf("status expected 200 got %d", w.Code) }
|
||||
// Status (initially stopped)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/status", nil)
|
||||
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", nil)
|
||||
r.ServeHTTP(w2, req2)
|
||||
if w2.Code != http.StatusOK { t.Fatalf("start expected 200 got %d", w2.Code) }
|
||||
// Start
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/start", nil)
|
||||
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", nil)
|
||||
r.ServeHTTP(w3, req3)
|
||||
if w3.Code != http.StatusOK { t.Fatalf("stop expected 200 got %d", w3.Code) }
|
||||
// Stop
|
||||
w3 := httptest.NewRecorder()
|
||||
req3 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/stop", nil)
|
||||
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)
|
||||
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)
|
||||
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()
|
||||
// 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()) }
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
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)
|
||||
fe := &fakeExec{}
|
||||
h := NewCrowdsecHandler(db, fe, "/bin/false", tmpDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
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()
|
||||
// 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()) }
|
||||
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()) == "" && (len(e.Name()) > 0) && (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")
|
||||
}
|
||||
// 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()) == "" && (len(e.Name()) > 0) && (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", nil)
|
||||
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", nil)
|
||||
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", nil)
|
||||
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 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 exists next to data dir
|
||||
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 {
|
||||
t.Fatalf("expected backup directory next to data dir")
|
||||
}
|
||||
// ensure file content exists in new data dir
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "conf.d", "new.conf")); err != nil {
|
||||
t.Fatalf("expected file written: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,109 +1,109 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
// FeatureFlagsHandler exposes simple DB-backed feature flags with env fallback.
|
||||
type FeatureFlagsHandler struct {
|
||||
DB *gorm.DB
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
func NewFeatureFlagsHandler(db *gorm.DB) *FeatureFlagsHandler {
|
||||
return &FeatureFlagsHandler{DB: db}
|
||||
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",
|
||||
"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)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// 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
|
||||
}
|
||||
// Default false
|
||||
result[key] = false
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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"})
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
@@ -1,77 +1,77 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"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/models"
|
||||
)
|
||||
|
||||
func setupFlagsDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open in-memory sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Setting{}); err != nil {
|
||||
t.Fatalf("auto migrate failed: %v", err)
|
||||
}
|
||||
return db
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open in-memory sqlite: %v", err)
|
||||
}
|
||||
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)
|
||||
db := setupFlagsDB(t)
|
||||
|
||||
h := NewFeatureFlagsHandler(db)
|
||||
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)
|
||||
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", nil)
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 1) GET should return all default flags (as keys)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/feature-flags", nil)
|
||||
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())
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ func (h *ImportHandler) Upload(c *gin.Context) {
|
||||
log.Printf("Import Upload: no hosts parsed and no imports detected; content_len=%d", len(req.Content))
|
||||
}
|
||||
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})
|
||||
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"})
|
||||
@@ -679,10 +679,10 @@ func (h *ImportHandler) Commit(c *gin.Context) {
|
||||
if err := h.proxyHostSvc.Create(&host); err != nil {
|
||||
errMsg := fmt.Sprintf("%s: %s", host.DomainNames, err.Error())
|
||||
errors = append(errors, errMsg)
|
||||
log.Printf("Import Commit Error: %s", util.SanitizeForLog(errMsg))
|
||||
log.Printf("Import Commit Error: %s", util.SanitizeForLog(errMsg))
|
||||
} else {
|
||||
created++
|
||||
log.Printf("Import Commit Success: Created host %s", util.SanitizeForLog(host.DomainNames))
|
||||
log.Printf("Import Commit Success: Created host %s", util.SanitizeForLog(host.DomainNames))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
func TestIsSafePathUnderBase(t *testing.T) {
|
||||
base := filepath.FromSlash("/tmp/session")
|
||||
cases := []struct{
|
||||
cases := []struct {
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
"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
|
||||
service *services.NotificationService
|
||||
}
|
||||
|
||||
func NewNotificationTemplateHandler(s *services.NotificationService) *NotificationTemplateHandler {
|
||||
return &NotificationTemplateHandler{service: s}
|
||||
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)
|
||||
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)
|
||||
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)
|
||||
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"})
|
||||
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 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
|
||||
}
|
||||
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
|
||||
}
|
||||
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})
|
||||
// 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})
|
||||
}
|
||||
|
||||
@@ -1,52 +1,52 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"io"
|
||||
"testing"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"strings"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/Wikid82/charon/backend/internal/services"
|
||||
"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"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func setupDB(t *testing.T) *gorm.DB {
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
db.AutoMigrate(&models.NotificationTemplate{})
|
||||
return db
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
db.AutoMigrate(&models.NotificationTemplate{})
|
||||
return db
|
||||
}
|
||||
|
||||
func TestNotificationTemplateCRUD(t *testing.T) {
|
||||
db := setupDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
db := setupDB(t)
|
||||
svc := services.NewNotificationService(db)
|
||||
h := NewNotificationTemplateHandler(svc)
|
||||
|
||||
// Create
|
||||
payload := `{"name":"Simple","config":"{\"title\": \"{{.Title}}\"}","template":"custom"}`
|
||||
req := httptest.NewRequest("POST", "/", nil)
|
||||
req.Body = io.NopCloser(strings.NewReader(payload))
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
h.Create(c)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
// Create
|
||||
payload := `{"name":"Simple","config":"{\"title\": \"{{.Title}}\"}","template":"custom"}`
|
||||
req := httptest.NewRequest("POST", "/", nil)
|
||||
req.Body = io.NopCloser(strings.NewReader(payload))
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = req
|
||||
h.Create(c)
|
||||
require.Equal(t, http.StatusCreated, w.Code)
|
||||
|
||||
// List
|
||||
req2 := httptest.NewRequest("GET", "/", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Request = req2
|
||||
h.List(c2)
|
||||
require.Equal(t, http.StatusOK, w2.Code)
|
||||
var list []models.NotificationTemplate
|
||||
require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &list))
|
||||
require.Len(t, list, 1)
|
||||
// List
|
||||
req2 := httptest.NewRequest("GET", "/", nil)
|
||||
w2 := httptest.NewRecorder()
|
||||
c2, _ := gin.CreateTestContext(w2)
|
||||
c2.Request = req2
|
||||
h.List(c2)
|
||||
require.Equal(t, http.StatusOK, w2.Code)
|
||||
var list []models.NotificationTemplate
|
||||
require.NoError(t, json.Unmarshal(w2.Body.Bytes(), &list))
|
||||
require.Len(t, list, 1)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
package handlers
|
||||
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
@@ -94,13 +93,13 @@ func (h *ProxyHostHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
if h.caddyManager != nil {
|
||||
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
|
||||
// Rollback: delete the created host if config application fails
|
||||
log.Printf("Error applying config: %s", sanitizeForLog(err.Error()))
|
||||
if deleteErr := h.service.Delete(host.ID); deleteErr != nil {
|
||||
idStr := strconv.FormatUint(uint64(host.ID), 10)
|
||||
log.Printf("Critical: Failed to rollback host %s: %s", sanitizeForLog(idStr), sanitizeForLog(deleteErr.Error()))
|
||||
}
|
||||
if err := h.caddyManager.ApplyConfig(c.Request.Context()); err != nil {
|
||||
// Rollback: delete the created host if config application fails
|
||||
log.Printf("Error applying config: %s", sanitizeForLog(err.Error()))
|
||||
if deleteErr := h.service.Delete(host.ID); deleteErr != nil {
|
||||
idStr := strconv.FormatUint(uint64(host.ID), 10)
|
||||
log.Printf("Critical: Failed to rollback host %s: %s", sanitizeForLog(idStr), sanitizeForLog(deleteErr.Error()))
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to apply configuration: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"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
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeForLog(t *testing.T) {
|
||||
cases := []struct{
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"normal text", "normal text"},
|
||||
{"line\nbreak", "line break"},
|
||||
{"carriage\rreturn\nline", "carriage return line"},
|
||||
{"control\x00chars", "control chars"},
|
||||
}
|
||||
cases := []struct {
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"normal text", "normal text"},
|
||||
{"line\nbreak", "line break"},
|
||||
{"carriage\rreturn\nline", "carriage return line"},
|
||||
{"control\x00chars", "control chars"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
got := sanitizeForLog(tc.in)
|
||||
if got != tc.want {
|
||||
t.Fatalf("sanitizeForLog(%q) = %q; want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := sanitizeForLog(tc.in)
|
||||
if got != tc.want {
|
||||
t.Fatalf("sanitizeForLog(%q) = %q; want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,12 +42,33 @@ func (h *SecurityHandler) GetStatus(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// Treat external crowdsec mode as unsupported in this release. If configured as 'external',
|
||||
// present it as disabled so the UI doesn't attempt to call out to an external agent.
|
||||
if mode == "external" {
|
||||
mode = "disabled"
|
||||
apiURL = ""
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"cerberus": gin.H{"enabled": enabled},
|
||||
"crowdsec": gin.H{
|
||||
"mode": h.cfg.CrowdSecMode,
|
||||
"api_url": h.cfg.CrowdSecAPIURL,
|
||||
"enabled": h.cfg.CrowdSecMode != "disabled",
|
||||
"mode": mode,
|
||||
"api_url": apiURL,
|
||||
"enabled": mode == "local",
|
||||
},
|
||||
"waf": gin.H{
|
||||
"mode": h.cfg.WAFMode,
|
||||
|
||||
@@ -80,3 +80,77 @@ func TestSecurityHandler_Cerberus_DBOverride(t *testing.T) {
|
||||
cerb := response["cerberus"].(map[string]interface{})
|
||||
assert.Equal(t, true, cerb["enabled"].(bool))
|
||||
}
|
||||
|
||||
func TestSecurityHandler_CrowdSec_Mode_DBOverride(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
db := setupTestDB(t)
|
||||
// set DB to configure crowdsec.mode to local
|
||||
if err := db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "local"}).Error; err != nil {
|
||||
t.Fatalf("failed to insert setting: %v", err)
|
||||
}
|
||||
|
||||
cfg := config.SecurityConfig{CrowdSecMode: "disabled"}
|
||||
handler := NewSecurityHandler(cfg, db)
|
||||
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, http.StatusOK, w.Code)
|
||||
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
cs := response["crowdsec"].(map[string]interface{})
|
||||
assert.Equal(t, "local", cs["mode"].(string))
|
||||
}
|
||||
|
||||
func TestSecurityHandler_CrowdSec_ExternalMappedToDisabled_DBOverride(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupTestDB(t)
|
||||
// set DB to configure crowdsec.mode to external
|
||||
if err := db.Create(&models.Setting{Key: "security.crowdsec.mode", Value: "external"}).Error; err != nil {
|
||||
t.Fatalf("failed to insert setting: %v", err)
|
||||
}
|
||||
cfg := config.SecurityConfig{CrowdSecMode: "local"}
|
||||
handler := NewSecurityHandler(cfg, db)
|
||||
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, http.StatusOK, w.Code)
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
cs := response["crowdsec"].(map[string]interface{})
|
||||
assert.Equal(t, "disabled", cs["mode"].(string))
|
||||
assert.Equal(t, false, cs["enabled"].(bool))
|
||||
}
|
||||
|
||||
func TestSecurityHandler_ExternalModeMappedToDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := config.SecurityConfig{
|
||||
CrowdSecMode: "external",
|
||||
WAFMode: "disabled",
|
||||
RateLimitMode: "disabled",
|
||||
ACLMode: "disabled",
|
||||
}
|
||||
handler := NewSecurityHandler(cfg, 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, http.StatusOK, w.Code)
|
||||
var response map[string]interface{}
|
||||
err := json.Unmarshal(w.Body.Bytes(), &response)
|
||||
assert.NoError(t, err)
|
||||
cs := response["crowdsec"].(map[string]interface{})
|
||||
assert.Equal(t, "disabled", cs["mode"].(string))
|
||||
assert.Equal(t, false, cs["enabled"].(bool))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestGetClientIPHeadersAndRemoteAddr(t *testing.T) {
|
||||
// Cloudflare header should win
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("CF-Connecting-IP", "5.6.7.8")
|
||||
ip := getClientIP(req)
|
||||
if ip != "5.6.7.8" {
|
||||
t.Fatalf("expected 5.6.7.8 got %s", ip)
|
||||
}
|
||||
|
||||
// X-Real-IP should be preferred over RemoteAddr
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req2.Header.Set("X-Real-IP", "10.0.0.4")
|
||||
req2.RemoteAddr = "1.2.3.4:5678"
|
||||
ip2 := getClientIP(req2)
|
||||
if ip2 != "10.0.0.4" {
|
||||
t.Fatalf("expected 10.0.0.4 got %s", ip2)
|
||||
}
|
||||
|
||||
// X-Forwarded-For returns first in list
|
||||
req3 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req3.Header.Set("X-Forwarded-For", "192.168.0.1, 192.168.0.2")
|
||||
ip3 := getClientIP(req3)
|
||||
if ip3 != "192.168.0.1" {
|
||||
t.Fatalf("expected 192.168.0.1 got %s", ip3)
|
||||
}
|
||||
|
||||
// Fallback to remote addr port trimmed
|
||||
req4 := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req4.RemoteAddr = "7.7.7.7:8888"
|
||||
ip4 := getClientIP(req4)
|
||||
if ip4 != "7.7.7.7" {
|
||||
t.Fatalf("expected 7.7.7.7 got %s", ip4)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMyIPHandler(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
handler := NewSystemHandler()
|
||||
r.GET("/myip", handler.GetMyIP)
|
||||
|
||||
// With CF header
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/myip", nil)
|
||||
req.Header.Set("CF-Connecting-IP", "5.6.7.8")
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const ImportCaddy = lazy(() => import('./pages/ImportCaddy'))
|
||||
const ImportCrowdSec = lazy(() => import('./pages/ImportCrowdSec'))
|
||||
const Certificates = lazy(() => import('./pages/Certificates'))
|
||||
const SystemSettings = lazy(() => import('./pages/SystemSettings'))
|
||||
const CrowdSecConfig = lazy(() => import('./pages/CrowdSecConfig'))
|
||||
const Account = lazy(() => import('./pages/Account'))
|
||||
const Settings = lazy(() => import('./pages/Settings'))
|
||||
const Backups = lazy(() => import('./pages/Backups'))
|
||||
@@ -61,6 +62,7 @@ export default function App() {
|
||||
<Route path="settings" element={<Settings />}>
|
||||
<Route index element={<SystemSettings />} />
|
||||
<Route path="system" element={<SystemSettings />} />
|
||||
<Route path="crowdsec" element={<CrowdSecConfig />} />
|
||||
<Route path="account" element={<Account />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@@ -24,4 +24,24 @@ export async function importCrowdsecConfig(file: File) {
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig }
|
||||
export async function exportCrowdsecConfig() {
|
||||
const resp = await client.get('/admin/crowdsec/export', { responseType: 'blob' })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function listCrowdsecFiles() {
|
||||
const resp = await client.get<{ files: string[] }>('/admin/crowdsec/files')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function readCrowdsecFile(path: string) {
|
||||
const resp = await client.get<{ content: string }>(`/admin/crowdsec/file?path=${encodeURIComponent(path)}`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function writeCrowdsecFile(path: string, content: string) {
|
||||
const resp = await client.post('/admin/crowdsec/file', { path, content })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export default { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile }
|
||||
|
||||
@@ -216,7 +216,7 @@ export function AccessListForm({ initialData, onSubmit, onCancel, onDelete, isLo
|
||||
<label htmlFor="type" className="block text-sm font-medium text-gray-300 mb-2">
|
||||
Type *
|
||||
<a
|
||||
href="https://wikid82.github.io/charon/docs/security.html#acl-best-practices-by-service-type"
|
||||
href="https://wikid82.github.io/charon/security#acl-best-practices-by-service-type"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 text-blue-400 hover:text-blue-300 text-xs"
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function AccessListSelector({ value, onChange }: AccessListSelect
|
||||
</a>
|
||||
{' • '}
|
||||
<a
|
||||
href="https://wikid82.github.io/charon/docs/security.html#acl-best-practices-by-service-type"
|
||||
href="https://wikid82.github.io/charon/security#acl-best-practices-by-service-type"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline inline-flex items-center gap-1"
|
||||
|
||||
@@ -64,6 +64,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
icon: '⚙️',
|
||||
children: [
|
||||
{ name: 'System', path: '/settings/system', icon: '⚙️' },
|
||||
{ name: 'CrowdSec', path: '/settings/crowdsec', icon: '🛡️' },
|
||||
{ name: 'Account', path: '/settings/account', icon: '🛡️' },
|
||||
]
|
||||
},
|
||||
|
||||
@@ -232,7 +232,7 @@ export default function AccessLists() {
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/docs/security.html#acl-best-practices-by-service-type', '_blank')}
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/security#acl-best-practices-by-service-type', '_blank')}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Best Practices
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { getSecurityStatus } from '../api/security'
|
||||
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile } from '../api/crowdsec'
|
||||
import { createBackup } from '../api/backups'
|
||||
import { updateSetting } from '../api/settings'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../utils/toast'
|
||||
|
||||
export default function CrowdSecConfig() {
|
||||
const { data: status } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus })
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const backupMutation = useMutation({ mutationFn: () => createBackup() })
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (file: File) => {
|
||||
return await importCrowdsecConfig(file)
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('CrowdSec config imported (backup created)')
|
||||
queryClient.invalidateQueries({ queryKey: ['security-status'] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to import')
|
||||
}
|
||||
})
|
||||
|
||||
const listMutation = useQuery({ queryKey: ['crowdsec-files'], queryFn: listCrowdsecFiles })
|
||||
const readMutation = useMutation({ mutationFn: (path: string) => readCrowdsecFile(path), onSuccess: (data) => setFileContent(data.content) })
|
||||
const writeMutation = useMutation({ mutationFn: async ({ path, content }: { path: string; content: string }) => writeCrowdsecFile(path, content), onSuccess: () => { toast.success('File saved'); queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] }) } })
|
||||
const updateModeMutation = useMutation({ mutationFn: async (mode: string) => updateSetting('security.crowdsec.mode', mode, 'security', 'string'), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['security-status'] }) })
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const blob = await exportCrowdsecConfig()
|
||||
const url = window.URL.createObjectURL(new Blob([blob]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `crowdsec-config-${new Date().toISOString().slice(0,19).replace(/[:T]/g, '-')}.tar.gz`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
toast.success('CrowdSec configuration exported')
|
||||
} catch {
|
||||
toast.error('Failed to export CrowdSec configuration')
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file) return
|
||||
try {
|
||||
await backupMutation.mutateAsync()
|
||||
await importMutation.mutateAsync(file)
|
||||
setFile(null)
|
||||
} catch {
|
||||
// handled in onError
|
||||
}
|
||||
}
|
||||
|
||||
const handleReadFile = async (path: string) => {
|
||||
setSelectedPath(path)
|
||||
await readMutation.mutateAsync(path)
|
||||
}
|
||||
|
||||
const handleSaveFile = async () => {
|
||||
if (!selectedPath || fileContent === null) return
|
||||
try {
|
||||
await backupMutation.mutateAsync()
|
||||
await writeMutation.mutateAsync({ path: selectedPath, content: fileContent })
|
||||
} catch {
|
||||
// handled
|
||||
}
|
||||
}
|
||||
|
||||
const handleModeChange = async (mode: string) => {
|
||||
updateModeMutation.mutate(mode)
|
||||
if (mode === 'external') {
|
||||
toast.error('External CrowdSec mode is not supported in this release')
|
||||
} else {
|
||||
toast.success('CrowdSec mode saved (restart may be required)')
|
||||
}
|
||||
}
|
||||
|
||||
if (!status) return <div className="p-8 text-center">Loading...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">CrowdSec Configuration</h1>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Mode</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm text-gray-400">Mode:</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<select value={status.crowdsec.mode} onChange={(e) => handleModeChange(e.target.value)} className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white">
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="local">Local</option>
|
||||
<option value="external">External (unsupported)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{status.crowdsec.mode === 'disabled' && (
|
||||
<p className="text-xs text-yellow-500">Note: External CrowdSec mode is not supported in this build.</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={handleExport}>Export</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-md font-semibold">Import Configuration</h3>
|
||||
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" />
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleImport} disabled={!file || importMutation.isPending} data-testid="import-btn">
|
||||
{importMutation.isPending ? 'Importing...' : 'Import'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-md font-semibold">Edit Configuration Files</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<select className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white" value={selectedPath ?? ''} onChange={(e) => handleReadFile(e.target.value)}>
|
||||
<option value="">Select a file...</option>
|
||||
{listMutation.data?.files.map((f) => (
|
||||
<option value={f} key={f}>{f}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="secondary" onClick={() => listMutation.refetch()}>Refresh</Button>
|
||||
</div>
|
||||
<textarea value={fileContent ?? ''} onChange={(e) => setFileContent(e.target.value)} rows={12} className="w-full bg-gray-900 border border-gray-700 rounded-lg p-3 text-white" />
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSaveFile} isLoading={writeMutation.isPending || backupMutation.isPending}>Save</Button>
|
||||
<Button variant="secondary" onClick={() => { setSelectedPath(null); setFileContent(null) }}>Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+172
-46
@@ -2,8 +2,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Shield, ShieldAlert, ShieldCheck, Lock, Activity, ExternalLink } from 'lucide-react'
|
||||
import { getSecurityStatus } from '../api/security'
|
||||
import { exportCrowdsecConfig } from '../api/crowdsec'
|
||||
import { updateSetting } from '../api/settings'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { toast } from '../utils/toast'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
|
||||
@@ -14,6 +16,21 @@ export default function Security() {
|
||||
queryFn: getSecurityStatus,
|
||||
})
|
||||
const queryClient = useQueryClient()
|
||||
// Generic toggle mutation for per-service settings
|
||||
const toggleServiceMutation = useMutation({
|
||||
mutationFn: async ({ key, enabled }: { key: string; enabled: boolean }) => {
|
||||
await updateSetting(key, enabled ? 'true' : 'false', 'security', 'bool')
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['security-status'] })
|
||||
toast.success('Security setting updated')
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
toast.error(`Failed to update setting: ${msg}`)
|
||||
},
|
||||
})
|
||||
const toggleCerberusMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
await updateSetting('security.cerberus.enabled', enabled ? 'true' : 'false', 'security', 'bool')
|
||||
@@ -34,51 +51,84 @@ export default function Security() {
|
||||
|
||||
const allDisabled = !status?.crowdsec?.enabled && !status?.waf?.enabled && !status?.rate_limit?.enabled && !status?.acl?.enabled
|
||||
|
||||
|
||||
if (allDisabled) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center space-y-6">
|
||||
<div className="bg-gray-100 dark:bg-gray-800 p-6 rounded-full">
|
||||
<Shield className="w-16 h-16 text-gray-400" />
|
||||
</div>
|
||||
<div className="max-w-md space-y-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">Security Services Not Enabled</h2>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Charon supports advanced security features like CrowdSec, WAF, ACLs, and Rate Limiting.
|
||||
These are optional and can be enabled via environment variables.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/docs/security.html', '_blank')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
View Implementation Guide
|
||||
</Button>
|
||||
// Replace the previous early-return that instructed enabling via env vars.
|
||||
// If allDisabled, show a banner and continue to render the dashboard with disabled controls.
|
||||
const headerBanner = allDisabled ? (
|
||||
<div className="flex flex-col items-center justify-center text-center space-y-4 p-6 bg-gray-900/5 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-8 h-8 text-gray-400" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Security Suite Disabled</h2>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-lg">
|
||||
Charon supports advanced security features (CrowdSec, WAF, ACLs, Rate Limiting). Enable the global Cerberus toggle in System Settings and activate individual services below.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/security', '_blank')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
Documentation
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{headerBanner}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ShieldCheck className="w-8 h-8 text-green-500" />
|
||||
Security Dashboard
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Enable Cerberus</label>
|
||||
<Switch
|
||||
checked={status?.cerberus?.enabled ?? false}
|
||||
onChange={(e) => toggleCerberusMutation.mutate(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Enable Cerberus</label>
|
||||
<Switch
|
||||
checked={status?.cerberus?.enabled ?? false}
|
||||
onChange={(e) => toggleCerberusMutation.mutate(e.target.checked)}
|
||||
data-testid="toggle-cerberus"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// enable all services
|
||||
const keys = [
|
||||
'security.crowdsec.enabled',
|
||||
'security.waf.enabled',
|
||||
'security.acl.enabled',
|
||||
'security.rate_limit.enabled',
|
||||
]
|
||||
keys.forEach(k => toggleServiceMutation.mutate({ key: k, enabled: true }))
|
||||
}}
|
||||
data-testid="enable-all-btn"
|
||||
>
|
||||
Enable All
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const keys = [
|
||||
'security.crowdsec.enabled',
|
||||
'security.waf.enabled',
|
||||
'security.acl.enabled',
|
||||
'security.rate_limit.enabled',
|
||||
]
|
||||
keys.forEach(k => toggleServiceMutation.mutate({ key: k, enabled: false }))
|
||||
}}
|
||||
data-testid="disable-all-btn"
|
||||
>
|
||||
Disable All
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/docs/security.html', '_blank')}
|
||||
onClick={() => window.open('https://wikid82.github.io/charon/security', '_blank')}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
@@ -91,7 +141,22 @@ export default function Security() {
|
||||
<Card className={status.crowdsec.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">CrowdSec</h3>
|
||||
<ShieldAlert className={`w-4 h-4 ${status.crowdsec.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={status.crowdsec.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
onChange={(e) => {
|
||||
// pre-validate if enabling external CrowdSec without API URL
|
||||
if (e.target.checked && status.crowdsec?.mode === 'external') {
|
||||
toast.error('External CrowdSec mode is not supported in this release')
|
||||
return
|
||||
}
|
||||
toggleServiceMutation.mutate({ key: 'security.crowdsec.enabled', enabled: e.target.checked })
|
||||
}}
|
||||
data-testid="toggle-crowdsec"
|
||||
/>
|
||||
<ShieldAlert className={`w-4 h-4 ${status.crowdsec.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold mb-1 text-white">
|
||||
@@ -103,16 +168,43 @@ export default function Security() {
|
||||
: 'Intrusion Prevention System'}
|
||||
</p>
|
||||
{status.crowdsec.enabled && (
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => navigate('/tasks/logs?search=crowdsec')}
|
||||
>
|
||||
View Logs
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => navigate('/tasks/logs?search=crowdsec')}
|
||||
>
|
||||
View Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
// download config
|
||||
try {
|
||||
const resp = await exportCrowdsecConfig()
|
||||
const url = window.URL.createObjectURL(new Blob([resp]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `crowdsec-config-${new Date().toISOString().slice(0,19).replace(/[:T]/g, '-')}.tar.gz`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
toast.success('CrowdSec configuration exported')
|
||||
} catch {
|
||||
toast.error('Failed to export CrowdSec configuration')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="w-full" onClick={() => navigate('/settings/crowdsec')}>
|
||||
Configure
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -121,7 +213,15 @@ export default function Security() {
|
||||
<Card className={status.waf.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">WAF (Coraza)</h3>
|
||||
<Shield className={`w-4 h-4 ${status.waf.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={status.waf.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.waf.enabled', enabled: e.target.checked })}
|
||||
data-testid="toggle-waf"
|
||||
/>
|
||||
<Shield className={`w-4 h-4 ${status.waf.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold mb-1 text-white">
|
||||
@@ -137,7 +237,15 @@ export default function Security() {
|
||||
<Card className={status.acl.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">Access Control</h3>
|
||||
<Lock className={`w-4 h-4 ${status.acl.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={status.acl.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.acl.enabled', enabled: e.target.checked })}
|
||||
data-testid="toggle-acl"
|
||||
/>
|
||||
<Lock className={`w-4 h-4 ${status.acl.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold mb-1 text-white">
|
||||
@@ -158,6 +266,11 @@ export default function Security() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!status.acl.enabled && (
|
||||
<div className="mt-4">
|
||||
<Button size="sm" variant="secondary" onClick={() => navigate('/access-lists')}>Configure</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -165,7 +278,15 @@ export default function Security() {
|
||||
<Card className={status.rate_limit.enabled ? 'border-green-200 dark:border-green-900' : ''}>
|
||||
<div className="flex flex-row items-center justify-between pb-2">
|
||||
<h3 className="text-sm font-medium text-white">Rate Limiting</h3>
|
||||
<Activity className={`w-4 h-4 ${status.rate_limit.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={status.rate_limit.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
onChange={(e) => toggleServiceMutation.mutate({ key: 'security.rate_limit.enabled', enabled: e.target.checked })}
|
||||
data-testid="toggle-rate-limit"
|
||||
/>
|
||||
<Activity className={`w-4 h-4 ${status.rate_limit.enabled ? 'text-green-500' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold mb-1 text-white">
|
||||
@@ -181,6 +302,11 @@ export default function Security() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!status.rate_limit.enabled && (
|
||||
<div className="mt-4">
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/settings/system')}>Configure</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { toast } from '../utils/toast'
|
||||
import { getSettings, updateSetting } from '../api/settings'
|
||||
import { getFeatureFlags, updateFeatureFlags } from '../api/featureFlags'
|
||||
import client from '../api/client'
|
||||
import { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig } from '../api/crowdsec'
|
||||
import { startCrowdsec, stopCrowdsec, statusCrowdsec, importCrowdsecConfig, exportCrowdsecConfig } from '../api/crowdsec'
|
||||
import { Loader2, Server, RefreshCw, Save, Activity } from 'lucide-react'
|
||||
|
||||
interface HealthResponse {
|
||||
@@ -356,6 +356,22 @@ export default function SystemSettings() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Button onClick={() => startMutation.mutate()} isLoading={startMutation.isPending}>Start</Button>
|
||||
<Button onClick={() => stopMutation.mutate()} isLoading={stopMutation.isPending} variant="secondary">Stop</Button>
|
||||
<Button onClick={async () => {
|
||||
try {
|
||||
const b = await exportCrowdsecConfig()
|
||||
const url = window.URL.createObjectURL(new Blob([b]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `crowdsec-config-${new Date().toISOString().slice(0,19).replace(/[:T]/g, '-')}.tar.gz`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
toast.success('CrowdSec configuration exported')
|
||||
} catch {
|
||||
toast.error('Failed to export CrowdSec configuration')
|
||||
}
|
||||
}} variant="secondary">Export</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import CrowdSecConfig from '../CrowdSecConfig'
|
||||
import * as api from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('../../api/settings')
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
{ui}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('CrowdSecConfig', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('exports config when clicking Export', async () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] } as any)
|
||||
const blob = new Blob(['dummy'])
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob as any)
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const exportBtn = screen.getByText('Export')
|
||||
await userEvent.click(exportBtn)
|
||||
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('uploads a file and calls import on Import (backup before save)', async () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any)
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' } as any)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] } as any)
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({ status: 'imported' } as any)
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const input = screen.getByTestId('import-file') as HTMLInputElement
|
||||
const file = new File(['dummy'], 'cfg.tar.gz')
|
||||
await userEvent.upload(input, file)
|
||||
const btn = screen.getByTestId('import-btn')
|
||||
await userEvent.click(btn)
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('lists files, reads file content and can save edits (backup before save)', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['conf.d/a.conf', 'b.conf'] } as any)
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: 'rule1' } as any)
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' } as any)
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' } as any)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
// wait for file list
|
||||
await waitFor(() => expect(screen.getByText('conf.d/a.conf')).toBeInTheDocument())
|
||||
const selects = screen.getAllByRole('combobox')
|
||||
const select = selects[1]
|
||||
await userEvent.selectOptions(select, 'conf.d/a.conf')
|
||||
await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf'))
|
||||
// ensure textarea populated
|
||||
const textarea = screen.getByRole('textbox')
|
||||
expect(textarea).toHaveValue('rule1')
|
||||
// edit and save
|
||||
await userEvent.clear(textarea)
|
||||
await userEvent.type(textarea, 'updated')
|
||||
const saveBtn = screen.getByText('Save')
|
||||
await userEvent.click(saveBtn)
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf', 'updated'))
|
||||
})
|
||||
|
||||
it('persists crowdsec.mode via settings when changed', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'disabled', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } } as any
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] } as any)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const selects = screen.getAllByRole('combobox')
|
||||
const modeSelect = selects[0]
|
||||
await userEvent.selectOptions(modeSelect, 'local')
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.mode', 'local', 'security', 'string'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import Security from '../Security'
|
||||
import * as api from '../../api/security'
|
||||
import type { SecurityStatus } from '../../api/security'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../api/crowdsec')
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
const qc = createQueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<BrowserRouter>
|
||||
{ui}
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Security page', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('shows banner when all services are disabled and links to docs', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: false },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
expect(await screen.findByText('Security Suite Disabled')).toBeInTheDocument()
|
||||
const docBtns = screen.getAllByText('Documentation')
|
||||
expect(docBtns.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('renders per-service toggles and calls updateSetting on change', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(crowdsecToggle).toBeTruthy()
|
||||
await userEvent.click(crowdsecToggle)
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool'))
|
||||
// Test Enable All toggles also call updateSetting for multiple keys
|
||||
const enableAllBtn = screen.getByTestId('enable-all-btn')
|
||||
await userEvent.click(enableAllBtn)
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.waf.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('calls export endpoint when clicking Export', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: true, mode: 'local' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
const blob = new Blob(['dummy'])
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob as any)
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
const exportBtn = screen.getByText('Export')
|
||||
await userEvent.click(exportBtn)
|
||||
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('disables service toggles when cerberus is off', async () => {
|
||||
const status: SecurityStatus = {
|
||||
cerberus: { enabled: false },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Suite Disabled')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(crowdsecToggle).toBeDisabled()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user