// Package crowdsec wraps sudo /usr/bin/cscli calls for the edgeguard // management API. All list operations use -o json. Mutation operations // (add/delete) use the appropriate cscli sub-commands. // // edgeguard runs as a non-root system user; every cscli call goes // through sudo (allowed entries are in /etc/sudoers.d/edgeguard). package crowdsec import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "log/slog" "os" "os/exec" "strings" ) // ErrNotInstalled is returned when /usr/bin/cscli is not found. var ErrNotInstalled = errors.New("crowdsec not installed") // IsInstalled checks whether /usr/bin/cscli exists on this host. func IsInstalled() bool { _, err := os.Stat("/usr/bin/cscli") return err == nil } // ---------- Types ----------------------------------------------------------- // Decision represents a single IP decision (ban/captcha/etc.) in CrowdSec. type Decision struct { ID int64 `json:"id"` Origin string `json:"origin"` Type string `json:"type"` Scope string `json:"scope"` Value string `json:"value"` Duration string `json:"duration"` Reason string `json:"reason"` Country string `json:"country,omitempty"` AS string `json:"as,omitempty"` } // Alert represents a CrowdSec alert with associated decisions. type Alert struct { ID int64 `json:"id"` Scenario string `json:"scenario"` EventsCount int `json:"events_count"` Source AlertSource `json:"source"` StartAt string `json:"start_at"` StopAt string `json:"stop_at"` Decisions []Decision `json:"decisions,omitempty"` } // AlertSource holds the source IP/range info for an alert. type AlertSource struct { IP string `json:"ip"` Country string `json:"cn,omitempty"` ASName string `json:"as_name,omitempty"` Range string `json:"range,omitempty"` Scope string `json:"scope,omitempty"` Value string `json:"value,omitempty"` } // Bouncer represents a registered CrowdSec bouncer. type Bouncer struct { Name string `json:"name"` IPAddress string `json:"ip_address,omitempty"` Revoked bool `json:"revoked"` LastPull string `json:"last_pull,omitempty"` Type string `json:"type,omitempty"` Version string `json:"version,omitempty"` CreatedAt string `json:"created_at"` AuthType string `json:"auth_type,omitempty"` } // Machine represents a registered CrowdSec agent/machine. type Machine struct { MachineID string `json:"machineId"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` LastPush string `json:"last_push,omitempty"` IsValidated bool `json:"isValidated"` Version string `json:"version,omitempty"` Status string `json:"status,omitempty"` } // HubItem represents a CrowdSec hub item (collection, parser, scenario, etc.). type HubItem struct { Name string `json:"name"` Description string `json:"description,omitempty"` Status string `json:"status"` LocalVersion string `json:"local_version,omitempty"` LocalPath string `json:"local_path,omitempty"` Author string `json:"author,omitempty"` Type string `json:"type,omitempty"` } // Status summarizes the runtime state of the CrowdSec stack on this node. type Status struct { Installed bool `json:"installed"` AgentRunning bool `json:"agent_running"` BouncerRunning bool `json:"bouncer_running"` Version string `json:"version,omitempty"` DecisionCount int `json:"decision_count"` AlertCount int `json:"alert_count"` BouncerCount int `json:"bouncer_count"` MachineCount int `json:"machine_count"` } // ---------- Helpers --------------------------------------------------------- // sudoCscli executes `sudo -n /usr/bin/cscli ` and returns stdout. func sudoCscli(ctx context.Context, args ...string) ([]byte, error) { full := append([]string{"-n", "/usr/bin/cscli"}, args...) cmd := exec.CommandContext(ctx, "sudo", full...) var out, errBuf bytes.Buffer cmd.Stdout = &out cmd.Stderr = &errBuf if err := cmd.Run(); err != nil { slog.Error("crowdsec: sudoCscli failed", "args", args, "error", err, "stderr", errBuf.String()) return nil, err } if errBuf.Len() > 0 { slog.Warn("crowdsec: sudoCscli stderr", "args", args, "stderr", errBuf.String()) } slog.Debug("crowdsec: sudoCscli ok", "args", args[0], "bytes", out.Len()) return out.Bytes(), nil } // systemctlActive returns true when the named unit is "active". func systemctlActive(ctx context.Context, unit string) bool { cmd := exec.CommandContext(ctx, "systemctl", "is-active", "--quiet", unit) return cmd.Run() == nil } // unmarshalSlice unmarshals JSON that may be "null" (cscli returns null // instead of [] when no items exist). Returns an empty slice in that case. func unmarshalSlice[T any](data []byte) ([]T, error) { data = bytes.TrimSpace(data) if bytes.Equal(data, []byte("null")) || len(data) == 0 { return []T{}, nil } var result []T if err := json.Unmarshal(data, &result); err != nil { return nil, err } return result, nil } // ---------- ServiceStatus --------------------------------------------------- // ServiceStatus returns a Status struct describing the current state of the // CrowdSec agent and bouncer on this node. Does NOT need cscli installed — // it uses systemctl for the running-state checks. Version is extracted via // `cscli version` when available. func ServiceStatus(ctx context.Context) Status { st := Status{ Installed: IsInstalled(), AgentRunning: systemctlActive(ctx, "crowdsec"), BouncerRunning: systemctlActive(ctx, "crowdsec-firewall-bouncer"), } if st.Installed { // Grab version from `sudo -n /usr/bin/cscli version` — first line only. // Output is not JSON; it looks like "version: v1.6.3-..." if out, err := sudoCscli(ctx, "version"); err == nil { scanner := bufio.NewScanner(bytes.NewReader(out)) if scanner.Scan() { st.Version = strings.TrimSpace(scanner.Text()) } } } // Only query cscli data endpoints when the agent is running — cscli // hangs on its local socket when the agent is stopped, which would // block the entire status response and leave the UI with no data. if st.AgentRunning { if decisions, err := Decisions(ctx); err == nil { st.DecisionCount = len(decisions) } if alerts, err := Alerts(ctx, 500); err == nil { st.AlertCount = len(alerts) } if bouncers, err := Bouncers(ctx); err == nil { st.BouncerCount = len(bouncers) } if machines, err := Machines(ctx); err == nil { st.MachineCount = len(machines) } } return st } // ---------- Decisions ------------------------------------------------------- // cscli decisions list -o json returns alert-level objects with nested // decisions[] arrays. These intermediate types are used only for parsing. type cscliDecisionRaw struct { ID int64 `json:"id"` Duration string `json:"duration"` Origin string `json:"origin"` Scope string `json:"scope"` Type string `json:"type"` Value string `json:"value"` } type cscliAlertRaw struct { Scenario string `json:"scenario"` Decisions []cscliDecisionRaw `json:"decisions"` Source struct { IP string `json:"ip"` CN string `json:"cn"` ASName string `json:"as_name"` } `json:"source"` } // Decisions lists all active decisions by flattening the alert-level JSON // that cscli emits (each alert contains a nested decisions[] array). func Decisions(ctx context.Context) ([]Decision, error) { if !IsInstalled() { return nil, ErrNotInstalled } out, err := sudoCscli(ctx, "decisions", "list", "-o", "json") if err != nil { return nil, err } alerts, err := unmarshalSlice[cscliAlertRaw](out) if err != nil { return nil, err } var result []Decision for _, a := range alerts { for _, d := range a.Decisions { result = append(result, Decision{ ID: d.ID, Origin: d.Origin, Type: d.Type, Scope: d.Scope, Value: d.Value, Duration: d.Duration, Reason: a.Scenario, Country: a.Source.CN, AS: a.Source.ASName, }) } } if result == nil { result = []Decision{} } return result, nil } // AddDecision creates a new ban/captcha decision for the given IP. func AddDecision(ctx context.Context, ip, duration, reason, typ string) error { if !IsInstalled() { return ErrNotInstalled } _, err := sudoCscli(ctx, "decisions", "add", "--ip", ip, "--duration", duration, "--reason", reason, "--type", typ, ) return err } // DeleteDecisionByIP removes all decisions for a given IP address. func DeleteDecisionByIP(ctx context.Context, ip string) error { if !IsInstalled() { return ErrNotInstalled } _, err := sudoCscli(ctx, "decisions", "delete", "--ip", ip) return err } // DeleteDecisionByID removes a single decision by its numeric ID. func DeleteDecisionByID(ctx context.Context, id string) error { if !IsInstalled() { return ErrNotInstalled } _, err := sudoCscli(ctx, "decisions", "delete", "--id", id) return err } // ---------- Alerts ---------------------------------------------------------- // Alerts lists recent alerts (up to limit). func Alerts(ctx context.Context, limit int) ([]Alert, error) { if !IsInstalled() { return nil, ErrNotInstalled } out, err := sudoCscli(ctx, "alerts", "list", "-o", "json", "-l", fmt.Sprintf("%d", limit)) if err != nil { return nil, err } return unmarshalSlice[Alert](out) } // DeleteAlert discards (deletes) a single alert by its ID. func DeleteAlert(ctx context.Context, id string) error { if !IsInstalled() { return ErrNotInstalled } _, err := sudoCscli(ctx, "alerts", "delete", "--id", id) return err } // ---------- Bouncers -------------------------------------------------------- // Bouncers lists all registered bouncers. func Bouncers(ctx context.Context) ([]Bouncer, error) { if !IsInstalled() { return nil, ErrNotInstalled } out, err := sudoCscli(ctx, "bouncers", "list", "-o", "json") if err != nil { return nil, err } return unmarshalSlice[Bouncer](out) } // DeleteBouncer removes a bouncer by name. func DeleteBouncer(ctx context.Context, name string) error { if !IsInstalled() { return ErrNotInstalled } _, err := sudoCscli(ctx, "bouncers", "delete", name) return err } // ---------- Machines -------------------------------------------------------- // cscliMachineRaw mirrors the actual cscli JSON with its mixed camelCase / // snake_case field names. Only used inside Machines(). type cscliMachineRaw struct { MachineID string `json:"machineId"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` LastPush string `json:"last_push"` IsValidated bool `json:"isValidated"` Version string `json:"version"` Status string `json:"status"` } // Machines lists all registered machines/agents. func Machines(ctx context.Context) ([]Machine, error) { if !IsInstalled() { return nil, ErrNotInstalled } out, err := sudoCscli(ctx, "machines", "list", "-o", "json") if err != nil { return nil, err } raw, err := unmarshalSlice[cscliMachineRaw](out) if err != nil { return nil, err } result := make([]Machine, len(raw)) for i, r := range raw { result[i] = Machine(r) } return result, nil } // DeleteMachine removes a machine by its machine ID. func DeleteMachine(ctx context.Context, id string) error { if !IsInstalled() { return ErrNotInstalled } _, err := sudoCscli(ctx, "machines", "delete", "--machine-id", id) return err } // ---------- Collections ----------------------------------------------------- // Collections lists installed/available hub collections. // cscli returns {"collections": [...]} (not a flat array) — we unwrap the key. func Collections(ctx context.Context) ([]HubItem, error) { if !IsInstalled() { return nil, ErrNotInstalled } out, err := sudoCscli(ctx, "collections", "list", "-o", "json") if err != nil { return nil, err } out = bytes.TrimSpace(out) if bytes.Equal(out, []byte("null")) || len(out) == 0 { return []HubItem{}, nil } // cscli wraps collections in {"collections": [...]} var wrapper struct { Collections []HubItem `json:"collections"` } if err := json.Unmarshal(out, &wrapper); err != nil { return nil, err } if wrapper.Collections == nil { return []HubItem{}, nil } return wrapper.Collections, nil } // InstallCollection installs a hub collection by name (--force to upgrade). func InstallCollection(ctx context.Context, name string) error { if !IsInstalled() { return ErrNotInstalled } _, err := sudoCscli(ctx, "collections", "install", name, "--force") return err } // RemoveCollection removes a hub collection by name. func RemoveCollection(ctx context.Context, name string) error { if !IsInstalled() { return ErrNotInstalled } _, err := sudoCscli(ctx, "collections", "remove", name) return err }