From 5c26c7530a874f77496ff3ace9a900daeec9d392 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Fri, 10 Jul 2026 05:21:03 +0000 Subject: [PATCH] It's state, not config. --- README.md | 2 +- internal/api/v1/handlers.go | 38 ++-- internal/api/v1/handlers_certbot.go | 6 +- internal/api/v1/handlers_config_test.go | 8 +- internal/api/v1/handlers_dnsmasq.go | 18 +- internal/api/v1/handlers_dnsmasq_test.go | 4 +- internal/api/v1/handlers_haproxy.go | 2 +- internal/api/v1/handlers_haproxy_test.go | 2 +- .../api/v1/handlers_reconciliation_test.go | 31 +-- internal/api/v1/handlers_services.go | 18 ++ internal/api/v1/handlers_wireguard.go | 2 +- internal/api/v1/helpers.go | 28 ++- internal/config/config.go | 2 +- internal/config/config_test.go | 177 +++++++++--------- internal/config/manager.go | 37 ---- internal/dnsmasq/config_modular.go | 4 - internal/dnsmasq/config_test.go | 19 +- web/src/hooks/useConfig.ts | 2 +- 18 files changed, 184 insertions(+), 216 deletions(-) diff --git a/README.md b/README.md index f572c0c..6132266 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Wild Central runs on a device on your LAN (e.g., a Raspberry Pi) and manages: ## Two sources of truth -**Central config** drives Central's own services — its UI domain, VPN, firewall, DHCP. These are managed through Central's web UI and config file. +**Central state** drives Central's own services — its UI domain, VPN, firewall, DHCP. These are managed through Central's web UI and state file. **Service registrations** drive external consumer services. Wild Cloud registers its k8s instance domains. Wild Works registers its program services. Each registration is a domain that needs routing through Central's networking stack. diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index 5cd60da..7644216 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -1,6 +1,7 @@ package v1 import ( + "errors" "fmt" "io" "log/slog" @@ -54,10 +55,6 @@ type API struct { func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) { configMgr := config.NewManager() - if err := configMgr.EnsureGlobalConfig(dataDir); err != nil { - return nil, fmt.Errorf("failed to ensure global config: %w", err) - } - // Determine config paths from env or defaults dnsmasqConfigPath := envOrDefault("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", "/etc/dnsmasq.d/wild-cloud.conf") haproxyConfigPath := envOrDefault("WILD_CENTRAL_HAPROXY_CONFIG_PATH", "/etc/haproxy/haproxy.cfg") @@ -118,8 +115,7 @@ func (api *API) StartDDNS(ctx gocontext.Context) { // reloadDDNSIfEnabled re-reads DDNS config and secrets and restarts the DDNS goroutine. func (api *API) reloadDDNSIfEnabled() { - globalConfigPath := filepath.Join(api.dataDir, "config.yaml") - globalCfg, err := config.LoadGlobalConfig(globalConfigPath) + globalCfg, err := config.LoadState(api.statePath()) if err != nil || !globalCfg.Cloud.DDNS.Enabled { api.ddns.Stop() return @@ -246,9 +242,9 @@ func (api *API) RegisterRoutes(r *mux.Router) { // --- Global Config/Secrets handlers --- -// GetGlobalConfig returns the global config wrapped in { configured, config }. +// GetGlobalConfig returns the global state wrapped in { configured, config }. func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) { - configPath := filepath.Join(api.dataDir, "config.yaml") + configPath := api.statePath() data, err := os.ReadFile(configPath) if err != nil { respondJSON(w, http.StatusOK, map[string]any{ @@ -269,9 +265,9 @@ func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) { }) } -// UpdateGlobalConfig updates the global config +// UpdateGlobalConfig updates the global state func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) { - configPath := filepath.Join(api.dataDir, "config.yaml") + configPath := api.statePath() body, err := io.ReadAll(r.Body) if err != nil { @@ -285,10 +281,10 @@ func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) { return } - // Read existing config + // Read existing state (may not exist yet) existingContent, err := storage.ReadFile(configPath) - if err != nil && !os.IsNotExist(err) { - respondError(w, http.StatusInternalServerError, "Failed to read existing config") + if err != nil && !errors.Is(err, os.ErrNotExist) { + respondError(w, http.StatusInternalServerError, "Failed to read existing state") return } @@ -423,18 +419,12 @@ func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) { func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime time.Time, dataDir string) { uptime := time.Since(startTime) - pendingRestart := false - if info, err := os.Stat(filepath.Join(dataDir, "config.yaml")); err == nil { - pendingRestart = info.ModTime().After(startTime) - } - respondJSON(w, http.StatusOK, map[string]any{ - "status": "running", - "version": api.version, - "uptime": uptime.String(), - "uptimeSeconds": int(uptime.Seconds()), - "dataDir": dataDir, - "pendingRestart": pendingRestart, + "status": "running", + "version": api.version, + "uptime": uptime.String(), + "uptimeSeconds": int(uptime.Seconds()), + "dataDir": dataDir, }) } diff --git a/internal/api/v1/handlers_certbot.go b/internal/api/v1/handlers_certbot.go index ffbae56..1f73392 100644 --- a/internal/api/v1/handlers_certbot.go +++ b/internal/api/v1/handlers_certbot.go @@ -16,7 +16,7 @@ import ( func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) { centralDomain := api.getCentralDomain() - globalCfg, _ := config.LoadGlobalConfig(api.getGlobalConfigPath()) + globalCfg, _ := config.LoadState(api.statePath()) email := "" if globalCfg != nil { email = globalCfg.Operator.Email @@ -113,7 +113,7 @@ func (api *API) CertProvision(w http.ResponseWriter, r *http.Request) { return } - globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath()) + globalCfg, err := config.LoadState(api.statePath()) if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to load config: %v", err)) return @@ -179,7 +179,7 @@ func (api *API) CertRenew(w http.ResponseWriter, r *http.Request) { // getCentralDomain returns the configured central domain or empty string. func (api *API) getCentralDomain() string { - globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath()) + globalCfg, err := config.LoadState(api.statePath()) if err != nil { return "" } diff --git a/internal/api/v1/handlers_config_test.go b/internal/api/v1/handlers_config_test.go index d4f4fa1..d6be5f7 100644 --- a/internal/api/v1/handlers_config_test.go +++ b/internal/api/v1/handlers_config_test.go @@ -28,7 +28,7 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) { }, } data, _ := yaml.Marshal(cfg) - storage.WriteFile(filepath.Join(tmpDir, "config.yaml"), data, 0644) + storage.WriteFile(filepath.Join(tmpDir, "state.yaml"), data, 0644) req := httptest.NewRequest("GET", "/api/v1/config", nil) w := httptest.NewRecorder() @@ -62,8 +62,8 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) { func TestGetGlobalConfig_EmptyReturnsNotConfigured(t *testing.T) { api, tmpDir := setupTestAPI(t) - // Remove the default config that EnsureGlobalConfig created - os.Remove(filepath.Join(tmpDir, "config.yaml")) + // Ensure no state file exists + os.Remove(filepath.Join(tmpDir, "state.yaml")) req := httptest.NewRequest("GET", "/api/v1/config", nil) w := httptest.NewRecorder() @@ -168,7 +168,7 @@ func TestUpdateGlobalConfig(t *testing.T) { } // Verify file was written - data, _ := os.ReadFile(filepath.Join(tmpDir, "config.yaml")) + data, _ := os.ReadFile(filepath.Join(tmpDir, "state.yaml")) var cfg map[string]any yaml.Unmarshal(data, &cfg) diff --git a/internal/api/v1/handlers_dnsmasq.go b/internal/api/v1/handlers_dnsmasq.go index d07445b..a6ce875 100644 --- a/internal/api/v1/handlers_dnsmasq.go +++ b/internal/api/v1/handlers_dnsmasq.go @@ -65,8 +65,8 @@ func (api *API) DnsmasqGenerate(w http.ResponseWriter, r *http.Request) { overwrite := r.URL.Query().Get("overwrite") == "true" // Load global config - globalConfigPath := api.getGlobalConfigPath() - globalCfg, err := config.LoadGlobalConfig(globalConfigPath) + globalConfigPath := api.statePath() + globalCfg, err := config.LoadState(globalConfigPath) if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to load global config: %v", err)) return @@ -174,8 +174,8 @@ func writeConfigFile(path, content string) error { // updateDnsmasqConfig regenerates the main dnsmasq config from global config and restarts. // Instance-specific DNS records are managed via service registration (not here). func (api *API) updateDnsmasqConfig() error { - globalConfigPath := api.getGlobalConfigPath() - globalCfg, err := config.LoadGlobalConfig(globalConfigPath) + globalConfigPath := api.statePath() + globalCfg, err := config.LoadState(globalConfigPath) if err != nil { return fmt.Errorf("loading global config: %w", err) } @@ -184,9 +184,9 @@ func (api *API) updateDnsmasqConfig() error { return api.dnsmasq.UpdateConfig(globalCfg, nil, true) } -// getGlobalConfigPath returns the path to the global config file -func (api *API) getGlobalConfigPath() string { - return api.dataDir + "/config.yaml" +// statePath returns the path to the global state file +func (api *API) statePath() string { + return api.dataDir + "/state.yaml" } // DHCPLease represents an active DHCP lease from the dnsmasq leases file @@ -259,7 +259,7 @@ func (api *API) DnsmasqDHCPAddStatic(w http.ResponseWriter, r *http.Request) { return } - globalConfigPath := api.getGlobalConfigPath() + globalConfigPath := api.statePath() if err := api.addDHCPStaticLease(globalConfigPath, req); err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add static lease: %v", err)) return @@ -280,7 +280,7 @@ func (api *API) DnsmasqDHCPDeleteStatic(w http.ResponseWriter, r *http.Request) return } - globalConfigPath := api.getGlobalConfigPath() + globalConfigPath := api.statePath() if err := api.removeDHCPStaticLease(globalConfigPath, mac); err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove static lease: %v", err)) return diff --git a/internal/api/v1/handlers_dnsmasq_test.go b/internal/api/v1/handlers_dnsmasq_test.go index 346b692..a94176d 100644 --- a/internal/api/v1/handlers_dnsmasq_test.go +++ b/internal/api/v1/handlers_dnsmasq_test.go @@ -32,8 +32,8 @@ func TestDnsmasqGenerate(t *testing.T) { api, tmpDir := setupTestDnsmasq(t) globalConfig := config.GlobalConfig{} - globalConfig.Cloud.Router.IP = "192.168.1.1" - configPath := filepath.Join(tmpDir, "config.yaml") + globalConfig.Cloud.Central.Domain = "central.example.com" + configPath := filepath.Join(tmpDir, "state.yaml") configData, _ := yaml.Marshal(globalConfig) if err := storage.WriteFile(configPath, configData, 0644); err != nil { t.Fatal(err) diff --git a/internal/api/v1/handlers_haproxy.go b/internal/api/v1/handlers_haproxy.go index bb0c6ec..c0d41bf 100644 --- a/internal/api/v1/handlers_haproxy.go +++ b/internal/api/v1/handlers_haproxy.go @@ -74,7 +74,7 @@ func (api *API) syncHAProxy() ([]haproxy.InstanceRoute, []haproxy.CustomRoute, s return nil, nil, "", fmt.Errorf("failed to list instances: %w", err) } - globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath()) + globalCfg, err := config.LoadState(api.statePath()) if err != nil { return nil, nil, "", fmt.Errorf("failed to load global config: %w", err) } diff --git a/internal/api/v1/handlers_haproxy_test.go b/internal/api/v1/handlers_haproxy_test.go index 2b3b0a3..1a5211b 100644 --- a/internal/api/v1/handlers_haproxy_test.go +++ b/internal/api/v1/handlers_haproxy_test.go @@ -113,7 +113,7 @@ func TestHaproxyStats_WhenSocketMissing(t *testing.T) { func TestHaproxyGenerate_MissingGlobalConfig(t *testing.T) { api, tmpDir := setupTestHaproxy(t) - os.Remove(filepath.Join(tmpDir, "config.yaml")) + os.Remove(filepath.Join(tmpDir, "state.yaml")) req := httptest.NewRequest("POST", "/api/v1/haproxy/generate", nil) w := httptest.NewRecorder() diff --git a/internal/api/v1/handlers_reconciliation_test.go b/internal/api/v1/handlers_reconciliation_test.go index 1544982..813cf55 100644 --- a/internal/api/v1/handlers_reconciliation_test.go +++ b/internal/api/v1/handlers_reconciliation_test.go @@ -75,10 +75,12 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) { }) case services.BackendHTTP: httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ - Name: svc.Domain, - Domain: svc.Domain, - Backend: svc.Backend.Address, - HealthPath: svc.Backend.Health, + Name: svc.Domain, + Domain: svc.Domain, + Routes: []haproxy.HTTPRouteBackend{{ + Backend: svc.Backend.Address, + HealthPath: svc.Backend.Health, + }}, }) } } @@ -88,7 +90,6 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) { Domain: "central.payne.io", Source: "central", Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP}, - // Public defaults to false TLS: services.TLSTerminate, }); err != nil { t.Fatalf("Register central failed: %v", err) @@ -102,10 +103,12 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) { for _, svc := range svcs { if svc.Backend.Type == services.BackendHTTP { httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ - Name: svc.Domain, - Domain: svc.Domain, - Backend: svc.Backend.Address, - HealthPath: svc.Backend.Health, + Name: svc.Domain, + Domain: svc.Domain, + Routes: []haproxy.HTTPRouteBackend{{ + Backend: svc.Backend.Address, + HealthPath: svc.Backend.Health, + }}, }) } } @@ -299,9 +302,11 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) { for _, svc := range svcs { if svc.Backend.Type == services.BackendHTTP { httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ - Name: svc.Domain, - Domain: svc.Domain, - Backend: svc.Backend.Address, + Name: svc.Domain, + Domain: svc.Domain, + Routes: []haproxy.HTTPRouteBackend{{ + Backend: svc.Backend.Address, + }}, }) } } @@ -376,7 +381,7 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) { // writeTestConfig writes a minimal global config with the given Central domain. func writeTestConfig(t *testing.T, dataDir, centralDomain string) { t.Helper() - configPath := filepath.Join(dataDir, "config.yaml") + configPath := filepath.Join(dataDir, "state.yaml") content := fmt.Sprintf("cloud:\n central:\n domain: %s\n", centralDomain) if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { t.Fatalf("write config: %v", err) diff --git a/internal/api/v1/handlers_services.go b/internal/api/v1/handlers_services.go index 9d00de9..a3f8054 100644 --- a/internal/api/v1/handlers_services.go +++ b/internal/api/v1/handlers_services.go @@ -3,7 +3,9 @@ package v1 import ( "encoding/json" "fmt" + "net" "net/http" + "strings" "github.com/gorilla/mux" @@ -48,6 +50,22 @@ func (api *API) ServicesRegister(w http.ResponseWriter, r *http.Request) { 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 diff --git a/internal/api/v1/handlers_wireguard.go b/internal/api/v1/handlers_wireguard.go index 7dc46f6..19740a6 100644 --- a/internal/api/v1/handlers_wireguard.go +++ b/internal/api/v1/handlers_wireguard.go @@ -54,7 +54,7 @@ func (api *API) VpnUpdateConfig(w http.ResponseWriter, r *http.Request) { return } // Resync nftables so the VPN listen port is automatically allowed/removed - if globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath()); err == nil { + if globalCfg, err := config.LoadState(api.statePath()); err == nil { go api.syncNftablesOnly(globalCfg) } respondJSON(w, http.StatusOK, map[string]string{"message": "VPN configuration saved"}) diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index 49285c6..2b93600 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -4,7 +4,6 @@ import ( "fmt" "log/slog" "os" - "path/filepath" "strings" "time" @@ -72,10 +71,9 @@ func (api *API) reconcileNetworking() { return } - globalConfigPath := filepath.Join(api.dataDir, "config.yaml") - globalCfg, err := config.LoadGlobalConfig(globalConfigPath) + globalCfg, err := config.LoadState(api.statePath()) if err != nil { - slog.Warn("reconcile: failed to load global config, using empty", "error", err) + slog.Warn("reconcile: failed to load state, using empty", "error", err) globalCfg = &config.GlobalConfig{} } @@ -84,20 +82,30 @@ func (api *API) reconcileNetworking() { var httpRoutes []haproxy.HTTPRoute for _, svc := range svcs { - switch svc.Backend.Type { + switch svc.EffectiveBackendType() { case services.BackendTCPPassthrough: instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{ Name: svc.Domain, Domain: svc.Domain, - BackendIP: extractHost(svc.Backend.Address), + BackendIP: extractHost(svc.EffectiveBackendAddress()), Subdomains: svc.Subdomains, }) case services.BackendHTTP: + var routeBackends []haproxy.HTTPRouteBackend + for _, r := range svc.EffectiveRoutes() { + routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{ + Paths: r.Paths, + Backend: r.Backend.Address, + HealthPath: r.Backend.Health, + Headers: r.Headers, + IPAllow: r.IPAllow, + }) + } httpRoutes = append(httpRoutes, haproxy.HTTPRoute{ Name: svc.Domain, Domain: svc.Domain, - Backend: svc.Backend.Address, - HealthPath: svc.Backend.Health, + Subdomains: svc.Subdomains, + Routes: routeBackends, }) } } @@ -151,9 +159,9 @@ func (api *API) reconcileNetworking() { // Choose the DNS target IP based on service type dnsIP := centralIP - if svc.Backend.Type == services.BackendTCPPassthrough { + if svc.EffectiveBackendType() == services.BackendTCPPassthrough { // k8s instances: LAN clients connect directly to the k8s LB - dnsIP = extractHost(svc.Backend.Address) + dnsIP = extractHost(svc.EffectiveBackendAddress()) } ic := config.InstanceConfig{} diff --git a/internal/config/config.go b/internal/config/config.go index b27b236..dc12cbc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -271,7 +271,7 @@ func DeepMerge(dst, src map[string]any) map[string]any { // Assembles apps.* from config/apps/*/config.yaml files. func LoadMergedInstanceConfig(dataDir, instanceName string) (map[string]any, error) { instanceConfigPath := filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml") - globalConfigPath := filepath.Join(dataDir, "config.yaml") + globalConfigPath := filepath.Join(dataDir, "state.yaml") instanceData, err := os.ReadFile(instanceConfigPath) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 29ffa64..2ac11d3 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -9,36 +9,35 @@ import ( "gopkg.in/yaml.v3" ) -// Test: LoadGlobalConfig loads valid configuration -func TestLoadGlobalConfig(t *testing.T) { +// Test: LoadState loads valid state +func TestLoadState(t *testing.T) { tests := []struct { - name string - configYAML string - verify func(t *testing.T, config *GlobalConfig) - wantErr bool + name string + stateYAML string + verify func(t *testing.T, config *GlobalConfig) + wantErr bool }{ { - name: "loads complete configuration", - configYAML: `operator: + name: "loads complete state", + stateYAML: `operator: email: "admin@example.com" cloud: - router: - ip: "192.168.1.254" - dynamicDns: "example.dyndns.org" + central: + domain: "central.example.com" `, verify: func(t *testing.T, config *GlobalConfig) { if config.Operator.Email != "admin@example.com" { t.Error("operator email not loaded correctly") } - if config.Cloud.Router.IP != "192.168.1.254" { - t.Error("router IP not loaded correctly") + if config.Cloud.Central.Domain != "central.example.com" { + t.Error("central domain not loaded correctly") } }, wantErr: false, }, { - name: "loads minimal configuration", - configYAML: `operator: + name: "loads minimal state", + stateYAML: `operator: email: "admin@example.com" `, verify: func(t *testing.T, config *GlobalConfig) { @@ -49,9 +48,8 @@ cloud: wantErr: false, }, { - name: "loads empty configuration", - configYAML: `{} -`, + name: "loads empty state", + stateYAML: "{}\n", verify: func(t *testing.T, config *GlobalConfig) { if config.Operator.Email != "" { t.Error("expected empty operator email") @@ -64,13 +62,13 @@ cloud: for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yaml") + statePath := filepath.Join(tempDir, "state.yaml") - if err := os.WriteFile(configPath, []byte(tt.configYAML), 0644); err != nil { + if err := os.WriteFile(statePath, []byte(tt.stateYAML), 0644); err != nil { t.Fatalf("setup failed: %v", err) } - config, err := LoadGlobalConfig(configPath) + config, err := LoadState(statePath) if tt.wantErr { if err == nil { t.Error("expected error, got nil") @@ -94,8 +92,8 @@ cloud: } } -// Test: LoadGlobalConfig error cases -func TestLoadGlobalConfig_Errors(t *testing.T) { +// Test: LoadState error cases +func TestLoadState_Errors(t *testing.T) { tests := []struct { name string setupFunc func(t *testing.T) string @@ -112,12 +110,12 @@ func TestLoadGlobalConfig_Errors(t *testing.T) { name: "invalid yaml", setupFunc: func(t *testing.T) string { tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yaml") + statePath := filepath.Join(tempDir, "state.yaml") content := `invalid: yaml: [[[` - if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { + if err := os.WriteFile(statePath, []byte(content), 0644); err != nil { t.Fatalf("setup failed: %v", err) } - return configPath + return statePath }, errContains: "parsing config file", }, @@ -125,8 +123,8 @@ func TestLoadGlobalConfig_Errors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - configPath := tt.setupFunc(t) - _, err := LoadGlobalConfig(configPath) + statePath := tt.setupFunc(t) + _, err := LoadState(statePath) if err == nil { t.Error("expected error, got nil") @@ -137,42 +135,41 @@ func TestLoadGlobalConfig_Errors(t *testing.T) { } } -// Test: SaveGlobalConfig saves configuration correctly -func TestSaveGlobalConfig(t *testing.T) { +// Test: SaveState saves state correctly +func TestSaveState(t *testing.T) { tests := []struct { name string config *GlobalConfig - verify func(t *testing.T, configPath string) + verify func(t *testing.T, statePath string) }{ { - name: "saves complete configuration", + name: "saves complete state", config: func() *GlobalConfig { cfg := &GlobalConfig{} cfg.Operator.Email = "admin@example.com" - cfg.Cloud.Router.IP = "192.168.1.254" - cfg.Cloud.Router.DynamicDns = "example.dyndns.org" + cfg.Cloud.Central.Domain = "central.example.com" return cfg }(), - verify: func(t *testing.T, configPath string) { - content, err := os.ReadFile(configPath) + verify: func(t *testing.T, statePath string) { + content, err := os.ReadFile(statePath) if err != nil { - t.Fatalf("failed to read saved config: %v", err) + t.Fatalf("failed to read saved state: %v", err) } contentStr := string(content) if !strings.Contains(contentStr, "admin@example.com") { - t.Error("saved config missing operator email") + t.Error("saved state missing operator email") } - if !strings.Contains(contentStr, "192.168.1.254") { - t.Error("saved config missing router IP") + if !strings.Contains(contentStr, "central.example.com") { + t.Error("saved state missing central domain") } }, }, { - name: "saves empty configuration", + name: "saves empty state", config: &GlobalConfig{}, - verify: func(t *testing.T, configPath string) { - if _, err := os.Stat(configPath); os.IsNotExist(err) { - t.Error("config file not created") + verify: func(t *testing.T, statePath string) { + if _, err := os.Stat(statePath); os.IsNotExist(err) { + t.Error("state file not created") } }, }, @@ -181,63 +178,63 @@ func TestSaveGlobalConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "subdir", "config.yaml") + statePath := filepath.Join(tempDir, "subdir", "state.yaml") - err := SaveGlobalConfig(tt.config, configPath) + err := SaveState(tt.config, statePath) if err != nil { - t.Errorf("SaveGlobalConfig failed: %v", err) + t.Errorf("SaveState failed: %v", err) return } // Verify file exists - if _, err := os.Stat(configPath); err != nil { - t.Errorf("config file not created: %v", err) + if _, err := os.Stat(statePath); err != nil { + t.Errorf("state file not created: %v", err) return } // Verify file permissions - info, err := os.Stat(configPath) + info, err := os.Stat(statePath) if err != nil { - t.Fatalf("failed to stat config file: %v", err) + t.Fatalf("failed to stat state file: %v", err) } if info.Mode().Perm() != 0644 { t.Errorf("expected permissions 0644, got %v", info.Mode().Perm()) } // Verify content can be loaded back - loadedConfig, err := LoadGlobalConfig(configPath) + loadedConfig, err := LoadState(statePath) if err != nil { - t.Errorf("failed to reload saved config: %v", err) + t.Errorf("failed to reload saved state: %v", err) } else if loadedConfig == nil { t.Error("loaded config is nil") } if tt.verify != nil { - tt.verify(t, configPath) + tt.verify(t, statePath) } }) } } -// Test: SaveGlobalConfig creates directory -func TestSaveGlobalConfig_CreatesDirectory(t *testing.T) { +// Test: SaveState creates directory +func TestSaveState_CreatesDirectory(t *testing.T) { tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "nested", "dirs", "config.yaml") + statePath := filepath.Join(tempDir, "nested", "dirs", "state.yaml") config := &GlobalConfig{} - err := SaveGlobalConfig(config, configPath) + err := SaveState(config, statePath) if err != nil { - t.Fatalf("SaveGlobalConfig failed: %v", err) + t.Fatalf("SaveState failed: %v", err) } // Verify nested directories were created - if _, err := os.Stat(filepath.Dir(configPath)); err != nil { + if _, err := os.Stat(filepath.Dir(statePath)); err != nil { t.Errorf("directory not created: %v", err) } // Verify file exists - if _, err := os.Stat(configPath); err != nil { - t.Errorf("config file not created: %v", err) + if _, err := os.Stat(statePath); err != nil { + t.Errorf("state file not created: %v", err) } } @@ -259,10 +256,10 @@ func TestGlobalConfig_IsEmpty(t *testing.T) { want: true, }, { - name: "config with only router IP is not empty", + name: "config with only central domain is not empty", config: func() *GlobalConfig { cfg := &GlobalConfig{} - cfg.Cloud.Router.IP = "192.168.1.254" + cfg.Cloud.Central.Domain = "central.example.com" return cfg }(), want: false, @@ -280,7 +277,7 @@ func TestGlobalConfig_IsEmpty(t *testing.T) { name: "config with all fields is not empty", config: func() *GlobalConfig { cfg := &GlobalConfig{} - cfg.Cloud.Router.IP = "192.168.1.254" + cfg.Cloud.Central.Domain = "central.example.com" cfg.Operator.Email = "admin@example.com" return cfg }(), @@ -482,34 +479,30 @@ func TestSaveCloudConfig(t *testing.T) { // Test: Round-trip save and load preserves data func TestGlobalConfig_RoundTrip(t *testing.T) { tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yaml") + statePath := filepath.Join(tempDir, "state.yaml") // Create config with all fields original := &GlobalConfig{} original.Operator.Email = "admin@example.com" - original.Cloud.Router.IP = "192.168.1.254" - original.Cloud.Router.DynamicDns = "example.dyndns.org" + original.Cloud.Central.Domain = "central.example.com" - // Save config - if err := SaveGlobalConfig(original, configPath); err != nil { - t.Fatalf("SaveGlobalConfig failed: %v", err) + // Save + if err := SaveState(original, statePath); err != nil { + t.Fatalf("SaveState failed: %v", err) } - // Load config - loaded, err := LoadGlobalConfig(configPath) + // Load + loaded, err := LoadState(statePath) if err != nil { - t.Fatalf("LoadGlobalConfig failed: %v", err) + t.Fatalf("LoadState failed: %v", err) } // Verify all fields match if loaded.Operator.Email != original.Operator.Email { t.Errorf("email mismatch: got %q, want %q", loaded.Operator.Email, original.Operator.Email) } - if loaded.Cloud.Router.IP != original.Cloud.Router.IP { - t.Errorf("router IP mismatch: got %q, want %q", loaded.Cloud.Router.IP, original.Cloud.Router.IP) - } - if loaded.Cloud.Router.DynamicDns != original.Cloud.Router.DynamicDns { - t.Errorf("dynamic DNS mismatch: got %q, want %q", loaded.Cloud.Router.DynamicDns, original.Cloud.Router.DynamicDns) + if loaded.Cloud.Central.Domain != original.Cloud.Central.Domain { + t.Errorf("central domain mismatch: got %q, want %q", loaded.Cloud.Central.Domain, original.Cloud.Central.Domain) } } @@ -561,7 +554,7 @@ func TestDeepMerge(t *testing.T) { name: "nested maps merge recursively", dst: map[string]any{ "cloud": map[string]any{ - "router": map[string]any{"ip": "192.168.1.1"}, + "central": map[string]any{"domain": "central.example.com"}, }, }, src: map[string]any{ @@ -571,8 +564,8 @@ func TestDeepMerge(t *testing.T) { }, expected: map[string]any{ "cloud": map[string]any{ - "router": map[string]any{"ip": "192.168.1.1"}, - "domain": "example.com", + "central": map[string]any{"domain": "central.example.com"}, + "domain": "example.com", }, }, }, @@ -620,11 +613,11 @@ func TestLoadMergedInstanceConfig(t *testing.T) { instanceConfigDir := filepath.Join(dataDir, "instances", instanceName, "config") os.MkdirAll(instanceConfigDir, 0755) - globalConfig := `operator: + globalState := `operator: email: test@example.com cloud: - router: - ip: 192.168.1.1 + central: + domain: central.example.com ` instanceConfig := `cluster: name: test @@ -635,7 +628,7 @@ cloud: domain: cloud.example.com ` - os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte(globalConfig), 0644) + os.WriteFile(filepath.Join(dataDir, "state.yaml"), []byte(globalState), 0644) os.WriteFile(filepath.Join(instanceConfigDir, "instance.yaml"), []byte(instanceConfig), 0644) merged, err := LoadMergedInstanceConfig(dataDir, instanceName) @@ -648,12 +641,12 @@ cloud: t.Fatal("missing cloud key in merged config") } - router, ok := cloud["router"].(map[string]any) + central, ok := cloud["central"].(map[string]any) if !ok { - t.Fatal("missing cloud.router key — global config not merged") + t.Fatal("missing cloud.central key — global state not merged") } - if router["ip"] != "192.168.1.1" { - t.Errorf("cloud.router.ip = %v, want 192.168.1.1", router["ip"]) + if central["domain"] != "central.example.com" { + t.Errorf("cloud.central.domain = %v, want central.example.com", central["domain"]) } if cloud["domain"] != "cloud.example.com" { @@ -670,14 +663,14 @@ cloud: operator, ok := merged["operator"].(map[string]any) if !ok { - t.Fatal("missing operator key — global config not merged") + t.Fatal("missing operator key — global state not merged") } if operator["email"] != "test@example.com" { t.Errorf("operator.email = %v, want test@example.com", operator["email"]) } } -func TestLoadMergedInstanceConfig_NoGlobalConfig(t *testing.T) { +func TestLoadMergedInstanceConfig_NoGlobalState(t *testing.T) { dataDir := t.TempDir() instanceConfigDir := filepath.Join(dataDir, "instances", "test", "config") os.MkdirAll(instanceConfigDir, 0755) diff --git a/internal/config/manager.go b/internal/config/manager.go index 58a362f..abe5f1b 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -3,12 +3,10 @@ package config import ( "bytes" "fmt" - "log/slog" "os/exec" "path/filepath" "strings" - "github.com/wild-cloud/wild-central/internal/network" "github.com/wild-cloud/wild-central/internal/storage" ) @@ -89,41 +87,6 @@ func NewManager() *Manager { } } -// EnsureGlobalConfig ensures a global config file exists with proper structure -func (m *Manager) EnsureGlobalConfig(dataDir string) error { - configPath := filepath.Join(dataDir, "config.yaml") - - // Check if config already exists - if storage.FileExists(configPath) { - // Validate existing config - if err := m.yq.Validate(configPath); err != nil { - return fmt.Errorf("invalid config file: %w", err) - } - return nil - } - - // Create config structure with detected defaults - initialConfig := &GlobalConfig{} - - // Detect network configuration - netInfo, err := network.DetectNetworkInfo() - if err != nil { - slog.Info("network detection failed, using defaults", "component", "config", "error", err) - } else { - // Set detected values - initialConfig.Cloud.Router.IP = netInfo.Gateway - slog.Info("detected network", "component", "config", "gateway", netInfo.Gateway, "interface", netInfo.PrimaryInterface) - } - - // Ensure data directory exists - if err := storage.EnsureDir(dataDir, 0755); err != nil { - return err - } - - // Save config using the model's save function - return SaveGlobalConfig(initialConfig, configPath) -} - // EnsureInstanceConfig ensures an instance config file exists with proper structure func (m *Manager) EnsureInstanceConfig(name string, dataDir string) error { configPath := filepath.Join(dataDir, "instances", name, "config", "instance.yaml") diff --git a/internal/dnsmasq/config_modular.go b/internal/dnsmasq/config_modular.go index c3e839b..4ce6a03 100644 --- a/internal/dnsmasq/config_modular.go +++ b/internal/dnsmasq/config_modular.go @@ -62,11 +62,7 @@ log-dhcp } fmt.Fprintf(&sb, "dhcp-range=%s,%s,%s\n", dhcp.RangeStart, dhcp.RangeEnd, leaseTime) - // Gateway: use explicit DHCP gateway, fall back to router IP gateway := dhcp.Gateway - if gateway == "" { - gateway = cfg.Cloud.Router.IP - } if gateway != "" { fmt.Fprintf(&sb, "dhcp-option=3,%s\n", gateway) // default gateway } diff --git a/internal/dnsmasq/config_test.go b/internal/dnsmasq/config_test.go index 1deb03c..d5590ba 100644 --- a/internal/dnsmasq/config_test.go +++ b/internal/dnsmasq/config_test.go @@ -209,7 +209,7 @@ func TestGenerateMainConfig_DHCPEnabled(t *testing.T) { cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Dnsmasq.DHCP.LeaseTime = "12h" - cfg.Cloud.Router.IP = "192.168.8.1" + cfg.Cloud.Dnsmasq.DHCP.Gateway = "192.168.8.1" out := g.GenerateMainConfig(cfg) @@ -221,24 +221,23 @@ func TestGenerateMainConfig_DHCPEnabled(t *testing.T) { } } -// Test: DHCP gateway falls back to router IP when not set -func TestGenerateMainConfig_DHCPGatewayFallback(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.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" - cfg.Cloud.Router.IP = "192.168.8.1" - // Gateway not set — should fall back to router IP + // No gateway set out := g.GenerateMainConfig(cfg) - if !strings.Contains(out, "dhcp-option=3,192.168.8.1") { - t.Errorf("expected router IP as fallback gateway, got:\n%s", out) + if strings.Contains(out, "dhcp-option=3,") { + t.Errorf("expected no gateway dhcp-option when gateway not set, got:\n%s", out) } } -// Test: DHCP explicit gateway takes precedence over router IP +// Test: DHCP explicit gateway is used func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") cfg := &config.GlobalConfig{} @@ -246,16 +245,12 @@ func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) { cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Dnsmasq.DHCP.Gateway = "192.168.8.254" - cfg.Cloud.Router.IP = "192.168.8.1" out := g.GenerateMainConfig(cfg) if !strings.Contains(out, "dhcp-option=3,192.168.8.254") { t.Errorf("expected explicit gateway in dhcp-option=3, got:\n%s", out) } - if strings.Contains(out, "dhcp-option=3,192.168.8.1") { - t.Errorf("router IP should not appear as gateway when explicit gateway set, got:\n%s", out) - } } // Test: DHCP static leases appear in output diff --git a/web/src/hooks/useConfig.ts b/web/src/hooks/useConfig.ts index 9a654a5..a35d70a 100644 --- a/web/src/hooks/useConfig.ts +++ b/web/src/hooks/useConfig.ts @@ -10,7 +10,7 @@ interface CreateConfigResponse { /** * Hook for managing Wild Central global configuration * Endpoint: /api/v1/config - * File: {dataDir}/config.yaml + * File: {dataDir}/state.yaml */ export const useConfig = () => { const queryClient = useQueryClient();