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:
@@ -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") {
|
||||
|
||||
@@ -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-- {
|
||||
|
||||
Reference in New Issue
Block a user