Skip to content

Commit 46d2e6b

Browse files
committed
decision-alert-history
1 parent b86afd3 commit 46d2e6b

16 files changed

Lines changed: 1716 additions & 3 deletions

File tree

cmd/server/main.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"crowdsec-manager/internal/cron"
2525
"crowdsec-manager/internal/database"
2626
"crowdsec-manager/internal/docker"
27+
"crowdsec-manager/internal/history"
2728
"crowdsec-manager/internal/logger"
2829
"crowdsec-manager/internal/messaging"
2930
)
@@ -50,6 +51,13 @@ func main() {
5051
defer db.Close()
5152
logger.Info("Database initialized", "path", cfg.DatabasePath)
5253

54+
historyStore, err := history.NewStore(cfg.HistoryDatabasePath)
55+
if err != nil {
56+
logger.Fatal("Failed to initialize history database", "error", err)
57+
}
58+
defer historyStore.Close()
59+
logger.Info("History database initialized", "path", cfg.HistoryDatabasePath)
60+
5361
// Initialize multi-host Docker client (falls back to single host if DOCKER_HOSTS is empty)
5462
multiHost, err := docker.NewMultiHostClient(cfg.DockerHosts)
5563
if err != nil {
@@ -75,6 +83,11 @@ func main() {
7583
go hub.Run()
7684
defer hub.Stop()
7785

86+
historyService := history.NewService(historyStore, dockerClient, cfg, hub)
87+
historyService.Start()
88+
defer historyService.Stop()
89+
handlers.SetHistoryService(historyService)
90+
7891
// Initialize config validator for drift detection and recovery
7992
validator := configvalidator.NewValidator(db, dockerClient, hub, cfg)
8093
handlers.SetConfigValidator(validator)

internal/api/handlers/alerts_inspect.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"net/http"
77
"regexp"
8+
"strconv"
89

910
"crowdsec-manager/internal/config"
1011
"crowdsec-manager/internal/docker"
@@ -50,6 +51,13 @@ func DeleteAlert(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFun
5051
return
5152
}
5253

54+
if historyService != nil {
55+
alertIDInt, _ := strconv.ParseInt(alertID, 10, 64)
56+
if err := historyService.MarkAlertDeleted(c.Request.Context(), alertIDInt); err != nil {
57+
logger.Warn("Failed to mark alert history stale after delete", "alertID", alertID, "error", err)
58+
}
59+
}
60+
5361
logger.Info("Alert deleted", "alertID", alertID, "output", output)
5462
c.JSON(http.StatusOK, models.Response{
5563
Success: true,

internal/api/handlers/decisions.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package handlers
33
import (
44
"fmt"
55
"net/http"
6+
"strconv"
67

78
"crowdsec-manager/internal/config"
89
"crowdsec-manager/internal/docker"
@@ -125,6 +126,13 @@ func DeleteDecision(dockerClient *docker.Client, cfg *config.Config) gin.Handler
125126
return
126127
}
127128

129+
if historyService != nil {
130+
decisionID, _ := strconv.ParseInt(req.ID, 10, 64)
131+
if err := historyService.MarkDecisionDeleted(c.Request.Context(), decisionID, req.Value); err != nil {
132+
logger.Warn("Failed to mark decision history stale after delete", "id", req.ID, "value", req.Value, "error", err)
133+
}
134+
}
135+
128136
c.JSON(http.StatusOK, models.Response{
129137
Success: true,
130138
Message: "Decision(s) deleted successfully",

internal/api/handlers/handlers.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,25 @@ package handlers
22

33
import (
44
"crowdsec-manager/internal/configvalidator"
5+
"crowdsec-manager/internal/history"
56
"crowdsec-manager/internal/logger"
67
)
78

89
// configValidator is the package-level validator used by handlers for auto-snapshotting.
910
// Set via SetConfigValidator during server startup.
1011
var configValidator *configvalidator.Validator
12+
var historyService *history.Service
1113

1214
// SetConfigValidator sets the package-level config validator for auto-snapshot hooks.
1315
func SetConfigValidator(v *configvalidator.Validator) {
1416
configValidator = v
1517
}
1618

19+
// SetHistoryService sets the package-level history service for history APIs/hooks.
20+
func SetHistoryService(s *history.Service) {
21+
historyService = s
22+
}
23+
1724
// autoSnapshot takes a snapshot of a config type after a successful write.
1825
// Safe to call when configValidator is nil (no-op).
1926
func autoSnapshot(configType string) {

internal/api/handlers/history.go

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
package handlers
2+
3+
import (
4+
"net/http"
5+
"strconv"
6+
7+
"crowdsec-manager/internal/models"
8+
9+
"github.com/gin-gonic/gin"
10+
)
11+
12+
func parseStaleFilter(raw string) (*bool, error) {
13+
if raw == "" {
14+
return nil, nil
15+
}
16+
value, err := strconv.ParseBool(raw)
17+
if err != nil {
18+
return nil, err
19+
}
20+
return &value, nil
21+
}
22+
23+
// GetHistoryConfig returns history retention config.
24+
func GetHistoryConfig() gin.HandlerFunc {
25+
return func(c *gin.Context) {
26+
if historyService == nil {
27+
c.JSON(http.StatusServiceUnavailable, models.Response{Success: false, Error: "history service unavailable"})
28+
return
29+
}
30+
31+
cfg, err := historyService.GetHistoryConfig(c.Request.Context())
32+
if err != nil {
33+
c.JSON(http.StatusInternalServerError, models.Response{Success: false, Error: "failed to read history config: " + err.Error()})
34+
return
35+
}
36+
37+
c.JSON(http.StatusOK, models.Response{Success: true, Data: cfg})
38+
}
39+
}
40+
41+
// UpdateHistoryConfig updates history retention config.
42+
func UpdateHistoryConfig() gin.HandlerFunc {
43+
return func(c *gin.Context) {
44+
if historyService == nil {
45+
c.JSON(http.StatusServiceUnavailable, models.Response{Success: false, Error: "history service unavailable"})
46+
return
47+
}
48+
49+
var req struct {
50+
RetentionDays int `json:"retention_days"`
51+
}
52+
if err := c.ShouldBindJSON(&req); err != nil {
53+
c.JSON(http.StatusBadRequest, models.Response{Success: false, Error: "invalid request: " + err.Error()})
54+
return
55+
}
56+
57+
if req.RetentionDays < 1 || req.RetentionDays > 365 {
58+
c.JSON(http.StatusBadRequest, models.Response{Success: false, Error: "retention_days must be between 1 and 365"})
59+
return
60+
}
61+
62+
cfg, err := historyService.UpdateRetentionDays(c.Request.Context(), req.RetentionDays)
63+
if err != nil {
64+
c.JSON(http.StatusInternalServerError, models.Response{Success: false, Error: "failed to update history config: " + err.Error()})
65+
return
66+
}
67+
68+
c.JSON(http.StatusOK, models.Response{Success: true, Message: "History config updated", Data: cfg})
69+
}
70+
}
71+
72+
// GetDecisionHistory returns persisted decision history entries.
73+
func GetDecisionHistory() gin.HandlerFunc {
74+
return func(c *gin.Context) {
75+
if historyService == nil {
76+
c.JSON(http.StatusServiceUnavailable, models.Response{Success: false, Error: "history service unavailable"})
77+
return
78+
}
79+
80+
stale, err := parseStaleFilter(c.Query("stale"))
81+
if err != nil {
82+
c.JSON(http.StatusBadRequest, models.Response{Success: false, Error: "invalid stale filter"})
83+
return
84+
}
85+
86+
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
87+
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
88+
filter := models.DecisionHistoryFilter{
89+
Stale: stale,
90+
Value: c.Query("value"),
91+
Scenario: c.Query("scenario"),
92+
Since: c.Query("since"),
93+
Limit: limit,
94+
Offset: offset,
95+
}
96+
97+
records, total, err := historyService.ListDecisionHistory(c.Request.Context(), filter)
98+
if err != nil {
99+
c.JSON(http.StatusInternalServerError, models.Response{Success: false, Error: "failed to load decision history: " + err.Error()})
100+
return
101+
}
102+
103+
c.JSON(http.StatusOK, models.Response{
104+
Success: true,
105+
Data: gin.H{
106+
"decisions": records,
107+
"count": len(records),
108+
"total": total,
109+
},
110+
})
111+
}
112+
}
113+
114+
// GetAlertHistory returns persisted alert history entries.
115+
func GetAlertHistory() gin.HandlerFunc {
116+
return func(c *gin.Context) {
117+
if historyService == nil {
118+
c.JSON(http.StatusServiceUnavailable, models.Response{Success: false, Error: "history service unavailable"})
119+
return
120+
}
121+
122+
stale, err := parseStaleFilter(c.Query("stale"))
123+
if err != nil {
124+
c.JSON(http.StatusBadRequest, models.Response{Success: false, Error: "invalid stale filter"})
125+
return
126+
}
127+
128+
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
129+
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
130+
filter := models.AlertHistoryFilter{
131+
Stale: stale,
132+
Value: c.Query("value"),
133+
Scenario: c.Query("scenario"),
134+
Since: c.Query("since"),
135+
Limit: limit,
136+
Offset: offset,
137+
}
138+
139+
records, total, err := historyService.ListAlertHistory(c.Request.Context(), filter)
140+
if err != nil {
141+
c.JSON(http.StatusInternalServerError, models.Response{Success: false, Error: "failed to load alert history: " + err.Error()})
142+
return
143+
}
144+
145+
c.JSON(http.StatusOK, models.Response{
146+
Success: true,
147+
Data: gin.H{
148+
"alerts": records,
149+
"count": len(records),
150+
"total": total,
151+
},
152+
})
153+
}
154+
}
155+
156+
// GetRepeatedOffenders returns repeated offenders from persisted history.
157+
func GetRepeatedOffenders() gin.HandlerFunc {
158+
return func(c *gin.Context) {
159+
if historyService == nil {
160+
c.JSON(http.StatusServiceUnavailable, models.Response{Success: false, Error: "history service unavailable"})
161+
return
162+
}
163+
164+
offenders, err := historyService.ListRepeatedOffenders(c.Request.Context())
165+
if err != nil {
166+
c.JSON(http.StatusInternalServerError, models.Response{Success: false, Error: "failed to load repeated offenders: " + err.Error()})
167+
return
168+
}
169+
170+
c.JSON(http.StatusOK, models.Response{
171+
Success: true,
172+
Data: gin.H{
173+
"offenders": offenders,
174+
"count": len(offenders),
175+
},
176+
})
177+
}
178+
}

internal/api/routes.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,9 +148,14 @@ func RegisterServicesRoutes(router *gin.RouterGroup, dockerClient *docker.Client
148148
crowdsec.DELETE("/decisions", handlers.DeleteDecision(dockerClient, cfg))
149149
crowdsec.POST("/decisions/import", handlers.ImportDecisions(dockerClient, cfg))
150150
crowdsec.GET("/decisions/analysis", handlers.GetDecisionsAnalysis(dockerClient, cfg))
151+
crowdsec.GET("/decisions/history", handlers.GetDecisionHistory())
152+
crowdsec.GET("/decisions/repeated-offenders", handlers.GetRepeatedOffenders())
151153
crowdsec.GET("/alerts/analysis", handlers.GetAlertsAnalysis(dockerClient, cfg, c))
154+
crowdsec.GET("/alerts/history", handlers.GetAlertHistory())
152155
crowdsec.GET("/alerts/:id", handlers.InspectAlert(dockerClient, cfg))
153156
crowdsec.DELETE("/alerts/:id", handlers.DeleteAlert(dockerClient, cfg))
157+
crowdsec.GET("/history/config", handlers.GetHistoryConfig())
158+
crowdsec.PUT("/history/config", handlers.UpdateHistoryConfig())
154159
crowdsec.GET("/metrics", handlers.GetMetrics(dockerClient, cfg, c))
155160
crowdsec.POST("/enroll", handlers.EnrollCrowdSec(dockerClient, db, cfg))
156161
crowdsec.POST("/enroll/finalize", handlers.FinalizeCrowdSecEnrollment(dockerClient, cfg))

internal/config/config.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ type Config struct {
2525
ConfigDir string
2626

2727
// Database
28-
DatabasePath string
28+
DatabasePath string
29+
HistoryDatabasePath string
2930

3031
// File paths (from environment or database)
3132
TraefikDynamicConfig string
@@ -96,6 +97,7 @@ func Load() (*Config, error) {
9697
PangolinDir: getEnv("PANGOLIN_DIR", "."),
9798
ConfigDir: getEnv("CONFIG_DIR", "./config"),
9899
DatabasePath: getEnv("DATABASE_PATH", "./data/settings.db"),
100+
HistoryDatabasePath: getEnv("HISTORY_DATABASE_PATH", "./data/history.db"),
99101
TraefikDynamicConfig: getEnv("TRAEFIK_DYNAMIC_CONFIG", "/etc/traefik/dynamic_config.yml"),
100102
TraefikStaticConfig: getEnv("TRAEFIK_STATIC_CONFIG", "/etc/traefik/traefik_config.yml"),
101103
TraefikAccessLog: getEnv("TRAEFIK_ACCESS_LOG", "/var/log/traefik/access.log"),

0 commit comments

Comments
 (0)