feat: enhance CrowdSec configuration tests and add new import/export functionality
- Added comprehensive tests for CrowdSec configuration, including preset application and validation error handling. - Introduced new test cases for importing CrowdSec configurations, ensuring backup creation and successful import. - Updated existing tests to reflect changes in UI elements and functionality, including toggling CrowdSec mode and exporting configurations. - Created utility functions for building export filenames and handling downloads, improving code organization and reusability. - Refactored existing tests to use new test IDs and ensure accurate assertions for UI elements and API calls.
This commit is contained in:
@@ -5,16 +5,20 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/crowdsec"
|
||||
"github.com/Wikid82/charon/backend/internal/logger"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -48,18 +52,53 @@ type CrowdsecHandler struct {
|
||||
CmdExec CommandExecutor
|
||||
BinPath string
|
||||
DataDir string
|
||||
Hub *crowdsec.HubService
|
||||
}
|
||||
|
||||
func NewCrowdsecHandler(db *gorm.DB, executor CrowdsecExecutor, binPath, dataDir string) *CrowdsecHandler {
|
||||
cacheDir := filepath.Join(dataDir, "hub_cache")
|
||||
cache, err := crowdsec.NewHubCache(cacheDir, 24*time.Hour)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).Warn("failed to init crowdsec hub cache")
|
||||
}
|
||||
hubSvc := crowdsec.NewHubService(&RealCommandExecutor{}, cache, dataDir)
|
||||
return &CrowdsecHandler{
|
||||
DB: db,
|
||||
Executor: executor,
|
||||
CmdExec: &RealCommandExecutor{},
|
||||
BinPath: binPath,
|
||||
DataDir: dataDir,
|
||||
Hub: hubSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// isCerberusEnabled returns true when Cerberus is enabled via DB or env flag.
|
||||
func (h *CrowdsecHandler) isCerberusEnabled() bool {
|
||||
if h.DB != nil && h.DB.Migrator().HasTable(&models.Setting{}) {
|
||||
var s models.Setting
|
||||
if err := h.DB.Where("key = ?", "feature.cerberus.enabled").First(&s).Error; err == nil {
|
||||
v := strings.ToLower(strings.TrimSpace(s.Value))
|
||||
return v == "true" || v == "1" || v == "yes"
|
||||
}
|
||||
}
|
||||
|
||||
if envVal, ok := os.LookupEnv("FEATURE_CERBERUS_ENABLED"); ok {
|
||||
if b, err := strconv.ParseBool(envVal); err == nil {
|
||||
return b
|
||||
}
|
||||
return envVal == "1"
|
||||
}
|
||||
|
||||
if envVal, ok := os.LookupEnv("CERBERUS_ENABLED"); ok {
|
||||
if b, err := strconv.ParseBool(envVal); err == nil {
|
||||
return b
|
||||
}
|
||||
return envVal == "1"
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Start starts the CrowdSec process.
|
||||
func (h *CrowdsecHandler) Start(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
@@ -326,6 +365,214 @@ func (h *CrowdsecHandler) WriteFile(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "written", "backup": backupDir})
|
||||
}
|
||||
|
||||
// ListPresets returns the curated preset catalog when Cerberus is enabled.
|
||||
func (h *CrowdsecHandler) ListPresets(c *gin.Context) {
|
||||
if !h.isCerberusEnabled() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
type presetInfo struct {
|
||||
crowdsec.Preset
|
||||
Available bool `json:"available"`
|
||||
Cached bool `json:"cached"`
|
||||
CacheKey string `json:"cache_key,omitempty"`
|
||||
Etag string `json:"etag,omitempty"`
|
||||
RetrievedAt *time.Time `json:"retrieved_at,omitempty"`
|
||||
}
|
||||
|
||||
result := map[string]*presetInfo{}
|
||||
for _, p := range crowdsec.ListCuratedPresets() {
|
||||
cp := p
|
||||
result[p.Slug] = &presetInfo{Preset: cp, Available: true}
|
||||
}
|
||||
|
||||
// Merge hub index when available
|
||||
if h.Hub != nil {
|
||||
ctx := c.Request.Context()
|
||||
if idx, err := h.Hub.FetchIndex(ctx); err == nil {
|
||||
for _, item := range idx.Items {
|
||||
slug := strings.TrimSpace(item.Name)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := result[slug]; !ok {
|
||||
result[slug] = &presetInfo{Preset: crowdsec.Preset{
|
||||
Slug: slug,
|
||||
Title: item.Title,
|
||||
Summary: item.Description,
|
||||
Source: "hub",
|
||||
Tags: []string{item.Type},
|
||||
RequiresHub: true,
|
||||
}, Available: true}
|
||||
} else {
|
||||
result[slug].Available = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.Log().WithError(err).Warn("crowdsec hub index unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
// Merge cache metadata
|
||||
if h.Hub != nil && h.Hub.Cache != nil {
|
||||
ctx := c.Request.Context()
|
||||
if cached, err := h.Hub.Cache.List(ctx); err == nil {
|
||||
for _, entry := range cached {
|
||||
if _, ok := result[entry.Slug]; !ok {
|
||||
result[entry.Slug] = &presetInfo{Preset: crowdsec.Preset{Slug: entry.Slug, Title: entry.Slug, Summary: "cached preset", Source: "hub", RequiresHub: true}}
|
||||
}
|
||||
result[entry.Slug].Cached = true
|
||||
result[entry.Slug].CacheKey = entry.CacheKey
|
||||
result[entry.Slug].Etag = entry.Etag
|
||||
if !entry.RetrievedAt.IsZero() {
|
||||
val := entry.RetrievedAt
|
||||
result[entry.Slug].RetrievedAt = &val
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.Log().WithError(err).Warn("crowdsec hub cache list failed")
|
||||
}
|
||||
}
|
||||
|
||||
list := make([]presetInfo, 0, len(result))
|
||||
for _, v := range result {
|
||||
list = append(list, *v)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"presets": list})
|
||||
}
|
||||
|
||||
// PullPreset downloads and caches a hub preset while returning a preview.
|
||||
func (h *CrowdsecHandler) PullPreset(c *gin.Context) {
|
||||
if !h.isCerberusEnabled() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
slug := strings.TrimSpace(payload.Slug)
|
||||
if slug == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "slug required"})
|
||||
return
|
||||
}
|
||||
if h.Hub == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub service unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
res, err := h.Hub.Pull(ctx, slug)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).WithField("slug", slug).Warn("crowdsec preset pull failed")
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "pulled",
|
||||
"slug": res.Meta.Slug,
|
||||
"preview": res.Preview,
|
||||
"cache_key": res.Meta.CacheKey,
|
||||
"etag": res.Meta.Etag,
|
||||
"retrieved_at": res.Meta.RetrievedAt,
|
||||
"source": res.Meta.Source,
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyPreset installs a pulled preset from cache or via cscli.
|
||||
func (h *CrowdsecHandler) ApplyPreset(c *gin.Context) {
|
||||
if !h.isCerberusEnabled() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
|
||||
slug := strings.TrimSpace(payload.Slug)
|
||||
if slug == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "slug required"})
|
||||
return
|
||||
}
|
||||
if h.Hub == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub service unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
res, err := h.Hub.Apply(ctx, slug)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).WithField("slug", slug).Warn("crowdsec preset apply failed")
|
||||
if h.DB != nil {
|
||||
_ = h.DB.Create(&models.CrowdsecPresetEvent{Slug: slug, Action: "apply", Status: "failed", CacheKey: res.CacheKey, BackupPath: res.BackupPath, Error: err.Error()}).Error
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "backup": res.BackupPath})
|
||||
return
|
||||
}
|
||||
|
||||
if h.DB != nil {
|
||||
status := res.Status
|
||||
if status == "" {
|
||||
status = "applied"
|
||||
}
|
||||
slugVal := res.AppliedPreset
|
||||
if slugVal == "" {
|
||||
slugVal = slug
|
||||
}
|
||||
_ = h.DB.Create(&models.CrowdsecPresetEvent{Slug: slugVal, Action: "apply", Status: status, CacheKey: res.CacheKey, BackupPath: res.BackupPath}).Error
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": res.Status,
|
||||
"backup": res.BackupPath,
|
||||
"reload_hint": res.ReloadHint,
|
||||
"used_cscli": res.UsedCSCLI,
|
||||
"cache_key": res.CacheKey,
|
||||
"slug": res.AppliedPreset,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCachedPreset returns cached preview for a slug when available.
|
||||
func (h *CrowdsecHandler) GetCachedPreset(c *gin.Context) {
|
||||
if !h.isCerberusEnabled() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
|
||||
return
|
||||
}
|
||||
if h.Hub == nil || h.Hub.Cache == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub cache unavailable"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
slug := strings.TrimSpace(c.Param("slug"))
|
||||
if slug == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "slug required"})
|
||||
return
|
||||
}
|
||||
preview, err := h.Hub.Cache.LoadPreview(ctx, slug)
|
||||
if err != nil {
|
||||
if errors.Is(err, crowdsec.ErrCacheMiss) || errors.Is(err, crowdsec.ErrCacheExpired) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cache miss"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
meta, _ := h.Hub.Cache.Load(ctx, slug)
|
||||
c.JSON(http.StatusOK, gin.H{"preview": preview, "cache_key": meta.CacheKey, "etag": meta.Etag})
|
||||
}
|
||||
|
||||
// CrowdSecDecision represents a ban decision from CrowdSec
|
||||
type CrowdSecDecision struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -479,6 +726,10 @@ func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("/admin/crowdsec/files", h.ListFiles)
|
||||
rg.GET("/admin/crowdsec/file", h.ReadFile)
|
||||
rg.POST("/admin/crowdsec/file", h.WriteFile)
|
||||
rg.GET("/admin/crowdsec/presets", h.ListPresets)
|
||||
rg.POST("/admin/crowdsec/presets/pull", h.PullPreset)
|
||||
rg.POST("/admin/crowdsec/presets/apply", h.ApplyPreset)
|
||||
rg.GET("/admin/crowdsec/presets/cache/:slug", h.GetCachedPreset)
|
||||
// Decision management endpoints (Banned IP Dashboard)
|
||||
rg.GET("/admin/crowdsec/decisions", h.ListDecisions)
|
||||
rg.POST("/admin/crowdsec/ban", h.BanIP)
|
||||
|
||||
@@ -221,6 +221,8 @@ func TestCrowdsec_ExportConfig_NotFound(t *testing.T) {
|
||||
os.RemoveAll(nonExistentDir) // Make sure it doesn't exist
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", nonExistentDir)
|
||||
// remove any cache dir created during handler init so Export sees missing dir
|
||||
_ = os.RemoveAll(nonExistentDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
@@ -360,3 +362,95 @@ func TestCrowdsec_WriteFile_Success(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "new content", string(content))
|
||||
}
|
||||
|
||||
func TestCrowdsec_ListPresets_Disabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func TestCrowdsec_ListPresets_Success(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
presets, ok := resp["presets"].([]interface{})
|
||||
assert.True(t, ok)
|
||||
assert.Greater(t, len(presets), 0)
|
||||
}
|
||||
|
||||
func TestCrowdsec_PullPreset_Validation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
||||
h.Hub = nil // simulate hub unavailable
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader([]byte("{}")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader([]byte(`{"slug":"demo"}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
}
|
||||
|
||||
func TestCrowdsec_ApplyPreset_Validation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
||||
h.Hub = nil
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader([]byte("{}")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader([]byte(`{"slug":"demo"}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -225,6 +232,46 @@ func TestListAndReadFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigStreamsArchive(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
dataDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte("hello"), 0o644))
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
|
||||
require.Contains(t, w.Header().Get("Content-Disposition"), "crowdsec-config-")
|
||||
|
||||
gr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
|
||||
require.NoError(t, err)
|
||||
tr := tar.NewReader(gr)
|
||||
found := false
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
require.NoError(t, err)
|
||||
if hdr.Name == "config.yaml" {
|
||||
data, readErr := io.ReadAll(tr)
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, "hello", string(data))
|
||||
found = true
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected exported archive to contain config file")
|
||||
}
|
||||
|
||||
func TestWriteFileCreatesBackup(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
@@ -251,20 +298,224 @@ func TestWriteFileCreatesBackup(t *testing.T) {
|
||||
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))
|
||||
// ensure backup directory was created
|
||||
entries, err := os.ReadDir(filepath.Dir(tmpDir))
|
||||
require.NoError(t, err)
|
||||
foundBackup := false
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && filepath.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") {
|
||||
found = true
|
||||
if e.IsDir() && strings.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") {
|
||||
foundBackup = 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)
|
||||
require.True(t, foundBackup, "expected backup directory to be created")
|
||||
}
|
||||
|
||||
func TestListPresetsCerberusDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
|
||||
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 when cerberus disabled got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileInvalidPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=../secret", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid path got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileInvalidPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"path": "../../escape", "content": "bad"})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid path got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileMissingPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"content": "data only"})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestWriteFileInvalidPayload(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewBufferString("not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestImportConfigRequiresFile(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 when file missing got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportConfigRejectsEmptyUpload(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
mw := multipart.NewWriter(buf)
|
||||
_, _ = mw.CreateFormFile("file", "empty.tgz")
|
||||
_ = mw.Close()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for empty upload got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilesMissingDir(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
missingDir := filepath.Join(t.TempDir(), "does-not-exist")
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", missingDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for missing dir got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilesReturnsEntries(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
dataDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "root.txt"), []byte("root"), 0o644))
|
||||
nestedDir := filepath.Join(dataDir, "nested")
|
||||
require.NoError(t, os.MkdirAll(nestedDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(nestedDir, "child.txt"), []byte("child"), 0o644))
|
||||
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", dataDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
require.ElementsMatch(t, []string{"root.txt", filepath.Join("nested", "child.txt")}, resp.Files)
|
||||
}
|
||||
|
||||
func TestIsCerberusEnabledFromDB(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := OpenTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
require.NoError(t, db.Create(&models.Setting{Key: "feature.cerberus.enabled", Value: "0"}).Error)
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 when cerberus disabled via DB got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCerberusEnabledInvalidEnv(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("FEATURE_CERBERUS_ENABLED", "not-a-bool")
|
||||
h := NewCrowdsecHandler(nil, &fakeExec{}, "/bin/false", t.TempDir())
|
||||
|
||||
if h.isCerberusEnabled() {
|
||||
t.Fatalf("expected cerberus to be disabled for invalid env flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCerberusEnabledLegacyEnv(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(nil, &fakeExec{}, "/bin/false", t.TempDir())
|
||||
|
||||
t.Setenv("CERBERUS_ENABLED", "0")
|
||||
|
||||
if h.isCerberusEnabled() {
|
||||
t.Fatalf("expected cerberus to be disabled for legacy env flag")
|
||||
}
|
||||
}
|
||||
|
||||
412
backend/internal/api/handlers/crowdsec_presets_handler_test.go
Normal file
412
backend/internal/api/handlers/crowdsec_presets_handler_test.go
Normal file
@@ -0,0 +1,412 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/crowdsec"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
)
|
||||
|
||||
type presetRoundTripper func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (p presetRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return p(req)
|
||||
}
|
||||
|
||||
func makePresetTar(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
gw := gzip.NewWriter(buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
for name, content := range files {
|
||||
hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))}
|
||||
require.NoError(t, tw.WriteHeader(hdr))
|
||||
_, err := tw.Write([]byte(content))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, tw.Close())
|
||||
require.NoError(t, gw.Close())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestListPresetsIncludesCacheAndIndex(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
_, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", []byte("archive"))
|
||||
require.NoError(t, err)
|
||||
|
||||
hub := crowdsec.NewHubService(nil, cache, t.TempDir())
|
||||
hub.HubBaseURL = "http://example.com"
|
||||
hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.String() == "http://example.com/api/index.json" {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","type":"collection"}]}`)), Header: make(http.Header)}, nil
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
|
||||
db := OpenTestDB(t)
|
||||
handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
|
||||
handler.Hub = hub
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
handler.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var payload struct {
|
||||
Presets []struct {
|
||||
Slug string `json:"slug"`
|
||||
Cached bool `json:"cached"`
|
||||
} `json:"presets"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
found := false
|
||||
for _, p := range payload.Presets {
|
||||
if p.Slug == "crowdsecurity/demo" {
|
||||
found = true
|
||||
require.True(t, p.Cached)
|
||||
}
|
||||
}
|
||||
require.True(t, found)
|
||||
}
|
||||
|
||||
func TestPullPresetHandlerSuccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
dataDir := filepath.Join(t.TempDir(), "crowdsec")
|
||||
archive := makePresetTar(t, map[string]string{"config.yaml": "key: value"})
|
||||
|
||||
hub := crowdsec.NewHubService(nil, cache, dataDir)
|
||||
hub.HubBaseURL = "http://example.com"
|
||||
hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.String() {
|
||||
case "http://example.com/api/index.json":
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","etag":"e1","download_url":"http://example.com/demo.tgz","preview_url":"http://example.com/demo.yaml"}]}`)), Header: make(http.Header)}, nil
|
||||
case "http://example.com/demo.yaml":
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("preview")), Header: make(http.Header)}, nil
|
||||
case "http://example.com/demo.tgz":
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil
|
||||
default:
|
||||
return &http.Response{StatusCode: http.StatusNotFound, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
|
||||
}
|
||||
})}
|
||||
|
||||
handler := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", dataDir)
|
||||
handler.Hub = hub
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
handler.RegisterRoutes(g)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), "cache_key")
|
||||
require.Contains(t, w.Body.String(), "preview")
|
||||
}
|
||||
|
||||
func TestApplyPresetHandlerAudits(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := OpenTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&models.CrowdsecPresetEvent{}))
|
||||
|
||||
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
dataDir := filepath.Join(t.TempDir(), "crowdsec")
|
||||
archive := makePresetTar(t, map[string]string{"conf.yaml": "v: 1"})
|
||||
_, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", archive)
|
||||
require.NoError(t, err)
|
||||
|
||||
hub := crowdsec.NewHubService(nil, cache, dataDir)
|
||||
|
||||
handler := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
|
||||
handler.Hub = hub
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
handler.RegisterRoutes(g)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var events []models.CrowdsecPresetEvent
|
||||
require.NoError(t, db.Find(&events).Error)
|
||||
require.Len(t, events, 1)
|
||||
require.Equal(t, "applied", events[0].Status)
|
||||
|
||||
// Failure path
|
||||
badCache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
badArchive := makePresetTar(t, map[string]string{"../bad.txt": "x"})
|
||||
_, err = badCache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", badArchive)
|
||||
require.NoError(t, err)
|
||||
|
||||
badHub := crowdsec.NewHubService(nil, badCache, filepath.Join(t.TempDir(), "crowdsec2"))
|
||||
handler.Hub = badHub
|
||||
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body))
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w2, req2)
|
||||
require.Equal(t, http.StatusInternalServerError, w2.Code)
|
||||
|
||||
require.NoError(t, db.Find(&events).Error)
|
||||
require.Len(t, events, 2)
|
||||
require.Equal(t, "failed", events[1].Status)
|
||||
}
|
||||
|
||||
func TestPullPresetHandlerHubError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
hub := crowdsec.NewHubService(nil, cache, t.TempDir())
|
||||
hub.HubBaseURL = "http://example.com"
|
||||
hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader("")), Header: make(http.Header)}, nil
|
||||
})}
|
||||
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
h.Hub = hub
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/missing"})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadGateway, w.Code)
|
||||
}
|
||||
|
||||
func TestGetCachedPresetNotFound(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir())
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/unknown", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func TestGetCachedPresetServiceUnavailable(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
h.Hub = &crowdsec.HubService{}
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/demo", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
}
|
||||
|
||||
func TestApplyPresetHandlerBackupFailure(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := OpenTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&models.CrowdsecPresetEvent{}))
|
||||
|
||||
baseDir := t.TempDir()
|
||||
dataDir := filepath.Join(baseDir, "crowdsec")
|
||||
require.NoError(t, os.MkdirAll(dataDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "keep.txt"), []byte("before"), 0o644))
|
||||
|
||||
hub := crowdsec.NewHubService(nil, nil, dataDir)
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
|
||||
h.Hub = hub
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"slug": "crowdsecurity/demo"})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
|
||||
var events []models.CrowdsecPresetEvent
|
||||
require.NoError(t, db.Find(&events).Error)
|
||||
require.Len(t, events, 1)
|
||||
require.Equal(t, "failed", events[0].Status)
|
||||
require.NotEmpty(t, events[0].BackupPath)
|
||||
|
||||
content, readErr := os.ReadFile(filepath.Join(dataDir, "keep.txt"))
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, "before", string(content))
|
||||
}
|
||||
|
||||
func TestListPresetsMergesCuratedAndHub(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
hub := crowdsec.NewHubService(nil, nil, t.TempDir())
|
||||
hub.HubBaseURL = "http://hub.example"
|
||||
hub.HTTPClient = &http.Client{Transport: presetRoundTripper(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.String() == "http://hub.example/api/index.json" {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"items":[{"name":"crowdsecurity/custom","title":"Custom","description":"d","type":"collection"}]}`)), Header: make(http.Header)}, nil
|
||||
}
|
||||
return nil, errors.New("unexpected request")
|
||||
})}
|
||||
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
h.Hub = hub
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
var payload struct {
|
||||
Presets []struct {
|
||||
Slug string `json:"slug"`
|
||||
Source string `json:"source"`
|
||||
Tags []string `json:"tags"`
|
||||
} `json:"presets"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
|
||||
foundCurated := false
|
||||
foundHub := false
|
||||
for _, p := range payload.Presets {
|
||||
if p.Slug == "honeypot-friendly-defaults" {
|
||||
foundCurated = true
|
||||
}
|
||||
if p.Slug == "crowdsecurity/custom" {
|
||||
foundHub = true
|
||||
require.Equal(t, []string{"collection"}, p.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
require.True(t, foundCurated)
|
||||
require.True(t, foundHub)
|
||||
}
|
||||
|
||||
func TestGetCachedPresetSuccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
||||
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
const slug = "demo"
|
||||
_, err = cache.Store(context.Background(), slug, "etag123", "hub", "preview-body", []byte("tgz"))
|
||||
require.NoError(t, err)
|
||||
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir())
|
||||
require.True(t, h.isCerberusEnabled())
|
||||
preview, err := h.Hub.Cache.LoadPreview(context.Background(), slug)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "preview-body", preview)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/"+slug, http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), "preview-body")
|
||||
require.Contains(t, w.Body.String(), "etag123")
|
||||
}
|
||||
|
||||
func TestGetCachedPresetSlugRequired(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
||||
cache, err := crowdsec.NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir())
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/%20", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
require.Contains(t, w.Body.String(), "slug required")
|
||||
}
|
||||
|
||||
func TestGetCachedPresetPreviewError(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("FEATURE_CERBERUS_ENABLED", "true")
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := crowdsec.NewHubCache(cacheDir, time.Hour)
|
||||
require.NoError(t, err)
|
||||
const slug = "broken"
|
||||
meta, err := cache.Store(context.Background(), slug, "etag999", "hub", "will-remove", []byte("tgz"))
|
||||
require.NoError(t, err)
|
||||
// Remove preview to force LoadPreview read error.
|
||||
require.NoError(t, os.Remove(meta.PreviewPath))
|
||||
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
h.Hub = crowdsec.NewHubService(nil, cache, t.TempDir())
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets/cache/"+slug, http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.Contains(t, w.Body.String(), "no such file")
|
||||
}
|
||||
@@ -58,6 +58,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
&models.SecurityAudit{},
|
||||
&models.SecurityRuleSet{},
|
||||
&models.UserPermittedHost{}, // Join table for user permissions
|
||||
&models.CrowdsecPresetEvent{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("auto migrate: %w", err)
|
||||
}
|
||||
|
||||
243
backend/internal/crowdsec/hub_cache.go
Normal file
243
backend/internal/crowdsec/hub_cache.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCacheMiss = errors.New("cache miss")
|
||||
ErrCacheExpired = errors.New("cache expired")
|
||||
)
|
||||
|
||||
// CachedPreset captures metadata about a pulled preset bundle.
|
||||
type CachedPreset struct {
|
||||
Slug string `json:"slug"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
Etag string `json:"etag"`
|
||||
Source string `json:"source"`
|
||||
RetrievedAt time.Time `json:"retrieved_at"`
|
||||
PreviewPath string `json:"preview_path"`
|
||||
ArchivePath string `json:"archive_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// HubCache persists pulled bundles on disk with TTL-based eviction.
|
||||
type HubCache struct {
|
||||
baseDir string
|
||||
ttl time.Duration
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
var slugPattern = regexp.MustCompile(`^[A-Za-z0-9./_-]+$`)
|
||||
|
||||
// NewHubCache constructs a cache rooted at baseDir with the provided TTL.
|
||||
func NewHubCache(baseDir string, ttl time.Duration) (*HubCache, error) {
|
||||
if baseDir == "" {
|
||||
return nil, fmt.Errorf("baseDir required")
|
||||
}
|
||||
if err := os.MkdirAll(baseDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create cache dir: %w", err)
|
||||
}
|
||||
return &HubCache{baseDir: baseDir, ttl: ttl, nowFn: time.Now}, nil
|
||||
}
|
||||
|
||||
// Store writes the bundle archive and preview to disk and returns the cache metadata.
|
||||
func (c *HubCache) Store(ctx context.Context, slug, etag, source, preview string, archive []byte) (CachedPreset, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return CachedPreset{}, err
|
||||
}
|
||||
cleanSlug := sanitizeSlug(slug)
|
||||
if cleanSlug == "" {
|
||||
return CachedPreset{}, fmt.Errorf("invalid slug")
|
||||
}
|
||||
dir := filepath.Join(c.baseDir, cleanSlug)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return CachedPreset{}, fmt.Errorf("create slug dir: %w", err)
|
||||
}
|
||||
|
||||
ts := c.nowFn().UTC()
|
||||
cacheKey := fmt.Sprintf("%s-%d", cleanSlug, ts.Unix())
|
||||
|
||||
archivePath := filepath.Join(dir, "bundle.tgz")
|
||||
if err := os.WriteFile(archivePath, archive, 0o640); err != nil {
|
||||
return CachedPreset{}, fmt.Errorf("write archive: %w", err)
|
||||
}
|
||||
previewPath := filepath.Join(dir, "preview.yaml")
|
||||
if err := os.WriteFile(previewPath, []byte(preview), 0o640); err != nil {
|
||||
return CachedPreset{}, fmt.Errorf("write preview: %w", err)
|
||||
}
|
||||
|
||||
meta := CachedPreset{
|
||||
Slug: cleanSlug,
|
||||
CacheKey: cacheKey,
|
||||
Etag: etag,
|
||||
Source: source,
|
||||
RetrievedAt: ts,
|
||||
PreviewPath: previewPath,
|
||||
ArchivePath: archivePath,
|
||||
SizeBytes: int64(len(archive)),
|
||||
}
|
||||
metaPath := filepath.Join(dir, "metadata.json")
|
||||
raw, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return CachedPreset{}, fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(metaPath, raw, 0o640); err != nil {
|
||||
return CachedPreset{}, fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// Load returns cached preset metadata, enforcing TTL.
|
||||
func (c *HubCache) Load(ctx context.Context, slug string) (CachedPreset, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return CachedPreset{}, err
|
||||
}
|
||||
cleanSlug := sanitizeSlug(slug)
|
||||
if cleanSlug == "" {
|
||||
return CachedPreset{}, fmt.Errorf("invalid slug")
|
||||
}
|
||||
metaPath := filepath.Join(c.baseDir, cleanSlug, "metadata.json")
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return CachedPreset{}, ErrCacheMiss
|
||||
}
|
||||
return CachedPreset{}, err
|
||||
}
|
||||
var meta CachedPreset
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return CachedPreset{}, fmt.Errorf("unmarshal metadata: %w", err)
|
||||
}
|
||||
|
||||
if c.ttl > 0 && c.nowFn().After(meta.RetrievedAt.Add(c.ttl)) {
|
||||
return CachedPreset{}, ErrCacheExpired
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// LoadPreview returns the preview contents for a cached preset.
|
||||
func (c *HubCache) LoadPreview(ctx context.Context, slug string) (string, error) {
|
||||
meta, err := c.Load(ctx, slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(meta.PreviewPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// List returns cached presets that have not expired.
|
||||
func (c *HubCache) List(ctx context.Context) ([]CachedPreset, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]CachedPreset, 0)
|
||||
err := filepath.WalkDir(c.baseDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() || d.Name() != "metadata.json" {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(c.baseDir, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
slug := filepath.Dir(rel)
|
||||
meta, err := c.Load(ctx, slug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
results = append(results, meta)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Evict removes cached data for the given slug.
|
||||
func (c *HubCache) Evict(ctx context.Context, slug string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanSlug := sanitizeSlug(slug)
|
||||
if cleanSlug == "" {
|
||||
return fmt.Errorf("invalid slug")
|
||||
}
|
||||
return os.RemoveAll(filepath.Join(c.baseDir, cleanSlug))
|
||||
}
|
||||
|
||||
// sanitizeSlug guards against traversal and unsupported characters.
|
||||
func sanitizeSlug(slug string) string {
|
||||
trimmed := strings.TrimSpace(slug)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
cleaned := filepath.Clean(trimmed)
|
||||
cleaned = strings.ReplaceAll(cleaned, "\\", "/")
|
||||
if strings.HasPrefix(cleaned, "..") || strings.Contains(cleaned, string(os.PathSeparator)+"..") || strings.HasPrefix(cleaned, string(os.PathSeparator)) {
|
||||
return ""
|
||||
}
|
||||
if !slugPattern.MatchString(cleaned) {
|
||||
return ""
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// Exists returns true when a non-expired cache entry is present.
|
||||
func (c *HubCache) Exists(ctx context.Context, slug string) bool {
|
||||
if _, err := c.Load(ctx, slug); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Touch updates the timestamp to extend TTL; noop when missing.
|
||||
func (c *HubCache) Touch(ctx context.Context, slug string) error {
|
||||
meta, err := c.Load(ctx, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta.RetrievedAt = c.nowFn().UTC()
|
||||
raw, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metaPath := filepath.Join(c.baseDir, meta.Slug, "metadata.json")
|
||||
return os.WriteFile(metaPath, raw, 0o640)
|
||||
}
|
||||
|
||||
// Size returns aggregated size of cached archives (best effort).
|
||||
func (c *HubCache) Size(ctx context.Context) int64 {
|
||||
var total int64
|
||||
_ = filepath.WalkDir(c.baseDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
return total
|
||||
}
|
||||
198
backend/internal/crowdsec/hub_cache_test.go
Normal file
198
backend/internal/crowdsec/hub_cache_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHubCacheStoreLoadAndExpire(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
meta, err := cache.Store(ctx, "crowdsecurity/demo", "etag1", "hub", "preview-text", []byte("archive-bytes"))
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, meta.CacheKey)
|
||||
|
||||
loaded, err := cache.Load(ctx, "crowdsecurity/demo")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, meta.CacheKey, loaded.CacheKey)
|
||||
require.Equal(t, "etag1", loaded.Etag)
|
||||
|
||||
cache.nowFn = func() time.Time { return meta.RetrievedAt.Add(2 * time.Minute) }
|
||||
_, err = cache.Load(ctx, "crowdsecurity/demo")
|
||||
require.ErrorIs(t, err, ErrCacheExpired)
|
||||
}
|
||||
|
||||
func TestHubCacheRejectsBadSlug(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cache.Store(context.Background(), "../bad", "etag", "hub", "preview", []byte("data"))
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = cache.Store(context.Background(), "..\\bad", "etag", "hub", "preview", []byte("data"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHubCacheListAndEvict(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
_, err = cache.Store(ctx, "crowdsecurity/demo", "etag1", "hub", "preview", []byte("data1"))
|
||||
require.NoError(t, err)
|
||||
_, err = cache.Store(ctx, "crowdsecurity/other", "etag2", "hub", "preview", []byte("data2"))
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := cache.List(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 2)
|
||||
|
||||
require.NoError(t, cache.Evict(ctx, "crowdsecurity/demo"))
|
||||
entries, err = cache.List(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, "crowdsecurity/other", entries[0].Slug)
|
||||
}
|
||||
|
||||
func TestHubCacheTouchUpdatesTTL(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
meta, err := cache.Store(ctx, "crowdsecurity/demo", "etag1", "hub", "preview", []byte("data1"))
|
||||
require.NoError(t, err)
|
||||
|
||||
cache.nowFn = func() time.Time { return meta.RetrievedAt.Add(30 * time.Second) }
|
||||
require.NoError(t, cache.Touch(ctx, "crowdsecurity/demo"))
|
||||
|
||||
cache.nowFn = func() time.Time { return meta.RetrievedAt.Add(2 * time.Minute) }
|
||||
_, err = cache.Load(ctx, "crowdsecurity/demo")
|
||||
require.ErrorIs(t, err, ErrCacheExpired)
|
||||
}
|
||||
|
||||
func TestHubCachePreviewExistsAndSize(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
archive := []byte("archive-bytes-here")
|
||||
_, err = cache.Store(ctx, "crowdsecurity/demo", "etag123", "hub", "preview-content", archive)
|
||||
require.NoError(t, err)
|
||||
|
||||
preview, err := cache.LoadPreview(ctx, "crowdsecurity/demo")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "preview-content", preview)
|
||||
require.True(t, cache.Exists(ctx, "crowdsecurity/demo"))
|
||||
require.GreaterOrEqual(t, cache.Size(ctx), int64(len(archive)))
|
||||
}
|
||||
|
||||
func TestHubCacheExistsHonorsTTL(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
meta, err := cache.Store(ctx, "crowdsecurity/demo", "etag123", "hub", "preview", []byte("data"))
|
||||
require.NoError(t, err)
|
||||
|
||||
cache.nowFn = func() time.Time { return meta.RetrievedAt.Add(3 * time.Second) }
|
||||
require.False(t, cache.Exists(ctx, "crowdsecurity/demo"))
|
||||
}
|
||||
|
||||
func TestSanitizeSlugCases(t *testing.T) {
|
||||
require.Equal(t, "demo/preset", sanitizeSlug(" demo/preset "))
|
||||
require.Equal(t, "", sanitizeSlug("../traverse"))
|
||||
require.Equal(t, "", sanitizeSlug("/abs/path"))
|
||||
require.Equal(t, "", sanitizeSlug("\\windows\\bad"))
|
||||
require.Equal(t, "", sanitizeSlug("bad spaces %"))
|
||||
}
|
||||
|
||||
func TestNewHubCacheRequiresBaseDir(t *testing.T) {
|
||||
_, err := NewHubCache("", time.Hour)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHubCacheTouchMissing(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cache.Touch(context.Background(), "missing")
|
||||
require.ErrorIs(t, err, ErrCacheMiss)
|
||||
}
|
||||
|
||||
func TestHubCacheTouchInvalidSlug(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cache.Touch(context.Background(), "../bad")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHubCacheStoreContextCanceled(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err = cache.Store(ctx, "demo", "etag", "hub", "preview", []byte("data"))
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
func TestHubCacheLoadInvalidSlug(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cache.Load(context.Background(), "../bad")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHubCacheExistsContextCanceled(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
require.False(t, cache.Exists(ctx, "demo"))
|
||||
}
|
||||
|
||||
func TestHubCacheListSkipsExpired(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Second)
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
fixed := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
cache.nowFn = func() time.Time { return fixed }
|
||||
_, err = cache.Store(ctx, "crowdsecurity/demo", "etag", "hub", "preview", []byte("data"))
|
||||
require.NoError(t, err)
|
||||
|
||||
cache.nowFn = func() time.Time { return fixed.Add(3 * time.Second) }
|
||||
entries, err := cache.List(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 0)
|
||||
}
|
||||
|
||||
func TestHubCacheEvictInvalidSlug(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
err = cache.Evict(context.Background(), "../bad")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHubCacheListContextCanceled(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err = cache.List(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
527
backend/internal/crowdsec/hub_sync.go
Normal file
527
backend/internal/crowdsec/hub_sync.go
Normal file
@@ -0,0 +1,527 @@
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/logger"
|
||||
)
|
||||
|
||||
// CommandExecutor defines the minimal command execution interface we need for cscli calls.
|
||||
type CommandExecutor interface {
|
||||
Execute(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultHubIndexPath = "/api/index.json"
|
||||
defaultHubArchivePath = "/%s.tgz"
|
||||
defaultHubPreviewPath = "/%s.yaml"
|
||||
maxArchiveSize = int64(25 * 1024 * 1024) // 25MiB safety cap
|
||||
)
|
||||
|
||||
// HubIndexEntry represents a single hub catalog entry.
|
||||
type HubIndexEntry struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
Version string `json:"version"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Etag string `json:"etag"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
PreviewURL string `json:"preview_url"`
|
||||
}
|
||||
|
||||
// HubIndex is a small wrapper for hub listing payloads.
|
||||
type HubIndex struct {
|
||||
Items []HubIndexEntry `json:"items"`
|
||||
}
|
||||
|
||||
// PullResult bundles the pull metadata, preview text, and cache entry.
|
||||
type PullResult struct {
|
||||
Meta CachedPreset
|
||||
Preview string
|
||||
}
|
||||
|
||||
// ApplyResult captures the outcome of an apply attempt.
|
||||
type ApplyResult struct {
|
||||
Status string `json:"status"`
|
||||
BackupPath string `json:"backup_path"`
|
||||
ReloadHint bool `json:"reload_hint"`
|
||||
UsedCSCLI bool `json:"used_cscli"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
ErrorMessage string `json:"error,omitempty"`
|
||||
AppliedPreset string `json:"slug"`
|
||||
}
|
||||
|
||||
// HubService coordinates hub pulls, caching, and apply operations.
|
||||
type HubService struct {
|
||||
Exec CommandExecutor
|
||||
Cache *HubCache
|
||||
DataDir string
|
||||
HTTPClient *http.Client
|
||||
HubBaseURL string
|
||||
PullTimeout time.Duration
|
||||
ApplyTimeout time.Duration
|
||||
}
|
||||
|
||||
// NewHubService constructs a HubService with sane defaults.
|
||||
func NewHubService(exec CommandExecutor, cache *HubCache, dataDir string) *HubService {
|
||||
return &HubService{
|
||||
Exec: exec,
|
||||
Cache: cache,
|
||||
DataDir: dataDir,
|
||||
HTTPClient: &http.Client{Timeout: 10 * time.Second},
|
||||
HubBaseURL: "https://hub.crowdsec.net",
|
||||
PullTimeout: 10 * time.Second,
|
||||
ApplyTimeout: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
// FetchIndex downloads the hub index. If the hub is unreachable, returns ErrCacheMiss.
|
||||
func (s *HubService) FetchIndex(ctx context.Context) (HubIndex, error) {
|
||||
if s.Exec != nil {
|
||||
if idx, err := s.fetchIndexCSCLI(ctx); err == nil {
|
||||
return idx, nil
|
||||
} else {
|
||||
logger.Log().WithError(err).Debug("cscli hub index failed, falling back to direct hub fetch")
|
||||
}
|
||||
}
|
||||
return s.fetchIndexHTTP(ctx)
|
||||
}
|
||||
|
||||
func (s *HubService) fetchIndexCSCLI(ctx context.Context) (HubIndex, error) {
|
||||
if s.Exec == nil {
|
||||
return HubIndex{}, fmt.Errorf("executor missing")
|
||||
}
|
||||
cmdCtx, cancel := context.WithTimeout(ctx, s.PullTimeout)
|
||||
defer cancel()
|
||||
|
||||
output, err := s.Exec.Execute(cmdCtx, "cscli", "hub", "list", "-o", "json")
|
||||
if err != nil {
|
||||
return HubIndex{}, err
|
||||
}
|
||||
return parseCSCLIIndex(output)
|
||||
}
|
||||
|
||||
func parseCSCLIIndex(raw []byte) (HubIndex, error) {
|
||||
bucket := map[string][]map[string]any{}
|
||||
if err := json.Unmarshal(raw, &bucket); err != nil {
|
||||
return HubIndex{}, fmt.Errorf("parse cscli index: %w", err)
|
||||
}
|
||||
items := make([]HubIndexEntry, 0)
|
||||
for section, list := range bucket {
|
||||
for _, obj := range list {
|
||||
name := sanitizeSlug(asString(obj["name"]))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
entry := HubIndexEntry{
|
||||
Name: name,
|
||||
Title: firstNonEmpty(asString(obj["title"]), name),
|
||||
Version: asString(obj["version"]),
|
||||
Type: firstNonEmpty(asString(obj["type"]), section),
|
||||
Description: asString(obj["description"]),
|
||||
Etag: firstNonEmpty(asString(obj["etag"]), asString(obj["hash"])),
|
||||
DownloadURL: asString(obj["download_url"]),
|
||||
PreviewURL: asString(obj["preview_url"]),
|
||||
}
|
||||
if entry.Title == "" {
|
||||
entry.Title = entry.Name
|
||||
}
|
||||
if entry.Description == "" {
|
||||
entry.Description = entry.Title
|
||||
}
|
||||
items = append(items, entry)
|
||||
}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return HubIndex{}, fmt.Errorf("empty cscli index")
|
||||
}
|
||||
return HubIndex{Items: items}, nil
|
||||
}
|
||||
|
||||
func asString(v any) string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case fmt.Stringer:
|
||||
return val.String()
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *HubService) fetchIndexHTTP(ctx context.Context) (HubIndex, error) {
|
||||
if s.HTTPClient == nil {
|
||||
return HubIndex{}, fmt.Errorf("http client missing")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.HubBaseURL+defaultHubIndexPath, nil)
|
||||
if err != nil {
|
||||
return HubIndex{}, err
|
||||
}
|
||||
resp, err := s.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return HubIndex{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return HubIndex{}, fmt.Errorf("hub index status %d", resp.StatusCode)
|
||||
}
|
||||
var idx HubIndex
|
||||
if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil {
|
||||
return HubIndex{}, fmt.Errorf("decode hub index: %w", err)
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// Pull downloads a preset bundle, validates it, and stores it in cache.
|
||||
func (s *HubService) Pull(ctx context.Context, slug string) (PullResult, error) {
|
||||
if s.Cache == nil {
|
||||
return PullResult{}, fmt.Errorf("cache unavailable")
|
||||
}
|
||||
cleanSlug := sanitizeSlug(slug)
|
||||
if cleanSlug == "" {
|
||||
return PullResult{}, fmt.Errorf("invalid slug")
|
||||
}
|
||||
|
||||
// Attempt to load non-expired cache first.
|
||||
cached, err := s.Cache.Load(ctx, cleanSlug)
|
||||
if err == nil {
|
||||
preview, loadErr := os.ReadFile(cached.PreviewPath)
|
||||
if loadErr == nil {
|
||||
return PullResult{Meta: cached, Preview: string(preview)}, nil
|
||||
}
|
||||
} else if errors.Is(err, ErrCacheExpired) {
|
||||
_ = s.Cache.Evict(ctx, cleanSlug)
|
||||
}
|
||||
|
||||
// Refresh index and download bundle
|
||||
pullCtx, cancel := context.WithTimeout(ctx, s.PullTimeout)
|
||||
defer cancel()
|
||||
|
||||
idx, err := s.FetchIndex(pullCtx)
|
||||
if err != nil {
|
||||
return PullResult{}, err
|
||||
}
|
||||
|
||||
entry, ok := findIndexEntry(idx, cleanSlug)
|
||||
if !ok {
|
||||
return PullResult{}, fmt.Errorf("preset not found in hub")
|
||||
}
|
||||
|
||||
archiveURL := entry.DownloadURL
|
||||
if archiveURL == "" {
|
||||
archiveURL = fmt.Sprintf(s.HubBaseURL+defaultHubArchivePath, cleanSlug)
|
||||
}
|
||||
previewURL := entry.PreviewURL
|
||||
if previewURL == "" {
|
||||
previewURL = fmt.Sprintf(s.HubBaseURL+defaultHubPreviewPath, cleanSlug)
|
||||
}
|
||||
|
||||
archiveBytes, err := s.fetchWithLimit(pullCtx, archiveURL)
|
||||
if err != nil {
|
||||
return PullResult{}, fmt.Errorf("download archive: %w", err)
|
||||
}
|
||||
|
||||
previewText, err := s.fetchPreview(pullCtx, previewURL)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).WithField("slug", cleanSlug).Warn("failed to download preview, falling back to archive inspection")
|
||||
previewText = s.peekFirstYAML(archiveBytes)
|
||||
}
|
||||
|
||||
cachedMeta, err := s.Cache.Store(pullCtx, cleanSlug, entry.Etag, "hub", previewText, archiveBytes)
|
||||
if err != nil {
|
||||
return PullResult{}, err
|
||||
}
|
||||
|
||||
return PullResult{Meta: cachedMeta, Preview: previewText}, nil
|
||||
}
|
||||
|
||||
// Apply installs the preset, preferring cscli when available. Falls back to manual extraction.
|
||||
func (s *HubService) Apply(ctx context.Context, slug string) (ApplyResult, error) {
|
||||
cleanSlug := sanitizeSlug(slug)
|
||||
if cleanSlug == "" {
|
||||
return ApplyResult{}, fmt.Errorf("invalid slug")
|
||||
}
|
||||
|
||||
backupPath := filepath.Clean(s.DataDir) + ".backup." + time.Now().Format("20060102-150405")
|
||||
result := ApplyResult{BackupPath: backupPath, AppliedPreset: cleanSlug, Status: "failed"}
|
||||
if err := s.backupExisting(backupPath); err != nil {
|
||||
return result, fmt.Errorf("backup: %w", err)
|
||||
}
|
||||
|
||||
applyCtx, cancel := context.WithTimeout(ctx, s.ApplyTimeout)
|
||||
defer cancel()
|
||||
if meta, err := s.loadCacheMeta(applyCtx, cleanSlug); err == nil {
|
||||
result.CacheKey = meta.CacheKey
|
||||
}
|
||||
|
||||
// Try cscli first
|
||||
if s.hasCSCLI(applyCtx) {
|
||||
if err := s.runCSCLI(applyCtx, cleanSlug); err == nil {
|
||||
result.Status = "applied"
|
||||
result.ReloadHint = true
|
||||
result.UsedCSCLI = true
|
||||
return result, nil
|
||||
} else {
|
||||
logger.Log().WithError(err).WithField("slug", cleanSlug).Warn("cscli install failed; attempting cache fallback")
|
||||
}
|
||||
}
|
||||
|
||||
meta, err := s.loadCacheMeta(applyCtx, cleanSlug)
|
||||
if err != nil {
|
||||
_ = s.rollback(backupPath)
|
||||
return result, fmt.Errorf("load cache: %w", err)
|
||||
}
|
||||
result.CacheKey = meta.CacheKey
|
||||
archive, err := os.ReadFile(meta.ArchivePath)
|
||||
if err != nil {
|
||||
_ = s.rollback(backupPath)
|
||||
return result, fmt.Errorf("read archive: %w", err)
|
||||
}
|
||||
if err := s.extractTarGz(applyCtx, archive, s.DataDir); err != nil {
|
||||
_ = s.rollback(backupPath)
|
||||
return result, fmt.Errorf("extract: %w", err)
|
||||
}
|
||||
|
||||
result.Status = "applied"
|
||||
result.ReloadHint = true
|
||||
result.UsedCSCLI = false
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *HubService) findPreviewFile(data []byte) string {
|
||||
buf := bytes.NewReader(data)
|
||||
gr, err := gzip.NewReader(buf)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer gr.Close()
|
||||
tr := tar.NewReader(gr)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if hdr.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(hdr.Name)
|
||||
if strings.HasSuffix(name, ".yaml") || strings.HasSuffix(name, ".yml") {
|
||||
limited := io.LimitReader(tr, 2048)
|
||||
content, err := io.ReadAll(limited)
|
||||
if err == nil {
|
||||
return string(content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HubService) fetchPreview(ctx context.Context, url string) (string, error) {
|
||||
if url == "" {
|
||||
return "", fmt.Errorf("preview url missing")
|
||||
}
|
||||
data, err := s.fetchWithLimit(ctx, url)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (s *HubService) fetchWithLimit(ctx context.Context, url string) ([]byte, error) {
|
||||
if s.HTTPClient == nil {
|
||||
return nil, fmt.Errorf("http client missing")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := s.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("http %d", resp.StatusCode)
|
||||
}
|
||||
lr := io.LimitReader(resp.Body, maxArchiveSize+1024)
|
||||
data, err := io.ReadAll(lr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxArchiveSize {
|
||||
return nil, fmt.Errorf("payload too large")
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *HubService) loadCacheMeta(ctx context.Context, slug string) (CachedPreset, error) {
|
||||
if s.Cache == nil {
|
||||
return CachedPreset{}, fmt.Errorf("cache unavailable for manual apply")
|
||||
}
|
||||
meta, err := s.Cache.Load(ctx, slug)
|
||||
if err != nil {
|
||||
return CachedPreset{}, err
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
func findIndexEntry(idx HubIndex, slug string) (HubIndexEntry, bool) {
|
||||
for _, i := range idx.Items {
|
||||
if i.Name == slug || i.Title == slug {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return HubIndexEntry{}, false
|
||||
}
|
||||
|
||||
func (s *HubService) hasCSCLI(ctx context.Context) bool {
|
||||
if s.Exec == nil {
|
||||
return false
|
||||
}
|
||||
_, err := s.Exec.Execute(ctx, "cscli", "version")
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).Debug("cscli not available")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *HubService) runCSCLI(ctx context.Context, slug string) error {
|
||||
if s.Exec == nil {
|
||||
return fmt.Errorf("executor missing")
|
||||
}
|
||||
safeSlug := cleanShellArg(slug)
|
||||
if safeSlug == "" {
|
||||
return fmt.Errorf("invalid slug")
|
||||
}
|
||||
if _, err := s.Exec.Execute(ctx, "cscli", "hub", "update"); err != nil {
|
||||
logger.Log().WithError(err).Warn("cscli hub update failed")
|
||||
}
|
||||
_, err := s.Exec.Execute(ctx, "cscli", "hub", "install", safeSlug)
|
||||
return err
|
||||
}
|
||||
|
||||
func cleanShellArg(val string) string {
|
||||
return sanitizeSlug(val)
|
||||
}
|
||||
|
||||
func (s *HubService) backupExisting(backupPath string) error {
|
||||
if _, err := os.Stat(s.DataDir); errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err := os.Rename(s.DataDir, backupPath); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HubService) rollback(backupPath string) error {
|
||||
_ = os.RemoveAll(s.DataDir)
|
||||
if backupPath == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := os.Stat(backupPath); err == nil {
|
||||
return os.Rename(backupPath, s.DataDir)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractTarGz validates and extracts archive into targetDir.
|
||||
func (s *HubService) extractTarGz(ctx context.Context, archive []byte, targetDir string) error {
|
||||
if err := os.RemoveAll(targetDir); err != nil {
|
||||
return fmt.Errorf("clean target: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir target: %w", err)
|
||||
}
|
||||
|
||||
buf := bytes.NewReader(archive)
|
||||
gr, err := gzip.NewReader(buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gunzip: %w", err)
|
||||
}
|
||||
defer gr.Close()
|
||||
|
||||
tr := tar.NewReader(gr)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read tar: %w", err)
|
||||
}
|
||||
if hdr.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||
return fmt.Errorf("symlinks not allowed in archive")
|
||||
}
|
||||
if hdr.FileInfo().Mode()&os.ModeType != 0 && !hdr.FileInfo().Mode().IsRegular() && !hdr.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
cleanName := filepath.Clean(hdr.Name)
|
||||
if strings.HasPrefix(cleanName, "..") || strings.Contains(cleanName, ".."+string(os.PathSeparator)) || filepath.IsAbs(cleanName) {
|
||||
return fmt.Errorf("unsafe path %s", hdr.Name)
|
||||
}
|
||||
destPath := filepath.Join(targetDir, cleanName)
|
||||
if !strings.HasPrefix(destPath, filepath.Clean(targetDir)) {
|
||||
return fmt.Errorf("path escapes target: %s", hdr.Name)
|
||||
}
|
||||
|
||||
if hdr.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(destPath, hdr.FileInfo().Mode()); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", destPath, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir parent: %w", err)
|
||||
}
|
||||
f, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, hdr.FileInfo().Mode())
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", destPath, err)
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil {
|
||||
_ = f.Close()
|
||||
return fmt.Errorf("write %s: %w", destPath, err)
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return fmt.Errorf("close %s: %w", destPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// peekFirstYAML attempts to extract the first YAML snippet for preview purposes.
|
||||
func (s *HubService) peekFirstYAML(archive []byte) string {
|
||||
if preview := s.findPreviewFile(archive); preview != "" {
|
||||
return preview
|
||||
}
|
||||
return ""
|
||||
}
|
||||
407
backend/internal/crowdsec/hub_sync_test.go
Normal file
407
backend/internal/crowdsec/hub_sync_test.go
Normal file
@@ -0,0 +1,407 @@
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type roundTripperFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
type recordingExec struct {
|
||||
outputs map[string][]byte
|
||||
errors map[string]error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func (r *recordingExec) Execute(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
cmd := name + " " + strings.Join(args, " ")
|
||||
r.calls = append(r.calls, cmd)
|
||||
if err, ok := r.errors[cmd]; ok {
|
||||
return nil, err
|
||||
}
|
||||
if out, ok := r.outputs[cmd]; ok {
|
||||
return out, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected command: %s", cmd)
|
||||
}
|
||||
|
||||
func newResponse(status int, body string) *http.Response {
|
||||
return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
|
||||
}
|
||||
|
||||
func makeTarGz(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
gw := gzip.NewWriter(buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
for name, content := range files {
|
||||
hdr := &tar.Header{Name: name, Mode: 0o644, Size: int64(len(content))}
|
||||
require.NoError(t, tw.WriteHeader(hdr))
|
||||
_, err := tw.Write([]byte(content))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
require.NoError(t, tw.Close())
|
||||
require.NoError(t, gw.Close())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestFetchIndexPrefersCSCLI(t *testing.T) {
|
||||
exec := &recordingExec{outputs: map[string][]byte{"cscli hub list -o json": []byte(`{"collections":[{"name":"crowdsecurity/test","description":"desc","version":"1.0"}]}`)}}
|
||||
svc := NewHubService(exec, nil, t.TempDir())
|
||||
svc.HTTPClient = nil
|
||||
|
||||
idx, err := svc.FetchIndex(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, idx.Items, 1)
|
||||
require.Equal(t, "crowdsecurity/test", idx.Items[0].Name)
|
||||
require.Contains(t, exec.calls, "cscli hub list -o json")
|
||||
}
|
||||
|
||||
func TestFetchIndexFallbackHTTP(t *testing.T) {
|
||||
exec := &recordingExec{errors: map[string]error{"cscli hub list -o json": fmt.Errorf("boom")}}
|
||||
cacheDir := t.TempDir()
|
||||
svc := NewHubService(exec, nil, cacheDir)
|
||||
svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path == defaultHubIndexPath {
|
||||
return newResponse(http.StatusOK, `{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","type":"collection"}]}`), nil
|
||||
}
|
||||
return newResponse(http.StatusNotFound, ""), nil
|
||||
})}
|
||||
|
||||
idx, err := svc.FetchIndex(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.Len(t, idx.Items, 1)
|
||||
require.Equal(t, "crowdsecurity/demo", idx.Items[0].Name)
|
||||
}
|
||||
|
||||
func TestPullCachesPreview(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
dataDir := filepath.Join(t.TempDir(), "crowdsec")
|
||||
cache, err := NewHubCache(cacheDir, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
archiveBytes := makeTarGz(t, map[string]string{"config.yaml": "value: 1"})
|
||||
|
||||
svc := NewHubService(nil, cache, dataDir)
|
||||
svc.HubBaseURL = "http://example.com"
|
||||
svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.String() {
|
||||
case "http://example.com" + defaultHubIndexPath:
|
||||
return newResponse(http.StatusOK, `{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","type":"collection","etag":"etag1","download_url":"http://example.com/demo.tgz","preview_url":"http://example.com/demo.yaml"}]}`), nil
|
||||
case "http://example.com/demo.yaml":
|
||||
return newResponse(http.StatusOK, "preview-body"), nil
|
||||
case "http://example.com/demo.tgz":
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archiveBytes)), Header: make(http.Header)}, nil
|
||||
default:
|
||||
return newResponse(http.StatusNotFound, ""), nil
|
||||
}
|
||||
})}
|
||||
|
||||
res, err := svc.Pull(context.Background(), "crowdsecurity/demo")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "preview-body", res.Preview)
|
||||
require.NotEmpty(t, res.Meta.CacheKey)
|
||||
require.FileExists(t, res.Meta.ArchivePath)
|
||||
}
|
||||
|
||||
func TestApplyUsesCacheWhenCSCLIFails(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
dataDir := filepath.Join(t.TempDir(), "data")
|
||||
|
||||
archive := makeTarGz(t, map[string]string{"a/b.yaml": "ok: 1"})
|
||||
_, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", archive)
|
||||
require.NoError(t, err)
|
||||
|
||||
exec := &recordingExec{outputs: map[string][]byte{"cscli version": []byte("v"), "cscli hub update": []byte("ok")}, errors: map[string]error{"cscli hub install crowdsecurity/demo": fmt.Errorf("install failed")}}
|
||||
svc := NewHubService(exec, cache, dataDir)
|
||||
|
||||
res, err := svc.Apply(context.Background(), "crowdsecurity/demo")
|
||||
require.NoError(t, err)
|
||||
require.False(t, res.UsedCSCLI)
|
||||
require.Equal(t, "applied", res.Status)
|
||||
require.FileExists(t, filepath.Join(dataDir, "a", "b.yaml"))
|
||||
}
|
||||
|
||||
func TestApplyRollsBackOnBadArchive(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
baseDir := filepath.Join(t.TempDir(), "data")
|
||||
require.NoError(t, os.MkdirAll(baseDir, 0o755))
|
||||
keep := filepath.Join(baseDir, "keep.txt")
|
||||
require.NoError(t, os.WriteFile(keep, []byte("before"), 0o644))
|
||||
|
||||
badArchive := makeTarGz(t, map[string]string{"../evil.txt": "boom"})
|
||||
_, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", badArchive)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewHubService(nil, cache, baseDir)
|
||||
_, err = svc.Apply(context.Background(), "crowdsecurity/demo")
|
||||
require.Error(t, err)
|
||||
|
||||
content, readErr := os.ReadFile(keep)
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, "before", string(content))
|
||||
}
|
||||
|
||||
func TestApplyUsesCacheWhenCscliMissing(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
dataDir := filepath.Join(t.TempDir(), "data")
|
||||
|
||||
archive := makeTarGz(t, map[string]string{"config.yml": "hello: world"})
|
||||
_, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", archive)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewHubService(nil, cache, dataDir)
|
||||
res, err := svc.Apply(context.Background(), "crowdsecurity/demo")
|
||||
require.NoError(t, err)
|
||||
require.False(t, res.UsedCSCLI)
|
||||
require.FileExists(t, filepath.Join(dataDir, "config.yml"))
|
||||
}
|
||||
|
||||
func TestPullReturnsCachedPreviewWithoutNetwork(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
archive := makeTarGz(t, map[string]string{"demo.yaml": "x: 1"})
|
||||
_, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "cached-preview", archive)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := NewHubService(nil, cache, t.TempDir())
|
||||
svc.HTTPClient = nil
|
||||
|
||||
res, err := svc.Pull(context.Background(), "crowdsecurity/demo")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "cached-preview", res.Preview)
|
||||
}
|
||||
|
||||
func TestPullEvictsExpiredCacheAndRefreshes(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
fixed := time.Now().Add(-2 * time.Second)
|
||||
cache.nowFn = func() time.Time { return fixed }
|
||||
archive := makeTarGz(t, map[string]string{"a.yaml": "v: 1"})
|
||||
initial, err := cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "old", archive)
|
||||
require.NoError(t, err)
|
||||
|
||||
cache.nowFn = func() time.Time { return fixed.Add(3 * time.Second) }
|
||||
svc := NewHubService(nil, cache, t.TempDir())
|
||||
svc.HubBaseURL = "http://example.com"
|
||||
svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
switch req.URL.String() {
|
||||
case "http://example.com" + defaultHubIndexPath:
|
||||
return newResponse(http.StatusOK, `{"items":[{"name":"crowdsecurity/demo","title":"Demo","description":"desc","type":"collection","etag":"etag2","download_url":"http://example.com/demo.tgz","preview_url":"http://example.com/demo.yaml"}]}`), nil
|
||||
case "http://example.com/demo.yaml":
|
||||
return newResponse(http.StatusOK, "fresh-preview"), nil
|
||||
case "http://example.com/demo.tgz":
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil
|
||||
default:
|
||||
return newResponse(http.StatusNotFound, ""), nil
|
||||
}
|
||||
})}
|
||||
|
||||
res, err := svc.Pull(context.Background(), "crowdsecurity/demo")
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, initial.CacheKey, res.Meta.CacheKey)
|
||||
require.Equal(t, "fresh-preview", res.Preview)
|
||||
}
|
||||
|
||||
func TestPullFallsBackToArchivePreview(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
archive := makeTarGz(t, map[string]string{"scenarios/demo.yaml": "title: demo"})
|
||||
|
||||
svc := NewHubService(nil, cache, t.TempDir())
|
||||
svc.HubBaseURL = "http://example.com"
|
||||
svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.String() == "http://example.com"+defaultHubIndexPath {
|
||||
return newResponse(http.StatusOK, `{"items":[{"name":"crowdsecurity/demo","title":"Demo","etag":"etag1","download_url":"http://example.com/demo.tgz","preview_url":"http://example.com/demo.yaml"}]}`), nil
|
||||
}
|
||||
if req.URL.String() == "http://example.com/demo.yaml" {
|
||||
return newResponse(http.StatusInternalServerError, ""), nil
|
||||
}
|
||||
if req.URL.String() == "http://example.com/demo.tgz" {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(archive)), Header: make(http.Header)}, nil
|
||||
}
|
||||
return newResponse(http.StatusNotFound, ""), nil
|
||||
})}
|
||||
|
||||
res, err := svc.Pull(context.Background(), "crowdsecurity/demo")
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, res.Preview, "title: demo")
|
||||
}
|
||||
|
||||
func TestFetchWithLimitRejectsLargePayload(t *testing.T) {
|
||||
svc := NewHubService(nil, nil, t.TempDir())
|
||||
big := bytes.Repeat([]byte("a"), int(maxArchiveSize+10))
|
||||
svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(big)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
|
||||
_, err := svc.fetchWithLimit(context.Background(), "http://example.com/large.tgz")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "payload too large")
|
||||
}
|
||||
|
||||
func makeSymlinkTar(t *testing.T, linkName string) []byte {
|
||||
t.Helper()
|
||||
buf := &bytes.Buffer{}
|
||||
gw := gzip.NewWriter(buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
hdr := &tar.Header{Name: linkName, Mode: 0o777, Typeflag: tar.TypeSymlink, Linkname: "target"}
|
||||
require.NoError(t, tw.WriteHeader(hdr))
|
||||
require.NoError(t, tw.Close())
|
||||
require.NoError(t, gw.Close())
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestExtractTarGzRejectsSymlink(t *testing.T) {
|
||||
svc := NewHubService(nil, nil, t.TempDir())
|
||||
archive := makeSymlinkTar(t, "bad.symlink")
|
||||
|
||||
err := svc.extractTarGz(context.Background(), archive, filepath.Join(t.TempDir(), "data"))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "symlinks not allowed")
|
||||
}
|
||||
|
||||
func TestExtractTarGzRejectsAbsolutePath(t *testing.T) {
|
||||
svc := NewHubService(nil, nil, t.TempDir())
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
gw := gzip.NewWriter(buf)
|
||||
tw := tar.NewWriter(gw)
|
||||
hdr := &tar.Header{Name: "/etc/passwd", Mode: 0o644, Size: int64(len("x"))}
|
||||
require.NoError(t, tw.WriteHeader(hdr))
|
||||
_, err := tw.Write([]byte("x"))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, tw.Close())
|
||||
require.NoError(t, gw.Close())
|
||||
|
||||
err = svc.extractTarGz(context.Background(), buf.Bytes(), filepath.Join(t.TempDir(), "data"))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "unsafe path")
|
||||
}
|
||||
|
||||
func TestFetchIndexHTTPError(t *testing.T) {
|
||||
svc := NewHubService(nil, nil, t.TempDir())
|
||||
svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return newResponse(http.StatusServiceUnavailable, ""), nil
|
||||
})}
|
||||
|
||||
_, err := svc.fetchIndexHTTP(context.Background())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPullValidatesSlugAndMissingPreset(t *testing.T) {
|
||||
svc := NewHubService(nil, nil, t.TempDir())
|
||||
|
||||
_, err := svc.Pull(context.Background(), " ")
|
||||
require.Error(t, err)
|
||||
|
||||
cache, cacheErr := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, cacheErr)
|
||||
svc.Cache = cache
|
||||
svc.HubBaseURL = "http://hub.example"
|
||||
svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return newResponse(http.StatusOK, `{"items":[{"name":"crowdsecurity/other","title":"Other","description":"d","type":"collection"}]}`), nil
|
||||
})}
|
||||
|
||||
_, err = svc.Pull(context.Background(), "crowdsecurity/missing")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFetchPreviewRequiresURL(t *testing.T) {
|
||||
svc := NewHubService(nil, nil, t.TempDir())
|
||||
_, err := svc.fetchPreview(context.Background(), "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFetchWithLimitRequiresClient(t *testing.T) {
|
||||
svc := NewHubService(nil, nil, t.TempDir())
|
||||
svc.HTTPClient = nil
|
||||
_, err := svc.fetchWithLimit(context.Background(), "http://example.com/demo.tgz")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestRunCSCLIRejectsUnsafeSlug(t *testing.T) {
|
||||
exec := &recordingExec{}
|
||||
svc := NewHubService(exec, nil, t.TempDir())
|
||||
|
||||
err := svc.runCSCLI(context.Background(), "../bad")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestApplyUsesCSCLISuccess(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
_, err = cache.Store(context.Background(), "crowdsecurity/demo", "etag1", "hub", "preview", makeTarGz(t, map[string]string{"config.yml": "val: 1"}))
|
||||
require.NoError(t, err)
|
||||
|
||||
exec := &recordingExec{outputs: map[string][]byte{
|
||||
"cscli version": []byte("v1"),
|
||||
"cscli hub update": []byte("ok"),
|
||||
"cscli hub install crowdsecurity/demo": []byte("installed"),
|
||||
}}
|
||||
|
||||
svc := NewHubService(exec, cache, t.TempDir())
|
||||
res, applyErr := svc.Apply(context.Background(), "crowdsecurity/demo")
|
||||
require.NoError(t, applyErr)
|
||||
require.True(t, res.UsedCSCLI)
|
||||
require.Equal(t, "applied", res.Status)
|
||||
}
|
||||
|
||||
func TestFetchIndexCSCLIParseError(t *testing.T) {
|
||||
exec := &recordingExec{outputs: map[string][]byte{"cscli hub list -o json": []byte("not-json")}}
|
||||
svc := NewHubService(exec, nil, t.TempDir())
|
||||
svc.HubBaseURL = "http://hub.example"
|
||||
svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return newResponse(http.StatusInternalServerError, ""), nil
|
||||
})}
|
||||
|
||||
_, err := svc.FetchIndex(context.Background())
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestFetchWithLimitStatusError(t *testing.T) {
|
||||
svc := NewHubService(nil, nil, t.TempDir())
|
||||
svc.HubBaseURL = "http://hub.example"
|
||||
svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return newResponse(http.StatusNotFound, ""), nil
|
||||
})}
|
||||
|
||||
_, err := svc.fetchWithLimit(context.Background(), "http://hub.example/demo.tgz")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestApplyRollsBackWhenCacheMissing(t *testing.T) {
|
||||
baseDir := t.TempDir()
|
||||
dataDir := filepath.Join(baseDir, "crowdsec")
|
||||
require.NoError(t, os.MkdirAll(dataDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "keep.txt"), []byte("before"), 0o644))
|
||||
|
||||
svc := NewHubService(nil, nil, dataDir)
|
||||
_, err := svc.Apply(context.Background(), "crowdsecurity/demo")
|
||||
require.Error(t, err)
|
||||
|
||||
content, readErr := os.ReadFile(filepath.Join(dataDir, "keep.txt"))
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, "before", string(content))
|
||||
}
|
||||
55
backend/internal/crowdsec/presets.go
Normal file
55
backend/internal/crowdsec/presets.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package crowdsec
|
||||
|
||||
// Preset represents a curated CrowdSec preset offered by Charon.
|
||||
type Preset struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Source string `json:"source"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
RequiresHub bool `json:"requires_hub"`
|
||||
}
|
||||
|
||||
var curatedPresets = []Preset{
|
||||
{
|
||||
Slug: "honeypot-friendly-defaults",
|
||||
Title: "Honeypot Friendly Defaults",
|
||||
Summary: "Lightweight parser and collection set tuned to reduce noise for tarpits and honeypots.",
|
||||
Source: "charon-curated",
|
||||
Tags: []string{"low-noise", "ssh", "http"},
|
||||
RequiresHub: false,
|
||||
},
|
||||
{
|
||||
Slug: "bot-mitigation-essentials",
|
||||
Title: "Bot Mitigation Essentials",
|
||||
Summary: "Core scenarios for bad bots and credential stuffing with minimal false positives.",
|
||||
Source: "charon-curated",
|
||||
Tags: []string{"bots", "auth", "web"},
|
||||
RequiresHub: false,
|
||||
},
|
||||
{
|
||||
Slug: "geolocation-aware",
|
||||
Title: "Geolocation Aware",
|
||||
Summary: "Adds geo-aware decisions to tighten access by region; best paired with existing ACLs.",
|
||||
Source: "charon-curated",
|
||||
Tags: []string{"geo", "access-control"},
|
||||
RequiresHub: false,
|
||||
},
|
||||
}
|
||||
|
||||
// ListCuratedPresets returns a copy of curated presets to avoid external mutation.
|
||||
func ListCuratedPresets() []Preset {
|
||||
out := make([]Preset, len(curatedPresets))
|
||||
copy(out, curatedPresets)
|
||||
return out
|
||||
}
|
||||
|
||||
// FindPreset returns a preset by slug.
|
||||
func FindPreset(slug string) (Preset, bool) {
|
||||
for _, p := range curatedPresets {
|
||||
if p.Slug == slug {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return Preset{}, false
|
||||
}
|
||||
31
backend/internal/crowdsec/presets_test.go
Normal file
31
backend/internal/crowdsec/presets_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package crowdsec
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestListCuratedPresetsReturnsCopy(t *testing.T) {
|
||||
got := ListCuratedPresets()
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected curated presets, got none")
|
||||
}
|
||||
|
||||
// mutate the copy and ensure originals stay intact on subsequent calls
|
||||
got[0].Title = "mutated"
|
||||
again := ListCuratedPresets()
|
||||
if again[0].Title == "mutated" {
|
||||
t.Fatalf("expected curated presets to be returned as copy, but mutation leaked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPreset(t *testing.T) {
|
||||
preset, ok := FindPreset("honeypot-friendly-defaults")
|
||||
if !ok {
|
||||
t.Fatalf("expected to find curated preset")
|
||||
}
|
||||
if preset.Slug != "honeypot-friendly-defaults" {
|
||||
t.Fatalf("unexpected preset slug %s", preset.Slug)
|
||||
}
|
||||
|
||||
if _, ok := FindPreset("missing"); ok {
|
||||
t.Fatalf("expected missing preset to return ok=false")
|
||||
}
|
||||
}
|
||||
16
backend/internal/models/crowdsec_preset_event.go
Normal file
16
backend/internal/models/crowdsec_preset_event.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// CrowdsecPresetEvent captures audit trail for preset pull/apply events.
|
||||
type CrowdsecPresetEvent struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
BackupPath string `json:"backup_path"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -20,7 +20,7 @@ Charon includes optional features that can be toggled on or off based on your ne
|
||||
|
||||
#### Cerberus Security Suite
|
||||
- **What it is:** Complete security system including CrowdSec integration, country blocking, WAF protection, and access control
|
||||
- **When enabled:** Security menu appears in sidebar, all protection features are active
|
||||
- **When enabled:** Cerberus/Dashboard entries appear in the sidebar, all protection features are active
|
||||
- **When disabled:** Security menu is hidden, all protection stops, but configuration data is preserved
|
||||
- **Default:** Enabled
|
||||
|
||||
@@ -110,11 +110,11 @@ When you disable a feature:
|
||||
|
||||
## \ud83d\udee1\ufe0f Security (Optional)
|
||||
|
||||
Charon includes **Cerberus**, a security system that blocks bad guys. It's off by default—turn it on when you're ready.
|
||||
Charon includes **Cerberus**, a security system that blocks bad guys. It's off by default—turn it on when you're ready. The main page is the **Cerberus Dashboard** (sidebar: Cerberus → Dashboard).
|
||||
|
||||
### Block Bad IPs Automatically
|
||||
|
||||
**What it does:** CrowdSec watches for attackers and blocks them before they can do damage.
|
||||
**What it does:** CrowdSec watches for attackers and blocks them before they can do damage. The overview now has a single Start/Stop toggle—no separate mode selector.
|
||||
|
||||
**Why you care:** Someone tries to guess your password 100 times? Blocked automatically.
|
||||
|
||||
@@ -157,6 +157,12 @@ Charon includes **Cerberus**, a security system that blocks bad guys. It's off b
|
||||
- Does NOT replace regular security updates
|
||||
|
||||
**Learn more:** [OWASP Core Rule Set](https://coreruleset.org/)
|
||||
|
||||
### Configuration Packages
|
||||
|
||||
- **Hub presets:** Pull presets from the CrowdSec Hub over HTTPS, use cache keys/ETags for faster repeat pulls, preview changes, then apply with an automatic backup and reload flag. Requires Cerberus to be enabled with admin scope; `cscli` is preferred for execution.
|
||||
- **Offline/curated:** If the Hub is unreachable or apply is not supported, curated/offline presets remain available.
|
||||
- **Validation:** Slugs are validated before apply. Hub errors surface cleanly (503 uses retry or cached data; 400 for bad slugs; apply failures prompt you to restore from the backup).
|
||||
---
|
||||
|
||||
## \ud83d\udc33 Docker Integration
|
||||
|
||||
@@ -1,95 +1,99 @@
|
||||
# Cerberus Rebrand & CrowdSec UX Simplification Plan
|
||||
# CrowdSec Hub Presets Sync & Apply Plan (feature/beta-release)
|
||||
|
||||
## Intent
|
||||
Rebrand the security surface from “Security” to “Cerberus,” streamline CrowdSec controls, and add export/preset affordances that keep novice users in flow while reducing duplicated toggles.
|
||||
## Current State (what exists today)
|
||||
- Backend: [backend/internal/api/handlers/crowdsec_handler.go](backend/internal/api/handlers/crowdsec_handler.go) exposes `ListPresets` (returns curated list from [backend/internal/crowdsec/presets.go](backend/internal/crowdsec/presets.go)) and a stubbed `PullAndApplyPreset` that only validates slug and returns preview or HTTP 501 when `apply=true`. No real hub sync or apply.
|
||||
- Backend uses `CommandExecutor` for `cscli decisions` only; no hub pull/install logic and no cache/backups beyond file write backups in `WriteFile` and import flow.
|
||||
- Frontend: [frontend/src/pages/CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx) calls `pullAndApplyCrowdsecPreset` then falls back to local `writeCrowdsecFile` apply. Preset catalog merges backend list with [frontend/src/data/crowdsecPresets.ts](frontend/src/data/crowdsecPresets.ts). Errors 501/404 are surfaced as info to keep local apply working. Overview toggle/start/stop already wired to `startCrowdsec`/`stopCrowdsec`.
|
||||
- Docs: [docs/cerberus.md](docs/cerberus.md) still notes CrowdSec integration is a placeholder; no hub sync described.
|
||||
|
||||
## Phase 0 — Recon & Guardrails
|
||||
- Inventory the navigation and overview copy in [frontend/src/components/Layout.tsx](frontend/src/components/Layout.tsx) and [frontend/src/pages/Security.tsx](frontend/src/pages/Security.tsx) to rename labels to “Cerberus” (keep route paths unchanged unless routing requires). Update tests that assert “Security” strings (e.g., [frontend/src/components/__tests__/Layout.test.tsx](frontend/src/components/__tests__/Layout.test.tsx), [frontend/src/pages/__tests__/SystemSettings.test.tsx](frontend/src/pages/__tests__/SystemSettings.test.tsx)).
|
||||
- Map CrowdSec UX touchpoints: overview card actions in [frontend/src/pages/Security.tsx](frontend/src/pages/Security.tsx) (toggle, start/stop, export), detail page flows in [frontend/src/pages/CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx), and import-only view in [frontend/src/pages/ImportCrowdSec.tsx](frontend/src/pages/ImportCrowdSec.tsx). Note supporting hooks/api in [frontend/src/hooks/useSecurity.ts](frontend/src/hooks/useSecurity.ts), [frontend/src/api/security.ts](frontend/src/api/security.ts), and [frontend/src/api/crowdsec.ts](frontend/src/api/crowdsec.ts).
|
||||
- Confirm copy/feature-flag alignment with Cerberus flag (`feature.cerberus.enabled`) and ensure the header banner in [frontend/src/pages/Security.tsx](frontend/src/pages/Security.tsx) narrates the new name.
|
||||
## Goal
|
||||
Implement real CrowdSec Hub preset sync + apply on backend (using cscli or direct hub index) with caching, validation, backups, rollback, and wire the UI to new endpoints so operators can preview/apply hub items with clear status/errors.
|
||||
|
||||
## Phase 1 — Cerberus Naming & Navigation
|
||||
- Sidebar: Rename “Security” group to “Cerberus” and child “Overview” to “Dashboard” in [frontend/src/components/Layout.tsx](frontend/src/components/Layout.tsx); keep emoji or refresh iconography to match three-head guardian motif. Ensure mobile/desktop nav tests cover the renamed labels.
|
||||
- Dashboard page: Retitle h1 and hero banner strings to “Cerberus Dashboard” (e.g., `Cerberus Dashboard`, `Cerberus Disabled`) in [frontend/src/pages/Security.tsx](frontend/src/pages/Security.tsx). Update toast and overlay copy to keep the lore tone (“Three heads turn…” etc.) but mention Cerberus explicitly where it helps recognition.
|
||||
- Docs links: Verify the external link buttons still point to https://wikid82.github.io/charon/security; if a Cerberus-specific page exists, swap URLs accordingly.
|
||||
## Backend Plan (handlers, helpers, storage)
|
||||
1) Route adjustments (gin group under `/admin/crowdsec` in [backend/internal/api/handlers/crowdsec_handler.go](backend/internal/api/handlers/crowdsec_handler.go)):
|
||||
- Replace stub endpoint with `POST /admin/crowdsec/presets/pull` → fetch hub item and cache; returns metadata + preview + cache key/etag.
|
||||
- Add `POST /admin/crowdsec/presets/apply` → apply previously pulled item by cache key/slug; performs backup + cscli install + optional restart.
|
||||
- Keep `GET /admin/crowdsec/presets` but include hub/etag info and whether cached locally.
|
||||
- Optional: `GET /admin/crowdsec/presets/cache/:slug` → raw preview/download for UI.
|
||||
2) Hub sync helper (new [backend/internal/crowdsec/hub_sync.go](backend/internal/crowdsec/hub_sync.go)):
|
||||
- Provide `type HubClient interface { FetchIndex(ctx) (HubIndex, error); FetchPreset(ctx, slug) (PresetBundle, error) }` with real impl using either:
|
||||
a) `cscli hub list -o json` and `cscli hub update` + `cscli hub install <item>` (preferred if cscli present), or
|
||||
b) direct fetch of https://hub.crowdsec.net/ or GitHub raw `.index.json` + tarball download.
|
||||
- Validate downloads: size limits, tarball path traversal guard, checksum/etag compare, basic YAML validation.
|
||||
3) Caching (new [backend/internal/crowdsec/hub_cache.go](backend/internal/crowdsec/hub_cache.go)):
|
||||
- Cache pulled bundles under `${DataDir}/hub_cache/<slug>/` with index metadata (etag, fetched_at, source URL) and preview YAML.
|
||||
- Expose `LoadCachedPreset(slug)` and `StorePreset(slug, bundle)`; evict stale on TTL (configurable, default 24h) or when etag changes.
|
||||
4) Apply flow (extend handler):
|
||||
- `Pull`: fetch index, resolve slug, download bundle to cache, return preview + warnings (missing cscli, requires restart, etc.).
|
||||
- `Apply`: before modify, run `backupDir := DataDir + ".backup." + timestamp` (mirror current write/import backups). Then:
|
||||
a) If cscli available: `cscli hub update`, `cscli hub install <slug>` (or collection path), maybe `cscli decisions list` sanity check. Use `CommandExecutor` with context timeout.
|
||||
b) If cscli absent: extract bundle into DataDir with sanitized paths; preserve permissions.
|
||||
c) Write audit record to DB table `crowdsec_preset_events` (new model in [backend/internal/models](backend/internal/models)).
|
||||
- On failure: restore backup (rename back), surface error + backup path.
|
||||
5) Status and restart:
|
||||
- After apply, optionally call `h.Executor.Stop/Start` if running to reload config; or `cscli service reload` when available. Return `reload_performed` flag.
|
||||
6) Validation & security hardening:
|
||||
- Enforce `Cerberus` enablement check (`isCerberusEnabled`) on all new routes.
|
||||
- Path sanitization with `filepath.Clean`, limit tar extraction to DataDir, reject symlinks/abs paths.
|
||||
- Timeouts on all external calls; default 10s pull, 15s apply.
|
||||
- Log with context: slug, etag, source, backup path; redact secrets.
|
||||
7) Migration of curated list:
|
||||
- Keep curated presets in [backend/internal/crowdsec/presets.go](backend/internal/crowdsec/presets.go) but add `Source: "hub"` for hub-backed items and include `RequiresHub` true when not bundled.
|
||||
- `ListPresets` should merge curated + live hub index when available, mark availability per slug (cached, remote-only, local-bundled).
|
||||
|
||||
## Phase 2 — CrowdSec Controls (Overview Card)
|
||||
- Remove redundant start/stop buttons when a master toggle exists. Convert the CrowdSec card action cluster in [frontend/src/pages/Security.tsx](frontend/src/pages/Security.tsx) to a single toggle that: (a) calls `startCrowdsec()` when switching on, (b) calls `stopCrowdsec()` when switching off, (c) reflects `status.crowdsec.enabled` and `statusCrowdsec()` state. Keep Logs/Config/Export buttons.
|
||||
- Eliminate the local “enabled” switch and start/stop duplication so the UI shows one clear state. Disable controls when Cerberus is off.
|
||||
- Keep export action but surface a naming prompt (see Phase 4) so downloads are intentional.
|
||||
## Frontend Plan (API wiring + UX)
|
||||
1) API client updates in [frontend/src/api/presets.ts](frontend/src/api/presets.ts):
|
||||
- Replace `pullAndApplyCrowdsecPreset` with `pullCrowdsecPreset({ slug })` and `applyCrowdsecPreset({ slug, cache_key })`; include response typing for preview/status/errors.
|
||||
- Add `getCrowdsecPresetCache(slug)` if backend exposes cache preview.
|
||||
2) CrowdSec config page [frontend/src/pages/CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx):
|
||||
- Use new mutations: `pull` to show preview + metadata (etag, fetched_at, source); disable local fallback unless backend says `apply_supported=false`.
|
||||
- Show status strip (success/error) and backup path from apply response; surface reload flag and errors inline.
|
||||
- Gate preset actions when Cerberus disabled; show tooltip if hub unreachable.
|
||||
- Keep local backup + manual file apply as last-resort only when backend explicitly returns 501/NotImplemented.
|
||||
3) Overview page [frontend/src/pages/Security.tsx](frontend/src/pages/Security.tsx):
|
||||
- No UI change except error surfacing when start/stop fails due to hub apply requiring reload; show toast from handler message.
|
||||
4) Import page [frontend/src/pages/ImportCrowdSec.tsx](frontend/src/pages/ImportCrowdSec.tsx):
|
||||
- Add note linking to presets apply so users prefer presets over raw package imports.
|
||||
|
||||
## Phase 3 — CrowdSec Config Page Simplification
|
||||
- Remove the “Mode” select block from [frontend/src/pages/CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx); replace with a binary toggle or pill (“Disabled” vs “Local”) bound to `updateSetting('security.crowdsec.mode', ...)` so users don’t see redundant enable/disable alongside the overview toggle.
|
||||
- Drop the “Mode” heading; elevate status microcopy near the toggle (“CrowdSec runs locally; disable to pause decisions”).
|
||||
- Keep ban/unban workflows and file editor intact; ensure decision queries are gated on mode !== disabled after refactor.
|
||||
## Hub Fetch/Validate/Apply Flow (detailed)
|
||||
1) Pull
|
||||
- Handler: `CrowdsecHandler.PullPreset(ctx)` (new) calls `HubClient.FetchPreset` → `HubCache.StorePreset` → returns `{preset, preview_yaml, etag, cache_key, fetched_at}`.
|
||||
- If hub unavailable, return 503 with message; UI shows retry/cached copy option.
|
||||
2) Apply
|
||||
- Handler: `CrowdsecHandler.ApplyPreset(ctx)` loads cache by slug/cache_key → `backupCurrentConfig()` → `InstallPreset()` (cscli or manual) → optional restart → returns `{status:"applied", backup, reloaded:true/false}`.
|
||||
- On error: restore backup, include `{status:"failed", backup, error}`.
|
||||
3) Caching & rollback
|
||||
- Cache directory per slug with checksum file; TTL enforced on pull; apply uses cached bundle unless `force_refetch` flag.
|
||||
- Backups stored with timestamp; keep last N (configurable). Provide restoration note in response for UI.
|
||||
4) Validation
|
||||
- Tarball extraction guard: reject absolute paths, `..`, symlinks; limit total size.
|
||||
- YAML sanity: parse key scenario/collection files to ensure readable; log warning not blocker unless parse fails.
|
||||
- Require explicit `apply=true` separate from pull; no implicit apply on pull.
|
||||
|
||||
## Phase 4 — Import/Export Experience
|
||||
- Rename “Import Configuration” section in [frontend/src/pages/CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx) to “Configuration Packages” with side-by-side Import and Export controls. (Recommended canonical section name: **Configuration Packages**.)
|
||||
- Add export capability on the config page (currently only on overview) using `exportCrowdsecConfig()` with a filename prompt that proposes a default from a lightweight “Planning agent” helper (stub: derive `crowdsec-export-${new Date().toISOString()}.tar.gz`, allow override before download). Reuse the same helper in the overview card export to keep naming consistent.
|
||||
- Update [frontend/src/pages/ImportCrowdSec.tsx](frontend/src/pages/ImportCrowdSec.tsx) to reuse the shared import/export helper UI if feasible, or clearly label it as a tasks-only entry point. Ensure backup creation messaging stays intact.
|
||||
## Security Considerations
|
||||
- Only allow these endpoints when Cerberus enabled and user authenticated to admin scope.
|
||||
- Use `CommandExecutor` to shell out to cscli; restrict PATH and working dir; do not pass user-controlled args without whitelist.
|
||||
- Network egress: if hub URL configurable, validate scheme is https and host is allowlisted (crowdsec official or configured mirror).
|
||||
- Rate limit pull/apply (simple in-memory token bucket) to avoid abuse.
|
||||
- Logging: include slug and etag, omit file contents; redact download URLs if they contain tokens (unlikely).
|
||||
|
||||
## Phase 5 — Presets for CrowdSec
|
||||
- Introduce a presets catalog file (e.g., [frontend/src/data/crowdsecPresets.ts](frontend/src/data/crowdsecPresets.ts)) mirroring the style of [frontend/src/data/securityPresets.ts](frontend/src/data/securityPresets.ts), with curated baseline parsers/collections (e.g., “Honeypot Friendly Defaults”, “Bot Mitigation Essentials”, “Geolocation Aware”).
|
||||
- Surface preset chooser in [frontend/src/pages/CrowdSecConfig.tsx](frontend/src/pages/CrowdSecConfig.tsx) above file editor: selecting a preset should prefill a preview and require explicit “Apply” to write via `writeCrowdsecFile()` (with `createBackup()` first). Add small-print warnings for aggressive presets.
|
||||
- Consider quick chips for “Local only” vs “Community decisions” if backend supports; otherwise, hide or disable with tooltip.
|
||||
## Required Tests
|
||||
- Backend unit/integration:
|
||||
- `backend/internal/api/handlers/crowdsec_handler_test.go`: success and error cases for `PullPreset` (hub reachable/unreachable, invalid slug), `ApplyPreset` (cscli success, cscli missing fallback, apply fails and restores backup), `ListPresets` merging cached hub entries.
|
||||
- `backend/internal/crowdsec/hub_sync_test.go`: parse index JSON, validate tar extraction guards, TTL eviction.
|
||||
- `backend/internal/crowdsec/hub_cache_test.go`: store/load/evict logic and checksum verification.
|
||||
- `backend/internal/api/handlers/crowdsec_exec_test.go`: ensure executor timeouts/commands constructed for cscli hub calls.
|
||||
- Frontend unit/UI:
|
||||
- [frontend/src/pages/__tests__/CrowdSecConfig.test.tsx](frontend/src/pages/__tests__/CrowdSecConfig.test.tsx): pull shows preview, apply success shows backup path/reload flag, hub failure falls back to cached/local message, Cerberus disabled disables actions.
|
||||
- [frontend/src/api/__tests__/presets.test.ts](frontend/src/api/__tests__/presets.test.ts): client hits new endpoints and maps response.
|
||||
- [frontend/src/pages/__tests__/Security.test.tsx](frontend/src/pages/__tests__/Security.test.tsx): start/stop toasts remain correct when apply errors bubble.
|
||||
|
||||
## Research: CrowdSec Hub / Presets (summary & recommended approach)
|
||||
- Official Hub: CrowdSec maintains a canonical Hub repository for parsers, scenarios, collections, and blockers at https://github.com/crowdsecurity/hub and an online Hub UI (https://hub.crowdsec.net/ or https://app.crowdsec.net/hub/). This is the same repo used as `cscli` source-of-truth.
|
||||
- Distribution Methods & UX:
|
||||
- `cscli` CLI: operators typically use `cscli hub pull` to fetch items from the Hub; Charon currently calls `cscli` for decisions/listing in the backend via `CommandExecutor`.
|
||||
- Index / API: the Hub publishes an index (e.g., `.index.json`) and the repo can be parsed to list available presets; the Hub UI uses this index.
|
||||
- Single-file or tarball packages: Hub items are directories that can be packaged and applied to a CrowdSec runtime.
|
||||
## Docs Updates
|
||||
- Update [docs/cerberus.md](docs/cerberus.md) CrowdSec section with new hub preset flow, backup/rollback notes, and requirement for cscli availability when using hub.
|
||||
- Update [docs/features.md](docs/features.md) to list “CrowdSec Hub presets sync/apply (admin)” and mention offline curated fallback.
|
||||
- Add short troubleshooting entry in [docs/troubleshooting/crowdsec.md](docs/troubleshooting/crowdsec.md) (new) for hub unreachable, checksum mismatch, or cscli missing.
|
||||
|
||||
- Integration options for Charon (recommended):
|
||||
1. Curated Presets (default): Ship a small set of curated presets with Charon (frontend `crowdsecPresets.ts`, backend `charon_presets.go`). This is the safest, offline-first, and support-friendly route.
|
||||
2. Live Hub Sync (optional advanced): Provide an admin-only backend endpoint to pull from the official Hub or via `cscli`. Cache and validate fetched presets; admin must explicitly Apply.
|
||||
3. Hybrid: Default to curated shipped presets with opt-in live sync that fetches additional or replacement presets from the Hub.
|
||||
|
||||
- Security & UX: validate remote presets before applying, create backups, and track origin/etag for auditability. If fetching from Hub, prefer a `pull` endpoint that runs in a server-side sanitized environment and returns a preview; require explicit `apply` to change the active config.
|
||||
|
||||
## Implementation Recommendations (High-level file/function list)
|
||||
**Frontend files & functions (UI changes):**
|
||||
- `frontend/src/components/Layout.tsx` — rename nav `Security` to `Cerberus` and `Overview` to `Dashboard`.
|
||||
- `frontend/src/pages/Security.tsx` — change header to `Cerberus Dashboard` and update hero/help copy to reference Cerberus. Update label `Security Suite Disabled` to `Cerberus Disabled` and UI text `Enable Cerberus` where applicable.
|
||||
- `frontend/src/pages/CrowdSecConfig.tsx` — replace the `Mode` select with a binary toggle (or pill) and rename `Import Configuration` to `Configuration Packages` for the import/export section. Add `PresetChooser` component to preview and apply presets.
|
||||
- `frontend/src/pages/ImportCrowdSec.tsx` — update copy to `Configuration Packages` if the import page is still used.
|
||||
- `frontend/src/data/crowdsecPresets.ts` — add curated default presets for Charon.
|
||||
- `frontend/src/hooks/useCrowdsecPresets.ts` — new hook for presets queries/mutations (`useQuery` for `getCrowdsecPresets`, `useMutation` for pull/apply).
|
||||
- `frontend/src/api/presets.ts` — typed API client functions: `getCrowdsecPresets`, `pullCrowdsecPreset`, `applyCrowdsecPreset`.
|
||||
|
||||
**Back-end files & functions (API & runtime behavior):**
|
||||
- `backend/internal/api/handlers/crowdsec_handler.go` — extend or add routes: `GET /admin/crowdsec/presets`, `POST /admin/crowdsec/presets/pull`, `POST /admin/crowdsec/presets/apply`, `POST /admin/crowdsec/presets/import` to mirror existing import flow.
|
||||
- `backend/internal/crowdsec/hub.go` — new backend helper to fetch `index.json` from Hub, download tarball, and validate (with `hublint`/`cshub` if available). Expose a `FetchPreset` function to return a `tar.gz` blob or parsed files for preview.
|
||||
- `backend/internal/crowdsec/presets.go` — curated presets and caching/validation.
|
||||
- `backend/internal/api/handlers/crowdsec_exec.go` — consider exposing a `ExecuteCscli()` helper wrapper for secure `cscli` usage if the backend runs on the same host where `cscli` is installed (current code provides an executor abstraction already; reuse it for hub operations).
|
||||
|
||||
## Unit test updates (explicit suggestions)
|
||||
- `frontend/src/components/__tests__/Layout.test.tsx` — assert `Cerberus` and `Dashboard` display labels and preserve route paths. Add test for collapsed/expanded state showing the new Dashboard name.
|
||||
- `frontend/src/pages/__tests__/Security.test.tsx` — update expectations for `Cerberus Dashboard` and hero copy `Cerberus Disabled` when `feature.cerberus.enabled` is false.
|
||||
- `frontend/src/pages/__tests__/CrowdSecConfig.test.tsx` — remove assertions for `Mode` select (or replace with binary toggle tests) and add tests for `PresetChooser` interactions: preview, pull, and apply. Add tests for `Configuration Packages` header presence.
|
||||
- `frontend/src/pages/__tests__/ImportCrowdSec.test.tsx` — update to assert `Configuration Packages` and to reuse import/export helper test cases.
|
||||
- `backend/internal/api/handlers/crowdsec_handler_coverage_test.go` — add tests for the new `presets` endpoints: `GET /admin/crowdsec/presets` and `POST /admin/crowdsec/presets/pull` with mocked hub fetch and error conditions.
|
||||
|
||||
## Live Hub vs Curated Presets (decision note)
|
||||
- Charon's default should be curated presets shipped with the app for stability and easier support.
|
||||
- Provide optional Live Hub Sync as an opt-in admin feature. Live Hub Sync should:
|
||||
- Fetch a list from the Hub index and display a preview in UI
|
||||
- Validate content and run a `trailing` or `linter` before presenting an operator with a safe apply
|
||||
- Cache fetched presets server-side and only apply them when the admin clicks "Apply" (with automatic pre-apply backup)
|
||||
- Allow rollback via stored backups
|
||||
|
||||
|
||||
## Phase 6 — Testing & Copy Polish
|
||||
- Update unit/UI tests that assert strings or button counts in [frontend/src/components/__tests__/Layout.test.tsx](frontend/src/components/__tests__/Layout.test.tsx), [frontend/src/pages/__tests__/SystemSettings.test.tsx](frontend/src/pages/__tests__/SystemSettings.test.tsx), and any CrowdSec page tests to reflect renamed labels and removed start/stop buttons.
|
||||
- Add targeted tests for the new export naming prompt and the toggle-driven start/stop behavior (mock `startCrowdsec`/`stopCrowdsec` in [frontend/src/api/crowdsec.ts](frontend/src/api/crowdsec.ts)).
|
||||
- Light copy edit to keep “Cerberus” consistent and reassure users when controls are disabled by global flag.
|
||||
|
||||
## Phase 7 — Docs & Housekeeping
|
||||
- Update [docs/features.md](docs/features.md) and [docs/security.md](docs/security.md) with Cerberus naming and simplified CrowdSec flows; include screenshots after UI update.
|
||||
- No `.gitignore`, `.dockerignore`, `.codecov.yml`, or `Dockerfile` changes needed based on current scope (new files stay under tracked `frontend/src/data` and existing build ignores already cover docs/plan artifacts). Re-evaluate if new binary assets or build args appear.
|
||||
|
||||
## Success Criteria
|
||||
- Sidebar, overview headings, and toasts consistently say “Cerberus.”
|
||||
- CrowdSec overview shows one toggle-driven control with non-conflicting actions; config page has no standalone Mode select.
|
||||
- Import/Export flows include a user-visible filename choice; presets available and gated by backup + apply flow.
|
||||
- Tests and docs updated; builds and lint/tests green via existing tasks.
|
||||
## Migration Notes
|
||||
- Existing curated presets remain but are marked as bundled; UI should continue to show them even if hub unreachable.
|
||||
- Stub endpoint `POST /admin/crowdsec/presets/pull/apply` is replaced by separate `pull` and `apply`; frontend must switch to new API paths before backend removal to avoid 404.
|
||||
- Backward compatibility: keep returning 501 from old endpoint until frontend merged; remove once new routes live and tested.
|
||||
|
||||
@@ -1,53 +1,37 @@
|
||||
# QA Report: System Settings & Security (Feature Flags OFF)
|
||||
# QA Report: CrowdSec Hub Preset (feature/beta-release)
|
||||
|
||||
**Date:** December 8, 2025
|
||||
**QA Agent:** QA_Security
|
||||
**Scope:** System Settings features card, Security page (no Cerberus master toggle), and backend/UX behavior when `feature.cerberus.enabled` and `feature.uptime.enabled` are `false`.
|
||||
**Specification:** Feature flag controls and UI expectations per product notes
|
||||
**Scope:** Post-merge QA after CrowdSec hub preset backend/frontend changes on `feature/beta-release`.
|
||||
**Requested Steps:** `pre-commit run --all-files`, `backend: go test ./...`, `frontend: npm run test:ci`.
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Final Verdict:** ✅ PASS WITH WARNINGS
|
||||
**Final Verdict:** ✅ PASS (coverage gate met)
|
||||
|
||||
- Frontend checks: `npm run type-check` and `npm run test:ci` pass; vitest emitted non-blocking act()/query warnings in security suites.
|
||||
- Feature flags verified in DB as `false` for Cerberus and Uptime; backend logs show only proxy/NZBget traffic, no uptime or cerberus activity.
|
||||
- Security page presents per-service toggles only (no global Cerberus switch) and respects disabled state in tests; System Settings Features card remains at top with two-column layout and tooltip text.
|
||||
- `pre-commit run --all-files` passes; coverage hook reports 85.0% vs required 85% (gate met) with hooks including Go vet, version check, frontend type-check, and lint fix.
|
||||
- `go test ./...` (backend) passes via task `Go: Test Backend`.
|
||||
- `npm run test:ci` passes (Vitest, 70 files / 598 tests). React Query undefined-data warnings and jsdom navigation warnings appear but suites stay green.
|
||||
|
||||
## Test Results
|
||||
|
||||
| Area | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| Frontend TypeScript | ✅ PASS | `npm run type-check` via task (Dockerized run) |
|
||||
| Frontend Unit Tests | ✅ PASS* | `npm run test:ci` (584 tests). Warnings: act() needed in Security audit tests; several React Query data undefined warnings in security/crowdsec specs; jsdom navigation not implemented (expected). |
|
||||
| Backend Tests | ⏭️ Not Run | Not requested for this cycle. |
|
||||
| UI Sanity (headless) | ✅ PASS | Layout verified via tests/code: Features card top, 2-col grid, tooltips via `title`, overlay during mutations; Security page shows per-service toggles, banner when Cerberus disabled. |
|
||||
| Backend Sanity | ✅ PASS | DB flags false; no uptime/cerberus activity visible in recent logs; services remain disabled. |
|
||||
| Pre-commit | ✅ PASS | Coverage gate satisfied at 85.0%; all hooks succeeded. |
|
||||
| Backend Unit Tests | ✅ PASS | `cd backend && go test ./...` (task: Go: Test Backend). |
|
||||
| Frontend Unit Tests | ✅ PASS* | `npm run test:ci` (Vitest, 70 files / 598 tests). Warnings: React Query "query data cannot be undefined" for `securityConfig`/`securityRulesets`/`feature-flags`; jsdom "navigation to another Document". |
|
||||
|
||||
## Validation Details
|
||||
## Evidence / Logs
|
||||
|
||||
- Feature flags state: `feature.cerberus.enabled=false`, `feature.uptime.enabled=false` (queried `/app/data/charon.db` inside `charon-debug`).
|
||||
- System Settings Features card: two switches (Cerberus, Uptime), `title` tooltips present, `ConfigReloadOverlay` shows during pending mutations; card positioned at top of page grid.
|
||||
- Security page: no global Cerberus toggle; per-service toggles disabled when Cerberus flag is false; disabled banner shown; overlay used during config mutations.
|
||||
- Backend behavior: recent `charon-debug` logs contain only NZBget/Caddy access entries; no uptime monitor or cerberus job traces while flags are off.
|
||||
|
||||
## Issues / Warnings
|
||||
|
||||
- Vitest warnings (non-failing):
|
||||
- act() wrapping needed in Security audit tests (double-click prevention) and Security loading test.
|
||||
- React Query “query data cannot be undefined” warnings in security and crowdsec specs; jsdom “navigation to another Document” warnings in security/crowdsec specs.
|
||||
- These did not fail the suite but add noise; consider tightening test setup/mocks.
|
||||
- Coverage hook output: `Computed coverage: 85.0% (minimum required 85%)` followed by “Coverage requirement met.”
|
||||
- Backend tests: task output shows `ok github.com/Wikid82/charon/backend/internal/...` with no failures.
|
||||
- Frontend Vitest: full log at [test-results/frontend-test.log](test-results/frontend-test.log) (70 files, 598 tests, warnings noted above).
|
||||
|
||||
## Follow-ups / Recommendations
|
||||
|
||||
1. Clean up Security/CrowdSec tests to wrap pending state updates in `act()` and ensure query mocks return non-undefined defaults to silence warnings.
|
||||
2. If deeper backend verification is needed, run `Go: Test Backend` or integration suite; not run in this cycle.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Frontend tests: `npm run test:ci` (all green, warnings noted above).
|
||||
- Feature flags: queried SQLite in `charon-debug` container showing both flags `false`.
|
||||
- Logs: `docker logs charon-debug --tail` showed only NZBget access traffic, no uptime/cerberus actions.
|
||||
1. Optionally tighten React Query mocks in Security and Layout suites to eliminate "query data cannot be undefined" warnings; consider default fixtures for `securityConfig`, `securityRulesets`, and `feature-flags`.
|
||||
2. Silence jsdom "navigation to another Document" warnings if noise persists (e.g., stub navigation or avoid window.location changes in tests).
|
||||
|
||||
---
|
||||
|
||||
**Status:** ✅ Approved with warnings logged above.
|
||||
**Status:** ✅ QA Passed (coverage gate satisfied).
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
Charon includes **Cerberus**, a security system that protects your websites. It's **enabled by default** so your sites are protected from the start.
|
||||
|
||||
You can disable it in **System Settings → Optional Features** if you don't need it, or configure it using this guide.
|
||||
You can disable it in **System Settings → Optional Features** if you don't need it, or configure it using this guide. The sidebar now shows **Cerberus → Dashboard**; the page header reads **Cerberus Dashboard**.
|
||||
|
||||
Want the quick reference? See https://wikid82.github.io/charon/security.
|
||||
|
||||
---
|
||||
|
||||
@@ -61,7 +63,9 @@ Restart again. Now bad guys actually get blocked.
|
||||
|
||||
### How to Enable It
|
||||
|
||||
**Local Mode** (Runs inside Charon):
|
||||
- **Web UI:** The Cerberus Dashboard shows a single **Start/Stop** toggle. Use it to run or stop CrowdSec; there is no separate mode selector.
|
||||
- **Configuration page:** Uses a simple **Disabled / Local** toggle (no Mode dropdown). Choose Local to run the embedded CrowdSec agent.
|
||||
- **Environment variables (optional):**
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
@@ -70,7 +74,7 @@ environment:
|
||||
|
||||
That's it. CrowdSec starts automatically and begins blocking bad IPs.
|
||||
|
||||
**What you'll see:** The "Security" page shows blocked IPs and why they were blocked.
|
||||
**What you'll see:** The Cerberus pages show blocked IPs and why they were blocked.
|
||||
|
||||
---
|
||||
|
||||
@@ -129,6 +133,14 @@ Now only devices on `192.168.x.x` or `10.x.x.x` can access it. The public intern
|
||||
|
||||
---
|
||||
|
||||
## Configuration Packages
|
||||
|
||||
- **Import/Export:** You can import or export Cerberus configuration packages; exports prompt you to confirm the filename before saving.
|
||||
- **Presets (CrowdSec Hub):** Pull presets from the CrowdSec Hub over HTTPS using cache keys/ETags, prefer `cscli` execution, and require Cerberus to be enabled with an admin-scoped session. Workflow: pull → preview → apply with an automatic backup and reload flag.
|
||||
- **Fallbacks:** If the Hub is unreachable (503 uses retry or cached data), curated/offline presets stay available; invalid slugs return a 400 with validation detail; apply failures remind you to restore from the backup; if apply is not supported (501), stay on curated/offline presets.
|
||||
|
||||
---
|
||||
|
||||
## Certificate Management Security
|
||||
|
||||
**What it protects:** Certificate deletion is a destructive operation that requires proper authorization.
|
||||
|
||||
20
docs/troubleshooting/crowdsec.md
Normal file
20
docs/troubleshooting/crowdsec.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# CrowdSec Troubleshooting
|
||||
|
||||
Keep Cerberus terminology and the Configuration Packages flow in mind while debugging Hub presets.
|
||||
|
||||
## Quick checks
|
||||
- Cerberus is enabled and you are signed in with admin scope.
|
||||
- `cscli` is available (preferred path); HTTPS CrowdSec Hub endpoints only.
|
||||
- Preset workflow: pull from Hub using cache keys/ETags → preview changes → apply with automatic backup and reload flag.
|
||||
- Offline/curated presets remain available at all times.
|
||||
|
||||
## Common issues
|
||||
- Hub unreachable (503): retry once, then Charon falls back to cached Hub data if available; otherwise stay on curated/offline presets until connectivity returns.
|
||||
- Bad preset slug (400): the slug must match Hub naming; correct the slug before retrying.
|
||||
- Apply failed: review the apply response and restore from the backup that was taken automatically, then retry after fixing the underlying issue.
|
||||
- Apply not supported (501): use curated/offline presets; Hub apply will be re-enabled when supported in your environment.
|
||||
|
||||
## Tips
|
||||
- Keep the CrowdSec Hub reachable over HTTPS; HTTP is blocked.
|
||||
- If you switch to offline mode, clear pending Hub pulls before retrying so cache keys/ETags refresh cleanly.
|
||||
- After restoring from a backup, re-run preview before applying again to verify changes.
|
||||
59
frontend/src/api/__tests__/presets.test.ts
Normal file
59
frontend/src/api/__tests__/presets.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as presets from '../presets'
|
||||
import client from '../client'
|
||||
|
||||
vi.mock('../client')
|
||||
|
||||
describe('crowdsec presets API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('lists presets via GET', async () => {
|
||||
const mockData = { presets: [{ slug: 'bot', title: 'Bot', summary: 'desc', source: 'hub', requires_hub: true, available: true, cached: false }] }
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await presets.listCrowdsecPresets()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/presets')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('pulls a preset via POST', async () => {
|
||||
const mockData = { status: 'pulled', slug: 'bot', preview: 'configs: {}', cache_key: 'cache-1' }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await presets.pullCrowdsecPreset('bot')
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/pull', { slug: 'bot' })
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('applies a preset via POST', async () => {
|
||||
const mockData = { status: 'applied', backup: '/tmp/backup', cache_key: 'cache-1' }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const payload = { slug: 'bot', cache_key: 'cache-1' }
|
||||
const result = await presets.applyCrowdsecPreset(payload)
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/apply', payload)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('fetches cached preview by slug', async () => {
|
||||
const mockData = { preview: 'cached', cache_key: 'cache-1', etag: 'etag-1' }
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await presets.getCrowdsecPresetCache('bot/collection')
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/presets/cache/bot%2Fcollection')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('exports default bundle', () => {
|
||||
expect(presets.default).toHaveProperty('listCrowdsecPresets')
|
||||
expect(presets.default).toHaveProperty('pullCrowdsecPreset')
|
||||
expect(presets.default).toHaveProperty('applyCrowdsecPreset')
|
||||
expect(presets.default).toHaveProperty('getCrowdsecPresetCache')
|
||||
})
|
||||
})
|
||||
67
frontend/src/api/presets.ts
Normal file
67
frontend/src/api/presets.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import client from './client'
|
||||
|
||||
export interface CrowdsecPresetSummary {
|
||||
slug: string
|
||||
title: string
|
||||
summary: string
|
||||
source: string
|
||||
tags?: string[]
|
||||
requires_hub: boolean
|
||||
available: boolean
|
||||
cached: boolean
|
||||
cache_key?: string
|
||||
etag?: string
|
||||
retrieved_at?: string
|
||||
}
|
||||
|
||||
export interface PullCrowdsecPresetResponse {
|
||||
status: string
|
||||
slug: string
|
||||
preview: string
|
||||
cache_key: string
|
||||
etag?: string
|
||||
retrieved_at?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface ApplyCrowdsecPresetResponse {
|
||||
status: string
|
||||
backup?: string
|
||||
reload_hint?: string
|
||||
used_cscli?: boolean
|
||||
cache_key?: string
|
||||
slug?: string
|
||||
}
|
||||
|
||||
export interface CachedCrowdsecPresetPreview {
|
||||
preview: string
|
||||
cache_key: string
|
||||
etag?: string
|
||||
}
|
||||
|
||||
export async function listCrowdsecPresets() {
|
||||
const resp = await client.get<{ presets: CrowdsecPresetSummary[] }>('/admin/crowdsec/presets')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function pullCrowdsecPreset(slug: string) {
|
||||
const resp = await client.post<PullCrowdsecPresetResponse>('/admin/crowdsec/presets/pull', { slug })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function applyCrowdsecPreset(payload: { slug: string; cache_key?: string }) {
|
||||
const resp = await client.post<ApplyCrowdsecPresetResponse>('/admin/crowdsec/presets/apply', payload)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function getCrowdsecPresetCache(slug: string) {
|
||||
const resp = await client.get<CachedCrowdsecPresetPreview>(`/admin/crowdsec/presets/cache/${encodeURIComponent(slug)}`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export default {
|
||||
listCrowdsecPresets,
|
||||
pullCrowdsecPreset,
|
||||
applyCrowdsecPreset,
|
||||
getCrowdsecPresetCache,
|
||||
}
|
||||
@@ -62,8 +62,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{ name: 'Domains', path: '/domains', icon: '🌍' },
|
||||
{ name: 'Certificates', path: '/certificates', icon: '🔒' },
|
||||
{ name: 'Uptime', path: '/uptime', icon: '📈' },
|
||||
{ name: 'Security', path: '/security', icon: '🛡️', children: [
|
||||
{ name: 'Overview', path: '/security', icon: '🛡️' },
|
||||
{ name: 'Cerberus', path: '/security', icon: '🛡️', children: [
|
||||
{ name: 'Dashboard', path: '/security', icon: '🛡️' },
|
||||
{ name: 'CrowdSec', path: '/security/crowdsec', icon: '🛡️' },
|
||||
{ name: 'Access Lists', path: '/security/access-lists', icon: '🔒' },
|
||||
{ name: 'Rate Limiting', path: '/security/rate-limiting', icon: '⚡' },
|
||||
@@ -104,7 +104,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
// Optional Features Logic
|
||||
// Default to visible (true) if flags are loading or undefined
|
||||
if (item.name === 'Uptime') return featureFlags?.['feature.uptime.enabled'] !== false
|
||||
if (item.name === 'Security') return featureFlags?.['feature.cerberus.enabled'] !== false
|
||||
if (item.name === 'Cerberus') return featureFlags?.['feature.cerberus.enabled'] !== false
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('Layout', () => {
|
||||
})
|
||||
|
||||
describe('Feature Flags - Conditional Sidebar Items', () => {
|
||||
it('displays Security nav item when Cerberus is enabled', async () => {
|
||||
it('displays Cerberus nav item when Cerberus is enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
@@ -162,11 +162,11 @@ describe('Layout', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cerberus')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides Security nav item when Cerberus is disabled', async () => {
|
||||
it('hides Cerberus nav item when Cerberus is disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': true,
|
||||
@@ -179,7 +179,7 @@ describe('Layout', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Security')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Cerberus')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('Layout', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Security and Uptime when both features are enabled', async () => {
|
||||
it('shows Cerberus and Uptime when both features are enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
@@ -230,12 +230,12 @@ describe('Layout', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cerberus')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides both Security and Uptime when both features are disabled', async () => {
|
||||
it('hides both Cerberus and Uptime when both features are disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': false,
|
||||
@@ -248,12 +248,12 @@ describe('Layout', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Security')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Cerberus')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Uptime')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults to showing Security and Uptime when feature flags are loading', async () => {
|
||||
it('defaults to showing Cerberus and Uptime when feature flags are loading', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue(undefined as any)
|
||||
|
||||
@@ -265,7 +265,7 @@ describe('Layout', () => {
|
||||
|
||||
// When flags are undefined, items should be visible by default (conservative approach)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cerberus')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { getSecurityStatus } from '../api/security'
|
||||
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, CrowdSecDecision } from '../api/crowdsec'
|
||||
import { listCrowdsecPresets, pullCrowdsecPreset, applyCrowdsecPreset, getCrowdsecPresetCache } from '../api/presets'
|
||||
import { createBackup } from '../api/backups'
|
||||
import { updateSetting } from '../api/settings'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../utils/toast'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
import { Shield, ShieldOff, Trash2 } from 'lucide-react'
|
||||
import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport'
|
||||
import { CROWDSEC_PRESETS, CrowdsecPreset } from '../data/crowdsecPresets'
|
||||
|
||||
export default function CrowdSecConfig() {
|
||||
const { data: status, isLoading, error } = 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 [selectedPresetSlug, setSelectedPresetSlug] = useState<string>('')
|
||||
const [showBanModal, setShowBanModal] = useState(false)
|
||||
const [banForm, setBanForm] = useState({ ip: '', duration: '24h', reason: '' })
|
||||
const [confirmUnban, setConfirmUnban] = useState<CrowdSecDecision | null>(null)
|
||||
const [isApplyingPreset, setIsApplyingPreset] = useState(false)
|
||||
const [presetPreview, setPresetPreview] = useState<string>('')
|
||||
const [presetMeta, setPresetMeta] = useState<{ cacheKey?: string; etag?: string; retrievedAt?: string; source?: string } | null>(null)
|
||||
const [presetStatusMessage, setPresetStatusMessage] = useState<string | null>(null)
|
||||
const [hubUnavailable, setHubUnavailable] = useState(false)
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
const [applyInfo, setApplyInfo] = useState<{ status?: string; backup?: string; reloadHint?: string; usedCscli?: boolean; cacheKey?: string } | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const isLocalMode = !!status && status.crowdsec?.mode !== 'disabled'
|
||||
|
||||
const backupMutation = useMutation({ mutationFn: () => createBackup() })
|
||||
const importMutation = useMutation({
|
||||
@@ -38,13 +52,133 @@ export default function CrowdSecConfig() {
|
||||
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 updateModeMutation = useMutation({
|
||||
mutationFn: async (mode: string) => updateSetting('security.crowdsec.mode', mode, 'security', 'string'),
|
||||
onSuccess: (_data, mode) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['security-status'] })
|
||||
toast.success(mode === 'disabled' ? 'CrowdSec disabled' : 'CrowdSec set to Local mode')
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to update mode'
|
||||
toast.error(msg)
|
||||
},
|
||||
})
|
||||
|
||||
const presetsQuery = useQuery({
|
||||
queryKey: ['crowdsec-presets'],
|
||||
queryFn: listCrowdsecPresets,
|
||||
enabled: !!status?.crowdsec,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
type PresetCatalogEntry = CrowdsecPreset & {
|
||||
requiresHub: boolean
|
||||
available?: boolean
|
||||
cached?: boolean
|
||||
cacheKey?: string
|
||||
etag?: string
|
||||
retrievedAt?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
const presetCatalog: PresetCatalogEntry[] = useMemo(() => {
|
||||
const remotePresets = presetsQuery.data?.presets
|
||||
const localMap = new Map(CROWDSEC_PRESETS.map((preset) => [preset.slug, preset]))
|
||||
if (remotePresets?.length) {
|
||||
return remotePresets.map((preset) => {
|
||||
const local = localMap.get(preset.slug)
|
||||
return {
|
||||
slug: preset.slug,
|
||||
title: preset.title || local?.title || preset.slug,
|
||||
description: local?.description || preset.summary,
|
||||
content: local?.content || '',
|
||||
tags: local?.tags || preset.tags,
|
||||
warning: local?.warning,
|
||||
requiresHub: Boolean(preset.requires_hub),
|
||||
available: preset.available,
|
||||
cached: preset.cached,
|
||||
cacheKey: preset.cache_key,
|
||||
etag: preset.etag,
|
||||
retrievedAt: preset.retrieved_at,
|
||||
source: preset.source,
|
||||
}
|
||||
})
|
||||
}
|
||||
return CROWDSEC_PRESETS.map((preset) => ({ ...preset, requiresHub: false, available: true, cached: false, source: 'charon-curated' }))
|
||||
}, [presetsQuery.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!presetCatalog.length) return
|
||||
if (!selectedPresetSlug || !presetCatalog.some((preset) => preset.slug === selectedPresetSlug)) {
|
||||
setSelectedPresetSlug(presetCatalog[0].slug)
|
||||
}
|
||||
}, [presetCatalog, selectedPresetSlug])
|
||||
|
||||
const selectedPreset = presetCatalog.find((preset) => preset.slug === selectedPresetSlug)
|
||||
const selectedPresetRequiresHub = selectedPreset?.requiresHub ?? false
|
||||
|
||||
const pullPresetMutation = useMutation({
|
||||
mutationFn: (slug: string) => pullCrowdsecPreset(slug),
|
||||
onSuccess: (data) => {
|
||||
setPresetPreview(data.preview)
|
||||
setPresetMeta({ cacheKey: data.cache_key, etag: data.etag, retrievedAt: data.retrieved_at, source: data.source })
|
||||
setPresetStatusMessage('Preview fetched from hub')
|
||||
setHubUnavailable(false)
|
||||
setValidationError(null)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
setPresetStatusMessage(null)
|
||||
if (isAxiosError(err)) {
|
||||
if (err.response?.status === 400) {
|
||||
setValidationError(err.response.data?.error || 'Preset slug is invalid')
|
||||
return
|
||||
}
|
||||
if (err.response?.status === 503) {
|
||||
setHubUnavailable(true)
|
||||
setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.')
|
||||
return
|
||||
}
|
||||
setPresetStatusMessage(err.response?.data?.error || 'Failed to pull preset preview')
|
||||
} else {
|
||||
setPresetStatusMessage('Failed to pull preset preview')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPreset) return
|
||||
setValidationError(null)
|
||||
setPresetStatusMessage(null)
|
||||
setApplyInfo(null)
|
||||
setPresetMeta({
|
||||
cacheKey: selectedPreset.cacheKey,
|
||||
etag: selectedPreset.etag,
|
||||
retrievedAt: selectedPreset.retrievedAt,
|
||||
source: selectedPreset.source,
|
||||
})
|
||||
setPresetPreview(selectedPreset.content || '')
|
||||
pullPresetMutation.mutate(selectedPreset.slug)
|
||||
}, [selectedPreset?.slug])
|
||||
|
||||
const loadCachedPreview = async () => {
|
||||
if (!selectedPreset) return
|
||||
try {
|
||||
const cached = await getCrowdsecPresetCache(selectedPreset.slug)
|
||||
setPresetPreview(cached.preview)
|
||||
setPresetMeta({ cacheKey: cached.cache_key, etag: cached.etag, retrievedAt: selectedPreset.retrievedAt, source: selectedPreset.source })
|
||||
setPresetStatusMessage('Loaded cached preview')
|
||||
setHubUnavailable(false)
|
||||
} catch (err) {
|
||||
const msg = isAxiosError(err) ? err.response?.data?.error || err.message : 'Failed to load cached preview'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Banned IPs queries and mutations
|
||||
const decisionsQuery = useQuery({
|
||||
queryKey: ['crowdsec-decisions'],
|
||||
queryFn: listCrowdsecDecisions,
|
||||
enabled: status?.crowdsec?.mode !== 'disabled',
|
||||
enabled: isLocalMode,
|
||||
})
|
||||
|
||||
const banMutation = useMutation({
|
||||
@@ -73,16 +207,13 @@ export default function CrowdSecConfig() {
|
||||
})
|
||||
|
||||
const handleExport = async () => {
|
||||
const defaultName = buildCrowdsecExportFilename()
|
||||
const filename = promptCrowdsecFilename(defaultName)
|
||||
if (!filename) return
|
||||
|
||||
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)
|
||||
downloadCrowdsecExport(blob, filename)
|
||||
toast.success('CrowdSec configuration exported')
|
||||
} catch {
|
||||
toast.error('Failed to export CrowdSec configuration')
|
||||
@@ -115,22 +246,129 @@ export default function CrowdSecConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleModeChange = async (mode: string) => {
|
||||
const handleModeToggle = (nextEnabled: boolean) => {
|
||||
const mode = nextEnabled ? 'local' : 'disabled'
|
||||
updateModeMutation.mutate(mode)
|
||||
toast.success('CrowdSec mode saved (restart may be required)')
|
||||
}
|
||||
|
||||
const applyPresetLocally = async (reason?: string) => {
|
||||
if (!selectedPreset) {
|
||||
toast.error('Select a preset to apply')
|
||||
return
|
||||
}
|
||||
|
||||
const targetPath = selectedPath ?? listMutation.data?.files?.[0]
|
||||
if (!targetPath) {
|
||||
toast.error('Select a configuration file to apply the preset')
|
||||
return
|
||||
}
|
||||
|
||||
const content = presetPreview || selectedPreset.content
|
||||
if (!content) {
|
||||
toast.error('Preset preview is unavailable; retry pulling before applying')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await backupMutation.mutateAsync()
|
||||
await writeCrowdsecFile(targetPath, content)
|
||||
queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] })
|
||||
setSelectedPath(targetPath)
|
||||
setFileContent(content)
|
||||
setApplyInfo({ status: 'applied-locally', cacheKey: presetMeta?.cacheKey })
|
||||
toast.success(reason ? `${reason}: preset applied locally` : 'Preset applied locally (backup created)')
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to apply preset locally'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyPreset = async () => {
|
||||
if (!selectedPreset) {
|
||||
toast.error('Select a preset to apply')
|
||||
return
|
||||
}
|
||||
|
||||
setIsApplyingPreset(true)
|
||||
setApplyInfo(null)
|
||||
setValidationError(null)
|
||||
try {
|
||||
const res = await applyCrowdsecPreset({ slug: selectedPreset.slug, cache_key: presetMeta?.cacheKey })
|
||||
setApplyInfo({
|
||||
status: res.status,
|
||||
backup: res.backup,
|
||||
reloadHint: res.reload_hint,
|
||||
usedCscli: res.used_cscli,
|
||||
cacheKey: res.cache_key,
|
||||
})
|
||||
|
||||
const reloadNote = res.reload_hint ? ` (${res.reload_hint})` : ''
|
||||
toast.success(`Preset applied via backend${reloadNote}`)
|
||||
if (res.backup) {
|
||||
setPresetStatusMessage(`Backup stored at ${res.backup}`)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAxiosError(err)) {
|
||||
if (err.response?.status === 501) {
|
||||
toast.info('Preset apply is not available on the server; applying locally instead')
|
||||
await applyPresetLocally('Backend apply unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
if (err.response?.status === 400) {
|
||||
setValidationError(err.response?.data?.error || 'Preset validation failed')
|
||||
toast.error('Preset validation failed')
|
||||
return
|
||||
}
|
||||
|
||||
if (err.response?.status === 503) {
|
||||
setHubUnavailable(true)
|
||||
setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.')
|
||||
toast.error('Hub unavailable; retry pull/apply or use cached copy')
|
||||
return
|
||||
}
|
||||
|
||||
const backupPath = (err.response?.data as { backup?: string })?.backup
|
||||
if (backupPath) {
|
||||
setApplyInfo({ status: 'failed', backup: backupPath, cacheKey: presetMeta?.cacheKey })
|
||||
toast.error(`Apply failed. Restore from backup at ${backupPath}`)
|
||||
return
|
||||
}
|
||||
toast.error(err.response?.data?.error || err.message)
|
||||
} else {
|
||||
toast.error('Failed to apply preset')
|
||||
}
|
||||
} finally {
|
||||
setIsApplyingPreset(false)
|
||||
}
|
||||
}
|
||||
|
||||
const presetActionDisabled =
|
||||
!selectedPreset ||
|
||||
isApplyingPreset ||
|
||||
backupMutation.isPending ||
|
||||
pullPresetMutation.isPending ||
|
||||
(selectedPresetRequiresHub && hubUnavailable)
|
||||
|
||||
// Determine if any operation is in progress
|
||||
const isApplyingConfig =
|
||||
importMutation.isPending ||
|
||||
writeMutation.isPending ||
|
||||
updateModeMutation.isPending ||
|
||||
backupMutation.isPending ||
|
||||
pullPresetMutation.isPending ||
|
||||
isApplyingPreset ||
|
||||
banMutation.isPending ||
|
||||
unbanMutation.isPending
|
||||
|
||||
// Determine contextual message
|
||||
const getMessage = () => {
|
||||
if (pullPresetMutation.isPending) {
|
||||
return { message: 'Fetching preset...', submessage: 'Pulling preview from CrowdSec Hub' }
|
||||
}
|
||||
if (isApplyingPreset) {
|
||||
return { message: 'Loading preset...', submessage: 'Applying curated preset with backup' }
|
||||
}
|
||||
if (importMutation.isPending) {
|
||||
return { message: 'Summoning the guardian...', submessage: 'Importing CrowdSec configuration' }
|
||||
}
|
||||
@@ -168,37 +406,163 @@ export default function CrowdSecConfig() {
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{status.crowdsec.mode === 'disabled' && (
|
||||
<p className="text-xs text-yellow-500">CrowdSec is disabled</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">CrowdSec Mode</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
{isLocalMode ? 'CrowdSec runs locally; disable to pause decisions.' : 'CrowdSec decisions are paused; enable to resume local protection.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={handleExport}>Export</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-400">Disabled</span>
|
||||
<Switch
|
||||
checked={isLocalMode}
|
||||
onChange={(e) => handleModeToggle(e.target.checked)}
|
||||
disabled={updateModeMutation.isPending}
|
||||
data-testid="crowdsec-mode-toggle"
|
||||
/>
|
||||
<span className="text-sm text-gray-200">Local</span>
|
||||
</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 className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<h3 className="text-md font-semibold">Configuration Packages</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleExport}
|
||||
disabled={importMutation.isPending || backupMutation.isPending}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={!file || importMutation.isPending || backupMutation.isPending} data-testid="import-btn">
|
||||
{importMutation.isPending ? 'Importing...' : 'Import'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">Import or export CrowdSec configuration packages. A backup is created before imports.</p>
|
||||
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-md font-semibold">CrowdSec Presets</h3>
|
||||
<p className="text-sm text-gray-400">Select a curated preset, preview it, then apply with an automatic backup.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
value={selectedPresetSlug}
|
||||
onChange={(e) => setSelectedPresetSlug(e.target.value)}
|
||||
data-testid="preset-select"
|
||||
>
|
||||
{presetCatalog.map((preset) => (
|
||||
<option key={preset.slug} value={preset.slug}>{preset.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => selectedPreset && pullPresetMutation.mutate(selectedPreset.slug)}
|
||||
disabled={!selectedPreset || pullPresetMutation.isPending}
|
||||
isLoading={pullPresetMutation.isPending}
|
||||
>
|
||||
Pull Preview
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApplyPreset}
|
||||
disabled={presetActionDisabled}
|
||||
isLoading={isApplyingPreset}
|
||||
data-testid="apply-preset-btn"
|
||||
>
|
||||
Apply Preset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<p className="text-sm text-red-400" data-testid="preset-validation-error">{validationError}</p>
|
||||
)}
|
||||
|
||||
{presetStatusMessage && (
|
||||
<p className="text-sm text-yellow-300" data-testid="preset-status">{presetStatusMessage}</p>
|
||||
)}
|
||||
|
||||
{hubUnavailable && (
|
||||
<div className="flex flex-wrap gap-2 items-center text-sm text-red-300" data-testid="preset-hub-unavailable">
|
||||
<span>Hub unreachable. Retry pull or load cached copy if available.</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => selectedPreset && pullPresetMutation.mutate(selectedPreset.slug)}
|
||||
disabled={pullPresetMutation.isPending}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
{selectedPreset?.cached && (
|
||||
<Button size="sm" variant="secondary" onClick={loadCachedPreview}>Use cached preview</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPreset && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-white">{selectedPreset.title}</p>
|
||||
<p className="text-sm text-gray-400">{selectedPreset.description}</p>
|
||||
{selectedPreset.warning && (
|
||||
<p className="text-xs text-yellow-300" data-testid="preset-warning">{selectedPreset.warning}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">Target file: {selectedPath ?? 'Select a file below (used for local fallback)'} </p>
|
||||
</div>
|
||||
{presetMeta && (
|
||||
<div className="text-xs text-gray-400 flex flex-wrap gap-3" data-testid="preset-meta">
|
||||
<span>Cache key: {presetMeta.cacheKey || '—'}</span>
|
||||
<span>Etag: {presetMeta.etag || '—'}</span>
|
||||
<span>Source: {presetMeta.source || selectedPreset.source || '—'}</span>
|
||||
<span>Fetched: {presetMeta.retrievedAt ? new Date(presetMeta.retrievedAt).toLocaleString() : '—'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-2">Preset preview (YAML)</p>
|
||||
<pre
|
||||
className="bg-gray-900 border border-gray-800 rounded-lg p-3 text-sm text-gray-200 whitespace-pre-wrap"
|
||||
data-testid="preset-preview"
|
||||
>
|
||||
{presetPreview || selectedPreset.content || 'Preview unavailable. Pull from hub or use cached copy.'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{applyInfo && (
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-900/70 p-3 text-xs text-gray-200" data-testid="preset-apply-info">
|
||||
<p>Status: {applyInfo.status || 'applied'}</p>
|
||||
{applyInfo.backup && <p>Backup: {applyInfo.backup}</p>}
|
||||
{applyInfo.reloadHint && <p>Reload: {applyInfo.reloadHint}</p>}
|
||||
{applyInfo.usedCscli !== undefined && <p>Method: {applyInfo.usedCscli ? 'cscli' : 'filesystem'}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center text-xs text-gray-400">
|
||||
{selectedPreset.cached && (
|
||||
<Button size="sm" variant="secondary" onClick={loadCachedPreview}>
|
||||
Load cached preview
|
||||
</Button>
|
||||
)}
|
||||
{selectedPresetRequiresHub && hubUnavailable && (
|
||||
<span className="text-red-300">Apply disabled while hub is offline.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{presetCatalog.length === 0 && (
|
||||
<p className="text-sm text-gray-500">No presets available. Ensure Cerberus is enabled.</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -206,7 +570,12 @@ export default function CrowdSecConfig() {
|
||||
<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)}>
|
||||
<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)}
|
||||
data-testid="crowdsec-file-select"
|
||||
>
|
||||
<option value="">Select a file...</option>
|
||||
{listMutation.data?.files?.map((f) => (
|
||||
<option value={f} key={f}>{f}</option>
|
||||
|
||||
@@ -47,11 +47,11 @@ export default function ImportCrowdSec() {
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-6">Import CrowdSec</h1>
|
||||
<h1 className="text-3xl font-bold text-white mb-6">CrowdSec Configuration Packages</h1>
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">Upload a tar.gz or zip with your CrowdSec configuration. A backup will be created before importing.</p>
|
||||
<input type="file" onChange={handleFile} accept=".tar.gz,.zip" />
|
||||
<p className="text-sm text-gray-400">Upload a tar.gz or zip package. A backup is created before importing so you can roll back if needed. Export the current package from the Cerberus dashboard or CrowdSec config page.</p>
|
||||
<input type="file" onChange={handleFile} accept=".tar.gz,.zip" data-testid="crowdsec-import-file" />
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => handleImport()} isLoading={backupMutation.isPending || importMutation.isPending} disabled={!file}>Import</Button>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toast } from '../utils/toast'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport'
|
||||
|
||||
export default function Security() {
|
||||
const navigate = useNavigate()
|
||||
@@ -78,27 +79,76 @@ export default function Security() {
|
||||
|
||||
useEffect(() => { fetchCrowdsecStatus() }, [])
|
||||
|
||||
const startMutation = useMutation({ mutationFn: () => startCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) })
|
||||
const stopMutation = useMutation({ mutationFn: () => stopCrowdsec(), onSuccess: () => fetchCrowdsecStatus(), onError: (e: unknown) => toast.error(String(e)) })
|
||||
const handleCrowdsecExport = async () => {
|
||||
const defaultName = buildCrowdsecExportFilename()
|
||||
const filename = promptCrowdsecFilename(defaultName)
|
||||
if (!filename) return
|
||||
|
||||
try {
|
||||
const resp = await exportCrowdsecConfig()
|
||||
downloadCrowdsecExport(resp, filename)
|
||||
toast.success('CrowdSec configuration exported')
|
||||
} catch {
|
||||
toast.error('Failed to export CrowdSec configuration')
|
||||
}
|
||||
}
|
||||
|
||||
const crowdsecPowerMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
await updateSetting('security.crowdsec.enabled', enabled ? 'true' : 'false', 'security', 'bool')
|
||||
if (enabled) {
|
||||
await startCrowdsec()
|
||||
} else {
|
||||
await stopCrowdsec()
|
||||
}
|
||||
return enabled
|
||||
},
|
||||
onMutate: async (enabled: boolean) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['security-status'] })
|
||||
const previous = queryClient.getQueryData(['security-status'])
|
||||
queryClient.setQueryData(['security-status'], (old: unknown) => {
|
||||
if (!old || typeof old !== 'object') return old
|
||||
const copy = { ...(old as SecurityStatus) }
|
||||
if (copy.crowdsec && typeof copy.crowdsec === 'object') {
|
||||
copy.crowdsec = { ...copy.crowdsec, enabled } as never
|
||||
}
|
||||
return copy
|
||||
})
|
||||
setCrowdsecStatus(prev => prev ? { ...prev, running: enabled } : prev)
|
||||
return { previous }
|
||||
},
|
||||
onError: (err: unknown, enabled: boolean, context: unknown) => {
|
||||
if (context && typeof context === 'object' && 'previous' in context) {
|
||||
queryClient.setQueryData(['security-status'], context.previous)
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
toast.error(enabled ? `Failed to start CrowdSec: ${msg}` : `Failed to stop CrowdSec: ${msg}`)
|
||||
fetchCrowdsecStatus()
|
||||
},
|
||||
onSuccess: async (enabled: boolean) => {
|
||||
await fetchCrowdsecStatus()
|
||||
queryClient.invalidateQueries({ queryKey: ['security-status'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
toast.success(enabled ? 'CrowdSec started' : 'CrowdSec stopped')
|
||||
},
|
||||
})
|
||||
|
||||
// Determine if any security operation is in progress
|
||||
const isApplyingConfig =
|
||||
toggleServiceMutation.isPending ||
|
||||
updateSecurityConfigMutation.isPending ||
|
||||
generateBreakGlassMutation.isPending ||
|
||||
startMutation.isPending ||
|
||||
stopMutation.isPending
|
||||
crowdsecPowerMutation.isPending
|
||||
|
||||
// Determine contextual message
|
||||
const getMessage = () => {
|
||||
if (toggleServiceMutation.isPending) {
|
||||
return { message: 'Three heads turn...', submessage: 'Security configuration updating' }
|
||||
return { message: 'Three heads turn...', submessage: 'Cerberus configuration updating' }
|
||||
}
|
||||
if (startMutation.isPending) {
|
||||
return { message: 'Summoning the guardian...', submessage: 'Intrusion prevention rising' }
|
||||
}
|
||||
if (stopMutation.isPending) {
|
||||
return { message: 'Guardian rests...', submessage: 'Intrusion prevention pausing' }
|
||||
if (crowdsecPowerMutation.isPending) {
|
||||
return crowdsecPowerMutation.variables
|
||||
? { message: 'Summoning the guardian...', submessage: 'CrowdSec is starting' }
|
||||
: { message: 'Guardian rests...', submessage: 'CrowdSec is stopping' }
|
||||
}
|
||||
return { message: 'Strengthening the guard...', submessage: 'Protective wards activating' }
|
||||
}
|
||||
@@ -113,6 +163,10 @@ export default function Security() {
|
||||
return <div className="p-8 text-center text-red-500">Failed to load security status</div>
|
||||
}
|
||||
|
||||
const cerberusDisabled = !status.cerberus?.enabled
|
||||
const crowdsecToggleDisabled = cerberusDisabled || crowdsecPowerMutation.isPending
|
||||
const crowdsecControlsDisabled = cerberusDisabled || crowdsecPowerMutation.isPending
|
||||
|
||||
// const suiteDisabled = !(status?.cerberus?.enabled ?? false)
|
||||
|
||||
// Replace the previous early-return that instructed enabling via env vars.
|
||||
@@ -121,10 +175,10 @@ export default function Security() {
|
||||
<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>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Cerberus 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.
|
||||
Cerberus powers CrowdSec, WAF, ACLs, and Rate Limiting. Enable the Cerberus toggle in System Settings to awaken the guardian, then configure each head below.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -153,7 +207,7 @@ export default function Security() {
|
||||
<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
|
||||
Cerberus Dashboard
|
||||
</h1>
|
||||
<div/>
|
||||
<Button
|
||||
@@ -185,9 +239,9 @@ export default function Security() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={status.crowdsec.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
disabled={crowdsecToggleDisabled}
|
||||
onChange={(e) => {
|
||||
toggleServiceMutation.mutate({ key: 'security.crowdsec.enabled', enabled: e.target.checked })
|
||||
crowdsecPowerMutation.mutate(e.target.checked)
|
||||
}}
|
||||
data-testid="toggle-crowdsec"
|
||||
/>
|
||||
@@ -206,67 +260,35 @@ export default function Security() {
|
||||
{crowdsecStatus && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{crowdsecStatus.running ? `Running (pid ${crowdsecStatus.pid})` : 'Stopped'}</p>
|
||||
)}
|
||||
{status.crowdsec.enabled && (
|
||||
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => navigate('/tasks/logs?search=crowdsec')}
|
||||
>
|
||||
Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
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 text-xs" onClick={() => navigate('/security/crowdsec')}>
|
||||
Config
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => startMutation.mutate()}
|
||||
data-testid="crowdsec-start"
|
||||
isLoading={startMutation.isPending}
|
||||
disabled={!!crowdsecStatus?.running}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => stopMutation.mutate()}
|
||||
data-testid="crowdsec-stop"
|
||||
isLoading={stopMutation.isPending}
|
||||
disabled={!crowdsecStatus?.running}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => navigate('/tasks/logs?search=crowdsec')}
|
||||
disabled={crowdsecControlsDisabled}
|
||||
>
|
||||
Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={handleCrowdsecExport}
|
||||
disabled={crowdsecControlsDisabled}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => navigate('/security/crowdsec')}
|
||||
disabled={crowdsecControlsDisabled}
|
||||
>
|
||||
Config
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { AxiosError } from 'axios'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
@@ -8,11 +9,14 @@ import * as api from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as presetsApi from '../../api/presets'
|
||||
import { CROWDSEC_PRESETS } from '../../data/crowdsecPresets'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../api/presets')
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
@@ -27,13 +31,46 @@ const renderWithProviders = (ui: React.ReactNode) => {
|
||||
}
|
||||
|
||||
describe('CrowdSecConfig', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({
|
||||
presets: CROWDSEC_PRESETS.map((preset) => ({
|
||||
slug: preset.slug,
|
||||
title: preset.title,
|
||||
summary: preset.description,
|
||||
source: 'charon',
|
||||
requires_hub: false,
|
||||
available: true,
|
||||
cached: false,
|
||||
})),
|
||||
})
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
|
||||
status: 'pulled',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
preview: CROWDSEC_PRESETS[0].content,
|
||||
cache_key: 'cache-123',
|
||||
etag: 'etag-123',
|
||||
retrieved_at: '2024-01-01T00:00:00Z',
|
||||
source: 'hub',
|
||||
})
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({
|
||||
status: 'applied',
|
||||
backup: '/tmp/backup.tar.gz',
|
||||
reload_hint: 'CrowdSec reloaded',
|
||||
used_cscli: true,
|
||||
cache_key: 'cache-123',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
})
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached', cache_key: 'cache-123', etag: 'etag-123' })
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
|
||||
})
|
||||
|
||||
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 } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
const blob = new Blob(['dummy'])
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob)
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export')
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const exportBtn = screen.getByText('Export')
|
||||
@@ -69,8 +106,7 @@ describe('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]
|
||||
const select = screen.getByTestId('crowdsec-file-select')
|
||||
await userEvent.selectOptions(select, 'conf.d/a.conf')
|
||||
await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf'))
|
||||
// ensure textarea populated
|
||||
@@ -93,9 +129,123 @@ describe('CrowdSecConfig', () => {
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const selects = screen.getAllByRole('combobox')
|
||||
const modeSelect = selects[0]
|
||||
await userEvent.selectOptions(modeSelect, 'local')
|
||||
const modeToggle = screen.getByTestId('crowdsec-mode-toggle')
|
||||
await userEvent.click(modeToggle)
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.mode', 'local', 'security', 'string'))
|
||||
})
|
||||
|
||||
it('renders preset preview and applies with backup when backend apply is unavailable', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
const presetContent = CROWDSEC_PRESETS.find((preset) => preset.slug === 'bot-mitigation-essentials')?.content || ''
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' })
|
||||
const axiosError = new AxiosError('not implemented', undefined, undefined, undefined, {
|
||||
status: 501,
|
||||
statusText: 'Not Implemented',
|
||||
headers: {},
|
||||
config: {},
|
||||
data: {},
|
||||
} as any)
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValue(axiosError)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('configs:'))
|
||||
const fileSelect = screen.getByTestId('crowdsec-file-select')
|
||||
await userEvent.selectOptions(fileSelect, 'acquis.yaml')
|
||||
const applyBtn = screen.getByTestId('apply-preset-btn')
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => expect(presetsApi.applyCrowdsecPreset).toHaveBeenCalledWith({ slug: 'bot-mitigation-essentials', cache_key: 'cache-123' }))
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('acquis.yaml', presetContent))
|
||||
})
|
||||
|
||||
it('surfaces validation error when slug is invalid', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
const validationError = new AxiosError('invalid', undefined, undefined, undefined, {
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: {},
|
||||
config: {},
|
||||
data: { error: 'slug invalid' },
|
||||
} as any)
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(validationError)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('slug invalid'))
|
||||
})
|
||||
|
||||
it('disables apply and offers cached preview when hub is unavailable', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({
|
||||
presets: [
|
||||
{
|
||||
slug: 'hub-only',
|
||||
title: 'Hub Only',
|
||||
summary: 'Needs hub',
|
||||
source: 'hub',
|
||||
requires_hub: true,
|
||||
available: true,
|
||||
cached: true,
|
||||
cache_key: 'cache-hub',
|
||||
etag: 'etag-hub',
|
||||
},
|
||||
],
|
||||
})
|
||||
const hubError = new AxiosError('unavailable', undefined, undefined, undefined, {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: {},
|
||||
config: {},
|
||||
data: { error: 'hub service unavailable' },
|
||||
} as any)
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValue(hubError)
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached-preview', cache_key: 'cache-hub', etag: 'etag-hub' })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
const select = await screen.findByTestId('preset-select')
|
||||
await waitFor(() => expect(screen.getByText('Hub Only')).toBeInTheDocument())
|
||||
await userEvent.selectOptions(select, 'hub-only')
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument())
|
||||
|
||||
const applyBtn = screen.getByTestId('apply-preset-btn') as HTMLButtonElement
|
||||
expect(applyBtn.disabled).toBe(true)
|
||||
|
||||
await userEvent.click(screen.getByText('Use cached preview'))
|
||||
await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('cached-preview'))
|
||||
})
|
||||
|
||||
it('shows apply response metadata including backup path', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValueOnce({
|
||||
status: 'applied',
|
||||
backup: '/tmp/crowdsec-backup',
|
||||
reload_hint: 'crowdsec reloaded',
|
||||
used_cscli: true,
|
||||
cache_key: 'cache-123',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
})
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
const applyBtn = await screen.findByTestId('apply-preset-btn')
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('/tmp/crowdsec-backup'))
|
||||
expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('crowdsec reloaded')
|
||||
})
|
||||
})
|
||||
|
||||
118
frontend/src/pages/__tests__/CrowdSecConfig.test.tsx
Normal file
118
frontend/src/pages/__tests__/CrowdSecConfig.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
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 { MemoryRouter } from 'react-router-dom'
|
||||
import CrowdSecConfig from '../CrowdSecConfig'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as presetsApi from '../../api/presets'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../api/presets')
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('CrowdSecConfig', () => {
|
||||
const createClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = () => {
|
||||
const queryClient = createClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<CrowdSecConfig />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled', enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
})
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({})
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['data']))
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ presets: [] })
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
|
||||
status: 'pulled',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
preview: 'configs: {}',
|
||||
cache_key: 'cache-123',
|
||||
})
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({ status: 'applied', backup: '/tmp/backup.tar.gz', cache_key: 'cache-123' })
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'configs: {}', cache_key: 'cache-123' })
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
window.URL.createObjectURL = vi.fn(() => 'blob:url')
|
||||
window.URL.revokeObjectURL = vi.fn()
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('toggles mode between local and disabled', async () => {
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-mode-toggle'))
|
||||
const toggle = screen.getByTestId('crowdsec-mode-toggle')
|
||||
|
||||
await userEvent.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.crowdsec.mode',
|
||||
'disabled',
|
||||
'security',
|
||||
'string'
|
||||
)
|
||||
expect(toast.success).toHaveBeenCalledWith('CrowdSec disabled')
|
||||
})
|
||||
})
|
||||
|
||||
it('exports configuration packages with prompted filename', async () => {
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
|
||||
const exportButton = screen.getByRole('button', { name: /Export/i })
|
||||
|
||||
await userEvent.click(exportButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled()
|
||||
expect(toast.success).toHaveBeenCalledWith('CrowdSec configuration exported')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Configuration Packages heading', async () => {
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => screen.getByText('Configuration Packages'))
|
||||
|
||||
expect(screen.getByText('Configuration Packages')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
64
frontend/src/pages/__tests__/ImportCrowdSec.test.tsx
Normal file
64
frontend/src/pages/__tests__/ImportCrowdSec.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import ImportCrowdSec from '../ImportCrowdSec'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ImportCrowdSec', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
|
||||
})
|
||||
|
||||
const renderPage = () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<ImportCrowdSec />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('renders configuration packages heading', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => screen.getByText('CrowdSec Configuration Packages'))
|
||||
expect(screen.getByText('CrowdSec Configuration Packages')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates a backup before importing selected package', async () => {
|
||||
renderPage()
|
||||
|
||||
const fileInput = screen.getByTestId('crowdsec-import-file') as HTMLInputElement
|
||||
const file = new File(['config'], 'config.tar.gz', { type: 'application/gzip' })
|
||||
|
||||
await userEvent.upload(fileInput, file)
|
||||
|
||||
const importButton = screen.getByRole('button', { name: /Import/i })
|
||||
await userEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled()
|
||||
expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalledWith(file)
|
||||
expect(toast.success).toHaveBeenCalledWith('CrowdSec config imported')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
* Security Page - QA Security Audit Tests
|
||||
*
|
||||
* Tests edge cases, input validation, error states, and security concerns
|
||||
* for the Security Dashboard implementation.
|
||||
* for the Cerberus Dashboard implementation.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
@@ -58,6 +58,8 @@ describe('Security Page - QA Security Audit', () => {
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
@@ -80,7 +82,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// DOM should not contain any actual script elements from user input
|
||||
expect(document.querySelectorAll('script[src*="alert"]').length).toBe(0)
|
||||
@@ -94,7 +96,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Empty whitelist input should exist and be empty
|
||||
const whitelistInput = screen.getByDisplayValue('')
|
||||
@@ -115,21 +117,24 @@ describe('Security Page - QA Security Audit', () => {
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to update setting'))
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.stringContaining('Failed to stop CrowdSec'))
|
||||
})
|
||||
})
|
||||
|
||||
it('handles CrowdSec start failure gracefully', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
|
||||
})
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockRejectedValue(new Error('Failed to start'))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-start'))
|
||||
const startButton = screen.getByTestId('crowdsec-start')
|
||||
await user.click(startButton)
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
@@ -144,9 +149,9 @@ describe('Security Page - QA Security Audit', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-stop'))
|
||||
const stopButton = screen.getByTestId('crowdsec-stop')
|
||||
await user.click(stopButton)
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
@@ -176,7 +181,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
await renderSecurityPage()
|
||||
|
||||
// Page should still render even if status check fails
|
||||
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -197,9 +202,12 @@ describe('Security Page - QA Security Audit', () => {
|
||||
await waitFor(() => expect(screen.getByText(/Three heads turn/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('prevents double-click on CrowdSec start button', async () => {
|
||||
it('prevents double toggle when starting CrowdSec', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
|
||||
})
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
|
||||
let callCount = 0
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(async () => {
|
||||
@@ -210,12 +218,12 @@ describe('Security Page - QA Security Audit', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-start'))
|
||||
const startButton = screen.getByTestId('crowdsec-start')
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
|
||||
// Double click
|
||||
await user.click(startButton)
|
||||
await user.click(startButton)
|
||||
await user.click(toggle)
|
||||
await user.click(toggle)
|
||||
|
||||
// Wait for potential multiple calls
|
||||
await act(async () => {
|
||||
@@ -235,7 +243,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Get initial card order
|
||||
const initialCards = screen.getAllByRole('heading', { level: 3 })
|
||||
@@ -260,7 +268,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Each layer should have correct emoji
|
||||
expect(screen.getByText(/🛡️ Layer 1/)).toBeInTheDocument()
|
||||
@@ -281,7 +289,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// All 4 cards should be present
|
||||
expect(screen.getByText('CrowdSec')).toBeInTheDocument()
|
||||
@@ -297,7 +305,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-acl')).toBeInTheDocument()
|
||||
@@ -310,22 +318,25 @@ describe('Security Page - QA Security Audit', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
expect(screen.getByTestId('waf-mode-select')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('waf-ruleset-select')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('CrowdSec buttons have proper test IDs when enabled', async () => {
|
||||
it('CrowdSec controls surface primary actions when enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
expect(screen.getByTestId('crowdsec-start')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('crowdsec-stop')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Logs/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /Export/i })).toBeInTheDocument()
|
||||
const configButtons = screen.getAllByRole('button', { name: /Config/i })
|
||||
expect(configButtons.some(btn => btn.textContent === 'Config')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -335,7 +346,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
const cardNames = cards.map(card => card.textContent)
|
||||
@@ -349,7 +360,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// From spec: Layer 1: IP Reputation, Layer 2: Access Control, Layer 3: Request Inspection, Layer 4: Volume Control
|
||||
expect(screen.getByText(/Layer 1: IP Reputation/i)).toBeInTheDocument()
|
||||
@@ -363,7 +374,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// From spec:
|
||||
// CrowdSec: "Known attackers, botnets, brute-force attempts"
|
||||
@@ -397,7 +408,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
}
|
||||
|
||||
// Page should still be functional
|
||||
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('handles undefined crowdsec status gracefully', async () => {
|
||||
@@ -407,7 +418,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
await renderSecurityPage()
|
||||
|
||||
// Should not crash
|
||||
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('Security page', () => {
|
||||
} as SecurityStatus)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
expect(await screen.findByText('Security Suite Disabled')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Cerberus Disabled')).toBeInTheDocument()
|
||||
const docBtns = screen.getAllByText('Documentation')
|
||||
expect(docBtns.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -80,14 +80,9 @@ describe('Security page', () => {
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
|
||||
// debug: ensure element state
|
||||
console.log('crowdsecToggle disabled:', (crowdsecToggle as HTMLInputElement).disabled)
|
||||
expect(crowdsecToggle).toBeTruthy()
|
||||
// Ensure the toggle exists and is not disabled
|
||||
expect(crowdsecToggle).toBeTruthy()
|
||||
expect((crowdsecToggle as HTMLInputElement).disabled).toBe(false)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec') as HTMLInputElement
|
||||
expect(crowdsecToggle.disabled).toBe(false)
|
||||
// Ensure enable-all controls were removed
|
||||
expect(screen.queryByTestId('enable-all-btn')).toBeNull()
|
||||
})
|
||||
@@ -103,7 +98,7 @@ describe('Security page', () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
const updateSpy = vi.mocked(settingsApi.updateSetting)
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const aclToggle = screen.getByTestId('toggle-acl')
|
||||
await userEvent.click(aclToggle)
|
||||
await waitFor(() => expect(updateSpy).toHaveBeenCalledWith('security.acl.enabled', 'true', 'security', 'bool'))
|
||||
@@ -120,42 +115,47 @@ describe('Security page', () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
const blob = new Blob(['dummy'])
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob)
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export')
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const exportBtn = screen.getByText('Export')
|
||||
await userEvent.click(exportBtn)
|
||||
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('calls start/stop endpoints for CrowdSec', async () => {
|
||||
const status: SecurityStatus = {
|
||||
it('calls start/stop endpoints for CrowdSec via toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
const baseStatus: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: true, mode: 'local' as const, api_url: '' },
|
||||
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)
|
||||
// Test start
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue(undefined)
|
||||
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(baseStatus as SecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue(undefined)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
const startBtn = screen.getByText('Start')
|
||||
await userEvent.click(startBtn)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled())
|
||||
// Cleanup before re-render to avoid multiple DOM instances
|
||||
|
||||
cleanup()
|
||||
|
||||
// Test stop: render with running state and click stop
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue(undefined)
|
||||
const enabledStatus: SecurityStatus = { ...baseStatus, crowdsec: { enabled: true, mode: 'local' as const, api_url: '' } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(enabledStatus as SecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 123 })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText('Stop')).toBeInTheDocument())
|
||||
const stopBtn = screen.getAllByText('Stop').find(b => !b.hasAttribute('disabled'))
|
||||
if (!stopBtn) throw new Error('No enabled Stop button found')
|
||||
await userEvent.click(stopBtn)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const stopToggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(stopToggle)
|
||||
await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('Security page', () => {
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Suite Disabled')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Disabled')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(crowdsecToggle).toBeDisabled()
|
||||
})
|
||||
@@ -325,7 +325,7 @@ describe('Security page', () => {
|
||||
vi.mocked(api.getRuleSets).mockResolvedValue(mockRuleSets)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
|
||||
// Mode selector and ruleset selector should not be visible
|
||||
expect(screen.queryByTestId('waf-mode-select')).not.toBeInTheDocument()
|
||||
|
||||
@@ -52,6 +52,7 @@ describe('Security', () => {
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob())
|
||||
vi.spyOn(window, 'open').mockImplementation(() => null)
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
})
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
@@ -89,16 +90,16 @@ describe('Security', () => {
|
||||
await waitFor(() => expect(screen.getByText(/Failed to load security status/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should render Security Dashboard when status loads', async () => {
|
||||
it('should render Cerberus Dashboard when status loads', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show banner when Cerberus is disabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, cerberus: { enabled: false } })
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => expect(screen.getByText(/Security Suite Disabled/i)).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Disabled/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -192,24 +193,30 @@ describe('Security', () => {
|
||||
})
|
||||
|
||||
describe('CrowdSec Controls', () => {
|
||||
it('should start CrowdSec', async () => {
|
||||
it('should start CrowdSec when toggling on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
|
||||
})
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue({ success: true })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-start'))
|
||||
const startButton = screen.getByTestId('crowdsec-start')
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await act(async () => {
|
||||
await user.click(startButton)
|
||||
await user.click(toggle)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled())
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'true', 'security', 'bool')
|
||||
expect(crowdsecApi.startCrowdsec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop CrowdSec', async () => {
|
||||
it('should stop CrowdSec when toggling off', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
|
||||
@@ -217,13 +224,16 @@ describe('Security', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-stop'))
|
||||
const stopButton = screen.getByTestId('crowdsec-stop')
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await act(async () => {
|
||||
await user.click(stopButton)
|
||||
await user.click(toggle)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled())
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.enabled', 'false', 'security', 'bool')
|
||||
expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('should export CrowdSec config', async () => {
|
||||
@@ -285,7 +295,7 @@ describe('Security', () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Get all card headings
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
@@ -299,7 +309,7 @@ describe('Security', () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Verify each layer indicator is present
|
||||
expect(screen.getByText(/Layer 1: IP Reputation/i)).toBeInTheDocument()
|
||||
@@ -312,7 +322,7 @@ describe('Security', () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Verify threat protection descriptions
|
||||
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
|
||||
@@ -339,15 +349,18 @@ describe('Security', () => {
|
||||
|
||||
it('should show overlay when starting CrowdSec', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
...mockSecurityStatus,
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false },
|
||||
})
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-start'))
|
||||
const startButton = screen.getByTestId('crowdsec-start')
|
||||
await user.click(startButton)
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Summoning the guardian/i)).toBeInTheDocument())
|
||||
})
|
||||
@@ -360,9 +373,9 @@ describe('Security', () => {
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-stop'))
|
||||
const stopButton = screen.getByTestId('crowdsec-stop')
|
||||
await user.click(stopButton)
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Guardian rests/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
24
frontend/src/utils/crowdsecExport.ts
Normal file
24
frontend/src/utils/crowdsecExport.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const buildCrowdsecExportFilename = (): string => {
|
||||
const timestamp = new Date().toISOString().replace(/:/g, '-')
|
||||
return `crowdsec-export-${timestamp}.tar.gz`
|
||||
}
|
||||
|
||||
export const promptCrowdsecFilename = (defaultName = buildCrowdsecExportFilename()): string | null => {
|
||||
const input = window.prompt('Name your CrowdSec export archive', defaultName)
|
||||
if (input === null || typeof input === 'undefined') return null
|
||||
const trimmed = typeof input === 'string' ? input.trim() : ''
|
||||
const candidate = trimmed || defaultName
|
||||
const sanitized = candidate.replace(/[\\/]+/g, '-').replace(/\s+/g, '-')
|
||||
return sanitized.toLowerCase().endsWith('.tar.gz') ? sanitized : `${sanitized}.tar.gz`
|
||||
}
|
||||
|
||||
export const downloadCrowdsecExport = (blob: Blob, filename: string) => {
|
||||
const url = window.URL.createObjectURL(new Blob([blob]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
Reference in New Issue
Block a user