Files
wild-central/internal/api/v1/helpers.go
Paul Payne f9d87ff975 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.
2026-07-11 20:42:33 +00:00

385 lines
12 KiB
Go

package v1
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"
"github.com/wild-cloud/wild-central/internal/sse"
)
// EnsureCentralDomain registers (or updates) Central's own domain as a domain
// so it participates in DNS, HAProxy routing, and TLS cert tracking like any
// other domain. Called on startup and when global config changes.
func (api *API) EnsureCentralDomain() {
centralDomain := api.getCentralDomain()
port := api.getRunningPort()
backend := fmt.Sprintf("127.0.0.1:%d", port)
// Check if the current registration already matches — skip if so.
if centralDomain != "" {
if existing, _ := api.domains.Get(centralDomain); existing != nil &&
existing.Source == "central" &&
existing.Backend.Address == backend {
return
}
}
// Clean up stale Central registrations (e.g. domain changed).
doms, _ := api.domains.List()
for _, dom := range doms {
if dom.Source == "central" && dom.DomainName != centralDomain {
_ = api.domains.Deregister(dom.DomainName)
}
}
if centralDomain == "" {
return
}
_ = api.domains.Register(domains.Domain{
DomainName: centralDomain,
Source: "central",
Backend: domains.Backend{
Address: backend,
Type: domains.BackendHTTP,
},
TLS: domains.TLSTerminate,
})
}
// Reconcile runs networking reconciliation immediately. Called on startup
// and automatically whenever a domain is registered/updated/deregistered.
func (api *API) Reconcile() {
api.reconcileNetworking()
}
// reconcileNetworking reads all registered domains and regenerates
// dnsmasq DNS entries and HAProxy routes to match.
func (api *API) reconcileNetworking() {
doms, err := api.domains.List()
if err != nil {
slog.Error("reconcile: failed to list domains", "error", err)
return
}
globalCfg, err := config.LoadState(api.statePath())
if err != nil {
slog.Warn("reconcile: failed to load state, using empty", "error", err)
globalCfg = &config.State{}
}
// Build HAProxy routes from registered domains
var instanceRoutes []haproxy.InstanceRoute
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
switch dom.EffectiveBackendType() {
case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.EffectiveBackendAddress()),
Subdomains: dom.Subdomains,
})
case domains.BackendDNSOnly:
// dns-only: no gateway routing, just DNS resolution
case domains.BackendHTTP:
var routeBackends []haproxy.HTTPRouteBackend
for _, r := range dom.EffectiveRoutes() {
routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{
Paths: r.Paths,
Backend: r.Backend.Address,
HealthPath: r.Backend.Health,
Headers: r.Headers,
IPAllow: r.IPAllow,
})
}
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
Name: dom.DomainName,
Domain: dom.DomainName,
Subdomains: dom.Subdomains,
Routes: routeBackends,
})
}
}
// 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) {
activeHTTPRoutes = append(activeHTTPRoutes, route)
} else {
slog.Warn("reconcile: skipping L7 route (no cert)", "domain", route.Domain)
}
}
// Generate and write HAProxy config
genOpts := haproxy.GenerateOpts{
HTTPRoutes: activeHTTPRoutes,
CertsDir: certsDir,
}
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, genOpts)
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
// Validation failed — identify and exclude broken domains, then retry
brokenDomains := haproxy.FindBrokenServices(haproxyCfg, haproxy.ParseValidationErrors(err.Error()))
if len(brokenDomains) > 0 {
slog.Error("reconcile: excluding broken domains and retrying",
"broken", brokenDomains, "error", err)
exclude := map[string]bool{}
for _, d := range brokenDomains {
exclude[d] = true
}
var filteredInstances []haproxy.InstanceRoute
for _, r := range instanceRoutes {
if !exclude[r.Domain] {
filteredInstances = append(filteredInstances, r)
}
}
var filteredHTTP []haproxy.HTTPRoute
for _, r := range activeHTTPRoutes {
if !exclude[r.Domain] {
filteredHTTP = append(filteredHTTP, r)
}
}
genOpts.HTTPRoutes = filteredHTTP
haproxyCfg = api.haproxy.GenerateWithOpts(filteredInstances, nil, genOpts)
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
slog.Error("reconcile: retry also failed", "error", err)
} else {
if err := api.haproxy.ReloadService(); err != nil {
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
} else {
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated (excluded broken domains)")
}
}
} else {
slog.Error("reconcile: failed to write HAProxy config (no broken domains identified)", "error", err)
}
} else {
if err := api.haproxy.ReloadService(); err != nil {
slog.Warn("reconcile: failed to reload HAProxy", "error", err)
} else {
api.broadcastHaproxyEvent("haproxy:config", "HAProxy config regenerated from registered domains")
}
}
// 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
if centralIP == "" {
centralIP, _ = network.GetWildCentralIP()
}
if centralIP == "" {
centralIP = "127.0.0.1"
}
var dnsEntries []dnsmasq.DNSEntry
for _, dom := range doms {
if dom.DomainName == "" {
continue
}
dnsIP := centralIP
bt := dom.EffectiveBackendType()
if bt == domains.BackendTCPPassthrough || bt == domains.BackendDNSOnly {
dnsIP = extractHost(dom.EffectiveBackendAddress())
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
})
}
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")
}
// Ensure TLS certs exist for HTTP domains (where Central terminates TLS).
// For domains under the gateway domain, a wildcard cert covers them all.
// For others, provision individual certs.
if len(httpRoutes) > 0 {
api.ensureTLSCerts(globalCfg, doms)
}
// Nudge DDNS to pick up any domain changes (public toggle, new domains).
// The runner reads fresh params on each check via its callback.
api.ddns.Trigger()
slog.Info("reconcile: networking updated",
"domains", len(doms),
"l4Routes", len(instanceRoutes),
"l7Routes", len(httpRoutes),
)
}
// ensureTLSCerts logs which certificates are missing for registered domains.
// Does NOT auto-provision — cert provisioning is user-initiated via the
// Certificates page. This just logs warnings so the operator knows what's needed.
func (api *API) ensureTLSCerts(globalCfg *config.State, doms []domains.Domain) {
gatewayDomain := ""
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1]
}
for _, dom := range doms {
if dom.TLS != domains.TLSTerminate || dom.DomainName == "" {
continue
}
// Check if covered by wildcard
if gatewayDomain != "" && strings.HasSuffix(dom.DomainName, "."+gatewayDomain) {
wildcardCert := certbot.HAProxyCertPath(gatewayDomain)
if _, err := os.Stat(wildcardCert); err != nil {
slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page",
"domain", dom.DomainName, "needed", "*."+gatewayDomain)
}
continue
}
// Check individual cert
if _, err := os.Stat(certbot.HAProxyCertPath(dom.DomainName)); err != nil {
slog.Warn("reconcile/tls: no cert for domain — provision via Certificates page",
"domain", dom.DomainName)
}
}
}
// 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 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 {
wildcardBase := parts[1]
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-- {
if addr[i] == ':' {
return addr[:i]
}
}
return addr
}
// broadcastDnsmasqEvent broadcasts SSE events for dnsmasq status changes
func (api *API) broadcastDnsmasqEvent(eventType string, message string) {
if api.sseManager == nil {
return
}
// Get current dnsmasq status
status, err := api.dnsmasq.GetStatus()
if err != nil {
status = nil
}
event := &sse.Event{
ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
"status": status,
},
}
api.sseManager.Broadcast(event)
}
// broadcastHaproxyEvent broadcasts SSE events for HAProxy status changes
func (api *API) broadcastHaproxyEvent(eventType string, message string) {
if api.sseManager == nil {
return
}
event := &sse.Event{
ID: fmt.Sprintf("haproxy-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
},
}
api.sseManager.Broadcast(event)
}
// broadcastCentralStatusEvent broadcasts SSE events for central status changes
func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
if api.sseManager == nil {
return
}
uptime := time.Since(startTime)
event := &sse.Event{
ID: fmt.Sprintf("central-%d", time.Now().UnixNano()),
Type: "central:status",
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"status": "running",
"version": api.version,
"uptime": uptime.String(),
"uptimeSeconds": int(uptime.Seconds()),
},
}
api.sseManager.Broadcast(event)
}