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

@@ -168,17 +168,18 @@ func TestReconciliation_HAProxyConfigFromDomains(t *testing.T) {
}
// TestReconciliation_DnsmasqConfigFromDomains verifies that reconciliation
// correctly generates dnsmasq config from registered domains, including
// the critical reach-based local=/ directives.
// correctly generates dnsmasq config from registered domains. Wildcard
// domains (subdomains:true) get address=/, exact-match domains get
// host-record=. No local=/ directives — AAAA is handled by filter-AAAA.
func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
api, _ := setupTestAPI(t)
centralIP := "192.168.8.151"
// Register domains with different reach and backend types
// Register domains with different backend types and subdomain settings
testDomains := []domains.Domain{
{
// tcp-passthrough, public → DNS points to k8s LB IP, no local=/
// tcp-passthrough, wildcard → address=/ with backend IP
DomainName: "cloud.payne.io",
Source: "wild-cloud",
Backend: domains.Backend{Address: "192.168.8.240:443", Type: domains.BackendTCPPassthrough},
@@ -186,13 +187,13 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
Public: true,
},
{
// http, internal → DNS points to Central IP, has local=/
// http, exact → host-record= with Central IP
DomainName: "my-api.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:9001", Type: domains.BackendHTTP},
},
{
// http, public → DNS points to Central IP, NO local=/
// http, exact → host-record= with Central IP
DomainName: "public-app.payne.io",
Source: "wild-works",
Backend: domains.Backend{Address: "192.168.8.60:8080", Type: domains.BackendHTTP},
@@ -226,35 +227,35 @@ func TestReconciliation_DnsmasqConfigFromDomains(t *testing.T) {
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
Domain: dom.DomainName,
IP: dnsIP,
Wildcard: dom.Subdomains,
})
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// --- TCP passthrough (public) → backend IP ---
// --- Wildcard (subdomains:true) → address=/ with backend IP ---
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("tcp-passthrough domain must point DNS to backend IP (192.168.8.240):\n%s", dnsmasqCfg)
t.Errorf("wildcard domain must use address=/ with backend IP:\n%s", dnsmasqCfg)
}
// --- HTTP domains → Central IP ---
if !strings.Contains(dnsmasqCfg, "address=/my-api.payne.io/192.168.8.151") {
t.Errorf("http/internal domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
// --- Exact-match HTTP domains → host-record= with Central IP ---
if !strings.Contains(dnsmasqCfg, "host-record=my-api.payne.io,192.168.8.151") {
t.Errorf("exact-match http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
}
if !strings.Contains(dnsmasqCfg, "address=/public-app.payne.io/192.168.8.151") {
t.Errorf("http/public domain must point DNS to Central IP (192.168.8.151):\n%s", dnsmasqCfg)
if !strings.Contains(dnsmasqCfg, "host-record=public-app.payne.io,192.168.8.151") {
t.Errorf("exact-match http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
}
// --- All domains get local=/ to prevent AAAA leaking upstream (Happy Eyeballs) ---
if !strings.Contains(dnsmasqCfg, "local=/my-api.payne.io/") {
t.Errorf("domain must have local=/ entry:\n%s", dnsmasqCfg)
// --- No local=/ directives (AAAA handled by filter-AAAA) ---
if strings.Contains(dnsmasqCfg, "local=/") {
t.Errorf("must not produce local=/ directives:\n%s", dnsmasqCfg)
}
if !strings.Contains(dnsmasqCfg, "local=/cloud.payne.io/") {
t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
}
if !strings.Contains(dnsmasqCfg, "local=/public-app.payne.io/") {
t.Errorf("domain must have local=/ entry (prevents AAAA leaking):\n%s", dnsmasqCfg)
// --- filter-AAAA present ---
if !strings.Contains(dnsmasqCfg, "filter-AAAA") {
t.Errorf("must include filter-AAAA directive:\n%s", dnsmasqCfg)
}
}
@@ -345,19 +346,20 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
dnsIP = extractHost(dom.Backend.Address)
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
Domain: dom.DomainName,
IP: dnsIP,
Wildcard: dom.Subdomains,
})
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// TCP passthrough → DNS points to backend (k8s LB), NOT central
// TCP passthrough with subdomains → address=/ to backend (k8s LB), NOT central
if !strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("tcp-passthrough DNS must point to backend IP 192.168.8.240:\n%s", dnsmasqCfg)
t.Errorf("wildcard tcp-passthrough DNS must use address=/ to backend IP 192.168.8.240:\n%s", dnsmasqCfg)
}
if strings.Contains(dnsmasqCfg, "address=/cloud.payne.io/192.168.8.151") {
t.Errorf("tcp-passthrough DNS must NOT point to Central IP:\n%s", dnsmasqCfg)
if strings.Contains(dnsmasqCfg, "host-record=cloud.payne.io,192.168.8.151") {
t.Errorf("wildcard tcp-passthrough DNS must NOT use host-record= to Central IP:\n%s", dnsmasqCfg)
}
}
@@ -437,20 +439,21 @@ func TestReconciliation_DNSOnlyNoGateway(t *testing.T) {
dnsIP = extractHost(dom.Backend.Address)
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
Domain: dom.DomainName,
IP: dnsIP,
Wildcard: dom.Subdomains,
})
}
dnsmasqCfg := api.dnsmasq.Generate(globalCfg, dnsEntries)
// dns-only domain should resolve to its backend IP (192.168.8.222)
if !strings.Contains(dnsmasqCfg, "address=/dev.payne.io/192.168.8.222") {
t.Errorf("dns-only domain must point DNS to backend IP:\n%s", dnsmasqCfg)
// dns-only domain (public, no subdomains) → host-record= with backend IP
if !strings.Contains(dnsmasqCfg, "host-record=dev.payne.io,192.168.8.222") {
t.Errorf("dns-only domain must use host-record= with backend IP:\n%s", dnsmasqCfg)
}
// HTTP domain should resolve to Central IP
if !strings.Contains(dnsmasqCfg, "address=/app.payne.io/192.168.8.151") {
t.Errorf("http domain must point DNS to Central IP:\n%s", dnsmasqCfg)
// HTTP domain (no subdomains) → host-record= with Central IP
if !strings.Contains(dnsmasqCfg, "host-record=app.payne.io,192.168.8.151") {
t.Errorf("http domain must use host-record= with Central IP:\n%s", dnsmasqCfg)
}
}

View File

@@ -242,8 +242,9 @@ func (api *API) reconcileNetworking() {
}
dnsEntries = append(dnsEntries, dnsmasq.DNSEntry{
Domain: dom.DomainName,
IP: dnsIP,
Domain: dom.DomainName,
IP: dnsIP,
Wildcard: dom.Subdomains,
})
}

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

View File

@@ -12,7 +12,11 @@ func entry(domain, ip string) DNSEntry {
return DNSEntry{Domain: domain, IP: ip}
}
// Test: Generate with entries produces expected DNS entries
func wildcardEntry(domain, ip string) DNSEntry {
return DNSEntry{Domain: domain, IP: ip, Wildcard: true}
}
// Test: Generate with exact-match entries produces host-record= directives
func TestGenerate_WithEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
entries := []DNSEntry{
@@ -22,17 +26,11 @@ func TestGenerate_WithEntries(t *testing.T) {
out := g.Generate(nil, entries)
if !strings.Contains(out, "local=/cloud.example.com/") {
t.Errorf("expected local=/ for domain, got:\n%s", out)
if !strings.Contains(out, "host-record=cloud.example.com,192.168.1.10") {
t.Errorf("expected host-record for domain, got:\n%s", out)
}
if !strings.Contains(out, "address=/cloud.example.com/192.168.1.10") {
t.Errorf("expected address entry for domain, got:\n%s", out)
}
if !strings.Contains(out, "local=/internal.example.com/") {
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
}
if !strings.Contains(out, "address=/internal.example.com/192.168.1.10") {
t.Errorf("expected address entry for internal domain, got:\n%s", out)
if !strings.Contains(out, "host-record=internal.example.com,192.168.1.10") {
t.Errorf("expected host-record for internal domain, got:\n%s", out)
}
if !strings.Contains(out, "server=1.1.1.1") {
t.Errorf("expected upstream DNS server, got:\n%s", out)
@@ -62,8 +60,8 @@ func TestGenerate_EntryWithoutIP(t *testing.T) {
out := g.Generate(nil, []DNSEntry{entry("cloud.example.com", "")})
if strings.Contains(out, "address=/") {
t.Errorf("expected no address= entries for empty IP, got:\n%s", out)
if strings.Contains(out, "host-record=") {
t.Errorf("expected no host-record entries for empty IP, got:\n%s", out)
}
}
@@ -198,78 +196,98 @@ func TestNewConfigGenerator_DefaultPath(t *testing.T) {
}
}
// Test: Domain-only entry (no internal domain pair)
func TestGenerate_DomainOnly(t *testing.T) {
// Test: Exact-match domain produces host-record=
func TestGenerate_ExactMatchDomain(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
out := g.Generate(globalCfg, []DNSEntry{entry("my-api.payne.io", "192.168.8.151")})
if !strings.Contains(out, "address=/my-api.payne.io/192.168.8.151") {
t.Errorf("expected address entry for domain, got:\n%s", out)
if !strings.Contains(out, "host-record=my-api.payne.io,192.168.8.151") {
t.Errorf("expected host-record entry for domain, got:\n%s", out)
}
if !strings.Contains(out, "local=/my-api.payne.io/") {
t.Errorf("expected local=/ entry for domain, got:\n%s", out)
if strings.Contains(out, "address=/") {
t.Errorf("exact-match domain must not produce address=/ directive:\n%s", out)
}
// Must NOT have empty entries
if strings.Contains(out, "local=//") {
t.Errorf("unexpected empty local=// in output:\n%s", out)
}
if strings.Contains(out, "address=//") {
t.Errorf("unexpected empty address=// in output:\n%s", out)
if strings.Contains(out, "local=/") {
t.Errorf("must not produce local=/ directive:\n%s", out)
}
}
// Test: All domains get local=/ to prevent AAAA leaking upstream
func TestGenerate_AllDomainsGetLocal(t *testing.T) {
// Test: Wildcard domain produces address=/ without local=/
func TestGenerate_WildcardDomain(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
out := g.Generate(globalCfg, []DNSEntry{wildcardEntry("cloud.payne.io", "192.168.8.240")})
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("expected address=/ entry for wildcard domain, got:\n%s", out)
}
if strings.Contains(out, "host-record=") {
t.Errorf("wildcard domain must not produce host-record= directive:\n%s", out)
}
if strings.Contains(out, "local=/") {
t.Errorf("must not produce local=/ directive:\n%s", out)
}
}
// Test: No local=/ directives are ever generated
func TestGenerate_NoLocalDirectives(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
// Both "internal" and "public" domains should get local=/ —
// prevents AAAA queries from leaking to upstream DNS (Happy Eyeballs).
entries := []DNSEntry{
entry("internal-api.payne.io", "192.168.8.151"),
entry("cloud.payne.io", "192.168.8.240"),
wildcardEntry("cloud.payne.io", "192.168.8.240"),
}
out := g.Generate(globalCfg, entries)
if !strings.Contains(out, "local=/internal-api.payne.io/") {
t.Errorf("expected local=/ for internal domain, got:\n%s", out)
}
if !strings.Contains(out, "local=/cloud.payne.io/") {
t.Errorf("expected local=/ for public domain (prevents AAAA leaking), got:\n%s", out)
if strings.Contains(out, "local=/") {
t.Errorf("must never produce local=/ directives:\n%s", out)
}
}
// Test: Mix of domains each get their own entries
// Test: filter-AAAA appears in generated config
func TestGenerate_FilterAAAA(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
out := g.Generate(nil, nil)
if !strings.Contains(out, "filter-AAAA") {
t.Errorf("expected filter-AAAA in config, got:\n%s", out)
}
}
// Test: Mix of exact and wildcard domains
func TestGenerate_MixedDomains(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
entries := []DNSEntry{
entry("cloud.payne.io", "192.168.8.240"), // k8s passthrough
entry("central.payne.io", "192.168.8.151"), // Central HTTP
entry("wild-cloud.payne.io", "192.168.8.151"), // service HTTP
wildcardEntry("cloud.payne.io", "192.168.8.240"), // wildcard
entry("central.payne.io", "192.168.8.151"), // exact
entry("wild-cloud.payne.io", "192.168.8.151"), // exact
}
out := g.Generate(globalCfg, entries)
if !strings.Contains(out, "address=/cloud.payne.io/192.168.8.240") {
t.Errorf("expected address for cloud domain, got:\n%s", out)
t.Errorf("expected address=/ for wildcard domain, got:\n%s", out)
}
if !strings.Contains(out, "address=/central.payne.io/192.168.8.151") {
t.Errorf("expected address for central, got:\n%s", out)
if !strings.Contains(out, "host-record=central.payne.io,192.168.8.151") {
t.Errorf("expected host-record for exact domain, got:\n%s", out)
}
if !strings.Contains(out, "address=/wild-cloud.payne.io/192.168.8.151") {
t.Errorf("expected address for wild-cloud, got:\n%s", out)
if !strings.Contains(out, "host-record=wild-cloud.payne.io,192.168.8.151") {
t.Errorf("expected host-record for exact domain, got:\n%s", out)
}
if strings.Contains(out, "local=//") {
t.Errorf("unexpected empty local=// in output:\n%s", out)
if strings.Contains(out, "local=/") {
t.Errorf("must not produce local=/ directives:\n%s", out)
}
}
// Test: empty entries list produces base config with no address= entries
// Test: empty entries list produces base config with no DNS entries
func TestGenerate_EmptyEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
@@ -282,15 +300,15 @@ func TestGenerate_EmptyEntries(t *testing.T) {
if !strings.Contains(out, "server=1.1.1.1") {
t.Errorf("expected upstream DNS server, got:\n%s", out)
}
if strings.Contains(out, "address=/") {
t.Errorf("expected no address= entries with empty entries, got:\n%s", out)
if strings.Contains(out, "host-record=") {
t.Errorf("expected no host-record entries with empty entries, got:\n%s", out)
}
if strings.Contains(out, "local=/") {
t.Errorf("expected no local=/ entries with empty entries, got:\n%s", out)
if strings.Contains(out, "address=/") {
t.Errorf("expected no address=/ entries with empty entries, got:\n%s", out)
}
}
// Test: multiple entries each get their own DNS records
// Test: multiple exact-match entries each get their own DNS records
func TestGenerate_MultipleEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
globalCfg := &config.State{}
@@ -304,11 +322,8 @@ func TestGenerate_MultipleEntries(t *testing.T) {
out := g.Generate(globalCfg, entries)
for _, e := range entries {
if !strings.Contains(out, fmt.Sprintf("address=/%s/%s", e.Domain, e.IP)) {
t.Errorf("expected address entry for %s, got:\n%s", e.Domain, out)
}
if !strings.Contains(out, fmt.Sprintf("local=/%s/", e.Domain)) {
t.Errorf("expected local=/ entry for %s, got:\n%s", e.Domain, out)
if !strings.Contains(out, fmt.Sprintf("host-record=%s,%s", e.Domain, e.IP)) {
t.Errorf("expected host-record entry for %s, got:\n%s", e.Domain, out)
}
}
}
@@ -377,7 +392,7 @@ func TestGenerate_ConfiguredDnsmasqIP(t *testing.T) {
}
}
// Test: verify no address=// or local=// entries appear
// Test: verify no leaked empty entries appear
func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
cfg := &config.State{}
@@ -387,7 +402,8 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
entries []DNSEntry
}{
{"empty", nil},
{"domain_only", []DNSEntry{entry("example.com", "10.0.0.1")}},
{"exact_domain", []DNSEntry{entry("example.com", "10.0.0.1")}},
{"wildcard_domain", []DNSEntry{wildcardEntry("example.com", "10.0.0.1")}},
{"no_ip", []DNSEntry{entry("example.com", "")}},
{"no_domain", []DNSEntry{entry("", "10.0.0.1")}},
}
@@ -395,11 +411,14 @@ func TestGenerate_NoLeakedEmptyEntries(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := g.Generate(cfg, tc.entries)
if strings.Contains(out, "host-record=,") {
t.Errorf("leaked empty host-record in output:\n%s", out)
}
if strings.Contains(out, "address=//") {
t.Errorf("leaked empty address=// in output:\n%s", out)
}
if strings.Contains(out, "local=//") {
t.Errorf("leaked empty local=// in output:\n%s", out)
if strings.Contains(out, "local=/") {
t.Errorf("must not produce local=/ directives:\n%s", out)
}
})
}