diff --git a/pkg/service/setup/preflight_crosscheck.go b/pkg/service/setup/preflight_crosscheck.go new file mode 100644 index 0000000..e01d8ac --- /dev/null +++ b/pkg/service/setup/preflight_crosscheck.go @@ -0,0 +1,82 @@ +package setup + +import ( + "fmt" + "strings" +) + +// crossCheckPreflights compares the URL fields visible via SSH (from the +// parsed SoundTouchSdkPrivateCfg.xml) with the same fields visible via +// telnet (from `getpdo CurrentSystemConfiguration`). Any field that is +// reported by both transports but with different values is recorded as +// a non-fatal warning. +// +// In practice the two sources can diverge briefly: `sys configuration …` +// writes the runtime fields, while `envswitch boseurls set …` writes a +// parallel persistence layer that wins on next boot — and the XML file +// is only re-rendered after a reboot. A warning here is therefore not an +// error per se; it usually means "reboot the device to make the two +// layers agree." +func (m *Manager) crossCheckPreflights(summary *MigrationSummary) { + if summary.ParsedCurrentConfig == nil || summary.TelnetVerifiedConfig == "" { + return + } + + telnet := parseGetpdoConfig(summary.TelnetVerifiedConfig) + xml := summary.ParsedCurrentConfig + + pairs := []struct { + name string + xmlValue string + }{ + {"margeServerUrl", xml.MargeServerUrl}, + {"statsServerUrl", xml.StatsServerUrl}, + {"swUpdateUrl", xml.SwUpdateUrl}, + {"bmxRegistryUrl", xml.BmxRegistryUrl}, + } + + for _, p := range pairs { + telnetValue, hasTelnet := telnet[p.name] + if !hasTelnet || p.xmlValue == "" { + continue + } + + if telnetValue == p.xmlValue { + continue + } + + summary.Warnings = append(summary.Warnings, fmt.Sprintf( + "%s differs between transports: SSH-XML=%q telnet-getpdo=%q (a reboot usually re-syncs the runtime layer with the persisted XML)", + p.name, p.xmlValue, telnetValue, + )) + } +} + +// parseGetpdoConfig extracts key=value pairs from `getpdo CurrentSystemConfiguration` +// output. The format observed in the wild is one pair per line; any line +// that does not match key=value is silently skipped, so the parser is +// tolerant to banner text or trailing prompt characters. +func parseGetpdoConfig(text string) map[string]string { + out := map[string]string{} + + for _, raw := range strings.Split(text, "\n") { + line := strings.TrimSpace(raw) + if line == "" { + continue + } + + i := strings.IndexByte(line, '=') + if i <= 0 { + continue + } + + key := strings.TrimSpace(line[:i]) + val := strings.TrimSpace(line[i+1:]) + + if key != "" { + out[key] = val + } + } + + return out +} diff --git a/pkg/service/setup/preflight_crosscheck_test.go b/pkg/service/setup/preflight_crosscheck_test.go new file mode 100644 index 0000000..a67b2eb --- /dev/null +++ b/pkg/service/setup/preflight_crosscheck_test.go @@ -0,0 +1,134 @@ +package setup + +import ( + "strings" + "testing" +) + +func TestParseGetpdoConfig_StandardLines(t *testing.T) { + in := "margeServerUrl=http://example:8000\nbmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n" + + got := parseGetpdoConfig(in) + + if got["margeServerUrl"] != "http://example:8000" { + t.Errorf("margeServerUrl = %q, want http://example:8000", got["margeServerUrl"]) + } + + if got["bmxRegistryUrl"] != "http://example:8000/bmx/registry/v1/services" { + t.Errorf("bmxRegistryUrl = %q", got["bmxRegistryUrl"]) + } +} + +func TestParseGetpdoConfig_TolerantToNoise(t *testing.T) { + in := "BoseShell\n-> getpdo CurrentSystemConfiguration\nmargeServerUrl=http://example:8000\nrandom line without equals\n statsServerUrl = http://example:8000 \n-> " + + got := parseGetpdoConfig(in) + + if got["margeServerUrl"] != "http://example:8000" { + t.Errorf("margeServerUrl = %q, want http://example:8000", got["margeServerUrl"]) + } + + if got["statsServerUrl"] != "http://example:8000" { + t.Errorf("statsServerUrl = %q, want trimmed http://example:8000", got["statsServerUrl"]) + } + + if _, exists := got["random line without equals"]; exists { + t.Errorf("non-key=value line should not be parsed") + } +} + +func TestCrossCheckPreflights_AgreementProducesNoWarnings(t *testing.T) { + m := &Manager{ServerURL: "http://example:8000"} + + summary := &MigrationSummary{ + ParsedCurrentConfig: &PrivateCfg{ + MargeServerUrl: "http://example:8000", + StatsServerUrl: "http://example:8000", + SwUpdateUrl: "http://example:8000/updates/soundtouch", + BmxRegistryUrl: "http://example:8000/bmx/registry/v1/services", + }, + TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n" + + "statsServerUrl=http://example:8000\n" + + "swUpdateUrl=http://example:8000/updates/soundtouch\n" + + "bmxRegistryUrl=http://example:8000/bmx/registry/v1/services\n", + } + + m.crossCheckPreflights(summary) + + if len(summary.Warnings) != 0 { + t.Errorf("Warnings = %v, want none when both transports agree", summary.Warnings) + } +} + +func TestCrossCheckPreflights_MismatchProducesWarning(t *testing.T) { + m := &Manager{ServerURL: "http://example:8000"} + + // SSH-XML still shows the original cloud URL (envswitch wrote the + // runtime layer but the on-device file hasn't been re-rendered). + summary := &MigrationSummary{ + ParsedCurrentConfig: &PrivateCfg{ + MargeServerUrl: "https://streaming.bose.com", + }, + TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n", + } + + m.crossCheckPreflights(summary) + + if len(summary.Warnings) != 1 { + t.Fatalf("Warnings = %v, want exactly one warning", summary.Warnings) + } + + w := summary.Warnings[0] + + if !strings.Contains(w, "margeServerUrl") { + t.Errorf("warning %q should name the field", w) + } + + if !strings.Contains(w, "streaming.bose.com") || !strings.Contains(w, "example:8000") { + t.Errorf("warning %q should quote both values", w) + } +} + +func TestCrossCheckPreflights_NoWarningWhenTelnetMissesField(t *testing.T) { + m := &Manager{ServerURL: "http://example:8000"} + + summary := &MigrationSummary{ + ParsedCurrentConfig: &PrivateCfg{ + MargeServerUrl: "http://example:8000", + StatsServerUrl: "http://example:8000", + }, + // getpdo only echoes margeServerUrl — statsServerUrl is silently + // absent on this firmware. Absence is not a disagreement. + TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n", + } + + m.crossCheckPreflights(summary) + + if len(summary.Warnings) != 0 { + t.Errorf("Warnings = %v, want none when a field is missing from one transport", summary.Warnings) + } +} + +func TestCrossCheckPreflights_OnlyOneTransportPresent(t *testing.T) { + m := &Manager{ServerURL: "http://example:8000"} + + t.Run("telnet only", func(t *testing.T) { + summary := &MigrationSummary{ + TelnetVerifiedConfig: "margeServerUrl=http://example:8000\n", + } + m.crossCheckPreflights(summary) + if len(summary.Warnings) != 0 { + t.Errorf("Warnings = %v, want none when SSH didn't read the XML", summary.Warnings) + } + }) + + t.Run("ssh only", func(t *testing.T) { + summary := &MigrationSummary{ + ParsedCurrentConfig: &PrivateCfg{MargeServerUrl: "http://example:8000"}, + } + m.crossCheckPreflights(summary) + if len(summary.Warnings) != 0 { + t.Errorf("Warnings = %v, want none when telnet didn't respond", summary.Warnings) + } + }) +} diff --git a/pkg/service/setup/setup.go b/pkg/service/setup/setup.go index 05744e1..80ffbda 100644 --- a/pkg/service/setup/setup.go +++ b/pkg/service/setup/setup.go @@ -93,6 +93,12 @@ type MigrationSummary struct { // KnownAccountIDs are accountIDs already present in the local datastore; // the UI offers them as choices when pairing a fresh device. KnownAccountIDs []string `json:"known_account_ids,omitempty"` + + // Warnings holds non-fatal advisories emitted during summary + // construction — currently the cross-check between SSH-XML and + // telnet-getpdo readings of the device's URL configuration. The UI + // should display them as informational hints, not errors. + Warnings []string `json:"warnings,omitempty"` } // SSHClient defines the interface for SSH operations. @@ -343,6 +349,10 @@ func (m *Manager) GetMigrationSummary(deviceIP, targetURL, proxyURL string, opti summary.TelnetVerifiedConfig = telnetResult.TelnetVerifiedConfig summary.TelnetProbeError = telnetResult.TelnetProbeError + // 9. Cross-check SSH-XML and telnet-getpdo readings; surface any + // divergence as a non-fatal warning. + m.crossCheckPreflights(summary) + return summary, nil }