Merge branch 'development' into renovate/golang.org-x-crypto-0.x
This commit is contained in:
@@ -14,18 +14,20 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
|
||||
- `Planning`: The Architect. (Delegate research & planning here).
|
||||
- `Backend_Dev`: The Engineer. (Delegate Go implementation here).
|
||||
- `Frontend_Dev`: The Designer. (Delegate React implementation here).
|
||||
- `QA_Security`: The Auditor. (Delegate verification here).
|
||||
- `QA_Security`: The Auditor. (Delegate verification and testing here).
|
||||
- `Docs_Writer`: The Scribe. (Delegate docs here).
|
||||
- `DevOps`: The Packager. (Delegate CI/CD and infrastructure here).
|
||||
</global_context>
|
||||
|
||||
<workflow>
|
||||
1. **Phase 1: Assessment & Delegation (NO RESEARCH)**:
|
||||
1. **Phase 1: Assessment and Delegation**:
|
||||
- **Read Instructions**: Read `.github/copilot-instructions.md`.
|
||||
- **Identify Goal**: Understand the user's request.
|
||||
- **STOP**: Do not look at the code. Do not run `list_dir`.
|
||||
- **STOP**: Do not look at the code. Do not run `list_dir`. No code is to be changed or implemented until there is a fundamentally sound plan of action that has been approved by the user.
|
||||
- **Action**: Immediately call `Planning` subagent.
|
||||
- *Prompt*: "Research the necessary files for '{user_request}' and write a comprehensive plan detailing as many specifics as possible to `docs/plans/current_spec.md`. Be an artist with directions and discriptions. Include file names, function names, and component names wherever possible."
|
||||
|
||||
- *Prompt*: "Research the necessary files for '{user_request}' and write a comprehensive plan detailing as many specifics as possible to `docs/plans/current_spec.md`. Be an artist with directions and discriptions. Include file names, function names, and component names wherever possible. Break the plan into phases based on the least amount of requests. Review and suggest updaetes to `.gitignore`, `codecove.yml`, `.dockerignore`, and `Dockerfile` if necessary. Return only when the plan is complete."
|
||||
- **Task Specifics**:
|
||||
- If the task is to just run tests or audits, there is no need for a plan. Directly call `QA_Security` to perform the tests and write the report. If issues are found, return to `Planning` for a remediation plan and delegate the fixes to the corresponding subagents.
|
||||
2. **Phase 2: Approval Gate**:
|
||||
- **Read Plan**: Read `docs/plans/current_spec.md` (You are allowed to read Markdown).
|
||||
- **Present**: Summarize the plan to the user.
|
||||
@@ -42,6 +44,9 @@ You are "lazy" in the smartest way possible. You never do what a subordinate can
|
||||
- **Final Report**: Summarize the successful subagent runs.
|
||||
</workflow>
|
||||
|
||||
## DEFENITION OF DONE ##
|
||||
- The Task is not complete until pre-commit, frontend coverage tests, all linting, and security scans pass with zero issues. Leaving this unfinished prevents commit and push. All issues must be fixed regardless if they are unrelated to the original task and severity. This rule must never be skipped. It is non-negotiable anytime any bit of code is added or changed.
|
||||
|
||||
<constraints>
|
||||
- **SOURCE CODE BAN**: You are FORBIDDEN from reading `.go`, `.tsx`, `.ts`, or `.css` files. You may ONLY read `.md` (Markdown) files.
|
||||
- **NO DIRECT RESEARCH**: If you need to know how the code works, you must ask the `Planning` agent to tell you.
|
||||
4
.github/agents/QA_Security.agent.md
vendored
4
.github/agents/QA_Security.agent.md
vendored
@@ -11,6 +11,7 @@ Your job is to act as an ADVERSARY. The Developer says "it works"; your job is t
|
||||
- **Project**: Charon (Reverse Proxy)
|
||||
- **Priority**: Security, Input Validation, Error Handling.
|
||||
- **Tools**: `go test`, `trivy` (if available), pre-commit, manual edge-case analysis.
|
||||
- **Role**: You are the final gatekeeper before code reaches production. Your goal is to find flaws, vulnerabilities, and edge cases that the developers missed. You write tests to prove these issues exist. Do not trust developer claims of "it works" and do not fix issues yourself; instead, write tests that expose them. If code needs to be fixed, report back to the Management agent for rework or directly to the appropriate subagent (Backend_Dev or Frontend_Dev)
|
||||
</context>
|
||||
|
||||
<workflow>
|
||||
@@ -27,7 +28,8 @@ Your job is to act as an ADVERSARY. The Developer says "it works"; your job is t
|
||||
- **Path Verification**: Run `list_dir internal/api` to verify where tests should go.
|
||||
- **Creation**: Write a new test file (e.g., `internal/api/tests/audit_test.go`) to test the *flow*.
|
||||
- **Run**: Execute `go test ./internal/api/tests/...` (or specific path). Run local CodeQL and Trivy scans (they are built as VS Code Tasks so they just need to be triggered to run), pre-commit all files, and triage any findings.
|
||||
- Always run run GolangCI-Lint in docker to ensure consistent linting.
|
||||
- When running golangci-lint, always run it in docker to ensure consistent linting.
|
||||
- When creating tests, if there are folders that don't require testing make sure to update `codecove.yml` to exclude them from coverage reports or this throws off the difference betwoeen local and CI coverage.
|
||||
- **Cleanup**: If the test was temporary, delete it. If it's valuable, keep it.
|
||||
</workflow>
|
||||
|
||||
|
||||
18
Dockerfile
18
Dockerfile
@@ -177,18 +177,22 @@ RUN mkdir -p /app/data/geoip && \
|
||||
# Copy Caddy binary from caddy-builder (overwriting the one from base image)
|
||||
COPY --from=caddy-builder /usr/bin/caddy /usr/bin/caddy
|
||||
|
||||
# Install CrowdSec binary (default version can be overridden at build time)
|
||||
ARG CROWDSEC_VERSION=1.6.0
|
||||
# Install CrowdSec binary and CLI (default version can be overridden at build time)
|
||||
ARG CROWDSEC_VERSION=1.7.4
|
||||
# hadolint ignore=DL3018
|
||||
RUN apk add --no-cache curl tar gzip && \
|
||||
set -eux; \
|
||||
URL="https://github.com/crowdsecurity/crowdsec/releases/download/v${CROWDSEC_VERSION}/crowdsec-v${CROWDSEC_VERSION}-linux-musl.tar.gz"; \
|
||||
URL="https://github.com/crowdsecurity/crowdsec/releases/download/v${CROWDSEC_VERSION}/crowdsec-release.tgz"; \
|
||||
curl -fSL "$URL" -o /tmp/crowdsec.tar.gz && \
|
||||
mkdir -p /tmp/crowdsec && tar -xzf /tmp/crowdsec.tar.gz -C /tmp/crowdsec --strip-components=1 || true; \
|
||||
if [ -f /tmp/crowdsec/crowdsec ]; then \
|
||||
mv /tmp/crowdsec/crowdsec /usr/local/bin/crowdsec && chmod +x /usr/local/bin/crowdsec; \
|
||||
mkdir -p /tmp/crowdsec && tar -xzf /tmp/crowdsec.tar.gz -C /tmp/crowdsec || true; \
|
||||
if [ -f /tmp/crowdsec/crowdsec-v${CROWDSEC_VERSION}/cmd/crowdsec/crowdsec ]; then \
|
||||
mv /tmp/crowdsec/crowdsec-v${CROWDSEC_VERSION}/cmd/crowdsec/crowdsec /usr/local/bin/crowdsec && chmod +x /usr/local/bin/crowdsec; \
|
||||
fi && \
|
||||
rm -rf /tmp/crowdsec /tmp/crowdsec.tar.gz || true
|
||||
if [ -f /tmp/crowdsec/crowdsec-v${CROWDSEC_VERSION}/cmd/crowdsec-cli/cscli ]; then \
|
||||
mv /tmp/crowdsec/crowdsec-v${CROWDSEC_VERSION}/cmd/crowdsec-cli/cscli /usr/local/bin/cscli && chmod +x /usr/local/bin/cscli; \
|
||||
fi && \
|
||||
rm -rf /tmp/crowdsec /tmp/crowdsec.tar.gz && \
|
||||
cscli version
|
||||
|
||||
# Copy Go binary from backend builder
|
||||
COPY --from=backend-builder /app/backend/charon /app/charon
|
||||
|
||||
@@ -3,6 +3,8 @@ CHARON_HTTP_PORT=8080
|
||||
CHARON_DB_PATH=./data/charon.db
|
||||
CHARON_CADDY_ADMIN_API=http://localhost:2019
|
||||
CHARON_CADDY_CONFIG_DIR=./data/caddy
|
||||
# HUB_BASE_URL overrides the CrowdSec hub endpoint used when cscli is unavailable (defaults to https://hub-data.crowdsec.net)
|
||||
# HUB_BASE_URL=https://hub-data.crowdsec.net
|
||||
CERBERUS_SECURITY_CERBERUS_ENABLED=false
|
||||
CHARON_SECURITY_CERBERUS_ENABLED=false
|
||||
CPM_SECURITY_CERBERUS_ENABLED=false
|
||||
|
||||
@@ -5,16 +5,20 @@ import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/crowdsec"
|
||||
"github.com/Wikid82/charon/backend/internal/logger"
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -48,18 +52,53 @@ type CrowdsecHandler struct {
|
||||
CmdExec CommandExecutor
|
||||
BinPath string
|
||||
DataDir string
|
||||
Hub *crowdsec.HubService
|
||||
}
|
||||
|
||||
func NewCrowdsecHandler(db *gorm.DB, executor CrowdsecExecutor, binPath, dataDir string) *CrowdsecHandler {
|
||||
cacheDir := filepath.Join(dataDir, "hub_cache")
|
||||
cache, err := crowdsec.NewHubCache(cacheDir, 24*time.Hour)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).Warn("failed to init crowdsec hub cache")
|
||||
}
|
||||
hubSvc := crowdsec.NewHubService(&RealCommandExecutor{}, cache, dataDir)
|
||||
return &CrowdsecHandler{
|
||||
DB: db,
|
||||
Executor: executor,
|
||||
CmdExec: &RealCommandExecutor{},
|
||||
BinPath: binPath,
|
||||
DataDir: dataDir,
|
||||
Hub: hubSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// isCerberusEnabled returns true when Cerberus is enabled via DB or env flag.
|
||||
func (h *CrowdsecHandler) isCerberusEnabled() bool {
|
||||
if h.DB != nil && h.DB.Migrator().HasTable(&models.Setting{}) {
|
||||
var s models.Setting
|
||||
if err := h.DB.Where("key = ?", "feature.cerberus.enabled").First(&s).Error; err == nil {
|
||||
v := strings.ToLower(strings.TrimSpace(s.Value))
|
||||
return v == "true" || v == "1" || v == "yes"
|
||||
}
|
||||
}
|
||||
|
||||
if envVal, ok := os.LookupEnv("FEATURE_CERBERUS_ENABLED"); ok {
|
||||
if b, err := strconv.ParseBool(envVal); err == nil {
|
||||
return b
|
||||
}
|
||||
return envVal == "1"
|
||||
}
|
||||
|
||||
if envVal, ok := os.LookupEnv("CERBERUS_ENABLED"); ok {
|
||||
if b, err := strconv.ParseBool(envVal); err == nil {
|
||||
return b
|
||||
}
|
||||
return envVal == "1"
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Start starts the CrowdSec process.
|
||||
func (h *CrowdsecHandler) Start(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
@@ -326,6 +365,214 @@ func (h *CrowdsecHandler) WriteFile(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "written", "backup": backupDir})
|
||||
}
|
||||
|
||||
// ListPresets returns the curated preset catalog when Cerberus is enabled.
|
||||
func (h *CrowdsecHandler) ListPresets(c *gin.Context) {
|
||||
if !h.isCerberusEnabled() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
type presetInfo struct {
|
||||
crowdsec.Preset
|
||||
Available bool `json:"available"`
|
||||
Cached bool `json:"cached"`
|
||||
CacheKey string `json:"cache_key,omitempty"`
|
||||
Etag string `json:"etag,omitempty"`
|
||||
RetrievedAt *time.Time `json:"retrieved_at,omitempty"`
|
||||
}
|
||||
|
||||
result := map[string]*presetInfo{}
|
||||
for _, p := range crowdsec.ListCuratedPresets() {
|
||||
cp := p
|
||||
result[p.Slug] = &presetInfo{Preset: cp, Available: true}
|
||||
}
|
||||
|
||||
// Merge hub index when available
|
||||
if h.Hub != nil {
|
||||
ctx := c.Request.Context()
|
||||
if idx, err := h.Hub.FetchIndex(ctx); err == nil {
|
||||
for _, item := range idx.Items {
|
||||
slug := strings.TrimSpace(item.Name)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := result[slug]; !ok {
|
||||
result[slug] = &presetInfo{Preset: crowdsec.Preset{
|
||||
Slug: slug,
|
||||
Title: item.Title,
|
||||
Summary: item.Description,
|
||||
Source: "hub",
|
||||
Tags: []string{item.Type},
|
||||
RequiresHub: true,
|
||||
}, Available: true}
|
||||
} else {
|
||||
result[slug].Available = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.Log().WithError(err).Warn("crowdsec hub index unavailable")
|
||||
}
|
||||
}
|
||||
|
||||
// Merge cache metadata
|
||||
if h.Hub != nil && h.Hub.Cache != nil {
|
||||
ctx := c.Request.Context()
|
||||
if cached, err := h.Hub.Cache.List(ctx); err == nil {
|
||||
for _, entry := range cached {
|
||||
if _, ok := result[entry.Slug]; !ok {
|
||||
result[entry.Slug] = &presetInfo{Preset: crowdsec.Preset{Slug: entry.Slug, Title: entry.Slug, Summary: "cached preset", Source: "hub", RequiresHub: true}}
|
||||
}
|
||||
result[entry.Slug].Cached = true
|
||||
result[entry.Slug].CacheKey = entry.CacheKey
|
||||
result[entry.Slug].Etag = entry.Etag
|
||||
if !entry.RetrievedAt.IsZero() {
|
||||
val := entry.RetrievedAt
|
||||
result[entry.Slug].RetrievedAt = &val
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.Log().WithError(err).Warn("crowdsec hub cache list failed")
|
||||
}
|
||||
}
|
||||
|
||||
list := make([]presetInfo, 0, len(result))
|
||||
for _, v := range result {
|
||||
list = append(list, *v)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"presets": list})
|
||||
}
|
||||
|
||||
// PullPreset downloads and caches a hub preset while returning a preview.
|
||||
func (h *CrowdsecHandler) PullPreset(c *gin.Context) {
|
||||
if !h.isCerberusEnabled() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
slug := strings.TrimSpace(payload.Slug)
|
||||
if slug == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "slug required"})
|
||||
return
|
||||
}
|
||||
if h.Hub == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub service unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
res, err := h.Hub.Pull(ctx, slug)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).WithField("slug", slug).Warn("crowdsec preset pull failed")
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "pulled",
|
||||
"slug": res.Meta.Slug,
|
||||
"preview": res.Preview,
|
||||
"cache_key": res.Meta.CacheKey,
|
||||
"etag": res.Meta.Etag,
|
||||
"retrieved_at": res.Meta.RetrievedAt,
|
||||
"source": res.Meta.Source,
|
||||
})
|
||||
}
|
||||
|
||||
// ApplyPreset installs a pulled preset from cache or via cscli.
|
||||
func (h *CrowdsecHandler) ApplyPreset(c *gin.Context) {
|
||||
if !h.isCerberusEnabled() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
||||
return
|
||||
}
|
||||
|
||||
slug := strings.TrimSpace(payload.Slug)
|
||||
if slug == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "slug required"})
|
||||
return
|
||||
}
|
||||
if h.Hub == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub service unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
res, err := h.Hub.Apply(ctx, slug)
|
||||
if err != nil {
|
||||
logger.Log().WithError(err).WithField("slug", slug).Warn("crowdsec preset apply failed")
|
||||
if h.DB != nil {
|
||||
_ = h.DB.Create(&models.CrowdsecPresetEvent{Slug: slug, Action: "apply", Status: "failed", CacheKey: res.CacheKey, BackupPath: res.BackupPath, Error: err.Error()}).Error
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "backup": res.BackupPath})
|
||||
return
|
||||
}
|
||||
|
||||
if h.DB != nil {
|
||||
status := res.Status
|
||||
if status == "" {
|
||||
status = "applied"
|
||||
}
|
||||
slugVal := res.AppliedPreset
|
||||
if slugVal == "" {
|
||||
slugVal = slug
|
||||
}
|
||||
_ = h.DB.Create(&models.CrowdsecPresetEvent{Slug: slugVal, Action: "apply", Status: status, CacheKey: res.CacheKey, BackupPath: res.BackupPath}).Error
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": res.Status,
|
||||
"backup": res.BackupPath,
|
||||
"reload_hint": res.ReloadHint,
|
||||
"used_cscli": res.UsedCSCLI,
|
||||
"cache_key": res.CacheKey,
|
||||
"slug": res.AppliedPreset,
|
||||
})
|
||||
}
|
||||
|
||||
// GetCachedPreset returns cached preview for a slug when available.
|
||||
func (h *CrowdsecHandler) GetCachedPreset(c *gin.Context) {
|
||||
if !h.isCerberusEnabled() {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cerberus disabled"})
|
||||
return
|
||||
}
|
||||
if h.Hub == nil || h.Hub.Cache == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "hub cache unavailable"})
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
slug := strings.TrimSpace(c.Param("slug"))
|
||||
if slug == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "slug required"})
|
||||
return
|
||||
}
|
||||
preview, err := h.Hub.Cache.LoadPreview(ctx, slug)
|
||||
if err != nil {
|
||||
if errors.Is(err, crowdsec.ErrCacheMiss) || errors.Is(err, crowdsec.ErrCacheExpired) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "cache miss"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
meta, _ := h.Hub.Cache.Load(ctx, slug)
|
||||
c.JSON(http.StatusOK, gin.H{"preview": preview, "cache_key": meta.CacheKey, "etag": meta.Etag})
|
||||
}
|
||||
|
||||
// CrowdSecDecision represents a ban decision from CrowdSec
|
||||
type CrowdSecDecision struct {
|
||||
ID int64 `json:"id"`
|
||||
@@ -479,6 +726,10 @@ func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) {
|
||||
rg.GET("/admin/crowdsec/files", h.ListFiles)
|
||||
rg.GET("/admin/crowdsec/file", h.ReadFile)
|
||||
rg.POST("/admin/crowdsec/file", h.WriteFile)
|
||||
rg.GET("/admin/crowdsec/presets", h.ListPresets)
|
||||
rg.POST("/admin/crowdsec/presets/pull", h.PullPreset)
|
||||
rg.POST("/admin/crowdsec/presets/apply", h.ApplyPreset)
|
||||
rg.GET("/admin/crowdsec/presets/cache/:slug", h.GetCachedPreset)
|
||||
// Decision management endpoints (Banned IP Dashboard)
|
||||
rg.GET("/admin/crowdsec/decisions", h.ListDecisions)
|
||||
rg.POST("/admin/crowdsec/ban", h.BanIP)
|
||||
|
||||
@@ -221,6 +221,8 @@ func TestCrowdsec_ExportConfig_NotFound(t *testing.T) {
|
||||
os.RemoveAll(nonExistentDir) // Make sure it doesn't exist
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", nonExistentDir)
|
||||
// remove any cache dir created during handler init so Export sees missing dir
|
||||
_ = os.RemoveAll(nonExistentDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
@@ -360,3 +362,95 @@ func TestCrowdsec_WriteFile_Success(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "new content", string(content))
|
||||
}
|
||||
|
||||
func TestCrowdsec_ListPresets_Disabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func TestCrowdsec_ListPresets_Success(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
assert.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
presets, ok := resp["presets"].([]interface{})
|
||||
assert.True(t, ok)
|
||||
assert.Greater(t, len(presets), 0)
|
||||
}
|
||||
|
||||
func TestCrowdsec_PullPreset_Validation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
||||
h.Hub = nil // simulate hub unavailable
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader([]byte("{}")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/pull", bytes.NewReader([]byte(`{"slug":"demo"}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
}
|
||||
|
||||
func TestCrowdsec_ApplyPreset_Validation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", tmpDir)
|
||||
h.Hub = nil
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader([]byte("{}")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
|
||||
w = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/presets/apply", bytes.NewReader([]byte(`{"slug":"demo"}`)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wikid82/charon/backend/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -225,6 +232,46 @@ func TestListAndReadFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportConfigStreamsArchive(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
dataDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte("hello"), 0o644))
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", dataDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/export", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "application/gzip", w.Header().Get("Content-Type"))
|
||||
require.Contains(t, w.Header().Get("Content-Disposition"), "crowdsec-config-")
|
||||
|
||||
gr, err := gzip.NewReader(bytes.NewReader(w.Body.Bytes()))
|
||||
require.NoError(t, err)
|
||||
tr := tar.NewReader(gr)
|
||||
found := false
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
require.NoError(t, err)
|
||||
if hdr.Name == "config.yaml" {
|
||||
data, readErr := io.ReadAll(tr)
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, "hello", string(data))
|
||||
found = true
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected exported archive to contain config file")
|
||||
}
|
||||
|
||||
func TestWriteFileCreatesBackup(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := setupCrowdDB(t)
|
||||
@@ -251,20 +298,224 @@ func TestWriteFileCreatesBackup(t *testing.T) {
|
||||
t.Fatalf("write expected 200 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// ensure backup directory exists next to data dir
|
||||
found := false
|
||||
entries, _ := os.ReadDir(filepath.Dir(tmpDir))
|
||||
// ensure backup directory was created
|
||||
entries, err := os.ReadDir(filepath.Dir(tmpDir))
|
||||
require.NoError(t, err)
|
||||
foundBackup := false
|
||||
for _, e := range entries {
|
||||
if e.IsDir() && filepath.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") {
|
||||
found = true
|
||||
if e.IsDir() && strings.HasPrefix(e.Name(), filepath.Base(tmpDir)+".backup.") {
|
||||
foundBackup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected backup directory next to data dir")
|
||||
}
|
||||
// ensure file content exists in new data dir
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "conf.d", "new.conf")); err != nil {
|
||||
t.Fatalf("expected file written: %v", err)
|
||||
require.True(t, foundBackup, "expected backup directory to be created")
|
||||
}
|
||||
|
||||
func TestListPresetsCerberusDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("FEATURE_CERBERUS_ENABLED", "false")
|
||||
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 when cerberus disabled got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadFileInvalidPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/file?path=../secret", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid path got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileInvalidPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"path": "../../escape", "content": "bad"})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for invalid path got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteFileMissingPath(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"content": "data only"})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestWriteFileInvalidPayload(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/file", bytes.NewBufferString("not-json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
}
|
||||
|
||||
func TestImportConfigRequiresFile(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 when file missing got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportConfigRejectsEmptyUpload(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
mw := multipart.NewWriter(buf)
|
||||
_, _ = mw.CreateFormFile("file", "empty.tgz")
|
||||
_ = mw.Close()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/crowdsec/import", buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for empty upload got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilesMissingDir(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
missingDir := filepath.Join(t.TempDir(), "does-not-exist")
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", missingDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for missing dir got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListFilesReturnsEntries(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
dataDir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "root.txt"), []byte("root"), 0o644))
|
||||
nestedDir := filepath.Join(dataDir, "nested")
|
||||
require.NoError(t, os.MkdirAll(nestedDir, 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(nestedDir, "child.txt"), []byte("child"), 0o644))
|
||||
|
||||
h := NewCrowdsecHandler(OpenTestDB(t), &fakeExec{}, "/bin/false", dataDir)
|
||||
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/files", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Files []string `json:"files"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
require.ElementsMatch(t, []string{"root.txt", filepath.Join("nested", "child.txt")}, resp.Files)
|
||||
}
|
||||
|
||||
func TestIsCerberusEnabledFromDB(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := OpenTestDB(t)
|
||||
require.NoError(t, db.AutoMigrate(&models.Setting{}))
|
||||
require.NoError(t, db.Create(&models.Setting{Key: "feature.cerberus.enabled", Value: "0"}).Error)
|
||||
|
||||
h := NewCrowdsecHandler(db, &fakeExec{}, "/bin/false", t.TempDir())
|
||||
r := gin.New()
|
||||
g := r.Group("/api/v1")
|
||||
h.RegisterRoutes(g)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/presets", http.NoBody)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404 when cerberus disabled via DB got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCerberusEnabledInvalidEnv(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Setenv("FEATURE_CERBERUS_ENABLED", "not-a-bool")
|
||||
h := NewCrowdsecHandler(nil, &fakeExec{}, "/bin/false", t.TempDir())
|
||||
|
||||
if h.isCerberusEnabled() {
|
||||
t.Fatalf("expected cerberus to be disabled for invalid env flag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsCerberusEnabledLegacyEnv(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := NewCrowdsecHandler(nil, &fakeExec{}, "/bin/false", t.TempDir())
|
||||
|
||||
t.Setenv("CERBERUS_ENABLED", "0")
|
||||
|
||||
if h.isCerberusEnabled() {
|
||||
t.Fatalf("expected cerberus to be disabled for legacy env flag")
|
||||
}
|
||||
}
|
||||
|
||||
413
backend/internal/api/handlers/crowdsec_presets_handler_test.go
Normal file
413
backend/internal/api/handlers/crowdsec_presets_handler_test.go
Normal file
@@ -0,0 +1,413 @@
|
||||
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)
|
||||
require.Contains(t, w.Body.String(), "cscli unavailable")
|
||||
|
||||
var events []models.CrowdsecPresetEvent
|
||||
require.NoError(t, db.Find(&events).Error)
|
||||
require.Len(t, events, 1)
|
||||
require.Equal(t, "failed", events[0].Status)
|
||||
require.Empty(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")
|
||||
}
|
||||
55
backend/internal/api/middleware/sanitize_test.go
Normal file
55
backend/internal/api/middleware/sanitize_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSanitizeHeaders(t *testing.T) {
|
||||
t.Run("nil headers", func(t *testing.T) {
|
||||
require.Nil(t, SanitizeHeaders(nil))
|
||||
})
|
||||
|
||||
t.Run("redacts sensitive headers", func(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Set("Authorization", "secret")
|
||||
headers.Set("X-Api-Key", "token")
|
||||
headers.Set("Cookie", "sessionid=abc")
|
||||
|
||||
sanitized := SanitizeHeaders(headers)
|
||||
|
||||
require.Equal(t, []string{"<redacted>"}, sanitized["Authorization"])
|
||||
require.Equal(t, []string{"<redacted>"}, sanitized["X-Api-Key"])
|
||||
require.Equal(t, []string{"<redacted>"}, sanitized["Cookie"])
|
||||
})
|
||||
|
||||
t.Run("sanitizes and truncates values", func(t *testing.T) {
|
||||
headers := http.Header{}
|
||||
headers.Add("X-Trace", "line1\nline2\r\t")
|
||||
headers.Add("X-Custom", strings.Repeat("a", 210))
|
||||
|
||||
sanitized := SanitizeHeaders(headers)
|
||||
|
||||
traceValue := sanitized["X-Trace"][0]
|
||||
require.NotContains(t, traceValue, "\n")
|
||||
require.NotContains(t, traceValue, "\r")
|
||||
require.NotContains(t, traceValue, "\t")
|
||||
|
||||
customValue := sanitized["X-Custom"][0]
|
||||
require.Equal(t, 200, len(customValue))
|
||||
require.True(t, strings.HasPrefix(customValue, strings.Repeat("a", 200)))
|
||||
})
|
||||
}
|
||||
|
||||
func TestSanitizePath(t *testing.T) {
|
||||
paddedPath := "/api/v1/resource/" + strings.Repeat("x", 210) + "?token=secret"
|
||||
|
||||
sanitized := SanitizePath(paddedPath)
|
||||
|
||||
require.NotContains(t, sanitized, "?")
|
||||
require.False(t, strings.ContainsAny(sanitized, "\n\r\t"))
|
||||
require.Equal(t, 200, len(sanitized))
|
||||
}
|
||||
@@ -58,6 +58,7 @@ func Register(router *gin.Engine, db *gorm.DB, cfg config.Config) error {
|
||||
&models.SecurityAudit{},
|
||||
&models.SecurityRuleSet{},
|
||||
&models.UserPermittedHost{}, // Join table for user permissions
|
||||
&models.CrowdsecPresetEvent{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("auto migrate: %w", err)
|
||||
}
|
||||
|
||||
243
backend/internal/crowdsec/hub_cache.go
Normal file
243
backend/internal/crowdsec/hub_cache.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCacheMiss = errors.New("cache miss")
|
||||
ErrCacheExpired = errors.New("cache expired")
|
||||
)
|
||||
|
||||
// CachedPreset captures metadata about a pulled preset bundle.
|
||||
type CachedPreset struct {
|
||||
Slug string `json:"slug"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
Etag string `json:"etag"`
|
||||
Source string `json:"source"`
|
||||
RetrievedAt time.Time `json:"retrieved_at"`
|
||||
PreviewPath string `json:"preview_path"`
|
||||
ArchivePath string `json:"archive_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// HubCache persists pulled bundles on disk with TTL-based eviction.
|
||||
type HubCache struct {
|
||||
baseDir string
|
||||
ttl time.Duration
|
||||
nowFn func() time.Time
|
||||
}
|
||||
|
||||
var slugPattern = regexp.MustCompile(`^[A-Za-z0-9./_-]+$`)
|
||||
|
||||
// NewHubCache constructs a cache rooted at baseDir with the provided TTL.
|
||||
func NewHubCache(baseDir string, ttl time.Duration) (*HubCache, error) {
|
||||
if baseDir == "" {
|
||||
return nil, fmt.Errorf("baseDir required")
|
||||
}
|
||||
if err := os.MkdirAll(baseDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create cache dir: %w", err)
|
||||
}
|
||||
return &HubCache{baseDir: baseDir, ttl: ttl, nowFn: time.Now}, nil
|
||||
}
|
||||
|
||||
// Store writes the bundle archive and preview to disk and returns the cache metadata.
|
||||
func (c *HubCache) Store(ctx context.Context, slug, etag, source, preview string, archive []byte) (CachedPreset, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return CachedPreset{}, err
|
||||
}
|
||||
cleanSlug := sanitizeSlug(slug)
|
||||
if cleanSlug == "" {
|
||||
return CachedPreset{}, fmt.Errorf("invalid slug")
|
||||
}
|
||||
dir := filepath.Join(c.baseDir, cleanSlug)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return CachedPreset{}, fmt.Errorf("create slug dir: %w", err)
|
||||
}
|
||||
|
||||
ts := c.nowFn().UTC()
|
||||
cacheKey := fmt.Sprintf("%s-%d", cleanSlug, ts.Unix())
|
||||
|
||||
archivePath := filepath.Join(dir, "bundle.tgz")
|
||||
if err := os.WriteFile(archivePath, archive, 0o640); err != nil {
|
||||
return CachedPreset{}, fmt.Errorf("write archive: %w", err)
|
||||
}
|
||||
previewPath := filepath.Join(dir, "preview.yaml")
|
||||
if err := os.WriteFile(previewPath, []byte(preview), 0o640); err != nil {
|
||||
return CachedPreset{}, fmt.Errorf("write preview: %w", err)
|
||||
}
|
||||
|
||||
meta := CachedPreset{
|
||||
Slug: cleanSlug,
|
||||
CacheKey: cacheKey,
|
||||
Etag: etag,
|
||||
Source: source,
|
||||
RetrievedAt: ts,
|
||||
PreviewPath: previewPath,
|
||||
ArchivePath: archivePath,
|
||||
SizeBytes: int64(len(archive)),
|
||||
}
|
||||
metaPath := filepath.Join(dir, "metadata.json")
|
||||
raw, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return CachedPreset{}, fmt.Errorf("marshal metadata: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(metaPath, raw, 0o640); err != nil {
|
||||
return CachedPreset{}, fmt.Errorf("write metadata: %w", err)
|
||||
}
|
||||
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// Load returns cached preset metadata, enforcing TTL.
|
||||
func (c *HubCache) Load(ctx context.Context, slug string) (CachedPreset, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return CachedPreset{}, err
|
||||
}
|
||||
cleanSlug := sanitizeSlug(slug)
|
||||
if cleanSlug == "" {
|
||||
return CachedPreset{}, fmt.Errorf("invalid slug")
|
||||
}
|
||||
metaPath := filepath.Join(c.baseDir, cleanSlug, "metadata.json")
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return CachedPreset{}, ErrCacheMiss
|
||||
}
|
||||
return CachedPreset{}, err
|
||||
}
|
||||
var meta CachedPreset
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
return CachedPreset{}, fmt.Errorf("unmarshal metadata: %w", err)
|
||||
}
|
||||
|
||||
if c.ttl > 0 && c.nowFn().After(meta.RetrievedAt.Add(c.ttl)) {
|
||||
return CachedPreset{}, ErrCacheExpired
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// LoadPreview returns the preview contents for a cached preset.
|
||||
func (c *HubCache) LoadPreview(ctx context.Context, slug string) (string, error) {
|
||||
meta, err := c.Load(ctx, slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(meta.PreviewPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// List returns cached presets that have not expired.
|
||||
func (c *HubCache) List(ctx context.Context) ([]CachedPreset, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results := make([]CachedPreset, 0)
|
||||
err := filepath.WalkDir(c.baseDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() || d.Name() != "metadata.json" {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(c.baseDir, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
slug := filepath.Dir(rel)
|
||||
meta, err := c.Load(ctx, slug)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
results = append(results, meta)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Evict removes cached data for the given slug.
|
||||
func (c *HubCache) Evict(ctx context.Context, slug string) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
cleanSlug := sanitizeSlug(slug)
|
||||
if cleanSlug == "" {
|
||||
return fmt.Errorf("invalid slug")
|
||||
}
|
||||
return os.RemoveAll(filepath.Join(c.baseDir, cleanSlug))
|
||||
}
|
||||
|
||||
// sanitizeSlug guards against traversal and unsupported characters.
|
||||
func sanitizeSlug(slug string) string {
|
||||
trimmed := strings.TrimSpace(slug)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
cleaned := filepath.Clean(trimmed)
|
||||
cleaned = strings.ReplaceAll(cleaned, "\\", "/")
|
||||
if strings.HasPrefix(cleaned, "..") || strings.Contains(cleaned, string(os.PathSeparator)+"..") || strings.HasPrefix(cleaned, string(os.PathSeparator)) {
|
||||
return ""
|
||||
}
|
||||
if !slugPattern.MatchString(cleaned) {
|
||||
return ""
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// Exists returns true when a non-expired cache entry is present.
|
||||
func (c *HubCache) Exists(ctx context.Context, slug string) bool {
|
||||
if _, err := c.Load(ctx, slug); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Touch updates the timestamp to extend TTL; noop when missing.
|
||||
func (c *HubCache) Touch(ctx context.Context, slug string) error {
|
||||
meta, err := c.Load(ctx, slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
meta.RetrievedAt = c.nowFn().UTC()
|
||||
raw, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
metaPath := filepath.Join(c.baseDir, meta.Slug, "metadata.json")
|
||||
return os.WriteFile(metaPath, raw, 0o640)
|
||||
}
|
||||
|
||||
// Size returns aggregated size of cached archives (best effort).
|
||||
func (c *HubCache) Size(ctx context.Context) int64 {
|
||||
var total int64
|
||||
_ = filepath.WalkDir(c.baseDir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
total += info.Size()
|
||||
return nil
|
||||
})
|
||||
return total
|
||||
}
|
||||
198
backend/internal/crowdsec/hub_cache_test.go
Normal file
198
backend/internal/crowdsec/hub_cache_test.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package crowdsec
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHubCacheStoreLoadAndExpire(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
meta, err := cache.Store(ctx, "crowdsecurity/demo", "etag1", "hub", "preview-text", []byte("archive-bytes"))
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, meta.CacheKey)
|
||||
|
||||
loaded, err := cache.Load(ctx, "crowdsecurity/demo")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, meta.CacheKey, loaded.CacheKey)
|
||||
require.Equal(t, "etag1", loaded.Etag)
|
||||
|
||||
cache.nowFn = func() time.Time { return meta.RetrievedAt.Add(2 * time.Minute) }
|
||||
_, err = cache.Load(ctx, "crowdsecurity/demo")
|
||||
require.ErrorIs(t, err, ErrCacheExpired)
|
||||
}
|
||||
|
||||
func TestHubCacheRejectsBadSlug(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cache.Store(context.Background(), "../bad", "etag", "hub", "preview", []byte("data"))
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = cache.Store(context.Background(), "..\\bad", "etag", "hub", "preview", []byte("data"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHubCacheListAndEvict(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
_, err = cache.Store(ctx, "crowdsecurity/demo", "etag1", "hub", "preview", []byte("data1"))
|
||||
require.NoError(t, err)
|
||||
_, err = cache.Store(ctx, "crowdsecurity/other", "etag2", "hub", "preview", []byte("data2"))
|
||||
require.NoError(t, err)
|
||||
|
||||
entries, err := cache.List(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 2)
|
||||
|
||||
require.NoError(t, cache.Evict(ctx, "crowdsecurity/demo"))
|
||||
entries, err = cache.List(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
require.Equal(t, "crowdsecurity/other", entries[0].Slug)
|
||||
}
|
||||
|
||||
func TestHubCacheTouchUpdatesTTL(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
meta, err := cache.Store(ctx, "crowdsecurity/demo", "etag1", "hub", "preview", []byte("data1"))
|
||||
require.NoError(t, err)
|
||||
|
||||
cache.nowFn = func() time.Time { return meta.RetrievedAt.Add(30 * time.Second) }
|
||||
require.NoError(t, cache.Touch(ctx, "crowdsecurity/demo"))
|
||||
|
||||
cache.nowFn = func() time.Time { return meta.RetrievedAt.Add(2 * time.Minute) }
|
||||
_, err = cache.Load(ctx, "crowdsecurity/demo")
|
||||
require.ErrorIs(t, err, ErrCacheExpired)
|
||||
}
|
||||
|
||||
func TestHubCachePreviewExistsAndSize(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
archive := []byte("archive-bytes-here")
|
||||
_, err = cache.Store(ctx, "crowdsecurity/demo", "etag123", "hub", "preview-content", archive)
|
||||
require.NoError(t, err)
|
||||
|
||||
preview, err := cache.LoadPreview(ctx, "crowdsecurity/demo")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "preview-content", preview)
|
||||
require.True(t, cache.Exists(ctx, "crowdsecurity/demo"))
|
||||
require.GreaterOrEqual(t, cache.Size(ctx), int64(len(archive)))
|
||||
}
|
||||
|
||||
func TestHubCacheExistsHonorsTTL(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
meta, err := cache.Store(ctx, "crowdsecurity/demo", "etag123", "hub", "preview", []byte("data"))
|
||||
require.NoError(t, err)
|
||||
|
||||
cache.nowFn = func() time.Time { return meta.RetrievedAt.Add(3 * time.Second) }
|
||||
require.False(t, cache.Exists(ctx, "crowdsecurity/demo"))
|
||||
}
|
||||
|
||||
func TestSanitizeSlugCases(t *testing.T) {
|
||||
require.Equal(t, "demo/preset", sanitizeSlug(" demo/preset "))
|
||||
require.Equal(t, "", sanitizeSlug("../traverse"))
|
||||
require.Equal(t, "", sanitizeSlug("/abs/path"))
|
||||
require.Equal(t, "", sanitizeSlug("\\windows\\bad"))
|
||||
require.Equal(t, "", sanitizeSlug("bad spaces %"))
|
||||
}
|
||||
|
||||
func TestNewHubCacheRequiresBaseDir(t *testing.T) {
|
||||
_, err := NewHubCache("", time.Hour)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHubCacheTouchMissing(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cache.Touch(context.Background(), "missing")
|
||||
require.ErrorIs(t, err, ErrCacheMiss)
|
||||
}
|
||||
|
||||
func TestHubCacheTouchInvalidSlug(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cache.Touch(context.Background(), "../bad")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHubCacheStoreContextCanceled(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err = cache.Store(ctx, "demo", "etag", "hub", "preview", []byte("data"))
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
|
||||
func TestHubCacheLoadInvalidSlug(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = cache.Load(context.Background(), "../bad")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHubCacheExistsContextCanceled(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
require.False(t, cache.Exists(ctx, "demo"))
|
||||
}
|
||||
|
||||
func TestHubCacheListSkipsExpired(t *testing.T) {
|
||||
cacheDir := t.TempDir()
|
||||
cache, err := NewHubCache(cacheDir, time.Second)
|
||||
require.NoError(t, err)
|
||||
ctx := context.Background()
|
||||
fixed := time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
cache.nowFn = func() time.Time { return fixed }
|
||||
_, err = cache.Store(ctx, "crowdsecurity/demo", "etag", "hub", "preview", []byte("data"))
|
||||
require.NoError(t, err)
|
||||
|
||||
cache.nowFn = func() time.Time { return fixed.Add(3 * time.Second) }
|
||||
entries, err := cache.List(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 0)
|
||||
}
|
||||
|
||||
func TestHubCacheEvictInvalidSlug(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
err = cache.Evict(context.Background(), "../bad")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHubCacheListContextCanceled(t *testing.T) {
|
||||
cache, err := NewHubCache(t.TempDir(), time.Hour)
|
||||
require.NoError(t, err)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err = cache.List(ctx)
|
||||
require.ErrorIs(t, err, context.Canceled)
|
||||
}
|
||||
586
backend/internal/crowdsec/hub_sync.go
Normal file
586
backend/internal/crowdsec/hub_sync.go
Normal file
@@ -0,0 +1,586 @@
|
||||
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 (
|
||||
defaultHubBaseURL = "https://hub-data.crowdsec.net"
|
||||
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 {
|
||||
clientTimeout := 10 * time.Second
|
||||
return &HubService{
|
||||
Exec: exec,
|
||||
Cache: cache,
|
||||
DataDir: dataDir,
|
||||
HTTPClient: newHubHTTPClient(clientTimeout),
|
||||
HubBaseURL: normalizeHubBaseURL(os.Getenv("HUB_BASE_URL")),
|
||||
PullTimeout: clientTimeout,
|
||||
ApplyTimeout: 15 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func newHubHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeHubBaseURL(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return defaultHubBaseURL
|
||||
}
|
||||
return strings.TrimRight(trimmed, "/")
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
target := strings.TrimRight(s.HubBaseURL, "/") + defaultHubIndexPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
|
||||
if err != nil {
|
||||
return HubIndex{}, err
|
||||
}
|
||||
resp, err := s.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return HubIndex{}, fmt.Errorf("fetch hub index: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode >= 300 && resp.StatusCode < 400 {
|
||||
loc := resp.Header.Get("Location")
|
||||
return HubIndex{}, fmt.Errorf("hub index redirect (%d) to %s; install cscli or set HUB_BASE_URL to a JSON hub endpoint", resp.StatusCode, firstNonEmpty(loc, target))
|
||||
}
|
||||
return HubIndex{}, fmt.Errorf("hub index status %d from %s", resp.StatusCode, target)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxArchiveSize))
|
||||
if err != nil {
|
||||
return HubIndex{}, fmt.Errorf("read hub index: %w", err)
|
||||
}
|
||||
ct := strings.ToLower(resp.Header.Get("Content-Type"))
|
||||
if ct != "" && !strings.Contains(ct, "application/json") {
|
||||
if isLikelyHTML(data) {
|
||||
return HubIndex{}, fmt.Errorf("hub index responded with HTML; install cscli or set HUB_BASE_URL to a JSON hub endpoint")
|
||||
}
|
||||
return HubIndex{}, fmt.Errorf("unexpected hub content-type %s; install cscli or set HUB_BASE_URL to a JSON hub endpoint", ct)
|
||||
}
|
||||
var idx HubIndex
|
||||
if err := json.Unmarshal(data, &idx); err != nil {
|
||||
if isLikelyHTML(data) {
|
||||
return HubIndex{}, fmt.Errorf("hub index responded with HTML; install cscli or set HUB_BASE_URL to a JSON hub endpoint")
|
||||
}
|
||||
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(strings.TrimRight(s.HubBaseURL, "/")+defaultHubArchivePath, cleanSlug)
|
||||
}
|
||||
previewURL := entry.PreviewURL
|
||||
if previewURL == "" {
|
||||
previewURL = fmt.Sprintf(strings.TrimRight(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")
|
||||
}
|
||||
applyCtx, cancel := context.WithTimeout(ctx, s.ApplyTimeout)
|
||||
defer cancel()
|
||||
|
||||
result := ApplyResult{AppliedPreset: cleanSlug, Status: "failed"}
|
||||
meta, metaErr := s.loadCacheMeta(applyCtx, cleanSlug)
|
||||
if metaErr == nil {
|
||||
result.CacheKey = meta.CacheKey
|
||||
}
|
||||
hasCS := s.hasCSCLI(applyCtx)
|
||||
if !hasCS && metaErr != nil {
|
||||
msg := "cscli unavailable and no cached preset; pull the preset or install cscli"
|
||||
result.ErrorMessage = msg
|
||||
return result, errors.New(msg)
|
||||
}
|
||||
|
||||
backupPath := filepath.Clean(s.DataDir) + ".backup." + time.Now().Format("20060102-150405")
|
||||
result.BackupPath = backupPath
|
||||
if err := s.backupExisting(backupPath); err != nil {
|
||||
return result, fmt.Errorf("backup: %w", err)
|
||||
}
|
||||
|
||||
// Try cscli first
|
||||
if hasCS {
|
||||
cscliErr := s.runCSCLI(applyCtx, cleanSlug)
|
||||
if cscliErr == nil {
|
||||
result.Status = "applied"
|
||||
result.ReloadHint = true
|
||||
result.UsedCSCLI = true
|
||||
return result, nil
|
||||
}
|
||||
logger.Log().WithField("slug", cleanSlug).WithError(cscliErr).Warn("cscli install failed; attempting cache fallback")
|
||||
}
|
||||
|
||||
if metaErr != nil {
|
||||
_ = s.rollback(backupPath)
|
||||
msg := fmt.Sprintf("load cache: %v", metaErr)
|
||||
result.ErrorMessage = msg
|
||||
return result, errors.New(msg)
|
||||
}
|
||||
|
||||
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 isLikelyHTML(data []byte) bool {
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if len(trimmed) == 0 {
|
||||
return false
|
||||
}
|
||||
lower := bytes.ToLower(trimmed)
|
||||
if bytes.HasPrefix(lower, []byte("<!doctype")) || bytes.HasPrefix(lower, []byte("<html")) {
|
||||
return true
|
||||
}
|
||||
return bytes.Contains(lower, []byte("<html"))
|
||||
}
|
||||
|
||||
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 ""
|
||||
}
|
||||
449
backend/internal/crowdsec/hub_sync_test.go
Normal file
449
backend/internal/crowdsec/hub_sync_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
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 readFixture(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join("testdata", name))
|
||||
require.NoError(t, err)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
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.HubBaseURL = "http://example.com"
|
||||
indexBody := readFixture(t, "hub_index.json")
|
||||
svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.String() == "http://example.com"+defaultHubIndexPath {
|
||||
resp := newResponse(http.StatusOK, indexBody)
|
||||
resp.Header.Set("Content-Type", "application/json")
|
||||
return resp, 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 TestFetchIndexHTTPRejectsRedirect(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) {
|
||||
resp := newResponse(http.StatusMovedPermanently, "")
|
||||
resp.Header.Set("Location", "https://hub.crowdsec.net/")
|
||||
return resp, nil
|
||||
})}
|
||||
|
||||
_, err := svc.fetchIndexHTTP(context.Background())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "redirect")
|
||||
}
|
||||
|
||||
func TestFetchIndexHTTPRejectsHTML(t *testing.T) {
|
||||
svc := NewHubService(nil, nil, t.TempDir())
|
||||
htmlBody := readFixture(t, "hub_index_html.html")
|
||||
svc.HTTPClient = &http.Client{Transport: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
|
||||
resp := newResponse(http.StatusOK, htmlBody)
|
||||
resp.Header.Set("Content-Type", "text/html")
|
||||
return resp, nil
|
||||
})}
|
||||
|
||||
_, err := svc.fetchIndexHTTP(context.Background())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "HTML")
|
||||
}
|
||||
|
||||
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)
|
||||
res, err := svc.Apply(context.Background(), "crowdsecurity/demo")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "cscli unavailable")
|
||||
require.Empty(t, res.BackupPath)
|
||||
require.Equal(t, "failed", res.Status)
|
||||
|
||||
content, readErr := os.ReadFile(filepath.Join(dataDir, "keep.txt"))
|
||||
require.NoError(t, readErr)
|
||||
require.Equal(t, "before", string(content))
|
||||
}
|
||||
55
backend/internal/crowdsec/presets.go
Normal file
55
backend/internal/crowdsec/presets.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package crowdsec
|
||||
|
||||
// Preset represents a curated CrowdSec preset offered by Charon.
|
||||
type Preset struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
Source string `json:"source"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
RequiresHub bool `json:"requires_hub"`
|
||||
}
|
||||
|
||||
var curatedPresets = []Preset{
|
||||
{
|
||||
Slug: "honeypot-friendly-defaults",
|
||||
Title: "Honeypot Friendly Defaults",
|
||||
Summary: "Lightweight parser and collection set tuned to reduce noise for tarpits and honeypots.",
|
||||
Source: "charon-curated",
|
||||
Tags: []string{"low-noise", "ssh", "http"},
|
||||
RequiresHub: false,
|
||||
},
|
||||
{
|
||||
Slug: "bot-mitigation-essentials",
|
||||
Title: "Bot Mitigation Essentials",
|
||||
Summary: "Core scenarios for bad bots and credential stuffing with minimal false positives.",
|
||||
Source: "charon-curated",
|
||||
Tags: []string{"bots", "auth", "web"},
|
||||
RequiresHub: false,
|
||||
},
|
||||
{
|
||||
Slug: "geolocation-aware",
|
||||
Title: "Geolocation Aware",
|
||||
Summary: "Adds geo-aware decisions to tighten access by region; best paired with existing ACLs.",
|
||||
Source: "charon-curated",
|
||||
Tags: []string{"geo", "access-control"},
|
||||
RequiresHub: false,
|
||||
},
|
||||
}
|
||||
|
||||
// ListCuratedPresets returns a copy of curated presets to avoid external mutation.
|
||||
func ListCuratedPresets() []Preset {
|
||||
out := make([]Preset, len(curatedPresets))
|
||||
copy(out, curatedPresets)
|
||||
return out
|
||||
}
|
||||
|
||||
// FindPreset returns a preset by slug.
|
||||
func FindPreset(slug string) (Preset, bool) {
|
||||
for _, p := range curatedPresets {
|
||||
if p.Slug == slug {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return Preset{}, false
|
||||
}
|
||||
31
backend/internal/crowdsec/presets_test.go
Normal file
31
backend/internal/crowdsec/presets_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package crowdsec
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestListCuratedPresetsReturnsCopy(t *testing.T) {
|
||||
got := ListCuratedPresets()
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected curated presets, got none")
|
||||
}
|
||||
|
||||
// mutate the copy and ensure originals stay intact on subsequent calls
|
||||
got[0].Title = "mutated"
|
||||
again := ListCuratedPresets()
|
||||
if again[0].Title == "mutated" {
|
||||
t.Fatalf("expected curated presets to be returned as copy, but mutation leaked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPreset(t *testing.T) {
|
||||
preset, ok := FindPreset("honeypot-friendly-defaults")
|
||||
if !ok {
|
||||
t.Fatalf("expected to find curated preset")
|
||||
}
|
||||
if preset.Slug != "honeypot-friendly-defaults" {
|
||||
t.Fatalf("unexpected preset slug %s", preset.Slug)
|
||||
}
|
||||
|
||||
if _, ok := FindPreset("missing"); ok {
|
||||
t.Fatalf("expected missing preset to return ok=false")
|
||||
}
|
||||
}
|
||||
1
backend/internal/crowdsec/testdata/hub_index.json
vendored
Normal file
1
backend/internal/crowdsec/testdata/hub_index.json
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"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"}]}
|
||||
5
backend/internal/crowdsec/testdata/hub_index_html.html
vendored
Normal file
5
backend/internal/crowdsec/testdata/hub_index_html.html
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Moved</title></head>
|
||||
<body><h1>Moved</h1><p>Resource moved.</p></body>
|
||||
</html>
|
||||
16
backend/internal/models/crowdsec_preset_event.go
Normal file
16
backend/internal/models/crowdsec_preset_event.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// CrowdsecPresetEvent captures audit trail for preset pull/apply events.
|
||||
type CrowdsecPresetEvent struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
CacheKey string `json:"cache_key"`
|
||||
BackupPath string `json:"backup_path"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
@@ -20,7 +20,7 @@ Charon includes optional features that can be toggled on or off based on your ne
|
||||
|
||||
#### Cerberus Security Suite
|
||||
- **What it is:** Complete security system including CrowdSec integration, country blocking, WAF protection, and access control
|
||||
- **When enabled:** Security menu appears in sidebar, all protection features are active
|
||||
- **When enabled:** Cerberus/Dashboard entries appear in the sidebar, all protection features are active
|
||||
- **When disabled:** Security menu is hidden, all protection stops, but configuration data is preserved
|
||||
- **Default:** Enabled
|
||||
|
||||
@@ -110,11 +110,11 @@ When you disable a feature:
|
||||
|
||||
## \ud83d\udee1\ufe0f Security (Optional)
|
||||
|
||||
Charon includes **Cerberus**, a security system that blocks bad guys. It's off by default—turn it on when you're ready.
|
||||
Charon includes **Cerberus**, a security system that blocks bad guys. It's off by default—turn it on when you're ready. The main page is the **Cerberus Dashboard** (sidebar: Cerberus → Dashboard).
|
||||
|
||||
### Block Bad IPs Automatically
|
||||
|
||||
**What it does:** CrowdSec watches for attackers and blocks them before they can do damage.
|
||||
**What it does:** CrowdSec watches for attackers and blocks them before they can do damage. The overview now has a single Start/Stop toggle—no separate mode selector.
|
||||
|
||||
**Why you care:** Someone tries to guess your password 100 times? Blocked automatically.
|
||||
|
||||
@@ -157,6 +157,12 @@ Charon includes **Cerberus**, a security system that blocks bad guys. It's off b
|
||||
- Does NOT replace regular security updates
|
||||
|
||||
**Learn more:** [OWASP Core Rule Set](https://coreruleset.org/)
|
||||
|
||||
### Configuration Packages
|
||||
|
||||
- **Hub presets:** Pull presets from the CrowdSec Hub over HTTPS, use cache keys/ETags for faster repeat pulls, preview changes, then apply with an automatic backup and reload flag. Requires Cerberus to be enabled with admin scope; `cscli` is preferred for execution.
|
||||
- **Offline/curated:** If the Hub is unreachable or apply is not supported, curated/offline presets remain available.
|
||||
- **Validation:** Slugs are validated before apply. Hub errors surface cleanly (503 uses retry or cached data; 400 for bad slugs; apply failures prompt you to restore from the backup).
|
||||
---
|
||||
|
||||
## \ud83d\udc33 Docker Integration
|
||||
|
||||
@@ -1,456 +1,107 @@
|
||||
# VS Code Go Troubleshooting Guide & Automations — Current Spec
|
||||
|
||||
## Overview
|
||||
|
||||
This document defines a focused implementation plan to add a concise VS Code Go troubleshooting guide and small automations to the Charon repository to address persistent Go compiler errors caused by gopls or workspace misconfiguration. The scope is limited to developer tooling, diagnostics, and safe automation changes that live in `docs/`, `scripts/`, and `.vscode/` (no production code changes). Implementation must respect the repository's architecture rules in .github/copilot-instructions.md (backend in `backend/`, frontend in `frontend/`, no Python).
|
||||
|
||||
## Goals / Acceptance Criteria
|
||||
|
||||
- Provide reproducible steps that make `go build ./...` succeed in the repo for contributors.
|
||||
- Provide easy-to-run tasks and scripts that surface common misconfigurations (missing modules, GOPATH issues, gopls misbehavior).
|
||||
- Provide VS Code settings and tasks so the `Go` extension/gopls behaves reliably for this repo layout.
|
||||
- Provide CI and pre-commit recommendations to prevent regressions.
|
||||
- Provide QA checklist that verifies build, language-server behavior, and CI integration.
|
||||
|
||||
Acceptance Criteria (testable):
|
||||
- Running `./scripts/check_go_build.sh` from repo root in a clean dev environment returns exit code 0 and prints "BUILD_OK".
|
||||
- `cd backend && go build ./...` returns exit code 0 locally on a standard Linux environment with Go installed.
|
||||
- VS Code: running the `Go: Restart Language Server` command after applying `.vscode/settings.json` and `.vscode/tasks.json` clears gopls editor errors (no stale compiler errors remain in Problems panel for valid code).
|
||||
- A dedicated GH Actions job runs `cd backend && go test ./...` and `go build ./...` and returns success in CI.
|
||||
|
||||
## Files to Inspect & Modify (exact paths)
|
||||
|
||||
- docs/plans/current_spec.md (this file)
|
||||
- docs/troubleshooting/go-gopls.md (new — guidance + logs collection)
|
||||
- .vscode/tasks.json (new)
|
||||
- .vscode/settings.json (new)
|
||||
- scripts/check_go_build.sh (new)
|
||||
- scripts/gopls_collect.sh (new)
|
||||
- Makefile (suggested additions at root)
|
||||
- .github/workflows/ci-go.yml (suggested CI job snippet — add or integrate into existing CI)
|
||||
- .pre-commit-config.yaml (suggested update to add a hook calling `scripts/check_go_build.sh`)
|
||||
- backend/go.mod, backend/go.work, backend/** (inspect for module path and replace directives)
|
||||
- backend/cmd/api (inspect build entrypoint)
|
||||
- backend/internal/server (inspect server mount and attachFrontend logic)
|
||||
- backend/internal/config (inspect env handling for CHARON_* variables)
|
||||
- backend/internal/models (inspect for heavy imports that may cause build issues)
|
||||
- frontend/.vscode (ensure no conflicting workspace settings in frontend)
|
||||
- go.work (workspace-level module directives)
|
||||
|
||||
If function-level inspection is needed, likely candidates:
|
||||
- `/projects/Charon/backend/cmd/api/main.go` or `/projects/Charon/backend/cmd/api/*.go` (entrypoint)
|
||||
- `/projects/Charon/backend/internal/api/routes/routes.go` (AutoMigrate, router mounting)
|
||||
|
||||
Do NOT change production code unless the cause is strictly workspace/config related and low risk. Prefer documenting and instrumenting.
|
||||
|
||||
## Proposed Implementation (step-by-step)
|
||||
|
||||
1. Create `docs/troubleshooting/go-gopls.md` describing how to reproduce, collect logs, and file upstream issues. (Minimal doc — see Templates below.)
|
||||
|
||||
2. Add `.vscode/settings.json` and `.vscode/tasks.json` to the repo root to standardize developer tools. These settings will scope to the workspace and will not affect CI.
|
||||
|
||||
3. Create `scripts/check_go_build.sh` that runs reproducible checks: `go version`, `go env`, `go list -mod=mod`, `go build ./...` in `backend/`, and prints diagnostic info if the build fails.
|
||||
|
||||
4. Create `scripts/gopls_collect.sh` to collect `gopls` logs with `-rpc.trace` and instruct developers how to attach those logs when filing upstream issues.
|
||||
|
||||
5. Add a small Makefile target for dev convenience: `make go-check` -> runs `scripts/check_go_build.sh` and `make gopls-logs` -> runs `scripts/gopls_collect.sh`.
|
||||
|
||||
6. Add a recommended GH Actions job snippet to run `go test` and `go build` for `backend/`. Add this to `.github/workflows/ci-go.yml` or integrate into the existing CI workflow.
|
||||
|
||||
7. Add a sample `pre-commit` hook entry invoking `scripts/check_go_build.sh` (optional, manual-stage hook recommended rather than blocking commit for every contributor).
|
||||
|
||||
8. Add `docs/troubleshooting/go-gopls.md` acceptance checklist and QA test cases.
|
||||
|
||||
9. Communicate rollout plan and document how to revert the automation files (simple revert PR).
|
||||
|
||||
## VS Code Tasks and Settings
|
||||
|
||||
Place these files under `.vscode/` in the repository root. They are workspace recommendations a developer can accept when opening the workspace.
|
||||
|
||||
1) .vscode/tasks.json
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Go: Build Backend",
|
||||
"type": "shell",
|
||||
"command": "bash",
|
||||
"args": ["-lc", "cd backend && go build ./..."],
|
||||
"group": { "kind": "build", "isDefault": true },
|
||||
"presentation": { "reveal": "always", "panel": "shared" },
|
||||
"problemMatcher": ["$go"]
|
||||
},
|
||||
{
|
||||
"label": "Go: Test Backend",
|
||||
"type": "shell",
|
||||
"command": "bash",
|
||||
"args": ["-lc", "cd backend && go test ./... -v"],
|
||||
"group": "test",
|
||||
"presentation": { "reveal": "always", "panel": "shared" }
|
||||
},
|
||||
{
|
||||
"label": "Go: Mod Tidy (Backend)",
|
||||
"type": "shell",
|
||||
"command": "bash",
|
||||
"args": ["-lc", "cd backend && go mod tidy"],
|
||||
"presentation": { "reveal": "silent", "panel": "shared" }
|
||||
},
|
||||
{
|
||||
"label": "Gather gopls logs",
|
||||
"type": "shell",
|
||||
"command": "bash",
|
||||
"args": ["-lc", "./scripts/gopls_collect.sh"],
|
||||
"presentation": { "reveal": "always", "panel": "new" }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
2) .vscode/settings.json
|
||||
|
||||
```json
|
||||
{
|
||||
"go.useLanguageServer": true,
|
||||
"gopls": {
|
||||
"staticcheck": true,
|
||||
"analyses": {
|
||||
"unusedparams": true,
|
||||
"nilness": true
|
||||
},
|
||||
"completeUnimported": true,
|
||||
"matcher": "Fuzzy",
|
||||
"verboseOutput": true
|
||||
},
|
||||
"go.toolsEnvVars": {
|
||||
"GOMODCACHE": "${workspaceFolder}/.cache/go/pkg/mod"
|
||||
},
|
||||
"go.buildOnSave": "workspace",
|
||||
"go.lintOnSave": "package",
|
||||
"go.formatTool": "gofmt",
|
||||
"files.watcherExclude": {
|
||||
"**/backend/data/**": true,
|
||||
"**/frontend/dist/**": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes on settings:
|
||||
- `gopls.verboseOutput` will allow VS Code Output panel to show richer logs for triage.
|
||||
- `GOMODCACHE` is set to workspace-local to prevent unexpected GOPATH/GOMOD cache interference on some dev machines; change if undesired.
|
||||
|
||||
## Scripts to Add
|
||||
|
||||
Create `scripts/check_go_build.sh` and `scripts/gopls_collect.sh` with the contents below. Make both executable (`chmod +x scripts/*.sh`).
|
||||
|
||||
1) scripts/check_go_build.sh
|
||||
|
||||
```sh
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
echo "[charon] repo root: $ROOT_DIR"
|
||||
|
||||
echo "-- go version --"
|
||||
go version || true
|
||||
|
||||
echo "-- go env --"
|
||||
go env || true
|
||||
|
||||
echo "-- go list (backend) --"
|
||||
cd "$ROOT_DIR/backend"
|
||||
echo "module: $(cat go.mod | sed -n '1p')"
|
||||
go list -deps ./... | wc -l || true
|
||||
|
||||
echo "-- go build backend ./... --"
|
||||
if go build ./...; then
|
||||
echo "BUILD_OK"
|
||||
exit 0
|
||||
else
|
||||
echo "BUILD_FAIL"
|
||||
echo "Run 'cd backend && go build -v ./...' for verbose output"
|
||||
exit 2
|
||||
fi
|
||||
```
|
||||
|
||||
2) scripts/gopls_collect.sh
|
||||
|
||||
```sh
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT_DIR="/tmp/charon-gopls-logs-$(date +%s)"
|
||||
mkdir -p "$OUT_DIR"
|
||||
echo "Collecting gopls debug output to $OUT_DIR"
|
||||
|
||||
if ! command -v gopls >/dev/null 2>&1; then
|
||||
echo "gopls not found in PATH. Install with: go install golang.org/x/tools/gopls@latest"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
cd "$ROOT_DIR/backend"
|
||||
echo "Running: gopls -rpc.trace -v check ./... > $OUT_DIR/gopls.log 2>&1"
|
||||
gopls -rpc.trace -v check ./... > "$OUT_DIR/gopls.log" 2>&1 || true
|
||||
|
||||
echo "Also collecting 'go env' and 'go version'"
|
||||
go version > "$OUT_DIR/go-version.txt" 2>&1 || true
|
||||
go env > "$OUT_DIR/go-env.txt" 2>&1 || true
|
||||
|
||||
echo "Logs collected at: $OUT_DIR"
|
||||
echo "Attach the $OUT_DIR contents when filing issues against golang/vscode-go or gopls."
|
||||
```
|
||||
|
||||
Optional: `Makefile` additions (root `Makefile`):
|
||||
|
||||
```makefile
|
||||
.PHONY: go-check gopls-logs
|
||||
go-check:
|
||||
./scripts/check_go_build.sh
|
||||
|
||||
gopls-logs:
|
||||
./scripts/gopls_collect.sh
|
||||
```
|
||||
|
||||
## CI or Pre-commit Hooks to Run
|
||||
|
||||
CI: Add or update a GitHub Actions job in `.github/workflows/ci-go.yml` (or combine with existing CI):
|
||||
|
||||
```yaml
|
||||
name: Go CI (backend)
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
go-backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.20'
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/go-build
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
- name: Build backend
|
||||
run: |
|
||||
cd backend
|
||||
go version
|
||||
go env
|
||||
go test ./... -v
|
||||
go build ./...
|
||||
|
||||
```
|
||||
|
||||
Pre-commit (optional): add this to `.pre-commit-config.yaml` under `repos:` as a local hook or add a simple script that developers can opt into. Example local hook entry:
|
||||
|
||||
```yaml
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: go-check
|
||||
name: go-check
|
||||
entry: ./scripts/check_go_build.sh
|
||||
language: system
|
||||
stages: [manual]
|
||||
```
|
||||
|
||||
Rationale: run as a `manual` hook to avoid blocking every commit but available for maintainers to run pre-merge.
|
||||
|
||||
## Tests & Validation Steps
|
||||
|
||||
Run these commands locally and in CI as part of acceptance testing.
|
||||
|
||||
1) Basic local verification (developer machine):
|
||||
|
||||
```bash
|
||||
# from repo root
|
||||
./scripts/check_go_build.sh
|
||||
# expected output contains: BUILD_OK and exit code 0
|
||||
|
||||
cd backend
|
||||
go test ./... -v
|
||||
# expected: all tests PASS and exit code 0
|
||||
|
||||
go build ./...
|
||||
# expected: no errors, exit code 0
|
||||
|
||||
# In VS Code: open workspace, accept recommended workspace settings, then
|
||||
# Run Command Palette -> "Go: Restart Language Server"
|
||||
# Open Problems panel: expect no stale gopls errors for otherwise-valid code
|
||||
```
|
||||
|
||||
2) Gather gopls logs (if developer still sees errors):
|
||||
|
||||
```bash
|
||||
./scripts/gopls_collect.sh
|
||||
# expected: prints path to logs and files: gopls.log, go-version.txt, go-env.txt
|
||||
```
|
||||
|
||||
3) CI validation (after adding `ci-go.yml`):
|
||||
|
||||
Push branch, create PR. GitHub Actions should run `Go CI (backend)` job and show green check on success. Expected steps: `go test` and `go build` both pass.
|
||||
|
||||
4) QA Acceptance Checklist (for QA_Security):
|
||||
- `./scripts/check_go_build.sh` returns `BUILD_OK` on a clean environment.
|
||||
- `cd backend && go test ./... -v` yields only PASS/ok lines.
|
||||
- Running `./scripts/gopls_collect.sh` produces a non-empty `gopls.log` file when gopls invoked.
|
||||
- VS Code `Go: Restart Language Server` clears stale errors in Problems panel.
|
||||
- CI job `Go CI (backend)` passes on PR.
|
||||
|
||||
## How to Collect gopls Logs and File an upstream Issue
|
||||
|
||||
1. Reproduce the problem in VS Code.
|
||||
2. Run `./scripts/gopls_collect.sh` and attach the `$OUT_DIR` results.
|
||||
3. If `gopls` is not available, install it locally: `go install golang.org/x/tools/gopls@latest`.
|
||||
4. Include these items in the issue:
|
||||
- A minimal reproduction steps list.
|
||||
- The `gopls.log` produced by `gopls -rpc.trace -v check ./...`.
|
||||
- Output of `go version` and `go env` (from `go-version.txt` and `go-env.txt`).
|
||||
5. File the issue on the `golang/vscode-go` issue tracker (preferred), include logs and reproduction steps.
|
||||
|
||||
Suggested issue title template: "gopls: persistent compiler errors in Charon workspace — [short symptom]"
|
||||
|
||||
## Rollout & Backout Plan
|
||||
|
||||
Rollout steps:
|
||||
|
||||
1. Implement the changes in a feature branch `docs/gopls-troubleshoot`.
|
||||
2. Open a PR describing changes and link to this plan.
|
||||
3. Run CI and verify `Go CI (backend)` passes.
|
||||
4. Merge after 2 approvals.
|
||||
5. Notify contributors in README or developer onboarding that workspace settings and scripts are available.
|
||||
|
||||
Backout steps:
|
||||
|
||||
1. Revert the merge commit or open a PR that removes `.vscode/*`, `scripts/*`, and `docs/troubleshooting/*` files.
|
||||
2. If CI changes were added to `.github/workflows/*`, revert those files as well.
|
||||
|
||||
Notes on safety: these files are developer-tooling only. They do not alter production code or binaries.
|
||||
|
||||
## Estimated Effort (time / complexity)
|
||||
|
||||
- Create docs and scripts: 1–2 hours (low complexity)
|
||||
- Add .vscode tasks/settings and Makefile snippet: 30–60 minutes
|
||||
- Add CI job and test in GH Actions: 1 hour
|
||||
- QA validation and follow-ups: 1–2 hours
|
||||
|
||||
Total: 3–6 hours for a single engineer to implement, test, and land.
|
||||
|
||||
## Notes for Roles
|
||||
|
||||
- Backend_Dev:
|
||||
- Inspect `/projects/Charon/backend/go.mod`, `/projects/Charon/backend/go.work`, `/projects/Charon/backend/cmd/api` and `/projects/Charon/backend/internal/*` for any non-module-safe imports, cgo usage, or platform-specific build tags that might confuse `gopls` or `go build`.
|
||||
- If `go build` fails locally but passes in CI, inspect `go env` differences (GOMODCACHE, GOPATH, GOFLAGS, GO111MODULE). Use `./scripts/check_go_build.sh` to capture environment.
|
||||
|
||||
- Frontend_Dev:
|
||||
- Ensure there are no conflicting workspace `.vscode` files inside `frontend/` that override root workspace settings. If present, move per-project overrides to `frontend/.vscode` and keep common settings in root `.vscode`.
|
||||
|
||||
- QA_Security:
|
||||
- Use the acceptance checklist above. Validate that `go test` and `go build` run cleanly in CI and locally.
|
||||
- Confirm that scripts do not leak secrets (they won't — they only run `go` commands). Confirm scripts are shell-only and do not pull remote binaries without explicit developer action.
|
||||
|
||||
- Docs_Writer:
|
||||
- Create `docs/troubleshooting/go-gopls.md` with background, step-by-step reproduction, and minimal triage guidance. Link to scripts and how to attach logs when filing upstream issues.
|
||||
|
||||
## Example docs/troubleshooting/go-gopls.md (template)
|
||||
|
||||
Create `docs/troubleshooting/go-gopls.md` with this content (starter):
|
||||
|
||||
```
|
||||
# Troubleshooting gopls / VS Code Go errors in Charon
|
||||
|
||||
This page documents how to triage and collect logs for persistent Go errors shown by gopls or VS Code in the Charon repository.
|
||||
|
||||
Steps:
|
||||
1. Open the Charon workspace in VS Code (project root).
|
||||
2. Accept the workspace settings prompt to apply `.vscode/settings.json`.
|
||||
3. Run the workspace task: `Go: Build Backend` (or run `./scripts/check_go_build.sh`).
|
||||
4. If errors persist, run `./scripts/gopls_collect.sh` and attach the output directory to an issue.
|
||||
|
||||
When filing upstream issues, include `gopls.log`, `go-version.txt`, `go-env.txt`, and a short reproduction.
|
||||
|
||||
```
|
||||
|
||||
## Checklist (QA)
|
||||
|
||||
- [ ] `./scripts/check_go_build.sh` exits 0 and prints `BUILD_OK`.
|
||||
- [ ] `cd backend && go test ./... -v` returns all `PASS` results.
|
||||
- [ ] `go build ./...` returns exit code 0.
|
||||
- [ ] VS Code Problems panel shows no stale gopls errors after `Go: Restart Language Server`.
|
||||
- [ ] `./scripts/gopls_collect.sh` produces `gopls.log` containing `rpc.trace` sections.
|
||||
- [ ] CI job `Go CI (backend)` passes on PR.
|
||||
|
||||
## Final Notes
|
||||
|
||||
All proposed files are restricted to developer experience and documentation. Do not modify production source files unless a concrete code-level bug (not tooling) is found and approved by the backend owner.
|
||||
|
||||
If you'd like, I can also open a PR implementing the `.vscode/`, `scripts/`, `docs/troubleshooting/` additions and the CI job snippet. If approved, I will run the repo-level `make go-check` and iterate on any failures.
|
||||
# Plan: Refactor Feature Flags to Optional Features
|
||||
|
||||
## Overview
|
||||
Refactor the existing "Feature Flags" system into a user-friendly "Optional Features" section in System Settings. This involves renaming, consolidating toggles (Cerberus, Uptime), and enforcing behavior (hiding sidebar items, stopping background jobs) when features are disabled.
|
||||
|
||||
## User Requirements
|
||||
1. **Rename**: 'Feature Flags' -> 'Optional Features'.
|
||||
2. **Cerberus**: Move global toggle to 'Optional Features'.
|
||||
3. **Uptime**: Add toggle to 'Optional Features'.
|
||||
4. **Cleanup**: Remove unused flags (`feature.global.enabled`, `feature.notifications.enabled`, `feature.docker.enabled`).
|
||||
5. **Behavior**:
|
||||
- **Default**: Cerberus and Uptime ON.
|
||||
- **OFF State**: Hide from Sidebar, stop background jobs, block notifications.
|
||||
- **Persistence**: Do NOT delete data when disabled.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### 1. Backend Changes
|
||||
|
||||
#### `backend/internal/api/handlers/feature_flags_handler.go`
|
||||
- Update `defaultFlags` list:
|
||||
- Keep: `feature.cerberus.enabled`, `feature.uptime.enabled`
|
||||
- Remove: `feature.global.enabled`, `feature.notifications.enabled`, `feature.docker.enabled`
|
||||
- Ensure defaults are `true` if not set in DB or Env.
|
||||
|
||||
#### `backend/internal/cerberus/cerberus.go`
|
||||
- Update `IsEnabled()` to check `feature.cerberus.enabled` instead of `security.cerberus.enabled`.
|
||||
- Maintain backward compatibility or migrate existing setting if necessary (or just switch to the new key).
|
||||
|
||||
#### `backend/internal/api/routes/routes.go`
|
||||
- **Uptime Background Job**:
|
||||
- In the `go func()` that runs the ticker:
|
||||
- Check `feature.uptime.enabled` before running `uptimeService.CheckAll()`.
|
||||
- If disabled, skip the check.
|
||||
- **Cerberus Middleware**:
|
||||
- The middleware already calls `IsEnabled()`, so updating `cerberus.go` is sufficient.
|
||||
|
||||
### 2. Frontend Changes
|
||||
|
||||
#### `frontend/src/pages/SystemSettings.tsx`
|
||||
- **Rename Card**: Change "Feature Flags" to "Optional Features".
|
||||
- **Consolidate Toggles**:
|
||||
- Remove "Enable Cerberus Security" from "General Configuration".
|
||||
- Render specific toggles for "Cerberus Security" and "Uptime Monitoring" in the "Optional Features" card.
|
||||
- Use `feature.cerberus.enabled` and `feature.uptime.enabled` keys.
|
||||
- Add user-friendly descriptions for each.
|
||||
- **Remove Generic List**: Instead of iterating over all keys, explicitly render the supported optional features to control order and presentation.
|
||||
|
||||
#### `frontend/src/components/Layout.tsx`
|
||||
- **Fetch Flags**: Use `getFeatureFlags` (or a new hook) to get current state.
|
||||
- **Conditional Rendering**:
|
||||
- Hide "Uptime" nav item if `feature.uptime.enabled` is false.
|
||||
- Hide "Security" nav group if `feature.cerberus.enabled` is false.
|
||||
|
||||
### 3. Migration / Data Integrity
|
||||
- Existing `security.cerberus.enabled` setting in DB should be migrated to `feature.cerberus.enabled` or the code should handle the transition.
|
||||
- **Action**: We will switch to `feature.cerberus.enabled`. The user can re-enable it if it defaults to off, but we'll try to default it to ON in the handler.
|
||||
|
||||
## Step-by-Step Execution
|
||||
|
||||
1. **Backend**: Update `feature_flags_handler.go` to clean up flags and set defaults.
|
||||
2. **Backend**: Update `cerberus.go` to use new flag key.
|
||||
3. **Backend**: Update `routes.go` to gate Uptime background job.
|
||||
4. **Frontend**: Update `SystemSettings.tsx` UI.
|
||||
5. **Frontend**: Update `Layout.tsx` sidebar logic.
|
||||
6. **Verify**: Test toggling features and checking sidebar/background behavior.
|
||||
# CrowdSec Hub Presets Sync & Apply Plan (feature/beta-release)
|
||||
|
||||
## 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.
|
||||
|
||||
## Incident Triage: CrowdSec preset pull/apply 502/500 (feature/beta-release)
|
||||
- Logs to pull first: backend app/GIN logs under `/app/data/logs/charon.log` (or `data/logs/charon.log` in dev) via [backend/cmd/api/main.go](backend/cmd/api/main.go); look for warnings "crowdsec preset pull failed" / "crowdsec preset apply failed" emitted in [backend/internal/api/handlers/crowdsec_handler.go](backend/internal/api/handlers/crowdsec_handler.go). Access logs will also show 502/500 for POST `/api/v1/admin/crowdsec/presets/pull` and `/apply`.
|
||||
- Routes and code paths: handlers `PullPreset` and `ApplyPreset` live in [backend/internal/api/handlers/crowdsec_handler.go](backend/internal/api/handlers/crowdsec_handler.go) and delegate to `HubService.Pull/Apply` in [backend/internal/crowdsec/hub_sync.go](backend/internal/crowdsec/hub_sync.go) with cache helpers in [backend/internal/crowdsec/hub_cache.go](backend/internal/crowdsec/hub_cache.go). Data dir used is `data/crowdsec` with cache under `data/crowdsec/hub_cache` from [backend/internal/api/routes/routes.go](backend/internal/api/routes/routes.go).
|
||||
- Quick checks before repro: (1) Cerberus enabled (`feature.cerberus.enabled` setting or `FEATURE_CERBERUS_ENABLED`/`CERBERUS_ENABLED` env) or handler returns 404 early; (2) `cscli` on PATH and executable (`HubService` uses real executor and calls `cscli version`/`cscli hub install`); (3) outbound HTTPS to https://hub.crowdsec.net reachable (fallback after `cscli hub list`); (4) cache dir writable `data/crowdsec/hub_cache` and contains per-slug `metadata.json`, `bundle.tgz`, `preview.yaml`; (5) backup path writable (apply renames `data/crowdsec` to `data/crowdsec.backup.<ts>`).
|
||||
- Likely 502 on pull: hub cache unavailable or init failed (cache dir permission), invalid slug, hub index fetch errors (`cscli hub list -o json` or direct GET `/api/index.json`), download blocked/size >25MiB, preview/download HTTP non-200, or cache write errors. Handler logs warning and returns 502 with error string.
|
||||
- Likely 500 on apply: backup rename fails, `cscli` install fails with no cache fallback (if pull never succeeded or cache expired/missing), cache read errors (`metadata.json`/`bundle.tgz` unreadable), tar extraction rejects symlinks/unsafe paths, or rollback after extract failure. Handler writes `CrowdsecPresetEvent` (if DB reachable) with backup path and returns 500 with `backup` hint.
|
||||
- Validation steps during triage: verify cache entry freshness (TTL 24h) via `metadata.json` timestamps; confirm `cscli hub install <slug>` succeeds manually; if cscli missing, ensure prior pull populated cache; test hub egress with curl to hub index and archive URLs; check file ownership/permissions on `data/crowdsec` and `data/crowdsec/hub_cache`; confirm log lines around warnings for exact error message; inspect backup directory to restore if partial apply.
|
||||
|
||||
## 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.
|
||||
|
||||
## Backend Plan (handlers, helpers, storage)
|
||||
1) Route adjustments (gin group under `/admin/crowdsec` in [backend/internal/api/handlers/crowdsec_handler.go](backend/internal/api/handlers/crowdsec_handler.go)):
|
||||
- Replace stub endpoint with `POST /admin/crowdsec/presets/pull` → fetch hub item and cache; returns metadata + preview + cache key/etag.
|
||||
- Add `POST /admin/crowdsec/presets/apply` → apply previously pulled item by cache key/slug; performs backup + cscli install + optional restart.
|
||||
- Keep `GET /admin/crowdsec/presets` but include hub/etag info and whether cached locally.
|
||||
- Optional: `GET /admin/crowdsec/presets/cache/:slug` → raw preview/download for UI.
|
||||
2) Hub sync helper (new [backend/internal/crowdsec/hub_sync.go](backend/internal/crowdsec/hub_sync.go)):
|
||||
- Provide `type HubClient interface { FetchIndex(ctx) (HubIndex, error); FetchPreset(ctx, slug) (PresetBundle, error) }` with real impl using either:
|
||||
a) `cscli hub list -o json` and `cscli hub update` + `cscli hub install <item>` (preferred if cscli present), or
|
||||
b) direct fetch of https://hub.crowdsec.net/ or GitHub raw `.index.json` + tarball download.
|
||||
- Validate downloads: size limits, tarball path traversal guard, checksum/etag compare, basic YAML validation.
|
||||
3) Caching (new [backend/internal/crowdsec/hub_cache.go](backend/internal/crowdsec/hub_cache.go)):
|
||||
- Cache pulled bundles under `${DataDir}/hub_cache/<slug>/` with index metadata (etag, fetched_at, source URL) and preview YAML.
|
||||
- Expose `LoadCachedPreset(slug)` and `StorePreset(slug, bundle)`; evict stale on TTL (configurable, default 24h) or when etag changes.
|
||||
4) Apply flow (extend handler):
|
||||
- `Pull`: fetch index, resolve slug, download bundle to cache, return preview + warnings (missing cscli, requires restart, etc.).
|
||||
- `Apply`: before modify, run `backupDir := DataDir + ".backup." + timestamp` (mirror current write/import backups). Then:
|
||||
a) If cscli available: `cscli hub update`, `cscli hub install <slug>` (or collection path), maybe `cscli decisions list` sanity check. Use `CommandExecutor` with context timeout.
|
||||
b) If cscli absent: extract bundle into DataDir with sanitized paths; preserve permissions.
|
||||
c) Write audit record to DB table `crowdsec_preset_events` (new model in [backend/internal/models](backend/internal/models)).
|
||||
- On failure: restore backup (rename back), surface error + backup path.
|
||||
5) Status and restart:
|
||||
- After apply, optionally call `h.Executor.Stop/Start` if running to reload config; or `cscli service reload` when available. Return `reload_performed` flag.
|
||||
6) Validation & security hardening:
|
||||
- Enforce `Cerberus` enablement check (`isCerberusEnabled`) on all new routes.
|
||||
- Path sanitization with `filepath.Clean`, limit tar extraction to DataDir, reject symlinks/abs paths.
|
||||
- Timeouts on all external calls; default 10s pull, 15s apply.
|
||||
- Log with context: slug, etag, source, backup path; redact secrets.
|
||||
7) Migration of curated list:
|
||||
- Keep curated presets in [backend/internal/crowdsec/presets.go](backend/internal/crowdsec/presets.go) but add `Source: "hub"` for hub-backed items and include `RequiresHub` true when not bundled.
|
||||
- `ListPresets` should merge curated + live hub index when available, mark availability per slug (cached, remote-only, local-bundled).
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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).
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Migration Notes
|
||||
- Existing curated presets remain but are marked as bundled; UI should continue to show them even if hub unreachable.
|
||||
- Stub endpoint `POST /admin/crowdsec/presets/pull/apply` is replaced by separate `pull` and `apply`; frontend must switch to new API paths before backend removal to avoid 404.
|
||||
- Backward compatibility: keep returning 501 from old endpoint until frontend merged; remove once new routes live and tested.
|
||||
|
||||
@@ -1,309 +1,37 @@
|
||||
# QA Report: Optional Features Implementation
|
||||
# QA Report: CrowdSec Hub Preset (feature/beta-release)
|
||||
|
||||
**Date:** December 7, 2025
|
||||
**Date:** December 8, 2025
|
||||
**QA Agent:** QA_Security
|
||||
**Feature:** Optional Features (Feature Flags Refactor)
|
||||
**Specification:** `docs/plans/current_spec.md`
|
||||
**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**
|
||||
**Final Verdict:** ✅ PASS (coverage gate met)
|
||||
|
||||
The Optional Features implementation successfully meets all requirements specified in the plan. All tests pass, security checks are validated, and the implementation follows the project's quality guidelines. One pre-existing test was updated to align with the new default-enabled specification.
|
||||
- `pre-commit run --all-files` passes; coverage hook reports 85.0% vs required 85% (gate met) after adding middleware sanitize tests. Hooks include 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 |
|
||||
| --- | --- | --- |
|
||||
| Pre-commit | ✅ PASS | Coverage gate satisfied at 85.0% (minimum 85%) after middleware sanitize tests; 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". |
|
||||
|
||||
## Evidence / Logs
|
||||
|
||||
- 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. 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).
|
||||
|
||||
---
|
||||
|
||||
## Test Results Summary
|
||||
|
||||
### Backend Tests
|
||||
|
||||
| Test Category | Status | Details |
|
||||
|--------------|--------|---------|
|
||||
| Unit Tests | ✅ PASS | All tests passing (excluding 1 updated test) |
|
||||
| Race Detector | ✅ PASS | No race conditions detected |
|
||||
| GolangCI-Lint | ⚠️ PASS* | 12 pre-existing issues unrelated to Optional Features |
|
||||
| Coverage | ✅ PASS | 85.3% (meets 85% minimum requirement) |
|
||||
|
||||
**Note:** Golangci-lint found 12 pre-existing issues (5 errcheck, 1 gocritic, 1 gosec, 1 staticcheck, 4 unused) that are not related to the Optional Features implementation.
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
| Test Category | Status | Details |
|
||||
|--------------|--------|---------|
|
||||
| Unit Tests | ✅ PASS | 586/586 tests passing |
|
||||
| TypeScript | ✅ PASS | No type errors |
|
||||
| ESLint | ✅ PASS | No linting errors |
|
||||
|
||||
### Pre-commit Checks
|
||||
|
||||
| Check | Status | Details |
|
||||
|-------|--------|---------|
|
||||
| Go Vet | ✅ PASS | No issues |
|
||||
| Go Tests | ✅ PASS | Coverage requirement met (85.3% ≥ 85%) |
|
||||
| Version Check | ✅ PASS | Version matches git tag |
|
||||
| Frontend TypeScript | ✅ PASS | No type errors |
|
||||
| Frontend Lint | ✅ PASS | No linting errors |
|
||||
|
||||
---
|
||||
|
||||
## Implementation Verification
|
||||
|
||||
### 1. Backend Implementation
|
||||
|
||||
#### ✅ Feature Flags Handler (`feature_flags_handler.go`)
|
||||
- **Default Flags**: Correctly limited to `feature.cerberus.enabled` and `feature.uptime.enabled`
|
||||
- **Default Behavior**: Both features default to `true` when no DB setting exists ✓
|
||||
- **Environment Variables**: Proper fallback support ✓
|
||||
- **Authorization**: Update endpoint properly protected ✓
|
||||
|
||||
#### ✅ Cerberus Integration (`cerberus.go`)
|
||||
- **Feature Flag Check**: Uses `feature.cerberus.enabled` as primary key ✓
|
||||
- **Legacy Support**: Falls back to `security.cerberus.enabled` for backward compatibility ✓
|
||||
- **Default Behavior**: Defaults to enabled (true) when no setting exists ✓
|
||||
- **Middleware Integration**: Properly gates security checks based on feature state ✓
|
||||
|
||||
#### ✅ Uptime Background Job (`routes.go`)
|
||||
- **Feature Check**: Checks `feature.uptime.enabled` before running background tasks ✓
|
||||
- **Ticker Logic**: Feature flag is checked on each tick (every 1 minute) ✓
|
||||
- **Initial Sync**: Respects feature flag during initial sync ✓
|
||||
- **Manual Trigger**: `/system/uptime/check` endpoint still available (feature check should be added) ⚠️
|
||||
|
||||
**Recommendation:** Add feature flag check to manual uptime check endpoint for consistency.
|
||||
|
||||
### 2. Frontend Implementation
|
||||
|
||||
#### ✅ System Settings Page (`SystemSettings.tsx`)
|
||||
- **Card Renamed**: "Feature Flags" → "Optional Features" ✓
|
||||
- **Cerberus Toggle**: Properly rendered with descriptive text ✓
|
||||
- **Uptime Toggle**: Properly rendered with descriptive text ✓
|
||||
- **API Integration**: Uses `updateFeatureFlags` mutation correctly ✓
|
||||
- **User Feedback**: Toast notifications on success/error ✓
|
||||
|
||||
#### ✅ Layout/Sidebar (`Layout.tsx`)
|
||||
- **Feature Flags Query**: Fetches flags with 5-minute stale time ✓
|
||||
- **Conditional Rendering**:
|
||||
- Uptime nav item hidden when `feature.uptime.enabled` is false ✓
|
||||
- Security nav group hidden when `feature.cerberus.enabled` is false ✓
|
||||
- **Default Behavior**: Both items visible when flags are loading (defaults to enabled) ✓
|
||||
- **Tests**: Comprehensive tests for sidebar hiding behavior ✓
|
||||
|
||||
### 3. API Endpoints
|
||||
|
||||
| Endpoint | Method | Protected | Tested |
|
||||
|----------|--------|-----------|--------|
|
||||
| `/api/feature-flags` | GET | ✅ | ✅ |
|
||||
| `/api/feature-flags` | PUT | ✅ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## Security Assessment
|
||||
|
||||
### Authentication & Authorization ✅
|
||||
- All feature flag endpoints require authentication
|
||||
- Update operations properly restricted to authenticated users
|
||||
- No privilege escalation vulnerabilities identified
|
||||
|
||||
### Input Validation ✅
|
||||
- Feature flag keys validated against whitelist (`defaultFlags`)
|
||||
- Only allowed keys (`feature.cerberus.enabled`, `feature.uptime.enabled`) can be modified
|
||||
- Invalid keys silently ignored (secure fail-closed behavior)
|
||||
|
||||
### Data Integrity ✅
|
||||
- **Disabling features does NOT delete configuration data** ✓
|
||||
- Database records preserved when features are toggled off
|
||||
- Configuration can be safely re-enabled without data loss
|
||||
|
||||
### Background Jobs ✅
|
||||
- Uptime monitoring stops when feature is disabled
|
||||
- Cerberus middleware respects feature state
|
||||
- No resource leaks or zombie processes identified
|
||||
|
||||
---
|
||||
|
||||
## Regression Testing
|
||||
|
||||
### Existing Functionality ✅
|
||||
- ✅ All existing tests continue to pass
|
||||
- ✅ No breaking changes to API contracts
|
||||
- ✅ Backward compatibility maintained (legacy `security.cerberus.enabled` supported)
|
||||
- ✅ Performance benchmarks within acceptable range
|
||||
|
||||
### Default Behavior ✅
|
||||
- ✅ Both Cerberus and Uptime default to **enabled**
|
||||
- ✅ Users must explicitly disable features
|
||||
- ✅ Conservative fail-safe approach
|
||||
|
||||
### Sidebar Behavior ✅
|
||||
- ✅ Security menu hidden when Cerberus disabled
|
||||
- ✅ Uptime menu hidden when Uptime disabled
|
||||
- ✅ Menu items reappear when features re-enabled
|
||||
- ✅ No UI glitches or race conditions
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Analysis
|
||||
|
||||
### Backend Coverage: 85.3%
|
||||
**Feature Flag Handler:**
|
||||
- `GetFlags()`: 100% covered
|
||||
- `UpdateFlags()`: 100% covered
|
||||
- Environment variable fallback: Tested ✓
|
||||
- Database upsert logic: Tested ✓
|
||||
|
||||
**Cerberus Integration:**
|
||||
- `IsEnabled()`: 100% covered
|
||||
- Feature flag precedence: Tested ✓
|
||||
- Legacy fallback: Tested ✓
|
||||
- Default behavior: Tested ✓
|
||||
|
||||
**Uptime Background Job:**
|
||||
- Feature flag gating: Implicitly tested via integration tests
|
||||
- Recommendation: Add explicit unit test for background job feature gating
|
||||
|
||||
### Frontend Coverage: 100% of New Code
|
||||
- SystemSettings toggles: Tested ✓
|
||||
- Layout conditional rendering: Tested ✓
|
||||
- Feature flag loading states: Tested ✓
|
||||
- API integration: Tested ✓
|
||||
|
||||
---
|
||||
|
||||
## Issues Found & Resolved
|
||||
|
||||
### Issue #1: Test Alignment with Specification ✅ **RESOLVED**
|
||||
**Test:** `TestCerberus_IsEnabled_Disabled`
|
||||
**Problem:** Test expected Cerberus to be disabled when `CerberusEnabled: false` in config and no DB setting exists, but specification requires default to **enabled**.
|
||||
**Resolution:** Updated test to set DB flag to `false` to properly test disabled state.
|
||||
**Status:** Fixed and verified
|
||||
|
||||
### Issue #2: Pre-existing Linter Warnings ⚠️ **NOT BLOCKING**
|
||||
**Findings:** 12 golangci-lint issues in unrelated files:
|
||||
- 5 unchecked error returns in `mail_service.go` (deferred Close() calls)
|
||||
- 1 regex pattern warning in `mail_service.go`
|
||||
- 1 weak random number usage in test helper
|
||||
- 1 deprecated API usage in test helper
|
||||
- 4 unused functions/types in test files
|
||||
|
||||
**Impact:** None of these are related to Optional Features implementation
|
||||
**Status:** Documented for future cleanup, not blocking this feature
|
||||
|
||||
---
|
||||
|
||||
## Recommendations
|
||||
|
||||
### High Priority
|
||||
None
|
||||
|
||||
### Medium Priority
|
||||
1. **Add Feature Flag Check to Manual Uptime Endpoint**
|
||||
- File: `backend/internal/api/routes/routes.go`
|
||||
- Endpoint: `POST /system/uptime/check`
|
||||
- Add check for `feature.uptime.enabled` before running `uptimeService.CheckAll()`
|
||||
- Consistency with background job behavior
|
||||
|
||||
### Low Priority
|
||||
1. **Add Explicit Unit Test for Uptime Background Job Feature Gating**
|
||||
- Create test that verifies background job respects feature flag
|
||||
- Current coverage is implicit via integration tests
|
||||
|
||||
2. **Address Pre-existing Linter Warnings**
|
||||
- Fix unchecked error returns in mail service
|
||||
- Update deprecated `rand.Seed` usage in test helpers
|
||||
- Clean up unused test helper functions
|
||||
|
||||
3. **Consider Feature Flag Logging**
|
||||
- Add structured logging when features are toggled on/off
|
||||
- Helps with debugging and audit trails
|
||||
|
||||
---
|
||||
|
||||
## Compliance & Standards
|
||||
|
||||
### Code Quality Guidelines ✅
|
||||
- DRY principle applied (handlers reuse common patterns)
|
||||
- No dead code introduced
|
||||
- Battle-tested packages used (GORM, Gin)
|
||||
- Clear naming and comments maintained
|
||||
- Conventional commit messages used
|
||||
|
||||
### Architecture Rules ✅
|
||||
- Frontend code exclusively in `frontend/` directory
|
||||
- Backend code exclusively in `backend/` directory
|
||||
- No Python introduced (Go + React/TypeScript stack maintained)
|
||||
- Single binary + static assets deployment preserved
|
||||
|
||||
### Security Best Practices ✅
|
||||
- Input sanitization implemented
|
||||
- Authentication required for all mutations
|
||||
- Safe fail-closed behavior (invalid keys ignored)
|
||||
- Data persistence ensured (no data loss on feature toggle)
|
||||
|
||||
---
|
||||
|
||||
## Performance Impact
|
||||
|
||||
### Backend
|
||||
- **API Response Time:** No measurable impact (<1ms overhead for feature flag checks)
|
||||
- **Background Jobs:** Properly gated, no unnecessary resource consumption
|
||||
- **Database Queries:** Minimal overhead (1 additional query per feature check, properly cached)
|
||||
|
||||
### Frontend
|
||||
- **Bundle Size:** Negligible increase (<2KB)
|
||||
- **Render Performance:** No impact on page load times
|
||||
- **API Calls:** Efficient query caching (5-minute stale time)
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The Optional Features implementation successfully refactors the Feature Flags system according to specification. All core requirements are met:
|
||||
|
||||
✅ Renamed to "Optional Features"
|
||||
✅ Cerberus toggle integrated
|
||||
✅ Uptime toggle implemented
|
||||
✅ Unused flags removed
|
||||
✅ Default behavior: both features enabled
|
||||
✅ Sidebar items conditionally rendered
|
||||
✅ Background jobs respect feature state
|
||||
✅ Data persistence maintained
|
||||
✅ Comprehensive test coverage
|
||||
✅ Security validated
|
||||
✅ No regressions introduced
|
||||
|
||||
The implementation is **production-ready** and recommended for merge.
|
||||
|
||||
---
|
||||
|
||||
## Sign-off
|
||||
|
||||
**QA Agent:** QA_Security
|
||||
**Date:** December 7, 2025
|
||||
**Status:** ✅ **APPROVED FOR PRODUCTION**
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Test Execution Summary
|
||||
|
||||
### Backend
|
||||
```
|
||||
Total Packages: 13
|
||||
Total Tests: 400+
|
||||
Passed: 100% (after fix)
|
||||
Duration: ~53 seconds
|
||||
Coverage: 85.3%
|
||||
```
|
||||
|
||||
### Frontend
|
||||
```
|
||||
Total Test Files: 67
|
||||
Total Tests: 586
|
||||
Passed: 100%
|
||||
Duration: ~52 seconds
|
||||
```
|
||||
|
||||
### Pre-commit
|
||||
```
|
||||
Total Checks: 5
|
||||
Passed: 100%
|
||||
Duration: ~3 minutes (includes full test suite)
|
||||
```
|
||||
**Status:** ✅ QA Passed (coverage gate satisfied).
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
Charon includes **Cerberus**, a security system that protects your websites. It's **enabled by default** so your sites are protected from the start.
|
||||
|
||||
You can disable it in **System Settings → Optional Features** if you don't need it, or configure it using this guide.
|
||||
You can disable it in **System Settings → Optional Features** if you don't need it, or configure it using this guide. The sidebar now shows **Cerberus → Dashboard**; the page header reads **Cerberus Dashboard**.
|
||||
|
||||
Want the quick reference? See https://wikid82.github.io/charon/security.
|
||||
|
||||
---
|
||||
|
||||
@@ -61,7 +63,9 @@ Restart again. Now bad guys actually get blocked.
|
||||
|
||||
### How to Enable It
|
||||
|
||||
**Local Mode** (Runs inside Charon):
|
||||
- **Web UI:** The Cerberus Dashboard shows a single **Start/Stop** toggle. Use it to run or stop CrowdSec; there is no separate mode selector.
|
||||
- **Configuration page:** Uses a simple **Disabled / Local** toggle (no Mode dropdown). Choose Local to run the embedded CrowdSec agent.
|
||||
- **Environment variables (optional):**
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
@@ -70,7 +74,7 @@ environment:
|
||||
|
||||
That's it. CrowdSec starts automatically and begins blocking bad IPs.
|
||||
|
||||
**What you'll see:** The "Security" page shows blocked IPs and why they were blocked.
|
||||
**What you'll see:** The Cerberus pages show blocked IPs and why they were blocked.
|
||||
|
||||
---
|
||||
|
||||
@@ -129,6 +133,14 @@ Now only devices on `192.168.x.x` or `10.x.x.x` can access it. The public intern
|
||||
|
||||
---
|
||||
|
||||
## Configuration Packages
|
||||
|
||||
- **Import/Export:** You can import or export Cerberus configuration packages; exports prompt you to confirm the filename before saving.
|
||||
- **Presets (CrowdSec Hub):** Pull presets from the CrowdSec Hub over HTTPS using cache keys/ETags, prefer `cscli` execution, and require Cerberus to be enabled with an admin-scoped session. Workflow: pull → preview → apply with an automatic backup and reload flag.
|
||||
- **Fallbacks:** If the Hub is unreachable (503 uses retry or cached data), curated/offline presets stay available; invalid slugs return a 400 with validation detail; apply failures remind you to restore from the backup; if apply is not supported (501), stay on curated/offline presets.
|
||||
|
||||
---
|
||||
|
||||
## Certificate Management Security
|
||||
|
||||
**What it protects:** Certificate deletion is a destructive operation that requires proper authorization.
|
||||
|
||||
20
docs/troubleshooting/crowdsec.md
Normal file
20
docs/troubleshooting/crowdsec.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# CrowdSec Troubleshooting
|
||||
|
||||
Keep Cerberus terminology and the Configuration Packages flow in mind while debugging Hub presets.
|
||||
|
||||
## Quick checks
|
||||
- Cerberus is enabled and you are signed in with admin scope.
|
||||
- `cscli` is available (preferred path); HTTPS CrowdSec Hub endpoints only.
|
||||
- Preset workflow: pull from Hub using cache keys/ETags → preview changes → apply with automatic backup and reload flag.
|
||||
- Offline/curated presets remain available at all times.
|
||||
|
||||
## Common issues
|
||||
- Hub unreachable (503): retry once, then Charon falls back to cached Hub data if available; otherwise stay on curated/offline presets until connectivity returns.
|
||||
- Bad preset slug (400): the slug must match Hub naming; correct the slug before retrying.
|
||||
- Apply failed: review the apply response and restore from the backup that was taken automatically, then retry after fixing the underlying issue.
|
||||
- Apply not supported (501): use curated/offline presets; Hub apply will be re-enabled when supported in your environment.
|
||||
|
||||
## Tips
|
||||
- Keep the CrowdSec Hub reachable over HTTPS; HTTP is blocked.
|
||||
- If you switch to offline mode, clear pending Hub pulls before retrying so cache keys/ETags refresh cleanly.
|
||||
- After restoring from a backup, re-run preview before applying again to verify changes.
|
||||
135
frontend/package-lock.json
generated
135
frontend/package-lock.json
generated
@@ -29,8 +29,8 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/coverage-istanbul": "^4.0.15",
|
||||
"@vitest/coverage-v8": "^4.0.15",
|
||||
@@ -44,7 +44,7 @@
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"typescript-eslint": "^8.49.0",
|
||||
"vite": "^7.2.7",
|
||||
"vitest": "^4.0.15"
|
||||
}
|
||||
@@ -2544,18 +2544,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.1.tgz",
|
||||
"integrity": "sha512-X63hI1bxl5ohelzr0LY5coufyl0LJNthld+abwxpCoo6Gq+hSqhKwci7MUWkXo67mzgUK6YFByhmaHmUcuBJmA==",
|
||||
"version": "8.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz",
|
||||
"integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.48.1",
|
||||
"@typescript-eslint/type-utils": "8.48.1",
|
||||
"@typescript-eslint/utils": "8.48.1",
|
||||
"@typescript-eslint/visitor-keys": "8.48.1",
|
||||
"graphemer": "^1.4.0",
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/type-utils": "8.49.0",
|
||||
"@typescript-eslint/utils": "8.49.0",
|
||||
"@typescript-eslint/visitor-keys": "8.49.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
@@ -2568,23 +2567,23 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.48.1.tgz",
|
||||
"integrity": "sha512-PC0PDZfJg8sP7cmKe6L3QIL8GZwU5aRvUFedqSIpw3B+QjRSUZeeITC2M5XKeMXEzL6wccN196iy3JLwKNvDVA==",
|
||||
"version": "8.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz",
|
||||
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.48.1",
|
||||
"@typescript-eslint/types": "8.48.1",
|
||||
"@typescript-eslint/typescript-estree": "8.48.1",
|
||||
"@typescript-eslint/visitor-keys": "8.48.1",
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
"@typescript-eslint/typescript-estree": "8.49.0",
|
||||
"@typescript-eslint/visitor-keys": "8.49.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2600,14 +2599,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.48.1.tgz",
|
||||
"integrity": "sha512-HQWSicah4s9z2/HifRPQ6b6R7G+SBx64JlFQpgSSHWPKdvCZX57XCbszg/bapbRsOEv42q5tayTYcEFpACcX1w==",
|
||||
"version": "8.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz",
|
||||
"integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.48.1",
|
||||
"@typescript-eslint/types": "^8.48.1",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.49.0",
|
||||
"@typescript-eslint/types": "^8.49.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2622,14 +2621,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.48.1.tgz",
|
||||
"integrity": "sha512-rj4vWQsytQbLxC5Bf4XwZ0/CKd362DkWMUkviT7DCS057SK64D5lH74sSGzhI6PDD2HCEq02xAP9cX68dYyg1w==",
|
||||
"version": "8.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz",
|
||||
"integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.48.1",
|
||||
"@typescript-eslint/visitor-keys": "8.48.1"
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
"@typescript-eslint/visitor-keys": "8.49.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2640,9 +2639,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.48.1.tgz",
|
||||
"integrity": "sha512-k0Jhs4CpEffIBm6wPaCXBAD7jxBtrHjrSgtfCjUvPp9AZ78lXKdTR8fxyZO5y4vWNlOvYXRtngSZNSn+H53Jkw==",
|
||||
"version": "8.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz",
|
||||
"integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2657,15 +2656,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.48.1.tgz",
|
||||
"integrity": "sha512-1jEop81a3LrJQLTf/1VfPQdhIY4PlGDBc/i67EVWObrtvcziysbLN3oReexHOM6N3jyXgCrkBsZpqwH0hiDOQg==",
|
||||
"version": "8.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz",
|
||||
"integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.48.1",
|
||||
"@typescript-eslint/typescript-estree": "8.48.1",
|
||||
"@typescript-eslint/utils": "8.48.1",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
"@typescript-eslint/typescript-estree": "8.49.0",
|
||||
"@typescript-eslint/utils": "8.49.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
@@ -2682,9 +2681,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.48.1.tgz",
|
||||
"integrity": "sha512-+fZ3LZNeiELGmimrujsDCT4CRIbq5oXdHe7chLiW8qzqyPMnn1puNstCrMNVAqwcl2FdIxkuJ4tOs/RFDBVc/Q==",
|
||||
"version": "8.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz",
|
||||
"integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2696,16 +2695,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.48.1.tgz",
|
||||
"integrity": "sha512-/9wQ4PqaefTK6POVTjJaYS0bynCgzh6ClJHGSBj06XEHjkfylzB+A3qvyaXnErEZSaxhIo4YdyBgq6j4RysxDg==",
|
||||
"version": "8.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz",
|
||||
"integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.48.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.48.1",
|
||||
"@typescript-eslint/types": "8.48.1",
|
||||
"@typescript-eslint/visitor-keys": "8.48.1",
|
||||
"@typescript-eslint/project-service": "8.49.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
"@typescript-eslint/visitor-keys": "8.49.0",
|
||||
"debug": "^4.3.4",
|
||||
"minimatch": "^9.0.4",
|
||||
"semver": "^7.6.0",
|
||||
@@ -2724,16 +2723,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.48.1.tgz",
|
||||
"integrity": "sha512-fAnhLrDjiVfey5wwFRwrweyRlCmdz5ZxXz2G/4cLn0YDLjTapmN4gcCsTBR1N2rWnZSDeWpYtgLDsJt+FpmcwA==",
|
||||
"version": "8.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz",
|
||||
"integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.48.1",
|
||||
"@typescript-eslint/types": "8.48.1",
|
||||
"@typescript-eslint/typescript-estree": "8.48.1"
|
||||
"@typescript-eslint/scope-manager": "8.49.0",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
"@typescript-eslint/typescript-estree": "8.49.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2748,13 +2747,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.48.1.tgz",
|
||||
"integrity": "sha512-BmxxndzEWhE4TIEEMBs8lP3MBWN3jFPs/p6gPm/wkv02o41hI6cq9AuSmGAaTTHPtA1FTi2jBre4A9rm5ZmX+Q==",
|
||||
"version": "8.49.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz",
|
||||
"integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.48.1",
|
||||
"@typescript-eslint/types": "8.49.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -4230,12 +4229,6 @@
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/graphemer": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz",
|
||||
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
@@ -6062,16 +6055,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.48.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.48.1.tgz",
|
||||
"integrity": "sha512-FbOKN1fqNoXp1hIl5KYpObVrp0mCn+CLgn479nmu2IsRMrx2vyv74MmsBLVlhg8qVwNFGbXSp8fh1zp8pEoC2A==",
|
||||
"version": "8.49.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz",
|
||||
"integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.48.1",
|
||||
"@typescript-eslint/parser": "8.48.1",
|
||||
"@typescript-eslint/typescript-estree": "8.48.1",
|
||||
"@typescript-eslint/utils": "8.48.1"
|
||||
"@typescript-eslint/eslint-plugin": "8.49.0",
|
||||
"@typescript-eslint/parser": "8.49.0",
|
||||
"@typescript-eslint/typescript-estree": "8.49.0",
|
||||
"@typescript-eslint/utils": "8.49.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.1",
|
||||
"@typescript-eslint/parser": "^8.48.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.49.0",
|
||||
"@typescript-eslint/parser": "^8.49.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"@vitest/coverage-v8": "^4.0.15",
|
||||
"@vitest/coverage-istanbul": "^4.0.15",
|
||||
@@ -63,7 +63,7 @@
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.48.1",
|
||||
"typescript-eslint": "^8.49.0",
|
||||
"vite": "^7.2.7",
|
||||
"vitest": "^4.0.15"
|
||||
}
|
||||
|
||||
59
frontend/src/api/__tests__/presets.test.ts
Normal file
59
frontend/src/api/__tests__/presets.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import * as presets from '../presets'
|
||||
import client from '../client'
|
||||
|
||||
vi.mock('../client')
|
||||
|
||||
describe('crowdsec presets API', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('lists presets via GET', async () => {
|
||||
const mockData = { presets: [{ slug: 'bot', title: 'Bot', summary: 'desc', source: 'hub', requires_hub: true, available: true, cached: false }] }
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await presets.listCrowdsecPresets()
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/presets')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('pulls a preset via POST', async () => {
|
||||
const mockData = { status: 'pulled', slug: 'bot', preview: 'configs: {}', cache_key: 'cache-1' }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await presets.pullCrowdsecPreset('bot')
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/pull', { slug: 'bot' })
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('applies a preset via POST', async () => {
|
||||
const mockData = { status: 'applied', backup: '/tmp/backup', cache_key: 'cache-1' }
|
||||
vi.mocked(client.post).mockResolvedValue({ data: mockData })
|
||||
|
||||
const payload = { slug: 'bot', cache_key: 'cache-1' }
|
||||
const result = await presets.applyCrowdsecPreset(payload)
|
||||
|
||||
expect(client.post).toHaveBeenCalledWith('/admin/crowdsec/presets/apply', payload)
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('fetches cached preview by slug', async () => {
|
||||
const mockData = { preview: 'cached', cache_key: 'cache-1', etag: 'etag-1' }
|
||||
vi.mocked(client.get).mockResolvedValue({ data: mockData })
|
||||
|
||||
const result = await presets.getCrowdsecPresetCache('bot/collection')
|
||||
|
||||
expect(client.get).toHaveBeenCalledWith('/admin/crowdsec/presets/cache/bot%2Fcollection')
|
||||
expect(result).toEqual(mockData)
|
||||
})
|
||||
|
||||
it('exports default bundle', () => {
|
||||
expect(presets.default).toHaveProperty('listCrowdsecPresets')
|
||||
expect(presets.default).toHaveProperty('pullCrowdsecPreset')
|
||||
expect(presets.default).toHaveProperty('applyCrowdsecPreset')
|
||||
expect(presets.default).toHaveProperty('getCrowdsecPresetCache')
|
||||
})
|
||||
})
|
||||
67
frontend/src/api/presets.ts
Normal file
67
frontend/src/api/presets.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import client from './client'
|
||||
|
||||
export interface CrowdsecPresetSummary {
|
||||
slug: string
|
||||
title: string
|
||||
summary: string
|
||||
source: string
|
||||
tags?: string[]
|
||||
requires_hub: boolean
|
||||
available: boolean
|
||||
cached: boolean
|
||||
cache_key?: string
|
||||
etag?: string
|
||||
retrieved_at?: string
|
||||
}
|
||||
|
||||
export interface PullCrowdsecPresetResponse {
|
||||
status: string
|
||||
slug: string
|
||||
preview: string
|
||||
cache_key: string
|
||||
etag?: string
|
||||
retrieved_at?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface ApplyCrowdsecPresetResponse {
|
||||
status: string
|
||||
backup?: string
|
||||
reload_hint?: string
|
||||
used_cscli?: boolean
|
||||
cache_key?: string
|
||||
slug?: string
|
||||
}
|
||||
|
||||
export interface CachedCrowdsecPresetPreview {
|
||||
preview: string
|
||||
cache_key: string
|
||||
etag?: string
|
||||
}
|
||||
|
||||
export async function listCrowdsecPresets() {
|
||||
const resp = await client.get<{ presets: CrowdsecPresetSummary[] }>('/admin/crowdsec/presets')
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function pullCrowdsecPreset(slug: string) {
|
||||
const resp = await client.post<PullCrowdsecPresetResponse>('/admin/crowdsec/presets/pull', { slug })
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function applyCrowdsecPreset(payload: { slug: string; cache_key?: string }) {
|
||||
const resp = await client.post<ApplyCrowdsecPresetResponse>('/admin/crowdsec/presets/apply', payload)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export async function getCrowdsecPresetCache(slug: string) {
|
||||
const resp = await client.get<CachedCrowdsecPresetPreview>(`/admin/crowdsec/presets/cache/${encodeURIComponent(slug)}`)
|
||||
return resp.data
|
||||
}
|
||||
|
||||
export default {
|
||||
listCrowdsecPresets,
|
||||
pullCrowdsecPreset,
|
||||
applyCrowdsecPreset,
|
||||
getCrowdsecPresetCache,
|
||||
}
|
||||
@@ -62,8 +62,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{ name: 'Domains', path: '/domains', icon: '🌍' },
|
||||
{ name: 'Certificates', path: '/certificates', icon: '🔒' },
|
||||
{ name: 'Uptime', path: '/uptime', icon: '📈' },
|
||||
{ name: 'Security', path: '/security', icon: '🛡️', children: [
|
||||
{ name: 'Overview', path: '/security', icon: '🛡️' },
|
||||
{ name: 'Cerberus', path: '/security', icon: '🛡️', children: [
|
||||
{ name: 'Dashboard', path: '/security', icon: '🛡️' },
|
||||
{ name: 'CrowdSec', path: '/security/crowdsec', icon: '🛡️' },
|
||||
{ name: 'Access Lists', path: '/security/access-lists', icon: '🔒' },
|
||||
{ name: 'Rate Limiting', path: '/security/rate-limiting', icon: '⚡' },
|
||||
@@ -104,7 +104,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
// Optional Features Logic
|
||||
// Default to visible (true) if flags are loading or undefined
|
||||
if (item.name === 'Uptime') return featureFlags?.['feature.uptime.enabled'] !== false
|
||||
if (item.name === 'Security') return featureFlags?.['feature.cerberus.enabled'] !== false
|
||||
if (item.name === 'Cerberus') return featureFlags?.['feature.cerberus.enabled'] !== false
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('Layout', () => {
|
||||
})
|
||||
|
||||
describe('Feature Flags - Conditional Sidebar Items', () => {
|
||||
it('displays Security nav item when Cerberus is enabled', async () => {
|
||||
it('displays Cerberus nav item when Cerberus is enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
@@ -162,11 +162,11 @@ describe('Layout', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cerberus')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides Security nav item when Cerberus is disabled', async () => {
|
||||
it('hides Cerberus nav item when Cerberus is disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': true,
|
||||
@@ -179,7 +179,7 @@ describe('Layout', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Security')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Cerberus')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -217,7 +217,7 @@ describe('Layout', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Security and Uptime when both features are enabled', async () => {
|
||||
it('shows Cerberus and Uptime when both features are enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': true,
|
||||
@@ -230,12 +230,12 @@ describe('Layout', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cerberus')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('hides both Security and Uptime when both features are disabled', async () => {
|
||||
it('hides both Cerberus and Uptime when both features are disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': false,
|
||||
@@ -248,12 +248,12 @@ describe('Layout', () => {
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Security')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Cerberus')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Uptime')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('defaults to showing Security and Uptime when feature flags are loading', async () => {
|
||||
it('defaults to showing Cerberus and Uptime when feature flags are loading', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue(undefined as any)
|
||||
|
||||
@@ -265,7 +265,7 @@ describe('Layout', () => {
|
||||
|
||||
// When flags are undefined, items should be visible by default (conservative approach)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Security')).toBeInTheDocument()
|
||||
expect(screen.getByText('Cerberus')).toBeInTheDocument()
|
||||
expect(screen.getByText('Uptime')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,25 +1,39 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { getSecurityStatus } from '../api/security'
|
||||
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, CrowdSecDecision } from '../api/crowdsec'
|
||||
import { listCrowdsecPresets, pullCrowdsecPreset, applyCrowdsecPreset, getCrowdsecPresetCache } from '../api/presets'
|
||||
import { createBackup } from '../api/backups'
|
||||
import { updateSetting } from '../api/settings'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../utils/toast'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
import { Shield, ShieldOff, Trash2 } from 'lucide-react'
|
||||
import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport'
|
||||
import { CROWDSEC_PRESETS, CrowdsecPreset } from '../data/crowdsecPresets'
|
||||
|
||||
export default function CrowdSecConfig() {
|
||||
const { data: status, isLoading, error } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus })
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string | null>(null)
|
||||
const [selectedPresetSlug, setSelectedPresetSlug] = useState<string>('')
|
||||
const [showBanModal, setShowBanModal] = useState(false)
|
||||
const [banForm, setBanForm] = useState({ ip: '', duration: '24h', reason: '' })
|
||||
const [confirmUnban, setConfirmUnban] = useState<CrowdSecDecision | null>(null)
|
||||
const [isApplyingPreset, setIsApplyingPreset] = useState(false)
|
||||
const [presetPreview, setPresetPreview] = useState<string>('')
|
||||
const [presetMeta, setPresetMeta] = useState<{ cacheKey?: string; etag?: string; retrievedAt?: string; source?: string } | null>(null)
|
||||
const [presetStatusMessage, setPresetStatusMessage] = useState<string | null>(null)
|
||||
const [hubUnavailable, setHubUnavailable] = useState(false)
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
const [applyInfo, setApplyInfo] = useState<{ status?: string; backup?: string; reloadHint?: string; usedCscli?: boolean; cacheKey?: string } | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const isLocalMode = !!status && status.crowdsec?.mode !== 'disabled'
|
||||
|
||||
const backupMutation = useMutation({ mutationFn: () => createBackup() })
|
||||
const importMutation = useMutation({
|
||||
@@ -38,13 +52,133 @@ export default function CrowdSecConfig() {
|
||||
const listMutation = useQuery({ queryKey: ['crowdsec-files'], queryFn: listCrowdsecFiles })
|
||||
const readMutation = useMutation({ mutationFn: (path: string) => readCrowdsecFile(path), onSuccess: (data) => setFileContent(data.content) })
|
||||
const writeMutation = useMutation({ mutationFn: async ({ path, content }: { path: string; content: string }) => writeCrowdsecFile(path, content), onSuccess: () => { toast.success('File saved'); queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] }) } })
|
||||
const updateModeMutation = useMutation({ mutationFn: async (mode: string) => updateSetting('security.crowdsec.mode', mode, 'security', 'string'), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['security-status'] }) })
|
||||
const updateModeMutation = useMutation({
|
||||
mutationFn: async (mode: string) => updateSetting('security.crowdsec.mode', mode, 'security', 'string'),
|
||||
onSuccess: (_data, mode) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['security-status'] })
|
||||
toast.success(mode === 'disabled' ? 'CrowdSec disabled' : 'CrowdSec set to Local mode')
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to update mode'
|
||||
toast.error(msg)
|
||||
},
|
||||
})
|
||||
|
||||
const presetsQuery = useQuery({
|
||||
queryKey: ['crowdsec-presets'],
|
||||
queryFn: listCrowdsecPresets,
|
||||
enabled: !!status?.crowdsec,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
type PresetCatalogEntry = CrowdsecPreset & {
|
||||
requiresHub: boolean
|
||||
available?: boolean
|
||||
cached?: boolean
|
||||
cacheKey?: string
|
||||
etag?: string
|
||||
retrievedAt?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
const presetCatalog: PresetCatalogEntry[] = useMemo(() => {
|
||||
const remotePresets = presetsQuery.data?.presets
|
||||
const localMap = new Map(CROWDSEC_PRESETS.map((preset) => [preset.slug, preset]))
|
||||
if (remotePresets?.length) {
|
||||
return remotePresets.map((preset) => {
|
||||
const local = localMap.get(preset.slug)
|
||||
return {
|
||||
slug: preset.slug,
|
||||
title: preset.title || local?.title || preset.slug,
|
||||
description: local?.description || preset.summary,
|
||||
content: local?.content || '',
|
||||
tags: local?.tags || preset.tags,
|
||||
warning: local?.warning,
|
||||
requiresHub: Boolean(preset.requires_hub),
|
||||
available: preset.available,
|
||||
cached: preset.cached,
|
||||
cacheKey: preset.cache_key,
|
||||
etag: preset.etag,
|
||||
retrievedAt: preset.retrieved_at,
|
||||
source: preset.source,
|
||||
}
|
||||
})
|
||||
}
|
||||
return CROWDSEC_PRESETS.map((preset) => ({ ...preset, requiresHub: false, available: true, cached: false, source: 'charon-curated' }))
|
||||
}, [presetsQuery.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!presetCatalog.length) return
|
||||
if (!selectedPresetSlug || !presetCatalog.some((preset) => preset.slug === selectedPresetSlug)) {
|
||||
setSelectedPresetSlug(presetCatalog[0].slug)
|
||||
}
|
||||
}, [presetCatalog, selectedPresetSlug])
|
||||
|
||||
const selectedPreset = presetCatalog.find((preset) => preset.slug === selectedPresetSlug)
|
||||
const selectedPresetRequiresHub = selectedPreset?.requiresHub ?? false
|
||||
|
||||
const pullPresetMutation = useMutation({
|
||||
mutationFn: (slug: string) => pullCrowdsecPreset(slug),
|
||||
onSuccess: (data) => {
|
||||
setPresetPreview(data.preview)
|
||||
setPresetMeta({ cacheKey: data.cache_key, etag: data.etag, retrievedAt: data.retrieved_at, source: data.source })
|
||||
setPresetStatusMessage('Preview fetched from hub')
|
||||
setHubUnavailable(false)
|
||||
setValidationError(null)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
setPresetStatusMessage(null)
|
||||
if (isAxiosError(err)) {
|
||||
if (err.response?.status === 400) {
|
||||
setValidationError(err.response.data?.error || 'Preset slug is invalid')
|
||||
return
|
||||
}
|
||||
if (err.response?.status === 503) {
|
||||
setHubUnavailable(true)
|
||||
setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.')
|
||||
return
|
||||
}
|
||||
setPresetStatusMessage(err.response?.data?.error || 'Failed to pull preset preview')
|
||||
} else {
|
||||
setPresetStatusMessage('Failed to pull preset preview')
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPreset) return
|
||||
setValidationError(null)
|
||||
setPresetStatusMessage(null)
|
||||
setApplyInfo(null)
|
||||
setPresetMeta({
|
||||
cacheKey: selectedPreset.cacheKey,
|
||||
etag: selectedPreset.etag,
|
||||
retrievedAt: selectedPreset.retrievedAt,
|
||||
source: selectedPreset.source,
|
||||
})
|
||||
setPresetPreview(selectedPreset.content || '')
|
||||
pullPresetMutation.mutate(selectedPreset.slug)
|
||||
}, [selectedPreset?.slug])
|
||||
|
||||
const loadCachedPreview = async () => {
|
||||
if (!selectedPreset) return
|
||||
try {
|
||||
const cached = await getCrowdsecPresetCache(selectedPreset.slug)
|
||||
setPresetPreview(cached.preview)
|
||||
setPresetMeta({ cacheKey: cached.cache_key, etag: cached.etag, retrievedAt: selectedPreset.retrievedAt, source: selectedPreset.source })
|
||||
setPresetStatusMessage('Loaded cached preview')
|
||||
setHubUnavailable(false)
|
||||
} catch (err) {
|
||||
const msg = isAxiosError(err) ? err.response?.data?.error || err.message : 'Failed to load cached preview'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// Banned IPs queries and mutations
|
||||
const decisionsQuery = useQuery({
|
||||
queryKey: ['crowdsec-decisions'],
|
||||
queryFn: listCrowdsecDecisions,
|
||||
enabled: status?.crowdsec?.mode !== 'disabled',
|
||||
enabled: isLocalMode,
|
||||
})
|
||||
|
||||
const banMutation = useMutation({
|
||||
@@ -73,16 +207,13 @@ export default function CrowdSecConfig() {
|
||||
})
|
||||
|
||||
const handleExport = async () => {
|
||||
const defaultName = buildCrowdsecExportFilename()
|
||||
const filename = promptCrowdsecFilename(defaultName)
|
||||
if (!filename) return
|
||||
|
||||
try {
|
||||
const blob = await exportCrowdsecConfig()
|
||||
const url = window.URL.createObjectURL(new Blob([blob]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `crowdsec-config-${new Date().toISOString().slice(0,19).replace(/[:T]/g, '-')}.tar.gz`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
downloadCrowdsecExport(blob, filename)
|
||||
toast.success('CrowdSec configuration exported')
|
||||
} catch {
|
||||
toast.error('Failed to export CrowdSec configuration')
|
||||
@@ -115,22 +246,129 @@ export default function CrowdSecConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleModeChange = async (mode: string) => {
|
||||
const handleModeToggle = (nextEnabled: boolean) => {
|
||||
const mode = nextEnabled ? 'local' : 'disabled'
|
||||
updateModeMutation.mutate(mode)
|
||||
toast.success('CrowdSec mode saved (restart may be required)')
|
||||
}
|
||||
|
||||
const applyPresetLocally = async (reason?: string) => {
|
||||
if (!selectedPreset) {
|
||||
toast.error('Select a preset to apply')
|
||||
return
|
||||
}
|
||||
|
||||
const targetPath = selectedPath ?? listMutation.data?.files?.[0]
|
||||
if (!targetPath) {
|
||||
toast.error('Select a configuration file to apply the preset')
|
||||
return
|
||||
}
|
||||
|
||||
const content = presetPreview || selectedPreset.content
|
||||
if (!content) {
|
||||
toast.error('Preset preview is unavailable; retry pulling before applying')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await backupMutation.mutateAsync()
|
||||
await writeCrowdsecFile(targetPath, content)
|
||||
queryClient.invalidateQueries({ queryKey: ['crowdsec-files'] })
|
||||
setSelectedPath(targetPath)
|
||||
setFileContent(content)
|
||||
setApplyInfo({ status: 'applied-locally', cacheKey: presetMeta?.cacheKey })
|
||||
toast.success(reason ? `${reason}: preset applied locally` : 'Preset applied locally (backup created)')
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to apply preset locally'
|
||||
toast.error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
const handleApplyPreset = async () => {
|
||||
if (!selectedPreset) {
|
||||
toast.error('Select a preset to apply')
|
||||
return
|
||||
}
|
||||
|
||||
setIsApplyingPreset(true)
|
||||
setApplyInfo(null)
|
||||
setValidationError(null)
|
||||
try {
|
||||
const res = await applyCrowdsecPreset({ slug: selectedPreset.slug, cache_key: presetMeta?.cacheKey })
|
||||
setApplyInfo({
|
||||
status: res.status,
|
||||
backup: res.backup,
|
||||
reloadHint: res.reload_hint,
|
||||
usedCscli: res.used_cscli,
|
||||
cacheKey: res.cache_key,
|
||||
})
|
||||
|
||||
const reloadNote = res.reload_hint ? ` (${res.reload_hint})` : ''
|
||||
toast.success(`Preset applied via backend${reloadNote}`)
|
||||
if (res.backup) {
|
||||
setPresetStatusMessage(`Backup stored at ${res.backup}`)
|
||||
}
|
||||
} catch (err) {
|
||||
if (isAxiosError(err)) {
|
||||
if (err.response?.status === 501) {
|
||||
toast.info('Preset apply is not available on the server; applying locally instead')
|
||||
await applyPresetLocally('Backend apply unavailable')
|
||||
return
|
||||
}
|
||||
|
||||
if (err.response?.status === 400) {
|
||||
setValidationError(err.response?.data?.error || 'Preset validation failed')
|
||||
toast.error('Preset validation failed')
|
||||
return
|
||||
}
|
||||
|
||||
if (err.response?.status === 503) {
|
||||
setHubUnavailable(true)
|
||||
setPresetStatusMessage('CrowdSec hub unavailable. Retry or load cached copy.')
|
||||
toast.error('Hub unavailable; retry pull/apply or use cached copy')
|
||||
return
|
||||
}
|
||||
|
||||
const backupPath = (err.response?.data as { backup?: string })?.backup
|
||||
if (backupPath) {
|
||||
setApplyInfo({ status: 'failed', backup: backupPath, cacheKey: presetMeta?.cacheKey })
|
||||
toast.error(`Apply failed. Restore from backup at ${backupPath}`)
|
||||
return
|
||||
}
|
||||
toast.error(err.response?.data?.error || err.message)
|
||||
} else {
|
||||
toast.error('Failed to apply preset')
|
||||
}
|
||||
} finally {
|
||||
setIsApplyingPreset(false)
|
||||
}
|
||||
}
|
||||
|
||||
const presetActionDisabled =
|
||||
!selectedPreset ||
|
||||
isApplyingPreset ||
|
||||
backupMutation.isPending ||
|
||||
pullPresetMutation.isPending ||
|
||||
(selectedPresetRequiresHub && hubUnavailable)
|
||||
|
||||
// Determine if any operation is in progress
|
||||
const isApplyingConfig =
|
||||
importMutation.isPending ||
|
||||
writeMutation.isPending ||
|
||||
updateModeMutation.isPending ||
|
||||
backupMutation.isPending ||
|
||||
pullPresetMutation.isPending ||
|
||||
isApplyingPreset ||
|
||||
banMutation.isPending ||
|
||||
unbanMutation.isPending
|
||||
|
||||
// Determine contextual message
|
||||
const getMessage = () => {
|
||||
if (pullPresetMutation.isPending) {
|
||||
return { message: 'Fetching preset...', submessage: 'Pulling preview from CrowdSec Hub' }
|
||||
}
|
||||
if (isApplyingPreset) {
|
||||
return { message: 'Loading preset...', submessage: 'Applying curated preset with backup' }
|
||||
}
|
||||
if (importMutation.isPending) {
|
||||
return { message: 'Summoning the guardian...', submessage: 'Importing CrowdSec configuration' }
|
||||
}
|
||||
@@ -168,37 +406,163 @@ export default function CrowdSecConfig() {
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">CrowdSec Configuration</h1>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Mode</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm text-gray-400">Mode:</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<select value={status.crowdsec.mode} onChange={(e) => handleModeChange(e.target.value)} className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white">
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="local">Local</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{status.crowdsec.mode === 'disabled' && (
|
||||
<p className="text-xs text-yellow-500">CrowdSec is disabled</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">CrowdSec Mode</h2>
|
||||
<p className="text-sm text-gray-400">
|
||||
{isLocalMode ? 'CrowdSec runs locally; disable to pause decisions.' : 'CrowdSec decisions are paused; enable to resume local protection.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" onClick={handleExport}>Export</Button>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-400">Disabled</span>
|
||||
<Switch
|
||||
checked={isLocalMode}
|
||||
onChange={(e) => handleModeToggle(e.target.checked)}
|
||||
disabled={updateModeMutation.isPending}
|
||||
data-testid="crowdsec-mode-toggle"
|
||||
/>
|
||||
<span className="text-sm text-gray-200">Local</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-md font-semibold">Import Configuration</h3>
|
||||
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" />
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleImport} disabled={!file || importMutation.isPending} data-testid="import-btn">
|
||||
{importMutation.isPending ? 'Importing...' : 'Import'}
|
||||
</Button>
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<h3 className="text-md font-semibold">Configuration Packages</h3>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleExport}
|
||||
disabled={importMutation.isPending || backupMutation.isPending}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<Button onClick={handleImport} disabled={!file || importMutation.isPending || backupMutation.isPending} data-testid="import-btn">
|
||||
{importMutation.isPending ? 'Importing...' : 'Import'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">Import or export CrowdSec configuration packages. A backup is created before imports.</p>
|
||||
<input type="file" onChange={(e) => setFile(e.target.files?.[0] ?? null)} data-testid="import-file" accept=".tar.gz,.zip" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-md font-semibold">CrowdSec Presets</h3>
|
||||
<p className="text-sm text-gray-400">Select a curated preset, preview it, then apply with an automatic backup.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
value={selectedPresetSlug}
|
||||
onChange={(e) => setSelectedPresetSlug(e.target.value)}
|
||||
data-testid="preset-select"
|
||||
>
|
||||
{presetCatalog.map((preset) => (
|
||||
<option key={preset.slug} value={preset.slug}>{preset.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => selectedPreset && pullPresetMutation.mutate(selectedPreset.slug)}
|
||||
disabled={!selectedPreset || pullPresetMutation.isPending}
|
||||
isLoading={pullPresetMutation.isPending}
|
||||
>
|
||||
Pull Preview
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApplyPreset}
|
||||
disabled={presetActionDisabled}
|
||||
isLoading={isApplyingPreset}
|
||||
data-testid="apply-preset-btn"
|
||||
>
|
||||
Apply Preset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<p className="text-sm text-red-400" data-testid="preset-validation-error">{validationError}</p>
|
||||
)}
|
||||
|
||||
{presetStatusMessage && (
|
||||
<p className="text-sm text-yellow-300" data-testid="preset-status">{presetStatusMessage}</p>
|
||||
)}
|
||||
|
||||
{hubUnavailable && (
|
||||
<div className="flex flex-wrap gap-2 items-center text-sm text-red-300" data-testid="preset-hub-unavailable">
|
||||
<span>Hub unreachable. Retry pull or load cached copy if available.</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => selectedPreset && pullPresetMutation.mutate(selectedPreset.slug)}
|
||||
disabled={pullPresetMutation.isPending}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
{selectedPreset?.cached && (
|
||||
<Button size="sm" variant="secondary" onClick={loadCachedPreview}>Use cached preview</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedPreset && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-semibold text-white">{selectedPreset.title}</p>
|
||||
<p className="text-sm text-gray-400">{selectedPreset.description}</p>
|
||||
{selectedPreset.warning && (
|
||||
<p className="text-xs text-yellow-300" data-testid="preset-warning">{selectedPreset.warning}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">Target file: {selectedPath ?? 'Select a file below (used for local fallback)'} </p>
|
||||
</div>
|
||||
{presetMeta && (
|
||||
<div className="text-xs text-gray-400 flex flex-wrap gap-3" data-testid="preset-meta">
|
||||
<span>Cache key: {presetMeta.cacheKey || '—'}</span>
|
||||
<span>Etag: {presetMeta.etag || '—'}</span>
|
||||
<span>Source: {presetMeta.source || selectedPreset.source || '—'}</span>
|
||||
<span>Fetched: {presetMeta.retrievedAt ? new Date(presetMeta.retrievedAt).toLocaleString() : '—'}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-2">Preset preview (YAML)</p>
|
||||
<pre
|
||||
className="bg-gray-900 border border-gray-800 rounded-lg p-3 text-sm text-gray-200 whitespace-pre-wrap"
|
||||
data-testid="preset-preview"
|
||||
>
|
||||
{presetPreview || selectedPreset.content || 'Preview unavailable. Pull from hub or use cached copy.'}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{applyInfo && (
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-900/70 p-3 text-xs text-gray-200" data-testid="preset-apply-info">
|
||||
<p>Status: {applyInfo.status || 'applied'}</p>
|
||||
{applyInfo.backup && <p>Backup: {applyInfo.backup}</p>}
|
||||
{applyInfo.reloadHint && <p>Reload: {applyInfo.reloadHint}</p>}
|
||||
{applyInfo.usedCscli !== undefined && <p>Method: {applyInfo.usedCscli ? 'cscli' : 'filesystem'}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 items-center text-xs text-gray-400">
|
||||
{selectedPreset.cached && (
|
||||
<Button size="sm" variant="secondary" onClick={loadCachedPreview}>
|
||||
Load cached preview
|
||||
</Button>
|
||||
)}
|
||||
{selectedPresetRequiresHub && hubUnavailable && (
|
||||
<span className="text-red-300">Apply disabled while hub is offline.</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{presetCatalog.length === 0 && (
|
||||
<p className="text-sm text-gray-500">No presets available. Ensure Cerberus is enabled.</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -206,7 +570,12 @@ export default function CrowdSecConfig() {
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-md font-semibold">Edit Configuration Files</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<select className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white" value={selectedPath ?? ''} onChange={(e) => handleReadFile(e.target.value)}>
|
||||
<select
|
||||
className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
value={selectedPath ?? ''}
|
||||
onChange={(e) => handleReadFile(e.target.value)}
|
||||
data-testid="crowdsec-file-select"
|
||||
>
|
||||
<option value="">Select a file...</option>
|
||||
{listMutation.data?.files?.map((f) => (
|
||||
<option value={f} key={f}>{f}</option>
|
||||
|
||||
@@ -47,11 +47,11 @@ export default function ImportCrowdSec() {
|
||||
|
||||
return (
|
||||
<div className="p-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-6">Import CrowdSec</h1>
|
||||
<h1 className="text-3xl font-bold text-white mb-6">CrowdSec Configuration Packages</h1>
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">Upload a tar.gz or zip with your CrowdSec configuration. A backup will be created before importing.</p>
|
||||
<input type="file" onChange={handleFile} accept=".tar.gz,.zip" />
|
||||
<p className="text-sm text-gray-400">Upload a tar.gz or zip package. A backup is created before importing so you can roll back if needed. Export the current package from the Cerberus dashboard or CrowdSec config page.</p>
|
||||
<input type="file" onChange={handleFile} accept=".tar.gz,.zip" data-testid="crowdsec-import-file" />
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => handleImport()} isLoading={backupMutation.isPending || importMutation.isPending} disabled={!file}>Import</Button>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { toast } from '../utils/toast'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport'
|
||||
|
||||
export default function Security() {
|
||||
const navigate = useNavigate()
|
||||
@@ -65,36 +66,9 @@ export default function Security() {
|
||||
},
|
||||
|
||||
})
|
||||
const toggleCerberusMutation = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
await updateSetting('security.cerberus.enabled', enabled ? 'true' : 'false', 'security', 'bool')
|
||||
},
|
||||
onMutate: async (enabled: boolean) => {
|
||||
await queryClient.cancelQueries({ queryKey: ['security-status'] })
|
||||
const previous = queryClient.getQueryData(['security-status'])
|
||||
if (previous) {
|
||||
queryClient.setQueryData(['security-status'], (old: unknown) => {
|
||||
const copy = JSON.parse(JSON.stringify(old)) as SecurityStatus
|
||||
if (!copy.cerberus) copy.cerberus = { enabled: false }
|
||||
copy.cerberus.enabled = enabled
|
||||
return copy
|
||||
})
|
||||
}
|
||||
return { previous }
|
||||
},
|
||||
onError: (_err, _vars, context: unknown) => {
|
||||
if (context && typeof context === 'object' && 'previous' in context) {
|
||||
queryClient.setQueryData(['security-status'], context.previous)
|
||||
}
|
||||
},
|
||||
// onSuccess: already set below
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['security-status'] })
|
||||
},
|
||||
})
|
||||
|
||||
const fetchCrowdsecStatus = async () => {
|
||||
|
||||
try {
|
||||
const s = await statusCrowdsec()
|
||||
setCrowdsecStatus(s)
|
||||
@@ -105,31 +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 =
|
||||
toggleCerberusMutation.isPending ||
|
||||
toggleServiceMutation.isPending ||
|
||||
updateSecurityConfigMutation.isPending ||
|
||||
generateBreakGlassMutation.isPending ||
|
||||
startMutation.isPending ||
|
||||
stopMutation.isPending
|
||||
crowdsecPowerMutation.isPending
|
||||
|
||||
// Determine contextual message
|
||||
const getMessage = () => {
|
||||
if (toggleCerberusMutation.isPending) {
|
||||
return { message: 'Cerberus awakens...', submessage: 'Guardian of the gates stands watch' }
|
||||
}
|
||||
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' }
|
||||
}
|
||||
@@ -144,6 +163,10 @@ export default function Security() {
|
||||
return <div className="p-8 text-center text-red-500">Failed to load security status</div>
|
||||
}
|
||||
|
||||
const cerberusDisabled = !status.cerberus?.enabled
|
||||
const crowdsecToggleDisabled = cerberusDisabled || crowdsecPowerMutation.isPending
|
||||
const crowdsecControlsDisabled = cerberusDisabled || crowdsecPowerMutation.isPending
|
||||
|
||||
// const suiteDisabled = !(status?.cerberus?.enabled ?? false)
|
||||
|
||||
// Replace the previous early-return that instructed enabling via env vars.
|
||||
@@ -152,10 +175,10 @@ export default function Security() {
|
||||
<div className="flex flex-col items-center justify-center text-center space-y-4 p-6 bg-gray-900/5 dark:bg-gray-800 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="w-8 h-8 text-gray-400" />
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Security Suite Disabled</h2>
|
||||
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">Cerberus Disabled</h2>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-lg">
|
||||
Charon supports advanced security features (CrowdSec, WAF, ACLs, Rate Limiting). Enable the global Cerberus toggle in System Settings and activate individual services below.
|
||||
Cerberus powers CrowdSec, WAF, ACLs, and Rate Limiting. Enable the Cerberus toggle in System Settings to awaken the guardian, then configure each head below.
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -184,16 +207,8 @@ export default function Security() {
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<ShieldCheck className="w-8 h-8 text-green-500" />
|
||||
Security Dashboard
|
||||
Cerberus Dashboard
|
||||
</h1>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-sm text-gray-500 dark:text-gray-400">Enable Cerberus</label>
|
||||
<Switch
|
||||
checked={status?.cerberus?.enabled ?? false}
|
||||
onChange={(e) => toggleCerberusMutation.mutate(e.target.checked)}
|
||||
data-testid="toggle-cerberus"
|
||||
/>
|
||||
</div>
|
||||
<div/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
@@ -224,9 +239,9 @@ export default function Security() {
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={status.crowdsec.enabled}
|
||||
disabled={!status.cerberus?.enabled}
|
||||
disabled={crowdsecToggleDisabled}
|
||||
onChange={(e) => {
|
||||
toggleServiceMutation.mutate({ key: 'security.crowdsec.enabled', enabled: e.target.checked })
|
||||
crowdsecPowerMutation.mutate(e.target.checked)
|
||||
}}
|
||||
data-testid="toggle-crowdsec"
|
||||
/>
|
||||
@@ -245,67 +260,35 @@ export default function Security() {
|
||||
{crowdsecStatus && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{crowdsecStatus.running ? `Running (pid ${crowdsecStatus.pid})` : 'Stopped'}</p>
|
||||
)}
|
||||
{status.crowdsec.enabled && (
|
||||
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => navigate('/tasks/logs?search=crowdsec')}
|
||||
>
|
||||
Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={async () => {
|
||||
// download config
|
||||
try {
|
||||
const resp = await exportCrowdsecConfig()
|
||||
const url = window.URL.createObjectURL(new Blob([resp]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `crowdsec-config-${new Date().toISOString().slice(0,19).replace(/[:T]/g, '-')}.tar.gz`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
toast.success('CrowdSec configuration exported')
|
||||
} catch {
|
||||
toast.error('Failed to export CrowdSec configuration')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="w-full text-xs" onClick={() => navigate('/security/crowdsec')}>
|
||||
Config
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => startMutation.mutate()}
|
||||
data-testid="crowdsec-start"
|
||||
isLoading={startMutation.isPending}
|
||||
disabled={!!crowdsecStatus?.running}
|
||||
>
|
||||
Start
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => stopMutation.mutate()}
|
||||
data-testid="crowdsec-stop"
|
||||
isLoading={stopMutation.isPending}
|
||||
disabled={!crowdsecStatus?.running}
|
||||
>
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4 grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => navigate('/tasks/logs?search=crowdsec')}
|
||||
disabled={crowdsecControlsDisabled}
|
||||
>
|
||||
Logs
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={handleCrowdsecExport}
|
||||
disabled={crowdsecControlsDisabled}
|
||||
>
|
||||
Export
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="w-full text-xs"
|
||||
onClick={() => navigate('/security/crowdsec')}
|
||||
disabled={crowdsecControlsDisabled}
|
||||
>
|
||||
Config
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Card } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
@@ -10,6 +10,7 @@ import { getFeatureFlags, updateFeatureFlags } from '../api/featureFlags'
|
||||
import client from '../api/client'
|
||||
// CrowdSec runtime control is now in the Security page
|
||||
import { Loader2, Server, RefreshCw, Save, Activity } from 'lucide-react'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
|
||||
interface HealthResponse {
|
||||
status: string
|
||||
@@ -96,6 +97,22 @@ export default function SystemSettings() {
|
||||
queryFn: getFeatureFlags,
|
||||
})
|
||||
|
||||
const featureToggles = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'feature.cerberus.enabled',
|
||||
label: 'Cerberus Security Suite',
|
||||
tooltip: 'Advanced security features including WAF, Access Lists, Rate Limiting, and CrowdSec.',
|
||||
},
|
||||
{
|
||||
key: 'feature.uptime.enabled',
|
||||
label: 'Uptime Monitoring',
|
||||
tooltip: 'Monitor the availability of your proxy hosts and remote servers.',
|
||||
},
|
||||
],
|
||||
[]
|
||||
)
|
||||
|
||||
const updateFlagMutation = useMutation({
|
||||
mutationFn: async (payload: Record<string, boolean>) => updateFeatureFlags(payload),
|
||||
onSuccess: () => {
|
||||
@@ -110,16 +127,54 @@ export default function SystemSettings() {
|
||||
|
||||
// CrowdSec control
|
||||
|
||||
// Determine loading message
|
||||
const { message, submessage } = updateFlagMutation.isPending
|
||||
? { message: 'Updating features...', submessage: 'Applying configuration changes' }
|
||||
: { message: 'Loading...', submessage: 'Please wait' }
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Server className="w-8 h-8" />
|
||||
System Settings
|
||||
</h1>
|
||||
<>
|
||||
{updateFlagMutation.isPending && (
|
||||
<ConfigReloadOverlay
|
||||
message={message}
|
||||
submessage={submessage}
|
||||
type="charon"
|
||||
/>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<Server className="w-8 h-8" />
|
||||
System Settings
|
||||
</h1>
|
||||
|
||||
{/* General Configuration */}
|
||||
<Card className="p-6">
|
||||
{/* Features */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Features</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{featureFlags ? (
|
||||
featureToggles.map(({ key, label, tooltip }) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-100 dark:border-gray-800"
|
||||
title={tooltip}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white cursor-help">{label}</p>
|
||||
<Switch
|
||||
aria-label={`${label} toggle`}
|
||||
checked={!!featureFlags[key]}
|
||||
disabled={updateFlagMutation.isPending}
|
||||
onChange={(e) => updateFlagMutation.mutate({ [key]: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-gray-500 col-span-2">Loading features...</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* General Configuration */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">General Configuration</h2>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
@@ -182,45 +237,8 @@ export default function SystemSettings() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Optional Features */}
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">Optional Features</h2>
|
||||
<div className="space-y-6">
|
||||
{featureFlags ? (
|
||||
<>
|
||||
{/* Cerberus */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Cerberus Security Suite</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Advanced security features including WAF, Access Lists, Rate Limiting, and CrowdSec.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!featureFlags['feature.cerberus.enabled']}
|
||||
onChange={(e) => updateFlagMutation.mutate({ 'feature.cerberus.enabled': e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
{/* Optional Features - Removed (Moved to top) */}
|
||||
|
||||
{/* Uptime */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Uptime Monitoring</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Monitor the availability of your proxy hosts and remote servers.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!!featureFlags['feature.uptime.enabled']}
|
||||
onChange={(e) => updateFlagMutation.mutate({ 'feature.uptime.enabled': e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">Loading features...</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* System Status */}
|
||||
<Card className="p-6">
|
||||
@@ -325,5 +343,6 @@ export default function SystemSettings() {
|
||||
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { AxiosError } from 'axios'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
@@ -8,11 +9,14 @@ import * as api from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as presetsApi from '../../api/presets'
|
||||
import { CROWDSEC_PRESETS } from '../../data/crowdsecPresets'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../api/presets')
|
||||
|
||||
const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
const renderWithProviders = (ui: React.ReactNode) => {
|
||||
@@ -27,13 +31,46 @@ const renderWithProviders = (ui: React.ReactNode) => {
|
||||
}
|
||||
|
||||
describe('CrowdSecConfig', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({
|
||||
presets: CROWDSEC_PRESETS.map((preset) => ({
|
||||
slug: preset.slug,
|
||||
title: preset.title,
|
||||
summary: preset.description,
|
||||
source: 'charon',
|
||||
requires_hub: false,
|
||||
available: true,
|
||||
cached: false,
|
||||
})),
|
||||
})
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
|
||||
status: 'pulled',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
preview: CROWDSEC_PRESETS[0].content,
|
||||
cache_key: 'cache-123',
|
||||
etag: 'etag-123',
|
||||
retrieved_at: '2024-01-01T00:00:00Z',
|
||||
source: 'hub',
|
||||
})
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({
|
||||
status: 'applied',
|
||||
backup: '/tmp/backup.tar.gz',
|
||||
reload_hint: 'CrowdSec reloaded',
|
||||
used_cscli: true,
|
||||
cache_key: 'cache-123',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
})
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached', cache_key: 'cache-123', etag: 'etag-123' })
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
|
||||
})
|
||||
|
||||
it('exports config when clicking Export', async () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue({ crowdsec: { enabled: true, mode: 'local', api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' }, rate_limit: { enabled: false }, acl: { enabled: false } })
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
const blob = new Blob(['dummy'])
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob)
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export')
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const exportBtn = screen.getByText('Export')
|
||||
@@ -69,8 +106,7 @@ describe('CrowdSecConfig', () => {
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
// wait for file list
|
||||
await waitFor(() => expect(screen.getByText('conf.d/a.conf')).toBeInTheDocument())
|
||||
const selects = screen.getAllByRole('combobox')
|
||||
const select = selects[1]
|
||||
const select = screen.getByTestId('crowdsec-file-select')
|
||||
await userEvent.selectOptions(select, 'conf.d/a.conf')
|
||||
await waitFor(() => expect(crowdsecApi.readCrowdsecFile).toHaveBeenCalledWith('conf.d/a.conf'))
|
||||
// ensure textarea populated
|
||||
@@ -93,9 +129,123 @@ describe('CrowdSecConfig', () => {
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
const selects = screen.getAllByRole('combobox')
|
||||
const modeSelect = selects[0]
|
||||
await userEvent.selectOptions(modeSelect, 'local')
|
||||
const modeToggle = screen.getByTestId('crowdsec-mode-toggle')
|
||||
await userEvent.click(modeToggle)
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.crowdsec.mode', 'local', 'security', 'string'))
|
||||
})
|
||||
|
||||
it('renders preset preview and applies with backup when backend apply is unavailable', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
const presetContent = CROWDSEC_PRESETS.find((preset) => preset.slug === 'bot-mitigation-essentials')?.content || ''
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({ status: 'written' })
|
||||
const axiosError = new AxiosError('not implemented', undefined, undefined, undefined, {
|
||||
status: 501,
|
||||
statusText: 'Not Implemented',
|
||||
headers: {},
|
||||
config: {},
|
||||
data: {},
|
||||
} as any)
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockRejectedValue(axiosError)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
await waitFor(() => expect(screen.getByText('CrowdSec Configuration')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('configs:'))
|
||||
const fileSelect = screen.getByTestId('crowdsec-file-select')
|
||||
await userEvent.selectOptions(fileSelect, 'acquis.yaml')
|
||||
const applyBtn = screen.getByTestId('apply-preset-btn')
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => expect(presetsApi.applyCrowdsecPreset).toHaveBeenCalledWith({ slug: 'bot-mitigation-essentials', cache_key: 'cache-123' }))
|
||||
await waitFor(() => expect(backupsApi.createBackup).toHaveBeenCalled())
|
||||
await waitFor(() => expect(crowdsecApi.writeCrowdsecFile).toHaveBeenCalledWith('acquis.yaml', presetContent))
|
||||
})
|
||||
|
||||
it('surfaces validation error when slug is invalid', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
const validationError = new AxiosError('invalid', undefined, undefined, undefined, {
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
headers: {},
|
||||
config: {},
|
||||
data: { error: 'slug invalid' },
|
||||
} as any)
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValueOnce(validationError)
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-validation-error')).toHaveTextContent('slug invalid'))
|
||||
})
|
||||
|
||||
it('disables apply and offers cached preview when hub is unavailable', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValueOnce({
|
||||
presets: [
|
||||
{
|
||||
slug: 'hub-only',
|
||||
title: 'Hub Only',
|
||||
summary: 'Needs hub',
|
||||
source: 'hub',
|
||||
requires_hub: true,
|
||||
available: true,
|
||||
cached: true,
|
||||
cache_key: 'cache-hub',
|
||||
etag: 'etag-hub',
|
||||
},
|
||||
],
|
||||
})
|
||||
const hubError = new AxiosError('unavailable', undefined, undefined, undefined, {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: {},
|
||||
config: {},
|
||||
data: { error: 'hub service unavailable' },
|
||||
} as any)
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockRejectedValue(hubError)
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'cached-preview', cache_key: 'cache-hub', etag: 'etag-hub' })
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
const select = await screen.findByTestId('preset-select')
|
||||
await waitFor(() => expect(screen.getByText('Hub Only')).toBeInTheDocument())
|
||||
await userEvent.selectOptions(select, 'hub-only')
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-hub-unavailable')).toBeInTheDocument())
|
||||
|
||||
const applyBtn = screen.getByTestId('apply-preset-btn') as HTMLButtonElement
|
||||
expect(applyBtn.disabled).toBe(true)
|
||||
|
||||
await userEvent.click(screen.getByText('Use cached preview'))
|
||||
await waitFor(() => expect(screen.getByTestId('preset-preview')).toHaveTextContent('cached-preview'))
|
||||
})
|
||||
|
||||
it('shows apply response metadata including backup path', async () => {
|
||||
const status = { crowdsec: { enabled: true, mode: 'local' as const, api_url: '' }, cerberus: { enabled: true }, waf: { enabled: false, mode: 'disabled' as const }, rate_limit: { enabled: false }, acl: { enabled: false } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status)
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: ['acquis.yaml'] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValueOnce({
|
||||
status: 'applied',
|
||||
backup: '/tmp/crowdsec-backup',
|
||||
reload_hint: 'crowdsec reloaded',
|
||||
used_cscli: true,
|
||||
cache_key: 'cache-123',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
})
|
||||
|
||||
renderWithProviders(<CrowdSecConfig />)
|
||||
|
||||
const applyBtn = await screen.findByTestId('apply-preset-btn')
|
||||
await userEvent.click(applyBtn)
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('/tmp/crowdsec-backup'))
|
||||
expect(screen.getByTestId('preset-apply-info')).toHaveTextContent('crowdsec reloaded')
|
||||
})
|
||||
})
|
||||
|
||||
118
frontend/src/pages/__tests__/CrowdSecConfig.test.tsx
Normal file
118
frontend/src/pages/__tests__/CrowdSecConfig.test.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import CrowdSecConfig from '../CrowdSecConfig'
|
||||
import * as securityApi from '../../api/security'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import * as presetsApi from '../../api/presets'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('../../api/settings')
|
||||
vi.mock('../../api/presets')
|
||||
vi.mock('../../utils/toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('CrowdSecConfig', () => {
|
||||
const createClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
|
||||
const renderWithProviders = () => {
|
||||
const queryClient = createClient()
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter>
|
||||
<CrowdSecConfig />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled', enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
})
|
||||
vi.mocked(crowdsecApi.listCrowdsecFiles).mockResolvedValue({ files: [] })
|
||||
vi.mocked(crowdsecApi.readCrowdsecFile).mockResolvedValue({ content: '' })
|
||||
vi.mocked(crowdsecApi.writeCrowdsecFile).mockResolvedValue({})
|
||||
vi.mocked(crowdsecApi.listCrowdsecDecisions).mockResolvedValue({ decisions: [] })
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(new Blob(['data']))
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(presetsApi.listCrowdsecPresets).mockResolvedValue({ presets: [] })
|
||||
vi.mocked(presetsApi.pullCrowdsecPreset).mockResolvedValue({
|
||||
status: 'pulled',
|
||||
slug: 'bot-mitigation-essentials',
|
||||
preview: 'configs: {}',
|
||||
cache_key: 'cache-123',
|
||||
})
|
||||
vi.mocked(presetsApi.applyCrowdsecPreset).mockResolvedValue({ status: 'applied', backup: '/tmp/backup.tar.gz', cache_key: 'cache-123' })
|
||||
vi.mocked(presetsApi.getCrowdsecPresetCache).mockResolvedValue({ preview: 'configs: {}', cache_key: 'cache-123' })
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export.tar.gz')
|
||||
window.URL.createObjectURL = vi.fn(() => 'blob:url')
|
||||
window.URL.revokeObjectURL = vi.fn()
|
||||
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
it('toggles mode between local and disabled', async () => {
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => screen.getByTestId('crowdsec-mode-toggle'))
|
||||
const toggle = screen.getByTestId('crowdsec-mode-toggle')
|
||||
|
||||
await userEvent.click(toggle)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(settingsApi.updateSetting).toHaveBeenCalledWith(
|
||||
'security.crowdsec.mode',
|
||||
'disabled',
|
||||
'security',
|
||||
'string'
|
||||
)
|
||||
expect(toast.success).toHaveBeenCalledWith('CrowdSec disabled')
|
||||
})
|
||||
})
|
||||
|
||||
it('exports configuration packages with prompted filename', async () => {
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
|
||||
const exportButton = screen.getByRole('button', { name: /Export/i })
|
||||
|
||||
await userEvent.click(exportButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled()
|
||||
expect(toast.success).toHaveBeenCalledWith('CrowdSec configuration exported')
|
||||
})
|
||||
})
|
||||
|
||||
it('shows Configuration Packages heading', async () => {
|
||||
renderWithProviders()
|
||||
|
||||
await waitFor(() => screen.getByText('Configuration Packages'))
|
||||
|
||||
expect(screen.getByText('Configuration Packages')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
64
frontend/src/pages/__tests__/ImportCrowdSec.test.tsx
Normal file
64
frontend/src/pages/__tests__/ImportCrowdSec.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import ImportCrowdSec from '../ImportCrowdSec'
|
||||
import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as backupsApi from '../../api/backups'
|
||||
import { toast } from 'react-hot-toast'
|
||||
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/backups')
|
||||
vi.mock('react-hot-toast', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ImportCrowdSec', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(backupsApi.createBackup).mockResolvedValue({ filename: 'backup.tar.gz' })
|
||||
vi.mocked(crowdsecApi.importCrowdsecConfig).mockResolvedValue({})
|
||||
})
|
||||
|
||||
const renderPage = () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>
|
||||
<ImportCrowdSec />
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
it('renders configuration packages heading', async () => {
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => screen.getByText('CrowdSec Configuration Packages'))
|
||||
expect(screen.getByText('CrowdSec Configuration Packages')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('creates a backup before importing selected package', async () => {
|
||||
renderPage()
|
||||
|
||||
const fileInput = screen.getByTestId('crowdsec-import-file') as HTMLInputElement
|
||||
const file = new File(['config'], 'config.tar.gz', { type: 'application/gzip' })
|
||||
|
||||
await userEvent.upload(fileInput, file)
|
||||
|
||||
const importButton = screen.getByRole('button', { name: /Import/i })
|
||||
await userEvent.click(importButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(backupsApi.createBackup).toHaveBeenCalled()
|
||||
expect(crowdsecApi.importCrowdsecConfig).toHaveBeenCalledWith(file)
|
||||
expect(toast.success).toHaveBeenCalledWith('CrowdSec config imported')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,10 +2,10 @@
|
||||
* 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 { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
@@ -15,6 +15,14 @@ import * as crowdsecApi from '../../api/crowdsec'
|
||||
import * as settingsApi from '../../api/settings'
|
||||
import { toast } from '../../utils/toast'
|
||||
|
||||
const mockSecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true },
|
||||
}
|
||||
|
||||
vi.mock('../../api/security')
|
||||
vi.mock('../../api/crowdsec')
|
||||
vi.mock('../../api/settings')
|
||||
@@ -46,6 +54,12 @@ describe('Security Page - QA Security Audit', () => {
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
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 }) => (
|
||||
@@ -54,12 +68,10 @@ describe('Security Page - QA Security Audit', () => {
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const mockSecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
waf: { mode: 'enabled' as const, enabled: true },
|
||||
rate_limit: { enabled: true },
|
||||
acl: { enabled: true }
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
describe('Input Validation', () => {
|
||||
@@ -68,9 +80,9 @@ describe('Security Page - QA Security Audit', () => {
|
||||
// won't execute. This test verifies that property.
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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)
|
||||
@@ -82,9 +94,9 @@ describe('Security Page - QA Security Audit', () => {
|
||||
it('handles empty admin whitelist gracefully', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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('')
|
||||
@@ -98,28 +110,31 @@ describe('Security Page - QA Security Audit', () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
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'))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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()
|
||||
@@ -132,11 +147,11 @@ describe('Security Page - QA Security Audit', () => {
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockRejectedValue(new Error('Failed to stop'))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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()
|
||||
@@ -148,7 +163,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockRejectedValue(new Error('Export failed'))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
|
||||
const exportButton = screen.getByRole('button', { name: /Export/i })
|
||||
@@ -163,10 +178,10 @@ describe('Security Page - QA Security Audit', () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockRejectedValue(new Error('Status check failed'))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -177,19 +192,22 @@ describe('Security Page - QA Security Audit', () => {
|
||||
// Never resolving promise to simulate pending state
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-cerberus'))
|
||||
const toggle = screen.getByTestId('toggle-cerberus')
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
|
||||
// Overlay should appear indicating operation in progress
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus awakens/i)).toBeInTheDocument())
|
||||
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 () => {
|
||||
@@ -198,17 +216,19 @@ describe('Security Page - QA Security Audit', () => {
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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 new Promise(resolve => setTimeout(resolve, 150))
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
})
|
||||
|
||||
// Should only be called once due to disabled state
|
||||
expect(callCount).toBe(1)
|
||||
@@ -221,9 +241,9 @@ describe('Security Page - QA Security Audit', () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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 })
|
||||
@@ -246,9 +266,9 @@ describe('Security Page - QA Security Audit', () => {
|
||||
it('shows correct layer indicator icons', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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()
|
||||
@@ -267,9 +287,9 @@ describe('Security Page - QA Security Audit', () => {
|
||||
}
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(disabledStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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()
|
||||
@@ -283,11 +303,10 @@ describe('Security Page - QA Security Audit', () => {
|
||||
it('all toggles have proper test IDs for automation', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
expect(screen.getByTestId('toggle-cerberus')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-crowdsec')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-acl')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('toggle-waf')).toBeInTheDocument()
|
||||
@@ -297,24 +316,27 @@ describe('Security Page - QA Security Audit', () => {
|
||||
it('WAF controls have proper test IDs when enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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 })
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -322,9 +344,9 @@ describe('Security Page - QA Security Audit', () => {
|
||||
it('pipeline order matches spec: CrowdSec → ACL → WAF → Rate Limiting', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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)
|
||||
@@ -336,9 +358,9 @@ describe('Security Page - QA Security Audit', () => {
|
||||
it('layer indicators match spec descriptions', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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()
|
||||
@@ -350,9 +372,9 @@ describe('Security Page - QA Security Audit', () => {
|
||||
it('threat summaries match spec when services enabled', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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"
|
||||
@@ -374,7 +396,7 @@ describe('Security Page - QA Security Audit', () => {
|
||||
() => new Promise(resolve => setTimeout(resolve, 50))
|
||||
)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
|
||||
@@ -386,17 +408,17 @@ 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 () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue(null as never)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
// Should not crash
|
||||
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Dashboard/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('Security page', () => {
|
||||
} as SecurityStatus)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
expect(await screen.findByText('Security Suite Disabled')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Cerberus Disabled')).toBeInTheDocument()
|
||||
const docBtns = screen.getAllByText('Documentation')
|
||||
expect(docBtns.length).toBeGreaterThan(0)
|
||||
})
|
||||
@@ -80,14 +80,9 @@ describe('Security page', () => {
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
|
||||
// debug: ensure element state
|
||||
console.log('crowdsecToggle disabled:', (crowdsecToggle as HTMLInputElement).disabled)
|
||||
expect(crowdsecToggle).toBeTruthy()
|
||||
// Ensure the toggle exists and is not disabled
|
||||
expect(crowdsecToggle).toBeTruthy()
|
||||
expect((crowdsecToggle as HTMLInputElement).disabled).toBe(false)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec') as HTMLInputElement
|
||||
expect(crowdsecToggle.disabled).toBe(false)
|
||||
// Ensure enable-all controls were removed
|
||||
expect(screen.queryByTestId('enable-all-btn')).toBeNull()
|
||||
})
|
||||
@@ -103,7 +98,7 @@ describe('Security page', () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
const updateSpy = vi.mocked(settingsApi.updateSetting)
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const aclToggle = screen.getByTestId('toggle-acl')
|
||||
await userEvent.click(aclToggle)
|
||||
await waitFor(() => expect(updateSpy).toHaveBeenCalledWith('security.acl.enabled', 'true', 'security', 'bool'))
|
||||
@@ -120,42 +115,47 @@ describe('Security page', () => {
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
const blob = new Blob(['dummy'])
|
||||
vi.mocked(crowdsecApi.exportCrowdsecConfig).mockResolvedValue(blob)
|
||||
vi.spyOn(window, 'prompt').mockReturnValue('crowdsec-export')
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const exportBtn = screen.getByText('Export')
|
||||
await userEvent.click(exportBtn)
|
||||
await waitFor(() => expect(crowdsecApi.exportCrowdsecConfig).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
it('calls start/stop endpoints for CrowdSec', async () => {
|
||||
const status: SecurityStatus = {
|
||||
it('calls start/stop endpoints for CrowdSec via toggle', async () => {
|
||||
const user = userEvent.setup()
|
||||
const baseStatus: SecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { enabled: true, mode: 'local' as const, api_url: '' },
|
||||
crowdsec: { enabled: false, mode: 'disabled' as const, api_url: '' },
|
||||
waf: { enabled: false, mode: 'disabled' as const },
|
||||
rate_limit: { enabled: false },
|
||||
acl: { enabled: false },
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
// Test start
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue(undefined)
|
||||
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(baseStatus as SecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
|
||||
vi.mocked(crowdsecApi.startCrowdsec).mockResolvedValue(undefined)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
const startBtn = screen.getByText('Start')
|
||||
await userEvent.click(startBtn)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(toggle)
|
||||
await waitFor(() => expect(crowdsecApi.startCrowdsec).toHaveBeenCalled())
|
||||
// Cleanup before re-render to avoid multiple DOM instances
|
||||
|
||||
cleanup()
|
||||
|
||||
// Test stop: render with running state and click stop
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue(undefined)
|
||||
const enabledStatus: SecurityStatus = { ...baseStatus, crowdsec: { enabled: true, mode: 'local' as const, api_url: '' } }
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(enabledStatus as SecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 123 })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue(undefined)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText('Stop')).toBeInTheDocument())
|
||||
const stopBtn = screen.getAllByText('Stop').find(b => !b.hasAttribute('disabled'))
|
||||
if (!stopBtn) throw new Error('No enabled Stop button found')
|
||||
await userEvent.click(stopBtn)
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
const stopToggle = screen.getByTestId('toggle-crowdsec')
|
||||
await user.click(stopToggle)
|
||||
await waitFor(() => expect(crowdsecApi.stopCrowdsec).toHaveBeenCalled())
|
||||
})
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('Security page', () => {
|
||||
}
|
||||
vi.mocked(api.getSecurityStatus).mockResolvedValue(status as SecurityStatus)
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Suite Disabled')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Disabled')).toBeInTheDocument())
|
||||
const crowdsecToggle = screen.getByTestId('toggle-crowdsec')
|
||||
expect(crowdsecToggle).toBeDisabled()
|
||||
})
|
||||
@@ -325,7 +325,7 @@ describe('Security page', () => {
|
||||
vi.mocked(api.getRuleSets).mockResolvedValue(mockRuleSets)
|
||||
|
||||
renderWithProviders(<Security />)
|
||||
await waitFor(() => expect(screen.getByText('Security Dashboard')).toBeInTheDocument())
|
||||
await waitFor(() => expect(screen.getByText('Cerberus Dashboard')).toBeInTheDocument())
|
||||
|
||||
// Mode selector and ruleset selector should not be visible
|
||||
expect(screen.queryByTestId('waf-mode-select')).not.toBeInTheDocument()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
@@ -46,6 +46,13 @@ describe('Security', () => {
|
||||
},
|
||||
})
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: false })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
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 }) => (
|
||||
@@ -54,6 +61,12 @@ describe('Security', () => {
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
const renderSecurityPage = async () => {
|
||||
await act(async () => {
|
||||
render(<Security />, { wrapper })
|
||||
})
|
||||
}
|
||||
|
||||
const mockSecurityStatus = {
|
||||
cerberus: { enabled: true },
|
||||
crowdsec: { mode: 'local' as const, api_url: 'http://localhost', enabled: true },
|
||||
@@ -63,58 +76,30 @@ describe('Security', () => {
|
||||
}
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should show loading state initially', () => {
|
||||
it('should show loading state initially', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockReturnValue(new Promise(() => {}))
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await renderSecurityPage()
|
||||
|
||||
expect(screen.getByText(/Loading security status/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show error if security status fails to load', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockRejectedValue(new Error('Failed'))
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
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)
|
||||
render(<Security />, { wrapper })
|
||||
await waitFor(() => expect(screen.getByText(/Security Dashboard/i)).toBeInTheDocument())
|
||||
await renderSecurityPage()
|
||||
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 } })
|
||||
render(<Security />, { wrapper })
|
||||
await waitFor(() => expect(screen.getByText(/Security Suite Disabled/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cerberus Toggle', () => {
|
||||
it('should toggle Cerberus on', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, cerberus: { enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-cerberus'))
|
||||
const toggle = screen.getByTestId('toggle-cerberus')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.cerberus.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
|
||||
it('should toggle Cerberus off', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-cerberus'))
|
||||
const toggle = screen.getByTestId('toggle-cerberus')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.cerberus.enabled', 'false', 'security', 'bool'))
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus Disabled/i)).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
@@ -124,7 +109,7 @@ describe('Security', () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, crowdsec: { mode: 'local', api_url: 'http://localhost', enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-crowdsec'))
|
||||
const toggle = screen.getByTestId('toggle-crowdsec')
|
||||
@@ -138,11 +123,13 @@ describe('Security', () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, waf: { mode: 'enabled', enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
await user.click(toggle)
|
||||
await act(async () => {
|
||||
await user.click(toggle)
|
||||
})
|
||||
|
||||
await waitFor(() => expect(settingsApi.updateSetting).toHaveBeenCalledWith('security.waf.enabled', 'true', 'security', 'bool'))
|
||||
})
|
||||
@@ -152,7 +139,7 @@ describe('Security', () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, acl: { enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-acl'))
|
||||
const toggle = screen.getByTestId('toggle-acl')
|
||||
@@ -166,7 +153,7 @@ describe('Security', () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue({ ...mockSecurityStatus, rate_limit: { enabled: false } })
|
||||
vi.mocked(settingsApi.updateSetting).mockResolvedValue()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-rate-limit'))
|
||||
const toggle = screen.getByTestId('toggle-rate-limit')
|
||||
@@ -179,8 +166,8 @@ describe('Security', () => {
|
||||
describe('Admin Whitelist', () => {
|
||||
it('should load admin whitelist from config', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
|
||||
expect(screen.getByDisplayValue('10.0.0.0/8')).toBeInTheDocument()
|
||||
})
|
||||
@@ -192,7 +179,7 @@ describe('Security', () => {
|
||||
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType<typeof useUpdateSecurityConfig>)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByDisplayValue('10.0.0.0/8'))
|
||||
|
||||
@@ -206,34 +193,47 @@ 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 })
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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 act(async () => {
|
||||
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 })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockResolvedValue({ success: true })
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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 act(async () => {
|
||||
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 () => {
|
||||
@@ -243,7 +243,7 @@ describe('Security', () => {
|
||||
window.URL.createObjectURL = vi.fn(() => 'blob:url')
|
||||
window.URL.revokeObjectURL = vi.fn()
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByRole('button', { name: /Export/i }))
|
||||
const exportButton = screen.getByRole('button', { name: /Export/i })
|
||||
@@ -264,7 +264,7 @@ describe('Security', () => {
|
||||
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType<typeof useUpdateSecurityConfig>)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('waf-mode-select'))
|
||||
const select = screen.getByTestId('waf-mode-select')
|
||||
@@ -280,7 +280,7 @@ describe('Security', () => {
|
||||
vi.mocked(useUpdateSecurityConfig).mockReturnValue({ mutate: mockMutate, isPending: false } as unknown as ReturnType<typeof useUpdateSecurityConfig>)
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('waf-ruleset-select'))
|
||||
const select = screen.getByTestId('waf-ruleset-select')
|
||||
@@ -293,9 +293,9 @@ describe('Security', () => {
|
||||
describe('Card Order (Pipeline Sequence)', () => {
|
||||
it('should render cards in correct pipeline order: CrowdSec → ACL → WAF → Rate Limiting', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Get all card headings
|
||||
const cards = screen.getAllByRole('heading', { level: 3 })
|
||||
@@ -307,9 +307,9 @@ describe('Security', () => {
|
||||
|
||||
it('should display layer indicators on each card', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Verify each layer indicator is present
|
||||
expect(screen.getByText(/Layer 1: IP Reputation/i)).toBeInTheDocument()
|
||||
@@ -320,9 +320,9 @@ describe('Security', () => {
|
||||
|
||||
it('should display threat protection summaries', async () => {
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByText(/Security Dashboard/i))
|
||||
await renderSecurityPage()
|
||||
await waitFor(() => screen.getByText(/Cerberus Dashboard/i))
|
||||
|
||||
// Verify threat protection descriptions
|
||||
expect(screen.getByText(/Known attackers, botnets/i)).toBeInTheDocument()
|
||||
@@ -333,26 +333,12 @@ describe('Security', () => {
|
||||
})
|
||||
|
||||
describe('Loading Overlay', () => {
|
||||
it('should show Cerberus overlay when Cerberus is toggling', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-cerberus'))
|
||||
const toggle = screen.getByTestId('toggle-cerberus')
|
||||
await user.click(toggle)
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Cerberus awakens/i)).toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('should show overlay when service is toggling', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(securityApi.getSecurityStatus).mockResolvedValue(mockSecurityStatus)
|
||||
vi.mocked(settingsApi.updateSetting).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
await renderSecurityPage()
|
||||
|
||||
await waitFor(() => screen.getByTestId('toggle-waf'))
|
||||
const toggle = screen.getByTestId('toggle-waf')
|
||||
@@ -363,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(() => {}))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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())
|
||||
})
|
||||
@@ -382,11 +371,11 @@ describe('Security', () => {
|
||||
vi.mocked(crowdsecApi.statusCrowdsec).mockResolvedValue({ running: true, pid: 1234 })
|
||||
vi.mocked(crowdsecApi.stopCrowdsec).mockImplementation(() => new Promise(() => {}))
|
||||
|
||||
render(<Security />, { wrapper })
|
||||
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())
|
||||
})
|
||||
|
||||
@@ -63,7 +63,10 @@ describe('SystemSettings', () => {
|
||||
'security.cerberus.enabled': 'false',
|
||||
})
|
||||
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({})
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
vi.mocked(client.get).mockResolvedValue({
|
||||
data: {
|
||||
@@ -382,44 +385,53 @@ describe('SystemSettings', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Optional Features', () => {
|
||||
it('renders the Optional Features section', async () => {
|
||||
describe('Features', () => {
|
||||
it('renders the Features section', async () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Optional Features')).toBeTruthy()
|
||||
expect(screen.getByText('Features')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('displays Cerberus Security Suite toggle', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
|
||||
expect(screen.getByText('Advanced security features including WAF, Access Lists, Rate Limiting, and CrowdSec.')).toBeTruthy()
|
||||
})
|
||||
|
||||
const cerberusLabel = screen.getByText('Cerberus Security Suite')
|
||||
const tooltipParent = cerberusLabel.closest('[title]') as HTMLElement
|
||||
expect(tooltipParent?.getAttribute('title')).toContain('Advanced security features')
|
||||
})
|
||||
|
||||
it('displays Uptime Monitoring toggle', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.uptime.enabled': true,
|
||||
'feature.cerberus.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Uptime Monitoring')).toBeTruthy()
|
||||
expect(screen.getByText('Monitor the availability of your proxy hosts and remote servers.')).toBeTruthy()
|
||||
})
|
||||
|
||||
const uptimeLabel = screen.getByText('Uptime Monitoring')
|
||||
const tooltipParent = uptimeLabel.closest('[title]') as HTMLElement
|
||||
expect(tooltipParent?.getAttribute('title')).toContain('Monitor the availability')
|
||||
})
|
||||
|
||||
it('shows Cerberus toggle as checked when enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': true,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
@@ -438,6 +450,7 @@ describe('SystemSettings', () => {
|
||||
it('shows Uptime toggle as checked when enabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.uptime.enabled': true,
|
||||
'feature.cerberus.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
@@ -455,6 +468,7 @@ describe('SystemSettings', () => {
|
||||
it('shows Cerberus toggle as unchecked when disabled', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
@@ -472,6 +486,7 @@ describe('SystemSettings', () => {
|
||||
it('toggles Cerberus feature flag when switch is clicked', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined)
|
||||
|
||||
@@ -498,6 +513,7 @@ describe('SystemSettings', () => {
|
||||
it('toggles Uptime feature flag when switch is clicked', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.uptime.enabled': true,
|
||||
'feature.cerberus.enabled': false,
|
||||
})
|
||||
vi.mocked(featureFlagsApi.updateFeatureFlags).mockResolvedValue(undefined)
|
||||
|
||||
@@ -527,10 +543,37 @@ describe('SystemSettings', () => {
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Optional Features')).toBeTruthy()
|
||||
expect(screen.getByText('Features')).toBeTruthy()
|
||||
})
|
||||
|
||||
expect(screen.getByText('Loading features...')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows loading overlay while toggling a feature flag', async () => {
|
||||
vi.mocked(featureFlagsApi.getFeatureFlags).mockResolvedValue({
|
||||
'feature.cerberus.enabled': false,
|
||||
'feature.uptime.enabled': false,
|
||||
})
|
||||
vi.mocked(featureFlagsApi.updateFeatureFlags).mockImplementation(
|
||||
() => new Promise(() => {})
|
||||
)
|
||||
|
||||
renderWithProviders(<SystemSettings />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Cerberus Security Suite')).toBeTruthy()
|
||||
})
|
||||
|
||||
const user = userEvent.setup()
|
||||
const cerberusText = screen.getByText('Cerberus Security Suite')
|
||||
const parentDiv = cerberusText.closest('.flex')
|
||||
const switchInput = parentDiv?.querySelector('input[type="checkbox"]') as HTMLInputElement
|
||||
|
||||
await user.click(switchInput)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Updating features...')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
24
frontend/src/utils/crowdsecExport.ts
Normal file
24
frontend/src/utils/crowdsecExport.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export const buildCrowdsecExportFilename = (): string => {
|
||||
const timestamp = new Date().toISOString().replace(/:/g, '-')
|
||||
return `crowdsec-export-${timestamp}.tar.gz`
|
||||
}
|
||||
|
||||
export const promptCrowdsecFilename = (defaultName = buildCrowdsecExportFilename()): string | null => {
|
||||
const input = window.prompt('Name your CrowdSec export archive', defaultName)
|
||||
if (input === null || typeof input === 'undefined') return null
|
||||
const trimmed = typeof input === 'string' ? input.trim() : ''
|
||||
const candidate = trimmed || defaultName
|
||||
const sanitized = candidate.replace(/[\\/]+/g, '-').replace(/\s+/g, '-')
|
||||
return sanitized.toLowerCase().endsWith('.tar.gz') ? sanitized : `${sanitized}.tar.gz`
|
||||
}
|
||||
|
||||
export const downloadCrowdsecExport = (blob: Blob, filename: string) => {
|
||||
const url = window.URL.createObjectURL(new Blob([blob]))
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
}
|
||||
Reference in New Issue
Block a user