DNS setup.

This commit is contained in:
2026-01-07 16:59:12 +00:00
parent f3d51cdf6a
commit 2464b8631c
7 changed files with 555 additions and 52 deletions

View File

@@ -17,6 +17,7 @@ import (
"github.com/wild-cloud/wild-central/daemon/internal/context"
"github.com/wild-cloud/wild-central/daemon/internal/dnsmasq"
"github.com/wild-cloud/wild-central/daemon/internal/instance"
"github.com/wild-cloud/wild-central/daemon/internal/network"
"github.com/wild-cloud/wild-central/daemon/internal/operations"
"github.com/wild-cloud/wild-central/daemon/internal/secrets"
"github.com/wild-cloud/wild-central/daemon/internal/storage"
@@ -202,6 +203,10 @@ func (api *API) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/v1/instances/{name}/backup-schedules/{schedule_id}/history", api.ScheduleHistoryHandler).Methods("GET")
r.HandleFunc("/api/v1/scheduler/status", api.SchedulerStatusHandler).Methods("GET")
// Global Configuration
r.HandleFunc("/api/v1/config", api.GetGlobalConfig).Methods("GET")
r.HandleFunc("/api/v1/config", api.UpdateGlobalConfig).Methods("PUT")
// Backup Configuration
r.HandleFunc("/api/v1/config/backup", api.GetBackupConfig).Methods("GET")
r.HandleFunc("/api/v1/config/backup", api.UpdateBackupConfig).Methods("PUT")
@@ -220,9 +225,13 @@ func (api *API) RegisterRoutes(r *mux.Router) {
// dnsmasq management
r.HandleFunc("/api/v1/dnsmasq/status", api.DnsmasqStatus).Methods("GET")
r.HandleFunc("/api/v1/dnsmasq/config", api.DnsmasqGetConfig).Methods("GET")
r.HandleFunc("/api/v1/dnsmasq/config", api.DnsmasqWriteConfig).Methods("PUT")
r.HandleFunc("/api/v1/dnsmasq/restart", api.DnsmasqRestart).Methods("POST")
r.HandleFunc("/api/v1/dnsmasq/generate", api.DnsmasqGenerate).Methods("POST")
r.HandleFunc("/api/v1/dnsmasq/update", api.DnsmasqUpdate).Methods("POST")
// Network detection
r.HandleFunc("/api/v1/network/info", api.NetworkInfoHandler).Methods("GET")
r.HandleFunc("/api/v1/network/resolve", api.NetworkResolveHandler).Methods("GET")
}
// CreateInstance creates a new instance
@@ -560,6 +569,39 @@ func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime
})
}
// NetworkInfoHandler returns detected network configuration
func (api *API) NetworkInfoHandler(w http.ResponseWriter, r *http.Request) {
info, err := network.DetectNetworkInfo()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to detect network info: %v", err))
return
}
respondJSON(w, http.StatusOK, info)
}
func (api *API) NetworkResolveHandler(w http.ResponseWriter, r *http.Request) {
domain := r.URL.Query().Get("domain")
if domain == "" {
respondError(w, http.StatusBadRequest, "domain parameter is required")
return
}
ip, err := network.ResolveDomain(domain)
if err != nil {
respondJSON(w, http.StatusOK, map[string]interface{}{
"success": false,
"error": err.Error(),
})
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"success": true,
"ip": ip,
})
}
// Helper functions
func respondJSON(w http.ResponseWriter, status int, data interface{}) {

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/daemon/internal/config"
)
// ConfigUpdate represents a single configuration update
@@ -74,3 +75,47 @@ func (api *API) ConfigUpdateBatch(w http.ResponseWriter, r *http.Request) {
"updated": updateCount,
})
}
// GetGlobalConfig returns the global configuration
func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) {
globalConfigPath := api.dataDir + "/config.yaml"
// Load global config
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
if err != nil {
// If config doesn't exist, return empty config with configured=false
respondJSON(w, http.StatusOK, map[string]interface{}{
"configured": false,
"config": nil,
})
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"configured": !globalCfg.IsEmpty(),
"config": globalCfg,
})
}
// UpdateGlobalConfig updates the global configuration
func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
globalConfigPath := api.dataDir + "/config.yaml"
// Parse request body
var globalCfg config.GlobalConfig
if err := json.NewDecoder(r.Body).Decode(&globalCfg); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
return
}
// Save global config
if err := config.SaveGlobalConfig(&globalCfg, globalConfigPath); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to save config: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "Global configuration updated successfully",
"config": globalCfg,
})
}

