diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go index 7d17623e..80db3209 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -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) diff --git a/backend/internal/api/handlers/crowdsec_handler_coverage_test.go b/backend/internal/api/handlers/crowdsec_handler_coverage_test.go index c0235ff3..e6e4216a 100644 --- a/backend/internal/api/handlers/crowdsec_handler_coverage_test.go +++ b/backend/internal/api/handlers/crowdsec_handler_coverage_test.go @@ -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) +} diff --git a/backend/internal/api/handlers/crowdsec_handler_test.go b/backend/internal/api/handlers/crowdsec_handler_test.go index d4e775e4..fdbc617b 100644 --- a/backend/internal/api/handlers/crowdsec_handler_test.go +++ b/backend/internal/api/handlers/crowdsec_handler_test.go @@ -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") } } diff --git a/backend/internal/api/handlers/crowdsec_presets_handler_test.go b/backend/internal/api/handlers/crowdsec_presets_handler_test.go new file mode 100644 index 00000000..754fc68e --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_presets_handler_test.go @@ -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") +} diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index c3d03f3a..eccba76d 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -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) } diff --git a/backend/internal/crowdsec/hub_cache.go b/backend/internal/crowdsec/hub_cache.go new file mode 100644 index 00000000..d203cc23 --- /dev/null +++ b/backend/internal/crowdsec/hub_cache.go @@ -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 +} diff --git a/backend/internal/crowdsec/hub_cache_test.go b/backend/internal/crowdsec/hub_cache_test.go new file mode 100644 index 00000000..5b79b2e8 --- /dev/null +++ b/backend/internal/crowdsec/hub_cache_test.go @@ -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) +} diff --git a/backend/internal/crowdsec/hub_sync.go b/backend/internal/crowdsec/hub_sync.go new file mode 100644 index 00000000..effbc466 --- /dev/null +++ b/backend/internal/crowdsec/hub_sync.go @@ -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 "" +} diff --git a/backend/internal/crowdsec/hub_sync_test.go b/backend/internal/crowdsec/hub_sync_test.go new file mode 100644 index 00000000..0f415c48 --- /dev/null +++ b/backend/internal/crowdsec/hub_sync_test.go @@ -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)) +} diff --git a/backend/internal/crowdsec/presets.go b/backend/internal/crowdsec/presets.go new file mode 100644 index 00000000..6f9a83dc --- /dev/null +++ b/backend/internal/crowdsec/presets.go @@ -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 +} diff --git a/backend/internal/crowdsec/presets_test.go b/backend/internal/crowdsec/presets_test.go new file mode 100644 index 00000000..9d86c2ee --- /dev/null +++ b/backend/internal/crowdsec/presets_test.go @@ -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") + } +} diff --git a/backend/internal/models/crowdsec_preset_event.go b/backend/internal/models/crowdsec_preset_event.go new file mode 100644 index 00000000..3e98d9d3 --- /dev/null +++ b/backend/internal/models/crowdsec_preset_event.go @@ -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"` +} diff --git a/docs/features.md b/docs/features.md index bb6962a9..602e7413 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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 diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index d4796833..09c5775d 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -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 ` (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//` 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 ` (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. diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index c4de668b..fd16c0dd 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -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). diff --git a/docs/security.md b/docs/security.md index d7da70aa..2c67b950 100644 --- a/docs/security.md +++ b/docs/security.md @@ -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. diff --git a/docs/troubleshooting/crowdsec.md b/docs/troubleshooting/crowdsec.md new file mode 100644 index 00000000..370e2cb0 --- /dev/null +++ b/docs/troubleshooting/crowdsec.md @@ -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. diff --git a/frontend/src/api/__tests__/presets.test.ts b/frontend/src/api/__tests__/presets.test.ts new file mode 100644 index 00000000..c79650f7 --- /dev/null +++ b/frontend/src/api/__tests__/presets.test.ts @@ -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') + }) +}) diff --git a/frontend/src/api/presets.ts b/frontend/src/api/presets.ts new file mode 100644 index 00000000..3ac7b469 --- /dev/null +++ b/frontend/src/api/presets.ts @@ -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('/admin/crowdsec/presets/pull', { slug }) + return resp.data +} + +export async function applyCrowdsecPreset(payload: { slug: string; cache_key?: string }) { + const resp = await client.post('/admin/crowdsec/presets/apply', payload) + return resp.data +} + +export async function getCrowdsecPresetCache(slug: string) { + const resp = await client.get(`/admin/crowdsec/presets/cache/${encodeURIComponent(slug)}`) + return resp.data +} + +export default { + listCrowdsecPresets, + pullCrowdsecPreset, + applyCrowdsecPreset, + getCrowdsecPresetCache, +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 026f80aa..87cd41f0 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -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 }) diff --git a/frontend/src/components/__tests__/Layout.test.tsx b/frontend/src/components/__tests__/Layout.test.tsx index ddc69cb8..e47ad827 100644 --- a/frontend/src/components/__tests__/Layout.test.tsx +++ b/frontend/src/components/__tests__/Layout.test.tsx @@ -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() }) }) diff --git a/frontend/src/pages/CrowdSecConfig.tsx b/frontend/src/pages/CrowdSecConfig.tsx index b60611b8..03d5327d 100644 --- a/frontend/src/pages/CrowdSecConfig.tsx +++ b/frontend/src/pages/CrowdSecConfig.tsx @@ -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(null) const [selectedPath, setSelectedPath] = useState(null) const [fileContent, setFileContent] = useState(null) + const [selectedPresetSlug, setSelectedPresetSlug] = useState('') const [showBanModal, setShowBanModal] = useState(false) const [banForm, setBanForm] = useState({ ip: '', duration: '24h', reason: '' }) const [confirmUnban, setConfirmUnban] = useState(null) + const [isApplyingPreset, setIsApplyingPreset] = useState(false) + const [presetPreview, setPresetPreview] = useState('') + const [presetMeta, setPresetMeta] = useState<{ cacheKey?: string; etag?: string; retrievedAt?: string; source?: string } | null>(null) + const [presetStatusMessage, setPresetStatusMessage] = useState(null) + const [hubUnavailable, setHubUnavailable] = useState(false) + const [validationError, setValidationError] = useState(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() {

CrowdSec Configuration

-
-
-

Mode

-
- -
- -
-
- {status.crowdsec.mode === 'disabled' && ( -

CrowdSec is disabled

- )} +
+
+

CrowdSec Mode

+

+ {isLocalMode ? 'CrowdSec runs locally; disable to pause decisions.' : 'CrowdSec decisions are paused; enable to resume local protection.'} +

-
- +
+ Disabled + handleModeToggle(e.target.checked)} + disabled={updateModeMutation.isPending} + data-testid="crowdsec-mode-toggle" + /> + Local
-

