From 83c0e999bc48e2055fd1658b7b4204a2f06e9ffb Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 31 May 2026 12:41:47 +0200 Subject: [PATCH] feat(service): capture speaker redirect config in diagnostic export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The diagnostic export captured the symptom of #345 (a TuneIn select escaping to the dead Bose Apigee gateway → BMX_HTTP_ERROR 4501 → INVALID_SOURCE) but none of the data that decides where a speaker sends its marge/BMX/streaming traffic, so we couldn't tell whether the request was ever redirected to AfterTouch. Collect that per speaker: - New collectSpeakerRedirectConfig prefers the on-device SoundTouchSdkPrivateCfg.xml over SSH (archives raw + parses marge/stats/swUpdate/bmxRegistry URLs), and falls back to `getpdo CurrentSystemConfiguration` over telnet when SSH is unavailable — the same channel the telnet migration uses. Parsed URLs and provenance land in diagnostic.json as redirect_config: source (ssh|telnet|none), ssh_reachable, and inferred_migration_method (telnet when only telnet answered, since xml/hosts/resolv all need SSH). - Pull redirection-relevant files over SSH: /etc/hosts(.original), /etc/resolv.conf, the resolv-method hook, /mnt/nv/remote_services, and the pre-migration .original backups (CA bundle and the URL config). - Dump the speaker firewall (iptables-save; ip6tables-save is empty on FW 27.0.6 but harmless) to catch self-inflicted DROP rules (cf. #354). Export ParseGetpdoConfig from pkg/service/setup and add a test pinning the field-name contract the export depends on. Diagnostic-collection only; does not change migration or playback. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/service/handlers/handlers_export.go | 181 +++++++++++++++++- pkg/service/setup/preflight_crosscheck.go | 9 + .../setup/preflight_crosscheck_test.go | 35 ++++ 3 files changed, 222 insertions(+), 3 deletions(-) diff --git a/pkg/service/handlers/handlers_export.go b/pkg/service/handlers/handlers_export.go index d293938..fe2ba3c 100644 --- a/pkg/service/handlers/handlers_export.go +++ b/pkg/service/handlers/handlers_export.go @@ -6,9 +6,11 @@ import ( "compress/gzip" "crypto/tls" "encoding/json" + "encoding/xml" "fmt" "io" "log" + "net" "net/http" "os" "path/filepath" @@ -19,7 +21,9 @@ import ( "github.com/gesellix/bose-soundtouch/pkg/models" "github.com/gesellix/bose-soundtouch/pkg/service/export" "github.com/gesellix/bose-soundtouch/pkg/service/health" + "github.com/gesellix/bose-soundtouch/pkg/service/setup" speakerssh "github.com/gesellix/bose-soundtouch/pkg/ssh" + "github.com/gesellix/bose-soundtouch/pkg/telnet" ) // diagnosticReport is the structured summary included as diagnostic.json @@ -42,6 +46,23 @@ type deviceDiagnostic struct { IPAddress string `json:"ip_address,omitempty"` Sources []sourceDiagnostic `json:"sources,omitempty"` Presets []presetDiagnostic `json:"presets,omitempty"` + RedirectConfig *redirectConfig `json:"redirect_config,omitempty"` +} + +// redirectConfig captures where a speaker is configured to send its +// marge/BMX/streaming traffic, and how AfterTouch obtained that config. The +// URLs come from the on-device SoundTouchSdkPrivateCfg.xml (via SSH) or, when +// SSH is unavailable, from `getpdo CurrentSystemConfiguration` over telnet. +// A speaker reachable only via telnet was, by elimination, migrated with the +// telnet method — the xml/hosts/resolv methods all require SSH. +type redirectConfig struct { + Source string `json:"source"` // "ssh" | "telnet" | "none" + SSHReachable bool `json:"ssh_reachable"` + InferredMigrationMethod string `json:"inferred_migration_method,omitempty"` + MargeServerURL string `json:"marge_server_url,omitempty"` + StatsServerURL string `json:"stats_server_url,omitempty"` + SwUpdateURL string `json:"sw_update_url,omitempty"` + BmxRegistryURL string `json:"bmx_registry_url,omitempty"` } type sourceDiagnostic struct { @@ -108,7 +129,12 @@ func (s *Server) buildDiagnosticArchive() ([]byte, error) { gz := gzip.NewWriter(&buf) tw := tar.NewWriter(gz) - report := s.buildDiagnosticReport() + // Collect speaker redirect config first (SSH-preferred, telnet fallback); + // it writes raw artifacts into the archive and feeds parsed URLs + + // provenance into the JSON report below. + redirectCfgs := s.collectSpeakerRedirectConfig(tw) + + report := s.buildDiagnosticReport(redirectCfgs) jsonBytes, err := json.MarshalIndent(report, "", " ") if err != nil { @@ -279,9 +305,33 @@ func (s *Server) addSpeakerHTTP(tw *tar.Writer, client *http.Client, devices []m } // speakerSSHPaths lists file paths to retrieve from each speaker via SSH. +// Each is best-effort: paths that don't exist on a given firmware/migration +// method are logged and skipped. var speakerSSHPaths = []string{ "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/pki/tls/certs/ca-bundle.crt.original", // pre-migration CA bundle backup "/etc/ssl/certs/ca-certificates.crt", + // Redirection-relevant state: where the speaker resolves Bose hostnames + // and what it had before migration. (The live marge/BMX URL config in + // SoundTouchSdkPrivateCfg.xml is handled by collectSpeakerRedirectConfig, + // which also parses it and has a telnet fallback; its .original backup is + // a plain raw pull here, showing the pre-migration Bose URLs.) + "/opt/Bose/etc/SoundTouchSdkPrivateCfg.xml.original", // pre-migration URL config backup + "/etc/hosts", // deprecated hosts-migration redirect or manual edits + "/etc/hosts.original", // factory backup, if the hosts method was ever used + "/etc/resolv.conf", // which DNS the speaker actually queries + "/etc/resolv.conf.original", // pre-migration resolv backup (resolv method) + "/mnt/nv/soundtouch-service/aftertouch.resolv.conf", // resolv-method nameserver hook + "/mnt/nv/aftertouch.resolv.conf", // legacy resolv-hook path + // resolv method's live redirection mechanism: a boot hook plus DHCP-renew + // hooks that prepend our nameserver. Whether these are intact decides if + // the speaker resolves Bose hosts to AfterTouch or escapes to the real + // cloud — written under a root remount, so persistent on the rootfs. + "/mnt/nv/rc.local", // boot hook installing the resolv override + "/etc/udhcpc.d/50default", // DHCP-renew hook (primary) + "/opt/Bose/udhcpc.script", // DHCP-renew hook (alternate, if present) + "/etc/remote_services", // SSH-enablement marker (preferred location) + "/mnt/nv/remote_services", // SSH-enablement marker (NV fallback) } // speakerLogWindow is the default look-back period for speaker syslog entries. @@ -384,7 +434,14 @@ func addSpeakerSSH(tw *tar.Writer, devices []models.ServiceDeviceInfo) { } // dmesg and any other plain commands. - for filename, cmd := range map[string]string{"dmesg.txt": "dmesg"} { + // firewall.txt: iptables-save yields the active filter on FW 27.0.6; + // ip6tables-save typically yields nothing (no error) but is harmless + // and future-proofs IPv6-capable builds. Catches a self-inflicted + // DROP of speaker↔service or speaker↔Bose-host traffic. + for filename, cmd := range map[string]string{ + "dmesg.txt": "dmesg", + "firewall.txt": "iptables-save 2>/dev/null; ip6tables-save 2>/dev/null", + } { out, err := sc.Run(cmd) if err != nil && strings.TrimSpace(out) == "" { log.Printf("[Export] SSH %s %q: %v", sanitizeLog(id), cmd, err) @@ -413,6 +470,122 @@ func addSpeakerSSH(tw *tar.Writer, devices []models.ServiceDeviceInfo) { } } +// sshReachable reports whether the speaker accepts a TCP connection on the SSH +// port. Used only to classify provenance: a speaker that answers telnet but +// not SSH was, by elimination, migrated via the telnet method. +func sshReachable(ip string) bool { + conn, err := net.DialTimeout("tcp", net.JoinHostPort(ip, "22"), time.Second) + if err != nil { + return false + } + + _ = conn.Close() + + return true +} + +// readTelnetSystemConfig reads the live URL set from the speaker's diagnostic +// shell (port 17000) via `getpdo CurrentSystemConfiguration`. Returns the raw +// reply and true on success. +func readTelnetSystemConfig(ip string) (string, bool) { + t := telnet.NewClient(ip) + if err := t.Dial(); err != nil { + return "", false + } + + defer func() { _ = t.Close() }() + + _, _ = t.Probe() + + resp, err := t.SendCommand("getpdo CurrentSystemConfiguration") + if err != nil || strings.TrimSpace(resp) == "" { + return "", false + } + + return resp, true +} + +// collectSpeakerRedirectConfig determines, per speaker, where it is configured +// to send marge/BMX/streaming traffic — the data that decides whether a +// TuneIn/RadioBrowser select reaches AfterTouch or escapes to the dead Bose +// cloud. It prefers the persisted SoundTouchSdkPrivateCfg.xml over SSH; when +// SSH is unavailable it falls back to `getpdo CurrentSystemConfiguration` over +// telnet (the same channel the telnet migration uses). Raw artifacts are +// written into the archive; parsed URLs + provenance are returned keyed by +// device ID for inclusion in diagnostic.json. +func (s *Server) collectSpeakerRedirectConfig(tw *tar.Writer) map[string]*redirectConfig { + out := map[string]*redirectConfig{} + + devices, err := s.ds.ListAllDevices() + if err != nil { + log.Printf("[Export] redirect config: list devices: %v", err) + + return out + } + + for i := range devices { + dev := &devices[i] + if dev.IPAddress == "" { + continue + } + + id := dev.DeviceID + if id == "" { + id = dev.IPAddress + } + + rc := &redirectConfig{Source: "none", SSHReachable: sshReachable(dev.IPAddress)} + + // 1. Prefer the persisted XML over SSH. + sc := speakerssh.NewClient(dev.IPAddress) + if data, rerr := sc.ReadFile(setup.SoundTouchSdkPrivateCfgPath); rerr == nil { + archivePath := "ssh/speaker/" + id + setup.SoundTouchSdkPrivateCfgPath + if aerr := addTarBytes(tw, archivePath, data); aerr != nil { + log.Printf("[Export] add %s: %v", sanitizeLog(archivePath), aerr) + } + + var cfg setup.PrivateCfg + if xerr := xml.Unmarshal(data, &cfg); xerr == nil { + rc.Source = "ssh" + rc.MargeServerURL = cfg.MargeServerUrl + rc.StatsServerURL = cfg.StatsServerUrl + rc.SwUpdateURL = cfg.SwUpdateUrl + rc.BmxRegistryURL = cfg.BmxRegistryUrl + } else { + log.Printf("[Export] parse %s for %s: %v", setup.SoundTouchSdkPrivateCfgPath, sanitizeLog(id), xerr) + } + } + + // 2. Fall back to telnet getpdo when SSH gave us nothing. + if rc.Source == "none" { + if raw, ok := readTelnetSystemConfig(dev.IPAddress); ok { + archivePath := "telnet/speaker/" + id + "/system-configuration.txt" + if aerr := addTarBytes(tw, archivePath, []byte(raw)); aerr != nil { + log.Printf("[Export] add %s: %v", sanitizeLog(archivePath), aerr) + } + + if fields := setup.ParseGetpdoConfig(raw); len(fields) > 0 { + rc.Source = "telnet" + rc.MargeServerURL = fields["margeServerUrl"] + rc.StatsServerURL = fields["statsServerUrl"] + rc.SwUpdateURL = fields["swUpdateUrl"] + rc.BmxRegistryURL = fields["bmxRegistryUrl"] + + // SSH-free reachability implies the telnet migration method + // (xml/hosts/resolv all require SSH). + if !rc.SSHReachable { + rc.InferredMigrationMethod = "telnet" + } + } + } + } + + out[dev.DeviceID] = rc + } + + return out +} + // 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) { @@ -593,7 +766,7 @@ func addTarBytes(tw *tar.Writer, name string, data []byte) error { return err } -func (s *Server) buildDiagnosticReport() diagnosticReport { +func (s *Server) buildDiagnosticReport(redirectCfgs map[string]*redirectConfig) diagnosticReport { report := diagnosticReport{ GeneratedAt: time.Now().UTC().Format(time.RFC3339), ServiceVersion: buildVersionInfo(), @@ -647,6 +820,8 @@ func (s *Server) buildDiagnosticReport() diagnosticReport { } } + dd.RedirectConfig = redirectCfgs[dev.DeviceID] + report.Devices = append(report.Devices, dd) } diff --git a/pkg/service/setup/preflight_crosscheck.go b/pkg/service/setup/preflight_crosscheck.go index adbbbb2..c190762 100644 --- a/pkg/service/setup/preflight_crosscheck.go +++ b/pkg/service/setup/preflight_crosscheck.go @@ -5,6 +5,15 @@ import ( "strings" ) +// ParseGetpdoConfig is an exported wrapper around the internal `getpdo +// CurrentSystemConfiguration` reply parser, for callers outside this package +// (e.g. the diagnostic export, which reads the live URL set over telnet when +// SSH is unavailable). Returns a map keyed by config field name +// (margeServerUrl, statsServerUrl, swUpdateUrl, bmxRegistryUrl, …). +func ParseGetpdoConfig(text string) map[string]string { + return parseGetpdoConfig(text) +} + // 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 diff --git a/pkg/service/setup/preflight_crosscheck_test.go b/pkg/service/setup/preflight_crosscheck_test.go index 0bb877a..cb42e45 100644 --- a/pkg/service/setup/preflight_crosscheck_test.go +++ b/pkg/service/setup/preflight_crosscheck_test.go @@ -19,6 +19,41 @@ func TestParseGetpdoConfig_StandardLines(t *testing.T) { } } +// TestParseGetpdoConfig_ExportedWrapper locks in the field-name contract the +// diagnostic export (handlers.collectSpeakerRedirectConfig) relies on: the +// exported wrapper must surface margeServerUrl / statsServerUrl / swUpdateUrl / +// bmxRegistryUrl from a protobuf-text reply. If the parser's output keys ever +// change, the export's telnet fallback would silently stop populating URLs — +// this test fails loudly instead. +func TestParseGetpdoConfig_ExportedWrapper(t *testing.T) { + in := `margeServerUrl { + text: "http://soundtouch.lan:8000" +} +statsServerUrl { + text: "http://soundtouch.lan:8000" +} +swUpdateUrl { + text: "http://soundtouch.lan:8000/updates" +} +bmxRegistryUrl { + text: "http://soundtouch.lan:8000/bmx/registry/v1/services" +} +->OK +` + + got := ParseGetpdoConfig(in) + + for _, key := range []string{"margeServerUrl", "statsServerUrl", "swUpdateUrl", "bmxRegistryUrl"} { + if got[key] == "" { + t.Errorf("ParseGetpdoConfig missing %q; export relies on this key. got=%v", key, got) + } + } + + if got["bmxRegistryUrl"] != "http://soundtouch.lan: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-> "