Skip to content

Commit 56e5a76

Browse files
Merge pull request #53 from hhftechnology/pangolin
decision-alert-history
2 parents aa5d8ee + cf91ecb commit 56e5a76

63 files changed

Lines changed: 4649 additions & 2471 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/server/main.go

Lines changed: 18 additions & 3 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)
@@ -98,13 +111,15 @@ func main() {
98111
router.Use(gin.Recovery())
99112
router.Use(logger.GinLogger())
100113

101-
// Configure CORS for frontend development servers
114+
// Configure CORS – allow all origins so the Capacitor mobile app
115+
// (which runs from capacitor://localhost or https://localhost) can
116+
// reach the API alongside browser-based frontends.
102117
router.Use(cors.New(cors.Config{
103-
AllowOrigins: []string{"http://localhost:3000", "http://localhost:5173"},
118+
AllowAllOrigins: true,
104119
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
105120
AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Docker-Host"},
106121
ExposeHeaders: []string{"Content-Length"},
107-
AllowCredentials: true,
122+
AllowCredentials: false,
108123
MaxAge: 12 * time.Hour,
109124
}))
110125

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/allowlists.go

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
package handlers
22

33
import (
4+
"bufio"
45
"fmt"
6+
"io"
7+
"net"
58
"net/http"
9+
"strconv"
10+
"strings"
611
"time"
712

813
"crowdsec-manager/internal/config"
@@ -343,3 +348,234 @@ func DeleteAllowlist(dockerClient *docker.Client, cfg *config.Config) gin.Handle
343348
})
344349
}
345350
}
351+
352+
// =============================================================================
353+
// ALLOWLIST IMPORT
354+
// =============================================================================
355+
356+
// privateIPRanges holds RFC 1918, loopback, and link-local ranges used by isPrivateIPAddr.
357+
var privateIPRanges = func() []*net.IPNet {
358+
cidrs := []string{
359+
"10.0.0.0/8",
360+
"172.16.0.0/12",
361+
"192.168.0.0/16",
362+
"127.0.0.0/8",
363+
"::1/128",
364+
"fc00::/7",
365+
"fe80::/10",
366+
}
367+
ranges := make([]*net.IPNet, 0, len(cidrs))
368+
for _, c := range cidrs {
369+
_, ipNet, err := net.ParseCIDR(c)
370+
if err == nil {
371+
ranges = append(ranges, ipNet)
372+
}
373+
}
374+
return ranges
375+
}()
376+
377+
// isValidIPOrCIDR returns true if s is a valid IP address or CIDR notation.
378+
func isValidIPOrCIDR(s string) bool {
379+
if net.ParseIP(s) != nil {
380+
return true
381+
}
382+
_, _, err := net.ParseCIDR(s)
383+
return err == nil
384+
}
385+
386+
// isPrivateIPAddr returns true if s falls within a private/loopback address range.
387+
func isPrivateIPAddr(s string) bool {
388+
ip := net.ParseIP(s)
389+
if ip == nil {
390+
// For CIDRs, check the network address
391+
ip, _, _ = net.ParseCIDR(s)
392+
}
393+
if ip == nil {
394+
return false
395+
}
396+
for _, r := range privateIPRanges {
397+
if r.Contains(ip) {
398+
return true
399+
}
400+
}
401+
return false
402+
}
403+
404+
// parseAllowlistImportFile reads a reader and returns unique, non-empty candidate strings.
405+
// Entries can be separated by newlines or commas.
406+
func parseAllowlistImportFile(r io.Reader) []string {
407+
seen := make(map[string]struct{})
408+
var out []string
409+
scanner := bufio.NewScanner(r)
410+
for scanner.Scan() {
411+
line := strings.TrimSpace(scanner.Text())
412+
if line == "" || strings.HasPrefix(line, "#") {
413+
continue
414+
}
415+
for _, part := range strings.Split(line, ",") {
416+
entry := strings.TrimSpace(part)
417+
if entry == "" {
418+
continue
419+
}
420+
if _, exists := seen[entry]; !exists {
421+
seen[entry] = struct{}{}
422+
out = append(out, entry)
423+
}
424+
}
425+
}
426+
return out
427+
}
428+
429+
// getExistingAllowlistValues fetches the current values in an allowlist for duplicate detection.
430+
func getExistingAllowlistValues(dockerClient *docker.Client, containerName, allowlistName string) map[string]struct{} {
431+
existing := make(map[string]struct{})
432+
output, err := dockerClient.ExecCommand(containerName, []string{"cscli", "allowlists", "inspect", allowlistName, "-o", "json"})
433+
if err != nil {
434+
return existing
435+
}
436+
dataBytes, err := parseCLIJSONToBytes(output)
437+
if err != nil {
438+
return existing
439+
}
440+
jsonparser.ArrayEach(dataBytes, func(itemValue []byte, _ jsonparser.ValueType, _ int, _ error) {
441+
if val, err := jsonparser.GetString(itemValue, "value"); err == nil {
442+
existing[val] = struct{}{}
443+
}
444+
}, "items")
445+
return existing
446+
}
447+
448+
// ImportAllowlistEntries imports a plain-text list of IPs/CIDRs into an allowlist with optional filtering.
449+
// Accepts multipart/form-data with fields:
450+
// - file – text file, one IP/CIDR per line (or comma-separated)
451+
// - allowlist_name – target allowlist (required)
452+
// - expiration – optional, e.g. "7d", "30d"
453+
// - description – optional note added to entries
454+
// - skip_invalid – "true"/"false" (default true) – drop non-IP/CIDR tokens
455+
// - skip_private – "true"/"false" (default false) – drop RFC 1918 addresses
456+
// - skip_duplicates – "true"/"false" (default true) – drop entries already in allowlist
457+
func ImportAllowlistEntries(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFunc {
458+
return func(c *gin.Context) {
459+
dockerClient = resolveDockerClient(c, dockerClient)
460+
461+
allowlistName := strings.TrimSpace(c.PostForm("allowlist_name"))
462+
if allowlistName == "" {
463+
c.JSON(http.StatusBadRequest, models.Response{
464+
Success: false,
465+
Error: "allowlist_name is required",
466+
})
467+
return
468+
}
469+
470+
expiration := strings.TrimSpace(c.PostForm("expiration"))
471+
description := strings.TrimSpace(c.PostForm("description"))
472+
473+
parseBool := func(key string, defaultVal bool) bool {
474+
v := strings.TrimSpace(c.PostForm(key))
475+
if v == "" {
476+
return defaultVal
477+
}
478+
b, err := strconv.ParseBool(v)
479+
if err != nil {
480+
return defaultVal
481+
}
482+
return b
483+
}
484+
skipInvalid := parseBool("skip_invalid", true)
485+
skipPrivate := parseBool("skip_private", false)
486+
skipDuplicates := parseBool("skip_duplicates", true)
487+
488+
file, _, err := c.Request.FormFile("file")
489+
if err != nil {
490+
c.JSON(http.StatusBadRequest, models.Response{
491+
Success: false,
492+
Error: "file upload required: " + err.Error(),
493+
})
494+
return
495+
}
496+
defer file.Close()
497+
498+
candidates := parseAllowlistImportFile(file)
499+
500+
var (
501+
skippedInvalid int
502+
skippedPrivate int
503+
skippedDuplicates int
504+
toAdd []string
505+
)
506+
507+
var existing map[string]struct{}
508+
if skipDuplicates {
509+
existing = getExistingAllowlistValues(dockerClient, cfg.CrowdsecContainerName, allowlistName)
510+
}
511+
512+
for _, entry := range candidates {
513+
if skipInvalid && !isValidIPOrCIDR(entry) {
514+
skippedInvalid++
515+
continue
516+
}
517+
if skipPrivate && isPrivateIPAddr(entry) {
518+
skippedPrivate++
519+
continue
520+
}
521+
if skipDuplicates {
522+
if _, exists := existing[entry]; exists {
523+
skippedDuplicates++
524+
continue
525+
}
526+
}
527+
toAdd = append(toAdd, entry)
528+
}
529+
530+
imported := 0
531+
const chunkSize = 50
532+
for i := 0; i < len(toAdd); i += chunkSize {
533+
end := i + chunkSize
534+
if end > len(toAdd) {
535+
end = len(toAdd)
536+
}
537+
chunk := toAdd[i:end]
538+
539+
cmd := []string{"cscli", "allowlists", "add", allowlistName}
540+
if expiration != "" {
541+
cmd = append(cmd, "--expiration", expiration)
542+
}
543+
if description != "" {
544+
cmd = append(cmd, "--description", description)
545+
}
546+
cmd = append(cmd, chunk...)
547+
548+
output, execErr := dockerClient.ExecCommand(cfg.CrowdsecContainerName, cmd)
549+
if execErr != nil {
550+
logger.Error("Failed to add allowlist chunk", "name", allowlistName, "error", execErr, "output", output)
551+
c.JSON(http.StatusInternalServerError, models.Response{
552+
Success: false,
553+
Error: fmt.Sprintf("Failed to add entries (imported %d so far): %v", imported, execErr),
554+
})
555+
return
556+
}
557+
imported += len(chunk)
558+
}
559+
560+
logger.Info("Allowlist import completed",
561+
"name", allowlistName,
562+
"total_input", len(candidates),
563+
"imported", imported,
564+
"skipped_invalid", skippedInvalid,
565+
"skipped_private", skippedPrivate,
566+
"skipped_duplicates", skippedDuplicates,
567+
)
568+
569+
c.JSON(http.StatusOK, models.Response{
570+
Success: true,
571+
Message: fmt.Sprintf("Imported %d entries into '%s'", imported, allowlistName),
572+
Data: gin.H{
573+
"total_input": len(candidates),
574+
"imported": imported,
575+
"skipped_invalid": skippedInvalid,
576+
"skipped_private": skippedPrivate,
577+
"skipped_duplicates": skippedDuplicates,
578+
},
579+
})
580+
}
581+
}

internal/api/handlers/bouncers.go

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import (
1111
"fmt"
1212
"net/http"
1313
"strings"
14-
"time"
1514

1615
"github.com/gin-gonic/gin"
1716
)
@@ -33,8 +32,9 @@ func GetBouncers(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFun
3332
return
3433
}
3534

36-
// Parse and normalize CLI JSON output first, then decode typed payload.
37-
dataBytes, parseErr := parseCLIJSONToBytes(output)
35+
// Use parseBouncersJSON to handle multiple timestamp formats (RFC3339Nano, RFC3339)
36+
// and compute status in one pass (avoids duplicate logic and silent zero-time failures).
37+
bouncers, parseErr := parseBouncersJSON(output, true)
3838
if parseErr != nil {
3939
logger.Warn("Failed to parse bouncers JSON",
4040
"error", parseErr,
@@ -47,29 +47,6 @@ func GetBouncers(dockerClient *docker.Client, cfg *config.Config) gin.HandlerFun
4747
return
4848
}
4949

50-
var bouncers []models.Bouncer
51-
if err := json.Unmarshal(dataBytes, &bouncers); err != nil {
52-
c.JSON(http.StatusInternalServerError, models.Response{
53-
Success: false,
54-
Error: fmt.Sprintf("Failed to parse bouncers JSON: %v", err),
55-
})
56-
return
57-
}
58-
59-
// Compute status for each bouncer
60-
for i := range bouncers {
61-
// Primary indicator: valid key + pulled within 60 minutes = connected
62-
if bouncers[i].Valid && time.Since(bouncers[i].LastPull) <= 60*time.Minute {
63-
bouncers[i].Status = "connected"
64-
} else if bouncers[i].Valid {
65-
// Valid key but hasn't pulled recently - stale but registered
66-
bouncers[i].Status = "stale"
67-
} else {
68-
// Key is invalid/revoked - bouncer is disconnected
69-
bouncers[i].Status = "disconnected"
70-
}
71-
}
72-
7350
logger.Debug("Bouncers API retrieved successfully", "count", len(bouncers))
7451

7552
// Return properly formatted data

0 commit comments

Comments
 (0)