Import Configuration

- setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" /> -
- +
+

Configuration Packages

+
+ + +
+

Import or export CrowdSec configuration packages. A backup is created before imports.

+ setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" /> +
+ + + +
+
+
+

CrowdSec Presets

+

Select a curated preset, preview it, then apply with an automatic backup.

+
+
+ + + +
+
+ + {validationError && ( +

{validationError}

+ )} + + {presetStatusMessage && ( +

{presetStatusMessage}

+ )} + + {hubUnavailable && ( +
+ Hub unreachable. Retry pull or load cached copy if available. + + {selectedPreset?.cached && ( + + )} +
+ )} + + {selectedPreset && ( +
+
+

{selectedPreset.title}

+

{selectedPreset.description}

+ {selectedPreset.warning && ( +

{selectedPreset.warning}

+ )} +

Target file: {selectedPath ?? 'Select a file below (used for local fallback)'}

+
+ {presetMeta && ( +
+ Cache key: {presetMeta.cacheKey || '—'} + Etag: {presetMeta.etag || '—'} + Source: {presetMeta.source || selectedPreset.source || '—'} + Fetched: {presetMeta.retrievedAt ? new Date(presetMeta.retrievedAt).toLocaleString() : '—'} +
+ )} +
+

Preset preview (YAML)

+
+                  {presetPreview || selectedPreset.content || 'Preview unavailable. Pull from hub or use cached copy.'}
+                
+
+ + {applyInfo && ( +
+

Status: {applyInfo.status || 'applied'}

+ {applyInfo.backup &&

Backup: {applyInfo.backup}

} + {applyInfo.reloadHint &&

Reload: {applyInfo.reloadHint}

} + {applyInfo.usedCscli !== undefined &&

Method: {applyInfo.usedCscli ? 'cscli' : 'filesystem'}

} +
+ )} + +
+ {selectedPreset.cached && ( + + )} + {selectedPresetRequiresHub && hubUnavailable && ( + Apply disabled while hub is offline. + )} +
+
+ )} + + {presetCatalog.length === 0 && ( +

No presets available. Ensure Cerberus is enabled.

+ )}
@@ -206,7 +570,12 @@ export default function CrowdSecConfig() {

Edit Configuration Files

- handleReadFile(e.target.value)} + data-testid="crowdsec-file-select" + > {listMutation.data?.files?.map((f) => ( diff --git a/frontend/src/pages/ImportCrowdSec.tsx b/frontend/src/pages/ImportCrowdSec.tsx index be733970..b6c34cbf 100644 --- a/frontend/src/pages/ImportCrowdSec.tsx +++ b/frontend/src/pages/ImportCrowdSec.tsx @@ -47,11 +47,11 @@ export default function ImportCrowdSec() { return (
-

Import CrowdSec

+

CrowdSec Configuration Packages

-

Upload a tar.gz or zip with your CrowdSec configuration. A backup will be created before importing.

- +

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.

+
diff --git a/frontend/src/pages/Security.tsx b/frontend/src/pages/Security.tsx index dafe5e72..9c74c89e 100644 --- a/frontend/src/pages/Security.tsx +++ b/frontend/src/pages/Security.tsx @@ -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
Failed to load security status
} + 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() {
-

Security Suite Disabled

+

Cerberus Disabled

- 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.

- - - - -
- )} +
+ + + +
diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx index bfef1290..373b22ab 100644 --- a/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx +++ b/frontend/src/pages/__tests__/CrowdSecConfig.spec.tsx @@ -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() 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() 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() + 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() + + 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() + + 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() + + 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') + }) }) diff --git a/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx b/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx new file mode 100644 index 00000000..a3bbb7de --- /dev/null +++ b/frontend/src/pages/__tests__/CrowdSecConfig.test.tsx @@ -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( + + + + + + ) + } + + 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() + }) +}) diff --git a/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx b/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx new file mode 100644 index 00000000..db4308c3 --- /dev/null +++ b/frontend/src/pages/__tests__/ImportCrowdSec.test.tsx @@ -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( + + + + + + ) + } + + 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') + }) + }) +}) diff --git a/frontend/src/pages/__tests__/Security.audit.test.tsx b/frontend/src/pages/__tests__/Security.audit.test.tsx index af79c009..5e1719df 100644 --- a/frontend/src/pages/__tests__/Security.audit.test.tsx +++ b/frontend/src/pages/__tests__/Security.audit.test.tsx @@ -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()) }) }) }) diff --git a/frontend/src/pages/__tests__/Security.spec.tsx b/frontend/src/pages/__tests__/Security.spec.tsx index aba8b842..30c6acdb 100644 --- a/frontend/src/pages/__tests__/Security.spec.tsx +++ b/frontend/src/pages/__tests__/Security.spec.tsx @@ -63,7 +63,7 @@ describe('Security page', () => { } as SecurityStatus) renderWithProviders() - 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() - 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() - 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() - 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() - 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() - 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() - 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() - 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() diff --git a/frontend/src/pages/__tests__/Security.test.tsx b/frontend/src/pages/__tests__/Security.test.tsx index 4b2fe7f3..4e2baddf 100644 --- a/frontend/src/pages/__tests__/Security.test.tsx +++ b/frontend/src/pages/__tests__/Security.test.tsx @@ -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()) }) diff --git a/frontend/src/utils/crowdsecExport.ts b/frontend/src/utils/crowdsecExport.ts new file mode 100644 index 00000000..b07fcfdf --- /dev/null +++ b/frontend/src/utils/crowdsecExport.ts @@ -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) +}