From 37c7c4aeb8503bfd1d8268eed69d76bfe2a86fb9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:35:17 +0000 Subject: [PATCH 01/17] chore(deps): update dependency knip to ^6.0.5 --- frontend/package-lock.json | 2 +- frontend/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 492f4e6e..da301968 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -69,7 +69,7 @@ "eslint-plugin-unicorn": "^63.0.0", "eslint-plugin-unused-imports": "^4.4.1", "jsdom": "29.0.1", - "knip": "^6.0.4", + "knip": "^6.0.5", "postcss": "^8.5.8", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", diff --git a/frontend/package.json b/frontend/package.json index 56faffa8..00961164 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -88,7 +88,7 @@ "eslint-plugin-unicorn": "^63.0.0", "eslint-plugin-unused-imports": "^4.4.1", "jsdom": "29.0.1", - "knip": "^6.0.4", + "knip": "^6.0.5", "postcss": "^8.5.8", "tailwindcss": "^4.2.2", "typescript": "^6.0.2", From 1fe69c2a150706d38e50bc1c668b3d0f21731f2e Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 25 Mar 2026 17:16:54 +0000 Subject: [PATCH 02/17] feat: add Top Attacking IPs chart component and integrate into CrowdSec configuration page - Implemented TopAttackingIPsChart component for visualizing top attacking IPs. - Created hooks for fetching CrowdSec dashboard data including summary, timeline, top IPs, scenarios, and alerts. - Added tests for the new hooks to ensure data fetching works as expected. - Updated translation files for new dashboard terms in multiple languages. - Refactored CrowdSecConfig page to include a tabbed interface for configuration and dashboard views. - Added end-to-end tests for CrowdSec dashboard functionality including tab navigation, data display, and interaction with time range and refresh features. --- CHANGELOG.md | 11 + README.md | 4 +- .../api/handlers/crowdsec_dashboard.go | 627 ++++++++ .../api/handlers/crowdsec_dashboard_cache.go | 62 + .../api/handlers/crowdsec_dashboard_test.go | 486 ++++++ .../internal/api/handlers/crowdsec_handler.go | 40 +- backend/internal/models/security_decision.go | 13 +- docs/features.md | 18 + docs/issues/crowdsec-dashboard-manual-test.md | 162 ++ docs/plans/current_spec.md | 1398 ++++++++++++----- docs/reports/qa_report_crowdsec_dashboard.md | 194 +++ frontend/package-lock.json | 378 ++++- frontend/package.json | 1 + frontend/src/api/crowdsecDashboard.ts | 116 ++ .../__tests__/ActiveDecisionsTable.test.tsx | 93 ++ .../components/__tests__/AlertsList.test.tsx | 162 ++ .../__tests__/BanTimelineChart.test.tsx | 61 + .../__tests__/CrowdSecDashboard.test.tsx | 99 ++ .../__tests__/DashboardSummaryCards.test.tsx | 74 + .../DashboardTimeRangeSelector.test.tsx | 109 ++ .../__tests__/DecisionsExportButton.test.tsx | 105 ++ .../__tests__/ScenarioBreakdownChart.test.tsx | 73 + .../__tests__/TopAttackingIPsChart.test.tsx | 60 + .../crowdsec/ActiveDecisionsTable.tsx | 155 ++ .../src/components/crowdsec/AlertsList.tsx | 187 +++ .../components/crowdsec/BanTimelineChart.tsx | 110 ++ .../components/crowdsec/CrowdSecDashboard.tsx | 100 ++ .../crowdsec/DashboardSummaryCards.tsx | 98 ++ .../crowdsec/DashboardTimeRangeSelector.tsx | 83 + .../crowdsec/DecisionsExportButton.tsx | 185 +++ .../crowdsec/ScenarioBreakdownChart.tsx | 107 ++ .../crowdsec/TopAttackingIPsChart.tsx | 88 ++ .../__tests__/useCrowdsecDashboard.test.tsx | 123 ++ frontend/src/hooks/useCrowdsecDashboard.ts | 57 + frontend/src/locales/de/translation.json | 67 + frontend/src/locales/en/translation.json | 67 + frontend/src/locales/es/translation.json | 67 + frontend/src/locales/fr/translation.json | 67 + frontend/src/locales/zh/translation.json | 67 + frontend/src/pages/CrowdSecConfig.tsx | 23 +- tests/security/crowdsec-dashboard.spec.ts | 273 ++++ 41 files changed, 5820 insertions(+), 450 deletions(-) create mode 100644 backend/internal/api/handlers/crowdsec_dashboard.go create mode 100644 backend/internal/api/handlers/crowdsec_dashboard_cache.go create mode 100644 backend/internal/api/handlers/crowdsec_dashboard_test.go create mode 100644 docs/issues/crowdsec-dashboard-manual-test.md create mode 100644 docs/reports/qa_report_crowdsec_dashboard.md create mode 100644 frontend/src/api/crowdsecDashboard.ts create mode 100644 frontend/src/components/__tests__/ActiveDecisionsTable.test.tsx create mode 100644 frontend/src/components/__tests__/AlertsList.test.tsx create mode 100644 frontend/src/components/__tests__/BanTimelineChart.test.tsx create mode 100644 frontend/src/components/__tests__/CrowdSecDashboard.test.tsx create mode 100644 frontend/src/components/__tests__/DashboardSummaryCards.test.tsx create mode 100644 frontend/src/components/__tests__/DashboardTimeRangeSelector.test.tsx create mode 100644 frontend/src/components/__tests__/DecisionsExportButton.test.tsx create mode 100644 frontend/src/components/__tests__/ScenarioBreakdownChart.test.tsx create mode 100644 frontend/src/components/__tests__/TopAttackingIPsChart.test.tsx create mode 100644 frontend/src/components/crowdsec/ActiveDecisionsTable.tsx create mode 100644 frontend/src/components/crowdsec/AlertsList.tsx create mode 100644 frontend/src/components/crowdsec/BanTimelineChart.tsx create mode 100644 frontend/src/components/crowdsec/CrowdSecDashboard.tsx create mode 100644 frontend/src/components/crowdsec/DashboardSummaryCards.tsx create mode 100644 frontend/src/components/crowdsec/DashboardTimeRangeSelector.tsx create mode 100644 frontend/src/components/crowdsec/DecisionsExportButton.tsx create mode 100644 frontend/src/components/crowdsec/ScenarioBreakdownChart.tsx create mode 100644 frontend/src/components/crowdsec/TopAttackingIPsChart.tsx create mode 100644 frontend/src/hooks/__tests__/useCrowdsecDashboard.test.tsx create mode 100644 frontend/src/hooks/useCrowdsecDashboard.ts create mode 100644 tests/security/crowdsec-dashboard.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index edcc6bd2..7474d9e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **CrowdSec Dashboard**: Visual analytics for CrowdSec security data within the Security section + - Summary cards showing total bans, active bans, unique IPs, and top scenario + - Interactive charts: ban timeline (area), top attacking IPs (bar), scenario breakdown (pie) + - Configurable time range selector (1h, 6h, 24h, 7d, 30d) + - Active decisions table with IP, scenario, duration, type, and time remaining + - Alerts feed with pagination sourced from CrowdSec LAPI + - CSV and JSON export for decisions data + - Server-side caching (30–60s TTL) for fast dashboard loads + - Full i18n support across all 5 locales (en, de, fr, es, zh) + - Keyboard navigable, screen-reader compatible (WCAG 2.2 AA) + - **Notifications:** Added Ntfy notification provider with support for self-hosted and cloud instances, optional Bearer token authentication, and JSON template customization - **Certificate Deletion**: Clean up expired and unused certificates directly from the Certificates page diff --git a/README.md b/README.md index 776b95a6..6955efc8 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ If you can use a website, you can run Charon. Charon includes security features that normally require multiple tools: - Web Application Firewall (WAF) -- CrowdSec intrusion detection +- CrowdSec intrusion detection with analytics dashboard - Access Control Lists (ACLs) - Rate limiting - Emergency recovery tools @@ -148,7 +148,7 @@ Secure all your subdomains with a single *.example.com certificate. Supports 15+ ### 🛡️ **Enterprise-Grade Security Built In** -Web Application Firewall, rate limiting, geographic blocking, access control lists, and intrusion detection via CrowdSec. Protection that "just works." +Web Application Firewall, rate limiting, geographic blocking, access control lists, and intrusion detection via CrowdSec—with a built-in analytics dashboard showing attack trends, top offenders, and ban history. Protection that "just works." ### 🔐 **Supply Chain Security** diff --git a/backend/internal/api/handlers/crowdsec_dashboard.go b/backend/internal/api/handlers/crowdsec_dashboard.go new file mode 100644 index 00000000..c36c9d64 --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_dashboard.go @@ -0,0 +1,627 @@ +package handlers + +import ( + "context" + "encoding/csv" + "encoding/json" + "fmt" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/Wikid82/charon/backend/internal/network" + "github.com/gin-gonic/gin" +) + +// Cache TTL constants for dashboard endpoints. +const ( + dashSummaryTTL = 30 * time.Second + dashTimelineTTL = 60 * time.Second + dashTopIPsTTL = 60 * time.Second + dashScenariosTTL = 60 * time.Second + dashAlertsTTL = 30 * time.Second + exportMaxRows = 100_000 +) + +// parseTimeRange converts a range string to a start time. Empty string defaults to 24h. +func parseTimeRange(rangeStr string) (time.Time, error) { + now := time.Now().UTC() + switch rangeStr { + case "1h": + return now.Add(-1 * time.Hour), nil + case "6h": + return now.Add(-6 * time.Hour), nil + case "24h", "": + return now.Add(-24 * time.Hour), nil + case "7d": + return now.Add(-7 * 24 * time.Hour), nil + case "30d": + return now.Add(-30 * 24 * time.Hour), nil + default: + return time.Time{}, fmt.Errorf("invalid range: %s (valid: 1h, 6h, 24h, 7d, 30d)", rangeStr) + } +} + +// normalizeRange returns the canonical range string (defaults empty to "24h"). +func normalizeRange(r string) string { + if r == "" { + return "24h" + } + return r +} + +// intervalForRange selects the default time-bucket interval for a given range. +func intervalForRange(rangeStr string) string { + switch rangeStr { + case "1h": + return "5m" + case "6h": + return "15m" + case "24h", "": + return "1h" + case "7d": + return "6h" + case "30d": + return "1d" + default: + return "1h" + } +} + +// intervalToStrftime maps an interval string to the SQLite strftime expression +// used for time bucketing. +func intervalToStrftime(interval string) string { + switch interval { + case "5m": + return "strftime('%Y-%m-%dT%H:', created_at) || printf('%02d:00Z', (CAST(strftime('%M', created_at) AS INTEGER) / 5) * 5)" + case "15m": + return "strftime('%Y-%m-%dT%H:', created_at) || printf('%02d:00Z', (CAST(strftime('%M', created_at) AS INTEGER) / 15) * 15)" + case "1h": + return "strftime('%Y-%m-%dT%H:00:00Z', created_at)" + case "6h": + return "strftime('%Y-%m-%dT', created_at) || printf('%02d:00:00Z', (CAST(strftime('%H', created_at) AS INTEGER) / 6) * 6)" + case "1d": + return "strftime('%Y-%m-%dT00:00:00Z', created_at)" + default: + return "strftime('%Y-%m-%dT%H:00:00Z', created_at)" + } +} + +// validInterval checks whether the provided interval is one of the known values. +func validInterval(interval string) bool { + switch interval { + case "5m", "15m", "1h", "6h", "1d": + return true + default: + return false + } +} + +// sanitizeCSVField prefixes fields starting with formula-trigger characters +// to prevent CSV injection (CWE-1236). +func sanitizeCSVField(field string) string { + if field == "" { + return field + } + switch field[0] { + case '=', '+', '-', '@', '\t', '\r': + return "'" + field + } + return field +} + +// DashboardSummary returns aggregate counts for the dashboard summary cards. +func (h *CrowdsecHandler) DashboardSummary(c *gin.Context) { + rangeStr := normalizeRange(c.Query("range")) + cacheKey := "dashboard:summary:" + rangeStr + + if cached, ok := h.dashCache.Get(cacheKey); ok { + c.JSON(http.StatusOK, cached) + return + } + + since, err := parseTimeRange(rangeStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + // Historical metrics from SQLite + var totalDecisions int64 + h.DB.Model(&models.SecurityDecision{}). + Where("source = ? AND created_at >= ?", "crowdsec", since). + Count(&totalDecisions) + + var uniqueIPs int64 + h.DB.Model(&models.SecurityDecision{}). + Where("source = ? AND created_at >= ?", "crowdsec", since). + Distinct("ip").Count(&uniqueIPs) + + var topScenario struct { + Scenario string + Cnt int64 + } + h.DB.Model(&models.SecurityDecision{}). + Select("scenario, COUNT(*) as cnt"). + Where("source = ? AND created_at >= ? AND scenario != ''", "crowdsec", since). + Group("scenario"). + Order("cnt DESC"). + Limit(1). + Scan(&topScenario) + + // Trend calculation: compare current period vs previous equal-length period + duration := time.Since(since) + previousSince := since.Add(-duration) + var previousCount int64 + h.DB.Model(&models.SecurityDecision{}). + Where("source = ? AND created_at >= ? AND created_at < ?", "crowdsec", previousSince, since). + Count(&previousCount) + + // Trend: percentage change vs. the previous equal-length period. + // Formula: round((current - previous) / previous * 100, 1) + // Special cases: no previous data → 0; no current data → -100%. + var trend float64 + if previousCount == 0 { + trend = 0.0 + } else if totalDecisions == 0 && previousCount > 0 { + trend = -100.0 + } else { + trend = math.Round(float64(totalDecisions-previousCount)/float64(previousCount)*1000) / 10 + } + + // Active decisions from LAPI (real-time) + activeDecisions := h.fetchActiveDecisionCount(c.Request.Context()) + + result := gin.H{ + "total_decisions": totalDecisions, + "active_decisions": activeDecisions, + "unique_ips": uniqueIPs, + "top_scenario": topScenario.Scenario, + "decisions_trend": trend, + "range": rangeStr, + "cached": false, + "generated_at": time.Now().UTC().Format(time.RFC3339), + } + + h.dashCache.Set(cacheKey, result, dashSummaryTTL) + c.JSON(http.StatusOK, result) +} + +// fetchActiveDecisionCount queries LAPI for active decisions count. +// Returns -1 when LAPI is unreachable. +func (h *CrowdsecHandler) fetchActiveDecisionCount(ctx context.Context) int64 { + lapiURL := "http://127.0.0.1:8085" + if h.Security != nil { + cfg, err := h.Security.Get() + if err == nil && cfg != nil && cfg.CrowdSecAPIURL != "" { + lapiURL = cfg.CrowdSecAPIURL + } + } + + baseURL, err := validateCrowdsecLAPIBaseURL(lapiURL) + if err != nil { + return -1 + } + + endpoint := baseURL.ResolveReference(&url.URL{Path: "/v1/decisions"}) + reqURL := endpoint.String() + + apiKey := getLAPIKey() + + reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, reqURL, http.NoBody) + if err != nil { + return -1 + } + if apiKey != "" { + req.Header.Set("X-Api-Key", apiKey) + } + req.Header.Set("Accept", "application/json") + + client := network.NewInternalServiceHTTPClient(10 * time.Second) + resp, err := client.Do(req) + if err != nil { + return -1 + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return -1 + } + + var decisions []interface{} + if decErr := json.NewDecoder(resp.Body).Decode(&decisions); decErr != nil { + return -1 + } + return int64(len(decisions)) +} + +// DashboardTimeline returns time-bucketed decision counts for the timeline chart. +func (h *CrowdsecHandler) DashboardTimeline(c *gin.Context) { + rangeStr := normalizeRange(c.Query("range")) + interval := c.Query("interval") + if interval == "" { + interval = intervalForRange(rangeStr) + } + if !validInterval(interval) { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid interval: %s (valid: 5m, 15m, 1h, 6h, 1d)", interval)}) + return + } + + cacheKey := fmt.Sprintf("dashboard:timeline:%s:%s", rangeStr, interval) + if cached, ok := h.dashCache.Get(cacheKey); ok { + c.JSON(http.StatusOK, cached) + return + } + + since, err := parseTimeRange(rangeStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + bucketExpr := intervalToStrftime(interval) + + type bucketRow struct { + Bucket string + Bans int64 + Captchas int64 + } + var rows []bucketRow + + h.DB.Model(&models.SecurityDecision{}). + Select(fmt.Sprintf("(%s) as bucket, SUM(CASE WHEN action = 'block' THEN 1 ELSE 0 END) as bans, SUM(CASE WHEN action = 'challenge' THEN 1 ELSE 0 END) as captchas", bucketExpr)). + Where("source = ? AND created_at >= ?", "crowdsec", since). + Group("bucket"). + Order("bucket ASC"). + Scan(&rows) + + buckets := make([]gin.H, 0, len(rows)) + for _, r := range rows { + buckets = append(buckets, gin.H{ + "timestamp": r.Bucket, + "bans": r.Bans, + "captchas": r.Captchas, + }) + } + + result := gin.H{ + "buckets": buckets, + "range": rangeStr, + "interval": interval, + "cached": false, + } + + h.dashCache.Set(cacheKey, result, dashTimelineTTL) + c.JSON(http.StatusOK, result) +} + +// DashboardTopIPs returns top attacking IPs ranked by decision count. +func (h *CrowdsecHandler) DashboardTopIPs(c *gin.Context) { + rangeStr := normalizeRange(c.Query("range")) + limitStr := c.DefaultQuery("limit", "10") + limit, err := strconv.Atoi(limitStr) + if err != nil || limit < 1 { + limit = 10 + } + if limit > 50 { + limit = 50 + } + + cacheKey := fmt.Sprintf("dashboard:top-ips:%s:%d", rangeStr, limit) + if cached, ok := h.dashCache.Get(cacheKey); ok { + c.JSON(http.StatusOK, cached) + return + } + + since, err := parseTimeRange(rangeStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + type ipRow struct { + IP string + Count int64 + LastSeen time.Time + Country string + } + var rows []ipRow + + h.DB.Model(&models.SecurityDecision{}). + Select("ip, COUNT(*) as count, MAX(created_at) as last_seen, MAX(country) as country"). + Where("source = ? AND created_at >= ?", "crowdsec", since). + Group("ip"). + Order("count DESC"). + Limit(limit). + Scan(&rows) + + ips := make([]gin.H, 0, len(rows)) + for _, r := range rows { + ips = append(ips, gin.H{ + "ip": r.IP, + "count": r.Count, + "last_seen": r.LastSeen.UTC().Format(time.RFC3339), + "country": r.Country, + }) + } + + result := gin.H{ + "ips": ips, + "range": rangeStr, + "cached": false, + } + + h.dashCache.Set(cacheKey, result, dashTopIPsTTL) + c.JSON(http.StatusOK, result) +} + +// DashboardScenarios returns scenario breakdown with counts and percentages. +func (h *CrowdsecHandler) DashboardScenarios(c *gin.Context) { + rangeStr := normalizeRange(c.Query("range")) + + cacheKey := "dashboard:scenarios:" + rangeStr + if cached, ok := h.dashCache.Get(cacheKey); ok { + c.JSON(http.StatusOK, cached) + return + } + + since, err := parseTimeRange(rangeStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + type scenarioRow struct { + Name string + Count int64 + } + var rows []scenarioRow + + h.DB.Model(&models.SecurityDecision{}). + Select("scenario as name, COUNT(*) as count"). + Where("source = ? AND created_at >= ? AND scenario != ''", "crowdsec", since). + Group("scenario"). + Order("count DESC"). + Limit(50). + Scan(&rows) + + var total int64 + for _, r := range rows { + total += r.Count + } + + scenarios := make([]gin.H, 0, len(rows)) + for _, r := range rows { + pct := 0.0 + if total > 0 { + pct = math.Round(float64(r.Count)/float64(total)*1000) / 10 + } + scenarios = append(scenarios, gin.H{ + "name": r.Name, + "count": r.Count, + "percentage": pct, + }) + } + + result := gin.H{ + "scenarios": scenarios, + "total": total, + "range": rangeStr, + "cached": false, + } + + h.dashCache.Set(cacheKey, result, dashScenariosTTL) + c.JSON(http.StatusOK, result) +} + +// ListAlerts wraps the CrowdSec LAPI /v1/alerts endpoint. +func (h *CrowdsecHandler) ListAlerts(c *gin.Context) { + rangeStr := normalizeRange(c.Query("range")) + scenario := strings.TrimSpace(c.Query("scenario")) + limitStr := c.DefaultQuery("limit", "50") + offsetStr := c.DefaultQuery("offset", "0") + + limit, err := strconv.Atoi(limitStr) + if err != nil || limit < 1 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + offset, err := strconv.Atoi(offsetStr) + if err != nil || offset < 0 { + offset = 0 + } + + cacheKey := fmt.Sprintf("dashboard:alerts:%s:%s:%d:%d", rangeStr, scenario, limit, offset) + if cached, ok := h.dashCache.Get(cacheKey); ok { + c.JSON(http.StatusOK, cached) + return + } + + since, tErr := parseTimeRange(rangeStr) + if tErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": tErr.Error()}) + return + } + + alerts, total, source := h.fetchLAPIAlerts(c.Request.Context(), since, scenario, limit, offset) + + result := gin.H{ + "alerts": alerts, + "total": total, + "source": source, + "cached": false, + } + + h.dashCache.Set(cacheKey, result, dashAlertsTTL) + c.JSON(http.StatusOK, result) +} + +// fetchLAPIAlerts attempts to get alerts from LAPI, falling back to cscli. +func (h *CrowdsecHandler) fetchLAPIAlerts(ctx context.Context, since time.Time, scenario string, limit, offset int) (alerts []interface{}, total int, source string) { + lapiURL := "http://127.0.0.1:8085" + if h.Security != nil { + cfg, err := h.Security.Get() + if err == nil && cfg != nil && cfg.CrowdSecAPIURL != "" { + lapiURL = cfg.CrowdSecAPIURL + } + } + + baseURL, err := validateCrowdsecLAPIBaseURL(lapiURL) + if err != nil { + return h.fetchAlertsCscli(ctx, scenario, limit) + } + + q := url.Values{} + q.Set("since", since.Format(time.RFC3339)) + if scenario != "" { + q.Set("scenario", scenario) + } + q.Set("limit", strconv.Itoa(limit)) + + endpoint := baseURL.ResolveReference(&url.URL{Path: "/v1/alerts"}) + endpoint.RawQuery = q.Encode() + reqURL := endpoint.String() + + apiKey := getLAPIKey() + + reqCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + req, reqErr := http.NewRequestWithContext(reqCtx, http.MethodGet, reqURL, http.NoBody) + if reqErr != nil { + return h.fetchAlertsCscli(ctx, scenario, limit) + } + if apiKey != "" { + req.Header.Set("X-Api-Key", apiKey) + } + req.Header.Set("Accept", "application/json") + + client := network.NewInternalServiceHTTPClient(10 * time.Second) + resp, doErr := client.Do(req) + if doErr != nil { + return h.fetchAlertsCscli(ctx, scenario, limit) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return h.fetchAlertsCscli(ctx, scenario, limit) + } + + var rawAlerts []interface{} + if decErr := json.NewDecoder(resp.Body).Decode(&rawAlerts); decErr != nil { + return h.fetchAlertsCscli(ctx, scenario, limit) + } + + // Capture full count before slicing for correct pagination semantics + fullTotal := len(rawAlerts) + + // Apply offset for pagination + if offset > 0 && offset < len(rawAlerts) { + rawAlerts = rawAlerts[offset:] + } else if offset >= len(rawAlerts) { + rawAlerts = nil + } + + if limit < len(rawAlerts) { + rawAlerts = rawAlerts[:limit] + } + + return rawAlerts, fullTotal, "lapi" +} + +// fetchAlertsCscli falls back to using cscli to list alerts. +func (h *CrowdsecHandler) fetchAlertsCscli(ctx context.Context, scenario string, limit int) (alerts []interface{}, total int, source string) { + args := []string{"alerts", "list", "-o", "json"} + if scenario != "" { + args = append(args, "-s", scenario) + } + args = append(args, "-l", strconv.Itoa(limit)) + + output, err := h.CmdExec.Execute(ctx, "cscli", args...) + if err != nil { + logger.Log().WithError(err).Warn("Failed to list alerts via cscli") + return []interface{}{}, 0, "cscli" + } + + if jErr := json.Unmarshal(output, &alerts); jErr != nil { + return []interface{}{}, 0, "cscli" + } + return alerts, len(alerts), "cscli" +} + +// ExportDecisions exports decisions as downloadable CSV or JSON. +func (h *CrowdsecHandler) ExportDecisions(c *gin.Context) { + format := strings.ToLower(c.DefaultQuery("format", "csv")) + rangeStr := normalizeRange(c.Query("range")) + source := strings.ToLower(c.DefaultQuery("source", "all")) + + if format != "csv" && format != "json" { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid format: must be csv or json"}) + return + } + + validSources := map[string]bool{"crowdsec": true, "waf": true, "ratelimit": true, "manual": true, "all": true} + if !validSources[source] { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid source: must be crowdsec, waf, ratelimit, manual, or all"}) + return + } + + since, err := parseTimeRange(rangeStr) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var decisions []models.SecurityDecision + q := h.DB.Where("created_at >= ?", since) + if source != "all" { + q = q.Where("source = ?", source) + } + q.Order("created_at DESC").Limit(exportMaxRows).Find(&decisions) + + ts := time.Now().UTC().Format("20060102-150405") + + switch format { + case "csv": + filename := fmt.Sprintf("crowdsec-decisions-%s.csv", ts) + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + + w := csv.NewWriter(c.Writer) + _ = w.Write([]string{"uuid", "ip", "action", "source", "scenario", "rule_id", "host", "country", "created_at", "expires_at"}) + for _, d := range decisions { + _ = w.Write([]string{ + d.UUID, + sanitizeCSVField(d.IP), + d.Action, + d.Source, + sanitizeCSVField(d.Scenario), + sanitizeCSVField(d.RuleID), + sanitizeCSVField(d.Host), + sanitizeCSVField(d.Country), + d.CreatedAt.UTC().Format(time.RFC3339), + d.ExpiresAt.UTC().Format(time.RFC3339), + }) + } + w.Flush() + if err := w.Error(); err != nil { + logger.Log().WithError(err).Warn("CSV export write error") + } + + case "json": + filename := fmt.Sprintf("crowdsec-decisions-%s.json", ts) + c.Header("Content-Type", "application/json") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + c.JSON(http.StatusOK, decisions) + } +} diff --git a/backend/internal/api/handlers/crowdsec_dashboard_cache.go b/backend/internal/api/handlers/crowdsec_dashboard_cache.go new file mode 100644 index 00000000..d439b8b5 --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_dashboard_cache.go @@ -0,0 +1,62 @@ +package handlers + +import ( + "strings" + "sync" + "time" +) + +type cacheEntry struct { + data interface{} + expiresAt time.Time +} + +type dashboardCache struct { + mu sync.RWMutex + entries map[string]*cacheEntry +} + +func newDashboardCache() *dashboardCache { + return &dashboardCache{ + entries: make(map[string]*cacheEntry), + } +} + +func (c *dashboardCache) Get(key string) (interface{}, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + entry, ok := c.entries[key] + if !ok { + return nil, false + } + if time.Now().After(entry.expiresAt) { + delete(c.entries, key) + return nil, false + } + return entry.data, true +} + +func (c *dashboardCache) Set(key string, data interface{}, ttl time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + + c.entries[key] = &cacheEntry{ + data: data, + expiresAt: time.Now().Add(ttl), + } +} + +func (c *dashboardCache) Invalidate(prefixes ...string) { + c.mu.Lock() + defer c.mu.Unlock() + + for key := range c.entries { + for _, prefix := range prefixes { + if strings.HasPrefix(key, prefix) { + delete(c.entries, key) + break + } + } + } +} diff --git a/backend/internal/api/handlers/crowdsec_dashboard_test.go b/backend/internal/api/handlers/crowdsec_dashboard_test.go new file mode 100644 index 00000000..ab76f5df --- /dev/null +++ b/backend/internal/api/handlers/crowdsec_dashboard_test.go @@ -0,0 +1,486 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/Wikid82/charon/backend/internal/models" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// setupDashboardHandler creates a CrowdsecHandler with an in-memory DB seeded with decisions. +func setupDashboardHandler(t *testing.T) (*CrowdsecHandler, *gin.Engine) { + t.Helper() + gin.SetMode(gin.TestMode) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + + h := &CrowdsecHandler{ + DB: db, + Executor: &fakeExec{}, + CmdExec: &fastCmdExec{}, + BinPath: "/bin/false", + DataDir: t.TempDir(), + dashCache: newDashboardCache(), + } + + seedDashboardData(t, h) + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + return h, r +} + +// seedDashboardData inserts representative records for testing. +func seedDashboardData(t *testing.T, h *CrowdsecHandler) { + t.Helper() + now := time.Now().UTC() + + decisions := []models.SecurityDecision{ + {UUID: uuid.NewString(), Source: "crowdsec", Action: "block", IP: "10.0.0.1", Scenario: "crowdsecurity/http-probing", Country: "US", CreatedAt: now.Add(-1 * time.Hour)}, + {UUID: uuid.NewString(), Source: "crowdsec", Action: "block", IP: "10.0.0.1", Scenario: "crowdsecurity/http-probing", Country: "US", CreatedAt: now.Add(-2 * time.Hour)}, + {UUID: uuid.NewString(), Source: "crowdsec", Action: "challenge", IP: "10.0.0.2", Scenario: "crowdsecurity/ssh-bf", Country: "DE", CreatedAt: now.Add(-3 * time.Hour)}, + {UUID: uuid.NewString(), Source: "crowdsec", Action: "block", IP: "10.0.0.3", Scenario: "crowdsecurity/http-probing", Country: "FR", CreatedAt: now.Add(-5 * time.Hour)}, + {UUID: uuid.NewString(), Source: "crowdsec", Action: "block", IP: "10.0.0.4", Scenario: "crowdsecurity/http-bad-user-agent", Country: "", CreatedAt: now.Add(-10 * time.Hour)}, + // Old record outside 24h but within 7d + {UUID: uuid.NewString(), Source: "crowdsec", Action: "block", IP: "10.0.0.5", Scenario: "crowdsecurity/http-probing", Country: "JP", CreatedAt: now.Add(-48 * time.Hour)}, + // Non-crowdsec source + {UUID: uuid.NewString(), Source: "waf", Action: "block", IP: "10.0.0.99", Scenario: "waf-rule", Country: "CN", CreatedAt: now.Add(-1 * time.Hour)}, + } + + for _, d := range decisions { + require.NoError(t, h.DB.Create(&d).Error) + } +} + +func TestParseTimeRange(t *testing.T) { + t.Parallel() + tests := []struct { + input string + valid bool + }{ + {"1h", true}, + {"6h", true}, + {"24h", true}, + {"7d", true}, + {"30d", true}, + {"", true}, + {"2h", false}, + {"1w", false}, + {"invalid", false}, + } + + for _, tc := range tests { + t.Run(fmt.Sprintf("range_%s", tc.input), func(t *testing.T) { + _, err := parseTimeRange(tc.input) + if tc.valid { + assert.NoError(t, err) + } else { + assert.Error(t, err) + } + }) + } +} + +func TestParseTimeRange_DefaultEmpty(t *testing.T) { + t.Parallel() + result, err := parseTimeRange("") + require.NoError(t, err) + expected := time.Now().UTC().Add(-24 * time.Hour) + assert.InDelta(t, expected.UnixMilli(), result.UnixMilli(), 1000) +} + +func TestDashboardSummary_OK(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/summary?range=24h", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + + assert.Contains(t, body, "total_decisions") + assert.Contains(t, body, "active_decisions") + assert.Contains(t, body, "unique_ips") + assert.Contains(t, body, "top_scenario") + assert.Contains(t, body, "decisions_trend") + assert.Contains(t, body, "range") + assert.Contains(t, body, "generated_at") + assert.Equal(t, "24h", body["range"]) + + // 5 crowdsec decisions within 24h (excludes 48h-old one) + total := body["total_decisions"].(float64) + assert.Equal(t, float64(5), total) + + // 4 unique crowdsec IPs within 24h + assert.Equal(t, float64(4), body["unique_ips"].(float64)) + + // LAPI unreachable in test => -1 + assert.Equal(t, float64(-1), body["active_decisions"].(float64)) +} + +func TestDashboardSummary_InvalidRange(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/summary?range=99z", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestDashboardSummary_Cached(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + // First call populates cache + w1 := httptest.NewRecorder() + req1 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/summary?range=24h", http.NoBody) + r.ServeHTTP(w1, req1) + assert.Equal(t, http.StatusOK, w1.Code) + + // Second call should hit cache + w2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/summary?range=24h", http.NoBody) + r.ServeHTTP(w2, req2) + assert.Equal(t, http.StatusOK, w2.Code) +} + +func TestDashboardTimeline_OK(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/timeline?range=24h", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + + assert.Contains(t, body, "buckets") + assert.Contains(t, body, "interval") + assert.Equal(t, "1h", body["interval"]) + assert.Equal(t, "24h", body["range"]) +} + +func TestDashboardTimeline_CustomInterval(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/timeline?range=6h&interval=15m", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + assert.Equal(t, "15m", body["interval"]) +} + +func TestDashboardTimeline_InvalidInterval(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/timeline?interval=99m", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestDashboardTopIPs_OK(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/top-ips?range=24h&limit=3", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + + ips := body["ips"].([]interface{}) + assert.LessOrEqual(t, len(ips), 3) + // 10.0.0.1 has 2 hits, should be first + if len(ips) > 0 { + first := ips[0].(map[string]interface{}) + assert.Equal(t, "10.0.0.1", first["ip"]) + assert.Equal(t, float64(2), first["count"]) + } +} + +func TestDashboardTopIPs_LimitCap(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + // Limit > 50 should be capped + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/top-ips?limit=100", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) +} + +func TestDashboardScenarios_OK(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/scenarios?range=24h", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + + assert.Contains(t, body, "scenarios") + assert.Contains(t, body, "total") + scenarios := body["scenarios"].([]interface{}) + assert.Greater(t, len(scenarios), 0) + + // Verify percentages sum to ~100 + var totalPct float64 + for _, s := range scenarios { + sc := s.(map[string]interface{}) + totalPct += sc["percentage"].(float64) + assert.Contains(t, sc, "name") + assert.Contains(t, sc, "count") + } + assert.InDelta(t, 100.0, totalPct, 1.0) +} + +func TestListAlerts_OK(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=24h", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + + assert.Contains(t, body, "alerts") + assert.Contains(t, body, "source") + // Falls back to cscli which returns empty/error in test + assert.Equal(t, "cscli", body["source"]) +} + +func TestListAlerts_InvalidRange(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/alerts?range=invalid", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestExportDecisions_CSV(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/export?format=csv&range=24h", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "text/csv") + assert.Contains(t, w.Header().Get("Content-Disposition"), "attachment") + assert.Contains(t, w.Body.String(), "uuid,ip,action,source,scenario") +} + +func TestExportDecisions_JSON(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/export?format=json&range=24h", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Contains(t, w.Header().Get("Content-Type"), "application/json") + + var decisions []models.SecurityDecision + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &decisions)) + assert.Greater(t, len(decisions), 0) +} + +func TestExportDecisions_InvalidFormat(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/export?format=xml", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestExportDecisions_InvalidSource(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/export?source=evil", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestSanitizeCSVField(t *testing.T) { + t.Parallel() + tests := []struct { + input string + expected string + }{ + {"normal", "normal"}, + {"=cmd", "'=cmd"}, + {"+cmd", "'+cmd"}, + {"-cmd", "'-cmd"}, + {"@cmd", "'@cmd"}, + {"\tcmd", "'\tcmd"}, + {"\rcmd", "'\rcmd"}, + {"", ""}, + } + + for _, tc := range tests { + assert.Equal(t, tc.expected, sanitizeCSVField(tc.input)) + } +} + +func TestDashboardCache_Invalidate(t *testing.T) { + t.Parallel() + cache := newDashboardCache() + cache.Set("dashboard:summary:24h", "data1", 5*time.Minute) + cache.Set("dashboard:timeline:24h", "data2", 5*time.Minute) + cache.Set("other:key", "data3", 5*time.Minute) + + cache.Invalidate("dashboard") + + _, ok1 := cache.Get("dashboard:summary:24h") + assert.False(t, ok1) + + _, ok2 := cache.Get("dashboard:timeline:24h") + assert.False(t, ok2) + + _, ok3 := cache.Get("other:key") + assert.True(t, ok3) +} + +func TestDashboardCache_TTLExpiry(t *testing.T) { + t.Parallel() + cache := newDashboardCache() + cache.Set("key", "value", 1*time.Millisecond) + + time.Sleep(5 * time.Millisecond) + _, ok := cache.Get("key") + assert.False(t, ok) +} + +func TestDashboardCache_TTLExpiry_DeletesEntry(t *testing.T) { + t.Parallel() + cache := newDashboardCache() + cache.Set("expired", "data", 1*time.Millisecond) + + time.Sleep(5 * time.Millisecond) + _, ok := cache.Get("expired") + assert.False(t, ok) + + cache.mu.Lock() + _, stillPresent := cache.entries["expired"] + cache.mu.Unlock() + assert.False(t, stillPresent, "expired entry should be deleted from map") +} + +func TestDashboardSummary_DecisionsTrend(t *testing.T) { + t.Parallel() + gin.SetMode(gin.TestMode) + + db := OpenTestDB(t) + require.NoError(t, db.AutoMigrate(&models.SecurityDecision{}, &models.SecurityConfig{}, &models.Setting{})) + + h := &CrowdsecHandler{ + DB: db, + Executor: &fakeExec{}, + CmdExec: &fastCmdExec{}, + BinPath: "/bin/false", + DataDir: t.TempDir(), + dashCache: newDashboardCache(), + } + + now := time.Now().UTC() + // Seed 3 decisions in the current 1h period + for i := 0; i < 3; i++ { + require.NoError(t, db.Create(&models.SecurityDecision{ + UUID: uuid.NewString(), Source: "crowdsec", Action: "block", + IP: "192.168.1.1", Scenario: "crowdsecurity/http-probing", + CreatedAt: now.Add(-time.Duration(i+1) * time.Minute), + }).Error) + } + // Seed 2 decisions in the previous 1h period + for i := 0; i < 2; i++ { + require.NoError(t, db.Create(&models.SecurityDecision{ + UUID: uuid.NewString(), Source: "crowdsec", Action: "block", + IP: "192.168.1.2", Scenario: "crowdsecurity/http-probing", + CreatedAt: now.Add(-1*time.Hour - time.Duration(i+1)*time.Minute), + }).Error) + } + + r := gin.New() + g := r.Group("/api/v1") + h.RegisterRoutes(g) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/dashboard/summary?range=1h", http.NoBody) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var body map[string]interface{} + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body)) + + // (3 - 2) / 2 * 100 = 50.0 + trend := body["decisions_trend"].(float64) + assert.InDelta(t, 50.0, trend, 0.1) +} + +func TestExportDecisions_SourceFilter(t *testing.T) { + t.Parallel() + _, r := setupDashboardHandler(t) + + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/crowdsec/decisions/export?format=json&range=7d&source=waf", http.NoBody) + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var decisions []models.SecurityDecision + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &decisions)) + for _, d := range decisions { + assert.Equal(t, "waf", d.Source) + } +} diff --git a/backend/internal/api/handlers/crowdsec_handler.go b/backend/internal/api/handlers/crowdsec_handler.go index 1b8f9a5d..595304d0 100644 --- a/backend/internal/api/handlers/crowdsec_handler.go +++ b/backend/internal/api/handlers/crowdsec_handler.go @@ -66,6 +66,7 @@ type CrowdsecHandler struct { CaddyManager *caddy.Manager // For config reload after bouncer registration LAPIMaxWait time.Duration // For testing; 0 means 60s default LAPIPollInterval time.Duration // For testing; 0 means 500ms default + dashCache *dashboardCache // registrationMutex protects concurrent bouncer registration attempts registrationMutex sync.Mutex @@ -370,14 +371,15 @@ func NewCrowdsecHandler(db *gorm.DB, executor CrowdsecExecutor, binPath, dataDir consoleSvc = crowdsec.NewConsoleEnrollmentService(db, &crowdsec.SecureCommandExecutor{}, dataDir, consoleSecret) } return &CrowdsecHandler{ - DB: db, - Executor: executor, - CmdExec: &RealCommandExecutor{}, - BinPath: binPath, - DataDir: dataDir, - Hub: hubSvc, - Console: consoleSvc, - Security: securitySvc, + DB: db, + Executor: executor, + CmdExec: &RealCommandExecutor{}, + BinPath: binPath, + DataDir: dataDir, + Hub: hubSvc, + Console: consoleSvc, + Security: securitySvc, + dashCache: newDashboardCache(), } } @@ -2287,6 +2289,20 @@ func (h *CrowdsecHandler) BanIP(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"status": "banned", "ip": ip, "duration": duration}) + + // Log to security_decisions for dashboard aggregation + if h.Security != nil { + parsedDur, _ := time.ParseDuration(duration) + _ = h.Security.LogDecision(&models.SecurityDecision{ + IP: ip, + Action: "block", + Source: "crowdsec", + RuleID: reason, + Scenario: "manual", + ExpiresAt: time.Now().Add(parsedDur), + }) + } + h.dashCache.Invalidate("dashboard") } // UnbanIP removes a ban for an IP address @@ -2313,6 +2329,7 @@ func (h *CrowdsecHandler) UnbanIP(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"status": "unbanned", "ip": ip}) + h.dashCache.Invalidate("dashboard") } // RegisterBouncer registers a new bouncer or returns existing bouncer status. @@ -2711,4 +2728,11 @@ func (h *CrowdsecHandler) RegisterRoutes(rg *gin.RouterGroup) { // Acquisition configuration endpoints rg.GET("/admin/crowdsec/acquisition", h.GetAcquisitionConfig) rg.PUT("/admin/crowdsec/acquisition", h.UpdateAcquisitionConfig) + // Dashboard aggregation endpoints (PR-1) + rg.GET("/admin/crowdsec/dashboard/summary", h.DashboardSummary) + rg.GET("/admin/crowdsec/dashboard/timeline", h.DashboardTimeline) + rg.GET("/admin/crowdsec/dashboard/top-ips", h.DashboardTopIPs) + rg.GET("/admin/crowdsec/dashboard/scenarios", h.DashboardScenarios) + rg.GET("/admin/crowdsec/alerts", h.ListAlerts) + rg.GET("/admin/crowdsec/decisions/export", h.ExportDecisions) } diff --git a/backend/internal/models/security_decision.go b/backend/internal/models/security_decision.go index 709c6c86..d5befa2f 100644 --- a/backend/internal/models/security_decision.go +++ b/backend/internal/models/security_decision.go @@ -9,11 +9,16 @@ import ( type SecurityDecision struct { ID uint `json:"-" gorm:"primaryKey"` UUID string `json:"uuid" gorm:"uniqueIndex"` - Source string `json:"source" gorm:"index"` // e.g., crowdsec, waf, ratelimit, manual - Action string `json:"action" gorm:"index"` // allow, block, challenge - IP string `json:"ip" gorm:"index"` + Source string `json:"source" gorm:"index;compositeIndex:idx_sd_source_created;compositeIndex:idx_sd_source_scenario_created;compositeIndex:idx_sd_source_ip_created"` // e.g., crowdsec, waf, ratelimit, manual + Action string `json:"action" gorm:"index"` // allow, block, challenge + IP string `json:"ip" gorm:"index;compositeIndex:idx_sd_source_ip_created"` Host string `json:"host" gorm:"index"` // optional RuleID string `json:"rule_id" gorm:"index"` Details string `json:"details" gorm:"type:text"` - CreatedAt time.Time `json:"created_at" gorm:"index"` + CreatedAt time.Time `json:"created_at" gorm:"index;compositeIndex:idx_sd_source_created,sort:desc;compositeIndex:idx_sd_source_scenario_created,sort:desc;compositeIndex:idx_sd_source_ip_created,sort:desc"` + + // Dashboard enrichment fields (Issue #26, PR-1) + Scenario string `json:"scenario" gorm:"index;compositeIndex:idx_sd_source_scenario_created"` + Country string `json:"country" gorm:"index;size:2"` + ExpiresAt time.Time `json:"expires_at" gorm:"index"` } diff --git a/docs/features.md b/docs/features.md index 139348d8..6d002d8a 100644 --- a/docs/features.md +++ b/docs/features.md @@ -78,6 +78,24 @@ Protect your applications using behavior-based threat detection powered by a glo --- +### 📊 CrowdSec Dashboard + +See your security posture at a glance. The CrowdSec Dashboard shows attack trends, active bans, top offenders, and scenario breakdowns—all from within Charon's Security section. + +**Highlights:** + +- **Summary Cards** — Total bans, active bans, unique IPs, and top scenario at a glance +- **Interactive Charts** — Ban timeline, top attacking IPs, and attack type breakdown +- **Alerts Feed** — Live view of CrowdSec alerts with pagination +- **Time Range Selector** — Filter data by 1 hour, 6 hours, 24 hours, 7 days, or 30 days +- **Export** — Download decisions as CSV or JSON for external analysis + +No SSH required. No CLI commands. Just open the Dashboard tab and see what's happening. + +→ [Learn More](features/crowdsec.md) + +--- + ### 🔐 Access Control Lists (ACLs) Define exactly who can access what. Block specific countries, allow only certain IP ranges, or require authentication for sensitive applications. Fine-grained rules give you complete control. diff --git a/docs/issues/crowdsec-dashboard-manual-test.md b/docs/issues/crowdsec-dashboard-manual-test.md new file mode 100644 index 00000000..8e35aa38 --- /dev/null +++ b/docs/issues/crowdsec-dashboard-manual-test.md @@ -0,0 +1,162 @@ +--- +title: "Manual Test Plan - Issue #26: CrowdSec Dashboard Integration" +status: Open +priority: High +assignee: QA +labels: testing, backend, frontend, security +--- + +# Test Objective + +Confirm that the CrowdSec Dashboard tab displays accurate security metrics, charts render correctly, time range filtering works, alerts paginate properly, and export produces valid output. + +# Scope + +- In scope: Dashboard tab navigation, summary cards, chart rendering, time range selector, active decisions table, alerts feed, CSV/JSON export, keyboard navigation, screen reader compatibility. +- Out of scope: CrowdSec engine start/stop/configuration, bouncer registration, existing security toggle behavior. + +# Prerequisites + +- Charon instance running with CrowdSec enabled and at least a few recorded decisions. +- Admin account credentials. +- Browser DevTools available for network inspection. +- Screen reader available for accessibility testing (e.g., NVDA, VoiceOver). + +# Manual Scenarios + +## 1) Dashboard Tab Navigation + +- [ ] Navigate to `/security/crowdsec`. +- [ ] **Expected**: Two tabs are visible — "Configuration" and "Dashboard." +- [ ] Click the "Dashboard" tab. +- [ ] **Expected**: The dashboard loads with summary cards, charts, and the decisions table. +- [ ] Click the "Configuration" tab. +- [ ] **Expected**: The existing CrowdSec configuration interface appears unchanged. +- [ ] Click back to "Dashboard." +- [ ] **Expected**: Dashboard state is preserved (same time range, same data). + +## 2) Summary Cards Accuracy + +- [ ] Open the Dashboard tab with the default 24h time range. +- [ ] **Expected**: Four summary cards are displayed — Total Bans, Active Bans, Unique IPs, Top Scenario. +- [ ] Compare the "Active Bans" count against `cscli decisions list` output from the container. +- [ ] **Expected**: The counts match (within the 30-second cache window). +- [ ] Check that the trend indicator (percentage change) is visible on the Total Bans card. + +## 3) Chart Rendering + +- [ ] Confirm the ban timeline chart (area chart) renders with data points. +- [ ] **Expected**: The X-axis shows time labels and the Y-axis shows ban counts. +- [ ] Confirm the top attacking IPs chart (horizontal bar chart) renders. +- [ ] **Expected**: Up to 10 IP addresses are listed with proportional bars. +- [ ] Confirm the scenario breakdown chart (pie/donut chart) renders. +- [ ] **Expected**: Slices represent different CrowdSec scenarios with a legend. +- [ ] Hover over data points in each chart. +- [ ] **Expected**: Tooltips appear with relevant values. + +## 4) Time Range Switching + +- [ ] Select the "1h" time range. +- [ ] **Expected**: All cards and charts update to reflect the last 1 hour of data. +- [ ] Select "7d." +- [ ] **Expected**: Data expands to show the last 7 days. +- [ ] Select "30d." +- [ ] **Expected**: Data expands to show the last 30 days. Charts may show wider time buckets. +- [ ] Rapidly toggle between "1h" and "30d" several times. +- [ ] **Expected**: No stale data, no visual glitches, and no console errors. The most recently selected range is always displayed. + +## 5) Active Decisions Table + +- [ ] Scroll to the active decisions table on the Dashboard. +- [ ] **Expected**: Table columns include IP, Scenario, Duration, Type (ban/captcha), Origin, and Time Remaining. +- [ ] Verify that the "Time Remaining" column shows a countdown or human-readable time. +- [ ] If there are more than 10 active decisions, confirm pagination or scrolling works. +- [ ] If there are zero active decisions, confirm a placeholder message is shown (e.g., "No active decisions"). + +## 6) Alerts Feed + +- [ ] Scroll to the alerts section of the Dashboard. +- [ ] **Expected**: A list of recent CrowdSec alerts is displayed with timestamps and scenario names. +- [ ] If there are enough alerts, confirm that pagination controls are present and functional. +- [ ] Click "Next" on the pagination. +- [ ] **Expected**: The next page of alerts loads without duplicates. +- [ ] Click "Previous." +- [ ] **Expected**: Returns to the first page with the original data. + +## 7) CSV Export + +- [ ] Click the "Export" button on the Dashboard. +- [ ] Select "CSV" as the format. +- [ ] **Expected**: A `.csv` file downloads to your machine. +- [ ] Open the file in a text editor or spreadsheet application. +- [ ] **Expected**: Columns match the decisions table (IP, Scenario, Duration, Type, etc.) and rows contain valid data. + +## 8) JSON Export + +- [ ] Click the "Export" button on the Dashboard. +- [ ] Select "JSON" as the format. +- [ ] **Expected**: A `.json` file downloads to your machine. +- [ ] Open the file in a text editor. +- [ ] **Expected**: Valid JSON array of decision objects. Fields match the decisions table. + +## 9) Keyboard Navigation + +- [ ] Use `Tab` to navigate from the tab bar to the summary cards, then to the charts, then to the table. +- [ ] **Expected**: Focus moves in a logical order. A visible focus indicator is shown on each interactive element. +- [ ] Use `Enter` or `Space` to activate the time range selector buttons. +- [ ] **Expected**: The selected time range changes and data updates. +- [ ] Use `Tab` to reach the "Export" button, then press `Enter`. +- [ ] **Expected**: The export dialog or menu opens. + +## 10) Screen Reader Compatibility + +- [ ] Enable a screen reader (NVDA, VoiceOver, or similar). +- [ ] Navigate to the Dashboard tab. +- [ ] **Expected**: The tab bar is announced correctly with "Configuration" and "Dashboard" tab names. +- [ ] Navigate to the summary cards. +- [ ] **Expected**: Each card's label and value is announced (e.g., "Total Bans: 1247"). +- [ ] Navigate to the charts. +- [ ] **Expected**: Charts have accessible labels or descriptions (e.g., "Ban Timeline Chart"). +- [ ] Navigate to the decisions table. +- [ ] **Expected**: Table headers and cell values are announced correctly. + +# Edge Cases + +## 11) Empty CrowdSec Data + +- [ ] Disable CrowdSec or test on an instance with zero recorded decisions. +- [ ] Open the Dashboard tab. +- [ ] **Expected**: Summary cards show `0` values. Charts show an empty state or placeholder. The decisions table shows "No active decisions." No errors in the console. + +## 12) Large Number of Decisions + +- [ ] Test on an instance with 1,000+ recorded decisions (or simulate with test data). +- [ ] Open the Dashboard tab with the "30d" time range. +- [ ] **Expected**: Dashboard loads within 2 seconds. Charts render without performance issues. Pagination handles the large dataset. + +## 13) CrowdSec LAPI Unavailable + +- [ ] Stop the CrowdSec container while Charon is running. +- [ ] Open the Dashboard tab. +- [ ] **Expected**: Historical data from the database still renders. Active decisions and alerts show an error or "unavailable" state. No unhandled errors in the UI. + +## 14) Rapid Time Range Switching Under Load + +- [ ] On an instance with significant data, rapidly click through all five time ranges in quick succession. +- [ ] **Expected**: Only the final selection's data is displayed. No race conditions, stale data, or flickering. + +# Expected Results + +- Dashboard tab loads and displays all components (cards, charts, table, alerts). +- Summary card numbers match CrowdSec LAPI and database records within the cache window. +- Charts render with correct data for the selected time range. +- Export produces valid CSV and JSON files with matching data. +- Keyboard and screen reader users can navigate and interact with all dashboard elements. +- Edge cases (empty data, LAPI down, large datasets) are handled gracefully. + +# Regression Checks + +- [ ] Confirm the existing CrowdSec Configuration tab is unchanged in behavior and layout. +- [ ] Confirm CrowdSec start/stop/restart functionality is unaffected. +- [ ] Confirm existing security toggles (ACL, WAF, Rate Limiting) are unaffected. +- [ ] Confirm no new console errors appear on pages outside the Dashboard. diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 91fe5bed..0a1ff69b 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,592 +1,1124 @@ -# Ntfy Notification Provider — Implementation Specification +# CrowdSec Dashboard Integration — Implementation Specification -## 1. Introduction +**Issue:** #26 +**Version:** 1.1 +**Status:** Draft — Post-Supervisor Review -### Overview +--- + +## 1. Executive Summary + +### What We're Building + +A metrics and analytics dashboard for CrowdSec within Charon's existing Security section. This adds visualization, aggregation, and export capabilities to the CrowdSec module — surfacing data that today is only available via CLI or raw LAPI queries. -Add **Ntfy** () as a notification provider in Charon, following -the same wrapper pattern used by Gotify, Telegram, Slack, and Pushover. Ntfy is -an HTTP-based pub/sub notification service that supports self-hosted and -cloud-hosted instances. Users publish messages by POSTing JSON to a topic URL, -optionally with an auth token. +### Why -### Objectives +CrowdSec is already operationally integrated (start/stop, config, bouncer registration, decisions, console enrollment). What's missing is **visibility**: users cannot see attack trends, scenario breakdowns, ban history, or top offenders without SSH-ing into the container and running `cscli` commands. A dashboard closes this gap and makes Charon's security posture observable from the UI. -1. Users can create/edit/delete an Ntfy notification provider via the Management UI. -2. Ntfy dispatches support all three template modes (minimal, detailed, custom). -3. Ntfy respects the global notification engine kill-switch and its own per-provider feature flag. -4. Security: auth tokens are stored securely (never exposed in API responses or logs). -5. Full E2E and unit test coverage matching the existing provider test suite. +### Success Metrics + +| Metric | Target | +|--------|--------| +| Issue #26 checklist tasks complete | 8/8 | +| New backend aggregation endpoints covered by unit tests | ≥ 85% line coverage | +| New frontend components covered by Vitest unit tests | ≥ 85% line coverage | +| E2E tests for dashboard page passing | All browsers (Firefox, Chromium, WebKit) | +| Dashboard page initial load time | < 2 seconds on cached data | +| No new CRITICAL/HIGH security findings | GORM scanner, CodeQL, Trivy | --- -## 2. Research Findings +## 2. Requirements (EARS Notation) -### Existing Architecture +### R1: Metrics Dashboard Tab -Charon's notification engine does **not** use a Go interface pattern. Instead, it -routes on string type values (`"discord"`, `"gotify"`, `"webhook"`, etc.) across -~15 switch/case + hardcoded lists in both backend and frontend. +**WHEN** the user navigates to `/security/crowdsec`, **THE SYSTEM SHALL** display a "Dashboard" tab alongside the existing configuration interface, showing summary statistics (total bans, active bans, unique IPs, top scenario). -**Key code paths per provider type:** +### R2: Active Bans with Reasons -| Layer | Location | Mechanism | -|-------|----------|-----------| -| Model | `backend/internal/models/notification_provider.go` | Generic — no per-type changes needed | -| Service — type allowlist | `notification_service.go:139` `isSupportedNotificationProviderType()` | `switch` on type string | -| Service — flag routing | `notification_service.go:148` `isDispatchEnabled()` | `switch` → feature flag lookup | -| Service — dispatch | `notification_service.go:381` `sendJSONPayload()` | Type-specific validation + URL / header construction | -| Feature flags | `notifications/feature_flags.go` | Const strings for settings DB keys | -| Router | `notifications/router.go:10` `ShouldUseNotify()` | `switch` on type → flag map lookup | -| Handler — create validation | `notification_provider_handler.go:185` | Hardcoded `!=` chain | -| Handler — update validation | `notification_provider_handler.go:245` | Hardcoded `!=` chain | -| Handler — URL validation | `notification_provider_handler.go:372` | Slack special-case (optional URL) | -| Frontend — type array | `api/notifications.ts:3` | `SUPPORTED_NOTIFICATION_PROVIDER_TYPES` const | -| Frontend — sanitize | `api/notifications.ts` `sanitizeProviderForWriteAction()` | Token mapping per type | -| Frontend — form | `pages/Notifications.tsx` | `