mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 08:36:13 +00:00
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:
co-authored by
Claude Opus 4.7
parent
7d46ae2280
commit
bb11b9d48c
+24
-21
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user