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:
GitHub Actions
2025-11-30 20:43:53 +00:00
parent 1244041bd7
commit d789ee85e5
29 changed files with 1721 additions and 607 deletions
@@ -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) }
}
+62 -62
View File
@@ -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")
}
}
+252 -93
View File
@@ -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
}
+12 -12
View File
@@ -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
}
+16 -16
View File
@@ -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)
}
}
+2
View File
@@ -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>
+21 -1
View File
@@ -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 }
+1 -1
View File
@@ -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"
+1
View File
@@ -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: '🛡️' },
]
},
+1 -1
View File
@@ -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
+151
View File
@@ -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
View File
@@ -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>
+17 -1
View File
@@ -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()
})
})