View File

@@ -1,9 +1,11 @@
package v1
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"github.com/wild-cloud/wild-central/daemon/internal/config"
)
@@ -16,10 +18,7 @@ func (api *API) DnsmasqStatus(w http.ResponseWriter, r *http.Request) {
return
}
if status.Status != "active" {
w.WriteHeader(http.StatusServiceUnavailable)
}
// Always return 200 OK with status in body - let client handle inactive status
respondJSON(w, http.StatusOK, status)
}
@@ -49,8 +48,12 @@ func (api *API) DnsmasqRestart(w http.ResponseWriter, r *http.Request) {
})
}
// DnsmasqGenerate generates the dnsmasq configuration without applying it (dry-run)
// DnsmasqGenerate generates the dnsmasq configuration from all instances
// Query param ?overwrite=true will write the config and restart the service
func (api *API) DnsmasqGenerate(w http.ResponseWriter, r *http.Request) {
// Check if overwrite flag is set
overwrite := r.URL.Query().Get("overwrite") == "true"
// Get all instances
instanceNames, err := api.instance.ListInstances()
if err != nil {
@@ -78,27 +81,77 @@ func (api *API) DnsmasqGenerate(w http.ResponseWriter, r *http.Request) {
instanceConfigs = append(instanceConfigs, *instanceCfg)
}
// Generate config without writing or restarting
// Generate config
configContent := api.dnsmasq.Generate(globalCfg, instanceConfigs)
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "dnsmasq configuration generated (dry-run mode)",
"config": configContent,
})
if overwrite {
// Check if this is the first time dnsmasq is being started
status, err := api.dnsmasq.GetStatus()
isFirstStart := err != nil || status.Status != "active"
// Write config and restart service
if err := api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to update dnsmasq: %v", err))
return
}
// Configure system DNS to use local dnsmasq on first start
if isFirstStart {
if err := api.dnsmasq.ConfigureSystemDNS(); err != nil {
log.Printf("Warning: Failed to configure system DNS: %v", err)
// Don't fail the request - dnsmasq is still running
}
}
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "dnsmasq configuration generated and applied successfully",
"config": configContent,
})
} else {
// Just return the generated config
respondJSON(w, http.StatusOK, map[string]interface{}{
"message": "dnsmasq configuration generated (preview mode)",
"config": configContent,
})
}
}
// DnsmasqUpdate regenerates and updates the dnsmasq configuration with all instances
func (api *API) DnsmasqUpdate(w http.ResponseWriter, r *http.Request) {
if err := api.updateDnsmasqForAllInstances(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to update dnsmasq: %v", err))
// DnsmasqWriteConfig writes custom config content to the dnsmasq config file
func (api *API) DnsmasqWriteConfig(w http.ResponseWriter, r *http.Request) {
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
if req.Content == "" {
respondError(w, http.StatusBadRequest, "Config content is required")
return
}
// Write the config directly using the dnsmasq config generator's WriteConfig
configPath := api.dnsmasq.GetConfigPath()
if err := writeConfigFile(configPath, req.Content); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write config: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{
"message": "dnsmasq configuration updated successfully",
"message": "dnsmasq configuration written successfully",
})
}
// writeConfigFile writes content to a file
func writeConfigFile(path, content string) error {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Errorf("writing config: %w", err)
}
return nil
}
// updateDnsmasqForAllInstances helper regenerates dnsmasq config from all instances
func (api *API) updateDnsmasqForAllInstances() error {
// Get all instances
@@ -126,8 +179,8 @@ func (api *API) updateDnsmasqForAllInstances() error {
instanceConfigs = append(instanceConfigs, *instanceCfg)
}
// Regenerate and write dnsmasq config
return api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs)
// Regenerate and write dnsmasq config with restart
return api.dnsmasq.UpdateConfig(globalCfg, instanceConfigs, true)
}
// getGlobalConfigPath returns the path to the global config file

