Replace InstanceConfig with DNSEntry for dnsmasq config generation

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.
This commit is contained in:
2026-07-11 20:42:33 +00:00
parent 1f3fcf50b4
commit f9d87ff975
10 changed files with 192 additions and 1517 deletions

View File

@@ -8,6 +8,7 @@ import (
"testing" "testing"
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsmasq"
"github.com/wild-cloud/wild-central/internal/domains" "github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy" "github.com/wild-cloud/wild-central/internal/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() doms, err := api.domains.List()
if err != nil { if err != nil {
t.Fatalf("List failed: %v", err) t.Fatalf("List failed: %v", err)
} }
globalCfg := &config.State{} globalCfg := &config.State{}
var instanceConfigs []config.InstanceConfig var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms { for _, dom := range doms {
if dom.DomainName == "" { if dom.DomainName == "" {
@@ -224,19 +225,13 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
dnsIP = extractHost(dom.Backend.Address) dnsIP = extractHost(dom.Backend.Address)
} }
ic := config.InstanceConfig{} dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
ic.Cluster.LoadBalancerIp = dnsIP Domain: dom.DomainName,
IP: dnsIP,
if !dom.Public { })
ic.Cloud.InternalDomain = dom.DomainName
} else {
ic.Cloud.Domain = dom.DomainName
} }
instanceConfigs = append(instanceConfigs, ic) dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs)
// --- TCP passthrough (public) → backend IP --- // --- TCP passthrough (public) → backend IP ---
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") { 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) 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/") { 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)
} }
if !strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") {
// --- public → NO local=/ --- t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
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=/public-app.payne.io/") { if !strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") {
t.Errorf("public domain must NOT have local=/ entry:\n%s", dnsmasqCfg) 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() doms, _ := api.domains.List()
globalCfg := &config.State{} globalCfg := &config.State{}
var instanceConfigs []config.InstanceConfig var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms { for _, dom := range doms {
dnsIP := centralIP dnsIP := centralIP
if dom.Backend.Type == domains.BackendTCPPassthrough { if dom.Backend.Type == domains.BackendTCPPassthrough {
dnsIP = extractHost(dom.Backend.Address) dnsIP = extractHost(dom.Backend.Address)
} }
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
ic := config.InstanceConfig{} Domain: dom.DomainName,
ic.Cluster.LoadBalancerIp = dnsIP IP: dnsIP,
if !dom.Public { })
ic.Cloud.InternalDomain = dom.DomainName
} else {
ic.Cloud.Domain = dom.DomainName
}
instanceConfigs = append(instanceConfigs, ic)
} }
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs) dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// TCP passthrough → DNS points to backend (k8s LB), NOT central // TCP passthrough → DNS points to backend (k8s LB), NOT central
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") { 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 // Build DNS config — dns-only should point to its backend IP
globalCfg := &config.State{} globalCfg := &config.State{}
var instanceConfigs []config.InstanceConfig var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms { for _, dom := range doms {
dnsIP := centralIP dnsIP := centralIP
@@ -448,18 +436,13 @@ func TestReconciliation_DNSOnlyNoGateway(t *testing.T) {
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly { if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
dnsIP = extractHost(dom.Backend.Address) dnsIP = extractHost(dom.Backend.Address)
} }
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
ic := config.InstanceConfig{} Domain: dom.DomainName,
ic.Cluster.LoadBalancerIp = dnsIP IP: dnsIP,
if !dom.Public { })
ic.Cloud.InternalDomain = dom.DomainName
} else {
ic.Cloud.Domain = dom.DomainName
}
instanceConfigs = append(instanceConfigs, ic)
} }
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, instanceConfigs) dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// dns-only domain should resolve to its backend IP (192.168.8.222) // 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") { if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") {

View File

