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>
260 lines
8.2 KiB
Go
260 lines
8.2 KiB
Go
package dnsmasq
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/wild-cloud/wild-central/internal/config"
|
|
"github.com/wild-cloud/wild-central/internal/network"
|
|
)
|
|
|
|
// ConfigGenerator handles dnsmasq configuration generation
|
|
type ConfigGenerator struct {
|
|
configPath string
|
|
}
|
|
|
|
// NewConfigGenerator creates a new dnsmasq config generator
|
|
func NewConfigGenerator(configPath string) *ConfigGenerator {
|
|
if configPath == "" {
|
|
configPath = "/etc/dnsmasq.d/wild-cloud.conf"
|
|
}
|
|
return &ConfigGenerator{
|
|
configPath: configPath,
|
|
}
|
|
}
|
|
|
|
// GetConfigPath returns the dnsmasq config file path
|
|
func (g *ConfigGenerator) GetConfigPath() string {
|
|
return g.configPath
|
|
}
|
|
|
|
// Generate creates a dnsmasq configuration from the app config
|
|
// It auto-detects the Wild Central server's IP address
|
|
func (g *ConfigGenerator) Generate(cfg *config.GlobalConfig, clouds []config.InstanceConfig) string {
|
|
// 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
|
|
// when dnsmasq is only listening on eth0.
|
|
dnsIP := ""
|
|
if cfg != nil {
|
|
dnsIP = cfg.Cloud.Dnsmasq.IP
|
|
}
|
|
if dnsIP == "" {
|
|
var err error
|
|
dnsIP, err = network.GetWildCentralIP()
|
|
if err != nil {
|
|
slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "op", "generate", "error", err)
|
|
dnsIP = ""
|
|
}
|
|
}
|
|
|
|
resolution_section := ""
|
|
for _, cloud := range clouds {
|
|
// Point cloud domains to the cluster load balancer 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)
|
|
resolution_section += fmt.Sprintf("# local=/%s/\n# address=/%s/<load-balancer-ip>\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain)
|
|
resolution_section += fmt.Sprintf("# address=/%s/<load-balancer-ip>\n", cloud.Cloud.Domain)
|
|
continue
|
|
}
|
|
// Internal domain (.internal.cloud.example.tld) - local only, no external DNS
|
|
if cloud.Cloud.InternalDomain != "" {
|
|
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain, loadBalancerIP)
|
|
}
|
|
|
|
// External/primary domain - resolve to backend IP
|
|
if cloud.Cloud.Domain != "" {
|
|
resolution_section += fmt.Sprintf("address=/%s/%s\n", cloud.Cloud.Domain, loadBalancerIP)
|
|
}
|
|
}
|
|
|
|
template := `# Configuration file for dnsmasq.
|
|
|
|
# Basic Settings
|
|
listen-address=%s
|
|
bind-interfaces
|
|
domain-needed
|
|
bogus-priv
|
|
no-resolv
|
|
|
|
# DNS Local Resolution - Central server handles these domains authoritatively
|
|
%s
|
|
server=1.1.1.1
|
|
server=8.8.8.8
|
|
|
|
log-queries
|
|
log-dhcp
|
|
`
|
|
|
|
return fmt.Sprintf(template,
|
|
dnsIP,
|
|
resolution_section,
|
|
)
|
|
}
|
|
|
|
// WriteConfig writes the dnsmasq configuration to the specified path
|
|
func (g *ConfigGenerator) WriteConfig(cfg *config.GlobalConfig, clouds []config.InstanceConfig, configPath string) error {
|
|
configContent := g.Generate(cfg, clouds)
|
|
|
|
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath)
|
|
|
|
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
|
return fmt.Errorf("writing dnsmasq config: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// RestartService reloads the dnsmasq service (SIGHUP — no downtime).
|
|
// Falls back to restart if reload fails.
|
|
func (g *ConfigGenerator) RestartService() error {
|
|
cmd := exec.Command("systemctl", "reload-or-restart", "dnsmasq.service")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to reload dnsmasq: %w (output: %s)", err, string(output))
|
|
}
|
|
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
|
|
return nil
|
|
}
|
|
|
|
// ServiceStatus represents the status of the dnsmasq service
|
|
type ServiceStatus struct {
|
|
Status string `json:"status"`
|
|
PID int `json:"pid"`
|
|
IP string `json:"ip"`
|
|
ConfigFile string `json:"config_file"`
|
|
InstancesConfigured int `json:"instances_configured"`
|
|
LastRestart time.Time `json:"last_restart"`
|
|
}
|
|
|
|
// GetStatus checks the status of the dnsmasq service
|
|
func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
|
|
// Get the Wild Central IP address
|
|
dnsIP, err := network.GetWildCentralIP()
|
|
if err != nil {
|
|
slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "op", "getStatus", "error", err)
|
|
dnsIP = ""
|
|
}
|
|
|
|
status := &ServiceStatus{
|
|
ConfigFile: g.configPath,
|
|
IP: dnsIP,
|
|
}
|
|
|
|
// Check if service is active
|
|
cmd := exec.Command("systemctl", "is-active", "dnsmasq.service")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
status.Status = "inactive"
|
|
return status, nil
|
|
}
|
|
|
|
statusStr := strings.TrimSpace(string(output))
|
|
status.Status = statusStr
|
|
|
|
// Get PID if running
|
|
if statusStr == "active" {
|
|
cmd = exec.Command("systemctl", "show", "dnsmasq.service", "--property=MainPID")
|
|
output, err := cmd.Output()
|
|
if err == nil {
|
|
parts := strings.Split(strings.TrimSpace(string(output)), "=")
|
|
if len(parts) == 2 {
|
|
if pid, err := strconv.Atoi(parts[1]); err == nil {
|
|
status.PID = pid
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get last restart time
|
|
cmd = exec.Command("systemctl", "show", "dnsmasq.service", "--property=ActiveEnterTimestamp")
|
|
output, err = cmd.Output()
|
|
if err == nil {
|
|
parts := strings.Split(strings.TrimSpace(string(output)), "=")
|
|
if len(parts) == 2 {
|
|
// Parse systemd timestamp format
|
|
if t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", parts[1]); err == nil {
|
|
status.LastRestart = t
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Count instances in config (both active and commented)
|
|
if data, err := os.ReadFile(g.configPath); err == nil {
|
|
// Count both "local=/" and "# local=/" occurrences
|
|
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
|
|
}
|
|
|
|
// ReadConfig reads the current dnsmasq configuration
|
|
func (g *ConfigGenerator) ReadConfig() (string, error) {
|
|
data, err := os.ReadFile(g.configPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading dnsmasq config: %w", err)
|
|
}
|
|
return string(data), nil
|
|
}
|
|
|
|
// UpdateConfig regenerates and writes the dnsmasq configuration for all instances
|
|
func (g *ConfigGenerator) UpdateConfig(cfg *config.GlobalConfig, instances []config.InstanceConfig, restart bool) error {
|
|
// Generate fresh config from scratch
|
|
configContent := g.Generate(cfg, instances)
|
|
|
|
// Write config
|
|
slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", g.configPath)
|
|
if err := os.WriteFile(g.configPath, []byte(configContent), 0644); err != nil {
|
|
return fmt.Errorf("writing dnsmasq config: %w", err)
|
|
}
|
|
|
|
// Restart service to apply changes if requested
|
|
if restart {
|
|
return g.RestartService()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server
|
|
// This should only be called on first start of dnsmasq
|
|
func (g *ConfigGenerator) ConfigureSystemDNS() error {
|
|
// Auto-detect network info to get the DNS IP
|
|
netInfo, err := network.DetectNetworkInfo()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to detect network info: %w", err)
|
|
}
|
|
|
|
dnsIP := netInfo.PrimaryIP
|
|
|
|
// Write systemd-resolved configuration to file owned by wildcloud user
|
|
// (created during package installation in postinst)
|
|
resolvedConfPath := "/etc/systemd/resolved.conf.d/wild-cloud.conf"
|
|
resolvedConf := fmt.Sprintf("[Resolve]\nDNS=%s\nDomains=~.\n", dnsIP)
|
|
|
|
if err := os.WriteFile(resolvedConfPath, []byte(resolvedConf), 0644); err != nil {
|
|
return fmt.Errorf("failed to write resolved.conf: %w", err)
|
|
}
|
|
|
|
slog.Info("configured systemd-resolved", "component", "dnsmasq", "dnsIP", dnsIP)
|
|
|
|
// Restart systemd-resolved to apply changes (via polkit)
|
|
cmd := exec.Command("systemctl", "restart", "systemd-resolved")
|
|
if output, err := cmd.CombinedOutput(); err != nil {
|
|
slog.Error("failed to restart systemd-resolved", "component", "dnsmasq", "error", err, "output", string(output))
|
|
// Don't return error - the config was written successfully
|
|
}
|
|
|
|
return nil
|
|
}
|