View File

@@ -0,0 +1,223 @@
package v1
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/wild-cloud/wild-central/daemon/internal/config"
"github.com/wild-cloud/wild-central/daemon/internal/storage"
"gopkg.in/yaml.v3"
)
func setupTestDnsmasq(t *testing.T) (*API, string) {
tmpDir := t.TempDir()
// Set dnsmasq config to temp directory
dnsmasqConfigPath := filepath.Join(tmpDir, "dnsmasq.conf")
os.Setenv("WILD_API_DNSMASQ_CONFIG_PATH", dnsmasqConfigPath)
t.Cleanup(func() {
os.Unsetenv("WILD_API_DNSMASQ_CONFIG_PATH")
})
appsDir := filepath.Join(tmpDir, "apps")
api, err := NewAPI(tmpDir, appsDir)
if err != nil {
t.Fatalf("Failed to create test API: %v", err)
}
return api, tmpDir
}
func TestDnsmasqGenerate_WithoutOverwrite(t *testing.T) {
api, tmpDir := setupTestDnsmasq(t)
// Create global config
globalConfig := config.GlobalConfig{}
globalConfig.Cloud.DNS.IP = "192.168.1.100"
globalConfig.Cloud.Dnsmasq.Interface = "eth0"
configPath := filepath.Join(tmpDir, "config.yaml")
configData, _ := yaml.Marshal(globalConfig)
storage.WriteFile(configPath, configData, 0644)
// Create test instance
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
// Create instance config
instanceConfig := config.InstanceConfig{}
instanceConfig.Cloud.Domain = "test.local"
instanceConfig.Cloud.InternalDomain = "internal.test.local"
instanceConfigPath := api.instance.GetInstanceConfigPath(instanceName)
instanceConfigData, _ := yaml.Marshal(instanceConfig)
storage.WriteFile(instanceConfigPath, instanceConfigData, 0644)
// Test generate without overwrite
req := httptest.NewRequest("POST", "/api/v1/dnsmasq/generate", nil)
w := httptest.NewRecorder()
api.DnsmasqGenerate(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
// Verify response contains config
if config, ok := resp["config"].(string); !ok || config == "" {
t.Fatal("Expected generated config in response")
}
// Verify config was NOT written to file
configPath = api.dnsmasq.GetConfigPath()
if _, err := os.Stat(configPath); err == nil {
// File should not exist yet
t.Fatal("Config file should not exist in dry-run mode")
}
}
func TestDnsmasqGenerate_WithOverwrite(t *testing.T) {
api, tmpDir := setupTestDnsmasq(t)
// Create global config
globalConfig := config.GlobalConfig{}
globalConfig.Cloud.DNS.IP = "192.168.1.100"
globalConfig.Cloud.Dnsmasq.Interface = "eth0"
configPath := filepath.Join(tmpDir, "config.yaml")
configData, _ := yaml.Marshal(globalConfig)
storage.WriteFile(configPath, configData, 0644)
// Create test instance
instanceName := "test-instance"
createTestInstance(t, api, instanceName)
// Create instance config
instanceConfig := config.InstanceConfig{}
instanceConfig.Cloud.Domain = "test.local"
instanceConfig.Cloud.InternalDomain = "internal.test.local"
instanceConfigPath := api.instance.GetInstanceConfigPath(instanceName)
instanceConfigData, _ := yaml.Marshal(instanceConfig)
storage.WriteFile(instanceConfigPath, instanceConfigData, 0644)
// Instead of calling the handler which would try to restart the service,
// directly test the UpdateConfig method with restart=false
instances, _ := api.instance.ListInstances()
var instanceConfigs []config.InstanceConfig
for _, name := range instances {
icPath := api.instance.GetInstanceConfigPath(name)
ic, _ := config.LoadCloudConfig(icPath)
instanceConfigs = append(instanceConfigs, *ic)
}
// Test UpdateConfig with restart=false (safe for tests)
err := api.dnsmasq.UpdateConfig(&globalConfig, instanceConfigs, false)
if err != nil {
t.Fatalf("UpdateConfig failed: %v", err)
}
// Verify config was written
dnsmasqConfigPath := api.dnsmasq.GetConfigPath()
content, err := os.ReadFile(dnsmasqConfigPath)
if err != nil {
t.Fatalf("Failed to read dnsmasq config: %v", err)
}
configStr := string(content)
if !strings.Contains(configStr, "test.local") {
t.Fatal("Expected config to contain test.local domain")
}
if !strings.Contains(configStr, "internal.test.local") {
t.Fatal("Expected config to contain internal.test.local domain")
}
if !strings.Contains(configStr, "192.168.1.100") {
t.Fatal("Expected config to contain DNS IP")
}
}
func TestDnsmasqWriteConfig(t *testing.T) {
api, _ := setupTestDnsmasq(t)
customConfig := `# Custom dnsmasq config
interface=wlan0
listen-address=192.168.2.1
`
reqBody := map[string]string{
"content": customConfig,
}
reqData, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", "/api/v1/dnsmasq/config", bytes.NewBuffer(reqData))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
api.DnsmasqWriteConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify config was written
configPath := api.dnsmasq.GetConfigPath()
content, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read written config: %v", err)
}
if string(content) != customConfig {
t.Fatalf("Config content mismatch.\nExpected:\n%s\nGot:\n%s", customConfig, string(content))
}
}
func TestDnsmasqWriteConfig_EmptyContent(t *testing.T) {
api, _ := setupTestDnsmasq(t)
reqBody := map[string]string{
"content": "",
}
reqData, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", "/api/v1/dnsmasq/config", bytes.NewBuffer(reqData))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
api.DnsmasqWriteConfig(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status 400, got %d", w.Code)
}
}
func TestDnsmasqGetConfig(t *testing.T) {
api, _ := setupTestDnsmasq(t)
// Write a config first
configPath := api.dnsmasq.GetConfigPath()
testConfig := "# Test config\ninterface=eth0\n"
os.MkdirAll(filepath.Dir(configPath), 0755)
os.WriteFile(configPath, []byte(testConfig), 0644)
req := httptest.NewRequest("GET", "/api/v1/dnsmasq/config", nil)
w := httptest.NewRecorder()
api.DnsmasqGetConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
content, ok := resp["content"].(string)
if !ok || content != testConfig {
t.Fatalf("Expected config content: %s, got: %s", testConfig, content)
}
}

