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

@@ -24,35 +24,37 @@ import (
"github.com/wild-cloud/wild-central/internal/ddns"
"github.com/wild-cloud/wild-central/internal/dnsfilter"
"github.com/wild-cloud/wild-central/internal/dnsmasq"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/nftables"
"github.com/wild-cloud/wild-central/internal/reconcile"
"github.com/wild-cloud/wild-central/internal/secrets"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/sse"
"github.com/wild-cloud/wild-central/internal/wireguard"
)
// API holds all dependencies for Central API handlers
type API struct {
dataDir string
version string
allowedOrigins []string
ctx gocontext.Context // parent context for restartable goroutines
secrets *secrets.Manager
dnsmasq *dnsmasq.ConfigGenerator
haproxy *haproxy.Manager
nftables *nftables.Manager
ddns *ddns.Runner
crowdsec *crowdsec.Manager
vpn *wireguard.Manager
certbot *certbot.Manager
authelia *authelia.Manager // Authelia authentication service manager
dnsFilter *dnsfilter.Manager // DNS filtering manager
dnsFilterRunner *dnsfilter.Runner // DNS filter background update runner
domains *domains.Manager // Domain registration manager
sseManager *sse.Manager // SSE manager for real-time events
port int // Running API port (for config-driven routes)
dataDir string
version string
allowedOrigins []string
ctx gocontext.Context // parent context for restartable goroutines
reconciler *reconcile.Reconciler
secrets *secrets.Manager
dnsmasq *dnsmasq.ConfigGenerator
haproxy *haproxy.Manager
nftables *nftables.Manager
ddns *ddns.Runner
crowdsec *crowdsec.Manager
vpn *wireguard.Manager
certbot *certbot.Manager
authelia *authelia.Manager // Authelia authentication service manager
dnsFilter *dnsfilter.Manager // DNS filtering manager
dnsFilterRunner *dnsfilter.Runner // DNS filter background update runner
domains *domains.Manager // Domain registration manager
sseManager *sse.Manager // SSE manager for real-time events
port int // Running API port (for config-driven routes)
}
// NewAPI creates a new Central API handler with all dependencies
@@ -77,8 +79,8 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
crowdsec: crowdsec.NewManager(),
vpn: wireguard.NewManager(dataDir, vpnConfigPath),
certbot: certbot.NewManager(""),
authelia: authelia.NewManager(filepath.Join(dataDir, "authelia")),
dnsFilter: dnsfilter.NewManager(filepath.Join(dataDir, "dns-filter")),
authelia: authelia.NewManager(filepath.Join(dataDir, "authelia")),
dnsFilter: dnsfilter.NewManager(filepath.Join(dataDir, "dns-filter")),
domains: domains.NewManager(dataDir),
sseManager: sseManager,
}
@@ -89,9 +91,22 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
filterDir := envOrDefault("WILD_CENTRAL_FILTER_DIR", "/var/lib/wild-central/dns-filter")
api.dnsFilter.SetHostsFilePath(filepath.Join(filterDir, "blocked.conf"))
// Build the reconciler — the core domain→config→daemon pipeline
api.reconciler = &reconcile.Reconciler{
Domains: api.domains,
HAProxy: api.haproxy,
DNS: api.dnsmasq,
Auth: api.authelia,
DDNS: api.ddns,
DNSFilter: api.dnsFilter,
SSE: sseManager,
StatePath: filepath.Join(dataDir, "state.yaml"),
GenerateAutheliaConfig: api.generateAutheliaConfig,
}
// Wire up domain registration reconciliation: when domains change,
// regenerate dnsmasq DNS entries and HAProxy routes.
api.domains.SetReconcileFn(api.reconcileNetworking)
api.domains.SetReconcileFn(api.reconciler.Reconcile)
return api, nil
}
@@ -259,15 +274,16 @@ func (api *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/ddns/trigger", api.DDNSTrigger).Methods("POST")
// WireGuard VPN management
r.HandleFunc("/api/v1/vpn/status", api.VpnStatus).Methods("GET")
r.HandleFunc("/api/v1/vpn/config", api.VpnGetConfig).Methods("GET")
r.HandleFunc("/api/v1/vpn/config", api.VpnUpdateConfig).Methods("PUT")
r.HandleFunc("/api/v1/vpn/keygen", api.VpnGenerateKeypair).Methods("POST")
r.HandleFunc("/api/v1/vpn/apply", api.VpnApply).Methods("POST")
r.HandleFunc("/api/v1/vpn/peers", api.VpnListPeers).Methods("GET")
r.HandleFunc("/api/v1/vpn/peers", api.VpnAddPeer).Methods("POST")
r.HandleFunc("/api/v1/vpn/peers/{id}/config", api.VpnGetPeerConfig).Methods("GET")
r.HandleFunc("/api/v1/vpn/peers/{id}", api.VpnDeletePeer).Methods("DELETE")
vpn := &vpnHandlers{vpn: api.vpn, statePath: api.statePath(), syncNftables: api.syncNftablesOnly}
r.HandleFunc("/api/v1/vpn/status", vpn.Status).Methods("GET")
r.HandleFunc("/api/v1/vpn/config", vpn.GetConfig).Methods("GET")
r.HandleFunc("/api/v1/vpn/config", vpn.UpdateConfig).Methods("PUT")
r.HandleFunc("/api/v1/vpn/keygen", vpn.GenerateKeypair).Methods("POST")
r.HandleFunc("/api/v1/vpn/apply", vpn.Apply).Methods("POST")
r.HandleFunc("/api/v1/vpn/peers", vpn.ListPeers).Methods("GET")
r.HandleFunc("/api/v1/vpn/peers", vpn.AddPeer).Methods("POST")
r.HandleFunc("/api/v1/vpn/peers/{id}/config", vpn.GetPeerConfig).Methods("GET")
r.HandleFunc("/api/v1/vpn/peers/{id}", vpn.DeletePeer).Methods("DELETE")
// TLS certificate management
r.HandleFunc("/api/v1/cert/status", api.CertStatus).Methods("GET")
@@ -275,19 +291,20 @@ func (api *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/cert/renew", api.CertRenew).Methods("POST")
// CrowdSec LAPI management
r.HandleFunc("/api/v1/crowdsec/status", api.CrowdSecStatus).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/summary", api.CrowdSecGetSummary).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/alerts", api.CrowdSecGetAlerts).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecGetDecisions).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecAddDecision).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/decisions/{id}", api.CrowdSecDeleteDecision).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", api.CrowdSecDeleteDecisionByIP).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecGetMachines).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecAddMachine).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/machines/{name}", api.CrowdSecDeleteMachine).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecGetBouncers).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecAddBouncer).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", api.CrowdSecDeleteBouncer).Methods("DELETE")
csec := &crowdsecHandlers{crowdsec: api.crowdsec}
r.HandleFunc("/api/v1/crowdsec/status", csec.Status).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/summary", csec.GetSummary).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/alerts", csec.GetAlerts).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/decisions", csec.GetDecisions).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/decisions", csec.AddDecision).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/decisions/{id}", csec.DeleteDecision).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", csec.DeleteDecisionByIP).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/machines", csec.GetMachines).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/machines", csec.AddMachine).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/machines/{name}", csec.DeleteMachine).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/bouncers", csec.GetBouncers).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/bouncers", csec.AddBouncer).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", csec.DeleteBouncer).Methods("DELETE")
// Cloudflare management
r.HandleFunc("/api/v1/cloudflare/verify", api.CloudflareVerify).Methods("GET")
@@ -399,8 +416,8 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime
// getDaemonStatus checks whether each managed daemon is active and gets its version.
func getDaemonStatus() map[string]map[string]any {
type daemonInfo struct {
unit string
verCmd []string // command to get version
unit string
verCmd []string // command to get version
verParse func(string) string // extract version from output
}

View File

@@ -114,91 +114,73 @@ func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) {
_ = api.secrets.SetSecret("authelia.smtpPassword", *req.SMTPPassword)
}
// Enabling Authelia
if state.Cloud.Authelia.Enabled {
if !api.authelia.IsInstalled() {
respondError(w, http.StatusBadRequest, "Authelia is not installed. Install it first (apt install authelia).")
return
}
if state.Cloud.Authelia.Domain == "" {
respondError(w, http.StatusBadRequest, "Auth portal domain is required when enabling Authelia")
return
}
if !api.authelia.HasLuaScript() {
respondError(w, http.StatusBadRequest,
"HAProxy auth-request Lua script not found at /etc/haproxy/lua/haproxy-auth-request.lua. "+
"Install it: sudo mkdir -p /etc/haproxy/lua && sudo curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua "+
"https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua")
return
}
// Ensure data directory exists
if err := api.authelia.EnsureDataDir(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to create data directory: %v", err))
return
}
// Auto-generate secrets if missing
if err := api.ensureAutheliaSecrets(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate secrets: %v", err))
return
}
// Generate JWKS key pair for OIDC
if err := api.authelia.EnsureJWKS(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate JWKS: %v", err))
return
}
// Ensure user database exists
if err := api.authelia.EnsureUsersDB(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to initialize user database: %v", err))
return
}
// Generate config
if err := api.generateAutheliaConfig(state); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate config: %v", err))
return
}
// Register auth portal domain
api.ensureAuthDomain(state.Cloud.Authelia.Domain)
// Start service — requires at least one user
startErr := error(nil)
if api.authelia.UserCount() > 0 {
startErr = api.authelia.RestartService()
}
// Save state and reconcile regardless of whether the service started
if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
go api.reconcileNetworking()
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
if startErr != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Configuration saved but Authelia failed to start: %v", startErr))
if err := api.enableAuthelia(state); err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
} else {
// Disabling — stop service and deregister domain
_ = api.authelia.StopService()
api.deregisterAuthDomain()
if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
go api.reconcileNetworking()
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
api.disableAuthelia(state)
}
if err := config.SaveState(state, api.statePath()); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
go api.reconciler.Reconcile()
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
respondMessage(w, http.StatusOK, "Authelia configuration updated")
}
// enableAuthelia validates prerequisites, sets up services, generates config,
// and starts Authelia. Returns a user-facing error message or nil.
func (api *API) enableAuthelia(state *config.State) error {
if !api.authelia.IsInstalled() {
return fmt.Errorf("Authelia is not installed. Install it first (apt install authelia).")
}
if state.Cloud.Authelia.Domain == "" {
return fmt.Errorf("Auth portal domain is required when enabling Authelia")
}
if !api.authelia.HasLuaScript() {
return fmt.Errorf(
"HAProxy auth-request Lua script not found at /etc/haproxy/lua/haproxy-auth-request.lua. " +
"Install it: sudo mkdir -p /etc/haproxy/lua && sudo curl -fsSL -o /etc/haproxy/lua/haproxy-auth-request.lua " +
"https://raw.githubusercontent.com/TimWolla/haproxy-auth-request/main/auth-request.lua")
}
if err := api.authelia.EnsureDataDir(); err != nil {
return fmt.Errorf("Failed to create data directory: %v", err)
}
if err := api.ensureAutheliaSecrets(); err != nil {
return fmt.Errorf("Failed to generate secrets: %v", err)
}
if err := api.authelia.EnsureJWKS(); err != nil {
return fmt.Errorf("Failed to generate JWKS: %v", err)
}
if err := api.authelia.EnsureUsersDB(); err != nil {
return fmt.Errorf("Failed to initialize user database: %v", err)
}
if err := api.generateAutheliaConfig(state); err != nil {
return fmt.Errorf("Failed to generate config: %v", err)
}
api.ensureAuthDomain(state.Cloud.Authelia.Domain)
if api.authelia.UserCount() > 0 {
if err := api.authelia.RestartService(); err != nil {
return fmt.Errorf("Configuration saved but Authelia failed to start: %v", err)
}
}
return nil
}
// disableAuthelia stops the service and removes auth domain registrations.
func (api *API) disableAuthelia(_ *config.State) {
_ = api.authelia.StopService()
api.deregisterAuthDomain()
}
// AutheliaRestart restarts the Authelia service
func (api *API) AutheliaRestart(w http.ResponseWriter, r *http.Request) {
if err := api.authelia.RestartService(); err != nil {

View File

@@ -148,7 +148,7 @@ func (api *API) CertProvision(w http.ResponseWriter, r *http.Request) {
}
// Trigger reconciliation so HAProxy picks up the new cert
go api.reconcileNetworking()
go api.reconciler.Reconcile()
status := api.certbot.GetStatus(domain)
respondJSON(w, http.StatusOK, map[string]any{
@@ -171,7 +171,7 @@ func (api *API) CertRenew(w http.ResponseWriter, r *http.Request) {
}
// Trigger reconciliation
go api.reconcileNetworking()
go api.reconciler.Reconcile()
respondJSON(w, http.StatusOK, map[string]string{"message": "Certificates renewed"})
}
@@ -184,4 +184,3 @@ func (api *API) getCentralDomain() string {
}
return globalCfg.Cloud.Central.Domain
}

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
}

View File

@@ -93,7 +93,7 @@ func (api *API) DNSFilterUpdateConfig(w http.ResponseWriter, r *http.Request) {
}
// Reconcile to add/remove addn-hosts from dnsmasq config
go api.reconcileNetworking()
go api.reconciler.Reconcile()
api.broadcastDNSFilterEvent("dns-filter:config", "DNS filter configuration updated")
respondMessage(w, http.StatusOK, "DNS filter configuration updated")

View File

@@ -11,10 +11,8 @@ import (
"time"
"github.com/gorilla/mux"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/storage"
)
// DnsmasqStatus returns the status of the dnsmasq service
@@ -259,8 +257,10 @@ func (api *API) DnsmasqDHCPAddStatic(w http.ResponseWriter, r *http.Request) {
return
}
globalConfigPath := api.statePath()
if err := api.addDHCPStaticLease(globalConfigPath, req); err != nil {
if err := api.modifyState(func(state *config.State) error {
state.AddDHCPStaticLease(req)
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add static lease: %v", err))
return
}
@@ -280,8 +280,10 @@ func (api *API) DnsmasqDHCPDeleteStatic(w http.ResponseWriter, r *http.Request)
return
}
globalConfigPath := api.statePath()
if err := api.removeDHCPStaticLease(globalConfigPath, mac); err != nil {
if err := api.modifyState(func(state *config.State) error {
state.RemoveDHCPStaticLease(mac)
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove static lease: %v", err))
return
}
@@ -292,113 +294,3 @@ func (api *API) DnsmasqDHCPDeleteStatic(w http.ResponseWriter, r *http.Request)
respondJSON(w, http.StatusOK, map[string]string{"message": "Static lease removed successfully"})
}
// addDHCPStaticLease adds or replaces a static lease entry in the global config file
func (api *API) addDHCPStaticLease(configPath string, lease config.DHCPStaticLease) error {
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("reading config: %w", err)
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parsing config: %w", err)
}
if cfg == nil {
cfg = make(map[string]any)
}
// Navigate/create the path cloud.dnsmasq.dhcp.staticLeases
cloud := getOrCreateMap(cfg, "cloud")
dnsmasqMap := getOrCreateMap(cloud, "dnsmasq")
dhcpMap := getOrCreateMap(dnsmasqMap, "dhcp")
leases, _ := dhcpMap["staticLeases"].([]any)
// Replace existing entry with same MAC, or append
newEntry := map[string]any{"mac": lease.MAC, "ip": lease.IP}
if lease.Hostname != "" {
newEntry["hostname"] = lease.Hostname
}
replaced := false
for i, l := range leases {
if m, ok := l.(map[string]any); ok {
if m["mac"] == lease.MAC {
leases[i] = newEntry
replaced = true
break
}
}
}
if !replaced {
leases = append(leases, newEntry)
}
dhcpMap["staticLeases"] = leases
out, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
lockPath := configPath + ".lock"
return storage.WithLock(lockPath, func() error {
return storage.WriteFile(configPath, out, 0644)
})
}
// removeDHCPStaticLease removes a static lease entry by MAC from the global config file
func (api *API) removeDHCPStaticLease(configPath string, mac string) error {
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("reading config: %w", err)
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parsing config: %w", err)
}
cloud, _ := cfg["cloud"].(map[string]any)
if cloud == nil {
return nil
}
dnsmasqMap, _ := cloud["dnsmasq"].(map[string]any)
if dnsmasqMap == nil {
return nil
}
dhcpMap, _ := dnsmasqMap["dhcp"].(map[string]any)
if dhcpMap == nil {
return nil
}
leases, _ := dhcpMap["staticLeases"].([]any)
var filtered []any
for _, l := range leases {
if m, ok := l.(map[string]any); ok {
if m["mac"] != mac {
filtered = append(filtered, l)
}
}
}
dhcpMap["staticLeases"] = filtered
out, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
lockPath := configPath + ".lock"
return storage.WithLock(lockPath, func() error {
return storage.WriteFile(configPath, out, 0644)
})
}
// getOrCreateMap returns the map at key in parent, creating it if absent
func getOrCreateMap(parent map[string]any, key string) map[string]any {
if v, ok := parent[key].(map[string]any); ok {
return v
}
m := make(map[string]any)
parent[key] = m
return m
}

View File

@@ -47,7 +47,7 @@ func (api *API) HaproxyRestart(w http.ResponseWriter, r *http.Request) {
// HaproxyGenerate regenerates HAProxy config from all WC instance data and custom routes,
// then updates nftables to match. Both operations happen together so ports stay in sync.
func (api *API) HaproxyGenerate(w http.ResponseWriter, r *http.Request) {
api.reconcileNetworking()
api.reconciler.Reconcile()
content, err := api.haproxy.ReadConfig()
if err != nil {
@@ -68,5 +68,3 @@ func (api *API) HaproxyStats(w http.ResponseWriter, r *http.Request) {
}
respondJSON(w, http.StatusOK, map[string]any{"backends": stats})
}

View File

@@ -13,6 +13,16 @@ import (
"github.com/wild-cloud/wild-central/internal/haproxy"
)
// extractHost gets the host part from a host:port string (test helper)
func extractHost(addr string) string {
for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' {
return addr[:i]
}
}
return addr
}
// TestReconciliation_HAProxyConfigFromDomains verifies that the reconciliation
// logic correctly maps registered domains to HAProxy configuration. This
// replicates the route-building logic from reconcileNetworking() and asserts

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/storage"
)
// loadState loads state from disk, returning an empty state if the file doesn't exist.
@@ -17,9 +18,21 @@ func (api *API) loadState() *config.State {
return state
}
// saveState writes state to disk and returns an error suitable for HTTP responses.
func (api *API) saveState(state *config.State) error {
return config.SaveState(state, api.statePath())
// modifyState atomically reads state, applies fn, and writes it back under a
// file lock. This prevents concurrent handlers from silently dropping each
// other's changes via overlapping read-modify-write cycles.
func (api *API) modifyState(fn func(state *config.State) error) error {
lockPath := api.statePath() + ".lock"
return storage.WithLock(lockPath, func() error {
state, err := config.LoadState(api.statePath())
if err != nil {
state = &config.State{}
}
if err := fn(state); err != nil {
return err
}
return config.SaveState(state, api.statePath())
})
}
// --- Operator ---
@@ -37,15 +50,18 @@ func (api *API) UpdateOperator(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
state.Operator.Email = updates.Email
if err := api.saveState(state); err != nil {
var result any
if err := api.modifyState(func(state *config.State) error {
state.Operator.Email = updates.Email
result = state.Operator
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
slog.Info("operator updated", "email", updates.Email)
respondJSON(w, http.StatusOK, state.Operator)
respondJSON(w, http.StatusOK, result)
}
// --- Central Domain ---
@@ -65,9 +81,10 @@ func (api *API) UpdateCentralDomain(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
state.Cloud.Central.Domain = updates.Domain
if err := api.saveState(state); err != nil {
if err := api.modifyState(func(state *config.State) error {
state.Cloud.Central.Domain = updates.Domain
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
@@ -95,25 +112,28 @@ func (api *API) UpdateDDNSConfig(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
if updates.Enabled != nil {
state.Cloud.DDNS.Enabled = *updates.Enabled
}
if updates.Provider != "" {
state.Cloud.DDNS.Provider = updates.Provider
}
if updates.IntervalMinutes != nil {
state.Cloud.DDNS.IntervalMinutes = *updates.IntervalMinutes
}
if err := api.saveState(state); err != nil {
var result any
if err := api.modifyState(func(state *config.State) error {
if updates.Enabled != nil {
state.Cloud.DDNS.Enabled = *updates.Enabled
}
if updates.Provider != "" {
state.Cloud.DDNS.Provider = updates.Provider
}
if updates.IntervalMinutes != nil {
state.Cloud.DDNS.IntervalMinutes = *updates.IntervalMinutes
}
result = state.Cloud.DDNS
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
go api.reloadDDNSIfEnabled()
slog.Info("DDNS config updated", "enabled", state.Cloud.DDNS.Enabled)
respondJSON(w, http.StatusOK, state.Cloud.DDNS)
slog.Info("DDNS config updated")
respondJSON(w, http.StatusOK, result)
}
// --- DNS Settings (dnsmasq IP/interface) ---
@@ -136,23 +156,26 @@ func (api *API) UpdateDNSSettings(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
if updates.IP != "" {
state.Cloud.Dnsmasq.IP = updates.IP
}
if updates.Interface != "" {
state.Cloud.Dnsmasq.Interface = updates.Interface
}
if err := api.saveState(state); err != nil {
var result map[string]string
if err := api.modifyState(func(state *config.State) error {
if updates.IP != "" {
state.Cloud.Dnsmasq.IP = updates.IP
}
if updates.Interface != "" {
state.Cloud.Dnsmasq.Interface = updates.Interface
}
result = map[string]string{
"ip": state.Cloud.Dnsmasq.IP,
"interface": state.Cloud.Dnsmasq.Interface,
}
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
slog.Info("DNS settings updated", "ip", state.Cloud.Dnsmasq.IP)
respondJSON(w, http.StatusOK, map[string]string{
"ip": state.Cloud.Dnsmasq.IP,
"interface": state.Cloud.Dnsmasq.Interface,
})
slog.Info("DNS settings updated", "ip", result["ip"])
respondJSON(w, http.StatusOK, result)
}
// --- DHCP Config ---
@@ -174,30 +197,33 @@ func (api *API) UpdateDHCPConfig(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
dhcp := &state.Cloud.Dnsmasq.DHCP
if updates.Enabled != nil {
dhcp.Enabled = *updates.Enabled
}
if updates.RangeStart != "" {
dhcp.RangeStart = updates.RangeStart
}
if updates.RangeEnd != "" {
dhcp.RangeEnd = updates.RangeEnd
}
if updates.LeaseTime != "" {
dhcp.LeaseTime = updates.LeaseTime
}
if updates.Gateway != "" {
dhcp.Gateway = updates.Gateway
}
if err := api.saveState(state); err != nil {
var result any
if err := api.modifyState(func(state *config.State) error {
dhcp := &state.Cloud.Dnsmasq.DHCP
if updates.Enabled != nil {
dhcp.Enabled = *updates.Enabled
}
if updates.RangeStart != "" {
dhcp.RangeStart = updates.RangeStart
}
if updates.RangeEnd != "" {
dhcp.RangeEnd = updates.RangeEnd
}
if updates.LeaseTime != "" {
dhcp.LeaseTime = updates.LeaseTime
}
if updates.Gateway != "" {
dhcp.Gateway = updates.Gateway
}
result = state.Cloud.Dnsmasq.DHCP
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
slog.Info("DHCP config updated", "enabled", dhcp.Enabled)
respondJSON(w, http.StatusOK, state.Cloud.Dnsmasq.DHCP)
slog.Info("DHCP config updated")
respondJSON(w, http.StatusOK, result)
}
// --- nftables Config ---
@@ -217,23 +243,26 @@ func (api *API) UpdateNftablesConfig(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
if updates.Enabled != nil {
state.Cloud.Nftables.Enabled = updates.Enabled
}
if updates.WANInterface != "" {
state.Cloud.Nftables.WANInterface = updates.WANInterface
}
if updates.ExtraPorts != nil {
state.Cloud.Nftables.ExtraPorts = updates.ExtraPorts
}
if err := api.saveState(state); err != nil {
var result any
if err := api.modifyState(func(state *config.State) error {
if updates.Enabled != nil {
state.Cloud.Nftables.Enabled = updates.Enabled
}
if updates.WANInterface != "" {
state.Cloud.Nftables.WANInterface = updates.WANInterface
}
if updates.ExtraPorts != nil {
state.Cloud.Nftables.ExtraPorts = updates.ExtraPorts
}
result = state.Cloud.Nftables
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
slog.Info("nftables config updated")
respondJSON(w, http.StatusOK, state.Cloud.Nftables)
respondJSON(w, http.StatusOK, result)
}
// --- HAProxy Custom Routes ---
@@ -253,15 +282,18 @@ func (api *API) UpdateHAProxyRoutes(w http.ResponseWriter, r *http.Request) {
return
}
state := api.loadState()
state.Cloud.HAProxy.CustomRoutes = updates.CustomRoutes
if err := api.saveState(state); err != nil {
var result any
if err := api.modifyState(func(state *config.State) error {
state.Cloud.HAProxy.CustomRoutes = updates.CustomRoutes
result = map[string]any{
"customRoutes": state.Cloud.HAProxy.CustomRoutes,
}
return nil
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save state")
return
}
slog.Info("HAProxy custom routes updated", "count", len(updates.CustomRoutes))
respondJSON(w, http.StatusOK, map[string]any{
"customRoutes": state.Cloud.HAProxy.CustomRoutes,
})
respondJSON(w, http.StatusOK, result)
}

View File

@@ -11,9 +11,30 @@ import (
"github.com/wild-cloud/wild-central/internal/wireguard"
)
// VpnStatus returns the current WireGuard interface status.
func (api *API) VpnStatus(w http.ResponseWriter, r *http.Request) {
status, err := api.vpn.GetStatus()
// VPNManager is the interface for VPN operations used by these handlers.
type VPNManager interface {
GetStatus() (*wireguard.Status, error)
GetConfig() (*wireguard.Config, error)
SaveConfig(cfg *wireguard.Config) error
GetPublicKey() string
GenerateKeypair() error
Apply() error
ListPeers() ([]*wireguard.Peer, error)
AddPeer(name string) (*wireguard.Peer, error)
GetPeer(id string) (*wireguard.Peer, error)
DeletePeer(id string) error
GeneratePeerConfigText(peerID string) (string, error)
}
// vpnHandlers groups VPN HTTP handlers with their dependencies.
type vpnHandlers struct {
vpn VPNManager
statePath string
syncNftables func(globalCfg *config.State) // callback to resync firewall after config changes
}
func (h *vpnHandlers) Status(w http.ResponseWriter, r *http.Request) {
status, err := h.vpn.GetStatus()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get VPN status: %v", err))
return
@@ -21,9 +42,8 @@ func (api *API) VpnStatus(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, status)
}
// VpnGetConfig returns the server interface configuration and public key.
func (api *API) VpnGetConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := api.vpn.GetConfig()
func (h *vpnHandlers) GetConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := h.vpn.GetConfig()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get VPN config: %v", err))
return
@@ -35,12 +55,11 @@ func (api *API) VpnGetConfig(w http.ResponseWriter, r *http.Request) {
"endpoint": cfg.Endpoint,
"dns": cfg.DNS,
"lanCIDR": cfg.LanCIDR,
"publicKey": api.vpn.GetPublicKey(),
"publicKey": h.vpn.GetPublicKey(),
})
}
// VpnUpdateConfig updates the server interface configuration.
func (api *API) VpnUpdateConfig(w http.ResponseWriter, r *http.Request) {
func (h *vpnHandlers) UpdateConfig(w http.ResponseWriter, r *http.Request) {
var req wireguard.Config
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
@@ -49,41 +68,38 @@ func (api *API) VpnUpdateConfig(w http.ResponseWriter, r *http.Request) {
if req.ListenPort == 0 {
req.ListenPort = 51820
}
if err := api.vpn.SaveConfig(&req); err != nil {
if err := h.vpn.SaveConfig(&req); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to save VPN config: %v", err))
return
}
// Resync nftables so the VPN listen port is automatically allowed/removed
if globalCfg, err := config.LoadState(api.statePath()); err == nil {
go api.syncNftablesOnly(globalCfg)
if globalCfg, err := config.LoadState(h.statePath); err == nil {
go h.syncNftables(globalCfg)
}
respondJSON(w, http.StatusOK, map[string]string{"message": "VPN configuration saved"})
}
// VpnGenerateKeypair generates a new server keypair.
func (api *API) VpnGenerateKeypair(w http.ResponseWriter, r *http.Request) {
if err := api.vpn.GenerateKeypair(); err != nil {
func (h *vpnHandlers) GenerateKeypair(w http.ResponseWriter, r *http.Request) {
if err := h.vpn.GenerateKeypair(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate keypair: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{
"message": "Server keypair generated",
"publicKey": api.vpn.GetPublicKey(),
"publicKey": h.vpn.GetPublicKey(),
})
}
// VpnApply writes the wg0.conf and brings the WireGuard interface up.
func (api *API) VpnApply(w http.ResponseWriter, r *http.Request) {
if err := api.vpn.Apply(); err != nil {
func (h *vpnHandlers) Apply(w http.ResponseWriter, r *http.Request) {
if err := h.vpn.Apply(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to apply VPN config: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "WireGuard interface applied successfully"})
}
// VpnListPeers returns all configured peers (without private keys).
func (api *API) VpnListPeers(w http.ResponseWriter, r *http.Request) {
peers, err := api.vpn.ListPeers()
func (h *vpnHandlers) ListPeers(w http.ResponseWriter, r *http.Request) {
peers, err := h.vpn.ListPeers()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list peers: %v", err))
return
@@ -91,8 +107,7 @@ func (api *API) VpnListPeers(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"peers": redactPeers(peers)})
}
// VpnAddPeer adds a new peer, auto-generating a keypair and assigning an IP.
func (api *API) VpnAddPeer(w http.ResponseWriter, r *http.Request) {
func (h *vpnHandlers) AddPeer(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
@@ -100,7 +115,7 @@ func (api *API) VpnAddPeer(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "name is required")
return
}
peer, err := api.vpn.AddPeer(req.Name)
peer, err := h.vpn.AddPeer(req.Name)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add peer: %v", err))
return
@@ -108,15 +123,14 @@ func (api *API) VpnAddPeer(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusCreated, redactPeer(peer))
}
// VpnGetPeerConfig returns the wg-quick config text for a peer (used by clients and QR generation).
func (api *API) VpnGetPeerConfig(w http.ResponseWriter, r *http.Request) {
func (h *vpnHandlers) GetPeerConfig(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
text, err := api.vpn.GeneratePeerConfigText(id)
text, err := h.vpn.GeneratePeerConfigText(id)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate peer config: %v", err))
return
}
peer, err := api.vpn.GetPeer(id)
peer, err := h.vpn.GetPeer(id)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get peer: %v", err))
return
@@ -127,10 +141,9 @@ func (api *API) VpnGetPeerConfig(w http.ResponseWriter, r *http.Request) {
})
}
// VpnDeletePeer removes a peer by ID.
func (api *API) VpnDeletePeer(w http.ResponseWriter, r *http.Request) {
func (h *vpnHandlers) DeletePeer(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
if err := api.vpn.DeletePeer(id); err != nil {
if err := h.vpn.DeletePeer(id); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to delete peer: %v", err))
return
}

View File

@@ -2,21 +2,10 @@ package v1
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/wild-cloud/wild-central/internal/authelia"
"github.com/wild-cloud/wild-central/internal/certbot"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsmasq"
"github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/network"
"github.com/wild-cloud/wild-central/internal/sse"
"github.com/wild-cloud/wild-central/internal/storage"
)
// EnsureCentralDomain registers (or updates) Central's own domain as a domain
@@ -62,290 +51,7 @@ func (api *API) EnsureCentralDomain() {
// Reconcile runs networking reconciliation immediately. Called on startup
// and automatically whenever a domain is registered/updated/deregistered.
func (api *API) Reconcile() {
api.reconcileNetworking()
}
// reconcileNetworking reads all registered domains and regenerates
// dnsmasq DNS entries and HAProxy routes to match.
func (api *API) reconcileNetworking() {
doms, err := api.domains.List()
if err != nil {
slog.Error("reconcile: failed to list domains", "error", err)
return
}
globalCfg, err := config.LoadState(api.statePath())
if err != nil {
slog.Warn("reconcile: failed to load state, using empty", "error", err)
globalCfg = &config.State{}
}
// Build HAProxy routes from registered domains
var l4Routes []haproxy.L4Route
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
switch dom.EffectiveBackendType() {
case domains.BackendTCPPassthrough:
l4Routes = append(l4Routes, haproxy.L4Route{
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.EffectiveBackendAddress()),
Subdomains: dom.Subdomains,
})
case domains.BackendDNSOnly:
// dns-only: no gateway routing, just DNS resolution
case domains.BackendHTTP:
var routeBackends []haproxy.HTTPRouteBackend
for _, r := range dom.EffectiveRoutes() {
rb := haproxy.HTTPRouteBackend{
Paths: r.Paths,
Backend: r.Backend.Address,
HealthPath: r.Backend.Health,
Headers: r.Headers,
IPAllow: r.IPAllow,
}
if dom.Auth != nil && dom.Auth.Enabled {
rb.AuthEnabled = true
rb.AuthPolicy = dom.Auth.Policy
}
routeBackends = append(routeBackends, rb)
}
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Subdomains: dom.Subdomains,
Routes: routeBackends,
})
}
}
// Clean up any 0-byte cert files that would poison HAProxy validation.
// A 0-byte .pem in the certs directory causes the global `bind ssl crt`
// to fail, taking down ALL L7 routes — not just the broken domain.
certsDir := "/etc/haproxy/certs/"
if entries, err := os.ReadDir(certsDir); err == nil {
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".pem") {
if info, err := e.Info(); err == nil && info.Size() == 0 {
slog.Warn("reconcile: removing 0-byte cert file", "file", e.Name())
_ = os.Remove(filepath.Join(certsDir, e.Name()))
}
}
}
}
// Only include L7 HTTP routes for domains that have a valid cert.
var activeHTTPRoutes []haproxy.HTTPRoute
for _, route := range httpRoutes {
if hasCertForDomain(certsDir, route.Domain) {
activeHTTPRoutes = append(activeHTTPRoutes, route)
} else {
slog.Warn("reconcile: skipping L7 route (no cert)", "domain", route.Domain)
}
}
// Generate and write HAProxy config
genOpts := haproxy.GenerateOpts{
HTTPRoutes: activeHTTPRoutes,
CertsDir: certsDir,
}
// Propagate Authelia forward-auth settings if enabled
if globalCfg.Cloud.Authelia.Enabled && globalCfg.Cloud.Authelia.Domain != "" {
genOpts.AuthEnabled = true
genOpts.AuthBackend = "127.0.0.1:9091"
genOpts.AuthDomain = globalCfg.Cloud.Authelia.Domain
genOpts.AuthSessionDomain = authelia.SessionDomainFromAuthDomain(globalCfg.Cloud.Authelia.Domain)
// Regenerate Authelia config so session cookie domains stay in sync
// with the set of auth-protected domains
if err := api.generateAutheliaConfig(globalCfg); err != nil {
slog.Warn("reconcile: failed to regenerate authelia config", "error", err)
} else if api.authelia.UserCount() > 0 {
_ = api.authelia.RestartService()
}
}
haproxyCfg := api.haproxy.GenerateWithOpts(l4Routes, nil, genOpts)
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
// Validation failed — identify and exclude broken domains, then retry
brokenDomains := haproxy.FindBrokenServices(haproxyCfg, haproxy.ParseValidationErrors(err.Error()))
if len(brokenDomains) > 0 {
slog.Error("reconcile: excluding broken domains and retrying",
"broken", brokenDomains, "error", err)
exclude := map[string]bool{}
for _, d := range brokenDomains {
exclude[d] = true
}
var filteredInstances []haproxy.L4Route
for _, r := range l4Routes {
if !exclude[r.Domain] {
filteredInstances = append(filteredInstances, r)
}
}
var filteredHTTP []haproxy.HTTPRoute
for _, r := range activeHTTPRoutes {
if !exclude[r.Domain] {
filteredHTTP = append(filteredHTTP, r)
}
}
genOpts.HTTPRoutes = filteredHTTP
haproxyCfg = api.haproxy.GenerateWithOpts(filteredInstances, nil, genOpts)
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
slog.Error("reconcile: retry also failed", "error", err)
} else {
if err := api.haproxy.ReloadService(); err != nil {
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
} else {
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated (excluded broken domains)")
}
}
} else {
slog.Error("reconcile: failed to write HAProxy config (no broken domains identified)", "error", err)
}
} else {
if err := api.haproxy.ReloadService(); err != nil {
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
} else {
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated from registered domains")
}
}
// Build dnsmasq DNS entries from registered domains.
// DNS target IP depends on backend type:
// tcp-passthrough/dns-only → backend IP (LAN traffic goes direct)
// http → Central IP (HAProxy terminates TLS)
// Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected
// (auto-detect can pick wlan0 if that's the default route).
centralIP := globalCfg.Cloud.Dnsmasq.IP
if centralIP == "" {
centralIP, _ = network.GetWildCentralIP()
}
if centralIP == "" {
centralIP = "127.0.0.1"
}
var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms {
if dom.DomainName == "" {
continue
}
dnsIP := centralIP
bt := dom.EffectiveBackendType()
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
dnsIP = extractHost(dom.EffectiveBackendAddress())
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
Wildcard: dom.Subdomains,
})
}
// Set addn-hosts path for DNS filtering
if globalCfg.Cloud.DNSFilter.Enabled {
hostsPath := api.dnsFilter.HostsFilePath()
if storage.FileExists(hostsPath) {
api.dnsmasq.SetFilterConfPath(hostsPath)
}
} else {
api.dnsmasq.SetFilterConfPath("")
}
if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil {
slog.Error("reconcile: failed to update dnsmasq", "error", err)
} else {
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains")
}
// Ensure TLS certs exist for HTTP domains (where Central terminates TLS).
// For domains under the gateway domain, a wildcard cert covers them all.
// For others, provision individual certs.
if len(httpRoutes) > 0 {
api.ensureTLSCerts(globalCfg, doms)
}
// Nudge DDNS to pick up any domain changes (public toggle, new domains).
// The runner reads fresh params on each check via its callback.
api.ddns.Trigger()
slog.Info("reconcile: networking updated",
"domains", len(doms),
"l4Routes", len(l4Routes),
"l7Routes", len(httpRoutes),
)
}
// ensureTLSCerts logs which certificates are missing for registered domains.
// Does NOT auto-provision — cert provisioning is user-initiated via the
// Certificates page. This just logs warnings so the operator knows what's needed.
func (api *API) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) {
gatewayDomain := ""
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1]
}
for _, dom := range doms {
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
continue
}
// Check if covered by wildcard
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
wildcardCert := certbot.HAProxyCertPath(gatewayDomain)
if _, err := os.Stat(wildcardCert); err != nil {
slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page",
"domain", dom.DomainName, "needed", "*."+gatewayDomain)
}
continue
}
// Check individual cert
if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil {
slog.Warn("reconcile/tls: no cert for domain — provision via Certificates page",
"domain", dom.DomainName)
}
}
}
// hasCertForDomain checks if a valid (non-empty) cert exists for a domain —
// either an individual cert (<domain>.pem) or a wildcard cert that covers it.
func hasCertForDomain(_, domain string) bool {
// Check individual cert
if isValidCertFile(certbot.HAProxyCertPath(domain)) {
return true
}
// Check wildcard certs — a *.example.com cert covers foo.example.com
parts := strings.SplitN(domain, ".", 2)
if len(parts) == 2 {
wildcardBase := parts[1]
if isValidCertFile(certbot.HAProxyCertPath(wildcardBase)) {
return true
}
}
return false
}
// isValidCertFile checks that a cert file exists and is non-empty.
func isValidCertFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Size() > 0
}
// extractHost gets the host part from a host:port string
func extractHost(addr string) string {
for i := len(addr) - 1; i >= 0; i-- {
if addr[i] == ':' {
return addr[:i]
}
}
return addr
api.reconciler.Reconcile()
}
// broadcastDnsmasqEvent broadcasts SSE events for dnsmasq status changes

View File

@@ -1,58 +0,0 @@
package v1
import (
"os"
"path/filepath"
"testing"
)
func TestIsValidCertFile_Valid(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "test.pem")
if err := os.WriteFile(path, []byte("--- cert content ---"), 0600); err != nil {
t.Fatal(err)
}
if !isValidCertFile(path) {
t.Error("expected valid cert file to return true")
}
}
func TestIsValidCertFile_Empty(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "empty.pem")
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
if isValidCertFile(path) {
t.Error("expected 0-byte cert file to return false")
}
}
func TestIsValidCertFile_Missing(t *testing.T) {
if isValidCertFile("/nonexistent/path.pem") {
t.Error("expected missing file to return false")
}
}
func TestHasCertForDomain_DirectMatch(t *testing.T) {
tmp := t.TempDir()
certsDir := filepath.Join(tmp, "certs")
os.MkdirAll(certsDir, 0700)
// hasCertForDomain checks certbot.HAProxyCertPath which is hardcoded to /etc/haproxy/certs/.
// We test isValidCertFile directly instead since hasCertForDomain uses absolute paths.
path := filepath.Join(certsDir, "example.com.pem")
os.WriteFile(path, []byte("cert data"), 0600)
if !isValidCertFile(path) {
t.Error("expected direct cert match to be valid")
}
}
func TestHasCertForDomain_ZeroByteShouldFail(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "bad.pem")
os.WriteFile(path, nil, 0600)
if isValidCertFile(path) {
t.Error("0-byte cert should not be considered valid")
}
}

View File

@@ -73,5 +73,3 @@ func RequestLoggingMiddleware(next http.Handler) http.Handler {
}
})
}

View File

@@ -10,9 +10,9 @@ import (
// Error responses use the Error field.
// Message-only responses use the Message field.
type APIResponse struct {
Data any `json:"data,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
Data any `json:"data,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
// respondJSON writes a JSON response with the given status code.
@@ -33,4 +33,3 @@ func respondMessage(w http.ResponseWriter, status int, message string) {
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, APIResponse{Error: message})
}