diff --git a/backend/internal/services/crowdsec_whitelist_service.go b/backend/internal/services/crowdsec_whitelist_service.go new file mode 100644 index 00000000..8d89c3ba --- /dev/null +++ b/backend/internal/services/crowdsec_whitelist_service.go @@ -0,0 +1,188 @@ +package services + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strings" + + "github.com/Wikid82/charon/backend/internal/logger" + "github.com/Wikid82/charon/backend/internal/models" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// Sentinel errors for CrowdSecWhitelistService operations. +var ( + ErrWhitelistNotFound = errors.New("whitelist entry not found") + ErrInvalidIPOrCIDR = errors.New("invalid IP address or CIDR notation") + ErrDuplicateEntry = errors.New("entry already exists in whitelist") +) + +const whitelistYAMLHeader = `name: charon-whitelist +description: "Charon-managed IP/CIDR whitelist" +filter: "evt.Meta.service == 'http'" +whitelist: + reason: "Charon managed whitelist" +` + +// CrowdSecWhitelistService manages the CrowdSec IP/CIDR whitelist. +type CrowdSecWhitelistService struct { + db *gorm.DB + dataDir string +} + +// NewCrowdSecWhitelistService creates a new CrowdSecWhitelistService. +func NewCrowdSecWhitelistService(db *gorm.DB, dataDir string) *CrowdSecWhitelistService { + return &CrowdSecWhitelistService{db: db, dataDir: dataDir} +} + +// List returns all whitelist entries ordered by creation time. +func (s *CrowdSecWhitelistService) List(ctx context.Context) ([]models.CrowdSecWhitelist, error) { + var entries []models.CrowdSecWhitelist + if err := s.db.WithContext(ctx).Order("created_at ASC").Find(&entries).Error; err != nil { + return nil, fmt.Errorf("list whitelist entries: %w", err) + } + return entries, nil +} + +// Add validates and persists a new whitelist entry, then regenerates the YAML file. +// Returns ErrInvalidIPOrCIDR for malformed input and ErrDuplicateEntry for conflicts. +func (s *CrowdSecWhitelistService) Add(ctx context.Context, ipOrCIDR, reason string) (*models.CrowdSecWhitelist, error) { + normalized, err := normalizeIPOrCIDR(strings.TrimSpace(ipOrCIDR)) + if err != nil { + return nil, ErrInvalidIPOrCIDR + } + + entry := models.CrowdSecWhitelist{ + UUID: uuid.New().String(), + IPOrCIDR: normalized, + Reason: reason, + } + + if err := s.db.WithContext(ctx).Create(&entry).Error; err != nil { + if errors.Is(err, gorm.ErrDuplicatedKey) || strings.Contains(err.Error(), "UNIQUE constraint failed") { + return nil, ErrDuplicateEntry + } + return nil, fmt.Errorf("add whitelist entry: %w", err) + } + + if err := s.WriteYAML(ctx); err != nil { + logger.Log().WithError(err).Warn("failed to write CrowdSec whitelist YAML after add (non-fatal)") + } + + return &entry, nil +} + +// Delete removes a whitelist entry by UUID and regenerates the YAML file. +// Returns ErrWhitelistNotFound if the UUID does not exist. +func (s *CrowdSecWhitelistService) Delete(ctx context.Context, id string) error { + result := s.db.WithContext(ctx).Where("uuid = ?", id).Delete(&models.CrowdSecWhitelist{}) + if result.Error != nil { + return fmt.Errorf("delete whitelist entry: %w", result.Error) + } + if result.RowsAffected == 0 { + return ErrWhitelistNotFound + } + + if err := s.WriteYAML(ctx); err != nil { + logger.Log().WithError(err).Warn("failed to write CrowdSec whitelist YAML after delete (non-fatal)") + } + + return nil +} + +// WriteYAML renders and atomically writes the CrowdSec whitelist YAML file. +// It is a no-op when dataDir is empty (unit-test mode). +func (s *CrowdSecWhitelistService) WriteYAML(ctx context.Context) error { + if s.dataDir == "" { + return nil + } + + var entries []models.CrowdSecWhitelist + if err := s.db.WithContext(ctx).Order("created_at ASC").Find(&entries).Error; err != nil { + return fmt.Errorf("write whitelist yaml: query entries: %w", err) + } + + var ips, cidrs []string + for _, e := range entries { + if strings.Contains(e.IPOrCIDR, "/") { + cidrs = append(cidrs, e.IPOrCIDR) + } else { + ips = append(ips, e.IPOrCIDR) + } + } + + content := buildWhitelistYAML(ips, cidrs) + + dir := filepath.Join(s.dataDir, "config", "parsers", "s02-enrich") + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("write whitelist yaml: create dir: %w", err) + } + + target := filepath.Join(dir, "charon-whitelist.yaml") + tmp := target + ".tmp" + + if err := os.WriteFile(tmp, content, 0o640); err != nil { + return fmt.Errorf("write whitelist yaml: write temp: %w", err) + } + + if err := os.Rename(tmp, target); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("write whitelist yaml: rename: %w", err) + } + + return nil +} + +// normalizeIPOrCIDR validates and normalizes an IP address or CIDR block. +// For CIDRs, the network address is returned (e.g. "10.0.0.1/8" → "10.0.0.0/8"). +func normalizeIPOrCIDR(raw string) (string, error) { + if strings.Contains(raw, "/") { + ip, network, err := net.ParseCIDR(raw) + if err != nil { + return "", err + } + _ = ip + return network.String(), nil + } + if net.ParseIP(raw) == nil { + return "", fmt.Errorf("invalid IP: %q", raw) + } + return raw, nil +} + +// buildWhitelistYAML constructs the YAML content for the CrowdSec whitelist parser. +func buildWhitelistYAML(ips, cidrs []string) []byte { + var sb strings.Builder + sb.WriteString(whitelistYAMLHeader) + + sb.WriteString(" ip:") + if len(ips) == 0 { + sb.WriteString(" []\n") + } else { + sb.WriteString("\n") + for _, ip := range ips { + sb.WriteString(" - \"") + sb.WriteString(ip) + sb.WriteString("\"\n") + } + } + + sb.WriteString(" cidr:") + if len(cidrs) == 0 { + sb.WriteString(" []\n") + } else { + sb.WriteString("\n") + for _, cidr := range cidrs { + sb.WriteString(" - \"") + sb.WriteString(cidr) + sb.WriteString("\"\n") + } + } + + return []byte(sb.String()) +}