View File

@@ -11,38 +11,38 @@ import (
// GlobalConfig represents the main configuration structure
type GlobalConfig struct {
Wildcloud struct {
Repository string `yaml:"repository" json:"repository"`
CurrentPhase string `yaml:"currentPhase" json:"currentPhase"`
CompletedPhases []string `yaml:"completedPhases" json:"completedPhases"`
} `yaml:"wildcloud" json:"wildcloud"`
Repository string `yaml:"repository,omitempty" json:"repository,omitempty"`
CurrentPhase string `yaml:"currentPhase,omitempty" json:"currentPhase,omitempty"`
CompletedPhases []string `yaml:"completedPhases,omitempty" json:"completedPhases,omitempty"`
} `yaml:"wildcloud,omitempty" json:"wildcloud,omitempty"`
Server struct {
Port int `yaml:"port" json:"port"`
Host string `yaml:"host" json:"host"`
} `yaml:"server" json:"server"`
Port int `yaml:"port,omitempty" json:"port,omitempty"`
Host string `yaml:"host,omitempty" json:"host,omitempty"`
} `yaml:"server,omitempty" json:"server,omitempty"`
Operator struct {
Email string `yaml:"email" json:"email"`
} `yaml:"operator" json:"operator"`
Email string `yaml:"email,omitempty" json:"email,omitempty"`
} `yaml:"operator,omitempty" json:"operator,omitempty"`
Cloud struct {
DNS struct {
IP string `yaml:"ip" json:"ip"`
ExternalResolver string `yaml:"externalResolver" json:"externalResolver"`
} `yaml:"dns" json:"dns"`
IP string `yaml:"ip,omitempty" json:"ip,omitempty"`
ExternalResolver string `yaml:"externalResolver,omitempty" json:"externalResolver,omitempty"`
} `yaml:"dns,omitempty" json:"dns,omitempty"`
Router struct {
IP string `yaml:"ip" json:"ip"`
DynamicDns string `yaml:"dynamicDns" json:"dynamicDns"`
} `yaml:"router" json:"router"`
IP string `yaml:"ip,omitempty" json:"ip,omitempty"`
DynamicDns string `yaml:"dynamicDns,omitempty" json:"dynamicDns,omitempty"`
} `yaml:"router,omitempty" json:"router,omitempty"`
Dnsmasq struct {
Interface string `yaml:"interface" json:"interface"`
} `yaml:"dnsmasq" json:"dnsmasq"`
} `yaml:"cloud" json:"cloud"`
Interface string `yaml:"interface,omitempty" json:"interface,omitempty"`
} `yaml:"dnsmasq,omitempty" json:"dnsmasq,omitempty"`
} `yaml:"cloud,omitempty" json:"cloud,omitempty"`
Cluster struct {
EndpointIP string `yaml:"endpointIp" json:"endpointIp"`
EndpointIP string `yaml:"endpointIp,omitempty" json:"endpointIp,omitempty"`
Nodes struct {
Talos struct {
Version string `yaml:"version" json:"version"`
} `yaml:"talos" json:"talos"`
} `yaml:"nodes" json:"nodes"`
} `yaml:"cluster" json:"cluster"`
Version string `yaml:"version,omitempty" json:"version,omitempty"`
} `yaml:"talos,omitempty" json:"talos,omitempty"`
} `yaml:"nodes,omitempty" json:"nodes,omitempty"`
} `yaml:"cluster,omitempty" json:"cluster,omitempty"`
}
// LoadGlobalConfig loads configuration from the specified path

