- JSON tags: fix snake_case to camelCase in dnsmasq (configFile, domainsConfigured, lastRestart), crowdsec Machine (lastPush, lastHeartbeat), network (primaryIP, primaryInterface). cscli raw parsing structs keep snake_case to match CLI output. - Error wrapping: fix %v to %w in enableAuthelia for proper error chain preservation - Naming: rename dnsmasq.ConfigGenerator to dnsmasq.Manager (matches all other packages), rename ServiceStatus to Status in dnsmasq and haproxy (matches authelia, crowdsec, etc.) - Logging: standardize all slog calls to use "component" key instead of message prefixes. Affects reconcile, dnsfilter, ddns — now consistent with dnsmasq, haproxy, nftables, sse.
352 lines
10 KiB
Go
352 lines
10 KiB
Go
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"
|
|
)
|
|
|
|
// DNSEntry represents a single domain-to-IP DNS mapping for dnsmasq.
|
|
// Exact-match domains use host-record= (SOA/NS queries forwarded upstream,
|
|
// enabling ACME DNS-01 validation). Wildcard domains use address=/ to resolve
|
|
// all subdomains. Happy Eyeballs (AAAA leaking) is handled globally by
|
|
// filter-AAAA in the config template.
|
|
type DNSEntry struct {
|
|
Domain string // FQDN to resolve (e.g. "cloud.payne.io")
|
|
IP string // target IPv4 address
|
|
Wildcard bool // true: address=/ (all subdomains); false: host-record= (exact match)
|
|
}
|
|
|
|
// Manager handles dnsmasq configuration generation
|
|
type Manager struct {
|
|
configPath string
|
|
filterConfPath string // path to addn-hosts file for DNS filtering
|
|
}
|
|
|
|
// NewManager creates a new dnsmasq config generator
|
|
func NewManager(configPath string) *Manager {
|
|
if configPath == "" {
|
|
configPath = "/etc/dnsmasq.d/wild-cloud.conf"
|
|
}
|
|
return &Manager{
|
|
configPath: configPath,
|
|
}
|
|
}
|
|
|
|
// GetConfigPath returns the dnsmasq config file path
|
|
func (g *Manager) GetConfigPath() string {
|
|
return g.configPath
|
|
}
|
|
|
|
// SetFilterConfPath sets the path to an additional hosts file for DNS filtering.
|
|
// When set, the generated config includes an addn-hosts= directive.
|
|
func (g *Manager) SetFilterConfPath(path string) {
|
|
g.filterConfPath = path
|
|
}
|
|
|
|
// Generate creates a dnsmasq configuration from registered domain entries.
|
|
// It auto-detects the Wild Central server's IP address for the listen directive.
|
|
func (g *Manager) Generate(cfg *config.State, entries []DNSEntry) string {
|
|
// Use configured dnsmasq IP (bound to eth0), falling back to auto-detect.
|
|
// Auto-detect can pick wlan0 if that's the default route, which is wrong
|
|
// when dnsmasq is only listening on eth0.
|
|
dnsIP := ""
|
|
if cfg != nil {
|
|
dnsIP = cfg.Cloud.Dnsmasq.IP
|
|
}
|
|
if dnsIP == "" {
|
|
var err error
|
|
dnsIP, err = network.GetWildCentralIP()
|
|
if err != nil {
|
|
slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "op", "generate", "error", err)
|
|
dnsIP = ""
|
|
}
|
|
}
|
|
|
|
resolution_section := ""
|
|
for _, entry := range entries {
|
|
if entry.Domain == "" || entry.IP == "" {
|
|
continue
|
|
}
|
|
if entry.Wildcard {
|
|
// Wildcard: resolves domain + all subdomains to this IP.
|
|
// Note: address=/ blocks forwarding for the entire subtree,
|
|
// so SOA walks stop here. For non-apex domains this is fine —
|
|
// the walk continues up to the parent (which uses host-record=).
|
|
resolution_section += fmt.Sprintf("address=/%s/%s\n", entry.Domain, entry.IP)
|
|
} else {
|
|
// Exact match: only this specific name. SOA/NS/TXT queries
|
|
// are forwarded upstream, enabling ACME DNS-01 validation.
|
|
resolution_section += fmt.Sprintf("host-record=%s,%s\n", entry.Domain, entry.IP)
|
|
}
|
|
}
|
|
|
|
template := `# Configuration file for dnsmasq.
|
|
|
|
# Basic Settings
|
|
listen-address=%s
|
|
bind-interfaces
|
|
domain-needed
|
|
bogus-priv
|
|
no-resolv
|
|
filter-AAAA
|
|
|
|
# DNS Local Resolution - registered domain A records
|
|
%s
|
|
server=1.1.1.1
|
|
server=8.8.8.8
|
|
|
|
log-queries
|
|
log-dhcp
|
|
`
|
|
|
|
result := fmt.Sprintf(template,
|
|
dnsIP,
|
|
resolution_section,
|
|
)
|
|
|
|
if g.filterConfPath != "" {
|
|
result += fmt.Sprintf("\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// RestartService fully restarts the dnsmasq service.
|
|
// A full restart is required because SIGHUP (reload) does not re-read
|
|
// host-record= directives — only address=/ and a few other directives
|
|
// are reloaded on SIGHUP.
|
|
func (g *Manager) RestartService() error {
|
|
cmd := exec.Command("systemctl", "restart", "dnsmasq.service")
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("failed to reload dnsmasq: %w (output: %s)", err, string(output))
|
|
}
|
|
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
|
|
return nil
|
|
}
|
|
|
|
// Status represents the status of the dnsmasq service
|
|
type Status struct {
|
|
Status string `json:"status"`
|
|
PID int `json:"pid"`
|
|
IP string `json:"ip"`
|
|
ConfigFile string `json:"configFile"`
|
|
DomainsConfigured int `json:"domainsConfigured"`
|
|
LastRestart time.Time `json:"lastRestart"`
|
|
}
|
|
|
|
// GetStatus checks the status of the dnsmasq service
|
|
func (g *Manager) GetStatus() (*Status, 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 := &Status{
|
|
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 domains in config by counting host-record= and address=/ directives
|
|
if data, err := os.ReadFile(g.configPath); err == nil {
|
|
config := string(data)
|
|
status.DomainsConfigured = strings.Count(config, "host-record=") + strings.Count(config, "\naddress=/")
|
|
}
|
|
|
|
return status, nil
|
|
}
|
|
|
|
// ReadConfig reads the current dnsmasq configuration
|
|
func (g *Manager) 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 and optionally restarts.
|
|
func (g *Manager) UpdateConfig(cfg *config.State, entries []DNSEntry, restart bool) error {
|
|
// Generate fresh config from scratch
|
|
configContent := g.Generate(cfg, entries)
|
|
|
|
// 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
|
|
}
|
|
|
|
// GenerateMainConfig creates the main dnsmasq configuration with global settings.
|
|
// Used by the DnsmasqGenerate handler to preview/apply the base config independently
|
|
// of domain-specific DNS entries (which are handled by Generate + UpdateConfig).
|
|
func (g *Manager) GenerateMainConfig(cfg *config.State) string {
|
|
dnsIP, err := network.GetWildCentralIP()
|
|
if err != nil {
|
|
slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "error", err)
|
|
dnsIP = ""
|
|
}
|
|
|
|
var sb strings.Builder
|
|
|
|
fmt.Fprintf(&sb, `# Wild Cloud DNS Configuration (Main)
|
|
# This file contains global settings. Domain-specific DNS entries are
|
|
# generated by reconciliation into the monolithic config.
|
|
|
|
# Basic Settings
|
|
listen-address=%s
|
|
bind-interfaces
|
|
domain-needed
|
|
bogus-priv
|
|
no-resolv
|
|
|
|
# Upstream DNS servers
|
|
server=1.1.1.1
|
|
server=8.8.8.8
|
|
|
|
# Logging
|
|
log-queries
|
|
log-dhcp
|
|
`, dnsIP)
|
|
|
|
// DHCP section (only when explicitly enabled)
|
|
if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled {
|
|
dhcp := cfg.Cloud.Dnsmasq.DHCP
|
|
sb.WriteString("\n# DHCP Configuration\n")
|
|
|
|
leaseTime := dhcp.LeaseTime
|
|
if leaseTime == "" {
|
|
leaseTime = "24h"
|
|
}
|
|
fmt.Fprintf(&sb, "dhcp-range=%s,%s,%s\n", dhcp.RangeStart, dhcp.RangeEnd, leaseTime)
|
|
|
|
gateway := dhcp.Gateway
|
|
if gateway != "" {
|
|
fmt.Fprintf(&sb, "dhcp-option=3,%s\n", gateway)
|
|
}
|
|
if dnsIP != "" {
|
|
fmt.Fprintf(&sb, "dhcp-option=6,%s\n", dnsIP)
|
|
}
|
|
|
|
for _, lease := range dhcp.StaticLeases {
|
|
if lease.Hostname != "" {
|
|
fmt.Fprintf(&sb, "dhcp-host=%s,%s,%s\n", lease.MAC, lease.IP, lease.Hostname)
|
|
} else {
|
|
fmt.Fprintf(&sb, "dhcp-host=%s,%s\n", lease.MAC, lease.IP)
|
|
}
|
|
}
|
|
}
|
|
|
|
if g.filterConfPath != "" {
|
|
fmt.Fprintf(&sb, "\n# DNS Filtering\nconf-file=%s\n", g.filterConfPath)
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// ValidateConfig tests a dnsmasq configuration file for syntax errors
|
|
func (g *Manager) ValidateConfig(configPath string) error {
|
|
cmd := exec.Command("dnsmasq", "--test", "-C", configPath)
|
|
output, err := cmd.CombinedOutput()
|
|
if err != nil {
|
|
return fmt.Errorf("config validation failed: %w (output: %s)", err, string(output))
|
|
}
|
|
if !strings.Contains(string(output), "syntax check OK") {
|
|
return fmt.Errorf("config validation did not report OK: %s", string(output))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReloadService restarts dnsmasq to apply configuration changes.
|
|
// Uses a full restart because SIGHUP does not re-read host-record= directives.
|
|
func (g *Manager) ReloadService() error {
|
|
return g.RestartService()
|
|
}
|
|
|
|
// ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server
|
|
// This should only be called on first start of dnsmasq
|
|
func (g *Manager) 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
|
|
}
|