1fe69c2a15
- 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.
628 lines
17 KiB
Go
628 lines
17 KiB
Go
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)
|
|
}
|
|
}
|