@@ -4,11 +4,13 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
"github.com/wild-cloud/wild-central/internal/certbot" "github.com/wild-cloud/wild-central/internal/certbot"
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/dnsmasq"
"github.com/wild-cloud/wild-central/internal/domains" "github.com/wild-cloud/wild-central/internal/domains"
"github.com/wild-cloud/wild-central/internal/haproxy" "github.com/wild-cloud/wild-central/internal/haproxy"
"github.com/wild-cloud/wild-central/internal/network" "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/" 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 var activeHTTPRoutes []haproxy.HTTPRoute
for _, route := range httpRoutes { for _, route := range httpRoutes {
if hasCertForDomain(certsDir, route.Domain) { if hasCertForDomain(certsDir, route.Domain) {
@@ -176,10 +192,10 @@ func (api *API) reconcileNetworking() {
} }
} }
// Generate dnsmasq DNS entries from registered domains. // Build dnsmasq DNS entries from registered domains.
// DNS target IP depends on domain type: // DNS target IP depends on backend type:
// tcp-passthrough (k8s): DNS → k8s LB IP (LAN traffic goes direct to k8s) // tcp-passthrough/dns-only → backend IP (LAN traffic goes direct)
// http/static: DNS → Central IP (HAProxy terminates TLS) // http → Central IP (HAProxy terminates TLS)
// Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected // Use the configured dnsmasq IP (eth0) as Central's IP, not auto-detected
// (auto-detect can pick wlan0 if that's the default route). // (auto-detect can pick wlan0 if that's the default route).
centralIP := globalCfg.Cloud.Dnsmasq.IP centralIP := globalCfg.Cloud.Dnsmasq.IP
@@ -190,34 +206,25 @@ func (api *API) reconcileNetworking() {
centralIP = "127.0.0.1" centralIP = "127.0.0.1"
} }
var instanceConfigs []config.InstanceConfig var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms { for _, dom := range doms {
if dom.DomainName == "" { if dom.DomainName == "" {
continue continue
} }
// Choose the DNS target IP based on domain type
dnsIP := centralIP dnsIP := centralIP
bt := dom.EffectiveBackendType() bt := dom.EffectiveBackendType()
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly { if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
// tcp-passthrough/dns-only: DNS resolves to the backend IP directly
dnsIP = extractHost(dom.EffectiveBackendAddress()) dnsIP = extractHost(dom.EffectiveBackendAddress())
} }
ic := config.InstanceConfig{} dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
ic.Cluster.LoadBalancerIp = dnsIP Domain: dom.DomainName,
IP: 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) if err := api.dnsmasq.UpdateConfig(globalCfg, dnsEntries, true); err != nil {
}
if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil {
slog.Error("reconcile: failed to update dnsmasq", "error", err) slog.Error("reconcile: failed to update dnsmasq", "error", err)
} else { } else {
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered domains") 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 // hasCertForDomain checks if a valid (non-empty) cert exists for a domain —
// cert (<domain>.pem) or a wildcard cert that covers it. // either an individual cert (<domain>.pem) or a wildcard cert that covers it.
func hasCertForDomain(certsDir, domain string) bool { func hasCertForDomain(_, domain string) bool {
// Check individual cert // Check individual cert
if _, err := os.Stat(certbot.HAProxyCertPath(domain)); err == nil { if isValidCertFile(certbot.HAProxyCertPath(domain)) {
return true return true
} }
// Check wildcard certs — a *.example.com cert covers foo.example.com // Check wildcard certs — a *.example.com cert covers foo.example.com
parts := strings.SplitN(domain, ".", 2) parts := strings.SplitN(domain, ".", 2)
if len(parts) == 2 { if len(parts) == 2 {
// Wildcard cert might be stored as the base domain PEM
wildcardBase := parts[1] wildcardBase := parts[1]
if _, err := os.Stat(certbot.HAProxyCertPath(wildcardBase)); err == nil { if isValidCertFile(certbot.HAProxyCertPath(wildcardBase)) {
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") {
return true return true
} }
} }
return false 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 // extractHost gets the host part from a host:port string
func extractHost(addr string) string { func extractHost(addr string) string {
for i := len(addr) - 1; i >= 0; i-- { for i := len(addr) - 1; i >= 0; i-- {

View File

@@ -3,7 +3,6 @@ package config
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -158,168 +157,3 @@ func (c *State) IsEmpty() bool {
return c.Cloud.Central.Domain == "" && c.Operator.Email == "" 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
}

View File

@@ -5,8 +5,6 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
"gopkg.in/yaml.v3"
) )
// Test: LoadState loads valid state // 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 // Test: Round-trip save and load preserves data
func TestState_RoundTrip(t *testing.T) { func TestState_RoundTrip(t *testing.T) {
tempDir := t.TempDir() 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"])
}
}

View File

@@ -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 // GetConfigValue retrieves a value from a config file
func (m *Manager) GetConfigValue(configPath, key string) (string, error) { func (m *Manager) GetConfigValue(configPath, key string) (string, error) {
if !storage.FileExists(configPath) { if !storage.FileExists(configPath) {
@@ -186,17 +161,3 @@ func (m *Manager) CopyConfig(srcPath, dstPath string) error {
return storage.WriteFile(dstPath, content, 0644) 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)
}

View File

@@ -1,7 +1,6 @@
package config package config
import ( import (
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "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 // Test: GetConfigValue retrieves values correctly
func TestGetConfigValue(t *testing.T) { func TestGetConfigValue(t *testing.T) {
tests := []struct { 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)
}
}
}

View File

@@ -13,6 +13,17 @@ import (
"github.com/wild-cloud/wild-central/internal/network" "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 // ConfigGenerator handles dnsmasq configuration generation
type ConfigGenerator struct { type ConfigGenerator struct {
configPath string configPath string
@@ -33,9 +44,9 @@ func (g *ConfigGenerator) GetConfigPath() string {
return g.configPath return g.configPath
} }
// Generate creates a dnsmasq configuration from the app config // Generate creates a dnsmasq configuration from registered domain entries.
// It auto-detects the Wild Central server's IP address // It auto-detects the Wild Central server's IP address for the listen directive.
func (g *ConfigGenerator) Generate(cfg *config.State, clouds []config.InstanceConfig) string { func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string {
// Use configured dnsmasq IP (bound to eth0), falling back to auto-detect. // 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 // Auto-detect can pick wlan0 if that's the default route, which is wrong
// when dnsmasq is only listening on eth0. // when dnsmasq is only listening on eth0.
@@ -53,30 +64,16 @@ func (g *ConfigGenerator) Generate(cfg *config.State, clouds []config.InstanceCo
} }
resolution_section := "" resolution_section := ""
for _, cloud := range clouds { for _, entry := range entries {
// Point cloud domains to the cluster load balancer IP if entry.Domain == "" || entry.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/<load-balancer-ip>\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain)
}
if cloud.Cloud.Domain != "" {
resolution_section += fmt.Sprintf("# address=/%s/<load-balancer-ip>\n", cloud.Cloud.Domain)
}
continue continue
} }
// Internal domain (.internal.cloud.example.tld) - local only, no external DNS // local=/ makes dnsmasq authoritative for the domain so AAAA queries
if cloud.Cloud.InternalDomain != "" { // return empty instead of leaking to upstream DNS. Without this,
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain, loadBalancerIP) // 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.
// External/primary domain - resolve to backend IP resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", entry.Domain, entry.Domain, entry.IP)
if cloud.Cloud.Domain != "" {
resolution_section += fmt.Sprintf("address=/%s/%s\n", cloud.Cloud.Domain, loadBalancerIP)
}
} }
template := `# Configuration file for dnsmasq. template := `# Configuration file for dnsmasq.
@@ -104,8 +101,8 @@ log-dhcp
} }
// WriteConfig writes the dnsmasq configuration to the specified path // WriteConfig writes the dnsmasq configuration to the specified path
func (g *ConfigGenerator) WriteConfig(cfg *config.State, clouds []config.InstanceConfig, configPath string) error { func (g *ConfigGenerator) WriteConfig(cfg *config.State, entries []DNSEntry, configPath string) error {
configContent := g.Generate(cfg, clouds) configContent := g.Generate(cfg, entries)
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath) slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath)
@@ -134,7 +131,7 @@ type ServiceStatus struct {
PID int `json:"pid"` PID int `json:"pid"`
IP string `json:"ip"` IP string `json:"ip"`
ConfigFile string `json:"config_file"` ConfigFile string `json:"config_file"`
InstancesConfigured int `json:"instances_configured"` DomainsConfigured int `json:"domains_configured"`
LastRestart time.Time `json:"last_restart"` 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 { if data, err := os.ReadFile(g.configPath); err == nil {
// Count both "local=/" and "# local=/" occurrences status.DomainsConfigured = strings.Count(string(data), "\nlocal=/")
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
} }
return status, nil return status, nil
@@ -211,10 +204,10 @@ func (g *ConfigGenerator) ReadConfig() (string, error) {
return string(data), nil return string(data), nil
} }
// UpdateConfig regenerates and writes the dnsmasq configuration for all instances // UpdateConfig regenerates and writes the dnsmasq configuration and optionally restarts.
func (g *ConfigGenerator) UpdateConfig(cfg *config.State, instances []config.InstanceConfig, restart bool) error { func (g *ConfigGenerator) UpdateConfig(cfg *config.State, entries []DNSEntry, restart bool) error {
// Generate fresh config from scratch // Generate fresh config from scratch
configContent := g.Generate(cfg, instances) configContent := g.Generate(cfg, entries)
// Write config // Write config
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", g.configPath) slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", g.configPath)

View File

@@ -3,21 +3,15 @@ package dnsmasq
import ( import (
"fmt" "fmt"
"log/slog" "log/slog"
"os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/network" "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 // 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 { func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
// Get the Wild Central IP address // Get the Wild Central IP address
dnsIP, err := network.GetWildCentralIP() dnsIP, err := network.GetWildCentralIP()
@@ -29,8 +23,8 @@ func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string {
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, `# Wild Cloud DNS Configuration (Main) fmt.Fprintf(&sb, `# Wild Cloud DNS Configuration (Main)
# This file contains global settings. Instance-specific DNS entries are in: # This file contains global settings. Domain-specific DNS entries are
# /etc/dnsmasq.d/wild-cloud-instances/*.conf # generated by reconciliation into the monolithic config.
# Basic Settings # Basic Settings
listen-address=%s listen-address=%s
@@ -39,9 +33,6 @@ domain-needed
bogus-priv bogus-priv
no-resolv no-resolv
# Include per-instance DNS configurations
conf-dir=%s,*.conf
# Upstream DNS servers # Upstream DNS servers
server=1.1.1.1 server=1.1.1.1
server=8.8.8.8 server=8.8.8.8
@@ -49,7 +40,7 @@ server=8.8.8.8
# Logging # Logging
log-queries log-queries
log-dhcp log-dhcp
`, dnsIP, instanceConfigDir) `, dnsIP)
// DHCP section (only when explicitly enabled) // DHCP section (only when explicitly enabled)
if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled { if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled {
@@ -82,57 +73,6 @@ log-dhcp
return sb.String() 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/<load-balancer-ip>\n\n", instance.Cloud.InternalDomain)
}
if instance.Cloud.Domain != "" {
fmt.Fprintf(&sb, "# address=/%s/<load-balancer-ip>\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 // ValidateConfig tests a dnsmasq configuration file for syntax errors
func (g *ConfigGenerator) ValidateConfig(configPath string) error { func (g *ConfigGenerator) ValidateConfig(configPath string) error {
// Use dnsmasq --test to validate the configuration // Use dnsmasq --test to validate the configuration
@@ -150,104 +90,6 @@ func (g *ConfigGenerator) ValidateConfig(configPath string) error {
return nil 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 // ReloadService sends a SIGHUP to dnsmasq to reload configuration
// This is lighter weight than a full restart // This is lighter weight than a full restart
func (g *ConfigGenerator) ReloadService() error { func (g *ConfigGenerator) ReloadService() error {
@@ -262,81 +104,3 @@ func (g *ConfigGenerator) ReloadService() error {
slog.Info("dnsmasq service reloaded", "component", "dnsmasq") slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
return nil 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
}

View File

@@ -8,141 +8,42 @@ import (
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
) )
func instanceWith(domain, internalDomain, lbIP string) config.InstanceConfig { func entry(domain, ip string) DNSEntry {
var inst config.InstanceConfig return DNSEntry{Domain: domain, IP: ip}
inst.Cluster.Name = "test"
inst.Cloud.Domain = domain
inst.Cloud.InternalDomain = internalDomain
inst.Cluster.LoadBalancerIp = lbIP
return inst
} }
// Test: instanceLoadBalancerIP prefers cluster.loadBalancerIp // Test: Generate with entries produces expected DNS entries
func TestInstanceLoadBalancerIP_ClusterField(t *testing.T) { func TestGenerate_WithEntries(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) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10") entries := []DNSEntry{
entry("cloud.example.com", "192.168.1.10"),
out := g.GenerateInstanceConfig(inst) entry("internal.example.com", "192.168.1.10"),
if !strings.Contains(out, "local=/internal.example.com/") {
t.Errorf("expected local=/ directive 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) 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") { 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/<load-balancer-ip>") {
t.Errorf("expected commented address entry for internal domain, got:\n%s", out)
}
if !strings.Contains(out, "# address=/cloud.example.com/<load-balancer-ip>") {
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/") { if !strings.Contains(out, "local=/internal.example.com/") {
t.Errorf("expected local=/ for internal domain, got:\n%s", out) t.Errorf("expected local=/ for internal domain, got:\n%s", out)
} }
if !strings.Contains(out, "address=/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) 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") { if !strings.Contains(out, "server=1.1.1.1") {
t.Errorf("expected upstream DNS server, got:\n%s", out) t.Errorf("expected upstream DNS server, got:\n%s", out)
} }
} }
// Test: Generate with no instances still produces valid config skeleton // Test: Generate with no entries still produces valid config skeleton
func TestGenerate_NoInstances(t *testing.T) { func TestGenerate_NoEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
out := g.Generate(nil, []config.InstanceConfig{}) out := g.Generate(nil, []DNSEntry{})
if !strings.Contains(out, "bind-interfaces") { if !strings.Contains(out, "bind-interfaces") {
t.Errorf("expected bind-interfaces in config, got:\n%s", out) 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 // Test: Generate skips entries with empty IP
func TestGenerate_InstanceWithoutLBIP(t *testing.T) { func TestGenerate_EntryWithoutIP(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") 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") { if strings.Contains(out, "address=/") {
t.Errorf("expected comment about missing LB IP, got:\n%s", out) 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) { func TestGenerateMainConfig(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
out := g.GenerateMainConfig(nil) 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") { if !strings.Contains(out, "bind-interfaces") {
t.Errorf("expected bind-interfaces in main config, got:\n%s", out) 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.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
// No gateway set
out := g.GenerateMainConfig(cfg) out := g.GenerateMainConfig(cfg)
@@ -282,7 +174,6 @@ func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
cfg.Cloud.Dnsmasq.DHCP.Enabled = true cfg.Cloud.Dnsmasq.DHCP.Enabled = true
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100" cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200" cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
// LeaseTime not set
out := g.GenerateMainConfig(cfg) out := g.GenerateMainConfig(cfg)
@@ -307,181 +198,117 @@ func TestNewConfigGenerator_DefaultPath(t *testing.T) {
} }
} }
// Test: Service registration — domain set but no internal domain // Test: Domain-only entry (no internal domain pair)
func TestGenerate_ServiceWithoutInternalDomain(t *testing.T) { func TestGenerate_DomainOnly(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{} globalCfg := &config.State{}
// Service registration: only domain + backend IP, no internal domain out := g.Generate(globalCfg, []DNSEntry{entry("my-api.payne.io", "192.168.8.151")})
inst := instanceWith("my-api.payne.io", "", "192.168.8.151")
out := g.Generate(globalCfg, []config.InstanceConfig{inst})
// Should have address entry for the domain
if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") { 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/") { 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") { // Must NOT have empty entries
t.Errorf("reach:internal must produce 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:public → no local=/ directive (allows upstream DNS forwarding) // Test: All domains get local=/ to prevent AAAA leaking upstream
func TestGenerate_ReachPublic_NoLocal(t *testing.T) { func TestGenerate_AllDomainsGetLocal(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{} globalCfg := &config.State{}
// Simulate reconciliation for a public-reach service: // Both "internal" and "public" domains should get local=/ —
// Domain is set (reach:public), InternalDomain is empty // prevents AAAA queries from leaking to upstream DNS (Happy Eyeballs).
inst := instanceWith("cloud.payne.io", "", "192.168.8.240") 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") { 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") { if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") {
t.Errorf("expected address for central, got:\n%s", out) 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=//") { if strings.Contains(out, "local=//") {
t.Errorf("unexpected empty local=// in output:\n%s", out) t.Errorf("unexpected empty local=// in output:\n%s", out)
} }
} }
// Test: empty instances list produces base config with no address= entries // Test: empty entries list produces base config with no address= entries
func TestGenerate_EmptyInstances(t *testing.T) { func TestGenerate_EmptyEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{} 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") { if !strings.Contains(out, "bind-interfaces") {
t.Errorf("expected bind-interfaces in config, got:\n%s", out) t.Errorf("expected bind-interfaces in config, got:\n%s", out)
} }
if !strings.Contains(out, "server=1.1.1.1") { if !strings.Contains(out, "server=1.1.1.1") {
t.Errorf("expected upstream DNS server, got:\n%s", out) t.Errorf("expected upstream DNS server, got:\n%s", out)
} }
// Should not have any address= entries
if strings.Contains(out, "address=/") { 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=/") { 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 // Test: multiple entries each get their own DNS records
func TestGenerate_MultipleInstances(t *testing.T) { func TestGenerate_MultipleEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{} globalCfg := &config.State{}
instances := []config.InstanceConfig{ entries := []DNSEntry{
instanceWith("cloud1.example.com", "internal1.example.com", "10.0.0.1"), entry("cloud1.example.com", "10.0.0.1"),
instanceWith("cloud2.example.com", "internal2.example.com", "10.0.0.2"), entry("cloud2.example.com", "10.0.0.2"),
instanceWith("cloud3.example.com", "internal3.example.com", "10.0.0.3"), entry("cloud3.example.com", "10.0.0.3"),
} }
out := g.Generate(globalCfg, instances) out := g.Generate(globalCfg, entries)
for i, inst := range instances { for _, e := range entries {
ip := []string{"10.0.0.1", "10.0.0.2", "10.0.0.3"}[i] if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", e.Domain, e.IP)) {
if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", inst.Cloud.Domain, ip)) { t.Errorf("expected address entry for %s, got:\n%s", e.Domain, out)
t.Errorf("expected address entry for %s, got:\n%s", inst.Cloud.Domain, out)
} }
if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", inst.Cloud.InternalDomain, ip)) { if !strings.Contains(out, fmt.Sprintf("local=/%s/", e.Domain)) {
t.Errorf("expected address entry for %s, got:\n%s", inst.Cloud.InternalDomain, out) t.Errorf("expected local=/ entry for %s, got:\n%s", e.Domain, 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)
} }
} }
} }
@@ -490,8 +317,7 @@ func TestGenerate_MultipleInstances(t *testing.T) {
func TestGenerate_NilConfig(t *testing.T) { func TestGenerate_NilConfig(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
// Should not panic with nil config out := g.Generate(nil, []DNSEntry{})
out := g.Generate(nil, []config.InstanceConfig{})
if !strings.Contains(out, "bind-interfaces") { if !strings.Contains(out, "bind-interfaces") {
t.Errorf("expected valid config with nil cfg, got:\n%s", out) 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 := &config.State{}
cfg.Cloud.Dnsmasq.IP = "192.168.8.100" 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") { if !strings.Contains(out, "listen-address=192.168.8.100") {
t.Errorf("expected configured dnsmasq IP in listen-address, got:\n%s", out) 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) { func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf") g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.State{} cfg := &config.State{}
cases := []struct { cases := []struct {
name string name string
instances []config.InstanceConfig entries []DNSEntry
}{ }{
{"empty", nil}, {"empty", nil},
{"domain_only", []config.InstanceConfig{instanceWith("example.com", "", "10.0.0.1")}}, {"domain_only", []DNSEntry{entry("example.com", "10.0.0.1")}},
{"internal_only", []config.InstanceConfig{instanceWith("", "internal.example.com", "10.0.0.1")}}, {"no_ip", []DNSEntry{entry("example.com", "")}},
{"both", []config.InstanceConfig{instanceWith("example.com", "internal.example.com", "10.0.0.1")}}, {"no_domain", []DNSEntry{entry("", "10.0.0.1")}},
{"no_lb_ip", []config.InstanceConfig{instanceWith("example.com", "internal.example.com", "")}},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { 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=//") { if strings.Contains(out, "address=//") {
t.Errorf("leaked empty address=// in output:\n%s", out) 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)
}
}

View File

@@ -5,73 +5,6 @@ export interface Status {
timestamp: string; 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<string, NodeConfig>;
};
};
apps?: Record<string, Record<string, unknown>>; // Each app has its own dynamic config
}
export interface InstanceConfigResponse {
config?: InstanceConfig;
message?: string;
}
export interface Message { export interface Message {
message: string; message: string;
type: 'info' | 'success' | 'error'; type: 'info' | 'success' | 'error';
@@ -100,7 +33,7 @@ export interface DnsmasqStatus {
pid: number; pid: number;
ip: string; ip: string;
config_file: string; config_file: string;
instances_configured: number; domains_configured: number;
last_restart: string; last_restart: string;
} }