Files
wild-central/internal/api/v1/handlers_crowdsec.go
Paul Payne 428d47f876 Refactor architecture: extract reconciler, add interfaces, reduce complexity, improve test coverage
Architecture:
- Extract reconcileNetworking into internal/reconcile package with 7 consumer-side interfaces
- Add locked modifyState helper to fix state.yaml read-modify-write race condition
- Extract CrowdSec and VPN handler groups with interfaces documenting dependency surface
- Replace raw map[string]any YAML manipulation with typed AddDHCPStaticLease/RemoveDHCPStaticLease
- Extract Cloudflare API functions into cfClient struct, eliminating repeated auth boilerplate

Complexity reduction:
- haproxy.GenerateWithOpts: 50 → 6 (extracted 8 focused helpers)
- reconcile.Reconcile: 42 → 11 (extracted buildRoutes, buildDNSEntries, writeHAProxyConfig)
- AutheliaUpdateConfig: 25 → 10 (extracted enableAuthelia/disableAuthelia)

Test coverage improvements:
- reconcile: 4.5% → 56.8% (stub-based tests for route building, DNS entries, orchestration)
- dnsfilter: 10.7% → 45.6% (Manager.Compile, AddList, ToggleList, custom entries)
- config: 67.3% → 89.8% (DHCP static lease mutation tests)
2026-07-14 04:21:30 +00:00

235 lines
8.2 KiB
Go

package v1
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/internal/crowdsec"
)
// CrowdSecManager is the interface for CrowdSec operations used by these handlers.
type CrowdSecManager interface {
GetStatus() (*crowdsec.Status, error)
GetBanSummary() (*crowdsec.BanSummary, error)
GetDecisions() ([]crowdsec.Decision, error)
AddDecision(ip, decType, reason, duration string) error
DeleteDecision(id int) error
DeleteDecisionByIP(ip string) error
GetAlerts(limit int) ([]crowdsec.Alert, error)
GetMachines() ([]crowdsec.Machine, error)
AddMachine(name, password string) error
DeleteMachine(name string) error
GetBouncers() ([]crowdsec.Bouncer, error)
AddBouncer(name, apiKey string) error
DeleteBouncer(name string) error
}
// crowdsecHandlers groups CrowdSec HTTP handlers with their single dependency.
type crowdsecHandlers struct {
crowdsec CrowdSecManager
}
// CrowdSecStatus returns whether CrowdSec is running on Wild Central
// and lists registered machines and bouncers.
func (h *crowdsecHandlers) Status(w http.ResponseWriter, r *http.Request) {
status, err := h.crowdsec.GetStatus()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CrowdSec status: %v", err))
return
}
respondJSON(w, http.StatusOK, status)
}
// GetSummary returns aggregated ban counts by scenario across all active decisions.
func (h *crowdsecHandlers) GetSummary(w http.ResponseWriter, r *http.Request) {
summary, err := h.crowdsec.GetBanSummary()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get ban summary: %v", err))
return
}
respondJSON(w, http.StatusOK, summary)
}
// GetDecisions returns active ban/captcha decisions
func (h *crowdsecHandlers) GetDecisions(w http.ResponseWriter, r *http.Request) {
decisions, err := h.crowdsec.GetDecisions()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get decisions: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"decisions": decisions})
}
// DeleteDecision removes a ban decision by numeric ID
func (h *crowdsecHandlers) DeleteDecision(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
respondError(w, http.StatusBadRequest, "invalid decision ID")
return
}
if err := h.crowdsec.DeleteDecision(id); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete decision: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Decision deleted"})
}
// AddDecision adds a manual ban or allow decision for an IP.
func (h *crowdsecHandlers) AddDecision(w http.ResponseWriter, r *http.Request) {
var req struct {
IP string `json:"ip"`
Type string `json:"type"`
Reason string `json:"reason"`
Duration string `json:"duration"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.IP == "" {
respondError(w, http.StatusBadRequest, "ip is required")
return
}
if req.Type != "ban" && req.Type != "allow" {
respondError(w, http.StatusBadRequest, "type must be 'ban' or 'allow'")
return
}
if req.Reason == "" {
req.Reason = "manual"
}
if req.Duration == "" {
req.Duration = "24h"
}
if err := h.crowdsec.AddDecision(req.IP, req.Type, req.Reason, req.Duration); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add decision: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": fmt.Sprintf("Decision added: %s %s for %s", req.Type, req.IP, req.Duration)})
}
// DeleteDecisionByIP removes all decisions for a specific IP address
func (h *crowdsecHandlers) DeleteDecisionByIP(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
ip := vars["ip"]
if ip == "" {
respondError(w, http.StatusBadRequest, "ip is required")
return
}
if err := h.crowdsec.DeleteDecisionByIP(ip); err != nil {
// cscli exits non-zero when there's nothing to delete — treat as success
if strings.Contains(err.Error(), "no decision") || strings.Contains(err.Error(), "0 decision") {
respondJSON(w, http.StatusOK, map[string]string{"message": "No active decision for " + ip})
return
}
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete decision: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Decisions removed for " + ip})
}
// GetAlerts returns recent detection events from registered agents.
func (h *crowdsecHandlers) GetAlerts(w http.ResponseWriter, r *http.Request) {
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 {
limit = n
}
}
alerts, err := h.crowdsec.GetAlerts(limit)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get alerts: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"alerts": alerts})
}
// GetMachines returns all registered CrowdSec agent machines
func (h *crowdsecHandlers) GetMachines(w http.ResponseWriter, r *http.Request) {
machines, err := h.crowdsec.GetMachines()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get machines: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"machines": machines})
}
// DeleteMachine removes a registered machine
func (h *crowdsecHandlers) DeleteMachine(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
if err := h.crowdsec.DeleteMachine(name); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete machine: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Machine deleted"})
}
// GetBouncers returns all registered bouncers
func (h *crowdsecHandlers) GetBouncers(w http.ResponseWriter, r *http.Request) {
bouncers, err := h.crowdsec.GetBouncers()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get bouncers: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"bouncers": bouncers})
}
// DeleteBouncer removes a registered bouncer
func (h *crowdsecHandlers) DeleteBouncer(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
if err := h.crowdsec.DeleteBouncer(name); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete bouncer: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Bouncer deleted"})
}
// AddMachine registers a new CrowdSec agent machine with pre-generated credentials.
func (h *crowdsecHandlers) AddMachine(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.Password == "" {
respondError(w, http.StatusBadRequest, "name and password are required")
return
}
if err := h.crowdsec.AddMachine(req.Name, req.Password); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add machine: %v", err))
return
}
respondJSON(w, http.StatusCreated, map[string]string{"message": fmt.Sprintf("Machine %s registered", req.Name)})
}
// AddBouncer registers a new bouncer with a pre-generated API key.
func (h *crowdsecHandlers) AddBouncer(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
APIKey string `json:"apiKey"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.Name == "" || req.APIKey == "" {
respondError(w, http.StatusBadRequest, "name and apiKey are required")
return
}
if err := h.crowdsec.AddBouncer(req.Name, req.APIKey); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add bouncer: %v", err))
return
}
respondJSON(w, http.StatusCreated, map[string]string{"message": fmt.Sprintf("Bouncer %s registered", req.Name)})
}