package crowdsec import ( "bytes" "encoding/json" "fmt" "os/exec" "strings" ) // Machine represents a CrowdSec agent registered with this LAPI type Machine struct { MachineID string `json:"machineId"` IsValidated bool `json:"isValidated"` LastPush string `json:"lastPush,omitempty"` LastHeartbeat string `json:"lastHeartbeat,omitempty"` IPAddress string `json:"ipAddress,omitempty"` Version string `json:"version,omitempty"` } // BanSummary aggregates active ban counts by scenario/reason type BanSummary struct { Total int `json:"total"` ByReason map[string]int `json:"byReason"` } // Bouncer represents a bouncer registered with this LAPI type Bouncer struct { Name string `json:"name"` Revoked bool `json:"revoked"` IPAddress string `json:"ipAddress,omitempty"` Type string `json:"type,omitempty"` Version string `json:"version,omitempty"` LastPull string `json:"lastPull,omitempty"` } // Decision represents an active ban/captcha/allow decision type Decision struct { ID int `json:"id"` Value string `json:"value"` Type string `json:"type"` Scope string `json:"scope"` Reason string `json:"scenario"` Origin string `json:"origin"` Duration string `json:"duration,omitempty"` } // Alert represents a detection event pushed by a registered agent type Alert struct { ID int `json:"id"` Scenario string `json:"scenario"` Message string `json:"message"` CreatedAt string `json:"createdAt"` MachineID string `json:"machineId,omitempty"` SourceIP string `json:"sourceIP,omitempty"` Country string `json:"country,omitempty"` ASName string `json:"asName,omitempty"` } // Status represents the current state of CrowdSec on Wild Central type Status struct { Active bool `json:"active"` FirewallBouncer bool `json:"firewallBouncer"` Machines []Machine `json:"machines"` Bouncers []Bouncer `json:"bouncers"` } // Manager wraps `sudo cscli` commands type Manager struct{} // NewManager creates a new CrowdSec manager func NewManager() *Manager { return &Manager{} } // GetStatus returns whether CrowdSec is active plus lists of machines and bouncers func (m *Manager) GetStatus() (*Status, error) { cmd := exec.Command("systemctl", "is-active", "--quiet", "crowdsec") active := cmd.Run() == nil fwCmd := exec.Command("systemctl", "is-active", "--quiet", "crowdsec-firewall-bouncer") fwActive := fwCmd.Run() == nil status := &Status{Active: active, FirewallBouncer: fwActive} if !active { return status, nil } machines, _ := m.GetMachines() bouncers, _ := m.GetBouncers() status.Machines = machines status.Bouncers = bouncers return status, nil } // GetMachines returns all registered CrowdSec agent machines func (m *Manager) GetMachines() ([]Machine, error) { out, err := runCscli("machines", "list", "-o", "json") if err != nil { return nil, fmt.Errorf("cscli machines list: %w", err) } out = strings.TrimSpace(out) if out == "" || out == "null" { return []Machine{}, nil } // cscli outputs snake_case JSON — map to our camelCase API types var raw []struct { MachineID string `json:"machineId"` IsValidated bool `json:"isValidated"` LastPush string `json:"last_push"` LastHeartbeat string `json:"last_heartbeat"` IPAddress string `json:"ipAddress"` Version string `json:"version"` } if err := json.Unmarshal([]byte(out), &raw); err != nil { return nil, fmt.Errorf("parsing machines: %w", err) } machines := make([]Machine, 0, len(raw)) for _, r := range raw { machines = append(machines, Machine{ MachineID: r.MachineID, IsValidated: r.IsValidated, LastPush: r.LastPush, LastHeartbeat: r.LastHeartbeat, IPAddress: r.IPAddress, Version: r.Version, }) } return machines, nil } // AddMachine registers an agent machine with pre-generated credentials. // Uses delete-first pattern for idempotency. func (m *Manager) AddMachine(name, password string) error { _ = m.DeleteMachine(name) if _, err := runCscli("machines", "add", name, "--password", password); err != nil { return fmt.Errorf("cscli machines add: %w", err) } return nil } // DeleteMachine removes an agent machine func (m *Manager) DeleteMachine(name string) error { _, err := runCscli("machines", "delete", name) return err } // GetBouncers returns all registered bouncers func (m *Manager) GetBouncers() ([]Bouncer, error) { out, err := runCscli("bouncers", "list", "-o", "json") if err != nil { return nil, fmt.Errorf("cscli bouncers list: %w", err) } out = strings.TrimSpace(out) if out == "" || out == "null" { return []Bouncer{}, nil } var bouncers []Bouncer if err := json.Unmarshal([]byte(out), &bouncers); err != nil { return nil, fmt.Errorf("parsing bouncers: %w", err) } return bouncers, nil } // AddBouncer registers a bouncer with a pre-generated API key. // Uses delete-first pattern for idempotency. func (m *Manager) AddBouncer(name, apiKey string) error { _ = m.DeleteBouncer(name) if _, err := runCscli("bouncers", "add", name, "--key", apiKey); err != nil { return fmt.Errorf("cscli bouncers add: %w", err) } return nil } // DeleteBouncer removes a bouncer func (m *Manager) DeleteBouncer(name string) error { _, err := runCscli("bouncers", "delete", name) return err } // GetDecisions returns active local decisions (not CAPI community bans). // cscli decisions list without --all returns the alert-format JSON; each alert // has a decisions array. We flatten across all alerts. func (m *Manager) GetDecisions() ([]Decision, error) { out, err := runCscli("decisions", "list", "-o", "json") if err != nil { return nil, fmt.Errorf("cscli decisions list: %w", err) } out = strings.TrimSpace(out) if out == "" || out == "null" { return []Decision{}, nil } // Try alert-format (array of alerts each with decisions array) var alerts []struct { Decisions []Decision `json:"decisions"` } if err := json.Unmarshal([]byte(out), &alerts); err == nil && len(alerts) > 0 && alerts[0].Decisions != nil { var result []Decision for _, a := range alerts { result = append(result, a.Decisions...) } if result == nil { return []Decision{}, nil } return result, nil } // Fall back to flat array format var decisions []Decision if err := json.Unmarshal([]byte(out), &decisions); err != nil { return nil, fmt.Errorf("parsing decisions: %w", err) } return decisions, nil } // AddDecision adds a manual ban or allow decision for an IP. // decType is "ban" or "allow"; duration is a Go duration string (e.g. "24h", "168h"). func (m *Manager) AddDecision(ip, decType, reason, duration string) error { if _, err := runCscli("decisions", "add", "--ip", ip, "--type", decType, "--reason", reason, "--duration", duration); err != nil { return fmt.Errorf("cscli decisions add: %w", err) } return nil } // DeleteDecision removes a ban decision by numeric ID func (m *Manager) DeleteDecision(id int) error { if _, err := runCscli("decisions", "delete", "--id", fmt.Sprintf("%d", id)); err != nil { return fmt.Errorf("cscli decisions delete: %w", err) } return nil } // DeleteDecisionByIP removes all decisions for a specific IP address func (m *Manager) DeleteDecisionByIP(ip string) error { if _, err := runCscli("decisions", "delete", "--ip", ip); err != nil { return fmt.Errorf("cscli decisions delete --ip: %w", err) } return nil } // GetBanSummary returns aggregated ban counts by scenario across all active decisions. // It uses cscli decisions list --all which includes CAPI community bans. // The output is alert-format: each alert has a decisions array with a reason field. func (m *Manager) GetBanSummary() (*BanSummary, error) { out, err := runCscli("decisions", "list", "--all", "-o", "json") if err != nil { return nil, fmt.Errorf("cscli decisions list --all: %w", err) } out = strings.TrimSpace(out) if out == "" || out == "null" { return &BanSummary{Total: 0, ByReason: map[string]int{}}, nil } var alerts []struct { Decisions []struct { Reason string `json:"scenario"` } `json:"decisions"` } if err := json.Unmarshal([]byte(out), &alerts); err != nil { return nil, fmt.Errorf("parsing ban summary: %w", err) } summary := &BanSummary{ByReason: map[string]int{}} for _, alert := range alerts { for _, d := range alert.Decisions { summary.Total++ summary.ByReason[d.Reason]++ } } return summary, nil } // GetAlerts returns recent detection events from registered agents. // These are alerts pushed by k8s CrowdSec agents to this LAPI. func (m *Manager) GetAlerts(limit int) ([]Alert, error) { out, err := runCscli("alerts", "list", "--limit", fmt.Sprintf("%d", limit), "-o", "json") if err != nil { return nil, fmt.Errorf("cscli alerts list: %w", err) } out = strings.TrimSpace(out) if out == "" || out == "null" { return []Alert{}, nil } var raw []struct { ID int `json:"id"` Scenario string `json:"scenario"` Message string `json:"message"` CreatedAt string `json:"created_at"` MachineID string `json:"machine_id"` Source struct { IP string `json:"ip"` Country string `json:"cn"` ASName string `json:"as_name"` } `json:"source"` } if err := json.Unmarshal([]byte(out), &raw); err != nil { return nil, fmt.Errorf("parsing alerts: %w", err) } alerts := make([]Alert, 0, len(raw)) for _, r := range raw { alerts = append(alerts, Alert{ ID: r.ID, Scenario: r.Scenario, Message: r.Message, CreatedAt: r.CreatedAt, MachineID: r.MachineID, SourceIP: r.Source.IP, Country: r.Source.Country, ASName: r.Source.ASName, }) } return alerts, nil } // runCscli executes a cscli command via sudo and returns its stdout func runCscli(args ...string) (string, error) { fullArgs := append([]string{"cscli"}, args...) cmd := exec.Command("sudo", fullArgs...) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr if err := cmd.Run(); err != nil { return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) } return stdout.String(), nil }