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"
"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") {

View File

@@ -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 (<domain>.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 (<domain>.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-- {