diff --git a/pkg/service/handlers/handlers_export.go b/pkg/service/handlers/handlers_export.go index 22a81d6..6f1a2c6 100644 --- a/pkg/service/handlers/handlers_export.go +++ b/pkg/service/handlers/handlers_export.go @@ -593,6 +593,36 @@ func (s *Server) collectSpeakerRedirectConfig(tw *tar.Writer) map[string]*redire return out } +// readSpeakerBmxRegistryURL reads a single speaker's runtime bmxRegistryUrl from +// its on-device SoundTouchSdkPrivateCfg.xml (via SSH), falling back to `getpdo +// CurrentSystemConfiguration` over telnet. It returns the URL and whether the +// runtime config could be read at all (SSH or telnet succeeded). Unlike +// collectSpeakerRedirectConfig it archives nothing; it's the lightweight read +// used by the runtime_bmx_url_stale health check. +func (s *Server) readSpeakerBmxRegistryURL(ip string) (string, bool) { + if ip == "" { + return "", false + } + + // 1. Prefer the persisted XML over SSH. + sc := speakerssh.NewClient(ip) + if data, err := sc.ReadFile(setup.SoundTouchSdkPrivateCfgPath); err == nil { + var cfg setup.PrivateCfg + if xml.Unmarshal(data, &cfg) == nil { + return cfg.BmxRegistryUrl, true + } + } + + // 2. Fall back to telnet getpdo when SSH gave us nothing. + if raw, ok := readTelnetSystemConfig(ip); ok { + if fields := setup.ParseGetpdoConfig(raw); len(fields) > 0 { + return fields["bmxRegistryUrl"], true + } + } + + return "", false +} + // addServiceLog appends the in-memory service log buffer as logs/service.txt. // Each entry is formatted as "2006-01-02T15:04:05Z ". func (s *Server) addServiceLog(tw *tar.Writer) { diff --git a/pkg/service/handlers/server.go b/pkg/service/handlers/server.go index 7880feb..63c7045 100644 --- a/pkg/service/handlers/server.go +++ b/pkg/service/handlers/server.go @@ -147,6 +147,15 @@ func NewServer(ds *datastore.DataStore, sm *setup.Manager, serverURL string, red health.RegisterSpeakerInfoReachable(s.healthRegistry, ds) health.RegisterSourcesXMLDiff(s.healthRegistry, ds) health.RegisterSpeakerMargeURLCheck(s.healthRegistry, ds, s.ExpectedHosts) + health.RegisterRuntimeBmxURLStaleCheck( + s.healthRegistry, + ds, + s.readSpeakerBmxRegistryURL, + func() bool { + running, _ := s.GetDNSRunning() + return running + }, + ) health.RegisterCertChainCheck( s.healthRegistry, func() string { diff --git a/pkg/service/health/checks_runtime_bmx_url.go b/pkg/service/health/checks_runtime_bmx_url.go new file mode 100644 index 0000000..a5d5f73 --- /dev/null +++ b/pkg/service/health/checks_runtime_bmx_url.go @@ -0,0 +1,133 @@ +package health + +import ( + "fmt" + "strings" + + "github.com/gesellix/bose-soundtouch/pkg/service/datastore" +) + +// CheckIDRuntimeBmxURLStale is the registry id of the runtime BMX-URL +// staleness check. +const CheckIDRuntimeBmxURLStale = "runtime_bmx_url_stale" + +// RegisterRuntimeBmxURLStaleCheck registers a check that reads each reachable +// speaker's *runtime* bmxRegistryUrl (from its on-device +// SoundTouchSdkPrivateCfg.xml via SSH, or `getpdo` over telnet) and flags +// speakers still pointed at the shut-down Bose cloud. +// +// Radio source types (TUNEIN / RADIO_BROWSER / LOCAL_INTERNET_RADIO) are +// delivered to the speaker through the BMX registry, so a speaker whose +// bmxRegistryUrl still names the Bose cloud can never mount them. That is the +// most common "radio missing after migration" cause. The service's own /sources +// listing is correct, which is why the existing sources_xml_diff check only +// reports the symptom ("missing 3 source types"); this check reports the +// per-device cause so the operator knows exactly which speakers need +// re-migrating. +// +// readBmxURL reads a speaker's runtime bmxRegistryUrl by IP, returning the URL +// and whether the runtime config could be read at all. It lives in the handlers +// layer because the health package deliberately avoids importing the SSH/telnet +// and setup packages (see the boundary comments in server.go). +// +// dnsRunningFn reports whether this service is running its own DNS interception. +// This is the false-positive guard: under a DNS-based migration (AfterTouch +// acting as the speaker's DNS server) a cloud URL is EXPECTED, because the +// redirect happens at the DNS layer rather than by rewriting the on-device URL, +// so when our DNS is running the check stays silent. The router-DNS variant (the +// LAN's DNS points at AfterTouch without our DNS server running) is called out +// as a known exception in the finding text rather than suppressed, since we +// cannot detect it here. +func RegisterRuntimeBmxURLStaleCheck(r *Registry, ds *datastore.DataStore, readBmxURL func(ip string) (string, bool), dnsRunningFn func() bool) { + r.Register(Check{ + ID: CheckIDRuntimeBmxURLStale, + Title: "Speaker runtime bmxRegistryUrl points at AfterTouch, not the Bose cloud", + Run: func() []Finding { + return runRuntimeBmxURLStaleCheck(ds, readBmxURL, dnsRunningFn) + }, + }) +} + +func runRuntimeBmxURLStaleCheck(ds *datastore.DataStore, readBmxURL func(ip string) (string, bool), dnsRunningFn func() bool) []Finding { + if ds == nil || readBmxURL == nil { + return nil + } + + // When this service intercepts DNS, a speaker legitimately keeps the Bose + // cloud hostnames in its on-device config (they resolve to AfterTouch), so a + // cloud URL is not evidence of a stale migration. Don't second-guess it. + if dnsRunningFn != nil && dnsRunningFn() { + return nil + } + + devices, err := ds.ListAllDevices() + if err != nil { + return []Finding{{ + Severity: SeverityError, + Message: "Could not enumerate devices: " + err.Error(), + }} + } + + var findings []Finding + + for i := range devices { + dev := &devices[i] + if dev.IPAddress == "" || dev.DeviceID == "" { + continue + } + + bmxURL, ok := readBmxURL(dev.IPAddress) + if !ok || bmxURL == "" { + // Couldn't read the runtime config (speaker offline, or neither SSH + // nor telnet available). speaker_info_reachable / sources_xml_diff + // cover the reachability angle; nothing to assert here. + continue + } + + findings = append(findings, assessRuntimeBmxURL(dev.AccountID, dev.DeviceID, dev.IPAddress, bmxURL)...) + } + + return findings +} + +// assessRuntimeBmxURL is the pure, per-device core: given a speaker's runtime +// bmxRegistryUrl, it returns a warning finding when the URL still names the Bose +// cloud. Split out from the datastore iteration so it can be unit-tested +// directly (mirrors assessMargeURLForDeviceWithURL). +func assessRuntimeBmxURL(account, deviceID, ipAddress, bmxURL string) []Finding { + host := hostFromURL(bmxURL) + if host == "" || !isBoseCloudHost(host) { + return nil + } + + return []Finding{{ + Severity: SeverityWarning, + Target: Target{Account: account, Device: deviceID}, + Message: fmt.Sprintf("Speaker's runtime BMX registry URL still points at the Bose cloud (%s).", host), + Details: "Radio source types (TUNEIN, RADIO_BROWSER, LOCAL_INTERNET_RADIO) are fetched from the BMX registry, so while this URL names the shut-down Bose cloud the speaker can never mount them, even though the service's own /sources listing looks correct. Re-migrate this speaker so its runtime bmxRegistryUrl points at AfterTouch, then reboot it. (Exception: if you migrate via DNS, meaning AfterTouch acts as the speaker's DNS server or your router points the LAN's DNS at AfterTouch, a cloud URL is expected and this warning can be ignored.)", + ManualCommands: []ManualCommand{{ + Label: "Re-migrate this speaker (the telnet method rewrites all runtime URLs):", + Command: fmt.Sprintf("soundtouch-cli --host %s setup migrate --method telnet --service-url http://:8000", ipAddress), + Hint: "Replace with a LAN-resolvable name or IP of this service. Reboot the speaker afterwards so it reloads the new config.", + }}, + }} +} + +// isBoseCloudHost reports whether host belongs to one of Bose's (shut-down) +// cloud domains: content.api.bose.io, streaming.bose.com, events.api.bosecm.com, +// worldwide.bose.com, and the like. A domain-suffix match keeps it robust +// against the various sub-domains seen across firmware versions. +func isBoseCloudHost(host string) bool { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + return false + } + + for _, domain := range []string{"bose.io", "bose.com", "bosecm.com"} { + if host == domain || strings.HasSuffix(host, "."+domain) { + return true + } + } + + return false +} diff --git a/pkg/service/health/checks_runtime_bmx_url_test.go b/pkg/service/health/checks_runtime_bmx_url_test.go new file mode 100644 index 0000000..c7f398e --- /dev/null +++ b/pkg/service/health/checks_runtime_bmx_url_test.go @@ -0,0 +1,64 @@ +package health + +import "testing" + +func TestRuntimeBmxURL_WarnsWhenStillOnBoseCloud(t *testing.T) { + got := assessRuntimeBmxURL("1000001", "DEVICEID01", "192.0.2.10", + "https://content.api.bose.io/bmx/registry/v1/services") + if len(got) != 1 { + t.Fatalf("expected 1 finding for a Bose-cloud bmx URL, got %d: %+v", len(got), got) + } + + f := got[0] + if f.Severity != SeverityWarning { + t.Errorf("expected SeverityWarning, got %q", f.Severity) + } + if f.Target.Account != "1000001" || f.Target.Device != "DEVICEID01" { + t.Errorf("unexpected target: %+v", f.Target) + } + if len(f.ManualCommands) != 1 { + t.Fatalf("expected a re-migrate manual command, got %+v", f.ManualCommands) + } +} + +func TestRuntimeBmxURL_NoFindingWhenPointingAtAfterTouch(t *testing.T) { + got := assessRuntimeBmxURL("1000001", "DEVICEID01", "192.0.2.10", + "http://192.0.2.10:8000/bmx/registry/v1/services") + if len(got) != 0 { + t.Errorf("expected no findings for an AfterTouch bmx URL, got %+v", got) + } +} + +func TestRuntimeBmxURL_NoFindingWhenEmpty(t *testing.T) { + if got := assessRuntimeBmxURL("1000001", "DEVICEID01", "192.0.2.10", ""); len(got) != 0 { + t.Errorf("expected no findings for an empty bmx URL, got %+v", got) + } +} + +func TestIsBoseCloudHost(t *testing.T) { + cloud := []string{ + "content.api.bose.io", + "streaming.bose.com", + "events.api.bosecm.com", + "worldwide.bose.com", + "bose.com", + } + for _, h := range cloud { + if !isBoseCloudHost(h) { + t.Errorf("expected %q to be a Bose cloud host", h) + } + } + + local := []string{ + "aftertouch.local", + "192.0.2.10", + "", + "notbose.example.com", + "bose.io.evil.example.com", + } + for _, h := range local { + if isBoseCloudHost(h) { + t.Errorf("expected %q NOT to be a Bose cloud host", h) + } + } +}