Files
Tobias GesellchenandClaude Opus 4.8 83c0e999bc feat(service): capture speaker redirect config in diagnostic export
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) <noreply@anthropic.com>
2026-05-31 12:56:45 +02:00

153 lines
4.3 KiB
Go

package setup
import (
"fmt"
"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
// 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 field values from a `getpdo
// CurrentSystemConfiguration` reply. Two formats are accepted:
//
// 1. Protobuf-text-like nested blocks (the format observed on FW
// 27.0.6 ST 10/20/300 in the wild):
//
// margeServerUrl {
// text: "https://streaming.bose.com"
// }
//
// 2. Flat key=value lines (kept as a tolerance path for firmware
// variants that report differently or for hand-crafted test
// fixtures).
//
// Any line that doesn't match either shape is silently ignored, so the
// parser tolerates banner text, prompt characters (`->`, `->OK`),
// blank lines, and unrelated fields.
func parseGetpdoConfig(text string) map[string]string {
out := map[string]string{}
var currentKey string
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
// Block open: "<key> {".
if strings.HasSuffix(line, "{") {
head := strings.TrimSpace(strings.TrimSuffix(line, "{"))
if head != "" && isIdentifier(head) {
currentKey = head
}
continue
}
// Block close.
if line == "}" {
currentKey = ""
continue
}
// "text: ..." inside a block is the field value.
if currentKey != "" && strings.HasPrefix(line, "text:") {
val := strings.TrimSpace(strings.TrimPrefix(line, "text:"))
val = strings.Trim(val, `"`)
out[currentKey] = val
continue
}
// Flat key=value, only if the key is a bare identifier (so we
// don't misread protobuf "text: value" as a key=value pair via
// some other separator).
if i := strings.IndexByte(line, '='); i > 0 {
key := strings.TrimSpace(line[:i])
if key != "" && isIdentifier(key) {
out[key] = strings.TrimSpace(line[i+1:])
}
}
}
return out
}
// isIdentifier reports whether s looks like a configuration field name —
// alphanumeric or underscore only. Used to keep parseGetpdoConfig from
// promoting random "x: y" or "x = y" lines (with spaces, punctuation,
// arrows) into the result map.
func isIdentifier(s string) bool {
if s == "" {
return false
}
for _, r := range s {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '_':
default:
return false
}
}
return true
}