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)
235 lines
8.2 KiB
Go
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)})
|
|
}
|