Refactor architecture: extract reconciler, add interfaces, reduce complexity, improve test coverage
Architecture: - Extract reconcileNetworking into internal/reconcile package with 7 consumer-side interfaces - Add locked modifyState helper to fix state.yaml read-modify-write race condition - Extract CrowdSec and VPN handler groups with interfaces documenting dependency surface - Replace raw map[string]any YAML manipulation with typed AddDHCPStaticLease/RemoveDHCPStaticLease - Extract Cloudflare API functions into cfClient struct, eliminating repeated auth boilerplate Complexity reduction: - haproxy.GenerateWithOpts: 50 → 6 (extracted 8 focused helpers) - reconcile.Reconcile: 42 → 11 (extracted buildRoutes, buildDNSEntries, writeHAProxyConfig) - AutheliaUpdateConfig: 25 → 10 (extracted enableAuthelia/disableAuthelia) Test coverage improvements: - reconcile: 4.5% → 56.8% (stub-based tests for route building, DNS entries, orchestration) - dnsfilter: 10.7% → 45.6% (Manager.Compile, AddList, ToggleList, custom entries) - config: 67.3% → 89.8% (DHCP static lease mutation tests)
This commit is contained in:
@@ -24,11 +24,12 @@ import (
|
|||||||
"github.com/wild-cloud/wild-central/internal/ddns"
|
"github.com/wild-cloud/wild-central/internal/ddns"
|
||||||
"github.com/wild-cloud/wild-central/internal/dnsfilter"
|
"github.com/wild-cloud/wild-central/internal/dnsfilter"
|
||||||
"github.com/wild-cloud/wild-central/internal/dnsmasq"
|
"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/haproxy"
|
||||||
"github.com/wild-cloud/wild-central/internal/network"
|
"github.com/wild-cloud/wild-central/internal/network"
|
||||||
"github.com/wild-cloud/wild-central/internal/nftables"
|
"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/secrets"
|
||||||
"github.com/wild-cloud/wild-central/internal/domains"
|
|
||||||
"github.com/wild-cloud/wild-central/internal/sse"
|
"github.com/wild-cloud/wild-central/internal/sse"
|
||||||
"github.com/wild-cloud/wild-central/internal/wireguard"
|
"github.com/wild-cloud/wild-central/internal/wireguard"
|
||||||
)
|
)
|
||||||
@@ -39,6 +40,7 @@ type API struct {
|
|||||||
version string
|
version string
|
||||||
allowedOrigins []string
|
allowedOrigins []string
|
||||||
ctx gocontext.Context // parent context for restartable goroutines
|
ctx gocontext.Context // parent context for restartable goroutines
|
||||||
|
reconciler *reconcile.Reconciler
|
||||||
secrets *secrets.Manager
|
secrets *secrets.Manager
|
||||||
dnsmasq *dnsmasq.ConfigGenerator
|
dnsmasq *dnsmasq.ConfigGenerator
|
||||||
haproxy *haproxy.Manager
|
haproxy *haproxy.Manager
|
||||||
@@ -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")
|
filterDir := envOrDefault("WILD_CENTRAL_FILTER_DIR", "/var/lib/wild-central/dns-filter")
|
||||||
api.dnsFilter.SetHostsFilePath(filepath.Join(filterDir, "blocked.conf"))
|
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,
|
// Wire up domain registration reconciliation: when domains change,
|
||||||
// regenerate dnsmasq DNS entries and HAProxy routes.
|
// regenerate dnsmasq DNS entries and HAProxy routes.
|
||||||
api.domains.SetReconcileFn(api.reconcileNetworking)
|
api.domains.SetReconcileFn(api.reconciler.Reconcile)
|
||||||
|
|
||||||
return api, nil
|
return api, nil
|
||||||
}
|
}
|
||||||
@@ -259,15 +274,16 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
|||||||
r.HandleFunc("/api/v1/ddns/trigger", api.DDNSTrigger).Methods("POST")
|
r.HandleFunc("/api/v1/ddns/trigger", api.DDNSTrigger).Methods("POST")
|
||||||
|
|
||||||
// WireGuard VPN management
|
// WireGuard VPN management
|
||||||
r.HandleFunc("/api/v1/vpn/status", api.VpnStatus).Methods("GET")
|
vpn := &vpnHandlers{vpn: api.vpn, statePath: api.statePath(), syncNftables: api.syncNftablesOnly}
|
||||||
r.HandleFunc("/api/v1/vpn/config", api.VpnGetConfig).Methods("GET")
|
r.HandleFunc("/api/v1/vpn/status", vpn.Status).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/vpn/config", api.VpnUpdateConfig).Methods("PUT")
|
r.HandleFunc("/api/v1/vpn/config", vpn.GetConfig).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/vpn/keygen", api.VpnGenerateKeypair).Methods("POST")
|
r.HandleFunc("/api/v1/vpn/config", vpn.UpdateConfig).Methods("PUT")
|
||||||
r.HandleFunc("/api/v1/vpn/apply", api.VpnApply).Methods("POST")
|
r.HandleFunc("/api/v1/vpn/keygen", vpn.GenerateKeypair).Methods("POST")
|
||||||
r.HandleFunc("/api/v1/vpn/peers", api.VpnListPeers).Methods("GET")
|
r.HandleFunc("/api/v1/vpn/apply", vpn.Apply).Methods("POST")
|
||||||
r.HandleFunc("/api/v1/vpn/peers", api.VpnAddPeer).Methods("POST")
|
r.HandleFunc("/api/v1/vpn/peers", vpn.ListPeers).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/vpn/peers/{id}/config", api.VpnGetPeerConfig).Methods("GET")
|
r.HandleFunc("/api/v1/vpn/peers", vpn.AddPeer).Methods("POST")
|
||||||
r.HandleFunc("/api/v1/vpn/peers/{id}", api.VpnDeletePeer).Methods("DELETE")
|
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
|
// TLS certificate management
|
||||||
r.HandleFunc("/api/v1/cert/status", api.CertStatus).Methods("GET")
|
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")
|
r.HandleFunc("/api/v1/cert/renew", api.CertRenew).Methods("POST")
|
||||||
|
|
||||||
// CrowdSec LAPI management
|
// CrowdSec LAPI management
|
||||||
r.HandleFunc("/api/v1/crowdsec/status", api.CrowdSecStatus).Methods("GET")
|
csec := &crowdsecHandlers{crowdsec: api.crowdsec}
|
||||||
r.HandleFunc("/api/v1/crowdsec/summary", api.CrowdSecGetSummary).Methods("GET")
|
r.HandleFunc("/api/v1/crowdsec/status", csec.Status).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/crowdsec/alerts", api.CrowdSecGetAlerts).Methods("GET")
|
r.HandleFunc("/api/v1/crowdsec/summary", csec.GetSummary).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecGetDecisions).Methods("GET")
|
r.HandleFunc("/api/v1/crowdsec/alerts", csec.GetAlerts).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecAddDecision).Methods("POST")
|
r.HandleFunc("/api/v1/crowdsec/decisions", csec.GetDecisions).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/crowdsec/decisions/{id}", api.CrowdSecDeleteDecision).Methods("DELETE")
|
r.HandleFunc("/api/v1/crowdsec/decisions", csec.AddDecision).Methods("POST")
|
||||||
r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", api.CrowdSecDeleteDecisionByIP).Methods("DELETE")
|
r.HandleFunc("/api/v1/crowdsec/decisions/{id}", csec.DeleteDecision).Methods("DELETE")
|
||||||
r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecGetMachines).Methods("GET")
|
r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", csec.DeleteDecisionByIP).Methods("DELETE")
|
||||||
r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecAddMachine).Methods("POST")
|
r.HandleFunc("/api/v1/crowdsec/machines", csec.GetMachines).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/crowdsec/machines/{name}", api.CrowdSecDeleteMachine).Methods("DELETE")
|
r.HandleFunc("/api/v1/crowdsec/machines", csec.AddMachine).Methods("POST")
|
||||||
r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecGetBouncers).Methods("GET")
|
r.HandleFunc("/api/v1/crowdsec/machines/{name}", csec.DeleteMachine).Methods("DELETE")
|
||||||
r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecAddBouncer).Methods("POST")
|
r.HandleFunc("/api/v1/crowdsec/bouncers", csec.GetBouncers).Methods("GET")
|
||||||
r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", api.CrowdSecDeleteBouncer).Methods("DELETE")
|
r.HandleFunc("/api/v1/crowdsec/bouncers", csec.AddBouncer).Methods("POST")
|
||||||
|
r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", csec.DeleteBouncer).Methods("DELETE")
|
||||||
|
|
||||||
// Cloudflare management
|
// Cloudflare management
|
||||||
r.HandleFunc("/api/v1/cloudflare/verify", api.CloudflareVerify).Methods("GET")
|
r.HandleFunc("/api/v1/cloudflare/verify", api.CloudflareVerify).Methods("GET")
|
||||||
|
|||||||
@@ -114,91 +114,73 @@ func (api *API) AutheliaUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = api.secrets.SetSecret("authelia.smtpPassword", *req.SMTPPassword)
|
_ = api.secrets.SetSecret("authelia.smtpPassword", *req.SMTPPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enabling Authelia
|
|
||||||
if state.Cloud.Authelia.Enabled {
|
if state.Cloud.Authelia.Enabled {
|
||||||
if !api.authelia.IsInstalled() {
|
if err := api.enableAuthelia(state); err != nil {
|
||||||
respondError(w, http.StatusBadRequest, "Authelia is not installed. Install it first (apt install authelia).")
|
respondError(w, http.StatusInternalServerError, err.Error())
|
||||||
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))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Disabling — stop service and deregister domain
|
api.disableAuthelia(state)
|
||||||
_ = api.authelia.StopService()
|
}
|
||||||
api.deregisterAuthDomain()
|
|
||||||
|
|
||||||
if err := config.SaveState(state, api.statePath()); err != nil {
|
if err := config.SaveState(state, api.statePath()); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
go api.reconcileNetworking()
|
go api.reconciler.Reconcile()
|
||||||
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
|
api.broadcastAutheliaEvent("authelia:config", "Authelia configuration updated")
|
||||||
}
|
|
||||||
|
|
||||||
respondMessage(w, http.StatusOK, "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
|
// AutheliaRestart restarts the Authelia service
|
||||||
func (api *API) AutheliaRestart(w http.ResponseWriter, r *http.Request) {
|
func (api *API) AutheliaRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := api.authelia.RestartService(); err != nil {
|
if err := api.authelia.RestartService(); err != nil {
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ func (api *API) CertProvision(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trigger reconciliation so HAProxy picks up the new cert
|
// Trigger reconciliation so HAProxy picks up the new cert
|
||||||
go api.reconcileNetworking()
|
go api.reconciler.Reconcile()
|
||||||
|
|
||||||
status := api.certbot.GetStatus(domain)
|
status := api.certbot.GetStatus(domain)
|
||||||
respondJSON(w, http.StatusOK, map[string]any{
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
@@ -171,7 +171,7 @@ func (api *API) CertRenew(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Trigger reconciliation
|
// Trigger reconciliation
|
||||||
go api.reconcileNetworking()
|
go api.reconciler.Reconcile()
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"message": "Certificates renewed"})
|
respondJSON(w, http.StatusOK, map[string]string{"message": "Certificates renewed"})
|
||||||
}
|
}
|
||||||
@@ -184,4 +184,3 @@ func (api *API) getCentralDomain() string {
|
|||||||
}
|
}
|
||||||
return globalCfg.Cloud.Central.Domain
|
return globalCfg.Cloud.Central.Domain
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,12 +8,36 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"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
|
// CrowdSecStatus returns whether CrowdSec is running on Wild Central
|
||||||
// and lists registered machines and bouncers.
|
// and lists registered machines and bouncers.
|
||||||
func (api *API) CrowdSecStatus(w http.ResponseWriter, r *http.Request) {
|
func (h *crowdsecHandlers) Status(w http.ResponseWriter, r *http.Request) {
|
||||||
status, err := api.crowdsec.GetStatus()
|
status, err := h.crowdsec.GetStatus()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CrowdSec status: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CrowdSec status: %v", err))
|
||||||
return
|
return
|
||||||
@@ -21,10 +45,9 @@ func (api *API) CrowdSecStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, status)
|
respondJSON(w, http.StatusOK, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrowdSecGetSummary returns aggregated ban counts by scenario across all active decisions.
|
// GetSummary returns aggregated ban counts by scenario across all active decisions.
|
||||||
// This includes CAPI community bans — can return tens of thousands of entries.
|
func (h *crowdsecHandlers) GetSummary(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) CrowdSecGetSummary(w http.ResponseWriter, r *http.Request) {
|
summary, err := h.crowdsec.GetBanSummary()
|
||||||
summary, err := api.crowdsec.GetBanSummary()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get ban summary: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get ban summary: %v", err))
|
||||||
return
|
return
|
||||||
@@ -32,9 +55,9 @@ func (api *API) CrowdSecGetSummary(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, summary)
|
respondJSON(w, http.StatusOK, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrowdSecGetDecisions returns active ban/captcha decisions
|
// GetDecisions returns active ban/captcha decisions
|
||||||
func (api *API) CrowdSecGetDecisions(w http.ResponseWriter, r *http.Request) {
|
func (h *crowdsecHandlers) GetDecisions(w http.ResponseWriter, r *http.Request) {
|
||||||
decisions, err := api.crowdsec.GetDecisions()
|
decisions, err := h.crowdsec.GetDecisions()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get decisions: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get decisions: %v", err))
|
||||||
return
|
return
|
||||||
@@ -42,24 +65,23 @@ func (api *API) CrowdSecGetDecisions(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, map[string]any{"decisions": decisions})
|
respondJSON(w, http.StatusOK, map[string]any{"decisions": decisions})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrowdSecDeleteDecision removes a ban decision by numeric ID
|
// DeleteDecision removes a ban decision by numeric ID
|
||||||
func (api *API) CrowdSecDeleteDecision(w http.ResponseWriter, r *http.Request) {
|
func (h *crowdsecHandlers) DeleteDecision(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
id, err := strconv.Atoi(vars["id"])
|
id, err := strconv.Atoi(vars["id"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusBadRequest, "invalid decision ID")
|
respondError(w, http.StatusBadRequest, "invalid decision ID")
|
||||||
return
|
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))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete decision: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"message": "Decision deleted"})
|
respondJSON(w, http.StatusOK, map[string]string{"message": "Decision deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrowdSecAddDecision adds a manual ban or allow decision for an IP.
|
// AddDecision adds a manual ban or allow decision for an IP.
|
||||||
// Body: { "ip": "1.2.3.4", "type": "ban", "reason": "manual", "duration": "24h" }
|
func (h *crowdsecHandlers) AddDecision(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) CrowdSecAddDecision(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
var req struct {
|
||||||
IP string `json:"ip"`
|
IP string `json:"ip"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -84,22 +106,22 @@ func (api *API) CrowdSecAddDecision(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Duration == "" {
|
if req.Duration == "" {
|
||||||
req.Duration = "24h"
|
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))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add decision: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"message": fmt.Sprintf("Decision added: %s %s for %s", req.Type, req.IP, req.Duration)})
|
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
|
// DeleteDecisionByIP removes all decisions for a specific IP address
|
||||||
func (api *API) CrowdSecDeleteDecisionByIP(w http.ResponseWriter, r *http.Request) {
|
func (h *crowdsecHandlers) DeleteDecisionByIP(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
ip := vars["ip"]
|
ip := vars["ip"]
|
||||||
if ip == "" {
|
if ip == "" {
|
||||||
respondError(w, http.StatusBadRequest, "ip is required")
|
respondError(w, http.StatusBadRequest, "ip is required")
|
||||||
return
|
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
|
// 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") {
|
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})
|
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})
|
respondJSON(w, http.StatusOK, map[string]string{"message": "Decisions removed for " + ip})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrowdSecGetAlerts returns recent detection events from registered agents.
|
// GetAlerts returns recent detection events from registered agents.
|
||||||
// Query param: limit (default 50)
|
func (h *crowdsecHandlers) GetAlerts(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) CrowdSecGetAlerts(w http.ResponseWriter, r *http.Request) {
|
|
||||||
limit := 50
|
limit := 50
|
||||||
if l := r.URL.Query().Get("limit"); l != "" {
|
if l := r.URL.Query().Get("limit"); l != "" {
|
||||||
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 {
|
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 {
|
||||||
limit = n
|
limit = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
alerts, err := api.crowdsec.GetAlerts(limit)
|
alerts, err := h.crowdsec.GetAlerts(limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get alerts: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get alerts: %v", err))
|
||||||
return
|
return
|
||||||
@@ -128,9 +149,9 @@ func (api *API) CrowdSecGetAlerts(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, map[string]any{"alerts": alerts})
|
respondJSON(w, http.StatusOK, map[string]any{"alerts": alerts})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrowdSecGetMachines returns all registered CrowdSec agent machines
|
// GetMachines returns all registered CrowdSec agent machines
|
||||||
func (api *API) CrowdSecGetMachines(w http.ResponseWriter, r *http.Request) {
|
func (h *crowdsecHandlers) GetMachines(w http.ResponseWriter, r *http.Request) {
|
||||||
machines, err := api.crowdsec.GetMachines()
|
machines, err := h.crowdsec.GetMachines()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get machines: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get machines: %v", err))
|
||||||
return
|
return
|
||||||
@@ -138,20 +159,20 @@ func (api *API) CrowdSecGetMachines(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, map[string]any{"machines": machines})
|
respondJSON(w, http.StatusOK, map[string]any{"machines": machines})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrowdSecDeleteMachine removes a registered machine
|
// DeleteMachine removes a registered machine
|
||||||
func (api *API) CrowdSecDeleteMachine(w http.ResponseWriter, r *http.Request) {
|
func (h *crowdsecHandlers) DeleteMachine(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
name := vars["name"]
|
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))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete machine: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"message": "Machine deleted"})
|
respondJSON(w, http.StatusOK, map[string]string{"message": "Machine deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrowdSecGetBouncers returns all registered bouncers
|
// GetBouncers returns all registered bouncers
|
||||||
func (api *API) CrowdSecGetBouncers(w http.ResponseWriter, r *http.Request) {
|
func (h *crowdsecHandlers) GetBouncers(w http.ResponseWriter, r *http.Request) {
|
||||||
bouncers, err := api.crowdsec.GetBouncers()
|
bouncers, err := h.crowdsec.GetBouncers()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get bouncers: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get bouncers: %v", err))
|
||||||
return
|
return
|
||||||
@@ -159,20 +180,19 @@ func (api *API) CrowdSecGetBouncers(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, map[string]any{"bouncers": bouncers})
|
respondJSON(w, http.StatusOK, map[string]any{"bouncers": bouncers})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrowdSecDeleteBouncer removes a registered bouncer
|
// DeleteBouncer removes a registered bouncer
|
||||||
func (api *API) CrowdSecDeleteBouncer(w http.ResponseWriter, r *http.Request) {
|
func (h *crowdsecHandlers) DeleteBouncer(w http.ResponseWriter, r *http.Request) {
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
name := vars["name"]
|
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))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete bouncer: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"message": "Bouncer deleted"})
|
respondJSON(w, http.StatusOK, map[string]string{"message": "Bouncer deleted"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrowdSecAddMachine registers a new CrowdSec agent machine with pre-generated credentials.
|
// AddMachine registers a new CrowdSec agent machine with pre-generated credentials.
|
||||||
// Body: { "name": "...", "password": "..." }
|
func (h *crowdsecHandlers) AddMachine(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) CrowdSecAddMachine(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Password string `json:"password"`
|
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")
|
respondError(w, http.StatusBadRequest, "name and password are required")
|
||||||
return
|
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))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add machine: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusCreated, map[string]string{"message": fmt.Sprintf("Machine %s registered", req.Name)})
|
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.
|
// AddBouncer registers a new bouncer with a pre-generated API key.
|
||||||
// Body: { "name": "...", "apiKey": "..." }
|
func (h *crowdsecHandlers) AddBouncer(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) CrowdSecAddBouncer(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
APIKey string `json:"apiKey"`
|
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")
|
respondError(w, http.StatusBadRequest, "name and apiKey are required")
|
||||||
return
|
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))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add bouncer: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ func (api *API) DNSFilterUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Reconcile to add/remove addn-hosts from dnsmasq config
|
// 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")
|
api.broadcastDNSFilterEvent("dns-filter:config", "DNS filter configuration updated")
|
||||||
respondMessage(w, http.StatusOK, "DNS filter configuration updated")
|
respondMessage(w, http.StatusOK, "DNS filter configuration updated")
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
"gopkg.in/yaml.v3"
|
|
||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"github.com/wild-cloud/wild-central/internal/config"
|
||||||
"github.com/wild-cloud/wild-central/internal/storage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DnsmasqStatus returns the status of the dnsmasq service
|
// DnsmasqStatus returns the status of the dnsmasq service
|
||||||
@@ -259,8 +257,10 @@ func (api *API) DnsmasqDHCPAddStatic(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
globalConfigPath := api.statePath()
|
if err := api.modifyState(func(state *config.State) error {
|
||||||
if err := api.addDHCPStaticLease(globalConfigPath, req); err != nil {
|
state.AddDHCPStaticLease(req)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add static lease: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add static lease: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -280,8 +280,10 @@ func (api *API) DnsmasqDHCPDeleteStatic(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
globalConfigPath := api.statePath()
|
if err := api.modifyState(func(state *config.State) error {
|
||||||
if err := api.removeDHCPStaticLease(globalConfigPath, mac); err != nil {
|
state.RemoveDHCPStaticLease(mac)
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove static lease: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove static lease: %v", err))
|
||||||
return
|
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"})
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
// 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.
|
// then updates nftables to match. Both operations happen together so ports stay in sync.
|
||||||
func (api *API) HaproxyGenerate(w http.ResponseWriter, r *http.Request) {
|
func (api *API) HaproxyGenerate(w http.ResponseWriter, r *http.Request) {
|
||||||
api.reconcileNetworking()
|
api.reconciler.Reconcile()
|
||||||
|
|
||||||
content, err := api.haproxy.ReadConfig()
|
content, err := api.haproxy.ReadConfig()
|
||||||
if err != nil {
|
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})
|
respondJSON(w, http.StatusOK, map[string]any{"backends": stats})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ import (
|
|||||||
"github.com/wild-cloud/wild-central/internal/haproxy"
|
"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
|
// TestReconciliation_HAProxyConfigFromDomains verifies that the reconciliation
|
||||||
// logic correctly maps registered domains to HAProxy configuration. This
|
// logic correctly maps registered domains to HAProxy configuration. This
|
||||||
// replicates the route-building logic from reconcileNetworking() and asserts
|
// replicates the route-building logic from reconcileNetworking() and asserts
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/config"
|
"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.
|
// 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
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveState writes state to disk and returns an error suitable for HTTP responses.
|
// modifyState atomically reads state, applies fn, and writes it back under a
|
||||||
func (api *API) saveState(state *config.State) error {
|
// 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())
|
return config.SaveState(state, api.statePath())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Operator ---
|
// --- Operator ---
|
||||||
@@ -37,15 +50,18 @@ func (api *API) UpdateOperator(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state := api.loadState()
|
var result any
|
||||||
|
if err := api.modifyState(func(state *config.State) error {
|
||||||
state.Operator.Email = updates.Email
|
state.Operator.Email = updates.Email
|
||||||
if err := api.saveState(state); err != nil {
|
result = state.Operator
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("operator updated", "email", updates.Email)
|
slog.Info("operator updated", "email", updates.Email)
|
||||||
respondJSON(w, http.StatusOK, state.Operator)
|
respondJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Central Domain ---
|
// --- Central Domain ---
|
||||||
@@ -65,9 +81,10 @@ func (api *API) UpdateCentralDomain(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state := api.loadState()
|
if err := api.modifyState(func(state *config.State) error {
|
||||||
state.Cloud.Central.Domain = updates.Domain
|
state.Cloud.Central.Domain = updates.Domain
|
||||||
if err := api.saveState(state); err != nil {
|
return nil
|
||||||
|
}); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -95,7 +112,8 @@ func (api *API) UpdateDDNSConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state := api.loadState()
|
var result any
|
||||||
|
if err := api.modifyState(func(state *config.State) error {
|
||||||
if updates.Enabled != nil {
|
if updates.Enabled != nil {
|
||||||
state.Cloud.DDNS.Enabled = *updates.Enabled
|
state.Cloud.DDNS.Enabled = *updates.Enabled
|
||||||
}
|
}
|
||||||
@@ -105,15 +123,17 @@ func (api *API) UpdateDDNSConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
if updates.IntervalMinutes != nil {
|
if updates.IntervalMinutes != nil {
|
||||||
state.Cloud.DDNS.IntervalMinutes = *updates.IntervalMinutes
|
state.Cloud.DDNS.IntervalMinutes = *updates.IntervalMinutes
|
||||||
}
|
}
|
||||||
if err := api.saveState(state); err != nil {
|
result = state.Cloud.DDNS
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
go api.reloadDDNSIfEnabled()
|
go api.reloadDDNSIfEnabled()
|
||||||
|
|
||||||
slog.Info("DDNS config updated", "enabled", state.Cloud.DDNS.Enabled)
|
slog.Info("DDNS config updated")
|
||||||
respondJSON(w, http.StatusOK, state.Cloud.DDNS)
|
respondJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DNS Settings (dnsmasq IP/interface) ---
|
// --- DNS Settings (dnsmasq IP/interface) ---
|
||||||
@@ -136,23 +156,26 @@ func (api *API) UpdateDNSSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state := api.loadState()
|
var result map[string]string
|
||||||
|
if err := api.modifyState(func(state *config.State) error {
|
||||||
if updates.IP != "" {
|
if updates.IP != "" {
|
||||||
state.Cloud.Dnsmasq.IP = updates.IP
|
state.Cloud.Dnsmasq.IP = updates.IP
|
||||||
}
|
}
|
||||||
if updates.Interface != "" {
|
if updates.Interface != "" {
|
||||||
state.Cloud.Dnsmasq.Interface = updates.Interface
|
state.Cloud.Dnsmasq.Interface = updates.Interface
|
||||||
}
|
}
|
||||||
if err := api.saveState(state); err != nil {
|
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")
|
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("DNS settings updated", "ip", state.Cloud.Dnsmasq.IP)
|
slog.Info("DNS settings updated", "ip", result["ip"])
|
||||||
respondJSON(w, http.StatusOK, map[string]string{
|
respondJSON(w, http.StatusOK, result)
|
||||||
"ip": state.Cloud.Dnsmasq.IP,
|
|
||||||
"interface": state.Cloud.Dnsmasq.Interface,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DHCP Config ---
|
// --- DHCP Config ---
|
||||||
@@ -174,7 +197,8 @@ func (api *API) UpdateDHCPConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state := api.loadState()
|
var result any
|
||||||
|
if err := api.modifyState(func(state *config.State) error {
|
||||||
dhcp := &state.Cloud.Dnsmasq.DHCP
|
dhcp := &state.Cloud.Dnsmasq.DHCP
|
||||||
if updates.Enabled != nil {
|
if updates.Enabled != nil {
|
||||||
dhcp.Enabled = *updates.Enabled
|
dhcp.Enabled = *updates.Enabled
|
||||||
@@ -191,13 +215,15 @@ func (api *API) UpdateDHCPConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
if updates.Gateway != "" {
|
if updates.Gateway != "" {
|
||||||
dhcp.Gateway = updates.Gateway
|
dhcp.Gateway = updates.Gateway
|
||||||
}
|
}
|
||||||
if err := api.saveState(state); err != nil {
|
result = state.Cloud.Dnsmasq.DHCP
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("DHCP config updated", "enabled", dhcp.Enabled)
|
slog.Info("DHCP config updated")
|
||||||
respondJSON(w, http.StatusOK, state.Cloud.Dnsmasq.DHCP)
|
respondJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- nftables Config ---
|
// --- nftables Config ---
|
||||||
@@ -217,7 +243,8 @@ func (api *API) UpdateNftablesConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state := api.loadState()
|
var result any
|
||||||
|
if err := api.modifyState(func(state *config.State) error {
|
||||||
if updates.Enabled != nil {
|
if updates.Enabled != nil {
|
||||||
state.Cloud.Nftables.Enabled = updates.Enabled
|
state.Cloud.Nftables.Enabled = updates.Enabled
|
||||||
}
|
}
|
||||||
@@ -227,13 +254,15 @@ func (api *API) UpdateNftablesConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
if updates.ExtraPorts != nil {
|
if updates.ExtraPorts != nil {
|
||||||
state.Cloud.Nftables.ExtraPorts = updates.ExtraPorts
|
state.Cloud.Nftables.ExtraPorts = updates.ExtraPorts
|
||||||
}
|
}
|
||||||
if err := api.saveState(state); err != nil {
|
result = state.Cloud.Nftables
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("nftables config updated")
|
slog.Info("nftables config updated")
|
||||||
respondJSON(w, http.StatusOK, state.Cloud.Nftables)
|
respondJSON(w, http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- HAProxy Custom Routes ---
|
// --- HAProxy Custom Routes ---
|
||||||
@@ -253,15 +282,18 @@ func (api *API) UpdateHAProxyRoutes(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state := api.loadState()
|
var result any
|
||||||
|
if err := api.modifyState(func(state *config.State) error {
|
||||||
state.Cloud.HAProxy.CustomRoutes = updates.CustomRoutes
|
state.Cloud.HAProxy.CustomRoutes = updates.CustomRoutes
|
||||||
if err := api.saveState(state); err != nil {
|
result = map[string]any{
|
||||||
|
"customRoutes": state.Cloud.HAProxy.CustomRoutes,
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
respondError(w, http.StatusInternalServerError, "Failed to save state")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("HAProxy custom routes updated", "count", len(updates.CustomRoutes))
|
slog.Info("HAProxy custom routes updated", "count", len(updates.CustomRoutes))
|
||||||
respondJSON(w, http.StatusOK, map[string]any{
|
respondJSON(w, http.StatusOK, result)
|
||||||
"customRoutes": state.Cloud.HAProxy.CustomRoutes,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,30 @@ import (
|
|||||||
"github.com/wild-cloud/wild-central/internal/wireguard"
|
"github.com/wild-cloud/wild-central/internal/wireguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VpnStatus returns the current WireGuard interface status.
|
// VPNManager is the interface for VPN operations used by these handlers.
|
||||||
func (api *API) VpnStatus(w http.ResponseWriter, r *http.Request) {
|
type VPNManager interface {
|
||||||
status, err := api.vpn.GetStatus()
|
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 {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get VPN status: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get VPN status: %v", err))
|
||||||
return
|
return
|
||||||
@@ -21,9 +42,8 @@ func (api *API) VpnStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusOK, status)
|
respondJSON(w, http.StatusOK, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// VpnGetConfig returns the server interface configuration and public key.
|
func (h *vpnHandlers) GetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) VpnGetConfig(w http.ResponseWriter, r *http.Request) {
|
cfg, err := h.vpn.GetConfig()
|
||||||
cfg, err := api.vpn.GetConfig()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get VPN config: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get VPN config: %v", err))
|
||||||
return
|
return
|
||||||
@@ -35,12 +55,11 @@ func (api *API) VpnGetConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
"endpoint": cfg.Endpoint,
|
"endpoint": cfg.Endpoint,
|
||||||
"dns": cfg.DNS,
|
"dns": cfg.DNS,
|
||||||
"lanCIDR": cfg.LanCIDR,
|
"lanCIDR": cfg.LanCIDR,
|
||||||
"publicKey": api.vpn.GetPublicKey(),
|
"publicKey": h.vpn.GetPublicKey(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// VpnUpdateConfig updates the server interface configuration.
|
func (h *vpnHandlers) UpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) VpnUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req wireguard.Config
|
var req wireguard.Config
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
|
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 {
|
if req.ListenPort == 0 {
|
||||||
req.ListenPort = 51820
|
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))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to save VPN config: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Resync nftables so the VPN listen port is automatically allowed/removed
|
// Resync nftables so the VPN listen port is automatically allowed/removed
|
||||||
if globalCfg, err := config.LoadState(api.statePath()); err == nil {
|
if globalCfg, err := config.LoadState(h.statePath); err == nil {
|
||||||
go api.syncNftablesOnly(globalCfg)
|
go h.syncNftables(globalCfg)
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"message": "VPN configuration saved"})
|
respondJSON(w, http.StatusOK, map[string]string{"message": "VPN configuration saved"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// VpnGenerateKeypair generates a new server keypair.
|
func (h *vpnHandlers) GenerateKeypair(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) VpnGenerateKeypair(w http.ResponseWriter, r *http.Request) {
|
if err := h.vpn.GenerateKeypair(); err != nil {
|
||||||
if err := api.vpn.GenerateKeypair(); err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate keypair: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate keypair: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]string{
|
respondJSON(w, http.StatusOK, map[string]string{
|
||||||
"message": "Server keypair generated",
|
"message": "Server keypair generated",
|
||||||
"publicKey": api.vpn.GetPublicKey(),
|
"publicKey": h.vpn.GetPublicKey(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// VpnApply writes the wg0.conf and brings the WireGuard interface up.
|
func (h *vpnHandlers) Apply(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) VpnApply(w http.ResponseWriter, r *http.Request) {
|
if err := h.vpn.Apply(); err != nil {
|
||||||
if err := api.vpn.Apply(); err != nil {
|
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to apply VPN config: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to apply VPN config: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"message": "WireGuard interface applied successfully"})
|
respondJSON(w, http.StatusOK, map[string]string{"message": "WireGuard interface applied successfully"})
|
||||||
}
|
}
|
||||||
|
|
||||||
// VpnListPeers returns all configured peers (without private keys).
|
func (h *vpnHandlers) ListPeers(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) VpnListPeers(w http.ResponseWriter, r *http.Request) {
|
peers, err := h.vpn.ListPeers()
|
||||||
peers, err := api.vpn.ListPeers()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list peers: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list peers: %v", err))
|
||||||
return
|
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)})
|
respondJSON(w, http.StatusOK, map[string]any{"peers": redactPeers(peers)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// VpnAddPeer adds a new peer, auto-generating a keypair and assigning an IP.
|
func (h *vpnHandlers) AddPeer(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) VpnAddPeer(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
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")
|
respondError(w, http.StatusBadRequest, "name is required")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
peer, err := api.vpn.AddPeer(req.Name)
|
peer, err := h.vpn.AddPeer(req.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add peer: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add peer: %v", err))
|
||||||
return
|
return
|
||||||
@@ -108,15 +123,14 @@ func (api *API) VpnAddPeer(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondJSON(w, http.StatusCreated, redactPeer(peer))
|
respondJSON(w, http.StatusCreated, redactPeer(peer))
|
||||||
}
|
}
|
||||||
|
|
||||||
// VpnGetPeerConfig returns the wg-quick config text for a peer (used by clients and QR generation).
|
func (h *vpnHandlers) GetPeerConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) VpnGetPeerConfig(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := mux.Vars(r)["id"]
|
id := mux.Vars(r)["id"]
|
||||||
text, err := api.vpn.GeneratePeerConfigText(id)
|
text, err := h.vpn.GeneratePeerConfigText(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate peer config: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate peer config: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
peer, err := api.vpn.GetPeer(id)
|
peer, err := h.vpn.GetPeer(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get peer: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get peer: %v", err))
|
||||||
return
|
return
|
||||||
@@ -127,10 +141,9 @@ func (api *API) VpnGetPeerConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// VpnDeletePeer removes a peer by ID.
|
func (h *vpnHandlers) DeletePeer(w http.ResponseWriter, r *http.Request) {
|
||||||
func (api *API) VpnDeletePeer(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id := mux.Vars(r)["id"]
|
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))
|
respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to delete peer: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,10 @@ package v1
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"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/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/sse"
|
||||||
"github.com/wild-cloud/wild-central/internal/storage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// EnsureCentralDomain registers (or updates) Central's own domain as a domain
|
// 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
|
// Reconcile runs networking reconciliation immediately. Called on startup
|
||||||
// and automatically whenever a domain is registered/updated/deregistered.
|
// and automatically whenever a domain is registered/updated/deregistered.
|
||||||
func (api *API) Reconcile() {
|
func (api *API) Reconcile() {
|
||||||
api.reconcileNetworking()
|
api.reconciler.Reconcile()
|
||||||
}
|
|
||||||
|
|
||||||
// reconcileNetworking reads all registered domains and regenerates
|
|
||||||
// dnsmasq DNS entries and HAProxy routes to match.
|
|
||||||
func (api *API) reconcileNetworking() {
|
|
||||||
doms, err := api.domains.List()
|
|
||||||
if err != nil {
|
|
||||||
slog.Error("reconcile: failed to list domains", "error", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
globalCfg, err := config.LoadState(api.statePath())
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("reconcile: failed to load state, using empty", "error", err)
|
|
||||||
globalCfg = &config.State{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build HAProxy routes from registered domains
|
|
||||||
var l4Routes []haproxy.L4Route
|
|
||||||
var httpRoutes []haproxy.HTTPRoute
|
|
||||||
|
|
||||||
for _, dom := range doms {
|
|
||||||
switch dom.EffectiveBackendType() {
|
|
||||||
case domains.BackendTCPPassthrough:
|
|
||||||
l4Routes = append(l4Routes, haproxy.L4Route{
|
|
||||||
Name: dom.DomainName,
|
|
||||||
Domain: dom.DomainName,
|
|
||||||
BackendIP: extractHost(dom.EffectiveBackendAddress()),
|
|
||||||
Subdomains: dom.Subdomains,
|
|
||||||
})
|
|
||||||
case domains.BackendDNSOnly:
|
|
||||||
// dns-only: no gateway routing, just DNS resolution
|
|
||||||
case domains.BackendHTTP:
|
|
||||||
var routeBackends []haproxy.HTTPRouteBackend
|
|
||||||
for _, r := range dom.EffectiveRoutes() {
|
|
||||||
rb := haproxy.HTTPRouteBackend{
|
|
||||||
Paths: r.Paths,
|
|
||||||
Backend: r.Backend.Address,
|
|
||||||
HealthPath: r.Backend.Health,
|
|
||||||
Headers: r.Headers,
|
|
||||||
IPAllow: r.IPAllow,
|
|
||||||
}
|
|
||||||
if dom.Auth != nil && dom.Auth.Enabled {
|
|
||||||
rb.AuthEnabled = true
|
|
||||||
rb.AuthPolicy = dom.Auth.Policy
|
|
||||||
}
|
|
||||||
routeBackends = append(routeBackends, rb)
|
|
||||||
}
|
|
||||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
|
||||||
Name: dom.DomainName,
|
|
||||||
Domain: dom.DomainName,
|
|
||||||
Subdomains: dom.Subdomains,
|
|
||||||
Routes: routeBackends,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up any 0-byte cert files that would poison HAProxy validation.
|
|
||||||
// A 0-byte .pem in the certs directory causes the global `bind ssl crt`
|
|
||||||
// to fail, taking down ALL L7 routes — not just the broken domain.
|
|
||||||
certsDir := "/etc/haproxy/certs/"
|
|
||||||
if entries, err := os.ReadDir(certsDir); err == nil {
|
|
||||||
for _, e := range entries {
|
|
||||||
if strings.HasSuffix(e.Name(), ".pem") {
|
|
||||||
if info, err := e.Info(); err == nil && info.Size() == 0 {
|
|
||||||
slog.Warn("reconcile: removing 0-byte cert file", "file", e.Name())
|
|
||||||
_ = os.Remove(filepath.Join(certsDir, e.Name()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only include L7 HTTP routes for domains that have a valid cert.
|
|
||||||
var activeHTTPRoutes []haproxy.HTTPRoute
|
|
||||||
for _, route := range httpRoutes {
|
|
||||||
if hasCertForDomain(certsDir, route.Domain) {
|
|
||||||
activeHTTPRoutes = append(activeHTTPRoutes, route)
|
|
||||||
} else {
|
|
||||||
slog.Warn("reconcile: skipping L7 route (no cert)", "domain", route.Domain)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate and write HAProxy config
|
|
||||||
genOpts := haproxy.GenerateOpts{
|
|
||||||
HTTPRoutes: activeHTTPRoutes,
|
|
||||||
CertsDir: certsDir,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Propagate Authelia forward-auth settings if enabled
|
|
||||||
if globalCfg.Cloud.Authelia.Enabled && globalCfg.Cloud.Authelia.Domain != "" {
|
|
||||||
genOpts.AuthEnabled = true
|
|
||||||
genOpts.AuthBackend = "127.0.0.1:9091"
|
|
||||||
genOpts.AuthDomain = globalCfg.Cloud.Authelia.Domain
|
|
||||||
genOpts.AuthSessionDomain = authelia.SessionDomainFromAuthDomain(globalCfg.Cloud.Authelia.Domain)
|
|
||||||
|
|
||||||
// Regenerate Authelia config so session cookie domains stay in sync
|
|
||||||
// with the set of auth-protected domains
|
|
||||||
if err := api.generateAutheliaConfig(globalCfg); err != nil {
|
|
||||||
slog.Warn("reconcile: failed to regenerate authelia config", "error", err)
|
|
||||||
} else if api.authelia.UserCount() > 0 {
|
|
||||||
_ = api.authelia.RestartService()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
haproxyCfg := api.haproxy.GenerateWithOpts(l4Routes, nil, genOpts)
|
|
||||||
|
|
||||||
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
|
||||||
// Validation failed — identify and exclude broken domains, then retry
|
|
||||||
brokenDomains := haproxy.FindBrokenServices(haproxyCfg, haproxy.ParseValidationErrors(err.Error()))
|
|
||||||
if len(brokenDomains) > 0 {
|
|
||||||
slog.Error("reconcile: excluding broken domains and retrying",
|
|
||||||
"broken", brokenDomains, "error", err)
|
|
||||||
|
|
||||||
exclude := map[string]bool{}
|
|
||||||
for _, d := range brokenDomains {
|
|
||||||
exclude[d] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var filteredInstances []haproxy.L4Route
|
|
||||||
for _, r := range l4Routes {
|
|
||||||
if !exclude[r.Domain] {
|
|
||||||
filteredInstances = append(filteredInstances, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var filteredHTTP []haproxy.HTTPRoute
|
|
||||||
for _, r := range activeHTTPRoutes {
|
|
||||||
if !exclude[r.Domain] {
|
|
||||||
filteredHTTP = append(filteredHTTP, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
genOpts.HTTPRoutes = filteredHTTP
|
|
||||||
haproxyCfg = api.haproxy.GenerateWithOpts(filteredInstances, nil, genOpts)
|
|
||||||
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
|
||||||
slog.Error("reconcile: retry also failed", "error", err)
|
|
||||||
} else {
|
|
||||||
if err := api.haproxy.ReloadService(); err != nil {
|
|
||||||
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
|
|
||||||
} else {
|
|
||||||
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated (excluded broken domains)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
slog.Error("reconcile: failed to write HAProxy config (no broken domains identified)", "error", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := api.haproxy.ReloadService(); err != nil {
|
|
||||||
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
|
|
||||||
} else {
|
|
||||||
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated from registered domains")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build dnsmasq DNS entries from registered domains.
|
|
||||||
// DNS target IP depends on backend type:
|
|
||||||
// tcp-passthrough/dns-only → backend IP (LAN traffic goes direct)
|
|
||||||
// http → Central IP (HAProxy terminates TLS)
|
|
||||||
// Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected
|
|
||||||
// (auto-detect can pick wlan0 if that's the default route).
|
|
||||||
centralIP := globalCfg.Cloud.Dnsmasq.IP
|
|
||||||
if centralIP == "" {
|
|
||||||
centralIP, _ = network.GetWildCentralIP()
|
|
||||||
}
|
|
||||||
if centralIP == "" {
|
|
||||||
centralIP = "127.0.0.1"
|
|
||||||
}
|
|
||||||
|
|
||||||
var dnsEntries []dnsmasq.DNSEntry
|
|
||||||
for _, dom := range doms {
|
|
||||||
if dom.DomainName == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
dnsIP := centralIP
|
|
||||||
bt := dom.EffectiveBackendType()
|
|
||||||
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
|
|
||||||
dnsIP = extractHost(dom.EffectiveBackendAddress())
|
|
||||||
}
|
|
||||||
|
|
||||||
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
|
|
||||||
Domain: dom.DomainName,
|
|
||||||
IP: dnsIP,
|
|
||||||
Wildcard: dom.Subdomains,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set addn-hosts path for DNS filtering
|
|
||||||
if globalCfg.Cloud.DNSFilter.Enabled {
|
|
||||||
hostsPath := api.dnsFilter.HostsFilePath()
|
|
||||||
if storage.FileExists(hostsPath) {
|
|
||||||
api.dnsmasq.SetFilterConfPath(hostsPath)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
api.dnsmasq.SetFilterConfPath("")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil {
|
|
||||||
slog.Error("reconcile: failed to update dnsmasq", "error", err)
|
|
||||||
} else {
|
|
||||||
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure TLS certs exist for HTTP domains (where Central terminates TLS).
|
|
||||||
// For domains under the gateway domain, a wildcard cert covers them all.
|
|
||||||
// For others, provision individual certs.
|
|
||||||
if len(httpRoutes) > 0 {
|
|
||||||
api.ensureTLSCerts(globalCfg, doms)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nudge DDNS to pick up any domain changes (public toggle, new domains).
|
|
||||||
// The runner reads fresh params on each check via its callback.
|
|
||||||
api.ddns.Trigger()
|
|
||||||
|
|
||||||
slog.Info("reconcile: networking updated",
|
|
||||||
"domains", len(doms),
|
|
||||||
"l4Routes", len(l4Routes),
|
|
||||||
"l7Routes", len(httpRoutes),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ensureTLSCerts logs which certificates are missing for registered domains.
|
|
||||||
// Does NOT auto-provision — cert provisioning is user-initiated via the
|
|
||||||
// Certificates page. This just logs warnings so the operator knows what's needed.
|
|
||||||
func (api *API) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) {
|
|
||||||
gatewayDomain := ""
|
|
||||||
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
|
|
||||||
gatewayDomain = parts[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, dom := range doms {
|
|
||||||
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if covered by wildcard
|
|
||||||
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
|
|
||||||
wildcardCert := certbot.HAProxyCertPath(gatewayDomain)
|
|
||||||
if _, err := os.Stat(wildcardCert); err != nil {
|
|
||||||
slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page",
|
|
||||||
"domain", dom.DomainName, "needed", "*."+gatewayDomain)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check individual cert
|
|
||||||
if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil {
|
|
||||||
slog.Warn("reconcile/tls: no cert for domain — provision via Certificates page",
|
|
||||||
"domain", dom.DomainName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// hasCertForDomain checks if a valid (non-empty) cert exists for a domain —
|
|
||||||
// either an individual cert (<domain>.pem) or a wildcard cert that covers it.
|
|
||||||
func hasCertForDomain(_, domain string) bool {
|
|
||||||
// Check individual cert
|
|
||||||
if isValidCertFile(certbot.HAProxyCertPath(domain)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
// Check wildcard certs — a *.example.com cert covers foo.example.com
|
|
||||||
parts := strings.SplitN(domain, ".", 2)
|
|
||||||
if len(parts) == 2 {
|
|
||||||
wildcardBase := parts[1]
|
|
||||||
if isValidCertFile(certbot.HAProxyCertPath(wildcardBase)) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidCertFile checks that a cert file exists and is non-empty.
|
|
||||||
func isValidCertFile(path string) bool {
|
|
||||||
info, err := os.Stat(path)
|
|
||||||
return err == nil && info.Size() > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractHost gets the host part from a host:port string
|
|
||||||
func extractHost(addr string) string {
|
|
||||||
for i := len(addr) - 1; i >= 0; i-- {
|
|
||||||
if addr[i] == ':' {
|
|
||||||
return addr[:i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return addr
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcastDnsmasqEvent broadcasts SSE events for dnsmasq status changes
|
// broadcastDnsmasqEvent broadcasts SSE events for dnsmasq status changes
|
||||||
|
|||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -73,5 +73,3 @@ func RequestLoggingMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,4 +33,3 @@ func respondMessage(w http.ResponseWriter, status int, message string) {
|
|||||||
func respondError(w http.ResponseWriter, status int, message string) {
|
func respondError(w http.ResponseWriter, status int, message string) {
|
||||||
respondJSON(w, status, APIResponse{Error: message})
|
respondJSON(w, status, APIResponse{Error: message})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,29 @@ type State struct {
|
|||||||
} `yaml:"cloud,omitempty" json:"cloud,omitempty"`
|
} `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
|
// LoadState loads state from the specified path
|
||||||
func LoadState(configPath string) (*State, error) {
|
func LoadState(configPath string) (*State, error) {
|
||||||
data, err := os.ReadFile(configPath)
|
data, err := os.ReadFile(configPath)
|
||||||
@@ -161,5 +184,3 @@ func SaveState(config *State, configPath string) error {
|
|||||||
|
|
||||||
return os.WriteFile(configPath, data, 0644)
|
return os.WriteFile(configPath, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ func (rn *Runner) run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// checkAndUpdate fetches fresh params, the current public IP, and syncs all records.
|
// 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.
|
// so there's no wasted API calls when nothing has changed.
|
||||||
func (rn *Runner) checkAndUpdate() {
|
func (rn *Runner) checkAndUpdate() {
|
||||||
rn.mu.Lock()
|
rn.mu.Lock()
|
||||||
@@ -170,10 +170,12 @@ func (rn *Runner) checkAndUpdate() {
|
|||||||
|
|
||||||
slog.Debug("DDNS: syncing records", "component", "ddns", "ip", ip, "records", p.Records)
|
slog.Debug("DDNS: syncing records", "component", "ddns", "ip", ip, "records", p.Records)
|
||||||
|
|
||||||
|
cf := &cfClient{apiToken: p.APIToken}
|
||||||
|
|
||||||
var lastErr string
|
var lastErr string
|
||||||
records := make([]RecordStatus, 0, len(p.Records))
|
records := make([]RecordStatus, 0, len(p.Records))
|
||||||
for _, record := range 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)
|
lastErr = fmt.Sprintf("updating %s: %v", record, err)
|
||||||
slog.Error("DDNS: failed to update record", "component", "ddns", "record", record, "error", 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()})
|
records = append(records, RecordStatus{Name: record, OK: false, Error: err.Error()})
|
||||||
@@ -238,6 +240,13 @@ func ipFromURL(url string) (string, error) {
|
|||||||
return ip, nil
|
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
|
// cloudflareRecord is used to find the record ID for a given hostname
|
||||||
type cloudflareRecord struct {
|
type cloudflareRecord struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -246,114 +255,72 @@ type cloudflareRecord struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateCloudflareRecord updates a single A record via the Cloudflare API.
|
// do executes an authenticated Cloudflare API request and checks the status.
|
||||||
// The record name must be a fully qualified domain name (e.g. "cloud.payne.io").
|
func (c *cfClient) do(method, url string, body io.Reader) (*http.Response, error) {
|
||||||
// If a CNAME already exists at that name (e.g. from a router DDNS service), it is
|
req, err := http.NewRequest(method, url, body)
|
||||||
// deleted first so the A record can be created without conflict.
|
if err != nil {
|
||||||
func updateCloudflareRecord(apiToken, recordName, ip string) error {
|
return nil, err
|
||||||
// Extract zone name: last two parts of the FQDN (e.g. "payne.io" from "cloud.payne.io")
|
}
|
||||||
|
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, ".")
|
parts := strings.Split(recordName, ".")
|
||||||
if len(parts) < 2 {
|
if len(parts) < 2 {
|
||||||
return fmt.Errorf("invalid record name: %s", recordName)
|
return fmt.Errorf("invalid record name: %s", recordName)
|
||||||
}
|
}
|
||||||
zoneName := strings.Join(parts[len(parts)-2:], ".")
|
zoneName := strings.Join(parts[len(parts)-2:], ".")
|
||||||
|
|
||||||
// Get zone ID
|
zoneID, err := c.getZoneID(zoneName)
|
||||||
zoneID, err := getCloudflareZoneID(apiToken, zoneName)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("getting zone ID for %s: %w", zoneName, err)
|
return fmt.Errorf("getting zone ID for %s: %w", zoneName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for existing A record — patch it if found
|
// 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 err == nil {
|
||||||
if currentIP == ip {
|
if currentIP == ip {
|
||||||
return nil // already up to date
|
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
|
// No A record. Remove any CNAME that would conflict.
|
||||||
// (common when migrating from a router-based DDNS service like GL.iNet).
|
if cnameID, _, cnameErr := c.getRecordID(zoneID, recordName, "CNAME"); cnameErr == nil {
|
||||||
if cnameID, _, cnameErr := getCloudflareRecordID(apiToken, zoneID, recordName, "CNAME"); cnameErr == nil {
|
|
||||||
slog.Info("DDNS: removing CNAME before creating A record", "component", "ddns", "record", recordName)
|
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)
|
return fmt.Errorf("removing CNAME for %s: %w", recordName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("DDNS: creating A record", "component", "ddns", "record", recordName)
|
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 (c *cfClient) getZoneID(zoneName string) (string, error) {
|
||||||
func createCloudflareRecord(apiToken, zoneID, recordName, ip string) error {
|
resp, err := c.do(http.MethodGet, "https://api.cloudflare.com/client/v4/zones?name="+zoneName, nil)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 {
|
var result struct {
|
||||||
Result []struct {
|
Result []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -368,22 +335,14 @@ func getCloudflareZoneID(apiToken, zoneName string) (string, error) {
|
|||||||
return result.Result[0].ID, nil
|
return result.Result[0].ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getCloudflareRecordID(apiToken, zoneID, recordName, recordType string) (id, currentContent string, err error) {
|
func (c *cfClient) getRecordID(zoneID, recordName, recordType string) (id, currentContent string, err error) {
|
||||||
req, _ := http.NewRequest(http.MethodGet,
|
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)
|
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 {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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 {
|
var result struct {
|
||||||
Result []cloudflareRecord `json:"result"`
|
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
|
return result.Result[0].ID, result.Result[0].Content, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteCloudflareRecord(apiToken, zoneID, recordID string) error {
|
func (c *cfClient) createRecord(zoneID, recordName, ip string) error {
|
||||||
req, _ := http.NewRequest(http.MethodDelete,
|
body, _ := json.Marshal(map[string]string{"type": "A", "name": recordName, "content": ip})
|
||||||
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID),
|
resp, err := c.do(http.MethodPost,
|
||||||
nil)
|
fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID),
|
||||||
req.Header.Set("Authorization", "Bearer "+apiToken)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("deleting record: %w", err)
|
return fmt.Errorf("deleting record: %w", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
304
internal/dnsfilter/manager_test.go
Normal file
304
internal/dnsfilter/manager_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,6 +94,31 @@ type GenerateOpts struct {
|
|||||||
func (m *Manager) GenerateWithOpts(instances []L4Route, custom []CustomRoute, opts GenerateOpts) string {
|
func (m *Manager) GenerateWithOpts(instances []L4Route, custom []CustomRoute, opts GenerateOpts) string {
|
||||||
var sb strings.Builder
|
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
|
sb.WriteString(`# Wild Cloud HAProxy Configuration
|
||||||
# Managed by Wild Cloud Central API — do not edit manually
|
# Managed by Wild Cloud Central API — do not edit manually
|
||||||
|
|
||||||
@@ -104,7 +129,7 @@ global
|
|||||||
maxconn 50000
|
maxconn 50000
|
||||||
daemon
|
daemon
|
||||||
`)
|
`)
|
||||||
if opts.AuthEnabled {
|
if authEnabled {
|
||||||
sb.WriteString(" lua-prepend-path /etc/haproxy/lua/?.lua\n")
|
sb.WriteString(" lua-prepend-path /etc/haproxy/lua/?.lua\n")
|
||||||
sb.WriteString(" lua-load /etc/haproxy/lua/haproxy-auth-request.lua\n")
|
sb.WriteString(" lua-load /etc/haproxy/lua/haproxy-auth-request.lua\n")
|
||||||
}
|
}
|
||||||
@@ -134,10 +159,15 @@ frontend http_in
|
|||||||
redirect scheme https code 301
|
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:
|
||||||
if hasL4Routes {
|
// 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("# HTTPS: SNI-based L4 routing (TLS passes through to k8s traefik)\n")
|
||||||
sb.WriteString("frontend https_in\n")
|
sb.WriteString("frontend https_in\n")
|
||||||
sb.WriteString(" bind *:443\n")
|
sb.WriteString(" bind *:443\n")
|
||||||
@@ -146,158 +176,162 @@ frontend http_in
|
|||||||
sb.WriteString(" tcp-request content accept if { req_ssl_hello_type 1 }\n")
|
sb.WriteString(" tcp-request content accept if { req_ssl_hello_type 1 }\n")
|
||||||
sb.WriteString("\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
|
// 1. L7 HTTP services — route to L7 TLS termination frontend
|
||||||
if len(opts.HTTPRoutes) > 0 {
|
if len(opts.HTTPRoutes) > 0 {
|
||||||
sb.WriteString(" # L7 HTTP services (→ TLS termination)\n")
|
sb.WriteString(" # L7 HTTP services (→ TLS termination)\n")
|
||||||
// Exact matches first
|
|
||||||
for _, route := range opts.HTTPRoutes {
|
for _, route := range opts.HTTPRoutes {
|
||||||
if route.Subdomains {
|
if route.Subdomains {
|
||||||
continue // handled below
|
continue
|
||||||
}
|
}
|
||||||
aclName := "is_l7_" + sanitizeName(route.Name)
|
aclName := "is_l7_" + sanitizeName(route.Name)
|
||||||
fmt.Fprintf(&sb, " # service: %s\n", route.Domain)
|
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, " acl %s req_ssl_sni -m str %s\n", aclName, route.Domain)
|
||||||
fmt.Fprintf(&sb, " use_backend be_l7_termination if %s\n", aclName)
|
fmt.Fprintf(sb, " use_backend be_l7_termination if %s\n", aclName)
|
||||||
}
|
}
|
||||||
// Wildcard matches after exact
|
|
||||||
for _, route := range opts.HTTPRoutes {
|
for _, route := range opts.HTTPRoutes {
|
||||||
if !route.Subdomains {
|
if !route.Subdomains {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
aclName := "is_l7_" + sanitizeName(route.Name)
|
aclName := "is_l7_" + sanitizeName(route.Name)
|
||||||
fmt.Fprintf(&sb, " # service: %s\n", route.Domain)
|
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 end .%s\n", aclName, route.Domain)
|
||||||
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %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)
|
fmt.Fprintf(sb, " use_backend be_l7_termination if %s\n", aclName)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. L4 exact matches — instances with subdomains:false
|
// 2. L4 exact matches
|
||||||
hasExact := false
|
hasExact := false
|
||||||
for _, inst := range instances {
|
for _, inst := range instances {
|
||||||
if inst.Subdomains {
|
if inst.Subdomains {
|
||||||
continue // handled in step 3
|
continue
|
||||||
}
|
}
|
||||||
hasExact = true
|
hasExact = true
|
||||||
aclName := "is_" + sanitizeName(inst.Name)
|
aclName := "is_" + sanitizeName(inst.Name)
|
||||||
fmt.Fprintf(&sb, " # service: %s\n", inst.Domain)
|
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, " 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)
|
fmt.Fprintf(sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
|
||||||
}
|
}
|
||||||
if hasExact {
|
if hasExact {
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. L4 wildcard matches — instances with subdomains:true
|
// 3. L4 wildcard matches
|
||||||
sb.WriteString(" # L4 instance routes (wildcard subdomain matching)\n")
|
sb.WriteString(" # L4 instance routes (wildcard subdomain matching)\n")
|
||||||
for _, inst := range instances {
|
for _, inst := range instances {
|
||||||
if !inst.Subdomains {
|
if !inst.Subdomains {
|
||||||
continue // already handled in step 2
|
continue
|
||||||
}
|
}
|
||||||
aclName := "is_" + sanitizeName(inst.Name)
|
aclName := "is_" + sanitizeName(inst.Name)
|
||||||
fmt.Fprintf(&sb, " # service: %s\n", inst.Domain)
|
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 end .%s\n", aclName, inst.Domain)
|
||||||
fmt.Fprintf(&sb, " acl %s req_ssl_sni -m str %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)
|
fmt.Fprintf(sb, " use_backend be_https_%s if %s\n", sanitizeName(inst.Name), aclName)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// 4. Default → L7 termination fallback
|
// 4. Default fallback
|
||||||
if len(opts.HTTPRoutes) > 0 {
|
if len(opts.HTTPRoutes) > 0 {
|
||||||
sb.WriteString(" default_backend be_l7_termination\n")
|
sb.WriteString(" default_backend be_l7_termination\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
// Instance L4 backends
|
// writeL4Backends writes L4 TCP passthrough backend definitions.
|
||||||
|
func writeL4Backends(sb *strings.Builder, instances []L4Route) {
|
||||||
for _, inst := range instances {
|
for _, inst := range instances {
|
||||||
fmt.Fprintf(&sb, "# service: %s\n", inst.Domain)
|
fmt.Fprintf(sb, "# service: %s\n", inst.Domain)
|
||||||
fmt.Fprintf(&sb, "backend be_https_%s\n", sanitizeName(inst.Name))
|
fmt.Fprintf(sb, "backend be_https_%s\n", sanitizeName(inst.Name))
|
||||||
sb.WriteString(" mode tcp\n")
|
sb.WriteString(" mode tcp\n")
|
||||||
sb.WriteString(" option tcp-check\n")
|
sb.WriteString(" option tcp-check\n")
|
||||||
fmt.Fprintf(&sb, " server s0 %s:443 check\n", inst.BackendIP)
|
fmt.Fprintf(sb, " server s0 %s:443 check\n", inst.BackendIP)
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// L7 TLS termination backend + frontend (for HTTP routes)
|
// writeL7Stack writes the L7 TLS termination backend, frontend, host ACLs,
|
||||||
if len(opts.HTTPRoutes) > 0 {
|
// per-route rules (IP whitelisting, headers, auth), routing, and backends.
|
||||||
// Load all PEM files from the certs directory — HAProxy serves
|
func writeL7Stack(sb *strings.Builder, opts GenerateOpts) {
|
||||||
// the right cert per-SNI. Each service gets its own <domain>.pem.
|
|
||||||
certPath := opts.CertsDir
|
certPath := opts.CertsDir
|
||||||
if certPath == "" {
|
if certPath == "" {
|
||||||
certPath = "/etc/haproxy/certs/"
|
certPath = "/etc/haproxy/certs/"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TLS termination backend + frontend binding
|
||||||
sb.WriteString("backend be_l7_termination\n")
|
sb.WriteString("backend be_l7_termination\n")
|
||||||
sb.WriteString(" mode tcp\n")
|
sb.WriteString(" mode tcp\n")
|
||||||
sb.WriteString(" server s0 127.0.0.1:8443\n")
|
sb.WriteString(" server s0 127.0.0.1:8443\n")
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
sb.WriteString("# L7 HTTP frontend: TLS termination + Host-based routing\n")
|
sb.WriteString("# L7 HTTP frontend: TLS termination + Host-based routing\n")
|
||||||
sb.WriteString("frontend l7_https\n")
|
sb.WriteString("frontend l7_https\n")
|
||||||
fmt.Fprintf(&sb, " bind 127.0.0.1:8443 ssl crt %s\n", certPath)
|
fmt.Fprintf(sb, " bind 127.0.0.1:8443 ssl crt %s\n", certPath)
|
||||||
sb.WriteString(" mode http\n")
|
sb.WriteString(" mode http\n")
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Host ACLs for all services
|
// Host ACLs
|
||||||
for _, httpRoute := range opts.HTTPRoutes {
|
for _, httpRoute := range opts.HTTPRoutes {
|
||||||
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
||||||
fmt.Fprintf(&sb, " # service: %s\n", httpRoute.Domain)
|
fmt.Fprintf(sb, " # service: %s\n", httpRoute.Domain)
|
||||||
if httpRoute.Subdomains {
|
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 end .%s\n", hostACL, httpRoute.Domain)
|
||||||
fmt.Fprintf(&sb, " acl %s hdr(host) -i -m str %s\n", hostACL, httpRoute.Domain)
|
fmt.Fprintf(sb, " acl %s hdr(host) -i -m str %s\n", hostACL, httpRoute.Domain)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(&sb, " acl %s hdr(host) -i %s\n", hostACL, httpRoute.Domain)
|
fmt.Fprintf(sb, " acl %s hdr(host) -i %s\n", hostACL, httpRoute.Domain)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
|
||||||
// Per-route ACLs (IP whitelisting, headers) and routing rules
|
// Per-route ACLs: IP whitelisting and headers
|
||||||
for _, httpRoute := range opts.HTTPRoutes {
|
writeL7RouteACLs(sb, opts.HTTPRoutes)
|
||||||
|
|
||||||
|
// Authelia forward-auth directives
|
||||||
|
if opts.AuthEnabled && opts.AuthBackend != "" {
|
||||||
|
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)
|
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
||||||
routeName := sanitizeName(httpRoute.Name)
|
routeName := sanitizeName(httpRoute.Name)
|
||||||
|
|
||||||
for i, rb := range httpRoute.Routes {
|
for i, rb := range httpRoute.Routes {
|
||||||
aclSuffix := fmt.Sprintf("%s_%d", routeName, i)
|
aclSuffix := fmt.Sprintf("%s_%d", routeName, i)
|
||||||
|
|
||||||
// IP whitelisting for this route
|
|
||||||
if len(rb.IPAllow) > 0 {
|
if len(rb.IPAllow) > 0 {
|
||||||
ipACL := "ip_" + aclSuffix
|
ipACL := "ip_" + aclSuffix
|
||||||
fmt.Fprintf(&sb, " acl %s src %s\n", ipACL, strings.Join(rb.IPAllow, " "))
|
fmt.Fprintf(sb, " acl %s src %s\n", ipACL, strings.Join(rb.IPAllow, " "))
|
||||||
if len(rb.Paths) > 0 {
|
if len(rb.Paths) > 0 {
|
||||||
pathACL := "path_" + aclSuffix
|
pathACL := "path_" + aclSuffix
|
||||||
fmt.Fprintf(&sb, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " "))
|
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)
|
fmt.Fprintf(sb, " http-request deny if %s %s !%s\n", hostACL, pathACL, ipACL)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(&sb, " http-request deny if %s !%s\n", hostACL, ipACL)
|
fmt.Fprintf(sb, " http-request deny if %s !%s\n", hostACL, ipACL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request headers
|
|
||||||
if rb.Headers != nil {
|
if rb.Headers != nil {
|
||||||
for _, k := range sortedKeys(rb.Headers.Request) {
|
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)
|
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) {
|
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)
|
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
|
// writeL7AuthDirectives writes Authelia forward-auth directives for eligible routes.
|
||||||
if opts.AuthEnabled && opts.AuthBackend != "" {
|
func writeL7AuthDirectives(sb *strings.Builder, opts GenerateOpts) {
|
||||||
authDomainACL := ""
|
authDomainACL := ""
|
||||||
if opts.AuthDomain != "" {
|
if opts.AuthDomain != "" {
|
||||||
authDomainACL = "host_" + sanitizeName(opts.AuthDomain)
|
authDomainACL = "host_" + sanitizeName(opts.AuthDomain)
|
||||||
@@ -311,16 +345,13 @@ frontend http_in
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip domains outside the auth session scope — forward-auth
|
// Skip domains outside the auth session scope
|
||||||
// requires shared cookies, which only work within the same
|
|
||||||
// parent domain. Other domains should use OIDC instead.
|
|
||||||
if opts.AuthSessionDomain != "" &&
|
if opts.AuthSessionDomain != "" &&
|
||||||
httpRoute.Domain != opts.AuthSessionDomain &&
|
httpRoute.Domain != opts.AuthSessionDomain &&
|
||||||
!strings.HasSuffix(httpRoute.Domain, "."+opts.AuthSessionDomain) {
|
!strings.HasSuffix(httpRoute.Domain, "."+opts.AuthSessionDomain) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any route on this domain has auth enabled
|
|
||||||
hasAuth := false
|
hasAuth := false
|
||||||
for _, rb := range httpRoute.Routes {
|
for _, rb := range httpRoute.Routes {
|
||||||
if rb.AuthEnabled {
|
if rb.AuthEnabled {
|
||||||
@@ -332,83 +363,74 @@ frontend http_in
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(&sb, " # auth: %s\n", httpRoute.Domain)
|
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 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)
|
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")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routing rules: path-specific first, then catch-all
|
// writeL7RoutingRules writes use_backend rules: path-specific first, then catch-all.
|
||||||
for _, httpRoute := range opts.HTTPRoutes {
|
func writeL7RoutingRules(sb *strings.Builder, httpRoutes []HTTPRoute) {
|
||||||
|
for _, httpRoute := range httpRoutes {
|
||||||
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
hostACL := "host_" + sanitizeName(httpRoute.Name)
|
||||||
routeName := sanitizeName(httpRoute.Name)
|
routeName := sanitizeName(httpRoute.Name)
|
||||||
|
|
||||||
// Path-specific routes first (most specific wins)
|
|
||||||
for i, rb := range httpRoute.Routes {
|
for i, rb := range httpRoute.Routes {
|
||||||
if len(rb.Paths) == 0 {
|
if len(rb.Paths) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
|
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
|
||||||
pathACL := fmt.Sprintf("path_%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, " acl %s path_beg %s\n", pathACL, strings.Join(rb.Paths, " "))
|
||||||
fmt.Fprintf(&sb, " use_backend %s if %s %s\n", backendName, hostACL, pathACL)
|
fmt.Fprintf(sb, " use_backend %s if %s %s\n", backendName, hostACL, pathACL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Catch-all route last
|
|
||||||
for i, rb := range httpRoute.Routes {
|
for i, rb := range httpRoute.Routes {
|
||||||
if len(rb.Paths) > 0 {
|
if len(rb.Paths) > 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
|
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
|
||||||
fmt.Fprintf(&sb, " use_backend %s if %s\n", backendName, hostACL)
|
fmt.Fprintf(sb, " use_backend %s if %s\n", backendName, hostACL)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
// L7 backends — one per route
|
// writeL7Backends writes L7 HTTP backend definitions — one per route.
|
||||||
for _, httpRoute := range opts.HTTPRoutes {
|
func writeL7Backends(sb *strings.Builder, httpRoutes []HTTPRoute) {
|
||||||
|
for _, httpRoute := range httpRoutes {
|
||||||
routeName := sanitizeName(httpRoute.Name)
|
routeName := sanitizeName(httpRoute.Name)
|
||||||
for i, rb := range httpRoute.Routes {
|
for i, rb := range httpRoute.Routes {
|
||||||
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
|
backendName := fmt.Sprintf("be_l7_%s_%d", routeName, i)
|
||||||
fmt.Fprintf(&sb, "# service: %s\n", httpRoute.Domain)
|
fmt.Fprintf(sb, "# service: %s\n", httpRoute.Domain)
|
||||||
fmt.Fprintf(&sb, "backend %s\n", backendName)
|
fmt.Fprintf(sb, "backend %s\n", backendName)
|
||||||
sb.WriteString(" mode http\n")
|
sb.WriteString(" mode http\n")
|
||||||
if rb.HealthPath != "" {
|
if rb.HealthPath != "" {
|
||||||
fmt.Fprintf(&sb, " option httpchk GET %s\n", rb.HealthPath)
|
fmt.Fprintf(sb, " option httpchk GET %s\n", rb.HealthPath)
|
||||||
fmt.Fprintf(&sb, " server s0 %s check\n", rb.Backend)
|
fmt.Fprintf(sb, " server s0 %s check\n", rb.Backend)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(&sb, " server s0 %s\n", rb.Backend)
|
fmt.Fprintf(sb, " server s0 %s\n", rb.Backend)
|
||||||
}
|
}
|
||||||
sb.WriteString("\n")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Authelia backend for forward-auth
|
|
||||||
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 writes custom TCP proxy frontend/backend pairs.
|
||||||
|
func writeCustomRoutes(sb *strings.Builder, custom []CustomRoute) {
|
||||||
for _, route := range custom {
|
for _, route := range custom {
|
||||||
fmt.Fprintf(&sb, "# Custom route: %s\n", route.Name)
|
fmt.Fprintf(sb, "# Custom route: %s\n", route.Name)
|
||||||
fmt.Fprintf(&sb, "frontend fe_%s\n", sanitizeName(route.Name))
|
fmt.Fprintf(sb, "frontend fe_%s\n", sanitizeName(route.Name))
|
||||||
fmt.Fprintf(&sb, " bind *:%d\n", route.Port)
|
fmt.Fprintf(sb, " bind *:%d\n", route.Port)
|
||||||
sb.WriteString(" mode tcp\n")
|
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")
|
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")
|
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")
|
sb.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sortedKeys returns the keys of a map in sorted order for deterministic output.
|
// sortedKeys returns the keys of a map in sorted order for deterministic output.
|
||||||
|
|||||||
399
internal/reconcile/reconciler.go
Normal file
399
internal/reconcile/reconciler.go
Normal file
@@ -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 (<domain>.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
|
||||||
|
}
|
||||||
286
internal/reconcile/reconciler_test.go
Normal file
286
internal/reconcile/reconciler_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -108,4 +108,3 @@ func EnsureFilePermissions(path string, perm os.FileMode) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user