Support custom domains. Fix host-record/address resolution.
This commit is contained in:
@@ -14,14 +14,14 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
|
||||
// ConfigGenerator handles dnsmasq configuration generation
|
||||
@@ -75,12 +75,17 @@ func (g *ConfigGenerator) Generate(cfg *config.State, entries []DNSEntry) string
|
||||
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)
|
||||
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.
|
||||
@@ -91,8 +96,9 @@ bind-interfaces
|
||||
domain-needed
|
||||
bogus-priv
|
||||
no-resolv
|
||||
filter-AAAA
|
||||
|
||||
# DNS Local Resolution - Central server handles these domains authoritatively
|
||||
# DNS Local Resolution - registered domain A records
|
||||
%s
|
||||
server=1.1.1.1
|
||||
server=8.8.8.8
|
||||
@@ -113,10 +119,12 @@ log-dhcp
|
||||
return result
|
||||
}
|
||||
|
||||
// RestartService reloads the dnsmasq service (SIGHUP — no downtime).
|
||||
// Falls back to restart if reload fails.
|
||||
// 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 *ConfigGenerator) RestartService() error {
|
||||
cmd := exec.Command("systemctl", "reload-or-restart", "dnsmasq.service")
|
||||
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))
|
||||
@@ -187,9 +195,10 @@ func (g *ConfigGenerator) GetStatus() (*ServiceStatus, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Count domains in config by counting local=/ directives
|
||||
// Count domains in config by counting host-record= and address=/ directives
|
||||
if data, err := os.ReadFile(g.configPath); err == nil {
|
||||
status.DomainsConfigured = strings.Count(string(data), "\nlocal=/")
|
||||
config := string(data)
|
||||
status.DomainsConfigured = strings.Count(config, "host-record=") + strings.Count(config, "\naddress=/")
|
||||
}
|
||||
|
||||
return status, nil
|
||||
@@ -303,17 +312,10 @@ func (g *ConfigGenerator) ValidateConfig(configPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadService sends a SIGHUP to dnsmasq to reload configuration.
|
||||
// Lighter weight than a full restart. Falls back to RestartService on failure.
|
||||
// ReloadService restarts dnsmasq to apply configuration changes.
|
||||
// Uses a full restart because SIGHUP does not re-read host-record= directives.
|
||||
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
|
||||
return g.RestartService()
|
||||
}
|
||||
|
||||
// ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server
|
||||
|
||||
Reference in New Issue
Block a user