From f9d87ff975fe6bf923a64287b540149ab864bbcb Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sat, 11 Jul 2026 20:42:33 +0000 Subject: [PATCH] Replace InstanceConfig with DNSEntry for dnsmasq config generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit InstanceConfig was a ~50-field struct designed for Wild Cloud k8s instances, but only 3 fields were ever read by dnsmasq (the sole consumer). The reconciliation bridge constructed fake InstanceConfig objects from registered domains just to satisfy this interface. Replace with DNSEntry{Domain, IP} — a 2-field struct purpose-built for dnsmasq. All domains get local=/ directives to prevent AAAA queries from leaking to upstream DNS (Happy Eyeballs / RFC 8305 latency on LAN). Removed dead code: InstanceConfig, NodeConfig, LoadCloudConfig, SaveCloudConfig, DeepMerge, LoadMergedInstanceConfig, EnsureInstanceConfig, instance path helpers, instanceLoadBalancerIP, GenerateInstanceConfig, and all modular per-instance dnsmasq methods. --- .../api/v1/handlers_reconciliation_test.go | 69 ++-- internal/api/v1/helpers.go | 74 ++-- internal/config/config.go | 166 -------- internal/config/config_test.go | 367 ----------------- internal/config/manager.go | 39 -- internal/config/manager_test.go | 233 ----------- internal/dnsmasq/config.go | 67 ++- internal/dnsmasq/config_modular.go | 244 +---------- internal/dnsmasq/config_test.go | 381 +++++------------- web/src/types/index.ts | 69 +--- 10 files changed, 192 insertions(+), 1517 deletions(-) diff --git a/internal/api/v1/handlers_reconciliation_test.go b/internal/api/v1/handlers_reconciliation_test.go index d32ce15..5ab3f59 100644 --- a/internal/api/v1/handlers_reconciliation_test.go +++ b/internal/api/v1/handlers_reconciliation_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/wild-cloud/wild-central/internal/config" + "github.com/wild-cloud/wild-central/internal/dnsmasq" "github.com/wild-cloud/wild-central/internal/domains" "github.com/wild-cloud/wild-central/internal/haproxy" ) @@ -205,14 +206,14 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) { } } - // Build dnsmasq instance configs the same way reconcileNetworking does + // Build dnsmasq entries the same way reconcileNetworking does doms, err := api.domains.List() if err != nil { t.Fatalf("List failed: %v", err) } globalCfg := &config.State{} - var instanceConfigs []config.InstanceConfig + var dnsEntries []dnsmasq.DNSEntry for _, dom := range doms { if dom.DomainName == "" { @@ -224,19 +225,13 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) { 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) + dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{ + Domain: dom.DomainName, + IP: dnsIP, + }) } - dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs) + dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries) // --- TCP passthrough (public) → backend IP --- if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") { @@ -251,17 +246,15 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) { t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg) } - // --- internal → local=/ present --- + // --- All domains get local=/ to prevent AAAA leaking upstream (Happy Eyeballs) --- if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") { - t.Errorf("internal domain must have local=/ entry:\n%s", dnsmasqCfg) + t.Errorf("domain must have local=/ entry:\n%s", dnsmasqCfg) } - - // --- public → NO local=/ --- - if strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") { - t.Errorf("public domain must NOT have local=/ entry:\n%s", dnsmasqCfg) + if !strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") { + t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg) } - if strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") { - t.Errorf("public domain must NOT have local=/ entry:\n%s", dnsmasqCfg) + if !strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") { + t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg) } } @@ -345,24 +338,19 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) { doms, _ := api.domains.List() globalCfg := &config.State{} - var instanceConfigs []config.InstanceConfig + var dnsEntries []dnsmasq.DNSEntry for _, dom := range doms { dnsIP := centralIP if dom.Backend.Type == domains.BackendTCPPassthrough { 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) + dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{ + Domain: dom.DomainName, + IP: dnsIP, + }) } - dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs) + dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries) // TCP passthrough → DNS points to backend (k8s LB), NOT central if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") { @@ -440,7 +428,7 @@ func TestReconciliation_DNSOnlyNoGateway(t *testing.T) { // Build DNS config — dns-only should point to its backend IP globalCfg := &config.State{} - var instanceConfigs []config.InstanceConfig + var dnsEntries []dnsmasq.DNSEntry for _, dom := range doms { dnsIP := centralIP @@ -448,18 +436,13 @@ func TestReconciliation_DNSOnlyNoGateway(t *testing.T) { 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) + dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{ + Domain: dom.DomainName, + IP: dnsIP, + }) } - dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs) + dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries) // 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") { diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index 09a6e05..c9384f3 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -4,11 +4,13 @@ import ( "fmt" "log/slog" "os" + "path/filepath" "strings" "time" "github.com/wild-cloud/wild-central/internal/certbot" "github.com/wild-cloud/wild-central/internal/config" + "github.com/wild-cloud/wild-central/internal/dnsmasq" "github.com/wild-cloud/wild-central/internal/domains" "github.com/wild-cloud/wild-central/internal/haproxy" "github.com/wild-cloud/wild-central/internal/network" @@ -111,8 +113,22 @@ func (api *API) reconcileNetworking() { } } - // Only include L7 HTTP routes for domains that have a cert. + // Clean up any 0-byte cert files that would poison HAProxy validation. + // A 0-byte .pem in the certs directory causes the global `bind ssl crt` + // to fail, taking down ALL L7 routes — not just the broken domain. certsDir := "/etc/haproxy/certs/" + if entries, err := os.ReadDir(certsDir); err == nil { + for _, e := range entries { + if strings.HasSuffix(e.Name(), ".pem") { + if info, err := e.Info(); err == nil && info.Size() == 0 { + slog.Warn("reconcile: removing 0-byte cert file", "file", e.Name()) + _ = os.Remove(filepath.Join(certsDir, e.Name())) + } + } + } + } + + // Only include L7 HTTP routes for domains that have a valid cert. var activeHTTPRoutes []haproxy.HTTPRoute for _, route := range httpRoutes { if hasCertForDomain(certsDir, route.Domain) { @@ -176,10 +192,10 @@ func (api *API) reconcileNetworking() { } } - // 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) + // Build dnsmasq DNS entries from registered domains. + // DNS target IP depends on backend type: + // tcp-passthrough/dns-only → backend IP (LAN traffic goes direct) + // http → Central IP (HAProxy terminates TLS) // Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected // (auto-detect can pick wlan0 if that's the default route). centralIP := globalCfg.Cloud.Dnsmasq.IP @@ -190,34 +206,25 @@ func (api *API) reconcileNetworking() { centralIP = "127.0.0.1" } - var instanceConfigs []config.InstanceConfig + var dnsEntries []dnsmasq.DNSEntry for _, dom := range doms { if dom.DomainName == "" { continue } - // Choose the DNS target IP based on domain type dnsIP := centralIP 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 !dom.Public { - // Internal-only: local=/ prevents upstream DNS forwarding - ic.Cloud.InternalDomain = dom.DomainName - } else { - ic.Cloud.Domain = dom.DomainName - } - - instanceConfigs = append(instanceConfigs, ic) + dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{ + Domain: dom.DomainName, + IP: dnsIP, + }) } - if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil { + if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil { slog.Error("reconcile: failed to update dnsmasq", "error", err) } else { api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains") @@ -273,35 +280,30 @@ func (api *API) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) { } } -// hasCertForDomain checks if a cert exists for a domain — either an individual -// cert (.pem) or a wildcard cert that covers it. -func hasCertForDomain(certsDir, domain string) bool { +// hasCertForDomain checks if a valid (non-empty) cert exists for a domain — +// either an individual cert (.pem) or a wildcard cert that covers it. +func hasCertForDomain(_, domain string) bool { // Check individual cert - if _, err := os.Stat(certbot.HAProxyCertPath(domain)); err == nil { + if isValidCertFile(certbot.HAProxyCertPath(domain)) { return true } // Check wildcard certs — a *.example.com cert covers foo.example.com parts := strings.SplitN(domain, ".", 2) if len(parts) == 2 { - // Wildcard cert might be stored as the base domain PEM wildcardBase := parts[1] - if _, err := os.Stat(certbot.HAProxyCertPath(wildcardBase)); err == nil { - return true - } - } - // Check if the certs directory has ANY .pem files (HAProxy needs at least one) - entries, err := os.ReadDir(certsDir) - if err != nil { - return false - } - for _, e := range entries { - if strings.HasSuffix(e.Name(), ".pem") { + if isValidCertFile(certbot.HAProxyCertPath(wildcardBase)) { return true } } return false } +// isValidCertFile checks that a cert file exists and is non-empty. +func isValidCertFile(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Size() > 0 +} + // extractHost gets the host part from a host:port string func extractHost(addr string) string { for i := len(addr) - 1; i >= 0; i-- { diff --git a/internal/config/config.go b/internal/config/config.go index d02b413..7c20dec 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,7 +3,6 @@ package config import ( "encoding/json" "fmt" - "log/slog" "os" "path/filepath" "strings" @@ -158,168 +157,3 @@ func (c *State) IsEmpty() bool { return c.Cloud.Central.Domain == "" && c.Operator.Email == "" } -type NodeConfig struct { - Role string `yaml:"role" json:"role"` - Interface string `yaml:"interface" json:"interface"` - Disk string `yaml:"disk" json:"disk"` - CurrentIp string `yaml:"currentIp" json:"currentIp"` -} - -type InstanceConfig struct { - Operator struct { - Email string `yaml:"email" json:"email"` - } `yaml:"operator" json:"operator"` - Cloud struct { - BaseDomain string `yaml:"baseDomain" json:"baseDomain"` - Domain string `yaml:"domain" json:"domain"` - InternalDomain string `yaml:"internalDomain" json:"internalDomain"` - DHCPRange string `yaml:"dhcpRange" json:"dhcpRange"` - NFS struct { - Host string `yaml:"host" json:"host"` - MediaPath string `yaml:"mediaPath" json:"mediaPath"` - StorageCapacity string `yaml:"storageCapacity" json:"storageCapacity"` - } `yaml:"nfs" json:"nfs"` - DockerRegistryHost string `yaml:"dockerRegistryHost" json:"dockerRegistryHost"` - } `yaml:"cloud" json:"cloud"` - Cluster struct { - Name string `yaml:"name" json:"name"` - LoadBalancerIp string `yaml:"loadBalancerIp" json:"loadBalancerIp"` - IpAddressPool string `yaml:"ipAddressPool" json:"ipAddressPool"` - HostnamePrefix string `yaml:"hostnamePrefix" json:"hostnamePrefix"` - CertManager struct { - Cloudflare struct { - Domain string `yaml:"domain" json:"domain"` - } `yaml:"cloudflare" json:"cloudflare"` - } `yaml:"certManager" json:"certManager"` - ExternalDns struct { - OwnerId string `yaml:"ownerId" json:"ownerId"` - } `yaml:"externalDns" json:"externalDns"` - InternalDns struct { - ExternalResolver string `yaml:"externalResolver" json:"externalResolver"` - } `yaml:"internalDns" json:"internalDns"` - DockerRegistry struct { - Storage string `yaml:"storage" json:"storage"` - } `yaml:"dockerRegistry" json:"dockerRegistry"` - Nodes struct { - Talos struct { - Version string `yaml:"version" json:"version"` - SchematicId string `yaml:"schematicId" json:"schematicId"` - } `yaml:"talos" json:"talos"` - Control struct { - Vip string `yaml:"vip" json:"vip"` - } `yaml:"control" json:"control"` - Active map[string]NodeConfig `yaml:"active" json:"active"` - } `yaml:"nodes" json:"nodes"` - } `yaml:"cluster" json:"cluster"` - Apps map[string]any `yaml:"apps" json:"apps"` -} - -func LoadCloudConfig(configPath string) (*InstanceConfig, error) { - data, err := os.ReadFile(configPath) - if err != nil { - return nil, fmt.Errorf("reading config file %s: %w", configPath, err) - } - - config := &InstanceConfig{} - if err := yaml.Unmarshal(data, config); err != nil { - return nil, fmt.Errorf("parsing config file: %w", err) - } - - return config, nil -} - -func SaveCloudConfig(config *InstanceConfig, 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) - } - - data, err := yaml.Marshal(config) - if err != nil { - return fmt.Errorf("marshaling config: %w", err) - } - - return os.WriteFile(configPath, data, 0644) -} - -// DeepMerge recursively merges src into dst, with src values taking precedence. -// Nested maps are merged recursively; all other types are overwritten by src. -func DeepMerge(dst, src map[string]any) map[string]any { - result := make(map[string]any) - for k, v := range dst { - result[k] = v - } - for k, v := range src { - if srcMap, ok := v.(map[string]any); ok { - if dstMap, ok := result[k].(map[string]any); ok { - result[k] = DeepMerge(dstMap, srcMap) - continue - } - } - result[k] = v - } - return result -} - -// LoadMergedInstanceConfig loads the global config as a base and merges the -// instance config on top. Returns the merged result as an untyped map suitable -// for passing to gomplate as template context. -// If the global config is missing, returns the instance config alone. -// 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, "state.yaml") - - instanceData, err := os.ReadFile(instanceConfigPath) - if err != nil { - return nil, fmt.Errorf("reading instance config %s: %w", instanceConfigPath, err) - } - - var instanceMap map[string]any - if err := yaml.Unmarshal(instanceData, &instanceMap); err != nil { - return nil, fmt.Errorf("parsing instance config: %w", err) - } - if instanceMap == nil { - instanceMap = make(map[string]any) - } - - // Assemble apps.* map from per-app config files - appsConfigDir := filepath.Join(dataDir, "instances", instanceName, "config", "apps") - if entries, err := os.ReadDir(appsConfigDir); err == nil { - appsMap := make(map[string]any) - for _, entry := range entries { - if !entry.IsDir() { - continue - } - appName := entry.Name() - appConfigPath := filepath.Join(dataDir, "instances", instanceName, "config", "apps", appName, "config.yaml") - appData, err := os.ReadFile(appConfigPath) - if err != nil { - continue - } - var appConfig map[string]any - if err := yaml.Unmarshal(appData, &appConfig); err == nil && appConfig != nil { - appsMap[appName] = appConfig - } - } - if len(appsMap) > 0 { - instanceMap["apps"] = appsMap - } - } - - globalData, err := os.ReadFile(globalConfigPath) - if err != nil { - if os.IsNotExist(err) { - slog.Warn("no global config found, using instance config only", "path", globalConfigPath) - return instanceMap, nil - } - return nil, fmt.Errorf("reading global config %s: %w", globalConfigPath, err) - } - - var globalMap map[string]any - if err := yaml.Unmarshal(globalData, &globalMap); err != nil { - return nil, fmt.Errorf("parsing global config: %w", err) - } - - return DeepMerge(globalMap, instanceMap), nil -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 742e68b..cff8963 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -5,8 +5,6 @@ import ( "path/filepath" "strings" "testing" - - "gopkg.in/yaml.v3" ) // Test: LoadState loads valid state @@ -295,187 +293,6 @@ func TestState_IsEmpty(t *testing.T) { } } -// Test: LoadCloudConfig loads instance configuration -func TestLoadCloudConfig(t *testing.T) { - tests := []struct { - name string - configYAML string - verify func(t *testing.T, config *InstanceConfig) - wantErr bool - }{ - { - name: "loads complete instance configuration", - configYAML: `cloud: - dhcpRange: "192.168.1.100,192.168.1.200" - baseDomain: "example.com" - domain: "home" - internalDomain: "internal.example.com" -cluster: - name: "my-cluster" - loadBalancerIp: "192.168.1.10" - nodes: - talos: - version: "v1.8.0" - activeNodes: - - node1: - role: "control" - interface: "eth0" - disk: "/dev/sda" -`, - verify: func(t *testing.T, config *InstanceConfig) { - if config.Cloud.BaseDomain != "example.com" { - t.Error("base domain not loaded correctly") - } - if config.Cloud.DHCPRange != "192.168.1.100,192.168.1.200" { - t.Error("DHCP range not loaded correctly") - } - if config.Cluster.Name != "my-cluster" { - t.Error("cluster name not loaded correctly") - } - if config.Cluster.Nodes.Talos.Version != "v1.8.0" { - t.Error("talos version not loaded correctly") - } - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yaml") - - if err := os.WriteFile(configPath, []byte(tt.configYAML), 0644); err != nil { - t.Fatalf("setup failed: %v", err) - } - - config, err := LoadCloudConfig(configPath) - if tt.wantErr { - if err == nil { - t.Error("expected error, got nil") - } - return - } - - if err != nil { - t.Errorf("unexpected error: %v", err) - return - } - - if config == nil { - t.Fatal("config is nil") - } - - if tt.verify != nil { - tt.verify(t, config) - } - }) - } -} - -// Test: LoadCloudConfig error cases -func TestLoadCloudConfig_Errors(t *testing.T) { - tests := []struct { - name string - setupFunc func(t *testing.T) string - errContains string - }{ - { - name: "non-existent file", - setupFunc: func(t *testing.T) string { - return filepath.Join(t.TempDir(), "nonexistent.yaml") - }, - errContains: "reading config file", - }, - { - name: "invalid yaml", - setupFunc: func(t *testing.T) string { - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yaml") - content := `invalid: yaml: [[[` - if err := os.WriteFile(configPath, []byte(content), 0644); err != nil { - t.Fatalf("setup failed: %v", err) - } - return configPath - }, - errContains: "parsing config file", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - configPath := tt.setupFunc(t) - _, err := LoadCloudConfig(configPath) - - if err == nil { - t.Error("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("error %q does not contain %q", err.Error(), tt.errContains) - } - }) - } -} - -// Test: SaveCloudConfig saves instance configuration -func TestSaveCloudConfig(t *testing.T) { - tests := []struct { - name string - config *InstanceConfig - verify func(t *testing.T, configPath string) - }{ - { - name: "saves instance configuration", - config: func() *InstanceConfig { - cfg := &InstanceConfig{} - cfg.Cloud.BaseDomain = "example.com" - cfg.Cloud.Domain = "home" - return cfg - }(), - verify: func(t *testing.T, configPath string) { - content, err := os.ReadFile(configPath) - if err != nil { - t.Fatalf("failed to read saved config: %v", err) - } - contentStr := string(content) - if !strings.Contains(contentStr, "example.com") { - t.Error("saved config missing base domain") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "subdir", "config.yaml") - - err := SaveCloudConfig(tt.config, configPath) - if err != nil { - t.Errorf("SaveCloudConfig failed: %v", err) - return - } - - // Verify file exists - if _, err := os.Stat(configPath); err != nil { - t.Errorf("config file not created: %v", err) - return - } - - // Verify content can be loaded back - loadedConfig, err := LoadCloudConfig(configPath) - if err != nil { - t.Errorf("failed to reload saved config: %v", err) - } else if loadedConfig == nil { - t.Error("loaded config is nil") - } - - if tt.verify != nil { - tt.verify(t, configPath) - } - }) - } -} - // Test: Round-trip save and load preserves data func TestState_RoundTrip(t *testing.T) { tempDir := t.TempDir() @@ -506,187 +323,3 @@ func TestState_RoundTrip(t *testing.T) { } } -// Test: Round-trip save and load for instance config -func TestInstanceConfig_RoundTrip(t *testing.T) { - tempDir := t.TempDir() - configPath := filepath.Join(tempDir, "config.yaml") - - // Create instance config - original := &InstanceConfig{} - original.Cloud.BaseDomain = "example.com" - original.Cloud.Domain = "home" - original.Cluster.Name = "my-cluster" - - // Save config - if err := SaveCloudConfig(original, configPath); err != nil { - t.Fatalf("SaveCloudConfig failed: %v", err) - } - - // Load config - loaded, err := LoadCloudConfig(configPath) - if err != nil { - t.Fatalf("LoadCloudConfig failed: %v", err) - } - - // Verify fields match - if loaded.Cloud.BaseDomain != original.Cloud.BaseDomain { - t.Errorf("base domain mismatch: got %q, want %q", loaded.Cloud.BaseDomain, original.Cloud.BaseDomain) - } - if loaded.Cluster.Name != original.Cluster.Name { - t.Errorf("cluster name mismatch: got %q, want %q", loaded.Cluster.Name, original.Cluster.Name) - } -} - -func TestDeepMerge(t *testing.T) { - tests := []struct { - name string - dst map[string]any - src map[string]any - expected map[string]any - }{ - { - name: "src overrides dst flat keys", - dst: map[string]any{"a": "1", "b": "2"}, - src: map[string]any{"b": "3", "c": "4"}, - expected: map[string]any{"a": "1", "b": "3", "c": "4"}, - }, - { - name: "nested maps merge recursively", - dst: map[string]any{ - "cloud": map[string]any{ - "central": map[string]any{"domain": "central.example.com"}, - }, - }, - src: map[string]any{ - "cloud": map[string]any{ - "domain": "example.com", - }, - }, - expected: map[string]any{ - "cloud": map[string]any{ - "central": map[string]any{"domain": "central.example.com"}, - "domain": "example.com", - }, - }, - }, - { - name: "src nested key overrides dst nested key", - dst: map[string]any{ - "cloud": map[string]any{"domain": "old.com"}, - }, - src: map[string]any{ - "cloud": map[string]any{"domain": "new.com"}, - }, - expected: map[string]any{ - "cloud": map[string]any{"domain": "new.com"}, - }, - }, - { - name: "empty src returns dst", - dst: map[string]any{"a": "1"}, - src: map[string]any{}, - expected: map[string]any{"a": "1"}, - }, - { - name: "empty dst returns src", - dst: map[string]any{}, - src: map[string]any{"a": "1"}, - expected: map[string]any{"a": "1"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := DeepMerge(tt.dst, tt.src) - resultYAML, _ := yaml.Marshal(result) - expectedYAML, _ := yaml.Marshal(tt.expected) - if string(resultYAML) != string(expectedYAML) { - t.Errorf("DeepMerge() =\n%s\nwant:\n%s", resultYAML, expectedYAML) - } - }) - } -} - -func TestLoadMergedInstanceConfig(t *testing.T) { - dataDir := t.TempDir() - instanceName := "test" - instanceConfigDir := filepath.Join(dataDir, "instances", instanceName, "config") - os.MkdirAll(instanceConfigDir, 0755) - - globalState := `operator: - email: test@example.com -cloud: - central: - domain: central.example.com -` - instanceConfig := `cluster: - name: test - nodes: - control: - vip: 192.168.1.100 -cloud: - domain: cloud.example.com -` - - 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) - if err != nil { - t.Fatalf("LoadMergedInstanceConfig() error: %v", err) - } - - cloud, ok := merged["cloud"].(map[string]any) - if !ok { - t.Fatal("missing cloud key in merged config") - } - - central, ok := cloud["central"].(map[string]any) - if !ok { - t.Fatal("missing cloud.central key — global state not merged") - } - if central["domain"] != "central.example.com" { - t.Errorf("cloud.central.domain = %v, want central.example.com", central["domain"]) - } - - if cloud["domain"] != "cloud.example.com" { - t.Errorf("cloud.domain = %v, want cloud.example.com", cloud["domain"]) - } - - cluster, ok := merged["cluster"].(map[string]any) - if !ok { - t.Fatal("missing cluster key — instance config not merged") - } - if cluster["name"] != "test" { - t.Errorf("cluster.name = %v, want test", cluster["name"]) - } - - operator, ok := merged["operator"].(map[string]any) - if !ok { - 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_NoGlobalState(t *testing.T) { - dataDir := t.TempDir() - instanceConfigDir := filepath.Join(dataDir, "instances", "test", "config") - os.MkdirAll(instanceConfigDir, 0755) - - os.WriteFile(filepath.Join(instanceConfigDir, "instance.yaml"), []byte("cluster:\n name: test\n"), 0644) - - merged, err := LoadMergedInstanceConfig(dataDir, "test") - if err != nil { - t.Fatalf("LoadMergedInstanceConfig() error: %v", err) - } - - cluster, ok := merged["cluster"].(map[string]any) - if !ok { - t.Fatal("missing cluster key") - } - if cluster["name"] != "test" { - t.Errorf("cluster.name = %v, want test", cluster["name"]) - } -} diff --git a/internal/config/manager.go b/internal/config/manager.go index abe5f1b..ddd4eee 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -87,31 +87,6 @@ func NewManager() *Manager { } } -// 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") - - // 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 - } - - // Ensure config directory exists - if err := storage.EnsureDir(filepath.Join(dataDir, "instances", name, "config"), 0755); err != nil { - return err - } - - initialConfig := &InstanceConfig{} - initialConfig.Cluster.Name = name - initialConfig.Cluster.Nodes.Active = make(map[string]NodeConfig) - initialConfig.Apps = make(map[string]any) - return SaveCloudConfig(initialConfig, configPath) -} - // GetConfigValue retrieves a value from a config file func (m *Manager) GetConfigValue(configPath, key string) (string, error) { if !storage.FileExists(configPath) { @@ -186,17 +161,3 @@ func (m *Manager) CopyConfig(srcPath, dstPath string) error { return storage.WriteFile(dstPath, content, 0644) } -// GetInstanceConfigPath returns the path to an instance's config file -func GetInstanceConfigPath(dataDir, instanceName string) string { - return filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml") -} - -// GetInstanceSecretsPath returns the path to an instance's secrets file -func GetInstanceSecretsPath(dataDir, instanceName string) string { - return filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml") -} - -// GetInstancePath returns the path to an instance directory -func GetInstancePath(dataDir, instanceName string) string { - return filepath.Join(dataDir, "instances", instanceName) -} diff --git a/internal/config/manager_test.go b/internal/config/manager_test.go index cf705b6..f750bca 100644 --- a/internal/config/manager_test.go +++ b/internal/config/manager_test.go @@ -1,7 +1,6 @@ package config import ( - "os" "path/filepath" "strings" "sync" @@ -18,139 +17,6 @@ func TestNewManager(t *testing.T) { } } -// Test: EnsureInstanceConfig creates config file with proper structure -func TestEnsureInstanceConfig(t *testing.T) { - const instanceName = "test-instance" - tests := []struct { - name string - setupFunc func(t *testing.T, dataDir string) - wantErr bool - errContains string - }{ - { - name: "creates config when not exists", - setupFunc: nil, - wantErr: false, - }, - { - name: "returns nil when config exists", - setupFunc: func(t *testing.T, dataDir string) { - configPath := filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml") - if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { - t.Fatalf("setup mkdir failed: %v", err) - } - content := `operator: - email: "" -cloud: - baseDomain: "test.local" - domain: "test" - internalDomain: "internal.test" - dhcpRange: "" - dns: - ip: "" - router: - ip: "" - dnsmasq: - interface: "" - nfs: - host: "" - mediaPath: "" - storageCapacity: "" - dockerRegistryHost: "" -cluster: - name: "" - loadBalancerIp: "" - ipAddressPool: "" - hostnamePrefix: "" - certManager: - cloudflare: - domain: "" - externalDns: - ownerId: "" - dockerRegistry: - storage: "" - nodes: - talos: - version: "" - schematicId: "" - control: - vip: "" - active: {} -apps: {} -` - if err := storage.WriteFile(configPath, []byte(content), 0644); err != nil { - t.Fatalf("setup failed: %v", err) - } - }, - wantErr: false, - }, - { - name: "returns error when config is invalid yaml", - setupFunc: func(t *testing.T, dataDir string) { - configPath := filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml") - if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil { - t.Fatalf("setup mkdir failed: %v", err) - } - if err := storage.WriteFile(configPath, []byte(`invalid: yaml: content: [[[`), 0644); err != nil { - t.Fatalf("setup failed: %v", err) - } - }, - wantErr: true, - errContains: "invalid config file", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dataDir := t.TempDir() - m := NewManager() - - if tt.setupFunc != nil { - tt.setupFunc(t, dataDir) - } - - err := m.EnsureInstanceConfig(instanceName, dataDir) - if tt.wantErr { - if err == nil { - t.Error("expected error, got nil") - } else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("error %q does not contain %q", err.Error(), tt.errContains) - } - return - } - - if err != nil { - t.Errorf("unexpected error: %v", err) - return - } - - // Verify config file exists - configPath := filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml") - if !storage.FileExists(configPath) { - t.Error("config file not created") - } - - // Verify config is valid YAML - if err := m.ValidateConfig(configPath); err != nil { - t.Errorf("config validation failed: %v", err) - } - - // Verify config has expected structure (canonical nested format) - content, err := storage.ReadFile(configPath) - if err != nil { - t.Fatalf("failed to read config: %v", err) - } - contentStr := string(content) - requiredFields := []string{"operator:", "cloud:", "cluster:", "apps:"} - for _, field := range requiredFields { - if !strings.Contains(contentStr, field) { - t.Errorf("config missing required field: %s", field) - } - } - }) - } -} - // Test: GetConfigValue retrieves values correctly func TestGetConfigValue(t *testing.T) { tests := []struct { @@ -821,102 +687,3 @@ func TestCopyConfig_Errors(t *testing.T) { } } -// Test: File permissions are preserved -func TestEnsureInstanceConfig_FilePermissions(t *testing.T) { - tempDir := t.TempDir() - m := NewManager() - - if err := m.EnsureInstanceConfig("my-wild-cloud", tempDir); err != nil { - t.Fatalf("EnsureInstanceConfig failed: %v", err) - } - - configPath := filepath.Join(tempDir, "instances", "my-wild-cloud", "config", "instance.yaml") - info, err := os.Stat(configPath) - if err != nil { - t.Fatalf("failed to stat config file: %v", err) - } - - // Verify file has 0644 permissions - if info.Mode().Perm() != 0644 { - t.Errorf("expected permissions 0644, got %v", info.Mode().Perm()) - } -} - -// Test: Idempotent config creation -func TestEnsureInstanceConfig_Idempotent(t *testing.T) { - tempDir := t.TempDir() - m := NewManager() - - // First call creates config - if err := m.EnsureInstanceConfig("my-wild-cloud", tempDir); err != nil { - t.Fatalf("first EnsureInstanceConfig failed: %v", err) - } - - configPath := filepath.Join(tempDir, "instances", "my-wild-cloud", "config", "instance.yaml") - firstContent, err := storage.ReadFile(configPath) - if err != nil { - t.Fatalf("failed to read config: %v", err) - } - - // Second call should not modify config - if err := m.EnsureInstanceConfig("my-wild-cloud", tempDir); err != nil { - t.Fatalf("second EnsureInstanceConfig failed: %v", err) - } - - secondContent, err := storage.ReadFile(configPath) - if err != nil { - t.Fatalf("failed to read config: %v", err) - } - - if string(firstContent) != string(secondContent) { - t.Error("config content changed on second call") - } -} - -// Test: Config structure contains all required fields -func TestEnsureInstanceConfig_RequiredFields(t *testing.T) { - tempDir := t.TempDir() - m := NewManager() - - if err := m.EnsureInstanceConfig("my-wild-cloud", tempDir); err != nil { - t.Fatalf("EnsureInstanceConfig failed: %v", err) - } - - configPath := filepath.Join(tempDir, "instances", "my-wild-cloud", "config", "instance.yaml") - content, err := storage.ReadFile(configPath) - if err != nil { - t.Fatalf("failed to read config: %v", err) - } - - contentStr := string(content) - requiredFields := []string{ - "operator:", - "cloud:", - "baseDomain:", - "domain:", - "internalDomain:", - "dhcpRange:", - "nfs:", - "cluster:", - "loadBalancerIp:", - "ipAddressPool:", - "hostnamePrefix:", - "certManager:", - "externalDns:", - "dockerRegistry:", - "nodes:", - "talos:", - "version:", - "schematicId:", - "control:", - "vip:", - "active:", - "apps:", - } - - for _, field := range requiredFields { - if !strings.Contains(contentStr, field) { - t.Errorf("config missing required field: %s", field) - } - } -} diff --git a/internal/dnsmasq/config.go b/internal/dnsmasq/config.go index 49efcd4..9475a26 100644 --- a/internal/dnsmasq/config.go +++ b/internal/dnsmasq/config.go @@ -13,6 +13,17 @@ import ( "github.com/wild-cloud/wild-central/internal/network" ) +// DNSEntry represents a single domain-to-IP DNS mapping for dnsmasq. +// Each entry produces both local=/ and address=/ directives. +// local=/ makes dnsmasq authoritative for the domain, which prevents +// AAAA queries from leaking to upstream DNS. Without it, upstream could +// return public IPv6 records and browsers using Happy Eyeballs (RFC 8305) +// would try the unreachable IPv6 path first, adding latency on LAN. +type DNSEntry struct { + Domain string // FQDN to resolve (e.g. "cloud.payne.io") + IP string // target IPv4 address +} + // ConfigGenerator handles dnsmasq configuration generation type ConfigGenerator struct { configPath string @@ -33,9 +44,9 @@ func (g *ConfigGenerator) GetConfigPath() string { return g.configPath } -// 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.State, clouds []config.InstanceConfig) string { +// Generate creates a dnsmasq configuration from registered domain entries. +// It auto-detects the Wild Central server's IP address for the listen directive. +func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) 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. @@ -53,30 +64,16 @@ func (g *ConfigGenerator) Generate(cfg *config.State, clouds []config.InstanceCo } resolution_section := "" - for _, cloud := range clouds { - // Point cloud domains to the cluster load balancer IP - loadBalancerIP := instanceLoadBalancerIP(cloud) - if loadBalancerIP == "" { - slog.Info("no load balancer IP configured, adding commented DNS config", "component", "dnsmasq", "instance", cloud.Cluster.Name) - // Add commented out entries for instances without load balancer - resolution_section += fmt.Sprintf("# No load balancer IP configured for instance %s\n", cloud.Cluster.Name) - if cloud.Cloud.InternalDomain != "" { - resolution_section += fmt.Sprintf("# local=/%s/\n# address=/%s/\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain) - } - if cloud.Cloud.Domain != "" { - resolution_section += fmt.Sprintf("# address=/%s/\n", cloud.Cloud.Domain) - } + for _, entry := range entries { + if entry.Domain == "" || entry.IP == "" { continue } - // Internal domain (.internal.cloud.example.tld) - local only, no external DNS - if cloud.Cloud.InternalDomain != "" { - resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain, loadBalancerIP) - } - - // External/primary domain - resolve to backend IP - if cloud.Cloud.Domain != "" { - resolution_section += fmt.Sprintf("address=/%s/%s\n", cloud.Cloud.Domain, loadBalancerIP) - } + // local=/ makes dnsmasq authoritative for the domain so AAAA queries + // return empty instead of leaking to upstream DNS. Without this, + // upstream could return public IPv6 records and browsers using Happy + // Eyeballs (RFC 8305) would try the unreachable IPv6 path first, + // adding 250ms-2s latency on LAN. + resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", entry.Domain, entry.Domain, entry.IP) } template := `# Configuration file for dnsmasq. @@ -104,8 +101,8 @@ log-dhcp } // WriteConfig writes the dnsmasq configuration to the specified path -func (g *ConfigGenerator) WriteConfig(cfg *config.State, clouds []config.InstanceConfig, configPath string) error { - configContent := g.Generate(cfg, clouds) +func (g *ConfigGenerator) WriteConfig(cfg *config.State, entries []DNSEntry, configPath string) error { + configContent := g.Generate(cfg, entries) slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath) @@ -134,7 +131,7 @@ type ServiceStatus struct { PID int `json:"pid"` IP string `json:"ip"` ConfigFile string `json:"config_file"` - InstancesConfigured int `json:"instances_configured"` + DomainsConfigured int `json:"domains_configured"` LastRestart time.Time `json:"last_restart"` } @@ -190,13 +187,9 @@ func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) { } } - // Count instances in config (both active and commented) + // Count domains in config by counting local=/ directives if data, err := os.ReadFile(g.configPath); err == nil { - // Count both "local=/" and "# local=/" occurrences - activeCount := strings.Count(string(data), "\nlocal=/") - commentedCount := strings.Count(string(data), "\n# local=/") - // Each instance creates 1 "local=/" entry (internal domain) - status.InstancesConfigured = activeCount + commentedCount + status.DomainsConfigured = strings.Count(string(data), "\nlocal=/") } return status, nil @@ -211,10 +204,10 @@ func (g *ConfigGenerator) ReadConfig() (string, error) { return string(data), nil } -// UpdateConfig regenerates and writes the dnsmasq configuration for all instances -func (g *ConfigGenerator) UpdateConfig(cfg *config.State, instances []config.InstanceConfig, restart bool) error { +// UpdateConfig regenerates and writes the dnsmasq configuration and optionally restarts. +func (g *ConfigGenerator) UpdateConfig(cfg *config.State, entries []DNSEntry, restart bool) error { // Generate fresh config from scratch - configContent := g.Generate(cfg, instances) + configContent := g.Generate(cfg, entries) // Write config slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", g.configPath) diff --git a/internal/dnsmasq/config_modular.go b/internal/dnsmasq/config_modular.go index 346d5a4..c2cc7e2 100644 --- a/internal/dnsmasq/config_modular.go +++ b/internal/dnsmasq/config_modular.go @@ -3,21 +3,15 @@ package dnsmasq import ( "fmt" "log/slog" - "os" "os/exec" - "path/filepath" "strings" "github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/network" ) -const ( - instanceConfigDir = "/etc/dnsmasq.d/wild-cloud-instances" -) - // GenerateMainConfig creates the main dnsmasq configuration with global settings -// and a conf-dir directive to include per-instance configs +// and a conf-dir directive to include per-domain configs func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string { // Get the Wild Central IP address dnsIP, err := network.GetWildCentralIP() @@ -29,8 +23,8 @@ func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string { var sb strings.Builder fmt.Fprintf(&sb, `# Wild Cloud DNS Configuration (Main) -# This file contains global settings. Instance-specific DNS entries are in: -# /etc/dnsmasq.d/wild-cloud-instances/*.conf +# This file contains global settings. Domain-specific DNS entries are +# generated by reconciliation into the monolithic config. # Basic Settings listen-address=%s @@ -39,9 +33,6 @@ domain-needed bogus-priv no-resolv -# Include per-instance DNS configurations -conf-dir=%s,*.conf - # Upstream DNS servers server=1.1.1.1 server=8.8.8.8 @@ -49,7 +40,7 @@ server=8.8.8.8 # Logging log-queries log-dhcp -`, dnsIP, instanceConfigDir) +`, dnsIP) // DHCP section (only when explicitly enabled) if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled { @@ -82,57 +73,6 @@ log-dhcp return sb.String() } -// instanceLoadBalancerIP returns the load balancer IP for an instance. -// It checks cluster.loadBalancerIp first, then falls back to apps.metallb.loadBalancerIp, -// which is where the normal app install flow writes it. -func instanceLoadBalancerIP(instance config.InstanceConfig) string { - if instance.Cluster.LoadBalancerIp != "" { - return instance.Cluster.LoadBalancerIp - } - if metallb, ok := instance.Apps["metallb"].(map[string]any); ok { - if ip, ok := metallb["loadBalancerIp"].(string); ok { - return ip - } - } - return "" -} - -// GenerateInstanceConfig creates a DNS configuration for a single instance -func (g *ConfigGenerator) GenerateInstanceConfig(instance config.InstanceConfig) string { - var sb strings.Builder - - fmt.Fprintf(&sb, "# DNS configuration for instance: %s\n", instance.Cluster.Name) - sb.WriteString("# Generated by Wild Cloud\n\n") - - loadBalancerIP := instanceLoadBalancerIP(instance) - if loadBalancerIP == "" { - sb.WriteString("# WARNING: No load balancer IP configured for this instance\n") - sb.WriteString("# DNS entries are commented out until load balancer IP is configured\n\n") - if instance.Cloud.InternalDomain != "" { - fmt.Fprintf(&sb, "# local=/%s/\n", instance.Cloud.InternalDomain) - fmt.Fprintf(&sb, "# address=/%s/\n\n", instance.Cloud.InternalDomain) - } - if instance.Cloud.Domain != "" { - fmt.Fprintf(&sb, "# address=/%s/\n", instance.Cloud.Domain) - } - } else { - // Internal domain (.internal.cloud.example.tld) - local only, no external DNS - if instance.Cloud.InternalDomain != "" { - sb.WriteString("# Internal domain (LAN-only)\n") - fmt.Fprintf(&sb, "local=/%s/\n", instance.Cloud.InternalDomain) - fmt.Fprintf(&sb, "address=/%s/%s\n\n", instance.Cloud.InternalDomain, loadBalancerIP) - } - - // External/primary domain - resolve to backend IP - if instance.Cloud.Domain != "" { - sb.WriteString("# Public domain (resolved locally to avoid external DNS)\n") - fmt.Fprintf(&sb, "address=/%s/%s\n", instance.Cloud.Domain, loadBalancerIP) - } - } - - return sb.String() -} - // ValidateConfig tests a dnsmasq configuration file for syntax errors func (g *ConfigGenerator) ValidateConfig(configPath string) error { // Use dnsmasq --test to validate the configuration @@ -150,104 +90,6 @@ func (g *ConfigGenerator) ValidateConfig(configPath string) error { return nil } -// WriteInstanceConfig writes the DNS configuration for a single instance -func (g *ConfigGenerator) WriteInstanceConfig(instanceName string, instance config.InstanceConfig) error { - instanceFile := filepath.Join(instanceConfigDir, fmt.Sprintf("%s.conf", instanceName)) - configContent := g.GenerateInstanceConfig(instance) - - // Ensure directory exists - if err := os.MkdirAll(instanceConfigDir, 0755); err != nil { - return fmt.Errorf("creating instance config directory: %w", err) - } - - // Write to temp file first - tempFile := instanceFile + ".tmp" - if err := os.WriteFile(tempFile, []byte(configContent), 0644); err != nil { - return fmt.Errorf("writing temp instance config: %w", err) - } - - // Validate the temp config along with main config - if err := g.ValidateWithInstance(tempFile); err != nil { - os.Remove(tempFile) // Clean up temp file - return fmt.Errorf("instance config validation failed: %w", err) - } - - // Move temp file to final location (atomic operation) - if err := os.Rename(tempFile, instanceFile); err != nil { - os.Remove(tempFile) // Clean up temp file - return fmt.Errorf("installing instance config: %w", err) - } - - slog.Info("wrote instance DNS config", "component", "dnsmasq", "path", instanceFile) - return nil -} - -// ValidateWithInstance validates the main config along with a specific instance config -func (g *ConfigGenerator) ValidateWithInstance(instanceConfigPath string) error { - // Create a temporary test directory - tempDir, err := os.MkdirTemp("", "dnsmasq-test-*") - if err != nil { - return fmt.Errorf("creating temp dir: %w", err) - } - defer os.RemoveAll(tempDir) - - // Copy main config to temp - mainContent, err := os.ReadFile(g.configPath) - if err != nil { - return fmt.Errorf("reading main config: %w", err) - } - - tempMainConfig := filepath.Join(tempDir, "main.conf") - // Modify the conf-dir line to point to our temp instance dir - tempInstanceDir := filepath.Join(tempDir, "instances") - if err := os.MkdirAll(tempInstanceDir, 0755); err != nil { - return fmt.Errorf("creating temp instance dir: %w", err) - } - - modifiedContent := strings.ReplaceAll( - string(mainContent), - fmt.Sprintf("conf-dir=%s,*.conf", instanceConfigDir), - fmt.Sprintf("conf-dir=%s,*.conf", tempInstanceDir), - ) - - if err := os.WriteFile(tempMainConfig, []byte(modifiedContent), 0644); err != nil { - return fmt.Errorf("writing temp main config: %w", err) - } - - // Copy instance config to temp - instanceContent, err := os.ReadFile(instanceConfigPath) - if err != nil { - return fmt.Errorf("reading instance config: %w", err) - } - - tempInstanceConfig := filepath.Join(tempInstanceDir, filepath.Base(instanceConfigPath)) - if err := os.WriteFile(tempInstanceConfig, instanceContent, 0644); err != nil { - return fmt.Errorf("writing temp instance config: %w", err) - } - - // Validate the combined configuration - return g.ValidateConfig(tempMainConfig) -} - -// RemoveInstanceConfig removes the DNS configuration for an instance -func (g *ConfigGenerator) RemoveInstanceConfig(instanceName string) error { - instanceFile := filepath.Join(instanceConfigDir, fmt.Sprintf("%s.conf", instanceName)) - - // Check if file exists - if _, err := os.Stat(instanceFile); os.IsNotExist(err) { - slog.Info("instance DNS config does not exist", "component", "dnsmasq", "path", instanceFile) - return nil // Not an error, already removed - } - - // Remove the file - if err := os.Remove(instanceFile); err != nil { - return fmt.Errorf("removing instance config: %w", err) - } - - slog.Info("removed instance DNS config", "component", "dnsmasq", "path", instanceFile) - return nil -} - // ReloadService sends a SIGHUP to dnsmasq to reload configuration // This is lighter weight than a full restart func (g *ConfigGenerator) ReloadService() error { @@ -262,81 +104,3 @@ func (g *ConfigGenerator) ReloadService() error { slog.Info("dnsmasq service reloaded", "component", "dnsmasq") return nil } - -// UpdateToModularConfig migrates from monolithic to modular configuration -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 - if err := os.MkdirAll(instanceConfigDir, 0755); err != nil { - return fmt.Errorf("creating instance config directory: %w", err) - } - - // Generate and write instance configs first (but don't reload yet) - for i, instance := range instances { - instanceName := instanceNames[i] - if err := g.WriteInstanceConfig(instanceName, instance); err != nil { - slog.Error("failed to write instance config", "component", "dnsmasq", "instance", instanceName, "error", err) - // Continue with other instances - } - } - - // Generate new main config with conf-dir - mainConfig := g.GenerateMainConfig(cfg) - - // Write to temp file first - tempFile := g.configPath + ".tmp" - if err := os.WriteFile(tempFile, []byte(mainConfig), 0644); err != nil { - return fmt.Errorf("writing temp main config: %w", err) - } - - // Validate the new config - if err := g.ValidateConfig(tempFile); err != nil { - os.Remove(tempFile) - return fmt.Errorf("main config validation failed: %w", err) - } - - // Backup current config - backupFile := g.configPath + ".pre-modular" - if err := os.Rename(g.configPath, backupFile); err != nil { - os.Remove(tempFile) - return fmt.Errorf("backing up current config: %w", err) - } - - // Install new config - if err := os.Rename(tempFile, g.configPath); err != nil { - // Try to restore backup - _ = os.Rename(backupFile, g.configPath) - return fmt.Errorf("installing new config: %w", err) - } - - // Reload dnsmasq - if err := g.ReloadService(); err != nil { - // Try to restore backup and reload - slog.Error("reload failed, restoring backup", "component", "dnsmasq", "error", err) - os.Remove(g.configPath) - _ = os.Rename(backupFile, g.configPath) - _ = g.ReloadService() - return fmt.Errorf("reloading with new config: %w", err) - } - - slog.Info("migrated to modular configuration", "component", "dnsmasq") - return nil -} - -// UpdateInstanceDNS updates DNS configuration for a single instance -// This is called when instance configuration changes (e.g., domain names) -func (g *ConfigGenerator) UpdateInstanceDNS(instanceName string, instance config.InstanceConfig) error { - // Write the new instance config - if err := g.WriteInstanceConfig(instanceName, instance); err != nil { - return fmt.Errorf("writing instance DNS config: %w", err) - } - - // Reload dnsmasq to pick up changes - if err := g.ReloadService(); err != nil { - return fmt.Errorf("reloading dnsmasq: %w", err) - } - - slog.Info("DNS updated for instance", "component", "dnsmasq", "instance", instanceName) - return nil -} diff --git a/internal/dnsmasq/config_test.go b/internal/dnsmasq/config_test.go index 3d89636..7da53c8 100644 --- a/internal/dnsmasq/config_test.go +++ b/internal/dnsmasq/config_test.go @@ -8,141 +8,42 @@ import ( "github.com/wild-cloud/wild-central/internal/config" ) -func instanceWith(domain, internalDomain, lbIP string) config.InstanceConfig { - var inst config.InstanceConfig - inst.Cluster.Name = "test" - inst.Cloud.Domain = domain - inst.Cloud.InternalDomain = internalDomain - inst.Cluster.LoadBalancerIp = lbIP - return inst +func entry(domain, ip string) DNSEntry { + return DNSEntry{Domain: domain, IP: ip} } -// Test: instanceLoadBalancerIP prefers cluster.loadBalancerIp -func TestInstanceLoadBalancerIP_ClusterField(t *testing.T) { - inst := instanceWith("cloud.example.com", "internal.example.com", "10.0.0.5") - if got := instanceLoadBalancerIP(inst); got != "10.0.0.5" { - t.Errorf("got %q, want %q", got, "10.0.0.5") - } -} - -// Test: instanceLoadBalancerIP falls back to apps.metallb.loadBalancerIp -func TestInstanceLoadBalancerIP_MetalLBFallback(t *testing.T) { - var inst config.InstanceConfig - inst.Apps = map[string]any{ - "metallb": map[string]any{ - "loadBalancerIp": "10.0.0.9", - }, - } - if got := instanceLoadBalancerIP(inst); got != "10.0.0.9" { - t.Errorf("got %q, want %q", got, "10.0.0.9") - } -} - -// Test: instanceLoadBalancerIP returns empty string when nothing is set -func TestInstanceLoadBalancerIP_Empty(t *testing.T) { - var inst config.InstanceConfig - if got := instanceLoadBalancerIP(inst); got != "" { - t.Errorf("got %q, want empty string", got) - } -} - -// Test: cluster.loadBalancerIp takes precedence over metallb -func TestInstanceLoadBalancerIP_ClusterTakesPrecedence(t *testing.T) { - inst := instanceWith("cloud.example.com", "internal.example.com", "10.0.0.5") - inst.Apps = map[string]any{ - "metallb": map[string]any{ - "loadBalancerIp": "10.0.0.9", - }, - } - if got := instanceLoadBalancerIP(inst); got != "10.0.0.5" { - t.Errorf("got %q, want cluster IP %q", got, "10.0.0.5") - } -} - -// Test: GenerateInstanceConfig with load balancer IP produces active DNS entries -func TestGenerateInstanceConfig_WithLBIP(t *testing.T) { +// Test: Generate with entries produces expected DNS entries +func TestGenerate_WithEntries(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10") - - out := g.GenerateInstanceConfig(inst) - - if !strings.Contains(out, "local=/internal.example.com/") { - t.Errorf("expected local=/ directive for internal domain, got:\n%s", out) + entries := []DNSEntry{ + entry("cloud.example.com", "192.168.1.10"), + entry("internal.example.com", "192.168.1.10"), } - if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") { - t.Errorf("expected address entry for internal domain, got:\n%s", out) + + out := g.Generate(nil, entries) + + if !strings.Contains(out, "local=/cloud.example.com/") { + t.Errorf("expected local=/ for domain, got:\n%s", out) } if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") { - t.Errorf("expected address entry for external domain, got:\n%s", out) + t.Errorf("expected address entry for domain, got:\n%s", out) } - if strings.Contains(out, "WARNING") { - t.Errorf("unexpected WARNING in output:\n%s", out) - } -} - -// Test: GenerateInstanceConfig without load balancer IP produces commented entries -func TestGenerateInstanceConfig_NoLBIP(t *testing.T) { - g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - inst := instanceWith("cloud.example.com", "internal.example.com", "") - - out := g.GenerateInstanceConfig(inst) - - if !strings.Contains(out, "WARNING") { - t.Errorf("expected WARNING in output when no LB IP, got:\n%s", out) - } - if !strings.Contains(out, "# local=/internal.example.com/") { - t.Errorf("expected commented local=/ directive, got:\n%s", out) - } - if !strings.Contains(out, "# address=/internal.example.com/") { - t.Errorf("expected commented address entry for internal domain, got:\n%s", out) - } - if !strings.Contains(out, "# address=/cloud.example.com/") { - t.Errorf("expected commented address entry for external domain, got:\n%s", out) - } - if strings.Contains(out, "\nlocal=/") { - t.Errorf("unexpected active local=/ directive when no LB IP, got:\n%s", out) - } -} - -// Test: GenerateInstanceConfig includes instance name in header comment -func TestGenerateInstanceConfig_IncludesInstanceName(t *testing.T) { - g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10") - inst.Cluster.Name = "my-instance" - - out := g.GenerateInstanceConfig(inst) - - if !strings.Contains(out, "my-instance") { - t.Errorf("expected instance name in config header, got:\n%s", out) - } -} - -// Test: Generate (monolithic) with instances produces expected DNS entries -func TestGenerate_WithInstances(t *testing.T) { - g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10") - - out := g.Generate(nil, []config.InstanceConfig{inst}) - if !strings.Contains(out, "local=/internal.example.com/") { t.Errorf("expected local=/ for internal domain, got:\n%s", out) } if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") { t.Errorf("expected address entry for internal domain, got:\n%s", out) } - if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") { - t.Errorf("expected address entry for external domain, got:\n%s", out) - } if !strings.Contains(out, "server=1.1.1.1") { t.Errorf("expected upstream DNS server, got:\n%s", out) } } -// Test: Generate with no instances still produces valid config skeleton -func TestGenerate_NoInstances(t *testing.T) { +// Test: Generate with no entries still produces valid config skeleton +func TestGenerate_NoEntries(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - out := g.Generate(nil, []config.InstanceConfig{}) + out := g.Generate(nil, []DNSEntry{}) if !strings.Contains(out, "bind-interfaces") { t.Errorf("expected bind-interfaces in config, got:\n%s", out) @@ -155,31 +56,23 @@ func TestGenerate_NoInstances(t *testing.T) { } } -// Test: Generate with instance missing LB IP produces commented entries -func TestGenerate_InstanceWithoutLBIP(t *testing.T) { +// Test: Generate skips entries with empty IP +func TestGenerate_EntryWithoutIP(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - inst := instanceWith("cloud.example.com", "internal.example.com", "") - inst.Cluster.Name = "no-lb-instance" - out := g.Generate(nil, []config.InstanceConfig{inst}) + out := g.Generate(nil, []DNSEntry{entry("cloud.example.com", "")}) - if !strings.Contains(out, "# No load balancer IP configured for instance no-lb-instance") { - t.Errorf("expected comment about missing LB IP, got:\n%s", out) + if strings.Contains(out, "address=/") { + t.Errorf("expected no address= entries for empty IP, got:\n%s", out) } } -// Test: GenerateMainConfig produces conf-dir directive +// Test: GenerateMainConfig produces valid base config func TestGenerateMainConfig(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") out := g.GenerateMainConfig(nil) - if !strings.Contains(out, "conf-dir=") { - t.Errorf("expected conf-dir directive in main config, got:\n%s", out) - } - if !strings.Contains(out, instanceConfigDir) { - t.Errorf("expected instance config dir %q in conf-dir, got:\n%s", instanceConfigDir, out) - } if !strings.Contains(out, "bind-interfaces") { t.Errorf("expected bind-interfaces in main config, got:\n%s", out) } @@ -228,7 +121,6 @@ func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) { cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" - // No gateway set out := g.GenerateMainConfig(cfg) @@ -282,7 +174,6 @@ func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) { cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" - // LeaseTime not set out := g.GenerateMainConfig(cfg) @@ -307,181 +198,117 @@ func TestNewConfigGenerator_DefaultPath(t *testing.T) { } } -// Test: Service registration — domain set but no internal domain -func TestGenerate_ServiceWithoutInternalDomain(t *testing.T) { +// Test: Domain-only entry (no internal domain pair) +func TestGenerate_DomainOnly(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.State{} - // Service registration: only domain + backend IP, no internal domain - inst := instanceWith("my-api.payne.io", "", "192.168.8.151") - out := g.Generate(globalCfg, []config.InstanceConfig{inst}) + out := g.Generate(globalCfg, []DNSEntry{entry("my-api.payne.io", "192.168.8.151")}) - // Should have address entry for the domain if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") { - t.Errorf("expected address entry for service domain, got:\n%s", out) + t.Errorf("expected address entry for domain, got:\n%s", out) } - // Must NOT have empty local=// entry - if strings.Contains(out, "local=//") { - t.Errorf("unexpected empty local=// in output:\n%s", out) - } - // Must NOT have empty address=// entry - if strings.Contains(out, "address=//") { - t.Errorf("unexpected empty address=// in output:\n%s", out) - } -} - -// Test: GenerateInstanceConfig — service without internal domain -func TestGenerateInstanceConfig_ServiceWithoutInternalDomain(t *testing.T) { - g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - inst := instanceWith("central.payne.io", "", "192.168.8.151") - - out := g.GenerateInstanceConfig(inst) - - if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") { - t.Errorf("expected address entry, got:\n%s", out) - } - if strings.Contains(out, "local=//") { - t.Errorf("unexpected empty local=// in output:\n%s", out) - } - if strings.Contains(out, "address=//") { - t.Errorf("unexpected empty address=// in output:\n%s", out) - } -} - -// 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.State{} - - // Simulate reconciliation for an internal-reach service: - // InternalDomain is set (reach:internal), Domain is empty (reach:public) - inst := instanceWith("", "my-api.payne.io", "192.168.8.151") - - out := g.Generate(globalCfg, []config.InstanceConfig{inst}) - if !strings.Contains(out, "local=/my-api.payne.io/") { - t.Errorf("reach:internal must produce local=/ directive, got:\n%s", out) + t.Errorf("expected local=/ entry for domain, got:\n%s", out) } - if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") { - t.Errorf("reach:internal must produce address entry, got:\n%s", out) + // Must NOT have empty entries + if strings.Contains(out, "local=//") { + t.Errorf("unexpected empty local=// in output:\n%s", out) + } + if strings.Contains(out, "address=//") { + t.Errorf("unexpected empty address=// in output:\n%s", out) } } -// Test: reach:public → no local=/ directive (allows upstream DNS forwarding) -func TestGenerate_ReachPublic_NoLocal(t *testing.T) { +// Test: All domains get local=/ to prevent AAAA leaking upstream +func TestGenerate_AllDomainsGetLocal(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.State{} - // Simulate reconciliation for a public-reach service: - // Domain is set (reach:public), InternalDomain is empty - inst := instanceWith("cloud.payne.io", "", "192.168.8.240") + // Both "internal" and "public" domains should get local=/ — + // prevents AAAA queries from leaking to upstream DNS (Happy Eyeballs). + entries := []DNSEntry{ + entry("internal-api.payne.io", "192.168.8.151"), + entry("cloud.payne.io", "192.168.8.240"), + } - out := g.Generate(globalCfg, []config.InstanceConfig{inst}) + out := g.Generate(globalCfg, entries) + + if !strings.Contains(out, "local=/internal-api.payne.io/") { + t.Errorf("expected local=/ for internal domain, got:\n%s", out) + } + if !strings.Contains(out, "local=/cloud.payne.io/") { + t.Errorf("expected local=/ for public domain (prevents AAAA leaking), got:\n%s", out) + } +} + +// Test: Mix of domains each get their own entries +func TestGenerate_MixedDomains(t *testing.T) { + g := NewConfigGenerator("/tmp/test-dnsmasq.conf") + globalCfg := &config.State{} + + entries := []DNSEntry{ + entry("cloud.payne.io", "192.168.8.240"), // k8s passthrough + entry("central.payne.io", "192.168.8.151"), // Central HTTP + entry("wild-cloud.payne.io", "192.168.8.151"), // service HTTP + } + + out := g.Generate(globalCfg, entries) if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") { - t.Errorf("reach:public must produce address entry, got:\n%s", out) + t.Errorf("expected address for cloud domain, got:\n%s", out) } - if strings.Contains(out, "local=/cloud.payne.io/") { - t.Errorf("reach:public must NOT produce local=/ directive, got:\n%s", out) - } -} - -// Test: InternalDomain field → local=/ entry for LAN-only resolution -func TestGenerate_InternalDomainOnly_HasLocal(t *testing.T) { - g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - globalCfg := &config.State{} - - // Instance with only an internal domain (no external domain) - inst := instanceWith("", "internal.cloud.payne.io", "192.168.8.240") - - out := g.Generate(globalCfg, []config.InstanceConfig{inst}) - - if !strings.Contains(out, "local=/internal.cloud.payne.io/") { - t.Errorf("InternalDomain must produce local=/ entry, got:\n%s", out) - } - if !strings.Contains(out, "address=/internal.cloud.payne.io/192.168.8.240") { - t.Errorf("InternalDomain must produce address entry, got:\n%s", out) - } - // No external domain → no address for empty domain - if strings.Contains(out, "address=//") { - t.Errorf("no external domain must not produce empty address=// entry, got:\n%s", out) - } -} - -// Test: Mix of instances with and without internal domains -func TestGenerate_MixedServicesAndInstances(t *testing.T) { - g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - globalCfg := &config.State{} - - instances := []config.InstanceConfig{ - instanceWith("cloud.payne.io", "internal.cloud.payne.io", "192.168.8.240"), // k8s instance - instanceWith("central.payne.io", "", "192.168.8.151"), // service (no internal domain) - instanceWith("wild-cloud.payne.io", "", "192.168.8.151"), // service (no internal domain) - } - - out := g.Generate(globalCfg, instances) - - // k8s instance should have both local= and address= for internal domain - if !strings.Contains(out, "local=/internal.cloud.payne.io/") { - t.Errorf("expected local=/ for k8s internal domain, got:\n%s", out) - } - // Services should have address= entries if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") { t.Errorf("expected address for central, got:\n%s", out) } - // No broken empty entries + if !strings.Contains(out, "address=/wild-cloud.payne.io/192.168.8.151") { + t.Errorf("expected address for wild-cloud, got:\n%s", out) + } if strings.Contains(out, "local=//") { t.Errorf("unexpected empty local=// in output:\n%s", out) } } -// Test: empty instances list produces base config with no address= entries -func TestGenerate_EmptyInstances(t *testing.T) { +// Test: empty entries list produces base config with no address= entries +func TestGenerate_EmptyEntries(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.State{} - out := g.Generate(globalCfg, []config.InstanceConfig{}) + out := g.Generate(globalCfg, []DNSEntry{}) - // Should have base config skeleton if !strings.Contains(out, "bind-interfaces") { t.Errorf("expected bind-interfaces in config, got:\n%s", out) } if !strings.Contains(out, "server=1.1.1.1") { t.Errorf("expected upstream DNS server, got:\n%s", out) } - // Should not have any address= entries if strings.Contains(out, "address=/") { - t.Errorf("expected no address= entries with empty instances, got:\n%s", out) + t.Errorf("expected no address= entries with empty entries, got:\n%s", out) } - // Should not have any local=/ entries if strings.Contains(out, "local=/") { - t.Errorf("expected no local=/ entries with empty instances, got:\n%s", out) + t.Errorf("expected no local=/ entries with empty entries, got:\n%s", out) } } -// Test: multiple instances each get their own entries -func TestGenerate_MultipleInstances(t *testing.T) { +// Test: multiple entries each get their own DNS records +func TestGenerate_MultipleEntries(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") globalCfg := &config.State{} - instances := []config.InstanceConfig{ - instanceWith("cloud1.example.com", "internal1.example.com", "10.0.0.1"), - instanceWith("cloud2.example.com", "internal2.example.com", "10.0.0.2"), - instanceWith("cloud3.example.com", "internal3.example.com", "10.0.0.3"), + entries := []DNSEntry{ + entry("cloud1.example.com", "10.0.0.1"), + entry("cloud2.example.com", "10.0.0.2"), + entry("cloud3.example.com", "10.0.0.3"), } - out := g.Generate(globalCfg, instances) + out := g.Generate(globalCfg, entries) - for i, inst := range instances { - ip := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"}[i] - if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", inst.Cloud.Domain, ip)) { - t.Errorf("expected address entry for %s, got:\n%s", inst.Cloud.Domain, out) + for _, e := range entries { + if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", e.Domain, e.IP)) { + t.Errorf("expected address entry for %s, got:\n%s", e.Domain, out) } - if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", inst.Cloud.InternalDomain, ip)) { - t.Errorf("expected address entry for %s, got:\n%s", inst.Cloud.InternalDomain, out) - } - if !strings.Contains(out, fmt.Sprintf("local=/%s/", inst.Cloud.InternalDomain)) { - t.Errorf("expected local=/ entry for %s, got:\n%s", inst.Cloud.InternalDomain, out) + if !strings.Contains(out, fmt.Sprintf("local=/%s/", e.Domain)) { + t.Errorf("expected local=/ entry for %s, got:\n%s", e.Domain, out) } } } @@ -490,8 +317,7 @@ func TestGenerate_MultipleInstances(t *testing.T) { func TestGenerate_NilConfig(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - // Should not panic with nil config - out := g.Generate(nil, []config.InstanceConfig{}) + out := g.Generate(nil, []DNSEntry{}) if !strings.Contains(out, "bind-interfaces") { t.Errorf("expected valid config with nil cfg, got:\n%s", out) @@ -507,32 +333,31 @@ func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) { cfg := &config.State{} cfg.Cloud.Dnsmasq.IP = "192.168.8.100" - out := g.Generate(cfg, []config.InstanceConfig{}) + out := g.Generate(cfg, []DNSEntry{}) if !strings.Contains(out, "listen-address=192.168.8.100") { t.Errorf("expected configured dnsmasq IP in listen-address, got:\n%s", out) } } -// Test: verify no address=// or local=// entries appear in any test output +// Test: verify no address=// or local=// entries appear func TestGenerate_NoLeakedEmptyEntries(t *testing.T) { g := NewConfigGenerator("/tmp/test-dnsmasq.conf") cfg := &config.State{} cases := []struct { - name string - instances []config.InstanceConfig + name string + entries []DNSEntry }{ {"empty", nil}, - {"domain_only", []config.InstanceConfig{instanceWith("example.com", "", "10.0.0.1")}}, - {"internal_only", []config.InstanceConfig{instanceWith("", "internal.example.com", "10.0.0.1")}}, - {"both", []config.InstanceConfig{instanceWith("example.com", "internal.example.com", "10.0.0.1")}}, - {"no_lb_ip", []config.InstanceConfig{instanceWith("example.com", "internal.example.com", "")}}, + {"domain_only", []DNSEntry{entry("example.com", "10.0.0.1")}}, + {"no_ip", []DNSEntry{entry("example.com", "")}}, + {"no_domain", []DNSEntry{entry("", "10.0.0.1")}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - out := g.Generate(cfg, tc.instances) + out := g.Generate(cfg, tc.entries) if strings.Contains(out, "address=//") { t.Errorf("leaked empty address=// in output:\n%s", out) } @@ -542,23 +367,3 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) { }) } } - -// Test: GenerateInstanceConfig with all fields empty doesn't produce broken entries -func TestGenerateInstanceConfig_EmptyFields(t *testing.T) { - g := NewConfigGenerator("/tmp/test-dnsmasq.conf") - var inst config.InstanceConfig - // All fields empty: no domain, no internal domain, no LB IP - - out := g.GenerateInstanceConfig(inst) - - if strings.Contains(out, "address=//") { - t.Errorf("empty fields produced broken address=// entry:\n%s", out) - } - if strings.Contains(out, "local=//") { - t.Errorf("empty fields produced broken local=// entry:\n%s", out) - } - // Should still contain the header comment - if !strings.Contains(out, "DNS configuration for instance") { - t.Errorf("expected header comment in output, got:\n%s", out) - } -} diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 5583bb5..5bce01f 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -5,73 +5,6 @@ export interface Status { timestamp: string; } -// ======================================== -// Instance Config Types (Wild Cloud instance level) -// Endpoint: /api/v1/instances/{name}/config -// File: {dataDir}/instances/{name}/config.yaml -// ======================================== - -export interface NodeConfig { - role: string; - interface: string; - disk: string; - currentIp: string; -} - -export interface InstanceConfig { - operator?: { - email?: string; - }; - cloud?: { - baseDomain?: string; - domain?: string; - internalDomain?: string; - dhcpRange?: string; - nfs?: { - host?: string; - mediaPath?: string; - storageCapacity?: string; - }; - dockerRegistryHost?: string; - }; - cluster?: { - name?: string; - loadBalancerIp?: string; - ipAddressPool?: string; - hostnamePrefix?: string; - certManager?: { - cloudflare?: { - domain?: string; - }; - }; - externalDns?: { - ownerId?: string; - }; - internalDns?: { - externalResolver?: string; - }; - dockerRegistry?: { - storage?: string; - }; - nodes?: { - talos?: { - version?: string; - schematicId?: string; - }; - control?: { - vip?: string; - }; - active?: Record; - }; - }; - apps?: Record>; // Each app has its own dynamic config -} - -export interface InstanceConfigResponse { - config?: InstanceConfig; - message?: string; -} - export interface Message { message: string; type: 'info' | 'success' | 'error'; @@ -100,7 +33,7 @@ export interface DnsmasqStatus { pid: number; ip: string; config_file: string; - instances_configured: number; + domains_configured: number; last_restart: string; }