feat: Extract Wild Central as standalone Go service
Extract the Central networking functionality from wild-cloud/api into a standalone service. Wild Central manages DNS (dnsmasq), gateway (HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot), security (CrowdSec), and DDNS — all the network appliance concerns. All Cloud-specific code (instances, clusters, nodes, apps, backups, operations, kubectl/talosctl tooling) has been removed. The API struct and route registration contain only Central endpoints. Tests updated to match the new API signature. Builds and all tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
247
internal/dnsmasq/config.go
Normal file
247
internal/dnsmasq/config.go
Normal file
@@ -0,0 +1,247 @@
|
||||
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 {
|
||||
// Get the Wild Central IP address
|
||||
dnsIP, err := network.GetWildCentralIP()
|
||||
if err != nil {
|
||||
slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "op", "generate", "error", err)
|
||||
// Fall back to empty string if detection fails
|
||||
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
|
||||
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain, loadBalancerIP)
|
||||
|
||||
// External domain (cloud.example.tld) - resolve to load balancer IP without external DNS lookup
|
||||
// This makes LAN traffic go directly to load balancer instead of routing through external DNS first
|
||||
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 restarts the dnsmasq service
|
||||
func (g *ConfigGenerator) RestartService() error {
|
||||
cmd := exec.Command("systemctl", "restart", "dnsmasq.service")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart dnsmasq: %w (output: %s)", err, string(output))
|
||||
}
|
||||
slog.Info("dnsmasq service restarted", "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
|
||||
}
|
||||
338
internal/dnsmasq/config_modular.go
Normal file
338
internal/dnsmasq/config_modular.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package dnsmasq
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
"github.com/wild-cloud/wild-central/internal/network"
|
||||
)
|
||||
|
||||
const (
|
||||
instanceConfigDir = "/etc/dnsmasq.d/wild-cloud-instances"
|
||||
)
|
||||
|
||||
// GenerateMainConfig creates the main dnsmasq configuration with global settings
|
||||
// and a conf-dir directive to include per-instance configs
|
||||
func (g *ConfigGenerator) GenerateMainConfig(cfg *config.GlobalConfig) string {
|
||||
// Get the Wild Central IP address
|
||||
dnsIP, err := network.GetWildCentralIP()
|
||||
if err != nil {
|
||||
slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "error", err)
|
||||
dnsIP = ""
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, `# Wild Cloud DNS Configuration (Main)
|
||||
# This file contains global settings. Instance-specific DNS entries are in:
|
||||
# /etc/dnsmasq.d/wild-cloud-instances/*.conf
|
||||
|
||||
# Basic Settings
|
||||
listen-address=%s
|
||||
bind-interfaces
|
||||
domain-needed
|
||||
bogus-priv
|
||||
no-resolv
|
||||
|
||||
# Include per-instance DNS configurations
|
||||
conf-dir=%s,*.conf
|
||||
|
||||
# Upstream DNS servers
|
||||
server=1.1.1.1
|
||||
server=8.8.8.8
|
||||
|
||||
# Logging
|
||||
log-queries
|
||||
log-dhcp
|
||||
`, dnsIP, instanceConfigDir)
|
||||
|
||||
// DHCP section (only when explicitly enabled)
|
||||
if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled {
|
||||
dhcp := cfg.Cloud.Dnsmasq.DHCP
|
||||
sb.WriteString("\n# DHCP Configuration\n")
|
||||
|
||||
leaseTime := dhcp.LeaseTime
|
||||
if leaseTime == "" {
|
||||
leaseTime = "24h"
|
||||
}
|
||||
fmt.Fprintf(&sb, "dhcp-range=%s,%s,%s\n", dhcp.RangeStart, dhcp.RangeEnd, leaseTime)
|
||||
|
||||
// Gateway: use explicit DHCP gateway, fall back to router IP
|
||||
gateway := dhcp.Gateway
|
||||
if gateway == "" {
|
||||
gateway = cfg.Cloud.Router.IP
|
||||
}
|
||||
if gateway != "" {
|
||||
fmt.Fprintf(&sb, "dhcp-option=3,%s\n", gateway) // default gateway
|
||||
}
|
||||
if dnsIP != "" {
|
||||
fmt.Fprintf(&sb, "dhcp-option=6,%s\n", dnsIP) // DNS server
|
||||
}
|
||||
|
||||
for _, lease := range dhcp.StaticLeases {
|
||||
if lease.Hostname != "" {
|
||||
fmt.Fprintf(&sb, "dhcp-host=%s,%s,%s\n", lease.MAC, lease.IP, lease.Hostname)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "dhcp-host=%s,%s\n", lease.MAC, lease.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// instanceLoadBalancerIP returns the load balancer IP for an instance.
|
||||
// It checks cluster.loadBalancerIp first, then falls back to apps.metallb.loadBalancerIp,
|
||||
// which is where the normal app install flow writes it.
|
||||
func instanceLoadBalancerIP(instance config.InstanceConfig) string {
|
||||
if instance.Cluster.LoadBalancerIp != "" {
|
||||
return instance.Cluster.LoadBalancerIp
|
||||
}
|
||||
if metallb, ok := instance.Apps["metallb"].(map[string]any); ok {
|
||||
if ip, ok := metallb["loadBalancerIp"].(string); ok {
|
||||
return ip
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GenerateInstanceConfig creates a DNS configuration for a single instance
|
||||
func (g *ConfigGenerator) GenerateInstanceConfig(instance config.InstanceConfig) string {
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "# DNS configuration for instance: %s\n", instance.Cluster.Name)
|
||||
sb.WriteString("# Generated by Wild Cloud\n\n")
|
||||
|
||||
loadBalancerIP := instanceLoadBalancerIP(instance)
|
||||
if loadBalancerIP == "" {
|
||||
sb.WriteString("# WARNING: No load balancer IP configured for this instance\n")
|
||||
sb.WriteString("# DNS entries are commented out until load balancer IP is configured\n\n")
|
||||
fmt.Fprintf(&sb, "# local=/%s/\n", instance.Cloud.InternalDomain)
|
||||
fmt.Fprintf(&sb, "# address=/%s/<load-balancer-ip>\n\n", instance.Cloud.InternalDomain)
|
||||
fmt.Fprintf(&sb, "# address=/%s/<load-balancer-ip>\n", instance.Cloud.Domain)
|
||||
} else {
|
||||
// Internal domain (.internal.cloud.example.tld) - local only, no external DNS
|
||||
sb.WriteString("# Internal domain (LAN-only)\n")
|
||||
fmt.Fprintf(&sb, "local=/%s/\n", instance.Cloud.InternalDomain)
|
||||
fmt.Fprintf(&sb, "address=/%s/%s\n\n", instance.Cloud.InternalDomain, loadBalancerIP)
|
||||
|
||||
// External domain (cloud.example.tld) - resolve to load balancer IP
|
||||
sb.WriteString("# Public domain (resolved locally to avoid external DNS)\n")
|
||||
fmt.Fprintf(&sb, "address=/%s/%s\n", instance.Cloud.Domain, loadBalancerIP)
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ValidateConfig tests a dnsmasq configuration file for syntax errors
|
||||
func (g *ConfigGenerator) ValidateConfig(configPath string) error {
|
||||
// Use dnsmasq --test to validate the configuration
|
||||
cmd := exec.Command("dnsmasq", "--test", "-C", configPath)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("config validation failed: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
// Check if output contains "syntax check OK"
|
||||
if !strings.Contains(string(output), "syntax check OK") {
|
||||
return fmt.Errorf("config validation did not report OK: %s", string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteInstanceConfig writes the DNS configuration for a single instance
|
||||
func (g *ConfigGenerator) WriteInstanceConfig(instanceName string, instance config.InstanceConfig) error {
|
||||
instanceFile := filepath.Join(instanceConfigDir, fmt.Sprintf("%s.conf", instanceName))
|
||||
configContent := g.GenerateInstanceConfig(instance)
|
||||
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(instanceConfigDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating instance config directory: %w", err)
|
||||
}
|
||||
|
||||
// Write to temp file first
|
||||
tempFile := instanceFile + ".tmp"
|
||||
if err := os.WriteFile(tempFile, []byte(configContent), 0644); err != nil {
|
||||
return fmt.Errorf("writing temp instance config: %w", err)
|
||||
}
|
||||
|
||||
// Validate the temp config along with main config
|
||||
if err := g.ValidateWithInstance(tempFile); err != nil {
|
||||
os.Remove(tempFile) // Clean up temp file
|
||||
return fmt.Errorf("instance config validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Move temp file to final location (atomic operation)
|
||||
if err := os.Rename(tempFile, instanceFile); err != nil {
|
||||
os.Remove(tempFile) // Clean up temp file
|
||||
return fmt.Errorf("installing instance config: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("wrote instance DNS config", "component", "dnsmasq", "path", instanceFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateWithInstance validates the main config along with a specific instance config
|
||||
func (g *ConfigGenerator) ValidateWithInstance(instanceConfigPath string) error {
|
||||
// Create a temporary test directory
|
||||
tempDir, err := os.MkdirTemp("", "dnsmasq-test-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
// Copy main config to temp
|
||||
mainContent, err := os.ReadFile(g.configPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading main config: %w", err)
|
||||
}
|
||||
|
||||
tempMainConfig := filepath.Join(tempDir, "main.conf")
|
||||
// Modify the conf-dir line to point to our temp instance dir
|
||||
tempInstanceDir := filepath.Join(tempDir, "instances")
|
||||
if err := os.MkdirAll(tempInstanceDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating temp instance dir: %w", err)
|
||||
}
|
||||
|
||||
modifiedContent := strings.ReplaceAll(
|
||||
string(mainContent),
|
||||
fmt.Sprintf("conf-dir=%s,*.conf", instanceConfigDir),
|
||||
fmt.Sprintf("conf-dir=%s,*.conf", tempInstanceDir),
|
||||
)
|
||||
|
||||
if err := os.WriteFile(tempMainConfig, []byte(modifiedContent), 0644); err != nil {
|
||||
return fmt.Errorf("writing temp main config: %w", err)
|
||||
}
|
||||
|
||||
// Copy instance config to temp
|
||||
instanceContent, err := os.ReadFile(instanceConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading instance config: %w", err)
|
||||
}
|
||||
|
||||
tempInstanceConfig := filepath.Join(tempInstanceDir, filepath.Base(instanceConfigPath))
|
||||
if err := os.WriteFile(tempInstanceConfig, instanceContent, 0644); err != nil {
|
||||
return fmt.Errorf("writing temp instance config: %w", err)
|
||||
}
|
||||
|
||||
// Validate the combined configuration
|
||||
return g.ValidateConfig(tempMainConfig)
|
||||
}
|
||||
|
||||
// RemoveInstanceConfig removes the DNS configuration for an instance
|
||||
func (g *ConfigGenerator) RemoveInstanceConfig(instanceName string) error {
|
||||
instanceFile := filepath.Join(instanceConfigDir, fmt.Sprintf("%s.conf", instanceName))
|
||||
|
||||
// Check if file exists
|
||||
if _, err := os.Stat(instanceFile); os.IsNotExist(err) {
|
||||
slog.Info("instance DNS config does not exist", "component", "dnsmasq", "path", instanceFile)
|
||||
return nil // Not an error, already removed
|
||||
}
|
||||
|
||||
// Remove the file
|
||||
if err := os.Remove(instanceFile); err != nil {
|
||||
return fmt.Errorf("removing instance config: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("removed instance DNS config", "component", "dnsmasq", "path", instanceFile)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadService sends a SIGHUP to dnsmasq to reload configuration
|
||||
// This is lighter weight than a full restart
|
||||
func (g *ConfigGenerator) ReloadService() error {
|
||||
// Use systemctl reload which sends SIGHUP
|
||||
cmd := exec.Command("systemctl", "reload", "dnsmasq.service")
|
||||
_, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// If reload fails, try restart as fallback
|
||||
slog.Error("reload failed, attempting restart", "component", "dnsmasq", "error", err)
|
||||
return g.RestartService()
|
||||
}
|
||||
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateToModularConfig migrates from monolithic to modular configuration
|
||||
func (g *ConfigGenerator) UpdateToModularConfig(cfg *config.GlobalConfig, instanceNames []string, instances []config.InstanceConfig) error {
|
||||
slog.Info("migrating to modular configuration", "component", "dnsmasq")
|
||||
|
||||
// Ensure instance directory exists
|
||||
if err := os.MkdirAll(instanceConfigDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating instance config directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate and write instance configs first (but don't reload yet)
|
||||
for i, instance := range instances {
|
||||
instanceName := instanceNames[i]
|
||||
if err := g.WriteInstanceConfig(instanceName, instance); err != nil {
|
||||
slog.Error("failed to write instance config", "component", "dnsmasq", "instance", instanceName, "error", err)
|
||||
// Continue with other instances
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new main config with conf-dir
|
||||
mainConfig := g.GenerateMainConfig(cfg)
|
||||
|
||||
// Write to temp file first
|
||||
tempFile := g.configPath + ".tmp"
|
||||
if err := os.WriteFile(tempFile, []byte(mainConfig), 0644); err != nil {
|
||||
return fmt.Errorf("writing temp main config: %w", err)
|
||||
}
|
||||
|
||||
// Validate the new config
|
||||
if err := g.ValidateConfig(tempFile); err != nil {
|
||||
os.Remove(tempFile)
|
||||
return fmt.Errorf("main config validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Backup current config
|
||||
backupFile := g.configPath + ".pre-modular"
|
||||
if err := os.Rename(g.configPath, backupFile); err != nil {
|
||||
os.Remove(tempFile)
|
||||
return fmt.Errorf("backing up current config: %w", err)
|
||||
}
|
||||
|
||||
// Install new config
|
||||
if err := os.Rename(tempFile, g.configPath); err != nil {
|
||||
// Try to restore backup
|
||||
_ = os.Rename(backupFile, g.configPath)
|
||||
return fmt.Errorf("installing new config: %w", err)
|
||||
}
|
||||
|
||||
// Reload dnsmasq
|
||||
if err := g.ReloadService(); err != nil {
|
||||
// Try to restore backup and reload
|
||||
slog.Error("reload failed, restoring backup", "component", "dnsmasq", "error", err)
|
||||
os.Remove(g.configPath)
|
||||
_ = os.Rename(backupFile, g.configPath)
|
||||
_ = g.ReloadService()
|
||||
return fmt.Errorf("reloading with new config: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("migrated to modular configuration", "component", "dnsmasq")
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateInstanceDNS updates DNS configuration for a single instance
|
||||
// This is called when instance configuration changes (e.g., domain names)
|
||||
func (g *ConfigGenerator) UpdateInstanceDNS(instanceName string, instance config.InstanceConfig) error {
|
||||
// Write the new instance config
|
||||
if err := g.WriteInstanceConfig(instanceName, instance); err != nil {
|
||||
return fmt.Errorf("writing instance DNS config: %w", err)
|
||||
}
|
||||
|
||||
// Reload dnsmasq to pick up changes
|
||||
if err := g.ReloadService(); err != nil {
|
||||
return fmt.Errorf("reloading dnsmasq: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("DNS updated for instance", "component", "dnsmasq", "instance", instanceName)
|
||||
return nil
|
||||
}
|
||||
312
internal/dnsmasq/config_test.go
Normal file
312
internal/dnsmasq/config_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package dnsmasq
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/wild-cloud/wild-central/internal/config"
|
||||
)
|
||||
|
||||
func instanceWith(domain, internalDomain, lbIP string) config.InstanceConfig {
|
||||
var inst config.InstanceConfig
|
||||
inst.Cluster.Name = "test"
|
||||
inst.Cloud.Domain = domain
|
||||
inst.Cloud.InternalDomain = internalDomain
|
||||
inst.Cluster.LoadBalancerIp = lbIP
|
||||
return inst
|
||||
}
|
||||
|
||||
// Test: instanceLoadBalancerIP prefers cluster.loadBalancerIp
|
||||
func TestInstanceLoadBalancerIP_ClusterField(t *testing.T) {
|
||||
inst := instanceWith("cloud.example.com", "internal.example.com", "10.0.0.5")
|
||||
if got := instanceLoadBalancerIP(inst); got != "10.0.0.5" {
|
||||
t.Errorf("got %q, want %q", got, "10.0.0.5")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: instanceLoadBalancerIP falls back to apps.metallb.loadBalancerIp
|
||||
func TestInstanceLoadBalancerIP_MetalLBFallback(t *testing.T) {
|
||||
var inst config.InstanceConfig
|
||||
inst.Apps = map[string]any{
|
||||
"metallb": map[string]any{
|
||||
"loadBalancerIp": "10.0.0.9",
|
||||
},
|
||||
}
|
||||
if got := instanceLoadBalancerIP(inst); got != "10.0.0.9" {
|
||||
t.Errorf("got %q, want %q", got, "10.0.0.9")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: instanceLoadBalancerIP returns empty string when nothing is set
|
||||
func TestInstanceLoadBalancerIP_Empty(t *testing.T) {
|
||||
var inst config.InstanceConfig
|
||||
if got := instanceLoadBalancerIP(inst); got != "" {
|
||||
t.Errorf("got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: cluster.loadBalancerIp takes precedence over metallb
|
||||
func TestInstanceLoadBalancerIP_ClusterTakesPrecedence(t *testing.T) {
|
||||
inst := instanceWith("cloud.example.com", "internal.example.com", "10.0.0.5")
|
||||
inst.Apps = map[string]any{
|
||||
"metallb": map[string]any{
|
||||
"loadBalancerIp": "10.0.0.9",
|
||||
},
|
||||
}
|
||||
if got := instanceLoadBalancerIP(inst); got != "10.0.0.5" {
|
||||
t.Errorf("got %q, want cluster IP %q", got, "10.0.0.5")
|
||||
}
|
||||
}
|
||||
|
||||
// Test: GenerateInstanceConfig with load balancer IP produces active DNS entries
|
||||
func TestGenerateInstanceConfig_WithLBIP(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10")
|
||||
|
||||
out := g.GenerateInstanceConfig(inst)
|
||||
|
||||
if !strings.Contains(out, "local=/internal.example.com/") {
|
||||
t.Errorf("expected local=/ directive for internal domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") {
|
||||
t.Errorf("expected address entry for internal domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") {
|
||||
t.Errorf("expected address entry for external domain, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "WARNING") {
|
||||
t.Errorf("unexpected WARNING in output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: GenerateInstanceConfig without load balancer IP produces commented entries
|
||||
func TestGenerateInstanceConfig_NoLBIP(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
inst := instanceWith("cloud.example.com", "internal.example.com", "")
|
||||
|
||||
out := g.GenerateInstanceConfig(inst)
|
||||
|
||||
if !strings.Contains(out, "WARNING") {
|
||||
t.Errorf("expected WARNING in output when no LB IP, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "# local=/internal.example.com/") {
|
||||
t.Errorf("expected commented local=/ directive, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "# address=/internal.example.com/<load-balancer-ip>") {
|
||||
t.Errorf("expected commented address entry for internal domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "# address=/cloud.example.com/<load-balancer-ip>") {
|
||||
t.Errorf("expected commented address entry for external domain, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "\nlocal=/") {
|
||||
t.Errorf("unexpected active local=/ directive when no LB IP, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: GenerateInstanceConfig includes instance name in header comment
|
||||
func TestGenerateInstanceConfig_IncludesInstanceName(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10")
|
||||
inst.Cluster.Name = "my-instance"
|
||||
|
||||
out := g.GenerateInstanceConfig(inst)
|
||||
|
||||
if !strings.Contains(out, "my-instance") {
|
||||
t.Errorf("expected instance name in config header, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Generate (monolithic) with instances produces expected DNS entries
|
||||
func TestGenerate_WithInstances(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
inst := instanceWith("cloud.example.com", "internal.example.com", "192.168.1.10")
|
||||
|
||||
out := g.Generate(nil, []config.InstanceConfig{inst})
|
||||
|
||||
if !strings.Contains(out, "local=/internal.example.com/") {
|
||||
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") {
|
||||
t.Errorf("expected address entry for internal domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") {
|
||||
t.Errorf("expected address entry for external domain, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "server=1.1.1.1") {
|
||||
t.Errorf("expected upstream DNS server, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Generate with no instances still produces valid config skeleton
|
||||
func TestGenerate_NoInstances(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
|
||||
out := g.Generate(nil, []config.InstanceConfig{})
|
||||
|
||||
if !strings.Contains(out, "bind-interfaces") {
|
||||
t.Errorf("expected bind-interfaces in config, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "server=1.1.1.1") {
|
||||
t.Errorf("expected upstream DNS server, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "server=8.8.8.8") {
|
||||
t.Errorf("expected upstream DNS server 8.8.8.8, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: Generate with instance missing LB IP produces commented entries
|
||||
func TestGenerate_InstanceWithoutLBIP(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
inst := instanceWith("cloud.example.com", "internal.example.com", "")
|
||||
inst.Cluster.Name = "no-lb-instance"
|
||||
|
||||
out := g.Generate(nil, []config.InstanceConfig{inst})
|
||||
|
||||
if !strings.Contains(out, "# No load balancer IP configured for instance no-lb-instance") {
|
||||
t.Errorf("expected comment about missing LB IP, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: GenerateMainConfig produces conf-dir directive
|
||||
func TestGenerateMainConfig(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
|
||||
out := g.GenerateMainConfig(nil)
|
||||
|
||||
if !strings.Contains(out, "conf-dir=") {
|
||||
t.Errorf("expected conf-dir directive in main config, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, instanceConfigDir) {
|
||||
t.Errorf("expected instance config dir %q in conf-dir, got:\n%s", instanceConfigDir, out)
|
||||
}
|
||||
if !strings.Contains(out, "bind-interfaces") {
|
||||
t.Errorf("expected bind-interfaces in main config, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "server=1.1.1.1") {
|
||||
t.Errorf("expected upstream DNS server in main config, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: GenerateMainConfig with DHCP disabled does not include dhcp-range
|
||||
func TestGenerateMainConfig_DHCPDisabled(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.GlobalConfig{}
|
||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = false
|
||||
|
||||
out := g.GenerateMainConfig(cfg)
|
||||
|
||||
if strings.Contains(out, "dhcp-range") {
|
||||
t.Errorf("expected no dhcp-range when DHCP disabled, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: GenerateMainConfig with DHCP enabled includes dhcp-range
|
||||
func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.GlobalConfig{}
|
||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||
cfg.Cloud.Dnsmasq.DHCP.LeaseTime = "12h"
|
||||
cfg.Cloud.Router.IP = "192.168.8.1"
|
||||
|
||||
out := g.GenerateMainConfig(cfg)
|
||||
|
||||
if !strings.Contains(out, "dhcp-range=192.168.8.100,192.168.8.200,12h") {
|
||||
t.Errorf("expected dhcp-range directive, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "dhcp-option=3,192.168.8.1") {
|
||||
t.Errorf("expected gateway dhcp-option=3, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: DHCP gateway falls back to router IP when not set
|
||||
func TestGenerateMainConfig_DHCPGatewayFallback(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.GlobalConfig{}
|
||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||
cfg.Cloud.Router.IP = "192.168.8.1"
|
||||
// Gateway not set — should fall back to router IP
|
||||
|
||||
out := g.GenerateMainConfig(cfg)
|
||||
|
||||
if !strings.Contains(out, "dhcp-option=3,192.168.8.1") {
|
||||
t.Errorf("expected router IP as fallback gateway, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: DHCP explicit gateway takes precedence over router IP
|
||||
func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.GlobalConfig{}
|
||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||
cfg.Cloud.Dnsmasq.DHCP.Gateway = "192.168.8.254"
|
||||
cfg.Cloud.Router.IP = "192.168.8.1"
|
||||
|
||||
out := g.GenerateMainConfig(cfg)
|
||||
|
||||
if !strings.Contains(out, "dhcp-option=3,192.168.8.254") {
|
||||
t.Errorf("expected explicit gateway in dhcp-option=3, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "dhcp-option=3,192.168.8.1") {
|
||||
t.Errorf("router IP should not appear as gateway when explicit gateway set, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: DHCP static leases appear in output
|
||||
func TestGenerateMainConfig_DHCPStaticLeases(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.GlobalConfig{}
|
||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||
cfg.Cloud.Dnsmasq.DHCP.StaticLeases = []config.DHCPStaticLease{
|
||||
{MAC: "aa:bb:cc:dd:ee:01", IP: "192.168.8.10", Hostname: "node1"},
|
||||
{MAC: "aa:bb:cc:dd:ee:02", IP: "192.168.8.11"},
|
||||
}
|
||||
|
||||
out := g.GenerateMainConfig(cfg)
|
||||
|
||||
if !strings.Contains(out, "dhcp-host=aa:bb:cc:dd:ee:01,192.168.8.10,node1") {
|
||||
t.Errorf("expected static lease with hostname, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "dhcp-host=aa:bb:cc:dd:ee:02,192.168.8.11") {
|
||||
t.Errorf("expected static lease without hostname, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: DHCP lease time defaults to 24h when not specified
|
||||
func TestGenerateMainConfig_DHCPLeaseTimeDefault(t *testing.T) {
|
||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||
cfg := &config.GlobalConfig{}
|
||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||
// LeaseTime not set
|
||||
|
||||
out := g.GenerateMainConfig(cfg)
|
||||
|
||||
if !strings.Contains(out, "dhcp-range=192.168.8.100,192.168.8.200,24h") {
|
||||
t.Errorf("expected default lease time of 24h, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// Test: NewConfigGenerator uses provided path
|
||||
func TestNewConfigGenerator_CustomPath(t *testing.T) {
|
||||
g := NewConfigGenerator("/custom/path/dnsmasq.conf")
|
||||
if g.GetConfigPath() != "/custom/path/dnsmasq.conf" {
|
||||
t.Errorf("got %q, want /custom/path/dnsmasq.conf", g.GetConfigPath())
|
||||
}
|
||||
}
|
||||
|
||||
// Test: NewConfigGenerator uses default path when empty
|
||||
func TestNewConfigGenerator_DefaultPath(t *testing.T) {
|
||||
g := NewConfigGenerator("")
|
||||
if g.GetConfigPath() != "/etc/dnsmasq.d/wild-cloud.conf" {
|
||||
t.Errorf("got %q, want /etc/dnsmasq.d/wild-cloud.conf", g.GetConfigPath())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user