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.
This commit is contained in:
627
backend/internal/api/handlers/crowdsec_dashboard.go
Normal file
627
backend/internal/api/handlers/crowdsec_dashboard.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
62
backend/internal/api/handlers/crowdsec_dashboard_cache.go
Normal file
62
backend/internal/api/handlers/crowdsec_dashboard_cache.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
486
backend/internal/api/handlers/crowdsec_dashboard_test.go
Normal file
486
backend/internal/api/handlers/crowdsec_dashboard_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user