Files
Paul Payne 3172e56288 Standardize codebase consistency: naming, JSON tags, logging, error wrapping
- JSON tags: fix snake_case to camelCase in dnsmasq (configFile, domainsConfigured,
  lastRestart), crowdsec Machine (lastPush, lastHeartbeat), network (primaryIP,
  primaryInterface). cscli raw parsing structs keep snake_case to match CLI output.
- Error wrapping: fix %v to %w in enableAuthelia for proper error chain preservation
- Naming: rename dnsmasq.ConfigGenerator to dnsmasq.Manager (matches all other packages),
  rename ServiceStatus to Status in dnsmasq and haproxy (matches authelia, crowdsec, etc.)
- Logging: standardize all slog calls to use "component" key instead of message prefixes.
  Affects reconcile, dnsfilter, ddns — now consistent with dnsmasq, haproxy, nftables, sse.
2026-07-14 04:38:48 +00:00

331 lines
9.9 KiB
Go

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
}