Extract the Central networking functionality from wild-cloud/api into a standalone service. Wild Central manages DNS (dnsmasq), gateway (HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot), security (CrowdSec), and DDNS — all the network appliance concerns. All Cloud-specific code (instances, clusters, nodes, apps, backups, operations, kubectl/talosctl tooling) has been removed. The API struct and route registration contain only Central endpoints. Tests updated to match the new API signature. Builds and all tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
312 lines
9.3 KiB
Go
312 lines
9.3 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:"last_push,omitempty"`
|
|
LastHeartbeat string `json:"last_heartbeat,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
|
|
}
|
|
var machines []Machine
|
|
if err := json.Unmarshal([]byte(out), &machines); err != nil {
|
|
return nil, fmt.Errorf("parsing machines: %w", err)
|
|
}
|
|
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", "-i", 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
|
|
}
|