Support custom domains. Fix host-record/address resolution.

This commit is contained in:
2026-07-13 00:26:13 +00:00
parent 65c7e56b0a
commit 1e7d93256e
12 changed files with 336 additions and 132 deletions

View File

@@ -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