The auto-detect picks the default route interface (wlan0 at 192.168.8.152) but dnsmasq is bound to eth0 (192.168.8.151). Now uses cloud.dnsmasq.ip from config as the Central IP for DNS entries, falling back to auto-detect only if not configured. Fixes DNS resolution failures when dnsmasq config points services to the wrong interface IP. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
308 lines
9.3 KiB
Go
308 lines
9.3 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/haproxy"
|
|
"github.com/wild-cloud/wild-central/internal/network"
|
|
"github.com/wild-cloud/wild-central/internal/services"
|
|
"github.com/wild-cloud/wild-central/internal/sse"
|
|
)
|
|
|
|
// reconcileNetworking reads all registered services and regenerates
|
|
// dnsmasq DNS entries and HAProxy routes to match. Called automatically
|
|
// whenever a service is registered, updated, or deregistered.
|
|
func (api *API) reconcileNetworking() {
|
|
svcs, err := api.services.List()
|
|
if err != nil {
|
|
slog.Error("reconcile: failed to list services", "error", err)
|
|
return
|
|
}
|
|
|
|
globalConfigPath := filepath.Join(api.dataDir, "config.yaml")
|
|
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
|
|
if err != nil {
|
|
slog.Warn("reconcile: failed to load global config, using empty", "error", err)
|
|
globalCfg = &config.GlobalConfig{}
|
|
}
|
|
|
|
// Build HAProxy routes from registered services
|
|
var instanceRoutes []haproxy.InstanceRoute
|
|
var httpRoutes []haproxy.HTTPRoute
|
|
|
|
for _, svc := range svcs {
|
|
if svc.Reach == services.ReachOff {
|
|
continue
|
|
}
|
|
|
|
switch svc.Backend.Type {
|
|
case services.BackendTCPPassthrough:
|
|
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
|
|
Name: svc.Name,
|
|
Domain: svc.Domain,
|
|
BackendIP: extractHost(svc.Backend.Address),
|
|
ExtraDomains: svc.ExtraDomains,
|
|
})
|
|
case services.BackendHTTP, services.BackendStatic:
|
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
|
Name: svc.Name,
|
|
Domain: svc.Domain,
|
|
Backend: svc.Backend.Address,
|
|
HealthPath: svc.Backend.Health,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Determine central domain for HAProxy — only if its cert exists
|
|
centralDomain := ""
|
|
if d := globalCfg.Cloud.Central.Domain; d != "" {
|
|
if _, err := os.Stat(certbot.HAProxyCertPath(d)); err == nil {
|
|
centralDomain = d
|
|
} else {
|
|
slog.Debug("reconcile: skipping central domain in HAProxy (no cert)", "domain", d)
|
|
}
|
|
}
|
|
|
|
// Only include L7 HTTP routes for services that have a cert.
|
|
// HAProxy uses a cert directory — each service needs its own <domain>.pem
|
|
// or be covered by a wildcard cert in the same directory.
|
|
certsDir := "/etc/haproxy/certs/"
|
|
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
|
|
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
|
|
CentralDomain: centralDomain,
|
|
HTTPRoutes: activeHTTPRoutes,
|
|
WildcardCert: certsDir,
|
|
})
|
|
|
|
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
|
slog.Error("reconcile: failed to write HAProxy config", "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 services")
|
|
}
|
|
}
|
|
|
|
// Generate dnsmasq DNS entries from registered services.
|
|
// DNS target IP depends on service type:
|
|
// tcp-passthrough (k8s): DNS → k8s LB IP (LAN traffic goes direct to k8s)
|
|
// http/static: DNS → 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 instanceConfigs []config.InstanceConfig
|
|
for _, svc := range svcs {
|
|
if svc.Reach == services.ReachOff || svc.Domain == "" {
|
|
continue
|
|
}
|
|
|
|
// Choose the DNS target IP based on service type
|
|
dnsIP := centralIP
|
|
if svc.Backend.Type == services.BackendTCPPassthrough {
|
|
// k8s instances: LAN clients connect directly to the k8s LB
|
|
dnsIP = extractHost(svc.Backend.Address)
|
|
}
|
|
|
|
// Primary domain
|
|
ic := config.InstanceConfig{}
|
|
ic.Cloud.Domain = svc.Domain
|
|
ic.Cluster.LoadBalancerIp = dnsIP
|
|
|
|
// Extra domains (e.g., internal.cloud.payne.io)
|
|
for _, extra := range svc.ExtraDomains {
|
|
if strings.HasPrefix(extra, "internal.") {
|
|
// Internal-only domain: local=/ prevents upstream DNS forwarding
|
|
ic.Cloud.InternalDomain = extra
|
|
}
|
|
}
|
|
|
|
instanceConfigs = append(instanceConfigs, ic)
|
|
}
|
|
|
|
if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil {
|
|
slog.Error("reconcile: failed to update dnsmasq", "error", err)
|
|
} else {
|
|
api.broadcastDnsmasqEvent("dnsmasq:config", "DNS config regenerated from registered services")
|
|
}
|
|
|
|
// Ensure TLS certs exist for HTTP services (where Central terminates TLS).
|
|
// For services under the gateway domain, a wildcard cert covers them all.
|
|
// For others, provision individual certs.
|
|
if len(httpRoutes) > 0 {
|
|
api.ensureTLSCerts(globalCfg, svcs)
|
|
}
|
|
|
|
slog.Info("reconcile: networking updated",
|
|
"services", len(svcs),
|
|
"l4Routes", len(instanceRoutes),
|
|
"l7Routes", len(httpRoutes),
|
|
)
|
|
}
|
|
|
|
// ensureTLSCerts logs which certificates are missing for registered services.
|
|
// 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.GlobalConfig, svcs []services.Service) {
|
|
gatewayDomain := ""
|
|
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
|
|
gatewayDomain = parts[1]
|
|
}
|
|
|
|
for _, svc := range svcs {
|
|
if svc.TLS != services.TLSTerminate || svc.Domain == "" {
|
|
continue
|
|
}
|
|
|
|
// Check if covered by wildcard
|
|
if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+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",
|
|
"service", svc.Name, "domain", svc.Domain, "needed", "*."+gatewayDomain)
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Check individual cert
|
|
if _, err := os.Stat(certbot.HAProxyCertPath(svc.Domain)); err != nil {
|
|
slog.Warn("reconcile/tls: no cert for service — provision via Certificates page",
|
|
"service", svc.Name, "domain", svc.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 {
|
|
// Check individual cert
|
|
if _, err := os.Stat(certbot.HAProxyCertPath(domain)); err == nil {
|
|
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") {
|
|
// There's at least one cert — HAProxy can start with the dir bind.
|
|
// The specific domain may not have its own cert but HAProxy won't crash.
|
|
// It will serve whichever cert matches best (or the first one as default).
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// 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", // dnsmasq is global/central-level, not instance-specific
|
|
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)
|
|
}
|