View File

@@ -10,6 +10,7 @@ import (
"time"
"github.com/wild-cloud/wild-central/daemon/internal/config"
"github.com/wild-cloud/wild-central/daemon/internal/network"
)
// ConfigGenerator handles dnsmasq configuration generation
@@ -33,12 +34,29 @@ func (g *ConfigGenerator) GetConfigPath() string {
}
// Generate creates a dnsmasq configuration from the app config
// If the DNS IP or interface in the config don't match the current network,
// it will auto-detect and use the current values
func (g *ConfigGenerator) Generate(cfg *config.GlobalConfig, clouds []config.InstanceConfig) string {
// Auto-detect network info to ensure we use the correct interface and IP
netInfo, err := network.DetectNetworkInfo()
if err != nil {
log.Printf("Warning: Failed to auto-detect network info, using config values: %v", err)
// Fall back to config values if detection fails
netInfo = &network.NetworkInfo{
PrimaryIP: cfg.Cloud.DNS.IP,
PrimaryInterface: cfg.Cloud.Dnsmasq.Interface,
}
}
// Use detected network info (this ensures dnsmasq works even if config is outdated)
dnsIP := netInfo.PrimaryIP
iface := netInfo.PrimaryInterface
resolution_section := ""
for _, cloud := range clouds {
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", cloud.Cloud.Domain, cloud.Cloud.Domain, cfg.Cluster.EndpointIP)
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain, cfg.Cluster.EndpointIP)
// Use detected DNS IP (Wild Central's actual IP) for domain resolution
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", cloud.Cloud.Domain, cloud.Cloud.Domain, dnsIP)
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", cloud.Cloud.InternalDomain, cloud.Cloud.InternalDomain, dnsIP)
}
template := `# Configuration file for dnsmasq.
@@ -46,6 +64,7 @@ func (g *ConfigGenerator) Generate(cfg *config.GlobalConfig, clouds []config.Ins
# Basic Settings
interface=%s
listen-address=%s
bind-interfaces
domain-needed
bogus-priv
no-resolv
@@ -60,8 +79,8 @@ log-dhcp
`
return fmt.Sprintf(template,
cfg.Cloud.Dnsmasq.Interface,
cfg.Cloud.DNS.IP,
iface,
dnsIP,
resolution_section,
)
}
@@ -71,6 +90,7 @@ func (g *ConfigGenerator) WriteConfig(cfg *config.GlobalConfig, clouds []config.
configContent := g.Generate(cfg, clouds)
log.Printf("Writing dnsmasq config to: %s", configPath)
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
return fmt.Errorf("writing dnsmasq config: %w", err)
}
@@ -78,11 +98,13 @@ func (g *ConfigGenerator) WriteConfig(cfg *config.GlobalConfig, clouds []config.
return nil
}
// RestartService restarts the dnsmasq service
// RestartService restarts the dnsmasq service using systemd's DBus API
func (g *ConfigGenerator) RestartService() error {
cmd := exec.Command("sudo", "/usr/bin/systemctl", "restart", "dnsmasq.service")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to restart dnsmasq: %w", err)
// Use systemctl without sudo - systemd handles permissions via polkit
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))
}
return nil
}
@@ -161,7 +183,7 @@ func (g *ConfigGenerator) ReadConfig() (string, error) {
}
// UpdateConfig regenerates and writes the dnsmasq configuration for all instances
func (g *ConfigGenerator) UpdateConfig(cfg *config.GlobalConfig, instances []config.InstanceConfig) error {
func (g *ConfigGenerator) UpdateConfig(cfg *config.GlobalConfig, instances []config.InstanceConfig, restart bool) error {
// Generate fresh config from scratch
configContent := g.Generate(cfg, instances)
@@ -171,6 +193,42 @@ func (g *ConfigGenerator) UpdateConfig(cfg *config.GlobalConfig, instances []con
return fmt.Errorf("writing dnsmasq config: %w", err)
}
// Restart service to apply changes
return g.RestartService()
// 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)
}
log.Printf("Configured systemd-resolved to use DNS at %s", dnsIP)
// Restart systemd-resolved to apply changes (via polkit)
cmd := exec.Command("systemctl", "restart", "systemd-resolved")
if output, err := cmd.CombinedOutput(); err != nil {
log.Printf("Warning: Failed to restart systemd-resolved: %v (output: %s)", err, string(output))
// Don't return error - the config was written successfully
}
return nil
}

