feat: Extract Wild Central as standalone Go service
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>
This commit is contained in:
311
internal/crowdsec/manager.go
Normal file
311
internal/crowdsec/manager.go
Normal file
@@ -0,0 +1,311 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user