From bb11b9d48c12507bc1904a2e785d7e6c0f814a67 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Tue, 19 May 2026 21:25:07 +0200 Subject: [PATCH] feat(health): add DNS interception sanity check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queries this service's own DNS server for every intercepted Bose hostname (api.bose.com, content.api.bose.io, etc.) and verifies the answer is the configured service IP. Catches: - DNS subsystem disabled or unbound (speakers using us as their resolver get NXDOMAIN). - DNS running but answers point at a stale IP (operator changed the LAN address without restarting). - Subset of intercepts silently failing — emits the failing hostname list explicitly so it's obvious which patterns are falling through shouldIntercept. For the mismatch case the finding includes a copyable `nslookup … ` so operators can verify the same behaviour from the speaker's network. To avoid duplicating the intercept list, exports it as `discovery.InterceptedBoseHosts` instead — same string slice that DNSDiscovery.shouldIntercept walks. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/discovery/dns.go | 45 ++--- pkg/service/handlers/server.go | 14 ++ pkg/service/health/checks_dns_sanity.go | 163 +++++++++++++++++ pkg/service/health/checks_dns_sanity_test.go | 177 +++++++++++++++++++ 4 files changed, 378 insertions(+), 21 deletions(-) create mode 100644 pkg/service/health/checks_dns_sanity.go create mode 100644 pkg/service/health/checks_dns_sanity_test.go diff --git a/pkg/discovery/dns.go b/pkg/discovery/dns.go index 49c979e..a23ab68 100644 --- a/pkg/discovery/dns.go +++ b/pkg/discovery/dns.go @@ -153,28 +153,31 @@ func (d *DNSDiscovery) recordQuery(hostname string, isIntercepted bool, remoteAd } } -func (d *DNSDiscovery) shouldIntercept(hostname string) bool { - // Intercept known Bose cloud services - interceptList := []string{ - "api.bose.com", - "marge.bose.com", - "bmx.bose.com", - "streaming.bose.com", - "streamingoauth.bose.com", - "updates.bose.com", - "stats.bose.com", - "content.api.bose.io", - "events.api.bosecm.com", - "bose-prod.apigee.net", - "bose-test.apigee.net", - "worldwide.bose.com", - "music.api.bose.com", - "bosecm.com", - "bose.io", - "downloads.bose.com", - } +// InterceptedBoseHosts is the canonical list of Bose cloud service +// hostnames the DNS server hijacks. Exposed so other packages +// (e.g. the Health tab's DNS sanity check) can iterate the list +// without duplicating it. +var InterceptedBoseHosts = []string{ + "api.bose.com", + "marge.bose.com", + "bmx.bose.com", + "streaming.bose.com", + "streamingoauth.bose.com", + "updates.bose.com", + "stats.bose.com", + "content.api.bose.io", + "events.api.bosecm.com", + "bose-prod.apigee.net", + "bose-test.apigee.net", + "worldwide.bose.com", + "music.api.bose.com", + "bosecm.com", + "bose.io", + "downloads.bose.com", +} - for _, service := range interceptList { +func (d *DNSDiscovery) shouldIntercept(hostname string) bool { + for _, service := range InterceptedBoseHosts { if strings.Contains(hostname, service) { return true } diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index ffcd14f..2c9da43 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -119,6 +119,20 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red }) health.RegisterOrionPathsCheck(s.healthRegistry, ds) health.RegisterPresetsCountCheck(s.healthRegistry, ds) + health.RegisterDNSSanityCheck( + s.healthRegistry, + s.GetDNSRunning, + func() string { + serverURL, _ := s.GetSettings() + + ip, err := s.ResolveServerURLIPForPreflight(serverURL) + if err != nil { + return "" + } + + return ip + }, + ) return s } diff --git a/pkg/service/health/checks_dns_sanity.go b/pkg/service/health/checks_dns_sanity.go new file mode 100644 index 0000000..f2838e6 --- /dev/null +++ b/pkg/service/health/checks_dns_sanity.go @@ -0,0 +1,163 @@ +package health + +import ( + "fmt" + "sort" + "strings" + "time" + + "github.com/gesellix/bose-soundtouch/pkg/discovery" + "github.com/miekg/dns" +) + +// CheckIDDNSSanity is the registry id of the DNS-interception +// sanity check. +const CheckIDDNSSanity = "dns_sanity" + +// DNSStatusFunc reports whether the service's DNS interception +// listener is running and on which UDP bind address (host:port). +// Closure over Server.GetDNSRunning to avoid a hard dependency +// from health onto handlers. +type DNSStatusFunc func() (running bool, bindAddr string) + +// ExpectedIPFunc returns the IP this service expects the +// intercepted hostnames to resolve to (i.e. its own LAN IP). +// Returns the empty string when no service URL is configured. +type ExpectedIPFunc func() string + +// RegisterDNSSanityCheck registers a check that queries the +// service's own DNS server for each intercepted Bose hostname and +// verifies the answer is the configured service IP. Catches three +// classes of misconfiguration recurring in #94, #218, #269: +// +// 1. DNS server is disabled or didn't bind — speakers using it +// as their resolver get NXDOMAIN. +// 2. DNS server is bound but answers point at the wrong IP +// (e.g. operator changed the LAN IP without restarting). +// 3. A subset of intercepted hostnames silently fail to resolve. +func RegisterDNSSanityCheck(r *Registry, statusFn DNSStatusFunc, expectedIPFn ExpectedIPFunc) { + r.Register(Check{ + ID: CheckIDDNSSanity, + Title: "DNS interception resolves Bose hostnames to this service", + Run: func() []Finding { + return runDNSSanityCheck(statusFn, expectedIPFn) + }, + }) +} + +func runDNSSanityCheck(statusFn DNSStatusFunc, expectedIPFn ExpectedIPFunc) []Finding { + running, bindAddr := statusFn() + if !running { + return []Finding{{ + Severity: SeverityInfo, + Message: "DNS interception is not running on this host.", + Details: "Speakers using this service as their DNS server would receive no answers for intercepted Bose hostnames. Enable DNS in Settings or set DNS_ENABLED=true if speakers should be redirected via DNS rather than /etc/hosts on the speaker.", + }} + } + + expectedIP := expectedIPFn() + if expectedIP == "" { + return []Finding{{ + Severity: SeverityWarning, + Message: "DNS server is running but no service IP could be resolved.", + Details: "Without a known target IP the sanity check can't validate answers. Configure SERVER_URL to a hostname that resolves to this service's LAN IP.", + }} + } + + // Query our DNS server for the canonical intercept list and + // classify the results. + hostnames := append([]string(nil), discovery.InterceptedBoseHosts...) + sort.Strings(hostnames) + + mismatches := make([]string, 0) + unanswered := make([]string, 0) + + for _, host := range hostnames { + ip, err := queryOwnDNS(bindAddr, host) + if err != nil { + unanswered = append(unanswered, host) + continue + } + + if ip != expectedIP { + mismatches = append(mismatches, fmt.Sprintf("%s → %s", host, ip)) + } + } + + var findings []Finding + + if len(unanswered) > 0 { + findings = append(findings, Finding{ + Severity: SeverityWarning, + Message: fmt.Sprintf( + "%d intercepted hostname(s) didn't get an answer from the local DNS server: %s.", + len(unanswered), strings.Join(unanswered, ", "), + ), + Details: fmt.Sprintf("Queried %s. Expected: %s. Check the dns server logs in the Logs tab for shouldIntercept misses.", bindAddr, expectedIP), + }) + } + + if len(mismatches) > 0 { + findings = append(findings, Finding{ + Severity: SeverityWarning, + Message: fmt.Sprintf( + "%d intercepted hostname(s) resolved to an unexpected IP (expected %s).", + len(mismatches), expectedIP, + ), + Details: strings.Join(mismatches, "; "), + ManualCommands: []ManualCommand{{ + Label: "Verify from the speaker's network:", + Command: "nslookup " + hostnames[0] + " " + extractHost(bindAddr), + Hint: "Run on a host that uses this service as its DNS resolver. Replace the hostname with any other intercepted name to spot-check.", + }}, + }) + } + + return findings +} + +// queryOwnDNS issues an A query against bindAddr (host:port). The +// service's DNS listener is UDP-only at the listener layer, so we +// always dial UDP here. +func queryOwnDNS(bindAddr, hostname string) (string, error) { + c := dns.Client{Timeout: 1 * time.Second} + + m := new(dns.Msg) + m.SetQuestion(dns.Fqdn(hostname), dns.TypeA) + m.RecursionDesired = true + + // Loopback substitution: a bind of 0.0.0.0:53 means "all + // interfaces" — we can't dial that, so resolve to 127.0.0.1 + // instead. + addr := bindAddr + if strings.HasPrefix(addr, "0.0.0.0:") { + addr = "127.0.0.1:" + strings.TrimPrefix(addr, "0.0.0.0:") + } else if strings.HasPrefix(addr, "[::]:") { + addr = "[::1]:" + strings.TrimPrefix(addr, "[::]:") + } + + r, _, err := c.Exchange(m, addr) + if err != nil { + return "", err + } + + if r.Rcode != dns.RcodeSuccess { + return "", fmt.Errorf("rcode %d", r.Rcode) + } + + for _, ans := range r.Answer { + if a, ok := ans.(*dns.A); ok && a.A != nil { + return a.A.String(), nil + } + } + + return "", fmt.Errorf("no A record in answer") +} + +func extractHost(bindAddr string) string { + if i := strings.LastIndex(bindAddr, ":"); i >= 0 { + return bindAddr[:i] + } + + return bindAddr +} diff --git a/pkg/service/health/checks_dns_sanity_test.go b/pkg/service/health/checks_dns_sanity_test.go new file mode 100644 index 0000000..b908d8e --- /dev/null +++ b/pkg/service/health/checks_dns_sanity_test.go @@ -0,0 +1,177 @@ +package health + +import ( + "net" + "strings" + "testing" + + "github.com/miekg/dns" +) + +func TestDNSSanity_NotRunning(t *testing.T) { + got := runDNSSanityCheck( + func() (bool, string) { return false, "" }, + func() string { return "192.0.2.10" }, + ) + + if len(got) != 1 || got[0].Severity != SeverityInfo { + t.Fatalf("expected one info finding when DNS not running, got %+v", got) + } + + if !strings.Contains(got[0].Message, "not running") { + t.Errorf("expected 'not running' in message, got %q", got[0].Message) + } +} + +func TestDNSSanity_NoExpectedIP(t *testing.T) { + got := runDNSSanityCheck( + func() (bool, string) { return true, "127.0.0.1:53000" }, + func() string { return "" }, + ) + + if len(got) != 1 || got[0].Severity != SeverityWarning { + t.Fatalf("expected one warning when expectedIP is empty, got %+v", got) + } +} + +func TestDNSSanity_HappyPath(t *testing.T) { + expectedIP := "192.0.2.10" + + srv := startStubDNSServer(t, func(hostname string) string { + return expectedIP + }) + + got := runDNSSanityCheck( + func() (bool, string) { return true, srv }, + func() string { return expectedIP }, + ) + + if len(got) != 0 { + t.Errorf("expected no findings on happy path, got %+v", got) + } +} + +func TestDNSSanity_MismatchedAnswer(t *testing.T) { + srv := startStubDNSServer(t, func(_ string) string { + return "203.0.113.99" // wrong IP, doesn't match expected + }) + + got := runDNSSanityCheck( + func() (bool, string) { return true, srv }, + func() string { return "192.0.2.10" }, + ) + + var foundMismatch bool + for _, f := range got { + if strings.Contains(f.Message, "unexpected IP") && f.Severity == SeverityWarning { + foundMismatch = true + if !strings.Contains(f.Details, "203.0.113.99") { + t.Errorf("expected actual IP in details, got %q", f.Details) + } + } + } + + if !foundMismatch { + t.Errorf("expected a mismatch warning, got %+v", got) + } +} + +func TestDNSSanity_UnansweredHostnames(t *testing.T) { + // Stub returns "" for some hostnames → server emits NXDOMAIN. + srv := startStubDNSServer(t, func(hostname string) string { + if strings.Contains(hostname, "streaming") { + return "" // refuse + } + + return "192.0.2.10" + }) + + got := runDNSSanityCheck( + func() (bool, string) { return true, srv }, + func() string { return "192.0.2.10" }, + ) + + var foundUnanswered bool + for _, f := range got { + if strings.Contains(f.Message, "didn't get an answer") { + foundUnanswered = true + if !strings.Contains(f.Message, "streaming") { + t.Errorf("expected 'streaming' in unanswered list, got %q", f.Message) + } + } + } + + if !foundUnanswered { + t.Errorf("expected unanswered warning, got %+v", got) + } +} + +func TestExtractHost(t *testing.T) { + cases := []struct{ in, want string }{ + {"127.0.0.1:53", "127.0.0.1"}, + {"0.0.0.0:53", "0.0.0.0"}, + {"[::]:53", "[::]"}, + {"no-port", "no-port"}, + } + + for _, c := range cases { + if got := extractHost(c.in); got != c.want { + t.Errorf("extractHost(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// startStubDNSServer binds a UDP DNS responder on 127.0.0.1 (random +// port). For each A query, answerForHost is called with the queried +// hostname (no trailing dot); a non-empty return is the answer, an +// empty return triggers NXDOMAIN. +func startStubDNSServer(t *testing.T, answerForHost func(string) string) string { + t.Helper() + + pc, err := net.ListenPacket("udp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen udp: %v", err) + } + + mux := dns.NewServeMux() + mux.HandleFunc(".", func(w dns.ResponseWriter, req *dns.Msg) { + resp := new(dns.Msg) + resp.SetReply(req) + + for _, q := range req.Question { + if q.Qtype != dns.TypeA { + continue + } + + name := strings.TrimSuffix(q.Name, ".") + ip := answerForHost(name) + + if ip == "" { + resp.SetRcode(req, dns.RcodeNameError) + continue + } + + parsed := net.ParseIP(ip) + if parsed == nil { + continue + } + + resp.Answer = append(resp.Answer, &dns.A{ + Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60}, + A: parsed.To4(), + }) + } + + _ = w.WriteMsg(resp) + }) + + srv := &dns.Server{PacketConn: pc, Handler: mux} + go func() { _ = srv.ActivateAndServe() }() + + t.Cleanup(func() { + _ = srv.Shutdown() + _ = pc.Close() + }) + + return pc.LocalAddr().String() +}