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)
This commit is contained in:
2026-07-14 04:21:30 +00:00
parent 1e7d93256e
commit 428d47f876
35 changed files with 1856 additions and 1194 deletions

View File

@@ -8,12 +8,36 @@ import (
"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 (api *API) CrowdSecStatus(w http.ResponseWriter, r *http.Request) {
status, err := api.crowdsec.GetStatus()
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
@@ -21,10 +45,9 @@ func (api *API) CrowdSecStatus(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, status)
}
// CrowdSecGetSummary returns aggregated ban counts by scenario across all active decisions.
// This includes CAPI community bans — can return tens of thousands of entries.
func (api *API) CrowdSecGetSummary(w http.ResponseWriter, r *http.Request) {
summary, err := api.crowdsec.GetBanSummary()
// 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
@@ -32,9 +55,9 @@ func (api *API) CrowdSecGetSummary(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, summary)
}
// CrowdSecGetDecisions returns active ban/captcha decisions
func (api *API) CrowdSecGetDecisions(w http.ResponseWriter, r *http.Request) {
decisions, err := api.crowdsec.GetDecisions()
// 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
@@ -42,24 +65,23 @@ func (api *API) CrowdSecGetDecisions(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"decisions": decisions})
}
// CrowdSecDeleteDecision removes a ban decision by numeric ID
func (api *API) CrowdSecDeleteDecision(w http.ResponseWriter, r *http.Request) {
// 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 := api.crowdsec.DeleteDecision(id); err != nil {
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"})
}
// CrowdSecAddDecision adds a manual ban or allow decision for an IP.
// Body: { "ip": "1.2.3.4", "type": "ban", "reason": "manual", "duration": "24h" }
func (api *API) CrowdSecAddDecision(w http.ResponseWriter, r *http.Request) {
// 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"`
@@ -84,22 +106,22 @@ func (api *API) CrowdSecAddDecision(w http.ResponseWriter, r *http.Request) {
if req.Duration == "" {
req.Duration = "24h"
}
if err := api.crowdsec.AddDecision(req.IP, req.Type, req.Reason, req.Duration); err != nil {
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)})
}
// CrowdSecDeleteDecisionByIP removes all decisions for a specific IP address
func (api *API) CrowdSecDeleteDecisionByIP(w http.ResponseWriter, r *http.Request) {
// 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 := api.crowdsec.DeleteDecisionByIP(ip); err != nil {
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})
@@ -111,16 +133,15 @@ func (api *API) CrowdSecDeleteDecisionByIP(w http.ResponseWriter, r *http.Reques
respondJSON(w, http.StatusOK, map[string]string{"message": "Decisions removed for " + ip})
}
// CrowdSecGetAlerts returns recent detection events from registered agents.
// Query param: limit (default 50)
func (api *API) CrowdSecGetAlerts(w http.ResponseWriter, r *http.Request) {
// 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 := api.crowdsec.GetAlerts(limit)
alerts, err := h.crowdsec.GetAlerts(limit)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get alerts: %v", err))
return
@@ -128,9 +149,9 @@ func (api *API) CrowdSecGetAlerts(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"alerts": alerts})
}
// CrowdSecGetMachines returns all registered CrowdSec agent machines
func (api *API) CrowdSecGetMachines(w http.ResponseWriter, r *http.Request) {
machines, err := api.crowdsec.GetMachines()
// 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
@@ -138,20 +159,20 @@ func (api *API) CrowdSecGetMachines(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"machines": machines})
}
// CrowdSecDeleteMachine removes a registered machine
func (api *API) CrowdSecDeleteMachine(w http.ResponseWriter, r *http.Request) {
// DeleteMachine removes a registered machine
func (h *crowdsecHandlers) DeleteMachine(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
if err := api.crowdsec.DeleteMachine(name); err != nil {
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"})
}
// CrowdSecGetBouncers returns all registered bouncers
func (api *API) CrowdSecGetBouncers(w http.ResponseWriter, r *http.Request) {
bouncers, err := api.crowdsec.GetBouncers()
// 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
@@ -159,20 +180,19 @@ func (api *API) CrowdSecGetBouncers(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"bouncers": bouncers})
}
// CrowdSecDeleteBouncer removes a registered bouncer
func (api *API) CrowdSecDeleteBouncer(w http.ResponseWriter, r *http.Request) {
// DeleteBouncer removes a registered bouncer
func (h *crowdsecHandlers) DeleteBouncer(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
if err := api.crowdsec.DeleteBouncer(name); err != nil {
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"})
}
// CrowdSecAddMachine registers a new CrowdSec agent machine with pre-generated credentials.
// Body: { "name": "...", "password": "..." }
func (api *API) CrowdSecAddMachine(w http.ResponseWriter, r *http.Request) {
// 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"`
@@ -185,16 +205,15 @@ func (api *API) CrowdSecAddMachine(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "name and password are required")
return
}
if err := api.crowdsec.AddMachine(req.Name, req.Password); err != nil {
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)})
}
// CrowdSecAddBouncer registers a new bouncer with a pre-generated API key.
// Body: { "name": "...", "apiKey": "..." }
func (api *API) CrowdSecAddBouncer(w http.ResponseWriter, r *http.Request) {
// 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"`
@@ -207,7 +226,7 @@ func (api *API) CrowdSecAddBouncer(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "name and apiKey are required")
return
}
if err := api.crowdsec.AddBouncer(req.Name, req.APIKey); err != nil {
if err := h.crowdsec.AddBouncer(req.Name, req.APIKey); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add bouncer: %v", err))
return
}