Skip to content

Commit 7a1f4cb

Browse files
committed
Minor-bugfixes
1 parent 1fcd5ab commit 7a1f4cb

12 files changed

Lines changed: 491 additions & 55 deletions

File tree

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

internal/api/handlers/health_diagnostics.go

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,11 @@ func parseBouncersJSON(bouncerOutput string, computeStatus bool) ([]models.Bounc
3535
bouncer.Valid = valid
3636
}
3737
if lastPull, err := jsonparser.GetString(value, "last_pull"); err == nil {
38-
if t, err := time.Parse(time.RFC3339, lastPull); err == nil {
39-
bouncer.LastPull = t
38+
for _, layout := range []string{time.RFC3339Nano, time.RFC3339} {
39+
if t, err := time.Parse(layout, lastPull); err == nil {
40+
bouncer.LastPull = t
41+
break
42+
}
4043
}
4144
}
4245
if bouncerType, err := jsonparser.GetString(value, "type"); err == nil {
@@ -47,12 +50,14 @@ func parseBouncersJSON(bouncerOutput string, computeStatus bool) ([]models.Bounc
4750
}
4851

4952
if computeStatus {
50-
if bouncer.Valid && time.Since(bouncer.LastPull) <= 60*time.Minute {
53+
if !bouncer.Valid {
54+
bouncer.Status = "disconnected"
55+
} else if bouncer.LastPull.IsZero() {
56+
bouncer.Status = "pending" // valid key, never pulled yet
57+
} else if time.Since(bouncer.LastPull) <= 60*time.Minute {
5158
bouncer.Status = "connected"
52-
} else if bouncer.Valid {
53-
bouncer.Status = "stale"
5459
} else {
55-
bouncer.Status = "disconnected"
60+
bouncer.Status = "stale"
5661
}
5762
}
5863

@@ -172,7 +177,6 @@ func collectContainerHealth(dockerClient *docker.Client, cfg *config.Config) ([]
172177
return containers, allRunning
173178
}
174179

175-
176180
// checkTraefikIntegrationDiagnostic checks Traefik integration for diagnostics
177181
func checkTraefikIntegrationDiagnostic(dockerClient *docker.Client, db *database.Database, cfg *config.Config) *models.TraefikIntegration {
178182
traefikIntegration := &models.TraefikIntegration{

internal/api/routes.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ func RegisterAllowlistRoutes(router *gin.RouterGroup, dockerClient *docker.Clien
6060
allowlist.GET("/inspect/:name", handlers.InspectAllowlist(dockerClient, cfg))
6161
allowlist.POST("/add", handlers.AddAllowlistEntries(dockerClient, cfg))
6262
allowlist.POST("/remove", handlers.RemoveAllowlistEntries(dockerClient, cfg))
63+
allowlist.POST("/import", handlers.ImportAllowlistEntries(dockerClient, cfg))
6364
allowlist.DELETE("/:name", handlers.DeleteAllowlist(dockerClient, cfg))
6465
}
6566
}

0 commit comments

Comments
 (0)