Wenn CrowdSec gestoppt war, hing ServiceStatus auf cscli decisions/alerts/ bouncers/machines (Socket des gestoppten Agents). Der Status-Endpoint antwortete nie → status=undefined im UI → Switch war disabled (rotes Schild). Fix: cscli-Datenabrufe (decisions, alerts, bouncers, machines) nur wenn AgentRunning=true. Version-Abruf via cscli version bleibt (schlägt schnell fehl wenn Agent gestoppt, kein Hang). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
433 lines
13 KiB
Go
433 lines
13 KiB
Go
// 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 summarises 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 <args...>` 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{
|
|
MachineID: r.MachineID,
|
|
CreatedAt: r.CreatedAt,
|
|
UpdatedAt: r.UpdatedAt,
|
|
LastPush: r.LastPush,
|
|
IsValidated: r.IsValidated,
|
|
Version: r.Version,
|
|
Status: r.Status,
|
|
}
|
|
}
|
|
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
|
|
}
|