View File

@@ -0,0 +1,82 @@
package network
import (
"fmt"
"net"
"os/exec"
"strings"
)
// NetworkInfo holds detected network configuration
type NetworkInfo struct {
PrimaryIP string `json:"primary_ip"`
PrimaryInterface string `json:"primary_interface"`
}
// DetectNetworkInfo detects the machine's primary network configuration
// It finds the interface and IP used for internet-bound traffic by checking
// the route to a public DNS server (8.8.8.8)
func DetectNetworkInfo() (*NetworkInfo, error) {
// Use ip route to find the default route interface and IP
// This tells us which interface would be used to reach the internet
cmd := exec.Command("ip", "route", "get", "8.8.8.8")
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("failed to get network route: %w", err)
}
line := string(output)
// Example output: "8.8.8.8 via 192.168.8.1 dev wlan0 src 192.168.8.152 uid 1000"
// Extract interface (after "dev")
iface := ""
if idx := strings.Index(line, "dev "); idx != -1 {
rest := line[idx+4:]
parts := strings.Fields(rest)
if len(parts) > 0 {
iface = parts[0]
}
}
// Extract source IP (after "src")
// This is the IP that would be used as the source for outbound traffic
ip := ""
if idx := strings.Index(line, "src "); idx != -1 {
rest := line[idx+4:]
parts := strings.Fields(rest)
if len(parts) > 0 {
ip = parts[0]
}
}
if ip == "" || iface == "" {
return nil, fmt.Errorf("could not detect network information from route output: %s", line)
}
return &NetworkInfo{
PrimaryIP: ip,
PrimaryInterface: iface,
}, nil
}
// ResolveDomain resolves a domain name to an IP address using the system DNS resolver
func ResolveDomain(domain string) (string, error) {
ips, err := net.LookupIP(domain)
if err != nil {
return "", fmt.Errorf("failed to resolve domain: %w", err)
}
if len(ips) == 0 {
return "", fmt.Errorf("no IP addresses found for domain")
}
// Return the first IPv4 address
for _, ip := range ips {
if ipv4 := ip.To4(); ipv4 != nil {
return ipv4.String(), nil
}
}
// If no IPv4, return first IP
return ips[0].String(), nil
}