From 79c0c32b981e7c90fc432353ea76bcd9c28b4b7b Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Fri, 10 Jul 2026 20:46:22 +0000 Subject: [PATCH] services -> domains --- CLAUDE.md | 30 +- internal/api/v1/handlers.go | 124 ++++-- internal/api/v1/handlers_certbot.go | 25 +- internal/api/v1/handlers_config_test.go | 26 +- internal/api/v1/handlers_ddns.go | 5 +- internal/api/v1/handlers_dnsmasq_test.go | 2 +- internal/api/v1/handlers_domains.go | 132 ++++++ ...vices_test.go => handlers_domains_test.go} | 108 ++--- internal/api/v1/handlers_haproxy_test.go | 2 +- internal/api/v1/handlers_nftables.go | 2 +- .../api/v1/handlers_reconciliation_test.go | 401 +++++++++++------- internal/api/v1/handlers_services.go | 132 ------ internal/api/v1/helpers.go | 141 +++--- internal/config/config.go | 21 +- internal/config/config_test.go | 42 +- internal/config/extraports_test.go | 10 +- internal/crowdsec/manager.go | 2 +- internal/ddns/cloudflare.go | 95 +++-- internal/ddns/cloudflare_test.go | 161 ++----- internal/dnsmasq/config.go | 6 +- internal/dnsmasq/config_modular.go | 4 +- internal/dnsmasq/config_test.go | 30 +- internal/{services => domains}/manager.go | 218 +++++----- .../{services => domains}/manager_test.go | 331 ++++++++------- internal/haproxy/config.go | 4 +- internal/haproxy/config_test.go | 6 +- internal/natsbus/server.go | 6 +- internal/natsbus/server_test.go | 6 +- internal/tools/yq.go | 73 ++++ internal/tunnel/manager.go | 10 +- internal/tunnel/manager_test.go | 14 +- web/src/components/AppSidebar.tsx | 79 ++-- web/src/components/DashboardComponent.tsx | 117 ++--- ...icesComponent.tsx => DomainsComponent.tsx} | 203 +++++---- web/src/hooks/useCentralStatus.ts | 5 + web/src/hooks/useConfig.ts | 49 ++- web/src/hooks/useDdns.ts | 10 +- .../hooks/{useServices.ts => useDomains.ts} | 10 +- web/src/router/pages/DomainsPage.tsx | 10 + web/src/router/pages/ServicesPage.tsx | 10 - web/src/router/routes.tsx | 4 +- web/src/services/api-legacy.ts | 22 +- web/src/services/api/index.ts | 4 +- web/src/services/api/networking.ts | 16 +- web/src/types/index.ts | 9 +- 45 files changed, 1447 insertions(+), 1270 deletions(-) create mode 100644 internal/api/v1/handlers_domains.go rename internal/api/v1/{handlers_services_test.go => handlers_domains_test.go} (58%) delete mode 100644 internal/api/v1/handlers_services.go rename internal/{services => domains}/manager.go (51%) rename internal/{services => domains}/manager_test.go (58%) create mode 100644 internal/tools/yq.go rename web/src/components/{ServicesComponent.tsx => DomainsComponent.tsx} (72%) rename web/src/hooks/{useServices.ts => useDomains.ts} (50%) create mode 100644 web/src/router/pages/DomainsPage.tsx delete mode 100644 web/src/router/pages/ServicesPage.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 64be923..8ba7c55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,8 +12,8 @@ Wild Central is the networking and coordination platform for the Wild product fa - **Tunnels** (cloudflared) — outbound-only public exposure via Cloudflare - **DDNS** (Cloudflare) — A record + CNAME management - **Security** (CrowdSec) — intrusion detection -- **NATS JetStream** — coordination bus for service registration, presence, and events -- **Service Registration** — the contract API for Wild Cloud and Wild Works +- **NATS JetStream** — coordination bus for domain registration, presence, and events +- **Domain Registration** — the contract API for Wild Cloud and Wild Works ## Architecture @@ -21,7 +21,7 @@ Wild Central is a Go service that manages Linux networking daemons via config fi **Consumers:** - **Wild Cloud** — registers k8s instance domains for L4 SNI passthrough -- **Wild Works** — registers services for L7 HTTP reverse proxy with TLS termination +- **Wild Works** — registers domains for L7 HTTP reverse proxy with TLS termination Both connect to Central's NATS (port 4222) or use the HTTP API (port 5055) to register services. @@ -52,23 +52,23 @@ make check # Lint + test Runtime state is persisted in `{WILD_CENTRAL_DATA_DIR}/`: - `state.yaml` — operator, domain, firewall, DDNS, DHCP settings (managed via API) - `secrets.yaml` — API tokens and credentials -- `services/` — per-domain service registration files +- `domains/` — per-domain registration files - `nats/` — embedded NATS JetStream data - `instances/` — Wild Cloud instance configs -## Service Registration API +## Domain Registration API -The key abstraction. Services register with Central to get DNS, proxy, TLS, and public exposure. +The key abstraction. Domains register with Central to get DNS, gateway routing, TLS, and public exposure. ``` -POST /api/v1/services Register a service -GET /api/v1/services List all services -GET /api/v1/services/{name} Get service details -PATCH /api/v1/services/{name} Update service -DELETE /api/v1/services/{name} Deregister service +POST /api/v1/domains Register a domain +GET /api/v1/domains List all domains +GET /api/v1/domains/{domain} Get domain details +PATCH /api/v1/domains/{domain} Update domain +DELETE /api/v1/domains/{domain} Deregister domain ``` -### Service payload +### Domain payload ```json { @@ -87,10 +87,10 @@ DELETE /api/v1/services/{name} Deregister service ### Backend types - `tcp-passthrough` — L4, SNI routing, backend handles TLS (Wild Cloud k8s) -- `http` — L7, Central terminates TLS with wildcard cert (Wild Works services) -- `static` — L7, static file serving (Wild Works frontends) +- `http` — L7, Central terminates TLS with wildcard cert (Wild Works) +- `dns-only` — DNS resolution only, no gateway routing (SSH, game servers, etc.) ### Public -- `false` (default) — LAN-visible only (DNS + proxy + TLS) +- `false` (default) — LAN-visible only (DNS + gateway + TLS) - `true` — internet-visible (+ DDNS or tunnel exposure) diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index 4fc98de..3ff2237 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -7,6 +7,7 @@ import ( "log/slog" "net/http" "os" + "os/exec" "path/filepath" "syscall" "time" @@ -25,7 +26,7 @@ import ( "github.com/wild-cloud/wild-central/internal/network" "github.com/wild-cloud/wild-central/internal/nftables" "github.com/wild-cloud/wild-central/internal/secrets" - "github.com/wild-cloud/wild-central/internal/services" + "github.com/wild-cloud/wild-central/internal/domains" "github.com/wild-cloud/wild-central/internal/sse" "github.com/wild-cloud/wild-central/internal/storage" "github.com/wild-cloud/wild-central/internal/wireguard" @@ -46,7 +47,7 @@ type API struct { crowdsec *crowdsec.Manager vpn *wireguard.Manager certbot *certbot.Manager - services *services.Manager // Service registration manager + domains *domains.Manager // Domain registration manager sseManager *sse.Manager // SSE manager for real-time events port int // Running API port (for config-driven routes) } @@ -76,13 +77,13 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) { crowdsec: crowdsec.NewManager(), vpn: wireguard.NewManager(dataDir, vpnConfigPath), certbot: certbot.NewManager(""), - services: services.NewManager(dataDir), + domains: domains.NewManager(dataDir), sseManager: sseManager, } - // Wire up service registration reconciliation: when services change, + // Wire up domain registration reconciliation: when domains change, // regenerate dnsmasq DNS entries and HAProxy routes. - api.services.SetReconcileFn(api.reconcileNetworking) + api.domains.SetReconcileFn(api.reconcileNetworking) return api, nil } @@ -107,30 +108,49 @@ func envOrDefault(key, defaultVal string) string { return defaultVal } -// StartDDNS loads DDNS config and starts the background goroutine. +// StartDDNS launches the DDNS background goroutine with a params callback +// that reads fresh state (token, public domains, interval) on each tick. func (api *API) StartDDNS(ctx gocontext.Context) { api.ctx = ctx - api.reloadDDNSIfEnabled() + api.ddns.Start(ctx, api.ddnsParams) } -// reloadDDNSIfEnabled re-reads DDNS config and secrets and restarts the DDNS goroutine. +// ddnsParams returns the current DDNS parameters derived from runtime state. +// Called by the DDNS runner on each tick — always returns fresh values. +func (api *API) ddnsParams() ddns.Params { + state, err := config.LoadState(api.statePath()) + if err != nil || !state.Cloud.DDNS.Enabled { + return ddns.Params{} + } + + doms, _ := api.domains.List() + var records []string + for _, dom := range doms { + if dom.Public && dom.DomainName != "" { + records = append(records, dom.DomainName) + } + } + + return ddns.Params{ + APIToken: api.getCloudflareToken(), + Records: records, + IntervalMinutes: state.Cloud.DDNS.IntervalMinutes, + } +} + +// reloadDDNSIfEnabled starts the runner if DDNS is enabled, or stops it if disabled. +// When already running, triggers an immediate check with fresh params. func (api *API) reloadDDNSIfEnabled() { - globalCfg, err := config.LoadState(api.statePath()) - if err != nil || !globalCfg.Cloud.DDNS.Enabled { + state, _ := config.LoadState(api.statePath()) + if state == nil || !state.Cloud.DDNS.Enabled { api.ddns.Stop() return } - - apiToken := api.getCloudflareToken() - - cfg := ddns.Config{ - Enabled: true, - APIToken: apiToken, - Records: globalCfg.Cloud.DDNS.Records, - IntervalMinutes: globalCfg.Cloud.DDNS.IntervalMinutes, + if !api.ddns.GetStatus().Enabled { + api.ddns.Start(api.ctx, api.ddnsParams) + } else { + api.ddns.Trigger() } - - api.ddns.Start(api.ctx, cfg) } // StartCentralStatusBroadcaster starts periodic broadcasting of central status @@ -151,9 +171,9 @@ func (api *API) StartCentralStatusBroadcaster(startTime time.Time) { func (api *API) RegisterRoutes(r *mux.Router) { r.Use(RequestLoggingMiddleware) - // Global Configuration - r.HandleFunc("/api/v1/config", api.GetGlobalConfig).Methods("GET") - r.HandleFunc("/api/v1/config", api.UpdateGlobalConfig).Methods("PUT") + // Global State (runtime settings — operator, DDNS, DHCP, etc.) + r.HandleFunc("/api/v1/state", api.GetState).Methods("GET") + r.HandleFunc("/api/v1/state", api.UpdateState).Methods("PUT") // Global Secrets r.HandleFunc("/api/v1/secrets", api.GetGlobalSecrets).Methods("GET") @@ -228,13 +248,13 @@ func (api *API) RegisterRoutes(r *mux.Router) { r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareGetZoneID).Methods("GET") r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST") - // Service registration — domain is the unique key - r.HandleFunc("/api/v1/services", api.ServicesListAll).Methods("GET") - r.HandleFunc("/api/v1/services", api.ServicesRegister).Methods("POST") - r.HandleFunc("/api/v1/services/deregister", api.ServicesDeregisterBySource).Methods("DELETE") - r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesGet).Methods("GET") - r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesUpdate).Methods("PATCH") - r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesDeregister).Methods("DELETE") + // Domain registration — domain is the unique key + r.HandleFunc("/api/v1/domains", api.DomainsListAll).Methods("GET") + r.HandleFunc("/api/v1/domains", api.DomainsRegister).Methods("POST") + r.HandleFunc("/api/v1/domains/deregister", api.DomainsDeregisterBySource).Methods("DELETE") + r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsGet).Methods("GET") + r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsUpdate).Methods("PATCH") + r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsDeregister).Methods("DELETE") // SSE events r.HandleFunc("/api/v1/events", api.GlobalEventStream).Methods("GET") @@ -242,10 +262,9 @@ func (api *API) RegisterRoutes(r *mux.Router) { // --- Global Config/Secrets handlers --- -// GetGlobalConfig returns the global state wrapped in { configured, config }. -func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) { - configPath := api.statePath() - data, err := os.ReadFile(configPath) +// GetState returns the global state wrapped in { configured, state }. +func (api *API) GetState(w http.ResponseWriter, r *http.Request) { + data, err := os.ReadFile(api.statePath()) if err != nil { respondJSON(w, http.StatusOK, map[string]any{ "configured": false, @@ -253,20 +272,20 @@ func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) { return } - var configMap map[string]any - if err := yaml.Unmarshal(data, &configMap); err != nil { - respondError(w, http.StatusInternalServerError, "Failed to parse config") + var stateMap map[string]any + if err := yaml.Unmarshal(data, &stateMap); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to parse state") return } respondJSON(w, http.StatusOK, map[string]any{ "configured": true, - "config": configMap, + "state": stateMap, }) } -// UpdateGlobalConfig updates the global state -func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) { +// UpdateState updates the global state +func (api *API) UpdateState(w http.ResponseWriter, r *http.Request) { configPath := api.statePath() body, err := io.ReadAll(r.Body) @@ -320,7 +339,7 @@ func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) { // Reload DDNS and re-register Central's service if config changed go api.reloadDDNSIfEnabled() - go api.EnsureCentralService() + go api.EnsureCentralDomain() respondMessage(w, http.StatusOK, "Config updated successfully") } @@ -380,9 +399,32 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime "uptime": uptime.String(), "uptimeSeconds": int(uptime.Seconds()), "dataDir": dataDir, + "daemons": getDaemonStatus(), }) } +// getDaemonStatus checks whether each managed daemon is active. +func getDaemonStatus() map[string]map[string]bool { + daemons := map[string]string{ + "dnsmasq": "dnsmasq.service", + "haproxy": "haproxy.service", + "wireguard": "wg-quick@wg0.service", + "crowdsec": "crowdsec.service", + } + + result := make(map[string]map[string]bool, len(daemons)+1) + for name, unit := range daemons { + err := exec.Command("systemctl", "is-active", "--quiet", unit).Run() + result[name] = map[string]bool{"active": err == nil} + } + + // nftables has no persistent service — check if the wild-cloud table exists + err := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run() + result["nftables"] = map[string]bool{"active": err == nil} + + return result +} + // DaemonRestart triggers a graceful self-restart via SIGTERM func (api *API) DaemonRestart(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusAccepted, map[string]any{"message": "Restarting Wild Central..."}) diff --git a/internal/api/v1/handlers_certbot.go b/internal/api/v1/handlers_certbot.go index 1f73392..c27c1db 100644 --- a/internal/api/v1/handlers_certbot.go +++ b/internal/api/v1/handlers_certbot.go @@ -8,7 +8,7 @@ import ( "github.com/wild-cloud/wild-central/internal/certbot" "github.com/wild-cloud/wild-central/internal/config" - "github.com/wild-cloud/wild-central/internal/services" + "github.com/wild-cloud/wild-central/internal/domains" ) // CertStatus returns TLS certificate status for all relevant domains: @@ -24,31 +24,30 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) { cfToken := api.getCloudflareToken() - // Gather cert statuses for all registered services that need TLS termination + // Gather cert statuses for all registered domains that need TLS termination certs := []map[string]any{} seen := map[string]bool{} - svcs, _ := api.services.List() - for _, svc := range svcs { - if svc.TLS != services.TLSTerminate || svc.Domain == "" { + doms, _ := api.domains.List() + for _, dom := range doms { + if dom.TLS != domains.TLSTerminate || dom.DomainName == "" { continue } - if seen[svc.Domain] { + if seen[dom.DomainName] { continue } - seen[svc.Domain] = true + seen[dom.DomainName] = true - status := api.certbot.GetStatus(svc.Domain) + status := api.certbot.GetStatus(dom.DomainName) entry := map[string]any{ - "domain": svc.Domain, - "service": svc.Domain, - "source": svc.Source, - "cert": status, + "domain": dom.DomainName, + "source": dom.Source, + "cert": status, } // Check if covered by a wildcard cert - parts := strings.SplitN(svc.Domain, ".", 2) + parts := strings.SplitN(dom.DomainName, ".", 2) if len(parts) == 2 && !status.Exists { wildcardBase := parts[1] wildcardStatus := api.certbot.GetStatus(wildcardBase) diff --git a/internal/api/v1/handlers_config_test.go b/internal/api/v1/handlers_config_test.go index d6be5f7..e068850 100644 --- a/internal/api/v1/handlers_config_test.go +++ b/internal/api/v1/handlers_config_test.go @@ -13,7 +13,7 @@ import ( "gopkg.in/yaml.v3" ) -func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) { +func TestGetState_ReturnsWrappedResponse(t *testing.T) { api, tmpDir := setupTestAPI(t) // Write a config with a central domain @@ -30,10 +30,10 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) { data, _ := yaml.Marshal(cfg) storage.WriteFile(filepath.Join(tmpDir, "state.yaml"), data, 0644) - req := httptest.NewRequest("GET", "/api/v1/config", nil) + req := httptest.NewRequest("GET", "/api/v1/state", nil) w := httptest.NewRecorder() - api.GetGlobalConfig(w, req) + api.GetState(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) @@ -42,33 +42,33 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) { var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) - // Must have "configured" and "config" keys + // Must have "configured" and "state" keys if resp["configured"] != true { t.Errorf("expected configured=true, got %v", resp["configured"]) } - config, ok := resp["config"].(map[string]any) + state, ok := resp["state"].(map[string]any) if !ok { - t.Fatalf("expected config to be a map, got %T", resp["config"]) + t.Fatalf("expected state to be a map, got %T", resp["state"]) } // Verify nested structure is preserved - cloud, _ := config["cloud"].(map[string]any) + cloud, _ := state["cloud"].(map[string]any) central, _ := cloud["central"].(map[string]any) if central["domain"] != "central.example.com" { t.Errorf("expected domain central.example.com, got %v", central["domain"]) } } -func TestGetGlobalConfig_EmptyReturnsNotConfigured(t *testing.T) { +func TestGetState_EmptyReturnsNotConfigured(t *testing.T) { api, tmpDir := setupTestAPI(t) // Ensure no state file exists os.Remove(filepath.Join(tmpDir, "state.yaml")) - req := httptest.NewRequest("GET", "/api/v1/config", nil) + req := httptest.NewRequest("GET", "/api/v1/state", nil) w := httptest.NewRecorder() - api.GetGlobalConfig(w, req) + api.GetState(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) @@ -146,7 +146,7 @@ func TestGetGlobalSecrets_RawShowsValues(t *testing.T) { } } -func TestUpdateGlobalConfig(t *testing.T) { +func TestUpdateState(t *testing.T) { api, tmpDir := setupTestAPI(t) update := map[string]any{ @@ -158,10 +158,10 @@ func TestUpdateGlobalConfig(t *testing.T) { } body, _ := json.Marshal(update) - req := httptest.NewRequest("PUT", "/api/v1/config", bytes.NewReader(body)) + req := httptest.NewRequest("PUT", "/api/v1/state", bytes.NewReader(body)) w := httptest.NewRecorder() - api.UpdateGlobalConfig(w, req) + api.UpdateState(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) diff --git a/internal/api/v1/handlers_ddns.go b/internal/api/v1/handlers_ddns.go index 2739263..5a409df 100644 --- a/internal/api/v1/handlers_ddns.go +++ b/internal/api/v1/handlers_ddns.go @@ -16,13 +16,14 @@ func (api *API) DDNSStatus(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, api.ddns.GetStatus()) } -// DDNSTrigger forces an immediate DDNS IP check and update +// DDNSTrigger forces an immediate DDNS check. The runner reads fresh params +// (token, records) via its callback, so updated tokens take effect immediately. func (api *API) DDNSTrigger(w http.ResponseWriter, r *http.Request) { if api.ddns == nil { respondError(w, http.StatusServiceUnavailable, "DDNS not configured") return } - api.ddns.Trigger() + api.reloadDDNSIfEnabled() respondJSON(w, http.StatusOK, map[string]string{ "message": "DDNS update triggered", }) diff --git a/internal/api/v1/handlers_dnsmasq_test.go b/internal/api/v1/handlers_dnsmasq_test.go index a94176d..11fc5d2 100644 --- a/internal/api/v1/handlers_dnsmasq_test.go +++ b/internal/api/v1/handlers_dnsmasq_test.go @@ -31,7 +31,7 @@ func setupTestDnsmasq(t *testing.T) (*API, string) { func TestDnsmasqGenerate(t *testing.T) { api, tmpDir := setupTestDnsmasq(t) - globalConfig := config.GlobalConfig{} + globalConfig := config.State{} globalConfig.Cloud.Central.Domain = "central.example.com" configPath := filepath.Join(tmpDir, "state.yaml") configData, _ := yaml.Marshal(globalConfig) diff --git a/internal/api/v1/handlers_domains.go b/internal/api/v1/handlers_domains.go new file mode 100644 index 0000000..a49c418 --- /dev/null +++ b/internal/api/v1/handlers_domains.go @@ -0,0 +1,132 @@ +package v1 + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + + "github.com/gorilla/mux" + + "github.com/wild-cloud/wild-central/internal/domains" +) + +// DomainsListAll lists all registered domains. +func (api *API) DomainsListAll(w http.ResponseWriter, r *http.Request) { + doms, err := api.domains.List() + if err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list domains: %v", err)) + return + } + + if doms == nil { + doms = []domains.Domain{} + } + + respondJSON(w, http.StatusOK, map[string]any{ + "domains": doms, + }) +} + +// DomainsGet retrieves a domain by its domain name. +func (api *API) DomainsGet(w http.ResponseWriter, r *http.Request) { + domain := mux.Vars(r)["domain"] + + dom, err := api.domains.Get(domain) + if err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Domain not found: %v", err)) + return + } + + respondJSON(w, http.StatusOK, dom) +} + +// DomainsRegister creates or updates a domain registration. +func (api *API) DomainsRegister(w http.ResponseWriter, r *http.Request) { + var dom domains.Domain + if err := json.NewDecoder(r.Body).Decode(&dom); err != nil { + respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err)) + return + } + + // Validate routes + for i, r := range dom.Routes { + for _, p := range r.Paths { + if !strings.HasPrefix(p, "/") { + respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: path prefix must start with /: %q", i, p)) + return + } + } + for _, cidr := range r.IPAllow { + if _, _, err := net.ParseCIDR(cidr); err != nil { + respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: invalid CIDR in ipAllow: %q", i, cidr)) + return + } + } + } + + if err := api.domains.Register(dom); err != nil { + respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to register domain: %v", err)) + return + } + + // Return the stored version (has defaults applied) + stored, _ := api.domains.Get(dom.DomainName) + respondJSON(w, http.StatusCreated, map[string]any{ + "message": fmt.Sprintf("Domain %q registered", dom.DomainName), + "domain": stored, + }) +} + +// DomainsUpdate applies partial updates to a domain. +func (api *API) DomainsUpdate(w http.ResponseWriter, r *http.Request) { + domain := mux.Vars(r)["domain"] + + var updates map[string]any + if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { + respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err)) + return + } + + if err := api.domains.Update(domain, updates); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to update domain: %v", err)) + return + } + + dom, _ := api.domains.Get(domain) + respondJSON(w, http.StatusOK, map[string]any{ + "message": fmt.Sprintf("Domain %q updated", domain), + "domain": dom, + }) +} + +// DomainsDeregister removes a domain registration. +func (api *API) DomainsDeregister(w http.ResponseWriter, r *http.Request) { + domain := mux.Vars(r)["domain"] + + if err := api.domains.Deregister(domain); err != nil { + respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to deregister domain: %v", err)) + return + } + + respondMessage(w, http.StatusOK, fmt.Sprintf("Domain %q deregistered", domain)) +} + +// DomainsDeregisterBySource removes all domains from a source with a given backend. +func (api *API) DomainsDeregisterBySource(w http.ResponseWriter, r *http.Request) { + source := r.URL.Query().Get("source") + backend := r.URL.Query().Get("backend") + + if source == "" { + respondError(w, http.StatusBadRequest, "source query parameter is required") + return + } + + if err := api.domains.DeregisterBySource(source, backend); err != nil { + respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to deregister: %v", err)) + return + } + + respondMessage(w, http.StatusOK, fmt.Sprintf("Domains from %q deregistered", source)) +} diff --git a/internal/api/v1/handlers_services_test.go b/internal/api/v1/handlers_domains_test.go similarity index 58% rename from internal/api/v1/handlers_services_test.go rename to internal/api/v1/handlers_domains_test.go index 0150b4a..03357ca 100644 --- a/internal/api/v1/handlers_services_test.go +++ b/internal/api/v1/handlers_domains_test.go @@ -10,7 +10,7 @@ import ( "github.com/gorilla/mux" ) -func TestServicesRegister(t *testing.T) { +func TestDomainsRegister(t *testing.T) { api, _ := setupTestAPI(t) body, _ := json.Marshal(map[string]any{ @@ -22,9 +22,9 @@ func TestServicesRegister(t *testing.T) { }, }) - req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body)) w := httptest.NewRecorder() - api.ServicesRegister(w, req) + api.DomainsRegister(w, req) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) @@ -33,19 +33,19 @@ func TestServicesRegister(t *testing.T) { var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) - svc := resp["service"].(map[string]any) - if svc["domain"] != "my-api.payne.io" { - t.Errorf("expected domain my-api.payne.io, got %v", svc["domain"]) + dom := resp["domain"].(map[string]any) + if dom["domain"] != "my-api.payne.io" { + t.Errorf("expected domain my-api.payne.io, got %v", dom["domain"]) } - if svc["tls"] != "terminate" { - t.Errorf("expected tls=terminate default for http, got %v", svc["tls"]) + if dom["tls"] != "terminate" { + t.Errorf("expected tls=terminate default for http, got %v", dom["tls"]) } - if svc["source"] != "wild-works" { - t.Errorf("expected source wild-works, got %v", svc["source"]) + if dom["source"] != "wild-works" { + t.Errorf("expected source wild-works, got %v", dom["source"]) } } -func TestServicesRegister_TCPPassthrough(t *testing.T) { +func TestDomainsRegister_TCPPassthrough(t *testing.T) { api, _ := setupTestAPI(t) body, _ := json.Marshal(map[string]any{ @@ -59,9 +59,9 @@ func TestServicesRegister_TCPPassthrough(t *testing.T) { "public": true, }) - req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body)) w := httptest.NewRecorder() - api.ServicesRegister(w, req) + api.DomainsRegister(w, req) if w.Code != http.StatusCreated { t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) @@ -70,21 +70,21 @@ func TestServicesRegister_TCPPassthrough(t *testing.T) { var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) - svc := resp["service"].(map[string]any) - if svc["tls"] != "passthrough" { - t.Errorf("expected tls=passthrough for tcp-passthrough, got %v", svc["tls"]) + dom := resp["domain"].(map[string]any) + if dom["tls"] != "passthrough" { + t.Errorf("expected tls=passthrough for tcp-passthrough, got %v", dom["tls"]) } - if svc["subdomains"] != true { - t.Errorf("expected subdomains=true, got %v", svc["subdomains"]) + if dom["subdomains"] != true { + t.Errorf("expected subdomains=true, got %v", dom["subdomains"]) } } -func TestServicesListAll_Empty(t *testing.T) { +func TestDomainsListAll_Empty(t *testing.T) { api, _ := setupTestAPI(t) - req := httptest.NewRequest("GET", "/api/v1/services", nil) + req := httptest.NewRequest("GET", "/api/v1/domains", nil) w := httptest.NewRecorder() - api.ServicesListAll(w, req) + api.DomainsListAll(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) @@ -92,13 +92,13 @@ func TestServicesListAll_Empty(t *testing.T) { var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) - svcs := resp["services"].([]any) - if len(svcs) != 0 { - t.Errorf("expected empty, got %d", len(svcs)) + doms := resp["domains"].([]any) + if len(doms) != 0 { + t.Errorf("expected empty, got %d", len(doms)) } } -func TestServicesGet(t *testing.T) { +func TestDomainsGet(t *testing.T) { api, _ := setupTestAPI(t) // Register @@ -107,41 +107,41 @@ func TestServicesGet(t *testing.T) { "source": "test", "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, }) - req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body)) w := httptest.NewRecorder() - api.ServicesRegister(w, req) + api.DomainsRegister(w, req) // Get by domain - req = httptest.NewRequest("GET", "/api/v1/services/get-test.example.com", nil) + req = httptest.NewRequest("GET", "/api/v1/domains/get-test.example.com", nil) req = mux.SetURLVars(req, map[string]string{"domain": "get-test.example.com"}) w = httptest.NewRecorder() - api.ServicesGet(w, req) + api.DomainsGet(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) } - var svc map[string]any - json.Unmarshal(w.Body.Bytes(), &svc) - if svc["domain"] != "get-test.example.com" { - t.Errorf("expected domain get-test.example.com, got %v", svc["domain"]) + var dom map[string]any + json.Unmarshal(w.Body.Bytes(), &dom) + if dom["domain"] != "get-test.example.com" { + t.Errorf("expected domain get-test.example.com, got %v", dom["domain"]) } } -func TestServicesGet_NotFound(t *testing.T) { +func TestDomainsGet_NotFound(t *testing.T) { api, _ := setupTestAPI(t) - req := httptest.NewRequest("GET", "/api/v1/services/nonexistent.com", nil) + req := httptest.NewRequest("GET", "/api/v1/domains/nonexistent.com", nil) req = mux.SetURLVars(req, map[string]string{"domain": "nonexistent.com"}) w := httptest.NewRecorder() - api.ServicesGet(w, req) + api.DomainsGet(w, req) if w.Code != http.StatusNotFound { t.Errorf("expected 404, got %d", w.Code) } } -func TestServicesUpdate(t *testing.T) { +func TestDomainsUpdate(t *testing.T) { api, _ := setupTestAPI(t) // Register @@ -150,16 +150,16 @@ func TestServicesUpdate(t *testing.T) { "source": "test", "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, }) - req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body)) w := httptest.NewRecorder() - api.ServicesRegister(w, req) + api.DomainsRegister(w, req) // Update public to true update, _ := json.Marshal(map[string]any{"public": true}) - req = httptest.NewRequest("PATCH", "/api/v1/services/update-test.example.com", bytes.NewReader(update)) + req = httptest.NewRequest("PATCH", "/api/v1/domains/update-test.example.com", bytes.NewReader(update)) req = mux.SetURLVars(req, map[string]string{"domain": "update-test.example.com"}) w = httptest.NewRecorder() - api.ServicesUpdate(w, req) + api.DomainsUpdate(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) @@ -167,13 +167,13 @@ func TestServicesUpdate(t *testing.T) { var resp map[string]any json.Unmarshal(w.Body.Bytes(), &resp) - svc := resp["service"].(map[string]any) - if svc["public"] != true { - t.Errorf("expected public=true, got %v", svc["public"]) + dom := resp["domain"].(map[string]any) + if dom["public"] != true { + t.Errorf("expected public=true, got %v", dom["public"]) } } -func TestServicesDeregister(t *testing.T) { +func TestDomainsDeregister(t *testing.T) { api, _ := setupTestAPI(t) // Register @@ -182,40 +182,40 @@ func TestServicesDeregister(t *testing.T) { "source": "test", "backend": map[string]any{"address": "127.0.0.1:9000", "type": "http"}, }) - req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body)) w := httptest.NewRecorder() - api.ServicesRegister(w, req) + api.DomainsRegister(w, req) // Delete - req = httptest.NewRequest("DELETE", "/api/v1/services/delete-me.example.com", nil) + req = httptest.NewRequest("DELETE", "/api/v1/domains/delete-me.example.com", nil) req = mux.SetURLVars(req, map[string]string{"domain": "delete-me.example.com"}) w = httptest.NewRecorder() - api.ServicesDeregister(w, req) + api.DomainsDeregister(w, req) if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d", w.Code) } // Verify gone - req = httptest.NewRequest("GET", "/api/v1/services/delete-me.example.com", nil) + req = httptest.NewRequest("GET", "/api/v1/domains/delete-me.example.com", nil) req = mux.SetURLVars(req, map[string]string{"domain": "delete-me.example.com"}) w = httptest.NewRecorder() - api.ServicesGet(w, req) + api.DomainsGet(w, req) if w.Code != http.StatusNotFound { t.Errorf("expected 404 after delete, got %d", w.Code) } } -func TestServicesRegister_Validation(t *testing.T) { +func TestDomainsRegister_Validation(t *testing.T) { api, _ := setupTestAPI(t) // Missing domain body, _ := json.Marshal(map[string]any{ "backend": map[string]any{"address": "127.0.0.1:80", "type": "http"}, }) - req := httptest.NewRequest("POST", "/api/v1/services", bytes.NewReader(body)) + req := httptest.NewRequest("POST", "/api/v1/domains", bytes.NewReader(body)) w := httptest.NewRecorder() - api.ServicesRegister(w, req) + api.DomainsRegister(w, req) if w.Code != http.StatusBadRequest { t.Errorf("expected 400 for missing domain, got %d", w.Code) } diff --git a/internal/api/v1/handlers_haproxy_test.go b/internal/api/v1/handlers_haproxy_test.go index 1a5211b..ecdba5f 100644 --- a/internal/api/v1/handlers_haproxy_test.go +++ b/internal/api/v1/handlers_haproxy_test.go @@ -110,7 +110,7 @@ func TestHaproxyStats_WhenSocketMissing(t *testing.T) { } } -func TestHaproxyGenerate_MissingGlobalConfig(t *testing.T) { +func TestHaproxyGenerate_MissingState(t *testing.T) { api, tmpDir := setupTestHaproxy(t) os.Remove(filepath.Join(tmpDir, "state.yaml")) diff --git a/internal/api/v1/handlers_nftables.go b/internal/api/v1/handlers_nftables.go index ba48a55..108ce5b 100644 --- a/internal/api/v1/handlers_nftables.go +++ b/internal/api/v1/handlers_nftables.go @@ -53,7 +53,7 @@ func (api *API) vpnAutoUDPPorts() []int { // syncNftablesOnly regenerates and applies nftables rules from the current global config // without touching HAProxy. Called when firewall settings change independently. // Runs errors are logged only — the caller does not wait for completion. -func (api *API) syncNftablesOnly(globalCfg *config.GlobalConfig) { +func (api *API) syncNftablesOnly(globalCfg *config.State) { nftCfg := globalCfg.Cloud.Nftables // Explicitly disabled: flush the wild-cloud table diff --git a/internal/api/v1/handlers_reconciliation_test.go b/internal/api/v1/handlers_reconciliation_test.go index 813cf55..d32ce15 100644 --- a/internal/api/v1/handlers_reconciliation_test.go +++ b/internal/api/v1/handlers_reconciliation_test.go @@ -8,55 +8,53 @@ import ( "testing" "github.com/wild-cloud/wild-central/internal/config" + "github.com/wild-cloud/wild-central/internal/domains" "github.com/wild-cloud/wild-central/internal/haproxy" - "github.com/wild-cloud/wild-central/internal/services" ) -// TestReconciliation_HAProxyConfigFromServices verifies that the reconciliation -// logic correctly maps registered services to HAProxy configuration. This +// TestReconciliation_HAProxyConfigFromDomains verifies that the reconciliation +// logic correctly maps registered domains to HAProxy configuration. This // replicates the route-building logic from reconcileNetworking() and asserts // on the generated config without requiring haproxy or dnsmasq binaries. -func TestReconciliation_HAProxyConfigFromServices(t *testing.T) { +func TestReconciliation_HAProxyConfigFromDomains(t *testing.T) { api, _ := setupTestAPI(t) - // Register a mix of services like a real deployment - testServices := []services.Service{ + // Register a mix of domains like a real deployment + testDomains := []domains.Domain{ { - Domain: "cloud.payne.io", + DomainName: "cloud.payne.io", Source: "wild-cloud", - Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough}, + Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough}, Subdomains: true, - Public: true, + Public: true, }, { - Domain: "payne.io", + DomainName: "payne.io", Source: "wild-cloud", - Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough}, + Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough}, Subdomains: false, - Public: true, + Public: true, }, { - Domain: "wild-cloud.payne.io", - Source: "wild-works", - Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP}, - // Public defaults to false + DomainName: "wild-cloud.payne.io", + Source: "wild-works", + Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP}, }, { - Domain: "my-api.payne.io", - Source: "wild-works", - Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP, Health: "/health"}, - // Public defaults to false + DomainName: "my-api.payne.io", + Source: "wild-works", + Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP, Health: "/health"}, }, } - for _, svc := range testServices { - if err := api.services.Register(svc); err != nil { - t.Fatalf("Register %s failed: %v", svc.Domain, err) + for _, dom := range testDomains { + if err := api.domains.Register(dom); err != nil { + t.Fatalf("Register %s failed: %v", dom.DomainName, err) } } // Replicate reconcileNetworking route-building logic - svcs, err := api.services.List() + doms, err := api.domains.List() if err != nil { t.Fatalf("List failed: %v", err) } @@ -64,50 +62,50 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) { var instanceRoutes []haproxy.InstanceRoute var httpRoutes []haproxy.HTTPRoute - for _, svc := range svcs { - switch svc.Backend.Type { - case services.BackendTCPPassthrough: + for _, dom := range doms { + switch dom.Backend.Type { + case domains.BackendTCPPassthrough: instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{ - Name: svc.Domain, - Domain: svc.Domain, - BackendIP: extractHost(svc.Backend.Address), - Subdomains: svc.Subdomains, + Name: dom.DomainName, + Domain: dom.DomainName, + BackendIP: extractHost(dom.Backend.Address), + Subdomains: dom.Subdomains, }) - case services.BackendHTTP: + case domains.BackendHTTP: httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ - Name: svc.Domain, - Domain: svc.Domain, + Name: dom.DomainName, + Domain: dom.DomainName, Routes: []haproxy.HTTPRouteBackend{{ - Backend: svc.Backend.Address, - HealthPath: svc.Backend.Health, + Backend: dom.Backend.Address, + HealthPath: dom.Backend.Health, }}, }) } } - // Central registers itself as a service; add it to the test data - if err := api.services.Register(services.Service{ - Domain: "central.payne.io", - Source: "central", - Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP}, - TLS: services.TLSTerminate, + // Central registers itself as a domain; add it to the test data + if err := api.domains.Register(domains.Domain{ + DomainName: "central.payne.io", + Source: "central", + Backend: domains.Backend{Address: "127.0.0.1:5055", Type: domains.BackendHTTP}, + TLS: domains.TLSTerminate, }); err != nil { t.Fatalf("Register central failed: %v", err) } // Re-list to pick up Central - svcs, err = api.services.List() + doms, err = api.domains.List() if err != nil { t.Fatalf("List failed: %v", err) } httpRoutes = nil - for _, svc := range svcs { - if svc.Backend.Type == services.BackendHTTP { + for _, dom := range doms { + if dom.Backend.Type == domains.BackendHTTP { httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ - Name: svc.Domain, - Domain: svc.Domain, + Name: dom.DomainName, + Domain: dom.DomainName, Routes: []haproxy.HTTPRouteBackend{{ - Backend: svc.Backend.Address, - HealthPath: svc.Backend.Health, + Backend: dom.Backend.Address, + HealthPath: dom.Backend.Health, }}, }) } @@ -120,7 +118,7 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) { // --- Assertions on HAProxy config --- - // L7 services have exact-match ACLs in the SNI frontend + // L7 domains have exact-match ACLs in the SNI frontend if !strings.Contains(cfg, "acl is_l7_central_payne_io req_ssl_sni -m str central.payne.io") { t.Errorf("expected L7 exact-match ACL for central.payne.io:\n%s", cfg) } @@ -128,12 +126,12 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) { t.Errorf("expected L7 exact-match ACL for wild-cloud.payne.io:\n%s", cfg) } - // L4 services with subdomains:true have wildcard ACLs + // L4 domains with subdomains:true have wildcard ACLs if !strings.Contains(cfg, "req_ssl_sni -m end .cloud.payne.io") { t.Errorf("expected L4 wildcard ACL for cloud.payne.io (subdomains:true):\n%s", cfg) } - // L4 services with subdomains:false have exact-match only + // L4 domains with subdomains:false have exact-match only if !strings.Contains(cfg, "req_ssl_sni -m str payne.io") { t.Errorf("expected L4 exact-match ACL for payne.io:\n%s", cfg) } @@ -168,72 +166,71 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) { } } -// TestReconciliation_DnsmasqConfigFromServices verifies that reconciliation -// correctly generates dnsmasq config from registered services, including +// TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation +// correctly generates dnsmasq config from registered domains, including // the critical reach-based local=/ directives. -func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) { +func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) { api, _ := setupTestAPI(t) centralIP := "192.168.8.151" - // Register services with different reach and backend types - testServices := []services.Service{ + // Register domains with different reach and backend types + testDomains := []domains.Domain{ { // tcp-passthrough, public → DNS points to k8s LB IP, no local=/ - Domain: "cloud.payne.io", + DomainName: "cloud.payne.io", Source: "wild-cloud", - Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough}, + Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough}, Subdomains: true, - Public: true, + Public: true, }, { // http, internal → DNS points to Central IP, has local=/ - Domain: "my-api.payne.io", - Source: "wild-works", - Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP}, - // Public defaults to false + DomainName: "my-api.payne.io", + Source: "wild-works", + Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP}, }, { // http, public → DNS points to Central IP, NO local=/ - Domain: "public-app.payne.io", - Source: "wild-works", - Backend: services.Backend{Address: "192.168.8.60:8080", Type: services.BackendHTTP}, - Public: true, + DomainName: "public-app.payne.io", + Source: "wild-works", + Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP}, + Public: true, }, } - for _, svc := range testServices { - if err := api.services.Register(svc); err != nil { - t.Fatalf("Register %s failed: %v", svc.Domain, err) + for _, dom := range testDomains { + if err := api.domains.Register(dom); err != nil { + t.Fatalf("Register %s failed: %v", dom.DomainName, err) } } // Build dnsmasq instance configs the same way reconcileNetworking does - svcs, err := api.services.List() + doms, err := api.domains.List() if err != nil { t.Fatalf("List failed: %v", err) } - globalCfg := &config.GlobalConfig{} + globalCfg := &config.State{} var instanceConfigs []config.InstanceConfig - for _, svc := range svcs { - if svc.Domain == "" { + for _, dom := range doms { + if dom.DomainName == "" { continue } dnsIP := centralIP - if svc.Backend.Type == services.BackendTCPPassthrough { - dnsIP = extractHost(svc.Backend.Address) + if dom.Backend.Type == domains.BackendTCPPassthrough { + dnsIP = extractHost(dom.Backend.Address) } ic := config.InstanceConfig{} ic.Cluster.LoadBalancerIp = dnsIP - if !svc.Public { - ic.Cloud.InternalDomain = svc.Domain + if !dom.Public { + ic.Cloud.InternalDomain = dom.DomainName } else { - ic.Cloud.Domain = svc.Domain + ic.Cloud.Domain = dom.DomainName } instanceConfigs = append(instanceConfigs, ic) @@ -243,69 +240,67 @@ func TestReconciliation_DnsmasqConfigFromServices(t *testing.T) { // --- TCP passthrough (public) → backend IP --- if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") { - t.Errorf("tcp-passthrough service must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg) + t.Errorf("tcp-passthrough domain must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg) } - // --- HTTP services → Central IP --- + // --- HTTP domains → Central IP --- if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") { - t.Errorf("http/internal service must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg) + t.Errorf("http/internal domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg) } if !strings.Contains(dnsmasqCfg, "address=/public-app.payne.io/192.168.8.151") { - t.Errorf("http/public service must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg) + t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg) } - // --- reach:internal → local=/ present --- + // --- internal → local=/ present --- if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") { - t.Errorf("reach:internal service must have local=/ entry:\n%s", dnsmasqCfg) + t.Errorf("internal domain must have local=/ entry:\n%s", dnsmasqCfg) } - // --- reach:public → NO local=/ --- + // --- public → NO local=/ --- if strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") { - t.Errorf("reach:public service must NOT have local=/ entry:\n%s", dnsmasqCfg) + t.Errorf("public domain must NOT have local=/ entry:\n%s", dnsmasqCfg) } if strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") { - t.Errorf("reach:public service must NOT have local=/ entry:\n%s", dnsmasqCfg) + t.Errorf("public domain must NOT have local=/ entry:\n%s", dnsmasqCfg) } } // TestReconciliation_CentralDomainInHAProxy verifies that Central, registered -// as a service, produces correct HAProxy L7 routes. +// as a domain, produces correct HAProxy L7 routes. func TestReconciliation_CentralDomainInHAProxy(t *testing.T) { api, _ := setupTestAPI(t) centralPort := 15055 - // Register Central as a service (as EnsureCentralService does) - if err := api.services.Register(services.Service{ - Domain: "central.payne.io", - Source: "central", - Backend: services.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: services.BackendHTTP}, - // Public defaults to false - TLS: services.TLSTerminate, + // Register Central as a domain (as EnsureCentralDomain does) + if err := api.domains.Register(domains.Domain{ + DomainName: "central.payne.io", + Source: "central", + Backend: domains.Backend{Address: fmt.Sprintf("127.0.0.1:%d", centralPort), Type: domains.BackendHTTP}, + TLS: domains.TLSTerminate, }); err != nil { t.Fatalf("Register central failed: %v", err) } - // Register another HTTP service - if err := api.services.Register(services.Service{ - Domain: "my-app.payne.io", - Source: "wild-works", - Backend: services.Backend{Address: "192.168.8.60:9001", Type: services.BackendHTTP}, - // Public defaults to false + // Register another HTTP domain + if err := api.domains.Register(domains.Domain{ + DomainName: "my-app.payne.io", + Source: "wild-works", + Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP}, }); err != nil { t.Fatalf("Register failed: %v", err) } - svcs, _ := api.services.List() + doms, _ := api.domains.List() var httpRoutes []haproxy.HTTPRoute - for _, svc := range svcs { - if svc.Backend.Type == services.BackendHTTP { + for _, dom := range doms { + if dom.Backend.Type == domains.BackendHTTP { httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ - Name: svc.Domain, - Domain: svc.Domain, + Name: dom.DomainName, + Domain: dom.DomainName, Routes: []haproxy.HTTPRouteBackend{{ - Backend: svc.Backend.Address, + Backend: dom.Backend.Address, }}, }) } @@ -330,39 +325,39 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) { } // TestReconciliation_TCPPassthroughDNSTarget verifies that tcp-passthrough -// services get DNS pointing to the backend IP, not Central's IP. +// domains get DNS pointing to the backend IP, not Central's IP. func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) { api, _ := setupTestAPI(t) centralIP := "192.168.8.151" // Register a k8s instance with tcp-passthrough - if err := api.services.Register(services.Service{ - Domain: "cloud.payne.io", + if err := api.domains.Register(domains.Domain{ + DomainName: "cloud.payne.io", Source: "wild-cloud", - Backend: services.Backend{Address: "192.168.8.240:443", Type: services.BackendTCPPassthrough}, + Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough}, Subdomains: true, - Public: true, + Public: true, }); err != nil { t.Fatalf("Register failed: %v", err) } - svcs, _ := api.services.List() - globalCfg := &config.GlobalConfig{} + doms, _ := api.domains.List() + globalCfg := &config.State{} var instanceConfigs []config.InstanceConfig - for _, svc := range svcs { + for _, dom := range doms { dnsIP := centralIP - if svc.Backend.Type == services.BackendTCPPassthrough { - dnsIP = extractHost(svc.Backend.Address) + if dom.Backend.Type == domains.BackendTCPPassthrough { + dnsIP = extractHost(dom.Backend.Address) } ic := config.InstanceConfig{} ic.Cluster.LoadBalancerIp = dnsIP - if !svc.Public { - ic.Cloud.InternalDomain = svc.Domain + if !dom.Public { + ic.Cloud.InternalDomain = dom.DomainName } else { - ic.Cloud.Domain = svc.Domain + ic.Cloud.Domain = dom.DomainName } instanceConfigs = append(instanceConfigs, ic) } @@ -378,6 +373,104 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) { } } +// TestReconciliation_DNSOnlyHasNoDNSButNoProxy verifies that dns-only domains +// get DNS entries but no HAProxy routes. +func TestReconciliation_DNSOnlyNoGateway(t *testing.T) { + api, _ := setupTestAPI(t) + + centralIP := "192.168.8.151" + + // Register a dns-only domain (e.g. for SSH access) + if err := api.domains.Register(domains.Domain{ + DomainName: "dev.payne.io", + Source: "manual", + Backend: domains.Backend{Address: "192.168.8.222", Type: domains.BackendDNSOnly}, + Public: true, + }); err != nil { + t.Fatalf("Register failed: %v", err) + } + + // Also register a normal HTTP domain to verify it still works + if err := api.domains.Register(domains.Domain{ + DomainName: "app.payne.io", + Source: "wild-works", + Backend: domains.Backend{Address: "127.0.0.1:8080", Type: domains.BackendHTTP}, + }); err != nil { + t.Fatalf("Register failed: %v", err) + } + + doms, _ := api.domains.List() + + // Build HAProxy routes — dns-only should be absent + var instanceRoutes []haproxy.InstanceRoute + var httpRoutes []haproxy.HTTPRoute + + for _, dom := range doms { + switch dom.Backend.Type { + case domains.BackendTCPPassthrough: + instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{ + Name: dom.DomainName, + Domain: dom.DomainName, + BackendIP: extractHost(dom.Backend.Address), + }) + case domains.BackendDNSOnly: + // dns-only: no gateway routing + case domains.BackendHTTP: + httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ + Name: dom.DomainName, + Domain: dom.DomainName, + Routes: []haproxy.HTTPRouteBackend{{Backend: dom.Backend.Address}}, + }) + } + } + + cfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{ + HTTPRoutes: httpRoutes, + CertsDir: "/etc/haproxy/certs/", + }) + + // dns-only domain must NOT appear in HAProxy config + if strings.Contains(cfg, "dev.payne.io") { + t.Errorf("dns-only domain must NOT appear in HAProxy config:\n%s", cfg) + } + // HTTP domain should appear + if !strings.Contains(cfg, "app.payne.io") { + t.Errorf("http domain should appear in HAProxy config:\n%s", cfg) + } + + // Build DNS config — dns-only should point to its backend IP + globalCfg := &config.State{} + var instanceConfigs []config.InstanceConfig + + for _, dom := range doms { + dnsIP := centralIP + bt := dom.Backend.Type + if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly { + dnsIP = extractHost(dom.Backend.Address) + } + + ic := config.InstanceConfig{} + ic.Cluster.LoadBalancerIp = dnsIP + if !dom.Public { + ic.Cloud.InternalDomain = dom.DomainName + } else { + ic.Cloud.Domain = dom.DomainName + } + instanceConfigs = append(instanceConfigs, ic) + } + + dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs) + + // dns-only domain should resolve to its backend IP (192.168.8.222) + if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") { + t.Errorf("dns-only domain must point DNS to backend IP:\n%s", dnsmasqCfg) + } + // HTTP domain should resolve to Central IP + if !strings.Contains(dnsmasqCfg, "address=/app.payne.io/192.168.8.151") { + t.Errorf("http domain must point DNS to Central IP:\n%s", dnsmasqCfg) + } +} + // writeTestConfig writes a minimal global config with the given Central domain. func writeTestConfig(t *testing.T, dataDir, centralDomain string) { t.Helper() @@ -388,85 +481,85 @@ func writeTestConfig(t *testing.T, dataDir, centralDomain string) { } } -func TestEnsureCentralService_RegistersWhenDomainConfigured(t *testing.T) { +func TestEnsureCentralDomain_RegistersWhenDomainConfigured(t *testing.T) { api, dataDir := setupTestAPI(t) api.SetPort(15055) writeTestConfig(t, dataDir, "central.example.com") - api.EnsureCentralService() + api.EnsureCentralDomain() - svc, err := api.services.Get("central.example.com") + dom, err := api.domains.Get("central.example.com") if err != nil { - t.Fatalf("expected Central service to be registered: %v", err) + t.Fatalf("expected Central domain to be registered: %v", err) } - if svc.Source != "central" { - t.Errorf("source = %q, want %q", svc.Source, "central") + if dom.Source != "central" { + t.Errorf("source = %q, want %q", dom.Source, "central") } - if svc.Backend.Address != "127.0.0.1:15055" { - t.Errorf("backend = %q, want %q", svc.Backend.Address, "127.0.0.1:15055") + if dom.Backend.Address != "127.0.0.1:15055" { + t.Errorf("backend = %q, want %q", dom.Backend.Address, "127.0.0.1:15055") } - if svc.TLS != services.TLSTerminate { - t.Errorf("tls = %q, want %q", svc.TLS, services.TLSTerminate) + if dom.TLS != domains.TLSTerminate { + t.Errorf("tls = %q, want %q", dom.TLS, domains.TLSTerminate) } } -func TestEnsureCentralService_Idempotent(t *testing.T) { +func TestEnsureCentralDomain_Idempotent(t *testing.T) { api, dataDir := setupTestAPI(t) api.SetPort(15055) writeTestConfig(t, dataDir, "central.example.com") - api.EnsureCentralService() - api.EnsureCentralService() // second call should be a no-op + api.EnsureCentralDomain() + api.EnsureCentralDomain() // second call should be a no-op - svcs, _ := api.services.List() + doms, _ := api.domains.List() count := 0 - for _, s := range svcs { - if s.Source == "central" { + for _, d := range doms { + if d.Source == "central" { count++ } } if count != 1 { - t.Errorf("expected 1 central service, got %d", count) + t.Errorf("expected 1 central domain, got %d", count) } } -func TestEnsureCentralService_CleansUpOnDomainChange(t *testing.T) { +func TestEnsureCentralDomain_CleansUpOnDomainChange(t *testing.T) { api, dataDir := setupTestAPI(t) api.SetPort(15055) // Register with old domain writeTestConfig(t, dataDir, "old.example.com") - api.EnsureCentralService() + api.EnsureCentralDomain() // Change domain writeTestConfig(t, dataDir, "new.example.com") - api.EnsureCentralService() + api.EnsureCentralDomain() // Old domain should be gone - if _, err := api.services.Get("old.example.com"); err == nil { - t.Error("old Central service should have been deregistered") + if _, err := api.domains.Get("old.example.com"); err == nil { + t.Error("old Central domain should have been deregistered") } // New domain should exist - svc, err := api.services.Get("new.example.com") + dom, err := api.domains.Get("new.example.com") if err != nil { - t.Fatalf("new Central service should be registered: %v", err) + t.Fatalf("new Central domain should be registered: %v", err) } - if svc.Source != "central" { - t.Errorf("source = %q, want %q", svc.Source, "central") + if dom.Source != "central" { + t.Errorf("source = %q, want %q", dom.Source, "central") } } -func TestEnsureCentralService_NoDomainIsNoop(t *testing.T) { +func TestEnsureCentralDomain_NoDomainIsNoop(t *testing.T) { api, _ := setupTestAPI(t) api.SetPort(15055) // Default config has no Central domain - api.EnsureCentralService() + api.EnsureCentralDomain() - svcs, _ := api.services.List() - for _, s := range svcs { - if s.Source == "central" { - t.Error("no Central service should be registered when domain is empty") + doms, _ := api.domains.List() + for _, d := range doms { + if d.Source == "central" { + t.Error("no Central domain should be registered when domain is empty") } } } diff --git a/internal/api/v1/handlers_services.go b/internal/api/v1/handlers_services.go deleted file mode 100644 index a3f8054..0000000 --- a/internal/api/v1/handlers_services.go +++ /dev/null @@ -1,132 +0,0 @@ -package v1 - -import ( - "encoding/json" - "fmt" - "net" - "net/http" - "strings" - - "github.com/gorilla/mux" - - "github.com/wild-cloud/wild-central/internal/services" -) - -// ServicesListAll lists all registered services. -func (api *API) ServicesListAll(w http.ResponseWriter, r *http.Request) { - svcs, err := api.services.List() - if err != nil { - respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list services: %v", err)) - return - } - - if svcs == nil { - svcs = []services.Service{} - } - - respondJSON(w, http.StatusOK, map[string]any{ - "services": svcs, - }) -} - -// ServicesGet retrieves a service by domain. -func (api *API) ServicesGet(w http.ResponseWriter, r *http.Request) { - domain := mux.Vars(r)["domain"] - - svc, err := api.services.Get(domain) - if err != nil { - respondError(w, http.StatusNotFound, fmt.Sprintf("Service not found: %v", err)) - return - } - - respondJSON(w, http.StatusOK, svc) -} - -// ServicesRegister creates or updates a service registration. -func (api *API) ServicesRegister(w http.ResponseWriter, r *http.Request) { - var svc services.Service - if err := json.NewDecoder(r.Body).Decode(&svc); err != nil { - respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err)) - return - } - - // Validate routes - for i, r := range svc.Routes { - for _, p := range r.Paths { - if !strings.HasPrefix(p, "/") { - respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: path prefix must start with /: %q", i, p)) - return - } - } - for _, cidr := range r.IPAllow { - if _, _, err := net.ParseCIDR(cidr); err != nil { - respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: invalid CIDR in ipAllow: %q", i, cidr)) - return - } - } - } - - if err := api.services.Register(svc); err != nil { - respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to register service: %v", err)) - return - } - - // Return the stored version (has defaults applied) - stored, _ := api.services.Get(svc.Domain) - respondJSON(w, http.StatusCreated, map[string]any{ - "message": fmt.Sprintf("Service %q registered", svc.Domain), - "service": stored, - }) -} - -// ServicesUpdate applies partial updates to a service. -func (api *API) ServicesUpdate(w http.ResponseWriter, r *http.Request) { - domain := mux.Vars(r)["domain"] - - var updates map[string]any - if err := json.NewDecoder(r.Body).Decode(&updates); err != nil { - respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request body: %v", err)) - return - } - - if err := api.services.Update(domain, updates); err != nil { - respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to update service: %v", err)) - return - } - - svc, _ := api.services.Get(domain) - respondJSON(w, http.StatusOK, map[string]any{ - "message": fmt.Sprintf("Service %q updated", domain), - "service": svc, - }) -} - -// ServicesDeregister removes a service registration. -func (api *API) ServicesDeregister(w http.ResponseWriter, r *http.Request) { - domain := mux.Vars(r)["domain"] - - if err := api.services.Deregister(domain); err != nil { - respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to deregister service: %v", err)) - return - } - - respondMessage(w, http.StatusOK, fmt.Sprintf("Service %q deregistered", domain)) -} - -// ServicesDeregisterBySource removes all services from a source with a given backend. -func (api *API) ServicesDeregisterBySource(w http.ResponseWriter, r *http.Request) { - source := r.URL.Query().Get("source") - backend := r.URL.Query().Get("backend") - - if source == "" { - respondError(w, http.StatusBadRequest, "source query parameter is required") - return - } - - if err := api.services.DeregisterBySource(source, backend); err != nil { - respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to deregister: %v", err)) - return - } - - respondMessage(w, http.StatusOK, fmt.Sprintf("Services from %q deregistered", source)) -} diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index c01a9de..09a6e05 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -9,23 +9,23 @@ import ( "github.com/wild-cloud/wild-central/internal/certbot" "github.com/wild-cloud/wild-central/internal/config" + "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/services" "github.com/wild-cloud/wild-central/internal/sse" ) -// EnsureCentralService registers (or updates) Central's own domain as a service +// EnsureCentralDomain registers (or updates) Central's own domain as a domain // so it participates in DNS, HAProxy routing, and TLS cert tracking like any -// other service. Called on startup and when global config changes. -func (api *API) EnsureCentralService() { +// other domain. Called on startup and when global config changes. +func (api *API) EnsureCentralDomain() { centralDomain := api.getCentralDomain() port := api.getRunningPort() backend := fmt.Sprintf("127.0.0.1:%d", port) // Check if the current registration already matches — skip if so. if centralDomain != "" { - if existing, _ := api.services.Get(centralDomain); existing != nil && + if existing, _ := api.domains.Get(centralDomain); existing != nil && existing.Source == "central" && existing.Backend.Address == backend { return @@ -33,10 +33,10 @@ func (api *API) EnsureCentralService() { } // Clean up stale Central registrations (e.g. domain changed). - svcs, _ := api.services.List() - for _, svc := range svcs { - if svc.Source == "central" && svc.Domain != centralDomain { - _ = api.services.Deregister(svc.Domain) + doms, _ := api.domains.List() + for _, dom := range doms { + if dom.Source == "central" && dom.DomainName != centralDomain { + _ = api.domains.Deregister(dom.DomainName) } } @@ -44,55 +44,56 @@ func (api *API) EnsureCentralService() { return } - _ = api.services.Register(services.Service{ - Domain: centralDomain, - Source: "central", - Backend: services.Backend{ + _ = api.domains.Register(domains.Domain{ + DomainName: centralDomain, + Source: "central", + Backend: domains.Backend{ Address: backend, - Type: services.BackendHTTP, + Type: domains.BackendHTTP, }, - // Public defaults to false (private) - TLS: services.TLSTerminate, + TLS: domains.TLSTerminate, }) } // Reconcile runs networking reconciliation immediately. Called on startup -// and automatically whenever a service is registered/updated/deregistered. +// and automatically whenever a domain is registered/updated/deregistered. func (api *API) Reconcile() { api.reconcileNetworking() } -// reconcileNetworking reads all registered services and regenerates +// reconcileNetworking reads all registered domains and regenerates // dnsmasq DNS entries and HAProxy routes to match. func (api *API) reconcileNetworking() { - svcs, err := api.services.List() + doms, err := api.domains.List() if err != nil { - slog.Error("reconcile: failed to list services", "error", err) + 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.GlobalConfig{} + globalCfg = &config.State{} } - // Build HAProxy routes from registered services + // Build HAProxy routes from registered domains var instanceRoutes []haproxy.InstanceRoute var httpRoutes []haproxy.HTTPRoute - for _, svc := range svcs { - switch svc.EffectiveBackendType() { - case services.BackendTCPPassthrough: + for _, dom := range doms { + switch dom.EffectiveBackendType() { + case domains.BackendTCPPassthrough: instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{ - Name: svc.Domain, - Domain: svc.Domain, - BackendIP: extractHost(svc.EffectiveBackendAddress()), - Subdomains: svc.Subdomains, + Name: dom.DomainName, + Domain: dom.DomainName, + BackendIP: extractHost(dom.EffectiveBackendAddress()), + Subdomains: dom.Subdomains, }) - case services.BackendHTTP: + case domains.BackendDNSOnly: + // dns-only: no gateway routing, just DNS resolution + case domains.BackendHTTP: var routeBackends []haproxy.HTTPRouteBackend - for _, r := range svc.EffectiveRoutes() { + for _, r := range dom.EffectiveRoutes() { routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{ Paths: r.Paths, Backend: r.Backend.Address, @@ -102,15 +103,15 @@ func (api *API) reconcileNetworking() { }) } httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ - Name: svc.Domain, - Domain: svc.Domain, - Subdomains: svc.Subdomains, + Name: dom.DomainName, + Domain: dom.DomainName, + Subdomains: dom.Subdomains, Routes: routeBackends, }) } } - // Only include L7 HTTP routes for services that have a cert. + // Only include L7 HTTP routes for domains that have a cert. certsDir := "/etc/haproxy/certs/" var activeHTTPRoutes []haproxy.HTTPRoute for _, route := range httpRoutes { @@ -129,10 +130,10 @@ func (api *API) reconcileNetworking() { haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, genOpts) if err := api.haproxy.WriteConfig(haproxyCfg); err != nil { - // Validation failed — identify and exclude broken services, then retry + // 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 services and retrying", + slog.Error("reconcile: excluding broken domains and retrying", "broken", brokenDomains, "error", err) exclude := map[string]bool{} @@ -161,22 +162,22 @@ func (api *API) reconcileNetworking() { 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 services)") + api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated (excluded broken domains)") } } } else { - slog.Error("reconcile: failed to write HAProxy config (no broken services identified)", "error", err) + 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 services") + api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated from registered domains") } } - // Generate dnsmasq DNS entries from registered services. - // DNS target IP depends on service type: + // Generate dnsmasq DNS entries from registered domains. + // DNS target IP depends on domain type: // tcp-passthrough (k8s): DNS → k8s LB IP (LAN traffic goes direct to k8s) // http/static: DNS → Central IP (HAProxy terminates TLS) // Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected @@ -190,26 +191,27 @@ func (api *API) reconcileNetworking() { } var instanceConfigs []config.InstanceConfig - for _, svc := range svcs { - if svc.Domain == "" { + for _, dom := range doms { + if dom.DomainName == "" { continue } - // Choose the DNS target IP based on service type + // Choose the DNS target IP based on domain type dnsIP := centralIP - if svc.EffectiveBackendType() == services.BackendTCPPassthrough { - // k8s instances: LAN clients connect directly to the k8s LB - dnsIP = extractHost(svc.EffectiveBackendAddress()) + bt := dom.EffectiveBackendType() + if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly { + // tcp-passthrough/dns-only: DNS resolves to the backend IP directly + dnsIP = extractHost(dom.EffectiveBackendAddress()) } ic := config.InstanceConfig{} ic.Cluster.LoadBalancerIp = dnsIP - if !svc.Public { + if !dom.Public { // Internal-only: local=/ prevents upstream DNS forwarding - ic.Cloud.InternalDomain = svc.Domain + ic.Cloud.InternalDomain = dom.DomainName } else { - ic.Cloud.Domain = svc.Domain + ic.Cloud.Domain = dom.DomainName } instanceConfigs = append(instanceConfigs, ic) @@ -218,51 +220,55 @@ func (api *API) reconcileNetworking() { if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil { slog.Error("reconcile: failed to update dnsmasq", "error", err) } else { - api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered services") + api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains") } - // Ensure TLS certs exist for HTTP services (where Central terminates TLS). - // For services under the gateway domain, a wildcard cert covers them all. + // 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, svcs) + 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", - "services", len(svcs), + "domains", len(doms), "l4Routes", len(instanceRoutes), "l7Routes", len(httpRoutes), ) } -// ensureTLSCerts logs which certificates are missing for registered services. +// 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.GlobalConfig, svcs []services.Service) { +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 _, svc := range svcs { - if svc.TLS != services.TLSTerminate || svc.Domain == "" { + for _, dom := range doms { + if dom.TLS != domains.TLSTerminate || dom.DomainName == "" { continue } // Check if covered by wildcard - if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+gatewayDomain) { + 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", svc.Domain, "needed", "*."+gatewayDomain) + "domain", dom.DomainName, "needed", "*."+gatewayDomain) } continue } // Check individual cert - if _, err := os.Stat(certbot.HAProxyCertPath(svc.Domain)); err != nil { - slog.Warn("reconcile/tls: no cert for service — provision via Certificates page", - "domain", svc.Domain) + 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) } } } @@ -290,9 +296,6 @@ func hasCertForDomain(certsDir, domain string) bool { } for _, e := range entries { if strings.HasSuffix(e.Name(), ".pem") { - // There's at least one cert — HAProxy can start with the dir bind. - // The specific domain may not have its own cert but HAProxy won't crash. - // It will serve whichever cert matches best (or the first one as default). return true } } @@ -324,7 +327,7 @@ func (api *API) broadcastDnsmasqEvent(eventType string, message string) { event := &sse.Event{ ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()), Type: eventType, - InstanceName: "global", // dnsmasq is global/central-level, not instance-specific + InstanceName: "global", Timestamp: time.Now(), Data: map[string]any{ "message": message, diff --git a/internal/config/config.go b/internal/config/config.go index f23d9cd..d02b413 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -79,8 +79,10 @@ type DHCPStaticLease struct { Hostname string `yaml:"hostname,omitempty" json:"hostname,omitempty"` } -// GlobalConfig represents the main configuration structure -type GlobalConfig struct { +// State represents Wild Central's runtime state — operator settings, networking +// configuration, DDNS, DHCP, etc. Persisted in {dataDir}/state.yaml and mutated +// via the API. This is NOT boot-time config (which comes from env vars). +type State struct { Operator struct { Email string `yaml:"email,omitempty" json:"email,omitempty"` } `yaml:"operator,omitempty" json:"operator,omitempty"` @@ -109,22 +111,21 @@ type GlobalConfig struct { ExtraPorts []ExtraPort `yaml:"extraPorts,omitempty" json:"extraPorts,omitempty"` } `yaml:"nftables,omitempty" json:"nftables,omitempty"` DDNS struct { - Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` - Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` - Records []string `yaml:"records,omitempty" json:"records,omitempty"` - IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"` + Enabled bool `yaml:"enabled,omitempty" json:"enabled,omitempty"` + Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` + IntervalMinutes int `yaml:"intervalMinutes,omitempty" json:"intervalMinutes,omitempty"` } `yaml:"ddns,omitempty" json:"ddns,omitempty"` } `yaml:"cloud,omitempty" json:"cloud,omitempty"` } // LoadState loads state from the specified path -func LoadState(configPath string) (*GlobalConfig, error) { +func LoadState(configPath string) (*State, error) { data, err := os.ReadFile(configPath) if err != nil { return nil, fmt.Errorf("reading config file %s: %w", configPath, err) } - config := &GlobalConfig{} + config := &State{} if err := yaml.Unmarshal(data, config); err != nil { return nil, fmt.Errorf("parsing config file: %w", err) } @@ -133,7 +134,7 @@ func LoadState(configPath string) (*GlobalConfig, error) { } // SaveState saves the state to the specified path -func SaveState(config *GlobalConfig, configPath string) error { +func SaveState(config *State, configPath string) error { // Ensure the directory exists if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { return fmt.Errorf("creating config directory: %w", err) @@ -148,7 +149,7 @@ func SaveState(config *GlobalConfig, configPath string) error { } // IsEmpty checks if the configuration is empty or uninitialized -func (c *GlobalConfig) IsEmpty() bool { +func (c *State) IsEmpty() bool { if c == nil { return true } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2ac11d3..742e68b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -14,7 +14,7 @@ func TestLoadState(t *testing.T) { tests := []struct { name string stateYAML string - verify func(t *testing.T, config *GlobalConfig) + verify func(t *testing.T, config *State) wantErr bool }{ { @@ -25,7 +25,7 @@ cloud: central: domain: "central.example.com" `, - verify: func(t *testing.T, config *GlobalConfig) { + verify: func(t *testing.T, config *State) { if config.Operator.Email != "admin@example.com" { t.Error("operator email not loaded correctly") } @@ -40,7 +40,7 @@ cloud: stateYAML: `operator: email: "admin@example.com" `, - verify: func(t *testing.T, config *GlobalConfig) { + verify: func(t *testing.T, config *State) { if config.Operator.Email != "admin@example.com" { t.Error("operator email not loaded correctly") } @@ -50,7 +50,7 @@ cloud: { name: "loads empty state", stateYAML: "{}\n", - verify: func(t *testing.T, config *GlobalConfig) { + verify: func(t *testing.T, config *State) { if config.Operator.Email != "" { t.Error("expected empty operator email") } @@ -139,13 +139,13 @@ func TestLoadState_Errors(t *testing.T) { func TestSaveState(t *testing.T) { tests := []struct { name string - config *GlobalConfig + config *State verify func(t *testing.T, statePath string) }{ { name: "saves complete state", - config: func() *GlobalConfig { - cfg := &GlobalConfig{} + config: func() *State { + cfg := &State{} cfg.Operator.Email = "admin@example.com" cfg.Cloud.Central.Domain = "central.example.com" return cfg @@ -166,7 +166,7 @@ func TestSaveState(t *testing.T) { }, { name: "saves empty state", - config: &GlobalConfig{}, + config: &State{}, verify: func(t *testing.T, statePath string) { if _, err := os.Stat(statePath); os.IsNotExist(err) { t.Error("state file not created") @@ -221,7 +221,7 @@ func TestSaveState_CreatesDirectory(t *testing.T) { tempDir := t.TempDir() statePath := filepath.Join(tempDir, "nested", "dirs", "state.yaml") - config := &GlobalConfig{} + config := &State{} err := SaveState(config, statePath) if err != nil { t.Fatalf("SaveState failed: %v", err) @@ -238,11 +238,11 @@ func TestSaveState_CreatesDirectory(t *testing.T) { } } -// Test: GlobalConfig.IsEmpty checks if config is empty -func TestGlobalConfig_IsEmpty(t *testing.T) { +// Test: State.IsEmpty checks if config is empty +func TestState_IsEmpty(t *testing.T) { tests := []struct { name string - config *GlobalConfig + config *State want bool }{ { @@ -252,13 +252,13 @@ func TestGlobalConfig_IsEmpty(t *testing.T) { }, { name: "default config is empty", - config: &GlobalConfig{}, + config: &State{}, want: true, }, { name: "config with only central domain is not empty", - config: func() *GlobalConfig { - cfg := &GlobalConfig{} + config: func() *State { + cfg := &State{} cfg.Cloud.Central.Domain = "central.example.com" return cfg }(), @@ -266,8 +266,8 @@ func TestGlobalConfig_IsEmpty(t *testing.T) { }, { name: "config with only operator email is not empty", - config: func() *GlobalConfig { - cfg := &GlobalConfig{} + config: func() *State { + cfg := &State{} cfg.Operator.Email = "admin@example.com" return cfg }(), @@ -275,8 +275,8 @@ func TestGlobalConfig_IsEmpty(t *testing.T) { }, { name: "config with all fields is not empty", - config: func() *GlobalConfig { - cfg := &GlobalConfig{} + config: func() *State { + cfg := &State{} cfg.Cloud.Central.Domain = "central.example.com" cfg.Operator.Email = "admin@example.com" return cfg @@ -477,12 +477,12 @@ func TestSaveCloudConfig(t *testing.T) { } // Test: Round-trip save and load preserves data -func TestGlobalConfig_RoundTrip(t *testing.T) { +func TestState_RoundTrip(t *testing.T) { tempDir := t.TempDir() statePath := filepath.Join(tempDir, "state.yaml") // Create config with all fields - original := &GlobalConfig{} + original := &State{} original.Operator.Email = "admin@example.com" original.Cloud.Central.Domain = "central.example.com" diff --git a/internal/config/extraports_test.go b/internal/config/extraports_test.go index cd2313e..d0ef68c 100644 --- a/internal/config/extraports_test.go +++ b/internal/config/extraports_test.go @@ -10,7 +10,7 @@ import ( func TestExtraPortsJSONDecode_Legacy(t *testing.T) { // Legacy format: plain integer array input := `{"cloud":{"nftables":{"extraPorts":[22]}}}` - var cfg GlobalConfig + var cfg State if err := json.Unmarshal([]byte(input), &cfg); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -22,7 +22,7 @@ func TestExtraPortsJSONDecode_Legacy(t *testing.T) { func TestExtraPortsJSONDecode_Structured(t *testing.T) { // New structured format input := `{"cloud":{"nftables":{"extraPorts":[{"port":22,"protocol":"tcp","label":"SSH"},{"port":5353,"protocol":"udp"}]}}}` - var cfg GlobalConfig + var cfg State if err := json.Unmarshal([]byte(input), &cfg); err != nil { t.Fatalf("unmarshal: %v", err) } @@ -38,7 +38,7 @@ func TestExtraPortsJSONDecode_Structured(t *testing.T) { } func TestExtraPortsJSONEncode(t *testing.T) { - var cfg GlobalConfig + var cfg State cfg.Cloud.Nftables.ExtraPorts = []ExtraPort{{Port: 22, Protocol: "tcp", Label: "SSH"}} out, err := json.Marshal(cfg) if err != nil { @@ -68,7 +68,7 @@ func TestExtraPortsJSONEncode(t *testing.T) { func TestExtraPortsYAML_LegacyRoundTrip(t *testing.T) { // Legacy YAML format with plain integers input := "cloud:\n nftables:\n extraPorts:\n - 22\n - 8080\n" - var cfg GlobalConfig + var cfg State if err := yaml.Unmarshal([]byte(input), &cfg); err != nil { t.Fatalf("yaml unmarshal: %v", err) } @@ -82,7 +82,7 @@ func TestExtraPortsYAML_LegacyRoundTrip(t *testing.T) { func TestExtraPortsYAML_StructuredRoundTrip(t *testing.T) { input := "cloud:\n nftables:\n extraPorts:\n - port: 22\n protocol: tcp\n label: SSH\n - port: 5353\n protocol: udp\n" - var cfg GlobalConfig + var cfg State if err := yaml.Unmarshal([]byte(input), &cfg); err != nil { t.Fatalf("yaml unmarshal: %v", err) } diff --git a/internal/crowdsec/manager.go b/internal/crowdsec/manager.go index 0c08456..3d17513 100644 --- a/internal/crowdsec/manager.go +++ b/internal/crowdsec/manager.go @@ -207,7 +207,7 @@ func (m *Manager) AddDecision(ip, decType, reason, duration string) error { // DeleteDecision removes a ban decision by numeric ID func (m *Manager) DeleteDecision(id int) error { - if _, err := runCscli("decisions", "delete", "-i", fmt.Sprintf("%d", id)); err != nil { + if _, err := runCscli("decisions", "delete", "--id", fmt.Sprintf("%d", id)); err != nil { return fmt.Errorf("cscli decisions delete: %w", err) } return nil diff --git a/internal/ddns/cloudflare.go b/internal/ddns/cloudflare.go index 24e5e54..2945a27 100644 --- a/internal/ddns/cloudflare.go +++ b/internal/ddns/cloudflare.go @@ -13,14 +13,18 @@ import ( "time" ) -// Config holds the DDNS configuration -type Config struct { - Enabled bool +// Params holds the runtime parameters for DDNS sync — derived from system state +// (public domains, API token, interval) on each tick via ParamsFunc. +type Params struct { APIToken string Records []string IntervalMinutes int } +// ParamsFunc returns the current DDNS parameters. Called on each tick so the +// runner always has fresh state (new tokens, new public domains, etc.). +type ParamsFunc func() Params + // RecordStatus holds the last-known sync result for a single DNS record. type RecordStatus struct { Name string `json:"name"` @@ -39,13 +43,16 @@ type Status struct { Records []RecordStatus `json:"records,omitempty"` } -// Runner manages the DDNS background goroutine and exposes current state +// Runner manages the DDNS background goroutine and exposes current state. +// The runner pulls fresh parameters via ParamsFunc on each tick, so token +// and record changes take effect without restarting the goroutine. type Runner struct { - mu sync.RWMutex - status Status - trigger chan struct{} - cancel context.CancelFunc - ipFetcher func() (string, error) // injectable for tests + mu sync.RWMutex + status Status + trigger chan struct{} + cancel context.CancelFunc + paramsFn ParamsFunc + ipFetcher func() (string, error) // injectable for tests } // NewRunner creates a new DDNS runner @@ -56,27 +63,30 @@ func NewRunner() *Runner { } } -// Start launches or restarts the DDNS goroutine with the given config. +// Start launches the DDNS goroutine with a params callback. // Safe to call multiple times — cancels the previous run first. -// Enabled is set to true here (not inside Run) to eliminate the race where -// the old goroutine's exit could clear Enabled after the new goroutine starts. -func (rn *Runner) Start(parentCtx context.Context, cfg Config) { +func (rn *Runner) Start(parentCtx context.Context, paramsFn ParamsFunc) { rn.mu.Lock() if rn.cancel != nil { rn.cancel() rn.cancel = nil } - if !cfg.Enabled || cfg.APIToken == "" || len(cfg.Records) == 0 { + rn.paramsFn = paramsFn + + // Validate initial params + p := paramsFn() + if p.APIToken == "" || len(p.Records) == 0 { rn.status.Enabled = false rn.mu.Unlock() return } + ctx, cancel := context.WithCancel(parentCtx) rn.cancel = cancel rn.status.Enabled = true - rn.status.CurrentIP = "" // clear so first check always syncs all records rn.mu.Unlock() - go rn.Run(ctx, cfg) + + go rn.run(ctx) } // Stop cancels the running DDNS goroutine if one is active. @@ -97,7 +107,7 @@ func (rn *Runner) GetStatus() Status { return rn.status } -// Trigger requests an immediate IP check and update (non-blocking) +// Trigger requests an immediate check with fresh params (non-blocking) func (rn *Runner) Trigger() { select { case rn.trigger <- struct{}{}: @@ -105,23 +115,19 @@ func (rn *Runner) Trigger() { } } -// Run starts the DDNS polling loop. It exits when ctx is cancelled. -// Enabled state is managed by Start/Stop, not this function. -func (rn *Runner) Run(ctx context.Context, cfg Config) { - if !cfg.Enabled || cfg.APIToken == "" || len(cfg.Records) == 0 { - slog.Info("DDNS disabled or not configured", "component", "ddns") - return - } +// run is the DDNS polling loop. Calls paramsFn on each tick for fresh state. +func (rn *Runner) run(ctx context.Context) { + p := rn.paramsFn() - interval := time.Duration(cfg.IntervalMinutes) * time.Minute + interval := time.Duration(p.IntervalMinutes) * time.Minute if interval <= 0 { interval = 5 * time.Minute } - slog.Info("DDNS started", "component", "ddns", "records", cfg.Records, "interval", interval) + slog.Info("DDNS started", "component", "ddns", "records", p.Records, "interval", interval) // Check immediately on start - rn.checkAndUpdate(cfg) + rn.checkAndUpdate() ticker := time.NewTicker(interval) defer ticker.Stop() @@ -132,21 +138,27 @@ func (rn *Runner) Run(ctx context.Context, cfg Config) { slog.Info("DDNS stopped", "component", "ddns") return case <-ticker.C: - rn.checkAndUpdate(cfg) + rn.checkAndUpdate() case <-rn.trigger: - rn.checkAndUpdate(cfg) + rn.checkAndUpdate() } } } -// checkAndUpdate fetches the current public IP and reconciles all records. -// Records are always verified against Cloudflare, not just when the IP changes, -// so external drift (e.g. a router DDNS overwriting with a CNAME) is self-healed. -func (rn *Runner) checkAndUpdate(cfg Config) { +// checkAndUpdate fetches fresh params, the current public IP, and syncs all records. +// The updateCloudflareRecord function short-circuits when the A record already matches, +// so there's no wasted API calls when nothing has changed. +func (rn *Runner) checkAndUpdate() { rn.mu.Lock() rn.status.LastChecked = time.Now() rn.mu.Unlock() + p := rn.paramsFn() + if p.APIToken == "" || len(p.Records) == 0 { + slog.Debug("DDNS: no token or records, skipping", "component", "ddns") + return + } + ip, err := rn.ipFetcher() if err != nil { rn.mu.Lock() @@ -156,21 +168,12 @@ func (rn *Runner) checkAndUpdate(cfg Config) { return } - rn.mu.RLock() - ipChanged := rn.status.CurrentIP != ip - rn.mu.RUnlock() - - if !ipChanged { - slog.Debug("DDNS: IP unchanged, skipping update", "component", "ddns", "ip", ip) - return - } - - slog.Info("DDNS: IP changed, updating records", "component", "ddns", "newIP", ip, "records", cfg.Records) + slog.Debug("DDNS: syncing records", "component", "ddns", "ip", ip, "records", p.Records) var lastErr string - records := make([]RecordStatus, 0, len(cfg.Records)) - for _, record := range cfg.Records { - if err := updateCloudflareRecord(cfg.APIToken, record, ip); err != nil { + records := make([]RecordStatus, 0, len(p.Records)) + for _, record := range p.Records { + if err := updateCloudflareRecord(p.APIToken, record, ip); err != nil { lastErr = fmt.Sprintf("updating %s: %v", record, err) slog.Error("DDNS: failed to update record", "component", "ddns", "record", record, "error", err) records = append(records, RecordStatus{Name: record, OK: false, Error: err.Error()}) diff --git a/internal/ddns/cloudflare_test.go b/internal/ddns/cloudflare_test.go index ca04f03..018c60b 100644 --- a/internal/ddns/cloudflare_test.go +++ b/internal/ddns/cloudflare_test.go @@ -36,78 +36,12 @@ func TestTrigger_NonBlocking(t *testing.T) { } } -func TestRun_ExitsOnContextCancel(t *testing.T) { - rn := NewRunner() - ctx, cancel := context.WithCancel(context.Background()) - - done := make(chan struct{}) - go func() { - rn.Run(ctx, Config{}) // not enabled — returns immediately - close(done) - }() - - cancel() - - select { - case <-done: - case <-time.After(time.Second): - t.Error("Run did not exit after context cancel") +func staticParams(token string, records []string) ParamsFunc { + return func() Params { + return Params{APIToken: token, Records: records, IntervalMinutes: 5} } } -func TestRun_DisabledConfig_ExitsImmediately(t *testing.T) { - rn := NewRunner() - ctx := context.Background() - - done := make(chan struct{}) - go func() { - rn.Run(ctx, Config{Enabled: false}) - close(done) - }() - - select { - case <-done: - case <-time.After(time.Second): - t.Error("Run should exit immediately when disabled") - } -} - -func TestRun_NoToken_ExitsImmediately(t *testing.T) { - rn := NewRunner() - ctx := context.Background() - - done := make(chan struct{}) - go func() { - rn.Run(ctx, Config{Enabled: true, APIToken: "", Records: []string{"cloud.example.com"}}) - close(done) - }() - - select { - case <-done: - case <-time.After(time.Second): - t.Error("Run should exit immediately when no API token") - } -} - -func TestRun_NoRecords_ExitsImmediately(t *testing.T) { - rn := NewRunner() - ctx := context.Background() - - done := make(chan struct{}) - go func() { - rn.Run(ctx, Config{Enabled: true, APIToken: "token", Records: nil}) - close(done) - }() - - select { - case <-done: - case <-time.After(time.Second): - t.Error("Run should exit immediately when no records configured") - } -} - -// TestStart_SetsEnabledStatus verifies Enabled=true after Start, false after Stop. -// Uses Start (not Run directly) since Start owns the Enabled lifecycle. func TestStart_SetsEnabledStatus(t *testing.T) { rn := NewRunner() started := make(chan struct{}, 1) @@ -119,8 +53,7 @@ func TestStart_SetsEnabledStatus(t *testing.T) { return "1.2.3.4", nil } - ctx := context.Background() - rn.Start(ctx, Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}) + rn.Start(context.Background(), staticParams("tok", []string{"a.example.com"})) select { case <-started: @@ -129,7 +62,7 @@ func TestStart_SetsEnabledStatus(t *testing.T) { } if !rn.GetStatus().Enabled { - t.Error("expected Enabled=true after Start with valid config") + t.Error("expected Enabled=true after Start with valid params") } rn.Stop() @@ -139,23 +72,20 @@ func TestStart_SetsEnabledStatus(t *testing.T) { } } -// TestStart_InvalidConfig_SetsDisabled verifies Enabled=false when Start is called with bad config. -func TestStart_InvalidConfig_SetsDisabled(t *testing.T) { - for _, cfg := range []Config{ - {Enabled: false}, - {Enabled: true, APIToken: ""}, - {Enabled: true, APIToken: "tok", Records: nil}, +func TestStart_InvalidParams_SetsDisabled(t *testing.T) { + for _, pfn := range []ParamsFunc{ + func() Params { return Params{} }, + func() Params { return Params{APIToken: ""} }, + func() Params { return Params{APIToken: "tok", Records: nil} }, } { rn := NewRunner() - rn.Start(context.Background(), cfg) + rn.Start(context.Background(), pfn) if rn.GetStatus().Enabled { - t.Errorf("expected Enabled=false for config %+v", cfg) + t.Errorf("expected Enabled=false for empty params") } } } -// TestStart_Restart_KeepsEnabled verifies that calling Start twice does not -// clear Enabled — the race where the old goroutine's exit sets Enabled=false. func TestStart_Restart_KeepsEnabled(t *testing.T) { rn := NewRunner() reached := make(chan struct{}, 2) @@ -167,11 +97,9 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) { return "1.2.3.4", nil } - ctx := context.Background() - cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}} - rn.Start(ctx, cfg) + pfn := staticParams("tok", []string{"a.example.com"}) + rn.Start(context.Background(), pfn) - // Wait for first goroutine to actually start select { case <-reached: case <-time.After(time.Second): @@ -179,9 +107,8 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) { } // Restart — old goroutine is cancelled, new one starts - rn.Start(ctx, cfg) + rn.Start(context.Background(), pfn) - // Enabled must still be true regardless of old goroutine's exit timing if !rn.GetStatus().Enabled { t.Error("expected Enabled=true after restart") } @@ -189,8 +116,6 @@ func TestStart_Restart_KeepsEnabled(t *testing.T) { rn.Stop() } - -// TestStop_ClearsEnabled verifies Stop sets Enabled=false. func TestStop_ClearsEnabled(t *testing.T) { rn := NewRunner() rn.mu.Lock() @@ -204,7 +129,6 @@ func TestStop_ClearsEnabled(t *testing.T) { } } -// TestStart_CancelsAndRestarts verifies calling Start twice cancels the previous goroutine. func TestStart_CancelsAndRestarts(t *testing.T) { rn := NewRunner() @@ -219,10 +143,8 @@ func TestStart_CancelsAndRestarts(t *testing.T) { return "1.2.3.4", nil } - ctx := context.Background() - cfg := Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}} - - rn.Start(ctx, cfg) + pfn := staticParams("tok", []string{"a.example.com"}) + rn.Start(context.Background(), pfn) select { case <-firstReady: @@ -230,13 +152,12 @@ func TestStart_CancelsAndRestarts(t *testing.T) { t.Fatal("first goroutine did not start") } - // Unblock the first goroutine so the second Start can cancel it close(block) - // Second Start with disabled config must not deadlock + // Second Start with disabled params must not deadlock done := make(chan struct{}) go func() { - rn.Start(ctx, Config{Enabled: false}) + rn.Start(context.Background(), func() Params { return Params{} }) close(done) }() @@ -247,59 +168,45 @@ func TestStart_CancelsAndRestarts(t *testing.T) { } } -// TestCheckAndUpdate_UpdatesCurrentIP verifies CurrentIP is set after IP fetch. -// Note: this calls updateCloudflareRecord which fails with a fake token, -// but CurrentIP is still updated to reflect the fetched IP. func TestCheckAndUpdate_UpdatesCurrentIP(t *testing.T) { rn := NewRunner() rn.ipFetcher = func() (string, error) { return "5.6.7.8", nil } + rn.paramsFn = staticParams("fake", []string{"a.example.com"}) - rn.checkAndUpdate(Config{APIToken: "fake", Records: []string{"a.example.com"}}) + rn.checkAndUpdate() if rn.GetStatus().CurrentIP != "5.6.7.8" { t.Errorf("expected CurrentIP=5.6.7.8, got %q", rn.GetStatus().CurrentIP) } } -// TestStart_ClearsCurrentIP verifies that Start resets CurrentIP so new records are -// always synced on the first check, even when the public IP hasn't changed. -// This catches the bug where adding a new record (e.g. dev.payne.io) would be -// skipped because currentIP already matched the fetched IP. -func TestStart_ClearsCurrentIP(t *testing.T) { +func TestCheckAndUpdate_SkipsWhenNoParams(t *testing.T) { rn := NewRunner() - rn.mu.Lock() - rn.status.CurrentIP = "1.2.3.4" // simulates a previously running goroutine - rn.mu.Unlock() + rn.ipFetcher = func() (string, error) { return "1.2.3.4", nil } + rn.paramsFn = func() Params { return Params{} } - rn.Start(context.Background(), Config{Enabled: true, APIToken: "tok", Records: []string{"a.example.com"}}) - rn.Stop() + rn.checkAndUpdate() if rn.GetStatus().CurrentIP != "" { - t.Error("Start should clear CurrentIP so the first check syncs all records") + t.Error("expected empty CurrentIP when params have no token/records") } } -// TestCheckAndUpdate_SkipsWhenIPUnchanged verifies no Cloudflare call when IP matches. -func TestCheckAndUpdate_SkipsWhenIPUnchanged(t *testing.T) { +func TestCheckAndUpdate_ParamsFuncCalledEachTime(t *testing.T) { rn := NewRunner() - rn.mu.Lock() - rn.status.CurrentIP = "9.9.9.9" - rn.mu.Unlock() + rn.ipFetcher = func() (string, error) { return "1.2.3.4", nil } calls := 0 - rn.ipFetcher = func() (string, error) { + rn.paramsFn = func() Params { calls++ - return "9.9.9.9", nil + return Params{APIToken: "fake", Records: []string{"a.example.com"}} } - prevUpdated := rn.GetStatus().LastUpdated - rn.checkAndUpdate(Config{APIToken: "tok", Records: []string{"a.example.com"}}) + rn.checkAndUpdate() + rn.checkAndUpdate() - if rn.GetStatus().LastUpdated != prevUpdated { - t.Error("LastUpdated should not change when IP is unchanged") - } - if calls != 1 { - t.Errorf("expected 1 ipFetcher call, got %d", calls) + if calls != 2 { + t.Errorf("expected paramsFn called 2 times, got %d", calls) } } diff --git a/internal/dnsmasq/config.go b/internal/dnsmasq/config.go index 26e9770..49efcd4 100644 --- a/internal/dnsmasq/config.go +++ b/internal/dnsmasq/config.go @@ -35,7 +35,7 @@ func (g *ConfigGenerator) GetConfigPath() string { // Generate creates a dnsmasq configuration from the app config // It auto-detects the Wild Central server's IP address -func (g *ConfigGenerator) Generate(cfg *config.GlobalConfig, clouds []config.InstanceConfig) string { +func (g *ConfigGenerator) Generate(cfg *config.State, clouds []config.InstanceConfig) string { // Use configured dnsmasq IP (bound to eth0), falling back to auto-detect. // Auto-detect can pick wlan0 if that's the default route, which is wrong // when dnsmasq is only listening on eth0. @@ -104,7 +104,7 @@ log-dhcp } // WriteConfig writes the dnsmasq configuration to the specified path -func (g *ConfigGenerator) WriteConfig(cfg *config.GlobalConfig, clouds []config.InstanceConfig, configPath string) error { +func (g *ConfigGenerator) WriteConfig(cfg *config.State, clouds []config.InstanceConfig, configPath string) error { configContent := g.Generate(cfg, clouds) slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath) @@ -212,7 +212,7 @@ func (g *ConfigGenerator) ReadConfig() (string, error) { } // UpdateConfig regenerates and writes the dnsmasq configuration for all instances -func (g *ConfigGenerator) UpdateConfig(cfg *config.GlobalConfig, instances []config.InstanceConfig, restart bool) error { +func (g *ConfigGenerator) UpdateConfig(cfg *config.State, instances []config.InstanceConfig, restart bool) error { // Generate fresh config from scratch configContent := g.Generate(cfg, instances) diff --git a/internal/dnsmasq/config_modular.go b/internal/dnsmasq/config_modular.go index 4ce6a03..346d5a4 100644 --- a/internal/dnsmasq/config_modular.go +++ b/internal/dnsmasq/config_modular.go @@ -18,7 +18,7 @@ const ( // GenerateMainConfig creates the main dnsmasq configuration with global settings // and a conf-dir directive to include per-instance configs -func (g *ConfigGenerator) GenerateMainConfig(cfg *config.GlobalConfig) string { +func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string { // Get the Wild Central IP address dnsIP, err := network.GetWildCentralIP() if err != nil { @@ -264,7 +264,7 @@ func (g *ConfigGenerator) ReloadService() error { } // UpdateToModularConfig migrates from monolithic to modular configuration -func (g *ConfigGenerator) UpdateToModularConfig(cfg *config.GlobalConfig, instanceNames []string, instances []config.InstanceConfig) error { +func (g *ConfigGenerator) UpdateToModularConfig(cfg *config.State, instanceNames []string, instances []config.InstanceConfig) error { slog.Info("migrating to modular configuration", "component", "dnsmasq") // Ensure instance directory exists diff --git a/internal/dnsmasq/config_test.go b/internal/dnsmasq/config_test.go index d5590ba..3d89636 100644 --- a/internal/dnsmasq/config_test.go +++ b/internal/dnsmasq/config_test.go @@ -191,7 +191,7 @@ func TestGenerateMainConfig(t *testing.T) { // Test: GenerateMainConfig with DHCP disabled does not include dhcp-range func TestGenerateMainConfig_DHCPDisabled(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - cfg := &config.GlobalConfig{} + cfg := &config.State{} cfg.Cloud.Dnsmasq.DHCP.Enabled = false out := g.GenerateMainConfig(cfg) @@ -204,7 +204,7 @@ func TestGenerateMainConfig_DHCPDisabled(t *testing.T) { // Test: GenerateMainConfig with DHCP enabled includes dhcp-range func TestGenerateMainConfig_DHCPEnabled(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - cfg := &config.GlobalConfig{} + cfg := &config.State{} cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" @@ -224,7 +224,7 @@ func TestGenerateMainConfig_DHCPEnabled(t *testing.T) { // Test: DHCP without gateway omits dhcp-option=3 func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - cfg := &config.GlobalConfig{} + cfg := &config.State{} cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" @@ -240,7 +240,7 @@ func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) { // Test: DHCP explicit gateway is used func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - cfg := &config.GlobalConfig{} + cfg := &config.State{} cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" @@ -256,7 +256,7 @@ func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) { // Test: DHCP static leases appear in output func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - cfg := &config.GlobalConfig{} + cfg := &config.State{} cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" @@ -278,7 +278,7 @@ func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) { // Test: DHCP lease time defaults to 24h when not specified func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - cfg := &config.GlobalConfig{} + cfg := &config.State{} cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" @@ -310,7 +310,7 @@ func TestNewConfigGenerator_DefaultPath(t *testing.T) { // Test: Service registration — domain set but no internal domain func TestGenerate_ServiceWithoutInternalDomain(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - globalCfg := &config.GlobalConfig{} + globalCfg := &config.State{} // Service registration: only domain + backend IP, no internal domain inst := instanceWith("my-api.payne.io", "", "192.168.8.151") @@ -351,7 +351,7 @@ func TestGenerateInstanceConfig_ServiceWithoutInternalDomain(t *testing.T) { // Test: reach:internal → local=/ directive present (prevents upstream DNS forwarding) func TestGenerate_ReachInternal_HasLocal(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - globalCfg := &config.GlobalConfig{} + globalCfg := &config.State{} // Simulate reconciliation for an internal-reach service: // InternalDomain is set (reach:internal), Domain is empty (reach:public) @@ -370,7 +370,7 @@ func TestGenerate_ReachInternal_HasLocal(t *testing.T) { // Test: reach:public → no local=/ directive (allows upstream DNS forwarding) func TestGenerate_ReachPublic_NoLocal(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - globalCfg := &config.GlobalConfig{} + globalCfg := &config.State{} // Simulate reconciliation for a public-reach service: // Domain is set (reach:public), InternalDomain is empty @@ -389,7 +389,7 @@ func TestGenerate_ReachPublic_NoLocal(t *testing.T) { // Test: InternalDomain field → local=/ entry for LAN-only resolution func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - globalCfg := &config.GlobalConfig{} + globalCfg := &config.State{} // Instance with only an internal domain (no external domain) inst := instanceWith("", "internal.cloud.payne.io", "192.168.8.240") @@ -411,7 +411,7 @@ func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) { // Test: Mix of instances with and without internal domains func TestGenerate_MixedServicesAndInstances(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - globalCfg := &config.GlobalConfig{} + globalCfg := &config.State{} instances := []config.InstanceConfig{ instanceWith("cloud.payne.io", "internal.cloud.payne.io", "192.168.8.240"), // k8s instance @@ -438,7 +438,7 @@ func TestGenerate_MixedServicesAndInstances(t *testing.T) { // Test: empty instances list produces base config with no address= entries func TestGenerate_EmptyInstances(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - globalCfg := &config.GlobalConfig{} + globalCfg := &config.State{} out := g.Generate(globalCfg, []config.InstanceConfig{}) @@ -462,7 +462,7 @@ func TestGenerate_EmptyInstances(t *testing.T) { // Test: multiple instances each get their own entries func TestGenerate_MultipleInstances(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - globalCfg := &config.GlobalConfig{} + globalCfg := &config.State{} instances := []config.InstanceConfig{ instanceWith("cloud1.example.com", "internal1.example.com", "10.0.0.1"), @@ -504,7 +504,7 @@ func TestGenerate_NilConfig(t *testing.T) { // Test: when cfg.Cloud.Dnsmasq.IP is set, uses that instead of auto-detect func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - cfg := &config.GlobalConfig{} + cfg := &config.State{} cfg.Cloud.Dnsmasq.IP = "192.168.8.100" out := g.Generate(cfg, []config.InstanceConfig{}) @@ -517,7 +517,7 @@ func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) { // Test: verify no address=// or local=// entries appear in any test output func TestGenerate_NoLeakedEmptyEntries(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - cfg := &config.GlobalConfig{} + cfg := &config.State{} cases := []struct { name string diff --git a/internal/services/manager.go b/internal/domains/manager.go similarity index 51% rename from internal/services/manager.go rename to internal/domains/manager.go index 1a2835c..615c1d1 100644 --- a/internal/services/manager.go +++ b/internal/domains/manager.go @@ -1,10 +1,10 @@ -// Package services manages service registrations from Wild Cloud, Wild Works, -// and manual entries. Services register with Central to get DNS, gateway +// Package domains manages domain registrations from Wild Cloud, Wild Works, +// and manual entries. Domains register with Central to get DNS, gateway // routing, TLS certificates, and optional public exposure via DDNS. // // Each registration is keyed by its domain — one registration per domain. // Central registers its own UI domain here on startup (source: "central"). -package services +package domains import ( "fmt" @@ -17,36 +17,38 @@ import ( "gopkg.in/yaml.v3" ) -// BackendType describes how the gateway handles traffic for this service. +// BackendType describes how the gateway handles traffic for this domain. type BackendType string const ( BackendTCPPassthrough BackendType = "tcp-passthrough" // L4 SNI passthrough (k8s) BackendHTTP BackendType = "http" // L7 HTTP reverse proxy + BackendDNSOnly BackendType = "dns-only" // DNS resolution only, no gateway ) -// TLSMode describes how TLS is handled for the service. +// TLSMode describes how TLS is handled for the domain. type TLSMode string const ( TLSTerminate TLSMode = "terminate" // Central terminates TLS TLSPassthrough TLSMode = "passthrough" // Backend handles TLS (k8s traefik) + TLSNone TLSMode = "none" // No TLS (dns-only) ) -// Backend describes the target for a service. +// Backend describes the target for a domain. type Backend struct { Address string `yaml:"address" json:"address"` // host:port Type BackendType `yaml:"type" json:"type"` // tcp-passthrough or http Health string `yaml:"health,omitempty" json:"health,omitempty"` } -// HeaderConfig describes custom request/response headers for L7 HTTP services. +// HeaderConfig describes custom request/response headers for L7 HTTP domains. type HeaderConfig struct { Request map[string]string `yaml:"request,omitempty" json:"request,omitempty"` Response map[string]string `yaml:"response,omitempty" json:"response,omitempty"` } -// Route describes one path→backend mapping within a service. +// Route describes one path→backend mapping within a domain. // Routes are evaluated in order — first match wins. // A route with empty Paths is a catch-all (should be last). type Route struct { @@ -56,12 +58,12 @@ type Route struct { IPAllow []string `yaml:"ipAllow,omitempty" json:"ipAllow,omitempty"` // per-route CIDR whitelist } -// Service represents a registered service. The Domain is the unique key. +// Domain represents a registered domain. The Domain field is the unique key. // -// Simple services use Backend directly. Multi-backend services use Routes +// Simple domains use Backend directly. Multi-backend domains use Routes // for path-based splitting (Backend is ignored when Routes is present). -type Service struct { - Domain string `yaml:"domain" json:"domain"` // FQDN — unique key +type Domain struct { + DomainName string `yaml:"domain" json:"domain"` // FQDN — unique key Source string `yaml:"source,omitempty" json:"source,omitempty"` // who registered: wild-cloud, wild-works, manual Backend Backend `yaml:"backend,omitempty" json:"backend,omitempty"` // single backend (simple case) Subdomains bool `yaml:"subdomains,omitempty" json:"subdomains,omitempty"` // also match *.domain @@ -73,58 +75,71 @@ type Service struct { Routes []Route `yaml:"routes,omitempty" json:"routes,omitempty"` } -// EffectiveRoutes returns the service's routing as a normalized []Route. -// Simple services (Backend, no Routes) are wrapped in a single-element slice. -func (s *Service) EffectiveRoutes() []Route { - if len(s.Routes) > 0 { - return s.Routes +// EffectiveRoutes returns the domain's routing as a normalized []Route. +// Simple domains (Backend, no Routes) are wrapped in a single-element slice. +func (d *Domain) EffectiveRoutes() []Route { + if len(d.Routes) > 0 { + return d.Routes } - if s.Backend.Address == "" { + if d.Backend.Address == "" { return nil } - return []Route{{Backend: s.Backend}} + return []Route{{Backend: d.Backend}} } // EffectiveBackendAddress returns the primary backend address for DNS/DDNS purposes. -// For multi-route services, returns the first route's backend. -func (s *Service) EffectiveBackendAddress() string { - if len(s.Routes) > 0 { - return s.Routes[0].Backend.Address +// For multi-route domains, returns the first route's backend. +func (d *Domain) EffectiveBackendAddress() string { + if len(d.Routes) > 0 { + return d.Routes[0].Backend.Address } - return s.Backend.Address + return d.Backend.Address } // EffectiveBackendType returns the backend type for routing decisions. -func (s *Service) EffectiveBackendType() BackendType { - if len(s.Routes) > 0 { - return s.Routes[0].Backend.Type +func (d *Domain) EffectiveBackendType() BackendType { + if len(d.Routes) > 0 { + return d.Routes[0].Backend.Type } - return s.Backend.Type + return d.Backend.Type } -// Manager handles service registration CRUD and triggers networking reconciliation. +// Manager handles domain registration CRUD and triggers networking reconciliation. type Manager struct { dataDir string mu sync.RWMutex reconcileFn func() } -// NewManager creates a new service registration manager. +// NewManager creates a new domain registration manager. func NewManager(dataDir string) *Manager { - servicesDir := filepath.Join(dataDir, "services") - if err := os.MkdirAll(servicesDir, 0755); err != nil { - slog.Warn("failed to create services directory", "path", servicesDir, "error", err) + domainsDir := filepath.Join(dataDir, "domains") + oldServicesDir := filepath.Join(dataDir, "services") + + // Migrate from old "services" directory if it exists + if _, err := os.Stat(oldServicesDir); err == nil { + if _, err := os.Stat(domainsDir); os.IsNotExist(err) { + if err := os.Rename(oldServicesDir, domainsDir); err != nil { + slog.Warn("failed to migrate services dir to domains", "error", err) + } else { + slog.Info("migrated data directory", "from", oldServicesDir, "to", domainsDir) + } + } + } + + if err := os.MkdirAll(domainsDir, 0755); err != nil { + slog.Warn("failed to create domains directory", "path", domainsDir, "error", err) } return &Manager{dataDir: dataDir} } -// SetReconcileFn sets the function called after service changes to update networking. +// SetReconcileFn sets the function called after domain changes to update networking. func (m *Manager) SetReconcileFn(fn func()) { m.reconcileFn = fn } -func (m *Manager) servicesDir() string { - return filepath.Join(m.dataDir, "services") +func (m *Manager) domainsDir() string { + return filepath.Join(m.dataDir, "domains") } // domainToFilename converts a domain to a filesystem-safe filename. @@ -132,31 +147,31 @@ func domainToFilename(domain string) string { return strings.ReplaceAll(domain, ".", "_") + ".yaml" } -func (m *Manager) servicePath(domain string) string { - return filepath.Join(m.servicesDir(), domainToFilename(domain)) +func (m *Manager) domainPath(domain string) string { + return filepath.Join(m.domainsDir(), domainToFilename(domain)) } -// Register creates or updates a service registration. -func (m *Manager) Register(svc Service) error { +// Register creates or updates a domain registration. +func (m *Manager) Register(dom Domain) error { m.mu.Lock() defer m.mu.Unlock() - if svc.Domain == "" { + if dom.DomainName == "" { return fmt.Errorf("domain is required") } - hasBackend := svc.Backend.Address != "" - hasRoutes := len(svc.Routes) > 0 + hasBackend := dom.Backend.Address != "" + hasRoutes := len(dom.Routes) > 0 if hasBackend && hasRoutes { - return fmt.Errorf("service must use backend or routes, not both") + return fmt.Errorf("domain must use backend or routes, not both") } if !hasBackend && !hasRoutes { return fmt.Errorf("backend address or routes required") } if hasRoutes { - for i, r := range svc.Routes { + for i, r := range dom.Routes { if r.Backend.Address == "" { return fmt.Errorf("route %d: backend address is required", i) } @@ -165,36 +180,38 @@ func (m *Manager) Register(svc Service) error { } } } else { - if svc.Backend.Type == "" { + if dom.Backend.Type == "" { return fmt.Errorf("backend type is required") } } // Default TLS mode based on backend type - if svc.TLS == "" { - bt := svc.EffectiveBackendType() - if bt == BackendTCPPassthrough { - svc.TLS = TLSPassthrough - } else { - svc.TLS = TLSTerminate + if dom.TLS == "" { + switch dom.EffectiveBackendType() { + case BackendTCPPassthrough: + dom.TLS = TLSPassthrough + case BackendDNSOnly: + dom.TLS = TLSNone + default: + dom.TLS = TLSTerminate } } // Default source - if svc.Source == "" { - svc.Source = "manual" + if dom.Source == "" { + dom.Source = "manual" } - data, err := yaml.Marshal(svc) + data, err := yaml.Marshal(dom) if err != nil { - return fmt.Errorf("marshaling service: %w", err) + return fmt.Errorf("marshaling domain: %w", err) } - if err := os.WriteFile(m.servicePath(svc.Domain), data, 0644); err != nil { - return fmt.Errorf("writing service file: %w", err) + if err := os.WriteFile(m.domainPath(dom.DomainName), data, 0644); err != nil { + return fmt.Errorf("writing domain file: %w", err) } - slog.Info("service registered", "domain", svc.Domain, "public", svc.Public, "source", svc.Source, "subdomains", svc.Subdomains) + slog.Info("domain registered", "domain", dom.DomainName, "public", dom.Public, "source", dom.Source, "subdomains", dom.Subdomains) if m.reconcileFn != nil { go func() { @@ -210,21 +227,21 @@ func (m *Manager) Register(svc Service) error { return nil } -// Deregister removes a service registration by domain. +// Deregister removes a domain registration by domain name. func (m *Manager) Deregister(domain string) error { m.mu.Lock() defer m.mu.Unlock() - path := m.servicePath(domain) + path := m.domainPath(domain) if _, err := os.Stat(path); os.IsNotExist(err) { - return fmt.Errorf("service %q not found", domain) + return fmt.Errorf("domain %q not found", domain) } if err := os.Remove(path); err != nil { - return fmt.Errorf("removing service file: %w", err) + return fmt.Errorf("removing domain file: %w", err) } - slog.Info("service deregistered", "domain", domain) + slog.Info("domain deregistered", "domain", domain) if m.reconcileFn != nil { go m.reconcileFn() @@ -233,76 +250,76 @@ func (m *Manager) Deregister(domain string) error { return nil } -// Get retrieves a service registration by domain. -func (m *Manager) Get(domain string) (*Service, error) { +// Get retrieves a domain registration by domain name. +func (m *Manager) Get(domain string) (*Domain, error) { m.mu.RLock() defer m.mu.RUnlock() - data, err := os.ReadFile(m.servicePath(domain)) + data, err := os.ReadFile(m.domainPath(domain)) if err != nil { if os.IsNotExist(err) { - return nil, fmt.Errorf("service %q not found", domain) + return nil, fmt.Errorf("domain %q not found", domain) } - return nil, fmt.Errorf("reading service file: %w", err) + return nil, fmt.Errorf("reading domain file: %w", err) } - var svc Service - if err := yaml.Unmarshal(data, &svc); err != nil { - return nil, fmt.Errorf("parsing service file: %w", err) + var dom Domain + if err := yaml.Unmarshal(data, &dom); err != nil { + return nil, fmt.Errorf("parsing domain file: %w", err) } - return &svc, nil + return &dom, nil } -// List returns all registered services. -func (m *Manager) List() ([]Service, error) { +// List returns all registered domains. +func (m *Manager) List() ([]Domain, error) { m.mu.RLock() defer m.mu.RUnlock() - entries, err := os.ReadDir(m.servicesDir()) + entries, err := os.ReadDir(m.domainsDir()) if err != nil { if os.IsNotExist(err) { return nil, nil } - return nil, fmt.Errorf("reading services directory: %w", err) + return nil, fmt.Errorf("reading domains directory: %w", err) } - var services []Service + var doms []Domain for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".yaml" { continue } - data, err := os.ReadFile(filepath.Join(m.servicesDir(), entry.Name())) + data, err := os.ReadFile(filepath.Join(m.domainsDir(), entry.Name())) if err != nil { - slog.Warn("failed to read service file", "file", entry.Name(), "error", err) + slog.Warn("failed to read domain file", "file", entry.Name(), "error", err) continue } - var svc Service - if err := yaml.Unmarshal(data, &svc); err != nil { - slog.Warn("failed to parse service file", "file", entry.Name(), "error", err) + var dom Domain + if err := yaml.Unmarshal(data, &dom); err != nil { + slog.Warn("failed to parse domain file", "file", entry.Name(), "error", err) continue } - services = append(services, svc) + doms = append(doms, dom) } - return services, nil + return doms, nil } // DeregisterBySource removes all registrations from a given source that match // a backend address. Used by consumers to clean up before re-registering. func (m *Manager) DeregisterBySource(source, backendAddress string) error { - svcs, err := m.List() + doms, err := m.List() if err != nil { return err } - for _, svc := range svcs { - if svc.Source == source && svc.EffectiveBackendAddress() == backendAddress { - if err := m.Deregister(svc.Domain); err != nil { - slog.Warn("failed to deregister service during cleanup", "domain", svc.Domain, "error", err) + for _, dom := range doms { + if dom.Source == source && dom.EffectiveBackendAddress() == backendAddress { + if err := m.Deregister(dom.DomainName); err != nil { + slog.Warn("failed to deregister domain during cleanup", "domain", dom.DomainName, "error", err) } } } @@ -310,32 +327,33 @@ func (m *Manager) DeregisterBySource(source, backendAddress string) error { return nil } -// Update applies partial updates to a service registration. +// Update applies partial updates to a domain registration. func (m *Manager) Update(domain string, updates map[string]any) error { - svc, err := m.Get(domain) + dom, err := m.Get(domain) if err != nil { return err } if public, ok := updates["public"].(bool); ok { - svc.Public = public + dom.Public = public } if sub, ok := updates["subdomains"].(bool); ok { - svc.Subdomains = sub + dom.Subdomains = sub } if backend, ok := updates["backend"].(map[string]any); ok { if addr, ok := backend["address"].(string); ok { - svc.Backend.Address = addr + dom.Backend.Address = addr } if typ, ok := backend["type"].(string); ok { - svc.Backend.Type = BackendType(typ) + dom.Backend.Type = BackendType(typ) } if health, ok := backend["health"].(string); ok { - svc.Backend.Health = health + dom.Backend.Health = health } } - // Routes is a full replacement — partial route updates are too complex - // to express via map[string]any. Re-register the full service instead. + if tls, ok := updates["tls"].(string); ok { + dom.TLS = TLSMode(tls) + } - return m.Register(*svc) + return m.Register(*dom) } diff --git a/internal/services/manager_test.go b/internal/domains/manager_test.go similarity index 58% rename from internal/services/manager_test.go rename to internal/domains/manager_test.go index 2054a57..e79d6b7 100644 --- a/internal/services/manager_test.go +++ b/internal/domains/manager_test.go @@ -1,4 +1,4 @@ -package services +package domains import ( "os" @@ -9,17 +9,16 @@ import ( func TestRegisterAndGet(t *testing.T) { mgr := NewManager(t.TempDir()) - svc := Service{ - Domain: "my-api.payne.io", - Source: "wild-works", + dom := Domain{ + DomainName: "my-api.payne.io", + Source: "wild-works", Backend: Backend{ Address: "192.168.8.60:9001", Type: BackendHTTP, }, - // Public defaults to false } - if err := mgr.Register(svc); err != nil { + if err := mgr.Register(dom); err != nil { t.Fatalf("Register failed: %v", err) } @@ -28,8 +27,8 @@ func TestRegisterAndGet(t *testing.T) { t.Fatalf("Get failed: %v", err) } - if got.Domain != "my-api.payne.io" { - t.Errorf("expected domain my-api.payne.io, got %s", got.Domain) + if got.DomainName != "my-api.payne.io" { + t.Errorf("expected domain my-api.payne.io, got %s", got.DomainName) } if got.Backend.Address != "192.168.8.60:9001" { t.Errorf("expected backend 192.168.8.60:9001, got %s", got.Backend.Address) @@ -45,18 +44,18 @@ func TestRegisterAndGet(t *testing.T) { func TestRegisterTCPPassthrough(t *testing.T) { mgr := NewManager(t.TempDir()) - svc := Service{ - Domain: "cloud.payne.io", - Source: "wild-cloud", + dom := Domain{ + DomainName: "cloud.payne.io", + Source: "wild-cloud", Backend: Backend{ Address: "192.168.8.240:443", Type: BackendTCPPassthrough, }, Subdomains: true, - Public: true, + Public: true, } - if err := mgr.Register(svc); err != nil { + if err := mgr.Register(dom); err != nil { t.Fatalf("Register failed: %v", err) } @@ -76,37 +75,35 @@ func TestRegisterTCPPassthrough(t *testing.T) { } } -func TestListServices(t *testing.T) { +func TestListDomains(t *testing.T) { mgr := NewManager(t.TempDir()) for _, domain := range []string{"a.example.com", "b.example.com", "c.example.com"} { - if err := mgr.Register(Service{ - Domain: domain, - Source: "test", - Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, - // Public defaults to false + if err := mgr.Register(Domain{ + DomainName: domain, + Source: "test", + Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, }); err != nil { t.Fatalf("Register %s failed: %v", domain, err) } } - svcs, err := mgr.List() + doms, err := mgr.List() if err != nil { t.Fatalf("List failed: %v", err) } - if len(svcs) != 3 { - t.Errorf("expected 3 services, got %d", len(svcs)) + if len(doms) != 3 { + t.Errorf("expected 3 domains, got %d", len(doms)) } } func TestDeregister(t *testing.T) { mgr := NewManager(t.TempDir()) - if err := mgr.Register(Service{ - Domain: "temp.example.com", - Source: "test", - Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, - // Public defaults to false + if err := mgr.Register(Domain{ + DomainName: "temp.example.com", + Source: "test", + Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, }); err != nil { t.Fatalf("Register failed: %v", err) } @@ -123,45 +120,43 @@ func TestDeregister(t *testing.T) { func TestDeregisterBySource(t *testing.T) { mgr := NewManager(t.TempDir()) - // Register 3 services from wild-cloud with same backend + // Register 3 domains from wild-cloud with same backend for _, domain := range []string{"cloud.payne.io", "internal.cloud.payne.io", "payne.io"} { - mgr.Register(Service{ - Domain: domain, - Source: "wild-cloud", - Backend: Backend{Address: "192.168.8.240:443", Type: BackendTCPPassthrough}, - Public: true, + mgr.Register(Domain{ + DomainName: domain, + Source: "wild-cloud", + Backend: Backend{Address: "192.168.8.240:443", Type: BackendTCPPassthrough}, + Public: true, }) } - // Register 1 service from wild-works (different source) - mgr.Register(Service{ - Domain: "my-api.payne.io", - Source: "wild-works", - Backend: Backend{Address: "127.0.0.1:9001", Type: BackendHTTP}, - // Public defaults to false + // Register 1 domain from wild-works (different source) + mgr.Register(Domain{ + DomainName: "my-api.payne.io", + Source: "wild-works", + Backend: Backend{Address: "127.0.0.1:9001", Type: BackendHTTP}, }) - // Deregister all wild-cloud services with backend 192.168.8.240:443 + // Deregister all wild-cloud domains with backend 192.168.8.240:443 if err := mgr.DeregisterBySource("wild-cloud", "192.168.8.240:443"); err != nil { t.Fatalf("DeregisterBySource failed: %v", err) } - svcs, _ := mgr.List() - if len(svcs) != 1 { - t.Errorf("expected 1 remaining service, got %d", len(svcs)) + doms, _ := mgr.List() + if len(doms) != 1 { + t.Errorf("expected 1 remaining domain, got %d", len(doms)) } - if svcs[0].Domain != "my-api.payne.io" { - t.Errorf("expected wild-works service to remain, got %s", svcs[0].Domain) + if doms[0].DomainName != "my-api.payne.io" { + t.Errorf("expected wild-works domain to remain, got %s", doms[0].DomainName) } } func TestUpdate(t *testing.T) { mgr := NewManager(t.TempDir()) - mgr.Register(Service{ - Domain: "updatable.example.com", - Source: "test", - Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, - // Public defaults to false + mgr.Register(Domain{ + DomainName: "updatable.example.com", + Source: "test", + Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, }) if err := mgr.Update("updatable.example.com", map[string]any{ @@ -184,17 +179,17 @@ func TestRegisterValidation(t *testing.T) { mgr := NewManager(t.TempDir()) // Missing domain - if err := mgr.Register(Service{Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}}); err == nil { + if err := mgr.Register(Domain{Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}}); err == nil { t.Error("expected error for missing domain") } // Missing backend address - if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Type: BackendHTTP}}); err == nil { + if err := mgr.Register(Domain{DomainName: "x.com", Backend: Backend{Type: BackendHTTP}}); err == nil { t.Error("expected error for missing backend address") } // Missing backend type - if err := mgr.Register(Service{Domain: "x.com", Backend: Backend{Address: "127.0.0.1:80"}}); err == nil { + if err := mgr.Register(Domain{DomainName: "x.com", Backend: Backend{Address: "127.0.0.1:80"}}); err == nil { t.Error("expected error for missing backend type") } } @@ -202,10 +197,9 @@ func TestRegisterValidation(t *testing.T) { func TestDefaultSource(t *testing.T) { mgr := NewManager(t.TempDir()) - mgr.Register(Service{ - Domain: "test.com", - Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}, - // Public defaults to false + mgr.Register(Domain{ + DomainName: "test.com", + Backend: Backend{Address: "127.0.0.1:80", Type: BackendHTTP}, }) got, _ := mgr.Get("test.com") @@ -217,31 +211,30 @@ func TestDefaultSource(t *testing.T) { func TestRegisterIdempotent(t *testing.T) { mgr := NewManager(t.TempDir()) - svc := Service{ - Domain: "idempotent.example.com", - Source: "test", - Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, - // Public defaults to false + dom := Domain{ + DomainName: "idempotent.example.com", + Source: "test", + Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, } // Register twice with same domain - if err := mgr.Register(svc); err != nil { + if err := mgr.Register(dom); err != nil { t.Fatalf("first Register failed: %v", err) } // Update the backend address on second registration - svc.Backend.Address = "127.0.0.1:9090" - if err := mgr.Register(svc); err != nil { + dom.Backend.Address = "127.0.0.1:9090" + if err := mgr.Register(dom); err != nil { t.Fatalf("second Register failed: %v", err) } - // Should still have only one service - svcs, err := mgr.List() + // Should still have only one domain + doms, err := mgr.List() if err != nil { t.Fatalf("List failed: %v", err) } - if len(svcs) != 1 { - t.Errorf("expected 1 service after idempotent register, got %d", len(svcs)) + if len(doms) != 1 { + t.Errorf("expected 1 domain after idempotent register, got %d", len(doms)) } // Should have the updated address @@ -257,50 +250,48 @@ func TestRegisterIdempotent(t *testing.T) { func TestListNoDuplicates(t *testing.T) { mgr := NewManager(t.TempDir()) - domains := []string{ + domainNames := []string{ "alpha.example.com", "beta.example.com", "gamma.example.com", } // Register each domain - for _, domain := range domains { - if err := mgr.Register(Service{ - Domain: domain, - Source: "test", - Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, - // Public defaults to false + for _, name := range domainNames { + if err := mgr.Register(Domain{ + DomainName: name, + Source: "test", + Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, }); err != nil { - t.Fatalf("Register %s failed: %v", domain, err) + t.Fatalf("Register %s failed: %v", name, err) } } // Re-register one domain to verify no duplication - if err := mgr.Register(Service{ - Domain: "beta.example.com", - Source: "test", - Backend: Backend{Address: "127.0.0.1:9090", Type: BackendHTTP}, - // Public defaults to false + if err := mgr.Register(Domain{ + DomainName: "beta.example.com", + Source: "test", + Backend: Backend{Address: "127.0.0.1:9090", Type: BackendHTTP}, }); err != nil { t.Fatalf("Re-register beta failed: %v", err) } - svcs, err := mgr.List() + doms, err := mgr.List() if err != nil { t.Fatalf("List failed: %v", err) } - if len(svcs) != 3 { - t.Errorf("expected 3 unique services, got %d", len(svcs)) + if len(doms) != 3 { + t.Errorf("expected 3 unique domains, got %d", len(doms)) } // Verify no duplicate domains seen := make(map[string]bool) - for _, svc := range svcs { - if seen[svc.Domain] { - t.Errorf("duplicate domain in List(): %s", svc.Domain) + for _, dom := range doms { + if seen[dom.DomainName] { + t.Errorf("duplicate domain in List(): %s", dom.DomainName) } - seen[svc.Domain] = true + seen[dom.DomainName] = true } } @@ -343,18 +334,16 @@ func TestDeregister_NotFound(t *testing.T) { func TestDeregisterBySource_NoMatch(t *testing.T) { mgr := NewManager(t.TempDir()) - // Register some services - mgr.Register(Service{ - Domain: "a.example.com", - Source: "wild-cloud", - Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough}, - Public: true, + mgr.Register(Domain{ + DomainName: "a.example.com", + Source: "wild-cloud", + Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough}, + Public: true, }) - mgr.Register(Service{ - Domain: "b.example.com", - Source: "wild-works", - Backend: Backend{Address: "10.0.0.2:8080", Type: BackendHTTP}, - // Public defaults to false + mgr.Register(Domain{ + DomainName: "b.example.com", + Source: "wild-works", + Backend: Backend{Address: "10.0.0.2:8080", Type: BackendHTTP}, }) // Deregister with a source that doesn't match anything @@ -363,10 +352,10 @@ func TestDeregisterBySource_NoMatch(t *testing.T) { t.Fatalf("DeregisterBySource with no matches should not error: %v", err) } - // All services should still be present - svcs, _ := mgr.List() - if len(svcs) != 2 { - t.Errorf("expected 2 services after no-match deregister, got %d", len(svcs)) + // All domains should still be present + doms, _ := mgr.List() + if len(doms) != 2 { + t.Errorf("expected 2 domains after no-match deregister, got %d", len(doms)) } } @@ -374,17 +363,17 @@ func TestDeregisterBySource_PartialMatch(t *testing.T) { mgr := NewManager(t.TempDir()) // Same source, different backends - mgr.Register(Service{ - Domain: "a.example.com", - Source: "wild-cloud", - Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough}, - Public: true, + mgr.Register(Domain{ + DomainName: "a.example.com", + Source: "wild-cloud", + Backend: Backend{Address: "10.0.0.1:443", Type: BackendTCPPassthrough}, + Public: true, }) - mgr.Register(Service{ - Domain: "b.example.com", - Source: "wild-cloud", - Backend: Backend{Address: "10.0.0.2:443", Type: BackendTCPPassthrough}, - Public: true, + mgr.Register(Domain{ + DomainName: "b.example.com", + Source: "wild-cloud", + Backend: Backend{Address: "10.0.0.2:443", Type: BackendTCPPassthrough}, + Public: true, }) // Deregister only the ones matching source AND backend @@ -393,12 +382,12 @@ func TestDeregisterBySource_PartialMatch(t *testing.T) { t.Fatalf("DeregisterBySource failed: %v", err) } - svcs, _ := mgr.List() - if len(svcs) != 1 { - t.Errorf("expected 1 remaining service, got %d", len(svcs)) + doms, _ := mgr.List() + if len(doms) != 1 { + t.Errorf("expected 1 remaining domain, got %d", len(doms)) } - if svcs[0].Domain != "b.example.com" { - t.Errorf("expected b.example.com to remain, got %s", svcs[0].Domain) + if doms[0].DomainName != "b.example.com" { + t.Errorf("expected b.example.com to remain, got %s", doms[0].DomainName) } } @@ -417,26 +406,25 @@ func TestRegister_OverwritePreservesFile(t *testing.T) { dir := t.TempDir() mgr := NewManager(dir) - svc := Service{ - Domain: "overwrite.example.com", - Source: "test", - Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, - // Public defaults to false + dom := Domain{ + DomainName: "overwrite.example.com", + Source: "test", + Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, } // Register first time - if err := mgr.Register(svc); err != nil { + if err := mgr.Register(dom); err != nil { t.Fatalf("first Register failed: %v", err) } // Register again with different backend - svc.Backend.Address = "127.0.0.1:9090" - if err := mgr.Register(svc); err != nil { + dom.Backend.Address = "127.0.0.1:9090" + if err := mgr.Register(dom); err != nil { t.Fatalf("second Register failed: %v", err) } // File count should be exactly 1 - entries, err := os.ReadDir(filepath.Join(dir, "services")) + entries, err := os.ReadDir(filepath.Join(dir, "domains")) if err != nil { t.Fatalf("ReadDir failed: %v", err) } @@ -454,21 +442,21 @@ func TestRegister_OverwritePreservesFile(t *testing.T) { func TestList_EmptyDir(t *testing.T) { mgr := NewManager(t.TempDir()) - svcs, err := mgr.List() + doms, err := mgr.List() if err != nil { t.Fatalf("List on empty dir should not error: %v", err) } - if len(svcs) != 0 { - t.Errorf("expected 0 services from empty dir, got %d", len(svcs)) + if len(doms) != 0 { + t.Errorf("expected 0 domains from empty dir, got %d", len(doms)) } } func TestRegisterWithL7Fields(t *testing.T) { mgr := NewManager(t.TempDir()) - svc := Service{ - Domain: "keila.cloud.payne.io", - Source: "wild-cloud", + dom := Domain{ + DomainName: "keila.cloud.payne.io", + Source: "wild-cloud", Routes: []Route{{ Paths: []string{"/.well-known/matrix", "/_matrix"}, Backend: Backend{ @@ -487,7 +475,7 @@ func TestRegisterWithL7Fields(t *testing.T) { }}, } - if err := mgr.Register(svc); err != nil { + if err := mgr.Register(dom); err != nil { t.Fatalf("Register failed: %v", err) } @@ -520,9 +508,9 @@ func TestRegisterWithL7Fields(t *testing.T) { func TestUpdateL7Fields(t *testing.T) { mgr := NewManager(t.TempDir()) - mgr.Register(Service{ - Domain: "updatable.example.com", - Source: "test", + mgr.Register(Domain{ + DomainName: "updatable.example.com", + Source: "test", Routes: []Route{{ Paths: []string{"/api", "/webhook"}, Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, @@ -555,28 +543,63 @@ func TestList_IgnoresNonYAML(t *testing.T) { dir := t.TempDir() mgr := NewManager(dir) - // Register one real service - mgr.Register(Service{ - Domain: "real.example.com", - Source: "test", - Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, - // Public defaults to false + // Register one real domain + mgr.Register(Domain{ + DomainName: "real.example.com", + Source: "test", + Backend: Backend{Address: "127.0.0.1:8080", Type: BackendHTTP}, }) - // Create non-YAML files in the services directory - servicesDir := filepath.Join(dir, "services") - os.WriteFile(filepath.Join(servicesDir, "README.md"), []byte("# ignore me"), 0644) - os.WriteFile(filepath.Join(servicesDir, "notes.txt"), []byte("some notes"), 0644) - os.WriteFile(filepath.Join(servicesDir, ".gitkeep"), []byte(""), 0644) + // Create non-YAML files in the domains directory + domainsDir := filepath.Join(dir, "domains") + os.WriteFile(filepath.Join(domainsDir, "README.md"), []byte("# ignore me"), 0644) + os.WriteFile(filepath.Join(domainsDir, "notes.txt"), []byte("some notes"), 0644) + os.WriteFile(filepath.Join(domainsDir, ".gitkeep"), []byte(""), 0644) - svcs, err := mgr.List() + doms, err := mgr.List() if err != nil { t.Fatalf("List failed: %v", err) } - if len(svcs) != 1 { - t.Errorf("expected 1 service (ignoring non-YAML files), got %d", len(svcs)) + if len(doms) != 1 { + t.Errorf("expected 1 domain (ignoring non-YAML files), got %d", len(doms)) } - if svcs[0].Domain != "real.example.com" { - t.Errorf("expected real.example.com, got %s", svcs[0].Domain) + if doms[0].DomainName != "real.example.com" { + t.Errorf("expected real.example.com, got %s", doms[0].DomainName) + } +} + +func TestRegisterDNSOnly(t *testing.T) { + mgr := NewManager(t.TempDir()) + + dom := Domain{ + DomainName: "dev.payne.io", + Source: "manual", + Backend: Backend{ + Address: "192.168.8.222", + Type: BackendDNSOnly, + }, + Public: true, + } + + if err := mgr.Register(dom); err != nil { + t.Fatalf("Register failed: %v", err) + } + + got, err := mgr.Get("dev.payne.io") + if err != nil { + t.Fatalf("Get failed: %v", err) + } + + if got.TLS != TLSNone { + t.Errorf("expected TLS none for dns-only, got %s", got.TLS) + } + if got.Backend.Type != BackendDNSOnly { + t.Errorf("expected backend type dns-only, got %s", got.Backend.Type) + } + if got.Backend.Address != "192.168.8.222" { + t.Errorf("expected address 192.168.8.222, got %s", got.Backend.Address) + } + if !got.Public { + t.Error("expected public=true") } } diff --git a/internal/haproxy/config.go b/internal/haproxy/config.go index 175c8c4..103f211 100644 --- a/internal/haproxy/config.go +++ b/internal/haproxy/config.go @@ -12,7 +12,7 @@ import ( "strings" "time" - "github.com/wild-cloud/wild-central/internal/services" + "github.com/wild-cloud/wild-central/internal/domains" ) // InstanceRoute represents a Wild Cloud instance's ingress routing configuration @@ -36,7 +36,7 @@ type HTTPRouteBackend struct { Paths []string // path prefixes (empty = catch-all) Backend string // host:port HealthPath string // optional health check path - Headers *services.HeaderConfig // per-route headers + Headers *domains.HeaderConfig // per-route headers IPAllow []string // per-route CIDR whitelist } diff --git a/internal/haproxy/config_test.go b/internal/haproxy/config_test.go index 8162588..55ecd6c 100644 --- a/internal/haproxy/config_test.go +++ b/internal/haproxy/config_test.go @@ -4,7 +4,7 @@ import ( "strings" "testing" - "github.com/wild-cloud/wild-central/internal/services" + "github.com/wild-cloud/wild-central/internal/domains" ) func TestNewManager_DefaultPath(t *testing.T) { @@ -606,7 +606,7 @@ func TestGenerateWithOpts_ResponseHeaders(t *testing.T) { Domain: "plausible.cloud.payne.io", Routes: []HTTPRouteBackend{{ Backend: "192.168.8.80:80", - Headers: &services.HeaderConfig{ + Headers: &domains.HeaderConfig{ Response: map[string]string{ "Access-Control-Allow-Private-Network": "true", }, @@ -631,7 +631,7 @@ func TestGenerateWithOpts_RequestHeaders(t *testing.T) { Domain: "api.payne.io", Routes: []HTTPRouteBackend{{ Backend: "192.168.8.80:80", - Headers: &services.HeaderConfig{ + Headers: &domains.HeaderConfig{ Request: map[string]string{ "X-Forwarded-Proto": "https", }, diff --git a/internal/natsbus/server.go b/internal/natsbus/server.go index 4fb5280..bc29109 100644 --- a/internal/natsbus/server.go +++ b/internal/natsbus/server.go @@ -17,7 +17,7 @@ import ( const ( // KV bucket names - BucketServices = "wild-services" // service registrations + BucketDomains = "wild-domains" // domain registrations BucketPresence = "wild-presence" // node liveness (TTL keys) // Stream names @@ -126,13 +126,13 @@ func (s *Server) ensureBuckets() error { // Services bucket — no TTL, entries persist until explicitly deleted _, err := s.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ - Bucket: BucketServices, + Bucket: BucketDomains, Description: "Service registrations from Wild Cloud, Wild Works, and manual entries", }) if err != nil { return fmt.Errorf("services bucket: %w", err) } - slog.Info("NATS KV bucket ready", "bucket", BucketServices) + slog.Info("NATS KV bucket ready", "bucket", BucketDomains) // Presence bucket — TTL-based, keys expire when nodes stop renewing _, err = s.js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{ diff --git a/internal/natsbus/server_test.go b/internal/natsbus/server_test.go index 7b25d12..7263f56 100644 --- a/internal/natsbus/server_test.go +++ b/internal/natsbus/server_test.go @@ -36,10 +36,10 @@ func TestBucketsCreated(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // Verify services bucket exists - kv, err := s.js.KeyValue(ctx, BucketServices) + // Verify domains bucket exists + kv, err := s.js.KeyValue(ctx, BucketDomains) if err != nil { - t.Fatalf("services bucket not found: %v", err) + t.Fatalf("domains bucket not found: %v", err) } // Write and read a value diff --git a/internal/tools/yq.go b/internal/tools/yq.go new file mode 100644 index 0000000..ca0693e --- /dev/null +++ b/internal/tools/yq.go @@ -0,0 +1,73 @@ +package tools + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +// YQ provides a wrapper around the yq command-line tool +type YQ struct { + yqPath string +} + +// NewYQ creates a new YQ wrapper +func NewYQ() *YQ { + path, err := exec.LookPath("yq") + if err != nil { + path = "yq" + } + return &YQ{yqPath: path} +} + +// Get retrieves a value from a YAML file using a yq expression +func (y *YQ) Get(filePath, expression string) (string, error) { + cmd := exec.Command(y.yqPath, expression, filePath) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("yq get failed: %w, stderr: %s", err, stderr.String()) + } + return strings.TrimSpace(stdout.String()), nil +} + +// Set sets a value in a YAML file using a yq expression +func (y *YQ) Set(filePath, expression, value string) error { + if !strings.HasPrefix(expression, ".") { + expression = "." + expression + } + quotedValue := fmt.Sprintf(`"%s"`, strings.ReplaceAll(value, `"`, `\"`)) + setExpr := fmt.Sprintf("%s = %s", expression, quotedValue) + cmd := exec.Command(y.yqPath, "-i", setExpr, filePath) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("yq set failed: %w, stderr: %s", err, stderr.String()) + } + return nil +} + +// Delete removes a key from a YAML file +func (y *YQ) Delete(filePath, expression string) error { + delExpr := fmt.Sprintf("del(%s)", expression) + cmd := exec.Command(y.yqPath, "-i", delExpr, filePath) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("yq delete failed: %w, stderr: %s", err, stderr.String()) + } + return nil +} + +// Validate checks if a YAML file is valid +func (y *YQ) Validate(filePath string) error { + cmd := exec.Command(y.yqPath, "eval", ".", filePath) + var stderr bytes.Buffer + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("yaml validation failed: %w, stderr: %s", err, stderr.String()) + } + return nil +} diff --git a/internal/tunnel/manager.go b/internal/tunnel/manager.go index f605390..d21c931 100644 --- a/internal/tunnel/manager.go +++ b/internal/tunnel/manager.go @@ -30,9 +30,9 @@ type Config struct { CredentialsDir string `yaml:"credentialsDir" json:"credentialsDir"` } -// PublicService describes a service that should be exposed through the tunnel. -type PublicService struct { - Name string // service name (used as subdomain) +// PublicDomain describes a domain that should be exposed through the tunnel. +type PublicDomain struct { + Name string // domain name (used as subdomain) Subdomain string // explicit subdomain override (defaults to Name) } @@ -79,7 +79,7 @@ type cloudflaredConfig struct { // GenerateConfig renders the cloudflared config.yml for the given public services. // Returns the YAML string, or empty string if tunnel is not configured or no services are public. -func (m *Manager) GenerateConfig(cfg Config, services []PublicService) string { +func (m *Manager) GenerateConfig(cfg Config, services []PublicDomain) string { if !cfg.Enabled || cfg.TunnelID == "" || cfg.PublicDomain == "" || cfg.GatewayDomain == "" { return "" } @@ -131,7 +131,7 @@ func (m *Manager) GenerateConfig(cfg Config, services []PublicService) string { } // WriteConfig generates and writes the cloudflared config to disk. -func (m *Manager) WriteConfig(cfg Config, services []PublicService) error { +func (m *Manager) WriteConfig(cfg Config, services []PublicDomain) error { content := m.GenerateConfig(cfg, services) if content == "" { // Remove config if tunnel is disabled or no public services diff --git a/internal/tunnel/manager_test.go b/internal/tunnel/manager_test.go index d8054b6..b1c278d 100644 --- a/internal/tunnel/manager_test.go +++ b/internal/tunnel/manager_test.go @@ -20,7 +20,7 @@ func testConfig() Config { func TestGenerateConfig_Basic(t *testing.T) { m := NewManager(t.TempDir()) - services := []PublicService{ + services := []PublicDomain{ {Name: "my-api"}, {Name: "dashboard"}, } @@ -71,7 +71,7 @@ func TestGenerateConfig_Basic(t *testing.T) { func TestGenerateConfig_SubdomainOverride(t *testing.T) { m := NewManager(t.TempDir()) - services := []PublicService{ + services := []PublicDomain{ {Name: "internal-name", Subdomain: "public-name"}, } @@ -88,7 +88,7 @@ func TestGenerateConfig_Disabled(t *testing.T) { cfg := testConfig() cfg.Enabled = false - out := m.GenerateConfig(cfg, []PublicService{{Name: "my-api"}}) + out := m.GenerateConfig(cfg, []PublicDomain{{Name: "my-api"}}) if out != "" { t.Errorf("expected empty config when disabled, got:\n%s", out) } @@ -107,7 +107,7 @@ func TestGenerateConfig_MissingTunnelID(t *testing.T) { cfg := testConfig() cfg.TunnelID = "" - out := m.GenerateConfig(cfg, []PublicService{{Name: "my-api"}}) + out := m.GenerateConfig(cfg, []PublicDomain{{Name: "my-api"}}) if out != "" { t.Errorf("expected empty config with missing tunnel ID, got:\n%s", out) } @@ -117,7 +117,7 @@ func TestWriteConfig(t *testing.T) { tmpDir := t.TempDir() m := NewManager(tmpDir) - services := []PublicService{{Name: "my-api"}} + services := []PublicDomain{{Name: "my-api"}} if err := m.WriteConfig(testConfig(), services); err != nil { t.Fatalf("WriteConfig failed: %v", err) } @@ -139,14 +139,14 @@ func TestWriteConfig_RemovesWhenDisabled(t *testing.T) { m := NewManager(tmpDir) // Write config first - if err := m.WriteConfig(testConfig(), []PublicService{{Name: "my-api"}}); err != nil { + if err := m.WriteConfig(testConfig(), []PublicDomain{{Name: "my-api"}}); err != nil { t.Fatalf("WriteConfig failed: %v", err) } // Now disable and write again — should remove the file cfg := testConfig() cfg.Enabled = false - if err := m.WriteConfig(cfg, []PublicService{{Name: "my-api"}}); err != nil { + if err := m.WriteConfig(cfg, []PublicDomain{{Name: "my-api"}}); err != nil { t.Fatalf("WriteConfig (disable) failed: %v", err) } diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index 7b5b6ee..6b7c9e0 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -15,10 +15,12 @@ import { useSidebar, } from './ui/sidebar'; import { useTheme } from '../contexts/ThemeContext'; +import { useCentralStatus } from '../hooks/useCentralStatus'; export function AppSidebar() { const { theme, setTheme } = useTheme(); const { state } = useSidebar(); + const { data: centralStatus } = useCentralStatus(); const cycleTheme = () => { if (theme === 'light') { @@ -52,16 +54,16 @@ export function AppSidebar() { } }; - const servicesItems = [ + const domainsItems = [ { to: '/central', icon: LayoutDashboard, label: 'Dashboard', end: true }, - { to: '/central/services', icon: Globe, label: 'Services' }, + { to: '/central/domains', icon: Globe, label: 'Domains' }, ]; const centralItems = [ - { to: '/central/vpn', icon: Lock, label: 'VPN' }, - { to: '/central/firewall', icon: Shield, label: 'Firewall' }, - { to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec' }, - { to: '/central/dhcp', icon: Wifi, label: 'DHCP' }, + { to: '/central/vpn', icon: Lock, label: 'VPN', daemon: 'wireguard' as const }, + { to: '/central/firewall', icon: Shield, label: 'Firewall', daemon: 'nftables' as const }, + { to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const }, + { to: '/central/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const }, ]; return ( @@ -80,26 +82,33 @@ export function AppSidebar() { {state === 'collapsed' ? ( - {[...servicesItems, ...centralItems].map((item) => ( - - - {({ isActive }) => ( - - - {item.label} - - )} - - - ))} + {[...domainsItems, ...centralItems].map((item) => { + const daemon = 'daemon' in item ? (item as any).daemon : undefined; + const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined; + return ( + + + {({ isActive }) => ( + + + {item.label} + {active !== undefined && ( + + )} + + )} + + + ); + })} ) : ( <> - Services + Domains - {servicesItems.map(({ to, icon: Icon, label, end }) => ( + {domainsItems.map(({ to, icon: Icon, label, end }) => ( {({ isActive }) => ( @@ -119,18 +128,24 @@ export function AppSidebar() { Central - {centralItems.map(({ to, icon: Icon, label }) => ( - - - {({ isActive }) => ( - - - {label} - - )} - - - ))} + {centralItems.map(({ to, icon: Icon, label, daemon }) => { + const active = centralStatus?.daemons?.[daemon]?.active; + return ( + + + {({ isActive }) => ( + + + {label} + {active !== undefined && ( + + )} + + )} + + + ); + })} diff --git a/web/src/components/DashboardComponent.tsx b/web/src/components/DashboardComponent.tsx index f825ada..5f7cb4f 100644 --- a/web/src/components/DashboardComponent.tsx +++ b/web/src/components/DashboardComponent.tsx @@ -7,7 +7,7 @@ import { Alert, AlertDescription } from './ui/alert'; import { Badge } from './ui/badge'; import { LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2, - RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key, Plus, X, + RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key, } from 'lucide-react'; import { useCloudflare } from '../hooks/useCloudflare'; import { useDdns } from '../hooks/useDdns'; @@ -22,7 +22,7 @@ export function DashboardComponent() { const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus(); const { verification, isLoading: cfLoading } = useCloudflare(); const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns(); - const { config: globalConfig, updateConfig } = useConfig(); + const { config: globalConfig } = useConfig(); const { config: vpnConfig } = useVpn(); const queryClient = useQueryClient(); @@ -36,8 +36,6 @@ export function DashboardComponent() { const [editingToken, setEditingToken] = useState(false); const [tokenValue, setTokenValue] = useState(''); - const [addingDdnsRecord, setAddingDdnsRecord] = useState(false); - const [newDdnsRecord, setNewDdnsRecord] = useState(''); const updateSecretsMutation = useMutation({ mutationFn: (values: Record) => secretsApi.update(values), @@ -260,76 +258,33 @@ export function DashboardComponent() { - {/* Record list with remove buttons */} - {globalConfig?.cloud?.ddns?.records?.length ? ( + {/* Record list (read-only — derived from public domains) */} + {ddnsStatus.records?.length ? (
- {globalConfig.cloud.ddns.records.map((r: string) => { - const rs: DdnsRecordStatus | undefined = ddnsStatus.records?.find((s: DdnsRecordStatus) => s.name === r); - return ( -
- {rs?.ok ? ( - - ) : rs && !rs.ok ? ( - - ) : ( -
- )} - {r} - {rs?.ok && rs.ip && ( - {rs.ip} - )} - {rs && !rs.ok && rs.error && ( - {rs.error} - )} - -
- ); - })} + {ddnsStatus.records.map((rs: DdnsRecordStatus) => ( +
+ {rs.ok ? ( + + ) : ( + + )} + {rs.name} + {rs.ok && rs.ip && ( + {rs.ip} + )} + {!rs.ok && rs.error && ( + {rs.error} + )} +
+ ))} +

