Files
wild-central/internal/api/v1/handlers_dnsmasq.go
Paul Payne 428d47f876 Refactor architecture: extract reconciler, add interfaces, reduce complexity, improve test coverage
Architecture:
- Extract reconcileNetworking into internal/reconcile package with 7 consumer-side interfaces
- Add locked modifyState helper to fix state.yaml read-modify-write race condition
- Extract CrowdSec and VPN handler groups with interfaces documenting dependency surface
- Replace raw map[string]any YAML manipulation with typed AddDHCPStaticLease/RemoveDHCPStaticLease
- Extract Cloudflare API functions into cfClient struct, eliminating repeated auth boilerplate

Complexity reduction:
- haproxy.GenerateWithOpts: 50 → 6 (extracted 8 focused helpers)
- reconcile.Reconcile: 42 → 11 (extracted buildRoutes, buildDNSEntries, writeHAProxyConfig)
- AutheliaUpdateConfig: 25 → 10 (extracted enableAuthelia/disableAuthelia)

Test coverage improvements:
- reconcile: 4.5% → 56.8% (stub-based tests for route building, DNS entries, orchestration)
- dnsfilter: 10.7% → 45.6% (Manager.Compile, AddList, ToggleList, custom entries)
- config: 67.3% → 89.8% (DHCP static lease mutation tests)
2026-07-14 04:21:30 +00:00

297 lines
9.1 KiB
Go

package v1
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/internal/config"
)
// 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.
// Domain-specific DNS records are managed via domain registration and reconciliation.
func (api *API) DnsmasqGenerate(w http.ResponseWriter, r *http.Request) {
overwrite := r.URL.Query().Get("overwrite") == "true"
// Load global config
globalConfigPath := api.statePath()
globalCfg, err := config.LoadState(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.
// Domain-specific DNS records are managed via domain registration and reconciliation.
func (api *API) updateDnsmasqConfig() error {
globalConfigPath := api.statePath()
globalCfg, err := config.LoadState(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)
}
// statePath returns the path to the global state file
func (api *API) statePath() string {
return api.dataDir + "/state.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
}
if err := api.modifyState(func(state *config.State) error {
state.AddDHCPStaticLease(req)
return nil
}); 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
}
if err := api.modifyState(func(state *config.State) error {
state.RemoveDHCPStaticLease(mac)
return nil
}); 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"})
}