Files
wild-central/internal/dnsmasq/config.go
Paul Payne 518cdbbce5 Add DNS filtering with dnsmasq address=/ directives for wildcard blocking
Adds Pi-hole-style DNS filtering using dnsmasq's native address=/ directives,
which block domains and all subdomains. Users subscribe to blocklists by URL
or upload files, with suggested lists from Hagezi, Steven Black, and OISD.
Background runner refreshes lists on a configurable interval (default 24h).
2026-07-12 12:44:19 +00:00

350 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.
// Each entry produces both local=/ and address=/ directives.
// local=/ makes dnsmasq authoritative for the domain, which prevents
// AAAA queries from leaking to upstream DNS. Without it, upstream could
// return public IPv6 records and browsers using Happy Eyeballs (RFC 8305)
// would try the unreachable IPv6 path first, adding latency on LAN.
type DNSEntry struct {
Domain string // FQDN to resolve (e.g. "cloud.payne.io")
IP string // target IPv4 address
}
// ConfigGenerator handles dnsmasq configuration generation
type ConfigGenerator struct {
configPath string
filterConfPath string // path to addn-hosts file for DNS filtering
}
// NewConfigGenerator creates a new dnsmasq config generator
func NewConfigGenerator(configPath string) *ConfigGenerator {
if configPath == "" {
configPath = "/etc/dnsmasq.d/wild-cloud.conf"
}
return &ConfigGenerator{
configPath: configPath,
}
}
// GetConfigPath returns the dnsmasq config file path
func (g *ConfigGenerator) 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 *ConfigGenerator) 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 *ConfigGenerator) 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
}
// local=/ makes dnsmasq authoritative for the domain so AAAA queries
// return empty instead of leaking to upstream DNS. Without this,
// upstream could return public IPv6 records and browsers using Happy
// Eyeballs (RFC 8305) would try the unreachable IPv6 path first,
// adding 250ms-2s latency on LAN.
resolution_section += fmt.Sprintf("local=/%s/\naddress=/%s/%s\n", entry.Domain, entry.Domain, entry.IP)
}
template := `# Configuration file for dnsmasq.
# Basic Settings
listen-address=%s
bind-interfaces
domain-needed
bogus-priv
no-resolv
# DNS Local Resolution - Central server handles these domains authoritatively
%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 reloads the dnsmasq service (SIGHUP — no downtime).
// Falls back to restart if reload fails.
func (g *ConfigGenerator) RestartService() error {
cmd := exec.Command("systemctl", "reload-or-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
}
// ServiceStatus represents the status of the dnsmasq service
type ServiceStatus struct {
Status string `json:"status"`
PID int `json:"pid"`
IP string `json:"ip"`
ConfigFile string `json:"config_file"`
DomainsConfigured int `json:"domains_configured"`
LastRestart time.Time `json:"last_restart"`
}
// GetStatus checks the status of the dnsmasq service
func (g *ConfigGenerator) GetStatus() (*ServiceStatus, 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 := &ServiceStatus{
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 local=/ directives
if data, err := os.ReadFile(g.configPath); err == nil {
status.DomainsConfigured = strings.Count(string(data), "\nlocal=/")
}
return status, nil
}
// ReadConfig reads the current dnsmasq configuration
func (g *ConfigGenerator) 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 *ConfigGenerator) 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 *ConfigGenerator) 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 *ConfigGenerator) 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 sends a SIGHUP to dnsmasq to reload configuration.
// Lighter weight than a full restart. Falls back to RestartService on failure.
func (g *ConfigGenerator) ReloadService() error {
cmd := exec.Command("systemctl", "reload", "dnsmasq.service")
_, err := cmd.CombinedOutput()
if err != nil {
slog.Error("reload failed, attempting restart", "component", "dnsmasq", "error", err)
return g.RestartService()
}
slog.Info("dnsmasq service reloaded", "component", "dnsmasq")
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)
}
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
}