feat(health): add DNS interception sanity check

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 … <our-dns-bind>` 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) <noreply@anthropic.com>
This commit is contained in:
Tobias Gesellchen
2026-05-19 23:20:40 +02:00
co-authored by Claude Opus 4.7
parent 7d46ae2280
commit bb11b9d48c
4 changed files with 378 additions and 21 deletions
+24 -21
View File
@@ -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
}
+14
View File
@@ -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
}
+163
View File
@@ -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
}
@@ -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()
}