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
|
||||
}
|
||||
Reference in New Issue
Block a user