package v1 import ( "bufio" "encoding/json" "fmt" "log/slog" "net/http" "os" "strings" "time" "github.com/gorilla/mux" "gopkg.in/yaml.v3" "github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/storage" ) // DnsmasqStatus returns the status of the dnsmasq service func (api *API) DnsmasqStatus(w http.ResponseWriter, r *http.Request) { status, err := api.dnsmasq.GetStatus() if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get dnsmasq status: %v", err)) return } // Always return 200 OK with status in body - let client handle inactive status respondJSON(w, http.StatusOK, status) } // DnsmasqGetConfig returns the current dnsmasq configuration func (api *API) DnsmasqGetConfig(w http.ResponseWriter, r *http.Request) { configContent, err := api.dnsmasq.ReadConfig() if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read dnsmasq config: %v", err)) return } respondJSON(w, http.StatusOK, map[string]any{ "config_file": api.dnsmasq.GetConfigPath(), "content": configContent, }) } // DnsmasqRestart restarts the dnsmasq service func (api *API) DnsmasqRestart(w http.ResponseWriter, r *http.Request) { if err := api.dnsmasq.RestartService(); err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to restart dnsmasq: %v", err)) return } // Broadcast SSE event api.broadcastDnsmasqEvent("dnsmasq:restart", "dnsmasq service restarted") respondJSON(w, http.StatusOK, map[string]string{ "message": "dnsmasq service restarted successfully", }) } // DnsmasqGenerate generates the dnsmasq configuration from global config. // Query param ?overwrite=true will write the config and restart the service. // Instance-specific DNS records are managed via service registration (not here). func (api *API) DnsmasqGenerate(w http.ResponseWriter, r *http.Request) { overwrite := r.URL.Query().Get("overwrite") == "true" // Load global config globalConfigPath := api.getGlobalConfigPath() globalCfg, err := config.LoadGlobalConfig(globalConfigPath) if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to load global config: %v", err)) return } // Generate dnsmasq config configContent := api.dnsmasq.GenerateMainConfig(globalCfg) if overwrite { // Check if this is the first time dnsmasq is being started status, err := api.dnsmasq.GetStatus() isFirstStart := err != nil || status.Status != "active" // Update main dnsmasq configuration slog.Info("updating dnsmasq main configuration") // Write the main config tempFile := api.dnsmasq.GetConfigPath() + ".tmp" if err := os.WriteFile(tempFile, []byte(configContent), 0644); err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write temp config: %v", err)) return } // Validate the config if err := api.dnsmasq.ValidateConfig(tempFile); err != nil { os.Remove(tempFile) respondError(w, http.StatusInternalServerError, fmt.Sprintf("Config validation failed: %v", err)) return } // Install the new config if err := os.Rename(tempFile, api.dnsmasq.GetConfigPath()); err != nil { os.Remove(tempFile) respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to install config: %v", err)) return } // Reload dnsmasq if err := api.dnsmasq.ReloadService(); err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to reload dnsmasq: %v", err)) return } // Configure system DNS to use local dnsmasq on first start if isFirstStart { if err := api.dnsmasq.ConfigureSystemDNS(); err != nil { slog.Error("failed to configure system DNS", "error", err) } } // Broadcast SSE event api.broadcastDnsmasqEvent("dnsmasq:config", "dnsmasq configuration updated and applied") respondJSON(w, http.StatusOK, map[string]any{ "message": "dnsmasq configuration generated and applied successfully", "config": configContent, }) } else { respondJSON(w, http.StatusOK, map[string]any{ "message": "dnsmasq configuration generated (preview mode)", "config": configContent, }) } } // 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 } // Broadcast SSE event api.broadcastDnsmasqEvent("dnsmasq:config", "dnsmasq configuration written") respondJSON(w, http.StatusOK, map[string]string{ "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 } // updateDnsmasqConfig regenerates the main dnsmasq config from global config and restarts. // Instance-specific DNS records are managed via service registration (not here). func (api *API) updateDnsmasqConfig() error { globalConfigPath := api.getGlobalConfigPath() globalCfg, err := config.LoadGlobalConfig(globalConfigPath) if err != nil { return fmt.Errorf("loading global config: %w", err) } // Regenerate and write dnsmasq config with restart (no instance configs) return api.dnsmasq.UpdateConfig(globalCfg, nil, true) } // getGlobalConfigPath returns the path to the global config file func (api *API) getGlobalConfigPath() string { return api.dataDir + "/config.yaml" } // DHCPLease represents an active DHCP lease from the dnsmasq leases file type DHCPLease struct { Expiry time.Time `json:"expiry"` MAC string `json:"mac"` IP string `json:"ip"` Hostname string `json:"hostname"` } // DnsmasqDHCPLeases returns active DHCP leases from /var/lib/misc/dnsmasq.leases func (api *API) DnsmasqDHCPLeases(w http.ResponseWriter, r *http.Request) { const leasesFile = "/var/lib/misc/dnsmasq.leases" f, err := os.Open(leasesFile) if err != nil { if os.IsNotExist(err) { respondJSON(w, http.StatusOK, map[string]any{"leases": []DHCPLease{}}) return } respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read leases file: %v", err)) return } defer f.Close() var leases []DHCPLease scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } // Format: parts := strings.Fields(line) if len(parts) < 4 { continue } var expiry time.Time if ts, err := time.Parse("1504605645", parts[0]); err == nil { expiry = ts } hostname := parts[3] if hostname == "*" { hostname = "" } leases = append(leases, DHCPLease{ Expiry: expiry, MAC: parts[1], IP: parts[2], Hostname: hostname, }) } if err := scanner.Err(); err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse leases file: %v", err)) return } respondJSON(w, http.StatusOK, map[string]any{"leases": leases}) } // DnsmasqDHCPAddStatic adds a static DHCP lease to global config and regenerates dnsmasq func (api *API) DnsmasqDHCPAddStatic(w http.ResponseWriter, r *http.Request) { var req config.DHCPStaticLease if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err)) return } if req.MAC == "" || req.IP == "" { respondError(w, http.StatusBadRequest, "mac and ip are required") return } globalConfigPath := api.getGlobalConfigPath() if err := api.addDHCPStaticLease(globalConfigPath, req); err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add static lease: %v", err)) return } if err := api.updateDnsmasqConfig(); err != nil { slog.Error("dnsmasq regeneration failed after adding static lease", "error", err) } respondJSON(w, http.StatusOK, map[string]string{"message": "Static lease added successfully"}) } // DnsmasqDHCPDeleteStatic removes a static DHCP lease from global config and regenerates dnsmasq func (api *API) DnsmasqDHCPDeleteStatic(w http.ResponseWriter, r *http.Request) { mac := mux.Vars(r)["mac"] if mac == "" { respondError(w, http.StatusBadRequest, "mac address is required") return } globalConfigPath := api.getGlobalConfigPath() if err := api.removeDHCPStaticLease(globalConfigPath, mac); err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove static lease: %v", err)) return } if err := api.updateDnsmasqConfig(); err != nil { slog.Error("dnsmasq regeneration failed after removing static lease", "error", err) } respondJSON(w, http.StatusOK, map[string]string{"message": "Static lease removed successfully"}) } // addDHCPStaticLease adds or replaces a static lease entry in the global config file func (api *API) addDHCPStaticLease(configPath string, lease config.DHCPStaticLease) error { data, err := os.ReadFile(configPath) if err != nil { return fmt.Errorf("reading config: %w", err) } var cfg map[string]any if err := yaml.Unmarshal(data, &cfg); err != nil { return fmt.Errorf("parsing config: %w", err) } if cfg == nil { cfg = make(map[string]any) } // Navigate/create the path cloud.dnsmasq.dhcp.staticLeases cloud := getOrCreateMap(cfg, "cloud") dnsmasqMap := getOrCreateMap(cloud, "dnsmasq") dhcpMap := getOrCreateMap(dnsmasqMap, "dhcp") leases, _ := dhcpMap["staticLeases"].([]any) // Replace existing entry with same MAC, or append newEntry := map[string]any{"mac": lease.MAC, "ip": lease.IP} if lease.Hostname != "" { newEntry["hostname"] = lease.Hostname } replaced := false for i, l := range leases { if m, ok := l.(map[string]any); ok { if m["mac"] == lease.MAC { leases[i] = newEntry replaced = true break } } } if !replaced { leases = append(leases, newEntry) } dhcpMap["staticLeases"] = leases out, err := yaml.Marshal(cfg) if err != nil { return fmt.Errorf("marshaling config: %w", err) } lockPath := configPath + ".lock" return storage.WithLock(lockPath, func() error { return storage.WriteFile(configPath, out, 0644) }) } // removeDHCPStaticLease removes a static lease entry by MAC from the global config file func (api *API) removeDHCPStaticLease(configPath string, mac string) error { data, err := os.ReadFile(configPath) if err != nil { return fmt.Errorf("reading config: %w", err) } var cfg map[string]any if err := yaml.Unmarshal(data, &cfg); err != nil { return fmt.Errorf("parsing config: %w", err) } cloud, _ := cfg["cloud"].(map[string]any) if cloud == nil { return nil } dnsmasqMap, _ := cloud["dnsmasq"].(map[string]any) if dnsmasqMap == nil { return nil } dhcpMap, _ := dnsmasqMap["dhcp"].(map[string]any) if dhcpMap == nil { return nil } leases, _ := dhcpMap["staticLeases"].([]any) var filtered []any for _, l := range leases { if m, ok := l.(map[string]any); ok { if m["mac"] != mac { filtered = append(filtered, l) } } } dhcpMap["staticLeases"] = filtered out, err := yaml.Marshal(cfg) if err != nil { return fmt.Errorf("marshaling config: %w", err) } lockPath := configPath + ".lock" return storage.WithLock(lockPath, func() error { return storage.WriteFile(configPath, out, 0644) }) } // getOrCreateMap returns the map at key in parent, creating it if absent func getOrCreateMap(parent map[string]any, key string) map[string]any { if v, ok := parent[key].(map[string]any); ok { return v } m := make(map[string]any) parent[key] = m return m }