Files
edgeguard-native/internal/crowdsec/service.go
Debian 02096c8ad8 chore(lint): golangci-lint --fix — misspell + staticcheck-Autofixes (Backlog 142→~98)
Erster Schritt des golangci-lint-Rollouts (non-blocking): 44 misspell + 4
staticcheck automatisch behoben (32 Dateien, nur Tippfehler/mechanisch).
build+test grün. Kein Runtime-Change → kein Deploy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 23:25:22 +02:00

425 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 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 <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(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
}