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:
404
internal/api/v1/handlers_dnsmasq.go
Normal file
404
internal/api/v1/handlers_dnsmasq.go
Normal file
@@ -0,0 +1,404 @@
|
||||
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: <unix-expiry> <mac> <ip> <hostname> <client-id>
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user