+ Records are derived from public domains. Toggle Public on the Domains page to manage. +

- ) : null} - - {/* Add DDNS record */} - {!addingDdnsRecord ? ( - ) : ( -
- setNewDdnsRecord(e.target.value)} - placeholder="dev.payne.io" - className="h-8 text-xs font-mono flex-1" - /> - - -
+

+ No public domains registered. Set a domain to Public on the Domains page to enable DDNS. +

)}
@@ -181,16 +179,32 @@ export function ServicesComponent() { {showAddForm && ( -
-
- - setNewDomain(e.target.value)} placeholder="cloud.payne.io" className="mt-1 font-mono" /> -
-
- - setNewBackendAddr(e.target.value)} placeholder="192.168.8.240:443" className="mt-1 font-mono" /> +
+ + setNewDomain(e.target.value)} placeholder="dev.payne.io" className="mt-1 font-mono" /> +
+
+ +
+ {([ + { value: 'none' as const, label: 'None' }, + { value: 'reverse-proxy' as const, label: 'Reverse proxy' }, + { value: 'passthrough' as const, label: 'Passthrough' }, + ] as const).map(opt => ( + + ))}
+
+ + setNewBackendAddr(e.target.value)} + placeholder={gatewayMode === 'none' ? '192.168.8.222' : '192.168.8.240:443'} + className="mt-1 font-mono" /> +
-
@@ -222,28 +236,32 @@ export function ServicesComponent() { )} {/* Loading */} - {isServicesLoading && ( + {isDomainsLoading && ( -

Loading services...

+

Loading domains...

)} {/* Empty */} - {!isServicesLoading && services.length === 0 && ( + {!isDomainsLoading && domains.length === 0 && ( -

No Services

-

Services appear when Wild Cloud or Wild Works registers domains, or add one manually.

+

No Domains

+

Domains appear when Wild Cloud or Wild Works registers them, or add one manually.

)} - {/* Service Cards */} - {services.map(service => { + {/* Domain Cards */} + {domains.map(service => { const isExpanded = expandedDomains.has(service.domain); + const backendType = getBackendType(service); + const isDnsOnly = backendType === 'dns-only'; + const isPassthrough = service.tls === 'passthrough' || backendType === 'tcp-passthrough'; + const hasGateway = !isDnsOnly; const tlsStatus = getTlsStatus(service, certDomains); - const ddnsStatus2 = service.public ? 'ok' as const : 'na' as const; - const isPassthrough = service.tls === 'passthrough' || getBackendType(service) === 'tcp-passthrough'; + const ddnsRecord = ddnsStatus?.records?.find(r => r.name === service.domain); + const ddnsStatus2: 'ok' | 'error' | 'na' = !service.public ? 'na' : ddnsRecord?.ok ? 'ok' : ddnsRecord ? 'error' : 'na'; const routeCount = service.routes?.length ?? 0; return ( @@ -261,8 +279,8 @@ export function ServicesComponent() {
- - + {hasGateway && } + {hasGateway && !isPassthrough && }
@@ -275,7 +293,7 @@ export function ServicesComponent() {
{!hasRoutes(service) && (
- + Subdomains
-
- { - if (hasRoutes(service)) { - updateMutation.mutate({ - domain: service.domain, - updates: { tls: v ? 'terminate' : 'passthrough' }, - }); - } else { - updateMutation.mutate({ - domain: service.domain, - updates: { - tls: v ? 'terminate' : 'passthrough', - backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' }, - }, - }); - } - }} - disabled={updateMutation.isPending} - /> - -
+ {hasGateway && ( +
+ { + if (hasRoutes(service)) { + updateMutation.mutate({ + domain: service.domain, + updates: { tls: v ? 'terminate' : 'passthrough' }, + }); + } else { + updateMutation.mutate({ + domain: service.domain, + updates: { + tls: v ? 'terminate' : 'passthrough', + backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' }, + }, + }); + } + }} + disabled={updateMutation.isPending} + /> + +
+ )}
{/* Routes detail */} @@ -355,8 +375,8 @@ export function ServicesComponent() { if (val && val !== route.backend.address) { const updatedRoutes = [...service.routes!]; updatedRoutes[i] = { ...route, backend: { ...route.backend, address: val } }; - servicesApi.register({ ...service, backend: undefined as any, routes: updatedRoutes } as any) - .then(() => queryClient.invalidateQueries({ queryKey: ['services'] })); + domainsApi.register({ ...service, backend: undefined as any, routes: updatedRoutes } as any) + .then(() => queryClient.invalidateQueries({ queryKey: ['domains'] })); } }} /> @@ -401,7 +421,7 @@ export function ServicesComponent() { )} {/* TLS cert info */} - {!isPassthrough && (() => { + {hasGateway && !isPassthrough && (() => { const cert = findCert(service.domain, certStatus?.certs ?? []); if (!cert) { const provisionDomain = service.subdomains ? `*.${service.domain}` : service.domain; @@ -449,6 +469,25 @@ export function ServicesComponent() { ); })()} + {/* DDNS error info */} + {ddnsStatus2 === 'error' && ddnsRecord && ( +
+
+ + + DDNS sync failed{ddnsRecord.error ? `: ${ddnsRecord.error}` : ''}. + {' '}Check that your Cloudflare API token has access to this domain's zone. + +
+ +
+ )} + {/* Source + deregister */}
Source: {service.source} diff --git a/web/src/hooks/useCentralStatus.ts b/web/src/hooks/useCentralStatus.ts index 7a9a722..006eed2 100644 --- a/web/src/hooks/useCentralStatus.ts +++ b/web/src/hooks/useCentralStatus.ts @@ -1,6 +1,10 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiClient } from '../services/api/client'; +interface DaemonStatus { + active: boolean; +} + interface CentralStatus { status: string; version: string; @@ -10,6 +14,7 @@ interface CentralStatus { appsDir: string; setupFiles: string; pendingRestart: boolean; + daemons?: Record; } /** diff --git a/web/src/hooks/useConfig.ts b/web/src/hooks/useConfig.ts index a35d70a..8ee9d17 100644 --- a/web/src/hooks/useConfig.ts +++ b/web/src/hooks/useConfig.ts @@ -1,62 +1,61 @@ import { useState, useEffect } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { apiService } from '../services/api-legacy'; -import type { GlobalConfig, GlobalConfigResponse } from '../types'; +import type { CentralState, CentralStateResponse } from '../types'; interface CreateConfigResponse { status: string; } /** - * Hook for managing Wild Central global configuration - * Endpoint: /api/v1/config + * Hook for managing Wild Central runtime state + * Endpoint: /api/v1/state * File: {dataDir}/state.yaml */ export const useConfig = () => { const queryClient = useQueryClient(); const [showConfigSetup, setShowConfigSetup] = useState(false); - const configQuery = useQuery({ - queryKey: ['globalConfig'], + const stateQuery = useQuery({ + queryKey: ['state'], queryFn: () => apiService.getConfig(), }); // Update showConfigSetup based on query data useEffect(() => { - if (configQuery.data) { - setShowConfigSetup(configQuery.data.configured === false); + if (stateQuery.data) { + setShowConfigSetup(stateQuery.data.configured === false); } - }, [configQuery.data]); + }, [stateQuery.data]); - const createConfigMutation = useMutation({ - mutationFn: (config) => apiService.createConfig(config), + const createStateMutation = useMutation({ + mutationFn: (state) => apiService.createConfig(state), onSuccess: () => { - // Invalidate and refetch config after successful creation - queryClient.invalidateQueries({ queryKey: ['globalConfig'] }); + queryClient.invalidateQueries({ queryKey: ['state'] }); setShowConfigSetup(false); }, }); - const updateConfigMutation = useMutation({ - mutationFn: (config) => apiService.updateConfig(config), + const updateStateMutation = useMutation({ + mutationFn: (state) => apiService.updateConfig(state), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['globalConfig'] }); + queryClient.invalidateQueries({ queryKey: ['state'] }); // Immediately re-check daemon status so the pending-restart banner appears queryClient.invalidateQueries({ queryKey: ['central', 'status'] }); }, }); return { - config: configQuery.data?.config || null, - isConfigured: configQuery.data?.configured || false, + config: stateQuery.data?.state || null, + isConfigured: stateQuery.data?.configured || false, showConfigSetup, setShowConfigSetup, - isLoading: configQuery.isLoading, - isCreating: createConfigMutation.isPending, - isUpdating: updateConfigMutation.isPending, - error: configQuery.error || createConfigMutation.error || updateConfigMutation.error, - createConfig: createConfigMutation.mutate, - updateConfig: updateConfigMutation.mutateAsync, - refetch: configQuery.refetch, + isLoading: stateQuery.isLoading, + isCreating: createStateMutation.isPending, + isUpdating: updateStateMutation.isPending, + error: stateQuery.error || createStateMutation.error || updateStateMutation.error, + createConfig: createStateMutation.mutate, + updateConfig: updateStateMutation.mutateAsync, + refetch: stateQuery.refetch, }; -}; \ No newline at end of file +}; diff --git a/web/src/hooks/useDdns.ts b/web/src/hooks/useDdns.ts index 7f857d9..98e17b3 100644 --- a/web/src/hooks/useDdns.ts +++ b/web/src/hooks/useDdns.ts @@ -14,10 +14,12 @@ export const useDdns = () => { const triggerMutation = useMutation({ mutationFn: () => ddnsApi.trigger(), onSuccess: () => { - // Refresh status shortly after trigger - setTimeout(() => { - queryClient.invalidateQueries({ queryKey: ['ddns', 'status'] }); - }, 2000); + // Refresh status after trigger — stagger to catch async completion + for (const delay of [1000, 3000, 6000]) { + setTimeout(() => { + queryClient.invalidateQueries({ queryKey: ['ddns', 'status'] }); + }, delay); + } }, }); diff --git a/web/src/hooks/useServices.ts b/web/src/hooks/useDomains.ts similarity index 50% rename from web/src/hooks/useServices.ts rename to web/src/hooks/useDomains.ts index bc4d972..899ae24 100644 --- a/web/src/hooks/useServices.ts +++ b/web/src/hooks/useDomains.ts @@ -1,16 +1,16 @@ import { useQuery } from '@tanstack/react-query'; -import { servicesApi } from '../services/api/networking'; +import { domainsApi } from '../services/api/networking'; -export const useServices = () => { +export const useDomains = () => { const query = useQuery({ - queryKey: ['services'], - queryFn: () => servicesApi.list(), + queryKey: ['domains'], + queryFn: () => domainsApi.list(), refetchInterval: 30000, staleTime: 10000, }); return { - services: query.data?.services ?? [], + domains: query.data?.domains ?? [], isLoading: query.isLoading, error: query.error, }; diff --git a/web/src/router/pages/DomainsPage.tsx b/web/src/router/pages/DomainsPage.tsx new file mode 100644 index 0000000..9c64a39 --- /dev/null +++ b/web/src/router/pages/DomainsPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../components'; +import { DomainsComponent } from '../../components/DomainsComponent'; + +export function DomainsPage() { + return ( + + + + ); +} diff --git a/web/src/router/pages/ServicesPage.tsx b/web/src/router/pages/ServicesPage.tsx deleted file mode 100644 index d35372a..0000000 --- a/web/src/router/pages/ServicesPage.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { ErrorBoundary } from '../../components'; -import { ServicesComponent } from '../../components/ServicesComponent'; - -export function ServicesPage() { - return ( - - - - ); -} diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index 582aa48..7e96801 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -3,7 +3,7 @@ import type { RouteObject } from 'react-router'; import { CentralLayout } from './CentralLayout'; import { NotFoundPage } from './pages/NotFoundPage'; import { DashboardPage } from './pages/DashboardPage'; -import { ServicesPage } from './pages/ServicesPage'; +import { DomainsPage } from './pages/DomainsPage'; import { FirewallPage } from './pages/FirewallPage'; import { VpnPage } from './pages/VpnPage'; import { CrowdSecPage } from './pages/CrowdSecPage'; @@ -19,7 +19,7 @@ export const routes: RouteObject[] = [ element: , children: [ { index: true, element: }, - { path: 'services', element: }, + { path: 'domains', element: }, { path: 'firewall', element: }, { path: 'vpn', element: }, { path: 'crowdsec', element: }, diff --git a/web/src/services/api-legacy.ts b/web/src/services/api-legacy.ts index 3aede58..efa6a27 100644 --- a/web/src/services/api-legacy.ts +++ b/web/src/services/api-legacy.ts @@ -1,7 +1,7 @@ import type { Status, - GlobalConfig, - GlobalConfigResponse, + CentralState, + CentralStateResponse, HealthResponse, StatusResponse, NetworkInfo, @@ -51,35 +51,35 @@ class ApiService { // ======================================== // Global Config APIs (Wild Central level) - // Endpoint: /api/v1/config + // Endpoint: /api/v1/state // ======================================== - async getConfig(): Promise { - return this.request('/api/v1/config'); + async getConfig(): Promise { + return this.request('/api/v1/state'); } async getConfigYaml(): Promise { - return this.requestText('/api/v1/config/yaml'); + return this.requestText('/api/v1/state/yaml'); } async updateConfigYaml(yamlContent: string): Promise { - return this.request('/api/v1/config/yaml', { + return this.request('/api/v1/state/yaml', { method: 'PUT', headers: { 'Content-Type': 'text/plain' }, body: yamlContent }); } - async createConfig(config: GlobalConfig): Promise { - return this.request('/api/v1/config', { + async createConfig(config: CentralState): Promise { + return this.request('/api/v1/state', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) }); } - async updateConfig(config: GlobalConfig): Promise { - return this.request('/api/v1/config', { + async updateConfig(config: CentralState): Promise { + return this.request('/api/v1/state', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(config) diff --git a/web/src/services/api/index.ts b/web/src/services/api/index.ts index bc404eb..8d4122e 100644 --- a/web/src/services/api/index.ts +++ b/web/src/services/api/index.ts @@ -1,8 +1,8 @@ export { apiClient, ApiError } from './client'; export * from './types'; export { dnsmasqApi } from './dnsmasq'; -export { servicesApi, haproxyApi, ddnsApi, dhcpApi, secretsApi, crowdSecApi } from './networking'; -export type { RegisteredService, HaproxyStatus, HaproxyGenerateResult, DdnsStatus, DhcpLease, DhcpStaticLease, BackendStat, CrowdSecStatus, CrowdSecMachine, CrowdSecBouncer, CrowdSecDecision, CrowdSecProvisionResult } from './networking'; +export { domainsApi, haproxyApi, ddnsApi, dhcpApi, secretsApi, crowdSecApi } from './networking'; +export type { RegisteredDomain, HaproxyStatus, HaproxyGenerateResult, DdnsStatus, DhcpLease, DhcpStaticLease, BackendStat, CrowdSecStatus, CrowdSecMachine, CrowdSecBouncer, CrowdSecDecision } from './networking'; export { vpnApi } from './vpn'; export type { VpnStatus, VpnConfig, VpnPeer, VpnPeerConfig } from './vpn'; export { certApi } from './cert'; diff --git a/web/src/services/api/networking.ts b/web/src/services/api/networking.ts index 9d95809..24a6411 100644 --- a/web/src/services/api/networking.ts +++ b/web/src/services/api/networking.ts @@ -1,6 +1,6 @@ import { apiClient } from './client'; -// Services +// Domains export interface HeaderConfig { request?: Record; @@ -18,7 +18,7 @@ export interface Route { ipAllow?: string[]; } -export interface RegisteredService { +export interface RegisteredDomain { domain: string; source: string; subdomains: boolean; @@ -32,15 +32,15 @@ export interface RegisteredService { routes?: Route[]; } -export const servicesApi = { - async list(): Promise<{ services: RegisteredService[] }> { - return apiClient.get('/api/v1/services'); +export const domainsApi = { + async list(): Promise<{ domains: RegisteredDomain[] }> { + return apiClient.get('/api/v1/domains'); }, - async register(svc: { domain: string; backend: { address: string; type: string; health?: string }; public?: boolean; subdomains?: boolean; source?: string }): Promise<{ message: string; service: RegisteredService }> { - return apiClient.post('/api/v1/services', svc); + async register(dom: { domain: string; backend: { address: string; type: string; health?: string }; public?: boolean; subdomains?: boolean; source?: string; tls?: string }): Promise<{ message: string; domain: RegisteredDomain }> { + return apiClient.post('/api/v1/domains', dom); }, async deregister(domain: string): Promise<{ message: string }> { - return apiClient.delete(`/api/v1/services/${domain}`); + return apiClient.delete(`/api/v1/domains/${domain}`); }, }; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index e08a9d0..e46f782 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -7,7 +7,7 @@ export interface Status { // ======================================== // Global Config Types (Wild Central level) -// Endpoint: /api/v1/config +// Endpoint: /api/v1/state // File: {dataDir}/state.yaml // ======================================== @@ -17,7 +17,7 @@ export interface HAProxyCustomRoute { backend: string; } -export interface GlobalConfig { +export interface CentralState { operator?: { email?: string; }; @@ -48,15 +48,14 @@ export interface GlobalConfig { ddns?: { enabled?: boolean; provider?: string; - records?: string[]; intervalMinutes?: number; }; }; } -export interface GlobalConfigResponse { +export interface CentralStateResponse { configured: boolean; - config?: GlobalConfig; + state?: CentralState; message?: string; }