From 428d47f8766c0d1767a4ca48e209466dc4035b88 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Tue, 14 Jul 2026 04:21:30 +0000 Subject: [PATCH] Refactor architecture: extract reconciler, add interfaces, reduce complexity, improve test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/api/v1/handlers.go | 109 ++-- internal/api/v1/handlers_authelia.go | 134 ++--- internal/api/v1/handlers_certbot.go | 5 +- internal/api/v1/handlers_crowdsec.go | 105 ++-- internal/api/v1/handlers_dnsfilter.go | 2 +- internal/api/v1/handlers_dnsmasq.go | 124 +--- internal/api/v1/handlers_haproxy.go | 4 +- .../api/v1/handlers_reconciliation_test.go | 10 + internal/api/v1/handlers_settings.go | 180 +++--- internal/api/v1/handlers_wireguard.go | 77 ++- internal/api/v1/helpers.go | 296 +-------- internal/api/v1/helpers_test.go | 58 -- internal/api/v1/middleware.go | 2 - internal/api/v1/response.go | 7 +- internal/authelia/config.go | 18 +- internal/authelia/oidc.go | 12 +- internal/certbot/manager.go | 16 +- internal/config/config.go | 29 +- internal/config/config_test.go | 50 ++ internal/config/extraports_test.go | 2 +- internal/ddns/cloudflare.go | 178 +++--- internal/dnsfilter/manager.go | 10 +- internal/dnsfilter/manager_test.go | 304 ++++++++++ internal/dnsmasq/config.go | 14 +- internal/dnsmasq/config_test.go | 6 +- internal/domains/manager.go | 14 +- internal/haproxy/config.go | 560 +++++++++--------- internal/natsbus/server.go | 2 +- internal/reconcile/reconciler.go | 399 +++++++++++++ internal/reconcile/reconciler_test.go | 286 +++++++++ internal/sse/manager.go | 8 +- internal/storage/storage.go | 1 - internal/tunnel/manager.go | 8 +- internal/tunnel/manager_test.go | 8 +- internal/wireguard/manager.go | 12 +- 35 files changed, 1856 insertions(+), 1194 deletions(-) delete mode 100644 internal/api/v1/helpers_test.go create mode 100644 internal/dnsfilter/manager_test.go create mode 100644 internal/reconcile/reconciler.go create mode 100644 internal/reconcile/reconciler_test.go diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index 82e9b3a..be1f52f 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -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 } diff --git a/internal/api/v1/handlers_authelia.go b/internal/api/v1/handlers_authelia.go index d94360d..d619cb6 100644 --- a/internal/api/v1/handlers_authelia.go +++ b/internal/api/v1/handlers_authelia.go @@ -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 { diff --git a/internal/api/v1/handlers_certbot.go b/internal/api/v1/handlers_certbot.go index c27c1db..e30afaa 100644 --- a/internal/api/v1/handlers_certbot.go +++ b/internal/api/v1/handlers_certbot.go @@ -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 } - diff --git a/internal/api/v1/handlers_crowdsec.go b/internal/api/v1/handlers_crowdsec.go index cbb57fd..846754f 100644 --- a/internal/api/v1/handlers_crowdsec.go +++ b/internal/api/v1/handlers_crowdsec.go @@ -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 } diff --git a/internal/api/v1/handlers_dnsfilter.go b/internal/api/v1/handlers_dnsfilter.go index e2dd2bb..a5b4b7f 100644 --- a/internal/api/v1/handlers_dnsfilter.go +++ b/internal/api/v1/handlers_dnsfilter.go @@ -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") diff --git a/internal/api/v1/handlers_dnsmasq.go b/internal/api/v1/handlers_dnsmasq.go index 7b3c595..55826ef 100644 --- a/internal/api/v1/handlers_dnsmasq.go +++ b/internal/api/v1/handlers_dnsmasq.go @@ -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 -} diff --git a/internal/api/v1/handlers_haproxy.go b/internal/api/v1/handlers_haproxy.go index 998d31d..ebbaf72 100644 --- a/internal/api/v1/handlers_haproxy.go +++ b/internal/api/v1/handlers_haproxy.go @@ -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}) } - - diff --git a/internal/api/v1/handlers_reconciliation_test.go b/internal/api/v1/handlers_reconciliation_test.go index 735bbc9..8d7ca30 100644 --- a/internal/api/v1/handlers_reconciliation_test.go +++ b/internal/api/v1/handlers_reconciliation_test.go @@ -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 diff --git a/internal/api/v1/handlers_settings.go b/internal/api/v1/handlers_settings.go index 16846a3..9c33ded 100644 --- a/internal/api/v1/handlers_settings.go +++ b/internal/api/v1/handlers_settings.go @@ -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) } diff --git a/internal/api/v1/handlers_wireguard.go b/internal/api/v1/handlers_wireguard.go index 19740a6..a3a7ea2 100644 --- a/internal/api/v1/handlers_wireguard.go +++ b/internal/api/v1/handlers_wireguard.go @@ -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 } diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index 3350754..8f470c6 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -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 (.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 diff --git a/internal/api/v1/helpers_test.go b/internal/api/v1/helpers_test.go deleted file mode 100644 index 655253c..0000000 --- a/internal/api/v1/helpers_test.go +++ /dev/null @@ -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") - } -} diff --git a/internal/api/v1/middleware.go b/internal/api/v1/middleware.go index 18e7611..cdd2e81 100644 --- a/internal/api/v1/middleware.go +++ b/internal/api/v1/middleware.go @@ -73,5 +73,3 @@ func RequestLoggingMiddleware(next http.Handler) http.Handler { } }) } - - diff --git a/internal/api/v1/response.go b/internal/api/v1/response.go index 8dfa3e0..60b7746 100644 --- a/internal/api/v1/response.go +++ b/internal/api/v1/response.go @@ -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}) } - diff --git a/internal/authelia/config.go b/internal/authelia/config.go index f1d4bd9..ff12f9f 100644 --- a/internal/authelia/config.go +++ b/internal/authelia/config.go @@ -15,10 +15,10 @@ import ( // ConfigOpts holds parameters for generating Authelia's configuration.yml type ConfigOpts struct { - Domain string // auth portal domain (e.g. auth.payne.io) - JWTSecret string // identity validation JWT secret - SessionSecret string // session cookie secret - StorageEncKey string // storage encryption key (>=20 chars) + Domain string // auth portal domain (e.g. auth.payne.io) + JWTSecret string // identity validation JWT secret + SessionSecret string // session cookie secret + StorageEncKey string // storage encryption key (>=20 chars) OIDCHMACSecret string // OIDC HMAC secret DefaultPolicy string // "one_factor" or "two_factor" SessionDomain string // cookie domain for SSO (e.g. payne.io) — forward-auth only works for subdomains of this @@ -116,11 +116,11 @@ func (m *Manager) buildConfig(opts ConfigOpts) map[string]any { "file": map[string]any{ "path": m.usersDBPath(), "password": map[string]any{ - "algorithm": "argon2id", - "iterations": 3, - "memory": 65536, + "algorithm": "argon2id", + "iterations": 3, + "memory": 65536, "parallelism": 2, - "key_length": 32, + "key_length": 32, "salt_length": 16, }, }, @@ -185,7 +185,7 @@ func (m *Manager) buildConfig(opts ConfigOpts) map[string]any { "client_secret": c.Secret, // already hashed "redirect_uris": c.RedirectURIs, "scopes": c.Scopes, - "authorization_policy": c.Policy, + "authorization_policy": c.Policy, } if c.ConsentMode != "" { client["consent_mode"] = c.ConsentMode diff --git a/internal/authelia/oidc.go b/internal/authelia/oidc.go index b4466e4..2a40168 100644 --- a/internal/authelia/oidc.go +++ b/internal/authelia/oidc.go @@ -11,13 +11,13 @@ import ( // OIDCClient represents a registered OIDC client application type OIDCClient struct { - ID string `yaml:"client_id" json:"clientId"` - Name string `yaml:"client_name" json:"clientName"` - Secret string `yaml:"client_secret" json:"clientSecret,omitempty"` // pbkdf2 hash in config, cleartext on create response + ID string `yaml:"client_id" json:"clientId"` + Name string `yaml:"client_name" json:"clientName"` + Secret string `yaml:"client_secret" json:"clientSecret,omitempty"` // pbkdf2 hash in config, cleartext on create response RedirectURIs []string `yaml:"redirect_uris" json:"redirectUris"` - Scopes []string `yaml:"scopes" json:"scopes"` - Policy string `yaml:"authorization_policy" json:"authorizationPolicy"` - ConsentMode string `yaml:"consent_mode,omitempty" json:"consentMode,omitempty"` + Scopes []string `yaml:"scopes" json:"scopes"` + Policy string `yaml:"authorization_policy" json:"authorizationPolicy"` + ConsentMode string `yaml:"consent_mode,omitempty" json:"consentMode,omitempty"` } // OIDCClientUpdate holds partial updates for an OIDC client diff --git a/internal/certbot/manager.go b/internal/certbot/manager.go index 038439f..cba3928 100644 --- a/internal/certbot/manager.go +++ b/internal/certbot/manager.go @@ -11,14 +11,14 @@ import ( // CertStatus represents the current state of a TLS certificate. type CertStatus struct { - Exists bool `json:"exists"` - Domain string `json:"domain"` - Wildcard bool `json:"wildcard,omitempty"` - Expiry time.Time `json:"expiry,omitempty"` - CertPath string `json:"certPath,omitempty"` - KeyPath string `json:"keyPath,omitempty"` - DaysLeft int `json:"daysLeft,omitempty"` - IssuerCN string `json:"issuerCN,omitempty"` + Exists bool `json:"exists"` + Domain string `json:"domain"` + Wildcard bool `json:"wildcard,omitempty"` + Expiry time.Time `json:"expiry,omitempty"` + CertPath string `json:"certPath,omitempty"` + KeyPath string `json:"keyPath,omitempty"` + DaysLeft int `json:"daysLeft,omitempty"` + IssuerCN string `json:"issuerCN,omitempty"` } // Manager handles TLS certificate provisioning via certbot. diff --git a/internal/config/config.go b/internal/config/config.go index a42a98f..aa4307b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -116,8 +116,8 @@ type State struct { } `yaml:"ddns,omitempty" json:"ddns,omitempty"` Authelia struct { Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` - Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // login portal domain (e.g. auth.payne.io) - DefaultPolicy string `yaml:"defaultPolicy,omitempty" json:"defaultPolicy,omitempty"` // one_factor or two_factor + Domain string `yaml:"domain,omitempty" json:"domain,omitempty"` // login portal domain (e.g. auth.payne.io) + DefaultPolicy string `yaml:"defaultPolicy,omitempty" json:"defaultPolicy,omitempty"` // one_factor or two_factor SMTP struct { Host string `yaml:"host,omitempty" json:"host,omitempty"` // e.g. smtp.gmail.com Port int `yaml:"port,omitempty" json:"port,omitempty"` // e.g. 587 @@ -132,6 +132,29 @@ type State struct { } `yaml:"cloud,omitempty" json:"cloud,omitempty"` } +// AddDHCPStaticLease adds or replaces a static lease entry (matched by MAC). +func (s *State) AddDHCPStaticLease(lease DHCPStaticLease) { + for i, l := range s.Cloud.Dnsmasq.DHCP.StaticLeases { + if l.MAC == lease.MAC { + s.Cloud.Dnsmasq.DHCP.StaticLeases[i] = lease + return + } + } + s.Cloud.Dnsmasq.DHCP.StaticLeases = append(s.Cloud.Dnsmasq.DHCP.StaticLeases, lease) +} + +// RemoveDHCPStaticLease removes a static lease entry by MAC. Returns true if found. +func (s *State) RemoveDHCPStaticLease(mac string) bool { + leases := s.Cloud.Dnsmasq.DHCP.StaticLeases + for i, l := range leases { + if l.MAC == mac { + s.Cloud.Dnsmasq.DHCP.StaticLeases = append(leases[:i], leases[i+1:]...) + return true + } + } + return false +} + // LoadState loads state from the specified path func LoadState(configPath string) (*State, error) { data, err := os.ReadFile(configPath) @@ -161,5 +184,3 @@ func SaveState(config *State, configPath string) error { return os.WriteFile(configPath, data, 0644) } - - diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e988914..cd9ec34 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -266,3 +266,53 @@ func TestState_RoundTrip(t *testing.T) { } } +func TestAddDHCPStaticLease_New(t *testing.T) { + s := &State{} + s.AddDHCPStaticLease(DHCPStaticLease{MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.100", Hostname: "host1"}) + + if len(s.Cloud.Dnsmasq.DHCP.StaticLeases) != 1 { + t.Fatalf("expected 1 lease, got %d", len(s.Cloud.Dnsmasq.DHCP.StaticLeases)) + } + l := s.Cloud.Dnsmasq.DHCP.StaticLeases[0] + if l.MAC != "aa:bb:cc:dd:ee:ff" || l.IP != "192.168.1.100" || l.Hostname != "host1" { + t.Errorf("lease mismatch: %+v", l) + } +} + +func TestAddDHCPStaticLease_ReplaceByMAC(t *testing.T) { + s := &State{} + s.AddDHCPStaticLease(DHCPStaticLease{MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.100"}) + s.AddDHCPStaticLease(DHCPStaticLease{MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.200"}) + + if len(s.Cloud.Dnsmasq.DHCP.StaticLeases) != 1 { + t.Fatalf("expected 1 lease after replace, got %d", len(s.Cloud.Dnsmasq.DHCP.StaticLeases)) + } + if s.Cloud.Dnsmasq.DHCP.StaticLeases[0].IP != "192.168.1.200" { + t.Errorf("expected IP to be updated to 192.168.1.200, got %s", s.Cloud.Dnsmasq.DHCP.StaticLeases[0].IP) + } +} + +func TestRemoveDHCPStaticLease_Exists(t *testing.T) { + s := &State{} + s.AddDHCPStaticLease(DHCPStaticLease{MAC: "aa:bb:cc:dd:ee:ff", IP: "192.168.1.100"}) + s.AddDHCPStaticLease(DHCPStaticLease{MAC: "11:22:33:44:55:66", IP: "192.168.1.101"}) + + found := s.RemoveDHCPStaticLease("aa:bb:cc:dd:ee:ff") + if !found { + t.Error("expected RemoveDHCPStaticLease to return true") + } + if len(s.Cloud.Dnsmasq.DHCP.StaticLeases) != 1 { + t.Fatalf("expected 1 lease remaining, got %d", len(s.Cloud.Dnsmasq.DHCP.StaticLeases)) + } + if s.Cloud.Dnsmasq.DHCP.StaticLeases[0].MAC != "11:22:33:44:55:66" { + t.Error("wrong lease was removed") + } +} + +func TestRemoveDHCPStaticLease_NotFound(t *testing.T) { + s := &State{} + found := s.RemoveDHCPStaticLease("nonexistent") + if found { + t.Error("expected RemoveDHCPStaticLease to return false for missing MAC") + } +} diff --git a/internal/config/extraports_test.go b/internal/config/extraports_test.go index d0ef68c..74d38a0 100644 --- a/internal/config/extraports_test.go +++ b/internal/config/extraports_test.go @@ -100,7 +100,7 @@ func TestExtraPortsYAML_StructuredRoundTrip(t *testing.T) { func TestSplitExtraPorts(t *testing.T) { ports := []ExtraPort{ {Port: 22, Protocol: "tcp"}, - {Port: 80}, // default = tcp + {Port: 80}, // default = tcp {Port: 5353, Protocol: "udp"}, {Port: 123, Protocol: "UDP"}, // case-insensitive } diff --git a/internal/ddns/cloudflare.go b/internal/ddns/cloudflare.go index 8b287c1..68d2184 100644 --- a/internal/ddns/cloudflare.go +++ b/internal/ddns/cloudflare.go @@ -146,7 +146,7 @@ func (rn *Runner) run(ctx context.Context) { } // checkAndUpdate fetches fresh params, the current public IP, and syncs all records. -// The updateCloudflareRecord function short-circuits when the A record already matches, +// The updateRecord method short-circuits when the A record already matches, // so there's no wasted API calls when nothing has changed. func (rn *Runner) checkAndUpdate() { rn.mu.Lock() @@ -170,10 +170,12 @@ func (rn *Runner) checkAndUpdate() { slog.Debug("DDNS: syncing records", "component", "ddns", "ip", ip, "records", p.Records) + cf := &cfClient{apiToken: p.APIToken} + var lastErr string records := make([]RecordStatus, 0, len(p.Records)) for _, record := range p.Records { - if err := updateCloudflareRecord(p.APIToken, record, ip); err != nil { + if err := cf.updateRecord(record, ip); err != nil { lastErr = fmt.Sprintf("updating %s: %v", record, err) slog.Error("DDNS: failed to update record", "component", "ddns", "record", record, "error", err) records = append(records, RecordStatus{Name: record, OK: false, Error: err.Error()}) @@ -238,6 +240,13 @@ func ipFromURL(url string) (string, error) { return ip, nil } +// --- Cloudflare API client --- + +// cfClient wraps the Cloudflare API token and provides DNS record operations. +type cfClient struct { + apiToken string +} + // cloudflareRecord is used to find the record ID for a given hostname type cloudflareRecord struct { ID string `json:"id"` @@ -246,114 +255,72 @@ type cloudflareRecord struct { Content string `json:"content"` } -// updateCloudflareRecord updates a single A record via the Cloudflare API. -// The record name must be a fully qualified domain name (e.g. "cloud.payne.io"). -// If a CNAME already exists at that name (e.g. from a router DDNS service), it is -// deleted first so the A record can be created without conflict. -func updateCloudflareRecord(apiToken, recordName, ip string) error { - // Extract zone name: last two parts of the FQDN (e.g. "payne.io" from "cloud.payne.io") +// do executes an authenticated Cloudflare API request and checks the status. +func (c *cfClient) do(method, url string, body io.Reader) (*http.Response, error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+c.apiToken) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return nil, fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b)) + } + return resp, nil +} + +// updateRecord updates a single A record via the Cloudflare API. +// If a CNAME already exists at that name, it is deleted first. +func (c *cfClient) updateRecord(recordName, ip string) error { parts := strings.Split(recordName, ".") if len(parts) < 2 { return fmt.Errorf("invalid record name: %s", recordName) } zoneName := strings.Join(parts[len(parts)-2:], ".") - // Get zone ID - zoneID, err := getCloudflareZoneID(apiToken, zoneName) + zoneID, err := c.getZoneID(zoneName) if err != nil { return fmt.Errorf("getting zone ID for %s: %w", zoneName, err) } // Check for existing A record — patch it if found - recordID, currentIP, err := getCloudflareRecordID(apiToken, zoneID, recordName, "A") + recordID, currentIP, err := c.getRecordID(zoneID, recordName, "A") if err == nil { if currentIP == ip { return nil // already up to date } - return patchCloudflareRecord(apiToken, zoneID, recordID, recordName, ip) + return c.patchRecord(zoneID, recordID, recordName, ip) } - // No A record. Remove any CNAME that would conflict with A record creation - // (common when migrating from a router-based DDNS service like GL.iNet). - if cnameID, _, cnameErr := getCloudflareRecordID(apiToken, zoneID, recordName, "CNAME"); cnameErr == nil { + // No A record. Remove any CNAME that would conflict. + if cnameID, _, cnameErr := c.getRecordID(zoneID, recordName, "CNAME"); cnameErr == nil { slog.Info("DDNS: removing CNAME before creating A record", "component", "ddns", "record", recordName) - if err := deleteCloudflareRecord(apiToken, zoneID, cnameID); err != nil { + if err := c.deleteRecord(zoneID, cnameID); err != nil { return fmt.Errorf("removing CNAME for %s: %w", recordName, err) } } slog.Info("DDNS: creating A record", "component", "ddns", "record", recordName) - return createCloudflareRecord(apiToken, zoneID, recordName, ip) + return c.createRecord(zoneID, recordName, ip) } -// createCloudflareRecord creates a new A record via the Cloudflare API -func createCloudflareRecord(apiToken, zoneID, recordName, ip string) error { - body, _ := json.Marshal(map[string]string{ - "type": "A", - "name": recordName, - "content": ip, - }) - req, _ := http.NewRequest(http.MethodPost, - fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID), - bytes.NewReader(body)) - req.Header.Set("Authorization", "Bearer "+apiToken) - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("creating record: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b)) - } - return nil -} - -// patchCloudflareRecord updates an existing A record via the Cloudflare API -func patchCloudflareRecord(apiToken, zoneID, recordID, recordName, ip string) error { - body, _ := json.Marshal(map[string]string{ - "type": "A", - "name": recordName, - "content": ip, - }) - req, _ := http.NewRequest(http.MethodPatch, - fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID), - bytes.NewReader(body)) - req.Header.Set("Authorization", "Bearer "+apiToken) - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("updating record: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b)) - } - return nil -} - -func getCloudflareZoneID(apiToken, zoneName string) (string, error) { - req, _ := http.NewRequest(http.MethodGet, - "https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil) - req.Header.Set("Authorization", "Bearer "+apiToken) - - resp, err := http.DefaultClient.Do(req) +func (c *cfClient) getZoneID(zoneName string) (string, error) { + resp, err := c.do(http.MethodGet, "https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil) if err != nil { return "", err } defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - b, _ := io.ReadAll(resp.Body) - return "", fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b)) - } - var result struct { Result []struct { ID string `json:"id"` @@ -368,22 +335,14 @@ func getCloudflareZoneID(apiToken, zoneName string) (string, error) { return result.Result[0].ID, nil } -func getCloudflareRecordID(apiToken, zoneID, recordName, recordType string) (id, currentContent string, err error) { - req, _ := http.NewRequest(http.MethodGet, +func (c *cfClient) getRecordID(zoneID, recordName, recordType string) (id, currentContent string, err error) { + resp, err := c.do(http.MethodGet, fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, recordType, recordName), nil) - req.Header.Set("Authorization", "Bearer "+apiToken) - - resp, err := http.DefaultClient.Do(req) if err != nil { return "", "", err } defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - b, _ := io.ReadAll(resp.Body) - return "", "", fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b)) - } - var result struct { Result []cloudflareRecord `json:"result"` } @@ -396,21 +355,36 @@ func getCloudflareRecordID(apiToken, zoneID, recordName, recordType string) (id, return result.Result[0].ID, result.Result[0].Content, nil } -func deleteCloudflareRecord(apiToken, zoneID, recordID string) error { - req, _ := http.NewRequest(http.MethodDelete, - fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID), - nil) - req.Header.Set("Authorization", "Bearer "+apiToken) +func (c *cfClient) createRecord(zoneID, recordName, ip string) error { + body, _ := json.Marshal(map[string]string{"type": "A", "name": recordName, "content": ip}) + resp, err := c.do(http.MethodPost, + fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID), + bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("creating record: %w", err) + } + resp.Body.Close() + return nil +} - resp, err := http.DefaultClient.Do(req) +func (c *cfClient) patchRecord(zoneID, recordID, recordName, ip string) error { + body, _ := json.Marshal(map[string]string{"type": "A", "name": recordName, "content": ip}) + resp, err := c.do(http.MethodPatch, + fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID), + bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("updating record: %w", err) + } + resp.Body.Close() + return nil +} + +func (c *cfClient) deleteRecord(zoneID, recordID string) error { + resp, err := c.do(http.MethodDelete, + fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID), nil) if err != nil { return fmt.Errorf("deleting record: %w", err) } - defer resp.Body.Close() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - b, _ := io.ReadAll(resp.Body) - return fmt.Errorf("cloudflare API returned %d: %s", resp.StatusCode, string(b)) - } + resp.Body.Close() return nil } diff --git a/internal/dnsfilter/manager.go b/internal/dnsfilter/manager.go index 3e4f810..40ae386 100644 --- a/internal/dnsfilter/manager.go +++ b/internal/dnsfilter/manager.go @@ -44,11 +44,11 @@ type FilterData struct { // FilterStats holds current filtering statistics. type FilterStats struct { - Enabled bool `json:"enabled"` - TotalBlocked int `json:"totalBlocked"` - ListCount int `json:"listCount"` - EnabledLists int `json:"enabledLists"` - LastUpdated time.Time `json:"lastUpdated"` + Enabled bool `json:"enabled"` + TotalBlocked int `json:"totalBlocked"` + ListCount int `json:"listCount"` + EnabledLists int `json:"enabledLists"` + LastUpdated time.Time `json:"lastUpdated"` QueryStats *QueryStats `json:"queryStats,omitempty"` } diff --git a/internal/dnsfilter/manager_test.go b/internal/dnsfilter/manager_test.go new file mode 100644 index 0000000..dd16fae --- /dev/null +++ b/internal/dnsfilter/manager_test.go @@ -0,0 +1,304 @@ +package dnsfilter + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func setupTestManager(t *testing.T) *Manager { + t.Helper() + tmp := t.TempDir() + m := NewManager(tmp) + m.SetHostsFilePath(filepath.Join(tmp, "blocked.conf")) + if err := m.EnsureDataDir(); err != nil { + t.Fatal(err) + } + return m +} + +func TestFilterData_Roundtrip(t *testing.T) { + m := setupTestManager(t) + + fd := &FilterData{ + Lists: []Blocklist{ + {ID: "abc123", Name: "Test List", Enabled: true, EntryCount: 42}, + }, + CustomEntries: []CustomEntry{ + {Domain: "example.com", Type: "allow"}, + {Domain: "ads.example.com", Type: "block"}, + }, + } + + if err := m.SaveFilterData(fd); err != nil { + t.Fatalf("save: %v", err) + } + + loaded, err := m.LoadFilterData() + if err != nil { + t.Fatalf("load: %v", err) + } + + if len(loaded.Lists) != 1 || loaded.Lists[0].ID != "abc123" { + t.Errorf("lists roundtrip failed: %+v", loaded.Lists) + } + if len(loaded.CustomEntries) != 2 { + t.Errorf("custom entries roundtrip failed: %+v", loaded.CustomEntries) + } +} + +func TestLoadFilterData_EmptyOnMissing(t *testing.T) { + m := setupTestManager(t) + fd, err := m.LoadFilterData() + if err != nil { + t.Fatal(err) + } + if fd == nil || len(fd.Lists) != 0 { + t.Error("expected empty filter data for missing file") + } +} + +func TestAddList_DeduplicatesURL(t *testing.T) { + m := setupTestManager(t) + + bl := Blocklist{URL: "https://example.com/list.txt", Name: "Test"} + if err := m.AddList(bl); err != nil { + t.Fatal(err) + } + + err := m.AddList(bl) + if err == nil { + t.Error("expected error on duplicate URL") + } +} + +func TestAddList_RequiresURLOrID(t *testing.T) { + m := setupTestManager(t) + err := m.AddList(Blocklist{Name: "No URL"}) + if err == nil { + t.Error("expected error for list without URL or ID") + } +} + +func TestToggleList(t *testing.T) { + m := setupTestManager(t) + + bl := Blocklist{URL: "https://example.com/list.txt", Name: "Test"} + if err := m.AddList(bl); err != nil { + t.Fatal(err) + } + + fd, _ := m.LoadFilterData() + id := fd.Lists[0].ID + if !fd.Lists[0].Enabled { + t.Error("expected list to be enabled by default") + } + + if err := m.ToggleList(id, false); err != nil { + t.Fatal(err) + } + + fd, _ = m.LoadFilterData() + if fd.Lists[0].Enabled { + t.Error("expected list to be disabled after toggle") + } +} + +func TestToggleList_NotFound(t *testing.T) { + m := setupTestManager(t) + err := m.ToggleList("nonexistent", true) + if err == nil { + t.Error("expected error for nonexistent list") + } +} + +func TestRemoveList(t *testing.T) { + m := setupTestManager(t) + + bl := Blocklist{URL: "https://example.com/list.txt", Name: "Test"} + if err := m.AddList(bl); err != nil { + t.Fatal(err) + } + + fd, _ := m.LoadFilterData() + id := fd.Lists[0].ID + + if err := m.RemoveList(id); err != nil { + t.Fatal(err) + } + + fd, _ = m.LoadFilterData() + if len(fd.Lists) != 0 { + t.Error("expected list to be removed") + } +} + +func TestSetCustomEntry(t *testing.T) { + m := setupTestManager(t) + + if err := m.SetCustomEntry(CustomEntry{Domain: "ads.example.com", Type: "block"}); err != nil { + t.Fatal(err) + } + + fd, _ := m.LoadFilterData() + if len(fd.CustomEntries) != 1 || fd.CustomEntries[0].Type != "block" { + t.Errorf("custom entry not saved: %+v", fd.CustomEntries) + } + + // Update same domain to allow + if err := m.SetCustomEntry(CustomEntry{Domain: "ads.example.com", Type: "allow"}); err != nil { + t.Fatal(err) + } + + fd, _ = m.LoadFilterData() + if len(fd.CustomEntries) != 1 || fd.CustomEntries[0].Type != "allow" { + t.Errorf("custom entry not updated: %+v", fd.CustomEntries) + } +} + +func TestSetCustomEntry_InvalidType(t *testing.T) { + m := setupTestManager(t) + err := m.SetCustomEntry(CustomEntry{Domain: "example.com", Type: "invalid"}) + if err == nil { + t.Error("expected error for invalid type") + } +} + +func TestRemoveCustomEntry(t *testing.T) { + m := setupTestManager(t) + + _ = m.SetCustomEntry(CustomEntry{Domain: "ads.example.com", Type: "block"}) + if err := m.RemoveCustomEntry("ads.example.com"); err != nil { + t.Fatal(err) + } + + fd, _ := m.LoadFilterData() + if len(fd.CustomEntries) != 0 { + t.Error("expected custom entry to be removed") + } +} + +func TestCompile_MergesBlocklists(t *testing.T) { + m := setupTestManager(t) + + // Create two cached list files + _ = os.WriteFile(m.cacheFile("list1"), []byte("ads.example.com\ntracker.example.com\n"), 0644) + _ = os.WriteFile(m.cacheFile("list2"), []byte("malware.example.com\nads.example.com\n"), 0644) + + fd := &FilterData{ + Lists: []Blocklist{ + {ID: "list1", Name: "List 1", Enabled: true}, + {ID: "list2", Name: "List 2", Enabled: true}, + }, + } + _ = m.SaveFilterData(fd) + + count, err := m.Compile() + if err != nil { + t.Fatal(err) + } + + // 3 unique domains (ads.example.com deduplicated) + if count != 3 { + t.Errorf("expected 3 domains, got %d", count) + } + + content, _ := os.ReadFile(m.HostsFilePath()) + output := string(content) + if !strings.Contains(output, "address=/ads.example.com/") { + t.Error("missing ads.example.com in output") + } + if !strings.Contains(output, "address=/tracker.example.com/") { + t.Error("missing tracker.example.com in output") + } + if !strings.Contains(output, "address=/malware.example.com/") { + t.Error("missing malware.example.com in output") + } +} + +func TestCompile_DisabledListsSkipped(t *testing.T) { + m := setupTestManager(t) + + _ = os.WriteFile(m.cacheFile("enabled"), []byte("good.example.com\n"), 0644) + _ = os.WriteFile(m.cacheFile("disabled"), []byte("should-not-appear.example.com\n"), 0644) + + fd := &FilterData{ + Lists: []Blocklist{ + {ID: "enabled", Name: "Enabled", Enabled: true}, + {ID: "disabled", Name: "Disabled", Enabled: false}, + }, + } + _ = m.SaveFilterData(fd) + + count, err := m.Compile() + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Errorf("expected 1 domain (disabled list skipped), got %d", count) + } + + content, _ := os.ReadFile(m.HostsFilePath()) + if strings.Contains(string(content), "should-not-appear") { + t.Error("disabled list domains should not appear in output") + } +} + +func TestCompile_CustomAllowOverridesBlock(t *testing.T) { + m := setupTestManager(t) + + _ = os.WriteFile(m.cacheFile("list1"), []byte("blocked.example.com\nallowed.example.com\n"), 0644) + + fd := &FilterData{ + Lists: []Blocklist{{ID: "list1", Name: "List 1", Enabled: true}}, + CustomEntries: []CustomEntry{{Domain: "allowed.example.com", Type: "allow"}}, + } + _ = m.SaveFilterData(fd) + + count, err := m.Compile() + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Errorf("expected 1 domain (allow override), got %d", count) + } + + content, _ := os.ReadFile(m.HostsFilePath()) + if strings.Contains(string(content), "allowed.example.com") { + t.Error("allowed domain should not be in blocklist") + } +} + +func TestCompile_CustomBlockAdds(t *testing.T) { + m := setupTestManager(t) + + fd := &FilterData{ + CustomEntries: []CustomEntry{{Domain: "custom-blocked.example.com", Type: "block"}}, + } + _ = m.SaveFilterData(fd) + + count, err := m.Compile() + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Errorf("expected 1 domain from custom block, got %d", count) + } + + content, _ := os.ReadFile(m.HostsFilePath()) + if !strings.Contains(string(content), "address=/custom-blocked.example.com/") { + t.Error("custom blocked domain should appear in output") + } +} + +func TestCompile_EmptyLists(t *testing.T) { + m := setupTestManager(t) + count, err := m.Compile() + if err != nil { + t.Fatal(err) + } + if count != 0 { + t.Errorf("expected 0 domains for empty lists, got %d", count) + } +} diff --git a/internal/dnsmasq/config.go b/internal/dnsmasq/config.go index 1e9c5e3..6a0004e 100644 --- a/internal/dnsmasq/config.go +++ b/internal/dnsmasq/config.go @@ -26,7 +26,7 @@ type DNSEntry struct { // ConfigGenerator handles dnsmasq configuration generation type ConfigGenerator struct { - configPath string + configPath string filterConfPath string // path to addn-hosts file for DNS filtering } @@ -135,12 +135,12 @@ func (g *ConfigGenerator) RestartService() error { // ServiceStatus represents the status of the dnsmasq service type ServiceStatus struct { - Status string `json:"status"` - PID int `json:"pid"` - IP string `json:"ip"` - ConfigFile string `json:"config_file"` - DomainsConfigured int `json:"domains_configured"` - LastRestart time.Time `json:"last_restart"` + Status string `json:"status"` + PID int `json:"pid"` + IP string `json:"ip"` + ConfigFile string `json:"config_file"` + DomainsConfigured int `json:"domains_configured"` + LastRestart time.Time `json:"last_restart"` } // GetStatus checks the status of the dnsmasq service diff --git a/internal/dnsmasq/config_test.go b/internal/dnsmasq/config_test.go index 33126c0..48e6508 100644 --- a/internal/dnsmasq/config_test.go +++ b/internal/dnsmasq/config_test.go @@ -266,9 +266,9 @@ func TestGenerate_MixedDomains(t *testing.T) { globalCfg := &config.State{} entries := []DNSEntry{ - wildcardEntry("cloud.payne.io", "192.168.8.240"), // wildcard - entry("central.payne.io", "192.168.8.151"), // exact - entry("wild-cloud.payne.io", "192.168.8.151"), // exact + wildcardEntry("cloud.payne.io", "192.168.8.240"), // wildcard + entry("central.payne.io", "192.168.8.151"), // exact + entry("wild-cloud.payne.io", "192.168.8.151"), // exact } out := g.Generate(globalCfg, entries) diff --git a/internal/domains/manager.go b/internal/domains/manager.go index db242df..f7f891a 100644 --- a/internal/domains/manager.go +++ b/internal/domains/manager.go @@ -70,13 +70,13 @@ type AuthConfig struct { // Simple domains use Backend directly. Multi-backend domains use Routes // for path-based splitting (Backend is ignored when Routes is present). type Domain struct { - DomainName string `yaml:"domain" json:"domain"` // FQDN — unique key - Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual - Backend Backend `yaml:"backend,omitempty" json:"backend,omitempty"` // single backend (simple case) - Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain - Public bool `yaml:"public,omitempty" json:"public,omitempty"` // true = internet-visible (DDNS + external), false = LAN only - TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate - Auth *AuthConfig `yaml:"auth,omitempty" json:"auth,omitempty"` // forward-auth protection (L7 HTTP only) + DomainName string `yaml:"domain" json:"domain"` // FQDN — unique key + Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual + Backend Backend `yaml:"backend,omitempty" json:"backend,omitempty"` // single backend (simple case) + Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain + Public bool `yaml:"public,omitempty" json:"public,omitempty"` // true = internet-visible (DDNS + external), false = LAN only + TLS TLSMode `yaml:"tls,omitempty" json:"tls,omitempty"` // passthrough or terminate + Auth *AuthConfig `yaml:"auth,omitempty" json:"auth,omitempty"` // forward-auth protection (L7 HTTP only) // Multi-backend path routing. When present, Backend is ignored. // Each route maps path prefixes to a specific backend with its own L7 options. diff --git a/internal/haproxy/config.go b/internal/haproxy/config.go index cf682d7..64d0332 100644 --- a/internal/haproxy/config.go +++ b/internal/haproxy/config.go @@ -34,11 +34,11 @@ type CustomRoute struct { // HTTPRouteBackend maps path prefixes to a specific backend with its own L7 options. type HTTPRouteBackend struct { - Paths []string // path prefixes (empty = catch-all) - Backend string // host:port - HealthPath string // optional health check path - Headers *domains.HeaderConfig // per-route headers - IPAllow []string // per-route CIDR whitelist + Paths []string // path prefixes (empty = catch-all) + Backend string // host:port + HealthPath string // optional health check path + Headers *domains.HeaderConfig // per-route headers + IPAllow []string // per-route CIDR whitelist AuthEnabled bool // forward-auth protection via Authelia AuthPolicy string // one_factor, two_factor (for access control) } @@ -82,18 +82,43 @@ func (m *Manager) GetConfigPath() string { // GenerateOpts holds optional parameters for HAProxy config generation. type GenerateOpts struct { - HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central) - CertsDir string // directory containing per-domain PEM files for L7 TLS - AuthEnabled bool // global Authelia forward-auth toggle - AuthBackend string // Authelia backend address (e.g. "127.0.0.1:9091") - AuthDomain string // Authelia login portal domain (excluded from protection) - AuthSessionDomain string // session cookie scope (e.g. "payne.io") — only subdomains are eligible for forward-auth + HTTPRoutes []HTTPRoute // L7 HTTP reverse proxy routes (TLS terminated by Central) + CertsDir string // directory containing per-domain PEM files for L7 TLS + AuthEnabled bool // global Authelia forward-auth toggle + AuthBackend string // Authelia backend address (e.g. "127.0.0.1:9091") + AuthDomain string // Authelia login portal domain (excluded from protection) + AuthSessionDomain string // session cookie scope (e.g. "payne.io") — only subdomains are eligible for forward-auth } // GenerateWithOpts creates a complete HAProxy configuration with full options. func (m *Manager) GenerateWithOpts(instances []L4Route, custom []CustomRoute, opts GenerateOpts) string { var sb strings.Builder + writeGlobalDefaults(&sb, opts.AuthEnabled) + + if len(instances) > 0 || len(opts.HTTPRoutes) > 0 { + writeHTTPSFrontend(&sb, instances, opts) + writeL4Backends(&sb, instances) + if len(opts.HTTPRoutes) > 0 { + writeL7Stack(&sb, opts) + } + } + + if opts.AuthEnabled && opts.AuthBackend != "" { + sb.WriteString("# Authelia forward-auth backend\n") + sb.WriteString("backend be_authelia\n") + sb.WriteString(" mode http\n") + fmt.Fprintf(&sb, " server s0 %s\n", opts.AuthBackend) + sb.WriteString("\n") + } + + writeCustomRoutes(&sb, custom) + + return sb.String() +} + +// writeGlobalDefaults writes the HAProxy global, defaults, stats, and HTTP redirect sections. +func writeGlobalDefaults(sb *strings.Builder, authEnabled bool) { sb.WriteString(`# Wild Cloud HAProxy Configuration # Managed by Wild Cloud Central API — do not edit manually @@ -104,7 +129,7 @@ global maxconn 50000 daemon `) - if opts.AuthEnabled { + if authEnabled { sb.WriteString(" lua-prepend-path /etc/haproxy/lua/?.lua\n") sb.WriteString(" lua-load /etc/haproxy/lua/haproxy-auth-request.lua\n") } @@ -134,281 +159,278 @@ frontend http_in redirect scheme https code 301 `) +} - hasL4Routes := len(instances) > 0 || len(opts.HTTPRoutes) > 0 +// writeHTTPSFrontend writes the SNI-based HTTPS frontend with ACL routing. +// ACL ordering is critical: +// 1. L7 HTTP exact matches → L7 termination +// 2. L4 exact matches → instance backends +// 3. L4 wildcard matches → instance backends +// 4. Default → L7 termination (if any HTTP routes exist) +func writeHTTPSFrontend(sb *strings.Builder, instances []L4Route, opts GenerateOpts) { + sb.WriteString("# HTTPS: SNI-based L4 routing (TLS passes through to k8s traefik)\n") + sb.WriteString("frontend https_in\n") + sb.WriteString(" bind *:443\n") + sb.WriteString(" mode tcp\n") + sb.WriteString(" tcp-request inspect-delay 5s\n") + sb.WriteString(" tcp-request content accept if { req_ssl_hello_type 1 }\n") + sb.WriteString("\n") - if hasL4Routes { - sb.WriteString("# HTTPS: SNI-based L4 routing (TLS passes through to k8s traefik)\n") - sb.WriteString("frontend https_in\n") - sb.WriteString(" bind *:443\n") - sb.WriteString(" mode tcp\n") - sb.WriteString(" tcp-request inspect-delay 5s\n") - sb.WriteString(" tcp-request content accept if { req_ssl_hello_type 1 }\n") - sb.WriteString("\n") - - // ACL ordering is critical. Process in this order: - // 1. L7 HTTP exact matches (central, wild-cloud app) → L7 termination - // 2. L4 exact matches (payne.io, civilsociety.dev) → instance backends - // 3. L4 wildcard matches (*.cloud.payne.io) → instance backends - // 4. Default → L7 termination (if any HTTP routes exist) - - // 1. L7 HTTP services — route to L7 TLS termination frontend - if len(opts.HTTPRoutes) > 0 { - sb.WriteString(" # L7 HTTP services (→ TLS termination)\n") - // Exact matches first - for _, route := range opts.HTTPRoutes { - if route.Subdomains { - continue // handled below - } - aclName := "is_l7_" + sanitizeName(route.Name) - fmt.Fprintf(&sb, " # service: %s\n", route.Domain) - fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain) - fmt.Fprintf(&sb, " use_backend be_l7_termination if %s\n", aclName) + // 1. L7 HTTP services — route to L7 TLS termination frontend + if len(opts.HTTPRoutes) > 0 { + sb.WriteString(" # L7 HTTP services (→ TLS termination)\n") + for _, route := range opts.HTTPRoutes { + if route.Subdomains { + continue } - // Wildcard matches after exact - for _, route := range opts.HTTPRoutes { - if !route.Subdomains { - continue - } - aclName := "is_l7_" + sanitizeName(route.Name) - fmt.Fprintf(&sb, " # service: %s\n", route.Domain) - fmt.Fprintf(&sb, " acl %s req_ssl_sni -m end .%s\n", aclName, route.Domain) - fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain) - fmt.Fprintf(&sb, " use_backend be_l7_termination if %s\n", aclName) - } - sb.WriteString("\n") + aclName := "is_l7_" + sanitizeName(route.Name) + fmt.Fprintf(sb, " # service: %s\n", route.Domain) + fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain) + fmt.Fprintf(sb, " use_backend be_l7_termination if %s\n", aclName) } - - // 2. L4 exact matches — instances with subdomains:false - hasExact := false - for _, inst := range instances { - if inst.Subdomains { - continue // handled in step 3 + for _, route := range opts.HTTPRoutes { + if !route.Subdomains { + continue } - hasExact = true - aclName := "is_" + sanitizeName(inst.Name) - fmt.Fprintf(&sb, " # service: %s\n", inst.Domain) - fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain) - fmt.Fprintf(&sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName) - } - if hasExact { - sb.WriteString("\n") - } - - // 3. L4 wildcard matches — instances with subdomains:true - sb.WriteString(" # L4 instance routes (wildcard subdomain matching)\n") - for _, inst := range instances { - if !inst.Subdomains { - continue // already handled in step 2 - } - aclName := "is_" + sanitizeName(inst.Name) - fmt.Fprintf(&sb, " # service: %s\n", inst.Domain) - fmt.Fprintf(&sb, " acl %s req_ssl_sni -m end .%s\n", aclName, inst.Domain) - fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain) - fmt.Fprintf(&sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName) + aclName := "is_l7_" + sanitizeName(route.Name) + fmt.Fprintf(sb, " # service: %s\n", route.Domain) + fmt.Fprintf(sb, " acl %s req_ssl_sni -m end .%s\n", aclName, route.Domain) + fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain) + fmt.Fprintf(sb, " use_backend be_l7_termination if %s\n", aclName) } sb.WriteString("\n") - - // 4. Default → L7 termination fallback - if len(opts.HTTPRoutes) > 0 { - sb.WriteString(" default_backend be_l7_termination\n") - } - - sb.WriteString("\n") - - // Instance L4 backends - for _, inst := range instances { - fmt.Fprintf(&sb, "# service: %s\n", inst.Domain) - fmt.Fprintf(&sb, "backend be_https_%s\n", sanitizeName(inst.Name)) - sb.WriteString(" mode tcp\n") - sb.WriteString(" option tcp-check\n") - fmt.Fprintf(&sb, " server s0 %s:443 check\n", inst.BackendIP) - sb.WriteString("\n") - } - - // L7 TLS termination backend + frontend (for HTTP routes) - if len(opts.HTTPRoutes) > 0 { - // Load all PEM files from the certs directory — HAProxy serves - // the right cert per-SNI. Each service gets its own .pem. - certPath := opts.CertsDir - if certPath == "" { - certPath = "/etc/haproxy/certs/" - } - - sb.WriteString("backend be_l7_termination\n") - sb.WriteString(" mode tcp\n") - sb.WriteString(" server s0 127.0.0.1:8443\n") - sb.WriteString("\n") - - sb.WriteString("# L7 HTTP frontend: TLS termination + Host-based routing\n") - sb.WriteString("frontend l7_https\n") - fmt.Fprintf(&sb, " bind 127.0.0.1:8443 ssl crt %s\n", certPath) - sb.WriteString(" mode http\n") - sb.WriteString("\n") - - // Host ACLs for all services - for _, httpRoute := range opts.HTTPRoutes { - hostACL := "host_" + sanitizeName(httpRoute.Name) - fmt.Fprintf(&sb, " # service: %s\n", httpRoute.Domain) - if httpRoute.Subdomains { - fmt.Fprintf(&sb, " acl %s hdr(host) -i -m end .%s\n", hostACL, httpRoute.Domain) - fmt.Fprintf(&sb, " acl %s hdr(host) -i -m str %s\n", hostACL, httpRoute.Domain) - } else { - fmt.Fprintf(&sb, " acl %s hdr(host) -i %s\n", hostACL, httpRoute.Domain) - } - } - sb.WriteString("\n") - - // Per-route ACLs (IP whitelisting, headers) and routing rules - for _, httpRoute := range opts.HTTPRoutes { - hostACL := "host_" + sanitizeName(httpRoute.Name) - routeName := sanitizeName(httpRoute.Name) - - for i, rb := range httpRoute.Routes { - aclSuffix := fmt.Sprintf("%s_%d", routeName, i) - - // IP whitelisting for this route - if len(rb.IPAllow) > 0 { - ipACL := "ip_" + aclSuffix - fmt.Fprintf(&sb, " acl %s src %s\n", ipACL, strings.Join(rb.IPAllow, " ")) - if len(rb.Paths) > 0 { - pathACL := "path_" + aclSuffix - fmt.Fprintf(&sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " ")) - fmt.Fprintf(&sb, " http-request deny if %s %s !%s\n", hostACL, pathACL, ipACL) - } else { - fmt.Fprintf(&sb, " http-request deny if %s !%s\n", hostACL, ipACL) - } - } - - // Request headers - if rb.Headers != nil { - for _, k := range sortedKeys(rb.Headers.Request) { - fmt.Fprintf(&sb, " http-request set-header %s %q if %s\n", k, rb.Headers.Request[k], hostACL) - } - } - - // Response headers - if rb.Headers != nil { - for _, k := range sortedKeys(rb.Headers.Response) { - fmt.Fprintf(&sb, " http-response set-header %s %q if %s\n", k, rb.Headers.Response[k], hostACL) - } - } - } - } - - // Authelia forward-auth directives for protected routes - if opts.AuthEnabled && opts.AuthBackend != "" { - authDomainACL := "" - if opts.AuthDomain != "" { - authDomainACL = "host_" + sanitizeName(opts.AuthDomain) - } - - for _, httpRoute := range opts.HTTPRoutes { - hostACL := "host_" + sanitizeName(httpRoute.Name) - - // Skip auth portal domain (would cause infinite redirect) - if hostACL == authDomainACL { - continue - } - - // Skip domains outside the auth session scope — forward-auth - // requires shared cookies, which only work within the same - // parent domain. Other domains should use OIDC instead. - if opts.AuthSessionDomain != "" && - httpRoute.Domain != opts.AuthSessionDomain && - !strings.HasSuffix(httpRoute.Domain, "."+opts.AuthSessionDomain) { - continue - } - - // Check if any route on this domain has auth enabled - hasAuth := false - for _, rb := range httpRoute.Routes { - if rb.AuthEnabled { - hasAuth = true - break - } - } - if !hasAuth { - continue - } - - fmt.Fprintf(&sb, " # auth: %s\n", httpRoute.Domain) - fmt.Fprintf(&sb, " http-request lua.auth-request be_authelia /api/authz/forward-auth if %s\n", hostACL) - fmt.Fprintf(&sb, " http-request redirect location https://%s/?rd=https://%%[hdr(host)]%%[path] if %s !{ var(txn.auth_response_successful) -m bool }\n", opts.AuthDomain, hostACL) - } - sb.WriteString("\n") - } - - // Routing rules: path-specific first, then catch-all - for _, httpRoute := range opts.HTTPRoutes { - hostACL := "host_" + sanitizeName(httpRoute.Name) - routeName := sanitizeName(httpRoute.Name) - - // Path-specific routes first (most specific wins) - for i, rb := range httpRoute.Routes { - if len(rb.Paths) == 0 { - continue - } - backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i) - pathACL := fmt.Sprintf("path_%s_%d", routeName, i) - fmt.Fprintf(&sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " ")) - fmt.Fprintf(&sb, " use_backend %s if %s %s\n", backendName, hostACL, pathACL) - } - - // Catch-all route last - for i, rb := range httpRoute.Routes { - if len(rb.Paths) > 0 { - continue - } - backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i) - fmt.Fprintf(&sb, " use_backend %s if %s\n", backendName, hostACL) - } - } - sb.WriteString("\n") - - // L7 backends — one per route - for _, httpRoute := range opts.HTTPRoutes { - routeName := sanitizeName(httpRoute.Name) - for i, rb := range httpRoute.Routes { - backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i) - fmt.Fprintf(&sb, "# service: %s\n", httpRoute.Domain) - fmt.Fprintf(&sb, "backend %s\n", backendName) - sb.WriteString(" mode http\n") - if rb.HealthPath != "" { - fmt.Fprintf(&sb, " option httpchk GET %s\n", rb.HealthPath) - fmt.Fprintf(&sb, " server s0 %s check\n", rb.Backend) - } else { - fmt.Fprintf(&sb, " server s0 %s\n", rb.Backend) - } - sb.WriteString("\n") - } - } - } } - // Authelia backend for forward-auth + // 2. L4 exact matches + hasExact := false + for _, inst := range instances { + if inst.Subdomains { + continue + } + hasExact = true + aclName := "is_" + sanitizeName(inst.Name) + fmt.Fprintf(sb, " # service: %s\n", inst.Domain) + fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain) + fmt.Fprintf(sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName) + } + if hasExact { + sb.WriteString("\n") + } + + // 3. L4 wildcard matches + sb.WriteString(" # L4 instance routes (wildcard subdomain matching)\n") + for _, inst := range instances { + if !inst.Subdomains { + continue + } + aclName := "is_" + sanitizeName(inst.Name) + fmt.Fprintf(sb, " # service: %s\n", inst.Domain) + fmt.Fprintf(sb, " acl %s req_ssl_sni -m end .%s\n", aclName, inst.Domain) + fmt.Fprintf(sb, " acl %s req_ssl_sni -m str %s\n", aclName, inst.Domain) + fmt.Fprintf(sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName) + } + sb.WriteString("\n") + + // 4. Default fallback + if len(opts.HTTPRoutes) > 0 { + sb.WriteString(" default_backend be_l7_termination\n") + } + sb.WriteString("\n") +} + +// writeL4Backends writes L4 TCP passthrough backend definitions. +func writeL4Backends(sb *strings.Builder, instances []L4Route) { + for _, inst := range instances { + fmt.Fprintf(sb, "# service: %s\n", inst.Domain) + fmt.Fprintf(sb, "backend be_https_%s\n", sanitizeName(inst.Name)) + sb.WriteString(" mode tcp\n") + sb.WriteString(" option tcp-check\n") + fmt.Fprintf(sb, " server s0 %s:443 check\n", inst.BackendIP) + sb.WriteString("\n") + } +} + +// writeL7Stack writes the L7 TLS termination backend, frontend, host ACLs, +// per-route rules (IP whitelisting, headers, auth), routing, and backends. +func writeL7Stack(sb *strings.Builder, opts GenerateOpts) { + certPath := opts.CertsDir + if certPath == "" { + certPath = "/etc/haproxy/certs/" + } + + // TLS termination backend + frontend binding + sb.WriteString("backend be_l7_termination\n") + sb.WriteString(" mode tcp\n") + sb.WriteString(" server s0 127.0.0.1:8443\n") + sb.WriteString("\n") + sb.WriteString("# L7 HTTP frontend: TLS termination + Host-based routing\n") + sb.WriteString("frontend l7_https\n") + fmt.Fprintf(sb, " bind 127.0.0.1:8443 ssl crt %s\n", certPath) + sb.WriteString(" mode http\n") + sb.WriteString("\n") + + // Host ACLs + for _, httpRoute := range opts.HTTPRoutes { + hostACL := "host_" + sanitizeName(httpRoute.Name) + fmt.Fprintf(sb, " # service: %s\n", httpRoute.Domain) + if httpRoute.Subdomains { + fmt.Fprintf(sb, " acl %s hdr(host) -i -m end .%s\n", hostACL, httpRoute.Domain) + fmt.Fprintf(sb, " acl %s hdr(host) -i -m str %s\n", hostACL, httpRoute.Domain) + } else { + fmt.Fprintf(sb, " acl %s hdr(host) -i %s\n", hostACL, httpRoute.Domain) + } + } + sb.WriteString("\n") + + // Per-route ACLs: IP whitelisting and headers + writeL7RouteACLs(sb, opts.HTTPRoutes) + + // Authelia forward-auth directives if opts.AuthEnabled && opts.AuthBackend != "" { - sb.WriteString("# Authelia forward-auth backend\n") - sb.WriteString("backend be_authelia\n") - sb.WriteString(" mode http\n") - fmt.Fprintf(&sb, " server s0 %s\n", opts.AuthBackend) - sb.WriteString("\n") + writeL7AuthDirectives(sb, opts) } + // Routing rules: path-specific first, then catch-all + writeL7RoutingRules(sb, opts.HTTPRoutes) + + // L7 backends — one per route + writeL7Backends(sb, opts.HTTPRoutes) +} + +// writeL7RouteACLs writes per-route IP whitelisting and header directives. +func writeL7RouteACLs(sb *strings.Builder, httpRoutes []HTTPRoute) { + for _, httpRoute := range httpRoutes { + hostACL := "host_" + sanitizeName(httpRoute.Name) + routeName := sanitizeName(httpRoute.Name) + + for i, rb := range httpRoute.Routes { + aclSuffix := fmt.Sprintf("%s_%d", routeName, i) + + if len(rb.IPAllow) > 0 { + ipACL := "ip_" + aclSuffix + fmt.Fprintf(sb, " acl %s src %s\n", ipACL, strings.Join(rb.IPAllow, " ")) + if len(rb.Paths) > 0 { + pathACL := "path_" + aclSuffix + fmt.Fprintf(sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " ")) + fmt.Fprintf(sb, " http-request deny if %s %s !%s\n", hostACL, pathACL, ipACL) + } else { + fmt.Fprintf(sb, " http-request deny if %s !%s\n", hostACL, ipACL) + } + } + + if rb.Headers != nil { + for _, k := range sortedKeys(rb.Headers.Request) { + fmt.Fprintf(sb, " http-request set-header %s %q if %s\n", k, rb.Headers.Request[k], hostACL) + } + for _, k := range sortedKeys(rb.Headers.Response) { + fmt.Fprintf(sb, " http-response set-header %s %q if %s\n", k, rb.Headers.Response[k], hostACL) + } + } + } + } +} + +// writeL7AuthDirectives writes Authelia forward-auth directives for eligible routes. +func writeL7AuthDirectives(sb *strings.Builder, opts GenerateOpts) { + authDomainACL := "" + if opts.AuthDomain != "" { + authDomainACL = "host_" + sanitizeName(opts.AuthDomain) + } + + for _, httpRoute := range opts.HTTPRoutes { + hostACL := "host_" + sanitizeName(httpRoute.Name) + + // Skip auth portal domain (would cause infinite redirect) + if hostACL == authDomainACL { + continue + } + + // Skip domains outside the auth session scope + if opts.AuthSessionDomain != "" && + httpRoute.Domain != opts.AuthSessionDomain && + !strings.HasSuffix(httpRoute.Domain, "."+opts.AuthSessionDomain) { + continue + } + + hasAuth := false + for _, rb := range httpRoute.Routes { + if rb.AuthEnabled { + hasAuth = true + break + } + } + if !hasAuth { + continue + } + + fmt.Fprintf(sb, " # auth: %s\n", httpRoute.Domain) + fmt.Fprintf(sb, " http-request lua.auth-request be_authelia /api/authz/forward-auth if %s\n", hostACL) + fmt.Fprintf(sb, " http-request redirect location https://%s/?rd=https://%%[hdr(host)]%%[path] if %s !{ var(txn.auth_response_successful) -m bool }\n", opts.AuthDomain, hostACL) + } + sb.WriteString("\n") +} + +// writeL7RoutingRules writes use_backend rules: path-specific first, then catch-all. +func writeL7RoutingRules(sb *strings.Builder, httpRoutes []HTTPRoute) { + for _, httpRoute := range httpRoutes { + hostACL := "host_" + sanitizeName(httpRoute.Name) + routeName := sanitizeName(httpRoute.Name) + + for i, rb := range httpRoute.Routes { + if len(rb.Paths) == 0 { + continue + } + backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i) + pathACL := fmt.Sprintf("path_%s_%d", routeName, i) + fmt.Fprintf(sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " ")) + fmt.Fprintf(sb, " use_backend %s if %s %s\n", backendName, hostACL, pathACL) + } + + for i, rb := range httpRoute.Routes { + if len(rb.Paths) > 0 { + continue + } + backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i) + fmt.Fprintf(sb, " use_backend %s if %s\n", backendName, hostACL) + } + } + sb.WriteString("\n") +} + +// writeL7Backends writes L7 HTTP backend definitions — one per route. +func writeL7Backends(sb *strings.Builder, httpRoutes []HTTPRoute) { + for _, httpRoute := range httpRoutes { + routeName := sanitizeName(httpRoute.Name) + for i, rb := range httpRoute.Routes { + backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i) + fmt.Fprintf(sb, "# service: %s\n", httpRoute.Domain) + fmt.Fprintf(sb, "backend %s\n", backendName) + sb.WriteString(" mode http\n") + if rb.HealthPath != "" { + fmt.Fprintf(sb, " option httpchk GET %s\n", rb.HealthPath) + fmt.Fprintf(sb, " server s0 %s check\n", rb.Backend) + } else { + fmt.Fprintf(sb, " server s0 %s\n", rb.Backend) + } + sb.WriteString("\n") + } + } +} + +// writeCustomRoutes writes custom TCP proxy frontend/backend pairs. +func writeCustomRoutes(sb *strings.Builder, custom []CustomRoute) { for _, route := range custom { - fmt.Fprintf(&sb, "# Custom route: %s\n", route.Name) - fmt.Fprintf(&sb, "frontend fe_%s\n", sanitizeName(route.Name)) - fmt.Fprintf(&sb, " bind *:%d\n", route.Port) + fmt.Fprintf(sb, "# Custom route: %s\n", route.Name) + fmt.Fprintf(sb, "frontend fe_%s\n", sanitizeName(route.Name)) + fmt.Fprintf(sb, " bind *:%d\n", route.Port) sb.WriteString(" mode tcp\n") - fmt.Fprintf(&sb, " default_backend be_%s\n", sanitizeName(route.Name)) + fmt.Fprintf(sb, " default_backend be_%s\n", sanitizeName(route.Name)) sb.WriteString("\n") - fmt.Fprintf(&sb, "backend be_%s\n", sanitizeName(route.Name)) + fmt.Fprintf(sb, "backend be_%s\n", sanitizeName(route.Name)) sb.WriteString(" mode tcp\n") - fmt.Fprintf(&sb, " server s0 %s\n", route.Backend) + fmt.Fprintf(sb, " server s0 %s\n", route.Backend) sb.WriteString("\n") } - - return sb.String() } // sortedKeys returns the keys of a map in sorted order for deterministic output. diff --git a/internal/natsbus/server.go b/internal/natsbus/server.go index bc29109..d065e12 100644 --- a/internal/natsbus/server.go +++ b/internal/natsbus/server.go @@ -17,7 +17,7 @@ import ( const ( // KV bucket names - BucketDomains = "wild-domains" // domain registrations + BucketDomains = "wild-domains" // domain registrations BucketPresence = "wild-presence" // node liveness (TTL keys) // Stream names diff --git a/internal/reconcile/reconciler.go b/internal/reconcile/reconciler.go new file mode 100644 index 0000000..2940ddf --- /dev/null +++ b/internal/reconcile/reconciler.go @@ -0,0 +1,399 @@ +// Package reconcile orchestrates the domain→config→daemon pipeline. +// When domains change, Reconcile regenerates HAProxy routes, dnsmasq DNS +// entries, and related subsystem configs, then reloads the affected daemons. +package reconcile + +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" +) + +// HAProxyManager is the subset of haproxy.Manager used by reconciliation. +type HAProxyManager interface { + GenerateWithOpts(instances []haproxy.L4Route, custom []haproxy.CustomRoute, opts haproxy.GenerateOpts) string + WriteConfig(content string) error + ReloadService() error +} + +// DNSManager is the subset of dnsmasq.ConfigGenerator used by reconciliation. +type DNSManager interface { + UpdateConfig(cfg *config.State, entries []dnsmasq.DNSEntry, restart bool) error + SetFilterConfPath(path string) + GetStatus() (*dnsmasq.ServiceStatus, error) +} + +// DomainManager is the subset of domains.Manager used by reconciliation. +type DomainManager interface { + List() ([]domains.Domain, error) +} + +// AuthManager is the subset of authelia.Manager used by reconciliation. +type AuthManager interface { + UserCount() int + RestartService() error +} + +// DDNSRunner is the subset of ddns.Runner used by reconciliation. +type DDNSRunner interface { + Trigger() +} + +// DNSFilterManager is the subset of dnsfilter.Manager used by reconciliation. +type DNSFilterManager interface { + HostsFilePath() string +} + +// EventBroadcaster is the subset of sse.Manager used by reconciliation. +type EventBroadcaster interface { + Broadcast(event *sse.Event) +} + +// GenerateAutheliaConfigFn generates the Authelia config from current state. +// This is a callback because the logic depends on secrets and OIDC clients +// that the reconciler doesn't own. +type GenerateAutheliaConfigFn func(state *config.State) error + +// Reconciler orchestrates config regeneration when domains change. +type Reconciler struct { + Domains DomainManager + HAProxy HAProxyManager + DNS DNSManager + Auth AuthManager + DDNS DDNSRunner + DNSFilter DNSFilterManager + SSE EventBroadcaster + StatePath string + GenerateAutheliaConfig GenerateAutheliaConfigFn +} + +// Reconcile reads all registered domains and regenerates dnsmasq DNS entries +// and HAProxy routes to match. +func (r *Reconciler) Reconcile() { + doms, err := r.Domains.List() + if err != nil { + slog.Error("reconcile: failed to list domains", "error", err) + return + } + + globalCfg, err := config.LoadState(r.StatePath) + if err != nil { + slog.Warn("reconcile: failed to load state, using empty", "error", err) + globalCfg = &config.State{} + } + + l4Routes, httpRoutes := buildRoutes(doms) + + cleanZeroByteCerts("/etc/haproxy/certs/") + + // Only include L7 HTTP routes for domains that have a valid cert. + var activeHTTPRoutes []haproxy.HTTPRoute + for _, route := range httpRoutes { + if hasCertForDomain(route.Domain) { + activeHTTPRoutes = append(activeHTTPRoutes, route) + } else { + slog.Warn("reconcile: skipping L7 route (no cert)", "domain", route.Domain) + } + } + + r.writeHAProxyConfig(l4Routes, activeHTTPRoutes, globalCfg) + + // Build and apply DNS config + centralIP := globalCfg.Cloud.Dnsmasq.IP + if centralIP == "" { + centralIP, _ = network.GetWildCentralIP() + } + if centralIP == "" { + centralIP = "127.0.0.1" + } + + dnsEntries := buildDNSEntries(doms, centralIP) + + if globalCfg.Cloud.DNSFilter.Enabled { + hostsPath := r.DNSFilter.HostsFilePath() + if storage.FileExists(hostsPath) { + r.DNS.SetFilterConfPath(hostsPath) + } + } else { + r.DNS.SetFilterConfPath("") + } + + if err := r.DNS.UpdateConfig(globalCfg, dnsEntries, true); err != nil { + slog.Error("reconcile: failed to update dnsmasq", "error", err) + } else { + r.broadcastDNSEvent("dnsmasq:config", "DNS config regenerated from registered domains") + } + + if len(httpRoutes) > 0 { + ensureTLSCerts(globalCfg, doms) + } + + r.DDNS.Trigger() + + slog.Info("reconcile: networking updated", + "domains", len(doms), + "l4Routes", len(l4Routes), + "l7Routes", len(httpRoutes), + ) +} + +// buildRoutes converts registered domains into HAProxy L4 and L7 route lists. +func buildRoutes(doms []domains.Domain) ([]haproxy.L4Route, []haproxy.HTTPRoute) { + 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 _, route := range dom.EffectiveRoutes() { + rb := haproxy.HTTPRouteBackend{ + Paths: route.Paths, + Backend: route.Backend.Address, + HealthPath: route.Backend.Health, + Headers: route.Headers, + IPAllow: route.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, + }) + } + } + + return l4Routes, httpRoutes +} + +// buildDNSEntries converts domains to dnsmasq entries with appropriate IPs. +// HTTP domains point to centralIP (HAProxy terminates TLS). +// TCP passthrough and DNS-only domains point to their backend IP directly. +func buildDNSEntries(doms []domains.Domain, centralIP string) []dnsmasq.DNSEntry { + var entries []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()) + } + + entries = append(entries, dnsmasq.DNSEntry{ + Domain: dom.DomainName, + IP: dnsIP, + Wildcard: dom.Subdomains, + }) + } + return entries +} + +// writeHAProxyConfig generates, validates, and writes the HAProxy config. +// On validation failure, it identifies broken domains and retries without them. +func (r *Reconciler) writeHAProxyConfig(l4Routes []haproxy.L4Route, httpRoutes []haproxy.HTTPRoute, globalCfg *config.State) { + genOpts := haproxy.GenerateOpts{ + HTTPRoutes: httpRoutes, + CertsDir: "/etc/haproxy/certs/", + } + + 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) + + if r.GenerateAutheliaConfig != nil { + if err := r.GenerateAutheliaConfig(globalCfg); err != nil { + slog.Warn("reconcile: failed to regenerate authelia config", "error", err) + } else if r.Auth.UserCount() > 0 { + _ = r.Auth.RestartService() + } + } + } + + cfg := r.HAProxy.GenerateWithOpts(l4Routes, nil, genOpts) + + if err := r.HAProxy.WriteConfig(cfg); err != nil { + brokenDomains := haproxy.FindBrokenServices(cfg, 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 filteredL4 []haproxy.L4Route + for _, route := range l4Routes { + if !exclude[route.Domain] { + filteredL4 = append(filteredL4, route) + } + } + var filteredHTTP []haproxy.HTTPRoute + for _, route := range httpRoutes { + if !exclude[route.Domain] { + filteredHTTP = append(filteredHTTP, route) + } + } + + genOpts.HTTPRoutes = filteredHTTP + cfg = r.HAProxy.GenerateWithOpts(filteredL4, nil, genOpts) + if err := r.HAProxy.WriteConfig(cfg); err != nil { + slog.Error("reconcile: retry also failed", "error", err) + } else if err := r.HAProxy.ReloadService(); err != nil { + slog.Warn("reconcile: failed to reload HAProxy", "error", err) + } else { + r.broadcastEvent("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 := r.HAProxy.ReloadService(); err != nil { + slog.Warn("reconcile: failed to reload HAProxy", "error", err) + } else { + r.broadcastEvent("haproxy:config", "HAProxy config regenerated from registered domains") + } +} + +// cleanZeroByteCerts removes 0-byte cert files that would poison HAProxy validation. +func cleanZeroByteCerts(certsDir string) { + entries, err := os.ReadDir(certsDir) + if err != nil { + return + } + 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())) + } + } + } +} + +// broadcastEvent sends a simple SSE event. +func (r *Reconciler) broadcastEvent(eventType, message string) { + if r.SSE == nil { + return + } + r.SSE.Broadcast(&sse.Event{ + ID: fmt.Sprintf("%s-%d", strings.SplitN(eventType, ":", 2)[0], time.Now().UnixNano()), + Type: eventType, + InstanceName: "global", + Timestamp: time.Now(), + Data: map[string]any{"message": message}, + }) +} + +// broadcastDNSEvent sends a dnsmasq SSE event including current status. +func (r *Reconciler) broadcastDNSEvent(eventType, message string) { + if r.SSE == nil { + return + } + status, err := r.DNS.GetStatus() + if err != nil { + status = nil + } + r.SSE.Broadcast(&sse.Event{ + ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()), + Type: eventType, + InstanceName: "global", + Timestamp: time.Now(), + Data: map[string]any{ + "message": message, + "status": status, + }, + }) +} + +// ensureTLSCerts logs which certificates are missing for registered domains. +// Does NOT auto-provision — cert provisioning is user-initiated. +func 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 (.pem) or a wildcard cert that covers it. +func hasCertForDomain(domain string) bool { + if isValidCertFile(certbot.HAProxyCertPath(domain)) { + return true + } + parts := strings.SplitN(domain, ".", 2) + if len(parts) == 2 { + if isValidCertFile(certbot.HAProxyCertPath(parts[1])) { + return true + } + } + return false +} + +func isValidCertFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Size() > 0 +} + +func extractHost(addr string) string { + for i := len(addr) - 1; i >= 0; i-- { + if addr[i] == ':' { + return addr[:i] + } + } + return addr +} diff --git a/internal/reconcile/reconciler_test.go b/internal/reconcile/reconciler_test.go new file mode 100644 index 0000000..893a25b --- /dev/null +++ b/internal/reconcile/reconciler_test.go @@ -0,0 +1,286 @@ +package reconcile + +import ( + "os" + "path/filepath" + "testing" + + "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/sse" +) + +// --- Stubs --- + +type stubDomainManager struct { + domains []domains.Domain + err error +} + +func (s *stubDomainManager) List() ([]domains.Domain, error) { + return s.domains, s.err +} + +type stubHAProxy struct { + generateCalls int + lastL4Routes []haproxy.L4Route + writtenConfig string + writeErr error + reloadCalled bool +} + +func (s *stubHAProxy) GenerateWithOpts(l4 []haproxy.L4Route, _ []haproxy.CustomRoute, _ haproxy.GenerateOpts) string { + s.generateCalls++ + s.lastL4Routes = l4 + return "generated-config" +} +func (s *stubHAProxy) WriteConfig(c string) error { s.writtenConfig = c; return s.writeErr } +func (s *stubHAProxy) ReloadService() error { s.reloadCalled = true; return nil } + +type stubDNS struct { + entries []dnsmasq.DNSEntry + filterPath string + updateCalled bool +} + +func (s *stubDNS) UpdateConfig(_ *config.State, entries []dnsmasq.DNSEntry, _ bool) error { + s.entries = entries + s.updateCalled = true + return nil +} +func (s *stubDNS) SetFilterConfPath(p string) { s.filterPath = p } +func (s *stubDNS) GetStatus() (*dnsmasq.ServiceStatus, error) { return nil, nil } + +type stubAuth struct { + userCount int + restartCalled bool +} + +func (s *stubAuth) UserCount() int { return s.userCount } +func (s *stubAuth) RestartService() error { s.restartCalled = true; return nil } + +type stubDDNS struct{ triggerCalled bool } + +func (s *stubDDNS) Trigger() { s.triggerCalled = true } + +type stubDNSFilter struct{ hostsPath string } + +func (s *stubDNSFilter) HostsFilePath() string { return s.hostsPath } + +type stubSSE struct{ events []*sse.Event } + +func (s *stubSSE) Broadcast(e *sse.Event) { s.events = append(s.events, e) } + +func newTestReconciler(t *testing.T, doms []domains.Domain) (*Reconciler, *stubHAProxy, *stubDNS, *stubDDNS) { + t.Helper() + tmpDir := t.TempDir() + statePath := filepath.Join(tmpDir, "state.yaml") + // Write minimal state + _ = config.SaveState(&config.State{}, statePath) + + hp := &stubHAProxy{} + dns := &stubDNS{} + ddns := &stubDDNS{} + + r := &Reconciler{ + Domains: &stubDomainManager{domains: doms}, + HAProxy: hp, + DNS: dns, + Auth: &stubAuth{}, + DDNS: ddns, + DNSFilter: &stubDNSFilter{}, + SSE: &stubSSE{}, + StatePath: statePath, + } + return r, hp, dns, ddns +} + +// --- Pure function tests --- + +func TestBuildRoutes_MixedBackendTypes(t *testing.T) { + doms := []domains.Domain{ + {DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}, Subdomains: true}, + {DomainName: "api.example.com", Backend: domains.Backend{Address: "192.168.1.20:8080", Type: domains.BackendHTTP}}, + {DomainName: "ssh.example.com", Backend: domains.Backend{Address: "192.168.1.30:22", Type: domains.BackendDNSOnly}}, + } + + l4, http := buildRoutes(doms) + + if len(l4) != 1 { + t.Fatalf("expected 1 L4 route, got %d", len(l4)) + } + if l4[0].Domain != "cloud.example.com" || l4[0].BackendIP != "192.168.1.10" || !l4[0].Subdomains { + t.Errorf("L4 route mismatch: %+v", l4[0]) + } + + if len(http) != 1 { + t.Fatalf("expected 1 HTTP route, got %d", len(http)) + } + if http[0].Domain != "api.example.com" { + t.Errorf("HTTP route domain = %q, want api.example.com", http[0].Domain) + } + if len(http[0].Routes) != 1 || http[0].Routes[0].Backend != "192.168.1.20:8080" { + t.Errorf("HTTP route backend mismatch: %+v", http[0].Routes) + } +} + +func TestBuildRoutes_EmptyDomains(t *testing.T) { + l4, http := buildRoutes(nil) + if l4 != nil || http != nil { + t.Error("expected nil routes for nil domains") + } +} + +func TestBuildDNSEntries_IPAssignment(t *testing.T) { + centralIP := "10.0.0.1" + doms := []domains.Domain{ + {DomainName: "central.example.com", Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP}}, + {DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}}, + {DomainName: "ssh.example.com", Backend: domains.Backend{Address: "192.168.1.30:22", Type: domains.BackendDNSOnly}}, + } + + entries := buildDNSEntries(doms, centralIP) + + if len(entries) != 3 { + t.Fatalf("expected 3 entries, got %d", len(entries)) + } + + // HTTP → centralIP + if entries[0].IP != centralIP { + t.Errorf("HTTP domain IP = %q, want %q", entries[0].IP, centralIP) + } + // TCP passthrough → backend IP + if entries[1].IP != "192.168.1.10" { + t.Errorf("TCP domain IP = %q, want 192.168.1.10", entries[1].IP) + } + // DNS-only → backend IP + if entries[2].IP != "192.168.1.30" { + t.Errorf("DNS-only domain IP = %q, want 192.168.1.30", entries[2].IP) + } +} + +func TestBuildDNSEntries_SkipsEmptyDomainName(t *testing.T) { + entries := buildDNSEntries([]domains.Domain{{DomainName: ""}}, "10.0.0.1") + if len(entries) != 0 { + t.Error("expected empty domain to be skipped") + } +} + +func TestBuildDNSEntries_WildcardFlag(t *testing.T) { + doms := []domains.Domain{ + {DomainName: "cloud.example.com", Backend: domains.Backend{Address: "192.168.1.10:443", Type: domains.BackendTCPPassthrough}, Subdomains: true}, + {DomainName: "exact.example.com", Backend: domains.Backend{Address: "192.168.1.20:443", Type: domains.BackendTCPPassthrough}, Subdomains: false}, + } + entries := buildDNSEntries(doms, "10.0.0.1") + if !entries[0].Wildcard { + t.Error("expected first entry to be wildcard") + } + if entries[1].Wildcard { + t.Error("expected second entry to not be wildcard") + } +} + +// --- Integration tests with stubs --- + +func TestReconcile_EmptyDomains(t *testing.T) { + r, hp, dns, ddns := newTestReconciler(t, nil) + r.Reconcile() + + if !dns.updateCalled { + t.Error("expected dnsmasq update") + } + if len(dns.entries) != 0 { + t.Errorf("expected 0 DNS entries, got %d", len(dns.entries)) + } + if !hp.reloadCalled { + // No routes but config is still generated and written + } + if !ddns.triggerCalled { + t.Error("expected DDNS trigger") + } + // With no routes, HAProxy should still generate (empty config is valid) + if hp.generateCalls != 1 { + t.Errorf("expected 1 HAProxy generate call, got %d", hp.generateCalls) + } +} + +func TestReconcile_DDNSTriggered(t *testing.T) { + doms := []domains.Domain{ + {DomainName: "test.example.com", Backend: domains.Backend{Address: "127.0.0.1:80", Type: domains.BackendHTTP}}, + } + r, _, _, ddns := newTestReconciler(t, doms) + r.Reconcile() + + if !ddns.triggerCalled { + t.Error("expected DDNS.Trigger() to be called") + } +} + +func TestReconcile_DNSEntriesBuiltFromDomains(t *testing.T) { + doms := []domains.Domain{ + {DomainName: "app.example.com", Backend: domains.Backend{Address: "127.0.0.1:8080", Type: domains.BackendHTTP}}, + {DomainName: "k8s.example.com", Backend: domains.Backend{Address: "192.168.1.50:443", Type: domains.BackendTCPPassthrough}}, + } + r, _, dns, _ := newTestReconciler(t, doms) + r.Reconcile() + + if len(dns.entries) != 2 { + t.Fatalf("expected 2 DNS entries, got %d", len(dns.entries)) + } + // HTTP domain should use centralIP (127.0.0.1 fallback since state has no IP) + if dns.entries[0].Domain != "app.example.com" { + t.Errorf("first DNS entry domain = %q", dns.entries[0].Domain) + } + // TCP passthrough should use backend IP directly + if dns.entries[1].IP != "192.168.1.50" { + t.Errorf("TCP DNS entry IP = %q, want 192.168.1.50", dns.entries[1].IP) + } +} + +// --- Helper tests --- + +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 TestExtractHost(t *testing.T) { + tests := []struct { + input, want string + }{ + {"192.168.1.1:8080", "192.168.1.1"}, + {"example.com:443", "example.com"}, + {"localhost", "localhost"}, + {"127.0.0.1", "127.0.0.1"}, + } + for _, tt := range tests { + if got := extractHost(tt.input); got != tt.want { + t.Errorf("extractHost(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/internal/sse/manager.go b/internal/sse/manager.go index f181ba0..86ee446 100644 --- a/internal/sse/manager.go +++ b/internal/sse/manager.go @@ -14,10 +14,10 @@ import ( // Event represents an SSE event type Event struct { - ID string `json:"id"` - Type string `json:"type"` - InstanceName string `json:"instanceName"` - Timestamp time.Time `json:"timestamp"` + ID string `json:"id"` + Type string `json:"type"` + InstanceName string `json:"instanceName"` + Timestamp time.Time `json:"timestamp"` Data any `json:"data"` Metadata map[string]any `json:"metadata,omitempty"` } diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 3ae832f..49c19ad 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -108,4 +108,3 @@ func EnsureFilePermissions(path string, perm os.FileMode) error { } return nil } - diff --git a/internal/tunnel/manager.go b/internal/tunnel/manager.go index d21c931..f894566 100644 --- a/internal/tunnel/manager.go +++ b/internal/tunnel/manager.go @@ -23,10 +23,10 @@ import ( // Config holds tunnel configuration. type Config struct { - Enabled bool `yaml:"enabled" json:"enabled"` - TunnelID string `yaml:"tunnelId" json:"tunnelId"` - PublicDomain string `yaml:"publicDomain" json:"publicDomain"` // e.g. "pub.payne.io" - GatewayDomain string `yaml:"gatewayDomain" json:"gatewayDomain"` // e.g. "payne.io" (internal) + Enabled bool `yaml:"enabled" json:"enabled"` + TunnelID string `yaml:"tunnelId" json:"tunnelId"` + PublicDomain string `yaml:"publicDomain" json:"publicDomain"` // e.g. "pub.payne.io" + GatewayDomain string `yaml:"gatewayDomain" json:"gatewayDomain"` // e.g. "payne.io" (internal) CredentialsDir string `yaml:"credentialsDir" json:"credentialsDir"` } diff --git a/internal/tunnel/manager_test.go b/internal/tunnel/manager_test.go index b1c278d..7e52729 100644 --- a/internal/tunnel/manager_test.go +++ b/internal/tunnel/manager_test.go @@ -9,10 +9,10 @@ import ( func testConfig() Config { return Config{ - Enabled: true, - TunnelID: "abc-123", - PublicDomain: "pub.payne.io", - GatewayDomain: "payne.io", + Enabled: true, + TunnelID: "abc-123", + PublicDomain: "pub.payne.io", + GatewayDomain: "payne.io", CredentialsDir: "/tmp/creds", } } diff --git a/internal/wireguard/manager.go b/internal/wireguard/manager.go index e317f99..041ba99 100644 --- a/internal/wireguard/manager.go +++ b/internal/wireguard/manager.go @@ -41,12 +41,12 @@ type secrets struct { // Status represents the current runtime state of the WireGuard interface. type Status struct { - Running bool `json:"running"` - Interface string `json:"interface"` - PublicKey string `json:"publicKey"` - ListenPort int `json:"listenPort"` - PeerCount int `json:"peerCount"` - RawOutput string `json:"rawOutput"` + Running bool `json:"running"` + Interface string `json:"interface"` + PublicKey string `json:"publicKey"` + ListenPort int `json:"listenPort"` + PeerCount int `json:"peerCount"` + RawOutput string `json:"rawOutput"` } // Manager handles WireGuard configuration and service management.